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>
204 lines
9.2 KiB
TypeScript
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');
|
|
});
|
|
});
|