agentic-workflow-manager 6.2.1 → 6.4.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.
Files changed (70) hide show
  1. package/README.md +17 -0
  2. package/dist/src/commands/job/gate.js +37 -13
  3. package/dist/src/commands/job/index.js +48 -8
  4. package/dist/src/commands/job/request.js +44 -6
  5. package/dist/src/commands/track/emit.js +26 -0
  6. package/dist/src/commands/track/index.js +194 -0
  7. package/dist/src/commands/track/status.js +63 -0
  8. package/dist/src/commands/track/supervisor-wrapper.js +89 -0
  9. package/dist/src/commands/watch/apply.js +201 -13
  10. package/dist/src/commands/watch/index.js +14 -0
  11. package/dist/src/commands/watch/runner.js +20 -1
  12. package/dist/src/commands/watch/supervisor.js +251 -27
  13. package/dist/src/commands/watch/teardown-driver.js +189 -0
  14. package/dist/src/commands/watch/tracks.js +1100 -0
  15. package/dist/src/core/journal/adapter.js +46 -6
  16. package/dist/src/core/journal/paths.js +9 -0
  17. package/dist/src/core/journal/process.js +101 -1
  18. package/dist/src/core/journal/requests.js +5 -1
  19. package/dist/src/core/journal/store.js +34 -2
  20. package/dist/src/core/journal/types.js +93 -4
  21. package/dist/src/core/paths.js +51 -0
  22. package/dist/src/core/tracks/concurrency.js +85 -0
  23. package/dist/src/core/tracks/context.js +89 -0
  24. package/dist/src/core/tracks/descriptor.js +40 -0
  25. package/dist/src/core/tracks/git.js +318 -0
  26. package/dist/src/core/tracks/join.js +185 -0
  27. package/dist/src/core/tracks/ownership.js +100 -0
  28. package/dist/src/core/tracks/plan-parser.js +108 -0
  29. package/dist/src/core/tracks/protocol.js +466 -0
  30. package/dist/src/core/tracks/teardown.js +34 -0
  31. package/dist/src/core/tracks/types.js +14 -0
  32. package/dist/src/index.js +2 -0
  33. package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
  34. package/dist/tests/commands/track/fixtures.js +13 -0
  35. package/dist/tests/commands/track/status.test.js +157 -0
  36. package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
  37. package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
  38. package/dist/tests/commands/track/verbs.test.js +326 -0
  39. package/dist/tests/commands/watch/apply.test.js +116 -4
  40. package/dist/tests/commands/watch/runner.test.js +22 -0
  41. package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
  42. package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
  43. package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
  44. package/dist/tests/commands/watch/track-finalize.test.js +643 -0
  45. package/dist/tests/commands/watch/track-freeze.test.js +591 -0
  46. package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
  47. package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
  48. package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
  49. package/dist/tests/core/journal/adapter-override.test.js +51 -0
  50. package/dist/tests/core/journal/process.test.js +37 -0
  51. package/dist/tests/core/journal/requests.test.js +21 -0
  52. package/dist/tests/core/journal/store.test.js +28 -0
  53. package/dist/tests/core/journal/types.test.js +89 -0
  54. package/dist/tests/core/same-existing-path.test.js +48 -0
  55. package/dist/tests/core/tracks/concurrency.test.js +134 -0
  56. package/dist/tests/core/tracks/context.test.js +177 -0
  57. package/dist/tests/core/tracks/descriptor.test.js +76 -0
  58. package/dist/tests/core/tracks/git.test.js +132 -0
  59. package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
  60. package/dist/tests/core/tracks/join.test.js +197 -0
  61. package/dist/tests/core/tracks/ownership.test.js +96 -0
  62. package/dist/tests/core/tracks/plan-parser.test.js +94 -0
  63. package/dist/tests/core/tracks/protocol.test.js +416 -0
  64. package/dist/tests/core/tracks/teardown.test.js +62 -0
  65. package/dist/tests/helpers/git-fixture.js +35 -0
  66. package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
  67. package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
  68. package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
  69. package/dist/tests/structural/sensor-configs-are-present.test.js +49 -0
  70. package/package.json +1 -1
@@ -10,10 +10,91 @@ const fingerprint_1 = require("../../core/journal/fingerprint");
10
10
  const adapter_1 = require("../../core/journal/adapter");
11
11
  const process_1 = require("../../core/journal/process");
12
12
  const gate_1 = require("../job/gate");
13
+ const git_1 = require("../../core/tracks/git");
13
14
  const lock_1 = require("./lock");
14
15
  const apply_1 = require("./apply");
15
16
  const runner_1 = require("./runner");
17
+ const tracks_1 = require("./tracks");
16
18
  const generations_1 = require("./generations");
