167 lines
7.2 KiB
TypeScript
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');
|
|
});
|
|
});
|