ostacky 0.9.7 → 0.10.1

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.
@@ -215,7 +215,10 @@ export function parseTaskVerifications(changeId, statePath) {
215
215
  if (!m) continue;
216
216
  const id = m[1];
217
217
  if (!isValidTaskId(id)) continue;
218
- const idx = line.indexOf('verificar:');
218
+ // lastIndexOf: la convención es que el marker va al FINAL de la línea. Con indexOf,
219
+ // una task que MENCIONA el formato en su descripción ("usá — verificar: file:...") se
220
+ // parseaba a sí misma mal: ganaba el ejemplo, no el marker real.
221
+ const idx = line.lastIndexOf('verificar:');
219
222
  if (idx !== -1) {
220
223
  const v = line
221
224
  .slice(idx + 'verificar:'.length)
@@ -230,6 +233,35 @@ export function parseTaskVerifications(changeId, statePath) {
230
233
  }
231
234
  }
232
235
 
236
+ /**
237
+ * test-verification-execution: evidencia real de una corrida de test.
238
+ *
239
+ * NO usa `status`: el shell tool de OpenCode reporta "completed" incluso con
240
+ * exit != 0 (medido: `sh -c 'exit 3'` -> status "completed", metadata.exit 3).
241
+ * El plugin registra {cmd, exit, ts} por cada shell; acá sólo se acepta el
242
+ * comando exacto, exit 0, y posterior a la última mutación.
243
+ */
244
+ export function findTestEvidence({ runs, cmd, lastMutationTs, now = Date.now() } = {}) {
245
+ const expected = String(cmd || '').trim();
246
+ if (!expected) return { ok: false, reason: 'test: sin comando' };
247
+ if (!Array.isArray(runs) || runs.length === 0)
248
+ return { ok: false, reason: 'sin evidencia: el plugin no registró corridas de shell (¿está corriendo?)' };
249
+ const matches = runs.filter((r) => r && String(r.cmd || '').trim() === expected);
250
+ if (matches.length === 0) return { ok: false, reason: `nunca se ejecutó exactamente "${expected}"` };
251
+ const last = matches.reduce((a, b) => ((Number(b.ts) || 0) >= (Number(a.ts) || 0) ? b : a));
252
+ if (typeof last.ts !== 'number' || typeof lastMutationTs !== 'number')
253
+ return {
254
+ ok: false,
255
+ reason: `sin timestamps: no se puede probar que "${expected}" sea posterior a la última edición`,
256
+ run: last,
257
+ };
258
+ if (last.ts <= lastMutationTs)
259
+ return { ok: false, reason: `la última corrida de "${expected}" es anterior a la última edición`, run: last };
260
+ if (last.exit !== 0)
261
+ return { ok: false, reason: `la última corrida de "${expected}" salió con exit ${last.exit}`, run: last };
262
+ return { ok: true, reason: `exit 0 hace ${Math.max(0, Math.round((now - last.ts) / 1000))}s`, run: last };
263
+ }
264
+
233
265
  function isValidTaskId(taskId) {
234
266
  return typeof taskId === 'string' && /^[a-zA-Z0-9-_.\/:]+$/.test(taskId);
235
267
  }
@@ -446,6 +478,11 @@ class OstackyController {
446
478
  #lockHeartbeatPath;
447
479
  #lockMaxAttempts = 5; // C1: 10→5 with jitter, overridable via opts for fast tests
448
480
  #lockOwner = false;
481
+ #lockDepth = 0; // multi-sesión: reentrancy (el lock se toma una vez por tool call)
482
+ #callerSessionId = null; // sessionId del caller (de _meta), solo en memoria
483
+ #loadedSnapshot = null; // huella del estado recién leído, para detectar "dirty"
484
+ #persistQueue = Promise.resolve(); // cola in-process: las escrituras nunca se solapan
485
+ #persistSeq = 0; // sufijo único por escritura (tmp.<pid>.<seq>)
449
486
 
450
487
  constructor(opts = {}) {
451
488
  this.#statePath = opts.statePath;
@@ -484,6 +521,76 @@ class OstackyController {
484
521
  }
485
522
  }
486
523
 
524
+ // ─── multi-sesión ────────────────────────────────────────────────────────
525
+ /**
526
+ * Re-lectura fresca desde disco. Invalida el cache #loaded y reusa la lógica
527
+ * de #load() (migraciones + fallback de backups). Se llama al INICIO de cada
528
+ * tool call, así dos procesos/sesiones nunca operan sobre un snapshot viejo
529
+ * (antes: `if (this.#loaded) return` hacía que el estado quedara congelado).
530
+ */
531
+ reloadFresh() {
532
+ this.#loaded = false;
533
+ this.#load();
534
+ this.#loadedSnapshot = this.#diffableState();
535
+ }
536
+
537
+ /**
538
+ * Huella serializada del estado (para el diff de "dirty").
539
+ * Excluye `lastHeartbeat` porque se actualiza en cada tool call y no debe
540
+ * forzar una escritura a disco (rotaría backups sin cambios reales).
541
+ */
542
+ #diffableState() {
543
+ if (!this.#state) return '';
544
+ try {
545
+ return safeJsonStringify({ ...this.#state, lastHeartbeat: 0 });
546
+ } catch {
547
+ return '';
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Persiste si hubo mutaciones en memoria respecto de la última lectura/escritura.
553
+ * Es OBLIGATORIO ahora que reloadFresh() descarta el estado en memoria al inicio
554
+ * de cada tool call: sin esto, una mutación que no llamó a #persist (p.ej. el
555
+ * buffer de auditoría, o los campos de ownership) se perdería al siguiente call.
556
+ */
557
+ async persistIfDirty() {
558
+ if (!this.#statePath || !this.#state) return false;
559
+ // Drenar auditoría pendiente: el buffer vive solo en memoria, así que si no
560
+ // lo volcamos acá se perdería en el próximo reloadFresh().
561
+ try {
562
+ if (this.#auditBuffer.length > 0) await this.#flushAudit(false);
563
+ } catch {}
564
+ if (this.#diffableState() === this.#loadedSnapshot) return false;
565
+ await this.#persist();
566
+ this.#loadedSnapshot = this.#diffableState();
567
+ return true;
568
+ }
569
+
570
+ /** Registra el sessionId del caller actual (de `_meta["ai.opencode/sessionID"]`). */
571
+ setCallerSession(sessionId) {
572
+ this.#callerSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null;
573
+ }
574
+
575
+ /** sessionId del caller actual, o null si el cliente no lo mandó. */
576
+ get callerSessionId() {
577
+ return this.#callerSessionId;
578
+ }
579
+
580
+ /**
581
+ * Toma el lock de estado por toda la duración de un tool call (read-modify-write
582
+ * serializado entre procesos/sesiones). Reentrante: #persist puede volver a
583
+ * pedirlo dentro de la misma operación.
584
+ */
585
+ async beginWrite() {
586
+ return this.#acquireLock();
587
+ }
588
+
589
+ /** Libera el lock tomado por beginWrite (reentrante). */
590
+ endWrite() {
591
+ this.#releaseLock();
592
+ }
593
+
487
594
  /**
488
595
  * Validates that a parsed state object has the required fields and valid values.
489
596
  * Returns null if valid, or an error message if invalid.
@@ -500,6 +607,13 @@ class OstackyController {
500
607
  // --- 3.4: State file locking (C1 fix: check stale BEFORE write, atomic wx, jitter, 15s stale, 1s timeout) ---
501
608
  async #acquireLock() {
502
609
  if (!this.#lockPath) return true;
610
+ // Reentrante: el boundary del tool ya tomó el lock (beginWrite) y #persist lo
611
+ // vuelve a pedir dentro de la MISMA operación. Sin esto, el segundo intento
612
+ // chocaba con nuestro propio lock (EEXIST) y hacía fallar la persistencia.
613
+ if (this.#lockDepth > 0) {
614
+ this.#lockDepth++;
615
+ return true;
616
+ }
503
617
  const maxAttempts = this.#lockMaxAttempts;
504
618
  const lockTimeout = 1000;
505
619
  const staleWindow = 15000;
@@ -546,6 +660,7 @@ class OstackyController {
546
660
  }
547
661
  this.#heartbeatLock();
548
662
  this.#lockOwner = true;
663
+ this.#lockDepth = 1;
549
664
  return true;
550
665
  } catch {
551
666
  const base = Math.min(lockTimeout, 100 * Math.pow(2, attempt));
@@ -561,6 +676,12 @@ class OstackyController {
561
676
 
562
677
  #releaseLock() {
563
678
  if (!this.#lockPath) return;
679
+ // Reentrante: solo el nivel más externo libera de verdad.
680
+ if (this.#lockDepth > 1) {
681
+ this.#lockDepth--;
682
+ return;
683
+ }
684
+ this.#lockDepth = 0;
564
685
  // D2: verify ownership before deleting — never delete another process's lock
565
686
  try {
566
687
  const ownerPid = readFileSync(this.#lockPidPath, 'utf8').trim();
@@ -743,7 +864,24 @@ class OstackyController {
743
864
  this.#loaded = true;
744
865
  }
745
866
 
746
- async #persist() {
867
+ /**
868
+ * Serializa las escrituras de estado DENTRO del proceso.
869
+ * Sin esto, un flush fire-and-forget (#enterDegradedMode/#exitDegradedMode) o un
870
+ * flush del watchdog puede interleavearse con el persist de un handler: ambos
871
+ * escriben el MISMO `tmp.<pid>` y el segundo rename falla con ENOENT (o, peor,
872
+ * se pierde la escritura). Ahora las escrituras se encolan: nunca se solapan.
873
+ */
874
+ #persist() {
875
+ const run = () => this.#persistInner();
876
+ const next = this.#persistQueue.then(run, run);
877
+ this.#persistQueue = next.then(
878
+ () => {},
879
+ () => {}
880
+ );
881
+ return next;
882
+ }
883
+
884
+ async #persistInner() {
747
885
  if (!this.#statePath) return;
748
886
 
749
887
  const dir = dirname(this.#statePath);
@@ -818,7 +956,7 @@ class OstackyController {
818
956
  stateForSerialize.snapshots = { codegraph: null, execution: null };
819
957
  serialized = safeJsonStringify(stateForSerialize, true);
820
958
  }
821
- const tmp = this.#statePath + '.tmp.' + process.pid;
959
+ const tmp = this.#statePath + '.tmp.' + process.pid + '.' + ++this.#persistSeq;
822
960
  await writeFileAsync(tmp, serialized, 'utf8');
823
961
  await renameAsync(tmp, this.#statePath);
824
962
  // 2.1: backup rotativo 3 niveles best-effort
@@ -1100,25 +1238,79 @@ class OstackyController {
1100
1238
  async startRequest({ requestId, changeId, force = false } = {}) {
1101
1239
  this.#load();
1102
1240
 
1241
+ const caller = this.#callerSessionId;
1242
+ const owner = this.#state.ownerSessionId;
1243
+ // OJO: `INTERPRETATION_PENDING` está en TERMINAL_STATES — es "terminal" para
1244
+ // decidir si se RESUME, pero NO significa que no haya trabajo. Por eso separamos:
1245
+ // - resumeEligible: semántica ORIGINAL de start_request (no la tocamos).
1246
+ // - foreignOwnedWork: hay una request de OTRA sesión que todavía no terminó.
1247
+ const resumeEligible = !TERMINAL_STATES.includes(this.#state.state) && !!this.#state.requestId;
1248
+ const foreignOwnedWork =
1249
+ !!this.#state.requestId &&
1250
+ this.#state.state !== STATES.DONE &&
1251
+ !!owner &&
1252
+ !!caller &&
1253
+ owner !== caller;
1254
+
1255
+ // ── Guard de ownership (multi-sesión) ──
1256
+ // Si hay una request viva de OTRA sesión y no se fuerza, NO pisamos nada:
1257
+ // devolvemos el estado real para que el caller haga un takeover explícito
1258
+ // (force:true, auditado). Sin caller conocido (clientes que no mandan `_meta`)
1259
+ // el guard no aplica: comportamiento idéntico al anterior.
1260
+ if (foreignOwnedWork && !force) {
1261
+ await this.#audit(
1262
+ this.#state.state,
1263
+ 'start_request_conflict',
1264
+ `caller=${caller} owner=${owner} requestId=${this.#state.requestId}`
1265
+ );
1266
+ return {
1267
+ conflict: true,
1268
+ state: this.#state.state,
1269
+ revision: this.#state.revision,
1270
+ requestId: this.#state.requestId,
1271
+ ownerSessionId: owner,
1272
+ callerSessionId: caller,
1273
+ message:
1274
+ 'Otra sesión posee la request activa. Reintentá con force:true para hacer takeover (queda auditado).',
1275
+ };
1276
+ }
1277
+
1103
1278
  // 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) {
1279
+ if (!force && resumeEligible) {
1280
+ // Reclamamos ownership si no había dueño o si cambió (sesión única / reconexión).
1281
+ if (caller && this.#state.ownerSessionId !== caller) {
1282
+ this.#state.ownerSessionId = caller;
1283
+ }
1284
+ this.#state.ownerHeartbeat = Date.now();
1105
1285
  await this.#audit(
1106
1286
  this.#state.state,
1107
1287
  'start_request',
1108
- `resumed from ${this.#state.state}, requestId=${this.#state.requestId}`
1288
+ `resumed from ${this.#state.state}, requestId=${this.#state.requestId}${caller ? `, session=${caller}` : ''}`
1109
1289
  );
1110
1290
  return {
1111
1291
  state: this.#state.state,
1112
1292
  revision: this.#state.revision,
1113
1293
  requestId: this.#state.requestId,
1294
+ ownerSessionId: this.#state.ownerSessionId,
1114
1295
  continued: true,
1115
1296
  };
1116
1297
  }
1117
1298
 
1299
+ // Takeover EXPLÍCITO (force sobre una request viva de otra sesión) → auditado
1300
+ if (force && foreignOwnedWork) {
1301
+ await this.#audit(
1302
+ this.#state.state,
1303
+ 'session_takeover',
1304
+ `from=${owner} to=${caller} requestId=${this.#state.requestId}`
1305
+ );
1306
+ }
1307
+
1118
1308
  // Force reset or terminal state: create new session
1119
1309
  await this.#transition('INTERPRETATION_PENDING', {
1120
1310
  requestId: requestId || 'req-' + Date.now(),
1121
1311
  changeId: changeId || null,
1312
+ ownerSessionId: caller || null,
1313
+ ownerHeartbeat: Date.now(),
1122
1314
  routeDecisionId: null,
1123
1315
  routeChoice: null,
1124
1316
  executionDecisionId: null,
@@ -1140,6 +1332,7 @@ class OstackyController {
1140
1332
  state: this.#state.state,
1141
1333
  revision: this.#state.revision,
1142
1334
  requestId: this.#state.requestId,
1335
+ ownerSessionId: this.#state.ownerSessionId,
1143
1336
  continued: false,
1144
1337
  };
1145
1338
  }
@@ -1563,6 +1756,14 @@ class OstackyController {
1563
1756
  expectedTasks = [...tasksMdIds];
1564
1757
  expectedTaskCount = tasksMdIds.length;
1565
1758
  }
1759
+ // task-verification-integrity D1: las verificaciones viajan con el MISMO parse de
1760
+ // tasks.md que ya usa expectedTasks. Antes sólo las cargaba set_expected_tasks, una
1761
+ // tool que el prompt nunca menciona -> `— verificar:` quedaba muerto en la práctica.
1762
+ let taskVerifs = null;
1763
+ try {
1764
+ const parsedVerifs = parseTaskVerifications(this.#state.changeId, this.#statePath);
1765
+ taskVerifs = Object.keys(parsedVerifs).length ? parsedVerifs : null;
1766
+ } catch {}
1566
1767
  // 8.1/8.2: lastProposal for execution — reasoning con plan
1567
1768
  let execShown = false;
1568
1769
  let execFiles = [];
@@ -1602,6 +1803,7 @@ class OstackyController {
1602
1803
  snapshots: { ...this.#state.snapshots, execution: snapshot ? structuredClone(snapshot) : null },
1603
1804
  expectedTasks: Array.isArray(expectedTasks) ? [...expectedTasks] : null,
1604
1805
  expectedTaskCount: typeof expectedTaskCount === 'number' ? expectedTaskCount : null,
1806
+ taskVerifications: taskVerifs,
1605
1807
  lastProposal: execLastProposal,
1606
1808
  });
1607
1809
  await this.#audit('EXECUTION_DECISION_PENDING', 'record_execution_analysis');
@@ -1653,20 +1855,11 @@ class OstackyController {
1653
1855
  }
1654
1856
  // C2: warning if missing codegraphUsed+recommendation and not degraded — snapshot missing also counts
1655
1857
  // 1.7: early-exit with taskCount<=2 is valid without codegraphUsed, do not warn
1656
- // mejora-acciones-controller F3: discovery-cache counts as valid evidence if discovery snapshot exists
1657
- const isDiscoveryCacheEvidence =
1658
- snapshot &&
1659
- Array.isArray(snapshot.codegraphUsed) &&
1660
- snapshot.codegraphUsed.includes('discovery-cache') &&
1661
- this.#state.snapshots.codegraph != null &&
1662
- Array.isArray(snapshot.expectedTaskIds) &&
1663
- snapshot.expectedTaskIds.length > 0;
1664
1858
  const hasEvidence =
1665
1859
  (snapshot &&
1666
1860
  Array.isArray(snapshot.codegraphUsed) &&
1667
1861
  snapshot.codegraphUsed.length > 0 &&
1668
- snapshot.recommendation != null) ||
1669
- isDiscoveryCacheEvidence;
1862
+ snapshot.recommendation != null);
1670
1863
  if (!hasEvidence && !this.#degraded && !isEarlyExitExec) {
1671
1864
  this.#state.codegraphBypassCount = (this.#state.codegraphBypassCount || 0) + 1;
1672
1865
  const auditId = `aud-${Date.now()}-${this.#state.auditSeq}`;
@@ -1770,6 +1963,25 @@ class OstackyController {
1770
1963
  return this.#makeError(`No decision to refresh from state ${s}`, 'refresh_decision');
1771
1964
  }
1772
1965
 
1966
+ /**
1967
+ * task-verification-integrity D3: si el estado no tiene expectedTasks ni expectedTaskCount,
1968
+ * leer la fuente (tasks.md) en el momento del gate. El gate no puede quedar vacío sólo
1969
+ * porque record_execution_analysis nunca corrió o el cache se perdió.
1970
+ * Devuelve null cuando no hay tasks.md (trabajo no-spec: sin gate) o cuando manda el count.
1971
+ */
1972
+ #deriveExpectedTasks() {
1973
+ if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
1974
+ return this.#state.expectedTasks;
1975
+ }
1976
+ if (typeof this.#state.expectedTaskCount === 'number') return null;
1977
+ try {
1978
+ const ids = parseTasksMd(this.#state.changeId, this.#statePath);
1979
+ return ids.length > 0 ? ids : null;
1980
+ } catch {
1981
+ return null;
1982
+ }
1983
+ }
1984
+
1773
1985
  async implementationComplete({ force } = {}) {
1774
1986
  this.#load();
1775
1987
  const to = this.#isAllowedTransition(this.#state.state, 'implementation_complete');
@@ -1779,9 +1991,11 @@ class OstackyController {
1779
1991
  'implementation_complete'
1780
1992
  );
1781
1993
  // C2 gate: check expectedTasks vs completed — do NOT transition if pending and not forced
1994
+ // D3: un cache vacío no vacía el gate — se deriva de tasks.md.
1995
+ const derivedExpected = this.#deriveExpectedTasks();
1782
1996
  let pending = [];
1783
- if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
1784
- pending = this.#state.expectedTasks.filter(
1997
+ if (Array.isArray(derivedExpected) && derivedExpected.length > 0) {
1998
+ pending = derivedExpected.filter(
1785
1999
  (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
1786
2000
  );
1787
2001
  } else if (typeof this.#state.expectedTaskCount === 'number') {
@@ -1794,7 +2008,14 @@ class OstackyController {
1794
2008
  const seenFp2 = new Set();
1795
2009
  try {
1796
2010
  for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
1797
- if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
2011
+ if (info.status !== 'COMPLETED' || !info.filePath) continue;
2012
+ // task-verification-integrity D2: COMPLETED con filePath y sin hash es un fantasma
2013
+ // (complete_task lo aceptaba); antes se salteaba y quedaba invisible al cierre.
2014
+ if (!info.fileHash) {
2015
+ staleFiles.push(`${taskId}:${info.filePath} (missing hash)`);
2016
+ seenFp2.add(info.filePath);
2017
+ continue;
2018
+ }
1798
2019
  const current = fingerprintFor(info.filePath, this.#statePath);
1799
2020
  if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
1800
2021
  else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
@@ -1866,10 +2087,27 @@ class OstackyController {
1866
2087
  };
1867
2088
  }
1868
2089
  if (hasBlocking && force) {
1869
- // 4.3: force requiere confirmación humana en últimas 5 entradas de audit
1870
- const recentAudit = [...(this.#state.audit || []).slice(-5), ...this.#auditBuffer.slice(-5)];
1871
- const hasHuman = recentAudit.some((e) => e.reasoning && /forzar|confirmo|force/i.test(e.reasoning));
2090
+ // task-verification-integrity D4: la evidencia es el TEXTO DEL USUARIO, no el audit.
2091
+ // El audit lo redacta el agente: aceptar /forzar|confirmo|force/ ahí era self-attestation
2092
+ // (cualquier tool call con esa palabra abría el gate). El plugin registra lastUserPrompts
2093
+ // desde ctx.session.hook("prompt"), antes de inyectar hints.
2094
+ // Durden: cada prompt lleva ts y cada rechazo setea lastForceAttemptTs — sólo
2095
+ // cuenta una confirmación POSTERIOR al último intento ("¿qué hace force:true?"
2096
+ // previo al attempt ya no habilita). Strings sin ts = estado legacy: válidos
2097
+ // sólo si nunca hubo intento registrado.
2098
+ const userPrompts = Array.isArray(this.#state.lastUserPrompts) ? this.#state.lastUserPrompts : [];
2099
+ const lastAttempt = Number(this.#state.lastForceAttemptTs) || 0;
2100
+ const isUserPhrase = (p) => {
2101
+ const text = typeof p === 'string' ? p : typeof p?.text === 'string' ? p.text : null;
2102
+ if (!text || !/forzar|confirmo|force/i.test(text)) return false;
2103
+ const ts = typeof p?.ts === 'number' ? p.ts : null;
2104
+ if (ts === null) return lastAttempt === 0;
2105
+ return ts > lastAttempt;
2106
+ };
2107
+ const hasHuman = userPrompts.some(isUserPhrase);
1872
2108
  if (!hasHuman) {
2109
+ this.#state.lastForceAttemptTs = Date.now();
2110
+ await this.#persist();
1873
2111
  const pendingMsg =
1874
2112
  pending.length > 0 ? `Te faltan ${pending.length} task(s): ${pending.join(', ')}.` : '';
1875
2113
  const staleMsg = staleFiles.length > 0 ? `Archivos stale: ${staleFiles.join(', ')}.` : '';
@@ -1883,7 +2121,7 @@ class OstackyController {
1883
2121
  verificationFailed: verificationFailed.length ? verificationFailed : undefined,
1884
2122
  current_state: this.#state.state,
1885
2123
  attempted_transition: 'implementation_complete',
1886
- suggestion: `${detail} Para forzar el cierre, escribí literal "confirmo forzar" en tu mensaje (quedará auditado) y reintentá con {force:true}. Si no querés forzar y es solo stale por refino, llamá refresh_fingerprint({filePath}) y reintentá sin force.`,
2124
+ suggestion: `${detail} Para forzar el cierre, el USUARIO debe escribir literal "confirmo forzar" en un mensaje NUEVO suyo (el plugin lo registra con timestamp; una frase previa a este aviso no cuenta, y el audit que redacta el agente tampoco) y luego reintentá con {force:true}. Si no querés forzar y es solo stale por refino, llamá refresh_fingerprint({filePath}) y reintentá sin force.`,
1887
2125
  };
1888
2126
  }
1889
2127
  const all = [...pending, ...staleFiles, ...verificationFailed].join(',');
@@ -2051,9 +2289,11 @@ class OstackyController {
2051
2289
 
2052
2290
  async verifyIntegrity() {
2053
2291
  this.#load();
2292
+ // D3: un cache vacío no vacía el gate — se deriva de tasks.md.
2293
+ const derivedExpected = this.#deriveExpectedTasks();
2054
2294
  let pending = [];
2055
- if (Array.isArray(this.#state.expectedTasks) && this.#state.expectedTasks.length > 0) {
2056
- pending = this.#state.expectedTasks.filter(
2295
+ if (Array.isArray(derivedExpected) && derivedExpected.length > 0) {
2296
+ pending = derivedExpected.filter(
2057
2297
  (id) => !this.#state.tasks[id] || this.#state.tasks[id].status !== 'COMPLETED'
2058
2298
  );
2059
2299
  } else if (typeof this.#state.expectedTaskCount === 'number') {
@@ -2066,7 +2306,13 @@ class OstackyController {
2066
2306
  const seenFp = new Set();
2067
2307
  try {
2068
2308
  for (const [taskId, info] of Object.entries(this.#state.tasks || {})) {
2069
- if (info.status !== 'COMPLETED' || !info.filePath || !info.fileHash) continue;
2309
+ if (info.status !== 'COMPLETED' || !info.filePath) continue;
2310
+ // task-verification-integrity D2: COMPLETED con filePath y sin hash es un fantasma
2311
+ if (!info.fileHash) {
2312
+ staleFiles.push(`${taskId}:${info.filePath} (missing hash)`);
2313
+ seenFp.add(info.filePath);
2314
+ continue;
2315
+ }
2070
2316
  const current = fingerprintFor(info.filePath, this.#statePath);
2071
2317
  if (!current) staleFiles.push(`${taskId}:${info.filePath} (missing)`);
2072
2318
  else if (current !== info.fileHash) staleFiles.push(`${taskId}:${info.filePath} (stale fingerprint)`);
@@ -2094,7 +2340,14 @@ class OstackyController {
2094
2340
  const parts = rest.split('contiene');
2095
2341
  needle = (parts[1] || parts[0] || '').trim() || rest;
2096
2342
  } else if (needle.startsWith('test:')) {
2097
- // test: asumimos ok si ya está completa (test se corrió en agente)
2343
+ // test-verification-execution: cierre re-verifica evidencia; antes se salteaba.
2344
+ const cmd = needle.slice('test:'.length).trim();
2345
+ const ev = findTestEvidence({
2346
+ runs: this.#state.shellRuns,
2347
+ cmd,
2348
+ lastMutationTs: this.#state.lastMutationTs,
2349
+ });
2350
+ if (!ev.ok) verificationFailed.push(`${tid}: ${verif} — ${ev.reason}`);
2098
2351
  continue;
2099
2352
  }
2100
2353
  if (!needle) continue;
@@ -2189,6 +2442,15 @@ class OstackyController {
2189
2442
  return {
2190
2443
  revision: this.#state.revision,
2191
2444
  state: this.#state.state,
2445
+ // multi-sesión: quién posee la request y quién está preguntando AHORA.
2446
+ // Se calcula con el callerSessionId de ESTA llamada (no está persistido).
2447
+ ownership: {
2448
+ ownerSessionId: this.#state.ownerSessionId ?? null,
2449
+ callerSessionId: this.#callerSessionId,
2450
+ isOwner:
2451
+ !!this.#callerSessionId && this.#callerSessionId === (this.#state.ownerSessionId ?? null),
2452
+ ownerHeartbeat: this.#state.ownerHeartbeat ?? 0,
2453
+ },
2192
2454
  degraded: this.#degraded || !!this.#state.degraded,
2193
2455
  consecutiveFailures: this.#consecutiveFailures,
2194
2456
  taskCounts: {
@@ -2366,7 +2628,9 @@ class OstackyController {
2366
2628
 
2367
2629
  async getState() {
2368
2630
  this.#load();
2369
- return structuredClone(this.#state);
2631
+ // callerSessionId es de la llamada actual (no del estado persistido):
2632
+ // permite verificar de un vistazo qué sesión está operando.
2633
+ return { ...structuredClone(this.#state), callerSessionId: this.#callerSessionId };
2370
2634
  }
2371
2635
 
2372
2636
  async getTasks() {
@@ -2618,6 +2882,19 @@ class OstackyController {
2618
2882
  // Generico: test: tasks no requieren archivo nuevo (son verificaciones de comportamiento)
2619
2883
  const verifEarly = this.#state.taskVerifications?.[taskId];
2620
2884
  const isTestTaskEarly = verifEarly && verifEarly.trim().startsWith('test:');
2885
+ // task-verification-integrity D2: un filePath que no existe no puede quedar COMPLETED.
2886
+ // Antes se aceptaba con fileHash:null y verifyIntegrity lo salteaba -> fantasma invisible.
2887
+ // Sin excepción por tipo de task: un path declarado debe existir; una task `test:` no
2888
+ // necesita declarar filePath (y si lo declara, ese archivo ya se creó antes de completarla).
2889
+ if (filePath) {
2890
+ const absPath =
2891
+ filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)
2892
+ ? resolve(filePath)
2893
+ : resolve(getProjectRoot(this.#statePath), filePath);
2894
+ if (!existsSync(absPath)) {
2895
+ return { error: `filePath does not exist: ${filePath}`, taskId, filePath };
2896
+ }
2897
+ }
2621
2898
  const isNewFile = !!(
2622
2899
  isTestTaskEarly ||
2623
2900
  (filePath &&
@@ -2655,7 +2932,7 @@ class OstackyController {
2655
2932
  `task ${taskId} verification failed: ${verifForComplete}`
2656
2933
  );
2657
2934
  return {
2658
- error: `verification failed for ${taskId}: ${verifForComplete} not found in ${vres.fileHint || filePath || 'unknown file'}`,
2935
+ error: `verification failed for ${taskId}: ${verifForComplete} — ${vres.reason || `not found in ${vres.fileHint || filePath || 'unknown file'}`}`,
2659
2936
  taskId,
2660
2937
  verification: verifForComplete,
2661
2938
  verificationFailed: true,
@@ -2795,14 +3072,27 @@ class OstackyController {
2795
3072
  else fileHint = fileHint || info?.filePath || null;
2796
3073
  if (!needle) needle = rest;
2797
3074
  } else if (needle.startsWith('test:')) {
2798
- // test: no podemos ejecutar desde controller, consideramos ok si hay filePath (ya editado) o fileHash
2799
- needle = needle.slice('test:'.length).trim();
2800
- const hasFile = !!(fileHint || info?.filePath || info?.fileHash);
2801
- const ok = hasFile;
3075
+ // test-verification-execution: evidencia real registrada por el plugin
3076
+ // (exit + timestamp). Nunca "assumed ok"; el comando debe coincidir exacto.
3077
+ const cmd = needle.slice('test:'.length).trim();
3078
+ const ev = findTestEvidence({
3079
+ runs: this.#state.shellRuns,
3080
+ cmd,
3081
+ lastMutationTs: this.#state.lastMutationTs,
3082
+ });
2802
3083
  if (!this.#state.taskVerificationResults) this.#state.taskVerificationResults = {};
2803
- this.#state.taskVerificationResults[taskId] = { ok, verif, ts: Date.now() };
3084
+ this.#state.taskVerificationResults[taskId] = {
3085
+ ok: ev.ok,
3086
+ verif,
3087
+ reason: ev.reason,
3088
+ // Copia plana, NO la referencia de shellRuns: el serializador seguro
3089
+ // marca referencias repetidas como "[Circular]" y corrompería la
3090
+ // evidencia persistida (bug detectado por el test de integración).
3091
+ run: ev.run ? { cmd: ev.run.cmd, exit: ev.run.exit, ts: ev.run.ts } : null,
3092
+ ts: Date.now(),
3093
+ };
2804
3094
  await this.#persist();
2805
- return { ok, taskId, verif, reason: hasFile ? 'test verification assumed ok (has file)' : 'no file' };
3095
+ return { ok: ev.ok, taskId, verif, command: cmd, reason: ev.reason, run: ev.run };
2806
3096
  }
2807
3097
  // Generic: check file content contains needle
2808
3098
  let content = null;
@@ -2924,16 +3214,55 @@ const controller = new OstackyController({ statePath });
2924
3214
  * @param {number} options.maxRetries - Maximum retry attempts (default: 0)
2925
3215
  * @param {number} options.baseTimeout - Base timeout in ms (default: 5000)
2926
3216
  */
3217
+ /**
3218
+ * Extrae el sessionId de OpenCode del contexto del request.
3219
+ * OpenCode lo manda como `_meta["ai.opencode/sessionID"]` en cada tools/call
3220
+ * (aplica a tools directos y a Code Mode; puede faltar → degradamos a null,
3221
+ * y con null NO se aplica guard de ownership para no romper clientes viejos).
3222
+ */
3223
+ function extractCallerSessionId(ctx) {
3224
+ try {
3225
+ const meta = ctx && ctx.mcpReq && ctx.mcpReq._meta;
3226
+ if (meta && typeof meta === 'object') {
3227
+ const v = meta['ai.opencode/sessionID'];
3228
+ if (typeof v === 'string' && v) return v;
3229
+ }
3230
+ } catch {}
3231
+ // Fallback: sessionId del transporte (stdio → puede ser único por proceso,
3232
+ // NO por sesión de OpenCode; por eso es solo fallback).
3233
+ try {
3234
+ if (ctx && typeof ctx.sessionId === 'string' && ctx.sessionId) return ctx.sessionId;
3235
+ } catch {}
3236
+ return null;
3237
+ }
3238
+
2927
3239
  function safeHandler(fn, options = {}) {
2928
3240
  const { maxRetries = 0, baseTimeout = 5000 } = options;
2929
3241
 
2930
- return async (params) => {
3242
+ return async (params, ctx) => {
3243
+ // ── multi-sesión: identidad + estado fresco + lock por operación ──
3244
+ // 1) sessionId del caller (de `_meta`), 2) re-lectura fresca desde disco
3245
+ // (mata el snapshot congelado del cache `#loaded` cuando hay 2+ procesos),
3246
+ // 3) lock reentrante durante TODO el read-modify-write → sin lost updates.
3247
+ let heldLock = false;
3248
+ try {
3249
+ controller.setCallerSession(extractCallerSessionId(ctx));
3250
+ controller.reloadFresh();
3251
+ heldLock = await controller.beginWrite();
3252
+ } catch {}
3253
+ const endOp = () => {
3254
+ if (!heldLock) return;
3255
+ heldLock = false;
3256
+ try {
3257
+ controller.endWrite();
3258
+ } catch {}
3259
+ };
2931
3260
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
2932
3261
  const currentTimeout = baseTimeout * Math.pow(1.5, attempt);
2933
3262
  const start = Date.now();
2934
3263
  try {
2935
3264
  const result = await Promise.race([
2936
- fn(params),
3265
+ fn(params, ctx),
2937
3266
  new Promise((_, reject) =>
2938
3267
  setTimeout(() => reject(new Error(`timeout ${currentTimeout}ms`)), currentTimeout)
2939
3268
  ),
@@ -2942,8 +3271,13 @@ function safeHandler(fn, options = {}) {
2942
3271
  try {
2943
3272
  await controller._recordToolDuration(duration);
2944
3273
  } catch {}
3274
+ // Flush si el handler mutó el estado sin persistir (audit buffer, ownership…)
3275
+ try {
3276
+ await controller.persistIfDirty();
3277
+ } catch {}
2945
3278
  // Update heartbeat on successful completion
2946
3279
  controller.updateHeartbeat();
3280
+ endOp();
2947
3281
  return { content: [{ type: 'text', text: safeJsonStringify(result) }] };
2948
3282
  } catch (error) {
2949
3283
  const isTimeout = error && error.message && error.message.includes('timeout');
@@ -2969,6 +3303,7 @@ function safeHandler(fn, options = {}) {
2969
3303
  try {
2970
3304
  await controller._recordToolTimeout();
2971
3305
  } catch {}
3306
+ endOp();
2972
3307
  return {
2973
3308
  content: [
2974
3309
  {
@@ -2985,6 +3320,7 @@ function safeHandler(fn, options = {}) {
2985
3320
  error: error.message,
2986
3321
  stack: error.stack,
2987
3322
  });
3323
+ endOp();
2988
3324
  return {
2989
3325
  content: [{ type: 'text', text: safeJsonStringify({ error: error.message }) }],
2990
3326
  isError: true,
@@ -2996,7 +3332,7 @@ function safeHandler(fn, options = {}) {
2996
3332
 
2997
3333
  const server = new McpServer({
2998
3334
  name: 'ostacky-controller',
2999
- version: '0.9.7',
3335
+ version: '0.10.1',
3000
3336
  });
3001
3337
 
3002
3338
  server.registerTool(
@@ -3598,7 +3934,7 @@ function setupGracefulShutdown(ctrl) {
3598
3934
  }
3599
3935
 
3600
3936
  async function main() {
3601
- log('Starting ostacky-controller MCP v0.9.7...');
3937
+ log('Starting ostacky-controller MCP v0.10.1...');
3602
3938
  log('State path:', { path: statePath });
3603
3939
  // Clean up stale tmp/lock files from previous runs
3604
3940
  cleanupTmpFiles(statePath);