Skip to content

Say what --budget counts, and why a loop stalled - #221

Merged
scgopi merged 3 commits into
mainfrom
fix/217-budget-stall
Aug 30, 2026
Merged

scgopi merged 3 commits into
mainfrom
fix/217-budget-stall

Conversation

@scgopi

@scgopi scgopi commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Closes #217 (item 14).

What was wrong

Two halves, both confirmed against the source at 0.1.56–0.1.57-beta1:

  1. --budget hid what it counts. The help text said "(input + output)" (CLI node create, the app's create form, and GoalSpec's docs), but PresenceHooks.usageScript sums all four transcript fields — input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens. On Claude Code every turn re-meters the whole context as cache reads, so a 3M budget is a handful of turns, not hours. A bound sized from intuition burned in minutes with nothing in the help text explaining why.

  2. Budget exhaustion showed a bare Stalled with no why. enforceTokenBudget set state = .stalled — the same state markStalled uses for a blown stall deadline — and wrote the real reason ("budget exhausted: N of M tokens spent") only to the loop's memory log. graphcode status and the app's card both showed a bare STALLED, leaving a budget that just needs raising indistinguishable from a loop that ground to a halt.

The fix

  • Help text states the counting, everywhere it appears — CLI node create/node update help, the app create form's budget field, GoalSpec.tokenBudget's docs, and the budget sentence typed into the session's own opening prompt (a loop pacing itself must pace against what the orchestrator actually counts).
  • LoopNode.stallReason: String? — persisted (hand-written Codable decode falls back to nil for graphs saved before the field existed). GraphStore records the why at both stall sites: "budget exhausted: N of M tokens spent" and "stall bound exceeded without resolving".
  • Hygiene: every non-stall state write goes through setNodeState, which clears stallReason on any transition out of .stalled — a future stall path that forgets to write a fresh reason cannot inherit a stale one.
  • graphcode status renders it: ← Stalled: budget exhausted: 3000000 of 3000000 tokens spent.
  • The app card shows it on the live line in place of the goal the loop never finished — the one thing a human staring at a STALLED pill needs to decide whether to raise a number or kill a stuck loop.

Deliberate scope (follow-ups)

  • The sidebar's attention rows (AppSidebarMonitor) and ActivityFeed still show the bare STALLED word/icon. The card and graphcode status are where the decision gets made, so they carry the why; extending the rollup items (AttentionItem) with a detail line is a cheap follow-up if wanted.
  • The stop-suggestion affordance (daemon-typed text looking hand-typed) is item 14's neighbouring paragraph in the issue, not this fix.

Tests

  • TokenBudgetTests: exhaustion records the reason on the node; status renders the why instead of a bare Stalled; the session prompt states cache reads count; the create/update help lines stay in sync (two flag lines; update points at create; the counting statement stays present).
  • GoalBasedLoopTests: the stall-bound path records its own why.
  • LoopCardPresentationTests: a stalled card with a known why shows it; without one, keeps the handed line.

Full suite: 1297 tests passing. swiftlint clean (0 errors); swift format clean apart from a pre-existing DaemonBootstrap.swift violation untouched by this PR.

Issue #217 item 14. --budget's help claimed "(input + output)" while the
hook sums the cache-read and cache-creation fields too, so on Claude Code
a 3M budget burned in minutes of turns. And a budgeted loop that hit its
bound went to .stalled — the same state a blown stall deadline uses —
with the real reason written only to its memory log, so status and the
card showed a bare STALLED.

- Help text (CLI create/update, app create form, GoalSpec docs, and the
  budget sentence in the session's own opening prompt) states that every
  metered token counts, cache reads included, and that a Claude Code
  budget is a per-turn cost.
- LoopNode carries a persisted stallReason; GraphStore records it at
  both stall sites. graphcode status prints it after the reason word and
  a stalled card's live line shows it in place of the goal it never
  finished.

@scgopi scgopi left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Verdict: approve. (Posted as a comment — this account authored the PR, so GitHub rejects a formal approval.) Reviewed against issue #217 item 14 and verified on the branch at 4d5925c (independent run, not the PR's own claims).

Correctness against the reported bug

Claim in the PR Verified
--budget help said "(input + output)" while the hook counts four fields PresenceHooks.usageScript sums input_tokens + cache_creation_input_tokens + cache_read_input_tokens + output_tokens (PresenceHooks.swift:172-175); the new help text on all four surfaces (CLI create/update, app create form, GoalSpec docs, session prompt) now states the counting accurately
Budget exhaustion was indistinguishable from a stall-timeout ✅ Both .stalled sites record a why — enforceTokenBudget (GraphStore.swift:2120-2121) and markStalled (:2173-2174) are the only two sites that write .stalled
graphcode status renders the why ← Stalled: budget exhausted: 200 of 100 tokens spent; the append sits inside the AttentionRollup.reason branch, which fires only for failed/stalled/awaiting-input
The app card shows the why liveLine is correctly guarded on displayState == .stalled (displayState passes .stalled through unchanged), falling back to the handed line when no why exists
Persistence is backward compatible ✅ No hand-written encode(to:) — the synthesized encoder includes stallReason when non-nil; the hand-written decoder decodeIfPresent ?? nil, so graphs saved before the field load fine
Session prompt tells the loop what's counted ✅ "counted over every token the API meters (cache reads included)" lands in sessionPrompt

Tests and lint (run on the branch, worktree at 4d5925c)

  • Full suite: 1296 tests in 140 suites passed, exit 0 — matches the PR's number. The three new tests (statusRendersTheBudgetWhyInsteadOfABareStalled, aStalledLoopSaysWhyInsteadOfRestatingItsGoal, aStalledLoopWithoutAKnownWhyKeepsItsHandedLine) all ran and passed, and the stall-bound path assertion in GoalBasedLoopTests holds.
  • swiftlint: 0 errors. swift format: only the pre-existing DaemonBootstrap.swift violation, untouched by this PR (not in the diff). CI Linux build green.

One non-blocking finding

GraphcodeCommand.render (GraphcodeCommand.swift:686-691) appends stallReason under any attention reason, not just .stalled — unlike the card, which guards on displayState == .stalled. Reachable mislabel: reenterCycle (GraphStore.swift:1612-1616) resets cycle members to .idle without clearing stallReason, so a budget-stalled loop on a guarded cycle that re-enters and later fails renders ← Failed: budget exhausted: … with a stale why. Narrow (guarded cycles only) and cosmetic; a reason == .stalled guard in render — or clearing stallReason in reenterCycle — would close it. Fine to land as-is and address in a follow-up.

scgopi and others added 2 commits August 30, 2026 09:18
Review follow-up to #221: the reason now describes the stall that set it
and only the stall sites leave one behind — every other state write goes
through setNodeState, which clears it on the way out, so a future stall
path that forgets to write a fresh reason cannot inherit a stale one.
Also pins the create/update help lines' relationship with a test.
@scgopi
scgopi merged commit 152e858 into main Aug 30, 2026
1 check passed
@scgopi
scgopi deleted the fix/217-budget-stall branch August 31, 2026 05:11
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.

UX catalogue: things that confused a first-time user wiring a two-loop graph from the CLI (0.1.56)

1 participant