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
@@ -0,0 +1,1100 @@
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.canCompleteCohort = canCompleteCohort;
7
+ exports.applyProtocolToState = applyProtocolToState;
8
+ exports.persist = persist;
9
+ exports.refOf = refOf;
10
+ exports.withRef = withRef;
11
+ exports.reconcileTracks = reconcileTracks;
12
+ exports.reconcileOpenJoin = reconcileOpenJoin;
13
+ exports.observeSupervisorFromDisk = observeSupervisorFromDisk;
14
+ exports.defaultTrackRuntime = defaultTrackRuntime;
15
+ // P1/P2 (bootstrap durable de tracks, R4.1-R4.10, C1/C2/C8/C11): el ÚNICO
16
+ // lugar que ejecuta side effects reales derivados de `protocol.ts`. Esta
17
+ // función es un driver DELGADO — toda decisión de qué pasa después vive en
18
+ // `core/tracks/protocol.ts` (regla de autoridad única, ver ese archivo);
19
+ // `reconcileTracks` solo traduce `JournalState` <-> `CohortProtocol`, ejecuta
20
+ // como máximo UNA llamada mutante a `TrackRuntime` por invocación, y persiste
21
+ // cada frontera antes/después de intentarla (para que Task 9 pueda inyectar
22
+ // crashes en cualquier punto y probar que el restart converge).
23
+ const fs_1 = __importDefault(require("fs"));
24
+ const path_1 = __importDefault(require("path"));
25
+ const crypto_1 = __importDefault(require("crypto"));
26
+ const store_1 = require("../../core/journal/store");
27
+ const requests_1 = require("../../core/journal/requests");
28
+ const paths_1 = require("../../core/journal/paths");
29
+ const process_1 = require("../../core/journal/process");
30
+ const fingerprint_1 = require("../../core/journal/fingerprint");
31
+ const protocol_1 = require("../../core/tracks/protocol");
32
+ const git_1 = require("../../core/tracks/git");
33
+ const ownership_1 = require("../../core/tracks/ownership");
34
+ const descriptor_1 = require("../../core/tracks/descriptor");
35
+ const join_1 = require("../../core/tracks/join");
36
+ const teardown_driver_1 = require("./teardown-driver");
37
+ const request_1 = require("../job/request");
38
+ const gate_1 = require("../job/gate");
39
+ const process_2 = require("../../core/journal/process");
40
+ const types_1 = require("../../core/tracks/types");
41
+ /** R8.1 (Task 12): pura, sin I/O — nombra los tracks que todavía no llegaron a
42
+ * `MERGED_UNVERIFIED` (el ciclo permanece IN_PROGRESS mientras existan). Vive
43
+ * acá (no en `core/tracks/protocol.ts`) porque no es una decisión de
44
+ * transición de fase — `nextProtocolEffect` YA calcula la misma condición
45
+ * internamente para decidir `request-global-qa` (`protocol.ts` sigue siendo
46
+ * la única autoridad de esa transición); esto es solo un helper de
47
+ * DIAGNÓSTICO/consulta sobre el mismo criterio, para que un caller (CLI,
48
+ * logging) pueda nombrar qué falta sin duplicar la condición a mano.
49
+ *
50
+ * Semántica sutil de "pendiente": un track cuenta como pendiente si TODAVÍA
51
+ * NO llegó a `MERGED_UNVERIFIED` **o más allá**. `JOINED` es la fase que
52
+ * sigue a `MERGED_UNVERIFIED` (ver `TRACK_PHASES` en `core/tracks/types.ts`
53
+ * y el observation `interlock-pass` en `protocol.ts`, que mueve TODOS los
54
+ * tracks a `JOINED` a la vez al cerrar la cohorte) — un track `JOINED` ya
55
+ * superó el hito que este helper vigila, así que NO es pendiente, aunque
56
+ * `JOINED !== 'MERGED_UNVERIFIED'` textualmente. Comparar solo contra
57
+ * `'MERGED_UNVERIFIED'` sin excluir también `'JOINED'` marcaría
58
+ * incorrectamente como pendiente un track que ya terminó. */
59
+ function canCompleteCohort(state) {
60
+ const tracks = state.tracks ?? [];
61
+ const pendingTracks = tracks
62
+ .filter((t) => t.phase !== 'MERGED_UNVERIFIED' && t.phase !== 'JOINED')
63
+ .map((t) => t.trackId).sort();
64
+ return { complete: tracks.length >= 2 && pendingTracks.length === 0, pendingTracks };
65
+ }
66
+ const RUNTIME_EFFECTS = new Set([
67
+ 'create-worktree', 'create-track-journal', 'spawn-track-supervisor', 'begin-teardown', 'freeze-track', 'merge-track',
68
+ // R7 (Task 12): las 3 fronteras finales — únicas de la cohorte, jamás por
69
+ // track (ver drivers `runRequestGlobalQa`/`runRequestFinalIntegration`/
70
+ // `runRunFinalInterlock` más abajo).
71
+ 'request-global-qa', 'request-final-integration', 'run-final-interlock',
72
+ ]);
73
+ function toProtocol(state, maxParallel) {
74
+ const tracks = {};
75
+ for (const ref of state.tracks ?? []) {
76
+ tracks[ref.trackId] = {
77
+ trackId: ref.trackId,
78
+ phase: ref.phase,
79
+ fencingToken: ref.fencingToken,
80
+ readinessNonce: ref.readinessNonce,
81
+ frozenHeadSha: ref.frozenHeadSha,
82
+ expectedPlanHeadSha: ref.joinIntent?.expectedPlanHeadSha,
83
+ expectedTrackHeadSha: ref.joinIntent?.expectedTrackHeadSha,
84
+ joinedCommitSha: ref.joinedCommitSha,
85
+ blockedReason: ref.blockedReason,
86
+ };
87
+ }
88
+ return {
89
+ planJournalId: state.journalId,
90
+ cohortPhase: state.cohortPhase ?? 'PREPARING',
91
+ maxParallel,
92
+ tracks,
93
+ // R6.2/R6.3/C7 (Task 11): `cohortPlanHeadSha` es el HEAD real y
94
+ // AVANZANTE de la rama del plan tras cada join aceptado — antes del
95
+ // primer merge todavía no existe, y el HEAD real del plan en ese
96
+ // momento ES `cohortBaseSha` (nada mutó la rama todavía). Usar
97
+ // `cohortBaseSha` acá SIEMPRE (en vez de solo como fallback) sería el
98
+ // bug: congelaría `expectedPlanHeadSha` en el base original para
99
+ // TODOS los joins de la cohorte, rompiendo el segundo join en
100
+ // adelante (C7 exige que cada join sucesivo valide contra el HEAD
101
+ // real post-merge-anterior, no contra el punto de partida).
102
+ planHeadSha: state.cohortPlanHeadSha ?? state.cohortBaseSha,
103
+ // R7/C3 (Task 12): espejo de `state.globalQaHeadSha`/
104
+ // `finalIntegrationJobId` — sin esto, un restart perdería la
105
+ // evidencia de que el QA global o la integración final ya pasaron y
106
+ // `nextProtocolEffect` repetiría el efecto desde cero.
107
+ globalQaHeadSha: state.globalQaHeadSha,
108
+ finalIntegrationJobId: state.finalIntegrationJobId,
109
+ // Mismo criterio que los tres campos de arriba: `reconcileProtocol` fija
110
+ // `fallbackReason` sobre el protocolo, pero este loop lo reconstruye desde el
111
+ // journal en cada vuelta — sin releerlo acá, la causa específica de la
112
+ // degradación se pierde antes de que `enter-serial` llegue a usarla.
113
+ fallbackReason: state.cohortFallbackReason,
114
+ };
115
+ }
116
+ /** Vuelca las decisiones de `protocol.ts` de regreso al `TrackRef[]` real —
117
+ * nunca al revés: esta función jamás decide, solo transcribe. Exportada
118
+ * (junto con `persist`/`refOf`/`withRef`/`EffectRunResult` más abajo) para
119
+ * que `./teardown-driver` — que ejecuta el mismo patrón "gather -> decide ->
120
+ * tocar el mundo real como mucho una vez -> persistir -> stop" que todo
121
+ * `run*` de este archivo — reutilice estos helpers sin duplicarlos. */
122
+ function applyProtocolToState(state, protocol) {
123
+ const next = structuredClone(state);
124
+ next.cohortPhase = protocol.cohortPhase;
125
+ // R6.2/R6.3/C7 (Task 11): persistir el HEAD del plan avanzado por
126
+ // `reconcileProtocol`'s `accept-merge` (`out.planHeadSha = decision.
127
+ // joinedCommitSha`) — sin esto, el siguiente `toProtocol` volvería a leer
128
+ // `cohortBaseSha` (stale) y el segundo join de la cohorte usaría un
129
+ // `expectedPlanHeadSha` incorrecto.
130
+ if (protocol.planHeadSha !== undefined)
131
+ next.cohortPlanHeadSha = protocol.planHeadSha;
132
+ // R7/C3 (Task 12): mismo criterio que `cohortPlanHeadSha` arriba — valores
133
+ // que solo avanzan (nunca retroceden a `undefined` una vez fijados por
134
+ // `reconcileProtocol`).
135
+ if (protocol.globalQaHeadSha !== undefined)
136
+ next.globalQaHeadSha = protocol.globalQaHeadSha;
137
+ if (protocol.finalIntegrationJobId !== undefined)
138
+ next.finalIntegrationJobId = protocol.finalIntegrationJobId;
139
+ if (protocol.fallbackReason !== undefined)
140
+ next.cohortFallbackReason = protocol.fallbackReason;
141
+ next.tracks = (next.tracks ?? []).map((ref) => {
142
+ const t = protocol.tracks[ref.trackId];
143
+ if (t === undefined)
144
+ return ref;
145
+ return {
146
+ ...ref,
147
+ phase: t.phase,
148
+ frozenHeadSha: t.frozenHeadSha,
149
+ blockedReason: t.blockedReason,
150
+ joinedCommitSha: t.joinedCommitSha,
151
+ joinIntent: t.expectedPlanHeadSha !== undefined && t.expectedTrackHeadSha !== undefined
152
+ ? { expectedPlanHeadSha: t.expectedPlanHeadSha, expectedTrackHeadSha: t.expectedTrackHeadSha, strategy: types_1.JOIN_STRATEGY_NO_FF }
153
+ : ref.joinIntent,
154
+ };
155
+ });
156
+ return next;
157
+ }
158
+ function persist(planRoot, branch, s) {
159
+ (0, store_1.writeJournal)(planRoot, branch, s);
160
+ const r = (0, store_1.readJournal)(planRoot, branch);
161
+ if (r.corrupt || r.state === null)
162
+ throw new Error('journal corrupto tras persistir tracks (R1.6)');
163
+ return r.state;
164
+ }
165
+ function refOf(s, trackId) {
166
+ const ref = s.tracks?.find((t) => t.trackId === trackId);
167
+ if (ref === undefined)
168
+ throw new Error(`invariante rota: TrackRef ausente para ${trackId}`);
169
+ return ref;
170
+ }
171
+ function withRef(s, trackId, patch) {
172
+ return { ...s, tracks: (s.tracks ?? []).map((t) => (t.trackId === trackId ? { ...t, ...patch } : t)) };
173
+ }
174
+ function mustBaseSha(s) {
175
+ if (s.cohortBaseSha === undefined)
176
+ throw new Error('invariante rota: cohortBaseSha ausente para una cohorte en PREPARING/ACTIVE');
177
+ return s.cohortBaseSha;
178
+ }
179
+ function runCreateWorktree(planRoot, branch, s, protocol, effect, runtime) {
180
+ const trackId = effect.trackId;
181
+ const ref = refOf(s, trackId);
182
+ // Task 9 (R4.2/R4.6/C11): observaciones read-only del mundo real ANTES de
183
+ // tocar `runtime` — ninguna de las dos cuenta como el side effect del
184
+ // tick, igual que documentaba la versión anterior de este comentario.
185
+ // `ownedWorktreeExists` se consulta PRIMERO: un destino no vacío que ya
186
+ // está registrado por git en la branch determinista de este track solo
187
+ // puede ser NUESTRO, de un `addWorktree` que genuinamente corrió pero
188
+ // crasheó antes de que este mismo bloque persistiera `worktree-observed`
189
+ // — sin este chequeo, `foreignPathExists` (que no distingue "ajeno" de
190
+ // "nuestro intento interrumpido") bloquearía el track para siempre.
191
+ const observed = (0, git_1.ownedWorktreeExists)(planRoot, ref.worktreePath, ref.branch)
192
+ ? { worktreeOwned: true }
193
+ : { worktreeForeignNonEmpty: (0, git_1.foreignPathExists)(ref.worktreePath) };
194
+ const decision = (0, protocol_1.decidePrepare)(protocol.tracks[trackId], observed);
195
+ if (decision === 'accept-worktree') {
196
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'worktree-observed', trackId, owned: true });
197
+ const next = persist(planRoot, branch, applyProtocolToState(s, applied));
198
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect', trackId, effect: effect.kind });
199
+ return { state: next, stop: true, executed: effect.kind };
200
+ }
201
+ if (decision === 'block-foreign') {
202
+ const blocked = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'worktree-observed', trackId, owned: false });
203
+ const next = persist(planRoot, branch, applyProtocolToState(s, blocked));
204
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-blocked', trackId, reason: 'worktree preexistente ajeno' });
205
+ // Post-review fix (Task 8): bloquear un track es en sí mismo UNA
206
+ // frontera — dejar `stop:false` acá permitía que el MISMO call
207
+ // siguiera y tocara `runtime.addWorktree` de otro track (dos side
208
+ // effects reales en un solo `reconcileTracks()`, rompiendo el
209
+ // supuesto de Task 9 de que cada boundary es crash-injectable por
210
+ // separado).
211
+ return { state: next, stop: true, executed: null };
212
+ }
213
+ // decision === 'retry-worktree': ni ajeno ni nuestro todavía — recién
214
+ // acá se toca `runtime` de verdad.
215
+ try {
216
+ runtime.addWorktree(planRoot, ref, mustBaseSha(s));
217
+ }
218
+ catch (error) {
219
+ const failed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-failed', trackId, effect: effect.kind, detail: error.message });
220
+ const next = persist(planRoot, branch, applyProtocolToState(s, failed));
221
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect-failed', trackId, effect: effect.kind, detail: error.message });
222
+ return { state: next, stop: true, executed: effect.kind };
223
+ }
224
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'worktree-observed', trackId, owned: true });
225
+ const next = persist(planRoot, branch, applyProtocolToState(s, applied));
226
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect', trackId, effect: effect.kind });
227
+ return { state: next, stop: true, executed: effect.kind };
228
+ }
229
+ function runCreateTrackJournal(planRoot, branch, s, protocol, effect, runtime) {
230
+ const trackId = effect.trackId;
231
+ const ref = refOf(s, trackId);
232
+ // GAP CONOCIDO Y DELIBERADAMENTE DIFERIDO (ver el comentario largo en
233
+ // `apply.ts` junto a `track-prepare-request`): `taskIds`/`planDigest`
234
+ // deberían nacer del plan .md parseado, pero NINGÚN task (1-8) provee un
235
+ // mecanismo para que el supervisor localice ese archivo — decisión
236
+ // humana pendiente sobre qué task lo resuelve. R2.1/R9.1: lo que SÍ es
237
+ // autoridad
238
+ // aquí es la identidad del track y el baseSha común de la cohorte.
239
+ const context = { trackId, taskIds: [], planDigest: '', baseSha: mustBaseSha(s), planJournalId: s.journalId };
240
+ // Task 9: a diferencia de `create-worktree`/`spawn-track-supervisor`,
241
+ // `decidePrepare` no tiene nada que desambiguar acá — `write-descriptor`
242
+ // es la única decisión posible en fase WORKTREE_CREATED, PORQUE
243
+ // `runtime.initTrackJournal` ya es idempotente por construcción
244
+ // (`initJournal` jamás pisa un `state.json` existente, `writeDescriptor`
245
+ // sobreescribe con el mismo contenido siempre): reintentar tras un crash
246
+ // en cualquier punto de esta llamada converge solo, sin necesitar
247
+ // distinguir "primera vez" de "reintento". Se llama de todos modos para
248
+ // que la autoridad de decisión sea siempre `protocol.ts`, nunca un
249
+ // "por qué no hace falta acá" implícito en `tracks.ts`.
250
+ if ((0, protocol_1.decidePrepare)(protocol.tracks[trackId], {}) !== 'write-descriptor') {
251
+ throw new Error(`invariante rota: decidePrepare esperaba 'write-descriptor' para ${trackId}`);
252
+ }
253
+ try {
254
+ runtime.initTrackJournal(ref, context);
255
+ }
256
+ catch (error) {
257
+ const failed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-failed', trackId, effect: effect.kind, detail: error.message });
258
+ const next = persist(planRoot, branch, applyProtocolToState(s, failed));
259
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect-failed', trackId, effect: effect.kind, detail: error.message });
260
+ return { state: next, stop: true, executed: effect.kind };
261
+ }
262
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-applied', effect });
263
+ const next = persist(planRoot, branch, applyProtocolToState(s, applied));
264
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect', trackId, effect: effect.kind });
265
+ return { state: next, stop: true, executed: effect.kind };
266
+ }
267
+ /** R5.2/R5.7/R5.8/R5.9/R6.3/R6.4/R6.5/C5 (Task 10): "freeze del track,
268
+ * quiescencia del plan y precondiciones de join". Deliberadamente NO usa
269
+ * `decidePrepare`/una fase intermedia `FREEZE_REQUESTED` persistida vía
270
+ * `effect-applied` — `nextProtocolEffect` solo re-emite `freeze-track`
271
+ * mientras `t.phase === 'JOIN_REQUESTED'` (ver `protocol.ts`), así que este
272
+ * driver progresa AL MISMO trackId en sucesivos ticks (como
273
+ * `runSpawnTrackSupervisor` progresa dentro de `SUPERVISOR_STARTING`) y
274
+ * recién transiciona la fase cuando emite `freeze-observation` (-> FROZEN),
275
+ * nunca antes.
276
+ *
277
+ * "El supervisor del plan solo acepta freeze cuando observa los seis hechos.
278
+ * No escribe directamente el journal del track" — este driver JAMÁS llama
279
+ * `writeJournal(ref.worktreePath, ...)`: solo LEE el journal propio del
280
+ * track (read-only, gathering nunca cuenta como el side effect del tick,
281
+ * mismo criterio que `foreignPathExists` en `runCreateWorktree`) y, si
282
+ * todavía no se pidió nada, escribe una `track-freeze-request` a su
283
+ * requestsDir vía `runtime.emitFreezeRequest` (el ÚNICO touch real de este
284
+ * efecto — el trabajo real de las 6 observaciones lo hace el propio
285
+ * `Supervisor.tick()` del track, en su journal, en su propio proceso). */
286
+ function runFreezeTrack(planRoot, branch, s, protocol, effect, runtime) {
287
+ const trackId = effect.trackId;
288
+ const ref = refOf(s, trackId);
289
+ const r = (0, store_1.readJournal)(ref.worktreePath, ref.branch);
290
+ if (r.corrupt || r.state === null) {
291
+ // Journal del track todavía no legible (crash entre `create-track-
292
+ // journal` y esto no debería alcanzar acá — la cohorte exige ARMED
293
+ // antes de ACTIVE — pero fail-closed: sin evidencia legible, nada se
294
+ // asume, se reintenta en el próximo tick).
295
+ return { state: s, stop: true, executed: null };
296
+ }
297
+ const trackState = r.state;
298
+ if (trackState.frozen !== undefined) {
299
+ // El track ya completó, DURABLEMENTE en SU journal, los 6 hechos
300
+ // internos (drenado, gate local verde, worktree limpio, generación
301
+ // propia terminada) — el plan re-verifica acá lo barato e
302
+ // independientemente comprobable ANTES de aceptar (nunca confía
303
+ // ciegamente en el autoreporte del track para lo que puede probar
304
+ // por sí mismo): supervisor realmente caído + lock realmente
305
+ // liberado. Gate/limpieza/drenado NO se re-verifican acá — son caros
306
+ // de recomputar desde afuera (requieren el propio contexto de
307
+ // fingerprint del track) y el propio track ya los probó bajo su lock
308
+ // antes de persistir `frozen`.
309
+ const supervisorAlive = ref.supervisorProcessRef !== undefined && (0, process_1.refIsAlive)(ref.supervisorProcessRef);
310
+ const lockExists = fs_1.default.existsSync((0, paths_1.supervisorLockPath)(ref.worktreePath));
311
+ if (supervisorAlive || lockExists) {
312
+ // Paso 6 ("libera el lock y sale") todavía no confirmado desde
313
+ // afuera: esperar al próximo tick (fail-closed, R6.4).
314
+ return { state: s, stop: true, executed: null };
315
+ }
316
+ const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, {
317
+ kind: 'freeze-observation', trackId, frozenHeadSha: trackState.frozen.headSha,
318
+ });
319
+ let next = applyProtocolToState(s, observed);
320
+ // R5.7/R5.8/R5.9/C5 (Step 6): ownership REAL post-hoc, desde commits
321
+ // YA congelados — nunca `git status` para esto (esa es la
322
+ // herramienta de limpieza, no de ownership). `ref.ownership` es el
323
+ // mismo campo que el plan ya lleva por track (hoy `[]` por el gap
324
+ // conocido y deliberadamente diferido de `apply.ts` — parsear el .md
325
+ // del plan sigue sin convención de descubrimiento; una vez que algo
326
+ // lo popule, este consumidor funciona sin cambios).
327
+ const parsedTrack = { trackId, taskIds: [], ownership: ref.ownership, dependsOn: [], sharedResources: [] };
328
+ let actual = { outsideOwnership: [], globalClasses: [] };
329
+ if (s.cohortBaseSha !== undefined) {
330
+ try {
331
+ const base = (0, git_1.mergeBase)(planRoot, s.cohortBaseSha, trackState.frozen.headSha);
332
+ const changes = (0, git_1.changedPaths)(planRoot, base, trackState.frozen.headSha);
333
+ actual = (0, ownership_1.assessActualOwnership)(parsedTrack, changes);
334
+ }
335
+ catch {
336
+ // git indemostrable: no se afirma nada sobre ownership — ni
337
+ // se bloquea el freeze por esto (Step 6 nunca revierte
338
+ // merges ya hechos ni el propio freeze), simplemente no hay
339
+ // evidencia nueva que registrar este tick.
340
+ }
341
+ }
342
+ if (actual.globalClasses.length > 0) {
343
+ const marks = actual.globalClasses.map((cls) => `${trackId}:${cls}`);
344
+ next = {
345
+ ...next,
346
+ cohortParallelInvalidatedBy: [...new Set([...(next.cohortParallelInvalidatedBy ?? []), ...marks])],
347
+ };
348
+ }
349
+ const persisted = persist(planRoot, branch, next);
350
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-frozen-observed', trackId, frozenHeadSha: trackState.frozen.headSha });
351
+ if (actual.outsideOwnership.length > 0) {
352
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-ownership-violation', trackId, paths: actual.outsideOwnership });
353
+ }
354
+ if (actual.globalClasses.length > 0) {
355
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'parallel-invalidated', trackId, classes: actual.globalClasses });
356
+ }
357
+ return { state: persisted, stop: true, executed: effect.kind };
358
+ }
359
+ if (trackState.freezeRequested === true) {
360
+ // Ya se pidió: el propio track todavía está drenando/verificando —
361
+ // nada nuevo que tocar este tick.
362
+ return { state: s, stop: true, executed: null };
363
+ }
364
+ // Todavía no se pidió nada (según el journal del track): antes de tocar
365
+ // `runtime`, un chequeo read-only barato — ¿ya hay una
366
+ // `track-freeze-request` sin consumir en su requestsDir? (misma
367
+ // convención que `ownedWorktreeExists`/`foreignPathExists` en
368
+ // `runCreateWorktree`: gathering nunca cuenta como el side effect).
369
+ // Sin esto, cada tick del plan mientras el track todavía no consumió
370
+ // re-emitiría una request nueva — inofensivo (idempotente en `apply.ts`)
371
+ // pero innecesario.
372
+ let alreadyPending = false;
373
+ try {
374
+ alreadyPending = (0, requests_1.listPendingRequests)(ref.worktreePath, ref.branch).some((p) => !p.corrupt && p.envelope.kind === 'track-freeze-request');
375
+ }
376
+ catch {
377
+ alreadyPending = false; // requestsDir todavía no listable: nada pendiente que se pueda probar
378
+ }
379
+ if (alreadyPending)
380
+ return { state: s, stop: true, executed: null };
381
+ // Emitir la request al journal PROPIO del track — único touch real de
382
+ // este efecto. El `generationToken` viaja con la generación activa QUE
383
+ // EL TRACK TIENE HOY; si no tiene ninguna, un sentinel no-vacío
384
+ // (`isWellFormedEnvelope` exige `generationToken` no-vacío para CUALQUIER
385
+ // request — R1.6, forma antes que contenido) que igual pasa el chequeo de
386
+ // `consumePendingRequests`, porque ese chequeo solo compara contra
387
+ // `activeToken` cuando `activeToken !== null`: sin generación activa,
388
+ // cualquier token (sentinel incluido) se acepta igual. Un desfasaje entre
389
+ // esta lectura y el consumo real produce, a lo sumo, un
390
+ // `rejected-stale-generation` inofensivo — nunca corrompe nada, y el
391
+ // próximo tick reintenta con una lectura fresca.
392
+ const activeGen = trackState.generations.find((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
393
+ runtime.emitFreezeRequest(ref, activeGen?.token ?? 'no-active-generation');
394
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-freeze-requested', trackId });
395
+ return { state: s, stop: true, executed: effect.kind };
396
+ }
397
+ /** R6.2/R6.6-R6.9/C7 (Task 11): gathering READ-ONLY del estado real de un
398
+ * intento de join — jamás cuenta como el side effect del tick (mismo
399
+ * criterio que `ownedWorktreeExists`/`foreignPathExists` en
400
+ * `runCreateWorktree`). `trackIsAncestor` solo se calcula cuando no hay
401
+ * `MERGE_HEAD` (si lo hay, la pregunta "¿ya se mergeó?" no aplica todavía —
402
+ * `decideJoinReconciliation` ni siquiera la consulta en ese caso). */
403
+ function gatherJoinObservation(planRoot, expectedTrackHeadSha) {
404
+ const mergeHead = (0, git_1.readMergeHead)(planRoot);
405
+ const planHead = (0, git_1.headSha)(planRoot);
406
+ const trackIsAncestor = mergeHead === null ? (0, git_1.isAncestor)(planRoot, expectedTrackHeadSha, planHead) : false;
407
+ return { mergeHead, planHead, trackIsAncestor };
408
+ }
409
+ /** R6.2/R6.3/R6.6-R6.9/C7 (Task 11): a lo sumo UN touch REAL de `merge-track`
410
+ * por invocación. Autoridad de DECISIÓN sigue siendo `decideJoinReconciliation`
411
+ * (`protocol.ts`, reexportada por `join.ts`) — este driver la llama dos
412
+ * veces con el MISMO criterio que `runCreateWorktree` llama `decidePrepare`
413
+ * y luego, por separado, `observeProtocolEffect`: una vez sobre la
414
+ * observación `before` para decidir CUÁL efecto git ejecutar (a lo sumo
415
+ * UNO: `mergeFrozenTrack` o `abortOwnedMerge`), y la fase final se persiste
416
+ * siempre a través de `observeProtocolEffect` (que la recalcula sobre la
417
+ * observación real, nunca confía en la excepción de la llamada a
418
+ * `runtime` por sí sola — un `git merge` que tira no prueba ni éxito ni
419
+ * fallo, la única fuente de verdad es releer el repo).
420
+ *
421
+ * Post-review fix (Finding 1, revisión de Task 11): `ensureIntegrationLock`
422
+ * y el intento de merge/abort SON DOS fronteras `reconcileTracks()`
423
+ * distintas, no una — mismo criterio que `runSpawnTrackSupervisor` separa
424
+ * "persistir el supervisorIntent" de "spawnear/observar" (R1.8/C11). En el
425
+ * PRIMER tick de un proceso vivo, `ensureIntegrationLock` hace trabajo real
426
+ * (pausa la generación del plan + escribe `integration.lock`, dos
427
+ * mutaciones genuinas) — devuelve `'acquired'` y este driver corta ACÁ,
428
+ * `stop: true`, sin llegar siquiera a leer `gatherJoinObservation` ni a
429
+ * decidir `retry-merge`/`abort-own-merge`. Recién el PRÓXIMO
430
+ * `reconcileTracks()`, con el lock ya confirmado held (`'already-held'`,
431
+ * no-op memoizado en el mismo proceso), este driver continúa y intenta el
432
+ * merge/abort. Tras un crash real entre ambas fronteras, un proceso nuevo
433
+ * repite el mismo `'acquired'` (reclamando identidad muerta, misma lógica
434
+ * que `acquireIntegrationLock` ya prueba) y vuelve a cortar ahí — nunca
435
+ * colapsa las dos mutaciones en un solo call. La liberación del lock sigue
436
+ * siendo responsabilidad de Task 12 (después del interlock final) — este
437
+ * driver JAMÁS libera lo que adquiere acá. */
438
+ async function runMergeTrack(planRoot, branch, s, protocol, effect, runtime) {
439
+ const trackId = effect.trackId;
440
+ const intent = {
441
+ expectedPlanHeadSha: effect.expectedPlanHeadSha,
442
+ expectedTrackHeadSha: effect.expectedTrackHeadSha,
443
+ strategy: types_1.JOIN_STRATEGY_NO_FF,
444
+ };
445
+ // C7: antes de la PRIMERA mutación real de la rama del plan — pausa la
446
+ // generación del plan y adquiere `integration.lock` (primitivos de Task
447
+ // 10, sin ningún caller hasta acá). Si esta llamada tuvo que hacer
448
+ // trabajo real (`'acquired'`), ESE es el único touch de este tick: se
449
+ // corta acá (ver el comentario grande arriba) — el intento de merge
450
+ // queda para el próximo `reconcileTracks()`, una vez el lock ya esté
451
+ // `'already-held'` (no-op memoizado mientras el mismo proceso siga vivo).
452
+ const lockState = await runtime.ensureIntegrationLock(s.journalId, intent.expectedPlanHeadSha);
453
+ if (lockState === 'acquired') {
454
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-integration-lock-acquired', trackId });
455
+ return { state: s, stop: true, executed: effect.kind };
456
+ }
457
+ const before = gatherJoinObservation(planRoot, intent.expectedTrackHeadSha);
458
+ const decision = (0, protocol_1.decideJoinReconciliation)(intent, before);
459
+ if (decision.action === 'retry-merge') {
460
+ let detail;
461
+ try {
462
+ runtime.mergeFrozenTrack(planRoot, intent);
463
+ }
464
+ catch (error) {
465
+ // Conflicto real o HEAD del plan movido bajo nuestros pies: NUNCA
466
+ // se asume cuál de los dos fue por la excepción sola — la
467
+ // relectura de abajo es la única fuente de verdad (R6.8). El
468
+ // mensaje SÍ se conserva como `detail` diagnóstico en el evento
469
+ // (Finding 2) — nunca para decidir, solo para que un operador
470
+ // mirando el log pueda distinguir un conflicto benigno (que
471
+ // resuelve solo en el próximo retry) de un repo estructuralmente
472
+ // roto (permisos, disco lleno, git roto) que retrearía en
473
+ // silencio para siempre sin esto.
474
+ detail = error.message;
475
+ }
476
+ const after = gatherJoinObservation(planRoot, intent.expectedTrackHeadSha);
477
+ const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'join-observation', trackId, ...after });
478
+ const next = persist(planRoot, branch, applyProtocolToState(s, observed));
479
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-merge-attempted', trackId, detail });
480
+ return { state: next, stop: true, executed: effect.kind };
481
+ }
482
+ if (decision.action === 'abort-own-merge') {
483
+ let detail;
484
+ try {
485
+ runtime.abortOwnedMerge(planRoot, intent);
486
+ }
487
+ catch (error) {
488
+ // Igual criterio que arriba: la relectura decide, nunca la
489
+ // excepción (ej. una carrera real donde el MERGE_HEAD dejó de
490
+ // ser nuestro entre `before` y este intento de abort) — el
491
+ // mensaje se conserva solo como `detail` diagnóstico (Finding 2).
492
+ detail = error.message;
493
+ }
494
+ const after = gatherJoinObservation(planRoot, intent.expectedTrackHeadSha);
495
+ const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'join-observation', trackId, ...after });
496
+ const next = persist(planRoot, branch, applyProtocolToState(s, observed));
497
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-merge-aborted', trackId, detail });
498
+ return { state: next, stop: true, executed: effect.kind };
499
+ }
500
+ // 'accept-merge' / 'block': la observación YA leída (`before`) alcanza —
501
+ // ningún efecto git nuevo que ejecutar este tick (mismo criterio que
502
+ // `runCreateWorktree`'s ramas 'accept-worktree'/'block-foreign').
503
+ const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'join-observation', trackId, ...before });
504
+ const next = persist(planRoot, branch, applyProtocolToState(s, observed));
505
+ (0, store_1.appendEvent)(planRoot, branch, {
506
+ kind: decision.action === 'accept-merge' ? 'track-merged' : 'track-blocked',
507
+ trackId, ...(decision.action === 'block' ? { reason: decision.reason } : {}),
508
+ });
509
+ return { state: next, stop: true, executed: effect.kind };
510
+ }
511
+ function sameIdSet(a, b) {
512
+ const left = [...(a ?? [])].sort();
513
+ const right = [...b].sort();
514
+ return left.length === right.length && left.every((v, i) => v === right[i]);
515
+ }
516
+ /** R7.1/R7.6/R7.7/C3 (Task 12): pura mutación de estado — jamás toca
517
+ * `runtime`. "Solo el HEAD final recibe QA e integración": `nextProtocolEffect`
518
+ * solo emite este efecto cuando TODOS los tracks llegaron a
519
+ * `MERGED_UNVERIFIED` a la vez (R7.6/R7.7) — nunca por cada merge
520
+ * individual. El primer branch acepta el autoreporte del controller
521
+ * (`qaFinalizeRequested`, ver `track-finalize-request` en `apply.ts`) SOLO
522
+ * tras re-verificar independientemente HEAD real + árbol limpio (fail-
523
+ * closed, mismo criterio que el freeze de un track en `runFreezeTrack` —
524
+ * nunca se confía ciegamente en el autoreporte); el segundo persiste el
525
+ * `nextAction` que instruye al controller a correr `post-implementation-qa`
526
+ * — el controller YA vivo (o el próximo que `Supervisor.ensureController`
527
+ * relance por su propio mecanismo existente) lo recoge leyendo el journal,
528
+ * sin que este driver necesite tocar ningún proceso. */
529
+ function runRequestGlobalQa(planRoot, branch, s, protocol, effect) {
530
+ if (s.qaFinalizeRequested !== undefined) {
531
+ const reported = s.qaFinalizeRequested;
532
+ let actualHead;
533
+ let clean;
534
+ try {
535
+ actualHead = (0, git_1.headSha)(planRoot);
536
+ clean = (0, git_1.dirtyPaths)(planRoot).length === 0;
537
+ }
538
+ catch {
539
+ return { state: s, stop: true, executed: null }; // indemostrable: esperar (fail-closed)
540
+ }
541
+ if (actualHead === reported.headSha && clean) {
542
+ const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'global-qa-pass', headSha: actualHead, clean: true });
543
+ const next = persist(planRoot, branch, applyProtocolToState(s, observed));
544
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'global-qa-passed', headSha: actualHead });
545
+ return { state: next, stop: true, executed: effect.kind };
546
+ }
547
+ // El autoreporte no coincide (todavía sucio, o HEAD distinto): nada
548
+ // que aceptar este tick — el controller sigue corrigiendo hallazgos.
549
+ return { state: s, stop: true, executed: null };
550
+ }
551
+ if (s.cycle.nextAction?.type === 'run-global-qa') {
552
+ return { state: s, stop: true, executed: null }; // ya pedido: esperar el autoreporte
553
+ }
554
+ const next = {
555
+ ...s,
556
+ cycle: {
557
+ ...s.cycle,
558
+ nextAction: { actionId: 'finalize-global-qa', type: 'run-global-qa', target: 'cycle', preconditions: [], attempt: 0, state: 'pending' },
559
+ },
560
+ };
561
+ const persisted = persist(planRoot, branch, next);
562
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'global-qa-requested' });
563
+ return { state: persisted, stop: true, executed: effect.kind };
564
+ }
565
+ /** R7.3/R7.6/C4 (Task 12): a lo sumo UNA mutación real por tick — mismo
566
+ * criterio que `runMergeTrack` separa `ensureIntegrationLock` del intento de
567
+ * merge en dos fronteras distintas: acá la pausa del controller
568
+ * (`pauseControllerGeneration`, que a diferencia de `ensureIntegrationLock`
569
+ * se llama de nuevo cada vez porque el controller puede haberse relanzado
570
+ * desde el último merge) y el pedido del job canónico son también DOS
571
+ * fronteras separadas. El conjunto de satisfiers es SIEMPRE el completo y
572
+ * ordenado de `track-integration:*` de la cohorte (C4) — nunca un
573
+ * subconjunto, ni siquiera tras un crash/restart, porque `ids` es una
574
+ * función determinista de `s.tracks` y la idempotencyKey de `requestJob` es
575
+ * una función determinista de ese mismo conjunto. */
576
+ async function runRequestFinalIntegration(planRoot, branch, s, protocol, effect, runtime) {
577
+ const ids = [...new Set((s.tracks ?? []).map((t) => `track-integration:${t.trackId}`))].sort();
578
+ const existing = Object.values(s.jobs).find((j) => sameIdSet(j.satisfies, ids));
579
+ if (existing !== undefined) {
580
+ if (existing.executionState === 'exited' && existing.verdict === 'pass') {
581
+ let headNow;
582
+ try {
583
+ headNow = (0, git_1.headSha)(planRoot);
584
+ }
585
+ catch {
586
+ return { state: s, stop: true, executed: null };
587
+ }
588
+ const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'integration-pass', jobId: existing.id, headSha: headNow });
589
+ const next = persist(planRoot, branch, applyProtocolToState(s, observed));
590
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-integration-passed', jobId: existing.id });
591
+ return { state: next, stop: true, executed: effect.kind };
592
+ }
593
+ // Sigue vivo, o terminó sin pass: nada que tocar este tick (fail-
594
+ // closed — un job canónico fallido requiere intervención humana, este
595
+ // driver jamás reintenta a ciegas con un job distinto).
596
+ return { state: s, stop: true, executed: null };
597
+ }
598
+ const pausedNow = await runtime.pauseControllerGeneration();
599
+ if (pausedNow) {
600
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'plan-generation-paused-for-integration' });
601
+ return { state: s, stop: true, executed: effect.kind };
602
+ }
603
+ if (s.trackIntegration === undefined) {
604
+ // Contrato canónico todavía no registrado (Step 6, `register --entity
605
+ // track-integration`): indemostrable qué comando correr — fail-closed,
606
+ // esperar a que se registre en vez de inventar un comando.
607
+ return { state: s, stop: true, executed: null };
608
+ }
609
+ const gen = s.generations.find((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
610
+ try {
611
+ (0, request_1.requestJob)(planRoot, branch, gen?.token ?? 'no-active-generation', s.trackIntegration.argv, s.trackIntegration.paths, '.', {
612
+ satisfies: ids, verificationKind: 'track-integration',
613
+ });
614
+ }
615
+ catch (error) {
616
+ // Precondición del Step 6 todavía no demostrable (merges pendientes,
617
+ // árbol sucio, argv no canónico): nada que tocar, reintentar el
618
+ // próximo tick con estado fresco.
619
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-integration-request-failed', detail: error.message });
620
+ return { state: s, stop: true, executed: null };
621
+ }
622
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-integration-requested', ids });
623
+ return { state: s, stop: true, executed: effect.kind };
624
+ }
625
+ /** R7.4/R7.5/C3 (Task 12): NO reimplementa el interlock — llama a
626
+ * `computeGate`, el mismo mecanismo de R1/R3.2 que ya exige `qa`+`interlock`
627
+ * en `cycleVerificationPlan` (y ahora también cada `track-integration:*`,
628
+ * poblados por `registerTrackIntegrationItems`) y recomputa la vigencia de
629
+ * CADA fingerprint contra el árbol real — "el interlock global existente"
630
+ * al que se refiere el plan. Solo al pasar libera `integration.lock`
631
+ * (R7.5), el complemento que Task 11 dejó explícitamente diferido. */
632
+ function runRunFinalInterlock(planRoot, branch, s, protocol, effect, runtime) {
633
+ const fingerprintNow = (argv, paths, cwd) => {
634
+ try {
635
+ return (0, fingerprint_1.computeFingerprint)(planRoot, argv, paths, cwd).fingerprint;
636
+ }
637
+ catch {
638
+ return null;
639
+ }
640
+ };
641
+ const gate = (0, gate_1.computeGate)(s, false, fingerprintNow);
642
+ if (!gate.pass)
643
+ return { state: s, stop: true, executed: null }; // fail-closed: esperar evidencia completa
644
+ if (protocol.globalQaHeadSha === undefined)
645
+ throw new Error('invariante rota: globalQaHeadSha ausente en FINAL_INTERLOCK');
646
+ const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'interlock-pass', headSha: protocol.globalQaHeadSha });
647
+ const next = persist(planRoot, branch, applyProtocolToState(s, observed));
648
+ runtime.releaseIntegrationLockIfHeld();
649
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'cohort-complete' });
650
+ return { state: next, stop: true, executed: effect.kind };
651
+ }
652
+ /** cliEntry: mismo patrón que `defaultWrapperSpawner` en runner.ts — desde
653
+ * `dist/src/commands/watch/tracks.js`, `../../` resuelve a `dist/src/index.js`. */
654
+ function defaultCliEntry() {
655
+ return path_1.default.resolve(__dirname, '..', '..', 'index.js');
656
+ }
657
+ function runSpawnTrackSupervisor(planRoot, branch, s, protocol, effect, runtime) {
658
+ const trackId = effect.trackId;
659
+ const ref = refOf(s, trackId);
660
+ if (ref.supervisorIntent === undefined) {
661
+ // R1.8/C11: el intent (nonce + argv + claimPath) se persiste ANTES de
662
+ // spawnear nada — si el supervisor del plan muere justo acá, el
663
+ // próximo tick reintenta el MISMO intent, nunca uno nuevo.
664
+ const supervisorNonce = crypto_1.default.randomBytes(16).toString('hex');
665
+ const intent = {
666
+ nonce: supervisorNonce,
667
+ // R4.7/C11: `--nonce` viaja explícito en el argv — es el MISMO
668
+ // valor que `observeSupervisorFromDisk` va a exigir de vuelta en
669
+ // el identity sidecar del wrapper (BLOCKER post-review: el
670
+ // wrapper NUNCA debe generar el suyo propio, o jamás matchea).
671
+ argv: [process.execPath, defaultCliEntry(), 'track', 'supervisor-wrapper', '--track', ref.trackId,
672
+ '--readiness', ref.readinessNonce, '--fence', ref.fencingToken, '--nonce', supervisorNonce],
673
+ claimPath: path_1.default.join(ref.worktreePath, '.awm', 'supervisor.claim'),
674
+ };
675
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-applied', effect });
676
+ const next = persist(planRoot, branch, withRef(applyProtocolToState(s, applied), trackId, { supervisorIntent: intent }));
677
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-supervisor-intent', trackId });
678
+ // Post-review fix: persistir el supervisorIntent es en sí mismo UNA
679
+ // frontera durable (R1.8/C11) — con `stop:false` el MISMO call podía
680
+ // seguir de largo y llamar a `runtime.observeSupervisor`/
681
+ // `spawnSupervisor` en la misma invocación, colapsando dos
682
+ // boundaries ("intent persistido" y "supervisor consultado/spawneado")
683
+ // que Task 9 necesita poder crashear por separado.
684
+ return { state: next, stop: true, executed: null };
685
+ }
686
+ // Task 9 (R4.2/C11, reemplaza el gate anterior basado ÚNICAMENTE en
687
+ // `ref.supervisorProcessRef === undefined`): SIEMPRE se observa el mundo
688
+ // real primero — read-only, jamás cuenta como el side effect del tick,
689
+ // mismo criterio que `foreignPathExists`/`ownedWorktreeExists` en
690
+ // `runCreateWorktree` — y es `decidePrepare` (protocol.ts) quien decide
691
+ // qué hacer con lo observado. Esto es una optimización real, no la
692
+ // garantía de correctitud: en el caso común (crash después de que el
693
+ // wrapper ya escribió claim/identity/ready en disco) evita un
694
+ // `spawnSupervisor` redundante. Pero sigue existiendo una ventana
695
+ // angosta entre que `runtime.spawnSupervisor` forkea el proceso real y
696
+ // que ESE proceso escribe su claim (`supervisor-wrapper.ts`, `fs.openSync`
697
+ // con `wx`) — un crash justo ahí deja `observeSupervisor` en `'absent'`
698
+ // y `decidePrepare` vuelve a pedir `retry-supervisor-same-intent`, o sea
699
+ // un segundo fork real. Lo que impide que eso deje dos supervisores
700
+ // vivos es el propio claim atómico `wx` del wrapper (mismo patrón que
701
+ // `job/exec-wrapper.ts`/`reconcile.ts`): el wrapper perdedor recibe
702
+ // `EEXIST` en su propio intento de claim y sale como "already-claimed"
703
+ // antes de escribir identity/ready o lanzar `awm watch` — inofensivo,
704
+ // no prevenido acá.
705
+ const observation = runtime.observeSupervisor(ref);
706
+ const decision = (0, protocol_1.decidePrepare)(protocol.tracks[trackId], { supervisorArtifact: observation.kind });
707
+ if (decision === 'block-foreign') {
708
+ const blocked = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'supervisor-observed', trackId, identity: 'other' });
709
+ const next = persist(planRoot, branch, applyProtocolToState(s, blocked));
710
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-blocked', trackId, reason: 'identidad de supervisor ajena' });
711
+ return { state: next, stop: true, executed: null };
712
+ }
713
+ if (decision === 'retry-supervisor-same-intent') {
714
+ if (ref.supervisorProcessRef === undefined) {
715
+ // Observación confirma 'absent': recién ACÁ se toca `runtime` de
716
+ // verdad. Leer (no-mutante) y, si de veras no hay nada, spawnear
717
+ // (mutante) en el MISMO call sigue siendo UNA sola frontera
718
+ // mutante por invocación — igual patrón que
719
+ // `foreignPathExists` -> `addWorktree` en `runCreateWorktree`.
720
+ const pr = runtime.spawnSupervisor(ref);
721
+ const next = pr !== undefined ? persist(planRoot, branch, withRef(s, trackId, { supervisorProcessRef: pr })) : s;
722
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect', trackId, effect: effect.kind });
723
+ return { state: next, stop: true, executed: effect.kind };
724
+ }
725
+ // `supervisorProcessRef` ya persistido (spawn de un tick anterior)
726
+ // pero el wrapper todavía no escribió ni su claim: ventana de
727
+ // arranque normal, nada que hacer todavía.
728
+ return { state: s, stop: true, executed: null };
729
+ }
730
+ // 'accept-readiness': ya hay evidencia real en disco de un intento
731
+ // previo (posiblemente de una instancia que crasheó antes de persistir
732
+ // `supervisorProcessRef`) — NUNCA volver a llamar `spawnSupervisor`
733
+ // (C11). C8 decide acá mismo si el nonce observado corresponde.
734
+ if (observation.kind === 'claimed') {
735
+ // Claim tomado pero identidad/readiness todavía no observables:
736
+ // esperar al próximo tick, nada que persistir.
737
+ return { state: s, stop: true, executed: null };
738
+ }
739
+ if (observation.kind === 'ready') {
740
+ const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, {
741
+ kind: 'supervisor-observed', trackId, identity: 'expected', readinessNonce: observation.readinessNonce,
742
+ });
743
+ const next = persist(planRoot, branch, applyProtocolToState(s, observed));
744
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-armed-or-blocked', trackId });
745
+ return { state: next, stop: true, executed: null };
746
+ }
747
+ // Defensivo: `decidePrepare`/`observeSupervisor` ya cubrieron
748
+ // exhaustivamente absent/foreign/claimed/ready arriba (fail-closed).
749
+ throw new Error(`invariante rota: observación de supervisor no manejada para ${trackId}`);
750
+ }
751
+ // Task 13 (R4.2/R4.3/R4.6/R4.10/C2/C9): el gathering READ-ONLY
752
+ // (`gatherTeardownObservation`) y el driver (`runBeginTeardown`) de UN track
753
+ // en teardown viven en `./teardown-driver` — extraídos post-review (mismo
754
+ // criterio que `join.ts` con `decideJoinReconciliation`/
755
+ // `acquireIntegrationLock`: la lógica del concern vive en su propio módulo,
756
+ // este archivo solo orquesta y wirea producción). `decideTeardown` sigue
757
+ // siendo la única autoridad de decisión, en `protocol.ts`, sin tocar.
758
+ async function executeRuntimeEffect(planRoot, branch, s, protocol, effect, runtime) {
759
+ if (effect.kind === 'create-worktree')
760
+ return runCreateWorktree(planRoot, branch, s, protocol, effect, runtime);
761
+ if (effect.kind === 'create-track-journal')
762
+ return runCreateTrackJournal(planRoot, branch, s, protocol, effect, runtime);
763
+ if (effect.kind === 'begin-teardown')
764
+ return (0, teardown_driver_1.runBeginTeardown)(planRoot, branch, s, protocol, effect, runtime);
765
+ if (effect.kind === 'freeze-track')
766
+ return runFreezeTrack(planRoot, branch, s, protocol, effect, runtime);
767
+ if (effect.kind === 'merge-track')
768
+ return runMergeTrack(planRoot, branch, s, protocol, effect, runtime);
769
+ if (effect.kind === 'request-global-qa')
770
+ return runRequestGlobalQa(planRoot, branch, s, protocol, effect);
771
+ if (effect.kind === 'request-final-integration')
772
+ return runRequestFinalIntegration(planRoot, branch, s, protocol, effect, runtime);
773
+ if (effect.kind === 'run-final-interlock')
774
+ return runRunFinalInterlock(planRoot, branch, s, protocol, effect, runtime);
775
+ return runSpawnTrackSupervisor(planRoot, branch, s, protocol, effect, runtime);
776
+ }
777
+ /**
778
+ * Driver de P1/P2: construye la vista pura desde el journal, pide la
779
+ * siguiente decisión a `protocol.ts`, y la ejecuta. SOLO las transiciones
780
+ * puramente en memoria y sin consecuencia observable fuera del journal
781
+ * (`persist-prepare-intent`, `activate-cohort`, `activate-track`) se drenan
782
+ * dentro del mismo call —persistiendo cada una antes de seguir—, porque no
783
+ * cruzan ninguna frontera con el mundo real ni bloquean nada.
784
+ *
785
+ * TODO lo demás cuenta como UNA frontera y detiene el call ahí mismo, aunque
786
+ * en sí mismo no haya llamado a `runtime` todavía: persistir un
787
+ * `supervisorIntent` (R1.8 — el intent debe quedar durable ANTES de que
788
+ * cualquier llamada futura a `runtime.spawnSupervisor` pueda ocurrir, en un
789
+ * tick DISTINTO), bloquear un track por ajeno (R4.6), y por supuesto
790
+ * cualquier consulta real a `runtime`
791
+ * (`addWorktree`/`initTrackJournal`/`spawnSupervisor`/`observeSupervisor`).
792
+ * Un `reconcileTracks()` jamás cruza dos de estas fronteras en el mismo call
793
+ * — ni dos tracks distintos, ni dos pasos del mismo track — precisamente
794
+ * para que Task 9 pueda inyectar un crash exactamente después de cualquiera
795
+ * de ellas y probar que el restart converge (post-review fix: la versión
796
+ * original dejaba `stop:false` en el bloqueo por ajeno y en el persist del
797
+ * `supervisorIntent`, permitiendo que un solo call colapsara dos fronteras).
798
+ * Esto es también lo que mantiene visible, entre dos ticks, el estado
799
+ * "todos ARMED pero la cohorte todavía no activó" (C1).
800
+ *
801
+ * `async` desde Task 9 (Step 5): el paso `supervisor` de `begin-teardown`
802
+ * termina el grupo del supervisor con `terminatePreviouslyOwnedGroup`
803
+ * (identity-verified, R4.8), que espera una escalera de gracia real
804
+ * (SIGTERM -> confirmar -> SIGKILL -> confirmar) — jamás un `kill(pid)` sin
805
+ * confirmación. Todo caller sigue siendo dueño de awaitear el resultado
806
+ * (ver `Supervisor.tick`); ningún test que llame a `reconcileTracks`
807
+ * directamente puede seguir tratándolo como síncrono.
808
+ */
809
+ async function reconcileTracks(planRoot, branch, state, runtime, maxParallel) {
810
+ let s = state;
811
+ for (;;) {
812
+ if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
813
+ return { state: s, effectExecuted: null };
814
+ }
815
+ const protocol = toProtocol(s, maxParallel);
816
+ const effect = (0, protocol_1.nextProtocolEffect)(protocol);
817
+ if (effect === null)
818
+ return { state: s, effectExecuted: null };
819
+ if (!RUNTIME_EFFECTS.has(effect.kind)) {
820
+ const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-applied', effect });
821
+ const candidate = applyProtocolToState(s, applied);
822
+ // Defensivo: un ProtocolEffect sin fase mapeada en `EFFECT_APPLIED_PHASE`
823
+ // (ej. `merge-track`/`request-global-qa`, todavía sin driver propio en
824
+ // esta task — llegan en Tasks 11/12) no debe hacer que este loop gire
825
+ // para siempre repitiendo el mismo efecto sin progreso observable.
826
+ if (candidate.cohortPhase === s.cohortPhase && JSON.stringify(candidate.tracks) === JSON.stringify(s.tracks)) {
827
+ return { state: s, effectExecuted: null };
828
+ }
829
+ s = persist(planRoot, branch, candidate);
830
+ const trackId = 'trackId' in effect ? effect.trackId : undefined;
831
+ const label = trackId !== undefined ? (s.tracks?.find((t) => t.trackId === trackId)?.phase ?? effect.kind) : s.cohortPhase;
832
+ // `enter-serial` es el ÚNICO efecto de este branch que lleva una causa: la
833
+ // degradación se lee entera en su propio evento, sin obligar a correlacionar
834
+ // hacia atrás con el `track-effect-failed` que la originó.
835
+ const reason = effect.kind === 'enter-serial' ? effect.reason : undefined;
836
+ (0, store_1.appendEvent)(planRoot, branch, { kind: 'track-protocol-persist', effect: effect.kind, trackId, phase: label, reason });
837
+ continue;
838
+ }
839
+ const result = await executeRuntimeEffect(planRoot, branch, s, protocol, effect, runtime);
840
+ s = result.state;
841
+ if (result.stop)
842
+ return { state: s, effectExecuted: result.executed };
843
+ }
844
+ }
845
+ /**
846
+ * R6.2/R6.8/C7 (Task 11): reconcilia un `MERGE_HEAD` REAL dejado abierto por
847
+ * un crash a mitad de un `merge-track` — se llama desde `Supervisor.tick()`
848
+ * ANTES de `verifyBranchInvariant` (y de cualquier guard general futuro que
849
+ * pudiera rechazar el repo por estar "a mitad de un merge"): hoy ningún
850
+ * guard existente rechaza por `MERGE_HEAD` (ni `verifyBranchInvariant` — solo
851
+ * compara nombre de rama — ni ningún otro), pero esta función corre primero
852
+ * de todos modos para que uno agregado en el futuro nunca pueda rechazar un
853
+ * estado que ya es reconciliable.
854
+ *
855
+ * Deliberadamente NO reimplementa la decisión: si el `MERGE_HEAD` observado
856
+ * coincide con el `joinIntent.expectedTrackHeadSha` de un track propio en
857
+ * `JOIN_INTENT`, delega en el mismísimo `reconcileTracks` (que, sobre ese
858
+ * estado, computa `merge-track` como su próximo efecto y lo ejecuta vía
859
+ * `runMergeTrack` — CERO lógica duplicada). Si el `MERGE_HEAD` no es
860
+ * atribuible a ningún intent propio (ajeno o indemostrable), no toca nada —
861
+ * fail-closed, deja que el guard/operador que corresponda decida.
862
+ *
863
+ * Devuelve `handled: true` cuando efectivamente ejecutó `reconcileTracks` —
864
+ * el caller (`Supervisor.tick`) usa esto para NO volver a invocarlo más
865
+ * tarde en el mismo tick (a lo sumo UNA mutación real de tracks por tick,
866
+ * mismo invariante que ya sostiene `reconcileTracks` por sí solo).
867
+ */
868
+ async function reconcileOpenJoin(planRoot, branch, state, runtime, maxParallel) {
869
+ if (state.tracks === undefined || state.tracks.length < 2 || state.cohortPhase === undefined) {
870
+ return { handled: false, state };
871
+ }
872
+ let mergeHead;
873
+ try {
874
+ mergeHead = (0, git_1.readMergeHead)(planRoot);
875
+ }
876
+ catch {
877
+ // Indemostrable acá: se deja para el `reconcileTracks` normal de este
878
+ // mismo tick (o el próximo) — esta función es un adelanto oportunista,
879
+ // nunca la única vía de reconciliación.
880
+ return { handled: false, state };
881
+ }
882
+ if (mergeHead === null)
883
+ return { handled: false, state };
884
+ const ownsIt = state.tracks.some((t) => t.phase === 'JOIN_INTENT' && t.joinIntent?.expectedTrackHeadSha === mergeHead);
885
+ if (!ownsIt)
886
+ return { handled: false, state }; // MERGE_HEAD ajeno o sin intent propio que lo explique (fail-closed)
887
+ const result = await reconcileTracks(planRoot, branch, state, runtime, maxParallel);
888
+ return { handled: true, state: result.state };
889
+ }
890
+ // --- Implementación de producción (wiring real de watch/supervisor.ts) -----
891
+ function planDescriptorContext(planRoot, planBranch) {
892
+ return { planRoot: fs_1.default.realpathSync(planRoot), planBranch };
893
+ }
894
+ /** Lee los sidecars que `track supervisor-wrapper` (Step 5) escribe en el
895
+ * propio worktree del track: claim -> identity -> ready, en ese orden — el
896
+ * mismo contrato de `exec-wrapper.ts` (claim/identity/result), adaptado al
897
+ * supervisor de un track en vez de a un job. */
898
+ function observeSupervisorFromDisk(ref) {
899
+ if (ref.supervisorIntent === undefined)
900
+ return { kind: 'absent' };
901
+ const claimPath = ref.supervisorIntent.claimPath;
902
+ const dir = path_1.default.dirname(claimPath);
903
+ if (!fs_1.default.existsSync(claimPath))
904
+ return { kind: 'absent' };
905
+ const identityPath = path_1.default.join(dir, 'supervisor.identity.json');
906
+ if (!fs_1.default.existsSync(identityPath))
907
+ return { kind: 'claimed' };
908
+ let identity;
909
+ try {
910
+ identity = JSON.parse(fs_1.default.readFileSync(identityPath, 'utf8'));
911
+ }
912
+ catch {
913
+ return { kind: 'foreign' };
914
+ }
915
+ if (identity.nonce !== ref.supervisorIntent.nonce)
916
+ return { kind: 'foreign' };
917
+ const readyPath = path_1.default.join(dir, 'supervisor.ready.json');
918
+ if (!fs_1.default.existsSync(readyPath))
919
+ return { kind: 'claimed' };
920
+ let ready;
921
+ try {
922
+ ready = JSON.parse(fs_1.default.readFileSync(readyPath, 'utf8'));
923
+ }
924
+ catch {
925
+ return { kind: 'claimed' };
926
+ }
927
+ if (typeof ready.readinessNonce !== 'string')
928
+ return { kind: 'claimed' };
929
+ return { kind: 'ready', readinessNonce: ready.readinessNonce };
930
+ }
931
+ /** Grace por defecto si el caller no provee una explícita (ej. tests viejos
932
+ * de `defaultTrackRuntime` que no ejercitan teardown): mismo orden de
933
+ * magnitud que `DEFAULT_SUPERVISOR_CONFIG` en `supervisor.ts`, nunca cero
934
+ * (una escalera SIGTERM->SIGKILL sin ventana de gracia no es una escalera). */
935
+ const DEFAULT_TEARDOWN_GRACE = { termGraceMs: 30000, killGraceMs: 5000 };
936
+ /** Implementación real de `TrackRuntime`, inyectada por `Supervisor` en
937
+ * producción. Los tests de `reconcileTracks` usan un fake — ningún proceso
938
+ * ni worktree real se toca fuera de esta función. */
939
+ function defaultTrackRuntime(planRoot, planBranch, grace = DEFAULT_TEARDOWN_GRACE) {
940
+ // C7 (Task 11): memoizado por CLOSURE — vive tanto como este runtime
941
+ // (creado una vez por `Supervisor`, ver su constructor). Un proceso
942
+ // nuevo tras un crash real construye un `defaultTrackRuntime` fresco
943
+ // (`integrationLock === null` de nuevo) y `acquireIntegrationLock`
944
+ // reclama con su propia lógica de identidad muerta probada — nunca acá.
945
+ let integrationLock = null;
946
+ return {
947
+ addWorktree(root, ref, baseSha) {
948
+ // CRITICAL post-review fix: `git check-ignore` es una operación de
949
+ // working-tree — solo puede responder por lo que está REALMENTE
950
+ // checkeado en algún lado. El repo del plan vive en su HEAD vivo,
951
+ // que puede ser un commit distinto (posterior) de `baseSha`; si un
952
+ // `.gitignore` para `.awm` se agregó (o quitó) entre `baseSha` y
953
+ // HEAD, chequear contra `root` responde por un árbol que el
954
+ // worktree que estamos por crear NUNCA va a tener. La única
955
+ // verificación honesta es contra el worktree YA CREADO, checkeado
956
+ // en `baseSha` de verdad — así que primero se crea, y recién
957
+ // después se verifica. Si falla, el worktree es NUESTRO (lo
958
+ // acabamos de crear en esta misma llamada) y se descarta —
959
+ // jamás queda vivo e inseguro (C2 fail-closed).
960
+ (0, git_1.addOwnedWorktree)(root, ref, baseSha);
961
+ if (!(0, git_1.isAwmGitignored)(ref.worktreePath)) {
962
+ (0, git_1.removeOwnedWorktree)(root, ref.worktreePath);
963
+ throw new Error('`.awm` no está gitignoreado en el worktree recién creado (checkeado en baseSha): se descarta (degradación C2)');
964
+ }
965
+ },
966
+ initTrackJournal(ref, context) {
967
+ (0, store_1.initJournal)(ref.worktreePath, ref.branch);
968
+ const r = (0, store_1.readJournal)(ref.worktreePath, ref.branch);
969
+ if (r.corrupt || r.state === null)
970
+ throw new Error('journal de track corrupto tras init');
971
+ const s = r.state;
972
+ if (s.trackContext !== undefined && s.trackContext.trackId !== context.trackId) {
973
+ // R4.6: un trackContext ajeno preexistente jamás se sobreescribe.
974
+ throw new Error(`trackContext preexistente pertenece a otro track: ${s.trackContext.trackId}`);
975
+ }
976
+ if (s.trackContext === undefined) {
977
+ s.trackContext = context;
978
+ (0, store_1.writeJournal)(ref.worktreePath, ref.branch, s);
979
+ }
980
+ const plan = planDescriptorContext(planRoot, planBranch);
981
+ (0, descriptor_1.writeDescriptor)(ref.worktreePath, {
982
+ schema: 1, planRoot: plan.planRoot, planBranch: plan.planBranch,
983
+ trackId: ref.trackId, planJournalId: context.planJournalId, fencingToken: ref.fencingToken,
984
+ });
985
+ },
986
+ spawnSupervisor(ref) {
987
+ if (ref.supervisorIntent === undefined)
988
+ return undefined;
989
+ const { child, ref: pref } = (0, process_2.spawnStructured)(ref.supervisorIntent.argv, ref.worktreePath, ref.supervisorIntent.nonce);
990
+ child.unref(); // detached: el supervisor del plan jamás espera al del track (R4.7)
991
+ return pref;
992
+ },
993
+ observeSupervisor: observeSupervisorFromDisk,
994
+ // R5.2/R6.3 (Task 10): `emitRequest` ya es el primitivo durable
995
+ // genérico de `core/journal/requests.ts` — cross-worktree acá solo
996
+ // significa "el primer argumento no es `planRoot`, es el worktree
997
+ // del TRACK" (`requestsDir` no distingue de quién es el journal).
998
+ emitFreezeRequest(ref, generationToken) {
999
+ (0, requests_1.emitRequest)(ref.worktreePath, ref.branch, {
1000
+ kind: 'track-freeze-request',
1001
+ generationToken,
1002
+ idempotencyKey: `freeze-${ref.trackId}-${ref.fencingToken}`,
1003
+ payload: { trackId: ref.trackId, fencingToken: ref.fencingToken },
1004
+ });
1005
+ },
1006
+ // Task 13 (reemplaza el `teardownOwned(ref, step)` TEMPORAL de Task
1007
+ // 9): cada primitivo es idempotente ante reintento tras crash —
1008
+ // "nada que remover"/"ya ausente" nunca lanza, es éxito vacuo (R4.2).
1009
+ async stopOwnSupervisor(ref) {
1010
+ if (ref.supervisorProcessRef === undefined)
1011
+ return true; // nunca llegamos a spawnear: nada que terminar
1012
+ // R4.8: identity-verified — jamás un `kill(pid)` crudo. Un `pgid`
1013
+ // reutilizado por OTRO proceso nunca se confunde con el nuestro:
1014
+ // post-review (hallazgo de revisión de Task 13), la identidad NO
1015
+ // se da por buena solo por haberse capturado en el spawn —
1016
+ // `supervisorProcessRef` se relee del journal persistido y este
1017
+ // paso puede correr arbitrariamente tarde (incluso tras un
1018
+ // restart completo), así que `terminatePreviouslyOwnedGroup`
1019
+ // reverifica la identidad completa del slot de líder
1020
+ // INMEDIATAMENTE antes de enviar cualquier señal (`process.ts`,
1021
+ // `groupLeaderReused`) — nunca confía solo en `groupIsGone`.
1022
+ const confirmed = (0, process_2.groupIsGone)(ref.supervisorProcessRef.processGroup)
1023
+ || await (0, process_2.terminatePreviouslyOwnedGroup)(ref.supervisorProcessRef, grace);
1024
+ if (confirmed) {
1025
+ // Step 4 del plan ("supervisor propio muerto confirmado Y
1026
+ // lock ausente"): un lock advisory que el supervisor dejó sin
1027
+ // liberar (crash a mitad de su propia salida) es, por
1028
+ // definición, stale una vez que TODO su grupo está
1029
+ // confirmado ausente por identidad — este path del lock vive
1030
+ // dentro del worktree PROPIO del track (`ref.worktreePath`),
1031
+ // nunca en un recurso compartido, así que reclamarlo acá
1032
+ // nunca puede pisar algo ajeno. Sin esto, un crash exacto
1033
+ // ahí dejaría el teardown reintentando `stop-own-supervisor`
1034
+ // para siempre (C9: debe converger).
1035
+ try {
1036
+ fs_1.default.rmSync((0, paths_1.supervisorLockPath)(ref.worktreePath), { force: true });
1037
+ }
1038
+ catch { /* best-effort */ }
1039
+ }
1040
+ return confirmed;
1041
+ },
1042
+ removeOwnedWorktree(repo, ref) {
1043
+ // Ownership ya probado por el driver (`worktreeOwnershipProven`,
1044
+ // ver `gatherTeardownObservation`) antes de que `decideTeardown`
1045
+ // llegara a `remove-owned-worktree` — acá solo el efecto real;
1046
+ // `git.ts` bloquea (nombrando paths) si el worktree está sucio, y
1047
+ // nunca usa `--force` (R4.10).
1048
+ (0, git_1.removeOwnedWorktree)(repo, ref.worktreePath);
1049
+ },
1050
+ removeOwnedBranch(repo, branchName) {
1051
+ // `git.ts` ya verifica que no siga checked out y usa `-d`, nunca
1052
+ // `-D` (R4.10) — no-op si la branch ya no existe.
1053
+ (0, git_1.removeOwnedBranch)(repo, branchName);
1054
+ },
1055
+ // R6.2/R6.3/C7 (Task 11): delega 1:1 en `core/tracks/git.ts` — la
1056
+ // decisión de CUÁL llamar (o si bloquear en su lugar) es siempre de
1057
+ // `decideJoinReconciliation`/`runMergeTrack`, nunca de acá.
1058
+ mergeFrozenTrack(repo, intent) {
1059
+ (0, git_1.mergeFrozenTrack)(repo, intent);
1060
+ },
1061
+ abortOwnedMerge(repo, intent) {
1062
+ (0, git_1.abortOwnedMerge)(repo, intent);
1063
+ },
1064
+ async ensureIntegrationLock(planJournalId, expectedPlanHeadSha) {
1065
+ if (integrationLock !== null)
1066
+ return 'already-held'; // ya adquirido por este proceso: no-op
1067
+ await (0, join_1.stopControllerGenerationConfirmed)(planRoot, planBranch, grace);
1068
+ integrationLock = (0, join_1.acquireIntegrationLock)(planRoot, { planJournalId, expectedPlanHeadSha });
1069
+ return 'acquired';
1070
+ },
1071
+ // R7/C3 (Task 12): a diferencia de `ensureIntegrationLock` (memoizado
1072
+ // por el resto de la vida del proceso), esto se llama de nuevo cada
1073
+ // vez que el driver de `request-final-integration` lo pide — el
1074
+ // controller puede haberse relanzado desde el último merge (ej. para
1075
+ // correr `post-implementation-qa`). `stopControllerGenerationConfirmed`
1076
+ // ya es un no-op seguro sin generación activa; `hadActive` es lo que
1077
+ // le permite al driver distinguir "hizo trabajo real" de "ya estaba
1078
+ // pausado", mismo criterio que `'acquired'`/`'already-held'` arriba.
1079
+ async pauseControllerGeneration() {
1080
+ const r = (0, store_1.readJournal)(planRoot, planBranch);
1081
+ if (r.corrupt || r.state === null)
1082
+ throw new Error('journal corrupto: no se puede pausar el controller del plan (R1.6)');
1083
+ const hadActive = r.state.generations.some((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
1084
+ if (!hadActive)
1085
+ return false;
1086
+ await (0, join_1.stopControllerGenerationConfirmed)(planRoot, planBranch, grace);
1087
+ return true;
1088
+ },
1089
+ // R7.5 (Task 12): complemento de `ensureIntegrationLock` que Task 11
1090
+ // dejó diferido — solo libera un lock que ESTE proceso adquirió
1091
+ // (identidad verificada por `releaseIntegrationLock`, que compara
1092
+ // `spawnNonce`), nunca uno ajeno.
1093
+ releaseIntegrationLockIfHeld() {
1094
+ if (integrationLock !== null) {
1095
+ (0, join_1.releaseIntegrationLock)(integrationLock);
1096
+ integrationLock = null;
1097
+ }
1098
+ },
1099
+ };
1100
+ }