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
14 changes: 14 additions & 0 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1504,7 +1504,7 @@
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
onRollbackPlaybook: onRollbackPlaybook,
onAnnounceError: effects.errors.append,

Check warning on line 1507 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

converting non-Sendable function value to '@sendable (String) -> Void' may introduce data races
// The board's gate forwards like any other side effect: a loop inside a piloted
// composite is a real loop whose session got the standard briefing — teaching
// verbs the child store would refuse is exactly the incoherence the gate exists
Expand Down Expand Up @@ -2369,7 +2369,7 @@
pendingFollowUps.append(
PendingFollowUp(id: UUID(), nodeID: nodeID, text: message, watchedPostID: nil))
}
await drainAndBroadcast()

Check warning on line 2372 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:unlessStillAt:)' is unused
}

/// A learned note into a node's memory log — `graphcode node memo`, the agent-written
Expand Down Expand Up @@ -2527,7 +2527,7 @@
let followUp = PendingFollowUp(id: UUID(), nodeID: nodeID, text: prompt, watchedPostID: nil)
pendingFollowUps.append(followUp)
goalFollowUps[nodeID] = followUp.id
await drainAndBroadcast()

Check warning on line 2530 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:unlessStillAt:)' is unused
}

/// Opening a resolved loop whose session was ended brings its conversation back. Panes
Expand All @@ -2535,6 +2535,10 @@
/// 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 }
Expand All @@ -2550,6 +2554,16 @@
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) {
Expand Down Expand Up @@ -3086,7 +3100,7 @@
return
}
if node.loopType == .composite {
await runInSubGraph(nodeID, .restartSessions, broadcastErrors: false)

Check warning on line 3103 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
return
}
await restart([node])
Expand All @@ -3095,7 +3109,7 @@
private func restartSessions() async {
let live = graph.nodes.filter { !$0.isResolved }
for composite in live where composite.loopType == .composite {
await runInSubGraph(composite.id, .restartSessions, broadcastErrors: false)

Check warning on line 3112 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
}
await restart(live.filter { $0.loopType != .composite })
}
Expand Down Expand Up @@ -3151,7 +3165,7 @@
// set below — a graph whose nodes have all stopped aggregates to `.idle`.
if node.loopType == .composite, let subGraph = node.subGraph {
for child in subGraph.nodes where !child.isResolved {
await runInSubGraph(

Check warning on line 3168 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'runInSubGraph(_:_:broadcastErrors:)' is unused
node.id, .stopNode(child.id), broadcastErrors: false)
}
}
Expand Down Expand Up @@ -4381,7 +4395,7 @@
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
onRollbackPlaybook: onRollbackPlaybook,
onAnnounceError: effects.errors.append,

Check warning on line 4398 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

converting non-Sendable function value to '@sendable (String) -> Void' may introduce data races
onMailroomEnabled: onMailroomEnabled,
goalCache: goalCache,
recurrence: effects.recurrence,
Expand All @@ -4397,7 +4411,7 @@
processRecurrence(effects.recurrence)
graph.nodes[id: ownerID]?.subGraph = await child.graph
rollUpComposite(ownerID)
await drainAndBroadcast()

Check warning on line 4414 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:unlessStillAt:)' is unused
}

/// Applies the recurrence requests a child store handed up, in order — an update's
Expand Down Expand Up @@ -4468,7 +4482,7 @@
now.timeIntervalSince(node.createdAt) >= stallAfter
{
markStalled(nodeID)
await drainAndBroadcast()

Check warning on line 4485 in GraphcodeKit/Sources/GraphStore.swift

View workflow job for this annotation

GitHub Actions / macos

result of call to 'drainAndBroadcast(broadcastErrors:unlessStillAt:)' is unused
return
}

