fix(fleet): verify fails-closed on unverifiable + interactive watch
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/pr/ci Pipeline was canceled

- 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:
Jarvis
2026-06-20 22:45:22 -05:00
parent ddeb200fdf
commit aec560162b
3 changed files with 251 additions and 58 deletions

View File

@@ -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);
});