diff --git a/.agents/skills/roundfix/SKILL.md b/.agents/skills/roundfix/SKILL.md index 36556c09..be02da5b 100644 --- a/.agents/skills/roundfix/SKILL.md +++ b/.agents/skills/roundfix/SKILL.md @@ -519,6 +519,46 @@ roundfix: warning: Journal Retention prune failed: and never block the Run. +## Run Worktree reconciliation + +Use the Reconcile Command to inspect retained terminal spec Run Worktrees and +Run Branches. Always run a dry-run before applying cleanup: + +```bash +roundfix reconcile +roundfix reconcile +roundfix reconcile --format json +``` + +A Run ID selects one terminal spec Run; omitting it scans the current +repository. The report classifies every selected Run into one of five states: + +| State | Agent action | +| --- | --- | +| `safe` | The Run Branch and recorded target resolve, any present registered Run Worktree is clean, and the Run Branch tip is an ancestor of the target tip. This is the only state eligible for cleanup. | +| `unintegrated` | Clean, resolved evidence proves that the Run Branch tip is not an ancestor of the target tip. Preserve the Run Worktree and Run Branch. | +| `dirty` | A present registered Run Worktree has tracked or untracked changes. Preserve the Run Worktree and Run Branch. | +| `unknown` | Metadata or Git evidence cannot prove another state. Preserve every identified Run Worktree and Run Branch. | +| `released` | Both the Run Worktree and Run Branch are absent. No cleanup is needed. | + +After reviewing the dry-run, apply cleanup explicitly: + +```bash +roundfix reconcile --apply +roundfix reconcile --apply +``` + +`--apply` is the only mutation switch. Roundfix acts only on entries classified +`safe` during that invocation and rechecks their metadata, worktree +cleanliness, heads, and ancestry before mutation. It preserves `unintegrated`, +`dirty`, and `unknown` work, and treats `released` as an idempotent no-op. +There is no force bypass. + +Never substitute manual Git deletion for this supported workflow. Do not run +`git worktree remove`, delete the Run Branch, or remove a recorded worktree +directory by hand. The Reconcile Command owns safety proof, evidence recording, +the guarded Integration Pending transition, and cleanup. + ## Stopping Runs Use `roundfix stop` for a graceful stop. Every selector keeps its existing diff --git a/.roundfixrc.yml b/.roundfixrc.yml index c8ec5f1b..910533a5 100644 --- a/.roundfixrc.yml +++ b/.roundfixrc.yml @@ -58,7 +58,7 @@ profiles: review_source: name: coderabbit - include_nitpicks: false + include_nitpicks: true watch: until_clean: true diff --git a/CONTEXT.md b/CONTEXT.md index 7897f071..ae331066 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -185,7 +185,7 @@ The named branch (`roundfix/run-`) that carries a spec Run's commits inside _Avoid_: Temp branch, detached HEAD, feature branch **Run Worktree Reconciliation**: -The proof-based classification of a terminal spec Run's retained Run Worktree and Run Branch as `safe`, `unintegrated`, `dirty`, `unknown`, or `released`, followed by optional cleanup only for `safe` work. +The proof-based classification of a terminal spec Run's retained Git surfaces: `safe` when the Run Branch and recorded target resolve, any present Run Worktree is registered and clean, and the Run Branch tip is an ancestor of the target tip; `unintegrated` when the same evidence resolves but ancestry is false; `dirty` when a present Run Worktree has tracked or untracked changes; `unknown` when metadata or Git evidence cannot prove another state; and `released` only when both the Run Worktree and Run Branch are absent. Only `safe` work can be cleaned up, after the proof is rechecked. _Avoid_: GC, force cleanup, manual branch deletion **Integration Pending**: @@ -339,7 +339,7 @@ The local recovery command that re-runs one failed Task's Verification commands _Avoid_: Retry command, auto-settle, task fix command **Reconcile Command**: -The support command that inspects terminal spec Run Worktrees and Run Branches, reports their Run Worktree Reconciliation state, and removes only `safe` work when explicitly invoked with `--apply`. +The support command that inspects terminal spec Run Worktrees and Run Branches and reports their Run Worktree Reconciliation state. It is read-only by default; `--apply` is its only mutation switch, removes only freshly revalidated `safe` work, and has no force bypass. _Avoid_: GC Command, Settle Command, automatic integration **Reprocess Command**: diff --git a/docs/findings/2026-07-27-bare-go-build-writes-an-untracked-root-binary.md b/docs/findings/2026-07-27-bare-go-build-writes-an-untracked-root-binary.md new file mode 100644 index 00000000..faf3897b --- /dev/null +++ b/docs/findings/2026-07-27-bare-go-build-writes-an-untracked-root-binary.md @@ -0,0 +1,62 @@ +--- +status: pending +created_at: 2026-07-27 +updated_at: 2026-07-27 +--- + +# Build hygiene — `go build ./cmd/roundfix` drops a 20 MiB binary at the repository root, and nothing ignores it (2026-07-27) + +`.gitignore` ignores `/bin/`, which is where the Makefile writes its build +output. It does not ignore `/roundfix`, which is where `go build ./cmd/roundfix` +writes when invoked without `-o`. Agents run the bare form routinely as a +compile check, so the artifact appears unignored and untracked in a working tree +that Daemon commits stage by diff. + +On 2026-07-27 that is exactly what happened: Spec 0038 Task 04's commit +`0d56f42` shipped a 20.1 MiB Mach-O arm64 executable at the repository root. The +Spec 0038 QA gate caught it as `RFQA-0038-01` and failed the gate. Neither +`make verify` nor CodeRabbit would have caught it — it is not a code error but a +scope-integrity problem, and only the QA gate's changed-path audit compares what +a Spec delivers against what it declared. + +## Why the obvious fix needs authorization + +Adding `/roundfix` to `.gitignore` closes the hole in one line. But +`docs/agents/agent-instructions.md:27` prohibits editing any "ignore file" +without express maintainer authorization, and no Spec's Tooling authority entry +covers `.gitignore`. + +This finding records that boundary honestly: during the repair of +`RFQA-0038-01`, the supervisor removed the stray binary **and** added the ignore +entry. The next QA run flagged the ignore edit as an unauthorized protected +tooling mutation, and the entry was reverted, leaving only the binary removal — +which is all the QA finding actually required. The gate was right, and the +recurrence risk is still open. + +## Suggested resolution + +1. With maintainer authorization, add `/roundfix` to `.gitignore` with a comment + naming its source, so a bare build cannot be swept into a commit again. +2. Consider whether the Daemon's commit staging should refuse to stage + executable files outright. A build artifact is never legitimate Task output, + and a guard there protects every repository rather than this one path in this + one repository. +3. Consider making the bare `go build ./cmd/roundfix` unnecessary — a documented + compile-check target (`make build` already exists and writes to the ignored + path) that guides Agents away from the form that litters the tree. + +## Suggested acceptance checks + +- Running `go build ./cmd/roundfix` leaves no path that `git status` reports as + untracked. +- A Task commit that would include an executable file is refused or reported. +- The Spec 0038 QA scope audit passes on a rerun. + +## What worked — keep + +- The QA gate's scope-integrity audit is the only check that caught this, and it + caught it by comparing delivered paths against declared Spec scope. Keep that + step; it finds a class of defect the test suite and code review cannot. +- The gate also caught the supervisor's own over-reach on the repair commit, + which is the behavior you want from an authorization audit that is not + deferential to whoever is driving. diff --git a/docs/findings/2026-07-27-claude-adapter-standardization.md b/docs/findings/2026-07-27-claude-adapter-standardization.md new file mode 100644 index 00000000..6c74781d --- /dev/null +++ b/docs/findings/2026-07-27-claude-adapter-standardization.md @@ -0,0 +1,166 @@ +--- +status: pending +created_at: 2026-07-27 +updated_at: 2026-07-27 +--- + +# Agent Selection — standardize on the official Claude adapter and stop reading `[...]` as reasoning effort (2026-07-27) + +The maintainer directs Roundfix to drop every reference to +`@zed-industries/claude-code-acp` and support only +`@agentclientprotocol/claude-agent-acp`, including Doctor and Setup. This +finding records the empirical verification that the replacement works, the +exact code sites that still name the deprecated package, and one **new +blocking defect** that only becomes visible after the migration: Roundfix +misreads the replacement's advertised model identifiers. + +This continues +[2026-07-26 — the configured Claude ACP adapter is deprecated](2026-07-26-claude-adapter-configoptions-migration.md), +which proposed the migration while `claude-agent-acp` was still uninstalled. +That finding's premise that `claude-opus-5` "is advertised and gets selected +successfully" describes the deprecated adapter only, and no longer holds — see +section 3. + +No repository change was made: an Implement Run for Spec +`0038-terminal-run-worktree-reconciliation` was active, and editing tracked +files during a Run strands it in Integration Pending. + +## Session evidence + +- Repository `/Users/marcio/dev/roundfix` at `8ec92ad`, macOS 25.5.0. +- `acpx 0.12.1`. `codex-acp` resolves to `@agentclientprotocol/codex-acp@1.1.7`. +- `@agentclientprotocol/claude-agent-acp@0.63.0` installed globally during this + session; `~/.acpx/config.json` `agents.claude.command` repointed from + `claude-code-acp` to `claude-agent-acp`. Prior config backed up. +- Every proof below ran through + `roundfix profiles configure --scope project --file --dry-run --json`, + which proves an exact tuple without writing config or creating a Run. From + inside a Claude Code session the probe needs + `env -u CLAUDECODE -u CLAUDE_CODE_ENTRYPOINT -u CLAUDE_CODE_SSE_PORT`. + +## 1. The replacement adapter resolves the capability failure + +With the deprecated adapter, every Claude tuple failed identically — +including `reasoning_effort: ""`, which rules out model and effort as causes: + +```text +classification: capability_evidence_invalid +adapter error: capability evidence invalid: missing_config_options +``` + +With `@agentclientprotocol/claude-agent-acp@0.63.0`, the same probe reaches +model evaluation and reports the controls it advertises: + +```text +advertised Agent Models: default, opus[1m], claude-fable-5[1m], sonnet, haiku +advertised reasoning efforts: default, low, medium, high, xhigh, max +``` + +`claude / sonnet / xhigh` proves clean. Claude is a usable runtime again, and +the migration is confirmed as the correct fix. + +## 2. Roundfix reads `[...]` as reasoning effort, so `opus[1m]` is unselectable + +`parseModelCapability` (`internal/agent/selection_capabilities.go:469`) treats +any advertised identifier shaped `name[suffix]` as Roundfix's model-variant +encoding `canonical[effort]`. The replacement adapter uses that bracket for +the **1M context window**, not for effort. The collision is proven: + +| Requested selection | Result | +| --- | --- | +| `claude / opus[1m] / xhigh` | fails `model_not_advertised` — although `opus[1m]` is in the advertised list | +| `claude / opus / 1m` | **passes** — Roundfix parsed canonical `opus` + effort `1m` | +| `claude / sonnet / xhigh` | passes — no brackets, so the independent reasoning control is used | + +Two consequences. A user cannot select a model by the identifier the adapter +actually advertises. And the only route to Opus today is to write a context +window into `reasoning_effort`, which forces the model-variant encoding and +therefore **discards explicit effort control** — the Task runs at the adapter's +default effort, not the configured one. + +The adapter advertises an independent reasoning select *and* bracketed model +identifiers at the same time. That combination is what Roundfix does not model: +when an independent reasoning control exists, the model identifier must be +treated as opaque instead of being parsed for an embedded effort. + +## 3. `claude-opus-5` is not advertised by the replacement adapter + +The advertised set is `default, opus[1m], claude-fable-5[1m], sonnet, haiku`. +Both `.roundfixrc.yml` files that pin `frontend` — this repository's and +`fluxus`'s — request `claude / claude-opus-5 / xhigh`, which no adapter +lineage advertises. Those profiles stay broken after the migration until they +are repointed, so any Spec with a `frontend` Task is blocked in both +repositories. Repointing is a supervisor-owned boundary commit, not Run work. + +## 4. Code sites that still name the deprecated package + +Codex has a complete lineage contract; Claude has none — only a bare command +name, which is why a same-named binary of either lineage passes today. + +| Location | Current value | Problem | +| --- | --- | --- | +| `internal/agent/acpx_runner.go:54` | `"claude": "claude-code-acp"` | default adapter command is the deprecated lineage | +| `internal/agent/acpx_runner.go:59` | `"claude-code-acp": "npm install -g @zed-industries/claude-code-acp"` | install hint installs the deprecated package | +| `internal/agent/acpx_runner.go:60` | `"claude-agent-acp": "npm install -g @zed-industries/claude-agent-acp"` | **wrong scope** — resolves to `@zed-industries/claude-agent-acp@0.23.1`, not the official `@agentclientprotocol/claude-agent-acp@0.63.0` | +| `internal/agent/acpx_runner.go:27-30` | `CodexAdapterPackage`, `PinnedCodexAdapterVersion`, `legacyCodexAdapterPackage`, `defaultCodexAdapterCommand` | Codex-only; no Claude equivalent exists | +| `docs/adr/0040-reasoning-effort-is-assigned-only-when-configured.md:7` | "claude-code-acp does not implement the reasoning config option at all" | premise is now false for the official adapter | + +Test fixtures naming the deprecated command: +`internal/agent/acpx_runner_test.go:175,2693` and +`internal/cli/implement_test.go:4163`. + +Both packages named `claude-agent-acp` exist on npm, so a lineage check cannot +rely on the command name alone: `@zed-industries/claude-agent-acp@0.23.1` and +`@agentclientprotocol/claude-agent-acp@0.63.0` are different lineages. + +## Implementation instructions + +1. Introduce the Claude counterparts of the Codex lineage constants — + official package `@agentclientprotocol/claude-agent-acp`, a pinned minimum + version, and `@zed-industries/claude-code-acp` plus + `@zed-industries/claude-agent-acp` as recognized legacy lineages. Make the + default Claude adapter command the official pinned form, matching how Codex + resolves `npx -y @agentclientprotocol/codex-acp@`. +2. Extend Adapter Readiness so Doctor and Setup prove Claude lineage the same + way they prove Codex: report the effective command, resolved package, and + version; classify a legacy lineage as a migration with the official install + action; and never accept a matching executable name as proof. +3. Fix the wrong-scope install command at `acpx_runner.go:60`. +4. Stop parsing `[...]` as an embedded reasoning effort when the adapter + advertises an independent reasoning control; treat the model identifier as + opaque so `opus[1m]` selects with an explicit effort. Keep the existing + variant encoding for adapters that genuinely use it, and cover both shapes + with tests. +5. Supersede ADR-0040's claim about Claude reasoning support, recording that + the official adapter advertises the reasoning control. +6. Update the operator docs and the Roundfix Skill pair so the Claude adapter + contract reads like the Codex one. The Skill pair edit changes its + `contentDigest` and requires the derived baseline/catalog digest pins to be + propagated under express authorization — see the Tooling authority entries + of archived Spec `0037-terminal-outcome-integrity`. + +## Suggested acceptance checks + +- Doctor on a machine whose `claude` command resolves to either + `@zed-industries` lineage fails with the official install action and does not + accept the name as proof. +- Doctor with `@agentclientprotocol/claude-agent-acp` at or above the pin + reports `adapter: ok` naming package and version for Claude as it does for + Codex. +- `claude / opus[1m] / xhigh` proves clean and applies `xhigh` through the + independent reasoning control. +- `claude / opus / 1m` no longer resolves an effort of `1m`. +- An adapter that really uses the `canonical[effort]` variant encoding keeps + working. +- No source, test, or documentation path outside `docs/findings/` and archived + Specs mentions `@zed-industries/claude-code-acp`. + +## What worked — keep + +- The dry-run tuple proof + (`profiles configure --dry-run`) diagnoses selection problems without writing + config or starting a Run, and its classifications (`missing_config_options` + versus `model_not_advertised`) were precise enough to separate an adapter + capability gap from a parsing defect. +- Roundfix's error message listing advertised models and efforts is what made + the bracket collision visible; keep that detail in selection failures. diff --git a/docs/findings/2026-07-27-derived-skill-digest-pins-have-no-regeneration-path.md b/docs/findings/2026-07-27-derived-skill-digest-pins-have-no-regeneration-path.md new file mode 100644 index 00000000..ccaacbbc --- /dev/null +++ b/docs/findings/2026-07-27-derived-skill-digest-pins-have-no-regeneration-path.md @@ -0,0 +1,111 @@ +--- +status: pending +created_at: 2026-07-27 +updated_at: 2026-07-27 +--- + +# Skill edits — derived digest pins have no regeneration path and block their own Task three times per day (2026-07-27) + +Editing a Roundfix-owned Skill changes its `contentDigest`. Five Baseline +artifacts pin that digest, and nothing in the repository regenerates them, so +every Skill edit fails `make verify` until a human hand-propagates five hashes. +Because the pins are not part of any Spec's tooling authorization, the Task that +edits the Skill also fails its own changed-path check. This blocked work three +separate times on 2026-07-27 — Spec 0037 task_07, the Spec 0037 QA gate, and +Spec 0038 task_07 — each time costing a failed Run, a manual digest chase, and a +Spec amendment. + +The Agents are behaving correctly every time: they refuse to touch paths their +Task does not authorize, and they report the blocker instead of widening their +own scope. The defect is that Roundfix asks a bounded Task to maintain derived +data by hand. + +## The chain + +One Skill edit requires five coordinated updates, each derived from the last: + +1. `internal/baseline/assets/setups/typescript-bun.json` — the roundfix entry's + `contentDigest`. +2. The same file's top-level `digest`, recomputed over the setup payload. +3. `internal/baseline/testdata/catalog.normalized.json` — regenerated from + `Catalog.Normalized()`. +4. `internal/baseline/testdata/catalog.digest` — regenerated from + `Catalog.Digest()`. +5. `internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json` + (`contentDigest`) and + `internal/baseline/testdata/parity-corpus/v1/manifest.json` (the sha256 row + for that fixture). + +Missing any one leaves `make verify` red. The failures surface as +`catalog.setup.digest.mismatch` and `TestAuthorialSkillSync/typescript-bun.json` +in `roundfix/skills`, plus roughly twenty `internal/baseline` failures reported +as `load embedded Baseline catalog: validate Baseline catalog: …`. That fan-out +reads like a broken catalog rather than a stale snapshot, which is why the first +diagnosis costs real time. + +`make skills-sync` does not help: it only copies `.agents/skills/` to +`skills/`. No target regenerates the pins. + +## Evidence + +- 2026-07-27, Spec 0037 task_07 settled `failed`: "Updating that snapshot is + outside Task 07's explicit allowlist." Recovered by hand-propagating the chain + and widening the Task's Verification allowlist. +- The same day, the Spec 0037 QA gate returned `verdict: fail` on finding F-01 + because the resulting commit changed five paths absent from the PRD and + TechSpec tooling bounds. It blocked all 61 real-application rows and forced a + Spec amendment plus a full QA rerun. +- The same day, Spec 0038 task_07 settled `failed` for the identical reason, + ending the Run `Unresolved` with six completed Tasks left unintegrated. + +Three occurrences, one root cause, three separate manual recoveries. + +## Why the current shape keeps failing + +- **The data is derived but maintained by hand.** Every value above is + computable from the Skill files. A human transcribing five hashes is a + reliability problem, not a review problem. +- **The authorization boundary is drawn around causes, not effects.** A Spec + authorizes editing two Skill files; the digest pins are the deterministic + effect of that authorized edit. Requiring separate express authorization for + an effect that cannot be avoided turns every Skill edit into a governance + amendment. +- **The Task's own Verification enforces the narrow list.** Its changed-path + `git status` check and `make verify` are mutually unsatisfiable whenever the + Skill changes, so the Task cannot pass however correct its work is. + +## Suggested resolution + +1. Add a regeneration target — `make baseline-digests` or an extension of + `make skills-sync` — that recomputes all five pins from the canonical Skill + sources, and have `make verify` (or a check target) fail with + `run 'make baseline-digests'` instead of raw digest mismatches. This alone + removes the manual chase. +2. Decide the authorization policy once, in `docs/agents/`, rather than per + Spec: derived digest pins regenerated by the sanctioned command are fallout + of an authorized Skill edit and need no separate express authorization. Then + the Skill-alignment Task template can stop enumerating them. +3. Until (2) lands, generate the Skill-alignment Task's changed-path allowlist + from the same list the tooling knows about, so the Task and the gate cannot + disagree. +4. Consider whether the parity-corpus fixture and manifest need to pin a + Roundfix-owned Skill digest at all, or whether that fixture can reference the + canonical source, shrinking the chain from five artifacts to one. + +## Suggested acceptance checks + +- Editing a Roundfix-owned Skill and running the sanctioned regeneration command + leaves `make verify` green with no hand-edited hashes. +- A stale pin fails with a message naming the regeneration command. +- A Skill-alignment Task completes without a Spec amendment. +- The QA gate's tooling audit passes for a commit containing a Skill edit plus + its regenerated pins. + +## What worked — keep + +- The Agents' refusal to exceed their authorization was correct every time and + should not be softened; the fix belongs in tooling and policy, not in loosening + Task boundaries. +- `TestAuthorialSkillSync` reports the exact canonical digest it wants, which + makes the manual repair possible at all. Keep that precision when the + regeneration target lands. diff --git a/docs/findings/2026-07-27-owner-identity-forks-ps-and-fails-closed-under-load.md b/docs/findings/2026-07-27-owner-identity-forks-ps-and-fails-closed-under-load.md new file mode 100644 index 00000000..bda0b780 --- /dev/null +++ b/docs/findings/2026-07-27-owner-identity-forks-ps-and-fails-closed-under-load.md @@ -0,0 +1,109 @@ +--- +status: pending +created_at: 2026-07-27 +updated_at: 2026-07-27 +--- + +# Force Stop — owner identity forks `/usr/bin/ps`, so the escape hatch fails exactly when the host is loaded (2026-07-27) + +Spec 0037 gave Force Stop a real ownership proof: Runs record an opaque owner +start-time identity, and Force Stop refuses to signal a PID whose live identity +does not match. The proof is correct, but it is obtained by forking +`/usr/bin/ps` on every read. When the host cannot fork, the proof fails, and +Force Stop fails closed — refusing to stop the Run. + +That inverts the availability property the command exists for. +`roundfix stop --force` is the escape hatch for a dead, stuck, or **runaway** +Run, and a runaway Run is precisely what exhausts a host's process slots. The +harder the machine is struggling, the less able Roundfix is to stop the Run +causing it. + +This is not hypothetical. During the Spec 0038 Implement Run on 2026-07-27, +five store/CLI tests failed inside the Daemon's own `make verify` because the +host could not fork `/usr/bin/ps`. The same code path serves production Force +Stop. + +## Mechanism + +`OwnerProcessControl.proveOwner` (`internal/store/process.go:80`): + +```go +liveIdentity, identityErr := processStartIdentity(ctx, pid) +if identityErr != nil { + // exited between checks? absence is its own proof + 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)) +} +``` + +`processStartIdentity` runs `ps -p -o lstart=` +(`internal/store/process_unix.go`). A transient +`fork/exec /usr/bin/ps: resource temporarily unavailable` is therefore +indistinguishable, in outcome, from a genuine PID-reuse refusal: the Run stays +Active, its lock is retained, and the operator is told the owner cannot be +proven. Retrying is the documented advice, but the retry forks again under the +same pressure. + +## Second, quieter consequence + +The same helper captures the identity when a Run is created. A capture failure +records NULL, which by design degrades to the legacy PID-only proof. Under host +pressure a Run can therefore be created **without** reuse protection, silently +and permanently, with no warning at creation and no indication later that the +Run is running unprotected. The degradation path exists for legacy rows +predating the column; it should not be reachable by transient load on a current +binary. + +## Why forking is avoidable + +The value is only ever compared for equality — it is an opaque token, never +parsed. Both supported platforms expose process start time without spawning +anything: + +- Linux: field 22 (`starttime`) of `/proc//stat`. +- macOS: `sysctl` `KERN_PROC_PID` → `kinfo_proc.kp_proc.p_starttime`. + +Either yields a stabler token than `ps` output, needs no subprocess, and +removes the `TZ`/`LC_ALL` pinning that CodeRabbit already had to request on +PR #38 to keep the rendered text stable across locale changes. + +## Suggested resolution + +1. Replace the `ps` fork with a syscall/procfs read behind the existing + build-tag structure, keeping the token opaque and equality-compared and + leaving the Windows and non-Unix stubs as they are. +2. Separate "identity mismatch" from "identity unreadable" in the failure + classification. A proven mismatch must keep failing closed. An unreadable + identity is a different condition and deserves its own diagnostic naming the + host resource failure, so an operator is not told to suspect PID reuse when + the machine simply could not fork. +3. Make a failed identity capture at Run creation visible — one warning at + startup and a recorded marker — so a Run running without reuse protection is + never silent. Reserve the NULL degradation for genuinely legacy rows. +4. Decide, explicitly, whether an unreadable identity on a machine under + pressure should keep failing closed. Failing closed is right when ownership + is genuinely unknown, but the operator needs a documented way to stop a + runaway Run when the host cannot answer at all. + +## Suggested acceptance checks + +- Owner identity is captured and compared with no subprocess spawned, proven by + a test that fails if the implementation execs anything. +- Under simulated fork exhaustion, Force Stop distinguishes an unreadable + identity from a mismatch and each carries its own diagnostic. +- A Run whose identity capture fails at creation emits a warning and is + observably marked as unprotected. +- The existing reuse-refusal, matching-identity, absent-owner, and legacy + no-token cases keep their current behavior. +- The full suite passes under parallel load without `ps`-related failures. + +## What worked — keep + +- Failing closed on a proven identity mismatch is correct and must survive any + fix here; the problem is only that unreadable and mismatched share one + outcome. +- Recording absence as its own proof (the owner exiting between the liveness + check and the identity read) is right and already handled. diff --git a/docs/findings/2026-07-27-sandboxed-agents-cannot-reach-the-default-go-cache.md b/docs/findings/2026-07-27-sandboxed-agents-cannot-reach-the-default-go-cache.md new file mode 100644 index 00000000..1edfe0ab --- /dev/null +++ b/docs/findings/2026-07-27-sandboxed-agents-cannot-reach-the-default-go-cache.md @@ -0,0 +1,68 @@ +--- +status: pending +created_at: 2026-07-27 +updated_at: 2026-07-27 +--- + +# Verification — sandboxed Agents cannot reach the default Go build cache, and nothing tells them so (2026-07-27) + +`make verify` is the authoritative gate for this repository, but an Agent +running inside the ACP sandbox cannot always reach the default `GOCACHE` +(`~/Library/Caches/go-build` on macOS). When the sandbox denies it, the gate +fails **before compilation**, producing a failure that looks like a broken build +rather than a denied path. + +Nothing in `docs/agents/go.md`, `AGENTS.md`, or the Roundfix Skill mentions +`GOCACHE`, so each Agent rediscovers the problem independently — or does not, +and reports a false gate failure. + +## Evidence + +- Spec 0037 QA gate, 2026-07-27: the Agent ran + `rtk env GOCACHE=/private/tmp/roundfix-qa-0037-gocache make verify` and the + gate passed. It had worked the problem out on its own. +- Spec 0038 QA gate rerun, 2026-07-27: a different Agent ran `make verify` + directly and reported it "blocked before compilation by sandbox denial of the + host Go cache." Flow QA rows were skipped as a consequence, so the verdict + rested on an unexecuted gate. +- Every supervisor-delegated repair in this session had to pass a portable + `GOCACHE` explicitly to get a clean run. +- The repository already carries scar tissue from the same class of problem: + commit `f98a12f`, "fix: use portable Go cache in CLI test." + +## Why this matters more than a flaky command + +The gate is what decides whether a Task settles `completed` and whether QA +passes. A gate that fails for environmental reasons produces two bad outcomes +that are hard to tell apart from real ones: a Task settled `failed` whose work +was correct, and a QA verdict derived from checks that never ran. Both cost a +full recovery cycle, and both look exactly like product defects in the report. + +## Suggested resolution + +1. Make the cache location deterministic for Agents rather than discovered: + have the Makefile default `GOCACHE` to a repository-local, gitignored path + when it is unset, so `make verify` behaves identically inside and outside the + sandbox. This needs maintainer authorization, since the Makefile and ignore + files are protected tooling. +2. Failing that, document the requirement once in `docs/agents/go.md` and in the + Roundfix Skill's verification guidance, so every Task and QA Agent sets it + the same way. +3. Teach the Daemon to distinguish an environment denial from a genuine + verification failure. A gate that never compiled should not settle a Task + `failed` with a reason that implies the code is broken; it should surface the + denied path and the remediation. + +## Suggested acceptance checks + +- `make verify` succeeds from inside a Task Worktree under the ACP sandbox with + no environment variables set by the Agent. +- A denied cache path produces a distinct, actionable diagnostic rather than a + compilation failure. +- Two Agents running the gate on the same commit reach the same verdict. + +## What worked — keep + +- Passing an explicit portable `GOCACHE` is a reliable workaround today and + should stay documented even after the default is fixed, for anyone running the + gate in an unusual sandbox. diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/task_01.md b/docs/specs/0038-terminal-run-worktree-reconciliation/task_01.md deleted file mode 100644 index d89a0675..00000000 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/task_01.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -task: task_01 -spec: 0038-terminal-run-worktree-reconciliation -status: pending -type: backend -complexity: high ---- - -# Task 01: Classify terminal Run Worktrees with positive proof - -## Overview - -Add one conservative classifier for terminal spec Run Worktrees and Run -Branches. It derives current reconciliation state from recorded Run metadata -and real Git evidence so every consumer receives the same safe, unintegrated, -dirty, unknown, or released result. - -## Requirements - -1. MUST inspect terminal spec Runs from their recorded Git root, worktree, - Run Branch, and target branch metadata. -2. MUST classify `safe` only when the retained worktree is clean and the Run - Branch tip is an ancestor of the current target tip. -3. MUST classify tracked or untracked worktree changes as `dirty`. -4. MUST distinguish `unintegrated`, `unknown`, and `released` without treating - missing proof as safe. -5. MUST report both resolved heads and one bounded reason. -6. MUST reject symlink, path, or metadata input that could escape the recorded - repository boundary. -7. MUST use real Git fixtures for every state and ambiguous-ref failure. - -## Subtasks - -- [ ] Add the five reconciliation states and result contract. -- [ ] Inspect cleanliness including untracked files. -- [ ] Resolve recorded Run and target branch tips. -- [ ] Prove ancestry through Git porcelain. -- [ ] Classify missing paths, refs, and metadata conservatively. -- [ ] Add real-repository fixtures for all classifications. - -## Acceptance Criteria - -- [ ] Every terminal fixture produces exactly one documented state. -- [ ] A clean reachable branch is `safe`; a clean divergent branch is - `unintegrated`. -- [ ] Any tracked or untracked worktree change produces `dirty`. -- [ ] Missing target metadata, ambiguous refs, or Git inspection uncertainty - produces `unknown` and preserves work. -- [ ] Absence of both retained worktree and Run Branch produces `released`. -- [ ] Output contains the recorded paths, both heads when known, and a bounded - deterministic reason. -- [ ] No input can make inspection read outside the recorded Git root. - -## 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/worktree/worktree.go` -- interface: `internal/worktree/worktree_test.go` -- interface: `internal/store/store.go` - -## Verification - -- `rtk go test ./internal/worktree -run 'TestInspectTerminalRun.*(Safe|Unintegrated|Dirty|Unknown|Released|UnsafePath)' -count=1` - — expected: real Git fixtures prove all five states and conservative path - handling. -- `rtk go test -race ./internal/worktree -run 'TestInspectTerminalRun' -count=1` - — expected: concurrent inspection fixtures are race-free. - -## References - -- `_prd.md` → Goals 1; User Stories 1 and 4; Core Features 1–3; Success - Metrics. -- `_techspec.md` → Interfaces; Data Models; Testing Approach; Build Order 1. -- `../../adr/0053-terminal-run-worktree-reconciliation-is-proof-based.md` → - positive cleanliness and ancestry proof. diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/task_02.md b/docs/specs/0038-terminal-run-worktree-reconciliation/task_02.md deleted file mode 100644 index 0dc0c227..00000000 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/task_02.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -task: task_02 -spec: 0038-terminal-run-worktree-reconciliation -status: pending -type: backend -complexity: high ---- - -# Task 02: Apply stale-proof cleanup and migrate terminal reaping - -## Overview - -Apply reconciliation only after revalidating the evidence immediately before -mutation, then move automatic terminal reaping onto the same classifier. This -slice removes safe or already released residue while preserving any work whose -cleanliness, heads, or ancestry changed after inspection. - -## Requirements - -1. MUST accept cleanup only for a result previously classified `safe`. -2. MUST re-resolve cleanliness, Run head, and target head immediately before - the first mutation. -3. MUST refuse apply when either head, worktree state, or recorded metadata - changed after inspection. -4. MUST remove the Run Worktree without force before deleting its Run Branch. -5. MUST preserve every remaining path or ref when a mutation step fails. -6. MUST make repeated cleanup report `released` without another mutation. -7. MUST replace creation-base terminal reaping with the shared classifier and - permit automatic cleanup only for `safe` or `released`. - -## Subtasks - -- [ ] Add stale-proof pre-mutation revalidation. -- [ ] Apply worktree removal before branch deletion. -- [ ] Preserve partial-failure evidence and remaining resources. -- [ ] Make repeated apply idempotent. -- [ ] Route automatic terminal reaping through the classifier. -- [ ] Reproduce reachable-later-merge and unique-branch regressions. - -## Acceptance Criteria - -- [ ] Only a still-clean result with unchanged heads reaches Git mutation. -- [ ] A stale head or newly dirty worktree is refused without deleting a path - or ref. -- [ ] Worktree removal uses no force option. -- [ ] Branch deletion occurs only after successful worktree removal. -- [ ] A failed removal or deletion reports the remaining recoverable surface. -- [ ] A second apply performs zero mutations and reports `released`. -- [ ] Automatic reaping removes a changed branch already reachable from target - and preserves a unique changed branch. - -## Context - -- instruction: `.agents/skills/coding-guidelines/SKILL.md` -- instruction: `.agents/skills/no-workarounds/SKILL.md` -- instruction: `.agents/skills/golang-error-handling/SKILL.md` -- instruction: `.agents/skills/golang-testing/SKILL.md` -- interface: `internal/worktree/worktree.go` -- interface: `internal/worktree/worktree_test.go` -- interface: `internal/cli/implement.go` -- interface: `internal/cli/implement_test.go` - -## Verification - -- `rtk go test ./internal/worktree -run 'TestApplyTerminalRun.*(Safe|Stale|Dirty|Failure|Released)|TestPruneTerminal.*Reconciliation' -count=1` - — expected: only fresh safe evidence mutates Git and repeat apply is - idempotent. -- `rtk go test ./internal/cli -run 'TestRunImplementPreflight.*Terminal.*(Safe|Unique|Reachable)' -count=1` - — expected: automatic reaping shares the proof-based classifier. -- `rtk go test -race ./internal/worktree -run 'Test(ApplyTerminalRun|PruneTerminal)' -count=1` - — expected: inspection-to-apply boundaries remain race-free. - -## References - -- `_prd.md` → Goals 1–2; User Stories 1 and 4; Core Features 4, 7, and 9; - Success Metrics. -- `_techspec.md` → API Contracts; Testing Approach; Build Order 2. -- `../../adr/0023-runs-execute-in-per-run-worktrees.md` → Run Worktree - ownership. -- `../../adr/0053-terminal-run-worktree-reconciliation-is-proof-based.md` → - apply safety. diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/task_03.md b/docs/specs/0038-terminal-run-worktree-reconciliation/task_03.md deleted file mode 100644 index e5e6625a..00000000 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/task_03.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -task: task_03 -spec: 0038-terminal-run-worktree-reconciliation -status: pending -type: data -complexity: high ---- - -# Task 03: Reconcile Integration Pending with durable evidence - -## Overview - -Connect fresh `safe` Git proof to the guarded Integration Pending transition -without weakening ordinary terminal immutability. The Run becomes Clean and -records its reconciliation evidence transactionally before any retained Git -surface is released; other outcomes retain their stored state. - -## Requirements - -1. MUST build reconciliation input from the freshly revalidated Run and target - heads. -2. MUST invoke the guarded Integration Pending to Clean store operation before - Git cleanup. -3. MUST record prior outcome, current outcome, classification, both branches, - both heads, worktree, and action in one durable Run Event. -4. MUST leave every non-Integration-Pending terminal outcome unchanged while - recording safe cleanup evidence. -5. MUST refuse stale, incomplete, or mismatched reconciliation input. -6. MUST perform no Git cleanup when durable reconciliation persistence fails. -7. MUST make repeated reconciliation and cleanup idempotent. - -## Subtasks - -- [ ] Map classifier evidence into the guarded store request. -- [ ] Journal complete reconciliation evidence transactionally. -- [ ] Preserve non-Integration-Pending outcomes. -- [ ] Order durable state before Git cleanup. -- [ ] Add stale and incomplete-evidence refusal cases. -- [ ] Add repeat reconciliation coverage. - -## Acceptance Criteria - -- [ ] A safe Integration Pending Run becomes Clean with one evidence event. -- [ ] The event contains both outcomes, branches, heads, worktree, and action. -- [ ] Unresolved, Failed, Stopped, and other terminal outcomes remain unchanged - after safe cleanup. -- [ ] Missing or stale evidence produces no state or Git mutation. -- [ ] Database failure starts no worktree or branch removal. -- [ ] Repeating the operation produces no duplicate transition or evidence. - -## 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/store.go` -- interface: `internal/store/store_test.go` -- interface: `internal/store/journal.go` -- interface: `internal/store/journal_test.go` -- interface: `internal/worktree/worktree.go` -- interface: `internal/worktree/worktree_test.go` - -## Verification - -- `rtk go test ./internal/store -run 'TestReconcileIntegration.*(Safe|Stale|Invalid|Idempotent|Outcome)' -count=1` - — expected: only Integration Pending with complete fresh evidence becomes - Clean transactionally. -- `rtk go test ./internal/worktree -run 'TestApplyTerminalRun.*(IntegrationPending|StoreFailure|Outcome)' -count=1` - — expected: durable reconciliation precedes Git cleanup and other outcomes - remain unchanged. - -## References - -- `_prd.md` → Goal 4; User Story 5; Core Feature 6; Success Metrics. -- `_techspec.md` → Data Models; Integration Points: Run Database; Build Order - 3. -- `../0037-terminal-outcome-integrity/_techspec.md` → guarded terminal - reconciliation boundary. -- `../../adr/0052-run-completion-is-compare-and-set.md` → sole terminal - transition exception. diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/task_04.md b/docs/specs/0038-terminal-run-worktree-reconciliation/task_04.md deleted file mode 100644 index 031534e5..00000000 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/task_04.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -task: task_04 -spec: 0038-terminal-run-worktree-reconciliation -status: pending -type: backend -complexity: high ---- - -# Task 04: Deliver the Reconcile Command and apply contract - -## Overview - -Expose proof-based reconciliation through a deterministic, dry-run-first CLI -for one terminal spec Run or every terminal spec Run in the current repository. -Text and versioned JSON report the same evidence; `--apply` acts only on results -proven safe during the current invocation. - -## Requirements - -1. MUST implement `roundfix reconcile [run-id] [--apply] [--format text|json]`. -2. MUST reject Active Runs, review Runs, missing Runs, and cross-repository - selectors before any Git mutation. -3. MUST scope the no-ID form to terminal spec Runs in the current Git root. -4. MUST default to a read-only report and print the exact apply command. -5. MUST render deterministic text and a versioned JSON envelope with ordered - results and summary counts. -6. MUST apply only entries classified and revalidated `safe` in that - invocation; no force or assertion bypass is permitted. -7. MUST keep expected preserved states successful while making operational Git - or database failures non-zero and actionable. - -## Subtasks - -- [ ] Add Reconcile Command parsing and usage. -- [ ] Resolve one-Run and repository-wide scopes. -- [ ] Render deterministic text and versioned JSON. -- [ ] Connect dry-run and explicit apply behavior. -- [ ] Preserve mixed safe and unsafe scan results. -- [ ] Add idempotent repeat and invalid-selector coverage. - -## Acceptance Criteria - -- [ ] Default invocation changes neither Git nor the Run Database. -- [ ] One-Run and repository-wide results are ordered newest-first. -- [ ] Text and JSON expose classification, branches, heads, worktree, evidence, - action, and refusal reason consistently. -- [ ] `--apply` removes all and only fresh `safe` results. -- [ ] Dirty, unintegrated, unknown, and released results are preserved and - reported without turning a complete scan into failure. -- [ ] Operational inspection or apply failure returns the Run failure exit code - and names the next safe action. -- [ ] A second apply reports `released` and performs zero mutations. - -## Context - -- instruction: `.agents/skills/agentic-cli-design/SKILL.md` -- instruction: `.agents/skills/coding-guidelines/SKILL.md` -- instruction: `.agents/skills/golang-cli/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/cli/runs.go` -- interface: `internal/worktree/worktree.go` - -## Verification - -- `rtk go test ./internal/cli -run 'TestRunReconcile.*(DryRun|Apply|JSON|Repository|Invalid|Mixed|Idempotent)' -count=1` - — expected: command scope, output, mutation, refusal, and repeat contracts - pass. -- `rtk go test -race ./internal/cli ./internal/worktree -run 'Test.*Reconcile' -count=1` - — expected: CLI inspection and apply coordination are race-free. -- `rtk go build -buildvcs=false ./cmd/roundfix` - — expected: the public command builds with repository build settings. - -## References - -- `_prd.md` → Goals 1–2; User Stories 1–2 and 4–5; Core Features 4–7; User - Experience; Success Metrics. -- `_techspec.md` → API Contracts; Integration Points; Build Order 4. -- `../../adr/0053-terminal-run-worktree-reconciliation-is-proof-based.md` → - explicit dry-run/apply command. diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/task_05.md b/docs/specs/0038-terminal-run-worktree-reconciliation/task_05.md deleted file mode 100644 index cef477fa..00000000 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/task_05.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -task: task_05 -spec: 0038-terminal-run-worktree-reconciliation -status: pending -type: backend -complexity: medium ---- - -# Task 05: Surface retained terminal Run Worktrees in Runs List - -## Overview - -Make retained terminal Run Worktrees discoverable without changing the stable -Runs List stdout rows. Each view reports the exact relevant count on stderr and -points to the Reconcile Command as the sole classification surface. - -## Requirements - -1. MUST preserve existing Runs List stdout row shape byte-for-byte. -2. MUST count terminal spec Runs that retain a recorded worktree path or Run - Branch. -3. MUST report hidden retained residue when the default Active view has no - matching terminal rows. -4. MUST report retained residue represented in terminal and all-state views. -5. MUST write the bounded note to stderr only. -6. MUST point to `roundfix reconcile` without classifying safety in Runs List. -7. MUST omit the note when no retained terminal surface exists. - -## Subtasks - -- [ ] Derive repository-scoped retained-worktree counts. -- [ ] Add the exact stderr guidance. -- [ ] Preserve stdout row serialization. -- [ ] Cover Active, terminal, and all-state views. -- [ ] Cover zero-retention and mixed-repository cases. - -## Acceptance Criteria - -- [ ] Active view reports the exact hidden terminal residue count on stderr. -- [ ] Terminal and all-state views report only retained Runs relevant to their - repository scope. -- [ ] Existing stdout rows remain byte-identical. -- [ ] The note contains the exact `roundfix reconcile` pointer. -- [ ] Runs List never labels a retained entry safe or unsafe. -- [ ] No note is printed when neither worktree nor Run Branch remains. - -## Context - -- instruction: `.agents/skills/agentic-cli-design/SKILL.md` -- instruction: `.agents/skills/golang-cli/SKILL.md` -- instruction: `.agents/skills/golang-testing/SKILL.md` -- interface: `internal/cli/runs.go` -- interface: `internal/cli/cli_test.go` -- interface: `internal/store/store.go` -- interface: `internal/worktree/worktree.go` - -## Verification - -- `rtk go test ./internal/cli -run 'TestRun.*List.*Retained.*Worktree' -count=1` - — expected: all views report exact stderr counts while stdout stays - byte-stable. -- `rtk go test ./internal/cli -run 'TestRun.*List.*(Active|Terminal|All)' -count=1` - — expected: existing Runs List view contracts remain passing. - -## References - -- `_prd.md` → Goal 3; User Story 3; Core Feature 8; Success Metrics. -- `_techspec.md` → API Contracts: Run listing; Testing Approach; Build Order 5. -- `CONTEXT.md` → Runs List and Reconcile Command vocabulary. diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/task_06.md b/docs/specs/0038-terminal-run-worktree-reconciliation/task_06.md deleted file mode 100644 index ca5ae0e1..00000000 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/task_06.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -task: task_06 -spec: 0038-terminal-run-worktree-reconciliation -status: pending -type: docs -complexity: medium ---- - -# Task 06: Align reconciliation docs and glossary - -## Overview - -Publish the Reconcile Command, five reconciliation states, dry-run/apply flow, -and Runs List discovery contract through supported documentation and canonical -vocabulary. This slice leaves every protected Skill path unchanged. - -## Requirements - -1. MUST define the Reconcile Command and Run Worktree Reconciliation states in - canonical glossary vocabulary. -2. MUST document single-Run and repository-wide dry-run examples. -3. MUST document `--apply` as the only mutation switch and the absence of a - force bypass. -4. MUST explain positive cleanliness and ancestry proof plus conservative - preservation of dirty, unintegrated, and unknown work. -5. MUST document Integration Pending promotion and unchanged other outcomes. -6. MUST document Runs List stderr discovery without promising classification. -7. MUST preserve ADR/finding traceability and leave protected tooling - unchanged. - -## Subtasks - -- [ ] Add canonical reconciliation vocabulary. -- [ ] Update command and usage documentation. -- [ ] Add dry-run, JSON, and explicit apply examples. -- [ ] Explain state, proof, and preservation semantics. -- [ ] Document Runs List retained-worktree guidance. -- [ ] Resolve ADR, Spec, finding, and command links. - -## Acceptance Criteria - -- [ ] A reader can predict all five states from the documented evidence rules. -- [ ] Examples distinguish requested stdout from diagnostic stderr. -- [ ] No documentation suggests age, outcome, missing path, or force is - sufficient for deletion. -- [ ] Integration Pending and other terminal outcome behavior is explicit. -- [ ] Runs List guidance points to Reconcile Command for classification. -- [ ] Every cited link resolves and terminology matches `CONTEXT.md`. -- [ ] 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 'roundfix reconcile\\|Reconcile Command\\|safe\\|unintegrated\\|released' CONTEXT.md docs/user-guide/commands.md docs/user-guide/usage.md` - — expected: canonical command and state guidance is present. -- `rtk go test ./internal/cli -run 'Test.*(Reconcile.*Help|DocumentationContract|RunsList.*Retained)' -count=1` - — expected: command and Runs List public contracts match documentation. -- `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/0053-terminal-run-worktree-reconciliation-is-proof-based.md` → - canonical reconciliation behavior. diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/task_07.md b/docs/specs/0038-terminal-run-worktree-reconciliation/task_07.md deleted file mode 100644 index d7efa34e..00000000 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/task_07.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -task: task_07 -spec: 0038-terminal-run-worktree-reconciliation -status: pending -type: docs -complexity: low ---- - -# Task 07: Align the protected Roundfix Skill pair - -## Overview - -Publish proof-based Run Worktree reconciliation in the canonical and generated -Roundfix Skill. This tooling-only slice is restricted to the two exact files -authorized by the maintainer and this Task file. - -## Requirements - -1. MUST align `.agents/skills/roundfix/SKILL.md` with dry-run-first - reconciliation, five states, and explicit safe-only apply. -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 docs, other owned Skills, - upstream-managed Skills, locks, and recommendation files unchanged. - -## Subtasks - -- [ ] Update the canonical Roundfix Skill reconciliation 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 Skill tells an Agent to inspect before apply and never use manual Git - deletion as the supported workflow. -- [ ] Dirty, unintegrated, and unknown results are preserved in Skill guidance. -- [ ] 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/0038-terminal-run-worktree-reconciliation/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/0038-terminal-run-worktree-reconciliation/_prd.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/_prd.md similarity index 91% rename from docs/specs/0038-terminal-run-worktree-reconciliation/_prd.md rename to docs/specs/_archived/0038-terminal-run-worktree-reconciliation/_prd.md index 925d2061..f968ceb8 100644 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/_prd.md +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/_prd.md @@ -1,10 +1,13 @@ --- spec: 0038-terminal-run-worktree-reconciliation -status: active +status: archived created: 2026-07-17 surfaces: [backend, cli, data, docs] +archived: "2026-07-27" +source_slug: 0038-terminal-run-worktree-reconciliation --- + # Terminal Run Worktree reconciliation Terminal spec Runs can retain clean Run Worktrees and Run Branches after their commits have already reached the user's target branch. Roundfix currently compares the Run Branch only with its creation base, hides the residue behind the default Active Run listing, and offers no supported cleanup command. Prior dogfood evidence, now absorbed into this Spec and retained in Git history, showed users having to prove ancestry and run Git commands manually. @@ -23,8 +26,14 @@ Terminal spec Runs can retain clean Run Worktrees and Run Branches after their c `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 diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/_tasks.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/_tasks.md similarity index 100% rename from docs/specs/0038-terminal-run-worktree-reconciliation/_tasks.md rename to docs/specs/_archived/0038-terminal-run-worktree-reconciliation/_tasks.md diff --git a/docs/specs/0038-terminal-run-worktree-reconciliation/_techspec.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/_techspec.md similarity index 95% rename from docs/specs/0038-terminal-run-worktree-reconciliation/_techspec.md rename to docs/specs/_archived/0038-terminal-run-worktree-reconciliation/_techspec.md index 96eada3f..63b32fe4 100644 --- a/docs/specs/0038-terminal-run-worktree-reconciliation/_techspec.md +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/_techspec.md @@ -24,8 +24,14 @@ The existing worktree module gains one classifier shared by the new Reconcile Co `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/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation-rerun/fixture.go b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation-rerun/fixture.go new file mode 100644 index 00000000..8478d32c --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation-rerun/fixture.go @@ -0,0 +1,321 @@ +//go:build qa + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "roundfix/internal/store" + runworktree "roundfix/internal/worktree" +) + +type fixture struct { + RunID string `json:"runId"` + State string `json:"state"` + Repository string `json:"repository"` + Worktree string `json:"worktree,omitempty"` + Branch string `json:"branch,omitempty"` +} + +type manifest struct { + Root string `json:"root"` + Home string `json:"home"` + Database string `json:"database"` + Location string `json:"location"` + Repos map[string]string `json:"repos"` + Fixtures map[string]fixture `json:"fixtures"` +} + +func main() { + if len(os.Args) < 2 { + fatalf("usage: fixture seed | fixture inspect ") + } + switch os.Args[1] { + case "seed": + if len(os.Args) != 3 { + fatalf("usage: fixture seed ") + } + seed(os.Args[2]) + case "inspect": + if len(os.Args) != 4 { + fatalf("usage: fixture inspect ") + } + inspect(os.Args[2], os.Args[3]) + default: + fatalf("unknown command %q", os.Args[1]) + } +} + +func seed(root string) { + ctx := context.Background() + root = mustAbs(root) + must(os.MkdirAll(root, 0o755)) + home := filepath.Join(root, "home") + location := filepath.Join(root, "worktrees") + must(os.MkdirAll(home, 0o755)) + must(os.MkdirAll(location, 0o755)) + + m := manifest{ + Root: root, + Home: home, + Database: store.DatabasePath(home), + Location: location, + Repos: map[string]string{}, + Fixtures: map[string]fixture{}, + } + for _, name := range []string{"matrix", "integration", "failure", "selector", "other"} { + repo := filepath.Join(root, name+"-repo") + initRepo(repo) + m.Repos[name] = repo + } + + m.Fixtures["matrix_safe"] = createWorktreeRun( + ctx, home, m.Repos["matrix"], location, "ma/qa-target", store.StateFailed, "safe", + ) + m.Fixtures["matrix_dirty"] = createWorktreeRun( + ctx, home, m.Repos["matrix"], location, "ma/qa-target", store.StateStopped, "dirty", + ) + m.Fixtures["matrix_unintegrated"] = createWorktreeRun( + ctx, home, m.Repos["matrix"], location, "ma/qa-target", store.StateUnresolved, "unintegrated", + ) + m.Fixtures["matrix_unknown"] = createWorktreeRun( + ctx, home, m.Repos["matrix"], location, "missing-target", store.StateTimedOut, "safe", + ) + m.Fixtures["matrix_released"] = createMetadataRun( + ctx, home, m.Repos["matrix"], "ma/qa-target", store.KindImplement, store.StateClean, + ) + + for name, state := range map[string]string{ + "integration_pending": store.StateIntegrationPending, + "integration_failed": store.StateFailed, + "integration_stopped": store.StateStopped, + "integration_timedout": store.StateTimedOut, + "integration_unresolved": store.StateUnresolved, + } { + m.Fixtures[name] = createWorktreeRun( + ctx, home, m.Repos["integration"], location, "ma/qa-target", state, "safe", + ) + } + parent := m.Fixtures["integration_failed"] + taskPath := parent.Worktree + "-task_01" + taskBranch := parent.Branch + "-task_01" + git(m.Repos["integration"], "worktree", "add", "-b", taskBranch, taskPath, "HEAD") + m.Fixtures["integration_task_worktree"] = fixture{ + State: "TaskWorktree", + Repository: m.Repos["integration"], + Worktree: taskPath, + Branch: taskBranch, + } + + m.Fixtures["failure_locked"] = createWorktreeRun( + ctx, home, m.Repos["failure"], location, "ma/qa-target", store.StateFailed, "safe", + ) + git(m.Repos["failure"], "worktree", "lock", m.Fixtures["failure_locked"].Worktree) + + m.Fixtures["selector_active"] = createMetadataRun( + ctx, home, m.Repos["selector"], "ma/qa-target", store.KindImplement, "", + ) + m.Fixtures["selector_review"] = createReviewRun( + ctx, home, m.Repos["selector"], store.StateFailed, + ) + m.Fixtures["other_terminal"] = createMetadataRun( + ctx, home, m.Repos["other"], "ma/qa-target", store.KindImplement, store.StateFailed, + ) + + content, err := json.MarshalIndent(m, "", " ") + must(err) + content = append(content, '\n') + must(os.WriteFile(filepath.Join(root, "manifest.json"), content, 0o644)) + fmt.Print(string(content)) +} + +func inspect(home, runID string) { + ctx := context.Background() + reader, err := store.OpenReader(ctx, home) + must(err) + defer func() { + must(reader.Close()) + }() + run, found, err := reader.Run(ctx, runID) + must(err) + if !found { + fatalf("Run %q not found", runID) + } + events, err := reader.RunEventsAfter(ctx, runID, 0, 100) + must(err) + output := struct { + Run store.Run `json:"run"` + Events []store.JournalEvent `json:"events"` + }{Run: run, Events: events} + content, err := json.MarshalIndent(output, "", " ") + must(err) + fmt.Println(string(content)) +} + +func initRepo(repo string) { + must(os.MkdirAll(repo, 0o755)) + git(repo, "init", "--initial-branch=main") + git(repo, "config", "user.name", "Roundfix QA") + git(repo, "config", "user.email", "roundfix-qa@example.com") + git(repo, "config", "commit.gpgsign", "false") + must(os.WriteFile(filepath.Join(repo, "README.md"), []byte("# QA fixture\n"), 0o644)) + git(repo, "add", "README.md") + git(repo, "commit", "-m", "seed fixture") + git(repo, "checkout", "-b", "ma/qa-target") +} + +func createWorktreeRun( + ctx context.Context, + home, repo, location, targetBranch, terminalState, mode string, +) fixture { + runStore, err := store.Open(ctx, home) + must(err) + defer func() { + must(runStore.Close()) + }() + head := strings.TrimSpace(gitOutput(repo, "rev-parse", "HEAD")) + run, err := runStore.CreateRun(ctx, store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: repo, + LocalBranch: targetBranch, + HeadSHA: head, + SpecSlug: "reconcile-" + strings.ToLower(terminalState), + Agent: "codex", + OwnerPID: os.Getpid(), + }) + must(err) + ref, err := runworktree.Create(ctx, runworktree.CreateOptions{ + UserRoot: repo, + Location: location, + RunID: run.ID, + HeadSHA: head, + }) + must(err) + ref.Path = mustAbs(ref.Path) + run, err = runStore.SetRunWorkDir(ctx, run.ID, ref.Path) + must(err) + completed, err := runStore.CompleteRun(ctx, run.ID, terminalState) + must(err) + + switch mode { + case "safe": + case "dirty": + must(os.WriteFile(filepath.Join(ref.Path, "README.md"), []byte("# tracked dirt\n"), 0o644)) + must(os.WriteFile(filepath.Join(ref.Path, "untracked.txt"), []byte("untracked dirt\n"), 0o644)) + case "unintegrated": + must(os.WriteFile(filepath.Join(ref.Path, "unique.txt"), []byte("unique run work\n"), 0o644)) + git(ref.Path, "add", "unique.txt") + git(ref.Path, "commit", "-m", "unique run work") + default: + fatalf("unknown fixture mode %q", mode) + } + return fixture{ + RunID: completed.Run.ID, + State: completed.Run.State, + Repository: repo, + Worktree: ref.Path, + Branch: ref.Branch, + } +} + +func createMetadataRun( + ctx context.Context, + home, repo, branch, kind, terminalState string, +) fixture { + runStore, err := store.Open(ctx, home) + must(err) + defer func() { + must(runStore.Close()) + }() + request := store.CreateRunRequest{ + Kind: kind, + GitRoot: repo, + LocalBranch: branch, + HeadSHA: strings.TrimSpace(gitOutput(repo, "rev-parse", "HEAD")), + SpecSlug: "metadata-" + strings.ToLower(strings.ReplaceAll(terminalState, " ", "-")), + Agent: "codex", + OwnerPID: os.Getpid(), + } + run, err := runStore.CreateRun(ctx, request) + must(err) + if terminalState != "" { + completed, completeErr := runStore.CompleteRun(ctx, run.ID, terminalState) + must(completeErr) + run = completed.Run + } + return fixture{RunID: run.ID, State: run.State, Repository: repo} +} + +func createReviewRun(ctx context.Context, home, repo, terminalState string) fixture { + runStore, err := store.Open(ctx, home) + must(err) + defer func() { + must(runStore.Close()) + }() + run, err := runStore.CreateRun(ctx, store.CreateRunRequest{ + Kind: store.KindResolve, + HeadRepository: "owner/project", + HeadBranch: "ma/qa-target", + BaseRepository: "owner/project", + PRNumber: "38", + GitRoot: repo, + LocalBranch: "ma/qa-target", + HeadSHA: strings.TrimSpace(gitOutput(repo, "rev-parse", "HEAD")), + ArtifactDir: filepath.Join(repo, ".roundfix", "reviews"), + Agent: "codex", + OwnerPID: os.Getpid(), + }) + must(err) + completed, err := runStore.CompleteRun(ctx, run.ID, terminalState) + must(err) + return fixture{RunID: completed.Run.ID, State: completed.Run.State, Repository: repo} +} + +func git(dir string, args ...string) { + _ = gitOutput(dir, args...) +} + +func gitOutput(dir string, args ...string) string { + cmd := exec.Command("git", append([]string{ + "-c", "user.name=Roundfix QA", + "-c", "user.email=roundfix-qa@example.com", + "-c", "commit.gpgsign=false", + }, args...)...) + cmd.Dir = dir + content, err := cmd.CombinedOutput() + if err != nil { + fatalf("git %s in %s: %v\n%s", strings.Join(args, " "), dir, err, content) + } + return string(content) +} + +func mustAbs(path string) string { + absolute, err := filepath.Abs(path) + must(err) + resolved, err := filepath.EvalSymlinks(absolute) + if err == nil { + return resolved + } + if !errors.Is(err, os.ErrNotExist) { + must(err) + } + return absolute +} + +func must(err error) { + if err != nil { + fatalf("%v", err) + } +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation-rerun/run-summary.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation-rerun/run-summary.md new file mode 100644 index 00000000..6af8229d --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation-rerun/run-summary.md @@ -0,0 +1,199 @@ +# Terminal Run Worktree reconciliation — rerun evidence + +Build: `3744d03669f75d05e8f8e728dc3e03e6300b61ce` +(`roundfix 0.0.1`, built as `3744d03-dirty` because the QA report was open). + +Application: `bin/roundfix`, built by the current run's `rtk make verify`. + +Environment: macOS Darwin 25.5.0 arm64; Go 1.26.5; Git 2.55.0. + +Fixture root: `/private/tmp/roundfix-qa-0038-rerun.4GhgTo/state`. + +The build-tagged `fixture.go` helper seeded only isolated Git repositories and +one isolated Roundfix Home through project Store and Worktree APIs. Every +verdict-bearing operation used the built public CLI. Confirmation used fresh +Git reads, database hashes, a second CLI process, or a read-only Store reopen. + +## Prior-failure regression and scope audit + +The prior QA run failed because Task 04 commit `0d56f42` added a 20.1 MiB +Mach-O executable at repository-root `roundfix`. + +Current-build evidence: + +- remediation commit `af03b5f` deletes `roundfix`; +- both `git ls-tree 8ec92ad -- roundfix` and + `git ls-tree HEAD -- roundfix` return no entry; +- base and HEAD `.gitignore` both resolve to blob + `498dd1c424271e8e612674bfcba3fed500eee252`; +- the post-failure commits add only the dated QA and findings artifacts after + restoring the protected ignore file byte-for-byte; +- current status before report closure contains only the dated QA report and + its evidence directory. + +Every Task commit was audited with +`git diff-tree --no-commit-id --name-status -r `. Tasks 01-06 matched +their declared implementation and documentation slices except for the Task 04 +binary now removed from HEAD. + +Task 07 commit `16da6754efe30e28228d0498318eb01701d61a7d` +contains exactly: + +- `.agents/skills/roundfix/SKILL.md`; +- `skills/roundfix/SKILL.md`; +- the five derived digest pins expressly authorized in the PRD and TechSpec; +- `task_07.md`. + +Task 06 commit `c443d76` contains only `CONTEXT.md`, the two user guides, and +`task_06.md`; it touches no protected tooling path. `cmp` confirms the Skill +pair is byte-identical, and +`TestAuthorialSkillSync/typescript-bun.json` passes. + +## Static gate + +- The first exact `rtk make verify` attempt could not access the sandboxed + default Go cache and stopped before compilation. +- The same exact command rerun with approved normal cache access passed: + 2,563 tests in 23 packages, four Skill-sync tests, all fourteen shipped + Skill checks, and the production build. +- `rtk git -c core.fsmonitor=false diff --check` passed after the gate. +- Verification introduced no tracked side effect. + +## Classification and dry-run + +Repository: +`/private/tmp/roundfix-qa-0038-rerun.4GhgTo/state/matrix-repo`. + +Text and `roundfix-reconcile/v1` JSON dry-runs both exited 0 and returned, +newest-first: + +| Run | Stored outcome | Classification | Evidence | +| --- | --- | --- | --- | +| `run_20260727T204846Z_00c039441bf0c350` | Clean | released | Worktree and Run Branch absent | +| `run_20260727T204846Z_f5301d45ef12691c` | TimedOut | unknown | target branch cannot resolve | +| `run_20260727T204846Z_8f0ecb4080eff186` | Unresolved | unintegrated | Run head `3f7686a…` not ancestor of target `347f101…` | +| `run_20260727T204846Z_de3aa0216c7170e7` | Stopped | dirty | tracked and untracked changes | +| `run_20260727T204846Z_26931c4f50c1ca51` | Failed | safe | clean worktree; both heads `347f101…` | + +Summary: +`total=5 safe=1 unintegrated=1 dirty=1 unknown=1 released=1 applied=0 preserved=3 operational-failures=0`. + +The Run Database SHA-256 remained +`67bbd9f535dcea188f85fc91dae06a06f8f7df28c499174586d64f222b594cf7` +across the text, JSON, and flags-before/after-ID dry-runs. Fresh worktree and +ref reads also showed no mutation. + +## Runs List discovery + +Before apply: + +- Active view: `No Runs found.` plus an exact retained count of four. +- Terminal and all-state repository views: five rows plus the same retained + count of four. +- Machine-wide `--all --state all --limit 0`: ten retained terminal Run + Worktrees across all fixture repositories. + +After the matrix's safe entry was released, the Active view reported exactly +three retained terminal Run Worktrees. After the integration repository was +fully released, its all-state view preserved the five terminal history rows +and emitted no retained-worktree note. Focused stream tests separately confirm +that rows stay on stdout and guidance stays on stderr. + +## Mixed apply and preservation + +`roundfix reconcile --apply --format json` in the matrix repository exited 0: + +`total=5 safe=1 unintegrated=1 dirty=1 unknown=1 released=1 applied=1 preserved=3 operational-failures=0`. + +Fresh Git reads found only the safe worktree and Run Branch absent. The dirty, +unintegrated, and unknown worktrees and branches remained. Independent reads +returned `# tracked dirt`, `untracked dirt`, and the unique unintegrated commit +`3f7686a`. + +## Durable outcomes and idempotency + +Repository: +`/private/tmp/roundfix-qa-0038-rerun.4GhgTo/state/integration-repo`. + +Repository-wide apply returned five `safe` results and `applied=5`. A fresh +`runs list --state all --limit 0` showed: + +- `run_20260727T204846Z_0df4aca31b4cd727`: Clean, previously + IntegrationPending; +- `run_20260727T204846Z_e848687119f05bc8`: Unresolved; +- `run_20260727T204846Z_ffa8eacb96affb88`: Failed; +- `run_20260727T204846Z_9285f861aef47ffe`: Stopped; +- `run_20260727T204846Z_2f9abf528397aaa5`: TimedOut. + +A fresh read-only Store process found exactly one event for the promoted Run. +The event records source `daemon`, kind `daemon.outcome`, previous outcome +IntegrationPending, current outcome Clean, classification safe, both branches, +both `347f101…` heads, the worktree, and action `cleanup`. + +The second apply returned five `released` results and `applied=0`. The Run +Database SHA-256 remained +`f53305c7bbd3bd656a6b3727d4120565e78c453087892fa966ebcbe3275f6767`, +and fresh worktree/ref reads were unchanged. + +The sibling Task Worktree and branch ending in `-task_01` remained after the +parent Run Worktree and Run Branch were released. + +## Selector refusals and operational recovery + +From the selector repository, missing, Active, review, and cross-repository +Run selectors each exited 2 with a specific Preflight Validation message. +The no-ID JSON form returned an empty repository-scoped result set. + +Invalid format, extra argument, `--force`, and missing `--format` value each +exited 2. The database SHA-256 remained +`f53305c7bbd3bd656a6b3727d4120565e78c453087892fa966ebcbe3275f6767`, +and selector-repository refs/worktrees remained unchanged. + +A locked clean worktree produced exit 1, `operational-failures=1`, the exact +rerun action, and both remaining recoverable surfaces. Fresh Git reads found +the worktree still registered and locked and the Run Branch still present. +A subsequent dry-run continued to classify the retained entry as safe. + +## Non-Goal probes + +- Mixed apply did not integrate the divergent commit or repair either dirty + file. +- The sibling Task Worktree and Task Branch remained. +- The Integration Pending reconciliation event remained readable after + cleanup; Reconcile did not prune journal or artifact state. +- The selector repository's no-ID form excluded every other repository. +- Help and user guides expose no age-, outcome-, missing-path-, or force-based + deletion path and keep GC ownership separate. + +## Focused current-build evidence + +- `go test ./internal/worktree -run + 'Test(InspectTerminalRun|ApplyTerminalRun|PruneTerminal)' -count=1` passed. +- `go test ./internal/store -run 'TestReconcileIntegration' -count=1` + passed. +- The focused CLI family for Reconcile, retained Runs List, Implement + Preflight, help, and documentation contracts passed. +- `go test -race ./internal/cli ./internal/worktree -run + 'Test.*Reconcile' -count=1` passed. +- `go test -race ./internal/worktree -run + 'Test(ApplyTerminalRun|PruneTerminal)' -count=1` passed. +- `go test -race ./internal/worktree -run 'TestInspectTerminalRun' -count=1` + passed. +- The Active, terminal, and all-state Runs List regression family passed. + +These focused checks cover the stale-head, changed-metadata, newly-dirty, +persistence-failure-before-Git, unsafe/symlink path, command-ordering, +non-force removal, stream separation, and automatic reaper seams that cannot +be paused safely through the public process. + +## Docs and shipped Skill + +- Built `roundfix reconcile --help` documents dry-run default, the five + states, safe-only apply, text/JSON, and no force switch. +- The glossary and user guides define the same states, stdout/stderr boundary, + outcome behavior, GC separation, and supported commands. +- The shipped Skill directs Agents to dry-run first, preserve unintegrated, + dirty, and unknown work, apply only fresh safe proof, and never substitute + manual Git deletion. +- `cmp`, `make skills-sync-check`, the authorial digest test, and the full + shipped Skill check all passed. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation/run-summary.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation/run-summary.md new file mode 100644 index 00000000..f67b6320 --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/evidence/2026-07-27-terminal-reconciliation/run-summary.md @@ -0,0 +1,191 @@ +# Terminal Run Worktree reconciliation — execution evidence + +Build: `01a16c8d9fe430a4d70d0f8db5c5a135eb1d803b` + +Application: `bin/roundfix`, built by `rtk make verify` + +Environment: macOS Darwin 25.5.0 arm64; Go 1.26.5; Git 2.55.0. + +Fixture root: `/private/tmp/roundfix-qa-0038.DEmQ8W` + +The fixture helper used project Store and Worktree APIs only to seed isolated +Git repositories and one isolated Roundfix Home. All behavior checks used the +built public CLI, then confirmed state through fresh Git commands, a second CLI +process, database hashes, or a read-only Store reopen. + +## Static gate + +- First sandboxed `rtk make verify`: stopped before compilation because the + sandbox could not read `/Users/marcio/Library/Caches/go-build/...`. +- Exact host-access `rtk make verify`: passed — 2,563 tests in 23 packages, + four Skill-sync tests, fourteen shipped Skill checks, and `bin/roundfix` + build. +- Postflight `git diff --check`: passed. +- Postflight status: only the Spec-local QA artifact plus the temporary + `.qa-tmp/` fixture source; no formatter side effect. + +## Classification and dry-run + +Repository: +`/private/tmp/roundfix-qa-0038.DEmQ8W/matrix-repo` + +`bin/roundfix reconcile` and `bin/roundfix reconcile --format json` both exited +0 and returned, newest-first: + +| Run | Stored outcome | Classification | Key evidence | +| --- | --- | --- | --- | +| `run_20260727T195402Z_9cdadf385b4c788e` | Clean | released | Run Worktree and Run Branch absent | +| `run_20260727T195402Z_23fbf5f2e1ca151b` | TimedOut | unknown | target branch could not resolve unambiguously | +| `run_20260727T195402Z_05f7efcfd5cba8d3` | Unresolved | unintegrated | Run head `25b043c…` is not an ancestor of target `55d89c6…` | +| `run_20260727T195402Z_a82861776b685a09` | Stopped | dirty | tracked and untracked changes present | +| `run_20260727T195402Z_ff0fc4bf6471bcb9` | Failed | safe | clean worktree; both heads `55d89c6…` | + +Summary: +`total=5 safe=1 unintegrated=1 dirty=1 unknown=1 released=1 applied=0 preserved=3 operational-failures=0`. + +The JSON envelope was `roundfix-reconcile/v1`, mode `dry-run`, with the same +five results and summary. `--format=json` before the Run ID also exited 0 and +selected exactly the requested Run. + +Run Database SHA-256 before and after all dry-runs: +`9a76468b6a4eb2b79f6d11f6ff6c3f2bac0029be530d66a6ca1d140da717fda7`. +`git worktree list --porcelain` and `git show-ref` were also byte-equivalent +before and after. + +## Mixed apply and preservation + +`bin/roundfix reconcile --apply` exited 0: + +`total=5 safe=1 unintegrated=1 dirty=1 unknown=1 released=1 applied=1 preserved=3 operational-failures=0`. + +Fresh Git reads found only the safe Run Worktree and Run Branch absent. The +dirty, unintegrated, and unknown worktrees and branches remained. Independent +content reads returned `tracked dirt`, `untracked dirt`, and the unintegrated +tip `25b043c unique run work`. + +## Durable outcomes and idempotency + +Repository: +`/private/tmp/roundfix-qa-0038.DEmQ8W/integration-repo` + +Repository-wide JSON apply exited 0 with five `safe` results and `applied=5`. +A fresh `runs list --state all --limit 0` showed: + +- `run_20260727T195403Z_9897285a8e208732`: Clean, previously + IntegrationPending. +- `run_20260727T195403Z_756779953cf61ec3`: Unresolved. +- `run_20260727T195403Z_020645731249b5c6`: Failed. +- `run_20260727T195403Z_0f4079f721002bbc`: Stopped. +- `run_20260727T195403Z_a0a2b2dcfe3f1ce1`: TimedOut. + +A fresh read-only Store process found one event for the promoted Run: + +```json +{ + "source": "daemon", + "kind": "daemon.outcome", + "summary": "Run reconciled IntegrationPending to Clean before terminal cleanup.", + "payload": { + "action": "cleanup", + "classification": "safe", + "current_outcome": "Clean", + "event": "integration_reconciliation", + "previous_outcome": "IntegrationPending", + "run_branch": "roundfix/run-run_20260727T195403Z_9897285a8e208732", + "run_head": "d0d4148e623582b25f7f702de3a155d456299d97", + "target_branch": "ma/qa-target", + "target_head": "d0d4148e623582b25f7f702de3a155d456299d97", + "worktree": "/private/tmp/roundfix-qa-0038.DEmQ8W/worktrees/integration-repo-bdd31c45/run_20260727T195403Z_9897285a8e208732" + } +} +``` + +The second repository-wide apply returned five `released` results, +`applied=0`, and no operational failure. Run Database SHA-256 stayed +`bf3b8a0a54d7b69c6a2c15fc35ec84af27bb000791ae0d4af084458cc97d0115`; +the Git worktree and ref surface also stayed unchanged. + +## Runs List discovery + +In the listing repository: + +- Active view: `No Runs found.` plus + `(3 terminal Run Worktrees retained; run 'roundfix reconcile' to inspect)`. +- Terminal and all-state repository views: the four existing terminal rows + plus the same exact retained count of three. +- Machine-wide `--all --state all --limit 0`: six retained terminal Run + Worktrees across the fixture repositories. +- Released-only integration repository: no retained-worktree note; the + existing generic hidden-terminal-row note remained. + +The full gate's stream assertions confirmed Runs List rows remain on stdout and +the retained-worktree note remains on stderr. + +## Refusals and recovery + +From the selector repository, these `--apply` selectors all exited 2: + +- Active Run `run_20260727T195403Z_5f6793da655b49a3`. +- Review Run `run_20260727T195403Z_f66ef7d1d49059b4`. +- Missing Run `run_missing_qa_0038`. +- Other-repository Run `run_20260727T195402Z_a82861776b685a09`. + +Database SHA-256 stayed +`bf3b8a0a54d7b69c6a2c15fc35ec84af27bb000791ae0d4af084458cc97d0115`; +refs and worktrees were unchanged. The no-ID JSON form in that repository +returned an empty current-repository result set. + +Invalid format, extra argument, `--force`, and a missing `--format` value all +exited 2. Database SHA-256 stayed +`7e92dac68673c71408fe200efbc6f0a77c581c0684cc34815edd33584d1fa7cb`. + +A locked clean worktree produced exit 1, `operational-failures=1`, the exact +next safe action, and the remaining worktree/branch in the refusal. Fresh Git +reads confirmed both remained and the worktree stayed locked. + +## Non-Goal probes + +- Mixed apply left the unique divergent commit and dirty tracked/untracked + content unchanged: no integration or repair occurred. +- A sibling Task Worktree and Task Branch named + `roundfix/run-run_20260727T195403Z_6200a0c37fe85dcd-task_01` remained after + the safe parent Run Branch was released. +- The Integration Pending reconciliation event remained readable after + cleanup; reconcile did not prune Run Event Journal or artifact storage. +- Repository-scoped no-ID reconciliation excluded every Run from the other + fixture repositories. + +## Focused current-build evidence + +- Worktree classification/apply/reaper families: passed in 6.137s. +- CLI reconcile, retained Runs List, automatic Implement Preflight, and docs + contract families: passed in 3.975s. +- Store reconciliation family: passed in 0.629s. +- `go test -race ./internal/cli ./internal/worktree -run 'Test.*Reconcile'`: + passed. +- `go test -race ./internal/worktree -run + 'Test(ApplyTerminalRun|PruneTerminal)'`: passed. + +These checks cover the timing-only stale-head/dirty/metadata boundary, +persistence-failure-before-Git boundary, unsafe/symlink path fixtures, and +automatic reaper seam that cannot be paused safely through the public process. + +## Tooling and scope audit + +Task 07 Daemon commit `16da6754efe30e28228d0498318eb01701d61a7d` +changed exactly: + +- `.agents/skills/roundfix/SKILL.md` +- `skills/roundfix/SKILL.md` +- the five maintainer-authorized derived digest pins +- `task_07.md` + +`cmp` passed, the `typescript-bun.json` authorial-sync test passed, the built +Skill check passed, and Task 06 changed no protected tooling path. + +Task 04 Daemon commit `0d56f4281b06602b6108a8d304885237a897f4d6` +also created a repository-root file named `roundfix`. It is a 20.1 MiB Mach-O +arm64 executable. Base `8ec92ad` has no such path; HEAD stores it as executable +blob `01aaed49a73f1e33f4d2dc8bb9ca51208aaa4db9`. The repository's declared build +output is ignored `bin/roundfix` (`Makefile:68`, `README.md:126`), so this root +binary is an unexplained generated artifact. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/qa-report-2026-07-27.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/qa-report-2026-07-27.md new file mode 100644 index 00000000..b7a53a2d --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/qa/qa-report-2026-07-27.md @@ -0,0 +1,144 @@ +--- +spec: 0038-terminal-run-worktree-reconciliation +date: 2026-07-27 +build: 3744d03669f75d05e8f8e728dc3e03e6300b61ce +status: closed +verdict: pass +surfaces: [backend, cli, data, docs] +--- + +# QA report — Terminal Run Worktree reconciliation + +## Scope and environment + +This full rerun validates every PRD user story, Core Feature, Success Metric, +Task acceptance criterion, Project Constraint, and scope exclusion against +commit `3744d03669f75d05e8f8e728dc3e03e6300b61ce`. + +The prior same-day run failed only its repository-scope row because Task 04 +had committed a platform-specific root binary. This rerun started with that +regression, proved the binary absent and the protected `.gitignore` +byte-restored, then re-executed the complete matrix. Prior verdict-bearing +results were not credited as current evidence. + +Actors: a Roundfix user, Agent, Supervisor, and maintainer. Surfaces: built +CLI, local Git backend, Run Database, and supported docs. Destructive flows ran +only in isolated repositories under +`/private/tmp/roundfix-qa-0038-rerun.4GhgTo/state`, with an isolated Roundfix +Home. No network, GitHub, authentication, or HTTP service was used. + +Application: `bin/roundfix`, version `0.0.1`, built as `3744d03-dirty` because +this QA report was open. Environment: macOS Darwin 25.5.0 arm64; Go 1.26.5; +Git 2.55.0. + +Evidence: + +- `qa/evidence/2026-07-27-terminal-reconciliation-rerun/run-summary.md` +- `qa/evidence/2026-07-27-terminal-reconciliation-rerun/fixture.go` + +The build-tagged fixture helper only seeded isolated Git and Run Database +state. Every verdict-bearing operation used the built public CLI, followed by +fresh Git reads, hashes, a second CLI process, or a read-only Store reopen. + +## Project Constraint audit + +| Constraint | PRD | TechSpec | Operative source | Status | Evidence | +| --- | --- | --- | --- | --- | --- | +| Identifier strategy | Not applicable: existing Run, branch, path, and Git object identities only | Same applicability and reason | `docs/agents/domain.md` | pass | Artifacts match; built flow reused existing identities and minted no project-owned scheme. | +| Authentication and HTTP | Not applicable: local Run Database and Git only | Same applicability and reason | `docs/agents/cli.md` | pass | Artifacts match; all live flows were local and required no network, authentication, or HTTP contract. | +| Active ADR obligations | ADR-0023, ADR-0024, ADR-0052, and ADR-0053 apply | Same four obligations | `docs/agents/domain.md` | pass | Real worktrees, porcelain Git, guarded Integration Pending transition, and positive proof all matched the active decisions. | +| Tooling authority | Exact Skill pair plus five derived digest paths expressly authorized | Same bounded authorization | `docs/agents/agent-instructions.md` | pass | Task 07 `diff-tree` exactly matched the authorized pair, five pins, and Task file; current delta adds only QA evidence. | + +## Static gate + +- First exact `rtk make verify`: environment-caused stop before compilation + because the sandbox denied the default Go cache. +- Exact `rtk make verify` with approved normal cache access: passed — 2,563 + tests in 23 packages, four Skill-sync tests, all fourteen shipped Skill + checks, and the production build. +- Focused worktree, store, CLI, docs, Runs List, and automatic-reaper families: + passed. +- Three race families for inspection, apply/reaper, and CLI reconciliation: + passed. +- `rtk git -c core.fsmonitor=false diff --check`: passed. + +## Results + +| # | Story / criterion / sweep | Actor and surface | Entry point and steps | Expected observable and independent confirmation | Persistence / probe | Status | Evidence | +| --- | --- | --- | --- | --- | --- | --- | --- | +| QA-25 | Repository scope integrity and prior failure regression | Maintainer; repository | Audit Task/remediation commits and prior root-binary path. | No root binary or unexplained shipped artifact; `.gitignore` equals base. Confirm with `diff-tree`, `ls-tree`, and blob IDs. | Compare current build output with ignored `bin/roundfix`. | pass | Root path absent in base and HEAD; `.gitignore` base/HEAD blob is `498dd1c…`; remediation/finding scopes are traceable. | +| QA-01 | Project Constraints: identifier and authentication/HTTP | Maintainer; docs/backend | Compare PRD/TechSpec with operative guides and built dependencies. | Matching reasoned entries and local-only runtime; inspect live flow for network/auth/new identity. | Recheck after live execution. | pass | Matching entries cite operative sources; all current built flows used only local Git and isolated Run Database. | +| QA-02 | Project Constraints: active ADR obligations | Maintainer; docs/backend | Read ADR-0023/0024/0052/0053 and compare live outcomes. | Worktree ownership, porcelain Git, guarded transition, and positive proof hold. | Fresh post-apply Store/Git reload. | pass | Real worktrees/branches, mixed apply, and durable event matched all four ADRs. | +| QA-03 | Tooling authorization; Task 07 AC3-AC6 | Maintainer; docs/tooling | Resolve Task 07 commit; run `diff-tree`, `cmp`, digest, sync, and Skill checks. | Only authorized paths plus Task file; copies match; shipped validation passes. | Compare current delta with committed scope. | pass | `16da675` contains exactly the authorized pair, five pins, and Task file; all checks passed. | +| QA-04 | Static repository gate; Task 07 AC6 | Maintainer; all | Run full Verification. | Formatting, tests, Skill sync/check, and build exit zero. | Status and `diff --check` postflight. | pass | Exact host-access rerun passed 2,563 tests/23 packages, 4 Skill tests, 14 Skill checks, and build. | +| QA-05 | US1; Core Features 1-3; Success Metric 1; Task 01 AC1-AC7 | User; CLI/backend | Seed all five real-Git states; run text and JSON dry-runs. | Exactly one documented state per fixture, complete bounded evidence, ambiguity never safe. | Repeat reads; focused unsafe-path/ambiguous-ref family. | pass | Current CLI returned safe, unintegrated, dirty, unknown, and released exactly once with paths and known heads. | +| QA-06 | US2; Core Features 4-5; Task 04 AC1-AC3 | Agent; CLI/data | Snapshot DB/Git; run repository and one-Run dry-runs in text/JSON. | Read-only equivalent reports, newest-first, repository-scoped. | DB hash, refs, worktrees, repeat JSON, flag order. | pass | DB hash stayed `67bbd9f…`; Git unchanged; text/JSON and flags-before/after-ID matched. | +| QA-07 | US3; Core Feature 8; Success Metric 4; Task 05 AC1-AC6 | User; CLI | Run Active, terminal, all, post-cleanup, and machine-wide views. | Stable stdout rows; exact stderr count and Reconcile pointer; no classification. | Rerun after release and released-only view. | pass | Counts moved from 4 to 3 after one release; machine-wide count was 10; released-only integration view emitted no retained note. | +| QA-08 | US4; Core Features 2-4 and 6; Success Metric 2; Task 02 AC1, AC3-AC5; Task 04 AC4-AC5 | User; CLI/backend/data | Apply mixed safe, dirty, unintegrated, unknown, and released repository. | All and only fresh safe surfaces removed; preserved states stay successful. | Fresh worktree/ref/file/DB reads. | pass | `applied=1 preserved=3`; only safe path/ref vanished; tracked/untracked dirt and unique commit remained. | +| QA-09 | US5; Core Feature 6; Success Metric 5; Task 03 AC1-AC3 | User/Supervisor; CLI/data | Apply safe Integration Pending and inspect state/event. | Clean before cleanup; one complete durable event. | Second CLI process and read-only Store reopen. | pass | Fresh listing showed Clean; one event contains both outcomes, branches, heads, worktree, safe classification, and cleanup action. | +| QA-10 | Core Feature 6; no other outcome rewrite; Task 03 AC2-AC3 | User; CLI/data | Apply safe Unresolved, Failed, Stopped, and TimedOut Runs. | Git cleanup evidence persists; each stored outcome stays unchanged. | Fresh all-state listing. | pass | Fresh listing retained all four outcomes exactly while their Git surfaces were released. | +| QA-11 | Core Feature 7; Success Metric 3; Task 02 AC6; Task 03 AC6; Task 04 AC7 | User/Agent; CLI/data | Apply again after cleanup. | Five released results, `applied=0`, no Git/DB mutation. | Before/after hash and Git reads. | pass | Second apply returned five released; DB hash remained `f53305c…`; worktrees/refs unchanged. | +| QA-12 | Apply revalidation race; Task 02 AC1-AC3; Task 03 AC4 | User; CLI/backend/data | Exercise stale heads, metadata, new dirt, and concurrent inspection/apply. | Stale proof blocks mutation and preserves state. | Fresh focused and race families. | pass | Current worktree and race checks passed all stale/revalidation cases; public locked path also remained intact. | +| QA-13 | Operational failure; Task 02 AC5; Task 03 AC5; Task 04 AC6 | User; CLI/backend/data | Apply to locked safe worktree; exercise persistence-failure seam. | Exit non-zero with next safe action and retained surfaces; store failure starts no cleanup. | Fresh path/ref/state reads. | pass | Exit 1 named rerun and both surfaces; worktree stayed locked, branch remained; focused StoreFailure passed. | +| QA-14 | Invalid selectors and repository scope; Task 04 requirements 2-3 and AC2 | Agent; CLI | Select missing, Active, review, and cross-repository Runs; run no-ID form. | Invalid selectors exit 2 before mutation; no-ID includes only current repository. | DB hash and Git reread. | pass | Four precise exit-2 refusals; no-ID returned empty current-repository results; DB/Git unchanged. | +| QA-15 | Automatic reaper shared proof; Core Feature 9; Task 02 AC7 | User; CLI/backend | Exercise Implement Preflight reachable/unique regressions. | Reachable safe residue reaped; unique work preserved. | Real-Git focused family plus public mixed canary. | pass | Current Implement Preflight family passed; public mixed apply independently preserved divergent `3f7686a…`. | +| QA-16 | Safety boundary; Task 01 AC7 | User; backend | Exercise registered paths, symlink ancestors/finals, ambiguous refs, and cross-root input. | No read escapes recorded root; uncertainty stays unknown. | Outside sentinel checks in current family. | pass | Current InspectTerminalRun and race families passed unsafe-path, symlink, ambiguous-ref, and missing-target cases. | +| QA-17 | Task 02 AC2-AC4 implementation contract | Maintainer; backend | Run focused immediate revalidation, non-force removal, and ordering checks. | Only fresh proof mutates; worktree removal precedes branch deletion without force. | Correlate with public mixed apply. | pass | Current apply/reaper and race families passed ordering/argument assertions; public result matched. | +| QA-18 | Task 03 AC4-AC6 transaction/replay | Maintainer; data/backend | Run invalid/stale evidence, store failure, outcome, and replay families. | Failure mutates nothing; replay creates no duplicate transition/event. | Correlate with public reload and idempotent hash. | pass | Store/worktree families passed; public reload found one event and repeat apply kept DB/Git identical. | +| QA-19 | Docs surface; Task 06 AC1-AC7 | User/Agent; docs/CLI | Follow glossary, guides, help, dry-run, JSON, apply, and Runs List instructions. | Reader predicts states/boundaries; links and vocabulary match CLI; no protected Task 06 path. | Execute documented commands in scratch repo. | pass | Help and live commands matched docs; grep/link inspection passed; Task 06 touched only declared docs. | +| QA-20 | Task 07 AC1-AC2 and Agent workflow | Agent; docs/CLI | Follow shipped Skill dry-run-first workflow and compare copies. | Preserve uncertain work, apply only safe, reject manual deletion; copies match. | `cmp`, checks, and live flow. | pass | Shipped guidance and public dry-run/apply matched; copy, sync, digest, and Skill checks passed. | +| QA-21 | Non-Goals: no integration, dirty repair, or failed Task Worktree deletion | User; CLI/backend | Apply divergent, dirty, and Task Worktree-adjacent fixtures. | Unique commit/files and sibling Task Worktree remain. | Fresh commit/file/path/ref reads. | pass | Unique commit and both dirty files remained; `-task_01` worktree/branch survived parent cleanup. | +| QA-22 | Non-Goals: GC ownership; no age/outcome/missing-path cleanup | User; CLI/backend | Inspect docs/help and incomplete-proof fixtures. | No journal/artifact pruning; age/outcome/one path never suffices. | Reopen event and inspect retained unknown state. | pass | Event remained readable; missing-target work stayed preserved; docs keep GC separate and expose no bypass. | +| QA-23 | Non-Goal: no cross-repository bulk invocation | Agent; CLI | Seed multiple repositories and run no-ID/selectors from one. | Only current-repository Runs appear or mutate. | Fresh other-repository Git/DB read. | pass | No-ID selector repository returned zero; cross-repository selector exited 2 without mutation. | +| QA-24 | High-risk probes: repeat, flag order, invalid input, stale proof | Agent; CLI | Repeat apply; move flags; use invalid format/extra arg/force/missing value; run races. | Replay harmless; valid orders agree; invalid exits 2 without mutation; uncertainty preserves. | Hash/Git reread after probes. | pass | Repeat and flag order matched; four malformed forms exited 2; DB/Git unchanged; race families passed. | + +## Acceptance-criterion traceability + +- Task 01 AC1-AC7 → QA-05, QA-16. +- Task 02 AC1-AC7 → QA-08, QA-11-QA-13, QA-15, QA-17. +- Task 03 AC1-AC6 → QA-09-QA-10, QA-13, QA-18. +- Task 04 AC1-AC7 → QA-06, QA-08, QA-11, QA-13-QA-14. +- Task 05 AC1-AC6 → QA-07. +- Task 06 AC1-AC7 → QA-19. +- Task 07 AC1-AC6 → QA-03-QA-04, QA-20. + +## Behavior probes + +- Repeated apply targeted destructive-operation idempotency (QA-11, QA-24). +- Flag ordering and invalid arguments targeted deterministic Agent use without + Interactive Input (QA-24). +- Changed heads, metadata, and cleanliness targeted stale proof at the deletion + boundary (QA-12, QA-24). +- A locked worktree targeted actionable partial-failure recovery without a + force workaround (QA-13). +- Mixed safe and preserved states targeted repository-wide blast radius + (QA-08). + +## Findings + +None on the current build. The prior root-binary finding is fixed: the path is +absent from HEAD, the protected ignore file matches its base blob, and the +scope regression row passed. + +## Blocked and skipped + +None. The sandboxed default-cache denial was environment-caused and did not +block the gate because the exact command completed successfully with approved +normal cache access. + +## Coverage + +Passed: 5/5 user stories, 9/9 Core Features, 5/5 Success Metrics, all 46 Task +acceptance criteria, 6/6 Non-Goals, four Project Constraint categories, four +declared surfaces, and five high-risk behavior probes. + +Results: 25 pass, 0 fail, 0 blocked, 0 skipped, 0 pending. + +Findings by impact: 0 Blocks-Completion, 0 Data-Loss, 0 Trust-Damage, +0 Friction, 0 Cosmetic. + +## Final verdict + +Pass. Every planned row passed on commit +`3744d03669f75d05e8f8e728dc3e03e6300b61ce`, including the prior +scope-integrity failure regression. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_01.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_01.md new file mode 100644 index 00000000..85e57be5 --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_01.md @@ -0,0 +1,138 @@ +--- +task: task_01 +spec: 0038-terminal-run-worktree-reconciliation +status: completed +type: backend +complexity: high +--- + +# Task 01: Classify terminal Run Worktrees with positive proof + +## Overview + +Add one conservative classifier for terminal spec Run Worktrees and Run +Branches. It derives current reconciliation state from recorded Run metadata +and real Git evidence so every consumer receives the same safe, unintegrated, +dirty, unknown, or released result. + +## Requirements + +1. MUST inspect terminal spec Runs from their recorded Git root, worktree, + Run Branch, and target branch metadata. +2. MUST classify `safe` only when the retained worktree is clean and the Run + Branch tip is an ancestor of the current target tip. +3. MUST classify tracked or untracked worktree changes as `dirty`. +4. MUST distinguish `unintegrated`, `unknown`, and `released` without treating + missing proof as safe. +5. MUST report both resolved heads and one bounded reason. +6. MUST reject symlink, path, or metadata input that could escape the recorded + repository boundary. +7. MUST use real Git fixtures for every state and ambiguous-ref failure. + +## Subtasks + +- [x] Add the five reconciliation states and result contract. +- [x] Inspect cleanliness including untracked files. +- [x] Resolve recorded Run and target branch tips. +- [x] Prove ancestry through Git porcelain. +- [x] Classify missing paths, refs, and metadata conservatively. +- [x] Add real-repository fixtures for all classifications. + +## Acceptance Criteria + +- [x] Every terminal fixture produces exactly one documented state. +- [x] A clean reachable branch is `safe`; a clean divergent branch is + `unintegrated`. +- [x] Any tracked or untracked worktree change produces `dirty`. +- [x] Missing target metadata, ambiguous refs, or Git inspection uncertainty + produces `unknown` and preserves work. +- [x] Absence of both retained worktree and Run Branch produces `released`. +- [x] Output contains the recorded paths, both heads when known, and a bounded + deterministic reason. +- [x] No input can make inspection read outside the recorded Git root. + +## 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/worktree/worktree.go` +- interface: `internal/worktree/worktree_test.go` +- interface: `internal/store/store.go` + +## Verification + +- `rtk go test ./internal/worktree -run 'TestInspectTerminalRun.*(Safe|Unintegrated|Dirty|Unknown|Released|UnsafePath)' -count=1` + — expected: real Git fixtures prove all five states and conservative path + handling. +- `rtk go test -race ./internal/worktree -run 'TestInspectTerminalRun' -count=1` + — expected: concurrent inspection fixtures are race-free. + +## References + +- `_prd.md` → Goals 1; User Stories 1 and 4; Core Features 1–3; Success + Metrics. +- `_techspec.md` → Interfaces; Data Models; Testing Approach; Build Order 1. +- `../../adr/0053-terminal-run-worktree-reconciliation-is-proof-based.md` → + positive cleanliness and ancestry proof. + +## Result + +Implemented one read-only terminal Run classifier in `internal/worktree`. It +derives the five reconciliation states from the recorded Git root, Run +Worktree, deterministic Run Branch, target branch, porcelain cleanliness, and +commit ancestry. It resolves refs only after validating local-branch metadata, +rejects ambiguous short refs, and runs worktree status only against a path +registered by the recorded repository. + +### Verification + +- Red baseline: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task01-gocache go test ./internal/worktree -run 'TestInspectTerminalRun' -count=1` + failed to compile because the classifier contract did not exist. +- Focused classifications: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task01-gocache go test ./internal/worktree -run 'TestInspectTerminalRun.*(Safe|Unintegrated|Dirty|Unknown|Released|UnsafePath)' -count=1` + passed. +- Race check: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task01-gocache go test -race ./internal/worktree -run 'TestInspectTerminalRun' -count=1` + passed, including eight concurrent inspections of one real Git fixture. +- Package regression: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task01-gocache go test ./internal/worktree -count=1` + passed. +- Scope and whitespace: + `rtk git -c core.fsmonitor=false diff --check` passed; only this Task file + and its two declared `internal/worktree` interfaces are changed. + +The writable `GOCACHE` wrapper was required because the sandbox denied the +default Go build cache. The Daemon remains responsible for running the two +declared Verification commands verbatim. + +### Acceptance evidence + +- Every classification is covered by a real temporary Git repository and the + result contract permits only `safe`, `unintegrated`, `dirty`, `unknown`, or + `released`. +- `TestInspectTerminalRunSafe`, + `TestInspectTerminalRunSafeWithoutWorktree`, and + `TestInspectTerminalRunUnintegrated` prove cleanliness plus positive + ancestry and divergence behavior. +- `TestInspectTerminalRunDirty` proves tracked and untracked changes produce + `dirty`, including when target metadata is missing. +- `TestInspectTerminalRunUnknownMissingTarget`, + `TestInspectTerminalRunUnknownMissingRunBranch`, and + `TestInspectTerminalRunUnknownAmbiguousRef` prove missing or ambiguous + evidence remains `unknown`. +- `TestInspectTerminalRunReleased` removes both the registered worktree and + Run Branch and proves the result is `released`. +- Every fixture checks the recorded Run ID, outcome, Run Worktree, Run Branch, + target branch, known heads, and one deterministic single-line reason bounded + to 160 bytes. +- `TestInspectTerminalRunUnsafePath` proves unclean paths and final or ancestor + symlinks cannot redirect inspection. Git status runs only in the + repository's registered Run Worktree path. + +### Follow-ups + +Safe cleanup, stale-head revalidation, automatic reaper migration, store +events, CLI behavior, and Run listing remain in their later Spec Tasks. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_02.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_02.md new file mode 100644 index 00000000..f182b667 --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_02.md @@ -0,0 +1,155 @@ +--- +task: task_02 +spec: 0038-terminal-run-worktree-reconciliation +status: completed +type: backend +complexity: high +--- + +# Task 02: Apply stale-proof cleanup and migrate terminal reaping + +## Overview + +Apply reconciliation only after revalidating the evidence immediately before +mutation, then move automatic terminal reaping onto the same classifier. This +slice removes safe or already released residue while preserving any work whose +cleanliness, heads, or ancestry changed after inspection. + +## Requirements + +1. MUST accept cleanup only for a result previously classified `safe`. +2. MUST re-resolve cleanliness, Run head, and target head immediately before + the first mutation. +3. MUST refuse apply when either head, worktree state, or recorded metadata + changed after inspection. +4. MUST remove the Run Worktree without force before deleting its Run Branch. +5. MUST preserve every remaining path or ref when a mutation step fails. +6. MUST make repeated cleanup report `released` without another mutation. +7. MUST replace creation-base terminal reaping with the shared classifier and + permit automatic cleanup only for `safe` or `released`. + +## Subtasks + +- [x] Add stale-proof pre-mutation revalidation. +- [x] Apply worktree removal before branch deletion. +- [x] Preserve partial-failure evidence and remaining resources. +- [x] Make repeated apply idempotent. +- [x] Route automatic terminal reaping through the classifier. +- [x] Reproduce reachable-later-merge and unique-branch regressions. + +## Acceptance Criteria + +- [x] Only a still-clean result with unchanged heads reaches Git mutation. +- [x] A stale head or newly dirty worktree is refused without deleting a path + or ref. +- [x] Worktree removal uses no force option. +- [x] Branch deletion occurs only after successful worktree removal. +- [x] A failed removal or deletion reports the remaining recoverable surface. +- [x] A second apply performs zero mutations and reports `released`. +- [x] Automatic reaping removes a changed branch already reachable from target + and preserves a unique changed branch. + +## Context + +- instruction: `.agents/skills/coding-guidelines/SKILL.md` +- instruction: `.agents/skills/no-workarounds/SKILL.md` +- instruction: `.agents/skills/golang-error-handling/SKILL.md` +- instruction: `.agents/skills/golang-testing/SKILL.md` +- interface: `internal/worktree/worktree.go` +- interface: `internal/worktree/worktree_test.go` +- interface: `internal/cli/implement.go` +- interface: `internal/cli/implement_test.go` + +## Verification + +- `rtk go test ./internal/worktree -run 'TestApplyTerminalRun.*(Safe|Stale|Dirty|Failure|Released)|TestPruneTerminal.*Reconciliation' -count=1` + — expected: only fresh safe evidence mutates Git and repeat apply is + idempotent. +- `rtk go test ./internal/cli -run 'TestRunImplementPreflight.*Terminal.*(Safe|Unique|Reachable)' -count=1` + — expected: automatic reaping shares the proof-based classifier. +- `rtk go test -race ./internal/worktree -run 'Test(ApplyTerminalRun|PruneTerminal)' -count=1` + — expected: inspection-to-apply boundaries remain race-free. + +## References + +- `_prd.md` → Goals 1–2; User Stories 1 and 4; Core Features 4, 7, and 9; + Success Metrics. +- `_techspec.md` → API Contracts; Testing Approach; Build Order 2. +- `../../adr/0023-runs-execute-in-per-run-worktrees.md` → Run Worktree + ownership. +- `../../adr/0053-terminal-run-worktree-reconciliation-is-proof-based.md` → + apply safety. + +## Result + +Implemented stale-proof terminal Run cleanup and moved automatic terminal +reaping onto the shared reconciliation classifier. Inspection now seals the +recorded Run metadata and Git proof used by apply. Apply repeats inspection +immediately before mutation, accepts only inspected `safe` evidence or an +already `released` no-op, removes the Run Worktree without force, and deletes +the Run Branch only after worktree removal succeeds. Typed apply failures +report which path or ref remains. + +Automatic reaping now loads each recorded terminal Run and removes a Run +Worktree or Run Branch only after the classifier returns `safe`; `released` +permits idempotent task-residue handling without another Run mutation. +Implement and force-stop call sites pass the full Run record to that lookup. + +### Verification + +- Red baseline: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task02-gocache go test ./internal/worktree -run 'TestApplyTerminalRun.*(Safe|Stale|Dirty|Failure|Released)|TestPruneTerminal.*Reconciliation' -count=1` + failed to compile because `ApplyTerminalRun`, its revalidation seam, and the + recorded-Run reaper lookup did not exist. +- Focused apply and reaper: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task02-gocache go test ./internal/worktree -run 'TestApplyTerminalRun.*(Safe|Stale|Dirty|Failure|Released)|TestPruneTerminal.*Reconciliation' -count=1` + passed. +- Implement Preflight: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task02-gocache go test ./internal/cli -run 'TestRunImplementPreflight.*Terminal.*(Safe|Unique|Reachable)' -count=1` + passed. +- Race check: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task02-gocache go test -race ./internal/worktree -run 'Test(ApplyTerminalRun|PruneTerminal)' -count=1` + passed. +- Worktree package regression: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task02-gocache go test ./internal/worktree -count=1` + passed. +- Additional CLI package run: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task02-gocache go test ./internal/cli -count=1` + reached the unrelated host restriction + `fork/exec /bin/ps: operation not permitted` in + `TestRunForceStopOwnerProcessIntegrationProvesExitBeforeStoreCompletion`; + the Task's focused CLI command passed. + +The writable `GOCACHE` wrapper was required because the sandbox denied the +default Go build cache. The Daemon remains responsible for running the three +declared Verification commands verbatim. + +### Acceptance evidence + +- `TestApplyTerminalRunSafeEvidenceOnly`, `TestApplyTerminalRunStaleHeads`, + `TestApplyTerminalRunStaleMetadata`, and `TestApplyTerminalRunDirty` prove + that only sealed, still-clean `safe` evidence with unchanged heads reaches + mutation; every stale case preserves the Run Worktree and Run Branch. +- `TestApplyTerminalRunRemovalFailure` records the exact + `git worktree remove ` arguments, proves `--force` is absent, and + proves no branch deletion follows a failed removal. +- `TestApplyTerminalRunDeletionFailure` records worktree removal before + `git branch -D` and proves a deletion failure leaves the Run Branch + recoverable. +- Both failure tests inspect `TerminalRunApplyError` and prove its remaining + worktree and branch fields match the resources left after the failed step. +- `TestApplyTerminalRunReleased` repeats apply against the original safe + evidence, observes zero mutation calls, and confirms a fresh inspection + reports `released`. +- `TestPruneTerminalReconciliationReachableChangedBranch` removes a changed + Run Branch already reachable from the recorded target; + `TestPruneTerminalReconciliationPreservesUniqueChangedBranch` preserves a + divergent changed branch. +- `TestRunImplementPreflightTerminalReachableChangedBranch` and + `TestRunImplementPreflightTerminalUniqueChangedBranch` reproduce those two + outcomes through the real Implement Preflight and Run Database lookup. + +### Follow-ups + +Durable reconciliation events, the explicit Reconcile Command, and terminal +Run listing remain in their later Spec Tasks. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_03.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_03.md new file mode 100644 index 00000000..7a0a68ae --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_03.md @@ -0,0 +1,145 @@ +--- +task: task_03 +spec: 0038-terminal-run-worktree-reconciliation +status: completed +type: data +complexity: high +--- + +# Task 03: Reconcile Integration Pending with durable evidence + +## Overview + +Connect fresh `safe` Git proof to the guarded Integration Pending transition +without weakening ordinary terminal immutability. The Run becomes Clean and +records its reconciliation evidence transactionally before any retained Git +surface is released; other outcomes retain their stored state. + +## Requirements + +1. MUST build reconciliation input from the freshly revalidated Run and target + heads. +2. MUST invoke the guarded Integration Pending to Clean store operation before + Git cleanup. +3. MUST record prior outcome, current outcome, classification, both branches, + both heads, worktree, and action in one durable Run Event. +4. MUST leave every non-Integration-Pending terminal outcome unchanged while + recording safe cleanup evidence. +5. MUST refuse stale, incomplete, or mismatched reconciliation input. +6. MUST perform no Git cleanup when durable reconciliation persistence fails. +7. MUST make repeated reconciliation and cleanup idempotent. + +## Subtasks + +- [x] Map classifier evidence into the guarded store request. +- [x] Journal complete reconciliation evidence transactionally. +- [x] Preserve non-Integration-Pending outcomes. +- [x] Order durable state before Git cleanup. +- [x] Add stale and incomplete-evidence refusal cases. +- [x] Add repeat reconciliation coverage. + +## Acceptance Criteria + +- [x] A safe Integration Pending Run becomes Clean with one evidence event. +- [x] The event contains both outcomes, branches, heads, worktree, and action. +- [x] Unresolved, Failed, Stopped, and other terminal outcomes remain unchanged + after safe cleanup. +- [x] Missing or stale evidence produces no state or Git mutation. +- [x] Database failure starts no worktree or branch removal. +- [x] Repeating the operation produces no duplicate transition or evidence. + +## 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/store.go` +- interface: `internal/store/store_test.go` +- interface: `internal/store/journal.go` +- interface: `internal/store/journal_test.go` +- interface: `internal/worktree/worktree.go` +- interface: `internal/worktree/worktree_test.go` + +## Verification + +- `rtk go test ./internal/store -run 'TestReconcileIntegration.*(Safe|Stale|Invalid|Idempotent|Outcome)' -count=1` + — expected: only Integration Pending with complete fresh evidence becomes + Clean transactionally. +- `rtk go test ./internal/worktree -run 'TestApplyTerminalRun.*(IntegrationPending|StoreFailure|Outcome)' -count=1` + — expected: durable reconciliation precedes Git cleanup and other outcomes + remain unchanged. + +## References + +- `_prd.md` → Goal 4; User Story 5; Core Feature 6; Success Metrics. +- `_techspec.md` → Data Models; Integration Points: Run Database; Build Order + 3. +- `../0037-terminal-outcome-integrity/_techspec.md` → guarded terminal + reconciliation boundary. +- `../../adr/0052-run-completion-is-compare-and-set.md` → sole terminal + transition exception. + +## Result + +Implemented the durable reconciliation barrier shared by explicit terminal Run +apply and automatic terminal reaping. Freshly revalidated `safe` evidence is +mapped into one guarded Store request before Git mutation. The Store validates +the recorded Run outcome, deterministic Run Branch, target branch, and +worktree; promotes only Integration Pending to Clean; preserves every other +terminal outcome; and appends the complete cleanup proof in the same +transaction. Exact evidence replay is a no-op. + +### Verification + +- Red baseline: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task03-gocache go test ./internal/store -run 'TestReconcileIntegration.*(Safe|Stale|Invalid|Idempotent|Outcome)' -count=1` + failed to compile because the Store request lacked prior outcome, + classification, Run Branch, worktree, and action evidence. +- Store reconciliation family: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task03-gocache go test ./internal/store -run 'TestReconcileIntegration' -count=1` + passed. +- Task-focused worktree reconciliation: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task03-gocache go test ./internal/worktree -run 'TestApplyTerminalRun.*(IntegrationPending|StoreFailure|Outcome)' -count=1` + passed. +- Worktree package regression: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task03-gocache go test ./internal/worktree -count=1` + passed. +- Automatic-reaper integration: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task03-gocache go test ./internal/cli -run 'TestRunImplementPreflight.*Terminal.*(Safe|Unique|Reachable)' -count=1` + passed. +- CLI compile check: + `rtk proxy env GOCACHE=/private/tmp/roundfix-task03-gocache go test ./internal/cli -run '^$' -count=1` + passed. +- The broad `internal/store` package run reached unrelated sandbox failures + because `/bin/ps` is not permitted in owner-process tests; the complete + reconciliation test family above passed. The Daemon remains responsible for + running the two declared Verification commands verbatim. + +### Acceptance evidence + +- `TestApplyTerminalRunIntegrationPendingPersistsBeforeCleanup` proves a fresh + safe Integration Pending Run becomes Clean, records exactly one event before + cleanup, removes both Git surfaces, and produces no duplicate event on + repeat. +- `TestReconcileIntegrationSafeCompleteEvidence` decodes the durable payload + and checks prior/current outcomes, classification, Run Branch and head, + target branch and head, worktree, and action. +- `TestReconcileIntegrationOutcomePreservedWithEvidence` and + `TestApplyTerminalRunOutcomeRemainsUnchanged` cover Unresolved, Failed, + Stopped, and Timed Out outcomes: cleanup evidence is recorded while the Run + row remains unchanged. +- `TestReconcileIntegrationInvalidCompleteEvidence`, + `TestReconcileIntegrationRejectsStaleTargetBranch`, and the existing + stale-head, changed-metadata, and newly-dirty worktree tests prove incomplete, + mismatched, or stale proof changes neither durable outcome nor Git surface. +- `TestApplyTerminalRunStoreFailureStartsNoCleanup` injects persistence failure + after fresh revalidation and observes zero worktree/branch mutation calls. +- `TestReconcileIntegrationIdempotent` and the repeated real-Git apply in + `TestApplyTerminalRunIntegrationPendingPersistsBeforeCleanup` prove replay + performs no duplicate transition, event, worktree removal, or branch + deletion. + +### Follow-ups + +The public Reconcile Command, result rendering, and selector behavior remain +Task 04. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_04.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_04.md new file mode 100644 index 00000000..7e41090d --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_04.md @@ -0,0 +1,134 @@ +--- +task: task_04 +spec: 0038-terminal-run-worktree-reconciliation +status: completed +type: backend +complexity: high +--- + +# Task 04: Deliver the Reconcile Command and apply contract + +## Overview + +Expose proof-based reconciliation through a deterministic, dry-run-first CLI +for one terminal spec Run or every terminal spec Run in the current repository. +Text and versioned JSON report the same evidence; `--apply` acts only on results +proven safe during the current invocation. + +## Requirements + +1. MUST implement `roundfix reconcile [run-id] [--apply] [--format text|json]`. +2. MUST reject Active Runs, review Runs, missing Runs, and cross-repository + selectors before any Git mutation. +3. MUST scope the no-ID form to terminal spec Runs in the current Git root. +4. MUST default to a read-only report and print the exact apply command. +5. MUST render deterministic text and a versioned JSON envelope with ordered + results and summary counts. +6. MUST apply only entries classified and revalidated `safe` in that + invocation; no force or assertion bypass is permitted. +7. MUST keep expected preserved states successful while making operational Git + or database failures non-zero and actionable. + +## Subtasks + +- [x] Add Reconcile Command parsing and usage. +- [x] Resolve one-Run and repository-wide scopes. +- [x] Render deterministic text and versioned JSON. +- [x] Connect dry-run and explicit apply behavior. +- [x] Preserve mixed safe and unsafe scan results. +- [x] Add idempotent repeat and invalid-selector coverage. + +## Acceptance Criteria + +- [x] Default invocation changes neither Git nor the Run Database. +- [x] One-Run and repository-wide results are ordered newest-first. +- [x] Text and JSON expose classification, branches, heads, worktree, evidence, + action, and refusal reason consistently. +- [x] `--apply` removes all and only fresh `safe` results. +- [x] Dirty, unintegrated, unknown, and released results are preserved and + reported without turning a complete scan into failure. +- [x] Operational inspection or apply failure returns the Run failure exit code + and names the next safe action. +- [x] A second apply reports `released` and performs zero mutations. + +## Context + +- instruction: `.agents/skills/agentic-cli-design/SKILL.md` +- instruction: `.agents/skills/coding-guidelines/SKILL.md` +- instruction: `.agents/skills/golang-cli/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/cli/runs.go` +- interface: `internal/worktree/worktree.go` + +## Verification + +- `rtk go test ./internal/cli -run 'TestRunReconcile.*(DryRun|Apply|JSON|Repository|Invalid|Mixed|Idempotent)' -count=1` + — expected: command scope, output, mutation, refusal, and repeat contracts + pass. +- `rtk go test -race ./internal/cli ./internal/worktree -run 'Test.*Reconcile' -count=1` + — expected: CLI inspection and apply coordination are race-free. +- `rtk go build -buildvcs=false ./cmd/roundfix` + — expected: the public command builds with repository build settings. + +## References + +- `_prd.md` → Goals 1–2; User Stories 1–2 and 4–5; Core Features 4–7; User + Experience; Success Metrics. +- `_techspec.md` → API Contracts; Integration Points; Build Order 4. +- `../../adr/0053-terminal-run-worktree-reconciliation-is-proof-based.md` → + explicit dry-run/apply command. + +## Result + +Implemented the Reconcile Command as a dry-run-first CLI over the shared +proof-based classifier. A positional Run ID and flags work in either order; +the no-ID form queries only terminal spec Runs for the canonical current Git +root. Text and `roundfix-reconcile/v1` JSON carry the same ordered evidence, +actions, refusal reasons, and summary counts. Apply opens the writable Run +Database only when the current scan contains a `safe` result, then delegates +each such result to stale-proof revalidation and cleanup. Expected preserved +states remain successful, while inspection, persistence, cleanup, and output +failures return the Run failure exit code with a rerun command. + +Verification: + +- `rtk go test ./internal/cli -run 'TestRunReconcile.*(DryRun|Apply|JSON|Repository|Invalid|Mixed|Idempotent)' -count=1` + — passed, 12 tests. +- `rtk go test -race ./internal/cli ./internal/worktree -run 'Test.*Reconcile' -count=1` + — passed, 12 tests across both packages. +- `rtk go build -buildvcs=false ./cmd/roundfix` — passed. +- `rtk go test ./internal/cli -count=1` — 779 tests passed; the sole remaining + sandbox failure was the pre-existing owner-process integration test because + `/bin/ps` was denied. Running that exact test outside the sandbox passed. +- `rtk git diff --check` — passed. + +Acceptance evidence: + +- `TestRunReconcileDryRunReadOnly` compares the Run Database bytes and the + complete Git worktree/ref surface before and after the default invocation; + both remain identical. +- `TestRunReconcileRepositoryScopeNewestFirst` pins creation timestamps, + observes newest-first current-repository output, and excludes a newer Run + belonging to another repository. A selected one-Run report contains exactly + that Run. +- `TestRunReconcileDryRunReadOnly` and + `TestRunReconcileJSONMatchesTextFields` cover classification, Run and target + branches and heads, worktree, evidence, action, refusal reason, schema + version, mode, repository, apply command, and summary counts. +- `TestRunReconcileApplyMixedResults` creates `safe`, `dirty`, `unintegrated`, + `unknown`, and `released` real-Git fixtures. Apply removes only the freshly + revalidated `safe` worktree and branch; all three preserved work surfaces + remain and the scan exits successfully. +- `TestRunReconcileApplyFailureNamesNextSafeAction` uses a locked clean + worktree to make real Git removal fail. The command exits with the Run + failure code, reports one operational failure and retained evidence, and + prints the exact safe rerun action. +- `TestRunReconcileIdempotentApply` proves the first apply reconciles + Integration Pending to Clean and releases Git state. The second apply + reports `released`, performs zero Git or Run Database mutations, and reports + `applied=0`. + +Follow-ups: none. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_05.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_05.md new file mode 100644 index 00000000..3ae3b169 --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_05.md @@ -0,0 +1,114 @@ +--- +task: task_05 +spec: 0038-terminal-run-worktree-reconciliation +status: completed +type: backend +complexity: medium +--- + +# Task 05: Surface retained terminal Run Worktrees in Runs List + +## Overview + +Make retained terminal Run Worktrees discoverable without changing the stable +Runs List stdout rows. Each view reports the exact relevant count on stderr and +points to the Reconcile Command as the sole classification surface. + +## Requirements + +1. MUST preserve existing Runs List stdout row shape byte-for-byte. +2. MUST count terminal spec Runs that retain a recorded worktree path or Run + Branch. +3. MUST report hidden retained residue when the default Active view has no + matching terminal rows. +4. MUST report retained residue represented in terminal and all-state views. +5. MUST write the bounded note to stderr only. +6. MUST point to `roundfix reconcile` without classifying safety in Runs List. +7. MUST omit the note when no retained terminal surface exists. + +## Subtasks + +- [x] Derive repository-scoped retained-worktree counts. +- [x] Add the exact stderr guidance. +- [x] Preserve stdout row serialization. +- [x] Cover Active, terminal, and all-state views. +- [x] Cover zero-retention and mixed-repository cases. + +## Acceptance Criteria + +- [x] Active view reports the exact hidden terminal residue count on stderr. +- [x] Terminal and all-state views report only retained Runs relevant to their + repository scope. +- [x] Existing stdout rows remain byte-identical. +- [x] The note contains the exact `roundfix reconcile` pointer. +- [x] Runs List never labels a retained entry safe or unsafe. +- [x] No note is printed when neither worktree nor Run Branch remains. + +## Context + +- instruction: `.agents/skills/agentic-cli-design/SKILL.md` +- instruction: `.agents/skills/golang-cli/SKILL.md` +- instruction: `.agents/skills/golang-testing/SKILL.md` +- interface: `internal/cli/runs.go` +- interface: `internal/cli/cli_test.go` +- interface: `internal/store/store.go` +- interface: `internal/worktree/worktree.go` + +## Verification + +- `rtk go test ./internal/cli -run 'TestRun.*List.*Retained.*Worktree' -count=1` + — expected: all views report exact stderr counts while stdout stays + byte-stable. +- `rtk go test ./internal/cli -run 'TestRun.*List.*(Active|Terminal|All)' -count=1` + — expected: existing Runs List view contracts remain passing. + +## References + +- `_prd.md` → Goal 3; User Story 3; Core Feature 8; Success Metrics. +- `_techspec.md` → API Contracts: Run listing; Testing Approach; Build Order 5. +- `CONTEXT.md` → Runs List and Reconcile Command vocabulary. + +## Result + +Runs List now derives one exact retained terminal Run Worktree count from its +unbounded, repository-scoped Run query. The shared proof-based inspector +distinguishes retained terminal spec Runs from `released` history, including +path-and-branch, path-only, and Run-Branch-only residue. When the count is +non-zero, stderr prints one bounded note that points to +`roundfix reconcile`; stdout row formatting and empty-result output are +unchanged. When no retained surface exists, the existing hidden-row guidance +remains the fallback. + +Verification: + +- `rtk proxy env GOCACHE=/private/tmp/roundfix-task05-gocache go test ./internal/cli -run 'TestRun.*List.*Retained.*Worktree' -count=1` + — passed. +- `rtk proxy env GOCACHE=/private/tmp/roundfix-task05-gocache go test ./internal/cli -run 'TestRun.*List.*(Active|Terminal|All)' -count=1` + — passed. +- `rtk proxy env GOCACHE=/private/tmp/roundfix-task05-gocache go test ./internal/cli -count=1` + — passed. +- `rtk proxy env GOCACHE=/private/tmp/roundfix-task05-gocache make verify` + — passed. +- The first focused invocation without the task-local `GOCACHE` did not reach + compilation because the sandbox denied the host Go cache; the equivalent + task-local-cache invocation above passed. + +Acceptance evidence: + +- `TestRunRunsListActiveReportsRetainedWorktreesWithoutChangingStdout` creates + path-and-branch, branch-only, path-only, and released terminal spec Runs. The + Active view reports exactly three retained Runs on stderr and matches the + pre-existing stdout row serialization byte-for-byte. +- `TestRunRunsListTerminalAndAllReportRetainedWorktreesByRepository` proves + terminal and all-state views count one retained Run in the current + repository and two with `--all`, while excluding the other repository from + repository-scoped stdout. +- The retained note is exactly + `(N terminal Run Worktree(s) retained; run 'roundfix reconcile' to inspect)` + with grammatical singular/plural rendering. Tests reject the words `safe` + and `unsafe` and confirm the pointer never enters stdout. +- `TestRunRunsListRetainedWorktreeNoteOmittedWhenReleased` removes both the Run + Worktree and Run Branch, keeps the terminal history row visible, and observes + empty stderr. + +Follow-ups: none. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_06.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_06.md new file mode 100644 index 00000000..e650c855 --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_06.md @@ -0,0 +1,130 @@ +--- +task: task_06 +spec: 0038-terminal-run-worktree-reconciliation +status: completed +type: docs +complexity: medium +--- + +# Task 06: Align reconciliation docs and glossary + +## Overview + +Publish the Reconcile Command, five reconciliation states, dry-run/apply flow, +and Runs List discovery contract through supported documentation and canonical +vocabulary. This slice leaves every protected Skill path unchanged. + +## Requirements + +1. MUST define the Reconcile Command and Run Worktree Reconciliation states in + canonical glossary vocabulary. +2. MUST document single-Run and repository-wide dry-run examples. +3. MUST document `--apply` as the only mutation switch and the absence of a + force bypass. +4. MUST explain positive cleanliness and ancestry proof plus conservative + preservation of dirty, unintegrated, and unknown work. +5. MUST document Integration Pending promotion and unchanged other outcomes. +6. MUST document Runs List stderr discovery without promising classification. +7. MUST preserve ADR/finding traceability and leave protected tooling + unchanged. + +## Subtasks + +- [x] Add canonical reconciliation vocabulary. +- [x] Update command and usage documentation. +- [x] Add dry-run, JSON, and explicit apply examples. +- [x] Explain state, proof, and preservation semantics. +- [x] Document Runs List retained-worktree guidance. +- [x] Resolve ADR, Spec, finding, and command links. + +## Acceptance Criteria + +- [x] A reader can predict all five states from the documented evidence rules. +- [x] Examples distinguish requested stdout from diagnostic stderr. +- [x] No documentation suggests age, outcome, missing path, or force is + sufficient for deletion. +- [x] Integration Pending and other terminal outcome behavior is explicit. +- [x] Runs List guidance points to Reconcile Command for classification. +- [x] Every cited link resolves and terminology matches `CONTEXT.md`. +- [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 'roundfix reconcile\|Reconcile Command\|safe\|unintegrated\|released' CONTEXT.md docs/user-guide/commands.md docs/user-guide/usage.md` + — expected: canonical command and state guidance is present. +- `rtk go test ./internal/cli -run 'Test.*(Reconcile.*Help|DocumentationContract|RunsList.*Retained)' -count=1` + — expected: command and Runs List public contracts match documentation. +- `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/0053-terminal-run-worktree-reconciliation-is-proof-based.md` → + canonical reconciliation behavior. + +## Result + +Published the Reconcile Command as a dry-run-first, proof-based workflow with +single-Run, repository-wide, JSON, and explicit apply examples. The canonical +vocabulary and user guides now define all five reconciliation states, separate +requested stdout from diagnostic stderr, explain conservative preservation, +and route Runs List discovery to the Reconcile Command without treating the +listing as a classification surface. + +Verification: + +- `rtk grep -n 'roundfix reconcile\|Reconcile Command\|safe\|unintegrated\|released' CONTEXT.md docs/user-guide/commands.md docs/user-guide/usage.md` + passed and found the canonical command, all-state guidance, dry-run/apply + examples, and retained-worktree note across the three supported documents. +- `rtk go test ./internal/cli -run 'Test.*(Reconcile.*Help|DocumentationContract|RunsList.*Retained)' -count=1` + passed with 30 tests in one package. +- `rtk git diff --check` passed. +- Link and anchor inspection found `CONTEXT.md`, ADR-0053, Spec 0038, the + detached-watch finding, the Reconcile Command heading, the Stop Command + heading, and finding 4 at their cited paths and anchors. +- `rtk git -c core.fsmonitor=false diff --name-only` listed only `CONTEXT.md`, + this Task file, `docs/user-guide/commands.md`, and + `docs/user-guide/usage.md`. + +Acceptance evidence: + +- The state table and glossary define `safe`, `unintegrated`, `dirty`, + `unknown`, and `released` from cleanliness, ref resolution, ancestry, and + surface-presence evidence. +- Redirection examples send Reconcile reports and Runs List rows to stdout + files while sending Runs List discovery guidance to a separate stderr file. +- Both guides state that age, terminal outcome, one missing path, and a force + assertion are insufficient; only freshly revalidated `safe` evidence can + release work. +- The apply contract records a safe Integration Pending Run as Clean and keeps + every other terminal outcome unchanged. +- Runs List keeps its stdout rows unchanged, reports the retained count on + stderr without classification, and points to `roundfix reconcile`. +- The link inspection and glossary comparison resolved every new citation and + kept canonical capitalization and terminology. +- The changed-path inspection proves that neither protected Roundfix Skill path + changed in this Task. + +Follow-ups: none. + +### Verification feedback attempt 1 + +The Daemon exposed an escaping defect in the first Verification command: its +task-file pattern contained two literal backslashes before each basic regular +expression alternation, so the exact extracted command matched none of the +documented terms. The Verification contract now stores one backslash before +each alternation. The corrected command passed locally against all three +documented surfaces; the Daemon remains authoritative for the full rerun. diff --git a/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_07.md b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_07.md new file mode 100644 index 00000000..713899ff --- /dev/null +++ b/docs/specs/_archived/0038-terminal-run-worktree-reconciliation/task_07.md @@ -0,0 +1,193 @@ +--- +task: task_07 +spec: 0038-terminal-run-worktree-reconciliation +status: completed +type: docs +complexity: low +--- + +# Task 07: Align the protected Roundfix Skill pair + +## Overview + +Publish proof-based Run Worktree reconciliation in the canonical and generated +Roundfix Skill. This tooling-only slice is restricted to the two exact files +authorized by the maintainer and this Task file. + +## Requirements + +1. MUST align `.agents/skills/roundfix/SKILL.md` with dry-run-first + reconciliation, five states, and explicit safe-only apply. +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 docs, other owned Skills, + upstream-managed Skills, locks, and recommendation files unchanged. + +## Subtasks + +- [x] Update the canonical Roundfix Skill reconciliation contract. +- [x] Apply the identical generated Roundfix Skill copy. +- [x] Verify exact changed-file scope and byte identity. +- [x] Confirm shipped Skill and full-gate compatibility. + +## Acceptance Criteria + +- [x] The Skill tells an Agent to inspect before apply and never use manual Git + deletion as the supported workflow. +- [x] Dirty, unintegrated, and unknown results are preserved in Skill guidance. +- [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/0038-terminal-run-worktree-reconciliation/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 maintainer-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 + +The canonical and generated Roundfix Skill now direct Agents to inspect with a +dry-run before apply, define all five Run Worktree Reconciliation states, +preserve `unintegrated`, `dirty`, and `unknown` work, allow cleanup only for +freshly revalidated `safe` entries, and prohibit manual Git or filesystem +deletion as the supported workflow. + +### First-pass acceptance evidence (before maintainer authorization) + +1. `rtk rg -n '^## Run Worktree reconciliation|Always run a dry-run|safe|unintegrated|dirty|unknown|released|Never substitute manual Git deletion' .agents/skills/roundfix/SKILL.md` + found the dry-run instruction, all five states, preservation rules, and the + manual-deletion prohibition. +2. `rtk cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md` exited + zero with no output. +3. `rtk git -c core.fsmonitor=false status --porcelain` and + `rtk git -c core.fsmonitor=false diff --name-only` reported only the two + authorized Skill paths and this Task file. +4. `rtk make skills-sync-check` passed: four focused Skill tests passed and + every canonical/generated owned Skill pair had no drift. +5. `rtk go run -buildvcs=false ./cmd/roundfix skills check` passed for every + shipped Roundfix-owned Skill. +6. `rtk git diff --check` passed with no whitespace errors. +7. `rtk make verify` failed during `go test ./...`: 2,556 tests passed, seven + failed, and two skipped. Five store/CLI failures could not read process + identity because the host could not fork `/usr/bin/ps`; the deterministic + blocking failure was + `TestAuthorialSkillSync/typescript-bun.json`. +8. `rtk proxy go test -count=1 ./skills -run 'TestAuthorialSkillSync/typescript-bun.json'` + reproduced the deterministic failure: the snapshot records Roundfix Skill + digest `fa774d82f16661c81c235738c78116fba2fdc328bac5797fd663fa31a05f42d6`, + while the updated canonical Skill requires + `91b833ef01c723b308604e57dc4075ec8e216880c8d50cf493d7dbced7096f6d`. + +### Maintainer-authorized derived digest propagation + +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. + +On 2026-07-27 the maintainer expressly authorized the deterministic +Skill-digest fallout of the authorized Skill edit in exactly the five derived +paths now named in the `_prd.md` and `_techspec.md` Tooling authority entries. +Under that authorization the roundfix Skill entry's `contentDigest` in +`internal/baseline/assets/setups/typescript-bun.json` was updated from +`fa774d82f16661c81c235738c78116fba2fdc328bac5797fd663fa31a05f42d6` to the +canonical +`91b833ef01c723b308604e57dc4075ec8e216880c8d50cf493d7dbced7096f6d`. Because +that value feeds further derived identity pins, the same mechanical fallout +propagated to: + +- the setup's own canonical `digest` in the same file + (`36f512c6c0370aab357c345a8bf2ceb902738ff837bdd34da7c1c2085567533c` → + `fe98e52e2a6812b899bd4a048c29afc515e9736ddc4df7120bd8f9b1cf7d9896`, the value + the catalog validator reports as canonical through + `catalog.setup.digest.mismatch`); +- the roundfix `contentDigest` row in + `internal/baseline/testdata/parity-corpus/v1/fixtures/asset-sync.json`, moved + to the same canonical Skill digest; +- the catalog identity fixtures + `internal/baseline/testdata/catalog.normalized.json` (the + `setups/typescript-bun.json` file digest `sha256:31c1b8c9…` → + `sha256:411768db…`, byte count unchanged at 40,649) and + `internal/baseline/testdata/catalog.digest` + (`sha256:c0435d250c1440584454009211b5f044711382fb4d9a01e9dc4e97b91e3ca014` → + `sha256:c715657c891c29b108e58a73d20c8bb6b9647cf150852f3600e29b65a950a70f`), + both regenerated from `Catalog.Normalized()` and `Catalog.Digest()` of the + embedded catalog; +- the `fixtures/asset-sync.json` sha256 row in + `internal/baseline/testdata/parity-corpus/v1/manifest.json` + (`31620de106dfae46414ea11c8ff420e611b1c874c5d4e2936d8f37234142c59b` → + `cf7dc89818d21d5f7bebb91c2a920f642058db8184285a6ec572c3d1f7827248`, byte count + unchanged at 83,662). + +No `SKILL.md`, manifest task file, or configuration outside those derived pins +changed in this settlement pass, and `make skills-sync` was still not run. + +### Final verification evidence + +Run in the Run worktree with `GOFLAGS=-buildvcs=false` and a portable +`GOCACHE`: + +- `go test ./skills/... -count=1` — passed: `ok roundfix/skills 0.604s`; + `TestAuthorialSkillSync` and all its subtests, including + `typescript-bun.json`, report PASS. +- `go test ./internal/baseline -count=1` — passed: + `ok roundfix/internal/baseline 82.706s`. +- `go test ./...` — full suite green: every package reports `ok` + (`internal/baseline 109.188s`, `internal/cli 169.515s`, `skills 3.538s`, and + all remaining packages), with no failures. The `/usr/bin/ps` fork failures + seen in the first pass did not recur. +- `make verify` — passed end to end: formatting check clean, `go test ./...` + reported `2563 passed in 23 packages`, `skills-sync-check` reported + `4 passed in 1 packages` with no canonical/generated drift, + `go run -buildvcs=false ./cmd/roundfix skills check` reported + `Roundfix skill check passed` for all fourteen shipped Skills, and the build + produced `bin/roundfix`. +- `cmp .agents/skills/roundfix/SKILL.md skills/roundfix/SKILL.md` — no output + and exit zero; the pair is still byte-identical. +- `git status --porcelain` — the only changed code and testdata paths are the + two authorized `SKILL.md` files and the five maintainer-authorized derived + digest pins, alongside this Task file and the `_prd.md` and `_techspec.md` + Tooling authority entries that record the authorization. + +### Follow-up + +Resolved. The maintainer expressly authorized updating the derived Skill-digest +pins as mechanical fallout of the authorized Skill edit, the `_prd.md` and +`_techspec.md` Tooling authority entries record that authorization, and this +Task's changed-file allowlist now names the same five derived paths. The Skill +pair is aligned, the derived pins are canonical, and the complete repository +gate is green. diff --git a/docs/specs/_reviews/pr-39/round-001/issue_001.md b/docs/specs/_reviews/pr-39/round-001/issue_001.md new file mode 100644 index 00000000..35388a9c --- /dev/null +++ b/docs/specs/_reviews/pr-39/round-001/issue_001.md @@ -0,0 +1,78 @@ +--- +source: coderabbit +pr: "39" +round: 1 +round_created_at: "2026-07-27T21:22:30Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-run-worktree-reconciliation +head_sha: 44fa422cea404a2d8c47e4b8011f065c4c0481ba +file: internal/cli/runs.go +line: 114 +severity: major +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UNfMO,comment:PRRC_kwDOS0qyts7aNkKv +review_hash: 4a561e8878c0559f6f5c6fddd6c5d60f4150c85146d4071a7a1708f4d40df054 +duplicate_of: "" +source_review_id: "4791610618" +source_review_submitted_at: "2026-07-27T21:21:25Z" +--- + +# Issue 001: _ Performance & Scalability_ _ Major_ _ Quick win_ + +## Review Comment + +_🚀 Performance & Scalability_ | _🟠 Major_ | _⚡ Quick win_ + +**`runs list` now forks Git per terminal Run on every invocation.** `countRetainedTerminalRunWorktrees` inspects every terminal implement Run returned by the unbounded all-states query, even when the user asked for `--state active` or a small `--limit`. Each `InspectTerminalRun` call executes several Git subprocesses, so listing cost grows linearly with terminal Run history and turns a cheap DB read into a multi-second operation on repositories with a long Run history. + +Consider bounding this: skip inspection when the recorded `WorkDir` is empty, cap the number of inspected Runs, or compute the note only for states the listing is actually reporting. + +Also note the swallowed error at Line 176 — an inspection failure is silently counted as retained with no diagnostic, which will mask Git problems. + + + + + + +Also applies to: 169-181 + +
+🤖 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/runs.go` at line 114, Update countRetainedTerminalRunWorktrees +and its InspectTerminalRun loop to avoid inspecting every terminal Run from the +unbounded query: skip empty WorkDir values and bound inspections to Runs +relevant to the requested state/limit, or otherwise enforce a safe cap. In the +inspection error path around the retained-worktree count, report the failure +with diagnostic context instead of silently treating it as retained. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: Runs List performed a full reconciliation inspection, including + multiple Git subprocesses, for every terminal implement Run in the + unbounded history query. It also converted inspection errors into retained + counts without a diagnostic. The fix batches Git root validation and Run + Branch listing once per repository, checks recorded paths without Git, and + emits contextual warnings for repository or path inspection failures. + +## Verification + +- `rtk proxy env GOCACHE=/private/tmp/roundfix-pr39-batch001-gocache go test ./internal/worktree ./internal/cli ./internal/store -run 'Test(CountRetainedTerminalRunsBatchesGitInspectionByRepository|InspectTerminalRunUnsafePath|RunRunsList.*Retained.*Worktree|RunRunsListReportsRetainedWorktreeInspectionFailure|PruneTerminalReapsOnlyEmptyTerminalRunAndTaskBranches|ReconcileIntegration)' -count=1` + — passed. diff --git a/docs/specs/_reviews/pr-39/round-001/issue_002.md b/docs/specs/_reviews/pr-39/round-001/issue_002.md new file mode 100644 index 00000000..23ef8e29 --- /dev/null +++ b/docs/specs/_reviews/pr-39/round-001/issue_002.md @@ -0,0 +1,108 @@ +--- +source: coderabbit +pr: "39" +round: 1 +round_created_at: "2026-07-27T21:22:30Z" +status: invalid +terminal_reason: "Archived Spec 0038 makes retained-worktree guidance the sole trailing note, so emitting hidden guidance too would violate the shipped CLI contract." +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-run-worktree-reconciliation +head_sha: 44fa422cea404a2d8c47e4b8011f065c4c0481ba +file: internal/cli/runs.go +line: 127 +severity: minor +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UNfMU,comment:PRRC_kwDOS0qyts7aNkK2 +review_hash: 1bf400f2fe9ff6c784bfa6c69c2be7ccb18d3f20e416b75dd5962a5ad277cacc +duplicate_of: "" +source_review_id: "4791610618" +source_review_submitted_at: "2026-07-27T21:21:25Z" +--- + +# Issue 002: _ Maintainability & Code Quality_ _ Minor_ _ Quick win_ + +## Review Comment + +_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_ + +**Retained-worktree note suppresses the hidden/older guidance.** When any terminal worktree is retained, the user no longer sees the `--state all` / `--limit 0` hint even though rows are hidden. Printing both notes keeps the pagination guidance intact. + +
+🛠️ Proposed fix + +```diff +- note := runsListRetainedWorktreeNote(retainedWorktrees) +- if note == "" { +- note = runsListHiddenNote(opts.state, len(runs), len(matching), len(visible)) +- } +- if note != "" { +- fmt.Fprintln(stderr, note) ++ for _, note := range []string{ ++ runsListHiddenNote(opts.state, len(runs), len(matching), len(visible)), ++ runsListRetainedWorktreeNote(retainedWorktrees), ++ } { ++ if note != "" { ++ fmt.Fprintln(stderr, note) ++ } + } +``` +
+ +Note the added tests at `internal/cli/cli_test.go` assert exact stderr equality and would need updating. + + + +
+📝 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 + for _, note := range []string{ + runsListHiddenNote(opts.state, len(runs), len(matching), len(visible)), + runsListRetainedWorktreeNote(retainedWorktrees), + } { + if note != "" { + fmt.Fprintln(stderr, note) + } + } +``` + +
+ + + +
+🤖 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/runs.go` around lines 121 - 127, Update the note-rendering logic +in the runs-list flow so runsListRetainedWorktreeNote and runsListHiddenNote can +both be emitted when applicable, instead of the retained-worktree note +suppressing hidden/older guidance. Preserve the existing note formatting and +update the exact stderr expectations in the related CLI tests. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `INVALID` +- Notes: The suppression is intentional. Archived Spec 0038 Task 05 requires + the retained-worktree count to be the bounded stderr guidance and records + hidden-row guidance as the fallback only when no retained residue exists. + `docs/user-guide/commands.md` also states that the retained-worktree + diagnostic is the one trailing stderr note. Printing both would change the + accepted byte-stable CLI contract, so no code or test change is appropriate. diff --git a/docs/specs/_reviews/pr-39/round-001/issue_003.md b/docs/specs/_reviews/pr-39/round-001/issue_003.md new file mode 100644 index 00000000..8122f19f --- /dev/null +++ b/docs/specs/_reviews/pr-39/round-001/issue_003.md @@ -0,0 +1,67 @@ +--- +source: coderabbit +pr: "39" +round: 1 +round_created_at: "2026-07-27T21:22:30Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-run-worktree-reconciliation +head_sha: 44fa422cea404a2d8c47e4b8011f065c4c0481ba +file: internal/store/store.go +line: 540 +severity: nitpick +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UNfMf,comment:PRRC_kwDOS0qyts7aNkLD +review_hash: 2e81ee1680c458414f68d809f15448dbfba10523fa79e90fea4eabcd8b3946bf +duplicate_of: "" +source_review_id: "4791610618" +source_review_submitted_at: "2026-07-27T21:21:25Z" +--- + +# Issue 003: _ Data Integrity & Integration_ _ Trivial_ _ Quick win_ + +## Review Comment + +_🗄️ Data Integrity & Integration_ | _🔵 Trivial_ | _⚡ Quick win_ + +**Run Branch naming is duplicated across packages.** + +`"roundfix/run-" + req.RunID` re-implements `worktree.BranchName` (which uses `runBranchPrefix`). Since `internal/worktree` imports `internal/store`, the constant cannot be imported back, so a change to the prefix in the worktree package would make every reconciliation request fail this check with no compile-time signal. Extract the prefix into a small shared package (or a `store`-owned exported constant that `worktree.BranchName` consumes) so both sides derive the name from one source. + +
+🤖 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 532 - 540, The Run Branch prefix is +duplicated in the reconciliation check and worktree naming logic. Extract the +prefix into a dependency-safe shared symbol, or make it a store-owned exported +constant consumed by worktree.BranchName, then update the reconciliation +validation around expectedRunBranch to derive the expected name through that +single source instead of concatenating "roundfix/run-" directly. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: The Run Branch prefix was independently encoded in `internal/store` + and `internal/worktree`, so either package could drift without a compile-time + signal. `store.RunBranchPrefix` is now the single source consumed by both + reconciliation validation and worktree branch naming. + +## Verification + +- `rtk proxy env GOCACHE=/private/tmp/roundfix-pr39-batch001-gocache go test ./internal/worktree ./internal/cli ./internal/store -run 'Test(CountRetainedTerminalRunsBatchesGitInspectionByRepository|InspectTerminalRunUnsafePath|RunRunsList.*Retained.*Worktree|RunRunsListReportsRetainedWorktreeInspectionFailure|PruneTerminalReapsOnlyEmptyTerminalRunAndTaskBranches|ReconcileIntegration)' -count=1` + — passed. diff --git a/docs/specs/_reviews/pr-39/round-001/issue_004.md b/docs/specs/_reviews/pr-39/round-001/issue_004.md new file mode 100644 index 00000000..b9eabc00 --- /dev/null +++ b/docs/specs/_reviews/pr-39/round-001/issue_004.md @@ -0,0 +1,111 @@ +--- +source: coderabbit +pr: "39" +round: 1 +round_created_at: "2026-07-27T21:22:30Z" +status: invalid +terminal_reason: "The repository requires stdlib-only tests, and this test already joins all eight bounded goroutines through an exact-size result channel." +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-run-worktree-reconciliation +head_sha: 44fa422cea404a2d8c47e4b8011f065c4c0481ba +file: internal/worktree/worktree_test.go +line: 895 +severity: minor +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UNfMk,comment:PRRC_kwDOS0qyts7aNkLL +review_hash: 41b8a5ddf5baca38e1872ee841e71b9dc8aab340c0a378d3d5655774257839ce +duplicate_of: "" +source_review_id: "4791610618" +source_review_submitted_at: "2026-07-27T21:21:25Z" +--- + +# Issue 004: _ Maintainability & Code Quality_ _ Minor_ _ Quick win_ + +## Review Comment + +_📐 Maintainability & Code Quality_ | _🟡 Minor_ | _⚡ Quick win_ + +
+🧩 Analysis chain + +🏁 Script executed: + +```shell +#!/bin/bash +# Check for TestMain/goleak usage in the worktree package and repo-wide +rg -nP --type=go 'goleak|func TestMain\(' -g '!vendor/**' -C2 +``` + +Repository: marcioaltoe/roundfix + +Length of output: 158 + +--- + +🏁 Script executed: + +```shell +#!/bin/bash +set -euo pipefail + +printf 'Files in internal/worktree:\n' +git ls-files 'internal/worktree/*' | sed 's#^`#-` #' + +printf '\nSearch for TestMain and goleak in internal/worktree:\n' +rg -n --hidden --glob '!vendor/**' 'func TestMain\(|goleak' internal/worktree || true + +printf '\nSearch for goroutine-spawning patterns in internal/worktree tests:\n' +rg -n --hidden --glob 'internal/worktree/*_test.go' '\bgo func\(|\bgo\s+[A-Za-z_]' internal/worktree || true + +printf '\nRelevant slice of internal/worktree/worktree_test.go:\n' +sed -n '830,930p' internal/worktree/worktree_test.go +``` + +Repository: marcioaltoe/roundfix + +Length of output: 4244 + +--- + + + +
+ +**Add goroutine-leak checks for `internal/worktree`** +`internal/worktree/worktree_test.go` now spawns goroutines, and the package has no `TestMain`/`goleak` coverage. Add `goleak.VerifyTestMain(m)` for the package or `goleak.VerifyNone(t)` around the concurrent tests so leaks fail fast. + +
+🤖 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/worktree/worktree_test.go` around lines 867 - 895, Add goleak +coverage for the goroutines introduced by TestInspectTerminalRunConcurrentSafe, +using goleak.VerifyNone around the concurrent test or goleak.VerifyTestMain via +a package TestMain. Ensure leaked goroutines cause the internal/worktree tests +to fail while preserving the existing inspection assertions. +``` + +
+ + + + + + + +_Source: Coding guidelines_ + + + +## Triage + +- Decision: `INVALID` +- Notes: `docs/agents/go.md` prohibits test dependencies and requires stdlib + `testing` only, so adding `go.uber.org/goleak` would violate a repository + Normative Clause. The test starts exactly eight goroutines, gives each one + result slot in an eight-element buffered channel, and receives exactly eight + results before returning. There is no fire-and-forget package goroutine or + lifecycle gap that warrants a new dependency. diff --git a/docs/specs/_reviews/pr-39/round-001/issue_005.md b/docs/specs/_reviews/pr-39/round-001/issue_005.md new file mode 100644 index 00000000..62f1125e --- /dev/null +++ b/docs/specs/_reviews/pr-39/round-001/issue_005.md @@ -0,0 +1,86 @@ +--- +source: coderabbit +pr: "39" +round: 1 +round_created_at: "2026-07-27T21:22:30Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-run-worktree-reconciliation +head_sha: 44fa422cea404a2d8c47e4b8011f065c4c0481ba +file: internal/worktree/worktree_test.go +line: 1562 +severity: nitpick +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UNfMo,comment:PRRC_kwDOS0qyts7aNkLP +review_hash: 7319fd8371fbdb3856b92a8f7a32a49d15265f5e7966a5c0d8839958cdbd22d7 +duplicate_of: "" +source_review_id: "4791610618" +source_review_submitted_at: "2026-07-27T21:21:25Z" +--- + +# Issue 005: _ Maintainability & Code Quality_ _ Trivial_ _ Quick win_ + +## Review Comment + +_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_ + +**Use `slices.Equal` instead of a hand-rolled comparison.** + +`slicesEqual` duplicates the standard library helper. + + + +
+♻️ Proposed replacement + +```diff +-func slicesEqual(left, right []string) bool { +- if len(left) != len(right) { +- return false +- } +- for index := range left { +- if left[index] != right[index] { +- return false +- } +- } +- return true +-} +``` + +Import `slices` and call `slices.Equal(...)` at the three comparison sites (lines 1293, 1347-1348, 1333). +
+ +
+🤖 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/worktree/worktree_test.go` around lines 1552 - 1562, Remove the +hand-rolled slicesEqual helper and import the standard library slices package. +Replace all three slicesEqual call sites in the affected tests with +slices.Equal, preserving the existing comparison arguments and behavior. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: The test-only `slicesEqual` helper duplicated the Go standard + library. Its three call sites now use `slices.Equal`, and the redundant + helper was removed without changing assertions. + +## Verification + +- `rtk proxy env GOCACHE=/private/tmp/roundfix-pr39-batch001-gocache go test ./internal/worktree ./internal/cli ./internal/store -run 'Test(CountRetainedTerminalRunsBatchesGitInspectionByRepository|InspectTerminalRunUnsafePath|RunRunsList.*Retained.*Worktree|RunRunsListReportsRetainedWorktreeInspectionFailure|PruneTerminalReapsOnlyEmptyTerminalRunAndTaskBranches|ReconcileIntegration)' -count=1` + — passed. diff --git a/docs/specs/_reviews/pr-39/round-001/issue_006.md b/docs/specs/_reviews/pr-39/round-001/issue_006.md new file mode 100644 index 00000000..af25a061 --- /dev/null +++ b/docs/specs/_reviews/pr-39/round-001/issue_006.md @@ -0,0 +1,68 @@ +--- +source: coderabbit +pr: "39" +round: 1 +round_created_at: "2026-07-27T21:22:30Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-run-worktree-reconciliation +head_sha: 44fa422cea404a2d8c47e4b8011f065c4c0481ba +file: internal/worktree/worktree.go +line: 222 +severity: minor +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UNfMu,comment:PRRC_kwDOS0qyts7aNkLV +review_hash: 86612da12c2d2aca59fd975bc0997a6c95952b8c6e38c583739dad0349528181 +duplicate_of: "" +source_review_id: "4791610618" +source_review_submitted_at: "2026-07-27T21:21:25Z" +--- + +# Issue 006: _ Functional Correctness_ _ Minor_ _ Quick win_ + +## Review Comment + +_🎯 Functional Correctness_ | _🟡 Minor_ | _⚡ Quick win_ + +**Misleading reason for an unregistered worktree directory.** + +`recordedWorktree` returns `unsafe=true` both for genuinely unsafe paths (symlink, unclean, non-absolute) and for a directory that exists but is simply not registered in the Git root (line 1116). In the latter case operators see "recorded Run Worktree path is unsafe" instead of the dedicated `reconciliationReasonWorktreeUnregistered`. Consider distinguishing the "exists but unregistered" outcome so the surfaced reason matches the actual evidence. + +
+🤖 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/worktree/worktree.go` around lines 218 - 222, Update the worktree +reconciliation flow around recordedWorktree so an existing directory that is not +registered in the Git root produces reconciliationReasonWorktreeUnregistered, +while genuinely unsafe paths continue using reconciliationReasonWorktreePath. +Distinguish these outcomes using the recordedWorktree result and preserve the +existing return behavior. +``` + +
+ + + + + + + + + +## Triage + +- Decision: `VALID` +- Notes: `recordedWorktree` collapsed an existing unregistered directory into + the same boolean used for unsafe paths. It now returns an explicit state, so + existing unregistered directories produce + `reconciliationReasonWorktreeUnregistered` while malformed, symlinked, or + non-directory paths retain the unsafe-path reason. + +## Verification + +- `rtk proxy env GOCACHE=/private/tmp/roundfix-pr39-batch001-gocache go test ./internal/worktree ./internal/cli ./internal/store -run 'Test(CountRetainedTerminalRunsBatchesGitInspectionByRepository|InspectTerminalRunUnsafePath|RunRunsList.*Retained.*Worktree|RunRunsListReportsRetainedWorktreeInspectionFailure|PruneTerminalReapsOnlyEmptyTerminalRunAndTaskBranches|ReconcileIntegration)' -count=1` + — passed. diff --git a/docs/specs/_reviews/pr-39/round-001/issue_007.md b/docs/specs/_reviews/pr-39/round-001/issue_007.md new file mode 100644 index 00000000..cccb274e --- /dev/null +++ b/docs/specs/_reviews/pr-39/round-001/issue_007.md @@ -0,0 +1,92 @@ +--- +source: coderabbit +pr: "39" +round: 1 +round_created_at: "2026-07-27T21:22:30Z" +status: resolved +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-run-worktree-reconciliation +head_sha: 44fa422cea404a2d8c47e4b8011f065c4c0481ba +file: internal/worktree/worktree.go +line: 857 +severity: nitpick +author: coderabbitai[bot] +source_ref: thread:PRRT_kwDOS0qyts6UNfMy,comment:PRRC_kwDOS0qyts7aNkLZ +review_hash: b299f5390d70d2d3dd97d892568148a7657cf284b1f8532eb8f3714436df1e4d +duplicate_of: "" +source_review_id: "4791610618" +source_review_submitted_at: "2026-07-27T21:21:25Z" +--- + +# Issue 007: _ Maintainability & Code Quality_ _ Trivial_ _ Quick win_ + +## Review Comment + +_📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _⚡ Quick win_ + +**`TerminalRunLookup` cannot receive the prune context.** + +The lookup performs a Run Store read but takes only `runID`, so implementations must capture a context from the enclosing scope (or fall back to `context.Background()`), and the cancellation/deadline of the `ctx` passed to `PruneTerminalReport` is not propagated into the database call. Adding `ctx` as the first parameter keeps context propagation explicit through this boundary. + +As per coding guidelines: "Declare `ctx context.Context` as the first parameter of functions that accept a context" and "Use context-aware APIs for downstream operations". + + + + +
+♻️ Proposed signature change + +```diff +-type TerminalRunLookup func(runID string) (store.Run, bool, error) ++type TerminalRunLookup func(ctx context.Context, runID string) (store.Run, bool, error) +``` + +```diff +- run, found, err := loadTerminalRun(runID) ++ run, found, err := loadTerminalRun(ctx, runID) +``` +
+ + +Also applies to: 899-903 + +
+🤖 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/worktree/worktree.go` around lines 849 - 857, Update the +TerminalRunLookup function type to accept context.Context as its first +parameter, then pass the PruneTerminal ctx through every lookup invocation +within PruneTerminal and related reconciliation paths. Update all +implementations and callers to use the context-aware Run Store read, preserving +existing lookup behavior while propagating cancellation and deadlines. +``` + +
+ + + + + + + +_Source: Coding guidelines_ + + + +## Triage + +- Decision: `VALID` +- Notes: `TerminalRunLookup` forced callers to capture an outer context, so + the prune operation could not express context propagation at the function + boundary. The lookup now accepts `context.Context` first, + `PruneTerminalReport` passes its context through, and store-backed callers + use the received context. + +## Verification + +- `rtk proxy env GOCACHE=/private/tmp/roundfix-pr39-batch001-gocache go test ./internal/worktree ./internal/cli ./internal/store -run 'Test(CountRetainedTerminalRunsBatchesGitInspectionByRepository|InspectTerminalRunUnsafePath|RunRunsList.*Retained.*Worktree|RunRunsListReportsRetainedWorktreeInspectionFailure|PruneTerminalReapsOnlyEmptyTerminalRunAndTaskBranches|ReconcileIntegration)' -count=1` + — passed. diff --git a/docs/specs/_reviews/pr-39/round-001/round.md b/docs/specs/_reviews/pr-39/round-001/round.md new file mode 100644 index 00000000..94d6af92 --- /dev/null +++ b/docs/specs/_reviews/pr-39/round-001/round.md @@ -0,0 +1,14 @@ +--- +source: coderabbit +pr: "39" +round: 1 +round_created_at: "2026-07-27T21:22:30Z" +head_repository: marcioaltoe/roundfix +head_branch: ma/terminal-run-worktree-reconciliation +head_sha: 44fa422cea404a2d8c47e4b8011f065c4c0481ba +issue_count: 7 +--- + +# Round 001 + +Fetched 7 Review Issue(s). diff --git a/docs/user-guide/commands.md b/docs/user-guide/commands.md index ae035dfe..6d08e294 100644 --- a/docs/user-guide/commands.md +++ b/docs/user-guide/commands.md @@ -420,6 +420,85 @@ Non-interactive; creates no Run and never pushes. Verifies every Task is `_prd.md` and moves `//` to `/_archived//`. Refusals exit `2` naming the first unmet condition. +### reconcile + +```bash +roundfix reconcile [run-id] [--apply] [--format ] +``` + +The Reconcile Command classifies one terminal spec Run in the current +repository when a Run ID is supplied. Without a Run ID, it scans every +terminal spec Run for the current repository. Active Runs, review Runs, Runs +from another repository, and missing Run IDs fail Preflight Validation without +Git mutation. + +The default is a dry-run. These examples inspect one Run, inspect the current +repository, and request the versioned JSON report: + +```bash +roundfix reconcile +roundfix reconcile +roundfix reconcile --format json +``` + +Text and JSON reports are requested output and go to stdout. Diagnostics, +including validation and operational failures, go to stderr. Redirect them +independently when automating: + +```bash +roundfix reconcile --format json > reconcile.json +roundfix runs list > runs.txt 2> runs.diagnostics +``` + +The text report includes the repository, mode, each Run's outcome, +classification, Run Worktree, Run Branch, target branch, both resolved heads, +evidence, action, refusal reason, summary counts, and the exact apply command. +JSON uses the `roundfix-reconcile/v1` envelope with `mode`, `repository`, +`applyCommand`, `results`, and `summary`. + +Run Worktree Reconciliation uses five states: + +| State | Evidence and behavior | +| --- | --- | +| `safe` | The Run Branch and recorded target branch resolve, any present Run Worktree is registered and clean including untracked files, and the Run Branch tip is an ancestor of the current target tip. This is the only state eligible for cleanup. | +| `unintegrated` | The worktree cleanliness and ref evidence resolve, but the Run Branch tip is not an ancestor of the target tip. Roundfix preserves the worktree and branch. | +| `dirty` | A present registered Run Worktree has tracked or untracked changes. Dirty evidence takes precedence and Roundfix preserves the worktree and branch. | +| `unknown` | Invalid or missing metadata, an unsafe or unregistered worktree, an ambiguous or missing ref, or a Git inspection failure prevents proof. Roundfix preserves every surface it can identify. | +| `released` | Both the Run Worktree and Run Branch are absent. Repeated dry-run or apply is an idempotent no-op. | + +A missing worktree alone is not `released` and never authorizes deletion. When +the Run Branch remains, Roundfix still requires an unambiguous Run Branch tip, +the recorded target tip, and ancestry proof. Age and terminal outcome are also +not cleanup evidence. + +`--apply` is the only mutation switch: + +```bash +roundfix reconcile --apply +roundfix reconcile --apply +``` + +There is no force flag or user assertion that bypasses the proof. Apply acts +only on entries classified `safe` during that invocation, then rechecks the +metadata, worktree cleanliness, Run head, target head, and ancestry before +mutation. It removes the Run Worktree without force, deletes the Run Branch, +and reports failures while preserving any remaining path or ref. Dirty, +unintegrated, unknown, and released entries remain successful preserved +results unless an operational inspection fails. + +Before safe cleanup, Roundfix records the reconciliation evidence. A safe +Integration Pending Run moves to Clean through the guarded terminal transition; +every other terminal outcome remains unchanged. The Reconcile Command never +integrates unique commits, repairs dirty work, chooses another target branch, +or treats a missing path as proof. + +The contract uses the [Roundfix glossary](../../CONTEXT.md#language) and follows +[ADR-0053](../adr/0053-terminal-run-worktree-reconciliation-is-proof-based.md) +and [Spec 0038](../specs/0038-terminal-run-worktree-reconciliation/_prd.md). +Adjacent terminal-cleanup diagnostics remain traced through the +[Stop Command](#stop) to the +[detached-watch finding](../findings/2026-07-16-vortex-pr87-detached-watch-notification.md#4-cleanup-noise-appeared-before-the-actionable-failure). + ## Run discovery and monitoring ### runs and runs list @@ -443,6 +522,19 @@ and adds a repository column. When Runs are hidden, exactly one trailing stderr note names the hidden count and the widening flag. Empty results print `No Runs found.` and exit `0`. +When the selected repository scope contains retained terminal Run Worktrees or +Run Branches, Runs List keeps the stdout row shape unchanged and writes one +diagnostic to stderr: + +```text +(2 terminal Run Worktrees retained; run 'roundfix reconcile' to inspect) +``` + +The count can appear even when the default Active view has no rows. It is +discovery guidance, not a `safe` or unsafe classification; use the Reconcile +Command to classify each retained Run. When present, this retained-worktree +diagnostic is the one trailing stderr note. + ### attach ```bash diff --git a/docs/user-guide/usage.md b/docs/user-guide/usage.md index e387df11..83eeb6af 100644 --- a/docs/user-guide/usage.md +++ b/docs/user-guide/usage.md @@ -349,6 +349,60 @@ notification when the Run reaches its terminal outcome. Treat that notification as the unattended-Run signal; use `attach` or the console log for details. `attach` never stops, commits, or mutates the Run; detaching leaves it running. +### Reconcile retained Run work + +Runs List keeps requested rows on stdout. When terminal spec Runs retain a Run +Worktree or Run Branch in the selected repository scope, it writes the exact +count and `roundfix reconcile` guidance to stderr without classifying the +work: + +```text +(2 terminal Run Worktrees retained; run 'roundfix reconcile' to inspect) +``` + +Inspect before applying anything. A Run ID selects one terminal spec Run; +omitting it scans the current repository: + +```bash +roundfix reconcile # single-Run dry-run +roundfix reconcile # repository-wide dry-run +roundfix reconcile --format json # requested JSON on stdout +``` + +The report classifies every selected Run as `safe`, `unintegrated`, `dirty`, +`unknown`, or `released`. `safe` requires resolved Run and target heads, +positive cleanliness for any present registered Run Worktree, and proof that +the Run head is an ancestor of the target head. `unintegrated` has clean, +resolved evidence but lacks ancestry; `dirty` has tracked or untracked +worktree changes; `unknown` lacks trustworthy metadata or Git proof; and +`released` means both the Run Worktree and Run Branch are absent. + +Only `safe` is eligible for cleanup. Age, terminal outcome, or one missing path +does not establish safety, and Roundfix preserves dirty, unintegrated, and +unknown work. Redirect requested output and diagnostics separately in scripts: + +```bash +roundfix reconcile --format json > reconcile.json +roundfix runs list > runs.txt 2> runs.diagnostics +``` + +After reviewing the current dry-run, opt into mutation explicitly: + +```bash +roundfix reconcile --apply +roundfix reconcile --apply +``` + +`--apply` is the only mutation switch; there is no force bypass. Roundfix +revalidates cleanliness and both heads, then releases only entries that remain +`safe`. A safe Integration Pending Run becomes Clean with its evidence +recorded before cleanup. Other terminal outcomes remain unchanged, and a +second run reports `released` without another mutation. + +See the [Reconcile Command reference](commands.md#reconcile) for the full +state table, stdout and stderr contract, JSON fields, refusal behavior, and +links to the glossary, ADR, Spec, and finding trail. + ### Read the outcome and act | Outcome line | Meaning | Next action | @@ -537,6 +591,7 @@ For the full failure and replay contract, see the | `watch` | Fetch and resolve in a watched loop | | `runs` | Browse Runs in the read-only Run Browser (interactive terminal) | | `runs list` | List Runs from the Run Database, bounded and plain-text | +| `reconcile` | Classify retained terminal Run work and explicitly release proven-safe entries | | `attach` | Browse or replay a Run's timeline, read-only | | `stop` | Request or force-stop an Active Run | | `gc` | Prune old terminal Run journals and artifacts | diff --git a/internal/baseline/assets/setups/typescript-bun.json b/internal/baseline/assets/setups/typescript-bun.json index 3be62cee..6e665024 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": "36f512c6c0370aab357c345a8bf2ceb902738ff837bdd34da7c1c2085567533c", + "digest": "fe98e52e2a6812b899bd4a048c29afc515e9736ddc4df7120bd8f9b1cf7d9896", "skills": [ { "name": "find-rules", @@ -1027,7 +1027,7 @@ "type": "repo", "name": "roundfix" }, - "contentDigest": "fa774d82f16661c81c235738c78116fba2fdc328bac5797fd663fa31a05f42d6" + "contentDigest": "91b833ef01c723b308604e57dc4075ec8e216880c8d50cf493d7dbced7096f6d" }, { "name": "the-fool", diff --git a/internal/baseline/testdata/catalog.digest b/internal/baseline/testdata/catalog.digest index dcfffe56..c23b1c19 100644 --- a/internal/baseline/testdata/catalog.digest +++ b/internal/baseline/testdata/catalog.digest @@ -1 +1 @@ -sha256:c0435d250c1440584454009211b5f044711382fb4d9a01e9dc4e97b91e3ca014 +sha256:c715657c891c29b108e58a73d20c8bb6b9647cf150852f3600e29b65a950a70f diff --git a/internal/baseline/testdata/catalog.normalized.json b/internal/baseline/testdata/catalog.normalized.json index 71a934d7..c316a08e 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: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"]} +{"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:411768db412eb85be1dbb8bd271632d37c21cbaf336167e5a65a63b10513581d"},{"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 80b8202a..ea9d2776 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": "fa774d82f16661c81c235738c78116fba2fdc328bac5797fd663fa31a05f42d6", + "contentDigest": "91b833ef01c723b308604e57dc4075ec8e216880c8d50cf493d7dbced7096f6d", "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 6b38fda7..65a51ea2 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": "31620de106dfae46414ea11c8ff420e611b1c874c5d4e2936d8f37234142c59b" + "sha256": "cf7dc89818d21d5f7bebb91c2a920f642058db8184285a6ec572c3d1f7827248" }, { "bytes": 330237, diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0d322ab7..21407c31 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -46,6 +46,7 @@ Usage: roundfix watch --source coderabbit --pr [--spec ] --until-clean roundfix implement --spec roundfix settle --spec --task + roundfix reconcile [run-id] [--apply] [--format ] roundfix release plan [--from ] [--to ] [--format ] roundfix release plan --reset-to [--format ] roundfix baseline plan (--profile | --profile-file ) [--decision ...] [--decision-file ...] [--repo ] [--format ] @@ -79,6 +80,7 @@ Commands: watch Fetch and resolve in a watched loop implement Execute a Spec's Task Graph as one Run settle Verify and commit all current worktree changes for one failed Task + reconcile Inspect or release proven-safe terminal spec Run worktrees release Plan the next release version without mutating repository or release state baseline Plan, apply, and validate a Context-Driven Baseline profiles Show Agent Selection Profiles and advisory recommendations @@ -241,6 +243,8 @@ func runWithContext(ctx context.Context, args []string, stdout, stderr io.Writer return runImplementCommand(ctx, args[1:], stdout, stderr, detachChild) case "settle": return runSettleCommand(ctx, args[1:], stdout, stderr) + case "reconcile": + return runReconcileCommand(ctx, args[1:], stdout, stderr) case "release": return runReleaseCommand(ctx, args[1:], stdout, stderr) case "baseline": @@ -669,9 +673,8 @@ func forceStopRun(ctx context.Context, runStore *store.Store, active store.Run, 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 { - run, found, err := runStore.Run(ctx, runID) - return err == nil && found && store.IsTerminalState(run.State) + pruned, pruneErr := pruneTerminalRunWorktrees(ctx, active.GitRoot, worktreeLocation, runStore, func(lookupCtx context.Context, runID string) (store.Run, bool, error) { + return runStore.Run(lookupCtx, runID) }) for _, ref := range pruned { warnings = append(warnings, cleanupNoticef("reaped terminal Worktree path=%s branch=%s", ref.Path, ref.Branch)) @@ -4215,6 +4218,20 @@ Options: return implementUsage case "settle": return settleUsage + case "reconcile": + return `Usage: + roundfix reconcile [run-id] [--apply] [--format ] + +Inspects one terminal spec Run in the current repository, or every terminal +spec Run in the current repository when no Run ID is supplied. The default is +a read-only report. --apply releases only entries classified and revalidated +safe during the current invocation; dirty, unintegrated, unknown, and already +released entries remain successful preserved results. + +Options: + --apply Release freshly revalidated safe Run Worktrees and Run Branches + --format Output format: text (default) or json +` case "release": return `Usage: roundfix release plan [--from ] [--to ] [--impact --reason ] [--format ] diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b20e39ba..275d3fbc 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -276,6 +276,11 @@ func TestRunCommandHelp(t *testing.T) { args: []string{"runs", "--help"}, contains: []string{"roundfix runs list [--all] [--state ] [--limit N]", "--all", "--state", "--limit"}, }, + { + name: "reconcile", + args: []string{"reconcile", "--help"}, + contains: []string{"roundfix reconcile [run-id] [--apply] [--format ]", "read-only report", "--apply", "--format"}, + }, { name: "profiles", args: []string{"profiles", "--help"}, @@ -310,6 +315,392 @@ func TestRunCommandHelp(t *testing.T) { } } +func TestRunReconcileDryRunReadOnly(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + run, ref := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateFailed) + beforeDatabase := readReconcileBytes(t, store.DatabasePath(homeDir)) + beforeGit := reconcileGitSurface(t, repoDir) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext( + context.Background(), + []string{"reconcile", run.ID, "--format", "text"}, + &stdout, + &stderr, + ) + + if code != exitOK { + t.Fatalf("dry-run exit = %d, want 0 stderr=%q stdout=%q", code, stderr.String(), stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("dry-run stderr = %q, want empty", stderr.String()) + } + for _, want := range []string{ + "Run: " + run.ID, + "outcome: " + store.StateFailed, + "classification: safe", + "run-branch: " + ref.Branch, + "run-head: ", + "target-branch: ma/widget-flow", + "target-head: ", + "worktree: " + ref.Path, + "evidence: ", + "action: would release with --apply", + "refusal-reason: -", + "Apply with: roundfix reconcile " + run.ID + " --apply", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("dry-run output missing %q:\n%s", want, stdout.String()) + } + } + if got := readReconcileBytes(t, store.DatabasePath(homeDir)); !bytes.Equal(got, beforeDatabase) { + t.Fatal("dry-run changed the Run Database") + } + if got := reconcileGitSurface(t, repoDir); got != beforeGit { + t.Fatalf("dry-run changed Git state\nbefore:\n%s\nafter:\n%s", beforeGit, got) + } + assertReconcilePathState(t, ref.Path, true) + assertReconcileBranchState(t, repoDir, ref.Branch, true) +} + +func TestRunReconcileDryRunOutputFailure(t *testing.T) { + _, _, _ = newReconcileWorkspace(t) + var stderr bytes.Buffer + + code := RunContext( + context.Background(), + []string{"reconcile"}, + failingWriter{err: errors.New("output unavailable")}, + &stderr, + ) + + if code != exitRunFailed { + t.Fatalf("output failure exit = %d, want %d stderr=%q", code, exitRunFailed, stderr.String()) + } + if !strings.Contains(stderr.String(), "write reconciliation report: output unavailable") || + !strings.Contains(stderr.String(), "Next safe action: roundfix reconcile") { + t.Fatalf("output failure did not name the operation and next safe action: %q", stderr.String()) + } +} + +func TestRunReconcileJSONMatchesTextFields(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + run, ref := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateStopped) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext( + context.Background(), + []string{"reconcile", "--format=json", run.ID}, + &stdout, + &stderr, + ) + + if code != exitOK { + t.Fatalf("JSON dry-run exit = %d, want 0 stderr=%q stdout=%q", code, stderr.String(), stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("JSON dry-run stderr = %q, want empty", stderr.String()) + } + var report reconcileReport + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("decode reconciliation JSON: %v\n%s", err, stdout.String()) + } + if report.SchemaVersion != reconcileSchemaVersion || + report.Mode != "dry-run" || + report.Repository != repoDir || + report.ApplyCommand != "roundfix reconcile "+run.ID+" --apply" { + t.Fatalf("unexpected JSON envelope: %+v", report) + } + if len(report.Results) != 1 { + t.Fatalf("JSON results = %d, want 1: %+v", len(report.Results), report.Results) + } + result := report.Results[0] + if result.RunID != run.ID || + result.Outcome != store.StateStopped || + result.Classification != "safe" || + result.RunBranch != ref.Branch || + result.RunHead == "" || + result.TargetBranch != "ma/widget-flow" || + result.TargetHead == "" || + result.Worktree != ref.Path || + result.Evidence == "" || + result.Action != "would release with --apply" || + result.RefusalReason != "" { + t.Fatalf("JSON result omitted text-contract evidence: %+v", result) + } + if report.Summary.Total != 1 || report.Summary.Safe != 1 || + report.Summary.Applied != 0 || report.Summary.OperationalFailures != 0 { + t.Fatalf("unexpected JSON summary: %+v", report.Summary) + } +} + +func TestRunReconcileRepositoryScopeNewestFirst(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + older, _ := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateFailed) + newer, _ := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateStopped) + otherRepo := t.TempDir() + other := createReconcileMetadataRun( + t, + homeDir, + store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: otherRepo, + LocalBranch: "ma/other", + HeadSHA: "other-head", + SpecSlug: "other-spec", + Agent: "codex", + OwnerPID: os.Getpid(), + }, + store.StateFailed, + ) + setListedRunCreatedAt(t, homeDir, older.ID, time.Date(2026, 7, 27, 10, 0, 0, 0, time.UTC)) + setListedRunCreatedAt(t, homeDir, newer.ID, time.Date(2026, 7, 27, 11, 0, 0, 0, time.UTC)) + setListedRunCreatedAt(t, homeDir, other.ID, time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC)) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext(context.Background(), []string{"reconcile"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("repository scan exit = %d, want 0 stderr=%q stdout=%q", code, stderr.String(), stdout.String()) + } + newerIndex := strings.Index(stdout.String(), "Run: "+newer.ID) + olderIndex := strings.Index(stdout.String(), "Run: "+older.ID) + if newerIndex < 0 || olderIndex < 0 || newerIndex >= olderIndex { + t.Fatalf("repository results are not newest-first:\n%s", stdout.String()) + } + if strings.Contains(stdout.String(), other.ID) { + t.Fatalf("repository scan included cross-repository Run %s:\n%s", other.ID, stdout.String()) + } + if !strings.Contains(stdout.String(), "Summary: total=2 ") { + t.Fatalf("repository summary did not count only current repository Runs:\n%s", stdout.String()) + } +} + +func TestRunReconcileInvalidSelectorsMutateNothing(t *testing.T) { + homeDir, repoDir, _ := newReconcileWorkspace(t) + active := createReconcileMetadataRun( + t, + homeDir, + store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: repoDir, + LocalBranch: "ma/widget-flow", + HeadSHA: strings.TrimSpace(gitImplementOutput(t, repoDir, "rev-parse", "HEAD")), + SpecSlug: "active-spec", + Agent: "codex", + OwnerPID: os.Getpid(), + }, + "", + ) + review := createReconcileMetadataRun( + t, + homeDir, + store.CreateRunRequest{ + Kind: store.KindResolve, + HeadRepository: "owner/project", + HeadBranch: "ma/review", + BaseRepository: "owner/project", + PRNumber: "38", + GitRoot: repoDir, + LocalBranch: "ma/widget-flow", + HeadSHA: "review-head", + ArtifactDir: filepath.Join(repoDir, ".roundfix", "review"), + Agent: "codex", + OwnerPID: os.Getpid(), + }, + store.StateFailed, + ) + crossRepository := createReconcileMetadataRun( + t, + homeDir, + store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: t.TempDir(), + LocalBranch: "ma/other", + HeadSHA: "other-head", + SpecSlug: "cross-repository", + Agent: "codex", + OwnerPID: os.Getpid(), + }, + store.StateFailed, + ) + beforeDatabase := readReconcileBytes(t, store.DatabasePath(homeDir)) + beforeGit := reconcileGitSurface(t, repoDir) + tests := []struct { + name string + id string + want string + }{ + {name: "active", id: active.ID, want: "is Active"}, + {name: "review", id: review.ID, want: "is a review Run"}, + {name: "missing", id: "run_missing", want: "does not exist"}, + {name: "cross repository", id: crossRepository.ID, want: "belongs to repository"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext( + context.Background(), + []string{"reconcile", tt.id, "--apply"}, + &stdout, + &stderr, + ) + + if code != exitPreflight { + t.Fatalf("invalid selector exit = %d, want %d stderr=%q", code, exitPreflight, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("invalid selector wrote stdout: %q", stdout.String()) + } + if !strings.Contains(stderr.String(), tt.want) { + t.Fatalf("invalid selector stderr missing %q: %q", tt.want, stderr.String()) + } + if got := readReconcileBytes(t, store.DatabasePath(homeDir)); !bytes.Equal(got, beforeDatabase) { + t.Fatal("invalid selector changed the Run Database") + } + if got := reconcileGitSurface(t, repoDir); got != beforeGit { + t.Fatalf("invalid selector changed Git state\nbefore:\n%s\nafter:\n%s", beforeGit, got) + } + }) + } +} + +func TestRunReconcileApplyMixedResults(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + safeRun, safeRef := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateFailed) + _, dirtyRef := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateStopped) + mustWrite(t, filepath.Join(dirtyRef.Path, "dirty.txt"), "preserve\n") + _, unintegratedRef := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateUnresolved) + mustWrite(t, filepath.Join(unintegratedRef.Path, "unique.txt"), "unique\n") + gitImplement(t, unintegratedRef.Path, "add", "unique.txt") + gitImplement(t, unintegratedRef.Path, "commit", "-m", "unique work") + _, unknownRef := createReconcileRun(t, homeDir, repoDir, location, "ma/missing-target", store.StateTimedOut) + _, releasedRef := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateClean) + gitImplement(t, repoDir, "worktree", "remove", releasedRef.Path) + gitImplement(t, repoDir, "branch", "-D", releasedRef.Branch) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext(context.Background(), []string{"reconcile", "--apply"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("mixed apply exit = %d, want 0 stderr=%q stdout=%q", code, stderr.String(), stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("mixed apply stderr = %q, want empty", stderr.String()) + } + assertReconcilePathState(t, safeRef.Path, false) + assertReconcileBranchState(t, repoDir, safeRef.Branch, false) + for _, ref := range []runworktree.Ref{dirtyRef, unintegratedRef, unknownRef} { + assertReconcilePathState(t, ref.Path, true) + assertReconcileBranchState(t, repoDir, ref.Branch, true) + } + assertReconcilePathState(t, releasedRef.Path, false) + assertReconcileBranchState(t, repoDir, releasedRef.Branch, false) + for _, want := range []string{ + "Run: " + safeRun.ID, + "classification: safe", + "action: released", + "Summary: total=5 safe=1 unintegrated=1 dirty=1 unknown=1 released=1 applied=1 preserved=3 operational-failures=0", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("mixed apply output missing %q:\n%s", want, stdout.String()) + } + } +} + +func TestRunReconcileApplyFailureNamesNextSafeAction(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + run, ref := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateFailed) + gitImplement(t, repoDir, "worktree", "lock", ref.Path) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext( + context.Background(), + []string{"reconcile", run.ID, "--apply"}, + &stdout, + &stderr, + ) + + if code != exitRunFailed { + t.Fatalf("apply failure exit = %d, want %d stderr=%q stdout=%q", code, exitRunFailed, stderr.String(), stdout.String()) + } + if !strings.Contains(stderr.String(), "Next safe action: roundfix reconcile "+run.ID) { + t.Fatalf("apply failure did not name next safe action: %q", stderr.String()) + } + if !strings.Contains(stdout.String(), "operational-failures=1") || + !strings.Contains(stdout.String(), "inspect remaining Git state and rerun") || + !strings.Contains(stdout.String(), "refusal-reason: apply terminal Run") { + t.Fatalf("apply failure report is not actionable:\n%s", stdout.String()) + } + assertReconcilePathState(t, ref.Path, true) + assertReconcileBranchState(t, repoDir, ref.Branch, true) +} + +func TestRunReconcileIdempotentApply(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + run, ref := createReconcileRun( + t, + homeDir, + repoDir, + location, + "ma/widget-flow", + store.StateIntegrationPending, + ) + var firstStdout bytes.Buffer + var firstStderr bytes.Buffer + firstCode := RunContext( + context.Background(), + []string{"reconcile", run.ID, "--apply"}, + &firstStdout, + &firstStderr, + ) + if firstCode != exitOK { + t.Fatalf("first apply exit = %d, want 0 stderr=%q stdout=%q", firstCode, firstStderr.String(), firstStdout.String()) + } + afterFirstDatabase := readReconcileBytes(t, store.DatabasePath(homeDir)) + afterFirstGit := reconcileGitSurface(t, repoDir) + var secondStdout bytes.Buffer + var secondStderr bytes.Buffer + + secondCode := RunContext( + context.Background(), + []string{"reconcile", run.ID, "--apply"}, + &secondStdout, + &secondStderr, + ) + + if secondCode != exitOK { + t.Fatalf("second apply exit = %d, want 0 stderr=%q stdout=%q", secondCode, secondStderr.String(), secondStdout.String()) + } + if secondStderr.Len() != 0 { + t.Fatalf("second apply stderr = %q, want empty", secondStderr.String()) + } + if !strings.Contains(secondStdout.String(), "classification: released") || + !strings.Contains(secondStdout.String(), "action: none") || + !strings.Contains(secondStdout.String(), "applied=0") { + t.Fatalf("second apply did not report a zero-mutation released result:\n%s", secondStdout.String()) + } + if got := readReconcileBytes(t, store.DatabasePath(homeDir)); !bytes.Equal(got, afterFirstDatabase) { + t.Fatal("second apply changed the Run Database") + } + if got := reconcileGitSurface(t, repoDir); got != afterFirstGit { + t.Fatalf("second apply changed Git state\nbefore:\n%s\nafter:\n%s", afterFirstGit, got) + } + assertReconcilePathState(t, ref.Path, false) + assertReconcileBranchState(t, repoDir, ref.Branch, false) + stored := implementRunFromStore(t, homeDir, run.ID) + if stored.State != store.StateClean { + t.Fatalf("Integration Pending Run state = %q, want Clean", stored.State) + } +} + func TestCommandUsageDocumentsProfileLedAndCompleteSelectionOverrides(t *testing.T) { for _, name := range []string{"resolve", "watch", "implement"} { t.Run(name, func(t *testing.T) { @@ -1986,6 +2377,224 @@ func TestRunRunsListStateFlagFiltersAndNotes(t *testing.T) { } } +func TestRunRunsListActiveReportsRetainedWorktreesWithoutChangingStdout(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + t.Chdir(repoDir) + base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + _, retained := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateFailed) + _, branchOnly := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateStopped) + gitImplement(t, repoDir, "worktree", "remove", branchOnly.Path) + _, worktreeOnly := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateUnresolved) + gitImplement(t, worktreeOnly.Path, "checkout", "--detach") + gitImplement(t, repoDir, "branch", "-D", worktreeOnly.Branch) + _, released := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateClean) + gitImplement(t, repoDir, "worktree", "remove", released.Path) + gitImplement(t, repoDir, "branch", "-D", released.Branch) + active := createReconcileMetadataRun( + t, + homeDir, + store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: repoDir, + LocalBranch: "ma/widget-flow", + HeadSHA: strings.TrimSpace(gitImplementOutput(t, repoDir, "rev-parse", "HEAD")), + SpecSlug: "active-spec", + Agent: "codex", + OwnerPID: os.Getpid(), + }, + "", + ) + setListedRunCreatedAt(t, homeDir, active.ID, base) + withRunsListNow(t, base.Add(10*time.Minute)) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"runs", "list"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) + } + wantStdout := fmt.Sprintf( + "%s Active implement spec:active-spec codex 2026-07-27T12:00:00Z running 10m ma/widget-flow\n", + active.ID, + ) + if stdout.String() != wantStdout { + t.Fatalf("retained-worktree note changed stdout:\n got: %q\nwant: %q", stdout.String(), wantStdout) + } + wantStderr := "(3 terminal Run Worktrees retained; run 'roundfix reconcile' to inspect)\n" + if stderr.String() != wantStderr { + t.Fatalf("unexpected retained-worktree guidance:\n got: %q\nwant: %q", stderr.String(), wantStderr) + } + if strings.Contains(stdout.String(), "reconcile") || strings.Contains(stderr.String(), "safe") || + strings.Contains(stderr.String(), "unsafe") { + t.Fatalf("Runs List classified retained work or mixed guidance into stdout: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + if _, err := os.Stat(retained.Path); err != nil { + t.Fatalf("expected retained Run Worktree fixture to remain: %v", err) + } +} + +func TestRunRunsListTerminalAndAllReportRetainedWorktreesByRepository(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + t.Chdir(repoDir) + currentRun, _ := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateFailed) + + otherRepo := t.TempDir() + gitImplement(t, otherRepo, "init", "--initial-branch=main") + gitImplement(t, otherRepo, "config", "user.name", "Roundfix Test") + gitImplement(t, otherRepo, "config", "user.email", "roundfix-test@example.com") + gitImplement(t, otherRepo, "config", "commit.gpgsign", "false") + mustWrite(t, filepath.Join(otherRepo, "README.md"), "other repository\n") + gitImplement(t, otherRepo, "add", "README.md") + gitImplement(t, otherRepo, "commit", "-m", "seed other repository") + gitImplement(t, otherRepo, "checkout", "-b", "ma/other") + resolvedOtherRepo, err := filepath.EvalSymlinks(otherRepo) + if err != nil { + t.Fatalf("resolve other repository: %v", err) + } + otherRun, _ := createReconcileRun( + t, + homeDir, + resolvedOtherRepo, + t.TempDir(), + "ma/other", + store.StateStopped, + ) + now := time.Now().UTC().Add(time.Minute) + withRunsListNow(t, now) + + tests := []struct { + name string + args []string + wantCount int + wantRunIDs []string + forbiddenRuns []string + }{ + { + name: "terminal current repository", + args: []string{"runs", "list", "--state", "terminal"}, + wantCount: 1, + wantRunIDs: []string{currentRun.ID}, + forbiddenRuns: []string{otherRun.ID}, + }, + { + name: "all-state current repository", + args: []string{"runs", "list", "--state", "all"}, + wantCount: 1, + wantRunIDs: []string{currentRun.ID}, + forbiddenRuns: []string{otherRun.ID}, + }, + { + name: "terminal every repository", + args: []string{"runs", "list", "--all", "--state", "terminal"}, + wantCount: 2, + wantRunIDs: []string{currentRun.ID, otherRun.ID}, + }, + { + name: "all-state every repository", + args: []string{"runs", "list", "--all", "--state", "all"}, + wantCount: 2, + wantRunIDs: []string{currentRun.ID, otherRun.ID}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run(tt.args, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) + } + for _, runID := range tt.wantRunIDs { + if !strings.Contains(stdout.String(), runID+" ") { + t.Fatalf("stdout omitted Run %s: %q", runID, stdout.String()) + } + } + for _, runID := range tt.forbiddenRuns { + if strings.Contains(stdout.String(), runID) { + t.Fatalf("stdout included cross-repository Run %s: %q", runID, stdout.String()) + } + } + worktreeNoun := "Run Worktrees" + if tt.wantCount == 1 { + worktreeNoun = "Run Worktree" + } + wantStderr := fmt.Sprintf( + "(%d terminal %s retained; run 'roundfix reconcile' to inspect)\n", + tt.wantCount, + worktreeNoun, + ) + if stderr.String() != wantStderr { + t.Fatalf("unexpected retained-worktree guidance:\n got: %q\nwant: %q", stderr.String(), wantStderr) + } + }) + } +} + +func TestRunRunsListRetainedWorktreeNoteOmittedWhenReleased(t *testing.T) { + homeDir, repoDir, location := newReconcileWorkspace(t) + t.Chdir(repoDir) + run, released := createReconcileRun(t, homeDir, repoDir, location, "ma/widget-flow", store.StateClean) + gitImplement(t, repoDir, "worktree", "remove", released.Path) + gitImplement(t, repoDir, "branch", "-D", released.Branch) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"runs", "list", "--state", "terminal"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), run.ID+" ") { + t.Fatalf("terminal history row was not printed: %q", stdout.String()) + } + if stderr.Len() != 0 { + t.Fatalf("released Run printed retained-worktree guidance: %q", stderr.String()) + } +} + +func TestRunRunsListReportsRetainedWorktreeInspectionFailure(t *testing.T) { + homeDir, _ := withCLIWorkspace(t) + invalidRoot, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve invalid Git root fixture: %v", err) + } + runs := seedRunsForList(t, homeDir, []runListSeed{ + { + kind: store.KindImplement, + state: store.StateFailed, + gitRoot: invalidRoot, + branch: "ma/invalid-root", + specSlug: "invalid-root", + createdAt: time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC), + }, + }) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := Run([]string{"runs", "list", "--all", "--state", "terminal"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("expected exit code 0, got %d stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), runs[0].ID+" ") { + t.Fatalf("terminal history row was not printed: %q", stdout.String()) + } + for _, want := range []string{ + "roundfix: warning: inspect retained terminal Runs in repository", + invalidRoot, + } { + if !strings.Contains(stderr.String(), want) { + t.Fatalf("expected retained-worktree inspection diagnostic %q, got %q", want, stderr.String()) + } + } + if strings.Contains(stderr.String(), "retained; run 'roundfix reconcile'") { + t.Fatalf("inspection failure must not be silently counted as retained: %q", stderr.String()) + } +} + func TestRunRunsListLimitBoundsNewestMatches(t *testing.T) { homeDir, repoDir := withCLIWorkspace(t) base := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC) @@ -8453,6 +9062,136 @@ func withCLIWorkspace(t *testing.T) (string, string) { return homeDir, repoDir } +func newReconcileWorkspace(t *testing.T) (string, string, string) { + t.Helper() + homeDir, repoDir := newImplementWorkspace(t, []implementSeed{ + {id: "task_01", title: "Reconcile terminal work"}, + }) + return homeDir, repoDir, t.TempDir() +} + +func createReconcileRun( + t *testing.T, + homeDir string, + repoDir string, + location string, + targetBranch string, + terminalState string, +) (store.Run, runworktree.Ref) { + t.Helper() + ctx := context.Background() + head := strings.TrimSpace(gitImplementOutput(t, repoDir, "rev-parse", "HEAD")) + runStore, err := store.Open(ctx, homeDir) + if err != nil { + t.Fatalf("open reconciliation Run Database: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close reconciliation Run Database: %v", err) + } + }() + run, err := runStore.CreateRun(ctx, store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: repoDir, + LocalBranch: targetBranch, + HeadSHA: head, + SpecSlug: "reconcile-spec", + Agent: "codex", + OwnerPID: os.Getpid(), + }) + if err != nil { + t.Fatalf("create reconciliation Run: %v", err) + } + ref, err := runworktree.Create(ctx, runworktree.CreateOptions{ + UserRoot: repoDir, + Location: location, + RunID: run.ID, + HeadSHA: head, + }) + if err != nil { + t.Fatalf("create reconciliation Run Worktree: %v", err) + } + ref.Path, err = filepath.EvalSymlinks(ref.Path) + if err != nil { + t.Fatalf("resolve reconciliation Run Worktree path: %v", err) + } + run, err = runStore.SetRunWorkDir(ctx, run.ID, ref.Path) + if err != nil { + t.Fatalf("record reconciliation Run Worktree: %v", err) + } + completed, err := runStore.CompleteRun(ctx, run.ID, terminalState) + if err != nil { + t.Fatalf("complete reconciliation Run %s: %v", terminalState, err) + } + return completed.Run, ref +} + +func createReconcileMetadataRun( + t *testing.T, + homeDir string, + request store.CreateRunRequest, + terminalState string, +) store.Run { + t.Helper() + ctx := context.Background() + runStore, err := store.Open(ctx, homeDir) + if err != nil { + t.Fatalf("open reconciliation metadata store: %v", err) + } + defer func() { + if err := runStore.Close(); err != nil { + t.Fatalf("close reconciliation metadata store: %v", err) + } + }() + run, err := runStore.CreateRun(ctx, request) + if err != nil { + t.Fatalf("create reconciliation metadata Run: %v", err) + } + if terminalState == "" { + return run + } + completed, err := runStore.CompleteRun(ctx, run.ID, terminalState) + if err != nil { + t.Fatalf("complete reconciliation metadata Run %s: %v", terminalState, err) + } + return completed.Run +} + +func readReconcileBytes(t *testing.T, path string) []byte { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return content +} + +func reconcileGitSurface(t *testing.T, repoDir string) string { + t.Helper() + return gitImplementOutput(t, repoDir, "worktree", "list", "--porcelain") + + gitImplementOutput(t, repoDir, "show-ref") +} + +func assertReconcilePathState(t *testing.T, path string, wantExists bool) { + t.Helper() + _, err := os.Stat(path) + exists := err == nil + if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stat reconciliation path %s: %v", path, err) + } + if exists != wantExists { + t.Fatalf("reconciliation path %s exists=%v, want %v", path, exists, wantExists) + } +} + +func assertReconcileBranchState(t *testing.T, repoDir string, branch string, wantExists bool) { + t.Helper() + exists := strings.TrimSpace(gitImplementOutput(t, repoDir, "branch", "--list", branch)) != "" + if exists != wantExists { + t.Fatalf("reconciliation branch %s exists=%v, want %v", branch, exists, wantExists) + } +} + type runListSeed struct { kind string state string @@ -8773,7 +9512,7 @@ func withNoReviewRunWorktrees(t *testing.T) { cleanupCleanRunWorktree = func(_ context.Context, ref runworktree.Ref) error { return fmt.Errorf("review command unexpectedly cleaned a Run Worktree %s", ref.Path) } - pruneTerminalRunWorktrees = func(context.Context, string, string, func(string) bool) ([]runworktree.PrunedRef, error) { + pruneTerminalRunWorktrees = func(context.Context, string, string, runworktree.TerminalRunReconciliationStore, runworktree.TerminalRunLookup) ([]runworktree.PrunedRef, error) { return nil, nil } t.Cleanup(func() { diff --git a/internal/cli/implement.go b/internal/cli/implement.go index 9a5473fb..7700422f 100644 --- a/internal/cli/implement.go +++ b/internal/cli/implement.go @@ -938,9 +938,8 @@ func gitHEAD(ctx context.Context, workDir string) (string, error) { } func pruneTerminalRunWorktreeDebris(ctx context.Context, gitRoot string, location string, runtime agent.RuntimeSpec, runStore *store.Store, stderr io.Writer) error { - pruned, err := pruneTerminalRunWorktrees(ctx, gitRoot, location, func(runID string) bool { - run, found, err := runStore.Run(ctx, runID) - return err == nil && found && store.IsTerminalState(run.State) + pruned, err := pruneTerminalRunWorktrees(ctx, gitRoot, location, runStore, func(lookupCtx context.Context, runID string) (store.Run, bool, error) { + return runStore.Run(lookupCtx, runID) }) if err != nil { return err diff --git a/internal/cli/implement_test.go b/internal/cli/implement_test.go index e342eb5e..a3068501 100644 --- a/internal/cli/implement_test.go +++ b/internal/cli/implement_test.go @@ -841,7 +841,7 @@ func withFakeRunWorktrees(t *testing.T) { cleanupCleanRunWorktree = func(_ context.Context, ref runworktree.Ref) error { return os.RemoveAll(ref.Path) } - pruneTerminalRunWorktrees = func(context.Context, string, string, func(string) bool) ([]runworktree.PrunedRef, error) { + pruneTerminalRunWorktrees = func(context.Context, string, string, runworktree.TerminalRunReconciliationStore, runworktree.TerminalRunLookup) ([]runworktree.PrunedRef, error) { return nil, nil } t.Cleanup(func() { @@ -2852,6 +2852,69 @@ func TestRunImplementPreflightReapsEmptyTerminalRunAndTaskWorktrees(t *testing.T } } +func TestRunImplementPreflightTerminalReachableChangedBranch(t *testing.T) { + homeDir, repoDir := newImplementWorkspace(t, []implementSeed{{ + id: "task_01", + title: "Build after reachable cleanup", + status: string(spec.StatusPending), + }}) + location := configureSettleWorktreeLocation(t, repoDir, filepath.Join(homeDir, "worktrees")) + staleRun, staleRef, _ := createImplementRunWorktreeFixture(t, homeDir, repoDir, location, implementTestSlug, "", store.StateStopped) + mustWrite(t, filepath.Join(staleRef.Path, "reachable.txt"), "reachable\n") + gitImplement(t, staleRef.Path, "add", "reachable.txt") + gitImplement(t, staleRef.Path, "commit", "-m", "reachable terminal work") + gitImplement(t, repoDir, "merge", "--ff-only", staleRef.Branch) + runner := &implementFakeRunner{ + gitRoot: repoDir, + statusByTask: map[string]spec.Status{"task_01": spec.StatusCompleted}, + } + withAgentRunner(t, runner) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext(context.Background(), []string{"implement", "--spec", implementTestSlug, "--no-input"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("expected implement exit 0, got %d stderr=%q", code, stderr.String()) + } + assertRunWorktreeRemoved(t, staleRun.WorkDir) + assertRunBranchRemoved(t, repoDir, staleRef.Branch) + if !strings.Contains(stderr.String(), "roundfix: reaped terminal Worktree path="+staleRun.WorkDir+" branch="+staleRef.Branch) { + t.Fatalf("expected reachable terminal Run cleanup notice, got %q", stderr.String()) + } +} + +func TestRunImplementPreflightTerminalUniqueChangedBranch(t *testing.T) { + homeDir, repoDir := newImplementWorkspace(t, []implementSeed{{ + id: "task_01", + title: "Build while preserving unique work", + status: string(spec.StatusPending), + }}) + location := configureSettleWorktreeLocation(t, repoDir, filepath.Join(homeDir, "worktrees")) + staleRun, staleRef, _ := createImplementRunWorktreeFixture(t, homeDir, repoDir, location, implementTestSlug, "", store.StateStopped) + mustWrite(t, filepath.Join(staleRef.Path, "unique.txt"), "unique\n") + gitImplement(t, staleRef.Path, "add", "unique.txt") + gitImplement(t, staleRef.Path, "commit", "-m", "unique terminal work") + runner := &implementFakeRunner{ + gitRoot: repoDir, + statusByTask: map[string]spec.Status{"task_01": spec.StatusCompleted}, + } + withAgentRunner(t, runner) + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := RunContext(context.Background(), []string{"implement", "--spec", implementTestSlug, "--no-input"}, &stdout, &stderr) + + if code != exitOK { + t.Fatalf("expected implement exit 0, got %d stderr=%q", code, stderr.String()) + } + assertRunWorktreeExists(t, staleRun.WorkDir) + assertRunBranchExists(t, repoDir, staleRef.Branch) + if strings.Contains(stderr.String(), "reaped terminal Worktree path="+staleRun.WorkDir) { + t.Fatalf("expected unique terminal Run to be preserved, got %q", stderr.String()) + } +} + func TestRunImplementPreflightClosesTerminalRunSessionsOnly(t *testing.T) { homeDir, repoDir := newImplementWorkspace(t, []implementSeed{{ id: "task_01", diff --git a/internal/cli/reconcile.go b/internal/cli/reconcile.go new file mode 100644 index 00000000..af30e46b --- /dev/null +++ b/internal/cli/reconcile.go @@ -0,0 +1,444 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "roundfix/internal/app" + roundconfig "roundfix/internal/config" + "roundfix/internal/store" + runworktree "roundfix/internal/worktree" +) + +const reconcileSchemaVersion = "roundfix-reconcile/v1" + +type reconcileOptions struct { + runID string + apply bool + format string +} + +type reconcileResult struct { + RunID string `json:"runId"` + Outcome string `json:"outcome"` + Classification string `json:"classification"` + RunBranch string `json:"runBranch"` + RunHead string `json:"runHead"` + TargetBranch string `json:"targetBranch"` + TargetHead string `json:"targetHead"` + Worktree string `json:"worktree"` + Evidence string `json:"evidence"` + Action string `json:"action"` + RefusalReason string `json:"refusalReason"` + + inspected runworktree.RunWorktreeReconciliation +} + +type reconcileSummary struct { + Total int `json:"total"` + Safe int `json:"safe"` + Unintegrated int `json:"unintegrated"` + Dirty int `json:"dirty"` + Unknown int `json:"unknown"` + Released int `json:"released"` + Applied int `json:"applied"` + Preserved int `json:"preserved"` + OperationalFailures int `json:"operationalFailures"` +} + +type reconcileReport struct { + SchemaVersion string `json:"schemaVersion"` + Mode string `json:"mode"` + Repository string `json:"repository"` + ApplyCommand string `json:"applyCommand"` + Results []reconcileResult `json:"results"` + Summary reconcileSummary `json:"summary"` +} + +func runReconcileCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if commandWantsHelp(args) { + fmt.Fprint(stdout, commandUsage("reconcile")) + return exitOK + } + opts, err := parseReconcileOptions(args) + if err != nil { + printReconcileValidationFailure(err, stderr) + return exitPreflight + } + + loaded, err := roundconfig.Load(roundconfig.LoadOptions{Stderr: stderr}) + if err != nil { + printReconcileValidationFailure(err, stderr) + return exitPreflight + } + repository := strings.TrimSpace(loaded.GitRoot) + if repository == "" { + printReconcileValidationFailure( + validationError{message: "reconcile requires running inside a Git repository"}, + stderr, + ) + return exitPreflight + } + repository, err = filepath.EvalSymlinks(repository) + if err != nil { + printReconcileOperationalFailure( + fmt.Errorf("resolve current Git repository %q: %w", loaded.GitRoot, err), + reconcileRetryCommand(opts.runID), + stderr, + ) + return exitRunFailed + } + + runs, err := loadReconcileRuns(ctx, loaded.HomeDir, repository, opts.runID) + if err != nil { + var invalid validationError + if errors.As(err, &invalid) { + printReconcileValidationFailure(err, stderr) + return exitPreflight + } + printReconcileOperationalFailure(err, reconcileRetryCommand(opts.runID), stderr) + return exitRunFailed + } + + report := inspectReconcileRuns(ctx, repository, opts, runs) + if opts.apply && report.Summary.Safe > 0 { + applyReconcileReport(ctx, loaded.HomeDir, opts, &report) + } + if err := printReconcileReport(stdout, opts.format, report); err != nil { + printReconcileOperationalFailure( + fmt.Errorf("write reconciliation report: %w", err), + reconcileRetryCommand(opts.runID), + stderr, + ) + return exitRunFailed + } + if report.Summary.OperationalFailures > 0 { + printReconcileOperationalFailure( + fmt.Errorf("%d reconciliation operation(s) failed", report.Summary.OperationalFailures), + reconcileRetryCommand(opts.runID), + stderr, + ) + return exitRunFailed + } + return exitOK +} + +func parseReconcileOptions(args []string) (reconcileOptions, error) { + opts := reconcileOptions{format: "text"} + for index := 0; index < len(args); index++ { + arg := args[index] + switch { + case arg == "--apply": + opts.apply = true + case arg == "--format": + if index+1 >= len(args) { + return opts, validationError{message: "flag needs an argument: --format"} + } + index++ + opts.format = strings.TrimSpace(args[index]) + case strings.HasPrefix(arg, "--format="): + opts.format = strings.TrimSpace(strings.TrimPrefix(arg, "--format=")) + case strings.HasPrefix(arg, "-"): + return opts, validationError{message: fmt.Sprintf("unknown flag %q", arg)} + default: + if opts.runID != "" { + return opts, validationError{message: fmt.Sprintf("unexpected argument %q", arg)} + } + opts.runID = strings.TrimSpace(arg) + if opts.runID == "" { + return opts, validationError{message: "Run ID cannot be empty"} + } + } + } + if opts.format != "text" && opts.format != "json" { + return opts, validationError{ + message: fmt.Sprintf("unknown --format %q; use text or json", opts.format), + } + } + return opts, nil +} + +func loadReconcileRuns(ctx context.Context, homeDir, repository, runID string) ([]store.Run, error) { + reader, err := store.OpenReader(ctx, homeDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + if runID == "" { + return []store.Run{}, nil + } + return nil, validationError{message: fmt.Sprintf("Run %q does not exist", runID)} + } + return nil, fmt.Errorf("open Run Database for reconciliation: %w", err) + } + defer func() { + _ = reader.Close() + }() + + if runID != "" { + run, found, err := reader.Run(ctx, runID) + if err != nil { + return nil, fmt.Errorf("read Run %q for reconciliation: %w", runID, err) + } + if !found { + return nil, validationError{message: fmt.Sprintf("Run %q does not exist", runID)} + } + if run.Kind != store.KindImplement { + return nil, validationError{ + message: fmt.Sprintf("Run %q is a review Run; reconcile accepts only terminal spec Runs", runID), + } + } + if !store.IsTerminalState(run.State) { + return nil, validationError{ + message: fmt.Sprintf("Run %q is Active; stop it before reconciliation", runID), + } + } + if !sameRepository(run.GitRoot, repository) { + return nil, validationError{ + message: fmt.Sprintf( + "Run %q belongs to repository %q, not current repository %q", + runID, + run.GitRoot, + repository, + ), + } + } + return []store.Run{run}, nil + } + + runs, err := reader.ListRuns(ctx, store.ListRunsQuery{ + GitRoot: repository, + States: store.StatesTerminal, + }) + if err != nil { + return nil, fmt.Errorf("list terminal Runs for reconciliation: %w", err) + } + specRuns := make([]store.Run, 0, len(runs)) + for _, run := range runs { + if run.Kind == store.KindImplement { + specRuns = append(specRuns, run) + } + } + return specRuns, nil +} + +func inspectReconcileRuns( + ctx context.Context, + repository string, + opts reconcileOptions, + runs []store.Run, +) reconcileReport { + mode := "dry-run" + if opts.apply { + mode = "apply" + } + report := reconcileReport{ + SchemaVersion: reconcileSchemaVersion, + Mode: mode, + Repository: repository, + ApplyCommand: reconcileApplyCommand(opts.runID), + Results: make([]reconcileResult, 0, len(runs)), + } + for _, run := range runs { + inspected, err := runworktree.InspectTerminalRun(ctx, run) + result := newReconcileResult(inspected, opts.apply) + if err != nil { + result.Classification = string(runworktree.ReconciliationUnknown) + result.RefusalReason = err.Error() + result.Action = "inspect Git state and rerun: " + reconcileRetryCommand(run.ID) + report.Summary.OperationalFailures++ + } + report.Results = append(report.Results, result) + countReconcileClassification(&report.Summary, result.Classification) + } + report.Summary.Total = len(report.Results) + return report +} + +func newReconcileResult( + inspected runworktree.RunWorktreeReconciliation, + apply bool, +) reconcileResult { + result := reconcileResult{ + RunID: inspected.RunID, + Outcome: inspected.Outcome, + Classification: string(inspected.State), + RunBranch: inspected.Branch, + RunHead: inspected.RunHead, + TargetBranch: inspected.TargetBranch, + TargetHead: inspected.TargetHead, + Worktree: inspected.Path, + Evidence: inspected.Reason, + inspected: inspected, + } + switch inspected.State { + case runworktree.ReconciliationSafe: + if apply { + result.Action = "release after fresh safety proof" + } else { + result.Action = "would release with --apply" + } + case runworktree.ReconciliationReleased: + result.Action = "none" + default: + result.Action = "preserve" + result.RefusalReason = inspected.Reason + } + return result +} + +func applyReconcileReport( + ctx context.Context, + homeDir string, + opts reconcileOptions, + report *reconcileReport, +) { + runStore, err := store.Open(ctx, homeDir) + if err != nil { + for index := range report.Results { + if report.Results[index].Classification != string(runworktree.ReconciliationSafe) { + continue + } + report.Results[index].Action = "preserved; repair the Run Database and rerun: " + + reconcileApplyCommand(opts.runID) + report.Results[index].RefusalReason = fmt.Sprintf("open Run Database for apply: %v", err) + report.Summary.OperationalFailures++ + } + return + } + defer func() { + _ = runStore.Close() + }() + + for index := range report.Results { + result := &report.Results[index] + if result.Classification != string(runworktree.ReconciliationSafe) { + continue + } + if err := runworktree.ApplyTerminalRun(ctx, runStore, result.inspected); err != nil { + result.Action = "preserved; inspect remaining Git state and rerun: " + + reconcileApplyCommand(result.RunID) + result.RefusalReason = err.Error() + report.Summary.OperationalFailures++ + continue + } + result.Action = "released" + result.RefusalReason = "" + report.Summary.Applied++ + } +} + +func countReconcileClassification(summary *reconcileSummary, classification string) { + switch runworktree.ReconciliationState(classification) { + case runworktree.ReconciliationSafe: + summary.Safe++ + case runworktree.ReconciliationUnintegrated: + summary.Unintegrated++ + summary.Preserved++ + case runworktree.ReconciliationDirty: + summary.Dirty++ + summary.Preserved++ + case runworktree.ReconciliationUnknown: + summary.Unknown++ + summary.Preserved++ + case runworktree.ReconciliationReleased: + summary.Released++ + } +} + +func printReconcileReport(stdout io.Writer, format string, report reconcileReport) error { + if format == "json" { + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + return encoder.Encode(report) + } + _, err := io.WriteString(stdout, reconcileText(report)) + return err +} + +func reconcileText(report reconcileReport) string { + var output strings.Builder + fmt.Fprintf(&output, "Repository: %s\n", report.Repository) + fmt.Fprintf(&output, "Mode: %s\n", report.Mode) + for _, result := range report.Results { + fmt.Fprintf(&output, "Run: %s\n", textReconcileValue(result.RunID)) + fmt.Fprintf(&output, " outcome: %s\n", textReconcileValue(result.Outcome)) + fmt.Fprintf(&output, " classification: %s\n", textReconcileValue(result.Classification)) + fmt.Fprintf(&output, " run-branch: %s\n", textReconcileValue(result.RunBranch)) + fmt.Fprintf(&output, " run-head: %s\n", textReconcileValue(result.RunHead)) + fmt.Fprintf(&output, " target-branch: %s\n", textReconcileValue(result.TargetBranch)) + fmt.Fprintf(&output, " target-head: %s\n", textReconcileValue(result.TargetHead)) + fmt.Fprintf(&output, " worktree: %s\n", textReconcileValue(result.Worktree)) + fmt.Fprintf(&output, " evidence: %s\n", textReconcileValue(result.Evidence)) + fmt.Fprintf(&output, " action: %s\n", textReconcileValue(result.Action)) + fmt.Fprintf(&output, " refusal-reason: %s\n", textReconcileValue(result.RefusalReason)) + } + summary := report.Summary + fmt.Fprintf( + &output, + "Summary: total=%d safe=%d unintegrated=%d dirty=%d unknown=%d released=%d applied=%d preserved=%d operational-failures=%d\n", + summary.Total, + summary.Safe, + summary.Unintegrated, + summary.Dirty, + summary.Unknown, + summary.Released, + summary.Applied, + summary.Preserved, + summary.OperationalFailures, + ) + if report.Mode == "dry-run" { + fmt.Fprintf(&output, "Apply with: %s\n", report.ApplyCommand) + } + return output.String() +} + +func textReconcileValue(value string) string { + if strings.TrimSpace(value) == "" { + return "-" + } + return value +} + +func reconcileApplyCommand(runID string) string { + if strings.TrimSpace(runID) == "" { + return "roundfix reconcile --apply" + } + return "roundfix reconcile " + strings.TrimSpace(runID) + " --apply" +} + +func reconcileRetryCommand(runID string) string { + if strings.TrimSpace(runID) == "" { + return "roundfix reconcile" + } + return "roundfix reconcile " + strings.TrimSpace(runID) +} + +func sameRepository(left, right string) bool { + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + if left == "" || right == "" { + return false + } + if resolved, err := filepath.EvalSymlinks(left); err == nil { + left = resolved + } + if resolved, err := filepath.EvalSymlinks(right); err == nil { + right = resolved + } + return filepath.Clean(left) == filepath.Clean(right) +} + +func printReconcileValidationFailure(err error, stderr io.Writer) { + fmt.Fprintf(stderr, "%s: reconcile failed: %v\n", app.Name, err) + fmt.Fprintf(stderr, "Run '%s reconcile --help' for usage.\n", app.Name) +} + +func printReconcileOperationalFailure(err error, nextAction string, stderr io.Writer) { + fmt.Fprintf(stderr, "%s: reconcile failed: %v\n", app.Name, err) + fmt.Fprintf(stderr, "Next safe action: %s\n", nextAction) +} diff --git a/internal/cli/runs.go b/internal/cli/runs.go index f345c1ca..8ce17fc9 100644 --- a/internal/cli/runs.go +++ b/internal/cli/runs.go @@ -14,6 +14,7 @@ import ( roundconfig "roundfix/internal/config" "roundfix/internal/store" roundtui "roundfix/internal/tui" + runworktree "roundfix/internal/worktree" ) const ( @@ -110,13 +111,21 @@ func runRunsListCommand(ctx context.Context, args []string, stdout, stderr io.Wr printRunsListFailure(err, stderr) return exitPreflight } + retainedWorktrees, retainedInspectionFailures := runworktree.CountRetainedTerminalRuns(ctx, runs) + for _, inspectErr := range retainedInspectionFailures { + fmt.Fprintf(stderr, "%s: warning: %v\n", app.Name, inspectErr) + } matching := filterRunsListState(runs, opts.state) visible := matching if opts.limit > 0 && len(matching) > opts.limit { visible = matching[:opts.limit] } printRunsList(stdout, visible, opts, runsListNow()) - if note := runsListHiddenNote(opts.state, len(runs), len(matching), len(visible)); note != "" { + note := runsListRetainedWorktreeNote(retainedWorktrees) + if note == "" { + note = runsListHiddenNote(opts.state, len(runs), len(matching), len(visible)) + } + if note != "" { fmt.Fprintln(stderr, note) } return exitOK @@ -160,6 +169,21 @@ func filterRunsListState(runs []store.Run, state string) []store.Run { return matching } +func runsListRetainedWorktreeNote(retained int) string { + if retained <= 0 { + return "" + } + worktree := "Run Worktree" + if retained != 1 { + worktree += "s" + } + return fmt.Sprintf( + "(%d terminal %s retained; run 'roundfix reconcile' to inspect)", + retained, + worktree, + ) +} + // runsListHiddenNote names what the listing hid and the flag that widens // the view. Exactly one note prints; when both the bound and the state // filter hide Runs, the bound wins because it truncates Runs the caller diff --git a/internal/cli/settle_test.go b/internal/cli/settle_test.go index e7c2473d..a9954340 100644 --- a/internal/cli/settle_test.go +++ b/internal/cli/settle_test.go @@ -839,6 +839,11 @@ func createImplementRunWorktreeFixture(t *testing.T, homeDir string, repoDir str if err != nil { t.Fatalf("create Run Worktree: %v", err) } + runWorktreePath, err := filepath.EvalSymlinks(runRef.Path) + if err != nil { + t.Fatalf("resolve Run Worktree path: %v", err) + } + runRef.Path = runWorktreePath run, err = runStore.SetRunWorkDir(ctx, run.ID, runRef.Path) if err != nil { t.Fatalf("record Run Worktree: %v", err) diff --git a/internal/store/store.go b/internal/store/store.go index f37c822d..c61e64d3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -27,6 +27,9 @@ const ( KindWatch = "watch" KindImplement = "implement" + // RunBranchPrefix is the deterministic namespace for spec Run Branches. + RunBranchPrefix = "roundfix/run-" + // Active Run locks are keyed by work target (ADR 0016): review Kinds // lock the Open Pull Request, the implement Kind locks the Spec. targetKindPR = "pr" @@ -117,11 +120,16 @@ type CompleteRunResult struct { } type IntegrationReconciliation struct { - RunID string - RunHead string - TargetBranch string - TargetHead string - Time time.Time + RunID string + PreviousOutcome string + Classification string + RunBranch string + RunHead string + TargetBranch string + TargetHead string + Worktree string + Action string + Time time.Time } type InteractiveDefaults struct { @@ -478,41 +486,31 @@ func (store *Store) ReconcileIntegration(ctx context.Context, req IntegrationRec return Run{}, err } req.RunID = strings.TrimSpace(req.RunID) + req.PreviousOutcome = strings.TrimSpace(req.PreviousOutcome) + req.Classification = strings.TrimSpace(req.Classification) + req.RunBranch = strings.TrimSpace(req.RunBranch) req.RunHead = strings.TrimSpace(req.RunHead) req.TargetBranch = strings.TrimSpace(req.TargetBranch) req.TargetHead = strings.TrimSpace(req.TargetHead) + req.Worktree = strings.TrimSpace(req.Worktree) + req.Action = strings.TrimSpace(req.Action) 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) + return Run{}, fmt.Errorf("begin terminal Run 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) + return Run{}, fmt.Errorf("reconcile terminal 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, - ) + return Run{}, fmt.Errorf("read Run %q before terminal reconciliation: %w", req.RunID, err) } if run.Kind != KindImplement { return Run{}, fmt.Errorf( - "reconcile Integration Pending Run %q: Run kind %q is not %q", + "reconcile terminal Run %q: Run kind %q is not %q", req.RunID, run.Kind, KindImplement, @@ -520,57 +518,128 @@ func (store *Store) ReconcileIntegration(ctx context.Context, req IntegrationRec } 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", + "reconcile terminal 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 strings.TrimSpace(run.WorkDir) != req.Worktree { + return Run{}, fmt.Errorf( + "reconcile terminal Run %q: worktree %q does not match recorded worktree %q", + req.RunID, + req.Worktree, + run.WorkDir, + ) } - if affected != 1 { + expectedRunBranch := RunBranchPrefix + req.RunID + if req.RunBranch != expectedRunBranch { return Run{}, fmt.Errorf( - "reconcile Integration Pending Run %q: compare-and-set affected %d rows", + "reconcile terminal Run %q: Run Branch %q does not match recorded Run Branch %q", req.RunID, - affected, + req.RunBranch, + expectedRunBranch, ) } + currentOutcome := req.PreviousOutcome + if req.PreviousOutcome == StateIntegrationPending { + currentOutcome = StateClean + } payload, err := json.Marshal(map[string]string{ "event": "integration_reconciliation", - "previous_outcome": StateIntegrationPending, - "current_outcome": StateClean, + "previous_outcome": req.PreviousOutcome, + "current_outcome": currentOutcome, + "classification": req.Classification, + "run_branch": req.RunBranch, "run_head": req.RunHead, "target_branch": req.TargetBranch, "target_head": req.TargetHead, + "worktree": req.Worktree, + "action": req.Action, }) if err != nil { - return Run{}, fmt.Errorf("encode Integration Pending reconciliation for Run %q: %w", req.RunID, err) + return Run{}, fmt.Errorf("encode terminal reconciliation for Run %q: %w", req.RunID, err) + } + + var replay int + err = tx.QueryRowContext(ctx, ` +SELECT 1 +FROM run_events +WHERE run_id = ? AND source = ? AND kind = ? AND payload = ? +LIMIT 1`, + req.RunID, + string(runevent.SourceDaemon), + string(runevent.KindDaemonOutcome), + string(payload), + ).Scan(&replay) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return Run{}, fmt.Errorf("inspect terminal reconciliation replay for Run %q: %w", req.RunID, err) + } + if err == nil { + if run.State != currentOutcome { + return Run{}, fmt.Errorf( + "reconcile terminal Run %q: recorded evidence expects outcome %q but stored outcome is %q", + req.RunID, + currentOutcome, + run.State, + ) + } + if err := tx.Commit(); err != nil { + return Run{}, fmt.Errorf("commit terminal reconciliation replay for Run %q: %w", req.RunID, err) + } + return run, nil + } + if run.State != req.PreviousOutcome { + if IsTerminalState(run.State) { + return Run{}, TerminalOutcomeConflictError{ + RunID: req.RunID, + Stored: run.State, + Requested: currentOutcome, + } + } + return Run{}, fmt.Errorf( + "reconcile terminal Run %q: stored state %q is not terminal outcome %q", + req.RunID, + run.State, + req.PreviousOutcome, + ) + } + + if req.PreviousOutcome == StateIntegrationPending { + 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, + ) + } } 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."), + Summary: runevent.BoundSummary(fmt.Sprintf("Run reconciled %s to %s before terminal cleanup.", req.PreviousOutcome, currentOutcome)), Time: req.Time, Payload: payload, }); err != nil { - return Run{}, fmt.Errorf("journal Integration Pending reconciliation for Run %q: %w", req.RunID, err) + return Run{}, fmt.Errorf("journal terminal reconciliation for Run %q: %w", req.RunID, err) } reconciled, err := selectRun(ctx, tx, req.RunID) @@ -578,7 +647,7 @@ WHERE id = ? AND state = ?`, return Run{}, fmt.Errorf("read reconciled Run %q: %w", req.RunID, err) } if err := tx.Commit(); err != nil { - return Run{}, fmt.Errorf("commit Integration Pending reconciliation for Run %q: %w", req.RunID, err) + return Run{}, fmt.Errorf("commit terminal reconciliation for Run %q: %w", req.RunID, err) } return reconciled, nil } @@ -586,19 +655,34 @@ WHERE id = ? AND state = ?`, func validateIntegrationReconciliation(req IntegrationReconciliation) error { runID := strings.TrimSpace(req.RunID) if runID == "" { - return errors.New("reconcile Integration Pending Run: Run ID is required") + return errors.New("reconcile terminal Run: Run ID is required") + } + if !IsTerminalState(strings.TrimSpace(req.PreviousOutcome)) { + return fmt.Errorf("reconcile terminal Run %q: previous outcome %q is not terminal", runID, req.PreviousOutcome) + } + if strings.TrimSpace(req.Classification) != "safe" { + return fmt.Errorf("reconcile terminal Run %q: classification must be safe", runID) + } + if strings.TrimSpace(req.RunBranch) == "" { + return fmt.Errorf("reconcile terminal Run %q: Run Branch is required", runID) } if strings.TrimSpace(req.RunHead) == "" { - return fmt.Errorf("reconcile Integration Pending Run %q: Run Branch head is required", runID) + return fmt.Errorf("reconcile terminal 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) + return fmt.Errorf("reconcile terminal 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) + return fmt.Errorf("reconcile terminal Run %q: target head is required", runID) + } + if strings.TrimSpace(req.Worktree) == "" { + return fmt.Errorf("reconcile terminal Run %q: worktree is required", runID) + } + if strings.TrimSpace(req.Action) != "cleanup" { + return fmt.Errorf("reconcile terminal Run %q: action must be cleanup", runID) } if req.Time.IsZero() { - return fmt.Errorf("reconcile Integration Pending Run %q: timestamp is required", runID) + return fmt.Errorf("reconcile terminal Run %q: timestamp is required", runID) } return nil } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index b288aa06..dff0d824 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -932,6 +932,7 @@ func TestReconcileIntegrationPendingRecordsEvidence(t *testing.T) { req.Kind = KindImplement req.SpecSlug = "0037-terminal-outcome-integrity" req.LocalBranch = "feature/terminal-outcomes" + req.WorkDir = filepath.Join("tmp", "run-worktree") run, err := runStore.CreateRun(ctx, req) if err != nil { t.Fatalf("create Implement Run: %v", err) @@ -943,11 +944,16 @@ func TestReconcileIntegrationPendingRecordsEvidence(t *testing.T) { 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, + RunID: run.ID, + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + Time: reconciledAt, }) if err != nil { t.Fatalf("reconcile Integration Pending Run: %v", err) @@ -995,6 +1001,7 @@ func TestReconcileIntegrationDatabaseFailureNamesOperationAndRun(t *testing.T) { req := sampleCreateRunRequest() req.Kind = KindImplement req.SpecSlug = "0037-terminal-outcome-integrity" + req.WorkDir = filepath.Join("tmp", "run-worktree") run, err := runStore.CreateRun(ctx, req) if err != nil { t.Fatalf("create Implement Run: %v", err) @@ -1007,14 +1014,19 @@ func TestReconcileIntegrationDatabaseFailureNamesOperationAndRun(t *testing.T) { } _, 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), + RunID: run.ID, + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + 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(), "begin terminal Run reconciliation") || !strings.Contains(err.Error(), run.ID) { t.Fatalf("expected wrapped reconciliation failure naming operation and Run %s, got %v", run.ID, err) } @@ -1028,6 +1040,7 @@ func TestReconcileIntegrationRejectsIncompleteEvidence(t *testing.T) { req := sampleCreateRunRequest() req.Kind = KindImplement req.SpecSlug = "0037-terminal-outcome-integrity" + req.WorkDir = filepath.Join("tmp", "run-worktree") run, err := runStore.CreateRun(ctx, req) if err != nil { t.Fatalf("create Implement Run: %v", err) @@ -1037,11 +1050,16 @@ func TestReconcileIntegrationRejectsIncompleteEvidence(t *testing.T) { 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), + RunID: run.ID, + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + Time: time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC), } tests := []struct { name string @@ -1086,6 +1104,7 @@ func TestReconcileIntegrationRejectsStaleTargetBranch(t *testing.T) { req.Kind = KindImplement req.SpecSlug = "0037-terminal-outcome-integrity" req.LocalBranch = "feature/terminal-outcomes" + req.WorkDir = filepath.Join("tmp", "run-worktree") run, err := runStore.CreateRun(ctx, req) if err != nil { t.Fatalf("create Implement Run: %v", err) @@ -1096,11 +1115,16 @@ func TestReconcileIntegrationRejectsStaleTargetBranch(t *testing.T) { } _, 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), + RunID: run.ID, + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: "feature/stale-target", + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + 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) @@ -1142,6 +1166,7 @@ func TestReconcileIntegrationRejectsEveryOtherSourceOutcome(t *testing.T) { req.Kind = KindImplement req.SpecSlug = fmt.Sprintf("terminal-source-%d", index) req.LocalBranch = fmt.Sprintf("feature/terminal-source-%d", index) + req.WorkDir = filepath.Join("tmp", fmt.Sprintf("run-worktree-%d", index)) run, err := runStore.CreateRun(ctx, req) if err != nil { t.Fatalf("create Implement Run: %v", err) @@ -1152,11 +1177,16 @@ func TestReconcileIntegrationRejectsEveryOtherSourceOutcome(t *testing.T) { } _, 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), + RunID: run.ID, + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + Time: time.Date(2026, 7, 27, 13, 0, 0, 0, time.UTC), }) var conflict TerminalOutcomeConflictError if !errors.As(err, &conflict) { @@ -1187,6 +1217,7 @@ func TestReconcileIntegrationRollsBackWhenJournalFails(t *testing.T) { req := sampleCreateRunRequest() req.Kind = KindImplement req.SpecSlug = "0037-terminal-outcome-integrity" + req.WorkDir = filepath.Join("tmp", "run-worktree") run, err := runStore.CreateRun(ctx, req) if err != nil { t.Fatalf("create Implement Run: %v", err) @@ -1205,11 +1236,16 @@ END`); err != nil { } _, 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), + RunID: run.ID, + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + 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) @@ -1229,6 +1265,197 @@ END`); err != nil { } } +func TestReconcileIntegrationSafeCompleteEvidence(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = "0038-terminal-run-worktree-reconciliation" + req.LocalBranch = "ma/reconciliation" + req.WorkDir = filepath.Join("tmp", "run-worktree") + 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) + } + reconciledAt := time.Date(2026, 7, 27, 16, 0, 0, 0, time.UTC) + evidence := IntegrationReconciliation{ + RunID: run.ID, + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + Time: reconciledAt, + } + + reconciled, err := runStore.ReconcileIntegration(ctx, evidence) + if err != nil { + t.Fatalf("reconcile safe Integration Pending Run: %v", err) + } + if reconciled.State != StateClean { + t.Fatalf("expected reconciled Clean Run, got %+v", 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)) + } + var payload map[string]string + if err := json.Unmarshal(events[0].Event.Payload, &payload); err != nil { + t.Fatalf("decode reconciliation event: %v", err) + } + want := map[string]string{ + "previous_outcome": StateIntegrationPending, + "current_outcome": StateClean, + "classification": evidence.Classification, + "run_branch": evidence.RunBranch, + "run_head": evidence.RunHead, + "target_branch": evidence.TargetBranch, + "target_head": evidence.TargetHead, + "worktree": evidence.Worktree, + "action": evidence.Action, + } + for key, value := range want { + if payload[key] != value { + t.Fatalf("expected reconciliation payload %s=%q, got %q in %#v", key, value, payload[key], payload) + } + } +} + +func TestReconcileIntegrationInvalidCompleteEvidence(t *testing.T) { + valid := IntegrationReconciliation{ + RunID: "run_01", + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-run_01", + RunHead: "run-head", + TargetBranch: "ma/reconciliation", + TargetHead: "target-head", + Worktree: filepath.Join("tmp", "run-worktree"), + Action: "cleanup", + Time: time.Date(2026, 7, 27, 16, 30, 0, 0, time.UTC), + } + tests := []struct { + name string + mutate func(*IntegrationReconciliation) + }{ + {name: "missing previous outcome", mutate: func(req *IntegrationReconciliation) { req.PreviousOutcome = "" }}, + {name: "unsafe classification", mutate: func(req *IntegrationReconciliation) { req.Classification = "unknown" }}, + {name: "missing Run Branch", mutate: func(req *IntegrationReconciliation) { req.RunBranch = "" }}, + {name: "missing worktree", mutate: func(req *IntegrationReconciliation) { req.Worktree = "" }}, + {name: "missing action", mutate: func(req *IntegrationReconciliation) { req.Action = "" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := valid + test.mutate(&input) + if err := validateIntegrationReconciliation(input); err == nil { + t.Fatal("expected incomplete or unsafe reconciliation evidence to fail") + } + }) + } +} + +func TestReconcileIntegrationOutcomePreservedWithEvidence(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + for index, outcome := range []string{StateUnresolved, StateFailed, StateStopped, StateTimedOut} { + t.Run(outcome, func(t *testing.T) { + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = fmt.Sprintf("0038-outcome-%d", index) + req.LocalBranch = fmt.Sprintf("ma/outcome-%d", index) + req.WorkDir = filepath.Join("tmp", fmt.Sprintf("run-worktree-%d", index)) + run, err := runStore.CreateRun(ctx, req) + if err != nil { + t.Fatalf("create Implement Run: %v", err) + } + if _, err := runStore.CompleteRun(ctx, run.ID, outcome); err != nil { + t.Fatalf("complete Run %s: %v", outcome, err) + } + evidence := IntegrationReconciliation{ + RunID: run.ID, + PreviousOutcome: outcome, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + Time: time.Date(2026, 7, 27, 17, index, 0, 0, time.UTC), + } + + reconciled, err := runStore.ReconcileIntegration(ctx, evidence) + if err != nil { + t.Fatalf("record safe %s reconciliation: %v", outcome, err) + } + if reconciled.State != outcome { + t.Fatalf("expected outcome %s unchanged, got %+v", outcome, reconciled) + } + if got := countRunEvents(t, ctx, runStore, run.ID); got != 1 { + t.Fatalf("expected one cleanup evidence event, got %d", got) + } + }) + } +} + +func TestReconcileIntegrationIdempotent(t *testing.T) { + ctx := context.Background() + runStore := openTestStore(t, ctx, t.TempDir()) + defer closeStore(t, runStore) + + req := sampleCreateRunRequest() + req.Kind = KindImplement + req.SpecSlug = "0038-terminal-run-worktree-reconciliation" + req.LocalBranch = "ma/reconciliation" + req.WorkDir = filepath.Join("tmp", "run-worktree") + 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) + } + evidence := IntegrationReconciliation{ + RunID: run.ID, + PreviousOutcome: StateIntegrationPending, + Classification: "safe", + RunBranch: "roundfix/run-" + run.ID, + RunHead: "run-head", + TargetBranch: req.LocalBranch, + TargetHead: "target-head", + Worktree: req.WorkDir, + Action: "cleanup", + Time: time.Date(2026, 7, 27, 17, 30, 0, 0, time.UTC), + } + + for attempt := 0; attempt < 2; attempt++ { + reconciled, err := runStore.ReconcileIntegration(ctx, evidence) + if err != nil { + t.Fatalf("reconciliation attempt %d: %v", attempt+1, err) + } + if reconciled.State != StateClean { + t.Fatalf("expected Clean replay, got %+v", reconciled) + } + } + if got := countRunEvents(t, ctx, runStore, run.ID); got != 1 { + t.Fatalf("expected exactly one reconciliation event after replay, got %d", got) + } +} + func TestRequestStopRecordsStopRequestForActiveRun(t *testing.T) { ctx := context.Background() runStore := openTestStore(t, ctx, t.TempDir()) diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index cbb0f8aa..a69dd36e 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -16,10 +16,12 @@ import ( "strings" "time" "unicode" + + "roundfix/internal/store" ) const ( - runBranchPrefix = "roundfix/run-" + runBranchPrefix = store.RunBranchPrefix ModeFastForwardMerge = "ff-merge" ModeBranchMove = "branch-move" @@ -117,6 +119,479 @@ type PrunedRef struct { Branch string } +type ReconciliationState string + +const ( + ReconciliationSafe ReconciliationState = "safe" + ReconciliationUnintegrated ReconciliationState = "unintegrated" + ReconciliationDirty ReconciliationState = "dirty" + ReconciliationUnknown ReconciliationState = "unknown" + ReconciliationReleased ReconciliationState = "released" +) + +const ( + reconciliationReasonSafe = "Run Branch is integrated and Run Worktree is clean" + reconciliationReasonUnintegrated = "Run Branch is not integrated into the target branch" + reconciliationReasonDirty = "Run Worktree has tracked or untracked changes" + reconciliationReasonReleased = "Run Worktree and Run Branch are absent" + reconciliationReasonRunMetadata = "recorded Run metadata is invalid" + reconciliationReasonWorktreePath = "recorded Run Worktree path is unsafe" + reconciliationReasonWorktreeUnregistered = "recorded Run Worktree is not registered in the Git root" + reconciliationReasonWorktreeInspection = "Run Worktree cleanliness could not be inspected" + reconciliationReasonRunBranch = "Run Branch could not be resolved unambiguously" + reconciliationReasonTargetMetadata = "recorded target branch is missing or invalid" + reconciliationReasonTargetBranch = "target branch could not be resolved unambiguously" + reconciliationReasonAncestry = "Run Branch ancestry could not be inspected" +) + +type RunWorktreeReconciliation struct { + RunID string + Outcome string + Path string + Branch string + TargetBranch string + RunHead string + TargetHead string + Reason string + State ReconciliationState + + evidence *terminalRunReconciliationEvidence +} + +func InspectTerminalRun(ctx context.Context, run store.Run) (RunWorktreeReconciliation, error) { + return inspectTerminalRun(ctx, execGitRunner{}, run) +} + +// CountRetainedTerminalRuns reports terminal spec Runs with an existing +// recorded Run Worktree or Run Branch. Git inspection is batched per +// repository so listing cost does not grow with terminal Run history. +func CountRetainedTerminalRuns(ctx context.Context, runs []store.Run) (int, []error) { + return countRetainedTerminalRuns(ctx, execGitRunner{}, runs) +} + +func countRetainedTerminalRuns(ctx context.Context, runner gitRunner, runs []store.Run) (int, []error) { + type repositoryRuns struct { + root string + runs []store.Run + } + + groupsByRoot := make(map[string]*repositoryRuns) + groups := make([]*repositoryRuns, 0) + for _, run := range runs { + if run.Kind != store.KindImplement || !store.IsTerminalState(run.State) { + continue + } + group, found := groupsByRoot[run.GitRoot] + if !found { + group = &repositoryRuns{root: run.GitRoot} + groupsByRoot[run.GitRoot] = group + groups = append(groups, group) + } + group.runs = append(group.runs, run) + } + + retained := 0 + var failures []error + for _, group := range groups { + branches := map[string]bool{} + gitRoot, err := recordedGitRoot(ctx, runner, group.root) + if err != nil { + failures = append( + failures, + fmt.Errorf("inspect retained terminal Runs in repository %q: %w", group.root, err), + ) + } else { + branches, err = listRunBranches(ctx, runner, gitRoot) + if err != nil { + failures = append( + failures, + fmt.Errorf("inspect retained terminal Runs in repository %q: %w", group.root, err), + ) + } + } + + for _, run := range group.runs { + pathPresent, pathErr := retainedRunWorktreePathExists(run.WorkDir) + if pathErr != nil { + failures = append( + failures, + fmt.Errorf("inspect retained terminal Run %q worktree %q: %w", run.ID, run.WorkDir, pathErr), + ) + } + if pathPresent || branches[BranchName(run.ID)] { + retained++ + } + } + } + return retained, failures +} + +func retainedRunWorktreePathExists(path string) (bool, error) { + if path == "" { + return false, nil + } + if strings.TrimSpace(path) != path || strings.ContainsAny(path, "\r\n\x00") || + !filepath.IsAbs(path) || filepath.Clean(path) != path { + return false, errors.New("recorded Run Worktree path is invalid") + } + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if !info.IsDir() { + return false, errors.New("recorded Run Worktree path is not a directory") + } + return true, nil +} + +func listRunBranches(ctx context.Context, runner gitRunner, gitRoot string) (map[string]bool, error) { + output, err := runner.Run( + ctx, + gitRoot, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/"+runBranchPrefix+"*", + ) + if err != nil { + return nil, fmt.Errorf("list Run Branches: %w", err) + } + branches := make(map[string]bool) + for _, line := range strings.Split(output, "\n") { + branch := strings.TrimSpace(line) + if strings.HasPrefix(branch, runBranchPrefix) { + branches[branch] = true + } + } + return branches, nil +} + +type terminalRunReconciliationEvidence struct { + run store.Run + gitRoot string + snapshot terminalRunReconciliationSnapshot + worktreePresent bool + runBranchPresent bool +} + +type terminalRunReconciliationSnapshot struct { + runID string + outcome string + path string + branch string + targetBranch string + runHead string + targetHead string + reason string + state ReconciliationState +} + +func inspectTerminalRun(ctx context.Context, runner gitRunner, run store.Run) (RunWorktreeReconciliation, error) { + result := RunWorktreeReconciliation{ + RunID: run.ID, + Outcome: run.State, + Path: run.WorkDir, + Branch: BranchName(run.ID), + TargetBranch: run.LocalBranch, + State: ReconciliationUnknown, + Reason: reconciliationReasonRunMetadata, + } + + gitRoot, err := recordedGitRoot(ctx, runner, run.GitRoot) + if err != nil { + return result, err + } + if run.Kind != store.KindImplement || !store.IsTerminalState(run.State) { + return result, nil + } + if strings.TrimSpace(run.ID) != run.ID { + return result, nil + } + if _, err := cleanPathSegment(run.ID); err != nil { + return result, nil + } + if !validLocalBranch(ctx, runner, gitRoot, result.Branch) { + return result, nil + } + + worktrees, err := listRegisteredWorktrees(ctx, runner, gitRoot) + if err != nil { + result.Reason = reconciliationReasonWorktreeInspection + return result, nil + } + worktree, worktreeState := recordedWorktree(result.Path, worktrees) + if worktreeState == recordedWorktreeUnsafe { + result.Reason = reconciliationReasonWorktreePath + return result, nil + } + if worktreeState == recordedWorktreeUnregistered { + result.Reason = reconciliationReasonWorktreeUnregistered + return result, nil + } + worktreePresent := worktreeState == recordedWorktreePresent + + runBranchPresent, err := localBranchExists(ctx, runner, gitRoot, result.Branch) + if err != nil { + result.Reason = reconciliationReasonRunBranch + return result, nil + } + if !worktreePresent && registeredBranchPath(worktrees, result.Branch) != "" { + result.Reason = reconciliationReasonWorktreeUnregistered + return result, nil + } + if !worktreePresent && !runBranchPresent { + result.State = ReconciliationReleased + result.Reason = reconciliationReasonReleased + result.evidence = newTerminalRunReconciliationEvidence(run, gitRoot, result, false, false) + return result, nil + } + + var runHeadErr error + if runBranchPresent { + result.RunHead, runHeadErr = resolveUnambiguousLocalBranch(ctx, runner, gitRoot, result.Branch) + } + targetMetadataValid := validLocalBranch(ctx, runner, gitRoot, result.TargetBranch) + var targetHeadErr error + if targetMetadataValid { + result.TargetHead, targetHeadErr = resolveUnambiguousLocalBranch(ctx, runner, gitRoot, result.TargetBranch) + } + + if worktreePresent { + if worktree.Branch != result.Branch { + result.Reason = reconciliationReasonWorktreeUnregistered + return result, nil + } + status, err := runner.Run(ctx, worktree.Path, "status", "--porcelain=v1", "--untracked-files=all") + if err != nil { + result.Reason = reconciliationReasonWorktreeInspection + return result, nil + } + if strings.TrimSpace(status) != "" { + result.State = ReconciliationDirty + result.Reason = reconciliationReasonDirty + return result, nil + } + } + + if runHeadErr != nil || result.RunHead == "" { + result.Reason = reconciliationReasonRunBranch + return result, nil + } + if !targetMetadataValid { + result.Reason = reconciliationReasonTargetMetadata + return result, nil + } + if targetHeadErr != nil || result.TargetHead == "" { + result.Reason = reconciliationReasonTargetBranch + return result, nil + } + if _, err := runner.Run(ctx, gitRoot, "merge-base", "--is-ancestor", result.RunHead, result.TargetHead); err != nil { + if isAncestryMiss(err) { + result.State = ReconciliationUnintegrated + result.Reason = reconciliationReasonUnintegrated + return result, nil + } + result.Reason = reconciliationReasonAncestry + return result, nil + } + result.State = ReconciliationSafe + result.Reason = reconciliationReasonSafe + result.evidence = newTerminalRunReconciliationEvidence(run, gitRoot, result, worktreePresent, runBranchPresent) + return result, nil +} + +func newTerminalRunReconciliationEvidence(run store.Run, gitRoot string, result RunWorktreeReconciliation, worktreePresent, runBranchPresent bool) *terminalRunReconciliationEvidence { + return &terminalRunReconciliationEvidence{ + run: run, + gitRoot: gitRoot, + snapshot: terminalRunSnapshot(result), + worktreePresent: worktreePresent, + runBranchPresent: runBranchPresent, + } +} + +func terminalRunSnapshot(result RunWorktreeReconciliation) terminalRunReconciliationSnapshot { + return terminalRunReconciliationSnapshot{ + runID: result.RunID, + outcome: result.Outcome, + path: result.Path, + branch: result.Branch, + targetBranch: result.TargetBranch, + runHead: result.RunHead, + targetHead: result.TargetHead, + reason: result.Reason, + state: result.State, + } +} + +type TerminalRunApplyError struct { + RunID string + WorktreePath string + RunBranch string + WorktreeRemaining bool + RunBranchRemaining bool + Err error +} + +func (err *TerminalRunApplyError) Error() string { + if err == nil { + return "apply terminal Run reconciliation failed" + } + var remaining []string + if err.WorktreeRemaining { + remaining = append(remaining, "worktree="+err.WorktreePath) + } + if err.RunBranchRemaining { + remaining = append(remaining, "branch="+err.RunBranch) + } + if len(remaining) == 0 { + remaining = append(remaining, "none") + } + return fmt.Sprintf("apply terminal Run %q: %v; remaining: %s", err.RunID, err.Err, strings.Join(remaining, " ")) +} + +func (err *TerminalRunApplyError) Unwrap() error { + if err == nil { + return nil + } + return err.Err +} + +type TerminalRunReconciliationStore interface { + ReconcileIntegration(context.Context, store.IntegrationReconciliation) (store.Run, error) +} + +func ApplyTerminalRun(ctx context.Context, runStore TerminalRunReconciliationStore, result RunWorktreeReconciliation) error { + return applyTerminalRunWithStore(ctx, execGitRunner{}, runStore, result) +} + +func applyTerminalRunWithStore( + ctx context.Context, + runner gitRunner, + runStore TerminalRunReconciliationStore, + result RunWorktreeReconciliation, +) error { + fresh, released, err := revalidateTerminalRunApply(ctx, runner, result) + if err != nil || released { + return err + } + if runStore == nil { + return errors.New("apply terminal Run reconciliation: Run Store is required before cleanup") + } + if _, err := runStore.ReconcileIntegration(ctx, store.IntegrationReconciliation{ + RunID: fresh.RunID, + PreviousOutcome: fresh.Outcome, + Classification: string(fresh.State), + RunBranch: fresh.Branch, + RunHead: fresh.RunHead, + TargetBranch: fresh.TargetBranch, + TargetHead: fresh.TargetHead, + Worktree: fresh.Path, + Action: "cleanup", + Time: time.Now().UTC(), + }); err != nil { + return fmt.Errorf("persist terminal Run %q reconciliation before cleanup: %w", fresh.RunID, err) + } + return cleanupTerminalRun(ctx, runner, fresh) +} + +func applyTerminalRun(ctx context.Context, runner gitRunner, result RunWorktreeReconciliation) error { + fresh, released, err := revalidateTerminalRunApply(ctx, runner, result) + if err != nil || released { + return err + } + return cleanupTerminalRun(ctx, runner, fresh) +} + +func revalidateTerminalRunApply( + ctx context.Context, + runner gitRunner, + result RunWorktreeReconciliation, +) (RunWorktreeReconciliation, bool, error) { + evidence := result.evidence + if evidence == nil || terminalRunSnapshot(result) != evidence.snapshot { + return RunWorktreeReconciliation{}, false, errors.New("apply terminal Run reconciliation: result was not produced by inspection or recorded metadata changed") + } + if result.State != ReconciliationSafe && result.State != ReconciliationReleased { + return RunWorktreeReconciliation{}, false, fmt.Errorf("apply terminal Run reconciliation: classification %q is not safe", result.State) + } + + fresh, err := inspectTerminalRun(ctx, runner, evidence.run) + if err != nil { + return RunWorktreeReconciliation{}, false, fmt.Errorf("revalidate terminal Run before cleanup: %w", err) + } + if fresh.State == ReconciliationReleased { + return fresh, true, nil + } + if result.State != ReconciliationSafe || + fresh.State != ReconciliationSafe || + fresh.RunHead != result.RunHead || + fresh.TargetHead != result.TargetHead { + return RunWorktreeReconciliation{}, false, fmt.Errorf( + "apply terminal Run reconciliation: evidence is stale: inspected state=%q Run head=%q target head=%q; current state=%q Run head=%q target head=%q", + result.State, + result.RunHead, + result.TargetHead, + fresh.State, + fresh.RunHead, + fresh.TargetHead, + ) + } + return fresh, false, nil +} + +func cleanupTerminalRun(ctx context.Context, runner gitRunner, fresh RunWorktreeReconciliation) error { + evidence := fresh.evidence + if fresh.evidence.worktreePresent { + if _, err := runner.Run(ctx, evidence.gitRoot, "worktree", "remove", fresh.Path); err != nil { + return terminalRunApplyFailure( + ctx, + runner, + evidence.gitRoot, + fresh, + fmt.Errorf("remove Run Worktree %q: %w", fresh.Path, err), + ) + } + } + if fresh.evidence.runBranchPresent { + if err := deleteRunBranch(ctx, runner, evidence.gitRoot, fresh.Branch); err != nil { + return terminalRunApplyFailure(ctx, runner, evidence.gitRoot, fresh, err) + } + } + return nil +} + +func terminalRunApplyFailure(ctx context.Context, runner gitRunner, gitRoot string, result RunWorktreeReconciliation, operationErr error) error { + worktreeRemaining, branchRemaining, inspectErr := remainingTerminalRunSurface(ctx, runner, gitRoot, result) + if inspectErr != nil { + operationErr = errors.Join(operationErr, fmt.Errorf("inspect remaining terminal Run surface: %w", inspectErr)) + } + return &TerminalRunApplyError{ + RunID: result.RunID, + WorktreePath: result.Path, + RunBranch: result.Branch, + WorktreeRemaining: worktreeRemaining, + RunBranchRemaining: branchRemaining, + Err: operationErr, + } +} + +func remainingTerminalRunSurface(ctx context.Context, runner gitRunner, gitRoot string, result RunWorktreeReconciliation) (bool, bool, error) { + worktrees, worktreeErr := listRegisteredWorktrees(ctx, runner, gitRoot) + worktreeRemaining := samePath(registeredBranchPath(worktrees, result.Branch), result.Path) + if !worktreeRemaining { + if _, err := os.Stat(result.Path); err == nil { + worktreeRemaining = true + } else if !errors.Is(err, os.ErrNotExist) { + worktreeErr = errors.Join(worktreeErr, fmt.Errorf("stat Run Worktree %q: %w", result.Path, err)) + } + } + branchRemaining, branchErr := localBranchExists(ctx, runner, gitRoot, result.Branch) + return worktreeRemaining, branchRemaining, errors.Join(worktreeErr, branchErr) +} + func Create(ctx context.Context, opts CreateOptions) (Ref, error) { ref, err := newRef(opts.UserRoot, opts.Location, opts.RunID) if err != nil { @@ -482,36 +957,107 @@ func CleanupTask(ctx context.Context, task TaskRef) error { return nil } -func PruneTerminal(ctx context.Context, userRoot string, location string, isTerminalRun func(runID string) bool) error { - _, err := PruneTerminalReport(ctx, userRoot, location, isTerminalRun) +type TerminalRunLookup func(ctx context.Context, runID string) (store.Run, bool, error) + +func PruneTerminal( + ctx context.Context, + userRoot string, + location string, + runStore TerminalRunReconciliationStore, + loadTerminalRun TerminalRunLookup, +) error { + _, err := PruneTerminalReport(ctx, userRoot, location, runStore, loadTerminalRun) return err } -func PruneTerminalReport(ctx context.Context, userRoot string, location string, isTerminalRun func(runID string) bool) ([]PrunedRef, error) { +func PruneTerminalReport( + ctx context.Context, + userRoot string, + location string, + runStore TerminalRunReconciliationStore, + loadTerminalRun TerminalRunLookup, +) ([]PrunedRef, error) { userRoot = filepath.Clean(strings.TrimSpace(userRoot)) if userRoot == "." || userRoot == "" { return nil, errors.New("prune Run Worktrees: user root is required") } - if isTerminalRun == nil { - return nil, errors.New("prune Run Worktrees: terminal callback is required") + if runStore == nil { + return nil, errors.New("prune Run Worktrees: Run Store is required before cleanup") } - - runner := execGitRunner{} - if _, err := runner.Run(ctx, userRoot, "worktree", "prune"); err != nil { - return nil, fmt.Errorf("prune git worktrees: %w", err) + if loadTerminalRun == nil { + return nil, errors.New("prune Run Worktrees: terminal Run lookup is required") } + runner := execGitRunner{} refs, err := terminalCandidates(ctx, runner, userRoot, location) if err != nil { return nil, err } + refsByRun := make(map[string][]terminalCandidate) + var runIDs []string + for _, ref := range refs { + if _, found := refsByRun[ref.RunID]; !found { + runIDs = append(runIDs, ref.RunID) + } + refsByRun[ref.RunID] = append(refsByRun[ref.RunID], ref) + } + sort.Strings(runIDs) + var pruned []PrunedRef var errs []error + for _, runID := range runIDs { + run, found, err := loadTerminalRun(ctx, runID) + if err != nil { + errs = append(errs, fmt.Errorf("load terminal Run %q: %w", runID, err)) + continue + } + if !found || !store.IsTerminalState(run.State) || canonicalPath(run.GitRoot) != canonicalPath(userRoot) { + continue + } + + result, err := inspectTerminalRun(ctx, runner, run) + if err != nil { + errs = append(errs, fmt.Errorf("inspect terminal Run %q for pruning: %w", runID, err)) + continue + } + if result.State != ReconciliationSafe && result.State != ReconciliationReleased { + continue + } + + if result.State == ReconciliationSafe { + if err := applyTerminalRunWithStore(ctx, runner, runStore, result); err != nil { + errs = append(errs, err) + continue + } + pruned = append(pruned, PrunedRef{ + RunID: result.RunID, + Path: result.Path, + Branch: result.Branch, + }) + } + + prunedTasks, taskErrs := pruneReleasedRunTaskRefs(ctx, runner, userRoot, refsByRun[runID]) + pruned = append(pruned, prunedTasks...) + errs = append(errs, taskErrs...) + } + return pruned, errors.Join(errs...) +} + +func pruneReleasedRunTaskRefs(ctx context.Context, runner gitRunner, userRoot string, refs []terminalCandidate) ([]PrunedRef, []error) { + var pruned []PrunedRef + var errs []error + worktrees, err := listRegisteredWorktrees(ctx, runner, userRoot) + if err != nil { + return nil, []error{err} + } for _, ref := range refs { - if !isTerminalRun(ref.RunID) { + if ref.TaskID == "" { continue } + if registeredPath := registeredBranchPath(worktrees, ref.Branch); registeredPath != "" { + ref.Path = registeredPath + } hasCommits, err := branchHasCommitsBeyondBase(ctx, runner, userRoot, ref.Branch) if err != nil { errs = append(errs, err) @@ -522,11 +1068,11 @@ func PruneTerminalReport(ctx context.Context, userRoot string, location string, } if _, err := os.Stat(ref.Path); err == nil { if _, err := runner.Run(ctx, userRoot, "worktree", "remove", "--force", ref.Path); err != nil { - errs = append(errs, fmt.Errorf("remove terminal Worktree %q: %w", ref.Path, err)) + errs = append(errs, fmt.Errorf("remove terminal Task Worktree %q: %w", ref.Path, err)) continue } - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - errs = append(errs, fmt.Errorf("stat terminal Worktree %q: %w", ref.Path, err)) + } else if !errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("stat terminal Task Worktree %q: %w", ref.Path, err)) continue } if err := deleteRunBranch(ctx, runner, userRoot, ref.Branch); err != nil { @@ -540,7 +1086,7 @@ func PruneTerminalReport(ctx context.Context, userRoot string, location string, Branch: ref.Branch, }) } - return pruned, errors.Join(errs...) + return pruned, errs } func BranchName(runID string) string { @@ -580,6 +1126,222 @@ func (execGitRunner) Run(ctx context.Context, workDir string, args ...string) (s return stdout.String(), nil } +type registeredWorktree struct { + Path string + Branch string +} + +type recordedWorktreeState uint8 + +const ( + recordedWorktreeAbsent recordedWorktreeState = iota + recordedWorktreePresent + recordedWorktreeUnregistered + recordedWorktreeUnsafe +) + +func recordedGitRoot(ctx context.Context, runner gitRunner, value string) (string, error) { + root := strings.TrimSpace(value) + if root == "" { + return "", errors.New("inspect terminal Run: recorded Git root is required") + } + if root != value || strings.ContainsAny(root, "\r\n\x00") { + return "", fmt.Errorf("inspect terminal Run: recorded Git root %q is invalid", value) + } + if !filepath.IsAbs(root) || filepath.Clean(root) != root { + return "", fmt.Errorf("inspect terminal Run: recorded Git root %q must be a clean absolute path", value) + } + hasSymlink, err := pathContainsSymlink(root) + if err != nil { + return "", fmt.Errorf("inspect terminal Run: stat recorded Git root %q: %w", root, err) + } + if hasSymlink { + return "", fmt.Errorf("inspect terminal Run: recorded Git root %q contains a symlink", root) + } + info, err := os.Stat(root) + if err != nil { + return "", fmt.Errorf("inspect terminal Run: stat recorded Git root %q: %w", root, err) + } + if !info.IsDir() { + return "", fmt.Errorf("inspect terminal Run: recorded Git root %q is not a real directory", root) + } + output, err := runner.Run(ctx, root, "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("inspect terminal Run: validate recorded Git root %q: %w", root, err) + } + topLevel := strings.TrimSpace(output) + if topLevel == "" || canonicalPath(topLevel) != canonicalPath(root) { + return "", fmt.Errorf("inspect terminal Run: recorded Git root %q is not the repository root", root) + } + return root, nil +} + +func listRegisteredWorktrees(ctx context.Context, runner gitRunner, gitRoot string) ([]registeredWorktree, error) { + output, err := runner.Run(ctx, gitRoot, "worktree", "list", "--porcelain", "-z") + if err != nil { + return nil, fmt.Errorf("list registered Git worktrees: %w", err) + } + var worktrees []registeredWorktree + var current registeredWorktree + appendCurrent := func() { + if current.Path != "" { + worktrees = append(worktrees, current) + } + current = registeredWorktree{} + } + for _, field := range strings.Split(output, "\x00") { + if field == "" { + appendCurrent() + continue + } + if value, ok := strings.CutPrefix(field, "worktree "); ok { + current.Path = filepath.Clean(value) + continue + } + if value, ok := strings.CutPrefix(field, "branch refs/heads/"); ok { + current.Branch = value + } + } + appendCurrent() + return worktrees, nil +} + +func recordedWorktree(path string, worktrees []registeredWorktree) (registeredWorktree, recordedWorktreeState) { + if path == "" { + return registeredWorktree{}, recordedWorktreeAbsent + } + if strings.ContainsAny(path, "\r\n\x00") { + return registeredWorktree{}, recordedWorktreeUnsafe + } + if !filepath.IsAbs(path) || filepath.Clean(path) != path { + return registeredWorktree{}, recordedWorktreeUnsafe + } + hasSymlink, err := pathContainsSymlink(path) + if errors.Is(err, os.ErrNotExist) { + return registeredWorktree{}, recordedWorktreeAbsent + } + if err != nil || hasSymlink { + return registeredWorktree{}, recordedWorktreeUnsafe + } + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return registeredWorktree{}, recordedWorktreeUnsafe + } + canonical := canonicalPath(path) + for _, worktree := range worktrees { + if canonicalPath(worktree.Path) == canonical { + return worktree, recordedWorktreePresent + } + } + return registeredWorktree{}, recordedWorktreeUnregistered +} + +func pathContainsSymlink(path string) (bool, error) { + volume := filepath.VolumeName(path) + current := volume + string(filepath.Separator) + remainder := strings.TrimPrefix(path, current) + for _, component := range strings.Split(remainder, string(filepath.Separator)) { + if component == "" { + continue + } + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 { + return true, nil + } + } + return false, nil +} + +func registeredBranchPath(worktrees []registeredWorktree, branch string) string { + for _, worktree := range worktrees { + if worktree.Branch == branch { + return worktree.Path + } + } + return "" +} + +func validLocalBranch(ctx context.Context, runner gitRunner, gitRoot, branch string) bool { + if branch == "" || strings.TrimSpace(branch) != branch || strings.HasPrefix(branch, "-") { + return false + } + _, err := runner.Run(ctx, gitRoot, "check-ref-format", "refs/heads/"+branch) + return err == nil +} + +func localBranchExists(ctx context.Context, runner gitRunner, gitRoot, branch string) (bool, error) { + _, err := runner.Run(ctx, gitRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) + if err == nil { + return true, nil + } + if isGitExitCode(err, 1) { + return false, nil + } + return false, err +} + +func resolveUnambiguousLocalBranch(ctx context.Context, runner gitRunner, gitRoot, branch string) (string, error) { + ambiguous, err := localBranchIsAmbiguous(ctx, runner, gitRoot, branch) + if err != nil { + return "", err + } + if ambiguous { + return "", fmt.Errorf("resolve local branch %q: short ref is ambiguous", branch) + } + output, err := runner.Run(ctx, gitRoot, "rev-parse", "--verify", "--end-of-options", "refs/heads/"+branch+"^{commit}") + if err != nil { + return "", err + } + head := strings.TrimSpace(output) + if head == "" || strings.ContainsAny(head, "\r\n") { + return "", fmt.Errorf("resolve local branch %q: Git returned an invalid head", branch) + } + return head, nil +} + +func localBranchIsAmbiguous(ctx context.Context, runner gitRunner, gitRoot, branch string) (bool, error) { + candidates := map[string]bool{ + "refs/" + branch: true, + "refs/tags/" + branch: true, + "refs/heads/" + branch: true, + "refs/remotes/" + branch: true, + "refs/remotes/" + branch + "/HEAD": true, + } + output, err := runner.Run( + ctx, + gitRoot, + "for-each-ref", + "--format=%(refname)", + "refs/"+branch, + "refs/tags/"+branch, + "refs/heads/"+branch, + "refs/remotes/"+branch, + ) + if err != nil { + return false, err + } + count := 0 + for _, line := range strings.Split(output, "\n") { + if candidates[strings.TrimSpace(line)] { + count++ + } + } + return count != 1, nil +} + +func isGitExitCode(err error, code int) bool { + var gitErr *gitCommandError + if !errors.As(err, &gitErr) { + return false + } + var exitErr *exec.ExitError + return errors.As(gitErr.err, &exitErr) && exitErr.ExitCode() == code +} + type gitCommandError struct { args []string stdout string diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 26414109..8df427aa 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "errors" + "fmt" "io" "os" "os/exec" "path/filepath" + "slices" "strings" "testing" "time" + + "roundfix/internal/store" ) // Suite: Run Worktree git lifecycle. @@ -429,9 +433,6 @@ func TestPruneTerminalReapsOnlyEmptyTerminalRunAndTaskBranches(t *testing.T) { if err != nil { t.Fatalf("create empty Task Worktree: %v", err) } - mustWriteWorktreeTest(t, filepath.Join(emptyRun.Path, ".env.local"), "secret=1\n") - mustMkdirWorktreeTest(t, filepath.Join(emptyRun.Path, "node_modules", "cache")) - mustWriteWorktreeTest(t, filepath.Join(emptyRun.Path, "node_modules", "cache", "entry.txt"), "cache\n") mustWriteWorktreeTest(t, filepath.Join(emptyTask.Path, ".env.local"), "secret=1\n") mustMkdirWorktreeTest(t, filepath.Join(emptyTask.Path, "node_modules", "cache")) mustWriteWorktreeTest(t, filepath.Join(emptyTask.Path, "node_modules", "cache", "entry.txt"), "cache\n") @@ -455,8 +456,30 @@ func TestPruneTerminalReapsOnlyEmptyTerminalRunAndTaskBranches(t *testing.T) { t.Fatalf("create non-terminal Task Worktree: %v", err) } - err = PruneTerminal(ctx, repoDir, location, func(runID string) bool { - return runID == "empty-run" || runID == "valuable-run" + runs := map[string]store.Run{ + "empty-run": { + ID: "empty-run", + Kind: store.KindImplement, + State: store.StateStopped, + GitRoot: canonicalPath(repoDir), + LocalBranch: "main", + WorkDir: canonicalPath(emptyRun.Path), + }, + "valuable-run": { + ID: "valuable-run", + Kind: store.KindImplement, + State: store.StateStopped, + GitRoot: canonicalPath(repoDir), + LocalBranch: "main", + WorkDir: canonicalPath(valuableRun.Path), + }, + } + err = PruneTerminal(ctx, repoDir, location, &recordingTerminalRunStore{}, func(lookupCtx context.Context, runID string) (store.Run, bool, error) { + if lookupCtx != ctx { + return store.Run{}, false, errors.New("terminal Run lookup received a different context") + } + run, found := runs[runID] + return run, found, nil }) if err != nil { t.Fatalf("prune terminal: %v", err) @@ -781,7 +804,7 @@ func TestCleanupCleanRemovesRunWorktreeWithUntrackedDebris(t *testing.T) { } } -func TestPruneTerminalReapsCrashedTerminalCleanRunOnly(t *testing.T) { +func TestPruneTerminalPreservesCrashedTerminalRunWithoutCleanlinessProof(t *testing.T) { ctx := context.Background() homeDir := t.TempDir() t.Setenv("HOME", homeDir) @@ -803,15 +826,23 @@ func TestPruneTerminalReapsCrashedTerminalCleanRunOnly(t *testing.T) { t.Fatalf("simulate crashed worktree removal: %v", err) } - err = PruneTerminal(ctx, repoDir, location, func(runID string) bool { - return runID == "terminal-clean" + run := store.Run{ + ID: "terminal-clean", + Kind: store.KindImplement, + State: store.StateStopped, + GitRoot: canonicalPath(repoDir), + LocalBranch: "main", + WorkDir: canonicalPath(cleanRef.Path), + } + err = PruneTerminal(ctx, repoDir, location, &recordingTerminalRunStore{}, func(_ context.Context, runID string) (store.Run, bool, error) { + return run, runID == run.ID, nil }) if err != nil { t.Fatalf("prune terminal: %v", err) } - if branchExists(t, repoDir, cleanRef.Branch) { - t.Fatalf("expected terminal Clean Run Branch %q deleted", cleanRef.Branch) + if !branchExists(t, repoDir, cleanRef.Branch) { + t.Fatalf("expected terminal Run Branch %q preserved without cleanliness proof", cleanRef.Branch) } if _, err := os.Stat(keptRef.Path); err != nil { t.Fatalf("expected non-terminal Run Worktree to survive, stat err=%v", err) @@ -821,6 +852,865 @@ func TestPruneTerminalReapsCrashedTerminalCleanRunOnly(t *testing.T) { } } +func TestInspectTerminalRunSafe(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-safe") + runHead := fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationSafe) + if result.RunHead != runHead || result.TargetHead != runHead { + t.Fatalf("expected both heads at %s, got Run=%q target=%q", runHead, result.RunHead, result.TargetHead) + } +} + +func TestInspectTerminalRunConcurrentSafe(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-concurrent-safe") + runHead := fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + + type inspection struct { + result RunWorktreeReconciliation + err error + } + const inspectors = 8 + inspections := make(chan inspection, inspectors) + for range inspectors { + go func() { + result, err := InspectTerminalRun(ctx, fixture.run) + inspections <- inspection{result: result, err: err} + }() + } + for range inspectors { + got := <-inspections + if got.err != nil { + t.Fatalf("inspect terminal Run concurrently: %v", got.err) + } + assertTerminalRunReconciliation(t, got.result, fixture.run, ReconciliationSafe) + if got.result.RunHead != runHead || got.result.TargetHead != runHead { + t.Fatalf("expected both concurrent heads at %s, got Run=%q target=%q", runHead, got.result.RunHead, got.result.TargetHead) + } + } +} + +func TestCountRetainedTerminalRunsBatchesGitInspectionByRepository(t *testing.T) { + root := canonicalPath(t.TempDir()) + runner := &retainedTerminalGitRunner{ + root: root, + branches: BranchName("retained") + "\n", + } + runs := make([]store.Run, 25) + for index := range runs { + runs[index] = store.Run{ + ID: fmt.Sprintf("released-%02d", index), + Kind: store.KindImplement, + State: store.StateStopped, + GitRoot: root, + } + } + runs[len(runs)-1].ID = "retained" + + retained, failures := countRetainedTerminalRuns(context.Background(), runner, runs) + + if retained != 1 { + t.Fatalf("expected one retained terminal Run, got %d", retained) + } + if len(failures) != 0 { + t.Fatalf("expected no retained terminal Run inspection failures, got %v", failures) + } + if len(runner.calls) != 2 { + t.Fatalf("expected one root validation and one batched branch listing, got %v", runner.calls) + } +} + +func TestInspectTerminalRunUnintegrated(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-unintegrated") + runHead := fixture.commitRunChange(t, "unintegrated.txt", "unintegrated\n") + targetHead := strings.TrimSpace(gitWorktreeTest(t, fixture.repoDir, "rev-parse", "main")) + + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationUnintegrated) + if result.RunHead != runHead || result.TargetHead != targetHead { + t.Fatalf("expected Run head %s and target head %s, got Run=%q target=%q", runHead, targetHead, result.RunHead, result.TargetHead) + } +} + +func TestInspectTerminalRunDirty(t *testing.T) { + tests := []struct { + name string + missingTarget bool + dirty func(t *testing.T, fixture terminalRunFixture) + }{ + { + name: "tracked change with missing target metadata", + missingTarget: true, + dirty: func(t *testing.T, fixture terminalRunFixture) { + mustWriteWorktreeTest(t, filepath.Join(fixture.ref.Path, "shared.txt"), "tracked dirt\n") + }, + }, + { + name: "untracked change", + dirty: func(t *testing.T, fixture terminalRunFixture) { + mustWriteWorktreeTest(t, filepath.Join(fixture.ref.Path, "untracked.txt"), "untracked dirt\n") + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-dirty-"+strings.ReplaceAll(test.name, " ", "-")) + test.dirty(t, fixture) + if test.missingTarget { + fixture.run.LocalBranch = "" + } + head := strings.TrimSpace(gitWorktreeTest(t, fixture.repoDir, "rev-parse", "main")) + + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationDirty) + if result.RunHead != head { + t.Fatalf("expected known Run head at %s for dirty worktree, got %q", head, result.RunHead) + } + if test.missingTarget && result.TargetHead != "" { + t.Fatalf("expected missing target head to stay empty, got %q", result.TargetHead) + } + if !test.missingTarget && result.TargetHead != head { + t.Fatalf("expected known target head at %s for dirty worktree, got %q", head, result.TargetHead) + } + }) + } +} + +func TestInspectTerminalRunUnknownMissingTarget(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-unknown-target") + fixture.run.LocalBranch = "" + runHead := strings.TrimSpace(gitWorktreeTest(t, fixture.repoDir, "rev-parse", fixture.ref.Branch)) + + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationUnknown) + if result.RunHead != runHead || result.TargetHead != "" { + t.Fatalf("expected only Run head %s to resolve, got Run=%q target=%q", runHead, result.RunHead, result.TargetHead) + } +} + +func TestInspectTerminalRunUnknownAmbiguousRef(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-unknown-ambiguous") + fixture.commitRunChange(t, "ambiguous.txt", "ambiguous\n") + gitWorktreeTest(t, fixture.repoDir, "branch", "ambiguous-target", "main") + gitWorktreeTest(t, fixture.repoDir, "tag", "ambiguous-target", fixture.ref.Branch) + fixture.run.LocalBranch = "ambiguous-target" + + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationUnknown) + if result.RunHead == "" { + t.Fatal("expected Run head to remain available when target ref is ambiguous") + } + if result.TargetHead != "" { + t.Fatalf("expected ambiguous target head to stay empty, got %q", result.TargetHead) + } +} + +func TestInspectTerminalRunUnknownMissingRunBranch(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-unknown-run-branch") + gitWorktreeTest(t, fixture.ref.Path, "checkout", "--detach") + gitWorktreeTest(t, fixture.repoDir, "branch", "-D", fixture.ref.Branch) + + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationUnknown) + if result.RunHead != "" { + t.Fatalf("expected missing Run Branch head to stay empty, got %q", result.RunHead) + } +} + +func TestInspectTerminalRunReleased(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-released") + gitWorktreeTest(t, fixture.repoDir, "worktree", "remove", "--force", fixture.ref.Path) + gitWorktreeTest(t, fixture.repoDir, "branch", "-D", fixture.ref.Branch) + + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationReleased) + if result.RunHead != "" || result.TargetHead != "" { + t.Fatalf("expected released result without heads, got Run=%q target=%q", result.RunHead, result.TargetHead) + } +} + +func TestInspectTerminalRunSafeWithoutWorktree(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "reconcile-safe-without-worktree") + gitWorktreeTest(t, fixture.repoDir, "worktree", "remove", "--force", fixture.ref.Path) + head := strings.TrimSpace(gitWorktreeTest(t, fixture.repoDir, "rev-parse", "main")) + + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationSafe) + if result.RunHead != head || result.TargetHead != head { + t.Fatalf("expected branch-only heads at %s, got Run=%q target=%q", head, result.RunHead, result.TargetHead) + } +} + +func TestApplyTerminalRunSafe(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-reconcile-safe") + fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + if err := ApplyTerminalRun(ctx, &recordingTerminalRunStore{}, result); err != nil { + t.Fatalf("apply terminal Run reconciliation: %v", err) + } + + assertPathRemoved(t, fixture.ref.Path) + assertBranchRemoved(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestApplyTerminalRunSafeEvidenceOnly(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-safe-evidence-only") + fixture.commitRunChange(t, "unique.txt", "unique\n") + unintegrated, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect unintegrated terminal Run: %v", err) + } + + if err := ApplyTerminalRun(ctx, &recordingTerminalRunStore{}, unintegrated); err == nil { + t.Fatal("expected non-safe inspected result to refuse apply") + } + forged := unintegrated + forged.State = ReconciliationSafe + if err := ApplyTerminalRun(ctx, &recordingTerminalRunStore{}, forged); err == nil { + t.Fatal("expected forged safe result to refuse apply") + } + + assertPathExists(t, fixture.ref.Path) + assertRunBranchExists(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestApplyTerminalRunStaleHeads(t *testing.T) { + tests := []struct { + name string + change func(t *testing.T, fixture terminalRunFixture) + }{ + { + name: "Run head changed", + change: func(t *testing.T, fixture terminalRunFixture) { + fixture.commitRunChange(t, "later-run.txt", "later\n") + }, + }, + { + name: "target head changed", + change: func(t *testing.T, fixture terminalRunFixture) { + commitWorktreeFile(t, fixture.repoDir, "later-target.txt", "later\n", "later target") + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-stale-"+strings.ReplaceAll(strings.ToLower(test.name), " ", "-")) + fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + test.change(t, fixture) + + if err := ApplyTerminalRun(ctx, &recordingTerminalRunStore{}, result); err == nil { + t.Fatal("expected changed reconciliation heads to refuse apply") + } + + assertPathExists(t, fixture.ref.Path) + assertRunBranchExists(t, fixture.repoDir, fixture.ref.Branch) + }) + } +} + +func TestApplyTerminalRunStaleMetadata(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-stale-metadata") + fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + result.TargetBranch = "other" + + if err := ApplyTerminalRun(ctx, &recordingTerminalRunStore{}, result); err == nil { + t.Fatal("expected changed recorded metadata to refuse apply") + } + + assertPathExists(t, fixture.ref.Path) + assertRunBranchExists(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestApplyTerminalRunDirty(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-newly-dirty") + fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + mustWriteWorktreeTest(t, filepath.Join(fixture.ref.Path, "new-dirt.txt"), "preserve me\n") + + if err := ApplyTerminalRun(ctx, &recordingTerminalRunStore{}, result); err == nil { + t.Fatal("expected newly dirty worktree to refuse apply") + } + + assertPathExists(t, fixture.ref.Path) + assertRunBranchExists(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestApplyTerminalRunIntegrationPendingPersistsBeforeCleanup(t *testing.T) { + ctx := context.Background() + fixture := newStoredTerminalRunFixture(t, store.StateIntegrationPending) + commitWorktreeFile(t, fixture.ref.Path, "safe.txt", "safe\n", "safe change") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + if err := ApplyTerminalRun(ctx, fixture.runStore, result); err != nil { + t.Fatalf("apply Integration Pending terminal Run: %v", err) + } + + stored, found, err := fixture.runStore.Run(ctx, fixture.run.ID) + if err != nil || !found { + t.Fatalf("read reconciled Run: found=%v err=%v", found, err) + } + if stored.State != store.StateClean { + t.Fatalf("expected Integration Pending Run reconciled Clean, got %+v", stored) + } + events, err := fixture.runStore.RunEventsAfter(ctx, fixture.run.ID, 0, 10) + if err != nil { + t.Fatalf("read reconciliation events: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected one reconciliation event, got %d", len(events)) + } + assertPathRemoved(t, fixture.ref.Path) + assertBranchRemoved(t, fixture.repoDir, fixture.ref.Branch) + + if err := ApplyTerminalRun(ctx, fixture.runStore, result); err != nil { + t.Fatalf("repeat terminal Run apply: %v", err) + } + events, err = fixture.runStore.RunEventsAfter(ctx, fixture.run.ID, 0, 10) + if err != nil { + t.Fatalf("read repeated reconciliation events: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected repeat apply to preserve one event, got %d", len(events)) + } +} + +func TestApplyTerminalRunStoreFailureStartsNoCleanup(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-store-failure") + fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + runStore := &recordingTerminalRunStore{err: errors.New("database unavailable")} + runner := &recordingGitRunner{delegate: execGitRunner{}} + + err = applyTerminalRunWithStore(ctx, runner, runStore, result) + if err == nil || !strings.Contains(err.Error(), "persist terminal Run") { + t.Fatalf("expected durable reconciliation failure, got %v", err) + } + if len(runStore.requests) != 1 { + t.Fatalf("expected one persistence attempt, got %d", len(runStore.requests)) + } + if len(runner.mutations) != 0 { + t.Fatalf("database failure must start no Git cleanup, got %v", runner.mutations) + } + assertPathExists(t, fixture.ref.Path) + assertRunBranchExists(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestApplyTerminalRunOutcomeRemainsUnchanged(t *testing.T) { + for _, outcome := range []string{ + store.StateUnresolved, + store.StateFailed, + store.StateStopped, + store.StateTimedOut, + } { + t.Run(outcome, func(t *testing.T) { + ctx := context.Background() + fixture := newStoredTerminalRunFixture(t, outcome) + commitWorktreeFile(t, fixture.ref.Path, "safe.txt", "safe\n", "safe change") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + if err := ApplyTerminalRun(ctx, fixture.runStore, result); err != nil { + t.Fatalf("apply %s terminal Run: %v", outcome, err) + } + + stored, found, err := fixture.runStore.Run(ctx, fixture.run.ID) + if err != nil || !found { + t.Fatalf("read reconciled Run: found=%v err=%v", found, err) + } + if stored.State != outcome { + t.Fatalf("expected outcome %s unchanged, got %+v", outcome, stored) + } + events, err := fixture.runStore.RunEventsAfter(ctx, fixture.run.ID, 0, 10) + if err != nil { + t.Fatalf("read reconciliation events: %v", err) + } + if len(events) != 1 { + t.Fatalf("expected one safe cleanup event, got %d", len(events)) + } + assertPathRemoved(t, fixture.ref.Path) + assertBranchRemoved(t, fixture.repoDir, fixture.ref.Branch) + }) + } +} + +func TestApplyTerminalRunRemovalFailure(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-removal-failure") + fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + runner := &recordingGitRunner{ + delegate: execGitRunner{}, + fail: func(_ string, args []string) error { + if slices.Equal(args, []string{"worktree", "remove", result.Path}) { + return errors.New("injected worktree removal failure") + } + return nil + }, + } + + err = applyTerminalRun(ctx, runner, result) + if err == nil { + t.Fatal("expected worktree removal failure") + } + if strings.Contains(strings.Join(runner.mutations[0], " "), "--force") { + t.Fatalf("worktree removal must not use force: %v", runner.mutations[0]) + } + if len(runner.mutations) != 1 { + t.Fatalf("branch deletion must not follow failed worktree removal, mutations=%v", runner.mutations) + } + if !strings.Contains(err.Error(), result.Path) || !strings.Contains(err.Error(), fixture.ref.Branch) { + t.Fatalf("expected failure to report remaining worktree and branch, got %v", err) + } + var applyErr *TerminalRunApplyError + if !errors.As(err, &applyErr) || !applyErr.WorktreeRemaining || !applyErr.RunBranchRemaining { + t.Fatalf("expected typed remaining worktree and branch evidence, got %#v", applyErr) + } + assertPathExists(t, fixture.ref.Path) + assertRunBranchExists(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestApplyTerminalRunDeletionFailure(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-deletion-failure") + fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + runner := &recordingGitRunner{ + delegate: execGitRunner{}, + fail: func(_ string, args []string) error { + if slices.Equal(args, []string{"branch", "-D", fixture.ref.Branch}) { + return errors.New("injected branch deletion failure") + } + return nil + }, + } + + err = applyTerminalRun(ctx, runner, result) + if err == nil { + t.Fatal("expected branch deletion failure") + } + if len(runner.mutations) != 2 { + t.Fatalf("expected worktree removal before branch deletion, mutations=%v", runner.mutations) + } + if !slices.Equal(runner.mutations[0], []string{"worktree", "remove", result.Path}) || + !slices.Equal(runner.mutations[1], []string{"branch", "-D", fixture.ref.Branch}) { + t.Fatalf("unexpected cleanup order: %v", runner.mutations) + } + if strings.Contains(err.Error(), "worktree="+result.Path) || !strings.Contains(err.Error(), fixture.ref.Branch) { + t.Fatalf("expected failure to report only the remaining branch, got %v", err) + } + var applyErr *TerminalRunApplyError + if !errors.As(err, &applyErr) || applyErr.WorktreeRemaining || !applyErr.RunBranchRemaining { + t.Fatalf("expected typed remaining branch evidence, got %#v", applyErr) + } + assertPathRemoved(t, fixture.ref.Path) + assertRunBranchExists(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestApplyTerminalRunReleased(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "apply-released") + fixture.commitRunChange(t, "safe.txt", "safe\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + result, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + if err := ApplyTerminalRun(ctx, &recordingTerminalRunStore{}, result); err != nil { + t.Fatalf("first apply terminal Run reconciliation: %v", err) + } + runner := &recordingGitRunner{delegate: execGitRunner{}} + + if err := applyTerminalRun(ctx, runner, result); err != nil { + t.Fatalf("repeat apply terminal Run reconciliation: %v", err) + } + if len(runner.mutations) != 0 { + t.Fatalf("repeat apply must perform zero mutations, got %v", runner.mutations) + } + released, err := InspectTerminalRun(ctx, fixture.run) + if err != nil { + t.Fatalf("inspect released terminal Run: %v", err) + } + assertTerminalRunReconciliation(t, released, fixture.run, ReconciliationReleased) +} + +func TestPruneTerminalReconciliationReachableChangedBranch(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "prune-reconciliation-reachable") + fixture.commitRunChange(t, "reachable.txt", "reachable\n") + gitWorktreeTest(t, fixture.repoDir, "merge", "--ff-only", fixture.ref.Branch) + location := filepath.Dir(filepath.Dir(fixture.ref.Path)) + + pruned, err := PruneTerminalReport(ctx, fixture.repoDir, location, &recordingTerminalRunStore{}, func(_ context.Context, runID string) (store.Run, bool, error) { + return fixture.run, runID == fixture.run.ID, nil + }) + if err != nil { + t.Fatalf("prune terminal reconciliation: %v", err) + } + + if len(pruned) != 1 || pruned[0].RunID != fixture.run.ID { + t.Fatalf("expected reachable changed Run to be pruned, got %#v", pruned) + } + assertPathRemoved(t, fixture.ref.Path) + assertBranchRemoved(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestPruneTerminalReconciliationPreservesUniqueChangedBranch(t *testing.T) { + ctx := context.Background() + fixture := newTerminalRunFixture(t, "prune-reconciliation-unique") + fixture.commitRunChange(t, "unique.txt", "unique\n") + location := filepath.Dir(filepath.Dir(fixture.ref.Path)) + + pruned, err := PruneTerminalReport(ctx, fixture.repoDir, location, &recordingTerminalRunStore{}, func(_ context.Context, runID string) (store.Run, bool, error) { + return fixture.run, runID == fixture.run.ID, nil + }) + if err != nil { + t.Fatalf("prune terminal reconciliation: %v", err) + } + + if len(pruned) != 0 { + t.Fatalf("expected unique changed Run to be preserved, got %#v", pruned) + } + assertPathExists(t, fixture.ref.Path) + assertRunBranchExists(t, fixture.repoDir, fixture.ref.Branch) +} + +func TestInspectTerminalRunUnsafePath(t *testing.T) { + t.Run("symlinked worktree", func(t *testing.T) { + fixture := newTerminalRunFixture(t, "reconcile-unsafe-symlink") + link := filepath.Join(t.TempDir(), "linked-worktree") + if err := os.Symlink(fixture.ref.Path, link); err != nil { + t.Fatalf("create worktree symlink: %v", err) + } + fixture.run.WorkDir = link + + result, err := InspectTerminalRun(context.Background(), fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationUnknown) + }) + + t.Run("symlinked worktree parent", func(t *testing.T) { + fixture := newTerminalRunFixture(t, "reconcile-unsafe-parent") + link := filepath.Join(t.TempDir(), "linked-parent") + if err := os.Symlink(filepath.Dir(fixture.ref.Path), link); err != nil { + t.Fatalf("create worktree parent symlink: %v", err) + } + fixture.run.WorkDir = filepath.Join(link, filepath.Base(fixture.ref.Path)) + + result, err := InspectTerminalRun(context.Background(), fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationUnknown) + }) + + t.Run("unclean worktree path", func(t *testing.T) { + fixture := newTerminalRunFixture(t, "reconcile-unsafe-path") + fixture.run.WorkDir = fixture.repoDir + string(filepath.Separator) + ".." + string(filepath.Separator) + "outside" + + result, err := InspectTerminalRun(context.Background(), fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationUnknown) + }) + + t.Run("existing unregistered worktree path", func(t *testing.T) { + fixture := newTerminalRunFixture(t, "reconcile-unregistered-path") + fixture.run.WorkDir = canonicalPath(t.TempDir()) + + result, err := InspectTerminalRun(context.Background(), fixture.run) + if err != nil { + t.Fatalf("inspect terminal Run: %v", err) + } + + assertTerminalRunReconciliation(t, result, fixture.run, ReconciliationUnknown) + if result.Reason != reconciliationReasonWorktreeUnregistered { + t.Fatalf("expected unregistered worktree reason, got %q", result.Reason) + } + }) + + t.Run("symlinked Git root", func(t *testing.T) { + fixture := newTerminalRunFixture(t, "reconcile-unsafe-root") + link := filepath.Join(t.TempDir(), "linked-root") + if err := os.Symlink(fixture.repoDir, link); err != nil { + t.Fatalf("create Git root symlink: %v", err) + } + fixture.run.GitRoot = link + + if _, err := InspectTerminalRun(context.Background(), fixture.run); err == nil { + t.Fatal("expected symlinked recorded Git root to be rejected") + } + }) + + t.Run("symlinked Git root parent", func(t *testing.T) { + fixture := newTerminalRunFixture(t, "reconcile-unsafe-root-parent") + link := filepath.Join(t.TempDir(), "linked-root-parent") + if err := os.Symlink(filepath.Dir(fixture.repoDir), link); err != nil { + t.Fatalf("create Git root parent symlink: %v", err) + } + fixture.run.GitRoot = filepath.Join(link, filepath.Base(fixture.repoDir)) + + if _, err := InspectTerminalRun(context.Background(), fixture.run); err == nil { + t.Fatal("expected recorded Git root under a symlink to be rejected") + } + }) +} + +type terminalRunFixture struct { + integrationFixture + run store.Run +} + +type storedTerminalRunFixture struct { + repoDir string + ref Ref + runStore *store.Store + run store.Run +} + +type recordingGitRunner struct { + delegate gitRunner + fail func(workDir string, args []string) error + mutations [][]string +} + +type retainedTerminalGitRunner struct { + root string + branches string + calls [][]string +} + +type recordingTerminalRunStore struct { + requests []store.IntegrationReconciliation + err error +} + +func (runStore *recordingTerminalRunStore) ReconcileIntegration( + _ context.Context, + req store.IntegrationReconciliation, +) (store.Run, error) { + runStore.requests = append(runStore.requests, req) + if runStore.err != nil { + return store.Run{}, runStore.err + } + outcome := req.PreviousOutcome + if outcome == store.StateIntegrationPending { + outcome = store.StateClean + } + return store.Run{ID: req.RunID, State: outcome}, nil +} + +func (runner *recordingGitRunner) Run(ctx context.Context, workDir string, args ...string) (string, error) { + if len(args) >= 2 && ((args[0] == "worktree" && args[1] == "remove") || (args[0] == "branch" && args[1] == "-D")) { + runner.mutations = append(runner.mutations, append([]string(nil), args...)) + } + if runner.fail != nil { + if err := runner.fail(workDir, args); err != nil { + return "", err + } + } + return runner.delegate.Run(ctx, workDir, args...) +} + +func (runner *retainedTerminalGitRunner) Run(_ context.Context, _ string, args ...string) (string, error) { + runner.calls = append(runner.calls, append([]string(nil), args...)) + switch { + case slices.Equal(args, []string{"rev-parse", "--show-toplevel"}): + return runner.root + "\n", nil + case slices.Equal(args, []string{"for-each-ref", "--format=%(refname:short)", "refs/heads/roundfix/run-*"}): + return runner.branches, nil + default: + return "", fmt.Errorf("unexpected Git arguments: %v", args) + } +} + +func newTerminalRunFixture(t *testing.T, runID string) terminalRunFixture { + t.Helper() + fixture := newIntegrationFixture(t, runID) + return terminalRunFixture{ + integrationFixture: fixture, + run: store.Run{ + ID: runID, + Kind: store.KindImplement, + State: store.StateIntegrationPending, + GitRoot: canonicalPath(fixture.repoDir), + LocalBranch: "main", + WorkDir: canonicalPath(fixture.ref.Path), + SpecSlug: "0038-terminal-run-worktree-reconciliation", + }, + } +} + +func newStoredTerminalRunFixture(t *testing.T, outcome string) storedTerminalRunFixture { + t.Helper() + ctx := context.Background() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + repoDir := initWorktreeRepo(t) + mustWriteWorktreeTest(t, filepath.Join(repoDir, "shared.txt"), "base\n") + gitWorktreeTest(t, repoDir, "add", "shared.txt") + gitWorktreeTest(t, repoDir, "commit", "-m", "initial") + headSHA := strings.TrimSpace(gitWorktreeTest(t, repoDir, "rev-parse", "HEAD")) + + runStore, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatalf("open Run Store: %v", err) + } + t.Cleanup(func() { + _ = runStore.Close() + }) + run, err := runStore.CreateRun(ctx, store.CreateRunRequest{ + Kind: store.KindImplement, + GitRoot: canonicalPath(repoDir), + LocalBranch: "main", + HeadSHA: headSHA, + SpecSlug: "0038-terminal-run-worktree-reconciliation", + }) + if err != nil { + t.Fatalf("create stored terminal Run: %v", err) + } + ref, err := Create(ctx, CreateOptions{ + UserRoot: repoDir, + Location: filepath.Join(homeDir, ".roundfix", "worktrees"), + RunID: run.ID, + HeadSHA: headSHA, + }) + if err != nil { + t.Fatalf("create stored Run Worktree: %v", err) + } + run, err = runStore.SetRunWorkDir(ctx, run.ID, canonicalPath(ref.Path)) + if err != nil { + t.Fatalf("record Run Worktree: %v", err) + } + completed, err := runStore.CompleteRun(ctx, run.ID, outcome) + if err != nil { + t.Fatalf("complete stored Run %s: %v", outcome, err) + } + return storedTerminalRunFixture{ + repoDir: repoDir, + ref: ref, + runStore: runStore, + run: completed.Run, + } +} + +func assertTerminalRunReconciliation(t *testing.T, result RunWorktreeReconciliation, run store.Run, want ReconciliationState) { + t.Helper() + if result.State != want { + t.Fatalf("expected reconciliation state %q, got %#v", want, result) + } + if result.RunID != run.ID || result.Outcome != run.State { + t.Fatalf("expected recorded Run identity and outcome, got %#v", result) + } + if result.Path != run.WorkDir || result.Branch != BranchName(run.ID) { + t.Fatalf("expected recorded worktree %q and Run Branch %q, got %#v", run.WorkDir, BranchName(run.ID), result) + } + if result.TargetBranch != run.LocalBranch { + t.Fatalf("expected recorded target branch %q, got %#v", run.LocalBranch, result) + } + if result.Reason == "" || len(result.Reason) > 160 || strings.ContainsAny(result.Reason, "\r\n") { + t.Fatalf("expected one bounded deterministic reason, got %q", result.Reason) + } + switch result.State { + case ReconciliationSafe, ReconciliationUnintegrated, ReconciliationDirty, ReconciliationUnknown, ReconciliationReleased: + default: + t.Fatalf("unexpected reconciliation state %q", result.State) + } +} + type integrationFixture struct { repoDir string ref Ref diff --git a/skills/roundfix/SKILL.md b/skills/roundfix/SKILL.md index 36556c09..be02da5b 100644 --- a/skills/roundfix/SKILL.md +++ b/skills/roundfix/SKILL.md @@ -519,6 +519,46 @@ roundfix: warning: Journal Retention prune failed: and never block the Run. +## Run Worktree reconciliation + +Use the Reconcile Command to inspect retained terminal spec Run Worktrees and +Run Branches. Always run a dry-run before applying cleanup: + +```bash +roundfix reconcile +roundfix reconcile +roundfix reconcile --format json +``` + +A Run ID selects one terminal spec Run; omitting it scans the current +repository. The report classifies every selected Run into one of five states: + +| State | Agent action | +| --- | --- | +| `safe` | The Run Branch and recorded target resolve, any present registered Run Worktree is clean, and the Run Branch tip is an ancestor of the target tip. This is the only state eligible for cleanup. | +| `unintegrated` | Clean, resolved evidence proves that the Run Branch tip is not an ancestor of the target tip. Preserve the Run Worktree and Run Branch. | +| `dirty` | A present registered Run Worktree has tracked or untracked changes. Preserve the Run Worktree and Run Branch. | +| `unknown` | Metadata or Git evidence cannot prove another state. Preserve every identified Run Worktree and Run Branch. | +| `released` | Both the Run Worktree and Run Branch are absent. No cleanup is needed. | + +After reviewing the dry-run, apply cleanup explicitly: + +```bash +roundfix reconcile --apply +roundfix reconcile --apply +``` + +`--apply` is the only mutation switch. Roundfix acts only on entries classified +`safe` during that invocation and rechecks their metadata, worktree +cleanliness, heads, and ancestry before mutation. It preserves `unintegrated`, +`dirty`, and `unknown` work, and treats `released` as an idempotent no-op. +There is no force bypass. + +Never substitute manual Git deletion for this supported workflow. Do not run +`git worktree remove`, delete the Run Branch, or remove a recorded worktree +directory by hand. The Reconcile Command owns safety proof, evidence recording, +the guarded Integration Pending transition, and cleanup. + ## Stopping Runs Use `roundfix stop` for a graceful stop. Every selector keeps its existing