pi-crew 0.9.42 → 0.9.44
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/CHANGELOG.md +147 -0
- package/README.md +14 -0
- package/dist/build-meta.json +6692 -13022
- package/dist/index.mjs +54585 -65621
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
- package/scripts/build-bundle.mjs +2 -1
- package/src/agents/agent-config.ts +1 -1
- package/src/config/config.ts +1 -1
- package/src/config/types.ts +0 -2
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/font-detect.ts +16 -3
- package/src/extension/cross-extension-rpc.ts +8 -4
- package/src/extension/registration/commands.ts +5 -1
- package/src/extension/registration/foreground-run-controller.ts +28 -0
- package/src/extension/team-tool.ts +5 -0
- package/src/prompt/prompt-runtime.ts +15 -0
- package/src/runtime/child-pi-constants.ts +42 -0
- package/src/runtime/child-pi-kill.ts +180 -0
- package/src/runtime/child-pi-spawn.ts +234 -0
- package/src/runtime/child-pi-steering.ts +128 -0
- package/src/runtime/child-pi-streams.ts +296 -0
- package/src/runtime/child-pi-transcript.ts +169 -0
- package/src/runtime/child-pi.ts +75 -837
- package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
- package/src/runtime/dwf-state-store.ts +5 -0
- package/src/runtime/dynamic-workflow-context.ts +78 -18
- package/src/runtime/dynamic-workflow-runner.ts +18 -2
- package/src/runtime/goal-evaluator.ts +59 -27
- package/src/runtime/goal-loop-runner.ts +40 -11
- package/src/runtime/live-agent-manager.ts +18 -0
- package/src/runtime/pi-json-output.ts +2 -1
- package/src/runtime/pi-spawn.ts +7 -1
- package/src/runtime/run-coalesced-task-group.ts +18 -10
- package/src/runtime/task-runner/prompt-builder.ts +2 -2
- package/src/runtime/team-runner.ts +6 -1
- package/src/state/event-log.ts +1 -0
- package/src/state/locks.ts +132 -49
- package/src/state/worker-atomic-writer.ts +1 -0
- package/src/utils/paths.ts +1 -1
- package/src/worktree/worktree-manager.ts +47 -19
package/package.json
CHANGED
package/scripts/build-bundle.mjs
CHANGED
|
@@ -83,7 +83,7 @@ export interface AgentConfig {
|
|
|
83
83
|
contextMode?: "fresh" | "fork";
|
|
84
84
|
/** Maximum turns for this agent. Overrides runtime config if set. */
|
|
85
85
|
maxTurns?: number;
|
|
86
|
-
/** Cap on output tokens per API call. Set via
|
|
86
|
+
/** Cap on output tokens per API call. Set via PI_CREW_MAX_OUTPUT env in child. */
|
|
87
87
|
maxTokens?: number;
|
|
88
88
|
/** Effort level for this agent. Controls how much work the agent puts in. */
|
|
89
89
|
effort?: "low" | "medium" | "high";
|
package/src/config/config.ts
CHANGED
|
@@ -169,7 +169,7 @@ export function invalidateConfigCache(): void {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function resolveHomeDir(): string {
|
|
172
|
-
const envValue = process.env.PI_TEAMS_HOME?.trim();
|
|
172
|
+
const envValue = (process.env.PI_TEAMS_HOME ?? process.env.PI_CREW_HOME)?.trim();
|
|
173
173
|
const defaultHome = os.homedir();
|
|
174
174
|
if (!envValue) return defaultHome;
|
|
175
175
|
// FIX (Round 14): When PI_TEAMS_HOME is explicitly set, validate that
|
package/src/config/types.ts
CHANGED
|
@@ -15,8 +15,6 @@ export interface PiTeamsAutonomousConfig {
|
|
|
15
15
|
preferAsyncForLongTasks?: boolean;
|
|
16
16
|
allowWorktreeSuggestion?: boolean;
|
|
17
17
|
magicKeywords?: Record<string, string[]>;
|
|
18
|
-
/** Mark certain bash commands as excludeFromContext to reduce context tokens. Default: false */
|
|
19
|
-
excludeContextBash?: boolean;
|
|
20
18
|
}
|
|
21
19
|
|
|
22
20
|
export interface CrewLimitsConfig {
|
|
@@ -13,7 +13,7 @@ export const CAPACITY_STATUS_ID = "pi-crew-bar";
|
|
|
13
13
|
export const PROVIDER_STATUS_ID = "pi-crew-bar";
|
|
14
14
|
|
|
15
15
|
function resolveHome(): string {
|
|
16
|
-
return process.env.PI_TEAMS_HOME?.trim() || process.env.HOME || process.env.USERPROFILE || "";
|
|
16
|
+
return (process.env.PI_TEAMS_HOME ?? process.env.PI_CREW_HOME)?.trim() || process.env.HOME || process.env.USERPROFILE || "";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function configPath(): string {
|
|
@@ -33,18 +33,30 @@ export function hasCrewFontFile(): boolean {
|
|
|
33
33
|
return _hasFontFile;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
let _isWebTerminal: boolean | null = null;
|
|
37
|
+
|
|
36
38
|
/** Returns true when running in a web-based terminal (gotty, wetty, etc.)
|
|
37
39
|
* where system fonts are not available for PUA rendering. */
|
|
38
40
|
export function isWebTerminal(): boolean {
|
|
39
|
-
if (
|
|
40
|
-
if (process.env.
|
|
41
|
+
if (_isWebTerminal !== null) return _isWebTerminal;
|
|
42
|
+
if (process.env.GOTTY || process.env.WEBTERM) {
|
|
43
|
+
_isWebTerminal = true;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (process.env.TERM === "dumb") {
|
|
47
|
+
_isWebTerminal = true;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
41
50
|
try {
|
|
42
51
|
// Check current process and ancestors — gotty is often the grandparent
|
|
43
52
|
// (gotty → tmux → pi), so /proc/self/cgroup may not contain it.
|
|
44
53
|
let pid = process.pid;
|
|
45
54
|
for (let i = 0; i < 6 && pid > 1; i++) {
|
|
46
55
|
const cgroup = readFileSync(`/proc/${pid}/cgroup`, "utf8");
|
|
47
|
-
if (cgroup.includes("gotty") || cgroup.includes("wetty"))
|
|
56
|
+
if (cgroup.includes("gotty") || cgroup.includes("wetty")) {
|
|
57
|
+
_isWebTerminal = true;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
48
60
|
const match = cgroup.match(/\d+:.*:(.*)/);
|
|
49
61
|
// Read PPid from status
|
|
50
62
|
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
@@ -54,5 +66,6 @@ export function isWebTerminal(): boolean {
|
|
|
54
66
|
} catch {
|
|
55
67
|
// not Linux or /proc not available
|
|
56
68
|
}
|
|
69
|
+
_isWebTerminal = false;
|
|
57
70
|
return false;
|
|
58
71
|
}
|
|
@@ -282,10 +282,14 @@ export function registerPiCrewRpc(
|
|
|
282
282
|
}
|
|
283
283
|
}, "pi-crew:rpc:status"),
|
|
284
284
|
),
|
|
285
|
-
on(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
285
|
+
on(
|
|
286
|
+
events,
|
|
287
|
+
"pi-crew:live-control",
|
|
288
|
+
withHmacVerification((raw) => {
|
|
289
|
+
const request = parseLiveControlRealtimeMessage(raw);
|
|
290
|
+
if (request) publishLiveControlRealtime(request);
|
|
291
|
+
}, "pi-crew:live-control"),
|
|
292
|
+
),
|
|
289
293
|
on(
|
|
290
294
|
events,
|
|
291
295
|
"pi-crew:rpc:live-control",
|
|
@@ -1114,7 +1114,11 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
|
|
|
1114
1114
|
const useProject = tokens.includes("--project");
|
|
1115
1115
|
const varEntries = tokens
|
|
1116
1116
|
.filter((t) => t.startsWith("--var=") || t.startsWith("--var "))
|
|
1117
|
-
.map((t)
|
|
1117
|
+
.map((t): [string, string] => {
|
|
1118
|
+
const s = t.replace(/^--var(?:\s+|=)/, "");
|
|
1119
|
+
const idx = s.indexOf("=");
|
|
1120
|
+
return idx === -1 ? [s, ""] : [s.slice(0, idx), s.slice(idx + 1)];
|
|
1121
|
+
});
|
|
1118
1122
|
const templateId = tokens.find((t) => !t.startsWith("--") && !t.includes("="));
|
|
1119
1123
|
if (!templateId) {
|
|
1120
1124
|
await notifyCommandResult(
|
|
@@ -165,6 +165,7 @@ function startForegroundRunImpl(
|
|
|
165
165
|
goal: loaded?.manifest.goal,
|
|
166
166
|
status,
|
|
167
167
|
taskCount: loaded?.tasks.length,
|
|
168
|
+
durationMs: loaded?.manifest.createdAt ? Date.now() - new Date(loaded.manifest.createdAt).getTime() : 0,
|
|
168
169
|
timestamp: Date.now(),
|
|
169
170
|
});
|
|
170
171
|
const eventType =
|
|
@@ -183,6 +184,33 @@ function startForegroundRunImpl(
|
|
|
183
184
|
status,
|
|
184
185
|
taskCount: loaded?.tasks.length,
|
|
185
186
|
goal: loaded?.manifest.goal,
|
|
187
|
+
durationMs: loaded?.manifest.createdAt ? Date.now() - new Date(loaded.manifest.createdAt).getTime() : 0,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Emit per-task events so task-level metrics (crew.task.count,
|
|
192
|
+
// crew.task.duration_ms, crew.task.tokens_total) record actual values.
|
|
193
|
+
const terminalTaskStatuses = new Set(["completed", "failed", "needs_attention"]);
|
|
194
|
+
for (const task of loaded?.tasks ?? []) {
|
|
195
|
+
if (!terminalTaskStatuses.has(task.status)) continue;
|
|
196
|
+
const taskEventType =
|
|
197
|
+
task.status === "completed"
|
|
198
|
+
? "crew.task.completed"
|
|
199
|
+
: task.status === "failed"
|
|
200
|
+
? "crew.task.failed"
|
|
201
|
+
: "crew.task.needs_attention";
|
|
202
|
+
pi.events?.emit?.(taskEventType, {
|
|
203
|
+
runId,
|
|
204
|
+
team: loaded?.manifest.team,
|
|
205
|
+
workflow: loaded?.manifest.workflow,
|
|
206
|
+
taskId: task.id,
|
|
207
|
+
taskCount: loaded?.tasks.length,
|
|
208
|
+
role: task.role,
|
|
209
|
+
durationMs:
|
|
210
|
+
task.startedAt && task.finishedAt
|
|
211
|
+
? new Date(task.finishedAt).getTime() - new Date(task.startedAt).getTime()
|
|
212
|
+
: 0,
|
|
213
|
+
tokens: (task.usage?.input ?? 0) + (task.usage?.output ?? 0),
|
|
186
214
|
});
|
|
187
215
|
}
|
|
188
216
|
}
|
|
@@ -8,6 +8,7 @@ import { loadConfig, updateAutonomousConfig, updateConfig } from "../config/conf
|
|
|
8
8
|
import type { executeTeamRun as _executeTeamRunFn } from "../runtime/team-runner.ts";
|
|
9
9
|
import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
|
|
10
10
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
11
|
+
import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
|
|
11
12
|
import { appendEvent, appendEventFireAndForget } from "../state/event-log.ts";
|
|
12
13
|
import { withRunLock } from "../state/locks.ts";
|
|
13
14
|
import { replayPendingMailboxMessages } from "../state/mailbox.ts";
|
|
@@ -501,6 +502,10 @@ export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTe
|
|
|
501
502
|
const task = loaded.tasks.find((t) => t.id === taskId);
|
|
502
503
|
if (!task) return result(`Task '${taskId}' not found`, { action: "steer", status: "error" }, true);
|
|
503
504
|
if (!task.pendingSteers) task.pendingSteers = [];
|
|
505
|
+
// T-S1: do not allow steering a task that has already reached a terminal status.
|
|
506
|
+
if (TEAM_TERMINAL_TASK_STATUSES.has(task.status)) {
|
|
507
|
+
return result(`Task '${taskId}' is ${task.status}; cannot steer.`, { action: "steer", status: "error" }, true);
|
|
508
|
+
}
|
|
504
509
|
// HIGH-04: Cap pendingSteers array to prevent unbounded memory growth
|
|
505
510
|
const MAX_PENDING_STEERS = 100;
|
|
506
511
|
if (task.pendingSteers.length >= MAX_PENDING_STEERS) {
|
|
@@ -170,6 +170,21 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
170
170
|
if (typeof payload.max_tokens === "number" && payload.max_tokens > maxTokensCap) {
|
|
171
171
|
payload.max_tokens = maxTokensCap;
|
|
172
172
|
}
|
|
173
|
+
// Also cap newer field names used by some providers
|
|
174
|
+
if (typeof payload.max_completion_tokens === "number" && payload.max_completion_tokens > maxTokensCap) {
|
|
175
|
+
payload.max_completion_tokens = maxTokensCap;
|
|
176
|
+
}
|
|
177
|
+
if (typeof payload.max_output_tokens === "number" && payload.max_output_tokens > maxTokensCap) {
|
|
178
|
+
payload.max_output_tokens = maxTokensCap;
|
|
179
|
+
}
|
|
180
|
+
const generationConfig = payload.generationConfig as Record<string, unknown> | undefined;
|
|
181
|
+
if (
|
|
182
|
+
generationConfig &&
|
|
183
|
+
typeof generationConfig.max_output_tokens === "number" &&
|
|
184
|
+
generationConfig.max_output_tokens > maxTokensCap
|
|
185
|
+
) {
|
|
186
|
+
generationConfig.max_output_tokens = maxTokensCap;
|
|
187
|
+
}
|
|
173
188
|
});
|
|
174
189
|
}
|
|
175
190
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-pi-constants.ts — Shared timing and capture constants for child-pi runtime.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from child-pi.ts (H-7 decomposition, step 3). Zero behavior change.
|
|
5
|
+
*
|
|
6
|
+
* These constants are shared between runChildPi (in child-pi.ts) and the kill
|
|
7
|
+
* helpers (in child-pi-kill.ts). Centralizing them here avoids circular imports
|
|
8
|
+
* and makes the timing budget configurable from one place.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
|
|
12
|
+
|
|
13
|
+
/** Post-exit window during which stdio is guarded against late writes. */
|
|
14
|
+
export const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
|
|
15
|
+
|
|
16
|
+
/** Maximum time to wait for a final assistant event after the last stdout byte. */
|
|
17
|
+
export const FINAL_DRAIN_MS = DEFAULT_CHILD_PI.finalDrainMs;
|
|
18
|
+
|
|
19
|
+
/** Time after SIGTERM to escalate to SIGKILL. */
|
|
20
|
+
export const HARD_KILL_MS = DEFAULT_CHILD_PI.hardKillMs;
|
|
21
|
+
|
|
22
|
+
/** Maximum time with no output before the child is considered unresponsive. */
|
|
23
|
+
export const RESPONSE_TIMEOUT_MS = DEFAULT_CHILD_PI.responseTimeoutMs;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Maximum size (bytes) for the ChildPiLineObserver's line accumulation buffer.
|
|
27
|
+
* When exceeded, the buffer is force-flushed to prevent unbounded memory growth
|
|
28
|
+
* from chatty child processes that produce output without newlines.
|
|
29
|
+
*/
|
|
30
|
+
export const MAX_LINE_BUFFER_BYTES = 1024 * 1024; // 1 MB
|
|
31
|
+
|
|
32
|
+
/** Maximum characters for assistant text fragments in compacted events. */
|
|
33
|
+
export const MAX_ASSISTANT_TEXT_CHARS = DEFAULT_CHILD_PI.maxAssistantTextChars;
|
|
34
|
+
|
|
35
|
+
/** Maximum characters for tool-result fragments in compacted events. */
|
|
36
|
+
export const MAX_TOOL_RESULT_CHARS = DEFAULT_CHILD_PI.maxToolResultChars;
|
|
37
|
+
|
|
38
|
+
/** Maximum characters for tool-input fragments in compacted events. */
|
|
39
|
+
export const MAX_TOOL_INPUT_CHARS = DEFAULT_CHILD_PI.maxToolInputChars;
|
|
40
|
+
|
|
41
|
+
/** Maximum characters for general compactable content (used by TruncationStage). */
|
|
42
|
+
export const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-pi-kill.ts — Kill/escape path for child Pi worker processes.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from child-pi.ts (H-7 decomposition, step 2). Zero behavior change.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* - appendBoundedTail(): bounded concatenation with truncation marker.
|
|
8
|
+
* - killProcessPid(): send SIGTERM → SIGKILL escalation, with Windows taskkill fallback.
|
|
9
|
+
* - killProcessTree(): log+kill a child by pid, optionally attach exit listener to clear timer.
|
|
10
|
+
* - terminateActiveChildPiProcesses(): kill all known active children.
|
|
11
|
+
* - registerActiveChild()/unregisterActiveChild(): book-keeping for the active-children Map.
|
|
12
|
+
* - Periodic zombie reaper (setInterval) that drops dead entries.
|
|
13
|
+
*
|
|
14
|
+
* The activeChildrenMap and hardKillTimers Map are kept here (not re-exported)
|
|
15
|
+
* because they are tightly coupled to the kill lifecycle.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ChildProcess } from "node:child_process";
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
|
|
21
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
22
|
+
import { HARD_KILL_MS } from "./child-pi-constants.ts";
|
|
23
|
+
import { TailCaptureStage } from "./compact-stages/tail-capture-stage.ts";
|
|
24
|
+
|
|
25
|
+
const MAX_CAPTURE_BYTES = DEFAULT_CHILD_PI.maxCaptureBytes;
|
|
26
|
+
|
|
27
|
+
// Active children bookkeeping. Mutated by registerActiveChild/unregisterActiveChild.
|
|
28
|
+
const activeChildProcesses = new Map<number, ChildProcess>();
|
|
29
|
+
const childHardKillTimers = new Map<number, NodeJS.Timeout>();
|
|
30
|
+
|
|
31
|
+
// Periodic cleanup of dead child process entries to prevent memory leaks.
|
|
32
|
+
// If a child process never emits exit/close (zombie), the entry would leak.
|
|
33
|
+
setInterval(() => {
|
|
34
|
+
for (const [pid, child] of activeChildProcesses) {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0); // Throws ESRCH if dead
|
|
37
|
+
} catch {
|
|
38
|
+
activeChildProcesses.delete(pid);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}, 60_000).unref();
|
|
42
|
+
|
|
43
|
+
/** Register a newly-spawned child so it can be tracked + killed on shutdown. */
|
|
44
|
+
export function registerActiveChild(pid: number, child: ChildProcess): void {
|
|
45
|
+
activeChildProcesses.set(pid, child);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Remove a child from the active set once it has exited (or before kill). */
|
|
49
|
+
export function unregisterActiveChild(pid: number): void {
|
|
50
|
+
activeChildProcesses.delete(pid);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Clear the SIGKILL escalation timer for a pid (called on early exit). */
|
|
54
|
+
export function clearHardKillTimer(pid: number | undefined): void {
|
|
55
|
+
if (pid === undefined) return;
|
|
56
|
+
const timer = childHardKillTimers.get(pid);
|
|
57
|
+
if (timer) {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
childHardKillTimers.delete(pid);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Append `chunk` to `current`, keeping the result within `maxBytes`. When the
|
|
65
|
+
* cap is exceeded, returns the tail (most recent bytes) prefixed with a marker
|
|
66
|
+
* showing how much was dropped. Delegates to TailCaptureStage so the logic
|
|
67
|
+
* stays consistent with the rest of the compaction pipeline.
|
|
68
|
+
*/
|
|
69
|
+
export function appendBoundedTail(current: string, chunk: string, maxBytes = MAX_CAPTURE_BYTES): string {
|
|
70
|
+
return new TailCaptureStage({
|
|
71
|
+
maxBytes,
|
|
72
|
+
marker: `[pi-crew captured output truncated to last ${Math.round(maxBytes / 1024)} KiB]`,
|
|
73
|
+
}).apply(current + chunk);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function spawnTaskkillSafe(pid: number): void {
|
|
77
|
+
// B6: spawn taskkill and attach an 'error' listener. spawn() emits ENOENT/EACCES
|
|
78
|
+
// via the 'error' event — unhandled these would become uncaughtException and
|
|
79
|
+
// can crash the parent as an uncaught exception. taskkill is a standard Windows
|
|
80
|
+
// utility and rarely fails to spawn, but be defensive.
|
|
81
|
+
try {
|
|
82
|
+
const taskkillChild = spawn("taskkill", ["/pid", String(pid), "/t", "/f"], {
|
|
83
|
+
stdio: "ignore",
|
|
84
|
+
detached: false,
|
|
85
|
+
});
|
|
86
|
+
taskkillChild.on("error", (err) => {
|
|
87
|
+
logInternalError("child-pi.taskkill-spawn-error", err instanceof Error ? err : new Error(String(err)), `pid=${pid}`);
|
|
88
|
+
});
|
|
89
|
+
taskkillChild.unref();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logInternalError("child-pi.taskkill-sync-error", error instanceof Error ? error : new Error(String(error)), `pid=${pid}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function killProcessPid(pid: number): void {
|
|
96
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
97
|
+
try {
|
|
98
|
+
if (process.platform === "win32") {
|
|
99
|
+
// 3.8: Windows path uses taskkill /T /F (force kill the entire tree).
|
|
100
|
+
// taskkill itself can silently fail (PID gone, permission denied, etc.)
|
|
101
|
+
// so verify after 2s and log a warning if the process is still alive.
|
|
102
|
+
spawnTaskkillSafe(pid);
|
|
103
|
+
const verifyTimer = setTimeout(() => {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0); // throws ESRCH when dead
|
|
106
|
+
// Still alive — log and retry once.
|
|
107
|
+
logInternalError(
|
|
108
|
+
"child-pi.taskkill-stuck",
|
|
109
|
+
new Error(`process ${pid} still alive 2s after taskkill /T /F; retrying`),
|
|
110
|
+
`pid=${pid}`,
|
|
111
|
+
"error",
|
|
112
|
+
);
|
|
113
|
+
try {
|
|
114
|
+
spawnTaskkillSafe(pid);
|
|
115
|
+
} catch {
|
|
116
|
+
/* best-effort */
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// ESRCH or EPERM — process is gone. OK.
|
|
120
|
+
}
|
|
121
|
+
}, 2000);
|
|
122
|
+
verifyTimer.unref();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
process.kill(-pid, "SIGTERM");
|
|
127
|
+
} catch (error) {
|
|
128
|
+
logInternalError("child-pi.sigterm", error, `pid=${pid}`);
|
|
129
|
+
try {
|
|
130
|
+
process.kill(pid, "SIGTERM");
|
|
131
|
+
} catch (fallbackError) {
|
|
132
|
+
logInternalError("child-pi.sigterm-absolute", fallbackError, `pid=${pid}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
clearHardKillTimer(pid);
|
|
136
|
+
const hardKillTimer = setTimeout(() => {
|
|
137
|
+
try {
|
|
138
|
+
process.kill(-pid, "SIGKILL");
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logInternalError("child-pi.sigkill", error, `pid=${pid}`);
|
|
141
|
+
try {
|
|
142
|
+
process.kill(pid, "SIGKILL");
|
|
143
|
+
} catch (fallbackError) {
|
|
144
|
+
logInternalError("child-pi.sigkill-absolute", fallbackError, `pid=${pid}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
childHardKillTimers.delete(pid);
|
|
148
|
+
}, HARD_KILL_MS);
|
|
149
|
+
hardKillTimer.unref();
|
|
150
|
+
childHardKillTimers.set(pid, hardKillTimer);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
logInternalError("child-pi.kill-process-pid", error, `pid=${pid}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function killProcessTree(pid: number | undefined, child?: ChildProcess): void {
|
|
157
|
+
// Phase-0 diagnostic (HB-003a): capture who invoked killProcessTree so the
|
|
158
|
+
// exit-null race has a provenance trail. .stack is best-effort (may be undefined
|
|
159
|
+
// under deep async), so we take a snapshot lazily.
|
|
160
|
+
try {
|
|
161
|
+
const callerStack = new Error("killProcessTree caller").stack ?? "(no stack)";
|
|
162
|
+
logInternalError(
|
|
163
|
+
"child-pi.kill-process-tree-invoked",
|
|
164
|
+
new Error(`pid=${pid} called from:\n${callerStack.split("\n").slice(0, 8).join("\n")}`),
|
|
165
|
+
`pid=${pid}`,
|
|
166
|
+
);
|
|
167
|
+
} catch {
|
|
168
|
+
/* diagnostic best-effort */
|
|
169
|
+
}
|
|
170
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0) return;
|
|
171
|
+
if (child && child.exitCode !== null) return;
|
|
172
|
+
killProcessPid(pid);
|
|
173
|
+
child?.once("exit", () => clearHardKillTimer(pid));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function terminateActiveChildPiProcesses(): number {
|
|
177
|
+
const entries = [...activeChildProcesses.entries()];
|
|
178
|
+
for (const [pid, child] of entries) killProcessTree(pid, child);
|
|
179
|
+
return entries.length;
|
|
180
|
+
}
|