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.
17 KiB
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.
push_working(verbworking.push). The runner, inside the sandbox, bundles the branch in its own clone against the work ref's tip (ormain) and sends it over the git socket. The host checks the cap and the action with the broker, caps the size, writes the bundle underbus-host/, and lands it in<dataRoot>/working/<business>/<project>.gitasrefs/heads/work/vikunja-<project>-<task>. The broker records the push as theaction.allowedforgit.push.workingon the task, naming the commit (see "Event kinds" below).request_review(verbreview.request). The broker checks that the commit is the task's latest push and that the named role holdsreview.verdict, records areview.requestedevent and sends that role a REVIEW-REQUEST message.fetch_review(verbworking.fetch). The host looks the commit up from the cited request, bundles it asrefs/mosaic/review, leaving out the commits the reviewer says it has, and sends it back. The runner fetches it intorefs/remotes/working/work/vikunja-…in the reviewer's clone.review_verdict(verbreview.verdict). One verdict per request,approveorchanges, sent to the role that asked.task_closeon a task with a recorded push must cite anapproveverdict 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.requestedevent. The schema has listed that kind since row 37, and the Console already shows it as "Review requested". The verb and the action stayreview.request. - The push is the
action.allowedforgit.push.working, written byrecordPushonce 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 oneaction.allowed, and a refused one has none. Decision 135's "a task with anygit.push.workingevent" reads as "a task with anaction.allowedforgit.push.workingthat names a commit". recordEventrefusesreview.requested,review.verdict, and anaction.allowedwhose action isgit.push.working, so a trusted adapter can't forge a push or a verdict. Mutantpush-forgeabledrops 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
- The close needs an approve verdict for the latest push.
authorize()runs#closeVerdictin the same transaction as the authority it consumes, so a refusal rolls theaction.allowedback. 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. - Descends from main. The bundle's branch is fetched into a temp ref
refs/mosaic/incoming/<hex>.merge-base <commit> mainmust succeed before the work ref moves, elseunrelated-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". - Re-seed main under the lock. Each push and each review fetches
mainfrom the project root into the bare repository first, under the per-repository lock. A bundle whose prerequisites the repository lacks refusesmissing-prerequisite, read fromgit 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 …". - Host git environment. Every host git call runs with
GIT_CONFIG_NOSYSTEM=1,GIT_CONFIG_GLOBAL=/dev/null,core.hooksPath=/dev/nulland the SAFE flags, and every bundle fetch withtransfer.fsckObjects=true. Test: working.test "a commit with a broken object refuses invalid-bundle, and only because fsck is on" lands the same bundle withfsck: [], so the refusal is fsck's. Mutantfsck-offempties the real constant. - 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.bundlefile is left inbus-host/or in either session's/tmp. Mutantbundle-keptdrops 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 namedmainor 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 andhave; 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'strail; 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.gitmodulescarries anupdate = !cmdline refusesinvalid-bundleat 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:checkPushrecords 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, anddecision-requiredfor 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.mjsandpackages/cli/src/git-socket.mjs, not incheckout.mjsedits (decision 135, answer 3). fetch_reviewruns 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_FILESheld 11. Withgit-tools.mjsit says twelve. - The
refuse()task-ref change inbroker.mjs(and itsruntime.mjsandprocess.mjsplumbing) is beyond the design. It has its own section above, a test, and mutantrefuse-subject-null. packages/bus/tests/broker.test.mjsL199: itstask-subject-requiredcase wrote areview.verdictthroughrecordEvent, which this build reserves, so it now writesaction.refused. The case checks the same rule.- The event kinds differ from the design's:
review.requestedand anaction.allowedforgit.push.working, not two new kinds. See "Event kinds: no schema change".
Limits and follow-ups
- Timeouts disagree.
callToolgives 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 refusesnothing-to-push, andtrailshows the push'saction.allowed. The 32 MiB cap keeps this rare. Documented in the harness README's Limits. - No assignment check. Any role holding
git.push.workingcan 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-maintenanceon every host fetch, so nogcruns inside the lock.- Row 80 overlap. Row 80 also edits
packages/bus/src/broker.mjs; its diff adds aSTRANDEDconst near L47, a stranded query in#agentState(about L1047 to L1052), andstrandedin 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..11bf4cb0touched none of the 29 paths. It did changepackages/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 on11bf4cb00: still none of the 29 paths. - Out of scope, as the brief says: landing a work ref,
git.push.protected, a forge, credentials.