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
@@ -16,10 +16,12 @@ import { AppDatabase } from "../../src/main/database";
|
||||
import { resolveWorkspaceWithLockMode } from "../../src/main/orchestration/resolve-workspace";
|
||||
|
||||
/**
|
||||
* Regression tests for the workspace lock policy introduced in v0.4.168+:
|
||||
* only `task.workspaceId` grants exclusive access. Agent-, project-, and
|
||||
* company-scoped resolutions are all `shared` so teammates can run in
|
||||
* parallel instead of queuing on a single workspace lock.
|
||||
* Regression tests for the current workspace lock policy: every
|
||||
* resolved workspace is held exclusively for the duration of the run.
|
||||
* Pi's coding tools always include bash/write/edit, so there is no
|
||||
* read-only category where concurrent runs are safe; teammates
|
||||
* sharing one repo queue through the wakeup table instead of racing
|
||||
* the filesystem.
|
||||
*
|
||||
* See `resolve-workspace.ts` for the full rationale.
|
||||
*/
|
||||
@@ -75,7 +77,7 @@ describe("resolveWorkspaceWithLockMode", () => {
|
||||
expect(result.workspace?.id).toBe(workspaceId);
|
||||
});
|
||||
|
||||
it("agent-bound workspace → shared (normal case: teammates share a repo)", () => {
|
||||
it("agent-bound workspace → exclusive (single writer per repo)", () => {
|
||||
const { companyId, workspaceId } = seedCompany(db);
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
@@ -84,10 +86,10 @@ describe("resolveWorkspaceWithLockMode", () => {
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("agent");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
expect(result.lockMode).toBe("exclusive");
|
||||
});
|
||||
|
||||
it("project-bound workspace → shared (team collaboration space)", () => {
|
||||
it("project-bound workspace → exclusive (single writer per repo)", () => {
|
||||
const { companyId } = seedCompany(db);
|
||||
const projectId = db.saveProject({
|
||||
companyId,
|
||||
@@ -95,9 +97,6 @@ describe("resolveWorkspaceWithLockMode", () => {
|
||||
description: "",
|
||||
status: "active",
|
||||
});
|
||||
// Create a second workspace bound to the project so the resolver's
|
||||
// project-primary branch actually fires (the default seeded workspace
|
||||
// has no project_id set).
|
||||
db.saveWorkspace({
|
||||
companyId,
|
||||
name: "Project repo",
|
||||
@@ -115,10 +114,10 @@ describe("resolveWorkspaceWithLockMode", () => {
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("project");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
expect(result.lockMode).toBe("exclusive");
|
||||
});
|
||||
|
||||
it("company-fallback → shared (unchanged legacy behavior)", () => {
|
||||
it("company-fallback → exclusive (single writer per repo)", () => {
|
||||
const { companyId } = seedCompany(db);
|
||||
const result = resolveWorkspaceWithLockMode(
|
||||
db,
|
||||
@@ -127,7 +126,7 @@ describe("resolveWorkspaceWithLockMode", () => {
|
||||
companyId,
|
||||
);
|
||||
expect(result.source).toBe("company_fallback");
|
||||
expect(result.lockMode).toBe("shared");
|
||||
expect(result.lockMode).toBe("exclusive");
|
||||
});
|
||||
|
||||
it("task workspace wins over agent workspace even when both are set", () => {
|
||||
|
||||
Reference in new issue
Block a user