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
@@ -124,6 +124,28 @@ describe('runner concurrente', () => {
124
124
  expect(s.jobs['j1'].spawnNonce).toBe('nunca-claimeo');
125
125
  expect(calls).toEqual([{ id: 'j1', nonce: 'nunca-claimeo' }]);
126
126
  });
127
+ test('dispatch:false retiene el retry-same-intent de un intent YA vivo, solo bloquea el spawn de jobs `received` (regresión R6.3 — el freeze no debe deadlockear en spawn-intent)', () => {
128
+ // Job `received`: trabajo GENUINAMENTE nuevo — debe quedar frenado.
129
+ seedJob(repo, { id: 'fresh', executionState: 'received' });
130
+ // Job `spawn-intent` sin claim, fuera de gracia: intent YA decidido
131
+ // ANTES del freeze (ej. un wrapper que crasheo justo al pedirse) —
132
+ // `reconcileJobs` lo clasifica `never-started` => `retry-same-intent`.
133
+ // Eso es DRENAJE de trabajo en vuelo, no arranque de trabajo nuevo:
134
+ // debe seguir corriendo aunque `dispatch:false`.
135
+ seedJob(repo, {
136
+ id: 'stuck', executionState: 'spawn-intent', spawnNonce: 'stuck-nonce',
137
+ phaseTimestamps: { 'spawn-intent': new Date(Date.now() - 60000).toISOString() },
138
+ });
139
+ const retried = [];
140
+ const out = (0, runner_1.runnerTick)(repo, 'rama', (job) => { retried.push(job.id); }, { reconcileGraceMs: 1000, dispatch: false });
141
+ expect(out.decisions.find((d) => d.jobId === 'stuck' && d.action === 'retry-same-intent')).toBeDefined();
142
+ expect(retried).toEqual(['stuck']); // el retry del intent ya vivo SI corrio...
143
+ expect(out.spawned).toBe(0); // ...pero ningun job nuevo se despacho
144
+ const s = (0, store_1.readJournal)(repo, 'rama').state;
145
+ expect(s.jobs['fresh'].executionState).toBe('received'); // nunca avanzo a spawn-intent
146
+ expect(s.jobs['stuck'].executionState).toBe('spawn-intent'); // mismo intent, reintentado, no abandonado
147
+ expect(s.jobs['stuck'].spawnNonce).toBe('stuck-nonce');
148
+ });
127
149
  test('claim sin resultado con procesos muertos => orphaned, jamas relanzar (R1.8)', () => {
128
150
  const dead = { pid: 999999, startTime: 'gone', spawnNonce: 'nZ', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
129
151
  seedJob(repo, {
@@ -17,6 +17,7 @@ const store_1 = require("../../../src/core/journal/store");
17
17
  const paths_1 = require("../../../src/core/journal/paths");
18
18
  const process_1 = require("../../../src/core/journal/process");
19
19
  const fingerprint_1 = require("../../../src/core/journal/fingerprint");
20
+ const tracks_1 = require("../../../src/commands/watch/tracks");
20
21
  jest.setTimeout(60000);
21
22
  // runSupervisorLoop's full external-controller lifecycle (spawn stub codex ->
22
23
  // identity captured by the wrapper -> adopted via collectControllerGeneration's
@@ -62,6 +63,83 @@ async function until(fn, ms = 30000) {
62
63
  await new Promise((r) => setTimeout(r, 50));
63
64
  }
64
65
  }
66
+ /** Mismo patron que `track-bootstrap-crash.test.ts::fakeProcessRef` — un
67
+ * `ProcessRef` sintetico (nunca un proceso real) para trackear cuantas veces
68
+ * se "spawneo" cada supervisor de track sin depender de wrappers reales. */
69
+ function fakeProcessRef(trackId, n) {
70
+ return { pid: 1, startTime: `x-${n}`, spawnNonce: trackId, argvDigest: 'x', processGroup: 1, psArgsDigest: `x-${n}` };
71
+ }
72
+ /** Runtime para forzar un fallback-a-SERIAL REAL (Finding 2, ronda 2 de
73
+ * re-review de Task 12): `addWorktree`/`teardownOwned` delegan al
74
+ * `defaultTrackRuntime` de verdad (git real, mismo criterio que
75
+ * `track-bootstrap-crash.test.ts::buildRuntime`) — solo `spawnSupervisor`/
76
+ * `observeSupervisor` quedan fake, porque a esta prueba no le importa la
77
+ * mecanica de un wrapper detached, sino que la cohorte atraviese el reducer
78
+ * real (`nextProtocolEffect`/`reconcileTracks`) hasta `enter-serial`. El
79
+ * worktree de `failWorktreeFor` falla SIEMPRE — el mismo disparador que usa
80
+ * Task 9 para su `'fallo del segundo track limpia el primero antes de
81
+ * serializar'`. */
82
+ function buildFallbackRuntime(planRoot, wrapperState, spawnCalls, failWorktreeFor) {
83
+ const real = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
84
+ return {
85
+ addWorktree(root, ref, baseSha) {
86
+ if (ref.trackId === failWorktreeFor)
87
+ throw new Error(`fallo inyectado: create-worktree de ${ref.trackId}`);
88
+ real.addWorktree(root, ref, baseSha);
89
+ },
90
+ initTrackJournal(ref, context) { real.initTrackJournal(ref, context); },
91
+ spawnSupervisor(ref) {
92
+ const n = (spawnCalls.get(ref.trackId) ?? 0) + 1;
93
+ spawnCalls.set(ref.trackId, n);
94
+ wrapperState.set(ref.trackId, 'claimed');
95
+ return fakeProcessRef(ref.trackId, n);
96
+ },
97
+ observeSupervisor(ref) {
98
+ const st = wrapperState.get(ref.trackId) ?? 'absent';
99
+ if (st === 'absent')
100
+ return { kind: 'absent' };
101
+ if (st === 'ready')
102
+ return { kind: 'ready', readinessNonce: ref.readinessNonce };
103
+ wrapperState.set(ref.trackId, 'ready');
104
+ return { kind: 'claimed' };
105
+ },
106
+ async stopOwnSupervisor() { return true; },
107
+ removeOwnedWorktree(repo, ref) { real.removeOwnedWorktree(repo, ref); },
108
+ removeOwnedBranch(repo, branch) { real.removeOwnedBranch(repo, branch); },
109
+ emitFreezeRequest() { throw new Error('no deberia llamarse (fallback a SERIAL nunca llega a freeze)'); },
110
+ mergeFrozenTrack() { throw new Error('no deberia llamarse (fallback a SERIAL nunca llega a merge)'); },
111
+ abortOwnedMerge() { throw new Error('no deberia llamarse (fallback a SERIAL nunca llega a merge)'); },
112
+ async ensureIntegrationLock() { throw new Error('no deberia llamarse (fallback a SERIAL nunca llega a integracion)'); },
113
+ async pauseControllerGeneration() { throw new Error('no deberia llamarse (fallback a SERIAL nunca llega a integracion)'); },
114
+ releaseIntegrationLockIfHeld() { throw new Error('no deberia llamarse (fallback a SERIAL nunca llega a integracion)'); },
115
+ };
116
+ }
117
+ /** Declara una cohorte de 2 tracks DIRECTAMENTE sobre el journal (mismo
118
+ * criterio que `track-bootstrap-crash.test.ts::declareCohort`: esta es la
119
+ * UNICA parte que no pasa por una request real — deliberado, porque
120
+ * `track-prepare-request` (`apply.ts`) tambien registra cada
121
+ * `track-integration:<trackId>` en el `cycleVerificationPlan` SIN
122
+ * `satisfiedBy`, y nada limpia esos items cuando la cohorte cae a SERIAL
123
+ * (el fallback nunca vuelve a satisfacerlos ni los remueve) — dejarian el
124
+ * gate generico permanentemente rojo y esta prueba dejaria de probar lo que
125
+ * el Finding 2 pide. Todo lo que pasa DESPUES de esta declaracion (worktree,
126
+ * journal, teardown, `enter-serial`) SI atraviesa el reducer real via
127
+ * `reconcileTracks`, exactamente como pide la ronda 2 de re-review. */
128
+ function declareFallbackCohort(repo, tracksRoot, baseSha) {
129
+ const s0 = (0, store_1.readJournal)(repo, 'main').state;
130
+ s0.cohortPhase = 'PREPARING';
131
+ s0.cohortBaseSha = baseSha;
132
+ s0.tracks = ['a', 'b'].map((id) => ({
133
+ trackId: id,
134
+ worktreePath: path_1.default.join(tracksRoot, `track-${id}`),
135
+ branch: `awm-track/${id}`,
136
+ ownership: [], sharedResources: [], dependsOn: [],
137
+ fencingToken: `fence-${id}`.padEnd(32, '0'),
138
+ phase: 'DECLARED',
139
+ readinessNonce: `ready-${id}`.padEnd(32, '0'),
140
+ }));
141
+ (0, store_1.writeJournal)(repo, 'main', s0);
142
+ }
65
143
  describe('supervisor loop', () => {
66
144
  let repo;
67
145
  let stubBin;
@@ -120,6 +198,78 @@ describe('supervisor loop', () => {
120
198
  expect(typeof final.cycle.completedAt).toBe('string');
121
199
  expect(Object.values(final.jobs).every((j) => j.executionState === 'exited' && j.verdict === 'pass')).toBe(true);
122
200
  });
201
+ // Regresion (post-review #2, Task 12/R7 — Finding 2 de la segunda ronda
202
+ // de re-review): la version anterior de esta prueba armaba el escenario
203
+ // hand-mutando el journal directo a `cohortPhase = 'SERIAL'` con tracks
204
+ // REMOVED/DECLARED "a mano" — probaba la logica del GUARD en aislamiento,
205
+ // pero nunca demostraba que el guard se comporta bien contra un journal
206
+ // con la FORMA que un fallback real deja (teardown real, git real,
207
+ // secuencia exacta begin-teardown -> enter-serial que decide
208
+ // `protocol.ts`). Esta version dispara el MISMO fallback de Task 9
209
+ // (`track-bootstrap-crash.test.ts::'fallo del segundo track limpia el
210
+ // primero antes de serializar'`: track 'a' llega a ARMED con git real,
211
+ // 'b' falla su `create-worktree` real, `nextProtocolEffect` decide
212
+ // FALLBACK_PENDING -> begin-teardown de 'a' -> enter-serial) sobre el
213
+ // MISMO journal que despues se usa para el camino generico de
214
+ // finalizacion de ciclo (task done + qa/interlock satisfechos, cero jobs
215
+ // vivos) y confirma que `cycle.status` llega a COMPLETE por ese camino
216
+ // generico, exactamente como lo haria un plan sin tracks en absoluto.
217
+ test('una cohorte que cayo a fallback SERIAL via el reducer real no cuelga el ciclo: COMPLETE llega igual por el camino generico (regresion post-review R7, ronda 2)', async () => {
218
+ (0, init_1.initWatch)(repo, 'main');
219
+ const tracksRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-loop-tracks-'));
220
+ try {
221
+ const baseSha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
222
+ declareFallbackCohort(repo, tracksRoot, baseSha);
223
+ // --- Fase 1: conducir la cohorte REAL (protocolo + git real)
224
+ // hasta SERIAL, sin pasar por Supervisor.tick() todavia (misma
225
+ // tecnica que track-bootstrap-crash.test.ts).
226
+ const wrapperState = new Map();
227
+ const spawnCalls = new Map();
228
+ const fallbackRuntime = buildFallbackRuntime(repo, wrapperState, spawnCalls, 'b');
229
+ let s = (0, store_1.readJournal)(repo, 'main').state;
230
+ for (let i = 0; i < 200 && s.cohortPhase !== 'SERIAL'; i++) {
231
+ s = (await (0, tracks_1.reconcileTracks)(repo, 'main', s, fallbackRuntime, 2)).state;
232
+ }
233
+ expect(s.cohortPhase).toBe('SERIAL');
234
+ // Mismo shape que exige `assertProtocolInvariants` para SERIAL, y
235
+ // el mismo que deja el fallback real de Task 9: REMOVED/DECLARED,
236
+ // nunca inventado por el test.
237
+ for (const t of s.tracks)
238
+ expect(['REMOVED', 'DECLARED']).toContain(t.phase);
239
+ expect(s.tracks).toHaveLength(2); // el array nunca se vacia (la regresion original)
240
+ // --- Fase 2: sobre ESE MISMO journal, completar el ciclo por el
241
+ // camino generico (task done + qa/interlock satisfechos + cero
242
+ // jobs vivos) — la misma receta que la prueba R4.5 de arriba.
243
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e1',
244
+ 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' }] } });
245
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e2',
246
+ payload: { entity: 'cycle-plan', items: [{ id: 'cv1', kind: 'qa' }, { id: 'cv-interlock', kind: 'interlock' }] } });
247
+ (0, request_1.requestJob)(repo, 'main', 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'v1' });
248
+ (0, request_1.requestJob)(repo, 'main', 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'v-sensors' });
249
+ (0, request_1.requestJob)(repo, 'main', 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'cv1' });
250
+ (0, request_1.requestJob)(repo, 'main', 'g0', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: 'cv-interlock' });
251
+ emitVerdict(repo, 'g0', 'o-spec', 'verd-spec');
252
+ emitVerdict(repo, 'g0', 'o-quality', 'verd-quality');
253
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'e3',
254
+ payload: { entity: 'task-status', taskId: 'T1', status: 'done' } });
255
+ const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, provider: 'codex', tickMs: 50, reconcileGraceMs: 10000 };
256
+ const sup = new supervisor_1.Supervisor(repo, 'main', cfg, fakeSpawner);
257
+ let outcome = 'continue';
258
+ for (let i = 0; i < 400 && outcome !== 'complete'; i++) {
259
+ outcome = await sup.tick();
260
+ await new Promise((r) => setTimeout(r, 50));
261
+ }
262
+ expect(outcome).toBe('complete');
263
+ const final = (0, store_1.readJournal)(repo, 'main').state;
264
+ expect(final.cycle.status).toBe('COMPLETE');
265
+ expect(final.cohortPhase).toBe('SERIAL'); // nunca inventa una transicion SERIAL -> COMPLETE
266
+ for (const t of final.tracks)
267
+ expect(['REMOVED', 'DECLARED']).toContain(t.phase);
268
+ }
269
+ finally {
270
+ fs_1.default.rmSync(tracksRoot, { recursive: true, force: true });
271
+ }
272
+ });
123
273
  test('tick verifica branch antes del launch y un ciclo COMPLETE no lanza otro controller', async () => {
124
274
  (0, init_1.initWatch)(repo, 'main');
125
275
  let calls = 0;
@@ -0,0 +1,342 @@
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 9 (R4.2, R4.3, R4.5, R4.6, R4.8, R4.9, R4.10, C1, C2, C11): crash/
7
+ // restart de P1 (bootstrap de tracks) y fallback serial tras bootstrap
8
+ // parcial. A diferencia de `track-bootstrap.test.ts` (Task 8, `TrackRuntime`
9
+ // completamente fake), esta suite usa git REAL para `addWorktree`/
10
+ // `initTrackJournal`/teardown de worktree+branch (`defaultTrackRuntime`,
11
+ // mismo patrón que `track-runtime-git.test.ts`) — la única forma honesta de
12
+ // probar C11 ("sin duplicar recursos") es mirar el repo real, no solo los
13
+ // eventos. `spawnSupervisor`/`observeSupervisor` quedan fake (nada de
14
+ // procesos reales): lo que importa acá es que `tracks.ts` nunca vuelva a
15
+ // invocarlos de más tras un crash, no la mecánica real de un wrapper
16
+ // detached (eso ya lo cubre `supervisor-wrapper.test.ts`).
17
+ const fs_1 = __importDefault(require("fs"));
18
+ const os_1 = __importDefault(require("os"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const child_process_1 = require("child_process");
21
+ const git_fixture_1 = require("../../helpers/git-fixture");
22
+ const tracks_1 = require("../../../src/commands/watch/tracks");
23
+ const store_1 = require("../../../src/core/journal/store");
24
+ const process_1 = require("../../../src/core/journal/process");
25
+ const BRANCH = 'main';
26
+ /** T13: a diferencia de un `pid`/`startTime` totalmente inventados (que
27
+ * `refIsAlive` — identity-verified, R4.8 — correctamente reportaría como
28
+ * "muerto", porque ningún proceso real matchea esos campos), esto captura la
29
+ * identidad REAL del propio proceso de test (siempre vivo mientras corre la
30
+ * suite), con un nonce distinto por (trackId, intento) — deja ejercitar de
31
+ * verdad el camino `stop-own-supervisor` de `runBeginTeardown` (Task 13) sin
32
+ * depender de un supervisor real. */
33
+ function fakeProcessRef(trackId, n) {
34
+ return (0, process_1.captureSelfRef)(`${trackId}-${n}`);
35
+ }
36
+ /** Runtime combinado: `addWorktree`/`initTrackJournal`/teardown de worktree y
37
+ * branch delegan al `defaultTrackRuntime` REAL (git de verdad) — el paso
38
+ * `supervisor` del teardown y el spawn/observe del supervisor quedan fake,
39
+ * respaldados por `instr` (que el test puede mutar directamente para
40
+ * simular "esto ya pasó antes del crash"). */
41
+ function buildRuntime(planRoot, instr, opts = {}) {
42
+ const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
43
+ return {
44
+ addWorktree(root, ref, baseSha) {
45
+ if (ref.trackId === opts.failWorktreeFor)
46
+ throw new Error(`fallo inyectado: create-worktree de ${ref.trackId}`);
47
+ real.addWorktree(root, ref, baseSha);
48
+ },
49
+ initTrackJournal(ref, context) {
50
+ real.initTrackJournal(ref, context);
51
+ },
52
+ spawnSupervisor(ref) {
53
+ const n = (instr.spawnCalls.get(ref.trackId) ?? 0) + 1;
54
+ instr.spawnCalls.set(ref.trackId, n);
55
+ // El "wrapper" real, al arrancar, escribe su claim antes que nada
56
+ // (mismo contrato que `supervisor-wrapper.ts`): simulamos eso acá.
57
+ instr.wrapperState.set(ref.trackId, 'claimed');
58
+ return fakeProcessRef(ref.trackId, n);
59
+ },
60
+ observeSupervisor(ref) {
61
+ const st = instr.wrapperState.get(ref.trackId) ?? 'absent';
62
+ if (st === 'absent')
63
+ return { kind: 'absent' };
64
+ if (st === 'ready')
65
+ return { kind: 'ready', readinessNonce: ref.readinessNonce };
66
+ // 'claimed'/'identified' colapsan al mismo `SupervisorObservation`
67
+ // que produce el `observeSupervisorFromDisk` real cuando el claim
68
+ // ya existe pero `ready.json` todavía no (ver su código: ambos
69
+ // casos devuelven 'claimed') — la recuperación de Task 9 no
70
+ // necesita distinguirlos, solo saber que YA hay evidencia real.
71
+ // El wrapper real sigue vivo (es un proceso aparte, ajeno a que
72
+ // el supervisor del PLAN haya crasheado) y termina su propio
73
+ // bootstrap poco después: la SIGUIENTE observación ya lo ve listo.
74
+ instr.wrapperState.set(ref.trackId, 'ready');
75
+ return { kind: 'claimed' };
76
+ },
77
+ async stopOwnSupervisor(ref) {
78
+ instr.teardownEvents.push(`supervisor-stopped:${ref.trackId}`);
79
+ return true;
80
+ },
81
+ removeOwnedWorktree(repo, ref) {
82
+ real.removeOwnedWorktree(repo, ref);
83
+ instr.teardownEvents.push(`worktree-removed:${ref.trackId}`);
84
+ },
85
+ removeOwnedBranch(repo, branchName) {
86
+ real.removeOwnedBranch(repo, branchName);
87
+ // El fake no conoce el trackId acá (mismo dato que `git.ts` usa:
88
+ // solo el nombre de branch) — se reconstruye del nombre
89
+ // determinista `awm-track/<id>` para mantener el mismo formato
90
+ // de evento que el resto de esta suite.
91
+ const trackId = branchName.split('/').pop() ?? branchName;
92
+ instr.teardownEvents.push(`branch-removed:${trackId}`);
93
+ },
94
+ emitFreezeRequest() { throw new Error('no debería llamarse (Task 10, sin cobertura en esta suite)'); },
95
+ mergeFrozenTrack() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
96
+ abortOwnedMerge() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
97
+ async ensureIntegrationLock() { throw new Error('no debería llamarse (Task 11, sin cobertura en esta suite)'); },
98
+ async pauseControllerGeneration() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
99
+ releaseIntegrationLockIfHeld() { throw new Error('no debería llamarse (Task 12, sin cobertura en esta suite)'); },
100
+ };
101
+ }
102
+ function declareCohort(planRoot, root, baseSha, trackIds) {
103
+ (0, store_1.initJournal)(planRoot, BRANCH);
104
+ const s0 = (0, store_1.readJournal)(planRoot, BRANCH).state;
105
+ s0.cohortPhase = 'PREPARING';
106
+ s0.cohortBaseSha = baseSha;
107
+ s0.tracks = trackIds.map((id) => ({
108
+ trackId: id,
109
+ worktreePath: path_1.default.join(root, `track-${id}`),
110
+ branch: `awm-track/${id}`,
111
+ ownership: [], sharedResources: [], dependsOn: [],
112
+ fencingToken: `fence-${id}`.padEnd(32, '0'),
113
+ phase: 'DECLARED',
114
+ readinessNonce: `ready-${id}`.padEnd(32, '0'),
115
+ }));
116
+ (0, store_1.writeJournal)(planRoot, BRANCH, s0);
117
+ return (0, store_1.readJournal)(planRoot, BRANCH).state;
118
+ }
119
+ /** Fija la fase de `trackId` directamente en el journal persistido, SIN pasar
120
+ * por `reconcileTracks` — necesario para 'after-prepare-intent' y
121
+ * 'after-worktree-effect': `persist-prepare-intent` es una transición
122
+ * puramente en memoria que `reconcileTracks` drena en el MISMO call que ya
123
+ * intenta `create-worktree` a continuación (ver el comentario grande sobre
124
+ * `reconcileTracks` en `tracks.ts`), así que "recién persistido
125
+ * PREPARE_INTENT, todavía nada más" no es un boundary EXTERNO alcanzable
126
+ * con un tick — hay que construirlo a mano para representar fielmente ese
127
+ * instante exacto de un crash real. */
128
+ function setPhase(planRoot, branch, trackId, phase) {
129
+ const s = (0, store_1.readJournal)(planRoot, branch).state;
130
+ s.tracks = s.tracks.map((t) => (t.trackId === trackId ? { ...t, phase } : t));
131
+ (0, store_1.writeJournal)(planRoot, branch, s);
132
+ return (0, store_1.readJournal)(planRoot, branch).state;
133
+ }
134
+ function realWorktreeBranches(planRoot) {
135
+ let out;
136
+ try {
137
+ out = (0, child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], { cwd: planRoot, encoding: 'utf8' });
138
+ }
139
+ catch {
140
+ return [];
141
+ }
142
+ const branches = [];
143
+ for (const line of out.split('\n')) {
144
+ if (line.startsWith('branch '))
145
+ branches.push(line.slice('branch '.length).replace('refs/heads/', ''));
146
+ }
147
+ return branches;
148
+ }
149
+ describe('reconcileTracks — crash/restart de P1 con git real (Task 9, R4.2/R4.3/R4.6/R4.8/R4.9/C1/C2/C11)', () => {
150
+ let root;
151
+ let planRoot;
152
+ let baseSha;
153
+ beforeEach(() => {
154
+ planRoot = (0, git_fixture_1.initRepo)();
155
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
156
+ baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
157
+ root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-crash-root-'));
158
+ });
159
+ afterEach(() => {
160
+ try {
161
+ (0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: planRoot, stdio: 'pipe' });
162
+ }
163
+ catch { /* best-effort */ }
164
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true });
165
+ fs_1.default.rmSync(root, { recursive: true, force: true });
166
+ });
167
+ const crashPoints = [
168
+ 'after-prepare-intent', 'after-worktree-effect', 'after-worktree-result',
169
+ 'after-journal-effect', 'after-journal-result', 'after-supervisor-intent',
170
+ 'after-supervisor-claim', 'after-supervisor-identity', 'after-readiness',
171
+ ];
172
+ test.each(crashPoints)('restart converge desde %s sin duplicar recursos (R4.2, C11)', async (point) => {
173
+ const instr = { spawnCalls: new Map(), wrapperState: new Map(), teardownEvents: [] };
174
+ const runtime = buildRuntime(planRoot, instr);
175
+ let s = declareCohort(planRoot, root, baseSha, ['a', 'b']);
176
+ // --- Fase 1: avanzar el track 'a' con ticks REALES hasta el punto
177
+ // natural más cercano al crash pedido (todo boundary que
178
+ // `reconcileTracks` ya persiste solo antes de tocar runtime de
179
+ // nuevo). 'b' se queda en DECLARED: PREPARING procesa un track a la
180
+ // vez, alfabéticamente, así que jamás arranca antes de que 'a' llegue
181
+ // a ARMED.
182
+ //
183
+ // 'after-prepare-intent'/'after-worktree-effect' son la EXCEPCIÓN:
184
+ // `persist-prepare-intent` es puramente en memoria y `reconcileTracks`
185
+ // la drena en el MISMO call que ya intenta `create-worktree` a
186
+ // continuación (ver el comentario grande sobre `reconcileTracks` en
187
+ // `tracks.ts`) — "recién PREPARE_INTENT, todavía nada más" no es un
188
+ // boundary externo alcanzable con un tick, así que se construye a
189
+ // mano con `setPhase` en vez de tickear.
190
+ const stopWhen = {
191
+ 'after-worktree-result': (r) => r.phase === 'WORKTREE_CREATED',
192
+ 'after-journal-effect': (r) => r.phase === 'WORKTREE_CREATED',
193
+ 'after-journal-result': (r) => r.phase === 'JOURNAL_CREATED',
194
+ 'after-supervisor-intent': (r) => r.phase === 'SUPERVISOR_STARTING' && r.supervisorIntent !== undefined,
195
+ 'after-supervisor-claim': (r) => r.phase === 'SUPERVISOR_STARTING' && r.supervisorIntent !== undefined,
196
+ 'after-supervisor-identity': (r) => r.phase === 'SUPERVISOR_STARTING' && r.supervisorIntent !== undefined,
197
+ 'after-readiness': (r) => r.phase === 'ARMED',
198
+ };
199
+ if (point === 'after-prepare-intent' || point === 'after-worktree-effect') {
200
+ s = setPhase(planRoot, BRANCH, 'a', 'PREPARE_INTENT');
201
+ }
202
+ else {
203
+ for (let i = 0; i < 50; i++) {
204
+ const a = s.tracks.find((t) => t.trackId === 'a');
205
+ if (stopWhen[point](a))
206
+ break;
207
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
208
+ }
209
+ const preCrashA = s.tracks.find((t) => t.trackId === 'a');
210
+ expect(stopWhen[point](preCrashA)).toBe(true);
211
+ }
212
+ // --- Simular el crash: para los 4 puntos donde el efecto real YA
213
+ // corrió pero `tracks.ts` no llegó a persistir la observación, se
214
+ // ejecuta la mutación real (mismas funciones que usaría
215
+ // `runCreateWorktree`/`runSpawnTrackSupervisor`) SIN pasar por
216
+ // `reconcileTracks` — así el journal queda exactamente como
217
+ // quedaría tras un crash real justo ahí.
218
+ const refA = () => s.tracks.find((t) => t.trackId === 'a');
219
+ if (point === 'after-worktree-effect') {
220
+ (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH).addWorktree(planRoot, refA(), baseSha);
221
+ }
222
+ if (point === 'after-journal-effect') {
223
+ (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH).initTrackJournal(refA(), {
224
+ trackId: 'a', taskIds: [], planDigest: '', baseSha, planJournalId: s.journalId,
225
+ });
226
+ }
227
+ if (point === 'after-supervisor-claim' || point === 'after-supervisor-identity') {
228
+ // El spawn real ya ocurrió (proceso detached lanzado) y el
229
+ // wrapper ya escribió su claim (y, en 'identity', también su
230
+ // identity sidecar) — pero el supervisor del PLAN crasheó antes
231
+ // de persistir `supervisorProcessRef`.
232
+ runtime.spawnSupervisor(refA());
233
+ instr.wrapperState.set('a', point === 'after-supervisor-claim' ? 'claimed' : 'identified');
234
+ }
235
+ // --- Fase 2 ("restart"): instrumentación fresca salvo lo que
236
+ // sobreviviría de verdad a un crash (spawnCalls y wrapperState ya
237
+ // acumulados arriba SÍ persisten — representan el mundo real, no
238
+ // memoria de proceso). Reconciliar hasta que la cohorte quede ACTIVE.
239
+ for (let i = 0; i < 100 && s.cohortPhase !== 'ACTIVE'; i++) {
240
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
241
+ }
242
+ expect(s.cohortPhase).toBe('ACTIVE');
243
+ for (const t of s.tracks)
244
+ expect(['ACTIVE', 'ARMED']).toContain(t.phase);
245
+ // C11: ni un spawn de más, ni un worktree de más, por track.
246
+ expect(Object.fromEntries(instr.spawnCalls)).toEqual({ a: 1, b: 1 });
247
+ const branches = realWorktreeBranches(planRoot);
248
+ expect(branches.filter((b) => b === 'awm-track/a')).toHaveLength(1);
249
+ expect(branches.filter((b) => b === 'awm-track/b')).toHaveLength(1);
250
+ // Ningún track quedó BLOCKED (el crash nunca debe leerse como ajeno).
251
+ for (const t of s.tracks)
252
+ expect(t.blockedReason).toBeUndefined();
253
+ });
254
+ // No entra en `crashPoints`/`test.each` de arriba a propósito: esa matriz
255
+ // asume que un restart converge SIN volver a llamar `spawnSupervisor`
256
+ // (`spawnCalls === {a:1, b:1}`). Esta ventana es la excepción real —
257
+ // documentada acá en vez de forzarla dentro de esa tabla compartida.
258
+ test('crash entre el fork real del wrapper y su claim en disco: el restart re-spawnea de más; la exclusión de un segundo proceso VIVO la garantiza supervisor-wrapper.ts, no este driver (R4.2, C11)', async () => {
259
+ // Ventana real que `observeSupervisor`/`decidePrepare` NO pueden
260
+ // cerrar: entre que `runtime.spawnSupervisor` forkea el proceso
261
+ // detached de verdad y que ESE proceso llega a
262
+ // `fs.openSync(claimPath, 'wx', ...)` (`supervisor-wrapper.ts`), no
263
+ // hay ningún artefacto en disco. Si el supervisor del PLAN crashea
264
+ // justo ahí, el restart observa 'absent' (lo único honesto: no hay
265
+ // evidencia todavía) y `decidePrepare` vuelve a pedir
266
+ // 'retry-supervisor-same-intent' — `spawnSupervisor` se llama DE
267
+ // NUEVO, un segundo fork real. Este test no simula al segundo
268
+ // wrapper perdiendo la carrera de `wx`/`EEXIST` contra el primero
269
+ // (el fake de este archivo no modela dos procesos wrapper
270
+ // concurrentes) — esa exclusión, la que de verdad evita terminar con
271
+ // dos supervisores VIVOS, ya tiene cobertura dedicada en
272
+ // `cli/tests/commands/track/supervisor-wrapper.test.ts` ("un segundo
273
+ // wrapper con el mismo claim detecta el claim existente y sale sin
274
+ // lanzar otro supervisor (C11)"). Lo que se prueba acá es que la
275
+ // segunda llamada a `spawnSupervisor` es el comportamiento ESPERADO
276
+ // en esta ventana, no una regresión de C11.
277
+ const instr = { spawnCalls: new Map(), wrapperState: new Map(), teardownEvents: [] };
278
+ const runtime = buildRuntime(planRoot, instr);
279
+ let s = declareCohort(planRoot, root, baseSha, ['a', 'b']);
280
+ for (let i = 0; i < 50; i++) {
281
+ const a = s.tracks.find((t) => t.trackId === 'a');
282
+ if (a.phase === 'SUPERVISOR_STARTING' && a.supervisorIntent !== undefined)
283
+ break;
284
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
285
+ }
286
+ const refA = () => s.tracks.find((t) => t.trackId === 'a');
287
+ expect(refA().phase).toBe('SUPERVISOR_STARTING');
288
+ expect(refA().supervisorProcessRef).toBeUndefined();
289
+ // Simular el fork real SIN claim en disco: a diferencia de
290
+ // 'after-supervisor-claim'/'after-supervisor-identity' (que llaman a
291
+ // `runtime.spawnSupervisor` y dejan que el fake avance
292
+ // `wrapperState`), acá solo se incrementa el contador de forks —
293
+ // `wrapperState` de 'a' queda 'absent' a propósito, representando la
294
+ // instantánea exacta ANTES de que el wrapper real escriba su claim.
295
+ instr.spawnCalls.set('a', 1);
296
+ for (let i = 0; i < 100 && s.cohortPhase !== 'ACTIVE'; i++) {
297
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
298
+ }
299
+ expect(s.cohortPhase).toBe('ACTIVE');
300
+ // El driver no tiene forma de saber que ya había un fork en vuelo:
301
+ // vuelve a spawnear. Dos llamadas reales a `spawnSupervisor` para
302
+ // 'a' es el resultado ESPERADO en esta ventana — no un bug de C11 —
303
+ // y por eso NO se afirma `spawnCalls === {a:1, b:1}` acá.
304
+ expect(instr.spawnCalls.get('a')).toBe(2);
305
+ expect(instr.spawnCalls.get('b')).toBe(1);
306
+ });
307
+ test('fallo del segundo track limpia el primero antes de serializar (R4.5, C2)', async () => {
308
+ const instr = { spawnCalls: new Map(), wrapperState: new Map(), teardownEvents: [] };
309
+ const runtime = buildRuntime(planRoot, instr, { failWorktreeFor: 'b' });
310
+ let s = declareCohort(planRoot, root, baseSha, ['a', 'b']);
311
+ for (let i = 0; i < 200 && s.cohortPhase !== 'SERIAL'; i++) {
312
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
313
+ }
314
+ // Marcador propio del test (no de `runtime`): registra el momento en
315
+ // que la cohorte se observa por primera vez en SERIAL, para poder
316
+ // comparar su índice contra el de 'branch-removed:a' más abajo —
317
+ // `nextProtocolEffect` solo emite 'enter-serial' una vez que TODOS
318
+ // los tracks owned llegan a REMOVED (ver el branch `FALLBACK_PENDING`
319
+ // en `protocol.ts`), así que este push siempre queda estrictamente
320
+ // después de cualquier evento de teardown real.
321
+ instr.teardownEvents.push('serial-entered');
322
+ expect(s.cohortPhase).toBe('SERIAL');
323
+ for (const t of s.tracks)
324
+ expect(['REMOVED', 'DECLARED']).toContain(t.phase);
325
+ // 'a' (el sobreviviente hasta que 'b' falló) tuvo que desmantelarse
326
+ // de verdad: supervisor "detenido", worktree y branch reales
327
+ // removidos — en ese orden, y ANTES de entrar a SERIAL.
328
+ expect(instr.teardownEvents).toEqual(expect.arrayContaining([
329
+ 'supervisor-stopped:a', 'worktree-removed:a', 'branch-removed:a',
330
+ ]));
331
+ expect(instr.teardownEvents.indexOf('branch-removed:a')).toBeLessThan(instr.teardownEvents.indexOf('serial-entered'));
332
+ // Prueba REAL (no solo eventos): ni worktree ni branch de 'a' siguen vivos.
333
+ const branches = realWorktreeBranches(planRoot);
334
+ expect(branches).not.toContain('awm-track/a');
335
+ expect(branches).not.toContain('awm-track/b');
336
+ const worktreeA = path_1.default.join(root, 'track-a');
337
+ expect(fs_1.default.existsSync(worktreeA)).toBe(false);
338
+ const list = (0, child_process_1.execFileSync)('git', ['branch', '--list'], { cwd: planRoot, encoding: 'utf8' });
339
+ expect(list).not.toContain('awm-track/a');
340
+ expect(list).not.toContain('awm-track/b');
341
+ });
342
+ });