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.
@@ -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
- async #persist() {
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 && !TERMINAL_STATES.includes(this.#state.state) && this.#state.requestId) {
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
- return structuredClone(this.#state);
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.9.7',
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.9.7...');
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
- function persistState(directory: string, state: any): void {
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
- let serialized = JSON.stringify(state, null, 2)
81
- if (serialized.length > MAX_STATE_FILE_SIZE) {
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
- writeFileSync(tmp, serialized, "utf-8")
92
- renameSync(tmp, p)
93
- try { renameSync(p + ".backup", p + ".backup.1") } catch {}
94
- try { renameSync(p + ".backup.1", p + ".backup.2") } catch {}
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
- const backupTmp = p + ".backup.tmp." + process.pid
97
- writeFileSync(backupTmp, serialized, "utf-8")
98
- renameSync(backupTmp, p + ".backup")
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
- } catch {}
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 {}