Incoming messages (human → agent, agent → agent, channel broadcast)
persisted to the inbox and were only projected into the recipient's
transcript on the next `prepareRun`. When the recipient was already
running, every message waited for the next wake — a Slack-like
system operating at heartbeat cadence, not in real time.
`RuntimeMessageInjector` closes the gap: it looks up the recipient's
active run through `RuntimeBridge.findActiveRunForAgent`, projects
the persisted `AgentMessageRecord` via the existing inbox-projection
helper, and posts a `steer-run` ParentMessage into the worker. The
worker's runner enqueues the projection on the Agent's steering
queue; pi drains it at the next turn boundary and emits the usual
`message_end` → `persistMessage` sequence, so the session store
shadow is preserved without double-writing.
`dispatchAgentMessage` gains an optional `injectRuntimeMessage` hook
used by every API-side dispatch. Injection succeeds → we mark the
inbox row delivered and skip `wakeAgentIfPossible`. Injection fails
(no active run) → the legacy inbox + wake path still fires, so
sleeping agents continue to receive their messages on the next turn.
The injector is owned by the service graph alongside `workerService`
and `piRuntimeRef`, which gives it both the worker postMessage
channel and the RuntimeBridge active-run map without additional
coupling. Internal dispatch call sites (sweepStaleReviews,
automation actions) leave the hook unset; those system messages
target idle agents by design and do not need the runtime path.
The Co-Founder beforeToolCall hook (introduced in 77f81a7) gated every
non-readonly pi tool call as a `dangerous_command` approval. Because
agents call `POST /api/companies/:id/messages`, `/agents` (hire), and
every other company mutation via bash→node/curl, the first bash call in
every Co-Founder run got parked on a 30-minute approval timeout. Users
observed it as "agents never talk to each other" and "every bash command
waits for approval"; both symptoms are the same bug.
The business-level approval flow (`hire_agent`, `document_review`,
`plan_review`, etc. in execution-policy.ts) already gates the events
users actually care about, at the HTTP route layer where the event
originates (routes-agents.ts:368 for hiring, document routes for
deliverables). Stacking a pi-level hook on top was duplicate gating with
the wrong taxonomy.
Removed:
- src/main/agent-runtime/approval-hook.ts (entire file, 137 LOC)
- `beforeToolCall` conditional in worker-runner.ts and the
corresponding RPC method in worker-types.ts and runtime-bridge.ts
- `approvalHook` field + `resolveApproval` callback + shutdown
`cancelAll` in runtime-bridge.ts / service-graph-pi-runtime.ts /
wire-api-server.ts
Unchanged:
- `dangerous_command` approval type itself (still used by automation
workflows and routes-misc.ts)
- CEO mode (already never attached the hook)
- All business-level approval types and their wiring
The NOT NULL constraint forced heartbeat runs to synthesize a fake task
row (`[Heartbeat] X — Check for work`) so the FK could resolve. Three
layers of code — acquireHeartbeatTask, findReusableHeartbeatTask,
cleanupDuplicateHeartbeatTasks, plus the is_heartbeat flag column + UI
filter — existed to paper over one bad constraint. Remove the
constraint and the whole workaround vanishes.
SQLite table rebuild with data preservation. Live production DB, no
backup — migration is atomic, idempotent, and verified before DROP.
## Migration v35 (rebuild runs with nullable task_id)
Uses `fn:` form so it can:
- Snapshot `COUNT(*) FROM runs` before copy
- Walk `PRAGMA table_info(runs)` to detect drift columns (v34 declared
a column set; any additive ALTER since v14 shows up here)
- Assert `COUNT(*) FROM runs_new == beforeCount` before `DROP TABLE runs`
- `PRAGMA defer_foreign_keys = ON` so run_events(run_id) FK defers to
commit time, by which runs has the same id set under the new name
- Recreate all 6 indexes on the new table
If the assertion fails, the migration throws inside the existing
`transaction()` wrapper. SQLite rolls back the whole migration, old
`runs` table is untouched, `schema_migrations` never records v35. Next
app start retries cleanly.
## Migration v36 (drop tasks.is_heartbeat + synthetic rows)
Runs now carry `task_id = NULL` for heartbeat dispatches. The
`is_heartbeat` flag is dead data going forward.
1. `UPDATE runs SET task_id = NULL WHERE task_id IN (SELECT id FROM tasks WHERE is_heartbeat = 1)`
2. `DELETE FROM tasks WHERE is_heartbeat = 1` (synthetic rows, not real work)
3. Rebuild `tasks` without the `is_heartbeat` column (same defer-FK +
row-count-assert pattern as v35)
## Code cleanup
- `RunRecord.taskId: string | null`; `createRun` / `cancelRunForDeletion`
/ `finishRun` guard every `UPDATE tasks` path with a null-check.
- `TaskRecord.isHeartbeat` removed; every SELECT / filter / UI reference
dropped.
- Delete `acquireHeartbeatTask` / `findReusableHeartbeatTask` /
`cleanupDuplicateHeartbeatTasks` — dead after the constraint lifts.
- Delete `src/main/orchestration/heartbeat-task.ts` (pure workaround
constant file).
- `heartbeat.ts` creates runs with `taskId: null` when the agent has no
assigned work. Turn context already supports nullable task
(heartbeatFallbackSection); prompt surface unchanged.
- `validateTaskCreation` drops the `isHeartbeat` parameter — user-
authored + automation-authored tasks are never heartbeat.
- Renderer: `run.taskId ? taskMap.get(...) : undefined` in Dashboard /
RunCenter / RunDetail / RunQueue.
- Delete `tests/unit/heartbeat-coalescing.test.ts` (12 tests that
exercised the deleted DB methods).
- Drop 1 isHeartbeat-bypass test from `task-quality-gates.test.ts`.
## Grep verification
- `isHeartbeatTaskTitle src/` → 0
- `acquireHeartbeatTask src/` → migration comment only
- `is_heartbeat src/` → schema.ts migration history only
- `isHeartbeat src/` → 0
Tests: 578/578 (was 591 — delta is 13 tests that tested deleted code).
An earlier cleanup deleted HumanApprovalHook wholesale on the rationale
that the body always returned {}. That was the stub implementation, not
the feature: 版本更新.md specifies AI Co-Founder mode as "human-reviewed
execution that pauses until human review arrives". With no beforeToolCall
attached, Co-Founder and CEO modes behaved identically — a silent
regression of the product's core differentiator.
Bring the hook back with real gating logic and split the Agent
construction path by execution mode so CEO runs pay zero IPC overhead.
- HumanApprovalHook.before(): create a `dangerous_command` approval row
for non-whitelisted tool calls, emit approval-updated, return a promise
that resolves when a human decides.
- AUTO_APPROVED_TOOLS whitelist: read/ls/glob/grep/find/tree. Anything
else (bash, write, edit, …) waits for human approval.
- approvalHook.resolveApproval(): invoked from wire-api-server's
onApprovalResolved *before* automation and thread follow-ups, so the
pi Agent unblocks the instant the human decides.
- approvalHook.cancelAll(): release every pending wait on shutdown so the
worker can drain cleanly; wired into service-graph-pi-runtime.release.
- worker-runner: attach beforeToolCall only when job.executionMode ===
"ai_cofounder". CEO mode constructs Agent with no hook, no RPC hops.
- WorkerRpcClient.invoke: accept per-call timeoutMs override so approval
RPCs can wait 30min instead of the 30s default.
- worker-types schema: restore beforeToolCall in the method enum.
- RuntimeBridge.handleRpc: restore beforeToolCall branch.
No changes to approval DB schema, approval lifecycle, or existing
automation/thread handling. CEO-mode runs retain zero-IPC-per-tool
behavior from the prior cleanup.
Splits the 391-line bootstrapApplication god function into a thin
orchestrator plus 7 focused modules under src/main/bootstrap/:
prepare-logger, prepare-database, prepare-services (service graph +
ipc wiring), wire-api-server (14-callback dictionary), recover-
scheduled-posts, run-startup-health-checks, recover-interrupted-tasks
(crash notification + clean-shutdown task resumption). Original
startup order, before-quit cleanup, and error handling are preserved;
the entry file drops to 153 lines.