Skip to content

Commit 339b405

Browse files
scgopiclaude
andcommitted
Stop a loop whose backend CLI is not on PATH and say so
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d6d54c2 commit 339b405

10 files changed

Lines changed: 486 additions & 7 deletions

File tree

GraphcodeKit/Sources/Domain/LoopNode.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,9 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
192192
/// `GraphStore` at the moment of the stall; `nil` for loops stalled before the field
193193
/// existed, and for stalls whose cause the graph had nothing to say about.
194194
public var stallReason: String?
195+
/// Set when the daemon stopped this loop because its backend's CLI is not on the
196+
/// launch shell's PATH; cleared by the restart that follows the fix.
197+
public var launchFailure: LaunchFailure?
195198
public var state: LoopState
196199
public var createdAt: Date
197200

@@ -492,7 +495,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
492495
case lastMailroomRead, mailroomWatch
493496
case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly
494497
case summary, board, heartbeatIntervalSeconds, stallReason
495-
case createdFromTemplateID, templateFollow, sessionRestarts
498+
case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure
496499
}
497500

498501
/// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph`
@@ -548,6 +551,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable {
548551
try container.decodeIfPresent(MailroomWatch.self, forKey: .mailroomWatch)
549552
?? decoder.legacyMailroomValue(MailroomWatch.self, "artifactoryWatch")
550553
stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason)
554+
launchFailure = try container.decodeIfPresent(LaunchFailure.self, forKey: .launchFailure)
551555
state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle
552556
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
553557
}

