gsd-pi 2.33.0 → 2.33.1-dev.29a8268

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +13 -18
  2. package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
  3. package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
  4. package/dist/resources/extensions/gsd/commands.ts +14 -2
  5. package/dist/resources/extensions/gsd/git-service.ts +17 -11
  6. package/dist/resources/extensions/gsd/index.ts +13 -2
  7. package/dist/resources/extensions/gsd/session-lock.ts +80 -16
  8. package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  9. package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  10. package/dist/resources/extensions/gsd/tests/git-service.test.ts +16 -7
  11. package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
  12. package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  13. package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  14. package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  15. package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  16. package/package.json +1 -1
  17. package/packages/pi-coding-agent/package.json +1 -1
  18. package/pkg/package.json +1 -1
  19. package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
  20. package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
  21. package/src/resources/extensions/gsd/commands.ts +14 -2
  22. package/src/resources/extensions/gsd/git-service.ts +17 -11
  23. package/src/resources/extensions/gsd/index.ts +13 -2
  24. package/src/resources/extensions/gsd/session-lock.ts +80 -16
  25. package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
  26. package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
  27. package/src/resources/extensions/gsd/tests/git-service.test.ts +16 -7
  28. package/src/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
  29. package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
  30. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
  31. package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
  32. package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
  33. package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
  34. package/src/resources/extensions/mcporter/extension-manifest.json +0 -12
package/README.md CHANGED
@@ -24,24 +24,19 @@ One command. Walk away. Come back to a built project with clean git history.
24
24
 
25
25
  ---
26
26
 
27
- ## What's New in v2.32
28
-
29
- - **Simplified pipeline** — research merged into planning, mechanical completion (ADR-003)
30
- - **Always-on health widget** — 🟢🟡🔴 traffic-light indicator in the progress widget and visualizer health tab
31
- - **Environment health checks** — progress scoring and status integration for auto-mode
32
- - **Extension registry** — user-managed enable/disable for bundled and custom extensions
33
- - **Built-in skill authoring** — create and distribute custom skills from within GSD
34
- - **Workflow templates** — right-sized workflows for every task type (research, plan, execute, complete)
35
- - **AWS Bedrock auth** — automatic credential refresh via the new `aws-auth` extension
36
- - **`-w` / `--worktree` CLI flag** — launch isolated worktree sessions from the command line
37
- - **Native MCP client** — replaced MCPorter with a built-in MCP client for better reliability
38
- - **External state directory** — `.gsd/` now lives in `~/.gsd/projects/` with a symlink (ADR-002)
39
- - **Model health indicator** — live health status based on error trends and consecutive failures
40
- - **Quick-task branch cleanup** — `/gsd quick` branches auto-merge back after completion
41
- - **Windows EPERM fallback** — migration rename uses copy+delete when NTFS blocks rename
42
- - **Worktree identity fix** — stable project hash across worktrees and main repo
43
- - **Crash recovery guidance** — actionable next-step messages based on what was interrupted
44
- - **UAT verdict gating** — non-PASS verdicts now block slice progression instead of being ignored
27
+ ## What's New in v2.33
28
+
29
+ - **Dispatch loop hardening** — defensive guards, reentrancy protection, and 125 new regression tests covering the full `deriveState → resolveDispatch` chain without an LLM
30
+ - **Live regression test harness** — post-build pipeline validation that catches dispatch, parser, and lock lifecycle regressions before promotion
31
+ - **Unified error handling** — `getErrorMessage()` helper replaces 65 inline duplicates across the codebase
32
+ - **Centralized unit ID parsing** — `parseUnitId()` eliminates fragile regex patterns scattered across dispatch, recovery, and metrics code
33
+ - **Milestone merge consolidation** — `tryMergeMilestone()` replaces 4 duplicate merge paths in the auto-mode loop
34
+ - **Lock alignment fix** — retry lock path now matches primary lock settings, preventing `ECOMPROMISED` errors on resume
35
+ - **NixOS/nix-darwin support** — symlinks in `.gsd/` are skipped during `makeTreeWritable` to prevent `EPERM` failures
36
+ - **Windows EPERM fallback** — `.gsd/` migration uses copy+delete when NTFS blocks direct rename
37
+ - **Worktree identity fix** — stable project hash resolved from main repo root, not worktree path
38
+ - **Quick-task branch cleanup** — `/gsd quick` branches auto-merge back to the original branch after completion
39
+ - **Crash recovery guidance** — actionable next-step messages based on what was interrupted and what state survived
45
40
 
