Q150: work can be sent back, reopened, moved to backlog, or blocked with a reason

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 01:12:17 -04:00
1 parent 7ab46bb2de
commit 809c67a54a
21 files changed
+153 -7

No files matched your search

+1
View File
@@ -38,6 +38,7 @@ export class HeartbeatScheduler {
left join provider_usage_windows u
on u.org_id = p.org_id and u.provider_id = p.id and u.window_start = ?
where t.status in ('backlog', 'in_progress')
and t.blocked_reason is null
and a.heartbeat_enabled = 1
and a.lifecycle = 'active'
and p.enabled = 1
+5 -1
View File
@@ -1,7 +1,7 @@
import type { DatabaseSync } from "node:sqlite";
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain";
import { createId } from "../../shared/ids";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
import { PolicyService } from "./policy-service";
@@ -472,6 +472,10 @@ export class OrganizationService {
this.tasks.update(input);
}
setTaskBlocked(input: SetTaskBlockedInput): void {
this.tasks.setBlocked(input);
}
deleteTask(input: DeleteTaskInput): void {
this.tasks.delete(input);
}
@@ -90,6 +90,7 @@ export class TaskExecutionService {
async runTask(input: RunTaskInput): Promise<string> {
const task = requireTask(this.db, input.orgId, input.taskId);
if (task.status === TASK_STATUS.DONE) throw new Error(`"${task.title}" is done. Reopen it before running it again.`);
if (task.blocked_reason !== null) throw new Error(`"${task.title}" is blocked: ${task.blocked_reason} Unblock it to run it.`);
const inFlight = this.db.prepare("select state from task_runs where task_id = ? and state in ('queued', 'running') limit 1")
.get(task.id) as { state: string } | undefined;
if (inFlight) {
@@ -115,6 +116,7 @@ export class TaskExecutionService {
try {
const task = requireTask(this.db, orgId, run.task_id);
if (task.status === TASK_STATUS.DONE) return this.supersedeRun(orgId, runId, run, "the task was closed after this run was queued.");
if (task.blocked_reason !== null) return this.supersedeRun(orgId, runId, run, `the task was blocked after this run was queued (${task.blocked_reason}).`);
if (task.assignee_agent_id !== run.agent_id) {
return this.supersedeRun(orgId, runId, run, `the task was reassigned after this run was queued (run agent ${run.agent_id}, assignee ${task.assignee_agent_id ?? "none"}).`);
}
+7 -2
View File
@@ -5,7 +5,9 @@ import { budgetBlock, isPriced, providerPrices } from "./spend";
interface OpenTaskRow {
id: string;
status: string;
stalled_reason: string | null;
blocked_reason: string | null;
enqueue_error: string | null;
agent_id: string | null;
provider_id: string | null;
@@ -20,7 +22,7 @@ interface OpenTaskRow {
export function taskHolds(db: DatabaseSync, now: Date): Map<string, TaskHold> {
const window = currentProviderUsageWindow(now);
const rows = db.prepare(`
select t.id, t.stalled_reason, t.enqueue_error, a.id as agent_id, p.id as provider_id, p.label as provider_label,
select t.id, t.status, t.stalled_reason, t.blocked_reason, t.enqueue_error, a.id as agent_id, p.id as provider_id, p.label as provider_label,
p.monthly_request_limit as request_limit, coalesce(u.request_count, 0) as request_count,
c.until_ms as cooldown_until_ms, c.label as cooldown_label
from tasks t
@@ -28,7 +30,7 @@ export function taskHolds(db: DatabaseSync, now: Date): Map<string, TaskHold> {
left join providers p on p.id = a.model_provider_id
left join provider_usage_windows u on u.org_id = p.org_id and u.provider_id = p.id and u.window_start = ?
left join provider_cooldowns c on c.provider_id = p.id
where t.status in ('backlog', 'in_progress')
where (t.status in ('backlog', 'in_progress') or (t.status = 'review' and t.blocked_reason is not null))
and not exists (select 1 from task_runs r where r.task_id = t.id and r.state = 'running')
`).all(window.start) as unknown as OpenTaskRow[];
const budgets = new Map<string, MoneyBlock | null>();
@@ -60,6 +62,9 @@ interface MoneyBlock {
}
function holdOf(row: OpenTaskRow, now: Date, windowEnd: string, budget: (agentId: string, providerId: string) => MoneyBlock | null): TaskHold | null {
if (row.blocked_reason) return { reason: `Blocked: ${row.blocked_reason}`, resumesAt: null, waitingOn: "operator" };
// Work in review waits on its reviewer, so only a block holds it here.
if (row.status === "review") return null;
if (row.stalled_reason) return { reason: row.stalled_reason, resumesAt: null, waitingOn: "operator" };
if (row.enqueue_error) return { reason: `Could not start: ${row.enqueue_error}`, resumesAt: null, waitingOn: "operator" };
if (!row.agent_id || !row.provider_id) return null;
+21 -1
View File
@@ -1,6 +1,6 @@
import type { DatabaseSync } from "node:sqlite";
import { RISK_LEVEL, TASK_STATUS, type TaskPriority } from "../../shared/domain";
import type { CreateTaskInput, DeleteTaskInput, UpdateTaskInput } from "../../shared/commands";
import type { CreateTaskInput, DeleteTaskInput, SetTaskBlockedInput, UpdateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { withSavepoint } from "../database/connection";
import { heldWorkTitles } from "./held-deliverables";
@@ -70,6 +70,26 @@ export class TaskMutations {
});
}
/** Blocks the task with a reason (no session starts, queued ones are superseded) or unblocks it with null. */
setBlocked(input: SetTaskBlockedInput): void {
const reason = input.reason === null ? null : input.reason.trim();
if (reason === "") throw new Error("Say why the task is blocked.");
withSavepoint(this.db, "task_block", () => {
const result = this.db.prepare("update tasks set blocked_reason = ?, updated_at = ? where id = ? and org_id = ?").run(reason, new Date().toISOString(), input.taskId, input.orgId);
if (result.changes === 0) throw new Error("Task was not found.");
if (reason !== null) {
this.db.prepare(`
update task_runs set state = 'superseded', completed_at = ?, error_message = ?
where task_id = ? and org_id = ? and state = 'queued'
`).run(new Date().toISOString(), `Superseded: the task was blocked (${reason}).`, input.taskId, input.orgId);
}
this.audit.record({
orgId: input.orgId, entityType: "task", entityId: input.taskId, actorType: "employee", actorId: null,
action: reason === null ? "unblocked" : "blocked", payload: { reason },
});
});
}
delete(input: DeleteTaskInput): void {
const running = this.db.prepare("select 1 from task_runs where task_id = ? and org_id = ? and state = 'running' limit 1")
.get(input.taskId, input.orgId);
+1
View File
@@ -21,6 +21,7 @@ export interface TaskExecutionTaskRow {
writing_mode: WritingMode;
handoff_json: string | null;
origin: string | null;
blocked_reason: string | null;
}
export interface TaskExecutionAgentRow {