pi-subagents 0.45.0 → 0.45.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/extension/tool-description.ts +2 -2
- package/src/runs/background/control-channel.ts +4 -37
- package/src/runs/background/resume-guidance.ts +33 -0
- package/src/runs/background/subagent-runner.ts +1 -1
- package/src/runs/background/subagent-wait.ts +5 -2
- package/src/runs/background/wait-subscriptions.ts +4 -3
- package/src/runs/foreground/async-steering-action.ts +1 -1
- package/src/runs/foreground/subagent-executor.ts +36 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +71 -5
- package/src/workflows/scripted-workflow.ts +39 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.45.2] - 2026-08-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Tell parents to revive resumable failed async runs before reporting failure or launching a replacement. Thanks to @Livan-pro for #938.
|
|
9
|
+
- Persist the actual agent and session file for workflow children when they start so their sessions can resume after a parent restart. Thanks to @Livan-pro for #932.
|
|
10
|
+
- Retry steering requests that remain pending after manual compaction and fail unresolved requests at shutdown. Thanks to @jtac for #933.
|
|
11
|
+
- Omit undefined object fields from `workflowScript` return values so completed `runs.all` results are not discarded when callers include unsupported fields such as `status` (#930).
|
|
12
|
+
- Keep child steering inbox `auto` requests queued between `agent_end` and `agent_settled` so settlement-time guidance is not sent as an idle prompt too early. Thanks to @jtac for #928.
|
|
13
|
+
|
|
14
|
+
## [0.45.1] - 2026-08-09
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Simplified async workflow activity projection and its regression test to reuse canonical status types.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Add actionable guidance when Markdown fence backticks make a `workflowScript` invalid JavaScript.
|
|
21
|
+
- Prevent async interrupt requests from signaling unverified runner PIDs, including the shared host PID stored by workflows. Thanks to @kdasme for #925.
|
|
22
|
+
|
|
5
23
|
## [0.45.0] - 2026-08-09
|
|
6
24
|
|
|
7
25
|
### Added
|
package/package.json
CHANGED
|
@@ -18,7 +18,7 @@ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { work
|
|
|
18
18
|
|
|
19
19
|
EXECUTION:
|
|
20
20
|
• Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
|
|
21
|
-
• WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, and resume keeps the stored agent/model/tool contract. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
|
|
21
|
+
• WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, and resume keeps the stored agent/model/tool contract. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
|
|
22
22
|
• Sequential example: { workflowScript: "const a = await runs.run('analyze', {agent:'agent-a', task:'Analyze the request'}); return (await runs.run('plan', {agent:'agent-b', task:'Plan from: '+a.output})).output" }
|
|
23
23
|
• Parallel example: { workflowScript: "const [a,b] = await runs.all([{key:'correctness',agent:'agent-a',task:'Review correctness'},{key:'tests',agent:'agent-b',task:'Review tests'}]); return {correctness:a.output,tests:b.output}" }
|
|
24
24
|
• Optional context is "fresh" or "fork". timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls; evidence levels end at verified, and acceptance.review.required requests independent writer review.
|
|
@@ -37,7 +37,7 @@ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { w
|
|
|
37
37
|
|
|
38
38
|
EXECUTE:
|
|
39
39
|
• Call { action:"list" } first and use only executable/non-disabled agents.
|
|
40
|
-
• SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
|
|
40
|
+
• SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
|
|
41
41
|
• Example: {workflowScript:"const [a,b]=await runs.all([{key:'a',agent:'agent-a',task:'Implement A',worktree:true},{key:'b',agent:'agent-b',task:'Implement B',worktree:true}]); return [a.output,b.output]"}
|
|
42
42
|
• context can be fresh or fork. timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls.
|
|
43
43
|
|
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
* This module adds a portable, file-based control inbox inside the run directory.
|
|
10
10
|
* The parent drops an interrupt request file; the runner watches the inbox and
|
|
11
11
|
* routes the request into its existing graceful `interruptRunner()` (pause +
|
|
12
|
-
* resumable), identically on every platform. The
|
|
13
|
-
*
|
|
14
|
-
* authoritative.
|
|
12
|
+
* resumable), identically on every platform. The file inbox is authoritative and
|
|
13
|
+
* avoids signaling a PID that the extension cannot prove belongs to the runner.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
import { randomUUID } from "node:crypto";
|
|
@@ -21,13 +20,6 @@ import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
|
21
20
|
import { POLL_INTERVAL_MS } from "../../shared/types.ts";
|
|
22
21
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
22
|
|
|
24
|
-
/**
|
|
25
|
-
* Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
|
|
26
|
-
* runner; on Windows `process.kill(pid, "SIGBREAK")` is not deliverable
|
|
27
|
-
* cross-process and throws `ENOSYS`, so the file inbox below is the real channel.
|
|
28
|
-
*/
|
|
29
|
-
export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
|
|
30
|
-
|
|
31
23
|
export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
|
|
32
24
|
export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
|
|
33
25
|
type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
|
|
@@ -545,38 +537,13 @@ export function consumeCheckpointDecisionRequest(
|
|
|
545
537
|
return undefined;
|
|
546
538
|
}
|
|
547
539
|
|
|
548
|
-
/**
|
|
549
|
-
* Parent side: portable interrupt = authoritative file request + best-effort OS
|
|
550
|
-
* signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
|
|
551
|
-
* is swallowed because the file inbox is authoritative there. Other signal
|
|
552
|
-
* failures are surfaced because they usually mean the runner is not alive to
|
|
553
|
-
* consume the request.
|
|
554
|
-
*/
|
|
540
|
+
/** Parent side: write the authoritative portable interrupt request. */
|
|
555
541
|
export function deliverInterruptRequest(input: {
|
|
556
542
|
asyncDir: string;
|
|
557
|
-
pid?: number;
|
|
558
|
-
kill?: KillFn;
|
|
559
|
-
signal?: NodeJS.Signals;
|
|
560
543
|
now?: () => number;
|
|
561
544
|
source?: string;
|
|
562
545
|
}): void {
|
|
563
|
-
|
|
564
|
-
if (typeof input.pid === "number" && input.pid > 0) {
|
|
565
|
-
try {
|
|
566
|
-
(input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
|
|
567
|
-
} catch (error) {
|
|
568
|
-
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
|
|
569
|
-
// File inbox is authoritative when custom cross-process signals are unavailable.
|
|
570
|
-
return;
|
|
571
|
-
}
|
|
572
|
-
try {
|
|
573
|
-
fs.rmSync(requestPath, { force: true });
|
|
574
|
-
} catch {
|
|
575
|
-
// Best effort cleanup; the caller still gets the signal failure.
|
|
576
|
-
}
|
|
577
|
-
throw error;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
546
|
+
requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
|
|
580
547
|
}
|
|
581
548
|
|
|
582
549
|
export function deliverTimeoutRequest(input: {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import type { AsyncRunSummary } from "./async-status.ts";
|
|
3
|
+
|
|
4
|
+
export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
|
|
5
|
+
const step = run.steps.find((candidate) => candidate.status === "failed" && candidate.sessionFile && fs.existsSync(candidate.sessionFile));
|
|
6
|
+
if (!step) {
|
|
7
|
+
if (run.steps.length === 1 && run.sessionFile && fs.existsSync(run.sessionFile)) {
|
|
8
|
+
return `subagent({ action: "resume", id: "${run.id}", message: "Continue from the persisted child session and report the result." })`;
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
const index = run.steps.length === 1 ? "" : `, index: ${step.index}`;
|
|
13
|
+
return `subagent({ action: "resume", id: "${run.id}"${index}, message: "Continue from the persisted child session and report the result." })`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatResumeFirstFailedRunDetail(run: AsyncRunSummary): string | undefined {
|
|
17
|
+
if (run.state !== "failed") return undefined;
|
|
18
|
+
const command = formatAsyncReviveCommand(run);
|
|
19
|
+
if (!command) return undefined;
|
|
20
|
+
return `Resume-first: failed run "${run.id}" has a persisted child session. Revive the original run with ${command} before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatResumeFirstFailedRunsNote(runs: AsyncRunSummary[]): string {
|
|
24
|
+
const resumable = runs
|
|
25
|
+
.filter((run) => run.state === "failed")
|
|
26
|
+
.map((run) => ({ run, command: formatAsyncReviveCommand(run) }))
|
|
27
|
+
.filter((entry): entry is { run: AsyncRunSummary; command: string } => Boolean(entry.command));
|
|
28
|
+
if (resumable.length === 0) return "";
|
|
29
|
+
const guidance = resumable.length === 1
|
|
30
|
+
? `failed run "${resumable[0]!.run.id}" has a persisted child session. Revive the original run with ${resumable[0]!.command}`
|
|
31
|
+
: `${resumable.length} failed runs have persisted child sessions. Inspect status and revive each original run before retrying`;
|
|
32
|
+
return ` Resume-first: ${guidance} before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`;
|
|
33
|
+
}
|
|
@@ -2287,7 +2287,7 @@ async function runSubagent(
|
|
|
2287
2287
|
const nestedAsyncDir = run.asyncDir ?? resolveNestedAsyncDir(config.nestedRoute.rootRunId, run);
|
|
2288
2288
|
if (!nestedAsyncDir) continue;
|
|
2289
2289
|
try {
|
|
2290
|
-
deliverInterruptRequest(
|
|
2290
|
+
deliverInterruptRequest({ asyncDir: nestedAsyncDir, source: "ancestor-interrupt" });
|
|
2291
2291
|
} catch (error) {
|
|
2292
2292
|
appendJsonl(eventsPath, JSON.stringify({
|
|
2293
2293
|
type: "subagent.nested.interrupt_failed",
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from "../../shared/types.ts";
|
|
63
63
|
import { formatDuration, shortenPath } from "../../shared/formatters.ts";
|
|
64
64
|
import { collectWaitCompletions } from "./wait-completions.ts";
|
|
65
|
+
import { formatResumeFirstFailedRunsNote } from "./resume-guidance.ts";
|
|
65
66
|
export { WAIT_TOOL_ENABLED_ENV, resolveWaitToolConfig, type ResolvedWaitToolConfig } from "./wait-config.ts";
|
|
66
67
|
|
|
67
68
|
/** States that mean a run is still in flight (not yet resolved). */
|
|
@@ -604,6 +605,7 @@ export async function waitForSubagents(
|
|
|
604
605
|
let finishedAsyncCount: number;
|
|
605
606
|
let failedAsyncCount: number;
|
|
606
607
|
let completions: WaitCompletion[] | undefined;
|
|
608
|
+
let resumeGuidance = "";
|
|
607
609
|
const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
|
|
608
610
|
const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
|
|
609
611
|
try {
|
|
@@ -612,6 +614,7 @@ export async function waitForSubagents(
|
|
|
612
614
|
finishedAsyncCount = terminal.length;
|
|
613
615
|
failedAsyncCount = terminal.filter((run) => run.state === "failed").length;
|
|
614
616
|
terminalSummary = summarizeTerminalRuns(terminal, providerFinishedCount);
|
|
617
|
+
resumeGuidance = formatResumeFirstFailedRunsNote(terminal);
|
|
615
618
|
completions = collectWaitCompletions(terminal, deps.state, deps.resultsDir ?? DIRS.results);
|
|
616
619
|
} catch (error) {
|
|
617
620
|
return result(error instanceof Error ? error.message : String(error), true);
|
|
@@ -637,7 +640,7 @@ export async function waitForSubagents(
|
|
|
637
640
|
: `${initialAsyncIds.size} async run(s) and ${initialProviderIds.size} provider item(s)`;
|
|
638
641
|
const status = relevantAttention.length > 0 ? "attention required" : "done";
|
|
639
642
|
return result(
|
|
640
|
-
`Waited ${elapsed} for ${scope}; ${status}.${outcome}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
|
|
643
|
+
`Waited ${elapsed} for ${scope}; ${status}.${outcome}${resumeGuidance}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
|
|
641
644
|
deps.failOnFailedRuns === true && failedAsyncCount > 0,
|
|
642
645
|
completions,
|
|
643
646
|
);
|
|
@@ -654,7 +657,7 @@ export async function waitForSubagents(
|
|
|
654
657
|
? `${relevantAttention.length} of ${initialCount} ${subject} need attention`
|
|
655
658
|
: `${finishedCount} of ${initialCount} ${subject} finished`;
|
|
656
659
|
return result(
|
|
657
|
-
`Waited ${elapsed}; ${progress}.${outcome}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
|
|
660
|
+
`Waited ${elapsed}; ${progress}.${outcome}${resumeGuidance}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
|
|
658
661
|
deps.failOnFailedRuns === true && failedAsyncCount > 0,
|
|
659
662
|
completions,
|
|
660
663
|
);
|
|
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { listAsyncRuns } from "./async-status.ts";
|
|
5
|
+
import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
|
|
6
|
+
import { formatResumeFirstFailedRunDetail } from "./resume-guidance.ts";
|
|
6
7
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
7
8
|
import {
|
|
8
9
|
DIRS,
|
|
@@ -61,7 +62,7 @@ function parseRecord(value: unknown): WaitSubscriptionRecord | undefined {
|
|
|
61
62
|
return record as WaitSubscriptionRecord;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
function needsAttention(run:
|
|
65
|
+
function needsAttention(run: AsyncRunSummary): boolean {
|
|
65
66
|
return run.activityState === "needs_attention" || run.steps.some((step) => step.activityState === "needs_attention");
|
|
66
67
|
}
|
|
67
68
|
|
|
@@ -166,7 +167,7 @@ export function createWaitSubscriptionManager(
|
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
168
169
|
if (run.state !== "queued" && run.state !== "running") {
|
|
169
|
-
settle(record, run.state === "complete" ? "completed" : run.state, "Inspect the run status for its final output.");
|
|
170
|
+
settle(record, run.state === "complete" ? "completed" : run.state, formatResumeFirstFailedRunDetail(run) ?? "Inspect the run status for its final output.");
|
|
170
171
|
}
|
|
171
172
|
};
|
|
172
173
|
|
|
@@ -173,7 +173,7 @@ export async function steerAsyncRun(input: {
|
|
|
173
173
|
return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: preCommitResult } };
|
|
174
174
|
}
|
|
175
175
|
try {
|
|
176
|
-
deliverInterruptRequest({ asyncDir,
|
|
176
|
+
deliverInterruptRequest({ asyncDir, source: "steering-recovery" });
|
|
177
177
|
} catch (error) {
|
|
178
178
|
fs.rmSync(markerPath, { force: true });
|
|
179
179
|
fs.rmSync(claimPath, { force: true });
|
|
@@ -868,8 +868,15 @@ function interruptAsyncRun(
|
|
|
868
868
|
details: { mode: "management", results: [] },
|
|
869
869
|
};
|
|
870
870
|
}
|
|
871
|
+
if (status.mode === "workflow") {
|
|
872
|
+
return {
|
|
873
|
+
content: [{ type: "text", text: `Interrupt is unsupported for async workflow ${target.asyncId}; use stop instead.` }],
|
|
874
|
+
isError: true,
|
|
875
|
+
details: { mode: "management", results: [] },
|
|
876
|
+
};
|
|
877
|
+
}
|
|
871
878
|
try {
|
|
872
|
-
deliverInterruptRequest(
|
|
879
|
+
deliverInterruptRequest({ asyncDir: target.asyncDir, source: "interrupt-action" });
|
|
873
880
|
const tracked = state.asyncJobs.get(target.asyncId);
|
|
874
881
|
if (tracked) {
|
|
875
882
|
delete tracked.activityState;
|
|
@@ -1178,7 +1185,7 @@ function directNestedAsyncInterrupt(target: ResolvedSubagentRunId & { kind: "nes
|
|
|
1178
1185
|
const pid = typeof status?.pid === "number" && status.pid > 0 ? status.pid : run.pid;
|
|
1179
1186
|
if (!status || status.state !== "running" || typeof pid !== "number" || pid <= 0) return undefined;
|
|
1180
1187
|
try {
|
|
1181
|
-
deliverInterruptRequest({ asyncDir,
|
|
1188
|
+
deliverInterruptRequest({ asyncDir, source: "nested-interrupt" });
|
|
1182
1189
|
return { content: [{ type: "text", text: `Interrupt requested for nested async run ${run.id}.` }], details: { mode: "management", results: [] } };
|
|
1183
1190
|
} catch (error) {
|
|
1184
1191
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -3935,6 +3942,8 @@ function duplicateSubagentCallResult(params: SubagentParamsLike): AgentToolResul
|
|
|
3935
3942
|
};
|
|
3936
3943
|
}
|
|
3937
3944
|
|
|
3945
|
+
const workflowLaunchObservers = new WeakMap<object, (launch: { agent: string; sessionFile?: string }) => void>();
|
|
3946
|
+
|
|
3938
3947
|
function workflowChildResult(key: string, result: AgentToolResult<Details>): WorkflowScriptChildResult {
|
|
3939
3948
|
const receiptOutput = result.content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n");
|
|
3940
3949
|
const output = result.details.results.length === 1 && result.details.results[0]?.finalOutput !== undefined
|
|
@@ -4152,6 +4161,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4152
4161
|
ctx: ExtensionContext,
|
|
4153
4162
|
preserveActiveSession = false,
|
|
4154
4163
|
): Promise<AgentToolResult<Details>> => {
|
|
4164
|
+
const workflowLaunchObserver = workflowLaunchObservers.get(params);
|
|
4155
4165
|
const delegatedThinkingOverride = delegatedThinkingOverrides.get(params);
|
|
4156
4166
|
const allowZeroToolBudget = delegatedZeroToolBudgets.has(params);
|
|
4157
4167
|
if (!preserveActiveSession) deps.state.baseCwd = ctx.cwd;
|
|
@@ -4273,7 +4283,8 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4273
4283
|
}
|
|
4274
4284
|
};
|
|
4275
4285
|
const projectWorkflowActivity = () => {
|
|
4276
|
-
const
|
|
4286
|
+
const steps = status.steps ?? [];
|
|
4287
|
+
const runningSteps = steps.filter((step) => step.status === "running");
|
|
4277
4288
|
const lastActivityAt = runningSteps.reduce<number | undefined>((latest, step) => step.lastActivityAt === undefined ? latest : Math.max(latest ?? step.lastActivityAt, step.lastActivityAt), undefined);
|
|
4278
4289
|
const activeToolStep = runningSteps
|
|
4279
4290
|
.filter((step) => step.currentTool)
|
|
@@ -4286,11 +4297,11 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4286
4297
|
status.currentTool = activeToolStep?.currentTool;
|
|
4287
4298
|
status.currentToolStartedAt = activeToolStep?.currentToolStartedAt;
|
|
4288
4299
|
status.currentPath = activeToolStep?.currentPath;
|
|
4289
|
-
const turnCounts =
|
|
4290
|
-
const toolCounts =
|
|
4300
|
+
const turnCounts = steps.flatMap((step) => step.turnCount === undefined ? [] : [step.turnCount]);
|
|
4301
|
+
const toolCounts = steps.flatMap((step) => step.toolCount === undefined ? [] : [step.toolCount]);
|
|
4291
4302
|
status.turnCount = turnCounts.length > 0 ? turnCounts.reduce((total, count) => total + count, 0) : undefined;
|
|
4292
4303
|
status.toolCount = toolCounts.length > 0 ? toolCounts.reduce((total, count) => total + count, 0) : undefined;
|
|
4293
|
-
status.currentStep = runningSteps.length === 1 ?
|
|
4304
|
+
status.currentStep = runningSteps.length === 1 ? steps.indexOf(runningSteps[0]!) : undefined;
|
|
4294
4305
|
};
|
|
4295
4306
|
const workflowJob: AsyncJobState = { asyncId: workflowRunId, asyncDir, cwd: workflowCwd, status: "running", sessionId: currentSessionId ?? undefined, mode: "workflow", agents: [], steps: [], startedAt, updatedAt: startedAt, ...(timeout !== undefined ? { timeoutMs: timeout, deadlineAt: startedAt + timeout } : {}), workflow: status.workflow };
|
|
4296
4307
|
deps.state.asyncJobs.set(workflowRunId, workflowJob);
|
|
@@ -4341,6 +4352,13 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
4341
4352
|
if (budgetState?.exhausted) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, usageBudgetExceededMessage(budgetState)));
|
|
4342
4353
|
patchMissionObjective(childParams.task);
|
|
4343
4354
|
const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, workflowRunId, key, { missionDetached: detachWorkflowChildMissions });
|
|
4355
|
+
workflowLaunchObservers.set(childRequest, (launch) => {
|
|
4356
|
+
const step = status.steps?.find((candidate) => candidate.workflowKey === key);
|
|
4357
|
+
if (!step) return;
|
|
4358
|
+
step.agent = launch.agent;
|
|
4359
|
+
step.sessionFile = launch.sessionFile;
|
|
4360
|
+
persist();
|
|
4361
|
+
});
|
|
4344
4362
|
const result = await execute(randomUUID(), childRequest, workflowSignal, (update) => {
|
|
4345
4363
|
const progress = update.details.progress?.[0];
|
|
4346
4364
|
const step = status.steps?.find((candidate) => candidate.workflowKey === key);
|
|
@@ -5413,6 +5431,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
|
|
|
5413
5431
|
|
|
5414
5432
|
let nestedForegroundStarted = false;
|
|
5415
5433
|
try {
|
|
5434
|
+
if (workflowLaunchObserver) {
|
|
5435
|
+
const singleTask = hasTasks && effectiveParams.tasks?.length === 1 ? effectiveParams.tasks[0] : undefined;
|
|
5436
|
+
const launch = hasSingle
|
|
5437
|
+
? { agent: effectiveParams.agent!, sessionFile: childSessionFileForTask(effectiveParams.agent!, 0, effectiveParams.model) }
|
|
5438
|
+
: singleTask
|
|
5439
|
+
? { agent: singleTask.agent, sessionFile: childSessionFileForTask(singleTask.agent, 0, singleTask.model) }
|
|
5440
|
+
: undefined;
|
|
5441
|
+
if (launch) {
|
|
5442
|
+
workflowLaunchObservers.delete(params);
|
|
5443
|
+
workflowLaunchObserver(launch);
|
|
5444
|
+
}
|
|
5445
|
+
}
|
|
5416
5446
|
const asyncResult = runAsyncPath(execData, deps);
|
|
5417
5447
|
if (asyncResult) return attachMission(withResolvedContext(withForkThinkingNotes(asyncResult, forkThinkingDowngrades), contextPolicy.contextSummary));
|
|
5418
5448
|
if (foregroundControl) {
|
|
@@ -30,6 +30,7 @@ import { drainOutstandingWork } from "../background/auto-drain.ts";
|
|
|
30
30
|
const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
|
|
31
31
|
const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
|
|
32
32
|
export const SUBAGENT_INTERCOM_SESSION_NAME_ENV = "PI_SUBAGENT_INTERCOM_SESSION_NAME";
|
|
33
|
+
const STEERING_LEGACY_SETTLE_FALLBACK_MS = 1000;
|
|
33
34
|
|
|
34
35
|
const STRUCTURED_OUTPUT_INSTRUCTIONS = [
|
|
35
36
|
"This subagent step has a strict structured output contract.",
|
|
@@ -330,7 +331,7 @@ function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undef
|
|
|
330
331
|
|
|
331
332
|
export function registerSteeringInbox(
|
|
332
333
|
pi: ExtensionAPI,
|
|
333
|
-
deps: { watch?: typeof fs.watch; nativeRealpath?: (filePath: string) => string } = {},
|
|
334
|
+
deps: { watch?: typeof fs.watch; nativeRealpath?: (filePath: string) => string; legacySettleFallbackMs?: number } = {},
|
|
334
335
|
): void {
|
|
335
336
|
const steerInbox = process.env[SUBAGENT_STEER_INBOX_ENV]?.trim();
|
|
336
337
|
if (!steerInbox) return;
|
|
@@ -343,11 +344,14 @@ export function registerSteeringInbox(
|
|
|
343
344
|
let disposed = false;
|
|
344
345
|
let agentRunning = false;
|
|
345
346
|
let inTurn = false;
|
|
347
|
+
let awaitingSettlement = false;
|
|
346
348
|
let flushing = false;
|
|
347
349
|
let started = false;
|
|
348
350
|
let canSteer = typeof sendUserMessage === "function";
|
|
349
351
|
let watcher: fs.FSWatcher | undefined;
|
|
350
352
|
let interval: NodeJS.Timeout | undefined;
|
|
353
|
+
let settleFallback: NodeJS.Timeout | undefined;
|
|
354
|
+
const legacySettleFallbackMs = deps.legacySettleFallbackMs ?? STEERING_LEGACY_SETTLE_FALLBACK_MS;
|
|
351
355
|
const acknowledge = (request: SteerRequest, state: "delivered" | "queued" | "failed", message: string, deliveryStatus?: SteerDeliveryStatus): void => {
|
|
352
356
|
if (!ackDir || !Number.isInteger(childIndex) || childIndex < 0) return;
|
|
353
357
|
writeSteerAckAt(steerAckPathFromDir(ackDir, request.id), {
|
|
@@ -375,7 +379,8 @@ export function registerSteeringInbox(
|
|
|
375
379
|
continue;
|
|
376
380
|
}
|
|
377
381
|
const requestedMode = request.mode ?? "steer";
|
|
378
|
-
const
|
|
382
|
+
const autoCanUseIdle = requestedMode === "auto" && !agentRunning && !awaitingSettlement;
|
|
383
|
+
const delivery = requestedMode === "follow_up" || (requestedMode === "auto" && (inTurn || awaitingSettlement)) ? "followUp" as const : "steer" as const;
|
|
379
384
|
const pendingFollowUps = [...pending.values()].reduce((count, entries) => count + entries.filter((entry) => entry.deliveryStatus === "queued").length, 0);
|
|
380
385
|
if (delivery === "followUp" && queued.length + pendingFollowUps >= MAX_STEER_QUEUE_SIZE) {
|
|
381
386
|
acknowledge(request, "failed", `Follow-up queue is full (${MAX_STEER_QUEUE_SIZE} messages).`);
|
|
@@ -386,7 +391,7 @@ export function registerSteeringInbox(
|
|
|
386
391
|
entries.push({ request, deliveryStatus: delivery === "followUp" ? "queued" : "delivered" });
|
|
387
392
|
pending.set(formatted, entries);
|
|
388
393
|
try {
|
|
389
|
-
sendUserMessage(formatted,
|
|
394
|
+
sendUserMessage(formatted, autoCanUseIdle ? undefined : { deliverAs: delivery });
|
|
390
395
|
} catch (error) {
|
|
391
396
|
entries.pop();
|
|
392
397
|
if (entries.length === 0) pending.delete(formatted);
|
|
@@ -440,14 +445,71 @@ export function registerSteeringInbox(
|
|
|
440
445
|
flush();
|
|
441
446
|
return undefined;
|
|
442
447
|
};
|
|
448
|
+
const clearSettleFallback = (): void => {
|
|
449
|
+
if (!settleFallback) return;
|
|
450
|
+
clearTimeout(settleFallback);
|
|
451
|
+
settleFallback = undefined;
|
|
452
|
+
};
|
|
453
|
+
const markSettled = (): undefined => {
|
|
454
|
+
clearSettleFallback();
|
|
455
|
+
agentRunning = false;
|
|
456
|
+
inTurn = false;
|
|
457
|
+
awaitingSettlement = false;
|
|
458
|
+
return activate();
|
|
459
|
+
};
|
|
460
|
+
const armLegacySettleFallback = (): void => {
|
|
461
|
+
clearSettleFallback();
|
|
462
|
+
settleFallback = setTimeout(() => {
|
|
463
|
+
settleFallback = undefined;
|
|
464
|
+
if (disposed || !awaitingSettlement) return;
|
|
465
|
+
agentRunning = false;
|
|
466
|
+
inTurn = false;
|
|
467
|
+
awaitingSettlement = false;
|
|
468
|
+
activate();
|
|
469
|
+
}, legacySettleFallbackMs);
|
|
470
|
+
settleFallback.unref?.();
|
|
471
|
+
};
|
|
443
472
|
|
|
444
473
|
const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
|
|
445
474
|
// Register input before the watcher so an accepted extension input cannot race request dispatch.
|
|
446
475
|
onRuntimeEvent("input", onInput);
|
|
447
476
|
onRuntimeEvent("session_start", () => start());
|
|
448
|
-
onRuntimeEvent("agent_start", () => {
|
|
449
|
-
|
|
477
|
+
onRuntimeEvent("agent_start", () => {
|
|
478
|
+
clearSettleFallback();
|
|
479
|
+
agentRunning = true;
|
|
480
|
+
awaitingSettlement = false;
|
|
481
|
+
return activate();
|
|
482
|
+
});
|
|
483
|
+
onRuntimeEvent("agent_end", (event) => {
|
|
484
|
+
inTurn = false;
|
|
485
|
+
if ((event as { willRetry?: unknown } | undefined)?.willRetry === true) {
|
|
486
|
+
clearSettleFallback();
|
|
487
|
+
agentRunning = true;
|
|
488
|
+
awaitingSettlement = true;
|
|
489
|
+
return activate();
|
|
490
|
+
}
|
|
491
|
+
agentRunning = true;
|
|
492
|
+
awaitingSettlement = true;
|
|
493
|
+
armLegacySettleFallback();
|
|
494
|
+
return activate();
|
|
495
|
+
});
|
|
496
|
+
onRuntimeEvent("agent_settled", markSettled);
|
|
497
|
+
onRuntimeEvent("session_compact", () => {
|
|
498
|
+
const unresolved = [...pending.values()].flat();
|
|
499
|
+
pending.clear();
|
|
500
|
+
for (const entry of unresolved) {
|
|
501
|
+
try {
|
|
502
|
+
writeSteerRequestToDir(steerInbox, { ...entry.request, mode: "follow_up" });
|
|
503
|
+
} catch (error) {
|
|
504
|
+
acknowledge(entry.request, "failed", `Could not retry steering after compaction: ${error instanceof Error ? error.message : String(error)}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return activate();
|
|
508
|
+
});
|
|
450
509
|
onRuntimeEvent("turn_start", () => {
|
|
510
|
+
clearSettleFallback();
|
|
511
|
+
agentRunning = true;
|
|
512
|
+
awaitingSettlement = false;
|
|
451
513
|
inTurn = true;
|
|
452
514
|
const next = queued.findIndex((entry) => entry.ready);
|
|
453
515
|
if (next >= 0) {
|
|
@@ -466,7 +528,11 @@ export function registerSteeringInbox(
|
|
|
466
528
|
}
|
|
467
529
|
onRuntimeEvent("session_shutdown", () => {
|
|
468
530
|
for (const entry of queued) acknowledge(entry.request, "failed", "Run ended before queued follow-up delivery.", "queued");
|
|
531
|
+
for (const entries of pending.values()) {
|
|
532
|
+
for (const entry of entries) acknowledge(entry.request, "failed", "Run ended before Pi confirmed steering input delivery.");
|
|
533
|
+
}
|
|
469
534
|
disposed = true;
|
|
535
|
+
clearSettleFallback();
|
|
470
536
|
try { watcher?.close(); } catch {}
|
|
471
537
|
if (interval) clearInterval(interval);
|
|
472
538
|
});
|
|
@@ -105,6 +105,17 @@ const capturedConsole = Object.freeze(Object.fromEntries(
|
|
|
105
105
|
}]),
|
|
106
106
|
));
|
|
107
107
|
|
|
108
|
+
function formatWorkflowScriptSyntaxError(error) {
|
|
109
|
+
const details = error && error.stack ? error.stack : String(error);
|
|
110
|
+
return [
|
|
111
|
+
"workflowScript must be valid JavaScript.",
|
|
112
|
+
"If task text contains Markdown fences or backticks, use an array joined with \"\\n\" or escaped strings instead of a raw backtick template literal.",
|
|
113
|
+
"",
|
|
114
|
+
"Original SyntaxError:",
|
|
115
|
+
details,
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
function assertJsonValue(value, path = "emit", seen = new Set()) {
|
|
109
120
|
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
110
121
|
if (typeof value === "number") {
|
|
@@ -128,6 +139,25 @@ function assertJsonValue(value, path = "emit", seen = new Set()) {
|
|
|
128
139
|
seen.delete(value);
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
function isPlainWorkflowObject(value) {
|
|
143
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
144
|
+
const prototype = Object.getPrototypeOf(value);
|
|
145
|
+
return prototype === null || prototype === Object.prototype || prototype === contextObjectPrototype;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function omitUndefinedWorkflowValues(value, seen = new Set()) {
|
|
149
|
+
if (value === null || typeof value !== "object") return value;
|
|
150
|
+
if (seen.has(value)) return value;
|
|
151
|
+
seen.add(value);
|
|
152
|
+
const normalized = Array.isArray(value)
|
|
153
|
+
? value.map((entry) => entry === undefined ? null : omitUndefinedWorkflowValues(entry, seen))
|
|
154
|
+
: isPlainWorkflowObject(value) && Object.getOwnPropertySymbols(value).length === 0
|
|
155
|
+
? Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => entry === undefined ? [] : [[key, omitUndefinedWorkflowValues(entry, seen)]]))
|
|
156
|
+
: value;
|
|
157
|
+
seen.delete(value);
|
|
158
|
+
return normalized;
|
|
159
|
+
}
|
|
160
|
+
|
|
131
161
|
parentPort.on("message", async (message) => {
|
|
132
162
|
if (message.type === "response") {
|
|
133
163
|
const entry = pending.get(message.callId);
|
|
@@ -143,9 +173,16 @@ parentPort.on("message", async (message) => {
|
|
|
143
173
|
if (message.stateEnabled) sandbox.state = state;
|
|
144
174
|
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
145
175
|
contextObjectPrototype = vm.runInContext("Object.prototype", context);
|
|
146
|
-
|
|
176
|
+
let compiled;
|
|
177
|
+
try {
|
|
178
|
+
compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "workflow-script.js" });
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
181
|
+
parentPort.postMessage({ type: "error", error: formatWorkflowScriptSyntaxError(error) });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
147
184
|
const value = await compiled.runInContext(context);
|
|
148
|
-
const persistedValue = value === undefined ? null : value;
|
|
185
|
+
const persistedValue = value === undefined ? null : omitUndefinedWorkflowValues(value);
|
|
149
186
|
assertJsonValue(persistedValue, "return");
|
|
150
187
|
parentPort.postMessage({ type: "complete", value: persistedValue });
|
|
151
188
|
} catch (error) {
|