46
41
  See the full [Changelog](./CHANGELOG.md) for details.
47
42
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Auto-mode Supervisor — SIGTERM handling and working-tree activity detection.
2
+ * Auto-mode Supervisor — signal handling and working-tree activity detection.
3
3
  *
4
4
  * Pure functions — no module-level globals or AutoContext dependency.
5
5
  */
@@ -8,10 +8,10 @@ import { clearLock } from "./crash-recovery.js";
8
8
  import { releaseSessionLock } from "./session-lock.js";
9
9
  import { nativeHasChanges } from "./native-git-bridge.js";
10
10
 
11
- // ─── SIGTERM Handling ─────────────────────────────────────────────────────────
11
+ // ─── Signal Handling ──────────────────────────────────────────────────────────
12
12
 
13
13
  /**
14
- * Register a SIGTERM handler that clears the lock file and exits cleanly.
14
+ * Register SIGTERM and SIGINT handlers that clear lock files and exit cleanly.
15
15
  * Captures the active base path at registration time so the handler
16
16
  * always references the correct path even if the module variable changes.
17
17
  * Removes any previously registered handler before installing the new one.
@@ -22,20 +22,25 @@ export function registerSigtermHandler(
22
22
  currentBasePath: string,
23
23
  previousHandler: (() => void) | null,
24
24
  ): () => void {
25
- if (previousHandler) process.off("SIGTERM", previousHandler);
25
+ if (previousHandler) {
26
+ process.off("SIGTERM", previousHandler);
27
+ process.off("SIGINT", previousHandler);
28
+ }
26
29
  const handler = () => {
27
30
  releaseSessionLock(currentBasePath);
28
31
  clearLock(currentBasePath);
29
32
  process.exit(0);
30
33
  };
31
34
  process.on("SIGTERM", handler);
35
+ process.on("SIGINT", handler);
32
36
  return handler;
33
37
  }
34
38
 
35
- /** Deregister the SIGTERM handler (called on stop/pause). */
39
+ /** Deregister signal handlers (called on stop/pause). */
36
40
  export function deregisterSigtermHandler(handler: (() => void) | null): void {
37
41
  if (handler) {
38
42
  process.off("SIGTERM", handler);
43
+ process.off("SIGINT", handler);
39
44
  }
40
45
  }
41
46
 
@@ -6,7 +6,7 @@
6
6
  * manages create, enter, detect, and teardown for auto-mode worktrees.
7
7
  */
8
8
 
9
- import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync } from "node:fs";
9
+ import { existsSync, readFileSync, realpathSync, unlinkSync, statSync, rmSync, readdirSync, cpSync, lstatSync as lstatSyncFn } from "node:fs";
10
10
  import { isAbsolute, join, sep } from "node:path";
11
11
  import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
12
12
  import { execSync, execFileSync } from "node:child_process";
@@ -45,6 +45,122 @@ import { getErrorMessage } from "./error-utils.js";
45
45
  /** Original project root before chdir into auto-worktree. */
46
46
  let originalBase: string | null = null;
47
47
 
