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,350 @@
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 8 (R5-T8): P1 bootstrap durable + barrera ARMED. Estos tests conducen
7
+ // `reconcileTracks` tick a tick contra un `TrackRuntime` FAKE — ningún git ni
8
+ // proceso real corre acá (eso es Task 9's `realGitHarness`). El único I/O
9
+ // real es el propio journal del plan (tmpdir) y los directorios que
10
+ // representan cada worktree (para poder probar la detección de "ajeno" con
11
+ // `fs` real, tal como lo hace `foreignPathExists` en producción).
12
+ const fs_1 = __importDefault(require("fs"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const os_1 = __importDefault(require("os"));
15
+ const tracks_1 = require("../../../src/commands/watch/tracks");
16
+ const store_1 = require("../../../src/core/journal/store");
17
+ const paths_1 = require("../../../src/core/journal/paths");
18
+ const BRANCH = 'main';
19
+ /** `reconcileTracks` decide spawn-vs-observar mirando `ref.supervisorProcessRef`
20
+ * (post-review fix) — un fake que devolviera `undefined` desde `spawnSupervisor`
21
+ * jamás dejaría ese campo seteado y el driver reintentaría el spawn para
22
+ * siempre. Un `ProcessRef` real de mentira alcanza; nada de esto se usa para
23
+ * identidad real en estos tests (esa es responsabilidad de `defaultTrackRuntime`). */
24
+ function fakeProcessRef(trackId) {
25
+ return { pid: 1, startTime: 'x', spawnNonce: trackId, argvDigest: 'x', processGroup: 1, psArgsDigest: 'x' };
26
+ }
27
+ function harness(trackIds, opts = {}) {
28
+ const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-bootstrap-'));
29
+ const planRoot = path_1.default.join(root, 'plan');
30
+ fs_1.default.mkdirSync(planRoot, { recursive: true });
31
+ (0, store_1.initJournal)(planRoot, BRANCH);
32
+ const s0 = (0, store_1.readJournal)(planRoot, BRANCH).state;
33
+ s0.cohortPhase = 'PREPARING';
34
+ s0.cohortBaseSha = 'base-sha';
35
+ s0.tracks = trackIds.map((id) => ({
36
+ trackId: id,
37
+ worktreePath: path_1.default.join(root, `track-${id}`),
38
+ branch: `awm-track/${id}`,
39
+ ownership: [], sharedResources: [], dependsOn: [],
40
+ fencingToken: `fence-${id}`.padEnd(32, '0'),
41
+ phase: 'DECLARED',
42
+ readinessNonce: `ready-${id}`.padEnd(32, '0'),
43
+ }));
44
+ (0, store_1.writeJournal)(planRoot, BRANCH, s0);
45
+ const dispatches = [];
46
+ const spawned = new Set();
47
+ const readinessOverrides = new Map();
48
+ const runtime = {
49
+ addWorktree(_planRootArg, ref) {
50
+ fs_1.default.mkdirSync(ref.worktreePath, { recursive: true });
51
+ fs_1.default.writeFileSync(path_1.default.join(ref.worktreePath, '.git'), 'gitdir: fake\n');
52
+ },
53
+ initTrackJournal(ref) {
54
+ fs_1.default.mkdirSync(path_1.default.join(ref.worktreePath, '.awm'), { recursive: true });
55
+ },
56
+ spawnSupervisor(ref) {
57
+ spawned.add(ref.trackId);
58
+ return fakeProcessRef(ref.trackId);
59
+ },
60
+ observeSupervisor(ref) {
61
+ if (!spawned.has(ref.trackId))
62
+ return { kind: 'absent' };
63
+ const override = readinessOverrides.get(ref.trackId);
64
+ return { kind: 'ready', readinessNonce: override ?? ref.readinessNonce };
65
+ },
66
+ async stopOwnSupervisor() { return true; },
67
+ removeOwnedWorktree() { },
68
+ removeOwnedBranch() { },
69
+ emitFreezeRequest() { throw new Error('no debería llamarse (Task 10, sin cobertura en esta suite)'); },
70
+ mergeFrozenTrack() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
71
+ abortOwnedMerge() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
72
+ async ensureIntegrationLock() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
73
+ async pauseControllerGeneration() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
74
+ releaseIntegrationLockIfHeld() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
75
+ };
76
+ const maxParallelTracks = opts.maxParallelTracks ?? 2;
77
+ function state() { return (0, store_1.readJournal)(planRoot, BRANCH).state; }
78
+ function track(id) { return state().tracks.find((t) => t.trackId === id); }
79
+ // `reconcileTracks` ya escribe cada frontera a `events.jsonl` vía
80
+ // `appendEvent` (mismo canal de auditoría que el resto del supervisor) —
81
+ // leemos DE AHÍ en vez de inferir eventos por diffing de estado, para no
82
+ // acoplar el test a cuántas veces `reconcileTracks` loopeó internamente.
83
+ function readRawEvents() {
84
+ let raw = '';
85
+ try {
86
+ raw = fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, BRANCH), 'utf8');
87
+ }
88
+ catch {
89
+ return [];
90
+ }
91
+ return raw.split('\n').filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
92
+ }
93
+ function formatEvent(e) {
94
+ if (e.kind === 'track-protocol-persist') {
95
+ return e.trackId !== undefined ? `persist:${String(e.phase)}:${String(e.trackId)}` : `persist:cohort:${String(e.phase)}`;
96
+ }
97
+ if (e.kind === 'track-supervisor-intent')
98
+ return `persist:SUPERVISOR_STARTING:${String(e.trackId)}`;
99
+ if (e.kind === 'track-effect')
100
+ return `effect:${String(e.effect)}:${String(e.trackId)}`;
101
+ if (e.kind === 'track-effect-failed')
102
+ return `effect-failed:${String(e.effect)}:${String(e.trackId)}`;
103
+ if (e.kind === 'track-blocked')
104
+ return `blocked:${String(e.trackId)}`;
105
+ if (e.kind === 'track-armed-or-blocked')
106
+ return `observed:${String(e.trackId)}`;
107
+ return null;
108
+ }
109
+ return {
110
+ config: { maxParallelTracks },
111
+ events: () => readRawEvents().map(formatEvent).filter((x) => x !== null),
112
+ dispatches: () => [...dispatches],
113
+ state,
114
+ track,
115
+ async tick() {
116
+ return (0, tracks_1.reconcileTracks)(planRoot, BRANCH, state(), runtime, maxParallelTracks);
117
+ },
118
+ async runUntil(predicate, maxTicks = 100) {
119
+ for (let i = 0; i < maxTicks; i++) {
120
+ if (predicate(state()))
121
+ return;
122
+ await this.tick();
123
+ }
124
+ throw new Error('runUntil: la condición nunca se cumplió');
125
+ },
126
+ observeReadiness(trackId, nonce) {
127
+ const s = state();
128
+ const ref = s.tracks.find((t) => t.trackId === trackId);
129
+ ref.phase = 'SUPERVISOR_STARTING';
130
+ ref.supervisorIntent = {
131
+ nonce: 'fake-intent-nonce', argv: [],
132
+ claimPath: path_1.default.join(ref.worktreePath, '.awm', 'supervisor.claim'),
133
+ };
134
+ // Fast-forward a "ya spawneamos, ahora solo observamos" — sin esto
135
+ // el driver (post-review fix) vería `supervisorProcessRef`
136
+ // ausente y volvería a intentar spawnear en vez de consultar
137
+ // `observeSupervisor` directamente, que es lo que este test
138
+ // necesita ejercitar.
139
+ ref.supervisorProcessRef = fakeProcessRef(trackId);
140
+ (0, store_1.writeJournal)(planRoot, BRANCH, s);
141
+ spawned.add(trackId);
142
+ readinessOverrides.set(trackId, nonce);
143
+ },
144
+ placeForeignJournal(trackId) {
145
+ const ref = track(trackId);
146
+ fs_1.default.mkdirSync(ref.worktreePath, { recursive: true });
147
+ fs_1.default.writeFileSync(path_1.default.join(ref.worktreePath, 'foreign.marker'), 'ajeno\n');
148
+ },
149
+ foreignJournalExists(trackId) {
150
+ const ref = track(trackId);
151
+ return fs_1.default.existsSync(path_1.default.join(ref.worktreePath, 'foreign.marker'));
152
+ },
153
+ cleanup() { fs_1.default.rmSync(root, { recursive: true, force: true }); },
154
+ };
155
+ }
156
+ describe('reconcileTracks — P1 bootstrap durable + barrera ARMED (R4.1-R4.10, C1, C2, C8, C11)', () => {
157
+ let h = null;
158
+ afterEach(() => { h?.cleanup(); h = null; });
159
+ test('P1 persiste cada fase antes del side effect y activa solo con todos ARMED (R4.1, R4.4, C1)', async () => {
160
+ h = harness(['a', 'b']);
161
+ await h.tick();
162
+ expect(h.events()).toEqual([
163
+ 'persist:PREPARE_INTENT:a', 'effect:create-worktree:a',
164
+ ]);
165
+ await h.runUntil((s) => s.tracks?.every((t) => t.phase === 'ARMED') ?? false);
166
+ expect(h.state().cohortPhase).toBe('PREPARING'); // C1: ARMED por sí solo NUNCA activa la cohorte
167
+ expect(h.dispatches()).toEqual([]);
168
+ await h.tick();
169
+ expect(h.state().cohortPhase).toBe('ACTIVE');
170
+ expect(h.state().tracks?.filter((t) => t.phase === 'ACTIVE')).toHaveLength(h.config.maxParallelTracks);
171
+ });
172
+ test('readiness con nonce diferente bloquea sin despachar (R4.9, C8)', async () => {
173
+ h = harness(['a', 'b']);
174
+ h.observeReadiness('a', 'wrong');
175
+ await h.tick();
176
+ expect(h.track('a')).toMatchObject({ phase: 'BLOCKED', blockedReason: expect.stringContaining('readinessNonce') });
177
+ expect(h.dispatches()).toEqual([]);
178
+ });
179
+ test('worktree/lock ajeno nunca se borra (R4.3, R4.6)', async () => {
180
+ h = harness(['a', 'b']);
181
+ h.placeForeignJournal('a');
182
+ await h.tick();
183
+ expect(h.foreignJournalExists('a')).toBe(true);
184
+ expect(h.track('a').phase).toBe('BLOCKED');
185
+ });
186
+ test('Repro 1 (post-review fix, revisado en Task 9): persistir supervisorIntent es su propia frontera; observar y spawnear pueden compartir call (una lectura + a lo sumo una escritura), pero nunca hay dos escrituras en un mismo call', async () => {
187
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-repro1-'));
188
+ try {
189
+ (0, store_1.initJournal)(dir, BRANCH);
190
+ const s = (0, store_1.readJournal)(dir, BRANCH).state;
191
+ s.cohortPhase = 'PREPARING';
192
+ s.cohortBaseSha = 'base-sha';
193
+ s.tracks = [
194
+ {
195
+ trackId: 'a', worktreePath: path_1.default.join(dir, 'wt-a'), branch: 'awm-track/a',
196
+ ownership: [], sharedResources: [], dependsOn: [],
197
+ fencingToken: 'fa'.padEnd(32, '0'), phase: 'JOURNAL_CREATED', readinessNonce: 'ra'.padEnd(32, '0'),
198
+ },
199
+ {
200
+ trackId: 'b', worktreePath: path_1.default.join(dir, 'wt-b'), branch: 'awm-track/b',
201
+ ownership: [], sharedResources: [], dependsOn: [],
202
+ fencingToken: 'fb'.padEnd(32, '0'), phase: 'ARMED', readinessNonce: 'rb'.padEnd(32, '0'),
203
+ },
204
+ ];
205
+ (0, store_1.writeJournal)(dir, BRANCH, s);
206
+ let observeCalls = 0;
207
+ let spawnCalls = 0;
208
+ const runtime = {
209
+ addWorktree: () => { throw new Error('no debería llamarse'); },
210
+ initTrackJournal: () => { throw new Error('no debería llamarse'); },
211
+ spawnSupervisor: (ref) => { spawnCalls++; return fakeProcessRef(ref.trackId); },
212
+ // Simula el mundo real: 'absent' hasta que el (único) spawn
213
+ // ocurra, 'claimed' después — igual progresión que el wrapper
214
+ // real (claim se escribe recién tras el spawn).
215
+ observeSupervisor: () => { observeCalls++; return spawnCalls > 0 ? { kind: 'claimed' } : { kind: 'absent' }; },
216
+ async stopOwnSupervisor() { return true; },
217
+ removeOwnedWorktree() { },
218
+ removeOwnedBranch() { },
219
+ emitFreezeRequest() { throw new Error('no debería llamarse (Task 10, sin cobertura en esta suite)'); },
220
+ mergeFrozenTrack() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
221
+ abortOwnedMerge() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
222
+ async ensureIntegrationLock() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
223
+ async pauseControllerGeneration() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
224
+ releaseIntegrationLockIfHeld() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
225
+ };
226
+ // Call 1: solo persiste el supervisorIntent — CERO llamadas a runtime.
227
+ const afterIntent = await (0, tracks_1.reconcileTracks)(dir, BRANCH, (0, store_1.readJournal)(dir, BRANCH).state, runtime, 2);
228
+ expect(observeCalls).toBe(0);
229
+ expect(spawnCalls).toBe(0);
230
+ const trackA1 = afterIntent.state.tracks.find((t) => t.trackId === 'a');
231
+ expect(trackA1.phase).toBe('SUPERVISOR_STARTING');
232
+ expect(trackA1.supervisorIntent).toBeDefined();
233
+ expect(trackA1.supervisorProcessRef).toBeUndefined();
234
+ // Call 2: supervisorIntent ya existe, supervisorProcessRef todavía
235
+ // no — Task 9 SIEMPRE observa antes de decidir (read-only, no
236
+ // cuenta como el side effect del tick, mismo criterio que
237
+ // `foreignPathExists` en `runCreateWorktree`): la observación
238
+ // confirma 'absent' y RECIÉN AHÍ se spawnea, exactamente una vez,
239
+ // dentro del MISMO call — una lectura + a lo sumo UNA escritura,
240
+ // nunca dos escrituras (eso seguiría siendo el bug original).
241
+ const afterSpawn = await (0, tracks_1.reconcileTracks)(dir, BRANCH, afterIntent.state, runtime, 2);
242
+ expect(observeCalls).toBe(1);
243
+ expect(spawnCalls).toBe(1);
244
+ const trackA2 = afterSpawn.state.tracks.find((t) => t.trackId === 'a');
245
+ expect(trackA2.supervisorProcessRef).toBeDefined();
246
+ // Call 3: supervisorProcessRef ya persistido — se vuelve a
247
+ // observar (ahora 'claimed') y NUNCA se vuelve a spawnear.
248
+ await (0, tracks_1.reconcileTracks)(dir, BRANCH, afterSpawn.state, runtime, 2);
249
+ expect(observeCalls).toBe(2);
250
+ expect(spawnCalls).toBe(1);
251
+ }
252
+ finally {
253
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
254
+ }
255
+ });
256
+ test('Repro 2 (post-review fix): bloquear un track ajeno nunca se combina, en el mismo call, con el create-worktree de otro track', async () => {
257
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-repro2-'));
258
+ try {
259
+ (0, store_1.initJournal)(dir, BRANCH);
260
+ const s = (0, store_1.readJournal)(dir, BRANCH).state;
261
+ s.cohortPhase = 'PREPARING';
262
+ s.cohortBaseSha = 'base-sha';
263
+ const worktreeA = path_1.default.join(dir, 'wt-a');
264
+ const worktreeB = path_1.default.join(dir, 'wt-b');
265
+ fs_1.default.mkdirSync(worktreeA, { recursive: true });
266
+ fs_1.default.writeFileSync(path_1.default.join(worktreeA, 'foreign.marker'), 'ajeno\n');
267
+ s.tracks = [
268
+ {
269
+ trackId: 'a', worktreePath: worktreeA, branch: 'awm-track/a',
270
+ ownership: [], sharedResources: [], dependsOn: [],
271
+ fencingToken: 'fa'.padEnd(32, '0'), phase: 'PREPARE_INTENT', readinessNonce: 'ra'.padEnd(32, '0'),
272
+ },
273
+ {
274
+ trackId: 'b', worktreePath: worktreeB, branch: 'awm-track/b',
275
+ ownership: [], sharedResources: [], dependsOn: [],
276
+ fencingToken: 'fb'.padEnd(32, '0'), phase: 'DECLARED', readinessNonce: 'rb'.padEnd(32, '0'),
277
+ },
278
+ ];
279
+ (0, store_1.writeJournal)(dir, BRANCH, s);
280
+ let addWorktreeCalls = 0;
281
+ const runtime = {
282
+ addWorktree: () => { addWorktreeCalls++; },
283
+ initTrackJournal: () => { throw new Error('no debería llamarse'); },
284
+ spawnSupervisor: () => { throw new Error('no debería llamarse'); },
285
+ observeSupervisor: () => ({ kind: 'absent' }),
286
+ async stopOwnSupervisor() { return true; },
287
+ removeOwnedWorktree() { },
288
+ removeOwnedBranch() { },
289
+ emitFreezeRequest() { throw new Error('no debería llamarse (Task 10, sin cobertura en esta suite)'); },
290
+ mergeFrozenTrack() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
291
+ abortOwnedMerge() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
292
+ async ensureIntegrationLock() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
293
+ async pauseControllerGeneration() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
294
+ releaseIntegrationLockIfHeld() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
295
+ };
296
+ const result = await (0, tracks_1.reconcileTracks)(dir, BRANCH, (0, store_1.readJournal)(dir, BRANCH).state, runtime, 2);
297
+ // 'a' se bloquea por ajeno; 'b' NO se toca en absoluto en este call
298
+ // (ni siquiera su persist-prepare-intent, puramente en memoria).
299
+ expect(addWorktreeCalls).toBe(0);
300
+ const a = result.state.tracks.find((t) => t.trackId === 'a');
301
+ const b = result.state.tracks.find((t) => t.trackId === 'b');
302
+ expect(a.phase).toBe('BLOCKED');
303
+ expect(b.phase).toBe('DECLARED');
304
+ }
305
+ finally {
306
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
307
+ }
308
+ });
309
+ test('reconcileTracks es no-op mientras la cohorte no está declarada (< 2 tracks o sin cohortPhase)', async () => {
310
+ h = harness(['a', 'b']);
311
+ // Journal recién creado sin tracks/cohortPhase: reconcileTracks jamás
312
+ // debe invocar `nextProtocolEffect` (que exigiría >= 2 tracks) sobre
313
+ // un estado que todavía no declaró la cohorte completa.
314
+ const bare = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-bare-'));
315
+ try {
316
+ (0, store_1.initJournal)(bare, BRANCH);
317
+ const bareState = (0, store_1.readJournal)(bare, BRANCH).state;
318
+ const fakeRuntime = {
319
+ addWorktree: () => { throw new Error('no debería llamarse'); },
320
+ initTrackJournal: () => { throw new Error('no debería llamarse'); },
321
+ spawnSupervisor: () => { throw new Error('no debería llamarse'); },
322
+ observeSupervisor: () => ({ kind: 'absent' }),
323
+ async stopOwnSupervisor() { throw new Error('no debería llamarse'); },
324
+ removeOwnedWorktree() { throw new Error('no debería llamarse'); },
325
+ removeOwnedBranch() { throw new Error('no debería llamarse'); },
326
+ emitFreezeRequest() { throw new Error('no debería llamarse (Task 10, sin cobertura en esta suite)'); },
327
+ mergeFrozenTrack() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
328
+ abortOwnedMerge() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
329
+ async ensureIntegrationLock() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
330
+ async pauseControllerGeneration() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
331
+ releaseIntegrationLockIfHeld() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
332
+ };
333
+ const result = await (0, tracks_1.reconcileTracks)(bare, BRANCH, bareState, fakeRuntime, 1);
334
+ expect(result.effectExecuted).toBeNull();
335
+ expect(result.state).toEqual(bareState);
336
+ }
337
+ finally {
338
+ fs_1.default.rmSync(bare, { recursive: true, force: true });
339
+ }
340
+ });
341
+ test('activate-track respeta maxParallelTracks: un tercer track ARMED espera su turno (R10.2/R10.3)', async () => {
342
+ h = harness(['a', 'b', 'c'], { maxParallelTracks: 2 });
343
+ await h.runUntil((s) => s.tracks?.every((t) => t.phase === 'ARMED') ?? false, 200);
344
+ await h.tick();
345
+ const tracks = h.state().tracks;
346
+ expect(tracks.filter((t) => t.phase === 'ACTIVE')).toHaveLength(2);
347
+ expect(tracks.filter((t) => t.phase === 'ARMED')).toHaveLength(1);
348
+ expect(h.state().cohortPhase).toBe('ACTIVE');
349
+ });
350
+ });