GraphcodeKit/Sources/GraphStore.swift

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ public actor GraphStore {
290290
deliveryDeadline: Duration = .seconds(45),
291291
onGraphChanged: (@Sendable (LoopGraph) -> Void)? = nil,
292292
onEnsureSession: (@Sendable (LoopNode, String?) -> Void)? = nil,
293+
onFindMissingProvider: (@Sendable (LoopNode, String?) async -> LaunchFailure?)? = nil,
293294
onTerminateSession: (@Sendable (LoopNode, String?) -> Void)? = nil,
294295
onRestartSession: (@Sendable (LoopNode, String?) async -> Bool)? = nil,
295296
onEvaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? = nil,
@@ -325,6 +326,7 @@ public actor GraphStore {
325326
self.subGraphDepth = subGraphDepth
326327
self.onGraphChanged = onGraphChanged
327328
self.onEnsureSession = onEnsureSession
329+
self.onFindMissingProvider = onFindMissingProvider
328330
self.onTerminateSession = onTerminateSession
329331
self.onRestartSession = onRestartSession
330332
self.onEvaluatePredicate = onEvaluatePredicate
@@ -423,6 +425,54 @@ public actor GraphStore {
423425
/// it; see `resolvedForLaunch`.
424426
private func ensureSession(_ node: LoopNode) {
425427
onEnsureSession?(resolvedForLaunch(node), graph.project.path)
428+
guard onFindMissingProvider != nil else { return }
429+
Task { await self.stopIfProviderMissing(node) }
430+
}
431+
432+
/// Whether the node's backend CLI is missing from the launch shell's PATH
433+
/// (`ProviderPath`). Asked beside the launch rather than before it: `ensureSession` is
434+
/// synchronous and a login shell takes a moment, and a launch whose CLI is missing
435+
/// only makes a session that exits at once — which the stop kills anyway.
436+
private let onFindMissingProvider: (@Sendable (LoopNode, String?) async -> LaunchFailure?)?
437+
438+
private func stopIfProviderMissing(_ node: LoopNode) async {
439+
guard let onFindMissingProvider,
440+
let failure = await onFindMissingProvider(node, graph.project.path),
441+
stopForMissingProvider(node.id, failure)
442+
else { return }
443+
broadcast()
444+
}
445+
446+
/// A stop rather than a failure: nothing the loop did went wrong, and the restart that
447+
/// follows the fix must be allowed (`restartNode`). Killed rather than asked, because
448+
/// there is no agent in the session to ask.
449+
@discardableResult
450+
private func stopForMissingProvider(_ nodeID: UUID, _ failure: LaunchFailure) -> Bool {
451+
guard let node = graph.nodes[id: nodeID], !node.isResolved else { return false }
452+
setNodeState(nodeID, .stopped)
453+
graph.nodes[id: nodeID]?.launchFailure = failure
454+
cancelGoalPoller(nodeID)
455+
cancelHeartbeat(nodeID)
456+
recordMemory(
457+
nodeID,
458+
"stopped: \(failure.title) — install \(failure.backend.displayName) or add the folder "
459+
+ "containing \(failure.executable) to the login shell's PATH, then restart the loop")
460+
terminateSession(node)
461+
fireOutgoingEdges(from: nodeID, sourceSucceeded: false)
462+
return true
463+
}
464+
465+
/// The restart after the fix. `sessionRestarts` moves because it is the app's cue to
466+
/// remount the workspace it closed for the restart (`SessionRestart.pendingReopen`).
467+
private func relaunchAfterMissingProvider(_ node: LoopNode, _ failure: LaunchFailure) {
468+
graph.nodes[id: node.id]?.launchFailure = nil
469+
graph.nodes[id: node.id]?.sessionRestarts += 1
470+
setNodeState(node.id, node.runsUnattended ? .running : .idle)
471+
recordMemory(node.id, "restarted after \(failure.title) — launching again")
472+
guard node.runsUnattended, let relaunched = graph.nodes[id: node.id] else { return }
473+
if relaunched.loopType == .goalBased { armGoalPoller(for: relaunched) }
474+
armHeartbeat(for: relaunched)
475+
ensureSession(relaunched)
426476
}
427477

428478
// MARK: - Template follows
@@ -1273,6 +1323,11 @@ public actor GraphStore {
12731323
guard graph.nodes[id: node.id]?.presence != reading else { continue }
12741324
graph.nodes[id: node.id]?.presence = reading
12751325
changed = true
1326+
// The backstop for a session no launch of ours checked: a zsh that exits 127 could
1327+
// not find its command, and the probe says whether that command was the agent.
1328+
if reading.exitCode == ProviderPath.commandNotFoundStatus, onFindMissingProvider != nil {
1329+
Task { await self.stopIfProviderMissing(node) }
1330+
}
12761331
}
12771332
if refreshActiveDependents() { changed = true }
12781333
return changed
@@ -2116,12 +2171,18 @@ public actor GraphStore {
21162171

21172172
/// Kills a loop's session and brings it back on the same transcript — see
21182173
/// `GraphCommand.restartNode`. A resolved loop has no session worth bringing back and
2119-
/// a stopped one was told to stay down, so both are refused rather than revived.
2174+
/// a stopped one was told to stay down, so both are refused rather than revived — except
2175+
/// a loop stopped for a missing CLI, which nobody told to stay down and whose restart
2176+
/// is exactly what its dialog asks the human for once the CLI is installed.
21202177
private func restartNode(_ nodeID: UUID) async {
21212178
guard let node = graph.nodes[id: nodeID] else {
21222179
announceError("no loop \(nodeID) in this graph")
21232180
return
21242181
}
2182+
if node.state == .stopped, let failure = node.launchFailure {
2183+
relaunchAfterMissingProvider(node, failure)
2184+
return
2185+
}
21252186
guard !node.isResolved else {
21262187
announceError("\(node.title) has finished — there is no session to restart")
21272188
return
@@ -2241,6 +2302,15 @@ public actor GraphStore {
22412302
/// memory, so a state nobody expected can be traced to the report that caused it.
22422303
private func sessionPermitsResolution(_ nodeID: UUID, succeeded: Bool) async -> Bool {
22432304
guard let node = graph.nodes[id: nodeID], !node.isResolved else { return true }
2305+
// An agent the launch shell could not find exits at once, which a pane reports exactly
2306+
// like an agent that finished — the loop resolved SUCCEEDED having never run. Asked
2307+
// before the restart grace, because a restart whose CLI is still missing exits in it.
2308+
if succeeded, let onFindMissingProvider,
2309+
let failure = await onFindMissingProvider(node, graph.project.path)
2310+
{
2311+
stopForMissingProvider(nodeID, failure)
2312+
return false
2313+
}
22442314
let report =
22452315
"surface reported its pane "
22462316
+ (succeeded ? "finished" : "closed with its process still running")
@@ -3525,7 +3595,9 @@ public actor GraphStore {
35253595
/// existing session first — `zmx run` itself is *not* idempotent, and re-running it
35263596
/// against a live session types the prompt in a second time.
35273597
public func ensureUnattendedSessions() {
3528-
for node in graph.nodes where node.runsUnattended {
3598+
// A loop stopped for a missing CLI waits for the human's restart: relaunching it at
3599+
// boot would only reach the same missing CLI and raise the same dialog.
3600+
for node in graph.nodes where node.runsUnattended && node.launchFailure == nil {
35293601
if node.loopType == .goalBased {
35303602
guard !node.isResolved else { continue }
35313603
armGoalPoller(for: node)

GraphcodeKit/Sources/ProjectRegistry.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,9 @@ public actor ProjectRegistry {
642642
Task { await self?.refreshAwakeAssertion() }
643643
},
644644
onEnsureSession: ensureSession,
645+
onFindMissingProvider: { node, path in
646+
await ProviderPath.missingProvider(for: node, projectPath: path)
647+
},
645648
onTerminateSession: terminateSession,
646649
onRestartSession: restartSession,
647650
onEvaluatePredicate: evaluatePredicate,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Foundation
2+
3+
/// Why the daemon stopped a loop whose backend CLI the launch shell could not find —
4+
/// carried on the node (`LoopNode.launchFailure`) so every client can say so, and so
5+
/// `GraphStore.restartNode` knows this stop is one the human is allowed to undo.
6+
public struct LaunchFailure: Codable, Equatable, Sendable {
7+
public var executable: String
8+
public var backend: CLISessionBackendKind
9+
public var occurredAt: Date
10+
11+
public init(executable: String, backend: CLISessionBackendKind, occurredAt: Date = Date()) {
12+
self.executable = executable
13+
self.backend = backend
14+
self.occurredAt = occurredAt
15+
}
16+
17+
public var title: String { "\(executable) is not on your PATH" }
18+
19+
public var message: String {
20+
"GraphCode couldn't find \(executable), the \(backend.displayName) command-line tool, so "
21+
+ "the loop was stopped instead of left running without an agent. Loops start their "
22+
+ "agent from a login shell (/bin/zsh -i -l): install \(backend.displayName), or add "
23+
+ "the folder containing \(executable) to PATH in ~/.zshrc or ~/.zprofile, then "
24+
+ "restart the loop."
25+
}
26+
}
27+
28+
/// Whether a backend's CLI resolves the way a session launch resolves it. Without this a
29+
/// missing CLI produced a session whose shell exited 127 at once while the graph went on
30+
/// reporting the loop as running — or, reported by its pane, as SUCCEEDED.
31+
public enum ProviderPath {
32+
/// What zsh exits with for a command it cannot find.
33+
public static let commandNotFoundStatus = 127
34+
35+
/// The same `-i -l` shell the launches use (`ZmxSessionLauncher.loginShellInvocation`,
36+
/// `GhosttyTerminalView.interactiveLoginShell`), since a developer's PATH usually comes
37+
/// from `~/.zshrc`. `whence -p` rather than `command -v`: the launch `exec`s the agent,
38+
/// which only a file on PATH satisfies, so an alias of the same name must not count.
39+
public static func probeInvocation(for executable: String) -> [String] {
40+
[
41+
"/bin/zsh", "-i", "-l", "-c",
42+
"whence -p -- \(RemoteProjectLocation.shellQuoted(executable)) >/dev/null 2>&1",
43+
]
44+
}
45+
46+
/// `nil` when the shell did not answer in time: a slow `~/.zshrc` says nothing about
47+
/// PATH, and must never be what stops a loop.
48+
public static func isOnPath(_ executable: String, deadline: Duration = .seconds(15)) async
49+
-> Bool?
50+
{
51+
if await FoundCache.shared.isFresh(executable) { return true }
52+
let invocation = probeInvocation(for: executable)
53+
guard
54+
let shell = invocation.first,
55+
let session = try? PTYProcessSession(
56+
executable: shell, arguments: Array(invocation.dropFirst()))
57+
else { return nil }
58+
guard let found = await withDeadline(deadline, { await session.waitUntilFinished() }) else {
59+
session.terminate()
60+
return nil
61+
}
62+
if found { await FoundCache.shared.record(executable) }
63+
return found
64+
}
65+
66+
/// The failure launching `node` would hit, or `nil`. Always `nil` for a remote project:
67+
/// its PATH belongs to another machine, which this shell cannot see.
68+
public static func missingProvider(for node: LoopNode, projectPath: String?) async
69+
-> LaunchFailure?
70+
{
71+
if let projectPath, RemoteProjectLocation.parse(projectPath: projectPath) != nil {
72+
return nil
73+
}
74+
guard let executable = node.backend.executableName else { return nil }
75+
guard await isOnPath(executable) == false else { return nil }
76+
return LaunchFailure(executable: executable, backend: node.backend)
77+
}
78+
79+
/// Found answers only, and briefly: a daemon loading a graph ensures every unattended
80+
/// loop at once, and one login shell per loop for the same answer is waste. A missing
81+
/// CLI is never cached, so the check after a fix sees the fix.
82+
private actor FoundCache {
83+
static let shared = FoundCache()
84+
private static let lifetime: TimeInterval = 300
85+
private var foundAt: [String: Date] = [:]
86+
87+
func isFresh(_ executable: String) -> Bool {
88+
guard let found = foundAt[executable] else { return false }
89+
return Date().timeIntervalSince(found) < Self.lifetime
90+
}
91+
92+
func record(_ executable: String) { foundAt[executable] = Date() }
93+
}
94+
}

graphcode/Sources/Features/App/AppFeature+LoopSessions.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ struct SessionRestart: Equatable {
1919
var isConfirmingAll = false
2020
/// The workspace closed for a restart, to be remounted once the daemon confirms.
2121
var pendingReopen: PendingReopen?
22+
/// A loop the daemon stopped because its CLI is not on PATH — see `LaunchFailure`.
23+
var launchFailureNotice: LaunchFailureNotice?
24+
/// Each node's failure already raised, so a snapshot that still carries it does not
25+
/// raise it again on every broadcast.
26+
var announcedLaunchFailures: [UUID: Date] = [:]
2227

2328
struct PendingReopen: Equatable {
2429
var projectPath: String
@@ -28,12 +33,23 @@ struct SessionRestart: Equatable {
2833
var seenRestarts: Int
2934
}
3035

36+
struct LaunchFailureNotice: Equatable {
37+
var title: String
38+
var message: String
39+
40+
init(node: LoopNode, failure: LaunchFailure) {
41+
title = failure.title
42+
message = "\(node.title)” was stopped. \(failure.message)"
43+
}
44+
}
45+
3146
@CasePathable
3247
enum Action: Equatable {
3348
case openLoopTapped
3449
case allTapped
3550
case allConfirmed
3651
case allCancelled
52+
case launchFailureNoticeDismissed
3753
}
3854
}
3955

@@ -116,7 +132,12 @@ extension AppFeature {
116132
}
117133
}
118134

135+
case .sessionRestart(.launchFailureNoticeDismissed):
136+
state.sessionRestart.launchFailureNotice = nil
137+
return .none
138+
119139
case .daemonEvent(.graphChanged(let graph)):
140+
announceLaunchFailures(in: graph, &state)
120141
guard let pending = state.sessionRestart.pendingReopen,
121142
pending.projectPath == graph.project.path
122143
else { return .none }
@@ -144,6 +165,23 @@ extension AppFeature {
144165
}
145166
}
146167

168+
/// A snapshot is how the app learns the daemon stopped a loop for a missing CLI,
169+
/// whichever process launched its session. A project's first snapshot only records
170+
/// what it already carries: those loops say why on their cards, and a stack of alerts
171+
/// at launch would be about the past.
172+
private func announceLaunchFailures(in graph: LoopGraph, _ state: inout State) {
173+
let firstSight = state.projects[id: graph.project.path] == nil
174+
for node in graph.nodes {
175+
guard let failure = node.launchFailure,
176+
state.sessionRestart.announcedLaunchFailures[node.id] != failure.occurredAt
177+
else { continue }
178+
state.sessionRestart.announcedLaunchFailures[node.id] = failure.occurredAt
179+
guard !firstSight, state.sessionRestart.launchFailureNotice == nil else { continue }
180+
state.sessionRestart.launchFailureNotice = SessionRestart.LaunchFailureNotice(
181+
node: node, failure: failure)
182+
}
183+
}
184+
147185
private func closeForRestart(_ state: inout State, reopening open: LoopWorkspaceFeature.State) {
148186
let current =
149187
state.projects[id: open.projectPath]?.graph.nodes[id: open.node.id]?.sessionRestarts

graphcode/Sources/Features/App/AppFeature.swift

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,10 +579,7 @@ struct AppFeature {
579579
}
580580
closeOpenWorkspace(&state)
581581
state.selectedProjectPath = projectPath
582-
return .run { _ in
583-
try? await orchestratorClient.send(
584-
.graphCommand(projectPath: projectPath, command: .deleteNode(id)))
585-
}
582+
return deleteAcknowledgedLoop(id, in: projectPath, state)
586583

587584
// Closing the workspace's last tab — by its x, by ⌘W, or by a plain shell simply
588585
// exiting. There is nothing left to show, which for a loop means ending the loop,

graphcode/Sources/Features/App/AppView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,9 @@ struct AppView: View {
255255
// Why a tap on a gated loop did nothing — same hosting rule again: the tap can
256256
// come from the sidebar or ⇧⌘] while any detail pane is up.
257257
.modifier(BlockedLoopDialog(store: store))
258+
// A loop stopped because its CLI is not on PATH — the daemon finds it whichever
259+
// project the loop is in, so it is hosted with the rest.
260+
.modifier(LaunchFailureDialog(store: store))
258261
}
259262

260263
/// Folders past their worktree notice threshold, for the titlebar chip. Policies
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import ComposableArchitecture
2+
import Foundation
3+
import GraphcodeKit
4+
import SwiftUI
5+
6+
/// The alert for `SessionRestart.launchFailureNotice`.
7+
struct LaunchFailureDialog: ViewModifier {
8+
let store: StoreOf<AppFeature>
9+
10+
func body(content: Content) -> some View {
11+
content
12+
.alert(
13+
store.sessionRestart.launchFailureNotice?.title ?? "",
14+
isPresented: Binding(
15+
get: { store.sessionRestart.launchFailureNotice != nil },
16+
set: { if !$0 { store.send(.sessionRestart(.launchFailureNoticeDismissed)) } }
17+
)
18+
) {
19+
Button("OK") { store.send(.sessionRestart(.launchFailureNoticeDismissed)) }
20+
} message: {
21+
Text(store.sessionRestart.launchFailureNotice?.message ?? "")
22+
}
23+
}
24+
}
25+
26+
extension AppFeature {
27+
/// The deletion a key on a dead agent pane stands for (`.primaryExitAcknowledged`) —
28+
/// except for a loop stopped for a missing CLI, which is waiting for the restart its
29+
/// dialog asked for. The key only puts that pane away.
30+
func deleteAcknowledgedLoop(_ id: UUID, in projectPath: String, _ state: State)
31+
-> Effect<Action>
32+
{
33+
guard state.projects[id: projectPath]?.graph.nodes[id: id]?.launchFailure == nil else {
34+
return .none
35+
}
36+
return .run { _ in
37+
try? await orchestratorClient.send(
38+
.graphCommand(projectPath: projectPath, command: .deleteNode(id)))
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)