From 996d4e9da47eca233bbcb01efd6b4fb743fc85c8 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sun, 13 Sep 2026 14:01:55 -0700 Subject: [PATCH] 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) --- .../Sources/Domain/BackendCommand.swift | 10 ++ GraphcodeKit/Sources/Domain/LoopNode.swift | 32 +++- .../Sources/Domain/SessionBriefing.swift | 4 +- GraphcodeKit/Sources/GraphStore.swift | 17 +- .../Sources/Sessions/ZmxSessionLauncher.swift | 50 +++++- .../LoopWorkspace/LoopWorkspaceView.swift | 2 +- .../Tests/GoalResolutionFollowUpTests.swift | 145 ++++++++++++++++++ graphcode/Tests/ZmxSessionLauncherTests.swift | 6 +- 8 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 graphcode/Tests/GoalResolutionFollowUpTests.swift diff --git a/GraphcodeKit/Sources/Domain/BackendCommand.swift b/GraphcodeKit/Sources/Domain/BackendCommand.swift index a6eadac0..cdd94965 100644 --- a/GraphcodeKit/Sources/Domain/BackendCommand.swift +++ b/GraphcodeKit/Sources/Domain/BackendCommand.swift @@ -227,6 +227,16 @@ extension CLISessionBackendKind { /// (`ZmxSessionLauncher.resumeArguments`) and the app's reboot restore /// (`GhosttyTerminalView.resumeCommand`) — so a backend gaining or losing resume /// support changes both paths together rather than one silently drifting. + /// Whether the daemon can read this backend's own verdict on its goal + /// (`GoalVerdictReader`). A backend that cannot has one way to resolve a goal loop with + /// no predicate: the session running `graphcode node done`. + public var recordsGoalVerdict: Bool { + switch self { + case .claudeCode, .codex, .copilotCLI: return true + case .openCode, .pi: return false + } + } + public var supportsResume: Bool { self == .claudeCode || self == .copilotCLI || self == .codex || self == .openCode || self == .pi diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index d246023b..58785d1f 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -273,6 +273,30 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// broadcast stay cheap. public static let maxMetricSamples = 20 + /// ASCII, and no path next to punctuation: it rides the typed launch line. + public static let reportDoneSentence = + "When the goal is met, run graphcode node done with this project's path, your node id " + + "and a one-line result - not before, and not while you still wait on mail, CI or " + + "loops you created." + + /// `reportDoneSentence` with the command spelled out verbatim — a session told the exact + /// command runs it, where one told to assemble it reported through the route it was + /// given instead. + public static func reportDoneSentence(projectPath: String, nodeID: UUID) -> String { + "When the goal is met, run: graphcode node done \(projectPath) \(nodeID.uuidString) " + + " - not before, and not while you still wait on mail, CI or loops " + + "you created." + } + + /// `sessionPrompt` for a launch that knows its project, with the finishing step's command + /// filled in. What both launchers — the daemon's and the app's pane — type. + public func sessionPrompt(forProjectPath projectPath: String?) -> String? { + guard let prompt = sessionPrompt else { return nil } + guard let projectPath, prompt.hasSuffix(Self.reportDoneSentence) else { return prompt } + return String(prompt.dropLast(Self.reportDoneSentence.count)) + + Self.reportDoneSentence(projectPath: projectPath, nodeID: id) + } + /// The opening prompt this node's `zmx` session should run, or `nil` when there is /// nothing to say. One place so `ZmxSessionLauncher` (daemon) and `LoopWorkspaceView` /// (app) can never disagree about what a loop starts with. @@ -328,7 +352,13 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { + "\(task) Do not schedule your own /loop, wakeup, or cron for it — the " + "orchestrator holds the timer. Stay in the session between heartbeats." case .goalBased: - return goal?.sessionPrompt(directive: backend.capabilities.goalDirective) + guard let prompt = goal?.sessionPrompt(directive: backend.capabilities.goalDirective) + else { return nil } + // A backend whose verdict the daemon cannot read resolves a goal with no predicate + // only when its session reports it met. The briefing says so, but a session follows + // its prompt first: OpenCode and pi loops finished their work and never reported. + guard !backend.recordsGoalVerdict, goal?.effectivePredicate == nil else { return prompt } + return prompt + " " + Self.reportDoneSentence case .turnBased: return Self.turnBasedPrompt( instruction: firstInstruction, check: checkDescription, diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index f9de9e5f..2af0a1ff 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -155,7 +155,9 @@ public enum SessionBriefing { always want.** The loop starts immediately and resolves when its goal is met. Add `--predicate ` only when a command can actually decide it (exit 0 means met, e.g. a test run); without one, it resolves when its backend records the - goal as met or when it runs `graphcode node done`. + goal as met or when it runs `graphcode node done`. **If you are a goal loop, run + `graphcode node done ` once your goal is met** + — never while you are still waiting on mail, CI, or loops you created. \(timeBullet.trimmingCharacters(in: .whitespacesAndNewlines)) - `--type turn --check ` — for work a **human** must review each turn before it continues. **A turn-based loop does not start on its own**: diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 6d354789..a16340f4 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -791,7 +791,11 @@ public actor GraphStore { recordMemory( node.id, "created by \(parent.title) — report results to it with: " - + "graphcode node send \(graph.project.path) \(creator.uuidString) ") + + "graphcode node send \(graph.project.path) \(creator.uuidString) " + + (node.loopType == .goalBased + ? "; once your goal is met, also run: graphcode node done " + + "\(graph.project.path) \(node.id.uuidString) " + : "")) } if node.runsUnattended { // Start it now rather than waiting for someone to open it — the loop is supposed @@ -3124,6 +3128,17 @@ public actor GraphStore { // simply vanished. The message now lands in the target's log, its next wake reads // it, and the sender is told the truth about what happened rather than either // "delivered" or a dead end. + // A follow-up question to a finished loop whose session is still up reaches it. The + // graph calls a resolved loop "not live" so edges and wakes leave it alone, but a + // human asking what it did is the point of keeping the session; the answer changes + // nothing about how it resolved (#346). + if target.state == .succeeded || target.state == .failed, + target.backend.capabilities.supportsMidSessionInput, + await onSessionAlive?(target, graph.project.path) == true, + await deliverToSession(target, message) + { + return + } if MessageBus.deliverability(to: target) != nil { recordMemory(nodeID, "while you were away: \(message)") announceError( diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index fd739641..72664c49 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -957,7 +957,9 @@ public enum ZmxSessionLauncher { forNode node: LoopNode, projectPath: String? = nil, settings: GraphcodeSettings = GraphcodeSettingsStore.load() ) -> [String]? { - guard let prompt = node.sessionPrompt, !prompt.isEmpty else { return nil } + guard let prompt = node.sessionPrompt(forProjectPath: projectPath), !prompt.isEmpty else { + return nil + } // A backend graphcode can't launch has no argv. `canHost` already refuses to create // such a node, so this is the belt to that braces — but silently starting the wrong // agent is the failure it exists to prevent, so it's worth both. @@ -1113,25 +1115,61 @@ public enum ZmxSessionLauncher { let promptFile = NodeMemory.writePrompt( filePrompt, projectPath: projectPath, nodeID: node.id) else { return unbriefedCommand } - let pointer = NodeMemory.promptPointer( + let plainPointer = NodeMemory.promptPointer( toPromptAt: remote == nil ? promptFile.path : RemoteGraphAccess.promptPath(forProjectPath: projectPath, nodeID: node.id)) + let directive = node.backend.capabilities.goalDirective let promptDirectory = remote == nil ? promptFile.deletingLastPathComponent().path : RemoteGraphAccess.memoryDirectory(forProjectPath: projectPath, nodeID: node.id) - let pointeredCommand = shed( - prompt: pointer, briefingPath: briefingPath, extraPath: promptDirectory) - if Self.fitsInATypedCommandLine(pointeredCommand) { return pointeredCommand } + // Longest first: the goal's opening words help its evaluator, the directive is what + // arms the goal at all, and the briefing outranks both (issue #345) — so the head + // shrinks before the directive goes, and the directive goes before the briefing. + let pointers = + Self.pointerHeadLengths.map { + Self.directiveLedPointer( + plainPointer, prompt: singleLine, directive: directive, headLength: $0) + } + [plainPointer] + for pointer in pointers { + let pointeredCommand = shed( + prompt: pointer, briefingPath: briefingPath, extraPath: promptDirectory) + if Self.fitsInATypedCommandLine(pointeredCommand) { return pointeredCommand } + } // Deep support-directory paths can push briefing plus pointer past the line even // now. Only then does the briefing go, keeping whichever prompt form is shorter. if Self.fitsInATypedCommandLine(unbriefedCommand) { return unbriefedCommand } - return shed(prompt: pointer, briefingPath: nil, extraPath: promptDirectory) + let shortestLed = Self.directiveLedPointer( + plainPointer, prompt: singleLine, directive: directive, headLength: 0) + return shed(prompt: shortestLed, briefingPath: nil, extraPath: promptDirectory) } return command } + /// The typed pointer for a prompt that moved to a file, still opening with the backend's + /// goal directive when the prompt did. `/goal` inside a file is prose: the session read + /// its instructions and never armed the goal, so its backend recorded no verdict (#346). + /// The start of the condition rides along — enough for the backend's evaluator, and for + /// `GoalVerdictReader` to match the verdict to this goal. + static func directiveLedPointer( + _ pointer: String, prompt: String, directive: String?, + headLength: Int = pointerHeadLengths[0] + ) -> String { + guard let directive, prompt.hasPrefix(directive + " ") else { return pointer } + guard headLength > 0 else { return "\(directive) \(pointer)" } + let condition = prompt.dropFirst(directive.count + 1) + var head = String(condition.prefix(headLength)) + if condition.count > head.count, let space = head.lastIndex(of: " ") { + head = String(head[.., memory: LockIsolated<[String]> + ) async -> (GraphStore, UUID) { + let store = GraphStore( + onDeliverMessage: { _, text, _ in + delivered.withValue { $0.append(text) } + return true + }, + onSessionAlive: { _, _ in alive }, + onAppendMemory: { _, entry in memory.withValue { $0.append(entry) } }) + await store.handle( + .createNode( + NodeDraft(title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write it")))) + let id = await store.graph.nodes[0].id + await store.handle(.completeNode(id, result: nil, from: id)) + return (store, id) + } + + @Test + func aQuestionToAFinishedLoopWithALiveSessionIsTypedInAndChangesNothing() async { + let delivered = LockIsolated<[String]>([]) + let memory = LockIsolated<[String]>([]) + let (store, id) = await finishedTarget(alive: true, delivered: delivered, memory: memory) + let resolution = await store.graph.nodes[id: id]?.resolution + + await store.handle(.messageNode(id, text: "what did you change?", from: nil, followUp: false)) + + #expect(delivered.value.contains { $0.contains("what did you change?") }) + #expect(!memory.value.contains { $0.hasPrefix("while you were away") }) + #expect(await store.graph.nodes[id: id]?.state == .succeeded) + #expect(await store.graph.nodes[id: id]?.resolution == resolution) + } + + @Test + func aQuestionToAFinishedLoopWhoseSessionEndedIsStaged() async { + let delivered = LockIsolated<[String]>([]) + let memory = LockIsolated<[String]>([]) + let (store, id) = await finishedTarget(alive: false, delivered: delivered, memory: memory) + + await store.handle(.messageNode(id, text: "what did you change?", from: nil, followUp: false)) + + #expect(!delivered.value.contains { $0.contains("what did you change?") }) + #expect(memory.value.contains { $0.hasPrefix("while you were away") }) + } + + // MARK: - /goal stays the command when the prompt moves to a file + + @Test + func aPointerForADirectiveLedPromptStillOpensWithTheDirective() { + let pointer = "Your complete instructions are in the file at /x/PROMPT.md - read it." + let led = ZmxSessionLauncher.directiveLedPointer( + pointer, prompt: "/goal Write the docs for the login flow", directive: "/goal") + #expect(led == "/goal Write the docs for the login flow - \(pointer)") + #expect(led.hasPrefix("/goal ")) + + let long = "/goal " + String(repeating: "word ", count: 80) + let cut = ZmxSessionLauncher.directiveLedPointer(pointer, prompt: long, directive: "/goal") + #expect(cut.hasPrefix("/goal word")) + #expect(cut.contains("... - \(pointer)")) + + #expect( + ZmxSessionLauncher.directiveLedPointer( + pointer, prompt: "/goal Write the docs", directive: "/goal", headLength: 0) + == "/goal \(pointer)") + #expect( + ZmxSessionLauncher.directiveLedPointer(pointer, prompt: "Work toward it", directive: nil) + == pointer) + #expect( + ZmxSessionLauncher.directiveLedPointer(pointer, prompt: "Plain prose", directive: "/goal") + == pointer) + } + + @Test + func aLongCodexGoalLaunchesWithGoalAsTheCommand() { + let goal = String(repeating: "Write the single line into the file and verify it. ", count: 60) + let node = LoopNode( + title: "Long", loopType: .goalBased, goal: GoalSpec(summary: goal), backend: .codex) + defer { NodeMemory.remove(projectPath: "/tmp", nodeID: node.id) } + + let arguments = + ZmxSessionLauncher.arguments( + forNode: node, projectPath: "/tmp", settings: GraphcodeSettings()) ?? [] + + let typedPrompt = arguments.first { $0.contains(NodeMemory.promptFileName) } + #expect(typedPrompt?.hasPrefix("/goal Write the single line") == true) + } + + // MARK: - A backend with no verdict of its own is told to report done + + @Test + func openCodeAndPiGoalsAreToldToRunNodeDone() { + for backend in [CLISessionBackendKind.openCode, .pi] { + let node = LoopNode( + title: "a", loopType: .goalBased, goal: GoalSpec(summary: "Ship it"), backend: backend) + #expect(node.sessionPrompt?.hasSuffix(LoopNode.reportDoneSentence) == true) + } + for backend in [CLISessionBackendKind.claudeCode, .codex, .copilotCLI] { + let node = LoopNode( + title: "a", loopType: .goalBased, goal: GoalSpec(summary: "Ship it"), backend: backend) + #expect(node.sessionPrompt?.contains("graphcode node done") == false) + } + let pi = LoopNode( + title: "a", loopType: .goalBased, goal: GoalSpec(summary: "Ship it"), backend: .pi) + let literal = pi.sessionPrompt(forProjectPath: "/Volumes/SCG/wd/graphcode") ?? "" + #expect( + literal.contains( + "run: graphcode node done /Volumes/SCG/wd/graphcode \(pi.id.uuidString) ")) + #expect(!literal.contains(LoopNode.reportDoneSentence)) + let predicated = LoopNode( + title: "a", loopType: .goalBased, goal: GoalSpec(summary: "Ship it", predicate: "true"), + backend: .pi) + #expect(predicated.sessionPrompt?.contains("graphcode node done") == false) + } + + @Test + func aChildGoalLoopIsHandedTheDoneCommandAtBirth() async { + let memory = LockIsolated<[(UUID, String)]>([]) + let store = GraphStore(onAppendMemory: { id, entry in memory.withValue { $0.append((id, entry)) } }) + await store.handle( + .createNode(NodeDraft(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: "Lead")))) + let leader = await store.graph.nodes[0].id + await store.handle( + .createNode( + NodeDraft( + title: "Child", loopType: .goalBased, goal: GoalSpec(summary: "Child work"), + backend: .pi, createdBy: leader))) + let child = await store.graph.nodes[1].id + + let birth = memory.value.first { $0.0 == child }?.1 ?? "" + #expect(birth.contains("graphcode node send")) + #expect(birth.contains("graphcode node done")) + #expect(birth.contains(child.uuidString)) + } +} diff --git a/graphcode/Tests/ZmxSessionLauncherTests.swift b/graphcode/Tests/ZmxSessionLauncherTests.swift index e6f3d3e9..ccc33fa8 100644 --- a/graphcode/Tests/ZmxSessionLauncherTests.swift +++ b/graphcode/Tests/ZmxSessionLauncherTests.swift @@ -325,9 +325,11 @@ struct ZmxSessionLauncherTests { // The whole point: what gets typed survives the tty. #expect(ZmxSessionLauncher.fitsInATypedCommandLine(arguments)) - // The typed prompt is the pointer, not the goal. + // The typed prompt is the pointer, not the goal — at most the goal's opening words + // ride ahead of it, so a `/goal` directive stays the command (#346). let typed = arguments.last ?? "" - #expect(!typed.contains("CONFLICT SCOPE")) + #expect(!typed.contains(goal)) + #expect(typed.components(separatedBy: "CONFLICT SCOPE").count <= 3) #expect(typed.contains(NodeMemory.promptFileName)) // And the file carries the full goal, nothing dropped mid-string. let file = NodeMemory.directory(forProjectPath: "/tmp", nodeID: node.id)