ci/woodpecker/pr/ci Pipeline failed
The mosaic wrapper makes pane_current_command=node (RUNTIME_ACCEPTABLE_COMMANDS.claude=['claude','node']); the walk matters precisely in that no-shell-wrapper case. Match reality. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
105 lines
3.8 KiB
TypeScript
105 lines
3.8 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import type { CommandResult, CommandRunner, FleetRoster } from '../commands/fleet.js';
|
|
import { TmuxPromotionTransport } from './promotion-transport.js';
|
|
|
|
const sessionId = 'a'.repeat(64);
|
|
const roster: FleetRoster = {
|
|
agents: [{ className: 'worker', name: 'claude-seat', runtime: 'claude' }],
|
|
defaults: { workingDirectory: '~/src' },
|
|
runtimes: {},
|
|
tmux: { holderSession: '_holder', socketName: 'mosaic-fleet' },
|
|
transport: 'tmux',
|
|
version: 1,
|
|
};
|
|
|
|
function result(stdout = '', exitCode = 0, stderr = ''): CommandResult {
|
|
return { exitCode, stderr, stdout };
|
|
}
|
|
|
|
describe('TmuxPromotionTransport', () => {
|
|
it('resolves the exact roster seat and sends the registered command literally', async () => {
|
|
const runner = vi
|
|
.fn<CommandRunner>()
|
|
.mockResolvedValueOnce(result('1234 claude 0 0 0 0\n'))
|
|
.mockResolvedValueOnce(result())
|
|
.mockResolvedValueOnce(result());
|
|
const environmentReader = vi.fn(async () => `MOSAIC_LEASE_SESSION_ID=${sessionId}\0`);
|
|
const transport = new TmuxPromotionTransport({
|
|
environmentReader,
|
|
mosaicHome: '/mosaic',
|
|
rosterLoader: async () => roster,
|
|
runner,
|
|
});
|
|
|
|
const target = await transport.resolve('claude-seat');
|
|
await transport.sendPromotion(target);
|
|
|
|
expect(target).toEqual({
|
|
bundle: 'mosaic-fleet',
|
|
seat: 'claude-seat',
|
|
sessionId,
|
|
});
|
|
expect(environmentReader).toHaveBeenCalledWith(1234);
|
|
expect(runner).toHaveBeenNthCalledWith(2, 'tmux', [
|
|
'-L',
|
|
'mosaic-fleet',
|
|
'send-keys',
|
|
'-t',
|
|
'=claude-seat:0.0',
|
|
'-l',
|
|
'/mosaic-promote',
|
|
]);
|
|
expect(runner).toHaveBeenNthCalledWith(3, 'tmux', [
|
|
'-L',
|
|
'mosaic-fleet',
|
|
'send-keys',
|
|
'-t',
|
|
'=claude-seat:0.0',
|
|
'Enter',
|
|
]);
|
|
});
|
|
|
|
// Regression for #1124: the launcher runs claude as a spawnSync CHILD of
|
|
// node(mosaic), so the lease env is on the child, not the tmux pane pid. The
|
|
// transport must WALK the subtree. This test exercises the real walk (no
|
|
// full mock of the resolution) — the seam the original unit test hid.
|
|
it('walks the pane subtree to the claude child that carries the lease id', async () => {
|
|
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
|
|
// pane pid 1234 = node(mosaic): NO lease env. child 5678 = claude: carries it.
|
|
const environmentReader = vi.fn(async (pid: number) =>
|
|
pid === 5678 ? `FOO=bar\0MOSAIC_LEASE_SESSION_ID=${sessionId}\0` : `FOO=bar\0`,
|
|
);
|
|
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
|
|
const transport = new TmuxPromotionTransport({
|
|
environmentReader,
|
|
childrenReader,
|
|
mosaicHome: '/mosaic',
|
|
rosterLoader: async () => roster,
|
|
runner,
|
|
});
|
|
|
|
const target = await transport.resolve('claude-seat');
|
|
|
|
expect(target.sessionId).toBe(sessionId);
|
|
expect(environmentReader).toHaveBeenCalledWith(1234); // pane pid: no lease
|
|
expect(environmentReader).toHaveBeenCalledWith(5678); // walked to the child
|
|
expect(childrenReader).toHaveBeenCalledWith(1234); // walk actually ran
|
|
});
|
|
|
|
it('fails closed when no process in the pane subtree carries a lease id', async () => {
|
|
const runner = vi.fn<CommandRunner>().mockResolvedValueOnce(result('1234 node 0 0 0 0\n'));
|
|
const environmentReader = vi.fn(async () => `FOO=bar\0`);
|
|
const childrenReader = vi.fn(async (pid: number) => (pid === 1234 ? [5678] : []));
|
|
const transport = new TmuxPromotionTransport({
|
|
environmentReader,
|
|
childrenReader,
|
|
mosaicHome: '/mosaic',
|
|
rosterLoader: async () => roster,
|
|
runner,
|
|
});
|
|
|
|
await expect(transport.resolve('claude-seat')).rejects.toThrow('no readable lease session');
|
|
expect(childrenReader).toHaveBeenCalledWith(1234);
|
|
});
|
|
});
|