agentic-workflow-manager 6.2.1 → 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 (69) 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/journal/adapter.js +46 -6
  16. package/dist/src/core/journal/paths.js +9 -0
  17. package/dist/src/core/journal/process.js +101 -1
  18. package/dist/src/core/journal/requests.js +5 -1
  19. package/dist/src/core/journal/store.js +34 -2
  20. package/dist/src/core/journal/types.js +93 -4
  21. package/dist/src/core/paths.js +51 -0
  22. package/dist/src/core/tracks/concurrency.js +85 -0
  23. package/dist/src/core/tracks/context.js +89 -0
  24. package/dist/src/core/tracks/descriptor.js +40 -0
  25. package/dist/src/core/tracks/git.js +318 -0
  26. package/dist/src/core/tracks/join.js +185 -0
  27. package/dist/src/core/tracks/ownership.js +100 -0
  28. package/dist/src/core/tracks/plan-parser.js +108 -0
  29. package/dist/src/core/tracks/protocol.js +466 -0
  30. package/dist/src/core/tracks/teardown.js +34 -0
  31. package/dist/src/core/tracks/types.js +14 -0
  32. package/dist/src/index.js +2 -0
  33. package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
  34. package/dist/tests/commands/track/fixtures.js +13 -0
  35. package/dist/tests/commands/track/status.test.js +157 -0
  36. package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
  37. package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
  38. package/dist/tests/commands/track/verbs.test.js +326 -0
  39. package/dist/tests/commands/watch/apply.test.js +116 -4
  40. package/dist/tests/commands/watch/runner.test.js +22 -0
  41. package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
  42. package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
  43. package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
  44. package/dist/tests/commands/watch/track-finalize.test.js +643 -0
  45. package/dist/tests/commands/watch/track-freeze.test.js +591 -0
  46. package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
  47. package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
  48. package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
  49. package/dist/tests/core/journal/adapter-override.test.js +51 -0
  50. package/dist/tests/core/journal/process.test.js +37 -0
  51. package/dist/tests/core/journal/requests.test.js +21 -0
  52. package/dist/tests/core/journal/store.test.js +28 -0
  53. package/dist/tests/core/journal/types.test.js +89 -0
  54. package/dist/tests/core/same-existing-path.test.js +48 -0
  55. package/dist/tests/core/tracks/concurrency.test.js +134 -0
  56. package/dist/tests/core/tracks/context.test.js +177 -0
  57. package/dist/tests/core/tracks/descriptor.test.js +76 -0
  58. package/dist/tests/core/tracks/git.test.js +132 -0
  59. package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
  60. package/dist/tests/core/tracks/join.test.js +197 -0
  61. package/dist/tests/core/tracks/ownership.test.js +96 -0
  62. package/dist/tests/core/tracks/plan-parser.test.js +94 -0
  63. package/dist/tests/core/tracks/protocol.test.js +416 -0
  64. package/dist/tests/core/tracks/teardown.test.js +62 -0
  65. package/dist/tests/helpers/git-fixture.js +35 -0
  66. package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
  67. package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
  68. package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
  69. package/package.json +1 -1
