agentic-workflow-manager 9.6.1 → 9.7.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.
package/README.md CHANGED
@@ -69,6 +69,8 @@ and one JSON argv integration command. Start or resume with `awm watch`; inspect
69
69
  `awm track status`. Track workers commit only their assigned files and request
70
70
  `awm track join`. The plan supervisor freezes and merges tracks, runs global QA once on the
71
71
  final HEAD, executes the canonical integration job once, then applies the interlock.
72
+ `awm track remove` requests a safe cancellation of the complete cohort; only demonstrably
73
+ owned resources are removed before returning to serial execution.
72
74
 
73
75
  Any missing declaration, overlap, global file class, unavailable worktree, or failed
74
76
  preparation degrades to serial with an event naming the cause. `BLOCKED` means ownership or
@@ -104,14 +104,10 @@ function registerTrackCommand(program) {
104
104
  }
105
105
  process.stdout.write(JSON.stringify({ requestId: r.requestId, idempotencyKey: r.idempotencyKey, qaHeadSha: r.qaHeadSha }, null, 2) + '\n');
106
106
  });
107
- // NO IMPLEMENTADO del lado del supervisor: no hay handler para `track-teardown-request`,
108
- // así que la request se emite y el supervisor la RECHAZA (`request-rejected-invalid`).
109
- // La descripción lo dice en vez de prometer un desmantelamiento que no ocurre — hasta
110
- // este release afirmaba que el supervisor desmantelaba worktree/rama, y no lo hacía.
111
- // El teardown que sí funciona es el automático de la degradación a serial
112
- // (`begin-teardown` desde `FALLBACK_PENDING`), que no pasa por este verbo.
107
+ // Request-only: el supervisor persiste la intención, cancela la cohorte completa y
108
+ // reutiliza el teardown fail-closed antes de volver a ejecución serial.
113
109
  track.command('remove')
114
- .description('NO IMPLEMENTADO: emite track-teardown-request, que el supervisor todavía no sabe aplicar y rechaza')
110
+ .description('emite track-teardown-request — cancela la cohorte y solicita teardown seguro antes del fallback serial')
115
111
  .requiredOption('--generation <token>', 'token de la generacion vigente')
116
112
  .argument('<trackId>')
