fix: wire the lifecycle fences to their real producers; fail closed everywhere

Second review of 17534d13a found the same failure mode repeated across the previous six
commits: mechanisms landed but their producers and consumers were not wired, so several
fences were inert in production while their tests passed by supplying inputs production
never sends. Addresses 9 of the 11 findings.

#2 (regression introduced by the resume lease) commitResumeLease clears the holder, so a
delayed duplicate resume could ADOPT a committed, running row, lose approvals.resolve,
and have releaseResumeLease demote a live generation to requires_action while freeing its
capacity slot. Acquire now records resumeAdopted (computed in the same pipeline $set, so
it observes the pre-update status), and release demotes ONLY rows it actually promoted;
an adopted row just gives the lease back untouched.

#1 The epoch CAS was inert: only resume.js's re-pause sent expectResumeSeq. The INITIAL
pause (request.js) and the reconciler pause (engine.ts) omitted it, and those are the
writers that actually race a fast resume — the approval card is emitted BEFORE the row
write. Both now send the epoch they observed. Also fixes epoch 0 never matching: a
never-resumed row has NO resumeSeq field, and equality on 0 does not match a missing
field, so the fence was silently disabled; it now matches $in [0, null].

#3 The resume lease had no recovery, so a post-claim crash left the row started with a
live-looking lease that the `running` skip stepped over forever. The reconciler now
recovers expired leases by phase: post-claim rolls FORWARD (terminalize as interrupted,
the approval is spent and can never be re-offered), pre-claim rolls BACK via
releaseResumeLease so the approval stays actionable and the slot is freed.

#5 Redis createdAt allocation was read-then-compute in the client, so two concurrent
creates could mint the same stamp — the exact collision the stamp exists to prevent. Now
allocated atomically inside Redis (JOB_STAMP_LUA). Also, both stores cleared local
caches BEFORE the CAS, so a refused delete still wiped the replacement generation's
cached graph/content; teardown now happens only after the guard fires.

#6 requestRunAbort was dead code, so B10's settlement guarantee did not exist. It is now
called on the abort path, before signalling, so a run keeps its capacity slot until its
generation owner writes a terminal outcome.

#7 The deletion barrier failed OPEN: markUserDeleting errors were logged and the cascade
continued, contradicting the fail-closed property the barrier is for. It now returns 503
rather than destroying on an unenforced guarantee.

#9 The reconciler's terminal bookkeeping carried no configRevision, so reconciled
outcomes could still auto-disable a schedule the owner had edited. Now fenced.

#10 The clustered fail-closed gated only API writes; the engine still armed and kept
firing EXISTING schedules on private per-worker stores. initializeScheduleEngine now
refuses to arm at all on clustered-without-shared-store, which also keeps the write gate
shut (it keys on the engine). Applied to the standard entrypoint too.

#11 The global kill switch gated only the engine tick, leaving Run Now and scheduled
resume open. Both now consult it.

Tests now drive production wiring rather than hand-supplied tokens: the initial-pause
test uses expectResumeSeq 0 exactly as request.js sends it; new cases cover the delayed
duplicate resume after commit (winner stays live), a genuine pre-claim rollback still
demoting, an unexpired lease not being stealable, and expired pre-claim vs post-claim
leases being adoptable/not.
This commit is contained in:
Danny Avila 2026-07-23 10:48:05 -04:00
parent 17534d13af
commit fe56ea4863
12 changed files with 286 additions and 37 deletions

View file

