agentic-workflow-manager 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/job/exec-wrapper.js +136 -0
- package/dist/src/commands/job/export.js +94 -0
- package/dist/src/commands/job/gate.js +118 -0
- package/dist/src/commands/job/heartbeat.js +15 -0
- package/dist/src/commands/job/index.js +246 -0
- package/dist/src/commands/job/query.js +37 -0
- package/dist/src/commands/job/reap.js +24 -0
- package/dist/src/commands/job/reconcile.js +112 -0
- package/dist/src/commands/job/request.js +27 -0
- package/dist/src/commands/watch/apply.js +352 -0
- package/dist/src/commands/watch/generations.js +249 -0
- package/dist/src/commands/watch/index.js +49 -0
- package/dist/src/commands/watch/init.js +72 -0
- package/dist/src/commands/watch/lock.js +89 -0
- package/dist/src/commands/watch/runner.js +191 -0
- package/dist/src/commands/watch/supervisor.js +266 -0
- package/dist/src/core/atomic-file.js +31 -0
- package/dist/src/core/export/pack.js +7 -1
- package/dist/src/core/journal/adapter.js +27 -0
- package/dist/src/core/journal/fingerprint.js +80 -0
- package/dist/src/core/journal/paths.js +56 -0
- package/dist/src/core/journal/process.js +284 -0
- package/dist/src/core/journal/redact.js +142 -0
- package/dist/src/core/journal/requests.js +132 -0
- package/dist/src/core/journal/store.js +107 -0
- package/dist/src/core/journal/types.js +165 -0
- package/dist/src/index.js +4 -0
- package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
- package/dist/tests/commands/job/export.test.js +76 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
- package/dist/tests/commands/job/reap-cli.test.js +101 -0
- package/dist/tests/commands/job/verbs.test.js +56 -0
- package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
- package/dist/tests/commands/watch/apply.test.js +397 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
- package/dist/tests/commands/watch/generations.test.js +115 -0
- package/dist/tests/commands/watch/integration.test.js +124 -0
- package/dist/tests/commands/watch/lock.test.js +60 -0
- package/dist/tests/commands/watch/runner.test.js +239 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
- package/dist/tests/commands/watch/watch-init.test.js +43 -0
- package/dist/tests/core/atomic-file-durable.test.js +42 -0
- package/dist/tests/core/journal/adapter.test.js +27 -0
- package/dist/tests/core/journal/fingerprint.test.js +164 -0
- package/dist/tests/core/journal/paths.test.js +35 -0
- package/dist/tests/core/journal/process.test.js +213 -0
- package/dist/tests/core/journal/redact.test.js +59 -0
- package/dist/tests/core/journal/requests.test.js +134 -0
- package/dist/tests/core/journal/store.test.js +88 -0
- package/dist/tests/core/journal/types.test.js +78 -0
- package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
- package/package.json +1 -1
|
@@ -0,0 +1,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
|
+
}
|