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

+32
View File
@@ -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)