fix(fleet): verify fails-closed on unverifiable + interactive watch
- isSendAccepted now returns 'accepted' | 'draft' | 'unverifiable' (was bool)
- Blank/empty capture => 'unverifiable' => process.exitCode=1 with distinct
"could not verify delivery (blank/no response captured)" message; previously
blank was treated as success, violating FR-5 fail-closed semantics
- Draft line ('^> ') => process.exitCode=1 with "left as unsubmitted draft"
message; distinct wording from unverifiable case
- agent watch now dispatched through injectable InteractiveRunner (stdio:inherit)
instead of the capturing CommandRunner; tmux attach requires TTY passthrough
- Default spawnInteractive implementation uses node:child_process spawn with
stdio:'inherit'; injectable via FleetCommandDeps.interactiveRunner for tests
- Removed buildSystemdIsActiveCommand (dead code — exported but unused)
- Tests: blank=>exitCode=1, draft=>exitCode=1, real response=>exitCode=0,
watch dispatched through interactiveRunner not capturing runner
- PRD: added "Known limitations" section (heuristic verify, blank fails closed,
non-pi/claude draft detection is best-effort, watch requires TTY passthrough)
- Code comment on isSendAccepted notes pi/claude-specific draft heuristic
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMoEx7hfdFGjUiCHuN1RRi
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
resolveFleetPaths,
|
||||
type AgentPsRow,
|
||||
type CommandRunner,
|
||||
type InteractiveRunner,
|
||||
} from './fleet.js';
|
||||
import { registerAgentCommand } from './agent.js';
|
||||
|
||||
@@ -1057,7 +1058,7 @@ describe('agent watch', () => {
|
||||
expect(cmd).toContain('-r');
|
||||
});
|
||||
|
||||
it('issues the read-only attach command through the injected runner', async () => {
|
||||
it('dispatches the read-only attach command through the interactiveRunner, NOT the capturing runner', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
@@ -1067,19 +1068,29 @@ describe('agent watch', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const calls: string[][] = [];
|
||||
const capturingCalls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
capturingCalls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
|
||||
const interactiveCalls: string[][] = [];
|
||||
const interactiveRunner: InteractiveRunner = async (command, args) => {
|
||||
interactiveCalls.push([command, ...args]);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||
registerAgentCommand(program, { runner, interactiveRunner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'agent', 'watch', 'coder0']);
|
||||
expect(calls).toEqual([['tmux', '-L', 'mosaic-factory', 'attach', '-r', '-t', '=coder0']]);
|
||||
// Must go through interactiveRunner, not the capturing runner
|
||||
expect(capturingCalls).toHaveLength(0);
|
||||
expect(interactiveCalls).toEqual([
|
||||
['tmux', '-L', 'mosaic-factory', 'attach', '-r', '-t', '=coder0'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1126,17 +1137,17 @@ describe('agent send --verify', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('isSendAccepted: returns true for normal response output', () => {
|
||||
expect(isSendAccepted('Some response text\nAnother line\n')).toBe(true);
|
||||
it('isSendAccepted: returns "accepted" for normal response output', () => {
|
||||
expect(isSendAccepted('Some response text\nAnother line\n')).toBe('accepted');
|
||||
});
|
||||
|
||||
it('isSendAccepted: returns false when last line starts with "> " (draft pattern)', () => {
|
||||
expect(isSendAccepted('> my unsent message')).toBe(false);
|
||||
it('isSendAccepted: returns "draft" when last line starts with "> " (draft pattern)', () => {
|
||||
expect(isSendAccepted('> my unsent message')).toBe('draft');
|
||||
});
|
||||
|
||||
it('isSendAccepted: returns true for blank pane (treated as submitted)', () => {
|
||||
expect(isSendAccepted('')).toBe(true);
|
||||
expect(isSendAccepted(' \n \n')).toBe(true);
|
||||
it('isSendAccepted: returns "unverifiable" for blank/empty pane (full-screen TUI case)', () => {
|
||||
expect(isSendAccepted('')).toBe('unverifiable');
|
||||
expect(isSendAccepted(' \n \n')).toBe('unverifiable');
|
||||
});
|
||||
|
||||
it('issues send then verify capture via injected runner when --verify is passed', async () => {
|
||||
@@ -1219,4 +1230,142 @@ describe('agent send --verify', () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('send --verify: blank capture sets process.exitCode=1 (unverifiable, fails closed)', async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const stderrMessages: string[] = [];
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
|
||||
stderrMessages.push(String(msg));
|
||||
return true;
|
||||
});
|
||||
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
const full = [command, ...args].join(' ');
|
||||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||||
// capture-pane returns blank (full-screen TUI)
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'agent',
|
||||
'send',
|
||||
'coder0',
|
||||
'--message',
|
||||
'hello',
|
||||
'--verify',
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(stderrMessages.join('')).toMatch(/could not verify delivery.*blank/i);
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
stderrSpy.mockRestore();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it('send --verify: draft line sets process.exitCode=1 with distinct wording', async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const stderrMessages: string[] = [];
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
|
||||
stderrMessages.push(String(msg));
|
||||
return true;
|
||||
});
|
||||
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
const full = [command, ...args].join(' ');
|
||||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||||
// capture-pane returns a draft line ("> unsent message")
|
||||
return { stdout: '> unsent message\n', stderr: '', exitCode: 0 };
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'agent',
|
||||
'send',
|
||||
'coder0',
|
||||
'--message',
|
||||
'hello',
|
||||
'--verify',
|
||||
]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(stderrMessages.join('')).toMatch(/unsubmitted draft/i);
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
stderrSpy.mockRestore();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it('send --verify: real response content sets exitCode=0 (accepted)', async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
|
||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
const full = [command, ...args].join(' ');
|
||||
if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
|
||||
// capture-pane returns real response content
|
||||
return { stdout: 'Agent response: task completed.\n', stderr: '', exitCode: 0 };
|
||||
};
|
||||
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'agent',
|
||||
'send',
|
||||
'coder0',
|
||||
'--message',
|
||||
'hello',
|
||||
'--verify',
|
||||
]);
|
||||
// exitCode should remain unchanged (not set to 1)
|
||||
expect(process.exitCode).toBe(originalExitCode);
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user