Skip to content

max_tokens_per_story is checked only post-done and only journals — warn while the story runs #336

Description

@pbean

limits.max_tokens_per_story (default 2M, policy.py:143) is read in exactly one place, and that place runs after the story is already done. A story that defers or escalates — the outcome in the umbrella field report — is never checked at all. The reporter burned 8.35M weighted tokens against a 2M cap and learned about it from nothing.

Evidence (validated at 3c68153)

  • Sole read site: engine.py:1768-1775, inside _finalize_commit_phase, after advance(task, Phase.DONE) (engine.py:1764). It appends one token-budget-exceeded journal line and does nothing else — no notify, no state.
  • Zero consumers of that journal kind anywhere. The TUI and status show totals only, never budget or headroom (cli.py:1567-1580, tui/widgets.py:165-177).
  • Worse than "late": the check lives only on the commit path. A story that defers or escalates gets no entry whatsoever. Total silence, not late silence.
  • No mid-session guardrail: a productive-but-looping session (265 turns, 49.7M tokens) is bounded only by the clock #158 does not cover this. The per-session guard (max_tokens_per_session, default 4M, adapters/generic.py:605-683) is a different cap, latched per session, and blind to cross-session story accumulation. Nothing budget-related has landed since 0.9.0.
  • core.toml:144-148 is missing label and description — the only limits field without them.

Fix outline

  • No new knob — the cap is advisory by contract (docs/FEATURES.md:145); warn at 100%.
  • Delete the post-done check (engine.py:1768-1775). Add _note_story_token_budget(task) called from _run_session right after self._save() (engine.py:2709), where task.tokens is both story-cumulative and fresh. Sweep and Stories engines inherit it for free.
  • Would have fired after session 1–2 of the reported run.
  • Reuse the token-budget-exceeded journal kind (zero src/ consumers) and add a budget field. gates.notify title story token budget exceeded: <key>, message carrying weighted / cap / raw plus "advisory: story continues".
  • Persisted latch StoryTask.token_budget_warned: bool = False — resume must not re-notify.
  • Weight comes from live policy limits.cache_read_weight (enforcement side of the doctrine comment at engine.py:615-623; display uses the persisted RunState.cache_read_weight() snapshot, model.py:399).
  • core.toml:144-148: add the missing label + description (presentation-only, no sync-test impact).
  • Docs: reword FEATURES.md:145 and README.md:593.
  • Zero changes to the No mid-session guardrail: a productive-but-looping session (265 turns, 49.7M tokens) is bounded only by the clock #158 per-session guard — SessionSpec, generic.py, opencode_http.py untouched.

Explicit non-goals (follow-ups)

Tests

Amend test_engine.py:1235-1264 (still exactly one journal entry, weighted = 160000, plus ATTENTION, the budget field, and the latch); keep :1065-1098 untouched as the no-false-positive case; new warns-on-a-deferring-story test (ablation: re-guard with phase == DONE → it must fail); warns-once test (ablate the latch); latch-survives-resume test (ablate to_dict); test_model.py round-trip. Settings sync tests must pass unchanged.

Edge cases already analysed: resume-replay sessions don't hit the new site (the persisted latch covers it); aborted sessions skip judgment; sweep bundles inherit the behaviour.

Part of the 0.9.0 field-report umbrella (linked parent).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:engineOrchestrator engine and run lifecyclebugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions