@ryan_nookpi/pi-extension-subagent 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/claude-sdk-runner.ts +1 -1
- package/commands.ts +4 -2
- package/diagnostics.ts +145 -0
- package/lifecycle.ts +13 -2
- package/package.json +2 -1
- package/run-utils.ts +1 -1
- package/runner.ts +185 -33
- package/tool-execute.ts +4 -2
package/README.md
CHANGED
|
@@ -238,6 +238,28 @@ Confirm the environment used to start pi has valid Anthropic authentication, suc
|
|
|
238
238
|
|
|
239
239
|
That is intentional. Hidden `>` runs are human-only UI jobs. Inspect them with `/sub:peek`, `<>runId`, or `/sub:open`.
|
|
240
240
|
|
|
241
|
+
### Debug runner termination
|
|
242
|
+
|
|
243
|
+
The extension records process lifecycle diagnostics as `subagent-runner-diagnostic` custom entries in the parent session JSONL. They include run/batch IDs, parent and child PID/PGID, abort reason, internal kill cause, `exit`/`close` code and signal, settle reason, shutdown reason, and process-error stacks. Custom entries are not sent to the LLM and remain hidden unless an entry renderer is registered, as documented by pi's [`appendEntry` API](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md#piappendentrycustomtype-data).
|
|
244
|
+
|
|
245
|
+
Run `/session` to find the current session file, set `SESSION_FILE` to that path, then extract only the diagnostic entries:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
jq -c 'select(.type == "custom" and .customType == "subagent-runner-diagnostic") | {timestamp, data}' "$SESSION_FILE"
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
For batch failures, compare entries by `batchId` and `runId`, then follow each run from `spawn` through `kill_intent`, `exit`, `close`, and `settled`. Share only the selected diagnostic lines—not the full session file, which may contain prompts or tool output.
|
|
252
|
+
|
|
253
|
+
To report a reproducible extension bug, create `subagent-issue.md` with the pi and extension versions, OS and Node version, launch mode/command, reproduction steps, expected and actual behavior, and the sanitized diagnostic lines. Then submit it with:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
gh issue create --repo Jonghakseo/pi-extension \
|
|
257
|
+
--title "subagent: unexpected child termination" \
|
|
258
|
+
--body-file subagent-issue.md
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Without GitHub CLI, use the repository's [new issue page](https://github.com/Jonghakseo/pi-extension/issues/new). Remove secrets and sensitive paths before attaching diagnostics.
|
|
262
|
+
|
|
241
263
|
## Security and trust boundary
|
|
242
264
|
|
|
243
265
|
- Claude SDK execution uses `permissionMode: "bypassPermissions"` with `allowDangerouslySkipPermissions`; Claude CLI execution uses `--dangerously-skip-permissions`.
|
package/claude-sdk-runner.ts
CHANGED
package/commands.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
SUBVIEW_OVERLAY_MAX_HEIGHT,
|
|
27
27
|
SUBVIEW_OVERLAY_WIDTH,
|
|
28
28
|
} from "./constants.js";
|
|
29
|
+
import { createRunDiagnosticSink } from "./diagnostics.js";
|
|
29
30
|
import {
|
|
30
31
|
buildSubagentDisplayTaskFallback,
|
|
31
32
|
createDisplayTaskRefreshToken,
|
|
@@ -1434,6 +1435,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
|
|
|
1434
1435
|
resumeSessionId: runState.claudeSessionId,
|
|
1435
1436
|
sidecarSessionFile: runState.sessionFile,
|
|
1436
1437
|
persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
|
|
1438
|
+
onDiagnostic: createRunDiagnosticSink(pi, runState),
|
|
1437
1439
|
},
|
|
1438
1440
|
),
|
|
1439
1441
|
);
|
|
@@ -1907,7 +1909,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
|
|
|
1907
1909
|
if (!controller) return false;
|
|
1908
1910
|
run.lastLine = "Aborting by user...";
|
|
1909
1911
|
run.lastOutput = run.lastLine;
|
|
1910
|
-
controller.abort();
|
|
1912
|
+
controller.abort({ source: "sub_abort_command", runId: run.id });
|
|
1911
1913
|
return true;
|
|
1912
1914
|
};
|
|
1913
1915
|
|
|
@@ -2187,7 +2189,7 @@ export function registerAll(pi: ExtensionAPI, store: SubagentStore): SubagentReg
|
|
|
2187
2189
|
if (run.status === "running" && shortcutController) {
|
|
2188
2190
|
run.lastLine = "Aborting by user...";
|
|
2189
2191
|
run.lastOutput = run.lastLine;
|
|
2190
|
-
shortcutController.abort();
|
|
2192
|
+
shortcutController.abort({ source: "sub_abort_shortcut", runId: id });
|
|
2191
2193
|
aborted++;
|
|
2192
2194
|
} else if (run.status !== "running") {
|
|
2193
2195
|
const result = removeRun(store, id, {
|
package/diagnostics.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as childProcess from "node:child_process";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { AgentRuntime } from "./agents.js";
|
|
4
|
+
import type { CommandRunState } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const RUNNER_DIAGNOSTIC_CUSTOM_TYPE = "subagent-runner-diagnostic";
|
|
7
|
+
export const RUNNER_DIAGNOSTIC_SCHEMA_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
export type RunnerDiagnosticEventName =
|
|
10
|
+
| "spawn"
|
|
11
|
+
| "abort_received"
|
|
12
|
+
| "kill_intent"
|
|
13
|
+
| "kill_result"
|
|
14
|
+
| "process_error"
|
|
15
|
+
| "exit"
|
|
16
|
+
| "close"
|
|
17
|
+
| "settled"
|
|
18
|
+
| "session_shutdown";
|
|
19
|
+
|
|
20
|
+
export interface RunnerDiagnosticEvent {
|
|
21
|
+
event: RunnerDiagnosticEventName;
|
|
22
|
+
runtime?: AgentRuntime;
|
|
23
|
+
parentPid?: number;
|
|
24
|
+
parentProcessGroupId?: number;
|
|
25
|
+
childPid?: number;
|
|
26
|
+
childParentPid?: number;
|
|
27
|
+
childProcessGroupId?: number;
|
|
28
|
+
processGroupMode?: "inherited";
|
|
29
|
+
code?: number | null;
|
|
30
|
+
signal?: NodeJS.Signals | null;
|
|
31
|
+
cause?: string;
|
|
32
|
+
abortReason?: unknown;
|
|
33
|
+
killSent?: boolean;
|
|
34
|
+
settleReason?: string;
|
|
35
|
+
stopReason?: string;
|
|
36
|
+
activeRunIds?: number[];
|
|
37
|
+
sessionShutdownReason?: string;
|
|
38
|
+
error?: DiagnosticError;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type RunnerDiagnosticSink = (event: RunnerDiagnosticEvent) => void;
|
|
42
|
+
|
|
43
|
+
interface DiagnosticError {
|
|
44
|
+
name?: string;
|
|
45
|
+
message: string;
|
|
46
|
+
stack?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ProcessIdentity {
|
|
50
|
+
pid: number;
|
|
51
|
+
parentPid?: number;
|
|
52
|
+
processGroupId?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function asFiniteInteger(value: string | undefined): number | undefined {
|
|
56
|
+
if (!value) return undefined;
|
|
57
|
+
const parsed = Number(value);
|
|
58
|
+
return Number.isInteger(parsed) ? parsed : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Best-effort POSIX process identity lookup. Diagnostics must never affect execution. */
|
|
62
|
+
export function readProcessIdentity(pid: number | undefined): ProcessIdentity | undefined {
|
|
63
|
+
if (!pid || !Number.isInteger(pid) || pid <= 0) return undefined;
|
|
64
|
+
if (process.platform === "win32") return { pid };
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const result = childProcess.spawnSync("ps", ["-o", "pid=,ppid=,pgid=", "-p", String(pid)], {
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
70
|
+
});
|
|
71
|
+
const fields = result.stdout?.trim().split(/\s+/);
|
|
72
|
+
if (!fields || fields.length < 3) return { pid };
|
|
73
|
+
return {
|
|
74
|
+
pid: asFiniteInteger(fields[0]) ?? pid,
|
|
75
|
+
parentPid: asFiniteInteger(fields[1]),
|
|
76
|
+
processGroupId: asFiniteInteger(fields[2]),
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return { pid };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function serializeDiagnosticValue(value: unknown): unknown {
|
|
84
|
+
if (value instanceof Error) {
|
|
85
|
+
return {
|
|
86
|
+
name: value.name,
|
|
87
|
+
message: value.message,
|
|
88
|
+
stack: value.stack,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (value === undefined || value === null) return value;
|
|
92
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(JSON.stringify(value));
|
|
96
|
+
} catch {
|
|
97
|
+
return String(value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function toDiagnosticError(error: unknown): DiagnosticError {
|
|
102
|
+
if (error instanceof Error) {
|
|
103
|
+
return { name: error.name, message: error.message, stack: error.stack };
|
|
104
|
+
}
|
|
105
|
+
return { message: String(error) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Emit best-effort diagnostics without allowing logging failures to affect a run. */
|
|
109
|
+
export function emitRunnerDiagnostic(sink: RunnerDiagnosticSink | undefined, event: RunnerDiagnosticEvent): void {
|
|
110
|
+
if (!sink) return;
|
|
111
|
+
try {
|
|
112
|
+
sink(event);
|
|
113
|
+
} catch {
|
|
114
|
+
// Diagnostics must never alter subagent execution or completion delivery.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function appendRunnerDiagnostic(pi: Pick<ExtensionAPI, "appendEntry">, data: Record<string, unknown>): void {
|
|
119
|
+
try {
|
|
120
|
+
pi.appendEntry(RUNNER_DIAGNOSTIC_CUSTOM_TYPE, {
|
|
121
|
+
schemaVersion: RUNNER_DIAGNOSTIC_SCHEMA_VERSION,
|
|
122
|
+
recordedAt: new Date().toISOString(),
|
|
123
|
+
...data,
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
// Session teardown may invalidate the old extension runtime.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createRunDiagnosticSink(
|
|
131
|
+
pi: Pick<ExtensionAPI, "appendEntry">,
|
|
132
|
+
runState: CommandRunState,
|
|
133
|
+
): RunnerDiagnosticSink {
|
|
134
|
+
return (event) => {
|
|
135
|
+
appendRunnerDiagnostic(pi, {
|
|
136
|
+
runId: runState.id,
|
|
137
|
+
agent: runState.agent,
|
|
138
|
+
batchId: runState.batchId,
|
|
139
|
+
pipelineId: runState.pipelineId,
|
|
140
|
+
pipelineStepIndex: runState.pipelineStepIndex,
|
|
141
|
+
...event,
|
|
142
|
+
abortReason: serializeDiagnosticValue(event.abortReason),
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
}
|
package/lifecycle.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { HANG_TIMEOUT_MS } from "./constants.js";
|
|
10
|
+
import { appendRunnerDiagnostic } from "./diagnostics.js";
|
|
10
11
|
import { getSessionFileMtimeMs, readPersistedSessionSnapshot } from "./persisted-session.js";
|
|
11
12
|
import { getLastNonEmptyLine } from "./runner.js";
|
|
12
13
|
import type { SubagentStore } from "./store.js";
|
|
@@ -59,6 +60,16 @@ export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, rea
|
|
|
59
60
|
for (const [runId, run] of store.commandRuns) activeRuns.set(runId, run);
|
|
60
61
|
for (const [runId, entry] of store.globalLiveRuns) activeRuns.set(runId, entry.runState);
|
|
61
62
|
|
|
63
|
+
const activeRunIds = Array.from(activeRuns)
|
|
64
|
+
.filter(([, run]) => run.status === "running")
|
|
65
|
+
.map(([runId]) => runId);
|
|
66
|
+
appendRunnerDiagnostic(pi, {
|
|
67
|
+
event: "session_shutdown",
|
|
68
|
+
parentPid: process.pid,
|
|
69
|
+
sessionShutdownReason: reason,
|
|
70
|
+
activeRunIds,
|
|
71
|
+
});
|
|
72
|
+
|
|
62
73
|
for (const [runId, run] of activeRuns) {
|
|
63
74
|
if (run.status !== "running") continue;
|
|
64
75
|
const message = `Aborted because the parent pi session ${reason} is shutting down.`;
|
|
@@ -70,7 +81,7 @@ export function shutdownSubagentRuns(store: SubagentStore, pi: ExtensionAPI, rea
|
|
|
70
81
|
run.removed = true;
|
|
71
82
|
|
|
72
83
|
const controller = run.abortController ?? store.globalLiveRuns.get(runId)?.abortController;
|
|
73
|
-
controller?.abort();
|
|
84
|
+
controller?.abort({ source: "session_shutdown", reason, runId, activeRunIds });
|
|
74
85
|
run.abortController = undefined;
|
|
75
86
|
|
|
76
87
|
if (run.deliveryMode === "humanOnly") continue;
|
|
@@ -141,7 +152,7 @@ export function checkForHungRuns(store: SubagentStore, _pi: ExtensionAPI): void
|
|
|
141
152
|
run.autoAbortReason = reason;
|
|
142
153
|
|
|
143
154
|
if (controller) {
|
|
144
|
-
controller.abort();
|
|
155
|
+
controller.abort({ source: "hang_timeout", runId, idleMs, reason });
|
|
145
156
|
}
|
|
146
157
|
}
|
|
147
158
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryan_nookpi/pi-extension-subagent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Asynchronous subagent delegation for pi with run, batch, chain, and continuation workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"config.ts",
|
|
32
32
|
"constants.ts",
|
|
33
33
|
"context-limits.ts",
|
|
34
|
+
"diagnostics.ts",
|
|
34
35
|
"display-task.ts",
|
|
35
36
|
"escalation.ts",
|
|
36
37
|
"format.ts",
|
package/run-utils.ts
CHANGED
|
@@ -92,7 +92,7 @@ export function removeRun(store: SubagentStore, runId: number, options: RemoveRu
|
|
|
92
92
|
const reason = options.reason ?? "Aborting by remove...";
|
|
93
93
|
run.lastLine = reason;
|
|
94
94
|
run.lastOutput = reason;
|
|
95
|
-
controller.abort();
|
|
95
|
+
controller.abort({ source: "remove_run", runId, reason, removalReason: options.removalReason });
|
|
96
96
|
aborted = true;
|
|
97
97
|
}
|
|
98
98
|
|
package/runner.ts
CHANGED
|
@@ -17,6 +17,13 @@ import {
|
|
|
17
17
|
} from "./claude-stream-parser.js";
|
|
18
18
|
import { resolveClaudeRuntimeMode } from "./config.js";
|
|
19
19
|
import { CONTEXT_GUARD_SIGNATURE, resolveContextGuardCeiling, shouldTripContextGuard } from "./context-limits.js";
|
|
20
|
+
import {
|
|
21
|
+
emitRunnerDiagnostic,
|
|
22
|
+
type RunnerDiagnosticSink,
|
|
23
|
+
readProcessIdentity,
|
|
24
|
+
serializeDiagnosticValue,
|
|
25
|
+
toDiagnosticError,
|
|
26
|
+
} from "./diagnostics.js";
|
|
20
27
|
import { formatToolCallPlain } from "./format.js";
|
|
21
28
|
import {
|
|
22
29
|
extractActivityPreviewFromTextDelta,
|
|
@@ -39,6 +46,7 @@ export interface RunSingleAgentSessionConfig {
|
|
|
39
46
|
resumeSessionId?: string;
|
|
40
47
|
sidecarSessionFile?: string;
|
|
41
48
|
persistedSessionBaseOffset?: number;
|
|
49
|
+
onDiagnostic?: RunnerDiagnosticSink;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
function isUuidLikeSessionId(value: string | undefined): boolean {
|
|
@@ -199,6 +207,7 @@ export async function runSingleAgent(
|
|
|
199
207
|
const resumeSessionId = normalizedSessionConfig.resumeSessionId;
|
|
200
208
|
const sidecarSessionFile = normalizedSessionConfig.sidecarSessionFile ?? sessionFile;
|
|
201
209
|
const persistedSessionBaseOffset = normalizedSessionConfig.persistedSessionBaseOffset ?? 0;
|
|
210
|
+
const onDiagnostic = normalizedSessionConfig.onDiagnostic;
|
|
202
211
|
const agent = agents.find((a) => a.name === agentName);
|
|
203
212
|
|
|
204
213
|
if (!agent) {
|
|
@@ -240,6 +249,7 @@ export async function runSingleAgent(
|
|
|
240
249
|
makeDetails,
|
|
241
250
|
resumeSessionId,
|
|
242
251
|
sidecarSessionFile,
|
|
252
|
+
onDiagnostic,
|
|
243
253
|
);
|
|
244
254
|
}
|
|
245
255
|
|
|
@@ -254,6 +264,7 @@ export async function runSingleAgent(
|
|
|
254
264
|
makeDetails,
|
|
255
265
|
sessionFile,
|
|
256
266
|
persistedSessionBaseOffset,
|
|
267
|
+
onDiagnostic,
|
|
257
268
|
);
|
|
258
269
|
}
|
|
259
270
|
|
|
@@ -267,6 +278,7 @@ async function runClaudeAgent(
|
|
|
267
278
|
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
268
279
|
resumeSessionId?: string,
|
|
269
280
|
sidecarSessionFile?: string,
|
|
281
|
+
onDiagnostic?: RunnerDiagnosticSink,
|
|
270
282
|
): Promise<SingleResult> {
|
|
271
283
|
try {
|
|
272
284
|
validateClaudeRuntimeModel(agent.model);
|
|
@@ -315,6 +327,10 @@ async function runClaudeAgent(
|
|
|
315
327
|
let completionMarkerWritten = false;
|
|
316
328
|
let wasAborted = false;
|
|
317
329
|
|
|
330
|
+
const diagnose = (event: Parameters<RunnerDiagnosticSink>[0]) => {
|
|
331
|
+
emitRunnerDiagnostic(onDiagnostic, { runtime: "claude", ...event });
|
|
332
|
+
};
|
|
333
|
+
|
|
318
334
|
const emitUpdate = () => {
|
|
319
335
|
if (!onUpdate) return;
|
|
320
336
|
const text = streamState.liveText ?? getFinalOutput(streamState.messages) ?? "(running...)";
|
|
@@ -362,9 +378,57 @@ async function runClaudeAgent(
|
|
|
362
378
|
let exitFallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
363
379
|
let resultLingerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
364
380
|
let lastExitCode = 0;
|
|
381
|
+
let lastProcessSignal: NodeJS.Signals | null = null;
|
|
365
382
|
let settleReason = "unknown";
|
|
366
383
|
let unparsedStdoutCount = 0;
|
|
367
384
|
const unparsedStdoutTail: string[] = [];
|
|
385
|
+
const parentIdentity = onDiagnostic ? readProcessIdentity(process.pid) : undefined;
|
|
386
|
+
const childIdentity = onDiagnostic ? readProcessIdentity(proc.pid) : undefined;
|
|
387
|
+
|
|
388
|
+
diagnose({
|
|
389
|
+
event: "spawn",
|
|
390
|
+
parentPid: process.pid,
|
|
391
|
+
parentProcessGroupId: parentIdentity?.processGroupId,
|
|
392
|
+
childPid: proc.pid,
|
|
393
|
+
childParentPid: childIdentity?.parentPid,
|
|
394
|
+
childProcessGroupId: childIdentity?.processGroupId,
|
|
395
|
+
processGroupMode: "inherited",
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const terminateProcess = (terminationSignal: NodeJS.Signals, cause: string): boolean => {
|
|
399
|
+
diagnose({
|
|
400
|
+
event: "kill_intent",
|
|
401
|
+
childPid: proc.pid,
|
|
402
|
+
signal: terminationSignal,
|
|
403
|
+
cause,
|
|
404
|
+
stopReason: streamState.stopReason,
|
|
405
|
+
});
|
|
406
|
+
try {
|
|
407
|
+
const killSent = proc.kill(terminationSignal);
|
|
408
|
+
diagnose({
|
|
409
|
+
event: "kill_result",
|
|
410
|
+
childPid: proc.pid,
|
|
411
|
+
signal: terminationSignal,
|
|
412
|
+
cause,
|
|
413
|
+
killSent,
|
|
414
|
+
});
|
|
415
|
+
return killSent;
|
|
416
|
+
} catch (error) {
|
|
417
|
+
diagnose({
|
|
418
|
+
event: "process_error",
|
|
419
|
+
childPid: proc.pid,
|
|
420
|
+
cause: `kill:${cause}`,
|
|
421
|
+
error: toDiagnosticError(error),
|
|
422
|
+
});
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const scheduleKillEscalation = (cause: string) => {
|
|
428
|
+
setTimeout(() => {
|
|
429
|
+
if (!procExited && proc.exitCode === null) terminateProcess("SIGKILL", `${cause}:grace_timeout`);
|
|
430
|
+
}, 5000);
|
|
431
|
+
};
|
|
368
432
|
|
|
369
433
|
const processLine = (line: string) => {
|
|
370
434
|
if (!line.trim()) return;
|
|
@@ -440,6 +504,14 @@ async function runClaudeAgent(
|
|
|
440
504
|
stderrBuf += `[runner] ${tail}\n`;
|
|
441
505
|
}
|
|
442
506
|
}
|
|
507
|
+
diagnose({
|
|
508
|
+
event: "settled",
|
|
509
|
+
childPid: proc.pid,
|
|
510
|
+
code: finalCode,
|
|
511
|
+
signal: lastProcessSignal,
|
|
512
|
+
settleReason,
|
|
513
|
+
stopReason: wasAborted ? "aborted" : streamState.stopReason,
|
|
514
|
+
});
|
|
443
515
|
resolve(finalCode);
|
|
444
516
|
};
|
|
445
517
|
|
|
@@ -452,10 +524,8 @@ async function runClaudeAgent(
|
|
|
452
524
|
if (resultLingerTimer) clearTimeout(resultLingerTimer);
|
|
453
525
|
resultLingerTimer = setTimeout(() => {
|
|
454
526
|
if (settled || procExited) return;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
458
|
-
}, 5000);
|
|
527
|
+
terminateProcess("SIGTERM", "result_linger_timeout");
|
|
528
|
+
scheduleKillEscalation("result_linger_timeout");
|
|
459
529
|
settleReason = "result_linger_timeout";
|
|
460
530
|
resolveOnce(0);
|
|
461
531
|
}, 3000);
|
|
@@ -472,9 +542,11 @@ async function runClaudeAgent(
|
|
|
472
542
|
stderrBuf += data.toString();
|
|
473
543
|
});
|
|
474
544
|
|
|
475
|
-
proc.on("exit", (code) => {
|
|
545
|
+
proc.on("exit", (code, terminationSignal) => {
|
|
476
546
|
procExited = true;
|
|
477
547
|
lastExitCode = code ?? 0;
|
|
548
|
+
lastProcessSignal = terminationSignal;
|
|
549
|
+
diagnose({ event: "exit", childPid: proc.pid, code, signal: terminationSignal });
|
|
478
550
|
if (streamState.resultReceived) {
|
|
479
551
|
settleReason = "exit_after_result";
|
|
480
552
|
resolveOnce(0);
|
|
@@ -486,8 +558,10 @@ async function runClaudeAgent(
|
|
|
486
558
|
}, 1500);
|
|
487
559
|
});
|
|
488
560
|
|
|
489
|
-
proc.on("close", (code) => {
|
|
561
|
+
proc.on("close", (code, terminationSignal) => {
|
|
490
562
|
procExited = true;
|
|
563
|
+
lastProcessSignal = terminationSignal ?? lastProcessSignal;
|
|
564
|
+
diagnose({ event: "close", childPid: proc.pid, code, signal: terminationSignal });
|
|
491
565
|
if (!settled) {
|
|
492
566
|
settleReason = "close";
|
|
493
567
|
resolveOnce(streamState.resultReceived ? 0 : (code ?? lastExitCode ?? 0));
|
|
@@ -497,6 +571,12 @@ async function runClaudeAgent(
|
|
|
497
571
|
proc.on("error", (error) => {
|
|
498
572
|
procExited = true;
|
|
499
573
|
stderrBuf += `[runner] process error: ${error?.message || String(error)}\n`;
|
|
574
|
+
diagnose({
|
|
575
|
+
event: "process_error",
|
|
576
|
+
childPid: proc.pid,
|
|
577
|
+
cause: "child_process_event",
|
|
578
|
+
error: toDiagnosticError(error),
|
|
579
|
+
});
|
|
500
580
|
settleReason = "process_error";
|
|
501
581
|
resolveOnce(1);
|
|
502
582
|
});
|
|
@@ -505,10 +585,13 @@ async function runClaudeAgent(
|
|
|
505
585
|
const killProc = () => {
|
|
506
586
|
wasAborted = true;
|
|
507
587
|
settleReason = "aborted_by_signal";
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
588
|
+
diagnose({
|
|
589
|
+
event: "abort_received",
|
|
590
|
+
childPid: proc.pid,
|
|
591
|
+
abortReason: serializeDiagnosticValue(signal.reason),
|
|
592
|
+
});
|
|
593
|
+
terminateProcess("SIGTERM", "abort_signal");
|
|
594
|
+
scheduleKillEscalation("abort_signal");
|
|
512
595
|
};
|
|
513
596
|
if (signal.aborted) killProc();
|
|
514
597
|
else signal.addEventListener("abort", killProc, { once: true });
|
|
@@ -549,6 +632,7 @@ async function runPiAgent(
|
|
|
549
632
|
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
550
633
|
sessionFile?: string,
|
|
551
634
|
persistedSessionBaseOffset = 0,
|
|
635
|
+
onDiagnostic?: RunnerDiagnosticSink,
|
|
552
636
|
): Promise<SingleResult> {
|
|
553
637
|
const args: string[] = ["--mode", "json", "-p"];
|
|
554
638
|
if (sessionFile) args.push("--session", sessionFile);
|
|
@@ -578,6 +662,10 @@ async function runPiAgent(
|
|
|
578
662
|
let contextGuardTripped = false;
|
|
579
663
|
let peakContextTokens = 0;
|
|
580
664
|
|
|
665
|
+
const diagnose = (event: Parameters<RunnerDiagnosticSink>[0]) => {
|
|
666
|
+
emitRunnerDiagnostic(onDiagnostic, { runtime: "pi", ...event });
|
|
667
|
+
};
|
|
668
|
+
|
|
581
669
|
const emitUpdate = () => {
|
|
582
670
|
if (onUpdate) {
|
|
583
671
|
onUpdate({
|
|
@@ -617,11 +705,59 @@ async function runPiAgent(
|
|
|
617
705
|
let terminalMessageFallbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
618
706
|
let sessionPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
619
707
|
let lastExitCode = 0;
|
|
708
|
+
let lastProcessSignal: NodeJS.Signals | null = null;
|
|
620
709
|
let lastEventAt = Date.now();
|
|
621
710
|
let sawAgentEnd = false;
|
|
622
711
|
let settleReason = "unknown";
|
|
623
712
|
let unparsedStdoutCount = 0;
|
|
624
713
|
const unparsedStdoutTail: string[] = [];
|
|
714
|
+
const parentIdentity = onDiagnostic ? readProcessIdentity(process.pid) : undefined;
|
|
715
|
+
const childIdentity = onDiagnostic ? readProcessIdentity(proc.pid) : undefined;
|
|
716
|
+
|
|
717
|
+
diagnose({
|
|
718
|
+
event: "spawn",
|
|
719
|
+
parentPid: process.pid,
|
|
720
|
+
parentProcessGroupId: parentIdentity?.processGroupId,
|
|
721
|
+
childPid: proc.pid,
|
|
722
|
+
childParentPid: childIdentity?.parentPid,
|
|
723
|
+
childProcessGroupId: childIdentity?.processGroupId,
|
|
724
|
+
processGroupMode: "inherited",
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const terminateProcess = (terminationSignal: NodeJS.Signals, cause: string): boolean => {
|
|
728
|
+
diagnose({
|
|
729
|
+
event: "kill_intent",
|
|
730
|
+
childPid: proc.pid,
|
|
731
|
+
signal: terminationSignal,
|
|
732
|
+
cause,
|
|
733
|
+
stopReason: currentResult.stopReason,
|
|
734
|
+
});
|
|
735
|
+
try {
|
|
736
|
+
const killSent = proc.kill(terminationSignal);
|
|
737
|
+
diagnose({
|
|
738
|
+
event: "kill_result",
|
|
739
|
+
childPid: proc.pid,
|
|
740
|
+
signal: terminationSignal,
|
|
741
|
+
cause,
|
|
742
|
+
killSent,
|
|
743
|
+
});
|
|
744
|
+
return killSent;
|
|
745
|
+
} catch (error) {
|
|
746
|
+
diagnose({
|
|
747
|
+
event: "process_error",
|
|
748
|
+
childPid: proc.pid,
|
|
749
|
+
cause: `kill:${cause}`,
|
|
750
|
+
error: toDiagnosticError(error),
|
|
751
|
+
});
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const scheduleKillEscalation = (cause: string) => {
|
|
757
|
+
setTimeout(() => {
|
|
758
|
+
if (!procExited && proc.exitCode === null) terminateProcess("SIGKILL", `${cause}:grace_timeout`);
|
|
759
|
+
}, 5000);
|
|
760
|
+
};
|
|
625
761
|
|
|
626
762
|
const syncFromPersistedSession = (allowResolve: boolean): boolean => {
|
|
627
763
|
if (!sessionFile) return false;
|
|
@@ -640,11 +776,12 @@ async function runPiAgent(
|
|
|
640
776
|
snapshot.completionMarker?.exitCode ??
|
|
641
777
|
(snapshot.terminalStopReason === "error" || snapshot.terminalStopReason === "aborted" ? 1 : 0);
|
|
642
778
|
writeCompletionMarkerOnce(forcedCode);
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
779
|
+
const fallbackReason = snapshot.completionMarker
|
|
780
|
+
? "session_done_marker_fallback"
|
|
781
|
+
: "session_terminal_message_fallback";
|
|
782
|
+
terminateProcess("SIGTERM", fallbackReason);
|
|
783
|
+
scheduleKillEscalation(fallbackReason);
|
|
784
|
+
settleReason = fallbackReason;
|
|
648
785
|
resolveOnce(forcedCode);
|
|
649
786
|
return true;
|
|
650
787
|
};
|
|
@@ -776,10 +913,8 @@ async function runPiAgent(
|
|
|
776
913
|
currentResult.errorMessage =
|
|
777
914
|
`${CONTEXT_GUARD_SIGNATURE} stopped at ${peakContextTokens} tokens ` +
|
|
778
915
|
`(ceiling ${contextGuardCeiling}) to preserve partial findings before provider context overflow.`;
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
782
|
-
}, 5000);
|
|
916
|
+
terminateProcess("SIGTERM", "context_guard");
|
|
917
|
+
scheduleKillEscalation("context_guard");
|
|
783
918
|
settleReason = "context_guard";
|
|
784
919
|
resolveOnce(1);
|
|
785
920
|
return;
|
|
@@ -858,6 +993,14 @@ async function runPiAgent(
|
|
|
858
993
|
);
|
|
859
994
|
}
|
|
860
995
|
}
|
|
996
|
+
diagnose({
|
|
997
|
+
event: "settled",
|
|
998
|
+
childPid: proc.pid,
|
|
999
|
+
code: finalCode,
|
|
1000
|
+
signal: lastProcessSignal,
|
|
1001
|
+
settleReason,
|
|
1002
|
+
stopReason: wasAborted ? "aborted" : currentResult.stopReason,
|
|
1003
|
+
});
|
|
861
1004
|
resolve(finalCode);
|
|
862
1005
|
};
|
|
863
1006
|
|
|
@@ -870,10 +1013,8 @@ async function runPiAgent(
|
|
|
870
1013
|
|
|
871
1014
|
const forcedCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0;
|
|
872
1015
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
876
|
-
}, 5000);
|
|
1016
|
+
terminateProcess("SIGTERM", "terminal_message_fallback_timeout");
|
|
1017
|
+
scheduleKillEscalation("terminal_message_fallback_timeout");
|
|
877
1018
|
|
|
878
1019
|
settleReason = "terminal_message_fallback_timeout";
|
|
879
1020
|
resolveOnce(forcedCode);
|
|
@@ -894,10 +1035,8 @@ async function runPiAgent(
|
|
|
894
1035
|
|
|
895
1036
|
const forcedCode = currentResult.stopReason === "error" || currentResult.stopReason === "aborted" ? 1 : 0;
|
|
896
1037
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
if (!procExited && proc.exitCode === null) proc.kill("SIGKILL");
|
|
900
|
-
}, 5000);
|
|
1038
|
+
terminateProcess("SIGTERM", "agent_end_fallback_timeout");
|
|
1039
|
+
scheduleKillEscalation("agent_end_fallback_timeout");
|
|
901
1040
|
|
|
902
1041
|
settleReason = "agent_end_fallback_timeout";
|
|
903
1042
|
resolveOnce(forcedCode);
|
|
@@ -921,9 +1060,11 @@ async function runPiAgent(
|
|
|
921
1060
|
currentResult.stderr += data.toString();
|
|
922
1061
|
});
|
|
923
1062
|
|
|
924
|
-
proc.on("exit", (code) => {
|
|
1063
|
+
proc.on("exit", (code, terminationSignal) => {
|
|
925
1064
|
procExited = true;
|
|
926
1065
|
lastExitCode = code ?? 0;
|
|
1066
|
+
lastProcessSignal = terminationSignal;
|
|
1067
|
+
diagnose({ event: "exit", childPid: proc.pid, code, signal: terminationSignal });
|
|
927
1068
|
// In rare cases stdout/stderr pipes may stay open after process exit.
|
|
928
1069
|
// Use a short fallback so runs cannot stay "running" forever.
|
|
929
1070
|
exitFallbackTimer = setTimeout(() => {
|
|
@@ -932,8 +1073,10 @@ async function runPiAgent(
|
|
|
932
1073
|
}, 1500);
|
|
933
1074
|
});
|
|
934
1075
|
|
|
935
|
-
proc.on("close", (code) => {
|
|
1076
|
+
proc.on("close", (code, terminationSignal) => {
|
|
936
1077
|
procExited = true;
|
|
1078
|
+
lastProcessSignal = terminationSignal ?? lastProcessSignal;
|
|
1079
|
+
diagnose({ event: "close", childPid: proc.pid, code, signal: terminationSignal });
|
|
937
1080
|
settleReason = "close";
|
|
938
1081
|
resolveOnce(code ?? lastExitCode ?? 0);
|
|
939
1082
|
});
|
|
@@ -941,6 +1084,12 @@ async function runPiAgent(
|
|
|
941
1084
|
proc.on("error", (error) => {
|
|
942
1085
|
procExited = true;
|
|
943
1086
|
appendStderrDiagnostic(currentResult, `process error: ${error?.message || String(error)}`);
|
|
1087
|
+
diagnose({
|
|
1088
|
+
event: "process_error",
|
|
1089
|
+
childPid: proc.pid,
|
|
1090
|
+
cause: "child_process_event",
|
|
1091
|
+
error: toDiagnosticError(error),
|
|
1092
|
+
});
|
|
944
1093
|
settleReason = "process_error";
|
|
945
1094
|
resolveOnce(1);
|
|
946
1095
|
});
|
|
@@ -949,10 +1098,13 @@ async function runPiAgent(
|
|
|
949
1098
|
const killProc = () => {
|
|
950
1099
|
wasAborted = true;
|
|
951
1100
|
settleReason = "aborted_by_signal";
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1101
|
+
diagnose({
|
|
1102
|
+
event: "abort_received",
|
|
1103
|
+
childPid: proc.pid,
|
|
1104
|
+
abortReason: serializeDiagnosticValue(signal.reason),
|
|
1105
|
+
});
|
|
1106
|
+
terminateProcess("SIGTERM", "abort_signal");
|
|
1107
|
+
scheduleKillEscalation("abort_signal");
|
|
956
1108
|
};
|
|
957
1109
|
if (signal.aborted) killProc();
|
|
958
1110
|
else signal.addEventListener("abort", killProc, { once: true });
|
package/tool-execute.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
SUBAGENT_STRONG_WAIT_MESSAGE,
|
|
23
23
|
} from "./constants.js";
|
|
24
24
|
import { isContextOverflowText } from "./context-limits.js";
|
|
25
|
+
import { createRunDiagnosticSink } from "./diagnostics.js";
|
|
25
26
|
import {
|
|
26
27
|
buildSubagentDisplayTaskFallback,
|
|
27
28
|
createDisplayTaskRefreshToken,
|
|
@@ -956,7 +957,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
|
|
|
956
957
|
|
|
957
958
|
run.lastLine = "Aborting by subagent tool...";
|
|
958
959
|
run.lastOutput = run.lastLine;
|
|
959
|
-
abortCtrl.abort();
|
|
960
|
+
abortCtrl.abort({ source: "subagent_tool_abort", runId });
|
|
960
961
|
aborting.push(runId);
|
|
961
962
|
}
|
|
962
963
|
|
|
@@ -1080,7 +1081,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
|
|
|
1080
1081
|
clearParentAbortListener(runId);
|
|
1081
1082
|
if (!signal || shouldRunAsync) return;
|
|
1082
1083
|
|
|
1083
|
-
const abortFromParent = () => abortController.abort();
|
|
1084
|
+
const abortFromParent = () => abortController.abort({ source: "parent_tool_abort", runId });
|
|
1084
1085
|
if (signal.aborted) {
|
|
1085
1086
|
abortFromParent();
|
|
1086
1087
|
return;
|
|
@@ -1201,6 +1202,7 @@ export function createSubagentToolExecute(pi: ExtensionAPI, store: SubagentStore
|
|
|
1201
1202
|
resumeSessionId: runState.claudeSessionId,
|
|
1202
1203
|
sidecarSessionFile: runState.sessionFile,
|
|
1203
1204
|
persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
|
|
1205
|
+
onDiagnostic: createRunDiagnosticSink(pi, runState),
|
|
1204
1206
|
},
|
|
1205
1207
|
),
|
|
1206
1208
|
).then((result) => finalizeRunState(runState, result));
|