From d2b26cc64cbf77b524bd5546b802feae55c2805c Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 27 Jun 2026 18:18:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20`agenthub=20work`=20=E2=80=94=20auto-cl?= =?UTF-8?q?aim=20daemon=20(TSK-0018)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The missing autonomy step: an implementer no longer needs a human prompt per task. `agenthub work --agent --role ` announces, then: - claims an already-open task addressed to the agent immediately, or - blocks on the SSE stream until one appears (newly delegated OR reopened after review), then claims + prints it. Loop: work → implement → `task review` → work. New/reopened tasks are picked up automatically; run it in the background so the wait doesn't tie up the turn. - src/cli/commands/start.ts: extracted announceAgent / findAddressedOpenTask / claimAndPrintTask (shared by start + work); addressed-match now also covers assignedTo (reopened tasks coming back for rework). - src/cli/commands/work.ts: wait-and-claim via /events, with a gap-close re-check after subscribing and an optional --timeout. - index.ts: `agenthub work` command. - templates: implementer guides lead with `work` (the autonomous loop), keep `start` as the one-shot. - tests: immediate claim (local) + wait-then-claim (server SSE). 119/119. Bump 0.3.1 -> 0.4.0. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src/cli/commands/start.ts | 113 +++++++++++++++++++++++-------------- src/cli/commands/work.ts | 114 ++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 21 ++++++- src/core/templates.ts | 16 ++++-- tests/work.test.ts | 80 ++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 49 deletions(-) create mode 100644 src/cli/commands/work.ts create mode 100644 tests/work.test.ts diff --git a/package.json b/package.json index 9b9eceb..c28bc02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "0.3.1", + "version": "0.4.0", "description": "Local coordination layer for AI coding agents", "type": "module", "main": "./dist/index.js", diff --git a/src/cli/commands/start.ts b/src/cli/commands/start.ts index 4e74a9f..85d4ae4 100644 --- a/src/cli/commands/start.ts +++ b/src/cli/commands/start.ts @@ -6,39 +6,54 @@ import { } from '../../core/services/taskService.js'; import { listHandoffs as svcListHandoffs, getHandoff as svcGetHandoff } from '../../core/services/handoffService.js'; -/** - * `agenthub start --agent --role ` — one-command onboarding for an - * agent. Announces presence, claims the open task addressed to this agent, and - * prints the task body + its handoff + the next step, so the agent can begin - * immediately without the human stitching commands together. - * - * "Addressed to " = task title starts with ":" (the delegation - * convention) OR a handoff for that task has toAgent === . - */ -export async function startAgent(opts: { +export interface AgentContext { serverUrl?: string; projectCwd: string; agent: string; role: string; -}): Promise { - const { serverUrl, projectCwd, agent, role } = opts; - const a = agent.toLowerCase(); +} - // 1. Announce (presence is best-effort — never block onboarding on it). +interface Listed { + id: string; + title?: string; + status?: string; + role?: string; + taskId?: string; + toAgent?: string; + assignedTo?: string; +} + +/** Announce presence (best-effort) and print the joined line. */ +export async function announceAgent(serverUrl: string | undefined, agent: string, role: string): Promise { if (serverUrl) { try { await remoteClient.announce(serverUrl, agent, role); } catch { - /* ignore */ + /* presence is best-effort */ } } console.log(`AgentHub: ${agent} joined (${role})`); +} - // 2. Gather open role tasks + handoffs (handoffs carry taskId + toAgent). - const tasks = serverUrl - ? await remoteClient.listTasks(serverUrl, { role, status: 'open' }) - : svcListTasks(projectCwd, { role, status: 'open' }); - const handoffs = serverUrl ? await remoteClient.listHandoffs(serverUrl) : svcListHandoffs(projectCwd); +async function listOpenRoleTasks(ctx: AgentContext): Promise { + return ctx.serverUrl + ? await remoteClient.listTasks(ctx.serverUrl, { role: ctx.role, status: 'open' }) + : svcListTasks(ctx.projectCwd, { role: ctx.role, status: 'open' }); +} + +/** + * Find the open task addressed to this agent. "Addressed" = task title starts + * with ":" (delegation convention), OR a handoff for the task has + * toAgent === , OR the task is already assignedTo this agent (a reopened + * task that came back for rework). Returns the task + the handoff list (so the + * caller can print the matching handoff without re-fetching). + */ +export async function findAddressedOpenTask( + ctx: AgentContext, +): Promise<{ task: Listed; handoffs: Listed[] } | undefined> { + const a = ctx.agent.toLowerCase(); + const tasks = await listOpenRoleTasks(ctx); + const handoffs = ctx.serverUrl ? await remoteClient.listHandoffs(ctx.serverUrl) : svcListHandoffs(ctx.projectCwd); const addressedByHandoff = new Set( handoffs @@ -46,30 +61,24 @@ export async function startAgent(opts: { .map((h) => String(h.taskId)), ); const mine = tasks.filter( - (t) => (t.title ?? '').toLowerCase().startsWith(`${a}:`) || addressedByHandoff.has(t.id), + (t) => + (t.title ?? '').toLowerCase().startsWith(`${a}:`) || + addressedByHandoff.has(t.id) || + (t.assignedTo && String(t.assignedTo).toLowerCase() === a), ); - // 3. Nothing addressed → guide the agent instead of guessing. - if (mine.length === 0) { - if (tasks.length === 0) { - console.log(`AgentHub: no open ${role} tasks — waiting for the architect to delegate.`); - } else { - console.log(`AgentHub: no task addressed to ${agent}. Open ${role} tasks:`); - for (const t of tasks) console.log(` ${t.id} ${t.title ?? ''}`); - console.log(`→ claim one yourself: agenthub task claim --agent ${agent}`); - } - return; - } + if (mine.length === 0) return undefined; + return { task: mine[0], handoffs }; +} - // 4. Claim the addressed task. - const task = mine[0]; - if (serverUrl) await remoteClient.claimTask(serverUrl, task.id, agent); - else svcClaimTask(projectCwd, task.id, agent); +/** Claim the task and print its body + handoff + the review-gate next step. */ +export async function claimAndPrintTask(ctx: AgentContext, task: Listed, handoffs: Listed[]): Promise { + if (ctx.serverUrl) await remoteClient.claimTask(ctx.serverUrl, task.id, ctx.agent); + else svcClaimTask(ctx.projectCwd, task.id, ctx.agent); console.log(`AgentHub: Task claimed ${task.id} ${task.title ?? ''}`); - // 5. Print the task body. try { - const detail = serverUrl ? await remoteClient.getTask(serverUrl, task.id) : svcGetTask(projectCwd, task.id); + const detail = ctx.serverUrl ? await remoteClient.getTask(ctx.serverUrl, task.id) : svcGetTask(ctx.projectCwd, task.id); if (detail.body && detail.body.trim()) { console.log(`\n─ Task ${task.id} ──────────────`); console.log(detail.body.trim()); @@ -78,11 +87,10 @@ export async function startAgent(opts: { /* body is optional */ } - // 6. Print the handoff for this task (scope + acceptance criteria). const hof = handoffs.find((h) => h.taskId === task.id); if (hof) { try { - const hd = serverUrl ? await remoteClient.getHandoff(serverUrl, hof.id) : svcGetHandoff(projectCwd, hof.id); + const hd = ctx.serverUrl ? await remoteClient.getHandoff(ctx.serverUrl, hof.id) : svcGetHandoff(ctx.projectCwd, hof.id); console.log(`\n─ Handoff ${hof.id} ──────────────`); console.log(hd.handoff.summary); if (hd.body && hd.body.trim()) console.log(hd.body.trim()); @@ -91,9 +99,32 @@ export async function startAgent(opts: { } } - // 7. Next steps — the review gate, spelled out. console.log(`\n─ Next ──────────────`); console.log(`Implement the task, then submit for review: agenthub task review ${task.id}`); console.log(`Report what you did: agenthub memory add --title "${task.id} result" --category implementation --content "…"`); console.log(`Only the architect closes a task (\`done\`). If reopened, address the feedback and review again.`); } + +/** + * `agenthub start --agent --role ` — one-shot onboarding. Announce, + * claim the addressed task and print it; if none is addressed, list the open + * role tasks so the agent can pick one. + */ +export async function startAgent(ctx: AgentContext): Promise { + await announceAgent(ctx.serverUrl, ctx.agent, ctx.role); + + const found = await findAddressedOpenTask(ctx); + if (found) { + await claimAndPrintTask(ctx, found.task, found.handoffs); + return; + } + + const tasks = await listOpenRoleTasks(ctx); + if (tasks.length === 0) { + console.log(`AgentHub: no open ${ctx.role} tasks — waiting for the architect to delegate.`); + } else { + console.log(`AgentHub: no task addressed to ${ctx.agent}. Open ${ctx.role} tasks:`); + for (const t of tasks) console.log(` ${t.id} ${t.title ?? ''}`); + console.log(`→ claim one yourself: agenthub task claim --agent ${ctx.agent}`); + } +} diff --git a/src/cli/commands/work.ts b/src/cli/commands/work.ts new file mode 100644 index 0000000..a83a58a --- /dev/null +++ b/src/cli/commands/work.ts @@ -0,0 +1,114 @@ +import { parseSSEBuffer } from './watch.js'; +import { announceAgent, findAddressedOpenTask, claimAndPrintTask, type AgentContext } from './start.js'; + +/** + * `agenthub work --agent --role ` — the auto-claim primitive + * (TSK-0018). Announce, then: + * - if a task addressed to this agent is already open → claim + print it now; + * - otherwise block on the SSE stream until one appears (newly created OR + * reopened after review), then claim + print it. + * + * Returns after claiming exactly one task (or on timeout). An agent loops it: + * work → implement → `task review` → work → … so new/reopened tasks are picked + * up automatically without a human prompt. Best run in the background so the + * wait doesn't tie up the foreground. + */ +export async function workAgent(ctx: AgentContext & { timeoutSec?: number }): Promise { + await announceAgent(ctx.serverUrl, ctx.agent, ctx.role); + + // Already-waiting task? + const found = await findAddressedOpenTask(ctx); + if (found) { + await claimAndPrintTask(ctx, found.task, found.handoffs); + return; + } + + if (!ctx.serverUrl) { + console.log(`AgentHub: no task for ${ctx.agent}, and no server to wait on. Re-run when a task is delegated.`); + return; + } + + console.log( + `AgentHub: waiting for a task addressed to ${ctx.agent}…${ctx.timeoutSec ? ` (timeout ${ctx.timeoutSec}s)` : ''}`, + ); + await waitAndClaim(ctx); +} + +function waitAndClaim(ctx: AgentContext & { timeoutSec?: number }): Promise { + const serverUrl = ctx.serverUrl as string; + return new Promise((resolve) => { + const controller = new AbortController(); + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + try { + controller.abort(); + } catch { + /* already aborted */ + } + resolve(); + }; + + const timer = ctx.timeoutSec + ? setTimeout(() => { + console.log(`AgentHub: no task for ${ctx.agent} after ${ctx.timeoutSec}s — exiting.`); + finish(); + }, ctx.timeoutSec * 1000) + : undefined; + + // Re-query then claim if a task addressed to us is now open. Returns true + // if a task was claimed (so the caller can stop). + const tryClaim = async (): Promise => { + const f = await findAddressedOpenTask(ctx); + if (!f) return false; + if (timer) clearTimeout(timer); + await claimAndPrintTask(ctx, f.task, f.handoffs); + finish(); + return true; + }; + + fetch(`${serverUrl}/events`, { signal: controller.signal, headers: { Accept: 'text/event-stream' } }) + .then(async (res) => { + if (!res.body) { + if (timer) clearTimeout(timer); + finish(); + return; + } + // Close the gap: a task may have appeared between the initial check and + // this subscription — check once more now that we're listening. + if (await tryClaim()) return; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (!settled) { + let done: boolean; + let value: Uint8Array | undefined; + try { + ({ done, value } = await reader.read()); + } catch { + break; // aborted or connection closed + } + if (done) break; + if (value) buffer += decoder.decode(value, { stream: true }); + + const { events, remaining } = parseSSEBuffer(buffer); + buffer = remaining; + // Any task event may mean a task addressed to us just opened/reopened. + if (events.some((e) => e.type === 'task')) { + if (await tryClaim()) return; + } + } + if (timer) clearTimeout(timer); + finish(); + }) + .catch((err: unknown) => { + if (!(err instanceof Error && err.name === 'AbortError')) { + console.error(`AgentHub: wait failed: ${err instanceof Error ? err.message : String(err)}`); + } + if (timer) clearTimeout(timer); + finish(); + }); + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 195349d..94c4b48 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,7 @@ import { serverStart } from './commands/server.js'; import { update } from './commands/update.js'; import { watchEvents } from './commands/watch.js'; import { startAgent } from './commands/start.js'; +import { workAgent } from './commands/work.js'; import { loadConfig, saveConfig } from '../core/config.js'; import { findProjectRoot } from '../core/paths.js'; import { discoverServer } from '../discovery.js'; @@ -113,7 +114,7 @@ async function runRemote(serverUrl: string, fn: () => Promise): Promise', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program @@ -500,6 +501,24 @@ export function createProgram(cwd: string): Command { } }); + // ─── work (auto-claim) ────────────────────────────────────────────────────── + program + .command('work') + .description('Wait for a task addressed to you, claim it, and print it (auto-claim loop)') + .requiredOption('--agent ', 'Agent name') + .option('--role ', 'Role (default: implementer)', 'implementer') + .option('--timeout ', 'Stop waiting after N seconds (default: wait indefinitely)') + .action(async (options) => { + const { serverUrl, projectCwd } = await resolveContext(program, cwd); + const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined; + const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec }; + if (serverUrl) { + await runRemote(serverUrl, () => workAgent(ctx)); + } else { + await workAgent(ctx); + } + }); + // ─── watch ─────────────────────────────────────────────────────────────── program .command('watch') diff --git a/src/core/templates.ts b/src/core/templates.ts index a55a7b9..6f34e06 100644 --- a/src/core/templates.ts +++ b/src/core/templates.ts @@ -46,19 +46,23 @@ function implementerMd(cliName: string, agentName: string, roles: string): strin **On your first turn, RUN this and follow its output — do not just summarize:** \`\`\` -agenthub start --agent ${agentName} --role implementer +agenthub work --agent ${agentName} --role implementer \`\`\` -That one command announces you, claims the task addressed to you, and prints the -task + its handoff. Then: +\`work\` waits until a task addressed to you is ready (newly delegated OR reopened +after review), claims it, and prints the task + its handoff. Then: 1. Implement the task. 2. **Submit for review (NOT done):** \`agenthub task review \` and report: \`agenthub memory add --title " result" --category implementation --content ""\` -3. Wait for the architect's verdict. If the task is **reopened** (status back to - \`open\`), read the new feedback handoff, address it, and \`agenthub task review \` - again. +3. **Run \`agenthub work --agent ${agentName} --role implementer\` again** — it blocks + until your next task (or a reopened one) is ready, then auto-claims it. This is + the loop: work → implement → review → work. Run it in the background so the wait + doesn't tie up your turn. + +(\`agenthub start --agent ${agentName} --role implementer\` is the one-shot variant: +it claims an already-open task but does not wait.) ⚠️ NEVER run \`agenthub task done\` — only the architect approves and closes tasks. You drive the AgentHub CLI yourself; the human does not type these commands for you. diff --git a/tests/work.test.ts b/tests/work.test.ts new file mode 100644 index 0000000..c1491de --- /dev/null +++ b/tests/work.test.ts @@ -0,0 +1,80 @@ +/** + * 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>; + + 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(); + 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); +});