Skip to content

feat(kanban): honor kanban.default_max_runtime_seconds when creating tasks - #153

Closed
exiao wants to merge 1 commit into
live-configfrom
feat/kanban-default-max-runtime
Closed

feat(kanban): honor kanban.default_max_runtime_seconds when creating tasks#153
exiao wants to merge 1 commit into
live-configfrom
feat/kanban-default-max-runtime

Conversation

@exiao

@exiao exiao commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Default runtime cap for kanban tasks

Branch: feat/kanban-default-max-runtime
Files: hermes_cli/kanban_db.py, tests/cli/test_kanban_default_max_runtime.py
Config: kanban.default_max_runtime_seconds: 5400 (90 min), set in ~/.hermes/config.yaml
Date: 2026-07-25

Problem

enforce_max_runtime() reclaims runaway workers, but its query is gated:

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

A task created without an explicit --max-runtime stored NULL, so the reaper
skipped it entirely. There was no config default anywhere: hermes_cli/kanban.py
passes _parse_duration(args.max_runtime), which returns None when the flag is
omitted, straight through to the INSERT.

On Eric's board, from 30 days of task_runs:

  • 91% of dev runs had no cap (879 of 970). Similar on other lanes.
  • 24 dev runs exceeded an hour and accounted for 245 of dev's 458 total hours.
  • One run, t_7c4131f0, ran 214.7 hours (Jul 8 onward) before being
    reclaimed: ~$82 of Modal sandbox time at $0.3799/hr for a 2-core/4GiB
    sandbox, i.e. 38% of the entire fleet's monthly compute bill from one card.

Fix

Add _default_max_runtime_seconds(), mirroring the existing
_default_assignee() helper, and apply it in create_task() only when the
caller passed nothing:

effective_max_runtime = (
    max_runtime_seconds
    if max_runtime_seconds is not None
    else _default_max_runtime_seconds()
)

Precedence: explicit value > config default > uncapped.

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

Why 90 minutes

Sized off actual completed-run data, not a guess:

lane avg max completed
dev 19.5 min 112.7 min
equity-analyst 14.9 min 354.5 min
code-reviewer 6.0 min 29.2 min
memo-evaluator 5.8 min 24.8 min

90 min sits above dev's realistic ceiling while killing anything genuinely
runaway. Per-card --max-runtime still overrides it.

Caveat: equity-analyst has a legitimate 354-min completed run, so 90 min
WILL truncate that lane's long tail. If those runs matter, that lane needs a
per-card override or a lane-level default (not currently supported).

Safety

  • 0 or negative means "no cap" (explicit opt-out), NOT a 0-second limit that
    would insta-kill every worker.
  • Any config-load failure resolves to None, preserving the old uncapped
    behavior rather than inventing a limit.
  • Malformed values ("ninety") fall back to None instead of raising into task
    creation.

Verification

  • 13 tests covering the precedence contract and the resolver's edge cases.
  • Reverted the wiring and re-rantest_config_default_applied_when_unset
    fails, proving the tests catch the real bug.
  • One test asserts enforce_max_runtime still filters on
    max_runtime_seconds IS NOT NULL, so if that assumption ever changes this
    fix's premise fails loudly instead of silently.
  • End-to-end against the LIVE board with the real config: a card created with
    no flag stored 5400; a card with max_runtime_seconds=600 stored 600.
    Both smoke cards archived.
  • 1240 passed across tests/cli + tests/tools/test_kanban_tools.py. One
    unrelated failure (test_resume_quiet_stderr) is test-pollution: it passes
    alone and in its own file, and this change touches no session/resume code.

Deploy note

The config value is already live in ~/.hermes/config.yaml, but the running
gateway executes ~/.hermes/hermes-agent on live-config, which does NOT yet
have this code. The cap does nothing until this merges and the gateway
restarts.
Until then, new cards still store NULL.

…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

@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: c51ee24e12

ℹ️ 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
from hermes_cli.config import load_config
cfg = load_config()
kanban_cfg = cfg.get("kanban", {}) if isinstance(cfg, dict) else {}
raw = kanban_cfg.get("default_max_runtime_seconds")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Register the runtime key in DEFAULT_CONFIG

When an operator uses the supported hermes config set kanban.default_max_runtime_seconds 5400 flow, _validate_config_key() derives recognized nested keys from DEFAULT_CONFIG; because this commit reads the key here without declaring it there, the command saves the value but warns that Hermes may not read it, while setup and missing-field discovery cannot surface the option. Add the key to the existing kanban defaults, using None if the setting should remain opt-in.

AGENTS.md reference: AGENTS.md:L416-L420

Useful? React with 👍 / 👎.

from hermes_cli import kanban_db as kb

