agentic-workflow-manager 3.3.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/src/commands/job/exec-wrapper.js +136 -0
  2. package/dist/src/commands/job/export.js +94 -0
  3. package/dist/src/commands/job/gate.js +118 -0
  4. package/dist/src/commands/job/heartbeat.js +15 -0
  5. package/dist/src/commands/job/index.js +246 -0
  6. package/dist/src/commands/job/query.js +37 -0
  7. package/dist/src/commands/job/reap.js +24 -0
  8. package/dist/src/commands/job/reconcile.js +112 -0
  9. package/dist/src/commands/job/request.js +27 -0
  10. package/dist/src/commands/ledger/index.js +1 -1
  11. package/dist/src/commands/watch/apply.js +352 -0
  12. package/dist/src/commands/watch/generations.js +249 -0
  13. package/dist/src/commands/watch/index.js +49 -0
  14. package/dist/src/commands/watch/init.js +72 -0
  15. package/dist/src/commands/watch/lock.js +89 -0
  16. package/dist/src/commands/watch/runner.js +191 -0
  17. package/dist/src/commands/watch/supervisor.js +266 -0
  18. package/dist/src/core/atomic-file.js +31 -0
  19. package/dist/src/core/export/pack.js +7 -1
  20. package/dist/src/core/export/transform.js +51 -1
  21. package/dist/src/core/journal/adapter.js +27 -0
  22. package/dist/src/core/journal/fingerprint.js +80 -0
  23. package/dist/src/core/journal/paths.js +56 -0
  24. package/dist/src/core/journal/process.js +284 -0
  25. package/dist/src/core/journal/redact.js +142 -0
  26. package/dist/src/core/journal/requests.js +132 -0
  27. package/dist/src/core/journal/store.js +107 -0
  28. package/dist/src/core/journal/types.js +165 -0
  29. package/dist/src/core/ledger/cluster.js +138 -0
  30. package/dist/src/core/ledger/store.js +20 -11
  31. package/dist/src/index.js +4 -0
  32. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  33. package/dist/tests/commands/job/export.test.js +76 -0
  34. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  35. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  36. package/dist/tests/commands/job/verbs.test.js +56 -0
  37. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  38. package/dist/tests/commands/watch/apply.test.js +397 -0
  39. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  40. package/dist/tests/commands/watch/generations.test.js +115 -0
  41. package/dist/tests/commands/watch/integration.test.js +124 -0
  42. package/dist/tests/commands/watch/lock.test.js +60 -0
  43. package/dist/tests/commands/watch/runner.test.js +239 -0
  44. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  45. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  46. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  47. package/dist/tests/core/export/engine.test.js +7 -2
  48. package/dist/tests/core/export/transform.test.js +77 -0
  49. package/dist/tests/core/journal/adapter.test.js +27 -0
  50. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  51. package/dist/tests/core/journal/paths.test.js +35 -0
  52. package/dist/tests/core/journal/process.test.js +213 -0
  53. package/dist/tests/core/journal/redact.test.js +59 -0
  54. package/dist/tests/core/journal/requests.test.js +134 -0
  55. package/dist/tests/core/journal/store.test.js +88 -0
  56. package/dist/tests/core/journal/types.test.js +78 -0
  57. package/dist/tests/core/ledger/cluster.test.js +240 -0
  58. package/dist/tests/core/ledger/store.test.js +42 -7
  59. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  60. package/package.json +1 -1
