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: 0 additions & 20 deletions GraphcodeKit/Sources/Sessions/SessionIDStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,26 +67,6 @@ public enum SessionIDStore {
}
}

/// The daemon-side twin of the `SessionStart` hook's write, for a backend that has no
/// hook to bank its own ID (Copilot): history line first, then the pointer, and nothing
/// at all when the pointer already names this ID — an ensure tick must not grow the
/// history.
public static func bank(_ sessionID: String, forNodeID nodeID: UUID, workingDirectory: String) {
guard load(forNodeID: nodeID) != sessionID else { return }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let line = "\(Int(Date().timeIntervalSince1970)) \(sessionID) \(workingDirectory)\n"
// `O_APPEND`, not seek-then-write: the daemon's bank task and the app's bank-on-open
// can land on the same file in the same instant, and only an append-mode write
// keeps both lines.
let descriptor = open(
historyFile(forNodeID: nodeID).path, O_WRONLY | O_APPEND | O_CREAT, 0o644)
if descriptor >= 0 {
_ = Array(line.utf8).withUnsafeBytes { write(descriptor, $0.baseAddress, $0.count) }
close(descriptor)
}
save(sessionID, forNodeID: nodeID)
}

public static func load(forNodeID nodeID: UUID) -> String? {
let url = file(forNodeID: nodeID)
guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
Expand Down
115 changes: 22 additions & 93 deletions GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -508,17 +508,6 @@ public enum ZmxSessionLauncher {
return command
}

/// Positive evidence that a session's task has ended: its listing row is there and
/// carries `ended=`. The complement of `daemonReadyCheckCommand` is *not* this — that
/// check also fails on a row marked `err=` (a busy daemon missing zmx's one-second
/// probe) and on an empty listing, neither of which says the agent is gone. Anything
/// destructive keys off this one.
public static func sessionEndedCheckCommand(zmxPath: String, sessionName: String) -> String {
let name = RemoteProjectLocation.shellQuoted("name=\(sessionName)\t")
return RemoteProjectLocation.shellQuoted(zmxPath)
+ " ls 2>/dev/null | grep " + name + " | grep -q $'\\tended='"
}

public static func waitingAttachCommand(
zmxPath: String, sessionName: String, executable: String?
) -> [String] {
Expand Down Expand Up @@ -1561,12 +1550,19 @@ public enum ZmxSessionLauncher {
// no session a keystroke can reach, so the run branch relaunches it — this is
// what lets an ensure, a send, or the sweep wake a loop that died unattended
// (issue #215), which a `zmx get` check could never do.
let sessionID = resumableSessionID(forNodeID: node.id, backend: node.backend)
let sessionID: String? =
SessionIDStore.load(forNodeID: node.id)
?? {
switch node.backend {
case .copilotCLI:
let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
return CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent
case .claudeCode, .codex, .openCode:
return nil
}
}()
guard let runArgs = arguments(forNode: node, projectPath: projectPath) else { return }
let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName
let copilotSessionBefore =
node.backend == .copilotCLI
? CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent : nil
if let sessionID,
let resumeArgs = resumeArguments(
forNode: node, sessionID: sessionID, projectPath: projectPath)
Expand All @@ -1585,14 +1581,7 @@ public enum ZmxSessionLauncher {
// only if the session really failed to survive is the ID treated as dead: it is
// dropped and the fresh launch runs. A resume that took is left alone, and its
// `SessionStart` hook has already rebanked the same ID.
guard await sessionDiedImmediately(node: node) else {
// A resume that took is the same conversation as before, so its ID is what a
// Copilot node banks — the only backend that cannot bank one itself.
if node.backend == .copilotCLI {
bankCopilotSessionID(sessionID, forNodeID: node.id, workingDirectory: wd ?? "")
}
return
}
guard await sessionDiedImmediately(node: node) else { return }
DialLog.record(session: name, dial: "ensure", event: "resume-dead")
SessionIDStore.remove(forNodeID: node.id)
}
Expand All @@ -1609,77 +1598,18 @@ public enum ZmxSessionLauncher {
case .codex, .openCode: break
}
}
// `copilotSessionBefore` was noted *before* the launch: the first pass and the ID
// bank below both wait for a Copilot session directory that was not already there,
// which is how they tell a session this ensure just started from one it found
// already running.
// Noted *before* the launch: the first pass below waits for a Copilot session
// directory that was not already there, which is how it tells a session it just
// started from one this ensure found already running.
let copilotSessionBefore =
firstPassMessage(for: node) != nil
? CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent : nil
await atomicCheckOrRun(
checkCommand: aliveCheck, runArguments: runArgs,
zmxPath: zmxPath, workingDirectory: wd,
logFragment: DialLog.fragment(session: name, dial: "ensure", event: "fresh"))
await kickOffFirstPass(
of: node, sessionNamed: name, projectPath: projectPath, after: copilotSessionBefore)
await bankCopilotSessionIDWhenItAppears(
forNode: node, sessionNamed: name, workingDirectory: wd, after: copilotSessionBefore)
}

/// The ID a node's session would resume from: the banked pointer, or for Copilot — the
/// one backend with no hook to bank its own — the session-state directory carrying
/// the `--name` graphcode launched it with. Public because the app's open path
/// (`GhosttyTerminalView.localResumeOrFreshCommand`) must make the same choice: for
/// as long as it read only the pointer, a local Copilot loop whose session was gone
/// was relaunched from its goal under the same name, and nothing logged the duplicate.
public static func resumableSessionID(forNodeID nodeID: UUID, backend: CLISessionBackendKind)
-> String?
{
if let banked = SessionIDStore.load(forNodeID: nodeID) { return banked }
guard backend == .copilotCLI else { return nil }
let name = SurfaceRef(id: nodeID, launchesClaudeCode: true).zmxSessionName
return CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent
}

/// Banks a Copilot session's resume ID on this machine — the local twin of
/// `CopilotSessionLog.remoteIDBankFragment`, and the same dial-log line. Before this
/// the daemon rediscovered the directory on every ensure and the app never looked, so
/// the two launchers could disagree on whether there was anything to resume.
public static func bankCopilotSessionID(
_ sessionID: String, forNodeID nodeID: UUID, workingDirectory: String
) {
guard SessionIDStore.load(forNodeID: nodeID) != sessionID else { return }
SessionIDStore.bank(sessionID, forNodeID: nodeID, workingDirectory: workingDirectory)
let name = SurfaceRef(id: nodeID, launchesClaudeCode: true).zmxSessionName
DialLog.record(session: name, dial: "bank", event: "copilot-id")
}

/// After a fresh ensure, waits for the directory a just-launched Copilot creates —
/// one that was not there before — and banks it. Nothing is banked when the wait runs
/// out: this cannot tell a session the ensure found already running (no new directory,
/// ever) from a cold `copilot` still at its trust dialog (a new directory, later), and
/// banking the old directory in the second case would point the next resume at the
/// dead conversation — the orphaning this whole change exists to end. An unbanked
/// Copilot node loses nothing: `resumableSessionID` falls back to the newest directory
/// with its name, and the next resume that takes banks it.
private static func bankCopilotSessionIDWhenItAppears(
forNode node: LoopNode, sessionNamed name: String, workingDirectory: String?,
after previous: String?
) async {
guard node.backend == .copilotCLI else { return }
Task {
guard await NodeTickets.copilotBank.claim(node.id) else { return }
defer { Task { await NodeTickets.copilotBank.release(node.id) } }
let deadline = Date().addingTimeInterval(firstPassWaitSeconds)
var observed: String?
while Date() < deadline, observed == nil {
let current = CopilotSessionLog.directory(forSessionNamed: name)?.lastPathComponent
if let current, current != previous {
observed = current
} else {
try? await Task.sleep(for: .seconds(firstPassPollSeconds))
}
}
guard let sessionID = observed else { return }
bankCopilotSessionID(sessionID, forNodeID: node.id, workingDirectory: workingDirectory ?? "")
}
}

/// The message that gives a Copilot time-based loop the pass its schedule will not give
Expand Down Expand Up @@ -1725,8 +1655,8 @@ public enum ZmxSessionLauncher {
) async {
guard let message = firstPassMessage(for: node), let projectPath else { return }
Task {
guard await NodeTickets.firstPass.claim(node.id) else { return }
defer { Task { await NodeTickets.firstPass.release(node.id) } }
guard await FirstPassTickets.shared.claim(node.id) else { return }
defer { Task { await FirstPassTickets.shared.release(node.id) } }
let deadline = Date().addingTimeInterval(firstPassWaitSeconds)
var observed: String?
while Date() < deadline, observed == nil {
Expand Down Expand Up @@ -1840,9 +1770,8 @@ public enum ZmxSessionLauncher {

/// One first-pass message per node at a time. Two ensure ticks that both see a fresh
/// session would otherwise type the task in twice.
private actor NodeTickets {
static let firstPass = NodeTickets()
static let copilotBank = NodeTickets()
private actor FirstPassTickets {
static let shared = FirstPassTickets()
private var claimed: Set<UUID> = []
func claim(_ id: UUID) -> Bool { claimed.insert(id).inserted }
func release(_ id: UUID) { claimed.remove(id) }
Expand Down
4 changes: 2 additions & 2 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,8 @@ let project = Project(
// (#33). Before that the suffix lived on the tag only, so betas 48/49
// of the 0.1.15 line read "0.1.15" and are told by the build number
// apart.
"CFBundleShortVersionString": "0.1.58-beta9",
"CFBundleVersion": "230",
"CFBundleShortVersionString": "0.1.58-beta7",
"CFBundleVersion": "228",
]),
resources: [
"graphcode/Resources/**"
Expand Down
96 changes: 32 additions & 64 deletions graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -353,88 +353,56 @@ struct GhosttyTerminalView: NSViewRepresentable {
/// at them. Every reboot afterwards faithfully resumed the near-empty replacements.
///
/// So this path resumes too, and the two launchers now make the same choice from the
/// same banked ID. The check-then-launch is one shell script rather than an argv
/// same banked ID. The check-then-launch is one `/bin/sh` script rather than an argv
/// because the decision has to be made *here*, on the machine, at the moment the pane
/// opens: whether a session exists, and whether the resume survived, are both facts
/// only the shell holding the terminal can see.
///
/// Every local agent launch takes this script — there is no silent branch. The one
/// there used to be, a bare `zmx attach <name> <agent> <prompt>` for a node with
/// nothing banked, is how a Copilot loop (no hook to bank its own ID; the daemon found
/// its session directory by name, this view never looked) was relaunched from its goal
/// under the same `--name` after every reboot, a second Copilot session for one loop
/// that no dial log ever recorded. Now the ID is discovered and banked first
/// (`ZmxSessionLauncher.resumableSessionID`), and a fresh launch says so.
///
/// The liveness check is the daemon's husk-aware one (`daemonReadyCheckCommand`), not
/// `zmx get`: a session whose agent died leaves its shell at a prompt, which answers
/// `zmx get` for as long as the machine stays up. Attaching to that showed the corpse
/// and nothing more. Such a husk is killed and the launch proceeds as if the session
/// were gone, which keeps the resume-or-fresh verdict below measurable.
///
/// The kill needs *positive* evidence of death — a listing row carrying `ended=`
/// (`sessionEndedCheckCommand`) — not the liveness check's failure. That check also
/// fails on a row zmx marks `err=`, which is a busy daemon missing its one-second probe
/// and nothing more; zmx itself refuses to clean up on that. Killing on it would take a
/// live agent down mid-turn at exactly the moment a human opens loops after a reboot,
/// and the same evidence rule keeps this from racing the daemon: its relaunch into a
/// husk clears the `ended=` mark the instant the command is typed in.
///
/// Deliberately *not* consuming the ID up front, which is what the remote path does:
/// there, one restorer owns the loop, and here the daemon's ensure may be running the
/// same resume concurrently. Two consumers racing on one `rm` is how the loser falls
/// through to a fresh launch — the very failure this exists to end. It is dropped only
/// once a resume has been *seen* to fail, which is also the check the daemon makes.
///
/// `nil` only for a surface that is not a node's; the plain agent argv is what that
/// gets.
/// `nil` only for a surface that is not a node's. A backend that cannot resume, or a
/// node with nothing banked, takes the ordinary fresh launch — logged, where it used
/// to be the one silent branch (the duplicate-session investigation of 2026-09-02
/// started from exactly that silence; the revert of #248/#249 keeps the dial).
func localResumeOrFreshCommand(agentLaunch: [String]) -> [String]? {
guard let nodeID = SurfaceRef.nodeID(fromZmxSessionName: sessionName) else { return nil }
let settings = GraphcodeSettingsStore.load()
if SessionIDStore.load(forNodeID: nodeID) == nil,
let discovered = ZmxSessionLauncher.resumableSessionID(forNodeID: nodeID, backend: backend)
{
ZmxSessionLauncher.bankCopilotSessionID(
discovered, forNodeID: nodeID, workingDirectory: workingDirectory ?? "")
let log = { (event: String) in
DialLog.fragment(session: self.sessionName, dial: "open", event: event) + "; "
}
let resumeLaunch = resumeCommand(
settings: settings, hooksFile: presenceHooksFile(), remoteSettingsPath: nil)
let zmx = ZmxLocator.binaryURL.path
let fresh = ZmxSessionLauncher.quotedCommand([zmx, "attach", sessionName] + agentLaunch)
let settings = GraphcodeSettingsStore.load()
guard SessionIDStore.load(forNodeID: nodeID) != nil,
let resumeLaunch = resumeCommand(
settings: settings, hooksFile: presenceHooksFile(), remoteSettingsPath: nil)
else {
return ["/bin/sh", "-c", log("fresh") + "exec \(fresh)"]
}
let quoted = RemoteProjectLocation.shellQuoted
let idFile = quoted(SessionIDStore.file(forNodeID: nodeID).path)
let attach = ZmxSessionLauncher.quotedCommand([zmx, "attach", sessionName])
let fresh = ZmxSessionLauncher.quotedCommand([zmx, "attach", sessionName] + agentLaunch)
let live = ZmxSessionLauncher.daemonReadyCheckCommand(
zmxPath: zmx, sessionName: sessionName, executable: nil)
let ended = ZmxSessionLauncher.sessionEndedCheckCommand(
zmxPath: zmx, sessionName: sessionName)
let kill = ZmxSessionLauncher.quotedCommand([zmx, "kill", sessionName])
let resume = ZmxSessionLauncher.quotedCommand(
[zmx, "attach", sessionName] + resumeLaunch)
let exists = ZmxSessionLauncher.quotedCommand([zmx, "get", sessionName])
// A live session is joined as it always was — the resume argv would be ignored by
// `zmx attach` anyway, and building it costs a settings read nobody needs.
let idVariable = ZmxSessionLauncher.remoteResumeIDVariable
let settle = ZmxSessionLauncher.resumeSettleSeconds
let log = { (event: String) in
DialLog.fragment(session: self.sessionName, dial: "open", event: event) + "; "
}
// A live session is joined as it always was — the resume argv would be ignored by
// `zmx attach` anyway.
let joined = "\(live) >/dev/null 2>&1 && { \(log("attach-live"))exec \(attach); }; "
let revive = "\(ended) >/dev/null 2>&1 && { \(log("husk-killed"))\(kill) >/dev/null 2>&1; }; "
var script = joined + revive
if let resumeLaunch {
let resume = ZmxSessionLauncher.quotedCommand([zmx, "attach", sessionName] + resumeLaunch)
let read = "\(idVariable)=$(cat \(idFile) 2>/dev/null); "
let attempt =
"if [ -n \"$\(idVariable)\" ]; then export \(idVariable); \(log("resume"))"
+ "gc_t0=$(date +%s); "
+ resume + "; gc_rc=$?; "
let verdict =
"[ $(($(date +%s) - gc_t0)) -ge \(settle) ] && exit \"$gc_rc\"; rm -f \(idFile); "
+ log("resume-dead")
+ #"printf '\033[1;33m── Resume did not take; starting fresh. ──\033[0m\r\n'; fi; "#
script += read + attempt + verdict
}
script += log("fresh") + "exec \(fresh)"
// zsh, like the daemon's own check-or-run: the alive check's `$'\t'` needs a shell
// that reads ANSI-C quoting, which `/bin/sh` is only when it happens to be bash.
return ["/bin/zsh", "-c", script]
let joined = "\(exists) >/dev/null 2>&1 && { \(log("attach-live"))exec \(attach); }; "
let read = "\(idVariable)=$(cat \(idFile) 2>/dev/null); "
let attempt =
"if [ -n \"$\(idVariable)\" ]; then export \(idVariable); \(log("resume"))"
+ "gc_t0=$(date +%s); "
+ resume + "; gc_rc=$?; "
let verdict =
"[ $(($(date +%s) - gc_t0)) -ge \(settle) ] && exit \"$gc_rc\"; rm -f \(idFile); "
+ log("resume-dead")
+ #"printf '\033[1;33m── Resume did not take; starting fresh. ──\033[0m\r\n'; fi; "#
let script = joined + read + attempt + verdict + log("fresh") + "exec \(fresh)"
return ["/bin/sh", "-c", script]
}
}
Loading
Loading