fix(harness): acknowledge Claude helper completion after stdout drains (#797)

This commit is contained in:
jason.woltje committed 2026-11-05 22:10:37 -06:00
1 parent 6c123ce671
commit 8d27a82ea7
6 files changed
+50 -11

No files matched your search

+5
View File
@@ -6220,3 +6220,8 @@ Before: 8094901bce2d46f952786eea6469643d8dac8868 propagated helper death/load er
After: the adapter captures a private fd-3 completion handshake independently of answer stdout, CLI stderr and exit status. The helper's child does not inherit the completion descriptor. Complete CLI results retain their bytes and nonzero codes; invalid/absent completion emits exactly the fixed diagnostic and cannot save a new session. Helper stacks and shell crash text stay private. No completion file or writable mount, no broker/role/contract changes, and no watchdog timing/default changes. Helper SHA-256 and bundle pin: ce66fb2581dd789163f227030b71a26af04ce0d165fc5da6da6818eeac044ed0.
Verification: added adjacent SIGKILL, missing-helper, static-import and runtime-exception regressions plus complete CLI exit-1/137 byte-parity controls. Before product edits, whole-file baseline repeat had 20 pass/4 intended fail; the first run's additional short-watchdog assertion failure is retained honestly. Focused fixed file passes 24/24. Final requested matrix: 420 pass, 0 fail/skip/cancel/todo across 31 whole files, one sequential node --test invocation per file. Node v24.22.1, requested PATH first, set -o pipefail and fresh TMPDIR from mktemp -d /mnt/storage/scratch/tmp/XXXXXX. Real CLI localhost HTTP 400 retains identical 38 stdout bytes, exit 1 and empty stderr, no activity/session. Adapted direct and 13 managed hostile/fallback probes pass; managed helper death/missing-helper stderr is exact, with no activity/session and recorded descendants reaped. Five real bubblewrap invocations preserve read-only stream.mjs/session.json and distinguish helper SIGKILL from handled CLI errors at 1/137. Source/pin scans, whitespace and append-only prefix checks pass. Evidence and report: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r3-report.md and coder-stall-fix-r3-evidence/. Local review candidate only; independent exact-commit review remains with the lead. No push, stash, amend/rewrite, forge write, real model call, contact with 10.1.1.53, deployment or live operation.
## 2026-11-06 — MVP 3 coder-stall r3 review F2 drain-window correction (#797), lead fix
Independent r3 review of 05fdfef9e..6c123ce67 returned CHANGES with one P2: stream.mjs wrote its fd-3 `complete` token before stdout had drained a large answer, so a helper killed in that window exited 137 with partial stdout and empty stderr, indistinguishable from a complete CLI error. Correction: the helper acknowledges only from the stdout write callback (an error there acknowledges `invalid`), and the token names the exit status (`complete <status>`). adapter.sh accepts only a token equal to `complete $HELPER_STATUS`; a bare or mismatched token gives the fixed diagnostic, and status 0 becomes 2. Complete CLI errors at 1/137 keep their bytes, status and empty stderr. Helper digest pin updated to ed1a052bdc1bf0bc38c28af684d7e67bc0dd47328e05f0c0a3b7e44cad625978. New regression in model-activity.test.mjs (8 MiB answer, unread stdout, helper SIGKILL) failed on 6c123ce67 (empty stderr) and passes after the fix. Whole-file matrix: 31 files, 421 pass, 0 fail, 0 skip, sequential, Node v24.22.1. Reviewer probes rerun against the fix: partial-drain reproducer passes; helper-failure 4/4; managed-hostile 13/13; confined bubblewrap probe passes; boundary probe passes after one fixture was adapted from the old bare `complete` token to `complete 19`, plus new mismatch and legacy-bare-token cases that now produce the diagnostic. Evidence: /mnt/storage/scratch/mosaic-lead/mvp3/coder-stall-fix-r4-evidence/. Local commit only; needs a fresh independent review. No push, stash, real model call or contact with 10.1.1.53.
+5 -2
View File
@@ -97,8 +97,11 @@ only the fixed diagnostic and fail the turn; a failed or interrupted first turn
does not persist a reusable session ID. The helper is mounted read-only and
its digest is recorded in the launch bundle. A private completion pipe separates
handled CLI exits from helper launch/runtime failures, even if their exit codes
collide. The adapter emits the fixed diagnostic if completion is absent or the
stream is invalid, suppressing helper stacks and shell crash text. CLI stderr
collide. The helper acknowledges only after its answer has been handed to
stdout, and the token names its exit status (`complete <status>`). The adapter
emits the fixed diagnostic if completion is absent, the stream is invalid, or
the helper's actual exit differs from the acknowledged status (the helper died
while its answer was draining), suppressing helper stacks and shell crash text. CLI stderr
keeps its existing channel; the CLI does not inherit the completion pipe. No extra
writable mount or completion file is needed. Pi retains print mode and signals
activity through its existing extension. Mock remains a test-only echo.
+4 -1
View File
@@ -109,7 +109,10 @@ HELPER_STATUS=0
"$SESSION_FLAG" "$SESSION_ID" 3>&1 1>&4 4>&-; } 2>/dev/null
) || HELPER_STATUS=$?
} 4>&1 5>&2
if [ "$HELPER_COMPLETION" != complete ]; then
# The token must name the helper's actual exit status: "complete 0" for
# success, "complete N" for a complete CLI error. A helper killed after its
# answer was written but before exit leaves a mismatched status.
if [ "$HELPER_COMPLETION" != "complete $HELPER_STATUS" ]; then
printf 'claude adapter: invalid or incomplete model stream\n' >&2
[ "$HELPER_STATUS" -ne 0 ] || HELPER_STATUS=2
fi
+10 -7
View File
@@ -63,12 +63,15 @@ child.on('close', (code, signal) => {
// Integrity and rendering are separate from the CLI's success classification.
// Buffer until close so corrupt/truncated streams never leak a final result.
if (!failed && (!resultSeen || signal)) fail();
const status = process.exitCode = code || (failed || resultFailed ? 2 : 0);
// Only a fully consumed stream whose answer has been handed to stdout can
// acknowledge completion, and the token names the status it will exit with.
// A helper that dies after acknowledging exits with a different status, so
// the adapter still reports it. The adapter owns the fixed diagnostic,
// including when this helper never loads or dies early. spawn's explicit
// stdio keeps fd 3 out of the CLI and its descendants.
const acknowledge = error => writeSync(3, failed || error ? 'invalid' : `complete ${status}`);
if (!failed && answer !== undefined) {
process.stdout.write(answer.endsWith('\n') ? answer : answer + '\n');
}
process.exitCode = code || (failed || resultFailed ? 2 : 0);
// Only a fully consumed stream can acknowledge completion. The adapter
// owns the fixed diagnostic, including when this helper never loads/dies.
// spawn's explicit stdio keeps fd 3 out of the CLI and its descendants.
writeSync(3, failed ? 'invalid' : 'complete');
process.stdout.write(answer.endsWith('\n') ? answer : answer + '\n', acknowledge);
} else acknowledge();
});
+1 -1
View File
@@ -152,7 +152,7 @@ test("a claude-code bundle adds the wrapped gate hook and the MCP config", (t) =
assert.ok(m.typed.every((n) => n.startsWith("mcp__mosaic__")));
assert.equal(m.sources.claudeStream.path, join(REPO, "adapters/claude/stream.mjs"));
assert.equal(m.sources.claudeStream.sha256, sha(m.sources.claudeStream.path));
assert.equal(m.sources.claudeStream.sha256, "ce66fb2581dd789163f227030b71a26af04ce0d165fc5da6da6818eeac044ed0");
assert.equal(m.sources.claudeStream.sha256, "ed1a052bdc1bf0bc38c28af684d7e67bc0dd47328e05f0c0a3b7e44cad625978");
assert.deepEqual(Object.keys(m.sources).sort(), ["claudeGate", "claudeStream", "gate", "mcpServer", "tools", "unslopCheck", "unslopHook", "unslopLists"]);
// Row 66 (#1559): the unslop hooks run after a reply and before the next prompt, and never block.
for (const [event, which] of [["Stop", "stop"], ["UserPromptSubmit", "prompt"]]) {
@@ -166,6 +166,31 @@ for (const mode of ['sigkill', 'missing', 'static-import', 'runtime']) {
});
}
// The helper can die after its result is complete but while a large answer is
// still draining. That must not look like a complete CLI result.
test('Claude helper killed while its answer drains reports the fixed diagnostic', { timeout: 20_000 }, async t => {
const f = fixture(t);
const script = join(f.runDir, 'cli.cjs');
writeFileSync(script, `require('node:fs').writeFileSync(${JSON.stringify(join(f.workspace, 'helper-pid'))}, String(process.ppid));
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'x'.repeat(8 << 20) }) + '\\n');`);
writeFileSync(f.cli, `#!/bin/sh\nexec '${process.execPath}' '${script}' "$@"\n`);
const child = spawn('/bin/sh', [ADAPTER], { env: f.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
let stderr = '', bytes = 0;
child.stderr.on('data', b => { stderr += b; });
const done = new Promise(resolve => child.once('close', code => resolve(code)));
// Leave stdout unread so the answer cannot drain, then kill the helper.
await new Promise(resolve => child.stdout.once('readable', resolve));
await delay(150);
process.kill(Number(readFileSync(join(f.workspace, 'helper-pid'), 'utf8')), 'SIGKILL');
child.stdout.on('data', b => { bytes += b.length; });
const code = await done;
assert.equal(code, 137);
assert.ok(bytes < (8 << 20) + 1, 'the answer was cut short');
assert.equal(stderr, 'claude adapter: invalid or incomplete model stream\n');
assert.equal(existsSync(join(f.runDir, 'session/claude-session-id')), false);
});
// A handled CLI result can return the same codes as a dead/unloaded helper.
for (const code of [1, 137]) {
test(`Claude complete CLI error exit ${code} preserves text bytes without a helper diagnostic`, async t => {