@mjasnikovs/pi-task 0.40.49 → 0.40.50
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.
|
@@ -446,10 +446,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
446
446
|
// invocation including its full prompt, and `opts`, along with everything
|
|
447
447
|
// the caller's callbacks close over.
|
|
448
448
|
let settled = false;
|
|
449
|
+
let endLeftoverWait;
|
|
449
450
|
const cleanup = () => {
|
|
450
451
|
stallProbe?.stop();
|
|
451
452
|
streamWatch?.stop();
|
|
452
453
|
signal?.removeEventListener('abort', onAbort);
|
|
454
|
+
if (endLeftoverWait)
|
|
455
|
+
signal?.removeEventListener('abort', endLeftoverWait);
|
|
453
456
|
};
|
|
454
457
|
const settle = (result) => {
|
|
455
458
|
cleanup();
|
|
@@ -516,8 +519,11 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
516
519
|
proc.stdout?.destroy?.();
|
|
517
520
|
proc.stderr?.destroy?.();
|
|
518
521
|
// Not settled before the leftovers are gone: the next phase needs their ports.
|
|
519
|
-
|
|
520
|
-
|
|
522
|
+
// A cancel arriving meanwhile ends the wait, not the reap.
|
|
523
|
+
endLeftoverWait = () => settle(result);
|
|
524
|
+
if (!settled)
|
|
525
|
+
signal?.addEventListener('abort', endLeftoverWait, { once: true });
|
|
526
|
+
void (leftovers?.reap() ?? Promise.resolve()).then(endLeftoverWait, endLeftoverWait);
|
|
521
527
|
};
|
|
522
528
|
proc.once('error', () => {
|
|
523
529
|
void leftovers?.reap();
|
|
@@ -5,7 +5,12 @@ export interface Leftovers {
|
|
|
5
5
|
/** End whatever the child left running. Never rejects. */
|
|
6
6
|
reap: () => Promise<void>;
|
|
7
7
|
}
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Start tracking a child about to be spawned with `base` as its environment.
|
|
10
|
+
* `graceMs` is POSIX's, between SIGTERM and SIGKILL. win32 has no grace to give:
|
|
11
|
+
* `taskkill /F` returns once the tree is dead, and its port free (40 of 40 on the
|
|
12
|
+
* windows runner).
|
|
13
|
+
*/
|
|
9
14
|
export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number): Leftovers;
|
|
10
15
|
/**
|
|
11
16
|
* A System32 executable by absolute path, so neither PATH nor the working directory
|
package/dist/shared/leftovers.js
CHANGED
|
@@ -20,7 +20,12 @@ import * as path from 'node:path';
|
|
|
20
20
|
export const LEFTOVER_TOKEN_ENV = 'PI_TASK_LEFTOVER_TOKEN';
|
|
21
21
|
const SHELL_REGISTRY_ENV = 'PI_TASK_SHELL_REGISTRY';
|
|
22
22
|
const USER_BASH_ENV = 'PI_TASK_USER_BASH_ENV';
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Start tracking a child about to be spawned with `base` as its environment.
|
|
25
|
+
* `graceMs` is POSIX's, between SIGTERM and SIGKILL. win32 has no grace to give:
|
|
26
|
+
* `taskkill /F` returns once the tree is dead, and its port free (40 of 40 on the
|
|
27
|
+
* windows runner).
|
|
28
|
+
*/
|
|
24
29
|
export function trackLeftovers(platform, base, graceMs) {
|
|
25
30
|
if (platform === 'win32')
|
|
26
31
|
return trackShells(base);
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* It also closes an asymmetry: `BootDeps` carries twelve injectable probes for
|
|
29
29
|
* the gate's boot half, while its command half had none.
|
|
30
30
|
*/
|
|
31
|
+
import { type ChildProcess, type SpawnOptions } from 'node:child_process';
|
|
31
32
|
/** What one finished command looks like, stripped of how it was spawned. */
|
|
32
33
|
export interface CommandRun {
|
|
33
34
|
/** The runner binary itself never started (ENOENT, no POSIX shell). */
|
|
@@ -51,7 +52,7 @@ export interface CommandSpec {
|
|
|
51
52
|
* rather than the live `process.env`.
|
|
52
53
|
*/
|
|
53
54
|
env?: Record<string, string | undefined>;
|
|
54
|
-
/** The caller's cancel. Kills
|
|
55
|
+
/** The caller's cancel. Kills a running child, whose run then reads as `status: null`. */
|
|
55
56
|
signal?: AbortSignal;
|
|
56
57
|
}
|
|
57
58
|
/**
|
|
@@ -66,6 +67,11 @@ export interface CommandSpec {
|
|
|
66
67
|
* paint a single frame through it.
|
|
67
68
|
*/
|
|
68
69
|
export type CommandRunner = (spec: CommandSpec) => Promise<CommandRun>;
|
|
70
|
+
/** What a runner spawns with. Tests hand in a fake child, and another platform's rules. */
|
|
71
|
+
export interface CommandSpawner {
|
|
72
|
+
spawn: (bin: string, args: string[], options: SpawnOptions) => ChildProcess;
|
|
73
|
+
platform: NodeJS.Platform;
|
|
74
|
+
}
|
|
69
75
|
/**
|
|
70
76
|
* The real runner: one bounded child, output collected, never rejects.
|
|
71
77
|
*
|
|
@@ -83,7 +89,7 @@ export type CommandRunner = (spec: CommandSpec) => Promise<CommandRun>;
|
|
|
83
89
|
* pipe. Waiting for `close` is therefore waiting for something no timeout can
|
|
84
90
|
* reach. `exit` settles the run, and the deadline settles it itself.
|
|
85
91
|
*/
|
|
86
|
-
export declare const spawnCommand:
|
|
92
|
+
export declare const spawnCommand: (spec: CommandSpec, spawner?: CommandSpawner) => Promise<CommandRun>;
|
|
87
93
|
/**
|
|
88
94
|
* A non-zero exit whose output shows an EXTERNAL runtime dependency is missing, not
|
|
89
95
|
* a code fault: a browser suite (Playwright/Cypress) whose browser binaries or system
|
package/dist/task/command-run.js
CHANGED
|
@@ -29,7 +29,8 @@
|
|
|
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, ownGroupSpawnOptions, reapGroupAfterExit, reapProcessGroup } from '../shared/child-process.js';
|
|
32
|
+
import { EXIT_DRAIN_MS, KILL_GRACE_MS, ownGroupSpawnOptions, reapGroupAfterExit, reapProcessGroup } from '../shared/child-process.js';
|
|
33
|
+
import { trackLeftovers } from '../shared/leftovers.js';
|
|
33
34
|
import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
34
35
|
/**
|
|
35
36
|
* How much of ONE stream may be held in the HOST process, and how it is split.
|
|
@@ -72,6 +73,7 @@ class BoundedOutput {
|
|
|
72
73
|
: this.head + this.tail;
|
|
73
74
|
}
|
|
74
75
|
}
|
|
76
|
+
const HOST_SPAWNER = { spawn, platform: process.platform };
|
|
75
77
|
/**
|
|
76
78
|
* The real runner: one bounded child, output collected, never rejects.
|
|
77
79
|
*
|
|
@@ -89,41 +91,56 @@ class BoundedOutput {
|
|
|
89
91
|
* pipe. Waiting for `close` is therefore waiting for something no timeout can
|
|
90
92
|
* reach. `exit` settles the run, and the deadline settles it itself.
|
|
91
93
|
*/
|
|
92
|
-
export const spawnCommand = spec => new Promise(resolve => {
|
|
94
|
+
export const spawnCommand = (spec, spawner = HOST_SPAWNER) => new Promise(resolve => {
|
|
95
|
+
const { platform } = spawner;
|
|
93
96
|
const out = new BoundedOutput();
|
|
94
97
|
const err = new BoundedOutput();
|
|
98
|
+
let result;
|
|
95
99
|
let settled = false;
|
|
100
|
+
let cutShort = false;
|
|
96
101
|
let exitStatus = null;
|
|
97
102
|
let exited = false;
|
|
98
103
|
let killed = false;
|
|
99
104
|
let endedStreams = 0;
|
|
100
105
|
let drain;
|
|
101
|
-
// Its own process group
|
|
102
|
-
//
|
|
103
|
-
|
|
106
|
+
// Its own process group, and descendants found by what they inherit: a pretest
|
|
107
|
+
// that backgrounds a daemon, a build that leaves a watcher, would otherwise
|
|
108
|
+
// hold their ports into the boot check.
|
|
109
|
+
const leftovers = trackLeftovers(platform, spec.env ?? process.env, KILL_GRACE_MS);
|
|
110
|
+
const child = spawner.spawn(spec.bin, spec.args, {
|
|
104
111
|
cwd: spec.cwd,
|
|
105
112
|
// stdin CLOSED. `spawnSync` gave the child none; the default `spawn`
|
|
106
113
|
// stdio is a live pipe nobody ever ends, so a check that reads stdin —
|
|
107
114
|
// a `cat`-style pipeline, a tool that prompts, a pager — would block
|
|
108
115
|
// until the kill timer fires instead of returning at once.
|
|
109
116
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
-
...ownGroupSpawnOptions(
|
|
111
|
-
|
|
117
|
+
...ownGroupSpawnOptions(platform),
|
|
118
|
+
env: leftovers.env
|
|
112
119
|
});
|
|
113
|
-
const
|
|
114
|
-
if (settled)
|
|
120
|
+
const settle = () => {
|
|
121
|
+
if (settled || !result)
|
|
115
122
|
return;
|
|
116
123
|
settled = true;
|
|
117
124
|
clearTimeout(timer);
|
|
118
|
-
clearTimeout(drain);
|
|
119
125
|
spec.signal?.removeEventListener('abort', killAndSettle);
|
|
120
|
-
resolve(
|
|
126
|
+
resolve(result);
|
|
127
|
+
};
|
|
128
|
+
const done = (status, failure) => {
|
|
129
|
+
if (result)
|
|
130
|
+
return;
|
|
131
|
+
clearTimeout(drain);
|
|
132
|
+
result = {
|
|
121
133
|
failedToStart: failure !== undefined,
|
|
122
134
|
...(failure === undefined ? {} : { failureMessage: failure }),
|
|
123
135
|
status,
|
|
124
136
|
stdout: out.toString(),
|
|
125
137
|
stderr: err.toString()
|
|
126
|
-
}
|
|
138
|
+
};
|
|
139
|
+
// Not settled before the leftovers are gone: the boot check binds their
|
|
140
|
+
// ports. The deadline and the cancel still end the wait.
|
|
141
|
+
void leftovers.reap().then(settle);
|
|
142
|
+
if (cutShort)
|
|
143
|
+
settle();
|
|
127
144
|
};
|
|
128
145
|
const expectedStreams = (child.stdout ? 1 : 0) + (child.stderr ? 1 : 0);
|
|
129
146
|
const settleIfDrained = () => {
|
|
@@ -132,7 +149,7 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
132
149
|
};
|
|
133
150
|
const kill = () => {
|
|
134
151
|
if (child.pid)
|
|
135
|
-
reapProcessGroup(child.pid, 'SIGKILL', { leaderExited: exited });
|
|
152
|
+
reapProcessGroup(child.pid, 'SIGKILL', { platform, leaderExited: exited });
|
|
136
153
|
try {
|
|
137
154
|
child.kill('SIGKILL');
|
|
138
155
|
}
|
|
@@ -143,9 +160,14 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
143
160
|
/**
|
|
144
161
|
* The deadline and the cancel both END the run. This cannot wait to observe
|
|
145
162
|
* the kill's effect — it kills, gives the pipes one drain, and reports
|
|
146
|
-
* `status: null` regardless.
|
|
163
|
+
* `status: null` regardless. A leader that already exited keeps its status.
|
|
147
164
|
*/
|
|
148
165
|
const killAndSettle = () => {
|
|
166
|
+
cutShort = true;
|
|
167
|
+
if (result)
|
|
168
|
+
return settle();
|
|
169
|
+
if (exited)
|
|
170
|
+
return;
|
|
149
171
|
killed = true;
|
|
150
172
|
kill();
|
|
151
173
|
clearTimeout(drain);
|
|
@@ -177,15 +199,16 @@ export const spawnCommand = spec => new Promise(resolve => {
|
|
|
177
199
|
child.on('error', (e) => done(null, e.message));
|
|
178
200
|
child.on('exit', (code) => {
|
|
179
201
|
exited = true;
|
|
180
|
-
//
|
|
181
|
-
|
|
202
|
+
// A win32 kill ends the child with exit code 1, the code a failed check exits
|
|
203
|
+
// with too. A POSIX kill leaves no code, so a code there is the child's own.
|
|
204
|
+
exitStatus = killed && platform === 'win32' && code === 1 ? null : code;
|
|
182
205
|
if (child.pid)
|
|
183
|
-
reapGroupAfterExit(child.pid);
|
|
206
|
+
reapGroupAfterExit(child.pid, platform);
|
|
184
207
|
// Both ends of the same question: settle now if the pipes are already
|
|
185
208
|
// at EOF, otherwise settle after one short drain rather than waiting on
|
|
186
209
|
// whoever else is holding them.
|
|
187
210
|
settleIfDrained();
|
|
188
|
-
if (!
|
|
211
|
+
if (!result) {
|
|
189
212
|
clearTimeout(drain);
|
|
190
213
|
drain = setTimeout(() => done(exitStatus), EXIT_DRAIN_MS);
|
|
191
214
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.50",
|
|
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",
|