agentic-kdd 3.5.7 → 3.5.8
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/CLAUDE.md +80 -55
- package/bin/akdd.js +3 -0
- package/lock-manager.cjs +545 -0
- package/package.json +1 -1
- package/templates/.agentic/grafo/lock-manager.cjs +545 -0
package/CLAUDE.md
CHANGED
|
@@ -38,31 +38,6 @@ Variantes: `aa: aprende`, `aa: aprende — módulo [x]`, `aa: aprende [archivo]`
|
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
|
41
|
-
## CUANDO VES aa: sprint
|
|
42
|
-
|
|
43
|
-
Lee `.agentic/agentes/09-sprint.md` y ejecuta su protocolo completo.
|
|
44
|
-
Coordina múltiples tareas encadenadas donde el output de cada una alimenta la siguiente.
|
|
45
|
-
La memoria KDD fluye entre todas las tareas del sprint.
|
|
46
|
-
|
|
47
|
-
Variantes:
|
|
48
|
-
- `aa: sprint — [objetivo]` con lista de tareas explícitas
|
|
49
|
-
- `aa: sprint — [objetivo]` sin lista → inferir tareas y proponer antes de ejecutar
|
|
50
|
-
- `aa: sprint skip` → saltar tarea actual y continuar
|
|
51
|
-
- `aa: sprint abort` → cancelar sprint, mantener lo completado
|
|
52
|
-
|
|
53
|
-
---
|
|
54
|
-
|
|
55
|
-
## CUANDO VES aa: aprende
|
|
56
|
-
|
|
57
|
-
Lee `.agentic/agentes/08-aprende.md` y ejecuta su protocolo completo.
|
|
58
|
-
Analiza el proyecto, detecta patrones/errores/decisiones implícitas
|
|
59
|
-
y propone que registrar — siempre pregunta antes de escribir.
|
|
60
|
-
|
|
61
|
-
Variantes: aa: aprende / aa: aprende modulo [x] / aa: aprende [archivo]
|
|
62
|
-
aa: aprende error: [x] / aa: aprende decision: [x] / aa: aprende patron: [x]
|
|
63
|
-
|
|
64
|
-
---
|
|
65
|
-
|
|
66
41
|
## CUANDO VES aa: help
|
|
67
42
|
|
|
68
43
|
Mostrar exactamente esto:
|
|
@@ -89,36 +64,6 @@ Consulta del grafo (en terminal):
|
|
|
89
64
|
akdd update → actualizar Agentic KDD
|
|
90
65
|
```
|
|
91
66
|
|
|
92
|
-
## CUANDO VES aa: sprint
|
|
93
|
-
|
|
94
|
-
Lee `.agentic/agentes/09-sprint.md` y ejecuta su protocolo completo.
|
|
95
|
-
Coordina múltiples tareas encadenadas donde el output de cada una alimenta la siguiente.
|
|
96
|
-
La memoria KDD fluye entre todas las tareas del sprint.
|
|
97
|
-
|
|
98
|
-
Variantes:
|
|
99
|
-
- `aa: sprint — [objetivo]` con lista de tareas explícitas
|
|
100
|
-
- `aa: sprint — [objetivo]` sin lista → inferir tareas y proponer antes de ejecutar
|
|
101
|
-
- `aa: sprint skip` → saltar tarea actual y continuar
|
|
102
|
-
- `aa: sprint abort` → cancelar sprint, mantener lo completado
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
## CUANDO VES aa: aprende
|
|
107
|
-
|
|
108
|
-
Lee `.agentic/agentes/08-aprende.md` y ejecuta su protocolo.
|
|
109
|
-
Analiza el código, detecta patrones/errores/decisiones implícitas
|
|
110
|
-
y propone qué registrar en memoria KDD antes de escribir nada.
|
|
111
|
-
|
|
112
|
-
Variantes:
|
|
113
|
-
- `aa: aprende` → analiza todo
|
|
114
|
-
- `aa: aprende — módulo [x]` → foco en módulo
|
|
115
|
-
- `aa: aprende [archivo]` → foco en archivo
|
|
116
|
-
- `aa: aprende — error: [x]` → registrar error directo
|
|
117
|
-
- `aa: aprende — decisión: [x]` → registrar decisión directo
|
|
118
|
-
- `aa: aprende — patrón: [x]` → registrar patrón directo
|
|
119
|
-
|
|
120
|
-
---
|
|
121
|
-
|
|
122
67
|
## CUANDO VES aa:
|
|
123
68
|
|
|
124
69
|
```
|
|
@@ -454,3 +399,83 @@ Luego proceder con el pipeline completo como si el dev hubiera escrito `aa:`.
|
|
|
454
399
|
# ============================================================
|
|
455
400
|
# INSTRUCCIONES DEL PROYECTO — agregar las tuyas aquí abajo
|
|
456
401
|
# ============================================================
|
|
402
|
+
|
|
403
|
+
## MODO EXPLORE — aa: explore [objetivo]
|
|
404
|
+
|
|
405
|
+
Antes de implementar, pensar junto al dev sin escribir código.
|
|
406
|
+
|
|
407
|
+
Si el mensaje empieza con `aa: explore` o `aa: think`:
|
|
408
|
+
|
|
409
|
+
1. Leer `.agentic/config.md` y memoria relevante
|
|
410
|
+
2. Analizar el objetivo: ¿qué implica? ¿qué riesgos hay? ¿qué alternativas existen?
|
|
411
|
+
3. Presentar:
|
|
412
|
+
```
|
|
413
|
+
🔍 EXPLORE: [objetivo]
|
|
414
|
+
|
|
415
|
+
Opciones de implementación:
|
|
416
|
+
A) [enfoque 1] — pros/contras
|
|
417
|
+
B) [enfoque 2] — pros/contras
|
|
418
|
+
|
|
419
|
+
Contratos en riesgo: [lista]
|
|
420
|
+
Blast radius estimado: [ALTO/MEDIO/BAJO]
|
|
421
|
+
Archivos que tocaría: [lista]
|
|
422
|
+
|
|
423
|
+
¿Arrancamos con aa: [opción elegida]?
|
|
424
|
+
```
|
|
425
|
+
4. NO escribir ningún archivo. NO correr tests. Solo análisis.
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## CONTRACT GUARD — Registro automático post-TDD Gate
|
|
430
|
+
|
|
431
|
+
Después de cada TDD Gate PASS en cualquier ciclo `aa:`, ejecutar:
|
|
432
|
+
|
|
433
|
+
```
|
|
434
|
+
node .agentic/grafo/tdd-gate.cjs run [area]
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Donde `[area]` es el nombre del módulo implementado (ej: `clients`, `auth`, `invoices`).
|
|
438
|
+
|
|
439
|
+
**Esto es obligatorio.** Sin este paso los contratos no se acumulan.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
# ============================================================
|
|
444
|
+
# INSTRUCCIONES DEL PROYECTO — agregar las tuyas aquí abajo
|
|
445
|
+
# ============================================================
|
|
446
|
+
|
|
447
|
+
## LOCK MANAGER — Desarrollo multi-instancia
|
|
448
|
+
|
|
449
|
+
Cuando múltiples instancias de Cursor o Claude Code trabajan en el mismo proyecto,
|
|
450
|
+
usar lock-manager.cjs para evitar colisiones.
|
|
451
|
+
|
|
452
|
+
### Al INICIO de cada ciclo aa:
|
|
453
|
+
```
|
|
454
|
+
node .agentic/grafo/lock-manager.cjs acquire --module=[área] --files=[archivos] --purpose=[tarea]
|
|
455
|
+
```
|
|
456
|
+
Si retorna 🔴 → STOP. Otro agente está trabajando en ese módulo. Esperar o elegir otro módulo.
|
|
457
|
+
|
|
458
|
+
### ANTES de cualquier migration de Prisma o schema:
|
|
459
|
+
```
|
|
460
|
+
node .agentic/grafo/lock-manager.cjs acquire-schema --purpose=migration
|
|
461
|
+
```
|
|
462
|
+
Si retorna 🔴 → STOP. No correr migrations hasta que el schema esté libre.
|
|
463
|
+
|
|
464
|
+
### AL TERMINAR cada ciclo aa: (después de Memory step):
|
|
465
|
+
```
|
|
466
|
+
node .agentic/grafo/lock-manager.cjs release --module=[área]
|
|
467
|
+
node .agentic/grafo/lock-manager.cjs release-schema # solo si se adquirió
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Comandos disponibles
|
|
471
|
+
```
|
|
472
|
+
akdd locks → ver locks activos
|
|
473
|
+
akdd locks release-all → liberar todos los locks de esta instancia
|
|
474
|
+
akdd locks check --files=src/auth.ts,src/middleware.ts → verificar archivos
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
### Reglas
|
|
478
|
+
- NUNCA adquirir lock de un módulo que ya tiene otro agente
|
|
479
|
+
- Renovar el lock si la tarea tarda más de 25 minutos: `lock-manager.cjs renew --module=[área]`
|
|
480
|
+
- Si Cursor crashea, los locks expiran solos en 30 minutos
|
|
481
|
+
- Schema lock: máximo 10 minutos — solo para el tiempo de la migration
|
package/bin/akdd.js
CHANGED
|
@@ -27,6 +27,8 @@ const HELP = `
|
|
|
27
27
|
akdd update Update agents + engine (memory stays intact)
|
|
28
28
|
akdd onboard Analyze existing project + pre-populate memory
|
|
29
29
|
akdd analyze Cross-artifact consistency check
|
|
30
|
+
akdd locks Lock Manager status
|
|
31
|
+
akdd locks release-all Release all locks for this instance
|
|
30
32
|
akdd health System health check — what's configured, what's missing
|
|
31
33
|
akdd health --fix Auto-fix common issues
|
|
32
34
|
|
|
@@ -160,6 +162,7 @@ switch (command) {
|
|
|
160
162
|
case 'update': update(); break;
|
|
161
163
|
case 'onboard': onboard(); break;
|
|
162
164
|
case 'analyze': runModule('akdd-analyze.cjs', args[0] || 'run'); break;
|
|
165
|
+
case 'locks': runModule('lock-manager.cjs', args[0] || 'status', args[1] || ''); break;
|
|
163
166
|
case 'analyze': analyze(); break;
|
|
164
167
|
|
|
165
168
|
// ── v3.0: Health ──────────────────────────────────────────────────────
|
package/lock-manager.cjs
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Agentic KDD — Lock Manager v2.0
|
|
4
|
+
* Lock real para desarrollo multi-instancia en el mismo proyecto.
|
|
5
|
+
*
|
|
6
|
+
* v2.0 fixes:
|
|
7
|
+
* - WAL mode en SQLite para escrituras concurrentes reales
|
|
8
|
+
* - Acquire atómico con transacción (elimina TOCTOU race condition)
|
|
9
|
+
* - Paths normalizados a relativos lowercase para evitar false misses
|
|
10
|
+
* - INSTANCE_ID persistido en disco (.agentic/_instance_id) — sobrevive restarts
|
|
11
|
+
* - Deadlock detection antes de cada acquire
|
|
12
|
+
* - Cleanup automático de locks huérfanos al iniciar
|
|
13
|
+
*
|
|
14
|
+
* Uso:
|
|
15
|
+
* node .agentic/grafo/lock-manager.cjs acquire --module=auth --files=src/auth.ts
|
|
16
|
+
* node .agentic/grafo/lock-manager.cjs release --module=auth
|
|
17
|
+
* node .agentic/grafo/lock-manager.cjs status
|
|
18
|
+
* node .agentic/grafo/lock-manager.cjs check --files=src/auth.ts,src/middleware.ts
|
|
19
|
+
* node .agentic/grafo/lock-manager.cjs acquire-schema
|
|
20
|
+
* node .agentic/grafo/lock-manager.cjs release-schema
|
|
21
|
+
* node .agentic/grafo/lock-manager.cjs release-all
|
|
22
|
+
* node .agentic/grafo/lock-manager.cjs wait --module=auth [--timeout=300]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
|
|
30
|
+
const ROOT = process.cwd();
|
|
31
|
+
const DB_PATH = path.join(ROOT, '.agentic', 'memoria.db');
|
|
32
|
+
const INST_FILE = path.join(ROOT, '.agentic', '_instance_id');
|
|
33
|
+
|
|
34
|
+
const LOCK_TTL_MINUTES = 30;
|
|
35
|
+
const SCHEMA_TTL_MINUTES = 10;
|
|
36
|
+
const WAIT_POLL_MS = 2000;
|
|
37
|
+
|
|
38
|
+
// ── INSTANCE_ID persistido ────────────────────────────────────────────────────
|
|
39
|
+
// Sobrevive reinicios de Cursor — permite liberar locks de sesiones anteriores
|
|
40
|
+
|
|
41
|
+
function getOrCreateInstanceId() {
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(INST_FILE)) {
|
|
44
|
+
const id = fs.readFileSync(INST_FILE, 'utf8').trim();
|
|
45
|
+
if (id && id.startsWith('inst_')) return id;
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
const id = `inst_${os.hostname().replace(/[^a-zA-Z0-9]/g,'')}_${crypto.randomBytes(6).toString('hex')}`;
|
|
49
|
+
try { fs.writeFileSync(INST_FILE, id, 'utf8'); } catch {}
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const INSTANCE_ID = process.env.AGENTIC_INSTANCE_ID || getOrCreateInstanceId();
|
|
54
|
+
|
|
55
|
+
// ── DB setup ─────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function openDB() {
|
|
58
|
+
const projNodeModules = path.join(ROOT, 'node_modules');
|
|
59
|
+
if (!module.paths.includes(projNodeModules)) module.paths.unshift(projNodeModules);
|
|
60
|
+
const db = new (require('better-sqlite3'))(DB_PATH);
|
|
61
|
+
// WAL mode: permite lecturas concurrentes mientras se escribe
|
|
62
|
+
db.pragma('journal_mode = WAL');
|
|
63
|
+
db.pragma('busy_timeout = 5000'); // esperar hasta 5s si la BD está ocupada
|
|
64
|
+
return db;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ensureSchema(db) {
|
|
68
|
+
db.exec(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS module_locks (
|
|
70
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
module_name TEXT NOT NULL UNIQUE,
|
|
72
|
+
instance_id TEXT NOT NULL,
|
|
73
|
+
files TEXT NOT NULL DEFAULT '[]',
|
|
74
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
75
|
+
expires_at TEXT NOT NULL,
|
|
76
|
+
purpose TEXT,
|
|
77
|
+
pid INTEGER
|
|
78
|
+
)
|
|
79
|
+
`);
|
|
80
|
+
db.exec(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS file_locks (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
file_path TEXT NOT NULL UNIQUE,
|
|
84
|
+
module_name TEXT NOT NULL,
|
|
85
|
+
instance_id TEXT NOT NULL,
|
|
86
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
87
|
+
expires_at TEXT NOT NULL
|
|
88
|
+
)
|
|
89
|
+
`);
|
|
90
|
+
db.exec(`
|
|
91
|
+
CREATE TABLE IF NOT EXISTS schema_lock (
|
|
92
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
93
|
+
instance_id TEXT NOT NULL,
|
|
94
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
95
|
+
expires_at TEXT NOT NULL,
|
|
96
|
+
purpose TEXT,
|
|
97
|
+
pid INTEGER
|
|
98
|
+
)
|
|
99
|
+
`);
|
|
100
|
+
db.exec(`
|
|
101
|
+
CREATE TABLE IF NOT EXISTS lock_waiters (
|
|
102
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
+
module_name TEXT NOT NULL,
|
|
104
|
+
instance_id TEXT NOT NULL,
|
|
105
|
+
waiting_since TEXT NOT NULL DEFAULT (datetime('now')),
|
|
106
|
+
timeout_at TEXT NOT NULL
|
|
107
|
+
)
|
|
108
|
+
`);
|
|
109
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_ml_inst ON module_locks(instance_id)"); } catch {}
|
|
110
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_path ON file_locks(file_path)"); } catch {}
|
|
111
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_module ON file_locks(module_name)"); } catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function expiresAt(minutes) {
|
|
117
|
+
return new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function waitTimeoutAt(seconds) {
|
|
121
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Normaliza paths: absoluto → relativo, backslashes → forward, lowercase en Windows
|
|
125
|
+
function normalizePath(filePath) {
|
|
126
|
+
let p = filePath;
|
|
127
|
+
// Convertir absoluto a relativo si está bajo ROOT
|
|
128
|
+
if (path.isAbsolute(p)) {
|
|
129
|
+
const rel = path.relative(ROOT, p);
|
|
130
|
+
if (!rel.startsWith('..')) p = rel;
|
|
131
|
+
}
|
|
132
|
+
// Forward slashes siempre
|
|
133
|
+
p = p.replace(/\\/g, '/');
|
|
134
|
+
// Quitar ./ inicial
|
|
135
|
+
if (p.startsWith('./')) p = p.slice(2);
|
|
136
|
+
return p;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizePaths(files) {
|
|
140
|
+
return [...new Set(files.map(normalizePath))];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function purgeExpired(db) {
|
|
144
|
+
db.prepare("DELETE FROM module_locks WHERE expires_at < datetime('now')").run();
|
|
145
|
+
db.prepare("DELETE FROM file_locks WHERE expires_at < datetime('now')").run();
|
|
146
|
+
db.prepare("DELETE FROM schema_lock WHERE expires_at < datetime('now')").run();
|
|
147
|
+
db.prepare("DELETE FROM lock_waiters WHERE timeout_at < datetime('now')").run();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Deadlock detection ────────────────────────────────────────────────────────
|
|
151
|
+
// Detecta ciclos: A espera B, B espera A → deadlock
|
|
152
|
+
|
|
153
|
+
function detectDeadlock(db, requestingInstance, targetModule) {
|
|
154
|
+
// ¿Quién tiene targetModule?
|
|
155
|
+
const holder = db.prepare(
|
|
156
|
+
"SELECT instance_id FROM module_locks WHERE module_name = ?"
|
|
157
|
+
).get(targetModule);
|
|
158
|
+
if (!holder) return { deadlock: false };
|
|
159
|
+
|
|
160
|
+
const holderInstance = holder.instance_id;
|
|
161
|
+
if (holderInstance === requestingInstance) return { deadlock: false };
|
|
162
|
+
|
|
163
|
+
// ¿El holder está esperando algún módulo que tenga requestingInstance?
|
|
164
|
+
const holderWaiting = db.prepare(
|
|
165
|
+
"SELECT module_name FROM lock_waiters WHERE instance_id = ?"
|
|
166
|
+
).all(holderInstance);
|
|
167
|
+
|
|
168
|
+
for (const w of holderWaiting) {
|
|
169
|
+
const wastedModuleHolder = db.prepare(
|
|
170
|
+
"SELECT instance_id FROM module_locks WHERE module_name = ?"
|
|
171
|
+
).get(w.module_name);
|
|
172
|
+
if (wastedModuleHolder && wastedModuleHolder.instance_id === requestingInstance) {
|
|
173
|
+
return {
|
|
174
|
+
deadlock: true,
|
|
175
|
+
cycle: `${requestingInstance} → [${targetModule}] → ${holderInstance} → [${w.module_name}] → ${requestingInstance}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { deadlock: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Acquire module lock (atómico) ─────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function acquireModuleLock(db, moduleName, files = [], purpose = '') {
|
|
186
|
+
const normalFiles = normalizePaths(files);
|
|
187
|
+
|
|
188
|
+
// Transacción atómica — elimina TOCTOU
|
|
189
|
+
const acquire = db.transaction(() => {
|
|
190
|
+
purgeExpired(db);
|
|
191
|
+
|
|
192
|
+
// 1. Verificar si el módulo está bloqueado por otra instancia
|
|
193
|
+
const existingModule = db.prepare(
|
|
194
|
+
"SELECT * FROM module_locks WHERE module_name = ?"
|
|
195
|
+
).get(moduleName);
|
|
196
|
+
|
|
197
|
+
if (existingModule && existingModule.instance_id !== INSTANCE_ID) {
|
|
198
|
+
// Detectar deadlock antes de reportar bloqueo
|
|
199
|
+
const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
|
|
200
|
+
return {
|
|
201
|
+
success: false,
|
|
202
|
+
reason: `Module "${moduleName}" locked by ${existingModule.instance_id}`,
|
|
203
|
+
locked_by: existingModule.instance_id,
|
|
204
|
+
expires_at: existingModule.expires_at,
|
|
205
|
+
deadlock: dl.deadlock,
|
|
206
|
+
deadlock_cycle: dl.cycle,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 2. Verificar conflictos de archivos con otras instancias
|
|
211
|
+
const fileConflicts = [];
|
|
212
|
+
for (const file of normalFiles) {
|
|
213
|
+
const fileLock = db.prepare(
|
|
214
|
+
"SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
|
|
215
|
+
).get(file, INSTANCE_ID);
|
|
216
|
+
if (fileLock) {
|
|
217
|
+
fileConflicts.push({ file, locked_by_module: fileLock.module_name, locked_by_instance: fileLock.instance_id });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (fileConflicts.length > 0) {
|
|
222
|
+
return { success: false, reason: 'File conflicts', conflicts: fileConflicts };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 3. Adquirir lock de módulo
|
|
226
|
+
const exp = expiresAt(LOCK_TTL_MINUTES);
|
|
227
|
+
db.prepare(`
|
|
228
|
+
INSERT INTO module_locks (module_name, instance_id, files, acquired_at, expires_at, purpose, pid)
|
|
229
|
+
VALUES (?, ?, ?, datetime('now'), ?, ?, ?)
|
|
230
|
+
ON CONFLICT(module_name) DO UPDATE SET
|
|
231
|
+
instance_id=excluded.instance_id, files=excluded.files,
|
|
232
|
+
acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
|
|
233
|
+
purpose=excluded.purpose, pid=excluded.pid
|
|
234
|
+
`).run(moduleName, INSTANCE_ID, JSON.stringify(normalFiles), exp, purpose, process.pid);
|
|
235
|
+
|
|
236
|
+
// 4. Adquirir locks de archivos
|
|
237
|
+
for (const file of normalFiles) {
|
|
238
|
+
db.prepare(`
|
|
239
|
+
INSERT INTO file_locks (file_path, module_name, instance_id, acquired_at, expires_at)
|
|
240
|
+
VALUES (?, ?, ?, datetime('now'), ?)
|
|
241
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
242
|
+
module_name=excluded.module_name, instance_id=excluded.instance_id,
|
|
243
|
+
acquired_at=excluded.acquired_at, expires_at=excluded.expires_at
|
|
244
|
+
`).run(file, moduleName, INSTANCE_ID, exp);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 5. Remover de waiters si estaba esperando
|
|
248
|
+
db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?")
|
|
249
|
+
.run(moduleName, INSTANCE_ID);
|
|
250
|
+
|
|
251
|
+
return { success: true, instance_id: INSTANCE_ID, module: moduleName, files: normalFiles, expires_at: exp };
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return acquire();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Release module lock ───────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
function releaseModuleLock(db, moduleName) {
|
|
260
|
+
const release = db.transaction(() => {
|
|
261
|
+
const lock = db.prepare(
|
|
262
|
+
"SELECT * FROM module_locks WHERE module_name = ? AND instance_id = ?"
|
|
263
|
+
).get(moduleName, INSTANCE_ID);
|
|
264
|
+
if (!lock) return { success: false, reason: `No lock owned by this instance for [${moduleName}]` };
|
|
265
|
+
|
|
266
|
+
db.prepare("DELETE FROM module_locks WHERE module_name = ? AND instance_id = ?")
|
|
267
|
+
.run(moduleName, INSTANCE_ID);
|
|
268
|
+
db.prepare("DELETE FROM file_locks WHERE module_name = ? AND instance_id = ?")
|
|
269
|
+
.run(moduleName, INSTANCE_ID);
|
|
270
|
+
|
|
271
|
+
return { success: true, module: moduleName };
|
|
272
|
+
});
|
|
273
|
+
return release();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Release all locks for this instance ──────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function releaseAll(db) {
|
|
279
|
+
const rel = db.transaction(() => {
|
|
280
|
+
const modules = db.prepare("SELECT module_name FROM module_locks WHERE instance_id = ?")
|
|
281
|
+
.all(INSTANCE_ID).map(r => r.module_name);
|
|
282
|
+
db.prepare("DELETE FROM module_locks WHERE instance_id = ?").run(INSTANCE_ID);
|
|
283
|
+
db.prepare("DELETE FROM file_locks WHERE instance_id = ?").run(INSTANCE_ID);
|
|
284
|
+
db.prepare("DELETE FROM schema_lock WHERE instance_id = ?").run(INSTANCE_ID);
|
|
285
|
+
db.prepare("DELETE FROM lock_waiters WHERE instance_id = ?").run(INSTANCE_ID);
|
|
286
|
+
return { success: true, released_modules: modules };
|
|
287
|
+
});
|
|
288
|
+
return rel();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Check files ───────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function checkFiles(db, files) {
|
|
294
|
+
purgeExpired(db);
|
|
295
|
+
const normalFiles = normalizePaths(files);
|
|
296
|
+
const conflicts = [];
|
|
297
|
+
for (const file of normalFiles) {
|
|
298
|
+
const lock = db.prepare(
|
|
299
|
+
"SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
|
|
300
|
+
).get(file, INSTANCE_ID);
|
|
301
|
+
if (lock) conflicts.push({ file, locked_by_module: lock.module_name, locked_by_instance: lock.instance_id, expires_at: lock.expires_at });
|
|
302
|
+
}
|
|
303
|
+
return { safe: conflicts.length === 0, conflicts };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Schema lock ───────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
function acquireSchemaLock(db, purpose = 'migration') {
|
|
309
|
+
const acquire = db.transaction(() => {
|
|
310
|
+
purgeExpired(db);
|
|
311
|
+
const existing = db.prepare("SELECT * FROM schema_lock WHERE id = 1").get();
|
|
312
|
+
if (existing && existing.instance_id !== INSTANCE_ID) {
|
|
313
|
+
return { success: false, reason: `Schema locked by ${existing.instance_id} for ${existing.purpose}`, expires_at: existing.expires_at };
|
|
314
|
+
}
|
|
315
|
+
const exp = expiresAt(SCHEMA_TTL_MINUTES);
|
|
316
|
+
db.prepare(`
|
|
317
|
+
INSERT INTO schema_lock (id, instance_id, acquired_at, expires_at, purpose, pid)
|
|
318
|
+
VALUES (1, ?, datetime('now'), ?, ?, ?)
|
|
319
|
+
ON CONFLICT(id) DO UPDATE SET instance_id=excluded.instance_id,
|
|
320
|
+
acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
|
|
321
|
+
purpose=excluded.purpose, pid=excluded.pid
|
|
322
|
+
`).run(INSTANCE_ID, exp, purpose, process.pid);
|
|
323
|
+
return { success: true, instance_id: INSTANCE_ID, purpose, expires_at: exp };
|
|
324
|
+
});
|
|
325
|
+
return acquire();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function releaseSchemaLock(db) {
|
|
329
|
+
const result = db.prepare("DELETE FROM schema_lock WHERE id = 1 AND instance_id = ?").run(INSTANCE_ID);
|
|
330
|
+
return { success: result.changes > 0 };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Wait for lock ─────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
function waitForLock(db, moduleName, timeoutSeconds = 300) {
|
|
336
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
337
|
+
|
|
338
|
+
// Register as waiter
|
|
339
|
+
db.prepare(`
|
|
340
|
+
INSERT OR REPLACE INTO lock_waiters (module_name, instance_id, waiting_since, timeout_at)
|
|
341
|
+
VALUES (?, ?, datetime('now'), ?)
|
|
342
|
+
`).run(moduleName, INSTANCE_ID, waitTimeoutAt(timeoutSeconds));
|
|
343
|
+
|
|
344
|
+
console.log(`⏳ Waiting for [${moduleName}] to be released (timeout: ${timeoutSeconds}s)...`);
|
|
345
|
+
|
|
346
|
+
while (Date.now() < deadline) {
|
|
347
|
+
const existing = db.prepare(
|
|
348
|
+
"SELECT * FROM module_locks WHERE module_name = ? AND instance_id != ?"
|
|
349
|
+
).get(moduleName, INSTANCE_ID);
|
|
350
|
+
|
|
351
|
+
if (!existing || new Date(existing.expires_at) < new Date()) {
|
|
352
|
+
// Lock is free — try to acquire
|
|
353
|
+
const result = acquireModuleLock(db, moduleName, [], 'waited');
|
|
354
|
+
if (result.success) {
|
|
355
|
+
console.log(`✅ Lock acquired after waiting: [${moduleName}]`);
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check for deadlock while waiting
|
|
361
|
+
const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
|
|
362
|
+
if (dl.deadlock) {
|
|
363
|
+
db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
|
|
364
|
+
return { success: false, reason: 'Deadlock detected', deadlock: true, cycle: dl.deadlock_cycle };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Sleep poll
|
|
368
|
+
const start = Date.now();
|
|
369
|
+
while (Date.now() - start < WAIT_POLL_MS) { /* spin */ }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
|
|
373
|
+
return { success: false, reason: `Timeout after ${timeoutSeconds}s waiting for [${moduleName}]` };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Renew lock ────────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
function renewLock(db, moduleName) {
|
|
379
|
+
const exp = expiresAt(LOCK_TTL_MINUTES);
|
|
380
|
+
const r1 = db.prepare("UPDATE module_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
|
|
381
|
+
.run(exp, moduleName, INSTANCE_ID);
|
|
382
|
+
db.prepare("UPDATE file_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
|
|
383
|
+
.run(exp, moduleName, INSTANCE_ID);
|
|
384
|
+
return { success: r1.changes > 0, renewed_until: exp };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Status ────────────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
function getStatus(db) {
|
|
390
|
+
purgeExpired(db);
|
|
391
|
+
return {
|
|
392
|
+
instance_id: INSTANCE_ID,
|
|
393
|
+
module_locks: db.prepare("SELECT * FROM module_locks ORDER BY acquired_at DESC").all(),
|
|
394
|
+
schema_lock: db.prepare("SELECT * FROM schema_lock WHERE id=1").get() || null,
|
|
395
|
+
waiters: db.prepare("SELECT * FROM lock_waiters").all(),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function printStatus(s) {
|
|
400
|
+
console.log('\n══════════════════════════════════════════════════');
|
|
401
|
+
console.log(' 🔒 Lock Manager v2.0 — Status');
|
|
402
|
+
console.log(` This instance: ${s.instance_id}`);
|
|
403
|
+
console.log('══════════════════════════════════════════════════');
|
|
404
|
+
|
|
405
|
+
if (s.schema_lock) {
|
|
406
|
+
const mine = s.schema_lock.instance_id === s.instance_id;
|
|
407
|
+
console.log(`\n 📐 Schema: ${mine ? '✅ YOURS' : '🔴 BLOCKED by ' + s.schema_lock.instance_id}`);
|
|
408
|
+
console.log(` Purpose: ${s.schema_lock.purpose} | Expires: ${s.schema_lock.expires_at}`);
|
|
409
|
+
} else {
|
|
410
|
+
console.log('\n 📐 Schema: ✅ free');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (s.module_locks.length === 0) {
|
|
414
|
+
console.log('\n 📦 No active module locks\n');
|
|
415
|
+
} else {
|
|
416
|
+
console.log(`\n 📦 Module locks (${s.module_locks.length}):`);
|
|
417
|
+
for (const lock of s.module_locks) {
|
|
418
|
+
const mine = lock.instance_id === s.instance_id;
|
|
419
|
+
const files = JSON.parse(lock.files || '[]');
|
|
420
|
+
console.log(`\n ${mine ? '✅' : '🔴'} [${lock.module_name}] — ${mine ? 'YOURS' : 'BLOCKED'}`);
|
|
421
|
+
console.log(` Instance: ${lock.instance_id}`);
|
|
422
|
+
if (files.length) console.log(` Files: ${files.join(', ')}`);
|
|
423
|
+
if (lock.purpose) console.log(` Purpose: ${lock.purpose}`);
|
|
424
|
+
console.log(` Expires: ${lock.expires_at}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (s.waiters.length > 0) {
|
|
429
|
+
console.log(`\n ⏳ Waiting (${s.waiters.length}):`);
|
|
430
|
+
for (const w of s.waiters) {
|
|
431
|
+
console.log(` [${w.module_name}] ← ${w.instance_id} (since ${w.waiting_since})`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.log('\n══════════════════════════════════════════════════\n');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
if (require.main === module) {
|
|
441
|
+
const args = process.argv.slice(2);
|
|
442
|
+
const cmd = args[0];
|
|
443
|
+
const opts = {};
|
|
444
|
+
for (const arg of args.slice(1)) {
|
|
445
|
+
const m = arg.match(/^--?([\w-]+)(?:=(.+))?$/);
|
|
446
|
+
if (m) opts[m[1]] = m[2] !== undefined ? m[2] : true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!fs.existsSync(DB_PATH)) {
|
|
450
|
+
console.error('Lock Manager: memoria.db not found. Run akdd init first.');
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let db;
|
|
455
|
+
try { db = openDB(); ensureSchema(db); }
|
|
456
|
+
catch(e) { console.error('Lock Manager DB error:', e.message); process.exit(1); }
|
|
457
|
+
|
|
458
|
+
switch(cmd) {
|
|
459
|
+
case 'acquire': {
|
|
460
|
+
const mod = opts.module || opts.m;
|
|
461
|
+
if (!mod) { console.error('--module required'); process.exit(1); }
|
|
462
|
+
const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
|
|
463
|
+
const purpose = opts.purpose || opts.p || '';
|
|
464
|
+
const result = acquireModuleLock(db, mod, files, purpose);
|
|
465
|
+
if (result.success) {
|
|
466
|
+
console.log(`✅ Lock acquired: [${mod}]`);
|
|
467
|
+
if (files.length) console.log(` Files: ${result.files.join(', ')}`);
|
|
468
|
+
console.log(` Expires: ${result.expires_at}`);
|
|
469
|
+
} else {
|
|
470
|
+
console.error(`🔴 DENIED: ${result.reason}`);
|
|
471
|
+
if (result.deadlock) console.error(` 💀 DEADLOCK: ${result.deadlock_cycle}`);
|
|
472
|
+
if (result.conflicts) result.conflicts.forEach(c => console.error(` Conflict: ${c.file} → [${c.locked_by_module}]`));
|
|
473
|
+
process.exit(1);
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case 'release': {
|
|
478
|
+
const mod = opts.module || opts.m;
|
|
479
|
+
if (!mod) { console.error('--module required'); process.exit(1); }
|
|
480
|
+
const r = releaseModuleLock(db, mod);
|
|
481
|
+
if (r.success) console.log(`✅ Released: [${mod}]`);
|
|
482
|
+
else { console.error(`🔴 ${r.reason}`); process.exit(1); }
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
case 'release-all': {
|
|
486
|
+
const r = releaseAll(db);
|
|
487
|
+
console.log(`✅ Released ${r.released_modules.length} locks: ${r.released_modules.join(', ') || '(none)'}`);
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case 'check': {
|
|
491
|
+
const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
|
|
492
|
+
if (!files.length) { console.error('--files required'); process.exit(1); }
|
|
493
|
+
const r = checkFiles(db, files);
|
|
494
|
+
if (r.safe) { console.log('✅ Files free — safe to proceed'); }
|
|
495
|
+
else {
|
|
496
|
+
console.error('🔴 Conflicts:');
|
|
497
|
+
r.conflicts.forEach(c => console.error(` ${c.file} → [${c.locked_by_module}]`));
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
case 'acquire-schema': {
|
|
503
|
+
const r = acquireSchemaLock(db, opts.purpose || opts.p || 'migration');
|
|
504
|
+
if (r.success) console.log(`✅ Schema lock acquired — ${r.purpose} | Expires: ${r.expires_at}`);
|
|
505
|
+
else { console.error(`🔴 Schema locked: ${r.reason}`); process.exit(1); }
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
case 'release-schema': {
|
|
509
|
+
const r = releaseSchemaLock(db);
|
|
510
|
+
if (r.success) console.log('✅ Schema lock released');
|
|
511
|
+
else console.error('⚠️ No schema lock owned by this instance');
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
case 'renew': {
|
|
515
|
+
const mod = opts.module || opts.m;
|
|
516
|
+
if (!mod) { console.error('--module required'); process.exit(1); }
|
|
517
|
+
const r = renewLock(db, mod);
|
|
518
|
+
if (r.success) console.log(`✅ Renewed [${mod}] until ${r.renewed_until}`);
|
|
519
|
+
else { console.error('🔴 No lock to renew'); process.exit(1); }
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
case 'wait': {
|
|
523
|
+
const mod = opts.module || opts.m;
|
|
524
|
+
if (!mod) { console.error('--module required'); process.exit(1); }
|
|
525
|
+
const timeout = parseInt(opts.timeout || '300');
|
|
526
|
+
const r = waitForLock(db, mod, timeout);
|
|
527
|
+
if (!r.success) { console.error(`🔴 ${r.reason}`); process.exit(1); }
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
case 'status':
|
|
531
|
+
default: {
|
|
532
|
+
printStatus(getStatus(db));
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
db.close();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
module.exports = {
|
|
540
|
+
acquireModuleLock, releaseModuleLock, releaseAll,
|
|
541
|
+
acquireSchemaLock, releaseSchemaLock,
|
|
542
|
+
checkFiles, renewLock, waitForLock,
|
|
543
|
+
getStatus, detectDeadlock, normalizePath,
|
|
544
|
+
INSTANCE_ID,
|
|
545
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentic-kdd",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.8",
|
|
4
4
|
"description": "Autonomous development pipeline — aa: · ag: · audit: · AST graph · Harness · Specs · Impact analysis · Decision trail · Metrics · MCP server. Works with Cursor and Claude Code.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Agentic KDD — Lock Manager v2.0
|
|
4
|
+
* Lock real para desarrollo multi-instancia en el mismo proyecto.
|
|
5
|
+
*
|
|
6
|
+
* v2.0 fixes:
|
|
7
|
+
* - WAL mode en SQLite para escrituras concurrentes reales
|
|
8
|
+
* - Acquire atómico con transacción (elimina TOCTOU race condition)
|
|
9
|
+
* - Paths normalizados a relativos lowercase para evitar false misses
|
|
10
|
+
* - INSTANCE_ID persistido en disco (.agentic/_instance_id) — sobrevive restarts
|
|
11
|
+
* - Deadlock detection antes de cada acquire
|
|
12
|
+
* - Cleanup automático de locks huérfanos al iniciar
|
|
13
|
+
*
|
|
14
|
+
* Uso:
|
|
15
|
+
* node .agentic/grafo/lock-manager.cjs acquire --module=auth --files=src/auth.ts
|
|
16
|
+
* node .agentic/grafo/lock-manager.cjs release --module=auth
|
|
17
|
+
* node .agentic/grafo/lock-manager.cjs status
|
|
18
|
+
* node .agentic/grafo/lock-manager.cjs check --files=src/auth.ts,src/middleware.ts
|
|
19
|
+
* node .agentic/grafo/lock-manager.cjs acquire-schema
|
|
20
|
+
* node .agentic/grafo/lock-manager.cjs release-schema
|
|
21
|
+
* node .agentic/grafo/lock-manager.cjs release-all
|
|
22
|
+
* node .agentic/grafo/lock-manager.cjs wait --module=auth [--timeout=300]
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
|
|
30
|
+
const ROOT = process.cwd();
|
|
31
|
+
const DB_PATH = path.join(ROOT, '.agentic', 'memoria.db');
|
|
32
|
+
const INST_FILE = path.join(ROOT, '.agentic', '_instance_id');
|
|
33
|
+
|
|
34
|
+
const LOCK_TTL_MINUTES = 30;
|
|
35
|
+
const SCHEMA_TTL_MINUTES = 10;
|
|
36
|
+
const WAIT_POLL_MS = 2000;
|
|
37
|
+
|
|
38
|
+
// ── INSTANCE_ID persistido ────────────────────────────────────────────────────
|
|
39
|
+
// Sobrevive reinicios de Cursor — permite liberar locks de sesiones anteriores
|
|
40
|
+
|
|
41
|
+
function getOrCreateInstanceId() {
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(INST_FILE)) {
|
|
44
|
+
const id = fs.readFileSync(INST_FILE, 'utf8').trim();
|
|
45
|
+
if (id && id.startsWith('inst_')) return id;
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
const id = `inst_${os.hostname().replace(/[^a-zA-Z0-9]/g,'')}_${crypto.randomBytes(6).toString('hex')}`;
|
|
49
|
+
try { fs.writeFileSync(INST_FILE, id, 'utf8'); } catch {}
|
|
50
|
+
return id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const INSTANCE_ID = process.env.AGENTIC_INSTANCE_ID || getOrCreateInstanceId();
|
|
54
|
+
|
|
55
|
+
// ── DB setup ─────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function openDB() {
|
|
58
|
+
const projNodeModules = path.join(ROOT, 'node_modules');
|
|
59
|
+
if (!module.paths.includes(projNodeModules)) module.paths.unshift(projNodeModules);
|
|
60
|
+
const db = new (require('better-sqlite3'))(DB_PATH);
|
|
61
|
+
// WAL mode: permite lecturas concurrentes mientras se escribe
|
|
62
|
+
db.pragma('journal_mode = WAL');
|
|
63
|
+
db.pragma('busy_timeout = 5000'); // esperar hasta 5s si la BD está ocupada
|
|
64
|
+
return db;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ensureSchema(db) {
|
|
68
|
+
db.exec(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS module_locks (
|
|
70
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
module_name TEXT NOT NULL UNIQUE,
|
|
72
|
+
instance_id TEXT NOT NULL,
|
|
73
|
+
files TEXT NOT NULL DEFAULT '[]',
|
|
74
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
75
|
+
expires_at TEXT NOT NULL,
|
|
76
|
+
purpose TEXT,
|
|
77
|
+
pid INTEGER
|
|
78
|
+
)
|
|
79
|
+
`);
|
|
80
|
+
db.exec(`
|
|
81
|
+
CREATE TABLE IF NOT EXISTS file_locks (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
file_path TEXT NOT NULL UNIQUE,
|
|
84
|
+
module_name TEXT NOT NULL,
|
|
85
|
+
instance_id TEXT NOT NULL,
|
|
86
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
87
|
+
expires_at TEXT NOT NULL
|
|
88
|
+
)
|
|
89
|
+
`);
|
|
90
|
+
db.exec(`
|
|
91
|
+
CREATE TABLE IF NOT EXISTS schema_lock (
|
|
92
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
93
|
+
instance_id TEXT NOT NULL,
|
|
94
|
+
acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
95
|
+
expires_at TEXT NOT NULL,
|
|
96
|
+
purpose TEXT,
|
|
97
|
+
pid INTEGER
|
|
98
|
+
)
|
|
99
|
+
`);
|
|
100
|
+
db.exec(`
|
|
101
|
+
CREATE TABLE IF NOT EXISTS lock_waiters (
|
|
102
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
103
|
+
module_name TEXT NOT NULL,
|
|
104
|
+
instance_id TEXT NOT NULL,
|
|
105
|
+
waiting_since TEXT NOT NULL DEFAULT (datetime('now')),
|
|
106
|
+
timeout_at TEXT NOT NULL
|
|
107
|
+
)
|
|
108
|
+
`);
|
|
109
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_ml_inst ON module_locks(instance_id)"); } catch {}
|
|
110
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_path ON file_locks(file_path)"); } catch {}
|
|
111
|
+
try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_module ON file_locks(module_name)"); } catch {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function expiresAt(minutes) {
|
|
117
|
+
return new Date(Date.now() + minutes * 60 * 1000).toISOString();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function waitTimeoutAt(seconds) {
|
|
121
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Normaliza paths: absoluto → relativo, backslashes → forward, lowercase en Windows
|
|
125
|
+
function normalizePath(filePath) {
|
|
126
|
+
let p = filePath;
|
|
127
|
+
// Convertir absoluto a relativo si está bajo ROOT
|
|
128
|
+
if (path.isAbsolute(p)) {
|
|
129
|
+
const rel = path.relative(ROOT, p);
|
|
130
|
+
if (!rel.startsWith('..')) p = rel;
|
|
131
|
+
}
|
|
132
|
+
// Forward slashes siempre
|
|
133
|
+
p = p.replace(/\\/g, '/');
|
|
134
|
+
// Quitar ./ inicial
|
|
135
|
+
if (p.startsWith('./')) p = p.slice(2);
|
|
136
|
+
return p;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function normalizePaths(files) {
|
|
140
|
+
return [...new Set(files.map(normalizePath))];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function purgeExpired(db) {
|
|
144
|
+
db.prepare("DELETE FROM module_locks WHERE expires_at < datetime('now')").run();
|
|
145
|
+
db.prepare("DELETE FROM file_locks WHERE expires_at < datetime('now')").run();
|
|
146
|
+
db.prepare("DELETE FROM schema_lock WHERE expires_at < datetime('now')").run();
|
|
147
|
+
db.prepare("DELETE FROM lock_waiters WHERE timeout_at < datetime('now')").run();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Deadlock detection ────────────────────────────────────────────────────────
|
|
151
|
+
// Detecta ciclos: A espera B, B espera A → deadlock
|
|
152
|
+
|
|
153
|
+
function detectDeadlock(db, requestingInstance, targetModule) {
|
|
154
|
+
// ¿Quién tiene targetModule?
|
|
155
|
+
const holder = db.prepare(
|
|
156
|
+
"SELECT instance_id FROM module_locks WHERE module_name = ?"
|
|
157
|
+
).get(targetModule);
|
|
158
|
+
if (!holder) return { deadlock: false };
|
|
159
|
+
|
|
160
|
+
const holderInstance = holder.instance_id;
|
|
161
|
+
if (holderInstance === requestingInstance) return { deadlock: false };
|
|
162
|
+
|
|
163
|
+
// ¿El holder está esperando algún módulo que tenga requestingInstance?
|
|
164
|
+
const holderWaiting = db.prepare(
|
|
165
|
+
"SELECT module_name FROM lock_waiters WHERE instance_id = ?"
|
|
166
|
+
).all(holderInstance);
|
|
167
|
+
|
|
168
|
+
for (const w of holderWaiting) {
|
|
169
|
+
const wastedModuleHolder = db.prepare(
|
|
170
|
+
"SELECT instance_id FROM module_locks WHERE module_name = ?"
|
|
171
|
+
).get(w.module_name);
|
|
172
|
+
if (wastedModuleHolder && wastedModuleHolder.instance_id === requestingInstance) {
|
|
173
|
+
return {
|
|
174
|
+
deadlock: true,
|
|
175
|
+
cycle: `${requestingInstance} → [${targetModule}] → ${holderInstance} → [${w.module_name}] → ${requestingInstance}`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { deadlock: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Acquire module lock (atómico) ─────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function acquireModuleLock(db, moduleName, files = [], purpose = '') {
|
|
186
|
+
const normalFiles = normalizePaths(files);
|
|
187
|
+
|
|
188
|
+
// Transacción atómica — elimina TOCTOU
|
|
189
|
+
const acquire = db.transaction(() => {
|
|
190
|
+
purgeExpired(db);
|
|
191
|
+
|
|
192
|
+
// 1. Verificar si el módulo está bloqueado por otra instancia
|
|
193
|
+
const existingModule = db.prepare(
|
|
194
|
+
"SELECT * FROM module_locks WHERE module_name = ?"
|
|
195
|
+
).get(moduleName);
|
|
196
|
+
|
|
197
|
+
if (existingModule && existingModule.instance_id !== INSTANCE_ID) {
|
|
198
|
+
// Detectar deadlock antes de reportar bloqueo
|
|
199
|
+
const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
|
|
200
|
+
return {
|
|
201
|
+
success: false,
|
|
202
|
+
reason: `Module "${moduleName}" locked by ${existingModule.instance_id}`,
|
|
203
|
+
locked_by: existingModule.instance_id,
|
|
204
|
+
expires_at: existingModule.expires_at,
|
|
205
|
+
deadlock: dl.deadlock,
|
|
206
|
+
deadlock_cycle: dl.cycle,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 2. Verificar conflictos de archivos con otras instancias
|
|
211
|
+
const fileConflicts = [];
|
|
212
|
+
for (const file of normalFiles) {
|
|
213
|
+
const fileLock = db.prepare(
|
|
214
|
+
"SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
|
|
215
|
+
).get(file, INSTANCE_ID);
|
|
216
|
+
if (fileLock) {
|
|
217
|
+
fileConflicts.push({ file, locked_by_module: fileLock.module_name, locked_by_instance: fileLock.instance_id });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (fileConflicts.length > 0) {
|
|
222
|
+
return { success: false, reason: 'File conflicts', conflicts: fileConflicts };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 3. Adquirir lock de módulo
|
|
226
|
+
const exp = expiresAt(LOCK_TTL_MINUTES);
|
|
227
|
+
db.prepare(`
|
|
228
|
+
INSERT INTO module_locks (module_name, instance_id, files, acquired_at, expires_at, purpose, pid)
|
|
229
|
+
VALUES (?, ?, ?, datetime('now'), ?, ?, ?)
|
|
230
|
+
ON CONFLICT(module_name) DO UPDATE SET
|
|
231
|
+
instance_id=excluded.instance_id, files=excluded.files,
|
|
232
|
+
acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
|
|
233
|
+
purpose=excluded.purpose, pid=excluded.pid
|
|
234
|
+
`).run(moduleName, INSTANCE_ID, JSON.stringify(normalFiles), exp, purpose, process.pid);
|
|
235
|
+
|
|
236
|
+
// 4. Adquirir locks de archivos
|
|
237
|
+
for (const file of normalFiles) {
|
|
238
|
+
db.prepare(`
|
|
239
|
+
INSERT INTO file_locks (file_path, module_name, instance_id, acquired_at, expires_at)
|
|
240
|
+
VALUES (?, ?, ?, datetime('now'), ?)
|
|
241
|
+
ON CONFLICT(file_path) DO UPDATE SET
|
|
242
|
+
module_name=excluded.module_name, instance_id=excluded.instance_id,
|
|
243
|
+
acquired_at=excluded.acquired_at, expires_at=excluded.expires_at
|
|
244
|
+
`).run(file, moduleName, INSTANCE_ID, exp);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 5. Remover de waiters si estaba esperando
|
|
248
|
+
db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?")
|
|
249
|
+
.run(moduleName, INSTANCE_ID);
|
|
250
|
+
|
|
251
|
+
return { success: true, instance_id: INSTANCE_ID, module: moduleName, files: normalFiles, expires_at: exp };
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return acquire();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Release module lock ───────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
function releaseModuleLock(db, moduleName) {
|
|
260
|
+
const release = db.transaction(() => {
|
|
261
|
+
const lock = db.prepare(
|
|
262
|
+
"SELECT * FROM module_locks WHERE module_name = ? AND instance_id = ?"
|
|
263
|
+
).get(moduleName, INSTANCE_ID);
|
|
264
|
+
if (!lock) return { success: false, reason: `No lock owned by this instance for [${moduleName}]` };
|
|
265
|
+
|
|
266
|
+
db.prepare("DELETE FROM module_locks WHERE module_name = ? AND instance_id = ?")
|
|
267
|
+
.run(moduleName, INSTANCE_ID);
|
|
268
|
+
db.prepare("DELETE FROM file_locks WHERE module_name = ? AND instance_id = ?")
|
|
269
|
+
.run(moduleName, INSTANCE_ID);
|
|
270
|
+
|
|
271
|
+
return { success: true, module: moduleName };
|
|
272
|
+
});
|
|
273
|
+
return release();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Release all locks for this instance ──────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
function releaseAll(db) {
|
|
279
|
+
const rel = db.transaction(() => {
|
|
280
|
+
const modules = db.prepare("SELECT module_name FROM module_locks WHERE instance_id = ?")
|
|
281
|
+
.all(INSTANCE_ID).map(r => r.module_name);
|
|
282
|
+
db.prepare("DELETE FROM module_locks WHERE instance_id = ?").run(INSTANCE_ID);
|
|
283
|
+
db.prepare("DELETE FROM file_locks WHERE instance_id = ?").run(INSTANCE_ID);
|
|
284
|
+
db.prepare("DELETE FROM schema_lock WHERE instance_id = ?").run(INSTANCE_ID);
|
|
285
|
+
db.prepare("DELETE FROM lock_waiters WHERE instance_id = ?").run(INSTANCE_ID);
|
|
286
|
+
return { success: true, released_modules: modules };
|
|
287
|
+
});
|
|
288
|
+
return rel();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Check files ───────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function checkFiles(db, files) {
|
|
294
|
+
purgeExpired(db);
|
|
295
|
+
const normalFiles = normalizePaths(files);
|
|
296
|
+
const conflicts = [];
|
|
297
|
+
for (const file of normalFiles) {
|
|
298
|
+
const lock = db.prepare(
|
|
299
|
+
"SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
|
|
300
|
+
).get(file, INSTANCE_ID);
|
|
301
|
+
if (lock) conflicts.push({ file, locked_by_module: lock.module_name, locked_by_instance: lock.instance_id, expires_at: lock.expires_at });
|
|
302
|
+
}
|
|
303
|
+
return { safe: conflicts.length === 0, conflicts };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Schema lock ───────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
function acquireSchemaLock(db, purpose = 'migration') {
|
|
309
|
+
const acquire = db.transaction(() => {
|
|
310
|
+
purgeExpired(db);
|
|
311
|
+
const existing = db.prepare("SELECT * FROM schema_lock WHERE id = 1").get();
|
|
312
|
+
if (existing && existing.instance_id !== INSTANCE_ID) {
|
|
313
|
+
return { success: false, reason: `Schema locked by ${existing.instance_id} for ${existing.purpose}`, expires_at: existing.expires_at };
|
|
314
|
+
}
|
|
315
|
+
const exp = expiresAt(SCHEMA_TTL_MINUTES);
|
|
316
|
+
db.prepare(`
|
|
317
|
+
INSERT INTO schema_lock (id, instance_id, acquired_at, expires_at, purpose, pid)
|
|
318
|
+
VALUES (1, ?, datetime('now'), ?, ?, ?)
|
|
319
|
+
ON CONFLICT(id) DO UPDATE SET instance_id=excluded.instance_id,
|
|
320
|
+
acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
|
|
321
|
+
purpose=excluded.purpose, pid=excluded.pid
|
|
322
|
+
`).run(INSTANCE_ID, exp, purpose, process.pid);
|
|
323
|
+
return { success: true, instance_id: INSTANCE_ID, purpose, expires_at: exp };
|
|
324
|
+
});
|
|
325
|
+
return acquire();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function releaseSchemaLock(db) {
|
|
329
|
+
const result = db.prepare("DELETE FROM schema_lock WHERE id = 1 AND instance_id = ?").run(INSTANCE_ID);
|
|
330
|
+
return { success: result.changes > 0 };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── Wait for lock ─────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
function waitForLock(db, moduleName, timeoutSeconds = 300) {
|
|
336
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
337
|
+
|
|
338
|
+
// Register as waiter
|
|
339
|
+
db.prepare(`
|
|
340
|
+
INSERT OR REPLACE INTO lock_waiters (module_name, instance_id, waiting_since, timeout_at)
|
|
341
|
+
VALUES (?, ?, datetime('now'), ?)
|
|
342
|
+
`).run(moduleName, INSTANCE_ID, waitTimeoutAt(timeoutSeconds));
|
|
343
|
+
|
|
344
|
+
console.log(`⏳ Waiting for [${moduleName}] to be released (timeout: ${timeoutSeconds}s)...`);
|
|
345
|
+
|
|
346
|
+
while (Date.now() < deadline) {
|
|
347
|
+
const existing = db.prepare(
|
|
348
|
+
"SELECT * FROM module_locks WHERE module_name = ? AND instance_id != ?"
|
|
349
|
+
).get(moduleName, INSTANCE_ID);
|
|
350
|
+
|
|
351
|
+
if (!existing || new Date(existing.expires_at) < new Date()) {
|
|
352
|
+
// Lock is free — try to acquire
|
|
353
|
+
const result = acquireModuleLock(db, moduleName, [], 'waited');
|
|
354
|
+
if (result.success) {
|
|
355
|
+
console.log(`✅ Lock acquired after waiting: [${moduleName}]`);
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check for deadlock while waiting
|
|
361
|
+
const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
|
|
362
|
+
if (dl.deadlock) {
|
|
363
|
+
db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
|
|
364
|
+
return { success: false, reason: 'Deadlock detected', deadlock: true, cycle: dl.deadlock_cycle };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Sleep poll
|
|
368
|
+
const start = Date.now();
|
|
369
|
+
while (Date.now() - start < WAIT_POLL_MS) { /* spin */ }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
|
|
373
|
+
return { success: false, reason: `Timeout after ${timeoutSeconds}s waiting for [${moduleName}]` };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Renew lock ────────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
function renewLock(db, moduleName) {
|
|
379
|
+
const exp = expiresAt(LOCK_TTL_MINUTES);
|
|
380
|
+
const r1 = db.prepare("UPDATE module_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
|
|
381
|
+
.run(exp, moduleName, INSTANCE_ID);
|
|
382
|
+
db.prepare("UPDATE file_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
|
|
383
|
+
.run(exp, moduleName, INSTANCE_ID);
|
|
384
|
+
return { success: r1.changes > 0, renewed_until: exp };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Status ────────────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
function getStatus(db) {
|
|
390
|
+
purgeExpired(db);
|
|
391
|
+
return {
|
|
392
|
+
instance_id: INSTANCE_ID,
|
|
393
|
+
module_locks: db.prepare("SELECT * FROM module_locks ORDER BY acquired_at DESC").all(),
|
|
394
|
+
schema_lock: db.prepare("SELECT * FROM schema_lock WHERE id=1").get() || null,
|
|
395
|
+
waiters: db.prepare("SELECT * FROM lock_waiters").all(),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function printStatus(s) {
|
|
400
|
+
console.log('\n══════════════════════════════════════════════════');
|
|
401
|
+
console.log(' 🔒 Lock Manager v2.0 — Status');
|
|
402
|
+
console.log(` This instance: ${s.instance_id}`);
|
|
403
|
+
console.log('══════════════════════════════════════════════════');
|
|
404
|
+
|
|
405
|
+
if (s.schema_lock) {
|
|
406
|
+
const mine = s.schema_lock.instance_id === s.instance_id;
|
|
407
|
+
console.log(`\n 📐 Schema: ${mine ? '✅ YOURS' : '🔴 BLOCKED by ' + s.schema_lock.instance_id}`);
|
|
408
|
+
console.log(` Purpose: ${s.schema_lock.purpose} | Expires: ${s.schema_lock.expires_at}`);
|
|
409
|
+
} else {
|
|
410
|
+
console.log('\n 📐 Schema: ✅ free');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (s.module_locks.length === 0) {
|
|
414
|
+
console.log('\n 📦 No active module locks\n');
|
|
415
|
+
} else {
|
|
416
|
+
console.log(`\n 📦 Module locks (${s.module_locks.length}):`);
|
|
417
|
+
for (const lock of s.module_locks) {
|
|
418
|
+
const mine = lock.instance_id === s.instance_id;
|
|
419
|
+
const files = JSON.parse(lock.files || '[]');
|
|
420
|
+
console.log(`\n ${mine ? '✅' : '🔴'} [${lock.module_name}] — ${mine ? 'YOURS' : 'BLOCKED'}`);
|
|
421
|
+
console.log(` Instance: ${lock.instance_id}`);
|
|
422
|
+
if (files.length) console.log(` Files: ${files.join(', ')}`);
|
|
423
|
+
if (lock.purpose) console.log(` Purpose: ${lock.purpose}`);
|
|
424
|
+
console.log(` Expires: ${lock.expires_at}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (s.waiters.length > 0) {
|
|
429
|
+
console.log(`\n ⏳ Waiting (${s.waiters.length}):`);
|
|
430
|
+
for (const w of s.waiters) {
|
|
431
|
+
console.log(` [${w.module_name}] ← ${w.instance_id} (since ${w.waiting_since})`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
console.log('\n══════════════════════════════════════════════════\n');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
if (require.main === module) {
|
|
441
|
+
const args = process.argv.slice(2);
|
|
442
|
+
const cmd = args[0];
|
|
443
|
+
const opts = {};
|
|
444
|
+
for (const arg of args.slice(1)) {
|
|
445
|
+
const m = arg.match(/^--?([\w-]+)(?:=(.+))?$/);
|
|
446
|
+
if (m) opts[m[1]] = m[2] !== undefined ? m[2] : true;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!fs.existsSync(DB_PATH)) {
|
|
450
|
+
console.error('Lock Manager: memoria.db not found. Run akdd init first.');
|
|
451
|
+
process.exit(1);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let db;
|
|
455
|
+
try { db = openDB(); ensureSchema(db); }
|
|
456
|
+
catch(e) { console.error('Lock Manager DB error:', e.message); process.exit(1); }
|
|
457
|
+
|
|
458
|
+
switch(cmd) {
|
|
459
|
+
case 'acquire': {
|
|
460
|
+
const mod = opts.module || opts.m;
|
|
461
|
+
if (!mod) { console.error('--module required'); process.exit(1); }
|
|
462
|
+
const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
|
|
463
|
+
const purpose = opts.purpose || opts.p || '';
|
|
464
|
+
const result = acquireModuleLock(db, mod, files, purpose);
|
|
465
|
+
if (result.success) {
|
|
466
|
+
console.log(`✅ Lock acquired: [${mod}]`);
|
|
467
|
+
if (files.length) console.log(` Files: ${result.files.join(', ')}`);
|
|
468
|
+
console.log(` Expires: ${result.expires_at}`);
|
|
469
|
+
} else {
|
|
470
|
+
console.error(`🔴 DENIED: ${result.reason}`);
|
|
471
|
+
if (result.deadlock) console.error(` 💀 DEADLOCK: ${result.deadlock_cycle}`);
|
|
472
|
+
if (result.conflicts) result.conflicts.forEach(c => console.error(` Conflict: ${c.file} → [${c.locked_by_module}]`));
|
|
473
|
+
process.exit(1);
|
|
474
|
+
}
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
case 'release': {
|
|
478
|
+
const mod = opts.module || opts.m;
|
|
479
|
+
if (!mod) { console.error('--module required'); process.exit(1); }
|
|
480
|
+
const r = releaseModuleLock(db, mod);
|
|
481
|
+
if (r.success) console.log(`✅ Released: [${mod}]`);
|
|
482
|
+
else { console.error(`🔴 ${r.reason}`); process.exit(1); }
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
case 'release-all': {
|
|
486
|
+
const r = releaseAll(db);
|
|
487
|
+
console.log(`✅ Released ${r.released_modules.length} locks: ${r.released_modules.join(', ') || '(none)'}`);
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case 'check': {
|
|
491
|
+
const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
|
|
492
|
+
if (!files.length) { console.error('--files required'); process.exit(1); }
|
|
493
|
+
const r = checkFiles(db, files);
|
|
494
|
+
if (r.safe) { console.log('✅ Files free — safe to proceed'); }
|
|
495
|
+
else {
|
|
496
|
+
console.error('🔴 Conflicts:');
|
|
497
|
+
r.conflicts.forEach(c => console.error(` ${c.file} → [${c.locked_by_module}]`));
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
case 'acquire-schema': {
|
|
503
|
+
const r = acquireSchemaLock(db, opts.purpose || opts.p || 'migration');
|
|
504
|
+
if (r.success) console.log(`✅ Schema lock acquired — ${r.purpose} | Expires: ${r.expires_at}`);
|
|
505
|
+
else { console.error(`🔴 Schema locked: ${r.reason}`); process.exit(1); }
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
case 'release-schema': {
|
|
509
|
+
const r = releaseSchemaLock(db);
|
|
510
|
+
if (r.success) console.log('✅ Schema lock released');
|
|
511
|
+
else console.error('⚠️ No schema lock owned by this instance');
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
case 'renew': {
|
|
515
|
+
const mod = opts.module || opts.m;
|
|
516
|
+
if (!mod) { console.error('--module required'); process.exit(1); }
|
|
517
|
+
const r = renewLock(db, mod);
|
|
518
|
+
if (r.success) console.log(`✅ Renewed [${mod}] until ${r.renewed_until}`);
|
|
519
|
+
else { console.error('🔴 No lock to renew'); process.exit(1); }
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
case 'wait': {
|
|
523
|
+
const mod = opts.module || opts.m;
|
|
524
|
+
if (!mod) { console.error('--module required'); process.exit(1); }
|
|
525
|
+
const timeout = parseInt(opts.timeout || '300');
|
|
526
|
+
const r = waitForLock(db, mod, timeout);
|
|
527
|
+
if (!r.success) { console.error(`🔴 ${r.reason}`); process.exit(1); }
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
case 'status':
|
|
531
|
+
default: {
|
|
532
|
+
printStatus(getStatus(db));
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
db.close();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
module.exports = {
|
|
540
|
+
acquireModuleLock, releaseModuleLock, releaseAll,
|
|
541
|
+
acquireSchemaLock, releaseSchemaLock,
|
|
542
|
+
checkFiles, renewLock, waitForLock,
|
|
543
|
+
getStatus, detectDeadlock, normalizePath,
|
|
544
|
+
INSTANCE_ID,
|
|
545
|
+
};
|