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,397 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const fs_1 = __importDefault(require("fs"));
|
|
40
|
+
const path_1 = __importDefault(require("path"));
|
|
41
|
+
const os_1 = __importDefault(require("os"));
|
|
42
|
+
const apply_1 = require("../../../src/commands/watch/apply");
|
|
43
|
+
const requests_1 = require("../../../src/core/journal/requests");
|
|
44
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
45
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
46
|
+
const atomicFile = __importStar(require("../../../src/core/atomic-file"));
|
|
47
|
+
const gate_1 = require("../../../src/commands/job/gate");
|
|
48
|
+
function jobPayload(argv) {
|
|
49
|
+
return { argv, paths: [], cwd: '.', fingerprint: 'fp-1', commandDigest: 'cd-1', expandedPaths: [] };
|
|
50
|
+
}
|
|
51
|
+
function verdictPayload(verdictId, obligationId, result, detail) {
|
|
52
|
+
return { verdictId, obligationId, result, detail, fingerprint: 'fp-review', argv: ['awm-review', obligationId], paths: [], cwd: '.' };
|
|
53
|
+
}
|
|
54
|
+
describe('aplicacion transaccional de requests', () => {
|
|
55
|
+
let repo;
|
|
56
|
+
beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-apply-')); (0, store_1.initJournal)(repo, 'rama'); });
|
|
57
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
58
|
+
test('orden estado->journal->borrado; replay tras crash NO re-aplica (R1.3)', () => {
|
|
59
|
+
const r1 = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k1', payload: jobPayload(['npm', 'test']) });
|
|
60
|
+
const file = r1.file;
|
|
61
|
+
const savedBody = fs_1.default.readFileSync(file, 'utf8'); // copia para simular crash pre-borrado
|
|
62
|
+
const out1 = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
63
|
+
expect(out1.applied).toBe(1);
|
|
64
|
+
expect(fs_1.default.existsSync(file)).toBe(false); // borrado DESPUES del journal
|
|
65
|
+
const s1 = (0, store_1.readJournal)(repo, 'rama').state;
|
|
66
|
+
expect(Object.keys(s1.jobs)).toHaveLength(1);
|
|
67
|
+
expect(s1.appliedRequests[r1.requestId].resultRef).toBe(Object.keys(s1.jobs)[0]);
|
|
68
|
+
// CRASH SIMULADO: el journal persistio pero el archivo NO se borro — lo restauramos
|
|
69
|
+
fs_1.default.writeFileSync(file, savedBody);
|
|
70
|
+
const out2 = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
71
|
+
expect(out2.applied).toBe(0); // requestId ya registrado: no re-aplica
|
|
72
|
+
expect(fs_1.default.existsSync(file)).toBe(false); // solo borra
|
|
73
|
+
expect(Object.keys((0, store_1.readJournal)(repo, 'rama').state.jobs)).toHaveLength(1); // sin duplicados
|
|
74
|
+
});
|
|
75
|
+
test('register-entity CREA task con VerificationPlan y ReviewObligations; cycle-plan y dispatch (R1.4/R1.4b)', () => {
|
|
76
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
77
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
78
|
+
payload: {
|
|
79
|
+
entity: 'task', taskId: 'T1', title: 'implementar',
|
|
80
|
+
verificationPlan: [{ id: 'v1', kind: 'test' }],
|
|
81
|
+
reviewObligations: [{ id: 'o1', kind: 'spec' }],
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
85
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e2',
|
|
86
|
+
payload: { entity: 'cycle-plan', items: [{ id: 'cv1', kind: 'qa' }] },
|
|
87
|
+
});
|
|
88
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
89
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e3',
|
|
90
|
+
payload: { entity: 'dispatch', dispatchId: 'd1', taskId: 'T1' },
|
|
91
|
+
});
|
|
92
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
93
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
94
|
+
expect(s.tasks).toHaveLength(1);
|
|
95
|
+
expect(s.tasks[0].verificationPlan).toEqual([{ id: 'v1', kind: 'test' }]);
|
|
96
|
+
expect(s.tasks[0].reviewObligations).toEqual([{ id: 'o1', taskId: 'T1', kind: 'spec' }]);
|
|
97
|
+
expect(s.tasks[0].status).toBe('pending');
|
|
98
|
+
expect(typeof s.tasks[0].createdAt).toBe('string');
|
|
99
|
+
expect(s.cycleVerificationPlan).toEqual([{ id: 'cv1', kind: 'qa' }]);
|
|
100
|
+
expect(s.dispatches).toHaveLength(1);
|
|
101
|
+
expect(s.tasks[0].attempts).toBe(1); // dispatch real incrementa attempts
|
|
102
|
+
});
|
|
103
|
+
test('verdict adverso crea Verdict + FixObligation en la MISMA escritura (R1.4c)', () => {
|
|
104
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
105
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
106
|
+
payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [], reviewObligations: [{ id: 'o1', kind: 'spec' }] },
|
|
107
|
+
});
|
|
108
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
109
|
+
const revBefore = (0, store_1.readJournal)(repo, 'rama').state.revision;
|
|
110
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
111
|
+
kind: 'verdict', generationToken: 'g1', idempotencyKey: 'e2',
|
|
112
|
+
payload: verdictPayload('verd-1', 'o1', 'fail', 'rompe X'),
|
|
113
|
+
});
|
|
114
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
115
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
116
|
+
expect(s.revision).toBe(revBefore + 1); // UNA escritura para verdict + fix
|
|
117
|
+
expect(s.verdicts).toHaveLength(1);
|
|
118
|
+
expect(s.fixes).toEqual([{ id: 'fix-verd-1', verdictId: 'verd-1', closed: false }]);
|
|
119
|
+
expect(s.tasks[0].reviewObligations[0].verdictId).toBe('verd-1');
|
|
120
|
+
});
|
|
121
|
+
test('job-request enlaza satisfies con el item del plan (R1.4c)', () => {
|
|
122
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
123
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
124
|
+
payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [{ id: 'v1', kind: 'test' }], reviewObligations: [] },
|
|
125
|
+
});
|
|
126
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
127
|
+
kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k1',
|
|
128
|
+
payload: { ...jobPayload(['npm', 'test']), satisfies: 'v1' },
|
|
129
|
+
});
|
|
130
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
131
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
132
|
+
const jobId = Object.keys(s.jobs)[0];
|
|
133
|
+
expect(s.tasks[0].verificationPlan[0].satisfiedBy).toBe(jobId);
|
|
134
|
+
expect(s.jobs[jobId].satisfies).toBe('v1');
|
|
135
|
+
});
|
|
136
|
+
test('fencing: token de generacion vieja => rejected-stale-generation auditado (R4.6)', () => {
|
|
137
|
+
(0, requests_1.emitRequest)(repo, 'rama', { kind: 'controller-heartbeat', generationToken: 'g-vieja', idempotencyKey: 'hb1', payload: {} });
|
|
138
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g-nueva');
|
|
139
|
+
expect(out.rejectedStale).toBe(1);
|
|
140
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
141
|
+
expect(Object.values(s.appliedRequests).some((a) => a.outcome === 'rejected-stale-generation')).toBe(true);
|
|
142
|
+
expect(s.controllerHeartbeatAt).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
test('request corrupta se aparta VISIBLE como .corrupt, jamas se descarta (R1.6)', () => {
|
|
145
|
+
fs_1.default.writeFileSync(path_1.default.join((0, paths_1.requestsDir)(repo, 'rama'), 'req-roto.json'), '{no-json');
|
|
146
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
147
|
+
expect(out.corrupt).toBe(1);
|
|
148
|
+
const files = fs_1.default.readdirSync((0, paths_1.requestsDir)(repo, 'rama'));
|
|
149
|
+
expect(files.some((f) => f.endsWith('.corrupt'))).toBe(true);
|
|
150
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
151
|
+
expect(state.requestProblems).toHaveLength(1);
|
|
152
|
+
const gate = (0, gate_1.computeGate)(state, false, () => 'fp-1');
|
|
153
|
+
expect(gate.reasons.some((r) => r.category === 'request-problem')).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
test('reutiliza un job mecanicamente identico para dos items sin digest mismatch ni segunda ejecucion', () => {
|
|
156
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
157
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'task',
|
|
158
|
+
payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [{ id: 'v1', kind: 'test' }, { id: 'v2', kind: 'test' }], reviewObligations: [] },
|
|
159
|
+
});
|
|
160
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
161
|
+
kind: 'job-request', generationToken: 'g1', idempotencyKey: 'job:v1',
|
|
162
|
+
payload: { ...jobPayload(['npm', 'test']), satisfies: 'v1' },
|
|
163
|
+
});
|
|
164
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
165
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
166
|
+
kind: 'job-request', generationToken: 'g1', idempotencyKey: 'job:v2',
|
|
167
|
+
payload: { ...jobPayload(['npm', 'test']), satisfies: 'v2' },
|
|
168
|
+
});
|
|
169
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
170
|
+
const state = (0, store_1.readJournal)(repo, 'rama').state;
|
|
171
|
+
expect(out.rejectedDigest).toBe(0);
|
|
172
|
+
expect(Object.keys(state.jobs)).toHaveLength(1);
|
|
173
|
+
expect(state.tasks[0].verificationPlan.map((item) => item.satisfiedBy)).toEqual([Object.keys(state.jobs)[0], Object.keys(state.jobs)[0]]);
|
|
174
|
+
});
|
|
175
|
+
test('un job fallido no se reutiliza como evidencia: una request nueva crea ejecucion nueva', () => {
|
|
176
|
+
(0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k1', payload: jobPayload(['npm', 'test']) });
|
|
177
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
178
|
+
let s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
179
|
+
const failed = Object.values(s.jobs)[0];
|
|
180
|
+
failed.executionState = 'exited';
|
|
181
|
+
failed.verdict = 'fail';
|
|
182
|
+
(0, store_1.writeJournal)(repo, 'rama', s);
|
|
183
|
+
(0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k2', payload: jobPayload(['npm', 'test']) });
|
|
184
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
185
|
+
s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
186
|
+
expect(Object.keys(s.jobs)).toHaveLength(2);
|
|
187
|
+
expect(Object.values(s.jobs).some((job) => job.executionState === 'received')).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
test('kind no reconocido (sintacticamente valido) se trata como corrupt, jamas se descarta en silencio (R1.6)', () => {
|
|
190
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k1', payload: jobPayload(['npm', 'test']) });
|
|
191
|
+
// simula un kind forward-incompatible/corrupto pero JSON sintacticamente valido
|
|
192
|
+
const raw = JSON.parse(fs_1.default.readFileSync(r.file, 'utf8'));
|
|
193
|
+
raw.kind = 'kind-desconocido-xyz';
|
|
194
|
+
fs_1.default.writeFileSync(r.file, JSON.stringify(raw, null, 2) + '\n');
|
|
195
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
196
|
+
expect(out).toEqual({ applied: 0, rejectedStale: 0, rejectedDigest: 0, rejectedInvalid: 0, corrupt: 1 }); // NUNCA "applied: 1" silencioso
|
|
197
|
+
expect(fs_1.default.existsSync(r.file)).toBe(false); // el .json original ya no esta
|
|
198
|
+
const files = fs_1.default.readdirSync((0, paths_1.requestsDir)(repo, 'rama'));
|
|
199
|
+
expect(files.some((f) => f.endsWith('.corrupt'))).toBe(true); // visible, jamas descartado
|
|
200
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
201
|
+
expect(s.appliedRequests[r.requestId]).toBeUndefined(); // NO se contamina con 'applied'
|
|
202
|
+
const events = fs_1.default.readFileSync((0, paths_1.eventsPath)(repo, 'rama'), 'utf8').trim().split('\n').map((l) => JSON.parse(l));
|
|
203
|
+
expect(events.some((e) => e.kind === 'request-corrupt')).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
test('batch solo-corrupt igual hace fsync del directorio (durabilidad del rename a .corrupt)', () => {
|
|
206
|
+
fs_1.default.writeFileSync(path_1.default.join((0, paths_1.requestsDir)(repo, 'rama'), 'req-roto.json'), '{no-json');
|
|
207
|
+
const spy = jest.spyOn(atomicFile, 'fsyncDirSync');
|
|
208
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
209
|
+
expect(out.corrupt).toBe(1);
|
|
210
|
+
expect(spy).toHaveBeenCalledWith((0, paths_1.requestsDir)(repo, 'rama'));
|
|
211
|
+
spy.mockRestore();
|
|
212
|
+
});
|
|
213
|
+
test('si falla la escritura durable, una request corrupta conserva su .json original para replay', () => {
|
|
214
|
+
const file = path_1.default.join((0, paths_1.requestsDir)(repo, 'rama'), 'req-roto.json');
|
|
215
|
+
fs_1.default.writeFileSync(file, '{no-json');
|
|
216
|
+
const spy = jest.spyOn(atomicFile, 'writeFileAtomicDurable').mockImplementationOnce(() => { throw new Error('disk full'); });
|
|
217
|
+
expect(() => (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1')).toThrow('disk full');
|
|
218
|
+
expect(fs_1.default.existsSync(file)).toBe(true);
|
|
219
|
+
expect(fs_1.default.existsSync(`${file}.corrupt`)).toBe(false);
|
|
220
|
+
spy.mockRestore();
|
|
221
|
+
});
|
|
222
|
+
test('shape anidada invalida se rechaza sin mutacion parcial ni journal corrupto', () => {
|
|
223
|
+
const request = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
224
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'bad-shape',
|
|
225
|
+
payload: { entity: 'task', taskId: 'T1', verificationPlan: [{ id: 'v1', kind: { nested: true } }], reviewObligations: [] },
|
|
226
|
+
});
|
|
227
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').rejectedInvalid).toBe(1);
|
|
228
|
+
const read = (0, store_1.readJournal)(repo, 'rama');
|
|
229
|
+
expect(read.corrupt).toBe(false);
|
|
230
|
+
expect(read.state.tasks).toHaveLength(0);
|
|
231
|
+
expect(fs_1.default.existsSync(`${request.file}.rejected`)).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
test('verdict para una obligacion inexistente se rechaza en vez de fabricar evidencia', () => {
|
|
234
|
+
const request = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
235
|
+
kind: 'verdict', generationToken: 'g1', idempotencyKey: 'orphan-verdict',
|
|
236
|
+
payload: verdictPayload('verd-orphan', 'missing', 'pass', 'no existe'),
|
|
237
|
+
});
|
|
238
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').rejectedInvalid).toBe(1);
|
|
239
|
+
expect((0, store_1.readJournal)(repo, 'rama').state.verdicts).toHaveLength(0);
|
|
240
|
+
expect(fs_1.default.existsSync(`${request.file}.rejected`)).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
// ---- post-implementation-qa: fixes 1-8 (cluster apply.ts/requests.ts/job/index.ts/types.ts) ----
|
|
243
|
+
test('Fix1: idempotencyKey reutilizada con payload distinto NO tira — se aplica la primera, se rechaza la segunda como rejected-digest-mismatch, sin wedge', () => {
|
|
244
|
+
const r1 = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'dup-key', payload: jobPayload(['npm', 'test']) });
|
|
245
|
+
const r2 = (0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'dup-key', payload: jobPayload(['npm', 'run', 'otro']) });
|
|
246
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
247
|
+
expect(out).toEqual({ applied: 1, rejectedStale: 0, rejectedDigest: 1, rejectedInvalid: 0, corrupt: 0 });
|
|
248
|
+
expect(fs_1.default.existsSync(r1.file)).toBe(false);
|
|
249
|
+
expect(fs_1.default.existsSync(r2.file)).toBe(false);
|
|
250
|
+
expect(fs_1.default.readdirSync((0, paths_1.requestsDir)(repo, 'rama'))).toHaveLength(0); // nada colgado — jamas wedge
|
|
251
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
252
|
+
expect(Object.keys(s.jobs)).toHaveLength(1);
|
|
253
|
+
expect(s.appliedRequests[r2.requestId].outcome).toBe('rejected-digest-mismatch');
|
|
254
|
+
expect(() => (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1')).not.toThrow(); // segunda vuelta: sin re-crash
|
|
255
|
+
});
|
|
256
|
+
test('Fix1 Parte C: un error de validacion inesperado en applyRequestToState NO tumba al supervisor — se aparta .rejected, visible, contabilizado', () => {
|
|
257
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
258
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
259
|
+
payload: { entity: 'task', title: 'sin id' }, // taskId ausente: pasa shape validation, falla la validacion profunda (Fix 6)
|
|
260
|
+
});
|
|
261
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
262
|
+
expect(out).toEqual({ applied: 0, rejectedStale: 0, rejectedDigest: 0, rejectedInvalid: 1, corrupt: 0 });
|
|
263
|
+
expect(fs_1.default.existsSync(r.file)).toBe(false);
|
|
264
|
+
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
265
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
266
|
+
expect(s.tasks).toHaveLength(0);
|
|
267
|
+
expect(s.appliedRequests[r.requestId]).toBeUndefined();
|
|
268
|
+
const events = fs_1.default.readFileSync((0, paths_1.eventsPath)(repo, 'rama'), 'utf8').trim().split('\n').map((l) => JSON.parse(l));
|
|
269
|
+
expect(events.some((e) => e.kind === 'request-rejected-invalid')).toBe(true);
|
|
270
|
+
expect(() => (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1')).not.toThrow();
|
|
271
|
+
});
|
|
272
|
+
test('Fix2 (BLOQUEADOR): un veredicto PASS sobre la MISMA obligacion cierra el fix de un veredicto adverso anterior — fail->fix->pass llega a poder certificar', () => {
|
|
273
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
274
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
275
|
+
payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [], reviewObligations: [{ id: 'o1', kind: 'spec' }] },
|
|
276
|
+
});
|
|
277
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
278
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
279
|
+
kind: 'verdict', generationToken: 'g1', idempotencyKey: 'v1',
|
|
280
|
+
payload: verdictPayload('verd-1', 'o1', 'fail', 'rompe X'),
|
|
281
|
+
});
|
|
282
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
283
|
+
const fpNull = () => null;
|
|
284
|
+
let s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
285
|
+
expect(s.fixes.find((f) => f.verdictId === 'verd-1').closed).toBe(false);
|
|
286
|
+
let g = (0, gate_1.computeGate)(s, false, fpNull);
|
|
287
|
+
expect(g.reasons.some((r) => r.category === 'open-fix')).toBe(true);
|
|
288
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
289
|
+
kind: 'verdict', generationToken: 'g1', idempotencyKey: 'v2',
|
|
290
|
+
payload: verdictPayload('verd-2', 'o1', 'pass', 'arreglado'),
|
|
291
|
+
});
|
|
292
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
293
|
+
s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
294
|
+
expect(s.fixes.find((f) => f.verdictId === 'verd-1').closed).toBe(true); // re-review pass cierra el fix
|
|
295
|
+
g = (0, gate_1.computeGate)(s, false, fpNull);
|
|
296
|
+
expect(g.reasons.some((r) => r.category === 'open-fix')).toBe(false); // ya no bloquea por open-fix
|
|
297
|
+
});
|
|
298
|
+
test('Fix4: register-entity cycle-plan es idempotente — un re-registro NO pisa el plan existente ni pierde satisfiedBy ya enlazado', () => {
|
|
299
|
+
(0, requests_1.emitRequest)(repo, 'rama', { kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1', payload: { entity: 'cycle-plan', items: [{ id: 'cv1', kind: 'qa' }] } });
|
|
300
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
301
|
+
(0, requests_1.emitRequest)(repo, 'rama', { kind: 'job-request', generationToken: 'g1', idempotencyKey: 'k1', payload: { ...jobPayload(['npm', 'run', 'qa']), satisfies: 'cv1' } });
|
|
302
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
303
|
+
let s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
304
|
+
const jobId = Object.keys(s.jobs)[0];
|
|
305
|
+
expect(s.cycleVerificationPlan[0].satisfiedBy).toBe(jobId);
|
|
306
|
+
// re-registro defensivo (ej. tras crash sin memoria de si ya se registro), incluso con items DISTINTOS
|
|
307
|
+
(0, requests_1.emitRequest)(repo, 'rama', { kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e2', payload: { entity: 'cycle-plan', items: [{ id: 'cv-otro', kind: 'interlock' }] } });
|
|
308
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
309
|
+
s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
310
|
+
expect(s.cycleVerificationPlan).toEqual([{ id: 'cv1', kind: 'qa', satisfiedBy: jobId }]); // plan original preservado
|
|
311
|
+
});
|
|
312
|
+
test('Fix5: verdict --detail se redacta antes de persistir en state.json (R2.3)', () => {
|
|
313
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
314
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
315
|
+
payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [], reviewObligations: [{ id: 'o1', kind: 'spec' }] },
|
|
316
|
+
});
|
|
317
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
318
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
319
|
+
kind: 'verdict', generationToken: 'g1', idempotencyKey: 'v1',
|
|
320
|
+
payload: verdictPayload('verd-1', 'o1', 'fail', 'export API_KEY=abc123secreto'),
|
|
321
|
+
});
|
|
322
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
323
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
324
|
+
expect(s.verdicts[0].detail).toContain('[REDACTED]');
|
|
325
|
+
expect(s.verdicts[0].detail).not.toContain('abc123secreto');
|
|
326
|
+
});
|
|
327
|
+
test('Fix6: register --entity task-status con taskId desconocido NO es fake-success — se rechaza, no crashea', () => {
|
|
328
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
329
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
330
|
+
payload: { entity: 'task-status', taskId: 'fantasma', status: 'done' },
|
|
331
|
+
});
|
|
332
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
333
|
+
expect(out.rejectedInvalid).toBe(1);
|
|
334
|
+
expect(out.applied).toBe(0);
|
|
335
|
+
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
test('Fix6: register --entity dispatch sin dispatchId se rechaza en vez de coercionar a la string "undefined"', () => {
|
|
338
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
339
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
340
|
+
payload: { entity: 'dispatch', taskId: 'T1' }, // dispatchId ausente
|
|
341
|
+
});
|
|
342
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
343
|
+
expect(out.rejectedInvalid).toBe(1);
|
|
344
|
+
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
345
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
346
|
+
expect(s.dispatches).toHaveLength(0);
|
|
347
|
+
expect(s.dispatches.some((d) => d.id === 'undefined')).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
test('Fix7 (R1.4b/R3.6): verificationPlan que no cubre los requiredVerifiers del repo se rechaza EN REGISTRO, no solo en gate', () => {
|
|
350
|
+
const before = (0, store_1.readJournal)(repo, 'rama').state;
|
|
351
|
+
before.requiredVerifiers = ['test'];
|
|
352
|
+
(0, store_1.writeJournal)(repo, 'rama', before);
|
|
353
|
+
const r = (0, requests_1.emitRequest)(repo, 'rama', {
|
|
354
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
355
|
+
payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [], reviewObligations: [] },
|
|
356
|
+
});
|
|
357
|
+
const out = (0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
358
|
+
expect(out.rejectedInvalid).toBe(1);
|
|
359
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
360
|
+
expect(s.tasks).toHaveLength(0); // rechazado en registro: jamas llega a existir
|
|
361
|
+
expect(fs_1.default.existsSync(`${r.file}.rejected`)).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
test('Fix8 (BLOQUEADOR, R1.5): register --entity next-action popula cycle.nextAction; un segundo registro con actionId distinto lo REEMPLAZA libremente', () => {
|
|
364
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
365
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
|
|
366
|
+
payload: { entity: 'next-action', actionId: 'a1', type: 'implement-task', target: 'T1', preconditions: ['x'], attempt: 1, state: 'in-progress' },
|
|
367
|
+
});
|
|
368
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
369
|
+
let s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
370
|
+
expect(s.cycle.nextAction).toEqual({ actionId: 'a1', type: 'implement-task', target: 'T1', preconditions: ['x'], attempt: 1, state: 'in-progress' });
|
|
371
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
372
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e2',
|
|
373
|
+
payload: { entity: 'next-action', actionId: 'a2', type: 'run-qa', target: 'ciclo' },
|
|
374
|
+
});
|
|
375
|
+
(0, apply_1.consumePendingRequests)(repo, 'rama', 'g1');
|
|
376
|
+
s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
377
|
+
// libremente sobre-escribible (a diferencia de cycle-plan, Fix4): es el "puntero de resumen" vigente
|
|
378
|
+
expect(s.cycle.nextAction).toEqual({ actionId: 'a2', type: 'run-qa', target: 'ciclo', preconditions: [], attempt: 0, state: 'pending' });
|
|
379
|
+
});
|
|
380
|
+
test('custodia solo se reanuda mediante una decision explicita y auditada', () => {
|
|
381
|
+
const before = (0, store_1.readJournal)(repo, 'rama').state;
|
|
382
|
+
before.cycle.status = 'BLOCKED';
|
|
383
|
+
before.cycle.blockedReason = 'identidad ambigua';
|
|
384
|
+
before.generations.push({ n: 1, token: 'g1', state: 'active', launchedAt: new Date().toISOString() });
|
|
385
|
+
(0, store_1.writeJournal)(repo, 'rama', before);
|
|
386
|
+
(0, requests_1.emitRequest)(repo, 'rama', {
|
|
387
|
+
kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'resume-1',
|
|
388
|
+
payload: { entity: 'custody-decision', decision: 'resume', reason: 'operador verifico que no hay llamada en vuelo' },
|
|
389
|
+
});
|
|
390
|
+
expect((0, apply_1.consumePendingRequests)(repo, 'rama', 'g1').applied).toBe(1);
|
|
391
|
+
const s = (0, store_1.readJournal)(repo, 'rama').state;
|
|
392
|
+
expect(s.cycle.status).toBe('IN_PROGRESS');
|
|
393
|
+
expect(s.cycle.blockedReason).toBeUndefined();
|
|
394
|
+
expect(s.generations[0].state).toBe('superseded');
|
|
395
|
+
expect(s.custodyDecisions).toEqual([expect.objectContaining({ decision: 'resume', reason: 'operador verifico que no hay llamada en vuelo', generationToken: 'g1' })]);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
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 process_1 = require("../../../src/core/journal/process");
|
|
11
|
+
jest.setTimeout(180000);
|
|
12
|
+
const CLI = path_1.default.resolve(__dirname, '..', '..', '..', 'dist', 'src', 'index.js');
|
|
13
|
+
function git(cwd, ...args) {
|
|
14
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd });
|
|
15
|
+
}
|
|
16
|
+
function readState(repo) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs_1.default.readFileSync(path_1.default.join(repo, '.awm', 'journal', 'main', 'state.json'), 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function until(fn, ms = 60000, label = 'condicion') {
|
|
25
|
+
const t0 = Date.now();
|
|
26
|
+
while (!fn()) {
|
|
27
|
+
if (Date.now() - t0 > ms)
|
|
28
|
+
throw new Error(`timeout esperando ${label}`);
|
|
29
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
describe('E2E real: crash/restart del supervisor', () => {
|
|
33
|
+
let repo;
|
|
34
|
+
let stubBin;
|
|
35
|
+
let env;
|
|
36
|
+
const children = [];
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
if (!fs_1.default.existsSync(CLI))
|
|
39
|
+
throw new Error('dist ausente: corre `cd cli && npm run build` antes de esta suite (Task 20 Step 1)');
|
|
40
|
+
});
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-e2e-'));
|
|
43
|
+
git(repo, 'init', '-q', '-b', 'main');
|
|
44
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ name: 'fixture', scripts: { test: 'node -e "process.exit(0)"' } }));
|
|
45
|
+
git(repo, 'add', '.');
|
|
46
|
+
git(repo, 'commit', '-qm', 'c');
|
|
47
|
+
stubBin = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-e2e-bin-'));
|
|
48
|
+
for (const name of ['codex', 'claude']) {
|
|
49
|
+
fs_1.default.writeFileSync(path_1.default.join(stubBin, name), '#!/bin/sh\nwhile true; do sleep 1; done\n', { mode: 0o755 });
|
|
50
|
+
}
|
|
51
|
+
env = { ...process.env, PATH: `${stubBin}:${process.env.PATH}` };
|
|
52
|
+
(0, child_process_1.execFileSync)(process.execPath, [CLI, 'watch', '--init'], { cwd: repo, env });
|
|
53
|
+
});
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
// higiene: terminar TODO grupo que hayamos originado (supervisores,
|
|
56
|
+
// stubs de controlador, wrappers) — cero huerfanos entre tests
|
|
57
|
+
const s = readState(repo);
|
|
58
|
+
const groups = new Set();
|
|
59
|
+
for (const c of children) {
|
|
60
|
+
if (c.pid !== undefined)
|
|
61
|
+
groups.add(c.pid);
|
|
62
|
+
}
|
|
63
|
+
if (s !== null) {
|
|
64
|
+
for (const g of s.generations ?? []) {
|
|
65
|
+
if (g.processRef !== undefined)
|
|
66
|
+
groups.add(g.processRef.processGroup);
|
|
67
|
+
}
|
|
68
|
+
for (const j of Object.values(s.jobs ?? {})) {
|
|
69
|
+
if (j.processRef !== undefined)
|
|
70
|
+
groups.add(j.processRef.processGroup);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const pgid of groups) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(-pgid, 'SIGKILL');
|
|
76
|
+
}
|
|
77
|
+
catch { /* ya muerto */ }
|
|
78
|
+
}
|
|
79
|
+
children.length = 0;
|
|
80
|
+
fs_1.default.rmSync(repo, { recursive: true, force: true });
|
|
81
|
+
fs_1.default.rmSync(stubBin, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
function startSupervisor(provider) {
|
|
84
|
+
const out = fs_1.default.openSync(path_1.default.join(repo, `sup-${children.length}.log`), 'a');
|
|
85
|
+
const child = (0, child_process_1.spawn)(process.execPath, [CLI, 'watch', '--provider', provider], {
|
|
86
|
+
cwd: repo, env, detached: true, stdio: ['ignore', out, out],
|
|
87
|
+
});
|
|
88
|
+
children.push(child);
|
|
89
|
+
child.unref();
|
|
90
|
+
return child;
|
|
91
|
+
}
|
|
92
|
+
test('SIGKILL a mitad de job: el wrapper sobrevive, el resultado llega, el restart adopta sin duplicar (R1.8/R4.1/R4.4)', async () => {
|
|
93
|
+
const sup1 = startSupervisor('codex');
|
|
94
|
+
const lockPath = path_1.default.join(fs_1.default.realpathSync(repo), '.awm', 'journal', 'supervisor.lock');
|
|
95
|
+
await until(() => fs_1.default.existsSync(lockPath), 30000, 'lock del supervisor 1');
|
|
96
|
+
await until(() => {
|
|
97
|
+
const s = readState(repo);
|
|
98
|
+
return s !== null && s.generations.some((g) => g.state === 'active');
|
|
99
|
+
}, 30000, 'generacion activa');
|
|
100
|
+
const token = readState(repo).generations.find((g) => g.state === 'active').token;
|
|
101
|
+
// job largo: sobrevive de sobra al SIGKILL del supervisor
|
|
102
|
+
(0, child_process_1.execFileSync)(process.execPath, [CLI, 'job', 'request', '--generation', token, '--',
|
|
103
|
+
'node', '-e', 'setTimeout(()=>process.exit(0), 8000)'], { cwd: repo, env });
|
|
104
|
+
await until(() => {
|
|
105
|
+
const s = readState(repo);
|
|
106
|
+
if (s === null)
|
|
107
|
+
return false;
|
|
108
|
+
return Object.values(s.jobs).some((j) => j.executionState === 'running');
|
|
109
|
+
}, 60000, 'job running con identidad real');
|
|
110
|
+
// CRASH REAL a mitad del job
|
|
111
|
+
process.kill(sup1.pid, 'SIGKILL');
|
|
112
|
+
const jobs = readState(repo).jobs;
|
|
113
|
+
const jobId = Object.keys(jobs)[0];
|
|
114
|
+
const nonce = jobs[jobId].spawnNonce;
|
|
115
|
+
const resultFile = path_1.default.join(repo, '.awm', 'journal', 'main', 'logs', `${jobId}.${nonce}.result.json`);
|
|
116
|
+
// (c) el wrapper EXTERNO sobrevive al supervisor muerto y deja el resultado
|
|
117
|
+
await until(() => fs_1.default.existsSync(resultFile), 60000, 'result sidecar con supervisor muerto');
|
|
118
|
+
expect(JSON.parse(fs_1.default.readFileSync(resultFile, 'utf8')).exitCode).toBe(0);
|
|
119
|
+
// (d) restart: reclama lock muerto y ADOPTA el resultado sin duplicar
|
|
120
|
+
startSupervisor('codex');
|
|
121
|
+
await until(() => {
|
|
122
|
+
const s = readState(repo);
|
|
123
|
+
if (s === null)
|
|
124
|
+
return false;
|
|
125
|
+
const j = s.jobs[jobId];
|
|
126
|
+
return j !== undefined && j.executionState === 'exited' && j.verdict === 'pass';
|
|
127
|
+
}, 60000, 'adopcion del resultado');
|
|
128
|
+
const finalJobs = readState(repo).jobs;
|
|
129
|
+
expect(Object.keys(finalJobs)).toHaveLength(1); // sin duplicacion
|
|
130
|
+
expect(Object.values(finalJobs).some((j) => j.attemptOf !== undefined)).toBe(false); // sin attempt fantasma
|
|
131
|
+
});
|
|
132
|
+
test('adapter claude-code lanza el stub claude; SIGTERM limpia y libera el lock (R4.8/R2.4)', async () => {
|
|
133
|
+
const sup = startSupervisor('claude-code');
|
|
134
|
+
const lockPath = path_1.default.join(fs_1.default.realpathSync(repo), '.awm', 'journal', 'supervisor.lock');
|
|
135
|
+
await until(() => fs_1.default.existsSync(lockPath), 30000, 'lock');
|
|
136
|
+
await until(() => {
|
|
137
|
+
const s = readState(repo);
|
|
138
|
+
if (s === null)
|
|
139
|
+
return false;
|
|
140
|
+
const gen = s.generations.find((g) => g.state === 'active');
|
|
141
|
+
if (gen?.processRef === undefined)
|
|
142
|
+
return false;
|
|
143
|
+
try {
|
|
144
|
+
const args = (0, child_process_1.execFileSync)('ps', ['-o', 'args=', '-p', String(gen.processRef.pid)], { encoding: 'utf8' });
|
|
145
|
+
return args.includes('claude');
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}, 30000, 'stub claude lanzado por el adapter');
|
|
151
|
+
const active = readState(repo).generations.find((g) => g.state === 'active');
|
|
152
|
+
const controllerRef = active.processRef;
|
|
153
|
+
process.kill(sup.pid, 'SIGTERM'); // handler de senial: primero drena ownership, luego libera
|
|
154
|
+
await until(() => !fs_1.default.existsSync(lockPath), 30000, 'lock liberado tras SIGTERM');
|
|
155
|
+
expect((0, process_1.refIsAlive)(controllerRef)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|