B2 Phase 1 follow-up: plug lease orphan gap in advanceTaskStatus + scope interruptStaleRuns cleanup
Linus review T1 surfaced three real bugs in the B2 Phase 1 shadow
dual-write surface. Fix them in one pass.
1. advanceTaskStatus left task_leases behind on terminal (done /
cancelled / blocked) and "return to queue" (todo / backlog)
transitions. The status->phase trigger advanced tasks.phase but the
lease row lingered as an orphan. dev-mode verifyPhaseConsistency
could see the divergence; production silently drifted. Wrap the
status UPDATE and an explicit DELETE FROM task_leases in a single
transaction so no caller observes the intermediate state.
2. The no-restricted-syntax guard in eslint.config.mjs only matched
literals shaped "UPDATE tasks SET status" -- advanceTaskStatus's
dynamic `UPDATE tasks SET ${timestampSets.join(", ")}` slipped past,
and so did saveTask / recoverStaleLocks (status not first column).
Tighten the selectors to (a) match any status-write within UPDATE
tasks regardless of column order, and (b) flag template literals
whose head ends with `SET ` so dynamic helpers must carry an
explicit eslint-disable with rationale. Five grandfathered sites
now carry matching disables.
3. interruptStaleRuns scoped its task+lease cleanup with
`WHERE finished_at = ?` against the freshly stamped timestamp --
safe in isolation, vulnerable to ISO-millisecond ties with other
already-interrupted runs. Capture the stale run ids before
mutating anything and drive every downstream statement from that
explicit id list. Original semantics preserved; scoping is exact.
Unit tests added in tests/unit/tasks-phase.test.ts cover the
advanceTaskStatus lease cleanup across done / cancelled / blocked /
todo, and pin the in_review case as "lease stays".
This commit is contained in:
1 parent
ded707d960
commit
54a97f67ac
5 files changed
+215
-25
No files matched your search
@@ -280,6 +280,116 @@ describe("tasks-phase helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("advanceTaskStatus lease cleanup", () => {
|
||||
// B2 Phase 1 follow-up (Linus review ask #1): advanceTaskStatus
|
||||
// used to write only the status column, leaving any lease row
|
||||
// behind as an orphan. The helpers below seed a lease directly,
|
||||
// then drive status transitions and assert the shadow row lifecycle.
|
||||
function seedLease(taskId: string, runId: string): void {
|
||||
const acquired = new Date().toISOString();
|
||||
const expires = new Date(Date.now() + 60_000).toISOString();
|
||||
(db as unknown as {
|
||||
db: { prepare: (sql: string) => { run: (...args: unknown[]) => unknown } };
|
||||
}).db
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO task_leases
|
||||
(task_id, run_id, acquired_at, expires_at, checkout_run_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(taskId, runId, acquired, expires, runId);
|
||||
}
|
||||
|
||||
function readLease(taskId: string): { run_id: string } | undefined {
|
||||
return (db as unknown as {
|
||||
db: { prepare: (sql: string) => { get: (...args: unknown[]) => unknown } };
|
||||
}).db
|
||||
.prepare("SELECT run_id FROM task_leases WHERE task_id = ?")
|
||||
.get(taskId) as { run_id: string } | undefined;
|
||||
}
|
||||
|
||||
it("drops the lease when the task transitions to done", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, agentId, workspaceId);
|
||||
const runId = createTestRun(db, companyId, taskId, agentId, workspaceId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "in_progress");
|
||||
seedLease(taskId, runId);
|
||||
expect(readLease(taskId)?.run_id).toBe(runId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "done");
|
||||
expect(readLease(taskId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the lease when the task transitions to cancelled", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, agentId, workspaceId);
|
||||
const runId = createTestRun(db, companyId, taskId, agentId, workspaceId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "in_progress");
|
||||
seedLease(taskId, runId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "cancelled");
|
||||
expect(readLease(taskId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the lease when the task transitions to blocked", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, agentId, workspaceId);
|
||||
const runId = createTestRun(db, companyId, taskId, agentId, workspaceId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "in_progress");
|
||||
seedLease(taskId, runId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "blocked");
|
||||
expect(readLease(taskId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops the lease when the task is bounced back to todo", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
const taskId = createTestTask(db, companyId, agentId, workspaceId);
|
||||
const runId = createTestRun(db, companyId, taskId, agentId, workspaceId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "in_progress");
|
||||
seedLease(taskId, runId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "todo");
|
||||
expect(readLease(taskId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the lease intact when the task transitions to in_review", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
createTestConnector(db);
|
||||
const workspaceId = createTestWorkspace(db, companyId);
|
||||
const agentId = createTestAgent(db, companyId, workspaceId);
|
||||
// in_review guard requires either a reports_to or requires_user_review.
|
||||
db.tasks.setTaskRequiresUserReview(
|
||||
createTestTask(db, companyId, agentId, workspaceId),
|
||||
true,
|
||||
);
|
||||
const taskId = createTestTask(db, companyId, agentId, workspaceId);
|
||||
db.tasks.setTaskRequiresUserReview(taskId, true);
|
||||
const runId = createTestRun(db, companyId, taskId, agentId, workspaceId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "in_progress");
|
||||
seedLease(taskId, runId);
|
||||
|
||||
db.tasks.advanceTaskStatus(taskId, "in_review");
|
||||
expect(readLease(taskId)?.run_id).toBe(runId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseTaskLease", () => {
|
||||
it("releases the lease when the runId matches", () => {
|
||||
const companyId = createTestCompany(db);
|
||||
|
||||
Reference in new issue
Block a user