Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/domain/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,47 @@ export class TaskService {
});
}

// Like `claim` but never advances the stage. For pipelines whose first
// stage is itself a do-work stage (read-context, pull-from-queue, etc.)
// where assignment and "begin spec" are distinct actions.
assign(taskId: number, assigneeName: string): Task {
validateAssignee(assigneeName);

return this.db.transaction(() => {
const task = this.requireTask(taskId);
if (task.status !== 'pending') {
throw new ConflictError(`Task ${taskId} is not pending (status: ${task.status}).`);
}

const gate = this.getGateConfig(task.project ?? undefined);
const minConfidence = gate?.min_confidence_for_claim;
if (typeof minConfidence === 'number' && minConfidence > 0) {
const { score, reasons } = scoreTaskConfidence({
title: task.title,
description: task.description,
});
if (score < minConfidence) {
const detail = reasons.length ? ` Issues: ${reasons.join('; ')}` : '';
throw new ValidationError(
`Task ${taskId} confidence ${score}/100 below required ${minConfidence}.${detail}`,
);
}
}

// Stage stays put; status flips to in_progress because an agent now
// actively owns the task. Status no longer follows stage strictly here,
// but a "pending + assigned_to set" state is incoherent for queue
// semantics (next-task picker would otherwise hand it out again).
this.db.run(
`UPDATE tasks SET status = 'in_progress', assigned_to = ?, updated_at = datetime('now') WHERE id = ?`,
[assigneeName, taskId],
);
const assigned = this.getById(taskId)!;
this.events.emit('task:claimed', { task: assigned, claimer: assigneeName });
return assigned;
});
}

// ---- Learnings ----

learn(
Expand Down
8 changes: 7 additions & 1 deletion src/transport/mcp-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ export function handleStage(
): unknown {
const action = validateEnum(
optString(args, 'action'),
['claim', 'advance', 'regress', 'complete', 'fail', 'cancel'] as const,
['claim', 'assign', 'advance', 'regress', 'complete', 'fail', 'cancel'] as const,
'action',
);
const taskId = requireNumber(args, 'task_id');
Expand All @@ -311,6 +311,12 @@ export function handleStage(
return withStageInstructions(ctx, claimed);
}

if (action === 'assign') {
const assignee = optString(args, 'claimer') ?? sessionName(session);
const assigned = ctx.tasks.assign(taskId, assignee);
return withStageInstructions(ctx, assigned);
}

if (action === 'advance') {
const advanceComment = optString(args, 'comment');
const advanced = ctx.tasks.advance(taskId, optString(args, 'stage'), advanceComment);
Expand Down
4 changes: 2 additions & 2 deletions src/transport/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,13 @@ export const tools: ToolDefinition[] = [
{
name: 'task_stage',
description:
'Move a task through its lifecycle. Actions: "claim" → assign to you and advance from backlog to spec, "advance" → next stage (or jump to a specific one), "regress" → earlier stage (requires reason), "complete" → marks done with result, "fail" → marks failed with error, "cancel" → cancels with reason.',
'Move a task through its lifecycle. Actions: "claim" → assign to you and advance from backlog to spec, "assign" → assign to you WITHOUT advancing the stage (use for custom pipelines where the first stage is itself a do-work stage), "advance" → next stage (or jump to a specific one), "regress" → earlier stage (requires reason), "complete" → marks done with result, "fail" → marks failed with error, "cancel" → cancels with reason.',
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['claim', 'advance', 'regress', 'complete', 'fail', 'cancel'],
enum: ['claim', 'assign', 'advance', 'regress', 'complete', 'fail', 'cancel'],
description: 'Lifecycle action to perform',
},
task_id: { type: 'number', description: 'Task ID' },
Expand Down
40 changes: 40 additions & 0 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,46 @@ describe('task_stage claim', () => {
});
});

// =============================================================================
// task_stage: assign (claim minus stage advance)
// =============================================================================

describe('task_stage assign', () => {
it('assigns without advancing the stage', () => {
const task = handle('task_create', { title: 'Assign me' }) as {
id: number;
stage: string;
};
expect(task.stage).toBe('backlog');
const assigned = handle('task_stage', { action: 'assign', task_id: task.id }) as {
stage: string;
status: string;
assigned_to: string;
};
expect(assigned.stage).toBe('backlog');
expect(assigned.assigned_to).toBe('test-agent');
expect(assigned.status).toBe('in_progress');
});

it('uses custom assignee name via claimer arg', () => {
const task = handle('task_create', { title: 'Assign me' }) as { id: number };
const assigned = handle('task_stage', {
action: 'assign',
task_id: task.id,
claimer: 'head-influencer',
}) as { assigned_to: string };
expect(assigned.assigned_to).toBe('head-influencer');
});

it('rejects assign on already-claimed task', () => {
const task = handle('task_create', { title: 'Locked' }) as { id: number };
handle('task_stage', { action: 'claim', task_id: task.id });
expect(() =>
handle('task_stage', { action: 'assign', task_id: task.id, claimer: 'other' }),
).toThrow('not pending');
});
});

// =============================================================================
// task_stage: advance
// =============================================================================
Expand Down
48 changes: 48 additions & 0 deletions tests/tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,54 @@ describe('claiming', () => {
});
});

