Repository navigation
feat: team-lead liveness checks, inbox watcher daemon, CI coverage gate #645
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 ' ') | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fallback branch for unparseable coverage is unreachable when 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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@')); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 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 | ||
| // ============================================================================ | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.