pi-subagents 0.67.0 → 0.68.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 +70 -0
- package/README.md +1 -1
- package/docs/agents.md +37 -12
- package/docs/configuration.md +61 -19
- package/docs/extension-api.md +5 -1
- package/docs/missions.md +2 -2
- package/docs/models.md +11 -79
- package/docs/observability.md +18 -8
- package/docs/standalone-background.md +13 -3
- package/docs/tool-reference.md +15 -12
- package/docs/watchdog.md +10 -12
- package/docs/workflows.md +11 -1
- package/index.ts +5 -2
- package/package.json +4 -2
- package/runner-peer-loader.mjs +24 -0
- package/runner-peer-preload.mjs +25 -11
- package/skills/pi-subagents/SKILL.md +18 -21
- package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
- package/skills/pi-subagents/references/execution-controls.md +4 -4
- package/skills/pi-subagents/references/management-authoring-rpc.md +0 -1
- package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +16 -12
- package/skills/pi-subagents/references/review-and-validation.md +3 -3
- package/src/agents/agent-management.ts +57 -58
- package/src/agents/agent-serializer.ts +4 -3
- package/src/agents/agents.ts +184 -71
- package/src/agents/chain-serializer.ts +5 -0
- package/src/agents/runtime-agent-registry.ts +7 -6
- package/src/api/preflight.ts +20 -16
- package/src/api/required-child-extensions.ts +6 -0
- package/src/extension/config.ts +10 -37
- package/src/extension/fanout-child.ts +3 -0
- package/src/extension/herdr-pi-bridge.ts +160 -0
- package/src/extension/index.ts +42 -31
- package/src/extension/public-execution.ts +3 -3
- package/src/extension/schemas.ts +16 -5
- package/src/extension/tool-description.ts +8 -7
- package/src/intercom/native-supervisor-channel.ts +22 -18
- package/src/policy/authority.ts +4 -0
- package/src/profiles/profiles.ts +12 -6
- package/src/runs/background/active-run-index.ts +17 -1
- package/src/runs/background/async-execution.ts +309 -126
- package/src/runs/background/async-job-tracker.ts +8 -6
- package/src/runs/background/async-resume.ts +13 -4
- package/src/runs/background/async-status.ts +15 -4
- package/src/runs/background/auto-drain.ts +20 -10
- package/src/runs/background/binary-bootstrap.ts +5 -0
- package/src/runs/background/chain-append.ts +1 -1
- package/src/runs/background/chain-root-attachment.ts +14 -33
- package/src/runs/background/notify.ts +74 -6
- package/src/runs/background/result-files.ts +8 -4
- package/src/runs/background/result-watcher.ts +19 -2
- package/src/runs/background/run-child-session.ts +20 -29
- package/src/runs/background/runner-aliases.ts +4 -33
- package/src/runs/background/runner-child-launch.ts +4 -1
- package/src/runs/background/runner-child-sessions.ts +2 -2
- package/src/runs/background/runner-http-dispatcher.ts +119 -0
- package/src/runs/background/scheduled-runs.ts +11 -5
- package/src/runs/background/stale-run-reconciler.ts +35 -11
- package/src/runs/background/subagent-runner.ts +396 -275
- package/src/runs/background/subagent-wait.ts +128 -23
- package/src/runs/background/wait-completions.ts +75 -27
- package/src/runs/background/wait-subscriptions.ts +9 -3
- package/src/runs/background/wait-tool.ts +4 -2
- package/src/runs/foreground/async-stop-action.ts +93 -3
- package/src/runs/foreground/execution.ts +91 -218
- package/src/runs/foreground/foreground-history.ts +2 -1
- package/src/runs/foreground/subagent-executor.ts +266 -80
- package/src/runs/shared/acceptance.ts +34 -10
- package/src/runs/shared/async-status-projection.ts +123 -33
- package/src/runs/shared/child-launch-plan.ts +15 -3
- package/src/runs/shared/child-launch.ts +19 -6
- package/src/runs/shared/child-runtime-config.ts +5 -0
- package/src/runs/shared/child-session.ts +94 -50
- package/src/runs/shared/child-tool-plan.ts +28 -16
- package/src/runs/shared/dynamic-fanout.ts +2 -2
- package/src/runs/shared/external-cli-contract.ts +11 -1
- package/src/runs/shared/external-cli-preflight.ts +6 -2
- package/src/runs/shared/herdr-connection.ts +134 -0
- package/src/runs/shared/herdr-external-adapters.ts +169 -0
- package/src/runs/shared/herdr-machine.ts +279 -0
- package/src/runs/shared/herdr-pi-protocol.ts +59 -0
- package/src/runs/shared/herdr-placed-run.ts +263 -0
- package/src/runs/shared/model-resolution-diagnostic.ts +76 -0
- package/src/runs/shared/{model-fallback.ts → model-resolution.ts} +22 -237
- package/src/runs/shared/model-scope.ts +1 -1
- package/src/runs/shared/nested-events.ts +11 -2
- package/src/runs/shared/parallel-utils.ts +7 -2
- package/src/runs/shared/pi-spawn.ts +1 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +4 -2
- package/src/runs/shared/worktree-setup-command.ts +27 -4
- package/src/runs/shared/worktree.ts +3 -3
- package/src/shared/child-cache-retention.ts +43 -0
- package/src/shared/launch-contract.ts +6 -9
- package/src/shared/pruned-fork.ts +1 -1
- package/src/shared/required-child-extensions.ts +81 -0
- package/src/shared/settings.ts +5 -2
- package/src/shared/shortcuts.ts +0 -4
- package/src/shared/types.ts +70 -29
- package/src/slash/slash-commands.ts +0 -6
- package/src/slash/subagents-admin.ts +13 -9
- package/src/tui/render.ts +20 -10
- package/src/watchdog/child-status.ts +28 -36
- package/src/watchdog/lsp-diagnostics.ts +1 -1
- package/src/watchdog/model-selection.ts +1 -1
- package/src/watchdog/register-child.ts +10 -3
- package/src/watchdog/register-main.ts +20 -20
- package/src/watchdog/render.ts +1 -1
- package/src/watchdog/review.ts +14 -30
- package/src/watchdog/rules.ts +1 -1
- package/src/watchdog/runtime.ts +23 -12
- package/src/watchdog/settings.ts +3 -6
- package/src/watchdog/types.ts +3 -5
- package/src/watchdog/warning-format.ts +1 -1
- package/src/workflows/scripted-workflow.ts +42 -3
- package/src/workflows/workflow-receipt.ts +21 -3
- package/src/workflows/workflow-resources.ts +13 -2
- package/src/runs/shared/model-exclusions.ts +0 -374
- package/src/runs/shared/readonly-model-continuation.ts +0 -69
- package/src/runs/shared/readonly-session-evidence.ts +0 -307
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
41
|
import * as fs from "node:fs";
|
|
42
|
+
import * as path from "node:path";
|
|
42
43
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
43
44
|
import {
|
|
44
45
|
listBackgroundWorkWakeChannels,
|
|
@@ -61,6 +62,7 @@ import {
|
|
|
61
62
|
type Usage,
|
|
62
63
|
type WaitCompletion,
|
|
63
64
|
} from "../../shared/types.ts";
|
|
65
|
+
import { nestedRunScope } from "../shared/nested-events.ts";
|
|
64
66
|
import { formatDuration, shortenPath } from "../../shared/formatters.ts";
|
|
65
67
|
import { toAgentToolUsage } from "../../shared/utils.ts";
|
|
66
68
|
import { collectWaitCompletions } from "./wait-completions.ts";
|
|
@@ -103,6 +105,8 @@ export interface SubagentWaitDeps {
|
|
|
103
105
|
onUpdate?: (result: AgentToolResult<Details>) => void;
|
|
104
106
|
asyncDirRoot?: string;
|
|
105
107
|
resultsDir?: string;
|
|
108
|
+
/** Root from the child's validated inherited route, not a tool argument. */
|
|
109
|
+
nestedRootRunId?: string;
|
|
106
110
|
kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
|
|
107
111
|
now?: () => number;
|
|
108
112
|
pollIntervalMs?: number;
|
|
@@ -118,6 +122,8 @@ export interface SubagentWaitDeps {
|
|
|
118
122
|
failOnFailedRuns?: boolean;
|
|
119
123
|
/** Internal auto-drain mode surfaces actionable attention as an error. */
|
|
120
124
|
failOnAttention?: boolean;
|
|
125
|
+
/** Durable owned supervisor-request barrier used by headless auto-drain. */
|
|
126
|
+
hasPendingSupervisorRequest?: () => boolean;
|
|
121
127
|
/** Arm a durable exact-target wait subscription in a long-lived interactive runtime. */
|
|
122
128
|
subscribe?: (input: { targetKind: "async" | "foreground"; runId: string; requestedId: string; timeoutMs: number }) => { token: string; expiresAt: number };
|
|
123
129
|
/** Injectable provider protocol surfaces for deterministic tests. */
|
|
@@ -264,22 +270,39 @@ function backgroundWorkForSession(deps: SubagentWaitDeps, nowMs: number): Backgr
|
|
|
264
270
|
return deps.backgroundWork?.snapshot(sessionId, nowMs) ?? snapshotBackgroundWork(sessionId, nowMs);
|
|
265
271
|
}
|
|
266
272
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
+
export function waitRunScopes(deps: Pick<SubagentWaitDeps, "asyncDirRoot" | "resultsDir" | "nestedRootRunId">): Array<{ asyncDirRoot: string; resultsDir: string }> {
|
|
274
|
+
return [
|
|
275
|
+
{ asyncDirRoot: deps.asyncDirRoot ?? DIRS.async, resultsDir: deps.resultsDir ?? DIRS.results },
|
|
276
|
+
...(deps.nestedRootRunId ? [nestedRunScope(deps.nestedRootRunId)] : []),
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function collectScopedCompletions(terminal: AsyncRunSummary[], deps: SubagentWaitDeps, references: string[]): WaitCompletion[] | undefined {
|
|
281
|
+
const completions = waitRunScopes(deps).flatMap((scope) => collectWaitCompletions(
|
|
282
|
+
terminal.filter((run) => path.resolve(path.dirname(run.asyncDir)) === path.resolve(scope.asyncDirRoot)),
|
|
283
|
+
deps.state, scope.resultsDir, (reference) => references.push(reference),
|
|
284
|
+
) ?? []);
|
|
285
|
+
return completions.length ? completions : undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Immediate-session runs only; named lookups can also recover already-terminal results. */
|
|
289
|
+
function runsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps, activeOnly = true): AsyncRunSummary[] {
|
|
290
|
+
const runs = waitRunScopes(deps).flatMap(({ asyncDirRoot, resultsDir }) => listAsyncRuns(asyncDirRoot, {
|
|
291
|
+
states: activeOnly ? [...ACTIVE_STATES] : undefined,
|
|
273
292
|
sessionId: deps.state.currentSessionId ?? undefined,
|
|
274
293
|
resultsDir,
|
|
275
294
|
kill: deps.kill,
|
|
276
295
|
now: deps.now,
|
|
277
296
|
includeNested: false,
|
|
278
297
|
...(params.id ? { runId: params.id } : {}),
|
|
279
|
-
});
|
|
298
|
+
}));
|
|
280
299
|
return params.id ? runs.filter((run) => matchesId(run, params.id!)) : runs;
|
|
281
300
|
}
|
|
282
301
|
|
|
302
|
+
function activeRunsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps): AsyncRunSummary[] {
|
|
303
|
+
return runsForSession(params, deps);
|
|
304
|
+
}
|
|
305
|
+
|
|
283
306
|
/** Runs (from the initial set) currently flagged needs_attention, for reporting. */
|
|
284
307
|
function attentionRunsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps, initialIds: Set<string>): AsyncRunSummary[] {
|
|
285
308
|
return activeRunsForSession(params, deps).filter((run) => needsAttention(run) && initialIds.has(run.id));
|
|
@@ -287,9 +310,7 @@ function attentionRunsForSession(params: SubagentWaitParams, deps: SubagentWaitD
|
|
|
287
310
|
|
|
288
311
|
/** Exact initial runs in any state, for the final summary. */
|
|
289
312
|
function runsForIds(runIds: Iterable<string>, deps: SubagentWaitDeps): AsyncRunSummary[] {
|
|
290
|
-
|
|
291
|
-
const resultsDir = deps.resultsDir ?? DIRS.results;
|
|
292
|
-
return [...runIds].flatMap((runId) => listAsyncRuns(asyncDirRoot, {
|
|
313
|
+
return waitRunScopes(deps).flatMap(({ asyncDirRoot, resultsDir }) => [...runIds].flatMap((runId) => listAsyncRuns(asyncDirRoot, {
|
|
293
314
|
sessionId: deps.state.currentSessionId ?? undefined,
|
|
294
315
|
resultsDir,
|
|
295
316
|
kill: deps.kill,
|
|
@@ -297,7 +318,7 @@ function runsForIds(runIds: Iterable<string>, deps: SubagentWaitDeps): AsyncRunS
|
|
|
297
318
|
includeNested: false,
|
|
298
319
|
runId,
|
|
299
320
|
exactRunId: true,
|
|
300
|
-
}));
|
|
321
|
+
})));
|
|
301
322
|
}
|
|
302
323
|
|
|
303
324
|
function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount = 0): string {
|
|
@@ -377,6 +398,49 @@ function windowElapsedResult(
|
|
|
377
398
|
};
|
|
378
399
|
}
|
|
379
400
|
|
|
401
|
+
function supervisorYieldResult(
|
|
402
|
+
activeRunIds: string[],
|
|
403
|
+
activeProviderItems: readonly RegisteredBackgroundWorkItem[] = [],
|
|
404
|
+
): AgentToolResult<Details> {
|
|
405
|
+
return {
|
|
406
|
+
content: [{ type: "text", text: "Wait yielded for a pending supervisor request. Background work remains active and will continue after the supervisor reply." }],
|
|
407
|
+
details: {
|
|
408
|
+
mode: "management",
|
|
409
|
+
results: [],
|
|
410
|
+
wait: {
|
|
411
|
+
reason: "supervisor_request",
|
|
412
|
+
timedOut: false,
|
|
413
|
+
activeRunIds,
|
|
414
|
+
activeProviderItems: activeProviderItems.map(({ provider, id }) => ({ provider, id })),
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
interface InitialWaitScope {
|
|
421
|
+
asyncRunIds: Set<string>;
|
|
422
|
+
foregroundRuns: Array<{ runId: string; sessionId?: string; detachedIndices: Set<number> }>;
|
|
423
|
+
providerIds: Set<string>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function supervisorYieldForScope(scope: InitialWaitScope, params: SubagentWaitParams, deps: SubagentWaitDeps, nowMs: number): AgentToolResult<Details> {
|
|
427
|
+
try {
|
|
428
|
+
const activeAsyncIds = new Set(activeRunsForSession(params.id ? { id: params.id } : {}, deps).map((run) => run.id));
|
|
429
|
+
const activeRunIds = new Set([...scope.asyncRunIds].filter((id) => activeAsyncIds.has(id)));
|
|
430
|
+
for (const initial of scope.foregroundRuns) {
|
|
431
|
+
const current = deps.state.foregroundRuns?.get(initial.runId);
|
|
432
|
+
if (!current || current.sessionId !== initial.sessionId) continue;
|
|
433
|
+
if (current.children.some((child) => initial.detachedIndices.has(child.index) && child.status === "detached")) activeRunIds.add(initial.runId);
|
|
434
|
+
}
|
|
435
|
+
const activeProviderItems = scope.providerIds.size > 0
|
|
436
|
+
? backgroundWorkForSession(deps, nowMs).items.filter((item) => scope.providerIds.has(backgroundWorkIdentity(item)))
|
|
437
|
+
: [];
|
|
438
|
+
return supervisorYieldResult([...activeRunIds], activeProviderItems);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
return result(error instanceof Error ? error.message : String(error), true);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
380
444
|
/** Build the live status shown while async work keeps bg_wait blocked. */
|
|
381
445
|
function asyncWaitUpdate(runs: AsyncRunSummary[], providerCount: number, elapsedMs: number): AgentToolResult<Details> {
|
|
382
446
|
const activity = runs.flatMap((run) => {
|
|
@@ -499,6 +563,7 @@ async function waitForDetachedForegroundRun(
|
|
|
499
563
|
now: () => number,
|
|
500
564
|
pollIntervalMs: number,
|
|
501
565
|
timeoutMs: number,
|
|
566
|
+
supervisorYield: () => AgentToolResult<Details>,
|
|
502
567
|
): Promise<AgentToolResult<Details>> {
|
|
503
568
|
const initialDetachedIndices = new Set(run.children.filter((child) => child.status === "detached").map((child) => child.index));
|
|
504
569
|
while (true) {
|
|
@@ -509,6 +574,7 @@ async function waitForDetachedForegroundRun(
|
|
|
509
574
|
if (!current || current.sessionId !== run.sessionId) {
|
|
510
575
|
return result(`Remembered foreground run "${run.runId}" disappeared before a terminal child result was recorded. Completion cannot be confirmed; do not launch a replacement without checking the originating child session.`, true);
|
|
511
576
|
}
|
|
577
|
+
if (deps.hasPendingSupervisorRequest?.()) return supervisorYield();
|
|
512
578
|
const pending = current.children.filter((child) => initialDetachedIndices.has(child.index) && child.status === "detached");
|
|
513
579
|
const attention = foregroundChildrenNeedingAttention(current, initialDetachedIndices);
|
|
514
580
|
if (attention.length > 0) return formatForegroundAttention(current, attention, now() - startedAt);
|
|
@@ -541,11 +607,13 @@ async function waitForSessionDetachedForegroundRuns(
|
|
|
541
607
|
now: () => number,
|
|
542
608
|
pollIntervalMs: number,
|
|
543
609
|
timeoutMs: number,
|
|
610
|
+
supervisorYield: () => AgentToolResult<Details>,
|
|
544
611
|
): Promise<AgentToolResult<Details>> {
|
|
545
612
|
const texts: string[] = [];
|
|
546
613
|
for (const run of runs) {
|
|
547
|
-
const one = await waitForDetachedForegroundRun(run, signal, deps, startedAt, now, pollIntervalMs, timeoutMs);
|
|
614
|
+
const one = await waitForDetachedForegroundRun(run, signal, deps, startedAt, now, pollIntervalMs, timeoutMs, supervisorYield);
|
|
548
615
|
if (one.isError) return one;
|
|
616
|
+
if (one.details.wait?.reason === "supervisor_request") return one;
|
|
549
617
|
if (one.details.wait?.reason === "window_elapsed") {
|
|
550
618
|
const activeRunIds = runs.filter((initial) => {
|
|
551
619
|
const current = deps.state.foregroundRuns?.get(initial.runId);
|
|
@@ -586,6 +654,7 @@ export async function waitForSubagents(
|
|
|
586
654
|
? params.timeoutMs
|
|
587
655
|
: deps.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
588
656
|
const startedAt = now();
|
|
657
|
+
const sessionId = deps.state.currentSessionId;
|
|
589
658
|
const waitForAll = params.id ? true : params.all === true;
|
|
590
659
|
if (params.nonBlocking && !params.id) {
|
|
591
660
|
return result("Non-blocking wait subscriptions require id so the registration can bind one exact run identity.", true);
|
|
@@ -598,7 +667,7 @@ export async function waitForSubagents(
|
|
|
598
667
|
let foreground: ForegroundResumeRun[];
|
|
599
668
|
let providerSnapshot: BackgroundWorkSnapshot;
|
|
600
669
|
try {
|
|
601
|
-
active =
|
|
670
|
+
active = runsForSession(params, deps, !params.id);
|
|
602
671
|
foreground = activeDetachedForegroundRuns(params, deps).map((run) => ({
|
|
603
672
|
...run,
|
|
604
673
|
children: run.children.map((child) => ({ ...child })),
|
|
@@ -608,6 +677,7 @@ export async function waitForSubagents(
|
|
|
608
677
|
return result(error instanceof Error ? error.message : String(error), true);
|
|
609
678
|
}
|
|
610
679
|
|
|
680
|
+
let selectedForeground: ForegroundResumeRun | undefined;
|
|
611
681
|
if (params.id) {
|
|
612
682
|
const candidates = [
|
|
613
683
|
...active.map((run) => ({ kind: "async" as const, id: run.id, run })),
|
|
@@ -616,9 +686,21 @@ export async function waitForSubagents(
|
|
|
616
686
|
const exact = candidates.filter((candidate) => candidate.id === params.id);
|
|
617
687
|
const matches = exact.length > 0 ? exact : candidates;
|
|
618
688
|
if (matches.length > 1) {
|
|
619
|
-
return result(`Ambiguous subagent run id prefix "${params.id}" matched ${matches.length}
|
|
689
|
+
return result(`Ambiguous subagent run id prefix "${params.id}" matched ${matches.length} runs: ${matches.map((candidate) => candidate.id).join(", ")}. Pass a longer id.`, true);
|
|
620
690
|
}
|
|
621
691
|
const selected = matches[0];
|
|
692
|
+
if (selected?.kind === "async" && !ACTIVE_STATES.includes(selected.run.state)) {
|
|
693
|
+
try {
|
|
694
|
+
const references: string[] = [];
|
|
695
|
+
const completions = collectScopedCompletions([selected.run], deps, references);
|
|
696
|
+
return result(
|
|
697
|
+
`Run "${selected.id}" is terminal. Outcome: ${summarizeTerminalRuns([selected.run])}.${formatCompletionRecovery(completions)}${references.length ? `\n${references.join("\n")}` : ""}`,
|
|
698
|
+
deps.failOnFailedRuns === true && (selected.run.state === "failed" || selected.run.state === "partial"), completions,
|
|
699
|
+
);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
return result(error instanceof Error ? error.message : String(error), true);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
622
704
|
if (selected && params.nonBlocking) {
|
|
623
705
|
if (!deps.subscribe) {
|
|
624
706
|
return result("Non-blocking wait subscriptions require a long-lived interactive subagent runtime; this runtime can only use blocking bg_wait calls.", true);
|
|
@@ -630,30 +712,47 @@ export async function waitForSubagents(
|
|
|
630
712
|
return result(error instanceof Error ? error.message : String(error), true);
|
|
631
713
|
}
|
|
632
714
|
}
|
|
633
|
-
|
|
634
|
-
return waitForDetachedForegroundRun(selected.run, signal, deps, startedAt, now, pollIntervalMs, timeoutMs);
|
|
635
|
-
}
|
|
715
|
+
selectedForeground = selected?.kind === "foreground" ? selected.run : undefined;
|
|
636
716
|
active = selected?.kind === "async" ? [selected.run] : [];
|
|
637
717
|
}
|
|
638
718
|
|
|
639
719
|
let providerActive = providerSnapshot.items;
|
|
720
|
+
const scopedForeground = selectedForeground ? [selectedForeground] : params.id ? [] : foreground;
|
|
721
|
+
const initialAsyncIds = new Set(active.map((run) => run.id));
|
|
722
|
+
const initialProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
|
|
723
|
+
const initialScope: InitialWaitScope = {
|
|
724
|
+
asyncRunIds: initialAsyncIds,
|
|
725
|
+
foregroundRuns: scopedForeground.map((run) => ({
|
|
726
|
+
runId: run.runId,
|
|
727
|
+
sessionId: run.sessionId,
|
|
728
|
+
detachedIndices: new Set(run.children.filter((child) => child.status === "detached").map((child) => child.index)),
|
|
729
|
+
})),
|
|
730
|
+
providerIds: initialProviderIds,
|
|
731
|
+
};
|
|
732
|
+
const supervisorYield = () => supervisorYieldForScope(initialScope, params, deps, now());
|
|
733
|
+
if (selectedForeground) {
|
|
734
|
+
return waitForDetachedForegroundRun(selectedForeground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs, supervisorYield);
|
|
735
|
+
}
|
|
640
736
|
if (active.length === 0 && providerActive.length === 0) {
|
|
641
737
|
if (waitForAll && !params.id && foreground.length > 0) {
|
|
642
|
-
return waitForSessionDetachedForegroundRuns(foreground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs);
|
|
738
|
+
return waitForSessionDetachedForegroundRuns(foreground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs, supervisorYield);
|
|
643
739
|
}
|
|
644
740
|
return result(params.id
|
|
645
741
|
? `No active run matched "${params.id}". Nothing to wait for.`
|
|
646
742
|
: "No active async runs or registered provider work in this session. Nothing to wait for.");
|
|
647
743
|
}
|
|
648
744
|
const waitParams = params.id ? { ...params, id: active[0]!.id } : params;
|
|
649
|
-
const initialAsyncIds = new Set(active.map((run) => run.id));
|
|
650
|
-
const initialProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
|
|
651
745
|
const initialProviderNames = new Set(providerActive.map((item) => item.provider));
|
|
652
746
|
const initialCount = initialAsyncIds.size + initialProviderIds.size;
|
|
653
747
|
const stopOnAttention = params.stopOnAttention ?? deps.stopOnAttention !== false;
|
|
654
748
|
let attention = active.filter((run) => needsAttention(run));
|
|
749
|
+
let supervisorBarrier = false;
|
|
655
750
|
|
|
656
751
|
const isDone = (): boolean => {
|
|
752
|
+
if (deps.hasPendingSupervisorRequest?.()) {
|
|
753
|
+
supervisorBarrier = true;
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
657
756
|
if (attention.some((run) => initialAsyncIds.has(run.id) && (stopOnAttention || hasSupervisorTool(run)))) return true;
|
|
658
757
|
const activeAsyncIds = new Set(active.map((run) => run.id));
|
|
659
758
|
const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
|
|
@@ -685,6 +784,7 @@ export async function waitForSubagents(
|
|
|
685
784
|
}
|
|
686
785
|
try {
|
|
687
786
|
await waitForWake(pollIntervalMs, signal, deps);
|
|
787
|
+
if (deps.state.currentSessionId !== sessionId) return result("Wait stopped because the active session changed.", true);
|
|
688
788
|
active = activeRunsForSession(waitParams, deps);
|
|
689
789
|
attention = attentionRunsForSession(waitParams, deps, initialAsyncIds);
|
|
690
790
|
providerSnapshot = params.id ? providerSnapshot : backgroundWorkForSession(deps, now());
|
|
@@ -698,12 +798,16 @@ export async function waitForSubagents(
|
|
|
698
798
|
return result(error instanceof Error ? error.message : String(error), true);
|
|
699
799
|
}
|
|
700
800
|
}
|
|
801
|
+
if (supervisorBarrier) {
|
|
802
|
+
return supervisorYield();
|
|
803
|
+
}
|
|
701
804
|
|
|
702
805
|
let terminalSummary: string;
|
|
703
806
|
let finishedAsyncCount: number;
|
|
704
807
|
let failedAsyncCount: number;
|
|
705
808
|
let completions: WaitCompletion[] | undefined;
|
|
706
809
|
let resumeGuidance = "";
|
|
810
|
+
const references: string[] = [];
|
|
707
811
|
const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
|
|
708
812
|
const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
|
|
709
813
|
try {
|
|
@@ -713,7 +817,7 @@ export async function waitForSubagents(
|
|
|
713
817
|
failedAsyncCount = terminal.filter((run) => run.state === "failed" || run.state === "partial").length;
|
|
714
818
|
terminalSummary = summarizeTerminalRuns(terminal, providerFinishedCount);
|
|
715
819
|
resumeGuidance = formatResumeFirstFailedRunsNote(terminal);
|
|
716
|
-
completions =
|
|
820
|
+
completions = collectScopedCompletions(terminal, deps, references);
|
|
717
821
|
} catch (error) {
|
|
718
822
|
return result(error instanceof Error ? error.message : String(error), true);
|
|
719
823
|
}
|
|
@@ -729,13 +833,14 @@ export async function waitForSubagents(
|
|
|
729
833
|
+ providerActive.filter((item) => initialProviderIds.has(backgroundWorkIdentity(item))).length;
|
|
730
834
|
const elapsed = formatDuration(now() - startedAt);
|
|
731
835
|
const outcome = terminalSummary ? ` Outcome: ${terminalSummary}.` : "";
|
|
732
|
-
const recoveryNote = formatCompletionRecovery(completions);
|
|
836
|
+
const recoveryNote = formatCompletionRecovery(completions) + (references.length ? `\n${references.join("\n")}\n` : "");
|
|
733
837
|
|
|
734
838
|
if (waitForAll) {
|
|
735
839
|
const foregroundResult = !params.id && foreground.length > 0 && relevantAttention.length === 0
|
|
736
|
-
? await waitForSessionDetachedForegroundRuns(foreground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs)
|
|
840
|
+
? await waitForSessionDetachedForegroundRuns(foreground, signal, deps, startedAt, now, pollIntervalMs, timeoutMs, supervisorYield)
|
|
737
841
|
: undefined;
|
|
738
842
|
if (foregroundResult?.isError) return foregroundResult;
|
|
843
|
+
if (foregroundResult?.details.wait?.reason === "supervisor_request") return foregroundResult;
|
|
739
844
|
if (foregroundResult?.details.wait?.reason === "window_elapsed") return foregroundResult;
|
|
740
845
|
const foregroundNote = foregroundResult
|
|
741
846
|
? `\n${foregroundResult.content.map((part) => part.type === "text" ? part.text : "").join("\n")}`
|
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import type { ArtifactPaths, SubagentState, Usage, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
|
|
3
3
|
import type { AsyncRunSummary } from "./async-status.ts";
|
|
4
4
|
import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.ts";
|
|
5
|
-
import { fallbackResultPayloadPathForSessionRun, resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
|
|
5
|
+
import { fallbackResultPayloadPathForSessionRun, resultFilePath, resultPayloadMatchesSessionRun, resultPayloadPathForSessionRun } from "./result-files.ts";
|
|
6
6
|
import { parseWorkflowChildSummary } from "../../workflows/workflow-child-summary.ts";
|
|
7
7
|
import { projectTimeoutRecovery } from "../shared/mutation-evidence.ts";
|
|
8
8
|
|
|
@@ -44,6 +44,27 @@ function errorMessage(error: unknown): string {
|
|
|
44
44
|
|
|
45
45
|
const STRUCTURED_OUTPUT_INLINE_LIMIT_BYTES = 4 * 1024;
|
|
46
46
|
|
|
47
|
+
function waitResultPayloadCandidate(resultsDir: string, run: AsyncRunSummary): string {
|
|
48
|
+
const publicPath = resultFilePath(resultsDir, run.id);
|
|
49
|
+
if (!run.sessionId) return publicPath;
|
|
50
|
+
try {
|
|
51
|
+
const indexedPath = resultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id);
|
|
52
|
+
return indexedPath ?? publicPath;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!isAccessDenied(error)) throw error;
|
|
55
|
+
const pendingPath = fallbackResultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id);
|
|
56
|
+
return pendingPath ?? publicPath;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readWaitResultPayload(candidatePath: string, run: AsyncRunSummary): Record<string, unknown> | undefined {
|
|
61
|
+
// A terminal run without session ownership cannot safely claim a payload.
|
|
62
|
+
if (!run.sessionId) return undefined;
|
|
63
|
+
const payload: unknown = JSON.parse(fs.readFileSync(candidatePath, "utf-8"));
|
|
64
|
+
if (!resultPayloadMatchesSessionRun(payload, run.sessionId, run.id)) return undefined;
|
|
65
|
+
return payload as Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
export function projectStructuredOutput(value: unknown): unknown {
|
|
48
69
|
if (value === undefined) return undefined;
|
|
49
70
|
const serialized = JSON.stringify(value);
|
|
@@ -118,8 +139,9 @@ export function toWaitCompletion(data: Record<string, unknown>, runId: string):
|
|
|
118
139
|
/**
|
|
119
140
|
* Record a consumed terminal payload for later surfacing by bg_wait, pruning
|
|
120
141
|
* stale entries with the same TTL that dedupes completion notifications. The result
|
|
121
|
-
* file is deleted after
|
|
122
|
-
* the watcher has consumed it.
|
|
142
|
+
* file is deleted after durable replay succeeds, so this record is the in-process
|
|
143
|
+
* source once the watcher has consumed it. Payload ownership must be explicit and
|
|
144
|
+
* agree with persistence ownership. Returns whether that replay is durable.
|
|
123
145
|
*/
|
|
124
146
|
export function recordWaitCompletion(
|
|
125
147
|
state: SubagentState,
|
|
@@ -128,7 +150,10 @@ export function recordWaitCompletion(
|
|
|
128
150
|
now: number,
|
|
129
151
|
ttlMs: number,
|
|
130
152
|
persistence?: { resultsDir: string; sessionId: string },
|
|
131
|
-
):
|
|
153
|
+
): boolean {
|
|
154
|
+
const sessionId = asNonEmptyString(data.sessionId);
|
|
155
|
+
if (!sessionId || !resultPayloadMatchesSessionRun(data, sessionId, runId)) return false;
|
|
156
|
+
if (persistence && persistence.sessionId !== sessionId) return false;
|
|
132
157
|
const store = state.completedResults ??= new Map();
|
|
133
158
|
for (const [key, entry] of store) {
|
|
134
159
|
if (now - entry.seenAt > ttlMs) store.delete(key);
|
|
@@ -148,7 +173,8 @@ export function recordWaitCompletion(
|
|
|
148
173
|
console.error(`Failed to persist completion replay for '${runId}':`, error);
|
|
149
174
|
}
|
|
150
175
|
}
|
|
151
|
-
store.set(runId, { seenAt: now, completion });
|
|
176
|
+
store.set(runId, { sessionId, seenAt: now, completion });
|
|
177
|
+
return completion.archivePath !== undefined;
|
|
152
178
|
}
|
|
153
179
|
|
|
154
180
|
/**
|
|
@@ -157,37 +183,59 @@ export function recordWaitCompletion(
|
|
|
157
183
|
* so a direct read never observes a torn write; the read is deliberately read-only —
|
|
158
184
|
* the watcher owns notification and cleanup.
|
|
159
185
|
*/
|
|
160
|
-
export function collectWaitCompletions(terminal: AsyncRunSummary[], state: SubagentState, resultsDir: string): WaitCompletion[] | undefined {
|
|
186
|
+
export function collectWaitCompletions(terminal: AsyncRunSummary[], state: SubagentState, resultsDir: string, onReference?: (text: string) => void): WaitCompletion[] | undefined {
|
|
161
187
|
if (terminal.length === 0) return undefined;
|
|
162
188
|
const completions: WaitCompletion[] = [];
|
|
189
|
+
const add = (completion: WaitCompletion, resultPath?: string): void => {
|
|
190
|
+
completions.push(completion);
|
|
191
|
+
// Tool details are not model context. Publish the evidence address in content
|
|
192
|
+
// without copying potentially unbounded reviewer output into every wait.
|
|
193
|
+
const reference = completion.archivePath ?? resultPath;
|
|
194
|
+
if (reference) onReference?.(`Result [${completion.runId}]: ${reference}`);
|
|
195
|
+
};
|
|
163
196
|
for (const run of terminal) {
|
|
164
197
|
const recorded = state.completedResults?.get(run.id);
|
|
165
|
-
if (recorded) {
|
|
166
|
-
|
|
198
|
+
if (recorded && recorded.sessionId === run.sessionId) {
|
|
199
|
+
if (recorded.completion.archivePath) {
|
|
200
|
+
add(recorded.completion);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const candidatePath = waitResultPayloadCandidate(resultsDir, run);
|
|
205
|
+
if (!readWaitResultPayload(candidatePath, run)) {
|
|
206
|
+
const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
|
|
207
|
+
add(replay?.completion ?? recorded.completion);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
add(recorded.completion, candidatePath);
|
|
211
|
+
continue;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (errorCode(error) !== "ENOENT") throw error;
|
|
214
|
+
}
|
|
215
|
+
const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
|
|
216
|
+
add(replay?.completion ?? recorded.completion);
|
|
167
217
|
continue;
|
|
168
218
|
}
|
|
169
|
-
|
|
170
|
-
let resultPath = publicResultPath;
|
|
219
|
+
let candidatePath: string;
|
|
171
220
|
try {
|
|
172
|
-
|
|
173
|
-
? resultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id) ?? publicResultPath
|
|
174
|
-
: publicResultPath;
|
|
221
|
+
candidatePath = waitResultPayloadCandidate(resultsDir, run);
|
|
175
222
|
} catch (error) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
throw new Error(`Failed to read subagent result '${publicResultPath}': ${errorMessage(fallbackError)}`, {
|
|
181
|
-
cause: fallbackError instanceof Error ? fallbackError : undefined,
|
|
182
|
-
});
|
|
183
|
-
}
|
|
223
|
+
const publicResultPath = resultFilePath(resultsDir, run.id);
|
|
224
|
+
throw new Error(`Failed to read subagent result '${publicResultPath}': ${errorMessage(error)}`, {
|
|
225
|
+
cause: error instanceof Error ? error : undefined,
|
|
226
|
+
});
|
|
184
227
|
}
|
|
185
228
|
try {
|
|
186
|
-
const
|
|
187
|
-
|
|
229
|
+
const payload = readWaitResultPayload(candidatePath, run);
|
|
230
|
+
if (!payload) {
|
|
231
|
+
const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
|
|
232
|
+
if (replay) add(replay.completion);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
add(toWaitCompletion(payload, run.id), candidatePath);
|
|
188
236
|
} catch (error) {
|
|
189
237
|
if (errorCode(error) !== "ENOENT") {
|
|
190
|
-
throw new Error(`Failed to read subagent result '${
|
|
238
|
+
throw new Error(`Failed to read subagent result '${candidatePath}': ${errorMessage(error)}`, {
|
|
191
239
|
cause: error instanceof Error ? error : undefined,
|
|
192
240
|
});
|
|
193
241
|
}
|
|
@@ -195,13 +243,13 @@ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: Subag
|
|
|
195
243
|
// read. Prefer its in-memory record, then the durable replay written before
|
|
196
244
|
// result cleanup so watcher reloads do not lose completion details.
|
|
197
245
|
const late = state.completedResults?.get(run.id);
|
|
198
|
-
if (late) {
|
|
199
|
-
|
|
246
|
+
if (late && late.sessionId === run.sessionId) {
|
|
247
|
+
add(late.completion);
|
|
200
248
|
continue;
|
|
201
249
|
}
|
|
202
250
|
try {
|
|
203
251
|
const replay = readCompletionReplay(resultsDir, run.id, { sessionId: run.sessionId });
|
|
204
|
-
if (replay)
|
|
252
|
+
if (replay) add(replay.completion);
|
|
205
253
|
} catch (replayError) {
|
|
206
254
|
throw new Error(`Failed to read completion replay for '${run.id}': ${errorMessage(replayError)}`, {
|
|
207
255
|
cause: replayError instanceof Error ? replayError : undefined,
|
|
@@ -44,6 +44,7 @@ export interface ArmWaitSubscriptionInput {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export interface WaitSubscriptionManager {
|
|
47
|
+
start(): void;
|
|
47
48
|
arm(input: ArmWaitSubscriptionInput): WaitSubscriptionRecord;
|
|
48
49
|
restore(): void;
|
|
49
50
|
reconcile(): void;
|
|
@@ -109,6 +110,7 @@ export function createWaitSubscriptionManager(
|
|
|
109
110
|
state.waitSubscriptions = subscriptions;
|
|
110
111
|
const unresolvedRestoredForegroundTokens = new Set<string>();
|
|
111
112
|
let disposed = false;
|
|
113
|
+
let interval: ReturnType<typeof setInterval> | undefined;
|
|
112
114
|
let lastForeignSweepAt = 0;
|
|
113
115
|
|
|
114
116
|
/**
|
|
@@ -283,10 +285,13 @@ export function createWaitSubscriptionManager(
|
|
|
283
285
|
SUBAGENT_RESULT_INTERCOM_EVENT,
|
|
284
286
|
];
|
|
285
287
|
const unsubscribes = wakeChannels.map((channel) => pi.events.on(channel, reconcile));
|
|
286
|
-
const interval = setInterval(reconcile, options.pollIntervalMs ?? RECONCILE_INTERVAL_MS);
|
|
287
|
-
interval.unref?.();
|
|
288
288
|
|
|
289
289
|
return {
|
|
290
|
+
start() {
|
|
291
|
+
if (disposed || interval) return;
|
|
292
|
+
interval = setInterval(reconcile, options.pollIntervalMs ?? RECONCILE_INTERVAL_MS);
|
|
293
|
+
interval.unref?.();
|
|
294
|
+
},
|
|
290
295
|
arm(input) {
|
|
291
296
|
const sessionId = state.currentSessionId;
|
|
292
297
|
if (!sessionId) throw new Error("A wait subscription requires an active session identity.");
|
|
@@ -338,7 +343,8 @@ export function createWaitSubscriptionManager(
|
|
|
338
343
|
dispose() {
|
|
339
344
|
if (disposed) return;
|
|
340
345
|
disposed = true;
|
|
341
|
-
clearInterval(interval);
|
|
346
|
+
if (interval) clearInterval(interval);
|
|
347
|
+
interval = undefined;
|
|
342
348
|
for (const unsubscribe of unsubscribes) {
|
|
343
349
|
try { unsubscribe(); } catch { /* best effort */ }
|
|
344
350
|
}
|
|
@@ -11,14 +11,15 @@ export function registerWaitTool(
|
|
|
11
11
|
enabled = resolveWaitToolConfig().enabled,
|
|
12
12
|
subscriptions?: Pick<WaitSubscriptionManager, "arm">,
|
|
13
13
|
defaultTimeoutMs?: number,
|
|
14
|
+
child?: { nestedRootRunId?: string },
|
|
14
15
|
): void {
|
|
15
16
|
const description = `Wait for background, provider, or detached work that has no native completion notification, then return.
|
|
16
17
|
|
|
17
|
-
Ordinary async subagent runs already notify this session natively when they complete or need attention. In an interactive chat, return control instead of calling this merely to wait. Use this tool for provider jobs, remembered detached foreground runs, or other background work without a native notification path. Headless runs auto-drain current-session subagent work at agent_end; use this tool only when the current turn must receive non-notifying background work results.
|
|
18
|
+
${child ? "This child runtime does not install the root session's native completion notifier. Use blocking bg_wait to collect your owned descendants during this turn, then read the returned result references before synthesizing findings. Automatic draining at agent_end keeps owned work alive but does not synthesize its results." : "Ordinary async subagent runs already notify this session natively when they complete or need attention. In an interactive chat, return control instead of calling this merely to wait. Use this tool for provider jobs, remembered detached foreground runs, or other background work without a native notification path. Headless runs auto-drain current-session subagent work at agent_end; use this tool only when the current turn must receive non-notifying background work results."}
|
|
18
19
|
|
|
19
20
|
• { } — return when the first initially active async run or registered provider item finishes, or when a subagent needs attention.
|
|
20
21
|
• { all: true } — wait for every async run, provider item, and remembered detached foreground descendant that was active when the call began.
|
|
21
|
-
• { id: "..." } — wait for one async or remembered detached foreground subagent run (id or prefix).
|
|
22
|
+
• { id: "..." } — wait for one async or remembered detached foreground subagent run (id or prefix). Named async runs that already finished return their terminal result references.
|
|
22
23
|
• { id: "...", nonBlocking: true } — resolve the prefix once, persist an exact-run wake subscription, and return immediately. Use this for detached work without native completion delivery; the originating interactive session wakes on completion, failure, attention, reconciliation failure, or timeout.
|
|
23
24
|
• { stopOnAttention: false } — for blocking waits only, keep waiting through idle or long-thinking attention; supervisor/contact requests still stop the wait.
|
|
24
25
|
• { timeoutMs: 600000 } — stop waiting after N ms; active work keeps running. Omitted values use waitTool.defaultTimeoutMs, then 30 minutes. Window expiry returns a non-error window_elapsed result with active work identities.
|
|
@@ -26,6 +27,7 @@ Ordinary async subagent runs already notify this session natively when they comp
|
|
|
26
27
|
Non-blocking subscriptions are visible in subagent status and differ from disabling waitTool: waitTool.enabled=false returns immediately without registering any future wake. Provider jobs are session-scoped and identified exactly, so replacing one job with another cannot hide a completion. Provider extensions must be explicitly loaded in this process. In a child agent, keep \`bg_wait\` in the child tool allowlist and load each provider through the agent's extensions or subagentOnlyExtensions; this tool never loads providers or grants tools itself.${enabled ? "" : "\n\nConfigured behavior: bg_wait is disabled by config.waitTool or PI_SUBAGENT_WAIT_TOOL_ENABLED and returns immediately without blocking."}`;
|
|
27
28
|
const execute: ToolDefinition<typeof SubagentWaitParams, Details>["execute"] = async (_id, params, signal, onUpdate, ctx) => finalizeToolResult(await waitForSubagents(params, signal, {
|
|
28
29
|
state,
|
|
30
|
+
nestedRootRunId: child?.nestedRootRunId,
|
|
29
31
|
events: pi.events,
|
|
30
32
|
enabled,
|
|
31
33
|
...(defaultTimeoutMs !== undefined ? { defaultTimeoutMs } : {}),
|