Skip to content

Commit 996d4e9

Browse files
scgopiclaude
andcommitted
Let finished loops answer, keep /goal armed, and tell pi and OpenCode to report done (#346)
A live run of goal loops on all five backends with 0.1.70-beta2 found three gaps in the #350 fix: - `graphcode node send` to a loop that succeeded or failed was staged to its memory even while its session was up. A follow-up question now reaches a live session and changes nothing about how the loop resolved; edges and wakes still leave resolved loops alone. - A long goal moves to PROMPT.md, and `/goal` inside a file is prose, so Codex never armed its goal and recorded no verdict. The typed pointer now opens with the directive and the start of the condition, shrinking the head before it would drop the briefing (#345). - pi and OpenCode, whose verdict the daemon cannot read, finished their work and reported to their creator but never ran `graphcode node done`. Their goal prompt now ends with the exact command, child goal loops get it with their own id at birth, and the briefing says it plainly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent edf57ec commit 996d4e9

8 files changed

Lines changed: 254 additions & 12 deletions

File tree

GraphcodeKit/Sources/Domain/BackendCommand.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,16 @@ extension CLISessionBackendKind {
227227
/// (`ZmxSessionLauncher.resumeArguments`) and the app's reboot restore
228228
/// (`GhosttyTerminalView.resumeCommand`) — so a backend gaining or losing resume
229229
/// support changes both paths together rather than one silently drifting.
230+
/// Whether the daemon can read this backend's own verdict on its goal
231+
/// (`GoalVerdictReader`). A backend that cannot has one way to resolve a goal loop with
232+
/// no predicate: the session running `graphcode node done`.
233+
public var recordsGoalVerdict: Bool {
234+
switch self {
235+
case .claudeCode, .codex, .copilotCLI: return true
236+
case .openCode, .pi: return false
237+
}
238+
}
239+
230240
public var supportsResume: Bool {
231241
self == .claudeCode || self == .copilotCLI || self == .codex || self == .openCode
232242
|| self == .pi

GraphcodeKit/Sources/Domain/LoopNode.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,30 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
273273
/// broadcast stay cheap.
274274
public static let maxMetricSamples = 20
275275

276+
/// ASCII, and no path next to punctuation: it rides the typed launch line.
277+
public static let reportDoneSentence =
278+
"When the goal is met, run graphcode node done with this project's path, your node id "
279+
+ "and a one-line result - not before, and not while you still wait on mail, CI or "
280+
+ "loops you created."
281+
282+
/// `reportDoneSentence` with the command spelled out verbatim — a session told the exact
283+
/// command runs it, where one told to assemble it reported through the route it was
284+
/// given instead.
285+
public static func reportDoneSentence(projectPath: String, nodeID: UUID) -> String {
286+
"When the goal is met, run: graphcode node done \(projectPath) \(nodeID.uuidString) "
287+
+ "<one-line result> - not before, and not while you still wait on mail, CI or loops "
288+
+ "you created."
289+
}
290+
291+
/// `sessionPrompt` for a launch that knows its project, with the finishing step's command
292+
/// filled in. What both launchers — the daemon's and the app's pane — type.
293+
public func sessionPrompt(forProjectPath projectPath: String?) -> String? {
294+
guard let prompt = sessionPrompt else { return nil }
295+
guard let projectPath, prompt.hasSuffix(Self.reportDoneSentence) else { return prompt }
296+
return String(prompt.dropLast(Self.reportDoneSentence.count))
297+
+ Self.reportDoneSentence(projectPath: projectPath, nodeID: id)
298+
}
299+
276300
/// The opening prompt this node's `zmx` session should run, or `nil` when there is
277301
/// nothing to say. One place so `ZmxSessionLauncher` (daemon) and `LoopWorkspaceView`
278302
/// (app) can never disagree about what a loop starts with.
@@ -328,7 +352,13 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
328352
+ "\(task) Do not schedule your own /loop, wakeup, or cron for it — the "
329353
+ "orchestrator holds the timer. Stay in the session between heartbeats."
330354
case .goalBased:
331-
return goal?.sessionPrompt(directive: backend.capabilities.goalDirective)
355+
guard let prompt = goal?.sessionPrompt(directive: backend.capabilities.goalDirective)
356+
else { return nil }
357+
// A backend whose verdict the daemon cannot read resolves a goal with no predicate
358+
// only when its session reports it met. The briefing says so, but a session follows
359+
// its prompt first: OpenCode and pi loops finished their work and never reported.
360+
guard !backend.recordsGoalVerdict, goal?.effectivePredicate == nil else { return prompt }
361+
return prompt + " " + Self.reportDoneSentence
332362
case .turnBased:
333363
return Self.turnBasedPrompt(
334364
instruction: firstInstruction, check: checkDescription,

GraphcodeKit/Sources/Domain/SessionBriefing.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ public enum SessionBriefing {
155155
always want.** The loop starts immediately and resolves when its goal is met. Add
156156
`--predicate <shell command>` only when a command can actually decide it (exit 0
157157
means met, e.g. a test run); without one, it resolves when its backend records the
158-
goal as met or when it runs `graphcode node done`.
158+
goal as met or when it runs `graphcode node done`. **If you are a goal loop, run
159+
`graphcode node done <project-path> <your-node-id> <result>` once your goal is met**
160+
— never while you are still waiting on mail, CI, or loops you created.
159161
\(timeBullet.trimmingCharacters(in: .whitespacesAndNewlines))
160162
- `--type turn --check <what a human verifies>` — for work a **human** must review
161163
each turn before it continues. **A turn-based loop does not start on its own**:

GraphcodeKit/Sources/GraphStore.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -791,7 +791,11 @@ public actor GraphStore {
791791
recordMemory(
792792
node.id,
793793
"created by \(parent.title) — report results to it with: "
794-
+ "graphcode node send \(graph.project.path) \(creator.uuidString) <message>")
794+
+ "graphcode node send \(graph.project.path) \(creator.uuidString) <message>"
795+
+ (node.loopType == .goalBased
796+
? "; once your goal is met, also run: graphcode node done "
797+
+ "\(graph.project.path) \(node.id.uuidString) <result>"
798+
: ""))
795799
}
796800
if node.runsUnattended {
797801
// Start it now rather than waiting for someone to open it — the loop is supposed
@@ -3124,6 +3128,17 @@ public actor GraphStore {
31243128
// simply vanished. The message now lands in the target's log, its next wake reads
31253129
// it, and the sender is told the truth about what happened rather than either
31263130
// "delivered" or a dead end.
3131+
// A follow-up question to a finished loop whose session is still up reaches it. The
3132+
// graph calls a resolved loop "not live" so edges and wakes leave it alone, but a
3133+
// human asking what it did is the point of keeping the session; the answer changes
3134+
// nothing about how it resolved (#346).
3135+
if target.state == .succeeded || target.state == .failed,
3136+
target.backend.capabilities.supportsMidSessionInput,
3137+
await onSessionAlive?(target, graph.project.path) == true,
3138+
await deliverToSession(target, message)
3139+
{
3140+
return
3141+
}
31273142
if MessageBus.deliverability(to: target) != nil {
31283143
recordMemory(nodeID, "while you were away: \(message)")
31293144
announceError(

GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,9 @@ public enum ZmxSessionLauncher {
957957
forNode node: LoopNode, projectPath: String? = nil,
958958
settings: GraphcodeSettings = GraphcodeSettingsStore.load()
959959
) -> [String]? {
960-
guard let prompt = node.sessionPrompt, !prompt.isEmpty else { return nil }
960+
guard let prompt = node.sessionPrompt(forProjectPath: projectPath), !prompt.isEmpty else {
961+
return nil
962+
}
961963
// A backend graphcode can't launch has no argv. `canHost` already refuses to create
962964
// such a node, so this is the belt to that braces — but silently starting the wrong
963965
// agent is the failure it exists to prevent, so it's worth both.
@@ -1113,25 +1115,61 @@ public enum ZmxSessionLauncher {
11131115
let promptFile = NodeMemory.writePrompt(
11141116
filePrompt, projectPath: projectPath, nodeID: node.id)
11151117
else { return unbriefedCommand }
1116-
let pointer = NodeMemory.promptPointer(
1118+
let plainPointer = NodeMemory.promptPointer(
11171119
toPromptAt: remote == nil
11181120
? promptFile.path
11191121
: RemoteGraphAccess.promptPath(forProjectPath: projectPath, nodeID: node.id))
1122+
let directive = node.backend.capabilities.goalDirective
11201123
let promptDirectory =
11211124
remote == nil
11221125
? promptFile.deletingLastPathComponent().path
11231126
: RemoteGraphAccess.memoryDirectory(forProjectPath: projectPath, nodeID: node.id)
1124-
let pointeredCommand = shed(
1125-
prompt: pointer, briefingPath: briefingPath, extraPath: promptDirectory)
1126-
if Self.fitsInATypedCommandLine(pointeredCommand) { return pointeredCommand }
1127+
// Longest first: the goal's opening words help its evaluator, the directive is what
1128+
// arms the goal at all, and the briefing outranks both (issue #345) — so the head
1129+
// shrinks before the directive goes, and the directive goes before the briefing.
1130+
let pointers =
1131+
Self.pointerHeadLengths.map {
1132+
Self.directiveLedPointer(
1133+
plainPointer, prompt: singleLine, directive: directive, headLength: $0)
1134+
} + [plainPointer]
1135+
for pointer in pointers {
1136+
let pointeredCommand = shed(
1137+
prompt: pointer, briefingPath: briefingPath, extraPath: promptDirectory)
1138+
if Self.fitsInATypedCommandLine(pointeredCommand) { return pointeredCommand }
1139+
}
11271140
// Deep support-directory paths can push briefing plus pointer past the line even
11281141
// now. Only then does the briefing go, keeping whichever prompt form is shorter.
11291142
if Self.fitsInATypedCommandLine(unbriefedCommand) { return unbriefedCommand }
1130-
return shed(prompt: pointer, briefingPath: nil, extraPath: promptDirectory)
1143+
let shortestLed = Self.directiveLedPointer(
1144+
plainPointer, prompt: singleLine, directive: directive, headLength: 0)
1145+
return shed(prompt: shortestLed, briefingPath: nil, extraPath: promptDirectory)
11311146
}
11321147
return command
11331148
}
11341149

1150+
/// The typed pointer for a prompt that moved to a file, still opening with the backend's
1151+
/// goal directive when the prompt did. `/goal` inside a file is prose: the session read
1152+
/// its instructions and never armed the goal, so its backend recorded no verdict (#346).
1153+
/// The start of the condition rides along — enough for the backend's evaluator, and for
1154+
/// `GoalVerdictReader` to match the verdict to this goal.
1155+
static func directiveLedPointer(
1156+
_ pointer: String, prompt: String, directive: String?,
1157+
headLength: Int = pointerHeadLengths[0]
1158+
) -> String {
1159+
guard let directive, prompt.hasPrefix(directive + " ") else { return pointer }
1160+
guard headLength > 0 else { return "\(directive) \(pointer)" }
1161+
let condition = prompt.dropFirst(directive.count + 1)
1162+
var head = String(condition.prefix(headLength))
1163+
if condition.count > head.count, let space = head.lastIndex(of: " ") {
1164+
head = String(head[..<space]) + "..."
1165+
}
1166+
return "\(directive) \(head) - \(pointer)"
1167+
}
1168+
1169+
/// The condition heads tried, longest first. 120 carries a goal summary's opening words
1170+
/// past a `Done when:` prefix; 0 keeps only the directive, when the line has no more room.
1171+
static let pointerHeadLengths = [120, 40, 0]
1172+
11351173
/// `zmx run` argv that resumes an existing backend session instead of starting fresh.
11361174
///
11371175
/// Used after a reboot: the zmx session is gone, but a persisted session ID lets the

graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ struct LoopWorkspaceView: View {
350350
// bare, and extra tabs/splits are plain shells either way. A succeeded loop's goal is
351351
// met: opening it resumes the conversation, and never starts that goal again.
352352
initialPrompt: ref.launchesClaudeCode && store.node.state != .succeeded
353-
? store.node.sessionPrompt : nil,
353+
? store.node.sessionPrompt(forProjectPath: store.projectPath) : nil,
354354
// A node without its own worktree yet still belongs to a project — its shells
355355
// should open there, not wherever the app process happened to launch from. A
356356
// global-graph loop belongs to no folder at all: home, the same answer the
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import ComposableArchitecture
2+
import Foundation
3+
import Testing
4+
5+
@testable import GraphcodeKit
6+
7+
/// The gaps the live five-backend run of #346 found on 0.1.70-beta2.
8+
@Suite
9+
struct GoalResolutionFollowUpTests {
10+
// MARK: - A follow-up question reaches a finished loop
11+
12+
private func finishedTarget(
13+
alive: Bool, delivered: LockIsolated<[String]>, memory: LockIsolated<[String]>
14+
) async -> (GraphStore, UUID) {
15+
let store = GraphStore(
16+
onDeliverMessage: { _, text, _ in
17+
delivered.withValue { $0.append(text) }
18+
return true
19+
},
20+
onSessionAlive: { _, _ in alive },
21+
onAppendMemory: { _, entry in memory.withValue { $0.append(entry) } })
22+
await store.handle(
23+
.createNode(
24+
NodeDraft(title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write it"))))
25+
let id = await store.graph.nodes[0].id
26+
await store.handle(.completeNode(id, result: nil, from: id))
27+
return (store, id)
28+
}
29+
30+
@Test
31+
func aQuestionToAFinishedLoopWithALiveSessionIsTypedInAndChangesNothing() async {
32+
let delivered = LockIsolated<[String]>([])
33+
let memory = LockIsolated<[String]>([])
34+
let (store, id) = await finishedTarget(alive: true, delivered: delivered, memory: memory)
35+
let resolution = await store.graph.nodes[id: id]?.resolution
36+
37+
await store.handle(.messageNode(id, text: "what did you change?", from: nil, followUp: false))
38+
39+
#expect(delivered.value.contains { $0.contains("what did you change?") })
40+
#expect(!memory.value.contains { $0.hasPrefix("while you were away") })
41+
#expect(await store.graph.nodes[id: id]?.state == .succeeded)
42+
#expect(await store.graph.nodes[id: id]?.resolution == resolution)
43+
}
44+
45+
@Test
46+
func aQuestionToAFinishedLoopWhoseSessionEndedIsStaged() async {
47+
let delivered = LockIsolated<[String]>([])
48+
let memory = LockIsolated<[String]>([])
49+
let (store, id) = await finishedTarget(alive: false, delivered: delivered, memory: memory)
50+
51+
await store.handle(.messageNode(id, text: "what did you change?", from: nil, followUp: false))
52+
53+
#expect(!delivered.value.contains { $0.contains("what did you change?") })
54+
#expect(memory.value.contains { $0.hasPrefix("while you were away") })
55+
}
56+
57+
// MARK: - /goal stays the command when the prompt moves to a file
58+
59+
@Test
60+
func aPointerForADirectiveLedPromptStillOpensWithTheDirective() {
61+
let pointer = "Your complete instructions are in the file at /x/PROMPT.md - read it."
62+
let led = ZmxSessionLauncher.directiveLedPointer(
63+
pointer, prompt: "/goal Write the docs for the login flow", directive: "/goal")
64+
#expect(led == "/goal Write the docs for the login flow - \(pointer)")
65+
#expect(led.hasPrefix("/goal "))
66+
67+
let long = "/goal " + String(repeating: "word ", count: 80)
68+
let cut = ZmxSessionLauncher.directiveLedPointer(pointer, prompt: long, directive: "/goal")
69+
#expect(cut.hasPrefix("/goal word"))
70+
#expect(cut.contains("... - \(pointer)"))
71+
72+
#expect(
73+
ZmxSessionLauncher.directiveLedPointer(
74+
pointer, prompt: "/goal Write the docs", directive: "/goal", headLength: 0)
75+
== "/goal \(pointer)")
76+
#expect(
77+
ZmxSessionLauncher.directiveLedPointer(pointer, prompt: "Work toward it", directive: nil)
78+
== pointer)
79+
#expect(
80+
ZmxSessionLauncher.directiveLedPointer(pointer, prompt: "Plain prose", directive: "/goal")
81+
== pointer)
82+
}
83+
84+
@Test
85+
func aLongCodexGoalLaunchesWithGoalAsTheCommand() {
86+
let goal = String(repeating: "Write the single line into the file and verify it. ", count: 60)
87+
let node = LoopNode(
88+
title: "Long", loopType: .goalBased, goal: GoalSpec(summary: goal), backend: .codex)
89+
defer { NodeMemory.remove(projectPath: "/tmp", nodeID: node.id) }
90+
91+
let arguments =
92+
ZmxSessionLauncher.arguments(
93+
forNode: node, projectPath: "/tmp", settings: GraphcodeSettings()) ?? []
94+
95+
let typedPrompt = arguments.first { $0.contains(NodeMemory.promptFileName) }
96+
#expect(typedPrompt?.hasPrefix("/goal Write the single line") == true)
97+
}
98+
99+
// MARK: - A backend with no verdict of its own is told to report done
100+
101+
@Test
102+
func openCodeAndPiGoalsAreToldToRunNodeDone() {
103+
for backend in [CLISessionBackendKind.openCode, .pi] {
104+
let node = LoopNode(
105+
title: "a", loopType: .goalBased, goal: GoalSpec(summary: "Ship it"), backend: backend)
106+
#expect(node.sessionPrompt?.hasSuffix(LoopNode.reportDoneSentence) == true)
107+
}
108+
for backend in [CLISessionBackendKind.claudeCode, .codex, .copilotCLI] {
109+
let node = LoopNode(
110+
title: "a", loopType: .goalBased, goal: GoalSpec(summary: "Ship it"), backend: backend)
111+
#expect(node.sessionPrompt?.contains("graphcode node done") == false)
112+
}
113+
let pi = LoopNode(
114+
title: "a", loopType: .goalBased, goal: GoalSpec(summary: "Ship it"), backend: .pi)
115+
let literal = pi.sessionPrompt(forProjectPath: "/Volumes/SCG/wd/graphcode") ?? ""
116+
#expect(
117+
literal.contains(
118+
"run: graphcode node done /Volumes/SCG/wd/graphcode \(pi.id.uuidString) <one-line result>"))
119+
#expect(!literal.contains(LoopNode.reportDoneSentence))
120+
let predicated = LoopNode(
121+
title: "a", loopType: .goalBased, goal: GoalSpec(summary: "Ship it", predicate: "true"),
122+
backend: .pi)
123+
#expect(predicated.sessionPrompt?.contains("graphcode node done") == false)
124+
}
125+
126+
@Test
127+
func aChildGoalLoopIsHandedTheDoneCommandAtBirth() async {
128+
let memory = LockIsolated<[(UUID, String)]>([])
129+
let store = GraphStore(onAppendMemory: { id, entry in memory.withValue { $0.append((id, entry)) } })
130+
await store.handle(
131+
.createNode(NodeDraft(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: "Lead"))))
132+
let leader = await store.graph.nodes[0].id
133+
await store.handle(
134+
.createNode(
135+
NodeDraft(
136+
title: "Child", loopType: .goalBased, goal: GoalSpec(summary: "Child work"),
137+
backend: .pi, createdBy: leader)))
138+
let child = await store.graph.nodes[1].id
139+
140+
let birth = memory.value.first { $0.0 == child }?.1 ?? ""
141+
#expect(birth.contains("graphcode node send"))
142+
#expect(birth.contains("graphcode node done"))
143+
#expect(birth.contains(child.uuidString))
144+
}
145+
}

graphcode/Tests/ZmxSessionLauncherTests.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,11 @@ struct ZmxSessionLauncherTests {
325325

326326
// The whole point: what gets typed survives the tty.
327327
#expect(ZmxSessionLauncher.fitsInATypedCommandLine(arguments))
328-
// The typed prompt is the pointer, not the goal.
328+
// The typed prompt is the pointer, not the goal — at most the goal's opening words
329+
// ride ahead of it, so a `/goal` directive stays the command (#346).
329330
let typed = arguments.last ?? ""
330-
#expect(!typed.contains("CONFLICT SCOPE"))
331+
#expect(!typed.contains(goal))
332+
#expect(typed.components(separatedBy: "CONFLICT SCOPE").count <= 3)
331333
#expect(typed.contains(NodeMemory.promptFileName))
332334
// And the file carries the full goal, nothing dropped mid-string.
333335
let file = NodeMemory.directory(forProjectPath: "/tmp", nodeID: node.id)

0 commit comments

Comments
 (0)