describe('assigning (no stage advance)', () => {
it('assigns a task without advancing the stage', () => {
const task = ctx.tasks.create({ title: 'Assign me' }, 'agent-1');
expect(task.stage).toBe('backlog');
const assigned = ctx.tasks.assign(task.id, 'agent-2');
expect(assigned.assigned_to).toBe('agent-2');
expect(assigned.stage).toBe('backlog');
expect(assigned.status).toBe('in_progress');
});

it('respects custom pipelines (stays at the first stage)', () => {
ctx.tasks.setPipelineConfig('proj', ['I-0-pull', 'I-1-plan', 'I-2-exec', 'I-3-closeout']);
const task = ctx.tasks.create({ title: 'Custom' }, 'agent-1');
ctx.tasks.update(task.id, { project: 'proj' });
// re-create at first stage of custom pipeline
const wave = ctx.tasks.create({ title: 'Wave', project: 'proj', stage: 'I-0-pull' }, 'agent-1');
const assigned = ctx.tasks.assign(wave.id, 'head-influencer');
expect(assigned.stage).toBe('I-0-pull');
expect(assigned.assigned_to).toBe('head-influencer');
expect(assigned.status).toBe('in_progress');
});

it('rejects assigning non-pending task', () => {
const task = ctx.tasks.create({ title: 'Already claimed' }, 'agent-1');
ctx.tasks.claim(task.id, 'agent-1');
expect(() => ctx.tasks.assign(task.id, 'agent-2')).toThrow('not pending');
});

it('emits task:claimed event (same channel as claim)', () => {
const task = ctx.tasks.create({ title: 'Listen' }, 'agent-1');
let captured: { task: { id: number }; claimer: string } | null = null;
ctx.events.on('task:claimed', (e) => {
captured = e.data as { task: { id: number }; claimer: string };
});
ctx.tasks.assign(task.id, 'agent-2');
expect(captured).not.toBeNull();
expect(captured!.claimer).toBe('agent-2');
expect(captured!.task.id).toBe(task.id);
});

it('respects min_confidence_for_claim gate (parity with claim)', () => {
ctx.tasks.setPipelineConfig('strict', ['I-0-pull', 'I-1-plan', 'done']);
ctx.tasks.setGateConfig('strict', { min_confidence_for_claim: 80 });
const vague = ctx.tasks.create({ title: 'x', project: 'strict', stage: 'I-0-pull' }, 'agent-1');
expect(() => ctx.tasks.assign(vague.id, 'agent-2')).toThrow('confidence');
});
});

describe('advancement', () => {
it('advances through stages sequentially', () => {
const task = ctx.tasks.create({ title: 'Flow' }, 'agent-1');
Expand Down