From fd72d26899bc1467bef4bde6f2e45fd03ac50696 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 27 Sep 2026 11:33:44 -0500 Subject: [PATCH] feat(ledger): Piece E, queue section in the weekly ledger (row 13, #1508) The ledger prints a queue section above the weekly table. It checks four things: - open issues named by done rows; - owner registrations for active rows; - closed issues for done rows; - the age of required rows. The result is fail, incomplete or reduced pass. It uses its own Gitea budget of the open list plus at most 10 lookups. A full open page counts only while an issue in some row's closes has no known state (lead decision 40). T3 seats are exempt per run with --unsupported-runtime. The weekly routine is in packages/ledger/README.md. Built by Darkwing (build.patch ab1f12ca, manifest 0b20bbca). Filbert reviewed it: round 1 81f26f2e asked for changes (C1, ISO requiredSince never aged); round 2 ce8ce150 approved. Also carries Filbert's plan amendment for decision 40 (68a25ffe). Co-Authored-By: Claude Opus 5.5 --- agents/darkwing/work/queue-e/build.md | 285 +++++++++++ .../work/queue-as-data-plan-2026-09-26.md | 24 +- .../work/queue-e-review-r1-2026-09-27.md | 168 +++++++ .../work/queue-e-review-r2-2026-09-27.md | 101 ++++ packages/ledger/README.md | 124 ++++- packages/ledger/src/cli.mjs | 28 +- packages/ledger/src/queue-checks.mjs | 255 ++++++++++ packages/ledger/tests/ledger.test.mjs | 7 +- packages/ledger/tests/queue-checks.test.mjs | 449 ++++++++++++++++++ 9 files changed, 1421 insertions(+), 20 deletions(-) create mode 100644 agents/darkwing/work/queue-e/build.md create mode 100644 agents/filbert/work/queue-e-review-r1-2026-09-27.md create mode 100644 agents/filbert/work/queue-e-review-r2-2026-09-27.md create mode 100644 packages/ledger/src/queue-checks.mjs create mode 100644 packages/ledger/tests/queue-checks.test.mjs diff --git a/agents/darkwing/work/queue-e/build.md b/agents/darkwing/work/queue-e/build.md new file mode 100644 index 00000000..1e0e1e35 --- /dev/null +++ b/agents/darkwing/work/queue-e/build.md @@ -0,0 +1,285 @@ +# Queue E (#1508, row 13), candidate for review, round 2 + +Darkwing, 2026-09-27. Piece E is the ledger's queue section, per plan +section 8.10 (`agents/filbert/work/queue-as-data-plan-2026-09-26.md`) and +the brief's "Piece E: ledger checks the queue". The ledger now checks +`docs/plans/queue.json` against Gitea and the seat registrations and prints +the result above the weekly table. Filbert reviews E. Sage commits. Nothing +is committed, staged or pushed. + +Round 2 answers Filbert's round 1 review +(`agents/filbert/work/queue-e-review-r1-2026-09-27.md`, sha256 81f26f2e…): +C1, plus n1, n2 and n3. See "Round 2" below. The round 1 files are kept +unchanged in `r1/` (patch 02a01c29…, manifest cad51929…, build.md +d3bdb826…). + +Round 1 was amended before its verdict for lead decision 40 (origin +2333d837): a full open-issue page is undecided only while some issue a row +closes has no known state. See "The full page" below. + +Base is f3f48cfd. Nothing under `packages/ledger`, `packages/queue/src` or +`packages/seat/src` changed between it and origin 2333d837. In a fresh +clone at 2333d837 the patch applies, the result matches the manifest 5/5, +and `node --test packages/ledger/tests/` passes 78/78 in three runs. + +## Files + +`build.patch` (sha256 +`ab1f12cad711284f8a722ea51fa73cd8e344c703701f8b76957ae33de091ae84`) changes 3 +files and adds 2. `build-manifest.sha256` (sha256 +`0b20bbca6c9e0a00d39dc7aae0ca2e268b6908c4b24e9f9b401df8268d29c5fe`) pins all 5 +after the patch. + +- `packages/ledger/src/queue-checks.mjs` (new): reads the queue, classifies + owners, makes the issue calls, runs the checks, lists protected changes, + formats the section. +- `packages/ledger/src/cli.mjs`: `--no-queue`, `--unsupported-runtime SEAT` + (repeatable), the queue read before any Gitea call, the section above the + table and under `queue` in `--json`. +- `packages/ledger/README.md`: a "Queue section" with the rules, the call + budget, the result levels and the weekly routine. The heading "One Gitea + call" becomes "Gitea calls", and the exit-code paragraph names the queue. +- `packages/ledger/tests/queue-checks.test.mjs` (new, 20 tests). +- `packages/ledger/tests/ledger.test.mjs`: the fixture copies + `packages/queue/src` and `packages/seat/src`, which the ledger now + imports, and its runs pass `--no-queue`. Those tests cover the weekly + table; the new file covers the queue section. 58 tests before, 78 now. + +`docs/TOOLS.md` has no ledger entry today, so there is no TOOLS patch. The +README is the reference, as it was for Piece 3. + +## What a run prints + +``` +Queue checks: queue.json revision 19, as of 2026-09-27T16:09:10.883Z +owner darkwing (row 13): exempt, declared with --unsupported-runtime +... +liveness: 0 pid-present (unverified), 3 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone +declared unsupported runtime: darkwing, dewey, sage +queue issue checks: open list (full page), 2 lookups +protected changes in range: 0 (not checks; confirm the actors) +queue: 0 violations; result reduced pass +``` + +Each finding is a line `violation|undecided|disposition <#issue>: +`, so its identity (check, row, issue) can be read off the line for +the same-day remediation run. The JSON carries the same objects. + +## Choices and where they differ from the plan + +1. **A new module.** The plan's file list puts `queueChecks` in + `ledger.mjs`. I put the section in `queue-checks.mjs`, as `t3.mjs` did + for the T3 source, so `ledger.mjs` stays the metric code. The CLI + wires it in. +2. **Registrations through `readRegistration`**, not the control board's + scan. It returns a record, null or a thrown error per seat, with no text + to parse, and it keeps the board's import chain out of the ledger. + Liveness is its own signal-0 probe; EPERM counts as present. +3. **Closure uses `closes`, not `issues`** (J6). Rows 9 to 12 name #1508 + but close nothing, so they can be done while it is open. The brief's + literal "a row naming an open issue" would flag them. +4. **A malformed or absent pid is `invalid`, not `pid-unknown`.** The plan's + table puts it under `pid-unknown`. `validateRegistration` rejects a pid + that is not a positive integer or null, so the record fails validation + first. `invalid` is a violation where `pid-unknown` is undecided, so the + difference fails closed. `pid-unknown` is a valid record with a null pid. +5. **A registration for another checkout is `missing`.** The plan defines + missing as no registration for (canonical root, `repo`, seat). A `repo` + record whose `sessionsDir` is not under the queue's canonical root + matches the seat name but not the root. +6. **An unreadable config makes every non-exempt owner `invalid`**, and + the report still runs. It does not refuse, because the other checks don't + need the data root. +7. **Refusals.** A missing, symlinked or hand-edited `queue.json` exits 1 + before any Gitea call; the message names `--no-queue`. A failed or + malformed open-list call exits 2, like the metric call, and names + `--no-issues`. A failed lookup, a 404, a pull request or a number that + doesn't match leaves only that issue unknown. +8. **On by default.** `--no-queue` skips the section and prints `Queue: not + checked (--no-queue)`. It can't be combined with `--unsupported-runtime`. + `--unsupported-runtime` is the only repeatable flag; a repeated seat is + refused. +9. **Age is as of the run**, not `--until`: the gate is about today's queue, + and the table's range doesn't move it. "More than 14 days" is whole days, + so a row required on 2026-09-13 turns on 2026-09-28. +10. **Exit 0 whenever a report was computed.** The result is in the text and + the JSON, like every other number the ledger prints. +11. **Protected changes (added).** The 8.10 checks don't include it, but R4 + and R10 ("E lists every protected change from [the journal]"), J2 and + the queue README's trust boundary ("piece E lists changes for review") + do. So the section lists every log entry dated inside `--since`/`--until` + that changes a row which is required or parked before or after the + entry, with rev, verb, claimed actor and rows. It replays the log up to + the range with the queue's own `replay` and `applyEntry`. It is a list, + not a check: it never changes the result. For 2026-09-20 to 2026-09-26 + it lists nothing, because genesis was 2026-09-27; for a range that + includes today it lists 18 entries at rev 19, on rows 8 to 13 and 26 to 30. + +## Round 2 + +- **C1, an ISO `requiredSince` never aged.** `set required` and `add + --required` write an ISO time, and round 1 appended `T00:00:00Z` to it, + which parses to NaN. Now `Date.parse` reads the value as it is (a bare + date parses as 00:00Z), and the result is floored to its UTC day. One + deviation from the suggested fix: an ISO time counts from 00:00Z of its + day, not from its hour, so both forms age in whole UTC days and a Monday + run's result doesn't depend on the hour a row was made required. A row + required at 23:59Z on 2026-09-13 turns on 2026-09-28, as a date-only row + does. A value that doesn't parse is an `age-invalid` violation. I chose + a violation over undecided because row 13's gate counts violations, and + a bad value is a queue defect. The queue validator lets one through: + `ISO_RE` checks the shape, so `2026-13-01T00:00:00.000Z` passes it. That + gap is in `packages/queue/src/queue.mjs`, outside E; I'm raising it as a + follow-up, not fixing it here. +- **n1, the orphaned curl.** Each queue call now runs as `timeout -s KILL + 60 gitea-api.sh GET ...`, as D's `callTool` does, so the kill takes the + helper's process group. A kill reads `no answer within 60 s`, and exit + 126 or 127 from `timeout` (no helper) reads `gitea-api.sh unavailable`. + The metric call in `ledger.mjs` still uses execFileSync's timeout. It + isn't in this patch, so that is a follow-up too. +- **n2, a reopened issue.** Closed evidence from the metric page now needs + `state: "closed"` and a `closed_at`. Either one alone leads to a lookup. +- **n3.** The budget detail is `over the lookup budget`, so the message + reads `unknown (over the lookup budget)`. + +Two tests are new. One covers an ISO `requiredSince` at 15 days (fails), at +14 (passes), at 23:59Z fifteen days back (fails), and one that doesn't +parse (`age-invalid`, result fail). The other gives the calls a hanging +helper that starts a hanging child and a 1-second deadline. Both calls +return at the deadline and neither process survives. A missing helper +reads `gitea-api.sh unavailable`. The metric fixture adds a reopened issue +and a `state: "closed"` entry with no `closed_at`, and both are looked up. + +Round 2 mutants, all 12 killed: + +1. The round 1 template restored. +2. No floor to the UTC day. +3. No NaN guard. +4. NaN as undecided. +5. Ceiling instead of floor. +6. The metric check on `closed_at` alone. +7. The metric check on `state` alone. +8. A Node timeout with SIGKILL in place of `timeout`, with ETIMEDOUT + ignored. The orphan assertion kills it. +9. The kill flag always false. +10. The kill flag read from the exit status alone. +11. No 126/127 mapping. +12. The old budget detail. + +A first version kept the suggested `DATE_RE` branch, and dropping it was an +equivalent mutant because `Date.parse` already reads a bare date as 00:00Z. +I removed the branch. + +## The full page (lead decision 40) + +mosaicstack/stack has 50 or more open issues, so the open list is always a +full page. Plan 8.10 made a full page undecided on its own. But an issue +missing from the page is looked up, so the page matters only when an issue +is left without a known state, and that issue is already undecided. Sage +approved the change. Now `open-list-full` is added only when the page is +full and some issue a row closes is `unknown`, and its message names +those issues. The text still prints `open list (full page)`, and the JSON +keeps `openListFull`. + +Tests cover a full page with every issue resolved (no undecided item), an +open issue off the page found by lookup (its done row fails), and a full +page with an issue past the budget (still incomplete). They drive the fake +helper end to end through `issueStates` and `queueChecks`. + +## Live run (read-only) + +Round 2, in a fresh clone at 2333d837 with the patch applied, through the +new `timeout` path: the same result as below with the exemptions, 0 +violations, `reduced pass`, 2 lookups, exit 0. + +Round 1, at 2333d837 in the verify clone, with my own token file in +`MOSAIC_GITEA_CREDENTIAL_FILE`, for 2026-09-20 to 2026-09-26: exit 0, the +metric call, the open list and 2 lookups. That is GET only, and nothing +was posted. + +- Plain run: 3 violations, result `fail`. Rows 13 (darkwing) and 5 (dewey) + are `pid-gone`, from old pi launches; row 7 (sage) is `missing`. All + three seats run in T3, which writes no registration. +- With `--unsupported-runtime` for darkwing, dewey and sage: 0 violations, + result `reduced pass`. Before the amendment the same run was + `incomplete`, from the full page alone. +- No issue violations: every issue in a done row's `closes` is closed. + +This is not the acceptance run. That is a dated run posted on #1508 after +approval. + +## Lead decision 40 + +Sage ruled on the five points I raised: + +1. The full page: approved as amended above. +2. Row 7's brief pins `packages/ledger/README.md` at blob 3a2ce27c. Sage + re-pins it with `set 7 brief` in a queue commit right after E lands. +3. Row 7 stays. Its gate is Jason's, so Q9 is amended. The weekly routine + stays in the README. +4. The age rule stays. From 2026-09-28 the Monday run lists rows 9, 10, 11 + and 13, which is accurate. +5. The T3 exemption is accepted as built. The weekly run declares every T3 + seat that owns an active row. + +## Tests + +`packages/ledger/tests/queue-checks.test.mjs`. In-process checks run on +fixture rows with a fixed clock and an injected pid probe. CLI tests use a +scratch repository whose `queue.json` the real queue CLI wrote, a temporary +config and data root, and a fake `gitea-api.sh` that routes the open list, +single issues and the metric page and logs every call. No test reads a real +token, registration, config or `~/.t3`; HOME and MOSAIC_CONFIG are +temporary. + +- Issues: done with the issue open (fail) or closed (pass); a multi-row + issue open while one closer is pending, closed early (disposition), and + open with both done; rows 9 to 12's shape (issues without closes); + unknown and not run (incomplete); a full page is undecided only beside an + unknown issue. +- Issue calls: open list first, metric page next, then at most 10 lookups + in order and `unknown (budget)` after; a pull request on the open list is + not an issue; lookups of a pull request, a mismatched number and a 404 + are unknown; a metric entry with no `closed_at` is not closed evidence; + a full page. The open list refuses on exit 3, exit 1, bad JSON and bad or + closed records, and never echoes the helper's stderr. +- Owners: every class in one run, including another checkout and a broken + record; two rows for one owner; briefed and done owners not checked; no + config. +- Age: 15 days fails, 14 doesn't; done and not-required rows are skipped; + the legacy bound at 20 days (fail), exactly 14 and 3 (undecided); an ISO + `requiredSince` by UTC day, and one that doesn't parse (round 2). +- Deadline: a hanging helper and its child are both killed (round 2). +- `pidAlive`: running, exited, and EPERM (pid 1, non-root). +- `readQueue`: a real queue, a hand edit, a missing file, a symlink. +- Protected changes: genesis, a note on a required row, a note on an + ordinary row (not listed), an unpark by jason (listed from the row + before), and range boundaries (start included, end excluded). +- CLI: section above the table; `--json` key; a clean queue prints `queue: + 0 violations; result reduced pass`; call counts (3 with issues, 0 with + `--no-issues`, 1 with `--no-queue`); refusals cost no call; flag errors. + +Mutation testing, round 1: 37 hand-made mutants of `queue-checks.mjs` and the CLI +wiring (boundaries, each class, each result level, budget, filters, +refusals, the protected-change range and guard, the full-page rule). All +37 are killed. The +first pass left three alive (the 14-day genesis edge, EPERM, a null +`closed_at`) and a later one three more (the before-row guard and both +range edges); the tests above were added for them. + +Round 2 adds the 12 listed above. + +Suites: `node --test packages/ledger/tests/` 78/78, three runs; +`packages/queue/tests` and `packages/seat/tests` 161/161. + +## Verify + +```sh +git clone -q /mnt/storage/src/mosaic-stack /tmp/e && cd /tmp/e +git checkout -q 2333d837 +git apply /mnt/storage/src/mosaic-stack/agents/darkwing/work/queue-e/build.patch +sha256sum -c /mnt/storage/src/mosaic-stack/agents/darkwing/work/queue-e/build-manifest.sha256 +ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules +node --test packages/ledger/tests/ +node packages/ledger/src/cli.mjs --since 2026-09-20 --until 2026-09-26 --no-t3 --no-issues +``` diff --git a/agents/filbert/work/queue-as-data-plan-2026-09-26.md b/agents/filbert/work/queue-as-data-plan-2026-09-26.md index 7f4ca612..90b1a9f7 100644 --- a/agents/filbert/work/queue-as-data-plan-2026-09-26.md +++ b/agents/filbert/work/queue-as-data-plan-2026-09-26.md @@ -137,6 +137,10 @@ Changed: `packages/ledger`, so A leaves a one-line hand-written pointer to the weekly routine below the markers. E moves the routine into `packages/ledger/README.md` and removes the pointer. + *Amended by lead decision 40 (2026-09-27):* row 7 stays in the queue + until Jason closes it, because its gate is his. E still writes the + weekly routine into the ledger README. A left no pointer below the + markers, so E has none to remove. - `docs/TOOLS.md`: usage lines. This file carries other owners' uncommitted changes; add a scoped patch the way #1511 did. @@ -167,6 +171,8 @@ Changed: that was row 7). - One more Gitea call, `state=open`, one page; a full page fails as today (Q7). Referenced issues absent from that list count as closed. + *Superseded by 8.10:* absent issues are looked up, and a full page is + handled as lead decision 40 rules. - Reads `docs/plans/queue.json` through the queue validator by relative import (`../../queue/src/queue.mjs`). There are no workspaces, so a bare `@mosaic/queue` import would not resolve (R14). @@ -371,6 +377,10 @@ items have no owner or issue. *Decided:* row 7 moves to `packages/ledger/README.md` as the weekly routine (sequenced across A and E; see 2.A). The parked items become rows with state `parked`, owner `unassigned` and issue null. +*Amended by lead decision 40 (2026-09-27):* row 7 stays until Jason +closes it. Its gate is Jason's, and closing a Jason-gated row needs his +cited approval, so the lead can't retire it alone. E writes the weekly +routine into `packages/ledger/README.md` anyway. ## 5. Where the brief contradicts the code or itself @@ -1741,7 +1751,7 @@ source is unchanged. | Calls | Purpose | |---|---| | 1 | Metrics, unchanged. | -| 1 | Queue checks: `state=open`, limit 50. A full page makes the queue issue checks "incomplete". | +| 1 | Queue checks: `state=open`, limit 50. A full page makes the queue issue checks "incomplete" only when some issue in a row's `closes` is left without a known state (lead decision 40). | | up to 10 | Queue checks: `GET issues/N` for issues in some row's `closes` that are neither in the open list nor shown closed in the metric page. Issues beyond 10 are "unknown (budget)", and the run is incomplete. | That is at most 12 calls. `--no-issues` makes 0 calls and prints `queue @@ -1784,8 +1794,9 @@ days (legacy lower bound)". Until then its age is undecidable. `missing`, `invalid` or `pid-gone` owner, and any issue or age violation. - `incomplete`: no known violation, but something couldn't be decided. That covers: - - an issue check that was `unknown (budget)`, hit a full page, or was not - run; + - an issue check that was `unknown (budget)`, or was not run; + - a full open page while some issue in a row's `closes` has no known + state; - a `pid-unknown` owner; - an undecidable legacy age. - `reduced pass`: nothing known and nothing undecided. The liveness evidence @@ -2411,3 +2422,10 @@ The round-2 modifications: with no reviewer approvals, so a Jason-gated row could close without its reviewers. That move now carries the in-review→done checks on a comment round; the table and 8.9's receipts paragraph say so. +- 2026-09-27: amended for lead decision 40 (Piece E questions). A full + `state=open` page makes the queue issue checks incomplete only when some + issue in a row's `closes` is left without a known state. An issue off + the page is looked up or left `unknown (budget)`, so truncation can't + hide a violation. 8.10's budget table and result list say so. Row 7 + stays in the queue until Jason closes it; Q9 and 2.A say so. Section 2's + E text points to 8.10. diff --git a/agents/filbert/work/queue-e-review-r1-2026-09-27.md b/agents/filbert/work/queue-e-review-r1-2026-09-27.md new file mode 100644 index 00000000..8f20653c --- /dev/null +++ b/agents/filbert/work/queue-e-review-r1-2026-09-27.md @@ -0,0 +1,168 @@ +# Queue Piece E review, round 1 (#1508, row 13) + +Filbert, 2026-09-27. Plan: `queue-as-data-plan-2026-09-26.md` §8.10, +amended for lead decision 40 (plan sha256 68a25ffe…, uncommitted). + +## Verdict + +**Changes requested, one item (C1).** The review covers the amended +round-1 candidate: `build.patch` sha256 +02a01c291890626f58ba103d1c65e853b042b609887868bf59dea7f9b50b227d at +2333d837, with `build-manifest.sha256` cad51929… and `build.md` d3bdb826…. +I had also reviewed the first version (patch 78445322…, manifest +8f0e4fea…) in full. The amendment changes only the full-page rule, its +tests and the README, so everything I checked on the first version still +holds. + +C1 is a real bug in the age check, and it's small. Everything else is +ready, including the decision-40 change. + +## C1. The age check never flags a row made required after genesis + +**Where:** `packages/ledger/src/queue-checks.mjs`, line 200 in the amended +file: + +```js +const days = ageDays(Date.parse(`${r.requiredSince}T00:00:00Z`), now); +if (days > AGE_LIMIT_DAYS) add(violations, 'age', ... +``` + +**What happens.** The code assumes `requiredSince` is a date. Genesis +rows carry dates (rows 9, 10, 11 and 13 have `2026-09-13`). But the queue +writes a full ISO time for any row made required later: +`set required true` sets `requiredSince = entry.at` +(`packages/queue/src/queue.mjs:860`), and `add --required` does the same +(line 904). The validator accepts both forms (line 322, `checkTime(…, +{ date: true })`). For an ISO time the template gives +`2026-09-01T12:00:00.000ZT00:00:00Z`, `Date.parse` returns NaN, `days` +is NaN, and `NaN > 14` is false. The row is never an age violation, and +nothing is reported as undecided either. + +**Reproduced** in-process at 2026-09-27T12:00Z on one required, +in-progress row: +- `requiredSince: "2026-09-01"` → violations `owner-invalid, age`, result + `fail`; +- `requiredSince: "2026-09-01T12:00:00.000Z"` → violations `owner-invalid` + only. The age violation is missing. + +(`owner-invalid` comes from passing no seats; it doesn't matter here.) + +**Why it matters.** Today's rows all have dates, so Monday's run is right. +The first row made required through the queue CLI would slip past the +14-day gate without a word. A check that goes quiet is the failure the +ledger's result levels exist to prevent. + +**Fix.** +- A `DATE_RE` value parses as `T00:00:00Z`; anything else goes to + `Date.parse` as it stands. +- If the result is NaN, fail closed: an `age` undecided item naming the + row, or a violation. Never silence. (The validator should make this + unreachable, but the check shouldn't depend on that.) +- Tests: an ISO `requiredSince` 15 days old fails and one 14 days old + doesn't, next to the existing date cases. Your fixtures build + `requiredSince` with `since(n)`, so an ISO variant fits in the same test. +- Mutant: restore the old template. The new test should kill it. +- The README's age paragraph can say that `requiredSince` is a date for + genesis rows and an ISO time for later ones. + +## The full page (lead decision 40) + +I agree with the ruling, and the amendment implements it correctly. An +issue is never treated as closed because it's missing from the open list. +It's looked up or left `unknown (budget)`, and the unknown state already +makes the run incomplete. A server that caps pages below 50 would make +`full` meaningless anyway, so dropping it as a standalone signal costs +nothing. + +What I checked on the delta: +- `open-list-full` is added only when the page is full and some issue in + `wanted` is `unknown`. `wanted` is the union of every row's `closes`, + including rows that aren't done. An unknown issue on a pending row can + only affect a disposition, but it still keeps the page undecided. That + errs toward `incomplete`, and it matches the decision's wording ("some + wanted issue"). Keep it. +- The new end-to-end test drives the fake helper through `issueStates` and + `queueChecks` for all three cases: resolved, open off the page, and past + the budget. +- The README's result list and call table match the code. +- My plan now says the same thing: 8.10's budget table and result list, + a pointer from section 2's E text, and Q9 and 2.A for row 7. + +## What I checked + +All of this ran in scratch clones with push disabled: `/tmp/fqe` at +f304eaa5 for the first version, and `/tmp/fqe2` at 2333d837 for the +amendment. The inputs were frozen as 0444 copies. + +- **Manifest and suites.** The amended manifest checks 5/5. `node --test + packages/ledger/tests/` passes 76/76, and `packages/queue/tests` with + `packages/seat/tests` passes 161/161. The first version had passed + 75/75 and 161/161. +- **Source.** I read `queue-checks.mjs` in full, and the diffs to + `cli.mjs`, the README and both test files. I compared the amendment with + the first version file by file. Only `queue-checks.mjs` (the rule and its + comment), the README (two passages) and `queue-checks.test.mjs` changed. +- **Local run** on the first version, for 2026-09-27 with `--no-issues + --no-t3` and three seats declared: 0 violations, result `incomplete` + (issues not run), 18 protected changes. +- **Mutations.** I wrote 21 mutants of my own against the first version + (E1–E21), apart from Darkwing's 37. The suite kills 19. The two + survivors are equivalent: + - E2 checks the metric page before the open list. Both are current + sources, so they can't disagree about an issue. + - E8 applies the age check to rows that aren't required. The validator + refuses `requiredSince` on such a row, so the mutant changes nothing. + + I wrote five more against the amendment (F1–F5), and the suite kills + all five: + - F1: a full page is always undecided; + - F2: an unknown issue makes `open-list-full` without a full page; + - F3: any state other than open counts as unresolved; + - F4: only `unknown (budget)` counts, so a failed lookup doesn't; + - F5: it takes two unknown issues. + + None of my 26 mutants touches the age parsing. The C1 bug is in an input + shape the fixtures don't use. + +## Darkwing's choices + +I agree with choices 1 to 11 in `build.md`. Two of them depart from the +plan's table, and in both cases the change fails closed: +- 4: a malformed pid is `invalid`, not `pid-unknown`, so it's a violation + rather than undecided; +- 5: a registration for another checkout is `missing`, because it matches + the seat name but not the root. + +Choice 11 (protected changes listed, never checked) is what R4, R10 and J2 +asked for. + +## Non-blocking + +- **n1. The call deadline kills the helper, not curl.** `execFileSync` + with a 60-second `timeout` sends SIGTERM to `gitea-api.sh` only. I + checked the behaviour with a script that runs a child `sleep`: + `execFileSync` throws `ETIMEDOUT` at the deadline, so the ledger's wall + time stays bounded, but the child keeps running as an orphan. For the + ledger that means a stuck curl can outlive the run, and the helper's + response file may be left in `/tmp`. It's a GET, so nothing is written + to Gitea. D wrapped its helper call in `timeout -s KILL` for this. + Doing the same here is a small change. It can go in this round or + later. +- **n2. `closedInMetric` could also check `state === 'closed'`.** It uses + `closed_at` alone. I haven't checked whether Gitea clears `closed_at` + when an issue is reopened. If it doesn't, a reopened issue that is off + the open page but on the metric page would count as closed. The open + page is always full now, so that case can happen. Checking `state` as + well settles it either way. +- **n3. The unknown-budget message doubles a word.** The test pins + `unknown (unknown (budget))`. It would read better as `unknown (budget)` + or `unknown (lookup budget)`. Cosmetic. +- **n4. `--unsupported-runtime` is self-declared.** The flag is a + statement from whoever runs the ledger, not evidence. It's printed on + every run, which is what 8.10 asks for. Nothing to change. + +## For Darkwing and Sage + +Fix C1 with its test and mutant, then send the patch and manifest again. +I'll review round 2 against C1 only, plus n1 if it's in. My plan +amendment (68a25ffe…) goes into E's commit with this review. diff --git a/agents/filbert/work/queue-e-review-r2-2026-09-27.md b/agents/filbert/work/queue-e-review-r2-2026-09-27.md new file mode 100644 index 00000000..e9b14782 --- /dev/null +++ b/agents/filbert/work/queue-e-review-r2-2026-09-27.md @@ -0,0 +1,101 @@ +# Queue Piece E review, round 2 (#1508, row 13) + +Filbert, 2026-09-27. Round 1: `queue-e-review-r1-2026-09-27.md` +(sha256 81f26f2e…). This round checks C1, n1, n2 and n3. + +## Verdict + +**Approved.** The review covers `build.patch` sha256 +ab1f12cad711284f8a722ea51fa73cd8e344c703701f8b76957ae33de091ae84 at +2333d837, with `build-manifest.sha256` 0b20bbca… and `build.md` +75571f0b…. C1 is fixed, and so are n1, n2 and n3. Two of my mutants +survive. Neither hides a defect; see the notes below. Nothing needs a +round 3. + +## What I checked + +All of this ran in a scratch clone, `/tmp/fqe3`, at 2333d837 with push +disabled, on frozen 0444 copies of the three inputs. Darkwing's `r1/` +copies still match the hashes I reviewed in round 1. + +- **Manifest and suites.** The manifest checks 5/5. `node --test + packages/ledger/tests/` passes 78/78, and `packages/queue/tests` with + `packages/seat/tests` passes 161/161. +- **What changed.** I compared all five files with the round-1 candidate. + `cli.mjs` and `ledger.test.mjs` are unchanged. `queue-checks.mjs`, the + README and `queue-checks.test.mjs` change only for C1, n1, n2 and n3. +- **C1.** + - `requiredDay` floors `Date.parse` to 00:00Z of its UTC day. A bare date + parses as 00:00Z, so the separate date branch I suggested would add + nothing. Dropping it is right. + - Counting an ISO time from its UTC day rather than its hour is a change + from my fix, and I agree with it. Both forms age in whole UTC days. A + row can be flagged up to a day early, never late. + - A value that doesn't parse is an `age-invalid` violation, so the run + fails. Any finding in `violations` counts toward the result, and no + other code matches on the check name, so the new name needs no other + wiring. + - The tests cover an ISO time at 15 days (fails), at 14 days (passes), + and at 23:59Z fifteen days back (fails, which needs the floor), and + month 13 (`age-invalid`, result fail). +- **n1.** Each call runs as `timeout -s KILL 60 gitea-api.sh GET …`. Without + `--foreground`, GNU timeout signals the whole process group, which kills + curl too. The new test starts a helper with a hanging child and a 1 s + deadline, then checks that both pids are gone. Adding `--foreground` + leaves the child alive, and the test catches it (R7). +- **n2.** Metric-page evidence needs `state === 'closed'` and a + `closed_at`. The metric call returns the raw Gitea records, filtered but + not mapped (`ledger.mjs` `readIssues`), so `state` is there in real runs. + The fixture covers a reopened entry (`closed_at` only) and one with + `state` only. Both are looked up. +- **n3.** The message reads `is unknown (over the lookup budget)`. +- **Mutations.** I wrote 13 mutants of my own for this round. The suite + kills 11: + - R1: no floor on `requiredDay`; + - R2: the NaN guard removed; + - R3: `age-invalid` counted as undecided; + - R4: metric evidence on `closed_at` alone; + - R5: metric evidence on `state` alone; + - R6: the default TERM signal in place of KILL (caught by the message); + - R7: `--foreground` (caught by the orphan check); + - R8: a kill recognised only by exit 137; + - R10: exit 127 no longer read as "unavailable"; + - R11: `ceil` in place of `floor`; + - R12: a signal-killed call treated as success. + + Two survive: + - **R9**, a kill recognised only by `r.signal === 'SIGKILL'`. Without + `--foreground`, GNU timeout sends KILL to its own group and dies with + it, so `spawnSync` sees the signal, not exit 137. The `status === 137` + branch is defensive and can't be reached in this setup. The mutant is + equivalent. + - **R13**, `if (r.error) throw r.error;` removed. With `timeout` missing + from PATH, the call still fails, but the message says "credential or + Gitea request failure" rather than "the timeout command is + unavailable". That's still a refusal (exit 2); only the wording is + wrong. See n1. + +## Non-blocking + +- **n1. The missing-`timeout` message has no test (R13).** A test could + point `PATH` at an empty directory for one call and give the helper by + absolute path. That's optional. The failure is closed either way. +- **n2. A day past the end of the month doesn't parse as invalid.** V8 + turns `2026-02-30` and `2026-02-30T00:00:00.000Z` into 2026-03-02. It + returns NaN only for values like month 13. So `age-invalid` catches some + impossible dates but not all. A row whose date overflows ages from the + wrong day and gets no warning. This belongs with Darkwing's follow-up + (a): the queue validator checks shape, not calendar. A calendar check + there, such as a round trip through `toISOString`, closes both. The CLI + never writes such a value, and readQueue refuses hand edits, so it can't + happen today. +- **Darkwing's follow-ups.** I agree with both: + - (a), above; + - (b), the metric call's orphan curl in `ledger.mjs`, the same fix as + n1 in round 1. + Neither is E's scope. + +## For Darkwing and Sage + +E is approved as it stands. My plan amendment (68a25ffe…) and both review +files go into E's commit. diff --git a/packages/ledger/README.md b/packages/ledger/README.md index 3a2ce27c..483c981e 100644 --- a/packages/ledger/README.md +++ b/packages/ledger/README.md @@ -2,7 +2,9 @@ Read-only counts from local `refactor` commit subjects, one Gitea issue-list request through `scripts/gitea-api.sh`, repo seats' Pi session logs, and T3's -thread messages in `~/.t3/userdata/state.sqlite`. +thread messages in `~/.t3/userdata/state.sqlite`. Above the counts, a queue +section checks `docs/plans/queue.json` against Gitea and the seat +registrations (see "Queue section"). No board changes, data-root writes, fleet reads, transcript output, or scheduler. ```sh @@ -11,6 +13,8 @@ node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 --json node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues node packages/ledger/src/cli.mjs --since 2026-09-06 --no-t3 node packages/ledger/src/cli.mjs --since 2026-09-06 --t3-db /tmp/fixture.sqlite +node packages/ledger/src/cli.mjs --since 2026-09-06 --no-queue +node packages/ledger/src/cli.mjs --since 2026-09-06 --unsupported-runtime dewey node --test packages/ledger/tests/ ``` @@ -125,27 +129,127 @@ directory means no Pi seats ran here; a missing T3 database means the path or T3 changed, so it refuses instead of counting zero. Error messages name ids and paths, never message text. -## One Gitea call and missing evidence +## Queue section -The client requests issues updated since the start date, all states, first page, -limit 50. This includes issues closed in range, even if later updated. Gitea caps +The rules come from plan 8.10 in +`agents/filbert/work/queue-as-data-plan-2026-09-26.md` (Piece E, #1508). The +section is on by default and prints above the weekly table; `--json` puts it +under `queue`. `--no-queue` skips it and prints `Queue: not checked +(--no-queue)`. The ledger reads `docs/plans/queue.json` through the queue's own +validator before any Gitea call, so a missing, symlinked or hand-edited file +exits 1 and costs no call. It writes nothing, takes no queue lock and never +changes a row. + +Four checks, each finding named by (check, row, issue): + +- **Issues.** An issue is expected closed once every row whose `closes` + includes it is done (J6). Then an open issue is `issue-open` on each of + those rows. A row that names an issue in `issues` but not in `closes` is + never checked against it, so rows 9 to 12 can be done while #1508 is open. + A closed issue with a row that closes it still pending is printed as a + `disposition` for the lead. It is not a violation. +- **Owners.** Each owner of an `in-progress` or `in-review` row gets one + liveness class from its `repo` registration in `/seats`: + - `exempt`: declared on this run with `--unsupported-runtime SEAT`. + - `missing`: no registration, or one written for another checkout. + - `invalid`: the registration fails validation, or the config that names + the data root can't be read. A malformed or absent pid fails validation, + so it lands here, not in `pid-unknown`. + - `pid-unknown`: a valid registration with a null pid. + - `pid-gone`: the recorded pid is not running. + - `pid-present`: the pid is running. A registration holds no process-start + identity and a reused pid looks the same, so this reads "pid present + (identity not verified)". + The coverage line counts them: `liveness: N pid-present (unverified), N + exempt, N pid-unknown, N missing, N invalid, N pid-gone`. +- **Age.** A required row that is not done and whose `requiredSince` is more + than 14 whole days before the run is listed by name. `requiredSince` is a + date at genesis and an ISO time once `set required` or `add --required` + writes it; both count from 00:00Z of their UTC day. A value that doesn't + parse as a date is an `age-invalid` violation. A legacy row with + `requiredSince: "unknown"` was required no later than genesis, so once + genesis is more than 14 days old it is listed as `age ≥ N days (legacy lower + bound)`. Before that its age is undecided. Age runs to the time of the run, + not to `--until`. +- **Protected changes.** Every log entry dated inside the report range that + changes a required or parked row is listed with its revision, verb, claimed + actor and rows. The queue trusts `--by` (its README, "Trust boundary"), so + this list is how a wrong claim gets seen. It is not a check and never + changes the result. Jason confirms the actors weekly. + +Every run ends with `queue: N violations; result R`, where R is one of three: + +- `fail`: any violation. That is an open issue, a `missing`, `invalid` or + `pid-gone` owner, or an age. +- `incomplete`: no violation, but something undecided. That is an issue + whose state is unknown, a full open-issue page with some issue left + unknown, issue checks not run, a + `pid-unknown` owner, or a legacy age before genesis is 14 days old. +- `reduced pass`: nothing known and nothing undecided. There is no full pass, + because no owner's process identity is ever verified. + +The exit code stays 0 whenever a report was computed; the result is in the +text and the JSON. + +### Queue issue calls + +The metric call can't answer "is this issue closed", because an issue nobody +touched this week isn't in it. So the queue checks have their own budget, per +plan 8.10: + +| Calls | Purpose | +|---|---| +| 1 | `state=open`, issues only, limit 50, one page. A full page may be short. Issues missing from it are looked up, so it makes the run `incomplete` only while some issue a row closes has no known state (lead decision 40). | +| up to 10 | `GET issues/N` for each issue in some row's `closes` that is neither on the open list nor closed on the metric page. Beyond 10, the issue's state is unknown, "over the lookup budget". | + +With the metric call that is at most 12 calls. `--no-issues` makes none and +prints `queue issue checks: not run`. Closed needs positive evidence: an +entry on the metric page with state `closed` and a `closed_at`, or a lookup +returning a closed issue that isn't a pull request. A failed lookup, a 404, a +pull request or a mismatched number leaves the issue unknown. A failed or +malformed open-list call exits 2, like the metric call. Each queue call runs +under `timeout -s KILL 60`, which kills the helper and its curl together. + +### Weekly routine + +Run the ledger each Monday for the week that ended on Saturday, Sunday +through Saturday as row 7 counts it, with the queue section on and no +`--no-issues`: + +```sh +node packages/ledger/src/cli.mjs --since 2026-09-20 --until 2026-09-26 +``` + +Post the dated run on #1508 with its coverage line and result. Any seat +whose runtime writes no registration (T3 seats today) is passed with +`--unsupported-runtime SEAT`, which the output prints. A violation is +remediated when a second dated run on the same UTC day no longer reports its +(check, row, issue), and both runs are posted on #1508. A row edit alone is +not remediation, and a `note` can't clear an owner finding, because that +check reads registrations. Row 13's gate is one Monday run with zero +violations, or every violation remediated that day. + +## Gitea calls and missing evidence + +The metric client requests issues updated since the start date, all states, +first page, limit 50. This includes issues closed in range, even if later updated. Gitea caps responses at 50; a full page fails rather than silently reporting partial totals. Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked issue not returned by the updated-since query still has a row, with unknown metadata. This is the cost of the brief's one-call boundary. -Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git, -session or T3 evidence. Malformed JSONL, including a partially written last line, +Exit 0 means a report was computed. Exit 1 means bad arguments, a queue.json +the validator refuses, or unreadable git, session or T3 evidence. Malformed JSONL, including a partially written last line, refuses the report; rerun after the seat finishes writing. Exit 2 means issue credentials, API, payload, or completeness failure. The CLI never prints API -error bodies or reads authentication files itself. `--no-issues` makes no API -call, keeps commit-derived rows, and shows unknown issue metadata, closed counts, +error bodies or reads authentication files itself. The queue's open-list call +fails with exit 2 the same way. `--no-issues` makes no API call, keeps commit-derived rows, and shows unknown issue metadata, closed counts, median duration, and human-per-closed ratio. It cannot invent close-only rows. For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the repository scripts directory is appended to PATH for the issue request. -Tests use only temporary repositories, logs, T3 databases and fake API tools, -with no real credentials or network. Every CLI run in the tests sets `HOME` to +Tests use only temporary repositories, logs, T3 databases, queue files, seat +registrations, configs and fake API tools, with no real credentials or network. Every CLI run in the tests sets `HOME` to a temporary directory, so no test opens the real `~/.t3`. The helper regression stubs Node before any credential read and checks successful GET, successful POST, and failed HTTP status. diff --git a/packages/ledger/src/cli.mjs b/packages/ledger/src/cli.mjs index 5cf6705c..a261b8cd 100644 --- a/packages/ledger/src/cli.mjs +++ b/packages/ledger/src/cli.mjs @@ -3,19 +3,29 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs'; import { readT3, defaultT3Path } from './t3.mjs'; +import { readQueue, readSeatsDir, issueStates, queueChecks, formatQueue, checkSeatName } from './queue-checks.mjs'; -const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH]'; +const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues] [--no-t3 | --t3-db PATH] [--no-queue | --unsupported-runtime SEAT ...]'; export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) { - let since, until, t3Db, json = false, noIssues = false, noT3 = false; + let since, until, t3Db, json = false, noIssues = false, noT3 = false, noQueue = false; + const exempt = new Set(); const seen = new Set(); for (let i = 0; i < args.length; i++) { const flag = args[i]; + // A runtime is declared unsupported once per seat, so only that flag repeats. + if (flag === '--unsupported-runtime') { + const seat = checkSeatName(args[++i]); + if (exempt.has(seat)) throw new SourceError(`Duplicate --unsupported-runtime ${seat}`); + exempt.add(seat); + continue; + } if (seen.has(flag)) throw new SourceError(`Duplicate option: ${flag}`); seen.add(flag); if (flag === '--help') { console.log(usage); return; } if (flag === '--json') json = true; else if (flag === '--no-issues') noIssues = true; else if (flag === '--no-t3') noT3 = true; + else if (flag === '--no-queue') noQueue = true; else if (flag === '--t3-db') { t3Db = args[++i]; if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path'); @@ -27,20 +37,28 @@ export async function main(args, root = path.resolve(path.dirname(fileURLToPath( } if (!since) throw new SourceError(usage); if (noT3 && t3Db !== undefined) throw new SourceError('--no-t3 and --t3-db cannot be combined'); + if (noQueue && exempt.size) throw new SourceError('--no-queue and --unsupported-runtime cannot be combined'); const range = dateRange(since, until); + // The queue loads before any Gitea call, so a bad queue.json costs none. + const queue = noQueue ? null : readQueue(root); const commits = readCommits(root, range); // Fixture tools may be placed first on PATH. The repository client is the // default without requiring installation or reading auth material here. const priorPath = process.env.PATH; process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`; - let issues; - try { issues = noIssues ? null : readIssues(root, range); } + let issues, states = null; + try { + issues = noIssues ? null : readIssues(root, range); + if (queue && !noIssues) states = issueStates(root, queue.rows, issues); + } finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; } // T3 is on by default. A missing or unreadable database refuses the report. const t3 = noT3 ? null : await readT3(root, range, t3Db === undefined ? { dbPath: defaultT3Path(), isDefault: true } : { dbPath: t3Db, isDefault: false }); const sessions = mergeSources(await readSessions(root, range), t3); const report = summarize(range, commits, issues, sessions); - console.log(json ? JSON.stringify(report, null, 2) : formatTable(report)); + report.queue = queue ? queueChecks(queue, { issues: states, seats: readSeatsDir(), exempt, range }) : { checked: false }; + // The queue section prints above the weekly table. + console.log(json ? JSON.stringify(report, null, 2) : [...formatQueue(report.queue), formatTable(report)].join('\n')); return report; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/packages/ledger/src/queue-checks.mjs b/packages/ledger/src/queue-checks.mjs new file mode 100644 index 00000000..a6c2e0c4 --- /dev/null +++ b/packages/ledger/src/queue-checks.mjs @@ -0,0 +1,255 @@ +// The queue section (Piece E, #1508; plan 8.10). It reads +// docs/plans/queue.json through the queue's own validator, the owners' +// registrations through the seat package, and Gitea through the same helper +// as the metric call. It writes nothing. The best result is `reduced pass`: +// a registration holds a pid, not a process identity, so no owner is ever +// verified live. +import { spawnSync } from 'node:child_process'; +import { lstatSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { applyEntry, loadDoc, replay, resolvedFromResult, rowsArray, sameJson } from '../../queue/src/queue.mjs'; +import { SEAT_NAME, defaultConfigPath, loadDataRoot, readRegistration, samePath, seatsDir } from '../../seat/src/seat.mjs'; +import { SourceError, clean } from './ledger.mjs'; + +const DAY = 86400000; +export const AGE_LIMIT_DAYS = 14; +export const GITEA_DEADLINE_S = 60; +export const OPEN_LIMIT = 50; +export const LOOKUP_BUDGET = 10; +export const LIVENESS = ['pid-present', 'exempt', 'pid-unknown', 'missing', 'invalid', 'pid-gone']; +const ACTIVE = new Set(['in-progress', 'in-review']); + +export function checkSeatName(seat) { + if (typeof seat !== 'string' || !SEAT_NAME.test(seat)) throw new SourceError(`--unsupported-runtime needs a seat name, not ${JSON.stringify(clean(seat ?? ''))}`); + return seat; +} + +// Any refusal from the validator refuses the report: a queue that doesn't +// load has no rows to check. +export function readQueue(root) { + const file = path.join(root, 'docs/plans/queue.json'); + let bytes; + try { + if (!lstatSync(file).isFile()) throw new Error('not a regular file'); + bytes = readFileSync(file); + } catch { throw new SourceError('Queue unavailable: docs/plans/queue.json is missing or not a regular file; use --no-queue to skip the queue section'); } + try { + const { doc } = loadDoc(bytes); + return { revision: doc.revision, canonicalRoot: doc.canonicalRoot, genesisAt: doc.log[0].at, rows: doc.rows, log: doc.log }; + } catch (error) { throw new SourceError(`Queue unavailable: ${clean(error.message)}`); } +} + +// Signal 0 probe. EPERM still means a process has the pid. +export function pidAlive(pid) { + try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } +} + +// One class per owner seat, from its repo-layout registration for the +// queue's canonical root. A registration for the same seat name written by +// another checkout says nothing about this one, so it counts as missing. +export function classifySeat(seat, { seats, canonicalRoot, exempt, isPidAlive = pidAlive }) { + if (exempt.has(seat)) return { class: 'exempt', detail: 'declared with --unsupported-runtime' }; + if (seats === null) return { class: 'invalid', detail: 'no seats directory: the config is unreadable' }; + let record; + try { record = readRegistration(seats, seat, 'repo'); } catch { return { class: 'invalid', detail: 'the registration does not validate' }; } + if (record === null) return { class: 'missing', detail: 'no repo registration' }; + if (!samePath(record.sessionsDir, path.join(canonicalRoot, '.pi', 'state', seat, 'sessions'))) { + return { class: 'missing', detail: 'the repo registration is for another checkout' }; + } + if (record.pid === null) return { class: 'pid-unknown', detail: 'the registration has no pid' }; + return isPidAlive(record.pid) ? { class: 'pid-present', detail: 'pid present (identity not verified)' } : { class: 'pid-gone', detail: 'the recorded pid is not running' }; +} + +// The seats directory from the system config. A config that can't be read +// makes every non-exempt owner invalid rather than refusing the whole report. +export function readSeatsDir(configPath = defaultConfigPath()) { + try { return seatsDir(loadDataRoot(configPath)); } catch { return null; } +} + +// One helper call under the deadline. `timeout -s KILL` kills the helper's +// process group, curl included, as the queue's review call does; a Node +// timeout would kill the helper and leave curl running. +function gitea(root, apiPath, tool, deadline) { + const r = spawnSync('timeout', ['-s', 'KILL', String(deadline), tool, 'GET', apiPath], { cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); + if (r.error) throw r.error; + if (r.status !== 0) throw Object.assign(new Error('Gitea call failed'), { status: r.status, killed: r.signal === 'SIGKILL' || r.status === 137 }); + return r.stdout; +} +const issueRecord = i => i && Number.isSafeInteger(i.number) && i.number > 0 && (i.state === 'open' || i.state === 'closed'); + +// The open list: one call, one page. A failure refuses the report the way +// the metric call does. A full page may be short, but an issue missing from +// it is looked up, so the page is undecided only while some issue a row +// closes has no known state (lead decision 40). +export function readOpenIssues(root, tool = 'gitea-api.sh', deadline = GITEA_DEADLINE_S) { + let output; + try { output = gitea(root, `repos/mosaicstack/stack/issues?state=open&type=issues&limit=${OPEN_LIMIT}&page=1`, tool, deadline); } + catch (error) { + const reason = error.status === 3 ? 'credentials missing, unreadable, or invalid' : + error.killed ? `no answer within ${deadline} s` : + error.status === 126 || error.status === 127 ? 'gitea-api.sh unavailable' : + error.code === 'ENOENT' ? 'the timeout command is unavailable' : 'credential or Gitea request failure'; + throw new SourceError(`Open issues unavailable: ${reason}; use --no-issues to leave the queue issue checks unrun`, 2); + } + let list; + try { list = JSON.parse(output); } catch { throw new SourceError('Open issues unavailable: invalid Gitea JSON', 2); } + if (!Array.isArray(list) || !list.every(issueRecord)) throw new SourceError('Open issues unavailable: invalid Gitea issue records', 2); + const issues = list.filter(i => !i.pull_request); + if (issues.some(i => i.state !== 'open')) throw new SourceError('Open issues unavailable: the open list holds a closed issue', 2); + return { numbers: new Set(issues.map(i => i.number)), full: list.length >= OPEN_LIMIT }; +} + +// One issue by number. Anything but an issue with that number and a state is +// no evidence: a 404, a pull request, a failed call. +export function lookupIssue(root, number, tool = 'gitea-api.sh', deadline = GITEA_DEADLINE_S) { + let issue; + try { issue = JSON.parse(gitea(root, `repos/mosaicstack/stack/issues/${number}`, tool, deadline)); } + catch { return { state: 'unknown', detail: 'lookup failed' }; } + if (!issueRecord(issue) || issue.number !== number) return { state: 'unknown', detail: 'lookup returned no issue record' }; + if (issue.pull_request) return { state: 'unknown', detail: 'the number is a pull request' }; + return { state: issue.state, detail: 'lookup' }; +} + +// Issue states for every issue a row closes. `metric` is the metric call's +// list (issues only) or null when it wasn't made. Returns the states, the +// number of lookups made, and whether the open list was a full page. +export function issueStates(root, rows, metric, tool = 'gitea-api.sh') { + const wanted = [...new Set(rows.flatMap(r => r.closes))].sort((a, b) => a - b); + const open = readOpenIssues(root, tool); + // Closed evidence needs both fields: a reopened issue off the full open + // page must not count as closed on a stale closed_at. + const closedInMetric = new Set((metric ?? []).filter(i => i.state === 'closed' && i.closed_at).map(i => i.number)); + const states = new Map(); + let lookups = 0; + for (const n of wanted) { + if (open.numbers.has(n)) states.set(n, { state: 'open', detail: 'open list' }); + else if (closedInMetric.has(n)) states.set(n, { state: 'closed', detail: 'metric page' }); + else if (lookups < LOOKUP_BUDGET) { lookups++; states.set(n, lookupIssue(root, n, tool)); } + else states.set(n, { state: 'unknown', detail: 'over the lookup budget' }); + } + return { states, lookups, openListFull: open.full }; +} + +// Every log entry in the report range that changes a required or parked row, +// with the actor it claims, for Jason to confirm weekly. The queue trusts +// `--by` (its README, "Trust boundary"), so this list is the detection. A row +// counts when it is required or parked before or after the entry. The log has +// already replayed in loadDoc, so replaying it again here cannot refuse. +export function protectedChanges(log, range) { + const inRange = e => { const ms = Date.parse(e.at); return ms >= range.start && ms < range.end; }; + const first = log.findIndex(inRange); + if (first === -1) return []; + const guarded = r => r !== undefined && (r.required || r.state === 'parked'); + const changes = []; + let state = first === 0 ? { rows: new Map() } : replay(log.slice(0, first)); + for (let i = first; i < log.length; i++) { + const e = log[i]; + const next = i === 0 ? replay(log.slice(0, 1)) : applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result)).state; + if (inRange(e)) { + const rows = rowsArray(next).filter(r => !sameJson(r, state.rows.get(r.id)) && (guarded(r) || guarded(state.rows.get(r.id)))).map(r => r.id); + if (rows.length) changes.push({ rev: e.rev, op: e.op, verb: e.verb, by: e.by, at: e.at, rows }); + } + state = next; + } + return changes; +} + +const ageDays = (fromMs, now) => Math.floor((now - fromMs) / DAY); +// requiredSince is a date (the genesis form) or an ISO time (`set required` +// and `add --required` write one). Date.parse reads a bare date as 00:00Z, +// and either counts from 00:00Z of its UTC day, so age is whole UTC days +// whatever the hour. The queue validator checks the shape, not the +// calendar, so a value that doesn't parse is NaN. +const requiredDay = v => Math.floor(Date.parse(v) / DAY) * DAY; + +// The four checks. `issues` is null when the issue checks did not run. +// Each finding has an identity (check, row, issue): a second run on the same +// UTC day that no longer reports it is the remediation (plan 8.10). +export function queueChecks(queue, { issues, seats, exempt = new Set(), now = Date.now(), isPidAlive = pidAlive, range = null }) { + const violations = [], undecided = [], dispositions = []; + const add = (list, check, row, issue, message) => list.push({ check, row, issue, message }); + const rows = queue.rows; + + if (issues) { + const closers = new Map(); + for (const r of rows) for (const n of r.closes) closers.set(n, [...(closers.get(n) ?? []), r]); + for (const [n, rs] of [...closers].sort((a, b) => a[0] - b[0])) { + const { state, detail } = issues.states.get(n); + const allDone = rs.every(r => r.state === 'done'); + for (const r of rs.filter(r => r.state === 'done')) { + if (!allDone) continue; + if (state === 'open') add(violations, 'issue-open', r.id, n, `row ${r.id} is done and every row that closes #${n} is done, but #${n} is open`); + else if (state === 'unknown') add(undecided, 'issue-unknown', r.id, n, `row ${r.id} is done; whether #${n} is closed is unknown (${detail})`); + } + if (state === 'closed' && !allDone) { + const pending = rs.filter(r => r.state !== 'done').map(r => r.id); + add(dispositions, 'issue-closed-early', pending[0], n, `#${n} is closed, but row${pending.length > 1 ? 's' : ''} ${pending.join(', ')} that close${pending.length > 1 ? '' : 's'} it ${pending.length > 1 ? 'are' : 'is'} not done`); + } + } + const unresolved = [...issues.states].filter(([, s]) => s.state === 'unknown').map(([n]) => `#${n}`); + if (issues.openListFull && unresolved.length) { + add(undecided, 'open-list-full', null, null, `the open-issue list was a full page of ${OPEN_LIMIT}, and ${unresolved.join(', ')} ${unresolved.length === 1 ? 'has' : 'have'} no known state`); + } + } else { + add(undecided, 'issues-not-run', null, null, 'the issue checks did not run (--no-issues)'); + } + + const owners = new Map(); + for (const r of rows.filter(r => ACTIVE.has(r.state))) owners.set(r.owner, [...(owners.get(r.owner) ?? []), r.id]); + const liveness = []; + for (const [seat, ids] of [...owners].sort((a, b) => a[0].localeCompare(b[0]))) { + const c = classifySeat(seat, { seats, canonicalRoot: queue.canonicalRoot, exempt, isPidAlive }); + liveness.push({ seat, rows: ids, ...c }); + for (const id of ids) { + const message = `row ${id} owner ${seat}: ${c.class}, ${c.detail}`; + if (c.class === 'missing' || c.class === 'invalid' || c.class === 'pid-gone') add(violations, `owner-${c.class}`, id, null, message); + else if (c.class === 'pid-unknown') add(undecided, 'owner-pid-unknown', id, null, message); + } + } + + const genesisDays = ageDays(Date.parse(queue.genesisAt), now); + for (const r of rows.filter(r => r.required && r.state !== 'done')) { + if (r.requiredSince === 'unknown') { + if (genesisDays > AGE_LIMIT_DAYS) add(violations, 'age', r.id, null, `row ${r.id} "${clean(r.piece)}" is required and not done, age ≥ ${genesisDays} days (legacy lower bound)`); + else add(undecided, 'age-unknown', r.id, null, `row ${r.id} is required and not done; its age is unknown until genesis is over ${AGE_LIMIT_DAYS} days old`); + continue; + } + const from = requiredDay(r.requiredSince); + if (!Number.isFinite(from)) { + add(violations, 'age-invalid', r.id, null, `row ${r.id} is required and not done, and its requiredSince ${JSON.stringify(clean(r.requiredSince))} is not a date`); + continue; + } + const days = ageDays(from, now); + if (days > AGE_LIMIT_DAYS) add(violations, 'age', r.id, null, `row ${r.id} "${clean(r.piece)}" is required and not done, ${days} days since ${r.requiredSince}`); + } + + const counts = Object.fromEntries(LIVENESS.map(k => [k, liveness.filter(s => s.class === k).length])); + const result = violations.length ? 'fail' : undecided.length ? 'incomplete' : 'reduced pass'; + return { + checked: true, revision: queue.revision, asOf: new Date(now).toISOString(), result, + violations, undecided, dispositions, + liveness: { counts, seats: liveness, exempt: [...exempt].sort() }, + issueChecks: issues ? { run: true, lookups: issues.lookups, openListFull: issues.openListFull } : { run: false }, + protectedChanges: range && queue.log ? protectedChanges(queue.log, range) : [], + }; +} + +const where = f => [f.row === null ? null : `row ${f.row}`, f.issue === null ? null : `#${f.issue}`].filter(Boolean).join(' '); +export function formatQueue(q) { + if (!q.checked) return ['Queue: not checked (--no-queue)']; + const c = q.liveness.counts; + return [ + `Queue checks: queue.json revision ${q.revision}, as of ${q.asOf}`, + ...q.violations.map(f => `violation ${f.check} ${where(f)}: ${f.message}`), + ...q.undecided.map(f => `undecided ${f.check}${f.row === null && f.issue === null ? '' : ` ${where(f)}`}: ${f.message}`), + ...q.dispositions.map(f => `disposition ${f.check} ${where(f)}: ${f.message}`), + ...q.liveness.seats.map(s => `owner ${s.seat} (row${s.rows.length === 1 ? '' : 's'} ${s.rows.join(', ')}): ${s.class}, ${s.detail}`), + `liveness: ${c['pid-present']} pid-present (unverified), ${c.exempt} exempt, ${c['pid-unknown']} pid-unknown, ${c.missing} missing, ${c.invalid} invalid, ${c['pid-gone']} pid-gone`, + ...(q.liveness.exempt.length ? [`declared unsupported runtime: ${q.liveness.exempt.join(', ')}`] : []), + q.issueChecks.run ? `queue issue checks: open list${q.issueChecks.openListFull ? ' (full page)' : ''}, ${q.issueChecks.lookups} lookup${q.issueChecks.lookups === 1 ? '' : 's'}` : 'queue issue checks: not run', + ...q.protectedChanges.map(c => `protected change rev ${c.rev} ${c.verb} by ${c.by} at ${c.at}: row${c.rows.length === 1 ? '' : 's'} ${c.rows.join(', ')}`), + `protected changes in range: ${q.protectedChanges.length} (not checks; confirm the actors)`, + `queue: ${q.violations.length} violation${q.violations.length === 1 ? '' : 's'}; result ${q.result}`, + '', + ]; +} diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs index 834dbd55..ee48246d 100644 --- a/packages/ledger/tests/ledger.test.mjs +++ b/packages/ledger/tests/ledger.test.mjs @@ -64,13 +64,16 @@ function fixture(t) { mkdirSync(path.join(root, 'agents/alice'), { recursive: true }); mkdirSync(path.join(root, 'agents/bob'), { recursive: true }); cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true }); + // The queue section imports the queue validator and the seat registry. + for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true }); const api = put('bin/gitea-api.sh', '#!/usr/bin/env node\nconst fs=require("fs"); fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+"\\n"); if(process.env.API_FAIL){console.error("secret must not escape");process.exit(Number(process.env.API_FAIL));} process.stdout.write(fs.readFileSync(process.env.ISSUES,"utf8"));\n'); execFileSync('chmod', ['+x', api]); put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', ''); const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } }); const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }]; put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n'); - const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } }); + // These tests cover the weekly table; queue-checks.test.mjs covers the queue section. + const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', '--no-queue', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } }); return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs }; } test('fixture git subjects only, follow-ups and three session kinds', t => { @@ -103,7 +106,7 @@ test('missing credentials exit 2, no-issues never calls API and shows unknown', test('empty range gives no rows and zero totals', t => { const f = fixture(t); f.put('issues.json', '[]'); - const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } }); + const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json', '--no-queue'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } }); assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout); assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0)); }); diff --git a/packages/ledger/tests/queue-checks.test.mjs b/packages/ledger/tests/queue-checks.test.mjs new file mode 100644 index 00000000..86a58ae6 --- /dev/null +++ b/packages/ledger/tests/queue-checks.test.mjs @@ -0,0 +1,449 @@ +// The ledger's queue section (Piece E, #1508; plan 8.10). The checks run +// in-process on fixture rows with an injected clock and pid probe. The CLI +// runs in a scratch repository whose queue.json the real queue writer made, +// against a fake gitea-api.sh. No test reads a real credential, registration +// or config: every run sets HOME and MOSAIC_CONFIG to temporary paths. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { queueChecks, formatQueue, issueStates, readQueue, readOpenIssues, lookupIssue, classifySeat, pidAlive, protectedChanges, LOOKUP_BUDGET } from '../src/queue-checks.mjs'; +import { writeRegistration } from '../../seat/src/seat.mjs'; +import { scratchRepo, genesisCommitted, mapText, MAP_ROWS, cli as queueCli } from '../../queue/tests/helpers.mjs'; + +const PACKAGES = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const DAY = 86400000; +const NOW = Date.parse('2026-09-28T09:00:00Z'); +const ROOT = '/srv/checkout'; + +const row = (id, over = {}) => ({ id, piece: `Row ${id}`, owner: 'alice', issues: [], closes: [], state: 'briefed', required: false, requiredSince: null, ...over }); +const queue = (rows, genesisAt = '2026-09-27T01:00:00Z') => ({ revision: 7, canonicalRoot: ROOT, genesisAt, rows }); +const states = (map, { lookups = 0, openListFull = false } = {}) => ({ + states: new Map(Object.entries(map).map(([n, state]) => [Number(n), { state, detail: state === 'unknown' ? 'lookup failed' : 'lookup' }])), + lookups, openListFull, +}); +// Seats directory with one registration per entry: [seat, pid, root]. +function seatsWith(t, entries) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-seats-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + for (const [seat, pid, root = ROOT] of entries) { + writeRegistration(dir, { version: 1, seat, project: 'checkout', task: '', workspace: root, tmux: null, harness: 'pi', startedAt: '2026-09-27T00:00:00.000Z', + pid, sessionsDir: path.join(root, '.pi/state', seat, 'sessions'), seatDir: path.join(root, 'agents', seat), launchScript: path.join(root, 'agents', seat, 'launch.sh'), layout: 'repo', updatedAt: null }); + } + return dir; +} +const run = (rows, { issues = states({}), seats = null, exempt = [], genesisAt, isPidAlive = () => true } = {}) => + queueChecks(queue(rows, genesisAt), { issues, seats, exempt: new Set(exempt), now: NOW, isPidAlive }); +const ids = list => list.map(f => [f.check, f.row, f.issue]); + +test('a done row whose closing issue is open is a violation; a row that is not done is not', () => { + const rows = [row(1, { state: 'done', issues: [101], closes: [101] }), row(2, { state: 'briefed', issues: [102], closes: [102] })]; + const open = run(rows, { issues: states({ 101: 'open', 102: 'open' }) }); + assert.deepEqual(ids(open.violations), [['issue-open', 1, 101]]); + assert.equal(open.result, 'fail'); + const closed = run(rows, { issues: states({ 101: 'closed', 102: 'open' }) }); + assert.deepEqual([closed.violations, closed.undecided, closed.dispositions], [[], [], []]); + assert.equal(closed.result, 'reduced pass'); +}); + +test('an issue several rows close is expected closed only once all of them are done', () => { + const rows = [row(1, { state: 'done', issues: [200], closes: [200] }), row(2, { state: 'in-progress', owner: 'bob', issues: [200], closes: [200] })]; + const exempt = ['bob']; + assert.deepEqual(ids(run(rows, { issues: states({ 200: 'open' }), exempt }).violations), []); + const early = run(rows, { issues: states({ 200: 'closed' }), exempt }); + assert.deepEqual(ids(early.dispositions), [['issue-closed-early', 2, 200]]); + assert.match(early.dispositions[0].message, /^#200 is closed, but row 2 that closes it is not done$/); + assert.equal(early.result, 'reduced pass', 'a disposition is printed, not counted'); + const both = [rows[0], { ...rows[1], state: 'done' }]; + assert.deepEqual(ids(run(both, { issues: states({ 200: 'open' }) }).violations), [['issue-open', 1, 200], ['issue-open', 2, 200]]); + // Rows 9 to 12 name #1508 without closing it: done while it is open is fine. + const named = [row(9, { state: 'done', issues: [1508], closes: [] }), row(13, { state: 'briefed', issues: [1508], closes: [1508] })]; + const r = run(named, { issues: states({ 1508: 'open' }) }); + assert.deepEqual([r.violations, r.result], [[], 'reduced pass']); +}); + +test('closure needs positive evidence: unknown is undecided, and so is a skipped or short issue check', () => { + const rows = [row(1, { state: 'done', issues: [101], closes: [101] })]; + const unknown = run(rows, { issues: states({ 101: 'unknown' }) }); + assert.deepEqual([ids(unknown.undecided), unknown.result], [[['issue-unknown', 1, 101]], 'incomplete']); + assert.match(unknown.undecided[0].message, /whether #101 is closed is unknown \(lookup failed\)/); + const skipped = run(rows, { issues: null }); + assert.deepEqual([ids(skipped.undecided), skipped.result, skipped.issueChecks], [[['issues-not-run', null, null]], 'incomplete', { run: false }]); + // A full page is undecided only while some issue a row closes is unknown. + const full = run(rows, { issues: states({ 101: 'closed' }, { openListFull: true }) }); + assert.deepEqual([full.undecided, full.result], [[], 'reduced pass']); + const short = run([...rows, row(2, { issues: [102], closes: [102] })], { issues: states({ 101: 'closed', 102: 'unknown' }, { openListFull: true }) }); + assert.deepEqual([ids(short.undecided), short.result], [[['open-list-full', null, null]], 'incomplete']); + assert.match(short.undecided[0].message, /full page of 50, and #102 has no known state$/); +}); + +test('each owner of an in-progress or in-review row gets one liveness class', t => { + const other = '/srv/another-clone'; + const seats = seatsWith(t, [['present', 101], ['gone', 102], ['nopid', null], ['elsewhere', 103, other], ['exempted', 104]]); + mkdirSync(path.join(seats, 'repo', 'broken'), { recursive: true }); + writeFileSync(path.join(seats, 'repo', 'broken', 'registration.json'), '{not json'); + const owners = ['present', 'gone', 'nopid', 'elsewhere', 'exempted', 'broken', 'absent']; + const rows = [...owners.map((owner, i) => row(i + 1, { owner, state: i % 2 ? 'in-review' : 'in-progress' })), + row(20, { owner: 'idle', state: 'briefed' }), row(21, { owner: 'finished', state: 'done' }), row(22, { owner: 'present', state: 'in-progress' })]; + const r = run(rows, { seats, exempt: ['exempted'], isPidAlive: pid => pid !== 102 }); + assert.deepEqual(r.liveness.seats.map(s => [s.seat, s.class, s.rows]), [ + ['absent', 'missing', [7]], ['broken', 'invalid', [6]], ['elsewhere', 'missing', [4]], ['exempted', 'exempt', [5]], + ['gone', 'pid-gone', [2]], ['nopid', 'pid-unknown', [3]], ['present', 'pid-present', [1, 22]], + ]); + assert.deepEqual(r.liveness.counts, { 'pid-present': 1, exempt: 1, 'pid-unknown': 1, missing: 2, invalid: 1, 'pid-gone': 1 }); + assert.deepEqual(ids(r.violations).sort(), [['owner-invalid', 6, null], ['owner-missing', 4, null], ['owner-missing', 7, null], ['owner-pid-gone', 2, null]].sort()); + assert.deepEqual(ids(r.undecided), [['owner-pid-unknown', 3, null]]); + assert.match(r.violations.find(f => f.row === 4).message, /another checkout/); + // Without a readable config there is no seats directory: invalid, never missing. + assert.deepEqual(classifySeat('present', { seats: null, canonicalRoot: ROOT, exempt: new Set() }).class, 'invalid'); + // Nothing active, nothing checked: exempt seats with no rows add no count. + const idle = run([row(1)], { seats, exempt: ['present'] }); + assert.deepEqual([idle.liveness.seats, idle.liveness.exempt, idle.result], [[], ['present'], 'reduced pass']); +}); + +test('a required row not done after 14 days is a violation; a legacy row uses genesis as its lower bound', () => { + const since = days => new Date(NOW - days * DAY).toISOString().slice(0, 10); + const rows = [ + row(1, { required: true, requiredSince: since(15) }), row(2, { required: true, requiredSince: since(14) }), + row(3, { required: true, requiredSince: since(40), state: 'done' }), row(4, { required: false, state: 'waiting-on-jason' }), + ]; + const r = run(rows); + assert.deepEqual(ids(r.violations), [['age', 1, null]]); + assert.match(r.violations[0].message, /^row 1 "Row 1" is required and not done, 15 days since \d{4}-\d{2}-\d{2}$/); + const legacy = [row(5, { required: true, requiredSince: 'unknown', state: 'waiting-on-jason' })]; + const old = run(legacy, { genesisAt: new Date(NOW - 20 * DAY).toISOString() }); + assert.deepEqual(ids(old.violations), [['age', 5, null]]); + assert.match(old.violations[0].message, /age ≥ 20 days \(legacy lower bound\)$/); + const edge = run(legacy, { genesisAt: new Date(NOW - 14 * DAY).toISOString() }); + assert.deepEqual([ids(edge.violations), ids(edge.undecided)], [[], [['age-unknown', 5, null]]], 'exactly 14 days is not over 14'); + const young = run(legacy, { genesisAt: new Date(NOW - 3 * DAY).toISOString() }); + assert.deepEqual([ids(young.undecided), young.result], [[['age-unknown', 5, null]], 'incomplete']); +}); + +test('an ISO requiredSince, as `set required` writes it, ages from its UTC day; one that does not parse is a violation', () => { + const at = ms => new Date(ms).toISOString(); + const rows = [ + row(1, { required: true, requiredSince: at(NOW - 15 * DAY) }), row(2, { required: true, requiredSince: at(NOW - 14 * DAY) }), + // 23:59Z fifteen days back is still that day: 15 whole days, not 14. + row(3, { required: true, requiredSince: '2026-09-13T23:59:59.999Z' }), + ]; + const r = run(rows); + assert.deepEqual(ids(r.violations), [['age', 1, null], ['age', 3, null]]); + assert.match(r.violations[0].message, /^row 1 "Row 1" is required and not done, 15 days since 2026-09-13T09:00:00\.000Z$/); + // The queue validator checks the shape, not the calendar. + const bad = run([row(4, { required: true, requiredSince: '2026-13-01T00:00:00.000Z' })]); + assert.deepEqual([ids(bad.violations), bad.result], [[['age-invalid', 4, null]], 'fail']); + assert.match(bad.violations[0].message, /^row 4 is required and not done, and its requiredSince "2026-13-01T00:00:00\.000Z" is not a date$/); +}); + +test('the text section always ends in a count and a result, and never prints a full pass', () => { + const clean = formatQueue(run([row(1)])); + assert.deepEqual(clean.slice(-4), ['queue issue checks: open list, 0 lookups', 'protected changes in range: 0 (not checks; confirm the actors)', 'queue: 0 violations; result reduced pass', '']); + assert.ok(clean.includes('liveness: 0 pid-present (unverified), 0 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone')); + const bad = formatQueue(run([row(1, { state: 'done', issues: [101], closes: [101] })], { issues: states({ 101: 'open' }, { lookups: 1 }) })); + assert.ok(bad.includes('violation issue-open row 1 #101: row 1 is done and every row that closes #101 is done, but #101 is open')); + assert.deepEqual(bad.slice(-4), ['queue issue checks: open list, 1 lookup', 'protected changes in range: 0 (not checks; confirm the actors)', 'queue: 1 violation; result fail', '']); + assert.deepEqual(formatQueue({ checked: false }), ['Queue: not checked (--no-queue)']); + assert.ok(formatQueue(run([row(1)], { issues: null })).includes('queue issue checks: not run')); +}); + +test('pidAlive: a running pid is present, an exited one is gone, and EPERM still means present', () => { + assert.equal(pidAlive(process.pid), true); + const child = spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.pid))'], { encoding: 'utf8' }); + assert.equal(pidAlive(Number(child.stdout)), false); + // pid 1 belongs to root: signal 0 from another user fails with EPERM. + if (process.getuid() !== 0) assert.equal(pidAlive(1), true); +}); + +// A fake helper that answers the open list from OPEN, an issue from +// ISSUE_DIR/N.json (exit 1 when absent, like a 404), and logs every call. +function fakeTool(t) { + const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-fake-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const tool = path.join(dir, 'gitea-api.sh'); + writeFileSync(tool, `#!/usr/bin/env node +const fs = require('fs'), p = process.argv[3]; +fs.appendFileSync(process.env.CALLS, JSON.stringify(process.argv.slice(2)) + '\\n'); +if (process.env.API_FAIL) { console.error('secret must not escape'); process.exit(Number(process.env.API_FAIL)); } +if (p.includes('state=open')) { if (process.env.OPEN_FAIL) process.exit(Number(process.env.OPEN_FAIL)); process.stdout.write(fs.readFileSync(process.env.OPEN, 'utf8')); } +else if (/issues\\/\\d+$/.test(p)) { const f = process.env.ISSUE_DIR + '/' + p.split('/').pop() + '.json'; if (!fs.existsSync(f)) process.exit(1); process.stdout.write(fs.readFileSync(f, 'utf8')); } +else process.stdout.write(fs.readFileSync(process.env.ISSUES, 'utf8')); +`); + chmodSync(tool, 0o755); + mkdirSync(path.join(dir, 'issues')); + const env = { CALLS: path.join(dir, 'calls.jsonl'), OPEN: path.join(dir, 'open.json'), ISSUE_DIR: path.join(dir, 'issues'), ISSUES: path.join(dir, 'metric.json') }; + writeFileSync(env.CALLS, ''); writeFileSync(env.OPEN, '[]'); writeFileSync(env.ISSUES, '[]'); + const issue = (n, over = {}) => writeFileSync(path.join(env.ISSUE_DIR, `${n}.json`), JSON.stringify({ number: n, title: `Issue ${n}`, state: 'closed', ...over })); + const calls = () => readFileSync(env.CALLS, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)[1]); + return { dir, tool, env, issue, calls }; +} +const openIssue = n => ({ number: n, title: `Issue ${n}`, state: 'open' }); + +test('issue states: open list first, then the metric page, then at most 10 lookups', t => { + const f = fakeTool(t); + Object.assign(process.env, f.env); + t.after(() => { for (const k of Object.keys(f.env)) delete process.env[k]; }); + writeFileSync(f.env.OPEN, JSON.stringify([openIssue(1), { ...openIssue(2), pull_request: {} }])); + const closes = [1, 2, 3, ...Array.from({ length: 12 }, (_, i) => 10 + i)]; + const rows = closes.map(n => row(n, { issues: [n], closes: [n] })); + f.issue(10); f.issue(11, { state: 'open' }); f.issue(12, { pull_request: { merged: true } }); f.issue(13, { number: 99 }); + // #10 is on the metric page but not closed there, so it still needs a lookup. + // #11 was reopened: a closed_at without state closed is no evidence either, + // and neither is state closed without a closed_at (#12). + const metric = [{ number: 3, state: 'closed', closed_at: '2026-09-20T00:00:00Z' }, { number: 10, state: 'open', closed_at: null }, + { number: 11, state: 'open', closed_at: '2026-09-21T00:00:00Z' }, { number: 12, state: 'closed', closed_at: null }]; + const r = issueStates('/', rows, metric, f.tool); + const state = n => r.states.get(n); + assert.deepEqual([state(1).state, state(3), state(10), state(11).state], ['open', { state: 'closed', detail: 'metric page' }, { state: 'closed', detail: 'lookup' }, 'open']); + assert.deepEqual(state(2), { state: 'unknown', detail: 'lookup failed' }); + assert.deepEqual([state(12).detail, state(13).detail, state(14).detail], ['the number is a pull request', 'lookup returned no issue record', 'lookup failed']); + assert.equal(r.lookups, LOOKUP_BUDGET); + assert.deepEqual([state(20), state(21)], [{ state: 'unknown', detail: 'over the lookup budget' }, { state: 'unknown', detail: 'over the lookup budget' }]); + // The open list is one call; a pull request in it is not an issue, so #2 is looked up. + const calls = f.calls(); + assert.equal(calls.length, 1 + LOOKUP_BUDGET); + assert.match(calls[0], /^repos\/mosaicstack\/stack\/issues\?state=open&type=issues&limit=50&page=1$/); + assert.deepEqual(calls.slice(1), [2, 10, 11, 12, 13, 14, 15, 16, 17, 18].map(n => `repos/mosaicstack/stack/issues/${n}`)); + assert.equal(r.openListFull, false); + writeFileSync(f.env.OPEN, JSON.stringify(Array.from({ length: 50 }, (_, i) => openIssue(500 + i)))); + assert.equal(issueStates('/', [row(1, { issues: [500], closes: [500] })], null, f.tool).openListFull, true); +}); + +test('a full open list: lookups settle what it leaves out, and only an unsettled issue keeps it undecided', t => { + const f = fakeTool(t); + Object.assign(process.env, f.env); + t.after(() => { for (const k of Object.keys(f.env)) delete process.env[k]; }); + writeFileSync(f.env.OPEN, JSON.stringify(Array.from({ length: 50 }, (_, i) => openIssue(500 + i)))); + const check = rows => queueChecks(queue(rows), { issues: issueStates('/', rows, null, f.tool), seats: null, now: NOW }); + // Every wanted issue resolved: #600 closed by lookup, #501 open on the page for a pending row. + f.issue(600); + const resolved = check([row(1, { state: 'done', issues: [600], closes: [600] }), row(2, { issues: [501], closes: [501] })]); + assert.deepEqual([resolved.issueChecks, resolved.undecided, resolved.violations, resolved.result], [{ run: true, lookups: 1, openListFull: true }, [], [], 'reduced pass']); + assert.ok(formatQueue(resolved).includes('queue issue checks: open list (full page), 1 lookup')); + // An open issue off the page is found by lookup, and its done row fails. + f.issue(601, { state: 'open' }); + const offPage = check([row(1, { state: 'done', issues: [601], closes: [601] })]); + assert.deepEqual([ids(offPage.violations), offPage.undecided, offPage.result], [[['issue-open', 1, 601]], [], 'fail']); + // Past the budget an issue stays unknown, and the full page stays undecided. + const many = Array.from({ length: LOOKUP_BUDGET + 1 }, (_, i) => 700 + i); + for (const n of many) f.issue(n); + const budget = check(many.map(n => row(n, { state: 'done', issues: [n], closes: [n] }))); + assert.deepEqual(ids(budget.undecided), [['issue-unknown', 710, 710], ['open-list-full', null, null]]); + assert.match(budget.undecided[0].message, /is unknown \(over the lookup budget\)$/); + assert.equal(budget.result, 'incomplete'); +}); + +test('the open list refuses on a failed call or a bad record, and never echoes the helper', t => { + const f = fakeTool(t); + Object.assign(process.env, f.env); + t.after(() => { for (const k of [...Object.keys(f.env), 'OPEN_FAIL', 'API_FAIL']) delete process.env[k]; }); + const rows = [row(1, { issues: [1], closes: [1] })]; + const refuses = (pattern) => assert.throws(() => issueStates('/', rows, null, f.tool), e => e.exitCode === 2 && pattern.test(e.message) && !/secret/.test(e.message)); + process.env.API_FAIL = '3'; refuses(/credentials missing, unreadable, or invalid/); delete process.env.API_FAIL; + process.env.OPEN_FAIL = '1'; refuses(/credential or Gitea request failure/); delete process.env.OPEN_FAIL; + for (const bad of ['nope', '{}', '[{"number":0,"state":"open"}]', '[{"number":1,"state":"merged"}]', '[{"number":1,"state":"closed"}]']) { + writeFileSync(f.env.OPEN, bad); + refuses(/Open issues unavailable/); + } +}); + +test('a helper call past the deadline is killed with its child, and the call reports it', t => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-slow-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + // The helper starts a child, as gitea-api.sh starts curl, and both hang. + const tool = path.join(dir, 'gitea-api.sh'); + writeFileSync(tool, `#!/usr/bin/env node +const { spawn } = require('child_process'); +const child = spawn('sleep', ['30'], { stdio: 'ignore' }); +require('fs').appendFileSync('${dir}/pids', child.pid + '\\n'); +setTimeout(() => {}, 30000); +`); + chmodSync(tool, 0o755); + const started = Date.now(); + assert.throws(() => readOpenIssues('/', tool, 1), e => e.exitCode === 2 && /^Open issues unavailable: no answer within 1 s; use --no-issues/.test(e.message)); + assert.deepEqual(lookupIssue('/', 5, tool, 1), { state: 'unknown', detail: 'lookup failed' }); + assert.ok(Date.now() - started < 10000, 'each call returns at its deadline'); + const pids = readFileSync(path.join(dir, 'pids'), 'utf8').split('\n').filter(Boolean).map(Number); + assert.equal(pids.length, 2); + // Give init a moment to reap the killed children. + const until = Date.now() + 3000; + while (pids.some(pidAlive) && Date.now() < until) spawnSync('sleep', ['0.1']); + assert.deepEqual(pids.filter(pidAlive), [], 'no child outlives the call'); + assert.throws(() => readOpenIssues('/', path.join(dir, 'absent'), 1), /Open issues unavailable: gitea-api\.sh unavailable;/); +}); + +test('readQueue loads queue.json through the queue validator and refuses anything else', t => { + const repo = scratchRepo(t); + genesisCommitted(repo); + const q = readQueue(repo.root); + assert.deepEqual([q.revision, q.canonicalRoot, q.rows.map(r => r.id)], [0, repo.root, [1, 6, 8, 9, 11]]); + assert.match(q.genesisAt, /^\d{4}-\d{2}-\d{2}T/); + const text = readFileSync(repo.queuePath, 'utf8'); + writeFileSync(repo.queuePath, text.replace('"state": "briefed"', '"state": "done"')); + assert.throws(() => readQueue(repo.root), /Queue unavailable: .*hand edit/); + rmSync(repo.queuePath); + assert.throws(() => readQueue(repo.root), /missing or not a regular file; use --no-queue/); + writeFileSync(path.join(repo.base, 'elsewhere.json'), text); + symlinkSync(path.join(repo.base, 'elsewhere.json'), repo.queuePath); + assert.throws(() => readQueue(repo.root), /missing or not a regular file/); +}); + +test('protected changes list every in-range entry that changes a required or parked row', t => { + const repo = scratchRepo(t); + genesisCommitted(repo); + // Row 9 is required, row 6 is neither required nor parked. + for (const [id, op] of [[9, 'note-row9-0001'], [6, 'note-row6-0001']]) { + const r = queueCli(repo, ['note', String(id), 'text', '--op', op], { by: 'darkwing' }); + assert.equal(r.code, 0, r.err); + } + // Unparking row 8 leaves it neither parked nor required: the row before + // the entry makes it protected. + const unpark = queueCli(repo, ['move', '8', 'queued', '--op', 'move-row8-0001'], { by: 'jason' }); + assert.equal(unpark.code, 0, unpark.err); + const { log } = readQueue(repo.root); + const all = protectedChanges(log, { start: 0, end: Infinity }); + assert.deepEqual(all.map(c => [c.rev, c.verb, c.by, c.rows]), [[0, 'genesis', 'sage', [8, 9]], [1, 'note', 'darkwing', [9]], [3, 'move', 'jason', [8]]]); + assert.equal(all[1].op, 'note-row9-0001'); + // The range includes its start and excludes its end. A range after + // genesis replays up to its first entry and starts there. + assert.ok(Date.parse(log[0].at) < Date.parse(log[1].at) && Date.parse(log[1].at) < Date.parse(log[2].at)); + assert.deepEqual(protectedChanges(log, { start: Date.parse(log[1].at), end: Infinity }).map(c => c.rev), [1, 3]); + assert.deepEqual(protectedChanges(log, { start: 0, end: Date.parse(log[1].at) }).map(c => c.rev), [0]); + assert.deepEqual(protectedChanges(log, { start: Date.parse(log[1].at), end: Date.parse(log[2].at) }).map(c => c.rev), [1]); + assert.deepEqual(protectedChanges(log, { start: 0, end: Date.parse(log[0].at) }), []); + const q = queueChecks(readQueue(repo.root), { issues: null, seats: null, exempt: new Set(['darkwing']), now: NOW, range: { start: 0, end: Infinity } }); + const text = formatQueue(q); + assert.ok(text.includes('protected change rev 1 note by darkwing at ' + log[1].at + ': row 9')); + assert.ok(text.includes('protected changes in range: 3 (not checks; confirm the actors)')); + assert.equal(q.result, 'incomplete', 'the list is not a finding'); +}); + +// A scratch repository with a committed queue, the ledger and the seat code, +// a config whose dataRoot holds the registrations, and a fake helper. +const MAP = [ + { id: 1, piece: 'Closed and done', owner: 'darkwing', issues: [101], closes: [101], state: 'done', required: false, requiredSince: null, brief: null }, + { id: 2, piece: 'Done but open', owner: 'darkwing', issues: [102], closes: [102], state: 'done', required: false, requiredSince: null, brief: null }, + { id: 3, piece: 'Active and old', owner: 'dewey', issues: [103], closes: [103], state: 'in-progress', required: true, requiredSince: '2020-01-01', brief: { path: 'docs/plans/brief-a.md', anchor: 'Row six' } }, + { id: 4, piece: 'Active and registered', owner: 'rocko', issues: [103], closes: [], state: 'in-progress', required: false, requiredSince: null, brief: { path: 'docs/plans/brief-a.md', anchor: 'Row six' } }, +].map(r => { + const full = { previousState: null, gate: 'g', gateOwner: 'jason', after: [], reviewers: [], note: null, blockedReason: null, createdAt: '2026-09-13', ...r }; + // The map wants its keys in the queue's order. + return Object.fromEntries(Object.keys(MAP_ROWS[0]).map(k => [k, full[k]])); +}); +function cliFixture(t, rows = MAP) { + const repo = scratchRepo(t, { map: mapText(rows, { retired: [], highWater: Math.max(...rows.map(r => r.id)) }) }); + genesisCommitted(repo); + for (const pkg of ['ledger', 'seat']) cpSync(path.join(PACKAGES, pkg, 'src'), path.join(repo.root, 'packages', pkg, 'src'), { recursive: true }); + const dataRoot = path.join(repo.base, 'data'); + const config = path.join(repo.base, 'config.json'); + writeFileSync(config, JSON.stringify({ dataRoot })); + const f = fakeTool(t); + f.issue(101); + writeFileSync(f.env.OPEN, JSON.stringify([openIssue(102), openIssue(103)])); + writeFileSync(f.env.ISSUES, '[]'); + const seats = path.join(dataRoot, 'seats'); + mkdirSync(seats, { recursive: true }); + writeRegistration(seats, { version: 1, seat: 'rocko', project: 'repo', task: '', workspace: repo.root, tmux: null, harness: 'pi', startedAt: '2026-09-27T00:00:00.000Z', + pid: process.pid, sessionsDir: path.join(repo.root, '.pi/state/rocko/sessions'), seatDir: path.join(repo.root, 'agents/rocko'), launchScript: path.join(repo.root, 'agents/rocko/launch.sh'), layout: 'repo', updatedAt: null }); + const run = (args = [], env = {}, { until = '2026-09-26' } = {}) => { + writeFileSync(f.env.CALLS, ''); + const r = spawnSync(process.execPath, [path.join(repo.root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-20', '--until', until, '--no-t3', ...args], + { cwd: repo.root, encoding: 'utf8', env: { ...repo.env, PATH: `${f.dir}:${process.env.PATH}`, MOSAIC_CONFIG: config, ...f.env, ...env } }); + return { ...r, calls: f.calls() }; + }; + return { repo, f, run, config }; +} + +test('the CLI prints the queue section above the weekly table and under a queue key in --json', t => { + const { run } = cliFixture(t); + const text = run(['--unsupported-runtime', 'dewey']); + assert.equal(text.status, 0, text.stderr); + const lines = text.stdout.split('\n'); + assert.match(lines[0], /^Queue checks: queue\.json revision 0, as of \d{4}-/); + assert.ok(lines.indexOf('queue: 2 violations; result fail') < lines.findIndex(l => l.startsWith('Ledger 2026-09-20'))); + assert.ok(lines.includes('violation issue-open row 2 #102: row 2 is done and every row that closes #102 is done, but #102 is open')); + assert.ok(lines.some(l => /^violation age row 3: row 3 "Active and old" is required and not done, \d+ days since 2020-01-01$/.test(l))); + assert.ok(lines.includes('owner dewey (row 3): exempt, declared with --unsupported-runtime')); + assert.ok(lines.includes('owner rocko (row 4): pid-present, pid present (identity not verified)')); + assert.ok(lines.includes('liveness: 1 pid-present (unverified), 1 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone')); + assert.ok(lines.includes('queue issue checks: open list, 1 lookup')); + // Metric page, open list, one lookup for #101. + assert.deepEqual(text.calls.map(c => c.replace(/\?.*/, '?')), ['repos/mosaicstack/stack/issues?', 'repos/mosaicstack/stack/issues?', 'repos/mosaicstack/stack/issues/101']); + const json = JSON.parse(run(['--json', '--unsupported-runtime', 'dewey']).stdout); + assert.deepEqual([json.queue.result, json.queue.violations.map(v => [v.check, v.row, v.issue])], ['fail', [['issue-open', 2, 102], ['age', 3, null]]]); + // Genesis is dated today, outside the requested week. + assert.deepEqual(json.queue.protectedChanges, []); + const week = JSON.parse(run(['--json', '--no-issues'], {}, { until: new Date().toISOString().slice(0, 10) }).stdout).queue; + assert.deepEqual(week.protectedChanges.map(c => [c.rev, c.verb, c.rows]), [[0, 'genesis', [3]]]); + // Without the declaration, dewey has no registration. + const missing = JSON.parse(run(['--json']).stdout).queue; + assert.deepEqual(missing.violations.map(v => v.check), ['issue-open', 'owner-missing', 'age']); +}); + +test('a queue with nothing wrong prints 0 violations and a reduced pass, never a full pass', t => { + const { run } = cliFixture(t, [MAP[0], MAP[3]]); + const r = run(); + assert.equal(r.status, 0, r.stderr); + const lines = r.stdout.split('\n'); + assert.ok(lines.includes('queue: 0 violations; result reduced pass')); + assert.ok(lines.includes('liveness: 1 pid-present (unverified), 0 exempt, 0 pid-unknown, 0 missing, 0 invalid, 0 pid-gone')); + assert.ok(!lines.some(l => /^(violation|undecided|disposition) /.test(l))); + assert.equal(r.calls.length, 3, 'metric page, open list, one lookup'); +}); + +test('--no-issues makes no call and leaves the issue checks undecided; --no-queue skips the section', t => { + const { run } = cliFixture(t); + const none = run(['--no-issues', '--json']); + assert.equal(none.status, 0, none.stderr); + assert.deepEqual(none.calls, []); + assert.deepEqual(JSON.parse(none.stdout).queue.issueChecks, { run: false }); + const skipped = run(['--no-queue']); + assert.equal(skipped.status, 0, skipped.stderr); + assert.match(skipped.stdout, /^Queue: not checked \(--no-queue\)\nLedger 2026-09-20/); + assert.equal(skipped.calls.length, 1, 'the metric call only'); + assert.deepEqual(JSON.parse(run(['--no-queue', '--json']).stdout).queue, { checked: false }); +}); + +test('the CLI refuses a bad queue before any call, and a failed open list with exit 2', t => { + const { repo, run } = cliFixture(t); + const refusedOpen = run([], { OPEN_FAIL: '1' }); + assert.deepEqual([refusedOpen.status, refusedOpen.stdout], [2, '']); + assert.match(refusedOpen.stderr, /^Open issues unavailable: credential or Gitea request failure; use --no-issues/); + const text = readFileSync(repo.queuePath, 'utf8'); + writeFileSync(repo.queuePath, text.replace('"Done but open"', '"Done, hand edited"')); + const edited = run(); + assert.deepEqual([edited.status, edited.stdout, edited.calls], [1, '', []]); + assert.match(edited.stderr, /^Queue unavailable: /); + rmSync(repo.queuePath); + const gone = run(); + assert.deepEqual([gone.status, gone.calls], [1, []]); + assert.match(gone.stderr, /use --no-queue to skip the queue section/); +}); + +test('--unsupported-runtime repeats once per seat and takes a seat name', t => { + const { run } = cliFixture(t); + const two = JSON.parse(run(['--json', '--no-issues', '--unsupported-runtime', 'dewey', '--unsupported-runtime', 'rocko']).stdout).queue; + assert.deepEqual([two.liveness.exempt, two.liveness.counts.exempt], [['dewey', 'rocko'], 2]); + for (const [args, message] of [ + [['--unsupported-runtime', 'dewey', '--unsupported-runtime', 'dewey'], /^Duplicate --unsupported-runtime dewey\n$/], + [['--unsupported-runtime'], /needs a seat name/], + [['--unsupported-runtime', '--json'], /needs a seat name/], + [['--unsupported-runtime', 'Dewey'], /needs a seat name/], + [['--no-queue', '--unsupported-runtime', 'dewey'], /cannot be combined/], + ]) { + const r = run(args); + assert.equal(r.status, 1, args.join(' ')); + assert.match(r.stderr, message); + assert.deepEqual([r.stdout, r.calls], ['', []]); + } +}); + +test('an unreadable config makes every owner invalid instead of passing them', t => { + const { run } = cliFixture(t); + const r = JSON.parse(run(['--json', '--no-issues'], { MOSAIC_CONFIG: '/nonexistent/config.json' }).stdout).queue; + assert.deepEqual(r.liveness.seats.map(s => [s.seat, s.class]), [['dewey', 'invalid'], ['rocko', 'invalid']]); + assert.equal(r.result, 'fail'); +});