48
+ // ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
49
+
50
+ /**
51
+ * Sync .gsd/ state from the main repo into the worktree.
52
+ *
53
+ * When .gsd/ is a symlink to the external state directory, both the main
54
+ * repo and worktree share the same directory — no sync needed.
55
+ *
56
+ * When .gsd/ is a real directory (e.g., git-tracked or manage_gitignore:false),
57
+ * the worktree has its own copy that may be stale. This function copies
58
+ * missing milestones, CONTEXT, ROADMAP, DECISIONS, REQUIREMENTS, and
59
+ * PROJECT files from the main repo's .gsd/ into the worktree's .gsd/.
60
+ *
61
+ * Only adds missing content — never overwrites existing files in the worktree
62
+ * (the worktree's execution state is authoritative for in-progress work).
63
+ */
64
+ export function syncGsdStateToWorktree(mainBasePath: string, worktreePath_: string): { synced: string[] } {
65
+ const mainGsd = gsdRoot(mainBasePath);
66
+ const wtGsd = gsdRoot(worktreePath_);
67
+ const synced: string[] = [];
68
+
69
+ // If both resolve to the same directory (symlink), no sync needed
70
+ try {
71
+ const mainResolved = realpathSync(mainGsd);
72
+ const wtResolved = realpathSync(wtGsd);
73
+ if (mainResolved === wtResolved) return { synced };
74
+ } catch {
75
+ // Can't resolve — proceed with sync as a safety measure
76
+ }
77
+
78
+ if (!existsSync(mainGsd) || !existsSync(wtGsd)) return { synced };
79
+
80
+ // Sync root-level .gsd/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE)
81
+ const rootFiles = ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md", "OVERRIDES.md"];
82
+ for (const f of rootFiles) {
83
+ const src = join(mainGsd, f);
84
+ const dst = join(wtGsd, f);
85
+ if (existsSync(src) && !existsSync(dst)) {
86
+ try {
87
+ cpSync(src, dst);
88
+ synced.push(f);
89
+ } catch { /* non-fatal */ }
90
+ }
91
+ }
92
+
93
+ // Sync milestones: copy entire milestone directories that are missing
94
+ const mainMilestonesDir = join(mainGsd, "milestones");
95
+ const wtMilestonesDir = join(wtGsd, "milestones");
96
+ if (existsSync(mainMilestonesDir) && existsSync(wtMilestonesDir)) {
97
+ try {
98
+ const mainMilestones = readdirSync(mainMilestonesDir, { withFileTypes: true })
99
+ .filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))
100
+ .map(d => d.name);
101
+
102
+ for (const mid of mainMilestones) {
103
+ const srcDir = join(mainMilestonesDir, mid);
104
+ const dstDir = join(wtMilestonesDir, mid);
105
+
106
+ if (!existsSync(dstDir)) {
107
+ // Entire milestone missing from worktree — copy it
108
+ try {
109
+ cpSync(srcDir, dstDir, { recursive: true });
110
+ synced.push(`milestones/${mid}/`);
111
+ } catch { /* non-fatal */ }
112
+ } else {
113
+ // Milestone directory exists but may be missing files (stale snapshot).
114
+ // Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.)
115
+ try {
116
+ const srcFiles = readdirSync(srcDir).filter(f => f.endsWith(".md") || f.endsWith(".json"));
117
+ for (const f of srcFiles) {
118
+ const srcFile = join(srcDir, f);
119
+ const dstFile = join(dstDir, f);
120
+ if (!existsSync(dstFile)) {
121
+ try {
122
+ const srcStat = lstatSyncFn(srcFile);
123
+ if (srcStat.isFile()) {
124
+ cpSync(srcFile, dstFile);
125
+ synced.push(`milestones/${mid}/${f}`);
126
+ }
127
+ } catch { /* non-fatal */ }
128
+ }
129
+ }
130
+
131
+ // Sync slices directory if it exists in main but not in worktree
132
+ const srcSlicesDir = join(srcDir, "slices");
133
+ const dstSlicesDir = join(dstDir, "slices");
134
+ if (existsSync(srcSlicesDir) && !existsSync(dstSlicesDir)) {
135
+ try {
136
+ cpSync(srcSlicesDir, dstSlicesDir, { recursive: true });
137
+ synced.push(`milestones/${mid}/slices/`);
138
+ } catch { /* non-fatal */ }
139
+ } else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) {
140
+ // Both exist — sync missing slice directories
141
+ const srcSlices = readdirSync(srcSlicesDir, { withFileTypes: true })
142
+ .filter(d => d.isDirectory())
143
+ .map(d => d.name);
144
+ for (const sid of srcSlices) {
145
+ const srcSlice = join(srcSlicesDir, sid);
146
+ const dstSlice = join(dstSlicesDir, sid);
147
+ if (!existsSync(dstSlice)) {
148
+ try {
149
+ cpSync(srcSlice, dstSlice, { recursive: true });
150
+ synced.push(`milestones/${mid}/slices/${sid}/`);
151
+ } catch { /* non-fatal */ }
152
+ }
153
+ }
154
+ }
155
+ } catch { /* non-fatal */ }
156
+ }
157
+ }
158
+ } catch { /* non-fatal */ }
159
+ }
160
+
161
+ return { synced };
162
+ }
163
+
48
164
  // ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
49
165
 
