pi-subagents 0.43.0 → 0.45.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 +37 -0
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +13 -1
- package/src/extension/config.ts +28 -7
- package/src/extension/index.ts +3 -1
- package/src/extension/tool-description.ts +2 -2
- package/src/inspectors/herdr/actions.ts +1 -1
- package/src/inspectors/herdr/project-panes.ts +1 -1
- package/src/missions/lifecycle.ts +1 -1
- package/src/missions/workflow-state.ts +188 -15
- package/src/runs/background/async-execution.ts +12 -3
- package/src/runs/background/async-status.ts +6 -1
- package/src/runs/background/result-watcher.ts +5 -0
- package/src/runs/background/run-id-resolver.ts +71 -0
- package/src/runs/background/run-status.ts +3 -2
- package/src/runs/background/subagent-runner.ts +73 -53
- package/src/runs/background/subagent-wait.ts +12 -2
- package/src/runs/background/wait-completions.ts +112 -0
- package/src/runs/foreground/chain-clarify.ts +7 -3
- package/src/runs/foreground/execution.ts +25 -19
- package/src/runs/foreground/subagent-executor.ts +159 -31
- package/src/runs/shared/subagent-prompt-runtime.ts +6 -3
- package/src/shared/settings.ts +26 -9
- package/src/shared/types.ts +40 -0
- package/src/workflows/scripted-workflow.ts +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.45.0] - 2026-08-09
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Surface terminal completion payloads in `subagent_wait` tool-result details (`details.completions`): run identity, per-child agent/`runId`/success, and artifact paths. Async completions previously reached the parent only as text — the result file is consumed and deleted after delivery — so extensions and automation had no structured way to learn which runs finished or where their artifacts live. Workflow result files now also record each child's `runId`, which was previously dropped even though the workflow engine knows it; a workflow child's `artifactPaths` entry points at its saved output (`outputs/<runId>/…`), so without the explicit field the child's identity was not recoverable from the payload. Thanks to @lucasgrecco for #915.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Clarified mission-use policy in the packaged `pi-subagents` skill.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Prefix quoted Herdr pane commands with PowerShell's call operator on Windows. Thanks to @qsgy-edge for #921.
|
|
15
|
+
- Report live child activity for async workflow runs instead of deriving a false activity age from the workflow launch time. Thanks to @alexei-led (Alexei Ledenev) for #920.
|
|
16
|
+
- Expand `reads` home paths and apply configured reads to single-run launches. Thanks to @Adjuvant (Thomas Deacon) for #916.
|
|
17
|
+
- Drop late workflow child responses after worker settlement. Thanks to @xz-dev (Xiangzhe) for #922.
|
|
18
|
+
- Stabilize steering recovery tests by invalidating cached status metadata after fast test rewrites.
|
|
19
|
+
|
|
20
|
+
## [0.44.0] - 2026-08-08
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Added one automatic enclosing mission and durable workflow state to plain `workflowScript` launches; child runs no longer create separate missions.
|
|
24
|
+
- Added `scheduledRuns.storeRoot` for durable schedules outside project repositories. Thanks to @ProCleiton for #891 and the prior #890 implementation.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- Clarified native supervisor messaging and optional external intercom result delivery in the docs and packaged skill.
|
|
28
|
+
- Identify status and transcript targets before the spawn-budget summary in collapsed tool-result cards.
|
|
29
|
+
- Point interactive async-launch guidance to `subagent_wait({ id, nonBlocking: true })` when an explicit wake is needed without blocking the current turn.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Report the explicit workflow execution cwd in async workflow status, job, and result records. Thanks to @nicobailon for #907.
|
|
33
|
+
- Ignore stale extension-context errors from advisory foreground control notifications after reload. Thanks to @alexei-led for #905.
|
|
34
|
+
- Bound inherited portable tool IDs to 64 characters for Codex-compatible child contexts while keeping tool calls and results paired. Thanks to @alexei-led for #903.
|
|
35
|
+
- Prevent boolean chain `output` values from crashing clarify rendering. Thanks to @ftoleedo for #901.
|
|
36
|
+
- Preserve `workflow` mode when asynchronous workflow mission runs complete.
|
|
37
|
+
- Serialize and merge each mission workflow-state write with the latest file so separate workflows do not drop unrelated keys.
|
|
38
|
+
- Preserve `workflowScript` worktree children that detach for supervisor coordination instead of cleaning a live managed worktree. Thanks to @astarktc for #896.
|
|
39
|
+
- Accept schema-valid structured output after a child recovers from an earlier tool error. Thanks to @white-hat for the report in #888.
|
|
40
|
+
|
|
5
41
|
## [0.43.0] - 2026-08-07
|
|
6
42
|
|
|
7
43
|
### Added
|
|
@@ -19,6 +55,7 @@
|
|
|
19
55
|
- Require workflowScript-only persisted schedule targets. Removed legacy agent-target restore conversion.
|
|
20
56
|
|
|
21
57
|
### Fixed
|
|
58
|
+
- Use portable internal ids for async workflow directories and preserve host tool-call ids as correlation metadata. Thanks to @DrunkenDonkey80 for #889.
|
|
22
59
|
- Represent gate normalization with explicit success and failure results, removing ambiguous internal states without changing gate behavior.
|
|
23
60
|
- Preserve live composite child tool-call ids for APIs that normalize them, preventing context rewriting from breaking the next tool-loop turn.
|
|
24
61
|
- Sanitize inherited child tool history ids so forked subagent context stays provider-portable.
|
package/package.json
CHANGED
|
@@ -280,6 +280,16 @@ Ordinary launches with a task create a mission by default, so substantial delega
|
|
|
280
280
|
|
|
281
281
|
Use `mission.update` while work runs to record decisions, artifacts, labels, summaries, or delivery receipts. A receipt records a pull request, CI, deployment, or release link with a concise status; it does not authorize or automate merge, CI polling, or deployment. Record open product, architecture, or safety decisions there and escalate them upward; do not let a child decide silently. Use `mission.attach-run` only for runs launched outside the normal mission-backed path, and use `mission.close` with a terminal status and concise summary when the mission is done.
|
|
282
282
|
|
|
283
|
+
### Mission use policy
|
|
284
|
+
|
|
285
|
+
- **Keep the default.** Every ordinary `workflowScript` launch with a task creates one enclosing mission automatically. All workflow children share it and never get their own. Do not add `mission: {...}` boilerplate. Pass it only to set the title, objective, labels, or to enable `goal` with `budget`.
|
|
286
|
+
- **Use `mission: false` for noise.** Use it for trivial one-shot lookups, scouts, disposable probes, and quick validation where a recovery record is noise. It removes the mission and the `state` global for the whole workflow, so do not use it for monitors or multi-workflow loops that coordinate through `state`. Scheduled runs already launch without automatic missions.
|
|
287
|
+
- **Use `missionId` for follow-up work.** Attach later work to an existing objective with `missionId`; attachment re-marks the mission active. `missionId` and `mission` are mutually exclusive. Explicit attachment fails before launch if the mission is missing, while automatic missions degrade to `details.missionWarning` without blocking the run.
|
|
288
|
+
- **Keep `state` small.** Mission `state` is JSON coordination across workflows on the same mission. Keys use the same format as run keys, values must be JSON, and the whole state file is capped at 256 KiB. Each `set` merges one key under a file lock. Put large content in artifact files and store paths in state. In goal missions, write `state.set("nextReadyAction", "...")` so the next idle-turn notice names the exact ready step.
|
|
289
|
+
- **Use artifacts and receipts as evidence.** Mission-backed launches already record run artifacts such as async `status.json`, `events.jsonl`, child output paths, and handoff manifests. Add `mission.update` artifacts only for extra durable outputs such as `patch`, `review`, or `note` files. Add receipts for external outcomes: `pull_request`, `ci`, `deployment`, or `release`; each receipt needs an absolute URL. Receipts are evidence, not authority to merge, deploy, or release.
|
|
290
|
+
- **Treat decisions as append-only.** `mission.update` `decisions` can only add open decisions. No tool action resolves one. In a goal mission, an unresolved decision becomes the fallback next ready action in each notice. Use decisions sparingly there; record them for escalation and audit, steer goal continuation through `state.nextReadyAction`, and close the mission when the question is settled.
|
|
291
|
+
- **Close missions when done.** `mission.close` takes `missionStatus` `completed`, `failed`, or `cancelled` plus a concise `summary`, and ends any goal loop. Goal notices go only to the owning session and stop silently at `budget-exhausted` without closing or claiming success, so close explicitly. Terminal missions are pruned beyond configured retention, so store durable outputs as artifacts, receipts, and summary before closing.
|
|
292
|
+
|
|
283
293
|
After compaction, restart, or confusing history, recover from durable state first: `mission.list` in the project, `mission.list` with `missionScope: "global"` for the user-local cross-project pointer index, then `mission.show` for the relevant mission. `mission.show` refreshes linked async status when available and returns warnings instead of hiding the mission if a linked status file is temporarily unreadable. Use the linked run ids with normal `status`, `steer`, `resume`, or `stop` actions. Project mission JSON remains authoritative over chat history.
|
|
284
294
|
|
|
285
295
|
Routing rule:
|
|
@@ -367,7 +377,9 @@ Use `oracle` as a smart-friend escalation when the parent needs help with trajec
|
|
|
367
377
|
|
|
368
378
|
## Subagent + Intercom Coordination
|
|
369
379
|
|
|
370
|
-
`pi-subagents` includes native supervisor coordination. Child agents can use `contact_supervisor` to ask the exact parent session that spawned them; messages are scoped by parent session id and should not appear in other Pi sessions.
|
|
380
|
+
`pi-subagents` includes native supervisor coordination. Child agents can use `contact_supervisor` to ask the exact parent session that spawned them; messages are scoped by parent session id and should not appear in other Pi sessions. Parents inspect or reply with `subagent_supervisor`. This path does not require `pi-intercom`.
|
|
381
|
+
|
|
382
|
+
This is separate from optional external completion delivery. Set `intercomBridge.resultDelivery: true` only when an external listener consumes and acknowledges `subagent:result-intercom` grouped results. It does not deliver results by itself, and it does not change native supervisor asks or progress updates.
|
|
371
383
|
|
|
372
384
|
Most agents should not call generic `intercom` directly unless bridge instructions provide a target and `contact_supervisor` is unavailable. Do not invent a target. Prefer the tool from the injected bridge instructions.
|
|
373
385
|
|
package/src/extension/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
import type { ArtifactDirPreference, ExtensionConfig } from "../shared/types.ts";
|
|
4
5
|
import { validateMissionStoreConfig } from "../missions/store.ts";
|
|
@@ -8,6 +9,31 @@ import { validatePermissionConfig } from "../runs/shared/permissions.ts";
|
|
|
8
9
|
|
|
9
10
|
const ARTIFACT_DIR_PREFERENCES = new Set<ArtifactDirPreference>(["project", "session", "temp"]);
|
|
10
11
|
|
|
12
|
+
export function resolveScheduledStoreRoot(value: string): string {
|
|
13
|
+
const expanded = value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
|
|
14
|
+
if (!path.isAbsolute(expanded)) throw new Error(`config.scheduledRuns.storeRoot must be an absolute path or "~/...", got ${JSON.stringify(value)}`);
|
|
15
|
+
return path.normalize(expanded);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateScheduledRunsConfig(value: unknown): void {
|
|
19
|
+
if (value === undefined) return;
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("config.scheduledRuns must be a JSON object");
|
|
21
|
+
const storeRoot = (value as Record<string, unknown>).storeRoot;
|
|
22
|
+
if (storeRoot === undefined) return;
|
|
23
|
+
if (typeof storeRoot !== "string" || !storeRoot.trim()) throw new Error("config.scheduledRuns.storeRoot must be a non-empty string");
|
|
24
|
+
resolveScheduledStoreRoot(storeRoot);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validateConfig(config: Record<string, unknown>): void {
|
|
28
|
+
if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
|
|
29
|
+
throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
|
|
30
|
+
}
|
|
31
|
+
validateMissionStoreConfig(config.missions);
|
|
32
|
+
validateAuthorityPolicy(config.authorityPolicy);
|
|
33
|
+
validatePermissionConfig(config.permissions);
|
|
34
|
+
validateScheduledRunsConfig(config.scheduledRuns);
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
export function getConfigPath(): string {
|
|
12
38
|
return path.join(getAgentDir(), "extensions", "subagent", "config.json");
|
|
13
39
|
}
|
|
@@ -18,13 +44,7 @@ function readConfigForUpdate(configPath = getConfigPath()): ExtensionConfig {
|
|
|
18
44
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
19
45
|
throw new Error(`Subagent config at '${configPath}' must be a JSON object`);
|
|
20
46
|
}
|
|
21
|
-
|
|
22
|
-
if (config.artifactDir !== undefined && !ARTIFACT_DIR_PREFERENCES.has(config.artifactDir as ArtifactDirPreference)) {
|
|
23
|
-
throw new Error(`config.artifactDir must be "project", "session", or "temp"`);
|
|
24
|
-
}
|
|
25
|
-
validateMissionStoreConfig(config.missions);
|
|
26
|
-
validateAuthorityPolicy(config.authorityPolicy);
|
|
27
|
-
validatePermissionConfig(config.permissions);
|
|
47
|
+
validateConfig(parsed as Record<string, unknown>);
|
|
28
48
|
return parsed as ExtensionConfig;
|
|
29
49
|
}
|
|
30
50
|
|
|
@@ -36,6 +56,7 @@ export function saveConfig(config: ExtensionConfig, configPath = getConfigPath()
|
|
|
36
56
|
export function updateConfig(updater: (config: ExtensionConfig) => ExtensionConfig): ExtensionConfig {
|
|
37
57
|
const configPath = getConfigPath();
|
|
38
58
|
const next = updater(readConfigForUpdate(configPath));
|
|
59
|
+
validateConfig(next as Record<string, unknown>);
|
|
39
60
|
saveConfig(next, configPath);
|
|
40
61
|
return next;
|
|
41
62
|
}
|
package/src/extension/index.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { formatSteeringNotice, handleSubagentSteeringNotice, SUBAGENT_STEERING_M
|
|
|
50
50
|
import { SUBAGENT_CHILD_ENV, SUBAGENT_PARENT_SESSION_ENV } from "../runs/shared/pi-args.ts";
|
|
51
51
|
import { resolveCurrentSubagentCapabilityCeiling } from "../runs/shared/capability-ceiling.ts";
|
|
52
52
|
import { formatDuration, shortenPath } from "../shared/formatters.ts";
|
|
53
|
-
import { loadConfig, resolveAsyncByDefault } from "./config.ts";
|
|
53
|
+
import { loadConfig, resolveAsyncByDefault, resolveScheduledStoreRoot } from "./config.ts";
|
|
54
54
|
import { buildSubagentToolDescription } from "./tool-description.ts";
|
|
55
55
|
import { collectGoalContinuationNotices } from "../missions/goal-driver.ts";
|
|
56
56
|
import { syncMissionFromAsyncCompletion } from "../missions/lifecycle.ts";
|
|
@@ -383,8 +383,10 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
|
|
|
383
383
|
: undefined;
|
|
384
384
|
let executorScheduled: ((id: string, params: SubagentParamsLike, signal: AbortSignal, ctx: ExtensionContext) => Promise<AgentToolResult<Details>>) | undefined;
|
|
385
385
|
let goalTurnId = 0;
|
|
386
|
+
const scheduledStoreRoot = config.scheduledRuns?.storeRoot === undefined ? undefined : resolveScheduledStoreRoot(config.scheduledRuns.storeRoot);
|
|
386
387
|
const scheduledRunManager = createScheduledRunManager({
|
|
387
388
|
config,
|
|
389
|
+
storeRoot: scheduledStoreRoot,
|
|
388
390
|
launch: (params, ctx, signal) => {
|
|
389
391
|
if (!executorScheduled) {
|
|
390
392
|
return Promise.resolve({
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -93,7 +93,7 @@ function inspectorCommand(input: { runnerPath: string; asyncDir: string; runId:
|
|
|
93
93
|
const args = [process.execPath, input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop)];
|
|
94
94
|
if (input.index !== undefined) args.push("--index", String(input.index));
|
|
95
95
|
if (input.missionPath) args.push("--mission-path", input.missionPath);
|
|
96
|
-
return args.map(shellQuote).join(" ")
|
|
96
|
+
return `${process.platform === "win32" ? "& " : ""}${args.map(shellQuote).join(" ")}`;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
function missionForRun(asyncDir: string, cwd: string, config: MissionStoreConfig | undefined, runId: string): { id: string; path: string } | undefined {
|
|
@@ -94,7 +94,7 @@ async function paneExists(client: HerdrClient, paneId: string, signal?: AbortSig
|
|
|
94
94
|
function projectPaneCommand(message: string | undefined): string {
|
|
95
95
|
const args = message?.trim() ? [message.trim()] : [];
|
|
96
96
|
const command = getPiSpawnCommand(args);
|
|
97
|
-
return [command.command, ...command.args].map(shellQuote).join(" ")
|
|
97
|
+
return `${process.platform === "win32" ? "& " : ""}${[command.command, ...command.args].map(shellQuote).join(" ")}`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export async function handleHerdrProjectPaneAction(action: HerdrProjectPaneAction, params: ProjectPaneParams, deps: ProjectPaneDeps): Promise<AgentToolResult<Details>> {
|
|
@@ -339,7 +339,7 @@ export function syncMissionFromAsyncCompletion(value: unknown): MissionRecord |
|
|
|
339
339
|
: undefined);
|
|
340
340
|
return updateMission(binding.location, binding.missionId, {
|
|
341
341
|
status: missionStatusForRun(current, runId, runStatus),
|
|
342
|
-
addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
|
|
342
|
+
addRuns: [{ runId, mode: typeof event.mode === "string" && ["single", "parallel", "chain", "workflow"].includes(event.mode) ? event.mode as SubagentRunMode : "external", asyncDir: event.asyncDir, status: runStatus, completedAt, ...(usage && usage.tokens > 0 ? { usage } : {}) }],
|
|
343
343
|
addArtifacts: artifacts,
|
|
344
344
|
...(summary ? { summary } : {}),
|
|
345
345
|
});
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
1
3
|
import * as fs from "node:fs";
|
|
2
4
|
import * as path from "node:path";
|
|
3
5
|
import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
|
|
6
|
+
import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
|
|
4
7
|
import { assertWorkflowJsonValue } from "../workflows/scripted-workflow.ts";
|
|
5
8
|
import type { MissionStoreLocation } from "./types.ts";
|
|
6
9
|
import { validateMissionId } from "./store.ts";
|
|
7
10
|
|
|
8
11
|
const STATE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
12
|
+
const STATE_LOCK_STALE_MS = 60_000;
|
|
9
13
|
export const MISSION_STATE_MAX_BYTES = 256 * 1024;
|
|
10
14
|
|
|
11
15
|
export interface MissionWorkflowState {
|
|
@@ -18,6 +22,172 @@ export function missionStatePath(location: MissionStoreLocation, missionId: stri
|
|
|
18
22
|
return path.join(location.missionDir, validateMissionId(missionId), "state.json");
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
function isProcessAlive(pid: number): boolean {
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface StateLockOwner {
|
|
35
|
+
pid: number;
|
|
36
|
+
token: string;
|
|
37
|
+
createdAt: number;
|
|
38
|
+
processKey?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function linuxProcessStartKey(pid: number): string | undefined {
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
44
|
+
const tail = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
45
|
+
return tail[19] ? `linux:${tail[19]}` : undefined;
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function psProcessStartKey(pid: number): string | undefined {
|
|
52
|
+
try {
|
|
53
|
+
const raw = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
|
|
54
|
+
return raw ? `ps:${raw}` : undefined;
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function windowsProcessStartKey(pid: number): string | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const raw = execFileSync("powershell.exe", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\").CreationDate`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
|
|
63
|
+
return raw ? `win:${raw}` : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function processStartKey(pid: number): string | undefined {
|
|
70
|
+
if (process.platform === "linux") return linuxProcessStartKey(pid) ?? psProcessStartKey(pid);
|
|
71
|
+
if (process.platform === "win32") return windowsProcessStartKey(pid);
|
|
72
|
+
return psProcessStartKey(pid);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const CURRENT_PROCESS_KEY = processStartKey(process.pid);
|
|
76
|
+
|
|
77
|
+
function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
|
|
78
|
+
try {
|
|
79
|
+
const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf-8")) as { pid?: unknown; token?: unknown; createdAt?: unknown; processKey?: unknown };
|
|
80
|
+
if (Number.isSafeInteger(owner.pid) && (owner.pid as number) > 0 && typeof owner.token === "string" && owner.token && Number.isSafeInteger(owner.createdAt)) {
|
|
81
|
+
return {
|
|
82
|
+
pid: owner.pid as number,
|
|
83
|
+
token: owner.token,
|
|
84
|
+
createdAt: owner.createdAt as number,
|
|
85
|
+
...(typeof owner.processKey === "string" && owner.processKey ? { processKey: owner.processKey } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stateLockIsStale(lockPath: string, now = Date.now()): boolean {
|
|
95
|
+
const owner = readStateLockOwner(lockPath);
|
|
96
|
+
if (owner) {
|
|
97
|
+
if (!isProcessAlive(owner.pid)) return true;
|
|
98
|
+
const currentProcessKey = processStartKey(owner.pid);
|
|
99
|
+
return Boolean(owner.processKey && currentProcessKey && owner.processKey !== currentProcessKey);
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return now - fs.statSync(lockPath).mtimeMs > STATE_LOCK_STALE_MS;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function removeOwnedStateLock(lockPath: string, owner: StateLockOwner): void {
|
|
110
|
+
const current = readStateLockOwner(lockPath);
|
|
111
|
+
if (current?.token !== owner.token) return;
|
|
112
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function staleDirectoryExists(dirPath: string, now = Date.now()): boolean {
|
|
116
|
+
try {
|
|
117
|
+
return now - fs.statSync(dirPath).mtimeMs > STATE_LOCK_STALE_MS;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function tryMakeDirectory(dirPath: string, mode: number): boolean {
|
|
125
|
+
try {
|
|
126
|
+
fs.mkdirSync(dirPath, { mode });
|
|
127
|
+
return true;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function waitForStateLock(delayMs: number | undefined, lockPath: string): void {
|
|
135
|
+
if (delayMs === undefined) throw new Error(`Timed out acquiring mission state lock '${lockPath}'.`);
|
|
136
|
+
waitForFileSystemRetry(delayMs);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
|
|
140
|
+
if (!stateLockIsStale(lockPath)) return false;
|
|
141
|
+
if (!tryMakeDirectory(reclaimPath, 0o700)) return false;
|
|
142
|
+
try {
|
|
143
|
+
if (!stateLockIsStale(lockPath)) return false;
|
|
144
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
145
|
+
return true;
|
|
146
|
+
} finally {
|
|
147
|
+
fs.rmSync(reclaimPath, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function withStateFileLock<T>(filePath: string, operation: () => T): T {
|
|
152
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
153
|
+
const lockPath = `${filePath}.lock`;
|
|
154
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
155
|
+
let owner: StateLockOwner | undefined;
|
|
156
|
+
for (let attempt = 0; ; attempt++) {
|
|
157
|
+
if (fs.existsSync(reclaimPath)) {
|
|
158
|
+
if (staleDirectoryExists(reclaimPath)) {
|
|
159
|
+
fs.rmSync(reclaimPath, { recursive: true, force: true });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
167
|
+
owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
|
|
168
|
+
try {
|
|
169
|
+
fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
|
|
170
|
+
} catch (error) {
|
|
171
|
+
removeOwnedStateLock(lockPath, owner);
|
|
172
|
+
owner = undefined;
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
178
|
+
throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
179
|
+
}
|
|
180
|
+
if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
|
|
181
|
+
waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
return operation();
|
|
186
|
+
} finally {
|
|
187
|
+
if (owner) removeOwnedStateLock(lockPath, owner);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
21
191
|
function validateStateKey(value: unknown): string {
|
|
22
192
|
if (typeof value !== "string" || !STATE_KEY_PATTERN.test(value)) {
|
|
23
193
|
throw new Error("state key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.");
|
|
@@ -30,16 +200,12 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
|
|
|
30
200
|
let loaded = false;
|
|
31
201
|
let values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
32
202
|
|
|
33
|
-
const
|
|
34
|
-
if (loaded) return values;
|
|
203
|
+
const readStateFile = (): Record<string, unknown> => {
|
|
35
204
|
let raw: string;
|
|
36
205
|
try {
|
|
37
206
|
raw = fs.readFileSync(filePath, "utf-8");
|
|
38
207
|
} catch (error) {
|
|
39
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
40
|
-
loaded = true;
|
|
41
|
-
return values;
|
|
42
|
-
}
|
|
208
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return Object.create(null) as Record<string, unknown>;
|
|
43
209
|
throw new Error(`Failed to read mission state '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
44
210
|
}
|
|
45
211
|
const bytes = Buffer.byteLength(raw);
|
|
@@ -48,14 +214,19 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
|
|
|
48
214
|
const parsed: unknown = JSON.parse(raw);
|
|
49
215
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("root must be a JSON object");
|
|
50
216
|
assertWorkflowJsonValue(parsed, "mission state");
|
|
51
|
-
|
|
52
|
-
loaded = true;
|
|
53
|
-
return values;
|
|
217
|
+
return Object.assign(Object.create(null) as Record<string, unknown>, parsed);
|
|
54
218
|
} catch (error) {
|
|
55
219
|
throw new Error(`Invalid mission state file '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
56
220
|
}
|
|
57
221
|
};
|
|
58
222
|
|
|
223
|
+
const load = (): Record<string, unknown> => {
|
|
224
|
+
if (loaded) return values;
|
|
225
|
+
values = readStateFile();
|
|
226
|
+
loaded = true;
|
|
227
|
+
return values;
|
|
228
|
+
};
|
|
229
|
+
|
|
59
230
|
return {
|
|
60
231
|
path: filePath,
|
|
61
232
|
get(key) {
|
|
@@ -66,12 +237,14 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
|
|
|
66
237
|
set(key, value) {
|
|
67
238
|
const validKey = validateStateKey(key);
|
|
68
239
|
assertWorkflowJsonValue(value, `state.set('${validKey}') value`);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
240
|
+
withStateFileLock(filePath, () => {
|
|
241
|
+
const next = Object.assign(Object.create(null) as Record<string, unknown>, readStateFile(), { [validKey]: value });
|
|
242
|
+
const bytes = Buffer.byteLength(JSON.stringify(next, null, 2));
|
|
243
|
+
if (bytes > MISSION_STATE_MAX_BYTES) throw new Error(`Mission state exceeds the 256 KiB limit (${bytes} bytes; maximum ${MISSION_STATE_MAX_BYTES} bytes).`);
|
|
244
|
+
writePrivateAtomicJson(filePath, next);
|
|
245
|
+
values = next;
|
|
246
|
+
loaded = true;
|
|
247
|
+
});
|
|
75
248
|
},
|
|
76
249
|
};
|
|
77
250
|
}
|
|
@@ -15,7 +15,7 @@ import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts"
|
|
|
15
15
|
import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
|
|
16
16
|
import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
|
|
17
17
|
import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
18
|
-
import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
|
|
18
|
+
import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
|
|
19
19
|
import type { RunnerStep } from "../shared/parallel-utils.ts";
|
|
20
20
|
import type { ContextMode } from "../shared/context-mode.ts";
|
|
21
21
|
import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
|
|
@@ -192,6 +192,7 @@ interface AsyncSingleParams {
|
|
|
192
192
|
context?: ContextMode;
|
|
193
193
|
skills?: string[];
|
|
194
194
|
output?: string | boolean;
|
|
195
|
+
reads?: string[] | false;
|
|
195
196
|
outputMode?: "inline" | "file-only";
|
|
196
197
|
outputBaseDir?: string;
|
|
197
198
|
agentContract?: AgentContract;
|
|
@@ -269,7 +270,8 @@ export function formatAsyncStartedMessage(headline: string, interactive: boolean
|
|
|
269
270
|
? [
|
|
270
271
|
"The async run is detached and running in the background.",
|
|
271
272
|
"You are in an interactive session. By default, return control to the user now; Pi will wake you on completion when the run finishes or needs attention. Do NOT call subagent_wait() merely to wait, and do not run sleep/polling loops to wait for it.",
|
|
272
|
-
"
|
|
273
|
+
"When you need an explicit wake for one known run but do not need same-turn results, call subagent_wait({ id: \"...\", nonBlocking: true }) to arm a subscription and return immediately.",
|
|
274
|
+
"Override the default and call blocking subagent_wait() before ending the turn only when the current request is run-to-completion — for example, the user asked you to report results back here before continuing, or a skill must finish in one turn. In that case, call subagent_wait() to block until the run completes so its results are delivered in this turn instead of deferred.",
|
|
273
275
|
"Otherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.",
|
|
274
276
|
]
|
|
275
277
|
: [
|
|
@@ -1287,6 +1289,13 @@ export function executeAsyncSingle(
|
|
|
1287
1289
|
const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
|
|
1288
1290
|
if (validationError) return formatAsyncStartError("single", validationError);
|
|
1289
1291
|
const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath, agentConfig);
|
|
1292
|
+
// Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
|
|
1293
|
+
// absolute paths pass through; relative paths resolve against the child cwd.
|
|
1294
|
+
const reads = params.reads !== undefined ? params.reads : agentConfig.defaultReads ?? false;
|
|
1295
|
+
const readsInstruction = Array.isArray(reads) && reads.length > 0
|
|
1296
|
+
? `[Read from: ${reads.map((f) => resolveChainPath(f, runnerCwd)).join(", ")}]\n\n`
|
|
1297
|
+
: "";
|
|
1298
|
+
const taskText = readsInstruction + taskWithOutputInstruction;
|
|
1290
1299
|
const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
|
|
1291
1300
|
params.modelOverride ?? agentConfig.model,
|
|
1292
1301
|
ctx.currentModel,
|
|
@@ -1414,7 +1423,7 @@ export function executeAsyncSingle(
|
|
|
1414
1423
|
permissionRules,
|
|
1415
1424
|
...(capabilityCeiling ? { capabilityCeiling } : {}),
|
|
1416
1425
|
agent,
|
|
1417
|
-
task:
|
|
1426
|
+
task: taskText,
|
|
1418
1427
|
...(agentConfig.runner ? { runner: agentConfig.runner } : {}),
|
|
1419
1428
|
...(params.context ? { context: params.context } : {}),
|
|
1420
1429
|
cwd: runnerCwd,
|
|
@@ -65,6 +65,7 @@ interface AsyncRunStepSummary {
|
|
|
65
65
|
export interface AsyncRunSummary {
|
|
66
66
|
id: string;
|
|
67
67
|
asyncDir: string;
|
|
68
|
+
toolCallId?: string;
|
|
68
69
|
sessionId?: string;
|
|
69
70
|
state: "queued" | "running" | "complete" | "failed" | "paused" | "stopped" | "rejected";
|
|
70
71
|
error?: string;
|
|
@@ -206,7 +207,10 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
|
|
|
206
207
|
const currentStep = typeof status.currentStep === "number" ? status.steps?.[status.currentStep] : undefined;
|
|
207
208
|
return {
|
|
208
209
|
activityState: status.activityState,
|
|
209
|
-
lastActivityAt: status.lastActivityAt
|
|
210
|
+
lastActivityAt: status.lastActivityAt
|
|
211
|
+
?? outputFileMtime(outputPath)
|
|
212
|
+
?? currentStep?.lastActivityAt
|
|
213
|
+
?? (status.mode === "workflow" ? undefined : currentStep?.startedAt ?? status.startedAt),
|
|
210
214
|
};
|
|
211
215
|
}
|
|
212
216
|
|
|
@@ -288,6 +292,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
|
|
|
288
292
|
return {
|
|
289
293
|
id: status.runId || path.basename(asyncDir),
|
|
290
294
|
asyncDir,
|
|
295
|
+
...(status.toolCallId ? { toolCallId: status.toolCallId } : {}),
|
|
291
296
|
...(status.sessionId ? { sessionId: status.sessionId } : {}),
|
|
292
297
|
state: status.state,
|
|
293
298
|
...(status.error ? { error: status.error } : {}),
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "../../intercom/result-intercom.ts";
|
|
21
21
|
import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-events.ts";
|
|
22
22
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
|
+
import { recordWaitCompletion } from "./wait-completions.ts";
|
|
23
24
|
import type { CompletionNotifier, CompletionNotification } from "./notify.ts";
|
|
24
25
|
|
|
25
26
|
const WATCHER_RESTART_DELAY_MS = 3000;
|
|
@@ -155,6 +156,10 @@ export function createResultWatcher(
|
|
|
155
156
|
}
|
|
156
157
|
const epoch = deliveryEpoch;
|
|
157
158
|
if (!ownsSession(data.sessionId, epoch)) return;
|
|
159
|
+
// Recorded before dedupe and before the unlink below: the result file is
|
|
160
|
+
// the only durable carrier of the per-run payload, and subagent_wait
|
|
161
|
+
// surfaces this record in details once the file is gone.
|
|
162
|
+
recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs);
|
|
158
163
|
const hasExplicitNestedChildren = data.nestedChildren !== undefined;
|
|
159
164
|
let nestedChildren = compactNestedResultChildren(sanitizeNestedResultChildren(data.nestedChildren, resultPath, "nestedChildren"));
|
|
160
165
|
if (!nestedChildren?.length && !hasExplicitNestedChildren) {
|