From 040b667ddf1a4c2e22dc70efecb3f975a3375983 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 13:00:41 -0300 Subject: [PATCH 1/2] feat: add team-lead liveness checks, inbox watcher, and CI coverage gate - Wire isPaneAlive() into isTeamActive() so dead team-lead processes are detected - Store team-lead pane ID in agent-registry for tracking and auto-respawn - Add 30s grace period to prevent false negatives during slow startup - New inbox-watcher daemon polls native inboxes every 30s, spawns offline team-leads - Backoff after 3 failed spawn attempts to prevent crash loops - CI coverage gate enforces 68% minimum line coverage threshold - Add saveTeamLeadEntry/getTeamLeadEntry to agent-registry - Add listTeamsWithUnreadInbox to claude-native-teams --- .github/workflows/ci.yml | 18 ++- knip.json | 2 +- src/lib/agent-registry.ts | 43 ++++++ src/lib/claude-native-teams.ts | 59 +++++++++ src/lib/inbox-watcher.test.ts | 184 ++++++++++++++++++++++++++ src/lib/inbox-watcher.ts | 143 ++++++++++++++++++++ src/lib/team-auto-spawn.test.ts | 226 ++++++++++++++++++++++++++++++-- src/lib/team-auto-spawn.ts | 137 +++++++++++++++---- 8 files changed, 772 insertions(+), 40 deletions(-) create mode 100644 src/lib/inbox-watcher.test.ts create mode 100644 src/lib/inbox-watcher.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ae4762ae..1fa37d35a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,8 +65,22 @@ jobs: - name: Dead code check (knip) run: bunx knip - - name: Test - run: bun test || bun test + - name: Test with coverage + run: | + COVERAGE_OUTPUT=$(bun test --coverage 2>&1) + echo "$COVERAGE_OUTPUT" + LINE_COV=$(echo "$COVERAGE_OUTPUT" | grep "All files" | awk -F'|' '{print $2}' | tr -d ' ') + echo "Line coverage: ${LINE_COV}%" + if [ -z "$LINE_COV" ]; then + echo "WARNING: Could not parse coverage — skipping threshold check" + exit 0 + fi + THRESHOLD=68 + if [ "$(echo "$LINE_COV < $THRESHOLD" | bc -l)" = "1" ]; then + echo "FAILED: Coverage ${LINE_COV}% is below ${THRESHOLD}% minimum" + exit 1 + fi + echo "PASSED: Coverage ${LINE_COV}% meets ${THRESHOLD}% minimum" publish-next: name: Publish @next diff --git a/knip.json b/knip.json index 3e3315bd0..2f25eed5e 100644 --- a/knip.json +++ b/knip.json @@ -2,6 +2,6 @@ "$schema": "https://unpkg.com/knip@5.85.0/schema.json", "project": ["src/**/*.ts"], "ignoreBinaries": ["tmux", "which"], - "ignore": ["src/lib/team-auto-spawn.ts"], + "ignore": ["src/lib/inbox-watcher.ts"], "ignoreExportsUsedInFile": true } diff --git a/src/lib/agent-registry.ts b/src/lib/agent-registry.ts index be1f611ca..50ada0908 100644 --- a/src/lib/agent-registry.ts +++ b/src/lib/agent-registry.ts @@ -299,6 +299,49 @@ export async function removeSubPane(workerId: string, paneId: string, registryPa // Worker Templates (for auto-respawn) // ============================================================================ +// ============================================================================ +// Team-Lead Helpers +// ============================================================================ + +/** + * Save a team-lead entry in the agent registry. + * Creates/overwrites the entry keyed by `team-lead:`. + */ +export async function saveTeamLeadEntry( + teamName: string, + paneId: string, + session: string, + windowName: string, + repoPath: string, +): Promise { + const id = `team-lead:${teamName}`; + await withRegistry((reg) => { + reg.workers[id] = { + id, + paneId, + session, + worktree: null, + startedAt: new Date().toISOString(), + state: 'spawning', + lastStateChange: new Date().toISOString(), + repoPath, + windowName, + role: 'team-lead', + team: teamName, + }; + }); +} + +/** + * Get the team-lead registry entry for a team. + * Returns null if no team-lead is registered. + */ +export async function getTeamLeadEntry(teamName: string): Promise { + const registry = await loadRegistry(); + const id = `team-lead:${teamName}`; + return registry.workers[id] ?? null; +} + /** Save or update a worker template. */ export async function saveTemplate(template: WorkerTemplate): Promise { await withRegistry((reg) => { diff --git a/src/lib/claude-native-teams.ts b/src/lib/claude-native-teams.ts index 645bf1cd0..37ff3d997 100644 --- a/src/lib/claude-native-teams.ts +++ b/src/lib/claude-native-teams.ts @@ -319,6 +319,65 @@ export async function deleteNativeTeam(teamName: string): Promise { return true; } +// ============================================================================ +// Inbox Scanning +// ============================================================================ + +/** + * List all teams that have unread messages in their team-lead inbox. + * + * Scans `~/.claude/teams/` for teams where `inboxes/team-lead.json` + * contains messages with `read: false`. Returns the team name, unread + * count, and working directory (from config.json → members → team-lead → cwd). + */ +export async function listTeamsWithUnreadInbox(): Promise< + Array<{ teamName: string; unreadCount: number; workingDir: string | null }> +> { + const base = teamsBaseDir(); + let teamDirs: string[]; + try { + teamDirs = await readdir(base); + } catch { + return []; // No teams directory + } + + const results: Array<{ teamName: string; unreadCount: number; workingDir: string | null }> = []; + + for (const name of teamDirs) { + // Read inbox messages + const inboxFile = join(base, name, 'inboxes', 'team-lead.json'); + let messages: NativeInboxMessage[]; + try { + const content = await readFile(inboxFile, 'utf-8'); + messages = JSON.parse(content); + } catch { + continue; // No inbox or invalid JSON + } + + if (!Array.isArray(messages)) continue; + + const unreadCount = messages.filter((m) => m.read === false).length; + if (unreadCount === 0) continue; + + // Get workingDir from config.json → members → team-lead → cwd + let workingDir: string | null = null; + try { + const cfgContent = await readFile(join(base, name, 'config.json'), 'utf-8'); + const config: NativeTeamConfig = JSON.parse(cfgContent); + const leadMember = config.members.find((m) => m.name === 'team-lead' || m.agentId.startsWith('team-lead@')); + if (leadMember?.cwd) { + workingDir = leadMember.cwd; + } + } catch { + // Config missing or malformed — workingDir stays null + } + + results.push({ teamName: name, unreadCount, workingDir }); + } + + return results; +} + // ============================================================================ // Session Discovery // ============================================================================ diff --git a/src/lib/inbox-watcher.test.ts b/src/lib/inbox-watcher.test.ts new file mode 100644 index 000000000..e61611014 --- /dev/null +++ b/src/lib/inbox-watcher.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for inbox-watcher module + * + * Tests the inbox polling logic using dependency injection — + * no real tmux, filesystem, or Claude Code sessions required. + * + * Run with: bun test src/lib/inbox-watcher.test.ts + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { InboxWatcherDeps } from './inbox-watcher.js'; +import { checkInboxes, resetSpawnFailures } from './inbox-watcher.js'; + +// ============================================================================ +// Test helpers +// ============================================================================ + +/** Create a minimal deps object with sensible defaults. Override as needed. */ +function makeDeps(overrides: Partial = {}): InboxWatcherDeps { + return { + listTeamsWithUnreadInbox: async () => [], + isTeamActive: async () => false, + ensureTeamLead: async () => ({ created: true }), + warn: () => {}, + ...overrides, + }; +} + +// ============================================================================ +// checkInboxes tests +// ============================================================================ + +describe('checkInboxes', () => { + beforeEach(() => { + resetSpawnFailures(); + process.env.GENIE_INBOX_POLL_MS = undefined; + }); + + afterEach(() => { + process.env.GENIE_INBOX_POLL_MS = undefined; + }); + + test('no teams → returns empty', async () => { + const deps = makeDeps(); + const result = await checkInboxes(deps); + expect(result).toEqual([]); + }); + + test('team with unread messages + active team-lead → no spawn triggered', async () => { + let spawnCalled = false; + const deps = makeDeps({ + listTeamsWithUnreadInbox: async () => [{ teamName: 'alpha', unreadCount: 3, workingDir: '/tmp/alpha' }], + isTeamActive: async () => true, + ensureTeamLead: async () => { + spawnCalled = true; + return { created: true }; + }, + }); + const result = await checkInboxes(deps); + expect(result).toEqual([]); + expect(spawnCalled).toBe(false); + }); + + test('team with unread messages + inactive team-lead → spawn triggered', async () => { + let spawnedTeam = ''; + let spawnedDir = ''; + const deps = makeDeps({ + listTeamsWithUnreadInbox: async () => [{ teamName: 'beta', unreadCount: 1, workingDir: '/tmp/beta' }], + isTeamActive: async () => false, + ensureTeamLead: async (teamName, workingDir) => { + spawnedTeam = teamName; + spawnedDir = workingDir; + return { created: true }; + }, + }); + const result = await checkInboxes(deps); + expect(result).toEqual(['beta']); + expect(spawnedTeam).toBe('beta'); + expect(spawnedDir).toBe('/tmp/beta'); + }); + + test('3 consecutive spawn failures → team skipped with warning', async () => { + const warnings: string[] = []; + let spawnAttempts = 0; + const deps = makeDeps({ + listTeamsWithUnreadInbox: async () => [{ teamName: 'crash-team', unreadCount: 2, workingDir: '/tmp/crash' }], + isTeamActive: async () => false, + ensureTeamLead: async () => { + spawnAttempts++; + throw new Error('spawn failed'); + }, + warn: (msg) => warnings.push(msg), + }); + + // First 3 calls: each triggers a spawn attempt that fails + await checkInboxes(deps); + expect(spawnAttempts).toBe(1); + + await checkInboxes(deps); + expect(spawnAttempts).toBe(2); + + await checkInboxes(deps); + expect(spawnAttempts).toBe(3); + + // 4th call: team is skipped (no more spawn attempts) + const result = await checkInboxes(deps); + expect(spawnAttempts).toBe(3); // No new attempt + expect(result).toEqual([]); + expect(warnings.some((w) => w.includes('Skipping team "crash-team"'))).toBe(true); + }); + + test('disabled via GENIE_INBOX_POLL_MS=0 → returns empty', async () => { + process.env.GENIE_INBOX_POLL_MS = '0'; + let spawnCalled = false; + const deps = makeDeps({ + listTeamsWithUnreadInbox: async () => [{ teamName: 'gamma', unreadCount: 5, workingDir: '/tmp/gamma' }], + ensureTeamLead: async () => { + spawnCalled = true; + return { created: true }; + }, + }); + const result = await checkInboxes(deps); + expect(result).toEqual([]); + expect(spawnCalled).toBe(false); + }); + + test('team with no workingDir → skipped with warning', async () => { + const warnings: string[] = []; + const deps = makeDeps({ + listTeamsWithUnreadInbox: async () => [{ teamName: 'no-cwd', unreadCount: 1, workingDir: null }], + isTeamActive: async () => false, + warn: (msg) => warnings.push(msg), + }); + const result = await checkInboxes(deps); + expect(result).toEqual([]); + expect(warnings.some((w) => w.includes('no workingDir'))).toBe(true); + }); + + test('multiple teams — spawns only inactive ones', async () => { + const spawned: string[] = []; + const deps = makeDeps({ + listTeamsWithUnreadInbox: async () => [ + { teamName: 'active-team', unreadCount: 2, workingDir: '/tmp/active' }, + { teamName: 'dead-team', unreadCount: 1, workingDir: '/tmp/dead' }, + { teamName: 'another-dead', unreadCount: 3, workingDir: '/tmp/another' }, + ], + isTeamActive: async (teamName) => teamName === 'active-team', + ensureTeamLead: async (teamName) => { + spawned.push(teamName); + return { created: true }; + }, + }); + const result = await checkInboxes(deps); + expect(result).toEqual(['dead-team', 'another-dead']); + expect(spawned).toEqual(['dead-team', 'another-dead']); + }); + + test('successful spawn resets failure count', async () => { + let attempt = 0; + const deps = makeDeps({ + listTeamsWithUnreadInbox: async () => [{ teamName: 'flaky', unreadCount: 1, workingDir: '/tmp/flaky' }], + isTeamActive: async () => false, + ensureTeamLead: async () => { + attempt++; + if (attempt <= 2) throw new Error('transient failure'); + return { created: true }; + }, + warn: () => {}, + }); + + // Two failures + await checkInboxes(deps); + await checkInboxes(deps); + + // Third attempt succeeds — count resets + const result = await checkInboxes(deps); + expect(result).toEqual(['flaky']); + + // Next failure starts fresh (not at 2+1=3) + await checkInboxes(deps); + // Should still try (only 1 failure after reset) + expect(attempt).toBe(4); + }); +}); diff --git a/src/lib/inbox-watcher.ts b/src/lib/inbox-watcher.ts new file mode 100644 index 000000000..b13df9c1f --- /dev/null +++ b/src/lib/inbox-watcher.ts @@ -0,0 +1,143 @@ +/** + * Inbox Watcher — Polls native inboxes for unread messages and + * auto-spawns offline team-leads. + * + * Follows the idle-timeout.ts dependency-injection pattern so all + * logic is unit-testable without tmux or filesystem side effects. + */ + +import { listTeamsWithUnreadInbox } from './claude-native-teams.js'; +import { ensureTeamLead, isTeamActive } from './team-auto-spawn.js'; + +// ============================================================================ +// Dependency injection (testability without real filesystem/tmux) +// ============================================================================ + +/** Dependencies used by inbox-watcher functions. */ +export interface InboxWatcherDeps { + listTeamsWithUnreadInbox: typeof listTeamsWithUnreadInbox; + isTeamActive: (teamName: string) => Promise; + ensureTeamLead: (teamName: string, workingDir: string) => Promise<{ created: boolean }>; + warn: (msg: string) => void; +} + +/** Default production dependencies. */ +const defaultDeps: InboxWatcherDeps = { + listTeamsWithUnreadInbox, + isTeamActive: (teamName) => isTeamActive(teamName), + ensureTeamLead: (teamName, workingDir) => ensureTeamLead(teamName, workingDir), + warn: (msg) => console.warn(msg), +}; + +// ============================================================================ +// Configuration +// ============================================================================ + +/** Default inbox poll interval in milliseconds (30 seconds). */ +export const INBOX_POLL_INTERVAL_MS = 30_000; + +/** Maximum consecutive spawn failures before skipping a team. */ +const MAX_SPAWN_FAILURES = 3; + +/** + * Get the inbox poll interval from env or default. + * Set GENIE_INBOX_POLL_MS to override (0 = disabled). + */ +export function getInboxPollIntervalMs(): number { + const env = process.env.GENIE_INBOX_POLL_MS; + if (env !== undefined) { + if (env === '') return INBOX_POLL_INTERVAL_MS; + const parsed = Number(env); + if (!Number.isNaN(parsed) && parsed >= 0) return parsed; + } + return INBOX_POLL_INTERVAL_MS; +} + +// ============================================================================ +// Spawn failure tracking (in-memory, resets on daemon restart) +// ============================================================================ + +/** Consecutive spawn failure counts per team. */ +const spawnFailures = new Map(); + +/** Reset all failure counts (exposed for testing). */ +export function resetSpawnFailures(): void { + spawnFailures.clear(); +} + +// ============================================================================ +// Main polling function +// ============================================================================ + +/** + * Check all team inboxes and spawn team-leads for inactive teams + * that have unread messages. + * + * Returns list of team names where spawn was triggered. + */ +export async function checkInboxes(deps: InboxWatcherDeps = defaultDeps): Promise { + const pollMs = getInboxPollIntervalMs(); + if (pollMs === 0) return []; // Disabled + + const teamsWithUnread = await deps.listTeamsWithUnreadInbox(); + const spawned: string[] = []; + + for (const { teamName, workingDir } of teamsWithUnread) { + // Skip teams that have exceeded max spawn failures + const failures = spawnFailures.get(teamName) ?? 0; + if (failures >= MAX_SPAWN_FAILURES) { + deps.warn(`[inbox-watcher] Skipping team "${teamName}" — ${failures} consecutive spawn failures`); + continue; + } + + // Skip teams that already have an active team-lead + const active = await deps.isTeamActive(teamName); + if (active) continue; + + // No working dir means we can't spawn + if (!workingDir) { + deps.warn(`[inbox-watcher] Cannot spawn team-lead for "${teamName}" — no workingDir in config`); + continue; + } + + // Attempt to spawn team-lead + try { + await deps.ensureTeamLead(teamName, workingDir); + spawnFailures.set(teamName, 0); // Reset on success + spawned.push(teamName); + } catch (err) { + const newCount = failures + 1; + spawnFailures.set(teamName, newCount); + const message = err instanceof Error ? err.message : String(err); + deps.warn( + `[inbox-watcher] Failed to spawn team-lead for "${teamName}" (attempt ${newCount}/${MAX_SPAWN_FAILURES}): ${message}`, + ); + } + } + + return spawned; +} + +// ============================================================================ +// Daemon lifecycle +// ============================================================================ + +/** + * Start the inbox watcher polling loop. + * Returns a handle that can be passed to `stopInboxWatcher()`. + */ +export function startInboxWatcher(deps: InboxWatcherDeps = defaultDeps): NodeJS.Timeout { + return setInterval(() => { + checkInboxes(deps).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + deps.warn(`[inbox-watcher] Poll error: ${message}`); + }); + }, getInboxPollIntervalMs()); +} + +/** + * Stop the inbox watcher polling loop. + */ +export function stopInboxWatcher(handle: NodeJS.Timeout): void { + clearInterval(handle); +} diff --git a/src/lib/team-auto-spawn.test.ts b/src/lib/team-auto-spawn.test.ts index 67f262017..ff143b668 100644 --- a/src/lib/team-auto-spawn.test.ts +++ b/src/lib/team-auto-spawn.test.ts @@ -1,24 +1,231 @@ /** * Tests for team-auto-spawn module * - * Tests the core logic of ensureTeamLead without requiring actual tmux sessions. + * Tests the core logic of isTeamActive and ensureTeamLead using dependency + * injection — no real tmux sessions required. + * * Run with: bun test src/lib/team-auto-spawn.test.ts */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import type { TeamAutoSpawnDeps } from './team-auto-spawn.js'; +import { ensureTeamLead, isTeamActive } from './team-auto-spawn.js'; + +// ============================================================================ +// Test helpers +// ============================================================================ -// We test the helper functions and logic by importing from team-auto-spawn -// The tmux-dependent parts are integration-tested separately +/** Create a minimal deps object with sensible defaults. Override as needed. */ +function makeDeps(overrides: Partial = {}): TeamAutoSpawnDeps { + return { + loadConfig: async () => ({ + name: 'test-team', + description: '', + createdAt: Date.now(), + leadAgentId: '', + leadSessionId: '', + members: [], + }), + findSessionByName: async () => ({ id: '$0', name: 'genie', attached: false, windows: 1 }), + listWindows: async () => [{ id: '@1', name: 'test-team', active: false, sessionId: 'genie' }], + listPanes: async () => [{ id: '%1', windowId: '@1', active: true, title: '' }], + isPaneAlive: async () => true, + getTeamLeadEntry: async () => null, + saveTeamLeadEntry: async () => {}, + ensureNativeTeam: async () => + ({ + name: 'test-team', + description: '', + createdAt: Date.now(), + leadAgentId: '', + leadSessionId: '', + members: [], + }) as any, + registerNativeMember: async () => {}, + createSession: async () => ({ id: '$0', name: 'genie', attached: false, windows: 0 }), + ensureTeamWindow: async () => ({ windowId: '@2', windowName: 'test-team', paneId: '%5', created: true }), + executeTmux: async () => '', + existsSync: () => false, + buildTeamLeadCommand: () => 'claude --team test-team', + now: () => Date.now(), + ...overrides, + }; +} // ============================================================================ -// Unit tests for buildTeamLeadCommand (tested via session.ts buildClaudeCommand) -// The auto-spawn module reuses the same pattern, tested in session.test.ts +// isTeamActive tests // ============================================================================ +describe('isTeamActive', () => { + test('returns false when no config exists', async () => { + const deps = makeDeps({ loadConfig: async () => null }); + expect(await isTeamActive('test-team', deps)).toBe(false); + }); + + test('returns false when no tmux session exists', async () => { + const deps = makeDeps({ findSessionByName: async () => null }); + expect(await isTeamActive('test-team', deps)).toBe(false); + }); + + test('returns false when no matching window exists', async () => { + const deps = makeDeps({ listWindows: async () => [] }); + expect(await isTeamActive('test-team', deps)).toBe(false); + }); + + test('returns false when window has no panes', async () => { + const deps = makeDeps({ listPanes: async () => [] }); + expect(await isTeamActive('test-team', deps)).toBe(false); + }); + + test('window exists + pane alive → returns true', async () => { + const deps = makeDeps({ isPaneAlive: async () => true }); + expect(await isTeamActive('test-team', deps)).toBe(true); + }); + + test('window exists + pane dead → returns false', async () => { + const deps = makeDeps({ + isPaneAlive: async () => false, + getTeamLeadEntry: async () => null, + }); + expect(await isTeamActive('test-team', deps)).toBe(false); + }); + + test('window exists + pane dead + spawned < 30s ago → returns true (grace period)', async () => { + const now = Date.now(); + const deps = makeDeps({ + isPaneAlive: async () => false, + getTeamLeadEntry: async () => ({ + id: 'team-lead:test-team', + paneId: '%1', + session: 'genie', + worktree: null, + startedAt: new Date(now - 15_000).toISOString(), // 15s ago + state: 'spawning', + lastStateChange: new Date().toISOString(), + repoPath: '/tmp/test', + role: 'team-lead', + team: 'test-team', + }), + now: () => now, + }); + expect(await isTeamActive('test-team', deps)).toBe(true); + }); + + test('window exists + pane dead + spawned > 30s ago → returns false (grace expired)', async () => { + const now = Date.now(); + const deps = makeDeps({ + isPaneAlive: async () => false, + getTeamLeadEntry: async () => ({ + id: 'team-lead:test-team', + paneId: '%1', + session: 'genie', + worktree: null, + startedAt: new Date(now - 60_000).toISOString(), // 60s ago + state: 'spawning', + lastStateChange: new Date().toISOString(), + repoPath: '/tmp/test', + role: 'team-lead', + team: 'test-team', + }), + now: () => now, + }); + expect(await isTeamActive('test-team', deps)).toBe(false); + }); + + test('handles listWindows error gracefully', async () => { + const deps = makeDeps({ + listWindows: async () => { + throw new Error('tmux: no server running'); + }, + }); + expect(await isTeamActive('test-team', deps)).toBe(false); + }); +}); + +// ============================================================================ +// ensureTeamLead tests +// ============================================================================ + +describe('ensureTeamLead', () => { + test('returns immediately if team is already active', async () => { + let saveCalled = false; + const deps = makeDeps({ + isPaneAlive: async () => true, + saveTeamLeadEntry: async () => { + saveCalled = true; + }, + }); + const result = await ensureTeamLead('test-team', '/tmp/work', deps); + expect(result.created).toBe(false); + expect(saveCalled).toBe(false); + }); + + test('stores pane ID in registry on new spawn', async () => { + let savedPaneId = '' as string; + let savedTeam = '' as string; + const deps = makeDeps({ + // Make isTeamActive return false (no config) + loadConfig: async () => null, + ensureTeamWindow: async () => ({ windowId: '@3', windowName: 'test-team', paneId: '%42', created: true }), + saveTeamLeadEntry: async (teamName, paneId) => { + savedTeam = teamName; + savedPaneId = paneId; + }, + }); + const result = await ensureTeamLead('test-team', '/tmp/work', deps); + expect(result.created).toBe(true); + expect(savedPaneId).toBe('%42'); + expect(savedTeam).toBe('test-team'); + }); + + test('cleans up stale window and re-creates', async () => { + const killedWindows: string[] = []; + let spawnCount = 0; + const deps = makeDeps({ + // isTeamActive returns false (pane dead, grace expired) + loadConfig: async () => null, + // But a stale window exists in the session + findSessionByName: async () => ({ id: '$0', name: 'genie', attached: false, windows: 1 }), + listWindows: async () => [{ id: '@1', name: 'test-team', active: false, sessionId: 'genie' }], + executeTmux: async (cmd) => { + if (cmd.includes('kill-window')) { + killedWindows.push(cmd); + } + return ''; + }, + ensureTeamWindow: async () => { + spawnCount++; + return { windowId: '@2', windowName: 'test-team', paneId: '%10', created: true }; + }, + saveTeamLeadEntry: async () => {}, + }); + const result = await ensureTeamLead('test-team', '/tmp/work', deps); + expect(result.created).toBe(true); + expect(killedWindows.length).toBe(1); + expect(killedWindows[0]).toContain('kill-window'); + expect(spawnCount).toBe(1); + }); + + test('saves registry even when window already existed (not created)', async () => { + let savedPaneId = '' as string; + const deps = makeDeps({ + loadConfig: async () => null, + findSessionByName: async () => null, // No existing session to clean up + ensureTeamWindow: async () => ({ windowId: '@3', windowName: 'test-team', paneId: '%7', created: false }), + saveTeamLeadEntry: async (_teamName, paneId) => { + savedPaneId = paneId; + }, + }); + const result = await ensureTeamLead('test-team', '/tmp/work', deps); + expect(result.created).toBe(false); + expect(savedPaneId).toBe('%7'); + }); +}); + // ============================================================================ -// isTeamActive logic tests (config.json presence) +// Config.json detection (preserved from original) // ============================================================================ describe('team-auto-spawn: config.json detection', () => { @@ -39,9 +246,7 @@ describe('team-auto-spawn: config.json detection', () => { mkdirSync(teamDir, { recursive: true }); mkdirSync(join(teamDir, 'inboxes'), { recursive: true }); - // config.json should NOT exist expect(existsSync(join(teamDir, 'config.json'))).toBe(false); - // but inbox dir does expect(existsSync(join(teamDir, 'inboxes'))).toBe(true); }); @@ -67,8 +272,6 @@ describe('team-auto-spawn: config.json detection', () => { }); test('inbox file can exist without config.json (the gap we are fixing)', () => { - // This simulates what happens when Omni writes to a team that doesn't exist: - // It creates the inbox dir + file, but NOT config.json const teamDir = join(TEAMS_DIR, 'orphan-team'); const inboxDir = join(teamDir, 'inboxes'); mkdirSync(inboxDir, { recursive: true }); @@ -84,14 +287,13 @@ describe('team-auto-spawn: config.json detection', () => { ]; writeFileSync(join(inboxDir, 'team-lead.json'), JSON.stringify(inboxMessage, null, 2)); - // Inbox exists but config.json doesn't — this is the gap expect(existsSync(join(inboxDir, 'team-lead.json'))).toBe(true); expect(existsSync(join(teamDir, 'config.json'))).toBe(false); }); }); // ============================================================================ -// EnsureTeamLeadResult type tests +// EnsureTeamLeadResult type tests (preserved from original) // ============================================================================ describe('team-auto-spawn: result types', () => { diff --git a/src/lib/team-auto-spawn.ts b/src/lib/team-auto-spawn.ts index 0a8ba4db1..330719af3 100644 --- a/src/lib/team-auto-spawn.ts +++ b/src/lib/team-auto-spawn.ts @@ -6,18 +6,22 @@ * doesn't have an active team-lead process. * * Idempotent: safe to call repeatedly. If the team already has an active - * tmux window, this is a no-op. + * tmux window with a live Claude Code process, this is a no-op. */ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { sanitizeWindowName } from '../genie-commands/session.js'; +import * as registry from './agent-registry.js'; import { ensureNativeTeam, loadConfig, registerNativeMember, sanitizeTeamName } from './claude-native-teams.js'; import { buildTeamLeadCommand, shellQuote } from './team-lead-command.js'; import * as tmux from './tmux.js'; const DEFAULT_SESSION = 'genie'; +/** Grace period (ms) after spawn before liveness checks kick in. */ +const LIVENESS_GRACE_MS = 30_000; + interface EnsureTeamLeadResult { /** Whether a new team window was created (false = already existed) */ created: boolean; @@ -27,12 +31,54 @@ interface EnsureTeamLeadResult { window: string; } +// ============================================================================ +// Dependency injection (testability without mock.module) +// ============================================================================ + +/** Dependencies used by team-auto-spawn functions. */ +export interface TeamAutoSpawnDeps { + loadConfig: typeof loadConfig; + findSessionByName: typeof tmux.findSessionByName; + listWindows: typeof tmux.listWindows; + listPanes: typeof tmux.listPanes; + isPaneAlive: typeof tmux.isPaneAlive; + getTeamLeadEntry: typeof registry.getTeamLeadEntry; + saveTeamLeadEntry: typeof registry.saveTeamLeadEntry; + ensureNativeTeam: typeof ensureNativeTeam; + registerNativeMember: typeof registerNativeMember; + createSession: typeof tmux.createSession; + ensureTeamWindow: typeof tmux.ensureTeamWindow; + executeTmux: typeof tmux.executeTmux; + existsSync: typeof existsSync; + buildTeamLeadCommand: typeof buildTeamLeadCommand; + now: () => number; +} + +/** Default production dependencies. */ +const defaultDeps: TeamAutoSpawnDeps = { + loadConfig, + findSessionByName: tmux.findSessionByName, + listWindows: tmux.listWindows, + listPanes: tmux.listPanes, + isPaneAlive: tmux.isPaneAlive, + getTeamLeadEntry: registry.getTeamLeadEntry, + saveTeamLeadEntry: registry.saveTeamLeadEntry, + ensureNativeTeam, + registerNativeMember, + createSession: tmux.createSession, + ensureTeamWindow: tmux.ensureTeamWindow, + executeTmux: tmux.executeTmux, + existsSync, + buildTeamLeadCommand, + now: Date.now, +}; + /** * Get AGENTS.md file path from the working directory if it exists. */ -function getSystemPromptFile(workingDir: string): string | null { +function getSystemPromptFile(workingDir: string, deps: TeamAutoSpawnDeps): string | null { const agentsPath = join(workingDir, 'AGENTS.md'); - if (existsSync(agentsPath)) { + if (deps.existsSync(agentsPath)) { return agentsPath; } return null; @@ -42,11 +88,11 @@ function getSystemPromptFile(workingDir: string): string | null { * Ensure a tmux session exists for teams. * Creates the "genie" session if it doesn't exist. */ -async function ensureSession(): Promise { - const existing = await tmux.findSessionByName(DEFAULT_SESSION); +async function ensureSession(deps: TeamAutoSpawnDeps): Promise { + const existing = await deps.findSessionByName(DEFAULT_SESSION); if (existing) return DEFAULT_SESSION; - const session = await tmux.createSession(DEFAULT_SESSION); + const session = await deps.createSession(DEFAULT_SESSION); if (!session) { throw new Error(`Failed to create tmux session "${DEFAULT_SESSION}"`); } @@ -58,19 +104,37 @@ async function ensureSession(): Promise { * * A team is considered "active" if: * 1. Its native config.json exists, AND - * 2. A tmux window with the team name exists in the genie session + * 2. A tmux window with the team name exists in the genie session, AND + * 3. The window's pane has a live process (or is within the 30s grace period) */ -async function isTeamActive(teamName: string): Promise { - const config = await loadConfig(teamName); +export async function isTeamActive(teamName: string, deps: TeamAutoSpawnDeps = defaultDeps): Promise { + const config = await deps.loadConfig(teamName); if (!config) return false; - const session = await tmux.findSessionByName(DEFAULT_SESSION); + const session = await deps.findSessionByName(DEFAULT_SESSION); if (!session) return false; try { - const windows = await tmux.listWindows(DEFAULT_SESSION); + const windows = await deps.listWindows(DEFAULT_SESSION); const sanitized = sanitizeTeamName(teamName); - return windows.some((w) => w.name === sanitized || w.name === teamName); + const matchingWindow = windows.find((w) => w.name === sanitized || w.name === teamName); + if (!matchingWindow) return false; + + // Get the window's panes and check liveness + const panes = await deps.listPanes(matchingWindow.id); + if (panes.length === 0) return false; + + const paneId = panes[0].id; + + // Grace period: skip liveness check if team-lead was spawned < 30s ago + const entry = await deps.getTeamLeadEntry(teamName); + if (entry?.startedAt) { + const elapsed = deps.now() - new Date(entry.startedAt).getTime(); + if (elapsed < LIVENESS_GRACE_MS) return true; + } + + // Check if the process in the pane is still alive + return await deps.isPaneAlive(paneId); } catch { return false; } @@ -79,22 +143,43 @@ async function isTeamActive(teamName: string): Promise { /** * Ensure a team has an active Claude Code team-lead. * - * 1. If team is already active (config + tmux window exist), returns immediately. - * 2. Otherwise, creates native team structure + tmux window + launches Claude Code. + * 1. If team is already active (config + tmux window + live pane), returns immediately. + * 2. If stale window exists (window present but pane dead), kills it and re-creates. + * 3. Otherwise, creates native team structure + tmux window + launches Claude Code. * * @param teamName - The team name (will be sanitized for filesystem use) * @param workingDir - Working directory for the Claude Code session * @returns Result indicating whether the team was created or already existed */ -export async function ensureTeamLead(teamName: string, workingDir: string): Promise { +export async function ensureTeamLead( + teamName: string, + workingDir: string, + deps: TeamAutoSpawnDeps = defaultDeps, +): Promise { // Fast path: team already active - if (await isTeamActive(teamName)) { + if (await isTeamActive(teamName, deps)) { return { created: false, session: DEFAULT_SESSION, window: sanitizeWindowName(teamName) }; } + // Check for stale window (window exists but pane is dead) and clean up + const windowName = sanitizeWindowName(teamName); + const existingSession = await deps.findSessionByName(DEFAULT_SESSION); + if (existingSession) { + const windows = await deps.listWindows(DEFAULT_SESSION); + const sanitized = sanitizeTeamName(teamName); + const staleWindow = windows.find((w) => w.name === sanitized || w.name === teamName || w.name === windowName); + if (staleWindow) { + try { + await deps.executeTmux(`kill-window -t ${shellQuote(`${DEFAULT_SESSION}:${staleWindow.name}`)}`); + } catch { + /* best-effort cleanup */ + } + } + } + // Create native team structure - await ensureNativeTeam(teamName, `Genie team: ${teamName}`, 'pending'); - await registerNativeMember(teamName, { + await deps.ensureNativeTeam(teamName, `Genie team: ${teamName}`, 'pending'); + await deps.registerNativeMember(teamName, { agentName: 'team-lead', agentType: 'general-purpose', color: 'blue', @@ -102,20 +187,22 @@ export async function ensureTeamLead(teamName: string, workingDir: string): Prom }); // Ensure tmux session exists - const session = await ensureSession(); + const session = await ensureSession(deps); // Create team window (sanitize dots — tmux interprets '.' as pane separator) - const windowName = sanitizeWindowName(teamName); - const teamWindow = await tmux.ensureTeamWindow(session, windowName, workingDir); + const teamWindow = await deps.ensureTeamWindow(session, windowName, workingDir); + + // Save team-lead pane ID to agent registry + await deps.saveTeamLeadEntry(teamName, teamWindow.paneId, session, windowName, workingDir); if (teamWindow.created) { // Launch Claude Code in the new window - const systemPromptFile = getSystemPromptFile(workingDir); + const systemPromptFile = getSystemPromptFile(workingDir, deps); const target = `${session}:${windowName}`; const cdCmd = `cd ${shellQuote(workingDir)}`; - await tmux.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cdCmd)} Enter`); - const cmd = buildTeamLeadCommand(teamName, { systemPromptFile: systemPromptFile ?? undefined }); - await tmux.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cmd)} Enter`); + await deps.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cdCmd)} Enter`); + const cmd = deps.buildTeamLeadCommand(teamName, { systemPromptFile: systemPromptFile ?? undefined }); + await deps.executeTmux(`send-keys -t ${shellQuote(target)} ${shellQuote(cmd)} Enter`); } return { created: teamWindow.created, session, window: windowName }; From ced87a7640cc19d8a6412f68de3d0f661a036a9a Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 17 Mar 2026 13:07:07 -0300 Subject: [PATCH 2/2] fix: force main branch in createTempGitRepo test helper for worktree compat --- src/term-commands/ship.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/term-commands/ship.test.ts b/src/term-commands/ship.test.ts index c6dc6eb3d..5195958f9 100644 --- a/src/term-commands/ship.test.ts +++ b/src/term-commands/ship.test.ts @@ -22,7 +22,7 @@ import { $ } from 'bun'; async function createTempGitRepo(basePath: string, name: string): Promise { const repoPath = join(basePath, name); await mkdir(repoPath, { recursive: true }); - await $`git -C ${repoPath} init`.quiet(); + await $`git -C ${repoPath} init -b main`.quiet(); await $`git -C ${repoPath} config user.email "test@test.com"`.quiet(); await $`git -C ${repoPath} config user.name "Test User"`.quiet();