ostacky 0.9.7 → 0.10.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/README.md +93 -309
- package/assets/agents/ostacky.md +3 -3
- package/assets/commands/install-stack.md +1 -1
- package/assets/mcp/ostacky-controller/controller-core.js +4 -0
- package/assets/mcp/ostacky-controller/index.js +227 -9
- package/assets/plugins/ostacky-controller/controller-core.ts +4 -0
- package/assets/plugins/ostacky-controller/index.ts +136 -23
- package/dist/cli.js +55 -36
- package/manifest.json +28 -28
- package/package.json +1 -1
|
@@ -446,6 +446,11 @@ class OstackyController {
|
|
|
446
446
|
#lockHeartbeatPath;
|
|
447
447
|
#lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
|
|
448
448
|
#lockOwner = false;
|
|
449
|
+
#lockDepth = 0; // multi-sesión: reentrancy (el lock se toma una vez por tool call)
|
|
450
|
+
#callerSessionId = null; // sessionId del caller (de _meta), solo en memoria
|
|
451
|
+
#loadedSnapshot = null; // huella del estado recién leído, para detectar "dirty"
|
|
452
|
+
#persistQueue = Promise.resolve(); // cola in-process: las escrituras nunca se solapan
|
|
453
|
+
#persistSeq = 0; // sufijo único por escritura (tmp.<pid>.<seq>)
|
|
449
454
|
|
|
450
455
|
constructor(opts = {}) {
|
|
451
456
|
this.#statePath = opts.statePath;
|
|
@@ -484,6 +489,76 @@ class OstackyController {
|
|
|
484
489
|
}
|
|
485
490
|
}
|
|
486
491
|
|
|
492
|
+
// ─── multi-sesión ────────────────────────────────────────────────────────
|
|
493
|
+
/**
|
|
494
|
+
* Re-lectura fresca desde disco. Invalida el cache #loaded y reusa la lógica
|
|
495
|
+
* de #load() (migraciones + fallback de backups). Se llama al INICIO de cada
|
|
496
|
+
* tool call, así dos procesos/sesiones nunca operan sobre un snapshot viejo
|
|
497
|
+
* (antes: `if (this.#loaded) return` hacía que el estado quedara congelado).
|
|
498
|
+
*/
|
|
499
|
+
reloadFresh() {
|
|
500
|
+
this.#loaded = false;
|
|
501
|
+
this.#load();
|
|
502
|
+
this.#loadedSnapshot = this.#diffableState();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Huella serializada del estado (para el diff de "dirty").
|
|
507
|
+
* Excluye `lastHeartbeat` porque se actualiza en cada tool call y no debe
|
|
508
|
+
* forzar una escritura a disco (rotaría backups sin cambios reales).
|
|
509
|
+
*/
|
|
510
|
+
#diffableState() {
|
|
511
|
+
if (!this.#state) return '';
|
|
512
|
+
try {
|
|
513
|
+
return safeJsonStringify({ ...this.#state, lastHeartbeat: 0 });
|
|
514
|
+
} catch {
|
|
515
|
+
return '';
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Persiste si hubo mutaciones en memoria respecto de la última lectura/escritura.
|
|
521
|
+
* Es OBLIGATORIO ahora que reloadFresh() descarta el estado en memoria al inicio
|
|
522
|
+
* de cada tool call: sin esto, una mutación que no llamó a #persist (p.ej. el
|
|
523
|
+
* buffer de auditoría, o los campos de ownership) se perdería al siguiente call.
|
|
524
|
+
*/
|
|
525
|
+
async persistIfDirty() {
|
|
526
|
+
if (!this.#statePath || !this.#state) return false;
|
|
527
|
+
// Drenar auditoría pendiente: el buffer vive solo en memoria, así que si no
|
|
528
|
+
// lo volcamos acá se perdería en el próximo reloadFresh().
|
|
529
|
+
try {
|
|
530
|
+
if (this.#auditBuffer.length > 0) await this.#flushAudit(false);
|
|
531
|
+
} catch {}
|
|
532
|
+
if (this.#diffableState() === this.#loadedSnapshot) return false;
|
|
533
|
+
await this.#persist();
|
|
534
|
+
this.#loadedSnapshot = this.#diffableState();
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Registra el sessionId del caller actual (de `_meta["ai.opencode/sessionID"]`). */
|
|
539
|
+
setCallerSession(sessionId) {
|
|
540
|
+
this.#callerSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** sessionId del caller actual, o null si el cliente no lo mandó. */
|
|
544
|
+
get callerSessionId() {
|
|
545
|
+
return this.#callerSessionId;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Toma el lock de estado por toda la duración de un tool call (read-modify-write
|
|
550
|
+
* serializado entre procesos/sesiones). Reentrante: #persist puede volver a
|
|
551
|
+
* pedirlo dentro de la misma operación.
|
|
552
|
+
*/
|
|
553
|
+
async beginWrite() {
|
|
554
|
+
return this.#acquireLock();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Libera el lock tomado por beginWrite (reentrante). */
|
|
558
|
+
endWrite() {
|
|
559
|
+
this.#releaseLock();
|
|
560
|
+
}
|
|
561
|
+
|
|
487
562
|
/**
|
|
488
563
|
* Validates that a parsed state object has the required fields and valid values.
|
|
489
564
|
* Returns null if valid, or an error message if invalid.
|
|
@@ -500,6 +575,13 @@ class OstackyController {
|
|
|
500
575
|
// --- 3.4: State file locking (C1 fix: check stale BEFORE write, atomic wx, jitter, 15s stale, 1s timeout) ---
|
|
501
576
|
async #acquireLock() {
|
|
502
577
|
if (!this.#lockPath) return true;
|
|
578
|
+
// Reentrante: el boundary del tool ya tomó el lock (beginWrite) y #persist lo
|
|
579
|
+
// vuelve a pedir dentro de la MISMA operación. Sin esto, el segundo intento
|
|
580
|
+
// chocaba con nuestro propio lock (EEXIST) y hacía fallar la persistencia.
|
|
581
|
+
if (this.#lockDepth > 0) {
|
|
582
|
+
this.#lockDepth++;
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
503
585
|
const maxAttempts = this.#lockMaxAttempts;
|
|
504
586
|
const lockTimeout = 1000;
|
|
505
587
|
const staleWindow = 15000;
|
|
@@ -546,6 +628,7 @@ class OstackyController {
|
|
|
546
628
|
}
|
|
547
629
|
this.#heartbeatLock();
|
|
548
630
|
this.#lockOwner = true;
|
|
631
|
+
this.#lockDepth = 1;
|
|
549
632
|
return true;
|
|
550
633
|
} catch {
|
|
551
634
|
const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
|
|
@@ -561,6 +644,12 @@ class OstackyController {
|
|
|
561
644
|
|
|
562
645
|
#releaseLock() {
|
|
563
646
|
if (!this.#lockPath) return;
|
|
647
|
+
// Reentrante: solo el nivel más externo libera de verdad.
|
|
648
|
+
if (this.#lockDepth > 1) {
|
|
649
|
+
this.#lockDepth--;
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
this.#lockDepth = 0;
|
|
564
653
|
// D2: verify ownership before deleting — never delete another process's lock
|
|
565
654
|
try {
|
|
566
655
|
const ownerPid = readFileSync(this.#lockPidPath, 'utf8').trim();
|
|
@@ -743,7 +832,24 @@ class OstackyController {
|
|
|
743
832
|
this.#loaded = true;
|
|
744
833
|
}
|
|
745
834
|
|
|
746
|
-
|
|
835
|
+
/**
|
|
836
|
+
* Serializa las escrituras de estado DENTRO del proceso.
|
|
837
|
+
* Sin esto, un flush fire-and-forget (#enterDegradedMode/#exitDegradedMode) o un
|
|
838
|
+
* flush del watchdog puede interleavearse con el persist de un handler: ambos
|
|
839
|
+
* escriben el MISMO `tmp.<pid>` y el segundo rename falla con ENOENT (o, peor,
|
|
840
|
+
* se pierde la escritura). Ahora las escrituras se encolan: nunca se solapan.
|
|
841
|
+
*/
|
|
842
|
+
#persist() {
|
|
843
|
+
const run = () => this.#persistInner();
|
|
844
|
+
const next = this.#persistQueue.then(run, run);
|
|
845
|
+
this.#persistQueue = next.then(
|
|
846
|
+
() => {},
|
|
847
|
+
() => {}
|
|
848
|
+
);
|
|
849
|
+
return next;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async #persistInner() {
|
|
747
853
|
if (!this.#statePath) return;
|
|
748
854
|
|
|
749
855
|
const dir = dirname(this.#statePath);
|
|
@@ -818,7 +924,7 @@ class OstackyController {
|
|
|
818
924
|
stateForSerialize.snapshots = { codegraph: null, execution: null };
|
|
819
925
|
serialized = safeJsonStringify(stateForSerialize, true);
|
|
820
926
|
}
|
|
821
|
-
const tmp = this.#statePath + '.tmp.' + process.pid;
|
|
927
|
+
const tmp = this.#statePath + '.tmp.' + process.pid + '.' + ++this.#persistSeq;
|
|
822
928
|
await writeFileAsync(tmp, serialized, 'utf8');
|
|
823
929
|
await renameAsync(tmp, this.#statePath);
|
|
824
930
|
// 2.1: backup rotativo 3 niveles best-effort
|
|
@@ -1100,25 +1206,79 @@ class OstackyController {
|
|
|
1100
1206
|
async startRequest({ requestId, changeId, force = false } = {}) {
|
|
1101
1207
|
this.#load();
|
|
1102
1208
|
|
|
1209
|
+
const caller = this.#callerSessionId;
|
|
1210
|
+
const owner = this.#state.ownerSessionId;
|
|
1211
|
+
// OJO: `INTERPRETATION_PENDING` está en TERMINAL_STATES — es "terminal" para
|
|
1212
|
+
// decidir si se RESUME, pero NO significa que no haya trabajo. Por eso separamos:
|
|
1213
|
+
// - resumeEligible: semántica ORIGINAL de start_request (no la tocamos).
|
|
1214
|
+
// - foreignOwnedWork: hay una request de OTRA sesión que todavía no terminó.
|
|
1215
|
+
const resumeEligible = !TERMINAL_STATES.includes(this.#state.state) && !!this.#state.requestId;
|
|
1216
|
+
const foreignOwnedWork =
|
|
1217
|
+
!!this.#state.requestId &&
|
|
1218
|
+
this.#state.state !== STATES.DONE &&
|
|
1219
|
+
!!owner &&
|
|
1220
|
+
!!caller &&
|
|
1221
|
+
owner !== caller;
|
|
1222
|
+
|
|
1223
|
+
// ── Guard de ownership (multi-sesión) ──
|
|
1224
|
+
// Si hay una request viva de OTRA sesión y no se fuerza, NO pisamos nada:
|
|
1225
|
+
// devolvemos el estado real para que el caller haga un takeover explícito
|
|
1226
|
+
// (force:true, auditado). Sin caller conocido (clientes que no mandan `_meta`)
|
|
1227
|
+
// el guard no aplica: comportamiento idéntico al anterior.
|
|
1228
|
+
if (foreignOwnedWork && !force) {
|
|
1229
|
+
await this.#audit(
|
|
1230
|
+
this.#state.state,
|
|
1231
|
+
'start_request_conflict',
|
|
1232
|
+
`caller=${caller} owner=${owner} requestId=${this.#state.requestId}`
|
|
1233
|
+
);
|
|
1234
|
+
return {
|
|
1235
|
+
conflict: true,
|
|
1236
|
+
state: this.#state.state,
|
|
1237
|
+
revision: this.#state.revision,
|
|
1238
|
+
requestId: this.#state.requestId,
|
|
1239
|
+
ownerSessionId: owner,
|
|
1240
|
+
callerSessionId: caller,
|
|
1241
|
+
message:
|
|
1242
|
+
'Otra sesión posee la request activa. Reintentá con force:true para hacer takeover (queda auditado).',
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1103
1246
|
// If not forcing and current state is active (not terminal), resume instead of reset
|
|
1104
|
-
if (!force &&
|
|
1247
|
+
if (!force && resumeEligible) {
|
|
1248
|
+
// Reclamamos ownership si no había dueño o si cambió (sesión única / reconexión).
|
|
1249
|
+
if (caller && this.#state.ownerSessionId !== caller) {
|
|
1250
|
+
this.#state.ownerSessionId = caller;
|
|
1251
|
+
}
|
|
1252
|
+
this.#state.ownerHeartbeat = Date.now();
|
|
1105
1253
|
await this.#audit(
|
|
1106
1254
|
this.#state.state,
|
|
1107
1255
|
'start_request',
|
|
1108
|
-
`resumed from ${this.#state.state}, requestId=${this.#state.requestId}`
|
|
1256
|
+
`resumed from ${this.#state.state}, requestId=${this.#state.requestId}${caller ? `, session=${caller}` : ''}`
|
|
1109
1257
|
);
|
|
1110
1258
|
return {
|
|
1111
1259
|
state: this.#state.state,
|
|
1112
1260
|
revision: this.#state.revision,
|
|
1113
1261
|
requestId: this.#state.requestId,
|
|
1262
|
+
ownerSessionId: this.#state.ownerSessionId,
|
|
1114
1263
|
continued: true,
|
|
1115
1264
|
};
|
|
1116
1265
|
}
|
|
1117
1266
|
|
|
1267
|
+
// Takeover EXPLÍCITO (force sobre una request viva de otra sesión) → auditado
|
|
1268
|
+
if (force && foreignOwnedWork) {
|
|
1269
|
+
await this.#audit(
|
|
1270
|
+
this.#state.state,
|
|
1271
|
+
'session_takeover',
|
|
1272
|
+
`from=${owner} to=${caller} requestId=${this.#state.requestId}`
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1118
1276
|
// Force reset or terminal state: create new session
|
|
1119
1277
|
await this.#transition('INTERPRETATION_PENDING', {
|
|
1120
1278
|
requestId: requestId || 'req-' + Date.now(),
|
|
1121
1279
|
changeId: changeId || null,
|
|
1280
|
+
ownerSessionId: caller || null,
|
|
1281
|
+
ownerHeartbeat: Date.now(),
|
|
1122
1282
|
routeDecisionId: null,
|
|
1123
1283
|
routeChoice: null,
|
|
1124
1284
|
executionDecisionId: null,
|
|
@@ -1140,6 +1300,7 @@ class OstackyController {
|
|
|
1140
1300
|
state: this.#state.state,
|
|
1141
1301
|
revision: this.#state.revision,
|
|
1142
1302
|
requestId: this.#state.requestId,
|
|
1303
|
+
ownerSessionId: this.#state.ownerSessionId,
|
|
1143
1304
|
continued: false,
|
|
1144
1305
|
};
|
|
1145
1306
|
}
|
|
@@ -2189,6 +2350,15 @@ class OstackyController {
|
|
|
2189
2350
|
return {
|
|
2190
2351
|
revision: this.#state.revision,
|
|
2191
2352
|
state: this.#state.state,
|
|
2353
|
+
// multi-sesión: quién posee la request y quién está preguntando AHORA.
|
|
2354
|
+
// Se calcula con el callerSessionId de ESTA llamada (no está persistido).
|
|
2355
|
+
ownership: {
|
|
2356
|
+
ownerSessionId: this.#state.ownerSessionId ?? null,
|
|
2357
|
+
callerSessionId: this.#callerSessionId,
|
|
2358
|
+
isOwner:
|
|
2359
|
+
!!this.#callerSessionId && this.#callerSessionId === (this.#state.ownerSessionId ?? null),
|
|
2360
|
+
ownerHeartbeat: this.#state.ownerHeartbeat ?? 0,
|
|
2361
|
+
},
|
|
2192
2362
|
degraded: this.#degraded || !!this.#state.degraded,
|
|
2193
2363
|
consecutiveFailures: this.#consecutiveFailures,
|
|
2194
2364
|
taskCounts: {
|
|
@@ -2366,7 +2536,9 @@ class OstackyController {
|
|
|
2366
2536
|
|
|
2367
2537
|
async getState() {
|
|
2368
2538
|
this.#load();
|
|
2369
|
-
|
|
2539
|
+
// callerSessionId es de la llamada actual (no del estado persistido):
|
|
2540
|
+
// permite verificar de un vistazo qué sesión está operando.
|
|
2541
|
+
return { ...structuredClone(this.#state), callerSessionId: this.#callerSessionId };
|
|
2370
2542
|
}
|
|
2371
2543
|
|
|
2372
2544
|
async getTasks() {
|
|
@@ -2924,16 +3096,55 @@ const controller = new OstackyController({ statePath });
|
|
|
2924
3096
|
* @param {number} options.maxRetries - Maximum retry attempts (default: 0)
|
|
2925
3097
|
* @param {number} options.baseTimeout - Base timeout in ms (default: 5000)
|
|
2926
3098
|
*/
|
|
3099
|
+
/**
|
|
3100
|
+
* Extrae el sessionId de OpenCode del contexto del request.
|
|
3101
|
+
* OpenCode lo manda como `_meta["ai.opencode/sessionID"]` en cada tools/call
|
|
3102
|
+
* (aplica a tools directos y a Code Mode; puede faltar → degradamos a null,
|
|
3103
|
+
* y con null NO se aplica guard de ownership para no romper clientes viejos).
|
|
3104
|
+
*/
|
|
3105
|
+
function extractCallerSessionId(ctx) {
|
|
3106
|
+
try {
|
|
3107
|
+
const meta = ctx && ctx.mcpReq && ctx.mcpReq._meta;
|
|
3108
|
+
if (meta && typeof meta === 'object') {
|
|
3109
|
+
const v = meta['ai.opencode/sessionID'];
|
|
3110
|
+
if (typeof v === 'string' && v) return v;
|
|
3111
|
+
}
|
|
3112
|
+
} catch {}
|
|
3113
|
+
// Fallback: sessionId del transporte (stdio → puede ser único por proceso,
|
|
3114
|
+
// NO por sesión de OpenCode; por eso es solo fallback).
|
|
3115
|
+
try {
|
|
3116
|
+
if (ctx && typeof ctx.sessionId === 'string' && ctx.sessionId) return ctx.sessionId;
|
|
3117
|
+
} catch {}
|
|
3118
|
+
return null;
|
|
3119
|
+
}
|
|
3120
|
+
|
|
2927
3121
|
function safeHandler(fn, options = {}) {
|
|
2928
3122
|
const { maxRetries = 0, baseTimeout = 5000 } = options;
|
|
2929
3123
|
|
|
2930
|
-
return async (params) => {
|
|
3124
|
+
return async (params, ctx) => {
|
|
3125
|
+
// ── multi-sesión: identidad + estado fresco + lock por operación ──
|
|
3126
|
+
// 1) sessionId del caller (de `_meta`), 2) re-lectura fresca desde disco
|
|
3127
|
+
// (mata el snapshot congelado del cache `#loaded` cuando hay 2+ procesos),
|
|
3128
|
+
// 3) lock reentrante durante TODO el read-modify-write → sin lost updates.
|
|
3129
|
+
let heldLock = false;
|
|
3130
|
+
try {
|
|
3131
|
+
controller.setCallerSession(extractCallerSessionId(ctx));
|
|
3132
|
+
controller.reloadFresh();
|
|
3133
|
+
heldLock = await controller.beginWrite();
|
|
3134
|
+
} catch {}
|
|
3135
|
+
const endOp = () => {
|
|
3136
|
+
if (!heldLock) return;
|
|
3137
|
+
heldLock = false;
|
|
3138
|
+
try {
|
|
3139
|
+
controller.endWrite();
|
|
3140
|
+
} catch {}
|
|
3141
|
+
};
|
|
2931
3142
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2932
3143
|
const currentTimeout = baseTimeout * Math.pow(1.5, attempt);
|
|
2933
3144
|
const start = Date.now();
|
|
2934
3145
|
try {
|
|
2935
3146
|
const result = await Promise.race([
|
|
2936
|
-
fn(params),
|
|
3147
|
+
fn(params, ctx),
|
|
2937
3148
|
new Promise((_, reject) =>
|
|
2938
3149
|
setTimeout(() => reject(new Error(`timeout ${currentTimeout}ms`)), currentTimeout)
|
|
2939
3150
|
),
|
|
@@ -2942,8 +3153,13 @@ function safeHandler(fn, options = {}) {
|
|
|
2942
3153
|
try {
|
|
2943
3154
|
await controller._recordToolDuration(duration);
|
|
2944
3155
|
} catch {}
|
|
3156
|
+
// Flush si el handler mutó el estado sin persistir (audit buffer, ownership…)
|
|
3157
|
+
try {
|
|
3158
|
+
await controller.persistIfDirty();
|
|
3159
|
+
} catch {}
|
|
2945
3160
|
// Update heartbeat on successful completion
|
|
2946
3161
|
controller.updateHeartbeat();
|
|
3162
|
+
endOp();
|
|
2947
3163
|
return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
|
|
2948
3164
|
} catch (error) {
|
|
2949
3165
|
const isTimeout = error && error.message && error.message.includes('timeout');
|
|
@@ -2969,6 +3185,7 @@ function safeHandler(fn, options = {}) {
|
|
|
2969
3185
|
try {
|
|
2970
3186
|
await controller._recordToolTimeout();
|
|
2971
3187
|
} catch {}
|
|
3188
|
+
endOp();
|
|
2972
3189
|
return {
|
|
2973
3190
|
content: [
|
|
2974
3191
|
{
|
|
@@ -2985,6 +3202,7 @@ function safeHandler(fn, options = {}) {
|
|
|
2985
3202
|
error: error.message,
|
|
2986
3203
|
stack: error.stack,
|
|
2987
3204
|
});
|
|
3205
|
+
endOp();
|
|
2988
3206
|
return {
|
|
2989
3207
|
content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
|
|
2990
3208
|
isError: true,
|
|
@@ -2996,7 +3214,7 @@ function safeHandler(fn, options = {}) {
|
|
|
2996
3214
|
|
|
2997
3215
|
const server = new McpServer({
|
|
2998
3216
|
name: 'ostacky-controller',
|
|
2999
|
-
version: '0.
|
|
3217
|
+
version: '0.10.0',
|
|
3000
3218
|
});
|
|
3001
3219
|
|
|
3002
3220
|
server.registerTool(
|
|
@@ -3598,7 +3816,7 @@ function setupGracefulShutdown(ctrl) {
|
|
|
3598
3816
|
}
|
|
3599
3817
|
|
|
3600
3818
|
async function main() {
|
|
3601
|
-
log('Starting ostacky-controller MCP v0.
|
|
3819
|
+
log('Starting ostacky-controller MCP v0.10.0...');
|
|
3602
3820
|
log('State path:', { path: statePath });
|
|
3603
3821
|
// Clean up stale tmp/lock files from previous runs
|
|
3604
3822
|
cleanupTmpFiles(statePath);
|
|
@@ -141,6 +141,10 @@ export const DEFAULT_STATE: any = {
|
|
|
141
141
|
pendingFileAccess: {},
|
|
142
142
|
lastHeartbeat: 0,
|
|
143
143
|
watchdogEnabled: true,
|
|
144
|
+
// multi-sesión: dueño de la request activa (sessionId de OpenCode vía _meta).
|
|
145
|
+
// null = sin dueño conocido. Es UN campo (se sobreescribe) — nunca un archivo por sesión.
|
|
146
|
+
ownerSessionId: null,
|
|
147
|
+
ownerHeartbeat: 0,
|
|
144
148
|
ts: Date.now(),
|
|
145
149
|
specNotInSync: false,
|
|
146
150
|
engramBypassCount: 0,
|
|
@@ -73,31 +73,137 @@ function readState(directory: string): any | null {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/** Sleep sincrónico acotado — los call sites del plugin son sync. */
|
|
77
|
+
function sleepSync(ms: number): void {
|
|
78
|
+
try {
|
|
79
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
80
|
+
} catch {
|
|
81
|
+
const end = Date.now() + ms
|
|
82
|
+
while (Date.now() < end) {
|
|
83
|
+
/* spin de último recurso */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isPidAlive(pid: number): boolean {
|
|
89
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0)
|
|
92
|
+
return true
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Toma EL MISMO lock que el MCP (`<state>.lock.pid` con `wx`) para serializar
|
|
100
|
+
* plugin ↔ MCP cross-proceso. Devuelve una función de release; si no se pudo
|
|
101
|
+
* tomar, devuelve un no-op (la escritura sigue siendo segura por el merge).
|
|
102
|
+
*/
|
|
103
|
+
function acquireStateLock(statePath: string): () => void {
|
|
104
|
+
const pidPath = statePath + ".lock.pid"
|
|
105
|
+
const tsPath = statePath + ".lock.timestamp"
|
|
106
|
+
const staleWindow = 15_000
|
|
107
|
+
let acquired = false
|
|
108
|
+
for (let attempt = 0; attempt < 8 && !acquired; attempt++) {
|
|
109
|
+
// Lock viejo: solo lo liberamos si el dueño NO está vivo (mismo criterio que el MCP).
|
|
110
|
+
try {
|
|
111
|
+
const ts = parseInt(readFileSync(tsPath, "utf-8"), 10)
|
|
112
|
+
if (!Number.isNaN(ts) && Date.now() - ts >= staleWindow) {
|
|
113
|
+
const owner = parseInt(readFileSync(pidPath, "utf-8").trim(), 10)
|
|
114
|
+
if (!isPidAlive(owner)) {
|
|
115
|
+
try { unlinkSync(pidPath) } catch {}
|
|
116
|
+
try { unlinkSync(tsPath) } catch {}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
try {
|
|
121
|
+
writeFileSync(pidPath, String(process.pid), { encoding: "utf-8", flag: "wx" })
|
|
122
|
+
try { writeFileSync(tsPath, String(Date.now()), "utf-8") } catch {}
|
|
123
|
+
acquired = true
|
|
124
|
+
} catch {
|
|
125
|
+
sleepSync(5 + attempt * 10)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!acquired) return () => {}
|
|
129
|
+
return () => {
|
|
130
|
+
try {
|
|
131
|
+
if (readFileSync(pidPath, "utf-8").trim() !== String(process.pid)) return
|
|
132
|
+
} catch {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
try { unlinkSync(pidPath) } catch {}
|
|
136
|
+
try { unlinkSync(tsPath) } catch {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let persistSeq = 0
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Merge puro: parte del estado FRESCO de disco y le aplica SOLO las claves que
|
|
144
|
+
* el plugin posee. Garantiza estructuralmente que una escritura del plugin no
|
|
145
|
+
* pueda borrar un avance del MCP (transición, audit, tasks, revision).
|
|
146
|
+
*/
|
|
147
|
+
export function mergePluginOwnedState(fresh: any, state: any, ownedKeys: string[]): any {
|
|
148
|
+
const payload: any = { ...fresh }
|
|
149
|
+
for (const k of ownedKeys) {
|
|
150
|
+
if (Object.prototype.hasOwnProperty.call(state, k)) payload[k] = state[k]
|
|
151
|
+
}
|
|
152
|
+
return payload
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Persiste estado SIN poder pisar al MCP.
|
|
157
|
+
*
|
|
158
|
+
* El MCP es la autoridad de la máquina de estados; el plugin solo escribe un
|
|
159
|
+
* puñado de campos "advisory" (`ownedKeys`). Antes el plugin hacía
|
|
160
|
+
* readState → mutar → write del snapshot COMPLETO: si el MCP avanzaba en el
|
|
161
|
+
* medio (transición/audit/tasks), la escritura del plugin borraba ese avance.
|
|
162
|
+
*
|
|
163
|
+
* Ahora: (1) lock compartido con el MCP, (2) re-lectura fresca de disco DENTRO
|
|
164
|
+
* del lock, (3) se aplican SOLO las claves propias sobre el estado fresco,
|
|
165
|
+
* (4) write atómico (tmp único + rename). El `revision` se preserva del disco:
|
|
166
|
+
* las escrituras del plugin no son transiciones de la máquina de estados.
|
|
167
|
+
*/
|
|
168
|
+
export function persistState(directory: string, state: any, ownedKeys?: string[]): void {
|
|
77
169
|
const p = getStatePath(directory)
|
|
78
170
|
const dir = dirname(p)
|
|
79
171
|
try { mkdirSync(dir, { recursive: true }) } catch {}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
state.stateOversizedCount = (state.stateOversizedCount || 0) + 1
|
|
83
|
-
const trimmed = { ...state, snapshots: { codegraph: null, execution: null }, audit: (state.audit || []).slice(-50) }
|
|
84
|
-
serialized = JSON.stringify(trimmed, null, 2)
|
|
85
|
-
if (serialized.length > MAX_STATE_FILE_SIZE) return
|
|
86
|
-
state.snapshots = { codegraph: null, execution: null }
|
|
87
|
-
serialized = JSON.stringify(state, null, 2)
|
|
88
|
-
}
|
|
89
|
-
const tmp = p + ".tmp." + process.pid
|
|
172
|
+
|
|
173
|
+
const release = acquireStateLock(p)
|
|
90
174
|
try {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
175
|
+
let payload: any = state
|
|
176
|
+
if (ownedKeys && ownedKeys.length > 0) {
|
|
177
|
+
let fresh: any = null
|
|
178
|
+
try { fresh = JSON.parse(readFileSync(p, "utf-8")) } catch {}
|
|
179
|
+
if (fresh && typeof fresh === "object" && !Array.isArray(fresh)) {
|
|
180
|
+
payload = mergePluginOwnedState(fresh, state, ownedKeys)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
let serialized = JSON.stringify(payload, null, 2)
|
|
184
|
+
if (serialized.length > MAX_STATE_FILE_SIZE) {
|
|
185
|
+
payload.stateOversizedCount = (payload.stateOversizedCount || 0) + 1
|
|
186
|
+
const trimmed = { ...payload, snapshots: { codegraph: null, execution: null }, audit: (payload.audit || []).slice(-50) }
|
|
187
|
+
serialized = JSON.stringify(trimmed, null, 2)
|
|
188
|
+
if (serialized.length > MAX_STATE_FILE_SIZE) return
|
|
189
|
+
payload.snapshots = { codegraph: null, execution: null }
|
|
190
|
+
serialized = JSON.stringify(payload, null, 2)
|
|
191
|
+
}
|
|
192
|
+
const tmp = p + ".tmp." + process.pid + "." + ++persistSeq
|
|
95
193
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
renameSync(
|
|
194
|
+
writeFileSync(tmp, serialized, "utf-8")
|
|
195
|
+
renameSync(tmp, p)
|
|
196
|
+
try { renameSync(p + ".backup", p + ".backup.1") } catch {}
|
|
197
|
+
try { renameSync(p + ".backup.1", p + ".backup.2") } catch {}
|
|
198
|
+
try {
|
|
199
|
+
const backupTmp = p + ".backup.tmp." + process.pid + "." + persistSeq
|
|
200
|
+
writeFileSync(backupTmp, serialized, "utf-8")
|
|
201
|
+
renameSync(backupTmp, p + ".backup")
|
|
202
|
+
} catch {}
|
|
99
203
|
} catch {}
|
|
100
|
-
}
|
|
204
|
+
} finally {
|
|
205
|
+
release()
|
|
206
|
+
}
|
|
101
207
|
}
|
|
102
208
|
|
|
103
209
|
function fastFingerprint(filePath: string): string | null {
|
|
@@ -226,7 +332,7 @@ export default {
|
|
|
226
332
|
const pending = Array.isArray(s.expectedTasks) ? s.expectedTasks.filter((id: string) => !s.tasks?.[id] || s.tasks[id].status !== "COMPLETED").length : "?"
|
|
227
333
|
const msg = `[ostacky] Sigo trabajando — ${s.state} • ${completed}/${total} (${pending} pendientes) • hace ${Math.round(idle/1000)}s sin output`
|
|
228
334
|
try { console.log(msg) } catch {}
|
|
229
|
-
try { const st = readState(directory); if (st) { (st as any).lastPingTs = now; persistState(directory, st) } } catch {}
|
|
335
|
+
try { const st = readState(directory); if (st) { (st as any).lastPingTs = now; persistState(directory, st, ["lastPingTs"]) } } catch {}
|
|
230
336
|
} catch {}
|
|
231
337
|
}, 30_000)
|
|
232
338
|
if (pingInterval && typeof (pingInterval as any).unref === 'function') (pingInterval as any).unref()
|
|
@@ -335,7 +441,7 @@ export default {
|
|
|
335
441
|
if (st) {
|
|
336
442
|
st.engramBypassCount = (st.engramBypassCount || 0) + 1
|
|
337
443
|
// no persist cada vez si es muy frecuente: single-writer ya persiste en after, pero acá persistimos directo para auditoría
|
|
338
|
-
persistState(directory, st)
|
|
444
|
+
persistState(directory, st, ["engramBypassCount"])
|
|
339
445
|
}
|
|
340
446
|
} catch {}
|
|
341
447
|
}
|
|
@@ -541,7 +647,7 @@ export default {
|
|
|
541
647
|
try {
|
|
542
648
|
const st = readState(directory) || { ...DEFAULT_STATE }
|
|
543
649
|
st.staleContentAttempts = (st.staleContentAttempts || 0) + 1
|
|
544
|
-
persistState(directory, st)
|
|
650
|
+
persistState(directory, st, ["staleContentAttempts"])
|
|
545
651
|
} catch {}
|
|
546
652
|
throw new Error(`CONFLICT: stale fingerprint for ${targetPath}`)
|
|
547
653
|
}
|
|
@@ -599,6 +705,13 @@ export default {
|
|
|
599
705
|
ts: Date.now(),
|
|
600
706
|
mode: isCreate ? 'create' : 'replace',
|
|
601
707
|
}
|
|
708
|
+
// FIX multi-sesión: persistir lastValidated. Antes se mutaba un objeto
|
|
709
|
+
// local y se descartaba → complete_task leía un lastValidated viejo
|
|
710
|
+
// desde disco → CONFLICT → las tareas quedaban pending y el cierre
|
|
711
|
+
// (implementation_complete) se bloqueaba.
|
|
712
|
+
// `["lastValidated"]` = se escribe SOLO esa clave sobre el estado
|
|
713
|
+
// fresco que se re-lee bajo el lock compartido con el MCP.
|
|
714
|
+
persistState(directory, st, ["lastValidated"])
|
|
602
715
|
}
|
|
603
716
|
} catch {}
|
|
604
717
|
}
|
|
@@ -636,7 +749,7 @@ export default {
|
|
|
636
749
|
const shouldPersist = process.env.OSTACKY_PLUGIN_PERSIST === "1" || (now - (s.lastHeartbeat || 0) > 30000)
|
|
637
750
|
if (shouldPersist) {
|
|
638
751
|
s.lastHeartbeat = now
|
|
639
|
-
try { persistState(directory, s) } catch {}
|
|
752
|
+
try { persistState(directory, s, ["lastHeartbeat"]) } catch {}
|
|
640
753
|
}
|
|
641
754
|
}
|
|
642
755
|
} catch {}
|