diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 8d7f9ab5..d72c0e13 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -20,6 +20,10 @@ public enum GraphcodeCommand: Equatable, Sendable { case createNode(projectPath: String, draft: NodeDraft, into: UUID? = nil) case createEdge(projectPath: String, from: UUID, to: UUID, spec: EdgeSpec) case stopNode(projectPath: String, nodeID: UUID) + /// Kill the loop's session and resume it on the same transcript — the verb for a + /// replaced `zmx` or backend CLI. `restartSessions` does it for every live loop. + case restartNode(projectPath: String, nodeID: UUID) + case restartSessions(projectPath: String) case deleteNode(projectPath: String, nodeID: UUID) case sendMessage(projectPath: String, nodeID: UUID, text: String, followUp: Bool = false) case updateNode(projectPath: String, nodeID: UUID, update: NodeUpdate) @@ -83,6 +87,9 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode status graphcode node create --title --type [options] graphcode node stop + graphcode node restart kill its session and resume it on + the same transcript — for a replaced zmx or backend CLI + graphcode sessions restart the same, for every live loop graphcode node delete removes it, its edges, session and memory — irreversible; stop is the reversible verb graphcode node send [--follow-up] @@ -300,6 +307,13 @@ public enum GraphcodeCommand: Equatable, Sendable { try validateFlags(arguments, allowed: []) return .usage(projectPath: path) + case "sessions": + let verb = try take(&arguments, name: "sessions subcommand") + guard verb == "restart" else { throw ParseError.unknownCommand("sessions \(verb)") } + let path = try take(&arguments, name: "project-path") + try validateFlags(arguments, allowed: []) + return .restartSessions(projectPath: path) + case "reap": if arguments.contains(where: isHelpFlag) { throw HelpRequested() } let flags = try parseReapFlags(arguments) @@ -334,7 +348,8 @@ public enum GraphcodeCommand: Equatable, Sendable { into = id } return .createNode(projectPath: path, draft: try parseDraft(arguments), into: into) - case "stop", "delete", "pilot", "arm", "send", "update", "memo", "promote", "refine": + case "stop", "restart", "delete", "pilot", "arm", "send", "update", "memo", "promote", + "refine": let raw = try take(&arguments, name: "node-id") guard let nodeID = UUID(uuidString: raw) else { throw ParseError.invalidValue(argument: "node-id", value: raw) @@ -349,6 +364,9 @@ public enum GraphcodeCommand: Equatable, Sendable { case "delete": try validateFlags(arguments, allowed: []) return .deleteNode(projectPath: path, nodeID: nodeID) + case "restart": + try validateFlags(arguments, allowed: []) + return .restartNode(projectPath: path, nodeID: nodeID) case "promote": return .promoteNode( projectPath: path, nodeID: nodeID, promotion: try parsePromotion(arguments)) diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index c7ccdf74..3ec8476b 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -98,6 +98,11 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// What the backend has reported spending on this loop, if anything. Never estimated — /// see `UsageSample`. public var usage: UsageSample? + /// How many times the session has been restarted in place (`GraphCommand.restartNode`). + /// The number means nothing; a *change* in it is the daemon's word that the old session + /// is confirmed dead, which is what the app waits for before reattaching a pane. A pane + /// attached any earlier joins the dying session and reads its exit as the loop resolving. + public var sessionRestarts = 0 /// The last thing the session said it was doing — `"editing UsageReport.swift"`. /// /// Reported, never inferred, by exactly the mechanism `presence` and `usage` use: a @@ -486,7 +491,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case lastArtifactoryRead, artifactoryWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds, stallReason - case createdFromTemplateID, templateFollow + case createdFromTemplateID, templateFollow, sessionRestarts } /// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph` @@ -514,6 +519,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { subGraph = try container.decodeIfPresent(LoopGraph.self, forKey: .subGraph) pilotState = try container.decodeIfPresent(PilotState.self, forKey: .pilotState) ?? .notPiloted usage = try container.decodeIfPresent(UsageSample.self, forKey: .usage) + sessionRestarts = try container.decodeIfPresent(Int.self, forKey: .sessionRestarts) ?? 0 activity = try container.decodeIfPresent(String.self, forKey: .activity) // Unlike `presence` and `activity`, this survives a reload: pass summaries are the // account of a run, and a resolved loop's is the thing worth reading after the fact. diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index c621d71a..34701560 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -35,6 +35,10 @@ public actor GraphStore { private let onGraphChanged: (@Sendable (LoopGraph) -> Void)? private let onEnsureSession: (@Sendable (LoopNode, String?) -> Void)? private let onTerminateSession: (@Sendable (LoopNode, String?) -> Void)? + /// Kills a loop's session and, for an unattended loop, relaunches it on the same + /// transcript. Awaited, unlike the two above: the answer is whether the old session + /// is confirmed gone, and `restartNode` must not say so until it is. + private let onRestartSession: (@Sendable (LoopNode, String?) async -> Bool)? private let onEvaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? /// `onEvaluatePredicate` with the evidence kept: pass/fail plus the run's output tail /// (`ShellPredicateEvaluator.check`). Goal polling prefers this when wired, so a @@ -196,6 +200,7 @@ public actor GraphStore { onGraphChanged: (@Sendable (LoopGraph) -> Void)? = nil, onEnsureSession: (@Sendable (LoopNode, String?) -> Void)? = nil, onTerminateSession: (@Sendable (LoopNode, String?) -> Void)? = nil, + onRestartSession: (@Sendable (LoopNode, String?) async -> Bool)? = nil, onEvaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? = nil, onCheckPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)? = nil, onDeliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? = nil, @@ -226,6 +231,7 @@ public actor GraphStore { self.onGraphChanged = onGraphChanged self.onEnsureSession = onEnsureSession self.onTerminateSession = onTerminateSession + self.onRestartSession = onRestartSession self.onEvaluatePredicate = onEvaluatePredicate self.onCheckPredicate = onCheckPredicate self.onDeliverMessage = onDeliverMessage @@ -647,6 +653,12 @@ public actor GraphStore { case .stopNode(let nodeID): await stopNode(nodeID) + case .restartNode(let nodeID): + await restartNode(nodeID) + + case .restartSessions: + await restartSessions() + case .subGraphCommand(let nodeID, let inner): await runInSubGraph(nodeID, inner) @@ -715,7 +727,7 @@ public actor GraphStore { case .nodeCheckApproved(let id), .nodeCheckRejected(let id), .renameNode(let id, _), .updateNode(let id, _), .promoteNode(let id, _, _), .memoNode(let id, _, _), .refineNode(let id, _, _), .rollbackRefinement(let id, _), .messageNode(let id, _, _, _), - .deleteNode(let id), .stopNode(let id): + .deleteNode(let id), .stopNode(let id), .restartNode(let id): guard let ownerID = subGraphOwner(of: id) else { return nil } return .subGraphCommand(nodeID: ownerID, command: command) default: @@ -767,6 +779,7 @@ public actor GraphStore { // store. Stopping is still forwarded below: those sessions are real once piloted. onEnsureSession: nil, onTerminateSession: onTerminateSession, + onRestartSession: onRestartSession, onEvaluatePredicate: onEvaluatePredicate, onCheckPredicate: onCheckPredicate, onDeliverMessage: onDeliverMessage, @@ -1862,6 +1875,59 @@ 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. + private func restartNode(_ nodeID: UUID) async { + guard let node = graph.nodes[id: nodeID] else { + announceError("no loop \(nodeID) in this graph") + return + } + guard !node.isResolved else { + announceError("\(node.title) has finished — there is no session to restart") + return + } + if node.loopType == .composite { + await runInSubGraph(nodeID, .restartSessions) + return + } + await restart([node]) + } + + private func restartSessions() async { + let live = graph.nodes.filter { !$0.isResolved } + for composite in live where composite.loopType == .composite { + await runInSubGraph(composite.id, .restartSessions) + } + await restart(live.filter { $0.loopType != .composite }) + } + + /// The kills run concurrently: each one waits on `zmx` to confirm a death, and a dozen + /// loops in sequence would hold this actor for as long as their kills add up to. The + /// bump is written only for a confirmed kill — it is the app's cue to reattach, and a + /// pane reattached to a session that would not die reads the eventual exit as the + /// loop resolving. + private func restart(_ nodes: [LoopNode]) async { + guard let onRestartSession else { return } + let path = graph.project.path + let confirmed = await withTaskGroup(of: (UUID, Bool).self) { group in + for node in nodes { + group.addTask { (node.id, await onRestartSession(node, path)) } + } + var results: [UUID: Bool] = [:] + for await (id, died) in group { results[id] = died } + return results + } + for node in nodes { + if confirmed[node.id] == true { + graph.nodes[id: node.id]?.sessionRestarts += 1 + recordMemory(node.id, "session restarted in place, resumed from its transcript") + } else { + announceError("could not restart \(node.title): its session did not die") + } + } + } + /// Stops one loop: the session is *asked* to stop rather than killed. /// /// Killing the PTY took the whole agent with it — the transcript, the scrollback, and @@ -2641,6 +2707,7 @@ public actor GraphStore { GraphStore( graph: subGraph, onTerminateSession: onTerminateSession, + onRestartSession: onRestartSession, onEvaluatePredicate: onEvaluatePredicate, onCheckPredicate: onCheckPredicate, onDeliverMessage: onDeliverMessage, diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index e0f80022..208cb8ed 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -160,6 +160,15 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// agent alive. The session is only killed when it can't be reached to be asked. The /// node itself stays in the graph — stopping is not deleting. case stopNode(UUID) + /// Kill a loop's session and bring it straight back on the same transcript — the verb + /// for "`zmx` or the backend CLI was replaced under every running loop". Unlike the + /// kill `stopNode` falls back to, the banked session id survives, so the relaunch is a + /// resume rather than a fresh pass. An unattended loop is relaunched by the daemon; an + /// attended one comes back when a human next opens it, exactly as after a reboot. A + /// composite restarts its workers. + case restartNode(UUID) + /// `restartNode` for every unresolved loop in the graph, workers included. + case restartSessions /// Route a command into a composite node's sub-graph. Editing a composite's insides is /// the same set of operations as editing any graph, so it reuses them wholesale rather /// than growing a parallel vocabulary. diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index 73145711..19951c8d 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -32,6 +32,7 @@ public actor ProjectRegistry { private var sidebarConnections: Set = [] private let ensureSession: (@Sendable (LoopNode, String?) -> Void)? private let terminateSession: (@Sendable (LoopNode, String?) -> Void)? + private let restartSession: (@Sendable (LoopNode, String?) async -> Bool)? private let evaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? private let checkPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)? private let deliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? @@ -57,6 +58,8 @@ public actor ProjectRegistry { ensureSession: (@Sendable (LoopNode, String?) -> Void)? = CLISessionBackend.ensureSession, terminateSession: (@Sendable (LoopNode, String?) -> Void)? = CLISessionBackend.terminateSession, + restartSession: (@Sendable (LoopNode, String?) async -> Bool)? = + CLISessionBackend.restartSession, evaluatePredicate: (@Sendable (ShellPredicate) async -> Bool)? = ShellPredicateEvaluator .evaluate, checkPredicate: (@Sendable (ShellPredicate) async -> PredicateOutcome?)? = @@ -79,6 +82,7 @@ public actor ProjectRegistry { persistence = ProjectPersistence(baseDirectory: persistenceDirectory) self.ensureSession = ensureSession self.terminateSession = terminateSession + self.restartSession = restartSession self.evaluatePredicate = evaluatePredicate self.checkPredicate = checkPredicate self.deliverMessage = deliverMessage @@ -443,6 +447,7 @@ public actor ProjectRegistry { }, onEnsureSession: ensureSession, onTerminateSession: terminateSession, + onRestartSession: restartSession, onEvaluatePredicate: evaluatePredicate, onCheckPredicate: checkPredicate, onDeliverMessage: deliverMessage, diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index 61a0cc87..818c8894 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -32,6 +32,9 @@ public struct CLISessionBackend: Sendable { /// kill spoken only to the local socket left remote sessions running forever after /// their node was stopped or deleted. public var terminate: @Sendable (LoopNode, String?) async -> Void + /// End the session and, for an unattended loop, bring it back on the same transcript + /// (`ZmxSessionLauncher.restart`). Answers whether the old session is confirmed gone. + public var restart: @Sendable (LoopNode, String?) async -> Bool /// Push text into a live session. The transport behind a `.message` edge — see /// `MessageBusClient`. Returns false when the backend can't accept mid-session input /// or the session isn't live. `projectPath` routes the send the same way `terminate`'s @@ -58,6 +61,7 @@ public struct CLISessionBackend: Sendable { kind: CLISessionBackendKind, launch: @escaping @Sendable (LoopNode, String?) async -> Void, terminate: @escaping @Sendable (LoopNode, String?) async -> Void, + restart: @escaping @Sendable (LoopNode, String?) async -> Bool = { _, _ in false }, sendInput: @escaping @Sendable (LoopNode, String, String?) async -> Bool, presence: @escaping @Sendable (LoopNode, String?) async -> PresenceReading, usage: @escaping @Sendable (LoopNode, String?) async -> UsageSample?, @@ -67,6 +71,7 @@ public struct CLISessionBackend: Sendable { self.kind = kind self.launch = launch self.terminate = terminate + self.restart = restart self.sendInput = sendInput self.presence = presence self.usage = usage @@ -98,6 +103,9 @@ extension CLISessionBackend { terminate: { node, projectPath in await ZmxSessionLauncher.kill(node, projectPath: projectPath) }, + restart: { node, projectPath in + await ZmxSessionLauncher.restart(node, projectPath: projectPath) + }, sendInput: { node, text, projectPath in await ZmxSessionLauncher.send(text, to: node, projectPath: projectPath) }, @@ -232,6 +240,12 @@ extension CLISessionBackend { Task.detached { await backend(for: node).terminate(node, path) } } + /// Awaited rather than detached: `GraphStore.restartNode` needs the answer. + public static let restartSession: @Sendable (LoopNode, String?) async -> Bool = { + node, path in + await backend(for: node).restart(node, path) + } + /// The `.message` transport `GraphStore` is wired with — routed through the *target's* /// backend, since it's the target's session being typed into. public static let deliverMessage: @Sendable (LoopNode, String, String?) async -> Bool = { diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index 49e75ab9..99785133 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -169,6 +169,7 @@ public enum RemoteGraphAccess { graphcode status graphcode node create --title --type [options] graphcode node stop + graphcode node restart kill its session, resume it in place graphcode node delete irreversible; stop is reversible graphcode node send graphcode node memo @@ -484,13 +485,15 @@ public enum RemoteGraphAccess { create = {"subGraphCommand": {"nodeID": into, "command": create}} run_and_print(project, [graph_command(project, create)]) return - if subverb not in ("stop", "delete", "send", "memo"): + if subverb not in ("stop", "restart", "delete", "send", "memo"): fail("node %s runs from the Mac's own shell, not from a remote host" % subverb) if not arguments: fail("missing node-id") node_id = parse_uuid(arguments.pop(0), "node-id") if subverb == "stop": run_and_print(project, [graph_command(project, {"stopNode": {"_0": node_id}})]) + elif subverb == "restart": + run_and_print(project, [graph_command(project, {"restartNode": {"_0": node_id}})]) elif subverb == "delete": run_and_print(project, [graph_command(project, {"deleteNode": {"_0": node_id}})]) else: diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 53ebdb44..59d32336 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -557,6 +557,38 @@ public enum ZmxSessionLauncher { } } + /// `kill` for a session that is coming straight back: the same confirmed `zmx kill`, + /// minus everything that makes a kill final. The banked session id stays, so the + /// relaunch resumes the transcript; the first-pass record stays, so the loop is not + /// briefed as new; and the name is not condemned, because the reaper would otherwise + /// take out the very session this brings back. `false` when the old session would not + /// die — relaunching on top of it is how two agents end up on one name. + /// + /// The relaunch is `start`, detached: the ensure a reboot runs, resume-or-fresh with + /// the same husk check, and it settles for seconds a caller need not wait on. Only an + /// unattended loop is relaunched here; an attended one resumes when a human opens it, + /// exactly as after a reboot. + static func restart(_ node: LoopNode, projectPath: String? = nil) async -> Bool { + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + if let projectPath, let remote = RemoteProjectLocation.parse(projectPath: projectPath) { + guard await runRemoteRetrying(remoteKillInvocation(forNode: node, at: remote)) else { + return false + } + DialLog.record(session: name, dial: "restart", event: "killed") + if node.runsUnattended { + Task.detached { await startRemote(node, at: remote) } + } + return true + } + guard ZmxLocator.isInstalled else { return false } + guard await killConfirmingDeath(sessionNamed: name) else { return false } + DialLog.record(session: name, dial: "restart", event: "killed") + if node.runsUnattended { + Task.detached { await start(node, projectPath: projectPath) } + } + return true + } + /// `zmx kill`, then proof: `zmx kill` exits 0 whether or not anything died, and /// `zmx get` exits 1 both for absence and for a timeout against a live busy session. /// A successful `zmx ls` that contains no row for the name is the only unambiguous diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index d66e3619..f59c9898 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -200,6 +200,16 @@ do { projectPath: projectPath, [.graphCommand(projectPath: projectPath, command: .stopNode(nodeID))]) + case .restartNode(let projectPath, let nodeID): + try runAndPrintGraph( + projectPath: projectPath, + [.graphCommand(projectPath: projectPath, command: .restartNode(nodeID))]) + + case .restartSessions(let projectPath): + try runAndPrintGraph( + projectPath: projectPath, + [.graphCommand(projectPath: projectPath, command: .restartSessions)]) + case .deleteNode(let projectPath, let nodeID): try runAndPrintGraph( projectPath: projectPath, diff --git a/graphcode/Sources/Features/App/AppFeature+History.swift b/graphcode/Sources/Features/App/AppFeature+History.swift index d65bc578..023403d4 100644 --- a/graphcode/Sources/Features/App/AppFeature+History.swift +++ b/graphcode/Sources/Features/App/AppFeature+History.swift @@ -86,16 +86,7 @@ extension AppFeature { guard let project = state.projects[id: projectPath], let node = project.graph.nodes[id: nodeID] else { return } - let layout = terminalLayoutStore.load(forNode: nodeID) ?? .defaultLayout(forNode: nodeID) - state.openLoop = LoopWorkspaceFeature.State( - node: node, - graph: project.graph, - layout: layout, - projectPath: projectPath, - projectName: project.graph.project.name) - state.openLoop?.seenArtifactoryPostID = - LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: projectPath) - state.selectedProjectPath = projectPath + mountWorkspace(node: node, graph: project.graph, projectPath: projectPath, &state) } /// Records an arrival the human chose. Called from the two places a workspace opens on diff --git a/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift b/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift new file mode 100644 index 00000000..cabf76e9 --- /dev/null +++ b/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift @@ -0,0 +1,146 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit + +/// The Loop menu's verbs on a session: Stop Loop, and Restart Session / Restart All +/// Sessions… — kill a loop's `zmx` session and bring it back on the same transcript +/// (`GraphCommand.restartNode`), for the day `zmx` or a backend CLI was replaced under +/// every running loop. Stop lives here too because it is the same shape (a menu item, a +/// daemon command) and `AppFeature.swift` is at its lint budget. +/// +/// A restart's app half is about panes, not sessions. A mounted agent pane reads its process +/// exiting as the loop resolving (`primarySurfaceExited`), and a retained one keeps that +/// callback after the loop was switched away from — so every affected surface is +/// retired *before* the daemon is asked, and the workspace that was open is remounted +/// only once the daemon has bumped the node's `sessionRestarts`, its word that the old +/// session is confirmed dead. Remounted any earlier, the pane would join the dying +/// session and resolve the very loop it was meant to bring back. +struct SessionRestart: Equatable { + var isConfirmingAll = false + /// The workspace closed for a restart, to be remounted once the daemon confirms. + var pendingReopen: PendingReopen? + + struct PendingReopen: Equatable { + var projectPath: String + var nodeID: UUID + /// The count the node carried when the workspace closed; the remount waits for it + /// to move. + var seenRestarts: Int + } + + @CasePathable + enum Action: Equatable { + case openLoopTapped + case allTapped + case allConfirmed + case allCancelled + } +} + +extension AppFeature { + var loopSessionsReducer: some ReducerOf { + Reduce { state, action in + switch action { + case .stopNodeTapped(let projectPath, let nodeID): + return .run { _ in + try? await orchestratorClient.send( + .graphCommand(projectPath: projectPath, command: .stopNode(nodeID))) + } + + case .openLoop(.stopLoopTapped): + guard let id = state.openLoop?.node.id, let path = state.openLoop?.projectPath + else { return .none } + return .send(.stopNodeTapped(projectPath: path, nodeID: id)) + + case .sessionRestart(.openLoopTapped): + // A chat is not a node in any graph — the daemon has nothing to restart. + guard let open = state.openLoop, !state.isQuickChat(open.node.id) else { return .none } + let path = open.projectPath + let nodeID = open.node.id + closeForRestart(&state, reopening: open) + return .run { _ in + try? await orchestratorClient.send( + .graphCommand(projectPath: path, command: .restartNode(nodeID))) + } + + case .sessionRestart(.allTapped): + state.sessionRestart.isConfirmingAll = true + return .none + + case .sessionRestart(.allCancelled): + state.sessionRestart.isConfirmingAll = false + return .none + + case .sessionRestart(.allConfirmed): + state.sessionRestart.isConfirmingAll = false + if let open = state.openLoop, !state.isQuickChat(open.node.id) { + closeForRestart(&state, reopening: open) + } else { + closeOpenWorkspace(&state) + } + // Every retained surface, not just the open workspace's: a loop switched away + // from keeps its pane, and that pane keeps the exit callback. + terminalSurfaceClient.retireAll() + let paths = Array(state.projects.ids) + return .run { _ in + for path in paths { + try? await orchestratorClient.send( + .graphCommand(projectPath: path, command: .restartSessions)) + } + } + + case .daemonEvent(.graphChanged(let graph)): + guard let pending = state.sessionRestart.pendingReopen, + pending.projectPath == graph.project.path + else { return .none } + guard let node = graph.nodes[id: pending.nodeID] else { + state.sessionRestart.pendingReopen = nil + return .none + } + guard node.sessionRestarts > pending.seenRestarts else { return .none } + state.sessionRestart.pendingReopen = nil + // The human may have opened something else while the daemon worked; their + // choice stands. + guard state.openLoop == nil else { return .none } + mountWorkspace(node: node, graph: graph, projectPath: pending.projectPath, &state) + return .none + + // The daemon refusing the restart is the likeliest error to arrive while a + // remount is pending, and a remount that never comes is what it should mean. + case .daemonEvent(.errorOccurred): + state.sessionRestart.pendingReopen = nil + return .none + + default: + return .none + } + } + } + + private func closeForRestart(_ state: inout State, reopening open: LoopWorkspaceFeature.State) { + let current = + state.projects[id: open.projectPath]?.graph.nodes[id: open.node.id]?.sessionRestarts + ?? open.node.sessionRestarts + state.sessionRestart.pendingReopen = SessionRestart.PendingReopen( + projectPath: open.projectPath, nodeID: open.node.id, seenRestarts: current) + closeOpenWorkspace(&state) + state.selectedProjectPath = open.projectPath + } + + /// Puts a loop's workspace on screen — what a history step and a restart's remount + /// share, minus the recording and the blocked-loop gate, which are `openNode`'s. + func mountWorkspace( + node: LoopNode, graph: LoopGraph, projectPath: String, _ state: inout State + ) { + let layout = terminalLayoutStore.load(forNode: node.id) ?? .defaultLayout(forNode: node.id) + state.openLoop = LoopWorkspaceFeature.State( + node: node, + graph: graph, + layout: layout, + projectPath: projectPath, + projectName: graph.project.name) + state.openLoop?.seenArtifactoryPostID = + LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: projectPath) + state.selectedProjectPath = projectPath + } +} diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 50844224..7369fb03 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -125,6 +125,8 @@ struct AppFeature { /// A bundle replaced underneath this running app — see `BundleSwap` in /// `AppFeature+Updates.swift`. var bundleSwap = BundleSwap() + /// Restart Session / Restart All Sessions — see `AppFeature+LoopSessions.swift`. + var sessionRestart = SessionRestart() /// State changes seen since launch, for the activity strip — see /// `AppFeature+Activity.swift`. Bounded, and deliberately not persisted. @@ -241,6 +243,7 @@ struct AppFeature { /// A bundle replaced underneath this running window — asked on activation, answered /// with a relaunch prompt. See `BundleSwap.Action`. case bundleSwap(BundleSwap.Action) + case sessionRestart(SessionRestart.Action) case updateDownloadTapped case updateReleaseNotesTapped case updateAlertDismissed @@ -306,6 +309,7 @@ struct AppFeature { jumpPaletteReducer updatesReducer historyReducer + loopSessionsReducer // Before the main Reduce on purpose: its `.graphChanged` diff needs the previous // graph, which the main reducer replaces. See `AppFeature+Worktrees.swift`. AppWorktreesReducer() @@ -477,12 +481,6 @@ struct AppFeature { case .selectPreviousLoop: return stepOpenLoop(state, by: -1) - case .stopNodeTapped(let projectPath, let nodeID): - return .run { _ in - try? await orchestratorClient.send( - .graphCommand(projectPath: projectPath, command: .stopNode(nodeID))) - } - case .onboardingRequested: state.showingOnboarding = true return .none @@ -490,6 +488,11 @@ struct AppFeature { case .onboardingDismissed: return finishOnboarding(&state) + // Stop and restart are handled by `loopSessionsReducer`, in + // `AppFeature+LoopSessions.swift` — listed here so this switch stays exhaustive. + case .stopNodeTapped, .sessionRestart: + return .none + // Both handled by `historyReducer`, in `AppFeature+History.swift` — listed here // only so this switch stays exhaustive. case .historyBackTapped, .historyForwardTapped: @@ -572,11 +575,6 @@ struct AppFeature { .graphCommand(projectPath: projectPath, command: .deleteNode(id))) } - case .openLoop(.stopLoopTapped): - guard let id = state.openLoop?.node.id, let path = state.openLoop?.projectPath - else { return .none } - return .send(.stopNodeTapped(projectPath: path, nodeID: id)) - case .openLoop(.showInGraphTapped): // Closing the workspace *without* ending its terminals: the loop keeps running, // you are just looking at the graph again. `closeOpenWorkspace` is the other @@ -715,23 +713,13 @@ extension AppFeature { /// doesn't. The refusal raises the notice alert rather than doing nothing: a /// silent dead click reads as a broken canvas, not a rule (#194 follow-up). private func openNode(_ nodeID: UUID, in path: String, _ state: inout State) -> Effect { - guard let node = state.projects[id: path]?.graph.nodes[id: nodeID] + guard let graph = state.projects[id: path]?.graph, let node = graph.nodes[id: nodeID] else { return .none } guard node.opensOnHumanTap else { - state.blockedLoopNotice = BlockedLoopNotice( - node: node, graph: state.projects[id: path]?.graph) + state.blockedLoopNotice = BlockedLoopNotice(node: node, graph: graph) return .none } - let layout = terminalLayoutStore.load(forNode: nodeID) ?? .defaultLayout(forNode: nodeID) - state.openLoop = LoopWorkspaceFeature.State( - node: node, - graph: state.projects[id: path]?.graph ?? LoopGraph(scope: .global), - layout: layout, - projectPath: path, - projectName: state.projects[id: path]?.graph.project.name ?? path) - state.openLoop?.seenArtifactoryPostID = - LoopWorkspaceRail.loadSeenArtifactoryPost(forProjectPath: path) - state.selectedProjectPath = path + mountWorkspace(node: node, graph: graph, projectPath: path, &state) recordVisit(.loop(projectPath: path, nodeID: nodeID), &state) return .none } diff --git a/graphcode/Sources/Features/App/AppView.swift b/graphcode/Sources/Features/App/AppView.swift index 62dff29b..1ff99206 100644 --- a/graphcode/Sources/Features/App/AppView.swift +++ b/graphcode/Sources/Features/App/AppView.swift @@ -178,6 +178,22 @@ struct AppView: View { "Its terminal session is ended and every edge touching it is removed. " + "This can't be undone.") } + .confirmationDialog( + "Restart every session?", + isPresented: Binding( + get: { store.sessionRestart.isConfirmingAll }, + set: { if !$0 { store.send(.sessionRestart(.allCancelled)) } } + ), + titleVisibility: .visible + ) { + Button("Restart All") { store.send(.sessionRestart(.allConfirmed)) } + Button("Cancel", role: .cancel) { store.send(.sessionRestart(.allCancelled)) } + } message: { + Text( + "Every running loop's terminal session is ended and picked back up on the same " + + "transcript. Goal and timed loops come back on their own; a turn-based loop " + + "resumes when you next open it.") + } // A loop's title is written before the work exists, so it's the one thing about a // loop people want to change afterwards. An alert with a field rather than a sheet: // there is exactly one thing to type, and it has to be able to open over a terminal diff --git a/graphcode/Sources/Features/App/GraphcodeCommands.swift b/graphcode/Sources/Features/App/GraphcodeCommands.swift index 93bb980d..46c61696 100644 --- a/graphcode/Sources/Features/App/GraphcodeCommands.swift +++ b/graphcode/Sources/Features/App/GraphcodeCommands.swift @@ -81,6 +81,15 @@ struct GraphcodeCommands: Commands { .disabled(!hasWorkspace) Button("Stop Loop") { store.send(.openLoop(.stopLoopTapped)) } .disabled(!hasWorkspace) + + Divider() + + // The recovery for a replaced `zmx` or backend CLI: the session is killed and + // picked back up on the same transcript. See `AppFeature+LoopSessions.swift`. + Button("Restart Session") { store.send(.sessionRestart(.openLoopTapped)) } + .disabled(!hasRestartableLoop) + Button("Restart All Sessions…") { store.send(.sessionRestart(.allTapped)) } + .disabled(store.projects.isEmpty) } CommandMenu("Terminal") { @@ -130,6 +139,12 @@ struct GraphcodeCommands: Commands { return candidate } + /// A chat is not a node in any graph, so there is nothing for the daemon to restart. + private var hasRestartableLoop: Bool { + guard let open = store.openLoop else { return false } + return store.quickChats[id: open.node.id] == nil + } + private var railTitle: String { (store.openLoop?.isRailVisible ?? false) ? "Hide Loop Panel" : "Show Loop Panel" } diff --git a/graphcode/Sources/Infrastructure/Ghostty/TerminalSurfaceStore.swift b/graphcode/Sources/Infrastructure/Ghostty/TerminalSurfaceStore.swift index 0c8e84b4..d47f123f 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/TerminalSurfaceStore.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/TerminalSurfaceStore.swift @@ -65,6 +65,12 @@ final class TerminalSurfaceStore { } } + /// Every retained surface, mounted or not — for a restart of every session, where any + /// pane left alive would watch its process die and report it. + func retireAll() { + retire(Array(surfaces.keys)) + } + /// Whether a surface for `id` is currently alive. For tests and for callers deciding /// whether a rebuild is about to happen. func isRetained(_ id: UUID) -> Bool { surfaces[id] != nil } @@ -99,6 +105,7 @@ final class TerminalSurfaceStore { /// a plain closure rather than an effect. struct TerminalSurfaceClient: Sendable { var retire: @Sendable ([UUID]) -> Void + var retireAll: @Sendable () -> Void } extension TerminalSurfaceClient: DependencyKey { @@ -114,12 +121,21 @@ extension TerminalSurfaceClient: DependencyKey { MainActor.assumeIsolated { TerminalSurfaceStore.shared.retire(ids) } } } + }, + retireAll: { + if Thread.isMainThread { + MainActor.assumeIsolated { TerminalSurfaceStore.shared.retireAll() } + } else { + DispatchQueue.main.async { + MainActor.assumeIsolated { TerminalSurfaceStore.shared.retireAll() } + } + } }) /// Tests exercise the retention rules against `SurfaceRetentionPolicy` directly; a /// reducer test asserting on tab bookkeeping has no surfaces to retire and should not /// spin up a `ghostty_app_t` to find that out. - static let testValue = TerminalSurfaceClient(retire: { _ in }) + static let testValue = TerminalSurfaceClient(retire: { _ in }, retireAll: {}) } extension DependencyValues { diff --git a/graphcode/Tests/GraphcodeCommandTests.swift b/graphcode/Tests/GraphcodeCommandTests.swift index 8d2705b2..3a6369a3 100644 --- a/graphcode/Tests/GraphcodeCommandTests.swift +++ b/graphcode/Tests/GraphcodeCommandTests.swift @@ -229,6 +229,20 @@ struct GraphcodeCommandTests { == .stopNode(projectPath: "/tmp/x", nodeID: nodeID)) } + @Test + func restartingANodeAndEverySession() throws { + let nodeID = UUID() + #expect( + try GraphcodeCommand.parse(["node", "restart", "/tmp/x", nodeID.uuidString]) + == .restartNode(projectPath: "/tmp/x", nodeID: nodeID)) + #expect( + try GraphcodeCommand.parse(["sessions", "restart", "/tmp/x"]) + == .restartSessions(projectPath: "/tmp/x")) + #expect(throws: GraphcodeCommand.ParseError.self) { + try GraphcodeCommand.parse(["sessions", "stop", "/tmp/x"]) + } + } + @Test func pilotingAndArmingAComposite() throws { let nodeID = UUID() diff --git a/graphcode/Tests/SessionRestartTests.swift b/graphcode/Tests/SessionRestartTests.swift new file mode 100644 index 00000000..f8ebb2f5 --- /dev/null +++ b/graphcode/Tests/SessionRestartTests.swift @@ -0,0 +1,100 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import GraphcodeKit + +/// `GraphCommand.restartNode` / `.restartSessions` — kill a session and bring it back on +/// the same transcript. The store's part is small and all about the counter: it moves +/// only for a confirmed kill, because the app remounts a pane on it. +@Suite +struct SessionRestartTests { + private func draft(_ title: String) -> NodeDraft { + NodeDraft(title: title, loopType: .goalBased, goal: GoalSpec(summary: "say hi")) + } + + @Test + func aConfirmedKillBumpsTheCounterAndLeavesAMemo() async { + let restarted = LockIsolated<[(UUID, String?)]>([]) + let memos = LockIsolated<[String]>([]) + let store = GraphStore( + graph: LoopGraph(project: ProjectRef(path: "/tmp/p", name: "p")), + onRestartSession: { node, path in + restarted.withValue { $0.append((node.id, path)) } + return true + }, + onAppendMemory: { _, entry in memos.withValue { $0.append(entry) } }) + await store.handle(.createNode(draft("Worker"))) + let id = await store.graph.nodes[0].id + + await store.handle(.restartNode(id)) + + #expect(await store.graph.nodes[id: id]?.sessionRestarts == 1) + #expect(restarted.value.map(\.0) == [id]) + #expect(restarted.value.map(\.1) == ["/tmp/p"]) + #expect(memos.value.contains { $0.contains("restarted in place") }) + } + + @Test + func aKillThatDidNotLandLeavesTheCounterAloneAndSaysSo() async { + let errors = LockIsolated<[String]>([]) + let store = GraphStore( + onRestartSession: { _, _ in false }, + onAnnounceError: { message in errors.withValue { $0.append(message) } }) + await store.handle(.createNode(draft("Stuck"))) + let id = await store.graph.nodes[0].id + + await store.handle(.restartNode(id)) + + #expect(await store.graph.nodes[id: id]?.sessionRestarts == 0) + #expect(errors.value.count == 1) + } + + @Test + func aFinishedLoopIsRefused() async { + let restarted = LockIsolated(0) + let errors = LockIsolated<[String]>([]) + let store = GraphStore( + onRestartSession: { _, _ in + restarted.withValue { $0 += 1 } + return true + }, + onAnnounceError: { message in errors.withValue { $0.append(message) } }) + await store.handle(.createNode(draft("Done"))) + let id = await store.graph.nodes[0].id + await store.handle(.nodeCheckApproved(id)) + + await store.handle(.restartNode(id)) + + #expect(restarted.value == 0) + #expect(errors.value.count == 1) + } + + @Test + func restartingEverySessionSkipsTheFinishedOnes() async { + let restarted = LockIsolated>([]) + let store = GraphStore( + onRestartSession: { node, _ in + restarted.withValue { $0.insert(node.id) } + return true + }) + await store.handle(.createNode(draft("Live"))) + await store.handle(.createNode(draft("Also live"))) + await store.handle(.createNode(draft("Done"))) + let ids = await store.graph.nodes.map(\.id) + await store.handle(.nodeCheckApproved(ids[2])) + + await store.handle(.restartSessions) + + #expect(restarted.value == Set(ids.prefix(2))) + #expect(await store.graph.nodes[id: ids[0]]?.sessionRestarts == 1) + #expect(await store.graph.nodes[id: ids[2]]?.sessionRestarts == 0) + } + + @Test + func aNodeSavedBeforeTheCounterExistedDecodesAtZero() throws { + let json = #"{"id":"\#(UUID().uuidString)","title":"Old"}"# + let node = try JSONDecoder().decode(LoopNode.self, from: Data(json.utf8)) + #expect(node.sessionRestarts == 0) + } +}