diff --git a/packages/mosaic/src/fleet/promotion-transport.spec.ts b/packages/mosaic/src/fleet/promotion-transport.spec.ts index c37dcb9c..a59d9187 100644 --- a/packages/mosaic/src/fleet/promotion-transport.spec.ts +++ b/packages/mosaic/src/fleet/promotion-transport.spec.ts @@ -58,4 +58,47 @@ describe('TmuxPromotionTransport', () => { '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().mockResolvedValueOnce(result('1234 claude 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().mockResolvedValueOnce(result('1234 claude 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); + }); }); diff --git a/packages/mosaic/src/fleet/promotion-transport.ts b/packages/mosaic/src/fleet/promotion-transport.ts index 4a3f3015..2e8fa408 100644 --- a/packages/mosaic/src/fleet/promotion-transport.ts +++ b/packages/mosaic/src/fleet/promotion-transport.ts @@ -29,18 +29,27 @@ export interface PromotionTransport { export interface TmuxPromotionTransportOptions { environmentReader?: (pid: number) => Promise; + childrenReader?: (pid: number) => Promise; mosaicHome: string; rosterLoader?: () => Promise; runner: CommandRunner; } +// The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (see +// launch.ts:99 — deliberate, so the parent survives to propagate signals), so +// MOSAIC_LEASE_SESSION_ID lives on the claude child, NOT on the tmux pane's root +// pid. Bound the descendant search so a hung/large process tree can't stall it. +const MAX_SUBTREE_PIDS = 128; + /** Local, roster-bound transport for the in-seat promotion command. */ export class TmuxPromotionTransport implements PromotionTransport { private readonly environmentReader: (pid: number) => Promise; + private readonly childrenReader: (pid: number) => Promise; private readonly rosterLoader: () => Promise; constructor(private readonly options: TmuxPromotionTransportOptions) { this.environmentReader = options.environmentReader ?? readPaneEnvironment; + this.childrenReader = options.childrenReader ?? readChildPids; this.rosterLoader = options.rosterLoader ?? (() => loadFleetRoster(resolveFleetPaths(options.mosaicHome).rosterPath)); @@ -69,7 +78,7 @@ export class TmuxPromotionTransport implements PromotionTransport { ) { throw new Error(`Promotion seat runtime identity mismatch: ${seat}.`); } - const sessionId = parseLeaseSessionId(await this.environmentReader(pane.pid)); + const sessionId = await this.resolveLeaseSessionId(pane.pid); if (sessionId === null) { throw new Error(`Promotion seat has no readable lease session: ${seat}.`); } @@ -80,6 +89,39 @@ export class TmuxPromotionTransport implements PromotionTransport { }; } + /** + * Find the lease session id in the pane's process subtree. The pane's root pid + * is node(mosaic), which has no lease env; the id lives on the claude child. + * BFS from the root, bounded, returning the first descendant that carries a + * valid MOSAIC_LEASE_SESSION_ID. Fail-closed (null) if none is found. + */ + private async resolveLeaseSessionId(rootPid: number): Promise { + const queue: number[] = [rootPid]; + const seen = new Set(); + while (queue.length > 0 && seen.size < MAX_SUBTREE_PIDS) { + const pid = queue.shift()!; + if (seen.has(pid)) continue; + seen.add(pid); + let sessionId: string | null = null; + try { + sessionId = parseLeaseSessionId(await this.environmentReader(pid)); + } catch { + sessionId = null; + } + if (sessionId !== null) return sessionId; + let children: number[] = []; + try { + children = await this.childrenReader(pid); + } catch { + children = []; + } + for (const child of children) { + if (!seen.has(child)) queue.push(child); + } + } + return null; + } + async sendPromotion(target: PromotionTarget): Promise { const targetPane = `=${target.seat}:0.0`; const socketName = target.bundle === 'default' ? '' : target.bundle; @@ -143,6 +185,20 @@ async function readPaneEnvironment(pid: number): Promise { return readFile(`/proc/${pid}/environ`, 'utf8'); } +async function readChildPids(pid: number): Promise { + // Linux exposes direct children of the main thread here (CONFIG_PROC_CHILDREN). + try { + const raw = await readFile(`/proc/${pid}/task/${pid}/children`, 'utf8'); + return raw + .split(/\s+/) + .filter(Boolean) + .map((value) => Number.parseInt(value, 10)) + .filter((value) => Number.isInteger(value) && value > 0); + } catch { + return []; + } +} + function parseLeaseSessionId(environment: string): string | null { const value = environment .split('\0')