- Added maintenance module to the agent-teams-controller, enabling the reconciliation of stale kanban entries and linked inbox comments. - Implemented garbage collection logic in kanbanStore to remove invalid task references and clean up column orders. - Updated controller to expose maintenance functionalities, allowing for better task and comment management. - Enhanced tests to validate the new reconciliation process, ensuring idempotency and correctness in handling stale data. - Integrated new CLI argument options for worktree and custom arguments in team launch and creation dialogs, improving user flexibility.
196 lines
6.7 KiB
JavaScript
196 lines
6.7 KiB
JavaScript
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const { createController } = require('../src/index.js');
|
|
|
|
describe('agent-teams-controller API', () => {
|
|
function makeClaudeDir() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
|
|
fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
|
|
fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, 'teams', 'my-team', 'config.json'),
|
|
JSON.stringify(
|
|
{
|
|
name: 'my-team',
|
|
members: [
|
|
{ name: 'alice', role: 'team-lead' },
|
|
{ name: 'bob', role: 'developer' },
|
|
],
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
return dir;
|
|
}
|
|
|
|
it('creates tasks and exposes grouped controller modules', () => {
|
|
const claudeDir = makeClaudeDir();
|
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
|
|
|
const base = controller.tasks.createTask({ subject: 'Base task' });
|
|
const dependency = controller.tasks.createTask({ subject: 'Dependency task' });
|
|
const created = controller.tasks.createTask({
|
|
subject: 'Blocked task',
|
|
owner: 'bob',
|
|
'blocked-by': `${base.displayId},${dependency.displayId}`,
|
|
related: base.displayId,
|
|
});
|
|
|
|
expect(created.id).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
);
|
|
expect(created.displayId).toHaveLength(8);
|
|
expect(created.status).toBe('pending');
|
|
expect(created.reviewState).toBe('none');
|
|
expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
|
|
expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
|
|
|
|
controller.kanban.addReviewer('alice');
|
|
controller.tasks.completeTask(created.id, 'bob');
|
|
controller.review.requestReview(created.id, { from: 'alice' });
|
|
controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
|
|
|
|
const kanbanState = controller.kanban.getKanbanState();
|
|
expect(kanbanState.reviewers).toEqual(['alice']);
|
|
expect(kanbanState.tasks[created.id].column).toBe('approved');
|
|
expect(controller.tasks.getTask(created.id).reviewState).toBe('approved');
|
|
|
|
const sent = controller.messages.appendSentMessage({
|
|
from: 'team-lead',
|
|
to: 'user',
|
|
text: 'All good',
|
|
leadSessionId: 'session-1',
|
|
source: 'lead_process',
|
|
attachments: [{ id: 'a1', filename: 'diff.txt', mimeType: 'text/plain', size: 12 }],
|
|
});
|
|
expect(sent.leadSessionId).toBe('session-1');
|
|
|
|
const proc = controller.processes.registerProcess({
|
|
pid: process.pid,
|
|
label: 'dev-server',
|
|
port: '3000',
|
|
});
|
|
expect(proc.port).toBe(3000);
|
|
expect(controller.processes.listProcesses()).toHaveLength(1);
|
|
const stopped = controller.processes.stopProcess({ pid: process.pid });
|
|
expect(typeof stopped.stoppedAt).toBe('string');
|
|
});
|
|
|
|
it('creates a fresh registry entry when an old pid was recycled without stoppedAt', () => {
|
|
const claudeDir = makeClaudeDir();
|
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
|
const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
|
|
|
|
fs.writeFileSync(
|
|
processesPath,
|
|
JSON.stringify(
|
|
[
|
|
{
|
|
id: 'old-entry',
|
|
pid: 999999,
|
|
label: 'stale',
|
|
registeredAt: '2024-01-01T00:00:00.000Z',
|
|
},
|
|
],
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const registered = controller.processes.registerProcess({
|
|
pid: 999999,
|
|
label: 'fresh',
|
|
});
|
|
|
|
expect(registered.id).not.toBe('old-entry');
|
|
const rows = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows[0].stoppedAt).toBeTruthy();
|
|
expect(rows[1].id).toBe(registered.id);
|
|
});
|
|
|
|
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
|
const claudeDir = makeClaudeDir();
|
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
|
const task = controller.tasks.createTask({
|
|
subject: 'Ship migration',
|
|
owner: 'bob',
|
|
});
|
|
|
|
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
|
fs.writeFileSync(
|
|
kanbanPath,
|
|
JSON.stringify(
|
|
{
|
|
teamName: 'my-team',
|
|
reviewers: [],
|
|
tasks: {
|
|
[task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null },
|
|
staleTask: { column: 'approved', movedAt: '2026-01-01T00:00:00.000Z' },
|
|
},
|
|
columnOrder: {
|
|
review: [task.id, 'staleTask'],
|
|
approved: ['staleTask'],
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
|
|
fs.mkdirSync(inboxDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(inboxDir, 'bob.json'),
|
|
JSON.stringify(
|
|
[
|
|
{
|
|
from: 'alice',
|
|
to: 'bob',
|
|
summary: `Please revisit #${task.displayId}`,
|
|
messageId: 'm-1',
|
|
timestamp: '2026-02-23T10:00:00.000Z',
|
|
read: false,
|
|
text: 'Need one more verification pass.',
|
|
},
|
|
{
|
|
from: 'team-lead',
|
|
to: 'bob',
|
|
summary: `Comment on #${task.displayId}`,
|
|
messageId: 'm-2',
|
|
timestamp: '2026-02-23T11:00:00.000Z',
|
|
read: false,
|
|
text:
|
|
`Comment on task #${task.displayId} "Ship migration":\n\nHeads up\n\n` +
|
|
'<agent-block>\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n</agent-block>',
|
|
},
|
|
],
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
|
|
const first = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
|
|
expect(first.staleKanbanEntriesRemoved).toBe(1);
|
|
expect(first.staleColumnOrderRefsRemoved).toBe(2);
|
|
expect(first.linkedCommentsCreated).toBe(1);
|
|
|
|
const reloaded = controller.tasks.getTask(task.id);
|
|
expect(reloaded.comments).toHaveLength(1);
|
|
expect(reloaded.comments[0].id).toBe('msg-m-1');
|
|
expect(reloaded.comments[0].text).toBe('Need one more verification pass.');
|
|
|
|
const cleanedKanban = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
|
|
expect(cleanedKanban.tasks.staleTask).toBeUndefined();
|
|
expect(cleanedKanban.columnOrder.review).toEqual([task.id]);
|
|
expect(cleanedKanban.columnOrder.approved).toBeUndefined();
|
|
|
|
const second = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
|
|
expect(second.staleKanbanEntriesRemoved).toBe(0);
|
|
expect(second.staleColumnOrderRefsRemoved).toBe(0);
|
|
expect(second.linkedCommentsCreated).toBe(0);
|
|
});
|
|
});
|