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
10 changes: 10 additions & 0 deletions GraphcodeKit/Sources/Domain/BackendCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) "
+ "<one-line result> - 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.
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion GraphcodeKit/Sources/Domain/SessionBriefing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ public enum SessionBriefing {
always want.** The loop starts immediately and resolves when its goal is met. Add
`--predicate <shell command>` 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 <project-path> <your-node-id> <result>` 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 <what a human verifies>` — for work a **human** must review
each turn before it continues. **A turn-based loop does not start on its own**:
Expand Down
17 changes: 16 additions & 1 deletion GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) <message>")
+ "graphcode node send \(graph.project.path) \(creator.uuidString) <message>"
+ (node.loopType == .goalBased
? "; once your goal is met, also run: graphcode node done "
+ "\(graph.project.path) \(node.id.uuidString) <result>"
: ""))
}
if node.runsUnattended {
// Start it now rather than waiting for someone to open it — the loop is supposed
Expand Down Expand Up @@ -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(
Expand Down
50 changes: 44 additions & 6 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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[..<space]) + "..."
}
return "\(directive) \(head) - \(pointer)"
}

/// The condition heads tried, longest first. 120 carries a goal summary's opening words
/// past a `Done when:` prefix; 0 keeps only the directive, when the line has no more room.
static let pointerHeadLengths = [120, 40, 0]

/// `zmx run` argv that resumes an existing backend session instead of starting fresh.
///
/// Used after a reboot: the zmx session is gone, but a persisted session ID lets the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ struct LoopWorkspaceView: View {
// bare, and extra tabs/splits are plain shells either way. A succeeded loop's goal is
// met: opening it resumes the conversation, and never starts that goal again.
initialPrompt: ref.launchesClaudeCode && store.node.state != .succeeded
? store.node.sessionPrompt : nil,
? store.node.sessionPrompt(forProjectPath: store.projectPath) : nil,
// A node without its own worktree yet still belongs to a project — its shells
// should open there, not wherever the app process happened to launch from. A
// global-graph loop belongs to no folder at all: home, the same answer the
Expand Down
145 changes: 145 additions & 0 deletions graphcode/Tests/GoalResolutionFollowUpTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import ComposableArchitecture
import Foundation
import Testing

@testable import GraphcodeKit

/// The gaps the live five-backend run of #346 found on 0.1.70-beta2.
@Suite
struct GoalResolutionFollowUpTests {
// MARK: - A follow-up question reaches a finished loop

private func finishedTarget(
alive: Bool, delivered: LockIsolated<[String]>, 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) <one-line result>"))
#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))
}
}
6 changes: 4 additions & 2 deletions graphcode/Tests/ZmxSessionLauncherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading