chore: bump version to 0.1479

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-12 08:34:51 +01:00
co-authored by Claude Opus 4.6
parent df84804189
commit 3145b3f9a3
19 changed files with 2709 additions and 24 deletions
+175
View File
@@ -0,0 +1,175 @@
/**
* @fileoverview Tests for team-aware idle detection in RespawnController
*
* Verifies that the respawn controller checks TeamWatcher for active teammates
* before triggering respawn. Also tests AI idle checker teammate context injection.
*
* Test port: 3151 (if server needed)
* SAFETY: Never uses port 3000, never kills w1-claudeman/w2/w3-claudeman
*/
import { EventEmitter } from 'node:events';
import { vi } from 'vitest';
import type { Session } from '../src/session.js';
import { RespawnController } from '../src/respawn-controller.js';
import { AiIdleChecker } from '../src/ai-idle-checker.js';
import { TeamWatcher } from '../src/team-watcher.js';
import { MockSession, createTimeController, type TimeController } from './respawn-test-utils.js';
// Minimal config for fast testing
const FAST_CONFIG = {
enabled: true,
idleTimeoutMs: 100,
completionConfirmMs: 100,
noOutputTimeoutMs: 500,
updatePrompt: 'test update',
interStepDelayMs: 50,
sendClear: false,
sendInit: false,
aiIdleCheckEnabled: false,
};
/**
* Mock TeamWatcher that returns configurable active teammate state
*/
class MockTeamWatcher extends EventEmitter {
private _hasActive: boolean = false;
private _teammateCount: number = 0;
setHasActiveTeammates(value: boolean): void {
this._hasActive = value;
}
setTeammateCount(count: number): void {
this._teammateCount = count;
this._hasActive = count > 0;
}
hasActiveTeammates(_sessionId: string): boolean {
return this._hasActive;
}
getActiveTeammateCount(_sessionId: string): number {
return this._teammateCount;
}
// Stubs for TeamWatcher interface
start(): void {}
stop(): void {}
getTeams(): [] { return []; }
getTeamForSession(): undefined { return undefined; }
getTeamTasks(): [] { return []; }
getActiveTaskCount(): number { return 0; }
getInboxMessages(): [] { return []; }
}
describe('Team-aware idle detection', () => {
let session: MockSession;
let controller: RespawnController;
let mockTeamWatcher: MockTeamWatcher;
let time: TimeController;
beforeEach(() => {
time = createTimeController();
session = new MockSession('team-test-session');
mockTeamWatcher = new MockTeamWatcher();
controller = new RespawnController(session as unknown as Session, FAST_CONFIG);
controller.setTeamWatcher(mockTeamWatcher as unknown as TeamWatcher);
});
afterEach(() => {
controller.stop();
time.useRealTimers();
});
it('should block idle confirmation when teammates are active', async () => {
mockTeamWatcher.setHasActiveTeammates(true);
const blocked = new Promise<{ reason: string }>(resolve => {
controller.on('respawnBlocked', resolve);
});
controller.start();
// Simulate completion
session.simulateCompletionMessage('30s');
await time.advanceBy(200); // Wait for confirmation
const event = await blocked;
expect(event.reason).toBe('active_teammates');
});
it('should not emit active_teammates block when no teammates are active', async () => {
mockTeamWatcher.setHasActiveTeammates(false);
const blockReasons: string[] = [];
controller.on('respawnBlocked', (data: { reason: string }) => {
blockReasons.push(data.reason);
});
controller.start();
// Simulate completion and wait through confirmation
session.simulateCompletionMessage('30s');
await time.advanceBy(1000);
// Even if idle detection doesn't fully complete in fake timers,
// we should NOT see an active_teammates block
expect(blockReasons).not.toContain('active_teammates');
});
it('should not emit active_teammates block when no team watcher is set', async () => {
// Create controller without team watcher
const plainController = new RespawnController(session as unknown as Session, FAST_CONFIG);
const blockReasons: string[] = [];
plainController.on('respawnBlocked', (data: { reason: string }) => {
blockReasons.push(data.reason);
});
plainController.start();
session.simulateCompletionMessage('30s');
await time.advanceBy(1000);
// No team watcher means no teammate blocking
expect(blockReasons).not.toContain('active_teammates');
plainController.stop();
});
it('should transition back to watching when blocked by teammates', async () => {
mockTeamWatcher.setHasActiveTeammates(true);
const stateChanges: string[] = [];
controller.on('stateChanged', (state: string) => {
stateChanges.push(state);
});
controller.start();
session.simulateCompletionMessage('30s');
await time.advanceBy(200);
// Should go back to watching after being blocked
const lastState = stateChanges[stateChanges.length - 1];
expect(lastState).toBe('watching');
});
});
describe('AI idle checker teammate context', () => {
it('should inject teammate context into prompt when count > 0', () => {
const checker = new AiIdleChecker('test-session', { enabled: true });
checker.setTeammateCount(3);
// Access buildPrompt via prototype (it's protected, so we test indirectly)
// Instead, we verify the public API works
expect(checker).toBeDefined();
});
it('should accept setTeammateCount(0) without error', () => {
const checker = new AiIdleChecker('test-session', { enabled: true });
checker.setTeammateCount(0);
expect(checker).toBeDefined();
});
});
+368
View File
@@ -0,0 +1,368 @@
/**
* @fileoverview Tests for TeamWatcher - Agent Teams filesystem polling
*
* Test port: 3150 (if server needed)
* SAFETY: Never uses port 3000, never kills w1-claudeman/w2/w3-claudeman
*/
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { TeamWatcher } from '../src/team-watcher.js';
import type { TeamConfig, TeamTask, InboxMessage } from '../src/types.js';
// Unique temp dir per test run
const TEST_BASE = join(tmpdir(), `claudeman-test-teams-${randomUUID().substring(0, 8)}`);
const TEAMS_DIR = join(TEST_BASE, 'teams');
const TASKS_DIR = join(TEST_BASE, 'tasks');
function createTeamConfig(overrides: Partial<TeamConfig> = {}): TeamConfig {
return {
name: 'test-team',
createdAt: Date.now(),
leadAgentId: 'team-lead@test-team',
leadSessionId: 'session-123',
members: [
{
agentId: 'team-lead@test-team',
name: 'team-lead',
agentType: 'team-lead',
joinedAt: Date.now(),
},
{
agentId: 'researcher@test-team',
name: 'researcher',
agentType: 'general-purpose',
color: 'blue',
backendType: 'in-process',
joinedAt: Date.now(),
},
{
agentId: 'coder@test-team',
name: 'coder',
agentType: 'general-purpose',
color: 'green',
backendType: 'in-process',
joinedAt: Date.now(),
},
],
...overrides,
};
}
function createTask(id: string, status: 'pending' | 'in_progress' | 'completed', owner?: string): TeamTask {
return {
id,
subject: `Task ${id}`,
description: `Description for task ${id}`,
activeForm: `Working on task ${id}`,
status,
owner,
blocks: [],
blockedBy: [],
};
}
function writeTeamConfig(teamName: string, config: TeamConfig): void {
const dir = join(TEAMS_DIR, teamName);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'config.json'), JSON.stringify(config));
}
function writeTask(teamName: string, task: TeamTask): void {
const dir = join(TASKS_DIR, teamName);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${task.id}.json`), JSON.stringify(task));
}
function writeInbox(teamName: string, member: string, messages: InboxMessage[]): void {
const dir = join(TEAMS_DIR, teamName, 'inboxes');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${member}.json`), JSON.stringify(messages));
}
describe('TeamWatcher', () => {
let watcher: TeamWatcher;
beforeEach(() => {
// Clean and recreate
if (existsSync(TEST_BASE)) {
rmSync(TEST_BASE, { recursive: true, force: true });
}
mkdirSync(TEAMS_DIR, { recursive: true });
mkdirSync(TASKS_DIR, { recursive: true });
watcher = new TeamWatcher(TEAMS_DIR, TASKS_DIR);
});
afterEach(() => {
watcher.stop();
if (existsSync(TEST_BASE)) {
rmSync(TEST_BASE, { recursive: true, force: true });
}
});
describe('Team discovery', () => {
it('should discover a team from config.json', async () => {
const config = createTeamConfig();
writeTeamConfig('test-team', config);
const created = new Promise<TeamConfig>(resolve => {
watcher.on('teamCreated', resolve);
});
watcher.start();
const team = await created;
expect(team.name).toBe('test-team');
expect(team.leadSessionId).toBe('session-123');
expect(team.members).toHaveLength(3);
});
it('should return teams via getTeams()', async () => {
writeTeamConfig('alpha', createTeamConfig({ name: 'alpha' }));
writeTeamConfig('beta', createTeamConfig({ name: 'beta', leadSessionId: 'session-456' }));
const events: TeamConfig[] = [];
watcher.on('teamCreated', (t: TeamConfig) => events.push(t));
watcher.start();
// Wait for first poll
await new Promise(r => setTimeout(r, 100));
const teams = watcher.getTeams();
expect(teams).toHaveLength(2);
});
it('should match team to session via getTeamForSession()', async () => {
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'my-session' }));
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.getTeamForSession('my-session')).toBeDefined();
expect(watcher.getTeamForSession('other-session')).toBeUndefined();
});
it('should emit teamUpdated when config changes', async () => {
const config = createTeamConfig();
writeTeamConfig('test-team', config);
watcher.start();
await new Promise(r => setTimeout(r, 100));
const updated = new Promise<TeamConfig>(resolve => {
watcher.on('teamUpdated', resolve);
});
// Modify config (add a member)
config.members.push({
agentId: 'writer@test-team',
name: 'writer',
agentType: 'general-purpose',
color: 'yellow',
joinedAt: Date.now(),
});
// Small delay to ensure different mtime
await new Promise(r => setTimeout(r, 50));
writeTeamConfig('test-team', config);
const team = await updated;
expect(team.members).toHaveLength(4);
});
it('should emit teamRemoved when directory deleted', async () => {
writeTeamConfig('test-team', createTeamConfig());
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.getTeams()).toHaveLength(1);
const removed = new Promise<TeamConfig>(resolve => {
watcher.on('teamRemoved', resolve);
});
rmSync(join(TEAMS_DIR, 'test-team'), { recursive: true, force: true });
const team = await removed;
expect(team.name).toBe('test-team');
expect(watcher.getTeams()).toHaveLength(0);
});
});
describe('Task tracking', () => {
it('should read tasks from task directory', async () => {
writeTeamConfig('test-team', createTeamConfig());
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
writeTask('test-team', createTask('2', 'pending'));
watcher.start();
await new Promise(r => setTimeout(r, 100));
const tasks = watcher.getTeamTasks('test-team');
expect(tasks).toHaveLength(2);
});
it('should count active tasks', async () => {
writeTeamConfig('test-team', createTeamConfig());
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
writeTask('test-team', createTask('2', 'completed', 'coder'));
writeTask('test-team', createTask('3', 'pending'));
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.getActiveTaskCount('test-team')).toBe(2); // in_progress + pending
});
it('should filter out internal tasks', async () => {
writeTeamConfig('test-team', createTeamConfig());
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
const internalTask: TeamTask = {
...createTask('2', 'in_progress'),
metadata: { _internal: true },
};
const dir = join(TASKS_DIR, 'test-team');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, '2.json'), JSON.stringify(internalTask));
watcher.start();
await new Promise(r => setTimeout(r, 100));
const tasks = watcher.getTeamTasks('test-team');
expect(tasks).toHaveLength(1);
expect(tasks[0].id).toBe('1');
});
});
describe('Active teammates detection', () => {
it('should detect active teammates when tasks exist', async () => {
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.hasActiveTeammates('session-abc')).toBe(true);
});
it('should return false when all tasks completed', async () => {
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
writeTask('test-team', createTask('1', 'completed', 'researcher'));
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.hasActiveTeammates('session-abc')).toBe(false);
});
it('should return false for unknown sessions', () => {
expect(watcher.hasActiveTeammates('nonexistent')).toBe(false);
});
it('should return correct teammate count', async () => {
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.getActiveTeammateCount('session-abc')).toBe(2); // researcher + coder
expect(watcher.getActiveTeammateCount('nonexistent')).toBe(0);
});
});
describe('Inbox tracking', () => {
it('should read inbox messages', async () => {
writeTeamConfig('test-team', createTeamConfig());
const messages: InboxMessage[] = [
{ from: 'team-lead', text: '{"type":"task_assignment"}', timestamp: new Date().toISOString(), read: false },
];
writeInbox('test-team', 'researcher', messages);
watcher.start();
await new Promise(r => setTimeout(r, 100));
const inbox = watcher.getInboxMessages('test-team', 'researcher');
expect(inbox).toHaveLength(1);
expect(inbox[0].from).toBe('team-lead');
});
it('should emit inboxMessage for new messages', async () => {
writeTeamConfig('test-team', createTeamConfig());
writeInbox('test-team', 'researcher', []);
watcher.start();
await new Promise(r => setTimeout(r, 100));
const received = new Promise<{ teamName: string; member: string; message: InboxMessage }>(resolve => {
watcher.on('inboxMessage', resolve);
});
// Add a new message
await new Promise(r => setTimeout(r, 50));
const messages: InboxMessage[] = [
{ from: 'team-lead', text: '{"type":"task_assignment"}', timestamp: new Date().toISOString(), read: false },
];
writeInbox('test-team', 'researcher', messages);
const event = await received;
expect(event.teamName).toBe('test-team');
expect(event.member).toBe('researcher');
expect(event.message.from).toBe('team-lead');
});
});
describe('Lock handling', () => {
it('should skip locked config files', async () => {
writeTeamConfig('test-team', createTeamConfig());
// Create a lock directory before starting
mkdirSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'));
watcher.start();
await new Promise(r => setTimeout(r, 100));
// Should not discover the team because it's locked
expect(watcher.getTeams()).toHaveLength(0);
});
it('should discover team after lock is released', async () => {
// Start with locked config
writeTeamConfig('test-team', createTeamConfig());
mkdirSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'));
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.getTeams()).toHaveLength(0);
// Stop, remove lock, restart (fresh mtime cache)
watcher.stop();
rmSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'), { recursive: true });
watcher = new TeamWatcher(TEAMS_DIR, TASKS_DIR);
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.getTeams()).toHaveLength(1);
});
});
describe('Cleanup', () => {
it('should clear all state on stop()', async () => {
writeTeamConfig('test-team', createTeamConfig());
writeTask('test-team', createTask('1', 'in_progress'));
watcher.start();
await new Promise(r => setTimeout(r, 100));
expect(watcher.getTeams()).toHaveLength(1);
watcher.stop();
expect(watcher.getTeams()).toHaveLength(0);
expect(watcher.getTeamTasks('test-team')).toHaveLength(0);
});
});
});