Expand Down
3 changes: 2 additions & 1 deletion GraphcodeKit/Sources/IPC/DaemonProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions NodRuntime/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { tool: string; at: number }>();
private recentTools: string[] = [];
private lastUsage?: UsageReport;
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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<void> {
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` });
Expand Down
15 changes: 15 additions & 0 deletions NodRuntime/test/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((r) => (release = r)), { lastMessage: "" }), () => ({ lastMessage: "" })], [() => met]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Action> {
let nodeID = state.node.id
return nodGraphEffect(state) { send in try await send(.resumeSession(nodeID)) }
}

func nodGraphLayer(_ state: inout State, _ action: Action) -> Effect<Action> {
switch action {
case .nodPlanEdited(let plan):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 38 additions & 8 deletions graphcode/Sources/Features/Nod/NodChatFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []

Expand Down Expand Up @@ -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
Expand All @@ -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<Self> {
Reduce { state, action in
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -352,15 +369,28 @@ struct NodChatFeature {

private func command(_ command: NodCommand, _ state: State) -> Effect<Action> {
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..<Self.runtimeWaitAttempts {
do {
try await nodClient.send(directory, command)
await send(.commandFinished(.sent(command)))
return
} catch let error as NodControlError {
// A finished loop's session may have been ended to free the machine, and a chat
// loop has no terminal whose attach would start one: ask for it, then retry.
guard case .unreachable = error else {
await send(.commandFinished(.failed(command, error)))
return
}
lastError = error
} catch {
lastError = .unreachable(error.localizedDescription)
}
if attempt == 0 { await send(.commandFinished(.waitingForRuntime)) }
try await clock.sleep(for: .seconds(1))
}
await send(.commandFinished(.failed(command, lastError)))
}
}

Expand Down
7 changes: 7 additions & 0 deletions graphcode/Sources/Features/Nod/NodChatPaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,13 @@ struct NodChatPaneView: View {
onCompact: { store.send(.compactNowTapped) },
onRaiseCap: { store.send(.raiseCapTapped) })
}
if store.isStartingRuntime {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Starting Nod…").font(.system(size: 11.5)).foregroundStyle(.secondary)
Spacer()
}
}
if let error = store.sendError {
HStack(spacing: 8) {
Text(error).font(.system(size: 11.5)).foregroundStyle(NodStyle.failed)
Expand Down
37 changes: 37 additions & 0 deletions graphcode/Tests/NodChatFeatureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,43 @@ struct NodChatFeatureTests {
return store
}

/// A finished loop's session may have ended, and a chat pane cannot attach one into
/// being: an unreachable runtime is asked for, and the message is delivered once it is up.
@Test
func aMessageToAStoppedRuntimeAsksForItAndIsDeliveredWhenItIsUp() async {
let attempts = LockIsolated(0)
let clock = TestClock()
var state = NodChatFeature.State(
nodeID: UUID(), stateDirectory: Self.directory, loopTitle: "Monetization",
loopType: .goalBased, goal: "Done when every paid route enforces the cap")
state.draft = "one more thing"
let store = TestStore(initialState: state) {
NodChatFeature()
} withDependencies: {
$0.continuousClock = clock
$0.nodClient.send = { _, _ in
let attempt = attempts.withValue {
$0 += 1
return $0
}
if attempt < 3 { throw NodControlError.unreachable("connect: 2") }
}
}
store.exhaustivity = .off

await store.send(.returnPressed)
await store.skipReceivedActions()
#expect(store.state.isStartingRuntime)
#expect(store.state.sendError == nil)

await clock.advance(by: .seconds(2))
await store.skipReceivedActions()

#expect(!store.state.isStartingRuntime)
#expect(store.state.sendError == nil)
#expect(attempts.value == 3)
}

@Test
func theTailFeedsTheTranscript() async {
let log = NodLog.monetization
Expand Down
13 changes: 12 additions & 1 deletion graphcode/Tests/NodChatWiringTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,18 @@ struct NodWorkspaceWiringTests {
}
await store.send(.chatSurfaceAppeared)

let nod = TestStore(initialState: workspace(backend: .nod)) { LoopWorkspaceFeature() }
let requests = LockIsolated<[String]>([])
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)
Expand All @@ -49,6 +58,7 @@ struct NodWorkspaceWiringTests {
LoopWorkspaceFeature()
} withDependencies: {
$0.nodSettings.current = { settings }
$0.orchestratorClient.send = { _ in }
}
store.exhaustivity = .off
await store.send(.chatSurfaceAppeared)
Expand All @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions graphcode/Tests/NodLaunchTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ComposableArchitecture
import Foundation
import Testing

Expand Down Expand Up @@ -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)
Expand Down
Loading