aether-colony 1.1.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 (207) hide show
  1. package/.aether/CONTEXT.md +160 -0
  2. package/.aether/QUEEN.md +84 -0
  3. package/.aether/aether-utils.sh +7749 -0
  4. package/.aether/docs/QUEEN-SYSTEM.md +211 -0
  5. package/.aether/docs/README.md +68 -0
  6. package/.aether/docs/caste-system.md +48 -0
  7. package/.aether/docs/disciplines/DISCIPLINES.md +93 -0
  8. package/.aether/docs/disciplines/coding-standards.md +197 -0
  9. package/.aether/docs/disciplines/debugging.md +207 -0
  10. package/.aether/docs/disciplines/learning.md +254 -0
  11. package/.aether/docs/disciplines/tdd.md +257 -0
  12. package/.aether/docs/disciplines/verification-loop.md +167 -0
  13. package/.aether/docs/disciplines/verification.md +116 -0
  14. package/.aether/docs/error-codes.md +268 -0
  15. package/.aether/docs/known-issues.md +233 -0
  16. package/.aether/docs/pheromones.md +205 -0
  17. package/.aether/docs/queen-commands.md +97 -0
  18. package/.aether/exchange/colony-registry.xml +11 -0
  19. package/.aether/exchange/pheromone-xml.sh +575 -0
  20. package/.aether/exchange/pheromones.xml +87 -0
  21. package/.aether/exchange/queen-wisdom.xml +14 -0
  22. package/.aether/exchange/registry-xml.sh +273 -0
  23. package/.aether/exchange/wisdom-xml.sh +319 -0
  24. package/.aether/midden/approach-changes.md +5 -0
  25. package/.aether/midden/build-failures.md +5 -0
  26. package/.aether/midden/test-failures.md +5 -0
  27. package/.aether/model-profiles.yaml +100 -0
  28. package/.aether/rules/aether-colony.md +134 -0
  29. package/.aether/schemas/aether-types.xsd +255 -0
  30. package/.aether/schemas/colony-registry.xsd +309 -0
  31. package/.aether/schemas/example-prompt-builder.xml +234 -0
  32. package/.aether/schemas/pheromone.xsd +163 -0
  33. package/.aether/schemas/prompt.xsd +416 -0
  34. package/.aether/schemas/queen-wisdom.xsd +325 -0
  35. package/.aether/schemas/worker-priming.xsd +276 -0
  36. package/.aether/templates/QUEEN.md.template +79 -0
  37. package/.aether/templates/colony-state-reset.jq.template +22 -0
  38. package/.aether/templates/colony-state.template.json +35 -0
  39. package/.aether/templates/constraints.template.json +9 -0
  40. package/.aether/templates/crowned-anthill.template.md +36 -0
  41. package/.aether/templates/handoff-build-error.template.md +30 -0
  42. package/.aether/templates/handoff-build-success.template.md +39 -0
  43. package/.aether/templates/handoff.template.md +40 -0
  44. package/.aether/templates/learning-observations.template.json +6 -0
  45. package/.aether/templates/midden.template.json +7 -0
  46. package/.aether/templates/pheromones.template.json +6 -0
  47. package/.aether/templates/session.template.json +9 -0
  48. package/.aether/utils/atomic-write.sh +219 -0
  49. package/.aether/utils/chamber-compare.sh +193 -0
  50. package/.aether/utils/chamber-utils.sh +297 -0
  51. package/.aether/utils/colorize-log.sh +132 -0
  52. package/.aether/utils/error-handler.sh +212 -0
  53. package/.aether/utils/file-lock.sh +158 -0
  54. package/.aether/utils/queen-to-md.xsl +395 -0
  55. package/.aether/utils/semantic-cli.sh +413 -0
  56. package/.aether/utils/spawn-tree.sh +428 -0
  57. package/.aether/utils/spawn-with-model.sh +56 -0
  58. package/.aether/utils/state-loader.sh +215 -0
  59. package/.aether/utils/swarm-display.sh +268 -0
  60. package/.aether/utils/watch-spawn-tree.sh +253 -0
  61. package/.aether/utils/xml-compose.sh +253 -0
  62. package/.aether/utils/xml-convert.sh +273 -0
  63. package/.aether/utils/xml-core.sh +186 -0
  64. package/.aether/utils/xml-query.sh +201 -0
  65. package/.aether/utils/xml-utils.sh +110 -0
  66. package/.aether/workers.md +765 -0
  67. package/.claude/agents/ant/aether-ambassador.md +264 -0
  68. package/.claude/agents/ant/aether-archaeologist.md +322 -0
  69. package/.claude/agents/ant/aether-auditor.md +266 -0
  70. package/.claude/agents/ant/aether-builder.md +187 -0
  71. package/.claude/agents/ant/aether-chaos.md +268 -0
  72. package/.claude/agents/ant/aether-chronicler.md +304 -0
  73. package/.claude/agents/ant/aether-gatekeeper.md +325 -0
  74. package/.claude/agents/ant/aether-includer.md +373 -0
  75. package/.claude/agents/ant/aether-keeper.md +271 -0
  76. package/.claude/agents/ant/aether-measurer.md +317 -0
  77. package/.claude/agents/ant/aether-probe.md +210 -0
  78. package/.claude/agents/ant/aether-queen.md +325 -0
  79. package/.claude/agents/ant/aether-route-setter.md +173 -0
  80. package/.claude/agents/ant/aether-sage.md +353 -0
  81. package/.claude/agents/ant/aether-scout.md +142 -0
  82. package/.claude/agents/ant/aether-surveyor-disciplines.md +416 -0
  83. package/.claude/agents/ant/aether-surveyor-nest.md +354 -0
  84. package/.claude/agents/ant/aether-surveyor-pathogens.md +288 -0
  85. package/.claude/agents/ant/aether-surveyor-provisions.md +359 -0
  86. package/.claude/agents/ant/aether-tracker.md +265 -0
  87. package/.claude/agents/ant/aether-watcher.md +244 -0
  88. package/.claude/agents/ant/aether-weaver.md +247 -0
  89. package/.claude/commands/ant/archaeology.md +341 -0
  90. package/.claude/commands/ant/build.md +1160 -0
  91. package/.claude/commands/ant/chaos.md +349 -0
  92. package/.claude/commands/ant/colonize.md +270 -0
  93. package/.claude/commands/ant/continue.md +1070 -0
  94. package/.claude/commands/ant/council.md +309 -0
  95. package/.claude/commands/ant/dream.md +265 -0
  96. package/.claude/commands/ant/entomb.md +487 -0
  97. package/.claude/commands/ant/feedback.md +78 -0
  98. package/.claude/commands/ant/flag.md +139 -0
  99. package/.claude/commands/ant/flags.md +155 -0
  100. package/.claude/commands/ant/focus.md +58 -0
  101. package/.claude/commands/ant/help.md +122 -0
  102. package/.claude/commands/ant/history.md +137 -0
  103. package/.claude/commands/ant/init.md +409 -0
  104. package/.claude/commands/ant/interpret.md +267 -0
  105. package/.claude/commands/ant/lay-eggs.md +201 -0
  106. package/.claude/commands/ant/maturity.md +102 -0
  107. package/.claude/commands/ant/memory-details.md +77 -0
  108. package/.claude/commands/ant/migrate-state.md +165 -0
  109. package/.claude/commands/ant/oracle.md +387 -0
  110. package/.claude/commands/ant/organize.md +227 -0
  111. package/.claude/commands/ant/pause-colony.md +247 -0
  112. package/.claude/commands/ant/phase.md +126 -0
  113. package/.claude/commands/ant/plan.md +544 -0
  114. package/.claude/commands/ant/redirect.md +58 -0
  115. package/.claude/commands/ant/resume-colony.md +182 -0
  116. package/.claude/commands/ant/resume.md +363 -0
  117. package/.claude/commands/ant/seal.md +306 -0
  118. package/.claude/commands/ant/status.md +272 -0
  119. package/.claude/commands/ant/swarm.md +361 -0
  120. package/.claude/commands/ant/tunnels.md +425 -0
  121. package/.claude/commands/ant/update.md +209 -0
  122. package/.claude/commands/ant/verify-castes.md +95 -0
  123. package/.claude/commands/ant/watch.md +238 -0
  124. package/.opencode/agents/aether-ambassador.md +140 -0
  125. package/.opencode/agents/aether-archaeologist.md +108 -0
  126. package/.opencode/agents/aether-auditor.md +144 -0
  127. package/.opencode/agents/aether-builder.md +184 -0
  128. package/.opencode/agents/aether-chaos.md +115 -0
  129. package/.opencode/agents/aether-chronicler.md +122 -0
  130. package/.opencode/agents/aether-gatekeeper.md +116 -0
  131. package/.opencode/agents/aether-includer.md +117 -0
  132. package/.opencode/agents/aether-keeper.md +177 -0
  133. package/.opencode/agents/aether-measurer.md +128 -0
  134. package/.opencode/agents/aether-probe.md +133 -0
  135. package/.opencode/agents/aether-queen.md +286 -0
  136. package/.opencode/agents/aether-route-setter.md +130 -0
  137. package/.opencode/agents/aether-sage.md +106 -0
  138. package/.opencode/agents/aether-scout.md +101 -0
  139. package/.opencode/agents/aether-surveyor-disciplines.md +386 -0
  140. package/.opencode/agents/aether-surveyor-nest.md +324 -0
  141. package/.opencode/agents/aether-surveyor-pathogens.md +259 -0
  142. package/.opencode/agents/aether-surveyor-provisions.md +329 -0
  143. package/.opencode/agents/aether-tracker.md +137 -0
  144. package/.opencode/agents/aether-watcher.md +174 -0
  145. package/.opencode/agents/aether-weaver.md +130 -0
  146. package/.opencode/commands/ant/archaeology.md +338 -0
  147. package/.opencode/commands/ant/build.md +1200 -0
  148. package/.opencode/commands/ant/chaos.md +346 -0
  149. package/.opencode/commands/ant/colonize.md +202 -0
  150. package/.opencode/commands/ant/continue.md +938 -0
  151. package/.opencode/commands/ant/council.md +305 -0
  152. package/.opencode/commands/ant/dream.md +262 -0
  153. package/.opencode/commands/ant/entomb.md +367 -0
  154. package/.opencode/commands/ant/feedback.md +80 -0
  155. package/.opencode/commands/ant/flag.md +137 -0
  156. package/.opencode/commands/ant/flags.md +153 -0
  157. package/.opencode/commands/ant/focus.md +56 -0
  158. package/.opencode/commands/ant/help.md +124 -0
  159. package/.opencode/commands/ant/history.md +127 -0
  160. package/.opencode/commands/ant/init.md +337 -0
  161. package/.opencode/commands/ant/interpret.md +256 -0
  162. package/.opencode/commands/ant/lay-eggs.md +141 -0
  163. package/.opencode/commands/ant/maturity.md +92 -0
  164. package/.opencode/commands/ant/memory-details.md +77 -0
  165. package/.opencode/commands/ant/migrate-state.md +153 -0
  166. package/.opencode/commands/ant/oracle.md +338 -0
  167. package/.opencode/commands/ant/organize.md +224 -0
  168. package/.opencode/commands/ant/pause-colony.md +220 -0
  169. package/.opencode/commands/ant/phase.md +123 -0
  170. package/.opencode/commands/ant/plan.md +531 -0
  171. package/.opencode/commands/ant/redirect.md +67 -0
  172. package/.opencode/commands/ant/resume-colony.md +178 -0
  173. package/.opencode/commands/ant/resume.md +363 -0
  174. package/.opencode/commands/ant/seal.md +247 -0
  175. package/.opencode/commands/ant/status.md +272 -0
  176. package/.opencode/commands/ant/swarm.md +357 -0
  177. package/.opencode/commands/ant/tunnels.md +406 -0
  178. package/.opencode/commands/ant/update.md +191 -0
  179. package/.opencode/commands/ant/verify-castes.md +85 -0
  180. package/.opencode/commands/ant/watch.md +220 -0
  181. package/.opencode/opencode.json +3 -0
  182. package/CHANGELOG.md +325 -0
  183. package/DISCLAIMER.md +74 -0
  184. package/LICENSE +21 -0
  185. package/README.md +258 -0
  186. package/bin/cli.js +2436 -0
  187. package/bin/generate-commands.sh +291 -0
  188. package/bin/lib/caste-colors.js +57 -0
  189. package/bin/lib/colors.js +76 -0
  190. package/bin/lib/errors.js +255 -0
  191. package/bin/lib/event-types.js +190 -0
  192. package/bin/lib/file-lock.js +695 -0
  193. package/bin/lib/init.js +454 -0
  194. package/bin/lib/logger.js +242 -0
  195. package/bin/lib/model-profiles.js +445 -0
  196. package/bin/lib/model-verify.js +288 -0
  197. package/bin/lib/nestmate-loader.js +130 -0
  198. package/bin/lib/proxy-health.js +253 -0
  199. package/bin/lib/spawn-logger.js +266 -0
  200. package/bin/lib/state-guard.js +602 -0
  201. package/bin/lib/state-sync.js +516 -0
  202. package/bin/lib/telemetry.js +441 -0
  203. package/bin/lib/update-transaction.js +1454 -0
  204. package/bin/npx-install.js +178 -0
  205. package/bin/sync-to-runtime.sh +6 -0
  206. package/bin/validate-package.sh +88 -0
  207. package/package.json +70 -0
