agentic-workflow-manager 6.2.1 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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/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/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/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/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,1100 @@
|
|
|
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
|
+
exports.canCompleteCohort = canCompleteCohort;
|
|
7
|
+
exports.applyProtocolToState = applyProtocolToState;
|
|
8
|
+
exports.persist = persist;
|
|
9
|
+
exports.refOf = refOf;
|
|
10
|
+
exports.withRef = withRef;
|
|
11
|
+
exports.reconcileTracks = reconcileTracks;
|
|
12
|
+
exports.reconcileOpenJoin = reconcileOpenJoin;
|
|
13
|
+
exports.observeSupervisorFromDisk = observeSupervisorFromDisk;
|
|
14
|
+
exports.defaultTrackRuntime = defaultTrackRuntime;
|
|
15
|
+
// P1/P2 (bootstrap durable de tracks, R4.1-R4.10, C1/C2/C8/C11): el ÚNICO
|
|
16
|
+
// lugar que ejecuta side effects reales derivados de `protocol.ts`. Esta
|
|
17
|
+
// función es un driver DELGADO — toda decisión de qué pasa después vive en
|
|
18
|
+
// `core/tracks/protocol.ts` (regla de autoridad única, ver ese archivo);
|
|
19
|
+
// `reconcileTracks` solo traduce `JournalState` <-> `CohortProtocol`, ejecuta
|
|
20
|
+
// como máximo UNA llamada mutante a `TrackRuntime` por invocación, y persiste
|
|
21
|
+
// cada frontera antes/después de intentarla (para que Task 9 pueda inyectar
|
|
22
|
+
// crashes en cualquier punto y probar que el restart converge).
|
|
23
|
+
const fs_1 = __importDefault(require("fs"));
|
|
24
|
+
const path_1 = __importDefault(require("path"));
|
|
25
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
26
|
+
const store_1 = require("../../core/journal/store");
|
|
27
|
+
const requests_1 = require("../../core/journal/requests");
|
|
28
|
+
const paths_1 = require("../../core/journal/paths");
|
|
29
|
+
const process_1 = require("../../core/journal/process");
|
|
30
|
+
const fingerprint_1 = require("../../core/journal/fingerprint");
|
|
31
|
+
const protocol_1 = require("../../core/tracks/protocol");
|
|
32
|
+
const git_1 = require("../../core/tracks/git");
|
|
33
|
+
const ownership_1 = require("../../core/tracks/ownership");
|
|
34
|
+
const descriptor_1 = require("../../core/tracks/descriptor");
|
|
35
|
+
const join_1 = require("../../core/tracks/join");
|
|
36
|
+
const teardown_driver_1 = require("./teardown-driver");
|
|
37
|
+
const request_1 = require("../job/request");
|
|
38
|
+
const gate_1 = require("../job/gate");
|
|
39
|
+
const process_2 = require("../../core/journal/process");
|
|
40
|
+
const types_1 = require("../../core/tracks/types");
|
|
41
|
+
/** R8.1 (Task 12): pura, sin I/O — nombra los tracks que todavía no llegaron a
|
|
42
|
+
* `MERGED_UNVERIFIED` (el ciclo permanece IN_PROGRESS mientras existan). Vive
|
|
43
|
+
* acá (no en `core/tracks/protocol.ts`) porque no es una decisión de
|
|
44
|
+
* transición de fase — `nextProtocolEffect` YA calcula la misma condición
|
|
45
|
+
* internamente para decidir `request-global-qa` (`protocol.ts` sigue siendo
|
|
46
|
+
* la única autoridad de esa transición); esto es solo un helper de
|
|
47
|
+
* DIAGNÓSTICO/consulta sobre el mismo criterio, para que un caller (CLI,
|
|
48
|
+
* logging) pueda nombrar qué falta sin duplicar la condición a mano.
|
|
49
|
+
*
|
|
50
|
+
* Semántica sutil de "pendiente": un track cuenta como pendiente si TODAVÍA
|
|
51
|
+
* NO llegó a `MERGED_UNVERIFIED` **o más allá**. `JOINED` es la fase que
|
|
52
|
+
* sigue a `MERGED_UNVERIFIED` (ver `TRACK_PHASES` en `core/tracks/types.ts`
|
|
53
|
+
* y el observation `interlock-pass` en `protocol.ts`, que mueve TODOS los
|
|
54
|
+
* tracks a `JOINED` a la vez al cerrar la cohorte) — un track `JOINED` ya
|
|
55
|
+
* superó el hito que este helper vigila, así que NO es pendiente, aunque
|
|
56
|
+
* `JOINED !== 'MERGED_UNVERIFIED'` textualmente. Comparar solo contra
|
|
57
|
+
* `'MERGED_UNVERIFIED'` sin excluir también `'JOINED'` marcaría
|
|
58
|
+
* incorrectamente como pendiente un track que ya terminó. */
|
|
59
|
+
function canCompleteCohort(state) {
|
|
60
|
+
const tracks = state.tracks ?? [];
|
|
61
|
+
const pendingTracks = tracks
|
|
62
|
+
.filter((t) => t.phase !== 'MERGED_UNVERIFIED' && t.phase !== 'JOINED')
|
|
63
|
+
.map((t) => t.trackId).sort();
|
|
64
|
+
return { complete: tracks.length >= 2 && pendingTracks.length === 0, pendingTracks };
|
|
65
|
+
}
|
|
66
|
+
const RUNTIME_EFFECTS = new Set([
|
|
67
|
+
'create-worktree', 'create-track-journal', 'spawn-track-supervisor', 'begin-teardown', 'freeze-track', 'merge-track',
|
|
68
|
+
// R7 (Task 12): las 3 fronteras finales — únicas de la cohorte, jamás por
|
|
69
|
+
// track (ver drivers `runRequestGlobalQa`/`runRequestFinalIntegration`/
|
|
70
|
+
// `runRunFinalInterlock` más abajo).
|
|
71
|
+
'request-global-qa', 'request-final-integration', 'run-final-interlock',
|
|
72
|
+
]);
|
|
73
|
+
function toProtocol(state, maxParallel) {
|
|
74
|
+
const tracks = {};
|
|
75
|
+
for (const ref of state.tracks ?? []) {
|
|
76
|
+
tracks[ref.trackId] = {
|
|
77
|
+
trackId: ref.trackId,
|
|
78
|
+
phase: ref.phase,
|
|
79
|
+
fencingToken: ref.fencingToken,
|
|
80
|
+
readinessNonce: ref.readinessNonce,
|
|
81
|
+
frozenHeadSha: ref.frozenHeadSha,
|
|
82
|
+
expectedPlanHeadSha: ref.joinIntent?.expectedPlanHeadSha,
|
|
83
|
+
expectedTrackHeadSha: ref.joinIntent?.expectedTrackHeadSha,
|
|
84
|
+
joinedCommitSha: ref.joinedCommitSha,
|
|
85
|
+
blockedReason: ref.blockedReason,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
planJournalId: state.journalId,
|
|
90
|
+
cohortPhase: state.cohortPhase ?? 'PREPARING',
|
|
91
|
+
maxParallel,
|
|
92
|
+
tracks,
|
|
93
|
+
// R6.2/R6.3/C7 (Task 11): `cohortPlanHeadSha` es el HEAD real y
|
|
94
|
+
// AVANZANTE de la rama del plan tras cada join aceptado — antes del
|
|
95
|
+
// primer merge todavía no existe, y el HEAD real del plan en ese
|
|
96
|
+
// momento ES `cohortBaseSha` (nada mutó la rama todavía). Usar
|
|
97
|
+
// `cohortBaseSha` acá SIEMPRE (en vez de solo como fallback) sería el
|
|
98
|
+
// bug: congelaría `expectedPlanHeadSha` en el base original para
|
|
99
|
+
// TODOS los joins de la cohorte, rompiendo el segundo join en
|
|
100
|
+
// adelante (C7 exige que cada join sucesivo valide contra el HEAD
|
|
101
|
+
// real post-merge-anterior, no contra el punto de partida).
|
|
102
|
+
planHeadSha: state.cohortPlanHeadSha ?? state.cohortBaseSha,
|
|
103
|
+
// R7/C3 (Task 12): espejo de `state.globalQaHeadSha`/
|
|
104
|
+
// `finalIntegrationJobId` — sin esto, un restart perdería la
|
|
105
|
+
// evidencia de que el QA global o la integración final ya pasaron y
|
|
106
|
+
// `nextProtocolEffect` repetiría el efecto desde cero.
|
|
107
|
+
globalQaHeadSha: state.globalQaHeadSha,
|
|
108
|
+
finalIntegrationJobId: state.finalIntegrationJobId,
|
|
109
|
+
// Mismo criterio que los tres campos de arriba: `reconcileProtocol` fija
|
|
110
|
+
// `fallbackReason` sobre el protocolo, pero este loop lo reconstruye desde el
|
|
111
|
+
// journal en cada vuelta — sin releerlo acá, la causa específica de la
|
|
112
|
+
// degradación se pierde antes de que `enter-serial` llegue a usarla.
|
|
113
|
+
fallbackReason: state.cohortFallbackReason,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Vuelca las decisiones de `protocol.ts` de regreso al `TrackRef[]` real —
|
|
117
|
+
* nunca al revés: esta función jamás decide, solo transcribe. Exportada
|
|
118
|
+
* (junto con `persist`/`refOf`/`withRef`/`EffectRunResult` más abajo) para
|
|
119
|
+
* que `./teardown-driver` — que ejecuta el mismo patrón "gather -> decide ->
|
|
120
|
+
* tocar el mundo real como mucho una vez -> persistir -> stop" que todo
|
|
121
|
+
* `run*` de este archivo — reutilice estos helpers sin duplicarlos. */
|
|
122
|
+
function applyProtocolToState(state, protocol) {
|
|
123
|
+
const next = structuredClone(state);
|
|
124
|
+
next.cohortPhase = protocol.cohortPhase;
|
|
125
|
+
// R6.2/R6.3/C7 (Task 11): persistir el HEAD del plan avanzado por
|
|
126
|
+
// `reconcileProtocol`'s `accept-merge` (`out.planHeadSha = decision.
|
|
127
|
+
// joinedCommitSha`) — sin esto, el siguiente `toProtocol` volvería a leer
|
|
128
|
+
// `cohortBaseSha` (stale) y el segundo join de la cohorte usaría un
|
|
129
|
+
// `expectedPlanHeadSha` incorrecto.
|
|
130
|
+
if (protocol.planHeadSha !== undefined)
|
|
131
|
+
next.cohortPlanHeadSha = protocol.planHeadSha;
|
|
132
|
+
// R7/C3 (Task 12): mismo criterio que `cohortPlanHeadSha` arriba — valores
|
|
133
|
+
// que solo avanzan (nunca retroceden a `undefined` una vez fijados por
|
|
134
|
+
// `reconcileProtocol`).
|
|
135
|
+
if (protocol.globalQaHeadSha !== undefined)
|
|
136
|
+
next.globalQaHeadSha = protocol.globalQaHeadSha;
|
|
137
|
+
if (protocol.finalIntegrationJobId !== undefined)
|
|
138
|
+
next.finalIntegrationJobId = protocol.finalIntegrationJobId;
|
|
139
|
+
if (protocol.fallbackReason !== undefined)
|
|
140
|
+
next.cohortFallbackReason = protocol.fallbackReason;
|
|
141
|
+
next.tracks = (next.tracks ?? []).map((ref) => {
|
|
142
|
+
const t = protocol.tracks[ref.trackId];
|
|
143
|
+
if (t === undefined)
|
|
144
|
+
return ref;
|
|
145
|
+
return {
|
|
146
|
+
...ref,
|
|
147
|
+
phase: t.phase,
|
|
148
|
+
frozenHeadSha: t.frozenHeadSha,
|
|
149
|
+
blockedReason: t.blockedReason,
|
|
150
|
+
joinedCommitSha: t.joinedCommitSha,
|
|
151
|
+
joinIntent: t.expectedPlanHeadSha !== undefined && t.expectedTrackHeadSha !== undefined
|
|
152
|
+
? { expectedPlanHeadSha: t.expectedPlanHeadSha, expectedTrackHeadSha: t.expectedTrackHeadSha, strategy: types_1.JOIN_STRATEGY_NO_FF }
|
|
153
|
+
: ref.joinIntent,
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
function persist(planRoot, branch, s) {
|
|
159
|
+
(0, store_1.writeJournal)(planRoot, branch, s);
|
|
160
|
+
const r = (0, store_1.readJournal)(planRoot, branch);
|
|
161
|
+
if (r.corrupt || r.state === null)
|
|
162
|
+
throw new Error('journal corrupto tras persistir tracks (R1.6)');
|
|
163
|
+
return r.state;
|
|
164
|
+
}
|
|
165
|
+
function refOf(s, trackId) {
|
|
166
|
+
const ref = s.tracks?.find((t) => t.trackId === trackId);
|
|
167
|
+
if (ref === undefined)
|
|
168
|
+
throw new Error(`invariante rota: TrackRef ausente para ${trackId}`);
|
|
169
|
+
return ref;
|
|
170
|
+
}
|
|
171
|
+
function withRef(s, trackId, patch) {
|
|
172
|
+
return { ...s, tracks: (s.tracks ?? []).map((t) => (t.trackId === trackId ? { ...t, ...patch } : t)) };
|
|
173
|
+
}
|
|
174
|
+
function mustBaseSha(s) {
|
|
175
|
+
if (s.cohortBaseSha === undefined)
|
|
176
|
+
throw new Error('invariante rota: cohortBaseSha ausente para una cohorte en PREPARING/ACTIVE');
|
|
177
|
+
return s.cohortBaseSha;
|
|
178
|
+
}
|
|
179
|
+
function runCreateWorktree(planRoot, branch, s, protocol, effect, runtime) {
|
|
180
|
+
const trackId = effect.trackId;
|
|
181
|
+
const ref = refOf(s, trackId);
|
|
182
|
+
// Task 9 (R4.2/R4.6/C11): observaciones read-only del mundo real ANTES de
|
|
183
|
+
// tocar `runtime` — ninguna de las dos cuenta como el side effect del
|
|
184
|
+
// tick, igual que documentaba la versión anterior de este comentario.
|
|
185
|
+
// `ownedWorktreeExists` se consulta PRIMERO: un destino no vacío que ya
|
|
186
|
+
// está registrado por git en la branch determinista de este track solo
|
|
187
|
+
// puede ser NUESTRO, de un `addWorktree` que genuinamente corrió pero
|
|
188
|
+
// crasheó antes de que este mismo bloque persistiera `worktree-observed`
|
|
189
|
+
// — sin este chequeo, `foreignPathExists` (que no distingue "ajeno" de
|
|
190
|
+
// "nuestro intento interrumpido") bloquearía el track para siempre.
|
|
191
|
+
const observed = (0, git_1.ownedWorktreeExists)(planRoot, ref.worktreePath, ref.branch)
|
|
192
|
+
? { worktreeOwned: true }
|
|
193
|
+
: { worktreeForeignNonEmpty: (0, git_1.foreignPathExists)(ref.worktreePath) };
|
|
194
|
+
const decision = (0, protocol_1.decidePrepare)(protocol.tracks[trackId], observed);
|
|
195
|
+
if (decision === 'accept-worktree') {
|
|
196
|
+
const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'worktree-observed', trackId, owned: true });
|
|
197
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, applied));
|
|
198
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect', trackId, effect: effect.kind });
|
|
199
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
200
|
+
}
|
|
201
|
+
if (decision === 'block-foreign') {
|
|
202
|
+
const blocked = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'worktree-observed', trackId, owned: false });
|
|
203
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, blocked));
|
|
204
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-blocked', trackId, reason: 'worktree preexistente ajeno' });
|
|
205
|
+
// Post-review fix (Task 8): bloquear un track es en sí mismo UNA
|
|
206
|
+
// frontera — dejar `stop:false` acá permitía que el MISMO call
|
|
207
|
+
// siguiera y tocara `runtime.addWorktree` de otro track (dos side
|
|
208
|
+
// effects reales en un solo `reconcileTracks()`, rompiendo el
|
|
209
|
+
// supuesto de Task 9 de que cada boundary es crash-injectable por
|
|
210
|
+
// separado).
|
|
211
|
+
return { state: next, stop: true, executed: null };
|
|
212
|
+
}
|
|
213
|
+
// decision === 'retry-worktree': ni ajeno ni nuestro todavía — recién
|
|
214
|
+
// acá se toca `runtime` de verdad.
|
|
215
|
+
try {
|
|
216
|
+
runtime.addWorktree(planRoot, ref, mustBaseSha(s));
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
const failed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-failed', trackId, effect: effect.kind, detail: error.message });
|
|
220
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, failed));
|
|
221
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect-failed', trackId, effect: effect.kind, detail: error.message });
|
|
222
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
223
|
+
}
|
|
224
|
+
const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'worktree-observed', trackId, owned: true });
|
|
225
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, applied));
|
|
226
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect', trackId, effect: effect.kind });
|
|
227
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
228
|
+
}
|
|
229
|
+
function runCreateTrackJournal(planRoot, branch, s, protocol, effect, runtime) {
|
|
230
|
+
const trackId = effect.trackId;
|
|
231
|
+
const ref = refOf(s, trackId);
|
|
232
|
+
// GAP CONOCIDO Y DELIBERADAMENTE DIFERIDO (ver el comentario largo en
|
|
233
|
+
// `apply.ts` junto a `track-prepare-request`): `taskIds`/`planDigest`
|
|
234
|
+
// deberían nacer del plan .md parseado, pero NINGÚN task (1-8) provee un
|
|
235
|
+
// mecanismo para que el supervisor localice ese archivo — decisión
|
|
236
|
+
// humana pendiente sobre qué task lo resuelve. R2.1/R9.1: lo que SÍ es
|
|
237
|
+
// autoridad
|
|
238
|
+
// aquí es la identidad del track y el baseSha común de la cohorte.
|
|
239
|
+
const context = { trackId, taskIds: [], planDigest: '', baseSha: mustBaseSha(s), planJournalId: s.journalId };
|
|
240
|
+
// Task 9: a diferencia de `create-worktree`/`spawn-track-supervisor`,
|
|
241
|
+
// `decidePrepare` no tiene nada que desambiguar acá — `write-descriptor`
|
|
242
|
+
// es la única decisión posible en fase WORKTREE_CREATED, PORQUE
|
|
243
|
+
// `runtime.initTrackJournal` ya es idempotente por construcción
|
|
244
|
+
// (`initJournal` jamás pisa un `state.json` existente, `writeDescriptor`
|
|
245
|
+
// sobreescribe con el mismo contenido siempre): reintentar tras un crash
|
|
246
|
+
// en cualquier punto de esta llamada converge solo, sin necesitar
|
|
247
|
+
// distinguir "primera vez" de "reintento". Se llama de todos modos para
|
|
248
|
+
// que la autoridad de decisión sea siempre `protocol.ts`, nunca un
|
|
249
|
+
// "por qué no hace falta acá" implícito en `tracks.ts`.
|
|
250
|
+
if ((0, protocol_1.decidePrepare)(protocol.tracks[trackId], {}) !== 'write-descriptor') {
|
|
251
|
+
throw new Error(`invariante rota: decidePrepare esperaba 'write-descriptor' para ${trackId}`);
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
runtime.initTrackJournal(ref, context);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
const failed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-failed', trackId, effect: effect.kind, detail: error.message });
|
|
258
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, failed));
|
|
259
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect-failed', trackId, effect: effect.kind, detail: error.message });
|
|
260
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
261
|
+
}
|
|
262
|
+
const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-applied', effect });
|
|
263
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, applied));
|
|
264
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect', trackId, effect: effect.kind });
|
|
265
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
266
|
+
}
|
|
267
|
+
/** R5.2/R5.7/R5.8/R5.9/R6.3/R6.4/R6.5/C5 (Task 10): "freeze del track,
|
|
268
|
+
* quiescencia del plan y precondiciones de join". Deliberadamente NO usa
|
|
269
|
+
* `decidePrepare`/una fase intermedia `FREEZE_REQUESTED` persistida vía
|
|
270
|
+
* `effect-applied` — `nextProtocolEffect` solo re-emite `freeze-track`
|
|
271
|
+
* mientras `t.phase === 'JOIN_REQUESTED'` (ver `protocol.ts`), así que este
|
|
272
|
+
* driver progresa AL MISMO trackId en sucesivos ticks (como
|
|
273
|
+
* `runSpawnTrackSupervisor` progresa dentro de `SUPERVISOR_STARTING`) y
|
|
274
|
+
* recién transiciona la fase cuando emite `freeze-observation` (-> FROZEN),
|
|
275
|
+
* nunca antes.
|
|
276
|
+
*
|
|
277
|
+
* "El supervisor del plan solo acepta freeze cuando observa los seis hechos.
|
|
278
|
+
* No escribe directamente el journal del track" — este driver JAMÁS llama
|
|
279
|
+
* `writeJournal(ref.worktreePath, ...)`: solo LEE el journal propio del
|
|
280
|
+
* track (read-only, gathering nunca cuenta como el side effect del tick,
|
|
281
|
+
* mismo criterio que `foreignPathExists` en `runCreateWorktree`) y, si
|
|
282
|
+
* todavía no se pidió nada, escribe una `track-freeze-request` a su
|
|
283
|
+
* requestsDir vía `runtime.emitFreezeRequest` (el ÚNICO touch real de este
|
|
284
|
+
* efecto — el trabajo real de las 6 observaciones lo hace el propio
|
|
285
|
+
* `Supervisor.tick()` del track, en su journal, en su propio proceso). */
|
|
286
|
+
function runFreezeTrack(planRoot, branch, s, protocol, effect, runtime) {
|
|
287
|
+
const trackId = effect.trackId;
|
|
288
|
+
const ref = refOf(s, trackId);
|
|
289
|
+
const r = (0, store_1.readJournal)(ref.worktreePath, ref.branch);
|
|
290
|
+
if (r.corrupt || r.state === null) {
|
|
291
|
+
// Journal del track todavía no legible (crash entre `create-track-
|
|
292
|
+
// journal` y esto no debería alcanzar acá — la cohorte exige ARMED
|
|
293
|
+
// antes de ACTIVE — pero fail-closed: sin evidencia legible, nada se
|
|
294
|
+
// asume, se reintenta en el próximo tick).
|
|
295
|
+
return { state: s, stop: true, executed: null };
|
|
296
|
+
}
|
|
297
|
+
const trackState = r.state;
|
|
298
|
+
if (trackState.frozen !== undefined) {
|
|
299
|
+
// El track ya completó, DURABLEMENTE en SU journal, los 6 hechos
|
|
300
|
+
// internos (drenado, gate local verde, worktree limpio, generación
|
|
301
|
+
// propia terminada) — el plan re-verifica acá lo barato e
|
|
302
|
+
// independientemente comprobable ANTES de aceptar (nunca confía
|
|
303
|
+
// ciegamente en el autoreporte del track para lo que puede probar
|
|
304
|
+
// por sí mismo): supervisor realmente caído + lock realmente
|
|
305
|
+
// liberado. Gate/limpieza/drenado NO se re-verifican acá — son caros
|
|
306
|
+
// de recomputar desde afuera (requieren el propio contexto de
|
|
307
|
+
// fingerprint del track) y el propio track ya los probó bajo su lock
|
|
308
|
+
// antes de persistir `frozen`.
|
|
309
|
+
const supervisorAlive = ref.supervisorProcessRef !== undefined && (0, process_1.refIsAlive)(ref.supervisorProcessRef);
|
|
310
|
+
const lockExists = fs_1.default.existsSync((0, paths_1.supervisorLockPath)(ref.worktreePath));
|
|
311
|
+
if (supervisorAlive || lockExists) {
|
|
312
|
+
// Paso 6 ("libera el lock y sale") todavía no confirmado desde
|
|
313
|
+
// afuera: esperar al próximo tick (fail-closed, R6.4).
|
|
314
|
+
return { state: s, stop: true, executed: null };
|
|
315
|
+
}
|
|
316
|
+
const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, {
|
|
317
|
+
kind: 'freeze-observation', trackId, frozenHeadSha: trackState.frozen.headSha,
|
|
318
|
+
});
|
|
319
|
+
let next = applyProtocolToState(s, observed);
|
|
320
|
+
// R5.7/R5.8/R5.9/C5 (Step 6): ownership REAL post-hoc, desde commits
|
|
321
|
+
// YA congelados — nunca `git status` para esto (esa es la
|
|
322
|
+
// herramienta de limpieza, no de ownership). `ref.ownership` es el
|
|
323
|
+
// mismo campo que el plan ya lleva por track (hoy `[]` por el gap
|
|
324
|
+
// conocido y deliberadamente diferido de `apply.ts` — parsear el .md
|
|
325
|
+
// del plan sigue sin convención de descubrimiento; una vez que algo
|
|
326
|
+
// lo popule, este consumidor funciona sin cambios).
|
|
327
|
+
const parsedTrack = { trackId, taskIds: [], ownership: ref.ownership, dependsOn: [], sharedResources: [] };
|
|
328
|
+
let actual = { outsideOwnership: [], globalClasses: [] };
|
|
329
|
+
if (s.cohortBaseSha !== undefined) {
|
|
330
|
+
try {
|
|
331
|
+
const base = (0, git_1.mergeBase)(planRoot, s.cohortBaseSha, trackState.frozen.headSha);
|
|
332
|
+
const changes = (0, git_1.changedPaths)(planRoot, base, trackState.frozen.headSha);
|
|
333
|
+
actual = (0, ownership_1.assessActualOwnership)(parsedTrack, changes);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// git indemostrable: no se afirma nada sobre ownership — ni
|
|
337
|
+
// se bloquea el freeze por esto (Step 6 nunca revierte
|
|
338
|
+
// merges ya hechos ni el propio freeze), simplemente no hay
|
|
339
|
+
// evidencia nueva que registrar este tick.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (actual.globalClasses.length > 0) {
|
|
343
|
+
const marks = actual.globalClasses.map((cls) => `${trackId}:${cls}`);
|
|
344
|
+
next = {
|
|
345
|
+
...next,
|
|
346
|
+
cohortParallelInvalidatedBy: [...new Set([...(next.cohortParallelInvalidatedBy ?? []), ...marks])],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const persisted = persist(planRoot, branch, next);
|
|
350
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-frozen-observed', trackId, frozenHeadSha: trackState.frozen.headSha });
|
|
351
|
+
if (actual.outsideOwnership.length > 0) {
|
|
352
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-ownership-violation', trackId, paths: actual.outsideOwnership });
|
|
353
|
+
}
|
|
354
|
+
if (actual.globalClasses.length > 0) {
|
|
355
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'parallel-invalidated', trackId, classes: actual.globalClasses });
|
|
356
|
+
}
|
|
357
|
+
return { state: persisted, stop: true, executed: effect.kind };
|
|
358
|
+
}
|
|
359
|
+
if (trackState.freezeRequested === true) {
|
|
360
|
+
// Ya se pidió: el propio track todavía está drenando/verificando —
|
|
361
|
+
// nada nuevo que tocar este tick.
|
|
362
|
+
return { state: s, stop: true, executed: null };
|
|
363
|
+
}
|
|
364
|
+
// Todavía no se pidió nada (según el journal del track): antes de tocar
|
|
365
|
+
// `runtime`, un chequeo read-only barato — ¿ya hay una
|
|
366
|
+
// `track-freeze-request` sin consumir en su requestsDir? (misma
|
|
367
|
+
// convención que `ownedWorktreeExists`/`foreignPathExists` en
|
|
368
|
+
// `runCreateWorktree`: gathering nunca cuenta como el side effect).
|
|
369
|
+
// Sin esto, cada tick del plan mientras el track todavía no consumió
|
|
370
|
+
// re-emitiría una request nueva — inofensivo (idempotente en `apply.ts`)
|
|
371
|
+
// pero innecesario.
|
|
372
|
+
let alreadyPending = false;
|
|
373
|
+
try {
|
|
374
|
+
alreadyPending = (0, requests_1.listPendingRequests)(ref.worktreePath, ref.branch).some((p) => !p.corrupt && p.envelope.kind === 'track-freeze-request');
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
alreadyPending = false; // requestsDir todavía no listable: nada pendiente que se pueda probar
|
|
378
|
+
}
|
|
379
|
+
if (alreadyPending)
|
|
380
|
+
return { state: s, stop: true, executed: null };
|
|
381
|
+
// Emitir la request al journal PROPIO del track — único touch real de
|
|
382
|
+
// este efecto. El `generationToken` viaja con la generación activa QUE
|
|
383
|
+
// EL TRACK TIENE HOY; si no tiene ninguna, un sentinel no-vacío
|
|
384
|
+
// (`isWellFormedEnvelope` exige `generationToken` no-vacío para CUALQUIER
|
|
385
|
+
// request — R1.6, forma antes que contenido) que igual pasa el chequeo de
|
|
386
|
+
// `consumePendingRequests`, porque ese chequeo solo compara contra
|
|
387
|
+
// `activeToken` cuando `activeToken !== null`: sin generación activa,
|
|
388
|
+
// cualquier token (sentinel incluido) se acepta igual. Un desfasaje entre
|
|
389
|
+
// esta lectura y el consumo real produce, a lo sumo, un
|
|
390
|
+
// `rejected-stale-generation` inofensivo — nunca corrompe nada, y el
|
|
391
|
+
// próximo tick reintenta con una lectura fresca.
|
|
392
|
+
const activeGen = trackState.generations.find((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
|
|
393
|
+
runtime.emitFreezeRequest(ref, activeGen?.token ?? 'no-active-generation');
|
|
394
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-freeze-requested', trackId });
|
|
395
|
+
return { state: s, stop: true, executed: effect.kind };
|
|
396
|
+
}
|
|
397
|
+
/** R6.2/R6.6-R6.9/C7 (Task 11): gathering READ-ONLY del estado real de un
|
|
398
|
+
* intento de join — jamás cuenta como el side effect del tick (mismo
|
|
399
|
+
* criterio que `ownedWorktreeExists`/`foreignPathExists` en
|
|
400
|
+
* `runCreateWorktree`). `trackIsAncestor` solo se calcula cuando no hay
|
|
401
|
+
* `MERGE_HEAD` (si lo hay, la pregunta "¿ya se mergeó?" no aplica todavía —
|
|
402
|
+
* `decideJoinReconciliation` ni siquiera la consulta en ese caso). */
|
|
403
|
+
function gatherJoinObservation(planRoot, expectedTrackHeadSha) {
|
|
404
|
+
const mergeHead = (0, git_1.readMergeHead)(planRoot);
|
|
405
|
+
const planHead = (0, git_1.headSha)(planRoot);
|
|
406
|
+
const trackIsAncestor = mergeHead === null ? (0, git_1.isAncestor)(planRoot, expectedTrackHeadSha, planHead) : false;
|
|
407
|
+
return { mergeHead, planHead, trackIsAncestor };
|
|
408
|
+
}
|
|
409
|
+
/** R6.2/R6.3/R6.6-R6.9/C7 (Task 11): a lo sumo UN touch REAL de `merge-track`
|
|
410
|
+
* por invocación. Autoridad de DECISIÓN sigue siendo `decideJoinReconciliation`
|
|
411
|
+
* (`protocol.ts`, reexportada por `join.ts`) — este driver la llama dos
|
|
412
|
+
* veces con el MISMO criterio que `runCreateWorktree` llama `decidePrepare`
|
|
413
|
+
* y luego, por separado, `observeProtocolEffect`: una vez sobre la
|
|
414
|
+
* observación `before` para decidir CUÁL efecto git ejecutar (a lo sumo
|
|
415
|
+
* UNO: `mergeFrozenTrack` o `abortOwnedMerge`), y la fase final se persiste
|
|
416
|
+
* siempre a través de `observeProtocolEffect` (que la recalcula sobre la
|
|
417
|
+
* observación real, nunca confía en la excepción de la llamada a
|
|
418
|
+
* `runtime` por sí sola — un `git merge` que tira no prueba ni éxito ni
|
|
419
|
+
* fallo, la única fuente de verdad es releer el repo).
|
|
420
|
+
*
|
|
421
|
+
* Post-review fix (Finding 1, revisión de Task 11): `ensureIntegrationLock`
|
|
422
|
+
* y el intento de merge/abort SON DOS fronteras `reconcileTracks()`
|
|
423
|
+
* distintas, no una — mismo criterio que `runSpawnTrackSupervisor` separa
|
|
424
|
+
* "persistir el supervisorIntent" de "spawnear/observar" (R1.8/C11). En el
|
|
425
|
+
* PRIMER tick de un proceso vivo, `ensureIntegrationLock` hace trabajo real
|
|
426
|
+
* (pausa la generación del plan + escribe `integration.lock`, dos
|
|
427
|
+
* mutaciones genuinas) — devuelve `'acquired'` y este driver corta ACÁ,
|
|
428
|
+
* `stop: true`, sin llegar siquiera a leer `gatherJoinObservation` ni a
|
|
429
|
+
* decidir `retry-merge`/`abort-own-merge`. Recién el PRÓXIMO
|
|
430
|
+
* `reconcileTracks()`, con el lock ya confirmado held (`'already-held'`,
|
|
431
|
+
* no-op memoizado en el mismo proceso), este driver continúa y intenta el
|
|
432
|
+
* merge/abort. Tras un crash real entre ambas fronteras, un proceso nuevo
|
|
433
|
+
* repite el mismo `'acquired'` (reclamando identidad muerta, misma lógica
|
|
434
|
+
* que `acquireIntegrationLock` ya prueba) y vuelve a cortar ahí — nunca
|
|
435
|
+
* colapsa las dos mutaciones en un solo call. La liberación del lock sigue
|
|
436
|
+
* siendo responsabilidad de Task 12 (después del interlock final) — este
|
|
437
|
+
* driver JAMÁS libera lo que adquiere acá. */
|
|
438
|
+
async function runMergeTrack(planRoot, branch, s, protocol, effect, runtime) {
|
|
439
|
+
const trackId = effect.trackId;
|
|
440
|
+
const intent = {
|
|
441
|
+
expectedPlanHeadSha: effect.expectedPlanHeadSha,
|
|
442
|
+
expectedTrackHeadSha: effect.expectedTrackHeadSha,
|
|
443
|
+
strategy: types_1.JOIN_STRATEGY_NO_FF,
|
|
444
|
+
};
|
|
445
|
+
// C7: antes de la PRIMERA mutación real de la rama del plan — pausa la
|
|
446
|
+
// generación del plan y adquiere `integration.lock` (primitivos de Task
|
|
447
|
+
// 10, sin ningún caller hasta acá). Si esta llamada tuvo que hacer
|
|
448
|
+
// trabajo real (`'acquired'`), ESE es el único touch de este tick: se
|
|
449
|
+
// corta acá (ver el comentario grande arriba) — el intento de merge
|
|
450
|
+
// queda para el próximo `reconcileTracks()`, una vez el lock ya esté
|
|
451
|
+
// `'already-held'` (no-op memoizado mientras el mismo proceso siga vivo).
|
|
452
|
+
const lockState = await runtime.ensureIntegrationLock(s.journalId, intent.expectedPlanHeadSha);
|
|
453
|
+
if (lockState === 'acquired') {
|
|
454
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-integration-lock-acquired', trackId });
|
|
455
|
+
return { state: s, stop: true, executed: effect.kind };
|
|
456
|
+
}
|
|
457
|
+
const before = gatherJoinObservation(planRoot, intent.expectedTrackHeadSha);
|
|
458
|
+
const decision = (0, protocol_1.decideJoinReconciliation)(intent, before);
|
|
459
|
+
if (decision.action === 'retry-merge') {
|
|
460
|
+
let detail;
|
|
461
|
+
try {
|
|
462
|
+
runtime.mergeFrozenTrack(planRoot, intent);
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
// Conflicto real o HEAD del plan movido bajo nuestros pies: NUNCA
|
|
466
|
+
// se asume cuál de los dos fue por la excepción sola — la
|
|
467
|
+
// relectura de abajo es la única fuente de verdad (R6.8). El
|
|
468
|
+
// mensaje SÍ se conserva como `detail` diagnóstico en el evento
|
|
469
|
+
// (Finding 2) — nunca para decidir, solo para que un operador
|
|
470
|
+
// mirando el log pueda distinguir un conflicto benigno (que
|
|
471
|
+
// resuelve solo en el próximo retry) de un repo estructuralmente
|
|
472
|
+
// roto (permisos, disco lleno, git roto) que retrearía en
|
|
473
|
+
// silencio para siempre sin esto.
|
|
474
|
+
detail = error.message;
|
|
475
|
+
}
|
|
476
|
+
const after = gatherJoinObservation(planRoot, intent.expectedTrackHeadSha);
|
|
477
|
+
const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'join-observation', trackId, ...after });
|
|
478
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, observed));
|
|
479
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-merge-attempted', trackId, detail });
|
|
480
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
481
|
+
}
|
|
482
|
+
if (decision.action === 'abort-own-merge') {
|
|
483
|
+
let detail;
|
|
484
|
+
try {
|
|
485
|
+
runtime.abortOwnedMerge(planRoot, intent);
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
// Igual criterio que arriba: la relectura decide, nunca la
|
|
489
|
+
// excepción (ej. una carrera real donde el MERGE_HEAD dejó de
|
|
490
|
+
// ser nuestro entre `before` y este intento de abort) — el
|
|
491
|
+
// mensaje se conserva solo como `detail` diagnóstico (Finding 2).
|
|
492
|
+
detail = error.message;
|
|
493
|
+
}
|
|
494
|
+
const after = gatherJoinObservation(planRoot, intent.expectedTrackHeadSha);
|
|
495
|
+
const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'join-observation', trackId, ...after });
|
|
496
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, observed));
|
|
497
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-merge-aborted', trackId, detail });
|
|
498
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
499
|
+
}
|
|
500
|
+
// 'accept-merge' / 'block': la observación YA leída (`before`) alcanza —
|
|
501
|
+
// ningún efecto git nuevo que ejecutar este tick (mismo criterio que
|
|
502
|
+
// `runCreateWorktree`'s ramas 'accept-worktree'/'block-foreign').
|
|
503
|
+
const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'join-observation', trackId, ...before });
|
|
504
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, observed));
|
|
505
|
+
(0, store_1.appendEvent)(planRoot, branch, {
|
|
506
|
+
kind: decision.action === 'accept-merge' ? 'track-merged' : 'track-blocked',
|
|
507
|
+
trackId, ...(decision.action === 'block' ? { reason: decision.reason } : {}),
|
|
508
|
+
});
|
|
509
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
510
|
+
}
|
|
511
|
+
function sameIdSet(a, b) {
|
|
512
|
+
const left = [...(a ?? [])].sort();
|
|
513
|
+
const right = [...b].sort();
|
|
514
|
+
return left.length === right.length && left.every((v, i) => v === right[i]);
|
|
515
|
+
}
|
|
516
|
+
/** R7.1/R7.6/R7.7/C3 (Task 12): pura mutación de estado — jamás toca
|
|
517
|
+
* `runtime`. "Solo el HEAD final recibe QA e integración": `nextProtocolEffect`
|
|
518
|
+
* solo emite este efecto cuando TODOS los tracks llegaron a
|
|
519
|
+
* `MERGED_UNVERIFIED` a la vez (R7.6/R7.7) — nunca por cada merge
|
|
520
|
+
* individual. El primer branch acepta el autoreporte del controller
|
|
521
|
+
* (`qaFinalizeRequested`, ver `track-finalize-request` en `apply.ts`) SOLO
|
|
522
|
+
* tras re-verificar independientemente HEAD real + árbol limpio (fail-
|
|
523
|
+
* closed, mismo criterio que el freeze de un track en `runFreezeTrack` —
|
|
524
|
+
* nunca se confía ciegamente en el autoreporte); el segundo persiste el
|
|
525
|
+
* `nextAction` que instruye al controller a correr `post-implementation-qa`
|
|
526
|
+
* — el controller YA vivo (o el próximo que `Supervisor.ensureController`
|
|
527
|
+
* relance por su propio mecanismo existente) lo recoge leyendo el journal,
|
|
528
|
+
* sin que este driver necesite tocar ningún proceso. */
|
|
529
|
+
function runRequestGlobalQa(planRoot, branch, s, protocol, effect) {
|
|
530
|
+
if (s.qaFinalizeRequested !== undefined) {
|
|
531
|
+
const reported = s.qaFinalizeRequested;
|
|
532
|
+
let actualHead;
|
|
533
|
+
let clean;
|
|
534
|
+
try {
|
|
535
|
+
actualHead = (0, git_1.headSha)(planRoot);
|
|
536
|
+
clean = (0, git_1.dirtyPaths)(planRoot).length === 0;
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return { state: s, stop: true, executed: null }; // indemostrable: esperar (fail-closed)
|
|
540
|
+
}
|
|
541
|
+
if (actualHead === reported.headSha && clean) {
|
|
542
|
+
const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'global-qa-pass', headSha: actualHead, clean: true });
|
|
543
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, observed));
|
|
544
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'global-qa-passed', headSha: actualHead });
|
|
545
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
546
|
+
}
|
|
547
|
+
// El autoreporte no coincide (todavía sucio, o HEAD distinto): nada
|
|
548
|
+
// que aceptar este tick — el controller sigue corrigiendo hallazgos.
|
|
549
|
+
return { state: s, stop: true, executed: null };
|
|
550
|
+
}
|
|
551
|
+
if (s.cycle.nextAction?.type === 'run-global-qa') {
|
|
552
|
+
return { state: s, stop: true, executed: null }; // ya pedido: esperar el autoreporte
|
|
553
|
+
}
|
|
554
|
+
const next = {
|
|
555
|
+
...s,
|
|
556
|
+
cycle: {
|
|
557
|
+
...s.cycle,
|
|
558
|
+
nextAction: { actionId: 'finalize-global-qa', type: 'run-global-qa', target: 'cycle', preconditions: [], attempt: 0, state: 'pending' },
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
const persisted = persist(planRoot, branch, next);
|
|
562
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'global-qa-requested' });
|
|
563
|
+
return { state: persisted, stop: true, executed: effect.kind };
|
|
564
|
+
}
|
|
565
|
+
/** R7.3/R7.6/C4 (Task 12): a lo sumo UNA mutación real por tick — mismo
|
|
566
|
+
* criterio que `runMergeTrack` separa `ensureIntegrationLock` del intento de
|
|
567
|
+
* merge en dos fronteras distintas: acá la pausa del controller
|
|
568
|
+
* (`pauseControllerGeneration`, que a diferencia de `ensureIntegrationLock`
|
|
569
|
+
* se llama de nuevo cada vez porque el controller puede haberse relanzado
|
|
570
|
+
* desde el último merge) y el pedido del job canónico son también DOS
|
|
571
|
+
* fronteras separadas. El conjunto de satisfiers es SIEMPRE el completo y
|
|
572
|
+
* ordenado de `track-integration:*` de la cohorte (C4) — nunca un
|
|
573
|
+
* subconjunto, ni siquiera tras un crash/restart, porque `ids` es una
|
|
574
|
+
* función determinista de `s.tracks` y la idempotencyKey de `requestJob` es
|
|
575
|
+
* una función determinista de ese mismo conjunto. */
|
|
576
|
+
async function runRequestFinalIntegration(planRoot, branch, s, protocol, effect, runtime) {
|
|
577
|
+
const ids = [...new Set((s.tracks ?? []).map((t) => `track-integration:${t.trackId}`))].sort();
|
|
578
|
+
const existing = Object.values(s.jobs).find((j) => sameIdSet(j.satisfies, ids));
|
|
579
|
+
if (existing !== undefined) {
|
|
580
|
+
if (existing.executionState === 'exited' && existing.verdict === 'pass') {
|
|
581
|
+
let headNow;
|
|
582
|
+
try {
|
|
583
|
+
headNow = (0, git_1.headSha)(planRoot);
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return { state: s, stop: true, executed: null };
|
|
587
|
+
}
|
|
588
|
+
const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'integration-pass', jobId: existing.id, headSha: headNow });
|
|
589
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, observed));
|
|
590
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-integration-passed', jobId: existing.id });
|
|
591
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
592
|
+
}
|
|
593
|
+
// Sigue vivo, o terminó sin pass: nada que tocar este tick (fail-
|
|
594
|
+
// closed — un job canónico fallido requiere intervención humana, este
|
|
595
|
+
// driver jamás reintenta a ciegas con un job distinto).
|
|
596
|
+
return { state: s, stop: true, executed: null };
|
|
597
|
+
}
|
|
598
|
+
const pausedNow = await runtime.pauseControllerGeneration();
|
|
599
|
+
if (pausedNow) {
|
|
600
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'plan-generation-paused-for-integration' });
|
|
601
|
+
return { state: s, stop: true, executed: effect.kind };
|
|
602
|
+
}
|
|
603
|
+
if (s.trackIntegration === undefined) {
|
|
604
|
+
// Contrato canónico todavía no registrado (Step 6, `register --entity
|
|
605
|
+
// track-integration`): indemostrable qué comando correr — fail-closed,
|
|
606
|
+
// esperar a que se registre en vez de inventar un comando.
|
|
607
|
+
return { state: s, stop: true, executed: null };
|
|
608
|
+
}
|
|
609
|
+
const gen = s.generations.find((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
|
|
610
|
+
try {
|
|
611
|
+
(0, request_1.requestJob)(planRoot, branch, gen?.token ?? 'no-active-generation', s.trackIntegration.argv, s.trackIntegration.paths, '.', {
|
|
612
|
+
satisfies: ids, verificationKind: 'track-integration',
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
// Precondición del Step 6 todavía no demostrable (merges pendientes,
|
|
617
|
+
// árbol sucio, argv no canónico): nada que tocar, reintentar el
|
|
618
|
+
// próximo tick con estado fresco.
|
|
619
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-integration-request-failed', detail: error.message });
|
|
620
|
+
return { state: s, stop: true, executed: null };
|
|
621
|
+
}
|
|
622
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-integration-requested', ids });
|
|
623
|
+
return { state: s, stop: true, executed: effect.kind };
|
|
624
|
+
}
|
|
625
|
+
/** R7.4/R7.5/C3 (Task 12): NO reimplementa el interlock — llama a
|
|
626
|
+
* `computeGate`, el mismo mecanismo de R1/R3.2 que ya exige `qa`+`interlock`
|
|
627
|
+
* en `cycleVerificationPlan` (y ahora también cada `track-integration:*`,
|
|
628
|
+
* poblados por `registerTrackIntegrationItems`) y recomputa la vigencia de
|
|
629
|
+
* CADA fingerprint contra el árbol real — "el interlock global existente"
|
|
630
|
+
* al que se refiere el plan. Solo al pasar libera `integration.lock`
|
|
631
|
+
* (R7.5), el complemento que Task 11 dejó explícitamente diferido. */
|
|
632
|
+
function runRunFinalInterlock(planRoot, branch, s, protocol, effect, runtime) {
|
|
633
|
+
const fingerprintNow = (argv, paths, cwd) => {
|
|
634
|
+
try {
|
|
635
|
+
return (0, fingerprint_1.computeFingerprint)(planRoot, argv, paths, cwd).fingerprint;
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
const gate = (0, gate_1.computeGate)(s, false, fingerprintNow);
|
|
642
|
+
if (!gate.pass)
|
|
643
|
+
return { state: s, stop: true, executed: null }; // fail-closed: esperar evidencia completa
|
|
644
|
+
if (protocol.globalQaHeadSha === undefined)
|
|
645
|
+
throw new Error('invariante rota: globalQaHeadSha ausente en FINAL_INTERLOCK');
|
|
646
|
+
const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'interlock-pass', headSha: protocol.globalQaHeadSha });
|
|
647
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, observed));
|
|
648
|
+
runtime.releaseIntegrationLockIfHeld();
|
|
649
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'cohort-complete' });
|
|
650
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
651
|
+
}
|
|
652
|
+
/** cliEntry: mismo patrón que `defaultWrapperSpawner` en runner.ts — desde
|
|
653
|
+
* `dist/src/commands/watch/tracks.js`, `../../` resuelve a `dist/src/index.js`. */
|
|
654
|
+
function defaultCliEntry() {
|
|
655
|
+
return path_1.default.resolve(__dirname, '..', '..', 'index.js');
|
|
656
|
+
}
|
|
657
|
+
function runSpawnTrackSupervisor(planRoot, branch, s, protocol, effect, runtime) {
|
|
658
|
+
const trackId = effect.trackId;
|
|
659
|
+
const ref = refOf(s, trackId);
|
|
660
|
+
if (ref.supervisorIntent === undefined) {
|
|
661
|
+
// R1.8/C11: el intent (nonce + argv + claimPath) se persiste ANTES de
|
|
662
|
+
// spawnear nada — si el supervisor del plan muere justo acá, el
|
|
663
|
+
// próximo tick reintenta el MISMO intent, nunca uno nuevo.
|
|
664
|
+
const supervisorNonce = crypto_1.default.randomBytes(16).toString('hex');
|
|
665
|
+
const intent = {
|
|
666
|
+
nonce: supervisorNonce,
|
|
667
|
+
// R4.7/C11: `--nonce` viaja explícito en el argv — es el MISMO
|
|
668
|
+
// valor que `observeSupervisorFromDisk` va a exigir de vuelta en
|
|
669
|
+
// el identity sidecar del wrapper (BLOCKER post-review: el
|
|
670
|
+
// wrapper NUNCA debe generar el suyo propio, o jamás matchea).
|
|
671
|
+
argv: [process.execPath, defaultCliEntry(), 'track', 'supervisor-wrapper', '--track', ref.trackId,
|
|
672
|
+
'--readiness', ref.readinessNonce, '--fence', ref.fencingToken, '--nonce', supervisorNonce],
|
|
673
|
+
claimPath: path_1.default.join(ref.worktreePath, '.awm', 'supervisor.claim'),
|
|
674
|
+
};
|
|
675
|
+
const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-applied', effect });
|
|
676
|
+
const next = persist(planRoot, branch, withRef(applyProtocolToState(s, applied), trackId, { supervisorIntent: intent }));
|
|
677
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-supervisor-intent', trackId });
|
|
678
|
+
// Post-review fix: persistir el supervisorIntent es en sí mismo UNA
|
|
679
|
+
// frontera durable (R1.8/C11) — con `stop:false` el MISMO call podía
|
|
680
|
+
// seguir de largo y llamar a `runtime.observeSupervisor`/
|
|
681
|
+
// `spawnSupervisor` en la misma invocación, colapsando dos
|
|
682
|
+
// boundaries ("intent persistido" y "supervisor consultado/spawneado")
|
|
683
|
+
// que Task 9 necesita poder crashear por separado.
|
|
684
|
+
return { state: next, stop: true, executed: null };
|
|
685
|
+
}
|
|
686
|
+
// Task 9 (R4.2/C11, reemplaza el gate anterior basado ÚNICAMENTE en
|
|
687
|
+
// `ref.supervisorProcessRef === undefined`): SIEMPRE se observa el mundo
|
|
688
|
+
// real primero — read-only, jamás cuenta como el side effect del tick,
|
|
689
|
+
// mismo criterio que `foreignPathExists`/`ownedWorktreeExists` en
|
|
690
|
+
// `runCreateWorktree` — y es `decidePrepare` (protocol.ts) quien decide
|
|
691
|
+
// qué hacer con lo observado. Esto es una optimización real, no la
|
|
692
|
+
// garantía de correctitud: en el caso común (crash después de que el
|
|
693
|
+
// wrapper ya escribió claim/identity/ready en disco) evita un
|
|
694
|
+
// `spawnSupervisor` redundante. Pero sigue existiendo una ventana
|
|
695
|
+
// angosta entre que `runtime.spawnSupervisor` forkea el proceso real y
|
|
696
|
+
// que ESE proceso escribe su claim (`supervisor-wrapper.ts`, `fs.openSync`
|
|
697
|
+
// con `wx`) — un crash justo ahí deja `observeSupervisor` en `'absent'`
|
|
698
|
+
// y `decidePrepare` vuelve a pedir `retry-supervisor-same-intent`, o sea
|
|
699
|
+
// un segundo fork real. Lo que impide que eso deje dos supervisores
|
|
700
|
+
// vivos es el propio claim atómico `wx` del wrapper (mismo patrón que
|
|
701
|
+
// `job/exec-wrapper.ts`/`reconcile.ts`): el wrapper perdedor recibe
|
|
702
|
+
// `EEXIST` en su propio intento de claim y sale como "already-claimed"
|
|
703
|
+
// antes de escribir identity/ready o lanzar `awm watch` — inofensivo,
|
|
704
|
+
// no prevenido acá.
|
|
705
|
+
const observation = runtime.observeSupervisor(ref);
|
|
706
|
+
const decision = (0, protocol_1.decidePrepare)(protocol.tracks[trackId], { supervisorArtifact: observation.kind });
|
|
707
|
+
if (decision === 'block-foreign') {
|
|
708
|
+
const blocked = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'supervisor-observed', trackId, identity: 'other' });
|
|
709
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, blocked));
|
|
710
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-blocked', trackId, reason: 'identidad de supervisor ajena' });
|
|
711
|
+
return { state: next, stop: true, executed: null };
|
|
712
|
+
}
|
|
713
|
+
if (decision === 'retry-supervisor-same-intent') {
|
|
714
|
+
if (ref.supervisorProcessRef === undefined) {
|
|
715
|
+
// Observación confirma 'absent': recién ACÁ se toca `runtime` de
|
|
716
|
+
// verdad. Leer (no-mutante) y, si de veras no hay nada, spawnear
|
|
717
|
+
// (mutante) en el MISMO call sigue siendo UNA sola frontera
|
|
718
|
+
// mutante por invocación — igual patrón que
|
|
719
|
+
// `foreignPathExists` -> `addWorktree` en `runCreateWorktree`.
|
|
720
|
+
const pr = runtime.spawnSupervisor(ref);
|
|
721
|
+
const next = pr !== undefined ? persist(planRoot, branch, withRef(s, trackId, { supervisorProcessRef: pr })) : s;
|
|
722
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-effect', trackId, effect: effect.kind });
|
|
723
|
+
return { state: next, stop: true, executed: effect.kind };
|
|
724
|
+
}
|
|
725
|
+
// `supervisorProcessRef` ya persistido (spawn de un tick anterior)
|
|
726
|
+
// pero el wrapper todavía no escribió ni su claim: ventana de
|
|
727
|
+
// arranque normal, nada que hacer todavía.
|
|
728
|
+
return { state: s, stop: true, executed: null };
|
|
729
|
+
}
|
|
730
|
+
// 'accept-readiness': ya hay evidencia real en disco de un intento
|
|
731
|
+
// previo (posiblemente de una instancia que crasheó antes de persistir
|
|
732
|
+
// `supervisorProcessRef`) — NUNCA volver a llamar `spawnSupervisor`
|
|
733
|
+
// (C11). C8 decide acá mismo si el nonce observado corresponde.
|
|
734
|
+
if (observation.kind === 'claimed') {
|
|
735
|
+
// Claim tomado pero identidad/readiness todavía no observables:
|
|
736
|
+
// esperar al próximo tick, nada que persistir.
|
|
737
|
+
return { state: s, stop: true, executed: null };
|
|
738
|
+
}
|
|
739
|
+
if (observation.kind === 'ready') {
|
|
740
|
+
const observed = (0, protocol_1.observeProtocolEffect)(protocol, effect, {
|
|
741
|
+
kind: 'supervisor-observed', trackId, identity: 'expected', readinessNonce: observation.readinessNonce,
|
|
742
|
+
});
|
|
743
|
+
const next = persist(planRoot, branch, applyProtocolToState(s, observed));
|
|
744
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-armed-or-blocked', trackId });
|
|
745
|
+
return { state: next, stop: true, executed: null };
|
|
746
|
+
}
|
|
747
|
+
// Defensivo: `decidePrepare`/`observeSupervisor` ya cubrieron
|
|
748
|
+
// exhaustivamente absent/foreign/claimed/ready arriba (fail-closed).
|
|
749
|
+
throw new Error(`invariante rota: observación de supervisor no manejada para ${trackId}`);
|
|
750
|
+
}
|
|
751
|
+
// Task 13 (R4.2/R4.3/R4.6/R4.10/C2/C9): el gathering READ-ONLY
|
|
752
|
+
// (`gatherTeardownObservation`) y el driver (`runBeginTeardown`) de UN track
|
|
753
|
+
// en teardown viven en `./teardown-driver` — extraídos post-review (mismo
|
|
754
|
+
// criterio que `join.ts` con `decideJoinReconciliation`/
|
|
755
|
+
// `acquireIntegrationLock`: la lógica del concern vive en su propio módulo,
|
|
756
|
+
// este archivo solo orquesta y wirea producción). `decideTeardown` sigue
|
|
757
|
+
// siendo la única autoridad de decisión, en `protocol.ts`, sin tocar.
|
|
758
|
+
async function executeRuntimeEffect(planRoot, branch, s, protocol, effect, runtime) {
|
|
759
|
+
if (effect.kind === 'create-worktree')
|
|
760
|
+
return runCreateWorktree(planRoot, branch, s, protocol, effect, runtime);
|
|
761
|
+
if (effect.kind === 'create-track-journal')
|
|
762
|
+
return runCreateTrackJournal(planRoot, branch, s, protocol, effect, runtime);
|
|
763
|
+
if (effect.kind === 'begin-teardown')
|
|
764
|
+
return (0, teardown_driver_1.runBeginTeardown)(planRoot, branch, s, protocol, effect, runtime);
|
|
765
|
+
if (effect.kind === 'freeze-track')
|
|
766
|
+
return runFreezeTrack(planRoot, branch, s, protocol, effect, runtime);
|
|
767
|
+
if (effect.kind === 'merge-track')
|
|
768
|
+
return runMergeTrack(planRoot, branch, s, protocol, effect, runtime);
|
|
769
|
+
if (effect.kind === 'request-global-qa')
|
|
770
|
+
return runRequestGlobalQa(planRoot, branch, s, protocol, effect);
|
|
771
|
+
if (effect.kind === 'request-final-integration')
|
|
772
|
+
return runRequestFinalIntegration(planRoot, branch, s, protocol, effect, runtime);
|
|
773
|
+
if (effect.kind === 'run-final-interlock')
|
|
774
|
+
return runRunFinalInterlock(planRoot, branch, s, protocol, effect, runtime);
|
|
775
|
+
return runSpawnTrackSupervisor(planRoot, branch, s, protocol, effect, runtime);
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Driver de P1/P2: construye la vista pura desde el journal, pide la
|
|
779
|
+
* siguiente decisión a `protocol.ts`, y la ejecuta. SOLO las transiciones
|
|
780
|
+
* puramente en memoria y sin consecuencia observable fuera del journal
|
|
781
|
+
* (`persist-prepare-intent`, `activate-cohort`, `activate-track`) se drenan
|
|
782
|
+
* dentro del mismo call —persistiendo cada una antes de seguir—, porque no
|
|
783
|
+
* cruzan ninguna frontera con el mundo real ni bloquean nada.
|
|
784
|
+
*
|
|
785
|
+
* TODO lo demás cuenta como UNA frontera y detiene el call ahí mismo, aunque
|
|
786
|
+
* en sí mismo no haya llamado a `runtime` todavía: persistir un
|
|
787
|
+
* `supervisorIntent` (R1.8 — el intent debe quedar durable ANTES de que
|
|
788
|
+
* cualquier llamada futura a `runtime.spawnSupervisor` pueda ocurrir, en un
|
|
789
|
+
* tick DISTINTO), bloquear un track por ajeno (R4.6), y por supuesto
|
|
790
|
+
* cualquier consulta real a `runtime`
|
|
791
|
+
* (`addWorktree`/`initTrackJournal`/`spawnSupervisor`/`observeSupervisor`).
|
|
792
|
+
* Un `reconcileTracks()` jamás cruza dos de estas fronteras en el mismo call
|
|
793
|
+
* — ni dos tracks distintos, ni dos pasos del mismo track — precisamente
|
|
794
|
+
* para que Task 9 pueda inyectar un crash exactamente después de cualquiera
|
|
795
|
+
* de ellas y probar que el restart converge (post-review fix: la versión
|
|
796
|
+
* original dejaba `stop:false` en el bloqueo por ajeno y en el persist del
|
|
797
|
+
* `supervisorIntent`, permitiendo que un solo call colapsara dos fronteras).
|
|
798
|
+
* Esto es también lo que mantiene visible, entre dos ticks, el estado
|
|
799
|
+
* "todos ARMED pero la cohorte todavía no activó" (C1).
|
|
800
|
+
*
|
|
801
|
+
* `async` desde Task 9 (Step 5): el paso `supervisor` de `begin-teardown`
|
|
802
|
+
* termina el grupo del supervisor con `terminatePreviouslyOwnedGroup`
|
|
803
|
+
* (identity-verified, R4.8), que espera una escalera de gracia real
|
|
804
|
+
* (SIGTERM -> confirmar -> SIGKILL -> confirmar) — jamás un `kill(pid)` sin
|
|
805
|
+
* confirmación. Todo caller sigue siendo dueño de awaitear el resultado
|
|
806
|
+
* (ver `Supervisor.tick`); ningún test que llame a `reconcileTracks`
|
|
807
|
+
* directamente puede seguir tratándolo como síncrono.
|
|
808
|
+
*/
|
|
809
|
+
async function reconcileTracks(planRoot, branch, state, runtime, maxParallel) {
|
|
810
|
+
let s = state;
|
|
811
|
+
for (;;) {
|
|
812
|
+
if (s.tracks === undefined || s.tracks.length < 2 || s.cohortPhase === undefined) {
|
|
813
|
+
return { state: s, effectExecuted: null };
|
|
814
|
+
}
|
|
815
|
+
const protocol = toProtocol(s, maxParallel);
|
|
816
|
+
const effect = (0, protocol_1.nextProtocolEffect)(protocol);
|
|
817
|
+
if (effect === null)
|
|
818
|
+
return { state: s, effectExecuted: null };
|
|
819
|
+
if (!RUNTIME_EFFECTS.has(effect.kind)) {
|
|
820
|
+
const applied = (0, protocol_1.observeProtocolEffect)(protocol, effect, { kind: 'effect-applied', effect });
|
|
821
|
+
const candidate = applyProtocolToState(s, applied);
|
|
822
|
+
// Defensivo: un ProtocolEffect sin fase mapeada en `EFFECT_APPLIED_PHASE`
|
|
823
|
+
// (ej. `merge-track`/`request-global-qa`, todavía sin driver propio en
|
|
824
|
+
// esta task — llegan en Tasks 11/12) no debe hacer que este loop gire
|
|
825
|
+
// para siempre repitiendo el mismo efecto sin progreso observable.
|
|
826
|
+
if (candidate.cohortPhase === s.cohortPhase && JSON.stringify(candidate.tracks) === JSON.stringify(s.tracks)) {
|
|
827
|
+
return { state: s, effectExecuted: null };
|
|
828
|
+
}
|
|
829
|
+
s = persist(planRoot, branch, candidate);
|
|
830
|
+
const trackId = 'trackId' in effect ? effect.trackId : undefined;
|
|
831
|
+
const label = trackId !== undefined ? (s.tracks?.find((t) => t.trackId === trackId)?.phase ?? effect.kind) : s.cohortPhase;
|
|
832
|
+
// `enter-serial` es el ÚNICO efecto de este branch que lleva una causa: la
|
|
833
|
+
// degradación se lee entera en su propio evento, sin obligar a correlacionar
|
|
834
|
+
// hacia atrás con el `track-effect-failed` que la originó.
|
|
835
|
+
const reason = effect.kind === 'enter-serial' ? effect.reason : undefined;
|
|
836
|
+
(0, store_1.appendEvent)(planRoot, branch, { kind: 'track-protocol-persist', effect: effect.kind, trackId, phase: label, reason });
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const result = await executeRuntimeEffect(planRoot, branch, s, protocol, effect, runtime);
|
|
840
|
+
s = result.state;
|
|
841
|
+
if (result.stop)
|
|
842
|
+
return { state: s, effectExecuted: result.executed };
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* R6.2/R6.8/C7 (Task 11): reconcilia un `MERGE_HEAD` REAL dejado abierto por
|
|
847
|
+
* un crash a mitad de un `merge-track` — se llama desde `Supervisor.tick()`
|
|
848
|
+
* ANTES de `verifyBranchInvariant` (y de cualquier guard general futuro que
|
|
849
|
+
* pudiera rechazar el repo por estar "a mitad de un merge"): hoy ningún
|
|
850
|
+
* guard existente rechaza por `MERGE_HEAD` (ni `verifyBranchInvariant` — solo
|
|
851
|
+
* compara nombre de rama — ni ningún otro), pero esta función corre primero
|
|
852
|
+
* de todos modos para que uno agregado en el futuro nunca pueda rechazar un
|
|
853
|
+
* estado que ya es reconciliable.
|
|
854
|
+
*
|
|
855
|
+
* Deliberadamente NO reimplementa la decisión: si el `MERGE_HEAD` observado
|
|
856
|
+
* coincide con el `joinIntent.expectedTrackHeadSha` de un track propio en
|
|
857
|
+
* `JOIN_INTENT`, delega en el mismísimo `reconcileTracks` (que, sobre ese
|
|
858
|
+
* estado, computa `merge-track` como su próximo efecto y lo ejecuta vía
|
|
859
|
+
* `runMergeTrack` — CERO lógica duplicada). Si el `MERGE_HEAD` no es
|
|
860
|
+
* atribuible a ningún intent propio (ajeno o indemostrable), no toca nada —
|
|
861
|
+
* fail-closed, deja que el guard/operador que corresponda decida.
|
|
862
|
+
*
|
|
863
|
+
* Devuelve `handled: true` cuando efectivamente ejecutó `reconcileTracks` —
|
|
864
|
+
* el caller (`Supervisor.tick`) usa esto para NO volver a invocarlo más
|
|
865
|
+
* tarde en el mismo tick (a lo sumo UNA mutación real de tracks por tick,
|
|
866
|
+
* mismo invariante que ya sostiene `reconcileTracks` por sí solo).
|
|
867
|
+
*/
|
|
868
|
+
async function reconcileOpenJoin(planRoot, branch, state, runtime, maxParallel) {
|
|
869
|
+
if (state.tracks === undefined || state.tracks.length < 2 || state.cohortPhase === undefined) {
|
|
870
|
+
return { handled: false, state };
|
|
871
|
+
}
|
|
872
|
+
let mergeHead;
|
|
873
|
+
try {
|
|
874
|
+
mergeHead = (0, git_1.readMergeHead)(planRoot);
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
// Indemostrable acá: se deja para el `reconcileTracks` normal de este
|
|
878
|
+
// mismo tick (o el próximo) — esta función es un adelanto oportunista,
|
|
879
|
+
// nunca la única vía de reconciliación.
|
|
880
|
+
return { handled: false, state };
|
|
881
|
+
}
|
|
882
|
+
if (mergeHead === null)
|
|
883
|
+
return { handled: false, state };
|
|
884
|
+
const ownsIt = state.tracks.some((t) => t.phase === 'JOIN_INTENT' && t.joinIntent?.expectedTrackHeadSha === mergeHead);
|
|
885
|
+
if (!ownsIt)
|
|
886
|
+
return { handled: false, state }; // MERGE_HEAD ajeno o sin intent propio que lo explique (fail-closed)
|
|
887
|
+
const result = await reconcileTracks(planRoot, branch, state, runtime, maxParallel);
|
|
888
|
+
return { handled: true, state: result.state };
|
|
889
|
+
}
|
|
890
|
+
// --- Implementación de producción (wiring real de watch/supervisor.ts) -----
|
|
891
|
+
function planDescriptorContext(planRoot, planBranch) {
|
|
892
|
+
return { planRoot: fs_1.default.realpathSync(planRoot), planBranch };
|
|
893
|
+
}
|
|
894
|
+
/** Lee los sidecars que `track supervisor-wrapper` (Step 5) escribe en el
|
|
895
|
+
* propio worktree del track: claim -> identity -> ready, en ese orden — el
|
|
896
|
+
* mismo contrato de `exec-wrapper.ts` (claim/identity/result), adaptado al
|
|
897
|
+
* supervisor de un track en vez de a un job. */
|
|
898
|
+
function observeSupervisorFromDisk(ref) {
|
|
899
|
+
if (ref.supervisorIntent === undefined)
|
|
900
|
+
return { kind: 'absent' };
|
|
901
|
+
const claimPath = ref.supervisorIntent.claimPath;
|
|
902
|
+
const dir = path_1.default.dirname(claimPath);
|
|
903
|
+
if (!fs_1.default.existsSync(claimPath))
|
|
904
|
+
return { kind: 'absent' };
|
|
905
|
+
const identityPath = path_1.default.join(dir, 'supervisor.identity.json');
|
|
906
|
+
if (!fs_1.default.existsSync(identityPath))
|
|
907
|
+
return { kind: 'claimed' };
|
|
908
|
+
let identity;
|
|
909
|
+
try {
|
|
910
|
+
identity = JSON.parse(fs_1.default.readFileSync(identityPath, 'utf8'));
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
return { kind: 'foreign' };
|
|
914
|
+
}
|
|
915
|
+
if (identity.nonce !== ref.supervisorIntent.nonce)
|
|
916
|
+
return { kind: 'foreign' };
|
|
917
|
+
const readyPath = path_1.default.join(dir, 'supervisor.ready.json');
|
|
918
|
+
if (!fs_1.default.existsSync(readyPath))
|
|
919
|
+
return { kind: 'claimed' };
|
|
920
|
+
let ready;
|
|
921
|
+
try {
|
|
922
|
+
ready = JSON.parse(fs_1.default.readFileSync(readyPath, 'utf8'));
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
return { kind: 'claimed' };
|
|
926
|
+
}
|
|
927
|
+
if (typeof ready.readinessNonce !== 'string')
|
|
928
|
+
return { kind: 'claimed' };
|
|
929
|
+
return { kind: 'ready', readinessNonce: ready.readinessNonce };
|
|
930
|
+
}
|
|
931
|
+
/** Grace por defecto si el caller no provee una explícita (ej. tests viejos
|
|
932
|
+
* de `defaultTrackRuntime` que no ejercitan teardown): mismo orden de
|
|
933
|
+
* magnitud que `DEFAULT_SUPERVISOR_CONFIG` en `supervisor.ts`, nunca cero
|
|
934
|
+
* (una escalera SIGTERM->SIGKILL sin ventana de gracia no es una escalera). */
|
|
935
|
+
const DEFAULT_TEARDOWN_GRACE = { termGraceMs: 30000, killGraceMs: 5000 };
|
|
936
|
+
/** Implementación real de `TrackRuntime`, inyectada por `Supervisor` en
|
|
937
|
+
* producción. Los tests de `reconcileTracks` usan un fake — ningún proceso
|
|
938
|
+
* ni worktree real se toca fuera de esta función. */
|
|
939
|
+
function defaultTrackRuntime(planRoot, planBranch, grace = DEFAULT_TEARDOWN_GRACE) {
|
|
940
|
+
// C7 (Task 11): memoizado por CLOSURE — vive tanto como este runtime
|
|
941
|
+
// (creado una vez por `Supervisor`, ver su constructor). Un proceso
|
|
942
|
+
// nuevo tras un crash real construye un `defaultTrackRuntime` fresco
|
|
943
|
+
// (`integrationLock === null` de nuevo) y `acquireIntegrationLock`
|
|
944
|
+
// reclama con su propia lógica de identidad muerta probada — nunca acá.
|
|
945
|
+
let integrationLock = null;
|
|
946
|
+
return {
|
|
947
|
+
addWorktree(root, ref, baseSha) {
|
|
948
|
+
// CRITICAL post-review fix: `git check-ignore` es una operación de
|
|
949
|
+
// working-tree — solo puede responder por lo que está REALMENTE
|
|
950
|
+
// checkeado en algún lado. El repo del plan vive en su HEAD vivo,
|
|
951
|
+
// que puede ser un commit distinto (posterior) de `baseSha`; si un
|
|
952
|
+
// `.gitignore` para `.awm` se agregó (o quitó) entre `baseSha` y
|
|
953
|
+
// HEAD, chequear contra `root` responde por un árbol que el
|
|
954
|
+
// worktree que estamos por crear NUNCA va a tener. La única
|
|
955
|
+
// verificación honesta es contra el worktree YA CREADO, checkeado
|
|
956
|
+
// en `baseSha` de verdad — así que primero se crea, y recién
|
|
957
|
+
// después se verifica. Si falla, el worktree es NUESTRO (lo
|
|
958
|
+
// acabamos de crear en esta misma llamada) y se descarta —
|
|
959
|
+
// jamás queda vivo e inseguro (C2 fail-closed).
|
|
960
|
+
(0, git_1.addOwnedWorktree)(root, ref, baseSha);
|
|
961
|
+
if (!(0, git_1.isAwmGitignored)(ref.worktreePath)) {
|
|
962
|
+
(0, git_1.removeOwnedWorktree)(root, ref.worktreePath);
|
|
963
|
+
throw new Error('`.awm` no está gitignoreado en el worktree recién creado (checkeado en baseSha): se descarta (degradación C2)');
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
initTrackJournal(ref, context) {
|
|
967
|
+
(0, store_1.initJournal)(ref.worktreePath, ref.branch);
|
|
968
|
+
const r = (0, store_1.readJournal)(ref.worktreePath, ref.branch);
|
|
969
|
+
if (r.corrupt || r.state === null)
|
|
970
|
+
throw new Error('journal de track corrupto tras init');
|
|
971
|
+
const s = r.state;
|
|
972
|
+
if (s.trackContext !== undefined && s.trackContext.trackId !== context.trackId) {
|
|
973
|
+
// R4.6: un trackContext ajeno preexistente jamás se sobreescribe.
|
|
974
|
+
throw new Error(`trackContext preexistente pertenece a otro track: ${s.trackContext.trackId}`);
|
|
975
|
+
}
|
|
976
|
+
if (s.trackContext === undefined) {
|
|
977
|
+
s.trackContext = context;
|
|
978
|
+
(0, store_1.writeJournal)(ref.worktreePath, ref.branch, s);
|
|
979
|
+
}
|
|
980
|
+
const plan = planDescriptorContext(planRoot, planBranch);
|
|
981
|
+
(0, descriptor_1.writeDescriptor)(ref.worktreePath, {
|
|
982
|
+
schema: 1, planRoot: plan.planRoot, planBranch: plan.planBranch,
|
|
983
|
+
trackId: ref.trackId, planJournalId: context.planJournalId, fencingToken: ref.fencingToken,
|
|
984
|
+
});
|
|
985
|
+
},
|
|
986
|
+
spawnSupervisor(ref) {
|
|
987
|
+
if (ref.supervisorIntent === undefined)
|
|
988
|
+
return undefined;
|
|
989
|
+
const { child, ref: pref } = (0, process_2.spawnStructured)(ref.supervisorIntent.argv, ref.worktreePath, ref.supervisorIntent.nonce);
|
|
990
|
+
child.unref(); // detached: el supervisor del plan jamás espera al del track (R4.7)
|
|
991
|
+
return pref;
|
|
992
|
+
},
|
|
993
|
+
observeSupervisor: observeSupervisorFromDisk,
|
|
994
|
+
// R5.2/R6.3 (Task 10): `emitRequest` ya es el primitivo durable
|
|
995
|
+
// genérico de `core/journal/requests.ts` — cross-worktree acá solo
|
|
996
|
+
// significa "el primer argumento no es `planRoot`, es el worktree
|
|
997
|
+
// del TRACK" (`requestsDir` no distingue de quién es el journal).
|
|
998
|
+
emitFreezeRequest(ref, generationToken) {
|
|
999
|
+
(0, requests_1.emitRequest)(ref.worktreePath, ref.branch, {
|
|
1000
|
+
kind: 'track-freeze-request',
|
|
1001
|
+
generationToken,
|
|
1002
|
+
idempotencyKey: `freeze-${ref.trackId}-${ref.fencingToken}`,
|
|
1003
|
+
payload: { trackId: ref.trackId, fencingToken: ref.fencingToken },
|
|
1004
|
+
});
|
|
1005
|
+
},
|
|
1006
|
+
// Task 13 (reemplaza el `teardownOwned(ref, step)` TEMPORAL de Task
|
|
1007
|
+
// 9): cada primitivo es idempotente ante reintento tras crash —
|
|
1008
|
+
// "nada que remover"/"ya ausente" nunca lanza, es éxito vacuo (R4.2).
|
|
1009
|
+
async stopOwnSupervisor(ref) {
|
|
1010
|
+
if (ref.supervisorProcessRef === undefined)
|
|
1011
|
+
return true; // nunca llegamos a spawnear: nada que terminar
|
|
1012
|
+
// R4.8: identity-verified — jamás un `kill(pid)` crudo. Un `pgid`
|
|
1013
|
+
// reutilizado por OTRO proceso nunca se confunde con el nuestro:
|
|
1014
|
+
// post-review (hallazgo de revisión de Task 13), la identidad NO
|
|
1015
|
+
// se da por buena solo por haberse capturado en el spawn —
|
|
1016
|
+
// `supervisorProcessRef` se relee del journal persistido y este
|
|
1017
|
+
// paso puede correr arbitrariamente tarde (incluso tras un
|
|
1018
|
+
// restart completo), así que `terminatePreviouslyOwnedGroup`
|
|
1019
|
+
// reverifica la identidad completa del slot de líder
|
|
1020
|
+
// INMEDIATAMENTE antes de enviar cualquier señal (`process.ts`,
|
|
1021
|
+
// `groupLeaderReused`) — nunca confía solo en `groupIsGone`.
|
|
1022
|
+
const confirmed = (0, process_2.groupIsGone)(ref.supervisorProcessRef.processGroup)
|
|
1023
|
+
|| await (0, process_2.terminatePreviouslyOwnedGroup)(ref.supervisorProcessRef, grace);
|
|
1024
|
+
if (confirmed) {
|
|
1025
|
+
// Step 4 del plan ("supervisor propio muerto confirmado Y
|
|
1026
|
+
// lock ausente"): un lock advisory que el supervisor dejó sin
|
|
1027
|
+
// liberar (crash a mitad de su propia salida) es, por
|
|
1028
|
+
// definición, stale una vez que TODO su grupo está
|
|
1029
|
+
// confirmado ausente por identidad — este path del lock vive
|
|
1030
|
+
// dentro del worktree PROPIO del track (`ref.worktreePath`),
|
|
1031
|
+
// nunca en un recurso compartido, así que reclamarlo acá
|
|
1032
|
+
// nunca puede pisar algo ajeno. Sin esto, un crash exacto
|
|
1033
|
+
// ahí dejaría el teardown reintentando `stop-own-supervisor`
|
|
1034
|
+
// para siempre (C9: debe converger).
|
|
1035
|
+
try {
|
|
1036
|
+
fs_1.default.rmSync((0, paths_1.supervisorLockPath)(ref.worktreePath), { force: true });
|
|
1037
|
+
}
|
|
1038
|
+
catch { /* best-effort */ }
|
|
1039
|
+
}
|
|
1040
|
+
return confirmed;
|
|
1041
|
+
},
|
|
1042
|
+
removeOwnedWorktree(repo, ref) {
|
|
1043
|
+
// Ownership ya probado por el driver (`worktreeOwnershipProven`,
|
|
1044
|
+
// ver `gatherTeardownObservation`) antes de que `decideTeardown`
|
|
1045
|
+
// llegara a `remove-owned-worktree` — acá solo el efecto real;
|
|
1046
|
+
// `git.ts` bloquea (nombrando paths) si el worktree está sucio, y
|
|
1047
|
+
// nunca usa `--force` (R4.10).
|
|
1048
|
+
(0, git_1.removeOwnedWorktree)(repo, ref.worktreePath);
|
|
1049
|
+
},
|
|
1050
|
+
removeOwnedBranch(repo, branchName) {
|
|
1051
|
+
// `git.ts` ya verifica que no siga checked out y usa `-d`, nunca
|
|
1052
|
+
// `-D` (R4.10) — no-op si la branch ya no existe.
|
|
1053
|
+
(0, git_1.removeOwnedBranch)(repo, branchName);
|
|
1054
|
+
},
|
|
1055
|
+
// R6.2/R6.3/C7 (Task 11): delega 1:1 en `core/tracks/git.ts` — la
|
|
1056
|
+
// decisión de CUÁL llamar (o si bloquear en su lugar) es siempre de
|
|
1057
|
+
// `decideJoinReconciliation`/`runMergeTrack`, nunca de acá.
|
|
1058
|
+
mergeFrozenTrack(repo, intent) {
|
|
1059
|
+
(0, git_1.mergeFrozenTrack)(repo, intent);
|
|
1060
|
+
},
|
|
1061
|
+
abortOwnedMerge(repo, intent) {
|
|
1062
|
+
(0, git_1.abortOwnedMerge)(repo, intent);
|
|
1063
|
+
},
|
|
1064
|
+
async ensureIntegrationLock(planJournalId, expectedPlanHeadSha) {
|
|
1065
|
+
if (integrationLock !== null)
|
|
1066
|
+
return 'already-held'; // ya adquirido por este proceso: no-op
|
|
1067
|
+
await (0, join_1.stopControllerGenerationConfirmed)(planRoot, planBranch, grace);
|
|
1068
|
+
integrationLock = (0, join_1.acquireIntegrationLock)(planRoot, { planJournalId, expectedPlanHeadSha });
|
|
1069
|
+
return 'acquired';
|
|
1070
|
+
},
|
|
1071
|
+
// R7/C3 (Task 12): a diferencia de `ensureIntegrationLock` (memoizado
|
|
1072
|
+
// por el resto de la vida del proceso), esto se llama de nuevo cada
|
|
1073
|
+
// vez que el driver de `request-final-integration` lo pide — el
|
|
1074
|
+
// controller puede haberse relanzado desde el último merge (ej. para
|
|
1075
|
+
// correr `post-implementation-qa`). `stopControllerGenerationConfirmed`
|
|
1076
|
+
// ya es un no-op seguro sin generación activa; `hadActive` es lo que
|
|
1077
|
+
// le permite al driver distinguir "hizo trabajo real" de "ya estaba
|
|
1078
|
+
// pausado", mismo criterio que `'acquired'`/`'already-held'` arriba.
|
|
1079
|
+
async pauseControllerGeneration() {
|
|
1080
|
+
const r = (0, store_1.readJournal)(planRoot, planBranch);
|
|
1081
|
+
if (r.corrupt || r.state === null)
|
|
1082
|
+
throw new Error('journal corrupto: no se puede pausar el controller del plan (R1.6)');
|
|
1083
|
+
const hadActive = r.state.generations.some((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
|
|
1084
|
+
if (!hadActive)
|
|
1085
|
+
return false;
|
|
1086
|
+
await (0, join_1.stopControllerGenerationConfirmed)(planRoot, planBranch, grace);
|
|
1087
|
+
return true;
|
|
1088
|
+
},
|
|
1089
|
+
// R7.5 (Task 12): complemento de `ensureIntegrationLock` que Task 11
|
|
1090
|
+
// dejó diferido — solo libera un lock que ESTE proceso adquirió
|
|
1091
|
+
// (identidad verificada por `releaseIntegrationLock`, que compara
|
|
1092
|
+
// `spawnNonce`), nunca uno ajeno.
|
|
1093
|
+
releaseIntegrationLockIfHeld() {
|
|
1094
|
+
if (integrationLock !== null) {
|
|
1095
|
+
(0, join_1.releaseIntegrationLock)(integrationLock);
|
|
1096
|
+
integrationLock = null;
|
|
1097
|
+
}
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
}
|