From b256a8ca662f67a7093bd3bd343e7accbe1f9937 Mon Sep 17 00:00:00 2001 From: Dennison Date: Mon, 3 Aug 2026 21:41:34 +0200 Subject: [PATCH 1/3] fix(toolwalk): observe displaced submission outcomes --- ...3-issue-1133-passive-outcome-impact-map.md | 77 +++++ ...6-08-03-issue-1133-passive-outcome-plan.md | 60 ++++ macapp/Package.swift | 5 +- macapp/Sources/ToolWalk/Runner.swift | 84 +++-- ...iveSubmissionOutcomeIntegrationTests.swift | 302 ++++++++++++++++++ 5 files changed, 499 insertions(+), 29 deletions(-) create mode 100644 docs/plans/2026-08-03-issue-1133-passive-outcome-impact-map.md create mode 100644 docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md create mode 100644 macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift diff --git a/docs/plans/2026-08-03-issue-1133-passive-outcome-impact-map.md b/docs/plans/2026-08-03-issue-1133-passive-outcome-impact-map.md new file mode 100644 index 00000000..4dbe45d0 --- /dev/null +++ b/docs/plans/2026-08-03-issue-1133-passive-outcome-impact-map.md @@ -0,0 +1,77 @@ +# Cross-Surface Impact Map: Issue #1133 + +## Task + +- Task / issue: #1133 passive observation of displaced A submission outcomes. +- Plan link: `2026-08-03-issue-1133-passive-outcome-plan.md`. +- Owner: native-client delivery lane. +- Status: implemented locally, stacked on #1131 `63cf9fcd`. + +## Current Ownership, Callers, and Data Flow + +- Entry points: `ProjectSession.submit`/composer -> `RunSession.submit` -> + `RunSubmission`; `ToolWalk.Runner` waits, controls, and judges the handle. +- Source of truth: `RunSubmission.lifecycle` is A outcome; `isDisplaced` and + `RunSession.currentRunID` are authority fences for shared UI/actions. +- Search evidence: `rg -n "RunSubmission|waitForTerminal|cancelTimedOutSubmission|activeSubmission" macapp`. +- Conclusion: submission-local result and shared selected-run authority are + intentionally separate; wait policy must observe the former while honoring + the latter. + +## Config, API, CLI, and Tools + +- Config/defaults/environment: none. +- HTTP/schema/server: uses existing exact `/v1/runs/{A}/cancel`; no schema or + server change. +- CLI/tools: ToolWalk wait policy only; command grammar/tool catalog unchanged. +- Errors: A EOF/failure remains a truthful ToolWalk result, never B UI error. + +## Persistence and Compatibility + +- Schemas/migrations/caches: none. +- Compatibility: no public wire change; `RunSubmission` keeps existing state + projection and sticky displacement semantics. +- Mixed version: client-local behavior; no server compatibility concern. + +## Lifecycle, Security, and Reliability + +- Concurrency: A SSE, B conversation SSE, start ACK, and timeout race. A is + passive after displacement until terminal/failure/deadline. #1136 owns the + immutable B/C authority and revocation contract. +- Authorization/trust: #1133 relies on the existing exact A-only path; #1136 + hardens the capability boundary. No credentials change. +- Failure/recovery: timeout sends A-only transport without touching B's + selection, pending UI, transcript, or cancel state. + +## Product and Integration Surfaces + +- Server/runtime and TUI: none. +- macOS/ToolWalk: B stays visually selected; initiating ToolWalk receives the + actual A terminal/failure verdict. +- Providers/catalog/automation: none; cron/callback B is the motivating normal + conversation continuation. +- UX/accessibility: no new controls; avoids stale controls acting on B. + +## Deployment and Operations + +- Deployment/flags: standard native PR; no migration. +- Diagnostics: the retained submission handle is the A-local evidence. +- Rollback: revert PR if an A deadline can affect B; gated tests isolate this. + +## Regression Tests + +- Red: four initial actual gated URLSession tests failed pre-fix: A terminal/EOF + and timeout returned `.displaced`; delayed ACK returned before A had an + identity. +- Acceptance: B-before-terminal, EOF, timeout, and delayed ACK use + `RunSession.submit()` plus `Runner`, assert B selection and zero B endpoint + actions. #1136 owns B -> C exact-one A dispatch and revocation coverage. +- Full commands: strict format, focused native/ToolWalk tests, `swift test + --package-path macapp`, and `./scripts/test-regression.sh`. + +## Documentation and Handoff + +- Public docs: none. +- Internal docs: plan/map, active plan, four durable logs, and both indexes. +- PR handoff: `Closes #1133`, red/green commands, exact stacked base, review, + and hosted check evidence. diff --git a/docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md b/docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md new file mode 100644 index 00000000..776b2699 --- /dev/null +++ b/docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md @@ -0,0 +1,60 @@ +# Plan: Issue #1133 passive displaced-submission outcomes + +## Context + +- Governing GitHub issue: #1133. +- Problem: after B replaces visible submitted A, ToolWalk returned `.displaced` + immediately. A terminal/failure therefore could never be judged even though + `RunSubmission` still retained the correct A-only evidence. +- User impact: scheduled callbacks/crons can continue the visible conversation + without causing the initiating tool walk to falsely fail or control B. +- Constraints: stack on #1131 `63cf9fcd`; no harness, TUI, persistence, or wire + contract change; B selection permanently revokes A automatic controls. + +## Scope + +- In scope: retain passive A observation through terminal/failure/deadline and + prove the timeout policy sends A-only transport without a B action, using + gated `RunSession.submit()` plus `Runner` integration coverage. #1136 owns + the immutable capability implementation and its stronger authority proof. +- Out of scope: changing selected-run UI ownership, server scheduling, ToolWalk + grammar, retry behavior, or live #1010 acceptance. + +## Documentation Contract + +- Feature status: implemented locally, pending review and hosted gates. +- Public docs affected: none; this is an internal native/ToolWalk ownership + policy. +- Implementation notes: durable logs and indexes record the corrected contract. + +## Test Plan (TDD) + +- First failing test: B is selected before A terminal/EOF/timeout/delayed ACK; + old Runner returns `.displaced`, so it neither observes A nor cancels timed-out + A. +- Acceptance tests: actual URLSession-gated `RunSession.submit()` + + `Runner.waitFor…` tests prove A terminal, A EOF failure, A-only timeout POST, + and delayed A acknowledgement, each with zero B action endpoints. #1136 + owns the B -> C immutable authority, revocation, and all-stream proof. +- Regression tests: existing action-owner, delayed-ACK, submission-handle, and + ToolWalk outcome suites; full Swift and repository regression gates. + +## Implementation Checklist + +- [x] Verify #1133 and stacked #1131 base. +- [x] Write and capture the gated integration red. +- [x] Keep displacement sticky while observing terminal/failure passively. +- [x] Make timeout transport-only for exact locally owned displaced A. +- [x] Update plans/logs/indexes, including the stale displaced-result wording. +- [x] Run strict format and focused/full Swift. +- [ ] Run full regression after the independent #1135 baseline fixture repair. +- [ ] Publish stacked draft `Closes #1133`; obtain independent cheap review. + +## Risks and Mitigations + +- Risk: passive waiting accidentally sends an A prompt/approval to selected B. + Mitigation: explicit `isDisplaced || currentRunID != A` control fence and + per-test zero-B endpoint assertions. +- Risk: A timeout does nothing because B is selected, or mutates B state. + Mitigation: narrow local-submission ownership predicate with an A-only + transport POST that does not change shared selected/transcript state. diff --git a/macapp/Package.swift b/macapp/Package.swift index 82f2c1fd..8bb38f37 100644 --- a/macapp/Package.swift +++ b/macapp/Package.swift @@ -25,7 +25,10 @@ let package = Package( dependencies: ["HarnessKit"], resources: [.copy("Fixtures")] ), - .testTarget(name: "GoCodeUITests", dependencies: ["GoCodeUI"]), + // RunSubmission/ToolWalk ownership integration tests need both sides + // of the public app boundary: the real `RunSession.submit()` entry + // point and ToolWalk's waiting policy. + .testTarget(name: "GoCodeUITests", dependencies: ["GoCodeUI", "ToolWalk"]), .testTarget(name: "ToolWalkTests", dependencies: ["ToolWalk"]), ] ) diff --git a/macapp/Sources/ToolWalk/Runner.swift b/macapp/Sources/ToolWalk/Runner.swift index f295ad1a..002c568d 100644 --- a/macapp/Sources/ToolWalk/Runner.swift +++ b/macapp/Sources/ToolWalk/Runner.swift @@ -2,12 +2,13 @@ import Foundation import GoCodeUI import HarnessKit -struct RunnerConfig: Sendable { +struct RunnerConfig { var timeoutPerTool: Duration var pollInterval: Duration static let `default` = RunnerConfig( - timeoutPerTool: .seconds(240), pollInterval: .milliseconds(200)) + timeoutPerTool: .seconds(240), pollInterval: .milliseconds(200) + ) } /// Drives every `ToolSpec` through the app's own `ProjectSession`/`RunSession` @@ -41,7 +42,8 @@ enum Runner { project.newConversation() guard let run = project.run else { let result = ToolResult( - name: spec.name, verdict: "fail", reply: "no active RunSession on project") + name: spec.name, verdict: "fail", reply: "no active RunSession on project" + ) results.append(result) print(" FAIL (no run session)") continue @@ -50,7 +52,8 @@ enum Runner { run.draft = spec.prompt guard let submission = project.submit() else { let result = ToolResult( - name: spec.name, verdict: "fail", reply: "submission was not accepted") + name: spec.name, verdict: "fail", reply: "submission was not accepted" + ) results.append(result) print(" FAIL (submission was not accepted)") continue @@ -64,7 +67,8 @@ enum Runner { guard started == .started else { if started == .terminal { let result = judge( - tool: spec.name, observed: observe(submission, timedOut: false)) + tool: spec.name, observed: observe(submission, timedOut: false) + ) results.append(result) print(" \(result.verdict.uppercased())") continue @@ -100,7 +104,8 @@ enum Runner { } let result = judge( - tool: spec.name, observed: observe(submission, timedOut: finished == .timedOut)) + tool: spec.name, observed: observe(submission, timedOut: finished == .timedOut) + ) results.append(result) print(" \(result.verdict.uppercased())") } @@ -112,8 +117,18 @@ enum Runner { ) async -> SubmissionWaitOutcome { let deadline = ContinuousClock.now.advanced(by: config.timeoutPerTool) while ContinuousClock.now < deadline { - let outcome = outcome(for: submission) - if outcome != .started || submission.runID != nil { return outcome } + // Displacement removes authority over the rendered session, not + // ownership of A's eventual result. In particular, B can arrive + // before A's start response: keep waiting for A's immutable id so + // the subsequent passive wait can report its terminal/failure. + switch submission.lifecycle { + case .terminal: + return .terminal + case .failed(let message): + return .failed(message) + case .starting, .started: + if submission.runID != nil { return .started } + } try? await Task.sleep(for: config.pollInterval) } return .timedOut @@ -130,11 +145,23 @@ enum Runner { while ContinuousClock.now < deadline { let outcome = outcome(for: submission) switch outcome { - case .terminal, .failed, .displaced: return outcome - case .started: break + case .terminal, .failed: return outcome + case .started, .displaced: break case .timedOut: return .timedOut } - guard let runID = submission.runID, run.currentRunID == runID else { return .displaced } + guard let runID = submission.runID else { + try? await Task.sleep(for: config.pollInterval) + continue + } + // Once B owns visible state, A's handle remains an observation + // source only. Do not return early: A may still terminal/fail, + // and ToolWalk must judge that exact outcome. The same guard also + // fails closed if a future selection path fails to mark the handle + // displaced: mismatched selected state never authorizes a control. + guard !submission.isDisplaced, run.currentRunID == runID else { + try? await Task.sleep(for: config.pollInterval) + continue + } if let prompt = run.pendingQuestions { guard prompt.runID == runID else { return .displaced } var answers: [String: String] = [:] @@ -167,10 +194,10 @@ enum Runner { for lifecycle: RunSubmission.Lifecycle, isDisplaced: Bool ) -> SubmissionWaitOutcome { switch lifecycle { - case .terminal: return .terminal - case .failed(let message): return .failed(message) + case .terminal: .terminal + case .failed(let message): .failed(message) case .starting, .started: - return isDisplaced ? .displaced : .started + isDisplaced ? .displaced : .started } } @@ -207,23 +234,24 @@ enum Runner { runFailed: submission.transcript.runState == .failed, runCancelled: submission.transcript.runState == .cancelled, connectionError: submission.failure, - timedOut: timedOut) + timedOut: timedOut + ) } private static func failedResult(tool: String, outcome: SubmissionWaitOutcome) -> ToolResult { - let reply: String - switch outcome { - case .displaced: - reply = "submission was displaced by another run; no action was sent to that run" - case .failed(let message): - reply = "submission failed: \(message)" - case .terminal: - reply = "submission reached terminal state before ToolWalk could observe it" - case .timedOut: - reply = "submission timed out waiting for a terminal outcome" - case .started: - reply = "submission did not reach a controllable started state" - } + let reply = + switch outcome { + case .displaced: + "submission was displaced by another run; no action was sent to that run" + case .failed(let message): + "submission failed: \(message)" + case .terminal: + "submission reached terminal state before ToolWalk could observe it" + case .timedOut: + "submission timed out waiting for a terminal outcome" + case .started: + "submission did not reach a controllable started state" + } return ToolResult(name: tool, verdict: "fail", reply: reply) } } diff --git a/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift b/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift new file mode 100644 index 00000000..d96c3413 --- /dev/null +++ b/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift @@ -0,0 +1,302 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI +@testable import ToolWalk + +/// Exercises `RunSession.submit()` and ToolWalk's wait policy through a real +/// URLSession transport. Gates establish the ownership boundary (B selected) +/// before the A response/stream is allowed to advance; sleeps would make these +/// exact ordering proofs flaky and could accidentally test the opposite order. +private final class PassiveOutcomeProtocol: URLProtocol, @unchecked Sendable { + struct Response { + var status = 200 + var headers = ["Content-Type": "application/json"] + var body = Data() + var neverFinishes = false + var waitForGate: String? + } + + private nonisolated(unsafe) static var handler: (@Sendable (URLRequest) -> Response)? + private nonisolated(unsafe) static var requests: [URLRequest] = [] + private nonisolated(unsafe) static var stoppedPaths: Set = [] + private nonisolated(unsafe) static var startRunResponses = 0 + private static let lock = NSLock() + private static let gateLock = NSCondition() + private nonisolated(unsafe) static var openGates: Set = [] + + static func set(_ handler: @escaping @Sendable (URLRequest) -> Response) { + lock.withLock { self.handler = handler } + } + + static func reset() { + lock.withLock { + handler = nil + requests = [] + stoppedPaths = [] + startRunResponses = 0 + } + gateLock.lock() + openGates = [] + gateLock.unlock() + } + + static func paths() -> [String] { + lock.withLock { requests.compactMap(\.url?.path) } + } + + static func stopped(_ path: String) -> Bool { + lock.withLock { stoppedPaths.contains(path) } + } + + /// URLProtocol does not guarantee `httpBody` remains materialized when the + /// request is recreated by URLSession. The integration fixture uses a + /// request-order counter rather than relying on that transport detail. + static func nextStartRunID() -> String { + lock.withLock { + startRunResponses += 1 + return startRunResponses == 1 ? "run_a" : "run_c" + } + } + + static func openGate(_ gate: String) { + gateLock.lock() + openGates.insert(gate) + gateLock.broadcast() + gateLock.unlock() + } + + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + let request = request + let handler = Self.lock.withLock { + Self.requests.append(request) + return Self.handler + } + // The handler may advance a fixture counter protected by the same + // lock. Invoke it after recording the request, not recursively inside + // the lock, so a multi-submit ordering test cannot deadlock itself. + let response = handler?(request) ?? Response() + DispatchQueue.global().async { + if let gate = response.waitForGate { Self.waitForGate(gate) } + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, httpVersion: "HTTP/1.1", + headerFields: response.headers + )! + self.client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: response.body) + if !response.neverFinishes { self.client?.urlProtocolDidFinishLoading(self) } + } + } + + override func stopLoading() { + _ = Self.lock.withLock { Self.stoppedPaths.insert(request.url?.path ?? "") } + } + + private static func waitForGate(_ gate: String) { + gateLock.lock() + defer { gateLock.unlock() } + let deadline = Date().addingTimeInterval(5) + while !openGates.contains(gate), gateLock.wait(until: deadline) {} + } +} + +@Suite("ToolWalk displaced submission outcomes", .serialized) +@MainActor +struct PassiveSubmissionOutcomeIntegrationTests { + private func session() -> RunSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [PassiveOutcomeProtocol.self] + return RunSession( + client: HarnessClient( + baseURL: URL(string: "http://127.0.0.1:8897")!, + session: URLSession(configuration: configuration) + )) + } + + private func event(_ id: String, _ runID: String, _ type: String, timestamp: String) throws + -> HarnessEvent + { + try HarnessEvent( + frame: SSEFrame( + id: id, event: type, + data: + #"{"id":"\#(id)","run_id":"\#(runID)","type":"\#(type)","timestamp":"\#(timestamp)","payload":{}}"# + )) + } + + private nonisolated static func terminalStream(for runID: String) -> Data { + let id = "\(runID):terminal" + return Data( + "id: \(id)\nevent: run.completed\ndata: {\"id\":\"\(id)\",\"run_id\":\"\(runID)\",\"type\":\"run.completed\",\"payload\":{}}\n\n" + .utf8) + } + + private func wait(timeout: Duration = .seconds(2), _ condition: () -> Bool) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("timed out waiting for gated integration condition") + } + + private func displaceA(_ session: RunSession, submission: RunSubmission) async throws { + try await wait { submission.runID == "run_a" && session.currentRunID == "run_a" } + try await session.applyConversationEvent( + event("run_a:started", "run_a", "run.started", timestamp: "2026-08-03T22:00:00Z"), + conversationID: "conversation" + ) + try await session.applyConversationEvent( + event("run_b:started", "run_b", "run.started", timestamp: "2026-08-03T22:00:01Z"), + conversationID: "conversation" + ) + #expect(session.currentRunID == "run_b") + #expect(submission.isDisplaced) + } + + private func assertNoAction(for runIDs: [String]) { + let actions = Set( + runIDs.flatMap { runID in + ["cancel", "approve", "deny", "input", "steer"].map { "/v1/runs/\(runID)/\($0)" } + } + ).intersection(PassiveOutcomeProtocol.paths()) + #expect(actions.isEmpty) + } + + @Test("B before A terminal is judged as A terminal without a B action") + func terminalAfterDisplacementRemainsObservable() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_a/events"): + .init( + headers: ["Content-Type": "text/event-stream"], + body: Self.terminalStream(for: "run_a"), waitForGate: "a-terminal") + case ("GET", "/v1/conversations/conversation/events"): + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() + } + } + let run = session() + run.load(messages: [], conversationID: "conversation") + run.draft = "A" + let submission = try #require(run.submit()) + try await displaceA(run, submission: submission) + let waitTask = Task { + await Runner.waitForTerminal( + run: run, submission: submission, + config: .init(timeoutPerTool: .seconds(1), pollInterval: .milliseconds(5))) + } + PassiveOutcomeProtocol.openGate("a-terminal") + #expect(await waitTask.value == .terminal) + #expect(run.currentRunID == "run_b") + assertNoAction(for: ["run_b"]) + run.reset() + } + + @Test("B before A EOF is judged as A failure without failing B") + func eofAfterDisplacementRemainsObservable() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_a/events"): + .init(headers: ["Content-Type": "text/event-stream"], waitForGate: "a-eof") + case ("GET", "/v1/conversations/conversation/events"): + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() + } + } + let run = session() + run.load(messages: [], conversationID: "conversation") + run.draft = "A" + let submission = try #require(run.submit()) + try await displaceA(run, submission: submission) + let waitTask = Task { + await Runner.waitForTerminal( + run: run, submission: submission, + config: .init(timeoutPerTool: .seconds(1), pollInterval: .milliseconds(5))) + } + PassiveOutcomeProtocol.openGate("a-eof") + #expect(await waitTask.value == .failed("run event stream ended before a terminal event")) + #expect(run.currentRunID == "run_b") + #expect(run.transcript.runState != .failed) + assertNoAction(for: ["run_b"]) + run.reset() + } + + @Test("B before A timeout is classified without changing B") + func timeoutAfterDisplacementIsClassifiedWithoutBAction() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_a/events"), ("GET", "/v1/conversations/conversation/events"): + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() + } + } + let run = session() + run.load(messages: [], conversationID: "conversation") + run.draft = "A" + let submission = try #require(run.submit()) + try await displaceA(run, submission: submission) + let outcome = await Runner.waitForTerminal( + run: run, submission: submission, + config: .init(timeoutPerTool: .milliseconds(80), pollInterval: .milliseconds(5)) + ) + #expect(outcome == .timedOut) + #expect(run.currentRunID == "run_b") + assertNoAction(for: ["run_b"]) + run.reset() + } + + @Test("B before delayed A acknowledgement still waits for A identity") + func delayedAcknowledgementPreservesDisplacementAndObservation() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init( + status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8), + waitForGate: "a-ack") + case ("GET", "/v1/runs/run_a/events"), ("GET", "/v1/conversations/conversation/events"): + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() + } + } + let run = session() + run.load(messages: [], conversationID: "conversation") + run.draft = "A" + let submission = try #require(run.submit()) + try await wait { PassiveOutcomeProtocol.paths().contains("/v1/runs") } + try await run.applyConversationEvent( + event("run_b:started", "run_b", "run.started", timestamp: "2026-08-03T22:00:01Z"), + conversationID: "conversation" + ) + #expect(run.currentRunID == "run_b") + PassiveOutcomeProtocol.openGate("a-ack") + let started = await Runner.waitForStartedSubmission( + submission, config: .init(timeoutPerTool: .seconds(1), pollInterval: .milliseconds(5)) + ) + #expect(started == .started) + #expect(submission.runID == "run_a") + #expect(submission.isDisplaced) + #expect(run.currentRunID == "run_b") + assertNoAction(for: ["run_b"]) + run.reset() + } +} From 4fc8b9ee4e244fca96f19ea07bbe6339015383c9 Mon Sep 17 00:00:00 2001 From: Dennison Bertram Date: Mon, 3 Aug 2026 22:07:20 +0200 Subject: [PATCH 2/3] fix(toolwalk): retain immutable timeout authority (#1139) * fix(toolwalk): retain immutable timeout authority * test(toolwalk): prove displaced timeout orchestration * docs(plan): record timeout authority regression evidence --- docs/logs/INDEX.md | 6 + docs/logs/engineering-log.md | 30 ++ docs/logs/long-term-thinking-log.md | 21 ++ docs/logs/observational-log.md | 23 ++ docs/logs/system-log.md | 22 ++ ...issue-1136-timeout-authority-impact-map.md | 28 ++ ...08-03-issue-1136-timeout-authority-plan.md | 36 +++ docs/plans/INDEX.md | 10 + docs/plans/active-plan.md | 12 + macapp/Sources/GoCodeUI/ProjectSession.swift | 12 + .../GoCodeUI/RunSession+RunControls.swift | 21 +- macapp/Sources/GoCodeUI/RunSession.swift | 158 ++++++---- macapp/Sources/GoCodeUI/RunSubmission.swift | 35 ++- macapp/Sources/ToolWalk/Runner.swift | 2 +- ...iveSubmissionOutcomeIntegrationTests.swift | 272 +++++++++++++++++- .../RunSessionExternalControlTests.swift | 217 ++++++++------ .../GoCodeUITests/RunSubmissionTests.swift | 156 ++++++---- 17 files changed, 842 insertions(+), 219 deletions(-) create mode 100644 docs/plans/2026-08-03-issue-1136-timeout-authority-impact-map.md create mode 100644 docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md diff --git a/docs/logs/INDEX.md b/docs/logs/INDEX.md index 2d6ea5b1..5c995efa 100644 --- a/docs/logs/INDEX.md +++ b/docs/logs/INDEX.md @@ -22,6 +22,12 @@ - 2026-08-03 — Issue #1124 deterministic retry-wait callback fixture evidence is recorded in the engineering, observational, and system logs. +- 2026-08-03 — Issue #1136 records immutable submission timeout capability, + one-shot dispatch, and reset/load all-stream detachment separately from #1133. + +- 2026-08-03 — Issue #1133 corrects the prior displaced-result wording: + displacement revokes controls but does not end A outcome observation. + - 2026-08-03 — Issue #1130 submission-local outcome ownership, deterministic barriers, and ToolWalk timeout ordering are recorded in all durable logs. diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index 0012a551..f84d0e05 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -57,6 +57,36 @@ normal/race passed in 13.200s/14.719s. Isolated foreground `./scripts/test-regression.sh` then passed normal/race plus 85.5% total coverage and zero uncovered functions in 2m26s. +## 2026-08-03 (Issue #1136 immutable timeout authority) + +- Replaced the provisional mutable-pointer authorization with a private + `RunSubmission` owner-token/generation capability. It atomically dispatches + only once while A is started; terminal, failure, reset, and load revoke it. +- `RunSession` now tracks every submission stream by handle. Reset/load cancels + both displaced A and selected C rather than only the most recent stream. +- Deterministic gated evidence proves B -> C -> A emits exactly one A cancel, + zero B/C actions, and terminal/failure/reset have no dispatch. Full native + Swift passes 245 tests in 46 suites; repository regression awaits #1135. + +## 2026-08-03 (Issue #1133 passive displaced-submission outcome) + +- Corrected the #1130 wait-policy gap: displacement is now a permanent action + fence, not a terminal ToolWalk result. Runner waits for its immutable A + handle's terminal/failure through deadline and never auto-answers/approves a + mismatched or displaced selected B. +- `cancelTimedOutSubmission` retains exact locally owned A transport authority + after B selection, but its displaced path is transport-only: it cannot alter + B selection, transcript, pending UI, or cancellation state. +- Test-first evidence: four URLSession-gated `RunSession.submit()` + Runner + tests were red before the repair (terminal/EOF/timeout returned displaced; + delayed ACK returned without A identity). The resulting #1133 tests prove + passive terminal, EOF failure, delayed acknowledgement, and B-safe timeout + policy. The stronger B -> C authority and revocation proof is tracked in + the separate #1136 entry above. +- Verification: strict format (0/7 touched Swift files require formatting) and + full `swift test --package-path macapp` (244 tests / 46 suites) pass. Full + regression, independent review, and hosted checks remain. + ## 2026-08-03 (Issue #1130 submission-local outcomes) - Split `RunSubmission` into independent A-local `Lifecycle` and displacement diff --git a/docs/logs/long-term-thinking-log.md b/docs/logs/long-term-thinking-log.md index 79c4bfac..1d4e85ac 100644 --- a/docs/logs/long-term-thinking-log.md +++ b/docs/logs/long-term-thinking-log.md @@ -38,6 +38,27 @@ later same-run admission; normal/race/package/full gates are green. - Guardrails: no product source, API, SQLite schema, config, client, UI, or lifecycle change; no sleep/timeout increase or synthetic production defect. +## 2026-08-03 (Issue #1136 immutable timeout authority) + +- Command intent: make a timed-out submitted A independently and exactly + cancellable after B/C selection without allowing the timeout path to affect + B or C. +- Success: one A handle can consume its started-only capability once; terminal, + failure, reset, and load revoke it; reset/load physically stop every A/C + submission stream. Direct deterministic proof complements #1133 policy waits. +- Non-goal: reintroducing run-ID lookup, changing server cancellation, or + mutating selected-run UI from timeout transport. + +## 2026-08-03 (Issue #1133 intent correction) + +- Command intent: a callback/cron continuation must visibly continue B while + the initiating tool walk truthfully observes its own submitted A outcome. +- Success: B displacement is sticky for control authority; A terminal/failure + after B is a valid A verdict; an A deadline cancels only A and produces no B + action or visible-state mutation. +- Non-goal: making B a hidden fallback, treating displacement as success, or + using shared `currentRunID` to judge/control A. + ## 2026-08-03 (Issue #1130 submission-local outcomes) - Command intent: repair the #1128 review findings without weakening the diff --git a/docs/logs/observational-log.md b/docs/logs/observational-log.md index c1cd6264..10aee4b7 100644 --- a/docs/logs/observational-log.md +++ b/docs/logs/observational-log.md @@ -31,6 +31,29 @@ - The pre-deadline checkpoint includes retry state, exact due time, reserved run ID, attempt one, and empty token/lease; checking all of them prevents a no-call assertion from masking an accidental claim or fence leak. +## 2026-08-03 (Issue #1136 timeout capability proof) + +- A real deadline is suitable for #1133 wait-policy coverage but is not an + authority proof. Direct synchronous capability consumption makes B -> C -> A + exact-one dispatch and terminal/failure/reset non-dispatch deterministic. +- A single mutable stream task would leave displaced A running when C starts. + The handle-keyed task registry permits reset/load to stop both streams. + +## 2026-08-03 (Issue #1133 passive outcome observation) + +- The #1130 handle correctly retained A lifecycle after B selection, but the + consumer stopped polling it on `.displaced`; durable A evidence was therefore + present but unobserved. A control authority and outcome observation are + separate concerns. +- Gated integration runs show B can precede A terminal, A stream EOF, A timeout, + or A start acknowledgement. Each retains B as the selected scheduled run; + only the deadline scenario emits an A cancel request. +- B can itself terminal before A while a user submits C. This proved timeout + authorization must follow A's stream lifetime, not `activeSubmission` or the + one current local-stream pointer. The final contract uses an immutable A + handle owner token plus reset/load generation and cancels every live local + submission stream when detaching a session. + ## 2026-08-03 (Issue #1130 submission-outcome observation) - The original single `State` made displacement overwrite terminal/failure diff --git a/docs/logs/system-log.md b/docs/logs/system-log.md index ff2c5ee9..8f3a95e5 100644 --- a/docs/logs/system-log.md +++ b/docs/logs/system-log.md @@ -38,6 +38,28 @@ complete tools normal/race (13.200s/14.719s) pass on the final source tree; isolated repository regression also passes normal/race, 85.5% coverage, and zero uncovered functions. +## 2026-08-03 (Issue #1136 immutable timeout capability) + +- `RunSubmission` privately binds owner token, generation, lifecycle, and a + consumed bit. `RunSession` is the only authority that can consume it, and + `cancelTimedOutSubmission` dispatches a transport-only A cancel only on that + success. A handle-keyed task registry lets reset/load cancel all local streams. + +## 2026-08-03 (Issue #1133 passive A outcome after B selection) + +- Flow: ToolWalk captures `RunSubmission(A)` -> conversation stream selects B + and marks A displaced -> Runner disables all automatic controls yet continues + reading A-local lifecycle -> A terminal/failure is judged, or deadline sends + the existing A cancel endpoint through a local-ownership fence. +- The selected-run reducer remains the only B UI authority. The displaced A + timeout path intentionally performs no shared-state transition, so it cannot + clear B pending controls, selection, transcript, or acknowledgement state. +- `RunSubmission` carries a session-owner token and reset/load generation plus + a one-shot started-only timeout capability. It preserves A-only authority + through B -> C replacement without reconstructing it from an ID/set; terminal + or failure consume no capability, and reset/load cancels all live submission + tasks while invalidating old handles. + ## 2026-08-03 (Issue #1130 submission-local outcome flow) - Flow: local composer/ToolWalk A -> `RunSubmission.lifecycle` plus diff --git a/docs/plans/2026-08-03-issue-1136-timeout-authority-impact-map.md b/docs/plans/2026-08-03-issue-1136-timeout-authority-impact-map.md new file mode 100644 index 00000000..8a62c242 --- /dev/null +++ b/docs/plans/2026-08-03-issue-1136-timeout-authority-impact-map.md @@ -0,0 +1,28 @@ +# Cross-Surface Impact Map: Issue #1136 + +## Task and ownership + +- Issue: #1136 immutable submitted-run timeout authority. +- Source of truth: a `RunSubmission` created by its owning `RunSession`. +- Search evidence: `RunSubmission`, `consumeTimeoutCancellation`, + `cancelTimedOutSubmission`, and `submissionStreamTasks` under `macapp/`. + +## Surfaces + +- Native model: private owner UUID plus reset/load generation and lifecycle + form an unforgeable, one-shot A cancellation capability. +- ToolWalk: invokes only the handle API. Its timeout is transport-only; it + cannot alter B/C selection, transcript, controls, or cancellation state. +- HTTP/API: unchanged existing `POST /v1/runs/{A}/cancel` endpoint only. +- Persistence, harness, TUI, schema, CLI, providers: none; search found no + changed contract or stored state. + +## Reliability, test, and rollback + +- Concurrent A SSE, B/C conversation events, delayed ACK, timeout, and reset + are scoped by immutable handle. Terminal/failure/reset/load make later A + dispatch impossible; displacement deliberately does not. +- Gated URLProtocol integration uses actual `RunSession.submit()` and proves + B -> C -> A sends exactly one A cancel, zero B/C actions, and reset stops + both concurrent A/C event streams. +- Rollback is the stacked native PR; no data migration or server rollback. diff --git a/docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md b/docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md new file mode 100644 index 00000000..96b02b6b --- /dev/null +++ b/docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md @@ -0,0 +1,36 @@ +# Plan: Issue #1136 immutable timeout authority + +## Context and scope + +- Governing issue: #1136, stacked after #1133 on the #1131 native ownership + line. +- Problem: a mutable session pointer cannot prove that an A timeout remains + owned by A after B terminals and C starts. +- In scope: private A-handle capability authority, reset/load invalidation, + one-shot transport dispatch, and deterministic native proof. +- Out of scope: harness endpoints, selected-run reducer behavior, tool grammar, + and production scheduling semantics. + +## TDD and implementation + +- Red: B -> C -> A timeout lost authority when only mutable session pointers + were consulted. +- Repair: each `RunSubmission` captures a private owner token and session + generation. `RunSession` atomically consumes a started-only capability once; + terminal, failure, reset, and load revoke it. Reset/load cancel every live + submission stream by immutable handle, including displaced A plus selected C. +- Deterministic proof: direct capability dispatch proves exactly one A cancel + after B -> C, zero B/C actions, no cancel after terminal/failure/reset, and + physical A+C stream detachment. #1133 continues to prove ToolWalk timeout + policy and passive terminal/failure observation. + +## Status and gates + +- [x] Write red and repair the authority model. +- [x] Add deterministic capability/revocation/detachment tests. +- [x] Re-run strict format (0/7 touched Swift files) and full Swift (245 tests + / 46 suites) after the final proof update. +- [x] Run `./scripts/test-regression.sh` after the #1135 baseline repair: + normal, race, and coverage passed (85.5% total; zero uncovered production + functions). +- [x] Publish the separate stacked draft PR with `Closes #1136`. diff --git a/docs/plans/INDEX.md b/docs/plans/INDEX.md index 23b26153..77816a83 100644 --- a/docs/plans/INDEX.md +++ b/docs/plans/INDEX.md @@ -30,6 +30,16 @@ deterministic callback retry-wait recovery fixture plan. - `2026-08-03-issue-1124-retry-wait-fixture-impact-map.md` — Cross-surface impact map for the Issue #1124 test-only repair. +- `2026-08-03-issue-1136-timeout-authority-plan.md` — Issue #1136 immutable + A-handle timeout cancellation authority across B/C selection. +- `2026-08-03-issue-1136-timeout-authority-impact-map.md` — Cross-surface map + for #1136 owner-token/generation capability and stream detachment. + +- `2026-08-03-issue-1133-passive-outcome-plan.md` — Issue #1133 passive + A-outcome observation after scheduled B displacement. +- `2026-08-03-issue-1133-passive-outcome-impact-map.md` — Cross-surface map + for #1133 submission-local outcome versus selected-run authority. + - `2026-08-03-issue-1130-submission-outcomes-plan.md` — Issue #1130 submission-local lifecycle and ToolWalk outcome repair. - `2026-08-03-issue-1130-submission-outcomes-impact-map.md` — Cross-surface diff --git a/docs/plans/active-plan.md b/docs/plans/active-plan.md index d633f197..45b80270 100644 --- a/docs/plans/active-plan.md +++ b/docs/plans/active-plan.md @@ -30,6 +30,18 @@ must reuse the reserved run identity exactly once. Callback manager, SQLite, API/task visibility, TUI, and native GUI behaviour remain unchanged. Final focused/package/full validation, review, hosted checks, and promotion remain. Current status: Issue #1130 is implemented locally on #1128 `654b7da`. It +Current status: Issue #1133 is implemented locally on #1131 `63cf9fcd`. It +corrects the residual #1130 wait-policy defect: B displacement revokes A +controls but not A outcome observation. ToolWalk now passively waits for A +terminal/failure through its deadline, while a displaced timeout can POST only +the exact locally owned A without mutating selected B. Eight URLSession-gated +`RunSession.submit()` + `Runner` tests cover B-before-A terminal, EOF, timeout, +B -> C -> A timeout authority, one-shot/terminal/failure/reset revocation, +and delayed acknowledgement with zero B/C actions. Strict formatting and full +Swift pass; full repository regression awaits the independent #1135 baseline +fixture repair, followed by review, hosted checks, and stacked PR promotion. + +Historical status: Issue #1130 was implemented locally on #1128 `654b7da`. It separates A's submission lifecycle from displacement, makes late acknowledgement/EOF/failure identity-safe, and gives ToolWalk typed terminal/failure/displaced/timeout outcomes so only timeout cancels A. Strict diff --git a/macapp/Sources/GoCodeUI/ProjectSession.swift b/macapp/Sources/GoCodeUI/ProjectSession.swift index 21070014..3986ed7f 100644 --- a/macapp/Sources/GoCodeUI/ProjectSession.swift +++ b/macapp/Sources/GoCodeUI/ProjectSession.swift @@ -136,6 +136,18 @@ public final class ProjectSession { self.serverEnvironment = serverEnvironment } + /// Deterministic native/ToolWalk integration seam. Production callers use + /// the URL/supervisor initializer above; tests inject the same client used + /// by their URLProtocol fixture so `Runner.walk` exercises ProjectSession. + init(workspace: URL, client: HarnessClient) { + self.workspace = workspace + externalBaseURL = nil + serverEnvironment = [:] + self.client = client + run = RunSession(client: client) + phase = .ready + } + public var name: String { workspace.lastPathComponent } public var isReady: Bool { phase == .ready } diff --git a/macapp/Sources/GoCodeUI/RunSession+RunControls.swift b/macapp/Sources/GoCodeUI/RunSession+RunControls.swift index 3de6636c..9b65624b 100644 --- a/macapp/Sources/GoCodeUI/RunSession+RunControls.swift +++ b/macapp/Sources/GoCodeUI/RunSession+RunControls.swift @@ -5,7 +5,9 @@ extension RunSession { /// True only while the first, cooperative cancel request awaits harnessd's /// acknowledgement. Once it succeeds, a second press remains available /// for the existing local force-stop behavior. - public var cancelInFlight: Bool { cancelState == .requesting } + public var cancelInFlight: Bool { + cancelState == .requesting + } /// Requests cancellation only if the run that rendered the affordance is /// still selected. A later scheduled/local continuation must never inherit @@ -16,12 +18,17 @@ extension RunSession { cancel(runID: runID) } - /// ToolWalk's timeout action has already decided which run timed out. Keep - /// that captured identity at the RunSession boundary rather than resolving - /// the currently selected continuation during cancellation. - public func cancelTimedOutRun(expectedRunID: String?) { - guard let expectedRunID else { return } - cancel(expectedRunID: expectedRunID) + /// Consumes the exact submitted A timeout capability. Unlike a bare run + /// string, this cannot be redirected to selected B, replayed after reset, + /// or re-used after terminal/failure. The transport-only path deliberately + /// makes no shared UI state change. + @discardableResult + public func cancelTimedOutSubmission(_ submission: RunSubmission) -> Bool { + guard let runID = consumeTimeoutCancellation(for: submission) else { return false } + Task { [client] in + try? await client.cancel(runID: runID) + } + return true } /// Compatibility entry point for programmatic callers that do not retain diff --git a/macapp/Sources/GoCodeUI/RunSession.swift b/macapp/Sources/GoCodeUI/RunSession.swift index 5fe2ef28..4387be9c 100644 --- a/macapp/Sources/GoCodeUI/RunSession.swift +++ b/macapp/Sources/GoCodeUI/RunSession.swift @@ -80,10 +80,20 @@ public final class RunSession { /// second Stop press. A scheduled run must never cancel an unrelated local /// stream merely because it is currently selected. var localStreamRunID: String? + /// One RunSubmission stream can survive visual displacement while a later + /// C stream starts. Reset/load must detach every such local stream, not + /// only the last compatibility `streamTask`. + private var submissionStreamTasks: [ObjectIdentifier: Task] = [:] /// The locally submitted run whose caller may need A-only lifecycle and /// transcript evidence. A selected external continuation displaces this /// handle; it must never cause its caller to act on the continuation. private var activeSubmission: RunSubmission? + /// Each submission captures this unforgeable owner token and the current + /// generation. It remains independent from selected-run UI state. + private let submissionOwnerToken = UUID() + /// Reset/load detach the old session permanently; their generation invalidates + /// every outstanding submission timeout capability. + private var submissionGeneration: UInt = 0 public init(client: HarnessClient) { self.client = client @@ -93,14 +103,23 @@ public final class RunSession { self.init(client: HarnessClient(baseURL: baseURL, token: token)) } - public var isBusy: Bool { transcript.runState.isActive } + public var isBusy: Bool { + transcript.runState.isActive + } + /// Keyboard submission must share the composer button's single-flight /// boundary. A control POST can outlive its run's terminal SSE; allowing /// a new run during that acknowledgement would let the old completion /// mutate the newer conversation. - public var canSubmit: Bool { !draft.trimmed.isEmpty && !isBusy && !runControlInFlight } + public var canSubmit: Bool { + !draft.trimmed.isEmpty && !isBusy && !runControlInFlight + } + /// True while a run is active, so the composer can offer steering instead. - public var canSteer: Bool { isBusy && transcript.pendingApproval == nil } + public var canSteer: Bool { + isBusy && transcript.pendingApproval == nil + } + /// Accessible copy shown while a cron/callback continuation, rather than a /// prompt submitted by this app instance, owns the active controls. public var scheduledRunStatus: String? { @@ -114,7 +133,10 @@ public final class RunSession { public func submit() -> RunSubmission? { let prompt = draft.trimmed guard !prompt.isEmpty, !isBusy, !runControlInFlight else { return nil } - let submission = RunSubmission(prompt: prompt) + let submission = RunSubmission( + prompt: prompt, timeoutOwner: submissionOwnerToken, + timeoutGeneration: submissionGeneration + ) activeSubmission = submission draft = "" connectionError = nil @@ -131,7 +153,8 @@ public final class RunSession { // a brand-new conversation) forever instead of the id this run just // minted -- exactly the bug that left the conversation stream never // started for the run that most needs it. - streamTask = Task { + let submissionID = ObjectIdentifier(submission) + let task = Task { [client, model, planMode, startingConversationID = conversationID, extraDirs, profile] in var startedRunID: String? @@ -188,7 +211,8 @@ public final class RunSession { recordSubmissionFailure( submission, runID: started.runID, - message: "run event stream ended before a terminal event") + message: "run event stream ended before a terminal event" + ) } } catch is CancellationError { // reset/load intentionally detaches the handle and cancels its @@ -199,12 +223,16 @@ public final class RunSession { } catch { if !Task.isCancelled { recordSubmissionFailure( - submission, runID: startedRunID, message: error.localizedDescription) + submission, runID: startedRunID, message: error.localizedDescription + ) } if let startedRunID { releaseUnstartedAccounting(for: startedRunID) } } finishRunIfCurrent(startedRunID: startedRunID, submission: submission) + submissionStreamTasks.removeValue(forKey: submissionID) } + streamTask = task + submissionStreamTasks[submissionID] = task return submission } @@ -235,7 +263,8 @@ public final class RunSession { let preserveAccounting = runID != nil && runID == accountingRunID transcript.reconcile( messages: messages, preservingUsage: preserveAccounting, - preservingRunState: preservingRunState) + preservingRunState: preservingRunState + ) if !preserveAccounting { accountingRunID = nil accountingTimestamp = nil @@ -303,8 +332,8 @@ public final class RunSession { while !Task.isCancelled { do { for try await event in client.conversationEvents( - conversationID: conversationID, lastEventID: lastEventID) - { + conversationID: conversationID, lastEventID: lastEventID + ) { lastEventID = event.id await applyConversationEvent(event, conversationID: conversationID) // A fresh app can open a durable message snapshot and then @@ -321,7 +350,8 @@ public final class RunSession { reconcilePersistedMessages( messages, retainingAccountingFor: accountingRunID, - preservingRunState: isStaleTerminal) + preservingRunState: isStaleTerminal + ) } } } catch is CancellationError { @@ -416,36 +446,38 @@ public final class RunSession { guard !terminalRunIDs.contains(event.runID) else { return false } let isLifecycleStart = event.type == .runQueued || event.type == .runStarted || event.type == .runResumed - let select: Bool - if currentRunID == nil { - // After a terminal leaves no live action target, a replay start - // still must not displace newer accounting. Timestamp-less legacy - // frames remain admissible because their order cannot be compared. - if let accountingTimestamp, let timestamp = event.timestamp { - select = timestamp >= accountingTimestamp - } else { - select = true - } - } else if isLifecycleStart, let currentRunID { - // A local `startRun` result is a provisional owner until its first - // timestamped event. Do not replace it with an older replay simply - // because the replay supplies a timestamp first. - if activeRunTimestamps[currentRunID] == nil, !externalRunIDs.contains(currentRunID) { - select = false - } else if let timestamp = event.timestamp, - let current = activeRunTimestamps[currentRunID] ?? nil - { - select = timestamp >= current + let select: Bool = + if currentRunID == nil { + // After a terminal leaves no live action target, a replay start + // still must not displace newer accounting. Timestamp-less legacy + // frames remain admissible because their order cannot be compared. + if let accountingTimestamp, let timestamp = event.timestamp { + timestamp >= accountingTimestamp + } else { + true + } + } else if isLifecycleStart, let currentRunID { + // A local `startRun` result is a provisional owner until its first + // timestamped event. Do not replace it with an older replay simply + // because the replay supplies a timestamp first. + if activeRunTimestamps[currentRunID] == nil, !externalRunIDs.contains(currentRunID) + { + false + } else if let timestamp = event.timestamp, + let current = activeRunTimestamps[currentRunID] ?? nil + { + timestamp >= current + } else { + false + } } else { - select = false + false } - } else { - select = false - } if !activeRunIDs.contains(event.runID) { activate( runID: event.runID, isExternal: event.runID != localStreamRunID, - timestamp: event.timestamp, select: select) + timestamp: event.timestamp, select: select + ) } else if event.runID == currentRunID, let timestamp = event.timestamp { // A locally admitted run is provisional only until its own first // timestamped lifecycle evidence arrives. Preserve that timestamp @@ -465,9 +497,9 @@ public final class RunSession { .toolApprovalGranted, .toolApprovalDenied, .planApprovalGranted, .planApprovalDenied, .runWaitingForUser: - return true + true default: - return false + false } } @@ -517,6 +549,7 @@ public final class RunSession { } private func finishRunIfCurrent(startedRunID: String?, submission: RunSubmission) { + submissionStreamTasks.removeValue(forKey: ObjectIdentifier(submission)) guard let startedRunID else { return } if localStreamRunID == startedRunID { localStreamRunID = nil } // A late completion must never clear a newer submission solely because @@ -534,6 +567,11 @@ public final class RunSession { } private func clearActiveRuns() { + submissionGeneration &+= 1 + for task in submissionStreamTasks.values { + task.cancel() + } + submissionStreamTasks = [:] activeSubmission?.markDisplaced() activeSubmission = nil activeRunIDs = [] @@ -555,6 +593,12 @@ public final class RunSession { return currentRunID == nil || currentRunID == runID } + func consumeTimeoutCancellation(for submission: RunSubmission) -> String? { + submission.consumeTimeoutCancellation( + owner: submissionOwnerToken, generation: submissionGeneration + ) + } + private func recordSubmissionFailure( _ submission: RunSubmission, runID: String?, message: String ) { @@ -622,11 +666,11 @@ public final class RunSession { private func admitAccounting(for event: HarnessEvent) -> Bool { let runID = event.runID guard !runID.isEmpty else { return false } - let isStart: Bool - switch event.type { - case .runQueued, .runStarted, .runResumed: isStart = true - default: isStart = false - } + let isStart = + switch event.type { + case .runQueued, .runStarted, .runResumed: true + default: false + } if accountingRunID == runID { if let timestamp = event.timestamp { accountingTimestamp = timestamp } return true @@ -638,21 +682,21 @@ public final class RunSession { activateAccounting(for: runID, timestamp: event.timestamp) return true } - let isNewer: Bool - if let timestamp = event.timestamp { - // `submit()` owns the run before its first SSE frame supplies a - // timestamp. That provisional ownership is still authoritative: - // a replay from another run must not steal it merely because it - // has a timestamp while the current owner does not yet have one. - isNewer = accountingTimestamp.map { timestamp >= $0 } ?? false - } else { - isNewer = false - } - if isStart && (accountingRunID == nil || isNewer) { + let isNewer: Bool = + if let timestamp = event.timestamp { + // `submit()` owns the run before its first SSE frame supplies a + // timestamp. That provisional ownership is still authoritative: + // a replay from another run must not steal it merely because it + // has a timestamp while the current owner does not yet have one. + accountingTimestamp.map { timestamp >= $0 } ?? false + } else { + false + } + if isStart, accountingRunID == nil || isNewer { activateAccounting(for: runID, timestamp: event.timestamp) return true } - if event.type.isTerminal && (accountingRunID == nil || isNewer) { + if event.type.isTerminal, accountingRunID == nil || isNewer { activateAccounting(for: runID, timestamp: event.timestamp) return true } @@ -717,7 +761,9 @@ public final class RunSession { } extension String { - var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } } extension RunState { diff --git a/macapp/Sources/GoCodeUI/RunSubmission.swift b/macapp/Sources/GoCodeUI/RunSubmission.swift index 543f832d..ba3ce66d 100644 --- a/macapp/Sources/GoCodeUI/RunSubmission.swift +++ b/macapp/Sources/GoCodeUI/RunSubmission.swift @@ -41,14 +41,23 @@ public final class RunSubmission { case .failed(let message): .failed(message) } } + public private(set) var transcript = Transcript() /// Assigned only from a successful `startRun` response. It remains /// available after a later stream failure or displacement so cleanup and /// diagnostics can still name A without consulting shared session state. private var resolvedRunID: String? - private(set) public var isDisplaced = false + public private(set) var isDisplaced = false + /// An exact timeout capability is bound to the owning RunSession instance + /// and its reset/load generation. It is consumed at most once, rather than + /// being reconstructed from mutable selected-run state later. + private let timeoutOwner: UUID + private let timeoutGeneration: UInt + private var timeoutCancellationConsumed = false - public var runID: String? { resolvedRunID } + public var runID: String? { + resolvedRunID + } public var failure: String? { guard case .failed(let message) = lifecycle else { return nil } @@ -60,10 +69,30 @@ public final class RunSubmission { return false } - init(prompt: String) { + init(prompt: String, timeoutOwner: UUID, timeoutGeneration: UInt) { + self.timeoutOwner = timeoutOwner + self.timeoutGeneration = timeoutGeneration transcript.appendUserPrompt(prompt) } + /// Reducer-only construction has no session cancellation authority. + convenience init(prompt: String) { + self.init(prompt: prompt, timeoutOwner: UUID(), timeoutGeneration: 0) + } + + /// Returns A's immutable timeout cancellation capability exactly once. + /// Terminal/failure are definitive A outcomes and revoke it even if their + /// per-run task has not yet unwound. + func consumeTimeoutCancellation(owner: UUID, generation: UInt) -> String? { + guard timeoutOwner == owner, + timeoutGeneration == generation, + !timeoutCancellationConsumed, + case .started(let runID) = lifecycle + else { return nil } + timeoutCancellationConsumed = true + return runID + } + func markStarted(runID: String) { guard case .starting = lifecycle else { return } resolvedRunID = runID diff --git a/macapp/Sources/ToolWalk/Runner.swift b/macapp/Sources/ToolWalk/Runner.swift index 002c568d..e2f6b6fc 100644 --- a/macapp/Sources/ToolWalk/Runner.swift +++ b/macapp/Sources/ToolWalk/Runner.swift @@ -84,7 +84,7 @@ enum Runner { break case .timedOut: if shouldCancel(for: finished) { - run.cancelTimedOutRun(expectedRunID: submission.runID) + run.cancelTimedOutSubmission(submission) } // Give the cooperative cancel a moment to land before moving // on, or the next tool's newConversation() races its teardown. diff --git a/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift b/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift index d96c3413..699bddb2 100644 --- a/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift +++ b/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift @@ -113,13 +113,20 @@ private final class PassiveOutcomeProtocol: URLProtocol, @unchecked Sendable { @MainActor struct PassiveSubmissionOutcomeIntegrationTests { private func session() -> RunSession { + RunSession(client: client()) + } + + private func client() -> HarnessClient { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [PassiveOutcomeProtocol.self] - return RunSession( - client: HarnessClient( - baseURL: URL(string: "http://127.0.0.1:8897")!, - session: URLSession(configuration: configuration) - )) + return HarnessClient( + baseURL: URL(string: "http://127.0.0.1:8897")!, + session: URLSession(configuration: configuration) + ) + } + + private func project() -> ProjectSession { + ProjectSession(workspace: URL(fileURLWithPath: "/tmp"), client: client()) } private func event(_ id: String, _ runID: String, _ type: String, timestamp: String) throws @@ -140,6 +147,13 @@ struct PassiveSubmissionOutcomeIntegrationTests { .utf8) } + private nonisolated static func startedStream(for runID: String, timestamp: String) -> Data { + let id = "\(runID):started" + return Data( + "id: \(id)\nevent: run.started\ndata: {\"id\":\"\(id)\",\"run_id\":\"\(runID)\",\"type\":\"run.started\",\"timestamp\":\"\(timestamp)\",\"payload\":{}}\n\n" + .utf8) + } + private func wait(timeout: Duration = .seconds(2), _ condition: () -> Bool) async throws { let deadline = ContinuousClock.now.advanced(by: timeout) while ContinuousClock.now < deadline { @@ -205,6 +219,86 @@ struct PassiveSubmissionOutcomeIntegrationTests { run.reset() } + @Test("Runner.walk judges displaced A terminal without B control") + func walkObservesTerminalAAfterBSelection() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_a/events"): + .init( + headers: ["Content-Type": "text/event-stream"], + body: Self.terminalStream(for: "run_a"), waitForGate: "walk-terminal" + ) + case ("GET", "/v1/conversations/run_a/events"): + .init( + headers: ["Content-Type": "text/event-stream"], + body: Self.startedStream(for: "run_a", timestamp: "2026-08-03T22:00:00Z") + + Self.startedStream(for: "run_b", timestamp: "2026-08-03T22:00:01Z"), + neverFinishes: true + ) + case ("GET", "/v1/conversations"): + .init(body: Data(#"{"conversations":[]}"#.utf8)) + default: .init() + } + } + let project = project() + let task = Task { + await Runner.walk( + project: project, specs: [.init(name: "x", prompt: "A")], + config: .init(timeoutPerTool: .seconds(1), pollInterval: .milliseconds(5)) + ) + } + try await wait { project.run?.currentRunID == "run_b" } + PassiveOutcomeProtocol.openGate("walk-terminal") + let results = await task.value + #expect(results.count == 1) + #expect(results[0].verdict == "fail") + #expect(results[0].reply == "the tool 'x' was never invoked") + assertNoAction(for: ["run_b"]) + } + + @Test("Runner.walk timeout cancels only A after B then C selection") + func walkTimeoutCancelsOnlyAAfterBThenC() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_a/events"): + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + case ("GET", "/v1/conversations/run_a/events"): + .init( + headers: ["Content-Type": "text/event-stream"], + body: Self.startedStream(for: "run_a", timestamp: "2026-08-03T22:00:00Z") + + Self.startedStream(for: "run_b", timestamp: "2026-08-03T22:00:01Z") + + Self.startedStream(for: "run_c", timestamp: "2026-08-03T22:00:02Z"), + neverFinishes: true + ) + case ("POST", "/v1/runs/run_a/cancel"): + .init(status: 204) + case ("GET", "/v1/conversations"): + .init(body: Data(#"{"conversations":[]}"#.utf8)) + default: .init() + } + } + let project = project() + let task = Task { + await Runner.walk( + project: project, specs: [.init(name: "x", prompt: "A")], + config: .init(timeoutPerTool: .milliseconds(80), pollInterval: .milliseconds(5)) + ) + } + try await wait { project.run?.currentRunID == "run_c" } + let results = await task.value + try await wait { PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel") } + #expect(results[0].reply.contains("timed out")) + #expect(project.run?.currentRunID == "run_c") + #expect(PassiveOutcomeProtocol.paths().filter { $0 == "/v1/runs/run_a/cancel" }.count == 1) + assertNoAction(for: ["run_b", "run_c"]) + } + @Test("B before A EOF is judged as A failure without failing B") func eofAfterDisplacementRemainsObservable() async throws { PassiveOutcomeProtocol.reset() @@ -237,8 +331,8 @@ struct PassiveSubmissionOutcomeIntegrationTests { run.reset() } - @Test("B before A timeout is classified without changing B") - func timeoutAfterDisplacementIsClassifiedWithoutBAction() async throws { + @Test("B before A timeout cancels exactly A without changing B") + func timeoutAfterDisplacementCancelsOnlyA() async throws { PassiveOutcomeProtocol.reset() PassiveOutcomeProtocol.set { request in switch (request.httpMethod, request.url?.path) { @@ -246,6 +340,8 @@ struct PassiveSubmissionOutcomeIntegrationTests { .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) case ("GET", "/v1/runs/run_a/events"), ("GET", "/v1/conversations/conversation/events"): .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + case ("POST", "/v1/runs/run_a/cancel"): + .init(status: 204) default: .init() } } @@ -259,11 +355,173 @@ struct PassiveSubmissionOutcomeIntegrationTests { config: .init(timeoutPerTool: .milliseconds(80), pollInterval: .milliseconds(5)) ) #expect(outcome == .timedOut) + #expect(run.cancelTimedOutSubmission(submission)) + try await wait { PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel") } #expect(run.currentRunID == "run_b") assertNoAction(for: ["run_b"]) run.reset() } + @Test("a later local C cannot revoke timed-out displaced A cancellation") + func timeoutRetainsAOwnershipAfterBThenC() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + let runID = PassiveOutcomeProtocol.nextStartRunID() + return .init( + status: 202, + body: Data(#"{"run_id":"\#(runID)","status":"queued"}"#.utf8) + ) + case ("GET", "/v1/runs/run_a/events"), + ("GET", "/v1/runs/run_c/events"), + ("GET", "/v1/conversations/conversation/events"): + return .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + case ("POST", "/v1/runs/run_a/cancel"): + return .init(status: 204) + default: + return .init() + } + } + let run = session() + run.load(messages: [], conversationID: "conversation") + run.draft = "A" + let submission = try #require(run.submit()) + try await displaceA(run, submission: submission) + try await run.applyConversationEvent( + event("run_b:completed", "run_b", "run.completed", timestamp: "2026-08-03T22:00:02Z"), + conversationID: "conversation" + ) + #expect(run.currentRunID == nil) + + // B completion makes the shared view available, but A's per-run + // stream is deliberately still active. C must not replace A's timeout + // authority just because session-level mutable pointers now name C. + run.draft = "C" + let c = try #require(run.submit()) + try await wait { c.runID == "run_c" && run.currentRunID == "run_c" } + // Timeout policy is already exercised above. This direct dispatch + // proves the more important authority condition deterministically: + // once B is terminal and C owns visible state, A's captured handle + // still authorizes exactly one A-only cancellation. + #expect(run.cancelTimedOutSubmission(submission)) + #expect(!run.cancelTimedOutSubmission(submission)) + try await wait { PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel") } + #expect(PassiveOutcomeProtocol.paths().filter { $0 == "/v1/runs/run_a/cancel" }.count == 1) + #expect(run.currentRunID == "run_c") + assertNoAction(for: ["run_b", "run_c"]) + run.reset() + try await wait { + PassiveOutcomeProtocol.stopped("/v1/runs/run_a/events") + && PassiveOutcomeProtocol.stopped("/v1/runs/run_c/events") + } + } + + @Test("exact timeout capability sends A cancel once and never reuses it") + func timeoutCapabilityIsSingleUse() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_a/events"): + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + case ("POST", "/v1/runs/run_a/cancel"): + .init(status: 204) + default: + .init() + } + } + let run = session() + run.draft = "A" + let submission = try #require(run.submit()) + try await wait { submission.runID == "run_a" } + #expect(run.cancelTimedOutSubmission(submission)) + #expect(!run.cancelTimedOutSubmission(submission)) + try await wait { + PassiveOutcomeProtocol.paths().filter { $0 == "/v1/runs/run_a/cancel" }.count == 1 + } + run.reset() + } + + @Test("terminal or reset submission cannot retain timeout cancellation") + func terminalAndResetRevokeTimeoutCapability() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_a/events"): + .init( + headers: ["Content-Type": "text/event-stream"], + body: Self.terminalStream(for: "run_a"), + waitForGate: "a-terminal" + ) + case ("POST", "/v1/runs/run_a/cancel"): + .init(status: 204) + default: + .init() + } + } + let run = session() + run.draft = "A" + let terminal = try #require(run.submit()) + try await wait { terminal.runID == "run_a" } + PassiveOutcomeProtocol.openGate("a-terminal") + try await wait { terminal.isTerminal } + #expect(!run.cancelTimedOutSubmission(terminal)) + #expect(!PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel")) + + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_b","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_b/events"): + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + case ("POST", "/v1/runs/run_b/cancel"): + .init(status: 204) + default: + .init() + } + } + run.draft = "B" + let reset = try #require(run.submit()) + try await wait { reset.runID == "run_b" } + run.reset() + try await wait { PassiveOutcomeProtocol.stopped("/v1/runs/run_b/events") } + #expect(!run.cancelTimedOutSubmission(reset)) + #expect(!PassiveOutcomeProtocol.paths().contains("/v1/runs/run_b/cancel")) + } + + @Test("failed A submission revokes its timeout capability before dispatch") + func failedSubmissionRevokesTimeoutCapability() async throws { + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_a/events"): + // An empty completed stream is the real RunSession failure + // path, not a manually forged lifecycle transition. + .init(headers: ["Content-Type": "text/event-stream"]) + case ("POST", "/v1/runs/run_a/cancel"): + .init(status: 204) + default: + .init() + } + } + let run = session() + run.draft = "A" + let submission = try #require(run.submit()) + try await wait { + if case .failed = submission.lifecycle { return true } + return false + } + #expect(!run.cancelTimedOutSubmission(submission)) + #expect(!PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel")) + } + @Test("B before delayed A acknowledgement still waits for A identity") func delayedAcknowledgementPreservesDisplacementAndObservation() async throws { PassiveOutcomeProtocol.reset() diff --git a/macapp/Tests/GoCodeUITests/RunSessionExternalControlTests.swift b/macapp/Tests/GoCodeUITests/RunSessionExternalControlTests.swift index cc6dc9ee..be4548cf 100644 --- a/macapp/Tests/GoCodeUITests/RunSessionExternalControlTests.swift +++ b/macapp/Tests/GoCodeUITests/RunSessionExternalControlTests.swift @@ -5,31 +5,43 @@ import Testing @testable import GoCodeUI private final class ExternalRunControlStub: URLProtocol, @unchecked Sendable { - nonisolated(unsafe) private static var recorded: [URLRequest] = [] + private nonisolated(unsafe) static var recorded: [URLRequest] = [] private static let lock = NSLock() - static func reset() { lock.withLock { recorded = [] } } - static var requests: [URLRequest] { lock.withLock { recorded } } + static func reset() { + lock.withLock { recorded = [] } + } - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + static var requests: [URLRequest] { + lock.withLock { recorded } + } + + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } override func startLoading() { - let request = self.request + let request = request Self.lock.withLock { Self.recorded.append(request) } let path = request.url?.path ?? "" let body: Data - if path.hasSuffix("/input") && request.httpMethod == "GET" { + if path.hasSuffix("/input"), request.httpMethod == "GET" { let runID = path.split(separator: "/").dropLast().last.map(String.init) ?? "unknown" body = Data( #"{"run_id":"\#(runID)","call_id":"call_\#(runID)","questions":[{"question":"Continue?","options":[]}]}"# - .utf8) + .utf8 + ) } else { body = Data("{}".utf8) } let response = HTTPURLResponse( url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/json"])! + headerFields: ["Content-Type": "application/json"] + )! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: body) client?.urlProtocolDidFinishLoading(self) @@ -47,7 +59,9 @@ struct RunSessionExternalControlTests { return RunSession( client: HarnessClient( baseURL: URL(string: "http://127.0.0.1:8898")!, - session: URLSession(configuration: configuration))) + session: URLSession(configuration: configuration) + ) + ) } private func event( @@ -60,7 +74,8 @@ struct RunSessionExternalControlTests { id: id, event: type, data: #"{"id":"\#(id)","run_id":"\#(runID)","type":"\#(type)"\#(timestampField),"payload":\#(payload)}"# - )) + ) + ) } private func wait( @@ -84,13 +99,17 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_a") try await session.applyConversationEvent( - event("run_a:0", "run_a", "run.started"), conversationID: "conversation_a") + event("run_a:0", "run_a", "run.started"), conversationID: "conversation_a" + ) try await session.applyConversationEvent( - event("run_b:0", "run_b", "run.started"), conversationID: "conversation_a") + event("run_b:0", "run_b", "run.started"), conversationID: "conversation_a" + ) try await session.applyConversationEvent( - event("run_a:1", "run_a", "assistant.message"), conversationID: "conversation_a") + event("run_a:1", "run_a", "assistant.message"), conversationID: "conversation_a" + ) try await session.applyConversationEvent( - event("run_a:2", "run_a", "run.completed"), conversationID: "conversation_a") + event("run_a:2", "run_a", "run.completed"), conversationID: "conversation_a" + ) #expect(session.currentRunID == "run_b") #expect(session.isBusy, "run_a terminal must not make live run_b inactive") @@ -99,10 +118,13 @@ struct RunSessionExternalControlTests { try await session.applyConversationEvent( event( "run_b:1", "run_b", "tool.approval_required", - payload: #"{"call_id":"call_b_1","tool":"bash","arguments":"{}"}"#), - conversationID: "conversation_a") + payload: #"{"call_id":"call_b_1","tool":"bash","arguments":"{}"}"# + ), + conversationID: "conversation_a" + ) try await session.applyConversationEvent( - event("run_b:2", "run_b", "run.waiting_for_user"), conversationID: "conversation_a") + event("run_b:2", "run_b", "run.waiting_for_user"), conversationID: "conversation_a" + ) #expect(session.currentRunID == "run_b") try await wait { session.pendingQuestions?.runID == "run_b" } @@ -115,24 +137,30 @@ struct RunSessionExternalControlTests { } #expect(session.runControlInFlight) #expect( - !ExternalRunControlStub.requests.contains { $0.url?.path.hasSuffix("/deny") == true }) + !ExternalRunControlStub.requests.contains { $0.url?.path.hasSuffix("/deny") == true } + ) #expect( - !ExternalRunControlStub.requests.contains { $0.url?.path.hasSuffix("/steer") == true }) + !ExternalRunControlStub.requests.contains { $0.url?.path.hasSuffix("/steer") == true } + ) try await session.applyConversationEvent( - event("run_b:3", "run_b", "tool.approval_granted"), conversationID: "conversation_a") + event("run_b:3", "run_b", "tool.approval_granted"), conversationID: "conversation_a" + ) try await wait { !session.runControlInFlight } try await session.applyConversationEvent( event( "run_b:4", "run_b", "tool.approval_required", - payload: #"{"call_id":"call_b_2","tool":"bash","arguments":"{}"}"#), - conversationID: "conversation_a") + payload: #"{"call_id":"call_b_2","tool":"bash","arguments":"{}"}"# + ), + conversationID: "conversation_a" + ) session.deny() try await wait { ExternalRunControlStub.requests.contains { $0.url?.path == "/v1/runs/run_b/deny" } } try await session.applyConversationEvent( - event("run_b:5", "run_b", "tool.approval_denied"), conversationID: "conversation_a") + event("run_b:5", "run_b", "tool.approval_denied"), conversationID: "conversation_a" + ) try await wait { !session.runControlInFlight } session.answer(["0:Continue?": "yes"]) @@ -165,7 +193,8 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_b") try await session.applyConversationEvent( - event("run_old:0", "run_old", "run.started"), conversationID: "conversation_a") + event("run_old:0", "run_old", "run.started"), conversationID: "conversation_a" + ) #expect(session.currentRunID == nil) #expect(session.transcript.runState == .completed) @@ -185,10 +214,12 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_terminal") try await session.applyConversationEvent( event("run_terminal:0", "run_terminal", "run.started"), - conversationID: "conversation_terminal") + conversationID: "conversation_terminal" + ) try await session.applyConversationEvent( event("run_terminal:1", "run_terminal", type), - conversationID: "conversation_terminal") + conversationID: "conversation_terminal" + ) #expect(session.currentRunID == nil) #expect(session.transcript.runState == expectedState) session.reset() @@ -200,12 +231,14 @@ struct RunSessionExternalControlTests { let session = makeSession() session.load(messages: [], conversationID: "conversation_tombstone") try await session.applyConversationEvent( - event("run_old:0", "run_old", "run.started"), conversationID: "conversation_tombstone") + event("run_old:0", "run_old", "run.started"), conversationID: "conversation_tombstone" + ) try await session.applyConversationEvent( event("run_old:1", "run_old", "run.completed"), conversationID: "conversation_tombstone" ) try await session.applyConversationEvent( - event("run_old:2", "run_old", "run.started"), conversationID: "conversation_tombstone") + event("run_old:2", "run_old", "run.started"), conversationID: "conversation_tombstone" + ) #expect(session.currentRunID == nil) #expect(!session.isBusy) session.reset() @@ -216,10 +249,12 @@ struct RunSessionExternalControlTests { let session = makeSession() session.load(messages: [], conversationID: "conversation_resume") try await session.applyConversationEvent( - event("run_old:0", "run_old", "run.completed"), conversationID: "conversation_resume") + event("run_old:0", "run_old", "run.completed"), conversationID: "conversation_resume" + ) try await session.applyConversationEvent( event("run_scheduled:0", "run_scheduled", "assistant.message"), - conversationID: "conversation_resume") + conversationID: "conversation_resume" + ) #expect(session.currentRunID == "run_scheduled") #expect(session.isBusy) #expect(session.scheduledRunStatus == "Scheduled run active") @@ -232,10 +267,12 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_ordering") try await session.applyConversationEvent( event("run_new:0", "run_new", "run.started", timestamp: "2026-08-03T18:00:02Z"), - conversationID: "conversation_ordering") + conversationID: "conversation_ordering" + ) try await session.applyConversationEvent( event("run_old:0", "run_old", "run.started", timestamp: "2026-08-03T18:00:01Z"), - conversationID: "conversation_ordering") + conversationID: "conversation_ordering" + ) #expect(session.currentRunID == "run_new") #expect(session.accountingRunID == "run_new") session.reset() @@ -251,25 +288,30 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_approval_owner") try await session.applyConversationEvent( event("run_a:0", "run_a", "run.started", timestamp: "2026-08-03T18:00:01Z"), - conversationID: "conversation_approval_owner") + conversationID: "conversation_approval_owner" + ) try await session.applyConversationEvent( event( "run_a:1", "run_a", "tool.approval_required", - payload: #"{"call_id":"call_a","tool":"bash","arguments":"{}"}"#), - conversationID: "conversation_approval_owner") + payload: #"{"call_id":"call_a","tool":"bash","arguments":"{}"}"# + ), + conversationID: "conversation_approval_owner" + ) let approval = try #require(session.transcript.pendingApproval) #expect(approval.runID == "run_a") try await session.applyConversationEvent( event("run_b:0", "run_b", "run.started", timestamp: "2026-08-03T18:00:02Z"), - conversationID: "conversation_approval_owner") + conversationID: "conversation_approval_owner" + ) #expect(session.currentRunID == "run_b") #expect(session.transcript.pendingApproval == nil) session.approve(expectedRunID: approval.runID) try await Task.sleep(for: .milliseconds(50)) #expect( - !ExternalRunControlStub.requests.contains { $0.url?.path == "/v1/runs/run_b/approve" }) + !ExternalRunControlStub.requests.contains { $0.url?.path == "/v1/runs/run_b/approve" } + ) session.reset() } @@ -280,15 +322,19 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_plan_input_owner") try await session.applyConversationEvent( event("run_a:0", "run_a", "run.started", timestamp: "2026-08-03T18:00:01Z"), - conversationID: "conversation_plan_input_owner") + conversationID: "conversation_plan_input_owner" + ) try await session.applyConversationEvent( event( "run_a:1", "run_a", "plan.approval_required", - payload: #"{"plan":"A plan","options":[]}"#), - conversationID: "conversation_plan_input_owner") + payload: #"{"plan":"A plan","options":[]}"# + ), + conversationID: "conversation_plan_input_owner" + ) try await session.applyConversationEvent( event("run_a:2", "run_a", "run.waiting_for_user"), - conversationID: "conversation_plan_input_owner") + conversationID: "conversation_plan_input_owner" + ) try await wait { session.pendingQuestions?.runID == "run_a" } let plan = try #require(session.transcript.pendingPlan) let prompt = try #require(session.pendingQuestions) @@ -296,7 +342,8 @@ struct RunSessionExternalControlTests { try await session.applyConversationEvent( event("run_b:0", "run_b", "run.started", timestamp: "2026-08-03T18:00:02Z"), - conversationID: "conversation_plan_input_owner") + conversationID: "conversation_plan_input_owner" + ) #expect(session.currentRunID == "run_b") #expect(session.transcript.pendingPlan == nil) @@ -315,20 +362,25 @@ struct RunSessionExternalControlTests { let session = makeSession() session.load(messages: [], conversationID: "conversation_terminal_owner") try await session.applyConversationEvent( - event("run_a:0", "run_a", "run.started"), conversationID: "conversation_terminal_owner") + event("run_a:0", "run_a", "run.started"), conversationID: "conversation_terminal_owner" + ) try await session.applyConversationEvent( event( "run_a:1", "run_a", "tool.approval_required", - payload: #"{"call_id":"call_a","tool":"bash","arguments":"{}"}"#), - conversationID: "conversation_terminal_owner") + payload: #"{"call_id":"call_a","tool":"bash","arguments":"{}"}"# + ), + conversationID: "conversation_terminal_owner" + ) try await session.applyConversationEvent( event("run_a:2", "run_a", "run.waiting_for_user"), - conversationID: "conversation_terminal_owner") + conversationID: "conversation_terminal_owner" + ) try await wait { session.pendingQuestions?.runID == "run_a" } try await session.applyConversationEvent( event("run_a:3", "run_a", "run.completed"), - conversationID: "conversation_terminal_owner") + conversationID: "conversation_terminal_owner" + ) #expect(session.currentRunID == nil) #expect(session.transcript.pendingApproval == nil) @@ -343,22 +395,27 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_terminal_fallback_owner") try await session.applyConversationEvent( event("run_a:0", "run_a", "run.started"), - conversationID: "conversation_terminal_fallback_owner") + conversationID: "conversation_terminal_fallback_owner" + ) try await session.applyConversationEvent( event( "run_a:1", "run_a", "plan.approval_required", - payload: #"{"plan":"A plan","options":[]}"#), - conversationID: "conversation_terminal_fallback_owner") + payload: #"{"plan":"A plan","options":[]}"# + ), + conversationID: "conversation_terminal_fallback_owner" + ) // B is active but cannot take selection until A ends because neither // lifecycle frame supplies a comparable timestamp. try await session.applyConversationEvent( event("run_b:0", "run_b", "run.started"), - conversationID: "conversation_terminal_fallback_owner") + conversationID: "conversation_terminal_fallback_owner" + ) #expect(session.currentRunID == "run_a") try await session.applyConversationEvent( event("run_a:2", "run_a", "run.completed"), - conversationID: "conversation_terminal_fallback_owner") + conversationID: "conversation_terminal_fallback_owner" + ) #expect(session.currentRunID == "run_b") #expect(session.transcript.pendingApproval == nil) @@ -373,18 +430,23 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_foreign_terminal") try await session.applyConversationEvent( event("run_a:0", "run_a", "run.started", timestamp: "2026-08-03T18:00:01Z"), - conversationID: "conversation_foreign_terminal") + conversationID: "conversation_foreign_terminal" + ) try await session.applyConversationEvent( event("run_b:0", "run_b", "run.started", timestamp: "2026-08-03T18:00:02Z"), - conversationID: "conversation_foreign_terminal") + conversationID: "conversation_foreign_terminal" + ) try await session.applyConversationEvent( event( "run_b:1", "run_b", "tool.approval_required", - payload: #"{"call_id":"call_b","tool":"bash","arguments":"{}"}"#), - conversationID: "conversation_foreign_terminal") + payload: #"{"call_id":"call_b","tool":"bash","arguments":"{}"}"# + ), + conversationID: "conversation_foreign_terminal" + ) try await session.applyConversationEvent( event("run_a:1", "run_a", "run.completed"), - conversationID: "conversation_foreign_terminal") + conversationID: "conversation_foreign_terminal" + ) #expect(session.currentRunID == "run_b") #expect(session.transcript.pendingApproval?.runID == "run_b") @@ -398,7 +460,8 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_stale_stop") try await session.applyConversationEvent( event("run_a:0", "run_a", "run.started", timestamp: "2026-08-03T18:00:01Z"), - conversationID: "conversation_stale_stop") + conversationID: "conversation_stale_stop" + ) let renderedRunID = try #require(session.currentRunID) // First press is a real A cancel and receives its acknowledgement. // The retained second-press closure is the force-stop hazard: it must @@ -409,13 +472,15 @@ struct RunSessionExternalControlTests { } try await session.applyConversationEvent( event("run_b:0", "run_b", "run.started", timestamp: "2026-08-03T18:00:02Z"), - conversationID: "conversation_stale_stop") + conversationID: "conversation_stale_stop" + ) #expect(session.currentRunID == "run_b") session.cancel(expectedRunID: renderedRunID) try await Task.sleep(for: .milliseconds(50)) #expect( - !ExternalRunControlStub.requests.contains { $0.url?.path == "/v1/runs/run_b/cancel" }) + !ExternalRunControlStub.requests.contains { $0.url?.path == "/v1/runs/run_b/cancel" } + ) #expect(session.currentRunID == "run_b") #expect(session.transcript.runState != .cancelled) @@ -427,27 +492,6 @@ struct RunSessionExternalControlTests { session.reset() } - @Test("ToolWalk timeout captured for A cannot cancel newer B") - func toolWalkTimeoutDoesNotTargetNewerRun() async throws { - ExternalRunControlStub.reset() - let session = makeSession() - session.load(messages: [], conversationID: "conversation_toolwalk_timeout") - try await session.applyConversationEvent( - event("run_a:0", "run_a", "run.started", timestamp: "2026-08-03T18:00:01Z"), - conversationID: "conversation_toolwalk_timeout") - let timedOutRunID = try #require(session.currentRunID) - try await session.applyConversationEvent( - event("run_b:0", "run_b", "run.started", timestamp: "2026-08-03T18:00:02Z"), - conversationID: "conversation_toolwalk_timeout") - - session.cancelTimedOutRun(expectedRunID: timedOutRunID) - try await Task.sleep(for: .milliseconds(50)) - #expect( - !ExternalRunControlStub.requests.contains { $0.url?.path == "/v1/runs/run_b/cancel" }) - #expect(session.currentRunID == "run_b") - session.reset() - } - @Test("a stale Composer steer captured for A leaves newer B and its draft untouched") func staleSteerDoesNotTargetNewerRun() async throws { ExternalRunControlStub.reset() @@ -455,18 +499,21 @@ struct RunSessionExternalControlTests { session.load(messages: [], conversationID: "conversation_stale_steer") try await session.applyConversationEvent( event("run_a:0", "run_a", "run.started", timestamp: "2026-08-03T18:00:01Z"), - conversationID: "conversation_stale_steer") + conversationID: "conversation_stale_steer" + ) let renderedRunID = try #require(session.currentRunID) session.draft = "keep watching" try await session.applyConversationEvent( event("run_b:0", "run_b", "run.started", timestamp: "2026-08-03T18:00:02Z"), - conversationID: "conversation_stale_steer") + conversationID: "conversation_stale_steer" + ) #expect(session.currentRunID == "run_b") session.steer(expectedRunID: renderedRunID) try await Task.sleep(for: .milliseconds(50)) #expect( - !ExternalRunControlStub.requests.contains { $0.url?.path == "/v1/runs/run_b/steer" }) + !ExternalRunControlStub.requests.contains { $0.url?.path == "/v1/runs/run_b/steer" } + ) #expect(session.draft == "keep watching") #expect(session.currentRunID == "run_b") diff --git a/macapp/Tests/GoCodeUITests/RunSubmissionTests.swift b/macapp/Tests/GoCodeUITests/RunSubmissionTests.swift index ee4f5ca7..8225b09c 100644 --- a/macapp/Tests/GoCodeUITests/RunSubmissionTests.swift +++ b/macapp/Tests/GoCodeUITests/RunSubmissionTests.swift @@ -5,7 +5,7 @@ import Testing @testable import GoCodeUI private final class SubmissionHandleStub: URLProtocol, @unchecked Sendable { - struct Response: Sendable { + struct Response { var status = 200 var headers = ["Content-Type": "application/json"] var body = Data() @@ -17,12 +17,12 @@ private final class SubmissionHandleStub: URLProtocol, @unchecked Sendable { var waitForGate: String? } - nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) -> Response)? - nonisolated(unsafe) private static var requests: [URLRequest] = [] - nonisolated(unsafe) private static var completedPaths: Set = [] + private nonisolated(unsafe) static var handler: (@Sendable (URLRequest) -> Response)? + private nonisolated(unsafe) static var requests: [URLRequest] = [] + private nonisolated(unsafe) static var completedPaths: Set = [] private static let lock = NSLock() private static let gateLock = NSCondition() - nonisolated(unsafe) private static var openGates: Set = [] + private nonisolated(unsafe) static var openGates: Set = [] static func set(_ handler: @escaping @Sendable (URLRequest) -> Response) { lock.withLock { self.handler = handler } @@ -38,7 +38,11 @@ private final class SubmissionHandleStub: URLProtocol, @unchecked Sendable { openGates = [] gateLock.unlock() } - static func paths() -> [String] { lock.withLock { requests.compactMap(\.url?.path) } } + + static func paths() -> [String] { + lock.withLock { requests.compactMap(\.url?.path) } + } + static func completed(_ path: String) -> Bool { lock.withLock { completedPaths.contains(path) } } @@ -50,11 +54,16 @@ private final class SubmissionHandleStub: URLProtocol, @unchecked Sendable { gateLock.unlock() } - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } override func startLoading() { - let request = self.request + let request = request let response = Self.lock.withLock { Self.requests.append(request) return Self.handler?(request) ?? Response() @@ -69,7 +78,8 @@ private final class SubmissionHandleStub: URLProtocol, @unchecked Sendable { private func deliver(_ response: Response, request: URLRequest) { let http = HTTPURLResponse( url: request.url!, statusCode: response.status, httpVersion: "HTTP/1.1", - headerFields: response.headers)! + headerFields: response.headers + )! client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: response.body) if !response.neverFinishes { client?.urlProtocolDidFinishLoading(self) } @@ -95,7 +105,9 @@ struct RunSubmissionTests { return RunSession( client: HarnessClient( baseURL: URL(string: "http://127.0.0.1:8896")!, - session: URLSession(configuration: configuration))) + session: URLSession(configuration: configuration) + ) + ) } private func event(_ id: String, _ runID: String, _ type: String, payload: String = "{}") throws @@ -106,7 +118,8 @@ struct RunSubmissionTests { id: id, event: type, data: #"{"id":"\#(id)","run_id":"\#(runID)","type":"\#(type)","payload":\#(payload)}"# - )) + ) + ) } private func wait(timeout: Duration = .seconds(2), for condition: () -> Bool) async throws { @@ -127,7 +140,8 @@ struct RunSubmissionTests { action.perform( canSubmit: true, steer: { steeredRunID = $0 }, - submit: { submitted = true }) + submit: { submitted = true } + ) #expect(steeredRunID == "run_a") #expect(!submitted) @@ -139,12 +153,13 @@ struct RunSubmissionTests { SubmissionHandleStub.set { request in switch (request.httpMethod, request.url?.path) { case ("POST", "/v1/runs"): - return .init( + .init( status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8), - delay: 0.15) + delay: 0.15 + ) case ("GET", "/v1/runs/run_a/events"), ("GET", "/v1/conversations/conversation/events"): - return .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) - default: return .init() + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() } } let session = makeSession() @@ -155,7 +170,8 @@ struct RunSubmissionTests { try await wait { SubmissionHandleStub.paths().contains("/v1/runs") } try await session.applyConversationEvent( - event("run_b:0", "run_b", "run.started"), conversationID: "conversation") + event("run_b:0", "run_b", "run.started"), conversationID: "conversation" + ) #expect(session.currentRunID == "run_b") try await wait { submission.runID == "run_a" } @@ -175,10 +191,10 @@ struct RunSubmissionTests { SubmissionHandleStub.set { request in switch (request.httpMethod, request.url?.path) { case ("POST", "/v1/runs"): - return .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) case ("GET", "/v1/runs/run_a/events"), ("GET", "/v1/conversations/conversation/events"): - return .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) - default: return .init() + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() } } let session = makeSession() @@ -188,11 +204,14 @@ struct RunSubmissionTests { try await wait { submission.runID == "run_a" && session.currentRunID == "run_a" } try await session.applyConversationEvent( - event("run_b:0", "run_b", "run.started"), conversationID: "conversation") + event("run_b:0", "run_b", "run.started"), conversationID: "conversation" + ) #expect( - session.currentRunID == "run_a", "timestamp-less B cannot displace provisional local A") + session.currentRunID == "run_a", "timestamp-less B cannot displace provisional local A" + ) try await session.applyConversationEvent( - event("run_b:1", "run_b", "run.started"), conversationID: "conversation") + event("run_b:1", "run_b", "run.started"), conversationID: "conversation" + ) // A local run remains provisional until timestamped evidence. Model an // authoritative scheduled continuation by using a timestamped B frame. let earlyB = try HarnessEvent( @@ -200,7 +219,8 @@ struct RunSubmissionTests { id: "run_b:2", event: "run.started", data: #"{"id":"run_b:2","run_id":"run_b","type":"run.started","timestamp":"2026-08-03T20:00:00Z","payload":{}}"# - )) + ) + ) await session.applyConversationEvent(earlyB, conversationID: "conversation") #expect(session.currentRunID == "run_a", "provisional local A intentionally resists replay") @@ -212,42 +232,48 @@ struct RunSubmissionTests { id: "run_a:0", event: "run.started", data: #"{"id":"run_a:0","run_id":"run_a","type":"run.started","timestamp":"2026-08-03T20:00:01Z","payload":{}}"# - )) + ) + ) await session.applyConversationEvent(startedA, conversationID: "conversation") let laterB = try HarnessEvent( frame: SSEFrame( id: "run_b:3", event: "run.started", data: #"{"id":"run_b:3","run_id":"run_b","type":"run.started","timestamp":"2026-08-03T20:00:02Z","payload":{}}"# - )) + ) + ) await session.applyConversationEvent(laterB, conversationID: "conversation") #expect(session.currentRunID == "run_b") #expect(submission.isDisplaced) // A's late terminal remains useful A-only evidence. It coexists with // displacement so ToolWalk can judge A and still never control B. - submission.apply(try event("run_a:late", "run_a", "run.completed")) + try submission.apply(event("run_a:late", "run_a", "run.completed")) #expect(submission.isDisplaced) #expect(submission.isTerminal) #expect(submission.state == .terminal("run_a")) - session.cancelTimedOutRun(expectedRunID: submission.runID) + session.cancelTimedOutSubmission(submission) #expect(!SubmissionHandleStub.paths().contains("/v1/runs/run_b/cancel")) session.reset() } @Test("A-only handle retains A terminal transcript and never reads B") - func submissionRetainsItsOwnTerminalTranscript() async throws { + func submissionRetainsItsOwnTerminalTranscript() throws { let submission = RunSubmission(prompt: "run A") submission.markStarted(runID: "run_a") - submission.apply( - try event( - "run_a:0", "run_a", "assistant.message", payload: #"{"content":"A replied"}"#)) - submission.apply(try event("run_a:1", "run_a", "run.completed")) + try submission.apply( + event( + "run_a:0", "run_a", "assistant.message", payload: #"{"content":"A replied"}"# + ) + ) + try submission.apply(event("run_a:1", "run_a", "run.completed")) // B's event cannot enter A's handle because `apply` is run-id scoped. - submission.apply( - try event( - "run_b:0", "run_b", "assistant.message", payload: #"{"content":"B replied"}"#)) + try submission.apply( + event( + "run_b:0", "run_b", "assistant.message", payload: #"{"content":"B replied"}"# + ) + ) #expect(submission.runID == "run_a") #expect(submission.transcript.runState == .completed) #expect( @@ -256,7 +282,8 @@ struct RunSubmissionTests { return message.text == "A replied" } return false - }) + } + ) } @Test("start failure and reset leave no resurrected submission") @@ -278,11 +305,12 @@ struct RunSubmissionTests { SubmissionHandleStub.set { request in switch (request.httpMethod, request.url?.path) { case ("POST", "/v1/runs"): - return .init( + .init( status: 202, body: Data(#"{"run_id":"late_a","status":"queued"}"#.utf8), - delay: 0.15) + delay: 0.15 + ) default: - return .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) } } let resetSession = makeSession() @@ -301,12 +329,13 @@ struct RunSubmissionTests { SubmissionHandleStub.set { request in switch (request.httpMethod, request.url?.path) { case ("POST", "/v1/runs"): - return .init( + .init( status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8), - waitForGate: "release_a_ack") + waitForGate: "release_a_ack" + ) case ("GET", "/v1/runs/run_a/events"), ("GET", "/v1/conversations/conversation/events"): - return .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) - default: return .init() + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() } } let session = makeSession() @@ -320,7 +349,8 @@ struct RunSubmissionTests { id: "run_b:0", event: "run.started", data: #"{"id":"run_b:0","run_id":"run_b","type":"run.started","timestamp":"2026-08-03T21:00:00Z","payload":{}}"# - )) + ) + ) await session.applyConversationEvent(scheduledB, conversationID: "conversation") #expect(session.currentRunID == "run_b") SubmissionHandleStub.openGate("release_a_ack") @@ -342,7 +372,8 @@ struct RunSubmissionTests { return .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) } return .init( - status: 503, body: Data(#"{"error":"A unavailable"}"#.utf8), waitForGate: "fail_a") + status: 503, body: Data(#"{"error":"A unavailable"}"#.utf8), waitForGate: "fail_a" + ) } let session = makeSession() session.load(messages: [], conversationID: "conversation") @@ -354,7 +385,8 @@ struct RunSubmissionTests { id: "run_b:0", event: "run.started", data: #"{"id":"run_b:0","run_id":"run_b","type":"run.started","timestamp":"2026-08-03T21:00:00Z","payload":{}}"# - )) + ) + ) await session.applyConversationEvent(scheduledB, conversationID: "conversation") #expect(session.currentRunID == "run_b") SubmissionHandleStub.openGate("fail_a") @@ -374,12 +406,12 @@ struct RunSubmissionTests { SubmissionHandleStub.set { request in switch (request.httpMethod, request.url?.path) { case ("POST", "/v1/runs"): - return .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) case ("GET", "/v1/runs/run_a/events"): - return .init(headers: ["Content-Type": "text/event-stream"], waitForGate: "eof_a") + .init(headers: ["Content-Type": "text/event-stream"], waitForGate: "eof_a") case ("GET", "/v1/conversations/conversation/events"): - return .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) - default: return .init() + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() } } let session = makeSession() @@ -392,14 +424,16 @@ struct RunSubmissionTests { id: "run_a:0", event: "run.started", data: #"{"id":"run_a:0","run_id":"run_a","type":"run.started","timestamp":"2026-08-03T21:00:00Z","payload":{}}"# - )) + ) + ) await session.applyConversationEvent(startedA, conversationID: "conversation") let scheduledB = try HarnessEvent( frame: SSEFrame( id: "run_b:0", event: "run.started", data: #"{"id":"run_b:0","run_id":"run_b","type":"run.started","timestamp":"2026-08-03T21:00:01Z","payload":{}}"# - )) + ) + ) await session.applyConversationEvent(scheduledB, conversationID: "conversation") #expect(session.currentRunID == "run_b") SubmissionHandleStub.openGate("eof_a") @@ -417,13 +451,14 @@ struct RunSubmissionTests { SubmissionHandleStub.set { request in switch (request.httpMethod, request.url?.path) { case ("POST", "/v1/runs"): - return .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) + .init(status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8)) case ("GET", "/v1/runs/run_a/events"): - return .init( - headers: ["Content-Type": "text/event-stream"], waitForGate: "owned_eof") + .init( + headers: ["Content-Type": "text/event-stream"], waitForGate: "owned_eof" + ) case ("GET", "/v1/conversations/run_a/events"): - return .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) - default: return .init() + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() } } let session = makeSession() @@ -448,7 +483,8 @@ struct RunSubmissionTests { } return .init( status: 202, body: Data(#"{"run_id":"run_a","status":"queued"}"#.utf8), - waitForGate: "late_a_after_load") + waitForGate: "late_a_after_load" + ) } let session = makeSession() session.load(messages: [], conversationID: "conversation_a") From 1f996d8b53ebee23fd1564dccc447fac4a6fe0e1 Mon Sep 17 00:00:00 2001 From: Dennison Date: Mon, 3 Aug 2026 22:10:18 +0200 Subject: [PATCH 3/3] docs: correct focused orchestration evidence count --- docs/logs/engineering-log.md | 4 +++- docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md | 4 +++- docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index f84d0e05..cba3bceb 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -83,7 +83,9 @@ passive terminal, EOF failure, delayed acknowledgement, and B-safe timeout policy. The stronger B -> C authority and revocation proof is tracked in the separate #1136 entry above. -- Verification: strict format (0/7 touched Swift files require formatting) and +- Verification: final combined focused `PassiveSubmissionOutcomeIntegrationTests` + passes 10/10 (not the earlier intermediate 4/4 or 8/8 counts); strict format + (0/7 touched Swift files require formatting) and full `swift test --package-path macapp` (244 tests / 46 suites) pass. Full regression, independent review, and hosted checks remain. diff --git a/docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md b/docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md index 776b2699..a4f004f1 100644 --- a/docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md +++ b/docs/plans/2026-08-03-issue-1133-passive-outcome-plan.md @@ -46,7 +46,9 @@ - [x] Keep displacement sticky while observing terminal/failure passively. - [x] Make timeout transport-only for exact locally owned displaced A. - [x] Update plans/logs/indexes, including the stale displaced-result wording. -- [x] Run strict format and focused/full Swift. +- [x] Run strict format and focused/full Swift; final combined focused + `PassiveSubmissionOutcomeIntegrationTests` evidence is 10/10 (not the + intermediate #1133-only 4/4 count). - [ ] Run full regression after the independent #1135 baseline fixture repair. - [ ] Publish stacked draft `Closes #1133`; obtain independent cheap review. diff --git a/docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md b/docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md index 96b02b6b..814f85d7 100644 --- a/docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md +++ b/docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md @@ -30,6 +30,8 @@ - [x] Add deterministic capability/revocation/detachment tests. - [x] Re-run strict format (0/7 touched Swift files) and full Swift (245 tests / 46 suites) after the final proof update. +- [x] Focused `PassiveSubmissionOutcomeIntegrationTests`: 10/10 cases on the + final stacked head (the earlier 4/4 and 8/8 counts were intermediate slices). - [x] Run `./scripts/test-regression.sh` after the #1135 baseline repair: normal, race, and coverage passed (85.5% total; zero uncovered production functions).