fix(orchestration): enforce approval gates on heartbeat and recovery paths

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-29 20:17:33 -04:00
1 parent c9deca5454
commit 1e36259c5d
3 files changed
+27 -1

No files matched your search

+2 -1
View File
@@ -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;
+7
View File
@@ -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<typeof resolveWorkspace>;
try {
workspaceResolution = resolveWorkspace(task, agent, companyId);
+18
View File
@@ -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") {