From d107ded8cdc2c2a0e96aaf6cf5deb8ae78b07cf6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 17 Aug 2026 13:09:37 -0400 Subject: [PATCH] fix: release failed run-now claims --- packages/api/src/schedules/service.spec.ts | 48 ++++++++++++++++++++++ packages/api/src/schedules/service.ts | 11 ++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/api/src/schedules/service.spec.ts b/packages/api/src/schedules/service.spec.ts index b4118a58d1..2d65ac7e63 100644 --- a/packages/api/src/schedules/service.spec.ts +++ b/packages/api/src/schedules/service.spec.ts @@ -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: {}, diff --git a/packages/api/src/schedules/service.ts b/packages/api/src/schedules/service.ts index 4993b96797..a0c3a5458d 100644 --- a/packages/api/src/schedules/service.ts +++ b/packages/api/src/schedules/service.ts @@ -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; }