diff --git a/docs-ai/047-cross-agent-handoff/002-resume-authored-handoff.md b/docs-ai/047-cross-agent-handoff/002-resume-authored-handoff.md index 7a4bea6d..af2184be 100644 --- a/docs-ai/047-cross-agent-handoff/002-resume-authored-handoff.md +++ b/docs-ai/047-cross-agent-handoff/002-resume-authored-handoff.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | Implemented | | **Anchor date** | 2026-07-18 | -| **Primary PRs** | #554 (artifact base); follow-up PR unassigned | +| **Primary PRs** | #554 (artifact base); #603 (resume-authored handoff) | | **Related** | [047 plan](000-plan.md), [048 runtime adapters](../048-agent-runtime-adapters/000-plan.md), [045 native-agent-session-detection](../045-native-agent-session-detection/000-plan.md), #473, `docs/components/handoff.md` | ## Context diff --git a/docs-ai/047-cross-agent-handoff/003-plan-calibration.md b/docs-ai/047-cross-agent-handoff/003-plan-calibration.md index a9beb59c..e670133e 100644 --- a/docs-ai/047-cross-agent-handoff/003-plan-calibration.md +++ b/docs-ai/047-cross-agent-handoff/003-plan-calibration.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | Implemented (audit items marked open remain open) | | **Anchor date** | 2026-07-20 | -| **Primary PRs** | Follow-up PR unassigned (feature/hand-off branch) | +| **Primary PRs** | #603 | | **Related** | [047 plan](000-plan.md), [047.002 resume-authored handoff](002-resume-authored-handoff.md), [048 runtime adapters](../048-agent-runtime-adapters/000-plan.md) | ## Context diff --git a/docs-ai/048-agent-runtime-adapters/000-plan.md b/docs-ai/048-agent-runtime-adapters/000-plan.md index 605b65b9..ed6d9446 100644 --- a/docs-ai/048-agent-runtime-adapters/000-plan.md +++ b/docs-ai/048-agent-runtime-adapters/000-plan.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | Implemented | | **Anchor date** | 2026-07-18 | -| **Primary PRs** | Follow-up PR unassigned | +| **Primary PRs** | #603 | | **Related** | [045 native-agent-session-detection](../045-native-agent-session-detection/000-plan.md), [047 cross-agent-handoff](../047-cross-agent-handoff/000-plan.md), #473 | ## Background diff --git a/docs-ai/048-agent-runtime-adapters/001-action.md b/docs-ai/048-agent-runtime-adapters/001-action.md index 1d0633d1..120f0791 100644 --- a/docs-ai/048-agent-runtime-adapters/001-action.md +++ b/docs-ai/048-agent-runtime-adapters/001-action.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | Completed | | **Anchor date** | 2026-07-18 | -| **Primary PRs** | Follow-up PR unassigned | +| **Primary PRs** | #603 | | **Plan** | [000-plan.md](000-plan.md) | | **Related** | [047.002 resume-authored handoff](../047-cross-agent-handoff/002-resume-authored-handoff.md) | diff --git a/docs-ai/049-agents-toolbar-entry/000-plan.md b/docs-ai/049-agents-toolbar-entry/000-plan.md index 4df000d5..980797c2 100644 --- a/docs-ai/049-agents-toolbar-entry/000-plan.md +++ b/docs-ai/049-agents-toolbar-entry/000-plan.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | PR1 implemented on `feature/hand-off` (awaiting review); PR2 pending | | **Anchor date** | 2026-07-20 | -| **Primary PRs** | Unassigned (feature/hand-off branch) | +| **Primary PRs** | #603 | | **Related** | [047 cross-agent-handoff](../047-cross-agent-handoff/000-plan.md), [047.003 plan calibration](../047-cross-agent-handoff/003-plan-calibration.md), [048 agent-runtime-adapters](../048-agent-runtime-adapters/000-plan.md), [031 command-palette-architecture](../031-command-palette-architecture/000-plan.md) | ## Background @@ -59,10 +59,10 @@ plumbing from 047.003; no new pipeline work is expected. - otherwise: `Hands this task to another agent in a new tab` 4. **HUD choose step = target list + one secondary row, zero options.** Targets from `AgentRuntimeAdapterRegistry.launchableAgents` (same-agent row - stays, labeled as a fresh-session restart). Last row: "Only brief, don't - hand off" (the former save action; its only UI home). The prepare checkbox - from the prototype is dropped — timing decisions move to the execution - step's Skip. Config transparency: target rows state launch facts + stays, labeled as a fresh-session restart). Last row: "Only save progress, + don't hand off" (the former save action; its only UI home). The prepare + checkbox from the prototype is dropped — timing decisions move to the + execution step's Skip. Config transparency: target rows state launch facts (`Launches with its default setup` / `Will bypass permissions (carried over from codex)`), read-only. 5. **Execution step semantics.** Four stages; only briefing is long (≤2 min). @@ -171,3 +171,4 @@ plumbing from 047.003; no new pipeline work is expected. by the 2-minute resume timeout. The hard prerequisite — reducer-owned execution state with the HUD as a projection — shipped in PR1, so a future wave starts with zero rework if real usage surfaces the pain. +- Updated 2026-07-20: cancel/Skip now make reducer acceptance the boundary for transcribing a preparation reply, and Skip gains a keyboard path — see [002-briefing-cancellation.md](002-briefing-cancellation.md). diff --git a/docs-ai/049-agents-toolbar-entry/001-action.md b/docs-ai/049-agents-toolbar-entry/001-action.md index 619cf9bd..6941affc 100644 --- a/docs-ai/049-agents-toolbar-entry/001-action.md +++ b/docs-ai/049-agents-toolbar-entry/001-action.md @@ -30,8 +30,13 @@ and log lines as the CLI). Skip cancels the briefing child process and continues mechanically; Cancel during briefing aborts with zero artifact and log changes; stage guards drop racing briefing results; a key-capture - NSView keeps arrows/Return/Escape out of the live terminal. Execution + NSView keeps arrows/Return/Escape/**S** out of the live terminal. Execution state is reducer-owned — the HUD is a projection. +- **Briefing cancellation integrity**: source replies remain transient until + `HandoffHudFeature` accepts them while still briefing; late replies after + Skip or Cancel cannot write `current.md`. Skip is also available through + the HUD's `S` shortcut, which the key-capture view consumes before input can + reach the live terminal. See [002-briefing-cancellation.md](002-briefing-cancellation.md). - **Palette convergence**: one `Hand Off…` row opening the HUD; the two direct-execution rows are gone, and the post-palette focus restore skips hand-off so the HUD keeps first responder. diff --git a/docs-ai/049-agents-toolbar-entry/002-briefing-cancellation.md b/docs-ai/049-agents-toolbar-entry/002-briefing-cancellation.md new file mode 100644 index 00000000..609694ef --- /dev/null +++ b/docs-ai/049-agents-toolbar-entry/002-briefing-cancellation.md @@ -0,0 +1,47 @@ +# 049.002 — Briefing Cancellation Integrity + +| | | +| --- | --- | +| **Status** | Implemented | +| **Anchor date** | 2026-07-20 | +| **Primary PRs** | #603 | +| **Related** | [049 plan](000-plan.md), [047 cross-agent handoff](../047-cross-agent-handoff/000-plan.md), `docs/components/handoff.md` | + +## Context + +The PR1 HUD promises that Skip preserves the existing progress summary and that Cancel +leaves the handoff artifact untouched. The preparation effect currently transcribes a valid +source reply before the reducer accepts its completion action. A reply that wins a cancellation +race can therefore rewrite `current.md` after Skip or Cancel. + +The HUD's key-capture view also swallows Tab and every unhandled key. Arrow keys and Return can +choose a target, but a keyboard-only user has no route to the in-flight Skip control. + +## Change + +- Separate resume reply collection from preparation-reply transcription. The HUD commits a + reply only after its reducer confirms the run is still in the briefing stage; cancelled or + skipped runs discard late replies without filesystem changes. +- Keep CLI preparation behavior unchanged: its synchronous flow commits an accepted reply before + mechanical save/archive work begins. +- Add a HUD-specific Skip shortcut and advertise it in the control's tooltip, while preserving + the terminal-isolation behavior of the key capture view. +- Add race coverage with a cancellation-insensitive resume dependency; existing + reducer coverage exercises the shared Skip action used by the keyboard path. + +## Decisions + +- **The reducer is the commit authority for HUD briefing.** Cancellation is a user-visible + transaction boundary, so a detached background task cannot own the artifact mutation. +- **Retain the shared coordinator for persistence.** Only the timing changes: callers explicitly + commit a reply instead of `prepare` mutating as part of collection. +- **Use an explicit Skip hotkey rather than forwarding arbitrary keys.** It keeps live-terminal + input isolated while restoring a complete keyboard path for the one long-running decision. + +## Verification + +- `HandoffHudFeatureTests` covers a resume dependency that returns a valid reply + after Cancel; the handoff directory remains absent. +- Focused handoff suites pass, including the HUD state machine and artifact + persistence paths. +- `make check` passes. diff --git a/docs/components/handoff.md b/docs/components/handoff.md index 730847f6..97f9d132 100644 --- a/docs/components/handoff.md +++ b/docs/components/handoff.md @@ -155,11 +155,11 @@ The HUD runs in two steps: adapter rules as `prowl handoff to`. 2. **Run** — staged progress: collect a progress summary from the source agent, save context, archive, launch. While the source agent writes its - summary you can **Skip** (continue immediately with the existing notes and - fresh repo state, `preparation=skipped`) or **Cancel** (abort entirely — - the artifact is untouched and nothing is logged, like Ctrl-C on the CLI). - After the summary the remaining steps are sub-second and cannot be - interrupted. + summary you can **Skip** with **S** (continue immediately with the existing + notes and fresh repo state, `preparation=skipped`) or **Cancel** (abort + entirely — the artifact is untouched and nothing is logged, like Ctrl-C on + the CLI). After the summary the remaining steps are sub-second and cannot + be interrupted. The Command Palette (`⌘P`) offers the same flow as a single **Hand Off…** row for any selected workspace, git repository, worktree, or plain folder; diff --git a/supacode/Domain/Handoff/HandoffCoordinator.swift b/supacode/Domain/Handoff/HandoffCoordinator.swift index d1ef700e..7d9f2e9d 100644 --- a/supacode/Domain/Handoff/HandoffCoordinator.swift +++ b/supacode/Domain/Handoff/HandoffCoordinator.swift @@ -9,6 +9,15 @@ nonisolated struct HandoffSourceContext: Sendable, Equatable { let session: AgentSession? } +/// A source preparation reply before it is accepted for persistence. +/// HUD callers keep this transient until their reducer accepts the briefing +/// completion; a cancelled HUD must never let a late reply mutate the artifact. +nonisolated enum HandoffPreparationReply: Equatable, Sendable { + case reply(String) + case skipped + case failed +} + /// Shared orchestration core for a handoff: optional source-authored /// preparation, the mechanical context save, the pre-launch archive, and the /// transition log line. The CLI's `HandoffCommandHandler` and the Command @@ -49,32 +58,43 @@ nonisolated struct HandoffCoordinator: Sendable { case failed } - /// Resume the source read-only and transcribe its validated reply into - /// `current.md`. A nil request means preparation is skipped: no safe - /// session, an unsupported adapter, or `--no-prepare`. - /// `current.md` uses snapshot semantics: each preparation rewrites it from - /// the source session's own knowledge. History carries through the reading - /// chain — every receiver reads the previous snapshot when it takes over — - /// and full copies live under `archive/`, so no earlier text is embedded - /// or carried forward mechanically (stale carried-over sections would - /// mislead the next agent). - func prepare(_ request: AgentResumeRequest?, now: Date) async -> HandoffPreparationOutcome { + /// Resume the source read-only without mutating the artifact. Staged callers + /// must explicitly accept and apply a reply so cancellation remains a real + /// filesystem transaction boundary. + func collectPreparation(_ request: AgentResumeRequest?) async -> HandoffPreparationReply { guard let request else { return .skipped } - let store = self.store do { - let reply = try await resume(request, store.rootURL) - return await Task.detached { - store.applyPreparationReply(reply, now: now) ? HandoffPreparationOutcome.completed : .failed - }.value + return .reply(try await resume(request, store.rootURL)) } catch { return .failed } } + /// Validate and transcribe an accepted preparation reply into `current.md`. + /// A cancelled task never writes, including when its resume dependency + /// returned a reply after observing cancellation late. + func applyPreparation(_ reply: HandoffPreparationReply, now: Date) -> HandoffPreparationOutcome { + switch reply { + case .reply(let text): + guard !Task.isCancelled else { return .skipped } + return store.applyPreparationReply(text, now: now) ? .completed : .failed + case .skipped: + return .skipped + case .failed: + return .failed + } + } + + /// Resume, then immediately apply the reply for single-shot callers such as + /// the CLI. HUD callers use `collectPreparation` and `applyPreparation` + /// separately so reducer state decides whether a reply may be persisted. + func prepare(_ request: AgentResumeRequest?, now: Date) async -> HandoffPreparationOutcome { + applyPreparation(await collectPreparation(request), now: now) + } + /// Refresh generated context with an already-decided preparation outcome. - /// Staged callers (the HUD) run `prepare` separately so they can report - /// progress and support Skip; `save`/`makeTransitionArtifacts` compose this - /// for single-shot callers. + /// Staged callers collect a reply before the reducer accepts it, while + /// `save`/`makeTransitionArtifacts` compose collection and persistence. func saveArtifact( outgoingAgent: String?, sessionContext: HandoffStore.SessionContext?, diff --git a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift index 2f512d15..aa94f1ec 100644 --- a/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift +++ b/supacode/Features/HandoffHud/Reducer/HandoffHudFeature.swift @@ -162,6 +162,7 @@ struct HandoffHudFeature { case skipBriefingTapped case cancelTapped case closeTapped + case briefingReplyReceived(HandoffPreparationReply) case briefingFinished(HandoffPreparationOutcome) case savingFinished case archivingFinished @@ -201,11 +202,33 @@ struct HandoffHudFeature { guard state.isChoosing, state.targets.indices.contains(state.selectedIndex) else { return .none } return startRun(&state, target: state.targets[state.selectedIndex]) - case .briefingFinished(let outcome): + case .briefingReplyReceived(let reply): guard var run = state.run, run.stage == .briefing else { return .none } - run.preparation = outcome + switch reply { + case .reply: + // Leaving briefing is the reducer's commit decision. A late reply + // after Skip or Cancel is ignored before it reaches the filesystem. + run.stage = .saving + state.phase = .running(run) + let coordinator = makeCoordinator(state) + let timestamp = run.startedAt + return .run { send in + let outcome = coordinator.applyPreparation(reply, now: timestamp) + await send(.briefingFinished(outcome)) + } + .cancellable(id: BriefingCancelID(worktreeID: state.worktree.id), cancelInFlight: true) + + case .skipped: + run.preparation = .skipped + case .failed: + run.preparation = .failed + } return advance(&state, run: run, to: .saving) + case .briefingFinished(let outcome): + guard var run = state.run, run.stage == .saving else { return .none } + run.preparation = outcome + return advance(&state, run: run, to: .saving) case .savingFinished: guard let run = state.run, run.stage == .saving else { return .none } if run.target.kind == .briefOnly { @@ -290,10 +313,9 @@ struct HandoffHudFeature { } state.phase = .running(run) let coordinator = makeCoordinator(state) - let timestamp = run.startedAt return .run { send in - let outcome = await coordinator.prepare(request, now: timestamp) - await send(.briefingFinished(outcome)) + let reply = await coordinator.collectPreparation(request) + await send(.briefingReplyReceived(reply)) } .cancellable(id: BriefingCancelID(worktreeID: state.worktree.id), cancelInFlight: true) } diff --git a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift index a183d91c..21182629 100644 --- a/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift +++ b/supacode/Features/HandoffHud/Views/HandoffHudOverlayView.swift @@ -65,7 +65,8 @@ private struct HandoffHudCard: View { HandoffHudKeyCaptureView( onMove: { delta in store.send(.moveSelection(delta: delta)) }, onConfirm: { confirmForCurrentPhase() }, - onEscape: { escapeForCurrentPhase() } + onEscape: { escapeForCurrentPhase() }, + onSkip: { store.send(.skipBriefingTapped) } ) } .frame(maxWidth: 560) @@ -322,7 +323,8 @@ private struct HandoffHudRunView: View { onSkip() } .controlSize(.small) - .help("Hand off now with the current summary and repo state") + .keyboardShortcut("s") + .help("Hand off now with the current summary and repo state (S)") } } } @@ -429,12 +431,14 @@ private struct HandoffHudKeyCaptureView: NSViewRepresentable { let onMove: (Int) -> Void let onConfirm: () -> Void let onEscape: () -> Void + let onSkip: () -> Void func makeNSView(context: Context) -> KeyCaptureNSView { let view = KeyCaptureNSView() view.onMove = onMove view.onConfirm = onConfirm view.onEscape = onEscape + view.onSkip = onSkip return view } @@ -442,6 +446,7 @@ private struct HandoffHudKeyCaptureView: NSViewRepresentable { nsView.onMove = onMove nsView.onConfirm = onConfirm nsView.onEscape = onEscape + nsView.onSkip = onSkip nsView.grabFocusIfNeeded() } @@ -449,6 +454,7 @@ private struct HandoffHudKeyCaptureView: NSViewRepresentable { var onMove: ((Int) -> Void)? var onConfirm: (() -> Void)? var onEscape: (() -> Void)? + var onSkip: (() -> Void)? override var acceptsFirstResponder: Bool { true } @@ -463,6 +469,11 @@ private struct HandoffHudKeyCaptureView: NSViewRepresentable { } override func keyDown(with event: NSEvent) { + if event.charactersIgnoringModifiers?.lowercased() == "s" { + onSkip?() + return + } + switch event.keyCode { case 126: // up arrow onMove?(-1) diff --git a/supacodeTests/HandoffHudFeatureTests.swift b/supacodeTests/HandoffHudFeatureTests.swift index 132ebd9e..99905959 100644 --- a/supacodeTests/HandoffHudFeatureTests.swift +++ b/supacodeTests/HandoffHudFeatureTests.swift @@ -178,7 +178,6 @@ struct HandoffHudFeatureTests { initial.selectedIndex = claudeIndex let sent = LockIsolated<[TerminalClient.Command]>([]) - let resumed = LockIsolated(nil) let startedAt = Date(timeIntervalSince1970: 1_760_000_000) let store = TestStore(initialState: initial) { @@ -188,12 +187,7 @@ struct HandoffHudFeatureTests { $0[TerminalClient.self].send = { command in sent.withValue { $0.append(command) } } - $0[AgentRuntimeClient.self] = AgentRuntimeClient( - resume: { request, _ in - resumed.setValue(request) - return Self.usableReply - } - ) + $0[AgentRuntimeClient.self] = AgentRuntimeClient(resume: { _, _ in Self.usableReply }) } let claudeTarget = initial.targets[claudeIndex] @@ -207,6 +201,16 @@ struct HandoffHudFeatureTests { ) ) } + await store.receive(\.briefingReplyReceived) { + $0.phase = .running( + HandoffHudRun( + target: claudeTarget, + startedAt: startedAt, + stages: [.briefing, .saving, .archiving, .launching], + stage: .saving + ) + ) + } await store.receive(\.briefingFinished) { $0.phase = .running( HandoffHudRun( @@ -244,8 +248,6 @@ struct HandoffHudFeatureTests { $0.phase = .finished(.handedOff(agentDisplayName: "Claude Code")) } - // Source session was resumed for the brief and transcribed. - #expect(resumed.value?.agent == .codex) let handoffStore = HandoffStore(rootURL: root) let current = try String(contentsOf: handoffStore.currentURL, encoding: .utf8) #expect(current.contains("Finish the HUD.")) @@ -295,6 +297,7 @@ struct HandoffHudFeatureTests { store.exhaustivity = .off await store.send(.confirmSelection) + await store.receive(\.briefingReplyReceived) await store.receive(\.briefingFinished) await store.receive(\.savingFinished) { $0.phase = .finished(.briefSaved) @@ -423,6 +426,50 @@ struct HandoffHudFeatureTests { #expect(!FileManager.default.fileExists(atPath: handoffDirectory.path(percentEncoded: false))) } + @Test(.dependencies) func lateCancellationInsensitiveReplyAfterCancelDoesNotWriteArtifact() async throws { + let root = try makeTempRoot() + defer { remove(root) } + var initial = try #require( + HandoffHudFeature.State.make(worktree: makeWorktree(root: root), source: makeSourceContext()) + ) + let claudeIndex = try #require(initial.targets.firstIndex { $0.agent == .claude }) + initial.selectedIndex = claudeIndex + let replyContinuation = LockIsolated?>(nil) + + let store = TestStore(initialState: initial) { + HandoffHudFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 1_760_000_000) + $0[TerminalClient.self].send = { _ in } + $0[AgentRuntimeClient.self] = AgentRuntimeClient( + resume: { _, _ in + await withCheckedContinuation { continuation in + replyContinuation.setValue(continuation) + } + } + ) + } + store.exhaustivity = .off + + await store.send(.confirmSelection) + while replyContinuation.value == nil { + await Task.yield() + } + await store.send(.cancelTapped) + await store.receive(\.delegate.dismiss) + + replyContinuation.withValue { continuation in + continuation?.resume(returning: Self.usableReply) + continuation = nil + } + for _ in 0..<10 { + await Task.yield() + } + + let handoffDirectory = HandoffStore(rootURL: root).handoffDirectory + #expect(!FileManager.default.fileExists(atPath: handoffDirectory.path(percentEncoded: false))) + } + @Test(.dependencies) func lateBriefingResultAfterSkipIsIgnored() async throws { let root = try makeTempRoot() defer { remove(root) } @@ -444,10 +491,9 @@ struct HandoffHudFeatureTests { await store.send(.confirmSelection) await store.send(.skipBriefingTapped) await store.receive(\.launchFinished) - // A racing completion that arrives after Skip must not resurrect the run. - await store.send(.briefingFinished(.completed)) - await store.finish() - + // A racing reply that arrives after Skip must not resurrect the run or + // transcribe source prose after the mechanical hand-off has started. + await store.send(.briefingReplyReceived(.reply(Self.usableReply))) let log = try String(contentsOf: HandoffStore(rootURL: root).logURL, encoding: .utf8) #expect(log.contains("preparation=skipped")) #expect(!log.contains("preparation=completed"))