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 +2 -0
- package/dist/src/commands/track/index.js +3 -7
- package/dist/src/commands/watch/apply.js +15 -0
- package/dist/src/commands/watch/tracks.js +56 -7
- package/dist/src/core/journal/types.js +1 -0
- package/dist/src/core/tracks/git.js +15 -3
- package/dist/src/core/tracks/protocol.js +26 -6
- package/dist/tests/commands/track/verbs.test.js +6 -0
- package/dist/tests/commands/watch/apply.test.js +46 -9
- package/dist/tests/commands/watch/track-remove-request.test.js +165 -0
- package/dist/tests/commands/watch/track-teardown-crash.test.js +27 -0
- package/dist/tests/core/journal/types.test.js +11 -0
- package/dist/tests/core/tracks/git.test.js +7 -0
- package/dist/tests/core/tracks/protocol.test.js +103 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
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
|
-
//
|
|
108
|
-
//
|
|
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('
|
|
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):
|
|
139
|
-
// que
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (protocol.
|
|
144
|
-
next.
|
|
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
|
-
|
|
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 === '
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
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('
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
kind: 'track-teardown-request', generationToken: 'g1', idempotencyKey: 'teardown-
|
|
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
|
-
|
|
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
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|