src = inspect.getsource(kb.enforce_max_runtime)
assert "max_runtime_seconds IS NOT NULL" in src, (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Replace source inspection with a behavior test

This assertion does not reliably verify the timeout contract: an equivalent SQL refactor would fail it despite correct behavior, while leaving the literal in a comment or dead branch would let it pass after enforcement regressed. Exercise enforce_max_runtime() against real NULL-capped and capped task rows instead, so the test validates observable behavior rather than implementation text.

AGENTS.md reference: AGENTS.md:L67-L67

Useful? React with 👍 / 👎.

@exiao

exiao commented Jul 25, 2026

Copy link
Copy Markdown
Owner Author

Self-review

Verdict: do not merge as-is. Two defects, and a premise that has partly collapsed. I'm the author; this is me arguing against my own PR.

1. The headline number in the description is wrong

The description (and a docstring I committed into the code) claims a dev run burned 214.7 hours / ~$82 of Modal time. That is false, and it was the main justification for this change.

Checking last_heartbeat_at on t_7c4131f0: the last heartbeat was 6.4 minutes after start. The worker died there. The 214.7h figure is a leaked run row that stayed open until a manual cleanup on 2026-07-22, and that cleanup's own message says so: closed by manual cleanup: worker PID dead, task already terminal, run row leaked. Actual compute was roughly 4 cents.

I read a duration out of task_runs and treated it as wall-clock burn without checking whether the worker was alive for it. Fleet compute over 30d is ~$132/mo, not the $213 I reported.

This is now baked into the source, not just the PR body: the _default_max_runtime_seconds() docstring repeats the $82 claim. That alone needs a rewrite before merge — a wrong war story in a docstring outlives the PR and misleads the next reader.

2. A better mechanism already exists and ships today

kanban.dispatch_stale_timeout_seconds is already a registered config key (default 14400), already read by the gateway every tick in gateway/kanban_watchers.py:1323, and already drives detect_stale_running(), which reclaims a running task and terminates its worker when it exceeds the window and has no heartbeat for an hour.

That is a strictly better instrument than what this PR adds:

this PR (default_max_runtime_seconds) existing (dispatch_stale_timeout_seconds)
kills on wall-clock, unconditionally no-progress (stale heartbeat)
long healthy run killed survives
hung worker killed at the cap killed
code change yes none

The failure this PR was written for (t_7c4131f0: heartbeat stops at 6.4 min, task sits for days) is exactly the stale sweep's target. A hard wall-clock cap is the blunter tool.

Concretely harmful: a 90-minute default would kill equity-analyst's legitimate 354-minute completed run. This PR would break working behavior to solve a problem the existing key solves without breaking it.

3. Real code defect: wrong config accessor

from hermes_cli.config import load_config
cfg = load_config()

load_config() returns a deepcopy of the cached config, explicitly for callers that mutate. This function only reads. The codebase already has load_config_readonly() for exactly this, and kanban_db.py:2230 (the fd_headroom preflight) already uses it. I copied _default_assignee(), which has the same flaw, so I propagated an existing wart instead of following the better local precedent. This runs on every task creation.

Fix: load_config_readonly(), no mutation of the returned dict.

4. Real defect: the config key is unregistered

kanban.default_max_runtime_seconds is not in the hermes_cli/config.py defaults block (~line 2917) where its siblings live. hermes config set warned me at the time: "not a recognized config key — it was saved anyway, but Hermes may not read it." I set it and moved on. Any new key needs registering there or it's invisible to validation and discovery.

What I'd actually do

Preferred: close this, and instead tune dispatch_stale_timeout_seconds (currently 2400 on my board). Zero code, no risk to long healthy runs.

Caveat that keeps it honest: that sweep has fired exactly once, on 2026-06-29, despite obvious candidates since. Either it isn't running or something gates it. That's the bug worth chasing, and it's free. I'd rather find out why the existing mechanism is silent than layer a second mechanism on top of it — two half-working reapers is worse than one working one.

If kept anyway: rewrite the docstring to drop the false $82 story, switch to load_config_readonly(), register the key in config.py, and pick a default that doesn't truncate equity-analyst (or add per-lane defaults, which don't exist today).

What holds up

The tests are sound. Precedence (explicit > config > uncapped) is pinned, 0/negative correctly means opt-out rather than a 0-second insta-kill, and malformed values fall back to None instead of raising into task creation. I verified the tests fail against unwired code. One test asserts enforce_max_runtime still filters on max_runtime_seconds IS NOT NULL, so if that premise ever changes it fails loudly. If a maintainer wants the mechanism, the mechanism is correctly built. My objection is that it's the wrong mechanism, sold with a number I got wrong.

CI note

claude-review is red here too. Not a code finding: ANTHROPIC_API_KEY: is empty in the job log. All required checks pass is SUCCESS.

@exiao exiao closed this Jul 25, 2026
@exiao
exiao deleted the feat/kanban-default-max-runtime branch July 25, 2026 20:56
exiao added a commit that referenced this pull request Jul 27, 2026
…backstop (#156)

* feat(kanban): honor kanban.default_max_runtime_seconds when creating 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

* feat(kanban): honor kanban.default_max_runtime_seconds as a NULL-cap 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

* fix(kanban): cap decomposed child tasks

* test(kanban): verify runtime enforcement behavior
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