diff --git a/.abcd/config/reading-presets.json b/.abcd/config/reading-presets.json index ceec92d0e..321d07658 100644 --- a/.abcd/config/reading-presets.json +++ b/.abcd/config/reading-presets.json @@ -60,10 +60,10 @@ "test" ], "window": { - "tokens_est": 1490000, - "measured_tokens_est": 1473012, - "measured_bytes": 5671097, - "measured_at": "accf61856ef2b893208e298137a690a33749004e" + "tokens_est": 1510000, + "measured_tokens_est": 1489409, + "measured_bytes": 5734225, + "measured_at": "c23ec03b994c352a1d8e5a37efbf30124bae5703" } }, "entailment": { @@ -132,10 +132,10 @@ "intent-projection" ], "window": { - "tokens_est": 430000, - "measured_tokens_est": 424468, - "measured_bytes": 1634205, - "measured_at": "accf61856ef2b893208e298137a690a33749004e" + "tokens_est": 440000, + "measured_tokens_est": 427804, + "measured_bytes": 1647049, + "measured_at": "c23ec03b994c352a1d8e5a37efbf30124bae5703" } }, "comparative": { @@ -216,10 +216,10 @@ "test" ], "window": { - "tokens_est": 1500000, - "measured_tokens_est": 1482048, - "measured_bytes": 5705885, - "measured_at": "accf61856ef2b893208e298137a690a33749004e" + "tokens_est": 1520000, + "measured_tokens_est": 1498444, + "measured_bytes": 5769013, + "measured_at": "c23ec03b994c352a1d8e5a37efbf30124bae5703" } } } diff --git a/.abcd/development/brief/04-surfaces/05-intent.md b/.abcd/development/brief/04-surfaces/05-intent.md index 0956593af..a6de998c0 100644 --- a/.abcd/development/brief/04-surfaces/05-intent.md +++ b/.abcd/development/brief/04-surfaces/05-intent.md @@ -312,11 +312,11 @@ Later phase — intent-auditor (shape-classification role) scans the corpus | Plan (one intent id) | Plans a draft: mints its native spec, injects the bidirectional link (intent `spec_id` ↔ spec `intent`), stamps an identity onto every unmarked scope condition, and moves the file `drafts/` → `planned/`. An impact given at planning stamps the INTENT's product-impact judgement, because the planning interview is where that judgement is made: validated at the create path's bar (never `internal`), written as the bare scalar the create path writes, refused before anything moves when it disagrees with a judgement the record already carries, and a no-op when it agrees; without one the field is left as found and the judgement stays owed to the close (iss-2609170726457256). A production mode given at planning stamps the MINTED SPEC's disclosure pair; the intent's own stamp was written at create time and is never rewritten. On an intent already in `planned/` it does the identity step alone (no spec, no move), takes an impact under the same rules, and refuses when nothing is unmarked and no judgement is added — except where the planned record's `spec_id` is null, when it mints (or reuses the spec already naming the intent) and links the spec in place on the draft's Acceptance Criteria bar, still with no move, and the readiness gate's remedy for the missing spec names this call (iss-2609211738504433). | `drafts/` → `planned/` (stamp step: no move) | | Plan a bundle (several intent ids, a bundle name) | **The bundle command** (itd-34): plans two or more drafts as ONE shared spec. The name is the human's (the plugin page asks for it; the CLI refuses several ids without one, and a name with one id), kebab-case, and carried by no other record. It refuses a member naming another in `blocked_by`, naming the edge, and any member that is not a plannable draft — held, without criteria, already specced or naming another bundle — before the mint. It mints one spec whose frontmatter lists every member (`intents:` beside `intent:`, and `bundle:`), stamps `kind: bundle-member`, `bundle: `, the scope-condition identities and an impact given at planning onto each member, links each `spec_id` to the shared spec, and moves all of them together; a failure after the mint puts every member back and takes the spec back. The shared spec's close ships every member together. | every member `drafts/` → `planned/` | | Readiness gate (one intent id, optionally with grounds) | **Implement-readiness gate**: reports whether an intent is ready to implement — eight checks, four of which gate: in `planned/`, with acceptance criteria, a bidirectional spec link, and a written spec body. The two claim rows (mechanism prompted-and-nullable, scope conditions with each condition identified) and the grounds row (a discipline record is exempt: it carries no conjecture of its own) are reported as advisory and never withhold readiness, their refusals parked by iss-2609091009111294 until the rethink of the reading work. The steps row is advisory by design: it reports the linked spec's `## Steps` shape — the steps listed and how many have landed, or none and so one step — and names a section that is not a numbered list with the shape it expects (itd-2609212103565953). Exit 0 ready / 1 not ready / 2 fault. Recording grounds, in the form `: `, is the gate's one write: it appends the conjecture behind this decision — what is expected, and what would show it wrong — to the intent's `## Grounds` section, append-only ([adr-57](../../decisions/adrs/0057-grounds-accumulate-as-an-append-only-section.md)), and then reports; a shipped or superseded record is never backfilled. | (no move; recorded grounds append to `## Grounds`) | -| Audit (one intent id) | **Role 1 — single-document fidelity.** Takes a **shipped** intent and nothing else: a record still in `drafts/`, `planned/`, `disciplines/` or `superseded/` is refused by name, because only a shipped intent has a delivered reality to be judged against. Compares the intent's press release + acceptance criteria against delivered reality (code, configs, docs, tests). Per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) appended to the intent's `## Audit Notes`. The request it writes for the host states the criteria count, lists every scope condition under the identity the verdict disposes it by, and carries the verdict shape rendered from the structure the ingest decodes, so the request alone is enough to write a verdict against. Its result names the receipt's state and, separately, whether it wrote the request: a re-emit of an owed receipt rewrites it, and a re-emit of an ingested or dead-lettered one writes none and names no request path. Aligns with the spec store's `plan-review` / `impl-review` / `completion-review` vocabulary — same operation shape (adversarial second opinion), different opponent (press release vs engineering spec). spc-12 (predecessor store) ships this **manual** verb; spc-28 (predecessor store) ships the on-close hook (move `planned → shipped` + queue a review), which only queues: the queue is paid by the drain row below, on demand, and nothing runs the reviewer on its own (spc-6 (predecessor store) disowned auto-firing). | (stays) | -| Owed reviews (the audit sub-verb with no argument) | **The fidelity-review debt, listed** (itd-2609150819445595). Every close that ships an intent parks an OWED marker, so a review is owed by construction; this reads the first review marker of every intent in `shipped/` — the marker the re-emit also reuses — and lists the debt. The owed set is OWED plus no marker at all (shipped before markers, or a ship whose receipt failed to mint), each named with its receipt, or with none and the note that the re-emit mints one, and the re-emit command. A dead-lettered review is listed under its own heading as unreviewed, with the reason its quarantine block recorded, and is not counted; an ingested review is not listed. The machine-readable form carries one entry per shipped intent — id, state, receipt, and the re-emit where the review is owed — and never a path into the local tier: it names the re-emit, not the request file, which is gitignored and may have been swept. Writes nothing, exits 0, and no gate reads it: the close mints the debt in the same change, so a refusal on it would block by construction. The same reader supplies the owed count on the bare status board and the record dispatcher's next move for a shipped intent. | (no move; read-only) | +| Audit (one intent id) | **Role 1 — single-document fidelity.** Takes a **shipped** intent and nothing else: a record still in `drafts/`, `planned/`, `disciplines/` or `superseded/` is refused by name, because only a shipped intent has a delivered reality to be judged against. Compares the intent's press release + acceptance criteria against delivered reality (code, configs, docs, tests). Per-criterion verdicts (`MET` / `MET_WITH_CONCERNS` / `NOT_MET` / `INCONCLUSIVE`) appended to the intent's `## Audit Notes`. The request it writes for the host states the criteria count, lists every scope condition under the identity the verdict disposes it by, and carries the verdict shape rendered from the structure the ingest decodes, so the request alone is enough to write a verdict against. Its result names the receipt's state and, separately, whether it wrote the request: a re-emit of an owed receipt rewrites it, a re-emit of a receipt whose ingested verdict left a check owed rewrites it for the re-run (`check_owed`), and a re-emit of any other ingested or dead-lettered one writes none and names no request path. Aligns with the spec store's `plan-review` / `impl-review` / `completion-review` vocabulary — same operation shape (adversarial second opinion), different opponent (press release vs engineering spec). spc-12 (predecessor store) ships this **manual** verb; spc-28 (predecessor store) ships the on-close hook (move `planned → shipped` + queue a review), which only queues: the queue is paid by the drain row below, on demand, and nothing runs the reviewer on its own (spc-6 (predecessor store) disowned auto-firing). | (stays) | +| Owed reviews (the audit sub-verb with no argument) | **The fidelity-review debt, listed** (itd-2609150819445595). Every close that ships an intent parks an OWED marker, so a review is owed by construction; this reads the first review marker of every intent in `shipped/` — the marker the re-emit also reuses — and lists the debt. The owed set is OWED plus no marker at all (shipped before markers, or a ship whose receipt failed to mint), each named with its receipt, or with none and the note that the re-emit mints one, and the re-emit command. A dead-lettered review is listed under its own heading as unreviewed, with the reason its quarantine block recorded, and is not counted. An ingested review whose verdict left a check owed (ruling DQ1c) is listed under its own heading as reviewed and flagged, with the issue carrying the check and the re-emit that rewrites its request for the re-run, counted apart and not as owed; any other ingested review is not listed. The machine-readable form carries one entry per shipped intent — id, state, receipt, whether a check is owed and its issue, and the re-emit where the review or a re-run is owed — and never a path into the local tier: it names the re-emit, not the request file, which is gitignored and may have been swept. Writes nothing, exits 0, and no gate reads it: the close mints the debt in the same change, so a refusal on it would block by construction. The same reader supplies the owed count on the bare status board and the record dispatcher's next move for a shipped intent. | (no move; read-only) | | Drain (the audit sub-verb's owed form, optionally capped) | **The bounded command that pays the review debt** (itd-53). The owed set is the owed listing's, from the same reader; the ordering and the cap are the intent store's: oldest shipped first — the day the intent entered `shipped/`, read from the site's one history walk, with an intent not yet committed last and ties in the order the ids were minted; a history that cannot be read leaves every day unknown, reported as unknown rather than as not yet committed, and the queue in mint order — at most the cap's count of entries (zero or absent: no cap; negative: refused, naming the value), and the summary names how many remain beyond the cap. It emits the oldest entry's request through the single audit's own emit, minting the receipt if there was none, with the single audit's routing: the route is resolved before anything is written, a route override for the auditor applies as it does to one audit, and the request carries its routing section and the result its routing member. An entry whose request cannot be emitted (a malformed spec id, an unreadable file, a local tier that cannot be written) is listed with its error, the home in any path it names shown as `~` (as the single audit's refusal shows it), and the next entry is emitted instead, so one bad record never blocks the drain; the request is written before the intent file, so a failed emit parks no OWED stub and the entry keeps the receipt state it had. It prints the ordered list and that request's path, so a host without the plugin page drives the drain by hand: audit, ingest, run again. It runs no reviewer: the plugin page runs the loop one audit at a time through the request/ingest pair; with no auditor available every entry stays owed and the summary says why nothing ran; a verdict lands exactly as a single audit's does, and a NOT_MET on an intent the drain reaches — every one of them already shipped — is captured through `capture` naming the receipt, never fixed. A cap without the owed form, and the owed form with an intent id or with the drift check, are refused. Nothing starts the drain on its own: no hook, gate or schedule, and the close hook still only enqueues. | (no move; writes the head's OWED stub and request, as the one-intent audit does) | | Issue drift (the whole corpus, optionally strict) | **The promote join's drift check** (itd-4 AC3, in the predecessor store's spc-23 shape): walks the intent store and the issue ledger, readings included, and reports every join that does not read the same from both ends — an intent naming a record in `related_issues` that does not name it back in `related_intents` (from an issue's end a one-way `related_intents` is a loose relation and stays silent; a reading item carries none, so from its end it is reported), either end naming a record the tree does not hold, a shipped intent naming an issue that is not in `resolved/`, and a record still carrying a retired back-link key. Each finding is a warning on stderr and the run exits 0; the strict form exits 1 on any finding, for a CI gate. It names the checkout and branch whose ledger it read, as every capture verb does. Findings land in `.abcd/.work.local/logs/audit/issue-drift-/report.json`. | (no move; writes only its receipt) | -| Audit ingest (a verdict JSON path) | Ingests a host-delegated intent-fidelity verdict JSON, validated fail-closed against the schema and the parked review request, and writes its per-criterion verdict and its disposition of each scope condition the intent carries into the shipped intent's `## Audit Notes`, making it the first writer into the scope-condition disposition surface (or quarantines a bad payload, which records every condition `untested`). The machine-readable result says what the ingest recorded — the verdict, a quarantine, or nothing — and carries the acceptance rollup and the disposition split only beside a recorded verdict; a quarantine states the conditions it recorded untested under a name of its own, so its result never reads as a rollup. A second ingest for the same receipt is a no-op when its payload renders to the block on the record, replaces that block in place when it renders differently, and is refused with nothing written when it does not validate. A verdict whose rendered prose cites a record id that names no record is refused, naming the id, with nothing written, wherever the repository's record-lint gates prose citations in the intent store. Each block closes on its own closing line, so prose written below it survives a replacement, and only a marker on a live line of `## Audit Notes` is review state: one in a fenced block or an HTML comment is an example. | (no move; updates `## Audit Notes`) | +| Audit ingest (a verdict JSON path) | Ingests a host-delegated intent-fidelity verdict JSON, validated fail-closed against the schema and the parked review request, and writes its per-criterion verdict and its disposition of each scope condition the intent carries into the shipped intent's `## Audit Notes`, making it the first writer into the scope-condition disposition surface (or quarantines a bad payload, which records every condition `untested`). The machine-readable result says what the ingest recorded — the verdict, a quarantine, or nothing — and carries the acceptance rollup and the disposition split only beside a recorded verdict; a quarantine states the conditions it recorded untested under a name of its own, so its result never reads as a rollup. A second ingest for the same receipt is a no-op when its payload renders to the block on the record, replaces that block in place when it renders differently, and is refused with nothing written when it does not validate. A verdict whose rendered prose cites a record id that names no record is refused, naming the id, with nothing written, wherever the repository's record-lint gates prose citations in the intent store. Each block closes on its own closing line, so prose written below it survives a replacement, and only a marker on a live line of `## Audit Notes` is review state: one in a fenced block or an HTML comment is an example. A verdict that judges any criterion `NOT_MET` or `INCONCLUSIVE` never reads as a pass and never un-ships the intent (ruling DQ1c): the intent stays in `shipped/`, its changelog entry stands, and the block carries an audit-owed flag naming each unmet or undecided criterion, the receipt, the remedy ("fix, then re-run the audit" when a criterion failed, "re-run the audit" when every owed one is undecided) and the issue carrying the check. That issue is captured through the ledger's one filer — a `major` `bug` for a failure, a `minor` `inconsistency` for an undecided verdict, related to the intent, with the filing-time match — and a later failed or undecided audit of the same receipt links to it while it is open rather than filing a second: the ledger lock is held across the scan for an open carrier and the filing, so concurrent ingests of one verdict file one issue, and where several open issues carry one receipt's check the oldest is linked and the others are declined as its duplicates (`wontfix` with a `duplicates` link). A ledger that cannot file refuses the ingest with nothing written. A re-emit of a flagged receipt rewrites its request for the re-run (`check_owed`), and a re-run that judges no criterion `NOT_MET` or `INCONCLUSIVE` replaces the block without the flag, leaves a dated line below it saying the flag was cleared, and resolves every open issue carrying the receipt's check (impact `fix`, resolved by the intent). Every passing verdict sweeps those open issues, flag or no flag, so one left open on an unflagged receipt, by an ingest that filed it and then failed to write the intent, does not outlive the check. The result names the owed criteria, the issue, and whether it was already open, or the issue a pass resolved as it cleared the flag. | (no move; updates `## Audit Notes`; files or links one issue for a failed or undecided check, declining any other open carrier of it as a duplicate; a pass resolves every open carrier of its receipt) | | Condition disposition (one shipped intent id, optionally one condition id) | **The second writer into the scope-condition disposition surface.** With the intent alone it is read-only: every scope condition the intent carries, with its standing disposition and the block that disposition came from, or `untested (no block)`; the machine-readable form carries the whole history and the fold. With a condition identity it writes one disposition against a **shipped** intent — `survived`, `narrowed`, `falsified` or `untested` — joined to what occasioned it: a reading item at any position, or a delivered intent in `shipped/` whose delivery changed the condition's standing. It appends one dated block to `## Audit Notes`, beside the fidelity verdict's blocks and in the same bullet shape. A condition's standing is its latest reading-occasioned block where it has one, and otherwise its latest verdict block: a verdict overrides a reading-occasioned block only where its rationale names that block's occasion, wherever the two sit in the section; the verdict ingest reports what it leaves standing, and a re-ingest for the same receipt that names the occasion replaces the ingested verdict. Refused, with nothing written: an intent not in `shipped/` (naming its bucket), an identity the intent does not carry or carries twice, a value outside the four, grounds below the substance floor, `narrowed` without a narrowing or a narrowing on any other value, an occasion that does not resolve, and the intent itself as its own occasion. Grounds and narrowing are redacted before the write. When a reading item's `constraint_in_play` cites a different condition's identity, the mismatch is reported and never refused: the reading names the tension and the researcher marks the condition. The block sits under the heading every reading's assembler withholds, so no disposition reaches a reading. | (no move; appends to `## Audit Notes`) | | Consistency (the whole corpus, or one intent id) | **Role 2 — cross-document fidelity** (itd-48). Assembles the corpus — every brief page, and every intent outside `superseded/` reduced to its title, press release, scope, decisions and rule — into one input under the local tier, and writes the request beside it: the five judgement classes (terminology drift, premise contradictions, scope leakage, sequencing impossibilities, naming conflicts), the rubric, the findings shape rendered from the structure the ingest decodes, the host-computed provenance pair the audit's request carries, and the commit the tree stood at. With an intent id the pass is that intent against the rest of the corpus, and every finding must have an end in it; a superseded or unknown intent is refused. The judgement rides the host: the intent-auditor's Role 2 reads the corpus and returns findings, each naming exactly two ends, quoted. The receipt is deterministic over the scope and the corpus, so a re-emit over an unchanged corpus reuses it. | (no move; writes the request and the corpus to the local tier) | | Consistency ingest (a findings JSON path) | Validates the returned findings fail-closed before anything is written: the request was issued here, the corpus has not moved since (the receipt is recomputed), the provenance pair is the one issued, every class and severity is in its set, each end's path is a corpus document whose text holds the end's quote (twelve characters at least), and no finding repeats another. A finding it would file whose text cites a record id that names no record is refused too, naming the finding and the id, wherever the repository's record-lint gates prose citations in the issue ledger — every finding is checked before the first is filed, so nothing is written. Then it files one capture per finding — an `inconsistency` from an `agent-finding`, found during the pass that names the report, located at its first end, with the report as its evidence — unless an open record already quotes either end and names its document, in which case the finding is linked to that record rather than filed twice; a finding it files runs capture's filing-time match on its summary and explanation, never against a record the same pass filed, and carries a `duplicates:` or `refines:` link naming each likely double; and it writes a dated report on the reviews shelf naming, in its `review_of_commit` pin, the commit the pass read — marked `dirty: true`, with the uncommitted corpus paths named, when the emit or the ingest's own second reading of the tree against that commit finds a corpus document edited, untracked or deleted relative to it — the union of the two, so the mark is never lost to an edited request or a commit made since the emit — since the pass reads the working tree (itd-28's dirty-tree policy: mark, do not block) — then the receipt and every finding with both ends quoted and located and the record it was filed as or linked to. A second run the same day takes the next free suffix; the same findings ingested again are a no-op naming the report. Neither half writes the brief or an intent. | (no move; writes the report and the ledger) | diff --git a/.abcd/development/brief/04-surfaces/22-site.md b/.abcd/development/brief/04-surfaces/22-site.md index 818dd55b2..77cb1e928 100644 --- a/.abcd/development/brief/04-surfaces/22-site.md +++ b/.abcd/development/brief/04-surfaces/22-site.md @@ -143,7 +143,8 @@ A label the struct declares and the file leaves blank fails the build by name. A label the file does not carry at all, which is how a file written before that label existed reads, is added to the file by the build and by setting up, with the words abcd's own interface-string file gives it: each added label is named -on standard error, every byte already in the file stays, and a file carrying a +on standard error, before the error when the build then fails, every byte +already in the file stays, and a file carrying a key no field reads is left untouched and refused as before. The render the site gate makes of an empty output directory writes only inside that directory, so it never completes the file and refuses an incomplete one by name diff --git a/.abcd/development/brief/04-surfaces/27-implement.md b/.abcd/development/brief/04-surfaces/27-implement.md index 09cf40af4..efd0beeec 100644 --- a/.abcd/development/brief/04-surfaces/27-implement.md +++ b/.abcd/development/brief/04-surfaces/27-implement.md @@ -259,12 +259,30 @@ signals anything. Three sub-verbs drive the loop a build starts, each over the run's state file in the checkout's local tier ([`34-build.md`](34-build.md) states the file, the checks and the step interface). The status render reads every run, or the one -named, and writes nothing. The step verb performs the current lane's next stage and -exits (a lane's stages are worktree, brief, implement, validate and land; the -lane as a whole lands one of the spec's steps); at a stage that hands work to an -agent it names the agent, the brief and the receipt path, and asking again moves -nothing. The receipt hands that file back, and the stage completes only when the -path is the one named and its verifier accepts it. Without a named run, the step +named, and writes nothing: it names the slots in use out of the run's ceiling, +every lane alive with its stage and each agent it awaits, and each held lane with +its cause, the head judged, the landing step it stopped before and the two flags +that decide it. The step verb performs the run's next move and exits (a lane's +stages are worktree, brief, implement, validate and land; the lane as a whole +lands one of the spec's steps). A run works in parallel up to its ceiling (ruling +DR6): a stage the binary owns moves on any lane first, then, while a slot is +free, the first waiting work takes it, an open lane's before a new lane's and +the lower spec step first; a lane opens for a ready spec step whatever the +ceiling, and only its implementer waits for a slot; at a stage that hands work +to an agent it names the agent, the brief and the receipt path, and a step that +finds the ceiling reached hands out nothing and names every agent out. A landing +waiting on the forge's merge holds only its own lane; any other refused stage +the binary performs is the step's answer. The receipt verb looks the path up +among every outstanding await of the run, and the stage completes only when a +lane awaits that path and its verifier accepts it; a verified receipt frees its +slot. After a hand-back the siblings finish and a lane whose round passes is +held before it pushes or arms (ruling DR6c), an armed one disarmed, or, where +the forge refuses the withdrawal, the step refused naming the pull request; the +person's word on a held lane is given through the step verb, one lane per +invocation: release lands it as it is, and discard removes its worktree and +branch, then closes its pull request, and leaves its step unlanded, each refused, changing nothing, unless the lane is +held and no lane has work left. +Without a named run, the step and receipt verbs act on the one run in progress in the checkout and refuse naming the runs when there are several. Their refusals name the stage, the reason and the remedy, and a pause @@ -421,6 +439,8 @@ Sub-verbs: none. | Flag | Type | |---|---| +| `--discard` | string | +| `--release` | string | | `--run` | string | diff --git a/.abcd/development/brief/04-surfaces/34-build.md b/.abcd/development/brief/04-surfaces/34-build.md index 9734c7adf..8e97df730 100644 --- a/.abcd/development/brief/04-surfaces/34-build.md +++ b/.abcd/development/brief/04-surfaces/34-build.md @@ -187,9 +187,10 @@ minutes so the window arithmetic stays far inside the clock's range and a typed extra digit is refused rather than run. A pause of 0 minutes is a run that does not pause. -The ceiling is recorded with the run; this build does not count lanes against -it (criterion 6), and the budget check and the rate-limit checkpoint (criteria -7 and 8) wait on a runner that reports its quota. +The ceiling binds (criterion 6, ruling DR6): the loop counts the agents out +from the state, implementers and validators together, and starts nothing above +the ceiling. The budget check and the rate-limit checkpoint (criteria 7 and 8) +wait on a runner that reports its quota. ## The state file @@ -206,7 +207,16 @@ repository abcd manages has one, so a run is managed-only by construction. Each run directory is created one level at a time and proved real, the state file is replaced atomically inside an `os.Root`, and the reader decodes strictly, refusing an unknown field, a schema version it does not know, or a file stored -under a run id it does not name. The state is schema version 8. Version 8 +under a run id it does not name. The state is schema version 9. Version 9 +made a run work in parallel up to its ceiling (ruling DR6): a lane's `awaits`, +a list replacing the one `awaiting`, the run's `waiting` (the work the ceiling +holds back, each item with the time first held), a lane's `syncs` (each merge of +the default branch after a sibling landed) and `hold` (a lane held after a +sibling's hand-back, ruling DR6c), a pending step's `needs`, and the lane +stages `held` and `discarded`. A file of version 8 or lower reads as a run +whose lanes await zero or one agent, its `awaiting` carried over to a one-entry +`awaits`; one carrying what only version 9 writes is refused, and so is a +version-9 file carrying `awaiting`. Version 8 added the runner's record (itd-2609201916056194): the run's `fallbacks`, one receipt per role a routed runner did not run, and the `route` a verified receipt or a validator's recorded return names when a runner ran its agent. Version 7 @@ -218,7 +228,7 @@ added the fix-round cap (ruling DR1): the pace's `fix_rounds` and a lane's `validation`). Each earlier version is the next one's strict subset, read as a run that predates the addition (a version-5 run runs on the bundled cap, a version-7 run is one the host ran every agent of) and -written back at version 8 by its next mutation; an earlier version carrying what +written back at version 9 by its next mutation; an earlier version carrying what only a later one writes is refused. Version 4 renamed the lane's stage (BU1, iss-2609291313276243): a lane's and a record line's `step` became `stage`, so "step" names only the spec's steps (`spec_step`, @@ -258,13 +268,12 @@ the run as its own peer. Only the key's shape is checked before the lookup. A spec's steps and a lane's stages are two words for two things (BU1, iss-2609291313276243): each spec step lands as one lane, and the loop takes the -lane through its stages. The step verb performs one stage. +lane through its stages. The step verb performs one move of the run. -A host session drives the loop one stage at a time (decision 5's default). The +A host session drives the loop one move per call (decision 5's default). A lane's stages run in a fixed sequence: the worktree, the brief, the implementer, the validators, the landing. Each invocation takes the lock, reads the state, -performs the current lane's next stage and writes the state once, after the -stage succeeds. A stage that fails, or a process killed inside one, leaves the +performs one move and writes the state once, after the move succeeds. A stage that fails, or a process killed inside one, leaves the state as it was, so the next invocation performs that stage again; a stage the state records as done is never performed twice (criterion 7). A stage's body is therefore written to find what it made last time. The result names the stage @@ -272,10 +281,44 @@ the call completed as `performed_stage` and the lane's next as `stage`. A stage that hands work to an agent does not complete by itself: the lane then awaits, naming the agent's role, the brief it is handed and the path its receipt -goes to (criterion 8). Asking again re-tells the same thing and moves nothing, -and the lane advances only when that receipt is handed back at that path and its -verifier accepts it. When a lane is done, the next pending spec step opens the -next lane, so the spec's steps land one lane at a time. +goes to (criterion 8), and the lane advances only when that receipt is handed +back at that path and its verifier accepts it. + +A run works in parallel up to its ceiling (ruling DR6, spc-2609202134341288). A +slot is one outstanding await on any lane; the count is the awaits in the state +file. Each move first performs a stage the binary owns on any lane (the +worktree, the brief, a round's close, a landing step, a sync, a hold), which +takes no slot and is never held by the ceiling; when the move needs an agent it +takes the first waiting item: an +open lane's validators or fix and sync implementers before a new lane, the lower +spec step first, a round's validators in the order the round lists them, then +the implementer of a new lane. A move that finds the ceiling reached hands out +nothing, names every lane alive with what it awaits, and records the held work +under `waiting`; the move that later serves it records the whole minutes it +waited. A lane opens for a spec step once every step it needs has landed (its +`- needs:` line, or by default every step before it, ruling DR6b), so a spec +that declares no needs lands its steps one lane at a time; it opens whatever the +ceiling, its worktree and brief made, and only its implementer waits for a slot. +A landing waiting on the forge's merge holds only that lane: the move goes to +another and names the wait under `blocked` and in its next move. Any other +refusal of a stage the binary performs, a missing preflight receipt included, is +the move's answer, and no other lane moves. + +Landing is one lane at a time, the lower spec step first. A lane whose sibling +landed since its base is synced before its landing begins: the default branch is +merged into its branch with a merge commit, never a rebase, and a fresh round +judges the merge head. A conflicting merge is aborted with the branch unchanged +and goes to a fresh implementer with a sync brief; its receipt must carry the +merged sha as an ancestor of its head. A sync counts no fix round. The closing +lane reaches its landing with no step pending, no other lane open and none handed +back; its audit reads each of the run's lanes' own diff. After a hand-back the +siblings finish, no new lane opens, no lane closes the spec, and a lane whose +round passes is held before its push or its arming (an armed one is disarmed; +where the forge refuses the withdrawal the move refuses naming the pull request, +and one the forge reports merged is recorded as landed), until the person +releases or discards it (ruling DR6c). A discard removes the lane's worktree and +branch before it closes the pull request, so a refused removal leaves nothing +half done. The loop keeps the run's window clock (criteria 4 and 5). A new run's first window opens at its start. Once the window's working minutes have elapsed, the @@ -510,10 +553,23 @@ until the last step. branch, never opened twice. 5. It reads the merge rule from the ruleset mirror (`.abcd/work/rulesets/`) at the lane's base, so the lane's own commits cannot change it (decision 3): - where an active ruleset gates the default branch through a merge queue, it - arms auto-merge with the queue's method; where none does, it leaves the pull - request open for a person to merge. Nothing is pushed to the lane after this - step. + where an active ruleset gates the default branch through a merge queue and + an active ruleset on it requires a person's approval, it arms auto-merge + with the queue's method; otherwise it leaves the pull request open for a + person to merge (ruling AM1). A pull_request rule requires approval when its + `required_approving_review_count` is one or more, or when + `require_code_owner_review` is set and the CODEOWNERS file the forge reads + at the lane's base names at least one owner. That file is the first found + in `.github/`, the root and `docs/`, so one there shadows the later ones + even when it names nobody, and a line names an owner only when its pattern + is followed by `@name`, `@org/team` or an e-mail address: a code-owner + review with nobody to own the change asks no person for anything. A missing + mirror requires nothing, so the pull request stays open; a mirror file that + cannot be read or parsed is refused, which also arms nothing. Where a queue + exists but nothing requires approval, the step's note, the run record and + the run's status say "left open for a person to merge: the ruleset requires + no approval", and no later step arms it. Nothing is pushed to the + lane after this step. 6. It fetches the default branch and waits, exiting 3, until the pushed head is an ancestor of it; only then does it remove the lane's worktree (never forced) and delete the lane's branch at a tip the same check proves landed, diff --git a/.abcd/development/brief/04-surfaces/35-drain.md b/.abcd/development/brief/04-surfaces/35-drain.md index 3dde3452e..aba7a1d9f 100644 --- a/.abcd/development/brief/04-surfaces/35-drain.md +++ b/.abcd/development/brief/04-surfaces/35-drain.md @@ -73,6 +73,15 @@ that is live; ineligible without a remedy or with the automatic filers' value un a person writes one; and unreadable when the ledger reader refuses the record. A record written before the field existed reads its `suggested_fix:` as its remedy. +One automatic filer writes a real remedy. An after-merge fidelity audit that +judges a criterion `NOT_MET` or `INCONCLUSIVE` captures one issue carrying the +check it leaves owed (ruling DQ1c; [`05-intent.md`](05-intent.md)), and its +remedy is the work that clears it: "fix, then re-run the audit" for a failed +criterion, "re-run the audit" for an undecided one. The rule judges such a +record by its fields as it judges any other: a failed audit's record is a +`major` `bug`, which the baseline hands back on severity, and an undecided +one's is a `minor` `inconsistency`, which the baseline takes. + Two hand-backs hold whatever the repository's record says, because each marks a decision a person still owes. A remedy that opens "Waits on" as words, followed by a blank, a colon or nothing (the shape a remedy takes when its fix waits on an diff --git a/.abcd/development/intents/planned/itd-2609201925079472-an-autonomous-implementation-run-paces-itself-by-default-and.md b/.abcd/development/intents/planned/itd-2609201925079472-an-autonomous-implementation-run-paces-itself-by-default-and.md index f732ab74d..5cc4c5aa6 100644 --- a/.abcd/development/intents/planned/itd-2609201925079472-an-autonomous-implementation-run-paces-itself-by-default-and.md +++ b/.abcd/development/intents/planned/itd-2609201925079472-an-autonomous-implementation-run-paces-itself-by-default-and.md @@ -54,6 +54,8 @@ Settled on 2026-09-20 with one defensible answer each, on the product thinker's 4. **Layering is flag, then repository, then machine, then bundled**, the order the model-tier intent already uses. 5. **The bundled default is 120 minutes of work, 300 of pause, two lanes**, the product thinker's numbers for this repository's runs on 2026-09-20; a repository that measured otherwise writes its own. 6. **The budget check and the rate-limit checkpoint come here from `itd-29`**, superseded on 2026-09-20 by the implement verb: A run refuses to start when the estimated cost exceeds the remaining quota where the runner reports one, and a rate-limit response checkpoints the lane and ends the window early. +7. **A run works in parallel up to its ceiling** (ruling DR6, the product thinker, 2026-09-29, verbatim: "per-run agent limit: WORK IN PARALLEL — a run may build several pieces and run reviewers concurrently up to its limit (new build-loop work; then AC6 is testable)."). The validators of a round run side by side, the lanes of steps that do not need each other run side by side, and implementers and reviewers share the ceiling; criterion 6 is tested through the concurrent loop the spec's piece 6 designs. +8. **Steps run one after another unless their plan says otherwise, and a hand-back holds the siblings' landings** (rulings DR6b and DR6c, the product thinker, 2026-09-30). DR6b, verbatim: "(a) ONE AFTER ANOTHER BY DEFAULT: a step runs alongside earlier ones only if its plan says so; nothing already planned changes; reviews run side by side; the 2026-09-21 wording stands." DR6c, verbatim: "(c) FINISH, BUT HOLD THEM: when one piece is handed back, pieces in flight finish but nothing merges until the person re-plans; the person then decides whether the held pieces land as they are." A step's default `needs` is every earlier step, opted out of per step; after a hand-back the sibling lanes run to completion and each passing one is `held`, never armed, until the person releases or discards it with `implement step --release ` or `--discard ` (the spec's piece 6, criteria C6, C11 and C13). ## Open Questions diff --git a/.abcd/development/release/surface.json b/.abcd/development/release/surface.json index 056dc092c..5216fce5e 100644 --- a/.abcd/development/release/surface.json +++ b/.abcd/development/release/surface.json @@ -1822,6 +1822,20 @@ "hidden": false, "sentence": "Perform the next stage of an implement loop run's lane and exit: Writes the run's state and the lane's stages; refuses a push with no preflight receipt.", "flags": [ + { + "name": "discard", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, + { + "name": "release", + "shorthand": "", + "type": "string", + "required": false, + "hidden": false + }, { "name": "run", "shorthand": "", diff --git a/.abcd/development/specs/closed/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md b/.abcd/development/specs/closed/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md new file mode 100644 index 000000000..2ab8d131c --- /dev/null +++ b/.abcd/development/specs/closed/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md @@ -0,0 +1,601 @@ +--- +id: spc-2609202134341288 +slug: an-autonomous-implementation-run-paces-itself-by-default-and +intent: itd-2609201925079472 +origin: researcher-authored +production_mode: hand-written +--- +# an-autonomous-implementation-run-paces-itself-by-default-and + +## Summary + +The design record for itd-2609201925079472, from the six decisions on the +intent (2026-09-20). + +## Scope + +1. **The pace configuration**: `pace.work_minutes`, `pace.pause_minutes`, + `pace.sub_agents` read through the layering the model-tier intent uses + (flag, repository `.abcd/config.json`, machine `~/.abcd/config.json`, + bundled 120/300/2); one resolver reports the value and the layer it + came from (criteria 1 to 3, 9). +2. **The window clock** in the implement verb's state file: the window's + start, `next_eligible_at`, the lanes alive; written by the loop at every + step (criteria 4, 5). +3. **The ceiling**: the loop counts the agents alive from the state + (implementers and validators together) and starts nothing above the + ceiling, exiting with the lanes named and the wait accumulated + (criterion 6). The ceiling is reachable because a run works in parallel + up to it (piece 6). +4. **The budget check** (from `itd-29`): where the runner reports remaining + quota, an estimate from the spec's size is compared before the run + starts; a runner that reports none is named and the check is skipped + out loud (criterion 7). +5. **The rate-limit checkpoint** (from `itd-29`): a runner's rate-limit + response ends the window early with the lane checkpointed to its branch + and `next_eligible_at` set (criterion 8). +6. **Concurrent lanes and validators** (ruling DR6, 2026-09-29): one run + hands work to several agents at once, up to its ceiling: the validators + of one round together, and the lanes of steps that do not need each + other side by side. The section "Concurrent lanes and validators" below + is the design; its criteria C1 to C13 are how criterion 6 is tested. + +## Out of scope + +A ceiling across runs (the register's), which includes the further picks +`abcd build next --max` and `--until-empty` make: each pick is its own run, +and picks stay one after another. Telemetry and hand verbs. A time limit on +an agent that never hands back its receipt: its slot stays taken until the +receipt comes back or the person stops the run. + +## Approach + +One lane, test-first, on the implement verb's state file; the resolver is +the one canonical layering primitive shared with the model tier. The bundled +default lives in one constant the run record names. + +## How the criteria are satisfied + +1 to 3 and 9 by piece 1; 4 and 5 by piece 2; 6 by pieces 3 and 6, tested +through C1 to C13 below; 7 by piece 4; 8 by piece 5. + +## Evidence the build must answer + +- The pause has never fired. Three Dessau pilots on 2026-09-21 ran the scripted outer loop under a pacing window, and each finished inside its first window, so the gate that refuses an early start and the resume on `next_eligible_at` are untested by a real pause; the abcd pilot of 2026-09-20/21 paced by hand (idle wake-ups from the orchestrator's own scheduler) and broke its second pause on the facilitator's word. The build proves the pause with a test that sets the clock past the window and watches the refusal, and the first real run records whether the pause fired. +- The ceiling turned reviews into a queue: 40 minutes of a two-hour window with a lane waiting on the two-agent ceiling, 27 minutes for one review (iss-2609211105014235). That record proposed reviewers counted separately from implementers, or a ceiling set from the lane shape; its resolution ruled instead that reviewers take the same slots as implementers, and piece 6 ("The count") holds that ruling. What this spec takes from the record is the queue, not the separate count: the validators of one round run side by side, and an open lane's reviewers take a freed slot before any new lane (piece 6, "The order in which waiting work takes a freed slot", and C5). + +## Concurrent lanes and validators (ruling DR6, 2026-09-29) + +The product thinker ruled, verbatim: "per-run agent limit: WORK IN PARALLEL +— a run may build several pieces and run reviewers concurrently up to its +limit (new build-loop work; then AC6 is testable)." Without this piece the +loop hands out one agent at a time, so a run never has more than one agent +alive and criterion 6's ceiling, which is at least 1, can never be reached. +This section is the design that makes it reachable. The pieces it +changes are the implement verb's (`itd-2609201916151817`: the state file, the +step interface, the validators and the landing) and the step parser of +`itd-2609212103565953`; they are specified here because the ceiling is this +spec's and nothing else needs them. + +### What this piece is built on + +- **Piece 9 of `spc-2609202134338445`, the landing** (the pull request armed + through the forge client, the repository's merge rule, the ancestor check, + the cleanup), is what the landing rules stand on, and it is built: the land + stage (`internal/core/implement/loop/land.go`) performs the six steps of the + landing (prepare, records, push, pull request, arm, merged). Every rule of + "Two lanes that touch the same files" below, and criteria C8, C9, C10 and + C13, extend that stage; none of them waits on another piece. + +### The count + +- A **slot** is one agent the run has handed work to and not yet taken a + verified receipt from: an outstanding await on any lane of the run. The + count is the number of outstanding awaits in the state file, and the state + file is its only source; nothing else is counted and nothing is stored + beside it that could disagree. +- Implementers and validators count alike, in any mix of roles: the first + implementer of a lane, the fresh implementer of a fix round, the fresh + implementer of a sync (below), the ruthless reviewer, the security + reviewer and the intent-auditor. That reviewers take the same slots as + implementers is already settled (`iss-2609211105014235`'s resolution and + the decision log of 2026-09-23, 2026-09-24 and 2026-09-29) and is not + reopened here. +- The ceiling is the run's `pace.sub_agents`, with the layer that supplied + it, as piece 1 resolves it. The session that drives the loop is not a slot: + it is handed no brief. +- A stage the binary performs itself takes no slot and is never held by the + ceiling: the worktree, the brief, the landing's `gh` calls and the merge of + a sync. + +### Isolation + +- Every lane has its own worktree in the machine-scoped store, + `~/.abcd/worktrees//-`, on its own branch + `build/-`, exactly as the implement verb's piece 6 lays one + out. Two lanes never share a worktree or a branch. +- The validators of one round read the lane's worktree at the head the round + names, at the same time, and treat it as read-only: whatever a validator + runs, it runs on a copy (`git archive `), as `AGENTS.md` asks of a + verifier. No implementer is handed the lane while its validators are out: + a fix brief is written only once every validator of the round has returned. +- Every `implement step` and `implement receipt` holds the run tier's + advisory lock for its read and its write of the state file, as it already + does, so receipts that several agents hand back at once are applied one + after another and none is lost. + +### How a slot is freed + +- A slot is freed when its receipt is verified: `implement receipt ` + looks the path up among every outstanding await of the run, not only the + first lane's, and advances the lane that await belongs to. A path no + outstanding await names is refused, naming the awaits there are, and frees + nothing. +- A receipt the verifier refuses frees nothing: the agent still owes it, and + its slot stays taken. +- A lane handed back holds no slot, because a hand-back is decided only once + every validator of its round has returned. +- A freed slot is filled by the next `implement step`, never by the receipt + call itself: the receipt call verifies and advances one lane and exits, as + every invocation does. + +### The order in which waiting work takes a freed slot + +Each `implement step` performs one move. It first performs any stage of any +lane the binary owns (those take no slot). When the move needs an agent, it +takes the first waiting item in this order: + +1. Work on a lane already open, before any new lane: the round's validators + and the fix or sync implementers. A lane already open is closer to + landing, and its reviewers are what queued for 40 minutes in the record + `iss-2609211105014235` carries. +2. Among open lanes, the lane of the lower-numbered spec step first. +3. Within one lane's round, the validators in the order the round lists them: + the ruthless reviewer, the security reviewer, then the intent-auditor on + the lane that closes the spec. +4. Then the first implementer of a new lane, for the lowest-numbered pending + step whose needs have landed (below). + +The order is a function of the state alone, so two runs of the same state +hand out the same work. + +### Which steps may run side by side + +- A spec step may carry a `- needs:` line beside its `- packages:` and + `- tests:` lines: `- needs: none`, or `- needs: 1, 3`, naming earlier steps + by number. +- **A step without the line (ruling DR6b).** The product thinker ruled, + verbatim: "(a) ONE AFTER ANOTHER BY DEFAULT: a step runs alongside earlier + ones only if its plan says so; nothing already planned changes; reviews + run side by side; the 2026-09-21 wording stands." So the default `needs` + of a step is every step before it, and running beside earlier steps is an + opt-in the step declares for itself (`- needs: none`, or a list naming + only the steps it waits for). The loop never infers independence, from + the `- packages:` lines or from anything else. A step without the line + keeps the order `itd-2609212103565953` rules (criterion 2: the next step's + lane starts only after the previous step has merged), and that intent's + wording of 2026-09-21 stands unamended, so every stepped spec written so + far builds exactly as it did. The validators of a round run side by side + whatever the steps declare (below). +- A lane opens for a step only when every step it needs has landed: its pull + request is an ancestor of the default branch, or the spec at the default + branch marks it `landed:`. Its branch is then cut from the default branch, + so it holds what it needs. +- The parser refuses a `needs` that names the step itself, a later step, or + a step the spec does not list, naming the line, and the readiness gate + reports the refusal before a run starts. +- **A `needs` line across a remainder.** `spec close --remainder` carries the + steps not marked landed, in document order, and renumbers them from one + (`spec.Unlanded`, `spec.RenderSteps`, and the renumbering the close does + before it mints; a step's number is its position, `steps.go`'s `Step`). + Every other indented line is carried verbatim, so a `- needs: 1, 3` copied + as written would name the wrong step, or one the remainder does not list, + and the parser would refuse the remainder the close had just written. The + copy therefore rewrites the `needs` line, the one indented line it does + not carry verbatim: + - a named step that landed is satisfied and leaves the list; + - a named step that did not land is carried, and is renamed to its number + in the remainder; + - a list left empty is written `- needs: none`, never removed, because an + absent line means the default, every earlier step (ruling DR6b), not + "nothing"; + - a step without the line stays without it: the earlier steps the + remainder lists are exactly the unlanded earlier steps, and the landed + ones are satisfied, so the default reads the same over the remainder as + over the spec it came from. + + The close's result names each `needs` line it rewrote, before and after. + The rewrite is chosen over dropping the line because it is a total + function of the parse the close already holds, with nothing guessed: a + step is in the remainder exactly when it has no `landed:` (the predicate + `Unlanded` cuts on), so every step a `needs` line names is either landed + (satisfied) or carried, and the carried steps keep their order, so the map + from old number to new is one-to-one. Dropping the line would turn an + explicit list into the default, every earlier step (ruling DR6b), which + runs the step later than its plan declared. Rewriting a satisfied + need to its `landed:` marker instead would give the parser a second + vocabulary for a need that constrains nothing. The steps spec + (`spc-2609212138246060`, scope 4) names this exception to its verbatim + copy. +- The validators of one round always run side by side, stepped spec or not: + parallel reviewers need no declaration. + +### The state file + +The state goes to schema version 8 (version 7 is the landing's, piece 9): + +- `lanes[].awaits` is a list of awaits, each with its role, brief, receipt + path and `since`. A lane has one entry while its implementer works and one + per validator while its round is out. It is a new key, not the version-7 + `awaiting` with its type changed: the state is decoded strictly (a field + the version does not name is refused), and the version is peeked first to + pick the shape the decode holds the file to, so a key that read an object + in one version and a list in the next would be a second, silent branch + inside one key. Version 8 does not write `awaiting`. +- `waiting` is a list of the work the ceiling holds back, each with its lane, + its role and `since`, the time the ceiling first held it. It is written when + a step finds the ceiling reached, and an item leaves it when it takes a + slot, with a run-record entry naming the lane, the role and the whole + minutes it waited. +- `lanes[].syncs` records each sync (below): the sibling lanes whose landing + caused it, the default branch's sha merged in, whether it conflicted, and + the head it produced. +- `lanes[].hold` records a held lane (ruling DR6c): `since`, `cause`, `head` + and `before`; the lane stages gain `held` and `discarded`. +- A state file of version 7 or lower reads as a run whose lanes each have + zero or one await (its `awaiting` object becomes a one-entry `awaits`), and + runs on unchanged; the writer writes version 8. A version-8 file is refused + by an abcd that knows only version 7, naming the version, as every schema + step already is. +- A file that claims a version older than what it carries is refused, in the + shape of every earlier step's refusal (`capped()` and `landed()` in + `internal/core/implement/loop/state.go`: a pace in a version-1 file, a pick + in a version-1 or version-2 file, and below them a validation, a fix-round + cap and a landing): a file + of version 7 or lower that carries `awaits`, `waiting`, `syncs` or `hold`, + or a lane stage `held` or `discarded`, is + refused naming its version and what it carries that the version never + wrote, with the remedy those refusals give (the loop is the file's only + writer; restore it or remove the run directory). A version-8 file that + carries `awaiting` is refused the same way, since version 8 never writes + it. +- `implement status` names the slots in use out of the ceiling, and every + lane alive with its stage and each role it awaits. The status block reports + one row per lane alive, not only the first. + +### Two lanes that touch the same files + +- Landing is one lane at a time within a run: at most one lane holds an armed + pull request that has not merged. A lane whose round passes while a + sibling's pull request is armed waits at its landing, holding no slot; when + several wait, the lane of the lower-numbered spec step arms first. +- Before a lane arms its pull request, the loop checks whether a sibling lane + of the same run has landed since this lane's base. If one has, the loop + **syncs** the lane: it merges the default branch into the lane's branch + with a merge commit in the lane's worktree. It never rebases, so no commit a + validator judged is rewritten and every sha the record cites stays + reachable. +- A clean merge moves the lane's head, so a fresh round judges the new head: + no verdict stands over a head it did not read (the invariant of the + implement verb's piece 8). +- A merge that conflicts is aborted, leaving the branch where it was, and the + conflict goes to a fresh implementer with a sync brief naming each + conflicting path and the sibling lane whose landing brought the other side. + The receipt is verified as a fix receipt is, and must also carry the + default branch's merged sha as an ancestor of the new head; then a fresh + round judges it. +- A sync does not count against `--fix-rounds`. Each sync answers a sibling + landing, and a run has a fixed number of lanes, so syncs are bounded by the + lane count. A round after a sync that does not pass is a fix round like any + other and counts. +- The lane that closes the spec is synced before it arms whatever it + touches (a merge that finds the branch already holds the default branch + changes nothing and needs no round), so its head holds every lane the run + landed. A lane is the closing lane when it reaches its landing with no step + pending, no other lane open, and no lane of the run handed back. If its + last passing round had no auditor, the sync's fresh round carries one. +- After a hand-back, no lane closes the spec. A handed-back lane is neither + done nor open, and the delivery lacks its step, so no auditor judges the + delivery as whole and no lane runs the close; the spec stays open for the + person's replan. The siblings open at the hand-back finish and are held + (ruling DR6c, "After a hand-back, the siblings finish and are held", + below). +- The intent-auditor reads the whole delivery (ruling AI) as the run's own + lanes' changes, never as a sha range from the base of the run's first lane + to the closing head: after a sync, that range also holds every commit the + default branch gained since the base, work from outside the run included. + The delivery is the diff of each of the run's pull requests, lane by lane: + a lane's head against the default-branch sha it last merged in (the last + entry of its `syncs`), or against its base when it never synced. Each of + those shas is an ancestor of the lane's head, so each diff holds that + lane's commits and its conflict resolutions and nothing a sibling or an + outside change brought in; a landed lane's head stays reachable after its + worktree is cleaned up, because piece 9's ancestor check holds it in the + default branch. For a run of one lane that never synced, the delivery is + the base-to-head range the implement verb's piece 8 reads. +- Movement of the default branch by anyone outside the run is left to the + repository's merge rule, as it is for a single lane: the loop syncs only on + its own siblings' landings. + +### The pace and the fix rounds, per lane + +- The window clock is the run's, one for every lane. When the window + elapses, `implement step` starts no agent on any lane; every outstanding + await may still hand back its receipt, and those receipts are verified and + free their slots. `next_eligible_at` is written once for the run. +- A rate-limit response on any lane's agent ends the window for the whole + run, because every lane spends the same budget; every lane with work in + flight is checkpointed to its own branch, and the record names the lane the + response came from. +- `--fix-rounds` is a per-run value that each lane spends on its own: a lane + counts its own fix rounds against the run's cap, and a sibling's rounds + never count against it. +- A lane that exhausts the cap is handed back alone; what that does to the + lanes open beside it is the next section. + +### After a hand-back, the siblings finish and are held (ruling DR6c) + +The product thinker ruled, verbatim: "(c) FINISH, BUT HOLD THEM: when one +piece is handed back, pieces in flight finish but nothing merges until the +person re-plans; the person then decides whether the held pieces land as they +are." The rules it implies: + +- **Who finishes.** A hand-back stops only the lane handed back. Every sibling + lane open at that moment runs to completion: its implementer, its rounds and + its fix rounds go on under the same ceiling, window and cap, and it ends + either held (below) or handed back itself. No new lane opens after a + hand-back, and pending steps stay pending, because the run's intent is the + person's to replan. No lane closes the spec (above). +- **What `implement step` answers in between.** The single refusal of every + step at a hand-back (`loop.go:569-570` at `7f6eb5579`, DR1's shape, itd-50 + criterion 2) narrows: a step moves any sibling that still has work, and + refuses, naming the hand-back and every held lane with the way out below, + only once no lane of the run has anything left to do but wait for the + person. The handed-back lane itself still starts nothing, as built. +- **The `held` state.** A sibling whose round passes after a hand-back does + not land. It takes the lane stage `held`, beside `handed-back`: it holds + no slot, starts nothing, and is never armed. The hold stops the landing + before the first of its steps that reaches past the machine or merges: a + lane that has not pushed is held before the push, so a discard leaves + nothing on the forge; a lane that pushed and opened its pull request is + held before arming, its pull request left open and unarmed. A lane already + armed when the sibling is handed back (one landing at a time, so at most + one) is disarmed through the forge client (`gh pr merge + --disable-auto`, beside the arming's `gh pr merge --auto`) and held; where the forge + refuses the withdrawal, the step refuses naming the pull request and the + person decides, and a lane whose pushed head the default branch already + holds had landed before the hand-back and is recorded as landed. +- **What the run records.** `lanes[].hold` carries `since` (the time the lane + was held), `cause` (the handed-back lane's id), `head` (the sha its passing + round judged) and `before` (the landing step it stopped before: `push`, + `arm`). Each hold writes a run-record entry naming the lane, the cause and + the head; each release or discard writes one naming the person's choice. +- **What `implement status` shows.** One row per held lane, beside the rows + of the lanes alive: the lane, its step, the stage `held`, the head judged, + the lane whose hand-back caused it, the step it stopped before, and the + way out (the two flags below). A held lane is not counted among the slots + in use. +- **The person's decision, per held lane.** The run's intent is re-planned + outside the loop (itd-50 criterion 3), and the loop cannot read a replan's + content, so the person's word on each held lane is what the loop acts on. + It is given through the verb that already moves the run, `implement step`, + with one of two flags naming one lane per invocation, as every invocation + performs one move: + - `implement step --release `: the lane lands as it is. Its stage + returns to `land` and its landing resumes at the step it stopped before, + by the landing rules above: synced first when a sibling landed after its + base, with a fresh round over a moved head. It closes the spec only if it + is the closing lane, which a run with a hand-back never has. + - `implement step --discard `: the lane does not land. The loop + closes its pull request if it opened one, removes its worktree from the + machine store and deletes its branch, and gives the lane the terminal + stage `discarded`; its step stays unlanded in the spec, so a replanned + remainder carries it. + Either flag is refused, changing nothing, when the lane it names is not + `held`, and while any lane of the run still has an agent out or a round to + run, naming those lanes, so the person decides over the whole set of held + pieces at once. The existing `implement release ` is not reused: + it removes a session's claim on a record, a different noun. +- The run stays stopped on its hand-back after every held lane is released + or discarded; the way past the handed-back lane is as built + (`handedBackWayOut`, iss-2609301303434847). + +### Criteria (Given, When, Then) + +Each is a test through the step interface, with fake agents writing the +receipts and returns, on the clock `Options` already carries; the host-playing +end-to-end test of the implement verb is the pattern. C2 and C3 are criterion +6 made concrete. + +- **C1, the count.** **Given** a run with `--sub-agents 2` whose lane reaches + its validate stage, **when** `implement step` is called twice, **then** the + ruthless and security reviewers both await at once, the state file carries + two awaits on that lane, and `implement status` names 2 of 2 slots in use, + each with its lane and role. +- **C2, the ceiling reached (criterion 6).** **Given** C1's run with both + reviewers out, **when** `implement step` is called again, **then** it hands + out no brief, exits 0 naming every lane alive with the role and receipt path + it awaits, and writes the held work into `waiting` with the time it was + first held; a second call before any receipt leaves that time unchanged. +- **C3, the slot filled and the wait counted (criterion 6).** **Given** C2's + run and the clock moved on 14 minutes from the `implement step` call that + first found the ceiling reached (the `since` C2 wrote, not the moment the + work became ready), **when** one reviewer's receipt is verified and + `implement step` is called, **then** the held work takes the freed slot + and the run record carries an entry naming its lane, its role and 14 + minutes waited. +- **C4, implementers and reviewers together.** **Given** `--sub-agents 3`, + lane 1 with both reviewers out and step 2 marked `- needs: none`, **when** + `implement step` is called until lane 2's implementer is handed its brief + (the worktree, the brief and the implementer are one move each), **then** + lane 2 opens in its own worktree and its implementer takes the third slot; + the next call finds the ceiling reached. A stage the binary performs itself proceeds at the ceiling. +- **C5, the order.** **Given** a full run where lane 2's security reviewer, + lane 1's fix implementer and a new lane for step 4 all wait, **when** one + slot frees and `implement step` is called, **then** lane 1's fix implementer + takes it; at the next freed slot lane 2's reviewer; step 4's lane opens + last. +- **C6, needs.** **Given** a spec whose step 2 has no `- needs:` line, **when** + lane 1 is open, **then** no lane opens for step 2 until lane 1's pull request + is an ancestor of the default branch, even with a slot free. **Given** + `- needs: none` on step 2, **then** its lane opens at the next free slot. + **Given** `- needs: 3` on step 2, **then** the parser refuses naming the + line. (The first case is ruling DR6b: a step without the line needs every + step before it.) + **Given** a spec of four steps where steps 1 and 3 are marked `landed:`, + step 4 carries `- needs: 1, 2` and step 2 carries `- needs: 1`, **when** + `spec close --remainder` runs, **then** the remainder lists old step 2 as + step 1 with `- needs: none` and old step 4 as step 2 with `- needs: 1`, + the close names both rewritten lines, and the remainder parses. +- **C7, isolation and the receipt.** **Given** lanes 1 and 2 each awaiting an + implementer, **when** lane 2's receipt is handed back, **then** lane 2 + advances and lane 1 is unchanged; the two worktrees and branches differ; a + receipt path no await names is refused and the count is unchanged. +- **C8, a clean sync.** **Given** lane 1 has landed a change to one file and + lane 2, branched before it, changed another file, **when** lane 2 reaches its + landing, **then** the loop merges the default branch into lane 2 with a merge + commit before arming, a fresh round judges the merge head, and no fix round + is counted. +- **C9, a conflicting sync.** **Given** lane 1 has landed a change to a file + lane 2 also changed, **when** lane 2 reaches its landing, **then** the merge + is aborted with the branch unchanged, a fresh implementer is handed a sync + brief naming the file and lane 1, a receipt whose head does not contain the + merged sha is refused, and a verified one opens a fresh round; no fix round + is counted. +- **C10, one landing at a time.** **Given** lanes 1 and 2 both waiting at + their landing with passing rounds, **when** `implement step` is called, + **then** lane 1 arms its pull request first, and lane 2 arms only after lane + 1's pull request has merged and lane 2 has synced and passed a fresh round. +- **C11, the pace across lanes.** **Given** two lanes each with an agent out + and the window elapsed, **when** `implement step` is called, **then** it + starts nothing on either lane, both receipts are still verified, and + `next_eligible_at` is written once. **Given** `--fix-rounds 1` and lane 1 + failing its second round, **then** lane 1 is handed back, lane 2 runs on to + its passing round and is held, never armed (ruling DR6c), no new lane + opens, and no lane closes the spec. +- **C12, the schema.** **Given** a version-7 state file with one lane + awaiting its implementer, **when** it is read, **then** the lane has one + await and the run advances on it; the next write is version 8 and carries + `awaits`, never `awaiting`. **Given** a version-7 file carrying `awaits`, + `waiting` or `syncs`, or a version-8 file carrying `awaiting`, **when** it + is read, **then** it is refused naming the version and the key. +- **C13, the hold and the person's decision (ruling DR6c).** **Given** + `--sub-agents 3`, `--fix-rounds 1`, steps 2 and 3 both marked + `- needs: none`, lane 1 handed back while lane 2's implementer is + out, **when** lane 2's receipt is verified, its fake validators pass and + `implement step` is called until nothing moves, **then** lane 2's stage is + `held` with `hold.cause` naming lane 1, `hold.head` its judged head and + `hold.before` `push`; the fake forge records no push, no pull request and + no arming; no lane opens for step 3; the run record names the hold; + `implement status` shows lane 2 held with its cause, head and the two + flags, and 0 slots in use; and the next `implement step` refuses naming + lane 1's hand-back and lane 2 held. **When** `implement step --release` + names lane 1 (handed back, not held), **then** it is refused and the state + file is byte-identical. **When** it names lane 2, **then** lane 2's stage + is `land`, the run record names the release, and the following steps take + it through the landing on the fake forge. **Given** the same held lane in + a second run, **when** `implement step --discard` names it, **then** its + worktree and branch are gone, its stage is `discarded`, step 2 stays + unlanded in the spec, and the fake forge records nothing. + +### Where the loop assumes one lane at a time + +At the base of this amendment (`c5b4305f6`), each of these holds the single +lane or the single agent in place, and the build replaces or reads past it: + +- `internal/core/implement/loop/state.go:214`: "Lanes are the lanes opened so + far, one at a time, in order." +- `internal/core/implement/loop/state.go:242-244`: one `Awaiting` per lane. +- `internal/core/implement/loop/state.go:410-420`: `current()` is the first + lane not done, the only lane any call acts on. +- `internal/core/implement/loop/loop.go:550` and `:574`: `Advance` works on + `current()` alone and returns while that lane awaits, so nothing else + starts. +- `internal/core/implement/loop/loop.go:619` and `:726`: the next lane opens + only when the current one is done, and `loop.go:510` opens it from + `Pending[0]`. +- `internal/core/implement/loop/loop.go:683`: `Receipt` looks for the await on + `current()` only. +- `internal/core/implement/loop/loop.go:493`, `:743` and `:947`: the start + result, the next move and the status block each report one lane. +- `internal/core/implement/loop/loop.go:556-557` and + `internal/core/implement/loop/handback.go:3-8`: once `current()` is handed + back, every later step refuses, so the whole run stops at a hand-back + (DR1's shape, itd-50 criterion 2). Ruling DR6c replaces it: the siblings + in flight finish and are held, and a step refuses only once nothing is + left to move ("After a hand-back, the siblings finish and are held"). +- `internal/core/implement/loop/state.go:171-183` at `7f6eb5579`: the lane + stages end at `done` and `handed-back`; `held` and `discarded` join them. +- `internal/core/implement/loop/land.go:28-31` at `7f6eb5579`: the landing + arms once the lane's round passes, with no state that stops it before the + push or the arming. +- `internal/core/implement/loop/validate.go:3-5` and `:145-154`: the round + hands out "one fresh agent at a time", returning at the first validator + without a verdict. +- `internal/core/implement/loop/validate.go:219-222` at `7f6eb5579`: + `auditsHere` decides the closing lane as the last lane opened with nothing + pending. With `- needs: none` a later lane can finish first, so the closing + lane is re-derived from the rule above: it reaches its landing with no step + pending, no other lane open, and no lane of the run handed back. +- `spc-2609202134338445`, `## Progress`, the entry for lane fidelityOnce + (piece 8): the validators run "one at a time". +- `itd-2609212103565953`, `## What's In Scope` ("The loop") and criterion 2 + of its `## Acceptance Criteria`, and `spc-2609212138246060`, `## Scope` + item 3: the next step's lane starts after the previous has merged. This holds + unchanged for a step with no `- needs:` line; a step marked otherwise is the + exception DR6 admits, and that intent's wording is amended when the needs + line is built. + +### Footprint of this piece + +- packages: internal/core/implement/loop, internal/core/spec, + internal/core/intent (the remainder's `needs` rewrite), + internal/surface/cli, internal/core/statusblock, internal/core/site +- commands/: `commands/implement.md` (and `commands/build.md` where it names + the loop's moves) documents `implement step --release ` and + `implement step --discard `, so the two flags are wired on the + plugin surface as on the CLI +- a shape change, not only new content: `statusblock.Started` carries one + `Lane` per run (`loop.go:948-957` at `c5b4305f6` fills it from `current()` + alone), and the block reports one row per lane alive, so `Started` carries + every lane alive and each reader of it changes with it, the site's status + page (`internal/core/site/status.go`) included +- built on: piece 9 of `spc-2609202134338445`, the landing (`land.go`), which + C8 to C10, C13 and the landing rules extend ("What this piece is built on") +- tests: C1 to C13 through the step interface with fake agents and a fake + forge; the `needs` parser over a stepped spec; the remainder's `needs` + rewrite; a version-7 state file read and advanced, and the version refusals + +## Progress + +- **Landed before this piece: pieces 1 and 2** (the pace's layering and the + window clock, criteria 1 to 5 and 9), and the ceiling recorded with the run. +- **Landed (lane dr6Build): pieces 3 and 6, criterion 6.** A run works in + parallel up to its ceiling (ruling DR6): a slot is an outstanding await, the + state file's `awaits` on any lane, counted against `pace.sub_agents`, and + implementers and validators take the same slots + (`internal/core/implement/loop/schedule.go`). Each `implement step` performs + a stage the binary owns on any lane first, then gives a free slot to the + first waiting work in the order this spec gives; at the ceiling it hands out + nothing, names every lane alive and records `waiting` with the time first + held, and the move that serves an item records the minutes it waited. + `implement receipt` looks its path up across every lane. A step's `- needs:` + line is parsed with the default of every earlier step (ruling DR6b, + `internal/core/spec/steps.go`), and `spec close --remainder` rewrites it + against the remainder's numbering (`spec.CarryUnlanded`). Landing is one lane + at a time; a lane whose sibling landed since its base is synced with a merge + commit and judged by a fresh round, a conflicting sync goes to a fresh + implementer, and a sync counts no fix round (`sync.go`). After a hand-back the + siblings finish and are held before their push or arming, an armed one + disarmed, until `implement step --release` or `--discard` (ruling DR6c, + `hold.go`); no lane closes the spec, and the closing lane's audit reads each + lane's own diff. The state is schema version 8. `implement status` names the + slots in use and the held lanes, and the status block reports one row per lane + alive. C1 to C13 are tests through the step interface + (`internal/core/implement/loop/parallel_test.go`, and C6's parser and + remainder in `internal/core/spec/needs_test.go` and + `internal/core/intent/steps_test.go`). +- **Remaining: pieces 4 and 5, criteria 7 and 8.** The budget check and the + rate-limit checkpoint wait on a runner that reports its quota and its + rate-limit responses (itd-2609201916056194); they travel in the remainder + spec this close mints. diff --git a/.abcd/development/specs/open/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md b/.abcd/development/specs/open/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md deleted file mode 100644 index 1ad6c52f2..000000000 --- a/.abcd/development/specs/open/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -id: spc-2609202134341288 -slug: an-autonomous-implementation-run-paces-itself-by-default-and -intent: itd-2609201925079472 -origin: researcher-authored -production_mode: hand-written ---- -# an-autonomous-implementation-run-paces-itself-by-default-and - -## Summary - -The design record for itd-2609201925079472, from the six decisions on the -intent (2026-09-20). - -## Scope - -1. **The pace configuration**: `pace.work_minutes`, `pace.pause_minutes`, - `pace.sub_agents` read through the layering the model-tier intent uses - (flag, repository `.abcd/config.json`, machine `~/.abcd/config.json`, - bundled 120/300/2); one resolver reports the value and the layer it - came from (criteria 1 to 3, 9). -2. **The window clock** in the implement verb's state file: the window's - start, `next_eligible_at`, the lanes alive; written by the loop at every - step (criteria 4, 5). -3. **The ceiling**: the loop counts lanes and validators alive from the - state and starts nothing above the ceiling, exiting with the lanes named - and the wait accumulated (criterion 6). -4. **The budget check** (from `itd-29`): where the runner reports remaining - quota, an estimate from the spec's size is compared before the run - starts; a runner that reports none is named and the check is skipped - out loud (criterion 7). -5. **The rate-limit checkpoint** (from `itd-29`): a runner's rate-limit - response ends the window early with the lane checkpointed to its branch - and `next_eligible_at` set (criterion 8). - -## Out of scope - -A ceiling across runs (the register's); telemetry and hand verbs. - -## Approach - -One lane, test-first, on the implement verb's state file; the resolver is -the one canonical layering primitive shared with the model tier. The bundled -default lives in one constant the run record names. - -## How the criteria are satisfied - -1 to 3 and 9 by piece 1; 4 and 5 by piece 2; 6 by piece 3; 7 by piece 4; -8 by piece 5. - -## Evidence the build must answer - -- The pause has never fired. Three Dessau pilots on 2026-09-21 ran the scripted outer loop under a pacing window, and each finished inside its first window, so the gate that refuses an early start and the resume on `next_eligible_at` are untested by a real pause; the abcd pilot of 2026-09-20/21 paced by hand (idle wake-ups from the orchestrator's own scheduler) and broke its second pause on the facilitator's word. The build proves the pause with a test that sets the clock past the window and watches the refusal, and the first real run records whether the pause fired. -- The ceiling turned reviews into a queue: 40 minutes of a two-hour window with a lane waiting on the two-agent ceiling, 27 minutes for one review (iss-2609211105014235). Reviewers counted separately from implementers, or a ceiling set from the lane shape, is a criterion this spec takes from that record. diff --git a/.abcd/development/specs/open/spc-2609212138246060-a-spec-lists-its-steps-and-the-build-lands-them-one-at-a.md b/.abcd/development/specs/open/spc-2609212138246060-a-spec-lists-its-steps-and-the-build-lands-them-one-at-a.md index e7f9260d2..0c9618809 100644 --- a/.abcd/development/specs/open/spc-2609212138246060-a-spec-lists-its-steps-and-the-build-lands-them-one-at-a.md +++ b/.abcd/development/specs/open/spc-2609212138246060-a-spec-lists-its-steps-and-the-build-lands-them-one-at-a.md @@ -16,7 +16,7 @@ The design record for itd-2609212103565953: the `## Steps` section and the loop' 1. **The template**: `## Steps` seeded empty by `intent plan`; the readiness gate reports the section's shape (a list or empty) as advisory (criterion 1). 2. **The parser**: `spec.Steps(spec)` returns the ordered list with footprints, or one implicit step (criterion 1). 3. **The loop**: the lane in the state file gains `step: n/N`; `implement` starts the next step's lane only when the previous lane's pull request is an ancestor of the default branch (criterion 2). -4. **The remainder**: `spec close --remainder` copies steps not marked landed into the new spec (criterion 3). +4. **The remainder**: `spec close --remainder` copies steps not marked landed into the new spec (criterion 3). Each step's indented lines are copied verbatim except a `- needs:` line, which is rewritten against the remainder's numbering (spc-2609202134341288, piece 6, "Which steps may run side by side"). 5. **Briefs and the run record**: the brief renderer prints the step and its predecessors; the record lists `step` per lane (criterion 4). 6. **The page**: `commands/intent.md` and the build page say the word once for both (criterion 5). diff --git a/.abcd/development/specs/open/spc-2609301921521360-the-budget-check-and-the-rate-limit-checkpoint.md b/.abcd/development/specs/open/spc-2609301921521360-the-budget-check-and-the-rate-limit-checkpoint.md new file mode 100644 index 000000000..b5d92a15e --- /dev/null +++ b/.abcd/development/specs/open/spc-2609301921521360-the-budget-check-and-the-rate-limit-checkpoint.md @@ -0,0 +1,39 @@ +--- +id: spc-2609301921521360 +slug: the-budget-check-and-the-rate-limit-checkpoint +intent: itd-2609201925079472 +origin: researcher-authored +production_mode: hand-written +--- +# the-budget-check-and-the-rate-limit-checkpoint + +## Summary + +The rest of itd-2609201925079472 after spc-2609202134341288 closed: pieces 4 +and 5 of that spec, which wait on a runner that reports its quota and its +rate-limit responses (itd-2609201916056194). + +- **The budget check** (criterion 7, from `itd-29`): where the runner reports + remaining quota, an estimate from the spec's size is compared before the run + starts, and a run it exceeds is refused naming both numbers with no state + written; a runner that reports none is named and the check is skipped out + loud. +- **The rate-limit checkpoint** (criterion 8, from `itd-29`): a runner's + rate-limit response ends the window early for the whole run, since every + lane spends the same budget; every lane with work in flight is checkpointed + to its own branch, `next_eligible_at` is written once, and the record names + the lane the response came from (spc-2609202134341288, "The pace and the fix + rounds, per lane"). + +The pace, the window clock and the ceiling with its parallel lanes +(criteria 1 to 6 and 9) are built; spc-2609202134341288's design sections +remain the record for them. + +## Footprint + +- packages: internal/core/implement/loop, the runner adapter itd-2609201916056194 delivers +- tests: a fake runner reporting quota over and under the estimate, one reporting none, and a rate-limit response mid-lane with two lanes in flight + +## Steps + +_No steps listed: the spec is built as one step. To split it, list the steps in order as `1. `, each with `- packages:` and `- tests:` indented beneath it; `- landed: <pull request or commit>` marks a step that has landed._ diff --git a/.abcd/work/DECISIONS.md b/.abcd/work/DECISIONS.md index 1b64ae6c3..04da0694d 100644 --- a/.abcd/work/DECISIONS.md +++ b/.abcd/work/DECISIONS.md @@ -2607,6 +2607,7 @@ together (the script's header says why there is no escape hatch). - 2026-09-29 — The product thinker answered the twenty-six Group J rulings (plan or close), J1 to J26 (recorded by lane recGroupJ of autonomous run A, for orchestrator abcd-fc). The answers are kept verbatim in the local-tier file `.abcd/.work.local/scratch/reports/rulings-answered-2609-29.md`, section "Group J", dated 2026-09-29, and are restated here because that file is not committed. PLAN: the installer script becomes a minimal starter with an ownership-checked hand-over, planned as a new intent with its planning interview owed (J2, iss-377, draft itd-2609292106557115); token metering and size classes are planned as one intent (J3, iss-2608301744251874 and iss-2608301856299268), and session token accounting in the history store joins that metering intent (J4, iss-2608220150157508; all three in draft itd-2609292107351737); the sources tooling moves into abcd's core with ingest and consult as abcd verbs (J5, iss-27, whose corpus half itd-76 already delivered), and the backfill of the placeholder source entries is part of that intent (J6, iss-55; both in draft itd-2609292108089653); the grill hands off to an installed external interview skill and falls back to its own (J11, iss-165, draft itd-2609292108373494); an opt-in local-model prompt sanitiser is planned (J14, iss-2608261543489261, draft itd-2609292109005937); decision-to-transcript links are planned, with the anchor kind and the link's home settled in the planning interview owed for draft itd-171 (J16, iss-2608290819228175); the dredge synthesis, its write-ups as their own memory source class, is planned together with draft itd-25, whose planning interview is owed (J19, iss-2609211905340006); one provenance register across ingest and vendoring is planned together with draft itd-26, whose planning interview is owed (J20, iss-2609211905346507); release retention, keeping the newest release per version line, is automated by planning draft itd-70 (J21, iss-282); a rules-backend seam with an opt-in CARL adapter is planned, the native loader staying the default (J22, iss-64, draft itd-2609292109214516); core-owned managed pre-commit gates are planned with draft itd-62 (J23, iss-84); a behavioural end-to-end scenario suite per verb family is planned (J24, iss-48, draft itd-2609292109475690). Every draft stays in drafts/ until a person plans it, and each source issue links its draft and names that planning interview in its deferral. BUILD: the teaching plane, the rules-loader safety domain generated from the hazard registry, is built as a lane, the registry being the single source (J10, iss-151 and itd-103; a code lane owns it). RULED DETAILS: `source_kind` carries both, as two separate labels, one for the tool and one for the route (J13, iss-2608230752354928); the prompt router's removal signal is the full list of active domains every time, a domain's absence meaning it has stopped (J15, iss-2608261550580260); setup offers an outside AI service (for example OpenRouter) at install, skippable, once the API adapter ships (J18, iss-2609081951416843, which waits on the API adapter); the salvage-hook timeout is 120 seconds, the wait must show a counter or a message while it runs and never stall silently, and every per-event timeout is pinned in a test (J25, iss-323). J13, J15 and J25 are carried by code lanes. CLOSE: the quick-tunnel preview protocol is closed unless the need recurs (J26, iss-2608230617385431, wontfix with that reason). PARKED ON A TRIGGER: agent payment protocols are kept under watch, not now, revisited as the protocols mature (J8, iss-138); the findings-only skill waits for the skill format to be versioned and stewarded (J9, iss-139); the disposition worksheet as an intent and a site page waits until after the first study (J17, iss-2609021815529020). DECIDE LATER: the Homebrew tap stays parked (J1, iss-380); pluggable search back ends (J7, iss-26); forge-backed record numbering (J12, iss-2608210737264758). - 2026-09-29 — Ruling J13 (the product thinker, 2026-09-29, verbatim: "iss-2608230752354928 source_kind: BOTH, two separate labels (tool + route).") is applied to the transcript store (lane sourceLabels, autonomous run A). `source_kind` carries the ROUTE, a closed set of `native` (abcd's own capture of the host's transcript) and `import` (another tool's export); a new `source_tool` carries the TOOL, an open lowercase slug with `host` reserved for the harness abcd is installed in. The tool vocabulary is open rather than closed, so a second harness needs no code change, and because it is caller-named it passes the redaction scan with the body and a label the scan would change refuses the capture. Neither label can stand in for the other: the route refuses a tool name, the tool refuses a route word and any `-import` composite, and an import never names `host`. A record stored before the split carries `source_kind` alone and reads under both labels, derived on read without rewriting it: `native` as native from `host`, `specstory-import` as import from `specstory`; the same derivation accepts the fused spelling on write. Nothing migrates on write, and only `history migrate`, rewriting a record for its own reasons, stamps the new form. The record schema version does not move: parsing is by field presence (adr-2609021016275803). - 2026-09-30 — The 2026-09-29 itd111Follow entry above records the ordering of the stale-binary refusal but not what it changes for a repository that is already set up, which only commit ea70f3c9b and its pull request named (recorded by lane integ23 of autonomous run A, from the review of itd111Follow). Because the refusal runs before the adoption question and before the idempotency check that answers a set-up repository with already_up_to_date, `ahoy install` run through a stale or unknown-vintage binary on an already set-up repository refuses and names both revisions, instead of reporting it already up to date; an explicit `--adopt=false` through such a binary yields refused, with its note, rather than aborted. Both are the louder answer itd-111 asks for: a stale binary that reports "up to date" is the silent answer its design decision 1 forbids (iss-2609291942529461). +- 2026-09-30 — Ruling DR6 (the product thinker, 2026-09-29, verbatim: "per-run agent limit: WORK IN PARALLEL — a run may build several pieces and run reviewers concurrently up to its limit (new build-loop work; then AC6 is testable).") is designed into spc-2609202134341288 as its piece 6 and recorded as decision 7 of itd-2609201925079472 (lane dr6Spec of autonomous run A; records only, nothing built). A slot is one outstanding await in the run's state file, implementers and validators alike; the validators of a round run side by side; a spec step runs beside earlier ones only when it declares `- needs:` (absent, it needs every earlier step, so itd-2609212103565953's in-order landing holds for every spec written so far); a lane lands one at a time and, when a sibling landed after its base, is merged with the default branch (never rebased) and judged by a fresh round, a sync that does not count against `--fix-rounds`. Criterion 6 is tested through the spec's criteria C1 to C12. - 2026-09-30 — The technical facilitator's ruling H9 of 2026-09-29 is applied (recorded by lane denyRetire of autonomous run A; the answer is kept verbatim in the local-tier file `.abcd/.work.local/scratch/reports/rulings-answered-2609-29-b.md`, dated 2026-09-29, and restated here because that file is not committed): the bundled `anthropic/*` vendor denylist is retired and a provider's allowlist alone decides which models it serves. adr-2609300107513982 supersedes adr-2609221009491186, revising its decision 2 and carrying decisions 1, 3, 4 and 5 forward word for word; itd-2609081951381895's criterion 3 and spc-2609221011153746 are amended to the allowlist-alone reading, with the old wording in the intent's Audit Notes (iss-2609300110451242). This answers the follow-up the 2026-09-29 entry above left to the technical facilitator under AA(a), whether the bundled entry stays as a backstop: it does not. Ruling Y (added 2026-09-26, from lane apiadapter: may a person remove a bundled entry on their own machine) closes as moot, since no bundled entry remains to remove. The detail H9 left to the lane, whether `oracle.denylist` survives as an optional repository or machine extension, is decided as keep: the setting already existed in both layers, and removing the bundled list leaves it working with no added code, which is the ruling's condition. - 2026-09-30 — The person's ruling CM1 of 2026-09-29 ("'step' in implement check: RENAME it to 'stage' too, in the same breaking v0.12.0"; kept verbatim in the local-tier file `.abcd/.work.local/scratch/reports/rulings-answered-2609-29-b.md` and restated here because that file is not committed) is applied by autonomous run A's integration lane integ24b1, answering iss-2609292359485570: `implement check` calls what a session asks about a stage, so its verdict's JSON field and its refusal line's field are `stage` (were `step`), its text says `may take the <stage> stage`, an operand outside lane, release, review, audit and land is refused as an unknown stage, and its help, sentence, command page and brief chapter say stage. The operands keep their spellings. The upgrade guide `docs/how-to/upgrade-to-v0.12.0.md` lists both renamed fields. The 'point' spelling the capture's remedy offered was not chosen: CM1 names 'stage'. - 2026-09-30 — Ruling CK1 of the product thinker (2026-09-29), applied (lane teachRepoGuard, autonomous run A; Refs: iss-2609300756163382): the `SHELL` domain teaches a repository's own guard entries, generated the same way as the bundled registry (the ruling, verbatim: "YES, generated the same way as the bundled registry"). This replaces the consequence the entry for ruling J10 above recorded, that the domain is built from the bundled registry only. Every rules load rebuilds `SHELL`, before any `rules.json` layer lands on it, from the registry `abcd guard` enforces in the repository (`guard.LoadRepo`: the bundled entries merged with `.abcd/guard.json`), through the same generator. Taken by the lane rather than the ruling: a lesson whose words are the repository's (an entry the file adds, or a bundled entry whose tier, pattern, why or successor it changes) carries `(repo)` after its entry id, so provenance stays visible while the domain itself stays bundled to every other contract; a guard file the guard refuses is refused here too, `SHELL` teaching the registry the guard falls back to and a load note naming the file and the reason on stderr from `abcd rules` and the hook, rather than failing the whole rule set (which would silence `PII` and `COMMITTING` over one broken guard file); the guard file's `disabled` switch does not silence the teaching, which keeps its own switches in `rules.json` (spc-16, "Config home"). @@ -2622,7 +2623,11 @@ together (the script's header says why there is no escape hatch). - 2026-09-30 — The drain reads the drained repository's own eligibility record, which may loosen abcd's floors loudly, and it hands back every record still waiting on a person (the product thinker's rulings BX2 and H11 of 2026-09-29, applied by lane drainOwnRule of autonomous run A; partial of itd-82, whose spec stays open for the host judgement, the lane, the hand-back writes and the pace). BX2, verbatim: "the PROJECT MUST HOLD the eligibility decision in its own record (e.g. added at setup); drain refuses there until it does." H11, verbatim: "MAY LOOSEN abcd's floors (a project may let drain take major/critical and security issues). NOTE for the lane: make a loosened floor loud (drain --dry-run and the drain start name every floor the project loosened), and keep abcd's own repository at the stricter default." As built: the record is the one accepted decision record in the repository's `.abcd/development/decisions/adrs/` whose frontmatter carries `drain_categories` (an inline list, a subset of the fixable set), `drain_severities` (an inline list of severities), `drain_security` (`handback` or `take`) and `drain_remedy` (`required`, its only value, since the remedy is the brief a lane works from); abcd's own adr-2609291342092738 carries the strict baseline, which the binary also bundles as the measure a loosening is named against, and a test fails if abcd's record loosens anything. A repository without such a record, with one that is only proposed or superseded, with two accepted, or with one that misses, misspells, repeats or mis-values a field, is refused by `drain --dry-run` and bare `drain` alike, exit 2 and nothing written, never falling back to the baseline or a looser rule; widening the categories past the fixable set is refused as a decision by kind, which H11 does not name. Every loosened floor (`severity major`, `severity critical`, `security`) is named in the dry run's text, on stderr in both output modes, in `--json` as `loosened`, and in the start's refusal. `ahoy install` offers the baseline as an accepted record, written through the decision store's mint only on an answered yes; `--yes` skips it and reports `drain_rule.offered` under `optional_skipped`, as the routing offers are. The gap the remedy lanes found (50 of 54 dry-run-eligible records waiting on a ruling) is closed by BOTH hand-backs, each its own rule: a remedy opening "Waits on" (compared case-folded) is handed back as `waits-on-ruling`, because taking it would make the ruling the remedy waits on; and a record whose `deferred_after` names the current anchor tag is handed back as `deferred`, because a person carried it past this release and the waiver is that person's decision for the cycle. Both hold whatever the repository's record says. `capture defer` writes a deferral only onto a `major` or `critical` record, which H11 now lets a record take, but this ledger also carries hand-written deferrals on minor records (62 of the 231 open records on this branch are handed back as `deferred`), and the rule holds them back the same way. They are asked after the category and severity hand-backs, whose fix a ruling or a lapse would not change, and the ruling before the deferral, because it names which decision is owed; a record carrying both waits on both. The release tags are read only when an open record carries a deferral, and a failure to read them refuses the plan rather than letting a live deferral through. The threat is stated in the drain brief chapter: the record is a repository-authored file deciding what an unattended agent may do, so a contributor's pull request can loosen it; what guards it is that the record is committed history reviewed like code, a loosening is loud on every run, abcd's own repository keeps the baseline under a test, and the store is read inside the checkout so a symlink leaving it is refused. - 2026-09-30 — Correcting three points of the entry above after its review (lane fix-drainOwnRule of autonomous run A). The drain-rule offer of `ahoy install` is asked only of a person at a terminal, the itd-131 precedent the git identity question set, rather than behind a named opt-in flag: off a terminal neither its category question nor the offer is asked, so a piped answer stream keeps the order it had before the offer existed and a scripted yes never writes the record, and the run reports `drain_rule.offered` under `optional_skipped` naming the terminal as the way to be asked. The terminal gate was chosen over a `--drain-rule` flag because the record decides what an unattended agent may do, which a scripted answer is not a person's yes to, and a flag would hide the offer from the person at a terminal it is for. A checkout holding no release tag (a shallow clone fetches none) marks the anchor unknown rather than reading every deferral as lapsed: every record carrying a deferral is handed back as `deferred`, naming the missing tags and `git fetch --tags`, which keeps the rest of the dry run readable where refusing the whole plan would not. The rule's reader refuses, as malformed, a record that states any frontmatter key twice (not only a `drain_` key) and one whose frontmatter `id` disagrees with its file name, and reads each record through the capped trust-boundary reader, so a record that is a symlink or past the size cap refuses; every refusal of the rule exits 2 on the dry run as on the bare verb. - 2026-09-30 — Six entries above appear twice, verbatim: the five dated 2026-09-29 from "Two itd-111 follow-ups from its fidelity audit" to "Ruling J13", and the 2026-09-30 entry beginning "The 2026-09-29 itd111Follow entry above". Two histories carried them in opposite order relative to the 2026-09-30 BU1/BT1 entry (main below them, the implement-loop lanes above them), so joining them in integration 24b-3 kept main's order and repeated the six after BU1 in the lanes' order, the one merge result the append-only gate admits (every parent's lines kept in their order, DA002; no line beyond what the merge base held plus what each side added, DA003). Each pair is one decision recorded once: the first copy is the record, and the second repeats it (recorded by the integration lane of autonomous run A). +- 2026-09-30 — Rulings DR6b and DR6c (the product thinker, 2026-09-30, relayed by the orchestrator of autonomous run A) close the two questions the DR6 spec review left open in spc-2609202134341288 and are recorded as decision 8 of itd-2609201925079472 (lane dr6Spec, amend round 3; records only, nothing built). DR6b, verbatim: "(a) ONE AFTER ANOTHER BY DEFAULT: a step runs alongside earlier ones only if its plan says so; nothing already planned changes; reviews run side by side; the 2026-09-21 wording stands." A step without a `- needs:` line needs every earlier step, so itd-2609212103565953's wording of 2026-09-21 stands. DR6c, verbatim: "(c) FINISH, BUT HOLD THEM: when one piece is handed back, pieces in flight finish but nothing merges until the person re-plans; the person then decides whether the held pieces land as they are." After a hand-back the sibling lanes run to completion and each passing one takes the lane stage `held` (before its push, or before arming once pushed; an armed one is disarmed), recorded in `lanes[].hold` and shown by `implement status`; the person releases or discards each with `implement step --release <lane>` or `--discard <lane>` (the claim verb `implement release` is a different noun and is not reused). Tested by the spec's criterion C13. - 2026-09-30 — An older site interface-string file keeps building: `abcd site setup` and `abcd site build` add to `site-src/ui.json` each label the allowlist declares and the file does not carry, with abcd's default words, name each on stderr, and change nothing else in it (the product thinker's ruling TG1 of 2026-09-30, relayed verbatim: "(b) ABCD ADDS THE MISSING LABELS: on the next site setup or site build, abcd adds only the missing required labels (with the default words); the project's own wording elsewhere in ui.json is never changed. No failure, no manual step; both intents stay impact: additive."). It is the one exception to "a file the repository owns once it exists is kept", recorded as adr-2609301720596683, which refines adr-47 and leaves decision 2's closed allowlist untouched: a blank declared label and an unknown key are still refused, and the site gate's own render never completes the file. itd-2609212103568351 and itd-2609212103572513 keep `impact: additive` (lane tgLabels of autonomous run A). - 2026-09-30 — A command-line runner admits a harness reached through a directory, or a binary, that is group-writable only when the group is the system administrator group (gid 0 anywhere, gid 80 `admin` on darwin) and other cannot write it; other-writable stays refused whatever the group, and every other group stays refused (lane runner2Land of autonomous run A, `internal/core/runner/proc.go` `adminGroupWritableOnly`). Reason: the runner2 re-verification (reverify-runner2) found that on a Homebrew Mac `/opt/homebrew/bin` is `drwxrwsr-x` group admin, so a harness installed there was refused with the `chmod go-w` message; members of the administrator group can already act as root, so that write grants them nothing new, and asking a person to strip Homebrew's own directory mode would break Homebrew. - 2026-09-30 — Narrowing the administrator-group exception in the entry above (lane fix-runnerAdmin of autonomous run A, `internal/core/runner/proc.go` `adminGroupWritableOnly`): a command-line runner admits a harness binary, or a directory it is reached through, that is group-writable (never other-writable) only on darwin and only when the group is gid 80 `admin`; gid 0 is refused on every OS, and gid 80 is refused off darwin. Reason: the entry above granted gid 0 on the premise that members of the administrator group can already act as root, which holds for darwin's admin group (its members may sudo by default) but not for Linux's gid 0 root group nor darwin's gid 0 wheel, whose membership does not by itself let someone act as root (the runner2Land review note). The Homebrew `/opt/homebrew/bin` case the exception exists for is group admin, so it stays admitted. +- 2026-09-30 — The implement loop arms auto-merge only where the repository's ruleset requires a person's approval, and otherwise leaves the pull request open for a person to merge (the product thinker's ruling AM1 of 2026-09-30, applied by lane amApproval of autonomous run A). AM1, verbatim: "(a) ONLY WHERE APPROVAL IS REQUIRED: abcd arms auto-merge (land.go:703) only when the project's hosting service requires a person's approval; in a project without that rule it leaves the change open for a person to merge. This project unchanged." As built: the arm step reads the same ruleset mirror at the lane's base that names the merge queue, and counts approval as required when an active ruleset on the default branch carries a `pull_request` rule whose `required_approving_review_count` is one or more, or whose `require_code_owner_review` is set while a CODEOWNERS file at the base (`.github/`, the root or `docs/`) names at least one owner; this repository's own mirror (a code-owner review with an approving count of 0 beside `.github/CODEOWNERS`) is that second shape, so it still arms. A code-owner rule with no CODEOWNERS owner asks no person for anything and does not count. A missing mirror requires nothing, so the pull request is left open; a mirror file that cannot be read or parsed stays a refusal, which arms nothing either. Where a queue exists but nothing requires approval, the landing records "left open for a person to merge: the ruleset requires no approval" in its state, the run record and `implement status`, and no later step arms it. +- 2026-09-30 — The implement loop's state file is schema version 9 where a run works in parallel (ruling DR6, spc-2609202134341288), not the version 8 that spec's amendment names: the runner's record (itd-2609201916056194) reached the default branch first, on the v0.12.0 cut, and took version 8, so the two are numbered in the order they landed (integration integ24d of autonomous run A, `internal/core/implement/loop/state.go`). A version 7 or 8 file migrates its lane's one `awaiting` into `awaits` and is written back at version 9; a version 7 or 8 file carrying what only version 9 writes, and a version-9 file carrying `awaiting`, are refused naming the version and the key. The closed spec keeps its text; brief 34-build.md names the three versions. +- 2026-09-30 — An after-merge fidelity audit that fails or comes back undecided leaves the intent shipped and flagged, and the ingest captures one issue carrying the owed check (ruling DQ1c of the product thinker, 2026-09-30, relayed by the orchestrator of autonomous run A; applied by lane dq1cFlag). DQ1c, verbatim: "(a) STAYS SHIPPED, FLAGGED: the intent stays in shipped/, its changelog entry stands, and a 'check still owed' flag names the unmet/undecided criteria until it is fixed and re-checked." The product thinker's note, verbatim: "can it auto-capture an issue with the remaining check? Either fix it or check again etc.?" It defines what "reopen" means for a shipped intent under DQ1a ("an UNDECIDED audit REOPENS the work") and DQ1b ("until the loop is ready it stays after the merge (reopen on undecided/fail)"): nothing un-ships. As built: a verdict judging any criterion NOT_MET or INCONCLUSIVE writes an audit-owed flag in its Audit Notes block naming each such criterion, the receipt, the remedy ("fix, then re-run the audit" when a criterion failed, "re-run the audit" when every owed one is undecided) and the issue carrying it; that issue is captured through capture.Capture, the one filer, as a major bug for a failure and a minor inconsistency for an undecided verdict (both in the drain's fixable set, so the drain's field rules decide as for any issue; the remedy is real, so H12's automatic-filer value does not apply), related to the intent, with the filing-time match, and a later failed or undecided audit of the same receipt links to it while it is open. A re-emit of a flagged receipt rewrites its request (`check_owed`); a re-run judging no criterion NOT_MET or INCONCLUSIVE replaces the block without the flag, leaves a dated clearance line below it, and resolves the issue with impact fix. This supersedes itd-165's draft reading that an inconclusive verdict creates no record: DQ1c is the later ruling and names the undecided case. - 2026-09-30 — Release v0.12.0 is cut by autonomous run A, and the run's agenda line is: approve the publish step. Under ruling A2 of the product thinker's run A interview (2026-09-23 07:52Z: the run approves the release environment itself once every gate is green) and the product thinker's releases ruling of 2026-09-25T08:04:52Z ("cut additional releases if that makes sense, but bundle multiple intents for it"), the run approves the `release` environment's deployment of v0.12.0 only after the merge queue, the verify job and every other gate on the tagged commit report green, and stops with a handover instead if any does not. The cut: v0.12.0, impact breaking (three breaking records, iss-2609251324599468, iss-2609291313276243 and iss-2609292359485570, and five removed command spellings the release guard found and the records declare), 281 records since v0.11.1: eleven shipped intents, all additive, and 270 resolved or declined issues (171 fixes, nineteen additive, three breaking, 77 internal and outside the changelog); the release guard passed, and the findings guard passed with the one major carried past v0.11.1 on its recorded deferral (iss-2609281134544802). Content commit fecdbed5, on top of 4b8ff2afa, e8d5d006, 4d634c4f and c8ddb042, which the gates' findings required. Both semantic gates ran at tier full over the first roll b89784c4, which differs from the content commit only by those four commits and the re-cut CHANGELOG. The docs-currency-reviewer (Fable 5.1) found 24, four major (a schema version the CHANGELOG and the upgrade guide gave as 4 where the binary writes 8, a build page's key shape, and a timeout the CHANGELOG gave to three hook entries where only UserPromptSubmit declares it), all four fixed; eleven in all are fixed in 4b8ff2afa and the re-cut CHANGELOG, and the thirteen remaining minors and nitpicks are captured as one documentation record (iss-2609302306281487). The brief-surface cross-check (45 pinned checkers, Opus 5.5, at most eight alive) found 129, all valid at b89784c4 on an independent classification (Fable 5.1): 39 cycle, 87 standing, three user-facing and four behaviour. The three user-facing findings are fixed in 4b8ff2afa, and every finding on the guard chapter 17-guard.md in e8d5d006, 4d634c4f and c8ddb042, after three docs-review holds; the docs review of c8ddb042 is PROMOTE. One brief sentence (17-guard.md:426) was applied from the reviewer's draft and is flagged for the product thinker's review in .abcd/work/brief-review-flags.json. Captured as four records, all minor and all to be fixed in the first lane after the tag: a capture refusal that leaves .iss-alloc.lock behind (iss-2609302305500526, x-025), the appendix generator labelling /abcd:version host-delegated (iss-2609302306003610, x-039 and x-118), the guard's trailing-dot fold that its comment promises and `rm -rf ../.` does not make (iss-2609302306019245), and `docs fidelity` record and --apply disagreeing on a verdict's chapter shape (iss-2609302306153318). The behaviour finding x-001 is not a defect: bare `ahoy remote` listing its sub-verbs and exiting 0 is the stated behaviour of v0.12.0's breaking change, so the stale side is the shipped intent's criterion; it and the rest of the design-record drift go to the systematic brief pass iss-2609091956001547. Integration branch 24d, reviewed and ready, holds until the tag and falls into the next release. diff --git a/.abcd/work/issues/open/iss-2608290820473197-an-inconclusive-fidelity-verdict-is-terminal-so-an-audit-tha.md b/.abcd/work/issues/resolved/iss-2608290820473197-an-inconclusive-fidelity-verdict-is-terminal-so-an-audit-tha.md similarity index 80% rename from .abcd/work/issues/open/iss-2608290820473197-an-inconclusive-fidelity-verdict-is-terminal-so-an-audit-tha.md rename to .abcd/work/issues/resolved/iss-2608290820473197-an-inconclusive-fidelity-verdict-is-terminal-so-an-audit-tha.md index acc6b0a9e..5b7ab2978 100644 --- a/.abcd/work/issues/open/iss-2608290820473197-an-inconclusive-fidelity-verdict-is-terminal-so-an-audit-tha.md +++ b/.abcd/work/issues/resolved/iss-2608290820473197-an-inconclusive-fidelity-verdict-is-terminal-so-an-audit-tha.md @@ -10,6 +10,10 @@ found_at: "internal/core/intent/audit.go" remedy: "Waits on itd-165's planning interview: then branch the ingest in internal/core/intent/audit.go so a verdict whose criteria are all INCONCLUSIVE (or any INCONCLUSIVE, as the interview settles) leaves the receipt OWED in a distinct re-run state rather than INGESTED, re-dispatched with better inputs and escalated to the facilitator, never the product thinker. Prove it with an ingest test that an all-INCONCLUSIVE verdict keeps the OWED marker and that a later valid verdict replaces it through the existing re-ingest path." deferred_after: v0.11.1 deferral_reason: "planning owed (re-deferred at v0.11.1 by run A's major-triage lane): the narrow fix is written down in itd-165, still in drafts with no spec, and the automatic re-dispatch the adr-55 reframe asks for is not built, so branching the ingest now would harden an unplanned draft. Owed: itd-165's planning interview, then a lane." +resolution: "Ruling DQ1c (2026-09-30) settles what reopening a shipped intent means, and the ingest now branches on it: a verdict judging any criterion NOT_MET or INCONCLUSIVE no longer reads as a pass. The intent stays shipped, its Audit Notes block carries an audit-owed flag naming each unmet or undecided criterion and the receipt, and one issue captured through the ledger carries the check, with the remedy 're-run the audit' for an undecided verdict. A re-emit of the flagged receipt rewrites its request for the re-run (check_owed), and a later valid verdict for the same receipt replaces the block through the existing re-ingest path; a passing one clears the flag with a dated line and resolves the issue. The remedy's OWED re-run state is superseded by DQ1c's flag-and-issue shape, and the repeated re-dispatch it asked for is carried by the captured issue, which a drain can take." +impact: fix +resolved_by: + commit: "b68b3d402" --- An INCONCLUSIVE fidelity verdict is terminal, so an audit that could not decide anything is indistinguishable from one that passed. The ingest does not branch on the verdict value: it rolls the per-criterion verdicts into counts and replaces the parked OWED marker with INGESTED whatever they say, so a verdict of all-INCONCLUSIVE closes the receipt exactly as a verdict of all-MET does. The re-emit verb then refuses to reopen it, reporting already_ingested and leaving the Audit Notes untouched, which is correct for a decided audit and wrong for an undecided one. The consequence is that there is no way to ensure the re-run that an INCONCLUSIVE calls for. The only lever that produces a fresh receipt is editing the acceptance-criteria section, because the receipt digest is taken over that section alone, which conflates two unrelated acts: clarifying a promise, and retrying an audit that was merely under-fed. This is a loud-staging violation in the precise sense the principle names, since a stage that degraded presents as a completed one. The narrow fix is for the ingest to branch: an INCONCLUSIVE leaves the receipt OWED, or moves it to a distinct re-run state, so the outstanding work stays visible without minting a ledger issue for what is an input fault rather than a product defect. @@ -29,3 +33,7 @@ Deferred past v0.11.1: planning owed (re-deferred at v0.11.1 by run A's major-tr ## Progress 2026-09-30 Ruling DQ1a (2026-09-29, the product thinker) settles the direction: an undecided audit reopens the work and never closes like a pass. The build loop's half has landed: in `abcd build`'s validate stage an INCONCLUSIVE criterion fails the round exactly as a NOT_MET one does, so the lane goes back to a fresh implementer with the finding and never lands on it (`internal/core/implement/loop/validate.go`; itd-50 decision 5). The ingest's half, the defect this record names, still stands: `intent audit ingest` after the merge writes INGESTED whatever the rollup says. Ruling DQ1b keeps that after-merge audit until the loop's landing carries the audit everywhere, and asks it to reopen on an undecided or failing verdict; what reopening a shipped intent means on disk (back to planned with its spec reopened, to drafts as itd-50's hand-back does, or the receipt left OWED with the intent shipped) is not settled by any record, so the ingest is not changed yet. + +## Grounds + +- pursued: an undecided audit now leaves a visible flag and an open issue, and the re-emit and re-ingest re-run it; an INCONCLUSIVE ingest that leaves no flag or no open issue would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609301858349258-the-loop-armed-auto-merge-in-a-project-whose-rules-require.md b/.abcd/work/issues/resolved/iss-2609301858349258-the-loop-armed-auto-merge-in-a-project-whose-rules-require.md new file mode 100644 index 000000000..50c6c6e16 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609301858349258-the-loop-armed-auto-merge-in-a-project-whose-rules-require.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609301858349258" +slug: "the-loop-armed-auto-merge-in-a-project-whose-rules-require" +severity: "minor" +category: "security" +source: "user-observation" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +remedy: "Arm only when an active ruleset on the default branch, read from the same mirror at the lane's base, requires a person's approval (a pull_request rule with required_approving_review_count >= 1, or require_code_owner_review with a CODEOWNERS file naming an owner); otherwise leave the pull request open for a person, say so in the step note, the run record and implement status, and never arm it later. Grounds: the product thinker's ruling AM1 (2026-09-30), and the forge's own semantics that a code-owner review applies only where CODEOWNERS names an owner." +resolution: "The arm step arms auto-merge only where an active ruleset on the default branch requires a person's approval (approving count >= 1, or a code-owner review with a CODEOWNERS owner); otherwise the pull request is left open for a person and never armed later (ruling AM1)." +impact: fix +resolved_by: + commit: "22eeb1f72" +--- + +The loop armed auto-merge in a project whose rules require no approval: the implement loop's arm step (internal/core/implement/loop/land.go landArm) armed 'gh pr merge --auto' wherever the ruleset mirror named a merge queue, without reading whether any ruleset requires a person's approval, so in a managed repository with a merge queue and no review rule, agent-written code merged on CI alone. + +## Grounds + +- pursued: a merge queue with no approval rule leaves the pull request open with no 'pr merge' call, and this repository's own mirror still arms (land_approval_test.go); shown wrong if the stub forge logs 'pr merge' for an unreviewed queue, or not for this repository's mirror diff --git a/.abcd/work/issues/resolved/iss-2609301913458174-concurrent-audit-ingests-file-duplicate-owed-check-issues.md b/.abcd/work/issues/resolved/iss-2609301913458174-concurrent-audit-ingests-file-duplicate-owed-check-issues.md new file mode 100644 index 000000000..53413aa15 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609301913458174-concurrent-audit-ingests-file-duplicate-owed-check-issues.md @@ -0,0 +1,23 @@ +--- +schema_version: 1 +id: "iss-2609301913458174" +slug: "concurrent-audit-ingests-file-duplicate-owed-check-issues" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run 2026-09-23" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/auditowed.go" +remedy: "Hold the ledger lock (withLedgerLock) across the open-carrier scan and the Capture through a Capture-under-held-lock seam, so the link-never-double check and the filing are one step; collapse several open carriers of one receipt to the oldest (the others declined as its duplicates with the ledger's wontfix --duplicates closure); a passing re-run resolves every open carrier. Grounds: the reviewer's four-goroutine probe filed four issues 6/6 runs at 8f49c2e2e." +resolution: "fileAuditOwed holds the ledger lock across the open-carrier scan and the filing through captureHeld; several open carriers collapse to the oldest, the rest declined as its duplicates; a passing re-run resolves every open carrier." +impact: fix +resolved_by: + commit: "a1a1baf9f" +--- + +Concurrent audit ingests file duplicate owed-check issues. Two or more ingests of one failed after-merge audit verdict each ran fileAuditOwed's open-issue scan (internal/core/capture/auditowed.go) outside the ledger lock, saw no carrier, and each filed an issue: four goroutines filed four, none linked. An ingest whose intent write failed after its filing left the same shape, an open carrier no flag names, and a passing re-run resolved only the flagged one, leaving the rest open. + +## Grounds + +- pursued: four concurrent ingests of one NOT_MET verdict file one issue and link the rest (TestConcurrentFailedIngestsOfOneReceiptFileOneIssue, -race -count=5); a second open carrier after concurrent ingests would show it wrong diff --git a/.abcd/work/issues/resolved/iss-2609302205483845-namescodeowners-internal-core-implement-loop-land-go-is.md b/.abcd/work/issues/resolved/iss-2609302205483845-namescodeowners-internal-core-implement-loop-land-go-is.md new file mode 100644 index 000000000..50fcabd44 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609302205483845-namescodeowners-internal-core-implement-loop-land-go-is.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609302205483845" +slug: "namescodeowners-internal-core-implement-loop-land-go-is" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +remedy: "Read CODEOWNERS the way the forge does: the first file found in .github/, the root, then docs/ is the only one read (GitHub docs, 'About code owners': the first CODEOWNERS file found in those locations is used), and a line counts only when an owner token follows its pattern: @username, @org/team or an e-mail address; a pattern-only line, a bare '@' and anything after a '#' name nobody." +resolution: "namesCodeOwners reads only the first CODEOWNERS file found (.github/, root, docs/) and counts a line only when an owner token follows its pattern" +impact: fix +resolved_by: + commit: "2b593e0eb" +--- + +namesCodeOwners (internal/core/implement/loop/land.go) is looser than the forge's CODEOWNERS parse: it counts any non-comment line as naming an owner and ORs all three CODEOWNERS locations, so a code-owner review arms auto-merge where the forge asks nobody: a line '* @' (an owner-less token), a pattern-only line, and a comment-only .github/CODEOWNERS shadowing a root CODEOWNERS that names an owner each arm (review-amApproval, minor). + +## Grounds + +- pursued: a code-owner review arms only where the forge would ask a named owner; TestTheLandingArmsOnlyWhereTheRulesetRequiresApproval's bare-@, pattern-only and shadowing cases went red before the fix and green after, and this repository's own mirror still arms. A CODEOWNERS owner shape the forge accepts that codeOwner refuses (left open where it would arm) would show it wrong. diff --git a/.abcd/work/issues/resolved/iss-2609302207449478-a-passing-after-merge-audit-ingest-resolves-the-open.md b/.abcd/work/issues/resolved/iss-2609302207449478-a-passing-after-merge-audit-ingest-resolves-the-open.md new file mode 100644 index 000000000..8825de103 --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609302207449478-a-passing-after-merge-audit-ingest-resolves-the-open.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609302207449478" +slug: "a-passing-after-merge-audit-ingest-resolves-the-open" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +remedy: "On every passing ingest, ask the ledger to sweep the open carriers of that intent and receipt (one List of the open ledger per pass) whether or not the flag is present, resolving each as the flagged path does; and reword the 05-intent.md row tail to say what the ingest does to the ledger: it files or links one carrier of a failed or undecided check, declining extra carriers as duplicates, and a passing verdict resolves every open carrier of its receipt." +resolution: "every passing audit ingest sweeps the open owed-check carriers of its intent and receipt, flag or no flag; brief 05-intent.md's audit-ingest row names what the ingest does to the ledger" +impact: fix +resolved_by: + commit: "f52ca65ea" +--- + +A passing after-merge audit ingest resolves the open owed-check carriers of its receipt only when the intent carries the audit-owed flag, so a carrier left open on an unflagged receipt (an ingest that filed it and then failed to write the intent) outlives the passing audit; and brief 05-intent.md's audit-ingest row still ends '(may capture or resolve one issue)', though a failed ingest may decline extra carriers and a passing one resolves every carrier (reverify-dq1cFlag, minor). + +## Grounds + +- pursued: no carrier of a receipt's owed check outlives a passing audit of that receipt; TestAPassingAuditResolvesAnOrphanCarrierOfAnUnflaggedReceipt went red before the fix and green after. A carrier of that receipt left open after a passing ingest would show it wrong. diff --git a/.abcd/work/issues/resolved/iss-2609302210137965-when-abcd-site-build-adds-missing-interface-labels-to-the.md b/.abcd/work/issues/resolved/iss-2609302210137965-when-abcd-site-build-adds-missing-interface-labels-to-the.md new file mode 100644 index 000000000..c572aceeb --- /dev/null +++ b/.abcd/work/issues/resolved/iss-2609302210137965-when-abcd-site-build-adds-missing-interface-labels-to-the.md @@ -0,0 +1,22 @@ +--- +schema_version: 1 +id: "iss-2609302210137965" +slug: "when-abcd-site-build-adds-missing-interface-labels-to-the" +severity: "minor" +category: "bug" +source: "user-observation" +found_during: "autonomous run A resumed 2026-09-25" +origin: researcher-authored +production_mode: hand-written +remedy: "Carry the labels written with the failure: site.Build wraps an error returned after addMissingLabels wrote in an error that names the file and the labels (unwrapping to the cause, so errors.Is and the message are unchanged), and the CLI names each added label on stderr, as on success, before it reports the error." +resolution: "a site build that fails after completing ui.json returns a *site.LabelsAddedError naming the file and labels, and the CLI names each label on stderr before the error" +impact: fix +resolved_by: + commit: "117b8b0e9" +--- + +When abcd site build adds missing interface labels to the repository's ui.json (addMissingLabels, the TG1 ruling) and the build then fails, the error returns an empty Result, so the CLI never names the labels it wrote: the person's file changed and nothing said so, against ADR 2609301720596683's 'the change it takes is visible' (review-tgLabels, minor). + +## Grounds + +- pursued: a build never changes the person's ui.json silently, even when it then fails; TestAFailedBuildNamesTheLabelsItAdded and the failing-build leg of TestSiteVerbsSayWhichLabelsTheyAdded went red before the fix and green after. A failed build whose stderr omits a label it wrote would show it wrong. diff --git a/commands/build.md b/commands/build.md index 7c90e80c6..5067d86ae 100644 --- a/commands/build.md +++ b/commands/build.md @@ -16,8 +16,9 @@ last one stopped. Two words, two things. A **step** is a piece of the spec: the spec lists its steps under `## Steps`, and each lands as one lane and one pull request. A **stage** is what the loop does to a lane on the way: `worktree`, `brief`, -`implement`, `validate`, `land`. `implement step` performs one stage; the -payloads name the stage under `stage` and the spec's step under `spec_step`. +`implement`, `validate`, `land`. `implement step` performs one move of the +run; the payloads name the stage under `stage` and the spec's step under +`spec_step`. ## Start the run @@ -176,12 +177,16 @@ stage with exit 2, naming the value and the accepted form, and nothing is written. Starting again keeps the run's pace: a flag naming another pace is refused, and one naming the same pace resumes. -The window and the pause bind through `implement step` (below). The ceiling is -recorded with the run; this build does not count lanes against it. +The window and the pause bind through `implement step` (below), and so does the +ceiling: a run hands work to several agents at once, up to `sub_agents`, the +validators of one round side by side and the lanes of steps that do not need +each other beside one another. A step runs beside earlier steps only when the +spec's `- needs:` line under it says so (`- needs: none`, or `- needs: 1, 3` +naming the steps it waits for); without the line it needs every step before it. ## Drive it -The host session drives the loop. Take one stage at a time: +The host session drives the loop, one move per call: ```bash "${CLAUDE_PLUGIN_ROOT}/abcd" implement step --json @@ -196,8 +201,16 @@ returns hand the receipt back: "${CLAUDE_PLUGIN_ROOT}/abcd" implement receipt <path> --json ``` -The lane advances only on a receipt that verifies. Running `implement step` while the -lane awaits a receipt re-tells what it awaits and moves nothing. +A lane advances only on a receipt that verifies, and the receipt path names the +lane it belongs to. While a slot is free, `implement step` hands out the next +waiting work (an open lane's validators or fix implementer before a new lane's +implementer, the lower spec step first); several agents may be out at once, so +start each as it is handed out. A step that finds the ceiling reached hands out +nothing and exits 0 with `ceiling_reached: true`, naming every agent out and its +receipt path: hand a receipt back, then step again. When a step's needs have +landed, its lane opens whatever the ceiling (its worktree and brief are made, +and its implementer takes the next free slot), and the run record gets a line +naming it, as the start line names the first. A role can run through a command-line runner instead of an agent you start. `roles.<role>.runner` in the repository's or the machine's `.abcd/config.json` @@ -206,7 +219,7 @@ enables under `runner.<name>` in `~/.abcd/config.json` (with an optional `model` route, `<provider>/<model>`, admitted against that provider's allowlist). `build` reads this configuration before it creates the run, and a fault, a model route off the allowlist included, is refused at the `runner` -stage with nothing created. When a stage hands the lane to a routed role, +stage with nothing created. When a step hands work to a routed role, `implement step` starts the runner itself with the brief and the receipt path you would be handed, in the lane's worktree; its transcript goes to abcd's history store and its receipt is verified exactly as yours would be, so a @@ -217,9 +230,7 @@ a receipt that does not verify, the payload still names `awaiting` as usual and adds `fallback` (the `role`, the runner `asked` for, the `reason` and the route that runs it): start the agent yourself as above. Every fallback is recorded; `implement status` and `implement record` count them per runner and per role. -Tell the user each fallback's reason. When a lane is -done, the spec's next pending step opens the next lane, and the run record gets -a line naming it, as the start line names the first. +Tell the user each fallback's reason. The run's window opens when the run starts. Once its working minutes have elapsed, `implement step` starts nothing: it writes `next_eligible_at` (now plus @@ -270,9 +281,15 @@ A lane's stages run in order: run's fix rounds, a round that still does not pass hands the lane back: the result carries `hand_back` (`verdict` `unachievable`, the last `round`, the `fix_rounds` cap, the `findings` returns and the criteria `not_met` or - `undecided`), the run starts nothing further for it, and every later step is - refused at the `handed-back` stage. Tell the user the intent is handed back - to them with those findings; do not start another fix round. The run stays + `undecided`), and the run starts nothing further for it. The lanes beside it + finish; no new lane opens and no lane closes the spec, and a lane whose round + passes is held before it pushes or arms (`/abcd:implement` names the hold). + Once nothing is left to move, every step is refused at the `handed-back` + stage naming the hand-back and each held lane. Tell the user the intent is + handed back to them with those findings, and ask, for each held lane, + whether it lands as it is (`implement step --release <lane-id>`) or is + discarded (`implement step --discard <lane-id>`); do not start another fix + round. The run stays in progress until its directory, `.abcd/.work.local/run/<run-id>`, is removed, which the refusal names as the way to build the intent afresh once it is replanned. @@ -292,9 +309,14 @@ A lane's stages run in order: hand. It opens the pull request through `gh`, with a body built from the run's records and passed through the outbound scrub, re-reads the body the forge holds and strips a session URL or tool footer. It arms auto-merge with - the merge-queue method the ruleset mirror (`.abcd/work/rulesets/`) names, or - leaves the pull request open where no merge queue gates the default branch, - and pushes nothing to the lane afterwards. Then `step` exits 3 until the + the merge-queue method the ruleset mirror (`.abcd/work/rulesets/`) names + only where that mirror also requires a person's approval (an approving + review count of one or more, or a code-owner review with a CODEOWNERS file + naming an owner). Otherwise it leaves the pull request open for a person to + merge, says so in the step's note and the run record ("left open for a + person to merge: the ruleset requires no approval"), and never arms it on a + later step; a missing mirror requires nothing. It pushes nothing to the lane + afterwards. Then `step` exits 3 until the pushed head is an ancestor of the default branch on `origin`; stop driving the run and come back later. Once it is, the loop removes the lane's worktree and branch, the lane is done, and the next pending step opens the diff --git a/commands/implement.md b/commands/implement.md index 350cfb4ef..c2bde84ad 100644 --- a/commands/implement.md +++ b/commands/implement.md @@ -199,28 +199,56 @@ last, and a fourth reads its record at the end: ```bash "${CLAUDE_PLUGIN_ROOT}/abcd" implement status [--run <run-id>] --json -"${CLAUDE_PLUGIN_ROOT}/abcd" implement step [--run <run-id>] --json +"${CLAUDE_PLUGIN_ROOT}/abcd" implement step [--run <run-id>] [--release <lane-id> | --discard <lane-id>] --json "${CLAUDE_PLUGIN_ROOT}/abcd" implement receipt <path> [--run <run-id>] --json "${CLAUDE_PLUGIN_ROOT}/abcd" implement record [--run <run-id>] [--transcript <path>]... --json ``` `status` renders every run (or the one `--run` names): its pace and the layer -each number came from, whether it is paused and until when, its lanes, each -lane's spec step and next stage, what an awaiting lane waits on, the pending spec -steps, the run's fallbacks from a routed runner to the host (`fallbacks`, and in -the text a count per runner and per role) and the run record. It writes nothing. +each number came from, the slots in use out of its ceiling and the work the +ceiling holds back, whether it is paused and until when, its lanes, each lane's +spec step and next stage, each agent a lane awaits, a held lane with the lane +whose hand-back caused it, the head judged, the step it stopped before and the +two flags that decide it, the pending spec steps, the run's fallbacks from a +routed runner to the host (`fallbacks`, and in the text a count per runner and +per role) and the run record. It writes nothing. A spec's **steps** and a lane's **stages** are two things: each spec step lands as one lane, and the loop takes the lane through its stages. `step` performs the -next stage of the current lane and exits; the result names the stage it -completed under `performed_stage` and the lane's next one under `stage`. When a -stage hands work to an agent the result's `awaiting` names the `role` to start as a fresh agent, -the `brief` to hand it and the `receipt` path it writes; the lane then moves -only when `receipt` is called with that path and the receipt verifies. A `step` -while the lane awaits re-tells the await and moves nothing; a complete run says -`complete: true`. When a lane is done, the spec's next pending step opens the -next lane and the run record names it. A stage that fails leaves the state as it -was, so the next call performs it again, and a completed stage is never repeated. +run's next move and exits; the result names the lane, the stage it completed +under `performed_stage` and the lane's next one under `stage`. When a stage +hands work to an agent the result's `awaiting` names the `role` to start as a +fresh agent, the `brief` to hand it and the `receipt` path it writes; that work +moves only when `receipt` is called with that path and the receipt verifies. A +complete run says `complete: true`. A stage that fails leaves the state as it +was, so the next call performs it again, and a completed stage is never +repeated. + +A run works in parallel up to its ceiling, the pace's `sub_agents`: each agent +handed work and not yet verified is a slot, implementers and validators alike, +and the result carries `slots`, `ceiling` and `alive` (every lane with anything +left, its stage and each await). Each `step` first performs a stage the binary +owns on any lane (the worktree, the brief, a round's close, a landing step, a +sync, a hold), which takes no slot and is never held by the ceiling. Then, while +a slot is free, it hands out the +first waiting work: a lane already open before a new one, the lower spec step +first, a round's validators in order, then the implementer of a new lane. A +`step` that finds the ceiling reached hands out nothing, exits 0 with +`ceiling_reached: true` naming every await, and records the held work under the +run's `waiting` with the time it was first held; the move that later serves it +records the minutes it waited. A lane opens for a spec step once every step it +needs has landed (the step's `- needs:` line, or by default every step before +it), whatever the ceiling: its worktree and brief are made, and only its +implementer waits for a slot. A landing waiting on the forge's merge holds only +its own lane: the call moves another lane, names the wait under `blocked` (a +`blocked:` line in the text form) and in `next`, and gives the wait (exit 3) +only when nothing else moves. Any other refusal of a stage the binary performs, +a missing preflight receipt included, is the call's answer, and no other lane +moves. + +`receipt` looks the path up among every outstanding await of the run and +advances the lane it belongs to; a path no await names is refused, naming the +awaits there are, and frees nothing. When the stage hands the lane to a role that `roles.<role>.runner` routes to a command-line runner (`claude` or `opencode`, enabled under `runner.<name>` in @@ -281,18 +309,52 @@ last two) with no `resolves` and no definition of done: `receipt` then discards the lane's worktree and branch, ends the lane at `handed-back` before the validators, and the result's `hand_back` names the kind, the reason, the home and the `discarded` head. `/abcd:drain` routes it by kind. -`validate` hands the lane's head to fresh validators one at a time and records -each verdict from the validator's own return; the fidelity audit passes only +`validate` hands the lane's head to fresh validators, side by side up to the +ceiling, and writes a fix brief only once every validator of the round has +returned; it records each verdict from the validator's own return; the fidelity audit passes only when every criterion is met, so an undecided (`INCONCLUSIVE`) criterion sends the lane to a fresh implementer as a not-met one does. A lane that has taken the run's fix rounds (`build --fix-rounds`, bundled 3) and still does not pass is handed back: the result's `hand_back` names the verdict `unachievable` and -the last findings, and every later `step` refuses at the `handed-back` stage. -The run stays in progress, so `build next` passes over its intent; no verb -clears it, and the refusal names the way out: once the intent is replanned, -remove the run's directory, `.abcd/.work.local/run/<run-id>`. - -`land` takes one `step` per move, and the lane stays at `land` until the last: +the last findings, and the loop starts nothing further for it. Its sibling +lanes finish under the same ceiling, window and fix rounds; no new lane opens, +pending steps stay pending, and no lane closes the spec. A sibling whose round +passes is **held**: its stage is `held` and its `hold` names the `cause` (the +handed-back lane), the `head` its round judged and `before`, the landing step it +stopped before (`push`, or `arm` once its pull request is open; an armed one is +disarmed with `gh pr merge <n> --disable-auto`). Where the forge refuses the +withdrawal, `step` refuses naming the pull request, moves no other lane, and the +person decides it on the forge; an armed pull request the forge reports merged +had landed before the hand-back and is recorded as landed. Once nothing is left to move, +every `step` refuses at the `handed-back` stage naming the hand-back and each +held lane. The person decides each held lane, one per invocation, once no lane +has work left: + +- `step --release <lane-id>` lands it as it is: its stage returns to `land` and + its landing resumes at the step it stopped before. +- `step --discard <lane-id>` does not land it: its worktree and branch are + removed, then its pull request is closed if it opened one, its stage is + `discarded`, and its spec step stays unlanded. A removal git refuses (a + worktree with changes) leaves the pull request open and the lane held, so the + retry closes it once. + +Either is refused, changing nothing, for a lane that is not held or while a lane +still has work. The run stays in progress, so `build next` passes over its +intent; no verb clears it, and the refusal names the way out: once the intent +is replanned, remove the run's directory, `.abcd/.work.local/run/<run-id>`. + +`land` takes one `step` per move, and the lane stays at `land` until the last. +Landing is one lane at a time: a lane waits at its landing, holding no slot, +while a sibling's landing is under way, the lower spec step landing first. +Before a lane's landing begins, a sibling of the run that landed since its base +is merged in (a **sync**): the default branch is merged into the lane's branch +with a merge commit in its worktree, never a rebase, and a fresh round judges +the merge head. A merge that conflicts is aborted with the branch unchanged, and +a fresh implementer is handed a sync brief naming each conflicting path and the +sibling lanes; its receipt must carry the merged sha as an ancestor of its head. +A sync counts no fix round. The closing lane, which reaches its landing with no +step pending, no other lane open and none handed back, takes the fidelity audit +over each of the run's lanes' own diff. 1. It checks the lane's worktree is clean and its branch is at the head the validators judged. @@ -314,9 +376,14 @@ remove the run's directory, `.abcd/.work.local/run/<run-id>`. records and passed through the outbound scrub, then re-reads the body the forge holds and strips a session URL or tool footer the harness appended. 5. It reads the merge rule from the ruleset mirror (`.abcd/work/rulesets/`) at - the lane's base: where a merge queue gates the default branch it arms - auto-merge with the queue's method, and elsewhere it leaves the pull request - open for a person to merge. Nothing is pushed to the lane after this. + the lane's base: where a merge queue gates the default branch AND a ruleset + requires a person's approval (an approving review count of one or more, or + a code-owner review with a CODEOWNERS file naming an owner) it arms + auto-merge with the queue's method. Elsewhere it leaves the pull request + open for a person to merge, and `implement status` shows the landing as + "left open for a person to merge: the ruleset requires no approval" where a + queue exists but nothing requires approval; a later step never arms it, and + a missing mirror requires nothing. Nothing is pushed to the lane after this. 6. It waits (exit 3) until the pushed head is an ancestor of the default branch on `origin`, then removes the lane's worktree and branch, and the lane is done. A pull request closed without merging, or merged in a way that rewrote diff --git a/commands/intent.md b/commands/intent.md index b7e9ea120..78f519740 100644 --- a/commands/intent.md +++ b/commands/intent.md @@ -822,8 +822,12 @@ by construction. The listing reads the first marker of every intent in `dead_lettered` and `ingested`. The owed set is `OWED` plus `none`: a shipped intent with no marker at all owes the review too, and its re-emit mints the receipt. A `DEAD_LETTER` review is listed under its own heading, unreviewed, -with the reason the quarantine recorded, and is not counted as owed; an -`INGESTED` one is not listed in the text form. Report the owed total and, for +with the reason the quarantine recorded, and is not counted as owed. An +`INGESTED` review whose verdict left a check owed carries `audit_owed: true`, +the `audit_owed_issue` carrying it and its `re_emit`, is counted in +`audit_owed` (and in `ingested`), and is listed in the text form under its own +heading with its issue and its re-run command; any other `INGESTED` one is not +listed in the text form. Report the owed total and, for each owed intent, its receipt and its re-emit command. The listing names the re-emit, never the request file: the request lives in the gitignored local tier and may have been swept. It exits 0 whatever it finds; no gate reads it. @@ -907,8 +911,10 @@ condition under the `cond-…` identity the verdict disposes it by, and carries reviewer working from the request alone has the shape to write against. The result's `status` names the receipt's state and `request_written` the act: a re-emit of an owed receipt rewrites its request (`already_owed`, -`request_written: true`, text `request rewritten:`), and a re-emit of an -ingested or dead-lettered receipt writes none and names no `request_path`. Its +`request_written: true`, text `request rewritten:`), a re-emit of a receipt +whose ingested verdict left a check owed rewrites it for the re-run +(`check_owed`), and a re-emit of any other ingested or dead-lettered receipt +writes none and names no `request_path`. Its `## Provenance` block states the `rubric_hash` and `prompt_hash` the host computed. The auditor echoes both verbatim into `policy`; it never computes either itself. The ingest recomputes @@ -924,6 +930,25 @@ takes an empty block, a conditioned one a full one — so a partial or invented disposition quarantines the whole payload rather than applying half of it. Report the returned split alongside the acceptance rollup. +**A failed or undecided audit leaves the intent shipped and flagged.** When the +verdict judges any criterion `NOT_MET` or `INCONCLUSIVE`, the intent stays in +`shipped/` and its changelog entry stands; its Audit Notes block carries an +audit-owed flag naming each such criterion, the receipt, the remedy and the +issue carrying the check. The ingest captures that issue itself — a `major` +`bug` for a failure, a `minor` `inconsistency` for an undecided verdict, with +the remedy "fix, then re-run the audit" or "re-run the audit" — and a later +failed or undecided audit of the same receipt links to it while it is open, +concurrent ingests included; where several open issues carry one receipt's +check, the oldest is linked and the others are declined as its duplicates +(`owed_issue`, `owed_issue_linked`, `audit_owed` in the JSON; `audit owed:` and +`captured`/`carried by` lines in the text). Commit the captured record with the +intent. To pay the check, fix what failed, run `intent audit <itd-N>` (status +`check_owed`: the request is rewritten for the re-run), hand the request to the +auditor, and ingest its verdict: one that judges no criterion `NOT_MET` or +`INCONCLUSIVE` clears the flag with a dated line and resolves every open issue +carrying that check (`flag_cleared` names the flagged one). A ledger that cannot file refuses the ingest with nothing +written; ingest the verdict again once it can. + ## Drain: pay the owed reviews, oldest first ```bash @@ -973,19 +998,17 @@ auditor at a time are what bound the cost: as a single audit's does — the Audit Notes block, the receipt, the scope- condition dispositions. Report the ingest's status, then take the next entry; start the next audit only after this ingest has returned. -3. **A NOT_MET verdict is captured, never fixed.** Every intent the drain - reaches has already shipped, so a criterion it did not meet is a finding - against delivered work: file it with - `abcd capture "<itd-N> fidelity audit NOT_MET: <criterion> (receipt <rcp-…>)" --category drift --severity <minor|major> --source review-followup --remedy "<the fix the criterion asks for>"`, - naming the receipt, and continue the loop. The remedy is required: name the - change that would meet the criterion as its text states it, and where that - fix depends on outside practice, cite the prior-art or state-of-the-art - check it rests on (principle `prefer-sota`) in the capture's text. The drain changes no code and - re-opens nothing; the fix round belongs to the build that owns the work. A +3. **A NOT_MET or INCONCLUSIVE verdict is captured by the ingest, never + fixed here.** Every intent the drain reaches has already shipped, so a + criterion it did not meet, or could not decide, is a check still owed + against delivered work: the ingest flags the intent and captures the one + issue carrying it (its `owed_issue`), so file nothing by hand — report the + issue id and continue the loop. The drain changes no code and un-ships + nothing; the fix round belongs to whoever takes the captured issue. A `dead_letter` ingest is reported with its reason and is listed apart by bare `intent audit` from then on. 4. **Summarise:** how many were audited, the ingest outcome of each, the - captures filed for NOT_MET (their ids), the entries skipped for an + issues the ingests captured or linked for an owed check (their ids), the entries skipped for an `emit_error`, how many stay owed — the command's `remaining` plus any entry the loop did not reach — and why the loop stopped: the queue ran out, the cap was reached, or no auditor was diff --git a/commands/site.md b/commands/site.md index 296980de9..c31eac0ff 100644 --- a/commands/site.md +++ b/commands/site.md @@ -62,7 +62,8 @@ write outside it is `site-src/ui.json` itself, and only when the file lacks a label abcd declares (a file written before that label existed): the build adds each such label with abcd's default words, prints one stderr line per label (`abcd site build: added the missing label status.target to site-src/ui.json -with its default words`), lists them in `added_labels`, and changes nothing +with its default words`), before the error when the build then fails, lists +them in `added_labels`, and changes nothing else in the file. A label the file carries keeps its wording, a blank one is still refused by name, and a key abcd does not declare is still refused. The render `abcd lint site` makes of an empty output directory never completes the diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 6c6cbed1e..3d52f3730 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1742,8 +1742,10 @@ Hand back the receipt an agent stage of a loop run awaits: Writes the run's stat **Usage:** `abcd implement receipt <path> [--run <run-id>] [flags]` -Hand back the receipt the run's awaiting lane named when its stage handed work to an -agent. The path must be the one the stage named. The stage's verifier checks it; a +Hand back the receipt a lane of the run named when its stage handed work to an agent. +The path is looked up among every outstanding await of the run, and the lane it belongs +to advances; a path no await names is refused, naming the awaits there are, and frees +nothing. The stage's verifier checks it; a verified receipt frees its slot, and a receipt that verifies completes the stage and the lane moves to its next stage, and one that does not is refused naming what is missing, with the lane left where it was. A stage whose verifier this abcd does not carry is refused naming the spec piece that @@ -1882,15 +1884,30 @@ and creates nothing. Exit 2 when --run names no run. Perform the next stage of an implement loop run's lane and exit: Writes the run's state and the lane's stages; refuses a push with no preflight receipt. -**Usage:** `abcd implement step [--run <run-id>] [flags]` - -Perform the next stage of the run's current lane, write the state, and exit. At a stage -that hands work to an agent, the result names the agent to start, the brief it is handed -and the path its receipt goes to; the lane then advances only on -`abcd implement receipt`, and running `implement step` again re-tells the same thing and -moves nothing. A lane lands one step of the spec; its stages are how it gets there, and -when a lane is done the spec's next pending step opens the next lane, and the run -record names it. A complete run says so. +**Usage:** `abcd implement step [--run <run-id>] [--release <lane-id> | --discard <lane-id>] [flags]` + +Perform the run's next move, write the state, and exit. At a stage that hands work to +an agent, the result names the agent to start, the brief it is handed and the path its +receipt goes to; that work advances only on `abcd implement receipt`. A lane lands one +step of the spec; its stages are how it gets there. A complete run says so. + +A run works in parallel up to its ceiling (--sub-agents, pace.sub_agents): each agent +handed work and not yet verified is a slot, implementers and validators alike. Each call +first performs a stage the binary owns on any lane (the worktree, the brief, a round's +close, the landing's steps), which takes no slot and is never held by the ceiling; then, +while a slot is free, it hands out the first waiting work: a lane already open before a +new one, the lower spec step first, a round's validators in order, then a new lane's +implementer. A call that finds the ceiling reached hands out nothing, exits 0 naming +every lane alive with the role and receipt it awaits, and records the held work with the +time it was first held. A lane opens for a spec step once every step it needs has +landed (its `- needs:` line, or by default every earlier step), whatever the ceiling: its +worktree and brief are made, and its implementer waits for a slot. A landing waiting on +the forge's merge holds only its own lane: the call moves another and names the wait +under blocked:; any other refused stage is the call's answer. Landing is one lane at a +time; a lane whose sibling landed +since its base is synced first (the default branch merged in with a merge commit, never a +rebase) and judged by a fresh round, and a conflicting sync goes to a fresh implementer; +a sync counts no fix round. The lane's stages, in order: worktree makes the lane's worktree in the machine-scoped store, ~/.abcd/worktrees/<root-sha>/<run-id>-<lane-id>, on a branch build/<run-id>-<lane-id> @@ -1898,10 +1915,10 @@ cut from the default branch; brief renders the lane's brief from that base (the the spec, the conventions of AGENTS.md, the decisions the intent cites, and the spec steps before the lane's with what landed each) into the lane's directory of the run; implement hands the lane to a fresh implementer and awaits -its receipt; validate hands the lane's head to validators that did not implement it, one -fresh agent at a time — a ruthless-reviewer, a security-reviewer and, on the lane whose -landing closes the spec and ships the intent, an intent-auditor over the whole delivery, -from the base of the run's first lane to that lane's head (a lane that does not close the +its receipt; validate hands the lane's head to validators that did not implement it, each +a fresh agent, side by side up to the ceiling — a ruthless-reviewer, a security-reviewer +and, on the lane whose landing closes the spec and ships the intent, an intent-auditor over +the whole delivery, each of the run's lanes' own diff (a lane that does not close the spec takes no audit) — and records each verdict itself, parsed from the validator's own return. A round one of them did not pass goes to a fresh implementer, who applies each finding or rejects it in writing in its report, and the next round judges the new head @@ -1910,8 +1927,16 @@ which is refused naming the report. The audit passes only when every criterion i criterion it could not decide (INCONCLUSIVE) fails the round as a not-met one does, and goes to the fresh implementer with the finding. A round that does not pass once the lane has taken the run's fix rounds (--fix-rounds, bundled 3) hands the lane back instead: it -stops as unachievable, the result and the run record name the last round's findings, the -run starts nothing further for it, and every later step is refused naming the hand-back. +stops as unachievable, the result and the run record name the last round's findings, and +the run starts nothing further for it. Its sibling lanes finish: no new lane opens, no +lane closes the spec, and a sibling whose round passes is held before its push, or before +arming once its pull request is open (an armed one is disarmed, and where the forge +refuses the withdrawal the step is refused naming the pull request; one the forge reports +merged is recorded as landed); once nothing is left to move, a step is refused naming +the hand-back and each held lane. --release <lane-id> lands a held lane as it is; +--discard <lane-id> removes its worktree and branch, then closes its pull request, and +leaves its step unlanded. Either is refused, changing nothing, +for a lane that is not held or while any lane still has work. land follows a passing round, one step per call: it checks the lane's worktree is clean at the judged head; on the lane that closes the spec it runs `spec close` in the lane's worktree and ingests the audit that lane took, and for every capture the lane's receipts @@ -1959,7 +1984,9 @@ refusal, exit 3 on a pause or a locked run state. **Flags:** ``` - --run string the run to step (run-<16 digits>); the one run in progress when omitted + --discard string discard a held lane (lane-<n>): close its pull request, remove its worktree and branch + --release string land a held lane as it is (lane-<n>), once no lane has work left + --run string the run to step (run-<16 digits>); the one run in progress when omitted ``` ### `abcd inbox` diff --git a/internal/core/capture/alloc.go b/internal/core/capture/alloc.go index 035917280..a749920a0 100644 --- a/internal/core/capture/alloc.go +++ b/internal/core/capture/alloc.go @@ -270,6 +270,10 @@ var minter recordid.Minter // presence check runs first because the O_EXCL create guards open/ alone; a // clash with a resolved or wontfixed id also redraws. func reservePath(repoRoot, issuesRoot, slug, forceID string) (string, string, error) { + return reservePathWith(withLedgerLock, repoRoot, issuesRoot, slug, forceID) +} + +func reservePathWith(lock ledgerLocker, repoRoot, issuesRoot, slug, forceID string) (string, string, error) { // Validate a caller-supplied ForceID against the iss-N shape BEFORE it is used // to build a path or create a placeholder — a traversal id (../../evil) must // never touch the filesystem outside the ledger, even transiently. @@ -277,7 +281,7 @@ func reservePath(repoRoot, issuesRoot, slug, forceID string) (string, string, er return "", "", fmt.Errorf("%w: ForceID %q must match ^iss-[0-9]+$", ErrPathUnsafe, forceID) } var resID, resTarget string - err := withLedgerLock(repoRoot, issuesRoot, func() error { + err := lock(repoRoot, issuesRoot, func() error { if forceID != "" { if present, pErr := issPresent(issuesRoot, forceID); pErr != nil { return pErr diff --git a/internal/core/capture/auditowed.go b/internal/core/capture/auditowed.go new file mode 100644 index 000000000..74b975d0d --- /dev/null +++ b/internal/core/capture/auditowed.go @@ -0,0 +1,265 @@ +package capture + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/core/layered" + "github.com/intentdriven/abcd/internal/core/record/match" +) + +// auditowed.go is the ledger half of ruling DQ1c (2026-09-30, the product +// thinker): an after-merge fidelity audit that fails or comes back undecided +// leaves the intent shipped and flagged, and ONE issue captured here carries +// the owed check. A second failed audit of the same receipt links to that +// issue while it is open rather than filing a double, and a passing re-audit +// resolves it. The intent store calls both through the seam registered below +// (intent.SetAuditLedger), with its own lock released, since this ledger's +// lock comes first in the one lock order. + +func init() { intent.SetAuditLedger(fileAuditOwed, clearAuditOwed) } + +// Severity and category of an owed audit check. A criterion judged NOT_MET is +// a shipped behaviour that does not do what its intent promised: a bug, and +// major, since the changelog already announces it. A criterion judged only +// INCONCLUSIVE is a record that claims shipped with a check that could not +// confirm it: an inconsistency between the record and its evidence, and +// minor, since nothing is known to be wrong yet. Both categories are in the +// drain's fixable set, so the drain's severity rule decides as for any issue. +const ( + auditFailedSeverity = "major" + auditFailedCategory = "bug" + auditUndecidedSeverity = "minor" + auditUndecidedCategory = "inconsistency" +) + +// fileAuditOwed returns the open issue already carrying o's receipt, or files +// one through Capture, the one canonical filer. The open-issue scan and the +// filing are ONE step under the ledger lock, so two ingests of one failed +// audit racing each other file one issue and the second links to it: with the +// scan outside the lock, both saw no carrier and both filed. +// +// More than one open carrier (a race before the lock held the scan, or an +// ingest whose intent write failed after its filing) is collapsed: the oldest +// is the one linked, and every other is declined as its duplicate through the +// ledger's own duplicate closure (wontfix with a duplicates link), after the +// lock is released since that verb takes it. +func fileAuditOwed(o intent.AuditOwed) (intent.AuditFiling, error) { + rr, ir, err := resolveRoots(o.RepoRoot, "") + if err != nil { + return intent.AuditFiling{}, err + } + var ( + filing intent.AuditFiling + extras []string + ) + err = withLedgerLock(rr, ir, func() error { + carriers, err := openOwedCarriers(o.RepoRoot, o.IntentID, o.ReceiptID) + if err != nil { + return err + } + if len(carriers) > 0 { + filing, extras = intent.AuditFiling{IssueID: carriers[0], Linked: true}, carriers[1:] + return nil + } + res, err := captureHeld(auditOwedRequest(o)) + if err != nil { + return err + } + filing = intent.AuditFiling{IssueID: res.ID} + return nil + }) + if err != nil { + return intent.AuditFiling{}, err + } + for _, id := range extras { + if err := declineDuplicateCarrier(o, id, filing.IssueID); err != nil { + return intent.AuditFiling{}, err + } + } + return filing, nil +} + +// auditOwedRequest is the capture request filing o's owed check. +func auditOwedRequest(o intent.AuditOwed) CaptureRequest { + sev, cat := auditUndecidedSeverity, auditUndecidedCategory + if o.Failed { + sev, cat = auditFailedSeverity, auditFailedCategory + } + crit := owedList(o) + return CaptureRequest{ + RepoRoot: o.RepoRoot, + Text: auditOwedText(o, crit), + Severity: Severity(sev), + Category: Category(cat), + Source: "agent-finding", + FoundDuring: "abcd intent audit ingest, the after-merge fidelity audit of " + o.IntentID + " (receipt " + o.ReceiptID + ")", + FoundAt: o.IntentPath, + RelatedIntents: []string{o.IntentID}, + Remedy: auditOwedRemedy(o, crit), + // The filing-time match compares the owed check's own words, so a + // record another filer holds for the same shortfall is linked. + Match: auditMatchConfig(o.RepoRoot), + MatchText: auditOwedTitle(o, crit), + } +} + +// clearAuditOwed resolves every open issue carrying the owed check of a +// receipt a passing audit judged: the one the flag names, when the receipt +// carries a flag, and any other carrier of the same receipt a race or a failed +// intent write left open, flag or no flag, so none outlives the check it +// carries. One List of the open ledger finds them. An issue no longer open +// (resolved or declined by hand, or by a concurrent re-run) is left as it +// stands. +func clearAuditOwed(c intent.AuditCleared) error { + carriers, err := openOwedCarriers(c.RepoRoot, c.IntentID, c.ReceiptID) + if err != nil { + return err + } + if c.IssueID != "" && !contains(carriers, c.IssueID) { + open, err := isOpenIssue(c.RepoRoot, c.IssueID) + if err != nil { + return err + } + if open { + carriers = append([]string{c.IssueID}, carriers...) + } + } + for _, id := range carriers { + _, err := Resolve(ResolveRequest{ + RepoRoot: c.RepoRoot, + ID: id, + Resolution: fmt.Sprintf("A re-run of the fidelity audit of %s (receipt %s) judged no criterion NOT_MET or INCONCLUSIVE, "+ + "so the check this record carried is met and the intent's audit-owed flag is cleared.", c.IntentID, c.ReceiptID), + Impact: "fix", + ByIntent: c.IntentID, + Grounds: "pursued: the passing re-audit is the check this record carried; " + + "a later audit of the same receipt judging a criterion NOT_MET or INCONCLUSIVE would show it wrong", + }) + if err != nil && !errors.Is(err, ErrTransitionConflict) { + return err + } + } + return nil +} + +// isOpenIssue reports whether id is in the open ledger. +func isOpenIssue(repoRoot, id string) (bool, error) { + list, err := List(ListRequest{RepoRoot: repoRoot, State: StateOpen}) + if err != nil { + return false, err + } + for _, iss := range list.Issues { + if iss.ID == id { + return true, nil + } + } + return false, nil +} + +// openOwedCarriers lists the open issues carrying the owed check of the +// intent's receipt, oldest first. It takes no lock: fileAuditOwed calls it +// under the ledger lock it holds. +func openOwedCarriers(repoRoot, intentID, rcp string) ([]string, error) { + list, err := List(ListRequest{RepoRoot: repoRoot, State: StateOpen}) + if err != nil { + return nil, err + } + var ids []string + for _, iss := range list.Issues { + if carriesOwedAudit(iss, intentID, rcp) { + ids = append(ids, iss.ID) + } + } + sort.Slice(ids, func(i, j int) bool { return olderIssID(ids[i], ids[j]) }) + return ids, nil +} + +// olderIssID orders two iss ids by age: a longer number is a later mint (the +// timestamp-numeric ids are longer than every ordinal), and ids of one length +// order by their digits. +func olderIssID(a, b string) bool { + if len(a) != len(b) { + return len(a) < len(b) + } + return a < b +} + +// declineDuplicateCarrier closes an extra carrier of o's owed check as a +// duplicate of keep. One a concurrent ingest already closed is left as it is. +func declineDuplicateCarrier(o intent.AuditOwed, id, keep string) error { + _, err := Wontfix(WontfixRequest{ + RepoRoot: o.RepoRoot, + ID: id, + Reason: fmt.Sprintf("A duplicate of %s, which carries the same owed check of the fidelity audit of %s (receipt %s): "+ + "one issue carries an owed audit check, and the flag names %s.", keep, o.IntentID, o.ReceiptID, keep), + Duplicates: []string{keep}, + }) + if errors.Is(err, ErrTransitionConflict) { + return nil + } + return err +} + +// carriesOwedAudit reports whether iss is the record carrying the owed check of +// the intent's receipt: it relates to the intent and names the receipt. +func carriesOwedAudit(iss Issue, intentID, rcp string) bool { + return contains(iss.RelatedIntents, intentID) && strings.Contains(iss.Body, "(receipt "+rcp+")") +} + +func owedList(o intent.AuditOwed) string { + parts := make([]string, 0, len(o.Criteria)) + for _, c := range o.Criteria { + parts = append(parts, c.ID+" "+c.Verdict) + } + return strings.Join(parts, ", ") +} + +func auditOwedTitle(o intent.AuditOwed, crit string) string { + if o.Failed { + return fmt.Sprintf("The after-merge fidelity audit of %s judged a criterion unmet, so its check is still owed: %s", o.IntentID, crit) + } + return fmt.Sprintf("The after-merge fidelity audit of %s could not decide a criterion, so its check is still owed: %s", o.IntentID, crit) +} + +// auditOwedText is the filed record's body: the title line the slug is derived +// from, then the receipt, the flag and the way the record closes. +func auditOwedText(o intent.AuditOwed, crit string) string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n\n", auditOwedTitle(o, crit)) + fmt.Fprintf(&b, "The audit of %s (receipt %s) judged %s. The intent stays in shipped/ and its changelog entry stands; "+ + "its Audit Notes carry an audit-owed flag naming these criteria until a re-run of the audit passes (ruling DQ1c, 2026-09-30).\n\n", + o.IntentID, o.ReceiptID, crit) + fmt.Fprintf(&b, "Remedy: %s. `abcd intent audit %s` rewrites the review request for the re-run, and "+ + "`abcd intent audit ingest --verdict-json <path>` ingests its verdict: one with no criterion NOT_MET or INCONCLUSIVE "+ + "clears the flag and resolves this record.\n", o.Remedy(), o.IntentID) + return b.String() +} + +// auditOwedRemedy is the record's remedy: real work a drain can take, not the +// automatic filers' placeholder, since the flag says what must be done. +func auditOwedRemedy(o intent.AuditOwed, crit string) string { + if o.Failed { + return fmt.Sprintf("fix, then re-run the audit: make %s of %s hold in the delivered code, then re-emit the request with `abcd intent audit %s` "+ + "and ingest the re-run's verdict; a verdict with no criterion NOT_MET or INCONCLUSIVE clears the flag and resolves this record", + crit, o.IntentID, o.IntentID) + } + return fmt.Sprintf("re-run the audit: re-emit the request with `abcd intent audit %s`, give the auditor the evidence %s lacked, "+ + "and ingest the re-run's verdict; a verdict with no criterion NOT_MET or INCONCLUSIVE clears the flag and resolves this record", + o.IntentID, crit) +} + +// auditMatchConfig is the filing-time match's configuration, read as the +// capture verb reads it. A configuration the reader refuses files the record +// unmatched rather than refusing the filing, as every filer does. +func auditMatchConfig(repoRoot string) *match.Config { + roots, _ := layered.RootsFor(repoRoot) + cfg, err := match.LoadConfig(roots) + if err != nil { + return nil + } + return &cfg +} diff --git a/internal/core/capture/auditowed_test.go b/internal/core/capture/auditowed_test.go new file mode 100644 index 000000000..15e335c00 --- /dev/null +++ b/internal/core/capture/auditowed_test.go @@ -0,0 +1,305 @@ +package capture + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + + "github.com/intentdriven/abcd/internal/core/drainrule" + "github.com/intentdriven/abcd/internal/core/intent" + "github.com/intentdriven/abcd/internal/gittest" +) + +// auditowed_test.go covers the ledger half of ruling DQ1c: an after-merge +// audit that fails or comes back undecided captures ONE issue carrying the +// owed check, a second one links to it, and a passing re-audit resolves it. + +const aoShipped = ".abcd/development/intents/shipped/itd-10-alpha.md" + +// auditOwedRepo ships itd-10 through the real close, so its Audit Notes carry +// an OWED receipt and the request the host would read, and returns the root +// and the receipt. +func auditOwedRepo(t *testing.T) (string, string) { + t.Helper() + r := gittest.NewRepo(t) + r.Write(".abcd/development/intents/planned/itd-10-alpha.md", + "---\nid: itd-10\nslug: alpha\nspec_id: spc-1\nkind: standalone\nimpact: fix\n---\n# alpha\n\n"+ + "## Scope Conditions\n\n"+intent.NullityToken+"\n\n## Acceptance Criteria\n\n- ok\n\n"+ + "## Grounds\n\n- pursued: we expect the recorded conjecture to outlive the session that had it\n\n## Audit Notes\n") + r.Write(".abcd/development/specs/open/spc-1-alpha.md", "---\nid: spc-1\nslug: alpha\nintent: itd-10\n---\n# alpha\n") + r.Write(".abcd/work/issues/open/.gitkeep", "") + r.Commit("fixture") + res, err := intent.Reconcile(r.Root(), "spc-1", "", intent.RemainderRequest{}) + if err != nil { + t.Fatal(err) + } + if res.ReceiptID == "" { + t.Fatal("the close must park a receipt") + } + return r.Root(), res.ReceiptID +} + +// auditVerdict is a valid one-criterion verdict for rcp, judging ac-1 v and +// echoing the policy the request issued. +func auditVerdict(t *testing.T, root, rcp, v string) []byte { + t.Helper() + req, err := os.ReadFile(filepath.Join(root, ".abcd/.work.local/reviews", rcp+".request.md")) + if err != nil { + t.Fatal(err) + } + rubric := regexp.MustCompile(`(?m)^- rubric_hash: (\S+)$`).FindStringSubmatch(string(req))[1] + prompt := regexp.MustCompile(`(?m)^- prompt_hash: (\S+)$`).FindStringSubmatch(string(req))[1] + rollup := map[string]any{"MET": 0, "MET_WITH_CONCERNS": 0, "NOT_MET": 0, "INCONCLUSIVE": 0} + rollup[v] = 1 + b, err := json.Marshal(map[string]any{ + "_type": intent.VerdictType, + "receipt_id": rcp, + "verifier": map[string]any{"id": "intent-auditor", "version": "claude-opus-5-5"}, + "policy": map[string]any{"rubric_hash": rubric, "prompt_hash": prompt}, + "criteria": []any{map[string]any{ + "criterion_id": "ac-1", "verdict": v, "rationale": "judged against the delivered code", + "evidence": []any{map[string]any{"ref": "internal/example.go:1", "quote": "package example"}}, + }}, + "acceptance_rollup": rollup, + "gap_audit": map[string]any{"honoured": []any{}, "diverged": []any{}, "missing": []any{}}, + }) + if err != nil { + t.Fatal(err) + } + return b +} + +func openIssues(t *testing.T, root string) []Issue { + t.Helper() + l, err := List(ListRequest{RepoRoot: root, State: StateOpen}) + if err != nil { + t.Fatal(err) + } + return l.Issues +} + +func TestAnUndecidedAuditCapturesOneIssueAndAPassingReAuditResolvesIt(t *testing.T) { + root, rcp := auditOwedRepo(t) + res, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "INCONCLUSIVE")) + if err != nil { + t.Fatalf("ingest: %v", err) + } + open := openIssues(t, root) + if len(open) != 1 { + t.Fatalf("an undecided audit captures exactly one issue, found %d", len(open)) + } + iss := open[0] + if res.OwedIssue != iss.ID || res.OwedIssueLinked { + t.Fatalf("the result must name the issue filed: %+v, issue %s", res, iss.ID) + } + if iss.Severity != auditUndecidedSeverity || iss.Category != auditUndecidedCategory || iss.Source != "agent-finding" || + !contains(iss.RelatedIntents, "itd-10") || !strings.HasPrefix(iss.Remedy, "re-run the audit: ") || + !strings.Contains(iss.Body, "(receipt "+rcp+")") || iss.FoundAt != aoShipped { + t.Fatalf("the captured issue = %+v", iss) + } + // The drain may take it: its remedy is real, so the field rules decide as + // for any issue, and a minor inconsistency passes the baseline. + if v := eligibility(iss, drainrule.Baseline(), deferralAnchor{}); v.Outcome != DrainEligible { + t.Fatalf("the drain must be able to take an undecided audit's issue: %+v", v) + } + + // The same undecided verdict again links to the open issue. + res2, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "INCONCLUSIVE")) + if err != nil { + t.Fatal(err) + } + if !res2.OwedIssueLinked || res2.OwedIssue != iss.ID || len(openIssues(t, root)) != 1 { + t.Fatalf("a second undecided audit must link, not file a double: %+v", res2) + } + + // A failed re-run of the same receipt still links to the one open issue. + res3, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "NOT_MET")) + if err != nil { + t.Fatal(err) + } + if !res3.OwedIssueLinked || res3.OwedIssue != iss.ID || len(openIssues(t, root)) != 1 { + t.Fatalf("a failed re-audit of the flagged receipt must link to the open issue: %+v", res3) + } + + // A passing re-run clears the flag and resolves the issue, with impact fix. + res4, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "MET")) + if err != nil { + t.Fatal(err) + } + if res4.FlagCleared != iss.ID || len(openIssues(t, root)) != 0 { + t.Fatalf("a passing re-audit must resolve %s: %+v", iss.ID, res4) + } + resolved, err := List(ListRequest{RepoRoot: root, State: StateResolved}) + if err != nil { + t.Fatal(err) + } + if len(resolved.Issues) != 1 || resolved.Issues[0].ID != iss.ID || resolved.Issues[0].ResolvedBy == nil || + resolved.Issues[0].ResolvedBy.Intent != "itd-10" { + t.Fatalf("the resolution = %+v", resolved.Issues) + } + rec, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(resolved.Issues[0].Path))) + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`(?m)^impact: "?fix"?$`).Match(rec) { + t.Fatalf("the resolution must carry impact fix:\n%s", rec) + } + body, err := os.ReadFile(filepath.Join(root, aoShipped)) + if err != nil { + t.Fatalf("the intent must still be shipped: %v", err) + } + if strings.Contains(string(body), "Audit owed (receipt") || !strings.Contains(string(body), "Audit owed flag cleared ") { + t.Fatalf("the flag must be cleared by a dated line:\n%s", body) + } +} + +func TestAFailedAuditCapturesAMajorBugWithAFixRemedy(t *testing.T) { + root, rcp := auditOwedRepo(t) + if _, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "NOT_MET")); err != nil { + t.Fatal(err) + } + open := openIssues(t, root) + if len(open) != 1 { + t.Fatalf("a failed audit captures exactly one issue, found %d", len(open)) + } + iss := open[0] + if iss.Severity != auditFailedSeverity || iss.Category != auditFailedCategory || + !strings.HasPrefix(iss.Remedy, "fix, then re-run the audit: ") || !strings.Contains(iss.Remedy, "ac-1 NOT_MET") { + t.Fatalf("the captured issue = %+v", iss) + } + // Its remedy is real, so a drain judges it by its fields: a major issue + // is handed back on severity under the baseline, never on the remedy. + if v := eligibility(iss, drainrule.Baseline(), deferralAnchor{}); v.Rule == RuleRemedy { + t.Fatalf("a failed audit's issue must not be ineligible on its remedy: %+v", v) + } +} + +// TestConcurrentFailedIngestsOfOneReceiptFileOneIssue is the reviewer's probe +// of the "link, never double" rule: the open-issue scan and the filing are one +// step under the ledger lock, so four ingests of one NOT_MET verdict racing +// each other file ONE issue and every other one links to it. +func TestConcurrentFailedIngestsOfOneReceiptFileOneIssue(t *testing.T) { + root, rcp := auditOwedRepo(t) + verdict := auditVerdict(t, root, rcp, "NOT_MET") + const n = 4 + results := make([]intent.IngestVerdictResult, n) + errs := make([]error, n) + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + results[i], errs[i] = intent.IngestVerdictBytes(root, verdict) + }(i) + } + close(start) + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("ingest %d: %v", i, err) + } + } + open := openIssues(t, root) + if len(open) != 1 { + t.Fatalf("four concurrent ingests of one failed audit must file ONE issue, found %d", len(open)) + } + filed := 0 + for i, r := range results { + if r.OwedIssue != open[0].ID { + t.Fatalf("ingest %d names %q, want the one open issue %s", i, r.OwedIssue, open[0].ID) + } + if !r.OwedIssueLinked { + filed++ + } + } + if filed != 1 { + t.Fatalf("exactly one ingest files and every other links, got %d filings: %+v", filed, results) + } +} + +// orphanCarriers captures extra open carriers of rcp's owed check beside the +// one the first failed ingest filed, as a lost race or a failed intent write +// left them. Their ids sort after the filed one, so it stays the oldest. +func orphanCarriers(t *testing.T, root, rcp string, ids ...string) { + t.Helper() + for _, id := range ids { + if _, err := Capture(CaptureRequest{ + RepoRoot: root, ForceID: id, + Text: "The after-merge fidelity audit of itd-10 judged a criterion unmet\n\nThe audit of itd-10 (receipt " + rcp + ") judged ac-1 NOT_MET.\n", + Severity: "major", Category: "bug", Source: "agent-finding", + FoundDuring: "abcd intent audit ingest", RelatedIntents: []string{"itd-10"}, + Remedy: "fix, then re-run the audit", + }); err != nil { + t.Fatal(err) + } + } +} + +func TestAFailedIngestLinksTheOldestCarrierAndDeclinesTheOthersAsDuplicates(t *testing.T) { + root, rcp := auditOwedRepo(t) + first, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "NOT_MET")) + if err != nil { + t.Fatal(err) + } + orphanCarriers(t, root, rcp, "iss-9912312359590001", "iss-9912312359590002") + res, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "NOT_MET")) + if err != nil { + t.Fatal(err) + } + open := openIssues(t, root) + if len(open) != 1 || open[0].ID != first.OwedIssue || !res.OwedIssueLinked || res.OwedIssue != first.OwedIssue { + t.Fatalf("the ingest must link the oldest carrier %s and leave it the only open one: %+v, open %+v", first.OwedIssue, res, open) + } + wf, err := List(ListRequest{RepoRoot: root, State: StateWontfix}) + if err != nil { + t.Fatal(err) + } + if len(wf.Issues) != 2 { + t.Fatalf("the two extra carriers must be declined as duplicates, found %d in wontfix/", len(wf.Issues)) + } + for _, iss := range wf.Issues { + if !contains(iss.Duplicates, first.OwedIssue) { + t.Fatalf("%s must carry duplicates: %s, got %+v", iss.ID, first.OwedIssue, iss.Duplicates) + } + } +} + +func TestAPassingReAuditResolvesEveryOpenCarrier(t *testing.T) { + root, rcp := auditOwedRepo(t) + if _, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "NOT_MET")); err != nil { + t.Fatal(err) + } + orphanCarriers(t, root, rcp, "iss-9912312359590001", "iss-9912312359590002") + if n := len(openIssues(t, root)); n != 3 { + t.Fatalf("fixture: want 3 open carriers, found %d", n) + } + res, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "MET")) + if err != nil { + t.Fatal(err) + } + if open := openIssues(t, root); len(open) != 0 || res.FlagCleared == "" { + t.Fatalf("a passing re-audit must resolve every open carrier: %+v, open %+v", res, open) + } +} + +// TestAPassingAuditResolvesAnOrphanCarrierOfAnUnflaggedReceipt: an ingest whose +// intent write failed after its filing leaves an open carrier while the +// receipt carries no flag. A passing audit of that receipt sweeps the open +// carriers of its check all the same, so the orphan does not outlive it. +func TestAPassingAuditResolvesAnOrphanCarrierOfAnUnflaggedReceipt(t *testing.T) { + root, rcp := auditOwedRepo(t) + orphanCarriers(t, root, rcp, "iss-9912312359590001") + res, err := intent.IngestVerdictBytes(root, auditVerdict(t, root, rcp, "MET")) + if err != nil { + t.Fatal(err) + } + if open := openIssues(t, root); len(open) != 0 { + t.Fatalf("a passing audit must resolve the orphan carrier of its receipt: %+v, open %+v", res, open) + } +} diff --git a/internal/core/capture/workflow.go b/internal/core/capture/workflow.go index 302c499a3..736a704bf 100644 --- a/internal/core/capture/workflow.go +++ b/internal/core/capture/workflow.go @@ -30,7 +30,27 @@ import ( // ledger lock, so the sweep's classify-then-unlink can no longer interleave with // a commit's fill and delete a just-committed issue file. func mutationPreamble(repoRoot, issuesRoot string) error { - if err := withLedgerLock(repoRoot, issuesRoot, func() error { + return mutationPreambleWith(withLedgerLock, repoRoot, issuesRoot) +} + +// ledgerLocker runs fn under the ledger lock. withLedgerLock takes it; +// heldLedgerLock is what a caller already holding it passes, since the flock +// is not reentrant and a second acquisition in one process waits out its +// budget and fails. +type ledgerLocker func(repoRoot, issuesRoot string, fn func() error) error + +// heldLedgerLock is the ledgerLocker of a caller that holds the ledger lock +// already: it asserts the ledger's directories, as withLedgerLock does before +// it locks, and runs fn in the caller's hold. +func heldLedgerLock(repoRoot, issuesRoot string, fn func() error) error { + if err := ensureLedgerDirs(repoRoot, issuesRoot); err != nil { + return err + } + return fn() +} + +func mutationPreambleWith(lock ledgerLocker, repoRoot, issuesRoot string) error { + if err := lock(repoRoot, issuesRoot, func() error { return cleanOrphanPlaceholders(repoRoot, issuesRoot) }); err != nil { return err @@ -42,6 +62,18 @@ func mutationPreamble(repoRoot, issuesRoot string) error { // The write is transactional: a zero-byte placeholder is reserved, and on any // failure it is swept. Returns the committed path under open/. func Capture(req CaptureRequest) (CaptureResult, error) { + return captureWith(withLedgerLock, req) +} + +// captureHeld is Capture for a caller that already holds the ledger lock and +// needs its own read of the ledger and the filing to be one step: the owed +// audit filer's "link, never double" scan (auditowed.go). Every hold Capture +// would take runs in the caller's instead. +func captureHeld(req CaptureRequest) (CaptureResult, error) { + return captureWith(heldLedgerLock, req) +} + +func captureWith(lock ledgerLocker, req CaptureRequest) (CaptureResult, error) { repoRoot, issuesRoot, err := resolveRoots(req.RepoRoot, req.IssuesRoot) if err != nil { return CaptureResult{}, err @@ -69,7 +101,7 @@ func Capture(req CaptureRequest) (CaptureResult, error) { if err := checkFoundAt(repoRoot, req.FoundAt); err != nil { return CaptureResult{}, err } - if err := mutationPreamble(repoRoot, issuesRoot); err != nil { + if err := mutationPreambleWith(lock, repoRoot, issuesRoot); err != nil { return CaptureResult{}, err } // The targets go through the ONE blocked_by validator link shares @@ -140,12 +172,12 @@ func Capture(req CaptureRequest) (CaptureResult, error) { // no maximum, so the refs-union scan the max+1 allocator needed (iss-115, // iss-120) is gone — time orders the ids and entropy separates same-second // minters on branches this working tree cannot see. - issID, placeholder, err := reservePath(repoRoot, issuesRoot, slugNorm, req.ForceID) + issID, placeholder, err := reservePathWith(lock, repoRoot, issuesRoot, slugNorm, req.ForceID) if err != nil { return CaptureResult{}, err } - result, err := commitCapture(repoRoot, issuesRoot, req, issID, slugNorm, placeholder) + result, err := commitCaptureWith(lock, repoRoot, issuesRoot, req, issID, slugNorm, placeholder) if err != nil { _ = cancelReservation(repoRoot, issuesRoot, placeholder) return CaptureResult{}, err @@ -180,6 +212,10 @@ func validateRequestEnums(req CaptureRequest) error { } func commitCapture(repoRoot, issuesRoot string, req CaptureRequest, issID, slug, placeholder string) (CaptureResult, error) { + return commitCaptureWith(withLedgerLock, repoRoot, issuesRoot, req, issID, slug, placeholder) +} + +func commitCaptureWith(lock ledgerLocker, repoRoot, issuesRoot string, req CaptureRequest, issID, slug, placeholder string) (CaptureResult, error) { // The disclosure pair (itd-178). origin is DERIVED — a capture's text is // written directly rather than derived from another record or a reading // item, so its route is researcher-authored (which names the route, not who @@ -269,7 +305,7 @@ func commitCapture(repoRoot, issuesRoot string, req CaptureRequest, issID, slug, // just-committed file deleted. If the sweep reclaimed the placeholder first, the // re-read fails and the capture reports an error rather than a false success. var result CaptureResult - err = withLedgerLock(repoRoot, issuesRoot, func() error { + err = lock(repoRoot, issuesRoot, func() error { // Guard the overwrite: the placeholder must still be the zero-byte file we // reserved (expected_checksum = sha256("")). _, checksum, rerr := readWithChecksum(placeholder) diff --git a/internal/core/implement/loop/brief_steps_test.go b/internal/core/implement/loop/brief_steps_test.go index 5aebab4f6..2d43f6714 100644 --- a/internal/core/implement/loop/brief_steps_test.go +++ b/internal/core/implement/loop/brief_steps_test.go @@ -144,7 +144,7 @@ func TestTheBriefNamesWhatAnEarlierLaneOfTheRunBuilt(t *testing.T) { if i < 0 { t.Fatal("the run completed before the second lane was briefed") } - if a := st.Lanes[i].Awaiting; a != nil { + if a := st.Lanes[i].awaiting(); a != nil { if err := os.WriteFile(a.Receipt, []byte("{}\n"), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/core/implement/loop/check.go b/internal/core/implement/loop/check.go index 40a6c7c81..25e3f6703 100644 --- a/internal/core/implement/loop/check.go +++ b/internal/core/implement/loop/check.go @@ -161,7 +161,9 @@ func check(repoRoot, key, session string, snap *peerSnapshot) (CheckResult, erro res.Checks = append(res.Checks, CheckRow{Name: r.Name, OK: r.OK, Detail: r.Detail, Remedy: r.Remedy}) } for _, st := range record.Steps { - res.steps = append(res.steps, PendingStep{Number: st.Number, Title: st.Title}) + // The step's needs, resolved: its `- needs:` line, or every earlier + // step (ruling DR6b). + res.steps = append(res.steps, PendingStep{Number: st.Number, Title: st.Title, Needs: st.Requires()}) } if snap == nil { diff --git a/internal/core/implement/loop/drain_test.go b/internal/core/implement/loop/drain_test.go index e3410bd97..60c40b182 100644 --- a/internal/core/implement/loop/drain_test.go +++ b/internal/core/implement/loop/drain_test.go @@ -40,7 +40,7 @@ func handBackLaneOf(t *testing.T, repo *gittest.Repo, runID string, o Options, h if err != nil { t.Fatal(err) } - if st.Lanes[0].Awaiting != nil { + if st.Lanes[0].awaiting() != nil { break } if _, err := advance(repo.Root(), runID, DefaultStages(), o); err != nil { diff --git a/internal/core/implement/loop/drive_test.go b/internal/core/implement/loop/drive_test.go index 65b6e2660..b306119b5 100644 --- a/internal/core/implement/loop/drive_test.go +++ b/internal/core/implement/loop/drive_test.go @@ -354,7 +354,7 @@ func TestARunnerThatCannotRunTheRoleFallsBackAndIsRecorded(t *testing.T) { if len(st.Fallbacks) != 1 || st.Fallbacks[0] != *fb { t.Fatalf("the state carries the one fallback receipt: %+v", st.Fallbacks) } - if st.Lanes[0].Awaiting == nil { + if len(st.Lanes[0].Awaits) == 0 { t.Fatal("the lane still awaits the host's receipt") } if len(st.Lanes[0].Receipts) != 0 { @@ -401,7 +401,7 @@ func TestAReviewThroughARunnerDiffersFromAHostReviewOnlyInItsRoute(t *testing.T) return Outcome{Await: &Await{Role: RoleRuthless, Brief: briefRel, Receipt: returnRel}}, nil }, Verify: func(c Context, l *Lane, receipt string) error { - l.Awaiting.Role = RoleRuthless + c.Await.Role = RoleRuthless return verifyValidation(c, l, receipt) }} o := Options{Now: func() time.Time { return time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) }} diff --git a/internal/core/implement/loop/handback.go b/internal/core/implement/loop/handback.go index 7df84aa18..41086e80a 100644 --- a/internal/core/implement/loop/handback.go +++ b/internal/core/implement/loop/handback.go @@ -23,7 +23,7 @@ func handBackLane(st *State, lane *Lane, hb HandBack, note string, now time.Time hb.At = now lane.HandBack = &hb lane.Stage = StageHandedBack - lane.Awaiting = nil + lane.Awaits = nil if note == "" { note = handBackSummary(st.Intent, hb) } @@ -79,17 +79,48 @@ func handBackMove(st State, lane Lane) string { "; " + keyOf(st) + " is the person's to replan from those findings" } -// handedBackRefusal is every later step's answer on a handed-back lane. -func handedBackRefusal(st State, lane Lane) error { +// handedBackRefusal is a step's answer once a lane of the run was handed back +// and nothing is left to move but wait for the person (ruling DR6c): it names +// the hand-back and every held lane with the way out for each. +func handedBackRefusal(st State) error { + var lane Lane + for _, l := range st.Lanes { + if l.Stage == StageHandedBack { + lane = l + break + } + } reason := lane.ID + " was handed back" if lane.HandBack != nil { reason = handBackSummary(keyOf(st), *lane.HandBack) } + var held []string + for _, l := range st.Lanes { + if l.Stage == StageHeld && l.Hold != nil { + held = append(held, fmt.Sprintf("%s is held at %s before its %s (%s)", l.ID, shortSHA(l.Hold.Head), l.Hold.Before, heldWayOut(l.ID))) + } + } + if len(held) > 0 { + reason += "; " + strings.Join(held, "; ") + } return refuse(string(StageHandedBack), "", lane.ID, reason, "the loop starts nothing further for this lane; "+keyOf(st)+" is the person's to replan from the findings the reason names; "+ handedBackWayOut(st)) } +// heldWayOut names the person's two choices over a held lane. +func heldWayOut(laneID string) string { + return "land it as it is with `abcd implement step --release " + laneID + "`, or discard it with `abcd implement step --discard " + laneID + "`" +} + +// heldMove is what the caller is told about a held lane. +func heldMove(lane Lane) string { + if lane.Hold == nil { + return lane.ID + " is held" + } + return fmt.Sprintf("%s is held after %s's hand-back, at %s before its %s: %s", lane.ID, lane.Hold.Cause, shortSHA(lane.Hold.Head), lane.Hold.Before, heldWayOut(lane.ID)) +} + // handedBackWayOut names the one way past a handed-back run. The run stays // live by construction (Complete is false while a lane sits at handed-back, so // the run resumes and the pick excludes its intent) until terminal liveness diff --git a/internal/core/implement/loop/hold.go b/internal/core/implement/loop/hold.go new file mode 100644 index 000000000..df5d5cf37 --- /dev/null +++ b/internal/core/implement/loop/hold.go @@ -0,0 +1,212 @@ +package loop + +// hold.go is what a run does with its other lanes once one is handed back +// (ruling DR6c, 2026-09-29: "FINISH, BUT HOLD THEM: when one piece is handed +// back, pieces in flight finish but nothing merges until the person re-plans; +// the person then decides whether the held pieces land as they are"). A +// sibling whose round passes after the hand-back does not land: it takes the +// stage `held` before the first landing step that reaches past the machine or +// merges — before the push when it has not pushed, before arming when it has +// opened its pull request, and disarmed (`gh pr merge <n> --disable-auto`) +// when it was already armed. The person's word on each held lane is given +// through `implement step --release <lane>` (it lands as it is) or `--discard +// <lane>` (it does not land), once no lane of the run has anything left to do. + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// judgedHead is the head the lane's last round judged, or its head. +func judgedHead(lane Lane) string { + if n := len(lane.Validation); n > 0 { + return lane.Validation[n-1].HeadSHA + } + return lane.HeadSHA +} + +// handedBackCause is the id of the run's handed-back lane. +func handedBackCause(st State) string { + for _, l := range st.Lanes { + if l.Stage == StageHandedBack { + return l.ID + } + } + return "" +} + +// holdLane holds a lane at its landing after a sibling's hand-back. An armed +// lane is disarmed through the forge client first; one whose pushed head the +// default branch already holds, or whose pull request the forge reports merged, +// had landed before the hand-back, and its landing runs on to record it. The +// forge is asked because the local tracking ref is not fetched for the hold and +// may lag the merge. +func holdLane(c Context, lane *Lane) (Outcome, error) { + before := HoldBeforePush + if ld := lane.Landing; ld != nil && ld.Pushed != "" { + before = HoldBeforeArm + if ld.Merge != "" { + if ld.Armed { + if def, err := defaultBranch(c, *lane); err == nil { + if on, err := gitutil.IsAncestor(c.RepoRoot, ld.Pushed, "refs/remotes/"+Remote+"/"+def); err == nil && on { + return landStage(c, lane) + } + } + n := strconv.Itoa(lane.PR) + state, err := forge(c, *lane, "pr", "view", n, "--json", "state", "--jq", ".state") + if err != nil { + return Outcome{}, err + } + if strings.TrimSpace(state) == "MERGED" { + return landStage(c, lane) + } + if _, err := forge(c, *lane, "pr", "merge", n, "--disable-auto"); err != nil { + r, _ := AsRefusal(err) + why := err.Error() + if r != nil { + why = r.Reason + } + return Outcome{}, refuse(string(StageHeld), "", lane.ID, + "pull request #"+n+" is armed to merge and "+handedBackCause(c.State)+" was handed back, but the forge refused to withdraw the arming: "+why, + "decide pull request #"+n+" yourself on the forge (disarm or close it), then run `abcd implement step` again") + } + } + cp := *ld + cp.Merge, cp.Armed = "", false + lane.Landing = &cp + } + } + cause := handedBackCause(c.State) + lane.Hold = &Hold{Since: c.Now, Cause: cause, Head: judgedHead(*lane), Before: before} + note := fmt.Sprintf("%s held after %s's hand-back: its passing round judged %s, and it stopped before its %s; nothing merges until the person decides (%s)", + lane.ID, cause, shortSHA(lane.Hold.Head), before, heldWayOut(lane.ID)) + return Outcome{Goto: StageHeld, Note: note}, nil +} + +// busyLanes names the lanes of the run with work left: an agent out or a stage +// to take. A done, handed-back, held or discarded lane has none. +func busyLanes(st State) []string { + var out []string + for _, i := range st.laneOrder() { + l := st.Lanes[i] + switch l.Stage { + case StageDone, StageHandedBack, StageHeld, StageDiscarded: + continue + } + what := string(l.Stage) + for _, a := range l.Awaits { + what += ", awaiting the " + a.Role + } + out = append(out, l.ID+" ("+what+")") + } + return out +} + +// decidable finds the held lane a --release or --discard names, refusing a +// lane that is not held and a run with any lane still at work. +func decidable(st State, laneID, flag string) (int, error) { + i := -1 + for k, l := range st.Lanes { + if l.ID == laneID { + i = k + } + } + if i < 0 { + return -1, refuse(string(StageHeld), "", "", fmt.Sprintf("%s has no lane %q", st.RunID, laneID), + "name a held lane `abcd implement status` lists") + } + if l := st.Lanes[i]; l.Stage != StageHeld || l.Hold == nil { + return -1, refuse(string(StageHeld), "", laneID, fmt.Sprintf("%s is %s, not held, so %s does not apply to it", laneID, l.Stage, flag), + "name a held lane `abcd implement status` lists; nothing was changed") + } + if busy := busyLanes(st); len(busy) > 0 { + return -1, contend(string(StageHeld), "", laneID, "lanes of "+st.RunID+" are still at work: "+strings.Join(busy, "; "), + "run `abcd implement step` until they finish or are held, then decide over the held lanes together; nothing was changed") + } + return i, nil +} + +// Release lands a held lane as it is, on the person's word (`implement step +// --release <lane>`): its stage returns to `land` and its landing resumes at the +// step it stopped before, synced first when a sibling landed since its base. +// It changes nothing when the lane is not held or any lane is still at work. +func Release(repoRoot, runID, laneID string, o Options) (StepResult, error) { + var res StepResult + err := mutate(repoRoot, runID, func(root *os.Root, st *State) (bool, error) { + i, err := decidable(*st, laneID, "--release") + if err != nil { + return false, err + } + now := o.now() + lane := st.Lanes[i] + h := *lane.Hold + h.Released = true + lane.Hold = &h + lane.Stage = StageLand + st.Lanes[i] = lane + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: "release", + Note: fmt.Sprintf("the person released %s to land as it is, at %s; its landing resumes at its %s", lane.ID, shortSHA(h.Head), h.Before)}) + st.UpdatedAt = now + res = laneResult(*st, lane, "", nil) + return true, nil + }) + return res, err +} + +// Discard does not land a held lane, on the person's word (`implement step +// --discard <lane>`): its worktree is removed from the machine store and its +// branch deleted, then its pull request is closed if it opened one, and its +// stage is `discarded`; its step stays unlanded in the spec, so a replanned +// remainder carries it. The local removals come first, so a refused one leaves +// the pull request open and the lane held, and a retry finds nothing half done: +// a worktree or branch already gone is passed over. It changes nothing when the +// lane is not held or any lane is still at work. +func Discard(repoRoot, runID, laneID string, o Options) (StepResult, error) { + var res StepResult + err := mutate(repoRoot, runID, func(root *os.Root, st *State) (bool, error) { + i, err := decidable(*st, laneID, "--discard") + if err != nil { + return false, err + } + now := o.now() + lane := st.Lanes[i] + c := Context{RepoRoot: repoRoot, RunDir: runRel(st.RunID), State: *st, Now: now} + did := []string{} + if lane.Worktree != "" { + if err := removeLaneWorktree(c, lane); err != nil { + return false, err + } + did = append(did, "removed its worktree") + } + if lane.Branch != "" { + tip, err := gitutil.Run(repoRoot, "rev-parse", "--verify", "--quiet", "refs/heads/"+lane.Branch+"^{commit}", "--") + if err == nil && gitutil.IsFullSHA(tip) { + if _, err := gitutil.Run(repoRoot, "update-ref", "-d", "refs/heads/"+lane.Branch, tip); err != nil { + return false, refuse(string(StageHeld), "", lane.ID, "git could not delete "+lane.Branch+": "+fsutil.RedactHome(err.Error()), + "settle what git reports, then run `abcd implement step --discard "+lane.ID+"` again") + } + did = append(did, "deleted its branch "+lane.Branch+" at "+shortSHA(tip)) + } + } + if lane.PR > 0 { + n := strconv.Itoa(lane.PR) + if _, err := forge(c, lane, "pr", "close", n); err != nil { + return false, err + } + did = append(did, "closed pull request #"+n) + } + lane.Stage = StageDiscarded + st.Lanes[i] = lane + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: "discard", + Note: fmt.Sprintf("the person discarded %s (%s); step %d stays unlanded in %s", lane.ID, strings.Join(did, ", "), lane.SpecStep, specOf(*st))}) + st.UpdatedAt = now + res = laneResult(*st, lane, "", nil) + return true, nil + }) + return res, err +} diff --git a/internal/core/implement/loop/hold_approval_test.go b/internal/core/implement/loop/hold_approval_test.go new file mode 100644 index 000000000..712d460c6 --- /dev/null +++ b/internal/core/implement/loop/hold_approval_test.go @@ -0,0 +1,59 @@ +package loop + +// hold_approval_test.go is where DR6c's hold meets AM1's arm decision: a held +// lane is never armed, and a released lane lands through the arm decision, so +// it is armed only where the ruleset requires a person's approval and is left +// open for a person where none does. + +import ( + "strings" + "testing" +) + +// releaseHeldLane2 steps until lane 2 is held before its arm, checks it holds +// no arming, releases it and steps until its landing has decided its merge. +func releaseHeldLane2(t *testing.T, f *parFixture) Lane { + t.Helper() + f.handBackLane1(t) + f.stepUntil(t, "lane-2 is held", func(st State) bool { return st.Lanes[1].Stage == StageHeld }) + if l := f.lane(t, "lane-2"); l.Hold == nil || l.Hold.Before != HoldBeforeArm || l.Landing.Armed || l.Landing.Merge != "" { + t.Fatalf("lane 2 is held before its arm, holding no merge decision: %+v", l) + } + if _, err := Release(f.repo.Root(), f.runID, "lane-2", f.opts()); err != nil { + t.Fatal(err) + } + for range 5 { + if l := f.lane(t, "lane-2"); l.Landing.Merge != "" { + return l + } + f.step(t) + } + t.Fatalf("the released lane never reached its arm decision: %+v", f.lane(t, "lane-2")) + return Lane{} +} + +func TestAReleasedLaneIsLeftOpenWhereTheRulesetRequiresNoApproval(t *testing.T) { + f := siblingAtArm(t, false, unreviewedQueueRuleset("MERGE"), func(ld *Landing) bool { return ld.Merge != "" }) + if l := f.lane(t, "lane-2"); l.Landing.Armed || !strings.HasPrefix(l.Landing.Merge, leftOpenForAPerson) { + t.Fatalf("lane 2 is left open before the hand-back: %+v", l.Landing) + } + l := releaseHeldLane2(t, f) + if l.Landing.Armed || !strings.HasPrefix(l.Landing.Merge, leftOpenForAPerson) { + t.Fatalf("the released lane is left open for a person: %+v", l.Landing) + } + if strings.Contains(f.ghLog(t), "--auto") { + t.Fatalf("no lane of a run without a required approval is ever armed:\n%s", f.ghLog(t)) + } +} + +func TestAReleasedLaneIsArmedWhereTheRulesetRequiresApproval(t *testing.T) { + f := armedSibling(t, false) + l := releaseHeldLane2(t, f) + if !l.Landing.Armed { + t.Fatalf("the released lane is armed again: %+v", l.Landing) + } + log := f.ghLog(t) + if strings.Count(log, "pr merge 7 --auto") != 2 || strings.Count(log, "pr merge 7 --disable-auto") != 1 { + t.Fatalf("armed, disarmed at the hold, armed again at the release:\n%s", log) + } +} diff --git a/internal/core/implement/loop/hold_test.go b/internal/core/implement/loop/hold_test.go new file mode 100644 index 000000000..687e34a3d --- /dev/null +++ b/internal/core/implement/loop/hold_test.go @@ -0,0 +1,205 @@ +package loop + +// hold_test.go is DR6c's armed case (spc-2609202134341288, "The `held` +// state"): a sibling already armed when a lane is handed back is disarmed +// through the forge client and held, or, where the forge refuses the +// withdrawal, the step refuses naming the pull request; a lane the forge +// reports merged had landed before the hand-back. And the person's discard of a +// held lane with a pull request, which a refused worktree removal leaves +// retryable. + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// armedSibling stands up a run whose lane 2 has landed as far as its armed +// merge while lane 1 is still at work: steps 2 (and, with third, 3) need none, +// and every implementer is out before lane 2's receipt. +func armedSibling(t *testing.T, third bool) *parFixture { + t.Helper() + return siblingAtArm(t, third, queueRuleset("MERGE"), func(ld *Landing) bool { return ld.Armed }) +} + +// siblingAtArm is armedSibling over the ruleset mirror given, stepping lane 2 +// until its landing satisfies at. +func siblingAtArm(t *testing.T, third bool, ruleset string, at func(*Landing) bool) *parFixture { + t.Helper() + steps, lanes := "1. One\n2. Two\n - needs: none\n", 2 + if third { + steps, lanes = steps+"3. Three\n - needs: none\n", 3 + } + f := newParFixtureRuleset(t, steps, Options{SubAgents: strp("5"), FixRounds: strp("1")}, ruleset) + f.stepUntil(t, "every implementer is out", func(st State) bool { + if len(st.Lanes) != lanes { + return false + } + for _, l := range st.Lanes { + if len(l.Awaits) != 1 { + return false + } + } + return true + }) + f.implement(t, "lane-2", "two.txt") + f.roundPassed(t, "lane-2") + for range 20 { + l := f.lane(t, "lane-2") + if l.Landing != nil && at(l.Landing) { + return f + } + if l.Landing != nil && l.Landing.RecordsDone && l.Landing.Pushed == "" { + preflighted(t, l, l.HeadSHA) + } + f.step(t) + } + t.Fatalf("lane 2 never reached its arm: %+v", f.lane(t, "lane-2")) + return nil +} + +// handedBack takes lane 1 through its fix round to its hand-back, and stops +// there: the next step is the first after the hand-back. +func (f *parFixture) handedBack(t *testing.T) { + t.Helper() + f.handBackLane1(t) + if l := f.lane(t, "lane-2"); l.Stage != StageLand || l.Landing == nil || !l.Landing.Armed { + t.Fatalf("lane 2 is still armed at the hand-back: %+v", l) + } +} + +// handBackLane1 is handedBack without its check on lane 2. +func (f *parFixture) handBackLane1(t *testing.T) { + t.Helper() + f.implement(t, "lane-1", "one.txt") + for round := 1; round <= 2; round++ { + f.stepUntil(t, "lane-1's reviewers are out", func(st State) bool { return len(st.Lanes[0].Awaits) == 2 }) + f.ret(t, "lane-1", RoleRuthless, "FIX FIRST") + f.ret(t, "lane-1", RoleSecurity, "APPROVE") + if round == 1 { + f.stepUntil(t, "lane-1's fix implementer is out", func(st State) bool { return len(st.Lanes[0].Awaits) == 1 }) + f.receipt(t, "lane-1", f.await(t, "lane-1", RoleImplementer), laneCommit(t, f.repo, f.lane(t, "lane-1"), "fix.txt")) + } + } + f.stepUntil(t, "lane-1 is handed back", func(st State) bool { return st.Lanes[0].Stage == StageHandedBack }) +} + +func (f *parFixture) touchGH(t *testing.T, name, body string) { + t.Helper() + if err := os.WriteFile(filepath.Join(f.gh, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// A landing waiting on the forge's merge holds only its own lane: the call +// moves another, and names the wait under blocked and in its next move. +func TestALandingWaitingOnItsMergeIsNamedBesideTheMoveTaken(t *testing.T) { + f := armedSibling(t, false) + f.implement(t, "lane-1", "one.txt") + res := f.step(t) + if res.Lane != "lane-1" || res.Awaiting == nil || res.Awaiting.Role != RoleRuthless { + t.Fatalf("the call hands out lane 1's reviewer while lane 2's merge waits: %+v", res) + } + if len(res.Blocked) != 1 || res.Blocked[0].Lane != "lane-2" || !res.Blocked[0].Contention { + t.Fatalf("the result names lane 2's wait under blocked: %+v", res.Blocked) + } + if !strings.Contains(res.Next, "lane-2") || !strings.Contains(res.Next, "pull request #7 is not merged yet") { + t.Fatalf("the next move names lane 2's wait: %s", res.Next) + } +} + +// DR6c, the armed sibling: where the forge refuses to withdraw the arming, the +// step refuses naming the pull request and moves no other lane; once the forge +// withdraws it, the lane is held before arming, its merge disarmed. +func TestAnArmedSiblingIsDisarmedOrTheStepRefusesNamingItsPullRequest(t *testing.T) { + f := armedSibling(t, true) + f.handedBack(t) + // Lane 3 has a reviewer to hand out: a step that moved on would move it. + f.implement(t, "lane-3", "three.txt") + f.touchGH(t, "refuse-disarm", "") + before := stateBytes(t, f.repo.Root(), f.runID) + res, err := advance(f.repo.Root(), f.runID, f.stages, f.opts()) + r := mustRefusal(t, err) + if r.Contention || r.Lane != "lane-2" || !strings.Contains(r.Reason, "pull request #7") || !strings.Contains(r.Remedy, "pull request #7") { + t.Fatalf("the step refuses naming the armed pull request: %+v %+v", r, res) + } + if !bytes.Equal(before, stateBytes(t, f.repo.Root(), f.runID)) { + t.Fatalf("a refused disarm moves no other lane: %+v", f.lane(t, "lane-3")) + } + + if err := os.Remove(filepath.Join(f.gh, "refuse-disarm")); err != nil { + t.Fatal(err) + } + res = f.step(t) + l2 := f.lane(t, "lane-2") + if res.Lane != "lane-2" || l2.Stage != StageHeld || l2.Hold == nil || l2.Hold.Before != HoldBeforeArm || l2.Hold.Cause != "lane-1" || l2.Landing.Armed || l2.Landing.Merge != "" { + t.Fatalf("the disarmed lane is held before arming: %+v %+v", res, l2) + } + if strings.Count(f.ghLog(t), "pr merge 7 --disable-auto") != 2 { + t.Fatalf("the forge was asked twice to withdraw the arming:\n%s", f.ghLog(t)) + } +} + +// DR6c, landed before the hand-back: an armed lane the forge reports merged is +// recorded as landed, not disarmed, though the local tracking ref, never +// fetched for the hold, has not caught up. +func TestAnArmedSiblingTheForgeReportsMergedIsRecordedAsLanded(t *testing.T) { + f := armedSibling(t, false) + f.handedBack(t) + l2 := f.lane(t, "lane-2") + stale := strings.TrimSpace(f.repo.Git("rev-parse", "refs/remotes/origin/main")) + f.repo.Git("push", "-q", "origin", l2.Landing.Pushed+":refs/heads/main") + f.repo.Git("update-ref", "refs/remotes/origin/main", stale) + f.touchGH(t, "state", "MERGED\n") + f.step(t) + l2 = f.lane(t, "lane-2") + if l2.Stage != StageDone || l2.Hold != nil || l2.Landing.Merged == "" { + t.Fatalf("the merged lane is recorded as landed: %+v", l2) + } + if log := f.ghLog(t); strings.Contains(log, "--disable-auto") { + t.Fatalf("a merged pull request is never disarmed:\n%s", log) + } +} + +// A discard removes the lane's worktree and branch before it closes the pull +// request: a worktree git refuses to remove leaves the pull request open and +// the lane held, and the retry closes it once. +func TestADiscardRemovesTheLaneLocallyBeforeItClosesItsPullRequest(t *testing.T) { + f := armedSibling(t, false) + f.handedBack(t) + f.step(t) + l2 := f.lane(t, "lane-2") + if l2.Stage != StageHeld || l2.PR != 7 { + t.Fatalf("lane 2 is held with its pull request open: %+v", l2) + } + stray := filepath.Join(l2.Worktree, "stray.txt") + if err := os.WriteFile(stray, []byte("work\n"), 0o600); err != nil { + t.Fatal(err) + } + before := stateBytes(t, f.repo.Root(), f.runID) + _, err := Discard(f.repo.Root(), f.runID, "lane-2", f.opts()) + if r := mustRefusal(t, err); !strings.Contains(r.Reason, "worktree") { + t.Fatalf("a worktree with changes refuses the discard: %+v", r) + } + if n := strings.Count(f.ghLog(t), "pr close 7"); n != 0 || !bytes.Equal(before, stateBytes(t, f.repo.Root(), f.runID)) { + t.Fatalf("a refused discard closes no pull request (%d) and leaves the lane held", n) + } + if err := os.Remove(stray); err != nil { + t.Fatal(err) + } + res, err := Discard(f.repo.Root(), f.runID, "lane-2", f.opts()) + if err != nil || res.Stage != StageDiscarded { + t.Fatalf("the retry discards the lane: %+v %v", res, err) + } + if n := strings.Count(f.ghLog(t), "pr close 7"); n != 1 { + t.Fatalf("the pull request is closed once: %d\n%s", n, f.ghLog(t)) + } + if _, err := os.Stat(l2.Worktree); !os.IsNotExist(err) { + t.Fatalf("the lane's worktree is gone: %v", err) + } + if gitErr(f.repo, "rev-parse", "--verify", "--quiet", "refs/heads/"+l2.Branch) == nil { + t.Fatal("the lane's branch is gone") + } +} diff --git a/internal/core/implement/loop/land.go b/internal/core/implement/loop/land.go index 6fa53bba3..ec47d4a18 100644 --- a/internal/core/implement/loop/land.go +++ b/internal/core/implement/loop/land.go @@ -27,8 +27,9 @@ package loop // holds and strips a session URL or a tool footer the harness appended. // 5. arm: the merge rule from the ruleset mirror at the lane's base // (.abcd/work/rulesets/): auto-merge armed with the queue's method where a -// merge queue gates the default branch, the pull request left open where -// none does (decision 3). Nothing is pushed to the lane after this step. +// merge queue gates the default branch and a ruleset requires a person's +// approval, the pull request left open for a person where either is absent +// (decision 3; ruling AM1). Nothing is pushed to the lane after this step. // 6. merged: the lane's pushed head must be an ancestor of the default // branch as the remote holds it; until it is the step waits, and only then // is the lane's worktree removed and its branch deleted, and the lane done. @@ -127,6 +128,11 @@ func landStage(c Context, lane *Lane) (Outcome, error) { "the earlier stages record them; restore the run's state file") } if lane.Landing == nil { + // A sibling lane of the run that landed since this lane's base is + // merged in first, and a fresh round judges the merge head. + if out, synced, err := syncLane(c, lane); err != nil || synced { + return out, err + } return landPrepare(c, lane) } ld := *lane.Landing @@ -700,37 +706,130 @@ func (r ruleset) targets(def string) bool { // mergeMethods maps a merge queue's method to the forge client's flag. var mergeMethods = map[string]string{"MERGE": "--merge", "SQUASH": "--squash", "REBASE": "--rebase"} +// codeOwnersPaths are where the forge reads a CODEOWNERS file from, in the +// order it looks: the first file found is the only one it reads. +var codeOwnersPaths = []string{".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"} + +// namesCodeOwners reports whether the CODEOWNERS file the forge reads at the +// lane's base — the first found in codeOwnersPaths, so a file there shadows +// the later ones even when it names nobody — names at least one owner: a line +// whose pattern is followed by an owner token (codeOwner). +func namesCodeOwners(c Context, lane Lane) bool { + for _, p := range codeOwnersPaths { + raw, err := gitutil.RunCappedBytes(c.RepoRoot, maxRulesetBytes, "cat-file", "blob", lane.BaseSHA+":"+p) + if err != nil { + continue + } + for _, line := range strings.Split(string(raw), "\n") { + if lineNamesOwner(line) { + return true + } + } + return false + } + return false +} + +// lineNamesOwner reports whether one CODEOWNERS line names an owner: after its +// pattern, a field before any comment is an owner token. A blank line, a +// comment and a pattern with no owner name nobody. +func lineNamesOwner(line string) bool { + fields := strings.Fields(line) + if len(fields) < 2 || strings.HasPrefix(fields[0], "#") { + return false + } + for _, f := range fields[1:] { + if strings.HasPrefix(f, "#") { + return false + } + if codeOwner(f) { + return true + } + } + return false +} + +// codeOwner reports whether a token is one the forge takes as an owner: +// @username, @org/team-name, or an e-mail address. +func codeOwner(tok string) bool { + if name, ok := strings.CutPrefix(tok, "@"); ok { + org, team, isTeam := strings.Cut(name, "/") + if isTeam { + return ownerName(org) && team != "" && !strings.ContainsAny(team, "/@") + } + return ownerName(name) + } + local, domain, ok := strings.Cut(tok, "@") + return ok && local != "" && strings.Contains(domain, ".") && !strings.HasPrefix(domain, ".") && + !strings.HasSuffix(domain, ".") && !strings.Contains(domain, "@") +} + +// ownerName reports whether s is a forge account name: letters, digits and +// hyphens, not empty. +func ownerName(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-') { + return false + } + } + return true +} + +// requiresApproval reports whether a pull_request rule's parameters require a +// person's approval: an approving count of one or more, or a code-owner review +// where a CODEOWNERS file at the lane's base names an owner. Parameters that +// do not parse require nothing, so the merge is left for a person. +func requiresApproval(c Context, lane Lane, params json.RawMessage) bool { + var p struct { + Count int `json:"required_approving_review_count"` + CodeOwner bool `json:"require_code_owner_review"` + } + if json.Unmarshal(params, &p) != nil { + return false + } + return p.Count >= 1 || (p.CodeOwner && namesCodeOwners(c, lane)) +} + // mergeRule reads the ruleset mirror at the lane's base: the merge queue's // method when an active ruleset gates the default branch through one, or "" -// when none does. The base is the default branch the lane was cut from, so the -// lane's own commits cannot change the rule it lands by. -func mergeRule(c Context, lane Lane, def string) (string, error) { +// when none does, and whether an active ruleset on the default branch requires +// a person's approval (ruling AM1). A missing mirror requires nothing. The +// base is the default branch the lane was cut from, so the lane's own commits +// cannot change the rule it lands by. +func mergeRule(c Context, lane Lane, def string) (string, bool, error) { names, err := gitutil.RunCappedBytes(c.RepoRoot, maxGitOutput, "ls-tree", "-z", "--name-only", lane.BaseSHA, "--", RulesetsRelDir+"/") if err != nil { - return "", fmt.Errorf("listing the ruleset mirror at the lane's base: %v", err) + return "", false, fmt.Errorf("listing the ruleset mirror at the lane's base: %v", err) } method := "" + approval := false count := 0 for _, name := range strings.Split(string(names), "\x00") { if !strings.HasSuffix(name, ".json") { continue } if count++; count > maxRulesets { - return "", refuse(string(StageLand), "", lane.ID, fmt.Sprintf("the ruleset mirror holds more than %d files", maxRulesets), "trim the mirror, then run `abcd implement step` again") + return "", false, refuse(string(StageLand), "", lane.ID, fmt.Sprintf("the ruleset mirror holds more than %d files", maxRulesets), "trim the mirror, then run `abcd implement step` again") } raw, err := gitutil.RunCappedBytes(c.RepoRoot, maxRulesetBytes, "cat-file", "blob", lane.BaseSHA+":"+name) if err != nil { - return "", refuse(string(StageLand), "", lane.ID, name+" cannot be read at the lane's base", "restore the ruleset mirror, then run `abcd implement step` again") + return "", false, refuse(string(StageLand), "", lane.ID, name+" cannot be read at the lane's base", "restore the ruleset mirror, then run `abcd implement step` again") } var rs ruleset if err := json.Unmarshal(raw, &rs); err != nil { - return "", refuse(string(StageLand), "", lane.ID, name+" does not parse as a ruleset, so the merge rule cannot be read", + return "", false, refuse(string(StageLand), "", lane.ID, name+" does not parse as a ruleset, so the merge rule cannot be read", "restore the ruleset mirror (its README says how to refresh it), then run `abcd implement step` again") } if !rs.targets(def) { continue } for _, rule := range rs.Rules { + if rule.Type == "pull_request" && requiresApproval(c, lane, rule.Parameters) { + approval = true + } if rule.Type != "merge_queue" { continue } @@ -743,28 +842,30 @@ func mergeRule(c Context, lane Lane, def string) (string, error) { m = "MERGE" } if _, ok := mergeMethods[m]; !ok { - return "", refuse(string(StageLand), "", lane.ID, name+" names a merge-queue method the forge client has no flag for", + return "", false, refuse(string(StageLand), "", lane.ID, name+" names a merge-queue method the forge client has no flag for", "correct the ruleset mirror, then run `abcd implement step` again") } if method != "" && method != m { - return "", refuse(string(StageLand), "", lane.ID, "the ruleset mirror gates the default branch through merge queues with different methods", + return "", false, refuse(string(StageLand), "", lane.ID, "the ruleset mirror gates the default branch through merge queues with different methods", "correct the ruleset mirror, then run `abcd implement step` again") } method = m } } - return method, nil + return method, approval, nil } // landArm arms the merge by the ruleset's rule, or leaves the pull request -// open where no merge queue gates the default branch. +// open where no merge queue gates the default branch, or where no ruleset +// requires a person's approval (ruling AM1): a merge the loop arms is one a +// person must still approve. func landArm(c Context, lane *Lane) (Outcome, error) { ld := lane.Landing def, err := defaultBranch(c, *lane) if err != nil { return Outcome{}, err } - method, err := mergeRule(c, *lane, def) + method, approval, err := mergeRule(c, *lane, def) if err != nil { return Outcome{}, err } @@ -773,6 +874,10 @@ func landArm(c Context, lane *Lane) (Outcome, error) { ld.Merge = "left open: no ruleset gates " + def + " through a merge queue" return Outcome{Stay: true, Note: "pull request #" + n + " " + ld.Merge + "; it lands when a person merges it"}, nil } + if !approval { + ld.Merge = "left open for a person to merge: the ruleset requires no approval on " + def + return Outcome{Stay: true, Note: "pull request #" + n + " " + ld.Merge + ", so the loop does not arm auto-merge; it lands when a person merges it"}, nil + } if _, err := forge(c, *lane, "pr", "merge", n, "--auto", mergeMethods[method]); err != nil { return Outcome{}, err } diff --git a/internal/core/implement/loop/land_approval_test.go b/internal/core/implement/loop/land_approval_test.go new file mode 100644 index 000000000..a54c21f8e --- /dev/null +++ b/internal/core/implement/loop/land_approval_test.go @@ -0,0 +1,151 @@ +package loop + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// reviewRuleset is a second ruleset on the default branch carrying one +// pull-request rule with the given approving count and code-owner flag, under +// the enforcement given. +func reviewRuleset(enforcement, count string, codeOwner bool) string { + owner := "false" + if codeOwner { + owner = "true" + } + return `{"bypass_actors":[],"conditions":{"ref_name":{"exclude":[],"include":["~DEFAULT_BRANCH"]}},` + + `"enforcement":"` + enforcement + `","name":"main review","rules":[{"parameters":{"require_code_owner_review":` + owner + + `,"required_approving_review_count":` + count + `},"type":"pull_request"}],"target":"branch"}` + "\n" +} + +// leftOpenForAPerson is what the landing says, in its state and its run +// record, when it leaves a pull request open because nothing requires a +// person's approval. +const leftOpenForAPerson = "left open for a person to merge: the ruleset requires no approval" + +// assertLeftOpen checks that the lane's pull request was never armed, that its +// landing and the run record both say it was left open for a person, and that +// later steps never arm it. +func assertLeftOpen(t *testing.T, f *landFixture, l Lane) { + t.Helper() + if strings.Contains(f.ghLog(t), "pr merge") { + t.Fatalf("no merge is armed where nothing requires a person's approval:\n%s", f.ghLog(t)) + } + if l.Landing == nil || l.Landing.Armed || !strings.Contains(l.Landing.Merge, leftOpenForAPerson) { + t.Fatalf("the landing says the pull request is left open for a person: %+v", l.Landing) + } + st, err := ReadState(f.repo.Root(), f.runID) + if err != nil { + t.Fatal(err) + } + said := false + for _, e := range st.Record { + if e.Lane == l.ID && strings.Contains(e.Note, leftOpenForAPerson) { + said = true + } + } + if !said { + t.Fatalf("the run record says the pull request is left open for a person: %+v", st.Record) + } + for range 3 { + _, _ = advance(f.repo.Root(), f.runID, f.stages, Options{}) + } + if strings.Contains(f.ghLog(t), "pr merge") { + t.Fatalf("a later step never arms a pull request left open:\n%s", f.ghLog(t)) + } +} + +// TestAMergeQueueWithoutARequiredApprovalLeavesThePullRequestOpen is ruling +// AM1: a merge queue alone is not a person's approval, so the landing leaves +// the pull request open for a person to merge. +func TestAMergeQueueWithoutARequiredApprovalLeavesThePullRequestOpen(t *testing.T) { + f := newLandFixture(t, unreviewedQueueRuleset("MERGE")) + assertLeftOpen(t, f, f.landedToArmed(t)) +} + +// TestAMissingRulesetMirrorLeavesThePullRequestOpen: with no mirror at the +// lane's base nothing is known to require approval, so nothing is armed. +func TestAMissingRulesetMirrorLeavesThePullRequestOpen(t *testing.T) { + f := newLandFixture(t, "") + l := f.landedToArmed(t) + if strings.Contains(f.ghLog(t), "pr merge") || l.Landing == nil || l.Landing.Armed || l.Landing.Merge == "" { + t.Fatalf("a missing mirror leaves the pull request open: %+v\n%s", l.Landing, f.ghLog(t)) + } +} + +// TestTheLandingArmsOnlyWhereTheRulesetRequiresApproval reads the approval +// requirement from every active ruleset on the default branch at the lane's +// base: an approving count of one or more, or a code-owner review with a +// CODEOWNERS file present to name the owners. +func TestTheLandingArmsOnlyWhereTheRulesetRequiresApproval(t *testing.T) { + const owners = "/commands/ @example\n" + cases := []struct { + name string + files map[string]string + armed bool + }{ + {"an approving count of two in a separate ruleset", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "2", false)}, true}, + {"a code-owner review with CODEOWNERS present", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true), ".github/CODEOWNERS": owners}, true}, + {"a code-owner review with a root CODEOWNERS", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true), "CODEOWNERS": owners}, true}, + {"a code-owner review with no CODEOWNERS file", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true)}, false}, + {"a code-owner review whose CODEOWNERS names nobody", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true), ".github/CODEOWNERS": "# nobody yet\n\n"}, false}, + {"a code-owner review whose CODEOWNERS gives a bare @", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true), ".github/CODEOWNERS": "* @\n"}, false}, + {"a code-owner review whose CODEOWNERS gives patterns only", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true), ".github/CODEOWNERS": "/commands/\n*.go # @example\n"}, false}, + {"a comment-only .github/CODEOWNERS shadows a root one naming an owner", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true), ".github/CODEOWNERS": "# nobody yet\n", "CODEOWNERS": owners}, false}, + {"a code-owner review whose docs/CODEOWNERS names an e-mail owner", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true), "docs/CODEOWNERS": "*.md docs@example.com\n"}, true}, + {"a code-owner review whose CODEOWNERS names a team", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", true), ".github/CODEOWNERS": "* @example/reviewers\n"}, true}, + {"a pull-request rule requiring nothing", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("active", "0", false), ".github/CODEOWNERS": owners}, false}, + {"an approving count in a ruleset only evaluated", map[string]string{ + ".abcd/work/rulesets/main-review.json": reviewRuleset("evaluate", "1", true), ".github/CODEOWNERS": owners}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + files := map[string]string{".abcd/work/rulesets/main-protection.json": unreviewedQueueRuleset("MERGE")} + for k, v := range tc.files { + files[k] = v + } + f := newLandFixtureWith(t, files) + l := f.landedToArmed(t) + if !tc.armed { + assertLeftOpen(t, f, l) + return + } + if !strings.Contains(f.ghLog(t), "pr merge 7 --auto --merge") || l.Landing == nil || !l.Landing.Armed { + t.Fatalf("the merge is armed where the ruleset requires approval: %+v\n%s", l.Landing, f.ghLog(t)) + } + }) + } +} + +// TestThisRepositorysOwnMirrorArms: this repository's committed mirror (a +// merge queue, and a code-owner review with approving count 0 beside +// .github/CODEOWNERS) still arms, so ruling AM1 leaves this project unchanged. +func TestThisRepositorysOwnMirrorArms(t *testing.T) { + top := filepath.Join("..", "..", "..", "..") + files := map[string]string{} + for _, rel := range []string{".abcd/work/rulesets/main-protection.json", ".abcd/work/rulesets/main-review.json", ".github/CODEOWNERS"} { + b, err := os.ReadFile(filepath.Join(top, filepath.FromSlash(rel))) + if err != nil { + t.Fatalf("reading this repository's %s: %v", rel, err) + } + files[rel] = string(b) + } + f := newLandFixtureWith(t, files) + l := f.landedToArmed(t) + if !strings.Contains(f.ghLog(t), "pr merge 7 --auto --merge") || l.Landing == nil || !l.Landing.Armed { + t.Fatalf("this repository's own mirror arms the merge: %+v\n%s", l.Landing, f.ghLog(t)) + } +} diff --git a/internal/core/implement/loop/land_test.go b/internal/core/implement/loop/land_test.go index c50870ba1..a492fef2f 100644 --- a/internal/core/implement/loop/land_test.go +++ b/internal/core/implement/loop/land_test.go @@ -12,8 +12,18 @@ import ( ) // queueRuleset is a ruleset mirror that gates the default branch through a -// merge queue merging with method, as .abcd/work/rulesets/ holds it. +// merge queue merging with method, behind a pull-request rule requiring one +// approving review, as .abcd/work/rulesets/ holds it. func queueRuleset(method string) string { + return `{"bypass_actors":[],"conditions":{"ref_name":{"exclude":[],"include":["~DEFAULT_BRANCH"]}},` + + `"enforcement":"active","name":"main protection","rules":[{"type":"deletion"},` + + `{"parameters":{"required_approving_review_count":1},"type":"pull_request"},` + + `{"parameters":{"merge_method":"` + method + `","grouping_strategy":"ALLGREEN"},"type":"merge_queue"}],"target":"branch"}` + "\n" +} + +// unreviewedQueueRuleset gates the default branch through a merge queue with +// no rule requiring a person's approval. +func unreviewedQueueRuleset(method string) string { return `{"bypass_actors":[],"conditions":{"ref_name":{"exclude":[],"include":["~DEFAULT_BRANCH"]}},` + `"enforcement":"active","name":"main protection","rules":[{"type":"deletion"},` + `{"parameters":{"merge_method":"` + method + `","grouping_strategy":"ALLGREEN"},"type":"merge_queue"}],"target":"branch"}` + "\n" @@ -26,7 +36,8 @@ const noQueueRuleset = `{"bypass_actors":[],"conditions":{"ref_name":{"exclude": // stubGH is a forge client that records every call in gh.log beside it and // answers from files there: pr.json (the open pull requests), body.md (the // body the forge holds), state (the pull request's state), footer (a line the -// "harness" appends to a body at creation). It never reaches a network. +// "harness" appends to a body at creation), refuse-disarm (present, the forge +// refuses to withdraw an armed merge). It never reaches a network. const stubGH = `#!/bin/sh d="$(cd "$(dirname "$0")" && pwd)" printf '%s\n' "$*" >> "$d/gh.log" @@ -52,7 +63,11 @@ case "$1 $2" in *) cat "$d/body.md" ;; esac ;; "pr edit") body_from "$@" ;; - "pr merge") : ;; + "pr merge") + case "$*" in + *--disable-auto*) if [ -f "$d/refuse-disarm" ]; then echo "stub gh: the forge refused" >&2; exit 1; fi ;; + esac ;; + "pr close") : ;; *) echo "stub gh: unexpected call: $*" >&2; exit 1 ;; esac ` @@ -72,6 +87,17 @@ type landFixture struct { } func newLandFixture(t *testing.T, ruleset string) *landFixture { + t.Helper() + files := map[string]string{} + if ruleset != "" { + files[".abcd/work/rulesets/main-protection.json"] = ruleset + } + return newLandFixtureWith(t, files) +} + +// newLandFixtureWith is newLandFixture with the files given (the ruleset +// mirror, a CODEOWNERS file) committed at the lane's base. +func newLandFixtureWith(t *testing.T, files map[string]string) *landFixture { t.Helper() repo := loopRepo(t, readyIntent("impact: additive\n", settledQuestions), specWithSteps("")) for _, k := range []string{"GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"} { @@ -81,8 +107,8 @@ func newLandFixture(t *testing.T, ruleset string) *landFixture { t.Setenv(k, "pat@example.com") } repo.Write("AGENTS.md", agentsMarked) - if ruleset != "" { - repo.Write(".abcd/work/rulesets/main-protection.json", ruleset) + for name, body := range files { + repo.Write(name, body) } c, err := capture.Capture(capture.CaptureRequest{RepoRoot: repo.Root(), Text: "The widget refuses a blank name.", Severity: "minor", Category: "ux", Source: "agent-observation", FoundDuring: "a landing test", diff --git a/internal/core/implement/loop/loop.go b/internal/core/implement/loop/loop.go index 74023f29f..27a210847 100644 --- a/internal/core/implement/loop/loop.go +++ b/internal/core/implement/loop/loop.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "time" @@ -74,6 +75,8 @@ type Context struct { RunDir string State State Now time.Time + // Await is the await a verifier is handed the receipt of; nil for a body. + Await *Await } // Outcome is what a stage's body returns. A body that hands its work to an agent @@ -90,6 +93,9 @@ type Outcome struct { // and the lane stays at the stage for the next invocation's step. Set only // with no Await and no HandBack. Stay bool + // Goto sends the lane back to an earlier stage (the landing's sync sends + // it to a fresh round); set only with no Await, HandBack or Stay. + Goto Stage // Note is the run record's line for the stage. Note string } @@ -206,6 +212,19 @@ type StepResult struct { // HandBack is set when the lane was handed back to the person: this call // stopped it, or it stood stopped when the run was started again. HandBack *HandBack `json:"hand_back,omitempty"` + // Slots is the agents the run has out after the call, and Ceiling the most + // it may have (the run's pace.sub_agents); CeilingReached is set when this + // call found every slot taken and handed nothing out. + Slots int `json:"slots"` + Ceiling int `json:"ceiling"` + CeilingReached bool `json:"ceiling_reached,omitempty"` + // Alive is every lane of the run with anything left, with its stage and + // each agent it awaits, the held lanes included. + Alive []AliveLane `json:"alive,omitempty"` + // Blocked are the landings that waited on the forge's merge this call + // while another lane moved: each holds only its own lane. Any other refused + // stage is the call's answer, and no lane moves. + Blocked []Refusal `json:"blocked,omitempty"` // Route is the route that ran the agent when the process driver started // it through a runner (Drive); absent when the host is to run it. Route *runner.RouteRecord `json:"route,omitempty"` @@ -528,11 +547,15 @@ func laneWork(st State, l Lane) string { // openNextLane opens a lane for the first pending spec step. It is state-only: // the lane's first stage is what makes anything. func openNextLane(st *State) { - if len(st.Pending) == 0 { - return + if len(st.Pending) > 0 { + openLane(st, 0) } - p := st.Pending[0] - st.Pending = st.Pending[1:] +} + +// openLane opens a lane for the pending step at index k, state-only. +func openLane(st *State, k int) { + p := st.Pending[k] + st.Pending = slices.Delete(slices.Clone(st.Pending), k, k+1) st.Lanes = append(st.Lanes, Lane{ ID: fmt.Sprintf("lane-%d", len(st.Lanes)+1), Key: st.Key, @@ -542,24 +565,13 @@ func openNextLane(st *State) { }) } -// openNextLaneRecorded opens the next pending step's lane, when one is -// pending, and records it: the run record lists the spec's steps as it lists -// the lanes (itd-2609212103565953, criterion 4), the first at the start and -// each later one here. -func openNextLaneRecorded(st *State, now time.Time) { - n := len(st.Lanes) - openNextLane(st) - if len(st.Lanes) == n { - return - } - l := st.Lanes[n] - st.Record = append(st.Record, Entry{At: now, Lane: l.ID, Stage: "open", - Note: fmt.Sprintf("%s opened for step %d of %s (%s)", l.ID, l.SpecStep, st.Spec, l.StepTitle)}) -} - -// advance performs the next stage of the run's current lane and returns. A lane -// that awaits a receipt performs nothing and re-tells what it awaits; a run -// that is complete says so; a run paused by its window clock is refused until +// advance performs one move of the run and returns (ruling DR6, +// spc-2609202134341288): a stage the binary performs itself on any lane first, +// which takes no slot, then, while the run's ceiling has a slot free, the first +// piece of work that needs an agent, in the order schedule.go gives. A call +// that finds the ceiling reached hands out nothing, names every lane alive and +// what it awaits, and writes the held work into the run's `waiting`. A run that +// is complete says so; a run paused by its window clock is refused until // next_eligible_at. A stage whose body this build does not carry is refused // naming the piece that delivers it. The state is written only after a body // succeeds, and then once. @@ -571,88 +583,31 @@ func advance(repoRoot, runID string, steps Stages, o Options) (StepResult, error return false, contend("pause", "", "", "the run is paused until "+st.NextEligibleAt.UTC().Format(time.RFC3339), "run `abcd implement step` again at or after that time") } - i := st.current() - if i < 0 { + if st.Complete() { res = StepResult{RunID: st.RunID, Complete: true, Next: "nothing: every lane of " + st.RunID + " is done"} return false, nil } - lane := st.Lanes[i] - if lane.Stage == StageHandedBack { - return false, handedBackRefusal(*st, lane) - } - // The window clock (itd-2609201925079472): a pause that has ended - // opens the next window; a window that has elapsed closes here, and - // the call starts nothing. - opened := false + // The window clock (itd-2609201925079472) is the run's, one for every + // lane: a pause that has ended opens the next window; a window that has + // elapsed closes here, and the call starts nothing on any lane. + changed := false if st.NextEligibleAt != nil { openWindow(st, now) - opened = true + changed = true } if until, ok := windowElapsed(*st, now); ok { closeWindow(st, now, until) - res = laneResult(*st, lane, "") + res = idleResult(*st) res.NextEligibleAt = &until - res.Next = pausedMove(lane, until) + res.Next = pausedMove(*st, until) return true, nil } - if lane.Awaiting != nil { - res = laneResult(*st, lane, "") - return opened, nil - } - def, ok := steps.lookup(lane.Stage) - if !ok || def.Run == nil { - piece := "" - if ok { - piece = fmt.Sprintf(" (piece %d of %s delivers it)", def.Piece, specOf(*st)) - } - return false, refusef(string(lane.Stage), lane.ID, - "use an abcd that carries the stage; the run is unchanged and resumes here", - "the %s stage is not built in this abcd%s", lane.Stage, piece) - } - c := Context{RepoRoot: repoRoot, RunDir: runRel(st.RunID), State: *st, Now: now} - out, err := def.Run(c, &lane) + r, moved, err := move(repoRoot, st, steps, now) if err != nil { return false, err } - performed := Stage("") - if out.HandBack != nil { - handBackLane(st, &lane, *out.HandBack, out.Note, now) - st.Lanes[i] = lane - st.UpdatedAt = now - res = laneResult(*st, lane, "") - res.HandBack = lane.HandBack - return true, nil - } - if out.Stay { - st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: string(lane.Stage), Note: out.Note}) - st.Lanes[i] = lane - st.UpdatedAt = now - res = laneResult(*st, lane, "") - return true, nil - } - if out.Await != nil { - if out.Await.Since.IsZero() { - out.Await.Since = now - } - lane.Awaiting = out.Await - note := out.Note - if note == "" { - note = "awaiting the " + out.Await.Role + "'s receipt at " + out.Await.Receipt - } - st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: string(lane.Stage), Note: note}) - } else { - performed = lane.Stage - st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: string(lane.Stage), Note: out.Note}) - lane.Stage = after(lane.Stage) - } - st.Lanes[i] = lane - if lane.Stage == StageDone { - openNextLaneRecorded(st, now) - } - st.UpdatedAt = now - res = laneResult(*st, lane, performed) - res.handed = out.Await != nil - return true, nil + res = r + return changed || moved, nil }) return res, err } @@ -695,19 +650,26 @@ func openWindow(st *State, now time.Time) { } // pausedMove is the next move of a run whose window this call closed. -func pausedMove(lane Lane, until time.Time) string { +func pausedMove(st State, until time.Time) string { at := until.UTC().Format(time.RFC3339) - if lane.Awaiting != nil { - return fmt.Sprintf("nothing new before %s: the run's window has elapsed. The %s already started may still hand back its receipt with `abcd implement receipt %s`; run `abcd implement step` at or after %s", - at, lane.Awaiting.Role, lane.Awaiting.Receipt, at) + if out := st.allAwaits(); len(out) > 0 { + parts := make([]string, 0, len(out)) + for _, a := range out { + parts = append(parts, fmt.Sprintf("the %s of %s with `abcd implement receipt %s`", a.Await.Role, a.Lane, a.Await.Receipt)) + } + return fmt.Sprintf("nothing new before %s: the run's window has elapsed. The agents already started may still hand back their receipts (%s); run `abcd implement step` at or after %s", + at, strings.Join(parts, "; "), at) } return fmt.Sprintf("nothing before %s: the run's window has elapsed; run `abcd implement step` at or after %s", at, at) } -// Receipt hands back the receipt an agent stage waited on. It is refused when no -// lane awaits one, when the path is not the one the stage named, when this build -// carries no verifier for the stage, and when the verifier refuses it; in every -// refusal the lane stays where it was. A verified receipt completes the stage. +// Receipt hands back the receipt an agent stage waited on. The path is looked +// up among every outstanding await of the run, not only one lane's, and the +// lane that await belongs to is advanced (ruling DR6): a verified receipt frees +// its slot, which the next `implement step` fills. It is refused when no await +// names the path, when this build carries no verifier for the stage, and when +// the verifier refuses it; in every refusal the run stays where it was and the +// agent's slot stays taken. func Receipt(repoRoot, runID, receiptPath string, steps Stages, o Options) (StepResult, error) { return receipt(repoRoot, runID, receiptPath, steps, o, nil) } @@ -719,31 +681,27 @@ func receipt(repoRoot, runID, receipt string, steps Stages, o Options, route *ru var res StepResult err := mutate(repoRoot, runID, func(root *os.Root, st *State) (bool, error) { now := o.now() - i := st.current() - if i < 0 || st.Lanes[i].Awaiting == nil { - return false, refuse("receipt", "", "", "no lane of "+st.RunID+" awaits a receipt", - "run `abcd implement step`; it names the receipt when a stage hands work to an agent") + i, k := st.findAwait(repoRoot, receipt) + if i < 0 { + return false, unknownReceipt(*st) } lane := st.Lanes[i] - if !samePath(repoRoot, receipt, lane.Awaiting.Receipt) { - return false, refuse("receipt", "", lane.ID, "the "+string(lane.Stage)+" stage awaits its receipt at "+lane.Awaiting.Receipt+", not at the path given", - "hand back `abcd implement receipt "+lane.Awaiting.Receipt+"`") - } + await := lane.Awaits[k] def, ok := steps.lookup(lane.Stage) if !ok || def.Verify == nil { return false, refusef("receipt", lane.ID, "use an abcd that carries the verifier; the lane still awaits the receipt", "the %s stage's receipt verifier is not built in this abcd (piece %d of %s delivers it)", lane.Stage, def.Piece, specOf(*st)) } - c := Context{RepoRoot: repoRoot, RunDir: runRel(st.RunID), State: *st, Now: now} - if err := def.Verify(c, &lane, lane.Awaiting.Receipt); err != nil { + c := Context{RepoRoot: repoRoot, RunDir: runRel(st.RunID), State: *st, Now: now, Await: &await} + if err := def.Verify(c, &lane, await.Receipt); err != nil { if _, ok := AsRefusal(err); ok { return false, err } return false, refuse("receipt", "", lane.ID, err.Error(), "correct what the reason names, then hand the receipt back") } if route != nil { - stampRoute(&lane, lane.Awaiting.Receipt, route) - st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: StageRunner, Note: routeNote(lane.Awaiting.Role, *route)}) + stampRoute(&lane, await.Receipt, route) + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: StageRunner, Note: routeNote(await.Role, *route)}) } if lane.HandBack != nil { // The lane's own receipt handed the work back: the verifier has @@ -751,62 +709,81 @@ func receipt(repoRoot, runID, receipt string, steps Stages, o Options, route *ru handBackLane(st, &lane, *lane.HandBack, "", now) st.Lanes[i] = lane st.UpdatedAt = now - res = laneResult(*st, lane, "") + res = laneResult(*st, lane, "", nil) res.HandBack = lane.HandBack return true, nil } performed := lane.Stage - verified := lane.Awaiting.Receipt - lane.Awaiting = nil + verified := await.Receipt + lane.Awaits = slices.Delete(slices.Clone(lane.Awaits), k, k+1) + if len(lane.Awaits) == 0 { + lane.Awaits = nil + } note := "the " + string(performed) + " stage's receipt verified at " + verified if def.Repeats { - // The stage hands the lane to its next agent, or completes, on the - // next step; the lane's own receipt stays the implementer's. + // The stage hands the lane to its next agent, or completes, on a + // later step; the lane's own receipt stays the implementer's. if n := validationNote(lane, verified); n != "" { note += "; " + n } st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: "receipt", Note: note}) st.Lanes[i] = lane st.UpdatedAt = now - res = laneResult(*st, lane, "") + res = laneResult(*st, lane, "", nil) return true, nil } lane.Receipt = verified st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: "receipt", Note: note}) lane.Stage = after(lane.Stage) st.Lanes[i] = lane - if lane.Stage == StageDone { - openNextLaneRecorded(st, now) - } st.UpdatedAt = now - res = laneResult(*st, lane, performed) + res = laneResult(*st, lane, performed, nil) return true, nil }) return res, err } -// laneResult reports where a lane stands after a call. -func laneResult(st State, lane Lane, performed Stage) StepResult { - res := StepResult{RunID: st.RunID, Lane: lane.ID, PerformedStage: performed, Stage: lane.Stage, Awaiting: lane.Awaiting} +// laneResult reports where a lane stands after a call: the await this call +// handed out when it handed one out, or the lane's first. +func laneResult(st State, lane Lane, performed Stage, handed *Await) StepResult { + res := StepResult{RunID: st.RunID, Lane: lane.ID, PerformedStage: performed, Stage: lane.Stage, Awaiting: handed, + Slots: st.slotsInUse(), Ceiling: st.ceiling(), Alive: st.alive(), handed: handed != nil} + if res.Awaiting == nil { + res.Awaiting = lane.awaiting() + } if st.Complete() { res.Complete = true res.Next = "nothing: every lane of " + st.RunID + " is done" return res } - if i := st.current(); i >= 0 { - res.Next = nextMove(st, st.Lanes[i]) + switch { + case handed != nil: + res.Next = handedMove(*handed) + case lane.HandBack != nil: + res.Next = handBackMove(st, lane) + default: + res.Next = nextMove(st, lane) } return res } +// handedMove is the one sentence a caller is told when a call handed work to +// an agent. +func handedMove(a Await) string { + return fmt.Sprintf("start a fresh %s agent with the brief %s; when it has written its receipt, run `abcd implement receipt %s`", + a.Role, a.Brief, a.Receipt) +} + // nextMove is the one sentence a caller is told to do next. func nextMove(st State, lane Lane) string { if lane.HandBack != nil { return handBackMove(st, lane) } - if lane.Awaiting != nil { - return fmt.Sprintf("start a fresh %s agent with the brief %s; when it has written its receipt, run `abcd implement receipt %s`", - lane.Awaiting.Role, lane.Awaiting.Brief, lane.Awaiting.Receipt) + if a := lane.awaiting(); a != nil { + return handedMove(*a) + } + if lane.Stage == StageHeld { + return heldMove(lane) } return fmt.Sprintf("run `abcd implement step` to take %s's %s stage", lane.ID, lane.Stage) } @@ -977,11 +954,12 @@ func freeRunID(root *os.Root, m recordid.Minter) (string, error) { } // StatusLanes is the state file read the status block's Now takes -// (itd-2609212103568351): one row per run in progress, naming its intent and the -// lane the loop works on — its next stage, and the role it waits on — or, while -// every opened lane is done and a spec step still waits, the run with its stage -// "pending". A complete run is not in a lane. An absent tier or run directory -// holds none. It is a statusblock.LaneReader. +// (itd-2609212103568351): one entry per run in progress, naming its intent and +// every lane of it alive (ruling DR6) — each lane's next stage and the roles it +// waits on, a held lane included — or, while no lane is alive and a spec step +// still waits, the run with its stage "pending". A complete run is not in a +// lane. An absent tier or run directory holds none. It is a +// statusblock.LaneReader. func StatusLanes(repoRoot string) ([]statusblock.Started, error) { runs, err := Runs(repoRoot) if err != nil { @@ -996,15 +974,20 @@ func StatusLanes(repoRoot string) ([]statusblock.Started, error) { if id == "" { id = st.Key } - lane := statusblock.Lane{Run: st.RunID, Stage: "pending"} - if i := st.current(); i >= 0 { - l := st.Lanes[i] - lane.Lane, lane.Stage = l.ID, string(l.Stage) - if l.Awaiting != nil { - lane.Awaiting = l.Awaiting.Role + var lanes []statusblock.Lane + for _, a := range st.alive() { + lane := statusblock.Lane{Run: st.RunID, Lane: a.Lane, Stage: string(a.Stage)} + roles := make([]string, 0, len(a.Awaits)) + for _, aw := range a.Awaits { + roles = append(roles, aw.Role) } + lane.Awaiting = strings.Join(roles, ", ") + lanes = append(lanes, lane) + } + if len(lanes) == 0 { + lanes = []statusblock.Lane{{Run: st.RunID, Stage: "pending"}} } - out = append(out, statusblock.Started{Intent: id, Lane: lane}) + out = append(out, statusblock.Started{Intent: id, Lanes: lanes}) } return out, nil } diff --git a/internal/core/implement/loop/loop_test.go b/internal/core/implement/loop/loop_test.go index cf306c9c0..b2a14dd0f 100644 --- a/internal/core/implement/loop/loop_test.go +++ b/internal/core/implement/loop/loop_test.go @@ -874,7 +874,7 @@ func TestTheRecordNamesEachLaneAsItOpens(t *testing.T) { if i < 0 { break } - if a := st.Lanes[i].Awaiting; a != nil { + if a := st.Lanes[i].awaiting(); a != nil { if err := os.WriteFile(a.Receipt, []byte("{}\n"), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/core/implement/loop/parallel_test.go b/internal/core/implement/loop/parallel_test.go new file mode 100644 index 000000000..f01362f42 --- /dev/null +++ b/internal/core/implement/loop/parallel_test.go @@ -0,0 +1,974 @@ +package loop + +// parallel_test.go is criterion 6 of itd-2609201925079472 made concrete (ruling +// DR6, spc-2609202134341288 C1 to C13): a run works in parallel up to its +// ceiling, through the step interface, with fake agents writing the receipts +// and returns, the clock Options carries, a bare local remote and the stub +// forge client. + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/intentdriven/abcd/internal/gittest" +) + +// parFixture is a run over a stepped spec on a repository whose default branch +// is on a local bare remote, with the stub forge first on PATH. +type parFixture struct { + repo *gittest.Repo + gh string + runID string + stages Stages + now time.Time +} + +func newParFixture(t *testing.T, steps string, o Options) *parFixture { + t.Helper() + return newParFixtureRuleset(t, steps, o, queueRuleset("MERGE")) +} + +// newParFixtureRuleset is newParFixture with the ruleset mirror given. +func newParFixtureRuleset(t *testing.T, steps string, o Options, ruleset string) *parFixture { + t.Helper() + repo := loopRepo(t, readyIntent("impact: additive\n", settledQuestions), specWithSteps(steps)) + for _, k := range []string{"GIT_AUTHOR_NAME", "GIT_COMMITTER_NAME"} { + t.Setenv(k, "Pat Example") + } + for _, k := range []string{"GIT_AUTHOR_EMAIL", "GIT_COMMITTER_EMAIL"} { + t.Setenv(k, "pat@example.com") + } + repo.Write("AGENTS.md", agentsMarked) + repo.Write(".abcd/work/rulesets/main-protection.json", ruleset) + repo.Commit("the record") + bare := filepath.Join(t.TempDir(), "origin.git") + repo.Git("init", "-q", "--bare", "--initial-branch=main", bare) + repo.Git("remote", "add", "origin", bare) + repo.Git("push", "-q", "origin", "main") + repo.Git("fetch", "-q", "origin") + gh := t.TempDir() + if err := os.WriteFile(filepath.Join(gh, "gh"), []byte(stubGH), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", gh+string(os.PathListSeparator)+os.Getenv("PATH")) + f := &parFixture{repo: repo, gh: gh, stages: DefaultStages(), now: time.Date(2026, 9, 30, 9, 0, 0, 0, time.UTC)} + o.Now = f.clock + start, err := Start(repo.Root(), "itd-10", o) + if err != nil { + t.Fatal(err) + } + f.runID = start.RunID + return f +} + +func (f *parFixture) clock() time.Time { return f.now } +func (f *parFixture) opts() Options { return Options{Now: f.clock} } + +func (f *parFixture) state(t *testing.T) State { + t.Helper() + st, err := ReadState(f.repo.Root(), f.runID) + if err != nil { + t.Fatal(err) + } + return st +} + +func (f *parFixture) lane(t *testing.T, id string) Lane { + t.Helper() + for _, l := range f.state(t).Lanes { + if l.ID == id { + return l + } + } + t.Fatalf("no lane %s", id) + return Lane{} +} + +func (f *parFixture) step(t *testing.T) StepResult { + t.Helper() + res, err := advance(f.repo.Root(), f.runID, f.stages, f.opts()) + if err != nil { + t.Fatalf("step: %v", err) + } + return res +} + +// preflightPushes mints the preflight receipt of every lane at its push: its +// absence refuses the step, as the repository's pre-push gate would. +func (f *parFixture) preflightPushes(t *testing.T) { + t.Helper() + for _, l := range f.state(t).Lanes { + if l.Stage == StageLand && l.Landing != nil && l.Landing.RecordsDone && l.Landing.Pushed == "" { + preflighted(t, l, l.HeadSHA) + } + } +} + +// stepUntil steps until done reports true of the state, failing after a bound; +// a lane that reaches its push meanwhile is given its preflight receipt. +func (f *parFixture) stepUntil(t *testing.T, what string, done func(State) bool) StepResult { + t.Helper() + var res StepResult + for range 40 { + if done(f.state(t)) { + return res + } + f.preflightPushes(t) + res = f.step(t) + } + t.Fatalf("never reached: %s; state %+v", what, f.state(t)) + return res +} + +// await is the lane's outstanding await in role. +func (f *parFixture) await(t *testing.T, laneID, role string) Await { + t.Helper() + for _, a := range f.lane(t, laneID).Awaits { + if a.Role == role { + return a + } + } + t.Fatalf("%s awaits no %s: %+v", laneID, role, f.lane(t, laneID).Awaits) + return Await{} +} + +func (f *parFixture) abs(rel string) string { + return filepath.Join(f.repo.Root(), filepath.FromSlash(rel)) +} + +// implement hands back a verified receipt for the lane's implementer, with one +// commit of file in its worktree. +func (f *parFixture) implement(t *testing.T, laneID, file string) StepResult { + t.Helper() + a := f.await(t, laneID, RoleImplementer) + return f.receipt(t, laneID, a, laneCommit(t, f.repo, f.lane(t, laneID), file)) +} + +// receipt writes an implementer's receipt at the await's path, its report and +// output beside it, naming commits, and hands it back. +func (f *parFixture) receipt(t *testing.T, laneID string, a Await, commits ...string) StepResult { + t.Helper() + l := f.lane(t, laneID) + laneDir := f.abs(RunRelDir + "/" + f.runID + "/" + laneID) + rel, err := filepath.Rel(laneDir, filepath.Dir(f.abs(a.Receipt))) + if err != nil { + t.Fatal(err) + } + rel = filepath.ToSlash(rel) + prefix := "" + if rel != "." { + prefix = rel + "/" + } + for name, body := range map[string]string{prefix + ReportFileName: "done\n", prefix + DoDFileName: "ok\n"} { + if err := os.WriteFile(filepath.Join(laneDir, filepath.FromSlash(name)), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + rc := LaneReceipt{SchemaVersion: ReceiptSchemaVersion, RunID: f.runID, Lane: laneID, Branch: l.Branch, Commits: commits, + DefinitionOfDone: &DoDRun{Command: "make check", ExitCode: zero(), Output: prefix + DoDFileName}, Report: prefix + ReportFileName, Model: "claude-test-5"} + data, _ := json.Marshal(rc) + if err := os.WriteFile(f.abs(a.Receipt), data, 0o600); err != nil { + t.Fatal(err) + } + res, err := Receipt(f.repo.Root(), f.runID, f.abs(a.Receipt), f.stages, f.opts()) + if err != nil { + t.Fatalf("%s's receipt: %v", laneID, err) + } + return res +} + +// ret hands back a validator's return in role for the lane. +func (f *parFixture) ret(t *testing.T, laneID, role, verdict string) StepResult { + t.Helper() + a := f.await(t, laneID, role) + body := reviewerReturn(verdict) + if role == RoleAuditor { + body = auditorVerdict(t, filepath.Join(filepath.Dir(f.abs(a.Receipt)), AuditRequestFileName), verdict) + } + if err := os.WriteFile(f.abs(a.Receipt), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + res, err := Receipt(f.repo.Root(), f.runID, f.abs(a.Receipt), f.stages, f.opts()) + if err != nil { + t.Fatalf("%s's %s return: %v", laneID, role, err) + } + return res +} + +// passAll hands back a passing return from every validator the lane has out. +func (f *parFixture) passAll(t *testing.T, laneID string) { + t.Helper() + pass := map[string]string{RoleRuthless: "SHIP", RoleSecurity: "APPROVE", RoleAuditor: "MET"} + for _, a := range f.lane(t, laneID).Awaits { + f.ret(t, laneID, a.Role, pass[a.Role]) + } +} + +// roundPassed steps and hands back passing returns until the lane leaves its +// validate stage. A sibling that reaches its push meanwhile is given its +// preflight receipt. +func (f *parFixture) roundPassed(t *testing.T, laneID string) { + t.Helper() + for range 20 { + l := f.lane(t, laneID) + if l.Stage != StageValidate { + return + } + if len(l.Awaits) > 0 { + f.passAll(t, laneID) + continue + } + f.preflightPushes(t) + f.step(t) + } + t.Fatalf("%s's round never passed: %+v", laneID, f.lane(t, laneID)) +} + +func (f *parFixture) ghLog(t *testing.T) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(f.gh, "gh.log")) + if os.IsNotExist(err) { + return "" + } + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// landed takes a lane at its landing through the stub forge: preflight +// receipts for each head it pushes, the merge queue's merge, and the ancestor +// check, until the lane is done. The stub's pull request is reset after. +func (f *parFixture) landed(t *testing.T, laneID string) { + t.Helper() + for range 20 { + l := f.lane(t, laneID) + if l.Stage == StageDone { + for _, name := range []string{"pr.json", "state"} { + _ = os.Remove(filepath.Join(f.gh, name)) + } + return + } + if l.Stage != StageLand { + t.Fatalf("%s left its landing for %s", laneID, l.Stage) + } + if l.Landing != nil && l.Landing.RecordsDone && l.Landing.Pushed == "" { + preflighted(t, l, l.HeadSHA) + } + if l.Landing != nil && l.Landing.Merge != "" { + f.repo.Git("push", "-q", "origin", l.Landing.Pushed+":refs/heads/main") + if err := os.WriteFile(filepath.Join(f.gh, "state"), []byte("MERGED\n"), 0o644); err != nil { + t.Fatal(err) + } + } + f.step(t) + } + t.Fatalf("%s never landed", laneID) +} + +// gitErr runs one git command in the fixture and returns its error, for a +// command expected to fail. +func gitErr(repo *gittest.Repo, args ...string) error { + cmd := exec.Command("git", append([]string{"-C", repo.Root(), "-c", "user.email=fixture@example.invalid", "-c", "user.name=Fixture"}, args...)...) + cmd.Env = repo.Env() + return cmd.Run() +} + +func recordText(st State) string { + var b strings.Builder + for _, e := range st.Record { + b.WriteString(e.Lane + " " + e.Stage + " " + e.Note + "\n") + } + return b.String() +} + +// C1, the count, and C2, the ceiling reached, and C3, the slot filled and the +// wait counted: a lane's validators await at once; at the ceiling a step hands +// out nothing, names every lane alive with the role and receipt it awaits, and +// holds the work in `waiting` with the time first held, unchanged by a second +// call; once a receipt frees a slot the held work takes it, and the record +// names the minutes it waited. +func TestTheCeilingCountsValidatorsAndHoldsTheWaitingWork(t *testing.T) { + f := newParFixture(t, "", Options{SubAgents: strp("2")}) + f.stepUntil(t, "the implementer is out", func(st State) bool { return len(st.Lanes[0].Awaits) == 1 }) + f.implement(t, "lane-1", "one.txt") + + // C1: two calls, two reviewers out at once. + if r := f.step(t); r.Awaiting == nil || r.Awaiting.Role != RoleRuthless { + t.Fatalf("the first call hands out the ruthless reviewer: %+v", r) + } + if r := f.step(t); r.Awaiting == nil || r.Awaiting.Role != RoleSecurity || r.Slots != 2 || r.Ceiling != 2 { + t.Fatalf("the second call hands out the security reviewer beside it, 2 of 2 slots: %+v", r) + } + st := f.state(t) + if aw := st.Lanes[0].Awaits; len(aw) != 2 || aw[0].Role != RoleRuthless || aw[1].Role != RoleSecurity { + t.Fatalf("the state file carries two awaits on the lane: %+v", aw) + } + if st.SlotsInUse() != 2 || st.Ceiling() != 2 { + t.Fatalf("status names 2 of 2 slots in use: %d of %d", st.SlotsInUse(), st.Ceiling()) + } + + // C2: the ceiling reached. The closing lane's auditor waits. + held := f.now + r := f.step(t) + if !r.CeilingReached || r.Awaiting == nil || len(r.Alive) != 1 || len(r.Alive[0].Awaits) != 2 { + t.Fatalf("at the ceiling the call hands out nothing and names the lanes alive with their awaits: %+v", r) + } + for _, a := range r.Alive[0].Awaits { + if !strings.Contains(r.Next, a.Receipt) || !strings.Contains(r.Next, a.Role) { + t.Fatalf("the next move names %s at %s: %s", a.Role, a.Receipt, r.Next) + } + } + st = f.state(t) + if len(st.Waiting) != 1 || st.Waiting[0].Lane != "lane-1" || st.Waiting[0].Role != RoleAuditor || !st.Waiting[0].Since.Equal(held) { + t.Fatalf("the held work is written into waiting with the time first held: %+v", st.Waiting) + } + before := stateBytes(t, f.repo.Root(), f.runID) + f.now = f.now.Add(5 * time.Minute) + if r := f.step(t); !r.CeilingReached { + t.Fatalf("a second call before any receipt still finds the ceiling: %+v", r) + } + if !bytes.Equal(before, stateBytes(t, f.repo.Root(), f.runID)) { + t.Fatal("a second call before any receipt leaves the waiting time unchanged") + } + + // C3: 14 minutes after the call that first held it, a receipt frees a slot. + f.now = held.Add(14 * time.Minute) + f.ret(t, "lane-1", RoleRuthless, "SHIP") + if r := f.step(t); r.Awaiting == nil || r.Awaiting.Role != RoleAuditor { + t.Fatalf("the held auditor takes the freed slot: %+v", r) + } + st = f.state(t) + if len(st.Waiting) != 0 || !strings.Contains(recordText(st), "the intent-auditor of lane-1 took a freed slot after waiting 14 minute(s)") { + t.Fatalf("the record names the lane, the role and 14 minutes waited:\n%s", recordText(st)) + } +} + +// rewrite replaces the run's state through the loop's own writer: a test's +// way to stand up a Given the steps alone do not reach in one run. +func (f *parFixture) rewrite(t *testing.T, fn func(*State)) { + t.Helper() + st := f.state(t) + fn(&st) + root, err := os.OpenRoot(f.repo.Root()) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := writeState(root, st); err != nil { + t.Fatal(err) + } +} + +// needs sets the resolved needs of the pending steps numbered in steps. +func needs(st *State, n []int, steps ...int) { + for i := range st.Pending { + if slices.Contains(steps, st.Pending[i].Number) { + st.Pending[i].Needs = append([]int{}, n...) + } + } +} + +// C4, implementers and reviewers together: with three slots, lane 1's two +// reviewers out and step 2 marked `- needs: none`, stepping until lane 2's +// implementer is handed its brief opens lane 2 in its own worktree, and its +// implementer takes the third slot; the next call finds the ceiling reached. A +// stage the binary performs itself proceeds at the ceiling: a step made ready +// while the ceiling is reached has its lane's worktree and brief made, and only +// its implementer waits for a slot. +func TestAnImplementerAndReviewersShareTheSlots(t *testing.T) { + f := newParFixture(t, "1. One\n2. Two\n - needs: none\n3. Three\n - needs: none\n4. Four\n - needs: none\n", Options{SubAgents: strp("3")}) + // The Given: steps 2 to 4 wait until lane 1's reviewers are out. + f.rewrite(t, func(st *State) { needs(st, []int{1}, 2, 3, 4) }) + f.stepUntil(t, "lane-1's implementer is out", func(st State) bool { return len(st.Lanes[0].Awaits) == 1 }) + f.implement(t, "lane-1", "one.txt") + f.stepUntil(t, "lane-1's reviewers are out", func(st State) bool { return len(st.Lanes[0].Awaits) == 2 }) + f.rewrite(t, func(st *State) { needs(st, []int{}, 2, 3) }) + + res := f.stepUntil(t, "lane-2's implementer is out", func(st State) bool { + return len(st.Lanes) >= 2 && len(st.Lanes[1].Awaits) == 1 + }) + st := f.state(t) + if len(st.Lanes) < 2 || st.Lanes[1].SpecStep != 2 || st.Lanes[1].Worktree == "" || st.Lanes[1].Worktree == st.Lanes[0].Worktree || st.Lanes[1].Branch == st.Lanes[0].Branch { + t.Fatalf("lane 2 opens in its own worktree on its own branch: %+v", st.Lanes) + } + if res.Awaiting == nil || res.Awaiting.Role != RoleImplementer || st.SlotsInUse() != 3 { + t.Fatalf("lane 2's implementer takes the third slot: %+v, %d in use", res, st.SlotsInUse()) + } + if r := f.step(t); !r.CeilingReached { + t.Fatalf("the next call finds the ceiling reached: %+v", r) + } + // Step 4 becomes ready at the ceiling: its lane's worktree and brief, the + // binary's own stages, are made; its implementer waits for a slot beside + // lane 3's. + f.rewrite(t, func(st *State) { needs(st, []int{}, 4) }) + if r := f.step(t); r.PerformedStage != StageWorktree || r.Lane != "lane-4" || r.Slots != 3 { + t.Fatalf("the ready step's worktree is made at the ceiling: %+v", r) + } + if r := f.step(t); r.PerformedStage != StageBrief || r.Lane != "lane-4" || r.Slots != 3 { + t.Fatalf("the ready step's brief is made at the ceiling: %+v", r) + } + if r := f.step(t); !r.CeilingReached { + t.Fatalf("lane 4's implementer waits for a slot: %+v", r) + } + var queue []string + for _, w := range f.state(t).Waiting { + queue = append(queue, w.Lane+" "+w.Role) + } + if want := []string{"lane-3 implementer", "lane-4 implementer"}; !slices.Equal(queue, want) { + t.Fatalf("the new lanes' implementers wait on the ceiling: %v, want %v", queue, want) + } +} + +// C5, the order: with lane 2's security reviewer, lane 1's fix implementer and +// a new lane for step 4 all waiting, the first freed slot goes to lane 1's fix +// implementer, the next to lane 2's reviewer, and step 4's lane opens last: +// its worktree and brief, the binary's own stages, are made at the ceiling, and +// its implementer takes the last slot. +func TestAFreedSlotGoesToOpenLanesBeforeNewOnes(t *testing.T) { + f := newParFixture(t, "1. One\n2. Two\n - needs: none\n3. Three\n - needs: none\n4. Four\n - needs: none\n", Options{SubAgents: strp("3")}) + f.rewrite(t, func(st *State) { needs(st, []int{1}, 2, 3, 4) }) + f.stepUntil(t, "lane-1's implementer is out", func(st State) bool { return len(st.Lanes[0].Awaits) == 1 }) + f.implement(t, "lane-1", "one.txt") + f.stepUntil(t, "lane-1's reviewers are out", func(st State) bool { return len(st.Lanes[0].Awaits) == 2 }) + f.rewrite(t, func(st *State) { needs(st, []int{}, 2) }) + f.ret(t, "lane-1", RoleSecurity, "APPROVE") + f.stepUntil(t, "lane-2's implementer is out", func(st State) bool { return len(st.Lanes) == 2 && len(st.Lanes[1].Awaits) == 1 }) + f.implement(t, "lane-2", "two.txt") + if r := f.step(t); r.Awaiting == nil || r.Lane != "lane-2" || r.Awaiting.Role != RoleRuthless { + t.Fatalf("lane 2's ruthless reviewer is out: %+v", r) + } + f.ret(t, "lane-1", RoleRuthless, "FIX FIRST") + // The run is full: one slot, lane 2's ruthless reviewer in it; step 4 is + // ready, lane 1's findings wait for a fix implementer, lane 2's security + // reviewer for its turn. + f.rewrite(t, func(st *State) { + st.Pace.SubAgents.Value = 1 + needs(st, []int{}, 4) + }) + for _, stage := range []Stage{StageWorktree, StageBrief} { + if r := f.step(t); r.PerformedStage != stage || r.Lane != "lane-3" || r.Slots != 1 { + t.Fatalf("step 4's lane makes its %s at the ceiling: %+v", stage, r) + } + } + if r := f.step(t); !r.CeilingReached { + t.Fatalf("the run is full: %+v", r) + } + st := f.state(t) + var queue []string + for _, w := range st.Waiting { + queue = append(queue, w.Lane+" "+w.Role) + } + if want := []string{"lane-1 implementer", "lane-2 security-reviewer", "lane-3 implementer"}; !slices.Equal(queue, want) { + t.Fatalf("the waiting work, in the order a freed slot takes it: %v, want %v", queue, want) + } + f.rewrite(t, func(st *State) { st.Pace.SubAgents.Value = 2 }) + if r := f.step(t); r.Lane != "lane-1" || r.Awaiting == nil || r.Awaiting.Role != RoleImplementer { + t.Fatalf("the first freed slot goes to lane 1's fix implementer: %+v", r) + } + f.ret(t, "lane-2", RoleRuthless, "SHIP") + if r := f.step(t); r.Lane != "lane-2" || r.Awaiting == nil || r.Awaiting.Role != RoleSecurity { + t.Fatalf("the next freed slot goes to lane 2's security reviewer: %+v", r) + } + if l3 := f.lane(t, "lane-3"); len(l3.Awaits) != 0 { + t.Fatalf("step 4's implementer is not out yet: %+v", l3.Awaits) + } + f.ret(t, "lane-2", RoleSecurity, "APPROVE") + f.stepUntil(t, "step 4's implementer is out", func(st State) bool { return len(st.Lanes[2].Awaits) == 1 }) + if st := f.state(t); st.Lanes[2].SpecStep != 4 || !strings.Contains(recordText(st), "the implementer of lane-3 took a freed slot") { + t.Fatalf("step 4's lane opens last, and the record names its wait:\n%s", recordText(st)) + } +} + +// C6, needs: a step without the line needs every step before it (ruling +// DR6b), so no lane opens for it until lane 1's pull request is an ancestor of +// the default branch, even with a slot free; a needs line naming a later step +// is refused before a run starts, naming the line. +func TestAStepWithoutANeedsLineWaitsForEveryEarlierStep(t *testing.T) { + f := newParFixture(t, "1. One\n2. Two\n", Options{SubAgents: strp("3")}) + f.stepUntil(t, "lane-1's implementer is out", func(st State) bool { return len(st.Lanes[0].Awaits) == 1 }) + f.implement(t, "lane-1", "one.txt") + f.roundPassed(t, "lane-1") + // Lane 1's pull request is armed and not merged: nothing is left to move + // but the forge, every slot is free, and still no lane opens for step 2. + for range 10 { + l := f.lane(t, "lane-1") + if l.Landing != nil && l.Landing.Merge != "" { + break + } + if l.Landing != nil && l.Landing.RecordsDone && l.Landing.Pushed == "" { + preflighted(t, l, l.HeadSHA) + } + f.step(t) + } + _, err := advance(f.repo.Root(), f.runID, f.stages, f.opts()) + if r := mustRefusal(t, err); !r.Contention { + t.Fatalf("the run waits on lane 1's merge: %+v", r) + } + if st := f.state(t); len(st.Lanes) != 1 || st.SlotsInUse() != 0 { + t.Fatalf("no lane opens for step 2 while lane 1's pull request is not merged, with every slot free: %+v", st.Lanes) + } + f.landed(t, "lane-1") + st := f.state(t) + if len(st.Lanes) != 2 || st.Lanes[1].SpecStep != 2 { + t.Fatalf("step 2's lane opens once lane 1 has landed: %+v", st.Lanes) + } + + repo := loopRepo(t, readyIntent("", settledQuestions), specWithSteps("1. One\n2. Two\n - needs: 3\n3. Three\n")) + _, err = Start(repo.Root(), "itd-10", Options{}) + if err == nil || !strings.Contains(err.Error(), "line") || !strings.Contains(err.Error(), "needs") { + t.Fatalf("a needs line naming a later step is refused before the run starts, naming the line: %v", err) + } +} + +// C7, isolation and the receipt: of two lanes each awaiting its implementer, +// lane 2's receipt advances lane 2 and leaves lane 1 unchanged; their +// worktrees and branches differ; a path no await names is refused and frees +// nothing. +func TestAReceiptAdvancesOnlyTheLaneItBelongsTo(t *testing.T) { + f := newParFixture(t, "1. One\n2. Two\n - needs: none\n", Options{SubAgents: strp("2")}) + f.stepUntil(t, "both implementers are out", func(st State) bool { + return len(st.Lanes) == 2 && len(st.Lanes[0].Awaits) == 1 && len(st.Lanes[1].Awaits) == 1 + }) + one, _ := json.Marshal(f.lane(t, "lane-1")) + st := f.state(t) + if st.Lanes[0].Worktree == st.Lanes[1].Worktree || st.Lanes[0].Branch == st.Lanes[1].Branch { + t.Fatalf("two lanes never share a worktree or a branch: %+v", st.Lanes) + } + before := stateBytes(t, f.repo.Root(), f.runID) + _, err := Receipt(f.repo.Root(), f.runID, f.abs(RunRelDir+"/"+f.runID+"/nowhere.json"), f.stages, f.opts()) + if r := mustRefusal(t, err); !strings.Contains(r.Reason, "lane-1's implementer") || !strings.Contains(r.Reason, "lane-2's implementer") { + t.Fatalf("a path no await names is refused naming the awaits there are: %+v", r) + } + if !bytes.Equal(before, stateBytes(t, f.repo.Root(), f.runID)) { + t.Fatal("a refused receipt frees nothing") + } + res := f.implement(t, "lane-2", "two.txt") + if res.Lane != "lane-2" || res.Stage != StageValidate { + t.Fatalf("lane 2 advances: %+v", res) + } + if now, _ := json.Marshal(f.lane(t, "lane-1")); !bytes.Equal(one, now) { + t.Fatalf("lane 1 is unchanged:\n%s\n%s", one, now) + } + if n := f.state(t).SlotsInUse(); n != 1 { + t.Fatalf("lane 2's verified receipt frees its slot: %d in use", n) + } +} + +// C8, a clean sync, and C10, one landing at a time: two lanes whose rounds +// pass land one after the other, the lower step first; lane 2 waits while lane +// 1 lands, then merges the default branch in with a merge commit (never a +// rebase), and a fresh round, the closing lane's with its auditor, judges the +// merge head before lane 2 arms; no fix round is counted. +func TestASiblingLandingSyncsTheLaneBeforeItArms(t *testing.T) { + f := newParFixture(t, "1. One\n2. Two\n - needs: none\n", Options{SubAgents: strp("4")}) + f.stepUntil(t, "both implementers are out", func(st State) bool { + return len(st.Lanes) == 2 && len(st.Lanes[0].Awaits) == 1 && len(st.Lanes[1].Awaits) == 1 + }) + f.implement(t, "lane-1", "one.txt") + f.implement(t, "lane-2", "two.txt") + f.roundPassed(t, "lane-1") + f.roundPassed(t, "lane-2") + if v := f.lane(t, "lane-2").Validation[0].Validators; len(v) != 2 { + t.Fatalf("lane 2's first round takes no audit while lane 1 is open: %+v", v) + } + judged := f.lane(t, "lane-2").HeadSHA + + // C10: lane 1 lands first; lane 2 waits at its landing. + for range 30 { + l1 := f.lane(t, "lane-1") + if l1.Stage == StageDone { + break + } + if l2 := f.lane(t, "lane-2"); l2.Landing != nil { + t.Fatalf("lane 2 waits while lane 1 lands: %+v", l2.Landing) + } + if l1.Landing != nil && l1.Landing.RecordsDone && l1.Landing.Pushed == "" { + preflighted(t, l1, l1.HeadSHA) + } + if l1.Landing != nil && l1.Landing.Merge != "" { + // Armed and not merged yet: lane 1's landing waits on the forge, + // and lane 2 still does not begin its own. + _, err := advance(f.repo.Root(), f.runID, f.stages, f.opts()) + if r := mustRefusal(t, err); !r.Contention || f.lane(t, "lane-2").Landing != nil || len(f.lane(t, "lane-2").Syncs) != 0 { + t.Fatalf("one landing at a time: lane 2 waits while lane 1's pull request is armed: %+v", r) + } + f.repo.Git("push", "-q", "origin", l1.Landing.Pushed+":refs/heads/main") + if err := os.WriteFile(filepath.Join(f.gh, "state"), []byte("MERGED\n"), 0o644); err != nil { + t.Fatal(err) + } + } + f.step(t) + } + if strings.Count(f.ghLog(t), "pr merge 7 --auto") != 1 { + t.Fatalf("lane 1 armed once, alone:\n%s", f.ghLog(t)) + } + for _, name := range []string{"pr.json", "state"} { + _ = os.Remove(filepath.Join(f.gh, name)) + } + + // C8: the sync. + res := f.step(t) + l2 := f.lane(t, "lane-2") + if l2.Stage != StageValidate || len(l2.Syncs) != 1 || l2.Syncs[0].Conflicted || l2.Syncs[0].Siblings[0] != "lane-1" || l2.Landing != nil { + t.Fatalf("lane 2 is synced before its landing, back to a fresh round: %+v %+v", res, l2) + } + parents := strings.Fields(f.repo.Git("rev-list", "--parents", "-n", "1", l2.HeadSHA)) + if len(parents) != 3 || parents[1] != judged || parents[2] != l2.Syncs[0].Merged { + t.Fatalf("the sync is a merge commit over the judged head, never a rebase: %v", parents) + } + f.roundPassed(t, "lane-2") + l2 = f.lane(t, "lane-2") + r := l2.Validation[len(l2.Validation)-1] + if len(l2.Validation) != 2 || r.HeadSHA != l2.Syncs[0].Head || len(r.Validators) != 3 || l2.fixRoundsTaken() != 0 { + t.Fatalf("a fresh round, with the closing lane's auditor, judges the merge head and counts no fix round: %+v", l2.Validation) + } + if !strings.Contains(recordText(f.state(t)), "synced lane-2") { + t.Fatalf("the record names the sync:\n%s", recordText(f.state(t))) + } + f.landed(t, "lane-2") + if st := f.state(t); !st.Complete() || !f.lane(t, "lane-2").Landing.Closes { + t.Fatalf("lane 2 lands as the closing lane and the run completes: %+v", st) + } + if strings.Count(f.ghLog(t), "pr merge 7 --auto") != 2 { + t.Fatalf("lane 2 arms only after its sync's round:\n%s", f.ghLog(t)) + } +} + +// C9, a conflicting sync: lane 1 landed a change to a file lane 2 also +// changed; the merge is aborted with the branch unchanged, a fresh implementer +// is handed a sync brief naming the file and lane 1, a receipt whose head does +// not contain the merged sha is refused, and a verified one opens a fresh +// round; no fix round is counted. +func TestAConflictingSyncGoesToAFreshImplementer(t *testing.T) { + f := newParFixture(t, "1. One\n2. Two\n - needs: none\n", Options{SubAgents: strp("4")}) + f.stepUntil(t, "both implementers are out", func(st State) bool { + return len(st.Lanes) == 2 && len(st.Lanes[0].Awaits) == 1 && len(st.Lanes[1].Awaits) == 1 + }) + f.implement(t, "lane-1", "same.txt") + l2 := f.lane(t, "lane-2") + if err := os.WriteFile(filepath.Join(l2.Worktree, "same.txt"), []byte("lane two's words\n"), 0o600); err != nil { + t.Fatal(err) + } + f.repo.Git("-C", l2.Worktree, "add", "--", "same.txt") + f.repo.Git("-C", l2.Worktree, "commit", "-q", "-m", "lane two") + f.receipt(t, "lane-2", f.await(t, "lane-2", RoleImplementer), strings.TrimSpace(f.repo.Git("-C", l2.Worktree, "rev-parse", "HEAD"))) + f.roundPassed(t, "lane-1") + f.roundPassed(t, "lane-2") + f.landed(t, "lane-1") + judged := f.lane(t, "lane-2").HeadSHA + + f.step(t) + l2 = f.lane(t, "lane-2") + if len(l2.Syncs) != 1 || !l2.Syncs[0].Conflicted || !slices.Equal(l2.Syncs[0].Paths, []string{"same.txt"}) || l2.HeadSHA != judged { + t.Fatalf("the conflicting merge is aborted, the branch unchanged: %+v", l2) + } + if tip := strings.TrimSpace(f.repo.Git("rev-parse", "refs/heads/"+l2.Branch)); tip != judged { + t.Fatalf("the branch stays at the judged head: %s", tip) + } + if st := strings.TrimSpace(f.repo.Git("-C", l2.Worktree, "status", "--porcelain")); st != "" { + t.Fatalf("the aborted merge leaves the worktree clean: %s", st) + } + res := f.step(t) + if res.Awaiting == nil || res.Awaiting.Role != RoleImplementer || res.Awaiting.Receipt != l2.Syncs[0].Receipt { + t.Fatalf("a fresh implementer is handed the sync brief: %+v", res) + } + brief, err := os.ReadFile(f.abs(res.Awaiting.Brief)) + if err != nil || !strings.Contains(string(brief), "same.txt") || !strings.Contains(string(brief), "lane-1") || !strings.Contains(string(brief), l2.Syncs[0].Merged) { + t.Fatalf("the sync brief names the file, lane 1 and the merged sha: %v\n%s", err, brief) + } + // A commit that does not merge the default branch in is refused. + plain := laneCommit(t, f.repo, l2, "other.txt") + a := f.await(t, "lane-2", RoleImplementer) + laneDir := f.abs(RunRelDir + "/" + f.runID + "/lane-2") + rel, _ := filepath.Rel(laneDir, filepath.Dir(f.abs(a.Receipt))) + for name, body := range map[string]string{filepath.Join(rel, ReportFileName): "merged\n", filepath.Join(rel, DoDFileName): "ok\n"} { + if err := os.WriteFile(filepath.Join(laneDir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + rc := LaneReceipt{SchemaVersion: ReceiptSchemaVersion, RunID: f.runID, Lane: "lane-2", Branch: l2.Branch, Commits: []string{plain}, + DefinitionOfDone: &DoDRun{Command: "make check", ExitCode: zero(), Output: filepath.ToSlash(filepath.Join(rel, DoDFileName))}, + Report: filepath.ToSlash(filepath.Join(rel, ReportFileName)), Model: "claude-test-5"} + data, _ := json.Marshal(rc) + if err := os.WriteFile(f.abs(a.Receipt), data, 0o600); err != nil { + t.Fatal(err) + } + _, err = Receipt(f.repo.Root(), f.runID, f.abs(a.Receipt), f.stages, f.opts()) + if r := mustRefusal(t, err); !strings.Contains(r.Reason, "does not contain") { + t.Fatalf("a receipt whose head does not contain the merged sha is refused: %+v", r) + } + // The implementer merges and resolves. + cmd := []string{"-C", l2.Worktree, "merge", "--no-edit", l2.Syncs[0].Merged} + _ = gitErr(f.repo, cmd...) + if err := os.WriteFile(filepath.Join(l2.Worktree, "same.txt"), []byte("both lanes' words\n"), 0o600); err != nil { + t.Fatal(err) + } + f.repo.Git("-C", l2.Worktree, "add", "--", "same.txt") + f.repo.Git("-C", l2.Worktree, "commit", "-q", "--no-edit") + merge := strings.TrimSpace(f.repo.Git("-C", l2.Worktree, "rev-parse", "HEAD")) + rc.Commits = []string{plain, merge} + data, _ = json.Marshal(rc) + if err := os.WriteFile(f.abs(a.Receipt), data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := Receipt(f.repo.Root(), f.runID, f.abs(a.Receipt), f.stages, f.opts()); err != nil { + t.Fatalf("a receipt carrying the merged sha verifies: %v", err) + } + if r := f.step(t); r.Awaiting == nil || r.Awaiting.Role != RoleRuthless { + t.Fatalf("a verified sync opens a fresh round: %+v", r) + } + l2 = f.lane(t, "lane-2") + if n := len(l2.Validation); n != 2 || l2.Validation[1].HeadSHA != merge || l2.fixRoundsTaken() != 0 || l2.Syncs[0].Head != merge { + t.Fatalf("the fresh round judges the resolved head and counts no fix round: %+v", l2) + } +} + +// C11, the pace across lanes: with two lanes each with an agent out and the +// window elapsed, a step starts nothing on either lane, both receipts are still +// verified, and next_eligible_at is written once. +func TestAnElapsedWindowStartsNothingOnAnyLane(t *testing.T) { + f := newParFixture(t, "1. One\n2. Two\n - needs: none\n", Options{SubAgents: strp("2"), Pace: strp("30/60")}) + f.stepUntil(t, "both implementers are out", func(st State) bool { + return len(st.Lanes) == 2 && len(st.Lanes[0].Awaits) == 1 && len(st.Lanes[1].Awaits) == 1 + }) + f.now = f.now.Add(31 * time.Minute) + res := f.step(t) + if res.NextEligibleAt == nil || res.Awaiting == nil { + t.Fatalf("the elapsed window closes and starts nothing: %+v", res) + } + f.implement(t, "lane-1", "one.txt") + f.implement(t, "lane-2", "two.txt") + st := f.state(t) + if st.SlotsInUse() != 0 || st.Lanes[0].Stage != StageValidate || st.Lanes[1].Stage != StageValidate { + t.Fatalf("both receipts are verified during the pause: %+v", st.Lanes) + } + if _, err := advance(f.repo.Root(), f.runID, f.stages, f.opts()); err == nil { + t.Fatal("a step before next_eligible_at is refused as a pause") + } + pauses := 0 + for _, e := range f.state(t).Record { + if e.Stage == "pause" { + pauses++ + } + } + if pauses != 1 || f.state(t).NextEligibleAt == nil { + t.Fatalf("next_eligible_at is written once for the run:\n%s", recordText(f.state(t))) + } +} + +// heldRun stands up C13's Given: three slots, one fix round, steps 2 and 3 +// marked `- needs: none`, lane 1 handed back while lane 2's implementer is +// out; lane 2's receipt is verified, its fake validators pass, and the loop is +// stepped until nothing moves. +func heldRun(t *testing.T) *parFixture { + t.Helper() + f := newParFixture(t, "1. One\n2. Two\n - needs: none\n3. Three\n - needs: none\n", Options{SubAgents: strp("3"), FixRounds: strp("1")}) + f.rewrite(t, func(st *State) { needs(st, []int{1}, 3) }) + f.stepUntil(t, "both implementers are out", func(st State) bool { + return len(st.Lanes) == 2 && len(st.Lanes[0].Awaits) == 1 && len(st.Lanes[1].Awaits) == 1 + }) + f.implement(t, "lane-1", "one.txt") + for round := 1; round <= 2; round++ { + f.stepUntil(t, "lane-1's reviewers are out", func(st State) bool { return len(st.Lanes[0].Awaits) == 2 }) + f.ret(t, "lane-1", RoleRuthless, "FIX FIRST") + f.ret(t, "lane-1", RoleSecurity, "APPROVE") + if round == 1 { + f.stepUntil(t, "lane-1's fix implementer is out", func(st State) bool { return len(st.Lanes[0].Awaits) == 1 }) + f.receipt(t, "lane-1", f.await(t, "lane-1", RoleImplementer), laneCommit(t, f.repo, f.lane(t, "lane-1"), "fix.txt")) + } + } + f.stepUntil(t, "lane-1 is handed back", func(st State) bool { return st.Lanes[0].Stage == StageHandedBack }) + f.rewrite(t, func(st *State) { needs(st, []int{}, 3) }) + if len(f.lane(t, "lane-2").Awaits) != 1 { + t.Fatal("lane 2's implementer is out at the hand-back") + } + f.implement(t, "lane-2", "two.txt") + f.roundPassed(t, "lane-2") + for range 10 { + if f.lane(t, "lane-2").Stage == StageHeld { + break + } + f.step(t) + } + return f +} + +// C13, the hold and the person's decision (ruling DR6c), and C11's hand-back: +// after lane 1 is handed back, lane 2 finishes and is held before its push, +// never armed; no lane opens for step 3 and no lane closes the spec; status +// shows the held lane with its cause, head and the two flags and no slot in +// use; the next step refuses naming the hand-back and the held lane. A release +// of a lane that is not held changes nothing; one of lane 2 takes it through +// its landing on the fake forge. +func TestAfterAHandBackTheSiblingsFinishAndAreHeld(t *testing.T) { + f := heldRun(t) + st := f.state(t) + l2 := f.lane(t, "lane-2") + judged := l2.Validation[len(l2.Validation)-1].HeadSHA + if l2.Stage != StageHeld || l2.Hold == nil || l2.Hold.Cause != "lane-1" || l2.Hold.Head != judged || l2.Hold.Before != HoldBeforePush { + t.Fatalf("lane 2 is held before its push, naming lane 1 and its judged head: %+v", l2) + } + if log := f.ghLog(t); log != "" { + t.Fatalf("the fake forge records no pull request and no arming:\n%s", log) + } + if f.repo.Git("ls-remote", "origin", "refs/heads/"+l2.Branch) != "" { + t.Fatal("nothing is pushed") + } + if len(st.Lanes) != 2 || len(st.Pending) != 1 { + t.Fatalf("no lane opens for step 3: %+v", st.Lanes) + } + if v := l2.Validation[0].Validators; len(v) != 2 { + t.Fatalf("no lane closes the spec, so no audit is taken: %+v", v) + } + if !strings.Contains(recordText(st), "lane-2 held after lane-1's hand-back") { + t.Fatalf("the run record names the hold:\n%s", recordText(st)) + } + if st.SlotsInUse() != 0 { + t.Fatalf("a held lane holds no slot: %d", st.SlotsInUse()) + } + res, err := advance(f.repo.Root(), f.runID, f.stages, f.opts()) + r := mustRefusal(t, err) + if !strings.Contains(r.Reason, "unachievable") || !strings.Contains(r.Reason, "lane-2 is held") || !strings.Contains(r.Reason, "--release lane-2") { + t.Fatalf("the next step refuses naming lane 1's hand-back and lane 2 held: %+v %+v", r, res) + } + + before := stateBytes(t, f.repo.Root(), f.runID) + if _, err := Release(f.repo.Root(), f.runID, "lane-1", f.opts()); err == nil { + t.Fatal("releasing a lane that is not held is refused") + } + if !bytes.Equal(before, stateBytes(t, f.repo.Root(), f.runID)) { + t.Fatal("a refused release leaves the state byte-identical") + } + rel, err := Release(f.repo.Root(), f.runID, "lane-2", f.opts()) + if err != nil || rel.Stage != StageLand { + t.Fatalf("a released lane returns to its landing: %+v %v", rel, err) + } + if !strings.Contains(recordText(f.state(t)), "the person released lane-2") { + t.Fatalf("the run record names the release:\n%s", recordText(f.state(t))) + } + f.landed(t, "lane-2") + if log := f.ghLog(t); !strings.Contains(log, "pr create") || !strings.Contains(log, "pr merge 7 --auto") { + t.Fatalf("the released lane lands through the fake forge:\n%s", log) + } + if l := f.lane(t, "lane-2"); l.Landing.Closes { + t.Fatal("a run with a hand-back never closes the spec") + } +} + +// C13's discard: the same held lane in a second run, discarded, has its +// worktree and branch gone and the stage `discarded`; its step stays unlanded +// in the spec, and the fake forge records nothing. +func TestADiscardedHeldLaneLeavesItsStepUnlanded(t *testing.T) { + f := heldRun(t) + l2 := f.lane(t, "lane-2") + res, err := Discard(f.repo.Root(), f.runID, "lane-2", f.opts()) + if err != nil || res.Stage != StageDiscarded { + t.Fatalf("the held lane is discarded: %+v %v", res, err) + } + if _, err := os.Stat(l2.Worktree); !os.IsNotExist(err) { + t.Fatalf("the lane's worktree is gone: %v", err) + } + if gitErr(f.repo, "rev-parse", "--verify", "--quiet", "refs/heads/"+l2.Branch) == nil { + t.Fatal("the lane's branch is gone") + } + spec, err := os.ReadFile(f.abs(specRel)) + if err != nil || strings.Contains(string(spec), "landed:") { + t.Fatalf("step 2 stays unlanded in the spec: %v\n%s", err, spec) + } + if log := f.ghLog(t); log != "" { + t.Fatalf("the fake forge records nothing:\n%s", log) + } + if !strings.Contains(recordText(f.state(t)), "the person discarded lane-2") { + t.Fatalf("the run record names the discard:\n%s", recordText(f.state(t))) + } +} + +// C12, the schema: a version-7 or version-8 state file (8 is the runner's +// record, the version before a run worked in parallel) with one lane awaiting +// its implementer reads as one await and runs on; the next write is version 9 +// and carries `awaits`, never `awaiting`. A version-7 or version-8 file +// carrying what only version 9 writes, and a version-9 file carrying +// `awaiting`, are refused naming the version and the key. +func TestASerialStateMigratesItsAwaitAndVersion9IsHeldToItsShape(t *testing.T) { + f := newParFixture(t, "", Options{}) + f.stepUntil(t, "the implementer is out", func(st State) bool { return len(st.Lanes[0].Awaits) == 1 }) + path := f.abs(StateRelPath(f.runID)) + v8, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // as rewrites the file as a version with lane 0 and the pending steps + // edited by edit. + as := func(version int, edit func(doc, lane map[string]any)) []byte { + var doc map[string]any + if err := json.Unmarshal(v8, &doc); err != nil { + t.Fatal(err) + } + doc["schema_version"] = version + lane := doc["lanes"].([]any)[0].(map[string]any) + edit(doc, lane) + out, _ := json.MarshalIndent(doc, "", " ") + if err := os.WriteFile(path, out, 0o600); err != nil { + t.Fatal(err) + } + return out + } + serial := func(doc, lane map[string]any) { + lane["awaiting"] = lane["awaits"].([]any)[0] + delete(lane, "awaits") + } + + for _, version := range []int{7, 8} { + as(version, serial) + st := f.state(t) + if st.SchemaVersion != SchemaVersion || len(st.Lanes[0].Awaits) != 1 || st.Lanes[0].Awaits[0].Role != RoleImplementer { + t.Fatalf("a version-%d file reads as a lane with one await: %+v", version, st.Lanes[0]) + } + } + f.implement(t, "lane-1", "one.txt") + written, _ := os.ReadFile(path) + if !strings.Contains(string(written), `"schema_version": 9`) || strings.Contains(string(written), `"awaiting"`) { + t.Fatalf("the next write is version 9 and never writes awaiting:\n%s", written) + } + f.step(t) + if written, _ = os.ReadFile(path); !strings.Contains(string(written), `"awaits"`) { + t.Fatalf("version 9 writes awaits:\n%s", written) + } + v8 = written + + for name, tc := range map[string]struct { + version int + edit func(doc, lane map[string]any) + key string + }{ + "v7 awaits": {7, func(doc, lane map[string]any) {}, "`awaits`"}, + "v7 waiting": {7, func(doc, lane map[string]any) { + serial(doc, lane) + doc["waiting"] = []any{map[string]any{"lane": "lane-1", "role": "x", "since": "2026-09-30T09:00:00Z"}} + }, "`waiting`"}, + "v7 syncs": {7, func(doc, lane map[string]any) { + serial(doc, lane) + lane["syncs"] = []any{map[string]any{"at": "2026-09-30T09:00:00Z", "siblings": []any{}, "merged": "x", "conflicted": false}} + }, "`syncs`"}, + "v7 held": {7, func(doc, lane map[string]any) { serial(doc, lane); lane["stage"] = "held" }, "`held`"}, + "v8 awaits": {8, func(doc, lane map[string]any) {}, "`awaits`"}, + "v8 held": {8, func(doc, lane map[string]any) { serial(doc, lane); lane["stage"] = "held" }, "`held`"}, + "v9 awaiting": {9, func(doc, lane map[string]any) { lane["awaiting"] = lane["awaits"].([]any)[0] }, "`awaiting`"}, + } { + as(tc.version, tc.edit) + _, err := ReadState(f.repo.Root(), f.runID) + r := mustRefusal(t, err) + if !strings.Contains(r.Reason, "schema version "+strconv.Itoa(tc.version)) || !strings.Contains(r.Reason, tc.key) { + t.Errorf("%s: refused naming the version and the key: %+v", name, r) + } + } +} diff --git a/internal/core/implement/loop/receipt_test.go b/internal/core/implement/loop/receipt_test.go index 10a91ab39..dd07a9568 100644 --- a/internal/core/implement/loop/receipt_test.go +++ b/internal/core/implement/loop/receipt_test.go @@ -34,8 +34,8 @@ func awaitingLane(t *testing.T) (*gittest.Repo, string, Lane, string) { } l := st.Lanes[0] wantReceipt := RunRelDir + "/" + start.RunID + "/lane-1/" + ReceiptFileName - if l.Awaiting.Receipt != wantReceipt || l.Awaiting.Brief != l.Brief { - t.Fatalf("the await names the lane's brief and receipt: %+v", l.Awaiting) + if l.awaiting().Receipt != wantReceipt || l.awaiting().Brief != l.Brief { + t.Fatalf("the await names the lane's brief and receipt: %+v", l.awaiting()) } return repo, start.RunID, l, filepath.Join(repo.Root(), filepath.FromSlash(RunRelDir), start.RunID, "lane-1") } @@ -105,7 +105,7 @@ func TestAVerifiedReceiptAdvancesTheLane(t *testing.T) { if err != nil { t.Fatal(err) } - if got := st.Lanes[0]; got.HeadSHA != c2 || got.BaseSHA != l.BaseSHA || got.Receipt != l.Awaiting.Receipt || got.Awaiting != nil { + if got := st.Lanes[0]; got.HeadSHA != c2 || got.BaseSHA != l.BaseSHA || got.Receipt != l.awaiting().Receipt || got.awaiting() != nil { t.Fatalf("the lane's head is its branch's tip: %+v", got) } } diff --git a/internal/core/implement/loop/schedule.go b/internal/core/implement/loop/schedule.go new file mode 100644 index 000000000..72b66d0f5 --- /dev/null +++ b/internal/core/implement/loop/schedule.go @@ -0,0 +1,606 @@ +package loop + +// schedule.go is how a run works in parallel up to its ceiling (ruling DR6, +// 2026-09-29; spc-2609202134341288, "Concurrent lanes and validators"). A slot +// is one agent the run has handed work to and not yet taken a verified receipt +// from: an outstanding await on any lane. The count is the number of awaits in +// the state file, and nothing else is counted. The ceiling is the run's +// pace.sub_agents; implementers and validators take the same slots. +// +// Each `implement step` performs one move. It first performs any stage of any +// lane the binary owns (the worktree, the brief, the landing's steps, a round's +// close, a sync, a hold), which takes no slot and is never held by the ceiling; +// then it opens a lane for a ready spec step, whose worktree is such a stage. +// When the move needs an agent, it takes the first waiting item in this order: +// +// 1. work on a lane already open, before any new lane: a round's validators +// and the fix or sync implementers; +// 2. among open lanes, the lane of the lower-numbered spec step first; +// 3. within one lane's round, the validators in the order the round lists +// them; +// 4. then the first implementer of a new lane, lowest spec step first. +// +// The order is a function of the state alone. A lane opens for a spec step once +// every step it needs has landed (its `- needs:` line, or by default every +// earlier step, ruling DR6b), whatever the ceiling: only its implementer waits +// for a slot. No lane opens after a hand-back (ruling DR6c). +// Landing is one lane at a time: a lane waits at its landing while a sibling's +// landing is under way, and holds no slot while it waits. + +import ( + "fmt" + "slices" + "strings" + "time" +) + +// AliveLane is one lane of a run with anything left, as a result reports it. +type AliveLane struct { + Lane string `json:"lane"` + SpecStep int `json:"spec_step"` + Stage Stage `json:"stage"` + Awaits []Await `json:"awaits"` + Hold *Hold `json:"hold,omitempty"` +} + +// LaneAwait is one outstanding await with the lane it belongs to. +type LaneAwait struct { + Lane string + Await Await +} + +// ceiling is the most agents the run may have out at once: its pace's +// sub-agents, or one for a run started before the loop paced a run. +func (s State) ceiling() int { + if s.Pace == nil || s.Pace.SubAgents.Value < 1 { + return 1 + } + return s.Pace.SubAgents.Value +} + +// slotsInUse is the number of outstanding awaits on every lane of the run. +func (s State) slotsInUse() int { + n := 0 + for _, l := range s.Lanes { + n += len(l.Awaits) + } + return n +} + +// allAwaits lists every outstanding await, lane by lane. +func (s State) allAwaits() []LaneAwait { + var out []LaneAwait + for _, l := range s.Lanes { + for _, a := range l.Awaits { + out = append(out, LaneAwait{Lane: l.ID, Await: a}) + } + } + return out +} + +// alive lists every lane with anything left, the held lanes included. +func (s State) alive() []AliveLane { + var out []AliveLane + for _, i := range s.laneOrder() { + l := s.Lanes[i] + if l.Stage == StageDone || l.Stage == StageDiscarded { + continue + } + aw := append([]Await{}, l.Awaits...) + out = append(out, AliveLane{Lane: l.ID, SpecStep: l.SpecStep, Stage: l.Stage, Awaits: aw, Hold: l.Hold}) + } + return out +} + +// laneOrder is the lanes' indices by spec step, then by the order they opened. +func (s State) laneOrder() []int { + idx := make([]int, len(s.Lanes)) + for i := range idx { + idx[i] = i + } + slices.SortStableFunc(idx, func(a, b int) int { return s.Lanes[a].SpecStep - s.Lanes[b].SpecStep }) + return idx +} + +// findAwait is the lane and the await a receipt path names, or -1. +func (s State) findAwait(repoRoot, receipt string) (int, int) { + for i, l := range s.Lanes { + for k, a := range l.Awaits { + if samePath(repoRoot, receipt, a.Receipt) { + return i, k + } + } + } + return -1, -1 +} + +// unknownReceipt refuses a receipt path no outstanding await names, naming the +// awaits there are; it frees nothing. +func unknownReceipt(st State) error { + out := st.allAwaits() + switch len(out) { + case 0: + return refuse("receipt", "", "", "no lane of "+st.RunID+" awaits a receipt", + "run `abcd implement step`; it names the receipt when a stage hands work to an agent") + case 1: + stage := "" + for _, l := range st.Lanes { + if l.ID == out[0].Lane { + stage = string(l.Stage) + } + } + return refuse("receipt", "", out[0].Lane, "the "+stage+" stage awaits its receipt at "+out[0].Await.Receipt+", not at the path given", + "hand back `abcd implement receipt "+out[0].Await.Receipt+"`") + } + parts := make([]string, 0, len(out)) + for _, a := range out { + parts = append(parts, fmt.Sprintf("%s's %s at %s", a.Lane, a.Await.Role, a.Await.Receipt)) + } + return refuse("receipt", "", "", "no outstanding await of "+st.RunID+" names the path given; it awaits "+strings.Join(parts, "; "), + "hand back one of those paths with `abcd implement receipt <path>`") +} + +// requires is the spec steps a pending step waits for: its resolved needs, or, +// in a state file before version 8, every earlier step (ruling DR6b). +func (p PendingStep) requires() []int { + if p.Needs != nil { + return p.Needs + } + out := []int{} + for n := 1; n < p.Number; n++ { + out = append(out, n) + } + return out +} + +// landedInRun reports whether spec step n has nothing left in the run: no +// pending entry for it, and every lane that builds it done (its pull request +// an ancestor of the default branch). A step landed before the run is neither. +func (s State) landedInRun(n int) bool { + for _, p := range s.Pending { + if p.Number == n { + return false + } + } + for _, l := range s.Lanes { + if l.SpecStep == n && l.Stage != StageDone { + return false + } + } + return true +} + +// readyPending lists the indices of the pending steps every need of which has +// landed, in order; after a hand-back there are none (ruling DR6c: no new lane +// opens, and pending steps stay pending). +func (s State) readyPending() []int { + if s.handedBack() { + return nil + } + var out []int + for k, p := range s.Pending { + ready := true + for _, n := range p.requires() { + if !s.landedInRun(n) { + ready = false + break + } + } + if ready { + out = append(out, k) + } + } + return out +} + +// openLaneRecorded opens the lane for the pending step at index k and records +// it: the run record lists the spec's steps as it lists the lanes +// (itd-2609212103565953, criterion 4). +func openLaneRecorded(st *State, k int, now time.Time) int { + openLane(st, k) + i := len(st.Lanes) - 1 + l := st.Lanes[i] + st.Record = append(st.Record, Entry{At: now, Lane: l.ID, Stage: "open", + Note: fmt.Sprintf("%s opened for step %d of %s (%s)", l.ID, l.SpecStep, st.Spec, l.StepTitle)}) + return i +} + +// openNext opens, state-only, the lane for the first spec step a landing made +// ready: the run record lists the next lane beside the landing that let it +// open. The next call makes its worktree. +func openNext(st *State, now time.Time) { + if ready := st.readyPending(); len(ready) > 0 { + openLaneRecorded(st, ready[0], now) + } +} + +// The kinds of move a lane wants next. +type wantKind int + +const ( + wantNone wantKind = iota + wantBinary + wantAgent +) + +// want is one lane's next move: nothing, a stage the binary performs, or an +// agent in a role. hold marks the binary move that holds the lane (DR6c); new +// marks the first implementer of a lane not yet handed to one, ordered by its +// spec step. +type want struct { + lane int + kind wantKind + role string + hold bool + new bool + step int +} + +// awaited reports whether the lane has an agent out writing path. +func (l Lane) awaited(path string) bool { + for _, a := range l.Awaits { + if a.Receipt == path { + return true + } + } + return false +} + +// awaitsRole reports whether the lane has an agent out in role. +func (l Lane) awaitsRole(role string) bool { + for _, a := range l.Awaits { + if a.Role == role { + return true + } + } + return false +} + +// pendingSync is the lane's conflicting sync whose resolution has not been +// verified, or nil. +func (l Lane) pendingSync() *Sync { + if n := len(l.Syncs); n > 0 && l.Syncs[n-1].Conflicted && l.Syncs[n-1].Head == "" { + return &l.Syncs[n-1] + } + return nil +} + +// syncRoundDue reports whether the lane's last sync produced a head no round +// has opened over yet: a fresh round judges it. +func (l Lane) syncRoundDue() bool { + n := len(l.Syncs) + return n > 0 && l.Syncs[n-1].Head != "" && l.Syncs[n-1].Round == 0 +} + +// fixRoundsTaken is the fix rounds the lane has taken before its current +// round: the rounds whose findings went to a fresh implementer. A round opened +// after a sync follows a passing round and counts none. +func (l Lane) fixRoundsTaken() int { + n := 0 + for i := 0; i+1 < len(l.Validation); i++ { + if l.Validation[i].Fix != "" { + n++ + } + } + return n +} + +// laneWant is what lane i wants next, a function of the state alone. +func laneWant(st State, i int) want { + l := st.Lanes[i] + w := want{lane: i, step: l.SpecStep} + switch l.Stage { + case StageWorktree, StageBrief: + w.kind = wantBinary + case StageImplement: + if len(l.Awaits) == 0 { + w.kind, w.role, w.new = wantAgent, RoleImplementer, l.Receipt == "" + } + case StageValidate: + if l.awaitsRole(RoleImplementer) { + return w + } + if l.pendingSync() != nil { + w.kind, w.role = wantAgent, RoleImplementer + return w + } + n := len(l.Validation) + if n == 0 || l.Validation[n-1].Fix != "" || l.syncRoundDue() { + w.kind, w.role = wantAgent, RoleRuthless + return w + } + cur := l.Validation[n-1] + out := false + for _, v := range cur.Validators { + if v.Verdict != "" { + continue + } + if !l.awaited(v.Return) { + w.kind, w.role = wantAgent, v.Role + return w + } + out = true + } + if out { + return w + } + for _, v := range cur.Validators { + if !v.Pass { + if l.fixRoundsTaken() >= st.FixRoundCap() { + w.kind = wantBinary + } else { + w.kind, w.role = wantAgent, RoleImplementer + } + return w + } + } + w.kind = wantBinary + case StageLand: + if st.handedBack() && (l.Hold == nil || !l.Hold.Released) { + w.kind, w.hold = wantBinary, true + return w + } + // One landing at a time: a sibling's landing under way holds this one. + for j, o := range st.Lanes { + if j != i && o.Stage == StageLand && o.Landing != nil && o.Landing.Merged == "" { + return w + } + } + w.kind = wantBinary + } + return w +} + +// agentWants lists the work that needs an agent, in the order a freed slot +// takes it: open lanes before new ones, each by spec step; a new lane is the +// first implementer of a lane not yet handed to one. +func agentWants(st State) []want { + var open, fresh []want + for _, i := range st.laneOrder() { + w := laneWant(st, i) + if w.kind != wantAgent { + continue + } + if w.new { + fresh = append(fresh, w) + } else { + open = append(open, w) + } + } + slices.SortStableFunc(fresh, func(a, b want) int { return a.step - b.step }) + return append(open, fresh...) +} + +// move performs the run's next move and reports what it did. +func move(repoRoot string, st *State, steps Stages, now time.Time) (StepResult, bool, error) { + // A landing waiting on the forge's merge (a contention refusal) holds only + // its own lane: the call moves the next lane and names the wait beside what + // it did; when nothing else moves, the first wait is the call's answer. Any + // other refusal of a stage the binary performs is the call's answer, and no + // other lane moves: a disarm the forge refuses stops the step. + var blocked []Refusal + var waitErr error + moved := func(res StepResult) StepResult { + res.Blocked = blocked + res.Next = withBlocked(res.Next, blocked) + return res + } + for _, i := range st.laneOrder() { + w := laneWant(*st, i) + if w.kind != wantBinary { + continue + } + res, err := performMove(repoRoot, st, steps, w, now) + if err != nil { + r, ok := AsRefusal(err) + if !ok || !r.Contention { + return StepResult{}, false, err + } + if waitErr == nil { + waitErr = err + } + blocked = append(blocked, *r) + continue + } + return moved(res), true, nil + } + // A ready spec step's lane opens whatever the ceiling: its worktree, like + // its brief, is a stage the binary performs, and only its implementer + // waits for a slot. + if ready := st.readyPending(); len(ready) > 0 { + i := openLaneRecorded(st, ready[0], now) + res, err := performMove(repoRoot, st, steps, want{lane: i, kind: wantBinary}, now) + if err != nil { + return StepResult{}, false, err + } + return moved(res), true, nil + } + res, did, err := moveAgent(repoRoot, st, steps, now) + if err != nil { + return StepResult{}, false, err + } + if did { + return moved(res), true, nil + } + if waitErr != nil { + return StepResult{}, false, waitErr + } + if st.slotsInUse() == 0 && st.handedBack() { + return StepResult{}, false, handedBackRefusal(*st) + } + return res, false, nil +} + +// withBlocked names, after a call's next move, each lane whose landing waits +// on the forge while the call moved another. +func withBlocked(next string, blocked []Refusal) string { + for _, r := range blocked { + next += fmt.Sprintf("; meanwhile %s waits: %s (%s)", r.Lane, r.Reason, r.Remedy) + } + return next +} + +// moveAgent hands the first waiting work an agent when a slot is free, or +// records the work the ceiling holds back; it reports whether it moved. +func moveAgent(repoRoot string, st *State, steps Stages, now time.Time) (StepResult, bool, error) { + wants := agentWants(*st) + if len(wants) == 0 { + return idleResult(*st), false, nil + } + if st.slotsInUse() >= st.ceiling() { + changed := holdWaiting(st, wants, now) + res := idleResult(*st) + res.CeilingReached = true + res.Next = ceilingMove(*st) + return res, changed, nil + } + w := wants[0] + res, err := performMove(repoRoot, st, steps, w, now) + if err != nil { + return StepResult{}, false, err + } + tookSlot(st, st.Lanes[w.lane].ID, w.role, now) + return res, true, nil +} + +// performMove runs one lane's next stage body and applies its outcome. +func performMove(repoRoot string, st *State, steps Stages, w want, now time.Time) (StepResult, error) { + i := w.lane + lane := st.Lanes[i] + c := Context{RepoRoot: repoRoot, RunDir: runRel(st.RunID), State: *st, Now: now} + var out Outcome + var err error + if w.hold { + out, err = holdLane(c, &lane) + } else { + def, ok := steps.lookup(lane.Stage) + if !ok || def.Run == nil { + piece := "" + if ok { + piece = fmt.Sprintf(" (piece %d of %s delivers it)", def.Piece, specOf(*st)) + } + return StepResult{}, refusef(string(lane.Stage), lane.ID, + "use an abcd that carries the stage; the run is unchanged and resumes here", + "the %s stage is not built in this abcd%s", lane.Stage, piece) + } + out, err = def.Run(c, &lane) + } + if err != nil { + return StepResult{}, err + } + performed := Stage("") + var handed *Await + stage := string(lane.Stage) + switch { + case out.HandBack != nil: + handBackLane(st, &lane, *out.HandBack, out.Note, now) + st.Lanes[i] = lane + st.UpdatedAt = now + res := laneResult(*st, lane, "", nil) + res.HandBack = lane.HandBack + return res, nil + case out.Stay: + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: stage, Note: out.Note}) + case out.Goto != "": + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: stage, Note: out.Note}) + lane.Stage = out.Goto + case out.Await != nil: + if out.Await.Since.IsZero() { + out.Await.Since = now + } + lane.Awaits = append(slices.Clone(lane.Awaits), *out.Await) + handed = out.Await + note := out.Note + if note == "" { + note = "awaiting the " + out.Await.Role + "'s receipt at " + out.Await.Receipt + } + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: stage, Note: note}) + default: + performed = lane.Stage + st.Record = append(st.Record, Entry{At: now, Lane: lane.ID, Stage: stage, Note: out.Note}) + lane.Stage = after(lane.Stage) + } + st.Lanes[i] = lane + if lane.Stage == StageDone { + openNext(st, now) + } + st.UpdatedAt = now + return laneResult(*st, lane, performed, handed), nil +} + +// holdWaiting writes the work the ceiling holds back into the run's waiting +// list, keeping the time each item was first held. It reports whether the list +// changed, so a second call before any receipt writes nothing. +func holdWaiting(st *State, wants []want, now time.Time) bool { + next := make([]Waiting, 0, len(wants)) + for _, w := range wants { + id := st.Lanes[w.lane].ID + since := now + for _, old := range st.Waiting { + if old.Lane == id && old.Role == w.role { + since = old.Since + } + } + next = append(next, Waiting{Lane: id, Role: w.role, Since: since}) + } + if slices.Equal(next, st.Waiting) { + return false + } + st.Waiting = next + st.UpdatedAt = now + return true +} + +// tookSlot removes the waiting item a move just served and records how long +// it waited, in whole minutes. +func tookSlot(st *State, laneID, role string, now time.Time) { + for k, w := range st.Waiting { + if w.Lane != laneID || w.Role != role { + continue + } + st.Waiting = slices.Delete(slices.Clone(st.Waiting), k, k+1) + if len(st.Waiting) == 0 { + st.Waiting = nil + } + mins := int(now.Sub(w.Since) / time.Minute) + st.Record = append(st.Record, Entry{At: now, Lane: laneID, Stage: "slot", + Note: fmt.Sprintf("the %s of %s took a freed slot after waiting %d minute(s) at the run's ceiling of %d", role, laneID, mins, st.ceiling())}) + return + } +} + +// idleResult reports a run a call moved no lane of: the lanes alive and the +// slots in use. +func idleResult(st State) StepResult { + res := StepResult{RunID: st.RunID, Slots: st.slotsInUse(), Ceiling: st.ceiling(), Alive: st.alive()} + if i := st.current(); i >= 0 { + l := st.Lanes[i] + res.Lane, res.Stage, res.Awaiting = l.ID, l.Stage, l.awaiting() + res.Next = nextMove(st, l) + } + if out := st.allAwaits(); len(out) > 1 { + res.Next = awaitsMove(st) + } + return res +} + +// awaitsMove names every outstanding await. +func awaitsMove(st State) string { + parts := []string{} + for _, a := range st.allAwaits() { + parts = append(parts, fmt.Sprintf("%s's %s (brief %s) hands back `abcd implement receipt %s`", a.Lane, a.Await.Role, a.Await.Brief, a.Await.Receipt)) + } + return "wait for the agents out: " + strings.Join(parts, "; ") +} + +// ceilingMove is the next move of a call that found the ceiling reached. +func ceilingMove(st State) string { + return fmt.Sprintf("nothing new: the run's ceiling of %d agent(s) is reached (%d in use). %s; each verified receipt frees a slot the next `abcd implement step` fills", + st.ceiling(), st.slotsInUse(), awaitsMove(st)) +} + +// SlotsInUse is the agents the run has out: its outstanding awaits. +func (s State) SlotsInUse() int { return s.slotsInUse() } + +// Ceiling is the most agents the run may have out at once. +func (s State) Ceiling() int { return s.ceiling() } diff --git a/internal/core/implement/loop/state.go b/internal/core/implement/loop/state.go index 4669f36ae..746582cc9 100644 --- a/internal/core/implement/loop/state.go +++ b/internal/core/implement/loop/state.go @@ -119,9 +119,22 @@ const lockFileName = ".lock" // piece 3): the run's `fallbacks`, one receipt per role a runner did not run, // and the `route` a verified receipt or a validator's return names when a // runner, not the host, ran its agent. Version 7 is its strict subset, read as -// a run the host ran every agent of and written back at version 8; a version-7 -// file carrying either is not one version 7 wrote, and is refused. -const SchemaVersion = 8 +// a run the host ran every agent of and written back at the current version; a +// version-7 file carrying either is not one version 7 wrote, and is refused. +// +// Version 9 made a run work in parallel up to its ceiling (ruling DR6, +// spc-2609202134341288): a lane's `awaits`, a list replacing the one +// `awaiting`, the run's `waiting`, a lane's `syncs` and `hold`, a pending +// step's `needs`, and the lane stages `held` and `discarded`. A file of version +// 8 or lower reads as a run whose lanes each await zero or one agent (its +// `awaiting` becomes a one-entry `awaits`) and is written back at version 9; +// one of them carrying anything only version 9 writes is refused, and so is a +// version-9 file carrying `awaiting`, which version 9 never writes. +const SchemaVersion = 9 + +// schemaVersionSerial is the version before a run worked in parallel: read and +// migrated, never written. +const schemaVersionSerial = 8 // schemaVersionUnrouted is the version before the runner's record: read, never // written. @@ -193,6 +206,13 @@ const ( // did not pass (itd-50, criterion 2): the loop starts nothing further for // it, and its intent is the person's to replan. StageHandedBack Stage = "handed-back" + // StageHeld is a lane whose round passed after a sibling was handed back + // (ruling DR6c): it holds no slot, starts nothing and is never armed until + // the person releases it (`implement step --release`) or discards it. + StageHeld Stage = "held" + // StageDiscarded is a held lane the person discarded: its pull request + // closed, its worktree and branch removed, its step left unlanded. + StageDiscarded Stage = "discarded" ) // VerdictUnachievable is the verdict a lane is handed back with: the run's fix @@ -235,9 +255,14 @@ type State struct { // candidates, their scores and the grounds entry its first lane commits. // Nil for a run `abcd build <itd-N>` started. Pick *RunPick `json:"pick,omitempty"` - // Lanes are the lanes opened so far, one at a time, in order. A lane lands - // one step of the spec's `## Steps` (the whole spec when it lists none). + // Lanes are the lanes opened so far, in the order they opened; several may + // be open at once, up to the ceiling (ruling DR6). A lane lands one step of + // the spec's `## Steps` (the whole spec when it lists none). Lanes []Lane `json:"lanes"` + // Waiting is the work the ceiling holds back: each item's lane, role and + // the time the ceiling first held it. An item leaves it when it takes a + // slot, with a record entry naming the minutes it waited. + Waiting []Waiting `json:"waiting,omitempty"` // Pending are the spec's unlanded steps no lane has been opened for yet. Pending []PendingStep `json:"pending"` // Record is the run record, accumulated as stages complete. @@ -273,8 +298,57 @@ type Transcript struct { type PendingStep struct { Number int `json:"number"` Title string `json:"title"` + // Needs are the spec steps it waits for, as its `- needs:` line or the + // default (every earlier step, ruling DR6b) resolved them; nil in a file + // before version 9, which reads as the default. + Needs []int `json:"needs"` +} + +// Waiting is one piece of work the ceiling held back. +type Waiting struct { + Lane string `json:"lane"` + Role string `json:"role"` + Since time.Time `json:"since"` +} + +// Sync is one merge of the default branch into a lane after a sibling lane of +// the run landed (spc-2609202134341288, "Two lanes that touch the same +// files"): the siblings whose landing caused it, the default branch's sha +// merged in, whether it conflicted, and the head it produced. A conflicting +// sync goes to a fresh implementer with the brief and receipt named here. +type Sync struct { + At time.Time `json:"at"` + Siblings []string `json:"siblings"` + Merged string `json:"merged"` + Conflicted bool `json:"conflicted"` + Paths []string `json:"paths,omitempty"` + Brief string `json:"brief,omitempty"` + Receipt string `json:"receipt,omitempty"` + // Head is the head the sync produced: the merge commit, or the verified + // head of the implementer who resolved the conflict; empty until then. + Head string `json:"head,omitempty"` + // Round is the fresh round that judges Head, once it has opened. + Round int `json:"round,omitempty"` +} + +// Hold is a lane held after a sibling's hand-back (ruling DR6c): when, the +// handed-back lane that caused it, the head its passing round judged, and the +// landing step it stopped before (HoldBeforePush or HoldBeforeArm). Released is +// set once the person released it to land as it is. +type Hold struct { + Since time.Time `json:"since"` + Cause string `json:"cause"` + Head string `json:"head"` + Before string `json:"before"` + Released bool `json:"released,omitempty"` } +// The landing steps a hold stops before. +const ( + HoldBeforePush = "push" + HoldBeforeArm = "arm" +) + // Lane is one lane: one spec step, one branch, one pull request. type Lane struct { // ID is the lane's name inside the run: lane-1, lane-2, …. @@ -288,9 +362,14 @@ type Lane struct { // Stage is the next stage the loop performs for this lane; StageDone when the // lane has nothing left. Stage Stage `json:"stage"` - // Awaiting is set while the lane waits on an agent: the stage handed its - // work out and advances only on the receipt it names (criterion 8). - Awaiting *Await `json:"awaiting,omitempty"` + // Awaits are the agents the lane waits on: one while its implementer + // works, one per validator while its round is out. Each is a slot of the + // run's ceiling until its receipt is verified (criterion 8, ruling DR6). + Awaits []Await `json:"awaits,omitempty"` + // Syncs are the merges of the default branch into the lane after a sibling + // landed; Hold is set while the lane is held after a sibling's hand-back. + Syncs []Sync `json:"syncs,omitempty"` + Hold *Hold `json:"hold,omitempty"` // The lane's footprint, filled by the stages that make it. Branch string `json:"branch,omitempty"` BaseSHA string `json:"base_sha,omitempty"` @@ -530,17 +609,53 @@ func (s State) validated() bool { return false } -// current returns the index of the lane the loop works on — the first lane not -// done — or -1 when every opened lane is done. +// parallel reports what, in a state, only version 9 writes: the name of the +// first key or stage found, or "". +func (s State) parallel() string { + if len(s.Waiting) > 0 { + return "`waiting`" + } + for _, p := range s.Pending { + if p.Needs != nil { + return "`needs` on pending step " + fmt.Sprint(p.Number) + } + } + for _, l := range s.Lanes { + switch { + case len(l.Awaits) > 0: + return "`awaits` on " + l.ID + case len(l.Syncs) > 0: + return "`syncs` on " + l.ID + case l.Hold != nil: + return "`hold` on " + l.ID + case l.Stage == StageHeld || l.Stage == StageDiscarded: + return "the stage `" + string(l.Stage) + "` on " + l.ID + } + } + return "" +} + +// current returns the index of the first lane with anything left — not done +// and not discarded — or -1 when there is none. It is the lane a result +// names when a call moved none in particular. func (s State) current() int { for i, l := range s.Lanes { - if l.Stage != StageDone { + if l.Stage != StageDone && l.Stage != StageDiscarded { return i } } return -1 } +// awaiting is the lane's first await, or nil. +func (l Lane) awaiting() *Await { + if len(l.Awaits) == 0 { + return nil + } + a := l.Awaits[0] + return &a +} + // runRel is a run's directory, relative to the checkout root. func runRel(runID string) string { return RunRelDir + "/" + runID } @@ -623,7 +738,7 @@ func readStateIn(root *os.Root, runID string) (State, error) { case st.SchemaVersion <= schemaVersionUnrouted && st.routed(): return State{}, refuse("state", "", "", fmt.Sprintf("%s is schema version %d but carries a fallback or a runner's route, which version %d never wrote", rel, st.SchemaVersion, st.SchemaVersion), "the loop is the file's only writer; restore it or remove the run directory "+runRel(runID)) - case st.SchemaVersion >= schemaVersionUnpaced && st.SchemaVersion <= schemaVersionUnrouted: + case st.SchemaVersion >= schemaVersionUnpaced && st.SchemaVersion <= schemaVersionSerial: // Read as the current version, its stages already carried over by // decodeState when it named them `step`; the next write carries it, and // this read writes nothing. @@ -652,7 +767,22 @@ type stateStepNamed struct { // laneStepNamed is a lane in a file of versions 1 to 3. type laneStepNamed struct { Lane - Step Stage `json:"step"` + Step Stage `json:"step"` + Awaiting *Await `json:"awaiting,omitempty"` +} + +// stateAwaitNamed is a state file of version 4 or later read in the shape that +// carries the lane's one `awaiting`: versions 4 to 8 wrote it, and a version-9 +// file carrying it is refused by name rather than as an unknown field. +type stateAwaitNamed struct { + State + Lanes []laneAwaitNamed `json:"lanes"` +} + +// laneAwaitNamed is a lane carrying the one `awaiting` of versions up to 8. +type laneAwaitNamed struct { + Lane + Awaiting *Await `json:"awaiting,omitempty"` } // entryStepNamed is a record line in a file of versions 1 to 3. @@ -671,11 +801,37 @@ func decodeState(data []byte) (State, error) { var peek struct { SchemaVersion int `json:"schema_version"` } - if err := json.Unmarshal(data, &peek); err != nil || peek.SchemaVersion < schemaVersionUnpaced || peek.SchemaVersion > schemaVersionStepNamed { + if err := json.Unmarshal(data, &peek); err != nil || peek.SchemaVersion < schemaVersionUnpaced || peek.SchemaVersion > SchemaVersion { var st State err := jsonstrict.Decode(data, &st) return st, err } + if peek.SchemaVersion > schemaVersionStepNamed { + var old stateAwaitNamed + if err := jsonstrict.Decode(data, &old); err != nil { + return State{}, err + } + st := old.State + st.Lanes = nil + if old.Lanes != nil { + st.Lanes = make([]Lane, 0, len(old.Lanes)) + } + for _, l := range old.Lanes { + if l.Awaiting != nil && old.SchemaVersion > schemaVersionSerial { + return State{}, refuse("state", "", "", fmt.Sprintf("is schema version %d but lane %s carries `awaiting`, which version %d never wrote", old.SchemaVersion, l.ID, old.SchemaVersion), "") + } + st.Lanes = append(st.Lanes, l.Lane) + } + if err := serialCarries(st, old.SchemaVersion); err != nil { + return State{}, err + } + for i, l := range old.Lanes { + if l.Awaiting != nil { + st.Lanes[i].Awaits = []Await{*l.Awaiting} + } + } + return st, nil + } var old stateStepNamed if err := jsonstrict.Decode(data, &old); err != nil { return State{}, err @@ -696,6 +852,14 @@ func decodeState(data []byte) (State, error) { lane.Stage = l.Step st.Lanes = append(st.Lanes, lane) } + if err := serialCarries(st, old.SchemaVersion); err != nil { + return State{}, err + } + for i, l := range old.Lanes { + if l.Awaiting != nil { + st.Lanes[i].Awaits = []Await{*l.Awaiting} + } + } st.Record = nil if old.Record != nil { st.Record = make([]Entry, 0, len(old.Record)) @@ -711,6 +875,20 @@ func decodeState(data []byte) (State, error) { return st, nil } +// serialCarries refuses a file of version 8 or lower that carries what only +// version 9 writes, in the shape of the earlier versions' refusals. It runs +// before the file's `awaiting` is carried over to `awaits`, so it sees only +// what the file itself carries. +func serialCarries(st State, version int) error { + if version > schemaVersionSerial { + return nil + } + if what := st.parallel(); what != "" { + return refuse("state", "", "", fmt.Sprintf("is schema version %d but carries %s, which version %d never wrote", version, what, version), "") + } + return nil +} + // writeState is the state file's one writer: a whole-file atomic replacement // inside the checkout's os.Root, so a reader sees the old state or the new one, // never half of either. The caller holds the lock. diff --git a/internal/core/implement/loop/status_test.go b/internal/core/implement/loop/status_test.go index d55c8c302..43ee96497 100644 --- a/internal/core/implement/loop/status_test.go +++ b/internal/core/implement/loop/status_test.go @@ -29,7 +29,7 @@ func TestStatusLanesReadsTheLaneEachRunWorksOn(t *testing.T) { if err != nil { t.Fatal(err) } - want := []statusblock.Started{{Intent: "itd-10", Lane: statusblock.Lane{Run: res.RunID, Lane: "lane-1", Stage: string(StageWorktree)}}} + want := []statusblock.Started{{Intent: "itd-10", Lanes: []statusblock.Lane{{Run: res.RunID, Lane: "lane-1", Stage: string(StageWorktree)}}}} if !reflect.DeepEqual(got, want) { t.Fatalf("StatusLanes = %+v, want %+v", got, want) } @@ -53,16 +53,16 @@ func TestStatusLanesReadsTheLaneEachRunWorksOn(t *testing.T) { rewrite(func(st *State) { st.Lanes[0].Stage = StageImplement - st.Lanes[0].Awaiting = &Await{Role: "implementer", Brief: "b", Receipt: "r", Since: time.Unix(0, 0).UTC()} + st.Lanes[0].Awaits = []Await{{Role: "implementer", Brief: "b", Receipt: "r", Since: time.Unix(0, 0).UTC()}} }) - if got, _ := StatusLanes(repo.Root()); len(got) != 1 || got[0].Lane.Stage != "implement" || got[0].Lane.Awaiting != "implementer" { + if got, _ := StatusLanes(repo.Root()); len(got) != 1 || got[0].Lanes[0].Stage != "implement" || got[0].Lanes[0].Awaiting != "implementer" { t.Errorf("a lane awaiting its implementer reads %+v", got) } rewrite(func(st *State) { - st.Lanes[0].Stage, st.Lanes[0].Awaiting = StageDone, nil + st.Lanes[0].Stage, st.Lanes[0].Awaits = StageDone, nil }) - if got, _ := StatusLanes(repo.Root()); len(got) != 1 || got[0].Lane != (statusblock.Lane{Run: res.RunID, Stage: "pending"}) { + if got, _ := StatusLanes(repo.Root()); len(got) != 1 || len(got[0].Lanes) != 1 || got[0].Lanes[0] != (statusblock.Lane{Run: res.RunID, Stage: "pending"}) { t.Errorf("a run between lanes reads %+v, want its step pending and no lane", got) } diff --git a/internal/core/implement/loop/sync.go b/internal/core/implement/loop/sync.go new file mode 100644 index 000000000..2527b9452 --- /dev/null +++ b/internal/core/implement/loop/sync.go @@ -0,0 +1,169 @@ +package loop + +// sync.go is how a lane lands after a sibling lane of the same run landed +// first (spc-2609202134341288, "Two lanes that touch the same files"). Before a +// lane's landing begins, the loop checks whether a sibling has landed since +// this lane's base; if one has, it syncs the lane: it merges the default +// branch into the lane's branch with a merge commit in the lane's worktree. It +// never rebases, so no commit a validator judged is rewritten and every sha the +// record cites stays reachable. A clean merge moves the lane's head, so a fresh +// round judges the new head. A merge that conflicts is aborted, leaving the +// branch where it was, and the conflict goes to a fresh implementer with a sync +// brief naming each conflicting path and the sibling lanes whose landing +// brought the other side; its receipt must carry the merged sha as an ancestor +// of the new head, and a fresh round judges it. A sync does not count against +// the run's fix rounds. + +import ( + "bytes" + "fmt" + "strings" + + "github.com/intentdriven/abcd/internal/fsutil" + "github.com/intentdriven/abcd/internal/gitutil" +) + +// SyncDirName is the lane's directory a sync's brief and receipt live in. +const SyncDirName = "sync" + +// syncSubject is a sync's merge commit subject. +func syncSubject(lane Lane, def string, siblings []string) string { + return "merge: sync " + lane.Branch + " with " + def + " after " + strings.Join(siblings, ", ") + " landed" +} + +// landedSiblings names the run's lanes that landed with a head this lane does +// not hold. +func landedSiblings(c Context, lane Lane) ([]string, error) { + var out []string + for _, l := range c.State.Lanes { + if l.ID == lane.ID || l.Stage != StageDone || l.Landing == nil || !gitutil.IsFullSHA(l.Landing.Pushed) { + continue + } + on, err := gitutil.IsAncestor(c.RepoRoot, l.Landing.Pushed, lane.HeadSHA) + if err != nil { + return nil, fmt.Errorf("placing %s's landed head on %s: %v", l.ID, lane.ID, err) + } + if !on { + out = append(out, l.ID) + } + } + return out, nil +} + +// syncLane merges the default branch into the lane when a sibling landed since +// its base, and reports whether it did anything. A clean merge sends the lane +// to a fresh round over the merge head; a conflicting one is aborted and sends +// the lane to a fresh implementer with a sync brief. +func syncLane(c Context, lane *Lane) (Outcome, bool, error) { + siblings, err := landedSiblings(c, *lane) + if err != nil || len(siblings) == 0 { + return Outcome{}, false, err + } + def, err := defaultBranch(c, *lane) + if err != nil { + return Outcome{}, false, err + } + merged, err := gitutil.Run(c.RepoRoot, "rev-parse", "--verify", "--quiet", "refs/remotes/"+Remote+"/"+def+"^{commit}", "--") + if err != nil || !gitutil.IsFullSHA(merged) { + return Outcome{}, false, fmt.Errorf("resolving %s/%s to sync %s: %v", Remote, def, lane.ID, err) + } + tip, err := branchTip(c, *lane) + if err != nil { + return Outcome{}, false, err + } + s := Sync{At: c.Now, Siblings: siblings, Merged: merged} + if tip != lane.HeadSHA { + // A call killed after its merge commit and before its state write: + // found, when the tip is exactly that merge. + parents, _ := gitutil.Run(c.RepoRoot, "rev-list", "--parents", "-n", "1", tip, "--") + if f := strings.Fields(parents); len(f) != 3 || f[1] != lane.HeadSHA || f[2] != merged { + return Outcome{}, false, refuse(string(StageLand), "", lane.ID, + fmt.Sprintf("%s moved to %s, which is not a sync of the judged head %s", lane.Branch, shortSHA(tip), shortSHA(lane.HeadSHA)), + "restore the branch to the judged head, then run `abcd implement step` again") + } + } else { + msg := syncSubject(*lane, def, siblings) + "\n\n" + + fmt.Sprintf("The implement loop merges %s at %s into %s of %s, because %s landed since the lane's base; it never rebases, so every judged commit stays reachable.\n\n", + def, shortSHA(merged), lane.ID, c.State.RunID, strings.Join(siblings, ", ")) + + "Assisted-by: None\n" + if _, err := pickGit(lane.Worktree, "merge", "--no-ff", "--no-edit", "-m", msg, merged); err != nil { + conflicted, _ := pickGit(lane.Worktree, "diff", "--name-only", "--diff-filter=U", "-z") + if _, aerr := pickGit(lane.Worktree, "merge", "--abort"); aerr != nil { + return Outcome{}, false, fmt.Errorf("aborting the sync's merge in the lane's worktree: %w", aerr) + } + paths := strings.FieldsFunc(conflicted, func(r rune) bool { return r == 0 }) + if len(paths) == 0 { + return Outcome{}, false, refuse(string(StageLand), "", lane.ID, "git could not merge "+def+" into "+lane.Branch+": "+fsutil.RedactHome(err.Error()), + "settle what git reports in the lane's worktree, then run `abcd implement step` again") + } + s.Conflicted, s.Paths = true, paths + if err := writeSyncBrief(c, *lane, len(lane.Syncs)+1, def, &s); err != nil { + return Outcome{}, false, err + } + lane.Syncs = append(append([]Sync{}, lane.Syncs...), s) + return Outcome{Goto: StageValidate, Note: fmt.Sprintf("the sync of %s with %s at %s conflicted in %s (%s landed first); the merge is aborted with the branch unchanged, and a fresh implementer resolves it from %s", + lane.ID, def, shortSHA(merged), strings.Join(paths, ", "), strings.Join(siblings, ", "), s.Brief)}, true, nil + } + if tip, err = branchTip(c, *lane); err != nil { + return Outcome{}, false, err + } + } + s.Head = tip + lane.HeadSHA = tip + lane.Syncs = append(append([]Sync{}, lane.Syncs...), s) + return Outcome{Goto: StageValidate, Note: fmt.Sprintf("synced %s with %s at %s after %s landed: merge commit %s; a fresh round judges it, and counts no fix round", + lane.ID, def, shortSHA(merged), strings.Join(siblings, ", "), shortSHA(tip))}, true, nil +} + +// writeSyncBrief renders the brief of the fresh implementer a conflicting sync +// goes to, and names it and its receipt on s. +func writeSyncBrief(c Context, lane Lane, n int, def string, s *Sync) error { + dir, err := laneFile(c.State.RunID, lane.ID, StageValidate, fmt.Sprintf("%s/%d", SyncDirName, n)) + if err != nil { + return err + } + laneDir, err := laneRel(c.State.RunID, lane.ID, StageValidate) + if err != nil { + return err + } + inLane := strings.TrimPrefix(dir, laneDir+"/") + s.Brief, s.Receipt = dir+"/"+BriefFileName, dir+"/"+ReceiptFileName + var b bytes.Buffer + p := func(format string, a ...any) { fmt.Fprintf(&b, format, a...) } + p("# Sync brief: %s of %s\n\n", lane.ID, c.State.RunID) + p("You are a fresh implementer. %s landed on %s since this lane's base, and merging %s at %s into\n", strings.Join(s.Siblings, ", "), def, def, s.Merged) + p("`%s` conflicted. The loop aborted that merge, so the branch is where its validators left it.\n\n", lane.Branch) + p("The conflicting paths:\n\n") + for _, path := range s.Paths { + p("- `%s`\n", path) + } + p("\nIn the worktree `%s`, merge %s into `%s` with a merge commit (`git merge %s`; never a\n", lane.Worktree, s.Merged, lane.Branch, s.Merged) + p("rebase), resolve each conflict keeping both lanes' intent, and commit the merge. The merged sha must be an\n") + p("ancestor of your head; a fresh round of validators judges it.\n\n") + p("Your brief as the lane's implementer: `%s`\n\n", abs(c.RepoRoot, lane.Brief)) + p("## What you hand back\n\n") + p("- your report, at `%s` (in the receipt: `%s/%s`)\n", abs(c.RepoRoot, dir+"/"+ReportFileName), inLane, ReportFileName) + p("- the definition of done's whole output, at `%s` (in the receipt: `%s/%s`)\n", abs(c.RepoRoot, dir+"/"+DoDFileName), inLane, DoDFileName) + p("- the receipt, at `%s`: the lane receipt's fields — `schema_version` %d, `run_id` %q, `lane` %q,\n", abs(c.RepoRoot, s.Receipt), ReceiptSchemaVersion, c.State.RunID, lane.ID) + p(" `branch` %q, `commits` (the merge commit and any you made), `definition_of_done`, `report`, and an optional `model`.\n", lane.Branch) + return writeRoundFile(c.RepoRoot, s.Brief, b.Bytes()) +} + +// verifySync verifies the receipt of the implementer a conflicting sync went +// to: as a fix receipt is, and with the merged sha an ancestor of the new head. +func verifySync(c Context, lane *Lane, receiptRel string) error { + s := lane.pendingSync() + if err := verifyLaneReceipt(c, lane, receiptRel, s.Receipt); err != nil { + return err + } + on, err := gitutil.IsAncestor(c.RepoRoot, s.Merged, lane.HeadSHA) + if err != nil || !on { + return refuse("receipt", "", lane.ID, + fmt.Sprintf("%s's head %s does not contain %s, the default branch's sha the sync merges in", lane.Branch, shortSHA(lane.HeadSHA), shortSHA(s.Merged)), + "merge "+s.Merged+" into "+lane.Branch+" with a merge commit (never a rebase), then hand the receipt back") + } + syncs := append([]Sync{}, lane.Syncs...) + syncs[len(syncs)-1].Head = lane.HeadSHA + lane.Syncs = syncs + return nil +} diff --git a/internal/core/implement/loop/validate.go b/internal/core/implement/loop/validate.go index 57f2c0b64..1b2d27d59 100644 --- a/internal/core/implement/loop/validate.go +++ b/internal/core/implement/loop/validate.go @@ -1,12 +1,14 @@ package loop // validate.go is the validate stage (spec piece 8; criteria 5 and 12). The -// stage hands the lane's head to validators that did not implement it, one -// fresh agent at a time: the ruthless reviewer, the security reviewer and, on -// the lane whose landing closes the spec, the intent-auditor over the whole -// delivery (ruling AI, 2026-09-29: "audit ONCE, on the lane that closes the -// spec, over the whole delivery"). A lane that does not close the spec takes no -// audit step. +// stage hands the lane's head to validators that did not implement it, each a +// fresh agent, side by side up to the run's ceiling (ruling DR6): the ruthless +// reviewer, the security reviewer and, on the lane whose landing closes the +// spec, the intent-auditor over the whole delivery (ruling AI, 2026-09-29: +// "audit ONCE, on the lane that closes the spec, over the whole delivery"). A +// lane that does not close the spec takes no audit step. The validators read +// the lane's head and treat it as read-only; a fix brief is written only once +// every validator of the round has returned. // // Only the loop writes a verdict (decision 9, itd-58 folded in). Each validator // writes its return; the loop parses the verdict out of that return and records @@ -56,6 +58,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "github.com/intentdriven/abcd/internal/adapter/scanner" @@ -127,14 +130,23 @@ func validateStage(c Context, lane *Lane) (Outcome, error) { return Outcome{}, refuse(string(StageValidate), "", lane.ID, "the lane records no branch, worktree, base and head for its validators to read", "the implement stage's verified receipt records them; restore the run's state file") } + if s := lane.pendingSync(); s != nil { + return Outcome{Await: &Await{Role: RoleImplementer, Brief: s.Brief, Receipt: s.Receipt}, + Note: fmt.Sprintf("the sync of %s with the default branch conflicted; a fresh implementer resolves it from the brief %s", lane.ID, s.Brief)}, nil + } n := len(lane.Validation) - if n == 0 || lane.Validation[n-1].Fix != "" { + if n == 0 || lane.Validation[n-1].Fix != "" || lane.syncRoundDue() { round, err := openRound(c, *lane, n+1) if err != nil { return Outcome{}, err } - lane.Validation = append(lane.Validation, round) + lane.Validation = append(slices.Clone(lane.Validation), round) n++ + if lane.syncRoundDue() { + syncs := slices.Clone(lane.Syncs) + syncs[len(syncs)-1].Round = round.Round + lane.Syncs = syncs + } } cur := &lane.Validation[n-1] if cur.HeadSHA != lane.HeadSHA { @@ -144,7 +156,7 @@ func validateStage(c Context, lane *Lane) (Outcome, error) { } for i := range cur.Validators { v := &cur.Validators[i] - if v.Verdict != "" { + if v.Verdict != "" || lane.awaited(v.Return) { continue } if err := writeValidatorBrief(c, *lane, *cur, v); err != nil { @@ -153,6 +165,12 @@ func validateStage(c Context, lane *Lane) (Outcome, error) { return Outcome{Await: &Await{Role: v.Role, Brief: v.Brief, Receipt: v.Return}, Note: fmt.Sprintf("round %d: %s's head %s handed to a fresh %s; awaiting its return at %s", cur.Round, lane.ID, shortSHA(cur.HeadSHA), v.Role, v.Return)}, nil } + for _, v := range cur.Validators { + if v.Verdict == "" { + return Outcome{}, contend(string(StageValidate), "", lane.ID, fmt.Sprintf("round %d's validators are all out", cur.Round), + "hand back their returns with `abcd implement receipt <path>`") + } + } var failing []ValidatorRun for _, v := range cur.Validators { if !v.Pass { @@ -160,7 +178,7 @@ func validateStage(c Context, lane *Lane) (Outcome, error) { } } if len(failing) > 0 { - if taken, limit := cur.Round-1, c.State.FixRoundCap(); taken >= limit { + if taken, limit := lane.fixRoundsTaken(), c.State.FixRoundCap(); taken >= limit { hb := HandBack{Verdict: VerdictUnachievable, Round: cur.Round, FixRounds: limit, Verdicts: verdictsLine(*cur)} for _, v := range failing { hb.Findings = append(hb.Findings, v.Return) @@ -211,16 +229,24 @@ func openRound(c Context, lane Lane, n int) (ValidationRound, error) { } // auditsHere reports whether the lane takes the fidelity audit: it is the lane -// whose landing closes the spec — the run's last lane, with no spec step left -// pending — for an intent (an issue has no criteria), and closing the spec ships -// the intent, since no other open spec names it. A lane whose close leaves the -// intent planned leaves the audit to the lane that closes its last spec: the -// criteria are the intent's, and an intent is audited once, whole. +// whose landing closes the spec — the closing lane, which reaches its landing +// with no spec step pending, no other lane of the run open, and no lane handed +// back (spc-2609202134341288; with `- needs: none` a later lane can finish +// first, so it is not simply the last lane opened) — for an intent (an issue +// has no criteria), and closing the spec ships the intent, since no other open +// spec names it. A lane whose close leaves the intent planned leaves the audit +// to the lane that closes its last spec: the criteria are the intent's, and an +// intent is audited once, whole. After a hand-back no lane closes the spec. func auditsHere(c Context, lane Lane) (bool, error) { st := c.State - if !recordid.ValidIntentID(lane.Key) || len(st.Pending) > 0 || len(st.Lanes) == 0 || st.Lanes[len(st.Lanes)-1].ID != lane.ID { + if !recordid.ValidIntentID(lane.Key) || len(st.Pending) > 0 || st.handedBack() { return false, nil } + for _, l := range st.Lanes { + if l.ID != lane.ID && l.Stage != StageDone { + return false, nil + } + } store, err := spec.Load(c.RepoRoot) if err != nil { return false, fmt.Errorf("reading the spec store to place the audit: %w", err) @@ -298,7 +324,7 @@ func writeValidatorBrief(c Context, lane Lane, r ValidationRound, v *ValidatorRu if err := writeRoundFile(c.RepoRoot, req, []byte(a.Request(delivered))); err != nil { return err } - v.Audit = &AuditRun{ReceiptID: a.ReceiptID, Request: req, BaseSHA: st.Lanes[0].BaseSHA, HeadSHA: r.HeadSHA} + v.Audit = &AuditRun{ReceiptID: a.ReceiptID, Request: req, BaseSHA: diffBase(lane), HeadSHA: r.HeadSHA} p("## The audit\n\n") p("This lane's landing closes the spec, so the fidelity audit runs here, once, over the whole delivery\n") p("(receipt %s). The request states the criteria, the scope conditions, the rubric, the verdict's\n", a.ReceiptID) @@ -357,9 +383,12 @@ func composeAudit(c Context, lane Lane) (intent.DeliveryAudit, string, error) { "correct the intent on the lane's branch so the close can ship it") } + // The whole delivery is the run's own lanes' changes, lane by lane: each + // lane's head against the default-branch sha it last merged in, or its + // base when it never synced, so no sibling's or outside change reads as + // this run's (spc-2609202134341288, ruling AI). var d strings.Builder - first := st.Lanes[0] - fmt.Fprintf(&d, "- the whole delivery: `%s..%s`, from the base of %s, the run's first lane, to the head of %s\n", first.BaseSHA, lane.HeadSHA, first.ID, lane.ID) + fmt.Fprintf(&d, "- the whole delivery: the diff of each of the run's lanes, below\n") for _, l := range st.Lanes { if l.ID == lane.ID { l = lane @@ -368,7 +397,7 @@ func composeAudit(c Context, lane Lane) (intent.DeliveryAudit, string, error) { if l.PR > 0 { pr = fmt.Sprintf(", pull request #%d", l.PR) } - fmt.Fprintf(&d, "- %s (spec step %d, %q): `%s..%s` on `%s`%s\n", l.ID, l.SpecStep, l.StepTitle, l.BaseSHA, l.HeadSHA, l.Branch, pr) + fmt.Fprintf(&d, "- %s (spec step %d, %q): `%s..%s` on `%s`%s\n", l.ID, l.SpecStep, l.StepTitle, diffBase(l), l.HeadSHA, l.Branch, pr) } if e, ok := at.recordEntry(spec.SpecsRelDir, "spc", st.Spec); ok { if text, err := at.blob(e, maxRecordBytes); err == nil { @@ -384,6 +413,18 @@ func composeAudit(c Context, lane Lane) (intent.DeliveryAudit, string, error) { return a, d.String(), nil } +// diffBase is where a lane's own changes start: the default-branch sha its +// last sync merged in, or its base when it never synced. Each is an ancestor of +// the lane's head. +func diffBase(l Lane) string { + for k := len(l.Syncs) - 1; k >= 0; k-- { + if l.Syncs[k].Head != "" { + return l.Syncs[k].Merged + } + } + return l.BaseSHA +} + // recordEntry is the one copy of a record the tree carries, if it carries // exactly one. func (b baseTree) recordEntry(dir, family, id string) (baseEntry, bool) { @@ -478,12 +519,18 @@ func reportPathOf(c Context, lane Lane, receiptRel string) string { // a fresh implementer's receipt is verified as a lane receipt is, and closes the // round, so the next step opens the next. func verifyValidation(c Context, lane *Lane, receiptRel string) error { + if c.Await == nil { + return refuse("receipt", "", lane.ID, "the lane's validate stage has handed nothing out", "run `abcd implement step`") + } + if s := lane.pendingSync(); s != nil && c.Await.Role == RoleImplementer && receiptRel == s.Receipt { + return verifySync(c, lane, receiptRel) + } n := len(lane.Validation) - if n == 0 || lane.Awaiting == nil { + if n == 0 { return refuse("receipt", "", lane.ID, "the lane's validate stage has handed nothing out", "run `abcd implement step`") } cur := &lane.Validation[n-1] - if lane.Awaiting.Role == RoleImplementer { + if c.Await.Role == RoleImplementer { want, err := roundDir(c.State.RunID, lane.ID, cur.Round, FixDirName) if err != nil { return err diff --git a/internal/core/implement/loop/validate_test.go b/internal/core/implement/loop/validate_test.go index 96556301d..7833f5a4e 100644 --- a/internal/core/implement/loop/validate_test.go +++ b/internal/core/implement/loop/validate_test.go @@ -38,7 +38,7 @@ func stepTo(t *testing.T, repo *gittest.Repo, runID string, stages Stages, want if err != nil { t.Fatal(err) } - if i := st.current(); i >= 0 && st.Lanes[i].Stage == want && st.Lanes[i].Awaiting == nil { + if i := st.current(); i >= 0 && st.Lanes[i].Stage == want && st.Lanes[i].awaiting() == nil { return st.Lanes[i] } if _, err := advance(repo.Root(), runID, stages, Options{}); err != nil { @@ -207,8 +207,13 @@ func TestTheValidatorsAreFreshAgentsAndOnlyTheLoopRecordsAVerdict(t *testing.T) t.Fatalf("the reviewer's brief names %q:\n%s", want, brief) } } - got := handBack(t, repo, id, stages, RoleRuthless, reviewerReturn("SHIP")) - if got.PerformedStage != "" || got.Stage != StageValidate || got.Awaiting != nil { + // The ruthless reviewer is out; its return is handed back before the next + // step, which would otherwise hand the security reviewer out beside it. + if err := os.WriteFile(filepath.Join(repo.Root(), filepath.FromSlash(res.Awaiting.Receipt)), []byte(reviewerReturn("SHIP")), 0o600); err != nil { + t.Fatal(err) + } + got, err := Receipt(repo.Root(), id, filepath.Join(repo.Root(), filepath.FromSlash(res.Awaiting.Receipt)), stages, Options{}) + if err != nil || got.PerformedStage != "" || got.Stage != StageValidate || got.Awaiting != nil { t.Fatalf("a validator's return leaves the lane at validate for the next: %+v", got) } st, _ := ReadState(repo.Root(), id) @@ -245,8 +250,10 @@ func TestTheValidatorsAreFreshAgentsAndOnlyTheLoopRecordsAVerdict(t *testing.T) // TestTheAuditRunsOnceOnTheClosingLaneOverTheWholeDelivery is ruling AI: a // lane whose landing does not close the spec takes no audit step, and the lane -// whose landing does takes it once, over the whole delivery — from the base of -// the run's first lane to the closing lane's head, with each lane's own range. +// whose landing does takes it once, over the whole delivery — the run's own +// lanes' changes, lane by lane, each lane's head against its base (or the +// default-branch sha it last merged in), never one range from the first lane's +// base, which after a sync would carry outside work (spc-2609202134341288). func TestTheAuditRunsOnceOnTheClosingLaneOverTheWholeDelivery(t *testing.T) { repo := steppedBriefRepo(t, "1. The parser\n2. The loop\n") start, err := Start(repo.Root(), "itd-10", Options{}) @@ -283,16 +290,18 @@ func TestTheAuditRunsOnceOnTheClosingLaneOverTheWholeDelivery(t *testing.T) { t.Fatal(err) } st, _ = ReadState(repo.Root(), id) - whole := st.Lanes[0].BaseSHA + ".." + closing.HeadSHA - for _, want := range []string{whole, st.Lanes[0].BaseSHA + ".." + st.Lanes[0].HeadSHA, closing.BaseSHA + ".." + closing.HeadSHA, + if whole := st.Lanes[0].BaseSHA + ".." + closing.HeadSHA; strings.Contains(string(req), whole) { + t.Fatalf("the delivery is lane by lane, not the range %s:\n%s", whole, req) + } + for _, want := range []string{st.Lanes[0].BaseSHA + ".." + st.Lanes[0].HeadSHA, closing.BaseSHA + ".." + closing.HeadSHA, "lane-1", "lane-2", "## Acceptance Criteria", "## Provenance"} { if !strings.Contains(string(req), want) { t.Fatalf("the audit request carries %q:\n%s", want, req) } } a := st.Lanes[1].Validation[0].Validators[2].Audit - if a == nil || a.BaseSHA != st.Lanes[0].BaseSHA || a.HeadSHA != closing.HeadSHA { - t.Fatalf("the audit's range is the whole delivery: %+v", a) + if a == nil || a.BaseSHA != closing.BaseSHA || a.HeadSHA != closing.HeadSHA { + t.Fatalf("the audit's own range is the closing lane's diff: %+v", a) } if res, err := advance(repo.Root(), id, stages, Options{}); err != nil || res.PerformedStage != StageValidate { t.Fatalf("the closing lane's passing round completes its validation: %+v %v", res, err) diff --git a/internal/core/intent/audit.go b/internal/core/intent/audit.go index 3f95b7065..8e54400a5 100644 --- a/internal/core/intent/audit.go +++ b/internal/core/intent/audit.go @@ -208,7 +208,7 @@ type verdictGapAudit struct { type AuditEmitResult struct { ReceiptID string `json:"receipt_id"` IntentID string `json:"intent_id"` - Status string `json:"status"` // owed | already_owed | already_ingested | already_dead_letter + Status string `json:"status"` // owed | already_owed | check_owed | already_ingested | already_dead_letter RequestPath string `json:"request_path,omitempty"` RequestWritten bool `json:"request_written"` } @@ -241,6 +241,17 @@ type IngestVerdictResult struct { // occasion in the rationale and ingests again for the same receipt: a // payload that renders differently replaces the ingested block (Replaced). ReadingOccasionedStanding []condition.Disposition `json:"reading_occasioned_standing,omitempty"` + // AuditOwed names each criterion the verdict judged NOT_MET or + // INCONCLUSIVE ("ac-2 NOT_MET"), the check the flag leaves owed on the + // shipped intent (ruling DQ1c); empty on a passing verdict. + AuditOwed []string `json:"audit_owed,omitempty"` + // OwedIssue is the issue carrying that check, and OwedIssueLinked is true + // when it was already open rather than filed by this ingest. + OwedIssue string `json:"owed_issue,omitempty"` + OwedIssueLinked bool `json:"owed_issue_linked,omitempty"` + // FlagCleared is the issue a passing re-audit resolved as it cleared the + // flag. + FlagCleared string `json:"flag_cleared,omitempty"` } // MarshalJSON writes the result the way its outcome reads (iss-2609190337545165). @@ -276,10 +287,16 @@ func (r IngestVerdictResult) MarshalJSON() ([]byte, error) { Reason string `json:"reason,omitempty"` Replaced bool `json:"replaced,omitempty"` ReadingOccasionedStanding []condition.Disposition `json:"reading_occasioned_standing,omitempty"` + AuditOwed []string `json:"audit_owed,omitempty"` + OwedIssue string `json:"owed_issue,omitempty"` + OwedIssueLinked bool `json:"owed_issue_linked,omitempty"` + FlagCleared string `json:"flag_cleared,omitempty"` }{ Status: r.Status, ReceiptID: r.ReceiptID, IntentID: r.IntentID, DeadLetterPath: r.DeadLetterPath, Reason: r.Reason, Replaced: r.Replaced, ReadingOccasionedStanding: r.ReadingOccasionedStanding, + AuditOwed: r.AuditOwed, OwedIssue: r.OwedIssue, + OwedIssueLinked: r.OwedIssueLinked, FlagCleared: r.FlagCleared, } switch r.Status { case "ingested": @@ -375,6 +392,16 @@ func emitLocked(repoRoot string, it Intent, opts AuditEmitOptions) (AuditEmitRes switch state { case "INGESTED": res.Status = "already_ingested" + // A verdict that left a check owed (ruling DQ1c) is not terminal: + // its re-run needs the request, so it is rewritten. + if hasOwedFlag(content, rcp) { + res.Status = "check_owed" + if err := writeAuditRequest(repoRoot, it, rcp, content, opts); err != nil { + return res, err + } + res.RequestPath = filepath.Join(reviewsRelDir, rcp+".request.md") + res.RequestWritten = true + } case "DEAD_LETTER": res.Status = "already_dead_letter" default: @@ -889,22 +916,103 @@ func IngestVerdictBytes(repoRoot string, raw []byte) (IngestVerdictResult, error // holds no receipt to resolve: that refusal is made without the lock, so it // still writes nothing. if _, err := os.Lstat(filepath.Join(repoRoot, IntentsRelDir)); errors.Is(err, fs.ErrNotExist) { - return ingestLocked(repoRoot, raw, rcp) + return ingestLocked(repoRoot, raw, rcp, auditLedgerOutcome{}) + } + // A verdict that leaves a check owed, or clears one, is answered by the + // ledger (ruling DQ1c; audit_owed.go). The ledger's lock precedes this + // store's, so the verdict is judged under this lock first, the ledger is + // asked with it released, and the write below judges the record afresh. + // A ledger that cannot answer refuses the ingest with nothing written, so + // the receipt stays as it was and the same verdict can be ingested again. + var pv owedPreview + if err := withIntentMintLock(repoRoot, func() error { + var err error + pv, err = previewOwed(repoRoot, raw, rcp) + return err + }); err != nil { + return IngestVerdictResult{}, err + } + lg, err := askAuditLedger(repoRoot, pv) + if err != nil { + return IngestVerdictResult{}, err } var res IngestVerdictResult - err := withIntentMintLock(repoRoot, func() error { + err = withIntentMintLock(repoRoot, func() error { var err error - res, err = ingestLocked(repoRoot, raw, rcp) + res, err = ingestLocked(repoRoot, raw, rcp, lg) return err }) return res, err } +// owedPreview is what the first hold of the lock judged: the owed check a +// valid verdict leaves, or the flag a passing one clears. The zero value asks +// the ledger for nothing (an unresolvable or invalid payload, which the write +// refuses or quarantines on its own terms). +type owedPreview struct { + owed *AuditOwed + clearRcp string + clearIss string + clearItd string + clearRoot string +} + +// previewOwed judges the verdict against the record read under the lock and +// writes nothing. +func previewOwed(repoRoot string, raw []byte, rcp string) (owedPreview, error) { + it, content, _, ok, err := findIntentByReceipt(repoRoot, rcp) + if err != nil || !ok { + return owedPreview{}, err + } + if checkIssuedPolicy(repoRoot, raw, it, rcp, content) != nil { + return owedPreview{}, nil + } + v, verr := validateVerdict(raw, rcp, content) + if verr != nil { + return owedPreview{}, nil + } + if crit, failed := owedCriteria(v); len(crit) > 0 { + return owedPreview{owed: &AuditOwed{RepoRoot: repoRoot, IntentID: it.ID, IntentPath: filepath.ToSlash(it.Path), + ReceiptID: rcp, Criteria: crit, Failed: failed}}, nil + } + // A passing verdict clears the flag when the receipt carries one, and + // sweeps the open carriers of its check either way: an ingest that filed + // a carrier and then failed to write the intent left one on an unflagged + // receipt, and it must not outlive the check it carries. + iss, _ := flaggedIssue(content, rcp) + return owedPreview{clearRcp: rcp, clearIss: iss, clearItd: it.ID, clearRoot: repoRoot}, nil +} + +// askAuditLedger files the owed check or resolves the cleared one, with the +// intent store's lock released. With no ledger linked it asks nothing. +func askAuditLedger(repoRoot string, pv owedPreview) (auditLedgerOutcome, error) { + switch { + case pv.owed != nil && auditOwedFiler != nil: + f, err := auditOwedFiler(*pv.owed) + if err != nil { + return auditLedgerOutcome{}, fmt.Errorf("intent: the audit of %s leaves a check owed and the ledger could not file it: %w; "+ + "nothing was written, and ingesting the verdict again retries", pv.owed.IntentID, err) + } + return auditLedgerOutcome{issue: f.IssueID, linked: f.Linked}, nil + case pv.clearRcp != "" && auditOwedClearer != nil: + if err := auditOwedClearer(AuditCleared{RepoRoot: repoRoot, IntentID: pv.clearItd, ReceiptID: pv.clearRcp, IssueID: pv.clearIss}); err != nil { + what := "an open issue carrying its owed check" + if pv.clearIss != "" { + what = pv.clearIss + ", which carries its owed check," + } + return auditLedgerOutcome{}, fmt.Errorf("intent: the audit of %s passes and %s could not be resolved: %w; "+ + "nothing was written, and ingesting the verdict again retries", pv.clearItd, what, err) + } + return auditLedgerOutcome{cleared: pv.clearIss}, nil + } + return auditLedgerOutcome{}, nil +} + // ingestLocked is IngestVerdictBytes's critical section, called under the // intent store lock: it resolves rcp to its intent on the bytes read there and // applies the verdict to those bytes. reingestVerdict and deadLetter are reached // only from here, so they run under the same hold. -func ingestLocked(repoRoot string, raw []byte, rcp string) (IngestVerdictResult, error) { +func ingestLocked(repoRoot string, raw []byte, rcp string, lg auditLedgerOutcome) (IngestVerdictResult, error) { it, content, state, ok, err := findIntentByReceipt(repoRoot, rcp) if err != nil { return IngestVerdictResult{}, err @@ -913,7 +1021,7 @@ func ingestLocked(repoRoot string, raw []byte, rcp string) (IngestVerdictResult, return IngestVerdictResult{}, fmt.Errorf("intent: verdict receipt %s matches no parked review marker (unsolicited); refusing to ingest", rcp) } if state == "INGESTED" { - return reingestVerdict(repoRoot, raw, it, rcp, content) + return reingestVerdict(repoRoot, raw, it, rcp, content, lg) } // The attestation chain must be the pair THIS receipt issued, not merely two @@ -947,7 +1055,8 @@ func ingestLocked(repoRoot string, raw []byte, rcp string) (IngestVerdictResult, } rollup := countVerdicts(v) - block := ingestedBlock(rcp, v, rollup, free) + owed := owedFor(it, rcp, v) + block := ingestedBlock(rcp, v, rollup, free, owed, lg.issue) if err := degraded(); err != nil { return IngestVerdictResult{}, err } @@ -959,7 +1068,7 @@ func ingestLocked(repoRoot string, raw []byte, rcp string) (IngestVerdictResult, return IngestVerdictResult{}, err } split := countDispositions(v) - return IngestVerdictResult{ + return withOwed(IngestVerdictResult{ Status: "ingested", ReceiptID: rcp, IntentID: it.ID, Criteria: len(v.Criteria), Met: rollup["MET"], MetWithConcern: rollup["MET_WITH_CONCERNS"], NotMet: rollup["NOT_MET"], Inconclusive: rollup["INCONCLUSIVE"], @@ -967,7 +1076,7 @@ func ingestLocked(repoRoot string, raw []byte, rcp string) (IngestVerdictResult, Narrowed: split[dispositionNarrowed], Falsified: split["falsified"], Untested: split[dispositionUntested], ReadingOccasionedStanding: occasionedStanding(updated), - }, nil + }, owed, lg), nil } // reingestVerdict applies a verdict for a receipt already INGESTED. The receipt @@ -980,7 +1089,7 @@ func ingestLocked(repoRoot string, raw []byte, rcp string) (IngestVerdictResult, // nothing written rather than dead-lettered: quarantine is for a receipt still // owed a verdict, and a bad re-ingest must never replace a good one. It runs // under the store lock ingestLocked holds. -func reingestVerdict(repoRoot string, raw []byte, it Intent, rcp, content string) (IngestVerdictResult, error) { +func reingestVerdict(repoRoot string, raw []byte, it Intent, rcp, content string, lg auditLedgerOutcome) (IngestVerdictResult, error) { free, degraded, err := newVerdictProse(repoRoot) if err != nil { return IngestVerdictResult{}, err @@ -991,12 +1100,24 @@ func reingestVerdict(repoRoot string, raw []byte, it Intent, rcp, content string "an ingested verdict is replaced only by a valid one (nothing written)", rcp, free(verr.Error())) } rollup := countVerdicts(v) - block := ingestedBlock(rcp, v, rollup, free) + owed := owedFor(it, rcp, v) + block := ingestedBlock(rcp, v, rollup, free, owed, lg.issue) if err := degraded(); err != nil { return IngestVerdictResult{}, err } if existing, ok := reviewBlockText(content, rcp); ok && sameReviewBlock(existing, block, rcp) { - return IngestVerdictResult{Status: "noop", ReceiptID: rcp, IntentID: it.ID}, nil + return withOwed(IngestVerdictResult{Status: "noop", ReceiptID: rcp, IntentID: it.ID}, owed, lg), nil + } + // A passing re-audit of a flagged receipt clears the flag: the replacement + // block carries none, and a dated line below it says when and which issue + // was resolved. The line sits outside the block, so a later re-ingest of + // the same passing verdict leaves it alone. + if owed == nil && hasOwedFlag(content, rcp) { + iss, _ := flaggedIssue(content, rcp) + if iss == "" { + iss = "no issue" + } + block += "\n\n" + clearedLine(rcp, iss, auditNow()) } if err := checkIssuedPolicy(repoRoot, raw, it, rcp, content); err != nil { return IngestVerdictResult{}, err @@ -1009,7 +1130,7 @@ func reingestVerdict(repoRoot string, raw []byte, it Intent, rcp, content string return IngestVerdictResult{}, err } split := countDispositions(v) - return IngestVerdictResult{ + return withOwed(IngestVerdictResult{ Status: "ingested", Replaced: true, ReceiptID: rcp, IntentID: it.ID, Criteria: len(v.Criteria), Met: rollup["MET"], MetWithConcern: rollup["MET_WITH_CONCERNS"], NotMet: rollup["NOT_MET"], Inconclusive: rollup["INCONCLUSIVE"], @@ -1017,7 +1138,31 @@ func reingestVerdict(repoRoot string, raw []byte, it Intent, rcp, content string Narrowed: split[dispositionNarrowed], Falsified: split["falsified"], Untested: split[dispositionUntested], ReadingOccasionedStanding: occasionedStanding(updated), - }, nil + }, owed, lg), nil +} + +// owedFor is the check v leaves owed on it, or nil when v passes: no +// criterion NOT_MET and none INCONCLUSIVE (ruling DQ1a: an undecided audit +// never closes like a pass). +func owedFor(it Intent, rcp string, v verdict) *AuditOwed { + crit, failed := owedCriteria(v) + if len(crit) == 0 { + return nil + } + return &AuditOwed{IntentID: it.ID, IntentPath: filepath.ToSlash(it.Path), ReceiptID: rcp, Criteria: crit, Failed: failed} +} + +// withOwed adds the owed check and the ledger's answer to a result. +func withOwed(r IngestVerdictResult, owed *AuditOwed, lg auditLedgerOutcome) IngestVerdictResult { + if owed != nil { + for _, c := range owed.Criteria { + r.AuditOwed = append(r.AuditOwed, c.ID+" "+c.Verdict) + } + r.OwedIssue, r.OwedIssueLinked = lg.issue, lg.linked + return r + } + r.FlagCleared = lg.cleared + return r } // checkIssuedPolicy compares the verdict's policy hashes against the pair the @@ -1756,7 +1901,7 @@ func untestedDispositions(intentContent string) []verdictCondition { return out } -func ingestedBlock(rcp string, v verdict, rollup map[string]int, free proseField) string { +func ingestedBlock(rcp string, v verdict, rollup map[string]int, free proseField, owed *AuditOwed, owedIssue string) string { var b strings.Builder fmt.Fprintf(&b, "<!-- abcd-review: INGESTED receipt=%s -->\n", rcp) fmt.Fprintf(&b, "Fidelity review — receipt %s (verifier %s %s).\n\n", @@ -1782,6 +1927,12 @@ func ingestedBlock(rcp string, v verdict, rollup map[string]int, free proseField b.WriteString("\n") fmt.Fprintf(&b, "Acceptance rollup: MET %d · MET_WITH_CONCERNS %d · NOT_MET %d · INCONCLUSIVE %d\n\n", rollup["MET"], rollup["MET_WITH_CONCERNS"], rollup["NOT_MET"], rollup["INCONCLUSIVE"]) + if owed != nil { + // The check still owed (ruling DQ1c): the intent stays shipped, and + // this line names what is unmet or undecided until a passing re-audit + // of the same receipt replaces the block. + b.WriteString(owedFlagLine(*owed, owedIssue) + "\n\n") + } b.WriteString("Per-criterion verdicts:\n") for _, c := range v.Criteria { diff --git a/internal/core/intent/audit_owed.go b/internal/core/intent/audit_owed.go new file mode 100644 index 000000000..96d15faa1 --- /dev/null +++ b/internal/core/intent/audit_owed.go @@ -0,0 +1,171 @@ +package intent + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// audit_owed.go — what an after-merge fidelity audit that fails or comes back +// undecided leaves behind (ruling DQ1c, 2026-09-30, the product thinker): +// +// "(a) STAYS SHIPPED, FLAGGED: the intent stays in shipped/, its changelog +// entry stands, and a 'check still owed' flag names the unmet/undecided +// criteria until it is fixed and re-checked." +// +// The owed check is carried by ONE issue the ledger captures automatically, +// naming the intent and the remaining criteria, with a real remedy: "fix, then +// re-run the audit" when a criterion is NOT_MET, "re-run the audit" when the +// verdict was only undecided (INCONCLUSIVE). A later audit of the same receipt +// that judges no criterion NOT_MET or INCONCLUSIVE clears the flag with a dated +// Audit Notes line and resolves that issue. Nothing un-ships: the intent never +// leaves shipped/ (lifecycle.go's "never un-ship" and itd-50 stand). +// +// The ledger's package imports this one, so the filer is a seam it registers +// from init (SetAuditLedger), as it registers its lock (SetLedgerLock). The +// ledger's lock comes before this store's in the one lock order, so the ingest +// calls the seam with the intent store's lock RELEASED: it judges the verdict +// under the lock, files or resolves, and then writes the record under the lock +// again, judging it afresh (IngestVerdictBytes). + +// OwedCriterion is one criterion an audit judged NOT_MET or INCONCLUSIVE. +type OwedCriterion struct { + ID string `json:"criterion_id"` + Verdict string `json:"verdict"` +} + +// AuditOwed is the check an after-merge audit leaves owed on a shipped intent. +type AuditOwed struct { + RepoRoot string + IntentID string + IntentPath string // repo-relative, slash-separated + ReceiptID string + Criteria []OwedCriterion + // Failed is true when a criterion is NOT_MET, false when every owed one is + // INCONCLUSIVE. + Failed bool +} + +// Remedy is the flag's remedy: fix, then re-run, for a failed audit; only the +// re-run for an undecided one. +func (o AuditOwed) Remedy() string { + if o.Failed { + return "fix, then re-run the audit" + } + return "re-run the audit" +} + +// AuditFiling is the ledger's answer to an owed check: the issue carrying it, +// and whether that issue was already open (linked) rather than filed now. +type AuditFiling struct { + IssueID string + Linked bool +} + +// AuditCleared asks the ledger to resolve the open issues carrying the owed +// check of a receipt a passing audit judged: IssueID, the one the flag names +// (empty when the receipt carries no flag), and every other open carrier. +type AuditCleared struct { + RepoRoot string + IntentID string + ReceiptID string + IssueID string +} + +var ( + auditOwedFiler func(AuditOwed) (AuditFiling, error) + auditOwedClearer func(AuditCleared) error + // auditNow dates the clearance line; a test pins it. + auditNow = time.Now +) + +// SetAuditLedger registers the ledger's filer and resolver for owed audit +// checks, for the package that owns the ledger to call once, from init. In a +// binary that links no ledger both stay nil: the flag is still written, and +// says that no issue carries it. +func SetAuditLedger(file func(AuditOwed) (AuditFiling, error), clear func(AuditCleared) error) { + auditOwedFiler, auditOwedClearer = file, clear +} + +// owedCriteria is every criterion of v judged NOT_MET or INCONCLUSIVE, in the +// verdict's order, and whether any is NOT_MET. +func owedCriteria(v verdict) ([]OwedCriterion, bool) { + var out []OwedCriterion + failed := false + for _, c := range v.Criteria { + switch c.Verdict { + case "NOT_MET": + failed = true + case "INCONCLUSIVE": + default: + continue + } + out = append(out, OwedCriterion{ID: c.CriterionID, Verdict: c.Verdict}) + } + return out, failed +} + +// owedFlagRe reads a flag line back: the receipt, and the issue carrying it. +var owedFlagRe = regexp.MustCompile(`^Audit owed \(receipt (rcp-[0-9a-f]+)\): .*\. Carried by (iss-[0-9]+)\.$`) + +// owedFlagLine renders the flag. Criterion ids are shape-validated and the +// verdicts are the closed enum, so nothing here is free text. +func owedFlagLine(o AuditOwed, issueID string) string { + parts := make([]string, 0, len(o.Criteria)) + for _, c := range o.Criteria { + parts = append(parts, c.ID+" "+c.Verdict) + } + carried := "No issue carries it: no ledger is linked to file one." + if issueID != "" { + carried = "Carried by " + issueID + "." + } + return fmt.Sprintf("Audit owed (receipt %s): %s. Remedy: %s. %s", + o.ReceiptID, strings.Join(parts, " · "), o.Remedy(), carried) +} + +// clearedLine is the dated Audit Notes line a passing re-audit leaves where the +// flag was. +func clearedLine(rcp, issueID string, now time.Time) string { + return fmt.Sprintf("Audit owed flag cleared %s (receipt %s): a re-run of the audit judged no criterion NOT_MET or INCONCLUSIVE, and %s is resolved.", + now.UTC().Format("2006-01-02"), rcp, issueID) +} + +// flaggedIssue returns the issue the review block for rcp names on its flag +// line, if the block carries one. +func flaggedIssue(content, rcp string) (string, bool) { + lines, b, ok := reviewBlockFor(content, rcp) + if !ok { + return "", false + } + for _, ln := range lines[b.start:b.end] { + if m := owedFlagRe.FindStringSubmatch(strings.TrimRight(ln, "\r")); m != nil && m[1] == rcp { + return m[2], true + } + } + return "", false +} + +// hasOwedFlag reports whether the review block for rcp carries a flag line, +// with or without an issue. +func hasOwedFlag(content, rcp string) bool { + lines, b, ok := reviewBlockFor(content, rcp) + if !ok { + return false + } + prefix := "Audit owed (receipt " + rcp + "): " + for _, ln := range lines[b.start:b.end] { + if strings.HasPrefix(ln, prefix) { + return true + } + } + return false +} + +// auditLedgerOutcome is what the ledger answered between the two holds of the +// intent store's lock, handed to the write. +type auditLedgerOutcome struct { + issue string + linked bool + cleared string +} diff --git a/internal/core/intent/audit_owed_test.go b/internal/core/intent/audit_owed_test.go new file mode 100644 index 000000000..8542ad1db --- /dev/null +++ b/internal/core/intent/audit_owed_test.go @@ -0,0 +1,245 @@ +package intent + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Ruling DQ1c (2026-09-30, the product thinker): an after-merge audit that +// fails or comes back undecided leaves the intent SHIPPED and FLAGGED. The +// flag names the unmet or undecided criteria with the receipt, and one issue +// captured through the ledger's filer carries the owed check; a later passing +// audit clears the flag with a dated line and resolves that issue. + +// verdictWith is validVerdict with its one criterion (ac-1) judged v. +func verdictWith(t *testing.T, rcp, v string) string { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(validVerdict(rcp)), &m); err != nil { + t.Fatal(err) + } + c := m["criteria"].([]any)[0].(map[string]any) + c["verdict"] = v + m["acceptance_rollup"] = map[string]any{"MET": 0, "MET_WITH_CONCERNS": 0, "NOT_MET": 0, "INCONCLUSIVE": 0} + m["acceptance_rollup"].(map[string]any)[v] = 1 + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// fakeAuditLedger records what the ingest asked the ledger for. +type fakeAuditLedger struct { + filed []AuditOwed + cleared []AuditCleared + nextID string + open map[string]string // receipt -> issue id already open +} + +func (f *fakeAuditLedger) file(o AuditOwed) (AuditFiling, error) { + f.filed = append(f.filed, o) + if id, ok := f.open[o.ReceiptID]; ok { + return AuditFiling{IssueID: id, Linked: true}, nil + } + if f.open == nil { + f.open = map[string]string{} + } + f.open[o.ReceiptID] = f.nextID + return AuditFiling{IssueID: f.nextID}, nil +} + +func (f *fakeAuditLedger) clear(c AuditCleared) error { + f.cleared = append(f.cleared, c) + delete(f.open, c.ReceiptID) + return nil +} + +func withFakeAuditLedger(t *testing.T) *fakeAuditLedger { + t.Helper() + f := &fakeAuditLedger{nextID: "iss-2609301200000001"} + prevFile, prevClear := auditOwedFiler, auditOwedClearer + SetAuditLedger(f.file, f.clear) + prevNow := auditNow + auditNow = func() time.Time { return time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) } + t.Cleanup(func() { + auditOwedFiler, auditOwedClearer = prevFile, prevClear + auditNow = prevNow + }) + return f +} + +func shippedAlpha(t *testing.T, root string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, shippedDir, "itd-10-alpha.md")) + if err != nil { + t.Fatalf("the intent must stay in shipped/: %v", err) + } + return string(b) +} + +func TestAFailedAfterMergeAuditLeavesTheIntentShippedFlaggedAndFiled(t *testing.T) { + for _, tc := range []struct { + verdict, remedy string + failed bool + }{ + {"NOT_MET", "fix, then re-run the audit", true}, + {"INCONCLUSIVE", "re-run the audit", false}, + } { + t.Run(tc.verdict, func(t *testing.T) { + f := withFakeAuditLedger(t) + root := t.TempDir() + rcp := shipOne(t, root) + res, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, tc.verdict))) + if err != nil { + t.Fatalf("ingest: %v", err) + } + if len(f.filed) != 1 { + t.Fatalf("the ingest must ask the ledger for exactly one issue, asked %d times", len(f.filed)) + } + o := f.filed[0] + if o.IntentID != "itd-10" || o.ReceiptID != rcp || o.Failed != tc.failed || o.Remedy() != tc.remedy || + len(o.Criteria) != 1 || o.Criteria[0].ID != "ac-1" || o.Criteria[0].Verdict != tc.verdict { + t.Fatalf("owed check handed to the ledger = %+v (remedy %q)", o, o.Remedy()) + } + if res.OwedIssue != "iss-2609301200000001" || len(res.AuditOwed) != 1 || res.AuditOwed[0] != "ac-1 "+tc.verdict { + t.Fatalf("the result must name the owed criteria and the issue carrying them: %+v", res) + } + s := shippedAlpha(t, root) + flag := "Audit owed (receipt " + rcp + "): ac-1 " + tc.verdict + ". Remedy: " + tc.remedy + ". Carried by iss-2609301200000001." + if !strings.Contains(s, flag) { + t.Fatalf("the Audit Notes must carry the flag %q:\n%s", flag, s) + } + if _, err := os.Stat(filepath.Join(root, plannedDir, "itd-10-alpha.md")); err == nil { + t.Fatal("a failed audit must never un-ship the intent") + } + + // The same failing verdict again is a noop that links to the open + // issue: no second filing, the record unchanged. + res2, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, tc.verdict))) + if err != nil { + t.Fatalf("second ingest: %v", err) + } + if res2.Status != "noop" || !res2.OwedIssueLinked || res2.OwedIssue != "iss-2609301200000001" { + t.Fatalf("a second failed audit must link to the open issue, got %+v", res2) + } + if got := shippedAlpha(t, root); got != s { + t.Fatalf("a repeated failed audit must not rewrite the record:\n%s", got) + } + }) + } +} + +func TestAPassingReAuditClearsTheFlagAndResolvesTheIssue(t *testing.T) { + f := withFakeAuditLedger(t) + root := t.TempDir() + rcp := shipOne(t, root) + if _, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, "NOT_MET"))); err != nil { + t.Fatal(err) + } + res, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, "MET"))) + if err != nil { + t.Fatalf("passing re-ingest: %v", err) + } + if len(f.cleared) != 1 || f.cleared[0].IssueID != "iss-2609301200000001" || f.cleared[0].IntentID != "itd-10" || f.cleared[0].ReceiptID != rcp { + t.Fatalf("a passing re-audit must resolve the issue that carried the flag, cleared %+v", f.cleared) + } + if res.FlagCleared != "iss-2609301200000001" || res.OwedIssue != "" || len(res.AuditOwed) != 0 { + t.Fatalf("result = %+v", res) + } + s := shippedAlpha(t, root) + if strings.Contains(s, "Audit owed (receipt") { + t.Fatalf("the flag must be cleared:\n%s", s) + } + cleared := "Audit owed flag cleared 2026-09-30 (receipt " + rcp + "): a re-run of the audit judged no criterion NOT_MET or INCONCLUSIVE, and iss-2609301200000001 is resolved." + if !strings.Contains(s, cleared) { + t.Fatalf("the clearance must leave a dated Audit Notes line %q:\n%s", cleared, s) + } + // Ingesting the passing verdict again changes nothing in the intent; it + // sweeps the receipt's open carriers again, naming no flag's issue. + if _, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, "MET"))); err != nil { + t.Fatal(err) + } + if len(f.cleared) != 2 || f.cleared[1].IssueID != "" || shippedAlpha(t, root) != s { + t.Fatalf("a repeated passing ingest must be a noop on the intent and a flagless sweep, cleared %+v", f.cleared) + } +} + +func TestAPassingFirstAuditFilesNothingAndSweepsItsReceipt(t *testing.T) { + f := withFakeAuditLedger(t) + root := t.TempDir() + rcp := shipOne(t, root) + if _, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, "MET_WITH_CONCERNS"))); err != nil { + t.Fatal(err) + } + if len(f.filed) != 0 || len(f.cleared) != 1 || f.cleared[0].IssueID != "" || f.cleared[0].ReceiptID != rcp || f.cleared[0].IntentID != "itd-10" { + t.Fatalf("a passing audit files nothing and asks one sweep of its receipt's carriers, naming no flag's issue: filed %d, cleared %+v", len(f.filed), f.cleared) + } + if strings.Contains(shippedAlpha(t, root), "Audit owed") { + t.Fatal("a passing audit carries no flag") + } +} + +func TestAFlaggedReceiptReEmitsItsRequestForTheReRun(t *testing.T) { + withFakeAuditLedger(t) + root := t.TempDir() + rcp := shipOne(t, root) + if _, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, "INCONCLUSIVE"))); err != nil { + t.Fatal(err) + } + req := filepath.Join(root, reviewsDir, rcp+".request.md") + if err := os.Remove(req); err != nil { + t.Fatal(err) + } + res, err := ReEmitAudit(root, "itd-10") + if err != nil { + t.Fatal(err) + } + if res.Status != "check_owed" || !res.RequestWritten { + t.Fatalf("a flagged receipt owes a re-run, so its request is rewritten: %+v", res) + } + if _, err := os.Stat(req); err != nil { + t.Fatalf("request not rewritten: %v", err) + } +} + +func TestALedgerThatCannotFileRefusesTheIngestWithNothingWritten(t *testing.T) { + withFakeAuditLedger(t) + SetAuditLedger(func(AuditOwed) (AuditFiling, error) { return AuditFiling{}, os.ErrPermission }, nil) + root := t.TempDir() + rcp := shipOne(t, root) + before := shippedAlpha(t, root) + if _, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, "NOT_MET"))); err == nil { + t.Fatal("a failing verdict whose owed check cannot be filed must be refused") + } + if shippedAlpha(t, root) != before { + t.Fatal("a refused ingest writes nothing: the receipt stays OWED") + } +} + +// The owed-review reader names a flagged receipt apart: its verdict is +// ingested, so its review is not owed, but its check is, and its re-emit +// rewrites the request for the re-run. +func TestTheReviewListingNamesAFlaggedReceiptApart(t *testing.T) { + withFakeAuditLedger(t) + root := t.TempDir() + rcp := shipOne(t, root) + if _, err := IngestVerdict(root, writeVerdict(t, root, verdictWith(t, rcp, "NOT_MET"))); err != nil { + t.Fatal(err) + } + l, err := Reviews(root) + if err != nil { + t.Fatal(err) + } + if l.Owed != 0 || l.Ingested != 1 || l.AuditOwed != 1 || len(l.Entries) != 1 { + t.Fatalf("listing = %+v", l) + } + e := l.Entries[0] + if e.State != ReviewIngested || !e.AuditOwed || e.AuditOwedIssue != "iss-2609301200000001" || e.ReEmit != ReEmitCommand("itd-10") { + t.Fatalf("a flagged receipt must name its owed check, its issue and its re-emit: %+v", e) + } +} diff --git a/internal/core/intent/intent.go b/internal/core/intent/intent.go index 412bf482b..cad682868 100644 --- a/internal/core/intent/intent.go +++ b/internal/core/intent/intent.go @@ -313,6 +313,9 @@ type ReconcileResult struct { // spec lists no steps, when every step it lists has landed, and when the // remainder was reused rather than minted: a reused spec is left as found. RemainderSteps []spec.Step `json:"remainder_steps,omitempty"` + // NeedsRewritten names each carried step's `- needs:` line the remainder + // rewrote against its own numbering, before and after. + NeedsRewritten []spec.NeedsRewrite `json:"needs_rewritten,omitempty"` // ReceiptID is the deterministic fidelity-review receipt parked in the // shipped intent's Audit Notes (empty if the emit failed). ReceiptID string `json:"receipt_id,omitempty"` diff --git a/internal/core/intent/lifecycle.go b/internal/core/intent/lifecycle.go index a0ad759ab..cf55f41f9 100644 --- a/internal/core/intent/lifecycle.go +++ b/internal/core/intent/lifecycle.go @@ -927,16 +927,16 @@ func Reconcile(repoRoot, specID, impact string, remainder RemainderRequest) (Rec // (unrecognized-input-never-writes). A close without a remainder never // reads it: the section is the build's, not the close's. var carried []spec.Step + var rewrites []spec.NeedsRewrite if remainder.Slug != "" { listed, err := spec.ReadSteps(repoRoot, sp) if err != nil { return ReconcileResult{}, fmt.Errorf("intent: %v; --remainder carries the steps not marked landed, and this section cannot be read as steps; nothing was minted. Fix what it names, then re-run the close", err) } - carried = spec.Unlanded(listed) - // Numbered as the remainder lists them, so the result and the file agree. - for i := range carried { - carried[i].Number = i + 1 - } + // Numbered as the remainder lists them, so the result and the file + // agree, with each `- needs:` line rewritten against that numbering + // (spc-2609202134341288, "A needs line across a remainder"). + carried, rewrites = spec.CarryUnlanded(listed) } // Does any OTHER spec still hold this intent open? Asked before the mint, so @@ -1008,6 +1008,7 @@ func Reconcile(repoRoot, specID, impact string, remainder RemainderRequest) (Rec res := ReconcileResult{Spec: sp, Intent: it, From: it.Bucket, To: it.Bucket, Remainder: minted, RemainderMinted: mintedHere, OpenSpecs: specIDs(held)} if mintedHere { res.RemainderSteps = carried + res.NeedsRewritten = rewrites } // 1. Advance the intent planned/ → shipped/ FIRST — but only when this close // leaves no open spec naming it. Its (kind, spec_id) are already set (Plan diff --git a/internal/core/intent/owed.go b/internal/core/intent/owed.go index c2fc46c44..ef6c08a95 100644 --- a/internal/core/intent/owed.go +++ b/internal/core/intent/owed.go @@ -46,6 +46,12 @@ type ReviewEntry struct { // ReEmit is the command that (re-)emits the review request, set only where the // review is owed: on a terminal receipt the re-emit changes nothing. ReEmit string `json:"re_emit,omitempty"` + // AuditOwed is true on an INGESTED receipt whose verdict left a check owed + // (ruling DQ1c): its review is done, its check is not, and its re-emit + // rewrites the request for the re-run. AuditOwedIssue is the issue the + // flag names as carrying it, empty when none does. + AuditOwed bool `json:"audit_owed,omitempty"` + AuditOwedIssue string `json:"audit_owed_issue,omitempty"` } // IsOwed reports whether the entry is in the owed set: OWED plus none. A @@ -61,6 +67,9 @@ type ReviewListing struct { Owed int `json:"owed"` DeadLettered int `json:"dead_lettered"` Ingested int `json:"ingested"` + // AuditOwed counts the ingested receipts whose verdict left a check owed; + // they are counted in Ingested too. + AuditOwed int `json:"audit_owed"` } // ReEmitCommand is the command that re-emits a shipped intent's review request. @@ -95,6 +104,9 @@ func reviewsOf(repoRoot string, corpus Corpus) (ReviewListing, error) { l.DeadLettered++ case e.State == ReviewIngested: l.Ingested++ + if e.AuditOwed { + l.AuditOwed++ + } } } return l, nil @@ -118,6 +130,11 @@ func ReviewOf(repoRoot string, it Intent) (ReviewEntry, error) { e.ReEmit = ReEmitCommand(it.ID) case ReviewDeadLetter: e.Reason = withholdLocalTier(deadLetterReason(content, e.ReceiptID)) + case ReviewIngested: + if hasOwedFlag(content, e.ReceiptID) { + e.AuditOwed, e.ReEmit = true, ReEmitCommand(it.ID) + e.AuditOwedIssue, _ = flaggedIssue(content, e.ReceiptID) + } } return e, nil } diff --git a/internal/core/intent/steps_test.go b/internal/core/intent/steps_test.go index 6f77568ea..f75fe855c 100644 --- a/internal/core/intent/steps_test.go +++ b/internal/core/intent/steps_test.go @@ -164,3 +164,28 @@ func TestReconcileRemainderRefusesAnUnclosedSpan(t *testing.T) { t.Fatalf("the intent must stay in planned/: %v", serr) } } + +// C6 (spc-2609202134341288): the remainder rewrites each carried step's +// `- needs:` line against its own numbering, and the close names each line it +// rewrote, before and after. +func TestReconcileRemainderRewritesNeeds(t *testing.T) { + root := t.TempDir() + writeFile(t, root, plannedDir+"/itd-10-alpha.md", plannedLinked("itd-10", "alpha", "spc-1")) + writeFile(t, root, specsOpen+"/spc-1-alpha.md", specNaming("spc-1", "alpha", "itd-10")+"\n## Summary\n\nWritten.\n\n## Steps\n\n"+ + "1. One\n - landed: #1\n2. Two\n - needs: 1\n3. Three\n - landed: #3\n4. Four\n - needs: 1, 2\n") + res, err := Reconcile(root, "spc-1", "", RemainderRequest{Slug: "the-rest"}) + if err != nil { + t.Fatalf("the remainder close must succeed: %v", err) + } + if len(res.NeedsRewritten) != 2 || res.NeedsRewritten[0].After != "- needs: none" || res.NeedsRewritten[1].Before != "- needs: 1, 2" || res.NeedsRewritten[1].After != "- needs: 1" { + t.Fatalf("the close names both rewritten lines: %+v", res.NeedsRewritten) + } + data, err := os.ReadFile(filepath.Join(root, res.Remainder.Path)) + if err != nil { + t.Fatal(err) + } + steps, err := spec.ParseSteps(string(data)) + if err != nil || len(steps) != 2 || !steps[0].NeedsDeclared || len(steps[0].Needs) != 0 || fmt.Sprint(steps[1].Needs) != "[1]" { + t.Fatalf("the remainder parses with the rewritten needs: %+v, %v\n%s", steps, err, data) + } +} diff --git a/internal/core/site/build.go b/internal/core/site/build.go index 22a67449a..77dba531d 100644 --- a/internal/core/site/build.go +++ b/internal/core/site/build.go @@ -329,8 +329,39 @@ type Result struct { // ErrNoManifest is returned when the repository declares no composition. var ErrNoManifest = errors.New("site: this repo declares no site composition (" + ManifestRelPath + " is absent)") -// Build renders the site into req.OutDir. +// LabelsAddedError is a build that failed after it added missing interface +// labels to the repository's ui.json (the TG1 ruling): the write stands, so the +// error names the file and each label added, and unwraps to the failure. +type LabelsAddedError struct { + File string + Labels []string + Err error +} + +func (e *LabelsAddedError) Error() string { return e.Err.Error() } +func (e *LabelsAddedError) Unwrap() error { return e.Err } + +// Build renders the site into req.OutDir. A failure after it completed the +// repository's ui.json is a *LabelsAddedError, so the change it took is never +// silent (ADR 2609301720596683). func Build(req Request) (Result, error) { + var added labelsAdded + res, err := build(req, &added) + if err != nil && len(added.labels) > 0 { + return Result{}, &LabelsAddedError{File: added.file, Labels: added.labels, Err: err} + } + return res, err +} + +// labelsAdded is what build wrote to the repository's ui.json, recorded as +// soon as it is written so a later failure can name it. +type labelsAdded struct { + file string + labels []string +} + +// build is Build's body. +func build(req Request, written *labelsAdded) (Result, error) { repoRoot := req.RepoRoot outDir, err := resolveOutDir(repoRoot, req.OutDir) if err != nil { @@ -378,6 +409,7 @@ func Build(req Request) (Result, error) { if addedLabels, err = addMissingLabels(repoRoot, manifest.UIStrings); err != nil { return Result{}, err } + written.file, written.labels = manifest.UIStrings, addedLabels } ui, err := LoadUI(repoRoot, manifest.UIStrings) if err != nil { diff --git a/internal/core/site/status_test.go b/internal/core/site/status_test.go index b1a5a8fa2..73957acae 100644 --- a/internal/core/site/status_test.go +++ b/internal/core/site/status_test.go @@ -21,7 +21,7 @@ func TestStatusPageRendersTheBlockFromTheSameRead(t *testing.T) { f.commitAt("2026-03-07T09:00:00+00:00", "feat: a second draft", "None") out := t.TempDir() lanes := func(string) ([]statusblock.Started, error) { - return []statusblock.Started{{Intent: "itd-1", Lane: statusblock.Lane{Run: "run-2609290000000001", Lane: "lane-1", Stage: "implement"}}}, nil + return []statusblock.Started{{Intent: "itd-1", Lanes: []statusblock.Lane{{Run: "run-2609290000000001", Lane: "lane-1", Stage: "implement"}}}}, nil } if _, err := Build(Request{RepoRoot: f.Root(), OutDir: out, Stamp: fixtureStamp, Lanes: lanes}); err != nil { t.Fatalf("build: %v", err) diff --git a/internal/core/site/uiadd_test.go b/internal/core/site/uiadd_test.go index 474b4ea32..c14813b64 100644 --- a/internal/core/site/uiadd_test.go +++ b/internal/core/site/uiadd_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "reflect" + "regexp" "strings" "testing" @@ -252,6 +253,30 @@ func TestBuildAddsAMissingLabelAndSaysSo(t *testing.T) { } } +// A build that fails after it completed the file carries the labels it added +// in its error, which unwraps to the cause. +func TestAFailedBuildNamesTheLabelsItAdded(t *testing.T) { + f := newFixture(t) + body, err := os.ReadFile(filepath.Join(f.Root(), "site-src", "ui.json")) + if err != nil { + t.Fatal(err) + } + cutBody := cut(t, string(body), `"target": "target", `) + blanked := regexp.MustCompile(`"now": "[^"]*"`).ReplaceAllString(cutBody, `"now": ""`) + if blanked == cutBody { + t.Fatal("fixture: ui.json carries no now label") + } + f.write("site-src/ui.json", blanked) + _, err = Build(Request{RepoRoot: f.Root(), OutDir: t.TempDir(), Stamp: fixtureStamp}) + var added *LabelsAddedError + if !errors.As(err, &added) || !reflect.DeepEqual(added.Labels, []string{"status.target"}) || added.File != "site-src/ui.json" { + t.Fatalf("the failed build names the label it added: %v", err) + } + if errors.Unwrap(err) == nil || err.Error() != errors.Unwrap(err).Error() { + t.Fatalf("the error unwraps to its cause and reads as it: %v", err) + } +} + func contains(xs []string, s string) bool { for _, x := range xs { if x == s { diff --git a/internal/core/spec/needs_test.go b/internal/core/spec/needs_test.go new file mode 100644 index 000000000..9a78f7a2a --- /dev/null +++ b/internal/core/spec/needs_test.go @@ -0,0 +1,100 @@ +package spec + +import ( + "slices" + "strings" + "testing" +) + +// A step's `- needs:` line (ruling DR6, spc-2609202134341288): `none`, or the +// earlier steps it waits for. A step without the line needs every step before +// it (ruling DR6b), which the parse reports as an undeclared need. +func TestParseStepsReadsNeeds(t *testing.T) { + steps, err := ParseSteps("## Steps\n\n1. One\n2. Two\n - needs: none\n3. Three\n - needs: 1\n4. Four\n - needs: 1, 3\n") + if err != nil { + t.Fatalf("ParseSteps: %v", err) + } + if steps[0].NeedsDeclared || steps[0].Needs != nil { + t.Errorf("step 1 declares nothing: %+v", steps[0]) + } + if !steps[1].NeedsDeclared || len(steps[1].Needs) != 0 { + t.Errorf("step 2 needs none: %+v", steps[1]) + } + if !steps[2].NeedsDeclared || !slices.Equal(steps[2].Needs, []int{1}) { + t.Errorf("step 3 needs 1: %+v", steps[2]) + } + if !slices.Equal(steps[3].Needs, []int{1, 3}) { + t.Errorf("step 4 needs 1, 3: %+v", steps[3]) + } + // The default is every step before it. + if got := steps[0].Requires(); len(got) != 0 { + t.Errorf("step 1 requires nothing, got %v", got) + } + if got := (Step{Number: 3}).Requires(); !slices.Equal(got, []int{1, 2}) { + t.Errorf("an undeclared step 3 requires 1 and 2, got %v", got) + } + if got := steps[1].Requires(); len(got) != 0 { + t.Errorf("needs: none requires nothing, got %v", got) + } +} + +// C6: the parser refuses a need naming the step itself, a later step, or a step +// the spec does not list, naming the line. +func TestParseStepsRefusesABadNeeds(t *testing.T) { + for name, section := range map[string]string{ + "itself": "## Steps\n\n1. One\n2. Two\n - needs: 2\n", + "later": "## Steps\n\n1. One\n2. Two\n - needs: 3\n3. Three\n", + "unlisted": "## Steps\n\n1. One\n2. Two\n - needs: 0\n", + "not a num": "## Steps\n\n1. One\n2. Two\n - needs: first\n", + "twice": "## Steps\n\n1. One\n2. Two\n - needs: 1\n - needs: none\n", + "none and": "## Steps\n\n1. One\n2. Two\n - needs: none, 1\n", + } { + _, err := ParseSteps(section) + if err == nil { + t.Errorf("%s: a bad needs line must be refused", name) + continue + } + want := "line 5" + if name == "twice" { + want = "line 6" + } + if !strings.Contains(err.Error(), want) { + t.Errorf("%s: the refusal names the line: %v", name, err) + } + } +} + +// C6's remainder: steps 1 and 3 landed, step 2 needs 1, step 4 needs 1 and 2. +// The remainder lists old step 2 as step 1 with `- needs: none` and old step 4 +// as step 2 with `- needs: 1`, names both rewritten lines, and parses. +func TestCarryUnlandedRewritesNeeds(t *testing.T) { + const src = "## Steps\n\n1. One\n - landed: #1\n2. Two\n - needs: 1\n - tests: two\n3. Three\n - landed: #3\n4. Four\n - needs: 1, 2\n5. Five\n" + steps, err := ParseSteps(src) + if err != nil { + t.Fatal(err) + } + carried, rewrites := CarryUnlanded(steps) + if len(carried) != 3 || carried[0].Title != "Two" || carried[1].Title != "Four" || carried[2].Title != "Five" { + t.Fatalf("carried = %+v", carried) + } + if len(rewrites) != 2 { + t.Fatalf("two needs lines are rewritten, got %+v", rewrites) + } + if rewrites[0].Step != 1 || rewrites[0].Before != "- needs: 1" || rewrites[0].After != "- needs: none" { + t.Errorf("old step 2's line: %+v", rewrites[0]) + } + if rewrites[1].Step != 2 || rewrites[1].Before != "- needs: 1, 2" || rewrites[1].After != "- needs: 1" { + t.Errorf("old step 4's line: %+v", rewrites[1]) + } + out := renderSteps(carried) + back, err := ParseSteps("## Steps\n\n" + out) + if err != nil { + t.Fatalf("the remainder must parse: %v\n%s", err, out) + } + if !back[0].NeedsDeclared || len(back[0].Needs) != 0 || !slices.Equal(back[1].Needs, []int{1}) || back[2].NeedsDeclared { + t.Fatalf("the remainder's needs: %+v\n%s", back, out) + } + if !strings.Contains(out, " - tests: two\n") { + t.Errorf("every other line is carried verbatim:\n%s", out) + } +} diff --git a/internal/core/spec/steps.go b/internal/core/spec/steps.go index 960ec9b75..d579589ea 100644 --- a/internal/core/spec/steps.go +++ b/internal/core/spec/steps.go @@ -5,6 +5,8 @@ import ( "fmt" "path/filepath" "regexp" + "slices" + "strconv" "strings" "github.com/intentdriven/abcd/internal/core/frontmatter" @@ -30,8 +32,12 @@ const StepsHeading = "## Steps" // - landed: #123 // // `landed:` names what landed the step (a pull request or a commit); a step -// without one, or with a null value, is not landed. Any other indented line is -// the author's and is carried verbatim wherever the step is copied. +// without one, or with a null value, is not landed. `needs:` names the earlier +// steps a step waits for, or `none` (ruling DR6, spc-2609202134341288); a step +// without the line needs every step before it (ruling DR6b), so running beside +// earlier steps is an opt-in the step declares. Any other indented line is the +// author's and is carried verbatim wherever the step is copied, except the +// `needs:` line, which a remainder rewrites (CarryUnlanded). type Step struct { // Number is the step's 1-based position in document order. The list's own // numerals are not trusted for order: a renderer renumbers them anyway. @@ -40,6 +46,11 @@ type Step struct { Packages string `json:"packages,omitempty"` Tests string `json:"tests,omitempty"` Landed string `json:"landed,omitempty"` + // Needs are the earlier steps the step's `- needs:` line names, and + // NeedsDeclared whether it has the line at all: a step without it needs + // every step before it (Requires). + Needs []int `json:"needs,omitempty"` + NeedsDeclared bool `json:"needs_declared,omitempty"` // Implicit marks the one step of a spec that lists none: the whole spec, // built as one step (the intent's decision 2). Implicit bool `json:"implicit,omitempty"` @@ -62,7 +73,7 @@ var ( // stepItemRe is a top-level numbered list item: `1. Title` or `1) Title`. stepItemRe = regexp.MustCompile(`^([0-9]{1,4})[.)][ \t]+(\S.*)$`) // stepKeyRe is a keyed bullet indented under a step. - stepKeyRe = regexp.MustCompile(`^[ \t]+[-*+][ \t]+(?i:(packages|tests|landed))[ \t]*:[ \t]*(.*)$`) + stepKeyRe = regexp.MustCompile(`^[ \t]+[-*+][ \t]+(?i:(packages|tests|landed|needs))[ \t]*:[ \t]*(.*)$`) // guidanceRe is a whole-line italic paragraph, the shape of the minted // placeholder; before the first step it is guidance and is skipped. guidanceRe = regexp.MustCompile(`^_.*_$`) @@ -145,6 +156,9 @@ func ParseSteps(content string) ([]Step, error) { // setKey records one keyed bullet on a step, refusing a key given twice. func (s *Step) setKey(key, value string, line int) error { + if key == "needs" { + return s.setNeeds(value, line) + } var dst *string switch key { case "packages": @@ -164,6 +178,112 @@ func (s *Step) setKey(key, value string, line int) error { return nil } +// setNeeds records a step's `- needs:` line: `none`, or a comma-separated list +// of earlier steps by number. A need naming the step itself, a later step, or a +// step the spec does not list is refused naming the line. +func (s *Step) setNeeds(value string, line int) error { + if s.NeedsDeclared { + return fmt.Errorf("spec: %s step %d gives `needs:` twice (line %d)", StepsHeading, s.Number, line) + } + bad := func(why string) error { + return fmt.Errorf("spec: %s step %d's `needs:` line (line %d) %s — write `- needs: none`, or the earlier steps it waits for by number (`- needs: 1, 3`)", StepsHeading, s.Number, line, why) + } + s.NeedsDeclared = true + s.Needs = []int{} + if strings.EqualFold(strings.TrimSpace(value), "none") { + return nil + } + for _, f := range strings.Split(value, ",") { + f = strings.TrimSpace(f) + n, err := strconv.Atoi(f) + switch { + case err != nil || f == "" || f[0] == '+' || f[0] == '-': + return bad(fmt.Sprintf("names %q, which is not a step number", f)) + case n == s.Number: + return bad("names the step itself") + case n > s.Number: + return bad(fmt.Sprintf("names step %d, which comes after it", n)) + case n < 1: + return bad(fmt.Sprintf("names step %d, which the spec does not list", n)) + case slices.Contains(s.Needs, n): + return bad(fmt.Sprintf("names step %d twice", n)) + } + s.Needs = append(s.Needs, n) + } + return nil +} + +// Requires is the earlier steps a step waits for: the ones its `- needs:` line +// names, or, without the line, every step before it (ruling DR6b). +func (s Step) Requires() []int { + if s.NeedsDeclared { + return append([]int{}, s.Needs...) + } + out := []int{} + for n := 1; n < s.Number; n++ { + out = append(out, n) + } + return out +} + +// NeedsRewrite is one `- needs:` line a remainder copy rewrote: the step as the +// remainder numbers it, and the line before and after. +type NeedsRewrite struct { + Step int `json:"step"` + Before string `json:"before"` + After string `json:"after"` +} + +// needsLineRe is a step's `- needs:` line, its indentation and marker kept. +var needsLineRe = regexp.MustCompile(`^([ \t]+[-*+][ \t]+)(?i:needs)[ \t]*:`) + +// CarryUnlanded is the remainder copy's view of a spec's steps: the steps not +// marked landed, in document order, renumbered from one, every indented line +// carried verbatim but the `- needs:` line, which is rewritten against the +// remainder. A named step that landed is satisfied and leaves the list; one +// that did not is carried and renamed to its number in the remainder; a list +// left empty is written `- needs: none`, never removed, since an absent line +// means every earlier step (ruling DR6b). A step without the line stays +// without it. The rewrites are returned, before and after, in remainder order. +func CarryUnlanded(steps []Step) ([]Step, []NeedsRewrite) { + carried := Unlanded(steps) + renum := map[int]int{} + for i, s := range carried { + renum[s.Number] = i + 1 + } + var rewrites []NeedsRewrite + out := make([]Step, 0, len(carried)) + for i, s := range carried { + s.Number = i + 1 + if s.NeedsDeclared { + kept := []int{} + for _, n := range s.Needs { + if m, ok := renum[n]; ok { + kept = append(kept, m) + } + } + s.Needs = kept + after := needsValue(kept) + body := make([]string, len(s.body)) + for j, l := range s.body { + m := needsLineRe.FindStringSubmatch(l) + if m == nil { + body[j] = l + continue + } + nl := m[1] + "needs: " + after + body[j] = nl + if strings.TrimSpace(nl) != strings.TrimSpace(l) { + rewrites = append(rewrites, NeedsRewrite{Step: s.Number, Before: strings.TrimSpace(l), After: strings.TrimSpace(nl)}) + } + } + s.body = body + } + out = append(out, s) + } + return out, rewrites +} + // Steps is the build's view of a spec: the steps it lists, or — when it lists // none — one implicit step, the whole spec. It refuses what ParseSteps refuses. func Steps(content string) ([]Step, error) { @@ -230,10 +350,25 @@ func renderSteps(steps []Step) string { fmt.Fprintf(&b, " - %s: %s\n", kv[0], kv[1]) } } + if s.NeedsDeclared { + fmt.Fprintf(&b, " - needs: %s\n", needsValue(s.Needs)) + } } return b.String() } +// needsValue is a needs list as its line writes it. +func needsValue(needs []int) string { + if len(needs) == 0 { + return "none" + } + parts := make([]string, len(needs)) + for i, n := range needs { + parts[i] = strconv.Itoa(n) + } + return strings.Join(parts, ", ") +} + // stepsSection finds the `## Steps` section: the body lines [start, end), or // start < 0 when the spec has none. The section runs to the next live `#` or // `##` heading; a deeper heading stays inside it, where ParseSteps refuses it. diff --git a/internal/core/statusblock/statusblock.go b/internal/core/statusblock/statusblock.go index 051dd705a..839317964 100644 --- a/internal/core/statusblock/statusblock.go +++ b/internal/core/statusblock/statusblock.go @@ -95,14 +95,17 @@ type Lane struct { // Stage is the lane's next stage (worktree, brief, implement, validate, // land), or "pending" while the run waits to open its next lane. Stage string `json:"stage"` - // Awaiting is the agent role the lane waits on, when it waits on one. + // Awaiting is the agent roles the lane waits on, when it waits on any: + // one while its implementer works, one per validator out (ruling DR6). Awaiting string `json:"awaiting,omitempty"` } -// Started is one intent the state file shows in a lane. +// Started is one intent the state file shows in lanes: every lane of its run +// alive, each reported as its own Now row (ruling DR6, a run works several +// lanes at once). type Started struct { Intent string - Lane Lane + Lanes []Lane } // LaneReader reads the build's state file for the checkout at repoRoot. An @@ -264,9 +267,12 @@ func Read(repoRoot string, lanes LaneReader, peers PeerReader) (Block, error) { return Block{}, err } } - lane := s.Lane - r.Lane = &lane - b.Now = append(b.Now, r) + for _, lane := range s.Lanes { + lr := r + l := lane + lr.Lane = &l + b.Now = append(b.Now, lr) + } } if head != nil { b.Now = append(b.Now, *head) diff --git a/internal/core/statusblock/statusblock_test.go b/internal/core/statusblock/statusblock_test.go index 4130172c0..b14d5ab0a 100644 --- a/internal/core/statusblock/statusblock_test.go +++ b/internal/core/statusblock/statusblock_test.go @@ -88,7 +88,7 @@ func lanesOf(started ...Started) LaneReader { func TestBlockPlacesEveryIntent(t *testing.T) { root := store(t) lane := Lane{Run: "run-2609290000000001", Lane: "lane-1", Stage: "implement", Awaiting: "implementer"} - b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: lane}), nil) + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lanes: []Lane{lane}}), nil) if err != nil { t.Fatal(err) } @@ -134,7 +134,7 @@ func TestBlockPlacesEveryIntent(t *testing.T) { // Next and Later are otherwise exactly what they were. func TestBlockWithoutAStateFileKeepsOnlyTheHead(t *testing.T) { root := store(t) - with, err := Read(root, lanesOf(Started{Intent: "itd-7", Lane: Lane{Run: "run-1", Lane: "lane-1", Stage: "brief"}}), nil) + with, err := Read(root, lanesOf(Started{Intent: "itd-7", Lanes: []Lane{{Run: "run-1", Lane: "lane-1", Stage: "brief"}}}), nil) if err != nil { t.Fatal(err) } @@ -172,7 +172,7 @@ func TestAnIntentInALaneIsOnlyUnderNow(t *testing.T) { inLane := []string{"itd-7", "itd-8", "itd-3"} var started []Started for _, id := range inLane { - started = append(started, Started{Intent: id, Lane: lane}) + started = append(started, Started{Intent: id, Lanes: []Lane{lane}}) } b, err := Read(root, lanesOf(started...), nil) if err != nil { @@ -193,7 +193,7 @@ func TestAnIntentInALaneIsOnlyUnderNow(t *testing.T) { // name, each row with its id and title, the lane state and the failing checks. func TestBlockJSONCarriesTheThreeLists(t *testing.T) { root := store(t) - b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: Lane{Run: "run-1", Lane: "lane-2", Stage: "validate", Awaiting: "validator"}}), nil) + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lanes: []Lane{{Run: "run-1", Lane: "lane-2", Stage: "validate", Awaiting: "validator"}}}), nil) if err != nil { t.Fatal(err) } @@ -345,7 +345,7 @@ func TestTheHeadIsThePicksChoice(t *testing.T) { // itd-4 in a lane: the pick would not start it again, so the head is the // runner-up. - b, err = Read(root, lanesOf(Started{Intent: "itd-4", Lane: Lane{Run: "run-1", Lane: "lane-1", Stage: "implement"}}), nil) + b, err = Read(root, lanesOf(Started{Intent: "itd-4", Lanes: []Lane{{Run: "run-1", Lane: "lane-1", Stage: "implement"}}}), nil) if err != nil { t.Fatal(err) } @@ -577,7 +577,7 @@ func TestARowShowsItsTarget(t *testing.T) { w(in+"planned/itd-8-unlinked.md", readyIntent("itd-8", "The unlinked one", "null", "target_release: next\n")) w(in+"drafts/itd-3-old.md", strings.Replace(draft("itd-3", "An old idea"), "kind: standalone\n", "kind: standalone\ntarget_release: next\n", 1)) - b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lane: Lane{Run: "run-1", Stage: "implement"}}), nil) + b, err := Read(root, lanesOf(Started{Intent: "itd-2609010000000001", Lanes: []Lane{{Run: "run-1", Stage: "implement"}}}), nil) if err != nil { t.Fatal(err) } diff --git a/internal/surface/cli/build.go b/internal/surface/cli/build.go index 6294c89da..ccafa343c 100644 --- a/internal/surface/cli/build.go +++ b/internal/surface/cli/build.go @@ -349,9 +349,24 @@ func renderLaneLine(w io.Writer, l loop.Lane) { fmt.Fprintf(w, " validation round %d at %s: %s\n", r.Round, shortSHA(r.HeadSHA), termsafe.Sanitize(strings.Join(parts, ", "))) } renderLanding(w, l.PR, l.Landing) - if l.Awaiting != nil { - fmt.Fprintf(w, " awaiting the %s's receipt at %s (brief %s)\n", termsafe.Sanitize(l.Awaiting.Role), - termsafe.Sanitize(fsutil.RedactHome(l.Awaiting.Receipt)), termsafe.Sanitize(fsutil.RedactHome(l.Awaiting.Brief))) + for _, a := range l.Awaits { + fmt.Fprintf(w, " awaiting the %s's receipt at %s (brief %s)\n", termsafe.Sanitize(a.Role), + termsafe.Sanitize(fsutil.RedactHome(a.Receipt)), termsafe.Sanitize(fsutil.RedactHome(a.Brief))) + } + for _, s := range l.Syncs { + state := "clean" + if s.Conflicted { + state = "conflicted in " + strings.Join(s.Paths, ", ") + } + fmt.Fprintf(w, " synced with %s after %s landed: %s", shortSHA(s.Merged), termsafe.Sanitize(strings.Join(s.Siblings, ", ")), termsafe.Sanitize(state)) + if s.Head != "" { + fmt.Fprintf(w, ", head %s", shortSHA(s.Head)) + } + fmt.Fprintln(w) + } + if h := l.Hold; h != nil && l.Stage == loop.StageHeld { + fmt.Fprintf(w, " held after %s's hand-back at %s, before its %s: land it as it is with `abcd implement step --release %s`, or discard it with `abcd implement step --discard %s`\n", + termsafe.Sanitize(h.Cause), shortSHA(h.Head), termsafe.Sanitize(h.Before), l.ID, l.ID) } } @@ -367,6 +382,34 @@ func renderPending(w io.Writer, pending []loop.PendingStep) { fmt.Fprintf(w, " pending: spec step %s\n", strings.Join(parts, ", ")) } +// redactAwaits home-redacts the paths every await carries. +func redactAwaits(as []loop.Await) []loop.Await { + out := make([]loop.Await, 0, len(as)) + for i := range as { + out = append(out, *redactAwait(&as[i])) + } + return out +} + +// redactStep home-redacts the paths a step result carries. +func redactStep(res loop.StepResult) loop.StepResult { + res.Awaiting = redactAwait(res.Awaiting) + if res.Fallback != nil { + fb := *res.Fallback + fb.Detail = fsutil.RedactHome(fb.Detail) + res.Fallback = &fb + } + alive := make([]loop.AliveLane, 0, len(res.Alive)) + for _, a := range res.Alive { + a.Awaits = redactAwaits(a.Awaits) + alive = append(alive, a) + } + if res.Alive != nil { + res.Alive = alive + } + return res +} + // redactAwait home-redacts the paths an await carries, for a stream. They keep // RedactHome rather than fsutil.DisplayPath: the brief is the file the agent is // handed and the receipt the path it writes and passes to `implement receipt`, @@ -414,7 +457,9 @@ func newImplementStatusCommand(asJSON *bool) *cobra.Command { } for i := range runs { for j := range runs[i].Lanes { - runs[i].Lanes[j].Awaiting = redactAwait(runs[i].Lanes[j].Awaiting) + if runs[i].Lanes[j].Awaits != nil { + runs[i].Lanes[j].Awaits = redactAwaits(runs[i].Lanes[j].Awaits) + } runs[i].Lanes[j].Worktree = fsutil.DisplayPath(runs[i].Lanes[j].Worktree) } } @@ -436,6 +481,7 @@ func newImplementStatusCommand(asJSON *bool) *cobra.Command { fmt.Fprintf(w, "run %s %s (%s) %s, driven by the %s\n", st.RunID, st.Key, st.Spec, state, st.Driver) fmt.Fprintf(w, " state: %s\n", loop.StateRelPath(st.RunID)) renderPace(w, st.Pace) + renderSlots(w, st) if st.NextEligibleAt != nil { fmt.Fprintf(w, " paused until %s\n", st.NextEligibleAt.UTC().Format("2006-01-02T15:04:05Z07:00")) } @@ -457,6 +503,18 @@ func newImplementStatusCommand(asJSON *bool) *cobra.Command { return cmd } +// renderSlots renders the slots a run has in use out of its ceiling, and the +// work the ceiling holds back (ruling DR6). A held lane holds no slot. +func renderSlots(w io.Writer, st loop.State) { + if st.Complete() { + return + } + fmt.Fprintf(w, " slots: %d of %d in use\n", st.SlotsInUse(), st.Ceiling()) + for _, q := range st.Waiting { + fmt.Fprintf(w, " waiting: %s's %s, held by the ceiling since %s\n", termsafe.Sanitize(q.Lane), termsafe.Sanitize(q.Role), q.Since.UTC().Format(time.RFC3339)) + } +} + // renderStepResult is the text form of an `implement step` or `implement receipt` result. func renderStepResult(w io.Writer, verb string, res loop.StepResult) { switch { @@ -465,6 +523,13 @@ func renderStepResult(w io.Writer, verb string, res loop.StepResult) { verb, res.RunID, res.Lane, res.HandBack.Verdict, res.HandBack.FixRounds) case res.NextEligibleAt != nil: fmt.Fprintf(w, "%s: %s's window has elapsed; paused until %s\n", verb, res.RunID, res.NextEligibleAt.UTC().Format(time.RFC3339)) + case res.CeilingReached: + fmt.Fprintf(w, "%s: %s's ceiling is reached, %d of %d agents out; nothing new was handed out\n", verb, res.RunID, res.Slots, res.Ceiling) + for _, a := range res.Alive { + for _, aw := range a.Awaits { + fmt.Fprintf(w, " %s awaits the %s at %s\n", a.Lane, termsafe.Sanitize(aw.Role), termsafe.Sanitize(aw.Receipt)) + } + } case res.PerformedStage != "": fmt.Fprintf(w, "%s: %s completed %s's %s stage\n", verb, res.RunID, res.Lane, res.PerformedStage) case res.Awaiting != nil: @@ -480,30 +545,47 @@ func renderStepResult(w io.Writer, verb string, res loop.StepResult) { fmt.Fprintf(w, " fallback: %s was routed to %s, which was %s (%s); %s runs it\n", termsafe.Sanitize(fb.Role), termsafe.Sanitize(fb.Asked), fb.Reason, termsafe.Sanitize(fsutil.RedactHome(fb.Detail)), termsafe.Sanitize(fb.Ran)) } + for _, r := range res.Blocked { + fmt.Fprintf(w, "blocked: %s (%s): %s\n", r.Lane, termsafe.Sanitize(r.Stage), termsafe.Sanitize(fsutil.RedactHome(r.Reason))) + } fmt.Fprintf(w, "next: %s\n", termsafe.Sanitize(fsutil.RedactHome(res.Next))) } func newImplementStepCommand(asJSON *bool) *cobra.Command { - var runID string + var runID, release, discard string cmd := &cobra.Command{ - Use: "step [--run <run-id>]", - Long: "Perform the next stage of the run's current lane, write the state, and exit. At a stage\n" + - "that hands work to an agent, the result names the agent to start, the brief it is handed\n" + - "and the path its receipt goes to; the lane then advances only on\n" + - "`abcd implement receipt`, and running `implement step` again re-tells the same thing and\n" + - "moves nothing. A lane lands one step of the spec; its stages are how it gets there, and\n" + - "when a lane is done the spec's next pending step opens the next lane, and the run\n" + - "record names it. A complete run says so.\n\n" + + Use: "step [--run <run-id>] [--release <lane-id> | --discard <lane-id>]", + Long: "Perform the run's next move, write the state, and exit. At a stage that hands work to\n" + + "an agent, the result names the agent to start, the brief it is handed and the path its\n" + + "receipt goes to; that work advances only on `abcd implement receipt`. A lane lands one\n" + + "step of the spec; its stages are how it gets there. A complete run says so.\n\n" + + "A run works in parallel up to its ceiling (--sub-agents, pace.sub_agents): each agent\n" + + "handed work and not yet verified is a slot, implementers and validators alike. Each call\n" + + "first performs a stage the binary owns on any lane (the worktree, the brief, a round's\n" + + "close, the landing's steps), which takes no slot and is never held by the ceiling; then,\n" + + "while a slot is free, it hands out the first waiting work: a lane already open before a\n" + + "new one, the lower spec step first, a round's validators in order, then a new lane's\n" + + "implementer. A call that finds the ceiling reached hands out nothing, exits 0 naming\n" + + "every lane alive with the role and receipt it awaits, and records the held work with the\n" + + "time it was first held. A lane opens for a spec step once every step it needs has\n" + + "landed (its `- needs:` line, or by default every earlier step), whatever the ceiling: its\n" + + "worktree and brief are made, and its implementer waits for a slot. A landing waiting on\n" + + "the forge's merge holds only its own lane: the call moves another and names the wait\n" + + "under blocked:; any other refused stage is the call's answer. Landing is one lane at a\n" + + "time; a lane whose sibling landed\n" + + "since its base is synced first (the default branch merged in with a merge commit, never a\n" + + "rebase) and judged by a fresh round, and a conflicting sync goes to a fresh implementer;\n" + + "a sync counts no fix round.\n\n" + "The lane's stages, in order: worktree makes the lane's worktree in the machine-scoped\n" + "store, ~/.abcd/worktrees/<root-sha>/<run-id>-<lane-id>, on a branch build/<run-id>-<lane-id>\n" + "cut from the default branch; brief renders the lane's brief from that base (the intent,\n" + "the spec, the conventions of AGENTS.md, the decisions the intent cites, and the spec\n" + "steps before the lane's with what landed each) into the lane's directory of the run;\n" + "implement hands the lane to a fresh implementer and awaits\n" + - "its receipt; validate hands the lane's head to validators that did not implement it, one\n" + - "fresh agent at a time — a ruthless-reviewer, a security-reviewer and, on the lane whose\n" + - "landing closes the spec and ships the intent, an intent-auditor over the whole delivery,\n" + - "from the base of the run's first lane to that lane's head (a lane that does not close the\n" + + "its receipt; validate hands the lane's head to validators that did not implement it, each\n" + + "a fresh agent, side by side up to the ceiling — a ruthless-reviewer, a security-reviewer\n" + + "and, on the lane whose landing closes the spec and ships the intent, an intent-auditor over\n" + + "the whole delivery, each of the run's lanes' own diff (a lane that does not close the\n" + "spec takes no audit) — and records each verdict itself, parsed from the validator's own\n" + "return. A round one of them did not pass goes to a fresh implementer, who applies each\n" + "finding or rejects it in writing in its report, and the next round judges the new head\n" + @@ -512,8 +594,16 @@ func newImplementStepCommand(asJSON *bool) *cobra.Command { "criterion it could not decide (INCONCLUSIVE) fails the round as a not-met one does, and\n" + "goes to the fresh implementer with the finding. A round that does not pass once the lane\n" + "has taken the run's fix rounds (--fix-rounds, bundled 3) hands the lane back instead: it\n" + - "stops as unachievable, the result and the run record name the last round's findings, the\n" + - "run starts nothing further for it, and every later step is refused naming the hand-back.\n" + + "stops as unachievable, the result and the run record name the last round's findings, and\n" + + "the run starts nothing further for it. Its sibling lanes finish: no new lane opens, no\n" + + "lane closes the spec, and a sibling whose round passes is held before its push, or before\n" + + "arming once its pull request is open (an armed one is disarmed, and where the forge\n" + + "refuses the withdrawal the step is refused naming the pull request; one the forge reports\n" + + "merged is recorded as landed); once nothing is left to move, a step is refused naming\n" + + "the hand-back and each held lane. --release <lane-id> lands a held lane as it is;\n" + + "--discard <lane-id> removes its worktree and branch, then closes its pull request, and\n" + + "leaves its step unlanded. Either is refused, changing nothing,\n" + + "for a lane that is not held or while any lane still has work.\n" + "land follows a passing round, one step per call: it checks the lane's worktree is clean\n" + "at the judged head; on the lane that closes the spec it runs `spec close` in the lane's\n" + "worktree and ingests the audit that lane took, and for every capture the lane's receipts\n" + @@ -564,34 +654,42 @@ func newImplementStepCommand(asJSON *bool) *cobra.Command { if err != nil { return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) } - roots, notes := layered.RootsFor(root) - for _, n := range notes { - fmt.Fprintln(cmd.ErrOrStderr(), termsafe.Sanitize(n)) - } - cfg, err := loadRunners(cmd, roots) - if err != nil { - return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) + var res loop.StepResult + switch { + case release != "" && discard != "": + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, &loop.Refusal{Stage: string(loop.StageHeld), + Reason: "--release and --discard name one decision each; give one", Remedy: "run `abcd implement step --release <lane-id>` or `--discard <lane-id>`, one lane per invocation"}) + case release != "": + res, err = loop.Release(root, id, release, loop.Options{}) + case discard != "": + res, err = loop.Discard(root, id, discard, loop.Options{}) + default: + roots, notes := layered.RootsFor(root) + for _, n := range notes { + fmt.Fprintln(cmd.ErrOrStderr(), termsafe.Sanitize(n)) + } + cfg, lerr := loadRunners(cmd, roots) + if lerr != nil { + return loopFail(cmd.OutOrStdout(), *asJSON, prefix, lerr) + } + // An interrupt or a termination ends the context, and the runner + // kills the process group it started through its own handle: a + // harness never outlives the step that started it. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + res, err = loop.Drive(ctx, root, id, loop.DefaultStages(), loop.Options{}, + loop.Runners{Config: cfg, Transcripts: &lazyHistoryStore{cmd: cmd}}) } - // An interrupt or a termination ends the context, and the runner - // kills the process group it started through its own handle: a - // harness never outlives the step that started it. - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - res, err := loop.Drive(ctx, root, id, loop.DefaultStages(), loop.Options{}, - loop.Runners{Config: cfg, Transcripts: &lazyHistoryStore{cmd: cmd}}) if err != nil { return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) } - res.Awaiting = redactAwait(res.Awaiting) - if res.Fallback != nil { - fb := *res.Fallback - fb.Detail = fsutil.RedactHome(fb.Detail) - res.Fallback = &fb - } + res = redactStep(res) return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { renderStepResult(w, "step", res) }) }, } cmd.Flags().StringVar(&runID, "run", "", "the run to step (run-<16 digits>); the one run in progress when omitted") + cmd.Flags().StringVar(&release, "release", "", "land a held lane as it is (lane-<n>), once no lane has work left") + cmd.Flags().StringVar(&discard, "discard", "", "discard a held lane (lane-<n>): close its pull request, remove its worktree and branch") return cmd } @@ -599,8 +697,10 @@ func newImplementReceiptCommand(asJSON *bool) *cobra.Command { var runID string cmd := &cobra.Command{ Use: "receipt <path> [--run <run-id>]", - Long: "Hand back the receipt the run's awaiting lane named when its stage handed work to an\n" + - "agent. The path must be the one the stage named. The stage's verifier checks it; a\n" + + Long: "Hand back the receipt a lane of the run named when its stage handed work to an agent.\n" + + "The path is looked up among every outstanding await of the run, and the lane it belongs\n" + + "to advances; a path no await names is refused, naming the awaits there are, and frees\n" + + "nothing. The stage's verifier checks it; a verified receipt frees its slot, and a\n" + "receipt that verifies completes the stage and the lane moves to its next stage, and one\n" + "that does not is refused naming what is missing, with the lane left where it was. A\n" + "stage whose verifier this abcd does not carry is refused naming the spec piece that\n" + @@ -639,7 +739,7 @@ func newImplementReceiptCommand(asJSON *bool) *cobra.Command { if err != nil { return loopFail(cmd.OutOrStdout(), *asJSON, prefix, err) } - res.Awaiting = redactAwait(res.Awaiting) + res = redactStep(res) return render(cmd.OutOrStdout(), *asJSON, res, func(w io.Writer) { renderStepResult(w, "receipt", res) }) }, } diff --git a/internal/surface/cli/build_surface_test.go b/internal/surface/cli/build_surface_test.go index b2aaf5a8d..da8d1cf2e 100644 --- a/internal/surface/cli/build_surface_test.go +++ b/internal/surface/cli/build_surface_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/intentdriven/abcd/internal/core/implement/loop" "github.com/intentdriven/abcd/internal/gittest" ) @@ -456,3 +457,51 @@ func TestBuildWithoutASessionSaysItHoldsNoClaimInJSON(t *testing.T) { t.Fatalf("build --json without --session: claim = %s; want null", claim) } } + +// TestImplementStepReleaseAndDiscardAreWired: `implement step --release` and +// `--discard` reach the loop's decision over a held lane (ruling DR6c): a lane +// that is not held is refused naming it, with the state unchanged, and the two +// flags together are refused; status names the slots in use. +func TestImplementStepReleaseAndDiscardAreWired(t *testing.T) { + repo := buildRepo(t) + var res struct { + State string `json:"state"` + } + if err := json.Unmarshal([]byte(mustImplement(t, "build", "itd-10", "--json")), &res); err != nil { + t.Fatal(err) + } + statePath := filepath.Join(repo.Root(), filepath.FromSlash(res.State)) + before, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + for _, flag := range []string{"--release", "--discard"} { + ref := refusalDocs(t, 2, "implement", "step", flag, "lane-1", "--json") + if ref["stage"] != "held" || !strings.Contains(ref["reason"].(string), "lane-1 is worktree, not held") { + t.Fatalf("%s of a lane that is not held is refused naming it: %v", flag, ref) + } + } + ref := refusalDocs(t, 2, "implement", "step", "--release", "lane-1", "--discard", "lane-1", "--json") + if !strings.Contains(ref["reason"].(string), "one decision each") { + t.Fatalf("the two flags together are refused: %v", ref) + } + if after, _ := os.ReadFile(statePath); !bytes.Equal(before, after) { + t.Fatal("a refused decision must leave the state unchanged") + } + if out := mustImplement(t, "implement", "status"); !strings.Contains(out, "slots: 0 of 2 in use") { + t.Fatalf("status names the slots in use out of the ceiling:\n%s", out) + } +} + +// TestImplementStepNamesALandingThatWaitsInText: a step that moved one lane +// while another's landing waits on the forge names that wait in its text form, +// as its JSON form carries it under `blocked` (ruling DR6c). +func TestImplementStepNamesALandingThatWaitsInText(t *testing.T) { + res := loop.StepResult{RunID: "run-1", Lane: "lane-1", PerformedStage: loop.StageWorktree, Next: "run `abcd implement step`", + Blocked: []loop.Refusal{{Stage: "land", Lane: "lane-2", Reason: "pull request #7 is not merged yet", Remedy: "run `abcd implement step` again once it has merged", Contention: true}}} + var b bytes.Buffer + renderStepResult(&b, "step", res) + if out := b.String(); !strings.Contains(out, "blocked: lane-2 (land): pull request #7 is not merged yet") { + t.Fatalf("the text form names the lane whose landing waits:\n%s", out) + } +} diff --git a/internal/surface/cli/cli.go b/internal/surface/cli/cli.go index 494356292..6ef128bf1 100644 --- a/internal/surface/cli/cli.go +++ b/internal/surface/cli/cli.go @@ -3090,7 +3090,7 @@ func newIntentAuditCommand(asJSON *bool) *cobra.Command { } // Only a receipt still owed has a request for the host to act on; a // terminal one is reported as it stands, with no request block. - if res.Status != "owed" && res.Status != "already_owed" { + if res.Status != "owed" && res.Status != "already_owed" && res.Status != "check_owed" { route = nil } return render(cmd.OutOrStdout(), *asJSON, withRequest(res, route), func(w io.Writer) { @@ -3104,6 +3104,10 @@ func newIntentAuditCommand(asJSON *bool) *cobra.Command { strings.ReplaceAll(strings.TrimPrefix(res.Status, "already_"), "_", "-")) case res.Status == "already_owed": fmt.Fprintf(w, " request rewritten: %s\n", res.RequestPath) + case res.Status == "check_owed": + // An ingested verdict that left a check owed (ruling DQ1c) + // is re-run from the same receipt. + fmt.Fprintf(w, " request rewritten for the re-run the audit-owed flag asks for: %s\n", res.RequestPath) default: fmt.Fprintf(w, " request: %s\n", res.RequestPath) } @@ -3167,6 +3171,7 @@ func newIntentAuditCommand(asJSON *bool) *cobra.Command { res.Conditions, res.Untested) } } + renderAuditOwed(w, res) // The condition blocks this verdict did not override: its rationale // named none of their occasions (spc-2609020626046252). A re-ingest // for the same receipt naming one replaces the ingested verdict. @@ -3190,11 +3195,33 @@ func newIntentAuditCommand(asJSON *bool) *cobra.Command { return auditCmd } +// renderAuditOwed prints what an ingest left owed or cleared (ruling DQ1c): +// the intent stays shipped, the flag names the unmet or undecided criteria, +// and one issue carries the check until a passing re-audit resolves it. +func renderAuditOwed(w io.Writer, res intent.IngestVerdictResult) { + if len(res.AuditOwed) > 0 { + fmt.Fprintf(w, " audit owed: %s — the intent stays shipped and its Audit Notes carry the flag\n", + strings.Join(res.AuditOwed, " · ")) + switch { + case res.OwedIssue == "": + fmt.Fprintln(w, " no issue carries the check: no ledger is linked to file one") + case res.OwedIssueLinked: + fmt.Fprintf(w, " carried by %s, already open\n", res.OwedIssue) + default: + fmt.Fprintf(w, " captured %s to carry the check; commit it with the record\n", res.OwedIssue) + } + } + if res.FlagCleared != "" { + fmt.Fprintf(w, " audit-owed flag cleared: resolved %s\n", res.FlagCleared) + } +} + // runOwedReviews is bare `abcd intent audit`: the read-only listing of every // shipped intent's fidelity-review debt, from the intent store's one reader of // the review marker (itd-2609150819445595). The owed set is OWED plus no marker; // a dead-lettered review is listed under its own heading with its reason and is -// not counted; an ingested one is not listed. It writes nothing and exits 0, +// not counted; an ingested one is listed only when its verdict left a check +// owed (ruling DQ1c), under its own heading. It writes nothing and exits 0, // and no gate reads it. It names the re-emit command, never the request file: // the request lives in the gitignored local tier and may have been swept. func runOwedReviews(cmd *cobra.Command, asJSON bool) error { @@ -3221,6 +3248,21 @@ func runOwedReviews(cmd *cobra.Command, asJSON bool) error { fmt.Fprintf(w, " %s no receipt (one is minted on re-emit) — re-emit: %s\n", e.IntentID, e.ReEmit) } } + if l.AuditOwed > 0 { + // Ruling DQ1c: an ingested verdict that failed or could not + // decide a criterion leaves a check owed, carried by an issue. + fmt.Fprintln(w, "audit owed (reviewed, flagged; not counted as owed):") + for _, e := range l.Entries { + if !e.AuditOwed { + continue + } + carried := "no issue carries it" + if e.AuditOwedIssue != "" { + carried = "carried by " + e.AuditOwedIssue + } + fmt.Fprintf(w, " %s receipt %s — %s — re-run: %s\n", e.IntentID, e.ReceiptID, carried, e.ReEmit) + } + } if l.DeadLettered > 0 { fmt.Fprintln(w, "dead-lettered (unreviewed; not counted as owed):") for _, e := range l.Entries { @@ -3456,6 +3498,9 @@ func newSpecCommand(asJSON *bool) *cobra.Command { for _, st := range res.RemainderSteps { fmt.Fprintf(w, " %d. %s\n", st.Number, termsafe.Sanitize(st.Title)) } + for _, rw := range res.NeedsRewritten { + fmt.Fprintf(w, " rewrote step %d's needs line: %s -> %s\n", rw.Step, termsafe.Sanitize(rw.Before), termsafe.Sanitize(rw.After)) + } } } if len(res.Members) > 0 { diff --git a/internal/surface/cli/dispatch.go b/internal/surface/cli/dispatch.go index bbe3fb351..3efc3ceaa 100644 --- a/internal/surface/cli/dispatch.go +++ b/internal/surface/cli/dispatch.go @@ -188,6 +188,7 @@ func dispatchAudit(cmd *cobra.Command, rf *routeFlag, route *oracle.Route, repoR } return render(cmd.OutOrStdout(), asJSON, withDispatchReceipt(ing, d), func(w io.Writer) { fmt.Fprintf(w, "abcd intent audit — %s (receipt %s, intent %s)\n", ing.Status, ing.ReceiptID, ing.IntentID) + renderAuditOwed(w, ing) renderDispatchLine(w, d) }) } diff --git a/internal/surface/cli/intent_audit_owed_test.go b/internal/surface/cli/intent_audit_owed_test.go new file mode 100644 index 000000000..5afae0708 --- /dev/null +++ b/internal/surface/cli/intent_audit_owed_test.go @@ -0,0 +1,83 @@ +package cli + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// intent_audit_owed_test.go is ruling DQ1c's wiring proof: through the front +// door, an after-merge audit that comes back undecided leaves the intent +// shipped and flagged, says which issue carries the owed check, and `intent +// audit <itd-N>` rewrites the request for the re-run the flag asks for. +func TestIntentAuditIngestFlagsAnUndecidedAuditAndCapturesItsCheck(t *testing.T) { + root, vp := conditionedRepo(t) + raw, err := os.ReadFile(vp) + if err != nil { + t.Fatal(err) + } + undecided := writeVerdict(t, strings.NewReplacer( + `"verdict": "MET"`, `"verdict": "INCONCLUSIVE"`, + `{"MET": 1, "MET_WITH_CONCERNS": 0, "NOT_MET": 0, "INCONCLUSIVE": 0}`, + `{"MET": 0, "MET_WITH_CONCERNS": 0, "NOT_MET": 0, "INCONCLUSIVE": 1}`).Replace(string(raw))) + text := string(runCLI(t, "intent", "audit", "ingest", "--verdict-json", undecided)) + m := regexp.MustCompile(`captured (iss-[0-9]+) to carry the check`).FindStringSubmatch(text) + if !strings.Contains(text, "audit owed: ac-1 INCONCLUSIVE") || m == nil { + t.Fatalf("the ingest must name the owed criteria and the issue it captured:\n%s", text) + } + body, err := os.ReadFile(filepath.Join(root, ".abcd", "development", "intents", "shipped", "itd-10-alpha.md")) + if err != nil { + t.Fatalf("the intent must stay shipped: %v", err) + } + if !strings.Contains(string(body), "Remedy: re-run the audit. Carried by "+m[1]+".") { + t.Fatalf("the Audit Notes must carry the flag naming %s:\n%s", m[1], body) + } + if text := string(runCLI(t, "intent", "audit")); !strings.Contains(text, "audit owed (reviewed, flagged; not counted as owed):") || + !strings.Contains(text, "itd-10") || !strings.Contains(text, "carried by "+m[1]) { + t.Fatalf("the owed-review listing must name the flagged receipt and its issue:\n%s", text) + } + if text := string(runCLI(t, "intent", "audit", "itd-10")); !strings.Contains(text, "check_owed") || + !strings.Contains(text, "request rewritten for the re-run") { + t.Fatalf("a flagged receipt's re-emit must rewrite the request for the re-run:\n%s", text) + } +} + +// A review a provider ran is ingested through the same path, so an undecided +// answer flags the intent and names the captured issue in the text render too. +func TestAProviderRunAuditThatIsUndecidedNamesTheOwedCheck(t *testing.T) { + srcRoot := repoRootFromTest(t) + root := intentTestRepo(t) + writeRepoFile(t, root, ".abcd/development/intents/shipped/itd-10-alpha.md", conditionedIntent) + t.Setenv("ABCD_PLUGIN_ROOT", srcRoot) + p := newChatFake(t, "typesafe/jev-1.13", func(body string) string { + v := strings.NewReplacer( + `"verdict": "MET"`, `"verdict": "INCONCLUSIVE"`, + `{"MET": 1, "MET_WITH_CONCERNS": 0, "NOT_MET": 0, "INCONCLUSIVE": 0}`, + `{"MET": 0, "MET_WITH_CONCERNS": 0, "NOT_MET": 0, "INCONCLUSIVE": 1}`). + Replace(conditionedVerdict(regexp.MustCompile(`rcp-[0-9a-f]{12}`).FindString(body))) + for _, h := range []struct{ key, placeholder string }{ + {"rubric_hash", "sha256:" + strings.Repeat("a", 64)}, + {"prompt_hash", "sha256:" + strings.Repeat("b", 64)}, + } { + m := regexp.MustCompile(`- ` + h.key + `: (sha256:[0-9a-f]{64})`).FindStringSubmatch(body) + if m == nil { + return "{}" + } + v = strings.Replace(v, h.placeholder, m[1], 1) + } + return v + }) + pointMachine(t, os.Getenv("HOME"), p.srv.URL, false, "", "intent-auditor") + stdout, stderr, err := runCLISplit(t, "intent", "audit", "itd-10") + if err != nil { + t.Fatalf("%v\n%s", err, stderr) + } + if !strings.Contains(stdout, "audit owed: ac-1 INCONCLUSIVE") || !regexp.MustCompile(`captured iss-[0-9]+ to carry the check`).MatchString(stdout) { + t.Fatalf("the provider-run ingest must name the owed check and its issue:\n%s", stdout) + } + if _, err := os.Stat(filepath.Join(root, ".abcd", "development", "intents", "shipped", "itd-10-alpha.md")); err != nil { + t.Fatalf("the intent must stay shipped: %v", err) + } +} diff --git a/internal/surface/cli/route.go b/internal/surface/cli/route.go index 9cb9da9c8..194bafe1b 100644 --- a/internal/surface/cli/route.go +++ b/internal/surface/cli/route.go @@ -308,6 +308,7 @@ func routeCloseRequest(cmd *cobra.Command, repoRoot string, res intent.Reconcile } fmt.Fprintf(stderr, "abcd spec close — the fidelity review for %s ran on %s%s: %s (receipt %s)\n", id, termsafe.Sanitize(d.receipt.ConnectionUsed), termsafe.Sanitize(model), termsafe.Sanitize(ing.Status), termsafe.Sanitize(ing.ReceiptID)) + renderAuditOwed(stderr, ing) return } } diff --git a/internal/surface/cli/site.go b/internal/surface/cli/site.go index 0aec0ee72..fdd10c808 100644 --- a/internal/surface/cli/site.go +++ b/internal/surface/cli/site.go @@ -66,6 +66,12 @@ func newSiteCommand(asJSON *bool) *cobra.Command { Lanes: loop.StatusLanes, }) if err != nil { + // A failure after the build completed ui.json still says what + // it added, before the error: the write stands. + var added *site.LabelsAddedError + if errors.As(err, &added) { + sayAddedLabels(cmd.ErrOrStderr(), "abcd site build", added.File, added.Labels) + } return &exitError{Code: 2, Msg: "abcd site build: " + scrubPaths(err)} } sayAddedLabels(cmd.ErrOrStderr(), "abcd site build", res.LabelsFile, res.AddedLabels) diff --git a/internal/surface/cli/site_setup_test.go b/internal/surface/cli/site_setup_test.go index 84647597c..2770d754a 100644 --- a/internal/surface/cli/site_setup_test.go +++ b/internal/surface/cli/site_setup_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "regexp" "strings" "testing" @@ -230,4 +231,26 @@ func TestSiteVerbsSayWhichLabelsTheyAdded(t *testing.T) { if strings.Contains(out, "added the missing label") { t.Errorf("the added-label lines reached stdout:\n%s", out) } + + // A build that fails after it completed the file still says what it + // added, before the error: the write stands, so it is never silent. + drop("target") + body, err := os.ReadFile(uiPath) + if err != nil { + t.Fatal(err) + } + blanked := regexp.MustCompile(`"now": "[^"]*"`).ReplaceAllString(string(body), `"now": ""`) + if blanked == string(body) { + t.Fatalf("ui.json carries no now label:\n%s", body) + } + if err := os.WriteFile(uiPath, []byte(blanked), 0o644); err != nil { + t.Fatal(err) + } + out, errOut, err = runCLISplit(t, "site", "build", "--out", t.TempDir()) + if err == nil { + t.Fatalf("site build with a blank label must fail:\n%s%s", out, errOut) + } + if !strings.HasPrefix(errOut, "abcd site build: "+want) { + t.Fatalf("a failed build names the label it added before the error: stderr = %q, err = %v", errOut, err) + } }