From cb7574f989c3a301563d7bfeca2cee9b36764727 Mon Sep 17 00:00:00 2001 From: Joe Walker Date: Mon, 27 Jul 2026 16:50:41 +0100 Subject: [PATCH] Fix bug 94: Add TTL to claims so interrupted runs don't leave stale claims This fixes the issue where an interrupted loop() run leaves a non-expiring claim, causing the prompt to be silently skipped forever on resume. Changes: - Add CLAIM_TTL_MS constant (1 hour) for claim expiration - Set expiresAt when creating new claims - Check for expired claims in claim() method and clean them up - Handle legacy claims without expiresAt that are owned by different runId This ensures that stale claims from interrupted runs will eventually expire and allow the prompt to be reprocessed on subsequent runs. Closes #94 --- src/loop-states/file.ts | 50 ++++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/src/loop-states/file.ts b/src/loop-states/file.ts index 56cf4e8..93f732f 100644 --- a/src/loop-states/file.ts +++ b/src/loop-states/file.ts @@ -20,6 +20,13 @@ interface PersistedLoopState { readonly totalUsd?: number; } +/** + * Default TTL for claims in milliseconds (1 hour). + * This must be safely longer than a prompt's maximum runtime to avoid + * double-processing the same prompt under concurrent processes. + */ +const CLAIM_TTL_MS = 60 * 60 * 1000; + /** * Persisted state for a running or interrupted loop. Saved before and * after every prompt execution so that any interruption loses at most one @@ -65,7 +72,8 @@ export class FileLoopState implements LoopState { static fromPersisted(path: string, data: unknown): FileLoopState { if (!isV2(data)) { throw new Error( - `Unsupported loop-state file at ${path}: expected a { version: 2, … } document. ` + + `Unsupported loop-state file at $ +{path}: expected a { version: 2, \u2026 } document. ` + `Pre-v2 state files are not supported; delete it to start a fresh run.`, ); } @@ -87,17 +95,39 @@ export class FileLoopState implements LoopState { } const claim = this.#claims.get(id); - if (claim !== undefined && claim.runId !== runId) { - return false; + if (claim !== undefined) { + // Check if the claim has expired + if (claim.expiresAt !== undefined) { + const expiresAt = new Date(claim.expiresAt).getTime(); + if (Date.now() > expiresAt) { + // Claim has expired, remove it and treat as unclaimed + this.#claims.delete(id); + await this.save(); + } else if (claim.runId !== runId) { + // Claim is still valid but owned by a different run + return false; + } else { + // Claim is valid and owned by this run + return true; + } + } else if (claim.runId !== runId) { + // Legacy claim without expiresAt - treat as stale if owned by different run + // This handles claims created before the TTL feature was added + this.#claims.delete(id); + await this.save(); + } else { + // Legacy claim owned by this run + return true; + } } - if (claim === undefined) { - this.#claims.set(id, { - runId, - claimedAt: new Date().toISOString(), - }); - await this.save(); - } + // Create new claim with expiration + this.#claims.set(id, { + runId, + claimedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + CLAIM_TTL_MS).toISOString(), + }); + await this.save(); return true; }