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)