From 4d5925c7d4d484be66fd3f1042efd4833f11007a Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 29 Aug 2026 22:14:44 -0700 Subject: [PATCH 1/2] Say what --budget counts, and why a loop stalled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Sources/CLI/GraphcodeCommand.swift | 15 +++++++++--- GraphcodeKit/Sources/Domain/GoalSpec.swift | 23 ++++++++++++++----- GraphcodeKit/Sources/Domain/LoopNode.swift | 12 +++++++++- GraphcodeKit/Sources/GraphStore.swift | 5 ++++ .../Canvas/LoopCardPresentation.swift | 6 +++++ .../Project/NodeDraftTypeFields.swift | 7 ++++-- graphcode/Tests/GoalBasedLoopTests.swift | 2 ++ .../Tests/LoopCardPresentationTests.swift | 21 +++++++++++++++++ graphcode/Tests/TokenBudgetTests.swift | 17 ++++++++++++++ 9 files changed, 96 insertions(+), 12 deletions(-) diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 08097e7b..bbe39f75 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -120,7 +120,10 @@ public enum GraphcodeCommand: Equatable, Sendable { once per cycle pass (last stdout line must be a number) --direction minimize | maximize (default: maximize) --budget for --type goal: end the loop once its backend reports this - many tokens spent (input + output). Reported, never + many tokens spent — input + output + every cache-read and + cache-creation token the API metered. On Claude Code each + turn re-meters the whole context as cache reads, so a + budget burns per turn, not per hour. Reported, never estimated — a loop whose backend reports nothing is never stopped by a budget --skip-unchanged for --type goal: don't re-run the predicate while HEAD and @@ -132,7 +135,7 @@ public enum GraphcodeCommand: Equatable, Sendable { --goal, --predicate, --prompt, --check, --model, --metric, --direction as above --poll how often the predicate is polled --stall stall bound; 0 clears it - --budget token budget; 0 clears it + --budget token budget, counted as at creation; 0 clears it --heartbeat daemon heartbeat interval; 0 returns cadence to the prompt --skip-unchanged A loop may not change its own --predicate or --budget: the verifier stays outside @@ -681,7 +684,13 @@ extension GraphcodeCommand { if let exitCode = node.presence?.exitCode { line += " ← session exited (\(exitCode))" } else if let reason = AttentionRollup.reason(for: node) { - line += " ← \(reason.displayName)" + // Stalled is two different endings — a blown budget and a blown deadline — and + // only memory told them apart. The graph records which one it was; print it. + if let why = node.stallReason, !why.isEmpty { + line += " ← \(reason.displayName): \(why)" + } else { + line += " ← \(reason.displayName)" + } } lines.append(line) } diff --git a/GraphcodeKit/Sources/Domain/GoalSpec.swift b/GraphcodeKit/Sources/Domain/GoalSpec.swift index 672650de..ec179b49 100644 --- a/GraphcodeKit/Sources/Domain/GoalSpec.swift +++ b/GraphcodeKit/Sources/Domain/GoalSpec.swift @@ -41,10 +41,17 @@ public struct GoalSpec: Codable, Equatable, Sendable { public var metricCommand: String? /// Which way `metricCommand`'s number should move. Defaults to `.maximize`. public var metricDirection: MetricDirection - /// How many tokens (input + output, as the backend reports them) this loop may spend - /// before the orchestrator ends it — docs/08-quality-and-token-budgets.md's budget, - /// finally enforced rather than reviewed after the fact. `nil` means unbounded, which - /// stays the default: a budget is a bound the author chose, never one invented. + /// How many tokens this loop may spend before the orchestrator ends it — + /// docs/08-quality-and-token-budgets.md's budget, finally enforced rather than + /// reviewed after the fact. `nil` means unbounded, which stays the default: a budget + /// is a bound the author chose, never one invented. + /// + /// Counted the way the backend's API meters, not the way a turn feels: input, + /// output, cache creation *and cache reads* all count, because each is billed usage + /// (`PresenceHooks.usageScript` sums the transcript's four token fields). That makes + /// a Claude Code budget a per-turn cost — every turn re-meters the whole context as + /// cache reads — so a bound sized from turn counts is spent in minutes. The help + /// text says this because "(input + output)" read as hours and was not. /// /// Enforcement shares `UsageSample`'s honesty rule: usage is *reported* by the /// backend's hooks, never estimated, so a loop whose backend reports nothing can @@ -118,8 +125,12 @@ public struct GoalSpec: Codable, Equatable, Sendable { } if let budget = tokenBudget, budget > 0 { // Told to the session for the same reason the metric is: a loop that doesn't know - // its budget can't pace itself toward it — it can only be surprised by it. - parts.append("Token budget: \(budget) — the orchestrator ends this loop if it spends more.") + // its budget can't pace itself toward it — it can only be surprised by it. The + // counting is stated because a session metering its own spend from the API would + // otherwise pace against a number the orchestrator doesn't use. + parts.append( + "Token budget: \(budget), counted over every token the API meters (cache reads " + + "included) — the orchestrator ends this loop if it spends more.") } return parts.joined(separator: " ") } diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index f5c4c41a..687599b2 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -134,6 +134,13 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// handoff, and custody has to be: stopping or deleting a parent takes its spawned /// descendants with it, while a drawn edge to a peer must never be caught in that. public let createdBy: UUID? + /// Why the loop is `.stalled`, when the graph knows. A budget exhaustion and a stall + /// bound both land in the same terminal state, and both wrote their reason only to + /// the loop's memory log — every surface then showed a bare STALLED and a human had + /// to open memory to learn whether to raise a number or kill a stuck loop. Set by + /// `GraphStore` at the moment of the stall; `nil` for loops stalled before the field + /// existed, and for stalls whose cause the graph had nothing to say about. + public var stallReason: String? public var state: LoopState public var createdAt: Date @@ -159,6 +166,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { presence: PresenceReading? = nil, metricHistory: [MetricSample] = [], createdBy: UUID? = nil, + stallReason: String? = nil, state: LoopState = .idle, createdAt: Date = Date() ) { @@ -183,6 +191,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { self.presence = presence self.metricHistory = metricHistory self.createdBy = createdBy + self.stallReason = stallReason self.state = state self.createdAt = createdAt } @@ -421,7 +430,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case id, title, loopType, checkDescription, triggerPrompt, goal, backend, modelTier case worktreeBinding, subGraph, pilotState, usage, metricHistory, createdBy case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly - case summary, board, heartbeatIntervalSeconds + case summary, board, heartbeatIntervalSeconds, stallReason } /// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph` @@ -461,6 +470,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { metricHistory = try container.decodeIfPresent([MetricSample].self, forKey: .metricHistory) ?? [] createdBy = try container.decodeIfPresent(UUID.self, forKey: .createdBy) + stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index d05a669a..ee956df6 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -2094,6 +2094,9 @@ public actor GraphStore { /// nodes that pay this subprocess are exactly the ones whose author asked for the /// bound. A backend that reports nothing can never exhaust a budget: the sample /// stays nil and nil is "not reported", not zero — and not infinity either. + /// + /// The why lands on the node (`LoopNode.stallReason`) as well as in memory: `.stalled` + /// alone left every surface reading the same for a blown budget and a blown deadline. private func enforceTokenBudget(_ nodeID: UUID, goal: GoalSpec) async -> Bool { guard let budget = goal.tokenBudget, budget > 0 else { return false } guard let node = graph.nodes[id: nodeID] else { return false } @@ -2115,6 +2118,7 @@ public actor GraphStore { current, MessageBus.budgetExhaustedRequest(used: used, budget: budget)) } graph.nodes[id: nodeID]?.state = .stalled + graph.nodes[id: nodeID]?.stallReason = "budget exhausted: \(used) of \(budget) tokens spent" cancelGoalPoller(nodeID) recordMemory( nodeID, @@ -2167,6 +2171,7 @@ public actor GraphStore { /// way to proceed, which is worse than telling them the upstream didn't work out. private func markStalled(_ nodeID: UUID) { graph.nodes[id: nodeID]?.state = .stalled + graph.nodes[id: nodeID]?.stallReason = "stall bound exceeded without resolving" cancelGoalPoller(nodeID) recordMemory(nodeID, "stalled: exceeded its stall bound without resolving") fireOutgoingEdges(from: nodeID, sourceSucceeded: false) diff --git a/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift b/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift index 489ff157..af81c9a6 100644 --- a/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift +++ b/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift @@ -83,6 +83,11 @@ struct LoopCardPresentation: Equatable { /// goal, prompt or check the loop was created with — less immediate, and never wrong. /// The newest beat outranks both, when there is one. /// + /// One reading outranks them all: a `.stalled` loop that knows why it stalled says so. + /// The pill already said STALLED; without the reason on the same card, a budget that + /// simply needs raising looks identical to a loop that ground to a halt, and the why + /// sat only in the loop's memory log. + /// /// A beat is the sentence the session wrote about what it is *trying* to do; `activity` /// is the tool call it happens to be inside. "Working out why cached tokens get counted /// twice" is worth more on a card than "reading UsageProbe.swift", and it is the same @@ -94,6 +99,7 @@ struct LoopCardPresentation: Equatable { /// switched the reading off — while the live activity line it outranks sat unread /// underneath it. With the producer off this is exactly what shipped before. private static func liveLine(_ node: LoopNode, summarising: Bool) -> String? { + if node.displayState == .stalled, let why = collapsed(node.stallReason) { return why } let passes = node.metricHistory.count if summarising, let beat = node.summary?.current?.text, !beat.isEmpty { return passes > 0 ? "pass \(passes) · \(beat)" : beat diff --git a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift index 0dbe27d0..4fee12af 100644 --- a/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift +++ b/graphcode/Sources/Features/Project/NodeDraftTypeFields.swift @@ -113,8 +113,11 @@ struct GoalDraftFields: View { private var budgetFields: some View { DraftField( label: "Token budget", qualifier: "optional", - help: "Stopped once its backend reports this many tokens spent (input + output). " - + "Reported, never estimated — a backend that reports nothing is never stopped." + help: "Stopped once its backend reports this many tokens spent — input + output + " + + "every cache-read and cache-creation token the API metered. On Claude Code each " + + "turn re-meters the whole context as cache reads, so a budget burns per turn, " + + "not per hour. Reported, never estimated — a backend that reports nothing is " + + "never stopped." ) { DraftTextField(placeholder: "200000", text: $store.draftBudget, isMono: true) } diff --git a/graphcode/Tests/GoalBasedLoopTests.swift b/graphcode/Tests/GoalBasedLoopTests.swift index d3038b2a..8180b207 100644 --- a/graphcode/Tests/GoalBasedLoopTests.swift +++ b/graphcode/Tests/GoalBasedLoopTests.swift @@ -131,6 +131,8 @@ struct GoalBasedLoopTests { await store.evaluateGoal(nodeID, now: Date(timeIntervalSince1970: 61)) #expect(await store.graph.nodes[id: nodeID]?.state == .stalled) + #expect( + await store.graph.nodes[id: nodeID]?.stallReason == "stall bound exceeded without resolving") #expect(evaluated.value == 0) } diff --git a/graphcode/Tests/LoopCardPresentationTests.swift b/graphcode/Tests/LoopCardPresentationTests.swift index 824fc7fc..44fe2027 100644 --- a/graphcode/Tests/LoopCardPresentationTests.swift +++ b/graphcode/Tests/LoopCardPresentationTests.swift @@ -127,6 +127,27 @@ struct LoopCardPresentationTests { #expect(LoopCardPresentation(node: composite).liveLine == "2 loops · Armed") } + @Test + func aStalledLoopSaysWhyInsteadOfRestatingItsGoal() { + // The pill already said STALLED; a card that went on quoting the goal read as a + // loop still pursuing it, and the budget-vs-deadline question sat in memory only. + let blown = LoopNode( + title: "a", loopType: .goalBased, + goal: GoalSpec(summary: "the suite is green"), + stallReason: "budget exhausted: 3000000 of 3000000 tokens spent", state: .stalled) + #expect( + LoopCardPresentation(node: blown).liveLine + == "budget exhausted: 3000000 of 3000000 tokens spent") + } + + @Test + func aStalledLoopWithoutAKnownWhyKeepsItsHandedLine() { + let stalled = LoopNode( + title: "a", loopType: .goalBased, goal: GoalSpec(summary: "the suite is green"), + state: .stalled) + #expect(LoopCardPresentation(node: stalled).liveLine == "the suite is green") + } + @Test func aLoopWithNothingWrittenDownHasNoLiveLineRatherThanAnEmptyOne() { let blank = LoopNode(title: "blank", checkDescription: " ") diff --git a/graphcode/Tests/TokenBudgetTests.swift b/graphcode/Tests/TokenBudgetTests.swift index 99ed24b9..41250334 100644 --- a/graphcode/Tests/TokenBudgetTests.swift +++ b/graphcode/Tests/TokenBudgetTests.swift @@ -40,6 +40,20 @@ struct TokenBudgetTests { #expect(delivered.value.count == 1) #expect(delivered.value[0].contains("110 of its 100-token budget")) #expect(remembered.value.contains { $0.contains("budget exhausted: 110 of 100") }) + // The why travels with the node, not only into memory — a bare STALLED gave a blown + // budget and a blown deadline the same face (issue #217). + #expect(await store.graph.nodes[0].stallReason == "budget exhausted: 110 of 100 tokens spent") + } + + @Test + func statusRendersTheBudgetWhyInsteadOfABareStalled() async { + let graph = budgetGraph() + let store = GraphStore(graph: graph, onReadUsage: { _, _ in UsageSample(inputTokens: 200) }) + + await store.evaluateGoal(graph.nodes[0].id) + + let rendered = GraphcodeCommand.render(await store.graph) + #expect(rendered.contains("← Stalled: budget exhausted: 200 of 100 tokens spent")) } @Test @@ -130,6 +144,9 @@ struct TokenBudgetTests { goal: GoalSpec(summary: "done", tokenBudget: 5000)) let prompt = try #require(node.sessionPrompt) #expect(prompt.contains("Token budget: 5000")) + // The counting is stated to the session too: a loop pacing itself against the + // API's cache-read metering must know that is what the orchestrator counts. + #expect(prompt.contains("cache reads included")) let unbounded = LoopNode( title: "Sweep", loopType: .goalBased, goal: GoalSpec(summary: "done")) From 1d949907983e1020264c8a4c477f97f2c828f998 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 30 Aug 2026 09:18:32 -0700 Subject: [PATCH 2/2] Clear stallReason on every transition out of .stalled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- GraphcodeKit/Sources/GraphStore.swift | 26 ++++++++++++++++++-------- graphcode/Tests/TokenBudgetTests.swift | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index ee956df6..4bf581b3 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -500,7 +500,7 @@ public actor GraphStore { case .failed, .stalled: resolveNode(nodeID, succeeded: false) case .idle, .running, .awaitingInput, .blocked, .waiting, .stopped: - graph.nodes[id: nodeID]?.state = rolled + setNodeState(nodeID, rolled) } } @@ -516,7 +516,7 @@ public actor GraphStore { node.subGraph != nil else { return } graph.nodes[id: nodeID]?.pilotState = .piloting - graph.nodes[id: nodeID]?.state = .running + setNodeState(nodeID, .running) // Start every unattended loop inside the composite. That *is* the pilot: real // sessions, real output, real cost — just not wired to the recurring trigger yet. @@ -537,7 +537,7 @@ public actor GraphStore { node.pilotState.canArm else { return } graph.nodes[id: nodeID]?.pilotState = .armed - graph.nodes[id: nodeID]?.state = .running + setNodeState(nodeID, .running) } // MARK: - Usage @@ -1247,7 +1247,7 @@ public actor GraphStore { for newID in plan.idMapping.values { guard let node = graph.nodes[id: newID], node.runsUnattended, !node.isResolved else { continue } - if subGraphDepth == 0 { graph.nodes[id: newID]?.state = .running } + if subGraphDepth == 0 { setNodeState(newID, .running) } ensureSession(node) if node.loopType == .goalBased { armGoalPoller(for: node) } armHeartbeat(for: node) @@ -1372,7 +1372,7 @@ public actor GraphStore { if MessageBus.deliverability(to: node) == nil { asked = await deliverToSession(node, MessageBus.stopRequest) } - graph.nodes[id: node.id]?.state = .stopped + setNodeState(node.id, .stopped) cancelGoalPoller(node.id) // The experiment's clean-stop dividend: a heartbeat loop's cadence dies here, with // the timer — no typed request needed for a schedule the agent never owned. @@ -1429,7 +1429,7 @@ public actor GraphStore { _ nodeID: UUID, succeeded: Bool, sessionMayStillBeLive: Bool = false ) { guard let node = graph.nodes[id: nodeID] else { return } - graph.nodes[id: nodeID]?.state = succeeded ? .succeeded : .failed + setNodeState(nodeID, succeeded ? .succeeded : .failed) cancelGoalPoller(nodeID) recordMemory(nodeID, "resolved: \(succeeded ? "succeeded" : "failed")") // Skill distillation rides resolution: a goal loop that just succeeded is the one @@ -1610,7 +1610,7 @@ public actor GraphStore { let reentry = graph.edges[id: edge.id]?.fireCount ?? 0 let bound = edge.cycleGuard?.maxIterations.map { " of \($0)" } ?? "" for nodeID in members { - graph.nodes[id: nodeID]?.state = .idle + setNodeState(nodeID, .idle) cancelGoalPoller(nodeID) recordMemory(nodeID, "cycle re-entry \(reentry)\(bound): pass restarting") } @@ -1969,7 +1969,7 @@ public actor GraphStore { let stillBlocked = graph.edges.contains { $0.to == nodeID && $0.kind.blocksTarget && !$0.fired } - graph.nodes[id: nodeID]?.state = stillBlocked ? .blocked : .idle + setNodeState(nodeID, stillBlocked ? .blocked : .idle) } // MARK: - Goal-based stop-condition polling @@ -2165,6 +2165,16 @@ public actor GraphStore { broadcast() } + /// Every state write outside the two stall paths goes through here. `stallReason` + /// describes the stall that set it — carrying it into a later `.running` or `.idle` + /// would show a why for a stall the node has left, and a future stall path that + /// forgets to write a fresh reason would then inherit the old one. Clearing on the + /// way out makes that impossible: only the stall sites leave a reason behind. + private func setNodeState(_ nodeID: UUID, _ state: LoopState) { + graph.nodes[id: nodeID]?.state = state + if state != .stalled { graph.nodes[id: nodeID]?.stallReason = nil } + } + /// A stalled loop is terminal, and its downstream edges fire as if it failed. Leaving /// them unfired would be tidier in theory but deadlocks the rest of the graph in /// practice — every node waiting on a stalled one would sit blocked forever with no diff --git a/graphcode/Tests/TokenBudgetTests.swift b/graphcode/Tests/TokenBudgetTests.swift index 41250334..c783ed0b 100644 --- a/graphcode/Tests/TokenBudgetTests.swift +++ b/graphcode/Tests/TokenBudgetTests.swift @@ -153,6 +153,20 @@ struct TokenBudgetTests { #expect(try #require(unbounded.sessionPrompt).contains("Token budget") == false) } + @Test + func theHelpTextKeepsTheUpdateBudgetLineInSyncWithTheCreateOne() { + let lines = GraphcodeCommand.helpText.split(separator: "\n") + let budgetLines = lines.filter { $0.trimmingCharacters(in: .whitespaces).hasPrefix("--budget") } + // Exactly two flag lines: the create section owns the counting statement, and the + // update line must point at it rather than carry a copy that can drift apart. + #expect(budgetLines.count == 2) + #expect(budgetLines[0].contains("for --type goal")) + #expect(budgetLines[1].contains("counted as at creation")) + // The counting itself stays stated: cache reads are what makes a budget burn fast. + #expect(GraphcodeCommand.helpText.contains("cache-read")) + #expect(GraphcodeCommand.helpText.contains("cache-creation")) + } + @Test func theCLIParsesBudgetOnCreateAndUpdate() throws { let create = try GraphcodeCommand.parse([