@actiondock/core 2.5.1 → 2.6.0

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 (134) hide show
  1. package/dist/app/app.d.ts +14 -1
  2. package/dist/app/app.js +88 -222
  3. package/dist/app/static-index.d.ts +39 -0
  4. package/dist/app/static-index.js +164 -0
  5. package/dist/app/types.d.ts +5 -0
  6. package/dist/catalog/action-index.js +6 -4
  7. package/dist/catalog/package-catalog.js +2 -1
  8. package/dist/doctor/checks/index.d.ts +5 -0
  9. package/dist/doctor/checks/index.js +5 -0
  10. package/dist/doctor/checks/project-locate.d.ts +6 -0
  11. package/dist/doctor/checks/project-locate.js +36 -0
  12. package/dist/doctor/checks/project.d.ts +6 -0
  13. package/dist/doctor/checks/project.js +383 -0
  14. package/dist/doctor/checks/registry.d.ts +13 -0
  15. package/dist/doctor/checks/registry.js +207 -0
  16. package/dist/doctor/checks/runtime.d.ts +13 -0
  17. package/dist/doctor/checks/runtime.js +90 -0
  18. package/dist/doctor/checks/storage.d.ts +5 -0
  19. package/dist/doctor/checks/storage.js +34 -0
  20. package/dist/doctor/context.d.ts +35 -0
  21. package/dist/doctor/context.js +11 -0
  22. package/dist/doctor/doctor.d.ts +5 -0
  23. package/dist/doctor/doctor.js +33 -602
  24. package/dist/errors.d.ts +2 -0
  25. package/dist/errors.js +2 -0
  26. package/dist/execution/service.js +14 -9
  27. package/dist/host/host.d.ts +33 -0
  28. package/dist/host/host.js +222 -362
  29. package/dist/host/routing.d.ts +92 -0
  30. package/dist/host/routing.js +238 -0
  31. package/dist/host/types.d.ts +8 -0
  32. package/dist/ipc/host.js +38 -0
  33. package/dist/platform/types.d.ts +7 -0
  34. package/dist/process/control-arbiter.d.ts +107 -0
  35. package/dist/process/control-arbiter.js +251 -0
  36. package/dist/process/diagnostics-sink.d.ts +26 -0
  37. package/dist/process/diagnostics-sink.js +35 -0
  38. package/dist/process/index.d.ts +7 -0
  39. package/dist/process/index.js +7 -0
  40. package/dist/process/input-dispatcher.d.ts +88 -0
  41. package/dist/process/input-dispatcher.js +240 -0
  42. package/dist/process/managed-record.d.ts +78 -0
  43. package/dist/process/managed-record.js +1 -0
  44. package/dist/process/process-manager.d.ts +15 -56
  45. package/dist/process/process-manager.js +119 -707
  46. package/dist/process/reservation-table.d.ts +39 -0
  47. package/dist/process/reservation-table.js +80 -0
  48. package/dist/process/run-executor.d.ts +16 -0
  49. package/dist/process/run-executor.js +155 -0
  50. package/dist/process/terminal-output-cache.d.ts +66 -0
  51. package/dist/process/terminal-output-cache.js +132 -0
  52. package/dist/profile/client-actions.d.ts +56 -0
  53. package/dist/profile/client-actions.js +215 -0
  54. package/dist/profile/client-config.d.ts +20 -0
  55. package/dist/profile/client-config.js +51 -0
  56. package/dist/profile/client-health.d.ts +14 -0
  57. package/dist/profile/client-health.js +68 -0
  58. package/dist/profile/client-playbooks.d.ts +19 -0
  59. package/dist/profile/client-playbooks.js +36 -0
  60. package/dist/profile/client-query.d.ts +30 -0
  61. package/dist/profile/client-query.js +70 -0
  62. package/dist/profile/client-runs.d.ts +42 -0
  63. package/dist/profile/client-runs.js +58 -0
  64. package/dist/profile/client-state.d.ts +56 -0
  65. package/dist/profile/client-state.js +83 -0
  66. package/dist/profile/client-transport.d.ts +61 -0
  67. package/dist/profile/client-transport.js +155 -0
  68. package/dist/profile/client.d.ts +23 -142
  69. package/dist/profile/client.js +28 -588
  70. package/dist/profile/manager.js +1 -1
  71. package/dist/project/closure.d.ts +4 -4
  72. package/dist/project/closure.js +11 -5
  73. package/dist/project/init.js +2 -2
  74. package/dist/project/loader.d.ts +0 -25
  75. package/dist/project/loader.js +1 -154
  76. package/dist/project/transactions.d.ts +12 -14
  77. package/dist/project/transactions.js +60 -836
  78. package/dist/registry/registry.js +6 -4
  79. package/dist/registry/resolve.js +1 -1
  80. package/dist/runtime/action-registry.d.ts +45 -0
  81. package/dist/runtime/action-registry.js +108 -0
  82. package/dist/runtime/clock.d.ts +5 -17
  83. package/dist/runtime/clock.js +5 -15
  84. package/dist/runtime/context.d.ts +3 -0
  85. package/dist/runtime/context.js +17 -56
  86. package/dist/runtime/index.d.ts +3 -0
  87. package/dist/runtime/index.js +3 -0
  88. package/dist/runtime/package-runner-factory.d.ts +82 -0
  89. package/dist/runtime/package-runner-factory.js +186 -0
  90. package/dist/runtime/run-persistence.d.ts +85 -0
  91. package/dist/runtime/run-persistence.js +111 -0
  92. package/dist/runtime/runner.d.ts +23 -33
  93. package/dist/runtime/runner.js +92 -286
  94. package/dist/runtime/standalone.d.ts +11 -1
  95. package/dist/runtime/standalone.js +137 -25
  96. package/dist/server/dispatcher.d.ts +8 -3
  97. package/dist/server/dispatcher.js +16 -40
  98. package/dist/server/routes/runs.js +6 -4
  99. package/dist/server/security.d.ts +1 -7
  100. package/dist/server/security.js +2 -13
  101. package/dist/storage/clock.d.ts +25 -0
  102. package/dist/storage/clock.js +23 -0
  103. package/dist/storage/data-dir-lock.d.ts +3 -36
  104. package/dist/storage/data-dir-lock.js +48 -881
  105. package/dist/storage/index.d.ts +8 -1
  106. package/dist/storage/index.js +8 -2
  107. package/dist/storage/lazy.js +1 -0
  108. package/dist/storage/lock-core.d.ts +162 -0
  109. package/dist/storage/lock-core.js +885 -0
  110. package/dist/storage/sqlite.d.ts +19 -6
  111. package/dist/storage/sqlite.js +81 -89
  112. package/dist/storage/types.d.ts +27 -5
  113. package/dist/storage/types.js +21 -0
  114. package/dist/storage/utils.d.ts +22 -0
  115. package/dist/storage/utils.js +34 -0
  116. package/dist/target/local.d.ts +1 -0
  117. package/dist/target/local.js +23 -17
  118. package/dist/target/remote-errors.d.ts +21 -0
  119. package/dist/target/remote-errors.js +51 -0
  120. package/dist/target/remote-polling.d.ts +35 -0
  121. package/dist/target/remote-polling.js +104 -0
  122. package/dist/target/remote.d.ts +4 -0
  123. package/dist/target/remote.js +108 -301
  124. package/dist/target/sse-parser.d.ts +35 -0
  125. package/dist/target/sse-parser.js +163 -0
  126. package/dist/target/target.js +2 -0
  127. package/dist/target/types.d.ts +6 -0
  128. package/dist/utils/index.d.ts +1 -0
  129. package/dist/utils/index.js +1 -0
  130. package/dist/utils/net.d.ts +10 -0
  131. package/dist/utils/net.js +16 -0
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.js +1 -1
  134. package/package.json +2 -2
