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,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.planReap = planReap;
4
+ exports.executeReap = executeReap;
5
+ const process_1 = require("../../core/journal/process");
6
+ function planReap(state) {
7
+ return Object.values(state.jobs)
8
+ .filter((j) => j.processRef !== undefined)
9
+ .map((j) => ({ jobId: j.id, pid: j.processRef.pid, aliveWithIdentity: (0, process_1.refIsAlive)(j.processRef) }));
10
+ }
11
+ async function executeReap(state, jobIds) {
12
+ const killed = [];
13
+ for (const id of jobIds) {
14
+ const j = state.jobs[id];
15
+ if (j?.processRef === undefined)
16
+ continue;
17
+ if (!(0, process_1.refIsAlive)(j.processRef))
18
+ continue; // identidad no confirmada => ni una senial (R2.1)
19
+ const dead = await (0, process_1.terminateGroupConfirmed)(j.processRef, { termGraceMs: 3000, killGraceMs: 2000 });
20
+ if (dead)
21
+ killed.push(id);
22
+ }
23
+ return killed;
24
+ }
@@ -0,0 +1,112 @@
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.reconcileJobs = reconcileJobs;
7
+ exports.materializeRetry = materializeRetry;
8
+ // LA UNICA matriz de recuperacion (design R3.3 = R1.8, sin excepciones).
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const crypto_1 = __importDefault(require("crypto"));
11
+ const process_1 = require("../../core/journal/process");
12
+ const exec_wrapper_1 = require("./exec-wrapper");
13
+ /** El sidecar de resultado lo escribe un proceso EXTERNO no coordinado
14
+ * (exec-wrapper): existencia del archivo (`replayVerdict`) no es prueba de
15
+ * contenido bien formado. Nunca fabricar un pass/fail de JSON invalido o de
16
+ * forma incorrecta (R1.6) — un resultado no verificable cae al mismo
17
+ * disposition que "unprovable": orphaned-authorization-required. */
18
+ function isWellFormedJobResult(x) {
19
+ return typeof x === 'object' && x !== null && typeof x.exitCode === 'number'
20
+ && typeof x.endedAt === 'string'
21
+ && typeof x.resultPath === 'string';
22
+ }
23
+ function readCompletedResult(logsRoot, jobId, nonce) {
24
+ let raw;
25
+ try {
26
+ raw = fs_1.default.readFileSync((0, exec_wrapper_1.resultPath)(logsRoot, jobId, nonce), 'utf8');
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(raw);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ return isWellFormedJobResult(parsed) ? parsed : null;
39
+ }
40
+ const NON_TERMINAL = ['spawn-intent', 'claimed', 'running', 'cancel-requested'];
41
+ function reconcileJobs(state, logsRoot, opts = {}) {
42
+ const eligible = opts.eligible ?? (() => true);
43
+ const decisions = [];
44
+ for (const j of Object.values(state.jobs)) {
45
+ if (!NON_TERMINAL.includes(j.executionState))
46
+ continue;
47
+ if (!eligible(j))
48
+ continue;
49
+ const anyAlive = (j.processRef !== undefined && (0, process_1.refIsAlive)(j.processRef))
50
+ || (j.wrapperRef !== undefined && (0, process_1.refIsAlive)(j.wrapperRef));
51
+ if (anyAlive) {
52
+ decisions.push({ jobId: j.id, action: 'still-alive' });
53
+ continue;
54
+ }
55
+ const nonce = j.spawnNonce ?? j.processRef?.spawnNonce ?? 'sin-nonce';
56
+ const verdict = (0, exec_wrapper_1.replayVerdict)(logsRoot, j.id, nonce);
57
+ const result = verdict === 'completed' ? readCompletedResult(logsRoot, j.id, nonce) : null;
58
+ if (verdict === 'never-started') {
59
+ // Reemitir exactamente el mismo intent/nonce. El claim `wx` del
60
+ // wrapper hace que un spawn original demorado y este retry no
61
+ // puedan ejecutar ambos el comando.
62
+ decisions.push({ jobId: j.id, action: 'retry-same-intent' });
63
+ }
64
+ else if (result !== null) {
65
+ j.executionState = 'exited';
66
+ j.spawnNonce = nonce;
67
+ j.result = result;
68
+ j.verdict = result.exitCode === 0 ? 'pass' : 'fail';
69
+ j.phaseTimestamps.exited = j.phaseTimestamps.exited ?? new Date().toISOString();
70
+ decisions.push({ jobId: j.id, action: 'adopt-result' });
71
+ }
72
+ else {
73
+ // 'unprovable' O 'completed' con sidecar corrupto/mal formado:
74
+ // ambos son evidencia no verificable — jamas fabricar un pass/fail
75
+ // de JSON invalido, jamas relanzar solo (R1.6, R1.8).
76
+ j.executionState = 'orphaned';
77
+ decisions.push({ jobId: j.id, action: 'orphaned-authorization-required' });
78
+ }
79
+ }
80
+ return { decisions };
81
+ }
82
+ /** Re-reclamar = Attempt NUEVO enlazado, nunca reutilizar (R1.7). El job viejo
83
+ * queda 'cancelled' (su intent se retira); el nuevo nace 'received' sin nonce
84
+ * — el runner le asigna uno fresco en spawn-intent. */
85
+ function materializeRetry(state, jobId) {
86
+ const old = state.jobs[jobId];
87
+ if (old === undefined)
88
+ throw new Error(`job desconocido: ${jobId}`);
89
+ old.executionState = 'cancelled';
90
+ const fresh = {
91
+ ...old,
92
+ id: `${old.id}-a${crypto_1.default.randomBytes(3).toString('hex')}`,
93
+ executionState: 'received',
94
+ observationState: 'progressing',
95
+ spawnNonce: undefined, processRef: undefined, wrapperRef: undefined,
96
+ verdict: undefined, result: undefined,
97
+ phaseTimestamps: { received: new Date().toISOString() },
98
+ attemptOf: old.id,
99
+ };
100
+ state.jobs[fresh.id] = fresh;
101
+ for (const task of state.tasks) {
102
+ for (const item of task.verificationPlan) {
103
+ if (item.satisfiedBy === old.id)
104
+ item.satisfiedBy = fresh.id;
105
+ }
106
+ }
107
+ for (const item of state.cycleVerificationPlan) {
108
+ if (item.satisfiedBy === old.id)
109
+ item.satisfiedBy = fresh.id;
110
+ }
111
+ return fresh;
112
+ }
@@ -0,0 +1,27 @@
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.requestJob = requestJob;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const fingerprint_1 = require("../../core/journal/fingerprint");
9
+ const requests_1 = require("../../core/journal/requests");
10
+ /** El agente NO ejecuta: registra la intencion (design R3.1). La idempotencyKey
11
+ * es hash(fingerprint + commandDigest) => get-or-create atomico (RNF-T.7).
12
+ * El cwd relativo REAL es parte del fingerprint (R3.4). `satisfies` enlaza el
13
+ * job con el item de VerificationPlan que pretende satisfacer (R1.4c). */
14
+ function requestJob(repoRoot, branch, generationToken, argv, paths, cwdRel, opts = {}) {
15
+ const fp = (0, fingerprint_1.computeFingerprint)(repoRoot, argv, paths, cwdRel);
16
+ // La obligacion es parte de la identidad de la REQUEST, no de la ejecucion:
17
+ // apply.ts reutiliza el job mecanicamente equivalente y enlaza el nuevo item.
18
+ const idempotencyKey = crypto_1.default.createHash('sha256').update(`${fp.fingerprint}:${fp.commandDigest}:${opts.satisfies ?? ''}`).digest('hex');
19
+ return (0, requests_1.emitRequest)(repoRoot, branch, {
20
+ kind: 'job-request', generationToken, idempotencyKey,
21
+ payload: {
22
+ argv, paths, cwd: cwdRel,
23
+ fingerprint: fp.fingerprint, commandDigest: fp.commandDigest, expandedPaths: fp.expandedPaths,
24
+ ...(opts.satisfies !== undefined ? { satisfies: opts.satisfies } : {}),
25
+ },
26
+ });
27
+ }
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runCommand = runCommand;
4
+ const child_process_1 = require("child_process");
5
+ const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
6
+ const DEFAULT_KILL_GRACE_MS = 2_000;
7
+ /** After SIGKILL, resolve regardless. A sensor must never hang the gate. */
8
+ const POST_KILL_GRACE_MS = 1_000;
9
+ /**
10
+ * Kill an entire process tree, not just its root.
11
+ *
12
+ * This is the reason `execSync` had to go. `execSync(cmd, { timeout })` spawns
13
+ * `/bin/sh -c cmd` and, on the deadline, SIGTERMs *that shell only*. A sensor
14
+ * command is almost always a wrapper (`npx tsc --noEmit`, `npm test`), so the
15
+ * tool doing the actual work is a grandchild: it survives, gets reparented to
16
+ * init, and keeps burning CPU. Every timeout then leaves a full tsc/eslint
17
+ * running, which makes the next run slower, which makes it time out too. The
18
+ * leak compounds — a repo that was fine at 200 files becomes ungateable at 2000
19
+ * for reasons that have nothing to do with its size.
20
+ *
21
+ * `detached: true` puts the child in its own process group (pgid === pid) so a
22
+ * negative-pid kill reaches every descendant at once.
23
+ */
24
+ function killTree(pid, signal) {
25
+ if (process.platform === 'win32') {
26
+ // Windows has no process groups in the POSIX sense; taskkill /T walks the tree.
27
+ try {
28
+ (0, child_process_1.execFile)('taskkill', ['/pid', String(pid), '/T', '/F'], () => { });
29
+ }
30
+ catch { /* ignore */ }
31
+ return;
32
+ }
33
+ try {
34
+ process.kill(-pid, signal); // negative pid → the whole group
35
+ }
36
+ catch {
37
+ // Group already gone (normal race with a process exiting on its own),
38
+ // or we never became a group leader. Fall back to the direct child.
39
+ try {
40
+ process.kill(pid, signal);
41
+ }
42
+ catch { /* already dead */ }
43
+ }
44
+ }
45
+ /**
46
+ * Run a shell command to completion, a deadline, or an output cap — whichever
47
+ * comes first — and always return what was collected.
48
+ *
49
+ * Two guarantees `execSync` could not give:
50
+ * 1. Cutting a run short kills the whole process tree (see `killTree`).
51
+ * 2. Output produced before the cut is returned, not discarded. A timeout that
52
+ * throws away 60s of eslint output costs double: the wall clock, and then
53
+ * the re-run the caller has to do to learn anything at all.
54
+ */
55
+ function runCommand(cmd, opts) {
56
+ const maxBuffer = opts.maxBuffer ?? DEFAULT_MAX_BUFFER;
57
+ const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
58
+ return new Promise((resolve) => {
59
+ let stdout = '';
60
+ let stderr = '';
61
+ let timedOut = false;
62
+ let overflowed = false;
63
+ let settled = false;
64
+ const timers = [];
65
+ const child = (0, child_process_1.spawn)(cmd, {
66
+ shell: true,
67
+ cwd: opts.cwd,
68
+ detached: process.platform !== 'win32',
69
+ // stdin closed: a sensor must never block waiting for input, and the
70
+ // EOF also tells watch-mode-capable tools (vitest, jest) to run once.
71
+ stdio: ['ignore', 'pipe', 'pipe'],
72
+ });
73
+ const later = (fn, ms) => {
74
+ const t = setTimeout(fn, ms);
75
+ t.unref?.();
76
+ timers.push(t);
77
+ return t;
78
+ };
79
+ const finish = (extra) => {
80
+ if (settled)
81
+ return;
82
+ settled = true;
83
+ timers.forEach(clearTimeout);
84
+ resolve({ stdout, stderr, code: null, signal: null, timedOut, overflowed, ...extra });
85
+ };
86
+ /** Cut the run short: kill the tree, escalate, and never hang waiting for it. */
87
+ const cutShort = () => {
88
+ if (settled || child.pid === undefined)
89
+ return;
90
+ const pid = child.pid;
91
+ killTree(pid, 'SIGTERM');
92
+ later(() => killTree(pid, 'SIGKILL'), killGraceMs);
93
+ // If `close` still has not fired after the escalation, stop waiting.
94
+ // Whatever is holding the pipe open is no longer our problem to block on.
95
+ later(() => { child.unref(); finish({ signal: 'SIGKILL' }); }, killGraceMs + POST_KILL_GRACE_MS);
96
+ };
97
+ const collect = (into) => (chunk) => {
98
+ if (settled || overflowed)
99
+ return;
100
+ const text = String(chunk);
101
+ const current = into === 'out' ? stdout : stderr;
102
+ const room = maxBuffer - current.length;
103
+ const next = current + (text.length > room ? text.slice(0, room) : text);
104
+ if (into === 'out')
105
+ stdout = next;
106
+ else
107
+ stderr = next;
108
+ if (next.length >= maxBuffer) {
109
+ overflowed = true;
110
+ cutShort();
111
+ }
112
+ };
113
+ child.stdout?.on('data', collect('out'));
114
+ child.stderr?.on('data', collect('err'));
115
+ child.stdout?.on('error', () => { });
116
+ child.stderr?.on('error', () => { });
117
+ child.on('error', (err) => finish({ spawnError: err }));
118
+ child.on('close', (code, signal) => finish({ code, signal }));
119
+ later(() => { timedOut = true; cutShort(); }, opts.timeout);
120
+ });
121
+ }
@@ -27,8 +27,8 @@ function registerSensorsCommand(program) {
27
27
  .option('--fast', 'run fast sensors only (tsc, lint)')
28
28
  .option('--slow', 'run slow sensors only (semgrep, mutation)')
29
29
  .option('--all', 'run all sensors regardless of speed')
30
- .action((opts) => {
31
- const output = (0, run_1.runSensors)({ fast: opts.fast, slow: opts.slow, all: opts.all });
30
+ .action(async (opts) => {
31
+ const output = await (0, run_1.runSensors)({ fast: opts.fast, slow: opts.slow, all: opts.all });
32
32
  // Emit the verdict ALWAYS — an empty `sensors` with overall:'not_certified'
33
33
  // must be visible, never a silent exit-0 that reads as "clean".
34
34
  process.stdout.write(JSON.stringify(output, null, 2) + '\n');
@@ -51,9 +51,9 @@ function registerSensorsCommand(program) {
51
51
  sensors
52
52
  .command('baseline')
53
53
  .description('snapshot current findings as accepted — sensors then fail only on NEW ones')
54
- .action(() => {
54
+ .action(async () => {
55
55
  const manifestDir = (0, run_1.findManifestDir)(process.cwd());
56
- const output = (0, run_1.runSensors)({ all: true, ignoreBaseline: true });
56
+ const output = await (0, run_1.runSensors)({ all: true, ignoreBaseline: true });
57
57
  const baseline = (0, baseline_1.buildBaseline)(output.sensors.map(s => ({ name: s.name, errors: s.errors })));
58
58
  const writeDir = manifestDir ?? process.cwd();
59
59
  (0, baseline_1.writeBaseline)(writeDir, baseline);
@@ -6,10 +6,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.applyBaseline = applyBaseline;
7
7
  exports.reconcilePack = reconcilePack;
8
8
  exports.findManifestDir = findManifestDir;
9
+ exports.resolveConcurrency = resolveConcurrency;
9
10
  exports.runSensors = runSensors;
10
- const child_process_1 = require("child_process");
11
11
  const fs_1 = __importDefault(require("fs"));
12
+ const os_1 = __importDefault(require("os"));
12
13
  const path_1 = __importDefault(require("path"));
14
+ const exec_1 = require("./exec");
13
15
  const tsc_1 = require("./formatters/tsc");
14
16
  const eslint_1 = require("./formatters/eslint");
15
17
  const semgrep_1 = require("./formatters/semgrep");
@@ -22,9 +24,11 @@ const MANIFEST_FILE = '.awm/sensors.json';
22
24
  const DEFAULT_FAST_TIMEOUT = 10_000;
23
25
  const DEFAULT_SLOW_TIMEOUT = 120_000;
24
26
  // Sensor JSON output can be several MB on large repos (e.g. `eslint --format json`
25
- // with thousands of findings). execSync defaults to a 1MB buffer and kills the
26
- // child with SIGTERM when exceeded — which previously surfaced as a false "timeout".
27
+ // with thousands of findings). A 1MB cap killed the child with SIGTERM when
28
+ // exceeded — which previously surfaced as a false "timeout".
27
29
  const MAX_BUFFER = 64 * 1024 * 1024;
30
+ /** Hard ceiling on parallel sensors: past this, they only contend for the same cores. */
31
+ const MAX_CONCURRENCY = 4;
28
32
  /**
29
33
  * Apply the baseline to a sensor result: keep only findings not already accepted.
30
34
  * `status` becomes 'pass' when every finding was baseline-suppressed. Results
@@ -125,75 +129,117 @@ function getFormatter(name) {
125
129
  function isExitCodeSensor(name) {
126
130
  return name === 'test';
127
131
  }
128
- function runSensor(name, cmd, timeout, cwd) {
129
- try {
130
- const raw = (0, child_process_1.execSync)(cmd, { encoding: 'utf-8', timeout, cwd, maxBuffer: MAX_BUFFER, stdio: ['pipe', 'pipe', 'pipe'] });
131
- const errors = getFormatter(name)(raw);
132
- return { name, status: errors.length > 0 ? 'fail' : 'pass', errors };
132
+ async function runSensor(name, cmd, timeout, cwd) {
133
+ const res = await (0, exec_1.runCommand)(cmd, { timeout, cwd, maxBuffer: MAX_BUFFER });
134
+ const format = getFormatter(name);
135
+ // The shell itself never started (bad cwd, no shell). Nothing ran.
136
+ if (res.spawnError) {
137
+ return {
138
+ name,
139
+ status: 'fail',
140
+ errors: [{ message: `sensor could not be started: ${res.spawnError.message}` }],
141
+ };
133
142
  }
134
- catch (err) {
135
- // Output exceeded maxBuffer child is killed before output can be read.
136
- // Check this BEFORE the SIGTERM branch (ENOBUFS kills with SIGTERM too).
137
- // Nothing could be read, so nothing was certified.
138
- if (err.code === 'ENOBUFS') {
139
- return { name, status: 'inconclusive', errors: [], skipReason: `output exceeded ${MAX_BUFFER} bytes` };
140
- }
141
- // Genuine timeout: execSync kills with SIGTERM after `timeout` ms. The
142
- // sensor produced no verdict — inconclusive, not a benign skip.
143
- if (err.code === 'ETIMEDOUT' || (err.killed && err.signal === 'SIGTERM')) {
144
- return { name, status: 'inconclusive', errors: [], skipReason: `timeout after ${timeout}ms` };
145
- }
146
- // Non-zero exit — the normal path for linters/typecheckers that found
147
- // findings. Parse the output; if it yields findings, that's a fail.
148
- const raw = String((err.stdout ?? '') + (err.stderr ?? ''));
149
- const errors = getFormatter(name)(raw);
150
- if (errors.length > 0)
151
- return { name, status: 'fail', errors };
152
- // A missing tool (binary not installed) must NOT pass silently — the gate
153
- // cannot certify what it could not run. Treat it as a fail with a clear message.
154
- //
155
- // Exit 127 is the POSIX signal for "command not found" and is the only check
156
- // here that holds across shells and locales: bash writes `command not found`
157
- // but dash — `/bin/sh` on Debian/Ubuntu, hence most CI runners and containers
158
- // — writes `not found`, so matching shell text alone read an absent tool as a
159
- // benign skip. `err.code` does not cover it either: that is ENOENT only when
160
- // spawning the shell itself fails, not when the shell starts and the command
161
- // inside it is missing. The ENOBUFS and timeout branches are evaluated above,
162
- // so reaching here with status 127 means the command did not exist.
163
- //
164
- // A wrapper (`npm test`, `npx …`) that exits 127 because a binary it invokes
165
- // is absent is classified the same way, deliberately: the gate still ran
166
- // nothing and still cannot certify anything.
167
- const lower = raw.toLowerCase();
168
- const toolMissing = err.status === 127 || // POSIX: command not found
169
- err.code === 'ENOENT' || // execSync spawn failure (no shell)
170
- lower.includes('command not found') || // bash, zsh
171
- // cmd.exe reports an absent binary with exit 1, so 127 does not cover
172
- // Windows; this exact phrase does. Kept narrow on purpose — a loose
173
- // `not found` would also match a tool that ran and said "not found"
174
- // for reasons of its own.
175
- lower.includes('is not recognized as an internal or external command') ||
176
- lower.includes('enoent') ||
177
- lower.includes('could not determine executable');
178
- if (toolMissing) {
143
+ // Cut short by the deadline or the output cap. The run is NOT a verdict — but
144
+ // whatever it printed before being cut is still evidence, and throwing it away
145
+ // is what forced the caller to re-run the same command by hand to learn
146
+ // anything. Findings in the partial output are real findings; their absence
147
+ // proves nothing, so a clean partial can never be `pass`.
148
+ if (res.timedOut || res.overflowed) {
149
+ const reason = res.timedOut
150
+ ? `timeout after ${timeout}ms`
151
+ : `output exceeded ${MAX_BUFFER} bytes`;
152
+ const errors = format(res.stdout + res.stderr);
153
+ if (errors.length > 0) {
179
154
  return {
180
155
  name,
181
156
  status: 'fail',
182
- errors: [{ message: `sensor tool not available: ${raw.slice(0, 200)}` }],
157
+ errors,
158
+ incomplete: `${reason} — findings below are from partial output; the run did not finish`,
183
159
  };
184
160
  }
185
- // Exit-code sensors (tests): any genuine non-zero exit is a real failure,
186
- // even when no per-line findings can be parsed from the output.
187
- if (isExitCodeSensor(name)) {
188
- return { name, status: 'fail', errors: [{ message: `SENSOR[${name}] failed (exit ${err.status})` }] };
189
- }
190
- // Residual case: it exited non-zero, the tool exists, and no finding
191
- // could be parsed. We do not know what happened — say so instead of
192
- // reporting a benign skip.
193
- return { name, status: 'inconclusive', errors: [], skipReason: `exit ${err.status}: ${raw.slice(0, 200)}` };
161
+ return { name, status: 'inconclusive', errors: [], skipReason: reason };
162
+ }
163
+ if (res.code === 0) {
164
+ const errors = format(res.stdout);
165
+ return { name, status: errors.length > 0 ? 'fail' : 'pass', errors };
166
+ }
167
+ // Non-zero exit the normal path for linters/typecheckers that found
168
+ // findings. Parse the output; if it yields findings, that's a fail.
169
+ const raw = res.stdout + res.stderr;
170
+ const errors = format(raw);
171
+ if (errors.length > 0)
172
+ return { name, status: 'fail', errors };
173
+ // A missing tool (binary not installed) must NOT pass silently — the gate
174
+ // cannot certify what it could not run. Treat it as a fail with a clear message.
175
+ //
176
+ // Exit 127 is the POSIX signal for "command not found" and is the only check
177
+ // here that holds across shells and locales: bash writes `command not found`
178
+ // but dash — `/bin/sh` on Debian/Ubuntu, hence most CI runners and containers
179
+ // — writes `not found`, so matching shell text alone read an absent tool as a
180
+ // benign skip. A failure to spawn the shell itself is a different thing and
181
+ // is handled above via `spawnError`. The cut-short branches are evaluated
182
+ // above too, so reaching here with status 127 means the command did not exist.
183
+ //
184
+ // A wrapper (`npm test`, `npx …`) that exits 127 because a binary it invokes
185
+ // is absent is classified the same way, deliberately: the gate still ran
186
+ // nothing and still cannot certify anything.
187
+ const lower = raw.toLowerCase();
188
+ const toolMissing = res.code === 127 || // POSIX: command not found
189
+ lower.includes('command not found') || // bash, zsh
190
+ // cmd.exe reports an absent binary with exit 1, so 127 does not cover
191
+ // Windows; this exact phrase does. Kept narrow on purpose — a loose
192
+ // `not found` would also match a tool that ran and said "not found"
193
+ // for reasons of its own.
194
+ lower.includes('is not recognized as an internal or external command') ||
195
+ lower.includes('enoent') ||
196
+ lower.includes('could not determine executable');
197
+ if (toolMissing) {
198
+ return {
199
+ name,
200
+ status: 'fail',
201
+ errors: [{ message: `sensor tool not available: ${raw.slice(0, 200)}` }],
202
+ };
194
203
  }
204
+ // Exit-code sensors (tests): any genuine non-zero exit is a real failure,
205
+ // even when no per-line findings can be parsed from the output.
206
+ if (isExitCodeSensor(name)) {
207
+ return { name, status: 'fail', errors: [{ message: `SENSOR[${name}] failed (exit ${res.code})` }] };
208
+ }
209
+ // Residual case: it exited non-zero, the tool exists, and no finding
210
+ // could be parsed. We do not know what happened — say so instead of
211
+ // reporting a benign skip.
212
+ return { name, status: 'inconclusive', errors: [], skipReason: `exit ${res.code}: ${raw.slice(0, 200)}` };
213
+ }
214
+ /**
215
+ * How many sensors may run at once. Sensors are separate processes over the same
216
+ * tree, so they parallelise cleanly — but each one (tsc, eslint, depcruise) is
217
+ * largely single-threaded, and oversubscribing the box just makes every sensor
218
+ * slower and more likely to hit its own deadline. Leave a core for the agent.
219
+ */
220
+ function resolveConcurrency(manifest, sensorCount) {
221
+ const configured = Number(process.env.AWM_SENSORS_CONCURRENCY ?? manifest.concurrency);
222
+ if (Number.isFinite(configured) && configured >= 1)
223
+ return Math.min(Math.floor(configured), sensorCount);
224
+ const cores = os_1.default.cpus()?.length ?? 2;
225
+ return Math.max(1, Math.min(MAX_CONCURRENCY, cores - 1, sensorCount));
226
+ }
227
+ /** Run `tasks` with at most `limit` in flight, preserving input order in the output. */
228
+ async function pooled(tasks, limit) {
229
+ const results = new Array(tasks.length);
230
+ let next = 0;
231
+ const worker = async () => {
232
+ while (true) {
233
+ const i = next++;
234
+ if (i >= tasks.length)
235
+ return;
236
+ results[i] = await tasks[i]();
237
+ }
238
+ };
239
+ await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
240
+ return results;
195
241
  }
196
- function runSensors(opts = {}) {
242
+ async function runSensors(opts = {}) {
197
243
  const startCwd = opts.cwd ?? process.cwd();
198
244
  const manifestDir = findManifestDir(startCwd);
199
245
  if (!manifestDir)
@@ -204,31 +250,38 @@ function runSensors(opts = {}) {
204
250
  const reconciled = reconcilePack(manifestDir, manifest);
205
251
  const activeManifest = reconciled.manifest;
206
252
  const cwd = manifestDir; // ejecutar sensores y baseline desde donde vive el manifest
207
- const results = [];
208
253
  // Baseline suppresses already-accepted findings so sensors fail only on NEW
209
254
  // ones (essential on repos with a large pre-existing baseline). Absent file or
210
255
  // --ignore-baseline → every finding counts (backward-compatible).
211
256
  const baseline = opts.ignoreBaseline ? null : (0, baseline_1.readBaseline)(cwd);
257
+ // Sensors are independent processes over the same tree, so they run
258
+ // concurrently rather than one-after-another: wall clock becomes the slowest
259
+ // sensor instead of the sum of all of them. Tasks are built — and dispatched —
260
+ // in manifest order, so the reported order stays stable.
261
+ const tasks = [];
262
+ const settled = (r) => () => Promise.resolve(r);
212
263
  for (const [name, config] of Object.entries(activeManifest.sensors)) {
213
264
  const isFast = config.fast ?? false;
214
265
  if (!shouldRun(isFast, opts))
215
266
  continue;
216
267
  if (config.enabled === false) {
217
- results.push({ name, status: 'skipped', errors: [], skipReason: 'disabled' });
268
+ tasks.push(settled({ name, status: 'skipped', errors: [], skipReason: 'disabled' }));
218
269
  continue;
219
270
  }
220
271
  if (!config.cmd) {
221
272
  // Enabled but with nothing to run: broken config, not a deliberate
222
273
  // opt-out. `enabled: false` is how a sensor is turned off.
223
- results.push({ name, status: 'inconclusive', errors: [], skipReason: 'no cmd configured' });
274
+ tasks.push(settled({ name, status: 'inconclusive', errors: [], skipReason: 'no cmd configured' }));
224
275
  continue;
225
276
  }
277
+ const cmd = config.cmd;
226
278
  const timeout = config.timeout ?? (isFast ? DEFAULT_FAST_TIMEOUT : DEFAULT_SLOW_TIMEOUT);
227
- let result = runSensor(name, config.cmd, timeout, cwd);
228
- if (baseline)
229
- result = applyBaseline(result, baseline[name]);
230
- results.push(result);
279
+ tasks.push(async () => {
280
+ const result = await runSensor(name, cmd, timeout, cwd);
281
+ return baseline ? applyBaseline(result, baseline[name]) : result;
282
+ });
231
283
  }
284
+ const results = await pooled(tasks, resolveConcurrency(activeManifest, tasks.length));
232
285
  // `fail` outranks `inconclusive`: when something is broken AND something
233
286
  // could not be measured, the broken thing is the actionable verdict.
234
287
  let overall = results.some(r => r.status === 'fail') ? 'fail'