From 78bbebf43532ed899fb25c713b61246b2eb477d4 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sun, 28 Jun 2026 00:12:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(task):=20`agenthub=20task=20assign`=20?= =?UTF-8?q?=E2=80=94=20architect=20addresses=20an=20open=20task=20to=20an?= =?UTF-8?q?=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the addressing gap that left a waiting `agenthub work` blocked: the remaining Win tasks were titled "Win L2: …" / unassigned, so they matched no agent and the daemon waited forever. `agenthub task assign --agent ` sets assignedTo WITHOUT claiming (status stays open) and fires task/updated — so an agent's blocked `work` re-checks, matches via assignedTo, and auto-claims it. The architect can now route a specific open task to a specific agent and have its daemon pick it up. - taskService.assignTask; PATCH /tasks/:id handles assignedTo-without-status; remoteClient.assignTask; CLI `task assign`. - test: assign keeps status open + sets assignedTo; start/work then claims it via the assignedTo match. 122/122 green. Bump 0.5.0 -> 0.6.0. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src/cli/commands/task.ts | 7 ++++++- src/cli/index.ts | 19 +++++++++++++++++-- src/cli/remoteClient.ts | 4 ++++ src/core/services/taskService.ts | 10 ++++++++++ src/server/routes.ts | 21 ++++++++++++++++++++- tests/start.test.ts | 17 ++++++++++++++++- 7 files changed, 74 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 5aecdf7..1862365 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "0.5.0", + "version": "0.6.0", "description": "Local coordination layer for AI coding agents", "type": "module", "main": "./dist/index.js", diff --git a/src/cli/commands/task.ts b/src/cli/commands/task.ts index 3d36f2c..a2463cb 100644 --- a/src/cli/commands/task.ts +++ b/src/cli/commands/task.ts @@ -1,5 +1,5 @@ import { input, select } from '@inquirer/prompts'; -import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask } from '../../core/services/taskService.js'; +import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js'; import type { Task } from '../../core/schema.js'; export async function taskCreate(cwd: string, options: Partial = {}): Promise { @@ -64,3 +64,8 @@ export function taskReopen(cwd: string, id: string): void { reopenTask(cwd, id); console.log(`AgentHub: Task reopened ${id}`); } + +export function taskAssign(cwd: string, id: string, agentName: string): void { + assignTask(cwd, id, agentName); + console.log(`AgentHub: Task assigned ${id} → ${agentName}`); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 4dcba3f..3b5b4b9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { init } from './commands/init.js'; import { status } from './commands/status.js'; import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js'; -import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen } from './commands/task.js'; +import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { decisionCreate, decisionList } from './commands/decision.js'; import { delegate } from './commands/delegate.js'; @@ -114,7 +114,7 @@ async function runRemote(serverUrl: string, fn: () => Promise): Promise', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program @@ -317,6 +317,21 @@ export function createProgram(cwd: string): Command { taskDone(projectCwd, id, meta); } }); + taskCmd + .command('assign ') + .description('Address an open task to an agent (architect): it stays open and the agent\'s `work` auto-claims it') + .requiredOption('--agent ', 'Agent name') + .action(async (id, options) => { + const { serverUrl, projectCwd } = await resolveContext(program, cwd); + if (serverUrl) { + await runRemote(serverUrl, async () => { + await remoteClient.assignTask(serverUrl, id, options.agent); + console.log(`AgentHub: Task assigned ${id} → ${options.agent}`); + }); + } else { + taskAssign(projectCwd, id, options.agent); + } + }); taskCmd .command('review ') .description('Submit a task for architect review (implementer: use this instead of done)') diff --git a/src/cli/remoteClient.ts b/src/cli/remoteClient.ts index b7adbb3..a5f41f3 100644 --- a/src/cli/remoteClient.ts +++ b/src/cli/remoteClient.ts @@ -75,6 +75,10 @@ export const remoteClient = { }); }, + async assignTask(baseUrl: string, id: string, agentName: string): Promise { + return request(baseUrl, 'PATCH', `/tasks/${id}`, { assignedTo: agentName }); + }, + async reviewTask(baseUrl: string, id: string): Promise { return request(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'review' }); }, diff --git a/src/core/services/taskService.ts b/src/core/services/taskService.ts index 9fde0fd..db5b4f6 100644 --- a/src/core/services/taskService.ts +++ b/src/core/services/taskService.ts @@ -84,6 +84,16 @@ export function reopenTask(cwd: string, id: string): Task { return updateTask(cwd, id, { status: 'open' }); } +/** + * Address an OPEN task to an agent without claiming it: sets assignedTo but + * leaves the status untouched (open). The agent's `agenthub work` then auto- + * claims it (assignedTo match), so the architect can route a specific task to + * a specific agent and have a waiting daemon pick it up. + */ +export function assignTask(cwd: string, id: string, agentName: string): Task { + return updateTask(cwd, id, { assignedTo: agentName }); +} + function toIndexEntry(task: Task, filePath: string) { return { id: task.id, diff --git a/src/server/routes.ts b/src/server/routes.ts index 6bd6c9f..67246f3 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -1,5 +1,5 @@ import { FastifyInstance, FastifyReply } from 'fastify'; -import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask } from '../core/services/taskService.js'; +import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask } from '../core/services/taskService.js'; import { getTaskActivity } from '../core/services/activityService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js'; @@ -148,6 +148,25 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise const { id } = request.params as { id: string }; const patch = request.body as Partial; + // Assign without claiming: address an open task to an agent (no status + // change). Fires task/updated so a waiting `agenthub work` auto-claims it. + if (patch.assignedTo !== undefined && patch.status === undefined) { + const assigned = assignTask(cwd, id, patch.assignedTo); + emitChange( + { + type: 'task', + action: 'updated', + id: assigned.id, + title: assigned.title, + status: assigned.status, + role: assigned.role, + assignedTo: assigned.assignedTo, + }, + assigned.updatedAt, + ); + return assigned; + } + let task: Task; switch (patch.status) { case 'in_progress': diff --git a/tests/start.test.ts b/tests/start.test.ts index 953d962..78d80cd 100644 --- a/tests/start.test.ts +++ b/tests/start.test.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { init } from '../src/cli/commands/init.js'; import { startAgent } from '../src/cli/commands/start.js'; -import { createTask, getTask } from '../src/core/services/taskService.js'; +import { createTask, getTask, assignTask } from '../src/core/services/taskService.js'; describe('agenthub start — onboarding auto-claim', () => { let cwd: string; @@ -48,4 +48,19 @@ describe('agenthub start — onboarding auto-claim', () => { // Should not throw; nothing to claim. await expect(startAgent({ projectCwd: cwd, agent: 'kimi', role: 'implementer' })).resolves.toBeUndefined(); }); + + it('assignTask addresses an open task; start then claims it via assignedTo', async () => { + // A generically-titled task, NOT name-addressed to the agent. + const t = createTask(cwd, { title: 'Win L2: generic bypass task', role: 'implementer' }); + + assignTask(cwd, t.id, 'windows-claude'); + const afterAssign = getTask(cwd, t.id).task; + expect(afterAssign.status).toBe('open'); // assign does NOT claim + expect(afterAssign.assignedTo).toBe('windows-claude'); + + // Now the agent's start/work finds it via the assignedTo match and claims it. + await startAgent({ projectCwd: cwd, agent: 'windows-claude', role: 'implementer' }); + expect(getTask(cwd, t.id).task.status).toBe('in_progress'); + expect(getTask(cwd, t.id).task.assignedTo).toBe('windows-claude'); + }); });