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)