agenthub/tests/singleClaim.test.ts
chahinebrini 1f3126b3b5 feat(hub): Check-in-Kanal + Review bindet den Agenten — der Wurzelfix (TSK-0274/0273, DEC-0035)
WURZEL: Ein Agent empfängt SSE-Events ausschließlich, solange er in
agenthub_work blockiert. Während er einen Task AUSFÜHRT, ist er vollständig
taub — kein Reopen, kein Cancel, keine Nachricht erreicht ihn. Ein echter
Interrupt in einen laufenden Agenten-Turn existiert nicht. Heartbeat,
Watchdog, Polling-Fallback und Presence-Tracking waren allesamt Umgehungen
dieser einen Tatsache; deshalb kam das Problem jede Session zurück.

LÖSUNG — der einzige reale Kanal: die Momente, in denen der Agent von sich
aus mit dem Hub spricht.

- checkinService.resolvePending(): zustandslos aus Tasks + Messages
  abgeleitet, nichts, was ein Neustart verliert. Erkennt, dass dem Agenten
  die Arbeit entzogen wurde (reopen/cancel/fremder Claim), und liefert
  ungelesene Nachrichten + offene Zuweisungen mit.
- agenthub_task_log gibt den pending-Block zurück; der Tool-Text macht ihn
  verbindlich (pending.interrupted ⇒ sofort aufhören, nicht einreichen).
- Neu: agenthub_checkin(agent, taskId) für lange Strecken ohne Log-Zeile,
  plus GET /agents/:agent/pending.
- resolvePending degradiert auf reinen Namensvergleich, wenn keine Config da
  ist — ein Check-in darf nie an Konfiguration scheitern.

DEC-0035: `review` bindet den Agenten wie `in_progress`. Vorher galt er in
der Sekunde des Einreichens als frei, griff sich die nächste Task, und ein
Reopen prallte danach am Ein-Task-Guard ab (so ging der Reopen von TSK-0218
verloren). Der Loop bleibt aktiv — wach warten, nichts Neues anfangen.

Außerdem: in_progress → open erlaubt, damit der Architekt eine festhängende
Arbeit überhaupt entziehen kann (vorher 400, Agent blieb dauerhaft blockiert).

SICHTBARKEIT: /health und /agent-health zeigen pro Agent pendingCount
(wartende Tasks) und deafForSec (wie lange ohne Check-in bei laufender
Arbeit). Ungelesene Nachrichten stehen separat — der dreistellige
Altbestand einzelner Agenten hätte das Signal sonst erschlagen.

Tests: 279 → 290. tests/singleClaim (b) auf den neuen Vertrag umgeschrieben
(+ (b2): ein Reopen gewinnt gegen die nächste Task).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 18:05:08 +02:00

204 lines
9.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);
/**
* DEC-0035 (löst das frühere TSK-0237-Verhalten ab): `review` bindet den
* Agenten genauso wie `in_progress`. Früher galt er in der Sekunde des
* Einreichens als frei und griff sich die nächste Task — wies der Architekt
* die Review danach zurück, blockierte der Ein-Task-Guard den Reopen und die
* zurückgewiesene Arbeit blieb unbemerkt liegen (so ging der Reopen von
* TSK-0218 verloren). Erst das Approve gibt den Agenten frei.
*/
it('(b) claimt die nächste Task NICHT bei review — erst nach dem Architekten-Approve', 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');
const patch = (id: string, body: Record<string, unknown>) =>
fetch(`${server.url}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
// Eingereicht — der Agent bleibt gebunden, solange das Review offen ist.
await patch(first, { status: 'review' });
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, second)).status).toBe('open');
// Approve → jetzt erst ist er frei für die nächste Task.
await patch(first, { status: 'done' });
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
expect((await taskViaApi(server.url, second)).status).toBe('in_progress');
}, 14_000);
it('(b2) ein Reopen gibt dem Agenten die ALTE Task zurück, nicht die nächste', 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 });
const patch = (id: string, body: Record<string, unknown>) =>
fetch(`${server.url}/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
await patch(first, { status: 'review' });
await patch(first, { status: 'open' }); // Architekt weist zurück
await workAgent({ serverUrl: server.url, projectCwd: cwd, agent: AGENT, role: 'implementer', timeoutSec: 2 });
// Die zurückgewiesene Arbeit muss gewinnen — genau hier ging früher ein
// Reopen verloren, weil der Agent schon an der nächsten Task hing.
expect((await taskViaApi(server.url, first)).status).toBe('in_progress');
expect((await taskViaApi(server.url, second)).status).toBe('open');
}, 14_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');
});
});