Drei vom Architekten abgenommene Tasks, gebündelt als Checkpoint: - TSK-0242: Agent-Alias-Mapping (kimi-ah → kimi kanonisiert, Rollen → preferredAgent), reopenTask räumt claimedBy ab, fsWatch reindiziert direkte Datei-Edits, work-Default 300s → 50s, task_list mit Limit. - TSK-0245: zwei Agent-Klassen (dispatch loop|architect). Watchdog mahnt architekt-getriebene Agenten nur noch EINMAL statt im Minutentakt; `task dispatch` startet sie explizit, `task record` trägt extern erledigte Arbeit mit origin=external nach. - TSK-0249: Lifecycle wird serverseitig erzwungen (open→review scheitert mit klarer Meldung), claimedBy/doneBy überleben bis done, Presence pro Agent, Review-Watchdog, GET /architect/pulse (1.4 kB statt 34 kB, since-Cursor, omitted statt stillem Abschneiden), unbekannter Agent → 400 statt 500. Alle Punkte live am laufenden Hub nachgemessen, nicht aus Agenten-Logs übernommen. Tests: 242 → 279 grün, tsc sauber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
154 lines
5.9 KiB
TypeScript
154 lines
5.9 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import {
|
|
createTask, listTasks, getTask,
|
|
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask, recordExternalTask, dispatchTask,
|
|
} from '../src/core/services/taskService.js';
|
|
import { init } from '../src/cli/commands/init.js';
|
|
import { Index } from '../src/core/index.js';
|
|
import { loadConfig, saveConfig } from '../src/core/config.js';
|
|
|
|
describe('taskService', () => {
|
|
let cwd: string;
|
|
|
|
beforeEach(() => {
|
|
cwd = mkdtempSync(join(tmpdir(), 'ah-task-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
it('creates a task', () => {
|
|
const task = createTask(cwd, { title: 'Test', role: 'implementer' });
|
|
expect(task.id).toBe('TSK-0001');
|
|
expect(task.title).toBe('Test');
|
|
expect(task.status).toBe('open');
|
|
});
|
|
|
|
it('lists tasks', () => {
|
|
createTask(cwd, { title: 'A', role: 'implementer' });
|
|
expect(listTasks(cwd)).toHaveLength(1);
|
|
});
|
|
|
|
it('claims and completes a task', () => {
|
|
const task = createTask(cwd, { title: 'B', role: 'implementer' });
|
|
const claimed = claimTask(cwd, task.id, 'codex');
|
|
expect(claimed.status).toBe('in_progress');
|
|
expect(claimed.assignedTo).toBe('codex');
|
|
expect(claimed.claimedBy).toBe('codex');
|
|
expect(listTasks(cwd, { status: 'in_progress' })[0]).toMatchObject({ assignedTo: 'codex', claimedBy: 'codex' });
|
|
reviewTask(cwd, task.id);
|
|
const done = doneTask(cwd, task.id);
|
|
expect(done.status).toBe('done');
|
|
expect(done.claimedBy).toBe('codex');
|
|
});
|
|
|
|
it('transitions a task to review', () => {
|
|
const task = createTask(cwd, { title: 'C', role: 'implementer' });
|
|
claimTask(cwd, task.id, 'codex');
|
|
const inReview = reviewTask(cwd, task.id);
|
|
expect(inReview.status).toBe('review');
|
|
// Verify index is updated
|
|
const listed = listTasks(cwd, { status: 'review' });
|
|
expect(listed).toHaveLength(1);
|
|
expect(listed[0].id).toBe(task.id);
|
|
});
|
|
|
|
it('records an explicit reviewer separately from the assignee', () => {
|
|
const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' });
|
|
claimTask(cwd, task.id, 'codex');
|
|
const inReview = reviewTask(cwd, task.id, 'claude');
|
|
expect(inReview.status).toBe('review');
|
|
expect(inReview.assignedTo).toBe('codex');
|
|
expect(inReview.reviewer).toBe('claude');
|
|
|
|
const listed = listTasks(cwd, { status: 'review' });
|
|
expect(listed[0].assignedTo).toBe('codex');
|
|
expect(listed[0].reviewer).toBe('claude');
|
|
});
|
|
|
|
it('uses the preferred reviewer when reviewTask is called without one', () => {
|
|
init(cwd, { projectName: 'reviewer-test', yes: true });
|
|
const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' });
|
|
claimTask(cwd, task.id, 'codex');
|
|
const inReview = reviewTask(cwd, task.id);
|
|
expect(inReview.reviewer).toBe('claude');
|
|
});
|
|
|
|
it('cancels a task', () => {
|
|
const task = createTask(cwd, { title: 'D', role: 'implementer' });
|
|
const cancelled = cancelTask(cwd, task.id);
|
|
expect(cancelled.status).toBe('cancelled');
|
|
const listed = listTasks(cwd, { status: 'cancelled' });
|
|
expect(listed).toHaveLength(1);
|
|
});
|
|
|
|
it('reopens a task (any status → open)', () => {
|
|
const task = createTask(cwd, { title: 'E', role: 'implementer', assignedTo: 'kimi' });
|
|
claimTask(cwd, task.id, 'kimi');
|
|
reviewTask(cwd, task.id);
|
|
const reopened = reopenTask(cwd, task.id);
|
|
expect(reopened.status).toBe('open');
|
|
expect(reopened.assignedTo).toBe('kimi');
|
|
expect(reopened.claimedBy).toBeUndefined();
|
|
const listed = listTasks(cwd, { status: 'open' });
|
|
expect(listed).toHaveLength(1);
|
|
});
|
|
|
|
it('getTask reads back the correct task', () => {
|
|
const task = createTask(cwd, { title: 'F', role: 'architect' });
|
|
const { task: read } = getTask(cwd, task.id);
|
|
expect(read.title).toBe('F');
|
|
expect(read.role).toBe('architect');
|
|
});
|
|
|
|
it('deletes a task: gone from the list, file, and search index', () => {
|
|
const keep = createTask(cwd, { title: 'keep me', role: 'implementer' });
|
|
const ghost = createTask(cwd, { title: 'ghost purge me', role: 'implementer' });
|
|
expect(listTasks(cwd)).toHaveLength(2);
|
|
|
|
const res = deleteTask(cwd, ghost.id);
|
|
expect(res.id).toBe(ghost.id);
|
|
|
|
// Removed from the index list…
|
|
const remaining = listTasks(cwd);
|
|
expect(remaining).toHaveLength(1);
|
|
expect(remaining[0].id).toBe(keep.id);
|
|
|
|
// …its markdown file is gone (getTask throws)…
|
|
expect(() => getTask(cwd, ghost.id)).toThrow();
|
|
|
|
// …and it no longer surfaces in the FTS search index.
|
|
const index = new Index(cwd);
|
|
const hits = index.search('purge').map((h) => h.id);
|
|
index.close();
|
|
expect(hits).not.toContain(ghost.id);
|
|
});
|
|
|
|
it('deleteTask is idempotent (deleting a missing task does not throw)', () => {
|
|
expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow();
|
|
});
|
|
|
|
it('dispatches a session-less agent explicitly', () => {
|
|
init(cwd, { projectName: 'dispatch-test', yes: true });
|
|
const config = loadConfig(cwd);
|
|
config.agents = { ...(config.agents ?? {}), backyard: { role: 'implementer', dispatch: 'architect' } };
|
|
saveConfig(cwd, config);
|
|
const task = createTask(cwd, { title: 'Backend', assignedTo: 'backyard' });
|
|
expect(dispatchTask(cwd, task.id, 'backyard')).toMatchObject({
|
|
status: 'in_progress', assignedTo: 'backyard', claimedBy: 'backyard',
|
|
});
|
|
});
|
|
|
|
it('records external work directly as a provenance-marked done task', () => {
|
|
init(cwd, { projectName: 'record-test', yes: true });
|
|
const task = recordExternalTask(cwd, { title: 'Already shipped', doneBy: 'codex' });
|
|
expect(task).toMatchObject({ status: 'done', origin: 'external', doneBy: 'codex', claimedBy: 'codex' });
|
|
expect(task.recordedAt).toBeTruthy();
|
|
expect(getTask(cwd, task.id).body).toContain('Nachgetragen');
|
|
});
|
|
});
|