Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' ')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse the % Lines column for the coverage gate

The coverage row format is All files | % Funcs | % Lines | ..., but the script extracts field 2 (awk -F'|' '{print $2}'), which is function coverage, then labels it as line coverage. This makes the gate enforce the wrong metric and can fail or pass builds contrary to the intended 68% line-coverage policy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make unparseable coverage output truly non-fatal

The fallback branch for unparseable coverage is unreachable when grep "All files" finds no match, because this step runs with -e -o pipefail and the command substitution fails before if [ -z "$LINE_COV" ] executes. As a result, any Bun output format change hard-fails CI instead of taking the intended graceful-degradation path.

Useful? React with 👍 / 👎.

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
Expand Down
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
43 changes: 43 additions & 0 deletions src/lib/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<teamName>`.
*/
export async function saveTeamLeadEntry(
teamName: string,
paneId: string,
session: string,
windowName: string,
repoPath: string,
): Promise<void> {
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<Agent | null> {
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<void> {
await withRegistry((reg) => {
Expand Down
59 changes: 59 additions & 0 deletions src/lib/claude-native-teams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,65 @@ export async function deleteNativeTeam(teamName: string): Promise<boolean> {
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@'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive lead cwd without hardcoding the member name

listTeamsWithUnreadInbox() assumes the lead entry is named team-lead, but teams created through the normal session bootstrap path register the leader as basename(cwd) (see ensureNativeTeamForLeader() in src/genie-commands/session.ts), so this lookup often returns no match and leaves workingDir null. In that case checkInboxes() skips spawning entirely, which means unread inboxes for those existing teams will never trigger auto-respawn.

Useful? React with 👍 / 👎.

if (leadMember?.cwd) {
workingDir = leadMember.cwd;
}
} catch {
// Config missing or malformed — workingDir stays null
}

results.push({ teamName: name, unreadCount, workingDir });
}

return results;
Comment on lines +344 to +378

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The for...of loop processes each team directory sequentially due to the await calls inside for reading files. This could be slow if there are many teams. You can improve performance by processing teams in parallel using Promise.all.

  const results = await Promise.all(
    teamDirs.map(async (name) => {
      // 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 {
        return null; // No inbox or invalid JSON
      }

      if (!Array.isArray(messages)) return null;

      const unreadCount = messages.filter((m) => m.read === false).length;
      if (unreadCount === 0) return null;

      // 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
      }

      return { teamName: name, unreadCount, workingDir };
    }),
  );

  return results.filter((r): r is { teamName: string; unreadCount: number; workingDir: string | null } => r !== null);

}

// ============================================================================
// Session Discovery
// ============================================================================
Expand Down
184 changes: 184 additions & 0 deletions src/lib/inbox-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading
Loading