From fd52dc08619c41b825300bb1e99dccb784560303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 26 Jun 2026 17:34:09 -0400 Subject: [PATCH] Gate autonomous task runs on the execution approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../main/services/task-execution-service.ts | 5 ++ .../tests/unit/task-execution-service.test.ts | 64 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/AgentCompany-New/src/main/services/task-execution-service.ts b/AgentCompany-New/src/main/services/task-execution-service.ts index e6317d2b..929f303e 100644 --- a/AgentCompany-New/src/main/services/task-execution-service.ts +++ b/AgentCompany-New/src/main/services/task-execution-service.ts @@ -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); diff --git a/AgentCompany-New/tests/unit/task-execution-service.test.ts b/AgentCompany-New/tests/unit/task-execution-service.test.ts index dd9c9982..2a07444d 100644 --- a/AgentCompany-New/tests/unit/task-execution-service.test.ts +++ b/AgentCompany-New/tests/unit/task-execution-service.test.ts @@ -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);