From 99fee15c329f7aeaf47987bf0353002e657f4681 Mon Sep 17 00:00:00 2001 From: genie Date: Sat, 18 Apr 2026 15:08:38 +0000 Subject: [PATCH 1/4] fix(v0.2): add resolveWorkerLivenessByTransport helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared helper that dispatches on paneId shape: tmux (%N) → isPaneAlive, synthetic (sdk/inline/'') → isExecutorAlive. Mirrors the regex-guard pattern from scheduler-daemon.ts:countActiveWorkers (PR #1181) and unifies the call-sites that previously called isPaneAlive blindly (PR #1167 + this sweep). --- src/lib/executor-registry.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/lib/executor-registry.ts b/src/lib/executor-registry.ts index 87f5985a7..0f521aa1a 100644 --- a/src/lib/executor-registry.ts +++ b/src/lib/executor-registry.ts @@ -284,3 +284,39 @@ export async function getLiveExecutorState(agentId: string): Promise { return (await getLiveExecutorState(agentId)) !== null; } + +/** + * Transport-aware liveness check for a worker row. + * + * Dispatches on paneId shape: + * - tmux pane (`%N`) → `isPaneAliveFn(paneId)` (authoritative for tmux) + * - synthetic id (`sdk`, `inline`, `''`, etc.) → `isExecutorAliveFn(agentId)` + * which consults `executors.state` — the live signal for non-tmux transports. + * + * Unifies the five parallel call-sites that previously called `isPaneAlive` + * blindly (PR #1167 + this sweep). Mirrors the `%\d+` regex-guard pattern from + * `scheduler-daemon.ts:countActiveWorkers` and + * `term-commands/agents.ts:resolveWorkerLiveness`. + * + * Test injection: both `isPaneAliveFn` and `isExecutorAliveFn` are overridable + * so unit tests can exercise the branch logic without real tmux or PG. + */ +export async function resolveWorkerLivenessByTransport( + worker: { id: string; paneId: string }, + opts?: { + isPaneAliveFn?: (paneId: string) => Promise; + isExecutorAliveFn?: (agentId: string) => Promise; + }, +): Promise { + if (/^%\d+$/.test(worker.paneId)) { + const fn = + opts?.isPaneAliveFn ?? + (async (pane: string) => { + const { isPaneAlive } = await import('./tmux.js'); + return isPaneAlive(pane); + }); + return fn(worker.paneId); + } + const fn = opts?.isExecutorAliveFn ?? isExecutorAlive; + return fn(worker.id); +} From 8e47d356c84b12a58c57d5f5ed59f3c3b0c8c78e Mon Sep 17 00:00:00 2001 From: genie Date: Sat, 18 Apr 2026 15:14:00 +0000 Subject: [PATCH 2/4] fix(v0.2): transport-aware liveness at sites 1-3 in agents.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site 1 findDeadResumable: safe — provider filter excludes SDK (documented). Site 2 rejectDuplicateRole: dispatch via resolveWorkerLivenessByTransport so live SDK/omni/inline duplicates are detected instead of being silently cleaned. Session-recycle check gated on tmux paneId (synthetic paneIds never collide with a recycled pane). Site 3 resolveSpawnIdentity: widen isAliveFn signature to {id, paneId}; default routes through the shared helper. Prevents a live SDK canonical from being wrongly re-canonicalized and clobbering its session UUID via ON CONFLICT UPDATE. Updates test stubs in agents.test.ts and tui-spawn-dx.integration.test.ts to match signature. Complements #1167. --- .../tui-spawn-dx.integration.test.ts | 4 +- src/term-commands/agents.test.ts | 2 +- src/term-commands/agents.ts | 39 +++++++++++++++---- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/__tests__/tui-spawn-dx.integration.test.ts b/src/__tests__/tui-spawn-dx.integration.test.ts index a62cb33b9..9d8b193d7 100644 --- a/src/__tests__/tui-spawn-dx.integration.test.ts +++ b/src/__tests__/tui-spawn-dx.integration.test.ts @@ -107,9 +107,9 @@ async function killPane(id: string): Promise { } /** Controllable isAliveFn — returns true for any paneId seeded in the alive set. */ -function alivePanes(...alive: string[]): (paneId: string) => Promise { +function alivePanes(...alive: string[]): (agent: { id: string; paneId: string }) => Promise { const set = new Set(alive); - return async (paneId: string) => set.has(paneId); + return async (agent: { id: string; paneId: string }) => set.has(agent.paneId); } /** Stub crypto.randomUUID in a scoped fashion — restore after each test. */ diff --git a/src/term-commands/agents.test.ts b/src/term-commands/agents.test.ts index 47be2a228..382da7612 100644 --- a/src/term-commands/agents.test.ts +++ b/src/term-commands/agents.test.ts @@ -413,7 +413,7 @@ describe.skipIf(!DB_AVAILABLE)('spawn state machine', () => { }); } - /** Stub isPaneAlive — always-alive / always-dead. */ + /** Stub transport-aware liveness — always-alive / always-dead. */ const alwaysAlive = async () => true; const alwaysDead = async () => false; diff --git a/src/term-commands/agents.ts b/src/term-commands/agents.ts index 164362ace..8eb5341cf 100644 --- a/src/term-commands/agents.ts +++ b/src/term-commands/agents.ts @@ -1308,6 +1308,10 @@ export async function findDeadResumable(team: string, role: string): Promise w.role === role && w.team === team && w.claudeSessionId && w.provider === 'claude', ); if (!candidate) return null; + // Safe for SDK/non-tmux transports: the `provider === 'claude'` filter above + // excludes them by construction (SDK uses provider='claude-sdk'). Only + // tmux-resumable Claude-CLI agents reach `isPaneAlive`, so a plain paneId + // check is correct here — no transport dispatch needed. const alive = await isPaneAlive(candidate.paneId); return alive ? null : candidate; } @@ -1315,16 +1319,23 @@ export async function findDeadResumable(team: string, role: string): Promise { const existing = await registry.list(); for (const w of existing) { if (w.role === role && w.team === team) { - const alive = await isPaneAlive(w.paneId); + const alive = await executorRegistry.resolveWorkerLivenessByTransport(w); // tmux recycles pane IDs — a pane may be "alive" but belong to a - // completely different session now. Verify the pane is still in the - // expected session before blocking. - if (alive && w.session) { + // completely different session now. Verify the pane is still in the + // expected session before blocking. Only applies to real tmux panes; + // synthetic paneIds (sdk/inline/'') never collide with a recycled pane. + if (alive && w.session && /^%\d+$/.test(w.paneId)) { const paneSession = await getPaneSession(w.paneId); if (paneSession !== w.session) { // Pane was recycled — treat as dead @@ -1337,6 +1348,15 @@ async function rejectDuplicateRole(team: string, role: string): Promise { ); process.exit(1); } + // Live SDK/omni/inline row — block the duplicate without a session-recycle + // check, since synthetic paneIds cannot be recycled by tmux. + if (alive) { + console.error( + `Error: Worker with role "${role}" already exists in team "${team}" (state: ${w.state}, pane: ${w.paneId})\n` + + `Use a different --role name for a second worker, e.g.: --role ${role}-2`, + ); + process.exit(1); + } // Dead worker with same role — clean up stale registry entry so spawn can proceed await registry.unregister(w.id); } @@ -1754,13 +1774,18 @@ type SpawnIdentity = * registerable as Claude sessions. * * The `uuidFactory` and `isAliveFn` injection points exist for deterministic - * tests — production callers use `crypto.randomUUID` and `isPaneAlive`. + * tests — production callers use `crypto.randomUUID` and the shared + * `resolveWorkerLivenessByTransport` helper. `isAliveFn` receives the agent's + * id + paneId so it can dispatch by transport (tmux vs SDK/omni/inline) — a + * one-arg paneId-only check would misreport live SDK agents as dead and + * re-canonicalize them, clobbering the live row via ON CONFLICT UPDATE. */ export async function resolveSpawnIdentity( name: string, team: string, uuidFactory: () => string = () => crypto.randomUUID(), - isAliveFn: (paneId: string) => Promise = isPaneAlive, + isAliveFn: (agent: { id: string; paneId: string }) => Promise = (agent) => + executorRegistry.resolveWorkerLivenessByTransport(agent), ): Promise { const { getConnection } = await import('../lib/db.js'); const sql = await getConnection(); @@ -1795,7 +1820,7 @@ export async function resolveSpawnIdentity( }; } - const alive = existing.pane_id ? await isAliveFn(existing.pane_id) : false; + const alive = existing.pane_id ? await isAliveFn({ id: existing.id, paneId: existing.pane_id }) : false; if (!alive) { // findDeadResumable is the canonical resume path. If it didn't fire, the // existing row lacks a claudeSessionId or isn't a Claude row; creating a From d7e07100953d9d7467861c4ea2bfc03416e8d223 Mon Sep 17 00:00:00 2001 From: genie Date: Sat, 18 Apr 2026 15:15:53 +0000 Subject: [PATCH 3/4] fix(v0.2): transport-aware liveness at sites 4-7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Site 4 checkWorkerAlive (scheduler-daemon.ts): documented safe — runs.worker_id only carries real PIDs or a UUID daemonId fallback from handleTrigger; never a tmux pane or synthetic id. SDK agents live in agents/executors, not runs. Site 5 runAgentRecoveryPass (scheduler-daemon.ts): documented safe — claudeSessionId filter excludes SDK agents (SDK lacks a Claude-CLI JSONL). Site 6 isAgentAlive (team-auto-spawn.ts): dispatch via shared helper so inbox-watcher correctly routes messages to live SDK recipients instead of treating them as dead and dropping/misrouting. Site 7 executeAutoSpawn (hooks/handlers/auto-spawn.ts): dispatch via shared helper so the PreToolUse SendMessage handler stops triggering duplicate spawns on every message to a live SDK recipient. Complements #1167. --- src/hooks/handlers/auto-spawn.ts | 8 ++++++-- src/lib/scheduler-daemon.ts | 14 ++++++++++++++ src/lib/team-auto-spawn.ts | 13 +++++++------ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/hooks/handlers/auto-spawn.ts b/src/hooks/handlers/auto-spawn.ts index 6ca07bbc6..aa874b05a 100644 --- a/src/hooks/handlers/auto-spawn.ts +++ b/src/hooks/handlers/auto-spawn.ts @@ -76,12 +76,16 @@ function extractAutoSpawnTarget(payload: HookPayload): { recipient: string; team /** Find and execute a spawn template for the recipient. */ async function executeAutoSpawn(recipient: string, teamName: string): Promise { const registryMod = await import('../../lib/agent-registry.js'); - const tmuxMod = await import('../../lib/tmux.js'); + const executorRegistryMod = await import('../../lib/executor-registry.js'); const directoryMod = await import('../../lib/agent-directory.js'); const agents = await registryMod.list(); const existing = agents.find((a) => (a.role === recipient || a.id === recipient) && a.team === teamName); - if (existing && (await tmuxMod.isPaneAlive(existing.paneId))) return; + // Transport-aware liveness: a plain `isPaneAlive` check treats live SDK/ + // omni/inline recipients (synthetic paneIds like 'sdk', '', 'inline') as + // dead and triggers a duplicate spawn on every message. Dispatch by + // paneId shape so non-tmux transports consult `executors.state` instead. + if (existing && (await executorRegistryMod.resolveWorkerLivenessByTransport(existing))) return; const dirEntry = await directoryMod.resolve(recipient); const templates = await registryMod.listTemplates(); diff --git a/src/lib/scheduler-daemon.ts b/src/lib/scheduler-daemon.ts index 485f1a661..113b1eb6f 100644 --- a/src/lib/scheduler-daemon.ts +++ b/src/lib/scheduler-daemon.ts @@ -576,6 +576,15 @@ export async function reclaimExpiredLeases(deps: SchedulerDeps, daemonId: string /** * Check if a worker is alive by PID or tmux pane. * Returns { alive, isPid } for the given worker_id. + * + * Safe today for SDK/non-tmux transports: `runs.worker_id` is only ever + * populated by the scheduler at {@link handleTrigger} from `result.pid` (a + * real OS PID) or `daemonId` (a UUID fallback). It never stores a tmux pane + * id or a synthetic id like 'sdk'/'inline' (see `INSERT INTO runs` + + * `UPDATE runs SET worker_id` — the only two write sites). SDK-backed agents + * live in `agents`/`executors`, not `runs`, so no transport dispatch is + * needed here. If `runs.worker_id` ever starts carrying synthetic ids, add + * an `isExecutorAlive` branch below. */ async function checkWorkerAlive( deps: SchedulerDeps, @@ -742,6 +751,11 @@ export async function runAgentRecoveryPass( ): Promise<{ resumed: number; failed: number }> { const resolvedConfig = config ?? resolveConfig(); const workers = await deps.listWorkers(); + // Safe for SDK/non-tmux transports: the `claudeSessionId` filter below + // excludes them (SDK agents don't own a Claude-CLI JSONL session id). + // Only tmux-resumable Claude-CLI agents reach `isPaneAlive`, so a plain + // paneId check is correct here — no transport dispatch needed. If SDK + // ever gains resume support, gate on paneId shape like countActiveWorkers. const resumable = workers.filter((w) => w.state !== 'suspended' && w.state !== 'done' && w.claudeSessionId); let resumed = 0; diff --git a/src/lib/team-auto-spawn.ts b/src/lib/team-auto-spawn.ts index 3b58fdad9..34d14050b 100644 --- a/src/lib/team-auto-spawn.ts +++ b/src/lib/team-auto-spawn.ts @@ -106,14 +106,15 @@ export async function isTeamActive(teamName: string): Promise { } /** - * Check if a specific agent's tmux pane is alive. + * Check if a specific agent is alive (transport-aware). * - * Unlike `isTeamActive` which checks for the team window, this checks - * whether a specific agent's pane exists and is responsive. - * Used by inbox-watcher for per-recipient liveness checks. + * Used by inbox-watcher for per-recipient liveness checks. For tmux agents + * we ask tmux directly; for SDK/omni/inline agents (synthetic paneId), we + * consult `executors.state`. A plain `isPaneAlive` check misreports live + * SDK recipients as dead — causing the watcher to misroute messages. * * @param agentName - Agent name or ID to look up in the registry - * @returns true if the agent has a live pane + * @returns true if the agent has a live pane or a live executor */ export async function isAgentAlive(agentName: string): Promise { try { @@ -121,7 +122,7 @@ export async function isAgentAlive(agentName: string): Promise { const agents = await list(); const match = agents.find((a) => a.id === agentName || a.role === agentName); if (!match?.paneId) return false; - return tmux.isPaneAlive(match.paneId); + return executorRegistry.resolveWorkerLivenessByTransport(match); } catch { return false; } From 83a5b572f73f5ec5f26eef5747d96e283b400f55 Mon Sep 17 00:00:00 2001 From: genie Date: Sat, 18 Apr 2026 15:17:05 +0000 Subject: [PATCH 4/4] test(v0.2): regression coverage for transport-aware liveness sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors auto-resume-zombie-cap.test.ts:229-251 "synthetic paneIds" pattern: - 6 unit tests for resolveWorkerLivenessByTransport dispatch (tmux %N, sdk/inline/'', dead synthetic, dead tmux pane never falls through). - 4 DB-backed integration tests: isAgentAlive live SDK, isAgentAlive dead SDK, resolveSpawnIdentity live SDK → parallel, resolveSpawnIdentity dead SDK → canonical. Sites 2 and 7 are covered transitively — both callers pass their registry row straight to the shared helper, so the dispatch tests pin their contract. --- .../transport-aware-liveness.test.ts | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 src/lib/__tests__/transport-aware-liveness.test.ts diff --git a/src/lib/__tests__/transport-aware-liveness.test.ts b/src/lib/__tests__/transport-aware-liveness.test.ts new file mode 100644 index 000000000..9b4a07efc --- /dev/null +++ b/src/lib/__tests__/transport-aware-liveness.test.ts @@ -0,0 +1,308 @@ +/** + * Regression tests for the v0.2 transport-aware-liveness sweep. + * + * Complements PR #1167 (`buildWorkerStatusMap` / `resolveWorkerLiveness`) by + * covering the parallel callers that still called `isPaneAlive` blindly for + * every worker — mis-reporting SDK/omni/inline agents (synthetic paneIds like + * 'sdk', 'inline', '') as dead and triggering clobbers, dup-spawns, and + * misrouted messages. + * + * Reference pattern: `scheduler-daemon.ts:countActiveWorkers` (PR #1181) and + * `term-commands/agents.ts:resolveWorkerLiveness`. The shared helper lives in + * `executor-registry.ts:resolveWorkerLivenessByTransport`. + * + * Site coverage: + * - Helper: `resolveWorkerLivenessByTransport` dispatch (unit, no DB). + * - Site 3: `resolveSpawnIdentity` with live SDK canonical → parallel, not + * canonical clobber (integration, DB). + * - Site 6: `isAgentAlive` with live SDK agent → true (integration, DB). + * - Site 2 + Site 7: covered transitively by the helper dispatch tests — + * both callers pass their registry row straight to + * `resolveWorkerLivenessByTransport`, so the helper contract is the fix. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import * as registry from '../agent-registry.js'; +import { resolveWorkerLivenessByTransport } from '../executor-registry.js'; +import { DB_AVAILABLE, setupTestSchema } from '../test-db.js'; + +// --------------------------------------------------------------------------- +// Helper unit tests — exhaustive dispatch coverage without DB. +// --------------------------------------------------------------------------- + +describe('resolveWorkerLivenessByTransport (shared helper)', () => { + test('dispatches tmux paneIds (%N) to isPaneAliveFn', async () => { + const paneCalls: string[] = []; + const execCalls: string[] = []; + const alive = await resolveWorkerLivenessByTransport( + { id: 'alice', paneId: '%42' }, + { + isPaneAliveFn: async (p) => { + paneCalls.push(p); + return true; + }, + isExecutorAliveFn: async (id) => { + execCalls.push(id); + return false; + }, + }, + ); + + expect(alive).toBe(true); + expect(paneCalls).toEqual(['%42']); + expect(execCalls).toEqual([]); // never consulted + }); + + test('dispatches synthetic paneIds (sdk) to isExecutorAliveFn', async () => { + const paneCalls: string[] = []; + const execCalls: string[] = []; + const alive = await resolveWorkerLivenessByTransport( + { id: 'alice-sdk', paneId: 'sdk' }, + { + isPaneAliveFn: async (p) => { + paneCalls.push(p); + return false; + }, + isExecutorAliveFn: async (id) => { + execCalls.push(id); + return true; + }, + }, + ); + + expect(alive).toBe(true); + expect(paneCalls).toEqual([]); // never consulted — would have returned false + expect(execCalls).toEqual(['alice-sdk']); + }); + + test('dispatches empty paneId to isExecutorAliveFn', async () => { + const execCalls: string[] = []; + const alive = await resolveWorkerLivenessByTransport( + { id: 'alice-inline', paneId: '' }, + { + isPaneAliveFn: async () => { + throw new Error('should not call tmux for empty paneId'); + }, + isExecutorAliveFn: async (id) => { + execCalls.push(id); + return true; + }, + }, + ); + + expect(alive).toBe(true); + expect(execCalls).toEqual(['alice-inline']); + }); + + test('inline paneId routes to isExecutorAliveFn', async () => { + const alive = await resolveWorkerLivenessByTransport( + { id: 'alice-inline', paneId: 'inline' }, + { + isPaneAliveFn: async () => { + throw new Error('should not call tmux for inline paneId'); + }, + isExecutorAliveFn: async () => true, + }, + ); + expect(alive).toBe(true); + }); + + test('dead synthetic worker → false (no tmux call)', async () => { + let paneCalled = false; + const alive = await resolveWorkerLivenessByTransport( + { id: 'alice-sdk', paneId: 'sdk' }, + { + isPaneAliveFn: async () => { + paneCalled = true; + return true; + }, + isExecutorAliveFn: async () => false, + }, + ); + expect(alive).toBe(false); + expect(paneCalled).toBe(false); + }); + + test('%N with dead pane stays dead (no executor fallback)', async () => { + // Regression: the tmux branch must NOT fall through to isExecutorAlive + // when the pane is dead. Prevents false-alive for a tmux-transport agent + // that orphaned its executor row. + let execCalled = false; + const alive = await resolveWorkerLivenessByTransport( + { id: 'alice', paneId: '%99' }, + { + isPaneAliveFn: async () => false, + isExecutorAliveFn: async () => { + execCalled = true; + return true; + }, + }, + ); + expect(alive).toBe(false); + expect(execCalled).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Integration tests — DB-backed, exercises real executors rows. +// --------------------------------------------------------------------------- + +describe.skipIf(!DB_AVAILABLE)('transport-aware liveness — integration', () => { + let cleanupSchema: () => Promise; + + beforeAll(async () => { + cleanupSchema = await setupTestSchema(); + }); + + afterAll(async () => { + await cleanupSchema(); + }); + + beforeEach(async () => { + const { getConnection } = await import('../db.js'); + const sql = await getConnection(); + await sql`TRUNCATE TABLE agents CASCADE`; + await sql`TRUNCATE TABLE executors CASCADE`; + }); + + // --------------------------------------------------------------------- + // Site 6 — `isAgentAlive` in team-auto-spawn.ts + // Before: SDK agent with live executor → `isPaneAlive('sdk')` returned + // false → inbox-watcher misrouted messages as "agent dead". + // After: same scenario → transport-aware helper consults executors.state, + // returns true, routing proceeds normally. + // --------------------------------------------------------------------- + test('Site 6: isAgentAlive reports live SDK agent as alive', async () => { + const { isAgentAlive } = await import('../team-auto-spawn.js'); + const { getConnection } = await import('../db.js'); + const sql = await getConnection(); + + // Seed SDK agent row (synthetic paneId='sdk'). + await registry.register({ + id: 'alice-sdk', + paneId: 'sdk', + session: 'alice', + worktree: null, + startedAt: new Date().toISOString(), + state: 'idle', + lastStateChange: new Date().toISOString(), + repoPath: '/tmp/alice-sdk', + role: 'alice-sdk', + team: 'alice', + provider: 'claude-sdk', + }); + // Seed live executor + FK link (running state is in the "alive" set). + await sql` + INSERT INTO executors (id, agent_id, provider, transport, state, started_at) + VALUES ('exec-1', 'alice-sdk', 'claude-sdk', 'api', 'running', now()) + `; + await sql`UPDATE agents SET current_executor_id = 'exec-1' WHERE id = 'alice-sdk'`; + + expect(await isAgentAlive('alice-sdk')).toBe(true); + }); + + test('Site 6: isAgentAlive reports dead SDK agent as dead', async () => { + const { isAgentAlive } = await import('../team-auto-spawn.js'); + const { getConnection } = await import('../db.js'); + const sql = await getConnection(); + + await registry.register({ + id: 'bob-sdk', + paneId: 'sdk', + session: 'bob', + worktree: null, + startedAt: new Date().toISOString(), + state: 'done', + lastStateChange: new Date().toISOString(), + repoPath: '/tmp/bob-sdk', + role: 'bob-sdk', + team: 'bob', + provider: 'claude-sdk', + }); + // Seed terminated executor — getLiveExecutorState returns null. + await sql` + INSERT INTO executors (id, agent_id, provider, transport, state, started_at, ended_at) + VALUES ('exec-2', 'bob-sdk', 'claude-sdk', 'api', 'terminated', now(), now()) + `; + await sql`UPDATE agents SET current_executor_id = 'exec-2' WHERE id = 'bob-sdk'`; + + expect(await isAgentAlive('bob-sdk')).toBe(false); + }); + + // --------------------------------------------------------------------- + // Site 3 — `resolveSpawnIdentity` default liveness fn is transport-aware + // Before: SDK canonical with paneId='sdk' → isPaneAlive('sdk')=false → + // resolveSpawnIdentity returned 'canonical' → ON CONFLICT UPDATE + // rewrote the live SDK row's session UUID (clobber). + // After: same scenario → resolveWorkerLivenessByTransport consults + // executors.state, returns true → 'parallel', live row preserved. + // --------------------------------------------------------------------- + test('Site 3: resolveSpawnIdentity routes live SDK canonical to parallel', async () => { + const { resolveSpawnIdentity } = await import('../../term-commands/agents.js'); + const { getConnection } = await import('../db.js'); + const sql = await getConnection(); + + // Seed SDK canonical + live executor. + await registry.register({ + id: 'stefani', + paneId: 'sdk', + session: 'stefani', + worktree: null, + startedAt: new Date().toISOString(), + state: 'working', + lastStateChange: new Date().toISOString(), + repoPath: '/tmp/stefani', + claudeSessionId: 'original-canonical-uuid-0000-000000000000', + role: 'stefani', + team: 'stefani', + provider: 'claude-sdk', + }); + await sql` + INSERT INTO executors (id, agent_id, provider, transport, state, started_at) + VALUES ('exec-stefani', 'stefani', 'claude-sdk', 'api', 'working', now()) + `; + await sql`UPDATE agents SET current_executor_id = 'exec-stefani' WHERE id = 'stefani'`; + + const parallelUuid = 'feedface-1234-5678-9abc-abcdef012345'; + const identity = await resolveSpawnIdentity('stefani', 'stefani', () => parallelUuid); + + // Must route to parallel — canonical row is live and must not be rewritten. + expect(identity.kind).toBe('parallel'); + if (identity.kind === 'parallel') { + expect(identity.canonicalId).toBe('stefani'); + expect(identity.workerId).toBe('stefani-feed'); + } + + // Canonical row is byte-identical to the seed. + const canonical = await registry.get('stefani'); + expect(canonical?.claudeSessionId).toBe('original-canonical-uuid-0000-000000000000'); + expect(canonical?.paneId).toBe('sdk'); + }); + + test('Site 3: resolveSpawnIdentity treats dead SDK canonical as dead', async () => { + // Complementary regression: if the executor is terminated, the dead-row + // branch must still fire (returns canonical for recovery). + const { resolveSpawnIdentity } = await import('../../term-commands/agents.js'); + + await registry.register({ + id: 'zombie', + paneId: 'sdk', + session: 'zombie', + worktree: null, + startedAt: new Date().toISOString(), + state: 'error', + lastStateChange: new Date().toISOString(), + repoPath: '/tmp/zombie', + claudeSessionId: 'stale-uuid-0000-0000-0000-000000000000', + role: 'zombie', + team: 'zombie', + provider: 'claude-sdk', + }); + // No current_executor_id → getLiveExecutorState returns null → dead. + + const identity = await resolveSpawnIdentity('zombie', 'zombie', () => 'fresh-uuid'); + expect(identity.kind).toBe('canonical'); + expect(identity.workerId).toBe('zombie'); + expect(identity.sessionUuid).toBe('fresh-uuid'); + }); +});