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
81 changes: 60 additions & 21 deletions GraphcodeKit/Sources/GraphStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public actor GraphStore {
/// nothing produces beats — no reader wired, or the human has left the producer off.
private let onReadSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)?
private let onReadPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)?
/// Whether a local loop's session is alive and not a husk — what decides if a pane
/// closing may resolve the loop (`sessionPermitsResolution`).
private let onSessionAlive: (@Sendable (LoopNode, String?) async -> Bool)?
/// Cross-graph `.spawn`. `GraphStore` owns exactly one graph and cannot reach another,
/// so it hands the request up to `ProjectRegistry`, which is the layer that knows every
/// open project — the same split that keeps this actor unaware multi-project routing
Expand Down Expand Up @@ -134,6 +137,12 @@ public actor GraphStore {
static let maxSubGraphDepth = 6
static let maxNodesPerGraph = 50
private var goalPollers: [UUID: Task<Void, Never>] = [:]
/// When each loop's session was last restarted in place. A pane that watched that
/// kill reports an exit, and for this long afterwards the report is the restart's
/// own doing rather than the loop finishing. In-memory: a daemon restart forgetting
/// it costs nothing, since the sessions it relaunches are not these.
private var recentRestarts: [UUID: Date] = [:]
static let restartResolutionGrace: TimeInterval = 60
/// The experiment's timers — one per heartbeat-driven time loop, alive whether the
/// Settings toggle is on or off. The *tick* checks the toggle, not the arming: a
/// timer that skips its beat costs one closure call a minute, and it means flipping
Expand Down Expand Up @@ -209,6 +218,7 @@ public actor GraphStore {
onReadActivity: (@Sendable (LoopNode, String?) async -> String?)? = nil,
onReadSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? = nil,
onReadPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? = nil,
onSessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? = nil,
onSpawnIntoProject: (@Sendable (String, NodeDraft) -> Void)? = nil,
onAppendMemory: (@Sendable (UUID, String) -> Void)? = nil,
onRemoveMemory: (@Sendable (UUID) -> Void)? = nil,
Expand Down Expand Up @@ -240,6 +250,7 @@ public actor GraphStore {
self.onReadActivity = onReadActivity
self.onReadSummary = onReadSummary
self.onReadPresence = onReadPresence
self.onSessionAlive = onSessionAlive
self.onSpawnIntoProject = onSpawnIntoProject
self.onAppendMemory = onAppendMemory
self.onRemoveMemory = onRemoveMemory
Expand Down Expand Up @@ -603,13 +614,14 @@ public actor GraphStore {
unblockIfStillIdle(to)

case .nodeCheckApproved(let nodeID):
if await remoteSessionPermitsResolution(nodeID) {
resolveNode(nodeID, succeeded: true)
if await sessionPermitsResolution(nodeID, succeeded: true) {
resolveNode(nodeID, succeeded: true, reason: "its pane's process finished")
}

case .nodeCheckRejected(let nodeID):
if await remoteSessionPermitsResolution(nodeID) {
resolveNode(nodeID, succeeded: false)
if await sessionPermitsResolution(nodeID, succeeded: false) {
resolveNode(
nodeID, succeeded: false, reason: "its pane closed with the process still running")
}

case .messageNode(let nodeID, let text, let from, let followUp):
Expand Down Expand Up @@ -822,9 +834,9 @@ public actor GraphStore {

switch rolled {
case .succeeded:
resolveNode(nodeID, succeeded: true)
resolveNode(nodeID, succeeded: true, reason: "its workers rolled up to succeeded")
case .failed, .stalled:
resolveNode(nodeID, succeeded: false)
resolveNode(nodeID, succeeded: false, reason: "its workers rolled up to \(rolled)")
case .idle, .running, .awaitingInput, .blocked, .waiting, .stopped:
setNodeState(nodeID, rolled)
}
Expand Down Expand Up @@ -1921,6 +1933,7 @@ public actor GraphStore {
for node in nodes {
if confirmed[node.id] == true {
graph.nodes[id: node.id]?.sessionRestarts += 1
recentRestarts[node.id] = Date()
recordMemory(node.id, "session restarted in place, resumed from its transcript")
} else {
announceError("could not restart \(node.title): its session did not die")
Expand Down Expand Up @@ -1997,18 +2010,42 @@ public actor GraphStore {
/// it; the presence poll keeps the card honest either way, and reopening the loop
/// reattaches. The one probe (bounded by ssh's own ConnectTimeout) is deliberately
/// not retried: this actor serializes a project's commands, and a resolution can
/// simply arrive again once the link is back.
private func remoteSessionPermitsResolution(_ nodeID: UUID) async -> Bool {
guard RemoteProjectLocation.parse(projectPath: graph.project.path) != nil,
let onReadPresence, let node = graph.nodes[id: nodeID], !node.isResolved
else { return true }
let reading = await onReadPresence(node, graph.project.path)
if reading.presence == .absent { return true }
recordMemory(
nodeID,
"surface reported an exit, but the remote session was "
+ "\(reading.presence == .unknown ? "unreachable" : "still live") — not resolved")
return false
/// simply arrive again once the link is back. Every refusal is written to the node's
/// 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 }
let report =
"surface reported its pane "
+ (succeeded ? "finished" : "closed with its process still running")
// The restart's own kill: the pane that watched it die reports an exit that
// means nothing about the work. Every restarted loop showed FAILED or SUCCEEDED
// for exactly this reason before the grace existed.
if let restarted = recentRestarts[nodeID],
Date().timeIntervalSince(restarted) < Self.restartResolutionGrace
{
let seconds = Int(Date().timeIntervalSince(restarted))
recordMemory(
nodeID, "\(report) \(seconds)s after a restart — the restart's own kill, not resolved")
return false
}
if RemoteProjectLocation.parse(projectPath: graph.project.path) != nil {
guard let onReadPresence else { return true }
let reading = await onReadPresence(node, graph.project.path)
if reading.presence == .absent { return true }
recordMemory(
nodeID,
"\(report), but the remote session was "
+ "\(reading.presence == .unknown ? "unreachable" : "still live") — not resolved")
return false
}
// A pane closing is not the loop finishing: ⌘W in a running agent pane (Ghostty's
// own close binding, live whenever the app's Close Tab item is disabled) marked the
// loop failed while its session carried on headless.
if let onSessionAlive, await onSessionAlive(node, graph.project.path) {
recordMemory(nodeID, "\(report), but the session is still live — not resolved")
return false
}
return true
}

/// `sessionMayStillBeLive` is true only for predicate-driven resolutions: the goal
Expand All @@ -2017,12 +2054,12 @@ public actor GraphStore {
/// (`nodeCheckApproved`, composite roll-up) fire *because* the session ended, so
/// there is nobody left to speak to.
private func resolveNode(
_ nodeID: UUID, succeeded: Bool, sessionMayStillBeLive: Bool = false
_ nodeID: UUID, succeeded: Bool, reason: String, sessionMayStillBeLive: Bool = false
) {
guard let node = graph.nodes[id: nodeID] else { return }
setNodeState(nodeID, succeeded ? .succeeded : .failed)
cancelGoalPoller(nodeID)
recordMemory(nodeID, "resolved: \(succeeded ? "succeeded" : "failed")")
recordMemory(nodeID, "resolved: \(succeeded ? "succeeded" : "failed") — \(reason)")
// Two asks ride resolution, in one interruption. Skill distillation: a goal loop
// that just succeeded is the one agent holding a proven method in context, and
// success is load-bearing there — a failed loop's method is not a recipe. The
Expand Down Expand Up @@ -2714,6 +2751,7 @@ public actor GraphStore {
onCaptureScript: onCaptureScript,
onReadUsage: onReadUsage,
onReadPresence: onReadPresence,
onSessionAlive: onSessionAlive,
onAppendMemory: onAppendMemory,
onRemoveMemory: onRemoveMemory,
onRefinePlaybook: onRefinePlaybook,
Expand Down Expand Up @@ -2875,7 +2913,8 @@ public actor GraphStore {
// or its session exited and resolved it) while the predicate was running.
guard let current = graph.nodes[id: nodeID], !current.isResolved else { return }
if outcome.passed {
resolveNode(nodeID, succeeded: true, sessionMayStillBeLive: true)
resolveNode(
nodeID, succeeded: true, reason: "its goal predicate passed", sessionMayStillBeLive: true)
await drainAndBroadcast()
return
}
Expand Down
4 changes: 4 additions & 0 deletions GraphcodeKit/Sources/ProjectRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public actor ProjectRegistry {
private let readActivity: (@Sendable (LoopNode, String?) async -> String?)?
private let readSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)?
private let readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)?
private let sessionAlive: (@Sendable (LoopNode, String?) async -> Bool)?
private let composeBoard:
(@Sendable (LoopNode, LoopSummary, String?, String?) async -> SummaryBoard?)?
/// Non-nil only while at least one client is attached — see `startPresencePolling`.
Expand Down Expand Up @@ -75,6 +76,7 @@ public actor ProjectRegistry {
CLISessionBackend.readSummary,
readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? =
CLISessionBackend.readPresence,
sessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? = CLISessionBackend.sessionAlive,
composeBoard: (@Sendable (LoopNode, LoopSummary, String?, String?) async -> SummaryBoard?)? =
CLISessionBackend.composeBoard,
reapCondemnedSessions: Bool = false
Expand All @@ -91,6 +93,7 @@ public actor ProjectRegistry {
self.readActivity = readActivity
self.readSummary = readSummary
self.readPresence = readPresence
self.sessionAlive = sessionAlive
self.composeBoard = composeBoard
// The reap half of the two-phase kill (`CondemnedSessions`): once at startup, for a
// delete whose daemon died between condemning a session and confirming it dead, and
Expand Down Expand Up @@ -456,6 +459,7 @@ public actor ProjectRegistry {
onReadActivity: readActivity,
onReadSummary: readSummary,
onReadPresence: readPresence,
onSessionAlive: sessionAlive,
onSpawnIntoProject: spawnIntoProject,
// The node memory log (`NodeMemory`): episode records in, whole directory out
// when the node is deleted. Keyed by this store's project path, captured here so
Expand Down
6 changes: 6 additions & 0 deletions GraphcodeKit/Sources/Sessions/CLISessionBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,12 @@ extension CLISessionBackend {
/// chain that was otherwise complete: `presence` was implemented on every adapter and
/// called by nothing, so every surface had only `LoopState` to go on and a loop that
/// had finished its turn read RUNNING until a human stopped it.
/// The liveness hook `GraphStore` is wired with — session-level like `terminate`,
/// so it needs no per-backend adapter.
public static let sessionAlive: @Sendable (LoopNode, String?) async -> Bool = { node, path in
ZmxSessionLauncher.isSessionAlive(node, projectPath: path)
}

public static let readPresence: @Sendable (LoopNode, String?) async -> PresenceReading = {
node, path in
await backend(for: node).presence(node, path)
Expand Down
17 changes: 17 additions & 0 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,23 @@ public enum ZmxSessionLauncher {
}
}

/// Whether the node's local session is alive and not a husk — the ensure's own
/// create-or-run check (`aliveCheckCommand`), asked on its own. A remote session
/// answers `false`: its liveness is read through presence over ssh (`GraphStore`).
static func isSessionAlive(_ node: LoopNode, projectPath: String? = nil) -> Bool {
if let projectPath, RemoteProjectLocation.parse(projectPath: projectPath) != nil {
return false
}
guard ZmxLocator.isInstalled, let result = runZmx(["ls"]), result.status == 0 else {
return false
}
let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
return result.output.split(separator: "\n").contains { line in
!line.contains("\tended=") && !line.contains("\terr=")
&& line.split(whereSeparator: \.isWhitespace).contains("name=\(name)")
}
}

private enum SessionNamedState {
case present
case absent
Expand Down
31 changes: 29 additions & 2 deletions graphcode/Sources/Features/App/AppFeature+LoopSessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ 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
/// The Loop menu's verbs on a sessionStop Loop, Restart Session, Restart All
/// Sessions… — and the pane exit that resolves a loop. Restart kills 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.
Expand Down Expand Up @@ -52,6 +52,33 @@ extension AppFeature {
else { return .none }
return .send(.stopNodeTapped(projectPath: path, nodeID: id))

case .openLoop(.restartLoopTapped):
return .send(.sessionRestart(.openLoopTapped))

// A loop's own primary session exiting *is* its resolution — no separate human
// approve/reject step. `LoopWorkspaceFeature` already updated its local node
// state for this same action; telling `graphcoded` is this level's job, since it
// holds the connection, and it's what fires the outgoing edges. The daemon has
// the last word (`GraphStore.sessionPermitsResolution`), and the report is
// logged here so a resolution nobody expected can be traced to the pane that
// sent it.
case .openLoop(.primarySurfaceExited(let succeeded)):
guard let id = state.openLoop?.node.id, let projectPath = state.selectedProjectPath
else { return .none }
// A chat's session ending resolves nothing — there is no node in any graph for
// the daemon to update, so telling it would only earn an unknown-node error.
guard !state.isQuickChat(id) else { return .none }
DialLog.record(
session: SurfaceRef(id: id, launchesClaudeCode: true).zmxSessionName,
dial: "pane", event: succeeded ? "exit-finished" : "exit-alive")
// The pane that watched a restart's kill has nothing to say about the work.
guard state.sessionRestart.pendingReopen?.nodeID != id else { return .none }
let command: GraphCommand = succeeded ? .nodeCheckApproved(id) : .nodeCheckRejected(id)
return .run { _ in
try? await orchestratorClient.send(
.graphCommand(projectPath: projectPath, command: command))
}

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 }
Expand Down
12 changes: 0 additions & 12 deletions graphcode/Sources/Features/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -539,18 +539,6 @@ struct AppFeature {
// its local node state for this same action; telling `graphcoded` is this
// level's job, since it's the one holding the connection, and it's what
// actually triggers automatic outgoing-edge firing.
case .openLoop(.primarySurfaceExited(let succeeded)):
guard let id = state.openLoop?.node.id, let projectPath = state.selectedProjectPath
else { return .none }
// A chat's session ending resolves nothing — there is no node in any graph for
// the daemon to update, so telling it would only earn an unknown-node error.
guard !state.isQuickChat(id) else { return .none }
let command: GraphCommand = succeeded ? .nodeCheckApproved(id) : .nodeCheckRejected(id)
return .run { _ in
try? await orchestratorClient.send(
.graphCommand(projectPath: projectPath, command: command))
}

// The keypress on the agent pane's "Press any key to close" screen. The session
// was already resolved when it exited (above) — this is the human dismissing what
// remains, so the workspace closes *and* the node leaves the graph. Deleting via
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ struct LoopWorkspaceFeature {
/// the hairline, which is the whole point of it.
case workspaceLeft
case stopLoopTapped
/// The loop bar's Restart session — the session is killed and resumed on the same
/// transcript; `AppFeature` carries it out, as it does the stop.
case restartLoopTapped
case showInGraphTapped
case railTargetTapped(UUID)
}
Expand Down Expand Up @@ -308,7 +311,8 @@ struct LoopWorkspaceFeature {
}
return .none

case .stopLoopTapped, .showInGraphTapped, .railTargetTapped, .primaryExitAcknowledged:
case .stopLoopTapped, .restartLoopTapped, .showInGraphTapped, .railTargetTapped,
.primaryExitAcknowledged:
// Handled by `AppFeature`'s parent `Reduce` — see the actions' own doc comment.
return .none
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import SwiftUI
struct LoopWorkspaceLoopBar: View {
let node: LoopNode
let now: Date
let onStop: () -> Void
let onRestart: () -> Void
let onShowInGraph: () -> Void

private var card: LoopCardPresentation { LoopCardPresentation(node: node, now: now) }
Expand Down Expand Up @@ -106,8 +106,11 @@ struct LoopWorkspaceLoopBar: View {
// No Pause button. The design has one and the daemon has nothing behind it —
// `graphcoded` can stop a loop, not suspend one — and a control that looks like it
// holds a running agent while the agent keeps working is worse than no control.
// Restart rather than Stop: stopping is the rarer, graver verb and keeps its
// place in the Loop menu; what a human reaches for from inside a running loop is
// the session back on its feet after a replaced `zmx` or CLI.
if !node.isResolved {
barButton("Stop loop", action: onStop)
barButton("Restart session", action: onRestart)
}
Button("Show in graph", action: onShowInGraph)
.buttonStyle(.plain)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ struct LoopWorkspaceView: View {
LoopWorkspaceLoopBar(
node: store.node,
now: now,
onStop: { store.send(.stopLoopTapped) },
onRestart: { store.send(.restartLoopTapped) },
onShowInGraph: { store.send(.showInGraphTapped) })
// No divider under the strip: its own shadow line is that edge now, and stacking a
// system `Divider` on top of it draws the seam twice.
Expand Down
Loading
Loading