@@ -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)
0 commit comments