@@ -1,105 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import fs, { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- /**
6
- * 同步休眠指定毫秒数。
7
- */
8
- function sleepSync(ms) {
9
- try {
10
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
- }
12
- catch {
13
- const end = Date.now() + ms;
14
- while (Date.now() < end) {
15
- // 降级忙等待
16
- }
17
- }
18
- }
19
- /**
20
- * 获取项目排他锁路径最近一次修改时间戳。
21
- */
22
- function getProjectLockMtimeMs(lockPath, isDir) {
23
- try {
24
- const stat = statSync(lockPath);
25
- let mtime = Math.max(stat.mtimeMs, stat.ctimeMs ?? 0);
26
- if (isDir) {
27
- const metaPath = join(lockPath, "metadata.json");
28
- if (existsSync(metaPath)) {
29
- const metaStat = statSync(metaPath);
30
- mtime = Math.max(mtime, metaStat.mtimeMs, metaStat.ctimeMs ?? 0);
31
- }
32
- }
33
- return mtime;
34
- }
35
- catch {
36
- return Date.now();
37
- }
38
- }
39
- /**
40
- * 读取项目排他锁元数据并执行宽限期检测。
41
- */
42
- function readProjectLockWithGracePeriod(lockPath, gracePeriodMs = 3000, deadline) {
43
- if (!existsSync(lockPath)) {
44
- return { exists: false, isDir: false, inGracePeriod: false };
45
- }
46
- let isDir = false;
47
- try {
48
- isDir = statSync(lockPath).isDirectory();
49
- }
50
- catch {
51
- return { exists: false, isDir: false, inGracePeriod: false };
52
- }
53
- const metaPath = isDir ? join(lockPath, "metadata.json") : lockPath;
54
- const tryParse = () => {
55
- try {
56
- if (existsSync(metaPath)) {
57
- const raw = readFileSync(metaPath, "utf-8");
58
- if (raw.trim().length > 0) {
59
- const parsed = JSON.parse(raw);
60
- if (parsed && typeof parsed.pid === "number") {
61
- return parsed;
62
- }
63
- }
64
- }
65
- }
66
- catch {
67
- // 损坏或并发写入中
68
- }
69
- return undefined;
70
- };
71
- const initialInfo = tryParse();
72
- if (initialInfo) {
73
- return { exists: true, isDir, info: initialInfo, inGracePeriod: false };
74
- }
75
- const mtime = getProjectLockMtimeMs(lockPath, isDir);
76
- const age = Date.now() - mtime;
77
- if (age < gracePeriodMs) {
78
- const maxRetries = 20;
79
- for (let i = 0; i < maxRetries; i++) {
80
- if (deadline !== undefined && Date.now() >= deadline) {
81
- break;
82
- }
83
- const remaining = deadline !== undefined ? deadline - Date.now() : 50;
84
- if (remaining <= 0) {
85
- break;
86
- }
87
- sleepSync(Math.min(50, remaining));
88
- const retriedInfo = tryParse();
89
- if (retriedInfo) {
90
- return { exists: true, isDir, info: retriedInfo, inGracePeriod: false };
91
- }
92
- if (!existsSync(lockPath)) {
93
- return { exists: false, isDir: false, inGracePeriod: false };
94
- }
95
- }
96
- const currentAge = Date.now() - getProjectLockMtimeMs(lockPath, isDir);
97
- if (currentAge < gracePeriodMs) {
98
- return { exists: true, isDir, inGracePeriod: true };
99
- }
100
- }
101
- return { exists: true, isDir, inGracePeriod: false };
102
- }
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { acquireDirectoryLock, checkReclaimGuard, cleanStaleQuarantines, isProcessAlive, parseQuarantineTimestamp as parseQuarantineTimestampCore, readLockWithGracePeriod, safeReleaseLock as coreSafeReleaseLock, safeRemoveStaleReclaimGuard, safeRollbackLock, } from "../storage/lock-core.js";
103
6
  import { LOCKFILE_NAME } from "./lockfile.js";
104
7
  import { MANIFEST_FILE_NAME } from "./manifest.js";
105
8
  export const SNAPSHOT_TRACKED_FILES = [
@@ -113,92 +16,21 @@ export const SNAPSHOT_TRACKED_FILES = [
113
16
  "pnpm-lock.yaml",
114
17
  "yarn.lock",
115
18
  ];
19
+ const LOCK_DIR_NAME = "project.lock";
116
20
  /**
117
21
  * 检查当前进程 PID 是否处于活跃运行状态。
118
22
  */
119
23
  export function isPidAlive(pid) {
120
- if (typeof pid !== "number" || isNaN(pid) || pid <= 0) {
121
- return false;
122
- }
123
- try {
124
- process.kill(pid, 0);
125
- return true;
126
- }
127
- catch (err) {
128
- return err?.code === "EPERM";
129
- }
24
+ return isProcessAlive(pid);
130
25
  }
131
26
  /**
132
- * 检查接管守卫(reclaim guard)目录状态。
133
- *
134
- * - 若目录不存在:返回非活跃。
135
- * - 若处于宽限期内或持有者存活:返回活跃,禁止抢先创建主锁。
136
- * - 若持有者已死亡且超过宽限期:返回陈旧,可供安全清理。
137
- */
138
- function checkProjectReclaimGuard(reclaimPath, gracePeriodMs = 1000) {
139
- if (!existsSync(reclaimPath)) {
140
- return { exists: false, active: false, isStale: false };
141
- }
142
- let isDir = false;
143
- try {
144
- isDir = statSync(reclaimPath).isDirectory();
145
- }
146
- catch {
147
- return { exists: false, active: false, isStale: false };
148
- }
149
- const metaPath = isDir ? join(reclaimPath, "metadata.json") : reclaimPath;
150
- const tryParse = () => {
151
- try {
152
- if (existsSync(metaPath)) {
153
- const raw = readFileSync(metaPath, "utf-8");
154
- if (raw.trim().length > 0) {
155
- const parsed = JSON.parse(raw);
156
- if (parsed && typeof parsed.pid === "number") {
157
- return parsed;
158
- }
159
- }
160
- }
161
- }
162
- catch { }
163
- return undefined;
164
- };
165
- const info = tryParse();
166
- if (info && typeof info.pid === "number") {
167
- const guardToken = typeof info.guardToken === "string" ? info.guardToken : undefined;
168
- const createdAt = typeof info.createdAt === "number" ? info.createdAt : getProjectLockMtimeMs(reclaimPath, isDir);
169
- const age = Date.now() - createdAt;
170
- if (isPidAlive(info.pid)) {
171
- return { exists: true, active: true, isStale: false, holderPid: info.pid, guardToken };
172
- }
173
- if (age < gracePeriodMs) {
174
- return { exists: true, active: true, isStale: false, holderPid: info.pid, guardToken };
175
- }
176
- return { exists: true, active: false, isStale: true, holderPid: info.pid, guardToken };
177
- }
178
- const mtime = getProjectLockMtimeMs(reclaimPath, isDir);
179
- const age = Date.now() - mtime;
180
- if (age < gracePeriodMs) {
181
- return { exists: true, active: true, isStale: false };
182
- }
183
- return { exists: true, active: false, isStale: true };
184
- }
185
- /**
186
- * 尝试原子获取接管守卫(reclaim guard)。
187
- * 基于 mkdirSync 原子创建目录并写入自身元数据与唯一 guardToken。
27
+ * 从工程锁隔离或回滚临时目录名称中解析操作者 PID 与隔离生成时间戳。
28
+ * 命名规范:*.(quarantine|rollback|release).<pid>.<timestamp>.<uuid> 或 *.orphan.<timestamp>.<uuid>
188
29
  */
189
- function tryAcquireProjectReclaimGuard(reclaimPath, pid, guardToken) {
190
- try {
191
- mkdirSync(reclaimPath, { mode: 0o700 });
192
- const metaPath = join(reclaimPath, "metadata.json");
193
- const tmpPath = join(reclaimPath, `metadata.json.tmp.${pid}.${randomUUID().slice(0, 8)}`);
194
- writeFileSync(tmpPath, JSON.stringify({ pid, guardToken, createdAt: Date.now() }, null, 2), "utf-8");
195
- renameSync(tmpPath, metaPath);
196
- return true;
197
- }
198
- catch {
199
- return false;
200
- }
30
+ export function parseProjectQuarantineTimestamp(entryName) {
31
+ return parseQuarantineTimestampCore(entryName);
201
32
  }
33
+ export const parseQuarantineTimestamp = parseProjectQuarantineTimestamp;
202
34
  /**
203
35
  * 安全清理陈旧接管守卫(reclaim guard)。
204
36
  * 采用原子重命名检疫并严格核对 guardToken 与持有者存活状态,确保仅删除目标陈旧 guard,严禁误删活跃守卫或并发新守卫。
@@ -207,472 +39,29 @@ function tryAcquireProjectReclaimGuard(reclaimPath, pid, guardToken) {
207
39
  * @param expectedGuardToken 预期持有的守卫令牌(必填,拒绝未指定令牌的盲目清理)
208
40
  */
209
41
  export function safeRemoveStaleProjectReclaimGuard(reclaimPath, expectedGuardToken) {
210
- if (!expectedGuardToken || typeof expectedGuardToken !== "string")
211
- return;
212
- if (!existsSync(reclaimPath))
213
- return;
214
- let isDir = false;
215
- try {
216
- isDir = statSync(reclaimPath).isDirectory();
217
- }
218
- catch {
219
- return;
220
- }
221
- const metaPath = isDir ? join(reclaimPath, "metadata.json") : reclaimPath;
222
- if (!existsSync(metaPath)) {
223
- return;
224
- }
225
- try {
226
- const metaStat = statSync(metaPath);
227
- const mtimeMs = metaStat.mtimeMs;
228
- const raw = readFileSync(metaPath, "utf-8");
229
- if (raw.trim().length === 0) {
230
- return;
231
- }
232
- const parsed = JSON.parse(raw);
233
- if (parsed?.guardToken !== expectedGuardToken) {
234
- return;
235
- }
236
- // 自身验证 fail-closed:
237
- // 若不是当前进程自身清理自身守卫(即 parsed.pid !== process.pid):
238
- // 1. 验证持有者 PID 是否存活,若仍存活则判定守卫仍处于活跃状态,严禁删除!
239
- // 2. 验证是否处于创建宽限期内(1000ms),若在宽限期内严禁删除!
240
- if (parsed?.pid !== process.pid) {
241
- if (typeof parsed?.pid === "number" && isPidAlive(parsed.pid)) {
242
- return;
243
- }
244
- if (Date.now() - mtimeMs < 1000) {
245
- return;
246
- }
247
- }
248
- }
249
- catch {
250
- return;
251
- }
252
- const quarantinePath = `${reclaimPath}.quarantine.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
253
- try {
254
- fs.renameSync(reclaimPath, quarantinePath);
255
- }
256
- catch {
257
- return;
258
- }
259
- try {
260
- const quarantinedMetaPath = isDir ? join(quarantinePath, "metadata.json") : quarantinePath;
261
- let actualGuardToken;
262
- let actualPid;
263
- if (existsSync(quarantinedMetaPath)) {
264
- try {
265
- const raw = readFileSync(quarantinedMetaPath, "utf-8");
266
- if (raw.trim().length > 0) {
267
- const parsed = JSON.parse(raw);
268
- actualGuardToken = parsed?.guardToken;
269
- actualPid = typeof parsed?.pid === "number" ? parsed.pid : undefined;
270
- }
271
- }
272
- catch { }
273
- }
274
- // 后置校验:必须再次核对 actualGuardToken 与 expectedGuardToken 一致,
275
- // 且若持有者非自身,必须确保 actualPid 确已死亡。
276
- if (expectedGuardToken !== actualGuardToken ||
277
- (actualPid !== undefined && actualPid !== process.pid && isPidAlive(actualPid))) {
278
- // 并非此前检查的目标陈旧 guard(已被并发者替换或持有者存活)
279
- // 保持隔离状态,转换为 .orphan 脱离态,脱离创建者 Host PID 的存活保护,由 GC 基于真实凭证与时间清理
280
- const orphanPath = `${reclaimPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`;
281
- try {
282
- fs.renameSync(quarantinePath, orphanPath);
283
- }
284
- catch { }
285
- return;
286
- }
287
- // 确认正是目标陈旧 guard(或自身持有的 guard),安全清理
288
- fs.rmSync(quarantinePath, { recursive: true, force: true });
289
- }
290
- catch {
291
- // 发生异常,保持隔离状态,转换为 .orphan 脱离态
292
- const orphanPath = `${reclaimPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`;
293
- try {
294
- fs.renameSync(quarantinePath, orphanPath);
295
- }
296
- catch { }
297
- }
298
- }
299
- /**
300
- * 从工程锁隔离或回滚临时目录名称中解析操作者 PID 与隔离生成时间戳。
301
- * 命名规范:*.(quarantine|rollback|release).<pid>.<timestamp>.<uuid> 或 *.orphan.<timestamp>.<uuid>
302
- */
303
- export function parseProjectQuarantineTimestamp(entryName) {
304
- const orphanMatch = entryName.match(/\.orphan\.(\d+)(?:\.|$)/);
305
- if (orphanMatch && orphanMatch[1]) {
306
- const ts = parseInt(orphanMatch[1], 10);
307
- return {
308
- timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
309
- };
310
- }
311
- const match = entryName.match(/\.(?:quarantine|rollback|release)\.(\d+)\.(\d+)(?:\.|$)/);
312
- if (match && match[1] && match[2]) {
313
- const pid = parseInt(match[1], 10);
314
- const ts = parseInt(match[2], 10);
315
- return {
316
- operatorPid: !Number.isNaN(pid) && pid > 0 ? pid : undefined,
317
- timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
318
- };
319
- }
320
- const fallbackMatch = entryName.match(/\.(?:quarantine|rollback|release)\.(\d+)(?:\.|$)/);
321
- if (fallbackMatch && fallbackMatch[1]) {
322
- const ts = parseInt(fallbackMatch[1], 10);
323
- return {
324
- timestamp: !Number.isNaN(ts) && ts > 0 ? ts : undefined,
325
- };
326
- }
327
- return {};
328
- }
329
- export const parseQuarantineTimestamp = parseProjectQuarantineTimestamp;
330
- /**
331
- * 读取目标规范路径(工程主锁目录或 reclaim 目录)中记录的当前有效令牌凭据。
332
- */
333
- function readCanonicalProjectToken(targetPath) {
334
- try {
335
- if (!existsSync(targetPath)) {
336
- return undefined;
337
- }
338
- let isDir = false;
339
- try {
340
- isDir = statSync(targetPath).isDirectory();
341
- }
342
- catch {
343
- return undefined;
344
- }
345
- const metaPath = isDir ? join(targetPath, "metadata.json") : targetPath;
346
- if (!existsSync(metaPath)) {
347
- return undefined;
348
- }
349
- const raw = readFileSync(metaPath, "utf-8");
350
- if (raw.trim().length === 0) {
351
- return undefined;
352
- }
353
- const parsed = JSON.parse(raw);
354
- if (typeof parsed?.guardToken === "string" && parsed.guardToken.length > 0) {
355
- return parsed.guardToken;
356
- }
357
- if (typeof parsed?.lockToken === "string" && parsed.lockToken.length > 0) {
358
- return parsed.lockToken;
359
- }
360
- return undefined;
361
- }
362
- catch {
363
- return undefined;
364
- }
365
- }
366
- /**
367
- * 清理过期的工程锁隔离目录(GC 回收机制)。
368
- * 实施分流存活校验,防止误删活跃持锁者与回滚中目录,同时防止孤儿目录无限泄漏:
369
- * - 共同前置条件:存活年龄必须超过 maxAgeMs(默认 10000ms),未超期前严禁清理(保持宽限期)。
370
- * - 活跃隔离目录(quarantine / rollback / release):若 operatorPid 存活或内部 metadata.json 记录的 pid 与 childPids 存活,跳过不予清理。
371
- * - 孤儿脱离目录(orphan):内部旧 metadata.pid 不再作为续命依据;提取其内部 orphanToken,仅当当前规范主路径(主锁目录与 reclaim 目录)依然持有该凭证时保守跳过;规范路径不存在或已变更凭据时直接安全清理。
372
- */
373
- export function cleanStaleProjectQuarantines(parentDir, basePrefix, maxAgeMs = 10000, deadline) {
374
- try {
375
- if (!existsSync(parentDir))
376
- return;
377
- const entries = readdirSync(parentDir);
378
- const now = Date.now();
379
- const canonicalPrimaryLockPath = basePrefix.endsWith(".reclaim")
380
- ? join(parentDir, basePrefix.slice(0, -".reclaim".length))
381
- : join(parentDir, basePrefix);
382
- const canonicalReclaimPath = `${canonicalPrimaryLockPath}.reclaim`;
383
- for (const entry of entries) {
384
- if (deadline !== undefined && Date.now() >= deadline) {
385
- break;
386
- }
387
- if (!entry.startsWith(basePrefix))
388
- continue;
389
- if (!entry.includes(".quarantine.") &&
390
- !entry.includes(".rollback.") &&
391
- !entry.includes(".release.") &&
392
- !entry.includes(".orphan.")) {
393
- continue;
394
- }
395
- const fullPath = join(parentDir, entry);
396
- try {
397
- const { operatorPid, timestamp } = parseProjectQuarantineTimestamp(entry);
398
- // a. 共同前置条件:存活年龄必须超过 maxAgeMs(默认 10000ms),未超期前严禁清理(保持宽限期)
399
- let age;
400
- if (timestamp !== undefined) {
401
- age = now - timestamp;
402
- }
403
- else {
404
- const stat = statSync(fullPath);
405
- age = now - stat.mtimeMs;
406
- }
407
- if (age <= maxAgeMs) {
408
- continue;
409
- }
410
- const isOrphan = entry.includes(".orphan.");
411
- if (!isOrphan) {
412
- // b. 若不是 .orphan.(即活跃的 quarantine / rollback / release 目录):
413
- // - 若 operatorPid 存活,continue;
414
- if (operatorPid !== undefined && isPidAlive(operatorPid)) {
415
- continue;
416
- }
417
- // - 若内部 metadata.json 记录的 pid 或 childPids 存活,continue;
418
- let isDir = false;
419
- try {
420
- isDir = statSync(fullPath).isDirectory();
421
- }
422
- catch {
423
- continue;
424
- }
425
- const metaPath = isDir ? join(fullPath, "metadata.json") : fullPath;
426
- if (existsSync(metaPath)) {
427
- try {
428
- const raw = readFileSync(metaPath, "utf-8");
429
- if (raw.trim().length > 0) {
430
- const meta = JSON.parse(raw);
431
- if (typeof meta?.pid === "number" && isPidAlive(meta.pid)) {
432
- continue;
433
- }
434
- if (Array.isArray(meta?.childPids) &&
435
- meta.childPids.some((childPid) => typeof childPid === "number" && isPidAlive(childPid))) {
436
- continue;
437
- }
438
- }
439
- }
440
- catch {
441
- // 元数据损坏或不可读,不视为存在存活持有者
442
- }
443
- }
444
- rmSync(fullPath, { recursive: true, force: true });
445
- continue;
446
- }
447
- // c. 若是 .orphan. 目录(已脱离规范路径):
448
- // 严禁再用内部旧 metadata.pid 存活为由无限续命!
449
- // 提取 orphan 内部记录的 orphanToken(guardToken ?? lockToken)
450
- const orphanToken = readCanonicalProjectToken(fullPath);
451
- // 核对当前 canonical 规范主路径(主锁目录与 reclaim 目录)的当前凭证:
452
- // 仅当当前规范路径依然持有该 orphanToken 时保守跳过;若规范路径不存在或已切换为其他 token,直接执行 rmSync 安全清理!
453
- const primaryToken = readCanonicalProjectToken(canonicalPrimaryLockPath);
454
- const reclaimToken = readCanonicalProjectToken(canonicalReclaimPath);
455
- if (orphanToken !== undefined &&
456
- (orphanToken === primaryToken || orphanToken === reclaimToken)) {
457
- continue;
458
- }
459
- rmSync(fullPath, { recursive: true, force: true });
460
- }
461
- catch { }
462
- }
463
- }
464
- catch { }
42
+ safeRemoveStaleReclaimGuard(reclaimPath, expectedGuardToken);
465
43
  }
466
44
  /**
467
45
  * 安全回滚当前进程创建的工程修改锁。
468
46
  * 优先核对 lockToken,回退核对 sessionToken,防止误删接管者或并发新锁。
469
47
  */
470
48
  export function safeRollbackProjectLock(lockPath, expectedSessionToken, expectedLockToken) {
471
- if (!existsSync(lockPath))
472
- return;
473
- const quarantinePath = `${lockPath}.rollback.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
474
- try {
475
- renameSync(lockPath, quarantinePath);
476
- }
477
- catch {
478
- return;
479
- }
480
- try {
481
- let isDir = false;
482
- try {
483
- isDir = statSync(quarantinePath).isDirectory();
484
- }
485
- catch {
486
- isDir = false;
487
- }
488
- const metaPath = isDir ? join(quarantinePath, "metadata.json") : quarantinePath;
489
- let actualSessionToken;
490
- let actualLockToken;
491
- if (existsSync(metaPath)) {
492
- try {
493
- const raw = readFileSync(metaPath, "utf-8");
494
- const parsed = JSON.parse(raw);
495
- actualSessionToken = parsed?.sessionToken;
496
- actualLockToken = parsed?.lockToken;
497
- }
498
- catch { }
499
- }
500
- const isMatch = expectedLockToken
501
- ? (actualLockToken === expectedLockToken && actualSessionToken === expectedSessionToken)
502
- : (actualSessionToken === expectedSessionToken);
503
- if (!isMatch) {
504
- // 并非自身刚才创建的锁目录,立即恢复原位!
505
- try {
506
- renameSync(quarantinePath, lockPath);
507
- }
508
- catch {
509
- try {
510
- renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
511
- }
512
- catch { }
513
- }
514
- return;
515
- }
516
- rmSync(quarantinePath, { recursive: true, force: true });
517
- }
518
- catch {
519
- try {
520
- renameSync(quarantinePath, lockPath);
521
- }
522
- catch {
523
- try {
524
- renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
525
- }
526
- catch { }
527
- }
528
- }
49
+ safeRollbackLock(lockPath, expectedSessionToken, expectedLockToken);
529
50
  }
530
51
  /**
531
- * 安全隔离并清理陈旧工程主锁。
532
- * 隔离后复核元数据,确保仅删除目标陈旧锁,若已被更新或仍有存活所有者则恢复原位。
52
+ * 清理过期的工程锁隔离目录(GC 回收机制)。
53
+ * 实施分流存活校验,防止误删活跃持锁者与回滚中目录,同时防止孤儿目录无限泄漏,语义由目录锁内核统一承载。
533
54
  */
534
- function safeQuarantineStaleProjectLock(lockPath, expectedSessionToken, expectedLockToken) {
535
- if (!existsSync(lockPath))
536
- return true;
537
- const quarantinePath = `${lockPath}.quarantine.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
538
- try {
539
- renameSync(lockPath, quarantinePath);
540
- }
541
- catch {
542
- return false;
543
- }
544
- try {
545
- let isDir = false;
546
- try {
547
- isDir = statSync(quarantinePath).isDirectory();
548
- }
549
- catch {
550
- isDir = false;
551
- }
552
- const metaPath = isDir ? join(quarantinePath, "metadata.json") : quarantinePath;
553
- let actualSessionToken;
554
- let actualLockToken;
555
- let actualPid;
556
- if (existsSync(metaPath)) {
557
- try {
558
- const raw = readFileSync(metaPath, "utf-8");
559
- const parsed = JSON.parse(raw);
560
- actualSessionToken = parsed?.sessionToken;
561
- actualLockToken = parsed?.lockToken;
562
- actualPid = parsed?.pid;
563
- }
564
- catch { }
565
- }
566
- const tokenMismatch = expectedLockToken
567
- ? (actualLockToken !== expectedLockToken || (expectedSessionToken && actualSessionToken !== expectedSessionToken))
568
- : ((expectedSessionToken && actualSessionToken !== expectedSessionToken) || (!expectedSessionToken && actualSessionToken));
569
- if (tokenMismatch) {
570
- // 锁已被其他竞争者接管并写入新 token,绝不可删除!立即恢复原位
571
- try {
572
- renameSync(quarantinePath, lockPath);
573
- }
574
- catch {
575
- try {
576
- renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
577
- }
578
- catch { }
579
- }
580
- return false;
581
- }
582
- if (typeof actualPid === "number" && isPidAlive(actualPid)) {
583
- // 持有者实际仍存活,恢复原位
584
- try {
585
- renameSync(quarantinePath, lockPath);
586
- }
587
- catch {
588
- try {
589
- renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
590
- }
591
- catch { }
592
- }
593
- return false;
594
- }
595
- rmSync(quarantinePath, { recursive: true, force: true });
596
- return true;
597
- }
598
- catch {
599
- try {
600
- renameSync(quarantinePath, lockPath);
601
- }
602
- catch {
603
- try {
604
- renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
605
- }
606
- catch { }
607
- }
608
- return false;
609
- }
55
+ export function cleanStaleProjectQuarantines(parentDir, basePrefix, maxAgeMs = 10000, deadline) {
56
+ cleanStaleQuarantines(parentDir, basePrefix, maxAgeMs, deadline);
610
57
  }
611
58
  /**
612
59
  * 安全释放工程主锁。
613
60
  * 仅当锁目录中持有当前 lockToken / sessionToken 时才删除,杜绝删除他人新锁。
61
+ * 释放后同步触发一轮工程锁隔离目录 GC。
614
62
  */
615
63
  export function safeReleaseProjectLock(lockPath, expectedSessionToken, expectedLockToken) {
616
- if (!existsSync(lockPath))
617
- return;
618
- const quarantinePath = `${lockPath}.release.${process.pid}.${Date.now()}.${randomUUID().slice(0, 8)}`;
619
- try {
620
- renameSync(lockPath, quarantinePath);
621
- }
622
- catch {
623
- return;
624
- }
625
- try {
626
- let isDir = false;
627
- try {
628
- isDir = statSync(quarantinePath).isDirectory();
629
- }
630
- catch {
631
- isDir = false;
632
- }
633
- const metaPath = isDir ? join(quarantinePath, "metadata.json") : quarantinePath;
634
- let actualSessionToken;
635
- let actualLockToken;
636
- if (existsSync(metaPath)) {
637
- try {
638
- const raw = readFileSync(metaPath, "utf-8");
639
- const parsed = JSON.parse(raw);
640
- actualSessionToken = parsed?.sessionToken;
641
- actualLockToken = parsed?.lockToken;
642
- }
643
- catch { }
644
- }
645
- const isMatch = expectedLockToken
646
- ? (actualLockToken === expectedLockToken && actualSessionToken === expectedSessionToken)
647
- : (actualSessionToken === expectedSessionToken);
648
- if (isMatch) {
649
- rmSync(quarantinePath, { recursive: true, force: true });
650
- }
651
- else {
652
- // 并非当前会话持有的锁,恢复原位
653
- try {
654
- renameSync(quarantinePath, lockPath);
655
- }
656
- catch {
657
- try {
658
- renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
659
- }
660
- catch { }
661
- }
662
- }
663
- }
664
- catch {
665
- try {
666
- renameSync(quarantinePath, lockPath);
667
- }
668
- catch {
669
- try {
670
- renameSync(quarantinePath, `${lockPath}.orphan.${Date.now()}.${randomUUID().slice(0, 8)}`);
671
- }
672
- catch { }
673
- }
674
- }
675
- cleanStaleProjectQuarantines(dirname(lockPath), "project.lock");
64
+ coreSafeReleaseLock(lockPath, expectedSessionToken, expectedLockToken, { gcAfterRelease: true });
676
65
  }
677
66
  /**
678
67
  * 检查项目修改锁 .actiondock/project.lock 是否存在且持有者 PID 处于存活状态。
@@ -681,10 +70,10 @@ export function safeReleaseProjectLock(lockPath, expectedSessionToken, expectedL
681
70
  * @param excludeSelf 是否排除当前进程本身(默认为 true,即仅当其他活跃进程持锁时判定为锁被占用)
682
71
  */
683
72
  export function isProjectLockHeld(projectRoot, excludeSelf = true) {
684
- const lockPath = join(projectRoot, ".actiondock", "project.lock");
73
+ const lockPath = join(projectRoot, ".actiondock", LOCK_DIR_NAME);
685
74
  const reclaimPath = `${lockPath}.reclaim`;
686
75
  if (existsSync(reclaimPath)) {
687
- const reclaimState = checkProjectReclaimGuard(reclaimPath, 1000);
76
+ const reclaimState = checkReclaimGuard(reclaimPath, 1000);
688
77
  if (reclaimState.active) {
689
78
  if (!excludeSelf || reclaimState.holderPid !== process.pid) {
690
79
  return true;
@@ -695,7 +84,7 @@ export function isProjectLockHeld(projectRoot, excludeSelf = true) {
695
84
  return false;
696
85
  }
697
86
  try {
698
- const lockState = readProjectLockWithGracePeriod(lockPath, 3000);
87
+ const lockState = readLockWithGracePeriod(lockPath, 3000);
699
88
  if (!lockState.exists) {
700
89
  return false;
701
90
  }
@@ -706,7 +95,7 @@ export function isProjectLockHeld(projectRoot, excludeSelf = true) {
706
95
  if (excludeSelf && lockState.info.pid === process.pid) {
707
96
  return false;
708
97
  }
709
- return isPidAlive(lockState.info.pid);
98
+ return isProcessAlive(lockState.info.pid);
710
99
  }
711
100
  }
712
101
  catch {
@@ -725,14 +114,11 @@ export function isProjectLockHeld(projectRoot, excludeSelf = true) {
725
114
  */
726
115
  export function acquireProjectLock(projectRoot, options = {}) {
727
116
  const timeoutMs = options.acquireTimeoutMs ?? 5000;
728
- const deadline = Date.now() + timeoutMs;
729
117
  const metaDir = join(projectRoot, ".actiondock");
730
118
  if (!existsSync(metaDir)) {
731
119
  mkdirSync(metaDir, { recursive: true });
732
120
  }
733
- cleanStaleProjectQuarantines(metaDir, "project.lock", 10000, deadline);
734
- const lockPath = join(metaDir, "project.lock");
735
- const reclaimPath = `${lockPath}.reclaim`;
121
+ const lockPath = join(metaDir, LOCK_DIR_NAME);
736
122
  const sessionToken = options.sessionToken || randomUUID();
737
123
  const lockToken = randomUUID();
738
124
  const currentPid = process.pid;
@@ -743,214 +129,45 @@ export function acquireProjectLock(projectRoot, options = {}) {
743
129
  createdAt: Date.now(),
744
130
  };
745
131
  const content = JSON.stringify(lockData, null, 2);
746
- while (true) {
747
- // 1. 当竞争者尝试创建新主锁时,若检测到 reclaim 目录存在且处于宽限期内或持有者存活,必须等待,禁止在他人正在接管/验证期间抢先创建主锁
748
- const reclaimState = checkProjectReclaimGuard(reclaimPath, 1000);
749
- if (reclaimState.active) {
750
- if (Date.now() >= deadline) {
751
- const busyErr = new Error(`PROJECT_BUSY: Timeout waiting for active reclaim guard (PID ${reclaimState.holderPid ?? "unknown"}) on project '${projectRoot}' after ${timeoutMs}ms`);
132
+ acquireDirectoryLock({
133
+ lockPath,
134
+ parentDir: metaDir,
135
+ basePrefix: LOCK_DIR_NAME,
136
+ metadataContent: content,
137
+ sessionToken,
138
+ lockToken,
139
+ acquireTimeoutMs: options.acquireTimeoutMs,
140
+ createLockError(message) {
141
+ const busyErr = new Error(message);
142
+ busyErr.code = "PROJECT_BUSY";
143
+ return busyErr;
144
+ },
145
+ messages: {
146
+ timeoutWaitingReclaimGuard: (holderPid) => `PROJECT_BUSY: Timeout waiting for active reclaim guard (PID ${holderPid ?? "unknown"}) on project '${projectRoot}' after ${timeoutMs}ms`,
147
+ timeoutAcquireBlockedByGuard: (holderPid) => `PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' due to active reclaim guard (PID ${holderPid}) after ${timeoutMs}ms`,
148
+ timeoutAcquire: () => `PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`,
149
+ timeoutGracePeriod: () => `PROJECT_BUSY: Timeout waiting for project lock grace period on '${projectRoot}' after ${timeoutMs}ms`,
150
+ timeoutGuardContention: () => `PROJECT_BUSY: Timeout contending for project reclaim guard on '${projectRoot}' after ${timeoutMs}ms`,
151
+ timeoutReclaimStale: () => `PROJECT_BUSY: Timeout reclaiming stale project lock on '${projectRoot}' after ${timeoutMs}ms`,
152
+ timeoutCreate: () => `PROJECT_BUSY: Timeout creating project lock on '${projectRoot}' after ${timeoutMs}ms`,
153
+ },
154
+ assertStaleHolderReclaimable(info) {
155
+ const holderPid = info.pid;
156
+ if (isProcessAlive(holderPid)) {
157
+ const busyErr = new Error(`PROJECT_BUSY: Project modification lock is held by PID ${holderPid}. Another command is running in ${projectRoot}.`);
752
158
  busyErr.code = "PROJECT_BUSY";
753
159
  throw busyErr;
754
160
  }
755
- sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
756
- continue;
757
- }
758
- if (reclaimState.isStale && reclaimState.guardToken) {
759
- // 若 reclaim guard 持有者意外崩溃,其他竞争者核对此前检查的 guardToken 安全清理
760
- safeRemoveStaleProjectReclaimGuard(reclaimPath, reclaimState.guardToken);
761
- }
762
- // 2. 尝试常规获取新主锁
763
- try {
764
- fs.mkdirSync(lockPath, { mode: 0o700 });
765
- const metaFile = join(lockPath, "metadata.json");
766
- const tmpFile = join(lockPath, `metadata.json.tmp.${currentPid}.${randomUUID().slice(0, 8)}`);
767
- writeFileSync(tmpFile, content, "utf-8");
768
- renameSync(tmpFile, metaFile);
769
- // 再次确认在此窗口期内是否有他人持有活跃 reclaim guard
770
- const postCheck = checkProjectReclaimGuard(reclaimPath, 1000);
771
- if (postCheck.active && postCheck.holderPid !== currentPid) {
772
- // 仅当锁目录中包含自身创建的 sessionToken 与 lockToken 时安全回滚,杜绝误删他人新锁
773
- safeRollbackProjectLock(lockPath, sessionToken, lockToken);
774
- if (Date.now() >= deadline) {
775
- const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' due to active reclaim guard (PID ${postCheck.holderPid}) after ${timeoutMs}ms`);
776
- busyErr.code = "PROJECT_BUSY";
777
- throw busyErr;
778
- }
779
- sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
780
- continue;
781
- }
782
- break;
783
- }
784
- catch (err) {
785
- if (err && (err.code === "EEXIST" || err.code === "ENOENT")) {
786
- const lockState = readProjectLockWithGracePeriod(lockPath, 3000, deadline);
787
- if (!lockState.exists) {
788
- if (Date.now() >= deadline) {
789
- const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`);
790
- busyErr.code = "PROJECT_BUSY";
791
- throw busyErr;
792
- }
793
- continue;
794
- }
795
- if (lockState.inGracePeriod) {
796
- if (Date.now() >= deadline) {
797
- const busyErr = new Error(`PROJECT_BUSY: Timeout waiting for project lock grace period on '${projectRoot}' after ${timeoutMs}ms`);
798
- busyErr.code = "PROJECT_BUSY";
799
- throw busyErr;
800
- }
801
- sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
802
- continue;
803
- }
804
- if (lockState.info && typeof lockState.info.pid === "number") {
805
- if (isPidAlive(lockState.info.pid)) {
806
- const busyErr = new Error(`PROJECT_BUSY: Project modification lock is held by PID ${lockState.info.pid}. Another command is running in ${projectRoot}.`);
807
- busyErr.code = "PROJECT_BUSY";
808
- throw busyErr;
809
- }
810
- }
811
- const staleSessionToken = lockState.info?.sessionToken;
812
- const staleLockToken = lockState.info?.lockToken;
813
- const guardToken = randomUUID();
814
- // 当识别到主锁为陈旧锁时,竞争者必须先原子竞争获取 reclaim guard
815
- const acquiredReclaim = tryAcquireProjectReclaimGuard(reclaimPath, currentPid, guardToken);
816
- if (!acquiredReclaim) {
817
- const currentReclaim = checkProjectReclaimGuard(reclaimPath, 1000);
818
- if (currentReclaim.isStale && currentReclaim.guardToken) {
819
- safeRemoveStaleProjectReclaimGuard(reclaimPath, currentReclaim.guardToken);
820
- }
821
- if (Date.now() >= deadline) {
822
- const busyErr = new Error(`PROJECT_BUSY: Timeout contending for project reclaim guard on '${projectRoot}' after ${timeoutMs}ms`);
823
- busyErr.code = "PROJECT_BUSY";
824
- throw busyErr;
825
- }
826
- sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
827
- continue;
828
- }
829
- // 仅成功获取 reclaim guard 的唯一胜利者获准执行:
830
- // 复核主锁陈旧性 -> 隔离/清理陈旧锁 -> 原子创建新主锁并写入自身元数据 -> 清理 reclaim guard
831
- try {
832
- const verifyProjectReclaimOwnership = () => {
833
- const ownGuard = checkProjectReclaimGuard(reclaimPath, 1000);
834
- return Boolean(ownGuard.active &&
835
- ownGuard.holderPid === currentPid &&
836
- ownGuard.guardToken === guardToken);
837
- };
838
- // 自检校验自身守卫:验证自身 guardToken 依然有效且持有者为自身 PID
839
- // 若已被抢占或不匹配,立即退出当前接管并 continue 重试,杜绝在守卫已失窃的情况下操作主锁
840
- if (!verifyProjectReclaimOwnership()) {
841
- if (Date.now() >= deadline) {
842
- const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`);
843
- busyErr.code = "PROJECT_BUSY";
844
- throw busyErr;
845
- }
846
- sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
847
- continue;
848
- }
849
- // 1. 复核主锁陈旧性
850
- const recheckState = readProjectLockWithGracePeriod(lockPath, 1000, deadline);
851
- if (recheckState.exists) {
852
- if (recheckState.inGracePeriod) {
853
- if (Date.now() >= deadline) {
854
- const busyErr = new Error(`PROJECT_BUSY: Timeout waiting for project lock grace period on '${projectRoot}' after ${timeoutMs}ms`);
855
- busyErr.code = "PROJECT_BUSY";
856
- throw busyErr;
857
- }
858
- sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
859
- continue;
860
- }
861
- if (recheckState.info &&
862
- typeof recheckState.info.pid === "number" &&
863
- isPidAlive(recheckState.info.pid)) {
864
- const busyErr = new Error(`PROJECT_BUSY: Project modification lock is held by PID ${recheckState.info.pid}. Another command is running in ${projectRoot}.`);
865
- busyErr.code = "PROJECT_BUSY";
866
- throw busyErr;
867
- }
868
- // 2. 隔离并核验清理陈旧主锁(验证 sessionToken 一致,若已被他人占用则恢复原位)
869
- const cleaned = safeQuarantineStaleProjectLock(lockPath, recheckState.info?.sessionToken ?? staleSessionToken, recheckState.info?.lockToken ?? staleLockToken);
870
- if (!cleaned) {
871
- if (Date.now() >= deadline) {
872
- const busyErr = new Error(`PROJECT_BUSY: Timeout reclaiming stale project lock on '${projectRoot}' after ${timeoutMs}ms`);
873
- busyErr.code = "PROJECT_BUSY";
874
- throw busyErr;
875
- }
876
- continue;
877
- }
878
- }
879
- // 后置复核 1:在清理陈旧主锁之后、创建新主锁之前,再次核验 reclaim guard 所有权
880
- // 若守卫在此期间失窃,严禁继续创建新主锁,立即退出重试
881
- if (!verifyProjectReclaimOwnership()) {
882
- if (Date.now() >= deadline) {
883
- const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`);
884
- busyErr.code = "PROJECT_BUSY";
885
- throw busyErr;
886
- }
887
- sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
888
- continue;
889
- }
890
- // 3. 原子创建新主锁并写入自身元数据(严格遵守 deadline,并在每次重试检查时间窗口)
891
- let created = false;
892
- for (let attempt = 0; attempt < 40; attempt++) {
893
- try {
894
- fs.mkdirSync(lockPath, { mode: 0o700 });
895
- created = true;
896
- break;
897
- }
898
- catch (createErr) {
899
- if (createErr?.code === "EEXIST") {
900
- if (Date.now() >= deadline) {
901
- break;
902
- }
903
- const remaining = deadline - Date.now();
904
- if (remaining <= 0) {
905
- break;
906
- }
907
- sleepSync(Math.min(25, remaining));
908
- continue;
909
- }
910
- throw createErr;
911
- }
912
- }
913
- if (!created) {
914
- if (Date.now() >= deadline) {
915
- const busyErr = new Error(`PROJECT_BUSY: Timeout creating project lock on '${projectRoot}' after ${timeoutMs}ms`);
916
- busyErr.code = "PROJECT_BUSY";
917
- throw busyErr;
918
- }
919
- continue;
920
- }
921
- const metaFile = join(lockPath, "metadata.json");
922
- const tmpFile = join(lockPath, `metadata.json.tmp.${currentPid}.${randomUUID().slice(0, 8)}`);
923
- writeFileSync(tmpFile, content, "utf-8");
924
- renameSync(tmpFile, metaFile);
925
- // 后置复核 2:新主锁创建完成后、返回之前再次核验 reclaim guard 所有权
926
- // 若守卫在创建新主锁期间失窃,说明存在并发仲裁漂移,严禁生效并安全回滚自身主锁
927
- if (!verifyProjectReclaimOwnership()) {
928
- safeRollbackProjectLock(lockPath, sessionToken, lockToken);
929
- if (Date.now() >= deadline) {
930
- const busyErr = new Error(`PROJECT_BUSY: Timeout acquiring project lock on '${projectRoot}' after ${timeoutMs}ms`);
931
- busyErr.code = "PROJECT_BUSY";
932
- throw busyErr;
933
- }
934
- sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
935
- continue;
936
- }
937
- break;
938
- }
939
- finally {
940
- // 4. 清理 reclaim guard(必须核对自身 guardToken)
941
- safeRemoveStaleProjectReclaimGuard(reclaimPath, guardToken);
942
- }
943
- }
944
- throw err;
945
- }
946
- }
161
+ },
162
+ onAcquired: () => true,
163
+ });
947
164
  let released = false;
948
165
  return () => {
949
166
  if (released)
950
167
  return;
951
168
  released = true;
952
169
  safeReleaseProjectLock(lockPath, sessionToken, lockToken);
953
- cleanStaleProjectQuarantines(metaDir, "project.lock");
170
+ cleanStaleQuarantines(metaDir, LOCK_DIR_NAME);
954
171
  };
955
172
  }
956
173
  /**
@@ -996,6 +213,13 @@ export function runFrozenInstall(projectRoot) {
996
213
  cwd: projectRoot,
997
214
  stdio: "pipe",
998
215
  shell: process.platform === "win32",
216
+ env: {
217
+ ...process.env,
218
+ // 项目级安装禁用 npm 的 allow-scripts 白名单机制,避免用户全局 .npmrc
219
+ // 的 allow-scripts 约束触发 EALLOWSCRIPTS 导致恢复失败(本安装已带 --ignore-scripts)
220
+ npm_config_allow_scripts: "",
221
+ NPM_CONFIG_ALLOW_SCRIPTS: "",
222
+ },
999
223
  });
1000
224
  if (proc.status !== 0) {
1001
225
  const errorMsg = proc.stderr?.toString() || proc.stdout?.toString() || "Unknown error";