refactor(server): remove unreachable task-drain compensation paths (#12511)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task-drain service controls when task execution can start and stop. > - The service had compensation paths for states that its validators or recovery process already handle. > - These paths added rollback state and a stuck-claim marker without improving normal drain behavior. > - This pull request removes the unreachable TTL clamp, audit rollback, generation counter, and double-fault marker. > - The result keeps input validation, audit ordering, atomic release, and orphan recovery. ## Linked Issues or Issue Description **What existing behavior does this improve?** The task-drain service and its routes manage drain state, audit rows, and execution locks. **Subsystem affected** server/ — REST API and orchestration services. **Current behavior** The service clamps a validated TTL value. The routes mutate drain state before audit writes and then restore state after a failed write. Claim release also tracks a second durable-write failure with an in-memory marker. **Proposed behavior** The validator remains the single TTL policy. The routes write audit rows before they mutate drain state. Claim release logs a failed write and lets the orphan reaper release the issue lock. **Reason and benefit** The removed paths cannot handle a valid API request that reaches them. The rollback can lose the original start time. The marker can keep a drain non-quiescent until process restart. The simpler flow keeps state consistent and uses the existing recovery path. **Breaking changes** None to the public API. A failed claim release keeps the issue lock until the next orphan-reaper cycle. ## What Changed - Remove the service-layer TTL clamp because the shared validator rejects values above the limit. - Write task-drain audit rows before drain mutation and remove the rollback helpers. - Remove the rollback generation counter and its unused state. - Remove double-fault stuck-claim tracking and keep the atomic release path. - State that the quiescent flag describes work in this process. - Keep the orphan reaper as the recovery path after a failed claim release. ## Verification - Run `pnpm --filter @paperclipai/server test server/src/__tests__/heartbeat-task-drain-admission-release.test.ts`. - Run `pnpm --filter @paperclipai/server test server/src/__tests__/heartbeat-task-drain.test.ts`. - Run `pnpm --filter @paperclipai/server test server/src/__tests__/instance-settings-routes.test.ts`. - Run `pnpm --filter @paperclipai/server test server/src/__tests__/heartbeat-scheduling-suppression.test.ts`. - Run `pnpm --filter @paperclipai/server test server/src/__tests__/execution-lock-orphan-cleanup.test.ts`. - The five affected test files pass with 70 tests. - Confirm the full pull request checks pass before merge. ## Risks The issue lock remains held until the orphan reaper runs after a failed claim release. This uses the existing recovery path for interrupted runs. The change does not alter the public API or database schema. ## Model Used OpenAI Codex, GPT-5, 400K context window, tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e3eed3a3ae
commit
4310b0c947
|
|
@ -225,10 +225,9 @@ describeEmbeddedPostgres("heartbeat task-drain admission release", () => {
|
|||
// partway through, without touching any other table's update path.
|
||||
// tablesByCall maps a 0-based db.transaction() call index (in call order)
|
||||
// to the table that call should fail on; a call index with no entry runs
|
||||
// every update for real. For example { 0: issues, 1: agentWakeupRequests }
|
||||
// fails only the issue-lock write in the first transaction
|
||||
// (releaseRunClaimedJustBeforeSuppression) and only the wakeup write in
|
||||
// the second (failRunClaimedJustBeforeSuppression's own transaction).
|
||||
// every update for real. For example { 0: issues } fails only the
|
||||
// issue-lock write inside releaseRunClaimedJustBeforeSuppression's
|
||||
// transaction.
|
||||
function withFailingTransactionalUpdate(realDb: typeof db, tablesByCall: Record<number, unknown>) {
|
||||
let callIndex = 0;
|
||||
return new Proxy(realDb, {
|
||||
|
|
@ -258,141 +257,12 @@ describeEmbeddedPostgres("heartbeat task-drain admission release", () => {
|
|||
}) as typeof db;
|
||||
}
|
||||
|
||||
for (const [label, failingTable] of [
|
||||
["the wakeup-request update", agentWakeupRequests],
|
||||
["the issue-lock update", issues],
|
||||
] as const) {
|
||||
it(`fails the run instead of leaving it claimed when ${label} fails`, async () => {
|
||||
const { companyId, issueId, runId, wakeupRequestId } = await seedQueuedRun();
|
||||
// Fault only the first (release) transaction, so the fallback's own
|
||||
// transaction runs for real and this test proves it can still reach
|
||||
// "failed" on its own — atomicity of the fallback itself is covered
|
||||
// separately below.
|
||||
const failingDb = withFailingTransactionalUpdate(db, { 0: failingTable });
|
||||
const heartbeat = heartbeatService(failingDb);
|
||||
|
||||
const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => {
|
||||
const payload = event.payload as { runId?: string; status?: string };
|
||||
if (event.type === "heartbeat.run.status" && payload.runId === runId && payload.status === "running") {
|
||||
startTaskDrain({});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
// The atomic release transaction rolled back (a non-atomic release
|
||||
// would show a partial mix of "queued" and "claimed" instead), so
|
||||
// executeRun's fallback takes over and fails the run outright. A
|
||||
// stuck "running" run here would keep the wakeup claimed and the
|
||||
// issue locked forever while active tracking already reports zero
|
||||
// active runs — the false-quiescence bug this test guards against.
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(run?.status).toBe("failed");
|
||||
expect(run?.errorCode).toBe("claim_release_failed");
|
||||
|
||||
const wakeup = await db
|
||||
.select({ status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.status).toBe("failed");
|
||||
|
||||
const issue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
|
||||
// The database converged to the same "not active" conclusion active
|
||||
// tracking already reached, so quiescence now reads true because it
|
||||
// is genuinely true, not because the database was never checked.
|
||||
const status = getTaskDrainStatus();
|
||||
expect(status.activeRuns).toBe(0);
|
||||
expect(status.quiescent).toBe(true);
|
||||
}, 20_000);
|
||||
}
|
||||
|
||||
// Wraps db.transaction so the release transaction (call 0) fails on
|
||||
// releaseFailingTable exactly like withFailingTransactionalUpdate above —
|
||||
// this forces the fallback to run. The fallback's own transaction (call 1)
|
||||
// first writes a terminal outcome to the run, wakeup, and issue-lock rows
|
||||
// before it runs its real update. This stands in for a concurrent path (a
|
||||
// cancellation, the orphan reaper) that reaches a terminal status — and
|
||||
// finishes releasing the same three rows this fallback also guards — while
|
||||
// the fallback was still waiting to run its own update.
|
||||
function withRunTerminalizedBeforeFallbackUpdate(
|
||||
realDb: typeof db,
|
||||
releaseFailingTable: unknown,
|
||||
ids: { runId: string; wakeupRequestId: string; issueId: string },
|
||||
) {
|
||||
let callIndex = 0;
|
||||
return new Proxy(realDb, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop !== "transaction") return Reflect.get(target, prop, receiver);
|
||||
return (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const isReleaseCall = callIndex === 0;
|
||||
const isFallbackCall = callIndex === 1;
|
||||
callIndex += 1;
|
||||
return target.transaction(async (tx) => {
|
||||
if (isReleaseCall) {
|
||||
const txProxy = new Proxy(tx as object, {
|
||||
get(txTarget, txProp, txReceiver) {
|
||||
if (txProp === "update") {
|
||||
return (table: unknown) => {
|
||||
if (table === releaseFailingTable) {
|
||||
throw new Error("simulated transactional write failure");
|
||||
}
|
||||
return (txTarget as any).update(table);
|
||||
};
|
||||
}
|
||||
return Reflect.get(txTarget, txProp, txReceiver);
|
||||
},
|
||||
});
|
||||
return fn(txProxy);
|
||||
}
|
||||
if (isFallbackCall) {
|
||||
const now = new Date();
|
||||
const txDb = tx as typeof db;
|
||||
await txDb
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: now,
|
||||
error: "Cancelled while a task drain was pending",
|
||||
errorCode: "cancelled",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, ids.runId));
|
||||
await txDb
|
||||
.update(agentWakeupRequests)
|
||||
.set({ status: "cancelled", finishedAt: now, updatedAt: now })
|
||||
.where(eq(agentWakeupRequests.id, ids.wakeupRequestId));
|
||||
await txDb
|
||||
.update(issues)
|
||||
.set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: now })
|
||||
.where(eq(issues.id, ids.issueId));
|
||||
}
|
||||
return fn(tx);
|
||||
});
|
||||
};
|
||||
},
|
||||
}) as typeof db;
|
||||
}
|
||||
|
||||
it("leaves a run's outcome untouched when another path already terminalized it before the fallback runs", async () => {
|
||||
it("leaves the run row running for the orphan reaper when the atomic release fails", async () => {
|
||||
const { companyId, issueId, runId, wakeupRequestId } = await seedQueuedRun();
|
||||
|
||||
const failingDb = withRunTerminalizedBeforeFallbackUpdate(db, issues, { runId, wakeupRequestId, issueId });
|
||||
// Fault the release transaction on the issue-lock write, so executeRun's
|
||||
// suppression branch catches the failure, logs it, and returns instead
|
||||
// of throwing. There is no in-process fallback or retry for this path.
|
||||
const failingDb = withFailingTransactionalUpdate(db, { 0: issues });
|
||||
const heartbeat = heartbeatService(failingDb);
|
||||
|
||||
const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => {
|
||||
|
|
@ -409,305 +279,22 @@ describeEmbeddedPostgres("heartbeat task-drain admission release", () => {
|
|||
unsubscribe();
|
||||
}
|
||||
|
||||
// The other path's outcome survives untouched. Before the fix, the
|
||||
// fallback's unconditional update matched this already-terminal row and
|
||||
// overwrote it with "failed" / "claim_release_failed", losing the real
|
||||
// cause.
|
||||
// The release transaction rolled back, so the run, wakeup, and issue
|
||||
// lock stay exactly as the admission claim left them.
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(run?.status).toBe("cancelled");
|
||||
expect(run?.errorCode).toBe("cancelled");
|
||||
expect(run?.status).toBe("running");
|
||||
|
||||
const wakeup = await db
|
||||
.select({ status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.status).toBe("cancelled");
|
||||
expect(wakeup?.status).toBe("claimed");
|
||||
|
||||
const issue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBeNull();
|
||||
|
||||
// The run is not active by any measure: not in the live execution-promise
|
||||
// tracking (it already settled) and not in the stuck claim-release
|
||||
// marker (the fallback found no row to update, so it never throws).
|
||||
// Quiescence must be able to reach true.
|
||||
const status = getTaskDrainStatus();
|
||||
expect(status.activeRuns).toBe(0);
|
||||
expect(status.quiescent).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
for (const [label, failingTable] of [
|
||||
["the wakeup-request update", agentWakeupRequests],
|
||||
["the issue-lock update", issues],
|
||||
] as const) {
|
||||
it(`leaves the run claimed instead of a partial write when the fallback's own ${label} fails`, async () => {
|
||||
const { companyId, issueId, runId, wakeupRequestId } = await seedQueuedRun();
|
||||
// Fault the release transaction (call 0) on the issue lock so the
|
||||
// fallback engages, then fault the fallback's own transaction
|
||||
// (call 1) on a different table. Before the fix, the fallback wrote
|
||||
// the run row with a plain, unconditional update before it ever
|
||||
// touched the wakeup or issue rows — that write would have committed
|
||||
// here regardless of what came after it. With the fallback's writes
|
||||
// in one transaction, a failure anywhere inside it must roll back
|
||||
// everything, including the run-status write that ran first.
|
||||
const failingDb = withFailingTransactionalUpdate(db, { 0: issues, 1: failingTable });
|
||||
const heartbeat = heartbeatService(failingDb);
|
||||
|
||||
const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => {
|
||||
const payload = event.payload as { runId?: string; status?: string };
|
||||
if (event.type === "heartbeat.run.status" && payload.runId === runId && payload.status === "running") {
|
||||
startTaskDrain({});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
// Both transactions rolled back, so the database still shows the run
|
||||
// exactly as the admission claim left it — claimed, not a mix of
|
||||
// "failed" run row with a still-claimed wakeup or a still-locked issue.
|
||||
const run = await db
|
||||
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(run?.status).toBe("running");
|
||||
expect(run?.errorCode).toBeNull();
|
||||
|
||||
const wakeup = await db
|
||||
.select({ status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.status).toBe("claimed");
|
||||
|
||||
const issue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBe(runId);
|
||||
|
||||
// The database still holds the claim, so task-drain must not report
|
||||
// quiescent for it. Before the fix, executeRun's rejection here was
|
||||
// caught by the dispatch site's generic handler, which removed this
|
||||
// run's execution promise from active tracking regardless — reporting
|
||||
// quiescent while the run, wakeup, and issue lock were all still
|
||||
// durably claimed.
|
||||
const status = getTaskDrainStatus();
|
||||
expect(status.activeRuns).toBeGreaterThanOrEqual(1);
|
||||
expect(status.quiescent).toBe(false);
|
||||
|
||||
// The run's row is still "running", so the orphan reaper (which the
|
||||
// failing-transaction proxy no longer intercepts past call index 1)
|
||||
// finds it, finalizes the run, wakeup, and issue lock for real, and
|
||||
// this fix drops the in-memory marker along with them. Before the
|
||||
// fix, this marker survived the reap and quiescent stayed false
|
||||
// until the process restarted.
|
||||
const reapResult = await heartbeat.reapOrphanedRuns();
|
||||
expect(reapResult.runIds).toContain(runId);
|
||||
|
||||
const reapedRun = await db
|
||||
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(reapedRun?.status).toBe("failed");
|
||||
expect(reapedRun?.errorCode).toBe("process_lost");
|
||||
|
||||
const reapedWakeup = await db
|
||||
.select({ status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.id, wakeupRequestId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(reapedWakeup?.status).toBe("failed");
|
||||
|
||||
// The issue is still "todo" and assigned to the same agent, so the
|
||||
// reaper's normal self-heal path queues a fresh recovery run for it
|
||||
// instead of leaving the lock empty — that recovery is unrelated to
|
||||
// this fix and stays queued (not running) because the drain is still
|
||||
// active, so it does not itself count toward activeRuns below.
|
||||
const reapedIssue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(reapedIssue?.executionRunId).not.toBe(runId);
|
||||
|
||||
const statusAfterReap = getTaskDrainStatus();
|
||||
expect(statusAfterReap.activeRuns).toBe(0);
|
||||
expect(statusAfterReap.quiescent).toBe(true);
|
||||
}, 20_000);
|
||||
}
|
||||
|
||||
// Wraps a db handle (which may already be wrapped by
|
||||
// withFailingTransactionalUpdate) so a call to db.insert(table) throws
|
||||
// once armed.value is true. Lets a test fail one specific later cleanup
|
||||
// step without touching any insert that happens earlier.
|
||||
function withFailingInsertWhenArmed(realDb: typeof db, table: unknown, armed: { value: boolean }) {
|
||||
return new Proxy(realDb, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop !== "insert") return Reflect.get(target, prop, receiver);
|
||||
return (insertTable: unknown) => {
|
||||
if (armed.value && insertTable === table) {
|
||||
throw new Error("simulated insert failure");
|
||||
}
|
||||
return (target as any).insert(insertTable);
|
||||
};
|
||||
},
|
||||
}) as typeof db;
|
||||
}
|
||||
|
||||
// Wraps a db handle (which may already be wrapped by
|
||||
// withFailingTransactionalUpdate) so a db.transaction() callback's own
|
||||
// tx.update(table) call throws once armed.value is true. This mirrors
|
||||
// withFailingInsertWhenArmed above, but for a table a step updates inside
|
||||
// its own transaction (releaseIssueExecutionAndPromote updates the issues
|
||||
// table this way) instead of a plain top-level insert.
|
||||
function withFailingTransactionalUpdateWhenArmed(realDb: typeof db, table: unknown, armed: { value: boolean }) {
|
||||
return new Proxy(realDb, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop !== "transaction") return Reflect.get(target, prop, receiver);
|
||||
return (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
target.transaction((tx) => {
|
||||
const txProxy = new Proxy(tx as object, {
|
||||
get(txTarget, txProp, txReceiver) {
|
||||
if (txProp === "update") {
|
||||
return (updateTable: unknown) => {
|
||||
if (armed.value && updateTable === table) {
|
||||
throw new Error("simulated issue-lock release failure");
|
||||
}
|
||||
return (txTarget as any).update(updateTable);
|
||||
};
|
||||
}
|
||||
return Reflect.get(txTarget, txProp, txReceiver);
|
||||
},
|
||||
});
|
||||
return fn(txProxy);
|
||||
});
|
||||
},
|
||||
}) as typeof db;
|
||||
}
|
||||
|
||||
it("clears the stuck claim-release marker even when later reap cleanup rejects", async () => {
|
||||
const { companyId, runId } = await seedQueuedRun();
|
||||
// Reuse the same setup as "leaves the run claimed instead of a partial
|
||||
// write" above: both the release transaction and the fallback's own
|
||||
// transaction fail, so the run stays "running" and its claim-release
|
||||
// marker keeps task drain non-quiescent until the orphan reaper picks
|
||||
// the run up.
|
||||
const transactionFailingDb = withFailingTransactionalUpdate(db, { 0: issues, 1: agentWakeupRequests });
|
||||
const armedRunEventInsertFailure = { value: false };
|
||||
const failingDb = withFailingInsertWhenArmed(transactionFailingDb, heartbeatRunEvents, armedRunEventInsertFailure);
|
||||
const heartbeat = heartbeatService(failingDb);
|
||||
|
||||
const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => {
|
||||
const payload = event.payload as { runId?: string; status?: string };
|
||||
if (event.type === "heartbeat.run.status" && payload.runId === runId && payload.status === "running") {
|
||||
startTaskDrain({});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
const claimedStatus = getTaskDrainStatus();
|
||||
expect(claimedStatus.activeRuns).toBeGreaterThanOrEqual(1);
|
||||
expect(claimedStatus.quiescent).toBe(false);
|
||||
|
||||
// Arm the failure only now, so it hits the reap loop's own run-event
|
||||
// insert — a cleanup step that runs after the run's row already reaches
|
||||
// a terminal status — instead of any insert during the claim race above.
|
||||
armedRunEventInsertFailure.value = true;
|
||||
await expect(heartbeat.reapOrphanedRuns()).rejects.toThrow("simulated insert failure");
|
||||
|
||||
// The run's row reached "failed" before the injected failure, and the
|
||||
// marker must have cleared right after that point, not only after every
|
||||
// later cleanup step succeeds — the bug this test guards against.
|
||||
const reapedRun = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(reapedRun?.status).toBe("failed");
|
||||
|
||||
const statusAfterFailedReap = getTaskDrainStatus();
|
||||
expect(statusAfterFailedReap.activeRuns).toBe(0);
|
||||
expect(statusAfterFailedReap.quiescent).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
// This test's failure (the issue-lock release itself rejects) never
|
||||
// resolves the run's marker within this run of the process — see
|
||||
// stuckClaimReleaseRunIds's own comment in heartbeat.ts: that is the
|
||||
// documented, accepted outcome when the lock release itself keeps
|
||||
// failing, not a bug. Because the marker is process-memory state with no
|
||||
// per-test reset, this test runs last in the file so its permanently
|
||||
// stuck marker cannot affect another test's activeRuns count.
|
||||
it("keeps the stuck claim-release marker active when the reap loop's own issue-lock release rejects", async () => {
|
||||
const { companyId, issueId, runId } = await seedQueuedRun();
|
||||
// Same admission-race setup as "leaves the run claimed instead of a
|
||||
// partial write" above: both the release transaction and the fallback's
|
||||
// own transaction fail, so the run stays "running" and its
|
||||
// claim-release marker keeps task drain non-quiescent until the orphan
|
||||
// reaper picks the run up.
|
||||
const transactionFailingDb = withFailingTransactionalUpdate(db, { 0: issues, 1: agentWakeupRequests });
|
||||
const armedIssueReleaseFailure = { value: false };
|
||||
const failingDb = withFailingTransactionalUpdateWhenArmed(transactionFailingDb, issues, armedIssueReleaseFailure);
|
||||
const heartbeat = heartbeatService(failingDb);
|
||||
|
||||
const unsubscribe = subscribeCompanyLiveEvents(companyId, (event) => {
|
||||
const payload = event.payload as { runId?: string; status?: string };
|
||||
if (event.type === "heartbeat.run.status" && payload.runId === runId && payload.status === "running") {
|
||||
startTaskDrain({});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await heartbeat.resumeQueuedRuns();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
const claimedStatus = getTaskDrainStatus();
|
||||
expect(claimedStatus.activeRuns).toBeGreaterThanOrEqual(1);
|
||||
expect(claimedStatus.quiescent).toBe(false);
|
||||
|
||||
// Arm the failure only now, so it hits releaseIssueExecutionAndPromote's
|
||||
// own issue-lock update inside the reap loop, after the run's row
|
||||
// already reached "failed" — not the admission-time issue-lock write
|
||||
// exercised above.
|
||||
armedIssueReleaseFailure.value = true;
|
||||
await expect(heartbeat.reapOrphanedRuns()).rejects.toThrow("simulated issue-lock release failure");
|
||||
|
||||
const reapedRun = await db
|
||||
.select({ status: heartbeatRuns.status })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(reapedRun?.status).toBe("failed");
|
||||
|
||||
// The run's row reached a terminal status, but its issue-lock release
|
||||
// itself failed, so the issue is still locked to this run. The marker
|
||||
// must stay active and keep reporting this instance non-quiescent — the
|
||||
// failure this test guards against clears it as soon as the row reaches
|
||||
// a terminal status, before the lock is actually released.
|
||||
const issue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
|
|
@ -715,8 +302,36 @@ describeEmbeddedPostgres("heartbeat task-drain admission release", () => {
|
|||
.then((rows) => rows[0] ?? null);
|
||||
expect(issue?.executionRunId).toBe(runId);
|
||||
|
||||
const statusAfterFailedRelease = getTaskDrainStatus();
|
||||
expect(statusAfterFailedRelease.activeRuns).toBeGreaterThanOrEqual(1);
|
||||
expect(statusAfterFailedRelease.quiescent).toBe(false);
|
||||
// executeRun did not throw, so the dispatch site removed this run's
|
||||
// execution promise from active tracking like it does for any other
|
||||
// completed run. Task-drain now reads quiescent even though the
|
||||
// database still holds the run claimed: this reading counts in-process
|
||||
// work only.
|
||||
const status = getTaskDrainStatus();
|
||||
expect(status.activeRuns).toBe(0);
|
||||
expect(status.quiescent).toBe(true);
|
||||
|
||||
// The run's row is still "running", so the orphan reaper finds it,
|
||||
// finalizes it, and releases the issue lock on its own cycle.
|
||||
const reapResult = await heartbeat.reapOrphanedRuns();
|
||||
expect(reapResult.runIds).toContain(runId);
|
||||
|
||||
const reapedRun = await db
|
||||
.select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(reapedRun?.status).toBe("failed");
|
||||
expect(reapedRun?.errorCode).toBe("process_lost");
|
||||
|
||||
// The issue is still "todo" and assigned to the same agent, so the
|
||||
// reaper's normal self-heal path queues a fresh recovery run for it
|
||||
// instead of leaving the lock pointed at the failed run.
|
||||
const reapedIssue = await db
|
||||
.select({ executionRunId: issues.executionRunId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(reapedIssue?.executionRunId).not.toBe(runId);
|
||||
}, 20_000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { MAX_TASK_DRAIN_TTL_MS } from "@paperclipai/shared";
|
||||
import {
|
||||
getTaskDrainGeneration,
|
||||
getTaskDrainStatus,
|
||||
resolveHeartbeatSchedulingSuppression,
|
||||
restoreTaskDrainIfCurrent,
|
||||
startTaskDrain,
|
||||
stopTaskDrain,
|
||||
} from "../services/heartbeat.ts";
|
||||
|
|
@ -39,12 +36,6 @@ describe("heartbeat task drain", () => {
|
|||
expect(getTaskDrainStatus().expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it("ttl_above_the_maximum_clamps_to_24_hours", () => {
|
||||
const { startedAt, expiresAt } = startTaskDrain({ ttlMs: MAX_TASK_DRAIN_TTL_MS * 10 });
|
||||
expect(expiresAt).not.toBeNull();
|
||||
expect((expiresAt as Date).getTime() - startedAt.getTime()).toBe(MAX_TASK_DRAIN_TTL_MS);
|
||||
});
|
||||
|
||||
it("an_expired_ttl_ends_the_drain_and_restores_admission", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
|
|
@ -71,43 +62,4 @@ describe("heartbeat task drain", () => {
|
|||
expect(status.quiescent).toBe(true);
|
||||
});
|
||||
|
||||
it("a_stale_restore_does_not_clobber_a_newer_concurrent_mutation", () => {
|
||||
// Simulate a route's own mutation, whose caller wants to roll it back
|
||||
// on a failed audit write.
|
||||
startTaskDrain({ ttlMs: null });
|
||||
const staleGeneration = getTaskDrainGeneration();
|
||||
|
||||
// A concurrent request supersedes that mutation with its own drain
|
||||
// before the first caller's rollback runs.
|
||||
const { startedAt: newerStartedAt } = startTaskDrain({ ttlMs: 5_000 });
|
||||
|
||||
const restored = restoreTaskDrainIfCurrent(staleGeneration, { draining: false, ttlMs: null });
|
||||
|
||||
expect(restored).toBe(false);
|
||||
const status = getTaskDrainStatus();
|
||||
expect(status.draining).toBe(true);
|
||||
expect(status.startedAt).toEqual(newerStartedAt);
|
||||
});
|
||||
|
||||
it("restore_applies_when_no_newer_mutation_happened", () => {
|
||||
startTaskDrain({ ttlMs: 10_000 });
|
||||
const generation = getTaskDrainGeneration();
|
||||
|
||||
const restored = restoreTaskDrainIfCurrent(generation, { draining: false, ttlMs: null });
|
||||
|
||||
expect(restored).toBe(true);
|
||||
expect(getTaskDrainStatus().draining).toBe(false);
|
||||
});
|
||||
|
||||
it("restore_reinstates_a_prior_active_drain", () => {
|
||||
startTaskDrain({ ttlMs: null });
|
||||
const generation = getTaskDrainGeneration();
|
||||
|
||||
const restored = restoreTaskDrainIfCurrent(generation, { draining: true, ttlMs: 30_000 });
|
||||
|
||||
expect(restored).toBe(true);
|
||||
const status = getTaskDrainStatus();
|
||||
expect(status.draining).toBe(true);
|
||||
expect(status.expiresAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,11 +14,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
|||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
buildIssueGraphLivenessAutoRecoveryPreview: vi.fn(),
|
||||
reconcileIssueGraphLiveness: vi.fn(),
|
||||
startTaskDrain: vi.fn(),
|
||||
computeTaskDrain: vi.fn(),
|
||||
applyTaskDrain: vi.fn(),
|
||||
stopTaskDrain: vi.fn(),
|
||||
getTaskDrainStatus: vi.fn(),
|
||||
getTaskDrainGeneration: vi.fn(),
|
||||
restoreTaskDrainIfCurrent: vi.fn(),
|
||||
}));
|
||||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
|
|
@ -87,11 +86,10 @@ describe("instance settings routes", () => {
|
|||
mockInstanceSettingsService.listCompanyIds.mockReset();
|
||||
mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockReset();
|
||||
mockHeartbeatService.reconcileIssueGraphLiveness.mockReset();
|
||||
mockHeartbeatService.startTaskDrain.mockReset();
|
||||
mockHeartbeatService.computeTaskDrain.mockReset();
|
||||
mockHeartbeatService.applyTaskDrain.mockReset();
|
||||
mockHeartbeatService.stopTaskDrain.mockReset();
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReset();
|
||||
mockHeartbeatService.getTaskDrainGeneration.mockReset();
|
||||
mockHeartbeatService.restoreTaskDrainIfCurrent.mockReset();
|
||||
mockEnvironmentService.getById.mockReset();
|
||||
mockEnvironmentService.findManagedSandboxEnvironment.mockReset();
|
||||
mockEnvironmentService.findManagedSandboxEnvironment.mockResolvedValue(null);
|
||||
|
|
@ -976,10 +974,9 @@ describe("instance settings routes", () => {
|
|||
// A drain the mock left active must not carry over into an unrelated
|
||||
// test, so every test starts from the idle status again.
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReset();
|
||||
mockHeartbeatService.startTaskDrain.mockReset();
|
||||
mockHeartbeatService.computeTaskDrain.mockReset();
|
||||
mockHeartbeatService.applyTaskDrain.mockReset();
|
||||
mockHeartbeatService.stopTaskDrain.mockReset();
|
||||
mockHeartbeatService.getTaskDrainGeneration.mockReset();
|
||||
mockHeartbeatService.restoreTaskDrainIfCurrent.mockReset();
|
||||
});
|
||||
|
||||
it("returns the idle status", async () => {
|
||||
|
|
@ -992,10 +989,9 @@ describe("instance settings routes", () => {
|
|||
expect(res.body).toEqual(idleStatus);
|
||||
});
|
||||
|
||||
it("starts a drain and writes an activity record for every company in one transaction", async () => {
|
||||
const startedAt = "2026-08-29T00:00:00.000Z";
|
||||
const expiresAt = "2026-08-29T06:00:00.000Z";
|
||||
mockHeartbeatService.startTaskDrain.mockReturnValue({ startedAt, expiresAt });
|
||||
it("writes an activity record for every company, then applies the same drain values, in one transaction", async () => {
|
||||
const drain = { startedAt: "2026-08-29T00:00:00.000Z", expiresAt: "2026-08-29T06:00:00.000Z" };
|
||||
mockHeartbeatService.computeTaskDrain.mockReturnValue(drain);
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app)
|
||||
|
|
@ -1003,52 +999,71 @@ describe("instance settings routes", () => {
|
|||
.send({ ttlMs: 21_600_000 });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.startTaskDrain).toHaveBeenCalledWith({ ttlMs: 21_600_000 });
|
||||
expect(res.body).toEqual(drain);
|
||||
expect(mockHeartbeatService.computeTaskDrain).toHaveBeenCalledWith({ ttlMs: 21_600_000 });
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
for (const call of mockLogActivity.mock.calls) {
|
||||
expect(call[0]).toBe(TX_SENTINEL);
|
||||
expect(call[1]).toMatchObject({ action: "instance.task_drain.started" });
|
||||
expect(call[1]).toMatchObject({
|
||||
action: "instance.task_drain.started",
|
||||
details: { startedAt: drain.startedAt, expiresAt: drain.expiresAt },
|
||||
});
|
||||
}
|
||||
// Publish only runs after the shared transaction commits.
|
||||
// The mutation applies the same values the audit rows already carry,
|
||||
// and only after the shared transaction commits.
|
||||
expect(mockHeartbeatService.applyTaskDrain).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.applyTaskDrain).toHaveBeenCalledWith(drain);
|
||||
expect(mockPublishActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("starts an indefinite drain when the caller sends no ttlMs", async () => {
|
||||
mockHeartbeatService.startTaskDrain.mockReturnValue({ startedAt: "2026-08-29T00:00:00.000Z", expiresAt: null });
|
||||
mockHeartbeatService.computeTaskDrain.mockReturnValue({ startedAt: "2026-08-29T00:00:00.000Z", expiresAt: null });
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app).post("/api/instance/task-drain").send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.startTaskDrain).toHaveBeenCalledWith({ ttlMs: null });
|
||||
expect(mockHeartbeatService.computeTaskDrain).toHaveBeenCalledWith({ ttlMs: null });
|
||||
});
|
||||
|
||||
it("ends the drain and writes an activity record for every company in one transaction", async () => {
|
||||
mockHeartbeatService.stopTaskDrain.mockReturnValue({ wasActive: true });
|
||||
it("writes an activity record for every company, then stops the drain, using one wasActive value throughout", async () => {
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue({
|
||||
draining: true,
|
||||
startedAt: new Date(),
|
||||
expiresAt: null,
|
||||
activeRuns: 0,
|
||||
pendingWakes: 0,
|
||||
quiescent: true,
|
||||
});
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app).delete("/api/instance/task-drain");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockHeartbeatService.stopTaskDrain).toHaveBeenCalledWith();
|
||||
expect(res.body).toEqual({ wasActive: true });
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
for (const call of mockLogActivity.mock.calls) {
|
||||
expect(call[0]).toBe(TX_SENTINEL);
|
||||
expect(call[1]).toMatchObject({ action: "instance.task_drain.stopped" });
|
||||
expect(call[1]).toMatchObject({
|
||||
action: "instance.task_drain.stopped",
|
||||
details: { wasActive: true },
|
||||
});
|
||||
}
|
||||
// The mutation runs only after the shared transaction commits.
|
||||
expect(mockHeartbeatService.stopTaskDrain).toHaveBeenCalledWith();
|
||||
expect(mockPublishActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not start a drain when the company list read fails", async () => {
|
||||
it("does not apply a drain when the company list read fails", async () => {
|
||||
mockInstanceSettingsService.listCompanyIds.mockRejectedValue(new Error("db unavailable"));
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app).post("/api/instance/task-drain").send({});
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(500);
|
||||
expect(mockHeartbeatService.startTaskDrain).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("commits no activity record for any company when one company's audit write fails", async () => {
|
||||
|
|
@ -1057,8 +1072,7 @@ describe("instance settings routes", () => {
|
|||
// SAME transaction (rather than firing one independent write per
|
||||
// company) and never publishes a record for the company that did
|
||||
// succeed before the shared transaction rejected.
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus);
|
||||
mockHeartbeatService.startTaskDrain.mockReturnValue({
|
||||
mockHeartbeatService.computeTaskDrain.mockReturnValue({
|
||||
startedAt: "2026-08-29T00:00:00.000Z",
|
||||
expiresAt: null,
|
||||
});
|
||||
|
|
@ -1079,73 +1093,26 @@ describe("instance settings routes", () => {
|
|||
expect(mockPublishActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reverts the drain when the activity log write fails", async () => {
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus);
|
||||
mockHeartbeatService.startTaskDrain.mockReturnValue({
|
||||
it("does not apply the drain when the audit transaction rejects", async () => {
|
||||
mockHeartbeatService.computeTaskDrain.mockReturnValue({
|
||||
startedAt: "2026-08-29T00:00:00.000Z",
|
||||
expiresAt: null,
|
||||
});
|
||||
mockHeartbeatService.getTaskDrainGeneration.mockReturnValue(3);
|
||||
mockLogActivity.mockRejectedValue(new Error("activity insert failed"));
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app).post("/api/instance/task-drain").send({});
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(500);
|
||||
expect(mockHeartbeatService.startTaskDrain).toHaveBeenCalledTimes(1);
|
||||
// The rollback restores through the generation-guarded primitive
|
||||
// (not a direct start/stop call), so a concurrent mutation that
|
||||
// superseded this one after the generation was stamped is never
|
||||
// clobbered by this restore.
|
||||
expect(mockHeartbeatService.restoreTaskDrainIfCurrent).toHaveBeenCalledWith(3, {
|
||||
draining: false,
|
||||
ttlMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the prior drain when a POST over an active drain fails to write its audit record", async () => {
|
||||
const priorExpiresAt = new Date(Date.now() + 60_000);
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue({
|
||||
draining: true,
|
||||
startedAt: new Date(Date.now() - 60_000),
|
||||
expiresAt: priorExpiresAt,
|
||||
activeRuns: 0,
|
||||
pendingWakes: 0,
|
||||
quiescent: true,
|
||||
});
|
||||
mockHeartbeatService.startTaskDrain.mockReturnValue({
|
||||
startedAt: "2026-08-29T00:00:00.000Z",
|
||||
expiresAt: "2026-08-29T06:00:00.000Z",
|
||||
});
|
||||
mockHeartbeatService.getTaskDrainGeneration.mockReturnValue(9);
|
||||
mockLogActivity.mockRejectedValue(new Error("activity insert failed"));
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/instance/task-drain")
|
||||
.send({ ttlMs: 21_600_000 });
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(500);
|
||||
expect(mockHeartbeatService.stopTaskDrain).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.startTaskDrain).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.restoreTaskDrainIfCurrent).toHaveBeenCalledTimes(1);
|
||||
const [generationArg, restoreArg] = mockHeartbeatService.restoreTaskDrainIfCurrent.mock.calls[0];
|
||||
expect(generationArg).toBe(9);
|
||||
expect(restoreArg.draining).toBe(true);
|
||||
expect(restoreArg.ttlMs).toBeGreaterThan(0);
|
||||
expect(restoreArg.ttlMs).toBeLessThanOrEqual(60_000);
|
||||
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still reports the started drain when publishing its committed audit record fails", async () => {
|
||||
// The audit row already committed by the time publish runs, so a
|
||||
// publish failure must not roll the in-memory drain back — that
|
||||
// would desync it from the row a client can already read. It also
|
||||
// must not turn the response into a false failure: the caller asked
|
||||
// to start a drain, and the drain did start.
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus);
|
||||
// publish failure must not turn the response into a false failure:
|
||||
// the caller asked to start a drain, and the drain did start.
|
||||
const drain = { startedAt: "2026-08-29T00:00:00.000Z", expiresAt: null };
|
||||
mockHeartbeatService.startTaskDrain.mockReturnValue(drain);
|
||||
mockHeartbeatService.getTaskDrainGeneration.mockReturnValue(5);
|
||||
mockHeartbeatService.computeTaskDrain.mockReturnValue(drain);
|
||||
mockPublishActivity.mockImplementation(() => {
|
||||
throw new Error("live event bus unavailable");
|
||||
});
|
||||
|
|
@ -1157,7 +1124,7 @@ describe("instance settings routes", () => {
|
|||
expect(res.body).toEqual(drain);
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
expect(mockHeartbeatService.restoreTaskDrainIfCurrent).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.applyTaskDrain).toHaveBeenCalledWith(drain);
|
||||
});
|
||||
|
||||
it("does not stop the drain when the company list read fails", async () => {
|
||||
|
|
@ -1170,46 +1137,33 @@ describe("instance settings routes", () => {
|
|||
expect(mockHeartbeatService.stopTaskDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores an active drain when the activity log write fails", async () => {
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
it("does not stop the drain when the audit transaction rejects", async () => {
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue({
|
||||
draining: true,
|
||||
startedAt: new Date(),
|
||||
expiresAt,
|
||||
expiresAt: null,
|
||||
activeRuns: 0,
|
||||
pendingWakes: 0,
|
||||
quiescent: true,
|
||||
});
|
||||
mockHeartbeatService.stopTaskDrain.mockReturnValue({ wasActive: true });
|
||||
mockHeartbeatService.getTaskDrainGeneration.mockReturnValue(11);
|
||||
mockLogActivity.mockRejectedValue(new Error("activity insert failed"));
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
const res = await request(app).delete("/api/instance/task-drain");
|
||||
|
||||
expect(res.status).toBeGreaterThanOrEqual(500);
|
||||
expect(mockHeartbeatService.stopTaskDrain).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.startTaskDrain).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.restoreTaskDrainIfCurrent).toHaveBeenCalledTimes(1);
|
||||
const [generationArg, restoreArg] = mockHeartbeatService.restoreTaskDrainIfCurrent.mock.calls[0];
|
||||
expect(generationArg).toBe(11);
|
||||
expect(restoreArg.draining).toBe(true);
|
||||
expect(restoreArg.ttlMs).toBeGreaterThan(0);
|
||||
expect(restoreArg.ttlMs).toBeLessThanOrEqual(60_000);
|
||||
expect(mockHeartbeatService.stopTaskDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still reports the stopped drain when publishing its committed audit record fails", async () => {
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue({
|
||||
draining: true,
|
||||
startedAt: new Date(),
|
||||
expiresAt,
|
||||
expiresAt: null,
|
||||
activeRuns: 0,
|
||||
pendingWakes: 0,
|
||||
quiescent: true,
|
||||
});
|
||||
mockHeartbeatService.stopTaskDrain.mockReturnValue({ wasActive: true });
|
||||
mockHeartbeatService.getTaskDrainGeneration.mockReturnValue(7);
|
||||
mockPublishActivity.mockImplementation(() => {
|
||||
throw new Error("live event bus unavailable");
|
||||
});
|
||||
|
|
@ -1221,16 +1175,14 @@ describe("instance settings routes", () => {
|
|||
expect(res.body).toEqual({ wasActive: true });
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
expect(mockHeartbeatService.restoreTaskDrainIfCurrent).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.stopTaskDrain).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("still publishes the second company's record when the first company's publish fails", async () => {
|
||||
// Each committed audit record publishes independently, so one
|
||||
// company's publish failure must not stop the rest from publishing.
|
||||
const drain = { startedAt: "2026-08-29T00:00:00.000Z", expiresAt: null };
|
||||
mockHeartbeatService.getTaskDrainStatus.mockReturnValue(idleStatus);
|
||||
mockHeartbeatService.startTaskDrain.mockReturnValue(drain);
|
||||
mockHeartbeatService.getTaskDrainGeneration.mockReturnValue(5);
|
||||
mockHeartbeatService.computeTaskDrain.mockReturnValue(drain);
|
||||
mockPublishActivity.mockImplementation((publication: { companyId: string }) => {
|
||||
if (publication.companyId === "company-1") throw new Error("live event bus unavailable");
|
||||
});
|
||||
|
|
@ -1247,13 +1199,96 @@ describe("instance settings routes", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("queues an overlapping DELETE behind a POST whose audit transaction is still pending, so the audit order and the live state always agree", async () => {
|
||||
// Model the real drain state instead of a canned return value, so
|
||||
// this test can prove the applied state, the audit rows, and the
|
||||
// response all agree even when the earlier request's transaction
|
||||
// resolves after the later request's would have.
|
||||
const liveState = { draining: false };
|
||||
mockHeartbeatService.computeTaskDrain.mockReturnValue({
|
||||
startedAt: "2026-08-29T00:00:00.000Z",
|
||||
expiresAt: null,
|
||||
});
|
||||
mockHeartbeatService.applyTaskDrain.mockImplementation(() => {
|
||||
liveState.draining = true;
|
||||
});
|
||||
mockHeartbeatService.stopTaskDrain.mockImplementation(() => {
|
||||
const wasActive = liveState.draining;
|
||||
liveState.draining = false;
|
||||
return { wasActive };
|
||||
});
|
||||
mockHeartbeatService.getTaskDrainStatus.mockImplementation(() => ({
|
||||
draining: liveState.draining,
|
||||
startedAt: liveState.draining ? "2026-08-29T00:00:00.000Z" : null,
|
||||
expiresAt: null,
|
||||
activeRuns: 0,
|
||||
pendingWakes: 0,
|
||||
quiescent: true,
|
||||
}));
|
||||
|
||||
// The POST's transaction call blocks until the test releases it. The
|
||||
// DELETE's transaction call, if it is ever reached, commits at once —
|
||||
// so if the route let the two requests race, the DELETE would commit
|
||||
// its audit row, and reach stopTaskDrain, first.
|
||||
const transactionCalls: string[] = [];
|
||||
let releasePostTransaction: (() => void) | undefined;
|
||||
let sawFirstCall = false;
|
||||
mockDb.transaction.mockImplementation((fn: (tx: unknown) => Promise<unknown>) => {
|
||||
if (!sawFirstCall) {
|
||||
sawFirstCall = true;
|
||||
transactionCalls.push("post-start");
|
||||
return new Promise((resolve) => {
|
||||
releasePostTransaction = () => {
|
||||
transactionCalls.push("post-commit");
|
||||
resolve(fn(TX_SENTINEL));
|
||||
};
|
||||
});
|
||||
}
|
||||
transactionCalls.push("delete-start-and-commit");
|
||||
return fn(TX_SENTINEL);
|
||||
});
|
||||
|
||||
const app = await createApp(adminActor);
|
||||
|
||||
// supertest only sends the request once something calls .then() on
|
||||
// it, so kick both off eagerly instead of waiting for the final
|
||||
// Promise.all below to do it — otherwise neither request would even
|
||||
// reach the (still-pending) POST transaction during the wait.
|
||||
const postPromise = request(app).post("/api/instance/task-drain").send({});
|
||||
postPromise.then(() => {}, () => {});
|
||||
const deletePromise = request(app).delete("/api/instance/task-drain");
|
||||
deletePromise.then(() => {}, () => {});
|
||||
// Give both requests time to reach as far as they can go before the
|
||||
// POST's transaction is released.
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(transactionCalls).toEqual(["post-start"]);
|
||||
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.stopTaskDrain).not.toHaveBeenCalled();
|
||||
|
||||
releasePostTransaction?.();
|
||||
const [postRes, deleteRes] = await Promise.all([postPromise, deletePromise]);
|
||||
|
||||
expect(postRes.status).toBe(200);
|
||||
expect(deleteRes.status).toBe(200);
|
||||
// The DELETE's transaction only starts once the POST's has committed
|
||||
// and the POST has already applied its drain — audit order matches
|
||||
// application order.
|
||||
expect(transactionCalls).toEqual(["post-start", "post-commit", "delete-start-and-commit"]);
|
||||
// The DELETE observed the drain the POST applied, so its audit row
|
||||
// and its response correctly report an active drain, and the live
|
||||
// state ends idle — not stuck "draining" from a stale POST that
|
||||
// applied after the DELETE that was meant to be the final word.
|
||||
expect(deleteRes.body).toEqual({ wasActive: true });
|
||||
expect(liveState.draining).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a board actor without instance admin rights", async () => {
|
||||
const app = await createApp(nonAdminActor);
|
||||
|
||||
const res = await request(app).post("/api/instance/task-drain").send({});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockHeartbeatService.startTaskDrain).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a ttl above the maximum", async () => {
|
||||
|
|
@ -1264,7 +1299,7 @@ describe("instance settings routes", () => {
|
|||
.send({ ttlMs: 24 * 60 * 60 * 1000 + 1 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockHeartbeatService.startTaskDrain).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a zero or negative ttl", async () => {
|
||||
|
|
@ -1276,7 +1311,7 @@ describe("instance settings routes", () => {
|
|||
const negativeRes = await request(app).post("/api/instance/task-drain").send({ ttlMs: -1 });
|
||||
expect(negativeRes.status).toBe(400);
|
||||
|
||||
expect(mockHeartbeatService.startTaskDrain).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,6 +88,31 @@ function assertCanManageInstanceSettings(req: Request) {
|
|||
throw forbidden("Instance admin access required");
|
||||
}
|
||||
|
||||
// A task-drain start or stop reads the live drain state, writes an audit
|
||||
// transaction, and only then mutates the process-local drain state. The
|
||||
// audit write is an async gap: two overlapping requests can commit their
|
||||
// transactions in one order but reach the in-memory mutation in the other
|
||||
// order, so a stale transition would win, the audit log would not match the
|
||||
// live state, and the response for the newer request would not match what
|
||||
// actually ended up live. Run each request's whole read-audit-apply
|
||||
// sequence through this queue so overlapping requests execute one at a
|
||||
// time, in the order they enter it: audit order and apply order then always
|
||||
// agree, and each response reports exactly the state its own request
|
||||
// produced.
|
||||
let taskDrainTransitionQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function withTaskDrainTransition<T>(run: () => Promise<T>): Promise<T> {
|
||||
const turn = taskDrainTransitionQueue.then(run);
|
||||
// Normalize to a settled void promise for the next caller in line, so a
|
||||
// rejected transition (a failed audit write, for example) cannot wedge
|
||||
// every later transition behind it.
|
||||
taskDrainTransitionQueue = turn.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return turn;
|
||||
}
|
||||
|
||||
export function instanceSettingsRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = instanceSettingsService(db);
|
||||
|
|
@ -326,26 +351,23 @@ export function instanceSettingsRoutes(db: Db) {
|
|||
async (req, res) => {
|
||||
assertCanManageInstanceSettings(req);
|
||||
const actor = getActorInfo(req);
|
||||
// Read the company list, an operation that can fail, before the
|
||||
// process-local drain mutation below, so a failed read never leaves
|
||||
// that mutation in place with no audit record of it.
|
||||
const companyIds = await svc.listCompanyIds();
|
||||
// A POST over an already-active drain replaces it. Capture that prior
|
||||
// state before the mutation, so a failed audit write below can restore
|
||||
// it instead of clearing task-drain state the operator still relies on.
|
||||
const priorStatus = heartbeat.getTaskDrainStatus();
|
||||
const drain = heartbeat.startTaskDrain({ ttlMs: req.body.ttlMs ?? null });
|
||||
// Stamp the generation right after this call's own mutation (no
|
||||
// await runs between the two, so nothing else can mutate the drain
|
||||
// in between), so a later restore can tell whether a concurrent
|
||||
// request has already superseded it.
|
||||
const generation = heartbeat.getTaskDrainGeneration();
|
||||
// One transaction for every company's audit row, so a write that
|
||||
// succeeds for one company and fails for another never leaves a
|
||||
// partial activity history behind — either every company gets the
|
||||
// record, or none does.
|
||||
const postCommitActivityPublications: ActivityPublication[] = [];
|
||||
try {
|
||||
const ttlMs = req.body.ttlMs ?? null;
|
||||
// The whole read-audit-apply sequence runs as one queued transition
|
||||
// (see withTaskDrainTransition above), so an overlapping start or
|
||||
// stop cannot commit its audit row, or apply its live state, out of
|
||||
// order against this one. computeTaskDrain runs inside the turn so
|
||||
// startedAt reflects the moment this request actually took effect,
|
||||
// not the moment it arrived and was queued behind another transition.
|
||||
const drain = await withTaskDrainTransition(async () => {
|
||||
const computed = heartbeat.computeTaskDrain({ ttlMs });
|
||||
// One transaction for every company's audit row, so a write that
|
||||
// succeeds for one company and fails for another never leaves a
|
||||
// partial activity history behind — either every company gets the
|
||||
// record, or none does. The drain mutation below runs only after
|
||||
// this transaction commits, so a failed write leaves the live
|
||||
// drain untouched and there is no partial state to roll back.
|
||||
const postCommitActivityPublications: ActivityPublication[] = [];
|
||||
await db.transaction((tx) =>
|
||||
Promise.all(
|
||||
companyIds.map((companyId) =>
|
||||
|
|
@ -360,38 +382,22 @@ export function instanceSettingsRoutes(db: Db) {
|
|||
entityType: "instance_settings",
|
||||
entityId: "default",
|
||||
details: {
|
||||
startedAt: drain.startedAt,
|
||||
expiresAt: drain.expiresAt,
|
||||
startedAt: computed.startedAt,
|
||||
expiresAt: computed.expiresAt,
|
||||
},
|
||||
}, postCommitActivityPublications),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
// The audit record did not commit, so undo the in-memory drain this
|
||||
// call started. If a drain was already active, this call replaced
|
||||
// it — restore that prior drain (best-effort: the remaining TTL
|
||||
// carries over, but the original start time does not) instead of
|
||||
// clearing task-drain state the operator still relies on. Guard the
|
||||
// restore with the generation stamped above: if a concurrent
|
||||
// request has already mutated the drain again, this restore must
|
||||
// not overwrite that newer state with the state captured here.
|
||||
const remainingTtlMs = priorStatus.expiresAt
|
||||
? Math.max(0, priorStatus.expiresAt.getTime() - Date.now())
|
||||
: null;
|
||||
heartbeat.restoreTaskDrainIfCurrent(generation, {
|
||||
draining: priorStatus.draining,
|
||||
ttlMs: remainingTtlMs,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
// The audit record already committed, so a failure to publish it here
|
||||
// is not a reason to undo the drain: reverting the in-memory state at
|
||||
// this point would desync it from the committed row. Publish outside
|
||||
// the try above so this failure cannot reach the restore path, and
|
||||
// swallow a publish failure so it cannot turn a committed mutation
|
||||
// into a false 500 either.
|
||||
publishActivitiesBestEffort(postCommitActivityPublications, "instance.task_drain.started");
|
||||
heartbeat.applyTaskDrain(computed);
|
||||
// The audit record already committed, so a failure to publish it
|
||||
// here is not a reason to undo the drain: reverting the in-memory
|
||||
// state at this point would desync it from the committed row.
|
||||
// Swallow a publish failure so it cannot turn a committed mutation
|
||||
// into a false 500.
|
||||
publishActivitiesBestEffort(postCommitActivityPublications, "instance.task_drain.started");
|
||||
return computed;
|
||||
});
|
||||
res.json(drain);
|
||||
},
|
||||
);
|
||||
|
|
@ -400,13 +406,21 @@ export function instanceSettingsRoutes(db: Db) {
|
|||
assertCanManageInstanceSettings(req);
|
||||
const actor = getActorInfo(req);
|
||||
const companyIds = await svc.listCompanyIds();
|
||||
const priorStatus = heartbeat.getTaskDrainStatus();
|
||||
const result = heartbeat.stopTaskDrain();
|
||||
// See the POST handler above for why the generation is stamped here.
|
||||
const generation = heartbeat.getTaskDrainGeneration();
|
||||
// See the POST handler above for why this is one transaction.
|
||||
const postCommitActivityPublications: ActivityPublication[] = [];
|
||||
try {
|
||||
// See the POST handler above for why the whole read-audit-apply
|
||||
// sequence runs inside withTaskDrainTransition: it queues this stop
|
||||
// behind any transition already in flight, so it cannot read a status
|
||||
// an overlapping request is about to make stale, and its audit row and
|
||||
// its live-state mutation always land in the same order as every other
|
||||
// queued transition.
|
||||
const wasActive = await withTaskDrainTransition(async () => {
|
||||
const priorStatus = heartbeat.getTaskDrainStatus();
|
||||
// Read wasActive once, here, and use this same value for the audit
|
||||
// detail and the response body below. A TTL that expires between two
|
||||
// separate reads would otherwise make the two values disagree.
|
||||
const wasActive = priorStatus.draining;
|
||||
// See the POST handler above for why this is one transaction, and why
|
||||
// the drain mutation runs only after it commits.
|
||||
const postCommitActivityPublications: ActivityPublication[] = [];
|
||||
await db.transaction((tx) =>
|
||||
Promise.all(
|
||||
companyIds.map((companyId) =>
|
||||
|
|
@ -421,33 +435,22 @@ export function instanceSettingsRoutes(db: Db) {
|
|||
entityType: "instance_settings",
|
||||
entityId: "default",
|
||||
details: {
|
||||
wasActive: result.wasActive,
|
||||
wasActive,
|
||||
},
|
||||
}, postCommitActivityPublications),
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
// Restore the drain this call ended (best-effort: the remaining TTL
|
||||
// carries over, but the original start time does not) so a failed
|
||||
// audit write does not silently end a drain the operator still relies
|
||||
// on to hold new run admission. See the POST handler above for why
|
||||
// the restore is guarded by the generation stamped above.
|
||||
if (priorStatus.draining) {
|
||||
const remainingTtlMs = priorStatus.expiresAt
|
||||
? Math.max(0, priorStatus.expiresAt.getTime() - Date.now())
|
||||
: null;
|
||||
heartbeat.restoreTaskDrainIfCurrent(generation, { draining: true, ttlMs: remainingTtlMs });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// See the POST handler above for why publish runs outside the try, and
|
||||
// why a publish failure here is swallowed instead of failing the route:
|
||||
// the audit record already committed, so a publish failure here must
|
||||
// not undo a drain-stop that is already correct in the database, and
|
||||
// must not report the stop as failed when it succeeded.
|
||||
publishActivitiesBestEffort(postCommitActivityPublications, "instance.task_drain.stopped");
|
||||
res.json(result);
|
||||
heartbeat.stopTaskDrain();
|
||||
// See the POST handler above for why a publish failure here is
|
||||
// swallowed instead of failing the route: the audit record already
|
||||
// committed, so a publish failure here must not undo a drain-stop
|
||||
// that is already correct in the database, and must not report the
|
||||
// stop as failed when it succeeded.
|
||||
publishActivitiesBestEffort(postCommitActivityPublications, "instance.task_drain.stopped");
|
||||
return wasActive;
|
||||
});
|
||||
res.json({ wasActive });
|
||||
});
|
||||
|
||||
return router;
|
||||
|
|
|
|||
|
|
@ -4206,7 +4206,7 @@ registry.registerPath({
|
|||
method: "get",
|
||||
path: "/api/instance/task-drain",
|
||||
tags: ["instance"],
|
||||
summary: "Get the task-drain status",
|
||||
summary: "Get the task-drain status for this process only; quiescent counts in-process work, and a process restart clears it even when the database still holds running rows",
|
||||
responses: { 200: r.ok(), 401: r.unauthorized },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
CONNECTION_RUNTIME_TOOL_NAMES,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
ISSUE_DISPOSITION_REPAIR_RETRY_REASON,
|
||||
MAX_TASK_DRAIN_TTL_MS,
|
||||
MODEL_PROFILE_KEYS,
|
||||
PROVIDER_QUOTA_MONITOR_SERVICE_NAME,
|
||||
envBindingSchema,
|
||||
|
|
@ -866,38 +865,6 @@ const activeRunExecutionPromises = new Set<Promise<void>>();
|
|||
// can await a wake that is still before run registration. A caller that tears
|
||||
// down a shared database (a test afterEach) then cannot race a late wake.
|
||||
const activeWakeupPromises = new Set<Promise<unknown>>();
|
||||
// executeRun's task-drain suppression branch can fail to release a run's
|
||||
// claim two ways in a row: the atomic release transaction fails, then its
|
||||
// fallback transaction (which marks the run "failed" instead) also fails.
|
||||
// When that happens the run, wakeup, and issue lock are left in an unknown
|
||||
// durable state. The dispatch site still removes this run's execution
|
||||
// promise from activeRunExecutionPromises once executeRun settles —
|
||||
// drainActiveRunExecutions loops on that set's size, so an entry that never
|
||||
// clears would hang it forever — so a promise-based set cannot carry this
|
||||
// signal. Track the runId here instead. getTaskDrainStatus() folds this set
|
||||
// into its active-run count, so it keeps reporting a non-quiescent instance
|
||||
// for this run. There is no in-process retry for a fallback that already
|
||||
// failed once, so the run's row stays "running" until reapOrphanedRuns picks
|
||||
// it up as an orphan and finalizes it — that is also where this marker gets
|
||||
// removed, right after the reaper finishes the durable issue-lock cleanup
|
||||
// for the run (releaseIssueExecutionAndPromote, or handing the run's pending
|
||||
// work to a retry), and before any later, lock-unrelated cleanup runs. An
|
||||
// entry here only outlives that reap, and so only clears on a process
|
||||
// restart, if the reaper itself never runs again for this run, or if
|
||||
// classification, retry scheduling, or the lock release itself keeps
|
||||
// failing for it.
|
||||
const stuckClaimReleaseRunIds = new Set<string>();
|
||||
// Thrown by executeRun's task-drain suppression branch when both the atomic
|
||||
// claim release and its fallback fail a durable write for the same run. The
|
||||
// dispatch site catches this to add the run to stuckClaimReleaseRunIds
|
||||
// instead of treating it as an ordinary execution failure.
|
||||
class RunClaimReleaseUnresolvedFailure extends Error {
|
||||
constructor(runId: string, cause: unknown) {
|
||||
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
||||
super(`Run claim release failed durably for run ${runId}: ${causeMessage}`);
|
||||
this.name = "RunClaimReleaseUnresolvedFailure";
|
||||
}
|
||||
}
|
||||
// Task drain: an operator-controlled hold on new run admission, so a caller
|
||||
// can wait for active work to finish before it stops the process. The state
|
||||
// lives in process memory only — a process restart clears it — and it sits at
|
||||
|
|
@ -905,13 +872,6 @@ class RunClaimReleaseUnresolvedFailure extends Error {
|
|||
// resolveHeartbeatSchedulingSuppression() check and every heartbeatService()
|
||||
// instance see the same drain.
|
||||
let taskDrainState: { startedAt: Date; expiresAt: Date | null } | null = null;
|
||||
// Bumped on every explicit task-drain mutation (a start or a stop), so a
|
||||
// caller can tell whether a later mutation has already superseded its own.
|
||||
// A route rollback reads this right after its own mutation, then passes it
|
||||
// to restoreTaskDrainIfCurrent() before it restores prior state on a failed
|
||||
// audit write — so the rollback never overwrites a newer concurrent
|
||||
// mutation with stale state.
|
||||
let taskDrainGeneration = 0;
|
||||
|
||||
function readTaskDrain(now: Date): { startedAt: Date; expiresAt: Date | null } | null {
|
||||
if (taskDrainState && taskDrainState.expiresAt !== null && taskDrainState.expiresAt.getTime() <= now.getTime()) {
|
||||
|
|
@ -920,45 +880,37 @@ function readTaskDrain(now: Date): { startedAt: Date; expiresAt: Date | null } |
|
|||
return taskDrainState;
|
||||
}
|
||||
|
||||
export function startTaskDrain(opts: { ttlMs?: number | null } = {}): { startedAt: Date; expiresAt: Date | null } {
|
||||
/** Compute the drain a start call would apply, without changing state. */
|
||||
export function computeTaskDrain(opts: { ttlMs?: number | null } = {}): { startedAt: Date; expiresAt: Date | null } {
|
||||
const startedAt = new Date();
|
||||
const ttlMs = opts.ttlMs ?? null;
|
||||
const expiresAt = ttlMs === null ? null : new Date(startedAt.getTime() + Math.min(ttlMs, MAX_TASK_DRAIN_TTL_MS));
|
||||
taskDrainState = { startedAt, expiresAt };
|
||||
taskDrainGeneration += 1;
|
||||
return taskDrainState;
|
||||
const expiresAt = ttlMs === null ? null : new Date(startedAt.getTime() + ttlMs);
|
||||
return { startedAt, expiresAt };
|
||||
}
|
||||
|
||||
/** Assign the given drain as the current task-drain state. */
|
||||
export function applyTaskDrain(drain: { startedAt: Date; expiresAt: Date | null }): void {
|
||||
taskDrainState = drain;
|
||||
}
|
||||
|
||||
export function startTaskDrain(opts: { ttlMs?: number | null } = {}): { startedAt: Date; expiresAt: Date | null } {
|
||||
const drain = computeTaskDrain(opts);
|
||||
applyTaskDrain(drain);
|
||||
return drain;
|
||||
}
|
||||
|
||||
export function stopTaskDrain(): { wasActive: boolean } {
|
||||
const wasActive = readTaskDrain(new Date()) !== null;
|
||||
taskDrainState = null;
|
||||
taskDrainGeneration += 1;
|
||||
return { wasActive };
|
||||
}
|
||||
|
||||
/** The current task-drain mutation count. See taskDrainGeneration above. */
|
||||
export function getTaskDrainGeneration(): number {
|
||||
return taskDrainGeneration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a captured task-drain state, but only if no other mutation has
|
||||
* happened since expectedGeneration was read. Returns false, and leaves the
|
||||
* current state untouched, when a newer mutation has already superseded it.
|
||||
* Report the task-drain state for this process only. `activeRuns` and
|
||||
* `pendingWakes` count in-process work. A process restart clears both
|
||||
* counters, even when the database still holds `running` rows for runs
|
||||
* this process did not finish.
|
||||
*/
|
||||
export function restoreTaskDrainIfCurrent(
|
||||
expectedGeneration: number,
|
||||
restore: { draining: boolean; ttlMs: number | null },
|
||||
): boolean {
|
||||
if (taskDrainGeneration !== expectedGeneration) return false;
|
||||
if (restore.draining) {
|
||||
startTaskDrain({ ttlMs: restore.ttlMs });
|
||||
} else {
|
||||
stopTaskDrain();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getTaskDrainStatus(): {
|
||||
draining: boolean;
|
||||
startedAt: Date | null;
|
||||
|
|
@ -968,10 +920,7 @@ export function getTaskDrainStatus(): {
|
|||
quiescent: boolean;
|
||||
} {
|
||||
const state = readTaskDrain(new Date());
|
||||
// Fold in stuckClaimReleaseRunIds so a run whose claim release failed
|
||||
// durably (see the set's own comment) keeps this read non-quiescent, even
|
||||
// though its execution promise already left activeRunExecutionPromises.
|
||||
const activeRuns = activeRunExecutionPromises.size + stuckClaimReleaseRunIds.size;
|
||||
const activeRuns = activeRunExecutionPromises.size;
|
||||
const pendingWakes = activeWakeupPromises.size;
|
||||
return {
|
||||
draining: state !== null,
|
||||
|
|
@ -13114,91 +13063,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
});
|
||||
}
|
||||
|
||||
// Fallback for when the atomic release above itself fails (a genuine write
|
||||
// error, not a normal no-op). executeRun's caller removes this run's
|
||||
// promise from activeRunExecutionPromises as soon as executeRun settles,
|
||||
// whether it resolves or rejects — so task-drain quiescence is about to
|
||||
// read "no active runs" regardless of what happens here. If the run,
|
||||
// wakeup, and issue lock stayed at "running"/"claimed"/locked, that read
|
||||
// would be false: the database would still hold a claim nothing is
|
||||
// tracking anymore. Fail the run outright instead, so the database
|
||||
// reaches the same "not active" conclusion active tracking already
|
||||
// reached. This does not retry the "queued" release: a run that could not
|
||||
// even release cleanly is treated as failed, not requeued.
|
||||
//
|
||||
// The run row, the wakeup request, and the issue execution lock all guard
|
||||
// the same claim, so — same as the atomic release above — one transaction
|
||||
// commits all three writes together. A partial write here would leave the
|
||||
// same false-quiescence gap this fallback exists to close. Live-event and
|
||||
// plugin-event publishing run after the transaction commits, so a publish
|
||||
// failure cannot roll back the durable claim writes.
|
||||
//
|
||||
// The update below carries the same status: "running" condition the atomic
|
||||
// release above uses. While this fallback waits, a concurrent path (a
|
||||
// cancellation, the orphan reaper) can move the run to a terminal status.
|
||||
// Without the condition this update would match that row and overwrite its
|
||||
// real outcome. With it, the update matches no row, so the function returns
|
||||
// early below and leaves the terminal run, its wakeup request, and its
|
||||
// issue lock untouched.
|
||||
async function failRunClaimedJustBeforeSuppression(runId: string, cause: unknown) {
|
||||
const now = new Date();
|
||||
const causeMessage = cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const failed = await db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
status: "failed",
|
||||
finishedAt: now,
|
||||
error: `Failed to release the run claim before task-drain suppression: ${causeMessage}`,
|
||||
errorCode: "claim_release_failed",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.status, "running")))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
|
||||
if (updated.wakeupRequestId) {
|
||||
await tx
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
status: "failed",
|
||||
finishedAt: now,
|
||||
error: "Run claim release failed before task-drain suppression",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(agentWakeupRequests.id, updated.wakeupRequestId));
|
||||
}
|
||||
|
||||
const context = parseObject(updated.contextSnapshot);
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
if (issueId) {
|
||||
await tx
|
||||
.update(issues)
|
||||
.set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt: now })
|
||||
.where(and(
|
||||
eq(issues.id, issueId),
|
||||
eq(issues.companyId, updated.companyId),
|
||||
eq(issues.executionRunId, updated.id),
|
||||
));
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
if (!failed) return;
|
||||
|
||||
if (isHeartbeatRunTerminalStatus(failed.status)) {
|
||||
clearHeartbeatRunRuntimeStatus(failed.id);
|
||||
}
|
||||
publishLiveEvent({
|
||||
companyId: failed.companyId,
|
||||
type: "heartbeat.run.status",
|
||||
payload: buildHeartbeatRunStatusLiveEventPayload(failed),
|
||||
});
|
||||
publishRunLifecyclePluginEvent(failed);
|
||||
}
|
||||
|
||||
async function cancelQueuedRunForBlockedDependencies(
|
||||
run: typeof heartbeatRuns.$inferSelect,
|
||||
issueId: string,
|
||||
|
|
@ -14238,19 +14102,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
if (!retriedRun) {
|
||||
await releaseIssueExecutionAndPromote(finalizedRun);
|
||||
}
|
||||
// The run's row reached a terminal status above, and the block above
|
||||
// just finished the durable issue-lock cleanup for it — either
|
||||
// releaseIssueExecutionAndPromote cleared executionRunId/checkoutRunId,
|
||||
// or the retry took over the run's pending work. Only now is it safe to
|
||||
// drop this run's stuck claim-release marker (see stuckClaimReleaseRunIds
|
||||
// above): a failure in classification, retry scheduling, or the release
|
||||
// call above throws before this point, so the marker stays active and
|
||||
// task-drain keeps reporting this instance non-quiescent while the
|
||||
// issue may still be locked. Clearing it here, rather than after the
|
||||
// unrelated cleanup below (event logging, agent-status finalization,
|
||||
// queue promotion), keeps it from getting stuck on a failure in one of
|
||||
// those instead.
|
||||
stuckClaimReleaseRunIds.delete(run.id);
|
||||
|
||||
await appendRunEvent(finalizedRun, await nextRunEventSeq(finalizedRun.id), {
|
||||
eventType: "lifecycle",
|
||||
|
|
@ -14521,14 +14372,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
|
||||
for (const claimedRun of claimedRuns) {
|
||||
const execution = executeRun(claimedRun.id).catch((err) => {
|
||||
if (err instanceof RunClaimReleaseUnresolvedFailure) {
|
||||
// The run's claim release failed durably (both the atomic release
|
||||
// and its fallback failed a write), already logged inside
|
||||
// executeRun. Track the runId so getTaskDrainStatus() keeps
|
||||
// reporting this run as active — see stuckClaimReleaseRunIds.
|
||||
stuckClaimReleaseRunIds.add(claimedRun.id);
|
||||
return;
|
||||
}
|
||||
logger.error({ err, runId: claimedRun.id }, "queued heartbeat execution failed");
|
||||
});
|
||||
// Register the in-flight execution so drainActiveRunExecutions() can await
|
||||
|
|
@ -14537,11 +14380,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
// have landed before a caller (e.g. a test's afterEach) mutates the DB.
|
||||
activeRunExecutionPromises.add(execution);
|
||||
void execution.finally(() => {
|
||||
// Always remove the settled execution promise itself — drainActiveRunExecutions
|
||||
// loops on activeRunExecutionPromises.size, so an entry that never
|
||||
// clears here would hang it forever. A run added to
|
||||
// stuckClaimReleaseRunIds above stays reported as active through
|
||||
// that separate set instead.
|
||||
// drainActiveRunExecutions loops on activeRunExecutionPromises.size,
|
||||
// so an entry that never clears here would hang it forever.
|
||||
activeRunExecutionPromises.delete(execution);
|
||||
});
|
||||
}
|
||||
|
|
@ -14593,17 +14433,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, runId },
|
||||
"failed to release run claimed just before task-drain suppression; failing the run instead",
|
||||
"failed to release run claimed just before task-drain suppression; the run row stays running, and the orphan reaper finalizes it and releases the issue lock on its next cycle",
|
||||
);
|
||||
try {
|
||||
await failRunClaimedJustBeforeSuppression(runId, err);
|
||||
} catch (fallbackErr) {
|
||||
logger.error(
|
||||
{ err: fallbackErr, runId },
|
||||
"failed to fail the run after its claim release also failed; the run, wakeup, and issue lock are in an unknown state, so task-drain will keep reporting this run as active until the process restarts",
|
||||
);
|
||||
throw new RunClaimReleaseUnresolvedFailure(runId, fallbackErr);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -20343,8 +20174,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
startTaskDrain,
|
||||
stopTaskDrain,
|
||||
getTaskDrainStatus,
|
||||
getTaskDrainGeneration,
|
||||
restoreTaskDrainIfCurrent,
|
||||
computeTaskDrain,
|
||||
applyTaskDrain,
|
||||
|
||||
promoteDueScheduledRetries,
|
||||
retryScheduledRetryNow,
|
||||
|
|
|
|||
Loading…
Reference in New Issue