agenthub/tests/singleClaim.test.ts

167 lines
7.2 KiB
TypeScript

/**
* 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<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function createAndAssign(serverUrl: string, title: string, priority: string): Promise<string> {
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<Task> {
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<Response> {
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<ReturnType<typeof startServer>>;
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');
});
});