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.
Files changed (43) hide show
  1. package/dist/src/commands/doctor.js +8 -0
  2. package/dist/src/commands/hooks/shared.js +14 -1
  3. package/dist/src/commands/init.js +5 -1
  4. package/dist/src/commands/registry/add.js +10 -1
  5. package/dist/src/commands/sync.js +4 -0
  6. package/dist/src/commands/update.js +4 -0
  7. package/dist/src/core/atomic-file.js +24 -2
  8. package/dist/src/core/executor.js +40 -1
  9. package/dist/src/core/install-transaction.js +13 -1
  10. package/dist/src/core/journal/process.js +241 -9
  11. package/dist/src/core/paths.js +28 -10
  12. package/dist/src/index.js +0 -3
  13. package/dist/tests/commands/doctor-platform.test.js +48 -4
  14. package/dist/tests/commands/doctor.test.js +19 -0
  15. package/dist/tests/commands/hooks/install-symlink-fallback.test.js +25 -0
  16. package/dist/tests/commands/hooks/status.test.js +23 -3
  17. package/dist/tests/commands/init.test.js +39 -0
  18. package/dist/tests/commands/job/exec-wrapper.test.js +15 -1
  19. package/dist/tests/commands/job/gate-reconcile.test.js +31 -5
  20. package/dist/tests/commands/multi-agent-targeting.test.js +65 -0
  21. package/dist/tests/commands/preflight/preflight.test.js +4 -1
  22. package/dist/tests/commands/registry/add.test.js +27 -0
  23. package/dist/tests/commands/sensors/changed.test.js +13 -0
  24. package/dist/tests/commands/sensors/exec-windows.test.js +13 -0
  25. package/dist/tests/commands/sensors/exec.test.js +28 -6
  26. package/dist/tests/commands/sensors/formatters/ruff.test.js +22 -6
  27. package/dist/tests/commands/sensors/run-changed.test.js +33 -0
  28. package/dist/tests/commands/watch/e2e-crash.test.js +54 -18
  29. package/dist/tests/commands/watch/runner.test.js +32 -2
  30. package/dist/tests/commands/watch/supervisor-loop.test.js +24 -3
  31. package/dist/tests/core/artifact-state.test.js +11 -1
  32. package/dist/tests/core/atomic-file-durable.test.js +48 -1
  33. package/dist/tests/core/atomic-file.test.js +14 -3
  34. package/dist/tests/core/executor.test.js +58 -0
  35. package/dist/tests/core/install-transaction.test.js +59 -2
  36. package/dist/tests/core/journal/adapter.test.js +54 -0
  37. package/dist/tests/core/journal/fingerprint.test.js +10 -2
  38. package/dist/tests/core/journal/process.test.js +208 -9
  39. package/dist/tests/core/journal/store.test.js +8 -2
  40. package/dist/tests/core/no-color.test.js +23 -0
  41. package/dist/tests/core/paths.test.js +32 -5
  42. package/dist/tests/core/registries-sync.test.js +32 -3
  43. 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
- fs_1.default.symlinkSync(source, dest);
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
- const base = remote.replace(/\/+$/, '').split(/[/:]/).pop() ?? '';
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
- fs_1.default.fsyncSync(dirFd);
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.symlinkSync(sourcePath, staged, 'dir');
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
- throw new Error(`verification failed: ${op.targetPath} is not a symlink`);
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, detached: true,
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
  }