From 2b2a6446cb887ba4bcc5548246314acdbff673ef 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:27:26 -0400 Subject: [PATCH] Wake the CEO when the team stalls, not only when tasks are zero The bootstrap gate fired on goalCount > 0 && taskCount === 0, which missed the common failure mode: every task stuck in_review or blocked so the counter stays positive while no progress happens. The CEO then waited for the normal timer interval to spot the stall. Add countAdvancingTasksForCompany (todo + in_progress only) and gate bootstrap on that. Also pull messages to the top of the timer wake step list so an agent waking on the normal cadence doesn't skip over unanswered peer DMs in favour of its own task work. --- src/main/database-base.ts | 1 + src/main/database/task-queries.ts | 18 ++++++++++++++++++ src/main/orchestration/scheduler-heartbeat.ts | 14 +++++++++++--- src/main/prompt-builder/wake-reasons/en.ts | 4 ++-- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/main/database-base.ts b/src/main/database-base.ts index 1133cb60..fdfed48d 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -285,6 +285,7 @@ export class AppDatabaseBase { listTasksForAssignees(companyId: string, assigneeIds: string[]) { return this.tasks.listTasksForAssignees(companyId, assigneeIds); } listReviewTasksForManager(managerId: string, companyId: string) { return this.tasks.listReviewTasksForManager(managerId, companyId); } countActiveTasksForCompany(companyId: string) { return this.tasks.countActiveTasksForCompany(companyId); } + countAdvancingTasksForCompany(companyId: string) { return this.tasks.countAdvancingTasksForCompany(companyId); } listChildTasks(parentId: string, companyId: string) { return this.tasks.listChildTasks(parentId, companyId); } advanceTaskStatus(taskId: string, newStatus: string, comment?: string) { return this.tasks.advanceTaskStatus(taskId, newStatus, comment); } setTaskRequiresUserReview(taskId: string, value: boolean) { return this.tasks.setTaskRequiresUserReview(taskId, value); } diff --git a/src/main/database/task-queries.ts b/src/main/database/task-queries.ts index 4d7fca43..02e2eebf 100644 --- a/src/main/database/task-queries.ts +++ b/src/main/database/task-queries.ts @@ -112,6 +112,24 @@ export function buildTaskQueryMethods( return row.cnt; }, + /** + * Count tasks that are actively producing progress this cycle — + * todo (waiting to be checked out) and in_progress (currently being + * worked). Excludes in_review (waiting on a human or manager) and + * blocked (waiting on a resolution). Used by the bootstrap + * scheduler to decide whether the CEO needs to be pinged: a company + * where every task is stuck in review or blocked is functionally + * idle even though countActiveTasksForCompany reports > 0. + */ + countAdvancingTasksForCompany(companyId: string): number { + const row = getDb() + .prepare( + "select count(*) as cnt from tasks where company_id = ? and status in ('todo','in_progress')", + ) + .get(companyId) as { cnt: number }; + return row.cnt; + }, + listChildTasks( parentId: string, companyId: string, diff --git a/src/main/orchestration/scheduler-heartbeat.ts b/src/main/orchestration/scheduler-heartbeat.ts index 5da0096c..02a5a342 100644 --- a/src/main/orchestration/scheduler-heartbeat.ts +++ b/src/main/orchestration/scheduler-heartbeat.ts @@ -95,15 +95,23 @@ export function createHeartbeatScheduler( } const goalCount = db.countActiveGoalsForCompany(company.id); - const taskCount = db.countActiveTasksForCompany(company.id); - if (goalCount > 0 && taskCount === 0) { + // Bootstrap when the company has active goals but no work is + // actually advancing. The previous gate ("taskCount === 0") + // missed the case where every task is in_review or blocked — + // the company looks busy to the counter but produces zero + // progress, and the CEO would not be woken until the next + // regular timer interval. countAdvancingTasksForCompany + // counts only todo + in_progress so bootstrapping still + // fires once the team stalls. + const advancingCount = db.countAdvancingTasksForCompany(company.id); + if (goalCount > 0 && advancingCount === 0) { const ceo = db.getCeoForCompany(company.id); const ceoLastWake = ceo?.lastHeartbeatAt ? new Date(ceo.lastHeartbeatAt).getTime() : 0; const ceoLastAttempt = ceo ? attemptedAtByAgent.get(ceo.id) ?? 0 : 0; const ceoLastTouch = Math.max(ceoLastWake, ceoLastAttempt); const bootstrapCooldown = 120_000; if (ceo && !hasActiveRun({ agentId: ceo.id, companyId: company.id }) && (now - ceoLastTouch > bootstrapCooldown)) { - logger.info(`[heartbeat] Company ${company.name} has ${goalCount} goals but no tasks — bootstrapping CEO ${ceo.name}`); + logger.info(`[heartbeat] Company ${company.name} has ${goalCount} goal(s) and no advancing tasks — bootstrapping CEO ${ceo.name}`); tryWake(ceo.id, company.id, ceo.name, "bootstrap"); } } diff --git a/src/main/prompt-builder/wake-reasons/en.ts b/src/main/prompt-builder/wake-reasons/en.ts index 0b3ecba9..467f0452 100644 --- a/src/main/prompt-builder/wake-reasons/en.ts +++ b/src/main/prompt-builder/wake-reasons/en.ts @@ -27,8 +27,8 @@ export const WAKE_REASONS: Record = { title: "Regular heartbeat check-in. This is your periodic wake.", steps: () => [ "Call GET /api/agents/me/standup to get your full briefing", - "Review all your assigned tasks and their statuses", - "Take action on the most important item: reviews > unblocking > new work", + "Walk `unreadMessages` first — every unanswered peer DM blocks somebody else's next move. Reply and mark read before you pick up task work.", + "Then take action on the most important item remaining: reviews > unblocking reports > next step on assigned work", "If nothing actionable is in your briefing, return control without posting a status comment — activity noise is worse than silence.", ], },