117
113
  .action((trackId, opts) => {
@@ -250,6 +250,21 @@ function applyRequestToState(s, env, digest, repoRoot) {
250
250
  (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
251
251
  return;
252
252
  }
253
+ if (env.kind === 'track-teardown-request') {
254
+ const p = env.payload;
255
+ if (typeof p.trackId !== 'string' || p.trackId.length === 0)
256
+ throw new Error('track-teardown-request requiere trackId');
257
+ const trackId = p.trackId;
258
+ const ref = s.tracks?.find((t) => t.trackId === trackId);
259
+ if (ref === undefined)
260
+ throw new Error(`track-teardown-request: track desconocido: ${trackId}`);
261
+ if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
262
+ throw new Error('track-teardown-request requiere una cohorte válida de al menos dos tracks');
263
+ }
264
+ ref.teardownRequested = true;
265
+ (0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: trackId });
266
+ return;
267
+ }
253
268
  if (env.kind === 'track-freeze-request') {
254
269
  // R5.2/R6.3 (Task 10): request administrativa CROSS-JOURNAL — el
255
270
  // supervisor del PLAN la emite directamente al `requestsDir` de ESTE
@@ -135,13 +135,22 @@ function applyProtocolToState(state, protocol) {
135
135
  // `expectedPlanHeadSha` incorrecto.
136
136
  if (protocol.planHeadSha !== undefined)
137
137
  next.cohortPlanHeadSha = protocol.planHeadSha;
138
- // R7/C3 (Task 12): mismo criterio que `cohortPlanHeadSha` arriba — valores
139
- // que solo avanzan (nunca retroceden a `undefined` una vez fijados por
140
- // `reconcileProtocol`).
141
- if (protocol.globalQaHeadSha !== undefined)
142
- next.globalQaHeadSha = protocol.globalQaHeadSha;
143
- if (protocol.finalIntegrationJobId !== undefined)
144
- next.finalIntegrationJobId = protocol.finalIntegrationJobId;
138
+ // R7/C3 (Task 12): los valores finales normalmente solo avanzan, salvo
139
+ // que fallback abandone explícitamente esa ruta. En ese caso el reducer
140
+ // ya invalidó su evidencia y el mirror durable DEBE borrarla también;
141
+ // conservarla permitiría que un restart reconstruyera un protocolo que
142
+ // contradice los tracks en teardown.
143
+ if (protocol.cohortPhase === 'FALLBACK_PENDING') {
144
+ next.globalQaHeadSha = undefined;
145
+ next.finalIntegrationJobId = undefined;
146
+ next.qaFinalizeRequested = undefined;
147
+ }
148
+ else {
149
+ if (protocol.globalQaHeadSha !== undefined)
150
+ next.globalQaHeadSha = protocol.globalQaHeadSha;
151
+ if (protocol.finalIntegrationJobId !== undefined)
152
+ next.finalIntegrationJobId = protocol.finalIntegrationJobId;
153
+ }
145
154
  if (protocol.fallbackReason !== undefined)
146
155
  next.cohortFallbackReason = protocol.fallbackReason;
147
156
  next.tracks = (next.tracks ?? []).map((ref) => {
@@ -818,6 +827,46 @@ async function reconcileTracks(planRoot, branch, state, runtime, maxParallel) {
818
827
  if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
819
828
  return { state: s, effectExecuted: null };
820
829
  }
830
+ // La request de teardown se consume en dos pasos durables, igual que
831
+ // join: `apply.ts` solo deja el marcador y este reconciler traduce la
832
+ // intención al reducer. BLOCKED, SERIAL y COMPLETE no admiten nueva
833
+ // transición: limpiar únicamente el marcador preserva el estado seguro.
834
+ const pendingTeardown = (s.tracks ?? []).find((track) => track.teardownRequested === true);
835
+ if (pendingTeardown !== undefined) {
836
+ const moot = ['BLOCKED', 'SERIAL', 'COMPLETE'].includes(s.cohortPhase);
837
+ if (moot) {
838
+ const next = structuredClone(s);
839
+ for (const track of next.tracks ?? [])
840
+ if (track.trackId === pendingTeardown.trackId)
841
+ track.teardownRequested = undefined;
842
+ s = persist(planRoot, branch, next);
843
+ (0, store_1.appendEvent)(planRoot, branch, {
844
+ kind: 'track-teardown-moot', trackId: pendingTeardown.trackId, phase: s.cohortPhase,
845
+ });
846
+ return { state: s, effectExecuted: null };
847
+ }
848
+ const protocolBefore = toProtocol(s, maxParallel);
849
+ const observed = (0, protocol_1.reconcileProtocol)(protocolBefore, { kind: 'teardown-requested', trackId: pendingTeardown.trackId });
850
+ const next = applyProtocolToState(s, observed);
851
+ for (const track of next.tracks ?? [])
852
+ if (track.trackId === pendingTeardown.trackId)
853
+ track.teardownRequested = undefined;
854
+ s = persist(planRoot, branch, next);
855
+ // El lock pertenece al proceso vivo que alcanzó la integración.
856
+ // Al abandonar FINAL_INTERLOCK debe soltarse en este mismo proceso;
857
+ // tras un crash el lease muerto se reclama por identidad al iniciar.
858
+ if (protocolBefore.cohortPhase === 'FINAL_INTERLOCK' && s.cohortPhase === 'FALLBACK_PENDING') {
859
+ runtime.releaseIntegrationLockIfHeld();
860
+ }
861
+ (0, store_1.appendEvent)(planRoot, branch, {
862
+ kind: 'track-teardown-observed', trackId: pendingTeardown.trackId,
863
+ phase: s.tracks?.find((t) => t.trackId === pendingTeardown.trackId)?.phase,
864
+ });
865
+ // El marcador es una frontera durable por sí mismo. No se consume
866
+ // otro marcador ni se entra a begin-teardown hasta la siguiente
867
+ // llamada, para que un crash conserve el resto de las requests.
868
+ return { state: s, effectExecuted: null };
869
+ }
821
870
  // El pedido de join del controller (`joinRequested`, marcado declarativamente al
822
871
  // consumir `track-join-request`) se convierte ACÁ en la observación que el reducer
823
872
  // entiende. La regla de si eso mueve la fase vive en `reconcileProtocol` (solo
@@ -246,6 +246,7 @@ function isWellFormedTrackRef(x) {
246
246
  && (x.supervisorProcessRef === undefined || isWellFormedProcessRef(x.supervisorProcessRef))
247
247
  && (x.joinIntent === undefined || isWellFormedJoinIntent(x.joinIntent))
248
248
  && (x.teardownIntent === undefined || isWellFormedTeardownIntent(x.teardownIntent))
249
+ && (x.teardownRequested === undefined || typeof x.teardownRequested === 'boolean')
249
250
  && (x.joinedCommitSha === undefined || typeof x.joinedCommitSha === 'string')
250
251
  && (x.blockedReason === undefined || typeof x.blockedReason === 'string');
251
252
  }
@@ -136,13 +136,25 @@ function ownedWorktreeExists(repo, worktreePath, branch) {
136
136
  * worktree (contenido de filesystem que SÍ podría ser ajeno), una branch es
137
137
  * solo un ref; nada más crea una con ese nombre exacto. */
138
138
  function branchExists(repo, branch) {
139
+ // `show-ref --quiet` puede devolver 1 tanto para una ref ausente como
140
+ // para un repositorio que ni siquiera pudo abrirse. Primero probamos que
141
+ // git puede identificar el repositorio; solo después status 1 significa
142
+ // honestamente "la branch no existe".
143
+ try {
144
+ git(repo, ['rev-parse', '--git-dir']);
145
+ }
146
+ catch (error) {
147
+ throw new Error(`branchExists: repo de ${branch} indemostrable: ${error.message}`);
148
+ }
139
149
  try {
140
150
  git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
141
- return true;
142
151
  }
143
- catch {
144
- return false;
152
+ catch (error) {
153
+ if (error.status === 1)
154
+ return false;
155
+ throw new Error(`branchExists: existencia de ${branch} indemostrable: ${error.message}`);
145
156
  }
157
+ return true;
146
158
  }
147
159
  /** Task 9 (paso BRANCH_REMOVED, Task 13 endurece el guard — R4.10/C9): borra
148
160
  * la branch local determinista de un track SOLO después de que su worktree
@@ -269,7 +269,25 @@ function nextProtocolEffect(s) {
269
269
  }
270
270
  function reconcileProtocol(s, observation) {
271
271
  const out = structuredClone(s);
272
- if (observation.kind === 'prepare-failed') {
272
+ if (observation.kind === 'teardown-requested') {
273
+ const requested = out.tracks[observation.trackId];
274
+ if (requested === undefined)
275
+ throw new Error(`track desconocido: ${observation.trackId}`);
276
+ if (out.cohortPhase !== 'BLOCKED' && out.cohortPhase !== 'SERIAL' && out.cohortPhase !== 'COMPLETE'
277
+ && out.cohortPhase !== 'FALLBACK_PENDING') {
278
+ out.cohortPhase = 'FALLBACK_PENDING';
279
+ out.fallbackReason = `controller-requested:${observation.trackId}`;
280
+ // La evidencia de QA/integración describe la ruta de finalización
281
+ // que acabamos de abandonar. Debe desaparecer ANTES de mover los
282
+ // tracks a teardown: de otro modo el invariante de integración
283
+ // final ve evidence viva junto a tracks no mergeados y rechaza la
284
+ // transición segura hacia fallback.
285
+ out.globalQaHeadSha = undefined;
286
+ out.finalIntegrationJobId = undefined;
287
+ markTeardownRequested(out.tracks);
288
+ }
289
+ }
290
+ else if (observation.kind === 'prepare-failed') {
273
291
  out.cohortPhase = 'FALLBACK_PENDING';
274
292
  out.fallbackReason = `prepare-failed:${observation.trackId}`;
275
293
  markTeardownRequested(out.tracks);
@@ -295,10 +313,11 @@ function reconcileProtocol(s, observation) {
295
313
  // `decideTeardown` documenta para BLOCKED: solo el operador lo
296
314
  // saca de acá.
297
315
  const t = out.tracks[observation.trackId];
298
- if (t !== undefined) {
299
- t.phase = 'BLOCKED';
300
- t.blockedReason = observation.detail;
301
- }
316
+ if (t === undefined)
317
+ throw new Error(`track desconocido: ${observation.trackId}`);
318
+ t.phase = 'BLOCKED';
319
+ t.blockedReason = observation.detail;
320
+ out.cohortPhase = 'BLOCKED';
302
321
  }
303
322
  // effect-failed fuera de PREPARING/begin-teardown: sin transición definida todavía (no ejercitado por T1).
304
323
  }
@@ -351,6 +370,7 @@ function reconcileProtocol(s, observation) {
351
370
  if (decision === 'block-foreign') {
352
371
  t.phase = 'BLOCKED';
353
372
  t.blockedReason = observation.foreignSupervisor ? 'identidad de supervisor ajena' : 'worktree preexistente ajeno';
373
+ out.cohortPhase = 'BLOCKED';
354
374
  }
355
375
  else if (decision === 'accept-supervisor-stopped') {
356
376
  t.phase = 'SUPERVISOR_STOPPED';
@@ -432,7 +452,7 @@ function reconcileProtocol(s, observation) {
432
452
  }
433
453
  function observeProtocolEffect(s, effect, observation) {
434
454
  if (observation.kind === 'effect-failed' || observation.kind === 'prepare-failed' || observation.kind === 'join-observation'
435
- || observation.kind === 'freeze-observation' || observation.kind === 'join-requested'
455
+ || observation.kind === 'freeze-observation' || observation.kind === 'join-requested' || observation.kind === 'teardown-requested'
436
456
  || observation.kind === 'supervisor-observed' || observation.kind === 'worktree-observed'
437
457
  || observation.kind === 'teardown-observation'
438
458
  || observation.kind === 'global-qa-pass'
@@ -103,6 +103,12 @@ describe('awm track — verbos mutantes son request-only (R6.1)', () => {
103
103
  expect(git(repo, ['rev-parse', 'HEAD'])).toBe(beforeHead);
104
104
  expect(fs_1.default.readFileSync((0, paths_1.statePath)(repo, branch), 'utf8')).toBe(beforeState);
105
105
  });
106
+ test('remove se anuncia como cancelación request-only soportada (TR-REQ-10)', async () => {
107
+ const out = await runCli(repo, ['track', 'remove', '--help']);
108
+ expect(out.exitCode).toBe(0);
109
+ expect(out.out).toContain('cancela la cohorte');
110
+ expect(out.out).not.toContain('NO IMPLEMENTADO');
111
+ });
106
112
  // `finalize` es PLAN-scoped y sin <trackId>, así que no entra en las matrices de
107
113
  // add/join/remove — pero comparte con ellas la propiedad que define a la superficie:
108
114
  // emite una request y no toca Git ni state.json.
@@ -126,7 +126,7 @@ describe('aplicacion transaccional de requests', () => {
126
126
  expect(Object.keys((0, store_1.readJournal)(repo, 'rama').state.jobs)).toHaveLength(1); // sin duplicados
127
127
  });
128
128
  test('register-entity CREA task con VerificationPlan y ReviewObligations; cycle-plan y dispatch (R1.4/R1.4b)', () => {
129
- (0, requests_1.emitRequest)(repo, 'rama', {
129
+ const request = (0, requests_1.emitRequest)(repo, 'rama', {
130
130
  kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
131
131
  payload: {
132
132
  entity: 'task', taskId: 'T1', title: 'implementar',
@@ -513,20 +513,57 @@ describe('aplicacion transaccional de requests', () => {
513
513
  expect(out.rejectedInvalid).toBe(1);
514
514
  expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
515
515
  });
516
- test('un kind SIN handler falla cerrado: rechazo visible, no un `applied` que borra el archivo', () => {
517
- // `track-teardown-request` es un kind real, emitido por `awm track remove`, que hoy
518
- // no tiene handler en el supervisor. Antes de este cambio se contaba como aplicado y
519
- // desaparecía sin dejar rastro; ahora queda registrado como problema y el archivo se
520
- // conserva con sufijo `.rejected`, que es lo que permite darse cuenta.
521
- const r = (0, requests_1.emitRequest)(repo, 'rama', {
522
- kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-1',
516
+ test('track-teardown-request persiste intención sin mover fase ni cohorte', () => {
517
+ const s0 = (0, store_1.readJournal)(repo, 'rama').state;
518
+ s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }];
519
+ s0.cohortPhase = 'JOINING';
520
+ (0, store_1.writeJournal)(repo, 'rama', s0);
521
+ const request = (0, requests_1.emitRequest)(repo, 'rama', {
522
+ kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-alpha',
523
523
  payload: { trackId: 'alpha' },
524
524
  });
525
+ expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').applied).toBe(1);
526
+ const state = (0, store_1.readJournal)(repo, 'rama').state;
527
+ expect(state.tracks[0].teardownRequested).toBe(true);
528
+ expect(state.tracks[0].phase).toBe('ACTIVE');
529
+ expect(state.cohortPhase).toBe('JOINING');
530
+ expect(state.appliedRequests[request.requestId]).toMatchObject({ outcome: 'applied', resultRef: 'alpha' });
531
+ });
532
+ test.each([
533
+ ['ausente', {}],
534
+ ['vacio', { trackId: '' }],
535
+ ['malformado', { trackId: 42 }],
536
+ ['desconocido', { trackId: 'no-existe' }],
537
+ ])('track-teardown-request %s se rechaza visiblemente sin mutar el track', (_case, payload) => {
538
+ const s0 = (0, store_1.readJournal)(repo, 'rama').state;
539
+ s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }];
540
+ s0.cohortPhase = 'JOINING';
541
+ (0, store_1.writeJournal)(repo, 'rama', s0);
542
+ const r = (0, requests_1.emitRequest)(repo, 'rama', {
543
+ kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: `teardown-${_case}`,
544
+ payload,
545
+ });
525
546
  const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
526
547
  expect(out.applied).toBe(0);
527
548
  expect(out.rejectedInvalid).toBe(1);
528
549
  expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
529
- expect((0, store_1.readJournal)(repo, 'rama').state.requestProblems.some((p) => p.kind === 'rejected')).toBe(true);
550
+ const state = (0, store_1.readJournal)(repo, 'rama').state;
551
+ expect(state.tracks).toEqual([{ ...trackRef('alpha'), phase: 'ACTIVE' }, { ...trackRef('beta'), phase: 'ACTIVE' }]);
552
+ expect(state.cohortPhase).toBe('JOINING');
553
+ });
554
+ test('track-teardown-request se rechaza si la cohorte no puede reconciliarse', () => {
555
+ const s0 = (0, store_1.readJournal)(repo, 'rama').state;
556
+ s0.tracks = [{ ...trackRef('alpha'), phase: 'ACTIVE' }];
557
+ s0.cohortPhase = 'JOINING';
558
+ (0, store_1.writeJournal)(repo, 'rama', s0);
559
+ const request = (0, requests_1.emitRequest)(repo, 'rama', {
560
+ kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-invalid-cohort',
561
+ payload: { trackId: 'alpha' },
562
+ });
563
+ const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
564
+ expect(out.rejectedInvalid).toBe(1);
565
+ expect(fs_1.default.existsSync(`${request.file}.rejected`)).toBe(true);
566
+ expect((0, store_1.readJournal)(repo, 'rama').state.tracks[0].teardownRequested).toBeUndefined();
530
567
  });
531
568
  test('track-finalize-request persiste el autoreporte del controller (qaFinalizeRequested) — puramente declarativo (R7.2)', () => {
532
569
  (0, requests_1.emitRequest)(repo, 'rama', {
@@ -0,0 +1,165 @@
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
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const git_fixture_1 = require("../../helpers/git-fixture");
9
+ const tracks_1 = require("../../../src/commands/watch/tracks");
10
+ const apply_1 = require("../../../src/commands/watch/apply");
11
+ const emit_1 = require("../../../src/commands/track/emit");
12
+ const store_1 = require("../../../src/core/journal/store");
13
+ const paths_1 = require("../../../src/core/journal/paths");
14
+ const BRANCH = 'main';
15
+ describe('awm track remove -> fallback teardown', () => {
16
+ let planRoot;
17
+ let baseSha;
18
+ beforeEach(() => {
19
+ planRoot = (0, git_fixture_1.initRepo)();
20
+ (0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
21
+ baseSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
22
+ (0, store_1.initJournal)(planRoot, BRANCH);
23
+ });
24
+ afterEach(() => fs_1.default.rmSync(planRoot, { recursive: true, force: true }));
25
+ function declareCohort(cohortPhase = 'ACTIVE') {
26
+ const state = (0, store_1.readJournal)(planRoot, BRANCH).state;
27
+ state.cohortPhase = cohortPhase;
28
+ state.cohortBaseSha = baseSha;
29
+ state.tracks = ['a', 'b'].map((trackId) => ({
30
+ trackId, worktreePath: path_1.default.join(planRoot, `../nope-${trackId}`), branch: `awm-track/${trackId}`,
31
+ ownership: [], sharedResources: [], dependsOn: [], fencingToken: `fence-${trackId}`.padEnd(32, '0'),
32
+ phase: 'ACTIVE', readinessNonce: `ready-${trackId}`.padEnd(32, '0'),
33
+ }));
34
+ (0, store_1.writeJournal)(planRoot, BRANCH, state);
35
+ return (0, store_1.readJournal)(planRoot, BRANCH).state;
36
+ }
37
+ function inertRuntime() {
38
+ const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
39
+ return { ...real,
40
+ addWorktree: () => { throw new Error('no external mutation expected'); },
41
+ initTrackJournal: () => { throw new Error('no external mutation expected'); },
42
+ spawnSupervisor: () => { throw new Error('no external mutation expected'); },
43
+ stopOwnSupervisor: () => { throw new Error('no external mutation expected'); },
44
+ removeOwnedWorktree: () => { throw new Error('no external mutation expected'); },
45
+ removeOwnedBranch: () => { throw new Error('no external mutation expected'); },
46
+ };
47
+ }
48
+ function events() {
49
+ if (!fs_1.default.existsSync((0, paths_1.eventsPath)(planRoot, BRANCH)))
50
+ return [];
51
+ return fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, BRANCH), 'utf8').trim().split('\n').filter(Boolean)
52
+ .map((line) => JSON.parse(line));
53
+ }
54
+ it('consume el marker antes que join y persiste fallback cohort-wide (TR-REQ-03, TR-REQ-04)', async () => {
55
+ const state = declareCohort();
56
+ state.tracks[0].joinRequested = true;
57
+ (0, store_1.writeJournal)(planRoot, BRANCH, state);
58
+ (0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-teardown-request', 'b');
59
+ (0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1');
60
+ const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
61
+ expect(out.state.cohortPhase).toBe('FALLBACK_PENDING');
62
+ // Consumir el marcador es su propia frontera durable: el teardown
63
+ // empieza recién en la siguiente llamada.
64
+ expect(out.effectExecuted).toBeNull();
65
+ expect(out.state.cohortFallbackReason).toBe('controller-requested:b');
66
+ expect(out.state.tracks?.find((track) => track.trackId === 'b')?.teardownRequested)
67
+ .toBeUndefined();
68
+ expect(events().some((event) => event.kind === 'track-teardown-observed' && event.trackId === 'b'))
69
+ .toBe(true);
70
+ });
71
+ it('un marker sobreviviente a restart converge por el mismo reducer (TR-REQ-07)', async () => {
72
+ const state = declareCohort();
73
+ state.tracks[0].teardownRequested = true;
74
+ (0, store_1.writeJournal)(planRoot, BRANCH, state);
75
+ const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
76
+ expect(out.state.cohortPhase).toBe('FALLBACK_PENDING');
77
+ expect(out.state.tracks[0].teardownRequested).toBeUndefined();
78
+ });
79
+ it.each(['SERIAL', 'COMPLETE'])('consume remove tardío como moot en %s (TR-REQ-09)', async (phase) => {
80
+ const state = declareCohort(phase);
81
+ for (const track of state.tracks) {
82
+ track.phase = phase === 'SERIAL' ? 'REMOVED' : 'JOINED';
83
+ track.frozenHeadSha = phase === 'COMPLETE' ? baseSha : undefined;
84
+ }
85
+ state.tracks[0].teardownRequested = true;
86
+ (0, store_1.writeJournal)(planRoot, BRANCH, state);
87
+ const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
88
+ expect(out.state.cohortPhase).toBe(phase);
89
+ expect(out.state.tracks[0].teardownRequested).toBeUndefined();
90
+ expect(events()).toContainEqual(expect.objectContaining({ kind: 'track-teardown-moot', trackId: 'a', phase }));
91
+ });
92
+ it('una request en cohorte BLOCKED es moot y no reabre fallback', async () => {
93
+ const state = declareCohort('BLOCKED');
94
+ state.tracks[0].phase = 'BLOCKED';
95
+ state.tracks[1].phase = 'REMOVED';
96
+ state.tracks[1].teardownRequested = true;
97
+ (0, store_1.writeJournal)(planRoot, BRANCH, state);
98
+ const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
99
+ expect(out.effectExecuted).toBeNull();
100
+ expect(out.state.cohortPhase).toBe('BLOCKED');
101
+ expect(out.state.tracks[1].teardownRequested).toBeUndefined();
102
+ expect(events()).toContainEqual(expect.objectContaining({ kind: 'track-teardown-moot', trackId: 'b', phase: 'BLOCKED' }));
103
+ });
104
+ it('consume exactamente un marcador de teardown por reconcile antes de begin-teardown (issue #93)', async () => {
105
+ const s = declareCohort('ACTIVE');
106
+ s.tracks.forEach((track) => { track.teardownRequested = true; });
107
+ (0, store_1.writeJournal)(planRoot, BRANCH, s);
108
+ const first = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
109
+ expect(first.effectExecuted).toBeNull();
110
+ expect(first.state.tracks?.find((track) => track.trackId === 'a')?.teardownRequested).toBeUndefined();
111
+ expect(first.state.tracks?.find((track) => track.trackId === 'b')?.teardownRequested).toBe(true);
112
+ const second = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, first.state, inertRuntime(), 2);
113
+ expect(second.effectExecuted).toBeNull();
114
+ expect(second.state.tracks?.every((track) => track.teardownRequested === undefined)).toBe(true);
115
+ });
116
+ it('una request repetida durante teardown no rebobina fases ni ejecuta trabajo destructivo (issue #93)', async () => {
117
+ declareCohort('ACTIVE');
118
+ (0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-teardown-request', 'a');
119
+ (0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1');
120
+ const afterFirst = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
121
+ (0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g2', 'track-teardown-request', 'a');
122
+ (0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g2');
123
+ const repeated = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, inertRuntime(), 2);
124
+ expect(afterFirst.state.tracks?.find((track) => track.trackId === 'a')?.phase).toBe('TEARDOWN_REQUESTED');
125
+ expect(repeated.effectExecuted).toBeNull();
126
+ expect(repeated.state.tracks?.find((track) => track.trackId === 'a')?.phase).toBe('TEARDOWN_REQUESTED');
127
+ expect(repeated.state.cohortPhase).toBe('FALLBACK_PENDING');
128
+ });
129
+ it('request durante FINAL_INTERLOCK persiste fallback sin evidencia final stale (issue #93)', async () => {
130
+ const s = declareCohort('FINAL_INTERLOCK');
131
+ for (const track of s.tracks)
132
+ track.phase = 'MERGED_UNVERIFIED';
133
+ for (const track of s.tracks)
134
+ track.frozenHeadSha = `${track.trackId}-head`;
135
+ s.globalQaHeadSha = 'qa-head';
136
+ s.finalIntegrationJobId = 'integration-job';
137
+ s.qaFinalizeRequested = { headSha: 'qa-head', at: new Date().toISOString() };
138
+ (0, store_1.writeJournal)(planRoot, BRANCH, s);
139
+ (0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-teardown-request', 'a');
140
+ (0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1');
141
+ let releases = 0;
142
+ const runtime = { ...inertRuntime(), releaseIntegrationLockIfHeld: () => { releases += 1; } };
143
+ const out = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, runtime, 2);
144
+ const durable = (0, store_1.readJournal)(planRoot, BRANCH).state;
145
+ expect(out.state.cohortPhase).toBe('FALLBACK_PENDING');
146
+ expect(durable.globalQaHeadSha).toBeUndefined();
147
+ expect(durable.finalIntegrationJobId).toBeUndefined();
148
+ expect(durable.qaFinalizeRequested).toBeUndefined();
149
+ expect(durable.tracks?.every((track) => track.phase === 'TEARDOWN_REQUESTED')).toBe(true);
150
+ expect(releases).toBe(1);
151
+ });
152
+ it('un probe de branch indemostrable detiene teardown sin serializar (issue #93)', async () => {
153
+ const s = declareCohort('FALLBACK_PENDING');
154
+ s.tracks[0].phase = 'WORKTREE_REMOVED';
155
+ s.tracks[1].phase = 'REMOVED';
156
+ (0, store_1.writeJournal)(planRoot, BRANCH, s);
157
+ const gitDir = path_1.default.join(planRoot, '.git');
158
+ fs_1.default.renameSync(gitDir, `${gitDir}.unavailable`);
159
+ await expect((0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH), 2)).rejects.toThrow(/indemostrable/);
160
+ fs_1.default.renameSync(`${gitDir}.unavailable`, gitDir);
161
+ const durable = (0, store_1.readJournal)(planRoot, BRANCH).state;
162
+ expect(durable.cohortPhase).toBe('FALLBACK_PENDING');
163
+ expect(durable.tracks?.find((track) => track.trackId === 'a')?.phase).toBe('WORKTREE_REMOVED');
164
+ });
165
+ });
@@ -23,6 +23,8 @@ const child_process_1 = require("child_process");
23
23
  const git_fixture_1 = require("../../helpers/git-fixture");
24
24
  const tracks_1 = require("../../../src/commands/watch/tracks");
25
25
  const store_1 = require("../../../src/core/journal/store");
26
+ const apply_1 = require("../../../src/commands/watch/apply");
27
+ const emit_1 = require("../../../src/commands/track/emit");
26
28
  const git_1 = require("../../../src/core/tracks/git");
27
29
  const descriptor_1 = require("../../../src/core/tracks/descriptor");
28
30
  const BRANCH = 'main';
@@ -263,6 +265,31 @@ describe('reconcileTracks — crash/restart de teardown con git real (Task 13, R
263
265
  expect(fs_1.default.existsSync(path_1.default.join(root, 'track-a'))).toBe(false);
264
266
  expect(branchListed(planRoot, 'awm-track/a')).toBe(false);
265
267
  });
268
+ test('request bridge sobrevive crash tras el marcador y converge a SERIAL sin repetir remociones (issue #93)', async () => {
269
+ let s = declareCohort(planRoot, root, baseSha);
270
+ materializeTrackA(s);
271
+ s = (0, store_1.readJournal)(planRoot, BRANCH).state;
272
+ s.cohortPhase = 'PREPARING';
273
+ s.tracks = s.tracks.map((track) => track.trackId === 'a'
274
+ ? { ...track, phase: 'WORKTREE_CREATED', supervisorProcessRef: deadRef() }
275
+ : track);
276
+ (0, store_1.writeJournal)(planRoot, BRANCH, s);
277
+ (0, emit_1.emitTrackRequest)(planRoot, BRANCH, 'g1', 'track-teardown-request', 'a');
278
+ expect((0, apply_1.consumePendingRequests)(planRoot, BRANCH, 'g1').applied).toBe(1);
279
+ // Crash immediately after the request marker becomes the durable
280
+ // protocol observation: no teardown effect has run yet.
281
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, (0, store_1.readJournal)(planRoot, BRANCH).state, buildRuntime(planRoot, []), 2)).state;
282
+ expect(trackA(s).phase).toBe('TEARDOWN_REQUESTED');
283
+ const effects = [];
284
+ const restarted = buildRuntime(planRoot, effects);
285
+ for (let i = 0; i < 20 && s.cohortPhase !== 'SERIAL'; i++) {
286
+ s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, restarted, 2)).state;
287
+ }
288
+ expect(s.cohortPhase).toBe('SERIAL');
289
+ expect(trackA(s).phase).toBe('REMOVED');
290
+ expect(effects.filter((effect) => effect === 'worktree-effect:a')).toHaveLength(1);
291
+ expect(effects.filter((effect) => effect === 'branch-effect:awm-track/a')).toHaveLength(1);
292
+ });
266
293
  // R4.6/R4.10/C9: un worktree cuyo ownership es indemostrable (descriptor
267
294
  // ausente/corrupto, o simplemente no es el que `teardownIntent` espera)
268
295
  // NUNCA se adopta ni se borra — el track se bloquea, y el contenido
@@ -148,6 +148,17 @@ describe('journal types', () => {
148
148
  state.tracks[0].supervisorIntent.nonce = '';
149
149
  expect((0, types_1.isWellFormedState)(state)).toBe(false);
150
150
  });
151
+ test('TrackRef acepta teardownRequested booleano y rechaza otro runtime shape', () => {
152
+ const state = (0, types_1.emptyState)('main');
153
+ state.tracks = [{
154
+ trackId: 'cli', worktreePath: '/tmp/wt', branch: 'awm-track/cli', ownership: ['cli/'],
155
+ sharedResources: [], dependsOn: [], fencingToken: 'f'.repeat(32), phase: 'ACTIVE',
156
+ readinessNonce: 'r'.repeat(32), teardownRequested: true,
157
+ }];
158
+ expect((0, types_1.isWellFormedState)(state)).toBe(true);
159
+ state.tracks[0].teardownRequested = 'true';
160
+ expect((0, types_1.isWellFormedState)(state)).toBe(false);
161
+ });
151
162
  test('trackContext, cohortPhase y trackIntegration validan shape cuando presentes (R9.1, R9.3)', () => {
152
163
  const state = (0, types_1.emptyState)('main');
153
164
  state.trackContext = { trackId: 'cli', taskIds: ['t1'], planDigest: 'd', baseSha: 'abc', planJournalId: 'j-plan' };
@@ -128,5 +128,12 @@ describe('git adapter', () => {
128
128
  (0, child_process_1.execFileSync)('git', ['branch', 'awm-track/x'], { cwd: repo });
129
129
  expect((0, git_1.branchExists)(repo, 'awm-track/x')).toBe(true);
130
130
  });
131
+ test('branchExists falla cerrado cuando git no puede probar la ausencia', () => {
132
+ repo = (0, git_fixture_1.initRepo)();
133
+ (0, git_fixture_1.commitFile)(repo, 'a.txt', 'x');
134
+ const gitDir = path_1.default.join(repo, '.git');
135
+ fs_1.default.renameSync(gitDir, `${gitDir}.unavailable`);
136
+ expect(() => (0, git_1.branchExists)(repo, 'awm-track/nope')).toThrow(/indemostrable/);
137
+ });
131
138
  });
132
139
  });
@@ -34,6 +34,109 @@ describe('parallel-track protocol model', () => {
34
34
  expect(() => (0, protocol_1.observeProtocolEffect)(s, { kind: 'enter-serial', reason: 'x' }, { kind: 'effect-applied', effect: { kind: 'enter-serial', reason: 'x' } }))
35
35
  .toThrow(/SERIAL prohíbe recursos paralelos activos/);
36
36
  });
37
+ test.each([
38
+ {
39
+ name: 'observación de ownership ajeno',
40
+ observation: { kind: 'teardown-observation', trackId: 'cli', foreignWorktree: true },
41
+ },
42
+ {
43
+ name: 'fallo de begin-teardown',
44
+ observation: {
45
+ kind: 'effect-failed', trackId: 'cli', effect: 'begin-teardown', detail: 'ownership indemostrable',
46
+ },
47
+ },
48
+ ])('teardown indemostrable bloquea track y cohorte: $name (TR-REQ-05)', ({ observation }) => {
49
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
50
+ s.cohortPhase = 'FALLBACK_PENDING';
51
+ s.fallbackReason = 'controller-requested:cli';
52
+ s.tracks.cli.phase = 'TEARDOWN_REQUESTED';
53
+ s.tracks.docs.phase = 'REMOVED';
54
+ const out = (0, protocol_1.reconcileProtocol)(s, observation);
55
+ expect(out.tracks.cli.phase).toBe('BLOCKED');
56
+ expect(out.cohortPhase).toBe('BLOCKED');
57
+ expect((0, protocol_1.nextProtocolEffect)(out)).toBeNull();
58
+ });
59
+ test('fallo de begin-teardown para track inexistente falla cerrado (TR-REQ-05)', () => {
60
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
61
+ s.cohortPhase = 'FALLBACK_PENDING';
62
+ expect(() => (0, protocol_1.reconcileProtocol)(s, {
63
+ kind: 'effect-failed', trackId: 'missing', effect: 'begin-teardown', detail: 'ownership indemostrable',
64
+ })).toThrow('track desconocido: missing');
65
+ });
66
+ test('remove cancela toda la cohorte activa y conserva DECLARED/BLOCKED (TR-REQ-03, TR-REQ-05)', () => {
67
+ const s = (0, protocol_1.initialCohort)('journal-1', ['active', 'declared', 'blocked']);
68
+ s.cohortPhase = 'ACTIVE';
69
+ s.tracks.active.phase = 'ACTIVE';
70
+ s.tracks.declared.phase = 'DECLARED';
71
+ s.tracks.blocked.phase = 'BLOCKED';
72
+ const out = (0, protocol_1.reconcileProtocol)(s, { kind: 'teardown-requested', trackId: 'active' });
73
+ expect(out.cohortPhase).toBe('FALLBACK_PENDING');
74
+ expect(out.fallbackReason).toBe('controller-requested:active');
75
+ expect(out.tracks.active.phase).toBe('TEARDOWN_REQUESTED');
76
+ expect(out.tracks.declared.phase).toBe('DECLARED');
77
+ expect(out.tracks.blocked.phase).toBe('BLOCKED');
78
+ expect((0, protocol_1.nextProtocolEffect)(out)).toBeNull();
79
+ });
80
+ test('remove durante FINAL_INTERLOCK invalida evidencia final antes de pedir teardown (issue #93)', () => {
81
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
82
+ s.cohortPhase = 'FINAL_INTERLOCK';
83
+ s.globalQaHeadSha = 'qa-head';
84
+ s.finalIntegrationJobId = 'integration-job';
85
+ for (const track of Object.values(s.tracks)) {
86
+ track.phase = 'MERGED_UNVERIFIED';
87
+ track.frozenHeadSha = `${track.trackId}-head`;
88
+ }
89
+ const out = (0, protocol_1.reconcileProtocol)(s, { kind: 'teardown-requested', trackId: 'cli' });
90
+ expect(out.cohortPhase).toBe('FALLBACK_PENDING');
91
+ expect(out.globalQaHeadSha).toBeUndefined();
92
+ expect(out.finalIntegrationJobId).toBeUndefined();
93
+ expect(Object.values(out.tracks).every((track) => track.phase === 'TEARDOWN_REQUESTED')).toBe(true);
94
+ });
95
+ test('remove repetido no rebobina teardown ni reemplaza la causa (TR-REQ-08)', () => {
96
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
97
+ s.cohortPhase = 'FALLBACK_PENDING';
98
+ s.fallbackReason = 'controller-requested:cli';
99
+ s.tracks.cli.phase = 'SUPERVISOR_STOPPED';
100
+ s.tracks.docs.phase = 'TEARDOWN_REQUESTED';
101
+ const out = (0, protocol_1.reconcileProtocol)(s, { kind: 'teardown-requested', trackId: 'docs' });
102
+ expect(out.fallbackReason).toBe('controller-requested:cli');
103
+ expect(out.tracks.cli.phase).toBe('SUPERVISOR_STOPPED');
104
+ expect(out.tracks.docs.phase).toBe('TEARDOWN_REQUESTED');
105
+ });
106
+ test.each(['SERIAL', 'COMPLETE'])('remove es no-op protocolar en cohorte terminal %s (TR-REQ-09)', (cohortPhase) => {
107
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
108
+ s.cohortPhase = cohortPhase;
109
+ for (const track of Object.values(s.tracks)) {
110
+ track.phase = cohortPhase === 'SERIAL' ? 'REMOVED' : 'JOINED';
111
+ if (cohortPhase === 'COMPLETE')
112
+ track.frozenHeadSha = `${track.trackId}-head`;
113
+ }
114
+ expect((0, protocol_1.reconcileProtocol)(s, { kind: 'teardown-requested', trackId: 'cli' })).toEqual(s);
115
+ });
116
+ test('remove es no-op protocolar en cohorte BLOCKED y no reanuda teardown (TR-REQ-10)', () => {
117
+ const s = (0, protocol_1.initialCohort)('journal-1', ids);
118
+ s.cohortPhase = 'BLOCKED';
119
+ s.tracks.cli.phase = 'BLOCKED';
120
+ s.tracks.cli.blockedReason = 'ownership indemostrable';
121
+ s.tracks.docs.phase = 'REMOVED';
122
+ const out = (0, protocol_1.reconcileProtocol)(s, { kind: 'teardown-requested', trackId: 'docs' });
123
+ expect(out).toEqual(s);
124
+ expect(out.cohortPhase).toBe('BLOCKED');
125
+ expect((0, protocol_1.nextProtocolEffect)(out)).toBeNull();
126
+ });
127
+ test('remove converge a serial solo después de desmontar la cohorte (TR-REQ-06)', () => {
128
+ let s = (0, protocol_1.initialCohort)('journal-1', ids);
129
+ for (const track of Object.values(s.tracks))
130
+ track.phase = 'ACTIVE';
131
+ s.cohortPhase = 'ACTIVE';
132
+ s = (0, protocol_1.reconcileProtocol)(s, { kind: 'teardown-requested', trackId: 'cli' });
133
+ expect((0, protocol_1.nextProtocolEffect)(s)).toEqual({ kind: 'begin-teardown', trackId: 'cli' });
134
+ s.tracks.cli.phase = 'REMOVED';
135
+ s.tracks.docs.phase = 'REMOVED';
136
+ expect((0, protocol_1.nextProtocolEffect)(s)).toEqual({
137
+ kind: 'enter-serial', reason: 'controller-requested:cli',
138
+ });
139
+ });
37
140
  test('ningún merge empieza antes de que todos los tracks estén FROZEN (C3)', () => {
38
141
  const s = (0, protocol_1.initialCohort)('journal-1', ids);
39
142
  s.cohortPhase = 'JOINING';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "9.6.1",
3
+ "version": "9.7.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"
Binary file
Binary file