@mjasnikovs/pi-task 0.40.45 → 0.40.46

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.
@@ -3,6 +3,13 @@ import type { CommandKillReason } from './command-watchdog.js';
3
3
  import type { LoopHit } from '../task/loop-detector.js';
4
4
  /** Grace period between SIGTERM and SIGKILL (ms). */
5
5
  export declare const KILL_GRACE_MS = 5000;
6
+ /**
7
+ * After a child EXITS, how long its pipes may still deliver buffered data before
8
+ * the run is reported. Not a wait for the pipes to CLOSE, which a grandchild holding
9
+ * them can put off for as long as it lives: just the turn or two the reader needs
10
+ * to hand over what it has.
11
+ */
12
+ export declare const EXIT_DRAIN_MS = 50;
6
13
  /** Base flags shared by all child pi invocations. */
7
14
  export declare const CHILD_BASE_ARGS: readonly ["--print", "--no-skills", "--no-extensions", "--no-prompt-templates", "--no-context-files", "--no-session"];
8
15
  /** The subset of a child's stdin we use: write the prompt, then close it. */
@@ -27,8 +34,9 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
27
34
  cwd: string;
28
35
  shell: boolean;
29
36
  stdio: ['ignore' | 'pipe', 'pipe', 'pipe'];
30
- /** Set only when the invocation needs env overrides — git-state-guard's
31
- * `GIT_INDEX_FILE` throwaway index is the one caller. Absent the child
37
+ /** Set when the invocation needs env overrides — git-state-guard's
38
+ * `GIT_INDEX_FILE` throwaway index and for every model child, which
39
+ * carries what leftovers.ts finds its descendants by. Absent → the child
32
40
  * inherits this process's environment. */
33
41
  env?: NodeJS.ProcessEnv;
34
42
  /** true → give the child its own process group (POSIX `detached`), so any
@@ -53,8 +61,8 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
53
61
  * `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
54
62
  * that its descendants inherit. Either/or is load-bearing — Windows ignores
55
63
  * CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
56
- * which walks the live tree and needs no flag; unlike a POSIX group kill it
57
- * cannot catch what the child left behind after it exited.
64
+ * which walks the live tree and needs no flag. Neither reap reaches what pi's bash
65
+ * tool backgrounds, since it detaches every command; leftovers.ts finds that.
58
66
  */
59
67
  export declare function ownGroupSpawnOptions(platform: NodeJS.Platform): OwnGroupSpawnOptions;
60
68
  export type OwnGroupSpawnOptions = {
@@ -64,13 +72,16 @@ export type OwnGroupSpawnOptions = {
64
72
  };
65
73
  /**
66
74
  * Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
67
- * backgrounded. Best-effort: a group already gone is not an error. Kept beside
68
- * the spawn shape so a platform change edits one file.
75
+ * backgrounded that is still in its group. Best-effort: a group already gone is
76
+ * not an error. Kept beside the spawn shape so a platform change edits one file.
77
+ *
78
+ * `leaderExited` has no default: whether the leader still lives decides whether its
79
+ * pid is still its own. Returns whether a reap went out.
69
80
  */
70
- export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, { platform, leaderExited }?: {
81
+ export declare function reapProcessGroup(pid: number, sig: NodeJS.Signals, { platform, leaderExited }: {
71
82
  platform?: NodeJS.Platform;
72
- leaderExited?: boolean;
73
- }): void;
83
+ leaderExited: boolean;
84
+ }): boolean;
74
85
  /**
75
86
  * Why runChild killed the child. Five sources converge on one kill path, and
76
87
  * each names itself here rather than in its own flag — so a consumer reads ONE
@@ -1,10 +1,17 @@
1
1
  import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
2
- import * as path from 'node:path';
2
+ import { system32, trackLeftovers } from './leftovers.js';
3
3
  import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
4
4
  import { realStallTimerDeps, StallProbe } from './stall-probe.js';
5
5
  import { workerChannel } from '../workers/worker-channels.js';
6
6
  /** Grace period between SIGTERM and SIGKILL (ms). */
7
7
  export const KILL_GRACE_MS = 5000;
8
+ /**
9
+ * After a child EXITS, how long its pipes may still deliver buffered data before
10
+ * the run is reported. Not a wait for the pipes to CLOSE, which a grandchild holding
11
+ * them can put off for as long as it lives: just the turn or two the reader needs
12
+ * to hand over what it has.
13
+ */
14
+ export const EXIT_DRAIN_MS = 50;
8
15
  /** Base flags shared by all child pi invocations. */
9
16
  export const CHILD_BASE_ARGS = [
10
17
  '--print',
@@ -24,36 +31,38 @@ export const CHILD_BASE_ARGS = [
24
31
  * `windowsHide` (CREATE_NO_WINDOW) instead: the child gets a windowless console
25
32
  * that its descendants inherit. Either/or is load-bearing — Windows ignores
26
33
  * CREATE_NO_WINDOW next to DETACHED_PROCESS. The win32 reap is `taskkill /T`,
27
- * which walks the live tree and needs no flag; unlike a POSIX group kill it
28
- * cannot catch what the child left behind after it exited.
34
+ * which walks the live tree and needs no flag. Neither reap reaches what pi's bash
35
+ * tool backgrounds, since it detaches every command; leftovers.ts finds that.
29
36
  */
30
37
  export function ownGroupSpawnOptions(platform) {
31
38
  return platform === 'win32' ? { windowsHide: true } : { detached: true };
32
39
  }
33
40
  /**
34
41
  * Tear down a child spawned with `ownGroupSpawnOptions`, and whatever it
35
- * backgrounded. Best-effort: a group already gone is not an error. Kept beside
36
- * the spawn shape so a platform change edits one file.
42
+ * backgrounded that is still in its group. Best-effort: a group already gone is
43
+ * not an error. Kept beside the spawn shape so a platform change edits one file.
44
+ *
45
+ * `leaderExited` has no default: whether the leader still lives decides whether its
46
+ * pid is still its own. Returns whether a reap went out.
37
47
  */
38
- export function reapProcessGroup(pid, sig, { platform = process.platform, leaderExited = false } = {}) {
48
+ export function reapProcessGroup(pid, sig, { platform = process.platform, leaderExited }) {
39
49
  // taskkill walks the tree down from a live leader. Once the leader has exited its
40
50
  // pid may already be another process's, and /T /F would kill that one instead.
41
51
  if (platform === 'win32' && leaderExited)
42
- return;
52
+ return false;
43
53
  try {
44
54
  if (platform === 'win32') {
45
- // System32's taskkill by absolute path, as pi's killProcessTree finds it, so
46
- // neither PATH nor the working directory can supply another. Synchronous,
47
- // unlike pi's: runChild kills the leader next, and the walk must end first.
48
- const taskkill = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe');
49
- spawnSyncDefault(taskkill, ['/pid', String(pid), '/T', '/F']);
55
+ // Synchronous, unlike pi's killProcessTree: runChild kills the leader
56
+ // next, and the walk must end first.
57
+ spawnSyncDefault(system32('taskkill.exe'), ['/pid', String(pid), '/T', '/F']);
50
58
  }
51
59
  else {
52
60
  process.kill(-pid, sig);
53
61
  }
62
+ return true;
54
63
  }
55
64
  catch {
56
- // group already gone
65
+ return false;
57
66
  }
58
67
  }
59
68
  /** The cause a signal was aborted with, when its owner attached one. */
@@ -268,17 +277,19 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
268
277
  const usesStdin = invocation.stdin !== undefined;
269
278
  // Model children (json-events) run arbitrary bash — they can `bun run dev &`
270
279
  // a server that outlives the child and holds a port, wrecking the final gate
271
- // with a self-inflicted EADDRINUSE. Spawn them in their
272
- // OWN process group so every such grandchild can be reaped as a unit on exit.
280
+ // with a self-inflicted EADDRINUSE. They get their OWN process group, so a
281
+ // kill takes the child's tree with it, and leftovers.ts finds what escaped it.
273
282
  // Plumbing (git, mode:'text') never backgrounds anything and stays in-group.
274
283
  const ownGroup = opts?.mode === 'json-events';
275
284
  const platform = (ownGroup && opts.platform) || process.platform;
285
+ const leftovers = ownGroup ? trackLeftovers(platform, invocation.env ?? process.env, KILL_GRACE_MS) : null;
286
+ const env = leftovers?.env ?? invocation.env;
276
287
  const proc = spawn(invocation.command, invocation.args, {
277
288
  cwd,
278
289
  shell: false,
279
290
  stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
280
291
  ...(ownGroup ? ownGroupSpawnOptions(platform) : {}),
281
- ...(invocation.env ? { env: invocation.env } : {})
292
+ ...(env ? { env } : {})
282
293
  });
283
294
  if (usesStdin) {
284
295
  // A child killed before it read the prompt leaves the pipe broken, and an
@@ -294,28 +305,26 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
294
305
  proc.stdin?.end();
295
306
  }
296
307
  // Reap the child's whole process group — the child itself AND anything it
297
- // backgrounded.
308
+ // backgrounded. Set by 'exit' or 'close', whichever a spawn reports first.
298
309
  let leaderExited = false;
299
- proc.once('exit', () => (leaderExited = true));
300
- const reapGroup = (sig) => {
301
- if (!ownGroup || !proc.pid)
302
- return;
303
- reapProcessGroup(proc.pid, sig, { platform, leaderExited });
304
- };
310
+ const reapGroup = (sig) => ownGroup && !!proc.pid && reapProcessGroup(proc.pid, sig, { platform, leaderExited });
305
311
  // One kill path for every source: SIGTERM, then SIGKILL after a grace
306
312
  // period if the child ignored the term. For a group-owning (model) child,
307
313
  // ALSO sweep the group so anything it backgrounded dies with it —
308
314
  // proc.kill hits only the leader, reapGroup the grandchildren. The sweep
309
315
  // goes first because win32's taskkill walks the tree down from a live
310
316
  // leader. The FIRST cause wins: a stall kill's SIGTERM can trip the abort
311
- // path behind it.
317
+ // path behind it. A leader that already exited gave its own verdict.
312
318
  const killProc = (cause) => {
319
+ if (leaderExited)
320
+ return;
313
321
  kill ??= cause;
314
322
  reapGroup('SIGTERM');
315
323
  proc.kill('SIGTERM');
316
324
  setTimeout(() => {
317
325
  reapGroup('SIGKILL');
318
- if (!proc.killed)
326
+ // Not `proc.killed`: that turns true once SIGTERM is delivered.
327
+ if (!leaderExited)
319
328
  proc.kill('SIGKILL');
320
329
  }, KILL_GRACE_MS);
321
330
  };
@@ -432,15 +441,43 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
432
441
  settled = true;
433
442
  resolve(result);
434
443
  };
435
- proc.once('close', (code) => {
436
- // The child has exited, but anything it backgrounded (a dev server) may
437
- // still hold its process group and a port reap the group so the next
438
- // gate's boot check does not collide with our own orphan. Best-effort:
439
- // SIGTERM now, SIGKILL shortly after for anything that ignored it.
440
- if (ownGroup) {
441
- reapGroup('SIGTERM');
442
- setTimeout(() => reapGroup('SIGKILL'), 1_000).unref();
443
- }
444
+ // 'close' waits on every holder of the pipes, and a grandchild that inherited
445
+ // them holds them for as long as it lives. The run ends with the leader: at
446
+ // once if its pipes are already at EOF, otherwise after one drain.
447
+ let finished = false;
448
+ let exitCode = null;
449
+ let endedStreams = 0;
450
+ const expectedStreams = (proc.stdout ? 1 : 0) + (proc.stderr ? 1 : 0);
451
+ let drain;
452
+ const finishIfDrained = () => {
453
+ if (leaderExited && endedStreams >= expectedStreams)
454
+ finish(exitCode);
455
+ };
456
+ proc.stdout?.on('end', () => {
457
+ endedStreams++;
458
+ finishIfDrained();
459
+ });
460
+ proc.stderr?.on('end', () => {
461
+ endedStreams++;
462
+ finishIfDrained();
463
+ });
464
+ proc.once('exit', (code) => {
465
+ leaderExited = true;
466
+ exitCode = code;
467
+ finishIfDrained();
468
+ if (!finished)
469
+ drain = setTimeout(() => finish(exitCode), EXIT_DRAIN_MS);
470
+ });
471
+ proc.once('close', (code) => finish(code ?? exitCode));
472
+ const finish = (code) => {
473
+ if (finished)
474
+ return;
475
+ finished = true;
476
+ leaderExited = true;
477
+ clearTimeout(drain);
478
+ cleanup();
479
+ if (reapGroup('SIGTERM'))
480
+ setTimeout(() => reapGroup('SIGKILL'), KILL_GRACE_MS).unref();
444
481
  if (sink)
445
482
  sink.flush();
446
483
  const text = sink ? sink.text : undefined;
@@ -448,7 +485,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
448
485
  // killed means it ran on a prompt that never finished arriving. Its own
449
486
  // exit describes that half-spec, so it cannot stand as the verdict.
450
487
  const truncated = kill === undefined ? stdinError : undefined;
451
- settle({
488
+ const result = {
452
489
  stdout,
453
490
  stderr: truncated ? `${stderr}\nprompt delivery failed: ${truncated.message}` : stderr,
454
491
  exitCode: truncated ? (code ?? 0) || 1 : (code ?? 0),
@@ -456,9 +493,12 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
456
493
  ...(kill ? { kill } : {}),
457
494
  text,
458
495
  modelError: sink?.modelError
459
- });
460
- });
496
+ };
497
+ // Not settled before the leftovers are gone: the next phase needs their ports.
498
+ void (leftovers?.reap() ?? Promise.resolve()).then(() => settle(result));
499
+ };
461
500
  proc.once('error', () => {
501
+ void leftovers?.reap();
462
502
  settle({
463
503
  stdout,
464
504
  stderr,
@@ -0,0 +1,45 @@
1
+ export declare const LEFTOVER_TOKEN_ENV = "PI_TASK_LEFTOVER_TOKEN";
2
+ export interface Leftovers {
3
+ /** The environment to spawn the child with. */
4
+ env: NodeJS.ProcessEnv;
5
+ /** End whatever the child left running. Never rejects. */
6
+ reap(): Promise<void>;
7
+ }
8
+ /** Start tracking a child about to be spawned with `base` as its environment. */
9
+ export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number): Leftovers;
10
+ /**
11
+ * A System32 executable by absolute path, so neither PATH nor the working directory
12
+ * can supply another. `||`, not `??`: an empty SystemRoot would make it relative.
13
+ */
14
+ export declare function system32(...segments: string[]): string;
15
+ /** Pids whose environment holds `marker`, read from `<procRoot>/<pid>/environ`. */
16
+ export declare function linuxPidsWith(marker: string, procRoot?: string): number[];
17
+ /** Pids of the `ps -E` rows that carry `marker`. The pid is each row's first field. */
18
+ export declare function pidsInPsTable(table: string, marker: string): number[];
19
+ /**
20
+ * Sourced by every bash the child starts, after the user's own BASH_ENV so that one
21
+ * cannot replace the exit trap. Builtins only: a fork costs tens of milliseconds
22
+ * under MSYS. `/proc/$$/winpid` maps MSYS's pid to the Windows one.
23
+ */
24
+ export declare const BASH_ENV_SCRIPT: string;
25
+ /** A shell the registry recorded: its Windows pid and when it ran, as FILETIMEs. */
26
+ export interface Shell {
27
+ pid: number;
28
+ ranAt: bigint;
29
+ /** Absent when the shell was killed, exec'd into its command, or is still running. */
30
+ exitedAt?: bigint;
31
+ }
32
+ /** Rows are `ran <pid> <time>` and `exited <pid> <time>`. Each exit closes its pid's latest run. */
33
+ export declare function parseShells(text: string): Shell[];
34
+ export interface ProcessRow {
35
+ pid: number;
36
+ ppid: number;
37
+ createdAt: bigint;
38
+ }
39
+ /**
40
+ * The processes a recorded shell started: those created while it lived. Windows
41
+ * hands a dead shell's pid to the next process, whose children carry the same
42
+ * parent pid, so only the shell's lifetime tells them apart.
43
+ */
44
+ export declare function startedByShells(rows: ProcessRow[], shells: Shell[]): number[];
45
+ export declare function parseProcessTable(text: string): ProcessRow[];
@@ -0,0 +1,237 @@
1
+ /**
2
+ * leftovers — what a model child started that is still running after it ended.
3
+ *
4
+ * A process-group reap misses it. pi's bash tool starts every command detached, in
5
+ * a group of its own, so a server a command backgrounds is in no group of the
6
+ * child's; on win32 the tree it hung from is gone once its shell exits. Measured
7
+ * with pi's own bash tool: the server outlived the group reap and `taskkill /T`,
8
+ * of the exited child and of the live one, on linux and on the windows runner.
9
+ *
10
+ * So descendants are found by what they inherit. On linux and darwin that is an
11
+ * environment token, read back from the process table. Windows cannot read another
12
+ * process's environment, so there `BASH_ENV` has every bash the child runs record
13
+ * its pid and lifetime, and whatever those shells started is found by parent pid.
14
+ */
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { randomUUID } from 'node:crypto';
17
+ import * as fs from 'node:fs';
18
+ import * as os from 'node:os';
19
+ import * as path from 'node:path';
20
+ export const LEFTOVER_TOKEN_ENV = 'PI_TASK_LEFTOVER_TOKEN';
21
+ const SHELL_REGISTRY_ENV = 'PI_TASK_SHELL_REGISTRY';
22
+ const USER_BASH_ENV = 'PI_TASK_USER_BASH_ENV';
23
+ /** Start tracking a child about to be spawned with `base` as its environment. */
24
+ export function trackLeftovers(platform, base, graceMs) {
25
+ if (platform === 'win32')
26
+ return trackShells(base);
27
+ if (platform === 'linux' || platform === 'darwin')
28
+ return trackToken(platform, base, graceMs);
29
+ return { env: base, reap: () => Promise.resolve() };
30
+ }
31
+ /**
32
+ * A System32 executable by absolute path, so neither PATH nor the working directory
33
+ * can supply another. `||`, not `??`: an empty SystemRoot would make it relative.
34
+ */
35
+ export function system32(...segments) {
36
+ return path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', ...segments);
37
+ }
38
+ // ─── linux, darwin: the environment token ───────────────────────────────────
39
+ function trackToken(platform, base, graceMs) {
40
+ const token = randomUUID();
41
+ const marker = `${LEFTOVER_TOKEN_ENV}=${token}`;
42
+ const find = () => platform === 'linux' ? linuxPidsWith(marker) : pidsInPsTable(darwinPsTable(), marker);
43
+ return {
44
+ env: { ...base, [LEFTOVER_TOKEN_ENV]: token },
45
+ reap: () => {
46
+ // Found again for the SIGKILL, never remembered: a pid that died in the
47
+ // grace period may already be someone else's.
48
+ if (signalEach(find(), 'SIGTERM') > 0) {
49
+ setTimeout(() => signalEach(find(), 'SIGKILL'), graceMs).unref();
50
+ }
51
+ return Promise.resolve();
52
+ }
53
+ };
54
+ }
55
+ /** Pids whose environment holds `marker`, read from `<procRoot>/<pid>/environ`. */
56
+ export function linuxPidsWith(marker, procRoot = '/proc') {
57
+ const inner = Buffer.from(`\0${marker}\0`);
58
+ const first = Buffer.from(`${marker}\0`);
59
+ let names;
60
+ try {
61
+ names = fs.readdirSync(procRoot);
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ const pids = [];
67
+ for (const name of names) {
68
+ if (!/^\d+$/.test(name))
69
+ continue;
70
+ try {
71
+ const environ = fs.readFileSync(path.join(procRoot, name, 'environ'));
72
+ if (environ.includes(inner) || environ.subarray(0, first.length).equals(first)) {
73
+ pids.push(Number(name));
74
+ }
75
+ }
76
+ catch {
77
+ // exited meanwhile, or not ours to read
78
+ }
79
+ }
80
+ return pids;
81
+ }
82
+ /** darwin's `ps -E` appends each process's environment to its command line. */
83
+ function darwinPsTable() {
84
+ const r = spawnSync('/bin/ps', ['-Aww', '-o', 'pid=,ppid=,pgid=,lstart=,command=', '-E'], {
85
+ encoding: 'utf8',
86
+ maxBuffer: 64 * 1024 * 1024
87
+ });
88
+ return r.stdout ?? '';
89
+ }
90
+ /** Pids of the `ps -E` rows that carry `marker`. The pid is each row's first field. */
91
+ export function pidsInPsTable(table, marker) {
92
+ return table
93
+ .split('\n')
94
+ .filter(row => row.includes(marker))
95
+ .map(row => Number.parseInt(row, 10))
96
+ .filter(pid => pid > 0);
97
+ }
98
+ function signalEach(pids, sig) {
99
+ let sent = 0;
100
+ for (const pid of pids) {
101
+ try {
102
+ process.kill(pid, sig);
103
+ sent++;
104
+ }
105
+ catch {
106
+ // already gone
107
+ }
108
+ }
109
+ return sent;
110
+ }
111
+ // ─── win32: the shells `BASH_ENV` recorded ──────────────────────────────────
112
+ /**
113
+ * Sourced by every bash the child starts, after the user's own BASH_ENV so that one
114
+ * cannot replace the exit trap. Builtins only: a fork costs tens of milliseconds
115
+ * under MSYS. `/proc/$$/winpid` maps MSYS's pid to the Windows one.
116
+ */
117
+ export const BASH_ENV_SCRIPT = [
118
+ `if [ -n "\${${USER_BASH_ENV}:-}" ]; then . "$${USER_BASH_ENV}"; fi`,
119
+ '__pi_task_winpid=$$',
120
+ 'if [ -r /proc/$$/winpid ]; then read -r __pi_task_winpid < /proc/$$/winpid; fi',
121
+ `printf 'ran %s %s\\n' "$__pi_task_winpid" "\${EPOCHREALTIME:-}" >> "$${SHELL_REGISTRY_ENV}"`,
122
+ `trap 'printf "exited %s %s\\n" "$__pi_task_winpid" "\${EPOCHREALTIME:-}" >> "$${SHELL_REGISTRY_ENV}"' EXIT`,
123
+ ''
124
+ ].join('\n');
125
+ function trackShells(base) {
126
+ let dir;
127
+ try {
128
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-task-shells-'));
129
+ fs.writeFileSync(path.join(dir, 'bash-env.sh'), BASH_ENV_SCRIPT);
130
+ }
131
+ catch {
132
+ return { env: base, reap: () => Promise.resolve() };
133
+ }
134
+ const registry = path.join(dir, 'shells');
135
+ const env = {
136
+ ...base,
137
+ BASH_ENV: forwardSlashes(path.join(dir, 'bash-env.sh')),
138
+ [SHELL_REGISTRY_ENV]: forwardSlashes(registry)
139
+ };
140
+ if (base.BASH_ENV)
141
+ env[USER_BASH_ENV] = base.BASH_ENV;
142
+ return {
143
+ env,
144
+ reap: async () => {
145
+ try {
146
+ const shells = parseShells(fs.existsSync(registry) ? fs.readFileSync(registry, 'utf8') : '');
147
+ if (shells.length === 0)
148
+ return;
149
+ const started = startedByShells(parseProcessTable(await processTable()), shells);
150
+ await Promise.all(started.map(taskkillTree));
151
+ }
152
+ catch {
153
+ // best-effort, like every other reap
154
+ }
155
+ finally {
156
+ fs.rmSync(dir, { recursive: true, force: true });
157
+ }
158
+ }
159
+ };
160
+ }
161
+ /** Git Bash reads MSYS paths; forward slashes are the spelling both sides accept. */
162
+ function forwardSlashes(p) {
163
+ return p.replace(/\\/g, '/');
164
+ }
165
+ /** FILETIME counts 100ns ticks from 1601; the Unix epoch is this many ticks in. */
166
+ const UNIX_EPOCH_FILETIME = 116444736000000000n;
167
+ /** `EPOCHREALTIME` as a FILETIME. Its decimal mark follows bash's locale. */
168
+ function filetimeOf(epochRealtime) {
169
+ const m = /^(\d+)[.,](\d{6})$/.exec(epochRealtime);
170
+ return m ? (BigInt(m[1]) * 1000000n + BigInt(m[2])) * 10n + UNIX_EPOCH_FILETIME : undefined;
171
+ }
172
+ /** Rows are `ran <pid> <time>` and `exited <pid> <time>`. Each exit closes its pid's latest run. */
173
+ export function parseShells(text) {
174
+ const shells = [];
175
+ for (const row of text.split('\n')) {
176
+ const m = /^(ran|exited) (\d+) (\S+)$/.exec(row.trim());
177
+ const at = m ? filetimeOf(m[3]) : undefined;
178
+ if (!m || at === undefined)
179
+ continue;
180
+ const pid = Number(m[2]);
181
+ if (m[1] === 'ran') {
182
+ shells.push({ pid, ranAt: at });
183
+ }
184
+ else {
185
+ const open = shells.findLast(s => s.pid === pid && s.exitedAt === undefined);
186
+ if (open)
187
+ open.exitedAt = at;
188
+ }
189
+ }
190
+ return shells;
191
+ }
192
+ /**
193
+ * The processes a recorded shell started: those created while it lived. Windows
194
+ * hands a dead shell's pid to the next process, whose children carry the same
195
+ * parent pid, so only the shell's lifetime tells them apart.
196
+ */
197
+ export function startedByShells(rows, shells) {
198
+ const startedBy = (shell, row) => {
199
+ if (shell.pid !== row.ppid || row.createdAt < shell.ranAt)
200
+ return false;
201
+ if (shell.exitedAt !== undefined)
202
+ return row.createdAt <= shell.exitedAt;
203
+ // No exit on record: a process holding the pid since before the run is the shell.
204
+ return rows.some(r => r.pid === shell.pid && r.createdAt <= shell.ranAt);
205
+ };
206
+ return rows.filter(row => shells.some(shell => startedBy(shell, row))).map(row => row.pid);
207
+ }
208
+ export function parseProcessTable(text) {
209
+ const rows = [];
210
+ for (const line of text.split('\n')) {
211
+ const m = /^(\d+) (\d+) (\d+)$/.exec(line.trim());
212
+ if (m)
213
+ rows.push({ pid: Number(m[1]), ppid: Number(m[2]), createdAt: BigInt(m[3]) });
214
+ }
215
+ return rows;
216
+ }
217
+ /** Every process as `pid ppid creation-FILETIME`, one per line. */
218
+ function processTable() {
219
+ const query = 'Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,CreationDate'
220
+ + ' | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToFileTimeUtc())" }';
221
+ return new Promise(resolve => {
222
+ let out = '';
223
+ const ps = spawn(system32('WindowsPowerShell', 'v1.0', 'powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command', query], { stdio: ['ignore', 'pipe', 'ignore'] });
224
+ ps.stdout.on('data', (d) => (out += d.toString()));
225
+ ps.on('error', () => resolve(''));
226
+ ps.on('close', () => resolve(out));
227
+ });
228
+ }
229
+ function taskkillTree(pid) {
230
+ return new Promise(resolve => {
231
+ const tk = spawn(system32('taskkill.exe'), ['/pid', String(pid), '/T', '/F'], {
232
+ stdio: 'ignore'
233
+ });
234
+ tk.on('error', () => resolve());
235
+ tk.on('close', () => resolve());
236
+ });
237
+ }
@@ -163,7 +163,7 @@ export interface BootDeps {
163
163
  * a fake child has no group to kill and a real `process.kill(pid)` against a
164
164
  * fake pid would signal something else entirely.
165
165
  */
166
- killGroup?: (pid: number, signal: NodeJS.Signals) => void;
166
+ killGroup?: (pid: number, signal: NodeJS.Signals, leaderExited: boolean) => void;
167
167
  /** Which platform's spawn options, served-app rule and reap to use. Tests drive
168
168
  * the win32 arm from a POSIX host; production leaves it to `process.platform`. */
169
169
  platform?: NodeJS.Platform;
@@ -249,6 +249,7 @@ export declare function defaultFindPortHolder(port: number): {
249
249
  pid: number;
250
250
  command: string;
251
251
  } | null;
252
+ export declare const BOOT_KILL_GRACE_MS = 2000;
252
253
  /**
253
254
  * Exercise the start command ONCE. All four outcomes below were run against real
254
255
  * child processes in throwaway projects.
@@ -501,6 +501,7 @@ function holderIsOurs(command, boot) {
501
501
  return ((c.includes('bun') || c.includes('node') || c.includes('npm') || c.includes('make'))
502
502
  && (c.includes(` ${script}`) || c.endsWith(script)));
503
503
  }
504
+ export const BOOT_KILL_GRACE_MS = 2_000;
504
505
  /** The real spawn. Kept beside the seam so the default is one line to read. */
505
506
  function defaultSpawnBoot(bin, args, o) {
506
507
  return spawn(bin, args, o);
@@ -611,7 +612,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
611
612
  };
612
613
  let leaderExited = false;
613
614
  const reapGroup = opts.deps?.killGroup
614
- ?? ((pid, sig) => reapProcessGroup(pid, sig, { platform, leaderExited }));
615
+ ?? ((pid, sig, exited) => reapProcessGroup(pid, sig, { platform, leaderExited: exited }));
615
616
  const killGroup = (sig) => {
616
617
  // Truthiness, deliberately: `process.kill(0, sig)` signals the
617
618
  // CALLER's own process group, so a pid of 0 turns a best-effort
@@ -619,17 +620,17 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
619
620
  // `spawnBoot` is a seam now and a fake or future child could.
620
621
  if (!child.pid)
621
622
  return;
622
- reapGroup(child.pid, sig);
623
+ reapGroup(child.pid, sig, leaderExited);
623
624
  };
624
625
  const passAndKill = (renderNote) => {
625
626
  settle(renderNote ? { outcome: 'pass', renderNote } : { outcome: 'pass' });
626
627
  killGroup('SIGTERM');
627
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
628
+ setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
628
629
  };
629
630
  const failAndKill = (detail) => {
630
631
  settle({ outcome: 'fail', detail });
631
632
  killGroup('SIGTERM');
632
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
633
+ setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
633
634
  };
634
635
  // Served apps only: poll for a listening socket owned by our process group.
635
636
  // As soon as one appears the boot has demonstrably served → run the render
@@ -716,7 +717,7 @@ export async function runBootCheck(cwd, [bin, args], graceMs = 10_000, opts = {}
716
717
  detail: `still running after ${graceMs}ms but never opened a listening socket — the spec/dependencies promise an HTTP server`
717
718
  });
718
719
  killGroup('SIGTERM');
719
- setTimeout(() => killGroup('SIGKILL'), 2_000).unref();
720
+ setTimeout(() => killGroup('SIGKILL'), BOOT_KILL_GRACE_MS).unref();
720
721
  return;
721
722
  }
722
723
  passAndKill();
@@ -29,6 +29,7 @@
29
29
  * the gate's boot half, while its command half had none.
30
30
  */
31
31
  import { spawn } from 'node:child_process';
32
+ import { EXIT_DRAIN_MS } from '../shared/child-process.js';
32
33
  import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
33
34
  /**
34
35
  * How much of ONE stream may be held in the HOST process, and how it is split.
@@ -71,12 +72,6 @@ class BoundedOutput {
71
72
  : this.head + this.tail;
72
73
  }
73
74
  }
74
- /**
75
- * After the child EXITS, how long its pipes may still deliver buffered data
76
- * before the run is reported. Not a wait for the pipes to CLOSE — that is the
77
- * bug below — just the turn or two the reader needs to hand over what it has.
78
- */
79
- const DRAIN_MS = 50;
80
75
  /**
81
76
  * The real runner: one bounded child, output collected, never rejects.
82
77
  *
@@ -147,7 +142,7 @@ export const spawnCommand = spec => new Promise(resolve => {
147
142
  const killAndSettle = () => {
148
143
  kill();
149
144
  clearTimeout(drain);
150
- drain = setTimeout(() => done(null), DRAIN_MS);
145
+ drain = setTimeout(() => done(null), EXIT_DRAIN_MS);
151
146
  };
152
147
  // NOT unref'd. This timer is the only bound left on every gate command,
153
148
  // repo-health command and ACCEPT-debt re-run, and an unref'd timer is only
@@ -182,7 +177,7 @@ export const spawnCommand = spec => new Promise(resolve => {
182
177
  settleIfDrained();
183
178
  if (!settled) {
184
179
  clearTimeout(drain);
185
- drain = setTimeout(() => done(exitStatus), DRAIN_MS);
180
+ drain = setTimeout(() => done(exitStatus), EXIT_DRAIN_MS);
186
181
  }
187
182
  });
188
183
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.40.45",
3
+ "version": "0.40.46",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",