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
20 changes: 19 additions & 1 deletion GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -83,6 +87,9 @@ public enum GraphcodeCommand: Equatable, Sendable {
graphcode status <project-path>
graphcode node create <project-path> --title <t> --type <main|turn|goal|time|composite> [options]
graphcode node stop <project-path> <node-id>
graphcode node restart <project-path> <node-id> kill its session and resume it on
the same transcript — for a replaced zmx or backend CLI
graphcode sessions restart <project-path> the same, for every live loop
graphcode node delete <project-path> <node-id> removes it, its edges, session
and memory — irreversible; stop is the reversible verb
graphcode node send <project-path> <node-id> [--follow-up] <message…>
Expand Down Expand 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)
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down
8 changes: 7 additions & 1 deletion GraphcodeKit/Sources/Domain/LoopNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 68 additions & 1 deletion GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2641,6 +2707,7 @@ public actor GraphStore {
GraphStore(
graph: subGraph,
onTerminateSession: onTerminateSession,
onRestartSession: onRestartSession,
onEvaluatePredicate: onEvaluatePredicate,
onCheckPredicate: onCheckPredicate,
onDeliverMessage: onDeliverMessage,
Expand Down
9 changes: 9 additions & 0 deletions GraphcodeKit/Sources/IPC/DaemonProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions GraphcodeKit/Sources/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public actor ProjectRegistry {
private var sidebarConnections: Set<UUID> = []
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)?
Expand All @@ -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?)? =
Expand All @@ -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
Expand Down Expand Up @@ -443,6 +447,7 @@ public actor ProjectRegistry {
},
onEnsureSession: ensureSession,
onTerminateSession: terminateSession,
onRestartSession: restartSession,
onEvaluatePredicate: evaluatePredicate,
onCheckPredicate: checkPredicate,
onDeliverMessage: deliverMessage,
Expand Down
14 changes: 14 additions & 0 deletions GraphcodeKit/Sources/Sessions/CLISessionBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?,
Expand All @@ -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
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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 = {
Expand Down
5 changes: 4 additions & 1 deletion GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ public enum RemoteGraphAccess {
graphcode status <project-path>
graphcode node create <project-path> --title <t> --type <turn|goal|time|composite> [options]
graphcode node stop <project-path> <node-id>
graphcode node restart <project-path> <node-id> kill its session, resume it in place
graphcode node delete <project-path> <node-id> irreversible; stop is reversible
graphcode node send <project-path> <node-id> <message...>
graphcode node memo <project-path> <node-id> <note...>
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading