agentic-workflow-manager 6.2.0 → 6.3.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 (75) 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/diagnostics/provider-checks.js +12 -9
  16. package/dist/src/core/journal/adapter.js +46 -6
  17. package/dist/src/core/journal/paths.js +9 -0
  18. package/dist/src/core/journal/process.js +101 -1
  19. package/dist/src/core/journal/requests.js +5 -1
  20. package/dist/src/core/journal/store.js +34 -2
  21. package/dist/src/core/journal/types.js +93 -4
  22. package/dist/src/core/paths.js +51 -0
  23. package/dist/src/core/profile.js +15 -1
  24. package/dist/src/core/tracks/concurrency.js +85 -0
  25. package/dist/src/core/tracks/context.js +89 -0
  26. package/dist/src/core/tracks/descriptor.js +40 -0
  27. package/dist/src/core/tracks/git.js +318 -0
  28. package/dist/src/core/tracks/join.js +185 -0
  29. package/dist/src/core/tracks/ownership.js +100 -0
  30. package/dist/src/core/tracks/plan-parser.js +108 -0
  31. package/dist/src/core/tracks/protocol.js +466 -0
  32. package/dist/src/core/tracks/teardown.js +34 -0
  33. package/dist/src/core/tracks/types.js +14 -0
  34. package/dist/src/core/update-check.js +5 -1
  35. package/dist/src/index.js +2 -0
  36. package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
  37. package/dist/tests/commands/track/fixtures.js +13 -0
  38. package/dist/tests/commands/track/status.test.js +157 -0
  39. package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
  40. package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
  41. package/dist/tests/commands/track/verbs.test.js +326 -0
  42. package/dist/tests/commands/watch/apply.test.js +116 -4
  43. package/dist/tests/commands/watch/runner.test.js +22 -0
  44. package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
  45. package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
  46. package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
  47. package/dist/tests/commands/watch/track-finalize.test.js +643 -0
  48. package/dist/tests/commands/watch/track-freeze.test.js +591 -0
  49. package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
  50. package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
  51. package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
  52. package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
  53. package/dist/tests/core/journal/adapter-override.test.js +51 -0
  54. package/dist/tests/core/journal/process.test.js +37 -0
  55. package/dist/tests/core/journal/requests.test.js +21 -0
  56. package/dist/tests/core/journal/store.test.js +28 -0
  57. package/dist/tests/core/journal/types.test.js +89 -0
  58. package/dist/tests/core/profile.test.js +9 -2
  59. package/dist/tests/core/same-existing-path.test.js +48 -0
  60. package/dist/tests/core/tracks/concurrency.test.js +134 -0
  61. package/dist/tests/core/tracks/context.test.js +177 -0
  62. package/dist/tests/core/tracks/descriptor.test.js +76 -0
  63. package/dist/tests/core/tracks/git.test.js +132 -0
  64. package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
  65. package/dist/tests/core/tracks/join.test.js +197 -0
  66. package/dist/tests/core/tracks/ownership.test.js +96 -0
  67. package/dist/tests/core/tracks/plan-parser.test.js +94 -0
  68. package/dist/tests/core/tracks/protocol.test.js +416 -0
  69. package/dist/tests/core/tracks/teardown.test.js +62 -0
  70. package/dist/tests/core/update-check.test.js +34 -3
  71. package/dist/tests/helpers/git-fixture.js +35 -0
  72. package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
  73. package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
  74. package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
  75. package/package.json +1 -1
@@ -0,0 +1,643 @@
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
+ // Task 12 (R3.6, R7.1-R7.7, R8.1/R8.2, C3/C4): QA final, integración canónica
7
+ // única e interlock. Tres frentes:
8
+ // A. `canCompleteCohort` — pura, sin I/O (R8.1).
9
+ // A2. `runRequestFinalIntegration` — fronteras `reconcileTracks()`
10
+ // aisladas con un `TrackRuntime` fake (mismo criterio que Task 11 en
11
+ // `track-join-crash.test.ts`, describe "adquisición del lock y merge
12
+ // son fronteras separadas"): prueba DIRECTAMENTE que pausar la
13
+ // generación del controller y pedir el job canónico son dos llamadas
14
+ // distintas, sin depender de un loop de convergencia end-to-end.
15
+ // B. El finalizer end-to-end: `Supervisor.tick()` real + `reconcileTracks`
16
+ // real, sobre un repo git real y un journal real — mismo criterio que
17
+ // `track-freeze.test.ts`/`track-runtime-git.test.ts`: la única forma
18
+ // honesta de probar C3/C4 (un único job canónico, con el conjunto
19
+ // COMPLETO de satisfiers, superviviente a un crash/restart) es contra
20
+ // journal y git reales, nunca un mock del protocolo.
21
+ const fs_1 = __importDefault(require("fs"));
22
+ const path_1 = __importDefault(require("path"));
23
+ const os_1 = __importDefault(require("os"));
24
+ const child_process_1 = require("child_process");
25
+ const supervisor_1 = require("../../../src/commands/watch/supervisor");
26
+ const tracks_1 = require("../../../src/commands/watch/tracks");
27
+ const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
28
+ const init_1 = require("../../../src/commands/watch/init");
29
+ const requests_1 = require("../../../src/core/journal/requests");
30
+ const apply_1 = require("../../../src/commands/watch/apply");
31
+ const generations_1 = require("../../../src/commands/watch/generations");
32
+ const store_1 = require("../../../src/core/journal/store");
33
+ const gate_1 = require("../../../src/commands/job/gate");
34
+ const fingerprint_1 = require("../../../src/core/journal/fingerprint");
35
+ const paths_1 = require("../../../src/core/journal/paths");
36
+ const types_1 = require("../../../src/core/journal/types");
37
+ const git_fixture_1 = require("../../helpers/git-fixture");
38
+ function git(cwd, ...args) {
39
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'pipe' });
40
+ }
41
+ // --- Parte A: canCompleteCohort (R8.1), pura ------------------------------
42
+ function trackRef(trackId, phase) {
43
+ return {
44
+ trackId, worktreePath: `/tmp/${trackId}`, branch: `awm-track/${trackId}`,
45
+ ownership: [], sharedResources: [], dependsOn: [],
46
+ fencingToken: `f-${trackId}`.padEnd(32, '0'), phase, readinessNonce: `r-${trackId}`.padEnd(32, '0'),
47
+ };
48
+ }
49
+ function stateWithPhases(phases) {
50
+ const s = (0, types_1.emptyState)('main');
51
+ s.tracks = Object.entries(phases).map(([id, phase]) => trackRef(id, phase));
52
+ return s;
53
+ }
54
+ describe('canCompleteCohort (R8.1, Task 12)', () => {
55
+ test('un track pendiente mantiene IN_PROGRESS y lo nombra', () => {
56
+ expect((0, tracks_1.canCompleteCohort)(stateWithPhases({ a: 'MERGED_UNVERIFIED', b: 'FROZEN' })))
57
+ .toEqual({ complete: false, pendingTracks: ['b'] });
58
+ });
59
+ test('todos MERGED_UNVERIFIED: completo, sin pendientes', () => {
60
+ expect((0, tracks_1.canCompleteCohort)(stateWithPhases({ a: 'MERGED_UNVERIFIED', b: 'MERGED_UNVERIFIED' })))
61
+ .toEqual({ complete: true, pendingTracks: [] });
62
+ });
63
+ test('un track JOINED (más allá de MERGED_UNVERIFIED) no cuenta como pendiente', () => {
64
+ expect((0, tracks_1.canCompleteCohort)(stateWithPhases({ a: 'JOINED', b: 'FROZEN' })))
65
+ .toEqual({ complete: false, pendingTracks: ['b'] });
66
+ });
67
+ test('menos de dos tracks nunca se considera completo', () => {
68
+ expect((0, tracks_1.canCompleteCohort)(stateWithPhases({ a: 'MERGED_UNVERIFIED' })))
69
+ .toEqual({ complete: false, pendingTracks: [] });
70
+ });
71
+ test('sin tracks (journal que no es de una cohorte): nunca completo', () => {
72
+ expect((0, tracks_1.canCompleteCohort)((0, types_1.emptyState)('main'))).toEqual({ complete: false, pendingTracks: [] });
73
+ });
74
+ });
75
+ // --- Parte A2: `runRequestFinalIntegration` — pausa y pedido del job
76
+ // canónico son DOS fronteras `reconcileTracks()` distintas (R7.3/R7.6/C4,
77
+ // mismo criterio que Task 11 aplicó a `ensureIntegrationLock`/intento de
78
+ // merge, ver el describe "fronteras separadas" en `track-join-crash.test.ts`)
79
+ // ---------------------------------------------------------------------------
80
+ //
81
+ // Antes de este test, el split solo se ejercitaba INCIDENTALMENTE vía el
82
+ // loop de convergencia `tickUntil` de la Parte B — eso prueba que el
83
+ // RESULTADO FINAL converge, pero un colapso de las dos fronteras en una sola
84
+ // llamada (la clase de bug que Task 8 encontró tres veces) podría pasar
85
+ // desapercibido igual, porque `tickUntil` nunca aisla la llamada intermedia.
86
+ // Este bloque usa un `TrackRuntime` fake — solo `pauseControllerGeneration`
87
+ // controlado directamente, el resto son `unexpected()` porque el efecto
88
+ // `request-final-integration` no toca ningún otro método de la interfaz —
89
+ // y deja `requestJob`/`emitRequest` correr de verdad contra un repo git real
90
+ // (necesario para que `computeFingerprint`/el guard de árbol limpio de
91
+ // `requestJob` con `verificationKind: 'track-integration'` se ejerciten de
92
+ // verdad, no se simulen).
93
+ describe('runRequestFinalIntegration — pausa del controller y pedido del job canónico son fronteras separadas (Finding 2, revisión de Task 12)', () => {
94
+ const BRANCH = 'main';
95
+ function mergedTrackRef(id, headSha) {
96
+ return {
97
+ trackId: id, worktreePath: `/tmp/${id}`, branch: `awm-track/${id}`,
98
+ ownership: [], sharedResources: [], dependsOn: [],
99
+ fencingToken: `f-${id}`.padEnd(32, '0'), phase: 'MERGED_UNVERIFIED', readinessNonce: `r-${id}`.padEnd(32, '0'),
100
+ frozenHeadSha: headSha, joinedCommitSha: headSha,
101
+ };
102
+ }
103
+ /** Journal ya en `FINAL_INTEGRATION` con ambos tracks `MERGED_UNVERIFIED`
104
+ * y el contrato canónico registrado — exactamente el estado en el que
105
+ * `nextProtocolEffect` produce `request-final-integration` (protocol.ts
106
+ * línea `s.globalQaHeadSha !== undefined && s.finalIntegrationJobId ===
107
+ * undefined`), sin pasar por freeze/merge/QA (fuera de alcance acá). */
108
+ function declareFinalIntegrationCohort(planRoot, headSha) {
109
+ (0, store_1.initJournal)(planRoot, BRANCH);
110
+ const s0 = (0, store_1.readJournal)(planRoot, BRANCH).state;
111
+ s0.tracks = [mergedTrackRef('a', headSha), mergedTrackRef('b', headSha)];
112
+ s0.cohortPhase = 'FINAL_INTEGRATION';
113
+ s0.cohortBaseSha = headSha;
114
+ s0.cohortPlanHeadSha = headSha;
115
+ s0.globalQaHeadSha = headSha;
116
+ // `true` es un builtin POSIX, NO un ejecutable en Windows: cmd.exe responde
117
+ // "is not recognized" y el job sale != 0, así que el gate reporta verdict adverso y
118
+ // el test mide el error del fixture en vez del comportamiento del interlock. Mismo
119
+ // idioma multiplataforma que ya usa este archivo más abajo para un no-op.
120
+ s0.trackIntegration = { argv: ['node', '-e', 'process.exit(0)'], paths: [], planDigest: 'plan-1' };
121
+ (0, store_1.writeJournal)(planRoot, BRANCH, s0);
122
+ return (0, store_1.readJournal)(planRoot, BRANCH).state;
123
+ }
124
+ function fakePauseOnlyRuntime(pauseResult, calls) {
125
+ const unexpected = (what) => () => {
126
+ throw new Error(`no debería llamarse: ${what} (fixture ya en FINAL_INTEGRATION, solo request-final-integration en juego)`);
127
+ };
128
+ return {
129
+ addWorktree: unexpected('addWorktree'),
130
+ initTrackJournal: unexpected('initTrackJournal'),
131
+ spawnSupervisor: unexpected('spawnSupervisor'),
132
+ observeSupervisor: unexpected('observeSupervisor'),
133
+ stopOwnSupervisor: async () => unexpected('stopOwnSupervisor')(),
134
+ removeOwnedWorktree: unexpected('removeOwnedWorktree'),
135
+ removeOwnedBranch: unexpected('removeOwnedBranch'),
136
+ emitFreezeRequest: unexpected('emitFreezeRequest'),
137
+ mergeFrozenTrack: unexpected('mergeFrozenTrack'),
138
+ abortOwnedMerge: unexpected('abortOwnedMerge'),
139
+ ensureIntegrationLock: async () => unexpected('ensureIntegrationLock')(),
140
+ async pauseControllerGeneration() {
141
+ calls.pause += 1;
142
+ return pauseResult;
143
+ },
144
+ releaseIntegrationLockIfHeld: unexpected('releaseIntegrationLockIfHeld'),
145
+ };
146
+ }
147
+ function pendingCanonicalRequests(planRoot) {
148
+ return (0, requests_1.listPendingRequests)(planRoot, BRANCH).filter((p) => !p.corrupt && p.envelope.kind === 'job-request'
149
+ && Array.isArray(p.envelope.payload.satisfies)
150
+ && (p.envelope.payload.satisfies).some((id) => typeof id === 'string' && id.startsWith('track-integration:')));
151
+ }
152
+ function readEvents(planRoot) {
153
+ let raw = '';
154
+ try {
155
+ raw = fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, BRANCH), 'utf8');
156
+ }
157
+ catch {
158
+ return [];
159
+ }
160
+ return raw.split('\n').filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
161
+ }
162
+ let planRoot;
163
+ afterEach(() => { if (planRoot !== undefined)
164
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true }); });
165
+ test('(a) generación activa: un call SOLO pausa (stop:true) y NO pide el job canónico en ese mismo call', async () => {
166
+ planRoot = (0, git_fixture_1.initRepo)();
167
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
168
+ const headSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
169
+ const s0 = declareFinalIntegrationCohort(planRoot, headSha);
170
+ const calls = { pause: 0 };
171
+ const runtime = fakePauseOnlyRuntime(true, calls);
172
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, runtime, 2);
173
+ expect(calls.pause).toBe(1);
174
+ expect(result.effectExecuted).toBe('request-final-integration');
175
+ expect(result.state.finalIntegrationJobId).toBeUndefined();
176
+ // El único touch real de este call fue la pausa — ningún job-request
177
+ // quedó escrito en el requestsDir todavía.
178
+ expect(pendingCanonicalRequests(planRoot)).toEqual([]);
179
+ expect(readEvents(planRoot).some((e) => e.kind === 'plan-generation-paused-for-integration')).toBe(true);
180
+ expect(readEvents(planRoot).some((e) => e.kind === 'track-integration-requested')).toBe(false);
181
+ });
182
+ test('(b) una vez confirmada pausada (sin generación activa), el PRÓXIMO call recién ahí pide el job canónico', async () => {
183
+ planRoot = (0, git_fixture_1.initRepo)();
184
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
185
+ const headSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
186
+ const s0 = declareFinalIntegrationCohort(planRoot, headSha);
187
+ const callsA = { pause: 0 };
188
+ const afterPause = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, fakePauseOnlyRuntime(true, callsA), 2)).state;
189
+ expect(pendingCanonicalRequests(planRoot)).toEqual([]); // precondición: (a) todavía no pidió nada
190
+ const callsB = { pause: 0 };
191
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, afterPause, fakePauseOnlyRuntime(false, callsB), 2);
192
+ expect(callsB.pause).toBe(1); // confirmado sin generación activa, no memoizado: se sigue llamando
193
+ expect(result.effectExecuted).toBe('request-final-integration');
194
+ expect(pendingCanonicalRequests(planRoot)).toHaveLength(1);
195
+ expect(readEvents(planRoot).some((e) => e.kind === 'track-integration-requested')).toBe(true);
196
+ });
197
+ test('sin generación que pausar desde el arranque, pausa y pedido colapsan en el mismo call (sin violar "a lo sumo una mutación real")', async () => {
198
+ // Caso borde documentado por el propio driver: cuando `hadActive` es
199
+ // `false` desde el primer call, `pauseControllerGeneration` no hizo
200
+ // NINGÚN touch real (nunca llamó a `stopControllerGenerationConfirmed`)
201
+ // — así que seguir en el mismo call al pedido del job no funde dos
202
+ // mutaciones reales, solo una (el job-request). Esto es DISTINTO del
203
+ // caso (a)/(b): ahí `pausedNow === true` SÍ fue un touch real, y por
204
+ // eso corta el call.
205
+ planRoot = (0, git_fixture_1.initRepo)();
206
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
207
+ const headSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
208
+ const s0 = declareFinalIntegrationCohort(planRoot, headSha);
209
+ const calls = { pause: 0 };
210
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, fakePauseOnlyRuntime(false, calls), 2);
211
+ expect(calls.pause).toBe(1);
212
+ expect(pendingCanonicalRequests(planRoot)).toHaveLength(1);
213
+ expect(result.effectExecuted).toBe('request-final-integration');
214
+ });
215
+ });
216
+ // --- Parte B: el finalizer end-to-end -------------------------------------
217
+ // El comando canónico del fixture NO pasa por npm, y eso es portabilidad, no comodidad:
218
+ // (1) `execFile('npm', …)` falla en Windows —el ejecutable real es `npm.cmd`— y este argv se
219
+ // ejecuta sin shell, a propósito (C4); (2) `npm test -- --runInBand` pega el flag DIRECTO al
220
+ // script sin preservar el `--`, así que el script recibe un argumento que no espera. El
221
+ // fixture anterior sobrevivía a (2) solo porque `true` ignora sus argumentos — y `true` no
222
+ // existe como ejecutable en Windows, que era (1) otra vez. Un `node -e` autónomo no depende
223
+ // de ninguna de las dos cosas.
224
+ const INTEGRATION_ARGV = ['node', '-e', 'process.exit(0)'];
225
+ const INTEGRATION_PATHS = ['cli/src/**', 'cli/tests/**'];
226
+ /** Repo real con dos tracks 'a'/'b' YA declarados en la cohorte (fase inicial
227
+ * ACTIVE — jamás FROZEN_OR_LATER — para que `nextProtocolEffect` no intente
228
+ * ningún efecto de freeze/merge mientras el harness los marca MERGED_UNVERIFIED
229
+ * directamente: Task 12 empieza DESPUÉS de que Task 10/11 ya mergearon,
230
+ * reproducir ESE camino no es responsabilidad de esta suite). El contrato
231
+ * canónico de integración (`trackIntegration`) se registra desde el arranque
232
+ * (Step 6) — sin él, `runRequestFinalIntegration` jamás pide el job. */
233
+ function finalizerHarness(trackIds) {
234
+ const repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-finalize-'));
235
+ git(repo, 'init', '-q', '-b', 'main');
236
+ // `true` ignora cualquier argumento extra (`npm test -- --runInBand` pasa
237
+ // `--runInBand` al script) y siempre sale 0 — a diferencia de `node -e`,
238
+ // que interpreta `--runInBand` como flag de node y falla con exit 9.
239
+ // Queda por si algún test futuro invoca `npm test`; el comando canónico ya no lo hace.
240
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'node --version' } }));
241
+ git(repo, 'add', '.');
242
+ git(repo, 'commit', '-qm', 'seed');
243
+ fs_1.default.mkdirSync(path_1.default.join(repo, '.awm'), { recursive: true });
244
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.awm', 'sensors.json'), '{}');
245
+ (0, init_1.initWatch)(repo, 'main'); // gitignora .awm, detecta requiredVerifiers (test+sensors, R3.6)
246
+ git(repo, 'add', '.gitignore');
247
+ git(repo, 'commit', '-qm', 'gitignore .awm');
248
+ const baseSha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
249
+ const stubBin = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-finalize-stub-'));
250
+ fs_1.default.writeFileSync(path_1.default.join(stubBin, 'codex'), '#!/bin/sh\nwhile true; do sleep 1; done\n', { mode: 0o755 });
251
+ const oldPath = process.env.PATH;
252
+ process.env.PATH = `${stubBin}:${process.env.PATH}`;
253
+ let integrationCalls = 0;
254
+ const spawner = (job, nonce, logsRoot, repoRoot) => {
255
+ if (job.satisfies?.some((id) => id.startsWith('track-integration:')))
256
+ integrationCalls++;
257
+ void (0, exec_wrapper_1.runExecWrapper)({ logsRoot, jobId: job.id, nonce, argv: job.argv, cwd: job.cwd, repoRoot }).catch(() => { });
258
+ };
259
+ const s0 = (0, store_1.readJournal)(repo, 'main').state;
260
+ // 'test'/'sensors' (R1/R3.6, pre-existente): el repo YA los exige
261
+ // (`requiredVerifiers`, detectados arriba) — sin ALGÚN item de esos kinds
262
+ // en algún plan, `computeGate` jamás certifica por ausencia, sin importar
263
+ // qa/interlock/track-integration. En un cohorte real viven en el journal
264
+ // de CADA track; acá, fuera de ese alcance, se modelan a nivel de ciclo.
265
+ s0.cycleVerificationPlan = [
266
+ { id: 'qa', kind: 'qa' }, { id: 'interlock', kind: 'interlock' },
267
+ { id: 'test', kind: 'test' }, { id: 'sensors', kind: 'sensors' },
268
+ ];
269
+ s0.tracks = trackIds.map((id) => trackRef(id, 'ACTIVE'));
270
+ s0.cohortPhase = 'JOINING';
271
+ s0.cohortBaseSha = baseSha;
272
+ s0.cohortPlanHeadSha = baseSha;
273
+ // Mismo efecto que `track-prepare-request` produce en producción (R7.3) —
274
+ // el harness declara los tracks directamente (sin pasar por el protocolo
275
+ // completo de bootstrap P1/P2, fuera de alcance de esta suite) pero SÍ
276
+ // necesita el mismo resultado: los items `track-integration:*` deben
277
+ // existir en el plan ANTES de que cualquier job pueda enlazarse a ellos.
278
+ (0, apply_1.registerTrackIntegrationItems)(s0);
279
+ (0, store_1.writeJournal)(repo, 'main', s0);
280
+ (0, requests_1.emitRequest)(repo, 'main', {
281
+ kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'ti-contract',
282
+ payload: { entity: 'track-integration', argv: INTEGRATION_ARGV, paths: INTEGRATION_PATHS, planDigest: 'plan-1' },
283
+ });
284
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
285
+ let sup = new supervisor_1.Supervisor(repo, 'main', cfg, spawner);
286
+ const realFingerprintNow = (argv, paths, cwd) => {
287
+ try {
288
+ return (0, fingerprint_1.computeFingerprint)(repo, argv, paths, cwd).fingerprint;
289
+ }
290
+ catch {
291
+ return null;
292
+ }
293
+ };
294
+ return {
295
+ repo,
296
+ async tick() { return sup.tick(); },
297
+ async tickUntil(predicate, maxTicks = 400) {
298
+ let outcome = 'continue';
299
+ for (let i = 0; i < maxTicks && !predicate(); i++) {
300
+ outcome = await sup.tick();
301
+ if (predicate())
302
+ break;
303
+ await new Promise((r) => setTimeout(r, 15));
304
+ }
305
+ return outcome;
306
+ },
307
+ markMerged(trackId) {
308
+ const sha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
309
+ const s = (0, store_1.readJournal)(repo, 'main').state;
310
+ s.tracks = (s.tracks ?? []).map((t) => (t.trackId === trackId
311
+ ? { ...t, phase: 'MERGED_UNVERIFIED', frozenHeadSha: sha, joinedCommitSha: sha }
312
+ : t));
313
+ (0, store_1.writeJournal)(repo, 'main', s);
314
+ return sha;
315
+ },
316
+ allMerged() {
317
+ const sha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
318
+ const s = (0, store_1.readJournal)(repo, 'main').state;
319
+ s.tracks = (s.tracks ?? []).map((t) => ({ ...t, phase: 'MERGED_UNVERIFIED', frozenHeadSha: sha, joinedCommitSha: sha }));
320
+ s.cohortPlanHeadSha = sha;
321
+ (0, store_1.writeJournal)(repo, 'main', s);
322
+ return sha;
323
+ },
324
+ reportQaPass() {
325
+ // El "controller" corrige hallazgos y comitea (Step 7.2) — acá,
326
+ // sintéticamente, un archivo nuevo representa ese fix.
327
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'qa-fix.txt'), 'fix');
328
+ git(repo, 'add', '.');
329
+ git(repo, 'commit', '-qm', 'post-implementation-qa fixes');
330
+ const sha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
331
+ // 'qa'/'interlock'/'test'/'sensors' del ciclo (R1, pre-existente):
332
+ // satisfechos EN ESTE MISMO HEAD — ningún commit posterior los
333
+ // invalida antes del interlock final (el job canónico de
334
+ // integración no comitea).
335
+ for (const itemId of ['qa', 'interlock', 'test', 'sensors']) {
336
+ const argv = ['node', '-e', 'process.exit(0)'];
337
+ const fp = (0, fingerprint_1.computeFingerprint)(repo, argv, [], '.');
338
+ const s = (0, store_1.readJournal)(repo, 'main').state;
339
+ const jobId = `job-${itemId}`;
340
+ const job = {
341
+ id: jobId, fingerprint: fp.fingerprint, commandDigest: fp.commandDigest, argv, cwd: '.',
342
+ paths: [], expandedPaths: fp.expandedPaths, executionState: 'exited', observationState: 'progressing',
343
+ verdict: 'pass', phaseTimestamps: {}, satisfies: [itemId],
344
+ };
345
+ s.jobs[jobId] = job;
346
+ const item = s.cycleVerificationPlan.find((x) => x.id === itemId);
347
+ item.satisfiedBy = jobId;
348
+ (0, store_1.writeJournal)(repo, 'main', s);
349
+ }
350
+ (0, requests_1.emitRequest)(repo, 'main', {
351
+ kind: 'track-finalize-request', generationToken: 'g0', idempotencyKey: `finalize-${sha}`,
352
+ payload: { qaHeadSha: sha },
353
+ });
354
+ return sha;
355
+ },
356
+ nextAction() { return (0, store_1.readJournal)(repo, 'main').state.cycle.nextAction; },
357
+ requestedJobs() {
358
+ return Object.values((0, store_1.readJournal)(repo, 'main').state.jobs)
359
+ .filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')))
360
+ .map((j) => ({ argv: j.argv, paths: j.paths, cwd: j.cwd, satisfies: [...(j.satisfies ?? [])].sort() }));
361
+ },
362
+ integrationWrapperCalls() { return integrationCalls; },
363
+ crashAndRestart() { sup = new supervisor_1.Supervisor(repo, 'main', cfg, spawner); },
364
+ interlock() {
365
+ const s = (0, store_1.readJournal)(repo, 'main').state;
366
+ return (0, gate_1.computeGate)(s, false, realFingerprintNow);
367
+ },
368
+ moveHeadUnrelated() {
369
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'unrelated.txt'), String(Date.now()));
370
+ git(repo, 'add', '.');
371
+ git(repo, 'commit', '-qm', 'unrelated change');
372
+ return (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
373
+ },
374
+ // Finding 4 (ronda 2 de re-review): `runnerTick` (runner.ts) despacha
375
+ // TODO job en `received` (`spawnPendingWrappers`) en la MISMA llamada
376
+ // que lo colecciona/reconcilia — y esa llamada corre dentro del MISMO
377
+ // `Supervisor.tick()` que, antes, ya aplicó (`consumePendingRequests`)
378
+ // cualquier `job-request` pendiente escrito por un tick ANTERIOR. Por
379
+ // eso, vía la API pública `tick()`, jamás hay un tick completo que
380
+ // deje un job en `received` observable desde afuera — request
381
+ // (aplicación) y despacho colapsan en el mismo tick. La única forma
382
+ // honesta de reproducir ese punto exacto (el mismo que un crash real
383
+ // SIN puntos de `await` de por medio podría alcanzar) es llamar,
384
+ // directo, a la MISMA función que `tick()` invoca primero —
385
+ // `consumePendingRequests` — sin llegar a `runnerTick`.
386
+ hasPendingCanonicalRequest() {
387
+ return (0, requests_1.listPendingRequests)(repo, 'main').some((p) => !p.corrupt && p.envelope.kind === 'job-request'
388
+ && Array.isArray(p.envelope.payload.satisfies)
389
+ && (p.envelope.payload.satisfies).some((id) => typeof id === 'string' && id.startsWith('track-integration:')));
390
+ },
391
+ applyPendingRequestsOnly() {
392
+ const gen = (0, generations_1.activeGeneration)((0, store_1.readJournal)(repo, 'main').state);
393
+ (0, apply_1.consumePendingRequests)(repo, 'main', gen?.token ?? null);
394
+ },
395
+ cleanup() {
396
+ process.env.PATH = oldPath;
397
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
398
+ fs_1.default.rmSync(stubBin, { recursive: true, force: true });
399
+ },
400
+ };
401
+ }
402
+ describe('finalizer end-to-end (R7.1-R7.7, C3/C4, Task 12)', () => {
403
+ jest.setTimeout(60000);
404
+ let h;
405
+ afterEach(() => h?.cleanup());
406
+ test('solo el HEAD final recibe QA e integración (R7.7, C3): un merge aislado no dispara nada', async () => {
407
+ h = finalizerHarness(['a', 'b']);
408
+ h.markMerged('a');
409
+ await h.tick();
410
+ expect(h.requestedJobs()).toEqual([]);
411
+ expect(h.nextAction()?.type).not.toBe('run-global-qa');
412
+ h.allMerged(); // ahora AMBOS están MERGED_UNVERIFIED
413
+ await h.tick();
414
+ expect(h.nextAction()).toMatchObject({ type: 'run-global-qa', target: 'cycle' });
415
+ });
416
+ test('QA con fixes culmina en commit limpio antes del job canónico (C3/C4): un único job con TODOS los satisfiers', async () => {
417
+ h = finalizerHarness(['a', 'b']);
418
+ h.allMerged();
419
+ await h.tick(); // persiste nextAction run-global-qa
420
+ expect(h.nextAction()?.type).toBe('run-global-qa');
421
+ const qaHead = h.reportQaPass();
422
+ // converge: aceptar el autoreporte -> (pausar controller si hace
423
+ // falta) -> pedir el job canónico -> el job se ejecuta y pasa.
424
+ await h.tickUntil(() => h.requestedJobs().length > 0 && h.requestedJobs()[0].argv.length > 0);
425
+ await h.tickUntil(() => {
426
+ const jobs = h.requestedJobs();
427
+ return jobs.length === 1; // nunca más de uno
428
+ });
429
+ const jobs = h.requestedJobs();
430
+ expect(jobs).toEqual([{
431
+ argv: INTEGRATION_ARGV, paths: INTEGRATION_PATHS, cwd: '.',
432
+ satisfies: ['track-integration:a', 'track-integration:b'],
433
+ }]);
434
+ void qaHead;
435
+ });
436
+ // Matriz de crash del Step 8 (R7.1-R7.7, C3/C4): reusabilidad idempotente
437
+ // del job canónico probada en los CUATRO puntos que el plan nombra —
438
+ // "después de QA" (autoreporte aceptado, NINGÚN job pedido todavía),
439
+ // "después del request" (job pedido, aún sin correr/completar), "después
440
+ // del pass" (job pasó, interlock todavía no corrió) y "antes de COMPLETE"
441
+ // (interlock YA pasó — `cohortPhase` ya es `COMPLETE` — pero el chequeo
442
+ // genérico de `Supervisor.tick()` que fija `cycle.status = 'COMPLETE'`
443
+ // todavía no corrió sobre ese resultado). Las cuatro comparten el mismo
444
+ // rigor: git real, un contador REAL de invocaciones del wrapper (nunca
445
+ // solo una lectura del journal), y una assertion final de
446
+ // `integrationWrapperCalls() === 1` — el job canónico jamás corre dos
447
+ // veces sin importar en qué punto exacto "muere" el proceso.
448
+ test('crash "después de QA": el autoreporte ya fue ACEPTADO (HEAD/árbol re-verificados, cohortPhase ya en FINAL_INTEGRATION) pero NINGÚN job se pidió todavía — el restart converge a un único job con todos los satisfiers (C4, R7.3)', async () => {
449
+ h = finalizerHarness(['a', 'b']);
450
+ h.allMerged();
451
+ await h.tick();
452
+ expect(h.nextAction()?.type).toBe('run-global-qa');
453
+ h.reportQaPass(); // emite el request `track-finalize-request`
454
+ // Post-review (Finding 3, ronda 2 de re-review): la version anterior
455
+ // crasheaba ACA MISMO, sin tickear — es decir, ANTES de que
456
+ // `consumePendingRequests` siquiera aplicara el autoreporte. Eso
457
+ // probaba un escenario distinto (y ya cubierto por el resto de la
458
+ // matriz vía el mismo idempotencyKey). El punto que el Step 8 pide
459
+ // ("el autoreporte YA se aceptó, ningún job pedido todavía") exige
460
+ // avanzar hasta que `reconcileTracks` haya corrido de verdad
461
+ // `runRequestGlobalQa`: aplica `track-finalize-request`
462
+ // (`s.qaFinalizeRequested`), RE-VERIFICA `qaHeadSha === HEAD real` y
463
+ // árbol limpio (no confía ciegamente en el autoreporte, ver
464
+ // `apply.ts`), y solo entonces observa `global-qa-pass` — el efecto
465
+ // OBSERVABLE de esa aceptación es `cohortPhase` pasando a
466
+ // `FINAL_INTEGRATION` (`protocol.ts::reconcileProtocol`, rama
467
+ // `global-qa-pass`, fija `globalQaHeadSha` Y `cohortPhase` en LA
468
+ // MISMA transición). Un solo tick real basta — probado
469
+ // empíricamente: el mismo `reconcileTracks` que aplica el request
470
+ // también ejecuta `request-global-qa` en esa misma llamada — pero
471
+ // `tickUntil` en vez de `tick()` fijo documenta la condición real en
472
+ // vez de un conteo de ticks frágil.
473
+ await h.tickUntil(() => (0, store_1.readJournal)(h.repo, 'main').state.cohortPhase === 'FINAL_INTEGRATION', 20);
474
+ expect((0, store_1.readJournal)(h.repo, 'main').state.cohortPhase).toBe('FINAL_INTEGRATION');
475
+ expect((0, store_1.readJournal)(h.repo, 'main').state.qaFinalizeRequested).toBeDefined();
476
+ // El punto de crash exacto: el autoreporte ya fue aceptado Y
477
+ // re-verificado, pero el job canónico todavía no se pidió (el
478
+ // próximo efecto, `request-final-integration`, corre recién en un
479
+ // tick DISTINTO — `runRequestFinalIntegration` ni siquiera se
480
+ // invocó todavía).
481
+ expect(h.requestedJobs()).toEqual([]);
482
+ expect(h.integrationWrapperCalls()).toBe(0);
483
+ h.crashAndRestart(); // "restart": un proceso nuevo retoma desde el journal ya persistido
484
+ await h.tickUntil(() => h.requestedJobs().length === 1);
485
+ await h.tickUntil(() => {
486
+ const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
487
+ .filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
488
+ return jobs.length === 1 && jobs[0].executionState === 'exited';
489
+ });
490
+ const jobs = h.requestedJobs();
491
+ expect(jobs).toHaveLength(1); // nunca un subconjunto, ni siquiera arrancando de cero tras el crash
492
+ expect(jobs[0].satisfies).toEqual(['track-integration:a', 'track-integration:b']);
493
+ expect(h.integrationWrapperCalls()).toBe(1); // exactamente una vez: ni cero (atascado), ni dos (duplicado)
494
+ });
495
+ test('crash "después del request": el job ya fue CREADO en el journal (`received`) pero AÚN no se despachó — el restart nunca pide un subconjunto ni duplica la ejecución (C4, R7.6)', async () => {
496
+ h = finalizerHarness(['a', 'b']);
497
+ h.allMerged();
498
+ await h.tick();
499
+ h.reportQaPass();
500
+ // Post-review (Finding 4, ronda 2 de re-review): la version anterior
501
+ // usaba `tickUntil(() => h.requestedJobs().length === 1)` como punto
502
+ // de crash — pero `runner.ts::spawnPendingWrappers` despacha TODO job
503
+ // `received` (invoca el wrapper) en la MISMA llamada de
504
+ // `runnerTick` que ya corrió DENTRO del mismo tick que lo creó (ver
505
+ // `runnerTick`/`spawnPendingWrappers`, runner.ts): para cuando el job
506
+ // aparece en `requestedJobs()`, `integrationWrapperCalls()` YA es 1
507
+ // — es decir, ese punto de crash colapsaba con "después del pass",
508
+ // nunca representó "pedido pero sin despachar" (confirmado
509
+ // empíricamente: en este mismo harness, el job nace directo en
510
+ // `spawn-intent` con el wrapper ya invocado). La ÚNICA ventana real
511
+ // de "recibido, aún sin despachar" es la que un crash SIN puntos de
512
+ // `await` de por medio podría alcanzar dentro de un tick — entre que
513
+ // `consumePendingRequests` persiste el job en `received` y que
514
+ // `runnerTick` (más adelante en el MISMO `Supervisor.tick()`) lo
515
+ // despacha. Se reproduce llamando esa MISMA función (la primera
516
+ // mitad de ese tick) directo, sin completar el tick — mismo criterio
517
+ // que la prueba "antes de COMPLETE" de abajo usa con `reconcileTracks`.
518
+ for (let i = 0; i < 20 && !h.hasPendingCanonicalRequest(); i++)
519
+ await h.tick();
520
+ expect(h.hasPendingCanonicalRequest()).toBe(true); // el pedido ya esta en disco...
521
+ expect(h.requestedJobs()).toEqual([]); // ...pero AUN no es un Job del journal
522
+ h.applyPendingRequestsOnly(); // == la primera mitad exacta del proximo tick
523
+ const created = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
524
+ .filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
525
+ expect(created).toHaveLength(1);
526
+ expect(created[0].executionState).toBe('received'); // pedido y CREADO, pero SIN despachar
527
+ expect(h.integrationWrapperCalls()).toBe(0); // el wrapper jamas se invoco todavia
528
+ h.crashAndRestart();
529
+ // varios ticks más, ya con un proceso "nuevo": el mismo job se
530
+ // reconoce por idempotencyKey (fingerprint+commandDigest+satisfies) —
531
+ // jamás se pide un segundo job con un subconjunto, ni corre una
532
+ // segunda vez de verdad (contador del wrapper, no solo el journal).
533
+ await h.tickUntil(() => {
534
+ const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
535
+ .filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
536
+ return jobs.length === 1 && jobs[0].executionState === 'exited';
537
+ });
538
+ const jobs = h.requestedJobs();
539
+ expect(jobs).toHaveLength(1);
540
+ expect(jobs[0].satisfies).toEqual(['track-integration:a', 'track-integration:b']);
541
+ expect(h.integrationWrapperCalls()).toBe(1); // exactamente una vez: nunca corrio dos veces por el crash
542
+ });
543
+ test('el job canónico se ejecuta UNA sola vez (contador del wrapper) a través de un crash/restart', async () => {
544
+ h = finalizerHarness(['a', 'b']);
545
+ h.allMerged();
546
+ await h.tick();
547
+ h.reportQaPass();
548
+ await h.tickUntil(() => {
549
+ const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
550
+ .filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
551
+ return jobs.length === 1 && jobs[0].executionState === 'exited';
552
+ });
553
+ expect(h.integrationWrapperCalls()).toBe(1);
554
+ h.crashAndRestart();
555
+ for (let i = 0; i < 5; i++)
556
+ await h.tick();
557
+ expect(h.integrationWrapperCalls()).toBe(1); // jamás una segunda ejecución real
558
+ });
559
+ test('al pasar el job canónico, el interlock global (computeGate) certifica y no antes', async () => {
560
+ h = finalizerHarness(['a', 'b']);
561
+ h.allMerged();
562
+ await h.tick();
563
+ h.reportQaPass();
564
+ await h.tickUntil(() => {
565
+ const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
566
+ .filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
567
+ return jobs.length === 1 && jobs[0].executionState === 'exited';
568
+ });
569
+ // El interlock certifica UNA VEZ que la evidencia (qa, interlock,
570
+ // track-integration:* de ambos tracks) está completa y vigente.
571
+ // Instrumentado a propósito: un `toBe(true)` pelado en Windows dice "false" y nada
572
+ // más, y la causa (`stale-fingerprint`, `missing-verifier`, …) vive en `reasons`.
573
+ // Afirmar sobre las razones hace que el rojo TRAIGA su propio diagnóstico.
574
+ expect(h.interlock().reasons).toEqual([]);
575
+ await h.tickUntil(() => (0, store_1.readJournal)(h.repo, 'main').state.cohortPhase === 'COMPLETE');
576
+ const final = (0, store_1.readJournal)(h.repo, 'main').state;
577
+ expect(final.cohortPhase).toBe('COMPLETE');
578
+ expect(final.tracks.every((t) => t.phase === 'JOINED')).toBe(true);
579
+ // R7.5: integration.lock liberado al cerrar la cohorte.
580
+ expect(fs_1.default.existsSync((0, paths_1.integrationLockPath)(h.repo))).toBe(false);
581
+ });
582
+ test('crash "antes de COMPLETE": el interlock YA pasó (cohortPhase COMPLETE persistido) pero cycle.status todavía no flipeó — el restart converge sin repetir el job canónico (R7.4/R7.5)', async () => {
583
+ h = finalizerHarness(['a', 'b']);
584
+ h.allMerged();
585
+ await h.tick();
586
+ h.reportQaPass();
587
+ await h.tickUntil(() => {
588
+ const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
589
+ .filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
590
+ return jobs.length === 1 && jobs[0].executionState === 'exited';
591
+ });
592
+ expect(h.integrationWrapperCalls()).toBe(1);
593
+ // Deja que los ticks normales lleven la cohorte hasta FINAL_INTERLOCK
594
+ // (integration-pass ya observado, `finalIntegrationJobId` fijado) —
595
+ // el único efecto que falta es `run-final-interlock` en sí.
596
+ await h.tickUntil(() => (0, store_1.readJournal)(h.repo, 'main').state.cohortPhase === 'FINAL_INTERLOCK');
597
+ expect((0, store_1.readJournal)(h.repo, 'main').state.cycle.status).toBe('IN_PROGRESS');
598
+ // Punto de crash EXACTO que el Step 8 exige probar: `reconcileTracks`
599
+ // (la MISMA función que `Supervisor.tick()` invoca internamente,
600
+ // llamada acá directamente para poder detenernos a mitad de tick, algo
601
+ // que la API pública de `tick()` — atómica de punta a punta — no deja
602
+ // observar) corre el efecto `run-final-interlock` y persiste
603
+ // `cohortPhase = 'COMPLETE'` — pero el chequeo genérico de
604
+ // `Supervisor.tick()` (el ÚNICO lugar del código que fija
605
+ // `cycle.status = 'COMPLETE'`) todavía no se ejecutó sobre ese
606
+ // resultado. Un crash real (SIGKILL, sin puntos de `await` de por
607
+ // medio entre las dos escrituras) puede caer exactamente acá.
608
+ const before = (0, store_1.readJournal)(h.repo, 'main').state;
609
+ const runtime = (0, tracks_1.defaultTrackRuntime)(h.repo, 'main');
610
+ const { state: afterInterlock, effectExecuted } = await (0, tracks_1.reconcileTracks)(h.repo, 'main', before, runtime, supervisor_1.DEFAULT_SUPERVISOR_CONFIG.maxParallelTracks);
611
+ expect(effectExecuted).toBe('run-final-interlock');
612
+ expect(afterInterlock.cohortPhase).toBe('COMPLETE');
613
+ expect(afterInterlock.cycle.status).toBe('IN_PROGRESS'); // la ventana exacta del crash: todavía no flipeó
614
+ expect(afterInterlock.tracks.every((t) => t.phase === 'JOINED')).toBe(true);
615
+ expect(h.integrationWrapperCalls()).toBe(1); // el freeze de arriba no re-ejecuta el job canónico
616
+ h.crashAndRestart(); // "restart": un Supervisor nuevo retoma sobre el journal ya persistido
617
+ await h.tickUntil(() => (0, store_1.readJournal)(h.repo, 'main').state.cycle.status === 'COMPLETE');
618
+ const final = (0, store_1.readJournal)(h.repo, 'main').state;
619
+ expect(final.cycle.status).toBe('COMPLETE');
620
+ expect(final.cohortPhase).toBe('COMPLETE'); // reconcileTracks no-opea (protocol.ts: null tras COMPLETE) — nunca retrocede
621
+ expect(final.tracks.every((t) => t.phase === 'JOINED')).toBe(true);
622
+ expect(h.integrationWrapperCalls()).toBe(1); // jamás una segunda ejecución real del job canónico
623
+ });
624
+ test('mutación DESPUÉS de que el job canónico pasó vuelve stale el cierre (R7.7)', async () => {
625
+ h = finalizerHarness(['a', 'b']);
626
+ h.allMerged();
627
+ await h.tick();
628
+ h.reportQaPass();
629
+ await h.tickUntil(() => {
630
+ const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
631
+ .filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
632
+ return jobs.length === 1 && jobs[0].executionState === 'exited';
633
+ });
634
+ // Instrumentado a propósito: un `toBe(true)` pelado en Windows dice "false" y nada
635
+ // más, y la causa (`stale-fingerprint`, `missing-verifier`, …) vive en `reasons`.
636
+ // Afirmar sobre las razones hace que el rojo TRAIGA su propio diagnóstico.
637
+ expect(h.interlock().reasons).toEqual([]);
638
+ h.moveHeadUnrelated(); // el árbol mutó DESPUÉS de que la evidencia se fijó
639
+ const stale = h.interlock();
640
+ expect(stale.pass).toBe(false);
641
+ expect(stale.reasons).toEqual(expect.arrayContaining([expect.objectContaining({ category: 'stale-fingerprint' })]));
642
+ });
643
+ });