From d9cb86a9998f0c6c4d26a1f47a4109c095dbef7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 09:04:34 -0400 Subject: [PATCH] Q51: agents may only move work they own, manage or review; done needs evidence and sign-off Co-Authored-By: Claude Opus 5.5 --- src/main/services/agent-tools.ts | 14 ++-- src/main/services/organization-service.ts | 4 + src/main/services/task-mutations.ts | 91 +++++++++++++++++---- src/main/services/task-prompts.ts | 2 +- tests/integration/autonomous-run.test.ts | 2 +- tests/integration/task-dependencies.test.ts | 2 +- tests/unit/task-execution-service.test.ts | 2 +- tests/unit/task-review.test.ts | 6 +- tests/unit/task-status-authority.test.ts | 80 ++++++++++++++++++ 9 files changed, 175 insertions(+), 28 deletions(-) create mode 100644 tests/unit/task-status-authority.test.ts diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index c3d2585b..c0c9efff 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -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.`; } diff --git a/src/main/services/organization-service.ts b/src/main/services/organization-service.ts index dd503fe3..e67ec927 100644 --- a/src/main/services/organization-service.ts +++ b/src/main/services/organization-service.ts @@ -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); + } } diff --git a/src/main/services/task-mutations.ts b/src/main/services/task-mutations.ts index 12d58774..a211a1eb 100644 --- a/src/main/services/task-mutations.ts +++ b/src/main/services/task-mutations.ts @@ -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, diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index 04a33c37..a1b90f21 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -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); diff --git a/tests/integration/autonomous-run.test.ts b/tests/integration/autonomous-run.test.ts index 31fba397..411c6ba1 100644 --- a/tests/integration/autonomous-run.test.ts +++ b/tests/integration/autonomous-run.test.ts @@ -192,7 +192,7 @@ describe("autonomous bootstrap run", () => { if (taskId && !alreadyClosed) { return { text: "Reviewed the goal; closing the kickoff.", - toolCalls: [call("update_task_status", { task_id: taskId, status: "done" })], + toolCalls: [call("update_task_status", { task_id: taskId, status: "done", evidence: "Operating plan posted in #general." })], rawResponseJson: JSON.stringify({ id: "resp_goal_close" }), }; } diff --git a/tests/integration/task-dependencies.test.ts b/tests/integration/task-dependencies.test.ts index 24380198..54a958b5 100644 --- a/tests/integration/task-dependencies.test.ts +++ b/tests/integration/task-dependencies.test.ts @@ -65,7 +65,7 @@ describe("task dependencies", () => { .toThrow(`"${parentTitle}" has open subtasks: Hire the CTO. It can close once they are done.`); const ctx = { orgId: h.orgId, agent: { id: h.ceoId, modelProviderId: null, modelId: null }, taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null }; const deps = { db: h.connection.db, organization: h.organization, chat: new ChatService(h.connection.db) }; - expect(dispatchAgentTool(deps, ctx, { id: "c1", name: "update_task_status", arguments: { task_id: parentId, status: "done" } })) + expect(dispatchAgentTool(deps, ctx, { id: "c1", name: "update_task_status", arguments: { task_id: parentId, status: "done", evidence: "Delegated." } })) .toBe(`ERROR: "${parentTitle}" has open subtasks: Hire the CTO. It can close once they are done.`); // A run queued before the subtask existed is superseded instead of running ahead of it. diff --git a/tests/unit/task-execution-service.test.ts b/tests/unit/task-execution-service.test.ts index c0f35cfc..7b216cab 100644 --- a/tests/unit/task-execution-service.test.ts +++ b/tests/unit/task-execution-service.test.ts @@ -129,7 +129,7 @@ describe("task execution service", () => { expect(skillRuntimeSnapshot?.source_revision_ids).toContain(revisionId); expect(capturedPrompt).toContain("Task: Draft launch checklist"); expect(capturedPrompt).toContain(`Task id: ${taskId}`); - expect(capturedPrompt).toContain(`update_task_status(task_id="${taskId}", status="done")`); + expect(capturedPrompt).toContain(`update_task_status(task_id="${taskId}", status="done", evidence="what you produced and where")`); expect(capturedPrompt).toContain("Assignee: Ada (Execution lead)"); expect(capturedPrompt).toContain("Workspace: Task Workspace"); expect(capturedPrompt).toContain("Execution context:"); diff --git a/tests/unit/task-review.test.ts b/tests/unit/task-review.test.ts index d044e713..e9e7931c 100644 --- a/tests/unit/task-review.test.ts +++ b/tests/unit/task-review.test.ts @@ -34,11 +34,11 @@ describe("task review", () => { test("moving work to review hands a review task to the assignee's manager; the verdict closes it", () => { const h = setup(); const taskId = h.task(h.devId, "Login page"); - expect(dispatchAgentTool(h.deps, h.ctx(h.devId), { id: "c1", name: "update_task_status", arguments: { task_id: taskId, status: "review" } })) + expect(dispatchAgentTool(h.deps, h.ctx(h.devId), { id: "c1", name: "update_task_status", arguments: { task_id: taskId, status: "review", evidence: "Login page in file_id=fil_1" } })) .toBe(`Task ${taskId} moved to review. Cleo will review it.`); const [review] = h.reviews(taskId); expect(review).toMatchObject({ title: "Review: Login page", assignee_agent_id: h.ceoId, status: "backlog", origin: "task_review", priority: "high" }); - expect(review.description).toContain(`update_task_status(task_id="${taskId}", status="done")`); + expect(review.description).toContain(`update_task_status(task_id="${taskId}", status="done", evidence="what you checked")`); expect(review.description).toContain(`update_task_status(task_id="${taskId}", status="in_progress")`); // Moving to review again while one is open does not duplicate it. @@ -58,7 +58,7 @@ describe("task review", () => { expect(h.reviews(taskId).map((row) => row.status)).toEqual(["done"]); const ceoTask = h.task(h.ceoId, "Company plan"); - expect(dispatchAgentTool(h.deps, h.ctx(h.ceoId), { id: "c2", name: "update_task_status", arguments: { task_id: ceoTask, status: "review" } })) + expect(dispatchAgentTool(h.deps, h.ctx(h.ceoId), { id: "c2", name: "update_task_status", arguments: { task_id: ceoTask, status: "review", evidence: "Plan in the plan document" } })) .toBe(`Task ${ceoTask} moved to review. It has no manager to review it, so it waits for the operator on the Work board.`); expect(h.reviews(ceoTask)).toEqual([]); h.connection.close(); diff --git a/tests/unit/task-status-authority.test.ts b/tests/unit/task-status-authority.test.ts new file mode 100644 index 00000000..6813d95b --- /dev/null +++ b/tests/unit/task-status-authority.test.ts @@ -0,0 +1,80 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; + +function setup() { + const connection = new DatabaseConnection(join(tmpdir(), `ac-authority-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + const ids: Record = {}; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + ids.ceo = organization.createAgent({ orgId, name: "Cleo", role: "CEO", department: "Executive", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null, roleLevel: "c_suite" }); + ids.cto = organization.createAgent({ orgId, name: "Tara", role: "CTO", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: ids.ceo, roleLevel: "director" }); + ids.dev = organization.createAgent({ orgId, name: "Dev", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: ids.cto }); + ids.peer = organization.createAgent({ orgId, name: "Pia", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: ids.cto }); + ids.task = organization.createTask({ + orgId, title: "Login page", description: "Build it.", status: TASK_STATUS.IN_PROGRESS, assigneeAgentId: ids.dev, + risk: RISK_LEVEL.LOW, projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false, + }); + }); + const deps = { db: connection.db, organization, chat: new ChatService(connection.db) }; + const as = (agent: string, args: Record) => { + const ctx: AgentToolContext = { orgId, agent: { id: ids[agent], modelProviderId: null, modelId: null }, taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null }; + return dispatchAgentTool(deps, ctx, { id: `c_${randomUUID()}`, name: "update_task_status", arguments: { task_id: ids.task, ...args } }); + }; + const status = () => (connection.db.prepare("select status from tasks where id = ?").get(ids.task) as { status: string }).status; + return { connection, organization, orgId, ids, as, status }; +} + +describe("task status authority", () => { + test("only the assignee, their managers or the reviewer move a task, and done needs evidence and sign-off", () => { + const h = setup(); + expect(h.as("peer", { status: "done", evidence: "x" })).toBe('ERROR: Only Dev, their managers or the open reviewer can change "Login page".'); + expect(h.as("dev", { status: "done" })).toBe('ERROR: "evidence" is required to move work to review or done: say what was produced and where (file_ids, document ids, test results).'); + expect(h.status()).toBe("in_progress"); + + expect(h.as("dev", { status: "done", evidence: "file_id=fil_1, tests pass" })) + .toBe(`Task ${h.ids.task} moved to review: done needs sign-off from your manager. Tara will review it.`); + expect(h.status()).toBe("review"); + const review = h.connection.db.prepare("select description from tasks where review_of_task_id = ?").get(h.ids.task) as { description: string }; + expect(review.description).toContain("Evidence given: file_id=fil_1, tests pass"); + + expect(h.as("ceo", { status: "done", evidence: "Checked the page and the passing tests." })).toBe(`Task ${h.ids.task} moved to done.`); + const audit = h.connection.db.prepare("select actor_type, actor_id, json_extract(payload_json, '$.evidence') as evidence from audit_events where entity_id = ? and action = 'status_changed' order by rowid desc limit 1").get(h.ids.task); + expect(audit).toEqual({ actor_type: "agent", actor_id: h.ids.ceo, evidence: "Checked the page and the passing tests." }); + h.connection.close(); + }); + + test("a reviewer reassigned the work still needs their own manager; repeats and review vehicles never loop", () => { + const h = setup(); + expect(h.as("dev", { status: "backlog", evidence: 123 })).toBe('ERROR: "evidence" is required.'); + h.as("dev", { status: "done", evidence: "file_id=fil_1" }); + const reviewId = (h.connection.db.prepare("select id from tasks where review_of_task_id = ?").get(h.ids.task) as { id: string }).id; + h.organization.updateTask({ orgId: h.orgId, taskId: h.ids.task, title: "Login page", description: "Build it.", assigneeAgentId: h.ids.cto, risk: RISK_LEVEL.LOW, priority: TASK_PRIORITY.NORMAL, parentTaskId: null }); + expect(h.status()).toBe("in_progress"); + expect(h.connection.db.prepare("select status from tasks where id = ?").get(reviewId)).toEqual({ status: "done" }); + expect(h.as("cto", { status: "done", evidence: "Rebuilt the form." })).toBe(`Task ${h.ids.task} moved to review: done needs sign-off from your manager. Cleo will review it.`); + expect(h.status()).toBe("review"); + const fresh = h.connection.db.prepare("select assignee_agent_id, description from tasks where review_of_task_id = ? and status <> 'done'").get(h.ids.task) as { assignee_agent_id: string; description: string }; + expect(fresh.assignee_agent_id).toBe(h.ids.ceo); + expect(fresh.description).toContain("Evidence given: Rebuilt the form."); + + expect(h.as("ceo", { status: "done", evidence: "Verified." })).toBe(`Task ${h.ids.task} moved to done.`); + expect(h.as("cto", { status: "done", evidence: "again" })).toBe(`Task ${h.ids.task} moved to done.`); + expect(h.status()).toBe("done"); + + // The reviewer closing the review task itself closes it directly, without a review of the review. + h.connection.db.prepare("update tasks set status = 'backlog' where id = ?").run(reviewId); + h.ids.task = reviewId; + expect(h.as("cto", { status: "done", evidence: "Reviewed." })).toBe(`Task ${reviewId} moved to done.`); + expect(h.connection.db.prepare("select count(*) as n from tasks where review_of_task_id = ?").get(reviewId)).toEqual({ n: 0 }); + h.connection.close(); + }); +});