agentic-workflow-manager 3.4.0 → 3.6.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/sensors/exec.js +121 -0
- package/dist/src/commands/sensors/index.js +4 -4
- package/dist/src/commands/sensors/run.js +124 -71
- 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/sensors/exec-fixtures.js +24 -0
- package/dist/tests/commands/sensors/exec.test.js +91 -0
- package/dist/tests/commands/sensors/run-inconclusive.test.js +55 -66
- package/dist/tests/commands/sensors/run-partial.test.js +225 -0
- package/dist/tests/commands/sensors/run-tool-missing.test.js +6 -6
- package/dist/tests/commands/sensors/run.test.js +64 -81
- 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,352 @@
|
|
|
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.consumePendingRequests = consumePendingRequests;
|
|
7
|
+
// Consumo transaccional de requests (bloqueador 4): mutar estado ->
|
|
8
|
+
// writeJournal -> RECIEN AHI borrar archivos. El replay es seguro por
|
|
9
|
+
// requestId + idempotencyKey + digest.
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
12
|
+
const store_1 = require("../../core/journal/store");
|
|
13
|
+
const requests_1 = require("../../core/journal/requests");
|
|
14
|
+
const paths_1 = require("../../core/journal/paths");
|
|
15
|
+
const atomic_file_1 = require("../../core/atomic-file");
|
|
16
|
+
const redact_1 = require("../../core/journal/redact");
|
|
17
|
+
function now() { return new Date().toISOString(); }
|
|
18
|
+
const VERIFICATION_KINDS = ['test', 'lint', 'sensors', 'review', 'qa', 'interlock'];
|
|
19
|
+
function verificationItems(value, field) {
|
|
20
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'object' && item !== null
|
|
21
|
+
&& typeof item.id === 'string'
|
|
22
|
+
&& VERIFICATION_KINDS.includes(item.kind)
|
|
23
|
+
&& (item.satisfiedBy === undefined || typeof item.satisfiedBy === 'string'))) {
|
|
24
|
+
throw new Error(`${field} requiere items de verificacion validos`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function reviewObligations(value, taskId) {
|
|
29
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'object' && item !== null
|
|
30
|
+
&& typeof item.id === 'string'
|
|
31
|
+
&& (item.kind === 'spec' || item.kind === 'quality'))) {
|
|
32
|
+
throw new Error('reviewObligations requiere obligaciones spec|quality validas');
|
|
33
|
+
}
|
|
34
|
+
return value.map((item) => ({ ...item, taskId }));
|
|
35
|
+
}
|
|
36
|
+
function stringArray(value, field) {
|
|
37
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string'))
|
|
38
|
+
throw new Error(`${field} requiere array de strings`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
function linkSatisfies(s, itemId, jobId) {
|
|
42
|
+
const items = [...s.tasks.flatMap((t) => t.verificationPlan), ...s.cycleVerificationPlan];
|
|
43
|
+
const item = items.find((i) => i.id === itemId);
|
|
44
|
+
if (item === undefined)
|
|
45
|
+
throw new Error(`VerificationItem desconocido: ${itemId}`);
|
|
46
|
+
item.satisfiedBy = jobId;
|
|
47
|
+
}
|
|
48
|
+
function applyRequestToState(s, env, digest) {
|
|
49
|
+
const base = { requestId: env.requestId, idempotencyKey: env.idempotencyKey, payloadDigest: digest };
|
|
50
|
+
if (env.kind === 'controller-heartbeat') {
|
|
51
|
+
s.controllerHeartbeatAt = now();
|
|
52
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (env.kind === 'job-request') {
|
|
56
|
+
// get-or-create por idempotencyKey (RNF-T.7); duplicado => applyOutcome
|
|
57
|
+
// registra el ALIAS con el mismo resultRef (Task 8).
|
|
58
|
+
const prior = Object.values(s.appliedRequests).find((a) => a.idempotencyKey === env.idempotencyKey && a.outcome === 'applied');
|
|
59
|
+
if (prior !== undefined) {
|
|
60
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const p = env.payload;
|
|
64
|
+
if (!Array.isArray(p.argv) || !p.argv.every((arg) => typeof arg === 'string') || p.argv.length === 0)
|
|
65
|
+
throw new Error('job-request requiere argv no vacio');
|
|
66
|
+
if (typeof p.fingerprint !== 'string' || typeof p.commandDigest !== 'string')
|
|
67
|
+
throw new Error('job-request requiere fingerprint y commandDigest');
|
|
68
|
+
// Un mismo resultado mecanico puede satisfacer mas de un item. La
|
|
69
|
+
// request sigue teniendo identidad propia, pero no duplica ejecucion.
|
|
70
|
+
const equivalent = Object.values(s.jobs).find((j) => j.fingerprint === p.fingerprint && j.commandDigest === p.commandDigest
|
|
71
|
+
&& (['received', 'spawn-intent', 'claimed', 'running'].includes(j.executionState)
|
|
72
|
+
|| (j.executionState === 'exited' && j.verdict === 'pass')));
|
|
73
|
+
if (equivalent !== undefined) {
|
|
74
|
+
if (typeof p.satisfies === 'string')
|
|
75
|
+
linkSatisfies(s, p.satisfies, equivalent.id);
|
|
76
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: equivalent.id });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const jobId = `job-${Object.keys(s.jobs).length + 1}-${crypto_1.default.randomBytes(3).toString('hex')}`;
|
|
80
|
+
const job = {
|
|
81
|
+
id: jobId,
|
|
82
|
+
fingerprint: String(p.fingerprint), commandDigest: String(p.commandDigest),
|
|
83
|
+
argv: p.argv,
|
|
84
|
+
cwd: typeof p.cwd === 'string' ? p.cwd : '.',
|
|
85
|
+
paths: p.paths === undefined ? [] : stringArray(p.paths, 'job-request paths'),
|
|
86
|
+
expandedPaths: p.expandedPaths === undefined ? [] : stringArray(p.expandedPaths, 'job-request expandedPaths'),
|
|
87
|
+
executionState: 'received', observationState: 'progressing',
|
|
88
|
+
phaseTimestamps: { received: now() },
|
|
89
|
+
...(typeof p.satisfies === 'string' ? { satisfies: p.satisfies } : {}),
|
|
90
|
+
};
|
|
91
|
+
s.jobs[jobId] = job;
|
|
92
|
+
if (typeof p.satisfies === 'string')
|
|
93
|
+
linkSatisfies(s, p.satisfies, jobId);
|
|
94
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: jobId });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (env.kind === 'register-entity') {
|
|
98
|
+
const p = env.payload;
|
|
99
|
+
if (p.entity === 'task') {
|
|
100
|
+
if (typeof p.taskId !== 'string' || p.taskId.length === 0)
|
|
101
|
+
throw new Error('register --entity task requiere taskId (string no vacio)');
|
|
102
|
+
const taskId = p.taskId;
|
|
103
|
+
if (!s.tasks.some((t) => t.id === taskId)) {
|
|
104
|
+
const plan = p.verificationPlan === undefined ? [] : verificationItems(p.verificationPlan, 'verificationPlan');
|
|
105
|
+
// R1.4b/R3.6: rechazo EN REGISTRO (no solo en gate) si el plan no
|
|
106
|
+
// cubre los verificadores mecanicamente requeridos por el repo.
|
|
107
|
+
const missingKinds = s.requiredVerifiers.filter((k) => !plan.some((item) => item.kind === k));
|
|
108
|
+
if (missingKinds.length > 0) {
|
|
109
|
+
throw new Error(`register --entity task: verificationPlan no cubre los verificadores requeridos: ${missingKinds.join(', ')}`);
|
|
110
|
+
}
|
|
111
|
+
const obligations = p.reviewObligations === undefined ? [] : reviewObligations(p.reviewObligations, taskId);
|
|
112
|
+
s.tasks.push({
|
|
113
|
+
id: taskId, title: String(p.title ?? taskId), status: 'pending', attempts: 0,
|
|
114
|
+
verificationPlan: plan, reviewObligations: obligations, createdAt: now(),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: taskId });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (p.entity === 'cycle-plan') {
|
|
121
|
+
const items = verificationItems(p.items, 'register --entity cycle-plan items');
|
|
122
|
+
// Idempotente por creacion-unica (Task 4): un re-registro defensivo
|
|
123
|
+
// (ej. tras crash sin memoria de si ya se registro) NUNCA debe pisar
|
|
124
|
+
// un plan ya existente y perder los `satisfiedBy` ya enlazados.
|
|
125
|
+
if (s.cycleVerificationPlan.length === 0) {
|
|
126
|
+
s.cycleVerificationPlan = items;
|
|
127
|
+
}
|
|
128
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (p.entity === 'dispatch') {
|
|
132
|
+
if (typeof p.dispatchId !== 'string' || p.dispatchId.length === 0)
|
|
133
|
+
throw new Error('register --entity dispatch requiere dispatchId (string no vacio)');
|
|
134
|
+
if (typeof p.taskId !== 'string' || p.taskId.length === 0)
|
|
135
|
+
throw new Error('register --entity dispatch requiere taskId (string no vacio)');
|
|
136
|
+
const dispatchId = p.dispatchId;
|
|
137
|
+
const taskId = p.taskId;
|
|
138
|
+
if (!s.tasks.some((task) => task.id === taskId))
|
|
139
|
+
throw new Error('register --entity dispatch: taskId desconocido');
|
|
140
|
+
if (!s.dispatches.some((d) => d.id === dispatchId)) {
|
|
141
|
+
s.dispatches.push({ id: dispatchId, taskId, at: now() });
|
|
142
|
+
const task = s.tasks.find((t) => t.id === taskId);
|
|
143
|
+
if (task !== undefined)
|
|
144
|
+
task.attempts += 1;
|
|
145
|
+
}
|
|
146
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: dispatchId });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (p.entity === 'task-status') {
|
|
150
|
+
if (typeof p.taskId !== 'string' || p.taskId.length === 0)
|
|
151
|
+
throw new Error('register --entity task-status requiere taskId (string no vacio)');
|
|
152
|
+
if (p.status !== 'pending' && p.status !== 'in-progress' && p.status !== 'done')
|
|
153
|
+
throw new Error('register --entity task-status requiere status pending|in-progress|done');
|
|
154
|
+
const taskId = p.taskId;
|
|
155
|
+
const status = p.status;
|
|
156
|
+
const task = s.tasks.find((t) => t.id === taskId);
|
|
157
|
+
// Fake-success eliminado: una referencia a un taskId inexistente NO
|
|
158
|
+
// es un no-op silencioso con outcome 'applied' — se rechaza (Fix 1
|
|
159
|
+
// Parte C lo captura sin tumbar el supervisor).
|
|
160
|
+
if (task === undefined)
|
|
161
|
+
throw new Error('register --entity task-status: taskId desconocido');
|
|
162
|
+
task.status = status;
|
|
163
|
+
if (status === 'done')
|
|
164
|
+
task.completedAt = now();
|
|
165
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied' });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (p.entity === 'next-action') {
|
|
169
|
+
if (typeof p.actionId !== 'string' || typeof p.type !== 'string' || typeof p.target !== 'string') {
|
|
170
|
+
throw new Error('register --entity next-action requiere actionId, type, target (strings)');
|
|
171
|
+
}
|
|
172
|
+
s.cycle.nextAction = {
|
|
173
|
+
actionId: p.actionId,
|
|
174
|
+
type: p.type,
|
|
175
|
+
target: p.target,
|
|
176
|
+
preconditions: p.preconditions === undefined ? [] : stringArray(p.preconditions, 'next-action preconditions'),
|
|
177
|
+
attempt: typeof p.attempt === 'number' ? p.attempt : 0,
|
|
178
|
+
state: p.state === 'in-progress' ? 'in-progress' : 'pending',
|
|
179
|
+
};
|
|
180
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: p.actionId });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (p.entity === 'custody-decision') {
|
|
184
|
+
if (p.decision !== 'resume' || typeof p.reason !== 'string' || p.reason.trim().length === 0) {
|
|
185
|
+
throw new Error('register --entity custody-decision requiere decision=resume y reason no vacio');
|
|
186
|
+
}
|
|
187
|
+
if (s.cycle.status !== 'BLOCKED')
|
|
188
|
+
throw new Error('custody-decision solo aplica a un ciclo BLOCKED');
|
|
189
|
+
s.custodyDecisions ??= [];
|
|
190
|
+
s.custodyDecisions.push({ at: now(), decision: 'resume', reason: p.reason, generationToken: env.generationToken });
|
|
191
|
+
for (const generation of s.generations) {
|
|
192
|
+
if (generation.state === 'active' || generation.state === 'controller-suspected-stall')
|
|
193
|
+
generation.state = 'superseded';
|
|
194
|
+
}
|
|
195
|
+
s.cycle.status = 'IN_PROGRESS';
|
|
196
|
+
s.cycle.blockedReason = undefined;
|
|
197
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied' });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`register-entity desconocida: ${String(p.entity)}`);
|
|
201
|
+
}
|
|
202
|
+
if (env.kind === 'verdict') {
|
|
203
|
+
const p = env.payload;
|
|
204
|
+
if (typeof p.verdictId !== 'string' || p.verdictId.length === 0)
|
|
205
|
+
throw new Error('verdict requiere verdictId');
|
|
206
|
+
if (typeof p.obligationId !== 'string' || p.obligationId.length === 0)
|
|
207
|
+
throw new Error('verdict requiere obligationId');
|
|
208
|
+
if (typeof p.fingerprint !== 'string' || typeof p.cwd !== 'string') {
|
|
209
|
+
throw new Error('verdict requiere evidencia de fingerprint reproducible');
|
|
210
|
+
}
|
|
211
|
+
const verdictArgv = stringArray(p.argv, 'verdict argv');
|
|
212
|
+
const verdictPaths = stringArray(p.paths, 'verdict paths');
|
|
213
|
+
if (p.result !== 'pass' && p.result !== 'fail' && p.result !== 'inconclusive')
|
|
214
|
+
throw new Error('verdict requiere result pass|fail|inconclusive');
|
|
215
|
+
const verdictId = String(p.verdictId);
|
|
216
|
+
const obligationId = String(p.obligationId);
|
|
217
|
+
if (!s.tasks.some((task) => task.reviewObligations.some((obligation) => obligation.id === obligationId))) {
|
|
218
|
+
throw new Error(`verdict refiere obligationId desconocido: ${obligationId}`);
|
|
219
|
+
}
|
|
220
|
+
if (!s.verdicts.some((v) => v.id === verdictId)) {
|
|
221
|
+
const result = p.result;
|
|
222
|
+
// R2.3: redaccion tambien en el `detail` de texto libre humano, no
|
|
223
|
+
// solo en argv — antes de cualquier escritura durable.
|
|
224
|
+
s.verdicts.push({
|
|
225
|
+
id: verdictId, obligationId, result, detail: (0, redact_1.redactText)(String(p.detail ?? '')), receivedAt: now(),
|
|
226
|
+
fingerprint: p.fingerprint, argv: verdictArgv, paths: verdictPaths, cwd: p.cwd,
|
|
227
|
+
});
|
|
228
|
+
for (const t of s.tasks) {
|
|
229
|
+
const o = t.reviewObligations.find((x) => x.id === obligationId);
|
|
230
|
+
if (o !== undefined)
|
|
231
|
+
o.verdictId = verdictId;
|
|
232
|
+
for (const item of t.verificationPlan) {
|
|
233
|
+
if (item.kind === 'review' && item.id === obligationId)
|
|
234
|
+
item.satisfiedBy = verdictId;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Veredicto adverso => FixObligation ATOMICA: misma mutacion, misma
|
|
238
|
+
// escritura de estado (R1.4c, bloqueador 5).
|
|
239
|
+
if (result !== 'pass') {
|
|
240
|
+
s.fixes.push({ id: `fix-${verdictId}`, verdictId, closed: false });
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
// pass sobre la MISMA obligacion cierra cualquier fix abierto de
|
|
244
|
+
// un veredicto adverso anterior — un ciclo real fail->fix->pass
|
|
245
|
+
// debe poder llegar a COMPLETE (bloqueador de este dispatch).
|
|
246
|
+
const priorAdverseIds = s.verdicts
|
|
247
|
+
.filter((v) => v.obligationId === obligationId && v.id !== verdictId && v.result !== 'pass')
|
|
248
|
+
.map((v) => v.id);
|
|
249
|
+
for (const fix of s.fixes) {
|
|
250
|
+
if (priorAdverseIds.includes(fix.verdictId))
|
|
251
|
+
fix.closed = true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
(0, requests_1.applyOutcome)(s, { ...base, outcome: 'applied', resultRef: verdictId });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Consume TODAS las requests pendientes en orden. ORDEN CRITICO (R1.3,
|
|
260
|
+
* bloqueador 4): (1) mutar estado, (2) writeJournal, (3) borrar archivos,
|
|
261
|
+
* (4) fsync del directorio. Solo el supervisor llama esto (single-writer). */
|
|
262
|
+
function consumePendingRequests(repoRoot, branch, activeToken) {
|
|
263
|
+
const r = (0, store_1.readJournal)(repoRoot, branch);
|
|
264
|
+
if (r.corrupt || r.state === null)
|
|
265
|
+
throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
|
|
266
|
+
let s = r.state;
|
|
267
|
+
const pending = (0, requests_1.listPendingRequests)(repoRoot, branch);
|
|
268
|
+
const processedFiles = [];
|
|
269
|
+
const deferredRenames = [];
|
|
270
|
+
let applied = 0, rejectedStale = 0, rejectedDigest = 0, rejectedInvalid = 0, corrupt = 0;
|
|
271
|
+
let dirChanged = false; // corrupt-rename O borrado normal: cualquiera muta el directorio
|
|
272
|
+
let stateTouched = false;
|
|
273
|
+
for (const p of pending) {
|
|
274
|
+
if (p.corrupt) {
|
|
275
|
+
corrupt++;
|
|
276
|
+
deferredRenames.push({ from: p.file, to: `${p.file}.corrupt` });
|
|
277
|
+
if (!s.requestProblems.some((problem) => problem.file === p.file && problem.kind === 'corrupt')) {
|
|
278
|
+
s.requestProblems.push({ file: p.file, kind: 'corrupt', detail: 'request JSON/shape invalido', at: now() });
|
|
279
|
+
}
|
|
280
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'request-corrupt', file: p.file });
|
|
281
|
+
dirChanged = true;
|
|
282
|
+
stateTouched = true;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const env = p.envelope;
|
|
286
|
+
const digest = (0, requests_1.digestOf)(env.payload);
|
|
287
|
+
if (s.appliedRequests[env.requestId] !== undefined) {
|
|
288
|
+
// replay tras crash post-journal/pre-borrado: ya aplicada, solo borrar
|
|
289
|
+
processedFiles.push(p.file);
|
|
290
|
+
dirChanged = true;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
// Fix 1: idempotencyKey reutilizada con payload DISTINTO se detecta
|
|
294
|
+
// PROACTIVAMENTE aca, antes de mutar nada — jamas dejamos que
|
|
295
|
+
// applyOutcome('applied') tire (eso encallaria al supervisor para
|
|
296
|
+
// siempre, con el archivo ofensor nunca borrado). Se rechaza visible
|
|
297
|
+
// via el outcome ya existente 'rejected-digest-mismatch'.
|
|
298
|
+
const priorSameKey = Object.values(s.appliedRequests).find((a) => a.idempotencyKey === env.idempotencyKey);
|
|
299
|
+
if (priorSameKey !== undefined && priorSameKey.payloadDigest !== digest) {
|
|
300
|
+
(0, requests_1.applyOutcome)(s, { requestId: env.requestId, idempotencyKey: env.idempotencyKey, payloadDigest: digest, outcome: 'rejected-digest-mismatch' });
|
|
301
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'request-rejected-digest-mismatch', requestId: env.requestId });
|
|
302
|
+
rejectedDigest++;
|
|
303
|
+
}
|
|
304
|
+
else if (activeToken !== null && env.generationToken !== activeToken) {
|
|
305
|
+
(0, requests_1.applyOutcome)(s, { requestId: env.requestId, idempotencyKey: env.idempotencyKey, payloadDigest: digest, outcome: 'rejected-stale-generation' });
|
|
306
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'request-rejected-stale', requestId: env.requestId });
|
|
307
|
+
rejectedStale++;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// Defensa en profundidad (Fix 1 Parte C): CUALQUIER error de
|
|
311
|
+
// validacion inesperado (Fix 6/7 u otro futuro) jamas debe tumbar
|
|
312
|
+
// al supervisor. Se trata como el .corrupt existente — visible,
|
|
313
|
+
// jamas descartado en silencio — pero con sufijo distinto porque
|
|
314
|
+
// esto es rechazo de CONTENIDO, no de forma (R1.6).
|
|
315
|
+
try {
|
|
316
|
+
// Toda request se valida/muta sobre una copia. Un fallo profundo
|
|
317
|
+
// nunca deja un estado parcialmente contaminado que luego no se
|
|
318
|
+
// pueda serializar o reintentar.
|
|
319
|
+
const candidate = structuredClone(s);
|
|
320
|
+
applyRequestToState(candidate, env, digest);
|
|
321
|
+
s = candidate;
|
|
322
|
+
applied++;
|
|
323
|
+
stateTouched = true;
|
|
324
|
+
}
|
|
325
|
+
catch (e) {
|
|
326
|
+
rejectedInvalid++;
|
|
327
|
+
deferredRenames.push({ from: p.file, to: `${p.file}.rejected` });
|
|
328
|
+
if (!s.requestProblems.some((problem) => problem.file === p.file && problem.kind === 'rejected')) {
|
|
329
|
+
s.requestProblems.push({ file: p.file, kind: 'rejected', detail: (0, redact_1.redactText)(e.message), at: now() });
|
|
330
|
+
}
|
|
331
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'request-rejected-invalid', requestId: env.requestId, detail: e.message });
|
|
332
|
+
dirChanged = true;
|
|
333
|
+
stateTouched = true;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
processedFiles.push(p.file);
|
|
338
|
+
dirChanged = true;
|
|
339
|
+
}
|
|
340
|
+
if (processedFiles.length > 0 || stateTouched) {
|
|
341
|
+
(0, store_1.writeJournal)(repoRoot, branch, s); // (2) journal ANTES del borrado
|
|
342
|
+
}
|
|
343
|
+
for (const rename of deferredRenames) {
|
|
344
|
+
if (fs_1.default.existsSync(rename.from))
|
|
345
|
+
fs_1.default.renameSync(rename.from, rename.to);
|
|
346
|
+
}
|
|
347
|
+
for (const f of processedFiles)
|
|
348
|
+
fs_1.default.rmSync(f, { force: true }); // (3)
|
|
349
|
+
if (dirChanged)
|
|
350
|
+
(0, atomic_file_1.fsyncDirSync)((0, paths_1.requestsDir)(repoRoot, branch)); // (4) — incluye batches solo-corrupt
|
|
351
|
+
return { applied, rejectedStale, rejectedDigest, rejectedInvalid, corrupt };
|
|
352
|
+
}
|
|
@@ -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
|
+
}
|