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,439 @@
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 11 (R6.2/R6.3/R6.6-R6.9/C7): crash/restart de P2 (join durable) contra
7
+ // git REAL — mismo criterio que `track-bootstrap-crash.test.ts` (Task 9):
8
+ // probar "sin doble merge" (C11-adjacent) solo tiene sentido mirando el
9
+ // repo real, no eventos. Se reutiliza la MISMA instancia de `TrackRuntime`
10
+ // (`defaultTrackRuntime`) a lo largo de "antes" y "después" del crash — el
11
+ // "crash" se simula deteniendo los ticks y, cuando el efecto real ya ocurrió
12
+ // pero no se persistió, reproduciéndolo a mano con las MISMAS funciones
13
+ // reales (`mergeFrozenTrack`), exactamente como `buildRuntime`/`stopWhen` de
14
+ // Task 9 reconstruyen el mundo tras un crash sin volver a levantar un
15
+ // proceso nuevo (dentro de un mismo proceso Jest, un "proceso nuevo" real
16
+ // para `integration.lock` reclamaría identidad viva-por-definición del pid
17
+ // del test runner — no es lo que este archivo necesita probar; eso ya lo
18
+ // cubre `join.test.ts`/Task 10 con `ProcessRef`s fabricados).
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const os_1 = __importDefault(require("os"));
21
+ const path_1 = __importDefault(require("path"));
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 git_1 = require("../../../src/core/tracks/git");
26
+ const paths_1 = require("../../../src/core/journal/paths");
27
+ const store_1 = require("../../../src/core/journal/store");
28
+ const BRANCH = 'main';
29
+ function gitLocal(repo, args) {
30
+ return (0, child_process_1.execFileSync)('git', args, { cwd: repo, encoding: 'utf8', stdio: 'pipe' });
31
+ }
32
+ function trackRef(id, overrides = {}) {
33
+ return {
34
+ trackId: id, worktreePath: `/nonexistent/${id}`, branch: `awm-track/${id}`,
35
+ ownership: [], sharedResources: [], dependsOn: [],
36
+ fencingToken: `fence-${id}`.padEnd(32, '0'), phase: 'FROZEN', readinessNonce: `ready-${id}`.padEnd(32, '0'),
37
+ ...overrides,
38
+ };
39
+ }
40
+ function mergeCommitCountFor(repo, trackHeadSha) {
41
+ let out;
42
+ try {
43
+ out = gitLocal(repo, ['log', '--all', '--format=%H %P']);
44
+ }
45
+ catch {
46
+ return 0;
47
+ }
48
+ return out.split('\n').filter((line) => line.trim().length > 0 && line.split(' ').slice(1).includes(trackHeadSha)).length;
49
+ }
50
+ describe('reconcileTracks — crash/restart de P2 (join) con git real (Task 11, R6.2/R6.3/R6.6-R6.9/C7)', () => {
51
+ let planRoot;
52
+ let root;
53
+ let baseSha;
54
+ beforeEach(() => {
55
+ planRoot = (0, git_fixture_1.initRepo)();
56
+ gitLocal(planRoot, ['config', 'user.email', 't@t.t']);
57
+ gitLocal(planRoot, ['config', 'user.name', 't']);
58
+ gitLocal(planRoot, ['config', 'commit.gpgsign', 'false']);
59
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
60
+ baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
61
+ root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-join-crash-root-'));
62
+ });
63
+ afterEach(() => {
64
+ try {
65
+ (0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: planRoot, stdio: 'pipe' });
66
+ }
67
+ catch { /* best-effort */ }
68
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true });
69
+ fs_1.default.rmSync(root, { recursive: true, force: true });
70
+ });
71
+ /** Worktree REAL para un track — comparte el object store del repo del
72
+ * plan (mismo `.git`), así que el SHA congelado queda alcanzable sin
73
+ * fetch, exactamente como lo dejaría `addOwnedWorktree` en producción. */
74
+ function makeTrackHead(id, filename, content) {
75
+ const wt = path_1.default.join(root, `wt-${id}`);
76
+ gitLocal(planRoot, ['worktree', 'add', '-b', `awm-track/${id}`, wt, baseSha]);
77
+ fs_1.default.writeFileSync(path_1.default.join(wt, filename), content);
78
+ gitLocal(wt, ['add', '--', filename]);
79
+ gitLocal(wt, ['commit', '-qm', `track ${id}`]);
80
+ return gitLocal(wt, ['rev-parse', 'HEAD']).trim();
81
+ }
82
+ function declareCohort(refs) {
83
+ (0, store_1.initJournal)(planRoot, BRANCH);
84
+ const s0 = (0, store_1.readJournal)(planRoot, BRANCH).state;
85
+ s0.cohortPhase = 'JOINING';
86
+ s0.cohortBaseSha = baseSha;
87
+ s0.tracks = refs;
88
+ (0, store_1.writeJournal)(planRoot, BRANCH, s0);
89
+ return (0, store_1.readJournal)(planRoot, BRANCH).state;
90
+ }
91
+ async function driveToConvergence(runtime, maxTicks = 20) {
92
+ let s = (0, store_1.readJournal)(planRoot, BRANCH).state;
93
+ for (let i = 0; i < maxTicks; i++) {
94
+ if (s.tracks.every((t) => t.phase === 'MERGED_UNVERIFIED'))
95
+ break;
96
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
97
+ }
98
+ return s;
99
+ }
100
+ // --- Los 5 puntos de crash "en línea recta" (sin conflicto real): -------
101
+ // 'after-merge-effect' y 'after-merge-observation' colapsan a la MISMA
102
+ // frontera observable en esta implementación síncrona (`runMergeTrack`
103
+ // lee-decide-ejecuta-relee-persiste dentro de UNA sola invocación, sin
104
+ // punto async intermedio entre "el merge real ya ocurrió" y "se
105
+ // observó/persistió") — mismo criterio que `track-bootstrap-crash.test.ts`
106
+ // documenta para sus propios puntos no alcanzables como boundary externo
107
+ // (`after-prepare-intent`/`after-worktree-effect`). Ambos se prueban acá
108
+ // con la MISMA construcción manual: el merge real ya corrió (vía
109
+ // `mergeFrozenTrack` invocado directamente, fuera de `reconcileTracks`,
110
+ // igual que `buildRuntime` de Task 9 llama a `defaultTrackRuntime(...)
111
+ // .addWorktree(...)` a mano) pero el journal todavía muestra `JOIN_INTENT`.
112
+ const crashPoints = [
113
+ 'after-join-request', 'after-join-intent', 'after-merge-effect', 'after-merge-observation', 'after-merged-result',
114
+ ];
115
+ test.each(crashPoints)('join converge desde %s sin doble merge (R6.2, R6.8)', async (point) => {
116
+ const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
117
+ const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
118
+ const refA = trackRef('a', { frozenHeadSha: aHead });
119
+ const refB = trackRef('b', { frozenHeadSha: bHead });
120
+ const intentA = { expectedPlanHeadSha: baseSha, expectedTrackHeadSha: aHead, strategy: 'no-ff' };
121
+ if (point === 'after-join-request') {
122
+ declareCohort([refA, refB]);
123
+ }
124
+ else if (point === 'after-join-intent') {
125
+ declareCohort([{ ...refA, phase: 'JOIN_INTENT', joinIntent: intentA }, refB]);
126
+ }
127
+ else if (point === 'after-merge-effect' || point === 'after-merge-observation') {
128
+ declareCohort([{ ...refA, phase: 'JOIN_INTENT', joinIntent: intentA }, refB]);
129
+ // El merge real YA corrió (crash entre el efecto git y su
130
+ // observación/persistencia) — reproducido a mano con la MISMA
131
+ // función real que usaría `runMergeTrack`.
132
+ (0, git_1.mergeFrozenTrack)(planRoot, intentA);
133
+ }
134
+ else {
135
+ // 'after-merged-result': A ya quedó completamente persistido como
136
+ // MERGED_UNVERIFIED en un ciclo anterior — el merge real también
137
+ // ya corrió.
138
+ (0, git_1.mergeFrozenTrack)(planRoot, intentA);
139
+ const mergedHead = gitLocal(planRoot, ['rev-parse', 'HEAD']).trim();
140
+ const s0 = declareCohort([
141
+ { ...refA, phase: 'MERGED_UNVERIFIED', joinIntent: intentA, joinedCommitSha: mergedHead },
142
+ refB,
143
+ ]);
144
+ s0.cohortPlanHeadSha = mergedHead;
145
+ (0, store_1.writeJournal)(planRoot, BRANCH, s0);
146
+ }
147
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
148
+ const finalState = await driveToConvergence(runtime);
149
+ // C11-adjacent: ni un merge de más por track, sin importar desde qué
150
+ // frontera arrancó el "restart".
151
+ expect(mergeCommitCountFor(planRoot, aHead)).toBe(1);
152
+ expect(mergeCommitCountFor(planRoot, bHead)).toBe(1);
153
+ expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
154
+ expect((0, git_1.dirtyPaths)(planRoot)).toEqual([]);
155
+ const a = finalState.tracks.find((t) => t.trackId === 'a');
156
+ const b = finalState.tracks.find((t) => t.trackId === 'b');
157
+ expect(a.phase).toBe('MERGED_UNVERIFIED');
158
+ expect(b.phase).toBe('MERGED_UNVERIFIED');
159
+ // C7: `integration.lock` adquirido antes del primer join y RETENIDO
160
+ // (Task 12 lo libera recién tras el interlock final — esta task
161
+ // nunca lo suelta).
162
+ expect(fs_1.default.existsSync((0, paths_1.integrationLockPath)(planRoot))).toBe(true);
163
+ });
164
+ // --- 'during-conflict': fixture con conflicto REAL (misma línea en el
165
+ // merge de A ya aplicado y en el frozenHeadSha de B), no incluido en la
166
+ // tabla de arriba porque su convergencia NO es "ambos MERGED_UNVERIFIED"
167
+ // — es "el merge propio se abortó y queda reintentable, con índice
168
+ // limpio" (texto explícito del plan). -----------------------------------
169
+ test('join converge desde during-conflict abortando solo el merge propio, con índice limpio (R6.2, R6.8)', async () => {
170
+ // A y B editan la MISMA línea de `shared.txt`: al mergear A primero
171
+ // (limpio, contra `baseSha`) y a B después (contra el merge de A),
172
+ // git produce un conflicto REAL de contenido — no una carrera de
173
+ // HEAD ni un `assertPlanHead` fallido.
174
+ const aHead = makeTrackHead('a', 'shared.txt', 'A-version\n');
175
+ const bHead = makeTrackHead('b', 'shared.txt', 'B-version\n');
176
+ const refA = trackRef('a', { frozenHeadSha: aHead });
177
+ const refB = trackRef('b', { frozenHeadSha: bHead });
178
+ declareCohort([refA, refB]);
179
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
180
+ // Fase 1: converger A por el camino normal (limpio) hasta que quede
181
+ // MERGED_UNVERIFIED y B tenga su propio JOIN_INTENT persistido —
182
+ // ambos pasos vía el driver real, sin inyectar nada todavía.
183
+ let s = (0, store_1.readJournal)(planRoot, BRANCH).state;
184
+ for (let i = 0; i < 20; i++) {
185
+ const a = s.tracks.find((t) => t.trackId === 'a');
186
+ const b = s.tracks.find((t) => t.trackId === 'b');
187
+ if (a.phase === 'MERGED_UNVERIFIED' && b.phase === 'JOIN_INTENT' && b.joinIntent !== undefined)
188
+ break;
189
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
190
+ }
191
+ const aAfter = s.tracks.find((t) => t.trackId === 'a');
192
+ const bAfter = s.tracks.find((t) => t.trackId === 'b');
193
+ expect(aAfter.phase).toBe('MERGED_UNVERIFIED');
194
+ expect(bAfter.phase).toBe('JOIN_INTENT');
195
+ const intentB = bAfter.joinIntent;
196
+ // --- Simular el crash: el merge de B YA se intentó de verdad (fuera
197
+ // de `reconcileTracks`, misma técnica que los otros puntos) y
198
+ // produjo un conflicto REAL — el proceso murió antes de observarlo.
199
+ expect(() => (0, git_1.mergeFrozenTrack)(planRoot, intentB)).toThrow();
200
+ expect((0, git_1.readMergeHead)(planRoot)).toBe(bHead);
201
+ expect((0, git_1.dirtyPaths)(planRoot).length).toBeGreaterThan(0);
202
+ // --- "Restart": UN tick real debe abortar el merge propio (mergeHead
203
+ // coincide con nuestro propio `expectedTrackHeadSha`) y dejar el
204
+ // índice limpio — B queda reintentable (JOIN_INTENT), NUNCA se marca
205
+ // MERGED_UNVERIFIED con un conflicto sin resolver.
206
+ const afterAbort = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
207
+ expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
208
+ expect((0, git_1.dirtyPaths)(planRoot)).toEqual([]);
209
+ const bFinal = afterAbort.tracks.find((t) => t.trackId === 'b');
210
+ expect(['JOIN_INTENT', 'BLOCKED']).toContain(bFinal.phase);
211
+ expect(mergeCommitCountFor(planRoot, bHead)).toBe(0); // jamás se aplicó
212
+ expect(mergeCommitCountFor(planRoot, aHead)).toBe(1); // A no se re-tocó
213
+ // Un segundo ciclo completo (reintento -> conflicto de nuevo -> abort
214
+ // de nuevo) tampoco corrompe nada ni duplica el merge de A: la
215
+ // convergencia real de B requeriría resolución humana del conflicto,
216
+ // pero el mecanismo nunca dobla-mergea ni deja basura.
217
+ const afterRetryConflict = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, afterAbort, runtime, 2)).state;
218
+ const afterSecondAbort = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, afterRetryConflict, runtime, 2)).state;
219
+ expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
220
+ expect((0, git_1.dirtyPaths)(planRoot)).toEqual([]);
221
+ expect(mergeCommitCountFor(planRoot, bHead)).toBe(0);
222
+ expect(mergeCommitCountFor(planRoot, aHead)).toBe(1);
223
+ const bSecond = afterSecondAbort.tracks.find((t) => t.trackId === 'b');
224
+ expect(['JOIN_INTENT', 'BLOCKED']).toContain(bSecond.phase);
225
+ });
226
+ // --- Post-review fix (Finding 1, revisión de Task 11): `ensureIntegrationLock`
227
+ // y el intento de merge/abort son DOS fronteras `reconcileTracks()`
228
+ // distintas — antes, un `ensureIntegrationLock` que hacía trabajo real
229
+ // (primer tick de un proceso vivo) seguía, en el MISMO call, a intentar
230
+ // el merge/abort (dos mutaciones reales en una sola invocación). Estos
231
+ // tests usan un `TrackRuntime` fake para `ensureIntegrationLock`
232
+ // (controlando directamente 'acquired'/'already-held') pero delegan
233
+ // `mergeFrozenTrack`/`abortOwnedMerge` al `defaultTrackRuntime` REAL —
234
+ // mismo criterio de mezcla real/fake que `buildRuntime` en
235
+ // `track-bootstrap-crash.test.ts`. No se reimplementa la reclamación de
236
+ // identidad muerta acá (eso ya lo prueba `join.test.ts`/Task 10 en
237
+ // aislamiento, con `ProcessRef`s fabricados) — lo que se prueba es la
238
+ // consecuencia en `runMergeTrack`: NUNCA fundir "lock recién adquirido"
239
+ // con "intento de merge" en un mismo call, sin importar cuántas veces un
240
+ // restart real haya tenido que re-adquirir de verdad.
241
+ describe('runMergeTrack — adquisición del lock y merge son fronteras separadas (Finding 1)', () => {
242
+ function fakeMergeRuntime(lockResult, calls) {
243
+ const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
244
+ const unexpected = (what) => () => {
245
+ throw new Error(`no debería llamarse: ${what} (fixture ya en JOIN_INTENT, solo merge-track en juego)`);
246
+ };
247
+ return {
248
+ addWorktree: unexpected('addWorktree'),
249
+ initTrackJournal: unexpected('initTrackJournal'),
250
+ spawnSupervisor: unexpected('spawnSupervisor'),
251
+ observeSupervisor: unexpected('observeSupervisor'),
252
+ stopOwnSupervisor: async () => unexpected('stopOwnSupervisor')(),
253
+ removeOwnedWorktree: unexpected('removeOwnedWorktree'),
254
+ removeOwnedBranch: unexpected('removeOwnedBranch'),
255
+ emitFreezeRequest: unexpected('emitFreezeRequest'),
256
+ mergeFrozenTrack(repo, intent) {
257
+ calls.merge.push(intent.expectedTrackHeadSha);
258
+ real.mergeFrozenTrack(repo, intent);
259
+ },
260
+ abortOwnedMerge(repo, intent) {
261
+ calls.abort.push(intent.expectedTrackHeadSha);
262
+ real.abortOwnedMerge(repo, intent);
263
+ },
264
+ async ensureIntegrationLock() {
265
+ calls.lock += 1;
266
+ return lockResult;
267
+ },
268
+ pauseControllerGeneration: async () => unexpected('pauseControllerGeneration')(),
269
+ releaseIntegrationLockIfHeld: unexpected('releaseIntegrationLockIfHeld'),
270
+ };
271
+ }
272
+ function declareJoinIntentCohort(aHead, bHead) {
273
+ const refA = trackRef('a', { frozenHeadSha: aHead });
274
+ const refB = trackRef('b', { frozenHeadSha: bHead });
275
+ const intentA = { expectedPlanHeadSha: baseSha, expectedTrackHeadSha: aHead, strategy: 'no-ff' };
276
+ const s0 = declareCohort([{ ...refA, phase: 'JOIN_INTENT', joinIntent: intentA }, refB]);
277
+ return { s0, intentA };
278
+ }
279
+ test('un call que adquiere el lock por primera vez NO intenta el merge en ese mismo call (R6.2/C7)', async () => {
280
+ const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
281
+ const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
282
+ const { s0 } = declareJoinIntentCohort(aHead, bHead);
283
+ const calls = { lock: 0, merge: [], abort: [] };
284
+ const runtime = fakeMergeRuntime('acquired', calls);
285
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, runtime, 2);
286
+ expect(calls.lock).toBe(1);
287
+ expect(calls.merge).toEqual([]);
288
+ expect(calls.abort).toEqual([]);
289
+ expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
290
+ expect(mergeCommitCountFor(planRoot, aHead)).toBe(0);
291
+ const a = result.state.tracks.find((t) => t.trackId === 'a');
292
+ expect(a.phase).toBe('JOIN_INTENT'); // sin cambios: el intento de merge queda para el PRÓXIMO call
293
+ });
294
+ test('una vez el lock está confirmado held ("already-held"), el PRÓXIMO call sí intenta el merge (R6.2/C7)', async () => {
295
+ const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
296
+ const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
297
+ const { s0 } = declareJoinIntentCohort(aHead, bHead);
298
+ const calls = { lock: 0, merge: [], abort: [] };
299
+ const runtime = fakeMergeRuntime('already-held', calls);
300
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, runtime, 2);
301
+ expect(calls.lock).toBe(1);
302
+ expect(calls.merge).toEqual([aHead]);
303
+ const a = result.state.tracks.find((t) => t.trackId === 'a');
304
+ expect(a.phase).toBe('MERGED_UNVERIFIED');
305
+ });
306
+ test('crash justo tras adquirir el lock + restart: el proceso nuevo tampoco funde adquisición y merge, y converge sin re-adquirir de más una vez confirmado held (Finding 1)', async () => {
307
+ const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
308
+ const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
309
+ const { s0 } = declareJoinIntentCohort(aHead, bHead);
310
+ let s = s0;
311
+ // "Proceso 1" (antes del crash): primer tick de un proceso vivo —
312
+ // `ensureIntegrationLock` hace trabajo real y devuelve 'acquired'.
313
+ // El "crash" ocurre justo acá: cero merges intentados todavía.
314
+ const callsP1 = { lock: 0, merge: [], abort: [] };
315
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, fakeMergeRuntime('acquired', callsP1), 2)).state;
316
+ expect(callsP1.merge).toEqual([]);
317
+ expect(s.tracks.find((t) => t.trackId === 'a').phase).toBe('JOIN_INTENT');
318
+ // "Proceso 2" (restart): closure de runtime NUEVA — el journal
319
+ // sigue mostrando JOIN_INTENT (nada se persiste sobre el lock en
320
+ // sí, por diseño: es un primitivo de filesystem ajeno al
321
+ // journal). Su PROPIO primer tick también adquiere de verdad
322
+ // (reclamo de identidad muerta del proceso 1 — esa lógica en
323
+ // aislamiento ya la prueba `join.test.ts`/Task 10) y por eso
324
+ // TAMBIÉN corta acá — nunca funde su propia adquisición con un
325
+ // intento de merge en el mismo call.
326
+ const callsP2a = { lock: 0, merge: [], abort: [] };
327
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, fakeMergeRuntime('acquired', callsP2a), 2)).state;
328
+ expect(callsP2a.merge).toEqual([]);
329
+ expect((0, git_1.readMergeHead)(planRoot)).toBeNull();
330
+ expect(mergeCommitCountFor(planRoot, aHead)).toBe(0);
331
+ expect(s.tracks.find((t) => t.trackId === 'a').phase).toBe('JOIN_INTENT');
332
+ // Recién ahora, con el lock confirmado held DENTRO del proceso 2
333
+ // (misma memoización por closure que la implementación real), el
334
+ // SIGUIENTE call avanza al intento de merge — una única vez, sin
335
+ // re-adquirir ni doble-adquirir.
336
+ const callsP2b = { lock: 0, merge: [], abort: [] };
337
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, fakeMergeRuntime('already-held', callsP2b), 2)).state;
338
+ expect(callsP2b.lock).toBe(1);
339
+ expect(callsP2b.merge).toEqual([aHead]);
340
+ expect(mergeCommitCountFor(planRoot, aHead)).toBe(1);
341
+ expect(s.tracks.find((t) => t.trackId === 'a').phase).toBe('MERGED_UNVERIFIED');
342
+ });
343
+ });
344
+ // --- Post-review fix (Finding 2, revisión de Task 11): el mensaje de una
345
+ // excepción capturada de `mergeFrozenTrack`/`abortOwnedMerge` JAMÁS decide
346
+ // nada (la relectura vía `gatherJoinObservation` sigue siendo la única
347
+ // fuente de verdad, R6.8) — pero ahora SÍ queda como `detail` diagnóstico
348
+ // en el evento correspondiente, para que un operador pueda distinguir un
349
+ // conflicto benigno de un repo estructuralmente roto.
350
+ describe('runMergeTrack — el mensaje de una excepción capturada queda como detail diagnóstico, nunca decide (Finding 2)', () => {
351
+ function readEvents() {
352
+ let raw = '';
353
+ try {
354
+ raw = fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, BRANCH), 'utf8');
355
+ }
356
+ catch {
357
+ return [];
358
+ }
359
+ return raw.split('\n').filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
360
+ }
361
+ function runtimeWithThrowingMerge(errorMessage) {
362
+ const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
363
+ return {
364
+ addWorktree: real.addWorktree, initTrackJournal: real.initTrackJournal,
365
+ spawnSupervisor: real.spawnSupervisor, observeSupervisor: real.observeSupervisor,
366
+ stopOwnSupervisor: real.stopOwnSupervisor, removeOwnedWorktree: real.removeOwnedWorktree,
367
+ removeOwnedBranch: real.removeOwnedBranch, emitFreezeRequest: real.emitFreezeRequest,
368
+ // Deliberadamente NUNCA llama a `real.mergeFrozenTrack`: el repo
369
+ // real queda intacto, así la relectura post-catch ve EXACTAMENTE
370
+ // lo mismo que `before` — la única forma honesta de probar que
371
+ // el mensaje de la excepción no cambia la decisión.
372
+ mergeFrozenTrack() { throw new Error(errorMessage); },
373
+ abortOwnedMerge() { throw new Error(errorMessage); },
374
+ async ensureIntegrationLock() { return 'already-held'; },
375
+ async pauseControllerGeneration() { return false; },
376
+ releaseIntegrationLockIfHeld() { },
377
+ };
378
+ }
379
+ test('retry-merge: fallo simulado ("disco lleno") queda como detail en track-merge-attempted, decisión sigue siendo retry (R6.8)', async () => {
380
+ const aHead = makeTrackHead('a', 'a.txt', 'A content\n');
381
+ const bHead = makeTrackHead('b', 'b.txt', 'B content\n');
382
+ const refA = trackRef('a', { frozenHeadSha: aHead });
383
+ const refB = trackRef('b', { frozenHeadSha: bHead });
384
+ const intentA = { expectedPlanHeadSha: baseSha, expectedTrackHeadSha: aHead, strategy: 'no-ff' };
385
+ const s0 = declareCohort([{ ...refA, phase: 'JOIN_INTENT', joinIntent: intentA }, refB]);
386
+ const runtime = runtimeWithThrowingMerge('fallo simulado: disco lleno');
387
+ const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, runtime, 2);
388
+ // Fail-safe: NUNCA se marca fundido por confiar en la excepción —
389
+ // el repo real nunca se tocó, así que la relectura post-catch
390
+ // sigue viendo "no fundido todavía" y el track queda reintentable.
391
+ expect(result.state.tracks.find((t) => t.trackId === 'a').phase).toBe('JOIN_INTENT');
392
+ expect(mergeCommitCountFor(planRoot, aHead)).toBe(0);
393
+ // Fail-LOUD: el mensaje SÍ quedó como rastro diagnóstico del evento.
394
+ const attempted = readEvents().filter((e) => e.kind === 'track-merge-attempted');
395
+ expect(attempted).toHaveLength(1);
396
+ expect(attempted[0].detail).toBe('fallo simulado: disco lleno');
397
+ });
398
+ test('abort-own-merge: fallo simulado queda como detail en track-merge-aborted, decisión sigue siendo abort-own-merge (R6.8)', async () => {
399
+ // Conflicto REAL (misma técnica que 'during-conflict' arriba):
400
+ // necesitamos un MERGE_HEAD real apuntando a nuestro propio
401
+ // `expectedTrackHeadSha` para que `decideJoinReconciliation`
402
+ // devuelva 'abort-own-merge'.
403
+ const aHead = makeTrackHead('a', 'shared.txt', 'A-version\n');
404
+ const bHead = makeTrackHead('b', 'shared.txt', 'B-version\n');
405
+ const refA = trackRef('a', { frozenHeadSha: aHead });
406
+ const refB = trackRef('b', { frozenHeadSha: bHead });
407
+ declareCohort([refA, refB]);
408
+ const realRuntime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
409
+ let s = (0, store_1.readJournal)(planRoot, BRANCH).state;
410
+ for (let i = 0; i < 20; i++) {
411
+ const a = s.tracks.find((t) => t.trackId === 'a');
412
+ const b = s.tracks.find((t) => t.trackId === 'b');
413
+ if (a.phase === 'MERGED_UNVERIFIED' && b.phase === 'JOIN_INTENT' && b.joinIntent !== undefined)
414
+ break;
415
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, realRuntime, 2)).state;
416
+ }
417
+ const intentB = s.tracks.find((t) => t.trackId === 'b').joinIntent;
418
+ expect(() => (0, git_1.mergeFrozenTrack)(planRoot, intentB)).toThrow();
419
+ expect((0, git_1.readMergeHead)(planRoot)).toBe(bHead);
420
+ const runtime = runtimeWithThrowingMerge('fallo simulado: git binario roto');
421
+ const afterAbortAttempt = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
422
+ // Fail-safe: el fake NUNCA corrió `git merge --abort` de verdad
423
+ // (el MERGE_HEAD sigue siendo nuestro), así que la relectura
424
+ // post-catch decide 'abort-own-merge' de nuevo — el track sigue
425
+ // en JOIN_INTENT, jamás se asume abortado por la excepción sola.
426
+ expect((0, git_1.readMergeHead)(planRoot)).toBe(bHead);
427
+ expect(afterAbortAttempt.tracks.find((t) => t.trackId === 'b').phase).toBe('JOIN_INTENT');
428
+ // Fail-LOUD: el mensaje quedó como detail diagnóstico.
429
+ const aborted = readEvents().filter((e) => e.kind === 'track-merge-aborted');
430
+ expect(aborted).toHaveLength(1);
431
+ expect(aborted[0].detail).toBe('fallo simulado: git binario roto');
432
+ // Limpieza real: el índice sigue con el conflicto real dejado por
433
+ // el `mergeFrozenTrack` de arriba — un abort real (fuera del
434
+ // driver, mismo patrón que el resto de este archivo) lo deja
435
+ // limpio para no ensuciar los tests siguientes.
436
+ (0, child_process_1.execFileSync)('git', ['merge', '--abort'], { cwd: planRoot, stdio: 'pipe' });
437
+ });
438
+ });
439
+ });
@@ -0,0 +1,119 @@
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 post-review items 1 (CRITICAL) + 4: cobertura con git REAL de
7
+ // `defaultTrackRuntime` — la única implementación que toca git/worktrees/
8
+ // gitignore de verdad. Todos los demás tests de `reconcileTracks` usan un
9
+ // `TrackRuntime` fake; este archivo es el que habría atrapado el bug crítico
10
+ // original (chequear `.gitignore` contra el HEAD vivo del plan en vez de
11
+ // contra el worktree recién creado, checkeado en `baseSha`).
12
+ const fs_1 = __importDefault(require("fs"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const os_1 = __importDefault(require("os"));
15
+ const child_process_1 = require("child_process");
16
+ const git_fixture_1 = require("../../helpers/git-fixture");
17
+ const tracks_1 = require("../../../src/commands/watch/tracks");
18
+ const git_1 = require("../../../src/core/tracks/git");
19
+ function trackRef(worktreePath, overrides = {}) {
20
+ return {
21
+ trackId: 'x', worktreePath, branch: 'awm-track/x',
22
+ ownership: [], sharedResources: [], dependsOn: [],
23
+ fencingToken: 'f'.repeat(32), phase: 'PREPARE_INTENT', readinessNonce: 'r'.repeat(32),
24
+ ...overrides,
25
+ };
26
+ }
27
+ describe('defaultTrackRuntime — integración con git real (R4.1, R4.6, C2, post-review CRITICAL fix)', () => {
28
+ let planRoot;
29
+ let worktreePath;
30
+ beforeEach(() => {
31
+ planRoot = (0, git_fixture_1.initRepo)();
32
+ // `git worktree add` exige un destino que no exista o esté vacío: se
33
+ // reserva el nombre con mkdtemp y se libera antes de usarlo, para que
34
+ // el path sea único pero no un directorio ya ocupado por mkdtemp.
35
+ worktreePath = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-runtime-wt-'));
36
+ fs_1.default.rmdirSync(worktreePath);
37
+ });
38
+ afterEach(() => {
39
+ try {
40
+ (0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: planRoot, stdio: 'pipe' });
41
+ }
42
+ catch { /* planRoot puede ya no ser un repo válido */ }
43
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true });
44
+ fs_1.default.rmSync(worktreePath, { recursive: true, force: true });
45
+ });
46
+ test('happy path: crea el worktree real cuando `.awm` ya está ignorado en baseSha', () => {
47
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
48
+ const baseSha = (0, git_fixture_1.commitFile)(planRoot, 'a.txt', 'contenido'); // baseSha SÍ incluye el .gitignore commiteado arriba
49
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
50
+ const ref = trackRef(worktreePath);
51
+ expect(() => runtime.addWorktree(planRoot, ref, baseSha)).not.toThrow();
52
+ expect(fs_1.default.existsSync(path_1.default.join(worktreePath, 'a.txt'))).toBe(true);
53
+ expect(fs_1.default.existsSync(path_1.default.join(worktreePath, '.gitignore'))).toBe(true);
54
+ // `toContain` sobre la salida cruda compara STRINGS: git imprime separadores POSIX y
55
+ // el nombre largo, mientras `worktreePath` viene del tmpdir en 8.3 con backslashes.
56
+ // Se afirma la propiedad real — git registra ESTE worktree en ESTA branch — con la
57
+ // misma función que usa el producto.
58
+ expect((0, git_1.ownedWorktreeExists)(planRoot, worktreePath, 'awm-track/x')).toBe(true);
59
+ });
60
+ test('CRITICAL fix: baseSha SIN .gitignore de .awm pero el HEAD vivo del plan SÍ lo tiene — la comprobación vieja (contra planRoot) mentía "ignorado"; la nueva (contra el worktree) detecta que NO lo está y limpia el worktree que acaba de crear (C2)', () => {
61
+ // baseSha = el commit ANTES de que el plan declare `.awm/` ignorado.
62
+ const baseSha = (0, git_fixture_1.commitFile)(planRoot, 'a.txt', 'contenido');
63
+ // El HEAD VIVO del plan avanza y agrega el .gitignore DESPUÉS de baseSha.
64
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
65
+ // Demuestra la premisa exacta del bug: preguntarle al repo del plan en
66
+ // su HEAD vivo dice "ignorado" — pero el worktree que se va a crear
67
+ // está checkeado en `baseSha`, que NO tiene ese `.gitignore`.
68
+ expect((0, git_1.isAwmGitignored)(planRoot)).toBe(true);
69
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
70
+ const ref = trackRef(worktreePath);
71
+ expect(() => runtime.addWorktree(planRoot, ref, baseSha)).toThrow(/no está gitignoreado/);
72
+ // C2: el worktree es NUESTRO (lo acabamos de crear en esta misma
73
+ // llamada) — se descarta, nunca queda vivo e inseguro.
74
+ expect(fs_1.default.existsSync(worktreePath)).toBe(false);
75
+ const list = (0, child_process_1.execFileSync)('git', ['worktree', 'list'], { cwd: planRoot, encoding: 'utf8' });
76
+ expect(list).not.toContain(worktreePath);
77
+ // Prueba directa de que el worktree recién creado (checkeado en
78
+ // baseSha) efectivamente NO tenía el .gitignore, confirmando que la
79
+ // única forma honesta de responder es mirar el worktree, no el plan.
80
+ const probe = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-track-runtime-probe-'));
81
+ fs_1.default.rmdirSync(probe);
82
+ try {
83
+ (0, child_process_1.execFileSync)('git', ['worktree', 'add', probe, baseSha], { cwd: planRoot, stdio: 'pipe' });
84
+ expect((0, git_1.isAwmGitignored)(probe)).toBe(false);
85
+ }
86
+ finally {
87
+ try {
88
+ (0, child_process_1.execFileSync)('git', ['worktree', 'remove', '--force', probe], { cwd: planRoot, stdio: 'pipe' });
89
+ }
90
+ catch { /* best-effort */ }
91
+ fs_1.default.rmSync(probe, { recursive: true, force: true });
92
+ }
93
+ });
94
+ test('destino no vacío nunca se adopta (R4.6): addOwnedWorktree real rechaza sin siquiera llegar al chequeo de gitignore', () => {
95
+ const baseSha = (0, git_fixture_1.commitFile)(planRoot, 'a.txt', 'contenido');
96
+ fs_1.default.mkdirSync(worktreePath, { recursive: true });
97
+ fs_1.default.writeFileSync(path_1.default.join(worktreePath, 'foreign.txt'), 'ajeno');
98
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
99
+ const ref = trackRef(worktreePath);
100
+ expect(() => runtime.addWorktree(planRoot, ref, baseSha)).toThrow(/no vacío/);
101
+ expect(fs_1.default.existsSync(path_1.default.join(worktreePath, 'foreign.txt'))).toBe(true); // nunca tocado
102
+ const list = (0, child_process_1.execFileSync)('git', ['worktree', 'list'], { cwd: planRoot, encoding: 'utf8' });
103
+ expect(list).not.toContain(worktreePath);
104
+ });
105
+ test('initTrackJournal real: crea journal + descriptor en el worktree, y jamás pisa un trackContext ajeno preexistente (R4.6)', () => {
106
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
107
+ const baseSha = (0, git_fixture_1.commitFile)(planRoot, 'a.txt', 'contenido');
108
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, 'main');
109
+ const ref = trackRef(worktreePath, { branch: 'awm-track/x' });
110
+ runtime.addWorktree(planRoot, ref, baseSha);
111
+ const context = { trackId: 'x', taskIds: [], planDigest: '', baseSha, planJournalId: 'j-plan-1' };
112
+ expect(() => runtime.initTrackJournal(ref, context)).not.toThrow();
113
+ expect(fs_1.default.existsSync(path_1.default.join(worktreePath, '.awm', 'track.json'))).toBe(true);
114
+ // Un trackContext AJENO preexistente (otro trackId) nunca se sobreescribe.
115
+ const foreignRef = trackRef(worktreePath, { trackId: 'y', branch: 'awm-track/x' });
116
+ const foreignContext = { trackId: 'y', taskIds: [], planDigest: '', baseSha, planJournalId: 'j-plan-1' };
117
+ expect(() => runtime.initTrackJournal(foreignRef, foreignContext)).toThrow(/pertenece a otro track/);
118
+ });
119
+ });