Files
jason.woltje 158205c5cc lead: commit darkwing's untracked evidence (348 files: queue-137 queue-141 queue-150 queue-162 queue-165 queue-166 queue-167 queue-169 queue-179 queue-180 queue-181 queue-182 queue-185 queue-186 queue-187 queue-188 queue-190 queue-93)
Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2026-11-02 15:05:14 -06:00

304 lines
17 KiB
Markdown

# Row 93, a review loop through the bus, round 1 build (Darkwing)
Issue #1587. Brief blob `dff83c09`. Design and Sage's answers (decision
135): `r1-design.md` in this directory.
Base `d240645d`. `build.patch` is `git diff --cached --binary d240645d` of
the 29 paths below. Apply it with `git apply --index` to a detached
worktree of `d240645d`, and `sha256sum -c candidate-manifest.sha256` then
gives 29/29 OK.
## What it does
A coder pushes a branch of its clone to a bare working repository on the
host. It asks a reviewer to look at the commit, the reviewer fetches and
tests it in its own clone and answers with a verdict, and the PM closes the
task citing that verdict. The host never runs git in a session's clone and
never opens a path in a session's workspace. Commits move as git bundles
over a second host socket, `bus-host/git.sock`.
1. `push_working` (verb `working.push`). The runner, inside the sandbox,
bundles the branch in its own clone against the work ref's tip (or
`main`) and sends it over the git socket. The host checks the cap and
the action with the broker, caps the size, writes the bundle under
`bus-host/`, and lands it in
`<dataRoot>/working/<business>/<project>.git` as
`refs/heads/work/vikunja-<project>-<task>`. The broker records the push
as the `action.allowed` for `git.push.working` on the task, naming the
commit (see "Event kinds" below).
2. `request_review` (verb `review.request`). The broker checks that the
commit is the task's latest push and that the named role holds
`review.verdict`, records a `review.requested` event and sends that role
a REVIEW-REQUEST message.
3. `fetch_review` (verb `working.fetch`). The host looks the commit up from
the cited request, bundles it as `refs/mosaic/review`, leaving out the
commits the reviewer says it has, and sends it back. The runner fetches
it into `refs/remotes/working/work/vikunja-…` in the reviewer's clone.
4. `review_verdict` (verb `review.verdict`). One verdict per request,
`approve` or `changes`, sent to the role that asked.
5. `task_close` on a task with a recorded push must cite an
`approve` verdict on that task for its latest pushed commit. A task with
no push closes as before.
## Files
| File | Change |
|---|---|
| `packages/seat/src/working.mjs` (new) | the bare working repository: `workRef`, `validBranch`, `push`, `review`, the per-repository lock, the SAFE git environment |
| `packages/cli/src/git-socket.mjs` (new) | the host's git socket: framing, limits, broker checks, bundle files under `bus-host/` |
| `packages/harness/src/git-tools.mjs` (new) | the runner's side: bundle the branch, send it, fetch a review bundle into the clone |
| `packages/bus/src/broker.mjs` | `checkPush`, `recordPush`, `reviewFetch`, `review.request`, `review.verdict`, the `task.close` check, `refuse()` subject |
| `packages/bus/src/process.mjs`, `runtime.mjs` | the new host ops (`checkPush`, `recordPush`, `reviewFetch`) |
| `packages/cli/src/launcher.mjs` | starts the git socket; binds it only for a role with `git.push.working` or `review.verdict`; `session.json` names it |
| `packages/seat/src/sandbox.mjs`, `session.mjs` | the read-only bind of `bus-host/git.sock`; `git-tools.mjs` joins `RUNNER_FILES` |
| `packages/harness/src/tools.mjs`, `runner.mjs`, `bundle.mjs` | the four tools, local routing of the two git verbs, prompt lines |
| `packages/tasks/src/verbs.mjs` | `task.close` passes its `verdict` to the broker's check |
| `roles/coder.md`, `reviewer.md`, `pm.md` | the loop from each role's side |
| `packages/{bus,cli,harness,seat}/README.md` | the git socket, the working repositories, the tools, the limits |
| `packages/bus/tests/review.test.mjs` (new), `packages/cli/tests/git-socket.test.mjs` (new), `packages/seat/tests/working.test.mjs` (new) | see Tests |
| `packages/harness/tests/{bundle,tools}.test.mjs`, `packages/seat/tests/sandbox.test.mjs`, `packages/cli/tests/launcher.test.mjs`, `packages/tasks/tests/verbs.test.mjs` | one test or a few assertions each |
| `packages/bus/tests/broker.test.mjs` | one assertion: its `task-subject-required` check used a `review.verdict` event through `recordEvent`, which is now reserved, so it uses `action.refused` |
## Event kinds: no schema change
The first gate run failed because `packages/bus/schema.sql` limits
`events.kind` to a fixed list, and the build wrote two kinds not on it,
`git.push.working` and `review.request`. Adding them would be the bus's
first schema change. `store.mjs` pins the DDL's hash and compares a live
database's schema with it, so the running bus would refuse its own
database (`schema-mismatch`) until a migration rebuilt `events`, an
append-only table, and replaced the `schema_digest` row in `meta`, which
is append-only too. That is a breaking data change the row doesn't need,
so the build uses kinds the schema already has:
- The request is a `review.requested` event. The schema has listed that
kind since row 37, and the Console already shows it as "Review
requested". The verb and the action stay `review.request`.
- The push is the `action.allowed` for `git.push.working`, written by
`recordPush` once the ref moved, with `{project, branch, ref, commit,
previous}` in its body next to the usual `{action, class, target}`.
The git socket's first broker call, `checkPush`, now checks the
authority without recording anything (a refusal is still filed). So
each landed push has exactly one `action.allowed`, and a refused one
has none. Decision 135's "a task with any `git.push.working` event"
reads as "a task with an `action.allowed` for `git.push.working` that
names a commit".
- `recordEvent` refuses `review.requested`, `review.verdict`, and an
`action.allowed` whose action is `git.push.working`, so a trusted
adapter can't forge a push or a verdict. Mutant `push-forgeable` drops
the last check.
If Sage wants a dedicated `git.push.working` kind instead, that is a
schema migration and a separate ruling.
## The `refuse()` subject, a bus behaviour change
Sage asked for this as its own item (ruling 3).
Before: `broker.refuse(cap, code)` filed every host-decided refusal with
no context, so its `action.refused` event had a null subject. After:
`refuse(cap, code, subject = null)` files it under
`{ args: { task_ref: subject } }`. The refusal's subject is then the task
ref when it is one, and null otherwise, by the same rule every other
refusal already uses. The git socket passes the request's `task_ref`
(`process.mjs` forwards `m.subject`), so a `non-fast-forward` refusal
shows in that task's `trail`. The launcher's three existing `refuse` calls
pass no subject and record what they did before. `runtime.mjs`'s
`authorizeLaunch` became a `refusing()` wrapper that the git ops share;
launch refusals still pass no subject.
Test: review.test "a refusal the host decided is filed on the task it
names, and only a task ref counts". `TASK` files on `TASK`; `'gitea:1'`
(not a vikunja ref) and no subject both file null. Mutant
`refuse-subject-null` sets the subject back to null.
`broker.mjs` is a bus landing, so decision 109 notices go to Mos before
and after it lands. Sage sends them; the bus restart stays with Mos.
## Decision 135, item by item
1. **The close needs an approve verdict for the latest push.**
`authorize()` runs `#closeVerdict` in the same transaction as the
authority it consumes, so a refusal rolls the `action.allowed` back.
Codes: `verdict-not-found` (no citation, or one that isn't a verdict on
this task), `verdict-not-approve`, `verdict-stale`. Tests: review.test
"task.close on a pushed task needs …", and tasks verbs.test "a task
with pushed work closes only citing …", which also checks that no
refused close reached the tracker.
2. **Descends from main.** The bundle's branch is fetched into a temp ref
`refs/mosaic/incoming/<hex>`. `merge-base <commit> main` must succeed
before the work ref moves, else `unrelated-history`. The temp ref is
deleted on every path. The check runs on every push, not only the
first. A later push that fast-forwards a descendant passes it anyway,
and running it every time costs one git call. Test: working.test
"a branch with no history in common with main refuses
unrelated-history".
3. **Re-seed main under the lock.** Each push and each review fetches
`main` from the project root into the bare repository first, under the
per-repository lock. A bundle whose prerequisites the repository lacks
refuses `missing-prerequisite`, read from `git bundle verify`'s message
(not `-q`, which drops it in 2.55). Tests: working.test "main is
fetched from the root again before each push …", "a bundle that needs
commits … missing-prerequisite", "a root moved back after the clone …".
4. **Host git environment.** Every host git call runs with
`GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_GLOBAL=/dev/null`,
`core.hooksPath=/dev/null` and the SAFE flags, and every bundle fetch
with `transfer.fsckObjects=true`. Test: working.test "a commit with a
broken object refuses invalid-bundle, and only because fsck is on"
lands the same bundle with `fsck: []`, so the refusal is fsck's. Mutant
`fsck-off` empties the real constant.
5. **The bundle file is deleted on every path.** The git socket removes it
in a `finally`, and the runner removes its own copy in the run's
`/tmp`. After every case, refusals included, the git-socket tests check
that no `.bundle` file is left in `bus-host/` or in either session's
`/tmp`. Mutant `bundle-kept` drops the host's delete.
## Tests
**New files.**
- `packages/seat/tests/working.test.mjs`, 15 tests: workRef and
validBranch; a push and a fast-forward; non-fast-forward; unrelated
history; missing prerequisite; re-seed; root moved back; broken object
and fsck; only a v2 bundle with the named branch; a branch named `main`
or for another task moves only this task's ref; a record that throws
moves the ref back; a repository path that isn't a directory; the review
bundle and `have`; review with no repository; the runner's limits match
the host's.
- `packages/cli/tests/git-socket.test.mjs`, 6 tests: the whole loop
between a fake coder and a fake reviewer through a real bus, ending in
the PM's close and the task's `trail`; refusals (no action, non-fast-
forward, unknown commit or request, another task's branch); the push
size cap; the fetch size cap; no capability, an unknown one, a bad
request line; a rigged clone (hooks, fsmonitor, a filter, a gitlink, a
symlinked `.git/config`) leaves no marker, and a branch whose
`.gitmodules` carries an `update = !cmd` line refuses `invalid-bundle`
at the host's fsck with the work ref unmoved. Every case checks that the
project root's refs and the working repository's other refs did not
move.
- `packages/bus/tests/review.test.mjs`, 7 tests: `checkPush` records
nothing, push recording, and the reserved kinds and forged push; review.request; its refusals (`stale-commit`,
`unknown-commit`, `unknown-role`, `self-review`, `not-a-reviewer`, and
`decision-required` for a role that holds it only cross-role);
reviewFetch; one verdict per request; the
close check; the refuse subject.
**Additions.** harness bundle.test (the prompt lines per role), tools.test
(which role gets which tools), seat sandbox.test (the git socket bind is
read-only and only when given), cli launcher.test (the PM's `gitSocket` is
null, the socket is 0600, the coder is bound and the PM is not), tasks
verbs.test (the verdict reaches the broker).
## Gate
Run 2, 15:27:52Z to 15:31:00Z, on the 29-path candidate over `d240645d`,
in a clean worktree with `node_modules` linked, under the busy check.
Runner: `../gate.sh`.
| Suite | Result |
|---|---|
| seat (`node --test tests/`) | 105 pass, 0 fail |
| cli | 137 pass, 0 fail |
| harness | 69 pass, 0 fail |
| bus | 122 pass, 0 fail |
| tasks | 90 pass, 0 fail |
| webui (`node --test tests/`) | 104 pass, 0 fail |
| test-auth | 15 passed, 0 failed |
| test-conductor | 17 passed, 0 failed |
| test-config | 24 passed, 0 failed |
| test-discord | 66 passed, 0 failed |
| test-extension-package | 18 passed, 0 failed |
| test-foundation | 44 passed, 0 failed |
| test-queue | 27 passed, 0 failed |
| test-release | 14 passed, 0 failed |
| test-task (`DOCKER_HOST=unix:///nonexistent`) | 26 passed, 0 failed |
Run 1 (15:19Z) failed 14 node tests. Most came from the event kinds
above. The rest were three test faults: a sandbox assertion compared a
relative path with `RUNNER_FILES`' absolute ones, the junk-bundle case
sent bytes git read as a bundle with no such branch, and the rigged
clone's `update = !cmd` line was refused by the host's fsck before any
clone could run it. That refusal is right, so it became its own case.
## Mutants
`../mutants.sh` with `../mut.mjs`, 15:31Z, each mutant against the named
test files. All 15 killed; every file restored.
| Mutant | Site | Killed by |
|---|---|---|
| fsck-off | `FSCK = []` in working.mjs | git-socket rigged clone, working broken object |
| plus-refspec | `+` before the refspec | working non-fast-forward |
| ref-from-request | ref name from the request's branch | 6 tests |
| cap-off-push | push size check off | git-socket loop and size cap |
| cap-off-fetch | fetch size check off | git-socket fetch over the cap |
| refuse-subject-null | refuse() subject back to null | review.test refuse subject |
| no-close-check | task.close skips `#closeVerdict` | review.test close check |
| no-verdict-stale | stale verdict accepted | review.test close check |
| verbs-drop-verdict | task.close drops `verdict` | tasks verbs.test |
| push-forgeable | recordEvent takes a git.push.working action.allowed | review.test push record |
| no-request-commit-check | review.request takes any commit | review.test refusals |
| no-merge-base | no shared-history check | working unrelated history |
| no-reseed-on-push | main not fetched before a push | 9 tests |
| bundle-kept | host keeps the bundle file | 5 tests |
| git-socket-always | git socket bound for every role | launcher.test |
The runner-side `transfer.fsckObjects` in `git-tools.mjs` has no mutant.
The host repository fscks every bundle it takes, so a runner without it
still can't land a broken object on the host. What it guards is the
reviewer's own clone.
## Deviations from the brief
- The owner is Darkwing, not Filbert (Sage's assignment).
- The host-side git lives in `packages/seat/src/working.mjs` and
`packages/cli/src/git-socket.mjs`, not in `checkout.mjs` edits
(decision 135, answer 3).
- `fetch_review` runs git in the reviewer's sandbox, not on the host
(answer 1).
- Files outside the brief's list: `packages/tasks/src/verbs.mjs` (the
close passes its verdict), `packages/tasks/tests/verbs.test.mjs`, and
the cli README.
- The seat README said the runner files were "ten" at base, while
`RUNNER_FILES` held 11. With `git-tools.mjs` it says twelve.
- The `refuse()` task-ref change in `broker.mjs` (and its `runtime.mjs`
and `process.mjs` plumbing) is beyond the design. It has its own
section above, a test, and mutant `refuse-subject-null`.
- `packages/bus/tests/broker.test.mjs` L199: its `task-subject-required`
case wrote a `review.verdict` through `recordEvent`, which this build
reserves, so it now writes `action.refused`. The case checks the same
rule.
- The event kinds differ from the design's: `review.requested` and an
`action.allowed` for `git.push.working`, not two new kinds. See "Event
kinds: no schema change".
## Limits and follow-ups
- **Timeouts disagree.** `callTool` gives up at 60 s and the tool socket
at 150 s, while git-tools allows 300 s for git and its socket. A push
slower than 60 s looks failed to the model while the runner still
finishes it. A repeat then refuses `nothing-to-push`, and `trail` shows
the push's `action.allowed`. The 32 MiB cap keeps this rare. Documented
in the harness README's Limits.
- **No assignment check.** Any role holding `git.push.working` can push
to any task's work ref, and the broker doesn't check that the task is
assigned to it. That matches how the other task verbs treat authority
today. A follow-up row could add it.
- **`--no-auto-maintenance`** on every host fetch, so no `gc` runs inside
the lock.
- **Row 80 overlap.** Row 80 also edits `packages/bus/src/broker.mjs`;
its diff adds a `STRANDED` const near L47, a stranded query in
`#agentState` (about L1047 to L1052), and `stranded` in that return.
None of it touches lines this build changes. The nearest hunk here
starts at base L1055, about a dozen lines below `#agentState`'s return,
so a rebase should apply without a conflict. It is still the same file. Sage's ruling:
row 93 lands first, and row 80 rebases onto it and gates again. If row
80 lands first, I rebase and gate again.
- **Base drift.** At 15:28Z, `d240645d..11bf4cb0` touched none of the 29
paths. It did change `packages/tasks/src/fake.mjs` (row 71) and some
webui tests, which the tasks and webui suites run, so the landing gate
on the new base covers them. Rechecked at 15:33Z on `11bf4cb00`:
still none of the 29 paths.
- Out of scope, as the brief says: landing a work ref, `git.push.protected`,
a forge, credentials.