docs: lead decision 72, the S4 follow-up and cohort briefs, and the trackers boot test

Decision 72 rules on F2 (five definite refusals stop a DM; unknown
outcomes keep retrying) and J4 (type-check confirmed journal lines),
and opens two rows: the S4 follow-up for Rocko and the K1, K3 and K10
cohort failures for Darkwing. The trackers boot test from the row 39
gate is kept under agents/sage/work/s4-follow-up/ until the follow-up
moves it into packages/cli/tests/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-09 07:54:52 -05:00
co-authored by Claude Opus 5.5
parent e580ee2239
commit ec2a2c6773
3 changed files with 309 additions and 0 deletions
@@ -0,0 +1,66 @@
// Sage's row 39 gate check, not part of the candidate: the S4 host boots the
// S3 adapter through the real process.mjs when bootConfig emits trackers.
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Client } from "../../bus/src/client.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startHost, startTimeOf } from "../src/host.mjs";
import { writeJson } from "../../business/tests/helpers.mjs";
import { FakeVikunja } from "../../tasks/src/fake.mjs";
import { SCOPES } from "../../tasks/tests/world.mjs";
import { fixture, tmp } from "./helpers.mjs";
test("bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja", async (t) => {
const root = tmp(t);
const fake = new FakeVikunja();
const srv = await fake.listen();
t.after(() => srv.close());
const owner = fake.user("svc-acme");
const bots = {};
for (const r of ["sync", "pm", "cto", "coder", "reviewer"]) bots[r] = fake.user(`bot-acme-${r}`, { owner });
const project = fake.project("Acme", owner);
for (const r of ["pm", "cto", "coder", "reviewer"]) fake.share(project, bots[r], 1);
fake.share(project, bots.sync, 0);
fake.install(project);
const f = fixture(root, "acme", (doc) => {
doc.vars["tracker.baseUrl"] = srv.url;
doc.tracker.sync.botId = bots.sync;
for (const r of Object.keys(doc.roles)) doc.roles[r].tracker.botId = bots[r];
return doc;
});
const put = (file, token) => {
writeFileSync(file, token + "\n");
chmodSync(file, 0o600);
};
put(f.doc.tracker.sync.credentials.vikunja.file, fake.token(bots.sync, SCOPES.sync));
for (const r of Object.keys(f.doc.roles)) put(f.doc.roles[r].credentials.vikunja.file, fake.token(bots[r], r === "pm" ? SCOPES.pm : SCOPES.worker));
writeJson(join(root, "project", ".mosaic", "project.json"), { projectVersion: 1, id: "stack", vars: { "tracker.project": project } });
mkdirSync(f.dataRoot, { recursive: true, mode: 0o700 });
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
assert.deepEqual(boot.trackers, { acme: { baseUrl: srv.url, project, pollSeconds: 60, reconcileMinutes: 60 } });
const host = await startHost({ boot, business: "acme", log: () => {} });
t.after(() => host.close(0));
const launch = await host.bindLaunch({ business: "acme", role: "pm", run: "pm-run", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
const pm = new Client({ path: host.path, cap: launch.cap });
await pm.call("role.claim");
let code;
const end = Date.now() + 15000;
do {
code = await pm.call("task.close", {}).then(() => "ok", (e) => e.code);
if (code !== "tracker-starting") break;
await new Promise((r) => setTimeout(r, 100));
} while (Date.now() < end);
console.log(`task.close {} answered: ${code}; fake saw ${fake.requests.length} requests, first ${fake.requests.slice(0, 3).map((r) => `${r.method} ${r.path} ${r.status}`).join(", ")}`);
assert.ok(fake.requests.some((r) => r.path === "/info"), "the adapter called the fake");
assert.doesNotMatch(code, /^(tracker-|credential-|scope-too-broad)/, "the adapter is ready, so the verb fails on its own arguments");
const before = fake.requests.length;
const missing = await pm.call("task.close", { task_ref: `vikunja:${project}/999`, verdict: "gate check" }).then(() => "ok", (e) => e.code);
console.log(`task.close on a missing task answered: ${missing}; it made ${fake.requests.slice(before).map((r) => `${r.method} ${r.path} ${r.status}`).join(", ")}`);
assert.ok(fake.requests.length > before, "a well-formed verb reached Vikunja through the adapter");
assert.equal(await host.close(0), 0);
});
+36
View File
@@ -1396,3 +1396,39 @@ which stay with him. Each item names who decided it and what happened.
- Tests for mutants M22 to M24, M26 and M29.
- Follow-ups, not round 2: F2 (a refused DM retries every 30 min with
no limit) and J4 (loose types in confirmed lines).
72. **S4 follow-up row and the conversation cohort row (2026-10-09).**
Source: Filbert's and Darkwing's round 1 and round 2 reviews of
#1521, the row 38 and row 39 gate runs, and my probe of the cohort
failures. Brief: `docs/plans/2026-10-09_s4-follow-up-and-cohort.md`.
- The items decision 71 and the reviews held for later go in one
row for Rocko: F2, J4, the N2, N4, N5 and M28 mutants, the reader
capability check, the host's `broker.connected` guard, clearer
journal errors, the `mkdir -m 0700` line in the install message,
the flaky discord lock test, and my trackers boot test. Filbert
and Darkwing review, since the row changes the DM path again.
- F2 ruling. Retrying `unknown` outcomes (network, 5xx or 429)
stays unlimited at the 30-minute cap. A missed blocking decision
still costs more than a duplicate DM. A definite refusal is a
4xx other than 429. Five of them for one decision stop that DM:
one `gave-up` line, one log line, and the digest says "DM refused,
not retried". The count comes from the journal, so a restart
doesn't start it over. Five is enough to ride out a binding typo
fixed within a few hours. Fewer would give up on a fixable
mistake, and more only lengthens the journal.
- J4 ruling. Opening the journal type-checks confirmed lines and
refuses a bad one with exit 3, the same as a malformed line.
Today's lookups go by string, so the loose types do no harm yet.
But a journal that loads anything stops being evidence.
- The packet's BUILD.md M28 line stays as approved. BUILD-LOG holds
the correction. The bus shim's `spawnSync` timeout (Filbert round
1, note 8) stays out of scope until a bus row needs it.
- Cohort row. K1, K3 and K10 fail on this host in isolation. All
three use an `ignoreTerm` child under a systemd user scope. That
child survives SIGTERM outside a scope. They passed on 2026-10-05,
the package hasn't changed since 243e153c, and no systemd, kernel
or node upgrade landed after 2026-09-03. Darkwing owns the
diagnosis and fix, and Dewey reviews. Dewey wrote CHAT-03 I1, and
row 40 gates on this suite. If the cause is the host's systemd
configuration, the row reports it and stops. That's Jason's
machine.
@@ -0,0 +1,207 @@
# S4 follow-up and the conversation cohort failures (2026-10-09)
Status: written by Sage, lead, under lead decision 72. It follows
`docs/plans/BRIEF-TEMPLATE.md` and amends no section of the slice 1
brief. Two rows, one heading each.
## S4 follow-up: notifier retry limit, journal types and the round 2 test gaps
### Problem
Row 39 (S4, #1521) landed as 2f5303c1 with items the reviews left for
later. They are listed below.
- **F2.** `packages/cli/src/notifier.mjs` retries a refused DM with
backoff capped at 30 minutes and no attempt limit. It also appends a
journal line on every attempt, so a DM that Discord refuses for good
keeps sending and grows `sent.jsonl` by 48 lines a day.
- **J4.** Confirmed journal lines with loose types load. Examples are a
numeric `decision` and a digest line with no `day` (Filbert round 1,
note 4).
- **Surviving mutants.** N2, N4 and N5 survive (Filbert round 2, notes
4 to 6):
- N2: the directory check uses `statSync` instead of `lstatSync`.
- N4: `append` opens without `O_NOFOLLOW`.
- N5: `watchChildren`'s early check ignores `signalCode`.
- **M28.** It survived. No test covers the host's "a bus host already
runs" refusal. The BUILD-LOG entry for row 39 records the correction.
- **Reader capability.** host.test checks that the launch capability
stays out of `/proc/<pid>/cmdline` and `environ`. It doesn't check
the reader capability sent to the notifier (Filbert round 1, note 5).
- **Host shutdown path.** `packages/cli/src/host.mjs:144` and `:150` call
`broker.send({ op: "close" })` without checking `broker.connected`.
Against a broker that already died, that throws `ERR_IPC_CHANNEL_CLOSED`.
- **Test cleanup.** The host test "a notifier that dies" has no
`t.after` close, so a failure leaves children running.
- **Raw errors.**
- A read-only or 0500 journal directory surfaces a raw `EACCES`, not a
`CliError` (Filbert round 2, note 10; Darkwing round 2, note 2).
- A symlinked directory refuses with "must be mode 0700 and owned by
this user". That is true, but it doesn't name the link (Filbert
round 2, note 9).
- **Install message.** `scripts/bus-service.sh` tells the operator to
write `notify.json` but never says to create the directory 0700
(Darkwing round 2, note 1). An operator who runs a plain `mkdir -p`
gets a host that refuses with exit 3.
- **Flaky discord test.** `packages/discord/tests/journal.test.mjs:120`
publishes pid `2 ** 22 - 7` and expects `ownerState` to be `dead`. On
a host where that pid is live, it reads `unknown`. That was the only
failure in the row 39 M28 mutant run.
- **Trackers boot test.** The combined-tree check that S4's host
boots S3's tracker adapter from config ran from scratch at the row 39
gate and isn't in the suite. It is at
`agents/sage/work/s4-follow-up/trackers-boot.test.mjs`. Its relative
imports assume `packages/cli/tests/`.
### Owner and reviewer
- Owner: rocko.
- Reviewers: filbert, and darkwing because the row changes the DM path
and the discord package's tests.
### Files owned
- `packages/cli/src/notifier.mjs`, `packages/cli/src/host.mjs`
- `packages/cli/tests/*`, including the new
`packages/cli/tests/trackers-boot.test.mjs`
- `packages/cli/README.md`
- `packages/discord/tests/journal.test.mjs`
- `scripts/bus-service.sh`
- `docs/TOOLS.md`, only if a command's output changes
- `agents/rocko/work/s4-follow-up/` (the packet)
### What ships
1. **F2, under decision 72.**
- A DM refused with an HTTP 4xx other than 429 is a definite
refusal. After 5 definite refusals for one decision, the notifier
stops sending that DM. It appends one `gave-up` line and logs it
once.
- The count comes from the journal, so a restart doesn't reset it.
- The digest lists that decision as "DM refused, not retried".
- An `unknown` outcome (network, 5xx or 429) keeps the capped
backoff with no limit. Under decision 70, a duplicate DM is
cheaper than a missed one.
- The `outcome` comment in `notifier.mjs` and the README name the
new value.
2. **J4.**
- Opening the journal type-checks every complete line:
- `at`: ISO string
- `kind`: `dm` or `digest`
- `decision`: string, required for `dm`
- `day`: `YYYY-MM-DD` string, required for `digest`
- `outcome`: one of the four values
- `messageId`: string or null
- `status`: integer when present
- A line that fails refuses with exit 3, as a malformed line does
today. The decision 71 torn-tail rule is unchanged.
3. **Tests.**
- Tests that kill N2, N4, N5 and M28.
- A reader-capability exposure check in host.test.
- `t.after` cleanup in "a notifier that dies".
4. **Host shutdown.** Guard both `broker.send` calls with
`broker.connected`.
5. **Errors.**
- A journal directory the notifier can't write refuses with a
`CliError`, exit 3, naming the path.
- A symlinked directory refuses with a message that says it is a
link.
6. **Install message.** `bus-service.sh`'s install message adds
`mkdir -m 0700 -p <dataRoot>/notify/<business>`.
7. **Discord test.** `journal.test.mjs:120` uses a pid that is
provably not running, for example a reaped child's pid checked with
`kill(pid, 0)`. It must not depend on a fixed number.
8. **Trackers boot test.** Move it into `packages/cli/tests/` as
written, adjusting only its imports if needed.
9. **Suites.** cli, discord, bus and tasks node suites,
`test-discord.sh` and every `test-*.sh` green. Include a mutant
table for N2, N4, N5, M28 and the F2 limit.
### Out of scope
- Round 1 note 8, the `spawnSync` timeout in `human-cli`. It lives in
the bus shim. It goes to a bus row if it ever bites.
- The live DM run. That is Sage's, at Jason's go, under decision 70.
- The packet `agents/rocko/work/slice1-s4/BUILD.md`. It stays as
approved, and BUILD-LOG holds the M28 correction.
### Gate
- Filbert and Darkwing approve on the row's issue.
- Sage reruns the integration gate on a git worktree with the candidate
applied: every node package suite and every `scripts/test-*.sh`.
Expect zero failures, except the three conversation cases owned by
the next row, and only until that row lands.
## Conversation cohort: K1, K3 and K10 fail on the scope fixtures
### Problem
`packages/conversation/tests/cohort.test.mjs` fails K1, K3 and K10 on
this host.
- **Where it fails.** It failed at the row 38 and row 39 gates, and
again on the canonical tree run alone on 2026-10-09 at 12:50Z. That
run was `node --test --test-name-pattern='^K(1|3|10):'`: 0 pass,
3 fail.
- **The assertions.**
- K1: "the escaped child is a listed member".
- K3: "the member ignored TERM". The child is dead after TERM.
- K10: "the member is alive across the crash".
- **What all three share.** Each spawns a tool child with `ignoreTerm`
under a systemd user scope (`packages/conversation/tests/fake-pi.mjs`,
`spawnChild`).
- **Outside a scope.** The same child spawned outside a scope survives
SIGTERM (Sage's probe, 2026-10-09).
- **What passes.** K2 (process-group fallback), K11 to K15 and the
in-process cases pass.
- **History.** All three passed at the row 44 gate on 2026-10-05. The
package hasn't changed since 243e153c (2026-10-04).
- **Not a package upgrade.** The host's last relevant upgrades were
systemd 261.2, kernel 7.2.2 and node 26.8.1, all on 2026-09-03,
before that gate. So something else on the host or in the user
manager changed.
- **Who it blocks.** Row 40 (S5) gates on the conversation suite being
green.
### Owner and reviewer
- Owner: darkwing.
- Reviewer: dewey, who wrote CHAT-03 I1 and owns row 40, which needs
this suite green.
### Files owned
- `packages/conversation/tests/*`
- `packages/conversation/src/cohort.mjs`, only if the diagnosis shows a
real defect in the cohort proof rather than in the fixture
- `agents/darkwing/work/cohort-k1/` (the packet: diagnosis, probes and
receipts)
### What ships
1. **Diagnosis.** Say why an `ignoreTerm` child in the scope dies or
leaves the member list early. Back it with receipts:
- `systemctl --user show` on the scope
- `cgroup.procs` before and after TERM
- the child's exit signal
2. **Fixture defect.** If it is a fixture or environment defect, fix
the fixture so the three cases test what their names say on this
host. Never weaken an assertion to pass.
3. **Code defect.** If it is a defect in `cohort.mjs`, fix it. A
`stopped` proof must stay impossible while a member lives.
4. **Suites.** conversation and webui suites green on this host, and
K1, K3 and K10 green in 3 consecutive isolated runs.
### Out of scope
- Changes to the host's systemd configuration or user manager. If the
diagnosis points there, report it and stop; that's Jason's machine.
- Any CHAT-03 behavior beyond the stop and cohort proof.
### Gate
- Dewey approves on the row's issue.
- Sage reruns the conversation suite and the three cases in isolation
on a worktree with the candidate applied. Expect 152/152, then 3/3
three times.