From 0443b4a197598a9930cca489b631425868c2ddd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 19:22:34 -0400 Subject: [PATCH] Block approve_ceo_strategy until hires exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CEO prompt carried a plea — "do not submit strategy before hires, we have observed a loop" — in place of an actual gate. The model could (and did) ignore it, land back in team_building mode, and resubmit. Move the invariant into the /approvals POST handler: reject approve_ceo_strategy with 409 STRATEGY_BEFORE_HIRES when no hire_agent approval exists for the company, and 409 STRATEGY_ALREADY_FILED when one is already pending or approved. Introduce listCompanyApprovalsByType on the database facade to keep the route stateless. Trim the prompt to describe the gate instead of begging the model to obey. --- src/main/api-server/routes-approvals.ts | 27 +++++++++++++++++++ src/main/database-base.ts | 1 + src/main/database/approvals.ts | 13 +++++++++ .../ceo-modes/team-building.en.md | 8 ++---- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/main/api-server/routes-approvals.ts b/src/main/api-server/routes-approvals.ts index 46f88f59..aaeebfb2 100644 --- a/src/main/api-server/routes-approvals.ts +++ b/src/main/api-server/routes-approvals.ts @@ -41,6 +41,33 @@ export function registerApprovalRoutes(server: AgentApiServer) { return; } + // Team-building strategy approvals must follow their intended hires. + // Accepting an approve_ceo_strategy before any hire_agent requests + // exist was observed to re-enter the empty team-building loop (see + // comment in ceo-modes/team-building.en.md and §5.2 of 问题.md). + // Reject the request at the API boundary so the model cannot + // circumvent the ordering by resubmitting. + if (input.type === "approve_ceo_strategy") { + const companyHireApprovals = db.listCompanyApprovalsByType(companyId, "hire_agent"); + if (companyHireApprovals.length === 0) { + server.json(res, 409, { + error: "approve_ceo_strategy requires at least one hire_agent approval to exist first. Submit the intended hires, then file the strategy.", + code: "STRATEGY_BEFORE_HIRES", + }); + return; + } + const existingStrategy = db.listCompanyApprovalsByType(companyId, "approve_ceo_strategy") + .find((a) => a.state === "pending" || a.state === "approved"); + if (existingStrategy) { + server.json(res, 409, { + error: "approve_ceo_strategy already exists for this company; do not submit another.", + code: "STRATEGY_ALREADY_FILED", + existingApprovalId: existingStrategy.id, + }); + return; + } + } + let id: string; try { id = db.requestApproval({ diff --git a/src/main/database-base.ts b/src/main/database-base.ts index b6adad96..1133cb60 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -344,6 +344,7 @@ export class AppDatabaseBase { listApprovalsForApi(companyId: string, filters: { requestedByAgentId?: string; relatedTaskId?: string; statuses?: string[] | null }) { return this.approvalsMod.listApprovalsForApi(companyId, filters); } findPendingApprovalForTask(companyId: string, taskId: string) { return this.approvalsMod.findPendingApprovalForTask(companyId, taskId); } hasPendingReviewApprovalForAgent(companyId: string, agentId: string) { return this.approvalsMod.hasPendingReviewApprovalForAgent(companyId, agentId); } + listCompanyApprovalsByType(companyId: string, type: string) { return this.approvalsMod.listCompanyApprovalsByType(companyId, type); } // ─── Workspaces ──────────────────────────────────────────────────────────── diff --git a/src/main/database/approvals.ts b/src/main/database/approvals.ts index b5b74231..c2090711 100644 --- a/src/main/database/approvals.ts +++ b/src/main/database/approvals.ts @@ -349,6 +349,19 @@ export function buildApprovalMethods( return row ?? null; }, + /** + * Returns every approval in the company whose `type` matches exactly. + * Used by the approvals POST route to enforce ordering invariants + * (e.g. "approve_ceo_strategy must trail hire_agent"). + */ + listCompanyApprovalsByType(companyId: string, type: string): Array<{ id: string; state: string }> { + return readAll<{ id: string; state: string }>( + `SELECT id, state FROM approvals WHERE company_id = ? AND type = ? ORDER BY created_at ASC`, + companyId, + type, + ); + }, + /** * Returns true when the given agent has a pending review-type approval * against one of their tasks. Used by checkpoint-engine to decide diff --git a/src/main/prompt-builder/ceo-modes/team-building.en.md b/src/main/prompt-builder/ceo-modes/team-building.en.md index d3611412..2e51cbfe 100644 --- a/src/main/prompt-builder/ceo-modes/team-building.en.md +++ b/src/main/prompt-builder/ceo-modes/team-building.en.md @@ -2,13 +2,9 @@ You are the new CEO. Your company has no active team members yet. Your single job in this wake is to **staff the company**. Nothing else matters until you have direct reports. -## Critical ordering — do not violate +## Critical ordering — hires first, strategy approval last -**HIRES FIRST. STRATEGY APPROVAL LAST.** - -Do **not** submit `approve_ceo_strategy` until every intended hire has been submitted as a `hire_agent` request. Submitting strategy without hires leaves the company stuck — the strategy auto-approves, the heartbeat fires, and you land back here with nothing changed. This is a loop we have explicitly observed. Avoid it by obeying the order. - -If you see in your activity or approvals history that you have previously submitted an `approve_ceo_strategy` approval **and** no `hire_agent` approvals, recognise it as the stuck-loop signature and jump directly to Step 3 (submit hires) on this wake. Do **not** submit another strategy approval. +File every `hire_agent` request for this team-building wake before you post the single `approve_ceo_strategy` summary. The server enforces this order: `POST /approvals` with `type: "approve_ceo_strategy"` returns `409 STRATEGY_BEFORE_HIRES` while no hire requests exist, and `409 STRATEGY_ALREADY_FILED` if one has already been submitted. Don't try to bypass — the codes are there so the earlier stuck-loop failure mode can no longer recur. ## Step 1: Analyse the mission and the workspace (fast)