agentic-workflow-manager 3.4.0 → 3.5.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/dist/src/commands/job/exec-wrapper.js +136 -0
- package/dist/src/commands/job/export.js +94 -0
- package/dist/src/commands/job/gate.js +118 -0
- package/dist/src/commands/job/heartbeat.js +15 -0
- package/dist/src/commands/job/index.js +246 -0
- package/dist/src/commands/job/query.js +37 -0
- package/dist/src/commands/job/reap.js +24 -0
- package/dist/src/commands/job/reconcile.js +112 -0
- package/dist/src/commands/job/request.js +27 -0
- package/dist/src/commands/watch/apply.js +352 -0
- package/dist/src/commands/watch/generations.js +249 -0
- package/dist/src/commands/watch/index.js +49 -0
- package/dist/src/commands/watch/init.js +72 -0
- package/dist/src/commands/watch/lock.js +89 -0
- package/dist/src/commands/watch/runner.js +191 -0
- package/dist/src/commands/watch/supervisor.js +266 -0
- package/dist/src/core/atomic-file.js +31 -0
- package/dist/src/core/export/pack.js +7 -1
- package/dist/src/core/journal/adapter.js +27 -0
- package/dist/src/core/journal/fingerprint.js +80 -0
- package/dist/src/core/journal/paths.js +56 -0
- package/dist/src/core/journal/process.js +284 -0
- package/dist/src/core/journal/redact.js +142 -0
- package/dist/src/core/journal/requests.js +132 -0
- package/dist/src/core/journal/store.js +107 -0
- package/dist/src/core/journal/types.js +165 -0
- package/dist/src/index.js +4 -0
- package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
- package/dist/tests/commands/job/export.test.js +76 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
- package/dist/tests/commands/job/reap-cli.test.js +101 -0
- package/dist/tests/commands/job/verbs.test.js +56 -0
- package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
- package/dist/tests/commands/watch/apply.test.js +397 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
- package/dist/tests/commands/watch/generations.test.js +115 -0
- package/dist/tests/commands/watch/integration.test.js +124 -0
- package/dist/tests/commands/watch/lock.test.js +60 -0
- package/dist/tests/commands/watch/runner.test.js +239 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
- package/dist/tests/commands/watch/watch-init.test.js +43 -0
- package/dist/tests/core/atomic-file-durable.test.js +42 -0
- package/dist/tests/core/journal/adapter.test.js +27 -0
- package/dist/tests/core/journal/fingerprint.test.js +164 -0
- package/dist/tests/core/journal/paths.test.js +35 -0
- package/dist/tests/core/journal/process.test.js +213 -0
- package/dist/tests/core/journal/redact.test.js +59 -0
- package/dist/tests/core/journal/requests.test.js +134 -0
- package/dist/tests/core/journal/store.test.js +88 -0
- package/dist/tests/core/journal/types.test.js +78 -0
- package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
- package/package.json +1 -1
|
@@ -0,0 +1,249 @@
|
|
|
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.Backoff = void 0;
|
|
7
|
+
exports.decideStall = decideStall;
|
|
8
|
+
exports.activeGeneration = activeGeneration;
|
|
9
|
+
exports.beginGeneration = beginGeneration;
|
|
10
|
+
exports.launchControllerGeneration = launchControllerGeneration;
|
|
11
|
+
exports.collectControllerGeneration = collectControllerGeneration;
|
|
12
|
+
exports.controllerGenerationHasUnresolvedClaim = controllerGenerationHasUnresolvedClaim;
|
|
13
|
+
exports.ensureControllerGeneration = ensureControllerGeneration;
|
|
14
|
+
exports.enterCustody = enterCustody;
|
|
15
|
+
exports.resolveGeneration = resolveGeneration;
|
|
16
|
+
// State machine de generaciones (R4.2/R4.2b/R4.3): el silencio NUNCA autoriza
|
|
17
|
+
// kill; custodia BLOCKED conserva lock y ownership (el loop de supervisor.ts
|
|
18
|
+
// sigue vivo auditando — jamas sale dejando un vivo sin duenio).
|
|
19
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
20
|
+
const fs_1 = __importDefault(require("fs"));
|
|
21
|
+
const store_1 = require("../../core/journal/store");
|
|
22
|
+
const process_1 = require("../../core/journal/process");
|
|
23
|
+
const adapter_1 = require("../../core/journal/adapter");
|
|
24
|
+
const paths_1 = require("../../core/journal/paths");
|
|
25
|
+
const exec_wrapper_1 = require("../job/exec-wrapper");
|
|
26
|
+
const runner_1 = require("./runner");
|
|
27
|
+
const types_1 = require("../../core/journal/types");
|
|
28
|
+
/** Lectura obligatoria del journal (patron repetido en todo este archivo):
|
|
29
|
+
* el supervisor jamas opera sobre corrupcion (R1.6) — falla ruidoso, nunca
|
|
30
|
+
* sigue con un estado indemostrable. */
|
|
31
|
+
function requireState(repoRoot, branch) {
|
|
32
|
+
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
33
|
+
if (r.corrupt || r.state === null)
|
|
34
|
+
throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
|
|
35
|
+
return r.state;
|
|
36
|
+
}
|
|
37
|
+
/** Doble senial + senial positiva del adapter (design R4.2/R4.2b):
|
|
38
|
+
* - solo heartbeat vencido => observar (suspected-stall), JAMAS matar;
|
|
39
|
+
* - doble senial sin 'safe' del adapter => custodia BLOCKED sin matar;
|
|
40
|
+
* - doble senial + 'safe' => recien ahi resolver la generacion. */
|
|
41
|
+
function decideStall(signals, cfg) {
|
|
42
|
+
if (signals.heartbeatAgeMs < cfg.heartbeatTimeoutMs)
|
|
43
|
+
return 'healthy';
|
|
44
|
+
if (signals.activityFrozenMs < cfg.activityWindowMs)
|
|
45
|
+
return 'suspected-stall-observe';
|
|
46
|
+
if (signals.safeToReplace !== 'safe')
|
|
47
|
+
return 'custody-blocked';
|
|
48
|
+
return 'resolve-generation';
|
|
49
|
+
}
|
|
50
|
+
const BACKOFF_MS = [60000, 300000, 900000];
|
|
51
|
+
const MAX_RELAUNCHES_PER_HOUR = 6;
|
|
52
|
+
class Backoff {
|
|
53
|
+
idx = -1;
|
|
54
|
+
stamps = [];
|
|
55
|
+
nextMs() {
|
|
56
|
+
this.idx = Math.min(this.idx + 1, BACKOFF_MS.length - 1);
|
|
57
|
+
return BACKOFF_MS[this.idx];
|
|
58
|
+
}
|
|
59
|
+
reset() { this.idx = -1; }
|
|
60
|
+
recordRelaunch() { this.stamps.push(Date.now()); }
|
|
61
|
+
exhausted() {
|
|
62
|
+
const hourAgo = Date.now() - 3600000;
|
|
63
|
+
this.stamps = this.stamps.filter((t) => t > hourAgo);
|
|
64
|
+
return this.stamps.length >= MAX_RELAUNCHES_PER_HOUR;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.Backoff = Backoff;
|
|
68
|
+
function activeGeneration(s) {
|
|
69
|
+
return s.generations.find((g) => g.state === 'active' || g.state === 'controller-suspected-stall');
|
|
70
|
+
}
|
|
71
|
+
/** Emite generacion N+1: toda anterior queda superseded (fencing). NO lanza el
|
|
72
|
+
* proceso aqui — launchControllerGeneration lo hace con el adapter. */
|
|
73
|
+
function beginGeneration(repoRoot, branch) {
|
|
74
|
+
const s = requireState(repoRoot, branch);
|
|
75
|
+
for (const g of s.generations) {
|
|
76
|
+
if (g.state === 'active' || g.state === 'controller-suspected-stall')
|
|
77
|
+
g.state = 'superseded';
|
|
78
|
+
}
|
|
79
|
+
const gen = {
|
|
80
|
+
n: s.generations.length + 1,
|
|
81
|
+
token: crypto_1.default.randomBytes(8).toString('hex'),
|
|
82
|
+
state: 'active', launchedAt: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
s.generations.push(gen);
|
|
85
|
+
s.controllerHeartbeatAt = undefined; // el heartbeat pertenece al fencing token anterior
|
|
86
|
+
(0, store_1.writeJournal)(repoRoot, branch, s);
|
|
87
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'generation-begun', n: gen.n });
|
|
88
|
+
return gen;
|
|
89
|
+
}
|
|
90
|
+
function promptForGeneration(gen) {
|
|
91
|
+
return `${gen.resumePrompt}\nGeneracion activa: ${gen.token}. Incluye --generation ${gen.token} en cada comando awm job.`;
|
|
92
|
+
}
|
|
93
|
+
/** Persiste el intent completo ANTES de delegarlo al wrapper. El wrapper usa
|
|
94
|
+
* claim exclusivo por (controllerJobId, spawnNonce), por lo que reemitir este
|
|
95
|
+
* mismo intent tras un crash nunca lanza dos controllers. */
|
|
96
|
+
function launchControllerGeneration(repoRoot, branch, provider, resumePrompt, spawner = (0, runner_1.defaultWrapperSpawner)()) {
|
|
97
|
+
const s = requireState(repoRoot, branch);
|
|
98
|
+
const gen = activeGeneration(s);
|
|
99
|
+
if (gen === undefined)
|
|
100
|
+
throw new Error('no hay generacion activa para lanzar');
|
|
101
|
+
gen.controllerJobId = gen.controllerJobId ?? `controller-gen-${gen.n}`;
|
|
102
|
+
gen.spawnNonce = gen.spawnNonce ?? crypto_1.default.randomBytes(8).toString('hex');
|
|
103
|
+
gen.provider = gen.provider ?? provider;
|
|
104
|
+
gen.resumePrompt = gen.resumePrompt ?? resumePrompt;
|
|
105
|
+
(0, store_1.writeJournal)(repoRoot, branch, s);
|
|
106
|
+
const argv = (0, adapter_1.adapterFor)(gen.provider).launchArgv(promptForGeneration(gen));
|
|
107
|
+
const job = {
|
|
108
|
+
id: gen.controllerJobId, fingerprint: `generation:${gen.token}`, commandDigest: `generation:${gen.token}`,
|
|
109
|
+
argv, cwd: '.', paths: [], expandedPaths: [], executionState: 'spawn-intent',
|
|
110
|
+
observationState: 'progressing', spawnNonce: gen.spawnNonce,
|
|
111
|
+
phaseTimestamps: { 'spawn-intent': gen.launchedAt },
|
|
112
|
+
};
|
|
113
|
+
const wrapperRef = spawner(job, gen.spawnNonce, (0, paths_1.logsDir)(repoRoot, branch), repoRoot);
|
|
114
|
+
if (wrapperRef !== undefined) {
|
|
115
|
+
const afterSpawn = requireState(repoRoot, branch);
|
|
116
|
+
const same = afterSpawn.generations.find((candidate) => candidate.token === gen.token);
|
|
117
|
+
if (same !== undefined)
|
|
118
|
+
same.wrapperRef = wrapperRef;
|
|
119
|
+
(0, store_1.writeJournal)(repoRoot, branch, afterSpawn);
|
|
120
|
+
}
|
|
121
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'generation-launch-requested', provider: gen.provider, n: gen.n });
|
|
122
|
+
}
|
|
123
|
+
/** Adopta la identidad que el wrapper externo persistio. Nunca inventa PID y
|
|
124
|
+
* valida que el sidecar corresponda exactamente al intent de la generacion. */
|
|
125
|
+
function collectControllerGeneration(repoRoot, branch) {
|
|
126
|
+
const s = requireState(repoRoot, branch);
|
|
127
|
+
const gen = activeGeneration(s);
|
|
128
|
+
if (gen?.controllerJobId === undefined || gen.spawnNonce === undefined)
|
|
129
|
+
return false;
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.identityPath)((0, paths_1.logsDir)(repoRoot, branch), gen.controllerJobId, gen.spawnNonce), 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
138
|
+
return false;
|
|
139
|
+
const identity = parsed;
|
|
140
|
+
if (identity.jobId !== gen.controllerJobId || identity.nonce !== gen.spawnNonce
|
|
141
|
+
|| !(0, types_1.isWellFormedProcessRef)(identity.wrapper) || !(0, types_1.isWellFormedProcessRef)(identity.command)
|
|
142
|
+
|| identity.wrapper.spawnNonce !== gen.spawnNonce || identity.command.spawnNonce !== gen.spawnNonce
|
|
143
|
+
|| gen.provider === undefined || gen.resumePrompt === undefined
|
|
144
|
+
|| identity.command.argvDigest !== (0, process_1.argvDigest)((0, adapter_1.adapterFor)(gen.provider).launchArgv(promptForGeneration(gen))))
|
|
145
|
+
return false;
|
|
146
|
+
const changed = gen.wrapperRef?.pid !== identity.wrapper.pid || gen.processRef?.pid !== identity.command.pid;
|
|
147
|
+
gen.wrapperRef = identity.wrapper;
|
|
148
|
+
gen.processRef = identity.command;
|
|
149
|
+
if (changed) {
|
|
150
|
+
(0, store_1.writeJournal)(repoRoot, branch, s);
|
|
151
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'generation-launched', provider: gen.provider, pid: gen.processRef.pid, n: gen.n });
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
function controllerGenerationHasUnresolvedClaim(repoRoot, branch, gen) {
|
|
156
|
+
if (gen.controllerJobId === undefined || gen.spawnNonce === undefined || gen.processRef !== undefined)
|
|
157
|
+
return false;
|
|
158
|
+
const logs = (0, paths_1.logsDir)(repoRoot, branch);
|
|
159
|
+
return fs_1.default.existsSync((0, exec_wrapper_1.claimPath)(logs, gen.controllerJobId, gen.spawnNonce))
|
|
160
|
+
&& !fs_1.default.existsSync((0, exec_wrapper_1.resultPath)(logs, gen.controllerJobId, gen.spawnNonce));
|
|
161
|
+
}
|
|
162
|
+
/** Recupera tanto crash-before-spawn como crash-after-spawn-before-journal.
|
|
163
|
+
* Claim sin identidad queda ambiguo y se conserva bajo custodia tras la gracia;
|
|
164
|
+
* nunca se resuelve lanzando otro token/nonce a ciegas. */
|
|
165
|
+
function ensureControllerGeneration(repoRoot, branch, provider, resumePrompt, spawner, ambiguityGraceMs) {
|
|
166
|
+
if (collectControllerGeneration(repoRoot, branch))
|
|
167
|
+
return;
|
|
168
|
+
let gen = activeGeneration(requireState(repoRoot, branch));
|
|
169
|
+
if (gen === undefined)
|
|
170
|
+
return;
|
|
171
|
+
if (gen.processRef !== undefined || gen.wrapperRef !== undefined)
|
|
172
|
+
return;
|
|
173
|
+
if (gen.controllerJobId === undefined || gen.spawnNonce === undefined) {
|
|
174
|
+
launchControllerGeneration(repoRoot, branch, provider, resumePrompt, spawner);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const logs = (0, paths_1.logsDir)(repoRoot, branch);
|
|
178
|
+
if (fs_1.default.existsSync((0, exec_wrapper_1.resultPath)(logs, gen.controllerJobId, gen.spawnNonce))) {
|
|
179
|
+
enterCustody(repoRoot, branch, `controller ${gen.n} termino sin identidad adoptable: decision explicita requerida`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const claim = (0, exec_wrapper_1.claimPath)(logs, gen.controllerJobId, gen.spawnNonce);
|
|
183
|
+
if (fs_1.default.existsSync(claim)) {
|
|
184
|
+
let claimAgeMs = Number.POSITIVE_INFINITY;
|
|
185
|
+
try {
|
|
186
|
+
claimAgeMs = Date.now() - fs_1.default.statSync(claim).mtimeMs;
|
|
187
|
+
}
|
|
188
|
+
catch { /* si no se puede probar reciente, falla cerrado */ }
|
|
189
|
+
if (claimAgeMs > ambiguityGraceMs) {
|
|
190
|
+
enterCustody(repoRoot, branch, `claim de controller ${gen.n} sin identidad demostrable: custodia`);
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// El intent ya estaba durable pero el spawn no ocurrio: es seguro reemitir
|
|
195
|
+
// exactamente el mismo nonce. Si el wrapper original solo estaba demorado,
|
|
196
|
+
// su claim wx arbitra cual de ambos ejecuta.
|
|
197
|
+
launchControllerGeneration(repoRoot, branch, gen.provider ?? provider, gen.resumePrompt ?? resumePrompt, spawner);
|
|
198
|
+
}
|
|
199
|
+
/** Custodia (R4.5): ciclo BLOCKED con razon auditada. QUIEN NO HACE NADA:
|
|
200
|
+
* no mata, no relanza, no libera lock — el loop sigue vivo auditando. */
|
|
201
|
+
function enterCustody(repoRoot, branch, reason) {
|
|
202
|
+
const s = requireState(repoRoot, branch);
|
|
203
|
+
if (s.cycle.status !== 'BLOCKED' || s.cycle.blockedReason !== reason) {
|
|
204
|
+
s.cycle.status = 'BLOCKED';
|
|
205
|
+
s.cycle.blockedReason = reason;
|
|
206
|
+
(0, store_1.writeJournal)(repoRoot, branch, s);
|
|
207
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'custody-blocked', reason });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/** Resolucion de la generacion vigente (R4.2b). Con los adapters de R1,
|
|
211
|
+
* 'safe' solo ocurre con muerte probada; la escalera queda para adapters
|
|
212
|
+
* que puedan observar llamadas en vuelo. */
|
|
213
|
+
async function resolveGeneration(repoRoot, branch, adapter, grace) {
|
|
214
|
+
const gen = activeGeneration(requireState(repoRoot, branch));
|
|
215
|
+
if (gen?.processRef === undefined)
|
|
216
|
+
return 'proven-dead'; // nunca se lanzo: relanzar es seguro
|
|
217
|
+
const ref = gen.processRef;
|
|
218
|
+
if (adapter.safeToReplace(ref) !== 'safe') {
|
|
219
|
+
enterCustody(repoRoot, branch, 'stall confirmado pero el adapter no afirma safeToReplace: custodia sin matar (R4.2b)');
|
|
220
|
+
return 'custody-blocked';
|
|
221
|
+
}
|
|
222
|
+
// muerte confirmada sin intervencion nuestra: no corresponde marcarla
|
|
223
|
+
// 'terminated' aca, porque nadie la mato (esa transicion es solo para la
|
|
224
|
+
// rama con kill real, mas abajo). Queda 'active' hasta el proximo
|
|
225
|
+
// beginGeneration exitoso, que la supersede al relanzar. CAVEAT para
|
|
226
|
+
// Task 18 (superviseController): beginGeneration esta gateado por
|
|
227
|
+
// backoff/relanzamiento — si el backoff se agota, el loop entra en
|
|
228
|
+
// custodia SIN llamar beginGeneration, y esta generacion queda 'active'
|
|
229
|
+
// en el journal indefinidamente (superseded, nunca terminated, cuando
|
|
230
|
+
// eventualmente se relance). Hoy es inerte (ningun consumidor lee
|
|
231
|
+
// generation.state para reportar), pero cualquier futuro consumidor de
|
|
232
|
+
// observabilidad debe cruzar con cycle.status, no confiar en
|
|
233
|
+
// generation.state === 'active' como "puede seguir vivo un proceso".
|
|
234
|
+
if (!(0, process_1.refIsAlive)(ref) && (0, process_1.groupIsGone)(ref.processGroup))
|
|
235
|
+
return 'proven-dead';
|
|
236
|
+
// vivo + safe positivo (adapters futuros): SIGTERM -> gracia -> SIGKILL, confirmando
|
|
237
|
+
const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, grace);
|
|
238
|
+
if (!confirmed) {
|
|
239
|
+
enterCustody(repoRoot, branch, 'terminacion inconfirmable: custodia (R4.2b caso c)');
|
|
240
|
+
return 'custody-blocked';
|
|
241
|
+
}
|
|
242
|
+
const s = (0, store_1.readJournal)(repoRoot, branch).state;
|
|
243
|
+
const g = activeGeneration(s);
|
|
244
|
+
if (g !== undefined)
|
|
245
|
+
g.state = 'terminated';
|
|
246
|
+
(0, store_1.writeJournal)(repoRoot, branch, s);
|
|
247
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'generation-terminated-confirmed', n: g?.n });
|
|
248
|
+
return 'terminated-confirmed';
|
|
249
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerWatchCommand = registerWatchCommand;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const init_1 = require("./init");
|
|
6
|
+
const supervisor_1 = require("./supervisor");
|
|
7
|
+
const process_1 = require("../../core/journal/process");
|
|
8
|
+
function currentBranch(cwd) {
|
|
9
|
+
// stdio explicito (ver EXEC_STDIO en journal/process.ts): evita el relay
|
|
10
|
+
// default de execFileSync del stderr de git hacia el stderr del llamante,
|
|
11
|
+
// que EPIPE-crashea si ese fd es un pipe roto.
|
|
12
|
+
const b = (0, child_process_1.execFileSync)('git', ['branch', '--show-current'], { cwd, encoding: 'utf8', stdio: process_1.EXEC_STDIO }).trim();
|
|
13
|
+
if (b.length === 0)
|
|
14
|
+
throw new Error('no hay rama actual (HEAD detached): el journal es por rama');
|
|
15
|
+
return b;
|
|
16
|
+
}
|
|
17
|
+
function minutes(flag, raw) {
|
|
18
|
+
const n = Number(raw);
|
|
19
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
20
|
+
throw new Error(`${flag} requiere un numero de minutos > 0`);
|
|
21
|
+
return n * 60000;
|
|
22
|
+
}
|
|
23
|
+
function registerWatchCommand(program) {
|
|
24
|
+
program
|
|
25
|
+
.command('watch')
|
|
26
|
+
.description('supervisor durable: ejecuta jobs, releva controladores caidos, nunca mata trabajo vivo')
|
|
27
|
+
.option('--init', 'bootstrap: crea el journal de la rama actual, detecta verificadores y sale')
|
|
28
|
+
.option('--provider <p>', 'codex | claude-code', 'codex')
|
|
29
|
+
.option('--heartbeat-timeout <min>', 'minutos de silencio de heartbeat', '5')
|
|
30
|
+
.option('--activity-window <min>', 'minutos extra sin actividad de proceso', '10')
|
|
31
|
+
.action(async (opts) => {
|
|
32
|
+
const repo = process.cwd();
|
|
33
|
+
const branch = currentBranch(repo);
|
|
34
|
+
if (opts.init) {
|
|
35
|
+
const out = (0, init_1.initWatch)(repo, branch);
|
|
36
|
+
process.stdout.write(`journal inicializado para ${branch}; verificadores requeridos: ${JSON.stringify(out.requiredVerifiers)}\n`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const cfg = {
|
|
40
|
+
...supervisor_1.DEFAULT_SUPERVISOR_CONFIG,
|
|
41
|
+
provider: opts.provider,
|
|
42
|
+
heartbeatTimeoutMs: minutes('--heartbeat-timeout', opts.heartbeatTimeout),
|
|
43
|
+
activityWindowMs: minutes('--activity-window', opts.activityWindow),
|
|
44
|
+
};
|
|
45
|
+
process.stdout.write(`awm watch: supervisor activo (${cfg.provider}) — Ctrl-C para terminar\n`);
|
|
46
|
+
await (0, supervisor_1.runSupervisorLoop)(repo, branch, cfg);
|
|
47
|
+
process.stdout.write('gate verde: ciclo COMPLETE — drenado, lock liberado, apagando\n');
|
|
48
|
+
});
|
|
49
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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.detectRequiredVerifiers = detectRequiredVerifiers;
|
|
7
|
+
exports.ensureJournalGitignored = ensureJournalGitignored;
|
|
8
|
+
exports.initWatch = initWatch;
|
|
9
|
+
// Bootstrap unico writer (R4.1) + validacion MECANICA plan-vs-repo (R1.4b,
|
|
10
|
+
// bloqueador 5): la config real del repo determina los verificadores exigidos.
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const store_1 = require("../../core/journal/store");
|
|
14
|
+
function detectRequiredVerifiers(repoRoot) {
|
|
15
|
+
const kinds = new Set();
|
|
16
|
+
const visit = (dir) => {
|
|
17
|
+
const sensors = path_1.default.join(dir, '.awm', 'sensors.json');
|
|
18
|
+
if (fs_1.default.existsSync(sensors))
|
|
19
|
+
kinds.add('sensors');
|
|
20
|
+
let entries;
|
|
21
|
+
try {
|
|
22
|
+
entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (entry.isSymbolicLink())
|
|
29
|
+
continue;
|
|
30
|
+
if (entry.isFile() && entry.name === 'package.json') {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, entry.name), 'utf8'));
|
|
33
|
+
if (typeof pkg === 'object' && pkg !== null && typeof pkg.scripts === 'object' && pkg.scripts !== null && typeof pkg.scripts.test === 'string')
|
|
34
|
+
kinds.add('test');
|
|
35
|
+
}
|
|
36
|
+
catch { /* package ilegible: no prueba disponibilidad */ }
|
|
37
|
+
}
|
|
38
|
+
if (entry.isDirectory() && !['node_modules', '.git', '.awm'].includes(entry.name))
|
|
39
|
+
visit(path_1.default.join(dir, entry.name));
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
visit(repoRoot);
|
|
43
|
+
return ['test', 'sensors'].filter((kind) => kinds.has(kind));
|
|
44
|
+
}
|
|
45
|
+
/** El journal es gitignoreado (R1.1): sus escrituras jamas alteran fingerprints. */
|
|
46
|
+
function ensureJournalGitignored(repoRoot) {
|
|
47
|
+
const gi = path_1.default.join(repoRoot, '.gitignore');
|
|
48
|
+
let current = '';
|
|
49
|
+
try {
|
|
50
|
+
current = fs_1.default.readFileSync(gi, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
current = '';
|
|
54
|
+
}
|
|
55
|
+
if (!current.split('\n').some((l) => l.trim() === '.awm/' || l.trim() === '.awm')) {
|
|
56
|
+
fs_1.default.writeFileSync(gi, current.length > 0 && !current.endsWith('\n') ? `${current}\n.awm/\n` : `${current}.awm/\n`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function initWatch(repoRoot, branch) {
|
|
60
|
+
ensureJournalGitignored(repoRoot);
|
|
61
|
+
(0, store_1.initJournal)(repoRoot, branch);
|
|
62
|
+
const required = detectRequiredVerifiers(repoRoot);
|
|
63
|
+
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
64
|
+
if (r.corrupt || r.state === null)
|
|
65
|
+
throw new Error('journal corrupto tras init: no se continua (R1.6)');
|
|
66
|
+
const s = r.state;
|
|
67
|
+
if (JSON.stringify(s.requiredVerifiers) !== JSON.stringify(required)) {
|
|
68
|
+
s.requiredVerifiers = required;
|
|
69
|
+
(0, store_1.writeJournal)(repoRoot, branch, s);
|
|
70
|
+
}
|
|
71
|
+
return { requiredVerifiers: required };
|
|
72
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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.LockBlockedError = void 0;
|
|
7
|
+
exports.acquireLock = acquireLock;
|
|
8
|
+
exports.releaseLock = releaseLock;
|
|
9
|
+
exports.verifyBranchInvariant = verifyBranchInvariant;
|
|
10
|
+
// Exclusion fisica de supervisores (R4.1): un solo writer por worktree FISICO.
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
15
|
+
const paths_1 = require("../../core/journal/paths");
|
|
16
|
+
const process_1 = require("../../core/journal/process");
|
|
17
|
+
const types_1 = require("../../core/journal/types");
|
|
18
|
+
const atomic_file_1 = require("../../core/atomic-file");
|
|
19
|
+
/** Identidad indemostrable en el lock: BLOQUEAR con error DISTINTO — el
|
|
20
|
+
* operador decide con evidencia; el codigo jamas reclama lo que no puede
|
|
21
|
+
* probar muerto (bloqueador 6 de la review). */
|
|
22
|
+
class LockBlockedError extends Error {
|
|
23
|
+
constructor(message) { super(message); this.name = 'LockBlockedError'; }
|
|
24
|
+
}
|
|
25
|
+
exports.LockBlockedError = LockBlockedError;
|
|
26
|
+
function acquireLock(repoRoot) {
|
|
27
|
+
const lp = (0, paths_1.supervisorLockPath)(repoRoot);
|
|
28
|
+
fs_1.default.mkdirSync(path_1.default.dirname(lp), { recursive: true, mode: 0o700 });
|
|
29
|
+
const self = (0, process_1.captureSelfRef)(crypto_1.default.randomBytes(8).toString('hex'));
|
|
30
|
+
const body = JSON.stringify(self, null, 2) + '\n';
|
|
31
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
const fd = fs_1.default.openSync(lp, 'wx', 0o600); // creacion EXCLUSIVA real: sin ventana existsSync/write
|
|
34
|
+
try {
|
|
35
|
+
fs_1.default.writeFileSync(fd, body);
|
|
36
|
+
fs_1.default.fsyncSync(fd);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
fs_1.default.closeSync(fd);
|
|
40
|
+
}
|
|
41
|
+
(0, atomic_file_1.fsyncDirSync)(path_1.default.dirname(lp));
|
|
42
|
+
return { ref: self, path: lp };
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error.code !== 'EEXIST')
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
// EEXIST: leer + validar identidad COMPLETA
|
|
49
|
+
let prior;
|
|
50
|
+
try {
|
|
51
|
+
prior = JSON.parse(fs_1.default.readFileSync(lp, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new LockBlockedError(`lock ilegible en ${lp}: identidad indemostrable — BLOQUEADO (no se reclama; intervencion manual con evidencia)`);
|
|
55
|
+
}
|
|
56
|
+
if (!(0, types_1.isWellFormedProcessRef)(prior)) {
|
|
57
|
+
throw new LockBlockedError(`lock con shape invalido en ${lp}: identidad indemostrable — BLOQUEADO (no se reclama)`);
|
|
58
|
+
}
|
|
59
|
+
if ((0, process_1.refIsAlive)(prior)) {
|
|
60
|
+
throw new Error(`supervisor activo (pid ${prior.pid}) sobre este worktree`);
|
|
61
|
+
}
|
|
62
|
+
// Muerto PROBADO (la tupla completa no matchea a ningun proceso vivo):
|
|
63
|
+
// rm + reintento wx UNA sola vez — si la carrera persiste, error.
|
|
64
|
+
process.stderr.write('awm watch: lock previo con identidad muerta probada — reclamando\n');
|
|
65
|
+
fs_1.default.rmSync(lp, { force: true });
|
|
66
|
+
}
|
|
67
|
+
throw new Error('no se pudo adquirir el lock tras reintento unico (carrera persistente)');
|
|
68
|
+
}
|
|
69
|
+
function releaseLock(repoRoot, handle) {
|
|
70
|
+
const lp = handle.path;
|
|
71
|
+
try {
|
|
72
|
+
const onDisk = JSON.parse(fs_1.default.readFileSync(lp, 'utf8'));
|
|
73
|
+
if (onDisk.spawnNonce === handle.ref.spawnNonce)
|
|
74
|
+
fs_1.default.rmSync(lp);
|
|
75
|
+
}
|
|
76
|
+
catch { /* ya ausente o ilegible: no tocar lo que no es nuestro */ }
|
|
77
|
+
}
|
|
78
|
+
/** WHILE el supervisor este activo, el cambio de rama del worktree se bloquea:
|
|
79
|
+
* gate/reconcile/watch verifican rama actual == rama del journal (R1.1). */
|
|
80
|
+
function verifyBranchInvariant(repoRoot, journalBranch) {
|
|
81
|
+
// stdio explicito (ver EXEC_STDIO en journal/process.ts): sin esto, execFileSync
|
|
82
|
+
// relayea el stderr del subproceso git al stderr DEL SUPERVISOR — si ese fd es
|
|
83
|
+
// un pipe roto, el relay dispara un EPIPE no catcheable que crashea el proceso
|
|
84
|
+
// ENTERO (verifyBranchInvariant corre en cada tick, via supervisor.ts).
|
|
85
|
+
const current = (0, child_process_1.execFileSync)('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8', stdio: process_1.EXEC_STDIO }).trim();
|
|
86
|
+
if (current !== journalBranch) {
|
|
87
|
+
throw new Error(`BLOCKED: rama actual (${current}) != rama del journal (${journalBranch}) — cambio de rama con journal activo (R1.1)`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
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.DEFAULT_STALL_OBSERVATION_MS = void 0;
|
|
7
|
+
exports.defaultWrapperSpawner = defaultWrapperSpawner;
|
|
8
|
+
exports.spawnPendingWrappers = spawnPendingWrappers;
|
|
9
|
+
exports.collectAndReconcile = collectAndReconcile;
|
|
10
|
+
exports.runnerTick = runnerTick;
|
|
11
|
+
// Runner concurrente (bloqueador 3): el supervisor spawnea wrappers DETACHED y
|
|
12
|
+
// jamas los espera; el avance viene del scan de sidecars en cada tick.
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
16
|
+
const store_1 = require("../../core/journal/store");
|
|
17
|
+
const paths_1 = require("../../core/journal/paths");
|
|
18
|
+
const process_1 = require("../../core/journal/process");
|
|
19
|
+
const exec_wrapper_1 = require("../job/exec-wrapper");
|
|
20
|
+
const reconcile_1 = require("../job/reconcile");
|
|
21
|
+
const types_1 = require("../../core/journal/types");
|
|
22
|
+
/** Spawner real: `awm job exec-wrapper` como proceso EXTERNO detached via el
|
|
23
|
+
* CLI compilado. fire-and-forget: unref + stdio propio del wrapper. */
|
|
24
|
+
function defaultWrapperSpawner(cliEntry = path_1.default.resolve(__dirname, '..', '..', 'index.js')) {
|
|
25
|
+
return (job, nonce, logsRoot, repoRoot) => {
|
|
26
|
+
const argv = [
|
|
27
|
+
process.execPath, cliEntry, 'job', 'exec-wrapper',
|
|
28
|
+
'--job', job.id, '--nonce', nonce, '--logs', logsRoot, '--cwd', job.cwd,
|
|
29
|
+
'--', ...job.argv,
|
|
30
|
+
];
|
|
31
|
+
const { child, ref } = (0, process_1.spawnStructured)(argv, repoRoot, nonce);
|
|
32
|
+
child.unref(); // el supervisor NO espera; el wrapper sobrevive incluso si el supervisor muere
|
|
33
|
+
return ref;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** spawn-intent + nonce persistidos ANTES de cualquier spawn (R1.8): si el
|
|
37
|
+
* supervisor muere entre journal y spawn, el replay decide por claim. */
|
|
38
|
+
function spawnPendingWrappers(repoRoot, branch, spawner) {
|
|
39
|
+
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
40
|
+
if (r.corrupt || r.state === null)
|
|
41
|
+
throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
|
|
42
|
+
const s = r.state;
|
|
43
|
+
const received = Object.values(s.jobs).filter((j) => j.executionState === 'received');
|
|
44
|
+
if (received.length === 0)
|
|
45
|
+
return 0;
|
|
46
|
+
for (const j of received) {
|
|
47
|
+
j.spawnNonce = crypto_1.default.randomBytes(8).toString('hex');
|
|
48
|
+
j.executionState = 'spawn-intent';
|
|
49
|
+
j.phaseTimestamps['spawn-intent'] = new Date().toISOString();
|
|
50
|
+
}
|
|
51
|
+
(0, store_1.writeJournal)(repoRoot, branch, s); // intent DURABLE antes del primer spawn
|
|
52
|
+
const logs = (0, paths_1.logsDir)(repoRoot, branch);
|
|
53
|
+
for (const j of received) {
|
|
54
|
+
// Un spawn que lanza sincronicamente NO debe abortar el resto del lote:
|
|
55
|
+
// el spawn-intent de cada job ya quedo durable arriba: el proximo tick
|
|
56
|
+
// lo recoge (claim=>claimed) o la matriz lo declara never-started.
|
|
57
|
+
try {
|
|
58
|
+
spawner(j, j.spawnNonce, logs, repoRoot);
|
|
59
|
+
}
|
|
60
|
+
catch { /* ver comentario: el intent ya persistio, el proximo tick decide */ }
|
|
61
|
+
}
|
|
62
|
+
return received.length;
|
|
63
|
+
}
|
|
64
|
+
const SCANNABLE = ['spawn-intent', 'claimed', 'running'];
|
|
65
|
+
function lastPhaseAgeMs(j) {
|
|
66
|
+
const stamps = Object.values(j.phaseTimestamps).map((t) => Date.parse(t)).filter((n) => !Number.isNaN(n));
|
|
67
|
+
if (stamps.length === 0)
|
|
68
|
+
return Number.POSITIVE_INFINITY;
|
|
69
|
+
return Date.now() - Math.max(...stamps);
|
|
70
|
+
}
|
|
71
|
+
// Observacional puro (R3.5): la duracion NUNCA produce transicion terminal.
|
|
72
|
+
// Señal de progreso = mtime del log del job (analogo al "output consumido por
|
|
73
|
+
// el supervisor" del stall de CONTROLADOR en generations.ts, pero para el job
|
|
74
|
+
// mismo). Sin campo nuevo de tracking de bytes: el propio `lastProgressAt` ya
|
|
75
|
+
// persistido sirve de referencia — si el log crecio (mtime mas nuevo que la
|
|
76
|
+
// ultima marca), hay progreso; si no, y ya paso el umbral, es sospecha de
|
|
77
|
+
// estancamiento. `suspected-stall` jamas mata ni reintenta nada — eso solo lo
|
|
78
|
+
// hace `awm job reap`, invocado por un humano.
|
|
79
|
+
exports.DEFAULT_STALL_OBSERVATION_MS = 5 * 60000; // orden de magnitud de heartbeatTimeoutMs, pero independiente (jobs != controlador)
|
|
80
|
+
function updateJobObservation(j, logs, stallObservationMs) {
|
|
81
|
+
if (j.executionState !== 'running')
|
|
82
|
+
return;
|
|
83
|
+
const nonce = j.spawnNonce ?? 'sin-nonce';
|
|
84
|
+
let mtimeMs = 0;
|
|
85
|
+
try {
|
|
86
|
+
mtimeMs = fs_1.default.statSync((0, exec_wrapper_1.logPath)(logs, j.id, nonce)).mtimeMs;
|
|
87
|
+
}
|
|
88
|
+
catch { /* log aun no existe: sin progreso nuevo */ }
|
|
89
|
+
const baselineIso = j.lastProgressAt ?? j.phaseTimestamps.running;
|
|
90
|
+
const baselineMs = baselineIso !== undefined ? Date.parse(baselineIso) : Number.NEGATIVE_INFINITY;
|
|
91
|
+
if (mtimeMs > baselineMs) {
|
|
92
|
+
j.lastProgressAt = new Date().toISOString();
|
|
93
|
+
j.observationState = 'progressing';
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() - baselineMs > stallObservationMs) {
|
|
97
|
+
j.observationState = 'suspected-stall';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Cada tick: sidecars primero (claim=>claimed, identity=>running con identidad
|
|
101
|
+
* REAL, result=>exited+verdict), reconciliacion (matriz unica) despues — solo
|
|
102
|
+
* para jobs fuera de su ventana de gracia post-spawn. */
|
|
103
|
+
function collectAndReconcile(repoRoot, branch, opts = {}) {
|
|
104
|
+
const graceMs = opts.reconcileGraceMs ?? 10000;
|
|
105
|
+
const stallObservationMs = opts.stallObservationMs ?? exports.DEFAULT_STALL_OBSERVATION_MS;
|
|
106
|
+
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
107
|
+
if (r.corrupt || r.state === null)
|
|
108
|
+
throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
|
|
109
|
+
const s = r.state;
|
|
110
|
+
const logs = (0, paths_1.logsDir)(repoRoot, branch);
|
|
111
|
+
let advanced = 0;
|
|
112
|
+
let observationTouched = false;
|
|
113
|
+
for (const j of Object.values(s.jobs)) {
|
|
114
|
+
if (!SCANNABLE.includes(j.executionState))
|
|
115
|
+
continue;
|
|
116
|
+
const nonce = j.spawnNonce ?? 'sin-nonce';
|
|
117
|
+
if (fs_1.default.existsSync((0, exec_wrapper_1.claimPath)(logs, j.id, nonce)) && fs_1.default.existsSync((0, exec_wrapper_1.resultPath)(logs, j.id, nonce))) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.resultPath)(logs, j.id, nonce), 'utf8'));
|
|
120
|
+
if (typeof parsed.exitCode !== 'number' || typeof parsed.endedAt !== 'string' || typeof parsed.resultPath !== 'string')
|
|
121
|
+
continue;
|
|
122
|
+
j.executionState = 'exited';
|
|
123
|
+
j.result = parsed;
|
|
124
|
+
j.verdict = parsed.exitCode === 0 ? 'pass' : 'fail';
|
|
125
|
+
j.phaseTimestamps.exited = j.phaseTimestamps.exited ?? new Date().toISOString();
|
|
126
|
+
advanced++;
|
|
127
|
+
}
|
|
128
|
+
catch { /* resultado ilegible: la matriz decidira (unprovable) */ }
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (fs_1.default.existsSync((0, exec_wrapper_1.identityPath)(logs, j.id, nonce)) && j.executionState !== 'running') {
|
|
132
|
+
try {
|
|
133
|
+
const identity = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.identityPath)(logs, j.id, nonce), 'utf8'));
|
|
134
|
+
if (identity.jobId === j.id && identity.nonce === nonce
|
|
135
|
+
&& (0, types_1.isWellFormedProcessRef)(identity.wrapper) && (0, types_1.isWellFormedProcessRef)(identity.command)
|
|
136
|
+
&& identity.wrapper.spawnNonce === nonce && identity.command.spawnNonce === nonce
|
|
137
|
+
&& identity.command.argvDigest === (0, process_1.argvDigest)(j.argv)) {
|
|
138
|
+
j.wrapperRef = identity.wrapper;
|
|
139
|
+
j.processRef = identity.command; // identidad REAL, nunca pid 0 (bloqueador 3)
|
|
140
|
+
j.executionState = 'running';
|
|
141
|
+
j.phaseTimestamps.running = new Date().toISOString();
|
|
142
|
+
j.lastProgressAt = new Date().toISOString();
|
|
143
|
+
advanced++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch { /* sidecar a medio escribir: proximo tick */ }
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (fs_1.default.existsSync((0, exec_wrapper_1.claimPath)(logs, j.id, nonce)) && j.executionState === 'spawn-intent') {
|
|
150
|
+
j.executionState = 'claimed';
|
|
151
|
+
j.phaseTimestamps.claimed = new Date().toISOString();
|
|
152
|
+
advanced++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// observationState (R3.5): puramente informativo, corre para TODO job aun
|
|
156
|
+
// 'running' tras el scan de arriba — jamas toca executionState ni participa
|
|
157
|
+
// de la matriz de abajo.
|
|
158
|
+
for (const j of Object.values(s.jobs)) {
|
|
159
|
+
const before = `${j.observationState}|${j.lastProgressAt ?? ''}`;
|
|
160
|
+
updateJobObservation(j, logs, stallObservationMs);
|
|
161
|
+
if (`${j.observationState}|${j.lastProgressAt ?? ''}` !== before)
|
|
162
|
+
observationTouched = true;
|
|
163
|
+
}
|
|
164
|
+
// Matriz unica SOLO fuera de la gracia post-spawn: un wrapper recien
|
|
165
|
+
// spawneado que aun no claimeo NO es un never-started.
|
|
166
|
+
const out = (0, reconcile_1.reconcileJobs)(s, logs, { eligible: (j) => lastPhaseAgeMs(j) > graceMs });
|
|
167
|
+
if (advanced > 0 || observationTouched || out.decisions.some((d) => d.action !== 'still-alive')) {
|
|
168
|
+
(0, store_1.writeJournal)(repoRoot, branch, s);
|
|
169
|
+
}
|
|
170
|
+
return { advanced, decisions: out.decisions };
|
|
171
|
+
}
|
|
172
|
+
function runnerTick(repoRoot, branch, spawner, opts = {}) {
|
|
173
|
+
const collected = collectAndReconcile(repoRoot, branch, opts);
|
|
174
|
+
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
175
|
+
if (r.corrupt || r.state === null)
|
|
176
|
+
throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
|
|
177
|
+
const logs = (0, paths_1.logsDir)(repoRoot, branch);
|
|
178
|
+
for (const decision of collected.decisions) {
|
|
179
|
+
if (decision.action !== 'retry-same-intent')
|
|
180
|
+
continue;
|
|
181
|
+
const job = r.state.jobs[decision.jobId];
|
|
182
|
+
if (job?.executionState !== 'spawn-intent' || job.spawnNonce === undefined)
|
|
183
|
+
continue;
|
|
184
|
+
try {
|
|
185
|
+
spawner(job, job.spawnNonce, logs, repoRoot);
|
|
186
|
+
}
|
|
187
|
+
catch { /* el mismo intent durable se reintentara en otro tick */ }
|
|
188
|
+
}
|
|
189
|
+
const spawned = spawnPendingWrappers(repoRoot, branch, spawner);
|
|
190
|
+
return { spawned, advanced: collected.advanced, decisions: collected.decisions };
|
|
191
|
+
}
|