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.
Files changed (61) hide show
  1. package/dist/src/commands/job/exec-wrapper.js +136 -0
  2. package/dist/src/commands/job/export.js +94 -0
  3. package/dist/src/commands/job/gate.js +118 -0
  4. package/dist/src/commands/job/heartbeat.js +15 -0
  5. package/dist/src/commands/job/index.js +246 -0
  6. package/dist/src/commands/job/query.js +37 -0
  7. package/dist/src/commands/job/reap.js +24 -0
  8. package/dist/src/commands/job/reconcile.js +112 -0
  9. package/dist/src/commands/job/request.js +27 -0
  10. package/dist/src/commands/sensors/exec.js +121 -0
  11. package/dist/src/commands/sensors/index.js +4 -4
  12. package/dist/src/commands/sensors/run.js +124 -71
  13. package/dist/src/commands/watch/apply.js +352 -0
  14. package/dist/src/commands/watch/generations.js +249 -0
  15. package/dist/src/commands/watch/index.js +49 -0
  16. package/dist/src/commands/watch/init.js +72 -0
  17. package/dist/src/commands/watch/lock.js +89 -0
  18. package/dist/src/commands/watch/runner.js +191 -0
  19. package/dist/src/commands/watch/supervisor.js +266 -0
  20. package/dist/src/core/atomic-file.js +31 -0
  21. package/dist/src/core/export/pack.js +7 -1
  22. package/dist/src/core/journal/adapter.js +27 -0
  23. package/dist/src/core/journal/fingerprint.js +80 -0
  24. package/dist/src/core/journal/paths.js +56 -0
  25. package/dist/src/core/journal/process.js +284 -0
  26. package/dist/src/core/journal/redact.js +142 -0
  27. package/dist/src/core/journal/requests.js +132 -0
  28. package/dist/src/core/journal/store.js +107 -0
  29. package/dist/src/core/journal/types.js +165 -0
  30. package/dist/src/index.js +4 -0
  31. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  32. package/dist/tests/commands/job/export.test.js +76 -0
  33. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  34. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  35. package/dist/tests/commands/job/verbs.test.js +56 -0
  36. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  37. package/dist/tests/commands/sensors/exec-fixtures.js +24 -0
  38. package/dist/tests/commands/sensors/exec.test.js +91 -0
  39. package/dist/tests/commands/sensors/run-inconclusive.test.js +55 -66
  40. package/dist/tests/commands/sensors/run-partial.test.js +225 -0
  41. package/dist/tests/commands/sensors/run-tool-missing.test.js +6 -6
  42. package/dist/tests/commands/sensors/run.test.js +64 -81
  43. package/dist/tests/commands/watch/apply.test.js +397 -0
  44. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  45. package/dist/tests/commands/watch/generations.test.js +115 -0
  46. package/dist/tests/commands/watch/integration.test.js +124 -0
  47. package/dist/tests/commands/watch/lock.test.js +60 -0
  48. package/dist/tests/commands/watch/runner.test.js +239 -0
  49. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  50. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  51. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  52. package/dist/tests/core/journal/adapter.test.js +27 -0
  53. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  54. package/dist/tests/core/journal/paths.test.js +35 -0
  55. package/dist/tests/core/journal/process.test.js +213 -0
  56. package/dist/tests/core/journal/redact.test.js +59 -0
  57. package/dist/tests/core/journal/requests.test.js +134 -0
  58. package/dist/tests/core/journal/store.test.js +88 -0
  59. package/dist/tests/core/journal/types.test.js +78 -0
  60. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  61. 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
+ }