From f102559d282c733a91cc72c904aeef371bc40deb Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 13 Mar 2026 22:32:24 -0600 Subject: [PATCH 1/2] fix: remove infinite delivery retry loop for background job completions (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background job completions were delivered via an infinite retry loop with exponential backoff. Since delivery is an in-process function call (not a network operation), retries served no purpose and caused each retry to trigger a full LLM turn — burning tokens indefinitely until the 5-minute eviction timer fired. Delivery is now fire-once. The acknowledgeDeliveries API is retained as a no-op for compatibility with the await_job tool. Closes #301 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extensions/async-jobs/job-manager.ts | 47 ++----------------- 1 file changed, 4 insertions(+), 43 deletions(-) diff --git a/src/resources/extensions/async-jobs/job-manager.ts b/src/resources/extensions/async-jobs/job-manager.ts index 34a1b0527..174b923eb 100644 --- a/src/resources/extensions/async-jobs/job-manager.ts +++ b/src/resources/extensions/async-jobs/job-manager.ts @@ -31,18 +31,10 @@ export interface JobManagerOptions { onJobComplete?: (job: Job) => void; } -// ── Delivery Retry ───────────────────────────────────────────────────────── - -const DELIVERY_BASE_MS = 500; -const DELIVERY_MAX_MS = 30_000; -const DELIVERY_JITTER_MS = 200; - // ── Manager ──────────────────────────────────────────────────────────────── export class AsyncJobManager { private jobs = new Map(); - private deliveryTimers = new Map>(); - private acknowledgedJobs = new Set(); private evictionTimers = new Map>(); private maxRunning: number; @@ -157,28 +149,16 @@ export class AsyncJobManager { } /** - * Mark jobs as acknowledged so delivery retries stop. + * No-op. Retained for API compatibility with await_job tool. */ - acknowledgeDeliveries(jobIds: string[]): void { - for (const id of jobIds) { - this.acknowledgedJobs.add(id); - const timer = this.deliveryTimers.get(id); - if (timer) { - clearTimeout(timer); - this.deliveryTimers.delete(id); - } - } + acknowledgeDeliveries(_jobIds: string[]): void { + // Delivery is fire-once; no retries to cancel. } /** * Cleanup all timers and resources. */ shutdown(): void { - for (const timer of this.deliveryTimers.values()) { - clearTimeout(timer); - } - this.deliveryTimers.clear(); - for (const timer of this.evictionTimers.values()) { clearTimeout(timer); } @@ -195,26 +175,9 @@ export class AsyncJobManager { // ── Private ──────────────────────────────────────────────────────────── - private deliverResult(job: Job, attempt = 0): void { - if (this.acknowledgedJobs.has(job.id)) return; + private deliverResult(job: Job): void { if (!this.onJobComplete) return; - this.onJobComplete(job); - - // Schedule retry with exponential backoff + jitter - const delay = Math.min( - DELIVERY_BASE_MS * Math.pow(2, attempt) + Math.random() * DELIVERY_JITTER_MS, - DELIVERY_MAX_MS, - ); - - const timer = setTimeout(() => { - this.deliveryTimers.delete(job.id); - if (!this.acknowledgedJobs.has(job.id)) { - this.deliverResult(job, attempt + 1); - } - }, delay); - - this.deliveryTimers.set(job.id, timer); } private scheduleEviction(id: string): void { @@ -224,7 +187,6 @@ export class AsyncJobManager { const timer = setTimeout(() => { this.evictionTimers.delete(id); this.jobs.delete(id); - this.acknowledgedJobs.delete(id); }, this.evictionMs); this.evictionTimers.set(id, timer); @@ -244,7 +206,6 @@ export class AsyncJobManager { if (timer) clearTimeout(timer); this.evictionTimers.delete(oldest.id); this.jobs.delete(oldest.id); - this.acknowledgedJobs.delete(oldest.id); } } } From 770132b20ebd6b7956d06046d4b1453ce8dcf947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 13 Mar 2026 22:43:02 -0600 Subject: [PATCH 2/2] fix: eliminate branch checkout during slice merge that caused STATE.md conflicts (#302) (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge flow checked out the slice branch mid-merge to untrack runtime files, which failed when .gsd/STATE.md had uncommitted working tree changes. Instead, strip runtime files from the staged merge result post-merge — no branch switching needed. Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/git-service.ts | 27 +++++++++++---------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index a55c74adb..d4e07245f 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -657,18 +657,6 @@ export class GitServiceImpl { this.git(["commit", "-m", "chore: untrack .gsd/ runtime files before merge"], { allowFailure: true }); } - // Also untrack runtime files from the slice branch to prevent - // modify/delete conflicts during squash-merge (#218) - this.git(["checkout", branch]); - for (const exclusion of RUNTIME_EXCLUSION_PATHS) { - this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true }); - } - const branchUntrackDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true }); - if (branchUntrackDiff?.trim()) { - this.git(["commit", "-m", "chore: untrack .gsd/ runtime files before merge"], { allowFailure: true }); - } - this.git(["checkout", mainBranch]); - // Merge slice branch — strategy is configurable via git.merge_strategy // preference. Default: "squash" (preserves existing behavior). // "merge" uses --no-ff which is more resilient to conflicts from @@ -730,9 +718,22 @@ export class GitServiceImpl { } } - // Squash merge needs a separate commit; --no-ff merge already committed + // Strip runtime files from the merge result before committing (#302). + // This replaces the old approach of checking out the slice branch to + // untrack runtime files pre-merge, which failed when the working tree + // had uncommitted .gsd/ changes that blocked the checkout. + for (const exclusion of RUNTIME_EXCLUSION_PATHS) { + this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true }); + } + if (strategy === "squash") { this.git(["commit", "-F", "-"], { input: message }); + } else { + // --no-ff already committed; amend to include runtime file removal + const runtimeDiff = this.git(["diff", "--cached", "--stat"], { allowFailure: true }); + if (runtimeDiff?.trim()) { + this.git(["commit", "--amend", "--no-edit"]); + } } // Delete the merged branch