- work.ts: the CLI work loop now wakes on architect follow-up MESSAGES, not just tasks. drainAgentMessages() surfaces + marks-read the agent's inbox; wired into waitAndClaim (SSE message events) and as an upfront check in workAgent. reopen + next-assign already wake via task events; a plain message no longer leaves the re-armed loop dormant. - mcp/server.ts: implementer agenthub_work LOOP reminder now explicitly instructs re-arming after agenthub_task_review so the architect's approval/reopen/message wakes it (waitForTask already wakes on task+message). - tests: work.test.ts +message-wake +reopen-wake Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
6.7 KiB
TypeScript
184 lines
6.7 KiB
TypeScript
/**
|
|
* Tests for `agenthub work` — the auto-claim primitive (TSK-0018).
|
|
* - immediate: claims an already-open addressed task without waiting (local).
|
|
* - wait: blocks on SSE until a matching task is created, then claims it.
|
|
*/
|
|
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, getTask } from '../src/core/services/taskService.js';
|
|
import type { Task } from '../src/core/schema.js';
|
|
|
|
describe('agenthub work — immediate claim (local)', () => {
|
|
let cwd: string;
|
|
|
|
beforeEach(() => {
|
|
cwd = mkdtempSync(join(tmpdir(), 'ah-work-'));
|
|
init(cwd, { projectName: 'work-test', yes: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
it('claims an already-open addressed task without waiting', async () => {
|
|
const mine = createTask(cwd, { title: 'kimi: already open', role: 'implementer' });
|
|
|
|
await workAgent({ projectCwd: cwd, agent: 'kimi', role: 'implementer', timeoutSec: 2 });
|
|
|
|
const { task } = getTask(cwd, mine.id);
|
|
expect(task.status).toBe('in_progress');
|
|
expect(task.assignedTo).toBe('kimi');
|
|
}, 4000);
|
|
});
|
|
|
|
describe('agenthub work — wait then auto-claim (server SSE)', () => {
|
|
let cwd: string;
|
|
let server: Awaited<ReturnType<typeof startServer>>;
|
|
let nextServer: Awaited<ReturnType<typeof startServer>> | undefined;
|
|
|
|
beforeEach(async () => {
|
|
cwd = mkdtempSync(join(tmpdir(), 'ah-work-wait-'));
|
|
init(cwd, { projectName: 'work-wait', yes: true });
|
|
server = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await server.app.close().catch(() => undefined);
|
|
await nextServer?.app.close().catch(() => undefined);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
it('claims a task created AFTER work starts waiting', async () => {
|
|
// Start waiting; do NOT await yet — no task is addressed to kimi initially.
|
|
const workDone = workAgent({
|
|
serverUrl: server.url,
|
|
projectCwd: cwd,
|
|
agent: 'kimi',
|
|
role: 'implementer',
|
|
timeoutSec: 3,
|
|
});
|
|
|
|
// Give work a moment to reach the SSE wait, then delegate a task to kimi.
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
await fetch(`${server.url}/tasks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: 'kimi: delegated after wait', role: 'implementer' }),
|
|
});
|
|
|
|
await workDone; // resolves once work has claimed the task (or on timeout)
|
|
|
|
const tasks = (await fetch(`${server.url}/tasks`).then((r) => r.json())) as Task[];
|
|
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi:'));
|
|
expect(mine).toBeDefined();
|
|
expect(mine?.status).toBe('in_progress');
|
|
expect(mine?.assignedTo).toBe('kimi');
|
|
}, 6000);
|
|
|
|
it('reconnects after the SSE server moves and claims a later task', async () => {
|
|
const workDone = workAgent({
|
|
serverUrl: server.url,
|
|
projectCwd: cwd,
|
|
agent: 'kimi',
|
|
role: 'implementer',
|
|
timeoutSec: 6,
|
|
reconnectBackoffMs: [50, 100],
|
|
discoverServer: async () => nextServer?.url,
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
server.app.server.closeAllConnections?.();
|
|
await server.app.close();
|
|
nextServer = await startServer(cwd, { host: '127.0.0.1', port: 0 });
|
|
|
|
await fetch(`${nextServer.url}/tasks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: 'kimi: delegated after reconnect', role: 'implementer' }),
|
|
});
|
|
|
|
await workDone;
|
|
|
|
const tasks = (await fetch(`${nextServer.url}/tasks`).then((r) => r.json())) as Task[];
|
|
const mine = tasks.find((t) => (t.title ?? '').startsWith('kimi: delegated after reconnect'));
|
|
expect(mine).toBeDefined();
|
|
expect(mine?.status).toBe('in_progress');
|
|
expect(mine?.assignedTo).toBe('kimi');
|
|
}, 8000);
|
|
|
|
// ── TSK-0119: after a review submit the re-armed work loop must stay reachable ──
|
|
it('wakes on an architect follow-up message instead of going dormant', async () => {
|
|
// The implementer re-armed `work` after submitting; no task is addressed yet.
|
|
const workDone = workAgent({
|
|
serverUrl: server.url,
|
|
projectCwd: cwd,
|
|
agent: 'kimi',
|
|
role: 'implementer',
|
|
timeoutSec: 4,
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
await fetch(`${server.url}/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ from: 'claude', to: 'kimi', text: 'quick question about your submission' }),
|
|
});
|
|
|
|
await workDone; // resolves because the message woke the loop (not on timeout)
|
|
|
|
// The loop drained + marked the message read — proving it woke on the message,
|
|
// not that it merely timed out (a timeout would leave it delivered/unread).
|
|
const inbox = (await fetch(`${server.url}/messages?agent=kimi`).then((r) => r.json())) as Array<{ text: string; status: string }>;
|
|
const m = inbox.find((x) => x.text.includes('quick question'));
|
|
expect(m).toBeDefined();
|
|
expect(m?.status).toBe('read');
|
|
}, 7000);
|
|
|
|
it('wakes and re-claims when a submitted task is reopened', async () => {
|
|
// Seed a task addressed to kimi, claim it, submit for review.
|
|
await fetch(`${server.url}/tasks`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: 'kimi: build the widget', role: 'implementer' }),
|
|
});
|
|
await fetch(`${server.url}/tasks/TSK-0001`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: 'in_progress', assignedTo: 'kimi' }),
|
|
});
|
|
await fetch(`${server.url}/tasks/TSK-0001`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: 'review' }),
|
|
});
|
|
|
|
// Re-armed work loop: the task is in review, so nothing is addressed/open yet.
|
|
const workDone = workAgent({
|
|
serverUrl: server.url,
|
|
projectCwd: cwd,
|
|
agent: 'kimi',
|
|
role: 'implementer',
|
|
timeoutSec: 4,
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
// Architect reopens (send back to implementer).
|
|
await fetch(`${server.url}/tasks/TSK-0001`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: 'open' }),
|
|
});
|
|
|
|
await workDone;
|
|
|
|
const { task } = getTask(cwd, 'TSK-0001');
|
|
expect(task.status).toBe('in_progress');
|
|
expect(task.assignedTo).toBe('kimi');
|
|
}, 7000);
|
|
});
|