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