Skip to content

feat(kanban): honor kanban.default_max_runtime_seconds as a NULL-cap backstop - #156

Merged
exiao merged 4 commits into
live-configfrom
feat/kanban-default-max-runtime-v2
Jul 27, 2026
Merged

feat(kanban): honor kanban.default_max_runtime_seconds as a NULL-cap backstop#156
exiao merged 4 commits into
live-configfrom
feat/kanban-default-max-runtime-v2

Conversation

@exiao

@exiao exiao commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Default runtime cap for kanban tasks

Supersedes #153, which I opened and then closed the same day after self-review ("do not merge as-is"). Eric asked to reopen it; #153's branch was deleted on close, so this is the same work on a fresh branch with all four of its findings fixed.

Problem

enforce_max_runtime() is gated:

WHERE t.status = 'running' AND t.max_runtime_seconds IS NOT NULL

A task created without an explicit --max-runtime stores NULL, so that reaper skips it entirely. Measured on a live board: 91% of one lane's runs had no cap (879 of 970 over 30 days).

The uncapped cards are the hand-created ones (created_by=user). The PR-babysit detector already passes --max-runtime on everything it creates (35m / 40m / 9000s), so this gap is specifically about cards made inline.

Fix

_default_max_runtime_seconds() reads the config key; create_task() applies it only when the caller passed nothing.

Precedence: explicit value > config default > uncapped.

No change to the enforcement path, no schema change, no new env var (per AGENTS.md, behavioral settings belong in config.yaml). Ships defaulting to 0 = off.

What changed vs #153

# #153 finding Fix here
1 Justified by a false "214.7h / ~$82 Modal burn" claim, baked into the function and test docstrings Removed. That row was a leaked runlast_heartbeat_at stops 6.4 min in, closed by manual cleanup 2026-07-22, ~4 cents of real compute. Docstrings now cite the defensible 91%-uncapped figure
2 load_config() on a pure-read path that runs on every task creation (it deepcopies, for mutating callers) load_config_readonly(), matching the fd_headroom precedent at kanban_db.py:2230. New test pins that the resolver never mutates the shared cache
3 Key was unregistered — hermes config set warned "not a recognized config key" Registered in config.py beside dispatch_stale_timeout_seconds, default 0
4 "Wrong mechanism: dispatch_stale_timeout_seconds already does this, better" Partly conceded, and now documented in code instead of argued away — see below

On the wrong-mechanism objection (#153 item 4)

It was a fair hit and I'm not pretending otherwise. dispatch_stale_timeout_seconds kills on lack of progress (stale heartbeat); this kills on wall clock.

this key dispatch_stale_timeout_seconds
kills on wall-clock, unconditionally stale heartbeat
long healthy run killed survives
hung worker killed at the cap killed
code change yes none

The stale sweep is the better primary reaper. What it does not cover is the NULL-cap blind spot in enforce_max_runtime — that's the narrow gap this closes, and both the docstring and the config comment now say exactly that, including the warning that a cap below your slowest legitimate run truncates real work. Concrete example left in the comment: equity-analyst has a legitimate 354-minute completed run, so a 90-minute default would kill it. That's why this ships off (0) rather than with an opinionated default.

Still open, not addressed here: the stale sweep has fired exactly once (2026-06-29) despite obvious later candidates. Worth chasing separately — two half-working reapers is worse than one working one.

Verification

Live, not just unit:

  • tests/cli/test_kanban_default_max_runtime.py — 14 passed. Full tests/cli -k kanban — 36 passed.
  • Real DB in a temp HERMES_HOME, key set to 5400: uncapped create → 5400; explicit --max-runtime 600600.
  • Same, key absent: resolver → None, stored cap → None (old behaviour intact).
  • ruff check clean on all three files.

Decisions

  • Default is 0 (off), not 5400. A wall-clock cap that truncates a real 354-min run is worse than no cap. Operators opt in.
  • 0/negative = opt-out, not a 0-second insta-kill.
  • Malformed value or any config-load failure → None (uncapped), never raises into task creation.
  • Applied at create time, not dispatch time, so the stored value is visible in the DB and the reaper needs no change.
  • No per-lane defaults. They don't exist in the config schema today and adding them is a bigger change than this gap warrants.

Patch note: ~/.hermes/plans/hermes-patches/kanban-default-max-runtime.md

exiao added 2 commits July 25, 2026 16:40
…tasks

enforce_max_runtime() only reclaims rows where max_runtime_seconds IS NOT
NULL, and nothing supplied a default: a task created without an explicit
--max-runtime stored NULL and could run unbounded.

On a real board 91% of dev runs (879/970) were uncapped, and one run burned
214.7 hours of Modal sandbox time (~$82, 38% of the fleet's monthly compute)
before anything reclaimed it.

Add _default_max_runtime_seconds(), mirroring the existing _default_assignee()
helper, applied in create_task() only when the caller passed nothing.
Precedence: explicit value > config default > uncapped. No change to the
enforcement path, no new env var, no schema change.

0/negative is an explicit opt-out rather than a 0-second insta-kill, and any
config-load or parse failure resolves to None so the old uncapped behavior is
preserved rather than inventing a limit.

Verified the new tests FAIL against the unwired code, and end-to-end against a
live board: no flag -> 5400, explicit 600 -> 600.

Patch note: ~/.hermes/plans/hermes-patches/kanban-default-max-runtime.md
…backstop

enforce_max_runtime only reclaims rows where max_runtime_seconds IS NOT
NULL, so a task created without --max-runtime was invisible to it. 91% of
one lane's runs (879/970 over 30d) had no cap.

_default_max_runtime_seconds() reads the config key and create_task()
applies it only when the caller passed nothing. Precedence: explicit >
config default > uncapped.

Supersedes PR #153, which I closed after self-review. Fixes its four
findings:

- Drops the false '214.7h / $82 Modal burn' story from the function and
  test docstrings. That row was a leaked run (heartbeat stopped 6.4 min
  in, closed by manual cleanup 2026-07-22), ~4 cents of real compute.
- Uses load_config_readonly() instead of load_config(); this is a pure
  read on every task creation and load_config() deepcopies for mutating
  callers. Matches the fd_headroom precedent. New test pins that the
  resolver does not mutate the shared cache.
- Registers the key in config.py next to dispatch_stale_timeout_seconds,
  defaulting to 0 (no cap). It was previously unregistered, so
  'hermes config set' warned it may not be read.
- Documents in code that this is a BACKSTOP, not the primary reaper:
  dispatch_stale_timeout_seconds kills on stale heartbeat and spares long
  healthy runs, while a wall-clock cap cannot tell hung from slow. Set
  below your slowest legitimate run and it truncates real work.

Patch note: ~/.hermes/plans/hermes-patches/kanban-default-max-runtime.md

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f851be89ab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread hermes_cli/kanban_db.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 60d1157497

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/cli/test_kanban_default_max_runtime.py Outdated
@exiao
exiao merged commit 88d0007 into live-config Jul 27, 2026
35 checks passed
@exiao
exiao deleted the feat/kanban-default-max-runtime-v2 branch July 27, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant