agentic-workflow-manager 3.4.0 → 3.6.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 (61) 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/sensors/exec.js +121 -0
  11. package/dist/src/commands/sensors/index.js +4 -4
  12. package/dist/src/commands/sensors/run.js +124 -71
  13. package/dist/src/commands/watch/apply.js +352 -0
  14. package/dist/src/commands/watch/generations.js +249 -0
  15. package/dist/src/commands/watch/index.js +49 -0
  16. package/dist/src/commands/watch/init.js +72 -0
  17. package/dist/src/commands/watch/lock.js +89 -0
  18. package/dist/src/commands/watch/runner.js +191 -0
  19. package/dist/src/commands/watch/supervisor.js +266 -0
  20. package/dist/src/core/atomic-file.js +31 -0
  21. package/dist/src/core/export/pack.js +7 -1
  22. package/dist/src/core/journal/adapter.js +27 -0
  23. package/dist/src/core/journal/fingerprint.js +80 -0
  24. package/dist/src/core/journal/paths.js +56 -0
  25. package/dist/src/core/journal/process.js +284 -0
  26. package/dist/src/core/journal/redact.js +142 -0
  27. package/dist/src/core/journal/requests.js +132 -0
  28. package/dist/src/core/journal/store.js +107 -0
  29. package/dist/src/core/journal/types.js +165 -0
  30. package/dist/src/index.js +4 -0
  31. package/dist/tests/commands/job/exec-wrapper.test.js +85 -0
  32. package/dist/tests/commands/job/export.test.js +76 -0
  33. package/dist/tests/commands/job/gate-reconcile.test.js +297 -0
  34. package/dist/tests/commands/job/reap-cli.test.js +101 -0
  35. package/dist/tests/commands/job/verbs.test.js +56 -0
  36. package/dist/tests/commands/job/verdict-determinism.test.js +138 -0
  37. package/dist/tests/commands/sensors/exec-fixtures.js +24 -0
  38. package/dist/tests/commands/sensors/exec.test.js +91 -0
  39. package/dist/tests/commands/sensors/run-inconclusive.test.js +55 -66
  40. package/dist/tests/commands/sensors/run-partial.test.js +225 -0
  41. package/dist/tests/commands/sensors/run-tool-missing.test.js +6 -6
  42. package/dist/tests/commands/sensors/run.test.js +64 -81
  43. package/dist/tests/commands/watch/apply.test.js +397 -0
  44. package/dist/tests/commands/watch/e2e-crash.test.js +157 -0
  45. package/dist/tests/commands/watch/generations.test.js +115 -0
  46. package/dist/tests/commands/watch/integration.test.js +124 -0
  47. package/dist/tests/commands/watch/lock.test.js +60 -0
  48. package/dist/tests/commands/watch/runner.test.js +239 -0
  49. package/dist/tests/commands/watch/supervisor-loop.test.js +203 -0
  50. package/dist/tests/commands/watch/watch-init.test.js +43 -0
  51. package/dist/tests/core/atomic-file-durable.test.js +42 -0
  52. package/dist/tests/core/journal/adapter.test.js +27 -0
  53. package/dist/tests/core/journal/fingerprint.test.js +164 -0
  54. package/dist/tests/core/journal/paths.test.js +35 -0
  55. package/dist/tests/core/journal/process.test.js +213 -0
  56. package/dist/tests/core/journal/redact.test.js +59 -0
  57. package/dist/tests/core/journal/requests.test.js +134 -0
  58. package/dist/tests/core/journal/store.test.js +88 -0
  59. package/dist/tests/core/journal/types.test.js +78 -0
  60. package/dist/tests/structural/exec-invocation-explicit-stdio.test.js +94 -0
  61. package/package.json +1 -1
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.detectRequiredVerifiers = detectRequiredVerifiers;
7
+ exports.ensureJournalGitignored = ensureJournalGitignored;
8
+ exports.initWatch = initWatch;
9
+ // Bootstrap unico writer (R4.1) + validacion MECANICA plan-vs-repo (R1.4b,
10
+ // bloqueador 5): la config real del repo determina los verificadores exigidos.
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const store_1 = require("../../core/journal/store");
14
+ function detectRequiredVerifiers(repoRoot) {
15
+ const kinds = new Set();
16
+ const visit = (dir) => {
17
+ const sensors = path_1.default.join(dir, '.awm', 'sensors.json');
18
+ if (fs_1.default.existsSync(sensors))
19
+ kinds.add('sensors');
20
+ let entries;
21
+ try {
22
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
23
+ }
24
+ catch {
25
+ return;
26
+ }
27
+ for (const entry of entries) {
28
+ if (entry.isSymbolicLink())
29
+ continue;
30
+ if (entry.isFile() && entry.name === 'package.json') {
31
+ try {
32
+ const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, entry.name), 'utf8'));
33
+ if (typeof pkg === 'object' && pkg !== null && typeof pkg.scripts === 'object' && pkg.scripts !== null && typeof pkg.scripts.test === 'string')
34
+ kinds.add('test');
35
+ }
36
+ catch { /* package ilegible: no prueba disponibilidad */ }
37
+ }
38
+ if (entry.isDirectory() && !['node_modules', '.git', '.awm'].includes(entry.name))
39
+ visit(path_1.default.join(dir, entry.name));
40
+ }
41
+ };
42
+ visit(repoRoot);
43
+ return ['test', 'sensors'].filter((kind) => kinds.has(kind));
44
+ }
45
+ /** El journal es gitignoreado (R1.1): sus escrituras jamas alteran fingerprints. */
46
+ function ensureJournalGitignored(repoRoot) {
47
+ const gi = path_1.default.join(repoRoot, '.gitignore');
48
+ let current = '';
49
+ try {
50
+ current = fs_1.default.readFileSync(gi, 'utf8');
51
+ }
52
+ catch {
53
+ current = '';
54
+ }
55
+ if (!current.split('\n').some((l) => l.trim() === '.awm/' || l.trim() === '.awm')) {
56
+ fs_1.default.writeFileSync(gi, current.length > 0 && !current.endsWith('\n') ? `${current}\n.awm/\n` : `${current}.awm/\n`);
57
+ }
58
+ }
59
+ function initWatch(repoRoot, branch) {
60
+ ensureJournalGitignored(repoRoot);
61
+ (0, store_1.initJournal)(repoRoot, branch);
62
+ const required = detectRequiredVerifiers(repoRoot);
63
+ const r = (0, store_1.readJournal)(repoRoot, branch);
64
+ if (r.corrupt || r.state === null)
65
+ throw new Error('journal corrupto tras init: no se continua (R1.6)');
66
+ const s = r.state;
67
+ if (JSON.stringify(s.requiredVerifiers) !== JSON.stringify(required)) {
68
+ s.requiredVerifiers = required;
69
+ (0, store_1.writeJournal)(repoRoot, branch, s);
70
+ }
71
+ return { requiredVerifiers: required };
72
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LockBlockedError = void 0;
7
+ exports.acquireLock = acquireLock;
8
+ exports.releaseLock = releaseLock;
9
+ exports.verifyBranchInvariant = verifyBranchInvariant;
10
+ // Exclusion fisica de supervisores (R4.1): un solo writer por worktree FISICO.
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const crypto_1 = __importDefault(require("crypto"));
14
+ const child_process_1 = require("child_process");
15
+ const paths_1 = require("../../core/journal/paths");
16
+ const process_1 = require("../../core/journal/process");
17
+ const types_1 = require("../../core/journal/types");
18
+ const atomic_file_1 = require("../../core/atomic-file");
19
+ /** Identidad indemostrable en el lock: BLOQUEAR con error DISTINTO — el
20
+ * operador decide con evidencia; el codigo jamas reclama lo que no puede
21
+ * probar muerto (bloqueador 6 de la review). */
22
+ class LockBlockedError extends Error {
23
+ constructor(message) { super(message); this.name = 'LockBlockedError'; }
24
+ }
25
+ exports.LockBlockedError = LockBlockedError;
26
+ function acquireLock(repoRoot) {
27
+ const lp = (0, paths_1.supervisorLockPath)(repoRoot);
28
+ fs_1.default.mkdirSync(path_1.default.dirname(lp), { recursive: true, mode: 0o700 });
29
+ const self = (0, process_1.captureSelfRef)(crypto_1.default.randomBytes(8).toString('hex'));
30
+ const body = JSON.stringify(self, null, 2) + '\n';
31
+ for (let attempt = 0; attempt < 2; attempt++) {
32
+ try {
33
+ const fd = fs_1.default.openSync(lp, 'wx', 0o600); // creacion EXCLUSIVA real: sin ventana existsSync/write
34
+ try {
35
+ fs_1.default.writeFileSync(fd, body);
36
+ fs_1.default.fsyncSync(fd);
37
+ }
38
+ finally {
39
+ fs_1.default.closeSync(fd);
40
+ }
41
+ (0, atomic_file_1.fsyncDirSync)(path_1.default.dirname(lp));
42
+ return { ref: self, path: lp };
43
+ }
44
+ catch (error) {
45
+ if (error.code !== 'EEXIST')
46
+ throw error;
47
+ }
48
+ // EEXIST: leer + validar identidad COMPLETA
49
+ let prior;
50
+ try {
51
+ prior = JSON.parse(fs_1.default.readFileSync(lp, 'utf8'));
52
+ }
53
+ catch {
54
+ throw new LockBlockedError(`lock ilegible en ${lp}: identidad indemostrable — BLOQUEADO (no se reclama; intervencion manual con evidencia)`);
55
+ }
56
+ if (!(0, types_1.isWellFormedProcessRef)(prior)) {
57
+ throw new LockBlockedError(`lock con shape invalido en ${lp}: identidad indemostrable — BLOQUEADO (no se reclama)`);
58
+ }
59
+ if ((0, process_1.refIsAlive)(prior)) {
60
+ throw new Error(`supervisor activo (pid ${prior.pid}) sobre este worktree`);
61
+ }
62
+ // Muerto PROBADO (la tupla completa no matchea a ningun proceso vivo):
63
+ // rm + reintento wx UNA sola vez — si la carrera persiste, error.
64
+ process.stderr.write('awm watch: lock previo con identidad muerta probada — reclamando\n');
65
+ fs_1.default.rmSync(lp, { force: true });
66
+ }
67
+ throw new Error('no se pudo adquirir el lock tras reintento unico (carrera persistente)');
68
+ }
69
+ function releaseLock(repoRoot, handle) {
70
+ const lp = handle.path;
71
+ try {
72
+ const onDisk = JSON.parse(fs_1.default.readFileSync(lp, 'utf8'));
73
+ if (onDisk.spawnNonce === handle.ref.spawnNonce)
74
+ fs_1.default.rmSync(lp);
75
+ }
76
+ catch { /* ya ausente o ilegible: no tocar lo que no es nuestro */ }
77
+ }
78
+ /** WHILE el supervisor este activo, el cambio de rama del worktree se bloquea:
79
+ * gate/reconcile/watch verifican rama actual == rama del journal (R1.1). */
80
+ function verifyBranchInvariant(repoRoot, journalBranch) {
81
+ // stdio explicito (ver EXEC_STDIO en journal/process.ts): sin esto, execFileSync
82
+ // relayea el stderr del subproceso git al stderr DEL SUPERVISOR — si ese fd es
83
+ // un pipe roto, el relay dispara un EPIPE no catcheable que crashea el proceso
84
+ // ENTERO (verifyBranchInvariant corre en cada tick, via supervisor.ts).
85
+ const current = (0, child_process_1.execFileSync)('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8', stdio: process_1.EXEC_STDIO }).trim();
86
+ if (current !== journalBranch) {
87
+ throw new Error(`BLOCKED: rama actual (${current}) != rama del journal (${journalBranch}) — cambio de rama con journal activo (R1.1)`);
88
+ }
89
+ }
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.DEFAULT_STALL_OBSERVATION_MS = void 0;
7
+ exports.defaultWrapperSpawner = defaultWrapperSpawner;
8
+ exports.spawnPendingWrappers = spawnPendingWrappers;
9
+ exports.collectAndReconcile = collectAndReconcile;
10
+ exports.runnerTick = runnerTick;
11
+ // Runner concurrente (bloqueador 3): el supervisor spawnea wrappers DETACHED y
12
+ // jamas los espera; el avance viene del scan de sidecars en cada tick.
13
+ const fs_1 = __importDefault(require("fs"));
14
+ const path_1 = __importDefault(require("path"));
15
+ const crypto_1 = __importDefault(require("crypto"));
16
+ const store_1 = require("../../core/journal/store");
17
+ const paths_1 = require("../../core/journal/paths");
18
+ const process_1 = require("../../core/journal/process");
19
+ const exec_wrapper_1 = require("../job/exec-wrapper");
20
+ const reconcile_1 = require("../job/reconcile");
21
+ const types_1 = require("../../core/journal/types");
22
+ /** Spawner real: `awm job exec-wrapper` como proceso EXTERNO detached via el
23
+ * CLI compilado. fire-and-forget: unref + stdio propio del wrapper. */
24
+ function defaultWrapperSpawner(cliEntry = path_1.default.resolve(__dirname, '..', '..', 'index.js')) {
25
+ return (job, nonce, logsRoot, repoRoot) => {
26
+ const argv = [
27
+ process.execPath, cliEntry, 'job', 'exec-wrapper',
28
+ '--job', job.id, '--nonce', nonce, '--logs', logsRoot, '--cwd', job.cwd,
29
+ '--', ...job.argv,
30
+ ];
31
+ const { child, ref } = (0, process_1.spawnStructured)(argv, repoRoot, nonce);
32
+ child.unref(); // el supervisor NO espera; el wrapper sobrevive incluso si el supervisor muere
33
+ return ref;
34
+ };
35
+ }
36
+ /** spawn-intent + nonce persistidos ANTES de cualquier spawn (R1.8): si el
37
+ * supervisor muere entre journal y spawn, el replay decide por claim. */
38
+ function spawnPendingWrappers(repoRoot, branch, spawner) {
39
+ const r = (0, store_1.readJournal)(repoRoot, branch);
40
+ if (r.corrupt || r.state === null)
41
+ throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
42
+ const s = r.state;
43
+ const received = Object.values(s.jobs).filter((j) => j.executionState === 'received');
44
+ if (received.length === 0)
45
+ return 0;
46
+ for (const j of received) {
47
+ j.spawnNonce = crypto_1.default.randomBytes(8).toString('hex');
48
+ j.executionState = 'spawn-intent';
49
+ j.phaseTimestamps['spawn-intent'] = new Date().toISOString();
50
+ }
51
+ (0, store_1.writeJournal)(repoRoot, branch, s); // intent DURABLE antes del primer spawn
52
+ const logs = (0, paths_1.logsDir)(repoRoot, branch);
53
+ for (const j of received) {
54
+ // Un spawn que lanza sincronicamente NO debe abortar el resto del lote:
55
+ // el spawn-intent de cada job ya quedo durable arriba: el proximo tick
56
+ // lo recoge (claim=>claimed) o la matriz lo declara never-started.
57
+ try {
58
+ spawner(j, j.spawnNonce, logs, repoRoot);
59
+ }
60
+ catch { /* ver comentario: el intent ya persistio, el proximo tick decide */ }
61
+ }
62
+ return received.length;
63
+ }
64
+ const SCANNABLE = ['spawn-intent', 'claimed', 'running'];
65
+ function lastPhaseAgeMs(j) {
66
+ const stamps = Object.values(j.phaseTimestamps).map((t) => Date.parse(t)).filter((n) => !Number.isNaN(n));
67
+ if (stamps.length === 0)
68
+ return Number.POSITIVE_INFINITY;
69
+ return Date.now() - Math.max(...stamps);
70
+ }
71
+ // Observacional puro (R3.5): la duracion NUNCA produce transicion terminal.
72
+ // Señal de progreso = mtime del log del job (analogo al "output consumido por
73
+ // el supervisor" del stall de CONTROLADOR en generations.ts, pero para el job
74
+ // mismo). Sin campo nuevo de tracking de bytes: el propio `lastProgressAt` ya
75
+ // persistido sirve de referencia — si el log crecio (mtime mas nuevo que la
76
+ // ultima marca), hay progreso; si no, y ya paso el umbral, es sospecha de
77
+ // estancamiento. `suspected-stall` jamas mata ni reintenta nada — eso solo lo
78
+ // hace `awm job reap`, invocado por un humano.
79
+ exports.DEFAULT_STALL_OBSERVATION_MS = 5 * 60000; // orden de magnitud de heartbeatTimeoutMs, pero independiente (jobs != controlador)
80
+ function updateJobObservation(j, logs, stallObservationMs) {
81
+ if (j.executionState !== 'running')
82
+ return;
83
+ const nonce = j.spawnNonce ?? 'sin-nonce';
84
+ let mtimeMs = 0;
85
+ try {
86
+ mtimeMs = fs_1.default.statSync((0, exec_wrapper_1.logPath)(logs, j.id, nonce)).mtimeMs;
87
+ }
88
+ catch { /* log aun no existe: sin progreso nuevo */ }
89
+ const baselineIso = j.lastProgressAt ?? j.phaseTimestamps.running;
90
+ const baselineMs = baselineIso !== undefined ? Date.parse(baselineIso) : Number.NEGATIVE_INFINITY;
91
+ if (mtimeMs > baselineMs) {
92
+ j.lastProgressAt = new Date().toISOString();
93
+ j.observationState = 'progressing';
94
+ return;
95
+ }
96
+ if (Date.now() - baselineMs > stallObservationMs) {
97
+ j.observationState = 'suspected-stall';
98
+ }
99
+ }
100
+ /** Cada tick: sidecars primero (claim=>claimed, identity=>running con identidad
101
+ * REAL, result=>exited+verdict), reconciliacion (matriz unica) despues — solo
102
+ * para jobs fuera de su ventana de gracia post-spawn. */
103
+ function collectAndReconcile(repoRoot, branch, opts = {}) {
104
+ const graceMs = opts.reconcileGraceMs ?? 10000;
105
+ const stallObservationMs = opts.stallObservationMs ?? exports.DEFAULT_STALL_OBSERVATION_MS;
106
+ const r = (0, store_1.readJournal)(repoRoot, branch);
107
+ if (r.corrupt || r.state === null)
108
+ throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
109
+ const s = r.state;
110
+ const logs = (0, paths_1.logsDir)(repoRoot, branch);
111
+ let advanced = 0;
112
+ let observationTouched = false;
113
+ for (const j of Object.values(s.jobs)) {
114
+ if (!SCANNABLE.includes(j.executionState))
115
+ continue;
116
+ const nonce = j.spawnNonce ?? 'sin-nonce';
117
+ if (fs_1.default.existsSync((0, exec_wrapper_1.claimPath)(logs, j.id, nonce)) && fs_1.default.existsSync((0, exec_wrapper_1.resultPath)(logs, j.id, nonce))) {
118
+ try {
119
+ const parsed = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.resultPath)(logs, j.id, nonce), 'utf8'));
120
+ if (typeof parsed.exitCode !== 'number' || typeof parsed.endedAt !== 'string' || typeof parsed.resultPath !== 'string')
121
+ continue;
122
+ j.executionState = 'exited';
123
+ j.result = parsed;
124
+ j.verdict = parsed.exitCode === 0 ? 'pass' : 'fail';
125
+ j.phaseTimestamps.exited = j.phaseTimestamps.exited ?? new Date().toISOString();
126
+ advanced++;
127
+ }
128
+ catch { /* resultado ilegible: la matriz decidira (unprovable) */ }
129
+ continue;
130
+ }
131
+ if (fs_1.default.existsSync((0, exec_wrapper_1.identityPath)(logs, j.id, nonce)) && j.executionState !== 'running') {
132
+ try {
133
+ const identity = JSON.parse(fs_1.default.readFileSync((0, exec_wrapper_1.identityPath)(logs, j.id, nonce), 'utf8'));
134
+ if (identity.jobId === j.id && identity.nonce === nonce
135
+ && (0, types_1.isWellFormedProcessRef)(identity.wrapper) && (0, types_1.isWellFormedProcessRef)(identity.command)
136
+ && identity.wrapper.spawnNonce === nonce && identity.command.spawnNonce === nonce
137
+ && identity.command.argvDigest === (0, process_1.argvDigest)(j.argv)) {
138
+ j.wrapperRef = identity.wrapper;
139
+ j.processRef = identity.command; // identidad REAL, nunca pid 0 (bloqueador 3)
140
+ j.executionState = 'running';
141
+ j.phaseTimestamps.running = new Date().toISOString();
142
+ j.lastProgressAt = new Date().toISOString();
143
+ advanced++;
144
+ }
145
+ }
146
+ catch { /* sidecar a medio escribir: proximo tick */ }
147
+ continue;
148
+ }
149
+ if (fs_1.default.existsSync((0, exec_wrapper_1.claimPath)(logs, j.id, nonce)) && j.executionState === 'spawn-intent') {
150
+ j.executionState = 'claimed';
151
+ j.phaseTimestamps.claimed = new Date().toISOString();
152
+ advanced++;
153
+ }
154
+ }
155
+ // observationState (R3.5): puramente informativo, corre para TODO job aun
156
+ // 'running' tras el scan de arriba — jamas toca executionState ni participa
157
+ // de la matriz de abajo.
158
+ for (const j of Object.values(s.jobs)) {
159
+ const before = `${j.observationState}|${j.lastProgressAt ?? ''}`;
160
+ updateJobObservation(j, logs, stallObservationMs);
161
+ if (`${j.observationState}|${j.lastProgressAt ?? ''}` !== before)
162
+ observationTouched = true;
163
+ }
164
+ // Matriz unica SOLO fuera de la gracia post-spawn: un wrapper recien
165
+ // spawneado que aun no claimeo NO es un never-started.
166
+ const out = (0, reconcile_1.reconcileJobs)(s, logs, { eligible: (j) => lastPhaseAgeMs(j) > graceMs });
167
+ if (advanced > 0 || observationTouched || out.decisions.some((d) => d.action !== 'still-alive')) {
168
+ (0, store_1.writeJournal)(repoRoot, branch, s);
169
+ }
170
+ return { advanced, decisions: out.decisions };
171
+ }
172
+ function runnerTick(repoRoot, branch, spawner, opts = {}) {
173
+ const collected = collectAndReconcile(repoRoot, branch, opts);
174
+ const r = (0, store_1.readJournal)(repoRoot, branch);
175
+ if (r.corrupt || r.state === null)
176
+ throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
177
+ const logs = (0, paths_1.logsDir)(repoRoot, branch);
178
+ for (const decision of collected.decisions) {
179
+ if (decision.action !== 'retry-same-intent')
180
+ continue;
181
+ const job = r.state.jobs[decision.jobId];
182
+ if (job?.executionState !== 'spawn-intent' || job.spawnNonce === undefined)
183
+ continue;
184
+ try {
185
+ spawner(job, job.spawnNonce, logs, repoRoot);
186
+ }
187
+ catch { /* el mismo intent durable se reintentara en otro tick */ }
188
+ }
189
+ const spawned = spawnPendingWrappers(repoRoot, branch, spawner);
190
+ return { spawned, advanced: collected.advanced, decisions: collected.decisions };
191
+ }
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Supervisor = exports.DEFAULT_SUPERVISOR_CONFIG = void 0;
4
+ exports.runSupervisorLoop = runSupervisorLoop;
5
+ // Loop foreground (R4.4/R4.5): tick = apply -> collect/spawn -> stall -> gate.
6
+ // COMPLETE exige gate verde (que exige cero vivos): drenaje ANTES de declarar.
7
+ // Custodia BLOCKED: el loop sigue, el lock NO se libera, nada se mata.
8
+ const store_1 = require("../../core/journal/store");
9
+ const fingerprint_1 = require("../../core/journal/fingerprint");
10
+ const adapter_1 = require("../../core/journal/adapter");
11
+ const process_1 = require("../../core/journal/process");
12
+ const gate_1 = require("../job/gate");
13
+ const lock_1 = require("./lock");
14
+ const apply_1 = require("./apply");
15
+ const runner_1 = require("./runner");
16
+ const generations_1 = require("./generations");
17
+ exports.DEFAULT_SUPERVISOR_CONFIG = {
18
+ provider: 'codex',
19
+ heartbeatTimeoutMs: 5 * 60000, // R4.2 default 5 min
20
+ activityWindowMs: 10 * 60000, // R4.2 ventana propia adicional
21
+ tickMs: 5000,
22
+ termGraceMs: 30000, // R4.2b flush 30 s
23
+ killGraceMs: 5000,
24
+ reconcileGraceMs: 10000,
25
+ jobStallObservationMs: 5 * 60000, // R3.5 default: mismo orden de magnitud que heartbeatTimeoutMs, concern independiente
26
+ };
27
+ const LIVE = ['received', 'spawn-intent', 'claimed', 'running', 'cancel-requested'];
28
+ class Supervisor {
29
+ repoRoot;
30
+ branch;
31
+ cfg;
32
+ spawner;
33
+ backoff = new generations_1.Backoff();
34
+ relaunchNotBefore = 0;
35
+ lastActivity = null;
36
+ lastGenerationToken = null;
37
+ constructor(repoRoot, branch, cfg, spawner) {
38
+ this.repoRoot = repoRoot;
39
+ this.branch = branch;
40
+ this.cfg = cfg;
41
+ this.spawner = spawner;
42
+ }
43
+ fingerprintNow = (argv, paths, cwd) => {
44
+ try {
45
+ return (0, fingerprint_1.computeFingerprint)(this.repoRoot, argv, paths, cwd).fingerprint;
46
+ }
47
+ catch {
48
+ return null;
49
+ } // no recomputable => el gate NO certifica (fail-closed)
50
+ };
51
+ ensureController(resumePrompt) {
52
+ if (Date.now() < this.relaunchNotBefore)
53
+ return 'deferred';
54
+ if (this.backoff.exhausted()) {
55
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'tope de intentos de launch/relaunch por hora alcanzado (R4.3)');
56
+ return 'custody';
57
+ }
58
+ try {
59
+ (0, generations_1.ensureControllerGeneration)(this.repoRoot, this.branch, this.cfg.provider, resumePrompt, this.spawner, this.cfg.reconcileGraceMs);
60
+ return 'ok';
61
+ }
62
+ catch (error) {
63
+ this.backoff.recordRelaunch();
64
+ const delayMs = this.backoff.nextMs();
65
+ this.relaunchNotBefore = Date.now() + delayMs;
66
+ (0, store_1.appendEvent)(this.repoRoot, this.branch, {
67
+ kind: 'controller-launch-failed', detail: error.message, retryAfterMs: delayMs,
68
+ });
69
+ if (this.backoff.exhausted()) {
70
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'tope de intentos de launch/relaunch por hora alcanzado (R4.3)');
71
+ return 'custody';
72
+ }
73
+ return 'deferred';
74
+ }
75
+ }
76
+ async tick() {
77
+ const before = (0, store_1.readJournal)(this.repoRoot, this.branch);
78
+ if (before.corrupt || before.state === null)
79
+ throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
80
+ (0, lock_1.verifyBranchInvariant)(this.repoRoot, before.state.branch);
81
+ if (before.state.cycle.status === 'COMPLETE')
82
+ return 'complete';
83
+ const pending = before.state?.cycle.nextAction;
84
+ const resumePrompt = pending !== undefined ? `el next_action ${pending.actionId} del journal` : 'el plan del ciclo desde el journal';
85
+ if (this.ensureController(resumePrompt) === 'custody')
86
+ return 'custody';
87
+ const r0 = (0, store_1.readJournal)(this.repoRoot, this.branch);
88
+ if (r0.corrupt || r0.state === null)
89
+ throw new Error('journal corrupto: el supervisor no opera sobre corrupcion (R1.6)');
90
+ const gen = (0, generations_1.activeGeneration)(r0.state);
91
+ (0, apply_1.consumePendingRequests)(this.repoRoot, this.branch, gen?.token ?? null);
92
+ const afterRequests = (0, store_1.readJournal)(this.repoRoot, this.branch);
93
+ if (afterRequests.state !== null && (0, generations_1.activeGeneration)(afterRequests.state) === undefined
94
+ && afterRequests.state.generations.length > 0 && afterRequests.state.cycle.status === 'IN_PROGRESS') {
95
+ (0, generations_1.beginGeneration)(this.repoRoot, this.branch);
96
+ if (this.ensureController(resumePrompt) === 'custody')
97
+ return 'custody';
98
+ }
99
+ (0, runner_1.runnerTick)(this.repoRoot, this.branch, this.spawner, { reconcileGraceMs: this.cfg.reconcileGraceMs, stallObservationMs: this.cfg.jobStallObservationMs });
100
+ const custody = await this.superviseController();
101
+ if (custody)
102
+ return 'custody';
103
+ const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
104
+ const gate = (0, gate_1.computeGate)(r.state, r.corrupt, this.fingerprintNow);
105
+ const liveJobs = r.state === null ? 1 : Object.values(r.state.jobs).filter((j) => LIVE.includes(j.executionState)).length;
106
+ if (gate.pass && liveJobs === 0) { // gate verde YA implica cero vivos; doble cinturon (R4.5)
107
+ const s = r.state;
108
+ for (const generation of s.generations) {
109
+ for (const ref of [generation.processRef, generation.wrapperRef]) {
110
+ // Los tests pueden ejecutar el wrapper in-process; nunca
111
+ // enviar una senial al propio supervisor.
112
+ if (ref?.pid === process.pid)
113
+ continue;
114
+ if (ref === undefined || (0, process_1.groupIsGone)(ref.processGroup))
115
+ continue;
116
+ const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: this.cfg.termGraceMs, killGraceMs: this.cfg.killGraceMs });
117
+ if (!confirmed) {
118
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, `no se pudo terminar con identidad confirmada la generacion ${generation.n} antes de COMPLETE`);
119
+ return 'custody';
120
+ }
121
+ }
122
+ generation.state = 'terminated';
123
+ }
124
+ s.cycle.status = 'COMPLETE';
125
+ s.cycle.completedAt = new Date().toISOString();
126
+ (0, store_1.writeJournal)(this.repoRoot, this.branch, s);
127
+ (0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'cycle-complete' });
128
+ return 'complete';
129
+ }
130
+ return 'continue';
131
+ }
132
+ /** true => custodia (el caller NO libera lock ni sale). */
133
+ async superviseController() {
134
+ const r = (0, store_1.readJournal)(this.repoRoot, this.branch);
135
+ if (r.corrupt || r.state === null)
136
+ throw new Error('journal corrupto (R1.6)');
137
+ const s = r.state;
138
+ const gen = (0, generations_1.activeGeneration)(s);
139
+ if (gen?.processRef === undefined)
140
+ return false; // sin controlador propio: nada que supervisar
141
+ if (this.lastGenerationToken !== gen.token) {
142
+ this.lastGenerationToken = gen.token;
143
+ this.lastActivity = null;
144
+ }
145
+ const adapter = (0, adapter_1.adapterFor)(this.cfg.provider);
146
+ const heartbeatAgeMs = Date.now() - Date.parse(s.controllerHeartbeatAt ?? gen.launchedAt);
147
+ const snap = adapter.activity(gen.processRef);
148
+ const key = JSON.stringify(snap);
149
+ if (this.lastActivity === null || this.lastActivity.key !== key) {
150
+ this.lastActivity = { key, changedAt: Date.now() };
151
+ }
152
+ const activityFrozenMs = Date.now() - this.lastActivity.changedAt;
153
+ const decision = (0, generations_1.decideStall)({ heartbeatAgeMs, activityFrozenMs, safeToReplace: adapter.safeToReplace(gen.processRef) }, { heartbeatTimeoutMs: this.cfg.heartbeatTimeoutMs, activityWindowMs: this.cfg.activityWindowMs });
154
+ if (decision === 'healthy') {
155
+ this.backoff.reset();
156
+ return false;
157
+ }
158
+ if (decision === 'suspected-stall-observe') {
159
+ if (gen.state !== 'controller-suspected-stall') {
160
+ gen.state = 'controller-suspected-stall'; // SOLO observacion (R4.2)
161
+ (0, store_1.writeJournal)(this.repoRoot, this.branch, s);
162
+ (0, store_1.appendEvent)(this.repoRoot, this.branch, { kind: 'controller-suspected-stall', n: gen.n });
163
+ }
164
+ return false;
165
+ }
166
+ if (decision === 'custody-blocked') {
167
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'doble senial de stall sin safeToReplace positivo del adapter (R4.2b)');
168
+ return true;
169
+ }
170
+ // resolve-generation
171
+ const resolved = await (0, generations_1.resolveGeneration)(this.repoRoot, this.branch, adapter, { termGraceMs: this.cfg.termGraceMs, killGraceMs: this.cfg.killGraceMs });
172
+ if (resolved === 'custody-blocked')
173
+ return true;
174
+ if (this.backoff.exhausted()) {
175
+ (0, generations_1.enterCustody)(this.repoRoot, this.branch, 'tope de relanzamientos por hora alcanzado (R4.3)');
176
+ return true;
177
+ }
178
+ if (Date.now() < this.relaunchNotBefore)
179
+ return false; // esperando backoff, auditando
180
+ (0, generations_1.beginGeneration)(this.repoRoot, this.branch);
181
+ const nextAction = (0, store_1.readJournal)(this.repoRoot, this.branch).state.cycle.nextAction;
182
+ const prompt = nextAction !== undefined ? `el next_action ${nextAction.actionId} del journal` : 'el plan del ciclo desde el journal';
183
+ const launched = this.ensureController(prompt);
184
+ if (launched === 'custody')
185
+ return true;
186
+ if (launched === 'ok') {
187
+ this.backoff.recordRelaunch();
188
+ this.relaunchNotBefore = Date.now() + this.backoff.nextMs();
189
+ }
190
+ return false;
191
+ }
192
+ }
193
+ exports.Supervisor = Supervisor;
194
+ /** Foreground, visible, terminable (R2.4): sin daemons. SIGINT/SIGTERM libera
195
+ * el lock y sale; COMPLETE => auto-exit liberando lock y terminando la
196
+ * generacion propia (cero huerfanos). */
197
+ async function runSupervisorLoop(repoRoot, branch, cfg, spawner = (0, runner_1.defaultWrapperSpawner)()) {
198
+ const r = (0, store_1.readJournal)(repoRoot, branch);
199
+ if (r.corrupt || r.state === null)
200
+ throw new Error('journal ausente o corrupto: corre `awm watch --init` primero');
201
+ (0, lock_1.verifyBranchInvariant)(repoRoot, r.state.branch);
202
+ if (r.state.cycle.status === 'COMPLETE')
203
+ return;
204
+ const handle = (0, lock_1.acquireLock)(repoRoot);
205
+ let shutdownRequested = false;
206
+ let wakeSleep = null;
207
+ let safeToRelease = false;
208
+ const onSignal = () => { shutdownRequested = true; wakeSleep?.(); };
209
+ process.on('SIGINT', onSignal);
210
+ process.on('SIGTERM', onSignal);
211
+ const sup = new Supervisor(repoRoot, branch, cfg, spawner);
212
+ try {
213
+ const s0 = (0, store_1.readJournal)(repoRoot, branch).state;
214
+ if ((0, generations_1.activeGeneration)(s0) === undefined) {
215
+ (0, generations_1.beginGeneration)(repoRoot, branch);
216
+ }
217
+ for (;;) {
218
+ if (shutdownRequested)
219
+ break;
220
+ const out = await sup.tick();
221
+ if (out === 'complete')
222
+ break;
223
+ // 'custody': NO liberar lock, NO salir — seguir auditando (R4.5)
224
+ await new Promise((resolve) => {
225
+ let settled = false;
226
+ const finish = () => { if (!settled) {
227
+ settled = true;
228
+ clearTimeout(timer);
229
+ wakeSleep = null;
230
+ resolve();
231
+ } };
232
+ const timer = setTimeout(finish, cfg.tickMs);
233
+ wakeSleep = finish;
234
+ });
235
+ }
236
+ // En shutdown explicito, drenar ownership ANTES de liberar el lock. En
237
+ // COMPLETE, tick() ya hizo exactamente esta confirmacion antes de
238
+ // persistir el estado terminal; el loop solo verifica el invariante.
239
+ (0, generations_1.collectControllerGeneration)(repoRoot, branch);
240
+ const sEnd = (0, store_1.readJournal)(repoRoot, branch).state;
241
+ for (const g of sEnd.generations) {
242
+ if ((0, generations_1.controllerGenerationHasUnresolvedClaim)(repoRoot, branch, g)) {
243
+ throw new Error(`ownership retenido: generacion ${g.n} tiene claim sin identidad ni resultado`);
244
+ }
245
+ for (const ref of [g.processRef, g.wrapperRef]) {
246
+ if (ref?.pid === process.pid)
247
+ continue;
248
+ if (ref === undefined || (0, process_1.groupIsGone)(ref.processGroup))
249
+ continue;
250
+ const confirmed = await (0, process_1.terminateGroupConfirmed)(ref, { termGraceMs: cfg.termGraceMs, killGraceMs: cfg.killGraceMs });
251
+ if (!confirmed)
252
+ throw new Error(`ownership retenido: generacion ${g.n} sigue viva o su identidad es indemostrable`);
253
+ }
254
+ g.state = 'terminated';
255
+ }
256
+ if (shutdownRequested)
257
+ (0, store_1.writeJournal)(repoRoot, branch, sEnd);
258
+ safeToRelease = true;
259
+ }
260
+ finally {
261
+ process.removeListener('SIGINT', onSignal);
262
+ process.removeListener('SIGTERM', onSignal);
263
+ if (safeToRelease)
264
+ (0, lock_1.releaseLock)(repoRoot, handle);
265
+ }
266
+ }
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.writeFileAtomic = writeFileAtomic;
7
+ exports.fsyncDirSync = fsyncDirSync;
8
+ exports.writeFileAtomicDurable = writeFileAtomicDurable;
7
9
  const fs_1 = __importDefault(require("fs"));
8
10
  const path_1 = __importDefault(require("path"));
9
11
  const crypto_1 = __importDefault(require("crypto"));
@@ -70,3 +72,32 @@ function writeFileAtomic(file, content, mode = 0o644) {
70
72
  throw error;
71
73
  }
72
74
  }
75
+ /** fsync del directorio contenedor: garantiza que una ENTRADA creada/renombrada
76
+ * sobrevive un crash del OS. Falla LANZANDO — la durabilidad de la transición
77
+ * es parte del contrato, nunca un best-effort silencioso (design R1.2,
78
+ * bloqueador 4 de la review del plan). */
79
+ function fsyncDirSync(dir) {
80
+ let dirFd;
81
+ try {
82
+ dirFd = fs_1.default.openSync(dir, 'r');
83
+ fs_1.default.fsyncSync(dirFd);
84
+ }
85
+ catch (error) {
86
+ throw new Error(`fsync de directorio fallo para ${dir}: ${error.message}`);
87
+ }
88
+ finally {
89
+ if (dirFd !== undefined) {
90
+ try {
91
+ fs_1.default.closeSync(dirFd);
92
+ }
93
+ catch {
94
+ // best-effort SOLO el close: el fsync ya ocurrio o ya lanzo.
95
+ }
96
+ }
97
+ }
98
+ }
99
+ /** writeFileAtomic + fsync del directorio contenedor tras el rename. */
100
+ function writeFileAtomicDurable(file, content, mode = 0o644) {
101
+ writeFileAtomic(file, content, mode);
102
+ fsyncDirSync(path_1.default.dirname(file));
103
+ }