From 2d921ecfad2cfb54c34dae9b3941221e5b6c5c80 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 19 Mar 2026 11:01:37 -0400 Subject: [PATCH] fix: add PID self-check to guided-flow crash lock detection (#1398) guided-flow.ts showed 'Interrupted Session Detected' whenever auto.lock existed, without checking if the lock was written by the current process. This caused infinite prompt loops when the current session's own lock triggered the crash detection. Fix: Added crashLock.pid !== process.pid check, matching the guard in auto-start.ts. Also includes test fixes: - repo-identity-worktree: macOS /var canonicalization - resource-loader: partial-build dist/resources fallback - file-watcher: init delay + timeout for timing stability Fixes #1398 --- src/resource-loader.ts | 4 +++- src/resources/extensions/gsd/guided-flow.ts | 6 ++++-- .../gsd/tests/repo-identity-worktree.test.ts | 8 ++++---- src/tests/file-watcher.test.ts | 11 +++++++---- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index d06dd50a7..2e1a2c688 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -19,7 +19,9 @@ import { loadRegistry, readManifestFromEntryPath, isExtensionEnabled, ensureRegi const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const distResources = join(packageRoot, 'dist', 'resources') const srcResources = join(packageRoot, 'src', 'resources') -const resourcesDir = existsSync(distResources) ? distResources : srcResources +const resourcesDir = (existsSync(distResources) && existsSync(join(distResources, 'agents'))) + ? distResources + : srcResources const bundledExtensionsDir = join(resourcesDir, 'extensions') const resourceVersionManifestName = 'managed-resources.json' diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 62fcd0d5e..7ad3838bc 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -788,9 +788,11 @@ export async function showSmartEntry( // ── Self-heal stale runtime records from crashed auto-mode sessions ── selfHealRuntimeRecords(basePath, ctx); - // Check for crash from previous auto-mode session + // Check for crash from previous auto-mode session. + // Skip if the lock was written by the current process — acquireSessionLock() + // writes to the same file, so we'd always false-positive (#1398). const crashLock = readCrashLock(basePath); - if (crashLock) { + if (crashLock && crashLock.pid !== process.pid) { clearLock(basePath); const resume = await showNextAction(ctx, { title: "GSD — Interrupted Session Detected", diff --git a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts index 1719fe264..b5b894382 100644 --- a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts @@ -13,8 +13,8 @@ function run(command: string, cwd: string): string { } async function main(): Promise { - const base = mkdtempSync(join(tmpdir(), "gsd-repo-identity-")); - const stateDir = mkdtempSync(join(tmpdir(), "gsd-state-")); + const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-"))); + const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-"))); try { process.env.GSD_STATE_DIR = stateDir; @@ -38,7 +38,7 @@ async function main(): Promise { assertEq(worktreeState, expectedExternalState, "worktree symlink target matches main repo external state dir"); assertTrue(existsSync(join(worktreePath, ".gsd")), "worktree .gsd exists"); assertTrue(lstatSync(join(worktreePath, ".gsd")).isSymbolicLink(), "worktree .gsd is a symlink"); - assertEq(realpathSync(join(worktreePath, ".gsd")), expectedExternalState, "worktree .gsd symlink resolves to main repo external state dir"); + assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "worktree .gsd symlink resolves to main repo external state dir"); console.log("\n=== ensureGsdSymlink heals stale worktree symlinks ==="); const staleState = join(stateDir, "projects", "stale-worktree-state"); @@ -47,7 +47,7 @@ async function main(): Promise { symlinkSync(staleState, join(worktreePath, ".gsd"), "junction"); const healedState = ensureGsdSymlink(worktreePath); assertEq(healedState, expectedExternalState, "stale worktree symlink is repaired to canonical external state dir"); - assertEq(realpathSync(join(worktreePath, ".gsd")), expectedExternalState, "healed worktree symlink resolves to canonical external state dir"); + assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "healed worktree symlink resolves to canonical external state dir"); console.log("\n=== ensureGsdSymlink preserves worktree .gsd directories ==="); rmSync(join(worktreePath, ".gsd"), { recursive: true, force: true }); diff --git a/src/tests/file-watcher.test.ts b/src/tests/file-watcher.test.ts index e8dc7fd00..38040cdc6 100644 --- a/src/tests/file-watcher.test.ts +++ b/src/tests/file-watcher.test.ts @@ -54,10 +54,11 @@ test("settings.json change emits settings-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "settings.json"), JSON.stringify({ updated: true })); // Wait for debounce (300ms) + filesystem propagation - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "settings-changed"); assert.ok(matched.length > 0, "should emit settings-changed event"); @@ -68,9 +69,10 @@ test("auth.json change emits auth-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "auth.json"), JSON.stringify({ token: "new" })); - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "auth-changed"); assert.ok(matched.length > 0, "should emit auth-changed event"); @@ -81,9 +83,10 @@ test("models.json change emits models-changed event", async () => { const bus = createMockEventBus(); await startFileWatcher(dir, bus); + await delay(200); writeFileSync(join(dir, "models.json"), JSON.stringify({ model: "new" })); - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "models-changed"); assert.ok(matched.length > 0, "should emit models-changed event"); @@ -133,7 +136,7 @@ test("debouncing coalesces rapid changes into one event", async () => { for (let i = 0; i < 5; i++) { writeFileSync(join(dir, "settings.json"), JSON.stringify({ i })); } - await delay(600); + await delay(800); const matched = bus.events.filter((e) => e.channel === "settings-changed"); assert.strictEqual(