fix: release failed run-now claims

This commit is contained in:
Danny Avila 2026-08-17 13:09:37 -04:00
parent 41ca15c579
commit d107ded8cd
2 changed files with 57 additions and 2 deletions

View file

@ -85,6 +85,54 @@ describe('shutdown wiring', () => {
});
});
describe('manual Run Now lease cleanup', () => {
it('releases the old holder when an owner edit fences cleanup after a preflight throw', async () => {
const service = makeService(jest.fn(async (_userId: string) => []));
const leased = {
id: 's1',
user: 'user-1',
tenantId: 't1',
name: 'Digest',
prompt: 'Summarize',
agent_id: 'agent-1',
cadence: { frequency: 'daily', hour: 8, minute: 0 },
timezone: 'America/New_York',
target: 'new',
enabled: true,
claimToken: 'manual-token',
leaseBy: 'manual-holder',
runCount: 0,
failureCount: 0,
balanceSkipCount: 0,
} as never;
const methods = service.engineDeps.methods as unknown as {
acquireManualRunLease: jest.Mock;
releaseLease: jest.Mock;
releaseLeaseByHolder: jest.Mock;
};
methods.acquireManualRunLease = jest.fn(async () => leased);
// Simulates an edit rotating claimToken while deliberately preserving leaseBy.
methods.releaseLease = jest.fn(async () => false);
methods.releaseLeaseByHolder = jest.fn(async () => undefined);
service.engineDeps.getUserContext = jest.fn(async () => {
throw new Error('user lookup failed');
});
await expect(
service.fireScheduleNow(leased, {
enabled: true,
maxPerUser: 10,
minIntervalMinutes: 60,
autoDisableAfterFailures: 5,
fireConcurrency: 5,
}),
).rejects.toThrow('user lookup failed');
expect(methods.releaseLease).toHaveBeenCalledWith('s1', 'manual-token');
expect(methods.releaseLeaseByHolder).toHaveBeenCalledWith('s1', 'manual-holder');
});
});
describe('balance initialization', () => {
const balanceConfig = {
interfaceConfig: {},

View file

@ -652,8 +652,15 @@ export function createSchedulesService(
// window in between is reflected, so a stale prompt/agent is never dispatched.
return await fireSchedule(engineDeps, leased, limits, new Date(), { manual: true });
} catch (err) {
if (claimToken != null) {
await methods.releaseLease(schedule.id, claimToken).catch(() => undefined);
const released =
claimToken != null
? await methods.releaseLease(schedule.id, claimToken).catch(() => false)
: false;
if (!released && leased.leaseBy != null) {
// An owner edit during the failed preflight rotates the token while preserving
// this unique holder. Clear only that holder; a takeover has a different value
// and is therefore never stripped by this exception safety net.
await methods.releaseLeaseByHolder(schedule.id, leased.leaseBy).catch(() => undefined);
}
throw err;
}