Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,10 @@ public enum GraphcodeCommand: Equatable, Sendable {
once per cycle pass (last stdout line must be a number)
--direction <d> minimize | maximize (default: maximize)
--budget <tokens> 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
Expand All @@ -132,7 +135,7 @@ public enum GraphcodeCommand: Equatable, Sendable {
--goal, --predicate, --prompt, --check, --model, --metric, --direction as above
--poll <seconds> how often the predicate is polled
--stall <seconds> stall bound; 0 clears it
--budget <tokens> token budget; 0 clears it
--budget <tokens> token budget, counted as at creation; 0 clears it
--heartbeat <secs> daemon heartbeat interval; 0 returns cadence to the prompt
--skip-unchanged <true|false>
A loop may not change its own --predicate or --budget: the verifier stays outside
Expand Down Expand Up @@ -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)
}
Expand Down
23 changes: 17 additions & 6 deletions GraphcodeKit/Sources/Domain/GoalSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: " ")
}
Expand Down
12 changes: 11 additions & 1 deletion GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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()
}
Expand Down
31 changes: 23 additions & 8 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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,
Expand Down Expand Up @@ -2161,12 +2165,23 @@ 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
/// 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)
Expand Down
6 changes: 6 additions & 0 deletions graphcode/Sources/Features/Canvas/LoopCardPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions graphcode/Sources/Features/Project/NodeDraftTypeFields.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions graphcode/Tests/GoalBasedLoopTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
21 changes: 21 additions & 0 deletions graphcode/Tests/LoopCardPresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: " ")
Expand Down
31 changes: 31 additions & 0 deletions graphcode/Tests/TokenBudgetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -130,12 +144,29 @@ 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"))
#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([
Expand Down
Loading