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:
parent
ec378131e4
commit
78bbebf435
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"description": "Local coordination layer for AI coding agents",
|
"description": "Local coordination layer for AI coding agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { input, select } from '@inquirer/prompts';
|
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';
|
import type { Task } from '../../core/schema.js';
|
||||||
|
|
||||||
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
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);
|
reopenTask(cwd, id);
|
||||||
console.log(`AgentHub: Task reopened ${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}`);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Command } from 'commander';
|
|||||||
import { init } from './commands/init.js';
|
import { init } from './commands/init.js';
|
||||||
import { status } from './commands/status.js';
|
import { status } from './commands/status.js';
|
||||||
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.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 { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||||
import { delegate } from './commands/delegate.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 {
|
export function createProgram(cwd: string): Command {
|
||||||
const program = new Command('agenthub')
|
const program = new Command('agenthub')
|
||||||
.description('Local coordination layer for AI coding agents')
|
.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)');
|
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
|
||||||
|
|
||||||
program
|
program
|
||||||
@ -317,6 +317,21 @@ export function createProgram(cwd: string): Command {
|
|||||||
taskDone(projectCwd, id, meta);
|
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
|
taskCmd
|
||||||
.command('review <id>')
|
.command('review <id>')
|
||||||
.description('Submit a task for architect review (implementer: use this instead of done)')
|
.description('Submit a task for architect review (implementer: use this instead of done)')
|
||||||
|
|||||||
@ -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> {
|
async reviewTask(baseUrl: string, id: string): Promise<Task> {
|
||||||
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'review' });
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'review' });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -84,6 +84,16 @@ export function reopenTask(cwd: string, id: string): Task {
|
|||||||
return updateTask(cwd, id, { status: 'open' });
|
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) {
|
function toIndexEntry(task: Task, filePath: string) {
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
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 { getTaskActivity } from '../core/services/activityService.js';
|
||||||
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
||||||
import { createDecision, listDecisions } from '../core/services/decisionService.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 { id } = request.params as { id: string };
|
||||||
const patch = request.body as Partial<Task>;
|
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;
|
let task: Task;
|
||||||
switch (patch.status) {
|
switch (patch.status) {
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { tmpdir } from 'os';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { init } from '../src/cli/commands/init.js';
|
import { init } from '../src/cli/commands/init.js';
|
||||||
import { startAgent } from '../src/cli/commands/start.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', () => {
|
describe('agenthub start — onboarding auto-claim', () => {
|
||||||
let cwd: string;
|
let cwd: string;
|
||||||
@ -48,4 +48,19 @@ describe('agenthub start — onboarding auto-claim', () => {
|
|||||||
// Should not throw; nothing to claim.
|
// Should not throw; nothing to claim.
|
||||||
await expect(startAgent({ projectCwd: cwd, agent: 'kimi', role: 'implementer' })).resolves.toBeUndefined();
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user