diff --git a/AGENTS.md b/AGENTS.md index 018999b4c..7b825246a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,11 +30,11 @@ never rebuild the gate by hand. 1. Before dispatching `docket-implement-next`, run `run.gate-before` with `implement-next`. It prints `gate-armed `; keep all three (they won't survive the next tool - call) and copy the `` into the dispatch prompt. The `` is the run epoch id - you thread into `run.cancel --epoch` (below) and every `--run-epoch` dispatch flag (`agent.enter`, - `gate drive start`, `gate drive prepare-scope`). Add `--resume ` to arm for resuming an - already-in-progress change. `gate-unarmed` still lets you dispatch, but keyless (step 2's fallback) - and can never authorize a re-dispatch. + call) and copy the `` and the `` into the dispatch prompt. The `` + is the run epoch id you thread into `run.cancel --epoch` (below) and every `--run-epoch` dispatch + flag (`agent.enter`, `gate drive start`, `gate drive prepare-scope`). Add `--resume ` to arm + for resuming an already-in-progress change. `gate-unarmed` still lets you dispatch, but keyless + (step 2's fallback) and can never authorize a re-dispatch. 2. After the run returns, or its completion notification arrives, run `run.gate-verdict` with ``; without a key, run it with `--unattributed` plus any change id the notification names. Obey the resulting `gate-*` report line exactly, never its exit code or the child's prose. @@ -95,7 +95,7 @@ the existing agent (or re-dispatches with the change id and continuation id) as For Codex, description markers select the native launch over the general named-child wording. `[docket launch: root-coordinator]` takes precedence: foreground catalog-resolved `agent.enter` at the caller cwd. Otherwise `[docket worktree: feature]` requires foreground catalog-resolved `agent.enter` with the owning workflow's exact `--worktree`; an unmarked metadata child uses direct native named-agent dispatch. -For any `agent.enter` route: Write a request file containing the user's request unchanged; for implement-next include the unchanged gate dispatch-context token, labeled for `change.claim --gate-context` and gate-drive operations. Preserve resume/continuation ids and gate keys. Pass `--request`, `--role`, the active absolute caller `--cwd`, approval policy, and sandbox; pass the owning workflow's exact `--worktree` explicitly for feature children. Never omit dispatch context. +For any `agent.enter` route: Write a request file containing the user's request unchanged; for implement-next include the unchanged gate dispatch-context token, labeled for `change.claim --gate-context` and gate-drive operations, and the unchanged run epoch, labeled for `--run-epoch` on prepare-scope and build-owned starts. Preserve resume/continuation ids and gate keys. Pass `--request`, `--role`, the active absolute caller `--cwd`, approval policy, and sandbox; pass the owning workflow's exact `--worktree` explicitly for feature children. Never omit dispatch context. A shell-tool yield carrying a live task/session identity is a liveness transition, not completion. You must retain that exact task/session identity and collect its terminal exit and final output through the harness-native observation/wait mechanism. Never re-run `agent.enter`, start a second watcher, or return a completion report while the original task remains live or unobserved. Only after terminal output is collected may implement-next run the parent's keyed `run.gate-verdict` and obey its report. Coordinator prose, thread or turn ids, and process exit alone do not prove gate ownership or completion. Do not substitute `codex exec`, another harness, a generic agent, or a parent relay. diff --git a/cursor-rules/run-gate.md b/cursor-rules/run-gate.md index 5a2c118d7..c640d9618 100644 --- a/cursor-rules/run-gate.md +++ b/cursor-rules/run-gate.md @@ -9,11 +9,11 @@ never rebuild the gate by hand. 1. Before dispatching `docket-implement-next`, run `run.gate-before` with `implement-next`. It prints `gate-armed `; keep all three (they won't survive the next tool - call) and copy the `` into the dispatch prompt. The `` is the run epoch id - you thread into `run.cancel --epoch` (below) and every `--run-epoch` dispatch flag (`agent.enter`, - `gate drive start`, `gate drive prepare-scope`). Add `--resume ` to arm for resuming an - already-in-progress change. `gate-unarmed` still lets you dispatch, but keyless (step 2's fallback) - and can never authorize a re-dispatch. + call) and copy the `` and the `` into the dispatch prompt. The `` + is the run epoch id you thread into `run.cancel --epoch` (below) and every `--run-epoch` dispatch + flag (`agent.enter`, `gate drive start`, `gate drive prepare-scope`). Add `--resume ` to arm + for resuming an already-in-progress change. `gate-unarmed` still lets you dispatch, but keyless + (step 2's fallback) and can never authorize a re-dispatch. 2. After the run returns, or its completion notification arrives, run `run.gate-verdict` with ``; without a key, run it with `--unattributed` plus any change id the notification names. Obey the resulting `gate-*` report line exactly, never its exit code or the child's prose. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index b52f8ec7d..5701acf0c 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -940,7 +940,10 @@ docket workspace publish --id 412 --head **Arming** (`run.gate-before`) mints three values before a dispatch: the **gate key** (ties a finish to this launch), the **run epoch** (the id of this run, threaded into cancel and drive flags), and -the **dispatch context** (a token copied into the dispatch prompt). It prints +the **dispatch context** (a token). The dispatch context and the run epoch are both copied into the +implement-next dispatch prompt; a scope prepared with `--run-epoch` hands that epoch to every scoped +start under it, so build-task workers never receive it (except the repair worker, for its +build-owned post-fix re-run). It prints `gate-armed `; `gate-unarmed` still allows a keyless dispatch that can never authorise a re-dispatch. diff --git a/docs/results/2026-09-28-document-run-epoch-in-the-docket-build-task-gate-drive-start-results.md b/docs/results/2026-09-28-document-run-epoch-in-the-docket-build-task-gate-drive-start-results.md new file mode 100644 index 000000000..19d2b1e9c --- /dev/null +++ b/docs/results/2026-09-28-document-run-epoch-in-the-docket-build-task-gate-drive-start-results.md @@ -0,0 +1,33 @@ + +> ↩ **[Change 0467 — Scoped gate starts inherit the run epoch; thread it through the build chain](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0467-document-run-epoch-in-the-docket-build-task-gate-drive-start.md)** + +# Scoped gate starts inherit the run epoch; thread it through the build chain — Results + +**Human action:** None needed to merge. After merging, rebuild the installed `docket` binary (the usual post-merge reinstall), because the installed binary still has the old driver behavior until then. + +## Outcome + +During change 0461's run, a build worker's test-gate start was refused with `stale-run-epoch`. The worker only got through by guessing that it should pass `--run-epoch`. The cause had two parts. The "run epoch" (the id the parent's run gate prints when it arms a run) never reached the child agents, and the gate driver ignored the epoch pinned on a worker's recovery scope, looking only at the value the caller passed in. + +This change fixes both: + +- **Driver.** A scoped gate start now inherits the run epoch its scope was prepared with, so a worker that passes no epoch is admitted. A start that presents a *different* epoch is still refused. The build-owner fast-path check in the app layer (`startBudgetedBuild`) now uses the same effective epoch. Before this, it could refuse a start that the real admission check would have allowed. +- **Skill prose.** `docket-implement-next`, `docket-build`, and `docket-build-task` now say where the epoch goes: it is passed as `--run-epoch` to every `prepare-scope` and to every build-owned suite start. Ordinary task workers never handle it. The one exception is the repair worker, which is handed the epoch for its build-owned re-run after a fix. +- **Parent instructions.** The managed run-gate block in `AGENTS.md` (and `cursor-rules/run-gate.md`) now tells the parent to copy the `` into the implement-next dispatch prompt. On Codex, the dispatch prompt is the `agent.enter` request file, and that route now carries the epoch too. +- **Guards.** New repoguard tests pin each of these threads, and each one was mutation-tested. The `AGENTS.md` dispatch-block word budget was re-baselined from 1137 to 1153, which is still below its 1156 ceiling. + +## Verification performed + +- Full suite (`go run ./cmd/docket development test`) passed through the build-owned gate at the Task 3 head: 66/66 files. A final certification run at the PR head is recorded in the PR's build-evidence block. +- Whole-branch deep review returned 1 blocker, 2 important, and 1 minor finding. All four were fixed on the branch (commits `7fb799d08`, `6c9226f54`, `18db4ba15`, `c7a4c8e29`), and each fix's worker ran focused tests. No second review round was run, per policy. +- The budget report printed `PARALLEL-SENSITIVE` / `BUDGET WATCH` screening lines for existing slow integration tests. None of those tests is touched by this change, and no serial-confirmed breach was reported. + +## Known issues and follow-ups + +### Dispatch-block word budget is nearly full + +The `AGENTS.md` dispatch block is now 1153 words against a hard ceiling of 1156. The next change that adds wording to that block will have to trim something first. This is confirmed, has no runtime impact, and only affects authors. Suggested next step: trim the block the next time it is edited. + +### Installed binary lags until reinstall + +Until the post-merge reinstall, the installed `docket` binary still refuses epoch-less scoped starts. Any run that happens before then needs `--run-epoch` passed explicitly, as this run did. Rebuilding per the repo's post-merge rule removes the issue. diff --git a/docs/superpowers/plans/2026-09-28-document-run-epoch-in-the-docket-build-task-gate-drive-start.md b/docs/superpowers/plans/2026-09-28-document-run-epoch-in-the-docket-build-task-gate-drive-start.md new file mode 100644 index 000000000..a1478fa8c --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-document-run-epoch-in-the-docket-build-task-gate-drive-start.md @@ -0,0 +1,1114 @@ + +> ↩ **[Change 0467 — Scoped gate starts inherit the run epoch; thread it through the build chain](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0467-document-run-epoch-in-the-docket-build-task-gate-drive-start.md)** + +# Scoped Gate Starts Inherit the Run Epoch — Implementation Plan + +> **For agentic workers:** this plan is executed by the resolved build skill (`docket-build`), +> task-by-task: one named build-profile worker per task under the `docket-build-task` contract, +> strictly sequential, then one full-suite gate. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a scoped `gate.drive.start` take its run epoch from the scope it was prepared under, +and thread the parent's run epoch through the prose chain (run-gate block → implement-next → +docket-build) to every `gate.drive.prepare-scope` and build-owned `gate.drive.start`, so build-task +workers never handle the epoch. + +**Architecture:** One driver change in `internal/gatedrive/driver.go`: `precheckScopedStart` (which +already loads the scope) resolves the *effective* run epoch and `Admit` substitutes it into its own +copy of the request before every downstream use (epoch gate, scoped worktree admission record, +finished-incumbent reconciliation, admission ticket). A presented epoch that differs from the +scope's pinned one fails the existing `scope-identity-mismatch` check. Prose edits thread the epoch +only as far as the calls that mint a scope or start a scope-less build-owned drive; a new repoguard +prose-contract test pins that, keyed on syntactic shape. + +**Tech Stack:** Go (module `github.com/danielhanold/docket`), `go test`, markdown skill bodies, +`go generate` for the embedded asset tree, the `internal/document` managed-block patcher for the +committed `AGENTS.md` dispatch block. + +**Spec:** `docs/superpowers/specs/2026-09-28-document-run-epoch-in-the-docket-build-task-gate-drive-start-design.md` +(on the `docket` metadata branch; read it before starting any task). + +## Global Constraints + +- Every test run whose purpose is to observe a change in outcome — RED/GREEN checks, mutation probes, + manual re-verification — uses `go test -count=1`. A `(cached)` result is absence of evidence. +- Mutation-test restore is **copy-back**, never `git checkout --`: `cp "$f" "$f.bak"; ; + ; mv -f "$f.bak" "$f"`. Confirm a prose mutation actually landed by counting over a + whitespace-flattened copy (`tr -s '[:space:]' ' ' < "$f" | grep -o -F -- '' | wc -l`), + before and after — a count that does not drop means the mutation never applied. +- **Skill prose edits need embedded-copy regeneration**: after editing anything under `skills/`, + `agents/`, or `cursor-rules/`, run `go generate ./internal/assets/` from the worktree root (the + `//go:generate go run ../../cmd/genassets -repo ../..` directive in `internal/assets/generate.go`), + which rewrites `internal/assets/embedded/tree/**` and `internal/assets/embedded/manifest.json`. + Never hand-edit the embedded tree. `go test -count=1 ./internal/assets/` proves the mirror matches. +- **The managed run-gate block is regenerated, never hand-edited.** The committed `AGENTS.md` + `docket:dispatch` block is the output of `harness.CodexDispatchInterior(harness.RunGate(catalog))` + over the embedded catalog; `TestCommittedCodexDispatchMatchesGenerator` + (`internal/repoguard/root_entry_dispatch_test.go`) fails on any byte difference. Task 3 gives the + exact one-shot regeneration procedure. `CLAUDE.md` is a symlink to `AGENTS.md` — never replace it + with a regular file, never write through `CLAUDE.md`. +- Size budgets are pinned at exact counts: `skillBudgets` and `dispatchBudget` in + `internal/repoguard/budgets_test.go`. Re-baseline a row to the **exact** new `wc -l` / `wc -w` + counts with a leading `0467:` note naming what grew; `dispatchBudget` must stay strictly below + `dispatchOld` (1156). +- Out of scope — do not touch: the run-epoch fence in `reserveWorktreeExecution`, `stale-run-epoch` + semantics, epoch settlement (`EpochSettledFunc`), scope-less start behaviour, and deriving the epoch + from the dispatch-context token. +- The build-task worker's scoped `gate.drive.start` argv is **unchanged**; workers never receive or + pass `--run-epoch`. +- Skill bodies ship into other repositories: no sentence may be true only in this repo. +- Cross-references in maintained source anchor on a symbol name or quoted clause, never a line number. +- Stage only the paths the task names; never `git add -A` / `git add .`. + +## Review Focus + +1. **A successor start in the same scope presenting no epoch** (a worker's RED/GREEN/verification + drives after its baseline) must inherit the scope's epoch through the successor/rotation path, not + only the first-start path — pinned by `TestScopedSuccessorStartInheritsScopeEpoch` (Task 1). +2. **A worker that improvises a different `--run-epoch`** must be refused `scope-identity-mismatch` + with nothing reserved (slot bytes, scope slot, launch count untouched), on both a first start and a + successor start — `TestScopedStartForeignEpochRefused` and the foreign-successor leg of + `TestScopedSuccessorStartInheritsScopeEpoch` (Task 1). +3. **An epoch-less (legacy v2, or prepared-without) scope over an epoch-owned slot** keeps today's + behaviour — the presented value governs, so an empty one is still refused `stale-run-epoch` — + `TestEpochlessScopeKeepsPresentedEpoch` (Task 1). +4. **The WAITING-handoff continuation's re-prepared scope** in `docket-build` must carry the epoch too, + or every continued worker start is refused `stale-run-epoch` — the prong-A per-file floor of 2 + prepare-scope sites in `skills/docket-build/SKILL.md` (Task 2). +5. **Re-flowed prose**: a `--run-epoch` flag or the run-gate binding phrase that wraps across a line + break must still match — the whitespace-collapse cases in the `non_vacuity` subtests (Tasks 2, 3). + +**Recorded residuals (not fixed by this change, stated so review does not rediscover them):** +- `GateDriveService.startBudgetedBuild` (`internal/app/gate_drive.go`) runs its advisory + `ReconcileFinishedIncumbent(req.Worktree, req.RunEpochID)` with the *presented* epoch before + `Admit`. For a scoped build-owned start that omits the epoch over a busy slot, the advisory check + can refuse where the driver would have settled. Build-owned starts in the shipped prose are + scope-less and now pass `--run-epoch` explicitly, so this path is not exercised by the workflow. +- The Codex `agent.enter` route carries the dispatch context in its request file; whether the epoch + reaches the child's prompt on that route is governed by `agent.enter --run-epoch`, which this + change does not alter. + +--- + +### Task 1: Driver — a scoped start inherits its scope's run epoch + +Risk note for routing: touches the scoped admission path (`Driver.Admit` / +`precheckScopedStart`) — consequential but correctable. + +**Files:** +- Modify: `internal/gatedrive/driver.go` — `Driver.Admit`, `Driver.precheckScopedStart`, + `scopedIdentityMatch`; add `scopedRunEpoch`; update the `StartRequest.RunEpochID` doc comment. +- Modify: `internal/gatedrive/scope.go` — `scopeRecord.RunEpochID` doc comment only (make it name + the inheritance; the comment becomes true with this task). +- Test: `internal/gatedrive/epoch_test.go` + +**Interfaces:** +- Consumes (existing test helpers, same package): `sampleStart() StartRequest`, + `scopeReqFor(req StartRequest, gateContext string) ScopeRequest`, `scopedTestDriver(store *Store, + clk *fakeClock, proc ProcessSeam, git GitSeam) *Driver`, `stableGit()`, `startEpoch()`, + `releasedEpochSlot(t, s *Store, worktree, repoID string) admissionRecord` (seeds a released slot + owned by `"epoch-e1"`), `readSlotBytes(t, s *Store, worktree string) []byte`, + `isOwnershipKind(err error, kind OwnershipErrorKind) bool`, `(*Store).ownerCAS`, `fakeProc` + (`launchN` counter; default launch/observe = running, so a first slice WAITs). +- Produces: `func scopedRunEpoch(scope scopeRecord, presented string) string`; + `func (d *Driver) precheckScopedStart(req StartRequest) (string, error)` (was `error`). No exported + API change. + +- [ ] **Step 1: Write the failing tests** + +Add `"sync"` to the imports of `internal/gatedrive/epoch_test.go`, then append: + +```go +// --------------------------------------------------------------------------- +// Scoped starts inherit the scope's run epoch (change 0467). A scope prepared +// with epoch E hands E to every scoped start under it: a start presenting no +// epoch is admitted as E (it used to be refused stale-run-epoch against the +// E-owned slot), presenting E still admits, presenting F != E is refused +// scope-identity-mismatch before anything is reserved, and a scope with no +// epoch leaves the presented value governing, exactly as before. +// --------------------------------------------------------------------------- + +// recordingEpochGate is a permissive EpochLaunchGate that records every epoch id +// it is asked to validate, so a test can prove which epoch the driver gated on. +type recordingEpochGate struct { + mu sync.Mutex + seen []string +} + +func (g *recordingEpochGate) gate() EpochLaunchGate { + return func(epochID, _ string, reserve func() error) error { + g.mu.Lock() + g.seen = append(g.seen, epochID) + g.mu.Unlock() + return reserve() + } +} + +func (g *recordingEpochGate) epochs() []string { + g.mu.Lock() + defer g.mu.Unlock() + return append([]string(nil), g.seen...) +} + +// prepareEpochScopedStart prepares a scope pinned to scopeEpoch ("" for a scope +// with no epoch) over the sample worktree and returns a StartRequest wired to it +// that presents NO run epoch. +func prepareEpochScopedStart(t *testing.T, store *Store, scopeEpoch string) StartRequest { + t.Helper() + req := sampleStart() + sreq := scopeReqFor(req, "") + sreq.RunEpochID = scopeEpoch + grant, err := store.PrepareScope(sreq) + if err != nil { + t.Fatalf("PrepareScope: %v", err) + } + req.ScopeID = grant.ScopeID + req.ChildCapability = grant.ChildCapability + return req +} + +// TestScopedStartInheritsScopeEpoch: a start that presents no epoch, under a +// scope pinned to epoch-e1, over a released slot epoch-e1 still owns, is +// admitted as epoch-e1 — the slot keeps epoch-e1 and every epoch-gate call the +// start made named epoch-e1 (an empty epoch would bypass the gate entirely). +func TestScopedStartInheritsScopeEpoch(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "epoch-e1") + releasedEpochSlot(t, store, req.Worktree, req.RepoDir) + + g := &recordingEpochGate{} + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + d.SetEpochLaunchGate(g.gate()) + + doc, err := d.Start(req) // req.RunEpochID == "" + if err != nil { + t.Fatalf("a scoped start presenting no epoch must inherit the scope's, got %v", err) + } + if doc.Outcome != WAITING { + t.Fatalf("first slice must WAIT, got %s (%s)", doc.Outcome, doc.Cause) + } + slot, _, err := store.LoadWorktreeExecution(req.Worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + if slot.State != admissionExecuting || slot.RunEpochID != "epoch-e1" { + t.Fatalf("slot = %s/%q, want executing/epoch-e1", slot.State, slot.RunEpochID) + } + seen := g.epochs() + if len(seen) == 0 { + t.Fatal("the start never consulted the epoch gate: it ran epoch-less") + } + for _, e := range seen { + if e != "epoch-e1" { + t.Fatalf("epoch gate consulted with %q, want only epoch-e1 (all calls: %v)", e, seen) + } + } +} + +// TestScopedStartPresentingScopeEpochAdmits: presenting the scope's own epoch +// still admits (regression pin — green before and after this change). +func TestScopedStartPresentingScopeEpochAdmits(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "epoch-e1") + releasedEpochSlot(t, store, req.Worktree, req.RepoDir) + req.RunEpochID = "epoch-e1" + + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + doc, err := d.Start(req) + if err != nil || doc.Outcome != WAITING { + t.Fatalf("presenting the scope's epoch must admit and WAIT: doc=%+v err=%v", doc, err) + } + slot, _, err := store.LoadWorktreeExecution(req.Worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + if slot.RunEpochID != "epoch-e1" { + t.Fatalf("slot epoch = %q, want epoch-e1", slot.RunEpochID) + } +} + +// TestScopedStartForeignEpochRefused: presenting an epoch that differs from the +// scope's pinned one is refused scope-identity-mismatch before anything is +// reserved — the worktree slot is byte-for-byte untouched, the scope's single +// slot stays empty, nothing launched, and the epoch gate was never consulted. +func TestScopedStartForeignEpochRefused(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "epoch-e1") + releasedEpochSlot(t, store, req.Worktree, req.RepoDir) + req.RunEpochID = "epoch-foreign" + before := readSlotBytes(t, store, req.Worktree) + + g := &recordingEpochGate{} + proc := &fakeProc{} + d := scopedTestDriver(store, clk, proc, stableGit()) + d.SetEpochLaunchGate(g.gate()) + + if _, err := d.Start(req); !isOwnershipKind(err, ErrScopeIdentityMismatch) { + t.Fatalf("a foreign presented epoch must refuse scope-identity-mismatch, got %v", err) + } + if string(readSlotBytes(t, store, req.Worktree)) != string(before) { + t.Fatal("a refused start must not touch the worktree slot") + } + scope, err := store.LoadScope(req.ScopeID) + if err != nil { + t.Fatalf("LoadScope: %v", err) + } + if scope.CurrentDriveID != "" || scope.DriveCount != 0 { + t.Fatalf("a refused start must reserve no scope slot, got current=%q count=%d", scope.CurrentDriveID, scope.DriveCount) + } + if proc.launchN != 0 { + t.Fatalf("a refused start must launch nothing, launched %d", proc.launchN) + } + if n := len(g.epochs()); n != 0 { + t.Fatalf("a refused start must not reach the epoch gate, consulted %d times", n) + } +} + +// TestScopedSuccessorStartInheritsScopeEpoch: the worker's SEQUENCE of drives in +// one scope — a successor start presenting no epoch after a PASSED predecessor +// over the still-executing, epoch-e1-owned slot — is admitted (it rotates the +// slot), while a successor presenting a foreign epoch is refused +// scope-identity-mismatch without consuming the predecessor receipt. +func TestScopedSuccessorStartInheritsScopeEpoch(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "epoch-e1") + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + + first, err := d.Start(req) // presents no epoch: inherits epoch-e1 + if err != nil || first.Outcome != WAITING { + t.Fatalf("first start must inherit and WAIT: doc=%+v err=%v", first, err) + } + if err := store.ownerCAS(first.DriveID, func(r *driveRecord) error { + r.LastOutcome = PASSED + return nil + }); err != nil { + t.Fatalf("settle predecessor terminal: %v", err) + } + + succ := req + succ.PredecessorDriveID = first.DriveID + succ.PredecessorOwnerGen = first.Generation + + foreign := succ + foreign.RunEpochID = "epoch-foreign" + if _, err := d.Start(foreign); !isOwnershipKind(err, ErrScopeIdentityMismatch) { + t.Fatalf("a successor presenting a foreign epoch must refuse scope-identity-mismatch, got %v", err) + } + + second, err := d.Start(succ) // presents no epoch: inherits epoch-e1 + if err != nil { + t.Fatalf("a successor presenting no epoch must inherit the scope's, got %v", err) + } + if second.Outcome != WAITING || second.DriveID == first.DriveID { + t.Fatalf("successor must be a NEW waiting drive, got %+v", second) + } + slot, _, err := store.LoadWorktreeExecution(req.Worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + if slot.RunEpochID != "epoch-e1" { + t.Fatalf("successor slot epoch = %q, want epoch-e1", slot.RunEpochID) + } +} + +// TestEpochlessScopeKeepsPresentedEpoch: a scope with no pinned epoch (a legacy +// v2 scope, or one prepared without) supplies nothing — the presented value +// governs, unchanged from before. Presenting none over an epoch-e1-owned slot is +// still fenced stale-run-epoch; presenting epoch-e1 admits. +func TestEpochlessScopeKeepsPresentedEpoch(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "") + releasedEpochSlot(t, store, req.Worktree, req.RepoDir) + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + + if _, err := d.Start(req); !isOwnershipKind(err, ErrStaleRunEpoch) { + t.Fatalf("an epoch-less scope must not supply an epoch: want stale-run-epoch, got %v", err) + } + req.RunEpochID = "epoch-e1" + doc, err := d.Start(req) + if err != nil || doc.Outcome != WAITING { + t.Fatalf("presenting the owning epoch under an epoch-less scope must admit: doc=%+v err=%v", doc, err) + } +} +``` + +- [ ] **Step 2: Run the tests to verify the RED is the right one** + +Run: `go test -count=1 ./internal/gatedrive/ -run 'TestScopedStartInheritsScopeEpoch|TestScopedStartPresentingScopeEpochAdmits|TestScopedStartForeignEpochRefused|TestScopedSuccessorStartInheritsScopeEpoch|TestEpochlessScopeKeepsPresentedEpoch' -v` + +Expected: +- `TestScopedStartInheritsScopeEpoch` FAIL — error names `stale-run-epoch`. +- `TestScopedStartForeignEpochRefused` FAIL — got `stale-run-epoch`, wanted `scope-identity-mismatch`. +- `TestScopedSuccessorStartInheritsScopeEpoch` FAIL at the foreign-successor assert (`got `): + the slot is fresh, so today the first start records an empty epoch and nothing compares the + presented epoch with the scope's, so the foreign successor is admitted. Any FAIL whose message is + not about the epoch (compile error, helper misuse) is a test bug: fix the test before continuing. +- `TestScopedStartPresentingScopeEpochAdmits` and `TestEpochlessScopeKeepsPresentedEpoch` PASS + (regression pins for unchanged behaviour). + +- [ ] **Step 3: Implement the inheritance in `internal/gatedrive/driver.go`** + +(a) In `Driver.Admit`, replace the scope pre-check block: + +```go + if req.ScopeID != "" { + if err := d.precheckScopedStart(req); err != nil { + return nil, err + } + } +``` + +with: + +```go + if req.ScopeID != "" { + epoch, err := d.precheckScopedStart(req) + if err != nil { + return nil, err + } + // A scoped start takes its run epoch from the scope it was prepared under + // (change 0467): the scope's RunEpochID is written once by PrepareScope and + // never mutated, so this unlocked read is authoritative. req is Admit's own + // copy, so every later use — the epoch gate, the scoped worktree admission + // record, finished-incumbent reconciliation, and the admission ticket — sees + // the effective epoch rather than the caller-presented one. + req.RunEpochID = epoch + } +``` + +(b) Change `precheckScopedStart` to return the effective epoch. New signature +`func (d *Driver) precheckScopedStart(req StartRequest) (string, error)`; every existing +`return err` / `return ownershipErr(...)` / `return lerr` becomes `return "", `. The two +success exits become: + +- first-start exit (currently the `return nil` after the empty-receipt `scopedIdentityMatch` check): + `return scopedRunEpoch(scope, req.RunEpochID), nil` +- successor exit (currently `return predecessorReusableError(&prec, receipt.OwnerGen)`): + +```go + if err := predecessorReusableError(&prec, receipt.OwnerGen); err != nil { + return "", err + } + return scopedRunEpoch(scope, req.RunEpochID), nil +``` + +Extend its doc comment with one sentence: "On success it returns the start's effective run epoch +(scopedRunEpoch)." + +(c) In `scopedIdentityMatch`, before the final `return true`, add: + +```go + // A scope that pinned a run epoch accepts a start presenting none (it inherits + // the scope's — scopedRunEpoch) or the same one; a different presented epoch is + // an altered identity (change 0467). + if scope.RunEpochID != "" && req.RunEpochID != "" && req.RunEpochID != scope.RunEpochID { + return false + } +``` + +and extend its doc comment: "…plus the gate-context token when the scope pinned one, and the run +epoch when both the scope and the request carry one." + +(d) Add, directly after `scopedIdentityMatch`: + +```go +// scopedRunEpoch resolves the effective run epoch of a scoped start (change 0467): +// a scope that pinned an epoch supplies it — scopedIdentityMatch has already +// refused a start presenting a different one — and a scope with no epoch (a legacy +// v2 scope, or one prepared without) leaves the presented value governing, +// unchanged from before. +func scopedRunEpoch(scope scopeRecord, presented string) string { + if scope.RunEpochID != "" { + return scope.RunEpochID + } + return presented +} +``` + +(e) Doc comments. In `StartRequest.RunEpochID` (driver.go), append: "A scoped start inherits the +epoch its scope pinned when it presents none, and presenting a different one is refused +ErrScopeIdentityMismatch (change 0467)." In `scopeRecord.RunEpochID` (scope.go), replace "travels +onto each scoped start's worktree execution slot" with "is inherited by each scoped start (the +driver's scopedRunEpoch) and travels onto its worktree execution slot". Do not touch the +`ScopeRequest.RunEpochID` comment (already accurate). + +- [ ] **Step 4: Run the focused tests to verify GREEN** + +Run the Step 2 command again. Expected: all five PASS. + +- [ ] **Step 5: Mutation-test the two load-bearing lines** + +```bash +cd /Users/homer/dev/docket/.worktrees/document-run-epoch-in-the-docket-build-task-gate-drive-start +f=internal/gatedrive/driver.go +# M1: drop the substitution — the inheritance tests must redden. +cp "$f" "$f.bak" +perl -0pi -e 's/\n\t\treq\.RunEpochID = epoch\n/\n/' "$f" +grep -c 'req.RunEpochID = epoch' "$f" # expect 0 (was 1): the mutation landed +go test -count=1 ./internal/gatedrive/ -run 'TestScopedStartInheritsScopeEpoch|TestScopedSuccessorStartInheritsScopeEpoch' 2>&1 | tail -5 +mv -f "$f.bak" "$f" +# M2: drop the epoch clause in scopedIdentityMatch — the foreign-epoch tests must redden. +cp "$f" "$f.bak" +perl -0pi -e 's/\tif scope\.RunEpochID != "" && req\.RunEpochID != "" && req\.RunEpochID != scope\.RunEpochID \{\n\t\treturn false\n\t\}\n//' "$f" +grep -c 'req.RunEpochID != scope.RunEpochID' "$f" # expect 0 +go test -count=1 ./internal/gatedrive/ -run 'TestScopedStartForeignEpochRefused|TestScopedSuccessorStartInheritsScopeEpoch' 2>&1 | tail -5 +mv -f "$f.bak" "$f" +git diff --stat # only the intended driver.go/scope.go/epoch_test.go edits remain +``` + +Expected: M1 → both named tests FAIL (`stale-run-epoch`); M2 → both named tests FAIL (admitted +instead of `scope-identity-mismatch`). If a mutation leaves its target green, stop and investigate — +that is a finding about the code or the test, not a residual. + +- [ ] **Step 6: Run the whole gatedrive package plus its consumers** + +Run: `go test -count=1 ./internal/gatedrive/ ./internal/app/ ./internal/cli/` +Expected: PASS. A pre-existing test that now fails with `scope-identity-mismatch` presented an epoch +different from its scope's on purpose: read what it guards before touching it — change its +expectation only if its premise is exactly the old "presented epoch wins over the scope's" behaviour +this change replaces (spec §1 table, row 3), and say so in the commit body. + +- [ ] **Step 7: Commit** + +```bash +git add internal/gatedrive/driver.go internal/gatedrive/scope.go internal/gatedrive/epoch_test.go +git commit -m "fix(gatedrive): scoped starts inherit the scope's run epoch (change 0467)" +``` + +--- + +### Task 2: Thread the run epoch through the build-chain skill prose, guarded + +**Files:** +- Create: `internal/repoguard/gatedrive_run_epoch_thread_test.go` +- Modify: `skills/docket-implement-next/SKILL.md` (the *Verify the run* paragraph's dispatch-context + sentence; Step 6's *Validate the build evidence* re-mint argv) +- Modify: `skills/docket-build/SKILL.md` (the `feature-dispatch` block's prepare-scope argv and bundle + sentence; the WAITING-continuation re-prepare; the final gate's `build_gate: local` item) +- Modify: `skills/docket-build/references/gate-caller-loop.md` (the `start` and `prepare-scope` + operation rows) +- Modify: `skills/docket-build-task/SKILL.md` (the scoped task-start paragraph) +- Regenerate: `internal/assets/embedded/tree/**`, `internal/assets/embedded/manifest.json` +- Modify: `internal/repoguard/budgets_test.go` (`skillBudgets` rows for the four edited files) + +**Interfaces:** +- Consumes (existing, package `repoguard`): `guardRoot`, `maintainedPop`, `readMaintained`, + `isWorkflowMD`, `paragraphs` (splits on blank lines, collapses whitespace), `startOpRe`, + `isScopedTaskStartSite`, `buildSkillRel`, `buildTaskSkillRel`, `sharedContractRel`. +- Produces: `prepareScopeOpRe`, `ownerBuildRe`, `runEpochFlagRe`, `isPrepareScopeSite(p string) bool`, + `isBuildOwnerStartSite(p string) bool`, `carriesRunEpoch(p string) bool`, + `implementNextSkillRel` — Task 3 appends a second test function to the same file and reuses nothing + else from it. + +- [ ] **Step 1: Write the failing guard** + +Create `internal/repoguard/gatedrive_run_epoch_thread_test.go`: + +```go +package repoguard + +// Change 0467: the run epoch the gated parent's arm prints must reach every call +// that mints a recovery scope or starts a build-owned drive, while build-task +// workers never handle it — the driver hands a scoped start the epoch its scope +// pinned. Prongs over maintained workflow markdown (isWorkflowMD, so the +// embedded mirrors are scanned too): +// (A) every paragraph referencing gate.drive.prepare-scope carries --run-epoch; +// (B) every build-owned gate.drive.start paragraph (--owner build) carries +// --run-epoch; +// (C) no scoped task-owned start paragraph (--owner task) carries --run-epoch — +// the worker passes none; the scope supplies it. +// TestRunGateCopiesEpochIntoDispatchPrompt (below) binds the managed run-gate +// source to copying the epoch into the dispatch prompt. +// Site discovery is keyed on syntactic shape, never a per-file allowlist; the +// shared caller contract (sharedContractRel) is the operation reference, not a +// caller, and is excluded exactly as in TestGateDriveScopedStartIdentity. +// Residual risk, recorded not hidden: an instruction that names the operation +// without its `gate.drive.` prefix (a bare `prepare-scope`), or a build-owned +// start without the --owner build token in the same paragraph, is not a site; +// at run time the driver still fences such an epoch-less start against an +// epoch-owned worktree (stale-run-epoch). + +import ( + "fmt" + "regexp" + "strings" + "testing" +) + +const implementNextSkillRel = "skills/docket-implement-next/SKILL.md" + +var ( + prepareScopeOpRe = regexp.MustCompile(`gate\.drive\.prepare-scope`) + ownerBuildRe = regexp.MustCompile(`--owner build(?:[^a-z-]|$)`) + runEpochFlagRe = regexp.MustCompile(`--run-epoch(?:[^a-z-]|$)`) +) + +// isPrepareScopeSite: a collapsed paragraph that references the +// gate.drive.prepare-scope operation. +func isPrepareScopeSite(p string) bool { return prepareScopeOpRe.MatchString(p) } + +// isBuildOwnerStartSite: a collapsed paragraph that references gate.drive.start +// AND carries the --owner build token. +func isBuildOwnerStartSite(p string) bool { + return startOpRe.MatchString(p) && ownerBuildRe.MatchString(p) +} + +// carriesRunEpoch: the paragraph carries the --run-epoch flag token. +func carriesRunEpoch(p string) bool { return runEpochFlagRe.MatchString(p) } + +func TestGateDriveRunEpochThreaded(t *testing.T) { + root := guardRoot(t) + var violations []string + prepSites := map[string]int{} + buildSites := map[string]int{} + taskSites := map[string]int{} + for _, rel := range maintainedPop(t, root) { + if !isWorkflowMD(rel) || strings.HasSuffix(rel, sharedContractRel) { + continue + } + for _, p := range paragraphs(readMaintained(t, root, rel)) { + if isPrepareScopeSite(p) { + prepSites[rel]++ + if !carriesRunEpoch(p) { + violations = append(violations, fmt.Sprintf( + "%s: gate.drive.prepare-scope instruction lacks --run-epoch: %.160s", rel, p)) + } + } + if isBuildOwnerStartSite(p) { + buildSites[rel]++ + if !carriesRunEpoch(p) { + violations = append(violations, fmt.Sprintf( + "%s: build-owned gate.drive.start instruction lacks --run-epoch: %.160s", rel, p)) + } + } + if isScopedTaskStartSite(p) { + taskSites[rel]++ + if carriesRunEpoch(p) { + violations = append(violations, fmt.Sprintf( + "%s: scoped task-owned start must not pass --run-epoch (the scope supplies it): %.160s", rel, p)) + } + } + } + } + + // Population floors FIRST (a vacuous scan passes every negative). + mirror := func(rel string) []string { return []string{rel, "internal/assets/embedded/tree/" + rel} } + for _, rel := range append(mirror(buildSkillRel), mirror(implementNextSkillRel)...) { + if prepSites[rel] == 0 { + t.Errorf("coverage floor: %s contributes no gate.drive.prepare-scope site (scan or corpus drifted)", rel) + } + if buildSites[rel] == 0 { + t.Errorf("coverage floor: %s contributes no build-owned gate.drive.start site (scan or corpus drifted)", rel) + } + } + for _, rel := range mirror(buildSkillRel) { + // The per-dispatch scope AND the WAITING-continuation re-prepare. + if prepSites[rel] < 2 { + t.Errorf("coverage floor: %s must carry both the per-dispatch and the continuation prepare-scope sites, found %d", rel, prepSites[rel]) + } + } + for _, rel := range mirror(buildTaskSkillRel) { + if taskSites[rel] == 0 { + t.Errorf("coverage floor: %s contributes no scoped task-start site (scan or corpus drifted)", rel) + } + } + if len(violations) != 0 { + t.Errorf("run-epoch threading violations (%d):\n%s", len(violations), strings.Join(violations, "\n")) + } + + t.Run("non_vacuity", func(t *testing.T) { + prep := "run the `gate.drive.prepare-scope` operation with `--change-id --worktree --gate-context --run-epoch --json`" + if !isPrepareScopeSite(prep) || !carriesRunEpoch(prep) { + t.Fatalf("a complete prepare-scope invocation was misclassified") + } + if carriesRunEpoch(strings.Replace(prep, "--run-epoch ", "", 1)) { + t.Errorf("stripping --run-epoch from a prepare-scope invocation was not detected") + } + build := "the `gate.drive.start` operation with `--owner build --run-epoch --json`" + if !isBuildOwnerStartSite(build) || !carriesRunEpoch(build) { + t.Fatalf("a complete build-owned start was misclassified") + } + if carriesRunEpoch(strings.Replace(build, "--run-epoch ", "", 1)) { + t.Errorf("stripping --run-epoch from a build-owned start was not detected") + } + if isBuildOwnerStartSite("the `gate.drive.start` operation with `--owner builder --json`") { + t.Errorf("--owner build token boundary failed: 'builder' matched") + } + if isBuildOwnerStartSite("the `gate.drive.start` operation with `--owner task --json`") { + t.Errorf("a task-owned start was classified as build-owned") + } + if carriesRunEpoch("pass `--run-epoch-id `") { + t.Errorf("--run-epoch token boundary failed: '--run-epoch-id' matched") + } + task := "the `gate.drive.start` operation with `--owner task --scope-id --child-cap --run-epoch --json`" + if !isScopedTaskStartSite(task) || !carriesRunEpoch(task) { + t.Errorf("a worker start that passes --run-epoch must be classified and flagged") + } + wrapped := "run `gate.drive.prepare-scope` again\nfor the same change (and `--run-epoch\n`)" + if got := paragraphs(wrapped); len(got) != 1 || !isPrepareScopeSite(got[0]) || !carriesRunEpoch(got[0]) { + t.Errorf("whitespace collapse failed: a wrapped prepare-scope site did not match as one paragraph") + } + }) +} +``` + +- [ ] **Step 2: Run the guard to verify RED** + +Run: `go test -count=1 ./internal/repoguard/ -run TestGateDriveRunEpochThreaded -v` +Expected: FAIL with violations naming, for both `skills/…` and `internal/assets/embedded/tree/skills/…`: +`docket-build/SKILL.md` (2 prepare-scope sites + 1 build-owned start), `docket-implement-next/SKILL.md` +(1 prepare-scope site + 1 build-owned start). No coverage-floor errors (the floors hold on today's +corpus). `non_vacuity` PASS. A coverage-floor error at this step means the scan is miskeyed — fix the +test, not the prose. + +- [ ] **Step 3: Edit `skills/docket-implement-next/SKILL.md`** + +Both edits are inside single-line paragraphs; replace the exact substrings (each fenced block is +the literal text, on one line in the file). + +(a) In the *Verify the run* paragraph, replace + +``` +and into the Step-2 claim's --gate-context. +``` + +with + +``` +and into the Step-2 claim's --gate-context. It may also carry the **run epoch** id: pass it as `--run-epoch ` into every `gate.drive.prepare-scope` and every build-owned `gate.drive.start` (`--owner build` — Step 6's evidence re-mint and the build role's final suite gate) this run performs, omitting the flag only when the prompt carried none (a `gate-unarmed` or ungated run). Scoped task-owned starts inherit the epoch from their scope, so build-task workers are never handed it. +``` + +(b) In Step 6's *Validate the build evidence* paragraph, replace + +``` +the `gate.drive.start` operation with `--repo-dir --run-root --owner build --json` (`--owner build` resolves the build-owned command; no suite argv) +``` + +with + +``` +the `gate.drive.start` operation with `--repo-dir --run-root --owner build --run-epoch --json` (`--owner build` resolves the build-owned command; no suite argv; `--run-epoch` carries the run epoch from your dispatch prompt, omitted only when the prompt carried none) +``` + +- [ ] **Step 4: Edit `skills/docket-build/SKILL.md`** + +(a) In the `feature-dispatch` block, replace + +``` + --gate-context --json` (the dispatch context arrived in *your* prompt from +the gated parent — pass its value through). Capture the scope id and **both** capabilities from the +``` + +with + +``` + --gate-context --run-epoch --json` (the dispatch context and +the run epoch arrived in *your* prompt from the gated parent — pass each value through, omitting a +flag only when your prompt carried no such value). Capture the scope id and **both** capabilities from the +``` + +(b) In the same block, replace + +``` +worker to pass through to `gate.drive.start` unchanged. One scope now carries the worker's whole +``` + +with + +``` +worker to pass through to `gate.drive.start` unchanged. The bundle carries no run epoch: the scope +pinned it, and every scoped start inherits it. One scope now carries the worker's whole +``` + +(c) In the WAITING-continuation paragraph, replace + +``` +for the same change, task, phase, branch, and worktree (and dispatch context) and include the new +``` + +with + +``` +for the same change, task, phase, branch, and worktree (and dispatch context and +`--run-epoch `, as for the first scope) and include the new +``` + +(d) In the final gate's item 2, replace + +``` + **driver**: the `gate.drive.start` operation with `--owner build --json` — capture that first response into `gate_reply` (its exit +``` + +with + +``` + **driver**: the `gate.drive.start` operation with `--owner build --run-epoch --json` + (`--run-epoch` only when your prompt carried a run epoch) — capture that first response into `gate_reply` (its exit +``` + +- [ ] **Step 5: Edit `skills/docket-build/references/gate-caller-loop.md`** + +Both rows are single table lines; replace the exact substrings. + +(a) `prepare-scope` row: replace + +``` +--worktree [--gate-context ]`: mint a recovery scope +``` + +with + +``` +--worktree [--gate-context ] [--run-epoch ]`: mint a recovery scope +``` + +and replace + +``` +the child receives only the scope id and child capability. +``` + +with + +``` +the child receives only the scope id and child capability. A run epoch given here is pinned on the scope and inherited by every scoped start under it. +``` + +(b) `start` row: replace + +``` +and the driver rejects a start whose identity does not match the prepared scope. +``` + +with + +``` +and the driver rejects a start whose identity does not match the prepared scope. A scope-bound start takes its run epoch from the scope: it passes none, and a presented `--run-epoch` that differs from the pinned one is refused `scope-identity-mismatch`. +``` + +- [ ] **Step 6: Edit `skills/docket-build-task/SKILL.md`** + +In the scoped task-start paragraph, replace + +``` +rejects a start that omits or alters any of it. The run root is a scratch dir you pick and read from. +``` + +with + +``` +rejects a start that omits or alters any of it. The run epoch is not in the bundle: it rides on the +prepared scope, so you neither receive nor pass one — a start that invents a different epoch is +refused `scope-identity-mismatch`. The run root is a scratch dir you pick and read from. +``` + +Do **not** write the `--run-epoch` token in this paragraph (prong C flags it) and do not change the +argv. + +- [ ] **Step 7: Regenerate the embedded copies** + +```bash +cd /Users/homer/dev/docket/.worktrees/document-run-epoch-in-the-docket-build-task-gate-drive-start +go generate ./internal/assets/ +git status --short internal/assets/embedded # the four mirrored skill files + manifest.json, nothing else +go test -count=1 ./internal/assets/ +``` + +Expected: exactly the four mirrored files and `manifest.json` modified; assets tests PASS. + +- [ ] **Step 8: Run the guard to verify GREEN** + +Run: `go test -count=1 ./internal/repoguard/ -run 'TestGateDriveRunEpochThreaded|TestGateDriveScopedStartIdentity|TestGateDriveJSONCapture' -v` +Expected: PASS. + +- [ ] **Step 9: Mutation-test the guard against the real corpus** + +```bash +cd /Users/homer/dev/docket/.worktrees/document-run-epoch-in-the-docket-build-task-gate-drive-start +f=skills/docket-build/SKILL.md +count() { tr -s '[:space:]' ' ' < "$f" | grep -o -F -- '--run-epoch ' | wc -l; } +before=$(count) +cp "$f" "$f.bak" +# Strip the continuation re-prepare's flag (the site Review Focus 4 names). +perl -0pi -e 's/\(and dispatch context and\s+`--run-epoch `, as for the first scope\)/(and dispatch context)/' "$f" +after=$(count); echo "before=$before after=$after" # after must be before-1, else MUTATION DID NOT LAND +go test -count=1 ./internal/repoguard/ -run TestGateDriveRunEpochThreaded 2>&1 | grep -F 'docket-build/SKILL.md' +mv -f "$f.bak" "$f" +f=skills/docket-build-task/SKILL.md +cp "$f" "$f.bak" +perl -0pi -e 's/--run-root\n --json/--run-epoch --run-root\n --json/' "$f" +grep -c -F -- '--run-epoch ' "$f" # expect 1: the mutation landed +go test -count=1 ./internal/repoguard/ -run TestGateDriveRunEpochThreaded 2>&1 | grep -F 'must not pass --run-epoch' +mv -f "$f.bak" "$f" +git diff --stat -- skills # only the intended Step 3–6 edits +``` + +Expected: the first probe reports a violation naming `skills/docket-build/SKILL.md` (prepare-scope +lacks `--run-epoch`); the second reports the prong-C violation. If `perl` did not match (count +unchanged), re-derive the pattern from the file's actual wrapping — never read a no-op as "the guard +survived". + +- [ ] **Step 10: Re-baseline the skill size budgets** + +```bash +wc -lw skills/docket-build/SKILL.md skills/docket-build-task/SKILL.md \ + skills/docket-implement-next/SKILL.md skills/docket-build/references/gate-caller-loop.md +``` + +In `internal/repoguard/budgets_test.go` `skillBudgets`, set each of the four rows' line and word +ceilings to the exact measured counts (pre-change ceilings: `docket-build/SKILL.md` 432/4391, +`docket-build-task/SKILL.md` 204/2151, `docket-implement-next/SKILL.md` 214/8080, +`docket-build/references/gate-caller-loop.md` 175/1826 — the gate-caller-loop line ceiling stays 175 +if its line count did not grow). Prefix each row's comment with a note, e.g. +`// 0467: +run-epoch threading on prepare-scope and the build-owned start; the bundle carries no epoch (432/4391 -> L/W); 0459: …` +(keep the existing notes after it). + +Run: `go test -count=1 ./internal/repoguard/` +Expected: PASS (whole package — prose pins elsewhere may quote an edited sentence; a red pin is +relocated to the new wording only if it still asserts the same claim, never restored by re-adding +old text). + +- [ ] **Step 11: Commit** + +```bash +git add internal/repoguard/gatedrive_run_epoch_thread_test.go internal/repoguard/budgets_test.go \ + skills/docket-implement-next/SKILL.md skills/docket-build/SKILL.md \ + skills/docket-build/references/gate-caller-loop.md skills/docket-build-task/SKILL.md \ + internal/assets/embedded +git commit -m "docs(skills): thread the run epoch to prepare-scope and build-owned starts; guard it (change 0467)" +``` + +--- + +### Task 3: Parent run-gate block copies the epoch into the dispatch prompt + +**Files:** +- Modify: `cursor-rules/run-gate.md` (step 1) +- Regenerate: `internal/assets/embedded/tree/cursor-rules/run-gate.md`, + `internal/assets/embedded/manifest.json` +- Regenerate: `AGENTS.md` `docket:dispatch` block (`CLAUDE.md` follows via its symlink) +- Modify: `internal/repoguard/gatedrive_run_epoch_thread_test.go` (append one test function) +- Modify: `internal/repoguard/budgets_test.go` (`dispatchBudget`) + +**Interfaces:** +- Consumes: `guardRoot`, `readMaintained` (package `repoguard`); `document.Parse`, + `(*document.PatchSet).ReplaceBlock`, `doc.Apply`, `assets.EmbeddedCatalog`, `harness.RunGate`, + `harness.CodexDispatchInterior` — used exactly as `TestCommittedCodexDispatchMatchesGenerator` uses + them. +- Produces: `runGateEpochCopyRe`, `TestRunGateCopiesEpochIntoDispatchPrompt`. + +- [ ] **Step 1: Write the failing guard** + +Append to `internal/repoguard/gatedrive_run_epoch_thread_test.go`: + +```go +// runGateEpochCopyRe binds the copy instruction to the epoch AND to its +// destination with one bounded, sentence-local gap: a rewrite that keeps the +// word elsewhere but drops "copy it into the dispatch prompt" reddens. +var runGateEpochCopyRe = regexp.MustCompile("copy the [^.]{0,60}`` into the dispatch prompt") + +// TestRunGateCopiesEpochIntoDispatchPrompt: the managed run-gate source, its +// embedded mirror, and the committed AGENTS.md rendering all tell the parent to +// copy the arm's into the implement-next dispatch prompt (change 0467) — +// otherwise the epoch never reaches the build chain. +func TestRunGateCopiesEpochIntoDispatchPrompt(t *testing.T) { + root := guardRoot(t) + for _, rel := range []string{ + "cursor-rules/run-gate.md", + "internal/assets/embedded/tree/cursor-rules/run-gate.md", + "AGENTS.md", + } { + if !runGateEpochCopyRe.MatchString(collapseWS(readMaintained(t, root, rel))) { + t.Errorf("%s: run-gate step 1 does not copy the `` into the dispatch prompt", rel) + } + } + + t.Run("non_vacuity", func(t *testing.T) { + good := "keep all three and copy the `` and the ``\n into the dispatch prompt." + if !runGateEpochCopyRe.MatchString(collapseWS(good)) { + t.Fatalf("the intended (wrapped) wording did not match") + } + old := "copy the `` into the dispatch prompt. The `` is the run epoch id" + if runGateEpochCopyRe.MatchString(collapseWS(old)) { + t.Errorf("the pre-0467 wording (epoch not copied) matched") + } + if runGateEpochCopyRe.MatchString("copy the ``. Later, `` into the dispatch prompt") { + t.Errorf("bounded gap failed: the binding must not span sentences") + } + }) +} +``` + +(`collapseWS` already exists in package `repoguard`, in `finalize_rebuild_test.go`.) + +- [ ] **Step 2: Run to verify RED** + +Run: `go test -count=1 ./internal/repoguard/ -run TestRunGateCopiesEpochIntoDispatchPrompt -v` +Expected: FAIL for all three files; `non_vacuity` PASS. + +- [ ] **Step 3: Edit `cursor-rules/run-gate.md` step 1** + +Replace the whole step-1 list item: + +``` +1. Before dispatching `docket-implement-next`, run `run.gate-before` with `implement-next`. It prints + `gate-armed `; keep all three (they won't survive the next tool + call) and copy the `` into the dispatch prompt. The `` is the run epoch id + you thread into `run.cancel --epoch` (below) and every `--run-epoch` dispatch flag (`agent.enter`, + `gate drive start`, `gate drive prepare-scope`). Add `--resume ` to arm for resuming an + already-in-progress change. `gate-unarmed` still lets you dispatch, but keyless (step 2's fallback) + and can never authorize a re-dispatch. +``` + +with + +``` +1. Before dispatching `docket-implement-next`, run `run.gate-before` with `implement-next`. It prints + `gate-armed `; keep all three (they won't survive the next tool + call) and copy the `` and the `` into the dispatch prompt. The `` + is the run epoch id you thread into `run.cancel --epoch` (below) and every `--run-epoch` dispatch + flag (`agent.enter`, `gate drive start`, `gate drive prepare-scope`). Add `--resume ` to arm + for resuming an already-in-progress change. `gate-unarmed` still lets you dispatch, but keyless + (step 2's fallback) and can never authorize a re-dispatch. +``` + +(+3 words: "and the ``".) + +- [ ] **Step 4: Regenerate the embedded copy** + +```bash +cd /Users/homer/dev/docket/.worktrees/document-run-epoch-in-the-docket-build-task-gate-drive-start +go generate ./internal/assets/ +git status --short internal/assets/embedded # run-gate.md mirror + manifest.json only +``` + +- [ ] **Step 5: Regenerate the committed `AGENTS.md` dispatch block (never hand-edit it)** + +The block is generator output; regenerate it with a throwaway, uncommitted test that applies the same +patch `TestCommittedCodexDispatchMatchesGenerator` checks, then delete it: + +```bash +cd /Users/homer/dev/docket/.worktrees/document-run-epoch-in-the-docket-build-task-gate-drive-start +cat > internal/repoguard/zz_regen_agents_0467_test.go <<'EOF' +package repoguard + +import ( + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/assets" + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/harness" +) + +func TestZZRegenAgentsDispatch0467(t *testing.T) { + if os.Getenv("DOCKET_REGEN_AGENTS_0467") != "1" { + t.Skip("one-shot regeneration only") + } + p := filepath.Join(guardRoot(t), "AGENTS.md") + src, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + doc, err := document.Parse(src) + if err != nil { + t.Fatal(err) + } + catalog, err := assets.EmbeddedCatalog() + if err != nil { + t.Fatal(err) + } + gate, err := harness.RunGate(catalog) + if err != nil { + t.Fatal(err) + } + var patch document.PatchSet + patch.ReplaceBlock("dispatch", harness.CodexDispatchInterior(gate)) + out, err := doc.Apply(patch) + if err != nil { + t.Fatal(err) + } + tmp := p + ".regen" + if err := os.WriteFile(tmp, out, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Rename(tmp, p); err != nil { + t.Fatal(err) + } +} +EOF +DOCKET_REGEN_AGENTS_0467=1 go test -count=1 ./internal/repoguard/ -run TestZZRegenAgentsDispatch0467 -v +rm -f internal/repoguard/zz_regen_agents_0467_test.go +test -L CLAUDE.md && [ "$(readlink CLAUDE.md)" = "AGENTS.md" ] && echo "CLAUDE.md symlink intact" +git diff --stat -- AGENTS.md # only the step-1 lines inside the docket:dispatch block +git status --short internal/repoguard # the throwaway file must NOT appear +``` + +Expected: `AGENTS.md` changes only inside the `docket:dispatch` markers, mirroring the Step 3 edit; +`CLAUDE.md` is still the symlink; no `zz_regen_*` file remains. + +- [ ] **Step 6: Re-baseline the dispatch-block budget** + +Run: `go test -count=1 ./internal/repoguard/ -run TestDispatchBlockBudget -v` — it reports the +block's new word count (expected 1140) over the 1137 budget. Set `dispatchBudget` in +`internal/repoguard/budgets_test.go` to the exact reported count with a leading note, e.g. +`dispatchBudget = 1140 // 0467: step 1 copies the into the dispatch prompt alongside the dispatch context (was 1137); 0375: …` +(keep the existing note after it). It must remain strictly below `dispatchOld` (1156). + +- [ ] **Step 7: Verify GREEN and mutation-test** + +```bash +cd /Users/homer/dev/docket/.worktrees/document-run-epoch-in-the-docket-build-task-gate-drive-start +go test -count=1 ./internal/repoguard/ -run 'TestRunGateCopiesEpochIntoDispatchPrompt|TestCommittedCodexDispatchMatchesGenerator|TestDispatchBlockBudget' -v +go test -count=1 ./internal/assets/ ./internal/harness/... +f=cursor-rules/run-gate.md +cp "$f" "$f.bak" +perl -0pi -e 's/ and the `` into the dispatch prompt/ into the dispatch prompt/' "$f" +grep -c -F 'and the `` into' "$f" # expect 0: the mutation landed +go test -count=1 ./internal/repoguard/ -run TestRunGateCopiesEpochIntoDispatchPrompt 2>&1 | grep -F 'cursor-rules/run-gate.md' +mv -f "$f.bak" "$f" +``` + +Expected: first three tests PASS; assets/harness PASS; the mutation probe reports the +`cursor-rules/run-gate.md` failure. + +- [ ] **Step 8: Run the whole repoguard package** + +Run: `go test -count=1 ./internal/repoguard/ ./internal/install/...` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add cursor-rules/run-gate.md AGENTS.md internal/assets/embedded \ + internal/repoguard/gatedrive_run_epoch_thread_test.go internal/repoguard/budgets_test.go +git commit -m "docs(run-gate): copy the run epoch into the implement-next dispatch prompt (change 0467)" +``` + +--- + +## After the last task + +The build controller runs the whole suite once through its build-owned gate (the command +`build.test_command` resolves to — never only the tests named above) and reads the budget report +even on a green run. + +## Self-review against the spec + +- Spec §1 table rows 1–4 → Task 1 tests (`…InheritsScopeEpoch`, `…PresentingScopeEpochAdmits`, + `…ForeignEpochRefused`, `EpochlessScopeKeepsPresentedEpoch`); "replaces req.RunEpochID everywhere + the scoped start uses it" → the single substitution in `Admit` feeds `epochGated`, + `admitScoped`/`admitScopedWorktree`, `reconcileFinishedIncumbent`, and `ticket.runEpochID`; the + drive record carries no epoch field — `resolveDriveEpoch` already reads the scope's. Mutation check + → Task 1 Step 5. +- Spec §2 parent → Task 3; implement-next, docket-build, docket-build-task, embedded regeneration → + Task 2 (plus the shared contract's rows, so the reference agrees with the driver). +- Spec §3 guard (prepare-scope sites, build-owned starts, run-gate copy; derived by scan; + mutation-tested) → Task 2 prongs A/B + Task 3; prong C additionally pins "workers never handle the + epoch". +- Out-of-scope items are untouched by every task. diff --git a/internal/app/gate_drive.go b/internal/app/gate_drive.go index f846b7f68..f5aa248f9 100644 --- a/internal/app/gate_drive.go +++ b/internal/app/gate_drive.go @@ -114,6 +114,11 @@ type driveEngine interface { // execution slot with the engine's own process seam (change 0446 spec §3), so an // advisory busy refusal is final only after reconciliation had its chance. ReconcileFinishedIncumbent(worktree, runEpochID string) (settled bool, finding string, err error) + // AdvisoryRunEpoch resolves, read-only, the run epoch Admit would admit a start + // under (change 0467): a credentialed scoped start inherits its scope's pinned + // epoch, anything else keeps the presented one. The advisory precheck + // reconciles with it so it never refuses a start Admit would admit. + AdvisoryRunEpoch(gatedrive.StartRequest) string } // GateDriveService is the in-process seam over the native gate driver. It owns @@ -460,7 +465,12 @@ func (s *GateDriveService) startRequest(req GateDriveStartRequest) gatedrive.Sta // change fixes (a worktree-busy refusal must reserve no attempt). func (s *GateDriveService) startBudgetedBuild(req GateDriveStartRequest, startReq gatedrive.StartRequest) GateDriveResult { if err := s.budgetStore.WorktreeAdmissionRefusal(req.Worktree); err != nil { - settled, finding, _ := s.engine.ReconcileFinishedIncumbent(req.Worktree, req.RunEpochID) + // Reconcile under the epoch Admit would admit this start under — a scoped + // start inherits its scope's pinned epoch (change 0467) — never the raw + // presented one, or an epoch-less scoped start is fenced here though Admit + // would admit it. + epoch := s.engine.AdvisoryRunEpoch(startReq) + settled, finding, _ := s.engine.ReconcileFinishedIncumbent(req.Worktree, epoch) if !settled { if oe, ok := gatedrive.AsOwnershipError(err); ok { oe.Reconciliation = finding diff --git a/internal/app/gate_drive_test.go b/internal/app/gate_drive_test.go index 276fd994a..82ccaf16a 100644 --- a/internal/app/gate_drive_test.go +++ b/internal/app/gate_drive_test.go @@ -50,6 +50,9 @@ type fakeDriveEngine struct { // test can prove a busy advisory refusal reached reconciliation first. reconcile func(worktree, runEpochID string) (bool, string, error) reconcileCount int + // scopeEpoch, when set, is the run epoch a scoped start's scope pinned + // (AdvisoryRunEpoch, change 0467). + scopeEpoch string } func (f *fakeDriveEngine) ReconcileFinishedIncumbent(worktree, runEpochID string) (bool, string, error) { @@ -60,6 +63,16 @@ func (f *fakeDriveEngine) ReconcileFinishedIncumbent(worktree, runEpochID string return f.reconcile(worktree, runEpochID) } +// AdvisoryRunEpoch models the driver's resolution: a scoped start inherits +// scopeEpoch when it presents none (or the same one); anything else keeps the +// presented epoch. +func (f *fakeDriveEngine) AdvisoryRunEpoch(r gatedrive.StartRequest) string { + if r.ScopeID != "" && f.scopeEpoch != "" && (r.RunEpochID == "" || r.RunEpochID == f.scopeEpoch) { + return f.scopeEpoch + } + return r.RunEpochID +} + func (f *fakeDriveEngine) recordStart(r gatedrive.StartRequest) { f.lastStart = r f.startCalled = true @@ -1087,6 +1100,89 @@ func TestBudgetedBuildReconcilesBeforeRefusal(t *testing.T) { }) } +// TestBudgetedBuildAdvisoryReconcilesWithScopeEpoch (change 0467): a scoped +// build-owned start presenting NO run epoch, under a scope pinned to epoch E, over +// a proven-finished incumbent slot E owns, is admitted — the advisory precheck +// reconciles with the scope's epoch, exactly as Admit would, instead of the empty +// presented one (which the slot's epoch fence would refuse worktree-busy). A start +// presenting a foreign epoch stays fenced and is refused before admission. +func TestBudgetedBuildAdvisoryReconcilesWithScopeEpoch(t *testing.T) { + const ( + runID = "0467eeeeeeeeeeeeeeeeeeeeeeeeee01" + epoch = "epoch-e1" + ) + var reconciledEpochs []string + setup := func(t *testing.T) (*GateDriveService, *fakeDriveEngine, string, GateDriveStartRequest, *gatedrive.Store) { + t.Helper() + reconciledEpochs = nil + svc, eng, dir := newBudgetTestBuildService(t, 4) + worktree := testsupport.TempDir(t) + store := gatedrive.OpenStore(dir) + tok, err := store.ReserveWorktreeExecutionForEpoch("/repo", worktree, epoch, nil) + if err != nil { + t.Fatalf("occupy worktree slot for %s: %v", epoch, err) + } + if err := store.ConfirmWorktreeExecution(worktree, tok, runID, "/runs/"+runID); err != nil { + t.Fatalf("confirm incumbent: %v", err) + } + eng.scopeEpoch = epoch + // The E-owned slot is a scopeless-kind incumbent, which the real store proves + // finished only through a drive record this package cannot mint, so the seam + // is scripted: it applies the store's epoch fence (a slot another epoch owns + // is never settled) and otherwise reports the finished incumbent settled. + eng.reconcile = func(w, e string) (bool, string, error) { + reconciledEpochs = append(reconciledEpochs, e) + if e != epoch { + return false, "incumbent-epoch-fenced", nil + } + return true, "incumbent-settled", nil + } + req := GateDriveStartRequest{ + RepoDir: "/repo", Worktree: worktree, ChangeID: "0467", TaskID: "task-1", + Phase: "build", ScopeID: "scope-1", ChildCapability: "child-cap", + } + return svc, eng, dir, req, store + } + + t.Run("no presented epoch inherits the scope's and admits", func(t *testing.T) { + svc, eng, dir, req, _ := setup(t) + got := svc.Start(req) + if got.Result != ResultApplied { + t.Fatalf("a scoped start presenting no epoch must be admitted over its own epoch's finished incumbent: result=%s reason=%q msg=%q", got.Result, got.Reason, got.Message) + } + if eng.reconcileCount != 1 || eng.startCount != 1 || eng.startAdmittedCount != 1 { + t.Fatalf("reconcile=%d admit=%d launch=%d, want 1/1/1", eng.reconcileCount, eng.startCount, eng.startAdmittedCount) + } + if len(reconciledEpochs) != 1 || reconciledEpochs[0] != epoch { + t.Fatalf("the advisory check must reconcile under the scope's epoch, got %v", reconciledEpochs) + } + if used, _ := suiteUsage(t, dir, "0467"); used != 1 { + t.Fatalf("usage = %d, want exactly one charged attempt", used) + } + }) + + t.Run("foreign presented epoch stays fenced", func(t *testing.T) { + svc, eng, dir, req, _ := setup(t) + req.RunEpochID = "epoch-foreign" + got := svc.Start(req) + if got.Result == ResultApplied || got.Reason != string(gatedrive.ErrWorktreeBusy) { + t.Fatalf("a foreign presented epoch must refuse worktree-busy, got result=%s reason=%q", got.Result, got.Reason) + } + if !strings.Contains(got.Message, "incumbent-epoch-fenced") { + t.Fatalf("refusal must name the epoch fence, got %q", got.Message) + } + if eng.startCount != 0 { + t.Fatalf("a fenced start must not reach admission, got %d", eng.startCount) + } + if len(reconciledEpochs) != 1 || reconciledEpochs[0] != "epoch-foreign" { + t.Fatalf("a foreign epoch must be reconciled as presented, got %v", reconciledEpochs) + } + if used, limit := suiteUsage(t, dir, "0467"); used != 0 || limit != 0 { + t.Fatalf("a refused start must charge nothing, got (%d,%d)", used, limit) + } + }) +} + // TestAdmitRefusalChargesNoSuiteAttempt proves the AUTHORITATIVE half of the // ordering fix: when the advisory precheck cannot see the busy slot (it cannot // resolve the worktree) but Admit itself refuses — a race the precheck missed — the diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 1941e3cbd..ac3523afa 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,7 +1,7 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:cae80b0daeff8b3a310465fc28da7de6edd5b8c09df9925f829a425801145278", + "asset_set_id": "sha256:646723f60c240bd40b52d3760b3d4d8f6f1dc49381f845c193da47084d8e1cb0", "entries": [ { "path": ".docket.example.yml", @@ -266,8 +266,8 @@ "path": "cursor-rules/run-gate.md", "role": "dispatch", "mode": 420, - "size": 5170, - "sha256": "3202b66135b150a129566604a16e8d99a9672a87abce707b84853ba464f5ddd3" + "size": 5188, + "sha256": "fd3bd928880a846a3e8afe163b4374e1b78f9bfc2a7efaa27cc913af2e410bed" }, { "path": "skills/docket-adr/SKILL.md", @@ -301,22 +301,22 @@ "path": "skills/docket-build-task/SKILL.md", "role": "skill", "mode": 420, - "size": 13990, - "sha256": "37ecdd531f0e43a7935b64955f87ca343c83e46de2425f78d2cf005c8122bc6a" + "size": 14543, + "sha256": "ca35e9ad2585cb4c3a69a5f55d720d1bbb04bd96be6b7c66a42c31e4d0c19903" }, { "path": "skills/docket-build/SKILL.md", "role": "skill", "mode": 420, - "size": 29562, - "sha256": "6f1d155314e9c530bbcba49a856c1fdef4968626cf02858eec114ebdc5dfed4c" + "size": 30132, + "sha256": "2916825565ff7948578c69b03ff61cb65a9bd33d4dc900c0a21e1e59e446a5cf" }, { "path": "skills/docket-build/references/gate-caller-loop.md", "role": "skill", "mode": 420, - "size": 12367, - "sha256": "ba44f93dc386a7babfd985790c9329b3611eff1a2f267711e8c642f83e467aff" + "size": 12651, + "sha256": "cb628888f968684307267e3b040f0f3c57f8756237b678ea15ad0349f0b798f8" }, { "path": "skills/docket-build/references/gate-execution-evidence.md", @@ -406,8 +406,8 @@ "path": "skills/docket-implement-next/SKILL.md", "role": "skill", "mode": 420, - "size": 56298, - "sha256": "09add3ea3f253758bf8acc5e4b827f335ff9eb7459d6bdcd59785c01f1ce3f3e" + "size": 56932, + "sha256": "6e2421f44a0fa5c2b40a7d61b816fa1932c7cb898bce73ee1fe306786c4f9bc1" }, { "path": "skills/docket-implement-next/references/edge-paths.md", diff --git a/internal/assets/embedded/tree/cursor-rules/run-gate.md b/internal/assets/embedded/tree/cursor-rules/run-gate.md index 5a2c118d7..c640d9618 100644 --- a/internal/assets/embedded/tree/cursor-rules/run-gate.md +++ b/internal/assets/embedded/tree/cursor-rules/run-gate.md @@ -9,11 +9,11 @@ never rebuild the gate by hand. 1. Before dispatching `docket-implement-next`, run `run.gate-before` with `implement-next`. It prints `gate-armed `; keep all three (they won't survive the next tool - call) and copy the `` into the dispatch prompt. The `` is the run epoch id - you thread into `run.cancel --epoch` (below) and every `--run-epoch` dispatch flag (`agent.enter`, - `gate drive start`, `gate drive prepare-scope`). Add `--resume ` to arm for resuming an - already-in-progress change. `gate-unarmed` still lets you dispatch, but keyless (step 2's fallback) - and can never authorize a re-dispatch. + call) and copy the `` and the `` into the dispatch prompt. The `` + is the run epoch id you thread into `run.cancel --epoch` (below) and every `--run-epoch` dispatch + flag (`agent.enter`, `gate drive start`, `gate drive prepare-scope`). Add `--resume ` to arm + for resuming an already-in-progress change. `gate-unarmed` still lets you dispatch, but keyless + (step 2's fallback) and can never authorize a re-dispatch. 2. After the run returns, or its completion notification arrives, run `run.gate-verdict` with ``; without a key, run it with `--unattributed` plus any change id the notification names. Obey the resulting `gate-*` report line exactly, never its exit code or the child's prose. diff --git a/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md b/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md index 460d5b7a2..ebb9ffa2a 100644 --- a/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md @@ -66,7 +66,9 @@ worktree, and use the task-intent owner: the `gate.drive.start` operation with ` --json -- `. Every identity value comes in your dispatch prompt — pass the bundle through unchanged, omitting `--gate-context` only when no dispatch context was handed to you; the prepared scope pinned exactly this identity, and the driver -rejects a start that omits or alters any of it. The run root is a scratch dir you pick and read from. +rejects a start that omits or alters any of it. The run epoch is not in the bundle: it rides on the +prepared scope, so a task-owned start passes none — a start that invents a different epoch is +refused `scope-identity-mismatch`. The run root is a scratch dir you pick and read from. Capture the drive id and owner generation from that `--json` response before any advance or handoff (the shared JSON-capture requirement in `docket-build`'s `references/gate-caller-loop.md`; human text omits the generation). Capture the response into `gate_reply` and its exit code into `gate_rc` — the shared @@ -87,6 +89,11 @@ with the typed cause; `WAITING` → **immediately** perform the `gate.drive.hand and return `WAITING` naming the drive id and that token. After a first `WAITING` never `advance` or restart — the controller owns the drive. `WAITING` consumes neither repair nor escalation budget. +**The one epoch exception:** an integration-repair task's post-fix re-run of the full suite is +build-owned — run the `gate.drive.start` operation with `--owner build --run-epoch --json`, +passing the run epoch your repair dispatch payload carried (omitted when it carried none). Only that +start takes an epoch; every scoped task-owned start still passes none. + **A `worktree-busy` refusal is a blocking diagnostic, never a retry trigger.** One canonical worktree carries at most one running gate at a time. If `gate.drive.start` comes back refused with reason `worktree-busy` (or `unresolved-execution`), another gate is already live — or was left diff --git a/internal/assets/embedded/tree/skills/docket-build/SKILL.md b/internal/assets/embedded/tree/skills/docket-build/SKILL.md index 48a811811..5f63c9fde 100644 --- a/internal/assets/embedded/tree/skills/docket-build/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build/SKILL.md @@ -75,8 +75,9 @@ Emit one concise routing line per task naming both the profile and its reason. **Before each worker dispatch, prepare its recovery scope:** run the `gate.drive.prepare-scope` operation with `--change-id --task-id --phase build --branch --worktree - --gate-context --json` (the dispatch context arrived in *your* prompt from -the gated parent — pass its value through). Capture the scope id and **both** capabilities from the + --gate-context --run-epoch --json` (the dispatch context and +the run epoch arrived in *your* prompt from the gated parent — pass each value through, omitting a +flag only when your prompt carried no such value). Capture the scope id and **both** capabilities from the `--json` response before dispatching (the shared JSON-capture requirement); the parent capability stays in your notes. Then dispatch the selected profile agent **by name** — one of `docket-build-economy`, `docket-build-standard`, `docket-build-premium`, or `docket-build-max` — @@ -87,7 +88,8 @@ It also gives the worker the plan task text, applicable repository instructions, profile and routing reason, the completion schema, and one **complete start-ready scope bundle**: the change id, task id, phase (`build`), branch, scope id, child capability, and the dispatch context when your prompt carried one — each value exactly as `prepare-scope` pinned it, for the -worker to pass through to `gate.drive.start` unchanged. One scope now carries the worker's whole +worker to pass through to `gate.drive.start` unchanged. The bundle carries no run epoch: the scope +pinned it, and every scoped start inherits it. One scope now carries the worker's whole *sequence* of task-owned drives — baseline, RED, GREEN, verification — one at a time, and the worker closes it with a terminal `gate.drive.acknowledge` on normal completion; your WAITING-handoff and takeover handling below is unchanged. Of the two capabilities the worker @@ -145,7 +147,8 @@ transcript. The continuation — a same-agent resume or a fresh dispatch alike the claimed drive's id, its terminal verdict, and an explicit statement that the original scope is closed by your claim and must never be acknowledged or reused. When the continued task may still need test drives, run `gate.drive.prepare-scope` again -for the same change, task, phase, branch, and worktree (and dispatch context) and include the new +for the same change, task, phase, branch, and worktree (and dispatch context and +`--run-epoch `, as for the first scope) and include the new start-ready scope bundle — child capability only; the parent capability stays in your notes, as for any dispatch. Reading the continuation's return is unchanged: a `COMPLETE` is settled against git state exactly as *Reading a worker's return* requires. Waiting consumes neither the task's repair @@ -246,7 +249,8 @@ authoritative config the build role reads, never a command it invents: **skipped** evidence via the `evidence.record` operation (no run dir) — `result: skipped` / `reason: build-gate-off` at the current head — and proceed to review. Nothing to run or repair. 2. **`build_gate: local`, non-empty `build_test_command`** — drive it through the native gate - **driver**: the `gate.drive.start` operation with `--owner build --json` — capture that first response into `gate_reply` (its exit + **driver**: the `gate.drive.start` operation with `--owner build --run-epoch --json` + (`--run-epoch` only when your prompt carried a run epoch) — capture that first response into `gate_reply` (its exit code, if needed, into `gate_rc`; never a zsh read-only special parameter such as `status`) and read the drive id and owner generation from it — then `gate.drive.advance` operation slices, exactly as *Gate execution posture* describes. `--owner build` resolves the build-owned command @@ -296,7 +300,10 @@ admits another attempt, each red full-suite result becomes exactly one synthetic task, run through the same worker contract on the ladder `premium -> max -> halt`. The repair worker diagnoses the cross-task failure, adds regression coverage where appropriate, fixes it, and re-runs the full suite; that post-fix re-run **is** the next budgeted attempt — started build-owned through -the same driver so the facade charges it, no bypass. That ladder starts one rung above the default +the same driver so the facade charges it, no bypass. Its dispatch payload therefore also carries the +run epoch from your prompt, outside the scope bundle, for that one start: the `gate.drive.start` +operation with `--owner build --run-epoch --json` (flag omitted when your prompt carried none). +That ladder starts one rung above the default deliberately: repair is cross-task diagnosis, never routine work. **Green at any point ends the phase immediately; review is never invoked while red.** A refused start (`suite-attempts-exhausted`) or a red final permitted run halts per *Halting conditions* with the exhaustion reason naming diff --git a/internal/assets/embedded/tree/skills/docket-build/references/gate-caller-loop.md b/internal/assets/embedded/tree/skills/docket-build/references/gate-caller-loop.md index 2ddd9dfa2..c15d656bb 100644 --- a/internal/assets/embedded/tree/skills/docket-build/references/gate-caller-loop.md +++ b/internal/assets/embedded/tree/skills/docket-build/references/gate-caller-loop.md @@ -23,11 +23,11 @@ copy): | Operation | What it does | |---|---| -| `start` | Fingerprint the execution context, launch the first raw run through the supervisor, advance one slice, and return the drive id, owner generation, and disposition. A scope-bound start passes the complete identity the scope pinned — `--repo-dir --change-id --task-id --phase --branch --scope-id --child-cap `, plus `--gate-context ` when the dispatch carried one — and the driver rejects a start whose identity does not match the prepared scope. A **successor** start in the same scope additionally presents `--predecessor-drive-id --predecessor-owner-gen ` — the previous drive's captured receipt, both together — acknowledging exactly that durable `PASSED`/`FAILED` predecessor and reusing the scope's single slot; a scope's first start omits the pair, and a `WAITING`/`HALTED` or pending predecessor is refused. | +| `start` | Fingerprint the execution context, launch the first raw run through the supervisor, advance one slice, and return the drive id, owner generation, and disposition. A scope-bound start passes the complete identity the scope pinned — `--repo-dir --change-id --task-id --phase --branch --scope-id --child-cap `, plus `--gate-context ` when the dispatch carried one — and the driver rejects a start whose identity does not match the prepared scope. A scope-bound start takes its run epoch from the scope: it passes none, and a presented `--run-epoch` that differs from the pinned one is refused `scope-identity-mismatch`. A **successor** start in the same scope additionally presents `--predecessor-drive-id --predecessor-owner-gen ` — the previous drive's captured receipt, both together — acknowledging exactly that durable `PASSED`/`FAILED` predecessor and reusing the scope's single slot; a scope's first start omits the pair, and a `WAITING`/`HALTED` or pending predecessor is refused. | | `advance` | Resume the current attempt of a drive (by opaque drive id + owner generation) through one more slice. | | `handoff` | Prove current ownership, revalidate repository + process identity, invalidate the current owner, and mint a **single-use** handoff token — the only way a departing owner transfers a live drive. | | `claim` | Recompute identity, consume a handoff token with a compare-and-swap, and return a **fresh** owner generation the claimant advances with. | -| `prepare-scope` | `--change-id --task-id --phase --branch --worktree [--gate-context ]`: mint a recovery scope for one parent/child dispatch boundary with **separated** parent and child capabilities. The preparing parent keeps the parent capability; the child receives only the scope id and child capability. Effects: local-write. | +| `prepare-scope` | `--change-id --task-id --phase --branch --worktree [--gate-context ] [--run-epoch ]`: mint a recovery scope for one parent/child dispatch boundary with **separated** parent and child capabilities. The preparing parent keeps the parent capability; the child receives only the scope id and child capability. A run epoch given here is pinned on the scope and inherited by every scoped start under it. Effects: local-write. | | `takeover` | `--scope-id --parent-cap [--drive-id ]`: the event-authorized exceptional transfer — prove the parent capability and scope identity, atomically supersede the child's owner generation, and return a fresh generation. Effects: local-write. | | `acknowledge` | `--scope-id --child-cap --drive-id --owner-gen `: consume the scope's final durable `PASSED`/`FAILED` result and close the task scope — the last drive's "successor". Idempotent on an exact repeat; refuses a live, `HALTED`, unrelated, or superseded drive. Effects: local-write. | diff --git a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md index c33b3f2d4..303895780 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md @@ -94,7 +94,7 @@ It also supplies the change id, title, synchronized change-file (the plan backli ### Step 6 — Review + ADRs -**Validate the build evidence (change 0170).** Read the build-evidence record step 5's gate emitted — it must be present, its `head_sha` equal to the branch HEAD, and its `result` either `green` or `skipped` (the repo set `build_gate: off`, carrying `reason: build-gate-off`, no command). Missing, malformed, or stale is a build-contract violation — never review an uncertified branch. Re-mint once yourself: under `build_gate: off`, the `evidence.record` operation with `--id --head ` and no run dir mints the skipped record; under a local gate, **drive** the native gate — never author a launch/observe/sleep loop of your own (change 0342): the `gate.drive.start` operation with `--repo-dir --run-root --owner build --json` (`--owner build` resolves the build-owned command; no suite argv) — capture that first JSON response into `gate_reply` and the drive id and owner generation from it, its exit code (if needed) into `gate_rc`, never a zsh read-only special parameter such as `status` (the shared JSON-capture requirement; human text omits the generation) — then the `gate.drive.advance` operation with `--drive-id --owner-gen ` — one slice per call — under docket-build's bounded gate-execution posture until a terminal disposition. The typed disposition vocabulary lives in `docket-build`'s `references/gate-caller-loop.md`; only a **`PASSED`** disposition at the current head exposes the raw run dir that feeds the recording below — `FAILED`/`HALTED`/`WAITING` never do. +**Validate the build evidence (change 0170).** Read the build-evidence record step 5's gate emitted — it must be present, its `head_sha` equal to the branch HEAD, and its `result` either `green` or `skipped` (the repo set `build_gate: off`, carrying `reason: build-gate-off`, no command). Missing, malformed, or stale is a build-contract violation — never review an uncertified branch. Re-mint once yourself: under `build_gate: off`, the `evidence.record` operation with `--id --head ` and no run dir mints the skipped record; under a local gate, **drive** the native gate — never author a launch/observe/sleep loop of your own (change 0342): the `gate.drive.start` operation with `--repo-dir --run-root --owner build --run-epoch --json` (`--owner build` resolves the build-owned command; no suite argv; `--run-epoch` carries the run epoch from your dispatch prompt, omitted only when the prompt carried none) — capture that first JSON response into `gate_reply` and the drive id and owner generation from it, its exit code (if needed) into `gate_rc`, never a zsh read-only special parameter such as `status` (the shared JSON-capture requirement; human text omits the generation) — then the `gate.drive.advance` operation with `--drive-id --owner-gen ` — one slice per call — under docket-build's bounded gate-execution posture until a terminal disposition. The typed disposition vocabulary lives in `docket-build`'s `references/gate-caller-loop.md`; only a **`PASSED`** disposition at the current head exposes the raw run dir that feeds the recording below — `FAILED`/`HALTED`/`WAITING` never do. **Create the durable evidence.** Under a local gate, only from a **`PASSED`** drive disposition whose head equals the current feature head: the `evidence.record` operation with `--id --run --head ` reads the observed gate command and outcome from the run directory that disposition exposed — **no agent-supplied `passed` boolean** — and returns the immutable typed record; a `FAILED`/`HALTED`/`WAITING` or head-mismatched drive produces none. Then the `evidence.verify` operation with `--record --head ` re-checks its bytes against the head. Any review fix below changes HEAD and **invalidates this evidence** — repeat the record → verify chain before publishing. @@ -143,7 +143,7 @@ Local commit, remote publication, and metadata attachment are **distinct facts** **Mark implemented.** The `change.mark-implemented` operation with `--id --version --head --pr --evidence ` is the final mutation in this scope. Before its transaction it reprobes Git and GitHub and proves: the change is still the exact `in-progress` version, `reconciled: true`, linked to the verified plan; local and remote feature heads equal the supplied head; valid evidence names that head and a passed gate; exactly one verified PR for the feature branch targets the resolved effective-base branch and names that head; and a results artifact is attached and satisfies the final content contract and its artifact identity (missing, invalid, or unattached results — trivial changes included — refuse the transition). It then applies the landed transition atomically — `status: implemented` + `pr:` + updated date + `## Artifacts` block + inline board + audit receipt (letting the sweep read `pr:`), rendering the board inside that transaction, so no separate Board pass runs. It does NOT clear the claim, delete the branch/workspace, merge, archive, or close descendants (0316). A retry against an already-`implemented` change with matching PR/head returns the prior applied outcome, never a duplicate transition. -**Verify the run.** The `run.verify` operation with `--id ` is read-only: it reports one closed verdict — `run-complete` / `run-unclaimed` / `run-incomplete` / `run-halted` / `run-waiting` — and enumerates every unmet conjunct with its stable reason and observed identity. Automation keys on the typed verdict, **never** the exit code (all verdicts exit 0). `run-complete` is the receipt that Step 7's postcondition holds; `run-halted` is the closed verdict for a change carrying a `## Run halted` marker — a human is needed, and an attributed caller re-dispatches it through the resume path (the `change.resume-halted` operation with `--acknowledge-quiescent`, Step 2), never a fresh claim. `run-waiting ` (change 0342) is the closed verdict for a change whose gate stopped at a safe, resumable continuation — neither complete nor failed: an attributed caller resumes that exact handoff rather than drawing another change or re-dispatching a fresh run, and precedence is completed-run postconditions first, then a valid `run-waiting`, then ordinary `run-incomplete`; a persisted `run-halt` stays terminal. A run **dispatched with an explicit change id, continuation id, and gate key resumes, not restarts**: its FIRST act is the `run.gate-claim` operation with ` `, claiming the recovered drive **before any other work**; it never launches a replacement test, and this is a **continuation, not `gate-retry-once`** — the key stays active until a true terminal disposition. Each `gate-retry-once` authorizes exactly one next dispatch; the facade may grant another on a later eligible attempt while the configured `run.max_attempts` budget allows, and a continuation consumes none of that budget. A gated parent's prompt may also carry a **dispatch context** token; pass it into every `gate.drive.prepare-scope` / `gate.drive.start --gate-context` this run performs — each invoked with `--json` per the shared capture requirement — and into the Step-2 claim's --gate-context. +**Verify the run.** The `run.verify` operation with `--id ` is read-only: it reports one closed verdict — `run-complete` / `run-unclaimed` / `run-incomplete` / `run-halted` / `run-waiting` — and enumerates every unmet conjunct with its stable reason and observed identity. Automation keys on the typed verdict, **never** the exit code (all verdicts exit 0). `run-complete` is the receipt that Step 7's postcondition holds; `run-halted` is the closed verdict for a change carrying a `## Run halted` marker — a human is needed, and an attributed caller re-dispatches it through the resume path (the `change.resume-halted` operation with `--acknowledge-quiescent`, Step 2), never a fresh claim. `run-waiting ` (change 0342) is the closed verdict for a change whose gate stopped at a safe, resumable continuation — neither complete nor failed: an attributed caller resumes that exact handoff rather than drawing another change or re-dispatching a fresh run, and precedence is completed-run postconditions first, then a valid `run-waiting`, then ordinary `run-incomplete`; a persisted `run-halt` stays terminal. A run **dispatched with an explicit change id, continuation id, and gate key resumes, not restarts**: its FIRST act is the `run.gate-claim` operation with ` `, claiming the recovered drive **before any other work**; it never launches a replacement test, and this is a **continuation, not `gate-retry-once`** — the key stays active until a true terminal disposition. Each `gate-retry-once` authorizes exactly one next dispatch; the facade may grant another on a later eligible attempt while the configured `run.max_attempts` budget allows, and a continuation consumes none of that budget. A gated parent's prompt may also carry a **dispatch context** token; pass it into every `gate.drive.prepare-scope` / `gate.drive.start --gate-context` this run performs — each invoked with `--json` per the shared capture requirement — and into the Step-2 claim's --gate-context. It may also carry the **run epoch** id: pass it as `--run-epoch ` into every `gate.drive.prepare-scope` and every build-owned `gate.drive.start` (`--owner build` — Step 6's evidence re-mint, the build role's final suite gate, and its repair worker's post-fix re-run) this run performs, omitting the flag only when the prompt carried none (a `gate-unarmed` or ungated run). Scoped task-owned starts inherit the epoch from their scope, so a build-task worker is handed it only for that repair re-run. **STOP.** The change stays in `active/` as `implemented` until a human merges it, or approves `docket-finalize-change` to merge it. diff --git a/internal/gatedrive/driver.go b/internal/gatedrive/driver.go index 474d916dc..6f6d2c52e 100644 --- a/internal/gatedrive/driver.go +++ b/internal/gatedrive/driver.go @@ -132,7 +132,9 @@ type StartRequest struct { // does not carry this epoch is refused ErrStaleRunEpoch, so an omitted or stale // epoch cannot detach a workflow-owned worktree from its epoch. Empty for a // standalone gate that owns no implementation epoch (finalize's local gate, an - // ad-hoc task drive). (change 0375 Task 9) + // ad-hoc task drive). (change 0375 Task 9) A scoped start inherits the epoch its + // scope pinned when it presents none, and presenting a different one is refused + // ErrScopeIdentityMismatch (change 0467). RunEpochID string // Recovery-scope successor receipt (change 0405 Task 4): the previous drive's @@ -389,9 +391,17 @@ func (d *Driver) Admit(req StartRequest) (*AdmissionTicket, error) { // AUTHORITY that re-check every condition and arbitrate races, so a state // observed here but changed by a concurrent transition is caught there. if req.ScopeID != "" { - if err := d.precheckScopedStart(req); err != nil { + epoch, err := d.precheckScopedStart(req) + if err != nil { return nil, err } + // A scoped start takes its run epoch from the scope it was prepared under + // (change 0467): the scope's RunEpochID is written once by PrepareScope and + // never mutated, so this unlocked read is authoritative. req is Admit's own + // copy, so every later use — the epoch gate, the scoped worktree admission + // record, finished-incumbent reconciliation, and the admission ticket — sees + // the effective epoch rather than the caller-presented one. + req.RunEpochID = epoch } fp, err := ComputeFingerprint(req.Worktree, d.git) @@ -672,30 +682,31 @@ func (d *Driver) AbandonAdmission(t *AdmissionTicket) error { // scope's complete pinned identity, name a scope whose slot holds a launched current // drive, and name a predecessor with a durable PASSED/FAILED result still owned by // the presented generation and carrying no outstanding handoff. A half-filled -// receipt is a fail-closed ErrStalePredecessor. -func (d *Driver) precheckScopedStart(req StartRequest) error { +// receipt is a fail-closed ErrStalePredecessor. On success it returns the start's +// effective run epoch (scopedRunEpoch). +func (d *Driver) precheckScopedStart(req StartRequest) (string, error) { scope, err := d.store.LoadScope(req.ScopeID) if err != nil { - return err + return "", err } // The child capability is checked before the closed state, matching Acknowledge // and scopeReserveRefusal, so a rejected credential never learns whether the // scope was transferred or finished. if req.ChildCapability == "" || scope.ChildCapHash != capHash(req.ChildCapability) { - return ownershipErr(ErrScopeCapabilityMismatch, "start") + return "", ownershipErr(ErrScopeCapabilityMismatch, "start") } if scope.Closed { if !scope.FinalAcked { // Closed by a claim or takeover: scope authority transferred to the // parent, not finished by its own terminal acknowledgement (change 0459). - return ownershipErr(ErrScopeTransferred, "start") + return "", ownershipErr(ErrScopeTransferred, "start") } - return ownershipErr(ErrScopeClosed, "start") + return "", ownershipErr(ErrScopeClosed, "start") } receipt := predecessorReceipt{DriveID: req.PredecessorDriveID, OwnerGen: req.PredecessorOwnerGen} if receipt.halfFilled() { - return ownershipErr(ErrStalePredecessor, "start") + return "", ownershipErr(ErrStalePredecessor, "start") } if receipt.empty() { @@ -704,30 +715,30 @@ func (d *Driver) precheckScopedStart(req StartRequest) error { // against an empty slot (a first start still fixes the scope's identity). if scope.CurrentDriveID != "" { if scope.CurrentDriveState == scopeStateReserved { - return ownershipErr(ErrScopeBusy, "start") + return "", ownershipErr(ErrScopeBusy, "start") } - return ownershipErr(ErrScopeSecondDrive, "start") + return "", ownershipErr(ErrScopeSecondDrive, "start") } if !scopedIdentityMatch(scope, req) { - return ownershipErr(ErrScopeIdentityMismatch, "start") + return "", ownershipErr(ErrScopeIdentityMismatch, "start") } - return nil + return scopedRunEpoch(scope, req.RunEpochID), nil } // Successor start: the complete pinned identity, a launched current slot, and a // durable reusable predecessor the receipt names. if !scopedIdentityMatch(scope, req) { - return ownershipErr(ErrScopeIdentityMismatch, "start") + return "", ownershipErr(ErrScopeIdentityMismatch, "start") } if scope.CurrentDriveID == "" { // A successor acknowledges a predecessor result, but the scope holds none. - return ownershipErr(ErrStalePredecessor, "start") + return "", ownershipErr(ErrStalePredecessor, "start") } if scope.CurrentDriveState == scopeStateReserved { - return ownershipErr(ErrScopeBusy, "start") + return "", ownershipErr(ErrScopeBusy, "start") } if scope.PendingAckDriveID != "" { - return ownershipErr(ErrUnresolvedLaunchTransition, "start") + return "", ownershipErr(ErrUnresolvedLaunchTransition, "start") } // Validate the CLAIMED predecessor record (cheap, consumes nothing). Whether it is // the scope's CURRENT drive is reserveScopeDrive's authority — a wrong id there is @@ -737,18 +748,22 @@ func (d *Driver) precheckScopedStart(req StartRequest) error { prec, lerr := d.store.Load(receipt.DriveID) if lerr != nil { if _, ok := AsStoreError(lerr); ok { - return ownershipErr(ErrStalePredecessor, "start") + return "", ownershipErr(ErrStalePredecessor, "start") } - return lerr + return "", lerr } - return predecessorReusableError(&prec, receipt.OwnerGen) + if err := predecessorReusableError(&prec, receipt.OwnerGen); err != nil { + return "", err + } + return scopedRunEpoch(scope, req.RunEpochID), nil } // scopedIdentityMatch reports whether a scoped Start request carries the scope's // complete pinned identity: the repo/branch/worktree/change/task/phase bundle // scopeIdentityMatch checks, plus the gate-context token when the scope pinned one // (Invariant 6 — omission or alteration must not detach a drive from outer -// recovery). A scope that pinned no gate context accepts any (the pre-0359 default). +// recovery), and the run epoch when both the scope and the request carry one. A +// scope that pinned no gate context accepts any (the pre-0359 default). func scopedIdentityMatch(scope scopeRecord, req StartRequest) bool { if !scopeIdentityMatch(scope, req.RepoDir, req.Branch, req.Worktree, req.ChangeID, req.TaskID, req.Phase) { return false @@ -756,9 +771,49 @@ func scopedIdentityMatch(scope scopeRecord, req StartRequest) bool { if scope.GateContextHash != "" && capHash(req.GateContext) != scope.GateContextHash { return false } + // A scope that pinned a run epoch accepts a start presenting none (it inherits + // the scope's — scopedRunEpoch) or the same one; a different presented epoch is + // an altered identity (change 0467). + if scope.RunEpochID != "" && req.RunEpochID != "" && req.RunEpochID != scope.RunEpochID { + return false + } return true } +// scopedRunEpoch resolves the effective run epoch of a scoped start (change 0467): +// a scope that pinned an epoch supplies it — scopedIdentityMatch has already +// refused a start presenting a different one — and a scope with no epoch (a legacy +// v2 scope, or one prepared without) leaves the presented value governing, +// unchanged from before. +func scopedRunEpoch(scope scopeRecord, presented string) string { + if scope.RunEpochID != "" { + return scope.RunEpochID + } + return presented +} + +// AdvisoryRunEpoch resolves, without writing anything, the run epoch Admit would +// admit req under, for the application layer's advisory pre-admission check +// (change 0467). A scoped start that presents its scope's child capability takes +// scopedRunEpoch — the scope's pinned epoch, or the presented one for an +// epoch-less scope. A start presenting a foreign epoch keeps it (Admit refuses +// that start scope-identity-mismatch, so the advisory check must stay fenced), as +// does a scopeless start, an unreadable scope, or a rejected capability: those are +// Admit's to refuse, never the advisory check's to widen. +func (d *Driver) AdvisoryRunEpoch(req StartRequest) string { + if req.ScopeID == "" { + return req.RunEpochID + } + scope, err := d.store.LoadScope(req.ScopeID) + if err != nil || req.ChildCapability == "" || scope.ChildCapHash != capHash(req.ChildCapability) { + return req.RunEpochID + } + if scope.RunEpochID != "" && req.RunEpochID != "" && req.RunEpochID != scope.RunEpochID { + return req.RunEpochID + } + return scopedRunEpoch(scope, req.RunEpochID) +} + // admitScopeless runs the pre-launch admission half for a gate without a recovery // scope (for example, finalize's local gate): // diff --git a/internal/gatedrive/epoch_test.go b/internal/gatedrive/epoch_test.go index 785a99f45..aaff99899 100644 --- a/internal/gatedrive/epoch_test.go +++ b/internal/gatedrive/epoch_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "sync" "testing" "github.com/danielhanold/docket/internal/testsupport" @@ -167,3 +168,261 @@ func TestScopeSchemaV2LegacyTolerated(t *testing.T) { t.Fatalf("a CAS write must stamp the record forward to v%d, got v%d", scopeSchemaVersion, stored.Record.SchemaVersion) } } + +// --------------------------------------------------------------------------- +// Scoped starts inherit the scope's run epoch (change 0467). A scope prepared +// with epoch E hands E to every scoped start under it: a start presenting no +// epoch is admitted as E (it used to be refused stale-run-epoch against the +// E-owned slot), presenting E still admits, presenting F != E is refused +// scope-identity-mismatch before anything is reserved, and a scope with no +// epoch leaves the presented value governing, exactly as before. +// --------------------------------------------------------------------------- + +// recordingEpochGate is a permissive EpochLaunchGate that records every epoch id +// it is asked to validate, so a test can prove which epoch the driver gated on. +type recordingEpochGate struct { + mu sync.Mutex + seen []string +} + +func (g *recordingEpochGate) gate() EpochLaunchGate { + return func(epochID, _ string, reserve func() error) error { + g.mu.Lock() + g.seen = append(g.seen, epochID) + g.mu.Unlock() + return reserve() + } +} + +func (g *recordingEpochGate) epochs() []string { + g.mu.Lock() + defer g.mu.Unlock() + return append([]string(nil), g.seen...) +} + +// prepareEpochScopedStart prepares a scope pinned to scopeEpoch ("" for a scope +// with no epoch) over the sample worktree and returns a StartRequest wired to it +// that presents NO run epoch. +func prepareEpochScopedStart(t *testing.T, store *Store, scopeEpoch string) StartRequest { + t.Helper() + req := sampleStart() + sreq := scopeReqFor(req, "") + sreq.RunEpochID = scopeEpoch + grant, err := store.PrepareScope(sreq) + if err != nil { + t.Fatalf("PrepareScope: %v", err) + } + req.ScopeID = grant.ScopeID + req.ChildCapability = grant.ChildCapability + return req +} + +// TestScopedStartInheritsScopeEpoch: a start that presents no epoch, under a +// scope pinned to epoch-e1, over a released slot epoch-e1 still owns, is +// admitted as epoch-e1 — the slot keeps epoch-e1 and every epoch-gate call the +// start made named epoch-e1 (an empty epoch would bypass the gate entirely). +func TestScopedStartInheritsScopeEpoch(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "epoch-e1") + releasedEpochSlot(t, store, req.Worktree, req.RepoDir) + + g := &recordingEpochGate{} + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + d.SetEpochLaunchGate(g.gate()) + + doc, err := d.Start(req) // req.RunEpochID == "" + if err != nil { + t.Fatalf("a scoped start presenting no epoch must inherit the scope's, got %v", err) + } + if doc.Outcome != WAITING { + t.Fatalf("first slice must WAIT, got %s (%s)", doc.Outcome, doc.Cause) + } + slot, _, err := store.LoadWorktreeExecution(req.Worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + if slot.State != admissionExecuting || slot.RunEpochID != "epoch-e1" { + t.Fatalf("slot = %s/%q, want executing/epoch-e1", slot.State, slot.RunEpochID) + } + seen := g.epochs() + if len(seen) == 0 { + t.Fatal("the start never consulted the epoch gate: it ran epoch-less") + } + for _, e := range seen { + if e != "epoch-e1" { + t.Fatalf("epoch gate consulted with %q, want only epoch-e1 (all calls: %v)", e, seen) + } + } +} + +// TestScopedStartPresentingScopeEpochAdmits: presenting the scope's own epoch +// still admits (regression pin — green before and after this change). +func TestScopedStartPresentingScopeEpochAdmits(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "epoch-e1") + releasedEpochSlot(t, store, req.Worktree, req.RepoDir) + req.RunEpochID = "epoch-e1" + + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + doc, err := d.Start(req) + if err != nil || doc.Outcome != WAITING { + t.Fatalf("presenting the scope's epoch must admit and WAIT: doc=%+v err=%v", doc, err) + } + slot, _, err := store.LoadWorktreeExecution(req.Worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + if slot.RunEpochID != "epoch-e1" { + t.Fatalf("slot epoch = %q, want epoch-e1", slot.RunEpochID) + } +} + +// TestScopedStartForeignEpochRefused: presenting an epoch that differs from the +// scope's pinned one is refused scope-identity-mismatch before anything is +// reserved — the worktree slot is byte-for-byte untouched, the scope's single +// slot stays empty, nothing launched, and the epoch gate was never consulted. +func TestScopedStartForeignEpochRefused(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "epoch-e1") + releasedEpochSlot(t, store, req.Worktree, req.RepoDir) + req.RunEpochID = "epoch-foreign" + before := readSlotBytes(t, store, req.Worktree) + + g := &recordingEpochGate{} + proc := &fakeProc{} + d := scopedTestDriver(store, clk, proc, stableGit()) + d.SetEpochLaunchGate(g.gate()) + + if _, err := d.Start(req); !isOwnershipKind(err, ErrScopeIdentityMismatch) { + t.Fatalf("a foreign presented epoch must refuse scope-identity-mismatch, got %v", err) + } + if string(readSlotBytes(t, store, req.Worktree)) != string(before) { + t.Fatal("a refused start must not touch the worktree slot") + } + scope, err := store.LoadScope(req.ScopeID) + if err != nil { + t.Fatalf("LoadScope: %v", err) + } + if scope.CurrentDriveID != "" || scope.DriveCount != 0 { + t.Fatalf("a refused start must reserve no scope slot, got current=%q count=%d", scope.CurrentDriveID, scope.DriveCount) + } + if proc.launchN != 0 { + t.Fatalf("a refused start must launch nothing, launched %d", proc.launchN) + } + if n := len(g.epochs()); n != 0 { + t.Fatalf("a refused start must not reach the epoch gate, consulted %d times", n) + } +} + +// TestScopedSuccessorStartInheritsScopeEpoch: the worker's SEQUENCE of drives in +// one scope — a successor start presenting no epoch after a PASSED predecessor +// over the still-executing, epoch-e1-owned slot — is admitted (it rotates the +// slot), while a successor presenting a foreign epoch is refused +// scope-identity-mismatch without consuming the predecessor receipt. +func TestScopedSuccessorStartInheritsScopeEpoch(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "epoch-e1") + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + + first, err := d.Start(req) // presents no epoch: inherits epoch-e1 + if err != nil || first.Outcome != WAITING { + t.Fatalf("first start must inherit and WAIT: doc=%+v err=%v", first, err) + } + if err := store.ownerCAS(first.DriveID, func(r *driveRecord) error { + r.LastOutcome = PASSED + return nil + }); err != nil { + t.Fatalf("settle predecessor terminal: %v", err) + } + + succ := req + succ.PredecessorDriveID = first.DriveID + succ.PredecessorOwnerGen = first.Generation + + foreign := succ + foreign.RunEpochID = "epoch-foreign" + if _, err := d.Start(foreign); !isOwnershipKind(err, ErrScopeIdentityMismatch) { + t.Fatalf("a successor presenting a foreign epoch must refuse scope-identity-mismatch, got %v", err) + } + + second, err := d.Start(succ) // presents no epoch: inherits epoch-e1 + if err != nil { + t.Fatalf("a successor presenting no epoch must inherit the scope's, got %v", err) + } + if second.Outcome != WAITING || second.DriveID == first.DriveID { + t.Fatalf("successor must be a NEW waiting drive, got %+v", second) + } + slot, _, err := store.LoadWorktreeExecution(req.Worktree) + if err != nil { + t.Fatalf("LoadWorktreeExecution: %v", err) + } + if slot.RunEpochID != "epoch-e1" { + t.Fatalf("successor slot epoch = %q, want epoch-e1", slot.RunEpochID) + } +} + +// TestEpochlessScopeKeepsPresentedEpoch: a scope with no pinned epoch (a legacy +// v2 scope, or one prepared without) supplies nothing — the presented value +// governs, unchanged from before. Presenting none over an epoch-e1-owned slot is +// still fenced stale-run-epoch; presenting epoch-e1 admits. +func TestEpochlessScopeKeepsPresentedEpoch(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + req := prepareEpochScopedStart(t, store, "") + releasedEpochSlot(t, store, req.Worktree, req.RepoDir) + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + + if _, err := d.Start(req); !isOwnershipKind(err, ErrStaleRunEpoch) { + t.Fatalf("an epoch-less scope must not supply an epoch: want stale-run-epoch, got %v", err) + } + req.RunEpochID = "epoch-e1" + doc, err := d.Start(req) + if err != nil || doc.Outcome != WAITING { + t.Fatalf("presenting the owning epoch under an epoch-less scope must admit: doc=%+v err=%v", doc, err) + } +} + +// TestAdvisoryRunEpochMatchesAdmit pins the read-only resolution the application +// layer's advisory pre-admission check reconciles with (change 0467): a +// credentialed scoped start presenting no epoch (or the scope's) resolves to the +// scope's pinned epoch; a foreign presented epoch, a rejected capability, an +// unknown scope, a scopeless start, and an epoch-less scope all keep the +// presented value. +func TestAdvisoryRunEpochMatchesAdmit(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + d := scopedTestDriver(store, clk, &fakeProc{}, stableGit()) + pinned := prepareEpochScopedStart(t, store, "epoch-e1") + + cases := []struct { + name string + mut func(r StartRequest) StartRequest + want string + }{ + {"none presented inherits", func(r StartRequest) StartRequest { return r }, "epoch-e1"}, + {"same presented", func(r StartRequest) StartRequest { r.RunEpochID = "epoch-e1"; return r }, "epoch-e1"}, + {"foreign presented kept", func(r StartRequest) StartRequest { r.RunEpochID = "epoch-x"; return r }, "epoch-x"}, + {"bad capability", func(r StartRequest) StartRequest { r.ChildCapability = "nope"; return r }, ""}, + {"unknown scope", func(r StartRequest) StartRequest { r.ScopeID = "00000000000000000000000000000000"; return r }, ""}, + {"scopeless", func(r StartRequest) StartRequest { r.ScopeID = ""; r.RunEpochID = "epoch-s"; return r }, "epoch-s"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := d.AdvisoryRunEpoch(tc.mut(pinned)); got != tc.want { + t.Fatalf("AdvisoryRunEpoch = %q, want %q", got, tc.want) + } + }) + } + + store2 := OpenStore(testsupport.TempDir(t)) + d2 := scopedTestDriver(store2, clk, &fakeProc{}, stableGit()) + epochless := prepareEpochScopedStart(t, store2, "") + epochless.RunEpochID = "epoch-p" + if got := d2.AdvisoryRunEpoch(epochless); got != "epoch-p" { + t.Fatalf("an epoch-less scope must keep the presented epoch, got %q", got) + } +} diff --git a/internal/gatedrive/scope.go b/internal/gatedrive/scope.go index e1833b8bc..b82673376 100644 --- a/internal/gatedrive/scope.go +++ b/internal/gatedrive/scope.go @@ -126,9 +126,10 @@ type scopeRecord struct { // RunEpochID links every drive this scope admits to the workflow run epoch // (change 0375 Task 9). It is a locator, not a credential — the child capability - // carries authority — and travels onto each scoped start's worktree execution - // slot so an omitted or stale epoch cannot detach the worktree. Empty for a v2 - // legacy scope and for a scope prepared without an epoch. (schema v3) + // carries authority — and is inherited by each scoped start (the driver's + // scopedRunEpoch) and travels onto its worktree execution slot so an omitted or + // stale epoch cannot detach the worktree. Empty for a v2 legacy scope and for a + // scope prepared without an epoch. (schema v3) RunEpochID string `json:"run_epoch_id,omitempty"` // The single-slot lifecycle (schema v2). At most one current drive occupies diff --git a/internal/harness/dispatch.go b/internal/harness/dispatch.go index b0e2ef060..f3af810df 100644 --- a/internal/harness/dispatch.go +++ b/internal/harness/dispatch.go @@ -98,7 +98,7 @@ func DispatchInterior(runGate []byte) string { // root, feature, and metadata launch routes without a role-name roster. const CodexRootEntryClause = "### Codex root-coordinator entry\n\n" + "For Codex, description markers select the native launch over the general named-child wording. `[docket launch: root-coordinator]` takes precedence: foreground catalog-resolved `agent.enter` at the caller cwd. Otherwise `[docket worktree: feature]` requires foreground catalog-resolved `agent.enter` with the owning workflow's exact `--worktree`; an unmarked metadata child uses direct native named-agent dispatch.\n\n" + - "For any `agent.enter` route: Write a request file containing the user's request unchanged; for implement-next include the unchanged gate dispatch-context token, labeled for `change.claim --gate-context` and gate-drive operations. Preserve resume/continuation ids and gate keys. Pass `--request`, `--role`, the active absolute caller `--cwd`, approval policy, and sandbox; pass the owning workflow's exact `--worktree` explicitly for feature children. Never omit dispatch context.\n\n" + + "For any `agent.enter` route: Write a request file containing the user's request unchanged; for implement-next include the unchanged gate dispatch-context token, labeled for `change.claim --gate-context` and gate-drive operations, and the unchanged run epoch, labeled for `--run-epoch` on prepare-scope and build-owned starts. Preserve resume/continuation ids and gate keys. Pass `--request`, `--role`, the active absolute caller `--cwd`, approval policy, and sandbox; pass the owning workflow's exact `--worktree` explicitly for feature children. Never omit dispatch context.\n\n" + "A shell-tool yield carrying a live task/session identity is a liveness transition, not completion. You must retain that exact task/session identity and collect its terminal exit and final output through the harness-native observation/wait mechanism. Never re-run `agent.enter`, start a second watcher, or return a completion report while the original task remains live or unobserved. Only after terminal output is collected may implement-next run the parent's keyed `run.gate-verdict` and obey its report. Coordinator prose, thread or turn ids, and process exit alone do not prove gate ownership or completion. Do not substitute `codex exec`, another harness, a generic agent, or a parent relay." func CodexDispatchInterior(runGate []byte) string { diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index d7465a1d5..ccaecfb13 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -177,15 +177,15 @@ var skillBudgets = []skillBudget{ {"docket-adr/adr-template.md", 26, 90}, {"docket-auto-groom/SKILL.md", 70, 1627}, // 0382: typed change.groom abstain replaces the plain-git abstain commit prose (word ceiling 1750 -> 1627) {"docket-brainstorm/SKILL.md", 84, 692}, - {"docket-build/SKILL.md", 432, 4391}, // 0459: +continuation carries the claimed verdict, closed-scope statement, and fresh prepare-scope bundle (424/4284 -> 432/4391); 0405: sequential-drive contract; 0420: shell-safe capture; 0421: budgeted repair cycle (see note above) + {"docket-build/SKILL.md", 439, 4479}, // 0467 review fix: +the repair dispatch payload carries the run epoch for the build-owned post-fix re-run (436/4443 -> 439/4479); 0467: +run-epoch threading on prepare-scope and the build-owned start; the bundle carries no epoch (432/4391 -> 436/4443); 0459: +continuation carries the claimed verdict, closed-scope statement, and fresh prepare-scope bundle (424/4284 -> 432/4391); 0405: sequential-drive contract; 0420: shell-safe capture; 0421: budgeted repair cycle (see note above) // 0154: docket-build/references/delegation-execution.md removed — it was the // evidence record for the Bash delegation facade that change 0370 deleted; its // budget row is deleted with it. - {"docket-build/references/gate-caller-loop.md", 175, 1826}, // 0375: +worktree-admission section (word ceiling 1750 -> 1826) + {"docket-build/references/gate-caller-loop.md", 175, 1872}, // 0467: +prepare-scope --run-epoch and scope-inherited start epoch (word ceiling 1826 -> 1872); 0375: +worktree-admission section (word ceiling 1750 -> 1826) {"docket-build/references/gate-execution-evidence.md", 110, 1050}, {"docket-build/references/gate-execution.md", 170, 1520}, {"docket-build/references/task-routing.md", 50, 500}, - {"docket-build-task/SKILL.md", 204, 2151}, // 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) + {"docket-build-task/SKILL.md", 211, 2235}, // 0467 review fix: +the repair re-run epoch exception (206/2183 -> 211/2235); 0467: +the run epoch rides on the scope, never the bundle (204/2151 -> 206/2183); 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) {"docket-convention/SKILL.md", 400, 7969}, // 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above) // 0154: docket-convention/github-board-mirror.md removed — the GitHub mirror is // retired (unsupported, mutation-blocking); its budget row is deleted with it. @@ -197,7 +197,7 @@ var skillBudgets = []skillBudget{ {"docket-finalize-change/SKILL.md", 239, 5647}, // 0455: +record-invalid refusal (structural scope, findings remedy, merged-outside-docket precedence) in step 8 (word ceiling 5520 -> 5647); 0442: +post-publication base-advance guidance (word ceiling 5421 -> 5520); de-duplicated the shared forward-rebase mechanic against the 0438 unpublished-case paragraph (reclaimed 57 words), but the distinct published-refresh facts plus the retained 0438 guidance cannot fit the old ceiling without deleting required guidance; 0411: +reconciliation-write recovery exception paragraph in the resolver loop (ceilings 238/5232 -> 239/5421); 0413: +generated-bundle mixed-conflict handoff sentence in the resolver-loop block (word ceiling 5200 -> 5232); 0419: +repair-attempt budget payload line and rewired repair contract (line ceiling 236 -> 238); 0393: +exact payload, marker, and direct-dispatch lines atop 0349/0410 (see note above) {"docket-finalize-change/references/gate-failure.md", 147, 1901}, // 0411: +reconciliation-write exception section and abort-set carve-out (ceilings 135/1472 -> 147/1901); 0413: +conflicted_paths-lists-authored-only rule in the resolver-report section (line ceiling 133 -> 135, word ceiling 1465 -> 1472); 0419: +repair-attempt budget payload and rewired repair contract prose (word ceiling 1450 -> 1465); 0349: +reserve-before-dispatch resolver protocol prose; 0375: +worktree-slot note for the scopeless finalize gate (120/1300 -> 133/1450) {"docket-groom-next/SKILL.md", 78, 2082}, // 0461: +Step-4 retitle paragraph (title on change.groom; lines 77 -> 78, words 1996 -> 2081); +not-retitleable refusal code (2081 -> 2082); 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) - {"docket-implement-next/SKILL.md", 214, 8080}, // 0455: +pr.publish record-invalid refusal clause (word ceiling 8025 -> 8080); 0448: +named-invocation branch; bounded own-dependency closeout moved to edge-paths.md (ceilings 210/7716 -> 214/8270 -> 214/8025); 0393: +exact payload, marker, and direct-dispatch lines atop 0410/0354/0376; 0375: +gate-epoch resume pointer (word ceiling 7530 -> 7547); 0440: reader-first results prose + {"docket-implement-next/SKILL.md", 214, 8175}, // 0467 review fix: +the repair worker's post-fix re-run is a build-owned start (word ceiling 8165 -> 8175); 0467: +run epoch threaded to prepare-scope and build-owned starts (word ceiling 8080 -> 8165); 0455: +pr.publish record-invalid refusal clause (word ceiling 8025 -> 8080); 0448: +named-invocation branch; bounded own-dependency closeout moved to edge-paths.md (ceilings 210/7716 -> 214/8270 -> 214/8025); 0393: +exact payload, marker, and direct-dispatch lines atop 0410/0354/0376; 0375: +gate-epoch resume pointer (word ceiling 7530 -> 7547); 0440: reader-first results prose {"docket-implement-next/references/edge-paths.md", 118, 1554}, // 0410: +resume/recovery + required-results reconciliation; 0375: +gate-epoch resume refusals (78/1091 -> 93/1261); 0448: +named own-dependency closeout moved from SKILL.md (93/1261 -> 118/1554) {"docket-implement-next/references/fix-loop.md", 190, 1958}, // 0410: +findings-to-results checkpoint linkage (see note above) {"docket-implement-next/results-template.md", 64, 446}, // 0440: reader-first template — action statement + merged Known issues (see note above) @@ -278,7 +278,7 @@ func TestSkillSizeBudgets(t *testing.T) { const ( dispatchStart = "docket:dispatch:start" dispatchEnd = "docket:dispatch:end" - dispatchBudget = 1137 // 0375: step 1 now states the arm prints the run epoch id and where it threads (run.cancel --epoch and every --run-epoch dispatch flag), so the documented human Stop path is followable; this rides atop the earlier 0375 Stop/cancel + resume-after-stop contract (was 1110). Re-baselined at the exact new count; still strictly below the retired roster (the anti-regrowth invariant below). + dispatchBudget = 1153 // 0467 review fix-3: the Codex agent.enter request file also carries the unchanged run epoch for --run-epoch (was 1140); 0467: step 1 copies the into the dispatch prompt alongside the dispatch context (was 1137); 0375: step 1 now states the arm prints the run epoch id and where it threads (run.cancel --epoch and every --run-epoch dispatch flag), so the documented human Stop path is followable; this rides atop the earlier 0375 Stop/cancel + resume-after-stop contract (was 1110). Re-baselined at the exact new count; still strictly below the retired roster (the anti-regrowth invariant below). dispatchOld = 1156 // pre-0334 roster block; the ceiling must stay strictly below it. ) diff --git a/internal/repoguard/gatedrive_run_epoch_thread_test.go b/internal/repoguard/gatedrive_run_epoch_thread_test.go new file mode 100644 index 000000000..c42b7a7b1 --- /dev/null +++ b/internal/repoguard/gatedrive_run_epoch_thread_test.go @@ -0,0 +1,251 @@ +package repoguard + +// Change 0467: the run epoch the gated parent's arm prints must reach every call +// that mints a recovery scope or starts a build-owned drive, while a build-task +// worker's scoped starts never carry it — the driver hands a scoped start the +// epoch its scope pinned. The one worker exception is the integration-repair +// worker's build-owned post-fix full-suite re-run: docket-build hands it the +// epoch in the repair dispatch payload (0467 review fix-1). Prongs over maintained workflow markdown (isWorkflowMD, so the +// embedded mirrors are scanned too): +// (A) every paragraph referencing gate.drive.prepare-scope carries --run-epoch; +// (B) every build-owned gate.drive.start paragraph (--owner build) carries +// --run-epoch; +// (C) no scoped task-owned start paragraph (--owner task) carries --run-epoch — +// the worker passes none; the scope supplies it; +// (D) the repair worker's post-fix re-run is a build-owned start site in both +// docket-build and docket-build-task (floor), so prong B binds it to +// --run-epoch — the exception is pinned, and prong C stays unweakened. +// TestRunGateCopiesEpochIntoDispatchPrompt (below) binds the managed run-gate +// source to copying the epoch into the dispatch prompt. +// Site discovery is keyed on syntactic shape, never a per-file allowlist; the +// shared caller contract (sharedContractRel) is the operation reference, not a +// caller, and is excluded exactly as in TestGateDriveScopedStartIdentity. +// Residual risk, recorded not hidden: an instruction that names the operation +// without its `gate.drive.` prefix (a bare `prepare-scope`), or a build-owned +// start without the --owner build token in the same paragraph, is not a site; +// at run time the driver still fences such an epoch-less start against an +// epoch-owned worktree (stale-run-epoch). + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/harness" +) + +const implementNextSkillRel = "skills/docket-implement-next/SKILL.md" + +var ( + prepareScopeOpRe = regexp.MustCompile(`gate\.drive\.prepare-scope`) + ownerBuildRe = regexp.MustCompile(`--owner build(?:[^a-z-]|$)`) + repairRerunRe = regexp.MustCompile(`(?i)post-fix re-run`) + runEpochFlagRe = regexp.MustCompile(`--run-epoch(?:[^a-z-]|$)`) +) + +// isPrepareScopeSite: a collapsed paragraph that references the +// gate.drive.prepare-scope operation. +func isPrepareScopeSite(p string) bool { return prepareScopeOpRe.MatchString(p) } + +// isBuildOwnerStartSite: a collapsed paragraph that references gate.drive.start +// AND carries the --owner build token. +func isBuildOwnerStartSite(p string) bool { + return startOpRe.MatchString(p) && ownerBuildRe.MatchString(p) +} + +// isRepairRerunSite: a build-owned start paragraph that is about the +// integration-repair worker's post-fix re-run. +func isRepairRerunSite(p string) bool { + return isBuildOwnerStartSite(p) && repairRerunRe.MatchString(p) +} + +// carriesRunEpoch: the paragraph carries the --run-epoch flag token. +func carriesRunEpoch(p string) bool { return runEpochFlagRe.MatchString(p) } + +func TestGateDriveRunEpochThreaded(t *testing.T) { + root := guardRoot(t) + var violations []string + prepSites := map[string]int{} + buildSites := map[string]int{} + taskSites := map[string]int{} + repairSites := map[string]int{} + for _, rel := range maintainedPop(t, root) { + if !isWorkflowMD(rel) || strings.HasSuffix(rel, sharedContractRel) { + continue + } + for _, p := range paragraphs(readMaintained(t, root, rel)) { + if isPrepareScopeSite(p) { + prepSites[rel]++ + if !carriesRunEpoch(p) { + violations = append(violations, fmt.Sprintf( + "%s: gate.drive.prepare-scope instruction lacks --run-epoch: %.160s", rel, p)) + } + } + if isBuildOwnerStartSite(p) { + buildSites[rel]++ + if isRepairRerunSite(p) { + repairSites[rel]++ + } + if !carriesRunEpoch(p) { + violations = append(violations, fmt.Sprintf( + "%s: build-owned gate.drive.start instruction lacks --run-epoch: %.160s", rel, p)) + } + } + if isScopedTaskStartSite(p) { + taskSites[rel]++ + if carriesRunEpoch(p) { + violations = append(violations, fmt.Sprintf( + "%s: scoped task-owned start must not pass --run-epoch (the scope supplies it): %.160s", rel, p)) + } + } + } + } + + // Population floors FIRST (a vacuous scan passes every negative). + mirror := func(rel string) []string { return []string{rel, "internal/assets/embedded/tree/" + rel} } + for _, rel := range append(mirror(buildSkillRel), mirror(implementNextSkillRel)...) { + if prepSites[rel] == 0 { + t.Errorf("coverage floor: %s contributes no gate.drive.prepare-scope site (scan or corpus drifted)", rel) + } + if buildSites[rel] == 0 { + t.Errorf("coverage floor: %s contributes no build-owned gate.drive.start site (scan or corpus drifted)", rel) + } + } + for _, rel := range mirror(buildSkillRel) { + // The per-dispatch scope AND the WAITING-continuation re-prepare. + if prepSites[rel] < 2 { + t.Errorf("coverage floor: %s must carry both the per-dispatch and the continuation prepare-scope sites, found %d", rel, prepSites[rel]) + } + } + for _, rel := range append(mirror(buildSkillRel), mirror(buildTaskSkillRel)...) { + // (D) The repair worker's build-owned re-run is handed the epoch. + if repairSites[rel] == 0 { + t.Errorf("coverage floor: %s carries no build-owned repair re-run start with --run-epoch site (the repair worker has no epoch source)", rel) + } + } + for _, rel := range mirror(buildTaskSkillRel) { + if taskSites[rel] == 0 { + t.Errorf("coverage floor: %s contributes no scoped task-start site (scan or corpus drifted)", rel) + } + } + if len(violations) != 0 { + t.Errorf("run-epoch threading violations (%d):\n%s", len(violations), strings.Join(violations, "\n")) + } + + t.Run("non_vacuity", func(t *testing.T) { + prep := "run the `gate.drive.prepare-scope` operation with `--change-id --worktree --gate-context --run-epoch --json`" + if !isPrepareScopeSite(prep) || !carriesRunEpoch(prep) { + t.Fatalf("a complete prepare-scope invocation was misclassified") + } + if carriesRunEpoch(strings.Replace(prep, "--run-epoch ", "", 1)) { + t.Errorf("stripping --run-epoch from a prepare-scope invocation was not detected") + } + build := "the `gate.drive.start` operation with `--owner build --run-epoch --json`" + if !isBuildOwnerStartSite(build) || !carriesRunEpoch(build) { + t.Fatalf("a complete build-owned start was misclassified") + } + if carriesRunEpoch(strings.Replace(build, "--run-epoch ", "", 1)) { + t.Errorf("stripping --run-epoch from a build-owned start was not detected") + } + if isBuildOwnerStartSite("the `gate.drive.start` operation with `--owner builder --json`") { + t.Errorf("--owner build token boundary failed: 'builder' matched") + } + if isBuildOwnerStartSite("the `gate.drive.start` operation with `--owner task --json`") { + t.Errorf("a task-owned start was classified as build-owned") + } + if carriesRunEpoch("pass `--run-epoch-id `") { + t.Errorf("--run-epoch token boundary failed: '--run-epoch-id' matched") + } + repair := "the repair worker's post-fix re-run is the `gate.drive.start` operation with `--owner build --run-epoch --json`" + if !isRepairRerunSite(repair) || !carriesRunEpoch(repair) { + t.Fatalf("a complete repair re-run start was misclassified") + } + if carriesRunEpoch(strings.Replace(repair, "--run-epoch ", "", 1)) { + t.Errorf("stripping --run-epoch from the repair re-run start was not detected") + } + if isRepairRerunSite("the final suite gate is the `gate.drive.start` operation with `--owner build --json`, no failure to repair") { + t.Errorf("a non-repair build-owned start that merely mentions repair was classified as the repair re-run") + } + if isRepairRerunSite("the post-fix re-run: the `gate.drive.start` operation with `--owner task --json`") { + t.Errorf("a task-owned start was classified as the build-owned repair re-run") + } + task := "the `gate.drive.start` operation with `--owner task --scope-id --child-cap --run-epoch --json`" + if !isScopedTaskStartSite(task) || !carriesRunEpoch(task) { + t.Errorf("a worker start that passes --run-epoch must be classified and flagged") + } + wrapped := "run `gate.drive.prepare-scope` again\nfor the same change (and `--run-epoch\n`)" + if got := paragraphs(wrapped); len(got) != 1 || !isPrepareScopeSite(got[0]) || !carriesRunEpoch(got[0]) { + t.Errorf("whitespace collapse failed: a wrapped prepare-scope site did not match as one paragraph") + } + }) +} + +// runGateEpochCopyRe binds the copy instruction to the epoch AND to its +// destination with one bounded, sentence-local gap: a rewrite that keeps the +// word elsewhere but drops "copy it into the dispatch prompt" reddens. +var runGateEpochCopyRe = regexp.MustCompile("copy the [^.]{0,60}`` into the dispatch prompt") + +// TestRunGateCopiesEpochIntoDispatchPrompt: the managed run-gate source, its +// embedded mirror, and the committed AGENTS.md rendering all tell the parent to +// copy the arm's into the implement-next dispatch prompt (change 0467) — +// otherwise the epoch never reaches the build chain. +func TestRunGateCopiesEpochIntoDispatchPrompt(t *testing.T) { + root := guardRoot(t) + for _, rel := range []string{ + "cursor-rules/run-gate.md", + "internal/assets/embedded/tree/cursor-rules/run-gate.md", + "AGENTS.md", + } { + if !runGateEpochCopyRe.MatchString(collapseWS(readMaintained(t, root, rel))) { + t.Errorf("%s: run-gate step 1 does not copy the `` into the dispatch prompt", rel) + } + } + + t.Run("non_vacuity", func(t *testing.T) { + good := "keep all three and copy the `` and the ``\n into the dispatch prompt." + if !runGateEpochCopyRe.MatchString(collapseWS(good)) { + t.Fatalf("the intended (wrapped) wording did not match") + } + old := "copy the `` into the dispatch prompt. The `` is the run epoch id" + if runGateEpochCopyRe.MatchString(collapseWS(old)) { + t.Errorf("the pre-0467 wording (epoch not copied) matched") + } + if runGateEpochCopyRe.MatchString("copy the ``. Later, `` into the dispatch prompt") { + t.Errorf("bounded gap failed: the binding must not span sentences") + } + }) +} + +// codexRequestEpochRe binds the Codex agent.enter request-file sentence to the +// run epoch and its --run-epoch destination, sentence-local (a dot inside a token like change.claim is not a sentence end): agent.enter's own +// --run-epoch is lifecycle registration only and is never forwarded, so the +// request file is the epoch's only path to implement-next on that route (0467 +// review fix-3). +var codexRequestEpochRe = regexp.MustCompile("Write a request file (?:[^.]|\\.\\S){0,400}run epoch(?:[^.]|\\.\\S){0,80}`--run-epoch`") + +// TestCodexRequestFileCarriesRunEpoch: the generator source and the committed +// AGENTS.md rendering both tell the Codex agent.enter route to carry the run +// epoch in the request file. +func TestCodexRequestFileCarriesRunEpoch(t *testing.T) { + root := guardRoot(t) + for name, text := range map[string]string{ + "harness.CodexRootEntryClause": harness.CodexRootEntryClause, + "AGENTS.md": readMaintained(t, root, "AGENTS.md"), + } { + if !codexRequestEpochRe.MatchString(collapseWS(text)) { + t.Errorf("%s: the Codex agent.enter request file does not carry the run epoch for `--run-epoch`", name) + } + } + + t.Run("non_vacuity", func(t *testing.T) { + good := "Write a request file containing the request unchanged; for implement-next include the unchanged gate dispatch-context token, labeled for `change.claim --gate-context` and gate-drive operations, and the unchanged run epoch, labeled for `--run-epoch`." + if !codexRequestEpochRe.MatchString(good) { + t.Fatalf("the intended wording did not match") + } + old := "Write a request file containing the request unchanged; for implement-next include the unchanged gate dispatch-context token, labeled for `change.claim --gate-context` and gate-drive operations. Preserve ids. Pass the run epoch to `--run-epoch`." + if codexRequestEpochRe.MatchString(old) { + t.Errorf("the pre-fix wording (epoch outside the request-file sentence) matched") + } + }) +} diff --git a/skills/docket-build-task/SKILL.md b/skills/docket-build-task/SKILL.md index 460d5b7a2..ebb9ffa2a 100644 --- a/skills/docket-build-task/SKILL.md +++ b/skills/docket-build-task/SKILL.md @@ -66,7 +66,9 @@ worktree, and use the task-intent owner: the `gate.drive.start` operation with ` --json -- `. Every identity value comes in your dispatch prompt — pass the bundle through unchanged, omitting `--gate-context` only when no dispatch context was handed to you; the prepared scope pinned exactly this identity, and the driver -rejects a start that omits or alters any of it. The run root is a scratch dir you pick and read from. +rejects a start that omits or alters any of it. The run epoch is not in the bundle: it rides on the +prepared scope, so a task-owned start passes none — a start that invents a different epoch is +refused `scope-identity-mismatch`. The run root is a scratch dir you pick and read from. Capture the drive id and owner generation from that `--json` response before any advance or handoff (the shared JSON-capture requirement in `docket-build`'s `references/gate-caller-loop.md`; human text omits the generation). Capture the response into `gate_reply` and its exit code into `gate_rc` — the shared @@ -87,6 +89,11 @@ with the typed cause; `WAITING` → **immediately** perform the `gate.drive.hand and return `WAITING` naming the drive id and that token. After a first `WAITING` never `advance` or restart — the controller owns the drive. `WAITING` consumes neither repair nor escalation budget. +**The one epoch exception:** an integration-repair task's post-fix re-run of the full suite is +build-owned — run the `gate.drive.start` operation with `--owner build --run-epoch --json`, +passing the run epoch your repair dispatch payload carried (omitted when it carried none). Only that +start takes an epoch; every scoped task-owned start still passes none. + **A `worktree-busy` refusal is a blocking diagnostic, never a retry trigger.** One canonical worktree carries at most one running gate at a time. If `gate.drive.start` comes back refused with reason `worktree-busy` (or `unresolved-execution`), another gate is already live — or was left diff --git a/skills/docket-build/SKILL.md b/skills/docket-build/SKILL.md index 48a811811..5f63c9fde 100644 --- a/skills/docket-build/SKILL.md +++ b/skills/docket-build/SKILL.md @@ -75,8 +75,9 @@ Emit one concise routing line per task naming both the profile and its reason. **Before each worker dispatch, prepare its recovery scope:** run the `gate.drive.prepare-scope` operation with `--change-id --task-id --phase build --branch --worktree - --gate-context --json` (the dispatch context arrived in *your* prompt from -the gated parent — pass its value through). Capture the scope id and **both** capabilities from the + --gate-context --run-epoch --json` (the dispatch context and +the run epoch arrived in *your* prompt from the gated parent — pass each value through, omitting a +flag only when your prompt carried no such value). Capture the scope id and **both** capabilities from the `--json` response before dispatching (the shared JSON-capture requirement); the parent capability stays in your notes. Then dispatch the selected profile agent **by name** — one of `docket-build-economy`, `docket-build-standard`, `docket-build-premium`, or `docket-build-max` — @@ -87,7 +88,8 @@ It also gives the worker the plan task text, applicable repository instructions, profile and routing reason, the completion schema, and one **complete start-ready scope bundle**: the change id, task id, phase (`build`), branch, scope id, child capability, and the dispatch context when your prompt carried one — each value exactly as `prepare-scope` pinned it, for the -worker to pass through to `gate.drive.start` unchanged. One scope now carries the worker's whole +worker to pass through to `gate.drive.start` unchanged. The bundle carries no run epoch: the scope +pinned it, and every scoped start inherits it. One scope now carries the worker's whole *sequence* of task-owned drives — baseline, RED, GREEN, verification — one at a time, and the worker closes it with a terminal `gate.drive.acknowledge` on normal completion; your WAITING-handoff and takeover handling below is unchanged. Of the two capabilities the worker @@ -145,7 +147,8 @@ transcript. The continuation — a same-agent resume or a fresh dispatch alike the claimed drive's id, its terminal verdict, and an explicit statement that the original scope is closed by your claim and must never be acknowledged or reused. When the continued task may still need test drives, run `gate.drive.prepare-scope` again -for the same change, task, phase, branch, and worktree (and dispatch context) and include the new +for the same change, task, phase, branch, and worktree (and dispatch context and +`--run-epoch `, as for the first scope) and include the new start-ready scope bundle — child capability only; the parent capability stays in your notes, as for any dispatch. Reading the continuation's return is unchanged: a `COMPLETE` is settled against git state exactly as *Reading a worker's return* requires. Waiting consumes neither the task's repair @@ -246,7 +249,8 @@ authoritative config the build role reads, never a command it invents: **skipped** evidence via the `evidence.record` operation (no run dir) — `result: skipped` / `reason: build-gate-off` at the current head — and proceed to review. Nothing to run or repair. 2. **`build_gate: local`, non-empty `build_test_command`** — drive it through the native gate - **driver**: the `gate.drive.start` operation with `--owner build --json` — capture that first response into `gate_reply` (its exit + **driver**: the `gate.drive.start` operation with `--owner build --run-epoch --json` + (`--run-epoch` only when your prompt carried a run epoch) — capture that first response into `gate_reply` (its exit code, if needed, into `gate_rc`; never a zsh read-only special parameter such as `status`) and read the drive id and owner generation from it — then `gate.drive.advance` operation slices, exactly as *Gate execution posture* describes. `--owner build` resolves the build-owned command @@ -296,7 +300,10 @@ admits another attempt, each red full-suite result becomes exactly one synthetic task, run through the same worker contract on the ladder `premium -> max -> halt`. The repair worker diagnoses the cross-task failure, adds regression coverage where appropriate, fixes it, and re-runs the full suite; that post-fix re-run **is** the next budgeted attempt — started build-owned through -the same driver so the facade charges it, no bypass. That ladder starts one rung above the default +the same driver so the facade charges it, no bypass. Its dispatch payload therefore also carries the +run epoch from your prompt, outside the scope bundle, for that one start: the `gate.drive.start` +operation with `--owner build --run-epoch --json` (flag omitted when your prompt carried none). +That ladder starts one rung above the default deliberately: repair is cross-task diagnosis, never routine work. **Green at any point ends the phase immediately; review is never invoked while red.** A refused start (`suite-attempts-exhausted`) or a red final permitted run halts per *Halting conditions* with the exhaustion reason naming diff --git a/skills/docket-build/references/gate-caller-loop.md b/skills/docket-build/references/gate-caller-loop.md index 2ddd9dfa2..c15d656bb 100644 --- a/skills/docket-build/references/gate-caller-loop.md +++ b/skills/docket-build/references/gate-caller-loop.md @@ -23,11 +23,11 @@ copy): | Operation | What it does | |---|---| -| `start` | Fingerprint the execution context, launch the first raw run through the supervisor, advance one slice, and return the drive id, owner generation, and disposition. A scope-bound start passes the complete identity the scope pinned — `--repo-dir --change-id --task-id --phase --branch --scope-id --child-cap `, plus `--gate-context ` when the dispatch carried one — and the driver rejects a start whose identity does not match the prepared scope. A **successor** start in the same scope additionally presents `--predecessor-drive-id --predecessor-owner-gen ` — the previous drive's captured receipt, both together — acknowledging exactly that durable `PASSED`/`FAILED` predecessor and reusing the scope's single slot; a scope's first start omits the pair, and a `WAITING`/`HALTED` or pending predecessor is refused. | +| `start` | Fingerprint the execution context, launch the first raw run through the supervisor, advance one slice, and return the drive id, owner generation, and disposition. A scope-bound start passes the complete identity the scope pinned — `--repo-dir --change-id --task-id --phase --branch --scope-id --child-cap `, plus `--gate-context ` when the dispatch carried one — and the driver rejects a start whose identity does not match the prepared scope. A scope-bound start takes its run epoch from the scope: it passes none, and a presented `--run-epoch` that differs from the pinned one is refused `scope-identity-mismatch`. A **successor** start in the same scope additionally presents `--predecessor-drive-id --predecessor-owner-gen ` — the previous drive's captured receipt, both together — acknowledging exactly that durable `PASSED`/`FAILED` predecessor and reusing the scope's single slot; a scope's first start omits the pair, and a `WAITING`/`HALTED` or pending predecessor is refused. | | `advance` | Resume the current attempt of a drive (by opaque drive id + owner generation) through one more slice. | | `handoff` | Prove current ownership, revalidate repository + process identity, invalidate the current owner, and mint a **single-use** handoff token — the only way a departing owner transfers a live drive. | | `claim` | Recompute identity, consume a handoff token with a compare-and-swap, and return a **fresh** owner generation the claimant advances with. | -| `prepare-scope` | `--change-id --task-id --phase --branch --worktree [--gate-context ]`: mint a recovery scope for one parent/child dispatch boundary with **separated** parent and child capabilities. The preparing parent keeps the parent capability; the child receives only the scope id and child capability. Effects: local-write. | +| `prepare-scope` | `--change-id --task-id --phase --branch --worktree [--gate-context ] [--run-epoch ]`: mint a recovery scope for one parent/child dispatch boundary with **separated** parent and child capabilities. The preparing parent keeps the parent capability; the child receives only the scope id and child capability. A run epoch given here is pinned on the scope and inherited by every scoped start under it. Effects: local-write. | | `takeover` | `--scope-id --parent-cap [--drive-id ]`: the event-authorized exceptional transfer — prove the parent capability and scope identity, atomically supersede the child's owner generation, and return a fresh generation. Effects: local-write. | | `acknowledge` | `--scope-id --child-cap --drive-id --owner-gen `: consume the scope's final durable `PASSED`/`FAILED` result and close the task scope — the last drive's "successor". Idempotent on an exact repeat; refuses a live, `HALTED`, unrelated, or superseded drive. Effects: local-write. | diff --git a/skills/docket-implement-next/SKILL.md b/skills/docket-implement-next/SKILL.md index c33b3f2d4..303895780 100644 --- a/skills/docket-implement-next/SKILL.md +++ b/skills/docket-implement-next/SKILL.md @@ -94,7 +94,7 @@ It also supplies the change id, title, synchronized change-file (the plan backli ### Step 6 — Review + ADRs -**Validate the build evidence (change 0170).** Read the build-evidence record step 5's gate emitted — it must be present, its `head_sha` equal to the branch HEAD, and its `result` either `green` or `skipped` (the repo set `build_gate: off`, carrying `reason: build-gate-off`, no command). Missing, malformed, or stale is a build-contract violation — never review an uncertified branch. Re-mint once yourself: under `build_gate: off`, the `evidence.record` operation with `--id --head ` and no run dir mints the skipped record; under a local gate, **drive** the native gate — never author a launch/observe/sleep loop of your own (change 0342): the `gate.drive.start` operation with `--repo-dir --run-root --owner build --json` (`--owner build` resolves the build-owned command; no suite argv) — capture that first JSON response into `gate_reply` and the drive id and owner generation from it, its exit code (if needed) into `gate_rc`, never a zsh read-only special parameter such as `status` (the shared JSON-capture requirement; human text omits the generation) — then the `gate.drive.advance` operation with `--drive-id --owner-gen ` — one slice per call — under docket-build's bounded gate-execution posture until a terminal disposition. The typed disposition vocabulary lives in `docket-build`'s `references/gate-caller-loop.md`; only a **`PASSED`** disposition at the current head exposes the raw run dir that feeds the recording below — `FAILED`/`HALTED`/`WAITING` never do. +**Validate the build evidence (change 0170).** Read the build-evidence record step 5's gate emitted — it must be present, its `head_sha` equal to the branch HEAD, and its `result` either `green` or `skipped` (the repo set `build_gate: off`, carrying `reason: build-gate-off`, no command). Missing, malformed, or stale is a build-contract violation — never review an uncertified branch. Re-mint once yourself: under `build_gate: off`, the `evidence.record` operation with `--id --head ` and no run dir mints the skipped record; under a local gate, **drive** the native gate — never author a launch/observe/sleep loop of your own (change 0342): the `gate.drive.start` operation with `--repo-dir --run-root --owner build --run-epoch --json` (`--owner build` resolves the build-owned command; no suite argv; `--run-epoch` carries the run epoch from your dispatch prompt, omitted only when the prompt carried none) — capture that first JSON response into `gate_reply` and the drive id and owner generation from it, its exit code (if needed) into `gate_rc`, never a zsh read-only special parameter such as `status` (the shared JSON-capture requirement; human text omits the generation) — then the `gate.drive.advance` operation with `--drive-id --owner-gen ` — one slice per call — under docket-build's bounded gate-execution posture until a terminal disposition. The typed disposition vocabulary lives in `docket-build`'s `references/gate-caller-loop.md`; only a **`PASSED`** disposition at the current head exposes the raw run dir that feeds the recording below — `FAILED`/`HALTED`/`WAITING` never do. **Create the durable evidence.** Under a local gate, only from a **`PASSED`** drive disposition whose head equals the current feature head: the `evidence.record` operation with `--id --run --head ` reads the observed gate command and outcome from the run directory that disposition exposed — **no agent-supplied `passed` boolean** — and returns the immutable typed record; a `FAILED`/`HALTED`/`WAITING` or head-mismatched drive produces none. Then the `evidence.verify` operation with `--record --head ` re-checks its bytes against the head. Any review fix below changes HEAD and **invalidates this evidence** — repeat the record → verify chain before publishing. @@ -143,7 +143,7 @@ Local commit, remote publication, and metadata attachment are **distinct facts** **Mark implemented.** The `change.mark-implemented` operation with `--id --version --head --pr --evidence ` is the final mutation in this scope. Before its transaction it reprobes Git and GitHub and proves: the change is still the exact `in-progress` version, `reconciled: true`, linked to the verified plan; local and remote feature heads equal the supplied head; valid evidence names that head and a passed gate; exactly one verified PR for the feature branch targets the resolved effective-base branch and names that head; and a results artifact is attached and satisfies the final content contract and its artifact identity (missing, invalid, or unattached results — trivial changes included — refuse the transition). It then applies the landed transition atomically — `status: implemented` + `pr:` + updated date + `## Artifacts` block + inline board + audit receipt (letting the sweep read `pr:`), rendering the board inside that transaction, so no separate Board pass runs. It does NOT clear the claim, delete the branch/workspace, merge, archive, or close descendants (0316). A retry against an already-`implemented` change with matching PR/head returns the prior applied outcome, never a duplicate transition. -**Verify the run.** The `run.verify` operation with `--id ` is read-only: it reports one closed verdict — `run-complete` / `run-unclaimed` / `run-incomplete` / `run-halted` / `run-waiting` — and enumerates every unmet conjunct with its stable reason and observed identity. Automation keys on the typed verdict, **never** the exit code (all verdicts exit 0). `run-complete` is the receipt that Step 7's postcondition holds; `run-halted` is the closed verdict for a change carrying a `## Run halted` marker — a human is needed, and an attributed caller re-dispatches it through the resume path (the `change.resume-halted` operation with `--acknowledge-quiescent`, Step 2), never a fresh claim. `run-waiting ` (change 0342) is the closed verdict for a change whose gate stopped at a safe, resumable continuation — neither complete nor failed: an attributed caller resumes that exact handoff rather than drawing another change or re-dispatching a fresh run, and precedence is completed-run postconditions first, then a valid `run-waiting`, then ordinary `run-incomplete`; a persisted `run-halt` stays terminal. A run **dispatched with an explicit change id, continuation id, and gate key resumes, not restarts**: its FIRST act is the `run.gate-claim` operation with ` `, claiming the recovered drive **before any other work**; it never launches a replacement test, and this is a **continuation, not `gate-retry-once`** — the key stays active until a true terminal disposition. Each `gate-retry-once` authorizes exactly one next dispatch; the facade may grant another on a later eligible attempt while the configured `run.max_attempts` budget allows, and a continuation consumes none of that budget. A gated parent's prompt may also carry a **dispatch context** token; pass it into every `gate.drive.prepare-scope` / `gate.drive.start --gate-context` this run performs — each invoked with `--json` per the shared capture requirement — and into the Step-2 claim's --gate-context. +**Verify the run.** The `run.verify` operation with `--id ` is read-only: it reports one closed verdict — `run-complete` / `run-unclaimed` / `run-incomplete` / `run-halted` / `run-waiting` — and enumerates every unmet conjunct with its stable reason and observed identity. Automation keys on the typed verdict, **never** the exit code (all verdicts exit 0). `run-complete` is the receipt that Step 7's postcondition holds; `run-halted` is the closed verdict for a change carrying a `## Run halted` marker — a human is needed, and an attributed caller re-dispatches it through the resume path (the `change.resume-halted` operation with `--acknowledge-quiescent`, Step 2), never a fresh claim. `run-waiting ` (change 0342) is the closed verdict for a change whose gate stopped at a safe, resumable continuation — neither complete nor failed: an attributed caller resumes that exact handoff rather than drawing another change or re-dispatching a fresh run, and precedence is completed-run postconditions first, then a valid `run-waiting`, then ordinary `run-incomplete`; a persisted `run-halt` stays terminal. A run **dispatched with an explicit change id, continuation id, and gate key resumes, not restarts**: its FIRST act is the `run.gate-claim` operation with ` `, claiming the recovered drive **before any other work**; it never launches a replacement test, and this is a **continuation, not `gate-retry-once`** — the key stays active until a true terminal disposition. Each `gate-retry-once` authorizes exactly one next dispatch; the facade may grant another on a later eligible attempt while the configured `run.max_attempts` budget allows, and a continuation consumes none of that budget. A gated parent's prompt may also carry a **dispatch context** token; pass it into every `gate.drive.prepare-scope` / `gate.drive.start --gate-context` this run performs — each invoked with `--json` per the shared capture requirement — and into the Step-2 claim's --gate-context. It may also carry the **run epoch** id: pass it as `--run-epoch ` into every `gate.drive.prepare-scope` and every build-owned `gate.drive.start` (`--owner build` — Step 6's evidence re-mint, the build role's final suite gate, and its repair worker's post-fix re-run) this run performs, omitting the flag only when the prompt carried none (a `gate-unarmed` or ungated run). Scoped task-owned starts inherit the epoch from their scope, so a build-task worker is handed it only for that repair re-run. **STOP.** The change stays in `active/` as `implemented` until a human merges it, or approves `docket-finalize-change` to merge it.