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,591 @@
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 10 (R5.2/R5.7/R5.8/R5.9/R6.3/R6.4/R6.5/C5): freeze del track,
7
+ // quiescencia del plan y precondiciones de join. Dos frentes, cada uno con
8
+ // git REAL (mismo criterio que `track-bootstrap-crash.test.ts`/
9
+ // `track-runtime-git.test.ts` — la única forma honesta de probar C5/R6.4 es
10
+ // mirar el repo real, no solo eventos):
11
+ // 1. `runFreezeTrack` (driver del PLAN, en `reconcileTracks`): emite la
12
+ // request cross-journal, observa (read-only) el journal del track, y
13
+ // solo acepta FROZEN cuando los 6 hechos son demostrables — incluida la
14
+ // comparación de ownership post-hoc (Step 6/C5).
15
+ // 2. `Supervisor.tick()` de un track individual: ejecuta las 6
16
+ // observaciones reales (drenar, gate local, worktree limpio, terminar
17
+ // su propia generación) y persiste `frozen` — nunca antes de que todo
18
+ // sea demostrable.
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const os_1 = __importDefault(require("os"));
22
+ const child_process_1 = require("child_process");
23
+ const git_fixture_1 = require("../../helpers/git-fixture");
24
+ const tracks_1 = require("../../../src/commands/watch/tracks");
25
+ const supervisor_1 = require("../../../src/commands/watch/supervisor");
26
+ const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
27
+ const init_1 = require("../../../src/commands/watch/init");
28
+ const request_1 = require("../../../src/commands/job/request");
29
+ const requests_1 = require("../../../src/core/journal/requests");
30
+ const fingerprint_1 = require("../../../src/core/journal/fingerprint");
31
+ const store_1 = require("../../../src/core/journal/store");
32
+ const paths_1 = require("../../../src/core/journal/paths");
33
+ function git(cwd, ...args) {
34
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'pipe' });
35
+ }
36
+ // --- Parte 1: runFreezeTrack (driver del PLAN) ------------------------------
37
+ describe('runFreezeTrack — driver del PLAN (R5.2/R5.7/R5.8/R5.9/C5)', () => {
38
+ const PLAN_BRANCH = 'main';
39
+ const TRACK_BRANCH = 'awm-track/a';
40
+ let planRoot;
41
+ let trackWorktree;
42
+ let baseSha;
43
+ beforeEach(() => {
44
+ planRoot = (0, git_fixture_1.initRepo)();
45
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
46
+ baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
47
+ // Worktree real del track — un `git worktree add` de verdad, para que
48
+ // `mergeBase`/`changedPaths` (llamados contra `planRoot`) vean
49
+ // commits reales del track (mismo object store). El journal propio
50
+ // del track se inicializa EN SU PROPIA rama (`TRACK_BRANCH`) — igual
51
+ // que `defaultTrackRuntime.initTrackJournal` haría en producción.
52
+ trackWorktree = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-freeze-wt-'));
53
+ fs_1.default.rmdirSync(trackWorktree);
54
+ git(planRoot, 'worktree', 'add', '-b', TRACK_BRANCH, trackWorktree, baseSha);
55
+ (0, store_1.initJournal)(trackWorktree, TRACK_BRANCH);
56
+ });
57
+ afterEach(() => {
58
+ try {
59
+ (0, child_process_1.execFileSync)('git', ['worktree', 'remove', '--force', trackWorktree], { cwd: planRoot, stdio: 'pipe' });
60
+ }
61
+ catch { /* best-effort */ }
62
+ try {
63
+ (0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: planRoot, stdio: 'pipe' });
64
+ }
65
+ catch { /* best-effort */ }
66
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true });
67
+ fs_1.default.rmSync(trackWorktree, { recursive: true, force: true });
68
+ });
69
+ function trackJournal() { return (0, store_1.readJournal)(trackWorktree, TRACK_BRANCH).state; }
70
+ // Mismo canal de auditoria que el resto del supervisor (`appendEvent`) —
71
+ // leemos DE AHI en vez de inferir eventos por diffing de estado (mismo
72
+ // patron que `track-bootstrap.test.ts`).
73
+ function readRawEvents() {
74
+ let raw = '';
75
+ try {
76
+ raw = fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, PLAN_BRANCH), 'utf8');
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ return raw.split('\n').filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
82
+ }
83
+ function declarePlan(ownership = []) {
84
+ (0, store_1.initJournal)(planRoot, PLAN_BRANCH);
85
+ const s0 = (0, store_1.readJournal)(planRoot, PLAN_BRANCH).state;
86
+ s0.cohortPhase = 'ACTIVE';
87
+ s0.cohortBaseSha = baseSha;
88
+ s0.tracks = [
89
+ {
90
+ trackId: 'a', worktreePath: trackWorktree, branch: TRACK_BRANCH,
91
+ ownership, sharedResources: [], dependsOn: [],
92
+ fencingToken: 'fence-a'.padEnd(32, '0'), phase: 'JOIN_REQUESTED', readinessNonce: 'ready-a'.padEnd(32, '0'),
93
+ },
94
+ {
95
+ // Segundo track: satisface el mínimo de 2 de `initialCohort`
96
+ // y se mantiene ACTIVE — no participa de este freeze.
97
+ trackId: 'b', worktreePath: path_1.default.join(planRoot, 'nope-b'), branch: 'awm-track/b',
98
+ ownership: [], sharedResources: [], dependsOn: [],
99
+ fencingToken: 'fence-b'.padEnd(32, '0'), phase: 'ACTIVE', readinessNonce: 'ready-b'.padEnd(32, '0'),
100
+ },
101
+ ];
102
+ (0, store_1.writeJournal)(planRoot, PLAN_BRANCH, s0);
103
+ return (0, store_1.readJournal)(planRoot, PLAN_BRANCH).state;
104
+ }
105
+ function fakeRuntime(overrides = {}) {
106
+ const real = (0, tracks_1.defaultTrackRuntime)(planRoot, PLAN_BRANCH);
107
+ return {
108
+ addWorktree: () => { throw new Error('no debería llamarse'); },
109
+ initTrackJournal: () => { throw new Error('no debería llamarse'); },
110
+ spawnSupervisor: () => { throw new Error('no debería llamarse'); },
111
+ observeSupervisor: () => ({ kind: 'absent' }),
112
+ async stopOwnSupervisor() { throw new Error('no debería llamarse'); },
113
+ removeOwnedWorktree() { throw new Error('no debería llamarse'); },
114
+ removeOwnedBranch() { throw new Error('no debería llamarse'); },
115
+ emitFreezeRequest: real.emitFreezeRequest,
116
+ mergeFrozenTrack() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
117
+ abortOwnedMerge() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
118
+ async ensureIntegrationLock() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
119
+ async pauseControllerGeneration() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
120
+ releaseIntegrationLockIfHeld() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
121
+ ...overrides,
122
+ };
123
+ }
124
+ test('emite track-freeze-request al journal PROPIO del track (cross-worktree) y jamás escribe ese journal directamente', async () => {
125
+ const s = declarePlan();
126
+ const runtime = fakeRuntime();
127
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, s, runtime, 2);
128
+ // El plan NO mutó nada del lado del track ni de sí mismo (todavía en
129
+ // JOIN_REQUESTED — este call solo emitió la request).
130
+ const a = result.state.tracks.find((t) => t.trackId === 'a');
131
+ expect(a.phase).toBe('JOIN_REQUESTED');
132
+ expect(a.frozenHeadSha).toBeUndefined();
133
+ // La request llegó de verdad al requestsDir del TRACK.
134
+ const pending = (0, requests_1.listPendingRequests)(trackWorktree, TRACK_BRANCH);
135
+ expect(pending).toHaveLength(1);
136
+ expect(pending[0].envelope.kind).toBe('track-freeze-request');
137
+ // El journal del track sigue sin `freezeRequested` — nadie lo
138
+ // consumió todavía (eso es responsabilidad exclusiva del propio
139
+ // `Supervisor.tick()` del track, no de este driver).
140
+ expect(trackJournal().freezeRequested).toBeUndefined();
141
+ });
142
+ test('no duplica la request mientras la primera sigue sin consumir (read-only antes de tocar runtime)', async () => {
143
+ const s = declarePlan();
144
+ let emitCalls = 0;
145
+ const runtime = fakeRuntime({
146
+ emitFreezeRequest: (ref, token) => { emitCalls++; (0, tracks_1.defaultTrackRuntime)(planRoot, PLAN_BRANCH).emitFreezeRequest(ref, token); },
147
+ });
148
+ await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, s, runtime, 2);
149
+ await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, s, runtime, 2);
150
+ await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, s, runtime, 2);
151
+ expect(emitCalls).toBe(1);
152
+ expect((0, requests_1.listPendingRequests)(trackWorktree, TRACK_BRANCH)).toHaveLength(1);
153
+ });
154
+ test('acepta FROZEN solo cuando supervisor muerto Y lock libre confirman el autoreporte del track (R6.4)', async () => {
155
+ const s = declarePlan();
156
+ // Simula que el propio track YA completó sus 6 pasos internos
157
+ // (`Supervisor.tick()` de Parte 2 hace esto de verdad) — acá se
158
+ // fuerza directamente para aislar la lógica del lado del PLAN.
159
+ const frozenHeadSha = (0, git_fixture_1.commitFile)(trackWorktree, 'work.ts', 'work');
160
+ const ts = trackJournal();
161
+ ts.frozen = { headSha: frozenHeadSha, at: new Date().toISOString() };
162
+ (0, store_1.writeJournal)(trackWorktree, TRACK_BRANCH, ts);
163
+ // `ref.supervisorProcessRef` ausente => `supervisorAlive` falso por
164
+ // definición; ningún `supervisor.lock` real en `trackWorktree`.
165
+ const runtime = fakeRuntime();
166
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, s, runtime, 2);
167
+ const a = result.state.tracks.find((t) => t.trackId === 'a');
168
+ expect(a.phase).toBe('FROZEN');
169
+ expect(a.frozenHeadSha).toBe(frozenHeadSha);
170
+ expect(result.state.cohortPhase).toBe('JOINING');
171
+ });
172
+ test('supervisor vivo (identidad viva) o lock retenido: NO acepta FROZEN aunque el track ya se autoreportó (R6.4)', async () => {
173
+ const s = declarePlan();
174
+ const frozenHeadSha = (0, git_fixture_1.commitFile)(trackWorktree, 'work.ts', 'work');
175
+ const ts = trackJournal();
176
+ ts.frozen = { headSha: frozenHeadSha, at: new Date().toISOString() };
177
+ (0, store_1.writeJournal)(trackWorktree, TRACK_BRANCH, ts);
178
+ // Simular lock retenido: archivo real en la ruta esperada.
179
+ fs_1.default.mkdirSync(path_1.default.join(trackWorktree, '.awm', 'journal'), { recursive: true });
180
+ fs_1.default.writeFileSync(path_1.default.join(trackWorktree, '.awm', 'journal', 'supervisor.lock'), '{}');
181
+ const runtime = fakeRuntime();
182
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, s, runtime, 2);
183
+ const a = result.state.tracks.find((t) => t.trackId === 'a');
184
+ expect(a.phase).toBe('JOIN_REQUESTED'); // sin mover: lock todavía retenido
185
+ expect(a.frozenHeadSha).toBeUndefined();
186
+ });
187
+ test('ownership real fuera de declaración serializa (violación nombrada) sin revertir el freeze (R5.8/R5.9)', async () => {
188
+ const s = declarePlan(['src/']); // el track declara ownership de src/ solamente
189
+ fs_1.default.mkdirSync(path_1.default.join(trackWorktree, 'src'), { recursive: true });
190
+ (0, git_fixture_1.commitFile)(trackWorktree, 'src/a.ts', 'in-scope');
191
+ const frozenHeadSha = (0, git_fixture_1.commitFile)(trackWorktree, 'outside.txt', 'fuera de ownership');
192
+ const ts = trackJournal();
193
+ ts.frozen = { headSha: frozenHeadSha, at: new Date().toISOString() };
194
+ (0, store_1.writeJournal)(trackWorktree, TRACK_BRANCH, ts);
195
+ const runtime = fakeRuntime();
196
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, s, runtime, 2);
197
+ const a = result.state.tracks.find((t) => t.trackId === 'a');
198
+ expect(a.phase).toBe('FROZEN'); // el freeze en sí NUNCA se revierte
199
+ expect(result.state.cohortParallelInvalidatedBy ?? []).toEqual([]); // outside.txt no es una clase global
200
+ // Consumidor `track-ownership-violation` (Step 6/R5.8/R5.9):
201
+ // `assessActualOwnership` reporta `outside.txt` como fuera de
202
+ // ownership declarada (`src/`), y ese hallazgo SI debe emitirse
203
+ // nombrando el path exacto — sin esto, una regresion en el wiring
204
+ // `mergeBase`/`changedPaths`/`assessActualOwnership` pasaria
205
+ // desapercibida (nadie mas lee este campo del evento).
206
+ const violation = readRawEvents().find((e) => e.kind === 'track-ownership-violation');
207
+ expect(violation).toEqual({ at: expect.any(String), kind: 'track-ownership-violation', trackId: 'a', paths: ['outside.txt'] });
208
+ // Efecto observable en el ordenamiento de join: una violacion de
209
+ // ownership NO bloquea ni reordena nada por si sola — el track
210
+ // sigue avanzando a FROZEN/JOINING exactamente igual que sin
211
+ // violacion (Step 6 solo la deja NOMBRADA en el log de eventos para
212
+ // revision humana, jamas revierte un freeze ya consumado).
213
+ expect(a.blockedReason).toBeUndefined();
214
+ expect(result.state.cohortPhase).toBe('JOINING');
215
+ // Ninguna clase global fue tocada: el consumidor `parallel-invalidated`
216
+ // (probado en el test siguiente) permanece silencioso acá.
217
+ expect(readRawEvents().some((e) => e.kind === 'parallel-invalidated')).toBe(false);
218
+ });
219
+ test('una clase global tocada de verdad invalida el paralelismo de la cohorte y se persiste para futuros awm watch (R5.7/C5)', async () => {
220
+ const s = declarePlan(['src/']);
221
+ const frozenHeadSha = (0, git_fixture_1.commitFile)(trackWorktree, 'package-lock.json', '{}');
222
+ const ts = trackJournal();
223
+ ts.frozen = { headSha: frozenHeadSha, at: new Date().toISOString() };
224
+ (0, store_1.writeJournal)(trackWorktree, TRACK_BRANCH, ts);
225
+ const runtime = fakeRuntime();
226
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, s, runtime, 2);
227
+ const a = result.state.tracks.find((t) => t.trackId === 'a');
228
+ expect(a.phase).toBe('FROZEN');
229
+ expect(result.state.cohortParallelInvalidatedBy).toEqual(['a:lockfile:package-lock.json']);
230
+ // Consumidor `parallel-invalidated` (Step 6/R5.7/C5): el evento
231
+ // nombra la(s) clase(s) global(es) realmente tocadas, y
232
+ // `cohortParallelInvalidatedBy` — la marca DURABLE que futuros
233
+ // `awm watch` deben leer para no volver a paralelizar esta cohorte
234
+ // — usa el formato `<trackId>:<clase>:<path>` que produce
235
+ // `assessActualOwnership`, no un formato inventado por el test.
236
+ const invalidated = readRawEvents().find((e) => e.kind === 'parallel-invalidated');
237
+ expect(invalidated).toEqual({ at: expect.any(String), kind: 'parallel-invalidated', trackId: 'a', classes: ['lockfile:package-lock.json'] });
238
+ // `package-lock.json` tambien esta fuera de la ownership declarada
239
+ // (`src/`) — ambos consumidores del mismo commit se ejercitan acá.
240
+ const violation = readRawEvents().find((e) => e.kind === 'track-ownership-violation');
241
+ expect(violation).toEqual({ at: expect.any(String), kind: 'track-ownership-violation', trackId: 'a', paths: ['package-lock.json'] });
242
+ // La invalidacion de paralelismo tampoco bloquea/reordena el join
243
+ // de ESTE track — persiste como marca para cohortes FUTURAS.
244
+ expect(a.blockedReason).toBeUndefined();
245
+ expect(result.state.cohortPhase).toBe('JOINING');
246
+ });
247
+ });
248
+ // --- Parte 2: Supervisor.tick() de un track individual ----------------------
249
+ describe('Supervisor.tick() — freeze de un track individual (R5.2/R6.3)', () => {
250
+ jest.setTimeout(60000);
251
+ const BRANCH = 'main';
252
+ const fakeSpawner = (job, nonce, logsRoot, repoRoot) => {
253
+ void (0, exec_wrapper_1.runExecWrapper)({ logsRoot, jobId: job.id, nonce, argv: job.argv, cwd: job.cwd, repoRoot }).catch(() => { });
254
+ };
255
+ let repo;
256
+ let stubBin;
257
+ let oldPath;
258
+ beforeEach(() => {
259
+ repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-freeze-sup-'));
260
+ git(repo, 'init', '-q', '-b', BRANCH);
261
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
262
+ git(repo, 'add', '.');
263
+ git(repo, 'commit', '-qm', 'c');
264
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'node -e "process.exit(0)"' } }));
265
+ fs_1.default.mkdirSync(path_1.default.join(repo, '.awm'), { recursive: true });
266
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.awm', 'sensors.json'), '{}');
267
+ git(repo, 'add', '.');
268
+ git(repo, 'commit', '-qm', 'config');
269
+ stubBin = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-stub-'));
270
+ fs_1.default.writeFileSync(path_1.default.join(stubBin, 'codex'), '#!/bin/sh\nwhile true; do sleep 1; done\n', { mode: 0o755 });
271
+ oldPath = process.env.PATH;
272
+ process.env.PATH = `${stubBin}:${process.env.PATH}`;
273
+ });
274
+ afterEach(() => {
275
+ process.env.PATH = oldPath;
276
+ fs_1.default.rmSync(repo, { recursive: true, force: true });
277
+ fs_1.default.rmSync(stubBin, { recursive: true, force: true });
278
+ });
279
+ function emitVerdict(obligationId, verdictId) {
280
+ const argv = ['awm-review', obligationId];
281
+ const fp = (0, fingerprint_1.computeFingerprint)(repo, argv, [], '.').fingerprint;
282
+ (0, requests_1.emitRequest)(repo, BRANCH, { kind: 'verdict', generationToken: 'g0', idempotencyKey: verdictId,
283
+ payload: { verdictId, obligationId, result: 'pass', detail: 'ok', fingerprint: fp, argv, paths: [], cwd: '.' } });
284
+ }
285
+ function setUpGreenTrack() {
286
+ (0, init_1.initWatch)(repo, BRANCH);
287
+ // `initWatch` ESCRIBE `.gitignore` (`.awm/`) pero no lo commitea — un
288
+ // `.gitignore` sin trackear es en sí mismo un path "untracked" para
289
+ // `git status --porcelain` (R6.4's chequeo de limpieza no distingue
290
+ // "sucio de verdad" de "el propio bootstrap del journal"). Sin este
291
+ // commit, `isWorktreeClean` nunca sería `true` y el freeze jamás
292
+ // convergería — no por un bug del driver, sino por un repo de test
293
+ // que nunca terminó su propio setup.
294
+ git(repo, 'add', '.gitignore');
295
+ git(repo, 'commit', '-qm', 'gitignore .awm');
296
+ const s = (0, store_1.readJournal)(repo, BRANCH).state;
297
+ s.trackContext = { trackId: 'a', taskIds: ['T1'], planDigest: '', baseSha: 'seed', planJournalId: 'j-plan' };
298
+ (0, store_1.writeJournal)(repo, BRANCH, s);
299
+ (0, requests_1.emitRequest)(repo, BRANCH, { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e1',
300
+ payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [{ id: 'v1', kind: 'test' }, { id: 'v-sensors', kind: 'sensors' }], reviewObligations: [{ id: 'o-spec', kind: 'spec' }, { id: 'o-quality', kind: 'quality' }] } });
301
+ // argv DISTINTOS a propósito: mismo argv/paths/cwd => mismo
302
+ // fingerprint => `apply.ts` colapsa el segundo `job-request` en el
303
+ // mismo job del primero (RNF-T.7, "un mismo resultado satisface más
304
+ // de un item") — acá se quieren DOS jobs reales y vivos para
305
+ // ejercitar el drenaje del freeze (paso 2), no una sola ejecución
306
+ // compartida.
307
+ (0, request_1.requestJob)(repo, BRANCH, 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'v1' });
308
+ (0, request_1.requestJob)(repo, BRANCH, 'g0', ['node', '-e', '0; process.exit(0)'], [], '.', { satisfies: 'v-sensors' });
309
+ emitVerdict('o-spec', 'verd-spec');
310
+ emitVerdict('o-quality', 'verd-quality');
311
+ (0, requests_1.emitRequest)(repo, BRANCH, { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e3',
312
+ payload: { entity: 'task-status', taskId: 'T1', status: 'done' } });
313
+ }
314
+ function requestFreeze() {
315
+ (0, requests_1.emitRequest)(repo, BRANCH, { kind: 'track-freeze-request', generationToken: 'no-active-generation', idempotencyKey: 'freeze-a',
316
+ payload: { trackId: 'a', fencingToken: 'fa' } });
317
+ }
318
+ async function tickUntil(sup, predicate, maxTicks = 400) {
319
+ let outcome = 'continue';
320
+ for (let i = 0; i < maxTicks && !predicate(); i++) {
321
+ outcome = await sup.tick();
322
+ if (predicate())
323
+ break;
324
+ await new Promise((r) => setTimeout(r, 15));
325
+ }
326
+ return outcome;
327
+ }
328
+ test('freeze completo: drena jobs vivos, exige gate local + worktree limpio, termina la generación propia, persiste frozen (R5.2/R6.3)', async () => {
329
+ setUpGreenTrack(); // sin freeze todavía: el track hace su trabajo real primero, como en el mundo real
330
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
331
+ const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner);
332
+ // Drenar el trabajo real (2 jobs) ANTES de que el plan pida el
333
+ // freeze — mismo orden que el mundo real (freeze llega cuando el
334
+ // track ya terminó su verificación).
335
+ await tickUntil(sup, () => {
336
+ const s = (0, store_1.readJournal)(repo, BRANCH).state;
337
+ return Object.values(s.jobs).length === 2 && Object.values(s.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass');
338
+ });
339
+ requestFreeze();
340
+ const outcome = await tickUntil(sup, () => (0, store_1.readJournal)(repo, BRANCH).state.frozen !== undefined);
341
+ expect(outcome).toBe('frozen');
342
+ const final = (0, store_1.readJournal)(repo, BRANCH).state;
343
+ expect(final.freezeRequested).toBe(true);
344
+ expect(final.frozen).toBeDefined();
345
+ expect(final.frozen.headSha).toBe((0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim());
346
+ expect(final.generations.every((g) => g.state === 'terminated' || g.state === 'superseded')).toBe(true);
347
+ // Restart-safety: un tick más, ya con `frozen` persistido, jamás
348
+ // relanza nada — reafirma el mismo resultado terminal.
349
+ expect(await sup.tick()).toBe('frozen');
350
+ });
351
+ test('job varado en spawn-intent con refs muertas justo al pedirse el freeze SI se reintenta y drena — el freeze converge en vez de quedar en `continue` para siempre (regresión R6.3: `dispatch:false` no debe suprimir retry-same-intent)', async () => {
352
+ setUpGreenTrack();
353
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
354
+ const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner);
355
+ await tickUntil(sup, () => {
356
+ const s = (0, store_1.readJournal)(repo, BRANCH).state;
357
+ return Object.values(s.jobs).length === 2 && Object.values(s.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass');
358
+ });
359
+ // Simular un wrapper que crasheo EXACTAMENTE cuando se pidio el
360
+ // freeze: el job queda en `spawn-intent` con refs de PID que no
361
+ // existen y SIN ningun sidecar (`claim`/`identity`/`result`) en
362
+ // disco — `reconcileJobs` lo clasifica `never-started` =>
363
+ // `retry-same-intent`. Este job NO participa de ningun
364
+ // `verificationPlan` (el gate ya certifica con los 2 jobs de
365
+ // `setUpGreenTrack`) — lo unico que debe bloquear la convergencia
366
+ // del freeze es que siga contando como LIVE en `attemptFreeze`.
367
+ const dead = { pid: 999999, startTime: 'gone', spawnNonce: 'stuck-nonce', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
368
+ const s1 = (0, store_1.readJournal)(repo, BRANCH).state;
369
+ s1.jobs['stuck-job'] = {
370
+ id: 'stuck-job', fingerprint: 'fp', commandDigest: 'cd', argv: ['node', '-e', 'process.exit(0)'], cwd: '.',
371
+ paths: [], expandedPaths: [], executionState: 'spawn-intent', observationState: 'progressing',
372
+ spawnNonce: 'stuck-nonce', processRef: dead, wrapperRef: dead,
373
+ phaseTimestamps: { received: new Date(Date.now() - 120000).toISOString(), 'spawn-intent': new Date(Date.now() - 120000).toISOString() },
374
+ };
375
+ (0, store_1.writeJournal)(repo, BRANCH, s1);
376
+ requestFreeze();
377
+ // Con el bug original, `dispatch:false` tambien suprimia el retry
378
+ // de `stuck-job`: jamas saldria de `spawn-intent`, `liveJobs` jamas
379
+ // llegaria a 0 en `attemptFreeze` y el freeze quedaria varado en
380
+ // `'continue'` para siempre (deadlock). Con el fix, el retry corre
381
+ // igual (drenaje de trabajo YA en vuelo, no arranque de trabajo
382
+ // nuevo) y el job converge a un estado terminal.
383
+ const outcome = await tickUntil(sup, () => (0, store_1.readJournal)(repo, BRANCH).state.frozen !== undefined);
384
+ expect(outcome).toBe('frozen');
385
+ const final = (0, store_1.readJournal)(repo, BRANCH).state;
386
+ expect(final.jobs['stuck-job'].executionState).toBe('exited');
387
+ expect(final.jobs['stuck-job'].verdict).toBe('pass');
388
+ expect(final.frozen).toBeDefined();
389
+ expect(final.frozen.headSha).toBe((0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim());
390
+ });
391
+ test('deja de despachar trabajo NUEVO mientras el freeze está pendiente: un job-request llega pero no se spawnea (paso 1)', async () => {
392
+ (0, init_1.initWatch)(repo, BRANCH);
393
+ const s = (0, store_1.readJournal)(repo, BRANCH).state;
394
+ s.trackContext = { trackId: 'a', taskIds: [], planDigest: '', baseSha: 'seed', planJournalId: 'j-plan' };
395
+ (0, store_1.writeJournal)(repo, BRANCH, s);
396
+ requestFreeze();
397
+ let spawnCalls = 0;
398
+ const countingSpawner = () => { spawnCalls++; };
399
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
400
+ const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, countingSpawner);
401
+ await sup.tick(); // consume el freeze-request => freezeRequested:true
402
+ (0, request_1.requestJob)(repo, BRANCH, 'g0', ['node', '-e', 'process.exit(0)'], [], '.');
403
+ await sup.tick();
404
+ await sup.tick();
405
+ expect(spawnCalls).toBe(0);
406
+ const after = (0, store_1.readJournal)(repo, BRANCH).state;
407
+ expect(after.freezeRequested).toBe(true);
408
+ expect(Object.values(after.jobs)[0].executionState).toBe('received'); // nunca avanzó a spawn-intent
409
+ });
410
+ test('worktree sucio bloquea la finalización del freeze hasta que se commitea (paso 4)', async () => {
411
+ setUpGreenTrack();
412
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
413
+ const sup = new supervisor_1.Supervisor(repo, BRANCH, cfg, fakeSpawner);
414
+ await tickUntil(sup, () => {
415
+ const s = (0, store_1.readJournal)(repo, BRANCH).state;
416
+ return Object.values(s.jobs).length === 2 && Object.values(s.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass');
417
+ });
418
+ requestFreeze();
419
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'dirty.txt'), 'sin commitear'); // untracked: worktree sucio
420
+ let sawFrozenWhileDirty = false;
421
+ for (let i = 0; i < 20; i++) {
422
+ const outcome = await sup.tick();
423
+ if (outcome === 'frozen')
424
+ sawFrozenWhileDirty = true;
425
+ await new Promise((r) => setTimeout(r, 15));
426
+ }
427
+ expect(sawFrozenWhileDirty).toBe(false);
428
+ expect((0, store_1.readJournal)(repo, BRANCH).state.frozen).toBeUndefined();
429
+ // Se resuelve lo pendiente QUITANDO el archivo sin commitear (en vez
430
+ // de commitearlo): commitear un archivo NUEVO cambia el árbol que
431
+ // las evidencias ya persistidas (`v1`/`v-sensors`) fingerprintearon,
432
+ // invalidándolas de verdad (`stale-fingerprint`) — comportamiento
433
+ // CORRECTO del gate, pero un concern DISTINTO al que este test
434
+ // ejercita (paso 4, limpieza — no re-vigencia de evidencia, ya
435
+ // cubierta en `gate.test.ts`). Quitar el archivo deja el árbol
436
+ // BYTE A BYTE igual al que las evidencias ya certificaron.
437
+ fs_1.default.rmSync(path_1.default.join(repo, 'dirty.txt'));
438
+ const outcome = await tickUntil(sup, () => (0, store_1.readJournal)(repo, BRANCH).state.frozen !== undefined);
439
+ expect(outcome).toBe('frozen');
440
+ });
441
+ });
442
+ // --- Parte 3: loop end-to-end de DOS Supervisors reales ---------------------
443
+ //
444
+ // Las Partes 1 y 2 prueban, cada una con git real, solo UNA mitad del loop
445
+ // completo: Parte 1 fuerza `frozen` directamente en el journal del track
446
+ // (nunca deja que un `Supervisor` de track real lo produzca) y Parte 2
447
+ // inyecta la request via `emitRequest` crudo (nunca deja que un driver real
448
+ // del lado del PLAN, `reconcileTracks`, la emita). Este describe cierra esa
449
+ // brecha: DOS worktrees reales (uno para el plan, uno para el track, mismo
450
+ // object store via `git worktree add`), un `Supervisor` de track REAL cuyo
451
+ // `.tick()` se llama en loop hasta drenar/gatear/limpiar/terminar/persistir
452
+ // `frozen` por si mismo, y `reconcileTracks` (el mismo driver que
453
+ // `Supervisor.tick()` del PLAN llama en producción) llamado en loop del lado
454
+ // del plan hasta que observa y acepta ese `frozen` — probando que las dos
455
+ // mitades, cada una ya cubierta por separado arriba, cierran el loop de
456
+ // verdad cuando corren juntas.
457
+ describe('loop end-to-end: plan (reconcileTracks) + track (Supervisor.tick) cierran el freeze juntos', () => {
458
+ jest.setTimeout(60000);
459
+ const PLAN_BRANCH = 'main';
460
+ const TRACK_BRANCH = 'awm-track/a';
461
+ const fakeSpawner = (job, nonce, logsRoot, repoRoot) => {
462
+ void (0, exec_wrapper_1.runExecWrapper)({ logsRoot, jobId: job.id, nonce, argv: job.argv, cwd: job.cwd, repoRoot }).catch(() => { });
463
+ };
464
+ let planRoot;
465
+ let trackWorktree;
466
+ let baseSha;
467
+ let stubBin;
468
+ let oldPath;
469
+ beforeEach(() => {
470
+ planRoot = (0, git_fixture_1.initRepo)();
471
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n'); // `.awm` ya ignorado ANTES de crear el worktree del track
472
+ baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
473
+ trackWorktree = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-e2e-freeze-wt-'));
474
+ fs_1.default.rmdirSync(trackWorktree);
475
+ git(planRoot, 'worktree', 'add', '-b', TRACK_BRANCH, trackWorktree, baseSha);
476
+ // El track necesita sus propios verificadores REALES (mismo criterio
477
+ // que `setUpGreenTrack` de la Parte 2): `package.json` con script
478
+ // `test` (rastreado — coincide con la clase global `manifest`, a
479
+ // proposito: no es el foco de ESTE test, solo confirma que ese
480
+ // consumidor no interfiere con el loop) y `.awm/sensors.json`
481
+ // (ignorado por el `.gitignore` heredado de `baseSha`, no necesita
482
+ // commit — `detectRequiredVerifiers` lee el filesystem, no git).
483
+ fs_1.default.writeFileSync(path_1.default.join(trackWorktree, 'package.json'), JSON.stringify({ scripts: { test: 'node -e "process.exit(0)"' } }));
484
+ git(trackWorktree, 'add', 'package.json');
485
+ git(trackWorktree, 'commit', '-qm', 'package.json');
486
+ fs_1.default.mkdirSync(path_1.default.join(trackWorktree, '.awm'), { recursive: true });
487
+ fs_1.default.writeFileSync(path_1.default.join(trackWorktree, '.awm', 'sensors.json'), '{}');
488
+ stubBin = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-e2e-stub-'));
489
+ fs_1.default.writeFileSync(path_1.default.join(stubBin, 'codex'), '#!/bin/sh\nwhile true; do sleep 1; done\n', { mode: 0o755 });
490
+ oldPath = process.env.PATH;
491
+ process.env.PATH = `${stubBin}:${process.env.PATH}`;
492
+ });
493
+ afterEach(() => {
494
+ process.env.PATH = oldPath;
495
+ try {
496
+ (0, child_process_1.execFileSync)('git', ['worktree', 'remove', '--force', trackWorktree], { cwd: planRoot, stdio: 'pipe' });
497
+ }
498
+ catch { /* best-effort */ }
499
+ try {
500
+ (0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: planRoot, stdio: 'pipe' });
501
+ }
502
+ catch { /* best-effort */ }
503
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true });
504
+ fs_1.default.rmSync(trackWorktree, { recursive: true, force: true });
505
+ fs_1.default.rmSync(stubBin, { recursive: true, force: true });
506
+ });
507
+ function emitVerdict(obligationId, verdictId) {
508
+ const argv = ['awm-review', obligationId];
509
+ const fp = (0, fingerprint_1.computeFingerprint)(trackWorktree, argv, [], '.').fingerprint;
510
+ (0, requests_1.emitRequest)(trackWorktree, TRACK_BRANCH, { kind: 'verdict', generationToken: 'g0', idempotencyKey: verdictId,
511
+ payload: { verdictId, obligationId, result: 'pass', detail: 'ok', fingerprint: fp, argv, paths: [], cwd: '.' } });
512
+ }
513
+ test('reconcileTracks real (plan) + Supervisor.tick real (track), en loop contra dos worktrees reales, cierran request -> drenaje -> persist -> observación -> FROZEN', async () => {
514
+ // --- lado TRACK: registrar el trabajo y drenarlo con un Supervisor REAL ---
515
+ (0, init_1.initWatch)(trackWorktree, TRACK_BRANCH);
516
+ const ts0 = (0, store_1.readJournal)(trackWorktree, TRACK_BRANCH).state;
517
+ ts0.trackContext = { trackId: 'a', taskIds: ['T1'], planDigest: '', baseSha, planJournalId: 'j-plan' };
518
+ (0, store_1.writeJournal)(trackWorktree, TRACK_BRANCH, ts0);
519
+ (0, requests_1.emitRequest)(trackWorktree, TRACK_BRANCH, { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e1',
520
+ payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [{ id: 'v1', kind: 'test' }, { id: 'v-sensors', kind: 'sensors' }], reviewObligations: [{ id: 'o-spec', kind: 'spec' }, { id: 'o-quality', kind: 'quality' }] } });
521
+ (0, request_1.requestJob)(trackWorktree, TRACK_BRANCH, 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'v1' });
522
+ (0, request_1.requestJob)(trackWorktree, TRACK_BRANCH, 'g0', ['node', '-e', '0; process.exit(0)'], [], '.', { satisfies: 'v-sensors' });
523
+ emitVerdict('o-spec', 'verd-spec');
524
+ emitVerdict('o-quality', 'verd-quality');
525
+ (0, requests_1.emitRequest)(trackWorktree, TRACK_BRANCH, { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e3',
526
+ payload: { entity: 'task-status', taskId: 'T1', status: 'done' } });
527
+ const trackCfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
528
+ const trackSup = new supervisor_1.Supervisor(trackWorktree, TRACK_BRANCH, trackCfg, fakeSpawner);
529
+ for (let i = 0; i < 400; i++) {
530
+ const s = (0, store_1.readJournal)(trackWorktree, TRACK_BRANCH).state;
531
+ if (Object.values(s.jobs).length === 2 && Object.values(s.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass'))
532
+ break;
533
+ await trackSup.tick();
534
+ await new Promise((r) => setTimeout(r, 15));
535
+ }
536
+ const drained = (0, store_1.readJournal)(trackWorktree, TRACK_BRANCH).state;
537
+ expect(Object.values(drained.jobs)).toHaveLength(2);
538
+ expect(Object.values(drained.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass')).toBe(true);
539
+ // --- lado PLAN: journal real, cohorte ACTIVE con track 'a' en JOIN_REQUESTED ---
540
+ (0, store_1.initJournal)(planRoot, PLAN_BRANCH);
541
+ let planState = (0, store_1.readJournal)(planRoot, PLAN_BRANCH).state;
542
+ planState.cohortPhase = 'ACTIVE';
543
+ planState.cohortBaseSha = baseSha;
544
+ planState.tracks = [
545
+ {
546
+ trackId: 'a', worktreePath: trackWorktree, branch: TRACK_BRANCH,
547
+ ownership: [], sharedResources: [], dependsOn: [],
548
+ fencingToken: 'fence-a'.padEnd(32, '0'), phase: 'JOIN_REQUESTED', readinessNonce: 'ready-a'.padEnd(32, '0'),
549
+ },
550
+ {
551
+ // Segundo track: satisface el minimo de 2 de `initialCohort`
552
+ // y se mantiene ACTIVE — no participa de este freeze.
553
+ trackId: 'b', worktreePath: path_1.default.join(planRoot, 'nope-b'), branch: 'awm-track/b',
554
+ ownership: [], sharedResources: [], dependsOn: [],
555
+ fencingToken: 'fence-b'.padEnd(32, '0'), phase: 'ACTIVE', readinessNonce: 'ready-b'.padEnd(32, '0'),
556
+ },
557
+ ];
558
+ (0, store_1.writeJournal)(planRoot, PLAN_BRANCH, planState);
559
+ planState = (0, store_1.readJournal)(planRoot, PLAN_BRANCH).state;
560
+ // Runtime REAL (no un fake): `emitFreezeRequest` es el unico touch
561
+ // real que este loop necesita — el mismo `defaultTrackRuntime` que
562
+ // `Supervisor` del plan inyecta en producción.
563
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, PLAN_BRANCH);
564
+ // --- el loop en si: plan emite/observa, track real drena/gatea/termina ---
565
+ let reachedFrozen = false;
566
+ for (let i = 0; i < 400 && !reachedFrozen; i++) {
567
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, PLAN_BRANCH, planState, runtime, 2);
568
+ planState = result.state;
569
+ await trackSup.tick();
570
+ const a = planState.tracks.find((t) => t.trackId === 'a');
571
+ if (a.phase === 'FROZEN' && a.frozenHeadSha !== undefined)
572
+ reachedFrozen = true;
573
+ await new Promise((r) => setTimeout(r, 15));
574
+ }
575
+ expect(reachedFrozen).toBe(true);
576
+ const trackHead = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: trackWorktree, encoding: 'utf8' }).trim();
577
+ const finalPlan = (0, store_1.readJournal)(planRoot, PLAN_BRANCH).state;
578
+ const a = finalPlan.tracks.find((t) => t.trackId === 'a');
579
+ expect(a.phase).toBe('FROZEN');
580
+ expect(a.frozenHeadSha).toBe(trackHead);
581
+ expect(finalPlan.cohortPhase).toBe('JOINING');
582
+ // El track propio confirma el mismo hecho, INDEPENDIENTEMENTE — dos
583
+ // journals distintos, ambos de acuerdo (el plan jamás escribió el
584
+ // journal del track: solo la request cross-worktree y su propia
585
+ // observación read-only).
586
+ const trackFinal = (0, store_1.readJournal)(trackWorktree, TRACK_BRANCH).state;
587
+ expect(trackFinal.frozen).toBeDefined();
588
+ expect(trackFinal.frozen.headSha).toBe(trackHead);
589
+ expect(trackFinal.freezeRequested).toBe(true);
590
+ });
591
+ });