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
6 changes: 5 additions & 1 deletion GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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()
}
Expand Down
76 changes: 74 additions & 2 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions GraphcodeKit/Sources/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions GraphcodeKit/Sources/Sessions/ProviderPath.swift
Original file line number Diff line number Diff line change
@@ -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() }
}
}
38 changes: 38 additions & 0 deletions graphcode/Sources/Features/App/AppFeature+LoopSessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions graphcode/Sources/Features/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions graphcode/Sources/Features/App/AppView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions graphcode/Sources/Features/App/LaunchFailureDialog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import ComposableArchitecture
import Foundation
import GraphcodeKit
import SwiftUI

/// The alert for `SessionRestart.launchFailureNotice`.
struct LaunchFailureDialog: ViewModifier {
let store: StoreOf<AppFeature>

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<Action>
{
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)))
}
}
}
Loading
Loading