50
166
  /**
@@ -125,6 +241,12 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
125
241
  // Ensure worktree shares external state via symlink
126
242
  ensureGsdSymlink(info.path);
127
243
 
244
+ // Sync .gsd/ state from main repo into the worktree (#1311).
245
+ // Even with the symlink, the worktree may have stale git-tracked files
246
+ // if .gsd/ is not gitignored. And on fresh create, the milestone files
247
+ // created on main since the branch point won't be in the worktree.
248
+ syncGsdStateToWorktree(basePath, info.path);
249
+
128
250
  // Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
129
251
  const hookError = runWorktreePostCreateHook(basePath, info.path);
130
252
  if (hookError) {
@@ -267,6 +389,18 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
267
389
  throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`);
268
390
  }
269
391
 
392
+ // Ensure worktree shares external state via symlink (#1311).
393
+ // On resume (enterAutoWorktree), the symlink may be missing if it was
394
+ // created before ensureGsdSymlink existed, or the .gsd/ directory may be
395
+ // a stale git-tracked copy instead of a symlink. Refreshing here ensures
396
+ // the worktree sees the same milestone state as the main repo.
397
+ ensureGsdSymlink(p);
398
+
399
+ // Sync .gsd/ state from main repo into worktree (#1311).
400
+ // Covers the case where .gsd/ is a real directory (not symlinked) and
401
+ // milestones were created on main after the worktree was last used.
402
+ syncGsdStateToWorktree(basePath, p);
403
+
270
404
  const previousCwd = process.cwd();
271
405
 
272
406
  try {
@@ -52,8 +52,20 @@ import { handleStart, handleTemplates, getTemplateCompletions } from "./commands
52
52
 
53
53
  /** Resolve the effective project root, accounting for worktree paths. */
54
54
  export function projectRoot(): string {
55
- const root = resolveProjectRoot(process.cwd());
56
- assertSafeDirectory(root);
55
+ const cwd = process.cwd();
56
+ const root = resolveProjectRoot(cwd);
57
+
58
+ // When running inside a GSD worktree, the resolved root may be a "dangerous"
59
+ // directory (e.g., $HOME used as a git repo root — #1317). The safety check
60
+ // should validate the actual working directory, not the upstream root,
61
+ // because the worktree itself is a safe project subdirectory.
62
+ // Only skip the root check when we can confirm we're in a valid worktree.
63
+ if (root !== cwd) {
64
+ // We're in a worktree — validate the worktree path instead of the root
65
+ assertSafeDirectory(cwd);
66
+ } else {
67
+ assertSafeDirectory(root);
68
+ }
57
69
  return root;
58
70
  }
59
71
 
