Gate autonomous task runs on the execution approval

The manual runTask path enforces assertExecutionApproval, but the
autonomous heartbeat path (enqueueHeartbeatRuns -> enqueueRun -> the
queue worker) did not, so a high-risk or filesystem task whose approval
was still pending — the normal state in ai_cofounder mode, or after an
operator declines — was executed unsupervised.

Apply the same gate in enqueueRun. Pending/declined approvals leave the
task unqueued (the heartbeat's existing try/catch skips it) and it is
picked up on the next tick once approved. ai_ceo mode auto-approves at
task creation, so autonomous companies are unaffected.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-26 17:34:09 -04:00
1 parent c8de72c810
commit fd52dc0861
2 files changed
+69

No files matched your search

@@ -307,6 +307,11 @@ export class TaskExecutionService {
private enqueueRun(orgId: string, taskId: string): string {
const task = this.requireTask(orgId, taskId);
// Autonomous execution honours the same gate as a manual run: a high-risk or
// filesystem task whose approval is still pending (or was declined) is left
// unqueued and retried once the operator approves it, instead of running
// unsupervised. enqueueHeartbeatRuns swallows the throw and moves on.
this.assertExecutionApproval(orgId, task);
const agent = this.requireAssignedAgent(orgId, task);
const provider = this.requireAgentProvider(orgId, agent);
const workspace = this.workspace(task.workspace_id);
@@ -214,6 +214,70 @@ describe("task execution service", () => {
connection.close();
});
test("does not autonomously enqueue a high-risk task until its approval is granted", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const approvals = new ApprovalService(connection.db);
const providers = new ProviderService(connection.db, providerAdapters, testVault);
const execution = new TaskExecutionService(connection.db, providers);
let orgId = "";
let employeeId = "";
let taskId = "";
let approvalId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
executionMode: "ai_cofounder",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
const providerId = openAiProviderId(connection, orgId);
providers.saveCredential({ orgId, providerId, secret: "sk-live-provider", model: "gpt-5.4" });
const agentId = organization.createAgent({
orgId,
name: "Ada",
role: "Execution lead",
department: "Engineering",
modelProviderId: providerId,
modelId: "gpt-5.4",
budgetCents: 10000,
managerAgentId: null,
heartbeatEnabled: true,
});
taskId = organization.createTask({
orgId,
title: "Production deploy",
description: "Deploy a high-risk release.",
status: TASK_STATUS.IN_PROGRESS,
assigneeAgentId: agentId,
risk: RISK_LEVEL.HIGH,
projectId: null,
goalId: null,
workspaceId: null,
priority: TASK_PRIORITY.HIGH,
requiresFilesystem: false,
});
approvalId = (connection.db.prepare(`
select id from approvals where org_id = ? and source_entity_type = 'task' and source_entity_id = ?
`).get(orgId, taskId) as { id: string }).id;
});
// The autonomous heartbeat must not start the run while approval is pending.
expect(execution.enqueueHeartbeatRuns()).toEqual([]);
expect(connection.db.prepare("select count(*) as count from task_runs").get()).toEqual({ count: 0 });
// Once the operator approves, the same heartbeat enqueues it exactly once.
connection.transaction(() => approvals.decide({ orgId, approvalId, state: APPROVAL_STATE.APPROVED, decidedByEmployeeId: employeeId }));
const enqueued = execution.enqueueHeartbeatRuns();
expect(enqueued).toHaveLength(1);
expect(connection.db.prepare("select task_id, state, trigger from task_runs where id = ?").get(enqueued[0]))
.toMatchObject({ task_id: taskId, state: "queued", trigger: "heartbeat" });
connection.close();
});
test("marks task runs failed when provider execution fails", async () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);