feat(task): agenthub task assign — architect addresses an open task to an agent

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 <id> --agent <name>` 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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-28 00:12:50 +02:00
parent ec378131e4
commit 78bbebf435
7 changed files with 74 additions and 6 deletions

View File

@ -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",

View File

@ -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<Task> = {}): Promise<void> {
@ -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}`);
}

View File

@ -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<void>): Promise<vo
export function createProgram(cwd: string): Command {
const program = new Command('agenthub')
.description('Local coordination layer for AI coding agents')
.version('0.5.0')
.version('0.6.0')
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
program
@ -317,6 +317,21 @@ export function createProgram(cwd: string): Command {
taskDone(projectCwd, id, meta);
}
});
taskCmd
.command('assign <id>')
.description('Address an open task to an agent (architect): it stays open and the agent\'s `work` auto-claims it')
.requiredOption('--agent <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 <id>')
.description('Submit a task for architect review (implementer: use this instead of done)')

View File

@ -75,6 +75,10 @@ export const remoteClient = {
});
},
async assignTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { assignedTo: agentName });
},
async reviewTask(baseUrl: string, id: string): Promise<Task> {
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'review' });
},

View File

@ -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,

View File

@ -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<Task>;
// 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':

View File

@ -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');
});
});