@@ -336,13 +336,17 @@ export class GitServiceImpl {
336
336
  * @param extraExclusions Additional pathspec exclusions beyond RUNTIME_EXCLUSION_PATHS.
337
337
  */
338
338
  private smartStage(extraExclusions: readonly string[] = []): void {
339
- // Always exclude .gsd/ — state is managed externally (symlinked to ~/.gsd/projects/<hash>/)
340
- const allExclusions = [".gsd/", ...extraExclusions];
341
-
342
339
  // One-time cleanup: if runtime files are already tracked in the index
343
340
  // (from older versions where the fallback bug staged them), untrack them
344
341
  // in a dedicated commit. This must happen as a separate commit because
345
342
  // the git reset HEAD step below would otherwise undo the rm --cached.
343
+ //
344
+ // SAFETY: Only untrack the specific RUNTIME paths (activity/, runtime/,
345
+ // auto.lock, etc.) — NOT all of .gsd/. If .gsd/milestones/ files were
346
+ // previously tracked, they stay tracked until the milestone completes
347
+ // and the worktree is torn down. This prevents a mid-execution behavioral
348
+ // discontinuity where the first half of a milestone has .gsd/ artifacts
349
+ // committed but the second half doesn't (#1326).
346
350
  if (!this._runtimeFilesCleanedUp) {
347
351
  let cleaned = false;
348
352
  for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
@@ -357,17 +361,19 @@ export class GitServiceImpl {
357
361
 
358
362
  // Stage everything, then unstage excluded paths.
359
363
  //
360
- // Previous approach used pathspec excludes (:(exclude)...) with git add -A,
361
- // but that fails when .gsd/ is in .gitignore git exits non-zero before
362
- // evaluating the excludes. The catch fallback ran plain `git add -A`,
363
- // staging all tracked runtime files unconditionally and defeating the
364
- // exclusion list entirely.
364
+ // Exclude only RUNTIME paths from staging not the entire .gsd/ directory.
365
+ // When .gsd/milestones/ files are already tracked in the index (projects
366
+ // where .gsd/ is not gitignored, or Windows junctions that git sees as
367
+ // real directories), they should continue to be committed. Excluding the
368
+ // entire .gsd/ directory mid-milestone causes silent commit failure where
369
+ // the second half of a milestone's artifacts are never committed (#1326).
365
370
  //
366
- // git reset HEAD silently succeeds when the path isn't staged, so no
367
- // error handling is needed per-path.
371
+ // If .gsd/ IS in .gitignore (the default for external state projects),
372
+ // git add -A already skips it and the reset is a harmless no-op.
368
373
  nativeAddAll(this.basePath);
369
374
 
370
- for (const exclusion of allExclusions) {
375
+ const runtimeExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
376
+ for (const exclusion of runtimeExclusions) {
371
377
  try { nativeResetPaths(this.basePath, [exclusion]); } catch { /* path not staged — ignore */ }
372
378
  }
373
379
  }
@@ -223,11 +223,22 @@ export default function (pi: ExtensionAPI) {
223
223
  // chance to persist state and pause instead of crashing (see issue #739).
224
224
  if (!process.listeners("uncaughtException").some(l => l.name === "_gsdEpipeGuard")) {
225
225
  const _gsdEpipeGuard = (err: Error): void => {
226
- if ((err as NodeJS.ErrnoException).code === "EPIPE") {
226
+ const code = (err as NodeJS.ErrnoException).code;
227
+ if (code === "EPIPE") {
227
228
  // Pipe closed — nothing we can write; just exit cleanly
228
229
  process.exit(0);
229
230
  }
230
- // Re-throw anything that isn't EPIPE so real crashes still surface
231
+ // ECOMPROMISED: proper-lockfile's update timer detected mtime drift (system
232
+ // sleep, heavy event loop stall, or filesystem precision mismatch on Node.js
233
+ // v25+). The onCompromised callback already set _lockCompromised = true, but
234
+ // due to a subtle interaction between the synchronous fs adapter and the
235
+ // setTimeout boundary, the error can still propagate here as an uncaught
236
+ // exception. Exit cleanly so the process.once("exit") handler removes the
237
+ // lock directory — allowing the next session to acquire cleanly (#1322).
238
+ if (code === "ECOMPROMISED") {
239
+ process.exit(1);
240
+ }
241
+ // Re-throw anything that isn't EPIPE or ECOMPROMISED so real crashes still surface
231
242
  throw err;
232
243
  };
233
244
  process.on("uncaughtException", _gsdEpipeGuard);
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { createRequire } from "node:module";
20
- import { existsSync, readFileSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
20
+ import { existsSync, readFileSync, readdirSync, mkdirSync, unlinkSync, rmSync, statSync } from "node:fs";
21
21
  import { join, dirname } from "node:path";
22
22
  import { gsdRoot } from "./paths.js";
23
23
  import { atomicWriteSync } from "./atomic-write.js";
@@ -54,12 +54,81 @@ let _lockPid: number = 0;
54
54
  /** Set to true when proper-lockfile fires onCompromised (mtime drift, sleep, etc.). */
55
55
  let _lockCompromised: boolean = false;
56
56
 
57
+ /** Whether we've already registered a process.on('exit') handler. */
58
+ let _exitHandlerRegistered: boolean = false;
59
+
57
60
  const LOCK_FILE = "auto.lock";
58
61
 
59
62
  function lockPath(basePath: string): string {
60
63
  return join(gsdRoot(basePath), LOCK_FILE);
61
64
  }
62
65
 
66
+ // ─── Stray Lock Cleanup ─────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Remove numbered lock file variants (e.g. "auto 2.lock", "auto 3.lock")
70
+ * that accumulate from macOS file conflict resolution (iCloud/Dropbox/OneDrive)
71
+ * or other filesystem-level copy-on-conflict behavior (#1315).
72
+ *
73
+ * Also removes stray proper-lockfile directories beyond the canonical `.gsd.lock/`.
74
+ */
75
+ export function cleanupStrayLockFiles(basePath: string): void {
76
+ const gsdDir = gsdRoot(basePath);
77
+
78
+ // Clean numbered auto lock files inside .gsd/
79
+ try {
80
+ if (existsSync(gsdDir)) {
81
+ for (const entry of readdirSync(gsdDir)) {
82
+ // Match "auto <N>.lock" or "auto (<N>).lock" variants but NOT the canonical "auto.lock"
83
+ if (entry !== LOCK_FILE && /^auto\s.+\.lock$/i.test(entry)) {
84
+ try { unlinkSync(join(gsdDir, entry)); } catch { /* best-effort */ }
85
+ }
86
+ }
87
+ }
88
+ } catch { /* non-fatal: directory read failure */ }
89
+
90
+ // Clean stray proper-lockfile directories (e.g. ".gsd 2.lock/")
91
+ // The canonical one is ".gsd.lock/" — anything else is stray.
92
+ try {
93
+ const parentDir = dirname(gsdDir);
94
+ const gsdDirName = gsdDir.split("/").pop() || ".gsd";
95
+ if (existsSync(parentDir)) {
96
+ for (const entry of readdirSync(parentDir)) {
97
+ // Match ".gsd <N>.lock" or ".gsd (<N>).lock" directories but NOT ".gsd.lock"
98
+ if (entry !== `${gsdDirName}.lock` && entry.startsWith(gsdDirName) && entry.endsWith(".lock")) {
99
+ const fullPath = join(parentDir, entry);
100
+ try {
101
+ const stat = statSync(fullPath);
102
+ if (stat.isDirectory()) {
103
+ rmSync(fullPath, { recursive: true, force: true });
104
+ }
105
+ } catch { /* best-effort */ }
106
+ }
107
+ }
108
+ }
109
+ } catch { /* non-fatal */ }
110
+ }
111
+
112
+ /**
113
+ * Register a single process exit handler that cleans up lock state.
114
+ * Uses module-level references so it always operates on current state.
115
+ * Only registers once — subsequent calls are no-ops.
116
+ */
117
+ function ensureExitHandler(gsdDir: string): void {
118
+ if (_exitHandlerRegistered) return;
119
+ _exitHandlerRegistered = true;
120
+
121
+ process.once("exit", () => {
122
+ try {
123
+ if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
124
+ } catch { /* best-effort */ }
125
+ try {
126
+ const lockDir = join(gsdDir + ".lock");
127
+ if (existsSync(lockDir)) rmSync(lockDir, { recursive: true, force: true });
128
+ } catch { /* best-effort */ }
129
+ });
130
+ }
131
+
63
132
  // ─── Public API ─────────────────────────────────────────────────────────────
64
133
 
65
134
  /**
@@ -77,6 +146,9 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
77
146
  // Ensure the directory exists
78
147
  mkdirSync(dirname(lp), { recursive: true });
79
148
 
149
+ // Clean up numbered lock file variants from cloud sync conflicts (#1315)
150
+ cleanupStrayLockFiles(basePath);
151
+
80
152
  // Write our lock data first (the content is informational; the OS lock is the real guard)
81
153
  const lockData: SessionLockData = {
82
154
  pid: process.pid,
@@ -124,15 +196,7 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
124
196
 
125
197
  // Safety net: clean up lock dir on process exit if _releaseFunction
126
198
  // wasn't called (e.g., normal exit after clean completion) (#1245).
127
- const lockDirForCleanup = join(gsdDir + ".lock");
128
- process.once("exit", () => {
129
- try {
130
- if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; }
131
- } catch { /* best-effort */ }
132
- try {
133
- if (existsSync(lockDirForCleanup)) rmSync(lockDirForCleanup, { recursive: true, force: true });
134
- } catch { /* best-effort */ }
135
- });
199
+ ensureExitHandler(gsdDir);
136
200
 
137
201
  // Write the informational lock data
138
202
  atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
@@ -158,18 +222,15 @@ export function acquireSessionLock(basePath: string): SessionLockResult {
158
222
  update: 10_000,
159
223
  onCompromised: () => {
160
224
  _lockCompromised = true;
225
+ _releaseFunction = null;
161
226
  },
162
227
  });
163
228
  _releaseFunction = release;
164
229
  _lockedPath = basePath;
165
230
  _lockPid = process.pid;
166
231
 
167
- // Safety net for retry path too
168
- const retryLockDir = join(gsdDir + ".lock");
169
- process.once("exit", () => {
170
- try { if (_releaseFunction) { _releaseFunction(); _releaseFunction = null; } } catch {}
171
- try { if (existsSync(retryLockDir)) rmSync(retryLockDir, { recursive: true, force: true }); } catch {}
172
- });
232
+ // Safety net uses centralized handler to avoid double-registration
233
+ ensureExitHandler(gsdDir);
173
234
 
174
235
  atomicWriteSync(lp, JSON.stringify(lockData, null, 2));
175
236
  return { acquired: true };
@@ -310,6 +371,9 @@ export function releaseSessionLock(basePath: string): void {
310
371
  // Non-fatal
311
372
  }
312
373
 
374
+ // Clean up numbered lock file variants from cloud sync conflicts (#1315)
375
+ cleanupStrayLockFiles(basePath);
376
+
313
377
  _lockedPath = null;
314
378
  _lockPid = 0;
315
379
  _lockCompromised = false;