diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index ab62cc12..93279b8b 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -32,6 +32,8 @@ public enum GraphcodeCommand: Equatable, Sendable { /// matching how `updateNode` fills `updatedBy`. case promoteNode(projectPath: String, nodeID: UUID, promotion: SketchPromotion) case memoNode(projectPath: String, nodeID: UUID, text: String) + /// Report a goal loop's goal as met; the trailing words are the result, optional. + case completeNode(projectPath: String, nodeID: UUID, result: String?) /// Replace the loop's playbook (`NodeMemory.refinePlaybook`) — trailing words, or a /// whole file via `--file` since a playbook is a multi-line document and argv words /// arrive flattened. `--rollback` restores the previous version instead. @@ -97,6 +99,7 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode node promote --type [options] give a main loop a shape, keeping its session, edges and memory graphcode node memo + graphcode node done [result…] graphcode node refine graphcode node pilot dry-run a composite graphcode node arm arm it (needs a pilot first) @@ -216,6 +219,10 @@ public enum GraphcodeCommand: Equatable, Sendable { node memo appends a note to the loop's own memory log — what the next pass reads before starting. Record dead ends and decisions, not a transcript. + node done reports a goal loop's goal as met, with an optional result. Run it only + once the goal holds — never while waiting on mail, CI, or loops it created. A + predicate still decides, and a leader resolves once the loops it created have. + node refine replaces the loop's playbook — its own distilled method, carried into every wake ahead of the history. Whole document each time (--file for multi-line); the old version is snapshotted, --rollback restores it. A loop may refine itself; @@ -350,7 +357,7 @@ public enum GraphcodeCommand: Equatable, Sendable { } return .createNode(projectPath: path, draft: try parseDraft(arguments), into: into) case "stop", "restart", "delete", "pilot", "arm", "send", "update", "memo", "promote", - "refine": + "refine", "done": let raw = try take(&arguments, name: "node-id") guard let nodeID = UUID(uuidString: raw) else { throw ParseError.invalidValue(argument: "node-id", value: raw) @@ -392,6 +399,10 @@ public enum GraphcodeCommand: Equatable, Sendable { let text = arguments.joined(separator: " ").trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { throw ParseError.missingArgument("note") } return .memoNode(projectPath: path, nodeID: nodeID, text: text) + case "done": + let result = arguments.joined(separator: " ").trimmingCharacters(in: .whitespaces) + return .completeNode( + projectPath: path, nodeID: nodeID, result: result.isEmpty ? nil : result) case "refine": return try parseRefine(arguments, projectPath: path, nodeID: nodeID) default: diff --git a/GraphcodeKit/Sources/Domain/GoalVerdict.swift b/GraphcodeKit/Sources/Domain/GoalVerdict.swift new file mode 100644 index 00000000..8e8e2c2a --- /dev/null +++ b/GraphcodeKit/Sources/Domain/GoalVerdict.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A backend's own answer to "is this session's goal met?", read out of what the backend +/// records — never inferred from a turn ending. A turn ends while a loop waits on mail, +/// CI or its children; only a goal-specific record says the condition holds (#346). +public struct GoalVerdict: Equatable, Sendable { + public var met: Bool + /// The backend's stated reason, when it gives one — Claude Code's evaluator does. + public var detail: String? + /// When the backend wrote the record, so a verdict on an earlier goal can be told apart + /// from one on the goal the loop has now. + public var recordedAt: Date? + + public init(met: Bool, detail: String? = nil, recordedAt: Date? = nil) { + self.met = met + self.detail = detail + self.recordedAt = recordedAt + } +} diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index 978b39cc..b851d202 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -293,6 +293,15 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { /// (`SessionBriefing`). Off means loops behave exactly as they did before briefings /// existed — they do the work they were given and never create anything. public var briefsSessionsAboutTheGraph: Bool + /// How long a resolved loop's session is kept after it resolves, in minutes; `0` keeps + /// it until someone deletes the loop. Ending it frees the agent process and keeps the + /// transcript, so opening the loop resumes the conversation (#346). A number rather than + /// an optional because an encoded `nil` is an absent key, which reads back as the default. + public var endsResolvedSessionsAfterMinutes: Int + + public var resolvedSessionGrace: Duration? { + endsResolvedSessionsAfterMinutes > 0 ? .seconds(endsResolvedSessionsAfterMinutes * 60) : nil + } /// Whether graphcode picks a model for loops nobody chose one for. /// @@ -441,8 +450,10 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { daemonHeartbeatEnabled: Bool = false, mailroomEnabled: Bool = true, keepsMacAwakeWhileLoopsRun: Bool = false, + endsResolvedSessionsAfterMinutes: Int = 10, worktreePolicies: [String: WorktreeHygienePolicy] = [:] ) { + self.endsResolvedSessionsAfterMinutes = endsResolvedSessionsAfterMinutes self.defaultBackend = defaultBackend.isSpiked ? defaultBackend : .claudeCode self.codexApprovals = codexApprovals self.openCodePermissions = openCodePermissions @@ -485,6 +496,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { ?? .allowEverything briefsSessionsAboutTheGraph = try container.decodeIfPresent(Bool.self, forKey: .briefsSessionsAboutTheGraph) ?? true + endsResolvedSessionsAfterMinutes = + max(0, try container.decodeIfPresent(Int.self, forKey: .endsResolvedSessionsAfterMinutes) ?? 10) // Absent in files written before the setting existed, and those loops were all being // routed by graphcode. They take the new default — off — which is the point of #10: // the fix has to reach people who already have a settings file, not just new ones. diff --git a/GraphcodeKit/Sources/Domain/LoopNode.swift b/GraphcodeKit/Sources/Domain/LoopNode.swift index 9976bebc..d246023b 100644 --- a/GraphcodeKit/Sources/Domain/LoopNode.swift +++ b/GraphcodeKit/Sources/Domain/LoopNode.swift @@ -195,6 +195,16 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { /// Set when the daemon stopped this loop because its backend's CLI is not on the /// launch shell's PATH; cleared by the restart that follows the fix. public var launchFailure: LaunchFailure? + /// How the loop resolved; `nil` while it is unresolved, and for loops resolved before + /// the field existed. + public var resolution: LoopResolution? + /// A completion reported while loops this one created were still unresolved — held, + /// and applied the moment the last of them resolves. A leader whose own part is done + /// is not done while its workers run. + public var pendingCompletion: LoopResolution? + /// When the goal was last replaced; `nil` means it is still the one the loop was created + /// with. A backend verdict recorded before this belongs to an earlier goal. + public var goalSetAt: Date? public var state: LoopState public var createdAt: Date @@ -495,7 +505,8 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { case lastMailroomRead, mailroomWatch case state, createdAt, activity, presence, firstInstruction, pausesBeforeWritesOnly case summary, board, heartbeatIntervalSeconds, stallReason - case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure + case createdFromTemplateID, templateFollow, sessionRestarts, launchFailure, resolution + case pendingCompletion, goalSetAt } /// Hand-written for the same reason `LoopEdge`'s is: `ProjectPersistence.loadGraph` @@ -552,6 +563,12 @@ public struct LoopNode: Identifiable, Codable, Equatable, Sendable { ?? decoder.legacyMailroomValue(MailroomWatch.self, "artifactoryWatch") stallReason = try container.decodeIfPresent(String.self, forKey: .stallReason) launchFailure = try container.decodeIfPresent(LaunchFailure.self, forKey: .launchFailure) + // `try?`: a basis added by a newer daemon must cost an older app the label, not the + // whole graph — a client cannot skip a frame it fails to decode. + resolution = try? container.decodeIfPresent(LoopResolution.self, forKey: .resolution) + pendingCompletion = + try? container.decodeIfPresent(LoopResolution.self, forKey: .pendingCompletion) + goalSetAt = try? container.decodeIfPresent(Date.self, forKey: .goalSetAt) state = try container.decodeIfPresent(LoopState.self, forKey: .state) ?? .idle createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date() } diff --git a/GraphcodeKit/Sources/Domain/LoopResolution.swift b/GraphcodeKit/Sources/Domain/LoopResolution.swift new file mode 100644 index 00000000..4ca609cc --- /dev/null +++ b/GraphcodeKit/Sources/Domain/LoopResolution.swift @@ -0,0 +1,58 @@ +import Foundation + +/// How a loop reached `.succeeded` or `.failed` — the evidence behind the word on its card. +/// +/// A SUCCEEDED that a predicate proved and one an agent claimed are not the same claim, +/// and a human deciding whether to trust a result has to be able to tell them apart +/// without opening the loop's memory (issue #346). +public struct LoopResolution: Codable, Equatable, Sendable { + public enum Basis: String, Codable, Equatable, Sendable, CaseIterable { + /// The goal's `--predicate` exited 0. + case predicate + /// The backend recorded its own `/goal` as met (`GoalVerdictReader`). + case nativeGoal + /// The loop itself, or the loop that created it, ran `graphcode node done`. + case agentReported + /// Someone at the Mac's own shell ran `graphcode node done`. + case human + /// The pane watching the session saw its process finish. + case sessionExited + /// A composite's workers rolled up to a terminal state. + case workers + + /// A judgement on the goal itself, as opposed to a surface reporting that something + /// ended — which a human's repeated check approval legitimately does more than once. + public var isVerdict: Bool { + switch self { + case .predicate, .nativeGoal, .agentReported, .human: return true + case .sessionExited, .workers: return false + } + } + } + + public var basis: Basis + /// What the resolver had to say about it, when it said anything. + public var detail: String? + public var resolvedAt: Date + + public init(basis: Basis, detail: String? = nil, resolvedAt: Date = Date()) { + self.basis = basis + self.detail = detail + self.resolvedAt = resolvedAt + } + + /// The card's line for a resolved loop: the basis, then the resolver's own words. + public var displayLine: String { + let phrase: String + switch basis { + case .predicate: phrase = "predicate passed" + case .nativeGoal: phrase = "goal met" + case .agentReported: phrase = "reported done" + case .human: phrase = "marked done" + case .sessionExited: phrase = "session exited" + case .workers: phrase = "workers rolled up" + } + guard let detail, !detail.isEmpty else { return phrase } + return "\(phrase) · \(detail)" + } +} diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift index a1b24400..f9de9e5f 100644 --- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift +++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift @@ -152,10 +152,10 @@ public enum SessionBriefing { two of the three types start running the moment you create them, and one does not. - `--type goal --goal ` — **the default, and what you almost - always want.** The loop starts immediately and resolves when its own session - finishes the goal. Add `--predicate ` only when a command can - actually decide it (exit 0 means met, e.g. a test run); without one, finishing the - work is what resolves it. + always want.** The loop starts immediately and resolves when its goal is met. Add + `--predicate ` only when a command can actually decide it (exit 0 + means met, e.g. a test run); without one, it resolves when its backend records the + goal as met or when it runs `graphcode node done`. \(timeBullet.trimmingCharacters(in: .whitespacesAndNewlines)) - `--type turn --check ` — for work a **human** must review each turn before it continues. **A turn-based loop does not start on its own**: diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 275ec5e8..6d354789 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -54,6 +54,11 @@ public actor GraphStore { private let onDeliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? private let onCaptureScript: (@Sendable (ShellPredicate) async -> String?)? private let onReadUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? + private let onReadGoalVerdict: (@Sendable (LoopNode, String?) async -> GoalVerdict?)? + private let onEndSession: (@Sendable (LoopNode, String?) async -> Bool)? + private let onAttachedClients: (@Sendable (LoopNode, String?) async -> Int?)? + private let onResumeSession: (@Sendable (LoopNode, String?) async -> Bool)? + private let onResolvedSessionGrace: (@Sendable () -> Duration?)? private let onReadActivity: (@Sendable (LoopNode, String?) async -> String?)? /// What a working session has narrated, folded into `LoopNode.summary`. `nil` when /// nothing produces beats — no reader wired, or the human has left the producer off. @@ -274,6 +279,12 @@ public actor GraphStore { /// so the ordinary nudge drain would drop every one of these; this drain types into /// the PTY directly and lets an exited session fail the send harmlessly. private var pendingResolutionNudges: [(nodeID: UUID, text: String)] = [] + private var resolvedSessionEnders: [UUID: Task] = [:] + private var sessionEndCandidates: Set = [] + /// A reopened loop's new goal on its way to the session: `nil` while the delivery is + /// being arranged, then the follow-up carrying it. A `node done` sent before it lands + /// is about the old goal. + private var goalFollowUps: [UUID: UUID?] = [:] /// Messages the orchestrator declined to deliver, newest last. Surfaced so an /// undelivered message is visible rather than silently dropped. public private(set) var undeliveredMessages: @@ -301,7 +312,11 @@ public actor GraphStore { onReadActivity: (@Sendable (LoopNode, String?) async -> String?)? = nil, onReadSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? = nil, onReadPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? = nil, + onReadGoalVerdict: (@Sendable (LoopNode, String?) async -> GoalVerdict?)? = nil, onSessionAlive: (@Sendable (LoopNode, String?) async -> Bool)? = nil, + onEndSession: (@Sendable (LoopNode, String?) async -> Bool)? = nil, + onAttachedClients: (@Sendable (LoopNode, String?) async -> Int?)? = nil, + onResumeSession: (@Sendable (LoopNode, String?) async -> Bool)? = nil, onSpawnIntoProject: (@Sendable (String, NodeDraft) -> Void)? = nil, onAppendMemory: (@Sendable (UUID, String) -> Void)? = nil, onRemoveMemory: (@Sendable (UUID) -> Void)? = nil, @@ -309,6 +324,7 @@ public actor GraphStore { onRollbackPlaybook: (@Sendable (UUID) -> Bool)? = nil, onAnnounceError: (@Sendable (String) -> Void)? = nil, onHeartbeatEnabled: (@Sendable () -> Bool)? = nil, + onResolvedSessionGrace: (@Sendable () -> Duration?)? = nil, onDefaultBackend: (@Sendable () -> CLISessionBackendKind)? = nil, onComposeBoard: ( @Sendable (LoopNode, LoopSummary, String?, String?) async -> SummaryBoard? @@ -337,6 +353,11 @@ public actor GraphStore { self.onReadActivity = onReadActivity self.onReadSummary = onReadSummary self.onReadPresence = onReadPresence + self.onReadGoalVerdict = onReadGoalVerdict + self.onEndSession = onEndSession + self.onAttachedClients = onAttachedClients + self.onResumeSession = onResumeSession + self.onResolvedSessionGrace = onResolvedSessionGrace self.onSessionAlive = onSessionAlive self.onSpawnIntoProject = onSpawnIntoProject self.onAppendMemory = onAppendMemory @@ -815,13 +836,15 @@ public actor GraphStore { case .nodeCheckApproved(let nodeID): if await sessionPermitsResolution(nodeID, succeeded: true) { - resolveNode(nodeID, succeeded: true, reason: "its pane's process finished") + resolveNode( + nodeID, succeeded: true, basis: .sessionExited, reason: "its pane's process finished") } case .nodeCheckRejected(let nodeID): if await sessionPermitsResolution(nodeID, succeeded: false) { resolveNode( - nodeID, succeeded: false, reason: "its pane closed with the process still running") + nodeID, succeeded: false, basis: .sessionExited, + reason: "its pane closed with the process still running") } case .messageNode(let nodeID, let text, let from, let followUp): @@ -849,6 +872,8 @@ public actor GraphStore { case .memoNode(let nodeID, let text, let from): memoNode(nodeID, text: text, from: from) + case .completeNode(let nodeID, let result, let from): + await completeNode(nodeID, result: result, from: from) case .refineNode(let nodeID, let text, let from): refineNode(nodeID, text: text, from: from) @@ -871,6 +896,9 @@ public actor GraphStore { case .restartSessions: await restartSessions() + case .resumeSession(let nodeID): + await resumeResolvedSession(nodeID) + case .subGraphCommand(let nodeID, let inner): await runInSubGraph(nodeID, inner) @@ -938,8 +966,9 @@ public actor GraphStore { return .subGraphCommand(nodeID: ownerID, command: command) case .nodeCheckApproved(let id), .nodeCheckRejected(let id), .renameNode(let id, _), .updateNode(let id, _), .promoteNode(let id, _, _), .memoNode(let id, _, _), + .completeNode(let id, _, _), .refineNode(let id, _, _), .rollbackRefinement(let id, _), .messageNode(let id, _, _, _), - .deleteNode(let id), .stopNode(let id), .restartNode(let id): + .deleteNode(let id), .stopNode(let id), .restartNode(let id), .resumeSession(let id): guard let ownerID = subGraphOwner(of: id) else { return nil } return .subGraphCommand(nodeID: ownerID, command: command) default: @@ -1043,9 +1072,11 @@ public actor GraphStore { switch rolled { case .succeeded: - resolveNode(nodeID, succeeded: true, reason: "its workers rolled up to succeeded") + resolveNode( + nodeID, succeeded: true, basis: .workers, reason: "its workers rolled up to succeeded") case .failed, .stalled: - resolveNode(nodeID, succeeded: false, reason: "its workers rolled up to \(rolled)") + resolveNode( + nodeID, succeeded: false, basis: .workers, reason: "its workers rolled up to \(rolled)") case .idle, .running, .awaitingInput, .blocked, .waiting, .stopped: setNodeState(nodeID, rolled) } @@ -1337,10 +1368,14 @@ public actor GraphStore { var changed = false for node in graph.nodes where !node.isResolved { let firedOutgoing = graph.edges.filter { $0.from == node.id && $0.fired } - let hasActive = firedOutgoing.contains { edge in - guard let target = graph.nodes[id: edge.to] else { return false } - return !target.isResolved - } + let hasActive = + firedOutgoing.contains { edge in + guard let target = graph.nodes[id: edge.to] else { return false } + return !target.isResolved + } + // A leader whose own session died is dead, not waiting (#215's display). + || (node.presence?.presence != .absent + && spawnedDescendants(of: node.id).contains { !$0.isResolved }) guard graph.nodes[id: node.id]?.hasActiveDependents != hasActive else { continue } graph.nodes[id: node.id]?.hasActiveDependents = hasActive changed = true @@ -1500,6 +1535,13 @@ public actor GraphStore { ? "predicate skips re-runs while the tree is unchanged" : "predicate runs every poll") } + if update.goalSummary != nil || update.goalPredicate != nil { + if update.goalSummary != nil { node.goalSetAt = Date() } + if node.pendingCompletion != nil { + node.pendingCompletion = nil + observerSide.append("the held completion was discarded — the stop condition changed") + } + } node.goal = goal case .timeBased: @@ -1569,6 +1611,25 @@ public actor GraphStore { : "update refused: nothing in it applies to a \(node.loopType) loop") return } + // A new goal on a resolved goal loop reopens it. The met goal stays in its history — + // it is never pursued again — and the session carries on with the new one. + let reopens = node.loopType == .goalBased && node.isResolved && update.goalSummary != nil + if reopens, update.updatedBy == nodeID { + announceError("update refused: \(node.title) may not hand itself a new goal once resolved") + return + } + if reopens { + recordMemory( + nodeID, + "reopened with a new goal — the earlier one stays " + + (node.resolution.map { "\(node.state): \($0.displayLine)" } ?? "\(node.state)")) + node.state = .running + node.resolution = nil + node.pendingCompletion = nil + node.stallReason = nil + resolvedSessionEnders.removeValue(forKey: nodeID)?.cancel() + goalFollowUps.updateValue(nil, forKey: nodeID) + } graph.nodes[id: nodeID] = node // Re-arm rather than patch: `armGoalPoller` replaces any existing poller, and an @@ -1586,7 +1647,9 @@ public actor GraphStore { let author = update.updatedBy.flatMap { graph.nodes[id: $0]?.title } ?? "a human" let changes = (sessionFacing + observerSide).joined(separator: "; ") recordMemory(nodeID, "instructions updated by \(author): \(changes)") - if !sessionFacing.isEmpty { + if reopens, let prompt = node.sessionPrompt { + Task { await self.deliverReopenedGoal(nodeID, prompt: prompt) } + } else if !sessionFacing.isEmpty { pendingNudges.append( ( nodeID, @@ -1718,6 +1781,217 @@ public actor GraphStore { recordMemory(nodeID, "note\(sender.map { " (from \($0))" } ?? ""): \(trimmed)") } + /// `graphcode node done`: a goal loop's report that its goal is met. Accepted from the + /// loop itself, from the loop that created it, or from a human (`from == nil`) — never + /// from an unrelated peer. A predicate, when the goal has one, still decides: the report + /// runs it now instead of waiting for the next poll, and cannot resolve past it. + private func completeNode(_ nodeID: UUID, result: String?, from senderID: UUID?) async { + guard let node = graph.nodes[id: nodeID] else { + announceError("done refused: no loop \(nodeID) in this graph") + return + } + guard node.loopType == .goalBased else { + announceError("done refused: \(node.title) is not a goal loop") + return + } + if let senderID, senderID != nodeID, senderID != node.createdBy { + announceError( + "done refused: only \(node.title) itself or the loop that created it can report it done") + return + } + let trimmed = result?.trimmingCharacters(in: .whitespacesAndNewlines) + let detail = trimmed?.isEmpty == false ? trimmed : nil + guard !node.isResolved else { + recordMemory(nodeID, "done reported again, already \(node.state)") + return + } + if let waiting = goalFollowUps[nodeID] { + guard let followUpID = waiting, !pendingFollowUps.contains(where: { $0.id == followUpID }) + else { + announceError( + "done refused: \(node.title)'s new goal has not reached its session yet — " + + "this report is about the goal it replaced") + return + } + goalFollowUps.removeValue(forKey: nodeID) + } + // The predicate decides, on its own time: a minutes-long check must not hold this + // project's command stream, and the unchanged-tree skip must not refuse a check that + // watches something outside the tree. + if let predicate = node.goal?.effectivePredicate { + recordMemory( + nodeID, "done reported\(detail.map { ": \($0)" } ?? "") — `\(predicate)` decides") + Task { await self.evaluateGoal(nodeID, forcePredicate: true) } + return + } + // A human at the shell is overriding, not reporting: only the loop's own report waits. + if let senderID, + holdCompletion(nodeID, LoopResolution(basis: .agentReported, detail: detail), from: senderID) + { + return + } + resolveNode( + nodeID, succeeded: true, basis: senderID == nil ? .human : .agentReported, + reason: senderID == nil ? "marked done from the shell" : "its session reported the goal met", + detail: detail, sessionMayStillBeLive: true) + } + + /// Holds a completion while loops this one created are unresolved; returns whether it + /// held. A leader that reports done the moment its own turn ends would fire its edges + /// with its workers' results still outstanding. The creator marking a child done is + /// not held on the child's own children — that is the creator's call to make. + private func holdCompletion( + _ nodeID: UUID, _ completion: LoopResolution, from senderID: UUID? = nil + ) -> Bool { + if let senderID, senderID != nodeID { return false } + let waitingOn = spawnedDescendants(of: nodeID).filter { !$0.isResolved } + guard !waitingOn.isEmpty else { return false } + let firstHold = graph.nodes[id: nodeID]?.pendingCompletion == nil + graph.nodes[id: nodeID]?.pendingCompletion = completion + if firstHold { + recordMemory( + nodeID, + "\(completion.displayLine), held until the loops it created resolve: " + + waitingOn.map(\.title).joined(separator: ", ")) + } + return true + } + + /// Applies every held completion whose loop's created loops have all resolved. Repeats + /// because a leader resolving can release the leader that created it. + private func releaseHeldCompletions() { + var released = true + while released { + released = false + for node in graph.nodes where !node.isResolved { + guard let held = node.pendingCompletion else { continue } + let created = spawnedDescendants(of: node.id) + guard created.allSatisfy(\.isResolved) else { continue } + graph.nodes[id: node.id]?.pendingCompletion = nil + released = true + // Done on top of failed work is not done: the leader decides what the failures + // mean, and reports again. Any verdict it recorded before now no longer counts. + let unsuccessful = created.filter { $0.state != .succeeded } + guard unsuccessful.isEmpty else { + graph.nodes[id: node.id]?.goalSetAt = Date() + let list = unsuccessful.map { "\($0.title) (\($0.state))" }.joined(separator: ", ") + recordMemory( + node.id, "held completion discarded — not every loop it created succeeded: \(list)") + pendingNudges.append( + ( + node.id, + "[graphcode] Your done report was not applied: \(list) did not succeed. " + + "Handle that, then run `graphcode node done` again." + )) + continue + } + resolveNode( + node.id, succeeded: true, basis: held.basis, + reason: "the loops it created have all succeeded", detail: held.detail, + sessionMayStillBeLive: true) + } + } + } + + /// Hands a reopened loop its new goal exactly once. A live session takes it as a + /// follow-up; an ended one is brought back first — a resumed conversation still needs the + /// goal typed in, while a fresh launch already opens with it. + private func deliverReopenedGoal(_ nodeID: UUID, prompt: String) async { + guard let node = graph.nodes[id: nodeID], !node.isResolved else { + goalFollowUps.removeValue(forKey: nodeID) + return + } + let path = graph.project.path + if await onSessionAlive?(node, path) != true { + guard let onResumeSession else { + ensureSession(node) + goalFollowUps.removeValue(forKey: nodeID) + return + } + guard await onResumeSession(node, path) else { + goalFollowUps.removeValue(forKey: nodeID) + return + } + } + guard graph.nodes[id: nodeID]?.goal?.summary == node.goal?.summary else { return } + let followUp = PendingFollowUp(id: UUID(), nodeID: nodeID, text: prompt, watchedPostID: nil) + pendingFollowUps.append(followUp) + goalFollowUps[nodeID] = followUp.id + await drainAndBroadcast() + } + + /// Opening a resolved loop whose session was ended brings its conversation back. Panes + /// that wait for the daemon — every Codex goal loop, an unattended loop with nothing + /// banked, a remote loop — would otherwise wait for a launch that never comes. The met + /// goal is never issued again: a session that cannot be resumed opens on a note instead. + private func resumeResolvedSession(_ nodeID: UUID) async { + guard let node = graph.nodes[id: nodeID], node.isResolved, node.state != .stopped, + let onResumeSession + else { return } + let path = graph.project.path + if await onSessionAlive?(node, path) == true { return } + var quiet = node + quiet.loopType = .sketch + quiet.firstInstruction = + "[graphcode] This loop's goal was met and its session ended; the earlier conversation " + + "could not be resumed. Wait for the human's question." + _ = await onResumeSession(quiet, path) + scheduleSessionEnd(nodeID) + } + + /// Arms the end of a resolved loop's session, after the grace the Settings choose — long + /// enough for the resolution ask to be answered. No grace configured keeps it. + private func scheduleSessionEnd(_ nodeID: UUID, confirming: Bool = false) { + guard subGraphDepth == 0, onEndSession != nil, let grace = onResolvedSessionGrace?() else { + return + } + let wait = confirming ? min(grace, Self.sessionEndConfirmation) : grace + resolvedSessionEnders[nodeID]?.cancel() + resolvedSessionEnders[nodeID] = Task { [weak self] in + try? await Task.sleep(for: wait) + guard !Task.isCancelled else { return } + await self?.endResolvedSession(nodeID) + } + } + + static let sessionEndConfirmation: Duration = .seconds(60) + + /// Ends a resolved loop's session only on affirmative evidence that nobody is using it: + /// a reported (not guessed) idle, no terminal attached, and the same again a short while + /// later — one idle reading can be the gap between a human's question and the answer. + /// Anything less — unknown, busy, attached — waits another grace. Called by the scheduled + /// end, and directly by tests. + public func endResolvedSession(_ nodeID: UUID) async { + resolvedSessionEnders[nodeID] = nil + guard let node = graph.nodes[id: nodeID], node.isResolved, node.state != .stopped, + let onEndSession + else { return } + let reading = await presenceReading(of: node) + if reading?.presence == .absent { + sessionEndCandidates.remove(nodeID) + return + } + var quiet = reading?.presence == .idle && reading?.confidence != .heuristic + if quiet, let onAttachedClients { + quiet = await onAttachedClients(node, graph.project.path) == 0 + } + guard quiet, graph.nodes[id: nodeID]?.isResolved == true else { + sessionEndCandidates.remove(nodeID) + scheduleSessionEnd(nodeID) + return + } + guard sessionEndCandidates.contains(nodeID) else { + sessionEndCandidates.insert(nodeID) + scheduleSessionEnd(nodeID, confirming: true) + return + } + sessionEndCandidates.remove(nodeID) + if await onEndSession(node, graph.project.path) { + recordMemory( + nodeID, "session ended after resolving — transcript kept; opening the loop resumes it") + } + } + /// Replaces a node's playbook — `graphcode node refine`. Refusals are said out loud /// because the author will *work from* this document next wake: a refinement that /// silently didn't land is a loop following a playbook it believes it replaced. @@ -2265,6 +2539,7 @@ public actor GraphStore { asked = await deliverToSession(node, MessageBus.stopRequest) } setNodeState(node.id, .stopped) + graph.nodes[id: node.id]?.pendingCompletion = nil cancelGoalPoller(node.id) // The experiment's clean-stop dividend: a heartbeat loop's cadence dies here, with // the timer — no typed request needed for a schedule the agent never owned. @@ -2349,11 +2624,19 @@ public actor GraphStore { /// agent is both finished and present. The other resolution paths /// (`nodeCheckApproved`, composite roll-up) fire *because* the session ended, so /// there is nobody left to speak to. + /// + /// A verdict resolves once. A verdict re-read on the next poll is ignored, and a stale + /// surface report cannot overturn a verdict already recorded — either would fire the + /// loop's edges again. Surface reports over surface reports keep their old behaviour: + /// a turn-based loop's check is approved once per pass. private func resolveNode( - _ nodeID: UUID, succeeded: Bool, reason: String, sessionMayStillBeLive: Bool = false + _ nodeID: UUID, succeeded: Bool, basis: LoopResolution.Basis, reason: String, + detail: String? = nil, sessionMayStillBeLive: Bool = false ) { guard let node = graph.nodes[id: nodeID] else { return } + if node.isResolved, basis.isVerdict || node.resolution?.basis.isVerdict == true { return } setNodeState(nodeID, succeeded ? .succeeded : .failed) + graph.nodes[id: nodeID]?.resolution = LoopResolution(basis: basis, detail: detail) cancelGoalPoller(nodeID) recordMemory(nodeID, "resolved: \(succeeded ? "succeeded" : "failed") — \(reason)") // Two asks ride resolution, in one interruption. Skill distillation: a goal loop @@ -2372,6 +2655,7 @@ public actor GraphStore { pendingResolutionNudges.append((nodeID, ask)) } fireOutgoingEdges(from: nodeID, sourceSucceeded: succeeded) + if sessionMayStillBeLive { scheduleSessionEnd(nodeID) } } /// The Phase 3 half of docs/07-roadmap.md's "automatic edge evaluation and firing": @@ -2545,6 +2829,7 @@ public actor GraphStore { let bound = edge.cycleGuard?.maxIterations.map { " of \($0)" } ?? "" for nodeID in members { setNodeState(nodeID, .idle) + graph.nodes[id: nodeID]?.pendingCompletion = nil cancelGoalPoller(nodeID) recordMemory(nodeID, "cycle re-entry \(reentry)\(bound): pass restarting") } @@ -3166,7 +3451,10 @@ public actor GraphStore { let hasPredicate = goal.effectivePredicate != nil && (onEvaluatePredicate != nil || onCheckPredicate != nil) let hasBudget = goal.tokenBudget != nil && onReadUsage != nil - guard hasPredicate || goal.stallAfterSeconds != nil || hasBudget else { return } + let hasVerdict = + goal.effectivePredicate == nil && onReadGoalVerdict != nil + && node.backend.capabilities.goalDirective != nil + guard hasPredicate || hasVerdict || goal.stallAfterSeconds != nil || hasBudget else { return } goalPollers[node.id]?.cancel() let nodeID = node.id let interval = max(1, goal.pollIntervalSeconds) @@ -3250,6 +3538,7 @@ public actor GraphStore { onCaptureScript: onCaptureScript, onReadUsage: onReadUsage, onReadPresence: onReadPresence, + onReadGoalVerdict: onReadGoalVerdict, onSessionAlive: onSessionAlive, onAppendMemory: onAppendMemory, onRemoveMemory: onRemoveMemory, @@ -3330,7 +3619,7 @@ public actor GraphStore { /// Order matters: the stall bound is checked *before* the predicate, so a loop that /// has blown its bound is reported as stalled rather than spending another predicate /// evaluation on it. - public func evaluateGoal(_ nodeID: UUID, now: Date = Date()) async { + public func evaluateGoal(_ nodeID: UUID, now: Date = Date(), forcePredicate: Bool = false) async { guard let node = graph.nodes[id: nodeID], node.loopType == .goalBased, !node.isResolved, let goal = node.goal else { @@ -3353,15 +3642,31 @@ public actor GraphStore { return } - // No machine predicate means polling has nothing to ask. Such a node resolves only - // when its session exits — checked here, not just where the poller is armed, so an - // evaluator can never resolve a goal whose author never gave it a testable one. - guard let predicate = goal.effectivePredicate else { return } + // No machine predicate: the only thing worth asking is the backend's own verdict on + // the `/goal` it was launched with. A turn ending is never asked — it is not a verdict. + guard let predicate = goal.effectivePredicate else { + guard let onReadGoalVerdict, + let verdict = await onReadGoalVerdict(node, graph.project.path), verdict.met, + let current = graph.nodes[id: nodeID], !current.isResolved, + current.goalSetAt == node.goalSetAt, current.goal?.summary == goal.summary, + Self.verdict(verdict, isCurrentFor: current) + else { return } + if holdCompletion(nodeID, LoopResolution(basis: .nativeGoal, detail: verdict.detail)) { + await drainAndBroadcast() + return + } + resolveNode( + nodeID, succeeded: true, basis: .nativeGoal, + reason: "its backend recorded the goal as met", detail: verdict.detail, + sessionMayStillBeLive: true) + await drainAndBroadcast() + return + } let shellPredicate = ShellPredicate( command: predicate, workingDirectory: node.worktreeBinding?.worktreePath) var fingerprint: String? - if goal.skipsUnchangedWorkspace, let onCaptureScript { + if goal.skipsUnchangedWorkspace, !forcePredicate, let onCaptureScript { fingerprint = await onCaptureScript( ShellPredicate( command: Self.workspaceFingerprintCommand, @@ -3408,7 +3713,8 @@ public actor GraphStore { guard let current = graph.nodes[id: nodeID], !current.isResolved else { return } if outcome.passed { resolveNode( - nodeID, succeeded: true, reason: "its goal predicate passed", sessionMayStillBeLive: true) + nodeID, succeeded: true, basis: .predicate, reason: "its goal predicate passed", + sessionMayStillBeLive: true) await drainAndBroadcast() return } @@ -3416,6 +3722,16 @@ public actor GraphStore { await relayPredicateFailure(to: current, predicate: predicate, outcome: outcome) } + /// A verdict counts only for the goal it was recorded against. One dated before the goal + /// was last replaced belongs to the earlier goal; an undated one is trusted only while the + /// goal has never been replaced. + static func verdict(_ verdict: GoalVerdict, isCurrentFor node: LoopNode) -> Bool { + guard let setAt = node.goalSetAt else { + return verdict.recordedAt.map { $0 >= node.createdAt } ?? true + } + return verdict.recordedAt.map { $0 >= setAt } ?? false + } + /// `HEAD` plus the dirty file list, hashed — what `GoalSpec.skipsUnchangedWorkspace` /// means by "unchanged". Exits non-zero outside a git repository so the capture /// returns nil and the skip never applies where "the tree changed" has no meaning. @@ -3486,6 +3802,7 @@ public actor GraphStore { /// outside a command — goal polling resolves nodes and fires edges too, and an edge /// fired from a poll must not wait for the next unrelated command to be delivered. private func drainAndBroadcast() async { + releaseHeldCompletions() await drainPendingMessages() await drainPendingCycleReentries() await drainPendingHandoffDeliveries() @@ -3502,6 +3819,11 @@ public actor GraphStore { private func setNodeState(_ nodeID: UUID, _ state: LoopState) { graph.nodes[id: nodeID]?.state = state if state != .stalled { graph.nodes[id: nodeID]?.stallReason = nil } + if state != .succeeded && state != .failed { graph.nodes[id: nodeID]?.resolution = nil } + if graph.nodes[id: nodeID]?.isResolved == false { + resolvedSessionEnders.removeValue(forKey: nodeID)?.cancel() + sessionEndCandidates.remove(nodeID) + } } /// A stalled loop is terminal, and its downstream edges fire as if it failed. Leaving @@ -3599,7 +3921,11 @@ public actor GraphStore { // boot would only reach the same missing CLI and raise the same dialog. for node in graph.nodes where node.runsUnattended && node.launchFailure == nil { if node.loopType == .goalBased { - guard !node.isResolved else { continue } + // A session end armed before the restart lived in memory: arm it again. + guard !node.isResolved else { + scheduleSessionEnd(node.id) + continue + } armGoalPoller(for: node) } if !node.isResolved { armHeartbeat(for: node) } diff --git a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift index 31c220cb..6c1ab770 100644 --- a/GraphcodeKit/Sources/IPC/DaemonProtocol.swift +++ b/GraphcodeKit/Sources/IPC/DaemonProtocol.swift @@ -147,6 +147,10 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// Append a learned note to a node's memory log (`NodeMemory`) — what `graphcode /// node memo` rides on. `from` is attributed the same way `messageNode`'s is. case memoNode(UUID, text: String, from: UUID?) + /// Report a goal loop's goal as met — what `graphcode node done` rides on, and the one + /// completion signal every backend can send (#346). `from` is attributed the same way + /// `memoNode`'s is; `nil` is a human at the Mac's own shell. + case completeNode(UUID, result: String?, from: UUID?) /// Replace a node's playbook — its refinable supplemental prompt /// (`NodeMemory.refinePlaybook`), what `graphcode node refine` rides on. The /// continual-harness counterpart to `memoNode`: a memo appends one fact to the log, @@ -210,6 +214,9 @@ public indirect enum GraphCommand: Codable, Sendable, Equatable { /// attended one comes back when a human next opens it, exactly as after a reboot. A /// composite restarts its workers. case restartNode(UUID) + /// Bring a resolved loop's ended session back on its transcript — sent when a human + /// opens the loop. Never re-issues the met goal. + case resumeSession(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 diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index bf628a7e..f76be3e8 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -44,6 +44,7 @@ public actor ProjectRegistry { private let deliverMessage: (@Sendable (LoopNode, String, String?) async -> Bool)? private let captureScript: (@Sendable (ShellPredicate) async -> String?)? private let readUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? + private let readGoalVerdict: (@Sendable (LoopNode, String?) async -> GoalVerdict?)? private let readActivity: (@Sendable (LoopNode, String?) async -> String?)? private let readSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? private let readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? @@ -76,6 +77,8 @@ public actor ProjectRegistry { captureScript: (@Sendable (ShellPredicate) async -> String?)? = ShellPredicateEvaluator.capture, readUsage: (@Sendable (LoopNode, String?) async -> UsageSample?)? = CLISessionBackend.readUsage, + readGoalVerdict: (@Sendable (LoopNode, String?) async -> GoalVerdict?)? = + CLISessionBackend.readGoalVerdict, readActivity: (@Sendable (LoopNode, String?) async -> String?)? = CLISessionBackend.readActivity, readSummary: (@Sendable (LoopNode, String?) async -> SummaryReading?)? = @@ -99,6 +102,7 @@ public actor ProjectRegistry { self.deliverMessage = deliverMessage self.captureScript = captureScript self.readUsage = readUsage + self.readGoalVerdict = readGoalVerdict self.readActivity = readActivity self.readSummary = readSummary self.readPresence = readPresence @@ -655,7 +659,11 @@ public actor ProjectRegistry { onReadActivity: readActivity, onReadSummary: readSummary, onReadPresence: readPresence, + onReadGoalVerdict: readGoalVerdict, onSessionAlive: sessionAlive, + onEndSession: CLISessionBackend.endSession, + onAttachedClients: CLISessionBackend.attachedClients, + onResumeSession: CLISessionBackend.resumeSession, 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 @@ -673,6 +681,7 @@ public actor ProjectRegistry { NodeMemory.rollbackPlaybook(projectPath: path, nodeID: nodeID) }, onHeartbeatEnabled: { GraphcodeSettingsStore.load().daemonHeartbeatEnabled }, + onResolvedSessionGrace: { GraphcodeSettingsStore.load().resolvedSessionGrace }, onDefaultBackend: { GraphcodeSettingsStore.load().defaultBackend }, onComposeBoard: composeBoard, onBoardsEnabled: { diff --git a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift index dfa4e168..320d8c5e 100644 --- a/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift +++ b/GraphcodeKit/Sources/Sessions/CLISessionBackend.swift @@ -244,6 +244,23 @@ extension CLISessionBackend { Task.detached { await backend(for: node).terminate(node, path) } } + /// Ends a resolved loop's session, keeping its transcript resumable. Session-level like + /// `sessionAlive`, so it needs no per-backend adapter. + public static let endSession: @Sendable (LoopNode, String?) async -> Bool = { node, path in + await ZmxSessionLauncher.endKeepingTranscript(node, projectPath: path) + } + + public static let attachedClients: @Sendable (LoopNode, String?) async -> Int? = { + node, path in + ZmxSessionLauncher.attachedClients(node, projectPath: path) + } + + /// Returns whether an earlier conversation was resumed. + public static let resumeSession: @Sendable (LoopNode, String?) async -> Bool = { + node, path in + await ZmxSessionLauncher.resume(node, projectPath: path) + } + /// Awaited rather than detached: `GraphStore.restartNode` needs the answer. public static let restartSession: @Sendable (LoopNode, String?) async -> Bool = { node, path in @@ -263,6 +280,13 @@ extension CLISessionBackend { await backend(for: node).usage(node, path) } + /// The goal-verdict hook `GraphStore` is wired with — backend-specific records, read + /// in one place because none of them needs the adapter's session plumbing. + public static let readGoalVerdict: @Sendable (LoopNode, String?) async -> GoalVerdict? = { + node, path in + GoalVerdictReader.verdict(of: node, projectPath: path) + } + /// The activity-reading hook `GraphStore` is wired with. public static let readActivity: @Sendable (LoopNode, String?) async -> String? = { node, path in diff --git a/GraphcodeKit/Sources/Sessions/GoalVerdictReader.swift b/GraphcodeKit/Sources/Sessions/GoalVerdictReader.swift new file mode 100644 index 00000000..fa1d1287 --- /dev/null +++ b/GraphcodeKit/Sources/Sessions/GoalVerdictReader.swift @@ -0,0 +1,134 @@ +import Foundation + +#if canImport(SQLite3) + import SQLite3 +#endif + +/// Reads the goal verdict each backend records for the `/goal` graphcode launched it with. +/// +/// | Backend | Record | +/// |---|---| +/// | Claude Code | transcript `goal_status` attachment, `met: true` without `sentinel` | +/// | Codex | `~/.codex/goals_1.sqlite` `thread_goals.status = 'complete'` | +/// | Copilot CLI | `events.jsonl` `session.autopilot_objective_changed`, `status: "completed"` | +/// +/// OpenCode and pi record nothing goal-specific, so they have no reading here. Remote +/// projects have none yet either: their records live on the other machine. +public enum GoalVerdictReader { + public static var codexGoalsDatabase: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex/goals_1.sqlite") + + public static func verdict(of node: LoopNode, projectPath: String?) -> GoalVerdict? { + guard node.loopType == .goalBased, let goal = node.goal else { return nil } + if let projectPath, RemoteProjectLocation.parse(projectPath: projectPath) != nil { + return nil + } + switch node.backend { + case .claudeCode: + guard let sessionID = SessionIDStore.load(forNodeID: node.id), + let transcript = ClaudeSessionLog.transcript(forSessionID: sessionID) + else { return nil } + return claudeVerdict( + lines: CopilotSessionLog.tailLines(ofLogAt: transcript), goalSummary: goal.summary) + case .codex: + guard let threadID = SessionIDStore.load(forNodeID: node.id) else { return nil } + return codexVerdict(threadID: threadID, database: codexGoalsDatabase) + case .copilotCLI: + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + guard let directory = CopilotSessionLog.directory(forSessionNamed: name) else { return nil } + return copilotVerdict( + lines: CopilotSessionLog.tailLines( + ofLogAt: directory.appendingPathComponent("events.jsonl"))) + case .openCode, .pi: + return nil + } + } + + /// The newest `goal_status` decides. A `sentinel` record is Claude Code setting or + /// clearing a goal — a user's `/goal clear` writes `met: true` with the sentinel — so it + /// is never a verdict. The condition must be this loop's goal: a human can type a + /// different `/goal` into the same session. + static func claudeVerdict(lines: [Substring], goalSummary: String) -> GoalVerdict? { + for line in lines.reversed() where line.contains("\"goal_status\"") { + guard let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)), + let record = object as? [String: Any], + let attachment = record["attachment"] as? [String: Any], + attachment["type"] as? String == "goal_status" + else { continue } + if attachment["sentinel"] as? Bool == true { return nil } + guard let condition = attachment["condition"] as? String, + conditionNamesGoal(condition, goalSummary: goalSummary) + else { return nil } + let recordedAt = timestamp(record["timestamp"]) + guard attachment["met"] as? Bool == true else { + return GoalVerdict(met: false, recordedAt: recordedAt) + } + return GoalVerdict( + met: true, detail: attachment["reason"] as? String, recordedAt: recordedAt) + } + return nil + } + + static func timestamp(_ value: Any?) -> Date? { + guard let text = value as? String else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: text) ?? ISO8601DateFormatter().date(from: text) + } + + /// The condition is the summary plus whatever the launch appended, and a long one may be + /// cut to the backend's length cap — so a prefix of the summary is what is compared. + static func conditionNamesGoal(_ condition: String, goalSummary: String) -> Bool { + func normalized(_ text: String) -> String { + text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + } + let summary = String(normalized(goalSummary).prefix(80)) + return !summary.isEmpty && normalized(condition).contains(summary) + } + + /// The newest objective status decides; an objective reopened after completing is + /// active again. + static func copilotVerdict(lines: [Substring]) -> GoalVerdict? { + for line in lines.reversed() + where line.contains("\"session.autopilot_objective_changed\"") { + guard let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)), + let event = object as? [String: Any], + let data = event["data"] as? [String: Any], + let status = data["status"] as? String + else { continue } + return GoalVerdict(met: status == "completed", recordedAt: timestamp(event["timestamp"])) + } + return nil + } + + static func codexVerdict(threadID: String, database: URL) -> GoalVerdict? { + #if canImport(SQLite3) + guard FileManager.default.fileExists(atPath: database.path) else { return nil } + var handle: OpaquePointer? + guard sqlite3_open_v2(database.path, &handle, SQLITE_OPEN_READONLY, nil) == SQLITE_OK + else { + sqlite3_close(handle) + return nil + } + defer { sqlite3_close(handle) } + sqlite3_busy_timeout(handle, 500) + var statement: OpaquePointer? + guard + sqlite3_prepare_v2( + handle, "SELECT status, updated_at_ms FROM thread_goals WHERE thread_id = ?", -1, + &statement, nil) + == SQLITE_OK + else { return nil } + defer { sqlite3_finalize(statement) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, threadID, -1, transient) + guard sqlite3_step(statement) == SQLITE_ROW, let text = sqlite3_column_text(statement, 0) + else { return nil } + let updatedAt = Date( + timeIntervalSince1970: TimeInterval(sqlite3_column_int64(statement, 1)) / 1000) + return GoalVerdict(met: String(cString: text) == "complete", recordedAt: updatedAt) + #else + return nil + #endif + } +} diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift index 5114e71a..34194a11 100644 --- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift +++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift @@ -174,6 +174,7 @@ public enum RemoteGraphAccess { graphcode node delete irreversible; stop is reversible graphcode node send graphcode node memo + graphcode node done [result...] graphcode mail post [--topic ] graphcode mail inbox [--headlines] [--full] [--mark] [--json] graphcode mail read @@ -583,6 +584,27 @@ public enum RemoteGraphAccess { return "\n".join(lines) + def done_report(graph, node_id): + # The Swift CLI's words for the same outcome (graphcode-cli main.swift). + stack = list(graph.get("nodes") or []) + while stack: + node = stack.pop() + stack.extend((node.get("subGraph") or {}).get("nodes") or []) + if str(node.get("id", "")).lower() != str(node_id).lower(): + continue + state = node.get("state") + if isinstance(state, dict): + state = next(iter(state), "?") + if state in ("succeeded", "failed", "stalled", "stopped"): + return "resolved: %s" % state + if node.get("pendingCompletion"): + return "held: resolves when the loops it created have resolved" + if ((node.get("goal") or {}).get("predicate") or "").strip(): + return "reported: the goal's predicate decides" + return "not resolved: %s" % state + return "reported" + + def graph_command(project, command): return {"graphCommand": {"projectPath": project, "command": command}} @@ -968,7 +990,7 @@ public enum RemoteGraphAccess { create = {"subGraphCommand": {"nodeID": into, "command": create}} run_and_print(project, create) return - if subverb not in ("stop", "restart", "delete", "send", "memo"): + if subverb not in ("stop", "restart", "delete", "send", "memo", "done"): fail("node %s runs from the Mac's own shell, not from a remote host" % subverb) if not arguments: fail("missing node-id") @@ -979,6 +1001,16 @@ public enum RemoteGraphAccess { run_and_print(project, {"restartNode": {"_0": node_id}}) elif subverb == "delete": run_and_print(project, {"deleteNode": {"_0": node_id}}) + elif subverb == "done": + payload = {"_0": node_id} + result = " ".join(arguments).strip() + if result: + payload["result"] = result + sender = self_node_id() + if sender: + payload["from"] = sender + run_and_report(project, {"completeNode": payload}, + lambda graph: done_report(graph, node_id)) else: follow_up = False if subverb == "send" and arguments and arguments[0] == "--follow-up": diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index f02a2948..fd739641 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -793,6 +793,61 @@ public enum ZmxSessionLauncher { return true } + /// Ends a resolved loop's session for good while keeping what resuming needs: the + /// banked session id and the first-pass record, as `restart` keeps them — so opening + /// the loop later picks the conversation back up. `false` when the session would not die. + static func endKeepingTranscript(_ 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: "resolved", event: "ended") + return true + } + guard ZmxLocator.isInstalled else { return false } + guard await killConfirmingDeath(sessionNamed: name) else { return false } + DialLog.record(session: name, dial: "resolved", event: "ended") + return true + } + + /// How many terminals are attached to a node's session, or `nil` when that cannot be + /// told — a remote project, or `zmx` not answering. + static func attachedClients(_ node: LoopNode, projectPath: String? = nil) -> Int? { + if let projectPath, RemoteProjectLocation.parse(projectPath: projectPath) != nil { + return nil + } + guard ZmxLocator.isInstalled, let result = runZmx(["ls"]), result.status == 0 else { + return nil + } + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + for line in result.output.split(separator: "\n") { + let fields = line.split(whereSeparator: \.isWhitespace) + guard fields.contains("name=\(name)") else { continue } + return fields.first { $0.hasPrefix("clients=") } + .flatMap { Int($0.dropFirst("clients=".count)) } + } + return nil + } + + /// Brings a node's session back: the banked conversation when there is one, a fresh + /// launch on the node's own prompt otherwise. Returns whether an earlier conversation + /// was resumed — a fresh launch already opened with the prompt, a resume did not. + static func resume(_ node: LoopNode, projectPath: String? = nil) async -> Bool { + if let projectPath, let remote = RemoteProjectLocation.parse(projectPath: projectPath) { + await startRemote(node, at: remote) + return true + } + let name = SurfaceRef(id: node.id, launchesClaudeCode: true).zmxSessionName + let hadConversation = + SessionIDStore.load(forNodeID: node.id) != nil + || (node.backend == .copilotCLI + && CopilotSessionLog.directory(forSessionNamed: name) != nil) + await start(node, projectPath: projectPath) + return hadConversation + && (node.backend == .copilotCLI || SessionIDStore.load(forNodeID: node.id) != nil) + } + /// `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 diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 5315e267..711e26f5 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -371,6 +371,37 @@ do { if case .errorOccurred(let message) = memoVerdict { fail(message) } print("noted") + case .completeNode(let projectPath, let nodeID, let result): + let reporter = SurfaceRef.nodeID( + fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") + try openProject(projectPath) + try sendCommand( + .graphCommand( + projectPath: projectPath, + command: .completeNode(nodeID, result: result, from: reporter))) + let doneVerdict = try client.waitForEvent { event in + switch event { + case .graphChanged, .errorOccurred: return true + default: return false + } + } + if case .errorOccurred(let message) = doneVerdict { fail(message) } + if case .graphChanged(let graph) = doneVerdict, + let node = graph.nodesAtAnyDepth.first(where: { $0.id == nodeID }) + { + if node.isResolved { + print("resolved: \(node.state)") + } else if node.pendingCompletion != nil { + print("held: resolves when the loops it created have resolved") + } else if node.goal?.effectivePredicate != nil { + print("reported: the goal's predicate decides") + } else { + print("not resolved: \(node.state)") + } + } else { + print("reported") + } + case .refineNode(let projectPath, let nodeID, let text): let refiner = SurfaceRef.nodeID( fromZmxSessionName: ProcessInfo.processInfo.environment["ZMX_SESSION"] ?? "") diff --git a/graphcode/Sources/Features/App/AppFeature.swift b/graphcode/Sources/Features/App/AppFeature.swift index 07742653..0e866e6e 100644 --- a/graphcode/Sources/Features/App/AppFeature.swift +++ b/graphcode/Sources/Features/App/AppFeature.swift @@ -740,7 +740,13 @@ extension AppFeature { } mountWorkspace(node: node, graph: graph, projectPath: path, &state) recordVisit(.loop(projectPath: path, nodeID: nodeID), &state) - return .none + // A finished loop's session may have been ended to free the machine. The daemon brings + // the conversation back — the pane of a Codex, remote, or unbanked loop waits for it. + guard node.isResolved, node.state != .stopped else { return .none } + return .run { _ in + try? await orchestratorClient.send( + .graphCommand(projectPath: path, command: .resumeSession(nodeID))) + } } /// Steps the open workspace to another loop, in the order the sidebar draws them — diff --git a/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift b/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift index c47280d8..9c5851f2 100644 --- a/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift +++ b/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift @@ -101,6 +101,12 @@ struct LoopCardPresentation: Equatable { private static func liveLine(_ node: LoopNode, summarising: Bool) -> String? { if node.displayState == .stalled, let why = collapsed(node.stallReason) { return why } if node.displayState == .stopped, let failure = node.launchFailure { return failure.title } + if node.isResolved, let resolution = node.resolution { + return collapsed(resolution.displayLine) + } + if !node.isResolved, let held = node.pendingCompletion { + return collapsed("\(held.displayLine) · waiting on the loops it created") + } let passes = node.metricHistory.count if summarising, let beat = node.summary?.current?.text, !beat.isEmpty { return passes > 0 ? "pass \(passes) · \(beat)" : beat diff --git a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift index fe180ea4..76ca0534 100644 --- a/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift +++ b/graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift @@ -347,8 +347,10 @@ struct LoopWorkspaceView: View { loopType: store.node.loopType, // Only the agent surface of an unattended node starts from a prompt (a time-based // loop's `/loop`, a goal-based loop's goal); a turn-based loop's session opens - // bare, and extra tabs/splits are plain shells either way. - initialPrompt: ref.launchesClaudeCode ? store.node.sessionPrompt : nil, + // bare, and extra tabs/splits are plain shells either way. A succeeded loop's goal is + // met: opening it resumes the conversation, and never starts that goal again. + initialPrompt: ref.launchesClaudeCode && store.node.state != .succeeded + ? store.node.sessionPrompt : nil, // A node without its own worktree yet still belongs to a project — its shells // should open there, not wherever the app process happened to launch from. A // global-graph loop belongs to no folder at all: home, the same answer the diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index 724d2c8f..e021be54 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -228,6 +228,25 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + Section { + Picker( + "End a finished loop's session", selection: $model.settings.endsResolvedSessionsAfterMinutes + ) { + Text("After 1 minute").tag(1) + Text("After 10 minutes").tag(10) + Text("After 1 hour").tag(60) + Text("Never").tag(0) + } + } footer: { + Text( + "Once a loop resolves and has sent its final report, its agent process is ended " + + "to free memory. The loop, its history and its transcript stay: opening it " + + "resumes the conversation." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + Section { Toggle("Get beta releases", isOn: $model.betaUpdates) } header: { diff --git a/graphcode/Tests/GoalBasedLoopTests.swift b/graphcode/Tests/GoalBasedLoopTests.swift index 232903a9..0e469c58 100644 --- a/graphcode/Tests/GoalBasedLoopTests.swift +++ b/graphcode/Tests/GoalBasedLoopTests.swift @@ -60,6 +60,46 @@ struct GoalBasedLoopTests { #expect(graph.nodes[id: nodes[1].id]?.state == .idle) } + @Test + func aResolutionRecordsItsBasisAndALaterReportCannotReplaceIt() async { + let store = store(goalMet: true) + await store.handle( + .createNode( + NodeDraft( + title: "Green build", loopType: .goalBased, + goal: GoalSpec(summary: "CI passes", predicate: "make test")))) + await store.handle( + .createNode( + NodeDraft( + title: "Ship", loopType: .turnBased, checkDescription: "?", + firstInstruction: "Work"))) + let nodes = await store.graph.nodes + await store.handle(.createEdge(from: nodes[0].id, to: nodes[1].id, spec: EdgeSpec())) + + await store.evaluateGoal(nodes[0].id) + await store.handle(.nodeCheckRejected(nodes[0].id)) + + let graph = await store.graph + #expect(graph.nodes[id: nodes[0].id]?.state == .succeeded) + #expect(graph.nodes[id: nodes[0].id]?.resolution?.basis == .predicate) + #expect(graph.edges[0].fireCount == 1) + } + + @Test + func aResolutionSurvivesAReloadAndAnUnknownBasisCostsOnlyTheLabel() throws { + var node = LoopNode(title: "a", loopType: .goalBased, state: .succeeded) + node.resolution = LoopResolution(basis: .predicate, detail: "exit 0") + let data = try JSONEncoder().encode(node) + #expect(try JSONDecoder().decode(LoopNode.self, from: data).resolution == node.resolution) + + var json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + json["resolution"] = ["basis": "fromTheFuture", "resolvedAt": 0] + let future = try JSONSerialization.data(withJSONObject: json) + let decoded = try JSONDecoder().decode(LoopNode.self, from: future) + #expect(decoded.state == .succeeded) + #expect(decoded.resolution == nil) + } + @Test func anUnmetPredicateLeavesTheNodeRunning() async { let store = store(goalMet: false) diff --git a/graphcode/Tests/GoalResolutionReviewTests.swift b/graphcode/Tests/GoalResolutionReviewTests.swift new file mode 100644 index 00000000..fe79a73e --- /dev/null +++ b/graphcode/Tests/GoalResolutionReviewTests.swift @@ -0,0 +1,151 @@ +import ComposableArchitecture +import Foundation +import Testing + +@testable import GraphcodeKit + +/// The failures the independent review of #346 reproduced against the first cut, kept as +/// regressions: each one failed there. +@Suite +struct GoalResolutionReviewTests { + @Test + func aVerdictReadInFlightMustNotResolveAReplacementGoal() async { + let entered = AsyncStream.makeStream() + let resume = AsyncStream.makeStream() + let store = GraphStore(onReadGoalVerdict: { _, _ in + entered.continuation.yield(()) + for await _ in resume.stream { break } + return GoalVerdict(met: true) + }) + await store.handle( + .createNode( + NodeDraft(title: "Review", loopType: .goalBased, goal: GoalSpec(summary: "Old goal")))) + let id = await store.graph.nodes[0].id + let poll = Task { await store.evaluateGoal(id) } + for await _ in entered.stream { break } + await store.handle(.updateNode(id, update: NodeUpdate(goalSummary: "Replacement goal"))) + resume.continuation.yield(()) + await poll.value + #expect(await store.graph.nodes[id: id]?.state == .running) + } + + @Test + func unknownPresenceMustNotEndAHumansSession() async { + let ended = LockIsolated(false) + let store = GraphStore( + onReadPresence: { _, _ in .unknown }, + onEndSession: { _, _ in + ended.setValue(true) + return true + }) + await store.handle( + .createNode( + NodeDraft(title: "Review", loopType: .goalBased, goal: GoalSpec(summary: "Review it")))) + let id = await store.graph.nodes[0].id + await store.handle(.completeNode(id, result: nil, from: id)) + await store.endResolvedSession(id) + await store.endResolvedSession(id) + #expect(!ended.value) + } + + @Test + func reopeningMustNotAcceptThePreviousBackendCompletion() async { + let store = GraphStore( + onReadPresence: { _, _ in PresenceReading(presence: .busy, confidence: .reported) }, + onReadGoalVerdict: { _, _ in GoalVerdict(met: true) }) + await store.handle( + .createNode( + NodeDraft( + title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write docs"), + backend: .codex))) + let id = await store.graph.nodes[0].id + await store.evaluateGoal(id) + await store.handle(.updateNode(id, update: NodeUpdate(goalSummary: "Implement login"))) + await store.evaluateGoal(id) + #expect(await store.graph.nodes[id: id]?.state == .running) + } + + @Test + func replacingAHeldGoalMustDiscardItsOldCompletion() async { + let store = GraphStore() + await store.handle( + .createNode( + NodeDraft(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: "Old task")))) + let leader = await store.graph.nodes[0].id + await store.handle( + .createNode( + NodeDraft( + title: "Child", loopType: .goalBased, goal: GoalSpec(summary: "Child task"), + createdBy: leader))) + let child = await store.graph.nodes[1].id + await store.handle(.completeNode(leader, result: "Old task done", from: leader)) + await store.handle(.updateNode(leader, update: NodeUpdate(goalSummary: "Different task"))) + await store.handle(.completeNode(child, result: nil, from: child)) + #expect(await store.graph.nodes[id: leader]?.state == .running) + } + + @Test + func aDoneSentBeforeTheNewGoalLandsIsAboutTheOldGoal() async { + // pi's only verdict is `node done`: a late report from the old goal must not succeed the + // new one while that goal is still queued for the session. + let errors = LockIsolated<[String]>([]) + let store = GraphStore( + onSessionAlive: { _, _ in true }, + onAnnounceError: { message in errors.withValue { $0.append(message) } }) + await store.handle( + .createNode( + NodeDraft( + title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write docs"), + backend: .pi))) + let id = await store.graph.nodes[0].id + await store.handle(.completeNode(id, result: nil, from: id)) + await store.handle(.updateNode(id, update: NodeUpdate(goalSummary: "Add examples"))) + + await store.handle(.completeNode(id, result: "old goal done", from: id)) + + #expect(await store.graph.nodes[id: id]?.state == .running) + #expect(errors.value.contains { $0.contains("has not reached its session yet") }) + } + + @Test + func aFreshLaunchAlreadyCarriesTheNewGoalSoDoneIsAccepted() async { + let store = GraphStore( + onSessionAlive: { _, _ in false }, + onResumeSession: { _, _ in false }) + await store.handle( + .createNode( + NodeDraft( + title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write docs"), + backend: .pi))) + let id = await store.graph.nodes[0].id + await store.handle(.completeNode(id, result: nil, from: id)) + await store.handle(.updateNode(id, update: NodeUpdate(goalSummary: "Add examples"))) + + for _ in 0..<300 { + await store.handle(.completeNode(id, result: "examples added", from: id)) + if await store.graph.nodes[id: id]?.state == .succeeded { break } + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(await store.graph.nodes[id: id]?.resolution?.detail == "examples added") + } + + @Test + func addingAFailingPredicateMustInvalidateHeldCompletion() async { + let store = GraphStore(onEvaluatePredicate: { _ in false }) + await store.handle( + .createNode( + NodeDraft(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: "Old task")))) + let leader = await store.graph.nodes[0].id + await store.handle( + .createNode( + NodeDraft( + title: "Child", loopType: .goalBased, goal: GoalSpec(summary: "Child task"), + createdBy: leader))) + let child = await store.graph.nodes[1].id + await store.handle(.completeNode(leader, result: nil, from: leader)) + await store.handle(.updateNode(leader, update: NodeUpdate(goalPredicate: "false"))) + await store.handle(.completeNode(child, result: nil, from: child)) + #expect(await store.graph.nodes[id: leader]?.state == .running) + } +} diff --git a/graphcode/Tests/GoalVerdictTests.swift b/graphcode/Tests/GoalVerdictTests.swift new file mode 100644 index 00000000..71f45cb8 --- /dev/null +++ b/graphcode/Tests/GoalVerdictTests.swift @@ -0,0 +1,160 @@ +import Foundation +import SQLite3 +import Testing + +@testable import GraphcodeKit + +@Suite +struct GoalVerdictTests { + private func lines(_ records: [String]) -> [Substring] { + records.joined(separator: "\n").split(separator: "\n") + } + + @Test + func claudeCountsOnlyAnEvaluatorVerdictOnThisLoopsGoal() { + func status(_ fields: String) -> String { + #"{"attachment":{"type":"goal_status","condition":"say hi","# + fields + "}}" + } + let set = status(#""met":false,"sentinel":true"#) + let met = status(#""met":true,"reason":"said Hi!""#) + let cleared = status(#""met":true,"sentinel":true"#) + let notYet = status(#""met":false"#) + + #expect( + GoalVerdictReader.claudeVerdict(lines: lines([set, met]), goalSummary: "say hi") + == GoalVerdict(met: true, detail: "said Hi!")) + #expect(GoalVerdictReader.claudeVerdict(lines: lines([set]), goalSummary: "say hi") == nil) + #expect( + GoalVerdictReader.claudeVerdict(lines: lines([set, met, cleared]), goalSummary: "say hi") + == nil) + #expect( + GoalVerdictReader.claudeVerdict(lines: lines([set, notYet]), goalSummary: "say hi") + == GoalVerdict(met: false)) + #expect( + GoalVerdictReader.claudeVerdict(lines: lines([set, met]), goalSummary: "write docs") == nil) + } + + @Test + func aConditionCarryingTheLaunchsAppendedSentencesStillNamesTheGoal() { + #expect( + GoalVerdictReader.conditionNamesGoal( + "Fix the\nlogin bug. The goal counts as met when this command exits 0: make test", + goalSummary: "Fix the login bug.")) + #expect(!GoalVerdictReader.conditionNamesGoal("anything", goalSummary: " ")) + } + + @Test + func copilotFollowsTheNewestObjectiveStatus() { + func event(_ status: String) -> String { + #"{"type":"session.autopilot_objective_changed","data":{"status":""# + + status + #""}}"# + } + let turnEnd = #"{"type":"assistant.turn_end","data":{}}"# + let completedThenTurnEnd = lines([event("active"), event("completed"), turnEnd]) + #expect( + GoalVerdictReader.copilotVerdict(lines: completedThenTurnEnd) == GoalVerdict(met: true)) + #expect( + GoalVerdictReader.copilotVerdict(lines: lines([event("completed"), event("active")])) + == GoalVerdict(met: false)) + #expect(GoalVerdictReader.copilotVerdict(lines: lines([turnEnd])) == nil) + } + + @Test + func codexReadsTheThreadsGoalStatus() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("goals-\(UUID().uuidString).sqlite") + defer { try? FileManager.default.removeItem(at: url) } + var handle: OpaquePointer? + #expect(sqlite3_open(url.path, &handle) == SQLITE_OK) + let schema = """ + CREATE TABLE thread_goals ( + thread_id TEXT PRIMARY KEY, status TEXT NOT NULL, updated_at_ms INTEGER NOT NULL); + INSERT INTO thread_goals VALUES + ('done-thread', 'complete', 1789325210264), ('busy-thread', 'active', 1789329177000); + """ + #expect(sqlite3_exec(handle, schema, nil, nil, nil) == SQLITE_OK) + sqlite3_close(handle) + + #expect( + GoalVerdictReader.codexVerdict(threadID: "done-thread", database: url) + == GoalVerdict(met: true, recordedAt: Date(timeIntervalSince1970: 1_789_325_210.264))) + #expect( + GoalVerdictReader.codexVerdict(threadID: "busy-thread", database: url)?.met == false) + #expect(GoalVerdictReader.codexVerdict(threadID: "unknown", database: url) == nil) + } + + @Test + func recordsCarryTheirTimeSoAnEarlierGoalsVerdictIsRecognised() { + let line = + #"{"timestamp":"2026-09-05T18:19:20.490Z","attachment":{"type":"goal_status","# + + #""met":true,"condition":"say hi"}}"# + let verdict = GoalVerdictReader.claudeVerdict(lines: lines([line]), goalSummary: "say hi") + let recorded = Date(timeIntervalSince1970: 1_788_632_360.490) + #expect(verdict?.recordedAt.map { abs($0.timeIntervalSince(recorded)) < 0.001 } == true) + + var node = LoopNode(title: "a", loopType: .goalBased, createdAt: recorded.addingTimeInterval(-60)) + #expect(GraphStore.verdict(GoalVerdict(met: true, recordedAt: recorded), isCurrentFor: node)) + #expect(GraphStore.verdict(GoalVerdict(met: true), isCurrentFor: node)) + + node.goalSetAt = recorded.addingTimeInterval(1) + #expect(!GraphStore.verdict(GoalVerdict(met: true, recordedAt: recorded), isCurrentFor: node)) + #expect(!GraphStore.verdict(GoalVerdict(met: true), isCurrentFor: node)) + } + + @Test + func aMetVerdictResolvesAPredicateLessGoalAndFiresItsEdges() async { + let store = GraphStore(onReadGoalVerdict: { _, _ in GoalVerdict(met: true, detail: "done") }) + await store.handle( + .createNode( + NodeDraft( + title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "The doc reads well")))) + await store.handle( + .createNode( + NodeDraft( + title: "Ship", loopType: .turnBased, checkDescription: "?", firstInstruction: "Work"))) + let nodes = await store.graph.nodes + await store.handle(.createEdge(from: nodes[0].id, to: nodes[1].id, spec: EdgeSpec())) + + await store.evaluateGoal(nodes[0].id) + await store.evaluateGoal(nodes[0].id) + + let graph = await store.graph + #expect(graph.nodes[id: nodes[0].id]?.state == .succeeded) + #expect(graph.nodes[id: nodes[0].id]?.resolution?.basis == .nativeGoal) + #expect(graph.nodes[id: nodes[0].id]?.resolution?.detail == "done") + #expect(graph.edges[0].fireCount == 1) + } + + @Test + func anUnmetOrMissingVerdictLeavesTheGoalRunning() async { + for verdict in [GoalVerdict(met: false), nil] { + let store = GraphStore(onReadGoalVerdict: { _, _ in verdict }) + await store.handle( + .createNode( + NodeDraft( + title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "The doc reads well")))) + let nodeID = await store.graph.nodes[0].id + + await store.evaluateGoal(nodeID) + + #expect(await store.graph.nodes[id: nodeID]?.state == .running) + } + } + + @Test + func aPredicateStillDecidesWhenTheGoalHasOne() async { + let store = GraphStore( + onEvaluatePredicate: { _ in false }, + onReadGoalVerdict: { _, _ in GoalVerdict(met: true) }) + await store.handle( + .createNode( + NodeDraft( + title: "Green", loopType: .goalBased, + goal: GoalSpec(summary: "CI passes", predicate: "make test")))) + let nodeID = await store.graph.nodes[0].id + + await store.evaluateGoal(nodeID) + + #expect(await store.graph.nodes[id: nodeID]?.state == .running) + } +} diff --git a/graphcode/Tests/LeaderCompletionTests.swift b/graphcode/Tests/LeaderCompletionTests.swift new file mode 100644 index 00000000..4e5c6a43 --- /dev/null +++ b/graphcode/Tests/LeaderCompletionTests.swift @@ -0,0 +1,114 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +/// A leader is not done while the loops it created are still running (#346). +@Suite +struct LeaderCompletionTests { + private struct Fanout { + let store: GraphStore + let leaderID: UUID + let childID: UUID + let nextID: UUID + } + + private func fanout(verdict: GoalVerdict? = nil) async -> Fanout { + let store = GraphStore(onReadGoalVerdict: { node, _ in node.title == "Lead" ? verdict : nil }) + await store.handle( + .createNode( + NodeDraft(title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: "Merge all fixes")))) + let leaderID = await store.graph.nodes[0].id + await store.handle( + .createNode( + NodeDraft( + title: "Fix", loopType: .goalBased, goal: GoalSpec(summary: "Fix item one"), + createdBy: leaderID))) + await store.handle( + .createNode( + NodeDraft( + title: "Ship", loopType: .turnBased, checkDescription: "?", firstInstruction: "Work"))) + let nodes = await store.graph.nodes + let childID = nodes.first { $0.title == "Fix" }!.id + let nextID = nodes.first { $0.title == "Ship" }!.id + await store.handle(.createEdge(from: leaderID, to: nextID, spec: EdgeSpec())) + return Fanout(store: store, leaderID: leaderID, childID: childID, nextID: nextID) + } + + private func leaderEdgeFireCount(_ fanout: Fanout) async -> Int? { + await fanout.store.graph.edges.first { $0.from == fanout.leaderID && $0.to == fanout.nextID }? + .fireCount + } + + @Test + func aLeadersReportIsHeldUntilItsWorkerResolves() async { + let fanout = await fanout() + + await fanout.store.handle( + .completeNode(fanout.leaderID, result: "merged", from: fanout.leaderID)) + + let held = await fanout.store.graph.nodes[id: fanout.leaderID] + #expect(held?.state == .running) + #expect(held?.pendingCompletion?.basis == .agentReported) + #expect(await leaderEdgeFireCount(fanout) == 0) + + await fanout.store.handle(.completeNode(fanout.childID, result: nil, from: fanout.childID)) + + let resolved = await fanout.store.graph.nodes[id: fanout.leaderID] + #expect(resolved?.state == .succeeded) + #expect(resolved?.resolution?.basis == .agentReported) + #expect(resolved?.resolution?.detail == "merged") + #expect(resolved?.pendingCompletion == nil) + #expect(await leaderEdgeFireCount(fanout) == 1) + } + + @Test + func aBackendVerdictIsHeldUntilItsWorkerSucceeds() async { + let fanout = await fanout(verdict: GoalVerdict(met: true)) + + await fanout.store.evaluateGoal(fanout.leaderID) + await fanout.store.evaluateGoal(fanout.leaderID) + #expect(await fanout.store.graph.nodes[id: fanout.leaderID]?.state == .running) + + await fanout.store.handle(.completeNode(fanout.childID, result: nil, from: fanout.childID)) + + let leader = await fanout.store.graph.nodes[id: fanout.leaderID] + #expect(leader?.state == .succeeded) + #expect(leader?.resolution?.basis == .nativeGoal) + #expect(await leaderEdgeFireCount(fanout) == 1) + } + + @Test + func aWorkerThatDidNotSucceedDiscardsTheHeldCompletion() async { + // Done on top of failed work is not done: the leader must look at the failure and + // report again, and its earlier verdict no longer counts. + let fanout = await fanout(verdict: GoalVerdict(met: true)) + await fanout.store.evaluateGoal(fanout.leaderID) + + await fanout.store.handle(.stopNode(fanout.childID)) + await fanout.store.evaluateGoal(fanout.leaderID) + + let leader = await fanout.store.graph.nodes[id: fanout.leaderID] + #expect(leader?.state == .running) + #expect(leader?.pendingCompletion == nil) + #expect(await leaderEdgeFireCount(fanout) == 0) + } + + @Test + func aHumanMarkingTheLeaderDoneIsNotHeld() async { + let fanout = await fanout() + + await fanout.store.handle(.completeNode(fanout.leaderID, result: nil, from: nil)) + + #expect(await fanout.store.graph.nodes[id: fanout.leaderID]?.state == .succeeded) + } + + @Test + func theCreatorCanMarkItsWorkerDone() async { + let fanout = await fanout() + + await fanout.store.handle(.completeNode(fanout.childID, result: nil, from: fanout.leaderID)) + + #expect(await fanout.store.graph.nodes[id: fanout.childID]?.resolution?.basis == .agentReported) + } +} diff --git a/graphcode/Tests/LoopCardPresentationTests.swift b/graphcode/Tests/LoopCardPresentationTests.swift index 44fe2027..0b79b3f5 100644 --- a/graphcode/Tests/LoopCardPresentationTests.swift +++ b/graphcode/Tests/LoopCardPresentationTests.swift @@ -140,6 +140,28 @@ struct LoopCardPresentationTests { == "budget exhausted: 3000000 of 3000000 tokens spent") } + @Test + func aResolvedLoopSaysHowItResolvedInsteadOfRestatingItsGoal() { + var met = LoopNode( + title: "a", loopType: .goalBased, goal: GoalSpec(summary: "the suite is green"), + state: .succeeded) + met.resolution = LoopResolution(basis: .predicate) + #expect(LoopCardPresentation(node: met).liveLine == "predicate passed") + + met.resolution = LoopResolution(basis: .workers, detail: "3 of 3") + #expect(LoopCardPresentation(node: met).liveLine == "workers rolled up · 3 of 3") + } + + @Test + func aHeldLeadersCardSaysWhatItIsWaitingOn() { + var leader = LoopNode( + title: "Lead", loopType: .goalBased, goal: GoalSpec(summary: "Merge"), state: .running) + leader.pendingCompletion = LoopResolution(basis: .agentReported) + #expect( + LoopCardPresentation(node: leader).liveLine + == "reported done · waiting on the loops it created") + } + @Test func aStalledLoopWithoutAKnownWhyKeepsItsHandedLine() { let stalled = LoopNode( diff --git a/graphcode/Tests/NodeDoneTests.swift b/graphcode/Tests/NodeDoneTests.swift new file mode 100644 index 00000000..05613e88 --- /dev/null +++ b/graphcode/Tests/NodeDoneTests.swift @@ -0,0 +1,147 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +/// `graphcode node done` — the completion report every backend can send (#346). +@Suite +struct NodeDoneTests { + private struct Fixture { + let store: GraphStore + let goalID: UUID + let peerID: UUID + } + + private func goalStore( + backend: CLISessionBackendKind? = nil, predicate: String? = nil, + predicatePasses: Bool = false, errors: LockIsolated<[String]> = LockIsolated([]) + ) async -> Fixture { + let store = GraphStore( + onEvaluatePredicate: { _ in predicatePasses }, + onAnnounceError: { message in errors.withValue { $0.append(message) } }) + await store.handle( + .createNode( + NodeDraft( + title: "Docs", loopType: .goalBased, + goal: GoalSpec(summary: "The doc reads well", predicate: predicate), + backend: backend))) + await store.handle( + .createNode( + NodeDraft( + title: "Ship", loopType: .turnBased, checkDescription: "?", firstInstruction: "Work"))) + let nodes = await store.graph.nodes + await store.handle(.createEdge(from: nodes[0].id, to: nodes[1].id, spec: EdgeSpec())) + return Fixture(store: store, goalID: nodes[0].id, peerID: nodes[1].id) + } + + @Test + func theLoopReportingItselfDoneResolvesItOnceWithItsResult() async { + let fixture = await goalStore() + + await fixture.store.handle( + .completeNode(fixture.goalID, result: "PR #12 merged", from: fixture.goalID)) + await fixture.store.handle(.completeNode(fixture.goalID, result: "again", from: fixture.goalID)) + + let graph = await fixture.store.graph + #expect(graph.nodes[id: fixture.goalID]?.state == .succeeded) + #expect(graph.nodes[id: fixture.goalID]?.resolution?.basis == .agentReported) + #expect(graph.nodes[id: fixture.goalID]?.resolution?.detail == "PR #12 merged") + #expect(graph.edges[0].fireCount == 1) + } + + @Test + func aPiLoopWithNoVerdictOfItsOwnResolvesByReportingDone() async { + // pi has no `/goal`, so nothing in its session records a verdict: the report is the + // only way its predicate-less goal can resolve. + let fixture = await goalStore(backend: .pi) + let node = await fixture.store.graph.nodes[id: fixture.goalID] + #expect(node?.backend == .pi) + #expect(node.flatMap { GoalVerdictReader.verdict(of: $0, projectPath: nil) } == nil) + + await fixture.store.handle(.completeNode(fixture.goalID, result: nil, from: fixture.goalID)) + + #expect(await fixture.store.graph.nodes[id: fixture.goalID]?.resolution?.basis == .agentReported) + } + + @Test + func aHumanAtTheShellMarksItDone() async { + let fixture = await goalStore() + + await fixture.store.handle(.completeNode(fixture.goalID, result: nil, from: nil)) + + #expect(await fixture.store.graph.nodes[id: fixture.goalID]?.resolution?.basis == .human) + } + + @Test + func anUnrelatedPeerCannotReportAnotherLoopDone() async { + let errors = LockIsolated<[String]>([]) + let fixture = await goalStore(errors: errors) + + await fixture.store.handle(.completeNode(fixture.goalID, result: nil, from: fixture.peerID)) + + #expect(await fixture.store.graph.nodes[id: fixture.goalID]?.state == .running) + #expect(errors.value.contains { $0.hasPrefix("done refused:") }) + } + + @Test + func aFailingPredicateOutranksTheReport() async { + let checked = LockIsolated(0) + let store = GraphStore(onEvaluatePredicate: { _ in + checked.withValue { $0 += 1 } + return false + }) + await store.handle( + .createNode( + NodeDraft( + title: "Docs", loopType: .goalBased, + goal: GoalSpec(summary: "The doc reads well", predicate: "make test")))) + let goalID = await store.graph.nodes[0].id + + await store.handle(.completeNode(goalID, result: nil, from: goalID)) + + #expect(await eventually { checked.value > 0 }) + #expect(await store.graph.nodes[id: goalID]?.state == .running) + } + + @Test + func aPassingPredicateResolvesOnTheReportAsThePredicate() async { + let fixture = await goalStore(predicate: "make test", predicatePasses: true) + + await fixture.store.handle(.completeNode(fixture.goalID, result: nil, from: fixture.goalID)) + + #expect( + await eventually { + await fixture.store.graph.nodes[id: fixture.goalID]?.resolution?.basis == .predicate + }) + } + + private func eventually(_ condition: () async -> Bool) async -> Bool { + for _ in 0..<300 { + if await condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return false + } + + @Test + func onlyAGoalLoopCanBeReportedDone() async { + let errors = LockIsolated<[String]>([]) + let fixture = await goalStore(errors: errors) + + await fixture.store.handle(.completeNode(fixture.peerID, result: nil, from: nil)) + + #expect(await fixture.store.graph.nodes[id: fixture.peerID]?.state != .succeeded) + #expect(errors.value.contains { $0.contains("is not a goal loop") }) + } + + @Test + func theVerbTakesTrailingWordsAsTheResult() throws { + let id = UUID() + #expect( + try GraphcodeCommand.parse(["node", "done", "/p", id.uuidString, "merged", "#12"]) + == .completeNode(projectPath: "/p", nodeID: id, result: "merged #12")) + #expect( + try GraphcodeCommand.parse(["node", "done", "/p", id.uuidString]) + == .completeNode(projectPath: "/p", nodeID: id, result: nil)) + } +} diff --git a/graphcode/Tests/ResolvedSessionTests.swift b/graphcode/Tests/ResolvedSessionTests.swift new file mode 100644 index 00000000..8542d0d6 --- /dev/null +++ b/graphcode/Tests/ResolvedSessionTests.swift @@ -0,0 +1,190 @@ +import ComposableArchitecture +import Foundation +import GraphcodeKit +import Testing + +/// A resolved loop's session is ended to free the machine; the loop and its transcript +/// stay, and opening it brings the conversation back (#346). +@Suite +struct ResolvedSessionTests { + private struct Harness { + let store: GraphStore + let id: UUID + let ended: LockIsolated<[UUID]> + } + + private func resolved( + presence: PresenceReading?, clients: Int? = 0, grace: Duration? = nil + ) async -> Harness { + let ended = LockIsolated<[UUID]>([]) + let readPresence: (@Sendable (LoopNode, String?) async -> PresenceReading)? = + presence.map { reading in { _, _ in reading } } + let store = GraphStore( + onReadPresence: readPresence, + onEndSession: { node, _ in + ended.withValue { $0.append(node.id) } + return true + }, + onAttachedClients: { _, _ in clients }, + onResolvedSessionGrace: { grace }) + await store.handle( + .createNode( + NodeDraft(title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write it")))) + let id = await store.graph.nodes[0].id + await store.handle(.completeNode(id, result: nil, from: id)) + return Harness(store: store, id: id, ended: ended) + } + + private let idle = PresenceReading(presence: .idle, confidence: .reported) + + private func eventually(_ condition: () async -> Bool) async -> Bool { + for _ in 0..<300 { + if await condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return false + } + + @Test + func aQuietSessionIsEndedOnlyWhenItIsStillQuietTheSecondTime() async { + let harness = await resolved(presence: idle) + + await harness.store.endResolvedSession(harness.id) + #expect(harness.ended.value.isEmpty) + + await harness.store.endResolvedSession(harness.id) + #expect(harness.ended.value == [harness.id]) + #expect(await harness.store.graph.nodes[id: harness.id]?.state == .succeeded) + } + + @Test + func theScheduledEndActuallyRuns() async { + let harness = await resolved(presence: idle, grace: .milliseconds(10)) + + #expect(await eventually { harness.ended.value == [harness.id] }) + } + + @Test + func aSessionThatIsBusyUnknownGuessedOrAttachedIsLeftAlone() async { + let readings: [(PresenceReading?, Int?)] = [ + (PresenceReading(presence: .busy, confidence: .reported), 0), + (PresenceReading(presence: .awaitingInput, confidence: .reported), 0), + (.unknown, 0), + (PresenceReading(presence: .idle, confidence: .heuristic), 0), + (idle, 1), + (idle, nil), + (nil, 0), + ] + for (presence, clients) in readings { + let harness = await resolved(presence: presence, clients: clients) + await harness.store.endResolvedSession(harness.id) + await harness.store.endResolvedSession(harness.id) + #expect(harness.ended.value.isEmpty) + } + } + + @Test + func anUnresolvedLoopsSessionIsNeverEnded() async { + let ended = LockIsolated<[UUID]>([]) + let store = GraphStore( + onReadPresence: { _, _ in PresenceReading(presence: .idle, confidence: .reported) }, + onEndSession: { node, _ in + ended.withValue { $0.append(node.id) } + return true + }) + await store.handle( + .createNode( + NodeDraft(title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write it")))) + let id = await store.graph.nodes[0].id + + await store.endResolvedSession(id) + await store.endResolvedSession(id) + + #expect(ended.value.isEmpty) + } + + @Test + func openingAResolvedLoopWithNoSessionResumesItWithoutTheMetGoal() async { + let resumed = LockIsolated<[LoopNode]>([]) + let store = GraphStore( + onSessionAlive: { _, _ in false }, + onResumeSession: { node, _ in + resumed.withValue { $0.append(node) } + return true + }) + await store.handle( + .createNode( + NodeDraft(title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write it")))) + let id = await store.graph.nodes[0].id + + await store.handle(.resumeSession(id)) + #expect(resumed.value.isEmpty) + + await store.handle(.completeNode(id, result: nil, from: id)) + await store.handle(.resumeSession(id)) + + #expect(resumed.value.map(\.id) == [id]) + #expect(resumed.value.first?.sessionPrompt?.contains("Write it") == false) + #expect(await store.graph.nodes[id: id]?.state == .succeeded) + } + + @Test + func aNewGoalReopensAResolvedLoopWithoutRefiringItsEdges() async { + let resumed = LockIsolated<[LoopNode]>([]) + let store = GraphStore( + onSessionAlive: { _, _ in false }, + onResumeSession: { node, _ in + resumed.withValue { $0.append(node) } + return false + }) + await store.handle( + .createNode( + NodeDraft(title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write it")))) + await store.handle( + .createNode( + NodeDraft( + title: "Ship", loopType: .turnBased, checkDescription: "?", firstInstruction: "Work"))) + let nodes = await store.graph.nodes + await store.handle(.createEdge(from: nodes[0].id, to: nodes[1].id, spec: EdgeSpec())) + await store.handle(.completeNode(nodes[0].id, result: nil, from: nodes[0].id)) + + await store.handle(.updateNode(nodes[0].id, update: NodeUpdate(goalSummary: "Add examples"))) + + let graph = await store.graph + let reopened = graph.nodes[id: nodes[0].id] + #expect(reopened?.state == .running) + #expect(reopened?.resolution == nil) + #expect(reopened?.goal?.summary == "Add examples") + #expect(reopened?.goalSetAt != nil) + #expect(await eventually { resumed.value.first?.sessionPrompt?.contains("Add examples") == true }) + #expect(graph.edges[0].fireCount == 1) + } + + @Test + func aResolvedLoopCannotHandItselfANewGoal() async { + let errors = LockIsolated<[String]>([]) + let store = GraphStore(onAnnounceError: { message in errors.withValue { $0.append(message) } }) + await store.handle( + .createNode( + NodeDraft(title: "Docs", loopType: .goalBased, goal: GoalSpec(summary: "Write it")))) + let id = await store.graph.nodes[0].id + await store.handle(.completeNode(id, result: nil, from: id)) + + await store.handle( + .updateNode(id, update: NodeUpdate(goalSummary: "Do more", updatedBy: id))) + + #expect(await store.graph.nodes[id: id]?.state == .succeeded) + #expect(errors.value.contains { $0.contains("may not hand itself a new goal") }) + } + + @Test + func neverIsStoredAsZeroAndSurvivesARoundTrip() throws { + var settings = GraphcodeSettings() + #expect(settings.resolvedSessionGrace == .seconds(600)) + settings.endsResolvedSessionsAfterMinutes = 0 + let decoded = try JSONDecoder().decode( + GraphcodeSettings.self, from: JSONEncoder().encode(settings)) + #expect(decoded.endsResolvedSessionsAfterMinutes == 0) + #expect(decoded.resolvedSessionGrace == nil) + } +}