From 1e36259c5ddd558896a66484deb62059c1e08306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 29 Apr 2026 20:17:33 -0400 Subject: [PATCH] fix(orchestration): enforce approval gates on heartbeat and recovery paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heartbeat path (canRunAgent → startAgentHeartbeatRun) only checked isAgentPendingApproval (hire gate), completely bypassing plan_review, draft_review, strategy_review and other blocking approval types. Agents in Co-Founder mode continued running despite pending approvals. Root cause: task-runner.prepareTaskRun had the full getBlockingApproval check, but heartbeat runs never went through prepareTaskRun — they created runs directly, skipping the approval gate entirely. Three fixes: - heartbeat.canRunAgent: add getBlockingApproval(companyId, agentId, null) for agent-level blocking approvals - heartbeat.startAgentHeartbeatRun: add getBlockingApproval with task id after task resolution, before run creation - task-runner.resumeQueuedRuns: add getBlockingApproval check so app restart does not dispatch runs past pending approvals Also replaced the hardcoded type list in hasPendingReviewApprovalForAgent with a dynamic join on approval_policies.blocks_execution, eliminating the sync risk when new blocking approval types are added. --- src/main/database/approvals.ts | 3 ++- src/main/orchestration/heartbeat.ts | 7 +++++++ src/main/orchestration/task-runner.ts | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/main/database/approvals.ts b/src/main/database/approvals.ts index 947e9341..25da7786 100644 --- a/src/main/database/approvals.ts +++ b/src/main/database/approvals.ts @@ -415,9 +415,10 @@ export function buildApprovalMethods( .prepare( `SELECT 1 as hit FROM approvals a JOIN tasks t ON a.related_task_id = t.id + JOIN approval_policies p ON p.approval_type = a.type WHERE a.company_id = ? AND a.state = 'pending' AND t.assignee_agent_id = ? - AND a.type IN ('plan_review','scope_review','draft_review','strategy_review','deliverable_review','exception_review') + AND p.blocks_execution = 1 LIMIT 1`, ) .get(companyId, agentId) as { hit: number } | undefined; diff --git a/src/main/orchestration/heartbeat.ts b/src/main/orchestration/heartbeat.ts index e68c6660..fb2475e9 100644 --- a/src/main/orchestration/heartbeat.ts +++ b/src/main/orchestration/heartbeat.ts @@ -196,6 +196,8 @@ export function createHeartbeat( return BLOCKED("policy_blocked", "wake_on_demand_disabled"); } if (db.core.agents.isAgentPendingApproval(agentId)) return BLOCKED("human_blocked", "pending_approval"); + const agentLevelBlock = db.core.approvals.getBlockingApproval(companyId, agentId, null); + if (agentLevelBlock) return BLOCKED("human_blocked", `approval_gate:${agentLevelBlock.type}`); const companyMode = db.core.company.getCompanyExecutionMode(companyId); const approvalSettings = db.core.company.getCompanyApprovalSettings(companyId); @@ -268,6 +270,11 @@ export function createHeartbeat( logger.info(`[heartbeat] Starting taskless heartbeat run for ${agent.name}`); } + if (task) { + const taskBlock = db.core.approvals.getBlockingApproval(companyId, agent.id, task.id); + if (taskBlock) return SKIPPED(BLOCKED("human_blocked", `approval_gate:${taskBlock.type}`)); + } + let workspaceResolution: ReturnType; try { workspaceResolution = resolveWorkspace(task, agent, companyId); diff --git a/src/main/orchestration/task-runner.ts b/src/main/orchestration/task-runner.ts index e9861966..16f9d6fd 100644 --- a/src/main/orchestration/task-runner.ts +++ b/src/main/orchestration/task-runner.ts @@ -270,6 +270,24 @@ export function createTaskRunner( const task = run.taskId ? db.core.entity.getTask(run.taskId) : null; const agent = db.core.entity.getAgent(run.agentId); const connector = db.core.connectors.getConnector(run.connectorId); + const blockingApproval = db.core.approvals.getBlockingApproval(run.companyId, agent.id, run.taskId); + if (blockingApproval) { + finishQueuedRecoveryRun( + run, + "cancelled", + `Queued run blocked by pending approval: ${blockingApproval.type}`, + null, + ); + db.core.activity.addActivity({ + companyId: run.companyId, + actor: "system", + action: "run.recovery_blocked_approval", + entityType: "run", + entityId: run.id, + detail: `Recovered queued run blocked by ${blockingApproval.type} for task "${task?.title ?? "(heartbeat)"}"`, + }); + continue; + } const eligibilityFailure = getRunEligibilityFailure(agent, connector, task?.projectId ?? null); if (eligibilityFailure) { if (eligibilityFailure === "CONNECTOR_MODEL_NOT_ALLOWED") {