@@ -0,0 +1,115 @@
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 generations_1 = require("../../../src/commands/watch/generations");
10
+ const adapter_1 = require("../../../src/core/journal/adapter");
11
+ const process_1 = require("../../../src/core/journal/process");
12
+ const store_1 = require("../../../src/core/journal/store");
13
+ const paths_1 = require("../../../src/core/journal/paths");
14
+ const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
15
+ describe('generaciones', () => {
16
+ let repo;
17
+ beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-gen-')); (0, store_1.initJournal)(repo, 'rama'); });
18
+ afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
19
+ test('decideStall: heartbeat vencido solo => observar, nunca kill (R4.2)', () => {
20
+ const cfg = { heartbeatTimeoutMs: 5 * 60000, activityWindowMs: 10 * 60000 };
21
+ expect((0, generations_1.decideStall)({ heartbeatAgeMs: 60000, activityFrozenMs: 0, safeToReplace: 'indeterminate' }, cfg)).toBe('healthy');
22
+ expect((0, generations_1.decideStall)({ heartbeatAgeMs: 10 * 60000, activityFrozenMs: 0, safeToReplace: 'indeterminate' }, cfg)).toBe('suspected-stall-observe');
23
+ // doble senial + adapter indeterminate => custodia BLOCKED, sin matar (R4.2b)
24
+ expect((0, generations_1.decideStall)({ heartbeatAgeMs: 20 * 60000, activityFrozenMs: 15 * 60000, safeToReplace: 'indeterminate' }, cfg)).toBe('custody-blocked');
25
+ // doble senial + safe positivo => recien ahi resolver la generacion
26
+ expect((0, generations_1.decideStall)({ heartbeatAgeMs: 20 * 60000, activityFrozenMs: 15 * 60000, safeToReplace: 'safe' }, cfg)).toBe('resolve-generation');
27
+ });
28
+ test('backoff 1->5->15 con techo y tope por hora (R4.3)', () => {
29
+ const b = new generations_1.Backoff();
30
+ expect(b.nextMs()).toBe(60000);
31
+ expect(b.nextMs()).toBe(300000);
32
+ expect(b.nextMs()).toBe(900000);
33
+ expect(b.nextMs()).toBe(900000); // techo
34
+ expect(b.exhausted()).toBe(false);
35
+ for (let i = 0; i < 6; i++)
36
+ b.recordRelaunch();
37
+ expect(b.exhausted()).toBe(true);
38
+ });
39
+ test('beginGeneration hace fencing: la anterior queda superseded (R1.7)', () => {
40
+ const g1 = (0, generations_1.beginGeneration)(repo, 'rama');
41
+ const g2 = (0, generations_1.beginGeneration)(repo, 'rama');
42
+ expect(g2.n).toBe(2);
43
+ expect(g2.token).not.toBe(g1.token);
44
+ const s = (0, store_1.readJournal)(repo, 'rama').state;
45
+ expect(s.generations.find((g) => g.n === 1).state).toBe('superseded');
46
+ expect((0, generations_1.activeGeneration)(s).n).toBe(2);
47
+ });
48
+ test('beginGeneration reinicia el heartbeat al cambiar el fencing token', () => {
49
+ const s0 = (0, store_1.readJournal)(repo, 'rama').state;
50
+ s0.controllerHeartbeatAt = new Date().toISOString();
51
+ (0, store_1.writeJournal)(repo, 'rama', s0);
52
+ (0, generations_1.beginGeneration)(repo, 'rama');
53
+ expect((0, store_1.readJournal)(repo, 'rama').state.controllerHeartbeatAt).toBeUndefined();
54
+ });
55
+ test('el intent de launch queda durable y el prompt entrega el generation token al controller', () => {
56
+ const begun = (0, generations_1.beginGeneration)(repo, 'rama');
57
+ let captured;
58
+ const wrapperRef = { pid: 42, startTime: 't', spawnNonce: 'wrapper-nonce', argvDigest: 'a', processGroup: 42, psArgsDigest: 'p' };
59
+ (0, generations_1.launchControllerGeneration)(repo, 'rama', 'codex', 'continua el ciclo', (job, nonce) => {
60
+ const duringSpawn = (0, generations_1.activeGeneration)((0, store_1.readJournal)(repo, 'rama').state);
61
+ expect(duringSpawn.controllerJobId).toBe(job.id); // persistido ANTES del spawn
62
+ expect(duringSpawn.spawnNonce).toBe(nonce);
63
+ captured = { argv: job.argv, nonce };
64
+ return wrapperRef;
65
+ });
66
+ expect(captured.argv.join(' ')).toContain(begun.token);
67
+ expect(captured.argv.join(' ')).toContain(`--generation ${begun.token}`);
68
+ expect((0, generations_1.activeGeneration)((0, store_1.readJournal)(repo, 'rama').state).wrapperRef).toEqual(wrapperRef);
69
+ });
70
+ test('claim sin identidad/resultado conserva ownership ambiguo; usa la edad del claim, no la de begin', () => {
71
+ (0, generations_1.beginGeneration)(repo, 'rama');
72
+ (0, generations_1.launchControllerGeneration)(repo, 'rama', 'codex', 'continua', () => { });
73
+ let s = (0, store_1.readJournal)(repo, 'rama').state;
74
+ const gen = (0, generations_1.activeGeneration)(s);
75
+ gen.launchedAt = new Date(Date.now() - 3600000).toISOString();
76
+ (0, store_1.writeJournal)(repo, 'rama', s);
77
+ fs_1.default.mkdirSync((0, paths_1.logsDir)(repo, 'rama'), { recursive: true });
78
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'rama'), gen.controllerJobId, gen.spawnNonce), '{}');
79
+ expect((0, generations_1.controllerGenerationHasUnresolvedClaim)(repo, 'rama', gen)).toBe(true);
80
+ (0, generations_1.ensureControllerGeneration)(repo, 'rama', 'codex', 'continua', () => { }, 10000);
81
+ expect((0, store_1.readJournal)(repo, 'rama').state.cycle.status).toBe('IN_PROGRESS'); // claim recien creado: aun dentro de gracia
82
+ const old = new Date(Date.now() - 20000);
83
+ fs_1.default.utimesSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'rama'), gen.controllerJobId, gen.spawnNonce), old, old);
84
+ (0, generations_1.ensureControllerGeneration)(repo, 'rama', 'codex', 'continua', () => { }, 10000);
85
+ expect((0, store_1.readJournal)(repo, 'rama').state.cycle.status).toBe('BLOCKED');
86
+ fs_1.default.writeFileSync((0, exec_wrapper_1.resultPath)((0, paths_1.logsDir)(repo, 'rama'), gen.controllerJobId, gen.spawnNonce), JSON.stringify({ exitCode: 1 }));
87
+ expect((0, generations_1.controllerGenerationHasUnresolvedClaim)(repo, 'rama', gen)).toBe(false);
88
+ });
89
+ test('resolveGeneration: muerte probada => proven-dead; vivo+indeterminate => custodia con estado BLOCKED (R4.2b)', async () => {
90
+ const adapter = (0, adapter_1.adapterFor)('codex');
91
+ (0, generations_1.beginGeneration)(repo, 'rama');
92
+ let s = (0, store_1.readJournal)(repo, 'rama').state;
93
+ const gen = (0, generations_1.activeGeneration)(s);
94
+ gen.processRef = { pid: 999999, startTime: 'gone', spawnNonce: 'n', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
95
+ (0, store_1.writeJournal)(repo, 'rama', s);
96
+ expect(await (0, generations_1.resolveGeneration)(repo, 'rama', adapter, { termGraceMs: 100, killGraceMs: 100 })).toBe('proven-dead');
97
+ // vivo: el adapter codex no puede afirmar safeToReplace => custodia SIN matar
98
+ const { child, ref } = (0, process_1.spawnStructured)(['node', '-e', 'setTimeout(()=>{}, 8000)'], process.cwd(), 'nG');
99
+ s = (0, store_1.readJournal)(repo, 'rama').state;
100
+ (0, generations_1.activeGeneration)(s).processRef = ref;
101
+ (0, store_1.writeJournal)(repo, 'rama', s);
102
+ expect(await (0, generations_1.resolveGeneration)(repo, 'rama', adapter, { termGraceMs: 100, killGraceMs: 100 })).toBe('custody-blocked');
103
+ const after = (0, store_1.readJournal)(repo, 'rama').state;
104
+ expect(after.cycle.status).toBe('BLOCKED');
105
+ expect(after.cycle.blockedReason).toMatch(/safeToReplace/);
106
+ expect(child.killed).toBe(false); // JAMAS se toco al vivo
107
+ child.kill('SIGKILL');
108
+ });
109
+ test('enterCustody deja razon auditada y ciclo BLOCKED (R4.5)', () => {
110
+ (0, generations_1.enterCustody)(repo, 'rama', 'prueba de custodia');
111
+ const s = (0, store_1.readJournal)(repo, 'rama').state;
112
+ expect(s.cycle.status).toBe('BLOCKED');
113
+ expect(s.cycle.blockedReason).toBe('prueba de custodia');
114
+ });
115
+ });
@@ -0,0 +1,124 @@
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 apply_1 = require("../../../src/commands/watch/apply");
11
+ const runner_1 = require("../../../src/commands/watch/runner");
12
+ const generations_1 = require("../../../src/commands/watch/generations");
13
+ const init_1 = require("../../../src/commands/watch/init");
14
+ const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
15
+ const gate_1 = require("../../../src/commands/job/gate");
16
+ const reconcile_1 = require("../../../src/commands/job/reconcile");
17
+ const request_1 = require("../../../src/commands/job/request");
18
+ const fingerprint_1 = require("../../../src/core/journal/fingerprint");
19
+ const requests_1 = require("../../../src/core/journal/requests");
20
+ const store_1 = require("../../../src/core/journal/store");
21
+ const paths_1 = require("../../../src/core/journal/paths");
22
+ jest.setTimeout(30000);
23
+ const fakeSpawner = (job, nonce, logsRoot, repoRoot) => {
24
+ void (0, exec_wrapper_1.runExecWrapper)({ logsRoot, jobId: job.id, nonce, argv: job.argv, cwd: job.cwd, repoRoot }).catch(() => { });
25
+ };
26
+ function git(cwd, ...args) {
27
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd });
28
+ }
29
+ async function until(fn, ms = 15000) {
30
+ const t0 = Date.now();
31
+ while (!fn()) {
32
+ if (Date.now() - t0 > ms)
33
+ throw new Error('timeout');
34
+ await new Promise((r) => setTimeout(r, 50));
35
+ }
36
+ }
37
+ describe('integracion supervisor + jobs', () => {
38
+ let repo;
39
+ let fpNow;
40
+ beforeEach(() => {
41
+ repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-int-'));
42
+ git(repo, 'init', '-q', '-b', 'main');
43
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
44
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'package.json'), JSON.stringify({ scripts: { test: 'node -e "process.exit(0)"' } }));
45
+ git(repo, 'add', '.');
46
+ git(repo, 'commit', '-qm', 'c');
47
+ fs_1.default.mkdirSync(path_1.default.join(repo, '.awm'), { recursive: true });
48
+ fs_1.default.writeFileSync(path_1.default.join(repo, '.awm', 'sensors.json'), '{}');
49
+ (0, init_1.initWatch)(repo, 'main'); // gitignorea .awm ANTES del primer fingerprint
50
+ fpNow = (argv, paths, cwd) => {
51
+ try {
52
+ return (0, fingerprint_1.computeFingerprint)(repo, argv, paths, cwd).fingerprint;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ };
58
+ });
59
+ afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
60
+ async function drainJobs() {
61
+ await until(() => {
62
+ (0, runner_1.runnerTick)(repo, 'main', fakeSpawner, { reconcileGraceMs: 10000 });
63
+ const s = (0, store_1.readJournal)(repo, 'main').state;
64
+ return Object.values(s.jobs).every((j) => !['received', 'spawn-intent', 'claimed', 'running'].includes(j.executionState));
65
+ });
66
+ }
67
+ test('e2e in-process: request => supervisor ejecuta => resultado en journal, dedup por key (RNF-T.7, R4.4)', async () => {
68
+ (0, request_1.requestJob)(repo, 'main', 'g1', ['node', '-e', 'process.exit(0)'], [], '.');
69
+ (0, request_1.requestJob)(repo, 'main', 'g1', ['node', '-e', 'process.exit(0)'], [], '.'); // misma key
70
+ (0, apply_1.consumePendingRequests)(repo, 'main', 'g1');
71
+ await drainJobs();
72
+ const s = (0, store_1.readJournal)(repo, 'main').state;
73
+ const jobs = Object.values(s.jobs);
74
+ expect(jobs).toHaveLength(1); // get-or-create: un solo job (RNF-T.7)
75
+ expect(jobs[0].executionState).toBe('exited');
76
+ expect(jobs[0].verdict).toBe('pass');
77
+ });
78
+ test('job fallido ENLAZADO al plan bloquea el gate por adverse-verdict (R1.4c)', async () => {
79
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
80
+ payload: { entity: 'task', taskId: 'T1', title: 't', verificationPlan: [{ id: 'v1', kind: 'test' }, { id: 'v-sensors', kind: 'sensors' }], reviewObligations: [] } });
81
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e2',
82
+ payload: { entity: 'cycle-plan', items: [{ id: 'cv1', kind: 'qa' }] } });
83
+ // job largo que FALLA, enlazado a v1 (bloqueador 5: el test v1 no enlazaba)
84
+ (0, request_1.requestJob)(repo, 'main', 'g1', ['node', '-e', 'setTimeout(()=>process.exit(1), 1200)'], [], '.', { satisfies: 'v1' });
85
+ (0, apply_1.consumePendingRequests)(repo, 'main', 'g1');
86
+ await drainJobs(); // sin timeout terminal: espera lo que dure (R3.5)
87
+ const s = (0, store_1.readJournal)(repo, 'main').state;
88
+ const g = (0, gate_1.computeGate)(s, false, fpNow);
89
+ expect(g.pass).toBe(false);
90
+ expect(g.reasons.some((r) => r.category === 'adverse-verdict' && /v1/.test(r.detail))).toBe(true);
91
+ expect(g.reasons.some((r) => r.category === 'live-job')).toBe(false); // pero SI termino
92
+ });
93
+ test('evidencia pass con arbol cambiado despues => stale-fingerprint, historica (RF-2.8)', async () => {
94
+ (0, requests_1.emitRequest)(repo, 'main', { kind: 'register-entity', generationToken: 'g1', idempotencyKey: 'e1',
95
+ payload: { entity: 'cycle-plan', items: [
96
+ { id: 'cv-qa', kind: 'qa' }, { id: 'cv-interlock', kind: 'interlock' },
97
+ { id: 'cv-test', kind: 'test' }, { id: 'cv-sensors', kind: 'sensors' },
98
+ ] } });
99
+ for (const item of ['cv-qa', 'cv-interlock', 'cv-test', 'cv-sensors']) {
100
+ (0, request_1.requestJob)(repo, 'main', 'g1', ['node', '-e', 'process.exit(0)'], [], '.', { satisfies: item });
101
+ }
102
+ (0, apply_1.consumePendingRequests)(repo, 'main', 'g1');
103
+ await drainJobs();
104
+ expect((0, gate_1.computeGate)((0, store_1.readJournal)(repo, 'main').state, false, fpNow).pass).toBe(true);
105
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'CAMBIO'); // el arbol cambio tras la evidencia
106
+ const g = (0, gate_1.computeGate)((0, store_1.readJournal)(repo, 'main').state, false, fpNow);
107
+ expect(g.pass).toBe(false);
108
+ expect(g.reasons.some((r) => r.category === 'stale-fingerprint')).toBe(true);
109
+ });
110
+ test('custodia: doble senial sin safe => custody-blocked, jamas kill (R4.2b)', () => {
111
+ const d = (0, generations_1.decideStall)({ heartbeatAgeMs: 999999, activityFrozenMs: 999999, safeToReplace: 'indeterminate' }, { heartbeatTimeoutMs: 1, activityWindowMs: 1 });
112
+ expect(d).toBe('custody-blocked');
113
+ });
114
+ test('interrupcion entre spawn-intent y claim: la matriz decide por claim, no re-spawnea a ciegas (R1.8)', () => {
115
+ (0, request_1.requestJob)(repo, 'main', 'g1', ['node', '-e', 'process.exit(0)'], [], '.');
116
+ (0, apply_1.consumePendingRequests)(repo, 'main', 'g1');
117
+ const s = (0, store_1.readJournal)(repo, 'main').state;
118
+ const jid = Object.keys(s.jobs)[0];
119
+ s.jobs[jid].executionState = 'spawn-intent'; // crash simulado: intent persistido, spawn jamas ocurrio
120
+ s.jobs[jid].spawnNonce = 'nunca-uso';
121
+ const out = (0, reconcile_1.reconcileJobs)(s, (0, paths_1.logsDir)(repo, 'main'));
122
+ expect(out.decisions[0].action).toBe('retry-same-intent'); // mismo nonce: el claim wx impide duplicar
123
+ });
124
+ });
@@ -0,0 +1,60 @@
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 lock_1 = require("../../../src/commands/watch/lock");
11
+ const paths_1 = require("../../../src/core/journal/paths");
12
+ function git(cwd, ...args) {
13
+ (0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd });
14
+ }
15
+ describe('supervisor lock + branch invariant', () => {
16
+ let repo;
17
+ beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-lock-')); });
18
+ afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
19
+ test('adquisicion exclusiva: la segunda falla con supervisor vivo (R4.1)', () => {
20
+ const l1 = (0, lock_1.acquireLock)(repo);
21
+ expect(fs_1.default.existsSync((0, paths_1.supervisorLockPath)(repo))).toBe(true);
22
+ expect(() => (0, lock_1.acquireLock)(repo)).toThrow(/supervisor activo/);
23
+ (0, lock_1.releaseLock)(repo, l1);
24
+ expect(fs_1.default.existsSync((0, paths_1.supervisorLockPath)(repo))).toBe(false);
25
+ });
26
+ test('lock con identidad muerta PROBADA se reclama con reintento unico (R4.1)', () => {
27
+ fs_1.default.mkdirSync(path_1.default.dirname((0, paths_1.supervisorLockPath)(repo)), { recursive: true });
28
+ fs_1.default.writeFileSync((0, paths_1.supervisorLockPath)(repo), JSON.stringify({
29
+ pid: 999999, startTime: 'gone', spawnNonce: 'x', argvDigest: 'y', processGroup: 999999, psArgsDigest: 'z',
30
+ }));
31
+ const l = (0, lock_1.acquireLock)(repo);
32
+ expect(l.ref.pid).toBe(process.pid);
33
+ (0, lock_1.releaseLock)(repo, l);
34
+ });
35
+ test('lock ilegible o con shape invalido => LockBlockedError, JAMAS se reclama (R4.1)', () => {
36
+ fs_1.default.mkdirSync(path_1.default.dirname((0, paths_1.supervisorLockPath)(repo)), { recursive: true });
37
+ fs_1.default.writeFileSync((0, paths_1.supervisorLockPath)(repo), '{json roto');
38
+ expect(() => (0, lock_1.acquireLock)(repo)).toThrow(lock_1.LockBlockedError);
39
+ fs_1.default.writeFileSync((0, paths_1.supervisorLockPath)(repo), JSON.stringify({ pid: 1 })); // shape parcial
40
+ expect(() => (0, lock_1.acquireLock)(repo)).toThrow(lock_1.LockBlockedError);
41
+ expect(fs_1.default.existsSync((0, paths_1.supervisorLockPath)(repo))).toBe(true); // sigue ahi: nadie lo piso
42
+ });
43
+ test('branch invariant: discrepancia rama-journal => BLOCKED (R1.1)', () => {
44
+ git(repo, 'init', '-q', '-b', 'main');
45
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
46
+ git(repo, 'add', '.');
47
+ git(repo, 'commit', '-qm', 'c');
48
+ expect(() => (0, lock_1.verifyBranchInvariant)(repo, 'main')).not.toThrow();
49
+ git(repo, 'checkout', '-qb', 'otra');
50
+ expect(() => (0, lock_1.verifyBranchInvariant)(repo, 'main')).toThrow(/BLOCKED/);
51
+ });
52
+ test('branch invariant: HEAD detached => BLOCKED, jamas matchea (R1.1)', () => {
53
+ git(repo, 'init', '-q', '-b', 'main');
54
+ fs_1.default.writeFileSync(path_1.default.join(repo, 'f.txt'), 'x');
55
+ git(repo, 'add', '.');
56
+ git(repo, 'commit', '-qm', 'c');
57
+ git(repo, 'checkout', '-q', '--detach', 'HEAD');
58
+ expect(() => (0, lock_1.verifyBranchInvariant)(repo, 'main')).toThrow(/BLOCKED/);
59
+ });
60
+ });
@@ -0,0 +1,239 @@
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 runner_1 = require("../../../src/commands/watch/runner");
10
+ const exec_wrapper_1 = require("../../../src/commands/job/exec-wrapper");
11
+ const store_1 = require("../../../src/core/journal/store");
12
+ const paths_1 = require("../../../src/core/journal/paths");
13
+ const process_1 = require("../../../src/core/journal/process");
14
+ const fakeSpawner = (job, nonce, logsRoot, repoRoot) => {
15
+ // Mismo contrato que el spawner real: dispara el wrapper y NO espera.
16
+ void (0, exec_wrapper_1.runExecWrapper)({ logsRoot, jobId: job.id, nonce, argv: job.argv, cwd: job.cwd, repoRoot }).catch(() => { });
17
+ };
18
+ async function until(fn, ms = 8000) {
19
+ const t0 = Date.now();
20
+ while (!fn()) {
21
+ if (Date.now() - t0 > ms)
22
+ throw new Error('timeout esperando condicion');
23
+ await new Promise((r) => setTimeout(r, 50));
24
+ }
25
+ }
26
+ function seedJob(repo, partial) {
27
+ const s = (0, store_1.readJournal)(repo, 'rama').state;
28
+ const j = {
29
+ id: 'j1', fingerprint: 'fp', commandDigest: 'cd', argv: ['node', '-e', 'process.exit(0)'], cwd: '.',
30
+ paths: [], expandedPaths: [], executionState: 'received', observationState: 'progressing',
31
+ phaseTimestamps: { received: new Date().toISOString() }, ...partial,
32
+ };
33
+ s.jobs[j.id] = j;
34
+ (0, store_1.writeJournal)(repo, 'rama', s);
35
+ return j.id;
36
+ }
37
+ describe('runner concurrente', () => {
38
+ let repo;
39
+ beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-run-')); (0, store_1.initJournal)(repo, 'rama'); });
40
+ afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
41
+ test('spawnPendingWrappers persiste spawn-intent+nonce ANTES del spawn y NO bloquea (R1.8, R4.4)', async () => {
42
+ seedJob(repo, { argv: ['node', '-e', 'setTimeout(()=>process.exit(0), 800)'] });
43
+ const spawned = (0, runner_1.spawnPendingWrappers)(repo, 'rama', fakeSpawner);
44
+ expect(spawned).toBe(1);
45
+ // Retorno INMEDIATO: el job aun no esta exited — el supervisor no espero
46
+ const mid = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
47
+ expect(['spawn-intent', 'claimed', 'running']).toContain(mid.executionState);
48
+ expect(typeof mid.spawnNonce).toBe('string'); // intent durable pre-spawn
49
+ await until(() => {
50
+ (0, runner_1.collectAndReconcile)(repo, 'rama');
51
+ return (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].executionState === 'exited';
52
+ });
53
+ const done = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
54
+ expect(done.verdict).toBe('pass');
55
+ expect(done.processRef.pid).toBeGreaterThan(0); // identidad REAL adoptada del sidecar
56
+ expect(done.wrapperRef.pid).toBeGreaterThan(0);
57
+ });
58
+ test('job largo pasa por running con identidad real; jamas se mata por duracion (R3.5)', async () => {
59
+ seedJob(repo, { argv: ['node', '-e', 'setTimeout(()=>process.exit(0), 1500)'] });
60
+ (0, runner_1.spawnPendingWrappers)(repo, 'rama', fakeSpawner);
61
+ await until(() => {
62
+ (0, runner_1.collectAndReconcile)(repo, 'rama');
63
+ return (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].executionState === 'running';
64
+ });
65
+ const running = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
66
+ expect(running.processRef.psArgsDigest).toMatch(/^[0-9a-f]{16}$/);
67
+ await until(() => {
68
+ (0, runner_1.collectAndReconcile)(repo, 'rama');
69
+ return (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].executionState === 'exited';
70
+ });
71
+ });
72
+ test('spawn-intent sin claim fuera de gracia => re-spawn del MISMO intent y nonce (R3.3/R1.8)', () => {
73
+ seedJob(repo, {
74
+ executionState: 'spawn-intent', spawnNonce: 'nunca-claimeo',
75
+ phaseTimestamps: { 'spawn-intent': new Date(Date.now() - 60000).toISOString() },
76
+ });
77
+ const calls = [];
78
+ const out = (0, runner_1.runnerTick)(repo, 'rama', (job, nonce) => { calls.push({ id: job.id, nonce }); }, { reconcileGraceMs: 1000 });
79
+ expect(out.decisions.find((d) => d.action === 'retry-same-intent')).toBeDefined();
80
+ const s = (0, store_1.readJournal)(repo, 'rama').state;
81
+ expect(Object.keys(s.jobs)).toEqual(['j1']);
82
+ expect(s.jobs['j1'].executionState).toBe('spawn-intent');
83
+ expect(s.jobs['j1'].spawnNonce).toBe('nunca-claimeo');
84
+ expect(calls).toEqual([{ id: 'j1', nonce: 'nunca-claimeo' }]);
85
+ });
86
+ test('claim sin resultado con procesos muertos => orphaned, jamas relanzar (R1.8)', () => {
87
+ const dead = { pid: 999999, startTime: 'gone', spawnNonce: 'nZ', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
88
+ seedJob(repo, {
89
+ executionState: 'running', spawnNonce: 'nZ', processRef: dead, wrapperRef: { ...dead, pid: 999998 },
90
+ phaseTimestamps: { running: new Date(Date.now() - 60000).toISOString() },
91
+ });
92
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'rama'), 'j1', 'nZ'), '{}');
93
+ const out = (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 1000 });
94
+ expect(out.decisions.find((d) => d.action === 'orphaned-authorization-required')).toBeDefined();
95
+ expect((0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].executionState).toBe('orphaned');
96
+ });
97
+ test('identity sidecar a medio escribir (JSON invalido) => no avanza esta vuelta, no crashea; tick siguiente con identidad valida si avanza', () => {
98
+ seedJob(repo, {
99
+ executionState: 'claimed', spawnNonce: 'nI',
100
+ phaseTimestamps: { claimed: new Date().toISOString() },
101
+ });
102
+ fs_1.default.writeFileSync((0, exec_wrapper_1.identityPath)((0, paths_1.logsDir)(repo, 'rama'), 'j1', 'nI'), '{ esto no es json valido');
103
+ expect(() => (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000 })).not.toThrow();
104
+ const mid = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
105
+ expect(mid.executionState).toBe('claimed'); // sin avance: el sidecar aun no es legible
106
+ expect(mid.processRef).toBeUndefined();
107
+ const validIdentity = {
108
+ jobId: 'j1', nonce: 'nI',
109
+ wrapper: { pid: 111, startTime: 'x', spawnNonce: 'nI', argvDigest: 'd', processGroup: 111, psArgsDigest: 'e' },
110
+ command: { pid: 112, startTime: 'x', spawnNonce: 'nI', argvDigest: (0, process_1.argvDigest)(['node', '-e', 'process.exit(0)']), processGroup: 111, psArgsDigest: 'e' },
111
+ };
112
+ fs_1.default.writeFileSync((0, exec_wrapper_1.identityPath)((0, paths_1.logsDir)(repo, 'rama'), 'j1', 'nI'), JSON.stringify(validIdentity));
113
+ (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000 });
114
+ const done = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
115
+ expect(done.executionState).toBe('running');
116
+ expect(done.processRef.pid).toBe(112);
117
+ });
118
+ test('identity sidecar con JSON valido pero forma invalida (ProcessRef incompleto) => no avanza, no crashea (R1.8)', () => {
119
+ seedJob(repo, {
120
+ executionState: 'claimed', spawnNonce: 'nJ',
121
+ phaseTimestamps: { claimed: new Date().toISOString() },
122
+ });
123
+ fs_1.default.writeFileSync((0, exec_wrapper_1.identityPath)((0, paths_1.logsDir)(repo, 'rama'), 'j1', 'nJ'), JSON.stringify({ jobId: 'j1', nonce: 'nJ', wrapper: {}, command: {} }));
124
+ expect(() => (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000 })).not.toThrow();
125
+ const mid = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
126
+ expect(mid.executionState).toBe('claimed');
127
+ expect(mid.processRef).toBeUndefined();
128
+ });
129
+ test('claim sidecar a medio escribir (contenido no-JSON) igual dispara claimed — el contenido nunca se parsea (R1.8)', () => {
130
+ seedJob(repo, {
131
+ executionState: 'spawn-intent', spawnNonce: 'nK',
132
+ phaseTimestamps: { 'spawn-intent': new Date().toISOString() },
133
+ });
134
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'rama'), 'j1', 'nK'), '{"jobId":"j1truncado-sin-cerrar');
135
+ expect(() => (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000 })).not.toThrow();
136
+ expect((0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].executionState).toBe('claimed');
137
+ });
138
+ test('resultado parcial con solo exitCode => el scan no lo adopta; fuera de gracia cae a orphaned, jamas fabrica pass (R1.6)', () => {
139
+ const dead = { pid: 999999, startTime: 'gone', spawnNonce: 'nL', argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
140
+ seedJob(repo, {
141
+ executionState: 'running', spawnNonce: 'nL', processRef: dead,
142
+ phaseTimestamps: { running: new Date(Date.now() - 60000).toISOString() },
143
+ });
144
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)((0, paths_1.logsDir)(repo, 'rama'), 'j1', 'nL'), '{}');
145
+ fs_1.default.writeFileSync((0, exec_wrapper_1.resultPath)((0, paths_1.logsDir)(repo, 'rama'), 'j1', 'nL'), JSON.stringify({ exitCode: 0 }));
146
+ const out = (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 1000 });
147
+ expect(out.decisions.find((d) => d.action === 'orphaned-authorization-required')).toBeDefined();
148
+ const done = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
149
+ expect(done.executionState).toBe('orphaned');
150
+ expect(done.verdict).toBeUndefined(); // jamas fabricado de un sidecar sin forma
151
+ });
152
+ test('resultado completo sin claim no se adopta como evidencia', () => {
153
+ seedJob(repo, {
154
+ executionState: 'spawn-intent', spawnNonce: 'nNoClaim',
155
+ phaseTimestamps: { 'spawn-intent': new Date(Date.now() - 60000).toISOString() },
156
+ });
157
+ fs_1.default.writeFileSync((0, exec_wrapper_1.resultPath)((0, paths_1.logsDir)(repo, 'rama'), 'j1', 'nNoClaim'), JSON.stringify({
158
+ exitCode: 0, endedAt: new Date().toISOString(), resultPath: 'forged',
159
+ }));
160
+ const out = (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 1000 });
161
+ expect(out.decisions).toContainEqual({ jobId: 'j1', action: 'retry-same-intent' });
162
+ const job = (0, store_1.readJournal)(repo, 'rama').state.jobs.j1;
163
+ expect(job.executionState).toBe('spawn-intent');
164
+ expect(job.verdict).toBeUndefined();
165
+ });
166
+ test('runnerTick combina recoleccion + spawn en un tick sin esperar (R4.4)', async () => {
167
+ seedJob(repo, {});
168
+ const out = (0, runner_1.runnerTick)(repo, 'rama', fakeSpawner, { reconcileGraceMs: 10000 });
169
+ expect(out.spawned).toBe(1);
170
+ await until(() => (0, runner_1.runnerTick)(repo, 'rama', fakeSpawner, { reconcileGraceMs: 10000 }).advanced > 0
171
+ || (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].executionState === 'exited');
172
+ });
173
+ describe('observationState: suspected-stall por staleness del log, puramente observacional (R3.5)', () => {
174
+ function seedRunning(nonce, runningAt) {
175
+ const dead = { pid: 999999, startTime: 'gone', spawnNonce: nonce, argvDigest: 'd', processGroup: 999999, psArgsDigest: 'x' };
176
+ seedJob(repo, {
177
+ executionState: 'running', spawnNonce: nonce, processRef: dead, wrapperRef: { ...dead, pid: 999998 },
178
+ phaseTimestamps: { running: runningAt }, lastProgressAt: runningAt,
179
+ });
180
+ }
181
+ test('log que sigue creciendo (mtime avanza cada tick) queda progressing aun pasado el umbral', async () => {
182
+ const oldIso = new Date(Date.now() - 60000).toISOString();
183
+ seedRunning('nStall1', oldIso);
184
+ const logs = (0, paths_1.logsDir)(repo, 'rama');
185
+ fs_1.default.mkdirSync(logs, { recursive: true });
186
+ const file = (0, exec_wrapper_1.logPath)(logs, 'j1', 'nStall1');
187
+ fs_1.default.writeFileSync(file, 'salida inicial\n');
188
+ // umbral MUY corto (10ms): si el job estuviera realmente stalled, cualquiera
189
+ // de estos 3 ticks lo marcaria suspected-stall — pero el log sigue creciendo.
190
+ for (let i = 0; i < 3; i++) {
191
+ await new Promise((res) => setTimeout(res, 20)); // > stallObservationMs entre escrituras
192
+ fs_1.default.appendFileSync(file, `linea ${i}\n`);
193
+ (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000, stallObservationMs: 10 });
194
+ expect((0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].observationState).toBe('progressing');
195
+ }
196
+ });
197
+ // El log se escribe con un mtime EXPLICITAMENTE anterior a `lastProgressAt`
198
+ // (utimesSync, no timing de reloj real) — determinista, sin flakiness por
199
+ // resolucion de mtime del filesystem. Misma semantica que el runner real:
200
+ // el log no avanzo desde la ultima marca de progreso conocida.
201
+ function seedRunningWithStaleLog(nonce, content) {
202
+ const logs = (0, paths_1.logsDir)(repo, 'rama');
203
+ fs_1.default.mkdirSync(logs, { recursive: true });
204
+ const file = (0, exec_wrapper_1.logPath)(logs, 'j1', nonce);
205
+ fs_1.default.writeFileSync(file, content);
206
+ const past = new Date(Date.now() - 5000);
207
+ fs_1.default.utimesSync(file, past, past);
208
+ seedRunning(nonce, new Date(Date.now() - 1000).toISOString());
209
+ }
210
+ test('log sin crecer por mas del umbral => suspected-stall, executionState intacto (jamas mata nada)', () => {
211
+ seedRunningWithStaleLog('nStall2', 'salida unica, nunca mas crece\n');
212
+ (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000, stallObservationMs: 10 });
213
+ const j = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
214
+ expect(j.observationState).toBe('suspected-stall');
215
+ expect(j.executionState).toBe('running'); // observacional puro: nunca produce transicion terminal (R3.5)
216
+ });
217
+ test('un job stalled vuelve a progressing si el log retoma crecimiento', () => {
218
+ seedRunningWithStaleLog('nStall3', 'salida vieja\n');
219
+ (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000, stallObservationMs: 10 });
220
+ expect((0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].observationState).toBe('suspected-stall');
221
+ const logs = (0, paths_1.logsDir)(repo, 'rama');
222
+ fs_1.default.appendFileSync((0, exec_wrapper_1.logPath)(logs, 'j1', 'nStall3'), 'output nuevo: el job seguia vivo\n');
223
+ (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000, stallObservationMs: 10 });
224
+ expect((0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].observationState).toBe('progressing');
225
+ });
226
+ test('un job stalled que termina llega a exited via el sidecar de resultado normal, sin intervencion de staleness', () => {
227
+ seedRunningWithStaleLog('nStall4', 'salida vieja\n');
228
+ (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000, stallObservationMs: 10 });
229
+ expect((0, store_1.readJournal)(repo, 'rama').state.jobs['j1'].observationState).toBe('suspected-stall');
230
+ const logs = (0, paths_1.logsDir)(repo, 'rama');
231
+ fs_1.default.writeFileSync((0, exec_wrapper_1.claimPath)(logs, 'j1', 'nStall4'), '{}');
232
+ fs_1.default.writeFileSync((0, exec_wrapper_1.resultPath)(logs, 'j1', 'nStall4'), JSON.stringify({ exitCode: 0, endedAt: new Date().toISOString(), resultPath: 'x' }));
233
+ (0, runner_1.collectAndReconcile)(repo, 'rama', { reconcileGraceMs: 60000, stallObservationMs: 10 });
234
+ const j = (0, store_1.readJournal)(repo, 'rama').state.jobs['j1'];
235
+ expect(j.executionState).toBe('exited');
236
+ expect(j.verdict).toBe('pass');
237
+ });
238
+ });
239
+ });