gsd-pi 2.33.0 → 2.33.1
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.
- package/README.md +13 -18
- package/dist/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/dist/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/dist/resources/extensions/gsd/commands.ts +14 -2
- package/dist/resources/extensions/gsd/session-lock.ts +80 -16
- package/dist/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/dist/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/dist/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
- package/dist/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/dist/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/dist/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/dist/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-supervisor.ts +10 -5
- package/src/resources/extensions/gsd/auto-worktree.ts +135 -1
- package/src/resources/extensions/gsd/commands.ts +14 -2
- package/src/resources/extensions/gsd/session-lock.ts +80 -16
- package/src/resources/extensions/gsd/tests/auto-dispatch-loop.test.ts +691 -0
- package/src/resources/extensions/gsd/tests/cache-staleness-regression.test.ts +317 -0
- package/src/resources/extensions/gsd/tests/loop-regression.test.ts +39 -1
- package/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +358 -0
- package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +216 -0
- package/src/resources/extensions/gsd/tests/session-lock.test.ts +119 -0
- package/src/resources/extensions/gsd/tests/worktree-sync-milestones.test.ts +206 -0
- package/dist/resources/extensions/mcporter/extension-manifest.json +0 -12
- 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.
|
|
28
|
-
|
|
29
|
-
- **
|
|
30
|
-
- **
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
34
|
-
- **
|
|
35
|
-
- **
|
|
36
|
-
-
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
39
|
-
- **
|
|
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 —
|
|
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
|
-
// ───
|
|
11
|
+
// ─── Signal Handling ──────────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Register
|
|
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)
|
|
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
|
|
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
|
|
56
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
168
|
-
|
|
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;
|