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
|
@@ -0,0 +1,643 @@
|
|
|
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 12 (R3.6, R7.1-R7.7, R8.1/R8.2, C3/C4): QA final, integración canónica
|
|
7
|
+
// única e interlock. Tres frentes:
|
|
8
|
+
// A. `canCompleteCohort` — pura, sin I/O (R8.1).
|
|
9
|
+
// A2. `runRequestFinalIntegration` — fronteras `reconcileTracks()`
|
|
10
|
+
// aisladas con un `TrackRuntime` fake (mismo criterio que Task 11 en
|
|
11
|
+
// `track-join-crash.test.ts`, describe "adquisición del lock y merge
|
|
12
|
+
// son fronteras separadas"): prueba DIRECTAMENTE que pausar la
|
|
13
|
+
// generación del controller y pedir el job canónico son dos llamadas
|
|
14
|
+
// distintas, sin depender de un loop de convergencia end-to-end.
|
|
15
|
+
// B. El finalizer end-to-end: `Supervisor.tick()` real + `reconcileTracks`
|
|
16
|
+
// real, sobre un repo git real y un journal real — mismo criterio que
|
|
17
|
+
// `track-freeze.test.ts`/`track-runtime-git.test.ts`: la única forma
|
|
18
|
+
// honesta de probar C3/C4 (un único job canónico, con el conjunto
|
|
19
|
+
// COMPLETO de satisfiers, superviviente a un crash/restart) es contra
|
|
20
|
+
// journal y git reales, nunca un mock del protocolo.
|
|
21
|
+
const fs_1 = __importDefault(require("fs"));
|
|
22
|
+
const path_1 = __importDefault(require("path"));
|
|
23
|
+
const os_1 = __importDefault(require("os"));
|
|
24
|
+
const child_process_1 = require("child_process");
|
|
25
|
+
const supervisor_1 = require("../../../src/commands/watch/supervisor");
|
|
26
|
+
const tracks_1 = require("../../../src/commands/watch/tracks");
|
|
27
|
+
const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
|
|
28
|
+
const init_1 = require("../../../src/commands/watch/init");
|
|
29
|
+
const requests_1 = require("../../../src/core/journal/requests");
|
|
30
|
+
const apply_1 = require("../../../src/commands/watch/apply");
|
|
31
|
+
const generations_1 = require("../../../src/commands/watch/generations");
|
|
32
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
33
|
+
const gate_1 = require("../../../src/commands/job/gate");
|
|
34
|
+
const fingerprint_1 = require("../../../src/core/journal/fingerprint");
|
|
35
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
36
|
+
const types_1 = require("../../../src/core/journal/types");
|
|
37
|
+
const git_fixture_1 = require("../../helpers/git-fixture");
|
|
38
|
+
function git(cwd, ...args) {
|
|
39
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'pipe' });
|
|
40
|
+
}
|
|
41
|
+
// --- Parte A: canCompleteCohort (R8.1), pura ------------------------------
|
|
42
|
+
function trackRef(trackId, phase) {
|
|
43
|
+
return {
|
|
44
|
+
trackId, worktreePath: `/tmp/${trackId}`, branch: `awm-track/${trackId}`,
|
|
45
|
+
ownership: [], sharedResources: [], dependsOn: [],
|
|
46
|
+
fencingToken: `f-${trackId}`.padEnd(32, '0'), phase, readinessNonce: `r-${trackId}`.padEnd(32, '0'),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function stateWithPhases(phases) {
|
|
50
|
+
const s = (0, types_1.emptyState)('main');
|
|
51
|
+
s.tracks = Object.entries(phases).map(([id, phase]) => trackRef(id, phase));
|
|
52
|
+
return s;
|
|
53
|
+
}
|
|
54
|
+
describe('canCompleteCohort (R8.1, Task 12)', () => {
|
|
55
|
+
test('un track pendiente mantiene IN_PROGRESS y lo nombra', () => {
|
|
56
|
+
expect((0, tracks_1.canCompleteCohort)(stateWithPhases({ a: 'MERGED_UNVERIFIED', b: 'FROZEN' })))
|
|
57
|
+
.toEqual({ complete: false, pendingTracks: ['b'] });
|
|
58
|
+
});
|
|
59
|
+
test('todos MERGED_UNVERIFIED: completo, sin pendientes', () => {
|
|
60
|
+
expect((0, tracks_1.canCompleteCohort)(stateWithPhases({ a: 'MERGED_UNVERIFIED', b: 'MERGED_UNVERIFIED' })))
|
|
61
|
+
.toEqual({ complete: true, pendingTracks: [] });
|
|
62
|
+
});
|
|
63
|
+
test('un track JOINED (más allá de MERGED_UNVERIFIED) no cuenta como pendiente', () => {
|
|
64
|
+
expect((0, tracks_1.canCompleteCohort)(stateWithPhases({ a: 'JOINED', b: 'FROZEN' })))
|
|
65
|
+
.toEqual({ complete: false, pendingTracks: ['b'] });
|
|
66
|
+
});
|
|
67
|
+
test('menos de dos tracks nunca se considera completo', () => {
|
|
68
|
+
expect((0, tracks_1.canCompleteCohort)(stateWithPhases({ a: 'MERGED_UNVERIFIED' })))
|
|
69
|
+
.toEqual({ complete: false, pendingTracks: [] });
|
|
70
|
+
});
|
|
71
|
+
test('sin tracks (journal que no es de una cohorte): nunca completo', () => {
|
|
72
|
+
expect((0, tracks_1.canCompleteCohort)((0, types_1.emptyState)('main'))).toEqual({ complete: false, pendingTracks: [] });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
// --- Parte A2: `runRequestFinalIntegration` — pausa y pedido del job
|
|
76
|
+
// canónico son DOS fronteras `reconcileTracks()` distintas (R7.3/R7.6/C4,
|
|
77
|
+
// mismo criterio que Task 11 aplicó a `ensureIntegrationLock`/intento de
|
|
78
|
+
// merge, ver el describe "fronteras separadas" en `track-join-crash.test.ts`)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
//
|
|
81
|
+
// Antes de este test, el split solo se ejercitaba INCIDENTALMENTE vía el
|
|
82
|
+
// loop de convergencia `tickUntil` de la Parte B — eso prueba que el
|
|
83
|
+
// RESULTADO FINAL converge, pero un colapso de las dos fronteras en una sola
|
|
84
|
+
// llamada (la clase de bug que Task 8 encontró tres veces) podría pasar
|
|
85
|
+
// desapercibido igual, porque `tickUntil` nunca aisla la llamada intermedia.
|
|
86
|
+
// Este bloque usa un `TrackRuntime` fake — solo `pauseControllerGeneration`
|
|
87
|
+
// controlado directamente, el resto son `unexpected()` porque el efecto
|
|
88
|
+
// `request-final-integration` no toca ningún otro método de la interfaz —
|
|
89
|
+
// y deja `requestJob`/`emitRequest` correr de verdad contra un repo git real
|
|
90
|
+
// (necesario para que `computeFingerprint`/el guard de árbol limpio de
|
|
91
|
+
// `requestJob` con `verificationKind: 'track-integration'` se ejerciten de
|
|
92
|
+
// verdad, no se simulen).
|
|
93
|
+
describe('runRequestFinalIntegration — pausa del controller y pedido del job canónico son fronteras separadas (Finding 2, revisión de Task 12)', () => {
|
|
94
|
+
const BRANCH = 'main';
|
|
95
|
+
function mergedTrackRef(id, headSha) {
|
|
96
|
+
return {
|
|
97
|
+
trackId: id, worktreePath: `/tmp/${id}`, branch: `awm-track/${id}`,
|
|
98
|
+
ownership: [], sharedResources: [], dependsOn: [],
|
|
99
|
+
fencingToken: `f-${id}`.padEnd(32, '0'), phase: 'MERGED_UNVERIFIED', readinessNonce: `r-${id}`.padEnd(32, '0'),
|
|
100
|
+
frozenHeadSha: headSha, joinedCommitSha: headSha,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/** Journal ya en `FINAL_INTEGRATION` con ambos tracks `MERGED_UNVERIFIED`
|
|
104
|
+
* y el contrato canónico registrado — exactamente el estado en el que
|
|
105
|
+
* `nextProtocolEffect` produce `request-final-integration` (protocol.ts
|
|
106
|
+
* línea `s.globalQaHeadSha !== undefined && s.finalIntegrationJobId ===
|
|
107
|
+
* undefined`), sin pasar por freeze/merge/QA (fuera de alcance acá). */
|
|
108
|
+
function declareFinalIntegrationCohort(planRoot, headSha) {
|
|
109
|
+
(0, store_1.initJournal)(planRoot, BRANCH);
|
|
110
|
+
const s0 = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
111
|
+
s0.tracks = [mergedTrackRef('a', headSha), mergedTrackRef('b', headSha)];
|
|
112
|
+
s0.cohortPhase = 'FINAL_INTEGRATION';
|
|
113
|
+
s0.cohortBaseSha = headSha;
|
|
114
|
+
s0.cohortPlanHeadSha = headSha;
|
|
115
|
+
s0.globalQaHeadSha = headSha;
|
|
116
|
+
// `true` es un builtin POSIX, NO un ejecutable en Windows: cmd.exe responde
|
|
117
|
+
// "is not recognized" y el job sale != 0, así que el gate reporta verdict adverso y
|
|
118
|
+
// el test mide el error del fixture en vez del comportamiento del interlock. Mismo
|
|
119
|
+
// idioma multiplataforma que ya usa este archivo más abajo para un no-op.
|
|
120
|
+
s0.trackIntegration = { argv: ['node', '-e', 'process.exit(0)'], paths: [], planDigest: 'plan-1' };
|
|
121
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s0);
|
|
122
|
+
return (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
123
|
+
}
|
|
124
|
+
function fakePauseOnlyRuntime(pauseResult, calls) {
|
|
125
|
+
const unexpected = (what) => () => {
|
|
126
|
+
throw new Error(`no debería llamarse: ${what} (fixture ya en FINAL_INTEGRATION, solo request-final-integration en juego)`);
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
addWorktree: unexpected('addWorktree'),
|
|
130
|
+
initTrackJournal: unexpected('initTrackJournal'),
|
|
131
|
+
spawnSupervisor: unexpected('spawnSupervisor'),
|
|
132
|
+
observeSupervisor: unexpected('observeSupervisor'),
|
|
133
|
+
stopOwnSupervisor: async () => unexpected('stopOwnSupervisor')(),
|
|
134
|
+
removeOwnedWorktree: unexpected('removeOwnedWorktree'),
|
|
135
|
+
removeOwnedBranch: unexpected('removeOwnedBranch'),
|
|
136
|
+
emitFreezeRequest: unexpected('emitFreezeRequest'),
|
|
137
|
+
mergeFrozenTrack: unexpected('mergeFrozenTrack'),
|
|
138
|
+
abortOwnedMerge: unexpected('abortOwnedMerge'),
|
|
139
|
+
ensureIntegrationLock: async () => unexpected('ensureIntegrationLock')(),
|
|
140
|
+
async pauseControllerGeneration() {
|
|
141
|
+
calls.pause += 1;
|
|
142
|
+
return pauseResult;
|
|
143
|
+
},
|
|
144
|
+
releaseIntegrationLockIfHeld: unexpected('releaseIntegrationLockIfHeld'),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function pendingCanonicalRequests(planRoot) {
|
|
148
|
+
return (0, requests_1.listPendingRequests)(planRoot, BRANCH).filter((p) => !p.corrupt && p.envelope.kind === 'job-request'
|
|
149
|
+
&& Array.isArray(p.envelope.payload.satisfies)
|
|
150
|
+
&& (p.envelope.payload.satisfies).some((id) => typeof id === 'string' && id.startsWith('track-integration:')));
|
|
151
|
+
}
|
|
152
|
+
function readEvents(planRoot) {
|
|
153
|
+
let raw = '';
|
|
154
|
+
try {
|
|
155
|
+
raw = fs_1.default.readFileSync((0, paths_1.eventsPath)(planRoot, BRANCH), 'utf8');
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
return raw.split('\n').filter((l) => l.trim().length > 0).map((l) => JSON.parse(l));
|
|
161
|
+
}
|
|
162
|
+
let planRoot;
|
|
163
|
+
afterEach(() => { if (planRoot !== undefined)
|
|
164
|
+
fs_1.default.rmSync(planRoot, { recursive: true, force: true }); });
|
|
165
|
+
test('(a) generación activa: un call SOLO pausa (stop:true) y NO pide el job canónico en ese mismo call', async () => {
|
|
166
|
+
planRoot = (0, git_fixture_1.initRepo)();
|
|
167
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
168
|
+
const headSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
|
|
169
|
+
const s0 = declareFinalIntegrationCohort(planRoot, headSha);
|
|
170
|
+
const calls = { pause: 0 };
|
|
171
|
+
const runtime = fakePauseOnlyRuntime(true, calls);
|
|
172
|
+
const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, runtime, 2);
|
|
173
|
+
expect(calls.pause).toBe(1);
|
|
174
|
+
expect(result.effectExecuted).toBe('request-final-integration');
|
|
175
|
+
expect(result.state.finalIntegrationJobId).toBeUndefined();
|
|
176
|
+
// El único touch real de este call fue la pausa — ningún job-request
|
|
177
|
+
// quedó escrito en el requestsDir todavía.
|
|
178
|
+
expect(pendingCanonicalRequests(planRoot)).toEqual([]);
|
|
179
|
+
expect(readEvents(planRoot).some((e) => e.kind === 'plan-generation-paused-for-integration')).toBe(true);
|
|
180
|
+
expect(readEvents(planRoot).some((e) => e.kind === 'track-integration-requested')).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
test('(b) una vez confirmada pausada (sin generación activa), el PRÓXIMO call recién ahí pide el job canónico', async () => {
|
|
183
|
+
planRoot = (0, git_fixture_1.initRepo)();
|
|
184
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
185
|
+
const headSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
|
|
186
|
+
const s0 = declareFinalIntegrationCohort(planRoot, headSha);
|
|
187
|
+
const callsA = { pause: 0 };
|
|
188
|
+
const afterPause = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, fakePauseOnlyRuntime(true, callsA), 2)).state;
|
|
189
|
+
expect(pendingCanonicalRequests(planRoot)).toEqual([]); // precondición: (a) todavía no pidió nada
|
|
190
|
+
const callsB = { pause: 0 };
|
|
191
|
+
const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, afterPause, fakePauseOnlyRuntime(false, callsB), 2);
|
|
192
|
+
expect(callsB.pause).toBe(1); // confirmado sin generación activa, no memoizado: se sigue llamando
|
|
193
|
+
expect(result.effectExecuted).toBe('request-final-integration');
|
|
194
|
+
expect(pendingCanonicalRequests(planRoot)).toHaveLength(1);
|
|
195
|
+
expect(readEvents(planRoot).some((e) => e.kind === 'track-integration-requested')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
test('sin generación que pausar desde el arranque, pausa y pedido colapsan en el mismo call (sin violar "a lo sumo una mutación real")', async () => {
|
|
198
|
+
// Caso borde documentado por el propio driver: cuando `hadActive` es
|
|
199
|
+
// `false` desde el primer call, `pauseControllerGeneration` no hizo
|
|
200
|
+
// NINGÚN touch real (nunca llamó a `stopControllerGenerationConfirmed`)
|
|
201
|
+
// — así que seguir en el mismo call al pedido del job no funde dos
|
|
202
|
+
// mutaciones reales, solo una (el job-request). Esto es DISTINTO del
|
|
203
|
+
// caso (a)/(b): ahí `pausedNow === true` SÍ fue un touch real, y por
|
|
204
|
+
// eso corta el call.
|
|
205
|
+
planRoot = (0, git_fixture_1.initRepo)();
|
|
206
|
+
(0, git_fixture_1.commitFile)(planRoot, '.gitignore', '.awm/\n');
|
|
207
|
+
const headSha = (0, git_fixture_1.commitFile)(planRoot, 'seed.txt', 'seed');
|
|
208
|
+
const s0 = declareFinalIntegrationCohort(planRoot, headSha);
|
|
209
|
+
const calls = { pause: 0 };
|
|
210
|
+
const result = await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s0, fakePauseOnlyRuntime(false, calls), 2);
|
|
211
|
+
expect(calls.pause).toBe(1);
|
|
212
|
+
expect(pendingCanonicalRequests(planRoot)).toHaveLength(1);
|
|
213
|
+
expect(result.effectExecuted).toBe('request-final-integration');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
// --- Parte B: el finalizer end-to-end -------------------------------------
|
|
217
|
+
// El comando canónico del fixture NO pasa por npm, y eso es portabilidad, no comodidad:
|
|
218
|
+
// (1) `execFile('npm', …)` falla en Windows —el ejecutable real es `npm.cmd`— y este argv se
|
|
219
|
+
// ejecuta sin shell, a propósito (C4); (2) `npm test -- --runInBand` pega el flag DIRECTO al
|
|
220
|
+
// script sin preservar el `--`, así que el script recibe un argumento que no espera. El
|
|
221
|
+
// fixture anterior sobrevivía a (2) solo porque `true` ignora sus argumentos — y `true` no
|
|
222
|
+
// existe como ejecutable en Windows, que era (1) otra vez. Un `node -e` autónomo no depende
|
|
223
|
+
// de ninguna de las dos cosas.
|
|
224
|
+
const INTEGRATION_ARGV = ['node', '-e', 'process.exit(0)'];
|
|
225
|
+
const INTEGRATION_PATHS = ['cli/src/**', 'cli/tests/**'];
|
|
226
|
+
/** Repo real con dos tracks 'a'/'b' YA declarados en la cohorte (fase inicial
|
|
227
|
+
* ACTIVE — jamás FROZEN_OR_LATER — para que `nextProtocolEffect` no intente
|
|
228
|
+
* ningún efecto de freeze/merge mientras el harness los marca MERGED_UNVERIFIED
|
|
229
|
+
* directamente: Task 12 empieza DESPUÉS de que Task 10/11 ya mergearon,
|
|
230
|
+
* reproducir ESE camino no es responsabilidad de esta suite). El contrato
|
|
231
|
+
* canónico de integración (`trackIntegration`) se registra desde el arranque
|
|
232
|
+
* (Step 6) — sin él, `runRequestFinalIntegration` jamás pide el job. */
|
|
233
|
+
function finalizerHarness(trackIds) {
|
|
234
|
+
const repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-finalize-'));
|
|
235
|
+
git(repo, 'init', '-q', '-b', 'main');
|
|
236
|
+
// `true` ignora cualquier argumento extra (`npm test -- --runInBand` pasa
|
|
237
|
+
// `--runInBand` al script) y siempre sale 0 — a diferencia de `node -e`,
|
|
238
|
+
// que interpreta `--runInBand` como flag de node y falla con exit 9.
|
|
239
|
+
// Queda por si algún test futuro invoca `npm test`; el comando canónico ya no lo hace.
|
|
240
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'node --version' } }));
|
|
241
|
+
git(repo, 'add', '.');
|
|
242
|
+
git(repo, 'commit', '-qm', 'seed');
|
|
243
|
+
fs_1.default.mkdirSync(path_1.default.join(repo, '.awm'), { recursive: true });
|
|
244
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, '.awm', 'sensors.json'), '{}');
|
|
245
|
+
(0, init_1.initWatch)(repo, 'main'); // gitignora .awm, detecta requiredVerifiers (test+sensors, R3.6)
|
|
246
|
+
git(repo, 'add', '.gitignore');
|
|
247
|
+
git(repo, 'commit', '-qm', 'gitignore .awm');
|
|
248
|
+
const baseSha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
249
|
+
const stubBin = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-finalize-stub-'));
|
|
250
|
+
fs_1.default.writeFileSync(path_1.default.join(stubBin, 'codex'), '#!/bin/sh\nwhile true; do sleep 1; done\n', { mode: 0o755 });
|
|
251
|
+
const oldPath = process.env.PATH;
|
|
252
|
+
process.env.PATH = `${stubBin}:${process.env.PATH}`;
|
|
253
|
+
let integrationCalls = 0;
|
|
254
|
+
const spawner = (job, nonce, logsRoot, repoRoot) => {
|
|
255
|
+
if (job.satisfies?.some((id) => id.startsWith('track-integration:')))
|
|
256
|
+
integrationCalls++;
|
|
257
|
+
void (0, exec_wrapper_1.runExecWrapper)({ logsRoot, jobId: job.id, nonce, argv: job.argv, cwd: job.cwd, repoRoot }).catch(() => { });
|
|
258
|
+
};
|
|
259
|
+
const s0 = (0, store_1.readJournal)(repo, 'main').state;
|
|
260
|
+
// 'test'/'sensors' (R1/R3.6, pre-existente): el repo YA los exige
|
|
261
|
+
// (`requiredVerifiers`, detectados arriba) — sin ALGÚN item de esos kinds
|
|
262
|
+
// en algún plan, `computeGate` jamás certifica por ausencia, sin importar
|
|
263
|
+
// qa/interlock/track-integration. En un cohorte real viven en el journal
|
|
264
|
+
// de CADA track; acá, fuera de ese alcance, se modelan a nivel de ciclo.
|
|
265
|
+
s0.cycleVerificationPlan = [
|
|
266
|
+
{ id: 'qa', kind: 'qa' }, { id: 'interlock', kind: 'interlock' },
|
|
267
|
+
{ id: 'test', kind: 'test' }, { id: 'sensors', kind: 'sensors' },
|
|
268
|
+
];
|
|
269
|
+
s0.tracks = trackIds.map((id) => trackRef(id, 'ACTIVE'));
|
|
270
|
+
s0.cohortPhase = 'JOINING';
|
|
271
|
+
s0.cohortBaseSha = baseSha;
|
|
272
|
+
s0.cohortPlanHeadSha = baseSha;
|
|
273
|
+
// Mismo efecto que `track-prepare-request` produce en producción (R7.3) —
|
|
274
|
+
// el harness declara los tracks directamente (sin pasar por el protocolo
|
|
275
|
+
// completo de bootstrap P1/P2, fuera de alcance de esta suite) pero SÍ
|
|
276
|
+
// necesita el mismo resultado: los items `track-integration:*` deben
|
|
277
|
+
// existir en el plan ANTES de que cualquier job pueda enlazarse a ellos.
|
|
278
|
+
(0, apply_1.registerTrackIntegrationItems)(s0);
|
|
279
|
+
(0, store_1.writeJournal)(repo, 'main', s0);
|
|
280
|
+
(0, requests_1.emitRequest)(repo, 'main', {
|
|
281
|
+
kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'ti-contract',
|
|
282
|
+
payload: { entity: 'track-integration', argv: INTEGRATION_ARGV, paths: INTEGRATION_PATHS, planDigest: 'plan-1' },
|
|
283
|
+
});
|
|
284
|
+
const cfg = { ...supervisor_1.DEFAULT_SUPERVISOR_CONFIG, tickMs: 10, provider: 'codex' };
|
|
285
|
+
let sup = new supervisor_1.Supervisor(repo, 'main', cfg, spawner);
|
|
286
|
+
const realFingerprintNow = (argv, paths, cwd) => {
|
|
287
|
+
try {
|
|
288
|
+
return (0, fingerprint_1.computeFingerprint)(repo, argv, paths, cwd).fingerprint;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
repo,
|
|
296
|
+
async tick() { return sup.tick(); },
|
|
297
|
+
async tickUntil(predicate, maxTicks = 400) {
|
|
298
|
+
let outcome = 'continue';
|
|
299
|
+
for (let i = 0; i < maxTicks && !predicate(); i++) {
|
|
300
|
+
outcome = await sup.tick();
|
|
301
|
+
if (predicate())
|
|
302
|
+
break;
|
|
303
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
304
|
+
}
|
|
305
|
+
return outcome;
|
|
306
|
+
},
|
|
307
|
+
markMerged(trackId) {
|
|
308
|
+
const sha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
309
|
+
const s = (0, store_1.readJournal)(repo, 'main').state;
|
|
310
|
+
s.tracks = (s.tracks ?? []).map((t) => (t.trackId === trackId
|
|
311
|
+
? { ...t, phase: 'MERGED_UNVERIFIED', frozenHeadSha: sha, joinedCommitSha: sha }
|
|
312
|
+
: t));
|
|
313
|
+
(0, store_1.writeJournal)(repo, 'main', s);
|
|
314
|
+
return sha;
|
|
315
|
+
},
|
|
316
|
+
allMerged() {
|
|
317
|
+
const sha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
318
|
+
const s = (0, store_1.readJournal)(repo, 'main').state;
|
|
319
|
+
s.tracks = (s.tracks ?? []).map((t) => ({ ...t, phase: 'MERGED_UNVERIFIED', frozenHeadSha: sha, joinedCommitSha: sha }));
|
|
320
|
+
s.cohortPlanHeadSha = sha;
|
|
321
|
+
(0, store_1.writeJournal)(repo, 'main', s);
|
|
322
|
+
return sha;
|
|
323
|
+
},
|
|
324
|
+
reportQaPass() {
|
|
325
|
+
// El "controller" corrige hallazgos y comitea (Step 7.2) — acá,
|
|
326
|
+
// sintéticamente, un archivo nuevo representa ese fix.
|
|
327
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'qa-fix.txt'), 'fix');
|
|
328
|
+
git(repo, 'add', '.');
|
|
329
|
+
git(repo, 'commit', '-qm', 'post-implementation-qa fixes');
|
|
330
|
+
const sha = (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
331
|
+
// 'qa'/'interlock'/'test'/'sensors' del ciclo (R1, pre-existente):
|
|
332
|
+
// satisfechos EN ESTE MISMO HEAD — ningún commit posterior los
|
|
333
|
+
// invalida antes del interlock final (el job canónico de
|
|
334
|
+
// integración no comitea).
|
|
335
|
+
for (const itemId of ['qa', 'interlock', 'test', 'sensors']) {
|
|
336
|
+
const argv = ['node', '-e', 'process.exit(0)'];
|
|
337
|
+
const fp = (0, fingerprint_1.computeFingerprint)(repo, argv, [], '.');
|
|
338
|
+
const s = (0, store_1.readJournal)(repo, 'main').state;
|
|
339
|
+
const jobId = `job-${itemId}`;
|
|
340
|
+
const job = {
|
|
341
|
+
id: jobId, fingerprint: fp.fingerprint, commandDigest: fp.commandDigest, argv, cwd: '.',
|
|
342
|
+
paths: [], expandedPaths: fp.expandedPaths, executionState: 'exited', observationState: 'progressing',
|
|
343
|
+
verdict: 'pass', phaseTimestamps: {}, satisfies: [itemId],
|
|
344
|
+
};
|
|
345
|
+
s.jobs[jobId] = job;
|
|
346
|
+
const item = s.cycleVerificationPlan.find((x) => x.id === itemId);
|
|
347
|
+
item.satisfiedBy = jobId;
|
|
348
|
+
(0, store_1.writeJournal)(repo, 'main', s);
|
|
349
|
+
}
|
|
350
|
+
(0, requests_1.emitRequest)(repo, 'main', {
|
|
351
|
+
kind: 'track-finalize-request', generationToken: 'g0', idempotencyKey: `finalize-${sha}`,
|
|
352
|
+
payload: { qaHeadSha: sha },
|
|
353
|
+
});
|
|
354
|
+
return sha;
|
|
355
|
+
},
|
|
356
|
+
nextAction() { return (0, store_1.readJournal)(repo, 'main').state.cycle.nextAction; },
|
|
357
|
+
requestedJobs() {
|
|
358
|
+
return Object.values((0, store_1.readJournal)(repo, 'main').state.jobs)
|
|
359
|
+
.filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')))
|
|
360
|
+
.map((j) => ({ argv: j.argv, paths: j.paths, cwd: j.cwd, satisfies: [...(j.satisfies ?? [])].sort() }));
|
|
361
|
+
},
|
|
362
|
+
integrationWrapperCalls() { return integrationCalls; },
|
|
363
|
+
crashAndRestart() { sup = new supervisor_1.Supervisor(repo, 'main', cfg, spawner); },
|
|
364
|
+
interlock() {
|
|
365
|
+
const s = (0, store_1.readJournal)(repo, 'main').state;
|
|
366
|
+
return (0, gate_1.computeGate)(s, false, realFingerprintNow);
|
|
367
|
+
},
|
|
368
|
+
moveHeadUnrelated() {
|
|
369
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'unrelated.txt'), String(Date.now()));
|
|
370
|
+
git(repo, 'add', '.');
|
|
371
|
+
git(repo, 'commit', '-qm', 'unrelated change');
|
|
372
|
+
return (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
373
|
+
},
|
|
374
|
+
// Finding 4 (ronda 2 de re-review): `runnerTick` (runner.ts) despacha
|
|
375
|
+
// TODO job en `received` (`spawnPendingWrappers`) en la MISMA llamada
|
|
376
|
+
// que lo colecciona/reconcilia — y esa llamada corre dentro del MISMO
|
|
377
|
+
// `Supervisor.tick()` que, antes, ya aplicó (`consumePendingRequests`)
|
|
378
|
+
// cualquier `job-request` pendiente escrito por un tick ANTERIOR. Por
|
|
379
|
+
// eso, vía la API pública `tick()`, jamás hay un tick completo que
|
|
380
|
+
// deje un job en `received` observable desde afuera — request
|
|
381
|
+
// (aplicación) y despacho colapsan en el mismo tick. La única forma
|
|
382
|
+
// honesta de reproducir ese punto exacto (el mismo que un crash real
|
|
383
|
+
// SIN puntos de `await` de por medio podría alcanzar) es llamar,
|
|
384
|
+
// directo, a la MISMA función que `tick()` invoca primero —
|
|
385
|
+
// `consumePendingRequests` — sin llegar a `runnerTick`.
|
|
386
|
+
hasPendingCanonicalRequest() {
|
|
387
|
+
return (0, requests_1.listPendingRequests)(repo, 'main').some((p) => !p.corrupt && p.envelope.kind === 'job-request'
|
|
388
|
+
&& Array.isArray(p.envelope.payload.satisfies)
|
|
389
|
+
&& (p.envelope.payload.satisfies).some((id) => typeof id === 'string' && id.startsWith('track-integration:')));
|
|
390
|
+
},
|
|
391
|
+
applyPendingRequestsOnly() {
|
|
392
|
+
const gen = (0, generations_1.activeGeneration)((0, store_1.readJournal)(repo, 'main').state);
|
|
393
|
+
(0, apply_1.consumePendingRequests)(repo, 'main', gen?.token ?? null);
|
|
394
|
+
},
|
|
395
|
+
cleanup() {
|
|
396
|
+
process.env.PATH = oldPath;
|
|
397
|
+
fs_1.default.rmSync(repo, { recursive: true, force: true });
|
|
398
|
+
fs_1.default.rmSync(stubBin, { recursive: true, force: true });
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
describe('finalizer end-to-end (R7.1-R7.7, C3/C4, Task 12)', () => {
|
|
403
|
+
jest.setTimeout(60000);
|
|
404
|
+
let h;
|
|
405
|
+
afterEach(() => h?.cleanup());
|
|
406
|
+
test('solo el HEAD final recibe QA e integración (R7.7, C3): un merge aislado no dispara nada', async () => {
|
|
407
|
+
h = finalizerHarness(['a', 'b']);
|
|
408
|
+
h.markMerged('a');
|
|
409
|
+
await h.tick();
|
|
410
|
+
expect(h.requestedJobs()).toEqual([]);
|
|
411
|
+
expect(h.nextAction()?.type).not.toBe('run-global-qa');
|
|
412
|
+
h.allMerged(); // ahora AMBOS están MERGED_UNVERIFIED
|
|
413
|
+
await h.tick();
|
|
414
|
+
expect(h.nextAction()).toMatchObject({ type: 'run-global-qa', target: 'cycle' });
|
|
415
|
+
});
|
|
416
|
+
test('QA con fixes culmina en commit limpio antes del job canónico (C3/C4): un único job con TODOS los satisfiers', async () => {
|
|
417
|
+
h = finalizerHarness(['a', 'b']);
|
|
418
|
+
h.allMerged();
|
|
419
|
+
await h.tick(); // persiste nextAction run-global-qa
|
|
420
|
+
expect(h.nextAction()?.type).toBe('run-global-qa');
|
|
421
|
+
const qaHead = h.reportQaPass();
|
|
422
|
+
// converge: aceptar el autoreporte -> (pausar controller si hace
|
|
423
|
+
// falta) -> pedir el job canónico -> el job se ejecuta y pasa.
|
|
424
|
+
await h.tickUntil(() => h.requestedJobs().length > 0 && h.requestedJobs()[0].argv.length > 0);
|
|
425
|
+
await h.tickUntil(() => {
|
|
426
|
+
const jobs = h.requestedJobs();
|
|
427
|
+
return jobs.length === 1; // nunca más de uno
|
|
428
|
+
});
|
|
429
|
+
const jobs = h.requestedJobs();
|
|
430
|
+
expect(jobs).toEqual([{
|
|
431
|
+
argv: INTEGRATION_ARGV, paths: INTEGRATION_PATHS, cwd: '.',
|
|
432
|
+
satisfies: ['track-integration:a', 'track-integration:b'],
|
|
433
|
+
}]);
|
|
434
|
+
void qaHead;
|
|
435
|
+
});
|
|
436
|
+
// Matriz de crash del Step 8 (R7.1-R7.7, C3/C4): reusabilidad idempotente
|
|
437
|
+
// del job canónico probada en los CUATRO puntos que el plan nombra —
|
|
438
|
+
// "después de QA" (autoreporte aceptado, NINGÚN job pedido todavía),
|
|
439
|
+
// "después del request" (job pedido, aún sin correr/completar), "después
|
|
440
|
+
// del pass" (job pasó, interlock todavía no corrió) y "antes de COMPLETE"
|
|
441
|
+
// (interlock YA pasó — `cohortPhase` ya es `COMPLETE` — pero el chequeo
|
|
442
|
+
// genérico de `Supervisor.tick()` que fija `cycle.status = 'COMPLETE'`
|
|
443
|
+
// todavía no corrió sobre ese resultado). Las cuatro comparten el mismo
|
|
444
|
+
// rigor: git real, un contador REAL de invocaciones del wrapper (nunca
|
|
445
|
+
// solo una lectura del journal), y una assertion final de
|
|
446
|
+
// `integrationWrapperCalls() === 1` — el job canónico jamás corre dos
|
|
447
|
+
// veces sin importar en qué punto exacto "muere" el proceso.
|
|
448
|
+
test('crash "después de QA": el autoreporte ya fue ACEPTADO (HEAD/árbol re-verificados, cohortPhase ya en FINAL_INTEGRATION) pero NINGÚN job se pidió todavía — el restart converge a un único job con todos los satisfiers (C4, R7.3)', async () => {
|
|
449
|
+
h = finalizerHarness(['a', 'b']);
|
|
450
|
+
h.allMerged();
|
|
451
|
+
await h.tick();
|
|
452
|
+
expect(h.nextAction()?.type).toBe('run-global-qa');
|
|
453
|
+
h.reportQaPass(); // emite el request `track-finalize-request`
|
|
454
|
+
// Post-review (Finding 3, ronda 2 de re-review): la version anterior
|
|
455
|
+
// crasheaba ACA MISMO, sin tickear — es decir, ANTES de que
|
|
456
|
+
// `consumePendingRequests` siquiera aplicara el autoreporte. Eso
|
|
457
|
+
// probaba un escenario distinto (y ya cubierto por el resto de la
|
|
458
|
+
// matriz vía el mismo idempotencyKey). El punto que el Step 8 pide
|
|
459
|
+
// ("el autoreporte YA se aceptó, ningún job pedido todavía") exige
|
|
460
|
+
// avanzar hasta que `reconcileTracks` haya corrido de verdad
|
|
461
|
+
// `runRequestGlobalQa`: aplica `track-finalize-request`
|
|
462
|
+
// (`s.qaFinalizeRequested`), RE-VERIFICA `qaHeadSha === HEAD real` y
|
|
463
|
+
// árbol limpio (no confía ciegamente en el autoreporte, ver
|
|
464
|
+
// `apply.ts`), y solo entonces observa `global-qa-pass` — el efecto
|
|
465
|
+
// OBSERVABLE de esa aceptación es `cohortPhase` pasando a
|
|
466
|
+
// `FINAL_INTEGRATION` (`protocol.ts::reconcileProtocol`, rama
|
|
467
|
+
// `global-qa-pass`, fija `globalQaHeadSha` Y `cohortPhase` en LA
|
|
468
|
+
// MISMA transición). Un solo tick real basta — probado
|
|
469
|
+
// empíricamente: el mismo `reconcileTracks` que aplica el request
|
|
470
|
+
// también ejecuta `request-global-qa` en esa misma llamada — pero
|
|
471
|
+
// `tickUntil` en vez de `tick()` fijo documenta la condición real en
|
|
472
|
+
// vez de un conteo de ticks frágil.
|
|
473
|
+
await h.tickUntil(() => (0, store_1.readJournal)(h.repo, 'main').state.cohortPhase === 'FINAL_INTEGRATION', 20);
|
|
474
|
+
expect((0, store_1.readJournal)(h.repo, 'main').state.cohortPhase).toBe('FINAL_INTEGRATION');
|
|
475
|
+
expect((0, store_1.readJournal)(h.repo, 'main').state.qaFinalizeRequested).toBeDefined();
|
|
476
|
+
// El punto de crash exacto: el autoreporte ya fue aceptado Y
|
|
477
|
+
// re-verificado, pero el job canónico todavía no se pidió (el
|
|
478
|
+
// próximo efecto, `request-final-integration`, corre recién en un
|
|
479
|
+
// tick DISTINTO — `runRequestFinalIntegration` ni siquiera se
|
|
480
|
+
// invocó todavía).
|
|
481
|
+
expect(h.requestedJobs()).toEqual([]);
|
|
482
|
+
expect(h.integrationWrapperCalls()).toBe(0);
|
|
483
|
+
h.crashAndRestart(); // "restart": un proceso nuevo retoma desde el journal ya persistido
|
|
484
|
+
await h.tickUntil(() => h.requestedJobs().length === 1);
|
|
485
|
+
await h.tickUntil(() => {
|
|
486
|
+
const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
|
|
487
|
+
.filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
|
|
488
|
+
return jobs.length === 1 && jobs[0].executionState === 'exited';
|
|
489
|
+
});
|
|
490
|
+
const jobs = h.requestedJobs();
|
|
491
|
+
expect(jobs).toHaveLength(1); // nunca un subconjunto, ni siquiera arrancando de cero tras el crash
|
|
492
|
+
expect(jobs[0].satisfies).toEqual(['track-integration:a', 'track-integration:b']);
|
|
493
|
+
expect(h.integrationWrapperCalls()).toBe(1); // exactamente una vez: ni cero (atascado), ni dos (duplicado)
|
|
494
|
+
});
|
|
495
|
+
test('crash "después del request": el job ya fue CREADO en el journal (`received`) pero AÚN no se despachó — el restart nunca pide un subconjunto ni duplica la ejecución (C4, R7.6)', async () => {
|
|
496
|
+
h = finalizerHarness(['a', 'b']);
|
|
497
|
+
h.allMerged();
|
|
498
|
+
await h.tick();
|
|
499
|
+
h.reportQaPass();
|
|
500
|
+
// Post-review (Finding 4, ronda 2 de re-review): la version anterior
|
|
501
|
+
// usaba `tickUntil(() => h.requestedJobs().length === 1)` como punto
|
|
502
|
+
// de crash — pero `runner.ts::spawnPendingWrappers` despacha TODO job
|
|
503
|
+
// `received` (invoca el wrapper) en la MISMA llamada de
|
|
504
|
+
// `runnerTick` que ya corrió DENTRO del mismo tick que lo creó (ver
|
|
505
|
+
// `runnerTick`/`spawnPendingWrappers`, runner.ts): para cuando el job
|
|
506
|
+
// aparece en `requestedJobs()`, `integrationWrapperCalls()` YA es 1
|
|
507
|
+
// — es decir, ese punto de crash colapsaba con "después del pass",
|
|
508
|
+
// nunca representó "pedido pero sin despachar" (confirmado
|
|
509
|
+
// empíricamente: en este mismo harness, el job nace directo en
|
|
510
|
+
// `spawn-intent` con el wrapper ya invocado). La ÚNICA ventana real
|
|
511
|
+
// de "recibido, aún sin despachar" es la que un crash SIN puntos de
|
|
512
|
+
// `await` de por medio podría alcanzar dentro de un tick — entre que
|
|
513
|
+
// `consumePendingRequests` persiste el job en `received` y que
|
|
514
|
+
// `runnerTick` (más adelante en el MISMO `Supervisor.tick()`) lo
|
|
515
|
+
// despacha. Se reproduce llamando esa MISMA función (la primera
|
|
516
|
+
// mitad de ese tick) directo, sin completar el tick — mismo criterio
|
|
517
|
+
// que la prueba "antes de COMPLETE" de abajo usa con `reconcileTracks`.
|
|
518
|
+
for (let i = 0; i < 20 && !h.hasPendingCanonicalRequest(); i++)
|
|
519
|
+
await h.tick();
|
|
520
|
+
expect(h.hasPendingCanonicalRequest()).toBe(true); // el pedido ya esta en disco...
|
|
521
|
+
expect(h.requestedJobs()).toEqual([]); // ...pero AUN no es un Job del journal
|
|
522
|
+
h.applyPendingRequestsOnly(); // == la primera mitad exacta del proximo tick
|
|
523
|
+
const created = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
|
|
524
|
+
.filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
|
|
525
|
+
expect(created).toHaveLength(1);
|
|
526
|
+
expect(created[0].executionState).toBe('received'); // pedido y CREADO, pero SIN despachar
|
|
527
|
+
expect(h.integrationWrapperCalls()).toBe(0); // el wrapper jamas se invoco todavia
|
|
528
|
+
h.crashAndRestart();
|
|
529
|
+
// varios ticks más, ya con un proceso "nuevo": el mismo job se
|
|
530
|
+
// reconoce por idempotencyKey (fingerprint+commandDigest+satisfies) —
|
|
531
|
+
// jamás se pide un segundo job con un subconjunto, ni corre una
|
|
532
|
+
// segunda vez de verdad (contador del wrapper, no solo el journal).
|
|
533
|
+
await h.tickUntil(() => {
|
|
534
|
+
const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
|
|
535
|
+
.filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
|
|
536
|
+
return jobs.length === 1 && jobs[0].executionState === 'exited';
|
|
537
|
+
});
|
|
538
|
+
const jobs = h.requestedJobs();
|
|
539
|
+
expect(jobs).toHaveLength(1);
|
|
540
|
+
expect(jobs[0].satisfies).toEqual(['track-integration:a', 'track-integration:b']);
|
|
541
|
+
expect(h.integrationWrapperCalls()).toBe(1); // exactamente una vez: nunca corrio dos veces por el crash
|
|
542
|
+
});
|
|
543
|
+
test('el job canónico se ejecuta UNA sola vez (contador del wrapper) a través de un crash/restart', async () => {
|
|
544
|
+
h = finalizerHarness(['a', 'b']);
|
|
545
|
+
h.allMerged();
|
|
546
|
+
await h.tick();
|
|
547
|
+
h.reportQaPass();
|
|
548
|
+
await h.tickUntil(() => {
|
|
549
|
+
const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
|
|
550
|
+
.filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
|
|
551
|
+
return jobs.length === 1 && jobs[0].executionState === 'exited';
|
|
552
|
+
});
|
|
553
|
+
expect(h.integrationWrapperCalls()).toBe(1);
|
|
554
|
+
h.crashAndRestart();
|
|
555
|
+
for (let i = 0; i < 5; i++)
|
|
556
|
+
await h.tick();
|
|
557
|
+
expect(h.integrationWrapperCalls()).toBe(1); // jamás una segunda ejecución real
|
|
558
|
+
});
|
|
559
|
+
test('al pasar el job canónico, el interlock global (computeGate) certifica y no antes', async () => {
|
|
560
|
+
h = finalizerHarness(['a', 'b']);
|
|
561
|
+
h.allMerged();
|
|
562
|
+
await h.tick();
|
|
563
|
+
h.reportQaPass();
|
|
564
|
+
await h.tickUntil(() => {
|
|
565
|
+
const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
|
|
566
|
+
.filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
|
|
567
|
+
return jobs.length === 1 && jobs[0].executionState === 'exited';
|
|
568
|
+
});
|
|
569
|
+
// El interlock certifica UNA VEZ que la evidencia (qa, interlock,
|
|
570
|
+
// track-integration:* de ambos tracks) está completa y vigente.
|
|
571
|
+
// Instrumentado a propósito: un `toBe(true)` pelado en Windows dice "false" y nada
|
|
572
|
+
// más, y la causa (`stale-fingerprint`, `missing-verifier`, …) vive en `reasons`.
|
|
573
|
+
// Afirmar sobre las razones hace que el rojo TRAIGA su propio diagnóstico.
|
|
574
|
+
expect(h.interlock().reasons).toEqual([]);
|
|
575
|
+
await h.tickUntil(() => (0, store_1.readJournal)(h.repo, 'main').state.cohortPhase === 'COMPLETE');
|
|
576
|
+
const final = (0, store_1.readJournal)(h.repo, 'main').state;
|
|
577
|
+
expect(final.cohortPhase).toBe('COMPLETE');
|
|
578
|
+
expect(final.tracks.every((t) => t.phase === 'JOINED')).toBe(true);
|
|
579
|
+
// R7.5: integration.lock liberado al cerrar la cohorte.
|
|
580
|
+
expect(fs_1.default.existsSync((0, paths_1.integrationLockPath)(h.repo))).toBe(false);
|
|
581
|
+
});
|
|
582
|
+
test('crash "antes de COMPLETE": el interlock YA pasó (cohortPhase COMPLETE persistido) pero cycle.status todavía no flipeó — el restart converge sin repetir el job canónico (R7.4/R7.5)', async () => {
|
|
583
|
+
h = finalizerHarness(['a', 'b']);
|
|
584
|
+
h.allMerged();
|
|
585
|
+
await h.tick();
|
|
586
|
+
h.reportQaPass();
|
|
587
|
+
await h.tickUntil(() => {
|
|
588
|
+
const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
|
|
589
|
+
.filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
|
|
590
|
+
return jobs.length === 1 && jobs[0].executionState === 'exited';
|
|
591
|
+
});
|
|
592
|
+
expect(h.integrationWrapperCalls()).toBe(1);
|
|
593
|
+
// Deja que los ticks normales lleven la cohorte hasta FINAL_INTERLOCK
|
|
594
|
+
// (integration-pass ya observado, `finalIntegrationJobId` fijado) —
|
|
595
|
+
// el único efecto que falta es `run-final-interlock` en sí.
|
|
596
|
+
await h.tickUntil(() => (0, store_1.readJournal)(h.repo, 'main').state.cohortPhase === 'FINAL_INTERLOCK');
|
|
597
|
+
expect((0, store_1.readJournal)(h.repo, 'main').state.cycle.status).toBe('IN_PROGRESS');
|
|
598
|
+
// Punto de crash EXACTO que el Step 8 exige probar: `reconcileTracks`
|
|
599
|
+
// (la MISMA función que `Supervisor.tick()` invoca internamente,
|
|
600
|
+
// llamada acá directamente para poder detenernos a mitad de tick, algo
|
|
601
|
+
// que la API pública de `tick()` — atómica de punta a punta — no deja
|
|
602
|
+
// observar) corre el efecto `run-final-interlock` y persiste
|
|
603
|
+
// `cohortPhase = 'COMPLETE'` — pero el chequeo genérico de
|
|
604
|
+
// `Supervisor.tick()` (el ÚNICO lugar del código que fija
|
|
605
|
+
// `cycle.status = 'COMPLETE'`) todavía no se ejecutó sobre ese
|
|
606
|
+
// resultado. Un crash real (SIGKILL, sin puntos de `await` de por
|
|
607
|
+
// medio entre las dos escrituras) puede caer exactamente acá.
|
|
608
|
+
const before = (0, store_1.readJournal)(h.repo, 'main').state;
|
|
609
|
+
const runtime = (0, tracks_1.defaultTrackRuntime)(h.repo, 'main');
|
|
610
|
+
const { state: afterInterlock, effectExecuted } = await (0, tracks_1.reconcileTracks)(h.repo, 'main', before, runtime, supervisor_1.DEFAULT_SUPERVISOR_CONFIG.maxParallelTracks);
|
|
611
|
+
expect(effectExecuted).toBe('run-final-interlock');
|
|
612
|
+
expect(afterInterlock.cohortPhase).toBe('COMPLETE');
|
|
613
|
+
expect(afterInterlock.cycle.status).toBe('IN_PROGRESS'); // la ventana exacta del crash: todavía no flipeó
|
|
614
|
+
expect(afterInterlock.tracks.every((t) => t.phase === 'JOINED')).toBe(true);
|
|
615
|
+
expect(h.integrationWrapperCalls()).toBe(1); // el freeze de arriba no re-ejecuta el job canónico
|
|
616
|
+
h.crashAndRestart(); // "restart": un Supervisor nuevo retoma sobre el journal ya persistido
|
|
617
|
+
await h.tickUntil(() => (0, store_1.readJournal)(h.repo, 'main').state.cycle.status === 'COMPLETE');
|
|
618
|
+
const final = (0, store_1.readJournal)(h.repo, 'main').state;
|
|
619
|
+
expect(final.cycle.status).toBe('COMPLETE');
|
|
620
|
+
expect(final.cohortPhase).toBe('COMPLETE'); // reconcileTracks no-opea (protocol.ts: null tras COMPLETE) — nunca retrocede
|
|
621
|
+
expect(final.tracks.every((t) => t.phase === 'JOINED')).toBe(true);
|
|
622
|
+
expect(h.integrationWrapperCalls()).toBe(1); // jamás una segunda ejecución real del job canónico
|
|
623
|
+
});
|
|
624
|
+
test('mutación DESPUÉS de que el job canónico pasó vuelve stale el cierre (R7.7)', async () => {
|
|
625
|
+
h = finalizerHarness(['a', 'b']);
|
|
626
|
+
h.allMerged();
|
|
627
|
+
await h.tick();
|
|
628
|
+
h.reportQaPass();
|
|
629
|
+
await h.tickUntil(() => {
|
|
630
|
+
const jobs = Object.values((0, store_1.readJournal)(h.repo, 'main').state.jobs)
|
|
631
|
+
.filter((j) => j.satisfies?.some((id) => id.startsWith('track-integration:')));
|
|
632
|
+
return jobs.length === 1 && jobs[0].executionState === 'exited';
|
|
633
|
+
});
|
|
634
|
+
// Instrumentado a propósito: un `toBe(true)` pelado en Windows dice "false" y nada
|
|
635
|
+
// más, y la causa (`stale-fingerprint`, `missing-verifier`, …) vive en `reasons`.
|
|
636
|
+
// Afirmar sobre las razones hace que el rojo TRAIGA su propio diagnóstico.
|
|
637
|
+
expect(h.interlock().reasons).toEqual([]);
|
|
638
|
+
h.moveHeadUnrelated(); // el árbol mutó DESPUÉS de que la evidencia se fijó
|
|
639
|
+
const stale = h.interlock();
|
|
640
|
+
expect(stale.pass).toBe(false);
|
|
641
|
+
expect(stale.reasons).toEqual(expect.arrayContaining([expect.objectContaining({ category: 'stale-fingerprint' })]));
|
|
642
|
+
});
|
|
643
|
+
});
|