fix(db): strip implicit data mutations from startup

`init()` ran three implicit data-repair queries on every boot:
`reconcileOrphanedTasks`, `reconcileUnreviewableTasks`, and a
secrets-table migration whose fallback glued legacy rows onto a
synthetic zero UUID when no current company existed. The first
two papered over state-machine leaks at init time — hiding the
real bug and making startup observably mutate production data;
the third silently connected ciphertext to a non-existent company
with no UI visibility and no deletion path.

Root fix: the startup path is now allowed exactly one mutation —
`interruptStaleRuns`, which is crash recovery (running-at-boot =
worker died mid-turn) and cannot be expressed anywhere else.

The dead-letter `in_review` case moves to the transition point:
`tasks-status.guardInReviewHasReviewer` refuses to advance a task
into `in_review` when its assignee is a top-level agent
(`reports_to IS NULL`) and the task is not flagged for user
review. Callers either pre-flag the task (the CEO-completion path
in `automation-run-completion.advanceToInReview` now does this)
or the transition is rejected. No more silent resurrection at
boot; the failure is visible and actionable at source.

The orphan-task reconcile disappears entirely — the `finishRun`
guard already enforces the invariant at the one place a run can
leave the in-progress state, and `recoverStaleLocks` handles
stuck executions at runtime.

The secrets migration now throws when neither
`settings.current_company_id` nor any company row exists, rather
than binding legacy ciphertext to `00000000-0000-0000-0000-000000000000`.
Legacy rows stay on the old table until the operator creates a
real company, at which point the upgrade succeeds deterministically.

Tests rewritten: the reconciliation regression suite now asserts
the transition-time guard and the lone `interruptStaleRuns`
behavior. `resolve-workspace.test.ts` catches up with the
exclusive-by-default lock policy committed earlier.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 01:12:38 -04:00
1 parent cdf783c0b8
commit 7f23f40e87
6 files changed
+94 -208

No files matched your search

+13 -4
View File
@@ -74,10 +74,19 @@ export function migrateSecretsTable(db: DatabaseSync, readAll: ReadAll) {
db.prepare("create unique index if not exists secrets_company_name_idx on secrets(company_id, name)").run();
return;
}
const fallbackCompanyId =
((db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined)?.value) ??
((db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined)?.id) ??
"00000000-0000-0000-0000-000000000000";
// Legacy profiles predate the per-company secrets table. Rebind each
// row to the current company; if neither `current_company_id` nor any
// company row exists we refuse rather than attach secrets to a
// synthetic zero UUID that references nothing — that would leak
// ciphertext with no deletion path and no UI visibility.
const currentCompany = db.prepare("select value from settings where key = 'current_company_id'").get() as { value: string } | undefined;
const firstCompany = db.prepare("select id from companies order by created_at asc limit 1").get() as { id: string } | undefined;
const fallbackCompanyId = currentCompany?.value ?? firstCompany?.id;
if (!fallbackCompanyId) {
throw new Error(
"Cannot migrate legacy secrets table: no current_company_id is set and no company rows exist. Create a company before upgrading, or drop the legacy secrets table if the rows are stale.",
);
}
db.exec(`
alter table secrets rename to secrets_legacy;
create table secrets (id text primary key, company_id text not null, name text not null, description text not null, backend text not null, ciphertext blob not null, updated_at text not null, created_at text not null, unique(company_id, name));