/** * Regression tests for TSK-0237: single-claim semantics in the work loop. * * Bug (24.07. ~01:54): after the TSK-0230 auto-wake, an agent's waiting work * loop claimed EVERY task assigned to it in parallel (codex held TSK-0222 + * TSK-0235 at once) — the board lied about who works on what. * * Soll: * (a) several assigned tasks ⇒ the loop claims exactly ONE (highest * priority, oldest createdAt breaks ties); the rest stays open. * (b) the next auto-claim happens only after the active task reaches * review/done (or is reopened back). * (c) server-side guard: a claim while holding an in_progress task → error * (architect exempt, no --force for agents). * (d) the TSK-0230 auto-wake suite keeps passing. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { init } from '../src/cli/commands/init.js'; import { workAgent } from '../src/cli/commands/work.js'; import { startServer } from '../src/server/index.js'; import { createTask, claimTask, getTask } from '../src/core/services/taskService.js'; import type { Task } from '../src/core/schema.js'; const AGENT = 'kimi'; function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } async function createAndAssign(serverUrl: string, title: string, priority: string): Promise { const res = await fetch(`${serverUrl}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, role: 'implementer', priority }), }); const task = (await res.json()) as Task; await fetch(`${serverUrl}/tasks/${task.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assignedTo: AGENT }), }); return task.id; } async function taskViaApi(serverUrl: string, id: string): Promise { const res = (await fetch(`${serverUrl}/tasks/${id}`).then((r) => r.json())) as { task: Task }; return res.task; } async function claimViaApi(serverUrl: string, id: string, agent: string): Promise { return fetch(`${serverUrl}/tasks/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'in_progress', assignedTo: agent }), }); } describe('single-claim semantics (TSK-0237)', () => { let cwd: string; let server: Awaited>; beforeEach(async () => { cwd = mkdtempSync(join(tmpdir(), 'ah-single-claim-')); init(cwd, { projectName: 'single-claim-test', yes: true }); server = await startServer(cwd, { host: '127.0.0.1', port: 0 }); }); afterEach(async () => { await server.app.close().catch(() => undefined); rmSync(cwd, { recursive: true, force: true }); }); it('(a) claims exactly ONE of several assigned tasks — highest priority first', async () => { const low = await createAndAssign(server.url, 'kimi: low prio', 'low'); await sleep(10); const high = await createAndAssign(server.url, 'kimi: high prio', 'high'); await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 }); const highTask = await taskViaApi(server.url, high); expect(highTask.status).toBe('in_progress'); expect(highTask.assignedTo).toBe(AGENT); // The other task must stay open — no parallel claim. const lowTask = await taskViaApi(server.url, low); expect(lowTask.status).toBe('open'); expect(lowTask.assignedTo).toBe(AGENT); // A second work run while busy claims nothing (single-claim), it just waits. await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 1 }); expect((await taskViaApi(server.url, low)).status).toBe('open'); }, 10_000); it('(a2) breaks priority ties by oldest createdAt', async () => { const older = await createAndAssign(server.url, 'kimi: older medium', 'medium'); await sleep(10); const newer = await createAndAssign(server.url, 'kimi: newer medium', 'medium'); await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 }); expect((await taskViaApi(server.url, older)).status).toBe('in_progress'); expect((await taskViaApi(server.url, newer)).status).toBe('open'); }, 10_000); it('(b) auto-claims the next task only after the active one reaches review', async () => { const first = await createAndAssign(server.url, 'kimi: first', 'high'); await sleep(10); const second = await createAndAssign(server.url, 'kimi: second', 'medium'); await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 }); expect((await taskViaApi(server.url, first)).status).toBe('in_progress'); expect((await taskViaApi(server.url, second)).status).toBe('open'); // Submit the active task for review → the agent is free for the next one. await fetch(`${server.url}/tasks/${first}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'review' }), }); await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 }); expect((await taskViaApi(server.url, second)).status).toBe('in_progress'); }, 12_000); it('(c) server guard: claim while holding an in_progress task → 400, architect exempt', async () => { const t1 = await createAndAssign(server.url, 'kimi: held', 'medium'); const t2 = await createAndAssign(server.url, 'kimi: blocked', 'medium'); const ok = await claimViaApi(server.url, t1, AGENT); expect(ok.status).toBe(200); const blocked = await claimViaApi(server.url, t2, AGENT); expect(blocked.status).toBe(400); const body = (await blocked.json()) as { error?: string; message?: string }; expect(JSON.stringify(body)).toContain(t1); // The blocked task stays open. expect((await taskViaApi(server.url, t2)).status).toBe('open'); // Architect (default config: claude) may hold several threads. const t3 = await createAndAssign(server.url, 'claude: thread one', 'medium'); const t4 = await createAndAssign(server.url, 'claude: thread two', 'medium'); expect((await claimViaApi(server.url, t3, 'claude')).status).toBe(200); expect((await claimViaApi(server.url, t4, 'claude')).status).toBe(200); }, 10_000); it('(c2) claimTask unit: guard throws, same-task re-claim stays idempotent', () => { const a = createTask(cwd, { title: 'kimi: a', role: 'implementer' }); const b = createTask(cwd, { title: 'kimi: b', role: 'implementer' }); claimTask(cwd, a.id, AGENT); expect(() => claimTask(cwd, b.id, AGENT)).toThrowError(new RegExp(a.id)); // Idempotent re-claim of the SAME task is still a no-op. expect(() => claimTask(cwd, a.id, AGENT)).not.toThrow(); // Architect exempt. const c = createTask(cwd, { title: 'claude: x', role: 'architect' }); const d = createTask(cwd, { title: 'claude: y', role: 'architect' }); claimTask(cwd, c.id, 'claude'); expect(() => claimTask(cwd, d.id, 'claude')).not.toThrow(); const { task } = getTask(cwd, b.id); expect(task.status).toBe('open'); }); });