- heartbeat.ts and task-runner.ts now share a single ResolveWorkspaceFn
signature exported from run-dispatch.ts. Callers handle
WorkspaceConfigurationError explicitly:
* heartbeat: release the task lease and skip with new
workspace_unresolved reason (task re-enters pool next heartbeat)
* task-runner: release the lease and return WORKSPACE_UNRESOLVED
to the caller
No silent fallback to a random shared workspace.
- crash-recovery.ts imports WorkspaceConfigurationError for clean
catch-branch attribution; the outer try already rolls back the
queued run on any resolve failure.
- run-dispatch.ts exposes ResolveWorkspaceFn so downstream modules
type their injected resolver against the same shape (single source
of truth).
- HeartbeatSkipReason gains workspace_unresolved to keep the closed
enum honest about all heartbeat exit paths.
Migrate all remaining pass-through call sites (social, skills, budget,
entityCrud, documents, knowledge, automation, wakeup, backup, threads,
settings, snapshot, connectors, workspaces, activity) to their owning
domain modules. `AppDatabase` now carries only three methods:
- createHireRequest (multi-module composite: agents + approvalsMod)
- rollbackConfigRevision (multi-module composite: documents + entityCrud)
- queryAll (raw-SQL escape hatch, @internal)
The vault/env-binding infrastructure previously passed argument-by-
argument into `entityCrud.saveSecret`/`resolveSecretEnv` now binds at
module construction, letting callers invoke `db.entityCrud.saveSecret({...})`
without carrying vault plumbing.
Structural types that narrowed AppDatabase to a bag of flat methods
(`ConnectorModelPolicyDeps` callers, `CapabilityMatchDb`, test mocks in
budget-enforcement / budget-incidents / task-quality-gates /
failure-escalation / task-quality-gates-sweep) are reshaped to the new
namespace. `connectorPolicyDepsFromDb` replaces the old pattern of
handing the whole AppDatabase to `validateConnectorModelSelection`.
A new ESLint rule in eslint.config.mjs blocks new top-level methods on
AppDatabase so the pass-through layer cannot silently grow back.
`AppDatabase.ts` dropped from 193 lines to 58; `AppDatabaseBase` lost
the 270-line trailing pass-through block.
Rewrite ~376 call sites for messages, runs, approvalsMod, and company
domains and delete the corresponding pass-through methods on
AppDatabase / AppDatabaseBase. Structural type references
(ReturnType<HandlerDeps["db"]["getRun"]>) and SweepStaleReviewsDeps-style
mocks are updated to route through the owning sub-module.
These columns and the messages that plumbed them existed for the CLI-
adapter era — Gemini's resume id and provider-specific session params
rode the run-finished envelope back onto the session row so the next
turn could resume. Pi Mono dispatches every provider through pi-ai and
emits neither value. The worker has been posting literal null on every
finish, finalizeRun has been writing null into task_sessions, and
rotateSession has been carrying null forward.
Remove the wire, the writer, the carry-forward, and the columns:
- v43 migration rebuilds task_sessions without session_params_json /
session_display_id, and runs without session_display_id. Follows
the idempotent "create new, copy, swap, reindex" pattern used by
v41. context_snapshot_json is preserved (also unreferenced today
but outside this cleanup's scope).
- RunJob / EnqueueRunPayload / ChildMessage / worker-types zod
schema no longer carry the fields. finishRun() / createNewSession()
signatures tighten in lockstep.
- rotateSession starts the new session with fresh counters and no
carry-forward.
- turn-context resolveSession returns just the session id.
- TaskSessionRecord / RunRecord lose the dead fields.
498/498 unit tests pass. typecheck + build clean.
The per-connector "enabled/disabled model" settings lived under a
global key in the settings key-value table
(`disabled_models:${connectorId}`). No company dimension. Deleting a
company left the pool choices behind, and creating a new company
silently inherited them — the visible symptom was "onboarding's team-
model configuration survives company deletion and pollutes the next
company's CEO model list."
Root fix, not a cleanup hook: the storage becomes a real relational
table with `(company_id, connector_id)` primary key and an
`ON DELETE CASCADE` FK from `companies`. Company deletion wipes pool
config by construction; no cleanup step in `deleteCompany()` can
drift.
Schema (migration v42):
- CREATE TABLE connector_model_pool_settings (company_id, connector_id,
disabled_models_json, updated_at), PK (company_id, connector_id),
FK companies(id) ON DELETE CASCADE.
- Drop every `settings.disabled_models:*` key — these were the bug's
residue; there is no right company to attribute them to, so the
migration clears them. Users re-select on next onboarding.
API surgery — every reader/writer now takes companyId:
- db.getDisabledModels(companyId, connectorId)
- db.setDisabledModels(companyId, connectorId, models)
- db.hasModelPoolSettings(companyId, connectorId)
- connector-model-policy: getConnectorModelPolicy(deps, companyId,
connectorId); validateConnectorModelSelection(deps, companyId,
connectorId, model)
- model-dispatch: listEnabledModels(companyId, deps);
pickModelForAgent resolves companyId from agent.companyId
- orchestration heartbeat/task-runner/turn-context/run-dispatch pass
the agent's or run's companyId through to the policy deps
- IPC schema: connectorDisabledModelsSchema gains companyId; new
connectorModelPoolQuerySchema pairs companyId+connectorId for reads
Onboarding becomes commit-on-launch:
- useConnectorModelSelection no longer persists via IPC during
onboarding. Toggles mutate local state; it exposes `poolSelections`
to the wizard.
- bootstrapOnboardingSchema gains connectorPoolSelections. The
onboarding service saves the company first, then persists each
per-connector disabled list scoped to the new company id — CEO
model validation runs against the company's freshly-persisted pool,
not some stale global.
- IPC setConnectorDisabledModels now requires companyId; the only
consumer was onboarding, which no longer calls it at all.
- Main-side listConnectorModels returns the raw provider catalog
(no pool filtering), since that IPC is used from onboarding where
no company exists yet; pool filtering happens at dispatch time.
saveConnector IPC drops the per-company pool validation — connector
config is a global hint, company-scoped enforcement belongs at run
dispatch where the company is known.
Tests: 603/603 green. Typecheck clean. Integration tests rewritten
to set the pool via connectorPoolSelections on bootstrap input, not
a pre-bootstrap global setDisabledModels call.
Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
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).
Cost was written through three independent paths: worker-runner shipped
`finalUsage.costUsd` in run-finished -> finishRun wrote `runs.cost_usd`,
`tasks.cost_usd += costUsd`, `agents.spent_monthly_usd += costUsd`, and
inserted a per-run aggregate row in cost_entries. CostWriter
additionally inserted per-message rows in cost_entries from main. Any
CostWriter failure created silent drift between `SUM(cost_entries)`
(read by budget enforcement) and `runs.cost_usd` (read by the UI).
Make CostWriter the single writer, finishRun the single reader.
- Add `db.recordCostEntry(...)` as the first-class insert path for
per-message cost rows. CostWriter calls it; tests call it directly.
- Remove the aggregate INSERT from finishRun. finishRun now reads
`SELECT COALESCE(SUM(amount_usd), 0) FROM cost_entries WHERE run_id=?
AND unattributed=0` and writes that as the authoritative run total to
runs.cost_usd, tasks.cost_usd, and agents.spent_monthly_usd.
- Drop `costUsd` from `finishRun` input, the worker `run-finished`
ChildMessage shape, the automation `RunFinishedEvent` type, and every
caller + test fixture. Cost is owned by the per-message path.
- worker-runner stops extracting `usage.cost.total` — token counts stay
for session accumulation, cost comes from pi-ai via CostWriter.
- run-finished-helpers now gates budget incident checks on
`event.status === "succeeded"` instead of a cost field that no longer
exists on the event.
- automation-run-completion fires `budget_threshold` on every succeeded
run (post-run spent_monthly_usd already reflects the SUM path).
- Update budget tests to write a `recordCostEntry` before `finishRun`
to simulate the per-message cost path; drop `costUsd` from cross-
subsystem, edge-case-budget, database, and related fixtures.
One writer, one reader, one source of truth. No drift.
P1-21: worker-crash-recovery reinvented workspace resolution inline and
silently dropped the exclusive/shared distinction — a recovered run
could claim shared mode that the original run held exclusively.
Introduce resolve-workspace.ts as the single source of truth for the
task→agent→project→company fallback chain and its lockMode, used by
both run-dispatch and crash recovery.
The lock was previously claimed when the worker reported `running`, which
is after `agent.prompt` had already started streaming. Two concurrent
runs targeting the same workspace could both pass the main-side checks,
both get enqueued, and both read half-written state before one of them
lost the lock race and was cancelled mid-flight.
Move the `claimWorkspaceLock` call into `dispatchRunToWorker` so main
coordinates exclusivity authoritatively. If the lock is unavailable,
the run is finished as failed and never enqueued. The worker-side
lease-renewal path is preserved since it is a lease, not a lock.