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,297 @@
|
|
|
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 path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const gate_1 = require("../../../src/commands/job/gate");
|
|
10
|
+
const reconcile_1 = require("../../../src/commands/job/reconcile");
|
|
11
|
+
const reap_1 = require("../../../src/commands/job/reap");
|
|
12
|
+
const types_1 = require("../../../src/core/journal/types");
|
|
13
|
+
function job(partial) {
|
|
14
|
+
return {
|
|
15
|
+
id: 'j1', fingerprint: 'fp', commandDigest: 'cd', argv: ['npm', 'test'], cwd: '.',
|
|
16
|
+
paths: [], expandedPaths: [], executionState: 'received', observationState: 'progressing',
|
|
17
|
+
phaseTimestamps: {}, ...partial,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const fpCurrent = () => 'fp'; // recomputo == persistido => vigente
|
|
21
|
+
const fpStale = () => 'fp-cambiado'; // el arbol cambio => historica
|
|
22
|
+
/** Estado que SATISFACE el gate: task done, plan de task y de ciclo con pass
|
|
23
|
+
* vigente, verificador requerido cubierto, cero vivos, cero obligaciones. */
|
|
24
|
+
function passingState() {
|
|
25
|
+
const s = (0, types_1.emptyState)('r');
|
|
26
|
+
s.requiredVerifiers = ['test', 'sensors'];
|
|
27
|
+
s.cycleVerificationPlan = [
|
|
28
|
+
{ id: 'cv1', kind: 'qa', satisfiedBy: 'j2' },
|
|
29
|
+
{ id: 'cv2', kind: 'interlock', satisfiedBy: 'j3' },
|
|
30
|
+
];
|
|
31
|
+
s.tasks.push({
|
|
32
|
+
id: 'T1', title: 't', status: 'done', attempts: 1,
|
|
33
|
+
verificationPlan: [
|
|
34
|
+
{ id: 'v1', kind: 'test', satisfiedBy: 'j1' },
|
|
35
|
+
{ id: 'v-sensors', kind: 'sensors', satisfiedBy: 'j4' },
|
|
36
|
+
],
|
|
37
|
+
reviewObligations: [
|
|
38
|
+
{ id: 'o-spec', taskId: 'T1', kind: 'spec', verdictId: 'verd-spec' },
|
|
39
|
+
{ id: 'o-quality', taskId: 'T1', kind: 'quality', verdictId: 'verd-quality' },
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
s.jobs['j1'] = job({ id: 'j1', executionState: 'exited', verdict: 'pass' });
|
|
43
|
+
s.jobs['j2'] = job({ id: 'j2', executionState: 'exited', verdict: 'pass' });
|
|
44
|
+
s.jobs['j3'] = job({ id: 'j3', executionState: 'exited', verdict: 'pass' });
|
|
45
|
+
s.jobs['j4'] = job({ id: 'j4', executionState: 'exited', verdict: 'pass' });
|
|
46
|
+
s.verdicts.push({ id: 'verd-spec', obligationId: 'o-spec', result: 'pass', detail: 'ok', receivedAt: 'now', fingerprint: 'fp', argv: ['review', 'o-spec'], paths: [], cwd: '.' }, { id: 'verd-quality', obligationId: 'o-quality', result: 'pass', detail: 'ok', receivedAt: 'now', fingerprint: 'fp', argv: ['review', 'o-quality'], paths: [], cwd: '.' });
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
describe('gate', () => {
|
|
50
|
+
test('el estado de referencia pasa; la corrupcion bloquea (R3.2)', () => {
|
|
51
|
+
expect((0, gate_1.computeGate)(passingState(), false, fpCurrent).pass).toBe(true);
|
|
52
|
+
const g = (0, gate_1.computeGate)(null, true, fpCurrent);
|
|
53
|
+
expect(g.pass).toBe(false);
|
|
54
|
+
expect(g.reasons.some((r) => r.category === 'corrupt')).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
test('CycleVerificationPlan VACIO bloquea — jamas verde por vacuidad (R1.4b)', () => {
|
|
57
|
+
const s = passingState();
|
|
58
|
+
s.cycleVerificationPlan = [];
|
|
59
|
+
const g = (0, gate_1.computeGate)(s, false, fpCurrent);
|
|
60
|
+
expect(g.pass).toBe(false);
|
|
61
|
+
expect(g.reasons.some((r) => r.category === 'empty-cycle-plan')).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
test('CycleVerificationPlan exige QA e interlock, no solo cualquier item aprobado (R1.4b/R3.2)', () => {
|
|
64
|
+
const s = passingState();
|
|
65
|
+
s.cycleVerificationPlan = s.cycleVerificationPlan.filter((item) => item.kind !== 'interlock');
|
|
66
|
+
const g = (0, gate_1.computeGate)(s, false, fpCurrent);
|
|
67
|
+
expect(g.pass).toBe(false);
|
|
68
|
+
expect(g.reasons.some((r) => r.category === 'missing-verifier' && /interlock/.test(r.detail))).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
test('cada tarea exige obligaciones spec y quality, no certifica por omision', () => {
|
|
71
|
+
const s = passingState();
|
|
72
|
+
s.tasks[0].reviewObligations = [];
|
|
73
|
+
const g = (0, gate_1.computeGate)(s, false, fpCurrent);
|
|
74
|
+
expect(g.pass).toBe(false);
|
|
75
|
+
expect(g.reasons.filter((r) => r.category === 'open-obligation')).toHaveLength(2);
|
|
76
|
+
});
|
|
77
|
+
test('review pass con fingerprint historico no certifica', () => {
|
|
78
|
+
const s = passingState();
|
|
79
|
+
const fingerprintByArgv = (argv) => argv[0] === 'review' ? 'fp-cambiado' : 'fp';
|
|
80
|
+
const g = (0, gate_1.computeGate)(s, false, fingerprintByArgv);
|
|
81
|
+
expect(g.pass).toBe(false);
|
|
82
|
+
expect(g.reasons.some((r) => r.category === 'stale-fingerprint' && /verd-spec/.test(r.detail))).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
test('ausencia de suite o sensors configurados bloquea aunque el plan restante este verde (R3.6)', () => {
|
|
85
|
+
const s = passingState();
|
|
86
|
+
s.requiredVerifiers = ['test'];
|
|
87
|
+
const g = (0, gate_1.computeGate)(s, false, fpCurrent);
|
|
88
|
+
expect(g.pass).toBe(false);
|
|
89
|
+
expect(g.reasons.some((r) => r.category === 'missing-verifier' && /sensors/.test(r.detail))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
test('satisfiedBy colgante (job inexistente) bloquea (R3.2)', () => {
|
|
92
|
+
const s = passingState();
|
|
93
|
+
s.tasks[0].verificationPlan[0].satisfiedBy = 'job-fantasma';
|
|
94
|
+
const g = (0, gate_1.computeGate)(s, false, fpCurrent);
|
|
95
|
+
expect(g.pass).toBe(false);
|
|
96
|
+
expect(g.reasons.some((r) => r.category === 'dangling-reference')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
test('tarea pending o in-progress bloquea (R3.2)', () => {
|
|
99
|
+
const s = passingState();
|
|
100
|
+
s.tasks.push({ id: 'T2', title: 'x', status: 'pending', attempts: 0, verificationPlan: [], reviewObligations: [] });
|
|
101
|
+
expect((0, gate_1.computeGate)(s, false, fpCurrent).reasons.some((r) => r.category === 'pending-task')).toBe(true);
|
|
102
|
+
s.tasks[1].status = 'in-progress';
|
|
103
|
+
expect((0, gate_1.computeGate)(s, false, fpCurrent).reasons.some((r) => r.category === 'pending-task')).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
test('ciclo BLOCKED bloquea el gate (R3.2)', () => {
|
|
106
|
+
const s = passingState();
|
|
107
|
+
s.cycle.status = 'BLOCKED';
|
|
108
|
+
s.cycle.blockedReason = 'custodia';
|
|
109
|
+
expect((0, gate_1.computeGate)(s, false, fpCurrent).reasons.some((r) => r.category === 'cycle-blocked')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
test('fingerprint NO vigente => evidencia historica, bloquea (R1.4c, RF-2.8)', () => {
|
|
112
|
+
const g = (0, gate_1.computeGate)(passingState(), false, fpStale);
|
|
113
|
+
expect(g.pass).toBe(false);
|
|
114
|
+
expect(g.reasons.some((r) => r.category === 'stale-fingerprint')).toBe(true);
|
|
115
|
+
// recomputo imposible (null) tampoco certifica
|
|
116
|
+
const g2 = (0, gate_1.computeGate)(passingState(), false, () => null);
|
|
117
|
+
expect(g2.reasons.some((r) => r.category === 'stale-fingerprint')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
test('solo pass satisface: fail/inconclusive bloquean; sin verdict bloquea (R1.4c)', () => {
|
|
120
|
+
const s = passingState();
|
|
121
|
+
s.jobs['j1'].verdict = 'fail';
|
|
122
|
+
expect((0, gate_1.computeGate)(s, false, fpCurrent).reasons.some((r) => r.category === 'adverse-verdict')).toBe(true);
|
|
123
|
+
s.jobs['j1'].verdict = 'inconclusive';
|
|
124
|
+
expect((0, gate_1.computeGate)(s, false, fpCurrent).reasons.some((r) => r.category === 'adverse-verdict')).toBe(true);
|
|
125
|
+
delete s.jobs['j1'].verdict;
|
|
126
|
+
expect((0, gate_1.computeGate)(s, false, fpCurrent).reasons.some((r) => r.category === 'adverse-verdict')).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
test('item kind review se satisface con VERDICT pass, no con job (R1.4c)', () => {
|
|
129
|
+
const s = passingState();
|
|
130
|
+
s.tasks[0].verificationPlan.push({ id: 'v2', kind: 'review', satisfiedBy: 'verd-1' });
|
|
131
|
+
s.verdicts.push({ id: 'verd-1', obligationId: 'o1', result: 'pass', detail: 'ok', receivedAt: 'now', fingerprint: 'fp', argv: ['review', 'o1'], paths: [], cwd: '.' });
|
|
132
|
+
expect((0, gate_1.computeGate)(s, false, fpCurrent).pass).toBe(true);
|
|
133
|
+
s.verdicts[0].result = 'fail';
|
|
134
|
+
const g = (0, gate_1.computeGate)(s, false, fpCurrent);
|
|
135
|
+
expect(g.reasons.some((r) => r.category === 'adverse-verdict')).toBe(true);
|
|
136
|
+
expect(g.reasons.some((r) => r.category === 'open-fix')).toBe(true); // adverso sin fix cerrado
|
|
137
|
+
});
|
|
138
|
+
test('item sin satisfacer, job vivo, orphaned y obligacion abierta bloquean (R3.2, R4.5)', () => {
|
|
139
|
+
const s = passingState();
|
|
140
|
+
s.tasks[0].verificationPlan.push({ id: 'v3', kind: 'sensors' });
|
|
141
|
+
expect((0, gate_1.computeGate)(s, false, fpCurrent).reasons.some((r) => r.category === 'unsatisfied-plan')).toBe(true);
|
|
142
|
+
const s2 = passingState();
|
|
143
|
+
s2.jobs['vivo'] = job({ id: 'vivo', executionState: 'running' });
|
|
144
|
+
expect((0, gate_1.computeGate)(s2, false, fpCurrent).reasons.some((r) => r.category === 'live-job')).toBe(true);
|
|
145
|
+
s2.jobs['vivo'].executionState = 'orphaned';
|
|
146
|
+
expect((0, gate_1.computeGate)(s2, false, fpCurrent).reasons.some((r) => r.category === 'live-job')).toBe(true);
|
|
147
|
+
const s3 = passingState();
|
|
148
|
+
s3.tasks[0].reviewObligations.push({ id: 'o9', taskId: 'T1', kind: 'spec' });
|
|
149
|
+
expect((0, gate_1.computeGate)(s3, false, fpCurrent).reasons.some((r) => r.category === 'open-obligation')).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
test('verificador requerido por el repo sin item en ningun plan bloquea (R1.4b, R3.6)', () => {
|
|
152
|
+
const s = passingState();
|
|
153
|
+
s.tasks[0].verificationPlan = s.tasks[0].verificationPlan.filter((item) => item.kind !== 'sensors');
|
|
154
|
+
const g = (0, gate_1.computeGate)(s, false, fpCurrent); // ningun item kind 'sensors' existe
|
|
155
|
+
expect(g.pass).toBe(false);
|
|
156
|
+
expect(g.reasons.some((r) => r.category === 'missing-verifier' && /sensors/.test(r.detail))).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
test('reviewObligation con verdictId colgante o adverso bloquea, no solo si esta ausente (R3.2, R1.4c)', () => {
|
|
159
|
+
const s = passingState();
|
|
160
|
+
s.tasks[0].reviewObligations.push({ id: 'o1', taskId: 'T1', kind: 'spec', verdictId: 'verd-fantasma' });
|
|
161
|
+
const g = (0, gate_1.computeGate)(s, false, fpCurrent);
|
|
162
|
+
expect(g.pass).toBe(false);
|
|
163
|
+
expect(g.reasons.some((r) => r.category === 'dangling-reference')).toBe(true);
|
|
164
|
+
const s2 = passingState();
|
|
165
|
+
s2.verdicts.push({ id: 'verd-2', obligationId: 'o2', result: 'fail', detail: 'no', receivedAt: 'now', fingerprint: 'fp', argv: ['review', 'o2'], paths: [], cwd: '.' });
|
|
166
|
+
s2.tasks[0].reviewObligations.push({ id: 'o2', taskId: 'T1', kind: 'spec', verdictId: 'verd-2' });
|
|
167
|
+
const g2 = (0, gate_1.computeGate)(s2, false, fpCurrent);
|
|
168
|
+
expect(g2.pass).toBe(false);
|
|
169
|
+
expect(g2.reasons.some((r) => r.category === 'adverse-verdict')).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe('reconcile — matriz unica R1.8 (R3.3)', () => {
|
|
173
|
+
let logs;
|
|
174
|
+
beforeEach(() => { logs = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-rec-')); });
|
|
175
|
+
afterEach(() => { fs_1.default.rmSync(logs, { recursive: true, force: true }); });
|
|
176
|
+
test('sin claim => reintenta mismo intent; claim+resultado => adoptar; claim sin resultado => orphaned', () => {
|
|
177
|
+
const s = (0, types_1.emptyState)('r');
|
|
178
|
+
const deadRef = { pid: 999999, startTime: 'gone', spawnNonce: 'n1', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
|
|
179
|
+
s.jobs['a'] = job({ id: 'a', executionState: 'spawn-intent', spawnNonce: 'nA', processRef: { ...deadRef, spawnNonce: 'nA' } });
|
|
180
|
+
s.jobs['b'] = job({ id: 'b', executionState: 'running', spawnNonce: 'nB', processRef: { ...deadRef, spawnNonce: 'nB' } });
|
|
181
|
+
s.jobs['c'] = job({ id: 'c', executionState: 'running', spawnNonce: 'nC', processRef: { ...deadRef, spawnNonce: 'nC' } });
|
|
182
|
+
// b: claim + resultado => adoptar
|
|
183
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'b.nB.claim'), '{}');
|
|
184
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'b.nB.result.json'), JSON.stringify({ exitCode: 0, endedAt: 'x', resultPath: 'p' }));
|
|
185
|
+
// c: claim sin resultado => orphaned
|
|
186
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'c.nC.claim'), '{}');
|
|
187
|
+
const out = (0, reconcile_1.reconcileJobs)(s, logs);
|
|
188
|
+
expect(out.decisions.find((d) => d.jobId === 'a').action).toBe('retry-same-intent');
|
|
189
|
+
expect(out.decisions.find((d) => d.jobId === 'b').action).toBe('adopt-result');
|
|
190
|
+
expect(s.jobs['b'].executionState).toBe('exited');
|
|
191
|
+
expect(s.jobs['b'].verdict).toBe('pass');
|
|
192
|
+
expect(out.decisions.find((d) => d.jobId === 'c').action).toBe('orphaned-authorization-required');
|
|
193
|
+
expect(s.jobs['c'].executionState).toBe('orphaned');
|
|
194
|
+
});
|
|
195
|
+
test('materializeRetry crea Attempt NUEVO enlazado, nunca reutiliza (R1.7)', () => {
|
|
196
|
+
const s = (0, types_1.emptyState)('r');
|
|
197
|
+
s.jobs['a'] = job({ id: 'a', executionState: 'spawn-intent', spawnNonce: 'nA' });
|
|
198
|
+
s.tasks.push({ id: 'T1', title: 't', status: 'in-progress', attempts: 1,
|
|
199
|
+
verificationPlan: [{ id: 'v1', kind: 'test', satisfiedBy: 'a' }], reviewObligations: [] });
|
|
200
|
+
s.cycleVerificationPlan.push({ id: 'cv1', kind: 'qa', satisfiedBy: 'a' });
|
|
201
|
+
const nuevo = (0, reconcile_1.materializeRetry)(s, 'a');
|
|
202
|
+
expect(s.jobs['a'].executionState).toBe('cancelled'); // el intento viejo se retira
|
|
203
|
+
expect(nuevo.attemptOf).toBe('a');
|
|
204
|
+
expect(nuevo.executionState).toBe('received');
|
|
205
|
+
expect(nuevo.spawnNonce).toBeUndefined(); // nonce fresco lo asigna el runner
|
|
206
|
+
expect(s.jobs[nuevo.id]).toBe(nuevo);
|
|
207
|
+
expect(s.tasks[0].verificationPlan[0].satisfiedBy).toBe(nuevo.id);
|
|
208
|
+
expect(s.cycleVerificationPlan[0].satisfiedBy).toBe(nuevo.id);
|
|
209
|
+
});
|
|
210
|
+
test('claim+resultado con forma invalida (JSON valido pero sin exitCode numerico) => orphaned, jamas fabricar verdict (R1.6)', () => {
|
|
211
|
+
const s = (0, types_1.emptyState)('r');
|
|
212
|
+
const deadRef = { pid: 999999, startTime: 'gone', spawnNonce: 'nD', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
|
|
213
|
+
s.jobs['d'] = job({ id: 'd', executionState: 'running', spawnNonce: 'nD', processRef: { ...deadRef, spawnNonce: 'nD' } });
|
|
214
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'd.nD.claim'), '{}');
|
|
215
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'd.nD.result.json'), JSON.stringify({})); // sin exitCode: forma invalida
|
|
216
|
+
const out = (0, reconcile_1.reconcileJobs)(s, logs);
|
|
217
|
+
expect(out.decisions.find((d) => d.jobId === 'd').action).toBe('orphaned-authorization-required');
|
|
218
|
+
expect(s.jobs['d'].executionState).toBe('orphaned');
|
|
219
|
+
expect(s.jobs['d'].verdict).toBeUndefined(); // jamas fabricado de un sidecar sin forma
|
|
220
|
+
});
|
|
221
|
+
test('claim+resultado con JSON invalido (parse error) => orphaned, no crashea (R1.6)', () => {
|
|
222
|
+
const s = (0, types_1.emptyState)('r');
|
|
223
|
+
const deadRef = { pid: 999999, startTime: 'gone', spawnNonce: 'nE', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
|
|
224
|
+
s.jobs['e'] = job({ id: 'e', executionState: 'running', spawnNonce: 'nE', processRef: { ...deadRef, spawnNonce: 'nE' } });
|
|
225
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'e.nE.claim'), '{}');
|
|
226
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'e.nE.result.json'), '{ esto no es json valido');
|
|
227
|
+
let out;
|
|
228
|
+
expect(() => { out = (0, reconcile_1.reconcileJobs)(s, logs); }).not.toThrow();
|
|
229
|
+
expect(out.decisions.find((d) => d.jobId === 'e').action).toBe('orphaned-authorization-required');
|
|
230
|
+
expect(s.jobs['e'].executionState).toBe('orphaned');
|
|
231
|
+
});
|
|
232
|
+
test('adopt-result respalda spawnNonce resuelto via processRef, para que la evidencia siga siendo observable (R1.3, RNF-T.9)', () => {
|
|
233
|
+
const s = (0, types_1.emptyState)('r');
|
|
234
|
+
const deadRef = { pid: 999999, startTime: 'gone', spawnNonce: 'nR', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
|
|
235
|
+
s.jobs['r1'] = job({ id: 'r1', executionState: 'running', processRef: deadRef }); // spawnNonce propio ausente adrede
|
|
236
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'r1.nR.claim'), '{}');
|
|
237
|
+
fs_1.default.writeFileSync(path_1.default.join(logs, 'r1.nR.result.json'), JSON.stringify({ exitCode: 0, endedAt: 'x', resultPath: 'p' }));
|
|
238
|
+
(0, reconcile_1.reconcileJobs)(s, logs);
|
|
239
|
+
expect(s.jobs['r1'].spawnNonce).toBe('nR');
|
|
240
|
+
expect(s.jobs['r1'].executionState).toBe('exited');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
describe('reap — limpieza explicita con identidad validada (R2.2)', () => {
|
|
244
|
+
test('planReap reporta aliveWithIdentity via refIsAlive, solo para jobs con processRef', () => {
|
|
245
|
+
const s = (0, types_1.emptyState)('r');
|
|
246
|
+
const deadRef = { pid: 999999, startTime: 'gone', spawnNonce: 'n1', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
|
|
247
|
+
s.jobs['sinRef'] = job({ id: 'sinRef', executionState: 'running' });
|
|
248
|
+
s.jobs['muerto'] = job({ id: 'muerto', executionState: 'running', processRef: deadRef });
|
|
249
|
+
const plan = (0, reap_1.planReap)(s);
|
|
250
|
+
expect(plan.find((p) => p.jobId === 'sinRef')).toBeUndefined();
|
|
251
|
+
const m = plan.find((p) => p.jobId === 'muerto');
|
|
252
|
+
expect(m.pid).toBe(999999);
|
|
253
|
+
expect(m.aliveWithIdentity).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
test('planReap reporta aliveWithIdentity: true para un proceso genuinamente vivo', () => {
|
|
256
|
+
const { spawnStructured } = require('../../../src/core/journal/process');
|
|
257
|
+
const s = (0, types_1.emptyState)('r');
|
|
258
|
+
const { child, ref } = spawnStructured(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nLive');
|
|
259
|
+
s.jobs['vivo'] = job({ id: 'vivo', executionState: 'running', processRef: ref });
|
|
260
|
+
try {
|
|
261
|
+
const plan = (0, reap_1.planReap)(s);
|
|
262
|
+
const m = plan.find((p) => p.jobId === 'vivo');
|
|
263
|
+
expect(m.pid).toBe(ref.pid);
|
|
264
|
+
expect(m.aliveWithIdentity).toBe(true);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
child.kill('SIGKILL');
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
test('executeReap nunca señaliza sin processRef ni sin identidad viva confirmada (R2.1, R2.2)', async () => {
|
|
271
|
+
const s = (0, types_1.emptyState)('r');
|
|
272
|
+
const deadRef = { pid: 999999, startTime: 'gone', spawnNonce: 'n1', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
|
|
273
|
+
s.jobs['sinRef'] = job({ id: 'sinRef', executionState: 'running' });
|
|
274
|
+
s.jobs['muerto'] = job({ id: 'muerto', executionState: 'running', processRef: deadRef });
|
|
275
|
+
const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true);
|
|
276
|
+
try {
|
|
277
|
+
const killed = await (0, reap_1.executeReap)(s, ['sinRef', 'muerto', 'no-existe']);
|
|
278
|
+
expect(killed).toEqual([]);
|
|
279
|
+
// no solo el resultado: nunca se INTENTO ninguna señal (R2.1) —
|
|
280
|
+
// la ausencia de identidad viva confirmada corta antes de llamar
|
|
281
|
+
// a terminateGroupConfirmed/process.kill, no despues.
|
|
282
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
killSpy.mockRestore();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
test('executeReap mata y reporta solo jobs con muerte confirmada', async () => {
|
|
289
|
+
const { spawnStructured } = require('../../../src/core/journal/process');
|
|
290
|
+
const s = (0, types_1.emptyState)('r');
|
|
291
|
+
const { child, ref } = spawnStructured(['node', '-e', 'setTimeout(()=>{}, 5000)'], process.cwd(), 'nX');
|
|
292
|
+
s.jobs['vivo'] = job({ id: 'vivo', executionState: 'running', processRef: ref });
|
|
293
|
+
const killed = await (0, reap_1.executeReap)(s, ['vivo']);
|
|
294
|
+
expect(killed).toEqual(['vivo']);
|
|
295
|
+
child.kill('SIGKILL');
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
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 path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const commander_1 = require("commander");
|
|
11
|
+
const job_1 = require("../../../src/commands/job");
|
|
12
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
13
|
+
// CLI-level wiring de `job reap` (Gap 2 del post-implementation-qa): las
|
|
14
|
+
// funciones puras planReap/executeReap ya estan cubiertas en
|
|
15
|
+
// gate-reconcile.test.ts — esto prueba el comando REGISTRADO: (a) --execute
|
|
16
|
+
// sin --jobs falla con un mensaje claro, no un stack trace crudo; (b) el plan
|
|
17
|
+
// SIEMPRE se imprime antes de cualquier ejecucion (R2.2).
|
|
18
|
+
function gitInit(repo) {
|
|
19
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'init', '-q', '-b', 'rama'], { cwd: repo });
|
|
20
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
|
|
21
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: repo });
|
|
22
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'c'], { cwd: repo });
|
|
23
|
+
}
|
|
24
|
+
function job(partial) {
|
|
25
|
+
return {
|
|
26
|
+
id: 'j1', fingerprint: 'fp', commandDigest: 'cd', argv: ['npm', 'test'], cwd: '.',
|
|
27
|
+
paths: [], expandedPaths: [], executionState: 'running', observationState: 'progressing',
|
|
28
|
+
phaseTimestamps: {}, ...partial,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async function run(argv, cwd) {
|
|
32
|
+
const prog = new commander_1.Command();
|
|
33
|
+
prog.exitOverride();
|
|
34
|
+
(0, job_1.registerJobCommand)(prog);
|
|
35
|
+
const spy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
36
|
+
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(cwd);
|
|
37
|
+
let error = null;
|
|
38
|
+
try {
|
|
39
|
+
await prog.parseAsync(['node', 'awm', 'job', ...argv]);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
error = e;
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
cwdSpy.mockRestore();
|
|
46
|
+
}
|
|
47
|
+
const out = spy.mock.calls.map((c) => String(c[0])).join('');
|
|
48
|
+
spy.mockRestore();
|
|
49
|
+
return { out, error };
|
|
50
|
+
}
|
|
51
|
+
describe('awm job reap CLI wiring (R2.2)', () => {
|
|
52
|
+
let repo;
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-reap-cli-'));
|
|
55
|
+
gitInit(repo);
|
|
56
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
57
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
58
|
+
// job sin processRef: excluido del plan (planReap solo reporta jobs con processRef)
|
|
59
|
+
s.jobs['sinRef'] = job({ id: 'sinRef' });
|
|
60
|
+
// job con processRef de un pid que ya no existe: aliveWithIdentity: false
|
|
61
|
+
s.jobs['muerto'] = job({
|
|
62
|
+
id: 'muerto',
|
|
63
|
+
processRef: { pid: 999999, startTime: 'gone', spawnNonce: 'n1', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' },
|
|
64
|
+
});
|
|
65
|
+
(0, store_1.writeJournal)(repo, 'rama', s);
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
68
|
+
test('`job reap` sin --execute imprime el plan a stdout y no ejecuta nada', async () => {
|
|
69
|
+
const { out, error } = await run(['reap'], repo);
|
|
70
|
+
expect(error).toBeNull();
|
|
71
|
+
const plan = JSON.parse(out);
|
|
72
|
+
expect(Array.isArray(plan)).toBe(true);
|
|
73
|
+
// solo el job con processRef aparece
|
|
74
|
+
expect(plan).toHaveLength(1);
|
|
75
|
+
expect(plan[0]).toMatchObject({ jobId: 'muerto', pid: 999999, aliveWithIdentity: false });
|
|
76
|
+
// no se imprimio un segundo bloque {killed: ...} — no hubo ejecucion
|
|
77
|
+
expect(out).not.toContain('killed');
|
|
78
|
+
});
|
|
79
|
+
test('`job reap --execute` sin --jobs falla con un error claro, no un stack trace crudo', async () => {
|
|
80
|
+
const { out, error } = await run(['reap', '--execute'], repo);
|
|
81
|
+
expect(error).not.toBeNull();
|
|
82
|
+
expect(error.message).toMatch(/--execute requiere --jobs/);
|
|
83
|
+
// R2.2: el plan se imprimio ANTES de que la falta de --jobs abortara la ejecucion
|
|
84
|
+
const plan = JSON.parse(out);
|
|
85
|
+
expect(Array.isArray(plan)).toBe(true);
|
|
86
|
+
expect(plan).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
test('`job reap --execute --jobs <id>` imprime el plan primero y luego el resultado de la ejecucion', async () => {
|
|
89
|
+
const { out, error } = await run(['reap', '--execute', '--jobs', 'muerto'], repo);
|
|
90
|
+
expect(error).toBeNull();
|
|
91
|
+
// dos bloques JSON concatenados: el plan, despues {killed:[...]}
|
|
92
|
+
const planEndIdx = out.indexOf('\n]\n') + 3;
|
|
93
|
+
const planPart = out.slice(0, planEndIdx);
|
|
94
|
+
const resultPart = out.slice(planEndIdx);
|
|
95
|
+
const plan = JSON.parse(planPart);
|
|
96
|
+
expect(plan).toHaveLength(1);
|
|
97
|
+
const result = JSON.parse(resultPart);
|
|
98
|
+
// identidad muerta => nunca se envio senial, killed vacio (R2.1)
|
|
99
|
+
expect(result).toEqual({ killed: [] });
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
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 path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const request_1 = require("../../../src/commands/job/request");
|
|
11
|
+
const heartbeat_1 = require("../../../src/commands/job/heartbeat");
|
|
12
|
+
const query_1 = require("../../../src/commands/job/query");
|
|
13
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
14
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
15
|
+
const requests_1 = require("../../../src/core/journal/requests");
|
|
16
|
+
function gitInit(repo) {
|
|
17
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'init', '-q'], { cwd: repo });
|
|
18
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
|
|
19
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: repo });
|
|
20
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'c'], { cwd: repo });
|
|
21
|
+
}
|
|
22
|
+
describe('job verbs', () => {
|
|
23
|
+
let repo;
|
|
24
|
+
beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-verbs-')); gitInit(repo); (0, store_1.initJournal)(repo, 'rama'); });
|
|
25
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
26
|
+
test('requestJob emite request con get-or-create key = hash(fingerprint+cmd) y cwd real (R3.1)', () => {
|
|
27
|
+
const a = (0, request_1.requestJob)(repo, 'rama', 'gen-1', ['npm', 'test'], [], '.');
|
|
28
|
+
const b = (0, request_1.requestJob)(repo, 'rama', 'gen-1', ['npm', 'test'], [], '.');
|
|
29
|
+
expect(a.idempotencyKey).toBe(b.idempotencyKey); // mismo fingerprint+cmd => misma key (RNF-T.7)
|
|
30
|
+
fs_1.default.mkdirSync(path_1.default.join(repo, 'otro-cwd-logico'));
|
|
31
|
+
const c = (0, request_1.requestJob)(repo, 'rama', 'gen-1', ['npm', 'test'], [], 'otro-cwd-logico');
|
|
32
|
+
expect(c.idempotencyKey).not.toBe(a.idempotencyKey); // cwd distinto => key distinta (R3.4)
|
|
33
|
+
expect((0, requests_1.listPendingRequests)(repo, 'rama').length).toBe(3); // el supervisor colapsa por key
|
|
34
|
+
});
|
|
35
|
+
test('emitHeartbeat publica request de heartbeat (R3.5)', () => {
|
|
36
|
+
(0, heartbeat_1.emitHeartbeat)(repo, 'rama', 'gen-1');
|
|
37
|
+
const pending = (0, requests_1.listPendingRequests)(repo, 'rama');
|
|
38
|
+
expect(pending.some((p) => !p.corrupt && p.envelope.kind === 'controller-heartbeat')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
test('queryPs/list/show reportan corrupt visible, no lo descartan (R1.6)', () => {
|
|
41
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
42
|
+
s.jobs['j1'] = {
|
|
43
|
+
id: 'j1', fingerprint: 'fp', commandDigest: 'cd', argv: ['npm', 'test'], cwd: '.',
|
|
44
|
+
paths: [], expandedPaths: [], executionState: 'received', observationState: 'progressing', phaseTimestamps: {},
|
|
45
|
+
};
|
|
46
|
+
(0, store_1.writeJournal)(repo, 'rama', s);
|
|
47
|
+
expect((0, query_1.queryPs)(repo, 'rama').corruptState).toBe(false);
|
|
48
|
+
expect((0, query_1.queryList)(repo, 'rama').jobs).toHaveLength(1);
|
|
49
|
+
expect((0, query_1.queryShow)(repo, 'rama', 'j1').job.id).toBe('j1');
|
|
50
|
+
expect((0, query_1.queryShow)(repo, 'rama', 'no-existe').job).toBeNull();
|
|
51
|
+
fs_1.default.writeFileSync((0, paths_1.statePath)(repo, 'rama'), '{roto');
|
|
52
|
+
expect((0, query_1.queryPs)(repo, 'rama').corruptState).toBe(true);
|
|
53
|
+
expect((0, query_1.queryList)(repo, 'rama').corruptState).toBe(true);
|
|
54
|
+
expect((0, query_1.queryShow)(repo, 'rama', 'j1').corruptState).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
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 path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const commander_1 = require("commander");
|
|
11
|
+
const job_1 = require("../../../src/commands/job");
|
|
12
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
13
|
+
const requests_1 = require("../../../src/core/journal/requests");
|
|
14
|
+
const apply_1 = require("../../../src/commands/watch/apply");
|
|
15
|
+
const gate_1 = require("../../../src/commands/job/gate");
|
|
16
|
+
// Fix 3 (post-implementation-qa): `job verdict`'s verdictId era
|
|
17
|
+
// Date.now()+random en cada invocacion aunque idempotencyKey ya era
|
|
18
|
+
// determinista — un retry legitimo del MISMO comando producia un payload
|
|
19
|
+
// distinto (verdictId distinto => payloadDigest distinto) y, tras Fix 1,
|
|
20
|
+
// caia a rejected-digest-mismatch en vez de reconocerse como el mismo alias.
|
|
21
|
+
// Esto prueba el comando CLI REGISTRADO (no la logica interna de apply.ts):
|
|
22
|
+
// invocaciones identicas producen el MISMO verdictId, byte-identico.
|
|
23
|
+
function gitInit(repo) {
|
|
24
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'init', '-q', '-b', 'rama'], { cwd: repo });
|
|
25
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
|
|
26
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'add', '.'], { cwd: repo });
|
|
27
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'c'], { cwd: repo });
|
|
28
|
+
}
|
|
29
|
+
async function run(argv, cwd) {
|
|
30
|
+
const prog = new commander_1.Command();
|
|
31
|
+
prog.exitOverride();
|
|
32
|
+
(0, job_1.registerJobCommand)(prog);
|
|
33
|
+
const spy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
34
|
+
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(cwd);
|
|
35
|
+
let error = null;
|
|
36
|
+
try {
|
|
37
|
+
await prog.parseAsync(['node', 'awm', 'job', ...argv]);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
error = e;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
cwdSpy.mockRestore();
|
|
44
|
+
}
|
|
45
|
+
const out = spy.mock.calls.map((c) => String(c[0])).join('');
|
|
46
|
+
spy.mockRestore();
|
|
47
|
+
return { out, error };
|
|
48
|
+
}
|
|
49
|
+
describe('`job verdict` verdictId determinista (Fix 3)', () => {
|
|
50
|
+
let repo;
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-verdict-cli-'));
|
|
53
|
+
gitInit(repo);
|
|
54
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
55
|
+
});
|
|
56
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
57
|
+
test('dos invocaciones identicas producen el MISMO verdictId', async () => {
|
|
58
|
+
const args = ['verdict', '--generation', 'g1', '--obligation', 'o1', '--result', 'fail', '--detail', 'rompe X'];
|
|
59
|
+
const a = await run(args, repo);
|
|
60
|
+
const b = await run(args, repo);
|
|
61
|
+
expect(a.error).toBeNull();
|
|
62
|
+
expect(b.error).toBeNull();
|
|
63
|
+
const verdictIdA = JSON.parse(a.out).verdictId;
|
|
64
|
+
const verdictIdB = JSON.parse(b.out).verdictId;
|
|
65
|
+
expect(verdictIdA).toBe(verdictIdB);
|
|
66
|
+
// ademas: mismo idempotencyKey Y mismo payloadDigest (byte-identico) para
|
|
67
|
+
// ambas requests emitidas — un retry real, no un digest-mismatch espurio.
|
|
68
|
+
const pending = (0, requests_1.listPendingRequests)(repo, 'rama').filter((p) => !p.corrupt);
|
|
69
|
+
expect(pending).toHaveLength(2);
|
|
70
|
+
expect(pending[0].envelope.idempotencyKey).toBe(pending[1].envelope.idempotencyKey);
|
|
71
|
+
expect(JSON.stringify(pending[0].envelope.payload)).toBe(JSON.stringify(pending[1].envelope.payload));
|
|
72
|
+
});
|
|
73
|
+
test('generation distinta produce un verdictId distinto (evita colision entre generaciones)', async () => {
|
|
74
|
+
const a = await run(['verdict', '--generation', 'g1', '--obligation', 'o1', '--result', 'fail', '--detail', 'x'], repo);
|
|
75
|
+
const b = await run(['verdict', '--generation', 'g2', '--obligation', 'o1', '--result', 'fail', '--detail', 'x'], repo);
|
|
76
|
+
expect(JSON.parse(a.out).verdictId).not.toBe(JSON.parse(b.out).verdictId);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
// Regresion adversarial reviewer (post-624a4c0): 624a4c0 hizo verdictId
|
|
80
|
+
// generation-scoped pero DEJO idempotencyKey sin generation. Como
|
|
81
|
+
// payloadDigest se computa sobre TODO el payload (que embebe verdictId), un
|
|
82
|
+
// segundo veredicto con MISMA obligacion/result/detail pero OTRA generation
|
|
83
|
+
// (ej. controller crasheo y relanzo con nuevo generationToken entre dos
|
|
84
|
+
// submits — ver watch/generations.ts beginGeneration, esto pasa en CADA
|
|
85
|
+
// relanzamiento) produce: MISMA idempotencyKey (no incluia generation) pero
|
|
86
|
+
// payloadDigest DISTINTO (verdictId si difiere) => cae al chequeo proactivo
|
|
87
|
+
// de Fix 1 en consumePendingRequests y se rechaza como
|
|
88
|
+
// rejected-digest-mismatch en vez de aplicarse como el veredicto
|
|
89
|
+
// genuinamente nuevo y distinto que en realidad es — socavando directamente
|
|
90
|
+
// Fix 2 (cierre de FixObligation en re-review pass) si el pass llega bajo
|
|
91
|
+
// otra generation que el fail original.
|
|
92
|
+
describe('verdict idempotencyKey alineada en scope con verdictId (regresion adversarial reviewer)', () => {
|
|
93
|
+
let repo;
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-verdict-scope-'));
|
|
96
|
+
gitInit(repo);
|
|
97
|
+
(0, store_1.initJournal)(repo, 'rama');
|
|
98
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
99
|
+
kind: 'register-entity', generationToken: 'g0', idempotencyKey: 'reg-e1',
|
|
100
|
+
payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [], reviewObligations: [{ id: 'o1', kind: 'spec' }] },
|
|
101
|
+
});
|
|
102
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g0');
|
|
103
|
+
});
|
|
104
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
105
|
+
test('ESCENARIO EXACTO del reviewer: fail bajo g1 abre un fix que bloquea el gate; el MISMO veredicto (obligacion/result/detail) reenviado bajo g2 se APLICA, no se rechaza por digest-mismatch', async () => {
|
|
106
|
+
const fpNull = () => null;
|
|
107
|
+
const a = await run(['verdict', '--generation', 'g1', '--obligation', 'o1', '--result', 'fail', '--detail', 'rompe X'], repo);
|
|
108
|
+
expect(a.error).toBeNull();
|
|
109
|
+
const out1 = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
110
|
+
expect(out1).toEqual({ applied: 1, rejectedStale: 0, rejectedDigest: 0, rejectedInvalid: 0, corrupt: 0 });
|
|
111
|
+
let s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
112
|
+
const verdictIdFail = JSON.parse(a.out).verdictId;
|
|
113
|
+
expect(s.fixes.find((f) => f.verdictId === verdictIdFail).closed).toBe(false);
|
|
114
|
+
expect((0, gate_1.computeGate)(s, false, fpNull).reasons.some((r) => r.category === 'open-fix')).toBe(true);
|
|
115
|
+
// controller crashea y relanza (beginGeneration produce un token nuevo) —
|
|
116
|
+
// reenvia el MISMO veredicto (misma obligacion/result/detail) bajo g2.
|
|
117
|
+
const b = await run(['verdict', '--generation', 'g2', '--obligation', 'o1', '--result', 'fail', '--detail', 'rompe X'], repo);
|
|
118
|
+
expect(b.error).toBeNull();
|
|
119
|
+
const out2 = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g2');
|
|
120
|
+
expect(out2.rejectedDigest).toBe(0); // <- ANTES del fix: esto era 1 (rejected-digest-mismatch espurio)
|
|
121
|
+
expect(out2).toEqual({ applied: 1, rejectedStale: 0, rejectedDigest: 0, rejectedInvalid: 0, corrupt: 0 });
|
|
122
|
+
s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
123
|
+
expect(s.verdicts).toHaveLength(2); // el segundo veredicto, genuinamente distinto, quedo registrado
|
|
124
|
+
});
|
|
125
|
+
test('un veredicto PASS reenviado bajo OTRA generation que el fail original sigue cerrando el fix (Fix2 sobrevive al alineado de scope)', async () => {
|
|
126
|
+
const fpNull = () => null;
|
|
127
|
+
const a = await run(['verdict', '--generation', 'g1', '--obligation', 'o1', '--result', 'fail', '--detail', 'rompe X'], repo);
|
|
128
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
129
|
+
const verdictIdFail = JSON.parse(a.out).verdictId;
|
|
130
|
+
const b = await run(['verdict', '--generation', 'g2', '--obligation', 'o1', '--result', 'pass', '--detail', 'arreglado'], repo);
|
|
131
|
+
expect(b.error).toBeNull();
|
|
132
|
+
const out2 = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g2');
|
|
133
|
+
expect(out2).toEqual({ applied: 1, rejectedStale: 0, rejectedDigest: 0, rejectedInvalid: 0, corrupt: 0 });
|
|
134
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
135
|
+
expect(s.fixes.find((f) => f.verdictId === verdictIdFail).closed).toBe(true);
|
|
136
|
+
expect((0, gate_1.computeGate)(s, false, fpNull).reasons.some((r) => r.category === 'open-fix')).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|