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.
- package/.aether/CONTEXT.md +160 -0
- package/.aether/QUEEN.md +84 -0
- package/.aether/aether-utils.sh +7749 -0
- package/.aether/docs/QUEEN-SYSTEM.md +211 -0
- package/.aether/docs/README.md +68 -0
- package/.aether/docs/caste-system.md +48 -0
- package/.aether/docs/disciplines/DISCIPLINES.md +93 -0
- package/.aether/docs/disciplines/coding-standards.md +197 -0
- package/.aether/docs/disciplines/debugging.md +207 -0
- package/.aether/docs/disciplines/learning.md +254 -0
- package/.aether/docs/disciplines/tdd.md +257 -0
- package/.aether/docs/disciplines/verification-loop.md +167 -0
- package/.aether/docs/disciplines/verification.md +116 -0
- package/.aether/docs/error-codes.md +268 -0
- package/.aether/docs/known-issues.md +233 -0
- package/.aether/docs/pheromones.md +205 -0
- package/.aether/docs/queen-commands.md +97 -0
- package/.aether/exchange/colony-registry.xml +11 -0
- package/.aether/exchange/pheromone-xml.sh +575 -0
- package/.aether/exchange/pheromones.xml +87 -0
- package/.aether/exchange/queen-wisdom.xml +14 -0
- package/.aether/exchange/registry-xml.sh +273 -0
- package/.aether/exchange/wisdom-xml.sh +319 -0
- package/.aether/midden/approach-changes.md +5 -0
- package/.aether/midden/build-failures.md +5 -0
- package/.aether/midden/test-failures.md +5 -0
- package/.aether/model-profiles.yaml +100 -0
- package/.aether/rules/aether-colony.md +134 -0
- package/.aether/schemas/aether-types.xsd +255 -0
- package/.aether/schemas/colony-registry.xsd +309 -0
- package/.aether/schemas/example-prompt-builder.xml +234 -0
- package/.aether/schemas/pheromone.xsd +163 -0
- package/.aether/schemas/prompt.xsd +416 -0
- package/.aether/schemas/queen-wisdom.xsd +325 -0
- package/.aether/schemas/worker-priming.xsd +276 -0
- package/.aether/templates/QUEEN.md.template +79 -0
- package/.aether/templates/colony-state-reset.jq.template +22 -0
- package/.aether/templates/colony-state.template.json +35 -0
- package/.aether/templates/constraints.template.json +9 -0
- package/.aether/templates/crowned-anthill.template.md +36 -0
- package/.aether/templates/handoff-build-error.template.md +30 -0
- package/.aether/templates/handoff-build-success.template.md +39 -0
- package/.aether/templates/handoff.template.md +40 -0
- package/.aether/templates/learning-observations.template.json +6 -0
- package/.aether/templates/midden.template.json +7 -0
- package/.aether/templates/pheromones.template.json +6 -0
- package/.aether/templates/session.template.json +9 -0
- package/.aether/utils/atomic-write.sh +219 -0
- package/.aether/utils/chamber-compare.sh +193 -0
- package/.aether/utils/chamber-utils.sh +297 -0
- package/.aether/utils/colorize-log.sh +132 -0
- package/.aether/utils/error-handler.sh +212 -0
- package/.aether/utils/file-lock.sh +158 -0
- package/.aether/utils/queen-to-md.xsl +395 -0
- package/.aether/utils/semantic-cli.sh +413 -0
- package/.aether/utils/spawn-tree.sh +428 -0
- package/.aether/utils/spawn-with-model.sh +56 -0
- package/.aether/utils/state-loader.sh +215 -0
- package/.aether/utils/swarm-display.sh +268 -0
- package/.aether/utils/watch-spawn-tree.sh +253 -0
- package/.aether/utils/xml-compose.sh +253 -0
- package/.aether/utils/xml-convert.sh +273 -0
- package/.aether/utils/xml-core.sh +186 -0
- package/.aether/utils/xml-query.sh +201 -0
- package/.aether/utils/xml-utils.sh +110 -0
- package/.aether/workers.md +765 -0
- package/.claude/agents/ant/aether-ambassador.md +264 -0
- package/.claude/agents/ant/aether-archaeologist.md +322 -0
- package/.claude/agents/ant/aether-auditor.md +266 -0
- package/.claude/agents/ant/aether-builder.md +187 -0
- package/.claude/agents/ant/aether-chaos.md +268 -0
- package/.claude/agents/ant/aether-chronicler.md +304 -0
- package/.claude/agents/ant/aether-gatekeeper.md +325 -0
- package/.claude/agents/ant/aether-includer.md +373 -0
- package/.claude/agents/ant/aether-keeper.md +271 -0
- package/.claude/agents/ant/aether-measurer.md +317 -0
- package/.claude/agents/ant/aether-probe.md +210 -0
- package/.claude/agents/ant/aether-queen.md +325 -0
- package/.claude/agents/ant/aether-route-setter.md +173 -0
- package/.claude/agents/ant/aether-sage.md +353 -0
- package/.claude/agents/ant/aether-scout.md +142 -0
- package/.claude/agents/ant/aether-surveyor-disciplines.md +416 -0
- package/.claude/agents/ant/aether-surveyor-nest.md +354 -0
- package/.claude/agents/ant/aether-surveyor-pathogens.md +288 -0
- package/.claude/agents/ant/aether-surveyor-provisions.md +359 -0
- package/.claude/agents/ant/aether-tracker.md +265 -0
- package/.claude/agents/ant/aether-watcher.md +244 -0
- package/.claude/agents/ant/aether-weaver.md +247 -0
- package/.claude/commands/ant/archaeology.md +341 -0
- package/.claude/commands/ant/build.md +1160 -0
- package/.claude/commands/ant/chaos.md +349 -0
- package/.claude/commands/ant/colonize.md +270 -0
- package/.claude/commands/ant/continue.md +1070 -0
- package/.claude/commands/ant/council.md +309 -0
- package/.claude/commands/ant/dream.md +265 -0
- package/.claude/commands/ant/entomb.md +487 -0
- package/.claude/commands/ant/feedback.md +78 -0
- package/.claude/commands/ant/flag.md +139 -0
- package/.claude/commands/ant/flags.md +155 -0
- package/.claude/commands/ant/focus.md +58 -0
- package/.claude/commands/ant/help.md +122 -0
- package/.claude/commands/ant/history.md +137 -0
- package/.claude/commands/ant/init.md +409 -0
- package/.claude/commands/ant/interpret.md +267 -0
- package/.claude/commands/ant/lay-eggs.md +201 -0
- package/.claude/commands/ant/maturity.md +102 -0
- package/.claude/commands/ant/memory-details.md +77 -0
- package/.claude/commands/ant/migrate-state.md +165 -0
- package/.claude/commands/ant/oracle.md +387 -0
- package/.claude/commands/ant/organize.md +227 -0
- package/.claude/commands/ant/pause-colony.md +247 -0
- package/.claude/commands/ant/phase.md +126 -0
- package/.claude/commands/ant/plan.md +544 -0
- package/.claude/commands/ant/redirect.md +58 -0
- package/.claude/commands/ant/resume-colony.md +182 -0
- package/.claude/commands/ant/resume.md +363 -0
- package/.claude/commands/ant/seal.md +306 -0
- package/.claude/commands/ant/status.md +272 -0
- package/.claude/commands/ant/swarm.md +361 -0
- package/.claude/commands/ant/tunnels.md +425 -0
- package/.claude/commands/ant/update.md +209 -0
- package/.claude/commands/ant/verify-castes.md +95 -0
- package/.claude/commands/ant/watch.md +238 -0
- package/.opencode/agents/aether-ambassador.md +140 -0
- package/.opencode/agents/aether-archaeologist.md +108 -0
- package/.opencode/agents/aether-auditor.md +144 -0
- package/.opencode/agents/aether-builder.md +184 -0
- package/.opencode/agents/aether-chaos.md +115 -0
- package/.opencode/agents/aether-chronicler.md +122 -0
- package/.opencode/agents/aether-gatekeeper.md +116 -0
- package/.opencode/agents/aether-includer.md +117 -0
- package/.opencode/agents/aether-keeper.md +177 -0
- package/.opencode/agents/aether-measurer.md +128 -0
- package/.opencode/agents/aether-probe.md +133 -0
- package/.opencode/agents/aether-queen.md +286 -0
- package/.opencode/agents/aether-route-setter.md +130 -0
- package/.opencode/agents/aether-sage.md +106 -0
- package/.opencode/agents/aether-scout.md +101 -0
- package/.opencode/agents/aether-surveyor-disciplines.md +386 -0
- package/.opencode/agents/aether-surveyor-nest.md +324 -0
- package/.opencode/agents/aether-surveyor-pathogens.md +259 -0
- package/.opencode/agents/aether-surveyor-provisions.md +329 -0
- package/.opencode/agents/aether-tracker.md +137 -0
- package/.opencode/agents/aether-watcher.md +174 -0
- package/.opencode/agents/aether-weaver.md +130 -0
- package/.opencode/commands/ant/archaeology.md +338 -0
- package/.opencode/commands/ant/build.md +1200 -0
- package/.opencode/commands/ant/chaos.md +346 -0
- package/.opencode/commands/ant/colonize.md +202 -0
- package/.opencode/commands/ant/continue.md +938 -0
- package/.opencode/commands/ant/council.md +305 -0
- package/.opencode/commands/ant/dream.md +262 -0
- package/.opencode/commands/ant/entomb.md +367 -0
- package/.opencode/commands/ant/feedback.md +80 -0
- package/.opencode/commands/ant/flag.md +137 -0
- package/.opencode/commands/ant/flags.md +153 -0
- package/.opencode/commands/ant/focus.md +56 -0
- package/.opencode/commands/ant/help.md +124 -0
- package/.opencode/commands/ant/history.md +127 -0
- package/.opencode/commands/ant/init.md +337 -0
- package/.opencode/commands/ant/interpret.md +256 -0
- package/.opencode/commands/ant/lay-eggs.md +141 -0
- package/.opencode/commands/ant/maturity.md +92 -0
- package/.opencode/commands/ant/memory-details.md +77 -0
- package/.opencode/commands/ant/migrate-state.md +153 -0
- package/.opencode/commands/ant/oracle.md +338 -0
- package/.opencode/commands/ant/organize.md +224 -0
- package/.opencode/commands/ant/pause-colony.md +220 -0
- package/.opencode/commands/ant/phase.md +123 -0
- package/.opencode/commands/ant/plan.md +531 -0
- package/.opencode/commands/ant/redirect.md +67 -0
- package/.opencode/commands/ant/resume-colony.md +178 -0
- package/.opencode/commands/ant/resume.md +363 -0
- package/.opencode/commands/ant/seal.md +247 -0
- package/.opencode/commands/ant/status.md +272 -0
- package/.opencode/commands/ant/swarm.md +357 -0
- package/.opencode/commands/ant/tunnels.md +406 -0
- package/.opencode/commands/ant/update.md +191 -0
- package/.opencode/commands/ant/verify-castes.md +85 -0
- package/.opencode/commands/ant/watch.md +220 -0
- package/.opencode/opencode.json +3 -0
- package/CHANGELOG.md +325 -0
- package/DISCLAIMER.md +74 -0
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/bin/cli.js +2436 -0
- package/bin/generate-commands.sh +291 -0
- package/bin/lib/caste-colors.js +57 -0
- package/bin/lib/colors.js +76 -0
- package/bin/lib/errors.js +255 -0
- package/bin/lib/event-types.js +190 -0
- package/bin/lib/file-lock.js +695 -0
- package/bin/lib/init.js +454 -0
- package/bin/lib/logger.js +242 -0
- package/bin/lib/model-profiles.js +445 -0
- package/bin/lib/model-verify.js +288 -0
- package/bin/lib/nestmate-loader.js +130 -0
- package/bin/lib/proxy-health.js +253 -0
- package/bin/lib/spawn-logger.js +266 -0
- package/bin/lib/state-guard.js +602 -0
- package/bin/lib/state-sync.js +516 -0
- package/bin/lib/telemetry.js +441 -0
- package/bin/lib/update-transaction.js +1454 -0
- package/bin/npx-install.js +178 -0
- package/bin/sync-to-runtime.sh +6 -0
- package/bin/validate-package.sh +88 -0
- 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 };
|