Q51: agents may only move work they own, manage or review; done needs evidence and sign-off

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 09:04:34 -04:00
1 parent ddc8b485e9
commit d9cb86a999
9 files changed
+175 -28

No files matched your search

+8 -6
View File
@@ -15,7 +15,7 @@ import { KNOWLEDGE_TOOLS, recordKnowledgeTool, reviseKnowledgeTool, searchKnowle
import { DOCUMENT_TOOLS, DOCUMENT_TYPES, listDocumentsTool, readDocumentTool, updateDocumentTool } from "./agent-document-tools";
import { sendMessageTool } from "./agent-send-message";
import { withSavepoint } from "../database/connection";
import { optionalEnum, optionalString, optionalStringArray, requireString } from "./agent-tool-args";
import { optionalEnum, optionalString, optionalStringArray, optionalText, requireString } from "./agent-tool-args";
import { CEO_FIRST_HIRES } from "./org-building";
import type { JsonRecord } from "./provider-http";
import type { ToolCall, ToolDefinition } from "./provider-tool-turn";
@@ -98,13 +98,14 @@ export const AGENT_TOOLS: ToolDefinition[] = [
},
{
name: "update_task_status",
description: "Move a task to a new status.",
description: "Move a task you own, manage or review to a new status. Moving your own work to done sends it to your manager's review first.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
task_id: { type: "string" },
status: { type: "string", enum: ["backlog", "in_progress", "review", "done"] },
evidence: { type: "string", description: "Required for review and done: what was produced and where (file_ids, document ids, test results) or what you checked." },
},
required: ["task_id", "status"],
},
@@ -382,15 +383,16 @@ function delegationParent(db: DatabaseSync, ctx: AgentToolContext): string | nul
function updateTaskStatus(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
const taskId = requireString(args.task_id, "task_id");
const status = normalizeStatus(args.status);
deps.organization.updateTaskStatus(ctx.orgId, taskId, status);
if (status !== TASK_STATUS.REVIEW) return `Task ${taskId} moved to ${status}.`;
const applied = deps.organization.agentSetTaskStatus({ orgId: ctx.orgId, taskId, status, agentId: ctx.agent.id, evidence: optionalText(args.evidence, "evidence") });
if (applied !== TASK_STATUS.REVIEW) return `Task ${taskId} moved to ${applied}.`;
const routed = status === TASK_STATUS.DONE ? "review: done needs sign-off from your manager" : "review";
const reviewer = deps.db.prepare(`
select a.name from tasks r join agents a on a.id = r.assignee_agent_id
where r.review_of_task_id = ? and r.status <> 'done' limit 1
`).get(taskId) as { name: string } | undefined;
return reviewer
? `Task ${taskId} moved to review. ${reviewer.name} will review it.`
: `Task ${taskId} moved to review. It has no manager to review it, so it waits for the operator on the Work board.`;
? `Task ${taskId} moved to ${routed}. ${reviewer.name} will review it.`
: `Task ${taskId} moved to ${routed}. It has no manager to review it, so it waits for the operator on the Work board.`;
}
@@ -481,4 +481,8 @@ export class OrganizationService {
updateTaskStatus(orgId: string, taskId: string, status: string): void {
this.tasks.updateStatus(orgId, taskId, status);
}
agentSetTaskStatus(input: { orgId: string; taskId: string; status: string; agentId: string; evidence: string | null }): string {
return this.tasks.agentSetStatus(input);
}
}
+76 -15
View File
@@ -4,6 +4,8 @@ import type { CreateTaskInput, DeleteTaskInput, UpdateTaskInput } from "../../sh
import { AuditService } from "./audit-service";
import { withSavepoint } from "../database/connection";
type StatusActor = { type: "system" } | { type: "agent"; id: string };
interface TaskRow {
id: string;
title: string;
@@ -14,6 +16,7 @@ interface TaskRow {
goal_id: string | null;
workspace_id: string | null;
priority: TaskPriority;
origin: string | null;
}
/** Edits, deletion and status moves of existing tasks, with their hierarchy and review rules. */
@@ -25,11 +28,20 @@ export class TaskMutations {
}
update(input: UpdateTaskInput): void {
withSavepoint(this.db, "task_update", () => this.applyUpdate(input));
}
private applyUpdate(input: UpdateTaskInput): void {
const now = new Date().toISOString();
if (input.assigneeAgentId && !this.db.prepare("select 1 from agents where id = ? and org_id = ?").get(input.assigneeAgentId, input.orgId)) {
throw new Error("Assignee agent was not found.");
}
if (input.parentTaskId) this.assertParentAllowed(input.orgId, input.taskId, input.parentTaskId, input.title.trim());
const before = this.requireTask(input.orgId, input.taskId);
// Work in review that changes hands goes back to in progress; the new assignee submits it for their own manager's review.
if (before.status === TASK_STATUS.REVIEW && before.assignee_agent_id !== input.assigneeAgentId) {
this.applyStatus(input.orgId, before, TASK_STATUS.IN_PROGRESS, { type: "system" }, null);
}
const result = this.db.prepare(`
update tasks set title = ?, description = ?, assignee_agent_id = ?, risk = ?, priority = ?, parent_task_id = ?, updated_at = ?,
stalled_reason = null, run_budget_floor = (select coalesce(max(rowid), 0) from task_runs)
@@ -66,15 +78,65 @@ export class TaskMutations {
}
updateStatus(orgId: string, taskId: string, status: string): void {
withSavepoint(this.db, "task_status", () => this.applyStatus(orgId, taskId, status));
withSavepoint(this.db, "task_status", () => this.applyStatus(orgId, this.requireTask(orgId, taskId), status, { type: "system" }, null));
}
private applyStatus(orgId: string, taskId: string, status: string): void {
/** Assignee, managers up the chain or the open reviewer may move a task; finishing needs evidence and a manager's sign-off. */
agentSetStatus(input: { orgId: string; taskId: string; status: string; agentId: string; evidence: string | null }): string {
return withSavepoint(this.db, "task_status", () => {
const task = this.requireTask(input.orgId, input.taskId);
const isAssignee = task.assignee_agent_id === input.agentId;
const isManager = this.managesAssignee(input.orgId, input.agentId, task.assignee_agent_id);
const isReviewer = Boolean(this.db.prepare("select 1 from tasks where review_of_task_id = ? and assignee_agent_id = ? and status <> 'done'").get(task.id, input.agentId));
if (!isAssignee && !isManager && !isReviewer) {
const assignee = task.assignee_agent_id ? (this.db.prepare("select name from agents where id = ?").get(task.assignee_agent_id) as { name: string }).name : "the CEO";
throw new Error(`Only ${assignee}, their managers or the open reviewer can change "${task.title}".`);
}
const finishing = input.status === TASK_STATUS.DONE || input.status === TASK_STATUS.REVIEW;
if (finishing && !input.evidence) {
throw new Error('"evidence" is required to move work to review or done: say what was produced and where (file_ids, document ids, test results).');
}
// Plain work its own assignee calls done goes to the manager first; vehicles (reviews, chat replies) close directly.
const needsSignOff = input.status === TASK_STATUS.DONE && task.status !== TASK_STATUS.DONE && task.origin === null
&& isAssignee && this.activeManagerOf(input.orgId, task.assignee_agent_id) !== null;
const applied = needsSignOff ? TASK_STATUS.REVIEW : input.status;
this.applyStatus(input.orgId, task, applied, { type: "agent", id: input.agentId }, input.evidence);
return applied;
});
}
private requireTask(orgId: string, taskId: string): TaskRow {
const task = this.db.prepare(`
select id, title, description, status, assignee_agent_id, project_id, goal_id, workspace_id, priority
select id, title, description, status, assignee_agent_id, project_id, goal_id, workspace_id, priority, origin
from tasks where id = ? and org_id = ?
`).get(taskId, orgId) as TaskRow | undefined;
if (!task) throw new Error("Task was not found.");
return task;
}
/** True when the agent sits above the assignee in the management chain; unassigned work belongs to the CEO. */
private managesAssignee(orgId: string, agentId: string, assigneeId: string | null): boolean {
if (!assigneeId) return Boolean(this.db.prepare("select 1 from agents where id = ? and org_id = ? and role_level = 'c_suite'").get(agentId, orgId));
return Boolean(this.db.prepare(`
with recursive chain(id) as (
select manager_agent_id from agents where id = ? and org_id = ?
union select a.manager_agent_id from agents a join chain c on a.id = c.id where a.manager_agent_id is not null
)
select 1 from chain where id = ? limit 1
`).get(assigneeId, orgId, agentId));
}
private activeManagerOf(orgId: string, agentId: string | null): string | null {
if (!agentId) return null;
const row = this.db.prepare(`
select m.id from agents a join agents m on m.id = a.manager_agent_id
where a.id = ? and a.org_id = ? and m.lifecycle = 'active'
`).get(agentId, orgId) as { id: string } | undefined;
return row?.id ?? null;
}
private applyStatus(orgId: string, task: TaskRow, status: string, actor: StatusActor, evidence: string | null): void {
const taskId = task.id;
if (status === TASK_STATUS.DONE) this.assertNoOpenSubtasks(orgId, task);
const reopening = task.status === TASK_STATUS.DONE && status !== TASK_STATUS.DONE;
// Reopening a done task gives it a fresh run budget.
@@ -88,24 +150,22 @@ export class TaskMutations {
orgId,
entityType: "task",
entityId: taskId,
actorType: "system",
actorId: null,
actorType: actor.type,
actorId: actor.type === "agent" ? actor.id : null,
action: "status_changed",
payload: { status },
payload: evidence ? { status, evidence } : { status },
});
if (status === TASK_STATUS.REVIEW && task.status !== TASK_STATUS.REVIEW) this.openReview(orgId, task);
if (status === TASK_STATUS.REVIEW && task.status !== TASK_STATUS.REVIEW) this.openReview(orgId, task, evidence);
if (task.status === TASK_STATUS.REVIEW && status !== TASK_STATUS.REVIEW) this.closeReviews(orgId, taskId, status);
}
/** Hands work in review to the assignee's manager as a review task; without one it waits for the operator. */
private openReview(orgId: string, task: TaskRow): void {
private openReview(orgId: string, task: TaskRow, evidence: string | null): void {
const assignee = task.assignee_agent_id
? this.db.prepare("select name, manager_agent_id from agents where id = ?").get(task.assignee_agent_id) as { name: string; manager_agent_id: string | null }
? this.db.prepare("select name from agents where id = ?").get(task.assignee_agent_id) as { name: string }
: null;
const reviewer = assignee?.manager_agent_id
? this.db.prepare("select id from agents where id = ? and org_id = ? and lifecycle = 'active'").get(assignee.manager_agent_id, orgId) as { id: string } | undefined
: undefined;
if (!reviewer) {
const reviewerId = this.activeManagerOf(orgId, task.assignee_agent_id);
if (!reviewerId) {
this.audit.record({ orgId, entityType: "task", entityId: task.id, actorType: "system", actorId: null, action: "review_requested", payload: { reviewer: "operator" } });
return;
}
@@ -116,10 +176,11 @@ export class TaskMutations {
description: [
`${author} moved "${task.title}" (task_id=${task.id}) to review.`,
`Task brief:\n${task.description}`,
`Check the actual output against the brief: read_messages for their report, list_files/read_file and list_documents for what they produced. If it meets the brief, approve it with update_task_status(task_id="${task.id}", status="done"). If not, send it back with update_task_status(task_id="${task.id}", status="in_progress") and tell ${author} in a direct message exactly what to fix. Either verdict closes this review.`,
`Evidence given: ${evidence ?? "none; ask for it"}`,
`Check the actual output against the brief: read_messages for their report, list_files/read_file and list_documents for what they produced. If it meets the brief, approve it with update_task_status(task_id="${task.id}", status="done", evidence="what you checked"). If not, send it back with update_task_status(task_id="${task.id}", status="in_progress") and tell ${author} in a direct message exactly what to fix. Either verdict closes this review.`,
].join("\n\n"),
status: TASK_STATUS.BACKLOG,
assigneeAgentId: reviewer.id,
assigneeAgentId: reviewerId,
risk: RISK_LEVEL.LOW,
projectId: task.project_id,
goalId: task.goal_id,
+1 -1
View File
@@ -297,7 +297,7 @@ export function buildTaskPrompt(input: {
const managerReference = input.managerName ? `${manager} (${agentMention(input.managerName)})` : manager;
lines.push(
"",
`First, read_messages to check if anyone has said something you should respond to — reply to them like a colleague, not a report. Then do the work: write real output with write_deliverable, raise decisions and blockers with ${managerReference} in a direct message (send_message with to=), and keep team-wide topics in channels. Hand files over with /handoff and attachment_file_ids. Keep conversation going — ask questions, share your thinking, push back if something seems wrong. Before you finish: if the work is genuinely done, call update_task_status(task_id="${input.task.id}", status="done"); if it needs another session, move it to "in_progress" and say in the thread what remains — an open task will be run again.`,
`First, read_messages to check if anyone has said something you should respond to — reply to them like a colleague, not a report. Then do the work: write real output with write_deliverable, raise decisions and blockers with ${managerReference} in a direct message (send_message with to=), and keep team-wide topics in channels. Hand files over with /handoff and attachment_file_ids. Keep conversation going — ask questions, share your thinking, push back if something seems wrong. Before you finish: if the work is genuinely done, call update_task_status(task_id="${input.task.id}", status="done", evidence="what you produced and where"); if it needs another session, move it to "in_progress" and say in the thread what remains — an open task will be run again.`,
);
}
lines.push("", RECORD_LEARNINGS_INSTRUCTION);