Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions docs/logs/engineering-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
7 changes: 4 additions & 3 deletions docs/logs/long-term-thinking-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions docs/logs/observational-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
17 changes: 14 additions & 3 deletions docs/logs/system-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
18 changes: 14 additions & 4 deletions docs/plans/2026-08-03-issue-1136-timeout-authority-impact-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
41 changes: 31 additions & 10 deletions docs/plans/2026-08-03-issue-1136-timeout-authority-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
9 changes: 8 additions & 1 deletion macapp/Sources/GoCodeUI/ProjectSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 62 additions & 11 deletions macapp/Sources/GoCodeUI/RunSession+RunControls.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
31 changes: 28 additions & 3 deletions macapp/Sources/GoCodeUI/RunSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -572,6 +596,7 @@ public final class RunSession {
task.cancel()
}
submissionStreamTasks = [:]
submissionTimeoutGates = [:]
activeSubmission?.markDisplaced()
activeSubmission = nil
activeRunIDs = []
Expand Down
29 changes: 28 additions & 1 deletion macapp/Sources/GoCodeUI/RunSubmission.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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)
}

Expand All @@ -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)
Expand Down
Loading
Loading