From 7ee81f87710d34205dedc23dc0d70a3c3af96388 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 3 Oct 2026 08:28:27 -0700 Subject: [PATCH] Nod: keep answering after the goal is met - Runtime: once the goal holds, later turns are follow-up chat. They are never re-checked against the goal or sent back to it, which made Nod answer a follow-up and then repeat the original task. - Daemon: resumeSession also starts an unresolved chat-surface loop's session when none is running. A Nod pane has no terminal whose attach would start one, so a main Nod loop opened in the app never ran. - App: opening a Nod pane asks for its session; a command that finds no runtime (a finished loop's session ended to free the machine) asks for it, shows Starting Nod, and is retried for up to 30 seconds instead of failing. Co-Authored-By: Claude Opus 5.5 Signed-off-by: scgopi --- GraphcodeKit/Sources/GraphStore.swift | 14 ++++++ GraphcodeKit/Sources/IPC/DaemonProtocol.swift | 3 +- NodRuntime/src/runtime.ts | 11 +++-- NodRuntime/test/runtime.test.ts | 15 ++++++ .../LoopWorkspaceFeature+Nod.swift | 10 ++++ .../LoopWorkspace/LoopWorkspaceFeature.swift | 2 +- .../Sources/Features/Nod/NodChatFeature.swift | 46 +++++++++++++++---- .../Features/Nod/NodChatPaneView.swift | 7 +++ graphcode/Tests/NodChatFeatureTests.swift | 37 +++++++++++++++ graphcode/Tests/NodChatWiringTests.swift | 13 +++++- graphcode/Tests/NodLaunchTests.swift | 24 ++++++++++ 11 files changed, 168 insertions(+), 14 deletions(-) diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 44dc1a96..77f124cf 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -2535,6 +2535,10 @@ public actor GraphStore { /// banked, a remote loop — would otherwise wait for a launch that never comes. The met /// goal is never issued again: a session that cannot be resumed opens on a note instead. private func resumeResolvedSession(_ nodeID: UUID) async { + if let node = graph.nodes[id: nodeID], !node.isResolved { + await ensureChatSession(node) + return + } guard let node = graph.nodes[id: nodeID], node.isResolved, node.state != .stopped, let onResumeSession else { return } @@ -2550,6 +2554,16 @@ public actor GraphStore { scheduleSessionEnd(nodeID) } + /// A chat-surface loop (Nod) has no terminal pane whose attach would start its session, + /// so opening it, or sending to it with nothing running, asks for one here. Unattended + /// loops already run; this starts the rest, and is a no-op while a session is alive. + private func ensureChatSession(_ node: LoopNode) async { + guard node.backend.surface == .chat, node.state != .stopped, + await onSessionAlive?(node, graph.project.path) != true + else { return } + ensureSession(node) + } + /// Arms the end of a resolved loop's session, after the grace the Settings choose — long /// enough for the resolution ask to be answered. No grace configured keeps it. private func scheduleSessionEnd(_ nodeID: UUID, confirming: Bool = false) { diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 1cdf9618..f7ecf615 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -238,7 +238,8 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// composite restarts its workers. case restartNode(UUID) /// Bring a resolved loop's ended session back on its transcript — sent when a human - /// opens the loop. Never re-issues the met goal. + /// opens the loop. Never re-issues the met goal. For a chat-surface (Nod) loop that is + /// not resolved, starts its session if none is running: no terminal pane does that. case resumeSession(UUID) /// `restartNode` for every unresolved loop in the graph, workers included. case restartSessions diff --git a/NodRuntime/src/runtime.ts b/NodRuntime/src/runtime.ts index 8f03ab87..ce6085eb 100644 --- a/NodRuntime/src/runtime.ts +++ b/NodRuntime/src/runtime.ts @@ -78,6 +78,8 @@ export class NodRuntime { private compactRequested = false; private runFailed = false; private goalContinuations = 0; + /** Set once the goal holds: later turns are follow-up chat, never sent back to the goal. */ + private goalMet = false; private toolStarts = new Map(); private recentTools: string[] = []; private lastUsage?: UsageReport; @@ -213,7 +215,7 @@ export class NodRuntime { case "markGoalDone": if (!this.goal) throw new Error("this loop has no goal"); this.goal.markDone(); - if (!this.busy) await this.goal.check(this.turn, { lastMessage: "", toolResults: [] }); + if (!this.busy && (await this.goal.check(this.turn, { lastMessage: "", toolResults: [] })).met) this.goalMet = true; return; } } @@ -307,14 +309,17 @@ export class NodRuntime { } const stoppedEarly = this.stopRequested || result.interrupted || this.runFailed; - if (this.goal && !stoppedEarly && this.queue.length === 0) await this.checkGoal(turn, result.lastMessage); + if (this.goal && !this.goalMet && !stoppedEarly && this.queue.length === 0) await this.checkGoal(turn, result.lastMessage); } private async checkGoal(turn: number, lastMessage: string): Promise { const goal = this.goal!; void this.options.presence.presence("busy", "checking the goal"); const verdict = await goal.check(turn, { lastMessage, toolResults: this.recentTools.slice(-20) }); - if (verdict.met) return; + if (verdict.met) { + this.goalMet = true; + return; + } const limit = this.options.maxGoalContinuations ?? 20; if (this.goalContinuations >= limit) { this.options.log.append({ type: "activity", line: `Goal not met after ${limit} checks · waiting for you` }); diff --git a/NodRuntime/test/runtime.test.ts b/NodRuntime/test/runtime.test.ts index 1868d200..4e8d4429 100644 --- a/NodRuntime/test/runtime.test.ts +++ b/NodRuntime/test/runtime.test.ts @@ -365,6 +365,21 @@ describe("goal loops", () => { expect(engine.asks[0]!.model).toBe("haiku"); }); + test("once the goal holds, a follow-up is answered as chat and never sent back to the goal", async () => { + const engine = new FakeEngine( + [() => ({ lastMessage: "Fixed." }), () => ({ lastMessage: "Octopuses have three hearts." })], + [() => met], + ); + const { runtime, records } = setup(engine, { loopType: "goal", goal }); + await runtime.start(); + await runtime.whenIdle(); + runtime.send("Now tell me a fun fact.", "queue"); + await runtime.whenIdle(); + expect(engine.turns).toEqual([goal, "Now tell me a fun fact."]); + expect(records.filter((r) => r.type === "goalCheck").map((r) => r.type === "goalCheck" && [r.turn, r.met])).toEqual([[1, true]]); + expect(records.filter((r) => r.type === "turnStarted").map((r) => r.type === "turnStarted" && r.origin)).toEqual(["user", "user"]); + }); + test("the goal is not checked while more messages are queued", async () => { let release!: () => void; const engine = new FakeEngine([async () => (await new Promise((r) => (release = r)), { lastMessage: "" }), () => ({ lastMessage: "" })], [() => met]); diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature+Nod.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature+Nod.swift index 0d92ed41..d985723b 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature+Nod.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature+Nod.swift @@ -60,9 +60,19 @@ extension LoopWorkspaceFeature { case .editPolicyChosen(let policy): state.nodChat?.editPolicy = policy return .run { _ in await nodSettings.setEditPolicy(policy) } + + case .runtimeNeeded: + return requestNodSession(state) } } + /// Asks graphcoded for this loop's session: resumed if it ended, started if it never ran. + /// A no-op while one is alive, so opening the pane can ask unconditionally. + func requestNodSession(_ state: State) -> Effect { + let nodeID = state.node.id + return nodGraphEffect(state) { send in try await send(.resumeSession(nodeID)) } + } + func nodGraphLayer(_ state: inout State, _ action: Action) -> Effect { switch action { case .nodPlanEdited(let plan): diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 8c944a7d..04e27a0e 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -428,7 +428,7 @@ extension LoopWorkspaceFeature { state.nodChat?.loopTitle = node.title state.nodChat?.goal = node.goal?.summary } - return .none + return requestNodSession(state) } /// "Open in zsh tab": typed, not run — the human sees the command at the prompt and diff --git a/graphcode/Sources/Features/Nod/NodChatFeature.swift b/graphcode/Sources/Features/Nod/NodChatFeature.swift index 538fbd2a..c4bedae1 100644 --- a/graphcode/Sources/Features/Nod/NodChatFeature.swift +++ b/graphcode/Sources/Features/Nod/NodChatFeature.swift @@ -41,6 +41,8 @@ struct NodChatFeature { var commentingHunkID: String? var hunkComment = "" var sendError: String? + /// A command is waiting for the runtime the daemon was asked to start or resume. + var isStartingRuntime = false /// `NodCommand.type`s this runtime has refused — their actions show disabled. var unavailableCommands: Set = [] @@ -125,12 +127,16 @@ struct NodChatFeature { case graphCommand(name: String, argument: String) case messageLoop(UUID) case editPolicyChosen(NodSettings.EditPolicy) + /// Nothing is running behind the pane: ask graphcoded to start or resume the session. + case runtimeNeeded } } enum NodCommandOutcome: Equatable { case sent(NodCommand) case failed(NodCommand, NodControlError) + /// The runtime was unreachable; it has been asked for and the command is being retried. + case waitingForRuntime } /// Commands the runtime refuses until the graph layer behind them ships. A refusal of @@ -141,6 +147,10 @@ struct NodChatFeature { @Dependency(\.nodClient) var nodClient @Dependency(\.nodSettings) var nodSettings + @Dependency(\.continuousClock) var clock + + /// How long a command waits for a runtime that is being started or resumed. + static let runtimeWaitAttempts = 30 var body: some ReducerOf { Reduce { state, action in @@ -298,12 +308,19 @@ struct NodChatFeature { state.sendError = nil return .none + case .commandFinished(.waitingForRuntime): + state.isStartingRuntime = true + state.sendError = nil + return .send(.delegate(.runtimeNeeded)) + case .commandFinished(.sent(let command)): + state.isStartingRuntime = false state.sendError = nil if case .setModel(let payload) = command { state.chosenModel = payload.model } return persistAlwaysAllow(command, state) case .commandFinished(.failed(let command, let error)): + state.isStartingRuntime = false if case .rejected = error, Self.gatedCommands.contains(command.type) { state.unavailableCommands.insert(command.type) return .none @@ -352,15 +369,28 @@ struct NodChatFeature { private func command(_ command: NodCommand, _ state: State) -> Effect { let directory = state.stateDirectory - return .run { send in - do { - try await nodClient.send(directory, command) - await send(.commandFinished(.sent(command))) - } catch let error as NodControlError { - await send(.commandFinished(.failed(command, error))) - } catch { - await send(.commandFinished(.failed(command, .unreachable(error.localizedDescription)))) + return .run { [clock] send in + var lastError = NodControlError.unreachable("no reply") + for attempt in 0..([]) + let nod = TestStore(initialState: workspace(backend: .nod)) { + LoopWorkspaceFeature() + } withDependencies: { + $0.orchestratorClient.send = { request in requests.withValue { $0.append("\(request)") } } + } nod.exhaustivity = .off await nod.send(.chatSurfaceAppeared) + await nod.finish() + // No terminal attach starts a chat loop's session, so opening the pane asks for it. + #expect(requests.value.count == 1) + #expect(requests.value.first?.contains("resumeSession(\(nod.state.node.id))") == true) #expect(nod.state.nodChat?.goal == "every paid route enforces the cap") #expect(nod.state.nodChat?.loopType == .goalBased) #expect(nod.state.nodChat?.nodeID == nod.state.node.id) @@ -49,6 +58,7 @@ struct NodWorkspaceWiringTests { LoopWorkspaceFeature() } withDependencies: { $0.nodSettings.current = { settings } + $0.orchestratorClient.send = { _ in } } store.exhaustivity = .off await store.send(.chatSurfaceAppeared) @@ -72,6 +82,7 @@ struct NodWorkspaceWiringTests { baseDirectory: FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString)) $0.terminalSurfaceClient.typeText = { id, text in box.typed.append((id, text)) } + $0.orchestratorClient.send = { _ in } } store.exhaustivity = .off await store.send(.chatSurfaceAppeared) diff --git a/graphcode/Tests/NodLaunchTests.swift b/graphcode/Tests/NodLaunchTests.swift index 208e323c..396eb43a 100644 --- a/graphcode/Tests/NodLaunchTests.swift +++ b/graphcode/Tests/NodLaunchTests.swift @@ -1,3 +1,4 @@ +import ComposableArchitecture import Foundation import Testing @@ -576,6 +577,29 @@ struct NodGraphStoreTests { #expect(resolved?.resolution?.detail == "1 of 1 clauses met") } + /// A chat pane has no terminal whose attach would start a session, so opening a Nod loop + /// asks for one. A terminal backend's loop is left to its pane. + @Test + func openingAChatLoopStartsItsSessionOnlyWhenNoneIsRunning() async { + let nod = LoopNode(title: "Nod", loopType: .sketch, backend: .nod) + let claude = LoopNode(title: "Claude", loopType: .sketch) + let started = LockIsolated<[UUID]>([]) + let alive = LockIsolated(false) + var graph = LoopGraph(project: ProjectRef(path: "", name: "p")) + graph.nodes.append(contentsOf: [nod, claude]) + let store = GraphStore( + graph: graph, + onEnsureSession: { node, _ in started.withValue { $0.append(node.id) } }, + onSessionAlive: { _, _ in alive.value }) + + await store.handle(.resumeSession(claude.id)) + await store.handle(.resumeSession(nod.id)) + alive.setValue(true) + await store.handle(.resumeSession(nod.id)) + + #expect(started.value == [nod.id]) + } + @Test func aNodLoopWaitingOnAHumanSaysWhatItAsks() async { let nod = LoopNode(title: "Nod", loopType: .turnBased, backend: .nod, state: .running)