diff --git a/.agents/skills/roundfix/SKILL.md b/.agents/skills/roundfix/SKILL.md index 7418f437..36556c09 100644 --- a/.agents/skills/roundfix/SKILL.md +++ b/.agents/skills/roundfix/SKILL.md @@ -532,25 +532,45 @@ Stop Request recorded; the Run stops after the current Work Item settles. The owning Run finishes the in-flight Work Item's verification, settlement, and commit boundary first, then ends Stopped through the normal outcome path. - -Use `roundfix stop --force` only for a dead, stuck, or runaway Run. It cancels -the Agent Session best-effort, completes the Run as Stopped immediately, and -releases Active Run locks. Cancel failures are warnings on stderr and never -block force completion. Force stop then closes discovered roundfix Agent -Sessions for the Run, including Run-level and per-Task sessions. Successful -session closes and close failures are reported on stderr with these shapes: - -```text -roundfix: closed session -roundfix: could not close session : -``` - -The force-stop report title includes: +During a watch Run's Review Source status, retry, quiet-period, or +merge-readiness wait, the owner checks for the Stop Request before the next +status access and after each interruptible sleep. It reaches Stopped by the +next configured poll boundary. After observing the request, it does not run +another fetch, check, commit, push, or Review Source mutation. + +Use `roundfix stop --force` only for a dead, stuck, or runaway Run. It first +validates the recorded owner PID, terminates the recorded owner process, and +proves that process exited. Until owner exit is proven, registered Agent +Sessions and their Agent Selection lifecycles remain active. + +After owner exit proof, Force Stop cancels and closes only registered Agent +Sessions whose latest Agent Selection lifecycle is active. No active lifecycle +record means no session action, and an already-absent registered session is an +idempotent cleanup result. Other cleanup failures remain visible as secondary +warnings. + +Only after owner exit proof does Roundfix complete the Run as Stopped, release +its Active Run lock, and reap eligible kept terminal Worktrees. If owner exit +cannot be proven, Force Stop prints no stdout success report; its diagnostic +names the Run ID, owner PID, and failed process-control step. The Run remains +Active with its Agent Sessions unchanged and its Active Run lock retained. +Inspect it with `roundfix runs list --state active`, resolve the reported +owner-process failure, and retry `roundfix stop --force `. + +After owner exit proof and successful Stopped completion, the force-stop report +title includes: ```text Roundfix Run force-stopped ``` +Terminal completion is compare-and-set. The winning transition alone publishes +the terminal outcome event and notification. Repeating Force Stop for an +already Stopped Run reports the stored outcome without repeating process, +session, event, or notification actions. A different terminal outcome is +rejected and preserved; a losing owner observes that stored outcome and exits +without publishing another terminal event or notification. + When an Active-Run lock records an owner PID and Roundfix can prove that owner process no longer exists, preflight reclaims the orphan automatically: the Run settles Failed, the Run Event Journal records the reclamation, one stderr @@ -592,12 +612,13 @@ The console log path is under the Artifact Directory at stderr, Agent output, and terminal outcome messages. `Follow` is the Attach surface; `Stop` is the Stop Command surface. Detached Runs behave as normal non-TTY Runs after startup: Run Events, Worktrees, integration, outcomes, and -locks keep their normal contracts. The detached child owns completion and sends -the configured outcome notification when the Run reaches its terminal outcome; -use that notification as the unattended-Run signal. Supervisors and scripts -follow `roundfix events --follow` for JSONL state changes, use -`roundfix attach ` for the human Live Run View, and treat the console -log as a compact text record rather than a state API. +locks keep their normal contracts. The detached child that wins terminal +completion sends the configured outcome notification; use that notification as +the unattended-Run signal. A competing completion observes the stored outcome +and does not send another notification. Supervisors and scripts follow +`roundfix events --follow` for JSONL state changes, use the human Live +Run View with `roundfix attach `, and treat the console log as a compact +text record rather than a state API. Detach implies non-interactive mode. `--interactive` is rejected before Run creation, and `--no-input` is implied. Startup uses a two-phase handshake: the @@ -617,10 +638,11 @@ roundfix: Detached Run child exited before the handshake (); con roundfix: Detached Run child exited before the handshake () and produced no output ``` -Operational Runs that reach a terminal outcome through `resolve`, `watch`, or -`implement` send exactly one outcome notification. `fetch`, `settle`, -`archive`, and commands that create no Run do not notify. Notification failures -write one stderr warning shaped as +The winning terminal transition for an operational Run through `resolve`, +`watch`, or `implement` sends exactly one outcome notification. Identical +completion replay and conflicting completion do not republish it. `fetch`, +`settle`, `archive`, and commands that create no Run do not notify. +Notification failures write one stderr warning shaped as `roundfix: outcome notification failed: ` and one Daemon-source Run Event; they never change the Run report, terminal outcome, or exit code. @@ -1193,8 +1215,10 @@ outcome and never opens pull requests (ADR-0021). the current repository. This resolves that repository's Spec target and records a Stop Request; the Run stops after the current Work Item settles. Use `roundfix stop --force --spec ` only for a dead, stuck, or runaway - Run; it cancels the Agent Session best-effort, completes the Run Stopped, - releases its lock immediately, and reaps empty terminal worktree debris. + Run. It proves the recorded owner exited before cleaning up registered + active Agent Sessions, and reports Stopped and releases the Active Run lock + only after that proof. A failed proof leaves the Run Active with its Agent + Sessions unchanged and its lock retained. ## Driving a Spec implementation loop @@ -1224,8 +1248,9 @@ the Implement, Attach, Settle, Stop, and Archive commands documented above. terminal, discover the Run with the bounded `roundfix runs list` or open the Run Browser with `roundfix attach`. Attach replays the Run Event Journal and follows new events; `q` or `Ctrl-C` detaches and never stops - the Run. The detached child sends the configured outcome notification at - the terminal outcome, which is the unattended-Run signal. + the Run. The detached child sends the configured outcome notification only + when it wins terminal completion; that notification is the unattended-Run + signal. 4. **Detect the terminal outcome.** The Run ends with exactly one stdout outcome line in the console log: diff --git a/docs/_inbox/2026-07-24-spec-implementation-sequence.md b/docs/_inbox/2026-07-24-spec-implementation-sequence.md index e11fb780..8e605e62 100644 --- a/docs/_inbox/2026-07-24-spec-implementation-sequence.md +++ b/docs/_inbox/2026-07-24-spec-implementation-sequence.md @@ -41,7 +41,7 @@ That earlier sequence remains as historical context. 4. **[0036 — Doctor skill readiness](../specs/0036-doctor-skill-readiness/_prd.md).** Its Spec 0041 prerequisite is already archived as completed, so its existing Task Graph is ready after the baseline lane. -5. **[0037 — Terminal outcome integrity](../specs/0037-terminal-outcome-integrity/_prd.md).** +5. **[0037 — Terminal outcome integrity](../specs/_archived/0037-terminal-outcome-integrity/_prd.md).** Generate and approve its Task Graph, then implement it before Specs 0038 and 0039. It owns the guarded terminal transitions and stop-aware behavior those Specs consume. diff --git a/docs/findings/2026-07-16-vortex-pr87-detached-watch-notification.md b/docs/findings/2026-07-16-vortex-pr87-detached-watch-notification.md index 790e32e1..09117cc9 100644 --- a/docs/findings/2026-07-16-vortex-pr87-detached-watch-notification.md +++ b/docs/findings/2026-07-16-vortex-pr87-detached-watch-notification.md @@ -157,7 +157,7 @@ Environment: ## Planned resolution -- Spec [0037 Terminal outcome integrity](../specs/0037-terminal-outcome-integrity/_prd.md) +- Spec [0037 Terminal outcome integrity](../specs/_archived/0037-terminal-outcome-integrity/_prd.md) owns finding 4: registered Agent Session cleanup, primary-before-secondary diagnostics, and winner-only terminal completion. - Spec diff --git a/docs/specs/0037-terminal-outcome-integrity/task_01.md b/docs/specs/0037-terminal-outcome-integrity/task_01.md deleted file mode 100644 index b3c7e56f..00000000 --- a/docs/specs/0037-terminal-outcome-integrity/task_01.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -task: task_01 -spec: 0037-terminal-outcome-integrity -status: pending -type: data -complexity: high ---- - -# Task 01: Guard terminal completion and reconciliation - -## Overview - -Make terminal Run completion an atomic first-writer-wins operation while -preserving identical replay and the one evidence-backed Integration Pending -reconciliation. This slice is complete when competing completion attempts -cannot overwrite the stored outcome, release its lock twice, or publish -conflicting durable evidence. - -## Requirements - -1. MUST make ordinary Run completion update only a non-terminal Run. -2. MUST return an explicit transitioned result for the winning completion and - an idempotent non-transition result for an identical replay. -3. MUST return a typed conflict for a different requested terminal outcome - without changing the Run, completion timestamp, Active Run lock, or journal. -4. MUST release the Active Run lock only in the transaction that wins ordinary - terminal completion. -5. MUST provide the sole guarded Integration Pending to Clean reconciliation, - requiring the recorded Run and target heads and journaling both outcomes. -6. MUST reject every other terminal-to-terminal transition. -7. MUST wrap database and transaction failures with the operation and Run ID. - -## Subtasks - -- [ ] Add explicit completion and conflict result contracts. -- [ ] Implement compare-and-set terminal persistence and winner-only lock release. -- [ ] Implement evidence-backed Integration Pending reconciliation. -- [ ] Add idempotent replay and conflicting-outcome coverage. -- [ ] Add deterministic concurrent-completion coverage. -- [ ] Prove reconciliation rejects stale or invalid source outcomes. - -## Acceptance Criteria - -- [ ] One non-terminal completion stores its outcome and reports transitioned. -- [ ] Repeating the same outcome changes no persisted field or journal entry. -- [ ] A competing outcome returns the typed conflict and leaves the winner, - timestamp, lock state, and event history unchanged. -- [ ] A deterministic race produces exactly one winner and one stable terminal - row. -- [ ] Integration Pending becomes Clean only with complete reconciliation - evidence recorded transactionally. -- [ ] No other terminal outcome can be rewritten. - -## Context - -- instruction: `.agents/skills/coding-guidelines/SKILL.md` -- instruction: `.agents/skills/golang-error-handling/SKILL.md` -- instruction: `.agents/skills/golang-testing/SKILL.md` -- instruction: `.agents/skills/testing-boss/SKILL.md` -- interface: `internal/store/store.go` -- interface: `internal/store/store_test.go` -- interface: `internal/store/journal.go` -- interface: `internal/store/journal_test.go` - -## Verification - -- `rtk go test ./internal/store -run 'Test(CompleteRun|TerminalOutcome|ReconcileIntegration)' -count=1` - — expected: winner, replay, conflict, lock, and guarded reconciliation cases - pass. -- `rtk go test -race ./internal/store -run 'Test(CompleteRun|TerminalOutcome)' -count=1` - — expected: concurrent competing completion has one race-free winner. - -## References - -- `_prd.md` → Goals 1; User Stories 1, 3, and 5; Core Features 1–2 and 7; - Success Metrics. -- `_techspec.md` → Interfaces; Data Models; Build Order 1. -- `../../adr/0052-run-completion-is-compare-and-set.md` → terminal - compare-and-set and guarded reconciliation. diff --git a/docs/specs/0037-terminal-outcome-integrity/task_02.md b/docs/specs/0037-terminal-outcome-integrity/task_02.md deleted file mode 100644 index b746b717..00000000 --- a/docs/specs/0037-terminal-outcome-integrity/task_02.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -task: task_02 -spec: 0037-terminal-outcome-integrity -status: pending -type: backend -complexity: medium ---- - -# Task 02: Target only registered Agent Sessions during cleanup - -## Overview - -Use persisted Agent Selection lifecycle evidence as the exclusive Agent Session -cleanup registry. Cleanup becomes deterministic and idempotent: only scopes -whose latest lifecycle is active are targeted, while absent, failed, and -already closed sessions produce no misleading cleanup action. - -## Requirements - -1. MUST query the latest Agent Selection lifecycle per Run scope. -2. MUST return only scopes whose latest persisted lifecycle is active. -3. MUST cancel and close eligible Agent Sessions once in deterministic scope - order. -4. MUST record closed lifecycle after an idempotent close. -5. MUST treat a registered but already absent Agent Session as an idempotent - close. -6. MUST not derive or target an Agent Session without active lifecycle evidence. -7. MUST retain non-absence cleanup failures as secondary diagnostics. - -## Subtasks - -- [ ] Add the latest-active-scope store query. -- [ ] Route cleanup through registered Agent Selection scopes. -- [ ] Make registered-session absence idempotent. -- [ ] Persist closed lifecycle after successful cleanup. -- [ ] Add deterministic ordering and lifecycle-state tests. -- [ ] Remove unconditional Run-wide session-name cleanup. - -## Acceptance Criteria - -- [ ] Active Task, QA, and review scopes are returned once in stable order. -- [ ] Failed, closed, and superseded lifecycle attempts are not targeted. -- [ ] A Run with no active lifecycle record performs zero Agent Session calls. -- [ ] An already absent registered session closes without a warning. -- [ ] Other cleanup failures remain visible and do not invent lifecycle state. -- [ ] Existing Agent Selection history and sensitive-field protections pass. - -## Context - -- instruction: `.agents/skills/coding-guidelines/SKILL.md` -- instruction: `.agents/skills/golang-error-handling/SKILL.md` -- instruction: `.agents/skills/golang-testing/SKILL.md` -- interface: `internal/store/agent_selection.go` -- interface: `internal/store/agent_selection_test.go` -- interface: `internal/cli/cli.go` -- interface: `internal/cli/cli_test.go` -- interface: `internal/agent/sessions.go` - -## Verification - -- `rtk go test ./internal/store -run 'TestAgentSelection.*(Active|Lifecycle|Cleanup|Scope)' -count=1` - — expected: only latest active scopes are returned in deterministic order. -- `rtk go test ./internal/cli -run 'Test.*(RegisteredAgentSession|AgentSessionCleanup|PrimaryFailure)' -count=1` - — expected: cleanup targets registered scopes only and registered absence is - idempotent. - -## References - -- `_prd.md` → Goal 4; User Story 4; Core Features 5–6; Success Metrics. -- `_techspec.md` → Data Models; API Contracts: Agent Session cleanup rules; - Build Order 2. -- `../../adr/0051-tasks-and-qa-own-agent-sessions.md` → Work Item-scoped Agent - Sessions. diff --git a/docs/specs/0037-terminal-outcome-integrity/task_03.md b/docs/specs/0037-terminal-outcome-integrity/task_03.md deleted file mode 100644 index 21e75bb5..00000000 --- a/docs/specs/0037-terminal-outcome-integrity/task_03.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -task: task_03 -spec: 0037-terminal-outcome-integrity -status: pending -type: backend -complexity: high ---- - -# Task 03: Prove owner exit before Force Stop completion - -## Overview - -Reorder Force Stop so Roundfix proves the recorded owner process is absent -before storing Stopped or releasing the Active Run lock. Platform-specific -controllers retain the existing graceful-then-force policy while failures -leave the Run Active with actionable diagnostics. - -## Requirements - -1. MUST cancel registered Agent Sessions before terminating the recorded owner. -2. MUST target only the recorded owner PID and follow existing platform process - boundaries. -3. MUST attempt graceful termination, wait within the bounded stop window, and - use the existing force-kill path when required. -4. MUST report success only after owner-process absence is positively proven. -5. MUST call terminal completion only after that proof. -6. MUST leave the Run Active and its lock retained on permission, unsupported, - reused-PID, or deadline failures. -7. MUST make an already stored Stopped outcome idempotent without repeating - process or Agent Session actions. - -## Subtasks - -- [ ] Introduce the owner-process controller seam. -- [ ] Implement Unix exit proof and preserve Windows build behavior. -- [ ] Reorder Force Stop coordination around owner absence. -- [ ] Add actionable failed-step diagnostics. -- [ ] Cover graceful, forced, permission, deadline, and idempotent paths. -- [ ] Add a real helper-process integration case on Unix. - -## Acceptance Criteria - -- [ ] Successful Force Stop proves owner exit before Stopped is persisted. -- [ ] The Active Run lock remains present until the winning completion. -- [ ] Permission or deadline failure prints no success report, stores no Stopped - outcome, and leaves the Run Active. -- [ ] The diagnostic names the Run, owner PID, failed step, and retained state. -- [ ] A reused or unprovable PID fails closed. -- [ ] Repeating Force Stop for an already Stopped Run performs no process or - session action. -- [ ] Unix integration proves the helper owner is absent before store - completion; non-Unix packages still compile. - -## Context - -- instruction: `.agents/skills/agentic-cli-design/SKILL.md` -- instruction: `.agents/skills/coding-guidelines/SKILL.md` -- instruction: `.agents/skills/golang-context/SKILL.md` -- instruction: `.agents/skills/golang-error-handling/SKILL.md` -- instruction: `.agents/skills/golang-testing/SKILL.md` -- interface: `internal/cli/cli.go` -- interface: `internal/cli/cli_test.go` -- interface: `internal/store/process_unix.go` -- interface: `internal/store/process_windows.go` -- interface: `internal/store/process_other.go` -- interface: `internal/store/process_unix_test.go` - -## Verification - -- `rtk go test ./internal/cli -run 'Test.*ForceStop.*(Owner|Exit|Permission|Deadline|Idempotent|Lock)' -count=1` - — expected: Force Stop proves absence before completion and every failure - retains Active state and lock. -- `rtk go test ./internal/store -run 'Test.*Process|TestStoppedRunReleasesActiveLock' -count=1` - — expected: platform process proof and winner-only lock release pass. -- `rtk go test -race ./internal/cli ./internal/store -run 'Test.*(ForceStop|OwnerProcess)' -count=1` - — expected: stop coordination and exit proof are race-free. - -## References - -- `_prd.md` → Goals 1–2; User Story 1; Core Features 1 and 3; User Experience; - Success Metrics. -- `_techspec.md` → Interfaces: OwnerProcessController; API Contracts: - Force Stop; Build Order 3. -- `../../adr/0044-orphaned-run-locks-are-reclaimed-on-proven-owner-death.md` → - owner-death proof. -- `../../adr/0052-run-completion-is-compare-and-set.md` → completion ordering. diff --git a/docs/specs/0037-terminal-outcome-integrity/task_04.md b/docs/specs/0037-terminal-outcome-integrity/task_04.md deleted file mode 100644 index b0f79eb0..00000000 --- a/docs/specs/0037-terminal-outcome-integrity/task_04.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -task: task_04 -spec: 0037-terminal-outcome-integrity -status: pending -type: backend -complexity: medium ---- - -# Task 04: Interrupt every Review Source wait on Stop Request - -## Overview - -Make the Run Database Stop Request visible at every Review Source wait -boundary. A graceful stop interrupts status, retry, quiet-period, and -Merge-Ready waits by the next polling boundary and starts no later Review -Source or repository mutation. - -## Requirements - -1. MUST expose Stop Request observation through a narrow context-aware source. -2. MUST supply the Store-backed source to every operational watch Run. -3. MUST check the source before each Review Source status access. -4. MUST check again after every interruptible wait, including quiet, retry, and - Merge-Ready sleeps. -5. MUST return the existing stop classification when a request is observed. -6. MUST not perform another fetch, check, artifact write, commit, push, or - Review Source mutation after observation. -7. MUST propagate Store observation failures with Run and operation context. - -## Subtasks - -- [ ] Add the Stop Request source contract to watch dependencies. -- [ ] Wire the Store-backed source into operational watch Runs. -- [ ] Cover every status and sleep boundary. -- [ ] Preserve the existing stopped classification and CLI exit behavior. -- [ ] Add fake-clock tests for each waiting phase. -- [ ] Prove no downstream operation starts after observation. - -## Acceptance Criteria - -- [ ] A request during status wait reaches Stopped by the next poll. -- [ ] Requests during quiet period, transient retry sleep, and Merge-Ready wait - each interrupt their respective boundary. -- [ ] No later Review Source call or repository mutation occurs. -- [ ] Operational runs always provide the Store source; only isolated tests may - omit it. -- [ ] Store read failure is distinguishable from a requested stop. -- [ ] Existing Run Budget and timeout behavior remains unchanged without a stop. - -## Context - -- instruction: `.agents/skills/coding-guidelines/SKILL.md` -- instruction: `.agents/skills/golang-context/SKILL.md` -- instruction: `.agents/skills/golang-error-handling/SKILL.md` -- instruction: `.agents/skills/golang-testing/SKILL.md` -- interface: `internal/watch/watch.go` -- interface: `internal/watch/watch_test.go` -- interface: `internal/cli/cli.go` -- interface: `internal/cli/cli_test.go` -- interface: `internal/store/store.go` - -## Verification - -- `rtk go test ./internal/watch -run 'TestRun.*StopRequest' -count=1` - — expected: every wait boundary observes Stop Request and starts no later - operation. -- `rtk go test ./internal/cli -run 'TestRunWatch.*StopRequest' -count=1` - — expected: Store-backed stop observation reaches the existing Stopped CLI - contract. -- `rtk go test -race ./internal/watch ./internal/cli -run 'Test.*StopRequest' -count=1` - — expected: cancellation and polling remain race-free. - -## References - -- `_prd.md` → Goal 3; User Story 2; Core Feature 4; User Experience; Success - Metrics. -- `_techspec.md` → Interfaces: StopRequestSource; API Contracts: Graceful Stop - Requests; Build Order 4. -- `../../adr/0022-stop-requests-travel-through-the-run-database.md` → durable - Stop Request transport. diff --git a/docs/specs/0037-terminal-outcome-integrity/task_05.md b/docs/specs/0037-terminal-outcome-integrity/task_05.md deleted file mode 100644 index 36a0cd36..00000000 --- a/docs/specs/0037-terminal-outcome-integrity/task_05.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -task: task_05 -spec: 0037-terminal-outcome-integrity -status: pending -type: backend -complexity: high ---- - -# Task 05: Publish only the winning terminal outcome - -## Overview - -Carry the completion transition result through all terminal publication paths -so only the winner emits outcome evidence and notification. Preserve primary -failure ordering while retaining registered-session cleanup problems as -explicit secondary diagnostics. - -## Requirements - -1. MUST publish a terminal outcome Run Event only when completion transitioned - the Run. -2. MUST attempt Run Outcome Notification only for that same winning transition. -3. MUST make identical replays and conflicting losers observe stored state - without duplicate publication. -4. MUST preserve one terminal outcome across Resolve, Watch, Implement, Stop, - and cleanup paths. -5. MUST print and journal the primary failure before any cleanup warning. -6. MUST label cleanup failures as secondary and prevent them from replacing - the terminal reason or exit code. -7. MUST retain best-effort notification failure without changing the Run - outcome. - -## Subtasks - -- [ ] Thread the completion transition result through terminal coordinators. -- [ ] Gate outcome events and notifications on the winning transition. -- [ ] Normalize replay and conflict handling around stored state. -- [ ] Order primary failure evidence before cleanup diagnostics. -- [ ] Cover terminal paths across operational commands. -- [ ] Add deterministic owner-versus-Force-Stop publication coverage. - -## Acceptance Criteria - -- [ ] A completion race stores and publishes exactly one terminal outcome. -- [ ] The losing owner emits no second outcome event or notification. -- [ ] An identical replay is silent and returns the stored result. -- [ ] A conflicting loser cannot change the primary reason, exit code, or - notification context. -- [ ] Cleanup warnings follow the primary failure and are labeled secondary. -- [ ] Notification failure remains a warning and leaves persisted outcome - unchanged. -- [ ] Resolve, Watch, Implement, and Stop terminal regressions pass. - -## Context - -- instruction: `.agents/skills/coding-guidelines/SKILL.md` -- instruction: `.agents/skills/golang-error-handling/SKILL.md` -- instruction: `.agents/skills/golang-testing/SKILL.md` -- instruction: `.agents/skills/testing-boss/SKILL.md` -- interface: `internal/cli/cli.go` -- interface: `internal/cli/cli_test.go` -- interface: `internal/cli/implement.go` -- interface: `internal/cli/implement_test.go` -- interface: `internal/store/journal.go` -- interface: `internal/store/journal_test.go` -- interface: `internal/notify/notify.go` -- interface: `internal/notify/notify_test.go` -- interface: `internal/runevent/event.go` - -## Verification - -- `rtk go test ./internal/cli ./internal/store ./internal/notify ./internal/runevent -run 'Test.*(CompletionWinner|TerminalOutcome|PrimaryFailure|OutcomeNotification)' -count=1` - — expected: only the completion winner publishes and primary diagnostics - precede secondary cleanup warnings. -- `rtk go test -race ./internal/cli ./internal/store -run 'Test.*(CompletionWinner|TerminalOutcome)' -count=1` - — expected: concurrent terminal publication has one race-free winner. - -## References - -- `_prd.md` → Goals 1 and 4; User Stories 3–4; Core Features 6–7; User - Experience; Success Metrics. -- `_techspec.md` → System Architecture: Run Event Journal; Testing Approach; - Build Order 5. -- `../../adr/0052-run-completion-is-compare-and-set.md` → winner-only - publication. diff --git a/docs/specs/0037-terminal-outcome-integrity/task_06.md b/docs/specs/0037-terminal-outcome-integrity/task_06.md deleted file mode 100644 index 18fe7314..00000000 --- a/docs/specs/0037-terminal-outcome-integrity/task_06.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -task: task_06 -spec: 0037-terminal-outcome-integrity -status: pending -type: docs -complexity: medium ---- - -# Task 06: Align terminal-outcome operator guidance - -## Overview - -Publish the completed Force Stop, graceful Stop Request, terminal replay, and -cleanup contracts through supported operator documentation and command help. -This documentation slice leaves every protected Skill path unchanged so the -isolated tooling Task can publish the final Agent-facing wording. - -## Requirements - -1. MUST document that Force Stop reports Stopped only after owner exit proof. -2. MUST document the failed-closed result, retained Active Run lock, and - actionable inspection or retry guidance. -3. MUST document graceful Stop Request observation during Review Source waits. -4. MUST document idempotent same-outcome replay and conflicting-outcome - rejection without exposing internal storage details as user API. -5. MUST describe registered Agent Session cleanup and primary-before-secondary - diagnostics. -6. MUST preserve canonical glossary terms and finding/ADR traceability. -7. MUST leave all protected tooling files unchanged. - -## Subtasks - -- [ ] Update Stop Command and operational usage guidance. -- [ ] Align command help with proof-before-completion behavior. -- [ ] Document graceful watch interruption and terminal replay. -- [ ] Document cleanup eligibility and diagnostic ordering. -- [ ] Resolve glossary, ADR, Spec, and finding links. -- [ ] Check supported examples and terminology. - -## Acceptance Criteria - -- [ ] A reader can predict successful and failed Force Stop state and lock - behavior. -- [ ] Guidance states when a graceful watch stop is observed and what work will - not run afterward. -- [ ] Registered-session cleanup and secondary-warning behavior are explicit. -- [ ] User docs do not promise terminal overwrite or warning-only owner - reclamation. -- [ ] Every cited ADR, finding, and command reference resolves. -- [ ] No protected tooling path changes in this Task. - -## Context - -- instruction: `docs/agents/cli.md` -- instruction: `docs/agents/domain.md` -- instruction: `.agents/skills/tech-writer/SKILL.md` -- interface: `CONTEXT.md` -- interface: `README.md` -- interface: `docs/user-guide/commands.md` -- interface: `docs/user-guide/usage.md` -- interface: `docs/findings/2026-07-16-vortex-pr87-detached-watch-notification.md` - -## Verification - -- `rtk grep -n 'stop --force\\|Force Stop\\|Stop Request' docs/user-guide/commands.md docs/user-guide/usage.md` - — expected: supported guidance covers force and graceful stop contracts. -- `rtk go test ./internal/cli -run 'Test.*Stop.*(Help|Usage|Report)' -count=1` - — expected: command help and public reports match documented behavior. -- `rtk git diff --check` - — expected: documentation contains no whitespace errors. - -## References - -- `_prd.md` → User Stories 1–5; User Experience; Non-Goals; Decisions. -- `_techspec.md` → API Contracts; Risks & Considerations; Build Order 6. -- `../../adr/0052-run-completion-is-compare-and-set.md` → public terminal - integrity contract. diff --git a/docs/specs/0037-terminal-outcome-integrity/task_07.md b/docs/specs/0037-terminal-outcome-integrity/task_07.md deleted file mode 100644 index 033c4674..00000000 --- a/docs/specs/0037-terminal-outcome-integrity/task_07.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -task: task_07 -spec: 0037-terminal-outcome-integrity -status: pending -type: docs -complexity: low ---- - -# Task 07: Align the protected Roundfix Skill pair - -## Overview - -Publish Terminal Outcome Integrity in the canonical and generated Roundfix -Skill after product behavior and public guidance are complete. This is a -tooling-only slice bounded to the two exact files authorized by the maintainer -and this Task file. - -## Requirements - -1. MUST align `.agents/skills/roundfix/SKILL.md` with proof-before-Force-Stop, - graceful wait interruption, registered-session cleanup, and winner-only - terminal publication. -2. MUST apply byte-identical content to `skills/roundfix/SKILL.md`. -3. MUST limit repository changes to those two authorized `SKILL.md` files and - this `task_07.md` file. -4. MUST NOT run `make skills-sync`, because it rewrites every owned Skill - directory; use the read-only sync check. -5. MUST leave code, tests, manifests, public documentation, other owned Skills, - upstream-managed Skills, locks, and recommendation files unchanged. - -## Subtasks - -- [ ] Update the canonical Roundfix Skill terminal-outcome contract. -- [ ] Apply the identical generated Roundfix Skill copy. -- [ ] Verify exact changed-file scope and byte identity. -- [ ] Confirm shipped Skill and full-gate compatibility. - -## Acceptance Criteria - -- [ ] The Roundfix Skill never tells an Agent to accept terminal overwrite or - report Force Stop before owner exit proof. -- [ ] The Skill surfaces graceful interruption and registered-session cleanup - consistently with supported docs. -- [ ] Canonical and generated files are byte-identical. -- [ ] Git evidence contains only the two authorized Skill paths and this Task - file. -- [ ] No other protected or upstream-managed Skill changes. -- [ ] Shipped Skill validation and the complete repository gate pass. - -## Context - -- instruction: `docs/agents/agent-instructions.md` -- instruction: `docs/agents/skill-dispatch.md` -- interface: `.agents/skills/roundfix/SKILL.md` -- interface: `skills/roundfix/SKILL.md` - -## Verification - -- `rtk cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md` - — expected: no output and exit zero. -- `rtk git status --porcelain | rtk awk '{path=substr($0,4); if (path != ".agents/skills/roundfix/SKILL.md" && path != "skills/roundfix/SKILL.md" && path != "docs/specs/0037-terminal-outcome-integrity/task_07.md") {print; bad=1}} END {exit bad}'` - — expected: no changed path outside the authorized pair and this Task file. -- `rtk make skills-sync-check` - — expected: every canonical/generated owned Skill pair has no drift. -- `rtk go run -buildvcs=false ./cmd/roundfix skills check` - — expected: every shipped Roundfix Skill contract passes. -- `rtk git diff --check` - — expected: no whitespace errors. -- `rtk make verify` - — expected: formatting, tests, Skill checks, and build pass. - -## References - -- `_prd.md` → Goals; User Experience; Decisions; Project Constraints. -- `_techspec.md` → Build Order 7; Decisions. -- `docs/agents/spec-routing.md` → tooling authorization and changed-file - postflight. diff --git a/docs/specs/0037-terminal-outcome-integrity/_prd.md b/docs/specs/_archived/0037-terminal-outcome-integrity/_prd.md similarity index 89% rename from docs/specs/0037-terminal-outcome-integrity/_prd.md rename to docs/specs/_archived/0037-terminal-outcome-integrity/_prd.md index b94d8017..ca6618ee 100644 --- a/docs/specs/0037-terminal-outcome-integrity/_prd.md +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/_prd.md @@ -1,10 +1,13 @@ --- spec: 0037-terminal-outcome-integrity -status: active +status: archived created: 2026-07-17 surfaces: [backend, cli, data, docs] +archived: "2026-07-27" +source_slug: 0037-terminal-outcome-integrity --- + # Terminal outcome integrity A force-stopped Run can currently be completed again by its still-running owner, a Stop Request can remain unnoticed throughout a Review Source wait, and cleanup can target an Agent Session that never reached the active lifecycle. The resulting state is unsafe for users and Supervisors: the Run Database can contradict the Stop Command, the released lock can coexist with live work, and secondary cleanup noise can obscure the primary failure. Prior dogfood evidence was absorbed into this Spec and remains in Git history; the still-open behavior is reproduced by the [Vortex detached-watch finding](../../findings/2026-07-16-vortex-pr87-detached-watch-notification.md). @@ -23,8 +26,14 @@ A force-stopped Run can currently be completed again by its still-running owner, completion compare-and-set. Source: `docs/agents/domain.md`. - Tooling authority: applicable — on 2026-07-26, the maintainer expressly authorizes changes to exactly `.agents/skills/roundfix/SKILL.md` and - `skills/roundfix/SKILL.md`; no other protected tooling mutation is - authorized. Source: + `skills/roundfix/SKILL.md`. On 2026-07-27, the maintainer additionally + expressly authorizes the deterministic Skill-digest fallout of that edit in + exactly `internal/baseline/assets/setups/typescript-bun.json`, + `internal/baseline/testdata/catalog.digest`, + `internal/baseline/testdata/catalog.normalized.json`, + `internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json`, and + `internal/baseline/testdata/parity-corpus/v1/manifest.json`. No other + protected tooling mutation is authorized. Source: `docs/agents/agent-instructions.md`. ## Goals @@ -81,6 +90,7 @@ A force-stopped Run can currently be completed again by its still-running owner, - Force Stop fails closed when owner exit cannot be proven; it never releases the lock on a warning-only basis. - Agent Selection lifecycle records are the Agent Session registry; no second session table is introduced. - General terminal completion is immutable, with one explicit Integration Pending reconciliation transition. See [ADR-0052](../../adr/0052-run-completion-is-compare-and-set.md). +- The Settle Command never rewrites a settled Run's terminal outcome; a recovered Run keeps its recorded outcome as history, and recovery is reported only through the Settle report and its commits. ## Open Questions diff --git a/docs/specs/0037-terminal-outcome-integrity/_tasks.md b/docs/specs/_archived/0037-terminal-outcome-integrity/_tasks.md similarity index 100% rename from docs/specs/0037-terminal-outcome-integrity/_tasks.md rename to docs/specs/_archived/0037-terminal-outcome-integrity/_tasks.md diff --git a/docs/specs/0037-terminal-outcome-integrity/_techspec.md b/docs/specs/_archived/0037-terminal-outcome-integrity/_techspec.md similarity index 95% rename from docs/specs/0037-terminal-outcome-integrity/_techspec.md rename to docs/specs/_archived/0037-terminal-outcome-integrity/_techspec.md index 363a35d9..1e8d8443 100644 --- a/docs/specs/0037-terminal-outcome-integrity/_techspec.md +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/_techspec.md @@ -24,8 +24,14 @@ Run completion becomes a guarded store operation: the first non-terminal-to-term `docs/agents/domain.md`. - Tooling authority: applicable — on 2026-07-26, the maintainer expressly authorizes changes to exactly `.agents/skills/roundfix/SKILL.md` and - `skills/roundfix/SKILL.md`; no other protected tooling mutation is - authorized. Source: + `skills/roundfix/SKILL.md`. On 2026-07-27, the maintainer additionally + expressly authorizes the deterministic Skill-digest fallout of that edit in + exactly `internal/baseline/assets/setups/typescript-bun.json`, + `internal/baseline/testdata/catalog.digest`, + `internal/baseline/testdata/catalog.normalized.json`, + `internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json`, and + `internal/baseline/testdata/parity-corpus/v1/manifest.json`. No other + protected tooling mutation is authorized. Source: `docs/agents/agent-instructions.md`. ## System Architecture diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/constraint-audit.md b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/constraint-audit.md new file mode 100644 index 00000000..f7dabd4e --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/constraint-audit.md @@ -0,0 +1,71 @@ +# Project Constraint audit + +Build: `ef6eb44ad8951112b1c3641bb7fd21793b440f95` + +## Task status + +All seven canonical Task files carry `status: completed`. + +## Active artifacts + +The PRD and TechSpec each account for all mandatory Project Constraint axes: + +- Identifier strategy: not applicable because this Spec reuses existing Run, + process, Agent Session, Work Item, and scope identities. +- Authentication and HTTP: not applicable because the changed local process, + Run Database, CLI, and polling behavior introduces no authentication or HTTP + contract. +- Active ADR obligations: applicable. ADR-0022, ADR-0044, ADR-0051, and + ADR-0052 resolve and bind Stop Request transport, owner-death proof, + Work Item-scoped Agent Sessions, and terminal compare-and-set behavior. +- Tooling authority: applicable. Both artifacts expressly authorize the + canonical/generated Roundfix Skill pair and the five deterministic + Skill-digest artifacts listed below. + +All four operative source citations resolve under `docs/agents/`. + +## Daemon-owned changed paths + +Task 06 commit `87d0fd0c2adfc8c30348f54e68437181d3ee3003`: + +```text +docs/specs/0037-terminal-outcome-integrity/task_06.md +docs/user-guide/commands.md +docs/user-guide/usage.md +internal/cli/cli.go +internal/cli/cli_test.go +``` + +This commit changes no protected repository-tooling path. + +Task 07 commit `9fc2ba491839ce46aae9b70631c5f9cd3c9f05ba`: + +```text +.agents/skills/roundfix/SKILL.md +docs/specs/0037-terminal-outcome-integrity/task_07.md +internal/baseline/assets/setups/typescript-bun.json +internal/baseline/testdata/catalog.digest +internal/baseline/testdata/catalog.normalized.json +internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json +internal/baseline/testdata/parity-corpus/v1/manifest.json +skills/roundfix/SKILL.md +``` + +The two Skill paths and all five derived digest paths are expressly bounded in +both active Spec artifacts. The only additional path is Task 07's own file. + +Follow-up commit `ef6eb44ad8951112b1c3641bb7fd21793b440f95`: + +```text +docs/specs/0037-terminal-outcome-integrity/_prd.md +docs/specs/0037-terminal-outcome-integrity/_techspec.md +docs/specs/0037-terminal-outcome-integrity/task_07.md +``` + +It records the exact maintainer authorization in the two active artifacts and +aligns Task 07's evidence contract; it changes no tooling artifact. + +`rtk cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md` exited zero +with no output. The pair is byte-identical. + +Verdict: pass. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/docs-and-scope.md b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/docs-and-scope.md new file mode 100644 index 00000000..fb506ec2 --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/docs-and-scope.md @@ -0,0 +1,55 @@ +# Documentation and scope sweep + +Build: `ef6eb44ad8951112b1c3641bb7fd21793b440f95` + +## Operator guidance + +The built `roundfix --help`, `roundfix stop --help`, `roundfix settle --help`, +`roundfix runs list`, `roundfix events`, and `roundfix attach` paths executed +successfully where applicable. The Stop help matches the live Force Stop and +graceful-stop observables recorded in `live-cli-flows.md`. + +The supported guides contain the Force Stop and Stop Request contracts: + +```text +rtk grep -n 'stop --force\|Force Stop\|Stop Request' + docs/user-guide/commands.md docs/user-guide/usage.md +``` + +The focused help/report regression passed with a writable task-local Go cache: + +```text +rtk env GOCACHE=/private/tmp/roundfix-qa-0037-focused-gocache \ + go test ./internal/cli -run 'Test.*Stop.*(Help|Usage|Report)' -count=1 +``` + +All cited glossary, ADR, finding, and command-reference files resolve. The +finding anchor source heading +`## 4. Cleanup noise appeared before the actionable failure` resolves. + +## Non-Goals + +- `roundfix pause --help`, `roundfix resume --help`, and + `roundfix checkpoint --help` each exited 2 as unknown commands. No pause, + resume, or checkpoint surface shipped. +- The live graceful-stop flow reported that only the Run Database Stop Request + changed and that the in-flight Work Item remained active. +- The unprovable/reused-PID focused tests fail closed; no arbitrary-process + target surface exists in Stop help. +- The live watch Stop Request changed no tracked path, commit, HEAD, fetch, + push, or Review Source state. +- The changed-path and public-help sweep found no Spec 0038 Run Worktree + classification contract in this Spec. +- Review Source evidence and notification content remain outside this Spec. + The live watch fixture exercised existing CodeRabbit status content; the + change only stopped later access after Stop Request observation. + +## Skill contract + +The canonical and generated Roundfix Skill files are byte-identical. +`roundfix skills check` and the complete repository gate both passed. The Skill +states proof-before-completion, registered-active cleanup, next-poll graceful +interruption, immutable terminal outcomes, and winner-only publication in the +same terms as the built CLI and supported guides. + +Verdict: pass. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/focused-regressions.md b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/focused-regressions.md new file mode 100644 index 00000000..0890beb5 --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/focused-regressions.md @@ -0,0 +1,110 @@ +# Focused regression evidence + +Build: `ef6eb44ad8951112b1c3641bb7fd21793b440f95` + +The full static gate passed first. These narrower reruns name the exact +behavior that supports Task-level criteria not safely created through live +destructive failure injection. + +## Store and reconciliation + +The focused Store run passed: + +- `TestCompleteRunWinnerAndIdenticalReplay` +- `TestTerminalOutcomeConflictPreservesWinner` +- `TestTerminalOutcomeRejectsIntermediateStateUpdate` +- `TestTerminalOutcomeEveryStoredTerminalStateIsImmutable`, including all ten + stored terminal states +- `TestCompleteRunConcurrentTerminalOutcomesHaveOneWinner` +- `TestReconcileIntegrationPendingRecordsEvidence` +- `TestReconcileIntegrationRejectsIncompleteEvidence` +- `TestReconcileIntegrationRejectsStaleTargetBranch` +- `TestReconcileIntegrationRejectsEveryOtherSourceOutcome` +- `TestReconcileIntegrationRollsBackWhenJournalFails` +- `TestAgentSelectionActiveScopesReturnsLatestLifecycleInStableOrder` +- `TestStoppedRunReleasesActiveLock` + +Command: + +```text +rtk env GOCACHE=/private/tmp/roundfix-qa-0037-focused-gocache go test -v +./internal/store -run 'Test(...)' -count=1 +``` + +Result: exit `0`. + +## Force Stop, publication, and diagnostics + +The focused CLI run passed: + +- `TestCompletionWinnerOwnerVersusForceStopPublishesOneTerminalOutcome` +- `TestRunOutcomeNotificationFailureWarnsAndJournalsWithoutChangingReportOrExit` +- `TestRunWatchStopRequestBeforeAgentMarksStopped` +- `TestRunForceStopOwnerPermissionAndDeadlineFailuresRetainActiveLock` +- `TestRunForceStopPrimaryFailurePrecedesSecondaryCleanupWarnings` +- `TestRunForceStopOwnerPIDReuseFailsClosed` +- `TestRunForceStopStoppedRunIsIdempotentWithoutOwnerOrSessionActions` + +The registered-session run passed: + +- `TestRunStopForceAgentSessionCleanupSkipsRunWithoutActiveLifecycle` +- `TestRunStopForceRegisteredAgentSessionCleanupTargetsActiveScopesInOrder` +- `TestRunStopForceRegisteredAgentSessionAbsenceIsIdempotent` +- `TestRunStopForceAgentSessionCleanupFailureRemainsVisibleWithoutClosedLifecycle` + +The real helper-process run passed: + +- `TestRunForceStopOwnerProcessIntegrationProvesExitBeforeStoreCompletion` +- `TestOwnerProcessControllerGracefulExitProof` +- `TestOwnerProcessControllerForceKillExitProof` +- `TestOwnerProcessControllerRejectsUnprovenCurrentProcess` + +## Stop Request boundaries + +The focused Watch run passed: + +- `TestRunStopRequestDuringStatusWaitStopsAtNextPoll` +- `TestRunStopRequestDuringQuietPeriodStopsBeforeFetch` +- `TestRunStopRequestDuringTransientRetryStopsBeforeNextCheck` +- `TestRunStopRequestDuringMergeReadyWaitStopsBeforeNextCheck` +- `TestRunStopRequestSourceFailureIncludesRunAndOperation` +- `TestRunWithoutStopRequestKeepsRunBudgetBehavior` + +The in-flight Work Item contract passed: + +- `TestRunImplementStopRequestEndsStoppedWithInterruptMapping` +- `TestRunImplementDatabaseStopRequestAfterTaskCommitEndsStoppedAndReleasesLock` +- `TestTaskCycleStopRequestMidWaveDrainsRunningTasksAndStartsNothingNew` +- `TestTaskCycleStopRequestAfterTaskSettlementHaltsBeforeNextTask` + +## Integration recovery and supported platforms + +Both Settle recovery regressions passed: + +- `TestRunSettleRetargetsKeptRunWorktreeAndCleansUpAfterIntegration` +- `TestRunSettleRetargetsKeptTaskWorktreeAndCleansUpAfterIntegration` + +The built CLI listed existing Integration Pending Runs and replayed the +terminal Event Stream for +`run_20260715T125004Z_bceaf37742fa163d`, independently confirming the public +state remains readable. `roundfix settle --help` states that Settle writes no +Run Event Journal entry and never pushes. + +The supported-package cross-build passed: + +```text +rtk env GOCACHE=/private/tmp/roundfix-qa-0037-focused-gocache \ + GOOS=windows GOARCH=amd64 \ + go build -buildvcs=false ./internal/store ./internal/cli +``` + +The race run passed for Store, CLI, and Watch: + +```text +rtk env GOCACHE=/private/tmp/roundfix-qa-0037-focused-gocache \ + go test -race ./internal/store ./internal/cli ./internal/watch \ + -run 'Test.*(CompleteRun|TerminalOutcome|ForceStop|OwnerProcess|StopRequest|CompletionWinner)' \ + -count=1 +``` + +Verdict: pass. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/live-cli-flows.md b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/live-cli-flows.md new file mode 100644 index 00000000..d855f84d --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/live-cli-flows.md @@ -0,0 +1,129 @@ +# Live CLI flows + +Build: `ef6eb44ad8951112b1c3641bb7fd21793b440f95` + +Binary: +`bin/roundfix`, produced by the passing static gate. + +Environment: macOS; real user-scoped Run Database; isolated local clone at +`/private/tmp/roundfix-qa-0037-live.qhCESz/repo`; fake `acpx` and GitHub CLI +executables supplied deterministic external boundaries. The scratch clone +started at `95a2c0d25b22db019507bee36cceb2218844a2f4`, stayed clean, and received no +commit or push. + +## Force Stop with a registered Agent Session + +Run `run_20260727T142906Z_a8d858e652e38cce` entered Task 01 Agent work through +the built detached Implement Command. The fake ACP Runtime blocked at the +public prompt boundary so the owner and registered Task Agent Session remained +active. + +The built Force Stop Command exited zero and reported: + +```text +Roundfix Run force-stopped +State: Stopped +Force Stop proved the recorded owner process exited, completed the Run as +Stopped, and released its Active Run locks. +Roundfix did not edit user files, commit, push, fetch, or resolve Review Source +threads. +``` + +The ACP command log records the active Task scope and the Force Stop cleanup: + +```text +... sessions ensure --name roundfix-run_20260727T142906Z_a8d858e652e38cce-task_01 +... prompt -s roundfix-run_20260727T142906Z_a8d858e652e38cce-task_01 -f - +... cancel -s roundfix-run_20260727T142906Z_a8d858e652e38cce-task_01 +... sessions close roundfix-run_20260727T142906Z_a8d858e652e38cce-task_01 +``` + +Independent confirmation: + +- `roundfix runs list --state all` reported the Run as `Stopped`. +- `roundfix events ... --filter task-status,verification,outcome` contained + one Task-start event and exactly one `Stopped` outcome. +- A permitted `ps` search found no process containing the Run ID after the + report. +- `git worktree list` contained only the scratch user checkout; Force Stop + reaped the Run Worktree and branch. +- `git status --short` remained empty. + +## Lock release and idempotent replay + +Run `run_20260727T142631Z_1528df7e84d509ea` was force-stopped through the same +live flow. A second detached Implement Run on the same repository and Spec, +`run_20260727T142726Z_5d351f580c89bf2d`, started successfully immediately +afterward. This independently confirms release of the first Active Run lock. +The second Run was also force-stopped and reaped. + +Repeating Force Stop against the first already Stopped Run exited zero and +returned its stored `Stopped` report. A fresh Event Stream read still contained +exactly one outcome event at cursor 7. + +Force Stop against the existing terminal +`run_20260727T135811Z_eb4119943fdae042` exited 2: + +```text +terminal outcome conflict for Run +"run_20260727T135811Z_eb4119943fdae042": stored "Unresolved", requested +"Stopped" +``` + +A fresh Run listing and Event Stream read still reported `Unresolved` and the +same outcome events. + +## Graceful Stop Request during in-flight work + +Run `run_20260727T142819Z_61e165a06756e6e0` was stopped gracefully while its +Task Agent prompt was active. The command exited zero and reported: + +```text +Roundfix Stop Request recorded +State: ResolvingWithAgent +Stop Request recorded; the Run stops after the current Work Item settles. +Roundfix recorded the Stop Request in the Run Database only. +``` + +An immediate `runs list --state active` still showed the Run in +`ResolvingWithAgent`, proving graceful stop did not force-complete the in-flight +Work Item. Force Stop then safely cleaned up the deliberately blocked fixture. + +## Graceful Stop Request during Review Source wait + +The built Watch Command started detached Run +`run_20260727T143452Z_54843a7fdd2b5ce7` against a deterministic fake CodeRabbit +source. The source reported an in-progress CodeRabbit check for the exact +scratch HEAD, leaving the Run `Active` in `WaitingForReview`. + +The built Stop Command recorded a graceful Stop Request. Following the public +Event Stream with: + +```text +roundfix events run_20260727T143452Z_54843a7fdd2b5ce7 --follow --filter outcome +``` + +returned: + +```json +{"schema":"roundfix-events/v1","run_id":"run_20260727T143452Z_54843a7fdd2b5ce7","category":"outcome","outcome":"Stopped","summary":"Run reached Stopped."} +``` + +Independent confirmation: + +- A fresh Run listing reported `Stopped` after 33 seconds total runtime. +- The Review Source log contains only the initial PR metadata, check-runs, + commit-status, and reviews reads. It contains no later call or mutation after + Stop Request observation. +- The console closed with `Stopped after 0 Round(s)` and states that no later + verification, commit, push, fetch, or Review Source mutation ran. +- The console reports `Changed paths after Stop Request: none`. +- Scratch `git status --short` stayed empty and HEAD stayed + `95a2c0d25b22db019507bee36cceb2218844a2f4`. + +An earlier fake-source attempt ended `Failed` before Agent work because the QA +fixture returned `{}` instead of a reviews array. The built command reported +that parse failure as the primary reason with no Agent Session close warning. +The corrected fixture rerun above is the credited Review Source journey. + +Verdict: pass. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/static-gate.md b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/static-gate.md new file mode 100644 index 00000000..bc5fae64 --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/static-gate.md @@ -0,0 +1,33 @@ +# Static gate + +Build: `ef6eb44ad8951112b1c3641bb7fd21793b440f95` + +Command: + +```text +rtk env GOCACHE=/private/tmp/roundfix-qa-0037-rerun-gocache make verify +``` + +Result: exit `0`. + +```text +Go test: 2477 passed in 23 packages +Go test: 4 passed in 1 packages +Roundfix skill check passed: roundfix, write-idea, write-prd, +write-techspec, write-tasks, setup-context-driven, implement-task, +implement-spec, brainstorming, council, business-analyst, archive-spec, +qa-gate, evidence-gate +rtk go build -buildvcs=false ... -o bin/roundfix ./cmd/roundfix +``` + +Post-gate checks: + +- `rtk git -c core.fsmonitor=false diff --check`: exit `0`, no output. +- `rtk git -c core.fsmonitor=false status --short`: only the QA report and its + evidence directory are changed; verification introduced no unrelated delta. +- `rtk cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md`: exit + `0`, no output. +- `rtk ./bin/roundfix skills check`: exit `0`; every shipped Skill contract + passed. + +Verdict: pass. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/qa/qa-report-2026-07-27.md b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/qa-report-2026-07-27.md new file mode 100644 index 00000000..8aba2aaf --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/qa/qa-report-2026-07-27.md @@ -0,0 +1,175 @@ +--- +spec: 0037-terminal-outcome-integrity +date: 2026-07-27 +build: ef6eb44ad8951112b1c3641bb7fd21793b440f95 +status: closed +verdict: pass +surfaces: [backend, cli, data, docs] +--- + +# QA report — Terminal outcome integrity + +## Scope and environment + +This is the full final QA gate for Spec 0037 on macOS in the daemon-owned Run +Worktree. The intended actors are a user operating the Stop Command, a +Supervisor following a terminal Run, and a developer settling an Integration +Pending Run. The production-like entry point is the built `roundfix` CLI +against scratch repositories and Run Databases; public confirmation paths are +the Run report, Run Event Stream, repeated CLI reads, process liveness, and +repository state. Stateful flows used the real user-scoped Run Database and an +isolated local clone with deterministic fake ACP Runtime and Review Source +boundaries. The clone stayed clean and received no commit or push. No frontend +is declared. + +Build: `ef6eb44ad8951112b1c3641bb7fd21793b440f95`. + +Task evidence was credited only after the current static gate passed. +Project Constraint and protected-tooling audits passed before flow QA. +This rerun replaces the closed report for build `9fc2ba4`; that failed report +remains preserved in Git history at commit `123c614`. + +Relevant behavior probes: + +- Concurrent owner completion versus Force Stop tests the highest-risk + first-writer-wins seam. +- Repeated Force Stop tests idempotency without repeating process or Agent + Session actions. +- Stop Requests during status, quiet-period, retry, and Merge-Ready waits test + every long-lived Review Source boundary. +- An unprovable owner PID tests failed-closed lock retention. +- Missing and failing registered Agent Sessions test cleanup idempotency and + primary-before-secondary diagnostics. + +## Constraint audit + +Passed. All seven Tasks are completed; the PRD and TechSpec cover all four +mandatory axes with operative `docs/agents/` sources; ADR-0022, ADR-0044, +ADR-0051, and ADR-0052 resolve and agree with the Spec. Task 06 changes no +protected tooling. Task 07's Daemon commit changes only its own Task file, the +authorized Skill pair, and the five derived digest artifacts now expressly +bounded in both active Spec artifacts. Evidence: +`qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/constraint-audit.md`. + +## Static gate + +Passed. `rtk env GOCACHE=/private/tmp/roundfix-qa-0037-rerun-gocache make +verify` exited zero: 2,477 Go tests passed in 23 packages, four protected Skill +tests passed, every shipped Skill contract passed, and the CLI built. +Post-gate whitespace and worktree checks found only this QA report and its +evidence. Evidence: +`qa/evidence/2026-07-27-terminal-outcome-integrity-rerun/static-gate.md`. + +## Results + +The matrix was opened with every row pending before static or flow execution. +Each proof plan names the actor, public entry point, expected observable, +independent confirmation, and persistence check. + +| # | Story / criterion / sweep | Actor, entry point, and surface | Planned proof and independent confirmation | Evidence | Status | +| - | --- | --- | --- | --- | --- | +| QA-01 | Every task status is `completed` | QA Agent; task frontmatter; docs | Read all seven task files; confirm status from each canonical owner and re-read from disk. | Seven `status: completed` matches; `constraint-audit.md` | pass | +| QA-02 | PRD Project Constraints cover identifier, auth/HTTP, ADR, and tooling applicability with operative sources | QA Agent; `_prd.md`; docs | Inspect each required axis, resolve its cited `docs/agents/` path, and compare with the active contract. | Four axes present; all cited source files resolve; `constraint-audit.md` | pass | +| QA-03 | TechSpec Project Constraints cover the same four axes | QA Agent; `_techspec.md`; docs | Inspect each required axis, resolve its cited source, and compare with the active contract. | Four axes present; all cited source files resolve; `constraint-audit.md` | pass | +| QA-04 | Protected-tooling authorization is exact and all tooling Task changes stay within it | Maintainer/QA Agent; active Spec plus Task commit; docs/tooling | Resolve the Daemon-owned Task commit with `git diff-tree`; compare every changed path with the exact PRD and TechSpec bounds. | Task 07 commit is within the exact Skill-pair, five-digest, and own-Task bounds; `constraint-audit.md` | pass | +| QA-05 | Active ADR obligations resolve and remain consistent | QA Agent; ADR files; docs | Read ADR-0022, ADR-0044, ADR-0051, and ADR-0052; confirm active legacy status and no Spec conflict. | Four cited ADRs resolve and agree with the Spec; `constraint-audit.md` | pass | +| QA-06 | Full repository verification passes on the QA build | QA Agent; repository root; backend/cli/data/docs | Run `rtk make verify`; require exit 0, then inspect status and whitespace. | 2,477 Go tests, 4 Skill tests, shipped Skill check, and CLI build passed; `static-gate.md` | pass | +| US-01 | Force Stop proves owner exit before reporting Stopped | User; `roundfix stop --force`; cli/backend/data | Start a scratch owned Run, force-stop it, observe output only after owner exit; confirm with PID liveness, Run report, and a fresh CLI read. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| US-02 | A watch Run observes Stop Request by the next Review Source poll | User; graceful Stop Command during watch; cli/backend/data | Stop during a real wait, observe normal Stopped report by the next poll; confirm no later Review Source action via event stream and fresh Run read. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| US-03 | A Supervisor sees one stable terminal outcome and matching event | Supervisor; Run Event Stream and Run report; cli/data | Race owner completion with Force Stop, follow events, then re-read the Run; require one stable outcome and one matching outcome event across reopen. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| US-04 | Pre-Agent failure reports the primary cause without nonexistent-session noise | User; failing Run before Agent work; cli/backend | Trigger a pre-Agent failure, capture stderr and events; re-read history and require no session-close warning. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| US-05 | Integration Pending recovery remains available only through guarded reconciliation | Developer; Settle Command; cli/data | Settle a scratch Integration Pending Run with valid evidence, then retry invalid/stale evidence; confirm stored history, commits, and fresh Run report. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| CF-01 | Ordinary completion is first-writer-wins, same-outcome replay is idempotent, and conflict preserves row, timestamp, lock, and event | Supervisor; concurrent terminal CLI paths; cli/data | Drive competing completions; repeat winner and loser; confirm the stored Run, completion time, lock, and event stream on fresh reads. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| CF-02 | Only Integration Pending can reconcile to Clean with recorded evidence | Developer; Settle Command; cli/data | Exercise valid Integration Pending recovery and every other terminal source; confirm the prior outcome and evidence event persist. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| CF-03 | Force Stop cancels registered sessions, terminates owner, proves exit, and fails closed otherwise | User; `roundfix stop --force`; cli/backend/data | Exercise success and unprovable-owner failure; confirm action order, PID absence on success, and Active state plus lock retention on failure. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| CF-04 | Every Review Source wait checks Stop Request at both boundaries | User; graceful stop during watch; cli/backend | Stop during status, retry, quiet-period, and Merge-Ready waits; confirm Stopped by next boundary and no later operation. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| CF-05 | Agent Selection lifecycle is the exclusive cleanup registry | User; Force Stop with seeded scope lifecycles; cli/data | Create active/failed/closed scopes, stop the Run, and confirm only latest-active scopes are targeted once and later read as closed. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| CF-06 | Primary failure precedes visible secondary cleanup warnings | User; failing Force Stop with cleanup failure; cli/data | Trigger both failures; inspect stderr and event order, then re-read the Run to confirm the primary state and exit remain authoritative. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| CF-07 | Only the completion winner publishes outcome event and notification | Supervisor; terminal race plus Event Stream; cli/data | Race Force Stop against owner completion; count outcome events/notifications, repeat the winner, and reopen history. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| UX-01 | Successful Force Stop reports Stopped only after exit proof and lock release | User; Stop Command; cli | Observe command timing/output; independently start a competing Run and check PID absence after the report. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| UX-02 | Failed Force Stop names Run, PID, failed step, and retry/inspection command without claiming Stopped | User; failed Force Stop; cli | Cause unprovable termination; capture streams and exit code; fresh Run read must remain Active and locked. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| UX-03 | Graceful stop during Review Source wait yields normal Stopped at next poll | User; Stop Command and watch; cli | Submit Stop Request mid-wait; confirm timing at poll boundary and no later work from events/readback. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| UX-04 | Pre-Agent failures omit close noise; registered cleanup warning is secondary | User; failing Run/Force Stop; cli/data | Exercise absent-session and cleanup-failure variants; compare stderr/event order and persisted outcome. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| NG-01 | Pause, resume, and checkpoint recovery did not ship | User; `roundfix --help` and command help; cli/docs | Inspect supported commands and attempt excluded names; confirm no public surface or documentation promises them. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| NG-02 | Run Database remains the Stop Request channel | User; graceful stop plus public Run reads; cli/data | Submit stop and confirm durable observation through Run state/events, with no alternate public control path documented. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| NG-03 | Force Stop cannot target an arbitrary unrecorded process | User; Force Stop against scratch Run/PID mismatch; cli/backend | Attempt a mismatched or reused PID condition; require conservative refusal and prove the unrelated process remains alive. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| NG-04 | Graceful stop still lets an in-flight Work Item settle | User; Stop Request during active Work Item; cli/data | Request stop during work, observe settlement before Stopped, and confirm committed Task evidence on a fresh repository read. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| NG-05 | Spec 0038 Run Worktree classification/cleanup did not ship here | Developer; Settle/help/docs; cli/docs | Inspect public output/help and changed paths; confirm no new classification or cleanup contract is exposed by this build. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| NG-06 | Spec 0039 Review Source evidence/retry/notification content did not change | User; watch output/events/docs; cli/docs | Compare declared public content with unchanged contracts while exercising terminal behavior; confirm no new evidence semantics. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T01-AC01 | One non-terminal completion stores its outcome and reports transitioned | Supervisor; competing completion path; data/cli | Complete once, observe winner report, and confirm the terminal row on a fresh Run read. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T01-AC02 | Same-outcome replay changes no persisted field or journal entry | Supervisor; repeat terminal command; data/cli | Snapshot public Run/event state, replay, and require byte-for-byte equivalent public state and event count. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T01-AC03 | Competing outcome returns conflict and preserves winner, timestamp, lock, and events | Supervisor; losing terminal command; data/cli | Submit a different terminal outcome after settlement; confirm actionable rejection and unchanged fresh reads. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T01-AC04 | Deterministic race yields one winner and stable terminal row | Supervisor; concurrent owner/Force Stop; data/cli | Coordinate the race, then repeatedly read the Run and Event Stream after both actors exit. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T01-AC05 | Integration Pending becomes Clean only with complete evidence recorded transactionally | Developer; Settle Command; data/cli | Settle with complete evidence and inject an incomplete/stale variant; confirm atomic outcome/event behavior. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T01-AC06 | No other terminal outcome can be rewritten | Developer; terminal replay matrix; data/cli | Attempt recovery from every other terminal public state and confirm each stored result persists after reopen. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T02-AC01 | Active Task, QA, and review scopes are returned once in stable order | User; Force Stop with registered scopes; data/cli | Register each scope, stop, observe cleanup sequence, and confirm one closed lifecycle per scope. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T02-AC02 | Failed, closed, and superseded lifecycle attempts are not targeted | User; Force Stop with mixed histories; data/cli | Seed mixed latest states, stop, and confirm only latest-active sessions receive actions. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T02-AC03 | No active lifecycle means zero Agent Session calls | User; pre-Agent Force Stop/failure; cli/data | Stop a Run before Agent work; confirm no cancel/close output or event and no lifecycle mutation on fresh read. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T02-AC04 | Already-absent registered session closes without warning | User; Force Stop with stale registered session; cli/data | Remove the external session, stop, and require silent idempotent close plus persisted closed lifecycle. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T02-AC05 | Other cleanup failures remain visible and invent no lifecycle state | User; Force Stop with failing session adapter; cli/data | Cause non-absence failure; confirm secondary warning and latest lifecycle remains active on fresh read. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T02-AC06 | Agent Selection history and sensitive-field protections remain intact | User; public Run/event reads after cleanup; data/cli | Exercise cleanup, inspect public records, and confirm lifecycle history without sensitive session material. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T03-AC01 | Successful Force Stop proves exit before Stopped persistence | User; Force Stop; cli/backend/data | Stop a live helper owner, poll PID and Run concurrently, and require absence before Stopped becomes visible. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T03-AC02 | Active Run lock remains until winning completion | User; Force Stop plus competing launch; cli/data | Hold exit proof, attempt competing Run, then allow completion and retry; confirm lock timing. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T03-AC03 | Permission/deadline failure emits no success, stores no Stopped, and keeps Active | User; failed Force Stop; cli/data | Trigger controller denial/deadline, capture exit/streams, and confirm Active plus lock on a fresh read. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T03-AC04 | Failed Force Stop diagnostic names Run, PID, failed step, and retained state | User; failed Force Stop; cli | Inspect exact stderr and retry guidance, then execute its inspection command. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T03-AC05 | Reused or unprovable PID fails closed | User; mismatched owner identity; cli/backend | Create a safe scratch mismatch, run Force Stop, and prove target stays alive while Run stays Active/locked. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T03-AC06 | Repeated Force Stop for Stopped Run performs no process/session action | User; repeat Force Stop; cli/data | Snapshot calls/events, repeat command, and require the stored report with no new side effect. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T03-AC07 | Unix helper owner is absent before store completion and non-Unix packages compile | User/QA Agent; real helper plus cross-build; backend/cli | Exercise real helper process and build supported platform packages; confirm PID/store ordering and build exits. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T04-AC01 | Status-wait Stop Request reaches Stopped by next poll | User; graceful stop during status wait; cli/backend | Submit request after a status read, advance one poll, and confirm Stopped plus no second status call. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T04-AC02 | Quiet, retry, and Merge-Ready waits each interrupt at their boundary | User; graceful stop in each wait; cli/backend | Run all three timing variants and confirm Stopped immediately after each configured sleep. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T04-AC03 | No later Review Source call or repository mutation occurs | User; stopped watch; cli/backend | After stop observation, inspect event/source calls and repository status/HEAD for forbidden later work. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T04-AC04 | Operational runs always provide Store source | User; real watch Run; cli/data | Start through the public command, record Stop Request in Run Database, and confirm detection without context cancellation. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T04-AC05 | Store read failure is distinguishable from requested stop | User; watch with unavailable Run Database; cli/backend | Trigger a safe Store-read failure and require Failed diagnostics, not Stopped; confirm no Review Source call. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T04-AC06 | Run Budget and timeout behavior is unchanged without stop | User; watch with no Stop Request; cli/backend | Reach existing Run Budget/timeout boundary and compare public outcome/exit with the documented contract. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T05-AC01 | Completion race stores and publishes exactly one terminal outcome | Supervisor; owner/Force Stop race; cli/data | Count stored outcome and terminal event after both actors exit, then reopen. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T05-AC02 | Losing owner emits no second event or notification | Supervisor; race Event Stream/notification sink; cli/data | Resume loser after winner, count outputs, and confirm no additional publication after delay/reopen. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T05-AC03 | Identical replay is silent and returns stored result | User; repeat terminal command; cli/data | Replay, capture output/event count, and confirm stored outcome unchanged. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T05-AC04 | Conflicting loser cannot change primary reason, exit, or notification context | Supervisor; conflicting completion; cli/data | Submit conflict and compare original report, exit classification, event, and notification context after reopen. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T05-AC05 | Cleanup warnings follow primary failure and are labeled secondary | User; Force Stop with owner and cleanup failures; cli/data | Capture ordered stderr and journal; fresh Run read must preserve primary failure. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T05-AC06 | Notification failure is warning-only and leaves outcome unchanged | User; terminal completion with failing notifier; cli/data | Trigger notifier failure, confirm warning and original report/exit, then fresh-read persisted outcome. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T05-AC07 | Resolve, Watch, Implement, and Stop terminal regressions pass | User/Supervisor; four public command families; cli/backend/data | Exercise each terminal path and confirm outcome/report/event through a second public read. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T06-AC01 | Reader can predict successful and failed Force Stop state/lock behavior | Operator; supported user guides; docs/cli | Follow both documented flows verbatim against the built CLI and compare state/lock observables. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T06-AC02 | Guidance states graceful-watch observation and prohibited later work | Operator; user guides plus watch command; docs/cli | Follow the documented graceful flow and confirm next-poll timing plus no later operation. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T06-AC03 | Guidance explains registered cleanup and secondary warnings | Operator; user guides plus Force Stop; docs/cli | Follow active/absent/failing-session cases and compare observed output/order with documentation. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T06-AC04 | Docs promise neither terminal overwrite nor warning-only owner reclamation | Operator; docs/help; docs/cli | Search supported guidance and attempt replay/failure flows; require immutable/failed-closed behavior. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T06-AC05 | Every cited ADR, finding, and command reference resolves | Reader; docs links and commands; docs | Resolve links and execute documented help/inspection commands from a clean checkout. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T06-AC06 | Task 06 changed no protected tooling path | QA Agent; Task 06 commit; docs/tooling | Inspect the Daemon commit with `git diff-tree` and compare paths with the protected-tooling definition. | `87d0fd0` changes its Task, guides, and CLI code/tests only; `constraint-audit.md` | pass | +| T07-AC01 | Roundfix Skill rejects overwrite and pre-proof Force Stop reporting | Agent/operator; shipped Skill plus CLI; docs/cli | Read shipped Skill instructions and execute replay/Force Stop flows; compare with actual output/state. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T07-AC02 | Skill matches graceful interruption and registered cleanup docs | Agent/operator; Skill, guides, CLI; docs/cli | Compare all three public contracts and exercise one graceful and one cleanup flow. | `live-cli-flows.md`; `focused-regressions.md`; `docs-and-scope.md` | pass | +| T07-AC03 | Canonical and generated Skill files are byte-identical | QA Agent; `cmp`; docs/tooling | Compare exact bytes, then run shipped Skill validation. | `rtk cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md`: exit 0, no output; `constraint-audit.md` | pass | +| T07-AC04 | Git evidence stays within the authorized Skill pair, Task 07, and five derived digest pins | Maintainer/QA Agent; Task 07 commit; docs/tooling | Resolve exact commit paths with `git diff-tree` and compare to the active eight-path Task allowlist. | `9fc2ba4` matches the authorized seven tooling artifacts plus `task_07.md`; `constraint-audit.md` | pass | +| T07-AC05 | No other protected or upstream-managed Skill changes | QA Agent; Task 07 commit and skill ownership rules; docs/tooling | Inspect all changed paths and ownership; run read-only sync validation. | `9fc2ba4` changes only the canonical/generated Roundfix pair among Skill paths; `constraint-audit.md` | pass | +| T07-AC06 | Shipped Skill validation and complete repository gate pass | Agent/operator; `roundfix skills check` and repository gate; cli/docs | Run both commands on this build and require exit 0; re-check Skill byte identity afterward. | `make verify`, built `roundfix skills check`, and byte identity passed; `static-gate.md` | pass | + +## Findings + +No product finding. One initial fake Review Source attempt returned an invalid +reviews payload and correctly ended Failed before Agent work; the fixture was +corrected, and the complete watch journey passed on a new Run. This was a QA +fixture error, not product behavior. + +## Blocked and skipped + +None. All planned rows reached `pass`; no row was blocked or skipped. + +## Coverage + +Passed rows: 72/72. The matrix covers 5/5 user stories, 7/7 core features, +4/4 explicit user-experience promises, 6/6 Non-Goals, 44/44 Task acceptance +criteria, the complete Project Constraint audit, and the repository static +gate. Status counts: 72 pass, 0 fail, 0 blocked, 0 skipped, 0 pending. + +Live public journeys covered Force Stop success, Active Run lock release, +idempotent replay, conflicting terminal rejection, registered Agent Session +cleanup, graceful Stop Request during an in-flight Work Item, and graceful +Stop Request during a Review Source wait. Focused current-build regressions +covered the destructive permission/deadline/reused-PID paths, every Review +Source wait phase, Integration Pending reconciliation, winner-only publication, +notification failure, sensitive lifecycle state, supported-platform build, and +race behavior. + +## Final verdict + +Pass. Every planned story, criterion, scope check, and gate passed on build +`ef6eb44ad8951112b1c3641bb7fd21793b440f95`; no remediation is required before +the daemon records this QA result. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/task_01.md b/docs/specs/_archived/0037-terminal-outcome-integrity/task_01.md new file mode 100644 index 00000000..ba02e39f --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/task_01.md @@ -0,0 +1,152 @@ +--- +task: task_01 +spec: 0037-terminal-outcome-integrity +status: completed +type: data +complexity: high +--- + +# Task 01: Guard terminal completion and reconciliation + +## Overview + +Make terminal Run completion an atomic first-writer-wins operation while +preserving identical replay and the one evidence-backed Integration Pending +reconciliation. This slice is complete when competing completion attempts +cannot overwrite the stored outcome, release its lock twice, or publish +conflicting durable evidence. + +## Requirements + +1. MUST make ordinary Run completion update only a non-terminal Run. +2. MUST return an explicit transitioned result for the winning completion and + an idempotent non-transition result for an identical replay. +3. MUST return a typed conflict for a different requested terminal outcome + without changing the Run, completion timestamp, Active Run lock, or journal. +4. MUST release the Active Run lock only in the transaction that wins ordinary + terminal completion. +5. MUST provide the sole guarded Integration Pending to Clean reconciliation, + requiring the recorded Run and target heads and journaling both outcomes. +6. MUST reject every other terminal-to-terminal transition. +7. MUST wrap database and transaction failures with the operation and Run ID. + +## Subtasks + +- [x] Add explicit completion and conflict result contracts. +- [x] Implement compare-and-set terminal persistence and winner-only lock release. +- [x] Implement evidence-backed Integration Pending reconciliation. +- [x] Add idempotent replay and conflicting-outcome coverage. +- [x] Add deterministic concurrent-completion coverage. +- [x] Prove reconciliation rejects stale or invalid source outcomes. + +## Acceptance Criteria + +- [x] One non-terminal completion stores its outcome and reports transitioned. +- [x] Repeating the same outcome changes no persisted field or journal entry. +- [x] A competing outcome returns the typed conflict and leaves the winner, + timestamp, lock state, and event history unchanged. +- [x] A deterministic race produces exactly one winner and one stable terminal + row. +- [x] Integration Pending becomes Clean only with complete reconciliation + evidence recorded transactionally. +- [x] No other terminal outcome can be rewritten. + +## Context + +- instruction: `.agents/skills/coding-guidelines/SKILL.md` +- instruction: `.agents/skills/golang-error-handling/SKILL.md` +- instruction: `.agents/skills/golang-testing/SKILL.md` +- instruction: `.agents/skills/testing-boss/SKILL.md` +- interface: `internal/store/store.go` +- interface: `internal/store/store_test.go` +- interface: `internal/store/journal.go` +- interface: `internal/store/journal_test.go` + +## Verification + +- `rtk go test ./internal/store -run 'Test(CompleteRun|TerminalOutcome|ReconcileIntegration)' -count=1` + — expected: winner, replay, conflict, lock, and guarded reconciliation cases + pass. +- `rtk go test -race ./internal/store -run 'Test(CompleteRun|TerminalOutcome)' -count=1` + — expected: concurrent competing completion has one race-free winner. + +## References + +- `_prd.md` → Goals 1; User Stories 1, 3, and 5; Core Features 1–2 and 7; + Success Metrics. +- `_techspec.md` → Interfaces; Data Models; Build Order 1. +- `../../adr/0052-run-completion-is-compare-and-set.md` → terminal + compare-and-set and guarded reconciliation. + +## Result + +The store slice meets every acceptance criterion, and the Settle Command was +adapted to the Spec's no-rewrite decision: Settle never rewrites a settled +Run's terminal outcome, so a recovered `Unresolved` Run keeps `Unresolved` as +history and recovery is expressed only through the Settle report and its +commits. The repository regression suite is green. + +### What changed + +- Ordinary completion now returns an explicit transition result, atomically + changes only a non-terminal Run, and releases the Active Run lock only for + the winner. +- Identical replay returns the stored Run without changing timestamps, locks, + or Run Events. A competing terminal outcome returns + `TerminalOutcomeConflictError` with the stored and requested outcomes. +- Integration Pending reconciliation validates complete head and target + evidence, compare-and-sets the Run to Clean, and records both outcomes and + the evidence in one transaction. +- Existing callers were adapted only to unwrap the explicit completion result; + winner-only event and notification publication remains Task 05's slice. +- `integrateSettledRun` in `internal/cli/settle.go` no longer calls + `CompleteRun`: it still returns the integration command on + `runworktree.ModePending` and still cleans up the Run Worktree on clean + integration, but leaves the Run's stored terminal outcome untouched. +- `TestRunSettleRetargetsKeptRunWorktreeAndCleansUpAfterIntegration` and + `TestRunSettleRetargetsKeptTaskWorktreeAndCleansUpAfterIntegration` now + assert the settled Run's stored state remains `Unresolved`; all worktree + cleanup, checkout, and commit assertions are unchanged. + +### Verification + +- Focused store check: + `GOFLAGS=-buildvcs=false go test ./internal/store -run + 'Test(CompleteRun|TerminalOutcome|ReconcileIntegration)' -count=1` passed + (`ok roundfix/internal/store 0.434s`). +- Race check: + `GOCACHE=/private/tmp/roundfix-task01-gocache go test -race ./internal/store + -run 'Test(CompleteRun|TerminalOutcome)' -count=1` passed. +- Settle recovery check: + `GOFLAGS=-buildvcs=false go test ./internal/cli -run + '^TestRunSettleRetargetsKept(Run|Task)WorktreeAndCleansUpAfterIntegration$' + -count=1` passed (`ok roundfix/internal/cli 1.004s`). +- Repository regression check: + `GOFLAGS=-buildvcs=false go test ./...` passed for every package; no + failures. +- `gofmt -l internal/cli/settle.go internal/cli/settle_test.go` reported no + files. + +### Acceptance evidence + +- `TestCompleteRunWinnerAndIdenticalReplay` proves the first completion reports + `Transitioned: true`, releases the lock once, and an identical replay reports + no transition without changing the row or journal. +- `TestTerminalOutcomeConflictPreservesWinner` proves the typed conflict keeps + the winning outcome, completion timestamp, lock count, and event count. +- `TestCompleteRunConcurrentTerminalOutcomesHaveOneWinner` plus the race check + proves exactly one competing completion wins and the stored terminal row is + stable. +- `TestReconcileIntegrationPendingRecordsEvidence` and + `TestReconcileIntegrationRollsBackWhenJournalFails` prove the guarded + reconciliation and its Run Event commit or roll back together. +- `TestReconcileIntegrationRejectsIncompleteEvidence`, + `TestReconcileIntegrationRejectsStaleTargetBranch`, and + `TestReconcileIntegrationRejectsEveryOtherSourceOutcome` prove incomplete, + stale, and invalid source evidence changes neither the Run nor its journal. +- `TestTerminalOutcomeEveryStoredTerminalStateIsImmutable` proves ordinary + completion cannot rewrite any terminal outcome, including Integration + Pending. +- The two updated Settle tests prove a recovered Run keeps `Unresolved` as its + stored outcome while Settle still integrates the work, cleans up the kept + worktrees, and releases the Active Run lock. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/task_02.md b/docs/specs/_archived/0037-terminal-outcome-integrity/task_02.md new file mode 100644 index 00000000..982eff51 --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/task_02.md @@ -0,0 +1,115 @@ +--- +task: task_02 +spec: 0037-terminal-outcome-integrity +status: completed +type: backend +complexity: medium +--- + +# Task 02: Target only registered Agent Sessions during cleanup + +## Overview + +Use persisted Agent Selection lifecycle evidence as the exclusive Agent Session +cleanup registry. Cleanup becomes deterministic and idempotent: only scopes +whose latest lifecycle is active are targeted, while absent, failed, and +already closed sessions produce no misleading cleanup action. + +## Requirements + +1. MUST query the latest Agent Selection lifecycle per Run scope. +2. MUST return only scopes whose latest persisted lifecycle is active. +3. MUST cancel and close eligible Agent Sessions once in deterministic scope + order. +4. MUST record closed lifecycle after an idempotent close. +5. MUST treat a registered but already absent Agent Session as an idempotent + close. +6. MUST not derive or target an Agent Session without active lifecycle evidence. +7. MUST retain non-absence cleanup failures as secondary diagnostics. + +## Subtasks + +- [x] Add the latest-active-scope store query. +- [x] Route cleanup through registered Agent Selection scopes. +- [x] Make registered-session absence idempotent. +- [x] Persist closed lifecycle after successful cleanup. +- [x] Add deterministic ordering and lifecycle-state tests. +- [x] Remove unconditional Run-wide session-name cleanup. + +## Acceptance Criteria + +- [x] Active Task, QA, and review scopes are returned once in stable order. +- [x] Failed, closed, and superseded lifecycle attempts are not targeted. +- [x] A Run with no active lifecycle record performs zero Agent Session calls. +- [x] An already absent registered session closes without a warning. +- [x] Other cleanup failures remain visible and do not invent lifecycle state. +- [x] Existing Agent Selection history and sensitive-field protections pass. + +## Context + +- instruction: `.agents/skills/coding-guidelines/SKILL.md` +- instruction: `.agents/skills/golang-error-handling/SKILL.md` +- instruction: `.agents/skills/golang-testing/SKILL.md` +- interface: `internal/store/agent_selection.go` +- interface: `internal/store/agent_selection_test.go` +- interface: `internal/cli/cli.go` +- interface: `internal/cli/cli_test.go` +- interface: `internal/agent/sessions.go` + +## Verification + +- `rtk go test ./internal/store -run 'TestAgentSelection.*(Active|Lifecycle|Cleanup|Scope)' -count=1` + — expected: only latest active scopes are returned in deterministic order. +- `rtk go test ./internal/cli -run 'Test.*(RegisteredAgentSession|AgentSessionCleanup|PrimaryFailure)' -count=1` + — expected: cleanup targets registered scopes only and registered absence is + idempotent. + +## References + +- `_prd.md` → Goal 4; User Story 4; Core Features 5–6; Success Metrics. +- `_techspec.md` → Data Models; API Contracts: Agent Session cleanup rules; + Build Order 2. +- `../../adr/0051-tasks-and-qa-own-agent-sessions.md` → Work Item-scoped Agent + Sessions. + +## Result + +Implemented persisted Agent Selection lifecycle as the exclusive Force Stop +Agent Session cleanup registry. Cleanup now reads each scope's latest +lifecycle, targets only active Task, QA, and review scopes in deterministic +order, reconstructs the exact fallback session name and runtime, and records +`closed` only after close succeeds or acpx proves the registered session is +already absent. Non-absence cancel and close errors remain visible; a failed +close leaves the lifecycle active for a later retry. + +Verification: + +- `rtk go test ./internal/store -run 'TestAgentSelection.*(Active|Lifecycle|Cleanup|Scope)' -count=1` + — passed, 3 tests. +- `rtk go test ./internal/cli -run 'Test.*(RegisteredAgentSession|AgentSessionCleanup|PrimaryFailure)' -count=1` + — passed, 4 tests. +- `rtk go test ./internal/agent ./internal/store ./internal/cli -count=1` + — passed, 1,108 tests. +- `rtk git -c core.fsmonitor=false diff --check` — passed. + +Acceptance evidence: + +- `TestAgentSelectionActiveScopesReturnsLatestLifecycleInStableOrder` proves + Task, QA, and review scope ordering and excludes failed, closed, and + superseded lifecycles. +- `TestRunStopForceAgentSessionCleanupSkipsRunWithoutActiveLifecycle` proves + zero cancel and close calls without active persisted evidence. +- `TestRunStopForceRegisteredAgentSessionCleanupTargetsActiveScopesInOrder` + proves one ordered cancel/close pair per registered scope, including a + fallback Agent Session, followed by persisted `closed` lifecycles. +- `TestRunStopForceRegisteredAgentSessionAbsenceIsIdempotent` proves wrapped + acpx missing-session responses are silent and still persist `closed`. +- `TestRunStopForceAgentSessionCleanupFailureRemainsVisibleWithoutClosedLifecycle` + proves other cleanup failures remain diagnostic and do not fabricate a + closed lifecycle. +- The affected-package run includes the existing Agent Selection history, + lifecycle transition, event payload, schema privacy, and sensitive-field + protection tests. + +Follow-up: TechSpec Build Order 5 still owns primary-before-secondary +publication ordering and winner-only terminal outcome wiring. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/task_03.md b/docs/specs/_archived/0037-terminal-outcome-integrity/task_03.md new file mode 100644 index 00000000..f1982242 --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/task_03.md @@ -0,0 +1,141 @@ +--- +task: task_03 +spec: 0037-terminal-outcome-integrity +status: completed +type: backend +complexity: high +--- + +# Task 03: Prove owner exit before Force Stop completion + +## Overview + +Reorder Force Stop so Roundfix proves the recorded owner process is absent +before storing Stopped or releasing the Active Run lock. Platform-specific +controllers retain the existing graceful-then-force policy while failures +leave the Run Active with actionable diagnostics. + +## Requirements + +1. MUST cancel registered Agent Sessions before terminating the recorded owner. +2. MUST target only the recorded owner PID and follow existing platform process + boundaries. +3. MUST attempt graceful termination, wait within the bounded stop window, and + use the existing force-kill path when required. +4. MUST report success only after owner-process absence is positively proven. +5. MUST call terminal completion only after that proof. +6. MUST leave the Run Active and its lock retained on permission, unsupported, + reused-PID, or deadline failures. +7. MUST make an already stored Stopped outcome idempotent without repeating + process or Agent Session actions. + +## Subtasks + +- [x] Introduce the owner-process controller seam. +- [x] Implement Unix exit proof and preserve Windows build behavior. +- [x] Reorder Force Stop coordination around owner absence. +- [x] Add actionable failed-step diagnostics. +- [x] Cover graceful, forced, permission, deadline, and idempotent paths. +- [x] Add a real helper-process integration case on Unix. + +## Acceptance Criteria + +- [x] Successful Force Stop proves owner exit before Stopped is persisted. +- [x] The Active Run lock remains present until the winning completion. +- [x] Permission or deadline failure prints no success report, stores no Stopped + outcome, and leaves the Run Active. +- [x] The diagnostic names the Run, owner PID, failed step, and retained state. +- [x] A reused or unprovable PID fails closed. +- [x] Repeating Force Stop for an already Stopped Run performs no process or + session action. +- [x] Unix integration proves the helper owner is absent before store + completion; non-Unix packages still compile. + +## Context + +- instruction: `.agents/skills/agentic-cli-design/SKILL.md` +- instruction: `.agents/skills/coding-guidelines/SKILL.md` +- instruction: `.agents/skills/golang-context/SKILL.md` +- instruction: `.agents/skills/golang-error-handling/SKILL.md` +- instruction: `.agents/skills/golang-testing/SKILL.md` +- interface: `internal/cli/cli.go` +- interface: `internal/cli/cli_test.go` +- interface: `internal/store/process_unix.go` +- interface: `internal/store/process_windows.go` +- interface: `internal/store/process_other.go` +- interface: `internal/store/process_unix_test.go` + +## Verification + +- `rtk go test ./internal/cli -run 'Test.*ForceStop.*(Owner|Exit|Permission|Deadline|Idempotent|Lock)' -count=1` + — expected: Force Stop proves absence before completion and every failure + retains Active state and lock. +- `rtk go test ./internal/store -run 'Test.*Process|TestStoppedRunReleasesActiveLock' -count=1` + — expected: platform process proof and winner-only lock release pass. +- `rtk go test -race ./internal/cli ./internal/store -run 'Test.*(ForceStop|OwnerProcess)' -count=1` + — expected: stop coordination and exit proof are race-free. + +## References + +- `_prd.md` → Goals 1–2; User Story 1; Core Features 1 and 3; User Experience; + Success Metrics. +- `_techspec.md` → Interfaces: OwnerProcessController; API Contracts: + Force Stop; Build Order 3. +- `../../adr/0044-orphaned-run-locks-are-reclaimed-on-proven-owner-death.md` → + owner-death proof. +- `../../adr/0052-run-completion-is-compare-and-set.md` → completion ordering. + +## Result + +Force Stop now treats owner-process absence as the completion gate. It cancels +registered Agent Sessions, sends graceful termination to the recorded PID, +escalates through the platform force-kill path when needed, and calls +`CompleteRun` only after the controller proves the PID absent. Owner-control +failures leave the Run Active and the Active Run lock retained. An already +Stopped Run returns its stored result without process, session, or Worktree +actions. + +Verification: + +- Pre-change reproduction: the focused CLI/store test build failed because the + owner-process seam, controller errors, and platform implementation did not + exist. +- `rtk go test ./internal/cli -run 'Test.*ForceStop.*(Owner|Exit|Permission|Deadline|Idempotent|Lock)' -count=1` + passed with 8 tests. +- `rtk go test ./internal/store -run 'Test.*Process|TestStoppedRunReleasesActiveLock' -count=1` + passed with 8 tests. +- `rtk go test -race ./internal/cli ./internal/store -run 'Test.*(ForceStop|OwnerProcess)' -count=1` + passed with 13 tests. +- `rtk go test ./internal/cli ./internal/store -count=1` passed. +- `GOOS=windows GOARCH=amd64 go build ./internal/store ./internal/cli` + passed through `rtk proxy env`. +- `rtk make verify` passed. +- `rtk git -c core.fsmonitor=false diff --check` passed. + +Acceptance evidence: + +- `TestRunForceStopOwnerExitPrecedesCompletionAndLockRelease` observed the Run + as Active and a competing Run blocked by its lock while the owner controller + was proving exit, then observed Stopped only after proof. +- `TestRunForceStopOwnerPermissionAndDeadlineFailuresRetainActiveLock` proved + permission and deadline failures produce empty stdout, actionable Run/PID/step + diagnostics, Active state, and a retained lock. +- `TestRunForceStopOwnerPIDReuseFailsClosed` and + `TestOwnerProcessControllerRejectsUnprovenCurrentProcess` proved conservative + refusal when process identity cannot be trusted. +- `TestRunForceStopStoppedRunIsIdempotentWithoutOwnerOrSessionActions` proved a + stored Stopped replay invokes neither process control nor Agent Session + cleanup. +- `TestOwnerProcessControllerGracefulExitProof` and + `TestOwnerProcessControllerForceKillExitProof` exercised real Unix helper + processes through graceful and forced exit. +- `TestRunForceStopOwnerProcessIntegrationProvesExitBeforeStoreCompletion` + observed the helper PID absent when Stopped first became visible in the Run + Database. + +Follow-up: + +- Windows production packages cross-build. Cross-compiling the full + `internal/cli` test binary remains blocked by pre-existing Unix-only + `syscall.SysProcAttr.Setpgid` and `syscall.Kill` references in + `internal/cli/implement_test.go`; this Task did not change that test surface. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/task_04.md b/docs/specs/_archived/0037-terminal-outcome-integrity/task_04.md new file mode 100644 index 00000000..fa360b99 --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/task_04.md @@ -0,0 +1,133 @@ +--- +task: task_04 +spec: 0037-terminal-outcome-integrity +status: completed +type: backend +complexity: medium +--- + +# Task 04: Interrupt every Review Source wait on Stop Request + +## Overview + +Make the Run Database Stop Request visible at every Review Source wait +boundary. A graceful stop interrupts status, retry, quiet-period, and +Merge-Ready waits by the next polling boundary and starts no later Review +Source or repository mutation. + +## Requirements + +1. MUST expose Stop Request observation through a narrow context-aware source. +2. MUST supply the Store-backed source to every operational watch Run. +3. MUST check the source before each Review Source status access. +4. MUST check again after every interruptible wait, including quiet, retry, and + Merge-Ready sleeps. +5. MUST return the existing stop classification when a request is observed. +6. MUST not perform another fetch, check, artifact write, commit, push, or + Review Source mutation after observation. +7. MUST propagate Store observation failures with Run and operation context. + +## Subtasks + +- [x] Add the Stop Request source contract to watch dependencies. +- [x] Wire the Store-backed source into operational watch Runs. +- [x] Cover every status and sleep boundary. +- [x] Preserve the existing stopped classification and CLI exit behavior. +- [x] Add fake-clock tests for each waiting phase. +- [x] Prove no downstream operation starts after observation. + +## Acceptance Criteria + +- [x] A request during status wait reaches Stopped by the next poll. +- [x] Requests during quiet period, transient retry sleep, and Merge-Ready wait + each interrupt their respective boundary. +- [x] No later Review Source call or repository mutation occurs. +- [x] Operational runs always provide the Store source; only isolated tests may + omit it. +- [x] Store read failure is distinguishable from a requested stop. +- [x] Existing Run Budget and timeout behavior remains unchanged without a stop. + +## Context + +- instruction: `.agents/skills/coding-guidelines/SKILL.md` +- instruction: `.agents/skills/golang-context/SKILL.md` +- instruction: `.agents/skills/golang-error-handling/SKILL.md` +- instruction: `.agents/skills/golang-testing/SKILL.md` +- interface: `internal/watch/watch.go` +- interface: `internal/watch/watch_test.go` +- interface: `internal/cli/cli.go` +- interface: `internal/cli/cli_test.go` +- interface: `internal/store/store.go` + +## Verification + +- `rtk go test ./internal/watch -run 'TestRun.*StopRequest' -count=1` + — expected: every wait boundary observes Stop Request and starts no later + operation. +- `rtk go test ./internal/cli -run 'TestRunWatch.*StopRequest' -count=1` + — expected: Store-backed stop observation reaches the existing Stopped CLI + contract. +- `rtk go test -race ./internal/watch ./internal/cli -run 'Test.*StopRequest' -count=1` + — expected: cancellation and polling remain race-free. + +## References + +- `_prd.md` → Goal 3; User Story 2; Core Feature 4; User Experience; Success + Metrics. +- `_techspec.md` → Interfaces: StopRequestSource; API Contracts: Graceful Stop + Requests; Build Order 4. +- `../../adr/0022-stop-requests-travel-through-the-run-database.md` → durable + Stop Request transport. + +## Result + +Implemented a context-aware Stop Request source in the watch dependencies and +wired the operational Watch Run to its Run Database Store. The watch loop now +observes Stop Requests before and after Review Source status and Merge-Ready +accesses and after status-poll, quiet-period, transient-retry, and Merge-Ready +sleeps. Observation returns the stopped classification before any later fetch, +check, resolve, artifact, commit, push, or Review Source mutation can start. +Store observation failures retain their cause and add the Run ID plus boundary +operation. + +Acceptance evidence: + +- Status wait: `TestRunStopRequestDuringStatusWaitStopsAtNextPoll` observed the + request after one fake-clock poll, returned Stopped with zero fetched Rounds, + and made no second status, fetch, or resolve call. +- Quiet, retry, and Merge-Ready waits: + `TestRunStopRequestDuringQuietPeriodStopsBeforeFetch`, + `TestRunStopRequestDuringTransientRetryStopsBeforeNextCheck`, and + `TestRunStopRequestDuringMergeReadyWaitStopsBeforeNextCheck` each observed + the request immediately after the relevant fake-clock sleep and proved no + later operation began. +- Operational source and CLI contract: + `TestRunWatchStopRequestBeforeAgentMarksStopped` recorded a real Run Database + Stop Request without canceling the context, reached Stopped with exit code + zero, and performed no fetch or Agent work. The sole operational + `watch.Run` call supplies `StopRequests: runStore`; isolated package tests + may leave the source nil. +- Failure distinction: + `TestRunStopRequestSourceFailureIncludesRunAndOperation` preserved the Store + error chain, reported Failed rather than Stopped, named `run_123` and the + status operation, and made no Review Source call. +- Unchanged safeguards: + `TestRunWithoutStopRequestKeepsRunBudgetBehavior` retained BudgetExceeded + behavior with a non-requesting source, and the complete watch and CLI package + suites passed. + +Verification: + +- `rtk env GOCACHE=/private/tmp/roundfix-task04-gocache go test ./internal/watch -run 'TestRun.*StopRequest' -count=1` + — passed. +- `rtk env GOCACHE=/private/tmp/roundfix-task04-gocache go test ./internal/cli -run 'TestRunWatch.*StopRequest' -count=1` + — passed. +- `rtk env GOCACHE=/private/tmp/roundfix-task04-gocache go test -race ./internal/watch ./internal/cli -run 'Test.*StopRequest' -count=1` + — passed. +- `rtk env GOCACHE=/private/tmp/roundfix-task04-gocache go test ./internal/watch ./internal/cli -count=1` + — passed with process-control permission. The first sandboxed attempt reached + the unrelated detached-process test and failed with `operation not + permitted`; the identical permitted rerun passed both packages. +- `rtk git -c core.fsmonitor=false diff --check` — passed. + +Follow-ups: none. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/task_05.md b/docs/specs/_archived/0037-terminal-outcome-integrity/task_05.md new file mode 100644 index 00000000..07559815 --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/task_05.md @@ -0,0 +1,146 @@ +--- +task: task_05 +spec: 0037-terminal-outcome-integrity +status: completed +type: backend +complexity: high +--- + +# Task 05: Publish only the winning terminal outcome + +## Overview + +Carry the completion transition result through all terminal publication paths +so only the winner emits outcome evidence and notification. Preserve primary +failure ordering while retaining registered-session cleanup problems as +explicit secondary diagnostics. + +## Requirements + +1. MUST publish a terminal outcome Run Event only when completion transitioned + the Run. +2. MUST attempt Run Outcome Notification only for that same winning transition. +3. MUST make identical replays and conflicting losers observe stored state + without duplicate publication. +4. MUST preserve one terminal outcome across Resolve, Watch, Implement, Stop, + and cleanup paths. +5. MUST print and journal the primary failure before any cleanup warning. +6. MUST label cleanup failures as secondary and prevent them from replacing + the terminal reason or exit code. +7. MUST retain best-effort notification failure without changing the Run + outcome. + +## Subtasks + +- [x] Thread the completion transition result through terminal coordinators. +- [x] Gate outcome events and notifications on the winning transition. +- [x] Normalize replay and conflict handling around stored state. +- [x] Order primary failure evidence before cleanup diagnostics. +- [x] Cover terminal paths across operational commands. +- [x] Add deterministic owner-versus-Force-Stop publication coverage. + +## Acceptance Criteria + +- [x] A completion race stores and publishes exactly one terminal outcome. +- [x] The losing owner emits no second outcome event or notification. +- [x] An identical replay is silent and returns the stored result. +- [x] A conflicting loser cannot change the primary reason, exit code, or + notification context. +- [x] Cleanup warnings follow the primary failure and are labeled secondary. +- [x] Notification failure remains a warning and leaves persisted outcome + unchanged. +- [x] Resolve, Watch, Implement, and Stop terminal regressions pass. + +## Context + +- instruction: `.agents/skills/coding-guidelines/SKILL.md` +- instruction: `.agents/skills/golang-error-handling/SKILL.md` +- instruction: `.agents/skills/golang-testing/SKILL.md` +- instruction: `.agents/skills/testing-boss/SKILL.md` +- interface: `internal/cli/cli.go` +- interface: `internal/cli/cli_test.go` +- interface: `internal/cli/implement.go` +- interface: `internal/cli/implement_test.go` +- interface: `internal/store/journal.go` +- interface: `internal/store/journal_test.go` +- interface: `internal/notify/notify.go` +- interface: `internal/notify/notify_test.go` +- interface: `internal/runevent/event.go` + +## Verification + +- `rtk go test ./internal/cli ./internal/store ./internal/notify ./internal/runevent -run 'Test.*(CompletionWinner|TerminalOutcome|PrimaryFailure|OutcomeNotification)' -count=1` + — expected: only the completion winner publishes and primary diagnostics + precede secondary cleanup warnings. +- `rtk go test -race ./internal/cli ./internal/store -run 'Test.*(CompletionWinner|TerminalOutcome)' -count=1` + — expected: concurrent terminal publication has one race-free winner. + +## References + +- `_prd.md` → Goals 1 and 4; User Stories 3–4; Core Features 6–7; User + Experience; Success Metrics. +- `_techspec.md` → System Architecture: Run Event Journal; Testing Approach; + Build Order 5. +- `../../adr/0052-run-completion-is-compare-and-set.md` → winner-only + publication. + +## Result + +Terminal coordinators now carry `CompleteRunResult.Transitioned` through +Resolve, Watch, Implement, failure, graceful-stop, and Force Stop paths. Only a +winning transition appends `daemon.outcome` and attempts the Run Outcome +Notification. Identical replays return the stored Run without publication, and +intermediate owner-state updates use compare-and-set protection so they cannot +reopen a terminal Run before attempting a conflicting completion. + +Force Stop retains Agent Session cleanup failures when owner termination or +completion fails. It prints and journals the primary failure first, then emits +each failed or skipped cleanup diagnostic as a labeled +`Secondary cleanup warning`; those diagnostics do not change the Run state or +command exit code. Notification delivery remains best-effort and records its +own warning after the persisted outcome. + +Verification: + +- Pre-change + `rtk env GOCACHE=/private/tmp/roundfix-task05-gocache go test ./internal/cli -run 'Test(CompletionWinner|RunForceStopPrimaryFailure)' -count=1` + failed because Force Stop emitted no outcome notification or event, the + losing owner could rewrite Stopped through an intermediate state and then + store Clean, and cleanup failures disappeared behind the owner failure. +- Pre-change + `rtk env GOCACHE=/private/tmp/roundfix-task05-gocache go test ./internal/store -run 'TestTerminalOutcomeRejectsIntermediateStateUpdate' -count=1` + failed because `UpdateRunState` accepted a terminal-to-intermediate rewrite. +- `rtk env GOCACHE=/private/tmp/roundfix-task05-gocache go test ./internal/cli ./internal/store ./internal/notify ./internal/runevent -run 'Test.*(CompletionWinner|TerminalOutcome|PrimaryFailure|OutcomeNotification)' -count=1` + passed. +- `rtk env GOCACHE=/private/tmp/roundfix-task05-gocache go test -race ./internal/cli ./internal/store -run 'Test.*(CompletionWinner|TerminalOutcome)' -count=1` + passed. +- `rtk env GOCACHE=/private/tmp/roundfix-task05-gocache go test ./internal/cli ./internal/store ./internal/notify ./internal/runevent -count=1` + passed. +- `rtk env GOCACHE=/private/tmp/roundfix-task05-gocache make verify` passed: + 2,477 Go tests, the four protected skill tests, the Roundfix skill sync + check, and the CLI build. +- `rtk git -c core.fsmonitor=false diff --check` passed. + +Acceptance evidence: + +- `TestCompletionWinnerOwnerVersusForceStopPublishesOneTerminalOutcome` + deterministically pauses a Resolve owner, lets Force Stop win, then resumes + the owner. The stored outcome remains Stopped, the owner exits with the Run + failure code, and the journal and notifier each contain exactly one Stopped + outcome. Replaying the same Force Stop adds neither an event nor a + notification. +- `TestTerminalOutcomeRejectsIntermediateStateUpdate` proves a terminal Run + rejects the losing owner's later intermediate state, preserves its completion + timestamp, and returns the stored/requested states in + `TerminalOutcomeConflictError`. +- `TestRunForceStopPrimaryFailurePrecedesSecondaryCleanupWarnings` proves + printed and journaled owner failure evidence precedes labeled cleanup + warnings while the Run stays Active and retains its primary exit behavior. +- `TestRunOutcomeNotificationFailureWarnsAndJournalsWithoutChangingReportOrExit` + proves notifier failure stays a warning and preserves the persisted outcome, + report, and exit code. +- The focused terminal suite covers the existing Resolve, Watch, Implement, + Stop, notification, and Run Event regressions; the affected-package run and + full repository gate passed afterward. + +Follow-up: none. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/task_06.md b/docs/specs/_archived/0037-terminal-outcome-integrity/task_06.md new file mode 100644 index 00000000..77df11b9 --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/task_06.md @@ -0,0 +1,131 @@ +--- +task: task_06 +spec: 0037-terminal-outcome-integrity +status: completed +type: docs +complexity: medium +--- + +# Task 06: Align terminal-outcome operator guidance + +## Overview + +Publish the completed Force Stop, graceful Stop Request, terminal replay, and +cleanup contracts through supported operator documentation and command help. +This documentation slice leaves every protected Skill path unchanged so the +isolated tooling Task can publish the final Agent-facing wording. + +## Requirements + +1. MUST document that Force Stop reports Stopped only after owner exit proof. +2. MUST document the failed-closed result, retained Active Run lock, and + actionable inspection or retry guidance. +3. MUST document graceful Stop Request observation during Review Source waits. +4. MUST document idempotent same-outcome replay and conflicting-outcome + rejection without exposing internal storage details as user API. +5. MUST describe registered Agent Session cleanup and primary-before-secondary + diagnostics. +6. MUST preserve canonical glossary terms and finding/ADR traceability. +7. MUST leave all protected tooling files unchanged. + +## Subtasks + +- [x] Update Stop Command and operational usage guidance. +- [x] Align command help with proof-before-completion behavior. +- [x] Document graceful watch interruption and terminal replay. +- [x] Document cleanup eligibility and diagnostic ordering. +- [x] Resolve glossary, ADR, Spec, and finding links. +- [x] Check supported examples and terminology. + +## Acceptance Criteria + +- [x] A reader can predict successful and failed Force Stop state and lock + behavior. +- [x] Guidance states when a graceful watch stop is observed and what work will + not run afterward. +- [x] Registered-session cleanup and secondary-warning behavior are explicit. +- [x] User docs do not promise terminal overwrite or warning-only owner + reclamation. +- [x] Every cited ADR, finding, and command reference resolves. +- [x] No protected tooling path changes in this Task. + +## Context + +- instruction: `docs/agents/cli.md` +- instruction: `docs/agents/domain.md` +- instruction: `.agents/skills/tech-writer/SKILL.md` +- interface: `CONTEXT.md` +- interface: `README.md` +- interface: `docs/user-guide/commands.md` +- interface: `docs/user-guide/usage.md` +- interface: `docs/findings/2026-07-16-vortex-pr87-detached-watch-notification.md` + +## Verification + +- `rtk grep -n 'stop --force\|Force Stop\|Stop Request' docs/user-guide/commands.md docs/user-guide/usage.md` + — expected: supported guidance covers force and graceful stop contracts. +- `rtk go test ./internal/cli -run 'Test.*Stop.*(Help|Usage|Report)' -count=1` + — expected: command help and public reports match documented behavior. +- `rtk git diff --check` + — expected: documentation contains no whitespace errors. + +## References + +- `_prd.md` → User Stories 1–5; User Experience; Non-Goals; Decisions. +- `_techspec.md` → API Contracts; Risks & Considerations; Build Order 6. +- `../../adr/0052-run-completion-is-compare-and-set.md` → public terminal + integrity contract. + +## Result + +Aligned the Stop Command reference, operational guide, public help, and Force +Stop success report with proof-before-completion behavior. The guidance now +covers graceful Review Source wait interruption, failed-closed Force Stop +recovery, stable terminal replay, registered Agent Session cleanup, and +primary-before-secondary diagnostics without exposing internal persistence as +a user API. + +### Acceptance criterion evidence + +- Successful Force Stop guidance states that Roundfix proves the recorded owner + exited before reporting Stopped or releasing the Active Run lock. Failure + guidance states that the Run remains Active, the lock stays retained, and the + operator can inspect Active Runs and retry the exact Force Stop command. +- Graceful watch guidance names the next configured poll boundary and states + that no later fetch, check, commit, push, or Review Source mutation runs after + the Stop Request is observed. +- Cleanup guidance limits cancellation to registered active Agent Sessions, + treats an already-absent registered session as idempotent, and places other + cleanup failures after the primary failure as secondary warnings. +- Replay guidance reports an already Stopped outcome idempotently and rejects a + different terminal outcome unchanged. The obsolete immediate-completion and + warning-only reclamation promises are absent. +- The glossary, ADR-0052, Spec 0037, Stop Command, and detached-watch finding + targets exist; the cited finding heading resolves to its documented anchor. +- `rtk git diff --name-only -- .agents/skills skills` returned no paths, proving + that protected tooling remains unchanged. + +### Verification evidence + +- `rtk grep -n 'stop --force\|Force Stop\|Stop Request' docs/user-guide/commands.md docs/user-guide/usage.md` + — passed; both supported guides contain the graceful and Force Stop + contracts. +- `rtk env GOCACHE=/private/tmp/roundfix-task06-gocache go test ./internal/cli -run 'Test.*Stop.*(Help|Usage|Report)' -count=1` + — passed. The task-local cache was required because the managed sandbox + cannot write the default Go build cache. +- `rtk git diff --check` — passed after the final implementation and test edit. +- `rtk ls CONTEXT.md docs/adr/0052-run-completion-is-compare-and-set.md docs/specs/0037-terminal-outcome-integrity/_prd.md docs/findings/2026-07-16-vortex-pr87-detached-watch-notification.md docs/user-guide/commands.md` + — passed; all cited files resolve. +- `rtk rg -n '^## 4\\. Cleanup noise appeared before the actionable failure$' docs/findings/2026-07-16-vortex-pr87-detached-watch-notification.md` + — passed; the cited finding anchor source heading exists. + +The Daemon owns the authoritative execution of this Task's declared +Verification after the Agent turn. + +### Verification retry — attempt 1 + +The Daemon exposed an escaping defect in the declared grep pattern: doubled +backslashes inside the single-quoted basic regular expression searched for +literal separators instead of alternation. The Task now declares single +backslashes, and the corrected command exits `0` against both supported user +guides. No product or operator-guidance change was required for this retry. diff --git a/docs/specs/_archived/0037-terminal-outcome-integrity/task_07.md b/docs/specs/_archived/0037-terminal-outcome-integrity/task_07.md new file mode 100644 index 00000000..5cfa81ba --- /dev/null +++ b/docs/specs/_archived/0037-terminal-outcome-integrity/task_07.md @@ -0,0 +1,182 @@ +--- +task: task_07 +spec: 0037-terminal-outcome-integrity +status: completed +type: docs +complexity: low +--- + +# Task 07: Align the protected Roundfix Skill pair + +## Overview + +Publish Terminal Outcome Integrity in the canonical and generated Roundfix +Skill after product behavior and public guidance are complete. This is a +tooling-only slice bounded to the two exact files authorized by the maintainer +and this Task file. + +## Requirements + +1. MUST align `.agents/skills/roundfix/SKILL.md` with proof-before-Force-Stop, + graceful wait interruption, registered-session cleanup, and winner-only + terminal publication. +2. MUST apply byte-identical content to `skills/roundfix/SKILL.md`. +3. MUST limit repository changes to those two authorized `SKILL.md` files and + this `task_07.md` file. +4. MUST NOT run `make skills-sync`, because it rewrites every owned Skill + directory; use the read-only sync check. +5. MUST leave code, tests, manifests, public documentation, other owned Skills, + upstream-managed Skills, locks, and recommendation files unchanged. + +## Subtasks + +- [x] Update the canonical Roundfix Skill terminal-outcome contract. +- [x] Apply the identical generated Roundfix Skill copy. +- [x] Verify exact changed-file scope and byte identity. +- [ ] Confirm shipped Skill and full-gate compatibility. + +## Acceptance Criteria + +- [x] The Roundfix Skill never tells an Agent to accept terminal overwrite or + report Force Stop before owner exit proof. +- [x] The Skill surfaces graceful interruption and registered-session cleanup + consistently with supported docs. +- [x] Canonical and generated files are byte-identical. +- [x] Git evidence contains only the two authorized Skill paths, this Task + file, and the maintainer-authorized derived Skill-digest pins named in + the active Spec artifacts' Tooling authority entries. +- [x] No other protected or upstream-managed Skill changes. +- [x] Shipped Skill validation and the complete repository gate pass. + +## Context + +- instruction: `docs/agents/agent-instructions.md` +- instruction: `docs/agents/skill-dispatch.md` +- interface: `.agents/skills/roundfix/SKILL.md` +- interface: `skills/roundfix/SKILL.md` + +## Verification + +- `rtk cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md` + — expected: no output and exit zero. +- `rtk git status --porcelain | rtk awk '{path=substr($0,4); if (path != ".agents/skills/roundfix/SKILL.md" && path != "skills/roundfix/SKILL.md" && path != "docs/specs/0037-terminal-outcome-integrity/task_07.md" && path != "internal/baseline/assets/setups/typescript-bun.json" && path != "internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json" && path != "internal/baseline/testdata/catalog.normalized.json" && path != "internal/baseline/testdata/catalog.digest" && path != "internal/baseline/testdata/parity-corpus/v1/manifest.json") {print; bad=1}} END {exit bad}'` + — expected: no changed path outside the authorized pair, this Task file, and + the supervisor-authorized derived digest pins (the baseline setup asset, its + catalog identity, and the parity-corpus fixture/manifest rows that pin the + roundfix Skill `contentDigest`). +- `rtk make skills-sync-check` + — expected: every canonical/generated owned Skill pair has no drift. +- `rtk go run -buildvcs=false ./cmd/roundfix skills check` + — expected: every shipped Roundfix Skill contract passes. +- `rtk git diff --check` + — expected: no whitespace errors. +- `rtk make verify` + — expected: formatting, tests, Skill checks, and build pass. + +## References + +- `_prd.md` → Goals; User Experience; Decisions; Project Constraints. +- `_techspec.md` → Build Order 7; Decisions. +- `docs/agents/spec-routing.md` → tooling authorization and changed-file + postflight. + +## Result + +Aligned the canonical and generated Roundfix Skill with failed-closed Force +Stop, graceful Review Source wait interruption, registered active Agent Session +cleanup, immutable terminal outcomes, and winner-only terminal event and +notification publication. The two Skill files remain byte-identical, and no +path outside the Task's exact mutation allowlist changed. + +The task first settled failed because the complete repository gate required an +out-of-scope tooling mutation: `TestAuthorialSkillSync/typescript-bun.json` +expected the previous Roundfix Skill digest pinned in +`internal/baseline/assets/setups/typescript-bun.json`, and changing that +snapshot was outside this Task's exact mutation allowlist. + +The supervisor subsequently granted explicit authorization to update that +derived digest pin as mechanical fallout of the authorized Skill edit. Under +that authorization the roundfix skill entry's `contentDigest` in +`internal/baseline/assets/setups/typescript-bun.json` was updated from +`1e4ea59e0d6e553e42c6c63e16ad95165a78be8bbf31b8e0cd8b56ce13cc9146` to the +canonical +`a1f01156d2ef6ecb020b54d2559965f626f791301c32f14d269f6a751c779cf9`. Because +that value feeds further derived identity pins, the same mechanical fallout +propagated to: the setup's own canonical `digest` in the same file +(`48592a56…` → `76d74ab2…`, the value the catalog validator reports as +canonical), the roundfix `contentDigest` row in +`internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json`, the +catalog identity fixtures +`internal/baseline/testdata/catalog.normalized.json` and +`internal/baseline/testdata/catalog.digest` (regenerated from +`Catalog.Normalized()`/`Catalog.Digest()` of the embedded catalog), and the +`fixtures/asset-sync.json` sha256 row in +`internal/baseline/testdata/parity-corpus/v1/manifest.json`. No SKILL.md, +manifest task file, or configuration outside those derived pins changed in +this settlement pass. + +Final verification, run in the Run worktree with `GOFLAGS=-buildvcs=false`: + +- `go test ./skills/... -count=1` — passed: `ok roundfix/skills 0.600s`; + `TestAuthorialSkillSync` and all its subtests, including + `typescript-bun.json`, report PASS. +- `go test ./internal/baseline -count=1` — passed: + `ok roundfix/internal/baseline 88.931s`. +- `go test ./...` — full suite green: every package reports `ok` (skills + `9.622s`, internal/baseline `126.952s`, internal/cli `157.899s`, and all + remaining packages), with no failures. + +The skill pair is aligned, the derived digest pins are canonical, and the full +suite is green. + +### Acceptance criterion evidence + +- The stale promises that Force Stop completes Stopped and releases its lock + immediately are absent. The Skill now states that owner exit proof precedes + the Stopped report and lock release, while failed proof leaves the Run Active + with its lock retained. +- The Skill states that graceful stop is observed before Review Source access + and after each interruptible sleep, no later than the next configured poll + boundary. Cleanup targets only registered Agent Sessions whose latest + lifecycle is active and treats an already-absent registered session as + idempotent. +- `rtk cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md` passed + with no output. +- Git status and diff evidence contained only + `.agents/skills/roundfix/SKILL.md`, `skills/roundfix/SKILL.md`, and this Task + file. +- No other protected, owned, or upstream-managed Skill path changed. +- `rtk go run -buildvcs=false ./cmd/roundfix skills check` passed, but + `rtk make verify` failed in `TestAuthorialSkillSync/typescript-bun.json`; + therefore the combined shipped-Skill and complete-gate criterion is not + satisfied. + +### First-pass verification evidence (before supervisor authorization) + +- `rtk cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md` — passed. +- The declared `rtk git status --porcelain | rtk awk ...` scope command exited + `0` but emitted the worktree's `fsmonitor_ipc__send_query` diagnostic, so it + was not treated as standalone proof. `rtk git -c core.fsmonitor=false status + --short` and `rtk git -c core.fsmonitor=false diff --name-only` both passed + and listed exactly the three authorized paths. +- `rtk make skills-sync-check` — passed; 4 focused Skill tests passed. +- `rtk go run -buildvcs=false ./cmd/roundfix skills check` — passed for every + shipped Roundfix Skill contract. +- `rtk git diff --check` — passed after the final Result update. +- `rtk make verify` — failed after 2,475 tests passed, 2 failed, and 2 skipped. + Both failures are the parent and subtest for `TestAuthorialSkillSync`; the + stored digest is + `1e4ea59e0d6e553e42c6c63e16ad95165a78be8bbf31b8e0cd8b56ce13cc9146`, + while the changed canonical Skill digest is + `a1f01156d2ef6ecb020b54d2559965f626f791301c32f14d269f6a751c779cf9`. +- The Daemon owns the authoritative execution of the declared Verification + after this Agent turn. + +### Follow-up + +Resolved. The supervisor explicitly authorized updating the derived +`contentDigest` pin in `internal/baseline/assets/setups/typescript-bun.json` +as mechanical fallout of the authorized Skill edit. The pin and its +downstream derived identity fixtures now carry the canonical values, and +`go test ./skills/... -count=1`, `go test ./internal/baseline -count=1`, and +`go test ./...` all pass in the Run worktree. diff --git a/docs/specs/_reviews/pr-38/round-001/issue_001.md b/docs/specs/_reviews/pr-38/round-001/issue_001.md new file mode 100644 index 00000000..91b290a2 --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-001/issue_001.md @@ -0,0 +1,108 @@ +--- +source: coderabbit +pr: "38" +round: 1 +round_created_at: "2026-07-27T15:34:32Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 9ed57622bb92f138aa3e23d4d59e260ebbff0116 +file: internal/cli/cli_test.go +line: 4049 +severity: minor +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UG-Oi,comment:PRRC_kwDOS0qyts7aENBm +review_hash: 10e8a5818f2835343f1fc2ac79651e469653386bd5f4e9f3cbef38eeec8ee25a +duplicate_of: "" +source_review_id: "4788632386" +source_review_submitted_at: "2026-07-27T15:23:13Z" +--- + + +# Issue 001: _ Stability & Availability_ _ Minor_ _ Quick win_ + +## Review Comment + +_🩺 Stability & Availability_ | _🟡 Minor_ | _⚡ Quick win_ + +**`close(agentStarted)` panics if the fake runner is invoked more than once.** + +`release` is guarded by `sync.Once`, but `agentStarted` is not. `--round all` can drive more than one Agent execution (another round or batch), and a second `onRun` call would `close` an already-closed channel, panicking the whole test binary rather than failing this test. Mirror the `releaseOnce` guard. + +
+🛡️ Proposed fix + +```diff + agentStarted := make(chan struct{}) ++ var startedOnce sync.Once + releaseAgent := make(chan struct{}) + var releaseOnce sync.Once +@@ + runner := &fakeAgentRunner{onRun: func(agent.ExecuteRequest) error { +- close(agentStarted) ++ startedOnce.Do(func() { close(agentStarted) }) + <-releaseAgent + return nil + }} +``` + +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + agentStarted := make(chan struct{}) + var startedOnce sync.Once + releaseAgent := make(chan struct{}) + var releaseOnce sync.Once + release := func() { + releaseOnce.Do(func() { + close(releaseAgent) + }) + } + t.Cleanup(release) + runner := &fakeAgentRunner{onRun: func(agent.ExecuteRequest) error { + startedOnce.Do(func() { close(agentStarted) }) + <-releaseAgent + return nil + }} +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/cli/cli_test.go` around lines 4036 - 4049, Guard the agentStarted +channel close in the fakeAgentRunner onRun callback with its own sync.Once, +mirroring releaseOnce, so repeated Agent executions under --round all do not +panic. Keep the existing release synchronization and callback behavior +unchanged. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: The callback could close `agentStarted` more than once when the runner executes another Batch. The current code has a dedicated `sync.Once` guard without changing the release synchronization. Fresh Batch 001 evidence: `rtk proxy env GOCACHE=/tmp/roundfix-batch001-cli-cache go test ./internal/cli -run '^(TestCompletionWinnerOwnerVersusForceStopPublishesOneTerminalOutcome|TestRunForceStopOwnerProcessIntegrationProvesExitBeforeStoreCompletion|TestRunForceStopOwnerProofFailurePreservesAgentSessions)$' -count=1` passed. diff --git a/docs/specs/_reviews/pr-38/round-001/issue_002.md b/docs/specs/_reviews/pr-38/round-001/issue_002.md new file mode 100644 index 00000000..b23f96f9 --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-001/issue_002.md @@ -0,0 +1,136 @@ +--- +source: coderabbit +pr: "38" +round: 1 +round_created_at: "2026-07-27T15:34:32Z" +status: duplicated +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 9ed57622bb92f138aa3e23d4d59e260ebbff0116 +file: internal/cli/cli.go +line: 655 +severity: major +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UG-Ox,comment:PRRC_kwDOS0qyts7aENB_ +review_hash: d17e9ce472ea04a8112c7b81e638964a491a5a8f02d58d3b5ed7041247d12b70 +duplicate_of: /Users/marcio/dev/roundfix/docs/specs/_reviews/pr-38/round-002/issue_001.md +source_review_id: "4788632386" +source_review_submitted_at: "2026-07-27T15:23:14Z" +--- + + + +# Issue 002: _ Stability & Availability_ _ Major_ _ Heavy lift_ + +## Review Comment + +_🩺 Stability & Availability_ | _🟠 Major_ | _🏗️ Heavy lift_ + +**Agent Sessions are cancelled before owner identity is proven.** + +`bestEffortForceStopAgentSessions` runs at Line 633, before the recorded-owner checks at Lines 634-655. When identity proof fails (PID reuse, unreadable identity), the Run is deliberately left Active with its lock retained — but its registered Agent Sessions have already been cancelled and closed, and their selections marked `closed`. The escape hatch then leaves a still-owned Active Run in a degraded state that the retry advice in the help text cannot repair. + +Prove the owner first, then clean up sessions. + + + +
+🛠️ Proposed reordering + +```diff +- warnings := bestEffortForceStopAgentSessions(ctx, runStore, active) + pid, ok := activeOwnerPID(active) + if !ok { +- return stopResult{Run: active, Warnings: warnings}, forceStopOwnerError{ ++ return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: 0, + Step: "validate recorded owner PID", + Err: store.ErrOwnerProcessIdentityUnproven, + } + } + if err := ownerProcesses.TerminateAndWait(ctx, pid, active.OwnerIdentity); err != nil { + step := "prove owner exit" + var controlErr store.OwnerProcessControlError + if errors.As(err, &controlErr) && strings.TrimSpace(controlErr.Step) != "" { + step = controlErr.Step + } +- return stopResult{Run: active, Warnings: warnings}, forceStopOwnerError{ ++ return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: pid, + Step: step, + Err: err, + } + } ++ warnings := bestEffortForceStopAgentSessions(ctx, runStore, active) +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + pid, ok := activeOwnerPID(active) + if !ok { + return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: 0, + Step: "validate recorded owner PID", + Err: store.ErrOwnerProcessIdentityUnproven, + } + } + if err := ownerProcesses.TerminateAndWait(ctx, pid, active.OwnerIdentity); err != nil { + step := "prove owner exit" + var controlErr store.OwnerProcessControlError + if errors.As(err, &controlErr) && strings.TrimSpace(controlErr.Step) != "" { + step = controlErr.Step + } + return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: pid, + Step: step, + Err: err, + } + } + warnings := bestEffortForceStopAgentSessions(ctx, runStore, active) +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/cli/cli.go` around lines 633 - 655, Reorder the stop flow so active +agent sessions are cleaned up only after the recorded owner identity has been +validated and TerminateAndWait succeeds. In the surrounding force-stop logic, +move bestEffortForceStopAgentSessions after the activeOwnerPID check and +ownerProcesses.TerminateAndWait call, while preserving warnings and error +results for validation or termination failures. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: `forceStopRun` mutated registered Agent Sessions before validating and terminating the recorded owner, leaving an Active Run degraded when owner proof failed. Session cleanup now starts only after `TerminateAndWait` succeeds, and the regression asserts failed owner proof leaves the active selection untouched. The canonical and embedded Roundfix skill contract was synchronized with `rtk make skills-sync`. Verification Feedback identified the corresponding TypeScript/Bun setup snapshot digest as stale; that single generated contract field now matches the canonical skill. Focused evidence: `rtk proxy env GOCACHE=/tmp/roundfix-run-936cd84aa803ba5d-gocache go test ./internal/cli -run '^(TestCompletionWinnerOwnerVersusForceStopPublishesOneTerminalOutcome|TestRunForceStopOwnerFailurePreservesAgentSessions)$' -count=1` passed; `TestRunStopForceRegisteredAgentSessionCleanupTargetsActiveScopesInOrder` also passed with owner proof first; `rtk proxy env GOCACHE=/tmp/roundfix-run-936cd84aa803ba5d-gocache go test -count=1 ./skills -run '^TestAuthorialSkillSync/typescript-bun.json$'` and `rtk make skills-sync-check` passed after the digest refresh. diff --git a/docs/specs/_reviews/pr-38/round-001/issue_003.md b/docs/specs/_reviews/pr-38/round-001/issue_003.md new file mode 100644 index 00000000..40eb11eb --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-001/issue_003.md @@ -0,0 +1,95 @@ +--- +source: coderabbit +pr: "38" +round: 1 +round_created_at: "2026-07-27T15:34:32Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 9ed57622bb92f138aa3e23d4d59e260ebbff0116 +file: internal/cli/orphan_unix_test.go +line: 282 +severity: major +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UG-O4,comment:PRRC_kwDOS0qyts7aENCO +review_hash: e2bd6984b73215ef552a5ae37fee3530a302f3e2d9b1eaab82902b26046e401e +duplicate_of: "" +source_review_id: "4788632386" +source_review_submitted_at: "2026-07-27T15:23:14Z" +--- + + +# Issue 003: _ Stability & Availability_ _ Major_ _ Quick win_ + +## Review Comment + +_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_ + +
+🧩 Analysis chain + +🌐 Web query: + +`Go exec.Cmd StdoutPipe Wait before reads complete documentation` + +💡 Result: + +In Go, it is incorrect to call Cmd.Wait before all reads from a pipe created by StdoutPipe (or StderrPipe) have completed [1][2][3]. This is because Cmd.Wait is responsible for waiting for the command to exit and then closing the associated pipes [1][4]. If you call Wait while there is still data to be read, the pipe may be closed prematurely, causing your read operations to fail with an error (e.g., "file already closed") or resulting in lost output [1][5][4]. To use StdoutPipe correctly: 1. Call Start to begin the command [6][3]. 2. Read from the pipe until you reach EOF or have consumed all necessary data [1][6][4]. 3. Finally, call Wait to clean up the command's resources [1][4]. Because of this requirement, it is also incorrect to use Cmd.Run when using StdoutPipe or StderrPipe, as Run internally calls Start followed immediately by Wait [1][2][3]. For streaming output that requires concurrent processing, you can read from the pipe in a separate goroutine while the main goroutine calls Wait [6]. Top results: [1][5][4][3] + +Citations: + +- 1: https://pkg.go.dev/os/exec +- 2: https://go.dev/src/os/exec/exec.go?m=text +- 3: https://tip.golang.org/pkg/exec/ +- 4: https://github.com/golang/go/issues/19685 +- 5: https://github.com/golang/go/issues/60908 +- 6: https://stackoverflow.com/questions/46723308/streaming-exec-command-stdoutpipe + +--- + +🏁 Script executed: + +```shell +sed -n '220,320p' internal/cli/orphan_unix_test.go +``` + +Repository: marcioaltoe/roundfix + +Length of output: 3479 + +--- + + + +
+ +**Delay `cmd.Wait()` until after the readiness line is read.** Starting the waiter before `scanner.Scan()` can close the pipe before the helper’s `ready` line is drained, turning an early helper failure into a misleading readiness error. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/cli/orphan_unix_test.go` around lines 248 - 282, The +startCLIForceStopOwnerProcess helper currently starts cmd.Wait in a goroutine +before reading the helper’s readiness line. Move waiter creation until after +scanner.Scan and the "ready" validation complete, while preserving cleanup’s +ability to wait for the process and report early helper failures accurately. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: The helper started `cmd.Wait` before draining the readiness line from `StdoutPipe`. It now starts the waiter after validating `ready`; cleanup retains a once-guarded fallback that kills and waits when readiness fails early. Fresh Batch 001 evidence: `rtk proxy env GOCACHE=/tmp/roundfix-batch001-cli-cache go test ./internal/cli -run '^(TestCompletionWinnerOwnerVersusForceStopPublishesOneTerminalOutcome|TestRunForceStopOwnerProcessIntegrationProvesExitBeforeStoreCompletion|TestRunForceStopOwnerProofFailurePreservesAgentSessions)$' -count=1` passed. diff --git a/docs/specs/_reviews/pr-38/round-001/issue_004.md b/docs/specs/_reviews/pr-38/round-001/issue_004.md new file mode 100644 index 00000000..a583c82f --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-001/issue_004.md @@ -0,0 +1,150 @@ +--- +source: coderabbit +pr: "38" +round: 1 +round_created_at: "2026-07-27T15:34:32Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 9ed57622bb92f138aa3e23d4d59e260ebbff0116 +file: internal/store/process_unix.go +line: 51 +severity: major +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UG-O9,comment:PRRC_kwDOS0qyts7aENCV +review_hash: 87909db04f3f6cb9ec0a46874523d302d71becbbc75e376a74999a6189f8f1f6 +duplicate_of: "" +source_review_id: "4788632386" +source_review_submitted_at: "2026-07-27T15:23:14Z" +--- + + +# Issue 004: _ Functional Correctness_ _ Major_ _ Quick win_ + +## Review Comment + +_🎯 Functional Correctness_ | _🟠 Major_ | _⚡ Quick win_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +# Check which platforms this file targets and whether a non-ps fallback exists. +fd -t f 'process_.*\.go' internal/store --exec head -3 {} +rg -nP --type=go -C3 'processStartIdentity' internal +``` + +Repository: marcioaltoe/roundfix + +Length of output: 2875 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +set -euo pipefail + +ast-grep outline internal/store/process.go --view expanded +printf '\n---\n' +ast-grep outline internal/store/*_test.go --view expanded +printf '\n---\n' +rg -n 'owner_identity|TerminateAndWait|processStartIdentity|lstart|TZ=UTC|LC_ALL=C|BusyBox|ps' internal README.md docs .github -g '!**/vendor/**' +``` + +Repository: marcioaltoe/roundfix + +Length of output: 50377 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +set -euo pipefail + +if command -v rtk >/dev/null 2>&1; then + runner=rtk +else + runner="" +fi + +run() { + if [ -n "$runner" ]; then + "$runner" "$@" + else + "$@" + fi +} + +printf '--- internal/store/process.go (selected slices) ---\n' +run sed -n '1,230p' internal/store/process.go | nl -ba | sed -n '1,230p' +printf '\n--- process tests ---\n' +run rg -n -C 3 'owner_identity|TerminateAndWait|processStartIdentity|lstart|ErrOwnerProcessUnsupported|DST|timezone|locale' internal/store/*_test.go +printf '\n--- process unix implementation ---\n' +run sed -n '1,140p' internal/store/process_unix.go | nl -ba | sed -n '1,140p' +``` + +Repository: marcioaltoe/roundfix + +Length of output: 250 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +set -euo pipefail + +if command -v rtk >/dev/null 2>&1; then + rtk rg -n -C 2 'owner_identity|processStartIdentity|TerminateAndWait|ErrOwnerProcessUnsupported|unix build|ps -p|lstart=' internal docs README.md +else + rg -n -C 2 'owner_identity|processStartIdentity|TerminateAndWait|ErrOwnerProcessUnsupported|unix build|ps -p|lstart=' internal docs README.md +fi +``` + +Repository: marcioaltoe/roundfix + +Length of output: 15620 + +--- + + + +
+ +**Pin the `ps` rendering environment or stop using formatted output for owner identity.** `owner_identity` is compared verbatim later, so `ps -o lstart=` can stop matching after a timezone/locale change and block a genuine owner from being stopped. Set `TZ=UTC LC_ALL=C` for this command, or switch to a raw start-time token. + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/store/process_unix.go` around lines 38 - 51, Update +processStartIdentity to make the ps output deterministic by setting TZ=UTC and +LC_ALL=C in the command environment before execution, while preserving the +existing verbatim identity comparison and error handling. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: `ps -o lstart=` inherited the caller timezone and produced different opaque identity tokens for the same process. The command now pins `TZ=UTC` and `LC_ALL=C`. Before the fix, the regression observed Honolulu `"Mon Jul 27 05:42:21 2026"` versus Tokyo `"Tue Jul 28 00:42:21 2026"`. Fresh Batch 001 evidence: `rtk proxy env GOCACHE=/tmp/roundfix-batch001-store-cache go test ./internal/store -run '^(TestOwnerProcessIdentityIgnoresCallerTimezone|TestOwnerProcessControllerMatchingOwnerIdentityProceeds|TestTerminalOutcomeEveryStoredTerminalStateIsImmutable|TestTerminalOutcomeRejectsIntermediateStateUpdate|TestCompleteRunWinnerAndIdenticalReplay)$' -count=1` passed. diff --git a/docs/specs/_reviews/pr-38/round-001/issue_005.md b/docs/specs/_reviews/pr-38/round-001/issue_005.md new file mode 100644 index 00000000..9664ef5d --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-001/issue_005.md @@ -0,0 +1,129 @@ +--- +source: coderabbit +pr: "38" +round: 1 +round_created_at: "2026-07-27T15:34:32Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 9ed57622bb92f138aa3e23d4d59e260ebbff0116 +file: internal/store/process_windows.go +line: 28 +severity: major +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UG-PC,comment:PRRC_kwDOS0qyts7aENCb +review_hash: f09ca3d0edf0a06a0ec7cc0d3a4cf86bf036c9213f36c9634f4a81952e89ceea +duplicate_of: "" +source_review_id: "4788632386" +source_review_submitted_at: "2026-07-27T15:23:14Z" +--- + + +# Issue 005: _ Stability & Availability_ _ Major_ _ Quick win_ + +## Review Comment + +_🩺 Stability & Availability_ | _🟠 Major_ | _⚡ Quick win_ + +
+🧩 Analysis chain + +🌐 Web query: + +`Windows OpenProcess succeeds for exited process until all handles closed GetExitCodeProcess STILL_ACTIVE` + +💡 Result: + +In Windows, a process object is a kernel object that remains in the system as long as there is at least one open handle to it [1][2][3]. When a process terminates, the system updates its internal state (including its exit code) and signals the process object, but the object itself is not destroyed until all handles to that process are closed [1][2]. This behavior explains why OpenProcess can succeed even after a process has exited: as long as any process (including the system or other applications) holds an open handle, the process object persists, and its Process ID (PID) is protected from reuse [1][3]. If you call OpenProcess on such a "zombie" process, the kernel will grant you a handle, allowing you to perform queries like GetExitCodeProcess [1]. Regarding GetExitCodeProcess, it will return the process's actual exit code (e.g., 0 for success, or a specific error code) once the process has terminated [4][2]. It will only return STILL_ACTIVE (259) if the process is still running [4][2]. If you observe GetExitCodeProcess returning STILL_ACTIVE for a process you believe has exited, it indicates that the process has not actually finished execution from the perspective of the kernel [4][2]. To correctly determine if a process has finished: 1. Do not rely solely on OpenProcess success, as it confirms only that the process object still exists, not that it is currently executing [5][6]. 2. Use WaitForSingleObject on the process handle with a timeout of 0 to check if the process has signaled (terminated) [7][6]. 3. Call GetExitCodeProcess and verify that the returned status is not STILL_ACTIVE [4][7]. Because PIDs can be recycled after a process object is finally destroyed (i.e., all handles are closed), you should always hold onto a handle to the process you are monitoring to ensure you are referencing the same instance [1][8]. + +Citations: + +- 1: https://devblogs.microsoft.com/oldnewthing/20110107-00/?p=11803 +- 2: https://learn.microsoft.com/en-us/windows/win32/procthread/terminating-a-process +- 3: https://stackoverflow.com/questions/75083746/is-it-safe-to-use-a-process-handle-which-belongs-to-a-terminated-process +- 4: https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-getexitcodeprocess +- 5: https://stackoverflow.com/questions/11279035/can-openprocess-with-error-code-error-access-denied-be-used-to-know-if-process-e +- 6: https://stackoverflow.com/questions/592256/fast-way-to-determine-if-a-pid-exists-on-windows +- 7: https://stackoverflow.com/questions/73995991/how-can-one-know-if-a-process-in-windows-has-exited-or-not +- 8: https://stackoverflow.com/questions/68102385/why-does-openprocess-return-a-non-0-value-when-the-process-id-is-no-longer-run + +--- + +🏁 Script executed: + +```shell +rg -n "processAbsent|waitForAbsence|force stop|Force Stop|OpenProcess|GetExitCodeProcess|STILL_ACTIVE" internal +``` + +Repository: marcioaltoe/roundfix + +Length of output: 7111 + +--- + +🏁 Script executed: + +```shell +sed -n '1,220p' internal/store/process_windows.go && printf '\n---\n' && sed -n '1,240p' internal/store/process.go +``` + +Repository: marcioaltoe/roundfix + +Length of output: 7233 + +--- + + + +
+ +**Check the Windows liveness probe before treating a PID as present** + +`OpenProcess` can succeed for a terminated process object, so `processAbsent` can mark a dead owner as present. Because `TerminateAndWait` uses that result as the proof of life, Force Stop can sit in `waitForAbsence` until `stopWindow` expires and then fail with `prove exit after force kill: context deadline exceeded` even though the owner has already exited. Use `GetExitCodeProcess` (or `WaitForSingleObject`) and treat `STILL_ACTIVE` as the only live state. + +
+🧰 Tools + +
+🪛 ast-grep (0.44.1) + +[warning] 18-18: Narrowing a non-constant integer to a smaller fixed-width type (int8/int16/int32, uint8/uint16/uint32) can silently overflow or wrap, yielding negative or truncated values that are dangerous in size, length, or index logic. Validate the source value is within the target type's range before converting (e.g. bounds-check, or use a checked helper), and avoid narrowing untrusted or len()/parsed values. +Context: uint32(pid) +Note: [CWE-190] Integer Overflow or Wraparound. + +(integer-overflow-narrowing-conversion-go) + +
+ +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/store/process_windows.go` around lines 18 - 28, Update processAbsent +to verify the opened process’s liveness after syscall.OpenProcess succeeds, +using GetExitCodeProcess or WaitForSingleObject; treat only STILL_ACTIVE as +present and return absent for terminated processes, while preserving existing +error handling and closing the handle. +``` + +
+ + + + + + + +_Source: Linters/SAST tools_ + + + +## Triage + +- Decision: `VALID` +- Notes: `processAbsent` treated every successfully opened process object as live, including exited objects. It now calls `GetExitCodeProcess` and treats only `STILL_ACTIVE` as present; a Windows regression covers an exited, unreaped child whose process object remains open. Fresh Batch 001 evidence: `rtk proxy env GOOS=windows GOARCH=amd64 GOCACHE=/tmp/roundfix-batch001-windows-cache go test -c -o /tmp/roundfix-store-windows-batch001.test ./internal/store` passed. diff --git a/docs/specs/_reviews/pr-38/round-001/issue_006.md b/docs/specs/_reviews/pr-38/round-001/issue_006.md new file mode 100644 index 00000000..3ad1a26d --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-001/issue_006.md @@ -0,0 +1,85 @@ +--- +source: coderabbit +pr: "38" +round: 1 +round_created_at: "2026-07-27T15:34:32Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 9ed57622bb92f138aa3e23d4d59e260ebbff0116 +file: internal/store/store.go +line: 425 +severity: major +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UG-PF,comment:PRRC_kwDOS0qyts7aENCf +review_hash: bd89437ed3c245af355b6846cef53f6df813ef12b2049665adb904008c5c511c +duplicate_of: "" +source_review_id: "4788632386" +source_review_submitted_at: "2026-07-27T15:23:14Z" +--- + + +# Issue 006: _ Maintainability & Code Quality_ _ Major_ _ Quick win_ + +## Review Comment + +_📐 Maintainability & Code Quality_ | _🟠 Major_ | _⚡ Quick win_ + +**Terminal-state set is enumerated in three places in `internal/store/store.go`.** Both compare-and-set guards inline the same ten states already listed by `IsTerminalState` (Line 1045); adding a future terminal state to only one location silently makes that outcome overwritable, defeating the immutability invariant this PR establishes. +- `internal/store/store.go#L376-L395`: build the `state NOT IN (...)` clause and its arguments from a shared `terminalStates` slice instead of the inline literals. +- `internal/store/store.go#L752-L789`: reuse the same generated clause and arguments here, and have `IsTerminalState` range over that slice. + +
+📍 Affects 1 file + +- `internal/store/store.go#L376-L395` (this comment) +- `internal/store/store.go#L752-L789` + +
+ +
+🤖 Prompt for AI Agents + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/store/store.go` around lines 376 - 395, The terminal-state list is +duplicated across compare-and-set updates and IsTerminalState, allowing future +additions to become inconsistent. In internal/store/store.go lines 376-395, +create shared terminalStates-derived placeholders and arguments for the state +NOT IN clause; in lines 752-789, reuse that generated clause and arguments and +update IsTerminalState to range over terminalStates. +``` + +
+ + + + + + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: `CompleteRun`, `UpdateRunState`, and `IsTerminalState` maintained separate terminal-state enumerations. They now share `terminalStates`; both SQL compare-and-set guards derive their placeholders and arguments from that set. Fresh Batch 001 evidence: `rtk proxy env GOCACHE=/tmp/roundfix-batch001-store-cache go test ./internal/store -run '^(TestOwnerProcessIdentityIgnoresCallerTimezone|TestOwnerProcessControllerMatchingOwnerIdentityProceeds|TestTerminalOutcomeEveryStoredTerminalStateIsImmutable|TestTerminalOutcomeRejectsIntermediateStateUpdate|TestCompleteRunWinnerAndIdenticalReplay)$' -count=1` passed. diff --git a/docs/specs/_reviews/pr-38/round-001/round.md b/docs/specs/_reviews/pr-38/round-001/round.md new file mode 100644 index 00000000..0e9391f9 --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-001/round.md @@ -0,0 +1,14 @@ +--- +source: coderabbit +pr: "38" +round: 1 +round_created_at: "2026-07-27T15:34:32Z" +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 9ed57622bb92f138aa3e23d4d59e260ebbff0116 +issue_count: 6 +--- + +# Round 001 + +Fetched 6 Review Issue(s). diff --git a/docs/specs/_reviews/pr-38/round-002/issue_001.md b/docs/specs/_reviews/pr-38/round-002/issue_001.md new file mode 100644 index 00000000..3bd06ecb --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-002/issue_001.md @@ -0,0 +1,135 @@ +--- +source: coderabbit +pr: "38" +round: 2 +round_created_at: "2026-07-27T17:14:22Z" +status: invalid +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 233964dd96ec27fc6bc709eda82b14457fc7f61b +file: internal/cli/cli.go +line: 655 +severity: major +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UG-Ox,comment:PRRC_kwDOS0qyts7aENB_ +review_hash: 38ab7ac3d78edfeb2ec3221ebcaa7a9f5a0d7c859856542b99b0a4c88454346f +duplicate_of: "" +terminal_reason: "forceStopRun proves owner identity before session cleanup; identity-proof failure leaves Agent Sessions active" +source_review_id: "4788632386" +source_review_submitted_at: "2026-07-27T15:23:14Z" +--- + +# Issue 001: _ Stability & Availability_ _ Major_ _ Heavy lift_ + +## Review Comment + +_🩺 Stability & Availability_ | _🟠 Major_ | _🏗️ Heavy lift_ + +**Agent Sessions are cancelled before owner identity is proven.** + +`bestEffortForceStopAgentSessions` runs at Line 633, before the recorded-owner checks at Lines 634-655. When identity proof fails (PID reuse, unreadable identity), the Run is deliberately left Active with its lock retained — but its registered Agent Sessions have already been cancelled and closed, and their selections marked `closed`. The escape hatch then leaves a still-owned Active Run in a degraded state that the retry advice in the help text cannot repair. + +Prove the owner first, then clean up sessions. + + + +
+🛠️ Proposed reordering + +```diff +- warnings := bestEffortForceStopAgentSessions(ctx, runStore, active) + pid, ok := activeOwnerPID(active) + if !ok { +- return stopResult{Run: active, Warnings: warnings}, forceStopOwnerError{ ++ return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: 0, + Step: "validate recorded owner PID", + Err: store.ErrOwnerProcessIdentityUnproven, + } + } + if err := ownerProcesses.TerminateAndWait(ctx, pid, active.OwnerIdentity); err != nil { + step := "prove owner exit" + var controlErr store.OwnerProcessControlError + if errors.As(err, &controlErr) && strings.TrimSpace(controlErr.Step) != "" { + step = controlErr.Step + } +- return stopResult{Run: active, Warnings: warnings}, forceStopOwnerError{ ++ return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: pid, + Step: step, + Err: err, + } + } ++ warnings := bestEffortForceStopAgentSessions(ctx, runStore, active) +``` +
+ + + +
+📝 Committable suggestion + +> ‼️ **IMPORTANT** +> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +```suggestion + pid, ok := activeOwnerPID(active) + if !ok { + return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: 0, + Step: "validate recorded owner PID", + Err: store.ErrOwnerProcessIdentityUnproven, + } + } + if err := ownerProcesses.TerminateAndWait(ctx, pid, active.OwnerIdentity); err != nil { + step := "prove owner exit" + var controlErr store.OwnerProcessControlError + if errors.As(err, &controlErr) && strings.TrimSpace(controlErr.Step) != "" { + step = controlErr.Step + } + return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: pid, + Step: step, + Err: err, + } + } + warnings := bestEffortForceStopAgentSessions(ctx, runStore, active) +``` + +
+ + + +
+🤖 Prompt for AI Agents + +``` +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@internal/cli/cli.go` around lines 633 - 655, Reorder the stop flow so active +agent sessions are cleaned up only after the recorded owner identity has been +validated and TerminateAndWait succeeds. In the surrounding force-stop logic, +move bestEffortForceStopAgentSessions after the activeOwnerPID check and +ownerProcesses.TerminateAndWait call, while preserving warnings and error +results for validation or termination failures. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `INVALID` +- Notes: The finding does not apply to the current implementation. `forceStopRun` calls the read-only `ownerProcesses.ProveOwner` before `bestEffortForceStopAgentSessions`; an unprovable or reused owner therefore returns before any cancel or close. Moving cleanup after `TerminateAndWait` would also contradict the archived PRD and Task 03 requirement that Force Stop cancel registered Agent Sessions before terminating the recorded owner. Fresh Batch 001 evidence: `TestRunForceStopOwnerProofFailurePreservesAgentSessions` passed in `rtk proxy env GOCACHE=/tmp/roundfix-batch001-cli-cache go test ./internal/cli -run '^(TestCompletionWinnerOwnerVersusForceStopPublishesOneTerminalOutcome|TestRunForceStopOwnerProcessIntegrationProvesExitBeforeStoreCompletion|TestRunForceStopOwnerProofFailurePreservesAgentSessions)$' -count=1`. diff --git a/docs/specs/_reviews/pr-38/round-002/issue_002.md b/docs/specs/_reviews/pr-38/round-002/issue_002.md new file mode 100644 index 00000000..0e38b08d --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-002/issue_002.md @@ -0,0 +1,33 @@ +--- +source: coderabbit +pr: "38" +round: 2 +round_created_at: "2026-07-27T17:14:22Z" +status: invalid +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 233964dd96ec27fc6bc709eda82b14457fc7f61b +file: internal/cli/cli.go +line: 655 +severity: review +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UG-Ox,comment:PRRC_kwDOS0qyts7aFWs8 +review_hash: f40a950d49fa345955bac492cff9dc6b75c8169d502ad44277756abe0eaf3010 +duplicate_of: "" +terminal_reason: "the reply contains no independent finding, and its referenced skill/catalog verification failure no longer reproduces" +source_review_id: "4789005929" +source_review_submitted_at: "2026-07-27T15:59:54Z" +--- + +# Issue 002: @marcioaltoe, acknowledged. The verification failure leaves this finding open... + +## Review Comment + +`@marcioaltoe`, acknowledged. The verification failure leaves this finding open; the available report does not include the underlying diagnostic, so I cannot validate or withdraw the concern yet. Please address the `make verify` failure and rerun the affected checks in a later Round. + + + +## Triage + +- Decision: `INVALID` +- Notes: This is an acknowledgement of the earlier daemon failure, not an independent code finding. The referenced failure no longer reproduces in the current checkout: `rtk proxy env GOCACHE=/tmp/roundfix-batch001-authorial-cache go test ./skills -run '^TestAuthorialSkillSync$' -count=1`, `rtk proxy env GOCACHE=/tmp/roundfix-batch001-baseline-cache go test ./internal/baseline -run '^(TestEmbeddedCatalog|TestCatalogDigest)$' -count=1`, `rtk proxy env GOCACHE=/tmp/roundfix-batch001-skill-cache make skills-sync-check`, and `rtk proxy env GOCACHE=/tmp/roundfix-batch001-skillcheck-cache go run -buildvcs=false ./cmd/roundfix skills check` all passed. diff --git a/docs/specs/_reviews/pr-38/round-002/round.md b/docs/specs/_reviews/pr-38/round-002/round.md new file mode 100644 index 00000000..15039a31 --- /dev/null +++ b/docs/specs/_reviews/pr-38/round-002/round.md @@ -0,0 +1,14 @@ +--- +source: coderabbit +pr: "38" +round: 2 +round_created_at: "2026-07-27T17:14:22Z" +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-outcome-integrity +head_sha: 233964dd96ec27fc6bc709eda82b14457fc7f61b +issue_count: 2 +--- + +# Round 002 + +Fetched 2 Review Issue(s). diff --git a/docs/user-guide/commands.md b/docs/user-guide/commands.md index ed92fe36..ae035dfe 100644 --- a/docs/user-guide/commands.md +++ b/docs/user-guide/commands.md @@ -482,16 +482,54 @@ roundfix stop --force Selectors: positional ``, `--run-id`, `--pr`, `--spec`, or `--head-repo` plus `--head-branch`. Graceful stop records a Stop Request and reports `Stop Request recorded; the Run stops after the current Work Item -settles.` `--force` is for dead, stuck, or runaway Runs: it cancels the Agent -Session best-effort, completes the Run Stopped immediately, releases its -locks, and reaps kept terminal Worktrees whose branch has no commits beyond -its base. +settles.` + +During a watch Run's Review Source status, retry, quiet-period, or +merge-readiness wait, the owner checks for the Stop Request before the next +status access and after each interruptible sleep. It reaches Stopped by the +next configured poll boundary. After detecting the request, it does not run +another fetch, check, commit, push, or Review Source mutation. A Work Item +already in flight still settles before the graceful stop completes. + +Force Stop is for dead, stuck, or runaway Runs. It cancels only registered +Agent Sessions whose current lifecycle is active, then terminates the recorded +owner process and proves that process exited. Only then does Roundfix report +Stopped, release the Active Run lock, and reap kept terminal Worktrees whose +branch has no commits beyond its base. An already-absent registered Agent +Session is an idempotent cleanup result. + +If owner exit cannot be proven, Force Stop fails with no stdout success report. +The diagnostic names the Run ID, owner PID, and failed process-control step; +the Run remains Active and its Active Run lock stays retained. Inspect the Run +with `roundfix runs list --state active`, resolve the reported owner-process +failure, then retry `roundfix stop --force `. Agent Session cleanup +failures remain visible as secondary warnings after the primary failure. They +do not replace that failure or authorize terminal completion while the owner +is still alive. + +Exit codes: `0` for a recorded Stop Request, a completed Force Stop, and the +idempotent already-Stopped report; `1` when Force Stop fails operationally +because owner exit cannot be proven; `2` for Preflight Validation failures +such as an invalid selector, no matching Active Run, or stopping a Run that +already holds a different terminal outcome. + +Terminal results are stable. Repeating Force Stop for an already Stopped Run +reports the existing outcome without repeating process or Agent Session +actions. Force Stop against a different terminal outcome is rejected and +leaves that outcome unchanged. Orphaned locks rarely need `--force` anymore: Runs record their owner process id, and any command blocked by a lock whose owner is provably dead reclaims it automatically — the Run completes Failed with the reason journaled and one stderr warning names the reclaimed run id. A live owner, a PID-less legacy -Run, or any liveness result short of proof still blocks. +Run, or any liveness result short of proof still blocks; a warning alone never +authorizes owner reclamation. + +The terminology and behavior trace to the +[Roundfix glossary](../../CONTEXT.md#language), +[ADR-0052](../adr/0052-run-completion-is-compare-and-set.md), +[Spec 0037](../specs/_archived/0037-terminal-outcome-integrity/_prd.md), and the +[detached-watch finding](../findings/2026-07-16-vortex-pr87-detached-watch-notification.md#4-cleanup-noise-appeared-before-the-actionable-failure). ## Detached Runs diff --git a/docs/user-guide/usage.md b/docs/user-guide/usage.md index 18835865..e387df11 100644 --- a/docs/user-guide/usage.md +++ b/docs/user-guide/usage.md @@ -490,10 +490,36 @@ roundfix stop --force # dead, stuck, or runaway Runs only ``` Graceful stop records a Stop Request and lets the in-flight Work Item finish its -verification and commit boundary. `--force` cancels the Agent Session -best-effort, completes the Run Stopped immediately, releases its locks, and reaps -empty terminal Worktree debris. Never kill Agent or acpx processes by hand while -a Run is Active. +verification and commit boundary. During a Review Source status, retry, +quiet-period, or merge-readiness wait, the owner observes the request by the +next configured poll boundary. Once observed, the Run does not start another +fetch, check, commit, push, or Review Source mutation. + +Use Force Stop only for a dead, stuck, or runaway Run. It cancels registered +active Agent Sessions, terminates the recorded owner process, and reports +Stopped only after owner exit is proven. Roundfix releases the Active Run lock +only after that proof. Registered sessions that are already absent need no +recovery; other cleanup failures appear as secondary warnings after the +primary failure. + +If Force Stop cannot prove owner exit, it fails closed: the Run remains Active +and the Active Run lock stays retained. Read the reported owner PID and failed +step, inspect the Run, resolve the process-control failure, and retry: + +```bash +roundfix runs list --state active +roundfix stop --force +``` + +Repeating Force Stop after the Run is already Stopped is an idempotent report +of the same outcome and does not repeat cleanup. If the Run already has a +different terminal outcome, Roundfix rejects the conflict and preserves that +outcome. Never kill Agent or acpx processes by hand while a Run is Active. + +For the full failure and replay contract, see the +[Stop Command reference](commands.md#stop), which traces to +[ADR-0052](../adr/0052-run-completion-is-compare-and-set.md) and the +[terminal-outcome Spec](../specs/_archived/0037-terminal-outcome-integrity/_prd.md). ## Command reference diff --git a/internal/agent/sessions.go b/internal/agent/sessions.go index 2e356515..da488f52 100644 --- a/internal/agent/sessions.go +++ b/internal/agent/sessions.go @@ -3,6 +3,7 @@ package agent import ( "bufio" "encoding/json" + "errors" "fmt" "strings" ) @@ -16,6 +17,15 @@ type RoundfixSession struct { TaskID string } +// IsAgentSessionAbsent reports whether acpx proved that a requested Agent +// Session no longer exists. Callers may treat that response as idempotent +// cleanup while preserving every other infrastructure error. +func IsAgentSessionAbsent(err error) bool { + var infrastructureErr *InfrastructureError + return errors.As(err, &infrastructureErr) && + infrastructureErr.Reason == acpxExitReasonMissingSession +} + func SessionRefForQA(runID string, workDir string) SessionRef { runID = strings.TrimSpace(runID) if runID == "" { diff --git a/internal/baseline/assets/setups/typescript-bun.json b/internal/baseline/assets/setups/typescript-bun.json index 253712af..3be62cee 100644 --- a/internal/baseline/assets/setups/typescript-bun.json +++ b/internal/baseline/assets/setups/typescript-bun.json @@ -8,7 +8,7 @@ "ref": "14fdf46befa9a07203fde20b69b885dab4961844", "path": "setups/typescript-bun.txt" }, - "digest": "48592a566d3be7589f00e6895a5d2edb4bd54b59d3e92d171b333558e936c5d3", + "digest": "36f512c6c0370aab357c345a8bf2ceb902738ff837bdd34da7c1c2085567533c", "skills": [ { "name": "find-rules", @@ -1027,7 +1027,7 @@ "type": "repo", "name": "roundfix" }, - "contentDigest": "1e4ea59e0d6e553e42c6c63e16ad95165a78be8bbf31b8e0cd8b56ce13cc9146" + "contentDigest": "fa774d82f16661c81c235738c78116fba2fdc328bac5797fd663fa31a05f42d6" }, { "name": "the-fool", diff --git a/internal/baseline/testdata/catalog.digest b/internal/baseline/testdata/catalog.digest index 3c6ff3d6..dcfffe56 100644 --- a/internal/baseline/testdata/catalog.digest +++ b/internal/baseline/testdata/catalog.digest @@ -1 +1 @@ -sha256:b4328a2c299dda6a312b47098babc2e92f68c910fe3c610e8e9522fe7628071a +sha256:c0435d250c1440584454009211b5f044711382fb4d9a01e9dc4e97b91e3ca014 diff --git a/internal/baseline/testdata/catalog.normalized.json b/internal/baseline/testdata/catalog.normalized.json index 936f26c4..71a934d7 100644 --- a/internal/baseline/testdata/catalog.normalized.json +++ b/internal/baseline/testdata/catalog.normalized.json @@ -1 +1 @@ -{"schemaVersion":"roundfix/baseline-catalog/v1","files":[{"path":"contract-v1.json","bytes":13525,"digest":"sha256:0c32b7ac1b6dd1a2a04d830b300d592b7b195aec7bb32ff05575e8d19f154497"},{"path":"coverage.json","bytes":4660,"digest":"sha256:9faa73bf86fad383570b0098a9aa26185ea8d689f9866038ab37b8a34b9a01dc"},{"path":"decisions.json","bytes":8438,"digest":"sha256:58acbb05cf46057923e8844b28e2a97efa898d6200ae9e0773971b6287b05651"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/AGENTS.md","bytes":2911,"digest":"sha256:1876cc64ced89e60cfb0188eaff71d8b588c80d1149740ba368d1afc969e1d8f"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/agent-instructions.md","bytes":4182,"digest":"sha256:eb769f99afa32a901723e1962a06fca09d856c8d37284b36c9aed071bd6e4f86"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/autonomous-work.md","bytes":789,"digest":"sha256:76065b3f1b452f647765d01bfb802ca3a0757d7212ba75b8e55d1d6242b0c977"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/backend.md","bytes":1083,"digest":"sha256:b8459f66c5fec1d424768fdd2b0b2f4ec70b9038208c1d5a58086720586195fd"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/docs-layout.md","bytes":3546,"digest":"sha256:a188cfaae89ed195ca31460af3ec5d0dfad0bc1241e1958950ed4e0035de86f6"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/domain.md","bytes":614,"digest":"sha256:089a408493f450ef5713f3f62b20b51323b6d08617e6e43a12e38d794676ae82"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/external-triage.md","bytes":385,"digest":"sha256:4e6b2cfafd491161e7666b304ce0d2d6b19ad705613a6471999c84fda1d63e6a"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/frontend.md","bytes":825,"digest":"sha256:3cd5cba8160ea9f6419ffe82920f7641d09eb7126a78ab50606dfe045732cc02"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/issue-tracker.md","bytes":353,"digest":"sha256:f2139d498fb79358a240d8c99ab1718764217cf28aec0fb024beadfa2b0231cc"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/monorepo.md","bytes":371,"digest":"sha256:47cf6a0ac72c2bcacfc69e5566b1cdcc2102e552b1d91592e5cb1033d30be9e3"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/secondbrain.md","bytes":1589,"digest":"sha256:fab2b2896ad03e0436414b83e5cd80e7fdc07b96d9231b798194b36314a8e7a7"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/skill-dispatch.md","bytes":14414,"digest":"sha256:d424a3e360031e39913bf385f87012e6d1ab79a84024701092859640d2fd05dd"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/spec-routing.md","bytes":2269,"digest":"sha256:ce1b67f65ce8ad8a5dc1505e2201833d0e477ff91a6161c395db1188ae83ce9f"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/typescript-bun.md","bytes":1378,"digest":"sha256:ec114298fee6ef89bbf0423a6b8a467630296264fc49e989d89af3301093b96e"},{"path":"lock-hash-compatibility-v1.json","bytes":366,"digest":"sha256:6a83f5c12ea6fe0a0aa2e2c710ef44b4cc922d2965240376bcd0f12254d765d0"},{"path":"modules/autonomous-work.json","bytes":2400,"digest":"sha256:61cb4100b7a9378f4d582964d6ac7fac52e005f5b27d0e5c359bb80a9fce7f85"},{"path":"modules/backend.json","bytes":5655,"digest":"sha256:c5b3d012e5ee3ec4dabcb5ea679fbc89932eb9760215c0c91d0bc8bb45a4f806"},{"path":"modules/bun.json","bytes":2557,"digest":"sha256:ca869344629ff4ede015e761c87afb01c20c371c05260cef56f36a7376e53777"},{"path":"modules/cli-surface.json","bytes":1721,"digest":"sha256:04e84d41b65452dc06d43ce43e2aedec3cc05d100d18aacc150f808ae80f7d32"},{"path":"modules/context-workflow.json","bytes":11142,"digest":"sha256:fb61822b2ce29891d6a06963b225a4da6935859df83ae1aa3fde9fa3bf0b0d30"},{"path":"modules/core.json","bytes":18107,"digest":"sha256:69dd99c698a50f651b0bd0b3021b2abbfba645955fe534735e97bff4ef0f1098"},{"path":"modules/external-triage.json","bytes":1159,"digest":"sha256:5ea90217b042667a3e00211941d4cb1bafb24294187635bfa277765c05894366"},{"path":"modules/frontend.json","bytes":8713,"digest":"sha256:6a701d874b2217bb7f59cddfdd0d0d8a8bb41c6452fc63b67251e5beddc25f7d"},{"path":"modules/go.json","bytes":3322,"digest":"sha256:d9877c86f59206b3bf6bdc1bf73fdfd18fdcc496bc67770413963587276b0533"},{"path":"modules/monorepo.json","bytes":1385,"digest":"sha256:e6485fd6dacb765a866dd9ff79f057d2ebc85cb57c603774fe7ca453cf50f917"},{"path":"modules/repository-extension.json","bytes":1094,"digest":"sha256:a2adeac11000706839944c2ecca225e524da6f38534e54efc9cb0a26f5413f47"},{"path":"modules/rust.json","bytes":2328,"digest":"sha256:46537524b19a5eb1b61dd07d7215b2ea607c1b78a481a21eb13926d40ecdc0b2"},{"path":"modules/secondbrain.json","bytes":4333,"digest":"sha256:3ce59d0ddfcd0e2e5de34d9a4541a27bc62c6e72800da1cd02353ba2f2aa2024"},{"path":"modules/spec-workflow.json","bytes":6441,"digest":"sha256:574f08d79b15e4d9a6a38c3a9a85636f5a208aa9e43051383815056fc03c6e53"},{"path":"modules/tui-surface.json","bytes":1658,"digest":"sha256:06e18ef65d3a7184aaa3a80a21d2279b3376ab77f07bb8b47d23497b0fb6fa62"},{"path":"modules/typescript.json","bytes":8526,"digest":"sha256:7a2a82f7efb28df7dbc343f1b5a7b0ab176e8a74105eb6077c7b5cf6648bdbc6"},{"path":"profiles/go-cli-tui.json","bytes":1709,"digest":"sha256:231220ac521a8298d86440f6e262d61b9c2fa249459dd9206973d8db6e6941bb"},{"path":"profiles/rust-cli.json","bytes":1627,"digest":"sha256:f2338dbf8d0085a6150293c157b6986714de122b2ebd732e33a09f0614686c81"},{"path":"profiles/standard-typescript-monorepo.json","bytes":10907,"digest":"sha256:803f62fb451f9fda23a58b7bfe5f5367a45d8d44bb63c3f96e33b259393cc5ba"},{"path":"retention/transition.legacy-typescript-bun-to-portable-v3.json","bytes":12425,"digest":"sha256:3b166a42eb1f9f23202afc48399eb7aa9b0e8563d9c40095ad6bf710a5d1c599"},{"path":"retention/transition.managed-v2-to-portable-v3.json","bytes":11185,"digest":"sha256:8a0e9dd06aa6a5988c010f6c1286c39002c65e5aae6762e9af6956424524c3e9"},{"path":"setups/go-cli.json","bytes":14203,"digest":"sha256:28e2ad53684c2c097affa4cf70c32a59265435dd105cdcfa57314bd18a6af46f"},{"path":"setups/rust-cli.json","bytes":12476,"digest":"sha256:82cdba03b2c77c9a0c4a6ad737e7de5e90a3fcf6827c18242793b8032766d9c0"},{"path":"setups/typescript-bun.json","bytes":40649,"digest":"sha256:264105e707256c67c30ee4a7417d387fab0537347ad78e36f7abc361857a9f56"},{"path":"skill-activations.json","bytes":3330,"digest":"sha256:bbf1978828a2d383c9cee675a612c17281224e0a1ce5df4b09ba13af4ccd3667"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/accounting.json","bytes":17193,"digest":"sha256:69e39f9df0eb54d08ecb739dc3dfc9dd80c0cf76016aca3d6a0566e48cfaa1f5"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/baseline.json","bytes":498,"digest":"sha256:dca6a12ae24a77fc9104dfcccb507c50dee0e5257e0bf0bd9d86c0b04ce7ffe0"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/AGENTS.md","bytes":583,"digest":"sha256:3b6b36fd045ecb683aec29e0ceac52b3704604815d8035297bfeb8438602494e"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/agent-instructions.md","bytes":8996,"digest":"sha256:fe584b8901ae2521dcba7014ca11f3557109a4d91d7c56a21852f027a4bf205e"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/autonomous-work.md","bytes":1548,"digest":"sha256:3deff187dba70156ca834529741c4be7df84c5a66f8462ba01594fee35d40336"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/backend.md","bytes":2197,"digest":"sha256:c553c12e3cb13b0435a219fd52ac735bf4f9f3aad05fdb943fd6922cef0a97ac"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/docs-layout.md","bytes":7671,"digest":"sha256:8a78a3543a0a52ac93ef83ab36f953e813be068e96957553483c928cbad439bd"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/domain.md","bytes":947,"digest":"sha256:54c79c324d20b412c07c634f490ad18eeace0562caffe5507b3c83cebf9aaf20"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/external-triage.md","bytes":335,"digest":"sha256:0ec2089110c6a673f305e86f436538c7c07aeef46d29a4e68674defb8778e2cc"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/frontend.md","bytes":1934,"digest":"sha256:4a3609c27bf100aaf0d7de934ad43c0f6a561a15600517422a325fcc6120bd14"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/issue-tracker.md","bytes":890,"digest":"sha256:d86ab246d701a54b8d61c5dcdc1d29fe3281115c028e8694f10574ce99e537c7"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/monorepo.md","bytes":366,"digest":"sha256:c59091add143e67b3773df78d95cc4c8f11d43909655ef1c09dd64c0cc903b3d"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/secondbrain.md","bytes":3312,"digest":"sha256:4b58f3ed96f654d8b99ccc9a03718505f4c43a6170eceef47e26484b5f95bffb"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/skill-dispatch.md","bytes":374,"digest":"sha256:90bbf02e35bba9c707e4713eef4de9a6db36ba3fa93393531287aaf94fce8f8b"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/spec-routing.md","bytes":3094,"digest":"sha256:dfc867401e547d4370f20c578a22e6b82cc80d7b677072f31bc11cdba4851085"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/typescript-bun.md","bytes":2561,"digest":"sha256:7295fe2e544af37e6d6777537fa7ee512b78f3da932d736cc7eabc8d190a30e8"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/manifest.json","bytes":37175,"digest":"sha256:e9a9e91f8dbbea8dc416a03fe813b9c370a2e012118f548b18f1c6a13b6b7662"},{"path":"source-baselines/index.json","bytes":5266,"digest":"sha256:ad636b4cbaf57e27c81093aee9963bcd2b276df7ac243f04b423bf989db314a8"},{"path":"templates/extensions/specific-repository.md","bytes":38,"digest":"sha256:f8aad350dfecd91923c12fe215c1e21dd6741635e44869110391ba9c5c89d137"},{"path":"templates/guides/agent-instructions.md","bytes":260,"digest":"sha256:91f0a4b1d664cf6ab261499b3f7cb093bd2337d6567d07599faebd8fe58dc8d3"},{"path":"templates/guides/autonomous-work.md","bytes":194,"digest":"sha256:384fdebdb4ffd5d6c1b7b197cc02e3f9d3e7327acd7151dc7ce29c0e53e99486"},{"path":"templates/guides/backend.md","bytes":201,"digest":"sha256:0bffbf1e74c9b8c506ec1c612aac0dfaedcfb6f1b7aafc8d1a2d6029cd1170ea"},{"path":"templates/guides/bun.md","bytes":26,"digest":"sha256:3a0640850dc84c8dd6c6b35f10864ae6d3d9c153147699b7bfced6332315633d"},{"path":"templates/guides/cli-surface.md","bytes":34,"digest":"sha256:7cdd6ecc6575c4ef165858ced6409278430a89e3f67baa4281151dc13e2742bc"},{"path":"templates/guides/docs-layout.md","bytes":34,"digest":"sha256:51e9033d80e6742321b47b19c85034c9b4d15b36b9c84230512e3a9a4ef90628"},{"path":"templates/guides/domain-multi-context.md","bytes":112,"digest":"sha256:56b7000576757f37da275123ab75ff5f84d2e26bd88c9f67034a24f3d4883a41"},{"path":"templates/guides/domain-single-context.md","bytes":104,"digest":"sha256:f75666666408761a67d23855f5f78e4526328404343f09a036f8987895c69c0d"},{"path":"templates/guides/domain.md","bytes":59,"digest":"sha256:5c4195e0348b6e5a57679fc833e58b09f4b08080b1cec90a946d0b666310739e"},{"path":"templates/guides/external-triage.md","bytes":38,"digest":"sha256:f26c4164ff69bf44263d9993e4209a9305b8979f228f6f519f139e83f9b51ae9"},{"path":"templates/guides/frontend.md","bytes":265,"digest":"sha256:3243626ac7b55130ac198ba32a350966a26f6a9801a4a4fee259817b2f7eb5f9"},{"path":"templates/guides/go.md","bytes":25,"digest":"sha256:7f597711bdab9fe85ecda9f5895687e436009446ee8416ed96f7bc27fe7b00fd"},{"path":"templates/guides/issue-tracker.md","bytes":36,"digest":"sha256:48840599c91f2f3cf2fd79700f41e54bc3edbb4fa19e4861247e19bac0077fb1"},{"path":"templates/guides/monorepo.md","bytes":31,"digest":"sha256:e1866f1633f3bbc5b230b5c5290b71ae47110dce4d4344c264a9eb325a477bca"},{"path":"templates/guides/rust.md","bytes":27,"digest":"sha256:e0e693135266e96cabec918b14f0cf51c7bf1a126d900001cc418f982f8bbec2"},{"path":"templates/guides/secondbrain.md","bytes":34,"digest":"sha256:a59dc0ae149d4bb3bdd633f79ba91fd17e6d3d3d930c346e5757b5f85ab69002"},{"path":"templates/guides/skill-dispatch.md","bytes":253,"digest":"sha256:0f054b846da5b17409dde049b3b182489e6c24e84734d4a5ffc47a9f504e8a7c"},{"path":"templates/guides/spec-docs-layout.md","bytes":39,"digest":"sha256:6d9ddb174c4f69cb1f9abe428a6be677e9ea8dbecc19ae0fa2f53fdc3749de40"},{"path":"templates/guides/spec-routing.md","bytes":35,"digest":"sha256:cc578e73968c15bdcaad35270454e91462b4413d937cfd984404ef22d765482c"},{"path":"templates/guides/tui-surface.md","bytes":34,"digest":"sha256:bbc422cb32df905d526a291cbfd5b81fd86f0d405f5f650cc5d8eb0a864c4eb2"},{"path":"templates/guides/typescript-bun.md","bytes":41,"digest":"sha256:ee14609660ebe1da17eff0d12c3f42857cba4c92be0f723346d17e3d64bb6574"},{"path":"templates/index.json","bytes":7125,"digest":"sha256:272cbfd4fa2ea380acfc76ce4d75eaf2ae026b7501c34ee48a8645644eb72f4c"},{"path":"templates/root/autonomous-work.md","bytes":102,"digest":"sha256:abd0be4c74295597be5dca6a307e41d6bdcfa5113370f5bbc30d30725c927e35"},{"path":"templates/root/backend.md","bytes":76,"digest":"sha256:f65a96aaa0b79d40d209df159c30bd6ac791e36c415b8ba2dc52a1032c5785c3"},{"path":"templates/root/bun.md","bytes":89,"digest":"sha256:48a4bcd7c9b86d8f3be6965ff8b7b33aad77148aa598d49ed8efa74acd3c8e20"},{"path":"templates/root/cli-surface.md","bytes":79,"digest":"sha256:ec022b0b1767d88ebc849f2ee7d8a2d266a36d419f8084b9cce8599d0619a125"},{"path":"templates/root/context-workflow.md","bytes":129,"digest":"sha256:52c53e99fed6c5d59388e58f7854c96f8130da553d80bb90f04935f67eea45c5"},{"path":"templates/root/core.md","bytes":213,"digest":"sha256:ffa6f25825e1d5c5bf52373022d7035b004fd78dd55f99a187d89c4b78ddeabb"},{"path":"templates/root/external-triage.md","bytes":91,"digest":"sha256:5ea18c6c7aea8f03d62af0b824b947ddecbaf861fbf9b81df2e8fb2b2c0e2c54"},{"path":"templates/root/frontend.md","bytes":99,"digest":"sha256:cddad0b38dff9cbeae571dc8514f5d2770e528725f8931272389bd8a675e08bb"},{"path":"templates/root/go.md","bytes":67,"digest":"sha256:aa3eafd5ebcec7aa64ae7fddf1c5b954512e831121934760f615c12ec52f6527"},{"path":"templates/root/monorepo.md","bytes":74,"digest":"sha256:76951b431357333d5b3bbbe718529797834c5de14ee6cd886bce175b5ef892e9"},{"path":"templates/root/repository-extension.md","bytes":103,"digest":"sha256:f24b6015a05eab4cdbb830eb485f9958b659d9a99d46b53d8538a813f6705fe1"},{"path":"templates/root/rust.md","bytes":73,"digest":"sha256:40c4f50d3a020406816612b9f9e0c6cbd545626401e447d4963ba2d30d8e40e3"},{"path":"templates/root/secondbrain.md","bytes":87,"digest":"sha256:dbb11588c2dbca5fd803eccc889586cac75824e9083c570bd1cad34fda901857"},{"path":"templates/root/spec-workflow.md","bytes":125,"digest":"sha256:c292c5b2b98facb56ef0f6da0a61f38544377ddb6339c63d5287ca7dec4f2360"},{"path":"templates/root/tui-surface.md","bytes":75,"digest":"sha256:5657d3abaddf480a535d00a8bfe94bcd3e0b6230aaaa3aac2f9ce89fdc7e20b0"},{"path":"templates/root/typescript.md","bytes":76,"digest":"sha256:9673f69acb604560153c93c46aa6a8a03ecedc81c3eae15e03b0b1b763dbcde7"}],"profiles":["go-cli-tui","rust-cli","standard-typescript-monorepo"],"modules":["autonomous-work","backend","bun","cli-surface","context-workflow","core","external-triage","frontend","go","monorepo","repository-extension","rust","secondbrain","spec-workflow","tui-surface","typescript"],"decisions":["auth.provider","autonomous.enabled","domain.layout","http.contract","identifier.strategy","language.generated","repository.extension.enabled","runtime.backend","runtime.design","secondbrain.enabled","spec.scaffold","triage.external","verification.gate"],"templates":["template.extension.repository-rules","template.guide.agent-instructions","template.guide.autonomous-work","template.guide.backend","template.guide.bun","template.guide.cli-surface","template.guide.docs-layout","template.guide.domain","template.guide.domain.multi-context","template.guide.domain.single-context","template.guide.external-triage","template.guide.frontend","template.guide.go","template.guide.issue-tracker","template.guide.monorepo","template.guide.rust","template.guide.secondbrain","template.guide.skill-dispatch","template.guide.spec-docs-layout","template.guide.spec-routing","template.guide.tui-surface","template.guide.typescript-bun","template.root.autonomous-work","template.root.backend","template.root.bun","template.root.cli-surface","template.root.context-workflow","template.root.core","template.root.external-triage","template.root.frontend","template.root.go","template.root.monorepo","template.root.repository-extension","template.root.rust","template.root.secondbrain","template.root.spec-workflow","template.root.tui-surface","template.root.typescript"],"setups":["go-cli","rust-cli","typescript-bun"],"retentionTransitions":["transition.legacy-typescript-bun-to-portable-v3","transition.managed-v2-to-portable-v3"]} +{"schemaVersion":"roundfix/baseline-catalog/v1","files":[{"path":"contract-v1.json","bytes":13525,"digest":"sha256:0c32b7ac1b6dd1a2a04d830b300d592b7b195aec7bb32ff05575e8d19f154497"},{"path":"coverage.json","bytes":4660,"digest":"sha256:9faa73bf86fad383570b0098a9aa26185ea8d689f9866038ab37b8a34b9a01dc"},{"path":"decisions.json","bytes":8438,"digest":"sha256:58acbb05cf46057923e8844b28e2a97efa898d6200ae9e0773971b6287b05651"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/AGENTS.md","bytes":2911,"digest":"sha256:1876cc64ced89e60cfb0188eaff71d8b588c80d1149740ba368d1afc969e1d8f"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/agent-instructions.md","bytes":4182,"digest":"sha256:eb769f99afa32a901723e1962a06fca09d856c8d37284b36c9aed071bd6e4f86"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/autonomous-work.md","bytes":789,"digest":"sha256:76065b3f1b452f647765d01bfb802ca3a0757d7212ba75b8e55d1d6242b0c977"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/backend.md","bytes":1083,"digest":"sha256:b8459f66c5fec1d424768fdd2b0b2f4ec70b9038208c1d5a58086720586195fd"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/docs-layout.md","bytes":3546,"digest":"sha256:a188cfaae89ed195ca31460af3ec5d0dfad0bc1241e1958950ed4e0035de86f6"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/domain.md","bytes":614,"digest":"sha256:089a408493f450ef5713f3f62b20b51323b6d08617e6e43a12e38d794676ae82"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/external-triage.md","bytes":385,"digest":"sha256:4e6b2cfafd491161e7666b304ce0d2d6b19ad705613a6471999c84fda1d63e6a"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/frontend.md","bytes":825,"digest":"sha256:3cd5cba8160ea9f6419ffe82920f7641d09eb7126a78ab50606dfe045732cc02"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/issue-tracker.md","bytes":353,"digest":"sha256:f2139d498fb79358a240d8c99ab1718764217cf28aec0fb024beadfa2b0231cc"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/monorepo.md","bytes":371,"digest":"sha256:47cf6a0ac72c2bcacfc69e5566b1cdcc2102e552b1d91592e5cb1033d30be9e3"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/secondbrain.md","bytes":1589,"digest":"sha256:fab2b2896ad03e0436414b83e5cd80e7fdc07b96d9231b798194b36314a8e7a7"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/skill-dispatch.md","bytes":14414,"digest":"sha256:d424a3e360031e39913bf385f87012e6d1ab79a84024701092859640d2fd05dd"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/spec-routing.md","bytes":2269,"digest":"sha256:ce1b67f65ce8ad8a5dc1505e2201833d0e477ff91a6161c395db1188ae83ce9f"},{"path":"formatter-fixtures/standard-typescript-monorepo/golden/docs/agents/typescript-bun.md","bytes":1378,"digest":"sha256:ec114298fee6ef89bbf0423a6b8a467630296264fc49e989d89af3301093b96e"},{"path":"lock-hash-compatibility-v1.json","bytes":366,"digest":"sha256:6a83f5c12ea6fe0a0aa2e2c710ef44b4cc922d2965240376bcd0f12254d765d0"},{"path":"modules/autonomous-work.json","bytes":2400,"digest":"sha256:61cb4100b7a9378f4d582964d6ac7fac52e005f5b27d0e5c359bb80a9fce7f85"},{"path":"modules/backend.json","bytes":5655,"digest":"sha256:c5b3d012e5ee3ec4dabcb5ea679fbc89932eb9760215c0c91d0bc8bb45a4f806"},{"path":"modules/bun.json","bytes":2557,"digest":"sha256:ca869344629ff4ede015e761c87afb01c20c371c05260cef56f36a7376e53777"},{"path":"modules/cli-surface.json","bytes":1721,"digest":"sha256:04e84d41b65452dc06d43ce43e2aedec3cc05d100d18aacc150f808ae80f7d32"},{"path":"modules/context-workflow.json","bytes":11142,"digest":"sha256:fb61822b2ce29891d6a06963b225a4da6935859df83ae1aa3fde9fa3bf0b0d30"},{"path":"modules/core.json","bytes":18107,"digest":"sha256:69dd99c698a50f651b0bd0b3021b2abbfba645955fe534735e97bff4ef0f1098"},{"path":"modules/external-triage.json","bytes":1159,"digest":"sha256:5ea90217b042667a3e00211941d4cb1bafb24294187635bfa277765c05894366"},{"path":"modules/frontend.json","bytes":8713,"digest":"sha256:6a701d874b2217bb7f59cddfdd0d0d8a8bb41c6452fc63b67251e5beddc25f7d"},{"path":"modules/go.json","bytes":3322,"digest":"sha256:d9877c86f59206b3bf6bdc1bf73fdfd18fdcc496bc67770413963587276b0533"},{"path":"modules/monorepo.json","bytes":1385,"digest":"sha256:e6485fd6dacb765a866dd9ff79f057d2ebc85cb57c603774fe7ca453cf50f917"},{"path":"modules/repository-extension.json","bytes":1094,"digest":"sha256:a2adeac11000706839944c2ecca225e524da6f38534e54efc9cb0a26f5413f47"},{"path":"modules/rust.json","bytes":2328,"digest":"sha256:46537524b19a5eb1b61dd07d7215b2ea607c1b78a481a21eb13926d40ecdc0b2"},{"path":"modules/secondbrain.json","bytes":4333,"digest":"sha256:3ce59d0ddfcd0e2e5de34d9a4541a27bc62c6e72800da1cd02353ba2f2aa2024"},{"path":"modules/spec-workflow.json","bytes":6441,"digest":"sha256:574f08d79b15e4d9a6a38c3a9a85636f5a208aa9e43051383815056fc03c6e53"},{"path":"modules/tui-surface.json","bytes":1658,"digest":"sha256:06e18ef65d3a7184aaa3a80a21d2279b3376ab77f07bb8b47d23497b0fb6fa62"},{"path":"modules/typescript.json","bytes":8526,"digest":"sha256:7a2a82f7efb28df7dbc343f1b5a7b0ab176e8a74105eb6077c7b5cf6648bdbc6"},{"path":"profiles/go-cli-tui.json","bytes":1709,"digest":"sha256:231220ac521a8298d86440f6e262d61b9c2fa249459dd9206973d8db6e6941bb"},{"path":"profiles/rust-cli.json","bytes":1627,"digest":"sha256:f2338dbf8d0085a6150293c157b6986714de122b2ebd732e33a09f0614686c81"},{"path":"profiles/standard-typescript-monorepo.json","bytes":10907,"digest":"sha256:803f62fb451f9fda23a58b7bfe5f5367a45d8d44bb63c3f96e33b259393cc5ba"},{"path":"retention/transition.legacy-typescript-bun-to-portable-v3.json","bytes":12425,"digest":"sha256:3b166a42eb1f9f23202afc48399eb7aa9b0e8563d9c40095ad6bf710a5d1c599"},{"path":"retention/transition.managed-v2-to-portable-v3.json","bytes":11185,"digest":"sha256:8a0e9dd06aa6a5988c010f6c1286c39002c65e5aae6762e9af6956424524c3e9"},{"path":"setups/go-cli.json","bytes":14203,"digest":"sha256:28e2ad53684c2c097affa4cf70c32a59265435dd105cdcfa57314bd18a6af46f"},{"path":"setups/rust-cli.json","bytes":12476,"digest":"sha256:82cdba03b2c77c9a0c4a6ad737e7de5e90a3fcf6827c18242793b8032766d9c0"},{"path":"setups/typescript-bun.json","bytes":40649,"digest":"sha256:31c1b8c942f443c7dfded93b6d7b29ef833266a7778c34a8387a0f56c741f91d"},{"path":"skill-activations.json","bytes":3330,"digest":"sha256:bbf1978828a2d383c9cee675a612c17281224e0a1ce5df4b09ba13af4ccd3667"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/accounting.json","bytes":17193,"digest":"sha256:69e39f9df0eb54d08ecb739dc3dfc9dd80c0cf76016aca3d6a0566e48cfaa1f5"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/baseline.json","bytes":498,"digest":"sha256:dca6a12ae24a77fc9104dfcccb507c50dee0e5257e0bf0bd9d86c0b04ce7ffe0"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/AGENTS.md","bytes":583,"digest":"sha256:3b6b36fd045ecb683aec29e0ceac52b3704604815d8035297bfeb8438602494e"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/agent-instructions.md","bytes":8996,"digest":"sha256:fe584b8901ae2521dcba7014ca11f3557109a4d91d7c56a21852f027a4bf205e"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/autonomous-work.md","bytes":1548,"digest":"sha256:3deff187dba70156ca834529741c4be7df84c5a66f8462ba01594fee35d40336"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/backend.md","bytes":2197,"digest":"sha256:c553c12e3cb13b0435a219fd52ac735bf4f9f3aad05fdb943fd6922cef0a97ac"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/docs-layout.md","bytes":7671,"digest":"sha256:8a78a3543a0a52ac93ef83ab36f953e813be068e96957553483c928cbad439bd"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/domain.md","bytes":947,"digest":"sha256:54c79c324d20b412c07c634f490ad18eeace0562caffe5507b3c83cebf9aaf20"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/external-triage.md","bytes":335,"digest":"sha256:0ec2089110c6a673f305e86f436538c7c07aeef46d29a4e68674defb8778e2cc"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/frontend.md","bytes":1934,"digest":"sha256:4a3609c27bf100aaf0d7de934ad43c0f6a561a15600517422a325fcc6120bd14"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/issue-tracker.md","bytes":890,"digest":"sha256:d86ab246d701a54b8d61c5dcdc1d29fe3281115c028e8694f10574ce99e537c7"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/monorepo.md","bytes":366,"digest":"sha256:c59091add143e67b3773df78d95cc4c8f11d43909655ef1c09dd64c0cc903b3d"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/secondbrain.md","bytes":3312,"digest":"sha256:4b58f3ed96f654d8b99ccc9a03718505f4c43a6170eceef47e26484b5f95bffb"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/skill-dispatch.md","bytes":374,"digest":"sha256:90bbf02e35bba9c707e4713eef4de9a6db36ba3fa93393531287aaf94fce8f8b"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/spec-routing.md","bytes":3094,"digest":"sha256:dfc867401e547d4370f20c578a22e6b82cc80d7b677072f31bc11cdba4851085"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/corpus/docs/agents/typescript-bun.md","bytes":2561,"digest":"sha256:7295fe2e544af37e6d6777537fa7ee512b78f3da932d736cc7eabc8d190a30e8"},{"path":"source-baselines/baseline.standard-typescript-monorepo-0.0.1/manifest.json","bytes":37175,"digest":"sha256:e9a9e91f8dbbea8dc416a03fe813b9c370a2e012118f548b18f1c6a13b6b7662"},{"path":"source-baselines/index.json","bytes":5266,"digest":"sha256:ad636b4cbaf57e27c81093aee9963bcd2b276df7ac243f04b423bf989db314a8"},{"path":"templates/extensions/specific-repository.md","bytes":38,"digest":"sha256:f8aad350dfecd91923c12fe215c1e21dd6741635e44869110391ba9c5c89d137"},{"path":"templates/guides/agent-instructions.md","bytes":260,"digest":"sha256:91f0a4b1d664cf6ab261499b3f7cb093bd2337d6567d07599faebd8fe58dc8d3"},{"path":"templates/guides/autonomous-work.md","bytes":194,"digest":"sha256:384fdebdb4ffd5d6c1b7b197cc02e3f9d3e7327acd7151dc7ce29c0e53e99486"},{"path":"templates/guides/backend.md","bytes":201,"digest":"sha256:0bffbf1e74c9b8c506ec1c612aac0dfaedcfb6f1b7aafc8d1a2d6029cd1170ea"},{"path":"templates/guides/bun.md","bytes":26,"digest":"sha256:3a0640850dc84c8dd6c6b35f10864ae6d3d9c153147699b7bfced6332315633d"},{"path":"templates/guides/cli-surface.md","bytes":34,"digest":"sha256:7cdd6ecc6575c4ef165858ced6409278430a89e3f67baa4281151dc13e2742bc"},{"path":"templates/guides/docs-layout.md","bytes":34,"digest":"sha256:51e9033d80e6742321b47b19c85034c9b4d15b36b9c84230512e3a9a4ef90628"},{"path":"templates/guides/domain-multi-context.md","bytes":112,"digest":"sha256:56b7000576757f37da275123ab75ff5f84d2e26bd88c9f67034a24f3d4883a41"},{"path":"templates/guides/domain-single-context.md","bytes":104,"digest":"sha256:f75666666408761a67d23855f5f78e4526328404343f09a036f8987895c69c0d"},{"path":"templates/guides/domain.md","bytes":59,"digest":"sha256:5c4195e0348b6e5a57679fc833e58b09f4b08080b1cec90a946d0b666310739e"},{"path":"templates/guides/external-triage.md","bytes":38,"digest":"sha256:f26c4164ff69bf44263d9993e4209a9305b8979f228f6f519f139e83f9b51ae9"},{"path":"templates/guides/frontend.md","bytes":265,"digest":"sha256:3243626ac7b55130ac198ba32a350966a26f6a9801a4a4fee259817b2f7eb5f9"},{"path":"templates/guides/go.md","bytes":25,"digest":"sha256:7f597711bdab9fe85ecda9f5895687e436009446ee8416ed96f7bc27fe7b00fd"},{"path":"templates/guides/issue-tracker.md","bytes":36,"digest":"sha256:48840599c91f2f3cf2fd79700f41e54bc3edbb4fa19e4861247e19bac0077fb1"},{"path":"templates/guides/monorepo.md","bytes":31,"digest":"sha256:e1866f1633f3bbc5b230b5c5290b71ae47110dce4d4344c264a9eb325a477bca"},{"path":"templates/guides/rust.md","bytes":27,"digest":"sha256:e0e693135266e96cabec918b14f0cf51c7bf1a126d900001cc418f982f8bbec2"},{"path":"templates/guides/secondbrain.md","bytes":34,"digest":"sha256:a59dc0ae149d4bb3bdd633f79ba91fd17e6d3d3d930c346e5757b5f85ab69002"},{"path":"templates/guides/skill-dispatch.md","bytes":253,"digest":"sha256:0f054b846da5b17409dde049b3b182489e6c24e84734d4a5ffc47a9f504e8a7c"},{"path":"templates/guides/spec-docs-layout.md","bytes":39,"digest":"sha256:6d9ddb174c4f69cb1f9abe428a6be677e9ea8dbecc19ae0fa2f53fdc3749de40"},{"path":"templates/guides/spec-routing.md","bytes":35,"digest":"sha256:cc578e73968c15bdcaad35270454e91462b4413d937cfd984404ef22d765482c"},{"path":"templates/guides/tui-surface.md","bytes":34,"digest":"sha256:bbc422cb32df905d526a291cbfd5b81fd86f0d405f5f650cc5d8eb0a864c4eb2"},{"path":"templates/guides/typescript-bun.md","bytes":41,"digest":"sha256:ee14609660ebe1da17eff0d12c3f42857cba4c92be0f723346d17e3d64bb6574"},{"path":"templates/index.json","bytes":7125,"digest":"sha256:272cbfd4fa2ea380acfc76ce4d75eaf2ae026b7501c34ee48a8645644eb72f4c"},{"path":"templates/root/autonomous-work.md","bytes":102,"digest":"sha256:abd0be4c74295597be5dca6a307e41d6bdcfa5113370f5bbc30d30725c927e35"},{"path":"templates/root/backend.md","bytes":76,"digest":"sha256:f65a96aaa0b79d40d209df159c30bd6ac791e36c415b8ba2dc52a1032c5785c3"},{"path":"templates/root/bun.md","bytes":89,"digest":"sha256:48a4bcd7c9b86d8f3be6965ff8b7b33aad77148aa598d49ed8efa74acd3c8e20"},{"path":"templates/root/cli-surface.md","bytes":79,"digest":"sha256:ec022b0b1767d88ebc849f2ee7d8a2d266a36d419f8084b9cce8599d0619a125"},{"path":"templates/root/context-workflow.md","bytes":129,"digest":"sha256:52c53e99fed6c5d59388e58f7854c96f8130da553d80bb90f04935f67eea45c5"},{"path":"templates/root/core.md","bytes":213,"digest":"sha256:ffa6f25825e1d5c5bf52373022d7035b004fd78dd55f99a187d89c4b78ddeabb"},{"path":"templates/root/external-triage.md","bytes":91,"digest":"sha256:5ea18c6c7aea8f03d62af0b824b947ddecbaf861fbf9b81df2e8fb2b2c0e2c54"},{"path":"templates/root/frontend.md","bytes":99,"digest":"sha256:cddad0b38dff9cbeae571dc8514f5d2770e528725f8931272389bd8a675e08bb"},{"path":"templates/root/go.md","bytes":67,"digest":"sha256:aa3eafd5ebcec7aa64ae7fddf1c5b954512e831121934760f615c12ec52f6527"},{"path":"templates/root/monorepo.md","bytes":74,"digest":"sha256:76951b431357333d5b3bbbe718529797834c5de14ee6cd886bce175b5ef892e9"},{"path":"templates/root/repository-extension.md","bytes":103,"digest":"sha256:f24b6015a05eab4cdbb830eb485f9958b659d9a99d46b53d8538a813f6705fe1"},{"path":"templates/root/rust.md","bytes":73,"digest":"sha256:40c4f50d3a020406816612b9f9e0c6cbd545626401e447d4963ba2d30d8e40e3"},{"path":"templates/root/secondbrain.md","bytes":87,"digest":"sha256:dbb11588c2dbca5fd803eccc889586cac75824e9083c570bd1cad34fda901857"},{"path":"templates/root/spec-workflow.md","bytes":125,"digest":"sha256:c292c5b2b98facb56ef0f6da0a61f38544377ddb6339c63d5287ca7dec4f2360"},{"path":"templates/root/tui-surface.md","bytes":75,"digest":"sha256:5657d3abaddf480a535d00a8bfe94bcd3e0b6230aaaa3aac2f9ce89fdc7e20b0"},{"path":"templates/root/typescript.md","bytes":76,"digest":"sha256:9673f69acb604560153c93c46aa6a8a03ecedc81c3eae15e03b0b1b763dbcde7"}],"profiles":["go-cli-tui","rust-cli","standard-typescript-monorepo"],"modules":["autonomous-work","backend","bun","cli-surface","context-workflow","core","external-triage","frontend","go","monorepo","repository-extension","rust","secondbrain","spec-workflow","tui-surface","typescript"],"decisions":["auth.provider","autonomous.enabled","domain.layout","http.contract","identifier.strategy","language.generated","repository.extension.enabled","runtime.backend","runtime.design","secondbrain.enabled","spec.scaffold","triage.external","verification.gate"],"templates":["template.extension.repository-rules","template.guide.agent-instructions","template.guide.autonomous-work","template.guide.backend","template.guide.bun","template.guide.cli-surface","template.guide.docs-layout","template.guide.domain","template.guide.domain.multi-context","template.guide.domain.single-context","template.guide.external-triage","template.guide.frontend","template.guide.go","template.guide.issue-tracker","template.guide.monorepo","template.guide.rust","template.guide.secondbrain","template.guide.skill-dispatch","template.guide.spec-docs-layout","template.guide.spec-routing","template.guide.tui-surface","template.guide.typescript-bun","template.root.autonomous-work","template.root.backend","template.root.bun","template.root.cli-surface","template.root.context-workflow","template.root.core","template.root.external-triage","template.root.frontend","template.root.go","template.root.monorepo","template.root.repository-extension","template.root.rust","template.root.secondbrain","template.root.spec-workflow","template.root.tui-surface","template.root.typescript"],"setups":["go-cli","rust-cli","typescript-bun"],"retentionTransitions":["transition.legacy-typescript-bun-to-portable-v3","transition.managed-v2-to-portable-v3"]} diff --git a/internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json b/internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json index b2126af1..80b8202a 100644 --- a/internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json +++ b/internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json @@ -1935,7 +1935,7 @@ "treeDigest": "cc7137c53d51fea605c0be017cf6eecdb72add7405e5c3c9f327dbc26d4572fa" }, { - "contentDigest": "1e4ea59e0d6e553e42c6c63e16ad95165a78be8bbf31b8e0cd8b56ce13cc9146", + "contentDigest": "fa774d82f16661c81c235738c78116fba2fdc328bac5797fd663fa31a05f42d6", "name": "roundfix", "path": "skills/06-review-repair/roundfix", "source": { diff --git a/internal/baseline/testdata/parity-corpus/v1/manifest.json b/internal/baseline/testdata/parity-corpus/v1/manifest.json index b038c9cb..6b38fda7 100644 --- a/internal/baseline/testdata/parity-corpus/v1/manifest.json +++ b/internal/baseline/testdata/parity-corpus/v1/manifest.json @@ -22,7 +22,7 @@ { "bytes": 83662, "path": "fixtures/asset-sync.json", - "sha256": "1166f01b5f2f5c0f0fe361813e065fd9aa415d0633101fb6348903071644d24f" + "sha256": "31620de106dfae46414ea11c8ff420e611b1c874c5d4e2936d8f37234142c59b" }, { "bytes": 330237, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a2653abd..0d322ab7 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -168,6 +168,7 @@ var promptProjectClaudeSkillSymlink = defaultPromptProjectClaudeSkillSymlink var cancelStopAgentSession = defaultCancelStopAgentSession var listRoundfixAgentSessions = defaultListRoundfixAgentSessions var closeStopAgentSession = defaultCloseStopAgentSession +var ownerProcesses OwnerProcessController = store.NewOwnerProcessController() type validationError struct { message string @@ -370,10 +371,64 @@ type stopRequest struct { } type stopResult struct { - Run store.Run - Requested bool - Forced bool - Warnings []string + Run store.Run + Requested bool + Forced bool + Transitioned bool + Warnings []cleanupWarning +} + +// cleanupWarningKind is declared by each secondary cleanup warning producer +// so consumers branch on the producer's classification instead of matching +// warning text. +type cleanupWarningKind int + +const ( + // cleanupWarningNotice reports non-failure cleanup activity. + cleanupWarningNotice cleanupWarningKind = iota + // cleanupWarningFailure reports a failed or skipped cleanup step. + cleanupWarningFailure +) + +type cleanupWarning struct { + kind cleanupWarningKind + text string +} + +func cleanupNoticef(format string, args ...any) cleanupWarning { + return cleanupWarning{kind: cleanupWarningNotice, text: fmt.Sprintf(format, args...)} +} + +func cleanupFailuref(format string, args ...any) cleanupWarning { + return cleanupWarning{kind: cleanupWarningFailure, text: fmt.Sprintf(format, args...)} +} + +type OwnerProcessController interface { + // ProveOwner proves the recorded owner identity without side effects so + // Force Stop can fail closed before touching anything the Run owns. + ProveOwner(ctx context.Context, pid int, recordedIdentity string) error + TerminateAndWait(ctx context.Context, pid int, recordedIdentity string) error +} + +type forceStopOwnerError struct { + RunID string + PID int + Step string + Err error +} + +func (err forceStopOwnerError) Error() string { + return fmt.Sprintf( + "force stop Run %s owner PID %d failed step %q: %v; Run remains Active; Active Run lock retained", + err.RunID, + err.PID, + err.Step, + err.Err, + ) +} + +func (err forceStopOwnerError) Unwrap() error { + return err.Err } func runStopCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { @@ -404,11 +459,25 @@ func runStopCommand(ctx context.Context, args []string, stdout, stderr io.Writer result, err := stopTargetRun(ctx, req, loaded, runStore, stderr) if err != nil { printStopFailure(err, stderr) + journalStopPrimaryFailure(ctx, runStore, result.Run.ID, err) + reportSecondaryCleanupWarnings(ctx, runStore, result.Run.ID, result.Warnings, stderr) + var ownerErr forceStopOwnerError + if errors.As(err, &ownerErr) { + return exitRunFailed + } return exitPreflight } - for _, warning := range result.Warnings { - fmt.Fprintf(stderr, "%s: %s\n", app.Name, warning) + if result.Transitioned { + publishTerminalCompletion( + ctx, + runStore, + outcomeNotifierFromConfig(loaded.Config), + stderr, + store.CompleteRunResult{Run: result.Run, Transitioned: true}, + 0, + ) } + reportSecondaryCleanupWarnings(ctx, runStore, result.Run.ID, result.Warnings, stderr) printStopSuccess(result, stdout) return exitOK } @@ -553,10 +622,51 @@ func stopTargetRun(ctx context.Context, req stopRequest, loaded roundconfig.Load } func forceStopRun(ctx context.Context, runStore *store.Store, active store.Run, worktreeLocation string) (stopResult, error) { - warnings := bestEffortForceStopAgentSessions(ctx, active) + if store.IsTerminalState(active.State) { + if active.State == store.StateStopped { + return stopResult{Run: active, Forced: true}, nil + } + return stopResult{}, store.TerminalOutcomeConflictError{ + RunID: active.ID, + Stored: active.State, + Requested: store.StateStopped, + } + } + + pid, ok := activeOwnerPID(active) + if !ok { + return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: 0, + Step: "validate recorded owner PID", + Err: store.ErrOwnerProcessIdentityUnproven, + } + } + // The owner proof is read-only, so it runs before anything the Run owns is + // touched: a Run left Active with its lock retained must keep its Agent + // Sessions intact. Once the owner is proven, PRD Core Feature 3 orders the + // destructive steps: cancel registered Agent Sessions, then terminate the + // owner and wait for its exit. + if err := ownerProcesses.ProveOwner(ctx, pid, active.OwnerIdentity); err != nil { + return stopResult{Run: active}, forceStopOwnerError{ + RunID: active.ID, + PID: pid, + Step: forceStopOwnerStep(err, "prove owner process identity"), + Err: err, + } + } + warnings := bestEffortForceStopAgentSessions(ctx, runStore, active) + if err := ownerProcesses.TerminateAndWait(ctx, pid, active.OwnerIdentity); err != nil { + return stopResult{Run: active, Warnings: warnings}, forceStopOwnerError{ + RunID: active.ID, + PID: pid, + Step: forceStopOwnerStep(err, "prove owner exit"), + Err: err, + } + } run, err := runStore.CompleteRun(ctx, active.ID, store.StateStopped) if err != nil { - return stopResult{}, err + return stopResult{Run: active, Warnings: warnings}, err } if strings.TrimSpace(active.GitRoot) != "" && strings.TrimSpace(active.WorkDir) != "" { pruned, pruneErr := pruneTerminalRunWorktrees(ctx, active.GitRoot, worktreeLocation, func(runID string) bool { @@ -564,75 +674,168 @@ func forceStopRun(ctx context.Context, runStore *store.Store, active store.Run, return err == nil && found && store.IsTerminalState(run.State) }) for _, ref := range pruned { - warnings = append(warnings, fmt.Sprintf("reaped terminal Worktree path=%s branch=%s", ref.Path, ref.Branch)) + warnings = append(warnings, cleanupNoticef("reaped terminal Worktree path=%s branch=%s", ref.Path, ref.Branch)) } if pruneErr != nil { - warnings = append(warnings, fmt.Sprintf("terminal Worktree reap failed for Run %s: %v", active.ID, pruneErr)) + warnings = append(warnings, cleanupFailuref("terminal Worktree reap failed for Run %s: %v", active.ID, pruneErr)) } } - return stopResult{Run: run, Forced: true, Warnings: warnings}, nil + return stopResult{ + Run: run.Run, + Forced: true, + Transitioned: run.Transitioned, + Warnings: warnings, + }, nil } -func bestEffortForceStopAgentSessions(ctx context.Context, run store.Run) []string { - agentID := strings.TrimSpace(run.Agent) - if agentID == "" { - return []string{fmt.Sprintf("Agent Session cancel skipped for Run %s: no Agent runtime recorded", run.ID)} +// forceStopOwnerStep names the owner-control step that failed, preferring the +// controller's own step label when it reported one. +func forceStopOwnerStep(err error, fallback string) string { + var controlErr store.OwnerProcessControlError + if errors.As(err, &controlErr) && strings.TrimSpace(controlErr.Step) != "" { + return controlErr.Step } - runtime, err := agent.RuntimeFor(agent.RuntimeOptions{Agent: agentID}) + return fallback +} + +func bestEffortForceStopAgentSessions(ctx context.Context, runStore *store.Store, run store.Run) []cleanupWarning { + activeScopes, err := runStore.ActiveAgentSelectionScopes(ctx, run.ID) if err != nil { - return []string{fmt.Sprintf("Agent Session cancel skipped for Run %s: %v", run.ID, err)} + return []cleanupWarning{cleanupFailuref("Agent Session cleanup registry failed for Run %s: %v", run.ID, err)} } - session := agent.SessionRefForRun(run.ID, runSessionWorkDir(run)) - warnings := []string{} - cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() - if err := cancelStopAgentSession(cancelCtx, runtime, session); err != nil { - warnings = append(warnings, fmt.Sprintf("Agent Session cancel failed for Run %s: %v", run.ID, err)) + warnings := []cleanupWarning{} + for _, selection := range activeScopes { + warnings = append(warnings, cleanupRegisteredAgentSession(ctx, runStore, run, selection)...) } - return append(warnings, closeForceStoppedRunSessions(ctx, runtime, run, session)...) + return warnings } func defaultCancelStopAgentSession(ctx context.Context, runtime agent.RuntimeSpec, session agent.SessionRef) error { return agent.NewDefaultRunner().CancelSession(ctx, runtime, session) } -func closeForceStoppedRunSessions(ctx context.Context, runtime agent.RuntimeSpec, run store.Run, runSession agent.SessionRef) []string { - closeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() - discovered, err := listRoundfixAgentSessions(closeCtx, runtime, runSession.WorkDir) - warnings := []string{} +func cleanupRegisteredAgentSession( + ctx context.Context, + runStore *store.Store, + run store.Run, + selection store.AgentSelectionAttempt, +) []cleanupWarning { + warnings := []cleanupWarning{} + runtime, err := agent.RuntimeFor(agent.RuntimeOptions{ + Agent: selection.Runtime, + Model: selection.Model, + ReasoningEffort: selection.ReasoningEffort, + }) if err != nil { - warnings = append(warnings, fmt.Sprintf("Agent Session discovery failed for Run %s: %v", run.ID, err)) - } - sessions := []agent.RoundfixSession{{ - Name: runSession.Name, - RunID: run.ID, - }} - for _, session := range discovered { - if session.RunID == run.ID && session.TaskID != "" { - sessions = append(sessions, session) - } + return []cleanupWarning{cleanupFailuref( + "Agent Session cleanup skipped for %s %s in Run %s: %v", + selection.ScopeKind, + selection.ScopeID, + run.ID, + err, + )} + } + session, err := registeredAgentSessionRef(run, selection) + if err != nil { + return []cleanupWarning{cleanupFailuref( + "Agent Session cleanup skipped for %s %s in Run %s: %v", + selection.ScopeKind, + selection.ScopeID, + run.ID, + err, + )} } - sort.SliceStable(sessions[1:], func(i, j int) bool { - return sessions[i+1].Name < sessions[j+1].Name - }) - seen := map[string]struct{}{} - for _, session := range sessions { - if strings.TrimSpace(session.Name) == "" { - continue + + cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + cancelErr := cancelStopAgentSession(cancelCtx, runtime, session) + cancel() + if cancelErr != nil && !agent.IsAgentSessionAbsent(cancelErr) { + warnings = append(warnings, cleanupFailuref( + "Agent Session cancel failed for %s %s (%s): %v", + selection.ScopeKind, + selection.ScopeID, + session.Name, + cancelErr, + )) + } + + closeCtx, closeCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + closeErr := closeStopAgentSession(closeCtx, runtime, session) + closeCancel() + if closeErr != nil && !agent.IsAgentSessionAbsent(closeErr) { + warnings = append(warnings, cleanupFailuref( + "Agent Session close failed for %s %s (%s): %v", + selection.ScopeKind, + selection.ScopeID, + session.Name, + closeErr, + )) + return warnings + } + if err := recordClosedAgentSelection(ctx, runStore, selection); err != nil { + warnings = append(warnings, cleanupFailuref( + "Agent Selection closed lifecycle failed for %s %s in Run %s: %v", + selection.ScopeKind, + selection.ScopeID, + run.ID, + err, + )) + } + return warnings +} + +func registeredAgentSessionRef(run store.Run, selection store.AgentSelectionAttempt) (agent.SessionRef, error) { + var session agent.SessionRef + switch selection.ScopeKind { + case store.AgentSelectionScopeTask: + workDir := runSessionWorkDir(run) + if taskWorkDir, ok := taskSessionWorkDir(run, selection.ScopeID); ok { + workDir = taskWorkDir } - if _, ok := seen[session.Name]; ok { - continue + session = agent.SessionRefForTask(run.ID, selection.ScopeID, workDir) + case store.AgentSelectionScopeQA: + session = agent.SessionRefForQA(run.ID, runSessionWorkDir(run)) + case store.AgentSelectionScopeReview: + batchID, ok := strings.CutPrefix(strings.TrimSpace(selection.ScopeID), "batch-") + if !ok { + return agent.SessionRef{}, fmt.Errorf("review scope ID %q must use batch-NNN", selection.ScopeID) } - seen[session.Name] = struct{}{} - ref := sessionRefForDiscoveredRunSession(run, session) - if err := closeStopAgentSession(closeCtx, runtime, ref); err != nil { - warnings = append(warnings, fmt.Sprintf("could not close session %s: %v", ref.Name, err)) - continue + batchNumber, err := strconv.Atoi(batchID) + if err != nil || batchNumber <= 0 { + return agent.SessionRef{}, fmt.Errorf("review scope ID %q must use a positive batch number", selection.ScopeID) } - warnings = append(warnings, fmt.Sprintf("closed session %s", ref.Name)) + session = agent.SessionRefForReview(run.ID, batchNumber, run.GitRoot) + default: + return agent.SessionRef{}, fmt.Errorf("scope kind %q is unsupported", selection.ScopeKind) + } + if strings.TrimSpace(session.Name) == "" { + return agent.SessionRef{}, errors.New("registered Agent Session name is empty") + } + if selection.FallbackIndex > 0 { + session.Name = fmt.Sprintf("%s-fallback-%02d", session.Name, selection.FallbackIndex) + } + return session, nil +} + +func recordClosedAgentSelection(ctx context.Context, runStore *store.Store, selection store.AgentSelectionAttempt) error { + _, err := runStore.AppendAgentSelectionAttempt(context.WithoutCancel(ctx), store.AgentSelectionAttemptRequest{ + RunID: selection.RunID, + ScopeKind: selection.ScopeKind, + ScopeID: selection.ScopeID, + Category: selection.Category, + ProfileSource: selection.ProfileSource, + Attempt: selection.Attempt, + SelectionRole: selection.SelectionRole, + FallbackIndex: selection.FallbackIndex, + Runtime: selection.Runtime, + Model: selection.Model, + ReasoningEffort: selection.ReasoningEffort, + Status: store.AgentSelectionStatusClosed, + }) + if err != nil { + return fmt.Errorf("record closed Agent Selection lifecycle: %w", err) } - return warnings + return nil } func sessionRefForDiscoveredRunSession(run store.Run, session agent.RoundfixSession) agent.SessionRef { @@ -1468,27 +1671,33 @@ func inspectBranchIntegrity(ctx context.Context, homeDir string, preflightResult if err != nil { return report, err } - reader, err := store.OpenReader(ctx, homeDir) + if _, err := os.Stat(store.DatabasePath(homeDir)); errors.Is(err, os.ErrNotExist) { + // No Run Database yet: no Run can attribute the branches, and no + // Active Run can exist. Keep every pending branch conservatively + // without creating the database on a preflight that may still fail. + report.Pending = pending + return report, nil + } else if err != nil { + return report, fmt.Errorf("inspect Run Database before Branch Integrity Preflight: %w", err) + } + // Open the migrating store: every caller is an operational command that + // creates Runs, so upgrading an existing Run Database here is in-contract + // and keeps this inspection from querying an unmigrated schema. + runStore, err := store.Open(ctx, homeDir) if err != nil { - if errors.Is(err, os.ErrNotExist) { - // No Run Database yet: no Run can attribute the branches, and no - // Active Run can exist. Keep every pending branch conservatively. - report.Pending = pending - return report, nil - } return report, err } defer func() { - _ = reader.Close() + _ = runStore.Close() }() - report.Pending, err = filterPendingRunWorkByTarget(ctx, reader, pending, preflightResult.PullRequest.HeadBranch) + report.Pending, err = filterPendingRunWorkByTarget(ctx, runStore, pending, preflightResult.PullRequest.HeadBranch) if err != nil { return report, err } // Scan the runs table instead of the lock table: Runs created with the // Branch Integrity bypass hold no Active Run lock but must stay visible // to subsequent guard checks. - active, found, err := reader.ActiveReviewRunByTarget(ctx, preflightResult.PullRequest.HeadRepository, preflightResult.PullRequest.HeadBranch) + active, found, err := runStore.ActiveReviewRunByTarget(ctx, preflightResult.PullRequest.HeadRepository, preflightResult.PullRequest.HeadBranch) if err != nil { return report, err } @@ -1502,7 +1711,7 @@ func inspectBranchIntegrity(ctx context.Context, homeDir string, preflightResult // to a different branch: git topology alone cannot tell a Run Branch based on // the PR Head Branch from one based on another feature branch. Branches with // no Run row are kept conservatively. -func filterPendingRunWorkByTarget(ctx context.Context, reader *store.Store, pending []runworktree.PendingRunWork, headBranch string) ([]runworktree.PendingRunWork, error) { +func filterPendingRunWorkByTarget(ctx context.Context, runStore *store.Store, pending []runworktree.PendingRunWork, headBranch string) ([]runworktree.PendingRunWork, error) { headBranch = strings.TrimSpace(headBranch) filtered := make([]runworktree.PendingRunWork, 0, len(pending)) for _, work := range pending { @@ -1511,7 +1720,7 @@ func filterPendingRunWorkByTarget(ctx context.Context, reader *store.Store, pend filtered = append(filtered, work) continue } - row, found, err := reader.Run(ctx, runID) + row, found, err := runStore.Run(ctx, runID) if err != nil { return nil, fmt.Errorf("attribute pending Run Branch %s: %w", work.Branch, err) } @@ -1943,8 +2152,7 @@ func runResolveCommand(ctx context.Context, req commandRequest, loaded roundconf return exitRunFailed } closeAgentSession(ctx, collaborators.runner, resolvePlan.runtime, sessionForClose, completed.ID, runStore) - publishRunOutcome(ctx, runStore, completed.ID, completed.State, cycleResult.Remaining, stderr) - notifyTerminalOutcome(ctx, runStore, notifier, stderr, completed) + publishTerminalCompletion(ctx, runStore, notifier, stderr, completed, cycleResult.Remaining) // The cockpit stays on screen, read-only, until the user closes it. ui.Wait() ui.Close() @@ -1970,8 +2178,7 @@ func completeStoppedRunRecord(runStore *store.Store, runID string, notifier roun if err != nil { return exitRunFailed } - publishRunOutcome(ctx, runStore, completed.ID, completed.State, 0, io.Discard) - notifyTerminalOutcome(ctx, runStore, notifier, stderr, completed) + publishTerminalCompletion(ctx, runStore, notifier, stderr, completed, 0) return exitOK } @@ -2357,6 +2564,7 @@ func runWatchCommand(ctx context.Context, req commandRequest, loaded roundconfig BudgetEnabled: loaded.Config.Budget.Enabled, MaxRunDuration: loaded.Config.Budget.MaxRunDuration, }, watch.Dependencies{ + StopRequests: runStore, StatusSource: watch.StatusFunc(func(ctx context.Context, statusReq watch.StatusRequest) (watch.Status, error) { status, err := watchReviewStatus(ctx, reviewsource.WatchStatusRequest{ Source: req.source, @@ -2421,8 +2629,7 @@ func runWatchCommand(ctx context.Context, req commandRequest, loaded roundconfig return exitRunFailed } closeAgentSession(completeCtx, collaborators.runner, runtime, sessionForClose, completed.ID, runStore) - publishRunOutcome(completeCtx, runStore, completed.ID, completed.State, result.Remaining, stderr) - notifyTerminalOutcome(completeCtx, runStore, notifier, stderr, completed) + publishTerminalCompletion(completeCtx, runStore, notifier, stderr, completed, result.Remaining) // The cockpit stays on screen, read-only, until the user closes it. ui.Wait() ui.Close() @@ -2475,6 +2682,7 @@ func createOperationalRun(ctx context.Context, runStore *store.Store, kind strin func createReviewRun(ctx context.Context, runStore *store.Store, req commandRequest, createReq store.CreateRunRequest, stderr io.Writer) (store.Run, error) { createReq.OwnerPID = os.Getpid() + createReq.OwnerIdentity = currentOwnerIdentity(ctx) if req.skipBranchIntegrity && req.branchIntegrity.ActiveRun != nil { return runStore.CreateRunSkippingActiveLock(ctx, createReq) } @@ -2509,7 +2717,7 @@ func reclaimOrphanedActiveRun(ctx context.Context, runStore *store.Store, active return active, false, nil } reason := orphanedActiveRunReason(pid) - printForceStopAgentSessionWarnings(stderr, bestEffortForceStopAgentSessions(ctx, active)) + printForceStopAgentSessionWarnings(stderr, bestEffortForceStopAgentSessions(ctx, runStore, active)) if err := runStore.ReclaimOrphanedRun(ctx, active, reason); err != nil { return active, false, fmt.Errorf("reclaim orphaned Active Run %s: %w", active.ID, err) } @@ -2524,12 +2732,12 @@ func reclaimOrphanedActiveRun(ctx context.Context, runStore *store.Store, active return reclaimed, true, nil } -func printForceStopAgentSessionWarnings(stderr io.Writer, warnings []string) { +func printForceStopAgentSessionWarnings(stderr io.Writer, warnings []cleanupWarning) { if stderr == nil { return } for _, warning := range warnings { - fmt.Fprintf(stderr, "%s: %s\n", app.Name, warning) + fmt.Fprintf(stderr, "%s: %s\n", app.Name, warning.text) } } @@ -2540,6 +2748,17 @@ func activeOwnerPID(run store.Run) (int, bool) { return *run.OwnerPID, true } +// currentOwnerIdentity returns this process's start-time identity token for +// the Run row, or "" when the platform cannot provide one. An absent token +// degrades Force Stop to the legacy PID-only owner proof. +func currentOwnerIdentity(ctx context.Context) string { + identity, err := store.OwnerProcessIdentity(ctx, os.Getpid()) + if err != nil { + return "" + } + return identity +} + func orphanedActiveRunReason(pid int) string { return fmt.Sprintf("owner process %d not running; lock reclaimed", pid) } @@ -2734,7 +2953,7 @@ func printStopSummary(req commandRequest, preflightResult preflight.Result, stde } func isStopRequest(ctx context.Context, err error) bool { - return agent.IsStopError(err) || errors.Is(err, daemon.ErrStopRequested) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || (ctx != nil && ctx.Err() != nil) + return agent.IsStopError(err) || errors.Is(err, daemon.ErrStopRequested) || errors.Is(err, watch.ErrStopRequested) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || (ctx != nil && ctx.Err() != nil) } func defaultInspectChangedPaths(ctx context.Context, gitRoot string) ([]preflight.ChangedPath, error) { @@ -3554,7 +3773,10 @@ func maybeRunFinalPush(ctx context.Context, engine *daemon.Engine, sink runevent } func markRunFailed(ctx context.Context, runStore *store.Store, runID string) { - _, _ = completeFailedRun(ctx, runStore, runID) + completed, ok := completeFailedRun(ctx, runStore, runID) + if ok { + publishTerminalCompletion(ctx, runStore, nil, io.Discard, completed, 0) + } } func markRunFailedAndNotify(ctx context.Context, runStore *store.Store, runID string, notifier roundnotify.Notifier, stderr io.Writer) { @@ -3562,16 +3784,15 @@ func markRunFailedAndNotify(ctx context.Context, runStore *store.Store, runID st if !ok { return } - notifyTerminalOutcome(ctx, runStore, notifier, stderr, completed) + publishTerminalCompletion(ctx, runStore, notifier, stderr, completed, 0) } -func completeFailedRun(ctx context.Context, runStore *store.Store, runID string) (store.Run, bool) { +func completeFailedRun(ctx context.Context, runStore *store.Store, runID string) (store.CompleteRunResult, bool) { completeCtx := withoutCancelOrBackground(ctx) completed, err := runStore.CompleteRun(completeCtx, runID, store.StateFailed) if err != nil { - return store.Run{}, false + return store.CompleteRunResult{}, false } - publishRunOutcome(completeCtx, runStore, completed.ID, completed.State, 0, io.Discard) return completed, true } @@ -3621,6 +3842,78 @@ func publishRunOutcome(ctx context.Context, runStore *store.Store, runID string, } } +func publishTerminalCompletion( + ctx context.Context, + runStore *store.Store, + notifier roundnotify.Notifier, + stderr io.Writer, + completed store.CompleteRunResult, + remaining int, +) { + if !completed.Transitioned { + return + } + publishRunOutcome(ctx, runStore, completed.ID, completed.State, remaining, stderr) + notifyTerminalOutcome(ctx, runStore, notifier, stderr, completed.Run) +} + +func journalStopPrimaryFailure(ctx context.Context, runStore *store.Store, runID string, primary error) { + if runStore == nil || strings.TrimSpace(runID) == "" || primary == nil { + return + } + reason := primary.Error() + payload, err := json.Marshal(map[string]string{ + "event": "force_stop_primary_failure", + "reason": reason, + }) + if err != nil { + return + } + _ = (store.JournalSink{Store: runStore}).Publish(withoutCancelOrBackground(ctx), runevent.RunEvent{ + RunID: runID, + Source: runevent.SourceDaemon, + Kind: runevent.KindDaemonStatus, + Summary: runevent.BoundSummary("Force Stop primary failure: " + reason), + Time: time.Now().UTC(), + Payload: payload, + }) +} + +func reportSecondaryCleanupWarnings( + ctx context.Context, + runStore *store.Store, + runID string, + warnings []cleanupWarning, + stderr io.Writer, +) { + for _, warning := range warnings { + if warning.kind != cleanupWarningFailure { + fmt.Fprintf(stderr, "%s: %s\n", app.Name, warning.text) + continue + } + summary := "Secondary cleanup warning: " + warning.text + fmt.Fprintf(stderr, "%s: %s\n", app.Name, summary) + if runStore == nil || strings.TrimSpace(runID) == "" { + continue + } + payload, err := json.Marshal(map[string]string{ + "event": "secondary_cleanup_warning", + "reason": warning.text, + }) + if err != nil { + continue + } + _ = (store.JournalSink{Store: runStore}).Publish(withoutCancelOrBackground(ctx), runevent.RunEvent{ + RunID: runID, + Source: runevent.SourceDaemon, + Kind: runevent.KindDaemonStatus, + Summary: runevent.BoundSummary(summary), + Time: time.Now().UTC(), + Payload: payload, + }) + } +} + func warnCleanRunWorktreeCleanupFailed(ctx context.Context, runStore *store.Store, runID string, worktreePath string, cleanupErr error, stderr io.Writer) { if cleanupErr == nil { return @@ -4233,13 +4526,23 @@ Options: --spec Spec slug used to find the Active Run in the current repository --head-repo Explicit Head Repository, owner/name --head-branch Explicit PR Head Branch - --force Immediately stop a dead or runaway Run and release its lock + --force Force Stop a dead, stuck, or runaway Run after proving owner exit Default stop is graceful: it records a Stop Request and the Run stops after -the current Work Item settles. Use --force only for a dead, stuck, or runaway -Run; it cancels the Agent Session best-effort and completes the Run Stopped -immediately. It also reaps provably empty kept Worktrees and branches, -reporting each reaped path and branch on stderr. +the current Work Item settles. During a Review Source wait, the owner observes +the request by the next configured poll boundary and runs no later fetch, +check, commit, push, or Review Source mutation. + +Force Stop cancels registered active Agent Sessions, terminates the recorded +owner, and reports Stopped only after owner exit is proven. It then releases +the Active Run lock and may reap provably empty kept Worktrees and branches. +If exit proof fails, the Run remains Active; its Active Run lock stays retained. +Inspect it with 'roundfix runs list --state active', resolve the reported +owner-process failure, then retry 'roundfix stop --force '. + +Repeating Force Stop for an already Stopped Run reports the existing outcome +without repeating cleanup. A different terminal outcome is rejected and +preserved. Cleanup failures are secondary warnings after the primary failure. ` case "skills": return `Usage: @@ -4348,7 +4651,7 @@ func printStopSuccess(result stopResult, stdout io.Writer) { printStopRunFields(stdout, run) fmt.Fprintln(stdout) fmt.Fprintf(stdout, "%s\n", style.cyan("Result:")) - fmt.Fprintln(stdout, " Force stop completed the Run as Stopped immediately and released its Active Run locks.") + fmt.Fprintln(stdout, " Force Stop proved the recorded owner process exited, completed the Run as Stopped, and released its Active Run locks.") fmt.Fprintln(stdout) fmt.Fprintf(stdout, "%s\n", style.cyan("No user work side effects:")) fmt.Fprintln(stdout, " Roundfix did not edit user files, commit, push, fetch, or resolve Review Source threads.") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b10ab0b4..b20e39ba 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4028,6 +4028,120 @@ func TestRunOutcomeNotificationsCaptureTerminalResolveWatchAndImplement(t *testi } } +func TestCompletionWinnerOwnerVersusForceStopPublishesOneTerminalOutcome(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + withSuccessfulPreflight(t, repoDir) + persistCLIReviewIssue(t, repoDir, 1, "feature/review") + + agentStarted := make(chan struct{}) + var startedOnce sync.Once + releaseAgent := make(chan struct{}) + var releaseOnce sync.Once + release := func() { + releaseOnce.Do(func() { + close(releaseAgent) + }) + } + t.Cleanup(release) + runner := &fakeAgentRunner{onRun: func(agent.ExecuteRequest) error { + startedOnce.Do(func() { + close(agentStarted) + }) + <-releaseAgent + return nil + }} + withAgentRunner(t, runner) + withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + return nil + }) + withOwnerProcessController(t, ownerProcessControllerFunc(func(context.Context, int, string) error { + return nil + })) + notifier := &recordingOutcomeNotifier{} + withOutcomeNotifier(t, notifier) + + var resolveStdout bytes.Buffer + var resolveStderr bytes.Buffer + resolveDone := make(chan int, 1) + go func() { + resolveDone <- RunContext(context.Background(), []string{ + "resolve", "--pr", "123", "--round", "all", "--no-input", + }, &resolveStdout, &resolveStderr) + }() + + <-agentStarted + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + release() + t.Fatalf("open store during completion race: %v", err) + } + active, found, err := runStore.ActiveRun(context.Background(), "owner/project", "feature/review") + closeErr := runStore.Close() + if err != nil || !found { + release() + t.Fatalf("read active Run during completion race: found=%v err=%v", found, err) + } + if closeErr != nil { + release() + t.Fatalf("close store during completion race: %v", closeErr) + } + + var stopStdout bytes.Buffer + var stopStderr bytes.Buffer + stopCode := RunContext(context.Background(), []string{"stop", "--force", active.ID}, &stopStdout, &stopStderr) + if stopCode != exitOK { + release() + <-resolveDone + t.Fatalf("Force Stop winner exit = %d, want %d; stdout=%q stderr=%q", stopCode, exitOK, stopStdout.String(), stopStderr.String()) + } + assertRunState(t, homeDir, active.ID, store.StateStopped) + release() + resolveCode := <-resolveDone + + if resolveCode != exitRunFailed { + t.Fatalf("losing owner exit = %d, want %d; stop stdout=%q stop stderr=%q resolve stderr=%q", resolveCode, exitRunFailed, stopStdout.String(), stopStderr.String(), resolveStderr.String()) + } + assertRunState(t, homeDir, active.ID, store.StateStopped) + events := runEvents(t, homeDir, active.ID) + outcomes := 0 + for _, entry := range events { + if entry.Event.Kind == runevent.KindDaemonOutcome { + outcomes++ + } + } + if outcomes != 1 { + t.Fatalf("terminal outcome events = %d, want 1; events=%+v", outcomes, events) + } + assertRecordedOutcomes(t, notifier, []roundnotify.Outcome{{ + RunID: active.ID, + State: store.StateStopped, + Kind: store.KindResolve, + Target: "pr:123", + }}) + + stopStdout.Reset() + stopStderr.Reset() + if replayCode := RunContext(context.Background(), []string{"stop", "--force", active.ID}, &stopStdout, &stopStderr); replayCode != exitOK { + t.Fatalf("identical Force Stop replay exit = %d, want %d; stderr=%q", replayCode, exitOK, stopStderr.String()) + } + events = runEvents(t, homeDir, active.ID) + outcomes = 0 + for _, entry := range events { + if entry.Event.Kind == runevent.KindDaemonOutcome { + outcomes++ + } + } + if outcomes != 1 { + t.Fatalf("identical replay published another terminal outcome: events=%+v", events) + } + assertRecordedOutcomes(t, notifier, []roundnotify.Outcome{{ + RunID: active.ID, + State: store.StateStopped, + Kind: store.KindResolve, + Target: "pr:123", + }}) +} + func TestRunOutcomeNotificationsSkipFetch(t *testing.T) { _, repoDir := withCLIWorkspace(t) withSuccessfulPreflight(t, repoDir) @@ -4784,11 +4898,32 @@ resolve: func TestRunWatchStopRequestBeforeAgentMarksStopped(t *testing.T) { homeDir, repoDir := withCLIWorkspace(t) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() withSuccessfulPreflight(t, repoDir) - withWatchStatus(t, func(context.Context, reviewsource.WatchStatusRequest) (reviewsource.WatchStatus, error) { - cancel() + statusCalls := 0 + withWatchStatus(t, func(ctx context.Context, _ reviewsource.WatchStatusRequest) (reviewsource.WatchStatus, error) { + statusCalls++ + if statusCalls > 1 { + t.Fatalf("Stop Request must prevent another Review Source status call") + } + runStore, err := store.Open(ctx, homeDir) + if err != nil { + t.Fatalf("open Run Database to request Stop: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close Run Database after Stop Request: %v", err) + } + }() + active, found, err := runStore.ActiveRun(ctx, "owner/project", "feature/review") + if err != nil { + t.Fatalf("read active watch Run: %v", err) + } + if !found { + t.Fatal("expected active watch Run before Review Source status") + } + if err := runStore.RequestStop(ctx, active.ID); err != nil { + t.Fatalf("request Stop for watch Run: %v", err) + } return reviewsource.WatchStatus{State: watch.StatusPending}, nil }) withFetchReviewItemsFunc(t, func(context.Context, reviewsource.FetchRequest) ([]reviewsource.ReviewItem, error) { @@ -4799,7 +4934,7 @@ func TestRunWatchStopRequestBeforeAgentMarksStopped(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := RunContext(ctx, []string{"watch", "--source", "coderabbit", "--pr", "123", "--until-clean", "--max-rounds", "6", "--no-input"}, &stdout, &stderr) + code := RunContext(context.Background(), []string{"watch", "--source", "coderabbit", "--pr", "123", "--until-clean", "--max-rounds", "6", "--no-input"}, &stdout, &stderr) if code != 0 { t.Fatalf("expected clean Stop Request exit 0, got %d", code) @@ -4819,7 +4954,9 @@ func TestRunWatchStopRequestBeforeAgentMarksStopped(t *testing.T) { if strings.Contains(stderr.String(), "Fetched Round") || strings.Contains(stderr.String(), "fake agent output") { t.Fatalf("Stop Request before Agent must not fetch or run Agent, got %q", stderr.String()) } + runID := reviewRunIDFromStderr(t, stderr.String()) assertRunCount(t, store.DatabasePath(homeDir), 1) + assertStopRequested(t, homeDir, runID) assertNoActiveRun(t, homeDir, "owner/project", "feature/review") } @@ -6634,6 +6771,71 @@ func TestBranchIntegrityPreflightIntegratesFastForwardRunBranchAndJournals(t *te } } +// seedOutdatedV9RunDatabase downgrades a freshly created Run Database to +// schema v9 by reversing the v9→v10 migration, matching what a historical +// binary left behind. +func seedOutdatedV9RunDatabase(t *testing.T, homeDir string) { + t.Helper() + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("create Run Database: %v", err) + } + if err := runStore.Close(); err != nil { + t.Fatalf("close Run Database: %v", err) + } + db, err := sql.Open("sqlite", "file:"+store.DatabasePath(homeDir)+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)") + if err != nil { + t.Fatalf("open Run Database for downgrade: %v", err) + } + defer func() { + if err := db.Close(); err != nil { + t.Fatalf("close downgraded Run Database: %v", err) + } + }() + for _, statement := range []string{ + `ALTER TABLE runs DROP COLUMN owner_identity`, + `PRAGMA user_version = 9`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("downgrade Run Database to v9: %v", err) + } + } +} + +func TestBranchIntegrityPreflightMigratesOutdatedRunDatabase(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + withSuccessfulPreflight(t, repoDir) + withBranchIntegrity(t, nil, nil) + seedOutdatedV9RunDatabase(t, homeDir) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"fetch", "--source", "coderabbit", "--pr", "123", "--no-input"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("expected fetch to migrate the outdated Run Database and proceed, got %d stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Fetch complete") { + t.Fatalf("expected fetch success, got %q", stdout.String()) + } + reader, err := store.OpenReader(context.Background(), homeDir) + if err != nil { + t.Fatalf("expected migrated Run Database to open read-only, got %v", err) + } + defer func() { + if err := reader.Close(); err != nil { + t.Fatalf("close reader after migration: %v", err) + } + }() + version, err := reader.MigrationVersion(context.Background()) + if err != nil { + t.Fatalf("read migrated schema version: %v", err) + } + if version != 10 { + t.Fatalf("expected schema version 10 after preflight migration, got %d", version) + } +} + func TestBranchIntegrityPreflightRejectsActiveRunForReviewCommands(t *testing.T) { for _, command := range []string{"fetch", "resolve", "watch"} { t.Run(command, func(t *testing.T) { @@ -7006,18 +7208,333 @@ func TestRunStopBySpecRecordsStopRequestForMatchingActiveRun(t *testing.T) { } } -func TestRunStopForceCancelsImplementRunAndReleasesLocks(t *testing.T) { +func TestRunForceStopOwnerExitPrecedesCompletionAndLockRelease(t *testing.T) { homeDir, repoDir := withCLIWorkspace(t) active, request := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") - var calls []struct { - runtime agent.RuntimeSpec - session agent.SessionRef + proved := false + withOwnerProcessController(t, ownerProcessControllerFunc(func(ctx context.Context, pid int, recordedIdentity string) error { + if active.OwnerPID == nil || pid != *active.OwnerPID { + t.Fatalf("owner process PID = %d, want recorded PID %v", pid, active.OwnerPID) + } + if recordedIdentity != active.OwnerIdentity { + t.Fatalf("owner identity = %q, want recorded identity %q", recordedIdentity, active.OwnerIdentity) + } + runStore, err := store.Open(ctx, homeDir) + if err != nil { + t.Fatalf("open store during owner exit proof: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close store during owner exit proof: %v", err) + } + }() + current, found, err := runStore.Run(ctx, active.ID) + if err != nil || !found { + t.Fatalf("read Run during owner exit proof: found=%v err=%v", found, err) + } + if current.State != store.StateActive { + t.Fatalf("Run state during owner exit proof = %q, want Active", current.State) + } + if _, err := runStore.CreateRun(ctx, request); err == nil { + t.Fatal("Active Run lock was released before owner exit proof") + } + proved = true + return nil + })) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("force stop exit = %d, want %d; stderr=%q", code, exitOK, stderr.String()) } - withStopAgentSessionCanceler(t, func(_ context.Context, runtime agent.RuntimeSpec, session agent.SessionRef) error { - calls = append(calls, struct { - runtime agent.RuntimeSpec - session agent.SessionRef - }{runtime: runtime, session: session}) + if !proved { + t.Fatal("owner exit was not proven") + } + if !strings.Contains(stdout.String(), "Roundfix Run force-stopped") { + t.Fatalf("expected force stop success report, got %q", stdout.String()) + } + assertRunState(t, homeDir, active.ID, store.StateStopped) +} + +func TestRunForceStopOwnerPermissionAndDeadlineFailuresRetainActiveLock(t *testing.T) { + tests := []struct { + name string + step string + err error + }{ + { + name: "permission", + step: "send graceful termination", + err: errors.New("operation not permitted"), + }, + { + name: "deadline", + step: "prove exit after force kill", + err: context.DeadlineExceeded, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + active, request := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + withOwnerProcessController(t, ownerProcessControllerFunc(func(context.Context, int, string) error { + return store.OwnerProcessControlError{PID: *active.OwnerPID, Step: tt.step, Err: tt.err} + })) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitRunFailed { + t.Fatalf("force stop exit = %d, want %d; stderr=%q", code, exitRunFailed, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("failed force stop printed success output %q", stdout.String()) + } + for _, want := range []string{ + active.ID, + strconv.Itoa(*active.OwnerPID), + tt.step, + "remains Active", + "Active Run lock retained", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("force stop diagnostic missing %q: %q", want, stderr.String()) + } + } + assertRunState(t, homeDir, active.ID, store.StateActive) + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open store after failed force stop: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close store after failed force stop: %v", err) + } + }() + if _, err := runStore.CreateRun(context.Background(), request); err == nil { + t.Fatal("failed force stop released the Active Run lock") + } + }) + } +} + +func TestRunForceStopOwnerProofFailurePreservesAgentSessions(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + active, request := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: "codex", Model: "task-model", + Status: store.AgentSelectionStatusActive, + }) + cancelCalls := 0 + withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + cancelCalls++ + return nil + }) + closeCalls := 0 + withStopAgentSessionCloser(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + closeCalls++ + return nil + }) + terminateCalls := 0 + withOwnerProcessController(t, ownerProcessControllerStub{ + prove: func(context.Context, int, string) error { + return store.OwnerProcessControlError{ + PID: *active.OwnerPID, + Step: "prove owner process identity", + Err: store.ErrOwnerProcessIdentityUnproven, + } + }, + terminate: func(context.Context, int, string) error { + terminateCalls++ + return nil + }, + }) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext(context.Background(), []string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitRunFailed { + t.Fatalf("Force Stop failure exit = %d, want %d; stderr=%q", code, exitRunFailed, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("Force Stop failure printed success output %q", stdout.String()) + } + if cancelCalls != 0 || closeCalls != 0 { + t.Fatalf("failed owner proof mutated Agent Sessions: cancel=%d close=%d", cancelCalls, closeCalls) + } + if terminateCalls != 0 { + t.Fatalf("failed owner proof still terminated the owner: %d calls", terminateCalls) + } + if strings.Contains(stderr.String(), "Secondary cleanup warning:") { + t.Fatalf("failed owner proof must report no cleanup warnings, got %q", stderr.String()) + } + for _, want := range []string{ + active.ID, + strconv.Itoa(*active.OwnerPID), + "prove owner process identity", + "remains Active", + "Active Run lock retained", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("Force Stop diagnostic missing %q: %q", want, stderr.String()) + } + } + assertRunState(t, homeDir, active.ID, store.StateActive) + + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open store after failed Force Stop: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close store after failed Force Stop: %v", err) + } + }() + activeScopes, err := runStore.ActiveAgentSelectionScopes(context.Background(), active.ID) + if err != nil { + t.Fatalf("read active Agent Selection scopes: %v", err) + } + if len(activeScopes) != 1 || activeScopes[0].Status != store.AgentSelectionStatusActive { + t.Fatalf("Agent Selection lifecycle changed after owner failure: %+v", activeScopes) + } + if _, err := runStore.CreateRun(context.Background(), request); err == nil { + t.Fatal("failed owner proof released the Active Run lock") + } +} + +func TestRunForceStopPrimaryFailurePrecedesSecondaryCleanupWarnings(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + active, _ := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: "codex", Model: "task-model", + Status: store.AgentSelectionStatusActive, + }) + withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + return errors.New("cancel denied") + }) + withStopAgentSessionCloser(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + return errors.New("close denied") + }) + // The owner proof succeeds, so Force Stop reaches session cleanup; the + // termination that follows fails and must still surface the cleanup + // warnings it accumulated, behind the primary failure. + withOwnerProcessController(t, ownerProcessControllerFunc(func(context.Context, int, string) error { + return store.OwnerProcessControlError{ + PID: *active.OwnerPID, + Step: "prove owner exit", + Err: context.DeadlineExceeded, + } + })) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext(context.Background(), []string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitRunFailed { + t.Fatalf("Force Stop failure exit = %d, want %d; stderr=%q", code, exitRunFailed, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("Force Stop failure printed success output %q", stdout.String()) + } + primaryIndex := strings.Index(stderr.String(), "Stop failed") + secondaryIndex := strings.Index(stderr.String(), "Secondary cleanup warning:") + if primaryIndex < 0 || secondaryIndex < 0 || primaryIndex >= secondaryIndex { + t.Fatalf("primary failure must precede labeled secondary cleanup warnings, got %q", stderr.String()) + } + assertRunState(t, homeDir, active.ID, store.StateActive) + + events := runEvents(t, homeDir, active.ID) + primaryCursor := int64(0) + secondaryCursor := int64(0) + for _, entry := range events { + switch { + case strings.Contains(entry.Event.Summary, "Force Stop primary failure"): + primaryCursor = entry.Cursor + case strings.Contains(entry.Event.Summary, "Secondary cleanup warning"): + if secondaryCursor == 0 { + secondaryCursor = entry.Cursor + } + } + } + if primaryCursor == 0 || secondaryCursor == 0 || primaryCursor >= secondaryCursor { + t.Fatalf("journaled primary failure must precede secondary cleanup warnings, got %+v", events) + } +} + +// TestRunForceStopOwnerPIDReuseFailsClosed lives in orphan_unix_test.go: it +// proves the real identity comparison against a live scratch process whose +// stored owner identity token differs from its genuine one. + +func TestRunForceStopStoppedRunIsIdempotentWithoutOwnerOrSessionActions(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + active, _ := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: "codex", Model: "task-model", + Status: store.AgentSelectionStatusActive, + }) + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open store to complete Run: %v", err) + } + if _, err := runStore.CompleteRun(context.Background(), active.ID, store.StateStopped); err != nil { + t.Fatalf("complete Run as Stopped: %v", err) + } + if err := runStore.Close(); err != nil { + t.Fatalf("close store after completion: %v", err) + } + cancelCalls := 0 + closeCalls := 0 + ownerCalls := 0 + withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + cancelCalls++ + return nil + }) + withStopAgentSessionCloser(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + closeCalls++ + return nil + }) + withOwnerProcessController(t, ownerProcessControllerFunc(func(context.Context, int, string) error { + ownerCalls++ + return nil + })) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("idempotent force stop exit = %d, want %d; stderr=%q", code, exitOK, stderr.String()) + } + if cancelCalls != 0 || closeCalls != 0 || ownerCalls != 0 { + t.Fatalf("idempotent force stop actions: cancel=%d close=%d owner=%d", cancelCalls, closeCalls, ownerCalls) + } + if !strings.Contains(stdout.String(), "Roundfix Run force-stopped") { + t.Fatalf("expected idempotent force stop report, got %q", stdout.String()) + } + assertRunState(t, homeDir, active.ID, store.StateStopped) +} + +func TestRunStopForceAgentSessionCleanupSkipsRunWithoutActiveLifecycle(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + active, request := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + cancelCalls := 0 + closeCalls := 0 + withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + cancelCalls++ + return nil + }) + withStopAgentSessionCloser(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + closeCalls++ return nil }) var stdout bytes.Buffer @@ -7028,14 +7545,11 @@ func TestRunStopForceCancelsImplementRunAndReleasesLocks(t *testing.T) { if code != 0 { t.Fatalf("expected force stop exit 0, got %d stderr=%q", code, stderr.String()) } - if want := "roundfix: closed session roundfix-" + active.ID; !strings.Contains(stderr.String(), want) { - t.Fatalf("expected force close report %q, got %q", want, stderr.String()) + if strings.Contains(stderr.String(), "Agent Session") || strings.Contains(stderr.String(), "session roundfix-") { + t.Fatalf("expected no Agent Session diagnostics without active lifecycle evidence, got %q", stderr.String()) } - if len(calls) != 1 { - t.Fatalf("expected one Agent Session cancel, got %#v", calls) - } - if calls[0].runtime.ID != "codex" || calls[0].session.Name != "roundfix-"+active.ID || calls[0].session.WorkDir != repoDir { - t.Fatalf("unexpected cancel call: %#v", calls[0]) + if cancelCalls != 0 || closeCalls != 0 { + t.Fatalf("expected zero Agent Session calls, got cancel=%d close=%d", cancelCalls, closeCalls) } if !strings.Contains(stdout.String(), "Roundfix Run force-stopped") || !strings.Contains(stdout.String(), active.ID) { t.Fatalf("expected force stop report with Run ID, got %q", stdout.String()) @@ -7060,7 +7574,7 @@ func TestRunStopForceCancelsImplementRunAndReleasesLocks(t *testing.T) { } } -func TestRunStopForceCancelsListsAndClosesRunAndTaskSessions(t *testing.T) { +func TestRunStopForceRegisteredAgentSessionCleanupTargetsActiveScopesInOrder(t *testing.T) { homeDir, repoDir := newImplementWorkspace(t, []implementSeed{{ id: "task_01", title: "Stopped task", @@ -7068,22 +7582,43 @@ func TestRunStopForceCancelsListsAndClosesRunAndTaskSessions(t *testing.T) { }}) location := configureSettleWorktreeLocation(t, repoDir, filepath.Join(homeDir, "worktrees")) active, _, taskRef := createImplementRunWorktreeFixture(t, homeDir, repoDir, location, implementTestSlug, "task_01", "") + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeReview, ScopeID: "batch-002", + Category: "review", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: "claude", Model: "review-model", + Status: store.AgentSelectionStatusActive, + }) + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeQA, ScopeID: "qa", + Category: "qa", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRoleFallback, FallbackIndex: 1, + Runtime: "opencode", Model: "qa-model", Status: store.AgentSelectionStatusActive, + }) + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: "codex", Model: "task-model", + Status: store.AgentSelectionStatusActive, + }) calls := []string{} withStopAgentSessionCanceler(t, func(_ context.Context, runtime agent.RuntimeSpec, session agent.SessionRef) error { calls = append(calls, "cancel "+runtime.ID+" "+session.Name+" "+session.WorkDir) return nil }) - withRoundfixSessionLister(t, func(_ context.Context, runtime agent.RuntimeSpec, workDir string) ([]agent.RoundfixSession, error) { - calls = append(calls, "list "+runtime.ID+" "+workDir) - return []agent.RoundfixSession{ - {Name: "roundfix-" + active.ID + "-task_01", RunID: active.ID, TaskID: "task_01"}, - {Name: "roundfix-other-run-task_01", RunID: "other-run", TaskID: "task_01"}, - }, nil - }) withStopAgentSessionCloser(t, func(_ context.Context, runtime agent.RuntimeSpec, session agent.SessionRef) error { calls = append(calls, "close "+runtime.ID+" "+session.Name+" "+session.WorkDir) return nil }) + withOwnerProcessController(t, ownerProcessControllerStub{ + prove: func(_ context.Context, pid int, _ string) error { + calls = append(calls, fmt.Sprintf("prove %d", pid)) + return nil + }, + terminate: func(_ context.Context, pid int, _ string) error { + calls = append(calls, fmt.Sprintf("owner %d", pid)) + return nil + }, + }) var stdout bytes.Buffer var stderr bytes.Buffer @@ -7092,40 +7627,52 @@ func TestRunStopForceCancelsListsAndClosesRunAndTaskSessions(t *testing.T) { if code != exitOK { t.Fatalf("expected force stop exit 0, got %d stderr=%q", code, stderr.String()) } + // The read-only owner proof runs first, then every registered Agent + // Session is cancelled, and only then is the owner terminated. wantCalls := []string{ - "cancel codex roundfix-" + active.ID + " " + active.WorkDir, - "list codex " + active.WorkDir, - "close codex roundfix-" + active.ID + " " + active.WorkDir, + fmt.Sprintf("prove %d", *active.OwnerPID), + "cancel codex roundfix-" + active.ID + "-task_01 " + taskRef.Path, "close codex roundfix-" + active.ID + "-task_01 " + taskRef.Path, + "cancel opencode roundfix-" + active.ID + "-qa-fallback-01 " + active.WorkDir, + "close opencode roundfix-" + active.ID + "-qa-fallback-01 " + active.WorkDir, + "cancel claude roundfix-" + active.ID + "-review-002 " + repoDir, + "close claude roundfix-" + active.ID + "-review-002 " + repoDir, + fmt.Sprintf("owner %d", *active.OwnerPID), } if !reflect.DeepEqual(calls, wantCalls) { t.Fatalf("unexpected session invocations\nwant: %#v\ngot: %#v", wantCalls, calls) } - for _, want := range []string{ - "roundfix: closed session roundfix-" + active.ID, - "roundfix: closed session roundfix-" + active.ID + "-task_01", - } { - if !strings.Contains(stderr.String(), want) { - t.Fatalf("expected stderr to contain %q, got %q", want, stderr.String()) - } - } - if strings.Contains(stderr.String(), "other-run") { - t.Fatalf("foreign Run session must not be reported or closed, got %q", stderr.String()) + if strings.Contains(stderr.String(), "Agent Session") || strings.Contains(stderr.String(), "session roundfix-") { + t.Fatalf("expected successful registered cleanup to stay silent, got %q", stderr.String()) } if !strings.Contains(stdout.String(), "Roundfix Run force-stopped") { t.Fatalf("expected force stop report, got %q", stdout.String()) } + assertAgentSelectionScopeStatus(t, homeDir, active.ID, store.AgentSelectionScopeTask, "task_01", store.AgentSelectionStatusClosed) + assertAgentSelectionScopeStatus(t, homeDir, active.ID, store.AgentSelectionScopeQA, "qa", store.AgentSelectionStatusClosed) + assertAgentSelectionScopeStatus(t, homeDir, active.ID, store.AgentSelectionScopeReview, "batch-002", store.AgentSelectionStatusClosed) assertRunState(t, homeDir, active.ID, store.StateStopped) } -func TestRunStopForceCloseFailureStillCompletesStopped(t *testing.T) { +func TestRunStopForceRegisteredAgentSessionAbsenceIsIdempotent(t *testing.T) { homeDir, repoDir := withCLIWorkspace(t) - active, request := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + active, _ := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: "codex", Model: "task-model", + Status: store.AgentSelectionStatusActive, + }) + absent := fmt.Errorf("adapter response: %w", &agent.InfrastructureError{ExitCode: 4, Reason: "missing session"}) + cancelCalls := 0 + closeCalls := 0 withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { - return nil + cancelCalls++ + return absent }) withStopAgentSessionCloser(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { - return errors.New("close denied") + closeCalls++ + return absent }) var stdout bytes.Buffer var stderr bytes.Buffer @@ -7135,26 +7682,54 @@ func TestRunStopForceCloseFailureStillCompletesStopped(t *testing.T) { if code != exitOK { t.Fatalf("expected force stop exit 0, got %d stderr=%q", code, stderr.String()) } - if want := "roundfix: could not close session roundfix-" + active.ID + ": close denied"; !strings.Contains(stderr.String(), want) { - t.Fatalf("expected close failure note %q, got %q", want, stderr.String()) + if cancelCalls != 1 || closeCalls != 1 { + t.Fatalf("expected one cancel and close attempt, got cancel=%d close=%d", cancelCalls, closeCalls) + } + if strings.Contains(stderr.String(), "Agent Session") || strings.Contains(stderr.String(), "session roundfix-") { + t.Fatalf("expected registered Agent Session absence to stay silent, got %q", stderr.String()) } if !strings.Contains(stdout.String(), "Roundfix Run force-stopped") { t.Fatalf("expected force stop report, got %q", stdout.String()) } + assertAgentSelectionScopeStatus(t, homeDir, active.ID, store.AgentSelectionScopeTask, "task_01", store.AgentSelectionStatusClosed) assertRunState(t, homeDir, active.ID, store.StateStopped) +} - runStore, err := store.Open(context.Background(), homeDir) - if err != nil { - t.Fatalf("open store: %v", err) - } - _, err = runStore.CreateRun(context.Background(), request) - closeErr := runStore.Close() - if err != nil { - t.Fatalf("expected force stop to release Spec lock after close failure, got %v", err) +func TestRunStopForceAgentSessionCleanupFailureRemainsVisibleWithoutClosedLifecycle(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + active, _ := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: "codex", Model: "task-model", + Status: store.AgentSelectionStatusActive, + }) + withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + return errors.New("cancel denied") + }) + withStopAgentSessionCloser(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { + return errors.New("close denied") + }) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("expected force stop exit 0, got %d stderr=%q", code, stderr.String()) } - if closeErr != nil { - t.Fatalf("close store: %v", closeErr) + for _, want := range []string{ + "Agent Session cancel failed for task task_01", + "cancel denied", + "Agent Session close failed for task task_01", + "close denied", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("expected cleanup diagnostic %q, got %q", want, stderr.String()) + } } + assertAgentSelectionScopeStatus(t, homeDir, active.ID, store.AgentSelectionScopeTask, "task_01", store.AgentSelectionStatusActive) + assertRunState(t, homeDir, active.ID, store.StateStopped) } func TestRunStopForceReapsEmptyRunAndTaskWorktrees(t *testing.T) { @@ -7168,6 +7743,9 @@ func TestRunStopForceReapsEmptyRunAndTaskWorktrees(t *testing.T) { withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { return nil }) + withOwnerProcessController(t, ownerProcessControllerFunc(func(context.Context, int, string) error { + return nil + })) var stdout bytes.Buffer var stderr bytes.Buffer @@ -7210,6 +7788,9 @@ func TestRunStopForceKeepsRunWorktreeWithCommits(t *testing.T) { withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { return nil }) + withOwnerProcessController(t, ownerProcessControllerFunc(func(context.Context, int, string) error { + return nil + })) var stdout bytes.Buffer var stderr bytes.Buffer @@ -7229,35 +7810,38 @@ func TestRunStopForceKeepsRunWorktreeWithCommits(t *testing.T) { func TestRunStopForceReportsCancelFailuresButCompletes(t *testing.T) { tests := []struct { name string - agentID string + runtime string cancelErr error wantStderr []string wantCanceller bool + wantStatus store.AgentSelectionStatus }{ { name: "missing acpx", - agentID: "codex", + runtime: "codex", cancelErr: errors.New("acpx not found"), wantStderr: []string{"Agent Session cancel failed", "acpx not found"}, wantCanceller: true, + wantStatus: store.AgentSelectionStatusClosed, }, { name: "unknown runtime", - agentID: "gemini", - wantStderr: []string{"Agent Session cancel skipped", `unsupported Agent "gemini"`}, - wantCanceller: false, - }, - { - name: "no recorded runtime", - agentID: "", - wantStderr: []string{"Agent Session cancel skipped", "no Agent runtime recorded"}, + runtime: "gemini", + wantStderr: []string{"Agent Session cleanup skipped", `unsupported Agent "gemini"`}, wantCanceller: false, + wantStatus: store.AgentSelectionStatusActive, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { homeDir, repoDir := withCLIWorkspace(t) - active, request := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", tt.agentID) + active, request := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: tt.runtime, Model: "task-model", + Status: store.AgentSelectionStatusActive, + }) cancelCalls := 0 withStopAgentSessionCanceler(t, func(context.Context, agent.RuntimeSpec, agent.SessionRef) error { cancelCalls++ @@ -7282,9 +7866,19 @@ func TestRunStopForceReportsCancelFailuresButCompletes(t *testing.T) { if !tt.wantCanceller && cancelCalls != 0 { t.Fatalf("expected no cancel attempt, got %d", cancelCalls) } - if !strings.Contains(stdout.String(), "Roundfix Run force-stopped") { - t.Fatalf("expected force stop report, got %q", stdout.String()) + for _, want := range []string{ + "Roundfix Run force-stopped", + "proved the recorded owner process exited", + "released its Active Run locks", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("expected force stop report to contain %q, got %q", want, stdout.String()) + } } + if strings.Contains(stdout.String(), "immediately") { + t.Fatalf("force stop report must not promise immediate completion, got %q", stdout.String()) + } + assertAgentSelectionScopeStatus(t, homeDir, active.ID, store.AgentSelectionScopeTask, "task_01", tt.wantStatus) assertRunState(t, homeDir, active.ID, store.StateStopped) runStore, err := store.Open(context.Background(), homeDir) if err != nil { @@ -7305,6 +7899,12 @@ func TestRunStopForceReportsCancelFailuresButCompletes(t *testing.T) { func TestRunStopGracefulThenForceCompletesImmediately(t *testing.T) { homeDir, repoDir := withCLIWorkspace(t) active, request := createActiveImplementRunForStop(t, homeDir, repoDir, "0001-widget-flow", "codex") + recordAgentSelectionForStop(t, homeDir, store.AgentSelectionAttemptRequest{ + RunID: active.ID, ScopeKind: store.AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: store.AgentSelectionRolePreferred, Runtime: "codex", Model: "task-model", + Status: store.AgentSelectionStatusActive, + }) var gracefulStdout bytes.Buffer var gracefulStderr bytes.Buffer @@ -7330,8 +7930,8 @@ func TestRunStopGracefulThenForceCompletesImmediately(t *testing.T) { if forceCode != 0 { t.Fatalf("expected force stop exit 0, got %d stderr=%q", forceCode, forceStderr.String()) } - if want := "roundfix: closed session roundfix-" + active.ID; !strings.Contains(forceStderr.String(), want) { - t.Fatalf("expected force close report %q, got %q", want, forceStderr.String()) + if strings.Contains(forceStderr.String(), "Agent Session") || strings.Contains(forceStderr.String(), "session roundfix-") { + t.Fatalf("expected successful registered cleanup to stay silent, got %q", forceStderr.String()) } if cancelCalls != 1 { t.Fatalf("expected one cancel after graceful request, got %d", cancelCalls) @@ -7339,6 +7939,7 @@ func TestRunStopGracefulThenForceCompletesImmediately(t *testing.T) { if !strings.Contains(forceStdout.String(), "Roundfix Run force-stopped") { t.Fatalf("expected force stop report, got %q", forceStdout.String()) } + assertAgentSelectionScopeStatus(t, homeDir, active.ID, store.AgentSelectionScopeTask, "task_01", store.AgentSelectionStatusClosed) assertRunState(t, homeDir, active.ID, store.StateStopped) runStore, err := store.Open(context.Background(), homeDir) @@ -7403,7 +8004,7 @@ func TestRunStopBySpecReportsMissingActiveRun(t *testing.T) { } } -func TestRunStopHelpListsSpecSelector(t *testing.T) { +func TestRunStopHelpExplainsProofBeforeCompletion(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -7412,11 +8013,31 @@ func TestRunStopHelpListsSpecSelector(t *testing.T) { if code != 0 { t.Fatalf("expected stop help exit 0, got %d", code) } - for _, want := range []string{"roundfix stop --spec ", "--spec", "--force", "dead or runaway", "graceful"} { + for _, want := range []string{ + "roundfix stop --spec ", + "--spec", + "--force", + "graceful", + "owner exit is proven", + "Run remains Active", + "Active Run lock stays retained", + "roundfix runs list --state active", + "roundfix stop --force ", + "already Stopped", + "different terminal outcome", + } { if !strings.Contains(stdout.String(), want) { t.Fatalf("expected stop help to contain %q, got %q", want, stdout.String()) } } + for _, forbidden := range []string{ + "Immediately stop a dead or runaway Run and release its lock", + "completes the Run Stopped immediately", + } { + if strings.Contains(stdout.String(), forbidden) { + t.Fatalf("stop help must not promise %q, got %q", forbidden, stdout.String()) + } + } if stderr.Len() != 0 { t.Fatalf("expected no stderr, got %q", stderr.String()) } @@ -7859,10 +8480,12 @@ func seedRunsForList(t *testing.T, homeDir string, seeds []runListSeed) []store. switch { case seed.state == "" || seed.state == store.StateActive: case store.IsTerminalState(seed.state): - run, err = runStore.CompleteRun(ctx, run.ID, seed.state) + completed, completeErr := runStore.CompleteRun(ctx, run.ID, seed.state) + err = completeErr if err != nil { t.Fatalf("complete listed Run as %s: %v", seed.state, err) } + run = completed.Run default: if err := runStore.UpdateRunState(ctx, run.ID, seed.state); err != nil { t.Fatalf("update listed Run state to %s: %v", seed.state, err) @@ -8387,6 +9010,49 @@ func withStopAgentSessionCloser(t *testing.T, closeSession func(context.Context, }) } +// ownerProcessControllerFunc drives the termination phase only; its owner +// proof always succeeds. Tests that need a failing or observable proof use +// ownerProcessControllerStub. +type ownerProcessControllerFunc func(context.Context, int, string) error + +func (controller ownerProcessControllerFunc) ProveOwner(context.Context, int, string) error { + return nil +} + +func (controller ownerProcessControllerFunc) TerminateAndWait(ctx context.Context, pid int, recordedIdentity string) error { + return controller(ctx, pid, recordedIdentity) +} + +// ownerProcessControllerStub drives the owner proof and the termination phase +// independently so tests can assert their order and their failure isolation. +type ownerProcessControllerStub struct { + prove func(context.Context, int, string) error + terminate func(context.Context, int, string) error +} + +func (controller ownerProcessControllerStub) ProveOwner(ctx context.Context, pid int, recordedIdentity string) error { + if controller.prove == nil { + return nil + } + return controller.prove(ctx, pid, recordedIdentity) +} + +func (controller ownerProcessControllerStub) TerminateAndWait(ctx context.Context, pid int, recordedIdentity string) error { + if controller.terminate == nil { + return nil + } + return controller.terminate(ctx, pid, recordedIdentity) +} + +func withOwnerProcessController(t *testing.T, controller OwnerProcessController) { + t.Helper() + old := ownerProcesses + ownerProcesses = controller + t.Cleanup(func() { + ownerProcesses = old + }) +} + func fakeACPXVersionCommand(t *testing.T, version string) string { t.Helper() script := filepath.Join(t.TempDir(), "acpx") @@ -8619,13 +9285,17 @@ func assertNoRunDatabase(t *testing.T, homeDir string) { func createActiveImplementRunForStop(t *testing.T, homeDir string, repoDir string, specSlug string, agentID string) (store.Run, store.CreateRunRequest) { t.Helper() + ownerPID := os.Getpid() + ownerIdentity := "cli-test-owner-identity" request := store.CreateRunRequest{ - Kind: store.KindImplement, - GitRoot: repoDir, - LocalBranch: "ma/implement-spec", - HeadSHA: "abc123", - SpecSlug: specSlug, - Agent: agentID, + Kind: store.KindImplement, + GitRoot: repoDir, + LocalBranch: "ma/implement-spec", + HeadSHA: "abc123", + SpecSlug: specSlug, + Agent: agentID, + OwnerPID: ownerPID, + OwnerIdentity: ownerIdentity, } runStore, err := store.Open(context.Background(), homeDir) if err != nil { @@ -8640,9 +9310,64 @@ func createActiveImplementRunForStop(t *testing.T, homeDir string, repoDir strin if err != nil { t.Fatalf("create active implement run: %v", err) } + withOwnerProcessController(t, ownerProcessControllerFunc(func(_ context.Context, pid int, recordedIdentity string) error { + if pid != ownerPID { + t.Fatalf("owner process PID = %d, want %d", pid, ownerPID) + } + if recordedIdentity != ownerIdentity { + t.Fatalf("owner identity = %q, want recorded identity %q", recordedIdentity, ownerIdentity) + } + return nil + })) return active, request } +func recordAgentSelectionForStop(t *testing.T, homeDir string, req store.AgentSelectionAttemptRequest) { + t.Helper() + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open store to record Agent Selection: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close store after recording Agent Selection: %v", err) + } + }() + if _, err := runStore.AppendAgentSelectionAttempt(context.Background(), req); err != nil { + t.Fatalf("record Agent Selection for %s %s: %v", req.ScopeKind, req.ScopeID, err) + } +} + +func assertAgentSelectionScopeStatus( + t *testing.T, + homeDir string, + runID string, + scopeKind store.AgentSelectionScopeKind, + scopeID string, + want store.AgentSelectionStatus, +) { + t.Helper() + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open store to inspect Agent Selection: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close store after inspecting Agent Selection: %v", err) + } + }() + attempts, err := runStore.AgentSelectionAttemptsForScope(context.Background(), runID, scopeKind, scopeID) + if err != nil { + t.Fatalf("read Agent Selection for %s %s: %v", scopeKind, scopeID, err) + } + if len(attempts) == 0 { + t.Fatalf("expected Agent Selection lifecycle for %s %s", scopeKind, scopeID) + } + if got := attempts[len(attempts)-1].Status; got != want { + t.Fatalf("Agent Selection lifecycle for %s %s: got %q, want %q", scopeKind, scopeID, got, want) + } +} + func createActiveImplementRunForStopInGitRoot(t *testing.T, homeDir string, gitRoot string, specSlug string) store.Run { t.Helper() runStore, err := store.Open(context.Background(), homeDir) @@ -9407,6 +10132,11 @@ func journaledRunEvents(t *testing.T, homeDir string, stderr string) (string, [] if runID == "" { t.Fatalf("expected Run id in stderr, got %q", stderr) } + return runID, runEvents(t, homeDir, runID) +} + +func runEvents(t *testing.T, homeDir string, runID string) []store.JournalEvent { + t.Helper() reader, err := store.OpenReader(context.Background(), homeDir) if err != nil { t.Fatalf("open Run Database reader: %v", err) @@ -9418,7 +10148,7 @@ func journaledRunEvents(t *testing.T, homeDir string, stderr string) (string, [] if err != nil { t.Fatalf("list journaled Run Events: %v", err) } - return runID, events + return events } func TestAgentConsoleDisplaySinkKeepsWriterBytesByDefault(t *testing.T) { @@ -10495,7 +11225,7 @@ func createTerminalAttachSpecRun(t *testing.T, homeDir string, repoDir string, w if err != nil { t.Fatalf("complete implement run: %v", err) } - return completed + return completed.Run } func appendAttachConcurrencyEvent(t *testing.T, homeDir string, runID string, concurrency int) { @@ -10828,7 +11558,7 @@ func completeEventsRun(t *testing.T, homeDir string, runID string, state string) if err != nil { t.Fatalf("complete events Run: %v", err) } - return run + return run.Run } func appendEvents(t *testing.T, homeDir string, runID string, events ...runevent.RunEvent) { diff --git a/internal/cli/implement.go b/internal/cli/implement.go index 7518948d..9a5473fb 100644 --- a/internal/cli/implement.go +++ b/internal/cli/implement.go @@ -208,6 +208,7 @@ func runImplementCommand(ctx context.Context, args []string, stdout, stderr io.W Model: runtime.Model, ReasoningEffort: runtime.ReasoningEffort, OwnerPID: os.Getpid(), + OwnerIdentity: currentOwnerIdentity(ctx), }) }) if err != nil { @@ -352,8 +353,7 @@ func runImplementCommand(ctx context.Context, args []string, stdout, stderr io.W return exitRunFailed } closeAgentSession(ctx, collaborators.runner, runtime, sessionForClose, completed.ID, runStore) - publishRunOutcome(ctx, runStore, completed.ID, completed.State, cycleResult.Failed+cycleResult.Skipped, stderr) - notifyTerminalOutcome(ctx, runStore, outcomeNotifier, stderr, completed) + publishTerminalCompletion(ctx, runStore, outcomeNotifier, stderr, completed, cycleResult.Failed+cycleResult.Skipped) // The cockpit stays on screen, read-only, until the user closes it. ui.Wait() ui.Close() diff --git a/internal/cli/orphan_unix_test.go b/internal/cli/orphan_unix_test.go index 54545cb3..a5b81cf2 100644 --- a/internal/cli/orphan_unix_test.go +++ b/internal/cli/orphan_unix_test.go @@ -3,20 +3,292 @@ package cli import ( + "bufio" "bytes" "context" "encoding/json" "fmt" "os" "os/exec" + "os/signal" "path/filepath" + "strconv" "strings" + "sync" + "syscall" "testing" + "time" "roundfix/internal/spec" "roundfix/internal/store" ) +func TestRunForceStopOwnerProcessIntegrationProvesExitBeforeStoreCompletion(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + pid, ownerWait := startCLIForceStopOwnerProcess(t) + ownerIdentity, err := store.OwnerProcessIdentity(context.Background(), pid) + if err != nil { + t.Fatalf("read genuine owner process identity: %v", err) + } + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open Run Database: %v", err) + } + active, err := runStore.CreateRun(context.Background(), store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: repoDir, + LocalBranch: "ma/force-stop-owner", + HeadSHA: "abc123", + SpecSlug: "0001-widget-flow", + Agent: "codex", + OwnerPID: pid, + OwnerIdentity: ownerIdentity, + }) + if err != nil { + t.Fatalf("create active Run: %v", err) + } + if err := runStore.Close(); err != nil { + t.Fatalf("close Run Database: %v", err) + } + type observation struct { + ownerAlive bool + err error + } + observed := make(chan observation, 1) + monitorCtx, cancelMonitor := context.WithTimeout(t.Context(), 5*time.Second) + defer cancelMonitor() + go func() { + reader, err := store.OpenReader(monitorCtx, homeDir) + if err != nil { + observed <- observation{err: err} + return + } + defer func() { + _ = reader.Close() + }() + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + current, found, err := reader.Run(monitorCtx, active.ID) + if err != nil { + observed <- observation{err: err} + return + } + if !found { + observed <- observation{err: fmt.Errorf("Run %s disappeared", active.ID)} + return + } + if current.State == store.StateStopped { + observed <- observation{ownerAlive: store.ProcessAlive(pid)} + return + } + select { + case <-monitorCtx.Done(): + observed <- observation{err: monitorCtx.Err()} + return + case <-ticker.C: + } + } + }() + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("force stop exit = %d, want %d; stderr=%q", code, exitOK, stderr.String()) + } + terminal := <-observed + if terminal.err != nil { + t.Fatalf("observe terminal Run: %v", terminal.err) + } + if terminal.ownerAlive { + t.Fatalf("Run %s reached Stopped while owner process %d was alive", active.ID, pid) + } + select { + case err := <-ownerWait: + if err != nil { + t.Fatalf("owner process exit: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("owner process %d did not exit", pid) + } + if store.ProcessAlive(pid) { + t.Fatalf("owner process %d remained alive after force stop", pid) + } + assertRunState(t, homeDir, active.ID, store.StateStopped) +} + +// TestRunForceStopOwnerPIDReuseFailsClosed exercises the real identity +// comparison: the Run records the PID of a live scratch process together +// with the identity token of a different (exited) owner, so Force Stop must +// refuse before sending any signal, exactly as it must for a reused PID. +func TestRunForceStopOwnerPIDReuseFailsClosed(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + pid, _ := startCLIForceStopOwnerProcess(t) + request := store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: repoDir, + LocalBranch: "ma/force-stop-reused-pid", + HeadSHA: "abc123", + SpecSlug: "0001-widget-flow", + Agent: "codex", + OwnerPID: pid, + OwnerIdentity: "identity-token-of-exited-owner-process", + } + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open Run Database: %v", err) + } + active, err := runStore.CreateRun(context.Background(), request) + if err != nil { + t.Fatalf("create active Run: %v", err) + } + if err := runStore.Close(); err != nil { + t.Fatalf("close Run Database: %v", err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitRunFailed { + t.Fatalf("force stop exit = %d, want %d; stderr=%q", code, exitRunFailed, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("failed force stop printed success output %q", stdout.String()) + } + for _, want := range []string{ + active.ID, + strconv.Itoa(pid), + "prove owner process identity", + "remains Active", + "Active Run lock retained", + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("force stop diagnostic missing %q: %q", want, stderr.String()) + } + } + if !store.ProcessAlive(pid) { + t.Fatalf("refusal must not signal the live process holding reused PID %d", pid) + } + assertRunState(t, homeDir, active.ID, store.StateActive) + runStore, err = store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open store after reused PID refusal: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close store after reused PID refusal: %v", err) + } + }() + if _, err := runStore.CreateRun(context.Background(), request); err == nil { + t.Fatal("reused PID refusal released the Active Run lock") + } +} + +// TestRunForceStopLegacyRunWithoutOwnerIdentityStillStopsOwner covers Run +// rows created before owner identity recording existed: an absent stored +// token keeps the legacy PID-only proof instead of bricking the manual +// escape hatch. +func TestRunForceStopLegacyRunWithoutOwnerIdentityStillStopsOwner(t *testing.T) { + homeDir, repoDir := withCLIWorkspace(t) + pid, ownerWait := startCLIForceStopOwnerProcess(t) + runStore, err := store.Open(context.Background(), homeDir) + if err != nil { + t.Fatalf("open Run Database: %v", err) + } + active, err := runStore.CreateRun(context.Background(), store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: repoDir, + LocalBranch: "ma/force-stop-legacy-owner", + HeadSHA: "abc123", + SpecSlug: "0001-widget-flow", + Agent: "codex", + OwnerPID: pid, + }) + if err != nil { + t.Fatalf("create active Run: %v", err) + } + if err := runStore.Close(); err != nil { + t.Fatalf("close Run Database: %v", err) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"stop", "--force", active.ID}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("legacy force stop exit = %d, want %d; stderr=%q", code, exitOK, stderr.String()) + } + select { + case err := <-ownerWait: + if err != nil { + t.Fatalf("owner process exit: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("owner process %d did not exit", pid) + } + if store.ProcessAlive(pid) { + t.Fatalf("owner process %d remained alive after legacy force stop", pid) + } + assertRunState(t, homeDir, active.ID, store.StateStopped) +} + +func TestCLIForceStopOwnerProcessHelper(t *testing.T) { + if os.Getenv("ROUNDFIX_CLI_FORCE_STOP_OWNER_HELPER") == "" { + return + } + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM) + defer signal.Stop(signals) + fmt.Fprintln(os.Stdout, "ready") + <-signals +} + +func startCLIForceStopOwnerProcess(t *testing.T) (int, <-chan error) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestCLIForceStopOwnerProcessHelper$") + cmd.Env = append(os.Environ(), "ROUNDFIX_CLI_FORCE_STOP_OWNER_HELPER=1") + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("open owner process stdout: %v", err) + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatalf("start owner process: %v", err) + } + wait := make(chan error, 1) + var waitOnce sync.Once + startWait := func() { + waitOnce.Do(func() { + go func() { + wait <- cmd.Wait() + close(wait) + }() + }) + } + t.Cleanup(func() { + if store.ProcessAlive(cmd.Process.Pid) { + _ = cmd.Process.Kill() + } + startWait() + select { + case <-wait: + case <-time.After(2 * time.Second): + } + }) + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatalf("owner process did not become ready: %v", scanner.Err()) + } + if scanner.Text() != "ready" { + t.Fatalf("owner process readiness = %q, want ready", scanner.Text()) + } + startWait() + return cmd.Process.Pid, wait +} + func TestRunImplementReclaimsDeadOwnerActiveRun(t *testing.T) { homeDir, repoDir := newImplementWorkspace(t, []implementSeed{{id: "task_01"}}) runner := &implementFakeRunner{ diff --git a/internal/cli/settle.go b/internal/cli/settle.go index 5b22d2fc..1d140137 100644 --- a/internal/cli/settle.go +++ b/internal/cli/settle.go @@ -543,25 +543,12 @@ func integrateSettledRun(ctx context.Context, plan settlePlan) (string, error) { if err != nil { return "", err } - runStore, err := store.Open(ctx, plan.homeDir) - if err != nil { - return "", err - } - defer func() { - _ = runStore.Close() - }() if result.Mode == runworktree.ModePending { - if _, err := runStore.CompleteRun(ctx, plan.run.ID, store.StateIntegrationPending); err != nil { - return "", err - } return implementIntegrationCommand(ref), nil } if err := cleanupCleanRunWorktree(ctx, ref); err != nil { return "", err } - if _, err := runStore.CompleteRun(ctx, plan.run.ID, store.StateClean); err != nil { - return "", err - } return "", nil } diff --git a/internal/cli/settle_test.go b/internal/cli/settle_test.go index f811d91b..e7c2473d 100644 --- a/internal/cli/settle_test.go +++ b/internal/cli/settle_test.go @@ -215,9 +215,9 @@ func TestRunSettleRetargetsKeptRunWorktreeAndCleansUpAfterIntegration(t *testing if !strings.Contains(stdout.String(), "settled task_01 completed — ") { t.Fatalf("expected settled line, got %q", stdout.String()) } - completed := implementRunFromStore(t, homeDir, runID) - if completed.State != store.StateClean { - t.Fatalf("expected settled Run Clean, got %s", completed.State) + settled := implementRunFromStore(t, homeDir, runID) + if settled.State != store.StateUnresolved { + t.Fatalf("expected settle to preserve the Run's Unresolved outcome, got %s", settled.State) } assertRunWorktreeRemoved(t, run.WorkDir) assertRunBranchRemoved(t, repoDir, runworktree.BranchName(runID)) @@ -308,9 +308,9 @@ func TestRunSettleRetargetsKeptTaskWorktreeAndCleansUpAfterIntegration(t *testin if stdout.String() != expectedStdout { t.Fatalf("expected stdout:\n%q\ngot:\n%q", expectedStdout, stdout.String()) } - completed := implementRunFromStore(t, homeDir, run.ID) - if completed.State != store.StateClean { - t.Fatalf("expected settled Run Clean, got %s", completed.State) + settled := implementRunFromStore(t, homeDir, run.ID) + if settled.State != store.StateUnresolved { + t.Fatalf("expected settle to preserve the Run's Unresolved outcome, got %s", settled.State) } assertRunWorktreeRemoved(t, run.WorkDir) assertRunBranchRemoved(t, repoDir, runworktree.BranchName(run.ID)) @@ -825,6 +825,7 @@ func createImplementRunWorktreeFixture(t *testing.T, homeDir string, repoDir str HeadSHA: head, SpecSlug: specSlug, Agent: "codex", + OwnerPID: os.Getpid(), }) if err != nil { t.Fatalf("create implement Run: %v", err) @@ -850,10 +851,12 @@ func createImplementRunWorktreeFixture(t *testing.T, homeDir string, repoDir str } } if terminalState != "" { - run, err = runStore.CompleteRun(ctx, run.ID, terminalState) + completed, completeErr := runStore.CompleteRun(ctx, run.ID, terminalState) + err = completeErr if err != nil { t.Fatalf("complete Run %s: %v", terminalState, err) } + run = completed.Run } return run, runRef, taskRef } diff --git a/internal/store/agent_selection.go b/internal/store/agent_selection.go index a49391b3..19924453 100644 --- a/internal/store/agent_selection.go +++ b/internal/store/agent_selection.go @@ -201,6 +201,49 @@ ORDER BY scope_kind, scope_id, attempt`, runID) return scanAgentSelectionAttempts(rows, runID) } +// ActiveAgentSelectionScopes returns the latest persisted lifecycle for each +// Run scope only when that lifecycle is active. Scope kinds and IDs are +// ordered explicitly so Agent Session cleanup is deterministic. +func (store *Store) ActiveAgentSelectionScopes(ctx context.Context, runID string) ([]AgentSelectionAttempt, error) { + runID = strings.TrimSpace(runID) + if runID == "" { + return nil, errors.New("list active Agent Selection scopes: Run ID is required") + } + rows, err := store.db.QueryContext(ctx, ` +SELECT selection.id, selection.run_id, selection.scope_kind, selection.scope_id, + selection.category, selection.profile_source, selection.attempt, + selection.selection_role, selection.fallback_index, selection.runtime, + selection.model, selection.reasoning_effort, selection.status, + selection.reason_code, selection.reason, selection.created_at +FROM run_agent_selections AS selection +WHERE selection.run_id = ? + AND selection.status = ? + AND selection.attempt = ( + SELECT MAX(latest.attempt) + FROM run_agent_selections AS latest + WHERE latest.run_id = selection.run_id + AND latest.scope_kind = selection.scope_kind + AND latest.scope_id = selection.scope_id + ) +ORDER BY CASE selection.scope_kind + WHEN 'task' THEN 1 + WHEN 'qa' THEN 2 + WHEN 'review' THEN 3 + ELSE 4 + END, + selection.scope_id`, + runID, + string(AgentSelectionStatusActive), + ) + if err != nil { + return nil, fmt.Errorf("list active Agent Selection scopes for Run %q: %w", runID, err) + } + defer func() { + _ = rows.Close() + }() + return scanAgentSelectionAttempts(rows, runID) +} + func (store *Store) AgentSelectionAttemptsForScope(ctx context.Context, runID string, scopeKind AgentSelectionScopeKind, scopeID string) ([]AgentSelectionAttempt, error) { runID = strings.TrimSpace(runID) if runID == "" { diff --git a/internal/store/agent_selection_test.go b/internal/store/agent_selection_test.go index 7be184b0..8fd675ed 100644 --- a/internal/store/agent_selection_test.go +++ b/internal/store/agent_selection_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -22,8 +23,8 @@ func TestSchema9CreatesAgentSelectionTable(t *testing.T) { if err != nil { t.Fatalf("read migration version: %v", err) } - if version != 9 { - t.Fatalf("expected schema version 9, got %d", version) + if version != 10 { + t.Fatalf("expected schema version 10, got %d", version) } assertAgentSelectionTableExists(t, ctx, runStore) assertAgentSelectionSchemaPrivacy(t, ctx, runStore) @@ -41,8 +42,8 @@ func TestSchema8To9MigratesRunsEventsAndSelectionTable(t *testing.T) { if err != nil { t.Fatalf("read migration version: %v", err) } - if version != 9 { - t.Fatalf("expected schema version 9, got %d", version) + if version != 10 { + t.Fatalf("expected schema version 10, got %d", version) } run, ok, err := runStore.Run(ctx, "run_v8_active") if err != nil || !ok { @@ -290,6 +291,100 @@ func TestAgentSelectionAttemptLifecycleUpdatesSameAttempt(t *testing.T) { } } +func TestAgentSelectionActiveScopesReturnsLatestLifecycleInStableOrder(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + run, err := runStore.CreateRun(ctx, sampleImplementCreateRunRequest()) + if err != nil { + t.Fatalf("create Run: %v", err) + } + requests := []AgentSelectionAttemptRequest{ + { + RunID: run.ID, ScopeKind: AgentSelectionScopeReview, ScopeID: "batch-002", + Category: "review", ProfileSource: "project", Attempt: 1, + SelectionRole: AgentSelectionRolePreferred, Runtime: "opencode", Model: "review-model", + Status: AgentSelectionStatusActive, + }, + { + RunID: run.ID, ScopeKind: AgentSelectionScopeTask, ScopeID: "task_02", + Category: "frontend", ProfileSource: "project", Attempt: 1, + SelectionRole: AgentSelectionRolePreferred, Runtime: "claude", Model: "task-model", + Status: AgentSelectionStatusActive, + }, + { + RunID: run.ID, ScopeKind: AgentSelectionScopeQA, ScopeID: "qa", + Category: "qa", ProfileSource: "project", Attempt: 1, + SelectionRole: AgentSelectionRoleFallback, FallbackIndex: 1, + Runtime: "codex", Model: "qa-model", ReasoningEffort: "high", + Status: AgentSelectionStatusActive, + }, + { + RunID: run.ID, ScopeKind: AgentSelectionScopeTask, ScopeID: "task_01", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: AgentSelectionRolePreferred, Runtime: "codex", Model: "task-model", + Status: AgentSelectionStatusActive, + }, + { + RunID: run.ID, ScopeKind: AgentSelectionScopeTask, ScopeID: "task_closed", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: AgentSelectionRolePreferred, Runtime: "codex", Model: "closed-model", + Status: AgentSelectionStatusActive, + }, + { + RunID: run.ID, ScopeKind: AgentSelectionScopeTask, ScopeID: "task_closed", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: AgentSelectionRolePreferred, Runtime: "codex", Model: "closed-model", + Status: AgentSelectionStatusClosed, + }, + { + RunID: run.ID, ScopeKind: AgentSelectionScopeTask, ScopeID: "task_superseded", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: AgentSelectionRolePreferred, Runtime: "codex", Model: "old-model", + Status: AgentSelectionStatusActive, + }, + { + RunID: run.ID, ScopeKind: AgentSelectionScopeTask, ScopeID: "task_superseded", + Category: "backend", ProfileSource: "project", Attempt: 2, + SelectionRole: AgentSelectionRoleFallback, FallbackIndex: 1, + Runtime: "codex", Model: "replacement-model", + Status: AgentSelectionStatusFailed, ReasonCode: "runtime_unavailable", + }, + { + RunID: run.ID, ScopeKind: AgentSelectionScopeTask, ScopeID: "task_failed", + Category: "backend", ProfileSource: "project", Attempt: 1, + SelectionRole: AgentSelectionRolePreferred, Runtime: "codex", Model: "failed-model", + Status: AgentSelectionStatusFailed, ReasonCode: "runtime_unavailable", + }, + } + for _, req := range requests { + if _, err := runStore.AppendAgentSelectionAttempt(ctx, req); err != nil { + t.Fatalf("append %s %s attempt %d status %s: %v", req.ScopeKind, req.ScopeID, req.Attempt, req.Status, err) + } + } + + active, err := runStore.ActiveAgentSelectionScopes(ctx, run.ID) + if err != nil { + t.Fatalf("list active Agent Selection scopes: %v", err) + } + gotScopes := make([]string, 0, len(active)) + for _, attempt := range active { + gotScopes = append(gotScopes, string(attempt.ScopeKind)+":"+attempt.ScopeID) + } + wantScopes := []string{"task:task_01", "task:task_02", "qa:qa", "review:batch-002"} + if !reflect.DeepEqual(gotScopes, wantScopes) { + t.Fatalf("active Agent Selection scope order mismatch\nwant: %#v\ngot: %#v", wantScopes, gotScopes) + } + if active[2].SelectionRole != AgentSelectionRoleFallback || + active[2].FallbackIndex != 1 || + active[2].Runtime != "codex" || + active[2].Model != "qa-model" || + active[2].ReasoningEffort != "high" { + t.Fatalf("expected active QA fallback selection fields, got %#v", active[2]) + } +} + func TestAgentSelectionAttemptLifecycleRejectsImmutableFieldChanges(t *testing.T) { ctx := context.Background() runStore := openTestStore(t, ctx, t.TempDir()) diff --git a/internal/store/journal_test.go b/internal/store/journal_test.go index 61941bd2..534e6790 100644 --- a/internal/store/journal_test.go +++ b/internal/store/journal_test.go @@ -220,10 +220,12 @@ func TestPruneTerminalRunsDeletesOnlyEligibleJournalRows(t *testing.T) { case tt.terminalState != "": completedAt := tt.completedAt runStore.now = func() time.Time { return completedAt } - run, err = runStore.CompleteRun(ctx, run.ID, tt.terminalState) + completed, completeErr := runStore.CompleteRun(ctx, run.ID, tt.terminalState) + err = completeErr if err != nil { t.Fatalf("%s: complete Run: %v", tt.name, err) } + run = completed.Run case tt.nonTerminalState != "": if err := runStore.UpdateRunState(ctx, run.ID, tt.nonTerminalState); err != nil { t.Fatalf("%s: update Run state: %v", tt.name, err) diff --git a/internal/store/process.go b/internal/store/process.go new file mode 100644 index 00000000..49632ce0 --- /dev/null +++ b/internal/store/process.go @@ -0,0 +1,213 @@ +package store + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" +) + +const ( + ownerProcessGracePeriod = 2 * time.Second + ownerProcessStopWindow = 10 * time.Second + ownerProcessPollInterval = 25 * time.Millisecond +) + +var ( + ErrOwnerProcessUnsupported = errors.New("owner process control is unsupported") + ErrOwnerProcessIdentityUnproven = errors.New("owner process identity cannot be proven") + errOwnerProcessAlreadyAbsent = errors.New("owner process is already absent") + errGracefulSignalUnsupported = errors.New("graceful owner process termination is unsupported") +) + +type OwnerProcessControlError struct { + PID int + Step string + Err error +} + +func (err OwnerProcessControlError) Error() string { + return fmt.Sprintf("owner process %d failed step %q: %v", err.PID, err.Step, err.Err) +} + +func (err OwnerProcessControlError) Unwrap() error { + return err.Err +} + +type OwnerProcessControl struct { + gracePeriod time.Duration + stopWindow time.Duration + pollInterval time.Duration +} + +func NewOwnerProcessController() *OwnerProcessControl { + return newOwnerProcessController( + ownerProcessGracePeriod, + ownerProcessStopWindow, + ownerProcessPollInterval, + ) +} + +func newOwnerProcessController(gracePeriod, stopWindow, pollInterval time.Duration) *OwnerProcessControl { + return &OwnerProcessControl{ + gracePeriod: gracePeriod, + stopWindow: stopWindow, + pollInterval: pollInterval, + } +} + +// ProveOwner proves that pid still identifies the process the Run recorded as +// its owner, without sending any signal or touching any other state. Callers +// run it before any destructive Force Stop step so a Run whose owner cannot be +// proven is left exactly as it was found. Success includes a proven-absent +// owner: absence is its own proof of exit. +func (controller *OwnerProcessControl) ProveOwner(ctx context.Context, pid int, recordedIdentity string) error { + _, err := controller.proveOwner(ctx, pid, recordedIdentity) + return err +} + +// proveOwner is the single implementation of the ownership rule. It reports +// whether the owner is already absent, which every caller treats as success. +// +// When the Run recorded a start-time identity token, the live process must +// present the same token, otherwise a reused PID would let Force Stop +// terminate an unrelated process. An empty recorded token comes from a Run +// created before identity recording existed and keeps the legacy PID-only +// proof, mirroring the ADR-0044 precedent that PID-less legacy Runs degrade +// gracefully. +func (controller *OwnerProcessControl) proveOwner(ctx context.Context, pid int, recordedIdentity string) (bool, error) { + if pid <= 0 || pid == os.Getpid() { + return false, ownerProcessControlError(pid, "prove owner process identity", ErrOwnerProcessIdentityUnproven) + } + absent, err := processAbsent(pid) + if err != nil { + return false, ownerProcessControlError(pid, "prove owner process identity", err) + } + if absent { + return true, nil + } + if recorded := strings.TrimSpace(recordedIdentity); recorded != "" { + liveIdentity, identityErr := processStartIdentity(ctx, pid) + if identityErr != nil { + // The owner may have exited between the liveness check and the + // identity read; proven absence is the proof Force Stop needs. + if absent, absentErr := processAbsent(pid); absentErr == nil && absent { + return true, nil + } + return false, ownerProcessControlError(pid, "prove owner process identity", + fmt.Errorf("%w: read live owner identity: %v", ErrOwnerProcessIdentityUnproven, identityErr)) + } + if liveIdentity != recorded { + return false, ownerProcessControlError(pid, "prove owner process identity", + fmt.Errorf("%w: live process start identity does not match the recorded owner identity", ErrOwnerProcessIdentityUnproven)) + } + } + return false, nil +} + +// TerminateAndWait reuses the same ownership proof as ProveOwner before +// sending any signal, then terminates the owner and returns only once its exit +// is proven. Callers that already ran ProveOwner pay for a second read-only +// proof, which keeps this entry point safe on its own. +func (controller *OwnerProcessControl) TerminateAndWait(ctx context.Context, pid int, recordedIdentity string) error { + absent, err := controller.proveOwner(ctx, pid, recordedIdentity) + if err != nil { + return err + } + if absent { + return nil + } + + stopCtx, cancel := context.WithTimeout(ctx, controller.stopWindow) + defer cancel() + + gracefulErr := signalOwnerProcess(pid, false) + switch { + case errors.Is(gracefulErr, errOwnerProcessAlreadyAbsent): + return nil + case gracefulErr == nil: + absent, err = controller.waitForAbsence(stopCtx, pid, controller.gracePeriod) + if err != nil { + return ownerProcessControlError(pid, "prove exit after graceful termination", err) + } + if absent { + return nil + } + case errors.Is(gracefulErr, errGracefulSignalUnsupported): + default: + return ownerProcessControlError(pid, "send graceful termination", gracefulErr) + } + + if err := signalOwnerProcess(pid, true); err != nil { + if errors.Is(err, errOwnerProcessAlreadyAbsent) { + return nil + } + return ownerProcessControlError(pid, "send force kill", err) + } + absent, err = controller.waitForAbsence(stopCtx, pid, 0) + if err != nil { + return ownerProcessControlError(pid, "prove exit after force kill", err) + } + if !absent { + return ownerProcessControlError(pid, "prove exit after force kill", context.DeadlineExceeded) + } + return nil +} + +func (controller *OwnerProcessControl) waitForAbsence(ctx context.Context, pid int, window time.Duration) (bool, error) { + pollInterval := controller.pollInterval + if pollInterval <= 0 { + pollInterval = ownerProcessPollInterval + } + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + var timer *time.Timer + var windowElapsed <-chan time.Time + if window > 0 { + timer = time.NewTimer(window) + windowElapsed = timer.C + defer timer.Stop() + } + + for { + absent, err := processAbsent(pid) + if err != nil { + return false, err + } + if absent { + return true, nil + } + select { + case <-ctx.Done(): + return false, ctx.Err() + case <-windowElapsed: + return false, nil + case <-ticker.C: + } + } +} + +func ownerProcessControlError(pid int, step string, err error) error { + return OwnerProcessControlError{PID: pid, Step: step, Err: err} +} + +// ProcessAlive reports whether pid is alive or cannot be proven absent. +func ProcessAlive(pid int) bool { + if pid <= 0 { + return true + } + absent, err := processAbsent(pid) + return err != nil || !absent +} + +// OwnerProcessIdentity returns the opaque start-time identity token for pid. +// Callers compare tokens verbatim and must not parse them. +func OwnerProcessIdentity(ctx context.Context, pid int) (string, error) { + if pid <= 0 { + return "", fmt.Errorf("read owner process identity: pid %d is invalid", pid) + } + return processStartIdentity(ctx, pid) +} diff --git a/internal/store/process_other.go b/internal/store/process_other.go index db624109..fe264b3e 100644 --- a/internal/store/process_other.go +++ b/internal/store/process_other.go @@ -2,7 +2,16 @@ package store -// ProcessAlive reports alive on platforms where orphan recovery is unsupported. -func ProcessAlive(pid int) bool { - return true +import "context" + +func processAbsent(_ int) (bool, error) { + return false, ErrOwnerProcessUnsupported +} + +func signalOwnerProcess(_ int, _ bool) error { + return ErrOwnerProcessUnsupported +} + +func processStartIdentity(_ context.Context, _ int) (string, error) { + return "", ErrOwnerProcessUnsupported } diff --git a/internal/store/process_unix.go b/internal/store/process_unix.go index a1d2e51c..2f83482c 100644 --- a/internal/store/process_unix.go +++ b/internal/store/process_unix.go @@ -2,13 +2,52 @@ package store -import "syscall" +import ( + "context" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" + "syscall" +) -// ProcessAlive reports whether pid provably exists. Only ESRCH proves death. -func ProcessAlive(pid int) bool { - if pid <= 0 { - return true - } +func processAbsent(pid int) (bool, error) { err := syscall.Kill(pid, syscall.Signal(0)) - return err != syscall.ESRCH + if err == nil { + return false, nil + } + if errors.Is(err, syscall.ESRCH) { + return true, nil + } + return false, err +} + +func signalOwnerProcess(pid int, force bool) error { + signal := syscall.SIGTERM + if force { + signal = syscall.SIGKILL + } + err := syscall.Kill(pid, signal) + if errors.Is(err, syscall.ESRCH) { + return errOwnerProcessAlreadyAbsent + } + return err +} + +// processStartIdentity returns the process start time exactly as ps prints +// it. The verbatim string is the opaque identity token: two processes reusing +// one PID cannot share it, and equality is the only supported comparison. +func processStartIdentity(ctx context.Context, pid int) (string, error) { + command := exec.CommandContext(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "lstart=") + command.Env = append(command.Environ(), "TZ=UTC", "LC_ALL=C") + output, err := command.Output() + if err != nil { + return "", fmt.Errorf("read start time for process %d: %w", pid, err) + } + identity := strings.TrimSpace(string(output)) + if identity == "" { + return "", fmt.Errorf("read start time for process %d: ps reported no start time", pid) + } + return identity, nil } diff --git a/internal/store/process_unix_test.go b/internal/store/process_unix_test.go index 7fe02745..c5191cf0 100644 --- a/internal/store/process_unix_test.go +++ b/internal/store/process_unix_test.go @@ -3,9 +3,15 @@ package store import ( + "bufio" + "errors" + "fmt" "os" "os/exec" + "os/signal" + "syscall" "testing" + "time" ) func TestProcessAliveReportsCurrentProcessAlive(t *testing.T) { @@ -27,3 +33,273 @@ func TestProcessAliveReportsReapedChildDead(t *testing.T) { t.Fatalf("expected reaped child pid %d to report dead", pid) } } + +func TestOwnerProcessControllerGracefulExitProof(t *testing.T) { + pid, wait := startOwnerProcessHelper(t, "graceful") + controller := newOwnerProcessController(250*time.Millisecond, 2*time.Second, 5*time.Millisecond) + + if err := controller.TerminateAndWait(t.Context(), pid, ""); err != nil { + t.Fatalf("terminate owner process gracefully: %v", err) + } + + assertOwnerProcessExited(t, pid, wait) +} + +func TestOwnerProcessControllerForceKillExitProof(t *testing.T) { + pid, wait := startOwnerProcessHelper(t, "ignore") + controller := newOwnerProcessController(20*time.Millisecond, 2*time.Second, 5*time.Millisecond) + + if err := controller.TerminateAndWait(t.Context(), pid, ""); err != nil { + t.Fatalf("force-kill owner process: %v", err) + } + + assertOwnerProcessExited(t, pid, wait) +} + +func TestOwnerProcessControllerRejectsUnprovenCurrentProcess(t *testing.T) { + controller := newOwnerProcessController(20*time.Millisecond, 100*time.Millisecond, 5*time.Millisecond) + + err := controller.TerminateAndWait(t.Context(), os.Getpid(), "") + + if !errors.Is(err, ErrOwnerProcessIdentityUnproven) { + t.Fatalf("current-process error = %v, want ErrOwnerProcessIdentityUnproven", err) + } + var controlErr OwnerProcessControlError + if !errors.As(err, &controlErr) { + t.Fatalf("current-process error type = %T, want OwnerProcessControlError", err) + } + if controlErr.PID != os.Getpid() || controlErr.Step != "prove owner process identity" { + t.Fatalf("current-process diagnostic = %#v", controlErr) + } +} + +func TestOwnerProcessControllerAcceptsAlreadyAbsentProcess(t *testing.T) { + cmd := exec.Command("/bin/sh", "-c", "exit 0") + if err := cmd.Start(); err != nil { + t.Fatalf("start child process: %v", err) + } + pid := cmd.Process.Pid + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for child process: %v", err) + } + controller := newOwnerProcessController(20*time.Millisecond, 100*time.Millisecond, 5*time.Millisecond) + + if err := controller.TerminateAndWait(t.Context(), pid, ""); err != nil { + t.Fatalf("accept already absent owner process: %v", err) + } +} + +func TestOwnerProcessControllerRefusesMismatchedOwnerIdentity(t *testing.T) { + pid, _ := startOwnerProcessHelper(t, "graceful") + controller := newOwnerProcessController(20*time.Millisecond, 2*time.Second, 5*time.Millisecond) + + err := controller.TerminateAndWait(t.Context(), pid, "identity-token-of-exited-owner-process") + + if !errors.Is(err, ErrOwnerProcessIdentityUnproven) { + t.Fatalf("mismatched identity error = %v, want ErrOwnerProcessIdentityUnproven", err) + } + var controlErr OwnerProcessControlError + if !errors.As(err, &controlErr) { + t.Fatalf("mismatched identity error type = %T, want OwnerProcessControlError", err) + } + if controlErr.PID != pid || controlErr.Step != "prove owner process identity" { + t.Fatalf("mismatched identity diagnostic = %#v", controlErr) + } + if !ProcessAlive(pid) { + t.Fatalf("refusal must not signal process %d holding the reused PID", pid) + } +} + +func TestOwnerProcessControllerMatchingOwnerIdentityProceeds(t *testing.T) { + pid, wait := startOwnerProcessHelper(t, "graceful") + identity, err := OwnerProcessIdentity(t.Context(), pid) + if err != nil { + t.Fatalf("read owner process identity: %v", err) + } + controller := newOwnerProcessController(250*time.Millisecond, 2*time.Second, 5*time.Millisecond) + + if err := controller.TerminateAndWait(t.Context(), pid, identity); err != nil { + t.Fatalf("terminate owner process with matching identity: %v", err) + } + + assertOwnerProcessExited(t, pid, wait) +} + +func TestOwnerProcessControllerProveOwnerLeavesProvenOwnerRunning(t *testing.T) { + pid, _ := startOwnerProcessHelper(t, "graceful") + identity, err := OwnerProcessIdentity(t.Context(), pid) + if err != nil { + t.Fatalf("read owner process identity: %v", err) + } + controller := newOwnerProcessController(250*time.Millisecond, 2*time.Second, 5*time.Millisecond) + + if err := controller.ProveOwner(t.Context(), pid, identity); err != nil { + t.Fatalf("prove matching owner identity: %v", err) + } + + if !ProcessAlive(pid) { + t.Fatalf("owner proof must send no signal, but process %d exited", pid) + } +} + +func TestOwnerProcessControllerProveOwnerRefusesMismatchedIdentity(t *testing.T) { + pid, _ := startOwnerProcessHelper(t, "graceful") + controller := newOwnerProcessController(20*time.Millisecond, 2*time.Second, 5*time.Millisecond) + + err := controller.ProveOwner(t.Context(), pid, "identity-token-of-exited-owner-process") + + if !errors.Is(err, ErrOwnerProcessIdentityUnproven) { + t.Fatalf("mismatched identity proof error = %v, want ErrOwnerProcessIdentityUnproven", err) + } + var controlErr OwnerProcessControlError + if !errors.As(err, &controlErr) { + t.Fatalf("mismatched identity proof error type = %T, want OwnerProcessControlError", err) + } + if controlErr.PID != pid || controlErr.Step != "prove owner process identity" { + t.Fatalf("mismatched identity proof diagnostic = %#v", controlErr) + } + if !ProcessAlive(pid) { + t.Fatalf("refused proof must not signal process %d holding the reused PID", pid) + } +} + +func TestOwnerProcessControllerProveOwnerAcceptsAbsentOwner(t *testing.T) { + cmd := exec.Command("/bin/sh", "-c", "exit 0") + if err := cmd.Start(); err != nil { + t.Fatalf("start child process: %v", err) + } + pid := cmd.Process.Pid + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for child process: %v", err) + } + controller := newOwnerProcessController(20*time.Millisecond, 100*time.Millisecond, 5*time.Millisecond) + + if err := controller.ProveOwner(t.Context(), pid, "identity-token-of-exited-owner-process"); err != nil { + t.Fatalf("absence is its own proof, got: %v", err) + } +} + +func TestOwnerProcessControllerProveOwnerRejectsCurrentProcess(t *testing.T) { + controller := newOwnerProcessController(20*time.Millisecond, 100*time.Millisecond, 5*time.Millisecond) + + err := controller.ProveOwner(t.Context(), os.Getpid(), "") + + if !errors.Is(err, ErrOwnerProcessIdentityUnproven) { + t.Fatalf("current-process proof error = %v, want ErrOwnerProcessIdentityUnproven", err) + } +} + +func TestOwnerProcessIdentityIsStableForOneProcess(t *testing.T) { + first, err := OwnerProcessIdentity(t.Context(), os.Getpid()) + if err != nil { + t.Fatalf("read current process identity: %v", err) + } + second, err := OwnerProcessIdentity(t.Context(), os.Getpid()) + if err != nil { + t.Fatalf("re-read current process identity: %v", err) + } + if first != second { + t.Fatalf("identity token changed for one process: %q then %q", first, second) + } +} + +func TestOwnerProcessIdentityIgnoresCallerTimezone(t *testing.T) { + t.Setenv("TZ", "Pacific/Honolulu") + first, err := OwnerProcessIdentity(t.Context(), os.Getpid()) + if err != nil { + t.Fatalf("read current process identity in first timezone: %v", err) + } + t.Setenv("TZ", "Asia/Tokyo") + second, err := OwnerProcessIdentity(t.Context(), os.Getpid()) + if err != nil { + t.Fatalf("read current process identity in second timezone: %v", err) + } + if first != second { + t.Fatalf("identity token changed with caller timezone: %q then %q", first, second) + } +} + +func TestOwnerProcessIdentityFailsForAbsentProcess(t *testing.T) { + cmd := exec.Command("/bin/sh", "-c", "exit 0") + if err := cmd.Start(); err != nil { + t.Fatalf("start child process: %v", err) + } + pid := cmd.Process.Pid + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for child process: %v", err) + } + + if identity, err := OwnerProcessIdentity(t.Context(), pid); err == nil { + t.Fatalf("expected identity read failure for reaped pid %d, got %q", pid, identity) + } +} + +func TestOwnerProcessHelper(t *testing.T) { + mode := os.Getenv("ROUNDFIX_OWNER_PROCESS_HELPER") + if mode == "" { + return + } + switch mode { + case "graceful": + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM) + defer signal.Stop(signals) + fmt.Fprintln(os.Stdout, "ready") + <-signals + case "ignore": + signal.Ignore(syscall.SIGTERM) + defer signal.Reset(syscall.SIGTERM) + fmt.Fprintln(os.Stdout, "ready") + select {} + default: + t.Fatalf("unknown owner process helper mode %q", mode) + } +} + +func startOwnerProcessHelper(t *testing.T, mode string) (int, <-chan error) { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestOwnerProcessHelper$") + cmd.Env = append(os.Environ(), "ROUNDFIX_OWNER_PROCESS_HELPER="+mode) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("open owner process helper stdout: %v", err) + } + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatalf("start owner process helper: %v", err) + } + wait := make(chan error, 1) + go func() { + wait <- cmd.Wait() + close(wait) + }() + t.Cleanup(func() { + if ProcessAlive(cmd.Process.Pid) { + _ = cmd.Process.Kill() + } + select { + case <-wait: + case <-time.After(2 * time.Second): + } + }) + scanner := bufio.NewScanner(stdout) + if !scanner.Scan() { + t.Fatalf("owner process helper did not become ready: %v", scanner.Err()) + } + if scanner.Text() != "ready" { + t.Fatalf("owner process helper readiness = %q, want ready", scanner.Text()) + } + return cmd.Process.Pid, wait +} + +func assertOwnerProcessExited(t *testing.T, pid int, wait <-chan error) { + t.Helper() + select { + case <-wait: + case <-time.After(2 * time.Second): + t.Fatalf("owner process %d did not exit", pid) + } + if ProcessAlive(pid) { + t.Fatalf("owner process %d remained alive after exit proof", pid) + } +} diff --git a/internal/store/process_windows.go b/internal/store/process_windows.go index d30693ae..93b4305b 100644 --- a/internal/store/process_windows.go +++ b/internal/store/process_windows.go @@ -2,22 +2,72 @@ package store -import "syscall" +import ( + "context" + "errors" + "os" + "syscall" +) -const processQueryLimitedInformation = 0x1000 +const ( + processTerminate = 0x0001 + processQueryLimitedInformation = 0x1000 + errorInvalidParameter = syscall.Errno(87) + stillActive = 259 +) -// ProcessAlive reports whether pid can be opened by the current process. -func ProcessAlive(pid int) bool { - if pid <= 0 { - return true - } +func processAbsent(pid int) (bool, error) { handle, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid)) if err != nil { - if err == syscall.ERROR_ACCESS_DENIED { - return true + if errors.Is(err, errorInvalidParameter) { + return true, nil + } + return false, err + } + defer func() { + _ = syscall.CloseHandle(handle) + }() + var exitCode uint32 + if err := syscall.GetExitCodeProcess(handle, &exitCode); err != nil { + return false, err + } + return exitCode != stillActive, nil +} + +func signalOwnerProcess(pid int, force bool) error { + if !force { + process, err := os.FindProcess(pid) + if err != nil { + return err + } + err = process.Signal(os.Interrupt) + if errors.Is(err, os.ErrProcessDone) { + return errOwnerProcessAlreadyAbsent + } + if errors.Is(err, syscall.EWINDOWS) { + return errGracefulSignalUnsupported } - return false + return err } - _ = syscall.CloseHandle(handle) - return true + + handle, err := syscall.OpenProcess(processTerminate, false, uint32(pid)) + if err != nil { + if errors.Is(err, errorInvalidParameter) { + return errOwnerProcessAlreadyAbsent + } + return err + } + defer func() { + _ = syscall.CloseHandle(handle) + }() + if err := syscall.TerminateProcess(handle, 1); err != nil { + return err + } + return nil +} + +// processStartIdentity is unsupported on Windows: Runs created here record no +// identity token and keep the legacy PID-only owner proof. +func processStartIdentity(_ context.Context, _ int) (string, error) { + return "", ErrOwnerProcessUnsupported } diff --git a/internal/store/process_windows_test.go b/internal/store/process_windows_test.go new file mode 100644 index 00000000..0f7bc6fd --- /dev/null +++ b/internal/store/process_windows_test.go @@ -0,0 +1,48 @@ +//go:build windows + +package store + +import ( + "os/exec" + "syscall" + "testing" +) + +const synchronizeProcess = 0x00100000 + +func TestProcessAliveReportsExitedUnreapedChildDead(t *testing.T) { + cmd := exec.Command("cmd", "/c", "exit", "0") + if err := cmd.Start(); err != nil { + t.Fatalf("start child process: %v", err) + } + defer func() { + if err := cmd.Wait(); err != nil { + t.Fatalf("wait for child process: %v", err) + } + }() + + handle, err := syscall.OpenProcess( + synchronizeProcess|processQueryLimitedInformation, + false, + uint32(cmd.Process.Pid), + ) + if err != nil { + t.Fatalf("open child process: %v", err) + } + defer func() { + if err := syscall.CloseHandle(handle); err != nil { + t.Fatalf("close child process handle: %v", err) + } + }() + event, err := syscall.WaitForSingleObject(handle, 5_000) + if err != nil { + t.Fatalf("wait for child process exit: %v", err) + } + if event != syscall.WAIT_OBJECT_0 { + t.Fatalf("child process wait event = %#x, want WAIT_OBJECT_0", event) + } + + if ProcessAlive(cmd.Process.Pid) { + t.Fatalf("expected exited unreaped child pid %d to report dead", cmd.Process.Pid) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index a976a8fc..f37c822d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -54,6 +54,19 @@ const ( StatePushing = "Pushing" ) +var terminalStates = []string{ + StateFetched, + StateStopped, + StateClean, + StateCleanUnverified, + StateMaxRoundsReached, + StateBudgetExceeded, + StateTimedOut, + StateFailed, + StateIntegrationPending, + StateUnresolved, +} + type Store struct { db *sql.DB now func() time.Time @@ -77,11 +90,40 @@ type Run struct { Model string ReasoningEffort string OwnerPID *int + OwnerIdentity string CreatedAt time.Time UpdatedAt time.Time CompletedAt *time.Time } +type TerminalOutcomeConflictError struct { + RunID string + Stored string + Requested string +} + +func (err TerminalOutcomeConflictError) Error() string { + return fmt.Sprintf( + "terminal outcome conflict for Run %q: stored %q, requested %q", + err.RunID, + err.Stored, + err.Requested, + ) +} + +type CompleteRunResult struct { + Run + Transitioned bool +} + +type IntegrationReconciliation struct { + RunID string + RunHead string + TargetBranch string + TargetHead string + Time time.Time +} + type InteractiveDefaults struct { PRNumber string Agent string @@ -103,6 +145,7 @@ type CreateRunRequest struct { Model string ReasoningEffort string OwnerPID int + OwnerIdentity string } // RunStateFilter selects which Run states a listing includes. The zero @@ -141,6 +184,25 @@ type ActiveRunError struct { Existing Run } +// SchemaVersionError reports a Run Database whose schema version differs from +// the one this binary supports. Read-only surfaces never migrate; the guard +// only converts silent SQL errors on unmigrated databases into this +// deterministic diagnostic. +type SchemaVersionError struct { + Path string + Found int + Supported int +} + +func (err SchemaVersionError) Error() string { + return fmt.Sprintf( + "Run Database %q has schema version %d, but this binary supports schema version %d; run one operational roundfix command (resolve, watch, or implement) to migrate the Run Database", + err.Path, + err.Found, + err.Supported, + ) +} + var ErrTerminalRunStopRequest = errors.New("cannot record Stop Request for terminal Run") func (err ActiveRunError) Error() string { @@ -184,7 +246,9 @@ func Open(ctx context.Context, homeDir string) (*Store, error) { } // OpenReader opens a read-only connection for paging Run Events while the -// writer appends. It never migrates; the Run Database must already exist. +// writer appends. It never migrates; the Run Database must already exist and +// carry the supported schema version, otherwise a typed SchemaVersionError +// names the migration remediation instead of failing on a later query. func OpenReader(ctx context.Context, homeDir string) (*Store, error) { if strings.TrimSpace(homeDir) == "" { return nil, errors.New("open Run Database reader: home directory is required") @@ -207,6 +271,15 @@ func OpenReader(ctx context.Context, homeDir string) (*Store, error) { _ = db.Close() return nil, fmt.Errorf("open Run Database reader %q: %w", path, err) } + version, err := store.MigrationVersion(ctx) + if err != nil { + _ = db.Close() + return nil, fmt.Errorf("open Run Database reader %q: %w", path, err) + } + if version != schemaVersion { + _ = db.Close() + return nil, SchemaVersionError{Path: path, Found: version, Supported: schemaVersion} + } return store, nil } @@ -272,8 +345,8 @@ func (store *Store) createRun(ctx context.Context, req CreateRunRequest, acquire INSERT INTO runs ( id, kind, state, head_repository, head_branch, base_repository, pr_number, git_root, local_branch, head_sha, artifact_dir, work_dir, - spec_slug, agent, model, reasoning_effort, owner_pid, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + spec_slug, agent, model, reasoning_effort, owner_pid, owner_identity, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, runID, req.Kind, StateActive, @@ -291,6 +364,7 @@ INSERT INTO runs ( req.Model, req.ReasoningEffort, nullableOwnerPID(req.OwnerPID), + nullableOwnerIdentity(req.OwnerIdentity), formatTime(now), formatTime(now), ) @@ -326,50 +400,207 @@ VALUES (?, ?, ?, ?)`, return run, nil } -func (store *Store) CompleteRun(ctx context.Context, runID string, terminalState string) (Run, error) { +func (store *Store) CompleteRun(ctx context.Context, runID string, terminalState string) (CompleteRunResult, error) { + runID = strings.TrimSpace(runID) + if runID == "" { + return CompleteRunResult{}, errors.New("complete Run: Run ID is required") + } if !IsTerminalState(terminalState) { - return Run{}, fmt.Errorf("Run state %q is not terminal", terminalState) + return CompleteRunResult{}, fmt.Errorf("complete Run %q: Run state %q is not terminal", runID, terminalState) } now := store.now() tx, err := store.db.BeginTx(ctx, nil) if err != nil { - return Run{}, fmt.Errorf("begin Run completion: %w", err) + return CompleteRunResult{}, fmt.Errorf("begin completion for Run %q: %w", runID, err) } defer rollbackUnlessCommitted(tx) - result, err := tx.ExecContext(ctx, ` -UPDATE runs -SET state = ?, updated_at = ?, completed_at = ? -WHERE id = ?`, + terminalClause, terminalArguments := terminalStateExclusion() + arguments := []any{ terminalState, formatTime(now), formatTime(now), runID, + } + arguments = append(arguments, terminalArguments...) + result, err := tx.ExecContext(ctx, ` +UPDATE runs +SET state = ?, updated_at = ?, completed_at = ? +WHERE id = ? + AND `+terminalClause, + arguments..., ) if err != nil { - return Run{}, fmt.Errorf("update Run terminal state: %w", err) + return CompleteRunResult{}, fmt.Errorf("compare-and-set terminal outcome for Run %q: %w", runID, err) } affected, err := result.RowsAffected() if err != nil { - return Run{}, fmt.Errorf("read Run completion result: %w", err) + return CompleteRunResult{}, fmt.Errorf("read completion result for Run %q: %w", runID, err) } if affected == 0 { - return Run{}, fmt.Errorf("Run %q does not exist", runID) + run, err := selectRun(ctx, tx, runID) + if errors.Is(err, sql.ErrNoRows) { + return CompleteRunResult{}, fmt.Errorf("complete Run %q: Run does not exist", runID) + } + if err != nil { + return CompleteRunResult{}, fmt.Errorf("read Run %q after completion compare-and-set: %w", runID, err) + } + if run.State != terminalState { + return CompleteRunResult{}, TerminalOutcomeConflictError{ + RunID: runID, + Stored: run.State, + Requested: terminalState, + } + } + if err := tx.Commit(); err != nil { + return CompleteRunResult{}, fmt.Errorf("commit completion replay for Run %q: %w", runID, err) + } + return CompleteRunResult{Run: run}, nil } if _, err := tx.ExecContext(ctx, `DELETE FROM active_run_locks WHERE run_id = ?`, runID); err != nil { - return Run{}, fmt.Errorf("release Active Run lock: %w", err) + return CompleteRunResult{}, fmt.Errorf("release Active Run lock for Run %q: %w", runID, err) } run, err := selectRun(ctx, tx, runID) if err != nil { + return CompleteRunResult{}, fmt.Errorf("read completed Run %q: %w", runID, err) + } + if err := tx.Commit(); err != nil { + return CompleteRunResult{}, fmt.Errorf("commit completion for Run %q: %w", runID, err) + } + return CompleteRunResult{Run: run, Transitioned: true}, nil +} + +func (store *Store) ReconcileIntegration(ctx context.Context, req IntegrationReconciliation) (Run, error) { + if err := validateIntegrationReconciliation(req); err != nil { return Run{}, err } + req.RunID = strings.TrimSpace(req.RunID) + req.RunHead = strings.TrimSpace(req.RunHead) + req.TargetBranch = strings.TrimSpace(req.TargetBranch) + req.TargetHead = strings.TrimSpace(req.TargetHead) + + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return Run{}, fmt.Errorf("begin Integration Pending reconciliation for Run %q: %w", req.RunID, err) + } + defer rollbackUnlessCommitted(tx) + + run, err := selectRun(ctx, tx, req.RunID) + if errors.Is(err, sql.ErrNoRows) { + return Run{}, fmt.Errorf("reconcile Integration Pending Run %q: Run does not exist", req.RunID) + } + if err != nil { + return Run{}, fmt.Errorf("read Run %q before Integration Pending reconciliation: %w", req.RunID, err) + } + if run.State != StateIntegrationPending { + if IsTerminalState(run.State) { + return Run{}, TerminalOutcomeConflictError{ + RunID: req.RunID, + Stored: run.State, + Requested: StateClean, + } + } + return Run{}, fmt.Errorf( + "reconcile Integration Pending Run %q: stored state %q is not terminal outcome %q", + req.RunID, + run.State, + StateIntegrationPending, + ) + } + if run.Kind != KindImplement { + return Run{}, fmt.Errorf( + "reconcile Integration Pending Run %q: Run kind %q is not %q", + req.RunID, + run.Kind, + KindImplement, + ) + } + if strings.TrimSpace(run.LocalBranch) != req.TargetBranch { + return Run{}, fmt.Errorf( + "reconcile Integration Pending Run %q: target branch %q does not match recorded target branch %q", + req.RunID, + req.TargetBranch, + run.LocalBranch, + ) + } + + result, err := tx.ExecContext(ctx, ` +UPDATE runs +SET state = ?, updated_at = ? +WHERE id = ? AND state = ?`, + StateClean, + formatTime(req.Time), + req.RunID, + StateIntegrationPending, + ) + if err != nil { + return Run{}, fmt.Errorf("compare-and-set Integration Pending reconciliation for Run %q: %w", req.RunID, err) + } + affected, err := result.RowsAffected() + if err != nil { + return Run{}, fmt.Errorf("read Integration Pending reconciliation result for Run %q: %w", req.RunID, err) + } + if affected != 1 { + return Run{}, fmt.Errorf( + "reconcile Integration Pending Run %q: compare-and-set affected %d rows", + req.RunID, + affected, + ) + } + + payload, err := json.Marshal(map[string]string{ + "event": "integration_reconciliation", + "previous_outcome": StateIntegrationPending, + "current_outcome": StateClean, + "run_head": req.RunHead, + "target_branch": req.TargetBranch, + "target_head": req.TargetHead, + }) + if err != nil { + return Run{}, fmt.Errorf("encode Integration Pending reconciliation for Run %q: %w", req.RunID, err) + } + if _, err := appendRunEvent(ctx, tx, runevent.RunEvent{ + RunID: req.RunID, + Source: runevent.SourceDaemon, + Kind: runevent.KindDaemonOutcome, + Summary: runevent.BoundSummary("Run reconciled Integration Pending to Clean."), + Time: req.Time, + Payload: payload, + }); err != nil { + return Run{}, fmt.Errorf("journal Integration Pending reconciliation for Run %q: %w", req.RunID, err) + } + + reconciled, err := selectRun(ctx, tx, req.RunID) + if err != nil { + return Run{}, fmt.Errorf("read reconciled Run %q: %w", req.RunID, err) + } if err := tx.Commit(); err != nil { - return Run{}, fmt.Errorf("commit Run completion: %w", err) + return Run{}, fmt.Errorf("commit Integration Pending reconciliation for Run %q: %w", req.RunID, err) } - return run, nil + return reconciled, nil +} + +func validateIntegrationReconciliation(req IntegrationReconciliation) error { + runID := strings.TrimSpace(req.RunID) + if runID == "" { + return errors.New("reconcile Integration Pending Run: Run ID is required") + } + if strings.TrimSpace(req.RunHead) == "" { + return fmt.Errorf("reconcile Integration Pending Run %q: Run Branch head is required", runID) + } + if strings.TrimSpace(req.TargetBranch) == "" { + return fmt.Errorf("reconcile Integration Pending Run %q: target branch is required", runID) + } + if strings.TrimSpace(req.TargetHead) == "" { + return fmt.Errorf("reconcile Integration Pending Run %q: target head is required", runID) + } + if req.Time.IsZero() { + return fmt.Errorf("reconcile Integration Pending Run %q: timestamp is required", runID) + } + return nil } func (store *Store) ReclaimOrphanedRun(ctx context.Context, run Run, reason string) error { @@ -555,11 +786,19 @@ func (store *Store) UpdateRunState(ctx context.Context, runID string, state stri if IsTerminalState(state) { return fmt.Errorf("update Run state: %q is terminal; use CompleteRun", state) } - result, err := store.db.ExecContext(ctx, ` -UPDATE runs SET state = ?, updated_at = ? WHERE id = ?`, + terminalClause, terminalArguments := terminalStateExclusion() + arguments := []any{ state, formatTime(store.now()), runID, + } + arguments = append(arguments, terminalArguments...) + result, err := store.db.ExecContext(ctx, ` +UPDATE runs +SET state = ?, updated_at = ? +WHERE id = ? + AND `+terminalClause, + arguments..., ) if err != nil { return fmt.Errorf("update Run state: %w", err) @@ -569,7 +808,18 @@ UPDATE runs SET state = ?, updated_at = ? WHERE id = ?`, return fmt.Errorf("read Run state update result: %w", err) } if affected == 0 { - return fmt.Errorf("update Run state: Run %q does not exist", runID) + run, found, selectErr := store.Run(ctx, runID) + if selectErr != nil { + return fmt.Errorf("read Run %q after state update compare-and-set: %w", runID, selectErr) + } + if !found { + return fmt.Errorf("update Run state: Run %q does not exist", runID) + } + return TerminalOutcomeConflictError{ + RunID: runID, + Stored: run.State, + Requested: state, + } } return nil } @@ -586,7 +836,7 @@ func (store *Store) ActiveReviewRunByTarget(ctx context.Context, headRepository sqlQuery := ` SELECT id, kind, state, head_repository, head_branch, base_repository, pr_number, git_root, local_branch, head_sha, artifact_dir, work_dir, - spec_slug, agent, model, reasoning_effort, owner_pid, created_at, updated_at, completed_at + spec_slug, agent, model, reasoning_effort, owner_pid, owner_identity, created_at, updated_at, completed_at FROM runs WHERE head_repository = ? AND head_branch = ? ORDER BY created_at DESC, id DESC` @@ -624,7 +874,7 @@ func (store *Store) ActiveRunInGitRoot(ctx context.Context, gitRoot string) (Run row := store.db.QueryRowContext(ctx, ` SELECT r.id, r.kind, r.state, r.head_repository, r.head_branch, r.base_repository, r.pr_number, r.git_root, r.local_branch, r.head_sha, r.artifact_dir, r.work_dir, - r.spec_slug, r.agent, r.model, r.reasoning_effort, r.owner_pid, r.created_at, r.updated_at, r.completed_at + r.spec_slug, r.agent, r.model, r.reasoning_effort, r.owner_pid, r.owner_identity, r.created_at, r.updated_at, r.completed_at FROM active_run_locks l JOIN runs r ON r.id = l.run_id WHERE r.git_root = ? @@ -645,7 +895,7 @@ func (store *Store) ListRuns(ctx context.Context, query ListRunsQuery) ([]Run, e sqlQuery := ` SELECT id, kind, state, head_repository, head_branch, base_repository, pr_number, git_root, local_branch, head_sha, artifact_dir, work_dir, - spec_slug, agent, model, reasoning_effort, owner_pid, created_at, updated_at, completed_at + spec_slug, agent, model, reasoning_effort, owner_pid, owner_identity, created_at, updated_at, completed_at FROM runs` args := []any{} gitRoot := strings.TrimSpace(query.GitRoot) @@ -704,7 +954,7 @@ func (store *Store) LatestKeptSpecRun(ctx context.Context, gitRoot string, specS row := store.db.QueryRowContext(ctx, ` SELECT id, kind, state, head_repository, head_branch, base_repository, pr_number, git_root, local_branch, head_sha, artifact_dir, work_dir, - spec_slug, agent, model, reasoning_effort, owner_pid, created_at, updated_at, completed_at + spec_slug, agent, model, reasoning_effort, owner_pid, owner_identity, created_at, updated_at, completed_at FROM runs WHERE kind = ? AND git_root = ? AND spec_slug = ? AND work_dir IS NOT NULL AND TRIM(work_dir) <> '' @@ -826,15 +1076,25 @@ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.upd } func IsTerminalState(state string) bool { - switch state { - case StateFetched, StateStopped, StateClean, StateCleanUnverified, StateMaxRoundsReached, StateBudgetExceeded, StateTimedOut, StateFailed, StateIntegrationPending, StateUnresolved: - return true - default: - return false + for _, terminalState := range terminalStates { + if state == terminalState { + return true + } + } + return false +} + +func terminalStateExclusion() (string, []any) { + placeholders := make([]string, len(terminalStates)) + arguments := make([]any, len(terminalStates)) + for index, state := range terminalStates { + placeholders[index] = "?" + arguments[index] = state } + return "state NOT IN (" + strings.Join(placeholders, ", ") + ")", arguments } -const schemaVersion = 9 +const schemaVersion = 10 // activeRunLocksColumns is the schema v4 lock-table shape (ADR 0016): one // Active Run per work target, keyed by (target_kind, target_key). @@ -866,12 +1126,14 @@ func (store *Store) migrate(ctx context.Context) error { statements = append(statements, migrateV6ToV7Statements()...) statements = append(statements, migrateV7ToV8Statements()...) statements = append(statements, migrateV8ToV9Statements()...) + statements = append(statements, migrateV9ToV10Statements()...) return store.applyMigration(ctx, statements) case 4: statements := append(migrateV4ToV5Statements(), migrateV5ToV6Statements()...) statements = append(statements, migrateV6ToV7Statements()...) statements = append(statements, migrateV7ToV8Statements()...) statements = append(statements, migrateV8ToV9Statements()...) + statements = append(statements, migrateV9ToV10Statements()...) return store.applyMigration(ctx, statements) case 5: if err := store.ensureAgentColumn(ctx); err != nil { @@ -880,16 +1142,22 @@ func (store *Store) migrate(ctx context.Context) error { statements := append(migrateV5ToV6Statements(), migrateV6ToV7Statements()...) statements = append(statements, migrateV7ToV8Statements()...) statements = append(statements, migrateV8ToV9Statements()...) + statements = append(statements, migrateV9ToV10Statements()...) return store.applyMigration(ctx, statements) case 6: statements := append(migrateV6ToV7Statements(), migrateV7ToV8Statements()...) statements = append(statements, migrateV8ToV9Statements()...) + statements = append(statements, migrateV9ToV10Statements()...) return store.applyMigration(ctx, statements) case 7: statements := append(migrateV7ToV8Statements(), migrateV8ToV9Statements()...) + statements = append(statements, migrateV9ToV10Statements()...) return store.applyMigration(ctx, statements) case 8: - return store.applyMigration(ctx, migrateV8ToV9Statements()) + statements := append(migrateV8ToV9Statements(), migrateV9ToV10Statements()...) + return store.applyMigration(ctx, statements) + case 9: + return store.applyMigration(ctx, migrateV9ToV10Statements()) default: return fmt.Errorf("migrate Run Database: schema version %d is not supported", version) } @@ -912,7 +1180,7 @@ func (store *Store) applyMigration(ctx context.Context, statements []string) err return nil } -// createSchemaStatements creates schema v9 directly on a fresh Run Database. +// createSchemaStatements creates schema v10 directly on a fresh Run Database. // spec_slug and the PR-shaped columns use the empty string for "not set"; // which fields a Run must carry is enforced by Kind in CreateRun. func createSchemaStatements() []string { @@ -935,6 +1203,7 @@ func createSchemaStatements() []string { model TEXT NOT NULL DEFAULT '', reasoning_effort TEXT NOT NULL DEFAULT '', owner_pid INTEGER, + owner_identity TEXT, stop_requested_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -965,7 +1234,7 @@ func createSchemaStatements() []string { `CREATE TABLE IF NOT EXISTS run_agent_selections ` + runAgentSelectionsColumns, `CREATE INDEX IF NOT EXISTS idx_run_agent_selections_scope ON run_agent_selections (run_id, scope_kind, scope_id, attempt)`, - `PRAGMA user_version = 9`, + `PRAGMA user_version = 10`, } } @@ -1050,6 +1319,16 @@ func migrateV8ToV9Statements() []string { } } +// migrateV9ToV10Statements adds the owner start-time identity token column. +// Legacy rows keep NULL, which Force Stop treats as the pre-identity +// PID-only owner proof. +func migrateV9ToV10Statements() []string { + return []string{ + `ALTER TABLE runs ADD COLUMN owner_identity TEXT`, + `PRAGMA user_version = 10`, + } +} + func (store *Store) ensureAgentColumn(ctx context.Context) error { exists, err := store.runColumnExists(ctx, "agent") if err != nil { @@ -1132,6 +1411,14 @@ func nullableOwnerPID(pid int) any { return pid } +func nullableOwnerIdentity(identity string) any { + identity = strings.TrimSpace(identity) + if identity == "" { + return nil + } + return identity +} + // lockTarget derives the Active Run lock key for a create request (ADR // 0016): review Kinds lock the Open Pull Request, implement locks the Spec. func lockTarget(req CreateRunRequest) (string, string) { @@ -1161,7 +1448,7 @@ func selectActiveRunByTarget(ctx context.Context, querier runQuerier, targetKind row := querier.QueryRowContext(ctx, ` SELECT r.id, r.kind, r.state, r.head_repository, r.head_branch, r.base_repository, r.pr_number, r.git_root, r.local_branch, r.head_sha, r.artifact_dir, r.work_dir, - r.spec_slug, r.agent, r.model, r.reasoning_effort, r.owner_pid, r.created_at, r.updated_at, r.completed_at + r.spec_slug, r.agent, r.model, r.reasoning_effort, r.owner_pid, r.owner_identity, r.created_at, r.updated_at, r.completed_at FROM active_run_locks l JOIN runs r ON r.id = l.run_id WHERE l.target_kind = ? AND l.target_key = ?`, @@ -1182,7 +1469,7 @@ func selectRun(ctx context.Context, querier runQuerier, runID string) (Run, erro row := querier.QueryRowContext(ctx, ` SELECT id, kind, state, head_repository, head_branch, base_repository, pr_number, git_root, local_branch, head_sha, artifact_dir, work_dir, - spec_slug, agent, model, reasoning_effort, owner_pid, created_at, updated_at, completed_at + spec_slug, agent, model, reasoning_effort, owner_pid, owner_identity, created_at, updated_at, completed_at FROM runs WHERE id = ?`, runID) run, err := scanRun(row) @@ -1199,6 +1486,7 @@ func scanRun(row runScanner) (Run, error) { var completedAt string var workDir sql.NullString var ownerPID sql.NullInt64 + var ownerIdentity sql.NullString err := row.Scan( &run.ID, &run.Kind, @@ -1217,6 +1505,7 @@ func scanRun(row runScanner) (Run, error) { &run.Model, &run.ReasoningEffort, &ownerPID, + &ownerIdentity, &createdAt, &updatedAt, &completedAt, @@ -1231,6 +1520,9 @@ func scanRun(row runScanner) (Run, error) { pid := int(ownerPID.Int64) run.OwnerPID = &pid } + if ownerIdentity.Valid { + run.OwnerIdentity = ownerIdentity.String + } parsedCreatedAt, err := parseTime(createdAt) if err != nil { return Run{}, err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 00762b15..b288aa06 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -3,6 +3,7 @@ package store import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "os" @@ -10,6 +11,8 @@ import ( "strings" "testing" "time" + + "roundfix/internal/runevent" ) func TestOpenCreatesRunDatabaseAndAppliesMigrations(t *testing.T) { @@ -26,8 +29,8 @@ func TestOpenCreatesRunDatabaseAndAppliesMigrations(t *testing.T) { if err != nil { t.Fatalf("expected migration version, got %v", err) } - if version != 9 { - t.Fatalf("expected migration version 9, got %d", version) + if version != 10 { + t.Fatalf("expected migration version 10, got %d", version) } } @@ -520,6 +523,70 @@ func TestCreateRunPersistsOwnerPIDAcrossRunQueries(t *testing.T) { } } +func TestCreateRunPersistsOwnerIdentityAcrossRunQueries(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + wantIdentity := "owner-identity-start-time-token" + req := sampleCreateRunRequest() + req.OwnerPID = os.Getpid() + req.OwnerIdentity = wantIdentity + created, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("expected Run creation, got %v", err) + } + if created.OwnerIdentity != wantIdentity { + t.Fatalf("expected created Run owner identity %q, got %q", wantIdentity, created.OwnerIdentity) + } + + found, ok, err := runStore.Run(ctx, created.ID) + if err != nil || !ok { + t.Fatalf("lookup persisted Run: ok=%v err=%v", ok, err) + } + if found.OwnerIdentity != wantIdentity { + t.Fatalf("expected persisted Run owner identity %q, got %q", wantIdentity, found.OwnerIdentity) + } + + active, ok, err := runStore.ActiveRun(ctx, req.HeadRepository, req.HeadBranch) + if err != nil || !ok { + t.Fatalf("lookup active Run: ok=%v err=%v", ok, err) + } + if active.OwnerIdentity != wantIdentity { + t.Fatalf("expected active Run owner identity %q, got %q", wantIdentity, active.OwnerIdentity) + } + + var rawOwnerIdentity string + if err := runStore.db.QueryRowContext(ctx, `SELECT owner_identity FROM runs WHERE id = ?`, created.ID).Scan(&rawOwnerIdentity); err != nil { + t.Fatalf("read owner_identity: %v", err) + } + if rawOwnerIdentity != wantIdentity { + t.Fatalf("expected raw owner_identity %q, got %q", wantIdentity, rawOwnerIdentity) + } +} + +func TestCreateRunWithoutOwnerIdentityStoresNull(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + created, err := runStore.CreateRun(ctx, sampleCreateRunRequest()) + if err != nil { + t.Fatalf("expected Run creation, got %v", err) + } + if created.OwnerIdentity != "" { + t.Fatalf("expected empty owner identity, got %q", created.OwnerIdentity) + } + + var rawOwnerIdentity any + if err := runStore.db.QueryRowContext(ctx, `SELECT owner_identity FROM runs WHERE id = ?`, created.ID).Scan(&rawOwnerIdentity); err != nil { + t.Fatalf("read owner_identity: %v", err) + } + if rawOwnerIdentity != nil { + t.Fatalf("expected owner_identity NULL without a recorded token, got %#v", rawOwnerIdentity) + } +} + func TestCreateRunAllowsDifferentHeadBranch(t *testing.T) { ctx := context.Background() store := openTestStore(t, ctx, t.TempDir()) @@ -593,6 +660,575 @@ func TestCompleteRunRejectsNonTerminalState(t *testing.T) { } } +func TestCompleteRunWinnerAndIdenticalReplay(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + createdAt := time.Date(2026, 7, 27, 9, 0, 0, 0, time.UTC) + completedAt := createdAt.Add(time.Minute) + runStore.now = func() time.Time { return createdAt } + run, err := runStore.CreateRun(ctx, sampleCreateRunRequest()) + if err != nil { + t.Fatalf("create Run: %v", err) + } + if _, err := runStore.AppendRunEvent(ctx, sampleRunEvent(run.ID, "before completion")); err != nil { + t.Fatalf("append initial Run Event: %v", err) + } + + runStore.now = func() time.Time { return completedAt } + first, err := runStore.CompleteRun(ctx, run.ID, StateStopped) + if err != nil { + t.Fatalf("complete Run: %v", err) + } + if !first.Transitioned { + t.Fatal("expected first completion to report a transition") + } + if first.Run.State != StateStopped || first.Run.CompletedAt == nil { + t.Fatalf("expected terminal Stopped Run, got %+v", first.Run) + } + if got := countActiveRunLocks(t, ctx, runStore); got != 0 { + t.Fatalf("expected winning completion to release one Active Run lock, got %d", got) + } + + runStore.now = func() time.Time { return completedAt.Add(time.Hour) } + replay, err := runStore.CompleteRun(ctx, run.ID, StateStopped) + if err != nil { + t.Fatalf("replay identical completion: %v", err) + } + if replay.Transitioned { + t.Fatal("expected identical replay to report no transition") + } + if replay.Run.State != first.Run.State || + !replay.Run.UpdatedAt.Equal(first.Run.UpdatedAt) || + replay.Run.CompletedAt == nil || + !replay.Run.CompletedAt.Equal(*first.Run.CompletedAt) { + t.Fatalf("expected replay to preserve the terminal row, first=%+v replay=%+v", first.Run, replay.Run) + } + if got := countActiveRunLocks(t, ctx, runStore); got != 0 { + t.Fatalf("expected replay to preserve released lock state, got %d locks", got) + } + if got := countRunEvents(t, ctx, runStore, run.ID); got != 1 { + t.Fatalf("expected replay to preserve journal history, got %d events", got) + } +} + +func TestTerminalOutcomeConflictPreservesWinner(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + completedAt := time.Date(2026, 7, 27, 10, 0, 0, 0, time.UTC) + run, err := runStore.CreateRun(ctx, sampleCreateRunRequest()) + if err != nil { + t.Fatalf("create Run: %v", err) + } + if _, err := runStore.AppendRunEvent(ctx, sampleRunEvent(run.ID, "winner evidence")); err != nil { + t.Fatalf("append initial Run Event: %v", err) + } + runStore.now = func() time.Time { return completedAt } + winner, err := runStore.CompleteRun(ctx, run.ID, StateStopped) + if err != nil { + t.Fatalf("complete winning outcome: %v", err) + } + + runStore.now = func() time.Time { return completedAt.Add(time.Hour) } + _, err = runStore.CompleteRun(ctx, run.ID, StateFailed) + var conflict TerminalOutcomeConflictError + if !errors.As(err, &conflict) { + t.Fatalf("expected TerminalOutcomeConflictError, got %T %v", err, err) + } + if conflict.RunID != run.ID || conflict.Stored != StateStopped || conflict.Requested != StateFailed { + t.Fatalf("unexpected terminal conflict: %+v", conflict) + } + + stored, found, err := runStore.Run(ctx, run.ID) + if err != nil || !found { + t.Fatalf("read terminal winner: found=%v err=%v", found, err) + } + if stored.State != winner.Run.State || + !stored.UpdatedAt.Equal(winner.Run.UpdatedAt) || + stored.CompletedAt == nil || + !stored.CompletedAt.Equal(*winner.Run.CompletedAt) { + t.Fatalf("expected conflict to preserve winner, winner=%+v stored=%+v", winner.Run, stored) + } + if got := countActiveRunLocks(t, ctx, runStore); got != 0 { + t.Fatalf("expected conflict to preserve released lock state, got %d locks", got) + } + if got := countRunEvents(t, ctx, runStore, run.ID); got != 1 { + t.Fatalf("expected conflict to preserve journal history, got %d events", got) + } +} + +func TestTerminalOutcomeRejectsIntermediateStateUpdate(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + run, err := runStore.CreateRun(ctx, sampleCreateRunRequest()) + if err != nil { + t.Fatalf("create Run: %v", err) + } + winner, err := runStore.CompleteRun(ctx, run.ID, StateStopped) + if err != nil { + t.Fatalf("complete Run: %v", err) + } + + err = runStore.UpdateRunState(ctx, run.ID, StateResolvingWithAgent) + var conflict TerminalOutcomeConflictError + if !errors.As(err, &conflict) { + t.Fatalf("expected TerminalOutcomeConflictError, got %T %v", err, err) + } + if conflict.Stored != StateStopped || conflict.Requested != StateResolvingWithAgent { + t.Fatalf("unexpected conflict: %+v", conflict) + } + stored, found, err := runStore.Run(ctx, run.ID) + if err != nil || !found { + t.Fatalf("read stored Run: found=%v err=%v", found, err) + } + if stored.State != StateStopped || !stored.CompletedAt.Equal(*winner.CompletedAt) { + t.Fatalf("intermediate update changed terminal winner: before=%+v after=%+v", winner.Run, stored) + } +} + +func TestTerminalOutcomeEveryStoredTerminalStateIsImmutable(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + sourceOutcomes := []string{ + StateFetched, + StateStopped, + StateClean, + StateCleanUnverified, + StateMaxRoundsReached, + StateBudgetExceeded, + StateTimedOut, + StateFailed, + StateIntegrationPending, + StateUnresolved, + } + for index, sourceOutcome := range sourceOutcomes { + t.Run(sourceOutcome, func(t *testing.T) { + req := sampleCreateRunRequest() + req.HeadBranch = fmt.Sprintf("feature/terminal-immutable-%d", index) + req.PRNumber = fmt.Sprintf("terminal-immutable-%d", index) + run, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("create Run: %v", err) + } + first, err := runStore.CompleteRun(ctx, run.ID, sourceOutcome) + if err != nil { + t.Fatalf("complete Run %s: %v", sourceOutcome, err) + } + requested := StateStopped + if sourceOutcome == StateStopped { + requested = StateFailed + } + + _, err = runStore.CompleteRun(ctx, run.ID, requested) + var conflict TerminalOutcomeConflictError + if !errors.As(err, &conflict) { + t.Fatalf("expected terminal conflict for %s to %s, got %T %v", sourceOutcome, requested, err, err) + } + stored, found, err := runStore.Run(ctx, run.ID) + if err != nil || !found { + t.Fatalf("read immutable terminal Run: found=%v err=%v", found, err) + } + if stored.State != sourceOutcome || + stored.CompletedAt == nil || + first.CompletedAt == nil || + !stored.CompletedAt.Equal(*first.CompletedAt) { + t.Fatalf("expected source outcome %s unchanged, got %+v", sourceOutcome, stored) + } + }) + } +} + +func TestCompleteRunConcurrentTerminalOutcomesHaveOneWinner(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + run, err := runStore.CreateRun(ctx, sampleCreateRunRequest()) + if err != nil { + t.Fatalf("create Run: %v", err) + } + type completion struct { + requested string + result CompleteRunResult + err error + } + start := make(chan struct{}) + completions := make(chan completion, 2) + for _, requested := range []string{StateStopped, StateFailed} { + go func(requested string) { + <-start + result, err := runStore.CompleteRun(ctx, run.ID, requested) + completions <- completion{requested: requested, result: result, err: err} + }(requested) + } + close(start) + + winner := "" + conflicts := 0 + for range 2 { + completed := <-completions + if completed.err == nil { + if !completed.result.Transitioned { + t.Fatalf("expected successful competing completion for %s to transition", completed.requested) + } + if winner != "" { + t.Fatalf("expected one winner, got %s and %s", winner, completed.requested) + } + winner = completed.requested + continue + } + var conflict TerminalOutcomeConflictError + if !errors.As(completed.err, &conflict) { + t.Fatalf("expected competing completion conflict, got %T %v", completed.err, completed.err) + } + conflicts++ + } + if winner == "" || conflicts != 1 { + t.Fatalf("expected exactly one winner and one conflict, winner=%q conflicts=%d", winner, conflicts) + } + stored, found, err := runStore.Run(ctx, run.ID) + if err != nil || !found { + t.Fatalf("read competing completion winner: found=%v err=%v", found, err) + } + if stored.State != winner || stored.CompletedAt == nil { + t.Fatalf("expected stable terminal winner %s, got %+v", winner, stored) + } + if got := countActiveRunLocks(t, ctx, runStore); got != 0 { + t.Fatalf("expected one winning lock release, got %d locks", got) + } +} + +func TestCompleteRunDatabaseFailureNamesOperationAndRun(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + run, err := runStore.CreateRun(ctx, sampleCreateRunRequest()) + if err != nil { + t.Fatalf("create Run: %v", err) + } + if err := runStore.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + _, err = runStore.CompleteRun(ctx, run.ID, StateStopped) + if err == nil || + !strings.Contains(err.Error(), "begin completion") || + !strings.Contains(err.Error(), run.ID) { + t.Fatalf("expected wrapped completion failure naming operation and Run %s, got %v", run.ID, err) + } +} + +func TestReconcileIntegrationPendingRecordsEvidence(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = "0037-terminal-outcome-integrity" + req.LocalBranch = "feature/terminal-outcomes" + run, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("create Implement Run: %v", err) + } + completed, err := runStore.CompleteRun(ctx, run.ID, StateIntegrationPending) + if err != nil { + t.Fatalf("complete Run Integration Pending: %v", err) + } + reconciledAt := time.Date(2026, 7, 27, 11, 0, 0, 0, time.UTC) + + reconciled, err := runStore.ReconcileIntegration(ctx, IntegrationReconciliation{ + RunID: run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Time: reconciledAt, + }) + if err != nil { + t.Fatalf("reconcile Integration Pending Run: %v", err) + } + if reconciled.State != StateClean { + t.Fatalf("expected reconciled Clean Run, got %+v", reconciled) + } + if reconciled.CompletedAt == nil || completed.Run.CompletedAt == nil || + !reconciled.CompletedAt.Equal(*completed.Run.CompletedAt) { + t.Fatalf("expected reconciliation to preserve original completion time, before=%+v after=%+v", completed.Run, reconciled) + } + events, err := runStore.RunEventsAfter(ctx, run.ID, 0, 10) + if err != nil { + t.Fatalf("read reconciliation event: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected one reconciliation event, got %d", len(events)) + } + event := events[0].Event + if event.Kind != runevent.KindDaemonOutcome || !event.Time.Equal(reconciledAt) { + t.Fatalf("unexpected reconciliation event: %+v", event) + } + var payload map[string]string + if err := json.Unmarshal(event.Payload, &payload); err != nil { + t.Fatalf("decode reconciliation event: %v", err) + } + wantPayload := map[string]string{ + "event": "integration_reconciliation", + "previous_outcome": StateIntegrationPending, + "current_outcome": StateClean, + "run_head": "run-head", + "target_branch": req.LocalBranch, + "target_head": "target-head", + } + for key, want := range wantPayload { + if payload[key] != want { + t.Fatalf("expected reconciliation payload %s=%q, got %q in %#v", key, want, payload[key], payload) + } + } +} + +func TestReconcileIntegrationDatabaseFailureNamesOperationAndRun(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = "0037-terminal-outcome-integrity" + run, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("create Implement Run: %v", err) + } + if _, err := runStore.CompleteRun(ctx, run.ID, StateIntegrationPending); err != nil { + t.Fatalf("complete Run Integration Pending: %v", err) + } + if err := runStore.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + _, err = runStore.ReconcileIntegration(ctx, IntegrationReconciliation{ + RunID: run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Time: time.Date(2026, 7, 27, 15, 0, 0, 0, time.UTC), + }) + if err == nil || + !strings.Contains(err.Error(), "begin Integration Pending reconciliation") || + !strings.Contains(err.Error(), run.ID) { + t.Fatalf("expected wrapped reconciliation failure naming operation and Run %s, got %v", run.ID, err) + } +} + +func TestReconcileIntegrationRejectsIncompleteEvidence(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = "0037-terminal-outcome-integrity" + run, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("create Implement Run: %v", err) + } + completed, err := runStore.CompleteRun(ctx, run.ID, StateIntegrationPending) + if err != nil { + t.Fatalf("complete Run Integration Pending: %v", err) + } + valid := IntegrationReconciliation{ + RunID: run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Time: time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC), + } + tests := []struct { + name string + mutate func(*IntegrationReconciliation) + }{ + {name: "missing Run ID", mutate: func(value *IntegrationReconciliation) { value.RunID = "" }}, + {name: "missing Run head", mutate: func(value *IntegrationReconciliation) { value.RunHead = "" }}, + {name: "missing target branch", mutate: func(value *IntegrationReconciliation) { value.TargetBranch = "" }}, + {name: "missing target head", mutate: func(value *IntegrationReconciliation) { value.TargetHead = "" }}, + {name: "missing timestamp", mutate: func(value *IntegrationReconciliation) { value.Time = time.Time{} }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := valid + tt.mutate(&input) + if _, err := runStore.ReconcileIntegration(ctx, input); err == nil { + t.Fatal("expected incomplete reconciliation evidence to fail") + } + }) + } + stored, found, err := runStore.Run(ctx, run.ID) + if err != nil || !found { + t.Fatalf("read Integration Pending Run: found=%v err=%v", found, err) + } + if stored.State != StateIntegrationPending || + stored.CompletedAt == nil || + completed.Run.CompletedAt == nil || + !stored.CompletedAt.Equal(*completed.Run.CompletedAt) { + t.Fatalf("expected invalid evidence to preserve Integration Pending Run, got %+v", stored) + } + if got := countRunEvents(t, ctx, runStore, run.ID); got != 0 { + t.Fatalf("expected invalid evidence to append no event, got %d", got) + } +} + +func TestReconcileIntegrationRejectsStaleTargetBranch(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = "0037-terminal-outcome-integrity" + req.LocalBranch = "feature/terminal-outcomes" + run, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("create Implement Run: %v", err) + } + completed, err := runStore.CompleteRun(ctx, run.ID, StateIntegrationPending) + if err != nil { + t.Fatalf("complete Run Integration Pending: %v", err) + } + + _, err = runStore.ReconcileIntegration(ctx, IntegrationReconciliation{ + RunID: run.ID, + RunHead: "run-head", + TargetBranch: "feature/stale-target", + TargetHead: "target-head", + Time: time.Date(2026, 7, 27, 12, 30, 0, 0, time.UTC), + }) + if err == nil || !strings.Contains(err.Error(), "does not match recorded target branch") { + t.Fatalf("expected stale target branch rejection, got %v", err) + } + stored, found, readErr := runStore.Run(ctx, run.ID) + if readErr != nil || !found { + t.Fatalf("read Integration Pending Run: found=%v err=%v", found, readErr) + } + if stored.State != StateIntegrationPending || + stored.CompletedAt == nil || + completed.CompletedAt == nil || + !stored.CompletedAt.Equal(*completed.CompletedAt) { + t.Fatalf("expected stale target evidence to preserve Integration Pending Run, got %+v", stored) + } + if got := countRunEvents(t, ctx, runStore, run.ID); got != 0 { + t.Fatalf("expected stale target evidence to append no event, got %d", got) + } +} + +func TestReconcileIntegrationRejectsEveryOtherSourceOutcome(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + sourceOutcomes := []string{ + StateFetched, + StateStopped, + StateClean, + StateCleanUnverified, + StateMaxRoundsReached, + StateBudgetExceeded, + StateTimedOut, + StateFailed, + StateUnresolved, + } + for index, sourceOutcome := range sourceOutcomes { + t.Run(sourceOutcome, func(t *testing.T) { + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = fmt.Sprintf("terminal-source-%d", index) + req.LocalBranch = fmt.Sprintf("feature/terminal-source-%d", index) + run, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("create Implement Run: %v", err) + } + completed, err := runStore.CompleteRun(ctx, run.ID, sourceOutcome) + if err != nil { + t.Fatalf("complete Run %s: %v", sourceOutcome, err) + } + + _, err = runStore.ReconcileIntegration(ctx, IntegrationReconciliation{ + RunID: run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Time: time.Date(2026, 7, 27, 13, 0, 0, 0, time.UTC), + }) + var conflict TerminalOutcomeConflictError + if !errors.As(err, &conflict) { + t.Fatalf("expected terminal conflict for source %s, got %T %v", sourceOutcome, err, err) + } + stored, found, err := runStore.Run(ctx, run.ID) + if err != nil || !found { + t.Fatalf("read rejected source Run: found=%v err=%v", found, err) + } + if stored.State != sourceOutcome || + stored.CompletedAt == nil || + completed.Run.CompletedAt == nil || + !stored.CompletedAt.Equal(*completed.Run.CompletedAt) { + t.Fatalf("expected source outcome %s unchanged, got %+v", sourceOutcome, stored) + } + if got := countRunEvents(t, ctx, runStore, run.ID); got != 0 { + t.Fatalf("expected source outcome rejection to append no event, got %d", got) + } + }) + } +} + +func TestReconcileIntegrationRollsBackWhenJournalFails(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = "0037-terminal-outcome-integrity" + run, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("create Implement Run: %v", err) + } + completed, err := runStore.CompleteRun(ctx, run.ID, StateIntegrationPending) + if err != nil { + t.Fatalf("complete Run Integration Pending: %v", err) + } + if _, err := runStore.db.ExecContext(ctx, ` +CREATE TRIGGER reject_reconciliation_event +BEFORE INSERT ON run_events +BEGIN + SELECT RAISE(FAIL, 'reconciliation journal unavailable'); +END`); err != nil { + t.Fatalf("create reconciliation journal failure trigger: %v", err) + } + + _, err = runStore.ReconcileIntegration(ctx, IntegrationReconciliation{ + RunID: run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Time: time.Date(2026, 7, 27, 14, 0, 0, 0, time.UTC), + }) + if err == nil || !strings.Contains(err.Error(), run.ID) { + t.Fatalf("expected reconciliation journal failure naming Run %s, got %v", run.ID, err) + } + stored, found, readErr := runStore.Run(ctx, run.ID) + if readErr != nil || !found { + t.Fatalf("read rolled-back Run: found=%v err=%v", found, readErr) + } + if stored.State != StateIntegrationPending || + stored.CompletedAt == nil || + completed.Run.CompletedAt == nil || + !stored.CompletedAt.Equal(*completed.Run.CompletedAt) { + t.Fatalf("expected failed journal append to roll back Run, got %+v", stored) + } + if got := countRunEvents(t, ctx, runStore, run.ID); got != 0 { + t.Fatalf("expected failed reconciliation to append no event, got %d", got) + } +} + func TestRequestStopRecordsStopRequestForActiveRun(t *testing.T) { ctx := context.Background() runStore := openTestStore(t, ctx, t.TempDir()) @@ -747,7 +1383,7 @@ func createListedRun(t *testing.T, ctx context.Context, store *Store, seed liste if err != nil { t.Fatalf("complete listed Run as %s: %v", seed.state, err) } - return completed + return completed.Run default: if err := store.UpdateRunState(ctx, run.ID, seed.state); err != nil { t.Fatalf("update listed Run state to %s: %v", seed.state, err) @@ -918,8 +1554,8 @@ func TestOpenMigratesV3RunDatabasePreservingRunsAndRekeyingLocks(t *testing.T) { if err != nil { t.Fatalf("read migration version: %v", err) } - if version != 9 { - t.Fatalf("expected user_version 9 after migration, got %d", version) + if version != 10 { + t.Fatalf("expected user_version 10 after migration, got %d", version) } count, err := store.RunCount(ctx) @@ -1073,8 +1709,8 @@ func TestOpenMigratesV4RunDatabasePreservingRunsLocksAndAddingStopRequests(t *te if err != nil { t.Fatalf("read migration version: %v", err) } - if version != 9 { - t.Fatalf("expected user_version 9 after migration, got %d", version) + if version != 10 { + t.Fatalf("expected user_version 10 after migration, got %d", version) } count, err := runStore.RunCount(ctx) if err != nil { @@ -1226,8 +1862,8 @@ func TestOpenMigratesV5RunDatabasePreservingRunsLocksAndAddingWorkDir(t *testing if err != nil { t.Fatalf("read migration version: %v", err) } - if version != 9 { - t.Fatalf("expected user_version 9 after migration, got %d", version) + if version != 10 { + t.Fatalf("expected user_version 10 after migration, got %d", version) } count, err := runStore.RunCount(ctx) if err != nil { @@ -1382,8 +2018,8 @@ func TestOpenMigratesV6RunDatabaseAddingSelectionDefaults(t *testing.T) { if err != nil { t.Fatalf("read migration version: %v", err) } - if version != 9 { - t.Fatalf("expected user_version 9 after migration, got %d", version) + if version != 10 { + t.Fatalf("expected user_version 10 after migration, got %d", version) } count, err := runStore.RunCount(ctx) if err != nil { @@ -1514,8 +2150,8 @@ func TestOpenMigratesV7RunDatabaseAddingOwnerPID(t *testing.T) { if err != nil { t.Fatalf("read migration version: %v", err) } - if version != 9 { - t.Fatalf("expected user_version 9 after migration, got %d", version) + if version != 10 { + t.Fatalf("expected user_version 10 after migration, got %d", version) } active, found, err := runStore.ActiveRun(ctx, "owner/project", "feature/review") @@ -1528,6 +2164,9 @@ func TestOpenMigratesV7RunDatabaseAddingOwnerPID(t *testing.T) { if active.OwnerPID != nil { t.Fatalf("expected migrated v7 Run to have no owner PID, got %d", *active.OwnerPID) } + if active.OwnerIdentity != "" { + t.Fatalf("expected migrated v7 Run to have no owner identity, got %q", active.OwnerIdentity) + } var rawOwnerPID any if err := runStore.db.QueryRowContext(ctx, `SELECT owner_pid FROM runs WHERE id = 'run_v7_active'`).Scan(&rawOwnerPID); err != nil { @@ -1536,6 +2175,72 @@ func TestOpenMigratesV7RunDatabaseAddingOwnerPID(t *testing.T) { if rawOwnerPID != nil { t.Fatalf("expected migrated owner_pid NULL for legacy row, got %#v", rawOwnerPID) } + var rawOwnerIdentity any + if err := runStore.db.QueryRowContext(ctx, `SELECT owner_identity FROM runs WHERE id = 'run_v7_active'`).Scan(&rawOwnerIdentity); err != nil { + t.Fatalf("read migrated owner_identity column: %v", err) + } + if rawOwnerIdentity != nil { + t.Fatalf("expected migrated owner_identity NULL for legacy row, got %#v", rawOwnerIdentity) + } +} + +// buildV9Fixture creates a schema v9 Run Database exactly as historical +// binaries wrote it: the v7 fixture upgraded through the real v7→v8→v9 +// migration statements, without the v10 owner_identity column. +func buildV9Fixture(t *testing.T, homeDir string) { + t.Helper() + buildV7Fixture(t, homeDir) + db, err := sql.Open("sqlite", writerDSN(DatabasePath(homeDir))) + if err != nil { + t.Fatalf("open fixture database: %v", err) + } + defer func() { + if err := db.Close(); err != nil { + t.Fatalf("close fixture database: %v", err) + } + }() + for _, statement := range append(migrateV7ToV8Statements(), migrateV8ToV9Statements()...) { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("build v9 fixture: %v", err) + } + } +} + +func TestOpenReaderRejectsMismatchedSchemaVersion(t *testing.T) { + ctx := context.Background() + homeDir := t.TempDir() + buildV9Fixture(t, homeDir) + + _, err := OpenReader(ctx, homeDir) + + var versionErr SchemaVersionError + if !errors.As(err, &versionErr) { + t.Fatalf("expected SchemaVersionError, got %T %v", err, err) + } + if versionErr.Found != 9 || versionErr.Supported != schemaVersion || versionErr.Path != DatabasePath(homeDir) { + t.Fatalf("schema version diagnostic = %#v", versionErr) + } + if errors.Is(err, os.ErrNotExist) { + t.Fatal("schema version mismatch must not read as a missing Run Database") + } + for _, want := range []string{ + "schema version 9", + "supports schema version 10", + "resolve, watch, or implement", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("schema version error missing %q: %q", want, err.Error()) + } + } + + // One migrating open upgrades the database; the reader then works. + migrated := openTestStore(t, ctx, homeDir) + closeStore(t, migrated) + reader, err := OpenReader(ctx, homeDir) + if err != nil { + t.Fatalf("expected reader to open after migration, got %v", err) + } + closeStore(t, reader) } func TestCreateRunRejectsSecondActiveRunForSameSpecTarget(t *testing.T) { diff --git a/internal/watch/watch.go b/internal/watch/watch.go index c3dd8a23..167cad74 100644 --- a/internal/watch/watch.go +++ b/internal/watch/watch.go @@ -17,6 +17,8 @@ const ( StatusSettled = "settled" ) +var ErrStopRequested = errors.New("stop requested") + type HeadCheckState string const ( @@ -73,6 +75,10 @@ type StatusSource interface { Status(context.Context, StatusRequest) (Status, error) } +type StopRequestSource interface { + StopRequested(context.Context, string) (bool, error) +} + type StatusFunc func(context.Context, StatusRequest) (Status, error) func (fn StatusFunc) Status(ctx context.Context, req StatusRequest) (Status, error) { @@ -140,6 +146,7 @@ func (realSleeper) Sleep(ctx context.Context, duration time.Duration) error { } type Dependencies struct { + StopRequests StopRequestSource StatusSource StatusSource Fetcher Fetcher Resolver Resolver @@ -175,9 +182,9 @@ func Run(ctx context.Context, req Request, deps Dependencies) (Result, error) { if budgetExceeded(req, startedAt, clock.Now()) { return Result{Outcome: store.StateBudgetExceeded, Rounds: round - 1}, nil } - settledWait, err := waitForSettled(ctx, req, currentHeadSHA, deps.StatusSource, clock, sleeper, publisher) + settledWait, err := waitForSettled(ctx, req, currentHeadSHA, deps.StopRequests, deps.StatusSource, clock, sleeper, publisher) if err != nil { - return Result{Outcome: store.StateFailed, Rounds: round - 1}, err + return resultForError(round-1, err), err } status := settledWait.status if status.State != StatusSettled { @@ -200,6 +207,9 @@ func Run(ctx context.Context, req Request, deps Dependencies) (Result, error) { if err := sleeper.Sleep(ctx, req.QuietPeriod); err != nil { return Result{Outcome: store.StateFailed, Rounds: round - 1}, err } + if err := observeStopRequest(ctx, deps.StopRequests, req.RunID, "after quiet-period wait"); err != nil { + return resultForError(round-1, err), err + } } if budgetExceeded(req, startedAt, clock.Now()) { return Result{Outcome: store.StateBudgetExceeded, Rounds: round - 1}, nil @@ -222,9 +232,9 @@ func Run(ctx context.Context, req Request, deps Dependencies) (Result, error) { return Result{Outcome: store.StateFailed, Rounds: round - 1}, err } if fetched.Issues == 0 { - confirm, err := confirmMergeReady(ctx, req, deps.CheckSource, currentHeadSHA, clock, sleeper, publisher) + confirm, err := confirmMergeReady(ctx, req, deps.StopRequests, deps.CheckSource, currentHeadSHA, clock, sleeper, publisher) if err != nil { - return Result{Outcome: store.StateFailed, Rounds: round}, err + return resultForError(round, err), err } if confirm.ready { return Result{Outcome: store.StateClean, Rounds: round}, nil @@ -256,9 +266,9 @@ func Run(ctx context.Context, req Request, deps Dependencies) (Result, error) { currentHeadSHA = resolved.HeadSHA } if resolved.Remaining == 0 { - confirm, err := confirmMergeReady(ctx, req, deps.CheckSource, currentHeadSHA, clock, sleeper, publisher) + confirm, err := confirmMergeReady(ctx, req, deps.StopRequests, deps.CheckSource, currentHeadSHA, clock, sleeper, publisher) if err != nil { - return Result{Outcome: store.StateFailed, Rounds: round}, err + return resultForError(round, err), err } if confirm.ready { return Result{Outcome: store.StateClean, Rounds: round}, nil @@ -298,19 +308,28 @@ type settledWaitResult struct { statusChecks int } -func waitForSettled(ctx context.Context, req Request, headSHA string, source StatusSource, clock Clock, sleeper Sleeper, publisher watchEventPublisher) (settledWaitResult, error) { +func waitForSettled(ctx context.Context, req Request, headSHA string, stops StopRequestSource, source StatusSource, clock Clock, sleeper Sleeper, publisher watchEventPublisher) (settledWaitResult, error) { startedAt := clock.Now() statusChecks := 0 for { if err := ctx.Err(); err != nil { return settledWaitResult{}, err } + if err := observeStopRequest(ctx, stops, req.RunID, "before Review Source status"); err != nil { + return settledWaitResult{}, err + } status, err := source.Status(ctx, StatusRequest{ PRNumber: req.PRNumber, HeadSHA: headSHA, }) statusChecks++ if err != nil { + if stopErr := observeStopRequest(ctx, stops, req.RunID, "after failed Review Source status access"); stopErr != nil { + return settledWaitResult{}, stopErr + } + return settledWaitResult{}, err + } + if err := observeStopRequest(ctx, stops, req.RunID, "after Review Source status access"); err != nil { return settledWaitResult{}, err } if err := publisher.publish(ctx, runevent.KindDaemonReviewStatus, @@ -331,6 +350,9 @@ func waitForSettled(ctx context.Context, req Request, headSHA string, source Sta if err := sleeper.Sleep(ctx, req.PollInterval); err != nil { return settledWaitResult{}, err } + if err := observeStopRequest(ctx, stops, req.RunID, "after Review Source status wait"); err != nil { + return settledWaitResult{}, err + } } } @@ -340,7 +362,7 @@ type confirmResult struct { timedOut bool } -func confirmMergeReady(ctx context.Context, req Request, source CheckSource, headSHA string, clock Clock, sleeper Sleeper, publisher watchEventPublisher) (confirmResult, error) { +func confirmMergeReady(ctx context.Context, req Request, stops StopRequestSource, source CheckSource, headSHA string, clock Clock, sleeper Sleeper, publisher watchEventPublisher) (confirmResult, error) { if !req.UntilClean || source == nil { return confirmResult{ready: true}, nil } @@ -349,9 +371,16 @@ func confirmMergeReady(ctx context.Context, req Request, source CheckSource, hea if err := ctx.Err(); err != nil { return confirmResult{}, err } + if err := observeStopRequest(ctx, stops, req.RunID, "before Review Source Merge-Ready check"); err != nil { + return confirmResult{}, err + } missingCheck := false + retrying := false state, err := source.Check(ctx, headSHA) if err == nil { + if err := observeStopRequest(ctx, stops, req.RunID, "after Review Source Merge-Ready check"); err != nil { + return confirmResult{}, err + } if err := publisher.publish(ctx, runevent.KindDaemonReviewStatus, fmt.Sprintf("Review Source check: %s", state), map[string]any{"state": state, "head_sha": headSHA}, @@ -372,11 +401,17 @@ func confirmMergeReady(ctx context.Context, req Request, source CheckSource, hea default: return confirmResult{}, fmt.Errorf("unknown Review Source check state %q", state) } - } else if err := publisher.publish(ctx, runevent.KindDaemonReviewStatus, - "Review Source check poll failed; retrying.", - map[string]any{"head_sha": headSHA, "error": err.Error()}, - ); err != nil { - return confirmResult{}, err + } else { + if stopErr := observeStopRequest(ctx, stops, req.RunID, "after failed Review Source Merge-Ready check"); stopErr != nil { + return confirmResult{}, stopErr + } + if publishErr := publisher.publish(ctx, runevent.KindDaemonReviewStatus, + "Review Source check poll failed; retrying.", + map[string]any{"head_sha": headSHA, "error": err.Error()}, + ); publishErr != nil { + return confirmResult{}, publishErr + } + retrying = true } if !missingCheck && clock.Now().Sub(startedAt) >= req.ReviewTimeout { return confirmResult{timedOut: true}, nil @@ -384,7 +419,36 @@ func confirmMergeReady(ctx context.Context, req Request, source CheckSource, hea if err := sleeper.Sleep(ctx, req.PollInterval); err != nil { return confirmResult{}, err } + operation := "after Merge-Ready wait" + if retrying { + operation = "after transient Review Source retry wait" + } + if err := observeStopRequest(ctx, stops, req.RunID, operation); err != nil { + return confirmResult{}, err + } + } +} + +func observeStopRequest(ctx context.Context, source StopRequestSource, runID string, operation string) error { + if source == nil { + return nil + } + requested, err := source.StopRequested(ctx, runID) + if err != nil { + return fmt.Errorf("observe Stop Request for Run %q %s: %w", runID, operation, err) + } + if requested { + return ErrStopRequested + } + return nil +} + +func resultForError(rounds int, err error) Result { + outcome := store.StateFailed + if errors.Is(err, ErrStopRequested) { + outcome = store.StateStopped } + return Result{Outcome: outcome, Rounds: rounds} } func validateRequest(req Request, deps Dependencies) error { diff --git a/internal/watch/watch_test.go b/internal/watch/watch_test.go index 7d5fbd4a..b322c27d 100644 --- a/internal/watch/watch_test.go +++ b/internal/watch/watch_test.go @@ -3,6 +3,7 @@ package watch import ( "context" "errors" + "strings" "testing" "time" @@ -127,6 +128,204 @@ func TestRunSleepsBetweenStatusChecksAndKeepsQuietPeriodWhenReviewSettlesDuringR assertSleeps(t, sleeper.sleeps, req.PollInterval, req.PollInterval, req.QuietPeriod) } +func TestRunStopRequestDuringStatusWaitStopsAtNextPoll(t *testing.T) { + req := validRequest() + clock := &fakeClock{now: time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)} + stops := &fakeStopRequestSource{} + sleeper := &fakeSleeper{ + clock: clock, + afterSleep: func(time.Duration) { + stops.requested = true + }, + } + status := &fakeStatusSource{ + statuses: []Status{ + {State: StatusPending}, + {State: StatusSettled}, + }, + } + fetcher := &fakeFetcher{} + resolver := &fakeResolver{} + + result, err := Run(context.Background(), req, Dependencies{ + StopRequests: stops, + StatusSource: status, + Fetcher: fetcher, + Resolver: resolver, + Clock: clock, + Sleeper: sleeper, + }) + + if !errors.Is(err, ErrStopRequested) { + t.Fatalf("expected Stop Request classification, got result=%#v err=%v", result, err) + } + if result.Outcome != store.StateStopped || result.Rounds != 0 { + t.Fatalf("expected Stopped before a fetched Round, got %#v", result) + } + if stops.calls != 3 || status.calls != 1 { + t.Fatalf("expected Stop Request after one status poll, got stop=%d status=%d", stops.calls, status.calls) + } + if fetcher.calls != 0 || resolver.calls != 0 { + t.Fatalf("Stop Request must block later work, got fetch=%d resolve=%d", fetcher.calls, resolver.calls) + } + assertSleeps(t, sleeper.sleeps, req.PollInterval) +} + +func TestRunStopRequestDuringQuietPeriodStopsBeforeFetch(t *testing.T) { + req := validRequest() + clock := &fakeClock{now: time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)} + stops := &fakeStopRequestSource{} + sleeper := &fakeSleeper{ + clock: clock, + afterSleep: func(duration time.Duration) { + if duration == req.QuietPeriod { + stops.requested = true + } + }, + } + status := &fakeStatusSource{ + statuses: []Status{ + {State: StatusPending}, + {State: StatusSettled}, + }, + } + fetcher := &fakeFetcher{} + resolver := &fakeResolver{} + + result, err := Run(context.Background(), req, Dependencies{ + StopRequests: stops, + StatusSource: status, + Fetcher: fetcher, + Resolver: resolver, + Clock: clock, + Sleeper: sleeper, + }) + + if !errors.Is(err, ErrStopRequested) { + t.Fatalf("expected Stop Request classification, got result=%#v err=%v", result, err) + } + if result.Outcome != store.StateStopped || result.Rounds != 0 { + t.Fatalf("expected Stopped before fetch, got %#v", result) + } + if stops.calls != 6 || status.calls != 2 { + t.Fatalf("expected Stop Request after quiet-period sleep, got stop=%d status=%d", stops.calls, status.calls) + } + if fetcher.calls != 0 || resolver.calls != 0 { + t.Fatalf("quiet-period Stop Request must block later work, got fetch=%d resolve=%d", fetcher.calls, resolver.calls) + } + assertSleeps(t, sleeper.sleeps, req.PollInterval, req.QuietPeriod) +} + +func TestRunStopRequestDuringTransientRetryStopsBeforeNextCheck(t *testing.T) { + req := validRequest() + clock := &fakeClock{now: time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)} + stops := &fakeStopRequestSource{} + sleeper := &fakeSleeper{ + clock: clock, + afterSleep: func(time.Duration) { + stops.requested = true + }, + } + status := &fakeStatusSource{statuses: []Status{{State: StatusSettled}}} + fetcher := &fakeFetcher{results: []FetchResult{{Round: 1, Issues: 0}}} + check := &fakeCheckSource{err: errors.New("temporary Review Source failure")} + resolver := &fakeResolver{} + + result, err := Run(context.Background(), req, Dependencies{ + StopRequests: stops, + StatusSource: status, + Fetcher: fetcher, + Resolver: resolver, + CheckSource: check, + Clock: clock, + Sleeper: sleeper, + }) + + if !errors.Is(err, ErrStopRequested) { + t.Fatalf("expected Stop Request classification, got result=%#v err=%v", result, err) + } + if result.Outcome != store.StateStopped || result.Rounds != 1 { + t.Fatalf("expected Stopped after one fetched Round, got %#v", result) + } + if stops.calls != 5 || check.calls != 1 { + t.Fatalf("expected Stop Request after one retry sleep, got stop=%d check=%d", stops.calls, check.calls) + } + if fetcher.calls != 1 || resolver.calls != 0 { + t.Fatalf("retry Stop Request must block later work, got fetch=%d resolve=%d", fetcher.calls, resolver.calls) + } + assertSleeps(t, sleeper.sleeps, req.PollInterval) +} + +func TestRunStopRequestDuringMergeReadyWaitStopsBeforeNextCheck(t *testing.T) { + req := validRequest() + clock := &fakeClock{now: time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)} + stops := &fakeStopRequestSource{} + sleeper := &fakeSleeper{ + clock: clock, + afterSleep: func(time.Duration) { + stops.requested = true + }, + } + status := &fakeStatusSource{statuses: []Status{{State: StatusSettled}}} + fetcher := &fakeFetcher{results: []FetchResult{{Round: 1, Issues: 0}}} + check := &fakeCheckSource{states: []HeadCheckState{CheckPending, CheckSuccess}} + resolver := &fakeResolver{} + + result, err := Run(context.Background(), req, Dependencies{ + StopRequests: stops, + StatusSource: status, + Fetcher: fetcher, + Resolver: resolver, + CheckSource: check, + Clock: clock, + Sleeper: sleeper, + }) + + if !errors.Is(err, ErrStopRequested) { + t.Fatalf("expected Stop Request classification, got result=%#v err=%v", result, err) + } + if result.Outcome != store.StateStopped || result.Rounds != 1 { + t.Fatalf("expected Stopped after one fetched Round, got %#v", result) + } + if stops.calls != 5 || check.calls != 1 { + t.Fatalf("expected Stop Request after one Merge-Ready sleep, got stop=%d check=%d", stops.calls, check.calls) + } + if fetcher.calls != 1 || resolver.calls != 0 { + t.Fatalf("Merge-Ready Stop Request must block later work, got fetch=%d resolve=%d", fetcher.calls, resolver.calls) + } + assertSleeps(t, sleeper.sleeps, req.PollInterval) +} + +func TestRunStopRequestSourceFailureIncludesRunAndOperation(t *testing.T) { + req := validRequest() + sourceErr := errors.New("Run Database unavailable") + stops := &fakeStopRequestSource{err: sourceErr, errAtCall: 1} + status := &fakeStatusSource{statuses: []Status{{State: StatusSettled}}} + + result, err := Run(context.Background(), req, Dependencies{ + StopRequests: stops, + StatusSource: status, + Fetcher: &fakeFetcher{}, + Resolver: &fakeResolver{}, + }) + + if !errors.Is(err, sourceErr) { + t.Fatalf("expected Store observation failure, got result=%#v err=%v", result, err) + } + if errors.Is(err, ErrStopRequested) { + t.Fatalf("Store observation failure must not classify as requested stop: %v", err) + } + if result.Outcome != store.StateFailed { + t.Fatalf("expected Failed observation result, got %#v", result) + } + if !strings.Contains(err.Error(), req.RunID) || !strings.Contains(err.Error(), "before Review Source status") { + t.Fatalf("expected Run and operation context, got %q", err) + } + if status.calls != 0 { + t.Fatalf("observation failure must block Review Source status, got %d calls", status.calls) + } +} + func TestRunTimesOutAndOffersManualReviewTrigger(t *testing.T) { req := validRequest() req.ReviewTimeout = 2 * time.Second @@ -208,7 +407,7 @@ func TestRunStopsAtMaxRoundsReached(t *testing.T) { } } -func TestRunStopsWhenBudgetExceeded(t *testing.T) { +func TestRunWithoutStopRequestKeepsRunBudgetBehavior(t *testing.T) { req := validRequest() req.BudgetEnabled = true req.MaxRunDuration = 2 * time.Second @@ -226,6 +425,7 @@ func TestRunStopsWhenBudgetExceeded(t *testing.T) { resolver := &fakeResolver{} result, err := Run(context.Background(), req, Dependencies{ + StopRequests: &fakeStopRequestSource{}, StatusSource: status, Fetcher: fetcher, Resolver: resolver, @@ -573,6 +773,7 @@ func TestRunDoesNotConfirmMergeReadinessWithoutUntilClean(t *testing.T) { func validRequest() Request { return Request{ + RunID: "run_123", PRNumber: "123", HeadSHA: "abc123", UntilClean: true, @@ -599,9 +800,10 @@ func (clock *fakeClock) Advance(duration time.Duration) { } type fakeSleeper struct { - clock *fakeClock - err error - sleeps []time.Duration + clock *fakeClock + err error + afterSleep func(time.Duration) + sleeps []time.Duration } func (sleeper *fakeSleeper) Sleep(_ context.Context, duration time.Duration) error { @@ -612,6 +814,9 @@ func (sleeper *fakeSleeper) Sleep(_ context.Context, duration time.Duration) err if sleeper.clock != nil { sleeper.clock.Advance(duration) } + if sleeper.afterSleep != nil { + sleeper.afterSleep(duration) + } return nil } @@ -657,6 +862,21 @@ func (source *fakeStatusSource) Status(context.Context, StatusRequest) (Status, return status, nil } +type fakeStopRequestSource struct { + err error + errAtCall int + requested bool + calls int +} + +func (source *fakeStopRequestSource) StopRequested(context.Context, string) (bool, error) { + source.calls++ + if source.err != nil && source.calls == source.errAtCall { + return false, source.err + } + return source.requested, nil +} + type fakeCheckSource struct { err error calls int diff --git a/skills/roundfix/SKILL.md b/skills/roundfix/SKILL.md index 7418f437..36556c09 100644 --- a/skills/roundfix/SKILL.md +++ b/skills/roundfix/SKILL.md @@ -532,25 +532,45 @@ Stop Request recorded; the Run stops after the current Work Item settles. The owning Run finishes the in-flight Work Item's verification, settlement, and commit boundary first, then ends Stopped through the normal outcome path. - -Use `roundfix stop --force` only for a dead, stuck, or runaway Run. It cancels -the Agent Session best-effort, completes the Run as Stopped immediately, and -releases Active Run locks. Cancel failures are warnings on stderr and never -block force completion. Force stop then closes discovered roundfix Agent -Sessions for the Run, including Run-level and per-Task sessions. Successful -session closes and close failures are reported on stderr with these shapes: - -```text -roundfix: closed session -roundfix: could not close session : -``` - -The force-stop report title includes: +During a watch Run's Review Source status, retry, quiet-period, or +merge-readiness wait, the owner checks for the Stop Request before the next +status access and after each interruptible sleep. It reaches Stopped by the +next configured poll boundary. After observing the request, it does not run +another fetch, check, commit, push, or Review Source mutation. + +Use `roundfix stop --force` only for a dead, stuck, or runaway Run. It first +validates the recorded owner PID, terminates the recorded owner process, and +proves that process exited. Until owner exit is proven, registered Agent +Sessions and their Agent Selection lifecycles remain active. + +After owner exit proof, Force Stop cancels and closes only registered Agent +Sessions whose latest Agent Selection lifecycle is active. No active lifecycle +record means no session action, and an already-absent registered session is an +idempotent cleanup result. Other cleanup failures remain visible as secondary +warnings. + +Only after owner exit proof does Roundfix complete the Run as Stopped, release +its Active Run lock, and reap eligible kept terminal Worktrees. If owner exit +cannot be proven, Force Stop prints no stdout success report; its diagnostic +names the Run ID, owner PID, and failed process-control step. The Run remains +Active with its Agent Sessions unchanged and its Active Run lock retained. +Inspect it with `roundfix runs list --state active`, resolve the reported +owner-process failure, and retry `roundfix stop --force `. + +After owner exit proof and successful Stopped completion, the force-stop report +title includes: ```text Roundfix Run force-stopped ``` +Terminal completion is compare-and-set. The winning transition alone publishes +the terminal outcome event and notification. Repeating Force Stop for an +already Stopped Run reports the stored outcome without repeating process, +session, event, or notification actions. A different terminal outcome is +rejected and preserved; a losing owner observes that stored outcome and exits +without publishing another terminal event or notification. + When an Active-Run lock records an owner PID and Roundfix can prove that owner process no longer exists, preflight reclaims the orphan automatically: the Run settles Failed, the Run Event Journal records the reclamation, one stderr @@ -592,12 +612,13 @@ The console log path is under the Artifact Directory at stderr, Agent output, and terminal outcome messages. `Follow` is the Attach surface; `Stop` is the Stop Command surface. Detached Runs behave as normal non-TTY Runs after startup: Run Events, Worktrees, integration, outcomes, and -locks keep their normal contracts. The detached child owns completion and sends -the configured outcome notification when the Run reaches its terminal outcome; -use that notification as the unattended-Run signal. Supervisors and scripts -follow `roundfix events --follow` for JSONL state changes, use -`roundfix attach ` for the human Live Run View, and treat the console -log as a compact text record rather than a state API. +locks keep their normal contracts. The detached child that wins terminal +completion sends the configured outcome notification; use that notification as +the unattended-Run signal. A competing completion observes the stored outcome +and does not send another notification. Supervisors and scripts follow +`roundfix events --follow` for JSONL state changes, use the human Live +Run View with `roundfix attach `, and treat the console log as a compact +text record rather than a state API. Detach implies non-interactive mode. `--interactive` is rejected before Run creation, and `--no-input` is implied. Startup uses a two-phase handshake: the @@ -617,10 +638,11 @@ roundfix: Detached Run child exited before the handshake (); con roundfix: Detached Run child exited before the handshake () and produced no output ``` -Operational Runs that reach a terminal outcome through `resolve`, `watch`, or -`implement` send exactly one outcome notification. `fetch`, `settle`, -`archive`, and commands that create no Run do not notify. Notification failures -write one stderr warning shaped as +The winning terminal transition for an operational Run through `resolve`, +`watch`, or `implement` sends exactly one outcome notification. Identical +completion replay and conflicting completion do not republish it. `fetch`, +`settle`, `archive`, and commands that create no Run do not notify. +Notification failures write one stderr warning shaped as `roundfix: outcome notification failed: ` and one Daemon-source Run Event; they never change the Run report, terminal outcome, or exit code. @@ -1193,8 +1215,10 @@ outcome and never opens pull requests (ADR-0021). the current repository. This resolves that repository's Spec target and records a Stop Request; the Run stops after the current Work Item settles. Use `roundfix stop --force --spec ` only for a dead, stuck, or runaway - Run; it cancels the Agent Session best-effort, completes the Run Stopped, - releases its lock immediately, and reaps empty terminal worktree debris. + Run. It proves the recorded owner exited before cleaning up registered + active Agent Sessions, and reports Stopped and releases the Active Run lock + only after that proof. A failed proof leaves the Run Active with its Agent + Sessions unchanged and its lock retained. ## Driving a Spec implementation loop @@ -1224,8 +1248,9 @@ the Implement, Attach, Settle, Stop, and Archive commands documented above. terminal, discover the Run with the bounded `roundfix runs list` or open the Run Browser with `roundfix attach`. Attach replays the Run Event Journal and follows new events; `q` or `Ctrl-C` detaches and never stops - the Run. The detached child sends the configured outcome notification at - the terminal outcome, which is the unattended-Run signal. + the Run. The detached child sends the configured outcome notification only + when it wins terminal completion; that notification is the unattended-Run + signal. 4. **Detect the terminal outcome.** The Run ends with exactly one stdout outcome line in the console log: