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 <[email protected]>
This commit is contained in:
2026-09-27 11:33:44 -05:00
co-authored by Claude Opus 5.5
parent 2333d837e2
commit fd72d26899
9 changed files with 1421 additions and 20 deletions
+285
View File
@@ -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 <check> <row> <#issue>:
<message>`, 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
```
@@ -137,6 +137,10 @@ Changed:
`packages/ledger`, so A leaves a one-line hand-written pointer to the `packages/ledger`, so A leaves a one-line hand-written pointer to the
weekly routine below the markers. E moves the routine into weekly routine below the markers. E moves the routine into
`packages/ledger/README.md` and removes the pointer. `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 - `docs/TOOLS.md`: usage lines. This file carries other owners' uncommitted
changes; add a scoped patch the way #1511 did. changes; add a scoped patch the way #1511 did.
@@ -167,6 +171,8 @@ Changed:
that was row 7). that was row 7).
- One more Gitea call, `state=open`, one page; a full page fails as today - One more Gitea call, `state=open`, one page; a full page fails as today
(Q7). Referenced issues absent from that list count as closed. (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 - Reads `docs/plans/queue.json` through the queue validator by relative
import (`../../queue/src/queue.mjs`). There are no workspaces, so a bare import (`../../queue/src/queue.mjs`). There are no workspaces, so a bare
`@mosaic/queue` import would not resolve (R14). `@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 *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 routine (sequenced across A and E; see 2.A). The parked items become rows
with state `parked`, owner `unassigned` and issue null. 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 ## 5. Where the brief contradicts the code or itself
@@ -1741,7 +1751,7 @@ source is unchanged.
| Calls | Purpose | | Calls | Purpose |
|---|---| |---|---|
| 1 | Metrics, unchanged. | | 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. | | 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 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. `missing`, `invalid` or `pid-gone` owner, and any issue or age violation.
- `incomplete`: no known violation, but something couldn't be decided. That - `incomplete`: no known violation, but something couldn't be decided. That
covers: covers:
- an issue check that was `unknown (budget)`, hit a full page, or was not - an issue check that was `unknown (budget)`, or was not run;
run; - a full open page while some issue in a row's `closes` has no known
state;
- a `pid-unknown` owner; - a `pid-unknown` owner;
- an undecidable legacy age. - an undecidable legacy age.
- `reduced pass`: nothing known and nothing undecided. The liveness evidence - `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 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 reviewers. That move now carries the in-review→done checks on a comment
round; the table and 8.9's receipts paragraph say so. 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.
@@ -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 <id> 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.
@@ -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.
+114 -10
View File
@@ -2,7 +2,9 @@
Read-only counts from local `refactor` commit subjects, one Gitea issue-list 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 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. No board changes, data-root writes, fleet reads, transcript output, or scheduler.
```sh ```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-issues
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-t3 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 --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/ 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 T3 changed, so it refuses instead of counting zero. Error messages name ids
and paths, never message text. 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, The rules come from plan 8.10 in
limit 50. This includes issues closed in range, even if later updated. Gitea caps `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 `<dataRoot>/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. 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 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 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. 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, Exit 0 means a report was computed. Exit 1 means bad arguments, a queue.json
session or T3 evidence. Malformed JSONL, including a partially written last line, 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 refuses the report; rerun after the seat finishes writing. Exit 2 means issue
credentials, API, payload, or completeness failure. The CLI never prints API credentials, API, payload, or completeness failure. The CLI never prints API
error bodies or reads authentication files itself. `--no-issues` makes no API error bodies or reads authentication files itself. The queue's open-list call
call, keeps commit-derived rows, and shows unknown issue metadata, closed counts, 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. 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 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. repository scripts directory is appended to PATH for the issue request.
Tests use only temporary repositories, logs, T3 databases and fake API tools, Tests use only temporary repositories, logs, T3 databases, queue files, seat
with no real credentials or network. Every CLI run in the tests sets `HOME` to 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 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. read and checks successful GET, successful POST, and failed HTTP status.
+23 -5
View File
@@ -3,19 +3,29 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs'; import { dateRange, readCommits, readIssues, readSessions, mergeSources, summarize, formatTable, SourceError } from './ledger.mjs';
import { readT3, defaultT3Path } from './t3.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)), '../../..')) { 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(); const seen = new Set();
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const flag = args[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}`); if (seen.has(flag)) throw new SourceError(`Duplicate option: ${flag}`);
seen.add(flag); seen.add(flag);
if (flag === '--help') { console.log(usage); return; } if (flag === '--help') { console.log(usage); return; }
if (flag === '--json') json = true; if (flag === '--json') json = true;
else if (flag === '--no-issues') noIssues = true; else if (flag === '--no-issues') noIssues = true;
else if (flag === '--no-t3') noT3 = true; else if (flag === '--no-t3') noT3 = true;
else if (flag === '--no-queue') noQueue = true;
else if (flag === '--t3-db') { else if (flag === '--t3-db') {
t3Db = args[++i]; t3Db = args[++i];
if (!t3Db || t3Db.startsWith('--')) throw new SourceError('--t3-db requires a path'); 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 (!since) throw new SourceError(usage);
if (noT3 && t3Db !== undefined) throw new SourceError('--no-t3 and --t3-db cannot be combined'); 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); 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); const commits = readCommits(root, range);
// Fixture tools may be placed first on PATH. The repository client is the // Fixture tools may be placed first on PATH. The repository client is the
// default without requiring installation or reading auth material here. // default without requiring installation or reading auth material here.
const priorPath = process.env.PATH; const priorPath = process.env.PATH;
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`; process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
let issues; let issues, states = null;
try { issues = noIssues ? null : readIssues(root, range); } 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; } 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. // 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 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 sessions = mergeSources(await readSessions(root, range), t3);
const report = summarize(range, commits, issues, sessions); 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; return report;
} }
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+255
View File
@@ -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}`,
'',
];
}
+5 -2
View File
@@ -64,13 +64,16 @@ function fixture(t) {
mkdirSync(path.join(root, 'agents/alice'), { recursive: true }); mkdirSync(path.join(root, 'agents/alice'), { recursive: true });
mkdirSync(path.join(root, 'agents/bob'), { recursive: true }); mkdirSync(path.join(root, 'agents/bob'), { recursive: true });
cpSync(source, path.join(root, 'packages/ledger/src'), { 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'); 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]); execFileSync('chmod', ['+x', api]);
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', ''); 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 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' } }]; 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'); 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 }; return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
} }
test('fixture git subjects only, follow-ups and three session kinds', t => { 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 => { test('empty range gives no rows and zero totals', t => {
const f = fixture(t); const f = fixture(t);
f.put('issues.json', '[]'); 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.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)); assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
}); });
+449
View File
@@ -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');
});