fix(harness): acknowledge Claude helper completion after stdout drains (#797)
This commit is contained in:
1 parent
6c123ce671
commit
8d27a82ea7
6 files changed
+50
-11
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user