19
+ /** R7/C3/C4 (Task 12) + fix post-review #2 (re-derivado desde cero tras
20
+ * encontrar que la justificación original no probaba lo que decía — ver
21
+ * finding 1 del segundo round de re-review): fases de `CohortPhase` en las
22
+ * que una cohorte de tracks todavía puede "ganarle la carrera" a su propio
23
+ * `run-final-interlock`, es decir donde el `computeGate` GENÉRICO de este
24
+ * mismo `tick()` (línea de abajo, calculado DESPUÉS de `runnerTick`) puede
25
+ * certificar pass antes de que `reconcileTracks` (que corrió ANTES de
26
+ * `runnerTick`, arriba en este mismo `tick()`) haya tenido chance de
27
+ * observar ese mismo resultado y mover `cohortPhase` a `COMPLETE`.
28
+ *
29
+ * Cadena causal EXACTA, trazada contra `protocol.ts`/`tracks.ts`/`apply.ts`:
30
+ * 1. `nextProtocolEffect` (protocol.ts) solo emite `request-final-integration`
31
+ * cuando `s.globalQaHeadSha !== undefined` — y ese campo se asigna
32
+ * (`reconcileProtocol`, observación `global-qa-pass`) EN LA MISMA
33
+ * transición que pone `cohortPhase = 'FINAL_INTEGRATION'`. No existe
34
+ * ningún camino donde `request-final-integration` se emita con
35
+ * `cohortPhase` todavía en `'ACTIVE'`/`'JOINING'`.
36
+ * 2. `runRequestFinalIntegration` (tracks.ts) es quien llama a `requestJob`
37
+ * con `verificationKind: 'track-integration'` — el ÚNICO lugar que
38
+ * pide el job canónico. Por (1), esto solo puede ocurrir con
39
+ * `cohortPhase === 'FINAL_INTEGRATION'`.
40
+ * 3. `apply.ts::applyRequestToState` (rama `job-request`) es quien enlaza
41
+ * `VerificationItem.satisfiedBy` — y lo hace a REQUEST time (job recién
42
+ * creado en `executionState: 'received'`, o job "equivalente" ya en
43
+ * vuelo), nunca esperando a que el job termine. Por (2), el primer
44
+ * tick en que esto puede pasar ya tiene `cohortPhase === 'FINAL_INTEGRATION'`.
45
+ * 4. Cada tick corre COMO MÁXIMO una mutación de protocolo por invocación
46
+ * de `reconcileTracks` (invariante propio de esa función, ver su
47
+ * comment de cabecera) y esa invocación sucede ANTES de `runnerTick`
48
+ * (que despacha/recolecta jobs) y ANTES del `computeGate` genérico de
49
+ * abajo. Entonces: en el tick donde el job canónico recién enlazado
50
+ * pasa a `verdict: 'pass'` (recolectado por `runnerTick`, que corre
51
+ * DESPUÉS de que `reconcileTracks` ya intentó — y no pudo, porque el
52
+ * job seguía vivo — avanzar el protocolo este mismo tick), el
53
+ * `computeGate` genérico de abajo puede certificar `pass` con
54
+ * `liveJobs === 0` mientras `cohortPhase` sigue en
55
+ * `'FINAL_INTEGRATION'` (el job pasó, pero `finalIntegrationJobId`
56
+ * todavía no se asignó — eso requiere la observación `integration-pass`,
57
+ * que recién corre el PRÓXIMO tick). Esta es la primera fase en riesgo.
58
+ * 5. Una vez que ese próximo tick observa `integration-pass` y mueve
59
+ * `cohortPhase` a `'FINAL_INTERLOCK'` (dentro de `reconcileTracks`,
60
+ * otra vez ANTES del `computeGate` genérico de ESE tick), el gate
61
+ * genérico sigue viendo el mismo job ya-pasado satisfaciendo el mismo
62
+ * item — sigue en riesgo de certificar en ESE tick, con `cohortPhase`
63
+ * todavía en `'FINAL_INTERLOCK'` (la transición real a `COMPLETE` la
64
+ * hace `runRunFinalInterlock`, que corre recién el tick SIGUIENTE,
65
+ * otra vez antes que el gate genérico de ese tick). Segunda y última
66
+ * fase en riesgo.
67
+ * 6. Cuando `runRunFinalInterlock` sí corre y su propio `computeGate`
68
+ * interno pasa, mueve `cohortPhase` a `'COMPLETE'` SINCRÓNICAMENTE
69
+ * dentro de `reconcileTracks`, antes de que el gate genérico de ese
70
+ * mismo tick se calcule — por eso `'COMPLETE'` nunca necesita estar en
71
+ * este set (ya lo cubre `cohortDone` por igualdad directa).
72
+ *
73
+ * Por (1)-(3): `'ACTIVE'`/`'JOINING'` NO califican. Más fuerte todavía:
74
+ * `registerTrackIntegrationItems` (apply.ts) agrega cada `track-integration:*`
75
+ * al `cycleVerificationPlan` SIN `satisfiedBy` en cuanto el track se declara
76
+ * (mucho antes de `ACTIVE`/`JOINING`) — mientras al menos uno siga sin
77
+ * `satisfiedBy`, `computeGate` (gate.ts) nunca puede dar `pass`, así que el
78
+ * gate genérico es estructuralmente incapaz de certificar durante
79
+ * `ACTIVE`/`JOINING`, con o sin este guard.
80
+ * `'FINAL_QA'` tampoco califica — y no es solo "no está en riesgo", es
81
+ * DEAD: se rastreó cada asignación a `cohortPhase` en `protocol.ts`
82
+ * (`initialCohort`, `reconcileProtocol`, `observeProtocolEffect`) y ese
83
+ * valor nunca se produce; queda en el tipo `CohortPhase` pero ninguna
84
+ * transición real lo alcanza.
85
+ * Quedan fuera por lo ya documentado en la versión anterior de este
86
+ * comentario (verificado de nuevo, sigue siendo cierto): `'SERIAL'` (sin
87
+ * camino de vuelta a COMPLETE, `nextProtocolEffect` devuelve `null`
88
+ * incondicional), `'BLOCKED'` (lo maneja `enterCustody` aparte),
89
+ * `'PREPARING'`/`'FALLBACK_PENDING'` (todavía no existe job canónico
90
+ * enlazable) y `'COMPLETE'` (es el destino, no una fase "todavía corriendo").
91
+ *
92
+ * Las ÚNICAS dos fases donde el job canónico ya puede estar enlazado
93
+ * (`satisfiedBy` set) sin que la cohorte misma haya llegado a `COMPLETE`
94
+ * son `'FINAL_INTEGRATION'` y `'FINAL_INTERLOCK'`. */
95
+ const LIVE_COHORT_PHASES = new Set([
96
+ 'FINAL_INTEGRATION', 'FINAL_INTERLOCK',
97
+ ]);
17
98
  exports.DEFAULT_SUPERVISOR_CONFIG = {
18
99
  provider: 'codex',
19
100
  heartbeatTimeoutMs: 5 * 60000, // R4.2 default 5 min
@@ -23,6 +104,7 @@ exports.DEFAULT_SUPERVISOR_CONFIG = {
23
104
  killGraceMs: 5000,
24
105
  reconcileGraceMs: 10000,
25
106
  jobStallObservationMs: 5 * 60000, // R3.5 default: mismo orden de magnitud que heartbeatTimeoutMs, concern independiente
107
+ maxParallelTracks: 1, // overridden en tiempo de ejecución con loadDefaultParallelism() (watch/index.ts)
26
108
  };
27
109
  const LIVE = ['received', 'spawn-intent', 'claimed', 'running', 'cancel-requested'];
28
110
  class Supervisor {
@@ -34,11 +116,13 @@ class Supervisor {
34
116
  relaunchNotBefore = 0;
35
117
  lastActivity = null;
36
118
  lastGenerationToken = null;
37
- constructor(repoRoot, branch, cfg, spawner) {
119
+ trackRuntime;
120
+ constructor(repoRoot, branch, cfg, spawner, trackRuntime) {
38
121
  this.repoRoot = repoRoot;
39
122
  this.branch = branch;
40
123
  this.cfg = cfg;
41
124
  this.spawner = spawner;
125
+ this.trackRuntime = trackRuntime ?? (0, tracks_1.defaultTrackRuntime)(repoRoot, branch, { termGraceMs: cfg.termGraceMs, killGraceMs: cfg.killGraceMs });
42
126
  }
43
127
  fingerprintNow = (argv, paths, cwd) => {
44
128
  try {
@@ -74,13 +158,29 @@ class Supervisor {
74
158
  }
75
159
  }
76
160
  async tick() {
77
- const before = (0, store_1.readJournal)(this.repoRoot, this.branch);
78
- if (before.corrupt || before.state === null)
161
+ const before0 = (0, store_1.readJournal)(this.repoRoot, this.branch);
162
+ if (before0.corrupt || before0.state === null)
79
163
  throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
80
- (0, lock_1.verifyBranchInvariant)(this.repoRoot, before.state.branch);
81
- if (before.state.cycle.status === 'COMPLETE')
164
+ // R6.2/R6.8/C7 (Task 11): reconciliar un `MERGE_HEAD` abierto por un
165
+ // crash a mitad de un merge ANTES de cualquier guard general — hoy
166
+ // ningún guard existente (`verifyBranchInvariant` incluido) rechaza
167
+ // por `MERGE_HEAD`, pero esto corre primero de todos modos para que
168
+ // uno agregado en el futuro nunca pueda rechazar un estado ya
169
+ // reconciliable. `openJoin.handled` evita una SEGUNDA mutación real
170
+ // de tracks más abajo en este mismo tick (a lo sumo una por tick).
171
+ const openJoin = await (0, tracks_1.reconcileOpenJoin)(this.repoRoot, this.branch, before0.state, this.trackRuntime, this.cfg.maxParallelTracks);
172
+ const before = openJoin.state;
173
+ (0, lock_1.verifyBranchInvariant)(this.repoRoot, before.branch);
174
+ if (before.cycle.status === 'COMPLETE')
82
175
  return 'complete';
83
- const pending = before.state?.cycle.nextAction;
176
+ // R5.2/R6.3 (Task 10): restart-safe — un track ya `frozen` (crash del
177
+ // loop DESPUÉS de persistir el paso 6 pero ANTES de que
178
+ // `runSupervisorLoop` liberara el lock/saliera) jamás debe relanzar
179
+ // un controller nuevo ni volver a despachar; simplemente reafirma el
180
+ // mismo resultado terminal.
181
+ if (before.frozen !== undefined)
182
+ return 'frozen';
183
+ const pending = before.cycle.nextAction;
84
184
  const resumePrompt = pending !== undefined ? `el next_action ${pending.actionId} del journal` : 'el plan del ciclo desde el journal';
85
185
  if (this.ensureController(resumePrompt) === 'custody')
86
186
  return 'custody';
@@ -89,37 +189,99 @@ class Supervisor {
89
189
  throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
90
190
  const gen = (0, generations_1.activeGeneration)(r0.state);
91
191
  (0, apply_1.consumePendingRequests)(this.repoRoot, this.branch, gen?.token ?? null);
192
+ // P1/P2 (R4.1-R4.10): a lo sumo un side effect de bootstrap de tracks
193
+ // por tick, ANTES de tocar jobs — mientras la cohorte está PREPARING,
194
+ // ningún job de track se despacha (eso lo maneja `runnerTick` con los
195
+ // `Job` ya existentes; el bootstrap de tracks es un canal separado).
196
+ // Task 11: si `reconcileOpenJoin` ya ejecutó una mutación real este
197
+ // mismo tick (reconcilió un `MERGE_HEAD` abierto), no se vuelve a
198
+ // invocar `reconcileTracks` acá — a lo sumo una mutación real de
199
+ // tracks por tick, mismo invariante que ya sostenía `reconcileTracks`
200
+ // por sí solo antes de que existiera esta ruta temprana.
201
+ if (!openJoin.handled) {
202
+ const preTracks = (0, store_1.readJournal)(this.repoRoot, this.branch);
203
+ if (!preTracks.corrupt && preTracks.state !== null) {
204
+ // Task 9: `reconcileTracks` es `async` desde que `begin-teardown`
205
+ // puede terminar el grupo del supervisor de un track con
206
+ // `terminatePreviouslyOwnedGroup` (espera real de gracia,
207
+ // R4.8) — awaitear acá es obligatorio, nunca fire-and-forget.
208
+ await (0, tracks_1.reconcileTracks)(this.repoRoot, this.branch, preTracks.state, this.trackRuntime, this.cfg.maxParallelTracks);
209
+ }
210
+ }
92
211
  const afterRequests = (0, store_1.readJournal)(this.repoRoot, this.branch);
93
- if (afterRequests.state !== null && (0, generations_1.activeGeneration)(afterRequests.state) === undefined
212
+ // R7/C3 (Task 12): mientras la cohorte corre el job canónico de
213
+ // integración final o espera el interlock, NADIE debe relanzar el
214
+ // controller — `runRequestFinalIntegration`/`runRunFinalInterlock`
215
+ // (watch/tracks.ts) ya pausaron esa generación explícitamente
216
+ // (R7.3/C3) precisamente para que el árbol quede quieto durante esa
217
+ // ventana; sin este guard, esta misma rama la revivía en el mismo
218
+ // tick (o el siguiente) apenas la generación quedaba `terminated`,
219
+ // exactamente la mutación concurrente que la pausa buscaba evitar.
220
+ const finalizing = afterRequests.state?.cohortPhase === 'FINAL_INTEGRATION' || afterRequests.state?.cohortPhase === 'FINAL_INTERLOCK';
221
+ if (!finalizing && afterRequests.state !== null && (0, generations_1.activeGeneration)(afterRequests.state) === undefined
94
222
  && afterRequests.state.generations.length > 0 && afterRequests.state.cycle.status === 'IN_PROGRESS') {
95
223
  (0, generations_1.beginGeneration)(this.repoRoot, this.branch);
96
224
  if (this.ensureController(resumePrompt) === 'custody')
97
225
  return 'custody';
98
226
  }
99
- (0, runner_1.runnerTick)(this.repoRoot, this.branch, this.spawner, { reconcileGraceMs: this.cfg.reconcileGraceMs, stallObservationMs: this.cfg.jobStallObservationMs });
227
+ // R5.2/R6.3 (Task 10): recién leído tras `consumePendingRequests`
228
+ // si el request `track-freeze-request` llegó en ESTE tick, `apply.ts`
229
+ // ya lo tradujo a `freezeRequested`. `dispatch:false` corta SOLO el
230
+ // arranque de trabajo NUEVO; el drenaje de lo ya vivo sigue intacto
231
+ // (paso 2 del freeze — "consume/reconcilia jobs existentes").
232
+ const freezeCheck = (0, store_1.readJournal)(this.repoRoot, this.branch);
233
+ const freezing = !freezeCheck.corrupt && freezeCheck.state !== null
234
+ && freezeCheck.state.freezeRequested === true && freezeCheck.state.frozen === undefined;
235
+ (0, runner_1.runnerTick)(this.repoRoot, this.branch, this.spawner, {
236
+ reconcileGraceMs: this.cfg.reconcileGraceMs, stallObservationMs: this.cfg.jobStallObservationMs, dispatch: !freezing,
237
+ });
100
238
  const custody = await this.superviseController();
101
239
  if (custody)
102
240
  return 'custody';
241
+ if (freezing)
242
+ return this.attemptFreeze();
103
243
  const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
104
244
  const gate = (0, gate_1.computeGate)(r.state, r.corrupt, this.fingerprintNow);
105
245
  const liveJobs = r.state === null ? 1 : Object.values(r.state.jobs).filter((j) => LIVE.includes(j.executionState)).length;
106
- if (gate.pass && liveJobs === 0) { // gate verde YA implica cero vivos; doble cinturon (R4.5)
246
+ // R7/C3/C4 (Task 12): un journal de PLAN con cohorte de tracks NUNCA
247
+ // declara `cycle.status = COMPLETE` por este camino genérico mientras
248
+ // la cohorte no llegó ELLA MISMA a `cohortPhase === 'COMPLETE'` — sin
249
+ // este guard, `computeGate` certifica en cuanto el job canónico de
250
+ // integración se REQUIERE (apply.ts enlaza `satisfiedBy` al crear el
251
+ // job, antes de que termine) y pasa, ganándole la carrera al propio
252
+ // `run-final-interlock` (watch/tracks.ts): el ciclo se declararía
253
+ // COMPLETE mientras `cohortPhase` sigue en FINAL_INTEGRATION/
254
+ // FINAL_INTERLOCK, los tracks nunca llegan a JOINED y
255
+ // `integration.lock` queda retenido para siempre (este mismo check,
256
+ // arriba en `tick()`, corta el loop apenas ve `cycle.status ===
257
+ // COMPLETE` y jamás vuelve a llamar `reconcileTracks`).
258
+ //
259
+ // Fix post-review: la primera versión de este guard usaba
260
+ // `(tracks?.length ?? 0) >= 2` como único criterio de "gobernada" —
261
+ // pero el array `tracks` NUNCA se vacía ni se acorta cuando la
262
+ // cohorte cae a fallback SERIAL (Task 9: los tracks quedan
263
+ // `REMOVED`/`DECLARED` en el array, ver `track-bootstrap-crash.test.ts`),
264
+ // y `protocol.ts` no tiene (ni debe inventarse acá — single-authority)
265
+ // ningún camino de SERIAL de vuelta a COMPLETE. Con el criterio viejo,
266
+ // CUALQUIER plan cuya cohorte degradara a SERIAL quedaba
267
+ // PERMANENTEMENTE incapaz de completar su ciclo por este único lugar
268
+ // del código que fija `cycle.status = 'COMPLETE'`. El criterio
269
+ // correcto no es "¿existe un array de tracks?" sino "¿está la
270
+ // cohorte, AHORA MISMO, en una fase viva del ciclo de vida paralelo
271
+ // que todavía podría adelantarse a su propio interlock?"
272
+ // (`LIVE_COHORT_PHASES`, arriba). Una cohorte en SERIAL, BLOCKED,
273
+ // PREPARING o FALLBACK_PENDING — o un journal sin cohorte real — sigue
274
+ // el camino de siempre, sin cambios, igual que si nunca hubiera
275
+ // tenido tracks.
276
+ const cohortGoverned = r.state !== null && (r.state.tracks?.length ?? 0) >= 2
277
+ && r.state.cohortPhase !== undefined && LIVE_COHORT_PHASES.has(r.state.cohortPhase);
278
+ const cohortDone = !cohortGoverned || r.state.cohortPhase === 'COMPLETE';
279
+ if (gate.pass && liveJobs === 0 && cohortDone) { // gate verde YA implica cero vivos; doble cinturon (R4.5)
107
280
  const s = r.state;
108
- for (const generation of s.generations) {
109
- for (const ref of [generation.processRef, generation.wrapperRef]) {
110
- // Los tests pueden ejecutar el wrapper in-process; nunca
111
- // enviar una senial al propio supervisor.
112
- if (ref?.pid === process.pid)
113
- continue;
114
- if (ref === undefined || (0, process_1.groupIsGone)(ref.processGroup))
115
- continue;
116
- const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: this.cfg.termGraceMs, killGraceMs: this.cfg.killGraceMs });
117
- if (!confirmed) {
118
- (0, generations_1.enterCustody)(this.repoRoot, this.branch, `no se pudo terminar con identidad confirmada la generacion ${generation.n} antes de COMPLETE`);
119
- return 'custody';
120
- }
121
- }
122
- generation.state = 'terminated';
281
+ const terminated = await this.terminateAllGenerationsConfirmed(s);
282
+ if (!terminated.ok) {
283
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, `no se pudo terminar con identidad confirmada la generacion ${terminated.n} antes de COMPLETE`);
284
+ return 'custody';
123
285
  }
124
286
  s.cycle.status = 'COMPLETE';
125
287
  s.cycle.completedAt = new Date().toISOString();
@@ -129,6 +291,65 @@ class Supervisor {
129
291
  }
130
292
  return 'continue';
131
293
  }
294
+ /** Termina, con identidad CONFIRMADA (jamás un `kill(pid)` crudo), toda
295
+ * generación de `s` que todavía tenga un `processRef`/`wrapperRef` vivo
296
+ * — muta `s` en memoria (incluye marcar `state = 'terminated'`) pero
297
+ * JAMÁS persiste por sí sola: el caller combina esta mutación con la
298
+ * suya propia (`cycle.status = 'COMPLETE'` o `frozen = {...}`) en UN
299
+ * solo `writeJournal` (R1.3 — dos escrituras secuenciales sobre el
300
+ * mismo objeto violarían el CAS por revisión de `writeJournal`).
301
+ * Compartida entre el camino COMPLETE y el camino FROZEN (Task 10): el
302
+ * mismo requisito ("ningún controller administrado sigue vivo") aplica
303
+ * a ambos. */
304
+ async terminateAllGenerationsConfirmed(s) {
305
+ for (const generation of s.generations) {
306
+ for (const ref of [generation.processRef, generation.wrapperRef]) {
307
+ // Los tests pueden ejecutar el wrapper in-process; nunca
308
+ // enviar una senial al propio supervisor.
309
+ if (ref?.pid === process.pid)
310
+ continue;
311
+ if (ref === undefined || (0, process_1.groupIsGone)(ref.processGroup))
312
+ continue;
313
+ const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: this.cfg.termGraceMs, killGraceMs: this.cfg.killGraceMs });
314
+ if (!confirmed)
315
+ return { ok: false, n: generation.n };
316
+ }
317
+ generation.state = 'terminated';
318
+ }
319
+ return { ok: true };
320
+ }
321
+ /** Paso 2-6 del freeze (R5.2/R6.3, Task 10 — paso 1 "deja de despachar"
322
+ * ya lo hizo `dispatch:false` en `runnerTick`, arriba en `tick()`):
323
+ * fail-closed en cada paso — cualquier hecho todavía no demostrable
324
+ * simplemente pospone (`'continue'`, el próximo tick reintenta), JAMÁS
325
+ * fuerza `frozen` sobre evidencia incompleta. Solo cuando los 4 hechos
326
+ * restantes (cero vivos, gate local verde, worktree/index limpios,
327
+ * generación propia terminada CONFIRMADA) son TODOS demostrables se
328
+ * persiste `frozenHeadSha` + el marcador `frozen` — en el MISMO
329
+ * `writeJournal` que la terminación de generación de arriba. */
330
+ async attemptFreeze() {
331
+ const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
332
+ if (r.corrupt || r.state === null)
333
+ throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
334
+ const s = r.state;
335
+ const liveJobs = Object.values(s.jobs).filter((j) => LIVE.includes(j.executionState)).length;
336
+ if (liveJobs > 0)
337
+ return 'continue'; // paso 2: drenando todavía
338
+ const gate = (0, gate_1.computeTrackGate)(s, false, this.fingerprintNow);
339
+ if (!gate.pass)
340
+ return 'continue'; // paso 3: gate local todavía rojo
341
+ if (!(0, git_1.isWorktreeClean)(this.repoRoot))
342
+ return 'continue'; // paso 4: worktree/index todavía sucios
343
+ const terminated = await this.terminateAllGenerationsConfirmed(s); // paso 5
344
+ if (!terminated.ok) {
345
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, `no se pudo terminar con identidad confirmada la generacion ${terminated.n} antes de FROZEN (R5.2/R6.3)`);
346
+ return 'custody';
347
+ }
348
+ s.frozen = { headSha: (0, git_1.headSha)(this.repoRoot), at: new Date().toISOString() }; // paso 4/6: SHA congelado, durable
349
+ (0, store_1.writeJournal)(this.repoRoot, this.branch, s);
350
+ (0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'track-frozen', headSha: s.frozen.headSha });
351
+ return 'frozen'; // paso 6 ("libera el lock y sale"): runSupervisorLoop trata 'frozen' igual que 'complete'
352
+ }
132
353
  /** true => custodia (el caller NO libera lock ni sale). */
133
354
  async superviseController() {
134
355
  const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
@@ -194,7 +415,7 @@ exports.Supervisor = Supervisor;
194
415
  /** Foreground, visible, terminable (R2.4): sin daemons. SIGINT/SIGTERM libera
195
416
  * el lock y sale; COMPLETE => auto-exit liberando lock y terminando la
196
417
  * generacion propia (cero huerfanos). */
197
- async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.defaultWrapperSpawner)()) {
418
+ async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.defaultWrapperSpawner)(), trackRuntime) {
198
419
  const r = (0, store_1.readJournal)(repoRoot, branch);
199
420
  if (r.corrupt || r.state === null)
200
421
  throw new Error('journal ausente o corrupto: corre `awm watch --init` primero');
@@ -208,7 +429,7 @@ async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.d
208
429
  const onSignal = () => { shutdownRequested = true; wakeSleep?.(); };
209
430
  process.on('SIGINT', onSignal);
210
431
  process.on('SIGTERM', onSignal);
211
- const sup = new Supervisor(repoRoot, branch, cfg, spawner);
432
+ const sup = new Supervisor(repoRoot, branch, cfg, spawner, trackRuntime);
212
433
  try {
213
434
  const s0 = (0, store_1.readJournal)(repoRoot, branch).state;
214
435
  if ((0, generations_1.activeGeneration)(s0) === undefined) {
@@ -218,7 +439,10 @@ async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.d
218
439
  if (shutdownRequested)
219
440
  break;
220
441
  const out = await sup.tick();
221
- if (out === 'complete')
442
+ // 'frozen' (Task 10): mismo camino de salida que 'complete'
443
+ // drenar ownership y liberar el lock, el track ya cumplió su
444
+ // freeze y no debe seguir despachando ni corriendo su loop.
445
+ if (out === 'complete' || out === 'frozen')
222
446
  break;
223
447
  // 'custody': NO liberar lock, NO salir — seguir auditando (R4.5)
224
448
  await new Promise((resolve) => {
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runBeginTeardown = runBeginTeardown;
7
+ // Task 13 (R4.2/R4.3/R4.6/R4.10/C2/C9): extraído de `tracks.ts` (post-review
8
+ // de Task 13 — el archivo venía creciendo dos ciclos consecutivos de review
9
+ // hacia "actively harming reviewability") — mismo criterio que `join.ts` con
10
+ // `decideJoinReconciliation`/`acquireIntegrationLock`: la lógica de
11
+ // gathering + driver de UN concern vive en su propio módulo, `tracks.ts`
12
+ // sigue siendo el ÚNICO lugar con la orquestación (`reconcileTracks`,
13
+ // `executeRuntimeEffect`'s dispatch table) y el wiring de producción
14
+ // (`defaultTrackRuntime`). Esto es una extracción puramente MECÁNICA: cero
15
+ // cambio de comportamiento, `decideTeardown` sigue viviendo exclusivamente en
16
+ // `protocol.ts` (autoridad única, sin tocar).
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const paths_1 = require("../../core/journal/paths");
19
+ const process_1 = require("../../core/journal/process");
20
+ const protocol_1 = require("../../core/tracks/protocol");
21
+ const git_1 = require("../../core/tracks/git");
22
+ const teardown_1 = require("../../core/tracks/teardown");
23
+ const store_1 = require("../../core/journal/store");
24
+ const tracks_1 = require("./tracks");
25
+ /** Task 13 (R4.2/R4.3/R4.6/R4.10/C2/C9): gathering READ-ONLY del estado real
26
+ * de UN track en teardown — jamás cuenta como el side effect del tick
27
+ * (mismo criterio que `ownedWorktreeExists`/`foreignPathExists` en
28
+ * `runCreateWorktree`). Solo reúne los campos que la fase ACTUAL necesita
29
+ * (`decideTeardown` ignora el resto): `ownSupervisorAlive` en
30
+ * `TEARDOWN_INTENT`, `ownedWorktreeExists`/`foreignWorktree` en
31
+ * `SUPERVISOR_STOPPED`, `ownedBranchExists` en `WORKTREE_REMOVED`.
32
+ * `foreignSupervisor` deliberadamente nunca se produce acá: `refIsAlive`
33
+ * compara identidad completa (pid+startTime+pgid+argsDigest), así que un
34
+ * PID reciclado por otro proceso ya se reporta como "nuestro está muerto"
35
+ * (`ownSupervisorAlive: false`), nunca como "ajeno" — el campo existe en
36
+ * `TeardownObservation`/`decideTeardown` para la matriz pura y como defensa
37
+ * fail-closed, mismo criterio que `begin-fallback` en `decidePrepare`
38
+ * (nunca seleccionado por el driver real, pero cubierto por la autoridad
39
+ * única igual). */
40
+ function gatherTeardownObservation(planRoot, s, ref) {
41
+ if (ref.phase === 'TEARDOWN_INTENT') {
42
+ // Step 4 del plan: "supervisor propio muerto confirmado Y lock
43
+ // ausente" — un proceso identity-verified muerto que dejó su lock
44
+ // advisory sin liberar (crash a mitad de su propia salida, mismo
45
+ // riesgo que `runFreezeTrack` ya trata con `supervisorAlive ||
46
+ // lockExists`) todavía no prueba que sea seguro tocar el worktree.
47
+ // `ownSupervisorAlive: true` acá simplemente reintenta
48
+ // `stop-own-supervisor` en el próximo tick — inofensivo si el
49
+ // proceso ya está muerto (`groupIsGone` ya es `true`, no-op real),
50
+ // fail-closed si el lock sigue de verdad retenido.
51
+ const processAlive = ref.supervisorProcessRef !== undefined && (0, process_1.refIsAlive)(ref.supervisorProcessRef);
52
+ // `supervisorLockPath` exige un directorio real (`fs.realpathSync`)
53
+ // — un track abandonado ANTES de que su worktree llegara a existir
54
+ // (ej. `create-worktree` falló para el track siguiente, R4.5) nunca
55
+ // tuvo dónde escribir un lock: ausencia de directorio = ausencia de
56
+ // lock, nunca "indemostrable".
57
+ const lockExists = fs_1.default.existsSync(ref.worktreePath) && fs_1.default.existsSync((0, paths_1.supervisorLockPath)(ref.worktreePath));
58
+ return { ownSupervisorAlive: processAlive || lockExists };
59
+ }
60
+ if (ref.phase === 'SUPERVISOR_STOPPED') {
61
+ if (!fs_1.default.existsSync(ref.worktreePath))
62
+ return { ownedWorktreeExists: false };
63
+ if ((0, teardown_1.worktreeOwnershipProven)(planRoot, ref, s.journalId))
64
+ return { ownedWorktreeExists: true };
65
+ // Existe, pero no se pudo probar que es nuestro (R4.6/R4.10): jamás
66
+ // se adopta ni se borra a ciegas — bloquea en vez de saltear.
67
+ return { foreignWorktree: true };
68
+ }
69
+ if (ref.phase === 'WORKTREE_REMOVED') {
70
+ return { ownedBranchExists: (0, git_1.branchExists)(planRoot, ref.branch) };
71
+ }
72
+ return {};
73
+ }
74
+ /** Task 13 (R4.2/R4.3/R4.6/R4.10/C2/C9): reemplaza el `teardownOwned(ref,
75
+ * step)` TEMPORAL de Task 9 por el state machine completo de
76
+ * `decideTeardown` (`protocol.ts`, autoridad única) — a lo sumo UN efecto
77
+ * real por invocación, siempre persistido a través de `observeProtocolEffect`
78
+ * (nunca un `withRef` directo con una fase inventada acá, salvo para el
79
+ * snapshot no-decisional de `teardownIntent`), mismo criterio "gather ->
80
+ * decide -> tocar el mundo real como mucho una vez -> persistir -> stop"
81
+ * que `runCreateWorktree`/`runFreezeTrack`/`runMergeTrack`.
82
+ *
83
+ * Cada llamada a `decideTeardown` ocurre sobre la observación YA reunida
84
+ * (`observed`, antes de cualquier efecto): las decisiones "ya resuelto"
85
+ * (`accept-supervisor-stopped`, `mark-removed`, `block-foreign`) no
86
+ * necesitan tocar `runtime` — la observación existente alcanza para
87
+ * persistir el avance. Las decisiones "falta trabajo real"
88
+ * (`stop-own-supervisor`, `remove-owned-worktree`, `remove-owned-branch`)
89
+ * ejecutan EXACTAMENTE un efecto y vuelven a observar el mundo real después,
90
+ * persistiendo esa observación fresca — es `reconcileProtocol` quien, sobre
91
+ * ESA observación, vuelve a llamar `decideTeardown` para fijar la fase
92
+ * resultante (mismo patrón exacto que `runMergeTrack` con
93
+ * `decideJoinReconciliation`, T11: nunca se confía en que el efecto en sí
94
+ * haya funcionado, la relectura es la única fuente de verdad). */
95
+ async function runBeginTeardown(planRoot, branch, s, protocol, effect, runtime) {
96
+ const trackId = effect.trackId;
97
+ const ref = (0, tracks_1.refOf)(s, trackId);
98
+ const observed = gatherTeardownObservation(planRoot, s, ref);
99
+ const decision = (0, protocol_1.decideTeardown)(protocol.tracks[trackId], observed);
100
+ if (decision === 'persist-intent') {
101
+ // Único paso que además de la fase (`EFFECT_APPLIED_PHASE` ya mapea
102
+ // `begin-teardown` -> `TEARDOWN_INTENT`) captura el snapshot durable
103
+ // que Step 4 exige para probar ownership del worktree más adelante —
104
+ // dato del journal, no del protocolo puro, así que se aplica con
105
+ // `withRef` en la MISMA persistencia (una sola frontera, no dos).
106
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-applied', effect });
107
+ const withIntent = (0, tracks_1.withRef)((0, tracks_1.applyProtocolToState)(s, applied), trackId, {
108
+ teardownIntent: { worktreePath: ref.worktreePath, branch: ref.branch, supervisorNonce: ref.supervisorProcessRef?.spawnNonce },
109
+ });
110
+ const next = (0, tracks_1.persist)(planRoot, branch, withIntent);
111
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-teardown-step', trackId, step: 'TEARDOWN_INTENT' });
112
+ return { state: next, stop: true, executed: effect.kind };
113
+ }
114
+ if (decision === 'block-foreign') {
115
+ const blocked = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'teardown-observation', trackId, ...observed });
116
+ const next = (0, tracks_1.persist)(planRoot, branch, (0, tracks_1.applyProtocolToState)(s, blocked));
117
+ (0, store_1.appendEvent)(planRoot, branch, {
118
+ kind: 'track-blocked', trackId,
119
+ reason: observed.foreignSupervisor ? 'identidad de supervisor ajena' : 'worktree preexistente ajeno',
120
+ });
121
+ return { state: next, stop: true, executed: null };
122
+ }
123
+ if (decision === 'accept-supervisor-stopped' || decision === 'mark-removed') {
124
+ // Ya resuelto por la observación reunida — ningún touch nuevo de
125
+ // `runtime` este tick (mismo criterio que 'accept-worktree' en
126
+ // `runCreateWorktree`).
127
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'teardown-observation', trackId, ...observed });
128
+ const next = (0, tracks_1.persist)(planRoot, branch, (0, tracks_1.applyProtocolToState)(s, applied));
129
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-teardown-step', trackId, step: (0, tracks_1.refOf)(next, trackId).phase });
130
+ return { state: next, stop: true, executed: effect.kind };
131
+ }
132
+ if (decision === 'stop-own-supervisor') {
133
+ // R4.8: identity-verified — jamás un `kill(pid)` crudo. `true` <=>
134
+ // grupo confirmado ausente (recién ahora, o ya lo estaba).
135
+ const confirmed = await runtime.stopOwnSupervisor(ref);
136
+ const after = { ownSupervisorAlive: !confirmed };
137
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'teardown-observation', trackId, ...after });
138
+ const next = (0, tracks_1.persist)(planRoot, branch, (0, tracks_1.applyProtocolToState)(s, applied));
139
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-teardown-step', trackId, step: confirmed ? 'SUPERVISOR_STOPPED' : 'TEARDOWN_INTENT' });
140
+ return { state: next, stop: true, executed: effect.kind };
141
+ }
142
+ if (decision === 'remove-owned-worktree') {
143
+ // Ownership YA probado por `gatherTeardownObservation`
144
+ // (`worktreeOwnershipProven`) para que `decideTeardown` llegara acá —
145
+ // lo único que falta es el efecto real (`git.ts` aplica el guard de
146
+ // limpieza, bloqueando si está sucio, y nunca usa `--force`).
147
+ try {
148
+ runtime.removeOwnedWorktree(planRoot, ref);
149
+ }
150
+ catch (error) {
151
+ const failed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-failed', trackId, effect: effect.kind, detail: error.message });
152
+ const next = (0, tracks_1.persist)(planRoot, branch, (0, tracks_1.applyProtocolToState)(s, failed));
153
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect-failed', trackId, effect: effect.kind, detail: error.message });
154
+ return { state: next, stop: true, executed: effect.kind };
155
+ }
156
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'teardown-observation', trackId, ownedWorktreeExists: false });
157
+ const next = (0, tracks_1.persist)(planRoot, branch, (0, tracks_1.applyProtocolToState)(s, applied));
158
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-teardown-step', trackId, step: 'WORKTREE_REMOVED' });
159
+ return { state: next, stop: true, executed: effect.kind };
160
+ }
161
+ // decision === 'remove-owned-branch': dos fases de origen posibles
162
+ // (mismo valor de decisión, mismo criterio de dualidad que el resto de
163
+ // esta interfaz — ver el comentario de `decideTeardown`).
164
+ if (ref.phase === 'SUPERVISOR_STOPPED') {
165
+ // Sin worktree propio que remover (nunca existió, o ya se quitó en
166
+ // un intento previo que crasheó antes de persistir esto): avance
167
+ // puramente mecánico a `WORKTREE_REMOVED`, sin tocar `runtime` — el
168
+ // próximo tick, ya en esa fase, decide la branch de nuevo con una
169
+ // observación fresca.
170
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'teardown-observation', trackId, ownedWorktreeExists: false });
171
+ const next = (0, tracks_1.persist)(planRoot, branch, (0, tracks_1.applyProtocolToState)(s, applied));
172
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-teardown-step', trackId, step: 'WORKTREE_REMOVED' });
173
+ return { state: next, stop: true, executed: effect.kind };
174
+ }
175
+ // ref.phase === 'WORKTREE_REMOVED': la branch existe de verdad, efecto real.
176
+ try {
177
+ runtime.removeOwnedBranch(planRoot, ref.branch);
178
+ }
179
+ catch (error) {
180
+ const failed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-failed', trackId, effect: effect.kind, detail: error.message });
181
+ const next = (0, tracks_1.persist)(planRoot, branch, (0, tracks_1.applyProtocolToState)(s, failed));
182
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect-failed', trackId, effect: effect.kind, detail: error.message });
183
+ return { state: next, stop: true, executed: effect.kind };
184
+ }
185
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'teardown-observation', trackId, ownedBranchExists: false });
186
+ const next = (0, tracks_1.persist)(planRoot, branch, (0, tracks_1.applyProtocolToState)(s, applied));
187
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-teardown-step', trackId, step: 'BRANCH_REMOVED' });
188
+ return { state: next, stop: true, executed: effect.kind };
189
+ }