pi-subagents 0.60.0 → 0.61.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 +32 -0
- package/docs/agents.md +2 -2
- package/docs/configuration.md +9 -5
- package/docs/extension-api.md +14 -7
- package/docs/models.md +1 -1
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +12 -3
- package/docs/workflows.md +14 -13
- package/install.mjs +2 -1
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +6 -4
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/skills/pi-subagents/references/execution-controls.md +42 -11
- package/skills/pi-subagents/references/multi-lane-orchestration.md +1 -1
- package/skills/pi-subagents/references/prompting-and-roles.md +4 -4
- package/skills/pi-subagents/references/review-and-validation.md +1 -1
- package/src/agents/agent-management.ts +102 -63
- package/src/agents/agents.ts +527 -221
- package/src/api/background-work.ts +7 -2
- package/src/api/external-runs.ts +67 -4
- package/src/api/preflight.ts +13 -8
- package/src/api/shared-types.ts +1 -0
- package/src/extension/index.ts +7 -4
- package/src/extension/public-execution.ts +47 -4
- package/src/extension/rpc.ts +62 -4
- package/src/extension/schemas.ts +11 -7
- package/src/extension/tool-description.ts +14 -18
- package/src/runs/background/async-execution.ts +51 -35
- package/src/runs/background/async-job-tracker.ts +62 -3
- package/src/runs/background/async-resume.ts +3 -1
- package/src/runs/background/async-status.ts +59 -9
- package/src/runs/background/auto-drain.ts +1 -1
- package/src/runs/background/fleet-view.ts +1 -1
- package/src/runs/background/result-watcher.ts +1 -1
- package/src/runs/background/resume-guidance.ts +1 -1
- package/src/runs/background/run-status.ts +2 -2
- package/src/runs/background/subagent-runner.ts +1 -2
- package/src/runs/background/subagent-wait.ts +20 -21
- package/src/runs/background/wait-completions.ts +1 -1
- package/src/runs/background/wait-tool.ts +24 -18
- package/src/runs/foreground/execution.ts +61 -2
- package/src/runs/foreground/subagent-executor.ts +178 -58
- package/src/runs/shared/acceptance.ts +43 -18
- package/src/runs/shared/host-step-status.ts +1 -0
- package/src/runs/shared/model-fallback.ts +61 -17
- package/src/runs/shared/permissions.ts +1 -1
- package/src/runs/shared/tool-timeout.ts +1 -1
- package/src/runs/shared/workflow-graph.ts +3 -2
- package/src/shared/types.ts +40 -3
- package/src/shared/workflow-child-permit.ts +91 -0
- package/src/slash/prompt-template-bridge.ts +37 -1
- package/src/slash/slash-commands.ts +18 -26
- package/src/tui/render.ts +95 -52
- package/src/workflows/scripted-workflow.ts +153 -4
- package/src/workflows/workflow-child-summary.ts +1 -1
- package/src/workflows/workflow-receipt.ts +41 -4
- package/src/workflows/workflow-resources.ts +150 -0
|
@@ -26,7 +26,7 @@ import { backgroundProcessOptions } from "../shared/background-process-options.t
|
|
|
26
26
|
import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
|
|
27
27
|
import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
|
|
28
28
|
import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, PROMPT_REDACTED, resolveChildCwd } from "../../shared/utils.ts";
|
|
29
|
-
import { buildModelCandidates,
|
|
29
|
+
import { buildModelCandidates, resolveEffectiveSubagentModel, resolveModelOrigin, resolveSubagentModelOverride, type AvailableModelInfo, type ModelOrigin, type ParentModel } from "../shared/model-fallback.ts";
|
|
30
30
|
import { resolveToolTimeoutMs, toolTimeoutFromEnv } from "../shared/tool-timeout.ts";
|
|
31
31
|
import { resolveModelScopesForAgent, type ModelScopeConfig } from "../shared/model-scope.ts";
|
|
32
32
|
import { findModelInfo, resolveEffectiveThinking } from "../../shared/model-info.ts";
|
|
@@ -235,6 +235,7 @@ interface AsyncSingleParams {
|
|
|
235
235
|
structuredOutputSchema?: JsonSchemaObject;
|
|
236
236
|
modelOverride?: string;
|
|
237
237
|
modelOverrideFromParent?: boolean;
|
|
238
|
+
modelOrigin?: ModelOrigin;
|
|
238
239
|
fast?: boolean;
|
|
239
240
|
thinkingOverride?: AgentConfig["thinking"];
|
|
240
241
|
availableModels?: AvailableModelInfo[];
|
|
@@ -339,15 +340,15 @@ export function formatAsyncStartedMessage(headline: string, interactive: boolean
|
|
|
339
340
|
const guidance = interactive
|
|
340
341
|
? [
|
|
341
342
|
"The async run is detached and running in the background.",
|
|
342
|
-
"You are in an interactive session.
|
|
343
|
-
"
|
|
344
|
-
"
|
|
343
|
+
"You are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.",
|
|
344
|
+
"Use bg_wait only for provider, detached, or other background work that lacks a native completion notification.",
|
|
345
|
+
"If the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.",
|
|
345
346
|
"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.",
|
|
346
347
|
]
|
|
347
348
|
: [
|
|
348
349
|
"The async run is detached. Do not run sleep timers or polling loops just to wait for it.",
|
|
349
|
-
"This is a non-interactive run: Pi auto-drains current-session
|
|
350
|
-
"Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run
|
|
350
|
+
"This is a non-interactive run: Pi auto-drains current-session subagent work at agent_end so detached children are not abandoned. Use bg_wait only when this turn must receive provider, detached, or other background-work results that have no native completion notification.",
|
|
351
|
+
"Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run; do not poll in a loop.",
|
|
351
352
|
];
|
|
352
353
|
return [headline, "", ...guidance].join("\n");
|
|
353
354
|
}
|
|
@@ -857,14 +858,15 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
857
858
|
const task = namespaceOutputPath ? taskText : injectSingleOutputInstruction(taskText, outputPath, a);
|
|
858
859
|
|
|
859
860
|
const modelScopes = resolveModelScopesForAgent(ctx.modelScope, a.name, ctx.currentModel);
|
|
860
|
-
const
|
|
861
|
+
const modelOrigin = resolveModelOrigin({ explicitModel: s.model, agentModel: a.model, parentModel: ctx.currentModel });
|
|
862
|
+
const primaryModelFromParent = modelOrigin === "inherited";
|
|
861
863
|
const primaryModel = externalRunner ? undefined : resolveEffectiveSubagentModel(
|
|
862
864
|
s.model,
|
|
863
865
|
a.model,
|
|
864
866
|
ctx.currentModel,
|
|
865
867
|
availableModels,
|
|
866
868
|
a.modelProvider ?? ctx.currentModelProvider,
|
|
867
|
-
{ scope: modelScopes },
|
|
869
|
+
{ scope: modelScopes, source: modelOrigin === "explicit" ? "explicit" : "inherited" },
|
|
868
870
|
);
|
|
869
871
|
const thinkingOverride = flatIndex === undefined ? undefined : thinkingOverridesByFlatIndex?.[flatIndex];
|
|
870
872
|
const effectiveThinking = externalRunner ? undefined : thinkingOverride ?? a.thinking;
|
|
@@ -884,15 +886,17 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
884
886
|
}
|
|
885
887
|
const agentContract = s.agentContract ?? params.agentContract;
|
|
886
888
|
const permissionRules = resolvePermissionRules(ctx.permissions, a.permissions);
|
|
887
|
-
|
|
888
|
-
scope: modelScopes,
|
|
889
|
-
primaryModelFromParent,
|
|
890
|
-
}).flatMap((candidate) => {
|
|
891
|
-
const resolved = applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined);
|
|
892
|
-
return resolved ? [resolved] : [];
|
|
893
|
-
});
|
|
889
|
+
let modelCandidates: string[] = [];
|
|
894
890
|
if (!externalRunner) {
|
|
895
891
|
try {
|
|
892
|
+
modelCandidates = buildModelCandidates(primaryModel, a.fallbackModels, availableModels, a.modelProvider ?? ctx.currentModelProvider, {
|
|
893
|
+
scope: modelScopes,
|
|
894
|
+
primaryModelFromParent,
|
|
895
|
+
origin: modelOrigin,
|
|
896
|
+
}).flatMap((candidate) => {
|
|
897
|
+
const resolved = applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined);
|
|
898
|
+
return resolved ? [resolved] : [];
|
|
899
|
+
});
|
|
896
900
|
for (const candidate of modelCandidates) assertThinkingWithinCeiling({ model: candidate, configThinking: effectiveThinking, ceiling: thinkingCeiling, agent: a.name, runId: id });
|
|
897
901
|
} catch (error) {
|
|
898
902
|
throw new AsyncStartValidationError(error instanceof Error ? error.message : String(error));
|
|
@@ -1588,15 +1592,27 @@ export function executeAsyncSingle(
|
|
|
1588
1592
|
: "";
|
|
1589
1593
|
const taskText = readsInstruction + taskWithOutputInstruction;
|
|
1590
1594
|
const modelScopes = resolveModelScopesForAgent(ctx.modelScope, agentConfig.name, ctx.currentModel);
|
|
1591
|
-
const
|
|
1592
|
-
|
|
1593
|
-
:
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1595
|
+
const modelOrigin = resolveModelOrigin({
|
|
1596
|
+
fromParent: params.modelOverrideFromParent,
|
|
1597
|
+
storedOrigin: params.modelOrigin,
|
|
1598
|
+
explicitModel: params.modelOverrideFromParent ? undefined : params.modelOverride,
|
|
1599
|
+
agentModel: agentConfig.model,
|
|
1600
|
+
parentModel: ctx.currentModel,
|
|
1601
|
+
});
|
|
1602
|
+
let primaryModel: string | undefined;
|
|
1603
|
+
try {
|
|
1604
|
+
primaryModel = externalRunner ? undefined : modelOrigin === "inherited"
|
|
1605
|
+
? params.modelOverride ?? (ctx.currentModel ? `${ctx.currentModel.provider}/${ctx.currentModel.id}` : undefined)
|
|
1606
|
+
: resolveSubagentModelOverride(
|
|
1607
|
+
params.modelOverride ?? agentConfig.model,
|
|
1608
|
+
ctx.currentModel,
|
|
1609
|
+
availableModels,
|
|
1610
|
+
ctx.currentModelProvider,
|
|
1611
|
+
{ scope: modelScopes, source: modelOrigin === "explicit" ? "explicit" : "inherited" },
|
|
1612
|
+
);
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
return formatAsyncStartError("single", error instanceof Error ? error.message : String(error));
|
|
1615
|
+
}
|
|
1600
1616
|
const effectiveThinking = externalRunner ? undefined : params.thinkingOverride ?? agentConfig.thinking;
|
|
1601
1617
|
const model = externalRunner ? undefined : applyThinkingSuffix(primaryModel, effectiveThinking, params.thinkingOverride !== undefined);
|
|
1602
1618
|
const contextLimit = model ? findModelInfo(model, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider)?.contextWindow : undefined;
|
|
@@ -1633,18 +1649,17 @@ export function executeAsyncSingle(
|
|
|
1633
1649
|
const structuredOutput = params.structuredOutputSchema
|
|
1634
1650
|
? createStructuredOutputRuntime(params.structuredOutputSchema, path.join(asyncDir, "structured-output"), { captureAcceptanceReport: params.acceptance !== false })
|
|
1635
1651
|
: undefined;
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1652
|
+
let modelCandidates: string[] = [];
|
|
1653
|
+
if (!externalRunner) {
|
|
1654
|
+
try {
|
|
1655
|
+
modelCandidates = buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, agentConfig.modelProvider ?? ctx.currentModelProvider, {
|
|
1656
|
+
scope: modelScopes,
|
|
1657
|
+
primaryModelFromParent: modelOrigin === "inherited",
|
|
1658
|
+
origin: modelOrigin,
|
|
1659
|
+
}).flatMap((candidate) => {
|
|
1643
1660
|
const resolved = applyThinkingSuffix(candidate, effectiveThinking, params.thinkingOverride !== undefined);
|
|
1644
1661
|
return resolved ? [resolved] : [];
|
|
1645
1662
|
});
|
|
1646
|
-
if (!externalRunner) {
|
|
1647
|
-
try {
|
|
1648
1663
|
for (const candidate of modelCandidates) assertThinkingWithinCeiling({ model: candidate, configThinking: effectiveThinking, ceiling: thinkingCeiling, agent: agentConfig.name, runId: id });
|
|
1649
1664
|
} catch (error) {
|
|
1650
1665
|
return formatAsyncStartError("single", error instanceof Error ? error.message : String(error));
|
|
@@ -1730,7 +1745,8 @@ export function executeAsyncSingle(
|
|
|
1730
1745
|
...(model ? { model } : {}),
|
|
1731
1746
|
...(params.fast ?? recoveryAgentConfig.fast ? { fast: params.fast ?? recoveryAgentConfig.fast } : {}),
|
|
1732
1747
|
...(recoveryAgentConfig.modelProvider ? { modelProvider: recoveryAgentConfig.modelProvider } : {}),
|
|
1733
|
-
...(
|
|
1748
|
+
...(modelOrigin === "inherited" ? { modelOverrideFromParent: true } : {}),
|
|
1749
|
+
modelOrigin,
|
|
1734
1750
|
...(recoveryAgentConfig.fallbackModels ? { fallbackModels: [...recoveryAgentConfig.fallbackModels] } : {}),
|
|
1735
1751
|
...(effectiveThinking ? { thinking: resolveEffectiveThinking(model, effectiveThinking) } : {}),
|
|
1736
1752
|
...(thinkingCeiling ? { thinkingCeiling } : {}),
|
|
@@ -1799,7 +1815,7 @@ export function executeAsyncSingle(
|
|
|
1799
1815
|
thinking: resolveEffectiveThinking(model, effectiveThinking),
|
|
1800
1816
|
...(thinkingCeiling ? { thinkingCeiling } : {}),
|
|
1801
1817
|
modelCandidates,
|
|
1802
|
-
...(
|
|
1818
|
+
...(modelOrigin === "inherited" ? { skipPrimaryModelVerification: true } : {}),
|
|
1803
1819
|
...(availableModels && availableModels.length > 0 ? { modelVerificationRegistry: availableModels } : {}),
|
|
1804
1820
|
tools: agentConfig.tools,
|
|
1805
1821
|
allowNestedSubagents: agentConfig.allowNestedSubagents,
|
|
@@ -73,7 +73,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
73
73
|
const steeringNoticeSeen = new Map<string, number>();
|
|
74
74
|
const jobWatchers = new Map<string, { watchers: Map<string, fs.FSWatcher>; retryTimer?: ReturnType<typeof setTimeout> }>();
|
|
75
75
|
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
76
|
+
let widgetRerenderTimer: ReturnType<typeof setTimeout> | undefined;
|
|
76
77
|
const runningJobIds = new Set<string>();
|
|
78
|
+
const externalJobBridgeRuns = new Set<string>();
|
|
79
|
+
const externalJobBridgeEligibility = (steps: AsyncJobState["steps"]): "required" | "not-required" | "unknown" => {
|
|
80
|
+
if (!Array.isArray(steps)) return "unknown";
|
|
81
|
+
for (const step of steps) {
|
|
82
|
+
const runner = (step as { runner?: unknown } | null)?.runner;
|
|
83
|
+
if (runner === undefined) continue;
|
|
84
|
+
if (!runner || typeof runner !== "object" || Array.isArray(runner)) return "unknown";
|
|
85
|
+
const runnerType = (runner as { type?: unknown }).type;
|
|
86
|
+
if (typeof runnerType !== "string") return "unknown";
|
|
87
|
+
if (runnerType === "external-job") return "required";
|
|
88
|
+
if (runnerType !== "pi" && runnerType !== "external-cli") return "unknown";
|
|
89
|
+
}
|
|
90
|
+
return "not-required";
|
|
91
|
+
};
|
|
77
92
|
let rootWatcher: fs.FSWatcher | undefined;
|
|
78
93
|
let nextLivenessAt = Date.now() + livenessIntervalMs;
|
|
79
94
|
let nextWidgetAnimationAt = Date.now() + WIDGET_ANIMATION_INTERVAL_MS;
|
|
@@ -94,9 +109,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
94
109
|
const rerenderLastWidget = (jobs = Array.from(state.asyncJobs.values())) => {
|
|
95
110
|
withLastUiContext((ctx) => rerenderWidget(ctx, jobs));
|
|
96
111
|
};
|
|
112
|
+
const scheduleLastWidgetRerender = () => {
|
|
113
|
+
if (widgetRerenderTimer) return;
|
|
114
|
+
widgetRerenderTimer = setTimeout(() => {
|
|
115
|
+
widgetRerenderTimer = undefined;
|
|
116
|
+
rerenderLastWidget();
|
|
117
|
+
}, EVENT_REFRESH_DEBOUNCE_MS);
|
|
118
|
+
widgetRerenderTimer.unref?.();
|
|
119
|
+
};
|
|
97
120
|
const requestLastWidgetRender = () => {
|
|
98
121
|
if (options.widgetEnabled === false) return;
|
|
99
|
-
|
|
122
|
+
withLastUiContext((ctx) => {
|
|
123
|
+
if (state.widgetsSuspended) return;
|
|
124
|
+
const requestRender = (ctx.ui as { requestRender?: () => void }).requestRender;
|
|
125
|
+
if (requestRender) requestRender.call(ctx.ui);
|
|
126
|
+
else renderWidget(ctx, Array.from(state.asyncJobs.values()));
|
|
127
|
+
});
|
|
100
128
|
};
|
|
101
129
|
const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
|
|
102
130
|
const restoredControlEventCursor = (asyncDir: string) => {
|
|
@@ -340,6 +368,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
340
368
|
if (timer) clearTimeout(timer);
|
|
341
369
|
refreshTimers.delete(asyncId);
|
|
342
370
|
runningJobIds.delete(asyncId);
|
|
371
|
+
externalJobBridgeRuns.delete(asyncId);
|
|
343
372
|
};
|
|
344
373
|
|
|
345
374
|
const refreshJob = (job: AsyncJobState): boolean => {
|
|
@@ -354,9 +383,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
354
383
|
console.error(`Failed to refresh nested async descendants for '${job.asyncDir}':`, error);
|
|
355
384
|
}
|
|
356
385
|
};
|
|
386
|
+
let bridgeSweepAttempted = false;
|
|
357
387
|
try {
|
|
358
388
|
emitNewControlEvents(job);
|
|
359
|
-
|
|
389
|
+
const bridgeAlreadyRequired = externalJobBridgeRuns.has(job.asyncId) || externalJobBridgeEligibility(job.steps) === "required";
|
|
390
|
+
if (bridgeAlreadyRequired) {
|
|
391
|
+
externalJobBridgeRuns.add(job.asyncId);
|
|
392
|
+
bridgeSweepAttempted = true;
|
|
393
|
+
serviceExternalJobBridgeRequests(job.asyncDir);
|
|
394
|
+
}
|
|
360
395
|
try {
|
|
361
396
|
if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
|
|
362
397
|
} catch (error) {
|
|
@@ -382,6 +417,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
382
417
|
},
|
|
383
418
|
});
|
|
384
419
|
const status = reconciliation.status ?? readStatus(job.asyncDir);
|
|
420
|
+
if (!bridgeAlreadyRequired) {
|
|
421
|
+
const bridgeEligibility = externalJobBridgeEligibility(status?.steps);
|
|
422
|
+
if (bridgeEligibility === "required") externalJobBridgeRuns.add(job.asyncId);
|
|
423
|
+
// Missing or ambiguous status retains the legacy sweep for recovery races.
|
|
424
|
+
if (bridgeEligibility !== "not-required") {
|
|
425
|
+
bridgeSweepAttempted = true;
|
|
426
|
+
serviceExternalJobBridgeRequests(job.asyncDir);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
385
429
|
if (status) {
|
|
386
430
|
const previousStatus = job.status;
|
|
387
431
|
job.status = status.state;
|
|
@@ -456,6 +500,14 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
456
500
|
runningJobIds.add(job.asyncId);
|
|
457
501
|
}
|
|
458
502
|
} catch (error) {
|
|
503
|
+
if (!bridgeSweepAttempted) {
|
|
504
|
+
try {
|
|
505
|
+
bridgeSweepAttempted = true;
|
|
506
|
+
serviceExternalJobBridgeRequests(job.asyncDir);
|
|
507
|
+
} catch (bridgeError) {
|
|
508
|
+
console.error(`Failed to service external job bridge for '${job.asyncDir}':`, bridgeError);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
459
511
|
if (job.status !== "failed") {
|
|
460
512
|
console.error(`Failed to read async status for '${job.asyncDir}':`, error);
|
|
461
513
|
job.status = "failed";
|
|
@@ -473,7 +525,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
473
525
|
const timer = setTimeout(() => {
|
|
474
526
|
refreshTimers.delete(asyncId);
|
|
475
527
|
const job = state.asyncJobs.get(asyncId);
|
|
476
|
-
if (job && refreshJob(job))
|
|
528
|
+
if (job && refreshJob(job)) scheduleLastWidgetRerender();
|
|
477
529
|
}, delayMs);
|
|
478
530
|
timer.unref?.();
|
|
479
531
|
refreshTimers.set(asyncId, timer);
|
|
@@ -604,6 +656,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
604
656
|
: rawAgents;
|
|
605
657
|
const sessionRoot = state.liveAsyncSessionRoots?.get(info.id);
|
|
606
658
|
state.liveAsyncSessionRoots?.delete(info.id);
|
|
659
|
+
externalJobBridgeRuns.delete(info.id);
|
|
607
660
|
state.asyncJobs.set(info.id, {
|
|
608
661
|
asyncId: info.id,
|
|
609
662
|
asyncDir,
|
|
@@ -677,11 +730,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
677
730
|
for (const asyncId of jobWatchers.keys()) closeJobWatcher(asyncId);
|
|
678
731
|
for (const timer of refreshTimers.values()) clearTimeout(timer);
|
|
679
732
|
refreshTimers.clear();
|
|
733
|
+
if (widgetRerenderTimer) clearTimeout(widgetRerenderTimer);
|
|
734
|
+
widgetRerenderTimer = undefined;
|
|
680
735
|
runningJobIds.clear();
|
|
736
|
+
externalJobBridgeRuns.clear();
|
|
681
737
|
};
|
|
682
738
|
|
|
683
739
|
const resetJobs = (ctx?: ExtensionContext) => {
|
|
684
740
|
dispose();
|
|
741
|
+
state.statusProjectionSessionId = null;
|
|
685
742
|
for (const timer of state.cleanupTimers.values()) clearTimeout(timer);
|
|
686
743
|
state.cleanupTimers.clear();
|
|
687
744
|
state.asyncJobs.clear();
|
|
@@ -698,6 +755,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
698
755
|
|
|
699
756
|
const restoreActiveJobs = (ctx?: ExtensionContext) => {
|
|
700
757
|
if (ctx?.hasUI) state.lastUiContext = ctx;
|
|
758
|
+
state.statusProjectionSessionId = null;
|
|
701
759
|
if (!state.currentSessionId) return;
|
|
702
760
|
let runs: AsyncRunSummary[];
|
|
703
761
|
try {
|
|
@@ -713,6 +771,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
713
771
|
rememberFleetJob(state, job);
|
|
714
772
|
watchJob(job);
|
|
715
773
|
}
|
|
774
|
+
state.statusProjectionSessionId = state.currentSessionId;
|
|
716
775
|
if (runs.length === 0) return;
|
|
717
776
|
ensurePoller();
|
|
718
777
|
rerenderLastWidget();
|
|
@@ -314,7 +314,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
|
|
|
314
314
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
|
|
315
315
|
const parsed = value as Record<string, unknown>;
|
|
316
316
|
const allowedFields = new Set([
|
|
317
|
-
"version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
|
|
317
|
+
"version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
|
|
318
318
|
"subagentOnlyExtensions", "mcpDirectTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
|
|
319
319
|
"skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
|
|
320
320
|
"artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
|
|
@@ -347,6 +347,8 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
|
|
|
347
347
|
if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
|
|
348
348
|
if (parsed.context !== undefined && parsed.context !== "fresh" && parsed.context !== "fork") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': context is invalid.`);
|
|
349
349
|
if (parsed.modelOverrideFromParent !== undefined && typeof parsed.modelOverrideFromParent !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOverrideFromParent must be a boolean.`);
|
|
350
|
+
if (parsed.modelOrigin !== undefined && parsed.modelOrigin !== "explicit" && parsed.modelOrigin !== "inherited" && parsed.modelOrigin !== "configured") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': modelOrigin must be 'explicit', 'inherited', or 'configured'.`);
|
|
351
|
+
if (parsed.modelOrigin === undefined && parsed.model !== undefined) parsed.modelOrigin = parsed.modelOverrideFromParent ? "inherited" : "configured";
|
|
350
352
|
for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
|
|
351
353
|
if (typeof parsed[field] !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a boolean.`);
|
|
352
354
|
}
|
|
@@ -166,6 +166,40 @@ function getErrorMessage(error: unknown): string {
|
|
|
166
166
|
return error instanceof Error ? error.message : String(error);
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
function isAsyncStatusIsolationError(asyncDir: string, error: unknown): boolean {
|
|
170
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
171
|
+
const message = getErrorMessage(error);
|
|
172
|
+
return /^(?:Failed to (?:inspect|read|parse|validate) async status file|Invalid async status file) '/.test(message)
|
|
173
|
+
|| message.startsWith(`Invalid async status '${statusPath}'`)
|
|
174
|
+
|| message.startsWith(`Invalid host step '${statusPath}`)
|
|
175
|
+
|| /^(workflowChildren|Invalid workflowChildren)/.test(message);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isolateCorruptActiveRun(asyncDir: string, runId: string, error: unknown, now?: () => number): void {
|
|
179
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
180
|
+
const processTerminal = readProcessTerminal(asyncDir, { runId });
|
|
181
|
+
let markerAge: number | undefined;
|
|
182
|
+
try {
|
|
183
|
+
markerAge = activeRunMarkerAgeMs(asyncDir, now?.());
|
|
184
|
+
} catch (markerError) {
|
|
185
|
+
console.error(`Failed to inspect corrupt async active-run marker for '${runId}':`, markerError);
|
|
186
|
+
}
|
|
187
|
+
const markerCanBeReleased = processTerminal?.state === "observed"
|
|
188
|
+
|| (markerAge !== undefined && markerAge > DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS);
|
|
189
|
+
let markerAction = "active marker retained because runner liveness is unknown";
|
|
190
|
+
if (markerCanBeReleased) {
|
|
191
|
+
try {
|
|
192
|
+
releaseActiveRunIndex(asyncDir);
|
|
193
|
+
markerAction = processTerminal?.state === "observed"
|
|
194
|
+
? "active marker released after observed process-terminal proof"
|
|
195
|
+
: "stale active marker released";
|
|
196
|
+
} catch (releaseError) {
|
|
197
|
+
markerAction = `failed to release active marker: ${getErrorMessage(releaseError)}`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
console.error(`[pi-subagents] Skipping corrupt active async run '${runId}' at '${statusPath}': ${getErrorMessage(error)}; ${markerAction}.`);
|
|
201
|
+
}
|
|
202
|
+
|
|
169
203
|
function isNotFoundError(error: unknown): boolean {
|
|
170
204
|
return typeof error === "object"
|
|
171
205
|
&& error !== null
|
|
@@ -250,14 +284,16 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
|
|
|
250
284
|
}
|
|
251
285
|
|
|
252
286
|
function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
|
|
253
|
-
|
|
287
|
+
const statusPath = path.join(asyncDir, "status.json");
|
|
288
|
+
validateAsyncStatusLaneMetadata(status, `Invalid async status '${statusPath}'`);
|
|
254
289
|
const workflowChildren = parseWorkflowChildSummary(status.workflowChildren);
|
|
255
|
-
if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${
|
|
256
|
-
assertWorkflowGraphHostSteps(status.workflowGraph,
|
|
290
|
+
if (workflowChildren && workflowChildren.workflowRunId !== status.runId) throw new Error(`Invalid async status '${statusPath}': workflowChildren.workflowRunId does not match.`);
|
|
291
|
+
assertWorkflowGraphHostSteps(status.workflowGraph, statusPath, status.runId);
|
|
257
292
|
const hostSteps = validHostStepNodes(status.workflowGraph);
|
|
258
293
|
if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
|
|
259
|
-
throw new Error(`Invalid async status '${
|
|
294
|
+
throw new Error(`Invalid async status '${statusPath}': sessionId must be a string.`);
|
|
260
295
|
}
|
|
296
|
+
if (status.outputFile !== undefined && typeof status.outputFile !== "string") throw new Error(`Invalid async status '${statusPath}': outputFile must be a string.`);
|
|
261
297
|
const { activityState, lastActivityAt } = deriveAsyncActivityState(asyncDir, status);
|
|
262
298
|
const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
|
|
263
299
|
?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
|
|
@@ -499,10 +535,17 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
|
|
|
499
535
|
};
|
|
500
536
|
for (const entry of entries) {
|
|
501
537
|
const asyncDir = path.join(asyncDirRoot, entry);
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
538
|
+
let status: (AsyncStatus & { cwd?: string }) | null;
|
|
539
|
+
try {
|
|
540
|
+
const reconciliation = options.reconcile === false
|
|
541
|
+
? undefined
|
|
542
|
+
: reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
|
|
543
|
+
status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
|
|
546
|
+
isolateCorruptActiveRun(asyncDir, entry, error, options.now);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
506
549
|
if (!status) {
|
|
507
550
|
if (activeEntries.has(entry)) updateActiveRunIndex(asyncDir, "failed");
|
|
508
551
|
continue;
|
|
@@ -528,7 +571,14 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
|
|
|
528
571
|
nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
|
|
529
572
|
}
|
|
530
573
|
}
|
|
531
|
-
|
|
574
|
+
let summary: AsyncRunSummary;
|
|
575
|
+
try {
|
|
576
|
+
summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
|
|
577
|
+
} catch (error) {
|
|
578
|
+
if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
|
|
579
|
+
isolateCorruptActiveRun(asyncDir, entry, error, options.now);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
532
582
|
runs.push(summary);
|
|
533
583
|
}
|
|
534
584
|
|
|
@@ -62,7 +62,7 @@ export async function drainOutstandingWork(deps: AutoDrainDeps): Promise<void> {
|
|
|
62
62
|
},
|
|
63
63
|
);
|
|
64
64
|
if (waitResult.isError) {
|
|
65
|
-
throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "
|
|
65
|
+
throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -330,7 +330,7 @@ function formatDetachedForegroundFleetLines(runs: ForegroundRun[]): string[] {
|
|
|
330
330
|
const childSummary = detachedChildren.map((child) => `${fleetChildDisplayName(child)} #${child.index}`).join(", ");
|
|
331
331
|
lines.push(`- ${run.runId} | detached | ${run.mode}${childSummary ? ` | ${childSummary}` : ""}`);
|
|
332
332
|
lines.push(` status: subagent({ action: "status", id: "${run.runId}" })`);
|
|
333
|
-
lines.push(` recovery: reply to the supervisor request first, then wait with
|
|
333
|
+
lines.push(` recovery: reply to the supervisor request first, then wait with bg_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
|
|
334
334
|
}
|
|
335
335
|
return lines;
|
|
336
336
|
}
|
|
@@ -426,7 +426,7 @@ export function createResultWatcher(
|
|
|
426
426
|
if (observerSucceeded) removeMissionObserverIndex(resultsDir, runId);
|
|
427
427
|
const epoch = deliveryEpoch;
|
|
428
428
|
if (!ownsCompletion(sessionId, completionOwnerId, epoch)) return;
|
|
429
|
-
// Recorded before dedupe and before the unlink below so
|
|
429
|
+
// Recorded before dedupe and before the unlink below so bg_wait can
|
|
430
430
|
// use the in-memory record or its bounded durable replay after cleanup.
|
|
431
431
|
recordWaitCompletion(state, runId, data, Date.now(), completionTtlMs, {
|
|
432
432
|
resultsDir,
|
|
@@ -12,7 +12,7 @@ function isIntercomDetached(run: AsyncRunSummary): boolean {
|
|
|
12
12
|
|
|
13
13
|
function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined {
|
|
14
14
|
if (!isIntercomDetached(run)) return undefined;
|
|
15
|
-
return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with
|
|
15
|
+
return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with bg_wait({ id: "${run.id}" }). Use subagent({ action: "status", id: "${run.id}" }) to recover the result; do not resume or launch a replacement while it remains detached.`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
|
|
@@ -128,7 +128,7 @@ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent
|
|
|
128
128
|
const resumableWorkflowChildren = workflowChildren.filter(({ child }) => !(child.status === "paused" && child.activityState === "needs_attention"));
|
|
129
129
|
if (workflowChildren.length > 0) {
|
|
130
130
|
return [
|
|
131
|
-
...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with
|
|
131
|
+
...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with bg_wait({ id: "${child.runId}" }). Use subagent({ action: "status", id: "${child.runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.`),
|
|
132
132
|
...resumableWorkflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`),
|
|
133
133
|
].join("\n");
|
|
134
134
|
}
|
|
@@ -228,7 +228,7 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
|
|
|
228
228
|
const detached = run.children.some((child) => child.status === "detached");
|
|
229
229
|
const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
|
|
230
230
|
if (detached) {
|
|
231
|
-
lines.push(`Recovery: reply to the supervisor request first, then wait with
|
|
231
|
+
lines.push(`Recovery: reply to the supervisor request first, then wait with bg_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
|
|
232
232
|
} else if (resumable) {
|
|
233
233
|
lines.push(run.children.length === 1
|
|
234
234
|
? `Revive: subagent({ action: "resume", id: "${run.runId}", message: "..." })`
|
|
@@ -2149,7 +2149,7 @@ async function runSingleStepInner(
|
|
|
2149
2149
|
report: structuredAcceptanceReport as import("../../shared/types.ts").AcceptanceReport | undefined,
|
|
2150
2150
|
reportError: structuredAcceptanceReportError,
|
|
2151
2151
|
fileOutput: childWrittenOutput !== undefined && step.outputPath
|
|
2152
|
-
? { content: childWrittenOutput, path: step.outputPath, authoritative: step.outputMode === "file-only" }
|
|
2152
|
+
? { content: childWrittenOutput, path: step.outputPath, authoritative: step.outputMode === "file-only", durable: resolvedOutput.savedPath !== undefined }
|
|
2153
2153
|
: undefined,
|
|
2154
2154
|
cwd: step.cwd ?? ctx.cwd,
|
|
2155
2155
|
signal: combinedAbortSignal([ctx.timeoutSignal, ctx.stopSignal]),
|
|
@@ -5347,7 +5347,6 @@ async function runSubagent(
|
|
|
5347
5347
|
statusPayload.error = `Step failed: ${failedStep.agent}`;
|
|
5348
5348
|
}
|
|
5349
5349
|
}
|
|
5350
|
-
writeStatusPayload();
|
|
5351
5350
|
try {
|
|
5352
5351
|
runPersistence.write(resultPath, {
|
|
5353
5352
|
lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
|