agentic-workflow-manager 3.13.0 → 3.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/doctor.js +8 -0
- package/dist/src/commands/hooks/shared.js +14 -1
- package/dist/src/commands/init.js +5 -1
- package/dist/src/commands/registry/add.js +10 -1
- package/dist/src/commands/sync.js +4 -0
- package/dist/src/commands/update.js +4 -0
- package/dist/src/core/atomic-file.js +24 -2
- package/dist/src/core/executor.js +40 -1
- package/dist/src/core/install-transaction.js +13 -1
- package/dist/src/core/journal/process.js +241 -9
- package/dist/src/core/paths.js +28 -10
- package/dist/src/index.js +0 -3
- package/dist/tests/commands/doctor-platform.test.js +48 -4
- package/dist/tests/commands/doctor.test.js +19 -0
- package/dist/tests/commands/hooks/install-symlink-fallback.test.js +25 -0
- package/dist/tests/commands/hooks/status.test.js +23 -3
- package/dist/tests/commands/init.test.js +39 -0
- package/dist/tests/commands/job/exec-wrapper.test.js +15 -1
- package/dist/tests/commands/job/gate-reconcile.test.js +31 -5
- package/dist/tests/commands/multi-agent-targeting.test.js +65 -0
- package/dist/tests/commands/preflight/preflight.test.js +4 -1
- package/dist/tests/commands/registry/add.test.js +27 -0
- package/dist/tests/commands/sensors/changed.test.js +13 -0
- package/dist/tests/commands/sensors/exec-windows.test.js +13 -0
- package/dist/tests/commands/sensors/exec.test.js +28 -6
- package/dist/tests/commands/sensors/formatters/ruff.test.js +22 -6
- package/dist/tests/commands/sensors/run-changed.test.js +33 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +54 -18
- package/dist/tests/commands/watch/runner.test.js +32 -2
- package/dist/tests/commands/watch/supervisor-loop.test.js +24 -3
- package/dist/tests/core/artifact-state.test.js +11 -1
- package/dist/tests/core/atomic-file-durable.test.js +48 -1
- package/dist/tests/core/atomic-file.test.js +14 -3
- package/dist/tests/core/executor.test.js +58 -0
- package/dist/tests/core/install-transaction.test.js +59 -2
- package/dist/tests/core/journal/adapter.test.js +54 -0
- package/dist/tests/core/journal/fingerprint.test.js +10 -2
- package/dist/tests/core/journal/process.test.js +208 -9
- package/dist/tests/core/journal/store.test.js +8 -2
- package/dist/tests/core/no-color.test.js +23 -0
- package/dist/tests/core/paths.test.js +32 -5
- package/dist/tests/core/registries-sync.test.js +32 -3
- package/package.json +1 -1
|
@@ -99,6 +99,14 @@ function providerCheckLine(check) {
|
|
|
99
99
|
function renderProviderReport(report) {
|
|
100
100
|
const lines = [];
|
|
101
101
|
lines.push(picocolors_1.default.bold('AWM · harness status'));
|
|
102
|
+
lines.push(picocolors_1.default.dim(` platform: ${(0, paths_1.platformLabel)()}`));
|
|
103
|
+
// Advisory only, native Windows only. This is the text rendered by the
|
|
104
|
+
// real `awm doctor` command (`runDoctor` below) — the diagnostics surface
|
|
105
|
+
// an operator actually goes looking at for platform-specific detail.
|
|
106
|
+
// `init`/`update`/`sync` also note it once each, at the top of their own
|
|
107
|
+
// run (`runInit`/`runUpdateCore`/`runSyncCore`), independently of this.
|
|
108
|
+
if ((0, paths_1.isWindowsNative)())
|
|
109
|
+
lines.push(picocolors_1.default.dim(` ${paths_1.WINDOWS_KNOWN_GAP.replace(/\n\s*/g, ' ')}`));
|
|
102
110
|
lines.push('');
|
|
103
111
|
for (const provider of report.providers) {
|
|
104
112
|
lines.push(`Provider: ${provider.label} ${picocolors_1.default.dim(`(${provider.tier})`)}`);
|
|
@@ -29,7 +29,20 @@ function syncExecutable(source, dest, method) {
|
|
|
29
29
|
catch { /* not exists, fine */ }
|
|
30
30
|
fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
|
|
31
31
|
if (method === 'symlink') {
|
|
32
|
-
|
|
32
|
+
try {
|
|
33
|
+
fs_1.default.symlinkSync(source, dest);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// best-effort: a FILE symlink needs SeCreateSymbolicLinkPrivilege on
|
|
37
|
+
// Windows, denied by default on unprivileged accounts (incl. GitHub
|
|
38
|
+
// Actions' windows-latest runner) — fall back to a plain copy, same
|
|
39
|
+
// as the bootstrap skill file's own fallback (hooks/claude.ts) and
|
|
40
|
+
// executor.ts's stageArtifact for file artifacts. 'awm update' will
|
|
41
|
+
// not auto-propagate for this file until re-synced, same tradeoff.
|
|
42
|
+
fs_1.default.copyFileSync(source, dest);
|
|
43
|
+
const srcMode = fs_1.default.statSync(source).mode;
|
|
44
|
+
fs_1.default.chmodSync(dest, srcMode);
|
|
45
|
+
}
|
|
33
46
|
}
|
|
34
47
|
else {
|
|
35
48
|
fs_1.default.copyFileSync(source, dest);
|
|
@@ -125,6 +125,11 @@ function reportInitFailure(o) {
|
|
|
125
125
|
return 2;
|
|
126
126
|
}
|
|
127
127
|
async function runInit(opts = {}) {
|
|
128
|
+
// Fires at most once per `awm init` run (native Windows only) — this is
|
|
129
|
+
// the single emission point; `renderReport`/`renderInitOutcome` below do
|
|
130
|
+
// NOT also embed it (that used to triple-fire the same text: once here,
|
|
131
|
+
// once in the "Initial state" render, once in "Final state").
|
|
132
|
+
(0, paths_1.noteWindowsCaveat)((m) => console.log(picocolors_1.default.dim(`ℹ ${m}`)));
|
|
128
133
|
const cwd = opts.cwd ?? process.cwd();
|
|
129
134
|
const agent = opts.agent === undefined ? 'claude-code' : (0, providers_1.requireAgentTarget)(opts.agent);
|
|
130
135
|
// R2: gate BEFORE anything is read or written — an unsupported provider
|
|
@@ -313,7 +318,6 @@ function registerInitCommand(program) {
|
|
|
313
318
|
.option('--machine-only', 'Only run machine-level steps (skip project steps)')
|
|
314
319
|
.option('--json', 'Emit the InitOutcome as JSON — on success and on failure (failed steps + rollback)')
|
|
315
320
|
.action(async (options) => {
|
|
316
|
-
(0, paths_1.warnIfUnsupportedPlatform)((m) => console.warn(picocolors_1.default.yellow(`⚠ ${m}`)));
|
|
317
321
|
const code = await runInit({
|
|
318
322
|
yes: options.yes,
|
|
319
323
|
agent: options.agent,
|
|
@@ -13,7 +13,16 @@ const registries_1 = require("../../core/registries");
|
|
|
13
13
|
const discovery_1 = require("../../core/discovery");
|
|
14
14
|
const bundles_1 = require("../../core/bundles");
|
|
15
15
|
function deriveRegistryName(remote) {
|
|
16
|
-
|
|
16
|
+
// Split on '/', '\' and ':' — not just '/' and ':'. A git remote URL
|
|
17
|
+
// (https://…/repo.git, git@host:org/repo.git) only ever uses the first
|
|
18
|
+
// two, but `remote` here can also be a plain local filesystem path (e.g.
|
|
19
|
+
// a `awm registry add <local-repo>` clone source, or this repo's own
|
|
20
|
+
// tests), and on native Windows that path is backslash-separated
|
|
21
|
+
// (`C:\Users\...\src-alpha`). Splitting on `[/:]` alone leaves the whole
|
|
22
|
+
// backslash-joined tail as one segment (`\Users\...\src-alpha`), which
|
|
23
|
+
// then fails the caller's `/[/\\]/.test(name)` invalid-name check and
|
|
24
|
+
// makes every local-path `addRegistry` call fail on Windows.
|
|
25
|
+
const base = remote.replace(/[/\\]+$/, '').split(/[/\\:]/).pop() ?? '';
|
|
17
26
|
return base.replace(/\.git$/, '');
|
|
18
27
|
}
|
|
19
28
|
async function addRegistry(remote, nameOverride) {
|
|
@@ -16,6 +16,7 @@ exports.runSyncCore = runSyncCore;
|
|
|
16
16
|
// intro/outro chrome lives in `index.ts`'s Commander registration instead
|
|
17
17
|
// (which already imports clack at the top and is never `require()`d by tests).
|
|
18
18
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
19
|
+
const paths_1 = require("../core/paths");
|
|
19
20
|
const profile_1 = require("../core/profile");
|
|
20
21
|
const registries_1 = require("../core/registries");
|
|
21
22
|
const bundles_1 = require("../core/bundles");
|
|
@@ -31,6 +32,9 @@ const defaultDeps = {
|
|
|
31
32
|
};
|
|
32
33
|
/** Core, UI-free `awm sync` logic — see `runSync` for the Commander-facing wrapper. */
|
|
33
34
|
async function runSyncCore(options, deps = {}) {
|
|
35
|
+
// Fires at most once per `awm sync` run, native Windows only — the single
|
|
36
|
+
// emission point for this command (mirrors `runInit`/`runUpdateCore`).
|
|
37
|
+
(0, paths_1.noteWindowsCaveat)((m) => console.log(picocolors_1.default.dim(`ℹ ${m}`)));
|
|
34
38
|
const d = { ...defaultDeps, ...deps };
|
|
35
39
|
const cwd = options.cwd ?? process.cwd();
|
|
36
40
|
const projectRoot = (0, profile_1.findProjectRoot)(cwd);
|
|
@@ -20,6 +20,7 @@ exports.runUpdateCore = runUpdateCore;
|
|
|
20
20
|
// instead (which already imports clack at the top and is never `require()`d
|
|
21
21
|
// by tests).
|
|
22
22
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
23
|
+
const paths_1 = require("../core/paths");
|
|
23
24
|
const config_1 = require("../utils/config");
|
|
24
25
|
const agent_targets_1 = require("../core/agent-targets");
|
|
25
26
|
const registries_1 = require("../core/registries");
|
|
@@ -49,6 +50,9 @@ const defaultDeps = {
|
|
|
49
50
|
* `runInit`'s `actions` seam).
|
|
50
51
|
*/
|
|
51
52
|
async function runUpdateCore(options = {}, deps = {}) {
|
|
53
|
+
// Fires at most once per `awm update` run, native Windows only — the
|
|
54
|
+
// single emission point for this command (mirrors `runInit`/`runSyncCore`).
|
|
55
|
+
(0, paths_1.noteWindowsCaveat)((m) => console.log(picocolors_1.default.dim(`ℹ ${m}`)));
|
|
52
56
|
const d = { ...defaultDeps, ...deps };
|
|
53
57
|
const prefs = (0, config_1.getPreferences)();
|
|
54
58
|
const resolved = (0, agent_targets_1.resolveAgentTargetsOrError)({ prefs, explicit: options.agent });
|
|
@@ -75,12 +75,34 @@ function writeFileAtomic(file, content, mode = 0o644) {
|
|
|
75
75
|
/** fsync del directorio contenedor: garantiza que una ENTRADA creada/renombrada
|
|
76
76
|
* sobrevive un crash del OS. Falla LANZANDO — la durabilidad de la transición
|
|
77
77
|
* es parte del contrato, nunca un best-effort silencioso (design R1.2,
|
|
78
|
-
* bloqueador 4 de la review del plan)
|
|
78
|
+
* bloqueador 4 de la review del plan) — EXCEPTO en Windows, donde fsync-ear un
|
|
79
|
+
* file descriptor de directorio no es una operacion que el SO soporte en
|
|
80
|
+
* absoluto (no es una falla de durabilidad real: no existe el mecanismo
|
|
81
|
+
* POSIX que esta funcion intenta invocar). Confirmado via R6 CI (2026-08-08,
|
|
82
|
+
* primera corrida real en windows-latest): `fs.openSync(dir, 'r')` tiene
|
|
83
|
+
* exito, pero el `fsyncSync` subsiguiente sobre ese fd siempre falla con
|
|
84
|
+
* EPERM — libuv mapea asi el resultado de `FlushFileBuffers` sobre un handle
|
|
85
|
+
* de directorio en Win32, que Windows rechaza categoricamente (no es un
|
|
86
|
+
* fallo intermitente de hardware/permisos, es ausencia de la capacidad).
|
|
87
|
+
* NTFS ya registra los renames/creates en su propio journal transaccional,
|
|
88
|
+
* asi que la garantia de durabilidad de la ENTRADA sigue sostenida por el
|
|
89
|
+
* filesystem — solo el mecanismo explicito para pedirla no existe ahi.
|
|
90
|
+
* Por eso, y solo para esta combinacion exacta (win32 + EPERM en el fsync,
|
|
91
|
+
* nunca en el open), esta funcion degrada a no-op en vez de lanzar; culquier
|
|
92
|
+
* otra plataforma, o cualquier otro código de error incluso en Windows
|
|
93
|
+
* (el open fallando, ENOENT, EACCES por permisos reales), sigue lanzando. */
|
|
79
94
|
function fsyncDirSync(dir) {
|
|
80
95
|
let dirFd;
|
|
81
96
|
try {
|
|
82
97
|
dirFd = fs_1.default.openSync(dir, 'r');
|
|
83
|
-
|
|
98
|
+
try {
|
|
99
|
+
fs_1.default.fsyncSync(dirFd);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (process.platform === 'win32' && error.code === 'EPERM')
|
|
103
|
+
return;
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
84
106
|
}
|
|
85
107
|
catch (error) {
|
|
86
108
|
throw new Error(`fsync de directorio fallo para ${dir}: ${error.message}`);
|
|
@@ -10,6 +10,7 @@ exports.installArtifact = installArtifact;
|
|
|
10
10
|
// src/core/executor.ts
|
|
11
11
|
const fs_1 = __importDefault(require("fs"));
|
|
12
12
|
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const paths_1 = require("./paths");
|
|
13
14
|
function removeArtifact(targetPath) {
|
|
14
15
|
let exists = false;
|
|
15
16
|
try {
|
|
@@ -39,7 +40,45 @@ function stageArtifact(sourcePath, targetPath, method) {
|
|
|
39
40
|
const staged = path_1.default.join(parent, `.${path_1.default.basename(targetPath)}.${process.pid}.staged`);
|
|
40
41
|
fs_1.default.rmSync(staged, { recursive: true, force: true });
|
|
41
42
|
if (method === 'symlink') {
|
|
42
|
-
fs_1.default.
|
|
43
|
+
const sourceIsDirectory = fs_1.default.statSync(sourcePath).isDirectory();
|
|
44
|
+
if (sourceIsDirectory) {
|
|
45
|
+
// A directory *symlink* ('dir') needs SeCreateSymbolicLinkPrivilege on
|
|
46
|
+
// Windows — denied by default on unprivileged accounts, including
|
|
47
|
+
// GitHub Actions' windows-latest runner, so every install here would
|
|
48
|
+
// throw EPERM. A *junction* is a different NTFS reparse-point kind
|
|
49
|
+
// that Windows lets any account create, and Node/libuv report it the
|
|
50
|
+
// same way a symlink is reported (`lstat().isSymbolicLink()` is true,
|
|
51
|
+
// `readlinkSync()` resolves it) — so every downstream consumer
|
|
52
|
+
// (verify(), R19's provider-facts hashing, doctor's symlink checks)
|
|
53
|
+
// keeps working unmodified. Junctions require an absolute target,
|
|
54
|
+
// which `sourcePath` always is here (registry content roots are
|
|
55
|
+
// resolved under `awmHome()`). POSIX platforms are unaffected: 'dir'
|
|
56
|
+
// is a plain no-op hint there.
|
|
57
|
+
fs_1.default.symlinkSync(sourcePath, staged, (0, paths_1.isWindowsNative)() ? 'junction' : 'dir');
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// A junction is an NTFS *directory* reparse point — it has no
|
|
61
|
+
// equivalent for an individual FILE artifact (agent .md, workflow
|
|
62
|
+
// .md, ...), so a file source must never be passed to it (it was,
|
|
63
|
+
// before this branch existed, and that silently produced a
|
|
64
|
+
// reparse point that could not resolve back to the file — see
|
|
65
|
+
// tests/core/bundle-install.test.ts's "claude-code agents" case).
|
|
66
|
+
// A 'file'-type symlink is the correct primitive here, but it
|
|
67
|
+
// needs the same SeCreateSymbolicLinkPrivilege a directory symlink
|
|
68
|
+
// does, and Windows has no privilege-free substitute for files the
|
|
69
|
+
// way junctions are for directories. So: attempt the real symlink
|
|
70
|
+
// (works, and keeps `awm update` propagation, whenever the
|
|
71
|
+
// privilege IS available — e.g. Developer Mode) and fall back to a
|
|
72
|
+
// plain copy otherwise, mirroring the established fallback already
|
|
73
|
+
// used for the bootstrap skill file (hooks/claude.ts,
|
|
74
|
+
// tests/commands/hooks/install-symlink-fallback.test.ts).
|
|
75
|
+
try {
|
|
76
|
+
fs_1.default.symlinkSync(sourcePath, staged, 'file');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
fs_1.default.copyFileSync(sourcePath, staged);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
43
82
|
}
|
|
44
83
|
else {
|
|
45
84
|
fs_1.default.cpSync(sourcePath, staged, { recursive: true });
|
|
@@ -339,7 +339,19 @@ function defaultTransactionDeps() {
|
|
|
339
339
|
return;
|
|
340
340
|
}
|
|
341
341
|
if (op.method === 'symlink' && !stat.isSymbolicLink()) {
|
|
342
|
-
|
|
342
|
+
// executor.ts's stageArtifact falls back to a plain copy for a
|
|
343
|
+
// FILE source when the real 'file'-type symlink throws (no
|
|
344
|
+
// privilege-free equivalent to a directory junction exists for
|
|
345
|
+
// individual files on Windows) — accept that fallback here
|
|
346
|
+
// rather than failing verification for an install that landed
|
|
347
|
+
// correctly, just not as a symlink. A directory source always
|
|
348
|
+
// gets a privilege-free junction and should never legitimately
|
|
349
|
+
// reach this branch, so this stays scoped to files only.
|
|
350
|
+
const sourceIsDirectory = fs_1.default.existsSync(op.sourcePath) && fs_1.default.statSync(op.sourcePath).isDirectory();
|
|
351
|
+
const acceptableFileFallback = !sourceIsDirectory && stat.isFile();
|
|
352
|
+
if (!acceptableFileFallback) {
|
|
353
|
+
throw new Error(`verification failed: ${op.targetPath} is not a symlink`);
|
|
354
|
+
}
|
|
343
355
|
}
|
|
344
356
|
if (op.method === 'copy' && stat.isSymbolicLink()) {
|
|
345
357
|
throw new Error(`verification failed: ${op.targetPath} is unexpectedly a symlink`);
|
|
@@ -17,6 +17,7 @@ exports.terminateGroupConfirmed = terminateGroupConfirmed;
|
|
|
17
17
|
exports.terminatePreviouslyOwnedGroup = terminatePreviouslyOwnedGroup;
|
|
18
18
|
const crypto_1 = __importDefault(require("crypto"));
|
|
19
19
|
const child_process_1 = require("child_process");
|
|
20
|
+
const paths_1 = require("../paths");
|
|
20
21
|
exports.NONCE_ENV = 'AWM_SPAWN_NONCE';
|
|
21
22
|
/** Contrato dual, a proposito (tomo 3 rondas de fixes reales llegar aca —
|
|
22
23
|
* ver historial de Task 10): devuelve `null` SOLO cuando `ps` corrio y
|
|
@@ -42,23 +43,90 @@ exports.NONCE_ENV = 'AWM_SPAWN_NONCE';
|
|
|
42
43
|
* ese relay jamas ocurre: `execFileSync` captura el stderr del subproceso
|
|
43
44
|
* internamente y listo, sin tocar el fd real del proceso actual. */
|
|
44
45
|
exports.EXEC_STDIO = ['ignore', 'pipe', 'pipe'];
|
|
46
|
+
/** timeout explicito (defense-in-depth, mismo patron que win32ProcessInfo):
|
|
47
|
+
* sin esto, un `ps`/`pgrep` que cuelga (recurso bloqueado, binario
|
|
48
|
+
* emulado con comportamiento anomalo) cuelga TODO el proceso llamante
|
|
49
|
+
* indefinidamente — sin excepcion que atrapar, sin manera de fallar a
|
|
50
|
+
* favor de "vivo" porque el codigo nunca vuelve a ejecutar. Un timeout
|
|
51
|
+
* convierte ese cuelgue en un error normal, que el contrato existente de
|
|
52
|
+
* cada caller ya sabe manejar (nunca declarar muerte por un error). */
|
|
53
|
+
const PS_TIMEOUT_MS = 3000;
|
|
45
54
|
function psField(pid, field) {
|
|
46
55
|
try {
|
|
47
|
-
const out = (0, child_process_1.execFileSync)('ps', ['-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO }).trim();
|
|
56
|
+
const out = (0, child_process_1.execFileSync)('ps', ['-o', `${field}=`, '-p', String(pid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS }).trim();
|
|
48
57
|
return out.length > 0 ? out : null;
|
|
49
58
|
}
|
|
50
59
|
catch (error) {
|
|
51
60
|
const status = error.status;
|
|
52
61
|
if (status === 1)
|
|
53
62
|
return null; // ps corrio y confirmo: el pid no existe
|
|
54
|
-
throw error; // ps no pudo ejecutarse (ENOENT/permisos/etc): NO es prueba de nada
|
|
63
|
+
throw error; // ps no pudo ejecutarse (ENOENT/permisos/timeout/etc): NO es prueba de nada
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
function sleepSync(seconds) {
|
|
58
67
|
try {
|
|
59
|
-
(0, child_process_1.execFileSync)('sleep', [seconds], { stdio: exports.EXEC_STDIO });
|
|
68
|
+
(0, child_process_1.execFileSync)('sleep', [seconds], { stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS });
|
|
60
69
|
}
|
|
61
70
|
catch { /* sin sleep: seguimos */ }
|
|
71
|
+
// Nota win32: `sleep` no existe nativamente ahi (ver callers via
|
|
72
|
+
// win32ProcessInfo/captureRefFor) — el catch de arriba lo absorbe
|
|
73
|
+
// silenciosamente, asi que en esa plataforma los reintentos que llaman
|
|
74
|
+
// a esta funcion ocurren espalda-con-espalda sin pausa real. No es un
|
|
75
|
+
// fallo: solo pierde el espaciado, nunca crashea ni miente sobre
|
|
76
|
+
// resultado alguno.
|
|
77
|
+
}
|
|
78
|
+
/** Analogo win32 de `psField`/`stablePsArgs` (R2.1, R6): identidad real de un
|
|
79
|
+
* pid via WMI (clase Win32_Process), consultada a traves de PowerShell's
|
|
80
|
+
* Get-CimInstance — el reemplazo moderno soportado de `wmic` (que Microsoft
|
|
81
|
+
* viene retirando de instalaciones nuevas), y presente en cualquier Windows
|
|
82
|
+
* no deliberadamente reducido (a diferencia de `ps`/`pgrep`, que en Windows
|
|
83
|
+
* SOLO existen si algo como Git for Windows los puso en el PATH, y ahi son
|
|
84
|
+
* la capa emulada de MSYS/Cygwin ciega a procesos nativos — ver
|
|
85
|
+
* pidExistsNative). Contrato de TRES vias, deliberado, paralelo al de
|
|
86
|
+
* `psField`:
|
|
87
|
+
* - `'absent'`: PowerShell corrio bien y WMI no encontro NINGUN proceso
|
|
88
|
+
* con ese pid — evidencia POSITIVA de ausencia (el analogo exacto del
|
|
89
|
+
* exit-1 de `ps`/`pgrep` sobre un pid real), independiente de
|
|
90
|
+
* `process.kill` (que pidExistsNative usa) — asi que sigue siendo
|
|
91
|
+
* evidencia valida incluso si `process.kill` fuera mockeado/erroneo en
|
|
92
|
+
* algun caller (visto en CI real: un test que mockea process.kill para
|
|
93
|
+
* verificar que executeReap NUNCA senializa, sin intencion de simular
|
|
94
|
+
* un pid vivo — WMI no comparte ese mock y corrige la vista).
|
|
95
|
+
* - `null`: PowerShell no pudo ejecutarse, timeout, politica de ejecucion
|
|
96
|
+
* restrictiva, WMI/CIM deshabilitado, salida no parseable, etc. — CERO
|
|
97
|
+
* evidencia, ni de vida ni de muerte. Igual que el ENOENT de `ps` en
|
|
98
|
+
* POSIX: nunca se traduce a "muerto".
|
|
99
|
+
* - objeto con `creationDate`/`commandLine` reales: exito, comparables
|
|
100
|
+
* con `ref.startTime`/`ref.psArgsDigest` (via identityDigest) igual que
|
|
101
|
+
* `lstart`/`args` en la rama POSIX.
|
|
102
|
+
* Acotado con `timeout` (nunca cuelga al caller indefinidamente si el
|
|
103
|
+
* proveedor WMI no responde) y con `-NoProfile -NonInteractive -NoLogo`
|
|
104
|
+
* (arranque mas rapido y determinista, sin depender de perfiles del
|
|
105
|
+
* usuario). Solo se invoca desde refIsAlive/captureRefFor en win32, jamas
|
|
106
|
+
* desde un path "barato" (groupIsGone, pidExistsNative) — ver refIsAlive
|
|
107
|
+
* para el razonamiento de costo/beneficio. */
|
|
108
|
+
function win32ProcessInfo(pid) {
|
|
109
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
110
|
+
return null;
|
|
111
|
+
let out;
|
|
112
|
+
try {
|
|
113
|
+
const script = `try { $p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction Stop; if ($p) { [PSCustomObject]@{ CreationDate = $p.CreationDate.ToString('o'); CommandLine = [string]$p.CommandLine } | ConvertTo-Json -Compress } } catch { exit 1 }`;
|
|
114
|
+
out = (0, child_process_1.execFileSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-NoLogo', '-Command', script], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: 2000 }).trim();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null; // powershell/WMI no disponible, timeout, o error interno: sin evidencia (nunca "ausente")
|
|
118
|
+
}
|
|
119
|
+
if (out.length === 0)
|
|
120
|
+
return 'absent'; // corrio bien, cero coincidencias: evidencia positiva
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(out);
|
|
123
|
+
if (typeof parsed.CreationDate !== 'string' || typeof parsed.CommandLine !== 'string')
|
|
124
|
+
return null;
|
|
125
|
+
return { creationDate: parsed.CreationDate, commandLine: parsed.CommandLine };
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
} // salida inesperada: inconclusive, jamas "ausente" por un parseo roto
|
|
62
130
|
}
|
|
63
131
|
/** Variante de psField para contextos de CAPTURA de identidad (spawn time):
|
|
64
132
|
* aqui "no se pudo determinar" ya tiene un fallback seguro documentado
|
|
@@ -107,8 +175,34 @@ function psArgsDigestOf(pid, spawnNonce = '', requestedArgvDigest = '') {
|
|
|
107
175
|
return identityDigest(args, spawnNonce, requestedArgvDigest);
|
|
108
176
|
}
|
|
109
177
|
/** Captura la identidad COMPLETA de un pid recien spawneado (R2.1):
|
|
110
|
-
* startTime + pgid reales de ps + digest de `ps -o args=` estable.
|
|
178
|
+
* startTime + pgid reales de ps + digest de `ps -o args=` estable.
|
|
179
|
+
*
|
|
180
|
+
* win32: sin pgid POSIX real jamas (convencion fija: processGroup === pid,
|
|
181
|
+
* la misma que asume killTreeWindows/groupIsGone); startTime/psArgsDigest
|
|
182
|
+
* se intentan via WMI (win32ProcessInfo) con el mismo reintento acotado que
|
|
183
|
+
* la rama POSIX. Si WMI no responde (politica restrictiva, servicio
|
|
184
|
+
* deshabilitado, sin powershell.exe) degrada al mismo sentinel 'unknown'
|
|
185
|
+
* ya documentado y testeado (ver 'captureRefFor degrada a unknown...') —
|
|
186
|
+
* jamas crashea, jamas fabrica una identidad falsa. */
|
|
111
187
|
function captureRefFor(pid, nonce, argv) {
|
|
188
|
+
const requestedArgvDigest = argvDigest(argv);
|
|
189
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
190
|
+
let info = null;
|
|
191
|
+
for (let i = 0; i < 5 && (info === null || info === 'absent'); i++) {
|
|
192
|
+
info = win32ProcessInfo(pid);
|
|
193
|
+
if (info === null || info === 'absent')
|
|
194
|
+
sleepSync('0.05');
|
|
195
|
+
}
|
|
196
|
+
const resolved = info !== null && info !== 'absent' ? info : null;
|
|
197
|
+
return {
|
|
198
|
+
pid,
|
|
199
|
+
startTime: resolved?.creationDate ?? 'unknown',
|
|
200
|
+
spawnNonce: nonce,
|
|
201
|
+
argvDigest: requestedArgvDigest,
|
|
202
|
+
processGroup: pid,
|
|
203
|
+
psArgsDigest: resolved !== null ? identityDigest(resolved.commandLine, nonce, requestedArgvDigest) : 'unknown',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
112
206
|
let start = null;
|
|
113
207
|
for (let i = 0; i < 5 && start === null; i++) {
|
|
114
208
|
start = psFieldSafe(pid, 'lstart');
|
|
@@ -117,7 +211,6 @@ function captureRefFor(pid, nonce, argv) {
|
|
|
117
211
|
}
|
|
118
212
|
const pgid = psFieldSafe(pid, 'pgid');
|
|
119
213
|
const args = stablePsArgs(pid);
|
|
120
|
-
const requestedArgvDigest = argvDigest(argv);
|
|
121
214
|
return {
|
|
122
215
|
pid,
|
|
123
216
|
startTime: start ?? 'unknown',
|
|
@@ -138,7 +231,25 @@ function captureSelfRef(nonce) {
|
|
|
138
231
|
function spawnStructured(argv, cwd, nonce, extraEnv = {}) {
|
|
139
232
|
const [exe, ...args] = argv;
|
|
140
233
|
const child = (0, child_process_1.spawn)(exe, args, {
|
|
141
|
-
cwd, shell: false,
|
|
234
|
+
cwd, shell: false,
|
|
235
|
+
// R6 ronda 4 probo `detached: true` incondicional en win32 (razonando
|
|
236
|
+
// desde la doc de Node sobre supervivencia post-muerte del padre) y
|
|
237
|
+
// la ronda 5 lo REVIERTE: la primera corrida real en windows-latest
|
|
238
|
+
// mostro `refIsAlive(ref)` devolviendo `false` INMEDIATAMENTE despues
|
|
239
|
+
// de un spawn normal, sin matar nada — el test mas basico del
|
|
240
|
+
// archivo ('spawnStructured produce ProcessRef con tupla completa').
|
|
241
|
+
// `captureRefFor` fija `processGroup: pid` por convencion en win32
|
|
242
|
+
// (siempre), asi que ese `false` solo puede venir de
|
|
243
|
+
// `pidExistsNative` viendo ESRCH real — el proceso hijo aparentaba
|
|
244
|
+
// no existir casi de inmediato. Evidencia real > lectura de
|
|
245
|
+
// documentacion: se revierte a `!isWindowsNative()` (el diseño ya
|
|
246
|
+
// probado, correcto, en 4 rondas previas de CI real). La garantia de
|
|
247
|
+
// R1.8 ("el wrapper sobrevive al supervisor") queda como gap
|
|
248
|
+
// ABIERTO en win32 — ver el test skippeado en
|
|
249
|
+
// tests/commands/watch/e2e-crash.test.ts para el detalle y el
|
|
250
|
+
// proximo intento debe verificarse contra CI real antes de asumir
|
|
251
|
+
// que un cambio de `detached` lo resuelve.
|
|
252
|
+
detached: !(0, paths_1.isWindowsNative)(),
|
|
142
253
|
env: { ...process.env, [exports.NONCE_ENV]: nonce, ...extraEnv },
|
|
143
254
|
// stdio:'ignore' completo (nada de pipes): un pipe destruido/abandonado
|
|
144
255
|
// por el padre puede EPIPE-crashear al hijo si este escribe a su propio
|
|
@@ -150,9 +261,77 @@ function spawnStructured(argv, cwd, nonce, extraEnv = {}) {
|
|
|
150
261
|
throw new Error(`spawn fallo para ${exe}`);
|
|
151
262
|
return { child, ref: captureRefFor(child.pid, nonce, argv) };
|
|
152
263
|
}
|
|
264
|
+
/** Existencia de pid respaldada DIRECTAMENTE por el kernel via libuv
|
|
265
|
+
* (process.kill(pid,0) — funciona en cualquier plataforma que Node
|
|
266
|
+
* soporte, incluido win32), a diferencia de `ps`/`pgrep`: en Windows esos
|
|
267
|
+
* binarios, cuando resuelven en el PATH, son el `ps`/`pgrep` de
|
|
268
|
+
* MSYS/Cygwin (Git for Windows) — una capa de EMULACION con su propia
|
|
269
|
+
* tabla de pids, ciega a procesos nativos spawneados via CreateProcess
|
|
270
|
+
* (exactamente lo que produce spawnStructured). Confirmado en CI
|
|
271
|
+
* windows-latest: `ps -o stat= -p <pid>` para un hijo real y vivo salia
|
|
272
|
+
* con exit 1 ("no such process" segun la vista emulada), y `psField`
|
|
273
|
+
* interpretaba ese exit 1 como "el SO confirmo que el pid no existe" —
|
|
274
|
+
* falso: era ceguera de la herramienta, no evidencia de muerte. Eso
|
|
275
|
+
* rompia el invariante "JAMAS safe sin evidencia" (safeToReplace
|
|
276
|
+
* devolvia 'safe' para un proceso vivo). process.kill(pid,0) evita la
|
|
277
|
+
* capa de emulacion por completo. */
|
|
278
|
+
function pidExistsNative(pid) {
|
|
279
|
+
try {
|
|
280
|
+
process.kill(pid, 0);
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
// ESRCH = el SO confirmo que el pid no existe. Cualquier otro
|
|
285
|
+
// codigo (ej. EPERM: existe pero sin permiso de senializarlo) NO
|
|
286
|
+
// es evidencia de muerte — falla a favor de "vivo" (R2.1).
|
|
287
|
+
return error.code !== 'ESRCH';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
153
290
|
/** Vivo Y con la MISMA identidad — tupla completa, nunca PID solo (R2.1,
|
|
154
|
-
* bloqueador 6): startTime + pgid + digest de ps args.
|
|
291
|
+
* bloqueador 6): startTime + pgid + digest de ps args.
|
|
292
|
+
*
|
|
293
|
+
* win32 (R6, ronda 3 — REVIERTE la ronda 2): la ronda 2 intento cerrar el
|
|
294
|
+
* gap de reciclado de PID via una comparacion completa de identidad contra
|
|
295
|
+
* WMI (win32ProcessInfo, `Get-CimInstance Win32_Process`) en cada llamada.
|
|
296
|
+
* La PRIMERA corrida real en windows-latest CI (2026-08-08) mostro
|
|
297
|
+
* `refIsAlive` devolviendo `false` para un proceso node recien spawneado y
|
|
298
|
+
* genuinamente vivo (test platform-agnostico, SIN mocks de por medio) —
|
|
299
|
+
* osea que la comparacion WMI produjo un FALSO NEGATIVO real, la direccion
|
|
300
|
+
* de fallo MAS peligrosa para esta funcion (un supervisor que trata un job
|
|
301
|
+
* vivo como muerto puede duplicar trabajo o corromper estado). La misma
|
|
302
|
+
* corrida tambien mostro 2 tests E2E pesados (supervisor-loop, e2e-crash)
|
|
303
|
+
* colgando hasta el timeout, consistente con un loop de polling que nunca
|
|
304
|
+
* converge si su chequeo de vida es inestable.
|
|
305
|
+
*
|
|
306
|
+
* `pidExistsNative` (process.kill(pid,0), directo al kernel via libuv) en
|
|
307
|
+
* cambio sobrevivio SIN NINGUN falso negativo/positivo a 3 corridas reales
|
|
308
|
+
* de CI consecutivas (rondas anteriores de este mismo release) — evidencia
|
|
309
|
+
* empirica solida de que es confiable en windows-latest, mientras que WMI
|
|
310
|
+
* demostradamente no lo es (razon exacta de la inestabilia sin determinar:
|
|
311
|
+
* podria ser latencia de indexado de WMI para procesos recien creados,
|
|
312
|
+
* podria ser una conversion de CreationDate no perfectamente determinista
|
|
313
|
+
* entre dos consultas separadas — no hay Windows real disponible en este
|
|
314
|
+
* entorno para experimentar y confirmar cual).
|
|
315
|
+
*
|
|
316
|
+
* Decision (systematic-debugging: 2+ intentos revelando problemas nuevos en
|
|
317
|
+
* lugares distintos exige cuestionar la arquitectura, no seguir parchando):
|
|
318
|
+
* se revierte a existencia + convencion de processGroup solamente en
|
|
319
|
+
* win32, la MISMA superficie que la ronda 1 ya tenia probada. La proteccion
|
|
320
|
+
* contra reciclado de PID completa (bloqueador 6) queda como gap ACEPTADO
|
|
321
|
+
* y documentado en esta plataforma — la ventana real para que ocurra
|
|
322
|
+
* (Windows reciclando un pid entre esta verificacion y la captura original,
|
|
323
|
+
* tipicamente milisegundos/segundos antes, en el mismo proceso controlador)
|
|
324
|
+
* es angosta, y el costo de intentar cerrarla con un mecanismo demostrado
|
|
325
|
+
* no confiable es peor que el gap mismo. `win32ProcessInfo`/`captureRefFor`
|
|
326
|
+
* siguen poblando startTime/psArgsDigest REALES via WMI cuando responden
|
|
327
|
+
* (uso informativo — quedan en el ProcessRef persistido) pero refIsAlive
|
|
328
|
+
* YA NO los usa para su veredicto go/no-go. */
|
|
155
329
|
function refIsAlive(ref) {
|
|
330
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
331
|
+
if (!pidExistsNative(ref.pid))
|
|
332
|
+
return false;
|
|
333
|
+
return ref.processGroup === ref.pid;
|
|
334
|
+
}
|
|
156
335
|
try {
|
|
157
336
|
const stat = psField(ref.pid, 'stat');
|
|
158
337
|
if (stat === null || stat.startsWith('Z'))
|
|
@@ -181,9 +360,20 @@ function processStatesAreGone(states) {
|
|
|
181
360
|
return states.every((stat) => stat === null || stat.startsWith('Z'));
|
|
182
361
|
}
|
|
183
362
|
function groupIsGone(pgid) {
|
|
363
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
364
|
+
// Sin pgrep confiable en esta plataforma (ver pidExistsNative):
|
|
365
|
+
// mejor esfuerzo, solo confirma al LIDER (pgid === pid, por la
|
|
366
|
+
// convencion de fallback de captureRefFor en win32 — nunca hay un
|
|
367
|
+
// pgid real de ps ahi). No enumeramos descendientes sin Job
|
|
368
|
+
// Objects (fuera de alcance) — killTreeWindows ya los alcanza al
|
|
369
|
+
// matar via `taskkill /T`, aunque este check no los confirme
|
|
370
|
+
// individualmente. Nunca declarar "grupo ausente" por ceguera de
|
|
371
|
+
// una herramienta POSIX inexistente/emulada.
|
|
372
|
+
return !pidExistsNative(pgid);
|
|
373
|
+
}
|
|
184
374
|
let pids;
|
|
185
375
|
try {
|
|
186
|
-
const out = (0, child_process_1.execFileSync)('pgrep', ['-g', String(pgid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO });
|
|
376
|
+
const out = (0, child_process_1.execFileSync)('pgrep', ['-g', String(pgid)], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS });
|
|
187
377
|
pids = out.split('\n').filter(Boolean).map(Number).filter(Number.isInteger);
|
|
188
378
|
}
|
|
189
379
|
catch (error) {
|
|
@@ -203,9 +393,26 @@ function groupIsGone(pgid) {
|
|
|
203
393
|
return false; // sin observacion completa, falla cerrado
|
|
204
394
|
}
|
|
205
395
|
}
|
|
396
|
+
/** Llamada UNA VEZ POR TICK por el supervisor (ver superviseController en
|
|
397
|
+
* commands/watch/supervisor.ts) mientras un controlador este activo — el
|
|
398
|
+
* path mas caliente de todo el modulo. En win32, `ps`/`pgrep`, cuando
|
|
399
|
+
* resuelven en el PATH, son el binario EMULADO de MSYS/Cygwin (Git for
|
|
400
|
+
* Windows) — ciego a procesos nativos (el mismo hecho ya documentado y
|
|
401
|
+
* probado para pidExistsNative/refIsAlive/groupIsGone en este archivo).
|
|
402
|
+
* A diferencia de esas funciones, ESTA no fue tocada por ninguna ronda
|
|
403
|
+
* previa del fix de R6 — seguia intentando `ps`/`pgrep` reales en CADA
|
|
404
|
+
* tick, sin timeout, sobre un binario ya sabido no confiable ahi.
|
|
405
|
+
* Degradar directo (sin tocar el proceso real) evita acumular latencia de
|
|
406
|
+
* subprocess real en un loop de polling de alta frecuencia, con o sin el
|
|
407
|
+
* timeout de PS_TIMEOUT_MS de defensa — ese timeout cubre el caso en que
|
|
408
|
+
* el binario SI exista mal comportado; esta rama cubre el caso, ya
|
|
409
|
+
* confirmado real en este archivo, de que la herramienta simplemente no
|
|
410
|
+
* es fuente de verdad en esta plataforma. */
|
|
206
411
|
function activitySnapshot(ref) {
|
|
207
412
|
if (!refIsAlive(ref))
|
|
208
413
|
return null;
|
|
414
|
+
if ((0, paths_1.isWindowsNative)())
|
|
415
|
+
return { cpuTime: 'unknown', groupSize: 1 };
|
|
209
416
|
let cpu = '0';
|
|
210
417
|
try {
|
|
211
418
|
cpu = psField(ref.pid, 'time') ?? '0';
|
|
@@ -215,7 +422,7 @@ function activitySnapshot(ref) {
|
|
|
215
422
|
}
|
|
216
423
|
let groupSize = 1;
|
|
217
424
|
try {
|
|
218
|
-
groupSize = (0, child_process_1.execFileSync)('pgrep', ['-g', String(ref.processGroup)], { encoding: 'utf8', stdio: exports.EXEC_STDIO })
|
|
425
|
+
groupSize = (0, child_process_1.execFileSync)('pgrep', ['-g', String(ref.processGroup)], { encoding: 'utf8', stdio: exports.EXEC_STDIO, timeout: PS_TIMEOUT_MS })
|
|
219
426
|
.split('\n').filter(Boolean).length;
|
|
220
427
|
}
|
|
221
428
|
catch {
|
|
@@ -223,6 +430,17 @@ function activitySnapshot(ref) {
|
|
|
223
430
|
}
|
|
224
431
|
return { cpuTime: cpu, groupSize };
|
|
225
432
|
}
|
|
433
|
+
/** win32: sin process groups POSIX ni convencion de pid negativo, y sin
|
|
434
|
+
* distincion real SIGTERM/SIGKILL (Node mapea ambas a TerminateProcess
|
|
435
|
+
* alla) — un `taskkill /T /F` (recursivo, forzado) sustituye a la
|
|
436
|
+
* escalera completa. Mismo patron ya usado y testeado en
|
|
437
|
+
* sensors/exec.ts::killTree (ver tests/commands/sensors/exec-windows.test.ts). */
|
|
438
|
+
function killTreeWindows(pid) {
|
|
439
|
+
try {
|
|
440
|
+
(0, child_process_1.execFileSync)('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: exports.EXEC_STDIO });
|
|
441
|
+
}
|
|
442
|
+
catch { /* ya ausente, o taskkill no disponible: best-effort — la confirmacion la hace el poll de groupIsGone */ }
|
|
443
|
+
}
|
|
226
444
|
/** Escalera de gracia (design R4.2b): SIGTERM -> confirmar -> SIGKILL -> confirmar.
|
|
227
445
|
* true <=> lider muerto por identidad Y grupo entero desaparecido (pgrep -g
|
|
228
446
|
* vacio) — jamas confirmar solo el lider (bloqueador 6). */
|
|
@@ -242,6 +460,13 @@ async function terminateGroupConfirmed(ref, opts) {
|
|
|
242
460
|
// usar la falta de match como autorizacion para senializar ese grupo.
|
|
243
461
|
if (!refIsAlive(ref))
|
|
244
462
|
return false;
|
|
463
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
464
|
+
killTreeWindows(ref.pid);
|
|
465
|
+
if (await waitUntilGone(opts.termGraceMs))
|
|
466
|
+
return true;
|
|
467
|
+
killTreeWindows(ref.pid);
|
|
468
|
+
return waitUntilGone(opts.killGraceMs);
|
|
469
|
+
}
|
|
245
470
|
try {
|
|
246
471
|
process.kill(-ref.processGroup, 'SIGTERM');
|
|
247
472
|
}
|
|
@@ -270,6 +495,13 @@ async function terminatePreviouslyOwnedGroup(ref, opts) {
|
|
|
270
495
|
};
|
|
271
496
|
if (groupIsGone(ref.processGroup))
|
|
272
497
|
return true;
|
|
498
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
499
|
+
killTreeWindows(ref.pid);
|
|
500
|
+
if (await waitUntilGone(opts.termGraceMs))
|
|
501
|
+
return true;
|
|
502
|
+
killTreeWindows(ref.pid);
|
|
503
|
+
return waitUntilGone(opts.killGraceMs);
|
|
504
|
+
}
|
|
273
505
|
try {
|
|
274
506
|
process.kill(-ref.processGroup, 'SIGTERM');
|
|
275
507
|
}
|