diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index 969e08f7..08097e7b 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -678,7 +678,9 @@ extension GraphcodeCommand { } for node in graph.nodes { var line = " \(node.id) \(node.displayState) \(node.loopType) \(node.title)" - if let reason = AttentionRollup.reason(for: node) { + if let exitCode = node.presence?.exitCode { + line += " ← session exited (\(exitCode))" + } else if let reason = AttentionRollup.reason(for: node) { line += " ← \(reason.displayName)" } lines.append(line) diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index bc45717e..f5c4c41a 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -335,6 +335,7 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// `nil` and `.unknown` count as no: opening is what could *start* a session, and a /// gate deciding whether that's safe must not treat "don't know" as "yes". public var presenceShowsLiveSession: Bool { + if presence?.exitCode != nil { return false } switch presence?.presence { case .busy, .idle, .awaitingInput: return true case .absent, .unknown, nil: return false @@ -366,6 +367,9 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// untouched, which is exactly the behaviour every surface had before presence existed. public var displayState: LoopState { guard state == .running, let presence = presence?.presence else { return state } + if let exitCode = self.presence?.exitCode { + return exitCode == 0 ? .idle : .failed + } switch presence { case .busy: return .running case .idle: return hasActiveDependents ? .waiting : .idle diff --git a/GraphcodeKit/Sources/Domain/Presence.swift b/GraphcodeKit/Sources/Domain/Presence.swift index bd6a6641..3b956781 100644 --- a/GraphcodeKit/Sources/Domain/Presence.swift +++ b/GraphcodeKit/Sources/Domain/Presence.swift @@ -43,10 +43,12 @@ public enum PresenceConfidence: String, Codable, Equatable, Sendable { public struct PresenceReading: Codable, Equatable, Sendable { public var presence: Presence public var confidence: PresenceConfidence + public var exitCode: Int? - public init(presence: Presence, confidence: PresenceConfidence) { + public init(presence: Presence, confidence: PresenceConfidence, exitCode: Int? = nil) { self.presence = presence self.confidence = confidence + self.exitCode = exitCode } public static let absent = PresenceReading(presence: .absent, confidence: .reported) diff --git a/GraphcodeKit/Sources/Sessions/ClaudeCodeTrust.swift b/GraphcodeKit/Sources/Sessions/ClaudeCodeTrust.swift index 62c3da9e..ccee90c4 100644 --- a/GraphcodeKit/Sources/Sessions/ClaudeCodeTrust.swift +++ b/GraphcodeKit/Sources/Sessions/ClaudeCodeTrust.swift @@ -18,7 +18,9 @@ public enum ClaudeCodeTrust { } /// Sets `projects[directory].hasTrustDialogAccepted = true` unless it is already set. - /// Never throws and never clobbers: a config it cannot parse is left exactly as found. + /// Never throws and never clobbers: a config it cannot parse is left exactly as found, + /// and so is a config whose `projects` (or the project's own entry) holds something + /// other than the expected object — replacing it would trade one known value for a guess. public static func ensureTrusted(directory: String, configURL: URL = configURL) { guard !directory.isEmpty else { return } let fileManager = FileManager.default @@ -30,7 +32,9 @@ public enum ClaudeCodeTrust { else { return } config = dictionary } + if let value = config["projects"], !(value is [String: Any]) { return } var projects = config["projects"] as? [String: Any] ?? [:] + if let value = projects[directory], !(value is [String: Any]) { return } var entry = projects[directory] as? [String: Any] ?? [:] guard (entry["hasTrustDialogAccepted"] as? Bool) != true else { return } entry["hasTrustDialogAccepted"] = true diff --git a/GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift b/GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift index edaa7835..fcd2f704 100644 --- a/GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift +++ b/GraphcodeKit/Sources/Sessions/CopilotSessionLog.swift @@ -466,6 +466,8 @@ public enum CopilotSessionLog { switch status { case .unreachable: return .unknown case .absent: return .absent + case .exited(let code): + return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code) case .live(let label): guard let label, let event = parseCopilotEventLabel(label), let p = presence(forEvent: event) diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 33c4d6a7..5aafc96f 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -313,14 +313,30 @@ public enum ZmxSessionLauncher { of: node, at: remote, liveWithoutLabel: PresenceReading(presence: .idle, confidence: .heuristic)) } - guard ZmxLocator.isInstalled, await sessionExists(node) else { return .absent } + guard ZmxLocator.isInstalled else { return .absent } + // The task state, not the session record, is the truth a husk hides: `zmx ls` + // prints `ended=`/`exit_code=` only for a completed task (`sessionTaskState`), so + // an exit read here is zmx's own bookkeeping — never a marker a transcript could + // have quoted. An exited task's code rides the reading; the wrapper shell left + // behind has nothing to say. + switch await sessionTaskState(node) { + case .absent: return .absent + case .exited(let code): + return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code) + case .alive: break + } guard let session = try? PTYProcessSession( executable: ZmxLocator.binaryURL.path, arguments: presenceLabelArguments(forNode: node)) - else { return PresenceReading(presence: .idle, confidence: .heuristic) } + else { + return PresenceReading(presence: .idle, confidence: .heuristic) + } let (succeeded, output) = await session.waitCollectingOutput() + if succeeded, let reported = parsePresenceLabel(output), reported == .busy { + return PresenceReading(presence: reported, confidence: .reported) + } guard succeeded, let reported = parsePresenceLabel(output) else { return PresenceReading(presence: .idle, confidence: .heuristic) } @@ -353,7 +369,13 @@ public enum ZmxSessionLauncher { of: node, at: remote, liveWithoutLabel: PresenceReading(presence: .busy, confidence: .scanned)) } - guard ZmxLocator.isInstalled, await sessionExists(node) else { return .absent } + guard ZmxLocator.isInstalled else { return .absent } + switch await sessionTaskState(node) { + case .absent: return .absent + case .exited(let code): + return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code) + case .alive: break + } guard let session = try? PTYProcessSession( executable: ZmxLocator.binaryURL.path, @@ -1275,6 +1297,7 @@ public enum ZmxSessionLauncher { enum RemoteSessionStatus: Equatable { case unreachable case absent + case exited(code: Int) case live(label: String?) } @@ -1296,9 +1319,13 @@ public enum ZmxSessionLauncher { let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName let check = quotedCommand(["zmx", "get", name]) let read = quotedCommand(["zmx", "get", name, label]) + let history = quotedCommand(["zmx", "history", name]) let script = "if \(check) >/dev/null 2>&1; then " - + "echo \"\(remoteProbeMarker) live $(\(read) 2>/dev/null)\"; " + + "gc_done=$(\(history) 2>/dev/null | tail -c 4096 | " + + "sed -n 's/.*ZMX_TASK_COMPLETED:\\([0-9][0-9]*\\).*/\\1/p' | tail -1); " + + "if [ -n \"$gc_done\" ]; then echo \"\(remoteProbeMarker) exited $gc_done\"; " + + "else echo \"\(remoteProbeMarker) live $(\(read) 2>/dev/null)\"; fi; " + "else echo '\(remoteProbeMarker) absent'; fi" return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script)) } @@ -1329,6 +1356,11 @@ public enum ZmxSessionLauncher { let status = marked.dropFirst(remoteProbeMarker.count) .trimmingCharacters(in: .whitespaces) if status == "absent" { return .absent } + if status.hasPrefix("exited "), + let code = Int(status.dropFirst("exited ".count).trimmingCharacters(in: .whitespaces)) + { + return .exited(code: code) + } guard status.hasPrefix("live") else { return .unreachable } let label = status.dropFirst("live".count).trimmingCharacters(in: .whitespaces) return .live(label: label.isEmpty ? nil : label) @@ -1352,6 +1384,8 @@ public enum ZmxSessionLauncher { switch status { case .unreachable: return .unknown case .absent: return .absent + case .exited(let code): + return PresenceReading(presence: .idle, confidence: .scanned, exitCode: code) case .live(let label): guard let label, let reported = parsePresenceLabel(label) else { return liveWithoutLabel } return PresenceReading(presence: reported, confidence: .reported) diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 7f3f3710..cca814d5 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -78,6 +78,11 @@ struct GhosttyTerminalView: NSViewRepresentable { /// view showing it. func makeNSView(context: Context) -> TerminalSurfaceHostView { let host = TerminalSurfaceHostView() + if launchesClaudeCode, backend == .claudeCode, remoteLocation == nil { + if let workingDirectory { + ClaudeCodeTrust.ensureTrusted(directory: workingDirectory) + } + } let view = TerminalSurfaceStore.shared.surface(for: surfaceID) { // A remote surface needs the daemon's socket present on its host before the // delivered CLI can reach the graph — same forward the daemon's own launches @@ -320,10 +325,20 @@ struct GhosttyTerminalView: NSViewRepresentable { if let resuming = localResumeOrFreshCommand(agentLaunch: agentCommand) { return resuming } + // Unattended Codex sessions are started by graphcoded. Attaching with the agent + // command as well creates a race where zmx run types that command into Codex. + if defersCodexLaunchToDaemon { + return command + } command += agentCommand return command } + var defersCodexLaunchToDaemon: Bool { + backend == .codex && launchesClaudeCode + && (loopType == .goalBased || loopType == .timeBased) + } + /// Opening a loop whose session is gone used to start the agent **fresh**, prompt and /// all, because this was the one launch path that could not resume — `agentCommand` /// carries `sessionPrompt`, and only the daemon knew about `SessionIDStore`. diff --git a/graphcode/Tests/AttachedSessionBriefingTests.swift b/graphcode/Tests/AttachedSessionBriefingTests.swift index 9e086618..7f57a5d8 100644 --- a/graphcode/Tests/AttachedSessionBriefingTests.swift +++ b/graphcode/Tests/AttachedSessionBriefingTests.swift @@ -17,11 +17,11 @@ struct AttachedSessionBriefingTests { private func surface( _ backend: CLISessionBackendKind, launchesClaudeCode: Bool = true, - initialPrompt: String? = "go" + initialPrompt: String? = "go", loopType: LoopType = .turnBased ) -> GhosttyTerminalView { GhosttyTerminalView( surfaceID: UUID(), sessionName: "s", launchesClaudeCode: launchesClaudeCode, - backend: backend, initialPrompt: initialPrompt, workingDirectory: nil, + backend: backend, loopType: loopType, initialPrompt: initialPrompt, workingDirectory: nil, projectPath: "/tmp/proj", onProcessExited: { _ in }) } @@ -62,6 +62,14 @@ struct AttachedSessionBriefingTests { #expect(prompt.contains(briefing)) } + @Test + func unattendedCodexLeavesFirstLaunchToTheDaemon() { + #expect(surface(.codex, loopType: .goalBased).defersCodexLaunchToDaemon) + #expect(surface(.codex, loopType: .timeBased).defersCodexLaunchToDaemon) + #expect(!surface(.codex).defersCodexLaunchToDaemon) + #expect(!surface(.claudeCode, loopType: .goalBased).defersCodexLaunchToDaemon) + } + @Test func noBriefingMeansTheCommandAndPromptOfBefore() { let command = diff --git a/graphcode/Tests/ClaudeCodeTrustTests.swift b/graphcode/Tests/ClaudeCodeTrustTests.swift index a7a8911c..8fa32efc 100644 --- a/graphcode/Tests/ClaudeCodeTrustTests.swift +++ b/graphcode/Tests/ClaudeCodeTrustTests.swift @@ -79,6 +79,16 @@ struct ClaudeCodeTrustTests { #expect(try String(contentsOf: url, encoding: .utf8) == "{not json at all") } + /// A `projects` value the seed does not understand is left exactly as found too: + /// replacing it with a dictionary would trade the user's real state for a guess. + @Test + func anUnexpectedProjectsShapeIsLeftUntouched() throws { + let url = temporaryConfig(#"{"projects":"unexpected"}"#) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + ClaudeCodeTrust.ensureTrusted(directory: "/tmp/project", configURL: url) + #expect(try String(contentsOf: url, encoding: .utf8) == #"{"projects":"unexpected"}"#) + } + @Test func anEmptyDirectoryIsNeverWritten() throws { let url = temporaryConfig(#"{"projects":{}}"#) diff --git a/graphcode/Tests/GraphcodeCommandTests.swift b/graphcode/Tests/GraphcodeCommandTests.swift index 1b821e5d..8d2705b2 100644 --- a/graphcode/Tests/GraphcodeCommandTests.swift +++ b/graphcode/Tests/GraphcodeCommandTests.swift @@ -106,12 +106,14 @@ struct GraphcodeCommandTests { func aDaemonBackedCodexTimeLoopIsAccepted() throws { // Codex has no in-session recurrence, but a leading simple directive is converted to // the graphcode daemon cadence rather than rejected as an unsupported pairing. - #expect(throws: Never.self, performing: { - try GraphcodeCommand.parse([ - "node", "create", "/tmp/x", "--title", "Poll", "--type", "time", - "--prompt", "/loop 1h Check", "--backend", "codex", - ]) - }) + #expect( + throws: Never.self, + performing: { + try GraphcodeCommand.parse([ + "node", "create", "/tmp/x", "--title", "Poll", "--type", "time", + "--prompt", "/loop 1h Check", "--backend", "codex", + ]) + }) // And the pairing that is fine now, which is the point of the change. #expect( throws: Never.self, @@ -314,6 +316,21 @@ struct GraphcodeCommandTests { #expect(output.contains("Failed")) } + @Test + func renderingAGraphShowsTheBackendExitCode() { + let node = LoopNode( + title: "Trust dialog", loopType: .goalBased, goal: GoalSpec(summary: "work"), + presence: PresenceReading(presence: .idle, confidence: .scanned, exitCode: 1), + state: .running) + let graph = LoopGraph( + project: ProjectRef(path: "/tmp/x", name: "x"), nodes: [node]) + + let output = GraphcodeCommand.render(graph) + + #expect(output.contains("failed")) + #expect(output.contains("session exited (1)")) + } + @Test func renderingAnEmptyGraphSaysSoRatherThanPrintingNothing() { let output = GraphcodeCommand.render( diff --git a/graphcode/Tests/PresenceReportingTests.swift b/graphcode/Tests/PresenceReportingTests.swift index d250d79e..e4cd15bd 100644 --- a/graphcode/Tests/PresenceReportingTests.swift +++ b/graphcode/Tests/PresenceReportingTests.swift @@ -319,6 +319,16 @@ struct PresenceReportingTests { #expect(node(.running, .absent).displayState == .idle) } + @Test + func aNonzeroBackendExitIsSurfacedAsFailureWithoutResolvingTheLoop() { + var exited = node(.running, .idle) + exited.presence = PresenceReading(presence: .idle, confidence: .scanned, exitCode: 1) + + #expect(exited.displayState == .failed) + #expect(exited.state == .running) + #expect(!exited.presenceShowsLiveSession) + } + @Test func aGoneSessionOnAnUnattendedLoopPastTheGraceIsFailed() { // Issue #215: a goal loop whose agent exited on its first turn showed IDLE — the @@ -404,6 +414,13 @@ struct PresenceReportingTests { #expect(idleDecoded.presence?.presence == .idle) #expect(idleDecoded.displayState == .idle) + + var exited = node(.running, .idle) + exited.presence = PresenceReading(presence: .idle, confidence: .scanned, exitCode: 1) + let exitDecoded = try JSONDecoder().decode( + LoopNode.self, from: JSONEncoder().encode(exited)) + #expect(exitDecoded.presence?.exitCode == 1) + #expect(exitDecoded.displayState == .failed) } // MARK: - Remote Copilot event-log presence diff --git a/graphcode/Tests/RemoteSessionLaunchTests.swift b/graphcode/Tests/RemoteSessionLaunchTests.swift index ca521190..89b9b825 100644 --- a/graphcode/Tests/RemoteSessionLaunchTests.swift +++ b/graphcode/Tests/RemoteSessionLaunchTests.swift @@ -107,12 +107,28 @@ struct RemoteSessionLaunchTests { #expect(invocation.first == "/usr/bin/ssh") let remoteCommand = try #require(invocation.last) #expect(remoteCommand.contains("send")) + // The send is gated on the husk-aware alive check (#215): a session whose task has + // ended must fail it, or the keystrokes land at the husk's shell prompt. + #expect(remoteCommand.contains("ls 2>/dev/null")) + #expect(remoteCommand.contains("ended=")) #expect(remoteCommand.contains("task done")) #expect(remoteCommand.contains("sleep")) #expect( remoteCommand.contains(SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName)) } + @Test + func aRemoteCompletedTaskCarriesItsExitCode() { + let status = ZmxSessionLauncher.parseRemoteStatus( + succeeded: true, output: "graphcode-status: exited 1") + #expect(status == .exited(code: 1)) + let reading = ZmxSessionLauncher.presenceReading( + from: status, + liveWithoutLabel: PresenceReading(presence: .idle, confidence: .heuristic)) + #expect(reading.exitCode == 1) + #expect(reading.confidence == .scanned) + } + @Test func aRemoteCodexMessageClearsItsIdleLabelAfterSubmission() throws { let node = LoopNode(