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:
1 parent
cdf783c0b8
commit
7f23f40e87
6 files changed
+94
-208
No files matched your search
@@ -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));
|
||||
|
||||
@@ -37,6 +37,37 @@ export function buildTaskStatusMethods(
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Guard against the "in_review dead letter": a task whose assignee is
|
||||
* a top-level agent (reports_to IS NULL, the DB invariant CEO marker)
|
||||
* and that does not require user review has no reachable reviewer —
|
||||
* no manager to walk up to, and no human gate. Without this guard the
|
||||
* CEO heartbeat loops forever re-proposing the same approval.
|
||||
*
|
||||
* The right root fix is to reject the transition at source: a caller
|
||||
* that moves such a task into `in_review` must first set
|
||||
* requires_user_review=1 (escalate to human) or route differently
|
||||
* (advance to done, hire a reviewer, bounce to todo). Previous
|
||||
* versions silently resurrected these tasks at every DB init; this
|
||||
* replaces the startup side-effect with a clear runtime error.
|
||||
*/
|
||||
function guardInReviewHasReviewer(taskId: string): void {
|
||||
const row = readAll<{ requiresUserReview: number; reportsTo: string | null }>(
|
||||
`select t.requires_user_review as requiresUserReview,
|
||||
a.reports_to as reportsTo
|
||||
from tasks t
|
||||
left join agents a on a.id = t.assignee_agent_id
|
||||
where t.id = ?`,
|
||||
taskId,
|
||||
)[0];
|
||||
if (!row) return;
|
||||
if (row.requiresUserReview === 1) return;
|
||||
if (row.reportsTo !== null) return;
|
||||
throw new Error(
|
||||
`Task ${taskId} has a top-level assignee and no reviewer: cannot enter in_review without requires_user_review=1.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
advanceTaskStatus(
|
||||
taskId: string,
|
||||
@@ -52,6 +83,7 @@ export function buildTaskStatusMethods(
|
||||
if (!task) throw new Error(`Task not found: ${taskId}`);
|
||||
const previousStatus = task.status;
|
||||
if (previousStatus === newStatus) return { previousStatus };
|
||||
if (newStatus === "in_review") guardInReviewHasReviewer(taskId);
|
||||
const ts = nowIso();
|
||||
|
||||
// Status side-effect timestamps (Paperclip pattern)
|
||||
|
||||
Reference in new issue
Block a user