agentic-workflow-manager 9.12.0 → 9.12.1
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/gate.js +6 -1
- package/dist/src/commands/job/index.js +22 -1
- package/dist/src/commands/watch/apply.js +7 -1
- package/dist/src/commands/watch/archive-unused.js +2 -0
- package/dist/src/commands/watch/index.js +24 -0
- package/dist/src/commands/watch/init.js +2 -1
- package/dist/src/commands/watch/request-recovery.js +153 -0
- package/dist/src/commands/watch/supervisor.js +7 -3
- package/dist/src/core/journal/requests.js +1 -0
- package/dist/src/core/journal/types.js +29 -1
- package/dist/tests/commands/watch/request-recovery.test.js +253 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.computeGate = computeGate;
|
|
4
4
|
exports.computeTrackGate = computeTrackGate;
|
|
5
|
+
// Interlock mecanico (design R3.2): falla CERRADO. Solo `pass` con fingerprint
|
|
6
|
+
// VIGENTE satisface (R1.4c, RF-2.8). Cada agujero del bloqueador 5 de la
|
|
7
|
+
// review tiene su categoria propia — nada aprueba por vacuidad ni por
|
|
8
|
+
// referencia falsa.
|
|
9
|
+
const types_1 = require("../../core/journal/types");
|
|
5
10
|
const store_1 = require("../../core/journal/store");
|
|
6
11
|
const LIVE = ['received', 'spawn-intent', 'claimed', 'running', 'cancel-requested'];
|
|
7
12
|
function sameSelection(left, right) {
|
|
@@ -12,7 +17,7 @@ function evaluateEvidence(state, fingerprintNow, scope) {
|
|
|
12
17
|
if (state.cycle.status === 'BLOCKED') {
|
|
13
18
|
reasons.push({ category: 'cycle-blocked', detail: `ciclo BLOCKED: ${state.cycle.blockedReason ?? 'sin razon registrada'}` });
|
|
14
19
|
}
|
|
15
|
-
for (const problem of
|
|
20
|
+
for (const problem of (0, types_1.activeRequestProblems)(state)) {
|
|
16
21
|
reasons.push({ category: 'request-problem', detail: `request ${problem.kind} en ${problem.file}: ${problem.detail}` });
|
|
17
22
|
}
|
|
18
23
|
for (const j of Object.values(state.jobs)) {
|
|
@@ -116,13 +116,34 @@ function registerJobCommand(program) {
|
|
|
116
116
|
}
|
|
117
117
|
if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
|
|
118
118
|
throw new Error('--json requiere un objeto JSON');
|
|
119
|
+
if ('entity' in payload && payload.entity !== opts.entity) {
|
|
120
|
+
throw new Error('--json entity debe coincidir con --entity');
|
|
121
|
+
}
|
|
119
122
|
const repo = process.cwd();
|
|
120
123
|
const branch = branchOf(repo);
|
|
121
124
|
assertAuthenticatedCwd(repo, branch);
|
|
125
|
+
if (opts.entity === 'task') {
|
|
126
|
+
const current = (0, store_1.readJournal)(repo, branch);
|
|
127
|
+
if (current.corrupt || current.state === null)
|
|
128
|
+
throw new Error('register --entity task requiere journal valido');
|
|
129
|
+
const taskId = payload.taskId;
|
|
130
|
+
const rawPlan = payload.verificationPlan;
|
|
131
|
+
const allowedKinds = ['test', 'lint', 'sensors', 'review', 'qa', 'interlock', 'track-integration'];
|
|
132
|
+
if (typeof taskId !== 'string' || taskId.length === 0)
|
|
133
|
+
throw new Error('register --entity task requiere taskId');
|
|
134
|
+
if (rawPlan !== undefined && (!Array.isArray(rawPlan) || !rawPlan.every(item => typeof item === 'object' && item !== null && typeof item.id === 'string' && item.id.length > 0
|
|
135
|
+
&& allowedKinds.includes(item.kind) && (item.satisfiedBy === undefined || typeof item.satisfiedBy === 'string')))) {
|
|
136
|
+
throw new Error('register --entity task requiere verificationPlan valido');
|
|
137
|
+
}
|
|
138
|
+
const plan = (rawPlan ?? []);
|
|
139
|
+
const missing = current.state.requiredVerifiers.filter(kind => !plan.some(item => item.kind === kind));
|
|
140
|
+
if (missing.length > 0)
|
|
141
|
+
throw new Error(`register --entity task: verificationPlan no cubre los verificadores requeridos: ${missing.join(', ')}`);
|
|
142
|
+
}
|
|
122
143
|
const r = (0, requests_1.emitRequest)(repo, branch, {
|
|
123
144
|
kind: 'register-entity', generationToken: opts.generation,
|
|
124
145
|
idempotencyKey: crypto_1.default.createHash('sha256').update(`${opts.entity}:${opts.json}`).digest('hex'),
|
|
125
|
-
payload: { entity: opts.entity
|
|
146
|
+
payload: { ...payload, entity: opts.entity },
|
|
126
147
|
});
|
|
127
148
|
process.stdout.write(JSON.stringify({ requestId: r.requestId }, null, 2) + '\n');
|
|
128
149
|
});
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.applyCyclePlanAppend = applyCyclePlanAppend;
|
|
7
7
|
exports.registerTrackIntegrationItems = registerTrackIntegrationItems;
|
|
8
|
+
exports.applyRequestToState = applyRequestToState;
|
|
8
9
|
exports.consumePendingRequests = consumePendingRequests;
|
|
9
10
|
// Consumo transaccional de requests (bloqueador 4): mutar estado ->
|
|
10
11
|
// writeJournal -> RECIEN AHI borrar archivos. El replay es seguro por
|
|
@@ -502,6 +503,8 @@ function applyRequestToState(s, env, digest, repoRoot, eventScope) {
|
|
|
502
503
|
}
|
|
503
504
|
if (s.cycle.status !== 'BLOCKED')
|
|
504
505
|
throw new Error('custody-decision solo aplica a un ciclo BLOCKED');
|
|
506
|
+
if ((0, types_1.activeRequestProblems)(s).length > 0)
|
|
507
|
+
throw new Error('custody-decision no resuelve requests rechazadas pendientes');
|
|
505
508
|
s.custodyDecisions ??= [];
|
|
506
509
|
s.custodyDecisions.push({ at: now(), decision: 'resume', reason: p.reason, generationToken: env.generationToken });
|
|
507
510
|
for (const generation of s.generations) {
|
|
@@ -666,7 +669,10 @@ function consumePendingRequests(repoRoot, branch, activeToken, eventScope) {
|
|
|
666
669
|
rejectedInvalid++;
|
|
667
670
|
deferredRenames.push({ from: p.file, to: `${p.file}.rejected` });
|
|
668
671
|
if (!s.requestProblems.some((problem) => problem.file === p.file && problem.kind === 'rejected')) {
|
|
669
|
-
s.requestProblems.push({ file: p.file, kind: 'rejected', detail: (0, redact_1.redactText)(e.message), at: now()
|
|
672
|
+
s.requestProblems.push({ file: p.file, kind: 'rejected', detail: (0, redact_1.redactText)(e.message), at: now(),
|
|
673
|
+
requestId: env.requestId, generationToken: env.generationToken,
|
|
674
|
+
...(env.kind === 'register-entity' && env.payload.entity === 'task' && typeof env.payload.taskId === 'string'
|
|
675
|
+
? { taskId: env.payload.taskId } : {}) });
|
|
670
676
|
}
|
|
671
677
|
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'request-rejected-invalid', requestId: env.requestId, detail: e.message });
|
|
672
678
|
dirChanged = true;
|
|
@@ -50,8 +50,10 @@ function watchJournalStatus(repoRoot, branch) {
|
|
|
50
50
|
}
|
|
51
51
|
catch { /* recognized present journal, but never eligible by absence alone */ }
|
|
52
52
|
const binding = current.state.planBinding;
|
|
53
|
+
const latest = current.state.generations.reduce((prev, generation) => prev === undefined || generation.n > prev.n ? generation : prev, undefined);
|
|
53
54
|
return {
|
|
54
55
|
state: 'present', cycleState: current.state.cycle.status, bootstrapUnused,
|
|
56
|
+
...(latest ? { latestGeneration: { n: latest.n, token: latest.token, state: latest.state } } : {}),
|
|
55
57
|
...(binding ? { binding: { path: binding.path, digest: binding.digest, ...(binding.executionDigest ? { executionDigest: binding.executionDigest, executionIdentitySchema: binding.executionIdentitySchema } : {}), schema: binding.schema, executionMode: binding.executionMode, boundAt: binding.boundAt } } : {}),
|
|
56
58
|
};
|
|
57
59
|
}
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.registerWatchCommand = registerWatchCommand;
|
|
7
7
|
const child_process_1 = require("child_process");
|
|
8
8
|
const init_1 = require("./init");
|
|
9
|
+
const request_recovery_1 = require("./request-recovery");
|
|
9
10
|
const supervisor_1 = require("./supervisor");
|
|
10
11
|
const capabilities_1 = require("../../core/model-policy/capabilities");
|
|
11
12
|
const adapter_1 = require("../../core/journal/adapter");
|
|
@@ -144,6 +145,29 @@ function registerWatchCommand(program) {
|
|
|
144
145
|
await (0, supervisor_1.runSupervisorLoop)(repo, branch, cfg);
|
|
145
146
|
process.stdout.write('gate verde: ciclo COMPLETE — drenado, lock liberado, apagando\n');
|
|
146
147
|
});
|
|
148
|
+
watch.command('recover-request')
|
|
149
|
+
.description('reconcilia una task rechazada con una request correctiva aplicada bajo lock exclusivo')
|
|
150
|
+
.requiredOption('--rejected <requestId>', 'request rechazada archivada')
|
|
151
|
+
.requiredOption('--replacement <requestId>', 'request correctiva pendiente')
|
|
152
|
+
.requiredOption('--generation <token>', 'generacion vigente')
|
|
153
|
+
.requiredOption('--reason <text>', 'motivo auditado de la recuperacion')
|
|
154
|
+
.option('--resume', 'reanuda explicitamente custodia si el unico bloqueo era la request')
|
|
155
|
+
.action((opts) => {
|
|
156
|
+
const repo = process.cwd();
|
|
157
|
+
const branch = currentBranch(repo);
|
|
158
|
+
try {
|
|
159
|
+
(0, context_1.resolveCommandContext)(repo, branch);
|
|
160
|
+
(0, request_recovery_1.recoverRejectedTask)(repo, branch, {
|
|
161
|
+
rejectedRequestId: opts.rejected, replacementRequestId: opts.replacement,
|
|
162
|
+
generationToken: opts.generation, reason: opts.reason, resume: opts.resume === true,
|
|
163
|
+
});
|
|
164
|
+
process.stdout.write(JSON.stringify({ recovered: true, rejectedRequestId: opts.rejected, replacementRequestId: opts.replacement, resumed: opts.resume === true }) + '\n');
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
process.stderr.write(`${error.message}\n`);
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
147
171
|
watch
|
|
148
172
|
.command('rebind')
|
|
149
173
|
.description('reconcilia intencionalmente el binding desatendido tras un cambio válido del ciclo del plan')
|
|
@@ -13,6 +13,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
13
13
|
const path_1 = __importDefault(require("path"));
|
|
14
14
|
const store_1 = require("../../core/journal/store");
|
|
15
15
|
const paths_1 = require("../../core/journal/paths");
|
|
16
|
+
const types_1 = require("../../core/journal/types");
|
|
16
17
|
const lock_1 = require("./lock");
|
|
17
18
|
const validate_1 = require("../../core/plan/validate");
|
|
18
19
|
const identity_1 = require("../../core/plan/identity");
|
|
@@ -104,7 +105,7 @@ function rebindBlocker(state) {
|
|
|
104
105
|
const nonterminal = Object.values(state.jobs).filter(job => !TERMINAL_JOB_STATES.has(job.executionState));
|
|
105
106
|
if (nonterminal.length > 0)
|
|
106
107
|
return `hay jobs no terminales: ${nonterminal.map(job => job.id).join(', ')}`;
|
|
107
|
-
if (state.
|
|
108
|
+
if ((0, types_1.activeRequestProblems)(state).length > 0)
|
|
108
109
|
return 'hay conflictos durables de requests';
|
|
109
110
|
if (state.verdicts.some(verdict => verdict.result !== 'pass'))
|
|
110
111
|
return 'hay evidencia adversa durable';
|
|
@@ -0,0 +1,153 @@
|
|
|
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.recoverRejectedTask = recoverRejectedTask;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const json_1 = require("../../core/plan/json");
|
|
10
|
+
const atomic_file_1 = require("../../core/atomic-file");
|
|
11
|
+
const types_1 = require("../../core/journal/types");
|
|
12
|
+
const requests_1 = require("../../core/journal/requests");
|
|
13
|
+
const paths_1 = require("../../core/journal/paths");
|
|
14
|
+
const store_1 = require("../../core/journal/store");
|
|
15
|
+
const process_1 = require("../../core/journal/process");
|
|
16
|
+
const apply_1 = require("./apply");
|
|
17
|
+
const lock_1 = require("./lock");
|
|
18
|
+
const REQUEST_ID = /^req-[0-9]+-[0-9]{10}-[a-f0-9]{8}$/;
|
|
19
|
+
const MAX_REQUEST_BYTES = 256 * 1024;
|
|
20
|
+
function validatedId(value, label) {
|
|
21
|
+
if (typeof value !== 'string' || !REQUEST_ID.test(value))
|
|
22
|
+
throw new Error(`${label} requiere un requestId valido`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
/** Reads only one canonical, bounded request file. The inode check closes a
|
|
26
|
+
* swap between lstat and open, including platforms without O_NOFOLLOW. */
|
|
27
|
+
function requestEnvelope(file, requestId) {
|
|
28
|
+
const before = fs_1.default.lstatSync(file);
|
|
29
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > MAX_REQUEST_BYTES) {
|
|
30
|
+
throw new Error('request de recovery no es un archivo regular acotado');
|
|
31
|
+
}
|
|
32
|
+
const descriptor = fs_1.default.openSync(file, fs_1.default.constants.O_RDONLY | (fs_1.default.constants.O_NOFOLLOW ?? 0));
|
|
33
|
+
let body;
|
|
34
|
+
try {
|
|
35
|
+
const opened = fs_1.default.fstatSync(descriptor);
|
|
36
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) {
|
|
37
|
+
throw new Error('request de recovery cambio de identidad');
|
|
38
|
+
}
|
|
39
|
+
body = fs_1.default.readFileSync(descriptor, { encoding: 'utf8' });
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
fs_1.default.closeSync(descriptor);
|
|
43
|
+
}
|
|
44
|
+
const parsed = (0, json_1.parseJsonNoDuplicate)(body);
|
|
45
|
+
if (!(0, requests_1.isWellFormedEnvelope)(parsed) || parsed.requestId !== requestId)
|
|
46
|
+
throw new Error('request de recovery tiene envelope invalido');
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
function taskId(envelope) {
|
|
50
|
+
if (envelope.kind !== 'register-entity' || envelope.payload.entity !== 'task'
|
|
51
|
+
|| typeof envelope.payload.taskId !== 'string' || envelope.payload.taskId.length === 0) {
|
|
52
|
+
throw new Error('recovery solo admite register-entity task con taskId');
|
|
53
|
+
}
|
|
54
|
+
return envelope.payload.taskId;
|
|
55
|
+
}
|
|
56
|
+
function mayResume(state) {
|
|
57
|
+
if (state.cycle.status !== 'BLOCKED' || state.cycle.blockedReason !== 'recovery no autorizado: hay conflictos durables de requests') {
|
|
58
|
+
throw new Error('resume requiere custodia BLOCKED por requests');
|
|
59
|
+
}
|
|
60
|
+
if ((0, types_1.activeRequestProblems)(state).length > 0)
|
|
61
|
+
throw new Error('resume requiere resolver todas las requests activas');
|
|
62
|
+
}
|
|
63
|
+
/** Offline recovery lane: no supervisor or controller may own this journal.
|
|
64
|
+
* The correction and its audit link are published in one state transaction. */
|
|
65
|
+
function recoverRejectedTask(repoRoot, branch, input) {
|
|
66
|
+
if (typeof repoRoot !== 'string' || !path_1.default.isAbsolute(repoRoot) || typeof branch !== 'string' || branch.length === 0
|
|
67
|
+
|| !input || typeof input !== 'object' || typeof input.generationToken !== 'string' || input.generationToken.length === 0
|
|
68
|
+
|| typeof input.reason !== 'string' || input.reason.trim().length === 0 || input.reason.length > 1024 || typeof input.resume !== 'boolean') {
|
|
69
|
+
throw new Error('parametros de recovery invalidos');
|
|
70
|
+
}
|
|
71
|
+
const rejectedId = validatedId(input.rejectedRequestId, '--rejected');
|
|
72
|
+
const replacementId = validatedId(input.replacementRequestId, '--replacement');
|
|
73
|
+
if (rejectedId === replacementId)
|
|
74
|
+
throw new Error('la request correctiva debe ser distinta');
|
|
75
|
+
const lock = (0, lock_1.acquireLock)(repoRoot);
|
|
76
|
+
try {
|
|
77
|
+
const read = (0, store_1.readJournal)(repoRoot, branch);
|
|
78
|
+
if (read.corrupt || !read.state || read.state.schema !== 2 || !read.state.planBinding)
|
|
79
|
+
throw new Error('recovery requiere journal schema-2 vigente');
|
|
80
|
+
const state = read.state;
|
|
81
|
+
const latest = [...state.generations].sort((a, b) => b.n - a.n)[0];
|
|
82
|
+
if (!latest || latest.token !== input.generationToken)
|
|
83
|
+
throw new Error('generation de recovery no es la vigente');
|
|
84
|
+
for (const generation of state.generations) {
|
|
85
|
+
for (const ref of [generation.processRef, generation.wrapperRef]) {
|
|
86
|
+
if (ref && (0, process_1.refIsAlive)(ref))
|
|
87
|
+
throw new Error('controller vivo: recovery requiere custodia exclusiva');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const dir = (0, paths_1.requestsDir)(repoRoot, branch);
|
|
91
|
+
const originalFile = path_1.default.join(dir, `${rejectedId}.json`);
|
|
92
|
+
const problem = state.requestProblems.find(item => item.kind === 'rejected' && item.file === originalFile);
|
|
93
|
+
if (!problem)
|
|
94
|
+
throw new Error('rechazo durable no encontrado');
|
|
95
|
+
if (problem.resolution) {
|
|
96
|
+
if (problem.resolution.replacementRequestId !== replacementId || problem.resolution.generationToken !== input.generationToken || problem.resolution.reason !== input.reason
|
|
97
|
+
|| (0, types_1.activeRequestProblems)(state).includes(problem))
|
|
98
|
+
throw new Error('recovery previo incompatible');
|
|
99
|
+
if (input.resume && state.cycle.status === 'BLOCKED') {
|
|
100
|
+
const candidate = structuredClone(state);
|
|
101
|
+
mayResume(candidate);
|
|
102
|
+
candidate.custodyDecisions ??= [];
|
|
103
|
+
candidate.custodyDecisions.push({ at: new Date().toISOString(), decision: 'resume', reason: input.reason, generationToken: input.generationToken });
|
|
104
|
+
candidate.cycle.status = 'IN_PROGRESS';
|
|
105
|
+
candidate.cycle.blockedReason = undefined;
|
|
106
|
+
for (const generation of candidate.generations)
|
|
107
|
+
if (generation.state === 'active' || generation.state === 'controller-suspected-stall')
|
|
108
|
+
generation.state = 'superseded';
|
|
109
|
+
(0, store_1.writeJournal)(repoRoot, branch, candidate);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const original = requestEnvelope(`${originalFile}.rejected`, rejectedId);
|
|
114
|
+
const replacementFile = path_1.default.join(dir, `${replacementId}.json`);
|
|
115
|
+
const replacement = requestEnvelope(replacementFile, replacementId);
|
|
116
|
+
if (original.generationToken !== input.generationToken || replacement.generationToken !== input.generationToken || taskId(original) !== taskId(replacement)) {
|
|
117
|
+
throw new Error('request correctiva no enlaza el mismo taskId o generation');
|
|
118
|
+
}
|
|
119
|
+
if (state.tasks.some(task => task.id === taskId(original)))
|
|
120
|
+
throw new Error('task ya existente: recovery requiere un registro rechazado sin aplicar');
|
|
121
|
+
if (state.appliedRequests[replacementId] !== undefined || Object.values(state.appliedRequests).some(item => item.idempotencyKey === replacement.idempotencyKey && item.payloadDigest !== (0, requests_1.digestOf)(replacement.payload))) {
|
|
122
|
+
throw new Error('request correctiva ya tiene outcome o idempotencyKey conflictiva');
|
|
123
|
+
}
|
|
124
|
+
const candidate = structuredClone(state);
|
|
125
|
+
(0, apply_1.applyRequestToState)(candidate, replacement, (0, requests_1.digestOf)(replacement.payload), repoRoot);
|
|
126
|
+
if (candidate.appliedRequests[replacementId]?.outcome !== 'applied' || !candidate.tasks.some(task => task.id === taskId(replacement))) {
|
|
127
|
+
throw new Error('request correctiva no fue aplicada');
|
|
128
|
+
}
|
|
129
|
+
const target = candidate.requestProblems.find(item => item.kind === 'rejected' && item.file === originalFile);
|
|
130
|
+
target.requestId = rejectedId;
|
|
131
|
+
target.taskId = taskId(original);
|
|
132
|
+
target.generationToken = original.generationToken;
|
|
133
|
+
target.resolution = { replacementRequestId: replacementId, replacementPayloadDigest: (0, requests_1.digestOf)(replacement.payload), reason: input.reason,
|
|
134
|
+
generationToken: input.generationToken, at: new Date().toISOString() };
|
|
135
|
+
if (input.resume) {
|
|
136
|
+
mayResume(candidate);
|
|
137
|
+
candidate.custodyDecisions ??= [];
|
|
138
|
+
candidate.custodyDecisions.push({ at: new Date().toISOString(), decision: 'resume', reason: input.reason, generationToken: input.generationToken });
|
|
139
|
+
candidate.cycle.status = 'IN_PROGRESS';
|
|
140
|
+
candidate.cycle.blockedReason = undefined;
|
|
141
|
+
for (const generation of candidate.generations)
|
|
142
|
+
if (generation.state === 'active' || generation.state === 'controller-suspected-stall')
|
|
143
|
+
generation.state = 'superseded';
|
|
144
|
+
}
|
|
145
|
+
(0, store_1.writeJournal)(repoRoot, branch, candidate);
|
|
146
|
+
fs_1.default.rmSync(replacementFile);
|
|
147
|
+
(0, atomic_file_1.fsyncDirSync)(dir);
|
|
148
|
+
(0, store_1.appendEvent)(repoRoot, branch, { kind: 'request-recovered', rejectedRequestId: rejectedId, replacementRequestId: replacementId, generationToken: input.generationToken, resumed: input.resume });
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
(0, lock_1.releaseLock)(repoRoot, lock);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -4,6 +4,7 @@ exports.Supervisor = exports.MAX_SENSOR_DEFERRALS = exports.DEFAULT_SUPERVISOR_C
|
|
|
4
4
|
exports.routingFacts = routingFacts;
|
|
5
5
|
exports.defaultDispatchAdmission = defaultDispatchAdmission;
|
|
6
6
|
exports.admissionForConfig = admissionForConfig;
|
|
7
|
+
exports.recoveryWhitelistBlocker = recoveryWhitelistBlocker;
|
|
7
8
|
exports.runSupervisorLoop = runSupervisorLoop;
|
|
8
9
|
// Loop foreground (R4.4/R4.5): tick = apply -> collect/spawn -> stall -> gate.
|
|
9
10
|
// COMPLETE exige gate verde (que exige cero vivos): drenaje ANTES de declarar.
|
|
@@ -27,6 +28,7 @@ const apply_1 = require("./apply");
|
|
|
27
28
|
const runner_1 = require("./runner");
|
|
28
29
|
const tracks_1 = require("./tracks");
|
|
29
30
|
const generations_1 = require("./generations");
|
|
31
|
+
const types_1 = require("../../core/journal/types");
|
|
30
32
|
/** #166: compact v2 admission requires the runtime identity, and the supervisor
|
|
31
33
|
* never supplied it — so `awm watch` blocked on every tick with
|
|
32
34
|
* ADMISSION_ROUTING_FACTS_REQUIRED even with every other gate green.
|
|
@@ -201,9 +203,11 @@ function admissionForConfig(repoRoot, branch, cfg, deps = {}) {
|
|
|
201
203
|
exports.MAX_SENSOR_DEFERRALS = 3;
|
|
202
204
|
const LIVE = ['received', 'spawn-intent', 'claimed', 'running', 'cancel-requested'];
|
|
203
205
|
function recoveryWhitelistBlocker(state) {
|
|
204
|
-
if (state.cycle.status === 'BLOCKED')
|
|
205
|
-
return '
|
|
206
|
-
|
|
206
|
+
if (state.cycle.status === 'BLOCKED') {
|
|
207
|
+
return state.cycle.blockedReason === 'recovery no autorizado: hay conflictos durables de requests'
|
|
208
|
+
? 'hay conflictos durables de requests' : 'el ciclo está bloqueado';
|
|
209
|
+
}
|
|
210
|
+
if ((0, types_1.activeRequestProblems)(state).length > 0)
|
|
207
211
|
return 'hay conflictos durables de requests';
|
|
208
212
|
// Pending work is the normal reason to launch (or retry launching) the
|
|
209
213
|
// controller. It is not recovery evidence and therefore cannot turn a
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.digestOf = digestOf;
|
|
7
7
|
exports.emitRequest = emitRequest;
|
|
8
|
+
exports.isWellFormedEnvelope = isWellFormedEnvelope;
|
|
8
9
|
exports.listPendingRequests = listPendingRequests;
|
|
9
10
|
exports.applyOutcome = applyOutcome;
|
|
10
11
|
exports.ackFor = ackFor;
|
|
@@ -6,6 +6,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
6
6
|
};
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.GENERATION_STATES = exports.EXECUTION_STATES = void 0;
|
|
9
|
+
exports.activeRequestProblems = activeRequestProblems;
|
|
9
10
|
exports.isControllerRecoveryAction = isControllerRecoveryAction;
|
|
10
11
|
exports.emptyState = emptyState;
|
|
11
12
|
exports.isWellFormedState = isWellFormedState;
|
|
@@ -14,6 +15,7 @@ exports.isRoutingEnvelope = isRoutingEnvelope;
|
|
|
14
15
|
exports.isWellFormedProcessRef = isWellFormedProcessRef;
|
|
15
16
|
exports.isWellFormedJob = isWellFormedJob;
|
|
16
17
|
const crypto_1 = __importDefault(require("crypto"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
17
19
|
const types_1 = require("../tracks/types");
|
|
18
20
|
const paths_1 = require("./paths");
|
|
19
21
|
exports.EXECUTION_STATES = [
|
|
@@ -35,6 +37,19 @@ const COHORT_PHASES = [
|
|
|
35
37
|
];
|
|
36
38
|
const _cohortPhasesComplete = true;
|
|
37
39
|
const VERIFICATION_KINDS = ['test', 'lint', 'sensors', 'review', 'qa', 'interlock', 'track-integration'];
|
|
40
|
+
function activeRequestProblems(state) {
|
|
41
|
+
return state.requestProblems.filter(problem => {
|
|
42
|
+
const resolution = problem.resolution;
|
|
43
|
+
if (!resolution || !problem.requestId || !problem.taskId || !problem.generationToken
|
|
44
|
+
|| path_1.default.basename(problem.file) !== `${problem.requestId}.json`
|
|
45
|
+
|| resolution.generationToken !== problem.generationToken)
|
|
46
|
+
return true;
|
|
47
|
+
const applied = state.appliedRequests[resolution.replacementRequestId];
|
|
48
|
+
return applied?.outcome !== 'applied' || applied.resultRef !== problem.taskId
|
|
49
|
+
|| applied.payloadDigest !== resolution.replacementPayloadDigest
|
|
50
|
+
|| !state.tasks.some(task => task.id === problem.taskId);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
38
53
|
function isControllerRecoveryAction(x) {
|
|
39
54
|
return isObj(x) && Object.keys(x).length === 2
|
|
40
55
|
&& x.schema === 'controller-recovery/v1'
|
|
@@ -240,7 +255,20 @@ function isRoutingEnvelope(x) { if (!isObj(x) || !Object.keys(x).every(key => ['
|
|
|
240
255
|
function isWellFormedImplementationLineage(x) { return isObj(x) && Object.keys(x).every(key => ['id', 'obligationId', 'sliceId', 'planDigest', 'executionDigest', 'initialProfile', 'initialEffort', 'attempts'].includes(key)) && typeof x.id === 'string' && x.id.length > 0 && typeof x.obligationId === 'string' && x.obligationId.length > 0 && typeof x.sliceId === 'string' && x.sliceId.length > 0 && isDigest(x.planDigest) && isDigest(x.executionDigest) && ['mechanical', 'integration', 'judgment'].includes(String(x.initialProfile)) && ['medium', 'high', 'runtime-default'].includes(String(x.initialEffort)) && Number.isSafeInteger(x.attempts) && x.attempts >= 0 && x.attempts <= 3; }
|
|
241
256
|
function isWellFormedRequestProblem(x) {
|
|
242
257
|
return isObj(x) && typeof x.file === 'string' && (x.kind === 'corrupt' || x.kind === 'rejected')
|
|
243
|
-
&& typeof x.detail === 'string' && typeof x.at === 'string'
|
|
258
|
+
&& typeof x.detail === 'string' && typeof x.at === 'string'
|
|
259
|
+
&& (x.requestId === undefined || typeof x.requestId === 'string')
|
|
260
|
+
&& (x.taskId === undefined || typeof x.taskId === 'string')
|
|
261
|
+
&& (x.generationToken === undefined || typeof x.generationToken === 'string')
|
|
262
|
+
&& (x.resolution === undefined || (x.kind === 'rejected' && isObj(x.resolution)
|
|
263
|
+
&& Object.keys(x.resolution).every(key => ['replacementRequestId', 'replacementPayloadDigest', 'reason', 'generationToken', 'at'].includes(key))
|
|
264
|
+
&& typeof x.requestId === 'string' && x.requestId.length > 0
|
|
265
|
+
&& typeof x.taskId === 'string' && x.taskId.length > 0
|
|
266
|
+
&& typeof x.generationToken === 'string' && x.generationToken.length > 0
|
|
267
|
+
&& typeof x.resolution.replacementRequestId === 'string' && x.resolution.replacementRequestId.length > 0
|
|
268
|
+
&& typeof x.resolution.replacementPayloadDigest === 'string' && /^[a-f0-9]{64}$/.test(x.resolution.replacementPayloadDigest)
|
|
269
|
+
&& typeof x.resolution.reason === 'string' && x.resolution.reason.trim().length > 0
|
|
270
|
+
&& typeof x.resolution.generationToken === 'string' && x.resolution.generationToken.length > 0
|
|
271
|
+
&& typeof x.resolution.at === 'string' && Number.isFinite(Date.parse(x.resolution.at))));
|
|
244
272
|
}
|
|
245
273
|
function isWellFormedCustodyDecision(x) {
|
|
246
274
|
return isObj(x) && typeof x.at === 'string' && x.decision === 'resume'
|
|
@@ -0,0 +1,253 @@
|
|
|
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
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
11
|
+
const requests_1 = require("../../../src/core/journal/requests");
|
|
12
|
+
const apply_1 = require("../../../src/commands/watch/apply");
|
|
13
|
+
const gate_1 = require("../../../src/commands/job/gate");
|
|
14
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
15
|
+
const lock_1 = require("../../../src/commands/watch/lock");
|
|
16
|
+
const request_recovery_1 = require("../../../src/commands/watch/request-recovery");
|
|
17
|
+
const supervisor_1 = require("../../../src/commands/watch/supervisor");
|
|
18
|
+
const archive_unused_1 = require("../../../src/commands/watch/archive-unused");
|
|
19
|
+
const generations_1 = require("../../../src/commands/watch/generations");
|
|
20
|
+
describe('issue #194: recovery of a rejected task request', () => {
|
|
21
|
+
let repo;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-recover-request-'));
|
|
24
|
+
(0, child_process_1.execFileSync)('git', ['init', '-q', '-b', 'rama'], { cwd: repo });
|
|
25
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', 'commit', '--allow-empty', '-qm', 'seed'], { cwd: repo });
|
|
26
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
27
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
28
|
+
state.schema = 2;
|
|
29
|
+
state.planBinding = {
|
|
30
|
+
path: 'docs/plans/cycle.md', digest: 'a'.repeat(64), schema: 'compact-slices/v1',
|
|
31
|
+
executionMode: 'desatendido', boundAt: new Date().toISOString(),
|
|
32
|
+
};
|
|
33
|
+
state.requiredVerifiers = ['test', 'sensors'];
|
|
34
|
+
state.generations.push({ n: 1, token: 'g1', state: 'active', launchedAt: new Date().toISOString() });
|
|
35
|
+
state.cycle.status = 'BLOCKED';
|
|
36
|
+
state.cycle.blockedReason = 'recovery no autorizado: hay conflictos durables de requests';
|
|
37
|
+
(0, store_1.writeJournal)(repo, 'rama', state);
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => fs_1.default.rmSync(repo, { recursive: true, force: true }));
|
|
40
|
+
function rejectedTask() {
|
|
41
|
+
const rejected = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
42
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'bad-task',
|
|
43
|
+
payload: { entity: 'task', taskId: 'S1', title: 'slice', verificationPlan: [{ id: 'test-s1', kind: 'test' }], reviewObligations: [] },
|
|
44
|
+
});
|
|
45
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').rejectedInvalid).toBe(1);
|
|
46
|
+
expect(fs_1.default.existsSync(`${rejected.file}.rejected`)).toBe(true);
|
|
47
|
+
expect(fs_1.default.readFileSync((0, paths_1.eventsPath)(repo, 'rama'), 'utf8')).toMatch(/request-rejected-invalid/);
|
|
48
|
+
return rejected;
|
|
49
|
+
}
|
|
50
|
+
function correctedTask(taskId = 'S1', kinds = ['test', 'sensors']) {
|
|
51
|
+
return (0, requests_1.emitRequest)(repo, 'rama', {
|
|
52
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: `corrected-${taskId}-${kinds.join('-')}`,
|
|
53
|
+
payload: { entity: 'task', taskId, title: 'slice', verificationPlan: kinds.map(kind => ({ id: `${kind}-${taskId}`, kind })), reviewObligations: [] },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
test('rejection → corrected request → audited recovery → next action remains gated by real work', () => {
|
|
57
|
+
const rejected = rejectedTask();
|
|
58
|
+
const replacement = correctedTask();
|
|
59
|
+
expect((0, gate_1.computeGate)((0, store_1.readJournal)(repo, 'rama').state, false, () => null).reasons.some(reason => reason.category === 'request-problem')).toBe(true);
|
|
60
|
+
(0, request_recovery_1.recoverRejectedTask)(repo, 'rama', {
|
|
61
|
+
rejectedRequestId: rejected.requestId, replacementRequestId: replacement.requestId,
|
|
62
|
+
generationToken: 'g1', reason: 'corregido plan de verificación', resume: true,
|
|
63
|
+
});
|
|
64
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
65
|
+
expect(state.tasks.map(task => task.id)).toEqual(['S1']);
|
|
66
|
+
expect(state.appliedRequests[replacement.requestId]).toMatchObject({ outcome: 'applied', resultRef: 'S1' });
|
|
67
|
+
expect(state.requestProblems[0]).toMatchObject({ kind: 'rejected', resolution: { replacementRequestId: replacement.requestId, generationToken: 'g1', reason: 'corregido plan de verificación' } });
|
|
68
|
+
expect(state.cycle.status).toBe('IN_PROGRESS');
|
|
69
|
+
expect(state.custodyDecisions).toEqual([expect.objectContaining({ decision: 'resume', generationToken: 'g1' })]);
|
|
70
|
+
expect(fs_1.default.existsSync(`${rejected.file}.rejected`)).toBe(true);
|
|
71
|
+
expect(fs_1.default.existsSync(replacement.file)).toBe(false);
|
|
72
|
+
expect(fs_1.default.readFileSync((0, paths_1.eventsPath)(repo, 'rama'), 'utf8')).toMatch(/request-recovered/);
|
|
73
|
+
const gate = (0, gate_1.computeGate)(state, false, () => null);
|
|
74
|
+
expect(gate.reasons.some(reason => reason.category === 'request-problem')).toBe(false);
|
|
75
|
+
expect(gate.pass).toBe(false);
|
|
76
|
+
expect(gate.reasons.some(reason => reason.category === 'pending-task')).toBe(true);
|
|
77
|
+
(0, request_recovery_1.recoverRejectedTask)(repo, 'rama', {
|
|
78
|
+
rejectedRequestId: rejected.requestId, replacementRequestId: replacement.requestId,
|
|
79
|
+
generationToken: 'g1', reason: 'corregido plan de verificación', resume: true,
|
|
80
|
+
});
|
|
81
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.tasks).toHaveLength(1);
|
|
82
|
+
// Crash after state publication but before queue cleanup: supervisor
|
|
83
|
+
// replay consumes the same request ID without registering S1 twice.
|
|
84
|
+
fs_1.default.writeFileSync(replacement.file, JSON.stringify({ requestId: replacement.requestId,
|
|
85
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'corrected-S1-test-sensors',
|
|
86
|
+
payload: { entity: 'task', taskId: 'S1', title: 'slice', verificationPlan: [
|
|
87
|
+
{ id: 'test-S1', kind: 'test' }, { id: 'sensors-S1', kind: 'sensors' }
|
|
88
|
+
], reviewObligations: [] } }));
|
|
89
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').applied).toBe(0);
|
|
90
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.tasks).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
test('wrong task and still-invalid correction leave rejection active without partial writes', () => {
|
|
93
|
+
const rejected = rejectedTask();
|
|
94
|
+
const wrong = correctedTask('S2');
|
|
95
|
+
expect(() => (0, request_recovery_1.recoverRejectedTask)(repo, 'rama', { rejectedRequestId: rejected.requestId, replacementRequestId: wrong.requestId, generationToken: 'g1', reason: 'wrong task', resume: true })).toThrow(/mismo taskId/);
|
|
96
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.requestProblems[0].resolution).toBeUndefined();
|
|
97
|
+
fs_1.default.rmSync(wrong.file);
|
|
98
|
+
const invalid = correctedTask('S1', ['test']);
|
|
99
|
+
expect(() => (0, request_recovery_1.recoverRejectedTask)(repo, 'rama', { rejectedRequestId: rejected.requestId, replacementRequestId: invalid.requestId, generationToken: 'g1', reason: 'still invalid', resume: true })).toThrow(/sensors/);
|
|
100
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
101
|
+
expect(state.tasks).toHaveLength(0);
|
|
102
|
+
expect(state.requestProblems[0].resolution).toBeUndefined();
|
|
103
|
+
expect(state.cycle.status).toBe('BLOCKED');
|
|
104
|
+
});
|
|
105
|
+
test('stale generation, missing archive, and live supervisor lock cannot resolve a rejection', () => {
|
|
106
|
+
const rejected = rejectedTask();
|
|
107
|
+
const replacement = correctedTask();
|
|
108
|
+
const input = { rejectedRequestId: rejected.requestId, replacementRequestId: replacement.requestId,
|
|
109
|
+
generationToken: 'g1', reason: 'corregido', resume: true };
|
|
110
|
+
expect(() => (0, request_recovery_1.recoverRejectedTask)(repo, 'rama', { ...input, generationToken: 'g0' })).toThrow(/generation/);
|
|
111
|
+
const lock = (0, lock_1.acquireLock)(repo);
|
|
112
|
+
try {
|
|
113
|
+
expect(() => (0, request_recovery_1.recoverRejectedTask)(repo, 'rama', input)).toThrow(/supervisor activo/);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
(0, lock_1.releaseLock)(repo, lock);
|
|
117
|
+
}
|
|
118
|
+
fs_1.default.renameSync(`${rejected.file}.rejected`, `${rejected.file}.held`);
|
|
119
|
+
expect(() => (0, request_recovery_1.recoverRejectedTask)(repo, 'rama', input)).toThrow();
|
|
120
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.requestProblems[0].resolution).toBeUndefined();
|
|
121
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.tasks).toHaveLength(0);
|
|
122
|
+
});
|
|
123
|
+
test('an unrelated active problem prevents --resume and leaves the correction queued', () => {
|
|
124
|
+
const rejected = rejectedTask();
|
|
125
|
+
const replacement = correctedTask();
|
|
126
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
127
|
+
state.requestProblems.push({ file: path_1.default.join((0, paths_1.requestsDir)(repo, 'rama'), 'other.json'), kind: 'corrupt', detail: 'bad envelope', at: new Date().toISOString() });
|
|
128
|
+
(0, store_1.writeJournal)(repo, 'rama', state);
|
|
129
|
+
expect(() => (0, request_recovery_1.recoverRejectedTask)(repo, 'rama', { rejectedRequestId: rejected.requestId,
|
|
130
|
+
replacementRequestId: replacement.requestId, generationToken: 'g1', reason: 'corregido', resume: true })).toThrow(/todas las requests/);
|
|
131
|
+
expect(fs_1.default.existsSync(replacement.file)).toBe(true);
|
|
132
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.tasks).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
test('a resolution-shaped state without an applied replacement is still an active gate problem', () => {
|
|
135
|
+
rejectedTask();
|
|
136
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
137
|
+
state.requestProblems[0].resolution = { replacementRequestId: 'req-1-0000000000-deadbeef', replacementPayloadDigest: 'a'.repeat(64), reason: 'forged', generationToken: 'g1', at: new Date().toISOString() };
|
|
138
|
+
(0, store_1.writeJournal)(repo, 'rama', state);
|
|
139
|
+
expect((0, gate_1.computeGate)((0, store_1.readJournal)(repo, 'rama').state, false, () => null).reasons.some(reason => reason.category === 'request-problem')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
test('an unrelated applied task cannot be cited as the replacement of S1', () => {
|
|
142
|
+
rejectedTask();
|
|
143
|
+
const unrelated = correctedTask('S2');
|
|
144
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').applied).toBe(1);
|
|
145
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
146
|
+
state.requestProblems[0].resolution = { replacementRequestId: unrelated.requestId, replacementPayloadDigest: state.appliedRequests[unrelated.requestId].payloadDigest, reason: 'wrong link', generationToken: 'g1', at: new Date().toISOString() };
|
|
147
|
+
(0, store_1.writeJournal)(repo, 'rama', state);
|
|
148
|
+
expect((0, gate_1.computeGate)((0, store_1.readJournal)(repo, 'rama').state, false, () => null).reasons.some(reason => reason.category === 'request-problem')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
test('a corrupt request cannot be laundered through a task-shaped resolution', () => {
|
|
151
|
+
const replacement = correctedTask('S1');
|
|
152
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').applied).toBe(1);
|
|
153
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
154
|
+
state.requestProblems.push({ file: path_1.default.join((0, paths_1.requestsDir)(repo, 'rama'), 'req-1-0000000000-deadbeef.json'),
|
|
155
|
+
kind: 'corrupt', detail: 'bad JSON', at: new Date().toISOString(), requestId: 'req-1-0000000000-deadbeef',
|
|
156
|
+
taskId: 'S1', generationToken: 'g1', resolution: { replacementRequestId: replacement.requestId,
|
|
157
|
+
replacementPayloadDigest: state.appliedRequests[replacement.requestId].payloadDigest,
|
|
158
|
+
reason: 'forged', generationToken: 'g1', at: new Date().toISOString() } });
|
|
159
|
+
expect(() => (0, store_1.writeJournal)(repo, 'rama', state)).toThrow(/forma invalida/);
|
|
160
|
+
});
|
|
161
|
+
test('resume rejects a different custody cause even if its text mentions requests', () => {
|
|
162
|
+
const rejected = rejectedTask();
|
|
163
|
+
const replacement = correctedTask();
|
|
164
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
165
|
+
state.cycle.blockedReason = 'other failure; also mentions conflictos durables de requests';
|
|
166
|
+
(0, store_1.writeJournal)(repo, 'rama', state);
|
|
167
|
+
expect(() => (0, request_recovery_1.recoverRejectedTask)(repo, 'rama', { rejectedRequestId: rejected.requestId,
|
|
168
|
+
replacementRequestId: replacement.requestId, generationToken: 'g1', reason: 'corrected', resume: true })).toThrow(/custodia BLOCKED por requests/);
|
|
169
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.tasks).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
test('a blocked request remains the specific custody cause across supervisor ticks', () => {
|
|
172
|
+
rejectedTask();
|
|
173
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
174
|
+
expect((0, supervisor_1.recoveryWhitelistBlocker)(state)).toBe('hay conflictos durables de requests');
|
|
175
|
+
});
|
|
176
|
+
test('an independent custody cause is never overwritten by an active request problem', () => {
|
|
177
|
+
rejectedTask();
|
|
178
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
179
|
+
state.cycle.blockedReason = 'veredicto de sensores no concluyente';
|
|
180
|
+
(0, store_1.writeJournal)(repo, 'rama', state);
|
|
181
|
+
expect((0, supervisor_1.recoveryWhitelistBlocker)((0, store_1.readJournal)(repo, 'rama').state)).toBe('el ciclo está bloqueado');
|
|
182
|
+
});
|
|
183
|
+
test('public journal status exposes the latest generation token after supervisor shutdown', () => {
|
|
184
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
185
|
+
state.generations[0].state = 'terminated';
|
|
186
|
+
(0, store_1.writeJournal)(repo, 'rama', state);
|
|
187
|
+
expect((0, archive_unused_1.watchJournalStatus)(repo, 'rama')).toMatchObject({ latestGeneration: { n: 1, token: 'g1', state: 'terminated' } });
|
|
188
|
+
});
|
|
189
|
+
test('recovery without resume remains eligible for a later explicit resume', () => {
|
|
190
|
+
const rejected = rejectedTask();
|
|
191
|
+
const replacement = correctedTask();
|
|
192
|
+
const input = { rejectedRequestId: rejected.requestId, replacementRequestId: replacement.requestId,
|
|
193
|
+
generationToken: 'g1', reason: 'corregido', resume: false };
|
|
194
|
+
(0, request_recovery_1.recoverRejectedTask)(repo, 'rama', input);
|
|
195
|
+
const blocker = (0, supervisor_1.recoveryWhitelistBlocker)((0, store_1.readJournal)(repo, 'rama').state);
|
|
196
|
+
(0, generations_1.enterCustody)(repo, 'rama', `recovery no autorizado: ${blocker}`);
|
|
197
|
+
(0, request_recovery_1.recoverRejectedTask)(repo, 'rama', { ...input, resume: true });
|
|
198
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.cycle.status).toBe('IN_PROGRESS');
|
|
199
|
+
});
|
|
200
|
+
test('a correction from a newer generation cannot silently resolve the archived rejection', () => {
|
|
201
|
+
const rejected = rejectedTask();
|
|
202
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
203
|
+
state.generations[0].state = 'superseded';
|
|
204
|
+
state.generations.push({ n: 2, token: 'g2', state: 'active', launchedAt: new Date().toISOString() });
|
|
205
|
+
(0, store_1.writeJournal)(repo, 'rama', state);
|
|
206
|
+
const replacement = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'register-entity', generationToken: 'g2', idempotencyKey: 'corrected-g2',
|
|
207
|
+
payload: { entity: 'task', taskId: 'S1', verificationPlan: [{ id: 't2', kind: 'test' }, { id: 's2', kind: 'sensors' }], reviewObligations: [] } });
|
|
208
|
+
expect(() => (0, request_recovery_1.recoverRejectedTask)(repo, 'rama', { rejectedRequestId: rejected.requestId,
|
|
209
|
+
replacementRequestId: replacement.requestId, generationToken: 'g2', reason: 'different generation', resume: true })).toThrow(/generation/);
|
|
210
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.requestProblems[0].resolution).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
test('public watch recover-request command applies the correction under custody', () => {
|
|
213
|
+
const rejected = rejectedTask();
|
|
214
|
+
const replacement = correctedTask();
|
|
215
|
+
const cli = path_1.default.resolve(__dirname, '../../../src/index.ts');
|
|
216
|
+
const tsNode = require.resolve('ts-node/dist/bin.js');
|
|
217
|
+
const output = (0, child_process_1.execFileSync)(process.execPath, [tsNode, '--compiler-options', '{"module":"CommonJS"}', cli, 'watch', 'recover-request',
|
|
218
|
+
'--rejected', rejected.requestId, '--replacement', replacement.requestId,
|
|
219
|
+
'--generation', 'g1', '--reason', 'operador verificó reemplazo', '--resume'], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
220
|
+
expect(JSON.parse(output)).toMatchObject({ recovered: true, replacementRequestId: replacement.requestId });
|
|
221
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.cycle.status).toBe('IN_PROGRESS');
|
|
222
|
+
});
|
|
223
|
+
test('job register rejects missing sensors before writing a request file', () => {
|
|
224
|
+
const cli = path_1.default.resolve(__dirname, '../../../src/index.ts');
|
|
225
|
+
const tsNode = require.resolve('ts-node/dist/bin.js');
|
|
226
|
+
const payload = JSON.stringify({ taskId: 'S1', verificationPlan: [{ id: 't1', kind: 'test' }], reviewObligations: [] });
|
|
227
|
+
let diagnostic = '';
|
|
228
|
+
try {
|
|
229
|
+
(0, child_process_1.execFileSync)(process.execPath, [tsNode, '--compiler-options', '{"module":"CommonJS"}', cli,
|
|
230
|
+
'job', 'register', '--generation', 'g1', '--entity', 'task', '--json', payload], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
diagnostic = String(error.stderr ?? '');
|
|
234
|
+
}
|
|
235
|
+
expect(diagnostic).toMatch(/verificationPlan.*sensors/);
|
|
236
|
+
expect(fs_1.default.readdirSync((0, paths_1.requestsDir)(repo, 'rama')).filter(name => name.endsWith('.json'))).toHaveLength(0);
|
|
237
|
+
});
|
|
238
|
+
test('JSON cannot override --entity to bypass task prevalidation', () => {
|
|
239
|
+
const cli = path_1.default.resolve(__dirname, '../../../src/index.ts');
|
|
240
|
+
const tsNode = require.resolve('ts-node/dist/bin.js');
|
|
241
|
+
const payload = JSON.stringify({ entity: 'task', taskId: 'S1', verificationPlan: [{ id: 't1', kind: 'test' }], reviewObligations: [] });
|
|
242
|
+
let diagnostic = '';
|
|
243
|
+
try {
|
|
244
|
+
(0, child_process_1.execFileSync)(process.execPath, [tsNode, '--compiler-options', '{"module":"CommonJS"}', cli,
|
|
245
|
+
'job', 'register', '--generation', 'g1', '--entity', 'cycle-plan', '--json', payload], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
diagnostic = String(error.stderr ?? '');
|
|
249
|
+
}
|
|
250
|
+
expect(diagnostic).toMatch(/entity.*coincidir/);
|
|
251
|
+
expect(fs_1.default.readdirSync((0, paths_1.requestsDir)(repo, 'rama')).filter(name => name.endsWith('.json'))).toHaveLength(0);
|
|
252
|
+
});
|
|
253
|
+
});
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|