From 339b405bdbc8d747eabbbdc523e68c085f347b21 Mon Sep 17 00:00:00 2001 From: scgopi Date: Sat, 12 Sep 2026 16:11:25 -0700 Subject: [PATCH] Stop a loop whose backend CLI is not on PATH and say so Co-Authored-By: Claude Opus 5 (1M context) --- GraphcodeKit/Sources/Domain/LoopNode.swift | 6 +- GraphcodeKit/Sources/GraphStore.swift | 76 +++++- GraphcodeKit/Sources/ProjectRegistry.swift | 3 + .../Sources/Sessions/ProviderPath.swift | 94 ++++++++ .../App/AppFeature+LoopSessions.swift | 38 +++ .../Sources/Features/App/AppFeature.swift | 5 +- graphcode/Sources/Features/App/AppView.swift | 3 + .../Features/App/LaunchFailureDialog.swift | 41 ++++ .../Canvas/LoopCardPresentation.swift | 1 + graphcode/Tests/ProviderNotOnPathTests.swift | 226 ++++++++++++++++++ 10 files changed, 486 insertions(+), 7 deletions(-) create mode 100644 GraphcodeKit/Sources/Sessions/ProviderPath.swift create mode 100644 graphcode/Sources/Features/App/LaunchFailureDialog.swift create mode 100644 graphcode/Tests/ProviderNotOnPathTests.swift diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 42804355..9976bebc 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -192,6 +192,9 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// `GraphStore` at the moment of the stall; `nil` for loops stalled before the field /// existed, and for stalls whose cause the graph had nothing to say about. public var stallReason: String? + /// Set when the daemon stopped this loop because its backend's CLI is not on the + /// launch shell's PATH; cleared by the restart that follows the fix. + public var launchFailure: LaunchFailure? public var state: LoopState public var createdAt: Date @@ -492,7 +495,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case lastMailroomRead, mailroomWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds, stallReason - case createdFromTemplateID, templateFollow, sessionRestarts + case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure } /// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph` @@ -548,6 +551,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { try container.decodeIfPresent(MailroomWatch.self, forKey: .mailroomWatch) ?? decoder.legacyMailroomValue(MailroomWatch.self, "artifactoryWatch") stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason) + launchFailure = try container.decodeIfPresent(LaunchFailure.self, forKey: .launchFailure) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index bb296441..275ec5e8 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -290,6 +290,7 @@ public actor GraphStore { deliveryDeadline: Duration = .seconds(45), onGraphChanged: (@Sendable (LoopGraph) -> Void)? = nil, onEnsureSession: (@Sendable (LoopNode, String?) -> Void)? = nil, + onFindMissingProvider: (@Sendable (LoopNode, String?) async -> LaunchFailure?)? = nil, onTerminateSession: (@Sendable (LoopNode, String?) -> Void)? = nil, onRestartSession: (@Sendable (LoopNode, String?) async -> Bool)? = nil, onEvaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? = nil, @@ -325,6 +326,7 @@ public actor GraphStore { self.subGraphDepth = subGraphDepth self.onGraphChanged = onGraphChanged self.onEnsureSession = onEnsureSession + self.onFindMissingProvider = onFindMissingProvider self.onTerminateSession = onTerminateSession self.onRestartSession = onRestartSession self.onEvaluatePredicate = onEvaluatePredicate @@ -423,6 +425,54 @@ public actor GraphStore { /// it; see `resolvedForLaunch`. private func ensureSession(_ node: LoopNode) { onEnsureSession?(resolvedForLaunch(node), graph.project.path) + guard onFindMissingProvider != nil else { return } + Task { await self.stopIfProviderMissing(node) } + } + + /// Whether the node's backend CLI is missing from the launch shell's PATH + /// (`ProviderPath`). Asked beside the launch rather than before it: `ensureSession` is + /// synchronous and a login shell takes a moment, and a launch whose CLI is missing + /// only makes a session that exits at once — which the stop kills anyway. + private let onFindMissingProvider: (@Sendable (LoopNode, String?) async -> LaunchFailure?)? + + private func stopIfProviderMissing(_ node: LoopNode) async { + guard let onFindMissingProvider, + let failure = await onFindMissingProvider(node, graph.project.path), + stopForMissingProvider(node.id, failure) + else { return } + broadcast() + } + + /// A stop rather than a failure: nothing the loop did went wrong, and the restart that + /// follows the fix must be allowed (`restartNode`). Killed rather than asked, because + /// there is no agent in the session to ask. + @discardableResult + private func stopForMissingProvider(_ nodeID: UUID, _ failure: LaunchFailure) -> Bool { + guard let node = graph.nodes[id: nodeID], !node.isResolved else { return false } + setNodeState(nodeID, .stopped) + graph.nodes[id: nodeID]?.launchFailure = failure + cancelGoalPoller(nodeID) + cancelHeartbeat(nodeID) + recordMemory( + nodeID, + "stopped: \(failure.title) — install \(failure.backend.displayName) or add the folder " + + "containing \(failure.executable) to the login shell's PATH, then restart the loop") + terminateSession(node) + fireOutgoingEdges(from: nodeID, sourceSucceeded: false) + return true + } + + /// The restart after the fix. `sessionRestarts` moves because it is the app's cue to + /// remount the workspace it closed for the restart (`SessionRestart.pendingReopen`). + private func relaunchAfterMissingProvider(_ node: LoopNode, _ failure: LaunchFailure) { + graph.nodes[id: node.id]?.launchFailure = nil + graph.nodes[id: node.id]?.sessionRestarts += 1 + setNodeState(node.id, node.runsUnattended ? .running : .idle) + recordMemory(node.id, "restarted after \(failure.title) — launching again") + guard node.runsUnattended, let relaunched = graph.nodes[id: node.id] else { return } + if relaunched.loopType == .goalBased { armGoalPoller(for: relaunched) } + armHeartbeat(for: relaunched) + ensureSession(relaunched) } // MARK: - Template follows @@ -1273,6 +1323,11 @@ public actor GraphStore { guard graph.nodes[id: node.id]?.presence != reading else { continue } graph.nodes[id: node.id]?.presence = reading changed = true + // The backstop for a session no launch of ours checked: a zsh that exits 127 could + // not find its command, and the probe says whether that command was the agent. + if reading.exitCode == ProviderPath.commandNotFoundStatus, onFindMissingProvider != nil { + Task { await self.stopIfProviderMissing(node) } + } } if refreshActiveDependents() { changed = true } return changed @@ -2116,12 +2171,18 @@ public actor GraphStore { /// Kills a loop's session and brings it back on the same transcript — see /// `GraphCommand.restartNode`. A resolved loop has no session worth bringing back and - /// a stopped one was told to stay down, so both are refused rather than revived. + /// a stopped one was told to stay down, so both are refused rather than revived — except + /// a loop stopped for a missing CLI, which nobody told to stay down and whose restart + /// is exactly what its dialog asks the human for once the CLI is installed. private func restartNode(_ nodeID: UUID) async { guard let node = graph.nodes[id: nodeID] else { announceError("no loop \(nodeID) in this graph") return } + if node.state == .stopped, let failure = node.launchFailure { + relaunchAfterMissingProvider(node, failure) + return + } guard !node.isResolved else { announceError("\(node.title) has finished — there is no session to restart") return @@ -2241,6 +2302,15 @@ public actor GraphStore { /// memory, so a state nobody expected can be traced to the report that caused it. private func sessionPermitsResolution(_ nodeID: UUID, succeeded: Bool) async -> Bool { guard let node = graph.nodes[id: nodeID], !node.isResolved else { return true } + // An agent the launch shell could not find exits at once, which a pane reports exactly + // like an agent that finished — the loop resolved SUCCEEDED having never run. Asked + // before the restart grace, because a restart whose CLI is still missing exits in it. + if succeeded, let onFindMissingProvider, + let failure = await onFindMissingProvider(node, graph.project.path) + { + stopForMissingProvider(nodeID, failure) + return false + } let report = "surface reported its pane " + (succeeded ? "finished" : "closed with its process still running") @@ -3525,7 +3595,9 @@ public actor GraphStore { /// existing session first — `zmx run` itself is *not* idempotent, and re-running it /// against a live session types the prompt in a second time. public func ensureUnattendedSessions() { - for node in graph.nodes where node.runsUnattended { + // A loop stopped for a missing CLI waits for the human's restart: relaunching it at + // boot would only reach the same missing CLI and raise the same dialog. + for node in graph.nodes where node.runsUnattended && node.launchFailure == nil { if node.loopType == .goalBased { guard !node.isResolved else { continue } armGoalPoller(for: node) diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index ff30334f..bf628a7e 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -642,6 +642,9 @@ public actor ProjectRegistry { Task { await self?.refreshAwakeAssertion() } }, onEnsureSession: ensureSession, + onFindMissingProvider: { node, path in + await ProviderPath.missingProvider(for: node, projectPath: path) + }, onTerminateSession: terminateSession, onRestartSession: restartSession, onEvaluatePredicate: evaluatePredicate, diff --git a/GraphcodeKit/Sources/Sessions/ProviderPath.swift b/GraphcodeKit/Sources/Sessions/ProviderPath.swift new file mode 100644 index 00000000..e9633ea9 --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/ProviderPath.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Why the daemon stopped a loop whose backend CLI the launch shell could not find — +/// carried on the node (`LoopNode.launchFailure`) so every client can say so, and so +/// `GraphStore.restartNode` knows this stop is one the human is allowed to undo. +public struct LaunchFailure: Codable, Equatable, Sendable { + public var executable: String + public var backend: CLISessionBackendKind + public var occurredAt: Date + + public init(executable: String, backend: CLISessionBackendKind, occurredAt: Date = Date()) { + self.executable = executable + self.backend = backend + self.occurredAt = occurredAt + } + + public var title: String { "\(executable) is not on your PATH" } + + public var message: String { + "GraphCode couldn't find \(executable), the \(backend.displayName) command-line tool, so " + + "the loop was stopped instead of left running without an agent. Loops start their " + + "agent from a login shell (/bin/zsh -i -l): install \(backend.displayName), or add " + + "the folder containing \(executable) to PATH in ~/.zshrc or ~/.zprofile, then " + + "restart the loop." + } +} + +/// Whether a backend's CLI resolves the way a session launch resolves it. Without this a +/// missing CLI produced a session whose shell exited 127 at once while the graph went on +/// reporting the loop as running — or, reported by its pane, as SUCCEEDED. +public enum ProviderPath { + /// What zsh exits with for a command it cannot find. + public static let commandNotFoundStatus = 127 + + /// The same `-i -l` shell the launches use (`ZmxSessionLauncher.loginShellInvocation`, + /// `GhosttyTerminalView.interactiveLoginShell`), since a developer's PATH usually comes + /// from `~/.zshrc`. `whence -p` rather than `command -v`: the launch `exec`s the agent, + /// which only a file on PATH satisfies, so an alias of the same name must not count. + public static func probeInvocation(for executable: String) -> [String] { + [ + "/bin/zsh", "-i", "-l", "-c", + "whence -p -- \(RemoteProjectLocation.shellQuoted(executable)) >/dev/null 2>&1", + ] + } + + /// `nil` when the shell did not answer in time: a slow `~/.zshrc` says nothing about + /// PATH, and must never be what stops a loop. + public static func isOnPath(_ executable: String, deadline: Duration = .seconds(15)) async + -> Bool? + { + if await FoundCache.shared.isFresh(executable) { return true } + let invocation = probeInvocation(for: executable) + guard + let shell = invocation.first, + let session = try? PTYProcessSession( + executable: shell, arguments: Array(invocation.dropFirst())) + else { return nil } + guard let found = await withDeadline(deadline, { await session.waitUntilFinished() }) else { + session.terminate() + return nil + } + if found { await FoundCache.shared.record(executable) } + return found + } + + /// The failure launching `node` would hit, or `nil`. Always `nil` for a remote project: + /// its PATH belongs to another machine, which this shell cannot see. + public static func missingProvider(for node: LoopNode, projectPath: String?) async + -> LaunchFailure? + { + if let projectPath, RemoteProjectLocation.parse(projectPath: projectPath) != nil { + return nil + } + guard let executable = node.backend.executableName else { return nil } + guard await isOnPath(executable) == false else { return nil } + return LaunchFailure(executable: executable, backend: node.backend) + } + + /// Found answers only, and briefly: a daemon loading a graph ensures every unattended + /// loop at once, and one login shell per loop for the same answer is waste. A missing + /// CLI is never cached, so the check after a fix sees the fix. + private actor FoundCache { + static let shared = FoundCache() + private static let lifetime: TimeInterval = 300 + private var foundAt: [String: Date] = [:] + + func isFresh(_ executable: String) -> Bool { + guard let found = foundAt[executable] else { return false } + return Date().timeIntervalSince(found) < Self.lifetime + } + + func record(_ executable: String) { foundAt[executable] = Date() } + } +} diff --git a/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift b/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift index dca35524..4ac1986b 100644 --- a/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift +++ b/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift @@ -19,6 +19,11 @@ struct SessionRestart: Equatable { var isConfirmingAll = false /// The workspace closed for a restart, to be remounted once the daemon confirms. var pendingReopen: PendingReopen? + /// A loop the daemon stopped because its CLI is not on PATH — see `LaunchFailure`. + var launchFailureNotice: LaunchFailureNotice? + /// Each node's failure already raised, so a snapshot that still carries it does not + /// raise it again on every broadcast. + var announcedLaunchFailures: [UUID: Date] = [:] struct PendingReopen: Equatable { var projectPath: String @@ -28,12 +33,23 @@ struct SessionRestart: Equatable { var seenRestarts: Int } + struct LaunchFailureNotice: Equatable { + var title: String + var message: String + + init(node: LoopNode, failure: LaunchFailure) { + title = failure.title + message = "“\(node.title)” was stopped. \(failure.message)" + } + } + @CasePathable enum Action: Equatable { case openLoopTapped case allTapped case allConfirmed case allCancelled + case launchFailureNoticeDismissed } } @@ -116,7 +132,12 @@ extension AppFeature { } } + case .sessionRestart(.launchFailureNoticeDismissed): + state.sessionRestart.launchFailureNotice = nil + return .none + case .daemonEvent(.graphChanged(let graph)): + announceLaunchFailures(in: graph, &state) guard let pending = state.sessionRestart.pendingReopen, pending.projectPath == graph.project.path else { return .none } @@ -144,6 +165,23 @@ extension AppFeature { } } + /// A snapshot is how the app learns the daemon stopped a loop for a missing CLI, + /// whichever process launched its session. A project's first snapshot only records + /// what it already carries: those loops say why on their cards, and a stack of alerts + /// at launch would be about the past. + private func announceLaunchFailures(in graph: LoopGraph, _ state: inout State) { + let firstSight = state.projects[id: graph.project.path] == nil + for node in graph.nodes { + guard let failure = node.launchFailure, + state.sessionRestart.announcedLaunchFailures[node.id] != failure.occurredAt + else { continue } + state.sessionRestart.announcedLaunchFailures[node.id] = failure.occurredAt + guard !firstSight, state.sessionRestart.launchFailureNotice == nil else { continue } + state.sessionRestart.launchFailureNotice = SessionRestart.LaunchFailureNotice( + node: node, failure: failure) + } + } + private func closeForRestart(_ state: inout State, reopening open: LoopWorkspaceFeature.State) { let current = state.projects[id: open.projectPath]?.graph.nodes[id: open.node.id]?.sessionRestarts diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 48561fb2..07742653 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -579,10 +579,7 @@ struct AppFeature { } closeOpenWorkspace(&state) state.selectedProjectPath = projectPath - return .run { _ in - try? await orchestratorClient.send( - .graphCommand(projectPath: projectPath, command: .deleteNode(id))) - } + return deleteAcknowledgedLoop(id, in: projectPath, state) // Closing the workspace's last tab — by its x, by ⌘W, or by a plain shell simply // exiting. There is nothing left to show, which for a loop means ending the loop, diff --git a/graphcode/Sources/Features/App/AppView.swift b/graphcode/Sources/Features/App/AppView.swift index 1ff99206..a94da047 100644 --- a/graphcode/Sources/Features/App/AppView.swift +++ b/graphcode/Sources/Features/App/AppView.swift @@ -255,6 +255,9 @@ struct AppView: View { // Why a tap on a gated loop did nothing — same hosting rule again: the tap can // come from the sidebar or ⇧⌘] while any detail pane is up. .modifier(BlockedLoopDialog(store: store)) + // A loop stopped because its CLI is not on PATH — the daemon finds it whichever + // project the loop is in, so it is hosted with the rest. + .modifier(LaunchFailureDialog(store: store)) } /// Folders past their worktree notice threshold, for the titlebar chip. Policies diff --git a/graphcode/Sources/Features/App/LaunchFailureDialog.swift b/graphcode/Sources/Features/App/LaunchFailureDialog.swift new file mode 100644 index 00000000..e4121031 --- /dev/null +++ b/graphcode/Sources/Features/App/LaunchFailureDialog.swift @@ -0,0 +1,41 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import SwiftUI + +/// The alert for `SessionRestart.launchFailureNotice`. +struct LaunchFailureDialog: ViewModifier { + let store: StoreOf + + func body(content: Content) -> some View { + content + .alert( + store.sessionRestart.launchFailureNotice?.title ?? "", + isPresented: Binding( + get: { store.sessionRestart.launchFailureNotice != nil }, + set: { if !$0 { store.send(.sessionRestart(.launchFailureNoticeDismissed)) } } + ) + ) { + Button("OK") { store.send(.sessionRestart(.launchFailureNoticeDismissed)) } + } message: { + Text(store.sessionRestart.launchFailureNotice?.message ?? "") + } + } +} + +extension AppFeature { + /// The deletion a key on a dead agent pane stands for (`.primaryExitAcknowledged`) — + /// except for a loop stopped for a missing CLI, which is waiting for the restart its + /// dialog asked for. The key only puts that pane away. + func deleteAcknowledgedLoop(_ id: UUID, in projectPath: String, _ state: State) + -> Effect + { + guard state.projects[id: projectPath]?.graph.nodes[id: id]?.launchFailure == nil else { + return .none + } + return .run { _ in + try? await orchestratorClient.send( + .graphCommand(projectPath: projectPath, command: .deleteNode(id))) + } + } +} diff --git a/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift b/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift index af81c9a6..c47280d8 100644 --- a/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift +++ b/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift @@ -100,6 +100,7 @@ struct LoopCardPresentation: Equatable { /// underneath it. With the producer off this is exactly what shipped before. private static func liveLine(_ node: LoopNode, summarising: Bool) -> String? { if node.displayState == .stalled, let why = collapsed(node.stallReason) { return why } + if node.displayState == .stopped, let failure = node.launchFailure { return failure.title } let passes = node.metricHistory.count if summarising, let beat = node.summary?.current?.text, !beat.isEmpty { return passes > 0 ? "pass \(passes) · \(beat)" : beat diff --git a/graphcode/Tests/ProviderNotOnPathTests.swift b/graphcode/Tests/ProviderNotOnPathTests.swift new file mode 100644 index 00000000..b47a24c4 --- /dev/null +++ b/graphcode/Tests/ProviderNotOnPathTests.swift @@ -0,0 +1,226 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import GraphcodeKit +@testable import graphcode + +/// A loop whose backend CLI is not on the launch shell's PATH is stopped with a +/// `LaunchFailure` the app raises as a dialog — instead of a dead session that reads +/// running, or a pane exit that reads SUCCEEDED. +@Suite +struct ProviderNotOnPathTests { + private static let project = ProjectRef(path: "/tmp/provider-path", name: "provider-path") + private static let missing = LaunchFailure( + executable: "pi", backend: .claudeCode, occurredAt: Date(timeIntervalSince1970: 1)) + + private func draft() -> NodeDraft { + NodeDraft(title: "Worker", loopType: .goalBased, goal: GoalSpec(summary: "say hi")) + } + + private func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { + for _ in 0..<300 { + if await condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return false + } + + @Test + func theProbeAsksTheLaunchShellForAFileOnPath() { + let invocation = ProviderPath.probeInvocation(for: "pi") + #expect(Array(invocation.prefix(4)) == ["/bin/zsh", "-i", "-l", "-c"]) + #expect(invocation.last == "whence -p -- 'pi' >/dev/null 2>&1") + } + + @Test(.enabled(if: FileManager.default.isExecutableFile(atPath: "/bin/zsh"))) + func theProbeFindsARealExecutableAndMissesAnInventedOne() async { + #expect(await ProviderPath.isOnPath("ls") == true) + #expect(await ProviderPath.isOnPath("graphcode-no-such-cli-\(UUID().uuidString)") == false) + } + + @Test + func aRemoteProjectIsNeverJudgedByThisMachinesPath() async { + let node = LoopNode(title: "Remote", backend: .claudeCode) + let failure = await ProviderPath.missingProvider( + for: node, projectPath: "ssh://someone@box/~/project") + #expect(failure == nil) + } + + @Test + func theDialogNamesTheExecutableTheBackendAndTheFix() { + let failure = LaunchFailure(executable: "copilot", backend: .copilotCLI) + #expect(failure.title == "copilot is not on your PATH") + #expect(failure.message.contains("Copilot CLI")) + #expect(failure.message.contains("/bin/zsh -i -l")) + #expect(failure.message.contains("restart the loop")) + } + + @Test + func aLaunchWhoseCLIIsMissingStopsTheLoopAndKillsItsSession() async { + let killed = LockIsolated<[UUID]>([]) + let memos = LockIsolated<[String]>([]) + let store = GraphStore( + graph: LoopGraph(project: Self.project), + onEnsureSession: { _, _ in }, + onFindMissingProvider: { _, _ in Self.missing }, + onTerminateSession: { node, _ in killed.withValue { $0.append(node.id) } }, + onAppendMemory: { _, entry in memos.withValue { $0.append(entry) } }) + await store.handle(.createNode(draft())) + let id = await store.graph.nodes[0].id + + #expect(await eventually { await store.graph.nodes[id: id]?.state == .stopped }) + #expect(await store.graph.nodes[id: id]?.launchFailure == Self.missing) + #expect(killed.value.contains(id)) + #expect(memos.value.contains { $0.contains("pi is not on your PATH") }) + } + + @Test + func aLaunchWhoseCLIIsFoundKeepsRunning() async { + let probes = LockIsolated(0) + let store = GraphStore( + graph: LoopGraph(project: Self.project), + onEnsureSession: { _, _ in }, + onFindMissingProvider: { _, _ in + probes.withValue { $0 += 1 } + return nil + }) + await store.handle(.createNode(draft())) + let id = await store.graph.nodes[0].id + + #expect(await eventually { probes.value == 1 }) + await store.handle(.refreshUsage) + #expect(await store.graph.nodes[id: id]?.state == .running) + #expect(await store.graph.nodes[id: id]?.launchFailure == nil) + } + + @Test + func aPaneExitIsAStopRatherThanASuccessWhenTheCLIIsMissing() async { + let isMissing = LockIsolated(false) + let probes = LockIsolated(0) + let store = GraphStore( + graph: LoopGraph(project: Self.project), + onEnsureSession: { _, _ in }, + onFindMissingProvider: { _, _ in + probes.withValue { $0 += 1 } + return isMissing.value ? Self.missing : nil + }) + await store.handle(.createNode(draft())) + let id = await store.graph.nodes[0].id + #expect(await eventually { probes.value == 1 }) + isMissing.setValue(true) + + await store.handle(.nodeCheckApproved(id)) + + #expect(await store.graph.nodes[id: id]?.state == .stopped) + #expect(await store.graph.nodes[id: id]?.launchFailure == Self.missing) + } + + @Test + func restartingAfterTheFixClearsTheFailureAndLaunchesAgain() async { + let isMissing = LockIsolated(true) + let ensured = LockIsolated(0) + let store = GraphStore( + graph: LoopGraph(project: Self.project), + onEnsureSession: { _, _ in ensured.withValue { $0 += 1 } }, + onFindMissingProvider: { _, _ in isMissing.value ? Self.missing : nil }) + await store.handle(.createNode(draft())) + let id = await store.graph.nodes[0].id + #expect(await eventually { await store.graph.nodes[id: id]?.state == .stopped }) + isMissing.setValue(false) + + await store.handle(.restartNode(id)) + + let node = await store.graph.nodes[id: id] + #expect(node?.state == .running) + #expect(node?.launchFailure == nil) + #expect(node?.sessionRestarts == 1) + #expect(ensured.value == 2) + } + + @Test + func aDaemonRestartDoesNotRelaunchALoopStoppedForAMissingCLI() async { + var stopped = LoopNode( + title: "Stopped", loopType: .timeBased, triggerPrompt: "/loop 1h check", state: .stopped) + stopped.launchFailure = Self.missing + let control = LoopNode(title: "Control", loopType: .timeBased, triggerPrompt: "/loop 1h check") + var graph = LoopGraph(project: Self.project) + graph.nodes.append(stopped) + graph.nodes.append(control) + let ensured = LockIsolated<[UUID]>([]) + let store = GraphStore( + graph: graph, onEnsureSession: { node, _ in ensured.withValue { $0.append(node.id) } }) + + await store.ensureUnattendedSessions() + + #expect(ensured.value == [control.id]) + } + + @Test + func theFailureSurvivesTheWireAndAnOlderSnapshotDecodesWithoutIt() throws { + var node = LoopNode(title: "Worker") + node.launchFailure = Self.missing + let decoded = try JSONDecoder().decode(LoopNode.self, from: JSONEncoder().encode(node)) + #expect(decoded.launchFailure == Self.missing) + + let legacy = try JSONDecoder().decode( + LoopNode.self, from: Data(#"{"id":"\#(UUID().uuidString)","title":"Old"}"#.utf8)) + #expect(legacy.launchFailure == nil) + } + + @Test + @MainActor + func theAppRaisesANewFailureOnceAndNotTheOnesAProjectOpenedWith() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.orchestratorClient.send = { _ in } + } + store.exhaustivity = .off + + var old = LoopNode(title: "Old", state: .stopped) + old.launchFailure = Self.missing + var graph = LoopGraph(project: Self.project) + graph.nodes.append(old) + await store.send(.daemonEvent(.graphChanged(graph))) + #expect(store.state.sessionRestart.launchFailureNotice == nil) + + var fresh = LoopNode(title: "Fresh", state: .stopped) + fresh.launchFailure = LaunchFailure(executable: "codex", backend: .codex) + graph.nodes.append(fresh) + await store.send(.daemonEvent(.graphChanged(graph))) + #expect(store.state.sessionRestart.launchFailureNotice?.title == "codex is not on your PATH") + #expect(store.state.sessionRestart.launchFailureNotice?.message.contains("“Fresh”") == true) + + await store.send(.sessionRestart(.launchFailureNoticeDismissed)) + await store.send(.daemonEvent(.graphChanged(graph))) + #expect(store.state.sessionRestart.launchFailureNotice == nil) + } + + @Test + @MainActor + func aKeyOnTheDeadPaneOfAStoppedForMissingCLILoopDoesNotDeleteIt() async { + let sent = LockIsolated<[DaemonCommand]>([]) + var node = LoopNode(title: "Worker", state: .stopped) + node.launchFailure = Self.missing + var graph = LoopGraph(project: Self.project) + graph.nodes.append(node) + var initial = AppFeature.State() + initial.projects.append(ProjectFeature.State(graph: ProjectFeature.holding(graph))) + initial.openLoop = LoopWorkspaceFeature.State( + node: node, graph: graph, layout: TerminalLayout.opening(forNode: node.id, saved: nil), + projectPath: Self.project.path, projectName: Self.project.name) + let store = TestStore(initialState: initial) { + AppFeature() + } withDependencies: { + $0.orchestratorClient.send = { command in sent.withValue { $0.append(command) } } + } + store.exhaustivity = .off + + await store.send(.openLoop(.primaryExitAcknowledged)) + await store.finish() + + #expect(store.state.openLoop == nil) + #expect(sent.value.isEmpty) + } +}