@@ -0,0 +1,426 @@
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 13 (R4.2/R4.3/R4.6/R4.10/C2/C9): crash/restart del teardown durable de
7
+ // UN track, con git REAL (mismo criterio que `track-bootstrap-crash.test.ts`,
8
+ // Task 9, y `track-join-crash.test.ts`, Task 11 — Tasks 8-12 establecieron el
9
+ // precedente de probar convergencia de crash contra un repo/worktree real,
10
+ // no fakes en memoria). `runBeginTeardown` garantiza, por construcción, "a lo
11
+ // sumo UN efecto real por invocación, persistido antes de seguir" (ver su
12
+ // comentario grande en `tracks.ts`) — así que cada uno de los 8 puntos del
13
+ // plan se representa fijando el journal EXACTAMENTE como quedaría tras un
14
+ // crash real justo ahí (mismo patrón que `setPhase`/las manipulaciones
15
+ // directas de `track-bootstrap-crash.test.ts` para los puntos "efecto real
16
+ // ya corrió, resultado todavía no persistido"), y verificando que un
17
+ // `reconcileTracks` fresco (runtime NUEVO, journal persistido intacto)
18
+ // converge a `REMOVED` con el worktree y la branch REALMENTE ausentes.
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 store_1 = require("../../../src/core/journal/store");
26
+ const git_1 = require("../../../src/core/tracks/git");
27
+ const descriptor_1 = require("../../../src/core/tracks/descriptor");
28
+ const BRANCH = 'main';
29
+ /** Identidad que `refIsAlive` (identity-verified, R4.8) SIEMPRE reporta como
30
+ * muerta: ningún proceso real jamás matchea estos campos fabricados — usada
31
+ * para representar "el supervisor propio ya está confirmado ausente"
32
+ * (idéntico efecto observable que si `stopOwnSupervisor` realmente hubiera
33
+ * corrido y confirmado, sin depender de matar un proceso real). */
34
+ function deadRef() {
35
+ return { pid: 999999, startTime: 'nunca-existió', spawnNonce: 'x', argvDigest: 'x', processGroup: 999999, psArgsDigest: 'dead' };
36
+ }
37
+ /** Runtime de producción para worktree/branch Y para `stopOwnSupervisor`
38
+ * (git real y `terminatePreviouslyOwnedGroup` real vía `defaultTrackRuntime`
39
+ * — post-review (hallazgo de revisión de Task 13): un fake que nunca llama
40
+ * al primitivo real no puede probar la propiedad de seguridad
41
+ * "ninguna señal alcanza un proceso ajeno" para el camino que de verdad
42
+ * corre en producción). Con `deadRef()` esto sigue resolviendo por el atajo
43
+ * de `groupIsGone` (pid 999999 no existe de verdad) — cero espera, mismo
44
+ * comportamiento observable que el fake anterior — pero ahora es el código
45
+ * real, no una simulación. Ningún otro método de `TrackRuntime` debería
46
+ * invocarse — esta suite solo ejercita `begin-teardown`. */
47
+ function buildRuntime(planRoot, events) {
48
+ const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
49
+ const unexpected = (what) => () => {
50
+ throw new Error(`no debería llamarse: ${what} (esta suite solo ejercita begin-teardown)`);
51
+ };
52
+ return {
53
+ addWorktree: unexpected('addWorktree'),
54
+ initTrackJournal: unexpected('initTrackJournal'),
55
+ spawnSupervisor: unexpected('spawnSupervisor'),
56
+ observeSupervisor: unexpected('observeSupervisor'),
57
+ async stopOwnSupervisor(ref) {
58
+ const confirmed = await real.stopOwnSupervisor(ref);
59
+ events.push(`stop-effect:${ref.trackId}`);
60
+ return confirmed;
61
+ },
62
+ removeOwnedWorktree(repo, ref) {
63
+ real.removeOwnedWorktree(repo, ref);
64
+ events.push(`worktree-effect:${ref.trackId}`);
65
+ },
66
+ removeOwnedBranch(repo, branch) {
67
+ real.removeOwnedBranch(repo, branch);
68
+ events.push(`branch-effect:${branch}`);
69
+ },
70
+ emitFreezeRequest: unexpected('emitFreezeRequest'),
71
+ mergeFrozenTrack: unexpected('mergeFrozenTrack'),
72
+ abortOwnedMerge: unexpected('abortOwnedMerge'),
73
+ async ensureIntegrationLock() { return unexpected('ensureIntegrationLock')(); },
74
+ async pauseControllerGeneration() { return unexpected('pauseControllerGeneration')(); },
75
+ releaseIntegrationLockIfHeld: unexpected('releaseIntegrationLockIfHeld'),
76
+ };
77
+ }
78
+ function declareCohort(planRoot, root, baseSha) {
79
+ (0, store_1.initJournal)(planRoot, BRANCH);
80
+ const s0 = (0, store_1.readJournal)(planRoot, BRANCH).state;
81
+ s0.cohortPhase = 'FALLBACK_PENDING';
82
+ s0.cohortBaseSha = baseSha;
83
+ s0.tracks = [
84
+ {
85
+ trackId: 'a', worktreePath: path_1.default.join(root, 'track-a'), branch: 'awm-track/a',
86
+ ownership: [], sharedResources: [], dependsOn: [],
87
+ fencingToken: 'fa'.padEnd(32, '0'), phase: 'DECLARED', readinessNonce: 'ra'.padEnd(32, '0'),
88
+ },
89
+ // 'b' se queda en DECLARED siempre: `nextProtocolEffect` (FALLBACK_PENDING)
90
+ // solo elige un track a la vez, alfabéticamente — jamás toca 'b'
91
+ // mientras 'a' no llegue a REMOVED.
92
+ {
93
+ trackId: 'b', worktreePath: path_1.default.join(root, 'track-b'), branch: 'awm-track/b',
94
+ ownership: [], sharedResources: [], dependsOn: [],
95
+ fencingToken: 'fb'.padEnd(32, '0'), phase: 'DECLARED', readinessNonce: 'rb'.padEnd(32, '0'),
96
+ },
97
+ ];
98
+ (0, store_1.writeJournal)(planRoot, BRANCH, s0);
99
+ return (0, store_1.readJournal)(planRoot, BRANCH).state;
100
+ }
101
+ function setTrackA(planRoot, patch) {
102
+ const s = (0, store_1.readJournal)(planRoot, BRANCH).state;
103
+ s.tracks = s.tracks.map((t) => (t.trackId === 'a' ? { ...t, ...patch } : t));
104
+ (0, store_1.writeJournal)(planRoot, BRANCH, s);
105
+ return (0, store_1.readJournal)(planRoot, BRANCH).state;
106
+ }
107
+ function trackA(s) {
108
+ return s.tracks.find((t) => t.trackId === 'a');
109
+ }
110
+ function realWorktreeBranches(planRoot) {
111
+ let out;
112
+ try {
113
+ out = (0, child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], { cwd: planRoot, encoding: 'utf8' });
114
+ }
115
+ catch {
116
+ return [];
117
+ }
118
+ const branches = [];
119
+ for (const line of out.split('\n')) {
120
+ if (line.startsWith('branch '))
121
+ branches.push(line.slice('branch '.length).replace('refs/heads/', ''));
122
+ }
123
+ return branches;
124
+ }
125
+ function branchListed(planRoot, branch) {
126
+ const out = (0, child_process_1.execFileSync)('git', ['branch', '--list', branch], { cwd: planRoot, encoding: 'utf8' });
127
+ return out.trim().length > 0;
128
+ }
129
+ describe('reconcileTracks — crash/restart de teardown con git real (Task 13, R4.2/R4.3/R4.6/R4.10/C2/C9)', () => {
130
+ let root;
131
+ let planRoot;
132
+ let baseSha;
133
+ beforeEach(() => {
134
+ planRoot = (0, git_fixture_1.initRepo)();
135
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
136
+ baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
137
+ root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-teardown-crash-root-'));
138
+ });
139
+ afterEach(() => {
140
+ try {
141
+ (0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: planRoot, stdio: 'pipe' });
142
+ }
143
+ catch { /* best-effort */ }
144
+ fs_1.default.rmSync(planRoot, { recursive: true, force: true });
145
+ fs_1.default.rmSync(root, { recursive: true, force: true });
146
+ });
147
+ /** Materializa el worktree+branch REALES de 'a' (git de verdad) y su
148
+ * descriptor `.awm/track.json` — la prueba de ownership que
149
+ * `worktreeOwnershipProven` exige antes de `remove-owned-worktree`. */
150
+ function materializeTrackA(s) {
151
+ const ref = trackA(s);
152
+ (0, git_1.addOwnedWorktree)(planRoot, ref, baseSha);
153
+ (0, descriptor_1.writeDescriptor)(ref.worktreePath, {
154
+ schema: 1, planRoot: fs_1.default.realpathSync(planRoot), planBranch: BRANCH,
155
+ trackId: ref.trackId, planJournalId: s.journalId, fencingToken: ref.fencingToken,
156
+ });
157
+ }
158
+ const teardownCrashPoints = [
159
+ 'after-teardown-request', 'after-teardown-intent', 'after-stop-effect',
160
+ 'after-supervisor-stopped', 'after-worktree-effect', 'after-worktree-removed',
161
+ 'after-branch-effect', 'after-branch-removed',
162
+ ];
163
+ /** Deja el journal EXACTAMENTE como quedaría tras un crash real justo
164
+ * después del punto nombrado — mismo criterio que `setPhase` en
165
+ * `track-bootstrap-crash.test.ts`: para los puntos donde el efecto real
166
+ * YA corrió pero `tracks.ts` no llegó a persistir la observación
167
+ * (`after-stop-effect`, `after-worktree-effect`, `after-branch-effect`),
168
+ * se reproduce la mutación real fuera de `reconcileTracks` y se
169
+ * mantiene la fase ANTERIOR — el próximo tick debe volver a observar el
170
+ * mundo real (no confiar en memoria) para converger. */
171
+ function setupAt(point) {
172
+ let s = declareCohort(planRoot, root, baseSha);
173
+ materializeTrackA(s);
174
+ s = (0, store_1.readJournal)(planRoot, BRANCH).state;
175
+ const ref = trackA(s);
176
+ if (point === 'after-teardown-request') {
177
+ return setTrackA(planRoot, { phase: 'TEARDOWN_REQUESTED', supervisorProcessRef: deadRef() });
178
+ }
179
+ const teardownIntent = { worktreePath: ref.worktreePath, branch: ref.branch, supervisorNonce: undefined };
180
+ if (point === 'after-teardown-intent') {
181
+ return setTrackA(planRoot, { phase: 'TEARDOWN_INTENT', teardownIntent, supervisorProcessRef: deadRef() });
182
+ }
183
+ if (point === 'after-stop-effect') {
184
+ // El efecto de stop YA corrió y el mundo real ya muestra al
185
+ // supervisor confirmado ausente (`deadRef`) — pero la fase
186
+ // TODAVÍA no avanzó a SUPERVISOR_STOPPED (crash justo ahí).
187
+ return setTrackA(planRoot, { phase: 'TEARDOWN_INTENT', teardownIntent, supervisorProcessRef: deadRef() });
188
+ }
189
+ if (point === 'after-supervisor-stopped') {
190
+ return setTrackA(planRoot, { phase: 'SUPERVISOR_STOPPED', teardownIntent, supervisorProcessRef: deadRef() });
191
+ }
192
+ if (point === 'after-worktree-effect') {
193
+ // El worktree YA se removió de verdad (fuera de `reconcileTracks`,
194
+ // mismo criterio que `after-worktree-effect` de Task 9), pero la
195
+ // fase todavía dice SUPERVISOR_STOPPED.
196
+ (0, git_1.removeOwnedWorktree)(planRoot, ref.worktreePath);
197
+ return setTrackA(planRoot, { phase: 'SUPERVISOR_STOPPED', teardownIntent, supervisorProcessRef: deadRef() });
198
+ }
199
+ if (point === 'after-worktree-removed') {
200
+ (0, git_1.removeOwnedWorktree)(planRoot, ref.worktreePath);
201
+ return setTrackA(planRoot, { phase: 'WORKTREE_REMOVED', teardownIntent, supervisorProcessRef: deadRef() });
202
+ }
203
+ if (point === 'after-branch-effect') {
204
+ (0, git_1.removeOwnedWorktree)(planRoot, ref.worktreePath);
205
+ (0, git_1.removeOwnedBranch)(planRoot, ref.branch);
206
+ return setTrackA(planRoot, { phase: 'WORKTREE_REMOVED', teardownIntent, supervisorProcessRef: deadRef() });
207
+ }
208
+ // 'after-branch-removed'
209
+ (0, git_1.removeOwnedWorktree)(planRoot, ref.worktreePath);
210
+ (0, git_1.removeOwnedBranch)(planRoot, ref.branch);
211
+ return setTrackA(planRoot, { phase: 'BRANCH_REMOVED', teardownIntent, supervisorProcessRef: deadRef() });
212
+ }
213
+ test.each(teardownCrashPoints)('teardown converge desde %s (C9)', async (point) => {
214
+ let s = setupAt(point);
215
+ const events = [];
216
+ // "Restart" tras el crash: runtime NUEVO (misma disciplina que
217
+ // `track-bootstrap-crash.test.ts` — instrumentación fresca salvo lo
218
+ // que sobreviviría de verdad a un crash real, que acá es el propio
219
+ // repo git). Reconciliar hasta REMOVED o hasta agotar un límite
220
+ // generoso de ticks (cada tick es a lo sumo UNA frontera real).
221
+ const runtime = buildRuntime(planRoot, events);
222
+ for (let i = 0; i < 20 && trackA(s).phase !== 'REMOVED'; i++) {
223
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
224
+ }
225
+ expect(trackA(s).phase).toBe('REMOVED');
226
+ expect(trackA(s).blockedReason).toBeUndefined();
227
+ // Prueba REAL (no solo el journal): el worktree y la branch de 'a'
228
+ // están genuinamente ausentes del repo.
229
+ expect(fs_1.default.existsSync(path_1.default.join(root, 'track-a'))).toBe(false);
230
+ expect(realWorktreeBranches(planRoot)).not.toContain('awm-track/a');
231
+ expect(branchListed(planRoot, 'awm-track/a')).toBe(false);
232
+ // 'b' (DECLARED, nunca tocado) permanece exactamente como se declaró.
233
+ const b = s.tracks.find((t) => t.trackId === 'b');
234
+ expect(b.phase).toBe('DECLARED');
235
+ expect(fs_1.default.existsSync(path_1.default.join(root, 'track-b'))).toBe(false);
236
+ });
237
+ // Step 4 del plan ("supervisor propio muerto confirmado Y lock ausente"):
238
+ // un lock advisory que el supervisor dejó sin liberar (crash a mitad de
239
+ // su propia salida) nunca debe atascar el teardown para siempre — una
240
+ // vez que su grupo entero está confirmado ausente por identidad, el lock
241
+ // es demostrablemente stale (vive en el worktree PROPIO del track) y se
242
+ // reclama. Usa `defaultTrackRuntime` de PRODUCCIÓN (no el fake de esta
243
+ // suite) para probar el efecto real de `stopOwnSupervisor`.
244
+ test('lock advisory stale (supervisor muerto sin liberarlo) se reclama y el teardown converge (C9)', async () => {
245
+ let s = declareCohort(planRoot, root, baseSha);
246
+ materializeTrackA(s);
247
+ s = (0, store_1.readJournal)(planRoot, BRANCH).state;
248
+ const ref = trackA(s);
249
+ const lockPath = path_1.default.join(ref.worktreePath, '.awm', 'journal', 'supervisor.lock');
250
+ fs_1.default.mkdirSync(path_1.default.dirname(lockPath), { recursive: true });
251
+ fs_1.default.writeFileSync(lockPath, 'lock stale de un supervisor que crasheó sin liberar\n');
252
+ s = setTrackA(planRoot, {
253
+ phase: 'TEARDOWN_INTENT',
254
+ teardownIntent: { worktreePath: ref.worktreePath, branch: ref.branch, supervisorNonce: undefined },
255
+ supervisorProcessRef: deadRef(), // proceso confirmado muerto: solo el lock lo retiene
256
+ });
257
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
258
+ for (let i = 0; i < 20 && trackA(s).phase !== 'REMOVED'; i++) {
259
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
260
+ }
261
+ expect(trackA(s).phase).toBe('REMOVED');
262
+ expect(fs_1.default.existsSync(lockPath)).toBe(false);
263
+ expect(fs_1.default.existsSync(path_1.default.join(root, 'track-a'))).toBe(false);
264
+ expect(branchListed(planRoot, 'awm-track/a')).toBe(false);
265
+ });
266
+ // R4.6/R4.10/C9: un worktree cuyo ownership es indemostrable (descriptor
267
+ // ausente/corrupto, o simplemente no es el que `teardownIntent` espera)
268
+ // NUNCA se adopta ni se borra — el track se bloquea, y el contenido
269
+ // "ajeno" permanece byte-idéntico. `BLOCKED` es un dead end automático
270
+ // (ver `decideTeardown`): reconciliar de nuevo, cuantas veces sea, jamás
271
+ // lo saca solo de ahí.
272
+ test('worktree con ownership indemostrable bloquea SIN tocarlo — el fixture ajeno queda byte-idéntico (R4.6, R4.10, C9)', async () => {
273
+ let s = declareCohort(planRoot, root, baseSha);
274
+ const ref = trackA(s);
275
+ // El path existe (registrado como worktree real por git, igual que
276
+ // uno legítimamente propio) pero SIN el descriptor `.awm/track.json`
277
+ // que probaría identidad — `worktreeOwnershipProven` debe fallar.
278
+ (0, git_1.addOwnedWorktree)(planRoot, ref, baseSha);
279
+ const marker = path_1.default.join(ref.worktreePath, 'foreign.marker');
280
+ fs_1.default.writeFileSync(marker, 'contenido ajeno, nunca tocar\n');
281
+ const markerBytesBefore = fs_1.default.readFileSync(marker);
282
+ s = setTrackA(planRoot, {
283
+ phase: 'SUPERVISOR_STOPPED',
284
+ teardownIntent: { worktreePath: ref.worktreePath, branch: ref.branch, supervisorNonce: undefined },
285
+ supervisorProcessRef: deadRef(),
286
+ });
287
+ const events = [];
288
+ const runtime = buildRuntime(planRoot, events);
289
+ for (let i = 0; i < 5; i++) {
290
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
291
+ }
292
+ expect(trackA(s).phase).toBe('BLOCKED');
293
+ expect(trackA(s).blockedReason).toBe('worktree preexistente ajeno');
294
+ // Nunca se llamó al efecto real de remoción — ownership nunca se probó.
295
+ expect(events).toEqual([]);
296
+ // El worktree y su contenido siguen ahí, byte por byte.
297
+ expect(fs_1.default.existsSync(ref.worktreePath)).toBe(true);
298
+ expect(fs_1.default.readFileSync(marker)).toEqual(markerBytesBefore);
299
+ expect(realWorktreeBranches(planRoot)).toContain('awm-track/a');
300
+ // BLOCKED es un dead end AUTOMÁTICO: más reconciliaciones no lo mueven.
301
+ const again = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2);
302
+ expect(trackA(again.state).phase).toBe('BLOCKED');
303
+ expect(fs_1.default.readFileSync(marker)).toEqual(markerBytesBefore);
304
+ });
305
+ // Post-review (hallazgo de revisión de Task 13): la prueba literal que el
306
+ // sketch del plan (Step 5) pedía — `h.signalledForeignProcess()).toBe(false)`
307
+ // — y que esta suite nunca ejercitaba porque `stopOwnSupervisor` era
308
+ // 100% fake. `supervisorProcessRef` en el journal describe un supervisor
309
+ // previo ya extinto cuyo pgid el SO recicló (simulado, mismo criterio que
310
+ // el fixture R6 de `process.test.ts`) hacia `bystander`, un proceso REAL,
311
+ // vivo y completamente ajeno.
312
+ //
313
+ // Con `defaultTrackRuntime` real, esta identidad-fabricada NUNCA llega a
314
+ // `stopOwnSupervisor`/`terminatePreviouslyOwnedGroup`: `gatherTeardownObservation`
315
+ // ya usa `refIsAlive` (identidad completa) para decidir `ownSupervisorAlive`
316
+ // en la MISMA tick, así que un pgid reciclado con identidad distinta se
317
+ // reporta como "nuestro supervisor ya está muerto" y el teardown avanza
318
+ // por `accept-supervisor-stopped` — nunca por `stop-own-supervisor` — sin
319
+ // jamás tocar al bystander (defensa en profundidad ya existente, ver el
320
+ // comentario de `gatherTeardownObservation`). Esta prueba confirma esa
321
+ // propiedad end-to-end (converge a REMOVED sin señal alguna al ajeno); la
322
+ // prueba siguiente confirma la MISMA propiedad un nivel más abajo, en el
323
+ // primitivo `defaultTrackRuntime(...).stopOwnSupervisor` en sí mismo (el
324
+ // fix real de `groupLeaderReused` en `process.ts`), sin depender de esa
325
+ // capa superior.
326
+ test('supervisor propio con PGID reciclado por un proceso ajeno: el teardown converge sin jamás señalizarlo (R4.8, post-review)', async () => {
327
+ let s = declareCohort(planRoot, root, baseSha);
328
+ materializeTrackA(s);
329
+ s = (0, store_1.readJournal)(planRoot, BRANCH).state;
330
+ const ref = trackA(s);
331
+ // Vive hasta que el `finally` lo mate, NO 5 segundos. Estos dos tests son los
332
+ // únicos de la suite que asertan que el proceso ajeno SIGUE VIVO al final, y un
333
+ // tiempo fijo convierte esa aserción en una carrera contra la plataforma más lenta:
334
+ // en Windows este archivo tarda ~22s, así que el bystander moría de viejo y el test
335
+ // reportaba "lo matamos" cuando `signalledForeignProcess` probaba lo contrario.
336
+ // Se elimina la carrera en vez de agrandarle el margen.
337
+ const bystander = (0, child_process_1.spawn)(process.execPath, ['-e', 'setInterval(()=>{}, 1000)'], { detached: true, stdio: 'ignore' });
338
+ if (bystander.pid === undefined)
339
+ throw new Error('spawn de bystander falló');
340
+ const bystanderPid = bystander.pid;
341
+ await new Promise((resolve) => setTimeout(resolve, 150)); // dejar asentar identidad real vía ps
342
+ const staleSupervisorRef = {
343
+ pid: bystanderPid,
344
+ startTime: 'Mon Jan 1 00:00:00 2024', // supervisor anterior, ya extinto
345
+ spawnNonce: 'nonce-de-un-supervisor-anterior-ya-muerto',
346
+ argvDigest: 'deadbeefdeadbeef',
347
+ processGroup: bystanderPid, // MISMO número de pgid (reciclado)
348
+ psArgsDigest: 'cafebabecafebabe',
349
+ };
350
+ s = setTrackA(planRoot, {
351
+ phase: 'TEARDOWN_INTENT',
352
+ teardownIntent: { worktreePath: ref.worktreePath, branch: ref.branch, supervisorNonce: undefined },
353
+ supervisorProcessRef: staleSupervisorRef,
354
+ });
355
+ const killSpy = jest.spyOn(process, 'kill');
356
+ try {
357
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH, { termGraceMs: 100, killGraceMs: 100 });
358
+ for (let i = 0; i < 20 && trackA(s).phase !== 'REMOVED'; i++) {
359
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
360
+ }
361
+ // La propiedad que vale en TODAS las plataformas y es la razón de ser del test:
362
+ // el proceso ajeno JAMÁS recibe una señal. Se afirma primero, sin condicionar.
363
+ const signalledForeignProcess = killSpy.mock.calls.some(([target]) => Number(target) === -bystanderPid);
364
+ expect(signalledForeignProcess).toBe(false);
365
+ // La convergencia a REMOVED, en cambio, es POSIX-only y por diseño: en win32
366
+ // `refIsAlive` no consulta identidad (decisión deliberada de Ronda 3 — ps/pgrep
367
+ // de MSYS son ciegos a procesos nativos y declaraban muertes falsas), así que un
368
+ // pid reciclado se lee como "nuestro supervisor sigue vivo" y el teardown espera.
369
+ // Sobre-reportar vida es la dirección segura: nunca mata nada ajeno, solo espera.
370
+ // Afirmar REMOVED en win32 exigiría revertir esa decisión para que un test pase.
371
+ if (process.platform !== 'win32')
372
+ expect(trackA(s).phase).toBe('REMOVED');
373
+ // El bystander sigue vivo: nunca fue tocado.
374
+ expect(() => process.kill(bystanderPid, 0)).not.toThrow();
375
+ }
376
+ finally {
377
+ killSpy.mockRestore();
378
+ bystander.kill('SIGKILL');
379
+ }
380
+ });
381
+ // Complemento de la prueba anterior, un nivel más abajo: llama
382
+ // directamente a `defaultTrackRuntime(...).stopOwnSupervisor` (el mismo
383
+ // primitivo que `runBeginTeardown` invoca en producción para la decisión
384
+ // `stop-own-supervisor`), sin pasar por `gatherTeardownObservation`. Esto
385
+ // prueba el fix de `groupLeaderReused` (`process.ts`) en sí mismo — que
386
+ // el PRIMITIVO, no solo la capa de arriba, se niega a señalizar un pgid
387
+ // reciclado por un proceso ajeno vivo.
388
+ test('defaultTrackRuntime.stopOwnSupervisor jamás señaliza un pgid reciclado por un proceso ajeno, aunque se lo invoque directo (R4.8, post-review)', async () => {
389
+ let s = declareCohort(planRoot, root, baseSha);
390
+ materializeTrackA(s);
391
+ s = (0, store_1.readJournal)(planRoot, BRANCH).state;
392
+ const ref = trackA(s);
393
+ // Vive hasta que el `finally` lo mate, NO 5 segundos. Estos dos tests son los
394
+ // únicos de la suite que asertan que el proceso ajeno SIGUE VIVO al final, y un
395
+ // tiempo fijo convierte esa aserción en una carrera contra la plataforma más lenta:
396
+ // en Windows este archivo tarda ~22s, así que el bystander moría de viejo y el test
397
+ // reportaba "lo matamos" cuando `signalledForeignProcess` probaba lo contrario.
398
+ // Se elimina la carrera en vez de agrandarle el margen.
399
+ const bystander = (0, child_process_1.spawn)(process.execPath, ['-e', 'setInterval(()=>{}, 1000)'], { detached: true, stdio: 'ignore' });
400
+ if (bystander.pid === undefined)
401
+ throw new Error('spawn de bystander falló');
402
+ const bystanderPid = bystander.pid;
403
+ await new Promise((resolve) => setTimeout(resolve, 150));
404
+ const staleSupervisorRef = {
405
+ pid: bystanderPid,
406
+ startTime: 'Mon Jan 1 00:00:00 2024',
407
+ spawnNonce: 'nonce-de-un-supervisor-anterior-ya-muerto',
408
+ argvDigest: 'deadbeefdeadbeef',
409
+ processGroup: bystanderPid,
410
+ psArgsDigest: 'cafebabecafebabe',
411
+ };
412
+ const killSpy = jest.spyOn(process, 'kill');
413
+ try {
414
+ const runtime = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH, { termGraceMs: 100, killGraceMs: 100 });
415
+ const confirmed = await runtime.stopOwnSupervisor({ ...ref, supervisorProcessRef: staleSupervisorRef });
416
+ expect(confirmed).toBe(false); // fail-closed: nunca declara "confirmado ausente" arriesgando un ajeno
417
+ const signalledForeignProcess = killSpy.mock.calls.some(([target]) => Number(target) === -bystanderPid);
418
+ expect(signalledForeignProcess).toBe(false);
419
+ expect(() => process.kill(bystanderPid, 0)).not.toThrow();
420
+ }
421
+ finally {
422
+ killSpy.mockRestore();
423
+ bystander.kill('SIGKILL');
424
+ }
425
+ });
426
+ });
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ // R5 Task 16 (cierre por controller scripteado): `AWM_CONTROLLER_ARGV` es lo que vuelve
4
+ // FALSIFICABLE la afirmacion "el supervisor no conoce providers". Sin este seam, los unicos
5
+ // controllers posibles eran `codex` y `claude-code`, asi que la agnosticidad no se podia
6
+ // comprobar ni romper — solo creer.
7
+ const adapter_1 = require("../../../src/core/journal/adapter");
8
+ describe('adapterFor · override de argv del controller', () => {
9
+ const previous = process.env[adapter_1.CONTROLLER_ARGV_ENV];
10
+ afterEach(() => {
11
+ if (previous === undefined)
12
+ delete process.env[adapter_1.CONTROLLER_ARGV_ENV];
13
+ else
14
+ process.env[adapter_1.CONTROLLER_ARGV_ENV] = previous;
15
+ });
16
+ it('sin override, cada provider conserva su argv nativo', () => {
17
+ delete process.env[adapter_1.CONTROLLER_ARGV_ENV];
18
+ expect((0, adapter_1.adapterFor)('codex').launchArgv('P')[0]).toBe('codex');
19
+ expect((0, adapter_1.adapterFor)('claude-code').launchArgv('P')[0]).toBe('claude');
20
+ });
21
+ it('con override, el prompt viaja como ULTIMO argumento — el token llega igual', () => {
22
+ process.env[adapter_1.CONTROLLER_ARGV_ENV] = JSON.stringify(['node', '/tmp/ctl.mjs', '--flag']);
23
+ const argv = (0, adapter_1.adapterFor)('codex').launchArgv('Generacion activa: abc123');
24
+ expect(argv).toEqual(['node', '/tmp/ctl.mjs', '--flag', 'Generacion activa: abc123']);
25
+ });
26
+ it('el override NO reemplaza el resto del adapter (fencing/custodia siguen siendo del provider)', () => {
27
+ process.env[adapter_1.CONTROLLER_ARGV_ENV] = JSON.stringify(['node', 'ctl.mjs']);
28
+ const a = (0, adapter_1.adapterFor)('claude-code');
29
+ expect(a.provider).toBe('claude-code');
30
+ // `safeToReplace` es la señal POSITIVA que decide si el supervisor releva o entra en
31
+ // custodia. Si el override la tocara, un controller scripteado podría autorizar un
32
+ // relevo que el provider real nunca autorizaría — el seam quedaría mintiendo.
33
+ expect(a.safeToReplace).toBe((0, adapter_1.adapterFor)('codex').safeToReplace);
34
+ });
35
+ it.each([
36
+ ['claude -p "hola"', 'una linea de shell'],
37
+ ['[]', 'array vacio'],
38
+ ['["node", ""]', 'string vacio'],
39
+ ['["node", 3]', 'elemento no-string'],
40
+ ['{"cmd":"node"}', 'objeto en vez de array'],
41
+ ])('rechaza %s (%s) en vez de caer al provider nativo', (raw) => {
42
+ process.env[adapter_1.CONTROLLER_ARGV_ENV] = raw;
43
+ // Fail-closed a propósito: un override mal escrito que "funciona igual" lanzaría el
44
+ // agente REAL — con su costo en tokens — sin que nadie note que el override no aplicó.
45
+ expect(() => (0, adapter_1.adapterFor)('codex')).toThrow(adapter_1.CONTROLLER_ARGV_ENV);
46
+ });
47
+ it('un override en blanco es ausencia, no error', () => {
48
+ process.env[adapter_1.CONTROLLER_ARGV_ENV] = ' ';
49
+ expect((0, adapter_1.adapterFor)('codex').launchArgv('P')[0]).toBe('codex');
50
+ });
51
+ });
@@ -122,6 +122,43 @@ describe('process identity', () => {
122
122
  expect(dead).toBe(true);
123
123
  expect((0, process_1.groupIsGone)(ref.processGroup)).toBe(true);
124
124
  }, 15000);