@@ -0,0 +1,695 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * FileLock - PID-based file locking with stale detection
4
+ *
5
+ * Implements exclusive atomic locks for safe concurrent access to shared resources.
6
+ * Based on the pattern from .aether/utils/file-lock.sh for use in the CLI.
7
+ *
8
+ * @module bin/lib/file-lock
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { FileSystemError, ConfigurationError } = require('./errors');
14
+
15
+ /**
16
+ * Default configuration for lock behavior
17
+ */
18
+ const DEFAULT_OPTIONS = {
19
+ lockDir: '.aether/locks',
20
+ timeout: 5000, // 5 seconds total timeout
21
+ retryInterval: 50, // 50ms between retries
22
+ maxRetries: 100, // Total 5 seconds max wait
23
+ maxLockAge: 5 * 60 * 1000, // 5 minutes - configurable stale lock threshold
24
+ };
25
+
26
+ /**
27
+ * Module-level set of registered cleanup functions (PLAN-006 fix #4)
28
+ * Prevents duplicate cleanup handler registration across multiple FileLock instances
29
+ */
30
+ const registeredCleanups = new Set();
31
+
32
+ /**
33
+ * FileLock class for exclusive file locking with stale detection
34
+ *
35
+ * Enables safe concurrent access to COLONY_STATE.json by multiple processes,
36
+ * preventing race conditions during phase transitions.
37
+ */
38
+ class FileLock {
39
+ /**
40
+ * Create a FileLock instance
41
+ *
42
+ * @param {Object} options - Configuration options
43
+ * @param {string} options.lockDir - Directory for lock files (default: '.aether/locks')
44
+ * @param {number} options.timeout - Total timeout in milliseconds (default: 5000)
45
+ * @param {number} options.retryInterval - Milliseconds between retries (default: 50)
46
+ * @param {number} options.maxRetries - Maximum retry attempts (default: 100)
47
+ * @param {number} options.maxLockAge - Maximum lock age in ms before considered stale (default: 300000)
48
+ */
49
+ constructor(options = {}) {
50
+ this.options = { ...DEFAULT_OPTIONS, ...options };
51
+
52
+ // Validate lockDir (PLAN-006 fix #2)
53
+ if (!this.options.lockDir || typeof this.options.lockDir !== 'string') {
54
+ throw new ConfigurationError(
55
+ 'lockDir must be a non-empty string',
56
+ { lockDir: this.options.lockDir }
57
+ );
58
+ }
59
+
60
+ // Validate timeout (PLAN-006 fix #5)
61
+ if (typeof this.options.timeout !== 'number' || this.options.timeout < 0) {
62
+ throw new ConfigurationError(
63
+ 'timeout must be a non-negative number',
64
+ { timeout: this.options.timeout }
65
+ );
66
+ }
67
+
68
+ // Validate retryInterval
69
+ if (typeof this.options.retryInterval !== 'number' || this.options.retryInterval < 0) {
70
+ throw new ConfigurationError(
71
+ 'retryInterval must be a non-negative number',
72
+ { retryInterval: this.options.retryInterval }
73
+ );
74
+ }
75
+
76
+ // Validate maxRetries
77
+ if (typeof this.options.maxRetries !== 'number' || this.options.maxRetries < 0) {
78
+ throw new ConfigurationError(
79
+ 'maxRetries must be a non-negative number',
80
+ { maxRetries: this.options.maxRetries }
81
+ );
82
+ }
83
+
84
+ // Validate maxLockAge (PLAN-007 Fix 1)
85
+ if (typeof this.options.maxLockAge !== 'number' || this.options.maxLockAge < 0) {
86
+ throw new ConfigurationError(
87
+ 'maxLockAge must be a non-negative number',
88
+ { maxLockAge: this.options.maxLockAge }
89
+ );
90
+ }
91
+
92
+ this.currentLock = null;
93
+ this.currentPidFile = null;
94
+
95
+ // Ensure lock directory exists
96
+ this._ensureLockDir();
97
+
98
+ // Register cleanup handlers
99
+ this._registerCleanupHandlers();
100
+ }
101
+
102
+ /**
103
+ * Ensure the lock directory exists
104
+ * @private
105
+ */
106
+ _ensureLockDir() {
107
+ try {
108
+ if (!fs.existsSync(this.options.lockDir)) {
109
+ fs.mkdirSync(this.options.lockDir, { recursive: true });
110
+ }
111
+ } catch (error) {
112
+ throw new FileSystemError(
113
+ `Failed to create lock directory: ${this.options.lockDir}`,
114
+ { error: error.message, code: error.code }
115
+ );
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Register process cleanup handlers to ensure locks are released on exit
121
+ * Uses module-level tracking to prevent duplicate registrations (PLAN-006 fix #4)
122
+ * @private
123
+ */
124
+ _registerCleanupHandlers() {
125
+ // Create unique identifier for this cleanup based on lock directory
126
+ const cleanupId = `filelock-${this.options.lockDir}`;
127
+
128
+ // Only register if not already registered
129
+ if (registeredCleanups.has(cleanupId)) {
130
+ return;
131
+ }
132
+
133
+ registeredCleanups.add(cleanupId);
134
+
135
+ const cleanup = () => {
136
+ this.release();
137
+ };
138
+
139
+ // Register for various exit signals
140
+ process.on('exit', cleanup);
141
+ process.on('SIGINT', () => {
142
+ cleanup();
143
+ process.exit(130);
144
+ });
145
+ process.on('SIGTERM', () => {
146
+ cleanup();
147
+ process.exit(143);
148
+ });
149
+
150
+ // Handle uncaught exceptions
151
+ process.on('uncaughtException', (error) => {
152
+ cleanup();
153
+ // Re-throw to allow default handling
154
+ throw error;
155
+ });
156
+
157
+ // Handle unhandled promise rejections
158
+ process.on('unhandledRejection', () => {
159
+ cleanup();
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Generate lock file paths for a given resource
165
+ *
166
+ * @param {string} filePath - Path to the resource to lock
167
+ * @returns {Object} Object containing lockFile and pidFile paths
168
+ * @private
169
+ */
170
+ _getLockPaths(filePath) {
171
+ const baseName = path.basename(filePath);
172
+ const lockFile = path.join(this.options.lockDir, `${baseName}.lock`);
173
+ const pidFile = `${lockFile}.pid`;
174
+ return { lockFile, pidFile };
175
+ }
176
+
177
+ /**
178
+ * Check if a process with the given PID is running
179
+ *
180
+ * @param {number} pid - Process ID to check
181
+ * @returns {boolean} True if process is running, false otherwise
182
+ * @private
183
+ */
184
+ _isProcessRunning(pid) {
185
+ try {
186
+ // process.kill(pid, 0) checks if process exists without sending signal
187
+ process.kill(pid, 0);
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Safely unlink a file, ignoring ENOENT errors
196
+ *
197
+ * @param {string} filePath - Path to file to unlink
198
+ * @private
199
+ */
200
+ _safeUnlink(filePath) {
201
+ try {
202
+ fs.unlinkSync(filePath);
203
+ } catch (error) {
204
+ if (error.code !== 'ENOENT') {
205
+ // Log but don't throw - we're cleaning up
206
+ console.warn(`Warning: Failed to clean up ${filePath}: ${error.message}`);
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Clean up stale lock files
213
+ *
214
+ * Checks both PID file and lock file for PID (handles crash scenarios
215
+ * where only one file was created). Also checks lock age to handle
216
+ * PID reuse race condition.
217
+ *
218
+ * @param {string} lockFile - Path to lock file
219
+ * @param {string} pidFile - Path to PID file
220
+ * @returns {boolean} True if lock was cleaned up, false if still held
221
+ * @private
222
+ */
223
+ _cleanupStaleLock(lockFile, pidFile) {
224
+ // Maximum lock age before considering stale (configurable, default 5 minutes)
225
+ // This handles PID reuse race condition
226
+ const maxLockAgeMs = this.options.maxLockAge;
227
+
228
+ try {
229
+ // Check lock file age first (handles PID reuse)
230
+ if (fs.existsSync(lockFile)) {
231
+ try {
232
+ const stat = fs.statSync(lockFile);
233
+ const lockAge = Date.now() - stat.mtimeMs;
234
+
235
+ if (lockAge > maxLockAgeMs) {
236
+ // Lock is old enough to be considered stale regardless of PID
237
+ this._safeUnlink(lockFile);
238
+ this._safeUnlink(pidFile);
239
+ return true;
240
+ }
241
+ } catch {
242
+ // Cannot stat, proceed with PID check
243
+ }
244
+ }
245
+
246
+ let pid = null;
247
+
248
+ // Try to read PID from PID file first
249
+ if (fs.existsSync(pidFile)) {
250
+ try {
251
+ const pidData = fs.readFileSync(pidFile, 'utf8').trim();
252
+
253
+ // Validate PID is a positive integer (PLAN-006 fix #3)
254
+ if (!/^\d+$/.test(pidData)) {
255
+ // PID file contains invalid data - clean it up
256
+ this._safeUnlink(lockFile);
257
+ this._safeUnlink(pidFile);
258
+ return true;
259
+ }
260
+
261
+ pid = parseInt(pidData, 10);
262
+ } catch (readError) {
263
+ // PID file unreadable - will clean up
264
+ }
265
+ }
266
+
267
+ // If no valid PID from PID file, try lock file itself
268
+ if ((pid === null || isNaN(pid)) && fs.existsSync(lockFile)) {
269
+ try {
270
+ const lockData = fs.readFileSync(lockFile, 'utf8').trim();
271
+
272
+ // Validate PID is a positive integer
273
+ if (!/^\d+$/.test(lockData)) {
274
+ // Lock file contains invalid data - clean it up
275
+ this._safeUnlink(lockFile);
276
+ this._safeUnlink(pidFile);
277
+ return true;
278
+ }
279
+
280
+ pid = parseInt(lockData, 10);
281
+ } catch (readError) {
282
+ // Lock file unreadable - will clean up
283
+ }
284
+ }
285
+
286
+ // Check if process is running
287
+ if (!isNaN(pid) && this._isProcessRunning(pid)) {
288
+ // Process is running, lock is valid
289
+ return false;
290
+ }
291
+
292
+ // Either no valid PID or process not running - clean up stale lock
293
+ this._safeUnlink(lockFile);
294
+ this._safeUnlink(pidFile);
295
+
296
+ return true;
297
+ } catch (error) {
298
+ if (error.code === 'ENOENT') {
299
+ return true;
300
+ }
301
+ throw new FileSystemError(
302
+ `Failed to clean up stale lock: ${lockFile}`,
303
+ { error: error.message, code: error.code }
304
+ );
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Attempt to acquire a lock atomically
310
+ *
311
+ * Uses PID-file-first ordering for crash recovery:
312
+ * 1. Write PID file first (if this fails, no lock file created)
313
+ * 2. Create lock file atomically
314
+ * 3. On failure, clean up both files
315
+ *
316
+ * @param {string} lockFile - Path to lock file
317
+ * @param {string} pidFile - Path to PID file
318
+ * @returns {boolean} True if lock acquired, false otherwise
319
+ * @throws {FileSystemError} On unexpected filesystem errors
320
+ * @private
321
+ */
322
+ _tryAcquire(lockFile, pidFile) {
323
+ let pidFileCreated = false;
324
+ let lockFileCreated = false;
325
+
326
+ try {
327
+ // Step 1: Write PID file first (easier to clean up if lock fails)
328
+ try {
329
+ fs.writeFileSync(pidFile, process.pid.toString(), 'utf8');
330
+ pidFileCreated = true;
331
+ } catch (pidError) {
332
+ // Cannot write PID file - cannot proceed
333
+ throw new FileSystemError(
334
+ `Failed to write PID file: ${pidFile}`,
335
+ { error: pidError.message, code: pidError.code }
336
+ );
337
+ }
338
+
339
+ // Step 2: Create lock file atomically
340
+ try {
341
+ const fd = fs.openSync(lockFile, 'wx');
342
+ lockFileCreated = true;
343
+
344
+ try {
345
+ // Write PID to lock file as well (for redundancy)
346
+ fs.writeFileSync(fd, process.pid.toString(), 'utf8');
347
+ } finally {
348
+ fs.closeSync(fd);
349
+ }
350
+ } catch (lockError) {
351
+ if (lockError.code === 'EEXIST') {
352
+ // Lock file exists - clean up our PID file
353
+ this._safeUnlink(pidFile);
354
+ return false;
355
+ }
356
+ throw lockError;
357
+ }
358
+
359
+ // Step 3: Track current lock (only after both files created)
360
+ this.currentLock = lockFile;
361
+ this.currentPidFile = pidFile;
362
+
363
+ return true;
364
+
365
+ } catch (error) {
366
+ // Clean up on any failure
367
+ if (lockFileCreated) {
368
+ this._safeUnlink(lockFile);
369
+ }
370
+ if (pidFileCreated) {
371
+ this._safeUnlink(pidFile);
372
+ }
373
+
374
+ if (error instanceof FileSystemError) {
375
+ throw error;
376
+ }
377
+
378
+ throw new FileSystemError(
379
+ `Failed to acquire lock: ${lockFile}`,
380
+ { error: error.message, code: error.code }
381
+ );
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Acquire an exclusive lock on a file (SYNCHRONOUS - may block event loop)
387
+ *
388
+ * WARNING: This method uses a busy-wait loop that blocks the Node.js event loop
389
+ * during retry intervals. For non-blocking operation, use acquireAsync() instead.
390
+ *
391
+ * @param {string} filePath - Path to the file to lock
392
+ * @returns {boolean} True if lock acquired, false on timeout
393
+ * @throws {FileSystemError} On unexpected filesystem errors
394
+ */
395
+ acquire(filePath) {
396
+ const { lockFile, pidFile } = this._getLockPaths(filePath);
397
+
398
+ // Check for existing lock and handle stale locks
399
+ if (fs.existsSync(lockFile)) {
400
+ const cleaned = this._cleanupStaleLock(lockFile, pidFile);
401
+
402
+ if (!cleaned) {
403
+ // Lock is held by a running process, need to retry
404
+ }
405
+ }
406
+
407
+ // Try to acquire lock with retries
408
+ let retryCount = 0;
409
+ const startTime = Date.now();
410
+
411
+ while (retryCount < this.options.maxRetries) {
412
+ // Try to acquire the lock
413
+ if (this._tryAcquire(lockFile, pidFile)) {
414
+ return true;
415
+ }
416
+
417
+ // Check timeout
418
+ if (Date.now() - startTime >= this.options.timeout) {
419
+ return false;
420
+ }
421
+
422
+ // Wait before retry
423
+ retryCount++;
424
+ if (retryCount < this.options.maxRetries) {
425
+ // Simple synchronous delay using busy-wait
426
+ // In practice, this should be rare as locks are short-lived
427
+ const delayStart = Date.now();
428
+ while (Date.now() - delayStart < this.options.retryInterval) {
429
+ // Busy wait for precise timing
430
+ }
431
+ }
432
+ }
433
+
434
+ return false;
435
+ }
436
+
437
+ /**
438
+ * Release the current lock
439
+ *
440
+ * This method is idempotent - safe to call multiple times.
441
+ *
442
+ * @returns {boolean} True if lock was fully released, false if no lock was held
443
+ * or if deletion failed (check console.warn for details)
444
+ */
445
+ release() {
446
+ if (!this.currentLock) {
447
+ return false;
448
+ }
449
+
450
+ let success = true;
451
+
452
+ // Delete lock file
453
+ try {
454
+ if (fs.existsSync(this.currentLock)) {
455
+ fs.unlinkSync(this.currentLock);
456
+ }
457
+ } catch (error) {
458
+ if (error.code !== 'ENOENT') {
459
+ // Log but don't throw - we're cleaning up
460
+ console.warn(`Warning: Failed to remove lock file: ${error.message}`);
461
+ success = false;
462
+ }
463
+ }
464
+
465
+ // Delete PID file
466
+ if (this.currentPidFile) {
467
+ try {
468
+ if (fs.existsSync(this.currentPidFile)) {
469
+ fs.unlinkSync(this.currentPidFile);
470
+ }
471
+ } catch (error) {
472
+ if (error.code !== 'ENOENT') {
473
+ console.warn(`Warning: Failed to remove PID file: ${error.message}`);
474
+ success = false;
475
+ }
476
+ }
477
+ }
478
+
479
+ // Clear state
480
+ this.currentLock = null;
481
+ this.currentPidFile = null;
482
+
483
+ return success;
484
+ }
485
+
486
+ /**
487
+ * Check if a file is currently locked
488
+ *
489
+ * @param {string} filePath - Path to check
490
+ * @returns {boolean} True if locked, false otherwise
491
+ */
492
+ isLocked(filePath) {
493
+ const { lockFile } = this._getLockPaths(filePath);
494
+ return fs.existsSync(lockFile);
495
+ }
496
+
497
+ /**
498
+ * Get the PID of the process holding a lock
499
+ *
500
+ * @param {string} filePath - Path to check
501
+ * @returns {number|null} PID of lock holder, or null if not locked
502
+ */
503
+ getLockHolder(filePath) {
504
+ const { pidFile } = this._getLockPaths(filePath);
505
+
506
+ try {
507
+ if (!fs.existsSync(pidFile)) {
508
+ return null;
509
+ }
510
+
511
+ const pidData = fs.readFileSync(pidFile, 'utf8').trim();
512
+ const pid = parseInt(pidData, 10);
513
+
514
+ return isNaN(pid) ? null : pid;
515
+ } catch {
516
+ return null;
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Wait for a lock to be released (SYNCHRONOUS - may block event loop)
522
+ *
523
+ * WARNING: This method uses a busy-wait loop that blocks the Node.js event loop.
524
+ * For non-blocking operation, use waitForLockAsync() instead.
525
+ *
526
+ * @param {string} filePath - Path to wait for
527
+ * @param {number} maxWait - Maximum milliseconds to wait (default: timeout option)
528
+ * @returns {boolean} True if lock was released, false on timeout
529
+ */
530
+ waitForLock(filePath, maxWait = null) {
531
+ const waitTime = maxWait || this.options.timeout;
532
+ const startTime = Date.now();
533
+
534
+ while (this.isLocked(filePath)) {
535
+ if (Date.now() - startTime >= waitTime) {
536
+ return false;
537
+ }
538
+
539
+ // Small delay between checks
540
+ const delayStart = Date.now();
541
+ while (Date.now() - delayStart < 10) {
542
+ // 10ms busy-wait
543
+ }
544
+ }
545
+
546
+ return true;
547
+ }
548
+
549
+ /**
550
+ * Acquire an exclusive lock asynchronously (yields to event loop)
551
+ *
552
+ * This is the non-blocking version of acquire(). It uses setTimeout for
553
+ * delays, allowing other async operations to run during wait periods.
554
+ *
555
+ * @param {string} filePath - Path to the file to lock
556
+ * @returns {Promise<boolean>} True if lock acquired, false on timeout
557
+ * @throws {FileSystemError} On unexpected filesystem errors
558
+ */
559
+ async acquireAsync(filePath) {
560
+ const { lockFile, pidFile } = this._getLockPaths(filePath);
561
+
562
+ // Check for existing lock and handle stale locks
563
+ if (fs.existsSync(lockFile)) {
564
+ this._cleanupStaleLock(lockFile, pidFile);
565
+ // Lock is held by running process if not cleaned
566
+ }
567
+
568
+ // Try to acquire lock with retries
569
+ let retryCount = 0;
570
+ const startTime = Date.now();
571
+
572
+ while (retryCount < this.options.maxRetries) {
573
+ // Try to acquire the lock
574
+ if (this._tryAcquire(lockFile, pidFile)) {
575
+ return true;
576
+ }
577
+
578
+ // Check timeout
579
+ if (Date.now() - startTime >= this.options.timeout) {
580
+ return false;
581
+ }
582
+
583
+ // Wait before retry (ASYNC - yields to event loop)
584
+ retryCount++;
585
+ if (retryCount < this.options.maxRetries) {
586
+ await new Promise(resolve =>
587
+ setTimeout(resolve, this.options.retryInterval)
588
+ );
589
+ }
590
+ }
591
+
592
+ return false;
593
+ }
594
+
595
+ /**
596
+ * Wait for a lock to be released asynchronously (yields to event loop)
597
+ *
598
+ * This is the non-blocking version of waitForLock(). It uses setTimeout
599
+ * for delays, allowing other async operations to run during wait periods.
600
+ *
601
+ * @param {string} filePath - Path to wait for
602
+ * @param {number} maxWait - Maximum milliseconds to wait (default: timeout option)
603
+ * @returns {Promise<boolean>} True if lock was released, false on timeout
604
+ */
605
+ async waitForLockAsync(filePath, maxWait = null) {
606
+ const waitTime = maxWait || this.options.timeout;
607
+ const startTime = Date.now();
608
+
609
+ while (this.isLocked(filePath)) {
610
+ if (Date.now() - startTime >= waitTime) {
611
+ return false;
612
+ }
613
+
614
+ // Small async delay (yields to event loop)
615
+ await new Promise(resolve => setTimeout(resolve, 10));
616
+ }
617
+
618
+ return true;
619
+ }
620
+
621
+ /**
622
+ * Force cleanup of all locks in the lock directory
623
+ *
624
+ * Use with caution - only for emergency cleanup.
625
+ *
626
+ * @returns {number} Number of locks cleaned up
627
+ */
628
+ cleanupAll() {
629
+ let cleaned = 0;
630
+
631
+ try {
632
+ if (!fs.existsSync(this.options.lockDir)) {
633
+ return 0;
634
+ }
635
+
636
+ const files = fs.readdirSync(this.options.lockDir);
637
+
638
+ // First pass: identify which locks are held by running processes
639
+ const runningLocks = new Set();
640
+ for (const file of files) {
641
+ if (file.endsWith('.lock')) {
642
+ const filePath = path.join(this.options.lockDir, file);
643
+ const pidFile = `${filePath}.pid`;
644
+
645
+ try {
646
+ if (fs.existsSync(pidFile)) {
647
+ const pidData = fs.readFileSync(pidFile, 'utf8').trim();
648
+ const pid = parseInt(pidData, 10);
649
+
650
+ if (!isNaN(pid) && this._isProcessRunning(pid)) {
651
+ // Process is still running, mark both files to skip
652
+ runningLocks.add(file);
653
+ runningLocks.add(`${file}.pid`);
654
+ }
655
+ }
656
+ } catch {
657
+ // Error reading PID file, treat as stale
658
+ }
659
+ }
660
+ }
661
+
662
+ // Second pass: clean up stale locks
663
+ for (const file of files) {
664
+ if (file.endsWith('.lock') || file.endsWith('.lock.pid')) {
665
+ // Skip if held by running process
666
+ if (runningLocks.has(file)) {
667
+ continue;
668
+ }
669
+
670
+ const filePath = path.join(this.options.lockDir, file);
671
+
672
+ try {
673
+ fs.unlinkSync(filePath);
674
+ cleaned++;
675
+ } catch (error) {
676
+ if (error.code !== 'ENOENT') {
677
+ console.warn(`Warning: Failed to clean up ${filePath}: ${error.message}`);
678
+ }
679
+ }
680
+ }
681
+ }
682
+ } catch (error) {
683
+ if (error.code !== 'ENOENT') {
684
+ throw new FileSystemError(
685
+ `Failed to cleanup locks in ${this.options.lockDir}`,
686
+ { error: error.message, code: error.code }
687
+ );
688
+ }
689
+ }
690
+
691
+ return cleaned;
692
+ }
693
+ }
694
+
695
+ module.exports = { FileLock };