Skip to content

Commit 1ff0e41

Browse files
authored
Merge pull request #351 from scgopi/fix/346-followup-done-delivery
Let finished loops answer, keep /goal armed, report done on pi and OpenCode (#346)
2 parents edf57ec + 996d4e9 commit 1ff0e41

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)