125
+ test('terminatePreviouslyOwnedGroup jamas senializa un PGID reciclado por un proceso ajeno vivo (post-review, hallazgo de revision de Task 13)', async () => {
126
+ // Mismo criterio de simulacion deterministica que el fixture de R6 de
127
+ // mas arriba (`reusedPidStaleRef`): un proceso REAL y vivo ahora mismo
128
+ // (mismo pid/pgid), pero un ProcessRef que describe un "lider"
129
+ // DISTINTO y ya extinto que alguna vez tuvo ese mismo numero —
130
+ // startTime, nonce, argvDigest y psArgsDigest todos ajenos al
131
+ // proceso real que hoy ocupa ese slot. Antes del fix, la ausencia de
132
+ // este chequeo en `terminatePreviouslyOwnedGroup` (a diferencia de
133
+ // `terminateGroupConfirmed`, que sí verifica `refIsAlive`) habría
134
+ // enviado la señal directo a `-pgid`, alcanzando a este proceso
135
+ // ajeno.
136
+ const bystander = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'n-bystander');
137
+ const stalePriorLeaderRef = {
138
+ pid: bystander.ref.pid,
139
+ startTime: 'Mon Jan 1 00:00:00 2024', // "lider" anterior, ya extinto
140
+ spawnNonce: 'nonce-de-un-supervisor-anterior-ya-muerto',
141
+ argvDigest: 'deadbeefdeadbeef',
142
+ processGroup: bystander.ref.processGroup, // MISMO numero de pgid (reciclado)
143
+ psArgsDigest: 'cafebabecafebabe',
144
+ };
145
+ try {
146
+ const killSpy = jest.spyOn(process, 'kill');
147
+ try {
148
+ const confirmed = await (0, process_1.terminatePreviouslyOwnedGroup)(stalePriorLeaderRef, { termGraceMs: 50, killGraceMs: 50 });
149
+ expect(confirmed).toBe(false); // fail-closed: nunca declara "confirmado ausente" a costa de arriesgar un ajeno
150
+ const signalledForeignProcess = killSpy.mock.calls.some(([target]) => Number(target) === -bystander.ref.processGroup);
151
+ expect(signalledForeignProcess).toBe(false);
152
+ }
153
+ finally {
154
+ killSpy.mockRestore();
155
+ }
156
+ expect((0, process_1.refIsAlive)(bystander.ref)).toBe(true); // el ajeno sigue vivo, intacto
157
+ }
158
+ finally {
159
+ bystander.child.kill('SIGKILL');
160
+ }
161
+ });
125
162
  test('activitySnapshot reporta cpu y tamanio de grupo de un proceso vivo (soporte R4.2)', () => {
126
163
  const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 3000)'], process.cwd(), 'n4');
127
164
  const snap = (0, process_1.activitySnapshot)(ref);
@@ -131,4 +131,25 @@ describe('requests', () => {
131
131
  const listed = (0, requests_1.listPendingRequests)(repo, 'rama').map((p) => p.requestId);
132
132
  expect(listed.indexOf(idA)).toBeLessThan(listed.indexOf(idB));
133
133
  });
134
+ test('emitRequest soporta los nuevos kinds del protocolo de tracks (R9.2)', () => {
135
+ const kinds = [
136
+ 'track-prepare-request', 'track-freeze-request', 'track-join-request',
137
+ 'track-teardown-request', 'track-finalize-request',
138
+ ];
139
+ for (const kind of kinds) {
140
+ const r = (0, requests_1.emitRequest)(repo, 'rama', { kind, generationToken: 'g1', idempotencyKey: `k-${kind}`, payload: { trackId: 'cli' } });
141
+ const pending = (0, requests_1.listPendingRequests)(repo, 'rama').find((p) => p.requestId === r.requestId);
142
+ expect(pending?.corrupt).toBe(false);
143
+ expect(pending?.envelope.kind).toBe(kind);
144
+ }
145
+ });
146
+ test('un kind desconocido en el JSON se clasifica corrupto antes de consumirlo (R9.2)', () => {
147
+ const file = path_1.default.join((0, paths_1.requestsDir)(repo, 'rama'), 'unknown-kind.json');
148
+ fs_1.default.writeFileSync(file, JSON.stringify({
149
+ requestId: 'unknown-kind', kind: 'track-vaporize-request', generationToken: 'g1', idempotencyKey: 'k1', payload: {},
150
+ }));
151
+ const [pending] = (0, requests_1.listPendingRequests)(repo, 'rama');
152
+ expect(pending.corrupt).toBe(true);
153
+ expect(pending.requestId).toBe('unknown-kind.json');
154
+ });
134
155
  });