@ -356,9 +356,21 @@ const deleteUserController = async (req, res) => {
// scheduling admission consults this durable user-level flag instead. Raising it
// also invalidates the auth user-doc cache, without which the barrier would only be
// as strong as the shortest cache TTL.
await markUserDeleting(user.id).catch((error) =>
logger.error('[deleteUserController] Failed to raise the deletion barrier', error),
);
let barrierRaised = true;
try {
await markUserDeleting(user.id);
} catch (error) {
barrierRaised = false;
logger.error('[deleteUserController] Failed to raise the deletion barrier', error);
}
if (!barrierRaised) {
// FAIL CLOSED. Without the barrier we cannot promise that admission is refused for
// the rest of the cascade, so destroying now could let concurrently-created work
// outlive the account. Refuse rather than proceed on an unenforced guarantee.
return res.status(503).json({
message: 'Could not start account deletion. Please retry.',
});
}
const quiesced = await quiesceUserSchedules(user.id).catch((error) => {
logger.error('[deleteUserController] Failed to quiesce scheduled chats', error);

View file

@ -866,6 +866,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
scheduledFor,
status: 'requires_action',
conversationId: streamId,
// Segment 0: this generation came from the fire, which creates the run
// with no resumeSeq. The approval card is emitted BEFORE this write, so a
// fast resume can promote the run (bumping the epoch) first — without this
// token the stale pause would demote the run that resume just started.
expectResumeSeq: 0,
});
}
logger.debug(

View file

@ -368,12 +368,13 @@ const startServer = async () => {
// via SCHEDULES_CLUSTERED (or use USE_REDIS_STREAMS for a shared store). Without
// that, a peer replica's in-memory job would be misread as gone and its live run
// wrongly interrupted after the orphan cutoff.
const scheduleEngine = await initializeScheduleEngine({
clustered: isEnabled(process.env.SCHEDULES_CLUSTERED),
});
const clustered = isEnabled(process.env.SCHEDULES_CLUSTERED);
const scheduleEngine = await initializeScheduleEngine({ clustered });
// Only accept schedule writes once the engine confirmed its unique idempotency
// + TTL indexes exist. If index creation failed the engine is left undefined and
// schedule writes keep returning 503 (the app otherwise runs normally).
// The engine refuses to arm on an unsafe clustered topology, so a null engine
// already means "not scheduling here"; the write gate follows it.
schedulesReady = scheduleEngine != null;
if (!schedulesReady) {
logger.warn(

View file

@ -96,7 +96,38 @@ export function startScheduleEngine(deps: ScheduleEngineDeps): ScheduleEngine {
conversationId: run.conversationId,
error,
autoDisableAfterFailures: runLimits.autoDisableAfterFailures,
// Fence the reconciler's own pause on the epoch it OBSERVED: several awaits
// separate the read from this write, and a resume landing in between must
// not be demoted by a sweep that is now looking at a stale segment.
...(status === 'requires_action' ? { expectResumeSeq: run.resumeSeq ?? 0 } : {}),
// Terminal bookkeeping is fenced on the config the run started under, so a
// reconciled outcome cannot auto-disable a schedule the owner has edited.
...(run.configRevision != null ? { expectConfigRevision: run.configRevision } : {}),
});
// RESUME-LEASE RECOVERY. A resume that died between consuming the approval and
// reconstructing its generation leaves the row `started` with a live-looking
// lease, and the `running` skip below would step over it forever. Once the
// lease deadline passes, decide by phase:
// post-claim (resumeClaimedAt set) -> the approval is spent, so it can never
// be re-offered; roll FORWARD by terminalizing as interrupted.
// pre-claim -> the approval was never consumed, so roll BACK to
// requires_action (releaseResumeLease frees the slot) and let the user retry.
if (
run.resumeHolder != null &&
run.resumeExpiresAt != null &&
run.resumeExpiresAt.getTime() < Date.now()
) {
if (run.resumeClaimedAt != null) {
await finalize('interrupted', 'Resume did not complete');
} else {
await deps.methods.releaseResumeLease(
run.scheduleId,
run.scheduledFor,
run.resumeHolder,
);
}
continue;
}
if (jobStatus === 'running') {
continue;
}

View file

@ -22,6 +22,7 @@ function makeService(
disableUserSchedulesForDeletion: jest.fn(async () => undefined),
getActiveRunsForUser,
countActiveRuns: jest.fn(async () => 0),
requestRunAbort: jest.fn(async () => true),
};
const deps = {
methods,

View file

@ -424,11 +424,17 @@ export function createSchedulesService(deps: SchedulesServiceDeps): SchedulesSer
// another worker, and orphan reaping is disabled. This is unsupported for
// scheduled chats — warn the operator to enable USE_REDIS_STREAMS.
if (clustered && !GenerationJobManager.isRedis) {
logger.warn(
// FAIL CLOSED: do not arm the engine at all. Gating only API writes still left
// EXISTING schedules being claimed and fired on private per-worker stores, where
// a peer's run is unreachable for abort/quiesce and orphan recovery cannot see it.
// Returning undefined also keeps the write gate shut (it keys on the engine).
logger.error(
'[schedules] clustered deployment without a shared stream store (USE_REDIS_STREAMS): ' +
'scheduled-run peer aborts (deletion/account-deletion quiescing) and cross-worker ' +
'orphan recovery are NOT available. Enable USE_REDIS_STREAMS for safe multi-worker scheduling.',
'orphan recovery are NOT available. The scheduler is DISABLED for this process. ' +
'Enable USE_REDIS_STREAMS for safe multi-worker scheduling.',
);
return undefined;
}
// Explicitly build the Schedule/ScheduleRun indexes first — the unique
// idempotency index and TTL retention index would otherwise never exist when
@ -459,6 +465,11 @@ export function createSchedulesService(deps: SchedulesServiceDeps): SchedulesSer
schedule: FireableSchedule,
limits: ScheduleLimits,
): Promise<FireResult | null> {
// The global stop means STOP: a manual run dispatches the same billed generation as
// an automatic one, so gating only the engine tick would leave Run Now wide open.
if (await engineDeps.isGloballyDisabled()) {
return { fired: false, skipped: 'disabled' as const };
}
const leased = await methods.acquireManualRunLease(
schedule.id,
schedule.user,
@ -581,6 +592,11 @@ export function createSchedulesService(deps: SchedulesServiceDeps): SchedulesSer
if (!scheduleId || !scheduledFor) {
return { outcome: 'not-scheduled' };
}
// A resume restarts a billed generation, so the global stop applies here too.
// Reported as 'gone' so the approval is left unconsumed and the caller defers.
if (await engineDeps.isGloballyDisabled()) {
return { outcome: 'gone' };
}
// getScheduleById hides deleted/soft-deleted schedules, so a null here means the
// owner already deleted the schedule — its paused run must not be resumable even
// if the delete's best-effort abort raced. Reject before touching the run.
@ -665,6 +681,13 @@ export function createSchedulesService(deps: SchedulesServiceDeps): SchedulesSer
if (!run.conversationId) {
return false;
}
// Record the abort REQUEST before signalling. This keeps the run holding its global
// capacity slot until its generation owner writes a terminal outcome (settlement),
// so an abort that has been asked for but not yet honored cannot free capacity for a
// new run while the old generation is still alive.
await methods
.requestRunAbort(run.scheduleId, run.scheduledFor)
.catch((err) => logger.warn('[schedules] failed to record abort request:', err));
return engineDeps
.abortScheduledJob(
run.conversationId,

View file

@ -225,6 +225,8 @@ export class InMemoryJobStore implements IJobStore {
async deleteJob(streamId: string, expectedCreatedAt?: number): Promise<boolean> {
// Generation-fenced: an omitted guard behaves exactly as before. Read-check-write
// in one synchronous block, so no replacement can land in between.
// Guard BEFORE any teardown: a refused delete must leave the replacement
// generation's state completely untouched.
if (expectedCreatedAt != null && this.jobs.get(streamId)?.createdAt !== expectedCreatedAt) {
return false;
}

View file

@ -193,6 +193,18 @@ const JOB_DELETE_LUA =
'redis.call("DEL", KEYS[3]) redis.call("DEL", KEYS[4]) ' +
'return 1';
/**
* Allocates a STRICTLY monotonic generation stamp for a stream, atomically. Reading the
* prior createdAt in TS and computing the next value in the client is a race: two
* concurrent creates observe the same prior and mint the same stamp, which is exactly
* the collision the stamp exists to prevent. ARGV[1] is the caller's wall clock.
*/
const JOB_STAMP_LUA =
'local prev = redis.call("HGET", KEYS[1], "createdAt") ' +
'local now = tonumber(ARGV[1]) ' +
'if prev and tonumber(prev) and tonumber(prev) >= now then return tonumber(prev) + 1 end ' +
'return now';
const STEER_DRAIN_LUA =
'if ARGV[1] ~= "" and redis.call("HGET", KEYS[1], "createdAt") ~= ARGV[1] then return {} end ' +
'local items = redis.call("LRANGE", KEYS[2], 0, -1) ' +
@ -439,21 +451,18 @@ export class RedisJobStore implements IJobStore {
conversationId?: string,
tenantId?: string,
): Promise<SerializableJobData> {
// STRICTLY monotonic per streamId: createdAt is the generation fence and a streamId
// is reused across generations, so two creates in the same millisecond would mint
// identical tokens and let a stale caller's guard pass against the replacement.
const previousCreatedAt = await this.redis
.hget(KEYS.job(streamId), 'createdAt')
.then((value) => (value == null ? undefined : parseInt(value, 10)))
.catch(() => undefined);
// STRICTLY monotonic per streamId, allocated ATOMICALLY inside Redis: createdAt is
// the generation fence and a streamId is reused across generations, so a read-then-
// compute in the client would let two concurrent creates mint the same stamp.
const allocated = await this.redis
.eval(JOB_STAMP_LUA, 1, KEYS.job(streamId), String(Date.now()))
.catch(() => null);
const job: SerializableJobData = {
streamId,
userId,
...(tenantId && { tenantId }),
status: 'running',
createdAt: nextGenerationStamp(
Number.isFinite(previousCreatedAt) ? previousCreatedAt : undefined,
),
createdAt: typeof allocated === 'number' ? allocated : nextGenerationStamp(),
conversationId,
syncSent: false,
};
@ -785,9 +794,9 @@ export class RedisJobStore implements IJobStore {
}
async deleteJob(streamId: string, expectedCreatedAt?: number): Promise<boolean> {
this.localGraphCache.delete(streamId);
this.localContentParts.delete(streamId);
this.localCollectedUsageCache.delete(streamId);
// Local caches are dropped only AFTER the guarded delete fires (see
// deleteJobInternal): clearing them up front would wipe the REPLACEMENT
// generation's cached graph/content when the guard refuses this delete.
const job = await this.getJob(streamId);
// Cheap pre-check so a mismatched generation costs no round trip; the Lua guard
// below is what actually makes it atomic.
@ -803,10 +812,6 @@ export class RedisJobStore implements IJobStore {
userJobsKey: string | null,
expectedCreatedAt?: number,
): Promise<boolean> {
this.localGraphCache.delete(streamId);
this.localContentParts.delete(streamId);
this.localCollectedUsageCache.delete(streamId);
// The four per-stream keys share the {streamId} hash tag, so the guarded delete is
// one atomic single-slot script (safe on Cluster). The three SREMs are cross-slot
// and stay OUTSIDE the script, running only when the script actually deleted —
@ -824,6 +829,10 @@ export class RedisJobStore implements IJobStore {
logger.debug(`[RedisJobStore] Delete skipped (generation mismatch): ${streamId}`);
return false;
}
// Safe now: this delete owned the generation.
this.localGraphCache.delete(streamId);
this.localContentParts.delete(streamId);
this.localCollectedUsageCache.delete(streamId);
if (this.isCluster) {
await this.redis.srem(KEYS.runningJobs, streamId);
await this.redis.srem(KEYS.requiresActionJobs, streamId);

View file

@ -1095,13 +1095,15 @@ describe('lifecycle barriers: stale pause vs resume, and concurrent resumes at c
const schedule = await methods.createSchedule(scheduleData());
await methods.insertScheduleRun(runData(schedule, { scheduledFor }));
// Segment 1 pauses. Its writer observed epoch 0 (no resume yet).
// Segment 0 pauses. This mirrors the INITIAL pause exactly as request.js writes it:
// expectResumeSeq 0, against a row that has no resumeSeq field at all.
await methods.recordRunOutcome({
scheduleId: schedule.id,
scheduledFor,
status: 'requires_action',
conversationId: 'convo-1',
autoDisableAfterFailures: 3,
expectResumeSeq: 0,
});
expect((await getRun(schedule.id, scheduledFor)).status).toBe('requires_action');
@ -1196,3 +1198,127 @@ describe('lifecycle barriers: stale pause vs resume, and concurrent resumes at c
expect(await ScheduleRun.countDocuments({ status: 'started', capacitySlot: 0 })).toBe(0);
});
});
describe('resume lease: duplicate attempts and crash recovery', () => {
const scheduledFor = new Date('2026-07-20T12:00:00Z');
async function pausedRun() {
const schedule = await methods.createSchedule(scheduleData());
await methods.insertScheduleRun(runData(schedule, { scheduledFor, status: 'requires_action' }));
return schedule;
}
it('a delayed duplicate resume cannot demote the winner after it committed', async () => {
const schedule = await pausedRun();
// Winner acquires, consumes the approval, reconstructs, commits. Commit clears the
// holder, so the row is an ordinary `started` run again.
const winner = await methods.acquireResumeLease({
scheduleId: schedule.id,
scheduledFor,
holder: 'winner',
ttlMs: 60_000,
capacitySlot: 0,
});
expect(winner.outcome).toBe('acquired');
await methods.markResumeClaimed(schedule.id, scheduledFor, 'winner', 60_000);
expect(await methods.commitResumeLease(schedule.id, scheduledFor, 'winner')).toBe(true);
// A delayed duplicate submit for the SAME action now arrives. It can still adopt the
// holderless row, but it will LOSE approvals.resolve and then release.
const loser = await methods.acquireResumeLease({
scheduleId: schedule.id,
scheduledFor,
holder: 'loser',
ttlMs: 60_000,
capacitySlot: 0,
});
expect(loser.outcome).toBe('acquired');
await methods.releaseResumeLease(schedule.id, scheduledFor, 'loser');
// The winner's run must still be live. Demoting here would knock a running
// generation back to requires_action and free its capacity slot underneath it.
const row = await getRun(schedule.id, scheduledFor);
expect(row.status).toBe('started');
expect(row.capacitySlot).toBe(0);
expect(row.resumeHolder).toBeUndefined();
});
it('still rolls back a resume that genuinely promoted a paused run', async () => {
const schedule = await pausedRun();
const lease = await methods.acquireResumeLease({
scheduleId: schedule.id,
scheduledFor,
holder: 'solo',
ttlMs: 60_000,
capacitySlot: 0,
});
expect(lease.outcome).toBe('acquired');
// Pre-claim failure: this attempt promoted the row, so release MUST demote it and
// free the slot, leaving the approval actionable.
expect(await methods.releaseResumeLease(schedule.id, scheduledFor, 'solo')).toBe(true);
const row = await getRun(schedule.id, scheduledFor);
expect(row.status).toBe('requires_action');
expect(row.capacitySlot).toBeUndefined();
});
it('a second live attempt cannot steal an unexpired lease', async () => {
const schedule = await pausedRun();
await methods.acquireResumeLease({
scheduleId: schedule.id,
scheduledFor,
holder: 'first',
ttlMs: 60_000,
capacitySlot: 0,
});
const second = await methods.acquireResumeLease({
scheduleId: schedule.id,
scheduledFor,
holder: 'second',
ttlMs: 60_000,
capacitySlot: 1,
});
expect(second.outcome).toBe('held');
});
it('an EXPIRED pre-claim lease is adoptable; a post-claim one is not', async () => {
const preClaim = await pausedRun();
await methods.acquireResumeLease({
scheduleId: preClaim.id,
scheduledFor,
holder: 'crashed',
ttlMs: -1_000, // already expired
capacitySlot: 0,
});
// Never consumed the approval, so a retry may take it over.
const adopted = await methods.acquireResumeLease({
scheduleId: preClaim.id,
scheduledFor,
holder: 'retry',
ttlMs: 60_000,
capacitySlot: 0,
});
expect(adopted.outcome).toBe('acquired');
const postClaim = await pausedRun();
await methods.acquireResumeLease({
scheduleId: postClaim.id,
scheduledFor,
holder: 'crashed-2',
ttlMs: -1_000,
capacitySlot: 1,
});
// The approval WAS consumed, so this must roll forward (reconciler terminalizes it)
// rather than be re-run by another attempt.
await methods.markResumeClaimed(postClaim.id, scheduledFor, 'crashed-2', -1_000);
const stolen = await methods.acquireResumeLease({
scheduleId: postClaim.id,
scheduledFor,
holder: 'retry-2',
ttlMs: 60_000,
capacitySlot: 2,
});
expect(stolen.outcome).toBe('held');
});
});

View file

@ -673,6 +673,13 @@ export function createScheduleMethods(mongoose: typeof import('mongoose')): Sche
resumeHolder: holder,
resumeExpiresAt: { $add: ['$$NOW', ttlMs] },
capacitySlot,
// Whether this lease ADOPTED a row that was already `started` rather than
// promoting a paused one. Expressions in a pipeline $set see the PRE-stage
// document, so this observes the status before the line above changes it.
// Release must never demote an adopted row: a committed resume clears its
// holder, so a late duplicate attempt can otherwise adopt a LIVE running
// run and roll it back to requires_action.
resumeAdopted: { $eq: ['$status', 'started'] },
},
},
{ $unset: ['resumeClaimedAt'] },
@ -736,7 +743,7 @@ export function createScheduleMethods(mongoose: typeof import('mongoose')): Sche
): Promise<boolean> {
const result = await ScheduleRun().updateOne(
{ scheduleId, scheduledFor, resumeHolder: holder },
{ $unset: { resumeHolder: 1, resumeExpiresAt: 1, resumeClaimedAt: 1 } },
{ $unset: { resumeHolder: 1, resumeExpiresAt: 1, resumeClaimedAt: 1, resumeAdopted: 1 } },
);
return (result.matchedCount ?? 0) > 0;
}
@ -749,19 +756,38 @@ export function createScheduleMethods(mongoose: typeof import('mongoose')): Sche
scheduledFor: Date,
holder: string,
): Promise<boolean> {
const result = await ScheduleRun().updateOne(
// Demote ONLY a row this holder actually promoted out of `requires_action`. An
// ADOPTED row was already `started` (a committed resume, or one whose pause
// bookkeeping never landed), and demoting it would knock a live generation back to
// requires_action and free its capacity slot underneath it.
const promoted = await ScheduleRun().updateOne(
{
scheduleId,
scheduledFor,
resumeHolder: holder,
resumeClaimedAt: { $exists: false },
resumeAdopted: { $ne: true },
},
{
$set: { status: 'requires_action' },
$unset: { resumeHolder: 1, resumeExpiresAt: 1, capacitySlot: 1, resumeAdopted: 1 },
},
);
if ((promoted.matchedCount ?? 0) > 0) {
return true;
}
// Adopted row: drop only OUR lease so another attempt can take it, leaving the run
// (and its slot) exactly as we found it.
const released = await ScheduleRun().updateOne(
{
scheduleId,
scheduledFor,
resumeHolder: holder,
resumeClaimedAt: { $exists: false },
},
{
$set: { status: 'requires_action' },
$unset: { resumeHolder: 1, resumeExpiresAt: 1, capacitySlot: 1 },
},
{ $unset: { resumeHolder: 1, resumeExpiresAt: 1, resumeAdopted: 1 } },
);
return (result.matchedCount ?? 0) > 0;
return (released.matchedCount ?? 0) > 0;
}
/** Records that an abort was requested WITHOUT freeing the capacity slot: the run
@ -877,10 +903,15 @@ export function createScheduleMethods(mongoose: typeof import('mongoose')): Sche
// EPOCH CAS: a pause may only land on the segment that produced it. A resume
// bumps resumeSeq, so a stale requires_action callback from the PREVIOUS segment
// no longer matches and cannot demote the run the resume just promoted.
const epochFilter =
params.expectResumeSeq != null
? { resumeSeq: params.expectResumeSeq }
: ({} as Record<string, never>);
// `{field: null}` matches null OR missing in Mongo, so epoch 0 must be expressed
// as $in [0, null] — a never-resumed row has no resumeSeq at all, and a plain
// equality on 0 would never match it (silently disabling the fence).
let epochFilter: Record<string, unknown> = {};
if (params.expectResumeSeq === 0) {
epochFilter = { resumeSeq: { $in: [0, null] } };
} else if (params.expectResumeSeq != null) {
epochFilter = { resumeSeq: params.expectResumeSeq };
}
const activeRun = await ScheduleRun()
.findOne({
scheduleId: params.scheduleId,
@ -946,6 +977,7 @@ export function createScheduleMethods(mongoose: typeof import('mongoose')): Sche
resumeHolder: 1,
resumeExpiresAt: 1,
resumeClaimedAt: 1,
resumeAdopted: 1,
},
},
);

View file

@ -80,6 +80,11 @@ const scheduleRunSchema: Schema<IScheduleRunDocument> = new Schema(
resumeClaimedAt: {
type: Date,
},
/** True when the lease ADOPTED an already-`started` row instead of promoting a
* paused one. Release must not demote an adopted row (it may be a live run). */
resumeAdopted: {
type: Boolean,
},
/** Global concurrency slot held while `started`. The unique partial index below
* turns fireConcurrency into a DB-enforced bound instead of a racy count. */
capacitySlot: {

View file

@ -66,6 +66,8 @@ export interface IScheduleRun {
resumeExpiresAt?: Date;
/** Set once the approval claim succeeded (pre-claim vs post-claim recovery). */
resumeClaimedAt?: Date;
/** True when the lease adopted an already-`started` row rather than promoting a paused one. */
resumeAdopted?: boolean;
/** Global concurrency slot held while `started`. */
capacitySlot?: number;
/** When an abort was requested; capacity is held until settlement is confirmed. */