pi-subagents 0.65.1 → 0.67.0
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 +123 -0
- package/README.md +5 -4
- package/agents/evidence-auditor.md +34 -0
- package/agents/researcher.md +23 -13
- package/agents/reviewer.md +3 -2
- package/docs/agents.md +20 -3
- package/docs/configuration.md +25 -5
- package/docs/extension-api.md +124 -18
- package/docs/missions.md +8 -0
- package/docs/models.md +59 -2
- package/docs/observability.md +46 -6
- package/docs/standalone-background.md +49 -0
- package/docs/tool-reference.md +20 -10
- package/docs/watchdog.md +35 -4
- package/docs/workflows.md +40 -19
- package/inspector-runner.mjs +2 -2
- package/package.json +2 -1
- package/prompts/parallel-review.md +1 -1
- package/{runner-server-preload.mjs → runner-peer-preload.mjs} +8 -3
- package/skills/pi-subagents/SKILL.md +14 -0
- package/skills/pi-subagents/references/execution-controls.md +20 -5
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
- package/src/agents/advertised-agent-prompt.ts +94 -0
- package/src/agents/agent-management.ts +14 -1
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +14 -0
- package/src/agents/builtin-names.ts +1 -0
- package/src/api/delegation.ts +4 -0
- package/src/api/preflight.ts +76 -45
- package/src/api/shared-types.ts +3 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/fanout-child.ts +63 -4
- package/src/extension/index.ts +58 -8
- package/src/extension/public-execution.ts +4 -3
- package/src/extension/rpc.ts +8 -21
- package/src/extension/schemas.ts +71 -80
- package/src/extension/tool-description.ts +29 -81
- package/src/inspectors/actions.ts +148 -0
- package/src/inspectors/ghostty/actions.ts +74 -0
- package/src/inspectors/ghostty/plugin.ts +17 -0
- package/src/inspectors/herdr/actions.ts +99 -179
- package/src/inspectors/herdr/plugin.ts +20 -0
- package/src/inspectors/herdr/project-panes.ts +1 -1
- package/src/inspectors/{herdr/inspector-runner.ts → inspector-runner.ts} +12 -12
- package/src/inspectors/plugins.ts +8 -0
- package/src/inspectors/{herdr/session-roots-codec.ts → session-roots-codec.ts} +3 -14
- package/src/inspectors/types.ts +51 -0
- package/src/intercom/intercom-bridge.ts +50 -8
- package/src/intercom/native-supervisor-channel.ts +104 -67
- package/src/runs/background/active-async-capacity.ts +22 -18
- package/src/runs/background/async-execution.ts +45 -56
- package/src/runs/background/async-job-tracker.ts +35 -3
- package/src/runs/background/async-resume.ts +5 -9
- package/src/runs/background/async-status-snapshot.ts +10 -12
- package/src/runs/background/async-status.ts +17 -9
- package/src/runs/background/auto-drain.ts +44 -30
- package/src/runs/background/binary-bootstrap.ts +33 -0
- package/src/runs/background/chain-root-attachment.ts +8 -0
- package/src/runs/background/control-channel.ts +78 -44
- package/src/runs/background/fleet-view.ts +30 -2
- package/src/runs/background/notify.ts +117 -13
- package/src/runs/background/owned-process-tree.ts +35 -8
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/run-child-session.ts +121 -36
- package/src/runs/background/run-status.ts +78 -5
- package/src/runs/background/runner-aliases.ts +28 -9
- package/src/runs/background/runner-child-launch.ts +88 -0
- package/src/runs/background/runner-child-sessions.ts +5 -4
- package/src/runs/background/scheduled-runs.ts +40 -13
- package/src/runs/background/stale-run-reconciler.ts +3 -1
- package/src/runs/background/steering.ts +20 -2
- package/src/runs/background/subagent-runner.ts +458 -239
- package/src/runs/background/subagent-wait.ts +54 -8
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/background/wait-tool.ts +1 -1
- package/src/runs/foreground/async-steering-action.ts +37 -7
- package/src/runs/foreground/execution.ts +145 -56
- package/src/runs/foreground/prompt-audit.ts +3 -1
- package/src/runs/foreground/subagent-executor.ts +584 -297
- package/src/runs/foreground/workflow-detach-reconcile.ts +10 -5
- package/src/runs/foreground/workflow-foreground-steering.ts +57 -2
- package/src/runs/shared/acceptance.ts +7 -4
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/async-status-projection.ts +51 -47
- package/src/runs/shared/capability-ceiling.ts +2 -0
- package/src/runs/shared/child-hooks.ts +167 -3
- package/src/runs/shared/child-launch.ts +28 -13
- package/src/runs/shared/child-lifecycle.ts +6 -3
- package/src/runs/shared/child-runtime-config.ts +3 -1
- package/src/runs/shared/child-session.ts +75 -8
- package/src/runs/shared/child-tool-plan.ts +124 -5
- package/src/runs/shared/completion-evidence.ts +2 -2
- package/src/runs/shared/completion-guard.ts +6 -3
- package/src/runs/shared/effective-system-prompt.ts +33 -0
- package/src/runs/shared/external-cli-runner.ts +9 -7
- package/src/runs/shared/host-step-status.ts +11 -11
- package/src/runs/shared/llm-intent-arbiter.ts +21 -11
- package/src/runs/shared/model-fallback.ts +12 -6
- package/src/runs/shared/nested-events.ts +5 -5
- package/src/runs/shared/orca-progress-tabs.ts +7 -1
- package/src/runs/shared/parallel-handoff.ts +57 -12
- package/src/runs/shared/parallel-utils.ts +2 -2
- package/src/runs/shared/pi-spawn.ts +10 -0
- package/src/runs/shared/readonly-drain-observation.ts +42 -0
- package/src/runs/shared/readonly-model-continuation.ts +69 -0
- package/src/runs/shared/readonly-session-evidence.ts +307 -0
- package/src/runs/shared/run-fanout-budget.ts +8 -8
- package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
- package/src/runs/shared/subagent-prompt-runtime.ts +20 -4
- package/src/runs/shared/task-intent.ts +46 -13
- package/src/runs/shared/workflow-async-child-guidance.ts +18 -0
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +366 -208
- package/src/shared/fork-context.ts +15 -72
- package/src/shared/launch-contract.ts +65 -2
- package/src/shared/opencode-session-headers.ts +30 -0
- package/src/shared/types.ts +85 -61
- package/src/shared/utils.ts +7 -2
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/slash/delegation-adapters.ts +3 -1
- package/src/slash/delegation-request.ts +14 -0
- package/src/slash/slash-commands.ts +2 -1
- package/src/slash/subagents-admin.ts +11 -4
- package/src/tui/fleet-status.ts +164 -19
- package/src/tui/fleet.ts +27 -19
- package/src/tui/render.ts +172 -33
- package/src/watchdog/child-status.ts +8 -0
- package/src/watchdog/model-selection.ts +20 -0
- package/src/watchdog/permission-arbiter.ts +3 -1
- package/src/watchdog/register-child.ts +1 -0
- package/src/watchdog/register-main.ts +31 -27
- package/src/watchdog/review.ts +132 -67
- package/src/watchdog/runtime.ts +82 -20
- package/src/watchdog/scope.ts +1 -1
- package/src/watchdog/settings.ts +9 -3
- package/src/watchdog/tool-actions.ts +13 -12
- package/src/watchdog/turn-delta.ts +23 -0
- package/src/watchdog/types.ts +4 -0
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +275 -17
- package/src/workflows/workflow-checklist.ts +13 -17
- package/src/workflows/workflow-child-summary.ts +57 -8
- package/src/workflows/workflow-preflight.ts +19 -19
- package/src/workflows/workflow-receipt.ts +3 -3
- package/src/workflows/workflow-resources.ts +96 -21
- package/src/workflows/workflow-settlement.ts +3 -0
- /package/src/inspectors/{herdr/shell-command.ts → shell-command.ts} +0 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* observation channel, not a delivery acknowledgement.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { debuglog } from "node:util";
|
|
9
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { buildCompletionKey, markSeenWithTtl } from "./completion-dedupe.ts";
|
|
11
12
|
import {
|
|
@@ -34,6 +35,7 @@ export interface SubagentNotifyChildOutput {
|
|
|
34
35
|
export type SubagentNotifyWatchdogBlocker = Pick<ChildWatchdogWarningSummary, "summary" | "addressed" | "stalemate"> & { agent: string };
|
|
35
36
|
|
|
36
37
|
export interface SubagentNotifyDetails {
|
|
38
|
+
workflowReceiptPath?: string;
|
|
37
39
|
agent: string;
|
|
38
40
|
status: "completed" | "failed" | "paused" | "stopped";
|
|
39
41
|
source?: "async" | "foreground";
|
|
@@ -52,6 +54,16 @@ export interface SubagentNotifyDetails {
|
|
|
52
54
|
watchdogBlockers?: SubagentNotifyWatchdogBlocker[];
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
export interface IncrementalChildCompletion {
|
|
58
|
+
workflowRunId: string;
|
|
59
|
+
childKey: string;
|
|
60
|
+
childRunId?: string;
|
|
61
|
+
outcome: "completed" | "failed" | "paused" | "stopped";
|
|
62
|
+
outputReference?: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
workflowRunning: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
export interface CompletionNotification {
|
|
56
68
|
[key: string]: unknown;
|
|
57
69
|
id?: string | null;
|
|
@@ -189,11 +201,17 @@ function structuredOutputText(value: unknown): string | undefined {
|
|
|
189
201
|
}
|
|
190
202
|
}
|
|
191
203
|
|
|
204
|
+
function isDegenerateOutput(text: string): boolean {
|
|
205
|
+
return !text.trim() || text.trim() === "</think>";
|
|
206
|
+
}
|
|
207
|
+
|
|
192
208
|
function childInlinePreview(child: CompletionChild): { preview?: string; truncated?: boolean; unavailableReason?: string } {
|
|
193
209
|
const output = typeof child.output === "string" ? child.output : "";
|
|
194
210
|
const outputReference = childSavedOutputPath(child);
|
|
195
211
|
const referenceOnly = Boolean(outputReference && output.trim().startsWith("Output saved to:"));
|
|
196
|
-
const raw = child.outputState === "absent" || referenceOnly
|
|
212
|
+
const raw = child.outputState === "absent" || referenceOnly
|
|
213
|
+
? structuredOutputText(child.structuredOutput)
|
|
214
|
+
: isDegenerateOutput(output) ? structuredOutputText(child.structuredOutput) ?? output : output;
|
|
197
215
|
if (!raw?.trim()) {
|
|
198
216
|
return { unavailableReason: referenceOnly ? "saved output is file-only" : "no safe inline output" };
|
|
199
217
|
}
|
|
@@ -283,6 +301,7 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
|
|
|
283
301
|
: undefined;
|
|
284
302
|
return [
|
|
285
303
|
`${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
|
|
304
|
+
details.workflowReceiptPath ? `Workflow receipt: ${details.workflowReceiptPath}` : undefined,
|
|
286
305
|
"",
|
|
287
306
|
scheduleLine,
|
|
288
307
|
scheduleLine ? "" : undefined,
|
|
@@ -299,11 +318,35 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
|
|
|
299
318
|
.join("\n");
|
|
300
319
|
}
|
|
301
320
|
|
|
321
|
+
export function scheduledCompletionTriggersTurn(origin: ScheduleOrigin | undefined, outcome: string): boolean {
|
|
322
|
+
return !(origin?.quiet === true && outcome === "completed");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function formatIncrementalChildCompletion(child: IncrementalChildCompletion): string {
|
|
326
|
+
const statusText = child.outcome === "completed" ? "completed"
|
|
327
|
+
: child.outcome === "failed" ? "failed"
|
|
328
|
+
: child.outcome === "paused" ? "paused (needs attention)"
|
|
329
|
+
: "stopped";
|
|
330
|
+
const workflowStatus = child.workflowRunning ? "workflow still running" : "workflow finished";
|
|
331
|
+
return [
|
|
332
|
+
`Workflow child ${statusText}: **${child.childKey}**`,
|
|
333
|
+
`Workflow run: ${child.workflowRunId}`,
|
|
334
|
+
...(child.childRunId ? [`Child run: ${child.childRunId}`] : []),
|
|
335
|
+
...(child.outputReference ? [`Output: ${child.outputReference}`] : []),
|
|
336
|
+
...(child.error ? [`Error: ${child.error}`] : []),
|
|
337
|
+
`Status: ${workflowStatus}`,
|
|
338
|
+
].join("\n");
|
|
339
|
+
}
|
|
340
|
+
|
|
302
341
|
export function parseSubagentNotifyContent(content: string): SubagentNotifyDetails | undefined {
|
|
303
342
|
const lines = content.split("\n");
|
|
304
343
|
const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
|
|
305
344
|
if (!match) return undefined;
|
|
306
|
-
|
|
345
|
+
// Only the header slot before the blank separator can carry a receipt.
|
|
346
|
+
// Identical lines anywhere in model output remain preview text.
|
|
347
|
+
const receiptHeader = lines[1]?.startsWith("Workflow receipt: ") && lines[2] === "";
|
|
348
|
+
const workflowReceiptPath = receiptHeader ? lines[1]!.slice("Workflow receipt: ".length) : undefined;
|
|
349
|
+
let body = lines.slice(receiptHeader ? 3 : 2);
|
|
307
350
|
// Restore the schedule origin so a re-rendered notice keeps its attribution and
|
|
308
351
|
// does not fold the line into the result preview.
|
|
309
352
|
const scheduleMatch = (body[0] ?? "").match(/^Scheduled run from \*\*(.+?)\*\* \(schedule (.+?)\)\.$/);
|
|
@@ -359,6 +402,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
359
402
|
...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
|
|
360
403
|
...(match[4] ? { taskInfo: match[4] } : {}),
|
|
361
404
|
...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
|
|
405
|
+
...(workflowReceiptPath ? { workflowReceiptPath } : {}),
|
|
362
406
|
resultPreview,
|
|
363
407
|
...(handoffPath ? { handoffPath } : {}),
|
|
364
408
|
...(workflowRunId ? { workflowRunId } : {}),
|
|
@@ -377,6 +421,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
|
|
|
377
421
|
if (!detail) continue;
|
|
378
422
|
const sessionLine = formatSessionLine(detail);
|
|
379
423
|
blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}${detail.scheduleOrigin ? ` — scheduled run from ${detail.scheduleOrigin.name ?? detail.scheduleOrigin.id} (schedule ${detail.scheduleOrigin.id})` : ""}`);
|
|
424
|
+
if (detail.workflowReceiptPath) blocks.push(`Workflow receipt: ${detail.workflowReceiptPath}`);
|
|
380
425
|
blocks.push(formatResultPreview(detail));
|
|
381
426
|
blocks.push(...formatWatchdogBlockerLines(detail));
|
|
382
427
|
if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
|
|
@@ -387,7 +432,31 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
|
|
|
387
432
|
return blocks.join("\n").trimEnd();
|
|
388
433
|
}
|
|
389
434
|
|
|
435
|
+
const notificationDebug = debuglog("pi-subagents-notify");
|
|
436
|
+
type TraceIdentity = Pick<CompletionNotification, "id" | "runId" | "source">;
|
|
437
|
+
type NotificationReason = "disposed" | "missing_session" | "foreground_session_mismatch" | "not_owned"
|
|
438
|
+
| "intercom_delivered" | "deduped_ttl" | "deduped_pending" | "batch_deferred"
|
|
439
|
+
| "emit_foreground_session_mismatch" | "emit_not_owned" | "send_accepted" | "send_failed" | "dispose_pending";
|
|
440
|
+
|
|
441
|
+
// Slice before sanitizing: diagnostic work and each identity are bounded even for
|
|
442
|
+
// malformed result metadata. Never include task/output, paths, or error bodies.
|
|
443
|
+
function traceIdentity(result: TraceIdentity): TraceIdentity {
|
|
444
|
+
const identity = (value: unknown) => typeof value === "string"
|
|
445
|
+
? value.slice(0, 128).replace(/[^a-zA-Z0-9_.:-]/g, "_") : undefined;
|
|
446
|
+
return { id: identity(result.id), runId: identity(result.runId), source: result.source === "foreground" ? "foreground" : "async" };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function traceNotification(reason: NotificationReason, result?: TraceIdentity): void {
|
|
450
|
+
if (!notificationDebug.enabled) return;
|
|
451
|
+
try {
|
|
452
|
+
notificationDebug("%s", JSON.stringify({ reason, ...(result ? traceIdentity(result) : {}) }));
|
|
453
|
+
} catch {
|
|
454
|
+
// Diagnostics must never change delivery acknowledgement.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
390
458
|
interface PendingCompletion {
|
|
459
|
+
trace?: TraceIdentity;
|
|
391
460
|
key: string;
|
|
392
461
|
details: SubagentNotifyDetails;
|
|
393
462
|
sessionId: string;
|
|
@@ -448,6 +517,10 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
448
517
|
? result.parallelHandoff as { path?: unknown }
|
|
449
518
|
: undefined;
|
|
450
519
|
const handoffPath = typeof parallelHandoff?.path === "string" ? parallelHandoff.path : undefined;
|
|
520
|
+
const receipt = result.workflowReceipt;
|
|
521
|
+
const receiptPath = receipt && typeof receipt === "object" && !Array.isArray(receipt)
|
|
522
|
+
? (receipt as Record<string, unknown>).path : undefined;
|
|
523
|
+
const workflowReceiptPath = typeof receiptPath === "string" && receiptPath ? receiptPath : undefined;
|
|
451
524
|
const rawRunId = typeof result.runId === "string" ? result.runId : typeof result.id === "string" ? result.id : undefined;
|
|
452
525
|
const workflowRunId = (result.mode === "workflow" || agent === "workflow") && rawRunId ? rawRunId : undefined;
|
|
453
526
|
const directChild = !workflowRunId && result.results?.length === 1 ? result.results[0]! : undefined;
|
|
@@ -456,10 +529,17 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
456
529
|
: undefined;
|
|
457
530
|
const directSummary = summary.trim();
|
|
458
531
|
const directAgent = typeof directChild?.agent === "string" ? directChild.agent : agent;
|
|
532
|
+
const directSummaryBody = directAgent && summary.trimStart().startsWith(`${directAgent}:\n`)
|
|
533
|
+
? summary.trimStart().slice(directAgent.length + 2)
|
|
534
|
+
: directSummary;
|
|
535
|
+
const directDegenerateSummary = directChild
|
|
536
|
+
&& isDegenerateOutput(typeof directChild.output === "string" ? directChild.output : "")
|
|
537
|
+
&& isDegenerateOutput(directSummaryBody)
|
|
538
|
+
&& structuredOutputText(directChild.structuredOutput) !== undefined;
|
|
459
539
|
const directNoOutputSummary = directChild && (!directSummary
|
|
460
540
|
|| directSummary === "(no output)"
|
|
461
541
|
|| (directAgent && directSummary === `${directAgent}:\n(no output)`));
|
|
462
|
-
const resultPreview = directStructuredPreview && directNoOutputSummary
|
|
542
|
+
const resultPreview = directStructuredPreview && (directNoOutputSummary || directDegenerateSummary)
|
|
463
543
|
? `Structured output:\n${directStructuredPreview}`
|
|
464
544
|
: summary;
|
|
465
545
|
const childRuns = result.results?.flatMap((child) => {
|
|
@@ -517,6 +597,7 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
517
597
|
agent,
|
|
518
598
|
status,
|
|
519
599
|
...(scheduleOrigin ? { scheduleOrigin } : {}),
|
|
600
|
+
...(workflowReceiptPath ? { workflowReceiptPath } : {}),
|
|
520
601
|
...(result.source ? { source: result.source } : {}),
|
|
521
602
|
...(taskInfo ? { taskInfo } : {}),
|
|
522
603
|
resultPreview,
|
|
@@ -548,8 +629,9 @@ export default function registerSubagentNotify(
|
|
|
548
629
|
&& typeof completionOwnerId === "string"
|
|
549
630
|
&& completionOwnerId === state.completionOwnerId);
|
|
550
631
|
|
|
551
|
-
const settle = (items: PendingCompletion[], accepted: boolean) => {
|
|
632
|
+
const settle = (items: PendingCompletion[], accepted: boolean, reason?: NotificationReason) => {
|
|
552
633
|
for (const item of items) {
|
|
634
|
+
if (reason) traceNotification(reason, item.trace);
|
|
553
635
|
pending.delete(item.key);
|
|
554
636
|
if (accepted) markSeenWithTtl(seen, item.key, now(), ttlMs);
|
|
555
637
|
item.resolve(accepted);
|
|
@@ -562,10 +644,12 @@ export default function registerSubagentNotify(
|
|
|
562
644
|
const owned = item.details.source === "foreground"
|
|
563
645
|
? item.sessionId === state.currentSessionId
|
|
564
646
|
: ownsResult(item.sessionId, item.completionOwnerId);
|
|
647
|
+
if (!owned) traceNotification(item.details.source === "foreground" ? "emit_foreground_session_mismatch" : "emit_not_owned", item.trace);
|
|
565
648
|
(owned ? accepted : rejected).push(item);
|
|
566
649
|
}
|
|
567
650
|
settle(rejected, false);
|
|
568
|
-
|
|
651
|
+
const sent = sendCompletion(pi, accepted);
|
|
652
|
+
settle(accepted, sent, sent ? "send_accepted" : "send_failed");
|
|
569
653
|
};
|
|
570
654
|
const getBatcher = (result: CompletionNotification) => {
|
|
571
655
|
const key = completionBatchKey(result);
|
|
@@ -583,17 +667,35 @@ export default function registerSubagentNotify(
|
|
|
583
667
|
};
|
|
584
668
|
|
|
585
669
|
const deliver = (result: CompletionNotification): Promise<boolean> => {
|
|
586
|
-
if (disposed || typeof result.sessionId !== "string")
|
|
670
|
+
if (disposed || typeof result.sessionId !== "string") {
|
|
671
|
+
traceNotification(disposed ? "disposed" : "missing_session", result);
|
|
672
|
+
return Promise.resolve(false);
|
|
673
|
+
}
|
|
587
674
|
if (result.source === "foreground") {
|
|
588
|
-
if (result.sessionId !== state.currentSessionId)
|
|
589
|
-
|
|
590
|
-
|
|
675
|
+
if (result.sessionId !== state.currentSessionId) {
|
|
676
|
+
traceNotification("foreground_session_mismatch", result);
|
|
677
|
+
return Promise.resolve(false);
|
|
678
|
+
}
|
|
679
|
+
} else if (!ownsResult(result.sessionId, result.completionOwnerId)) {
|
|
680
|
+
traceNotification("not_owned", result);
|
|
681
|
+
return Promise.resolve(false);
|
|
682
|
+
}
|
|
683
|
+
if (result.intercomDelivered === true) {
|
|
684
|
+
traceNotification("intercom_delivered", result);
|
|
685
|
+
return Promise.resolve(true);
|
|
686
|
+
}
|
|
591
687
|
const key = buildCompletionKey(result, "notify");
|
|
592
688
|
const seenAt = seen.get(key);
|
|
593
|
-
if (seenAt !== undefined && now() - seenAt <= ttlMs)
|
|
689
|
+
if (seenAt !== undefined && now() - seenAt <= ttlMs) {
|
|
690
|
+
traceNotification("deduped_ttl", result);
|
|
691
|
+
return Promise.resolve(true);
|
|
692
|
+
}
|
|
594
693
|
if (seenAt !== undefined) seen.delete(key);
|
|
595
694
|
const inFlight = pending.get(key);
|
|
596
|
-
if (inFlight)
|
|
695
|
+
if (inFlight) {
|
|
696
|
+
traceNotification("deduped_pending", result);
|
|
697
|
+
return inFlight;
|
|
698
|
+
}
|
|
597
699
|
const details = buildCompletionDetails(result);
|
|
598
700
|
let resolve!: (accepted: boolean) => void;
|
|
599
701
|
const completion = new Promise<boolean>((settleCompletion) => { resolve = settleCompletion; });
|
|
@@ -603,9 +705,10 @@ export default function registerSubagentNotify(
|
|
|
603
705
|
details,
|
|
604
706
|
sessionId: result.sessionId,
|
|
605
707
|
completionOwnerId: result.completionOwnerId,
|
|
606
|
-
triggerTurn: result.triggerTurn !== false,
|
|
708
|
+
triggerTurn: result.triggerTurn !== false && scheduledCompletionTriggersTurn(result.scheduleOrigin, details.status),
|
|
607
709
|
resolve,
|
|
608
710
|
};
|
|
711
|
+
if (notificationDebug.enabled) item.trace = traceIdentity(result);
|
|
609
712
|
if (details.source === "foreground") {
|
|
610
713
|
emit([item]);
|
|
611
714
|
return completion;
|
|
@@ -616,6 +719,7 @@ export default function registerSubagentNotify(
|
|
|
616
719
|
emit([item]);
|
|
617
720
|
return completion;
|
|
618
721
|
}
|
|
722
|
+
if (batchConfig.enabled) traceNotification("batch_deferred", item.trace);
|
|
619
723
|
batcher.push(item);
|
|
620
724
|
return completion;
|
|
621
725
|
};
|
|
@@ -633,7 +737,7 @@ export default function registerSubagentNotify(
|
|
|
633
737
|
dispose() {
|
|
634
738
|
if (disposed) return;
|
|
635
739
|
disposed = true;
|
|
636
|
-
for (const batcher of batchers.values()) settle(batcher.dispose(), false);
|
|
740
|
+
for (const batcher of batchers.values()) settle(batcher.dispose(), false, "dispose_pending");
|
|
637
741
|
batchers.clear();
|
|
638
742
|
for (const unsubscribe of [unsubscribeAsync, unsubscribeForeground]) {
|
|
639
743
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ProcessTreeTerminal } from "../../shared/types.ts";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_TERM_GRACE_MS = 3000;
|
|
5
5
|
const DEFAULT_KILL_VERIFY_MS = 1000;
|
|
@@ -35,6 +35,23 @@ function activeProcessGroupMembers(processGroupId: number): number[] | { diagnos
|
|
|
35
35
|
return members;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function knownDetachedDescendants(processGroupId: number): number[] {
|
|
39
|
+
const result = spawnSync("ps", ["-axo", "pid=,ppid=,pgid=,stat="], { encoding: "utf-8" });
|
|
40
|
+
if (result.error || result.status !== 0) return [];
|
|
41
|
+
const owned = new Set<number>();
|
|
42
|
+
const outside: { pid: number; ppid: number }[] = [];
|
|
43
|
+
for (const line of result.stdout.split("\n")) {
|
|
44
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)/.exec(line);
|
|
45
|
+
if (!match || match[4]!.startsWith("Z")) continue;
|
|
46
|
+
const pid = Number(match[1]);
|
|
47
|
+
const ppid = Number(match[2]);
|
|
48
|
+
const pgid = Number(match[3]);
|
|
49
|
+
if (pgid === processGroupId) owned.add(pid);
|
|
50
|
+
else outside.push({ pid, ppid });
|
|
51
|
+
}
|
|
52
|
+
return outside.filter((row) => owned.has(row.ppid)).map((row) => row.pid);
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
async function waitUntilGroupTerminal(
|
|
39
56
|
processGroupId: number,
|
|
40
57
|
timeoutMs: number,
|
|
@@ -52,37 +69,47 @@ async function waitUntilGroupTerminal(
|
|
|
52
69
|
}
|
|
53
70
|
}
|
|
54
71
|
|
|
55
|
-
function observed(processGroupId: number):
|
|
72
|
+
function observed(processGroupId: number): ProcessTreeTerminal {
|
|
56
73
|
return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
|
|
57
74
|
}
|
|
58
75
|
|
|
76
|
+
function observedUnlessLiveDetached(processGroupId: number, detached: readonly number[]): ProcessTreeTerminal {
|
|
77
|
+
const live = detached.filter((pid) => {
|
|
78
|
+
const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf-8" });
|
|
79
|
+
return result.status === 0 && Boolean(result.stdout.trim()) && !result.stdout.trim().startsWith("Z");
|
|
80
|
+
});
|
|
81
|
+
if (live.length === 0) return observed(processGroupId);
|
|
82
|
+
return { state: "unknown", reason: "verification-failed", diagnostic: `Owned detached descendant(s) still active: ${live.join(", ")}.` };
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
/** Owns one writer process group and arbitrates its cleanup exactly once. */
|
|
60
86
|
export interface OwnedProcessTreeController {
|
|
61
|
-
terminate(): Promise<
|
|
62
|
-
finishAfterWriterClose(): Promise<
|
|
87
|
+
terminate(): Promise<ProcessTreeTerminal>;
|
|
88
|
+
finishAfterWriterClose(): Promise<ProcessTreeTerminal>;
|
|
63
89
|
}
|
|
64
90
|
|
|
65
91
|
export function createOwnedProcessTreeController(
|
|
66
92
|
pid: number,
|
|
67
93
|
options: { termGraceMs?: number; killVerifyMs?: number } = {},
|
|
68
94
|
): OwnedProcessTreeController {
|
|
69
|
-
let termination: Promise<
|
|
95
|
+
let termination: Promise<ProcessTreeTerminal> | undefined;
|
|
70
96
|
const posixGroupOwned = process.platform !== "win32";
|
|
71
97
|
const target = posixGroupOwned ? -pid : pid;
|
|
72
98
|
|
|
73
|
-
const terminate = (): Promise<
|
|
99
|
+
const terminate = (): Promise<ProcessTreeTerminal> => {
|
|
74
100
|
if (termination) return termination;
|
|
75
101
|
termination = (async () => {
|
|
76
102
|
if (!posixGroupOwned) {
|
|
77
103
|
signalProcess(target, "SIGTERM");
|
|
78
104
|
return { state: "unknown", reason: "unsupported-platform" };
|
|
79
105
|
}
|
|
106
|
+
const detached = knownDetachedDescendants(pid);
|
|
80
107
|
const term = signalProcess(target, "SIGTERM");
|
|
81
108
|
if (term !== "sent" && term !== "absent") {
|
|
82
109
|
return { state: "unknown", reason: "signal-failed", diagnostic: term.diagnostic };
|
|
83
110
|
}
|
|
84
111
|
const termExit = await waitUntilGroupTerminal(pid, options.termGraceMs ?? DEFAULT_TERM_GRACE_MS);
|
|
85
|
-
if (termExit === false) return
|
|
112
|
+
if (termExit === false) return observedUnlessLiveDetached(pid, detached);
|
|
86
113
|
|
|
87
114
|
const kill = signalProcess(target, "SIGKILL");
|
|
88
115
|
if (kill !== "sent" && kill !== "absent") {
|
|
@@ -95,7 +122,7 @@ export function createOwnedProcessTreeController(
|
|
|
95
122
|
if (killExit !== false) {
|
|
96
123
|
return { state: "unknown", reason: "verification-failed", diagnostic: killExit.diagnostic };
|
|
97
124
|
}
|
|
98
|
-
return
|
|
125
|
+
return observedUnlessLiveDetached(pid, detached);
|
|
99
126
|
})();
|
|
100
127
|
return termination;
|
|
101
128
|
};
|
|
@@ -4,10 +4,10 @@ import { writeAtomicJson, writePrivateAtomicJson } from "../../shared/atomic-jso
|
|
|
4
4
|
import {
|
|
5
5
|
SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|
|
6
6
|
type AsyncStatus,
|
|
7
|
-
type
|
|
8
|
-
type
|
|
7
|
+
type CanonicalSessionTerminal,
|
|
8
|
+
type ProcessInstanceExit,
|
|
9
9
|
type ProcessTerminalReason,
|
|
10
|
-
type
|
|
10
|
+
type ProcessTerminal,
|
|
11
11
|
} from "../../shared/types.ts";
|
|
12
12
|
import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
|
|
13
13
|
import { releaseActiveRunIndex } from "./active-run-index.ts";
|
|
@@ -16,7 +16,7 @@ export interface ProcessTerminalCandidate {
|
|
|
16
16
|
version: 1;
|
|
17
17
|
runId: string;
|
|
18
18
|
runnerProcessInstanceId: string;
|
|
19
|
-
writers: Record<string,
|
|
19
|
+
writers: Record<string, ProcessInstanceExit[]>;
|
|
20
20
|
expectedWriters?: Record<string, number>;
|
|
21
21
|
sessionFile?: string;
|
|
22
22
|
revivalLeaseToken?: string;
|
|
@@ -34,7 +34,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
34
34
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is
|
|
37
|
+
function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is ProcessInstanceExit {
|
|
38
38
|
if (!isRecord(value)) return false;
|
|
39
39
|
if (typeof value.processInstanceId !== "string" || value.processInstanceId.length === 0) return false;
|
|
40
40
|
if (kind ? value.kind !== kind : (value.kind !== "runner" && value.kind !== "pi-writer")) return false;
|
|
@@ -56,7 +56,7 @@ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): va
|
|
|
56
56
|
&& (value.processTree.diagnostic === undefined || typeof value.processTree.diagnostic === "string");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function validInstance(value: unknown): value is
|
|
59
|
+
function validInstance(value: unknown): value is ProcessInstanceExit {
|
|
60
60
|
return validProcessInstance(value, "pi-writer");
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -78,7 +78,7 @@ export function readProcessTerminalCandidate(asyncDir: string): ProcessTerminalC
|
|
|
78
78
|
if (!isRecord(raw) || raw.version !== 1 || typeof raw.runId !== "string" || typeof raw.runnerProcessInstanceId !== "string" || !isRecord(raw.writers)) {
|
|
79
79
|
throw new Error(`Invalid process-terminal candidate in '${asyncDir}'.`);
|
|
80
80
|
}
|
|
81
|
-
const writers: Record<string,
|
|
81
|
+
const writers: Record<string, ProcessInstanceExit[]> = {};
|
|
82
82
|
for (const [index, entries] of Object.entries(raw.writers)) {
|
|
83
83
|
if (!Array.isArray(entries) || !entries.every(validInstance)) throw new Error(`Invalid writer process records for child '${index}'.`);
|
|
84
84
|
writers[index] = entries;
|
|
@@ -137,7 +137,7 @@ export function markProcessTerminalCandidateLeaseRelease(asyncDir: string, token
|
|
|
137
137
|
writeProcessTerminalCandidate(asyncDir, { ...candidate, revivalLeaseReleaseAcknowledged: acknowledged });
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string):
|
|
140
|
+
function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string): ProcessTerminal {
|
|
141
141
|
return { version: 1, state: "unknown", runId, runnerProcessInstanceId, reason, ...(diagnostic ? { diagnostic } : {}) };
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -147,7 +147,7 @@ function resumeDisposition(state: string | undefined, sessionFile: string | unde
|
|
|
147
147
|
return sessionFile && fs.existsSync(sessionFile) ? "resumable" : "unavailable";
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>):
|
|
150
|
+
function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>): CanonicalSessionTerminal | undefined {
|
|
151
151
|
if (!candidate.sessionFile || lease.state !== "free") return undefined;
|
|
152
152
|
if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) return undefined;
|
|
153
153
|
return {
|
|
@@ -158,7 +158,7 @@ function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnTyp
|
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is
|
|
161
|
+
function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is ProcessTerminal {
|
|
162
162
|
if (!isRecord(raw) || raw.version !== 1 || !["pending", "observed", "unknown", "not-started"].includes(String(raw.state)) || typeof raw.runId !== "string" || !raw.runId || typeof raw.runnerProcessInstanceId !== "string" || !raw.runnerProcessInstanceId) {
|
|
163
163
|
throw new Error(`Invalid process-terminal proof in '${asyncDir}'.`);
|
|
164
164
|
}
|
|
@@ -177,21 +177,21 @@ function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: stri
|
|
|
177
177
|
return true;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"):
|
|
180
|
+
export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"): ProcessTerminal | undefined {
|
|
181
181
|
if (value === undefined) return undefined;
|
|
182
182
|
try {
|
|
183
183
|
validateProof(value, label, fallback);
|
|
184
|
-
return value as
|
|
184
|
+
return value as ProcessTerminal;
|
|
185
185
|
} catch (error) {
|
|
186
186
|
return unknownProof(fallback.runId ?? label, fallback.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }):
|
|
190
|
+
export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): ProcessTerminal | undefined {
|
|
191
191
|
try {
|
|
192
192
|
const raw = JSON.parse(fs.readFileSync(processTerminalPath(asyncDir), "utf-8")) as unknown;
|
|
193
193
|
validateProof(raw, asyncDir, fallback);
|
|
194
|
-
return raw as
|
|
194
|
+
return raw as ProcessTerminal;
|
|
195
195
|
} catch (error) {
|
|
196
196
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
197
197
|
return unknownProof(fallback?.runId ?? path.basename(asyncDir), fallback?.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
|
|
@@ -199,12 +199,12 @@ export function readProcessTerminal(asyncDir: string, fallback?: { runId?: strin
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
function stepProcessTerminalProof(
|
|
202
|
-
proof:
|
|
202
|
+
proof: ProcessTerminal,
|
|
203
203
|
childIndex: number,
|
|
204
|
-
state:
|
|
205
|
-
records:
|
|
206
|
-
resumeDispositionValue:
|
|
207
|
-
):
|
|
204
|
+
state: ProcessTerminal["state"],
|
|
205
|
+
records: ProcessInstanceExit[],
|
|
206
|
+
resumeDispositionValue: ProcessTerminal["resumeDisposition"],
|
|
207
|
+
): ProcessTerminal {
|
|
208
208
|
const base = {
|
|
209
209
|
version: 1 as const,
|
|
210
210
|
runId: proof.runId,
|
|
@@ -221,7 +221,7 @@ function stepProcessTerminalProof(
|
|
|
221
221
|
return { ...base, state };
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
function overlayStatus(asyncDir: string, proof:
|
|
224
|
+
function overlayStatus(asyncDir: string, proof: ProcessTerminal, candidate?: ProcessTerminalCandidate): void {
|
|
225
225
|
const statusPath = path.join(asyncDir, "status.json");
|
|
226
226
|
try {
|
|
227
227
|
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
|
|
@@ -244,13 +244,13 @@ export function finalizeProcessTerminal(
|
|
|
244
244
|
asyncDir: string,
|
|
245
245
|
runId: string,
|
|
246
246
|
runnerClose: RunnerCloseObservation,
|
|
247
|
-
):
|
|
247
|
+
): ProcessTerminal {
|
|
248
248
|
const existing = readProcessTerminal(asyncDir, { runId, runnerProcessInstanceId: runnerClose.processInstanceId });
|
|
249
249
|
if (existing && fs.existsSync(processTerminalPath(asyncDir))) {
|
|
250
250
|
if (existing.state === "observed" && existing.runId === runId && existing.runnerProcessInstanceId === runnerClose.processInstanceId) return existing;
|
|
251
251
|
if (existing.state === "unknown") return existing;
|
|
252
252
|
}
|
|
253
|
-
let proof:
|
|
253
|
+
let proof: ProcessTerminal;
|
|
254
254
|
let candidateForOverlay: ProcessTerminalCandidate | undefined;
|
|
255
255
|
try {
|
|
256
256
|
const candidate = readProcessTerminalCandidate(asyncDir);
|
|
@@ -279,7 +279,7 @@ export function finalizeProcessTerminal(
|
|
|
279
279
|
} else if (allWriters.some((writer) => writer.kind === "pi-writer" && writer.processTree.state !== "observed")) {
|
|
280
280
|
proof = unknownProof(runId, runnerClose.processInstanceId, "process-tree-unverified");
|
|
281
281
|
} else {
|
|
282
|
-
const runner:
|
|
282
|
+
const runner: ProcessInstanceExit = { kind: "runner", ...runnerClose };
|
|
283
283
|
const canonicalSession = session && sessionProjection(candidate, session);
|
|
284
284
|
proof = {
|
|
285
285
|
version: 1,
|