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.
Files changed (52) 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/watch/apply.js +352 -0
  11. package/dist/src/commands/watch/generations.js +249 -0
  12. package/dist/src/commands/watch/index.js +49 -0
  13. package/dist/src/commands/watch/init.js +72 -0
  14. package/dist/src/commands/watch/lock.js +89 -0
  15. package/dist/src/commands/watch/runner.js +191 -0
  16. package/dist/src/commands/watch/supervisor.js +266 -0
  17. package/dist/src/core/atomic-file.js +31 -0
  18. package/dist/src/core/export/pack.js +7 -1
  19. package/dist/src/core/journal/adapter.js +27 -0
  20. package/dist/src/core/journal/fingerprint.js +80 -0
  21. package/dist/src/core/journal/paths.js +56 -0
  22. package/dist/src/core/journal/process.js +284 -0
  23. package/dist/src/core/journal/redact.js +142 -0
  24. package/dist/src/core/journal/requests.js +132 -0
  25. package/dist/src/core/journal/store.js +107 -0
  26. package/dist/src/core/journal/types.js +165 -0
  27. package/dist/src/index.js +4 -0
  28. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  29. package/dist/tests/commands/job/export.test.js +76 -0
  30. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  31. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  32. package/dist/tests/commands/job/verbs.test.js +56 -0
  33. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  34. package/dist/tests/commands/watch/apply.test.js +397 -0
  35. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  36. package/dist/tests/commands/watch/generations.test.js +115 -0
  37. package/dist/tests/commands/watch/integration.test.js +124 -0
  38. package/dist/tests/commands/watch/lock.test.js +60 -0
  39. package/dist/tests/commands/watch/runner.test.js +239 -0
  40. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  41. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  42. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  43. package/dist/tests/core/journal/adapter.test.js +27 -0
  44. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  45. package/dist/tests/core/journal/paths.test.js +35 -0
  46. package/dist/tests/core/journal/process.test.js +213 -0
  47. package/dist/tests/core/journal/redact.test.js +59 -0
  48. package/dist/tests/core/journal/requests.test.js +134 -0
  49. package/dist/tests/core/journal/store.test.js +88 -0
  50. package/dist/tests/core/journal/types.test.js +78 -0
  51. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  52. package/package.json +1 -1
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.planReap = planReap;
4
+ exports.executeReap = executeReap;
5
+ const process_1 = require("../../core/journal/process");
6
+ function planReap(state) {
7
+ return Object.values(state.jobs)
8
+ .filter((j) => j.processRef !== undefined)
9
+ .map((j) => ({ jobId: j.id, pid: j.processRef.pid, aliveWithIdentity: (0, process_1.refIsAlive)(j.processRef) }));
10
+ }
11
+ async function executeReap(state, jobIds) {
12
+ const killed = [];
13
+ for (const id of jobIds) {
14
+ const j = state.jobs[id];
15
+ if (j?.processRef === undefined)
16
+ continue;
17
+ if (!(0, process_1.refIsAlive)(j.processRef))
18
+ continue; // identidad no confirmada => ni una senial (R2.1)
19
+ const dead = await (0, process_1.terminateGroupConfirmed)(j.processRef, { termGraceMs: 3000, killGraceMs: 2000 });
20
+ if (dead)
21
+ killed.push(id);
22
+ }
23
+ return killed;
24
+ }
@@ -0,0 +1,112 @@
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.reconcileJobs = reconcileJobs;
7
+ exports.materializeRetry = materializeRetry;
8
+ // LA UNICA matriz de recuperacion (design R3.3 = R1.8, sin excepciones).
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const crypto_1 = __importDefault(require("crypto"));
11
+ const process_1 = require("../../core/journal/process");
12
+ const exec_wrapper_1 = require("./exec-wrapper");
13
+ /** El sidecar de resultado lo escribe un proceso EXTERNO no coordinado
14
+ * (exec-wrapper): existencia del archivo (`replayVerdict`) no es prueba de
15
+ * contenido bien formado. Nunca fabricar un pass/fail de JSON invalido o de
16
+ * forma incorrecta (R1.6) — un resultado no verificable cae al mismo
17
+ * disposition que "unprovable": orphaned-authorization-required. */
18
+ function isWellFormedJobResult(x) {
19
+ return typeof x === 'object' && x !== null && typeof x.exitCode === 'number'
20
+ && typeof x.endedAt === 'string'
21
+ && typeof x.resultPath === 'string';
22
+ }
23
+ function readCompletedResult(logsRoot, jobId, nonce) {
24
+ let raw;
25
+ try {
26
+ raw = fs_1.default.readFileSync((0, exec_wrapper_1.resultPath)(logsRoot, jobId, nonce), 'utf8');
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(raw);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ return isWellFormedJobResult(parsed) ? parsed : null;
39
+ }
40
+ const NON_TERMINAL = ['spawn-intent', 'claimed', 'running', 'cancel-requested'];
41
+ function reconcileJobs(state, logsRoot, opts = {}) {
42
+ const eligible = opts.eligible ?? (() => true);
43
+ const decisions = [];
44
+ for (const j of Object.values(state.jobs)) {
45
+ if (!NON_TERMINAL.includes(j.executionState))
46
+ continue;
47
+ if (!eligible(j))
48
+ continue;
49
+ const anyAlive = (j.processRef !== undefined && (0, process_1.refIsAlive)(j.processRef))
50
+ || (j.wrapperRef !== undefined && (0, process_1.refIsAlive)(j.wrapperRef));
51
+ if (anyAlive) {
52
+ decisions.push({ jobId: j.id, action: 'still-alive' });
53
+ continue;
54
+ }
55
+ const nonce = j.spawnNonce ?? j.processRef?.spawnNonce ?? 'sin-nonce';
56
+ const verdict = (0, exec_wrapper_1.replayVerdict)(logsRoot, j.id, nonce);
57
+ const result = verdict === 'completed' ? readCompletedResult(logsRoot, j.id, nonce) : null;
58
+ if (verdict === 'never-started') {
59
+ // Reemitir exactamente el mismo intent/nonce. El claim `wx` del
60
+ // wrapper hace que un spawn original demorado y este retry no
61
+ // puedan ejecutar ambos el comando.
62
+ decisions.push({ jobId: j.id, action: 'retry-same-intent' });
63
+ }
64
+ else if (result !== null) {
65
+ j.executionState = 'exited';
66
+ j.spawnNonce = nonce;
67
+ j.result = result;
68
+ j.verdict = result.exitCode === 0 ? 'pass' : 'fail';
69
+ j.phaseTimestamps.exited = j.phaseTimestamps.exited ?? new Date().toISOString();
70
+ decisions.push({ jobId: j.id, action: 'adopt-result' });
71
+ }
72
+ else {
73
+ // 'unprovable' O 'completed' con sidecar corrupto/mal formado:
74
+ // ambos son evidencia no verificable — jamas fabricar un pass/fail
75
+ // de JSON invalido, jamas relanzar solo (R1.6, R1.8).
76
+ j.executionState = 'orphaned';
77
+ decisions.push({ jobId: j.id, action: 'orphaned-authorization-required' });
78
+ }
79
+ }
80
+ return { decisions };
81
+ }
82
+ /** Re-reclamar = Attempt NUEVO enlazado, nunca reutilizar (R1.7). El job viejo
83
+ * queda 'cancelled' (su intent se retira); el nuevo nace 'received' sin nonce
84
+ * — el runner le asigna uno fresco en spawn-intent. */
85
+ function materializeRetry(state, jobId) {
86
+ const old = state.jobs[jobId];
87
+ if (old === undefined)
88
+ throw new Error(`job desconocido: ${jobId}`);
89
+ old.executionState = 'cancelled';
90
+ const fresh = {
91
+ ...old,
92
+ id: `${old.id}-a${crypto_1.default.randomBytes(3).toString('hex')}`,
93
+ executionState: 'received',
94
+ observationState: 'progressing',
95
+ spawnNonce: undefined, processRef: undefined, wrapperRef: undefined,
96
+ verdict: undefined, result: undefined,
97
+ phaseTimestamps: { received: new Date().toISOString() },
98
+ attemptOf: old.id,
99
+ };
100
+ state.jobs[fresh.id] = fresh;
101
+ for (const task of state.tasks) {
102
+ for (const item of task.verificationPlan) {
103
+ if (item.satisfiedBy === old.id)
104
+ item.satisfiedBy = fresh.id;
105
+ }
106
+ }
107
+ for (const item of state.cycleVerificationPlan) {
108
+ if (item.satisfiedBy === old.id)
109
+ item.satisfiedBy = fresh.id;
110
+ }
111
+ return fresh;
112
+ }
@@ -0,0 +1,27 @@
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.requestJob = requestJob;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const fingerprint_1 = require("../../core/journal/fingerprint");
9
+ const requests_1 = require("../../core/journal/requests");
10
+ /** El agente NO ejecuta: registra la intencion (design R3.1). La idempotencyKey
11
+ * es hash(fingerprint + commandDigest) => get-or-create atomico (RNF-T.7).
12
+ * El cwd relativo REAL es parte del fingerprint (R3.4). `satisfies` enlaza el
13
+ * job con el item de VerificationPlan que pretende satisfacer (R1.4c). */
14
+ function requestJob(repoRoot, branch, generationToken, argv, paths, cwdRel, opts = {}) {
15
+ const fp = (0, fingerprint_1.computeFingerprint)(repoRoot, argv, paths, cwdRel);
16
+ // La obligacion es parte de la identidad de la REQUEST, no de la ejecucion:
17
+ // apply.ts reutiliza el job mecanicamente equivalente y enlaza el nuevo item.
18
+ const idempotencyKey = crypto_1.default.createHash('sha256').update(`${fp.fingerprint}:${fp.commandDigest}:${opts.satisfies ?? ''}`).digest('hex');
19
+ return (0, requests_1.emitRequest)(repoRoot, branch, {
20
+ kind: 'job-request', generationToken, idempotencyKey,
21
+ payload: {
22
+ argv, paths, cwd: cwdRel,
23
+ fingerprint: fp.fingerprint, commandDigest: fp.commandDigest, expandedPaths: fp.expandedPaths,
24
+ ...(opts.satisfies !== undefined ? { satisfies: opts.satisfies } : {}),
25
+ },
26
+ });
27
+ }
@@ -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
+ }