diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index cba3bceb..bc418ce4 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -59,14 +59,29 @@ 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. +- Replaced the provisional public handle cancel with a package-visible opaque + `TimedOutSubmissionTicket`. Its initializer is fileprivate to `Runner`; the + only mint point is `waitForTerminal`'s final deadline-edge lifecycle check. + Ticket consumption retains the private `RunSubmission` owner-token/generation + recheck and is transport-only. 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. +- TDD red: removing the old API produced nine expected focused compile errors + at former direct call sites. Gated proof now requires no ticket/action before + deadline, B -> C -> A exact-one dispatch, duplicate refusal, and post-ticket + terminal/failure/reset revocation. Remaining full-gate evidence is recorded + by this corrected PR rather than inherited from the superseded implementation. +- Review correction: the first ticket implementation left a package-scoped raw + transport method callable before deadline. The ticket, constructor, and + transport closure now live in GoCodeUI; ToolWalk binds the immutable duration + at submission and `submissionTimeoutGate(for:)` alone verifies the derived + deadline and mints once. The #1146 CI-flake repair introduces an internal + `RunSession` monotonic-now seam shared by `RunSubmission.markStarted` and + `SubmissionTimeoutGate`; tests freeze/advance it at epsilon and exact + deadline instead of sleeping. `Runner.waitForTerminal` now accepts only its + poll interval so a caller cannot silently pass a conflicting timeout after + submission. A direct + gate regression plus a source-surface drift test prevents that bypass. ## 2026-08-03 (Issue #1133 passive displaced-submission outcome) diff --git a/docs/logs/long-term-thinking-log.md b/docs/logs/long-term-thinking-log.md index 1d4e85ac..5cc895b5 100644 --- a/docs/logs/long-term-thinking-log.md +++ b/docs/logs/long-term-thinking-log.md @@ -43,9 +43,10 @@ - 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. +- Success: only a Runner deadline-minted opaque ticket can consume A's + 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. diff --git a/docs/logs/observational-log.md b/docs/logs/observational-log.md index 10aee4b7..d3c0d54d 100644 --- a/docs/logs/observational-log.md +++ b/docs/logs/observational-log.md @@ -33,9 +33,11 @@ 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 real deadline is suitable for #1133 wait-policy coverage but is not enough + if any caller can turn a submission handle into authority. The opaque ticket + is absent before deadline and can be constructed only at Runner's deadline + boundary; deterministic consumption makes B -> C -> A exact-one dispatch + and terminal/failure/reset non-dispatch observable. - 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. diff --git a/docs/logs/system-log.md b/docs/logs/system-log.md index 8f3a95e5..0aa91911 100644 --- a/docs/logs/system-log.md +++ b/docs/logs/system-log.md @@ -41,9 +41,20 @@ ## 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. + consumed bit. ToolWalk alone binds its configured duration at submission, + `markStarted` derives the absolute deadline, and `Runner.waitForTerminal` + mints a package-visible ticket with a fileprivate constructor only through + `RunSession.submissionTimeoutGate(for:)` after its exact deadline check. The + ticket captures a fileprivate transport closure; + no raw package or public `RunSubmission` handle-cancel API remains. A + handle-keyed task registry lets reset/load cancel all local streams. +- Deterministic native timing: production uses `ContinuousClock.now`; the + internal-only RunSession initializer injects the same monotonic closure into + `markStarted` deadline creation and gate expiry checks, so tests advance + epsilon/exact-deadline state without scheduler-dependent sleeps. +- Wait API boundary: `Runner.waitForTerminal` accepts only a polling interval. + The timeout is immutable submission configuration, not a second wait-phase + parameter that can conflict with the stored deadline. ## 2026-08-03 (Issue #1133 passive A outcome after B selection) 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 index 8a62c242..41528407 100644 --- 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 @@ -10,9 +10,16 @@ ## 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 + form the A-only capability. A package-visible ticket with a fileprivate + initializer prevents public callers from constructing pre-deadline authority. + ToolWalk-only submission binds the immutable duration; `markStarted` derives + the deadline. `RunSession.submissionTimeoutGate(for:)` is the only package + gate and refuses pre-deadline or duplicate minting. +- ToolWalk: consumes that ticket only through the GoCodeUI gate. Its + timeout is transport-only; it cannot alter B/C selection, transcript, controls, or cancellation state. + `waitForTerminal` receives only a poll interval; the configured timeout is + bound before start and cannot be contradicted by a later wait argument. - 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. @@ -23,6 +30,9 @@ 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. + no ticket/action before deadline; B -> C -> A sends exactly one A cancel; + duplicate, terminal, failure, and reset consume attempts fail; zero B/C + actions; and reset stops both concurrent A/C event streams. The test-only + internal RunSession clock seam freezes the same monotonic source used by + `markStarted` and the gate, removing wall-clock sleep races. - 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 index 814f85d7..9f7d581a 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 @@ -16,9 +16,18 @@ - 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. + generation. `RunSession` exposes no public handle-based timeout cancel. + ToolWalk binds its configured immutable `Duration` at submission; GUI uses + the parameter-free submit path. `RunSubmission.markStarted` derives the + sole absolute deadline, and `RunSession.submissionTimeoutGate(for:)` alone + mints a package-visible opaque ticket with a fileprivate constructor at that + deadline. `Runner.waitForTerminal` accepts only its poll interval: it cannot + reinterpret a timeout duration after submission. No caller supplies a + post-submit duration and no raw + transport API exists. Ticket consumption is transport-only and atomically rechecks started owner, + generation, and one-shot state; 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 @@ -28,11 +37,23 @@ - [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] Focused `PassiveSubmissionOutcomeIntegrationTests`: 10/10 cases on the +- [x] Re-run strict format and full Swift after the final submission-bound + deadline repair: formatter passed; 252 tests in 46 suites passed. +- [x] Focused `PassiveSubmissionOutcomeIntegrationTests`: 14/14 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). -- [x] Publish the separate stacked draft PR with `Closes #1136`. +- [x] Run `./scripts/test-regression.sh` after the final repair: normal, race, + and coverage passed (85.5% total; zero uncovered production functions). +- [x] Capture the API-removal red: former direct handle callers no longer + compile after `cancelTimedOutSubmission(_:)` is removed. +- [x] Add deadline-ticket proof for pre-expiry absence, B -> C -> A exact-one + dispatch, terminal/failure/reset revocation, and duplicate refusal. +- [x] Correct the review-found package raw-transport bypass: GoCodeUI owns the + ticket and fileprivate transport closure, while the submission-bound gate + returns the same immutable gate to repeated callers. A test-only internal + monotonic-now seam shared by `markStarted` and the gate advances epsilon and + exact-deadline state without wall-clock sleeps, proving no ticket/action + before expiry and then one A-only ticket/dispatch; + source-surface tests forbid both raw transport and `armSubmissionTimeout`. +- [x] Run exact strict Swift format, full Swift, and full repository regression + on the final opaque-ticket head. +- [ ] Publish the separate stacked draft PR with `Closes #1136`. diff --git a/macapp/Sources/GoCodeUI/ProjectSession.swift b/macapp/Sources/GoCodeUI/ProjectSession.swift index 3986ed7f..e3446b8e 100644 --- a/macapp/Sources/GoCodeUI/ProjectSession.swift +++ b/macapp/Sources/GoCodeUI/ProjectSession.swift @@ -343,11 +343,18 @@ public final class ProjectSession { @discardableResult public func submit() -> RunSubmission? { + submit(timeoutAfter: nil) + } + + /// ToolWalk alone supplies a bounded timeout; GUI submission remains + /// deliberately parameter-free. + @discardableResult + package func submit(timeoutAfter: Duration?) -> RunSubmission? { run?.model = selectedModel run?.planMode = planMode run?.extraDirs = extraDirs.map(\.path) run?.profile = selectedProfile - let submission = run?.submit() + let submission = run?.submit(timeoutAfter: timeoutAfter) Task { // `run.submit()` starts its own unstructured task that only sets // `conversationID` once harnessd has actually minted one — a diff --git a/macapp/Sources/GoCodeUI/RunSession+RunControls.swift b/macapp/Sources/GoCodeUI/RunSession+RunControls.swift index 9b65624b..b6bd37ca 100644 --- a/macapp/Sources/GoCodeUI/RunSession+RunControls.swift +++ b/macapp/Sources/GoCodeUI/RunSession+RunControls.swift @@ -1,6 +1,57 @@ import Foundation import HarnessKit +/// Opaque timeout authority. Its initializer is file-private: callers cannot +/// manufacture a ticket from a submission before the owning RunSession gates +/// it at the deadline. +@MainActor +package struct TimedOutSubmissionTicket { + private let consumeTransport: @MainActor () -> Bool + + fileprivate init(consumeTransport: @escaping @MainActor () -> Bool) { + self.consumeTransport = consumeTransport + } + + @discardableResult + package func consume() -> Bool { + consumeTransport() + } +} + +@MainActor +package final class SubmissionTimeoutGate { + private weak var session: RunSession? + private let submission: RunSubmission + private let deadline: ContinuousClock.Instant + private let now: @MainActor () -> ContinuousClock.Instant + + fileprivate init( + session: RunSession, submission: RunSubmission, deadline: ContinuousClock.Instant, + now: @escaping @MainActor () -> ContinuousClock.Instant + ) { + self.session = session + self.submission = submission + self.deadline = deadline + self.now = now + } + + package func ticketIfExpired() -> TimedOutSubmissionTicket? { + guard now() >= deadline, + let session, + submission.mintTimeoutTicket( + owner: session.submissionOwnerToken, generation: session.submissionGeneration + ) + else { return nil } + return TimedOutSubmissionTicket { [weak session, submission, client = session.client] in + guard let session, + let runID = session.consumeTimeoutCancellation(for: submission) + else { return false } + Task { try? await client.cancel(runID: runID) } + return true + } + } +} + extension RunSession { /// True only while the first, cooperative cancel request awaits harnessd's /// acknowledgement. Once it succeeds, a second press remains available @@ -18,17 +69,17 @@ extension RunSession { cancel(runID: runID) } - /// 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 + /// The sole package boundary for timeout authority. It arms a fixed + /// deadline gate; only that gate can later mint an opaque ticket. + package func submissionTimeoutGate(for submission: RunSubmission) -> SubmissionTimeoutGate? { + let id = ObjectIdentifier(submission) + if let gate = submissionTimeoutGates[id] { return gate } + guard let deadline = submission.timeoutDeadlineIfStarted() else { return nil } + let gate = SubmissionTimeoutGate( + session: self, submission: submission, deadline: deadline, now: submissionTimeoutNow + ) + submissionTimeoutGates[id] = gate + return gate } /// 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 4387be9c..8d8621c7 100644 --- a/macapp/Sources/GoCodeUI/RunSession.swift +++ b/macapp/Sources/GoCodeUI/RunSession.swift @@ -90,13 +90,28 @@ public final class RunSession { 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() + let submissionOwnerToken = UUID() + /// The single monotonic source used to derive and observe submission + /// deadlines. Production uses `ContinuousClock.now`; the internal + /// initializer makes deterministic native timing tests possible without + /// exposing a clock choice to GUI or ToolWalk callers. + let submissionTimeoutNow: @MainActor () -> ContinuousClock.Instant /// Reset/load detach the old session permanently; their generation invalidates /// every outstanding submission timeout capability. - private var submissionGeneration: UInt = 0 + var submissionGeneration: UInt = 0 + var submissionTimeoutGates: [ObjectIdentifier: SubmissionTimeoutGate] = [:] public init(client: HarnessClient) { self.client = client + submissionTimeoutNow = { ContinuousClock.now } + } + + init( + client: HarnessClient, + submissionTimeoutNow: @escaping @MainActor () -> ContinuousClock.Instant + ) { + self.client = client + self.submissionTimeoutNow = submissionTimeoutNow } public convenience init(baseURL: URL, token: String? = nil) { @@ -131,11 +146,20 @@ public final class RunSession { @discardableResult public func submit() -> RunSubmission? { + submit(timeoutAfter: nil) + } + + /// ToolWalk's bounded execution path is the sole caller permitted to bind + /// a timeout policy to a submission. GUI callers intentionally receive + /// the parameter-free overload above. + @discardableResult + package func submit(timeoutAfter: Duration?) -> RunSubmission? { let prompt = draft.trimmed guard !prompt.isEmpty, !isBusy, !runControlInFlight else { return nil } let submission = RunSubmission( prompt: prompt, timeoutOwner: submissionOwnerToken, - timeoutGeneration: submissionGeneration + timeoutGeneration: submissionGeneration, timeoutAfter: timeoutAfter, + timeoutNow: submissionTimeoutNow ) activeSubmission = submission draft = "" @@ -572,6 +596,7 @@ public final class RunSession { task.cancel() } submissionStreamTasks = [:] + submissionTimeoutGates = [:] activeSubmission?.markDisplaced() activeSubmission = nil activeRunIDs = [] diff --git a/macapp/Sources/GoCodeUI/RunSubmission.swift b/macapp/Sources/GoCodeUI/RunSubmission.swift index ba3ce66d..4fb372c3 100644 --- a/macapp/Sources/GoCodeUI/RunSubmission.swift +++ b/macapp/Sources/GoCodeUI/RunSubmission.swift @@ -53,6 +53,10 @@ public final class RunSubmission { /// being reconstructed from mutable selected-run state later. private let timeoutOwner: UUID private let timeoutGeneration: UInt + private let timeoutAfter: Duration? + private let timeoutNow: @MainActor () -> ContinuousClock.Instant + private var timeoutDeadline: ContinuousClock.Instant? + private var timeoutTicketMinted = false private var timeoutCancellationConsumed = false public var runID: String? { @@ -69,9 +73,15 @@ public final class RunSubmission { return false } - init(prompt: String, timeoutOwner: UUID, timeoutGeneration: UInt) { + init( + prompt: String, timeoutOwner: UUID, timeoutGeneration: UInt, + timeoutAfter: Duration? = nil, + timeoutNow: @escaping @MainActor () -> ContinuousClock.Instant = { ContinuousClock.now } + ) { self.timeoutOwner = timeoutOwner self.timeoutGeneration = timeoutGeneration + self.timeoutAfter = timeoutAfter + self.timeoutNow = timeoutNow transcript.appendUserPrompt(prompt) } @@ -93,12 +103,29 @@ public final class RunSubmission { return runID } + /// The deadline gate is intentionally separate from consumption: a ticket + /// can be minted only once after its exact deadline, while terminal, + /// failure, reset, and load still revoke the captured authority before it + /// is consumed. + func mintTimeoutTicket(owner: UUID, generation: UInt) -> Bool { + guard timeoutOwner == owner, + timeoutGeneration == generation, + !timeoutTicketMinted, + case .started = lifecycle + else { return false } + timeoutTicketMinted = true + return true + } + func markStarted(runID: String) { guard case .starting = lifecycle else { return } resolvedRunID = runID + if let timeoutAfter { timeoutDeadline = timeoutNow().advanced(by: timeoutAfter) } lifecycle = .started(runID) } + func timeoutDeadlineIfStarted() -> ContinuousClock.Instant? { timeoutDeadline } + func apply(_ event: HarnessEvent) { guard runID == event.runID else { return } transcript.apply(event) diff --git a/macapp/Sources/ToolWalk/Runner.swift b/macapp/Sources/ToolWalk/Runner.swift index e2f6b6fc..242c874d 100644 --- a/macapp/Sources/ToolWalk/Runner.swift +++ b/macapp/Sources/ToolWalk/Runner.swift @@ -50,7 +50,7 @@ enum Runner { } run.draft = spec.prompt - guard let submission = project.submit() else { + guard let submission = project.submit(timeoutAfter: config.timeoutPerTool) else { let result = ToolResult( name: spec.name, verdict: "fail", reply: "submission was not accepted" ) @@ -78,14 +78,15 @@ enum Runner { print(" FAIL (\(result.reply))") continue } - let finished = await waitForTerminal(run: run, submission: submission, config: config) + let finished = await waitForTerminal( + run: run, submission: submission, pollInterval: config.pollInterval + ) { ticket in + _ = ticket.consume() + } switch finished { case .terminal: break case .timedOut: - if shouldCancel(for: finished) { - run.cancelTimedOutSubmission(submission) - } // Give the cooperative cancel a moment to land before moving // on, or the next tool's newConversation() races its teardown. try? await Task.sleep(for: .seconds(1)) @@ -139,18 +140,22 @@ enum Runner { /// Without this, AskUserQuestion (and any tool a permission rule gates) /// would simply hang every walk until the timeout. static func waitForTerminal( - run: RunSession, submission: RunSubmission, config: RunnerConfig + run: RunSession, submission: RunSubmission, pollInterval: Duration, + onTimeout: @escaping @MainActor (TimedOutSubmissionTicket) -> Void = { _ in } ) async -> SubmissionWaitOutcome { - let deadline = ContinuousClock.now.advanced(by: config.timeoutPerTool) - while ContinuousClock.now < deadline { + while true { let outcome = outcome(for: submission) switch outcome { case .terminal, .failed: return outcome case .started, .displaced: break case .timedOut: return .timedOut } + if let ticket = run.submissionTimeoutGate(for: submission)?.ticketIfExpired() { + onTimeout(ticket) + return .timedOut + } guard let runID = submission.runID else { - try? await Task.sleep(for: config.pollInterval) + try? await Task.sleep(for: pollInterval) continue } // Once B owns visible state, A's handle remains an observation @@ -159,7 +164,7 @@ enum Runner { // 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) + try? await Task.sleep(for: pollInterval) continue } if let prompt = run.pendingQuestions { @@ -178,9 +183,8 @@ enum Runner { guard plan.runID == runID else { return .displaced } run.approve(expectedRunID: plan.runID, option: plan.options.first?.id) } - try? await Task.sleep(for: config.pollInterval) + try? await Task.sleep(for: pollInterval) } - return .timedOut } /// Lifecycle has priority over displacement. A selected B must prevent diff --git a/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift b/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift index 699bddb2..a8d6df7f 100644 --- a/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift +++ b/macapp/Tests/GoCodeUITests/PassiveSubmissionOutcomeIntegrationTests.swift @@ -116,6 +116,12 @@ struct PassiveSubmissionOutcomeIntegrationTests { RunSession(client: client()) } + private func session( + submissionTimeoutNow: @escaping @MainActor () -> ContinuousClock.Instant + ) -> RunSession { + RunSession(client: client(), submissionTimeoutNow: submissionTimeoutNow) + } + private func client() -> HarnessClient { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [PassiveOutcomeProtocol.self] @@ -163,6 +169,20 @@ struct PassiveSubmissionOutcomeIntegrationTests { Issue.record("timed out waiting for gated integration condition") } + /// The only way test code receives timeout authority mirrors production: + /// Runner mints the opaque ticket at the actual wait deadline. Holding a + /// submission before this helper returns never exposes a cancel API. + private func waitForTimeoutTicket( + _ run: RunSession, submission: RunSubmission + ) async -> (Runner.SubmissionWaitOutcome, TimedOutSubmissionTicket?) { + var ticket: TimedOutSubmissionTicket? + let outcome = await Runner.waitForTerminal( + run: run, submission: submission, + pollInterval: .milliseconds(5) + ) { ticket = $0 } + return (outcome, ticket) + } + private func displaceA(_ session: RunSession, submission: RunSubmission) async throws { try await wait { submission.runID == "run_a" && session.currentRunID == "run_a" } try await session.applyConversationEvent( @@ -202,20 +222,23 @@ struct PassiveSubmissionOutcomeIntegrationTests { default: .init() } } - let run = session() + let now = ContinuousClock.now + let run = session(submissionTimeoutNow: { now }) run.load(messages: [], conversationID: "conversation") run.draft = "A" - let submission = try #require(run.submit()) + let submission = try #require(run.submit(timeoutAfter: .milliseconds(100))) try await displaceA(run, submission: submission) + try await wait { PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/events") } let waitTask = Task { await Runner.waitForTerminal( run: run, submission: submission, - config: .init(timeoutPerTool: .seconds(1), pollInterval: .milliseconds(5))) + pollInterval: .milliseconds(5)) } PassiveOutcomeProtocol.openGate("a-terminal") + try await wait { submission.isTerminal } #expect(await waitTask.value == .terminal) #expect(run.currentRunID == "run_b") - assertNoAction(for: ["run_b"]) + assertNoAction(for: ["run_a", "run_b", "run_c"]) run.reset() } @@ -313,21 +336,27 @@ struct PassiveSubmissionOutcomeIntegrationTests { default: .init() } } - let run = session() + let now = ContinuousClock.now + let run = session(submissionTimeoutNow: { now }) run.load(messages: [], conversationID: "conversation") run.draft = "A" - let submission = try #require(run.submit()) + let submission = try #require(run.submit(timeoutAfter: .milliseconds(80))) try await displaceA(run, submission: submission) + try await wait { PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/events") } let waitTask = Task { await Runner.waitForTerminal( run: run, submission: submission, - config: .init(timeoutPerTool: .seconds(1), pollInterval: .milliseconds(5))) + pollInterval: .milliseconds(5)) } PassiveOutcomeProtocol.openGate("a-eof") + try await wait { + if case .failed = submission.lifecycle { return true } + return false + } #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"]) + assertNoAction(for: ["run_a", "run_b", "run_c"]) run.reset() } @@ -348,14 +377,11 @@ struct PassiveSubmissionOutcomeIntegrationTests { let run = session() run.load(messages: [], conversationID: "conversation") run.draft = "A" - let submission = try #require(run.submit()) + let submission = try #require(run.submit(timeoutAfter: .milliseconds(80))) try await displaceA(run, submission: submission) - let outcome = await Runner.waitForTerminal( - run: run, submission: submission, - config: .init(timeoutPerTool: .milliseconds(80), pollInterval: .milliseconds(5)) - ) + let (outcome, ticket) = await waitForTimeoutTicket(run, submission: submission) #expect(outcome == .timedOut) - #expect(run.cancelTimedOutSubmission(submission)) + #expect(ticket?.consume() == true) try await wait { PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel") } #expect(run.currentRunID == "run_b") assertNoAction(for: ["run_b"]) @@ -386,7 +412,7 @@ struct PassiveSubmissionOutcomeIntegrationTests { let run = session() run.load(messages: [], conversationID: "conversation") run.draft = "A" - let submission = try #require(run.submit()) + let submission = try #require(run.submit(timeoutAfter: .milliseconds(80))) try await displaceA(run, submission: submission) try await run.applyConversationEvent( event("run_b:completed", "run_b", "run.completed", timestamp: "2026-08-03T22:00:02Z"), @@ -400,12 +426,12 @@ struct PassiveSubmissionOutcomeIntegrationTests { 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)) + // Only the deadline wait may mint A's authority. C cannot replace + // that ticket even though it now owns selected shared state. + let (outcome, ticket) = await waitForTimeoutTicket(run, submission: submission) + #expect(outcome == .timedOut) + #expect(ticket?.consume() == true) + #expect(ticket?.consume() == false) 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") @@ -434,10 +460,12 @@ struct PassiveSubmissionOutcomeIntegrationTests { } let run = session() run.draft = "A" - let submission = try #require(run.submit()) + let submission = try #require(run.submit(timeoutAfter: .milliseconds(80))) try await wait { submission.runID == "run_a" } - #expect(run.cancelTimedOutSubmission(submission)) - #expect(!run.cancelTimedOutSubmission(submission)) + let (outcome, ticket) = await waitForTimeoutTicket(run, submission: submission) + #expect(outcome == .timedOut) + #expect(ticket?.consume() == true) + #expect(ticket?.consume() == false) try await wait { PassiveOutcomeProtocol.paths().filter { $0 == "/v1/runs/run_a/cancel" }.count == 1 } @@ -465,11 +493,11 @@ struct PassiveSubmissionOutcomeIntegrationTests { } let run = session() run.draft = "A" - let terminal = try #require(run.submit()) + let terminal = try #require(run.submit(timeoutAfter: .milliseconds(80))) try await wait { terminal.runID == "run_a" } PassiveOutcomeProtocol.openGate("a-terminal") try await wait { terminal.isTerminal } - #expect(!run.cancelTimedOutSubmission(terminal)) + // A terminal run can never mint a deadline ticket. #expect(!PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel")) PassiveOutcomeProtocol.reset() @@ -490,7 +518,7 @@ struct PassiveSubmissionOutcomeIntegrationTests { try await wait { reset.runID == "run_b" } run.reset() try await wait { PassiveOutcomeProtocol.stopped("/v1/runs/run_b/events") } - #expect(!run.cancelTimedOutSubmission(reset)) + // Reset detaches the stream before another deadline can mint a ticket. #expect(!PassiveOutcomeProtocol.paths().contains("/v1/runs/run_b/cancel")) } @@ -518,7 +546,156 @@ struct PassiveSubmissionOutcomeIntegrationTests { if case .failed = submission.lifecycle { return true } return false } - #expect(!run.cancelTimedOutSubmission(submission)) + // A failed run can never mint a deadline ticket. + #expect(!PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel")) + } + + @Test("deadline ticket is absent before expiry and reset revokes it after expiry") + func ticketCannotExistBeforeDeadlineAndResetRevokesIt() 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() + } + } + var now = ContinuousClock.now + let run = session(submissionTimeoutNow: { now }) + run.draft = "A" + let submission = try #require(run.submit(timeoutAfter: .milliseconds(100))) + try await wait { submission.runID == "run_a" } + + let gateA = try #require(run.submissionTimeoutGate(for: submission)) + let gateB = try #require(run.submissionTimeoutGate(for: submission)) + #expect(gateA === gateB) + #expect(gateA.ticketIfExpired() == nil) + now = now.advanced(by: .milliseconds(99)) + #expect(gateB.ticketIfExpired() == nil) + assertNoAction(for: ["run_a", "run_b", "run_c"]) + + now = now.advanced(by: .milliseconds(1)) + let minted = try #require(gateA.ticketIfExpired()) + #expect(gateB.ticketIfExpired() == nil) + #expect(minted.consume()) + #expect(!minted.consume()) + try await wait { PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel") } + #expect(PassiveOutcomeProtocol.paths().filter { $0 == "/v1/runs/run_a/cancel" }.count == 1) + assertNoAction(for: ["run_b", "run_c"]) + run.reset() + try await wait { PassiveOutcomeProtocol.stopped("/v1/runs/run_a/events") } + #expect(!minted.consume()) + } + + @Test("GUI submissions have no timeout gate and delayed start binds its own deadline") + func guiSubmissionHasNoTimeoutAndDeadlineStartsAtAcknowledgement() async throws { + var now = ContinuousClock.now + let delayed = RunSubmission( + prompt: "A", timeoutOwner: UUID(), timeoutGeneration: 0, + timeoutAfter: .milliseconds(60), timeoutNow: { now } + ) + #expect(delayed.timeoutDeadlineIfStarted() == nil) + now = now.advanced(by: .seconds(1)) + delayed.markStarted(runID: "run_delayed") + let deadline = try #require(delayed.timeoutDeadlineIfStarted()) + #expect(now < deadline) + + PassiveOutcomeProtocol.reset() + PassiveOutcomeProtocol.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + .init(status: 202, body: Data(#"{"run_id":"run_gui","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_gui/events"): + .init(headers: ["Content-Type": "text/event-stream"], neverFinishes: true) + default: .init() + } + } + let run = session() + run.draft = "GUI" + let submission = try #require(run.submit()) + try await wait { submission.runID == "run_gui" } + #expect(run.submissionTimeoutGate(for: submission) == nil) + assertNoAction(for: ["run_gui"]) + run.reset() + } + + @Test("terminal and failure after deadline revoke an already-minted ticket") + func terminalAndFailureAfterTicketRevokeTransport() 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 terminal = try #require(run.submit(timeoutAfter: .milliseconds(80))) + try await wait { terminal.runID == "run_a" } + let (outcome, ticket) = await waitForTimeoutTicket(run, submission: terminal) + #expect(outcome == .timedOut) + try terminal.apply( + event( + "run_a:terminal", "run_a", "run.completed", + timestamp: "2026-08-03T22:00:04Z" + ) + ) + let minted = try #require(ticket) + #expect(!minted.consume()) + + // Failure uses the same RunSession-owned submission path; it must + // revoke a ticket minted just before its stream reports EOF/failure. + run.reset() + run.draft = "failed A" + let failed = try #require(run.submit(timeoutAfter: .milliseconds(80))) + try await wait { failed.runID == "run_a" } + let (failureOutcome, failureTicket) = await waitForTimeoutTicket(run, submission: failed) + #expect(failureOutcome == .timedOut) + failed.markFailed("stream ended") + let mintedFailure = try #require(failureTicket) + #expect(!mintedFailure.consume()) + #expect(!PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel")) + run.reset() + } + + @Test("loading another conversation revokes an already-minted A ticket") + func loadRevokesMintedTicketWithoutActingOnReplacement() 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(timeoutAfter: .milliseconds(80))) + try await wait { submission.runID == "run_a" } + let (outcome, ticket) = await waitForTimeoutTicket(run, submission: submission) + #expect(outcome == .timedOut) + let minted = try #require(ticket) + + run.load(messages: [], conversationID: "replacement") + try await wait { PassiveOutcomeProtocol.stopped("/v1/runs/run_a/events") } + #expect(!minted.consume()) + #expect(run.currentRunID == nil) #expect(!PassiveOutcomeProtocol.paths().contains("/v1/runs/run_a/cancel")) } diff --git a/macapp/Tests/GoCodeUITests/RunSubmissionTests.swift b/macapp/Tests/GoCodeUITests/RunSubmissionTests.swift index 8225b09c..6f251dd4 100644 --- a/macapp/Tests/GoCodeUITests/RunSubmissionTests.swift +++ b/macapp/Tests/GoCodeUITests/RunSubmissionTests.swift @@ -253,7 +253,10 @@ struct RunSubmissionTests { #expect(submission.isTerminal) #expect(submission.state == .terminal("run_a")) - session.cancelTimedOutSubmission(submission) + // A submission handle alone is intentionally not cancellation + // authority. Only ToolWalk's deadline-minted opaque ticket can make + // the transport request, so this terminal A observation cannot act on + // selected B. #expect(!SubmissionHandleStub.paths().contains("/v1/runs/run_b/cancel")) session.reset() } @@ -505,4 +508,18 @@ struct RunSubmissionTests { #expect(session.transcript.runState != .failed) session.reset() } + + @Test("timeout transport has no raw submission-handle API") + func timeoutTransportSurfaceCannotDriftBackToRawHandle() throws { + let macappRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let controls = try String( + contentsOf: macappRoot.appending(path: "Sources/GoCodeUI/RunSession+RunControls.swift") + ) + #expect(!controls.contains("consumeTimedOutSubmissionTransport")) + #expect(!controls.contains("armSubmissionTimeout")) + #expect(controls.contains("submissionTimeoutGate")) + } }