row 66 r2 (#1559): bound the unslop note, keep it across a refused send, carry tools/unslop-hook in stack copies

F1: the whole note is at most 600 characters; the hook failure reason is cut at 300 and the
flagged part gets what is left, so a clipped answer plus its note stays under the bus's
32768-character body limit. F2: a runner test for a refused RESULT send; the note rides the next
RESULT the session sends. P1: COPY_PATHS carries tools/unslop-hook, and a stack-copy test imports
both harness modules from a copy and checks every unslop path they read exists there. N1: stale
session-test comments. N2 is a doc-only follow-up in BUILD.md.
This commit is contained in:
2026-10-20 18:14:22 -05:00
parent 688280eefe
commit 2369c00e36
11 changed files with 143 additions and 17 deletions
+52 -4
View File
@@ -26,8 +26,8 @@ the log for an earlier `notice_injected` with the same entry id). A broken lists
**The operator channel.** The runner sets `MOSAIC_UNSLOP_LOG` to `<run>/unslop.jsonl` after the
module's variables, so a module can't point it elsewhere. After the landing turn and after each message turn it reads what the
log gained (whole lines only, from an offset). The next RESULT it sends ends with a note of at
most 600 characters:
log gained (whole lines only, from an offset). The next RESULT it sends ends with a note. The
whole note, both parts and the blank line before them, is at most 600 characters (r2):
- `[unslop: N reply|replies of this session flagged since the last note: <details>. The session gets a correction notice on its next turn.]`
- `[unslop: the check is broken, so this session's replies go unchecked: <reason>]`, once a run.
@@ -35,13 +35,58 @@ most 600 characters:
That RESULT is what `mosaic talk` and `mosaic follow` print. A turn that sends no reply (an
incoming RESULT, the landing turn) keeps its note for the next one. `note()` doesn't change
state and `sent()` runs only after `message.send` succeeds, so a failed send keeps the note.
`clip()` caps the body at 32000 characters before the note, so the sum stays under the bus
limit of 32768.
`clip()` caps the body at 32000 characters plus its 33-character cut line before the note, so the
sum is at most 32633, under the bus limit of 32768. The broken reason is cut at 300 where the
runner takes it, from the log or from its own read failure, so the broken part is at most 371
characters and the flagged part is cut to what is left.
**Counts.** `unslopCounts(path)` reads the log after the session ended:
`{checked, flagged, patterns: {<source>: n}, notices, broken}`. A missing log gives zeros.
Findings now carry `source`, the entry's pattern id (`ms-unslop:7`), so `patterns` is per id.
**The stack copy (r2).** `scripts/stack-copy.sh` `COPY_PATHS` gains `tools/unslop-hook`. Both
modules join their files under `REPO`, and a session launched from a pinned copy has
`REPO = copies/<sha>`. Without it every managed session from a copy past this row would fail at
bundle build with ENOENT (Dewey P1, comment 29440).
## Round 2
Round 1 (candidate 8ec03b45a, manifest e68c6bce5ec4) got changes from Filbert (29439) and Dewey
(29440). Round 2 carries:
- F1 (both): a note with both parts wasn't bounded. A 300-character reason, a long flagged part
and a clipped answer came to 32934 (Filbert) or 33004 (Dewey), over 32768, and the broker
refused the send. `note()` now gives the flagged part only the room the broken part leaves, and
`fresh()` cuts every reason at 300, the runner's own read-failure reason included. Tests: the
runner test with an answer of 40000 characters, a 400-character `hook_failed` reason and 30
flagged replies (note ≤ 600, body ≤ 32768, cut line at 32000), and a unit check that both
parts come to exactly 600 and that a read failure on a 250-character path is cut.
- F2 (Filbert; Dewey's M3): no test failed a send through `main()`. The new runner test has the
broker refuse the first RESULT. The note then rides the next RESULT. One correction to the
F1 consequence as written: the broker marks a received message delivered, so the refused
message isn't received again in that run. It stays unread and its sender gets no reply; the
turn doesn't rerun.
- P1 (Dewey): `COPY_PATHS` gains `tools/unslop-hook`. The copy fixtures in
`stack-copy.test.mjs` and `pinned-copy.test.mjs` copy `tools/unslop-hook` too, and the
pinned-copy top-level list gains `tools`. A new `stack-copy.test.mjs` test makes a copy, loads
both harness modules from it, and checks that the adapter, the six unslop paths and the mounts
are under the copy and exist.
- N1 (Filbert): the two session-test comments say `<dir>/unslop.jsonl`.
- N2 (Filbert), doc-only follow-up: the brief's `<run>/session/unslop.jsonl` (:463) and "print
mode notifies nobody" (:477) are stale. The brief is Jason's text for this row; the deviation
below records the path.
- Dewey's notes, no change: the Claude Code Stop timing (finding 3 below) gets a look in the
landing session; `patterns` keys per entry source, which is the brief's "per pattern".
r2 mutants, each killed by its intended test (`mutants-r2.log`):
| Mutant | Killed by |
|---|---|
| K1 the r1 `note()` cut | the clipped-answer runner test (the send is refused and it times out) and the unit check |
| K2 `fresh()` doesn't cut the reason | the unit check |
| K3 `sent()` before `message.send` | the refused-send runner test |
| K4 `COPY_PATHS` without `tools/unslop-hook` | the copy test |
## Ship list against the brief
| Brief item | Where |
@@ -82,6 +127,9 @@ Findings now carry `source`, the entry's pattern id (`ms-unslop:7`), so `pattern
- **`packages/seat/tests/sandbox.test.mjs`** isn't an owned file. Its pin
`binds(args).length === list.length - 1` broke because the modules now mount three more files.
It is now `- 4`, and the test asserts the three unslop read-only binds by name.
- **`scripts/stack-copy.sh`** and the two copy tests aren't owned files. The copy change is P1
above. PB3 of the Part B window (`repin-partB-proof.md`) holds `scripts/stack-copy.sh` still
until B7, so this row lands after the Part B window has run.
- `MOSAIC_EXTENSIONS` is set through the pi module's `env`, not in the runner.
- `unslop-check.js` gained the `source` field on findings (needed for per-pattern counts).
+1
View File
@@ -411,6 +411,7 @@ One read-only tree supplies the bus, Console and human CLI (row 85,
`copy` requires a canonical checkout (.git directory); --allow-worktree
is a test override. COMMIT must be reachable from both local refactor and
origin/refactor. It archives the runtime/import closure, adapters, roles,
`tools/unslop-hook` (the unslop check both harness modules mount, row 66),
RELEASE, package.json and package-lock.json; refuses missing manifests;
runs npm ci inside the temporary copy; removes write permissions; and
renames beside the target. A published existing copy stays unchanged.
+15 -1
View File
@@ -14,7 +14,7 @@ export function setup(t, realPin=false){
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
mkdirSync(repo);mkdirSync(bin);
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
const pkg={name:'copy-fixture',version:'1.0.0'};
@@ -31,6 +31,20 @@ export function setup(t, realPin=false){
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
}
// Row 66: a session launched from a pinned copy reads each harness module's files from the copy,
// so every path a module names under its REPO (adapter, mounts, bundle sources) is in COPY_PATHS.
test('a copy carries every file the harness modules read from it',async t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const copy=join(s.copies,s.id),paths=[];
for(const kind of ['pi','claude-code']){
const m=await import(pathToFileURL(join(copy,'packages','plugins','harness',kind,'index.mjs')).href);
// pi's mounts() resolves node_modules, which this fixture copy lacks; its unslop mounts are its
// bundle sources, so the sources stand in for them.
paths.push(m.adapter(),...(kind==='pi'?Object.values(m.bundle().sources):m.mounts({env:{}}).map(x=>x.src)));
}
assert.equal(paths.filter(p=>p.includes('/tools/unslop-hook/')).length,6,paths.join('\n'));
for(const p of paths){assert.ok(p.startsWith(copy+'/'),p);assert.ok(existsSync(p),p);}
});
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
+4 -2
View File
@@ -272,8 +272,10 @@ it never touches the disk and isn't in the environment or argv.
when the next turn gets the correction notice, and `broken_lists`,
`skipped_broken` or `hook_failed` when there is no verdict. After each
turn the runner reads what the log gained, and the next RESULT it sends
ends with a note of at most 600 characters naming the flagged replies;
a broken check is told once a run. A turn that sends no reply (an
ends with a note naming the flagged replies; a broken check is told
once a run. The whole note, both parts together, is at most 600
characters, and the answer before it is clipped at 32000, so the RESULT
stays under the bus's 32768. A turn that sends no reply (an
incoming RESULT, a landing turn) keeps its note for the next reply, and
so does a send that failed. That RESULT is what `mosaic talk` and
`mosaic follow` print, so the operator sees the note there.
+7 -3
View File
@@ -186,7 +186,7 @@ export function unslopNotes(path, log = () => {}) {
let broken = null;
const fresh = (reason) => {
if (toldBroken || broken !== null) return;
broken = reason;
broken = cut(reason, 300);
log(`runner: unslop: the check is broken: ${broken}`);
};
return {
@@ -212,13 +212,17 @@ export function unslopNotes(path, log = () => {}) {
if (UNSLOP_BROKEN.has(e.ev)) fresh(unslopReason(e));
}
},
// The whole note, both parts and the blank line, is at most UNSLOP_NOTE_MAX, so a clipped body
// plus its note stays under the bus's body limit. The broken part is at most 371 characters
// (the reason is cut at 300), and the flagged part gets what is left.
note() {
const parts = [];
if (broken !== null && !toldBroken) parts.push(`[unslop: the check is broken, so this session's replies go unchecked: ${broken}]`);
if (flagged.length) {
const n = flagged.length;
parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, UNSLOP_NOTE_MAX - 60) +
". The session gets a correction notice on its next turn.]");
const tail = ". The session gets a correction notice on its next turn.]";
const room = UNSLOP_NOTE_MAX - 2 - parts.reduce((sum, p) => sum + p.length + 1, 0) - tail.length;
parts.push(cut(`[unslop: ${n} ${n === 1 ? "reply" : "replies"} of this session flagged since the last note: ${[...new Set(flagged)].join("; ")}`, room) + tail);
}
return parts.length ? `\n\n${parts.join("\n")}` : "";
},
@@ -41,7 +41,7 @@ async function session(t, script, tools = ["read", "bash"], done = (r) => `ANSWE
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
// Row 66: the run directory, so the unslop hooks log to <dir>/session/unslop.jsonl, which the
// Row 66: the run directory, so the unslop hooks log to <dir>/unslop.jsonl, which the
// runner makes before the first turn.
runDir: dir,
};
+5 -3
View File
@@ -7,6 +7,7 @@
// ENV <name> answer "env: <value of name>"
// UNSLOP <json> append each event of the JSON array to $MOSAIC_UNSLOP_LOG, as the harness's
// unslop check does (row 66), and answer "unslop: <count>"
// UNSLOPLONG <json> the same, with an answer of 40000 more characters, over the runner's clip
// anything else answer "echo: <request>"
// Every turn appends its pid to $FAKE_PIDS when set.
@@ -35,10 +36,11 @@ if (body.startsWith("TOOL ")) {
} else if (body.startsWith("ENV ")) {
process.stdout.write(`env: ${process.env[body.slice(4)]}\n`);
done();
} else if (body.startsWith("UNSLOP ")) {
const events = JSON.parse(body.slice(7));
} else if (body.startsWith("UNSLOP ") || body.startsWith("UNSLOPLONG ")) {
const long = body.startsWith("UNSLOPLONG ");
const events = JSON.parse(body.slice(long ? 11 : 7));
for (const e of events) appendFileSync(process.env.MOSAIC_UNSLOP_LOG, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
process.stdout.write(`unslop: ${events.length}\n`);
process.stdout.write(`unslop: ${events.length}${long ? " " + "x".repeat(40000) : ""}\n`);
done();
} else if (body === "NOMARKER") {
process.stdout.write("half an answer");
+1 -1
View File
@@ -42,7 +42,7 @@ async function session(t, script, place = (dir) => join(dir, "ws"), instance = "
sessionDir: join(dir, "session"),
toolSocket: sock.path,
turnMarker: join(dir, "turn.done"),
// Row 66: the run directory, so the unslop check logs to <dir>/session/unslop.jsonl, which the
// Row 66: the run directory, so the unslop check logs to <dir>/unslop.jsonl, which the
// runner makes before the first turn.
runDir: dir,
};
+53
View File
@@ -910,4 +910,57 @@ test("row 66 unslopNotes keeps a note until sent(), reads only whole lines, and
many.read();
assert.ok(many.note().length <= 600, many.note().length);
assert.match(many.note(), /^\n\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
// Both parts together are bounded too, and a reason the runner makes itself is cut like one the
// log gives: here the log path is a directory with a long name, so the read fails with EISDIR.
const deep = join(dir, "d".repeat(250));
mkdirSync(deep);
const both = unslopNotes(deep);
both.read();
assert.match(both.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: the runner can't read .*\.\.\.\]$/);
assert.equal(both.note().length, 2 + 71 + 300);
const both2 = unslopNotes(join(dir, "both.jsonl"));
writeFileSync(join(dir, "both.jsonl"), [JSON.stringify({ ev: "broken_lists", reason: "b".repeat(400) }), ...Array.from({ length: 50 }, (_, i) => JSON.stringify(tell(`banned word "w${i}" x1`.padEnd(40, "x"))))].join("\n") + "\n");
both2.read();
assert.equal(both2.note().length, 600);
assert.match(both2.note(), /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: b{297}\.\.\.\]\n\[unslop: 50 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
});
test("row 66 a clipped answer with both note parts stays under the bus's body limit, and the note under 600", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const events = [{ ev: "hook_failed", reason: "r".repeat(400) }, ...Array.from({ length: 30 }, (_, i) => tell(`banned word "w${i}" x1`.padEnd(60, "x")))];
ctx.call(ctx.pm, "message.send", { to: "coder", body: `UNSLOPLONG ${JSON.stringify(events)}`, class: "REQUEST" });
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
const body = ctx.inbox[0].body;
const at = body.indexOf("\n[answer cut at 32000 characters]");
assert.equal(at, 32000);
const note = body.slice(at + "\n[answer cut at 32000 characters]".length);
assert.ok(note.length <= 600, note.length);
assert.ok(body.length <= 32768, body.length);
assert.match(note, /^\n\n\[unslop: the check is broken, so this session's replies go unchecked: r+\.\.\.\]\n\[unslop: 30 replies .*\.\.\.\. The session gets a correction notice on its next turn\.\]$/s);
});
test("row 66 a refused send keeps the note: it rides the next RESULT the session sends", async (t) => {
const ctx = await setup(t);
ctx.inbox = [];
// The broker refuses the session's first RESULT once, as it would a body it can't take.
const request = ctx.broker.request.bind(ctx.broker);
let refused = 0;
ctx.broker.request = (cap, r) => {
if (r.verb === "message.send" && r.args?.class === "RESULT" && refused++ === 0) throw new BusError("invalid-request");
return request(cap, r);
};
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
await until(() => ctx.store.get("SELECT 1 FROM role_claims WHERE role='coder' AND op='claim'"));
const sent = unslopAsk(ctx, [tell('banned word "delve" x1')]);
await until(() => r.stderr().includes(`runner: reply to ${sent.id} failed: invalid-request\n`));
// The refused message was delivered and isn't read; its reply never reached the bus.
assert.ok(!ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
const next = await ask(ctx, "ENV MOSAIC_MODEL");
assert.equal(next.body, 'env: fake-model\n\n[unslop: 1 reply of this session flagged since the last note: banned word "delve" x1. The session gets a correction notice on its next turn.]');
assert.equal(ctx.inbox.length, 1);
});
+2 -2
View File
@@ -33,7 +33,7 @@ function tmp(t, prefix) {
function fixtureRepo(t) {
const dir = tmp(t, 'webui-copy-');
const repo = join(dir, 'repo');
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'tools/unslop-hook', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
@@ -72,7 +72,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts', 'tools']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
+2
View File
@@ -32,6 +32,8 @@ COPY_PATHS=(
packages/queue/src
# Whole directory: every in-repo plug-in module ships with the copy (row 131).
packages/plugins
# The unslop check both harness modules mount and list in the bundle (row 66).
tools/unslop-hook
)
# stack-release.sh sources this file and calls reachable before an activate (D1).
reachable() {