@@ -91,4 +91,32 @@ describe('journal store', () => {
91
91
  const wrongBranch = { ...s, branch: 'otra-rama' };
92
92
  expect(() => (0, store_1.writeJournal)(repo, 'rama', wrongBranch)).toThrow(/branch/);
93
93
  });
94
+ test('schema 1 legacy recibe journalId determinista sin perder evidencia (R9.3)', () => {
95
+ (0, store_1.initJournal)(repo, 'legacy');
96
+ const legacy = (0, store_1.readJournal)(repo, 'legacy').state;
97
+ delete legacy.journalId;
98
+ fs_1.default.writeFileSync((0, paths_1.statePath)(repo, 'legacy'), JSON.stringify(legacy));
99
+ const first = (0, store_1.readJournal)(repo, 'legacy').state;
100
+ const second = (0, store_1.readJournal)(repo, 'legacy').state;
101
+ expect(first.journalId).toBe(second.journalId);
102
+ expect(first.journalId).toMatch(/^legacy-[0-9a-f]{32}$/);
103
+ });
104
+ test('journalId legacy materializado se persiste en la siguiente escritura CAS normal (R9.3)', () => {
105
+ (0, store_1.initJournal)(repo, 'legacy2');
106
+ const legacy = (0, store_1.readJournal)(repo, 'legacy2').state;
107
+ delete legacy.journalId;
108
+ fs_1.default.writeFileSync((0, paths_1.statePath)(repo, 'legacy2'), JSON.stringify(legacy));
109
+ const read = (0, store_1.readJournal)(repo, 'legacy2').state;
110
+ // la lectura por si sola sigue siendo read-only: el disco AUN no tiene journalId
111
+ expect(JSON.parse(fs_1.default.readFileSync((0, paths_1.statePath)(repo, 'legacy2'), 'utf8')).journalId).toBeUndefined();
112
+ (0, store_1.writeJournal)(repo, 'legacy2', read);
113
+ const onDisk = JSON.parse(fs_1.default.readFileSync((0, paths_1.statePath)(repo, 'legacy2'), 'utf8'));
114
+ expect(onDisk.journalId).toBe(read.journalId);
115
+ });
116
+ test('normalizeSchemaOne no inventa tracks/trackContext para un journal legacy que nunca los tuvo (R9.2)', () => {
117
+ (0, store_1.initJournal)(repo, 'legacy3');
118
+ const read = (0, store_1.readJournal)(repo, 'legacy3').state;
119
+ expect(read.tracks).toBeUndefined();
120
+ expect(read.trackContext).toBeUndefined();
121
+ });
94
122
  });