From 5da3612da7dbced36fd7a38745f31926c6cc6237 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 14:34:46 -0700 Subject: [PATCH 1/3] Start restart-resolution-guard branch Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0199VUTtyfuWHbu2hHXpHYw2 From efd23370ec961ad870e74d49ddb6f5fec632aaf1 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 14:37:20 -0700 Subject: [PATCH 2/3] A pane exit never resolves a loop the daemon knows is live or just restarted Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0199VUTtyfuWHbu2hHXpHYw2 --- GraphcodeKit/Sources/GraphStore.swift | 79 ++++++++++++++----- GraphcodeKit/Sources/ProjectRegistry.swift | 4 + .../Sources/Sessions/CLISessionBackend.swift | 6 ++ .../Sources/Sessions/ZmxSessionLauncher.swift | 17 ++++ .../App/AppFeature+LoopSessions.swift | 31 +++++++- .../Sources/Features/App/AppFeature.swift | 12 --- .../LoopWorkspace/LoopWorkspaceFeature.swift | 6 +- .../LoopWorkspace/LoopWorkspaceLoopBar.swift | 7 +- .../LoopWorkspace/LoopWorkspaceView.swift | 2 +- graphcode/Tests/SessionRestartTests.swift | 40 ++++++++++ 10 files changed, 165 insertions(+), 39 deletions(-) diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 34701560..e3e770c9 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -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 @@ -134,6 +137,12 @@ public actor GraphStore { static let maxSubGraphDepth = 6 static let maxNodesPerGraph = 50 private var goalPollers: [UUID: Task] = [:] + /// 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 @@ -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, @@ -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 @@ -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): @@ -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) } @@ -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") @@ -1997,18 +2010,40 @@ 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 @@ -2017,12 +2052,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 @@ -2714,6 +2749,7 @@ public actor GraphStore { onCaptureScript: onCaptureScript, onReadUsage: onReadUsage, onReadPresence: onReadPresence, + onSessionAlive: onSessionAlive, onAppendMemory: onAppendMemory, onRemoveMemory: onRemoveMemory, onRefinePlaybook: onRefinePlaybook, @@ -2875,7 +2911,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 } diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index 19951c8d..03165f8f 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -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`. @@ -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 @@ -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 @@ -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 diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index 818c8894..227e9df5 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -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) diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 59d32336..115b82e7 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -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 diff --git a/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift b/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift index cabf76e9..3ab17a33 100644 --- a/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift +++ b/graphcode/Sources/Features/App/AppFeature+LoopSessions.swift @@ -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 session — Stop 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. @@ -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 } diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 7369fb03..45d90ea4 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -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 diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift index 090cd32f..bf6fe5fa 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceFeature.swift @@ -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) } @@ -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 } diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceLoopBar.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceLoopBar.swift index aca02581..dc29425d 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceLoopBar.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceLoopBar.swift @@ -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) } @@ -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) diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index 53e60d38..35508ad6 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -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. diff --git a/graphcode/Tests/SessionRestartTests.swift b/graphcode/Tests/SessionRestartTests.swift index f8ebb2f5..556028f3 100644 --- a/graphcode/Tests/SessionRestartTests.swift +++ b/graphcode/Tests/SessionRestartTests.swift @@ -91,6 +91,46 @@ struct SessionRestartTests { #expect(await store.graph.nodes[id: ids[2]]?.sessionRestarts == 0) } + @Test + func aPaneExitRightAfterARestartDoesNotResolveTheLoop() async { + let memos = LockIsolated<[String]>([]) + let store = GraphStore( + onRestartSession: { _, _ in true }, + onAppendMemory: { _, entry in memos.withValue { $0.append(entry) } }) + await store.handle(.createNode(draft("Bounced"))) + let id = await store.graph.nodes[0].id + await store.handle(.restartNode(id)) + + await store.handle(.nodeCheckRejected(id)) + await store.handle(.nodeCheckApproved(id)) + + #expect(await store.graph.nodes[id: id]?.state == .running) + #expect(memos.value.filter { $0.contains("after a restart") }.count == 2) + } + + @Test + func aPaneClosingOnALiveSessionDoesNotResolveTheLoop() async { + let alive = LockIsolated(true) + let memos = LockIsolated<[String]>([]) + let store = GraphStore( + onSessionAlive: { _, _ in alive.value }, + onAppendMemory: { _, entry in memos.withValue { $0.append(entry) } }) + await store.handle(.createNode(draft("Closed on"))) + let id = await store.graph.nodes[0].id + + await store.handle(.nodeCheckRejected(id)) + #expect(await store.graph.nodes[id: id]?.state == .running) + #expect(memos.value.contains { $0.contains("still live — not resolved") }) + + alive.setValue(false) + await store.handle(.nodeCheckRejected(id)) + #expect(await store.graph.nodes[id: id]?.state == .failed) + #expect( + memos.value.contains { + $0.contains("resolved: failed — its pane closed with the process still running") + }) + } + @Test func aNodeSavedBeforeTheCounterExistedDecodesAtZero() throws { let json = #"{"id":"\#(UUID().uuidString)","title":"Old"}"# From 937db372931107394e388de3db0e72f86e49bc46 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 14:38:28 -0700 Subject: [PATCH 3/3] Format the resolution guard Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0199VUTtyfuWHbu2hHXpHYw2 --- GraphcodeKit/Sources/GraphStore.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index e3e770c9..e7c6d08d 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -2014,7 +2014,8 @@ 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 } - let report = "surface reported its pane " + 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 @@ -2023,7 +2024,8 @@ public actor GraphStore { 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") + 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 {