pi-subagents 0.65.1 → 0.66.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 +55 -0
- package/README.md +1 -1
- package/agents/researcher.md +23 -13
- package/docs/agents.md +16 -2
- package/docs/configuration.md +18 -0
- package/docs/extension-api.md +91 -0
- package/docs/models.md +58 -1
- package/docs/observability.md +42 -2
- package/docs/tool-reference.md +3 -3
- package/docs/workflows.md +14 -7
- package/package.json +2 -1
- package/skills/pi-subagents/references/execution-controls.md +14 -1
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/src/agents/advertised-agent-prompt.ts +63 -0
- package/src/agents/agent-management.ts +14 -1
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +8 -0
- package/src/api/shared-types.ts +1 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/index.ts +40 -2
- package/src/extension/public-execution.ts +0 -1
- package/src/extension/rpc.ts +4 -21
- package/src/extension/schemas.ts +8 -6
- package/src/extension/tool-description.ts +6 -5
- package/src/intercom/native-supervisor-channel.ts +82 -54
- package/src/runs/background/active-async-capacity.ts +18 -18
- package/src/runs/background/async-job-tracker.ts +35 -3
- 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 +40 -29
- package/src/runs/background/chain-root-attachment.ts +8 -0
- package/src/runs/background/control-channel.ts +78 -44
- package/src/runs/background/notify.ts +86 -12
- package/src/runs/background/owned-process-tree.ts +6 -6
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/run-child-session.ts +60 -32
- package/src/runs/background/run-status.ts +75 -5
- package/src/runs/background/runner-aliases.ts +18 -7
- package/src/runs/background/runner-child-launch.ts +86 -0
- package/src/runs/background/stale-run-reconciler.ts +3 -1
- package/src/runs/background/subagent-runner.ts +412 -209
- package/src/runs/background/subagent-wait.ts +3 -0
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/async-steering-action.ts +19 -0
- package/src/runs/foreground/execution.ts +101 -25
- package/src/runs/foreground/subagent-executor.ts +474 -197
- package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
- package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
- package/src/runs/shared/acceptance.ts +2 -2
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/async-status-projection.ts +47 -47
- package/src/runs/shared/child-hooks.ts +151 -2
- package/src/runs/shared/child-launch.ts +18 -13
- package/src/runs/shared/child-session.ts +42 -6
- package/src/runs/shared/child-tool-plan.ts +2 -2
- package/src/runs/shared/completion-evidence.ts +2 -2
- package/src/runs/shared/completion-guard.ts +1 -0
- package/src/runs/shared/host-step-status.ts +11 -11
- package/src/runs/shared/llm-intent-arbiter.ts +10 -9
- package/src/runs/shared/model-fallback.ts +10 -6
- package/src/runs/shared/nested-events.ts +5 -5
- package/src/runs/shared/orca-progress-tabs.ts +6 -0
- package/src/runs/shared/parallel-handoff.ts +57 -12
- package/src/runs/shared/parallel-utils.ts +2 -2
- 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 +13 -3
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +332 -204
- package/src/shared/types.ts +81 -60
- package/src/shared/utils.ts +7 -2
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render.ts +23 -5
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +38 -10
- package/src/workflows/workflow-checklist.ts +11 -15
- 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
|
@@ -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";
|
|
@@ -189,11 +191,17 @@ function structuredOutputText(value: unknown): string | undefined {
|
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
function isDegenerateOutput(text: string): boolean {
|
|
195
|
+
return !text.trim() || text.trim() === "</think>";
|
|
196
|
+
}
|
|
197
|
+
|
|
192
198
|
function childInlinePreview(child: CompletionChild): { preview?: string; truncated?: boolean; unavailableReason?: string } {
|
|
193
199
|
const output = typeof child.output === "string" ? child.output : "";
|
|
194
200
|
const outputReference = childSavedOutputPath(child);
|
|
195
201
|
const referenceOnly = Boolean(outputReference && output.trim().startsWith("Output saved to:"));
|
|
196
|
-
const raw = child.outputState === "absent" || referenceOnly
|
|
202
|
+
const raw = child.outputState === "absent" || referenceOnly
|
|
203
|
+
? structuredOutputText(child.structuredOutput)
|
|
204
|
+
: isDegenerateOutput(output) ? structuredOutputText(child.structuredOutput) ?? output : output;
|
|
197
205
|
if (!raw?.trim()) {
|
|
198
206
|
return { unavailableReason: referenceOnly ? "saved output is file-only" : "no safe inline output" };
|
|
199
207
|
}
|
|
@@ -283,6 +291,7 @@ export function formatSingleCompletion(details: SubagentNotifyDetails): string {
|
|
|
283
291
|
: undefined;
|
|
284
292
|
return [
|
|
285
293
|
`${taskKind} ${details.status}: **${details.agent}**${details.taskInfo ?? ""}`,
|
|
294
|
+
details.workflowReceiptPath ? `Workflow receipt: ${details.workflowReceiptPath}` : undefined,
|
|
286
295
|
"",
|
|
287
296
|
scheduleLine,
|
|
288
297
|
scheduleLine ? "" : undefined,
|
|
@@ -303,7 +312,11 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
303
312
|
const lines = content.split("\n");
|
|
304
313
|
const match = (lines[0] ?? "").match(/^(Background task|Detached foreground task) (completed|failed|paused|stopped): \*\*(.+?)\*\*(?:\s+(\([^)]*\)))?$/);
|
|
305
314
|
if (!match) return undefined;
|
|
306
|
-
|
|
315
|
+
// Only the header slot before the blank separator can carry a receipt.
|
|
316
|
+
// Identical lines anywhere in model output remain preview text.
|
|
317
|
+
const receiptHeader = lines[1]?.startsWith("Workflow receipt: ") && lines[2] === "";
|
|
318
|
+
const workflowReceiptPath = receiptHeader ? lines[1]!.slice("Workflow receipt: ".length) : undefined;
|
|
319
|
+
let body = lines.slice(receiptHeader ? 3 : 2);
|
|
307
320
|
// Restore the schedule origin so a re-rendered notice keeps its attribution and
|
|
308
321
|
// does not fold the line into the result preview.
|
|
309
322
|
const scheduleMatch = (body[0] ?? "").match(/^Scheduled run from \*\*(.+?)\*\* \(schedule (.+?)\)\.$/);
|
|
@@ -359,6 +372,7 @@ export function parseSubagentNotifyContent(content: string): SubagentNotifyDetai
|
|
|
359
372
|
...(match[1] === "Detached foreground task" ? { source: "foreground" as const } : {}),
|
|
360
373
|
...(match[4] ? { taskInfo: match[4] } : {}),
|
|
361
374
|
...(parsedScheduleOrigin ? { scheduleOrigin: parsedScheduleOrigin } : {}),
|
|
375
|
+
...(workflowReceiptPath ? { workflowReceiptPath } : {}),
|
|
362
376
|
resultPreview,
|
|
363
377
|
...(handoffPath ? { handoffPath } : {}),
|
|
364
378
|
...(workflowRunId ? { workflowRunId } : {}),
|
|
@@ -377,6 +391,7 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
|
|
|
377
391
|
if (!detail) continue;
|
|
378
392
|
const sessionLine = formatSessionLine(detail);
|
|
379
393
|
blocks.push(`${index + 1}. ${detail.agent}${detail.taskInfo ?? ""}${detail.scheduleOrigin ? ` — scheduled run from ${detail.scheduleOrigin.name ?? detail.scheduleOrigin.id} (schedule ${detail.scheduleOrigin.id})` : ""}`);
|
|
394
|
+
if (detail.workflowReceiptPath) blocks.push(`Workflow receipt: ${detail.workflowReceiptPath}`);
|
|
380
395
|
blocks.push(formatResultPreview(detail));
|
|
381
396
|
blocks.push(...formatWatchdogBlockerLines(detail));
|
|
382
397
|
if (detail.handoffPath) blocks.push(`Parallel handoff: ${detail.handoffPath}`);
|
|
@@ -387,7 +402,31 @@ export function formatGroupedCompletion(details: SubagentNotifyDetails[]): strin
|
|
|
387
402
|
return blocks.join("\n").trimEnd();
|
|
388
403
|
}
|
|
389
404
|
|
|
405
|
+
const notificationDebug = debuglog("pi-subagents-notify");
|
|
406
|
+
type TraceIdentity = Pick<CompletionNotification, "id" | "runId" | "source">;
|
|
407
|
+
type NotificationReason = "disposed" | "missing_session" | "foreground_session_mismatch" | "not_owned"
|
|
408
|
+
| "intercom_delivered" | "deduped_ttl" | "deduped_pending" | "batch_deferred"
|
|
409
|
+
| "emit_foreground_session_mismatch" | "emit_not_owned" | "send_accepted" | "send_failed" | "dispose_pending";
|
|
410
|
+
|
|
411
|
+
// Slice before sanitizing: diagnostic work and each identity are bounded even for
|
|
412
|
+
// malformed result metadata. Never include task/output, paths, or error bodies.
|
|
413
|
+
function traceIdentity(result: TraceIdentity): TraceIdentity {
|
|
414
|
+
const identity = (value: unknown) => typeof value === "string"
|
|
415
|
+
? value.slice(0, 128).replace(/[^a-zA-Z0-9_.:-]/g, "_") : undefined;
|
|
416
|
+
return { id: identity(result.id), runId: identity(result.runId), source: result.source === "foreground" ? "foreground" : "async" };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function traceNotification(reason: NotificationReason, result?: TraceIdentity): void {
|
|
420
|
+
if (!notificationDebug.enabled) return;
|
|
421
|
+
try {
|
|
422
|
+
notificationDebug("%s", JSON.stringify({ reason, ...(result ? traceIdentity(result) : {}) }));
|
|
423
|
+
} catch {
|
|
424
|
+
// Diagnostics must never change delivery acknowledgement.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
390
428
|
interface PendingCompletion {
|
|
429
|
+
trace?: TraceIdentity;
|
|
391
430
|
key: string;
|
|
392
431
|
details: SubagentNotifyDetails;
|
|
393
432
|
sessionId: string;
|
|
@@ -448,6 +487,10 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
448
487
|
? result.parallelHandoff as { path?: unknown }
|
|
449
488
|
: undefined;
|
|
450
489
|
const handoffPath = typeof parallelHandoff?.path === "string" ? parallelHandoff.path : undefined;
|
|
490
|
+
const receipt = result.workflowReceipt;
|
|
491
|
+
const receiptPath = receipt && typeof receipt === "object" && !Array.isArray(receipt)
|
|
492
|
+
? (receipt as Record<string, unknown>).path : undefined;
|
|
493
|
+
const workflowReceiptPath = typeof receiptPath === "string" && receiptPath ? receiptPath : undefined;
|
|
451
494
|
const rawRunId = typeof result.runId === "string" ? result.runId : typeof result.id === "string" ? result.id : undefined;
|
|
452
495
|
const workflowRunId = (result.mode === "workflow" || agent === "workflow") && rawRunId ? rawRunId : undefined;
|
|
453
496
|
const directChild = !workflowRunId && result.results?.length === 1 ? result.results[0]! : undefined;
|
|
@@ -456,10 +499,17 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
456
499
|
: undefined;
|
|
457
500
|
const directSummary = summary.trim();
|
|
458
501
|
const directAgent = typeof directChild?.agent === "string" ? directChild.agent : agent;
|
|
502
|
+
const directSummaryBody = directAgent && summary.trimStart().startsWith(`${directAgent}:\n`)
|
|
503
|
+
? summary.trimStart().slice(directAgent.length + 2)
|
|
504
|
+
: directSummary;
|
|
505
|
+
const directDegenerateSummary = directChild
|
|
506
|
+
&& isDegenerateOutput(typeof directChild.output === "string" ? directChild.output : "")
|
|
507
|
+
&& isDegenerateOutput(directSummaryBody)
|
|
508
|
+
&& structuredOutputText(directChild.structuredOutput) !== undefined;
|
|
459
509
|
const directNoOutputSummary = directChild && (!directSummary
|
|
460
510
|
|| directSummary === "(no output)"
|
|
461
511
|
|| (directAgent && directSummary === `${directAgent}:\n(no output)`));
|
|
462
|
-
const resultPreview = directStructuredPreview && directNoOutputSummary
|
|
512
|
+
const resultPreview = directStructuredPreview && (directNoOutputSummary || directDegenerateSummary)
|
|
463
513
|
? `Structured output:\n${directStructuredPreview}`
|
|
464
514
|
: summary;
|
|
465
515
|
const childRuns = result.results?.flatMap((child) => {
|
|
@@ -517,6 +567,7 @@ export function buildCompletionDetails(result: CompletionNotification): Subagent
|
|
|
517
567
|
agent,
|
|
518
568
|
status,
|
|
519
569
|
...(scheduleOrigin ? { scheduleOrigin } : {}),
|
|
570
|
+
...(workflowReceiptPath ? { workflowReceiptPath } : {}),
|
|
520
571
|
...(result.source ? { source: result.source } : {}),
|
|
521
572
|
...(taskInfo ? { taskInfo } : {}),
|
|
522
573
|
resultPreview,
|
|
@@ -548,8 +599,9 @@ export default function registerSubagentNotify(
|
|
|
548
599
|
&& typeof completionOwnerId === "string"
|
|
549
600
|
&& completionOwnerId === state.completionOwnerId);
|
|
550
601
|
|
|
551
|
-
const settle = (items: PendingCompletion[], accepted: boolean) => {
|
|
602
|
+
const settle = (items: PendingCompletion[], accepted: boolean, reason?: NotificationReason) => {
|
|
552
603
|
for (const item of items) {
|
|
604
|
+
if (reason) traceNotification(reason, item.trace);
|
|
553
605
|
pending.delete(item.key);
|
|
554
606
|
if (accepted) markSeenWithTtl(seen, item.key, now(), ttlMs);
|
|
555
607
|
item.resolve(accepted);
|
|
@@ -562,10 +614,12 @@ export default function registerSubagentNotify(
|
|
|
562
614
|
const owned = item.details.source === "foreground"
|
|
563
615
|
? item.sessionId === state.currentSessionId
|
|
564
616
|
: ownsResult(item.sessionId, item.completionOwnerId);
|
|
617
|
+
if (!owned) traceNotification(item.details.source === "foreground" ? "emit_foreground_session_mismatch" : "emit_not_owned", item.trace);
|
|
565
618
|
(owned ? accepted : rejected).push(item);
|
|
566
619
|
}
|
|
567
620
|
settle(rejected, false);
|
|
568
|
-
|
|
621
|
+
const sent = sendCompletion(pi, accepted);
|
|
622
|
+
settle(accepted, sent, sent ? "send_accepted" : "send_failed");
|
|
569
623
|
};
|
|
570
624
|
const getBatcher = (result: CompletionNotification) => {
|
|
571
625
|
const key = completionBatchKey(result);
|
|
@@ -583,17 +637,35 @@ export default function registerSubagentNotify(
|
|
|
583
637
|
};
|
|
584
638
|
|
|
585
639
|
const deliver = (result: CompletionNotification): Promise<boolean> => {
|
|
586
|
-
if (disposed || typeof result.sessionId !== "string")
|
|
640
|
+
if (disposed || typeof result.sessionId !== "string") {
|
|
641
|
+
traceNotification(disposed ? "disposed" : "missing_session", result);
|
|
642
|
+
return Promise.resolve(false);
|
|
643
|
+
}
|
|
587
644
|
if (result.source === "foreground") {
|
|
588
|
-
if (result.sessionId !== state.currentSessionId)
|
|
589
|
-
|
|
590
|
-
|
|
645
|
+
if (result.sessionId !== state.currentSessionId) {
|
|
646
|
+
traceNotification("foreground_session_mismatch", result);
|
|
647
|
+
return Promise.resolve(false);
|
|
648
|
+
}
|
|
649
|
+
} else if (!ownsResult(result.sessionId, result.completionOwnerId)) {
|
|
650
|
+
traceNotification("not_owned", result);
|
|
651
|
+
return Promise.resolve(false);
|
|
652
|
+
}
|
|
653
|
+
if (result.intercomDelivered === true) {
|
|
654
|
+
traceNotification("intercom_delivered", result);
|
|
655
|
+
return Promise.resolve(true);
|
|
656
|
+
}
|
|
591
657
|
const key = buildCompletionKey(result, "notify");
|
|
592
658
|
const seenAt = seen.get(key);
|
|
593
|
-
if (seenAt !== undefined && now() - seenAt <= ttlMs)
|
|
659
|
+
if (seenAt !== undefined && now() - seenAt <= ttlMs) {
|
|
660
|
+
traceNotification("deduped_ttl", result);
|
|
661
|
+
return Promise.resolve(true);
|
|
662
|
+
}
|
|
594
663
|
if (seenAt !== undefined) seen.delete(key);
|
|
595
664
|
const inFlight = pending.get(key);
|
|
596
|
-
if (inFlight)
|
|
665
|
+
if (inFlight) {
|
|
666
|
+
traceNotification("deduped_pending", result);
|
|
667
|
+
return inFlight;
|
|
668
|
+
}
|
|
597
669
|
const details = buildCompletionDetails(result);
|
|
598
670
|
let resolve!: (accepted: boolean) => void;
|
|
599
671
|
const completion = new Promise<boolean>((settleCompletion) => { resolve = settleCompletion; });
|
|
@@ -606,6 +678,7 @@ export default function registerSubagentNotify(
|
|
|
606
678
|
triggerTurn: result.triggerTurn !== false,
|
|
607
679
|
resolve,
|
|
608
680
|
};
|
|
681
|
+
if (notificationDebug.enabled) item.trace = traceIdentity(result);
|
|
609
682
|
if (details.source === "foreground") {
|
|
610
683
|
emit([item]);
|
|
611
684
|
return completion;
|
|
@@ -616,6 +689,7 @@ export default function registerSubagentNotify(
|
|
|
616
689
|
emit([item]);
|
|
617
690
|
return completion;
|
|
618
691
|
}
|
|
692
|
+
if (batchConfig.enabled) traceNotification("batch_deferred", item.trace);
|
|
619
693
|
batcher.push(item);
|
|
620
694
|
return completion;
|
|
621
695
|
};
|
|
@@ -633,7 +707,7 @@ export default function registerSubagentNotify(
|
|
|
633
707
|
dispose() {
|
|
634
708
|
if (disposed) return;
|
|
635
709
|
disposed = true;
|
|
636
|
-
for (const batcher of batchers.values()) settle(batcher.dispose(), false);
|
|
710
|
+
for (const batcher of batchers.values()) settle(batcher.dispose(), false, "dispose_pending");
|
|
637
711
|
batchers.clear();
|
|
638
712
|
for (const unsubscribe of [unsubscribeAsync, unsubscribeForeground]) {
|
|
639
713
|
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;
|
|
@@ -52,25 +52,25 @@ async function waitUntilGroupTerminal(
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
function observed(processGroupId: number):
|
|
55
|
+
function observed(processGroupId: number): ProcessTreeTerminal {
|
|
56
56
|
return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** Owns one writer process group and arbitrates its cleanup exactly once. */
|
|
60
60
|
export interface OwnedProcessTreeController {
|
|
61
|
-
terminate(): Promise<
|
|
62
|
-
finishAfterWriterClose(): Promise<
|
|
61
|
+
terminate(): Promise<ProcessTreeTerminal>;
|
|
62
|
+
finishAfterWriterClose(): Promise<ProcessTreeTerminal>;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function createOwnedProcessTreeController(
|
|
66
66
|
pid: number,
|
|
67
67
|
options: { termGraceMs?: number; killVerifyMs?: number } = {},
|
|
68
68
|
): OwnedProcessTreeController {
|
|
69
|
-
let termination: Promise<
|
|
69
|
+
let termination: Promise<ProcessTreeTerminal> | undefined;
|
|
70
70
|
const posixGroupOwned = process.platform !== "win32";
|
|
71
71
|
const target = posixGroupOwned ? -pid : pid;
|
|
72
72
|
|
|
73
|
-
const terminate = (): Promise<
|
|
73
|
+
const terminate = (): Promise<ProcessTreeTerminal> => {
|
|
74
74
|
if (termination) return termination;
|
|
75
75
|
termination = (async () => {
|
|
76
76
|
if (!posixGroupOwned) {
|
|
@@ -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,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { Message } from "@earendil-works/pi-ai";
|
|
9
9
|
import type { ChildTranscriptWriter } from "../../shared/child-transcript.ts";
|
|
10
10
|
import { extractTextFromContent, extractToolArgsPreview, getFinalOutput, hasEmptyTerminalAssistantResponse } from "../../shared/utils.ts";
|
|
11
|
-
import type { EffectsProjection,
|
|
11
|
+
import type { EffectsProjection, RuntimeAcknowledgedChildExtensions, SubagentOutputState, ToolBudgetState, Usage } from "../../shared/types.ts";
|
|
12
12
|
import {
|
|
13
13
|
acceptChildWatchdogEvent,
|
|
14
14
|
applyChildWatchdogMessage,
|
|
@@ -22,9 +22,10 @@ import { projectChildLifecycle, type ChildLifecycleAction, type ChildLifecycleSt
|
|
|
22
22
|
import { formatSubagentModelVerificationError } from "../shared/model-fallback.ts";
|
|
23
23
|
import { isMutatingTool, resolveCurrentPath } from "../shared/long-running-guard.ts";
|
|
24
24
|
import { effectiveToolTimeoutMs, formatToolTimeoutMessage, toolTimeoutCallKey } from "../shared/tool-timeout.ts";
|
|
25
|
-
import type
|
|
25
|
+
import { createReportedChildSessionInput, type InProcessChildLaunch } from "../shared/child-launch.ts";
|
|
26
26
|
import { projectChildSessionEventForJson, type ChildSession, type ChildSessionEvent, type ChildSessionFactory } from "../shared/child-session.ts";
|
|
27
27
|
import { formatSteerMessage } from "../shared/subagent-prompt-runtime.ts";
|
|
28
|
+
import { getReadonlySessionEvidence, requestReadonlySessionEvidence, type SettledReadonlyEvidence } from "../shared/readonly-session-evidence.ts";
|
|
28
29
|
import type { SteerDeliveryStatus, SteerRequest } from "./control-channel.ts";
|
|
29
30
|
|
|
30
31
|
export interface ChildEventContext {
|
|
@@ -95,6 +96,16 @@ export interface RunChildSessionInput {
|
|
|
95
96
|
modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string }>;
|
|
96
97
|
modelResponseAliases?: Record<string, string[]>;
|
|
97
98
|
mutationTools?: readonly string[];
|
|
99
|
+
/** Internal guarded continuation handoff; never part of persisted results. */
|
|
100
|
+
readonlyContinuation?: { source: ChildSession; expected: SettledReadonlyEvidence; modelId: string };
|
|
101
|
+
collectReadonlyEvidence?: boolean;
|
|
102
|
+
canContinue?: () => boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const settledChildren = new WeakMap<RunChildSessionResult, ChildSession>();
|
|
106
|
+
export function getSettledReadonlyChild(result: RunChildSessionResult): ChildSession | undefined {
|
|
107
|
+
const child = settledChildren.get(result);
|
|
108
|
+
return child && getReadonlySessionEvidence(child) ? child : undefined;
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
export interface RunChildSessionResult {
|
|
@@ -123,7 +134,7 @@ export interface RunChildSessionResult {
|
|
|
123
134
|
toolBudget?: ToolBudgetState;
|
|
124
135
|
toolBudgetBlocked?: boolean;
|
|
125
136
|
structuredOutput?: unknown;
|
|
126
|
-
runtimeAcknowledgedExtensions?:
|
|
137
|
+
runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
|
|
127
138
|
abortRecoveryDiagnostic?: string;
|
|
128
139
|
effects?: EffectsProjection;
|
|
129
140
|
}
|
|
@@ -474,6 +485,10 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
|
|
|
474
485
|
}
|
|
475
486
|
}
|
|
476
487
|
if (event.message.errorMessage) assistantError = event.message.errorMessage;
|
|
488
|
+
else if (hasToolCall && event.message.stopReason === "toolUse") {
|
|
489
|
+
// A recovered request can finish via a terminating tool, without a text stop.
|
|
490
|
+
assistantError = undefined;
|
|
491
|
+
}
|
|
477
492
|
const eventUsage = event.message.usage;
|
|
478
493
|
if (eventUsage) {
|
|
479
494
|
usage.turns++;
|
|
@@ -533,29 +548,33 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
|
|
|
533
548
|
: interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal)
|
|
534
549
|
? 0
|
|
535
550
|
: finalError || promptError !== undefined ? 1 : 0;
|
|
536
|
-
void closed.then(() =>
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
551
|
+
void closed.then(() => {
|
|
552
|
+
const result: RunChildSessionResult = omitUndefined({
|
|
553
|
+
exitCode,
|
|
554
|
+
messages,
|
|
555
|
+
usage,
|
|
556
|
+
toolCount,
|
|
557
|
+
durationMs: Date.now() - startedAt,
|
|
558
|
+
model,
|
|
559
|
+
error: stopped ? stopMessage() : timedOut ? (error ?? timeoutMessage()) : interrupted || (forcedDrainAfterFinalSuccess && !forcedDrainAfterEmptyTerminal) ? undefined : finalError,
|
|
560
|
+
finalOutput: (timedOut || stopped) && !finalOutput.trim() ? (stopped ? stopMessage() : error ?? timeoutMessage()) : finalOutput,
|
|
561
|
+
outputState: finalOutput.trim() ? "present" : "absent",
|
|
562
|
+
interrupted: interrupted || undefined,
|
|
563
|
+
timedOut: timedOut || undefined,
|
|
564
|
+
stopped: stopped || undefined,
|
|
565
|
+
observedMutationAttempt,
|
|
566
|
+
structuredOutputToolInvoked,
|
|
567
|
+
structuredOutputMessageStartIndex,
|
|
568
|
+
watchdog: childWatchdogState,
|
|
569
|
+
sessionFile: session?.sessionFile,
|
|
570
|
+
currentTool,
|
|
571
|
+
currentToolArgs,
|
|
572
|
+
currentPath,
|
|
573
|
+
afterCompactionSettlement: afterCompactionSettlement || undefined,
|
|
574
|
+
});
|
|
575
|
+
if (session && !forced && !forcedTermination && !interrupted && !timedOut && !stopped && getReadonlySessionEvidence(session)) settledChildren.set(result, session);
|
|
576
|
+
resolve(result);
|
|
577
|
+
});
|
|
559
578
|
};
|
|
560
579
|
|
|
561
580
|
input.registerInterrupt?.(() => {
|
|
@@ -575,17 +594,25 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
|
|
|
575
594
|
|
|
576
595
|
void (async () => {
|
|
577
596
|
try {
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
597
|
+
const continuation = input.readonlyContinuation;
|
|
598
|
+
const checkContinuation = () => {
|
|
599
|
+
if (!continuation) return;
|
|
600
|
+
if (interrupted || timedOut || stopped || input.canContinue?.() !== true
|
|
601
|
+
|| (input.runDeadlineAt !== undefined && Date.now() >= input.runDeadlineAt)
|
|
602
|
+
|| getReadonlySessionEvidence(continuation.source) !== continuation.expected
|
|
603
|
+
|| continuation.source.detached || continuation.source.shutDown) throw new Error("Read-only continuation handoff vetoed");
|
|
604
|
+
};
|
|
605
|
+
checkContinuation();
|
|
606
|
+
const createInput = createReportedChildSessionInput(input.launch, input.transcriptWriter);
|
|
607
|
+
if (input.collectReadonlyEvidence || continuation) requestReadonlySessionEvidence(createInput, continuation?.expected);
|
|
608
|
+
const created = await input.factory.create(createInput);
|
|
584
609
|
if (settled) {
|
|
585
610
|
void created.dispose();
|
|
586
611
|
return;
|
|
587
612
|
}
|
|
588
613
|
session = created;
|
|
614
|
+
checkContinuation();
|
|
615
|
+
if (continuation && (created.modelId !== continuation.modelId || input.launch.capture.completionIntentContext?.()?.model?.api !== continuation.expected.api)) throw new Error("Read-only continuation model changed");
|
|
589
616
|
unsubscribe = created.subscribe(processEvent);
|
|
590
617
|
input.registerWatchdogStatus?.((event) => processEvent(event as unknown as ChildSessionEvent));
|
|
591
618
|
input.registerSteer?.(async (request) => {
|
|
@@ -602,6 +629,7 @@ export function runChildSession(input: RunChildSessionInput): Promise<RunChildSe
|
|
|
602
629
|
: { state: "delivered", deliveryStatus: "delivered", message: "Pi accepted the steering input." };
|
|
603
630
|
});
|
|
604
631
|
if (interrupted || timedOut || stopped) abortChild();
|
|
632
|
+
checkContinuation();
|
|
605
633
|
await created.prompt(input.prompt);
|
|
606
634
|
promptSettled = true;
|
|
607
635
|
settle(undefined);
|