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:
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);
|
||||
|
||||
Reference in new issue
Block a user