pi-subagents 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/README.md +1 -1
- package/agents/researcher.md +23 -13
- package/docs/agents.md +17 -3
- package/docs/configuration.md +34 -0
- package/docs/extension-api.md +94 -0
- package/docs/models.md +58 -1
- package/docs/observability.md +42 -2
- package/docs/tool-reference.md +11 -5
- package/docs/workflows.md +22 -7
- package/package.json +4 -1
- package/runner-server-preload.mjs +13 -0
- package/skills/pi-subagents/SKILL.md +2 -1
- package/skills/pi-subagents/references/execution-controls.md +19 -2
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- package/src/agents/advertised-agent-prompt.ts +63 -0
- package/src/agents/agent-management.ts +14 -1
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +8 -0
- package/src/api/preflight.ts +5 -1
- package/src/api/shared-types.ts +1 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/config.ts +4 -2
- package/src/extension/index.ts +71 -4
- package/src/extension/public-execution.ts +0 -1
- package/src/extension/rpc.ts +4 -21
- package/src/extension/schemas.ts +9 -7
- package/src/extension/tool-description.ts +10 -5
- package/src/integrations/pi-web-session-liveness.ts +73 -0
- package/src/intercom/native-supervisor-channel.ts +102 -88
- package/src/intercom/supervisor-ui.ts +3 -2
- package/src/missions/workflow-state.ts +37 -16
- package/src/runs/background/active-async-capacity.ts +18 -18
- package/src/runs/background/async-execution.ts +8 -1
- package/src/runs/background/async-job-tracker.ts +35 -3
- package/src/runs/background/async-resume.ts +3 -1
- package/src/runs/background/async-retention.ts +9 -0
- package/src/runs/background/async-status-snapshot.ts +10 -12
- package/src/runs/background/async-status.ts +17 -9
- package/src/runs/background/auto-drain.ts +40 -29
- package/src/runs/background/chain-root-attachment.ts +8 -0
- package/src/runs/background/control-channel.ts +78 -44
- package/src/runs/background/notify.ts +88 -12
- package/src/runs/background/owned-process-tree.ts +6 -6
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/src/runs/background/run-child-session.ts +62 -33
- package/src/runs/background/run-status.ts +75 -5
- package/src/runs/background/runner-aliases.ts +46 -8
- package/src/runs/background/runner-child-launch.ts +86 -0
- package/src/runs/background/stale-run-reconciler.ts +3 -1
- package/src/runs/background/subagent-runner.ts +430 -208
- package/src/runs/background/subagent-wait.ts +3 -0
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/async-steering-action.ts +19 -0
- package/src/runs/foreground/execution.ts +116 -26
- package/src/runs/foreground/foreground-history.ts +3 -1
- package/src/runs/foreground/prompt-audit.ts +9 -5
- package/src/runs/foreground/subagent-executor.ts +531 -227
- package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
- package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
- package/src/runs/shared/acceptance.ts +16 -3
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/async-status-projection.ts +47 -47
- package/src/runs/shared/child-hooks.ts +151 -2
- package/src/runs/shared/child-launch.ts +18 -13
- package/src/runs/shared/child-session.ts +55 -24
- package/src/runs/shared/child-tool-plan.ts +2 -2
- package/src/runs/shared/completion-evidence.ts +2 -2
- package/src/runs/shared/completion-guard.ts +1 -0
- package/src/runs/shared/host-step-status.ts +11 -11
- package/src/runs/shared/llm-intent-arbiter.ts +30 -20
- package/src/runs/shared/model-exclusions.ts +2 -1
- package/src/runs/shared/model-fallback.ts +41 -8
- package/src/runs/shared/nested-events.ts +8 -8
- package/src/runs/shared/orca-progress-tabs.ts +6 -0
- package/src/runs/shared/parallel-handoff.ts +57 -12
- package/src/runs/shared/parallel-utils.ts +3 -2
- package/src/runs/shared/readonly-drain-observation.ts +42 -0
- package/src/runs/shared/readonly-model-continuation.ts +69 -0
- package/src/runs/shared/readonly-session-evidence.ts +307 -0
- package/src/runs/shared/run-fanout-budget.ts +8 -8
- package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
- package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
- package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +403 -210
- package/src/shared/model-response-aliases.ts +13 -0
- package/src/shared/types.ts +89 -60
- package/src/shared/utils.ts +10 -2
- package/src/shared/watch-strategy.ts +2 -0
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/tui/fleet-status.ts +1 -1
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render.ts +44 -15
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +70 -16
- package/src/workflows/workflow-checklist.ts +15 -18
- package/src/workflows/workflow-child-summary.ts +57 -8
- package/src/workflows/workflow-preflight.ts +19 -19
- package/src/workflows/workflow-receipt.ts +3 -3
- package/src/workflows/workflow-resources.ts +96 -21
- package/src/workflows/workflow-settlement.ts +3 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { Agent,
|
|
3
|
-
import {
|
|
4
|
-
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type { Agent, AgentTool, StreamFn } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
4
|
import type { ProviderHeaders } from "@earendil-works/pi-ai";
|
|
6
5
|
import { Type, type Static } from "typebox";
|
|
7
6
|
import { agentStreamOptions } from "../../shared/agent-stream-options.ts";
|
|
@@ -52,7 +51,11 @@ export function mapArbiterDecision(
|
|
|
52
51
|
|
|
53
52
|
interface ArbiterRuntime {
|
|
54
53
|
model: NonNullable<RegistryModel>;
|
|
55
|
-
|
|
54
|
+
/** Explicit override; it always wins over a registered provider stream. */
|
|
55
|
+
explicitStreamFn?: StreamFn;
|
|
56
|
+
/** Registered provider stream, usable only when its api matches the model. */
|
|
57
|
+
registeredStreamFn?: StreamFn;
|
|
58
|
+
registeredApi?: string;
|
|
56
59
|
timeoutMs: number;
|
|
57
60
|
}
|
|
58
61
|
|
|
@@ -73,9 +76,10 @@ export interface TaskMutationArbiterOptions {
|
|
|
73
76
|
const DEFAULT_ARBITER_TIMEOUT_MS = 10_000;
|
|
74
77
|
|
|
75
78
|
type RegistryModel = ReturnType<NonNullable<ExtensionContext["modelRegistry"]["find"]>>;
|
|
79
|
+
type ArbiterModelContext = Pick<ExtensionContext, "model" | "modelRegistry">;
|
|
76
80
|
|
|
77
81
|
function resolveArbiterModel(
|
|
78
|
-
ctx:
|
|
82
|
+
ctx: ArbiterModelContext,
|
|
79
83
|
options?: TaskMutationArbiterOptions,
|
|
80
84
|
): NonNullable<RegistryModel> | null {
|
|
81
85
|
const registry = ctx.modelRegistry as {
|
|
@@ -95,7 +99,7 @@ function resolveArbiterModel(
|
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
function resolveArbiterRuntime(
|
|
98
|
-
ctx:
|
|
102
|
+
ctx: ArbiterModelContext,
|
|
99
103
|
options?: TaskMutationArbiterOptions,
|
|
100
104
|
): ArbiterRuntime | null {
|
|
101
105
|
const model = resolveArbiterModel(ctx, options);
|
|
@@ -103,23 +107,20 @@ function resolveArbiterRuntime(
|
|
|
103
107
|
const registry = ctx.modelRegistry as {
|
|
104
108
|
getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
|
|
105
109
|
};
|
|
106
|
-
const modelApi = (model as { api?: string }).api;
|
|
107
110
|
const registered = registry.getRegisteredProviderConfig?.(model.provider);
|
|
108
|
-
const baseStreamFn = options?.streamFn
|
|
109
|
-
?? (registered?.streamSimple && registered.api === modelApi
|
|
110
|
-
? registered.streamSimple
|
|
111
|
-
: streamSimple);
|
|
112
111
|
return {
|
|
113
112
|
model,
|
|
114
|
-
|
|
113
|
+
explicitStreamFn: options?.streamFn,
|
|
114
|
+
registeredStreamFn: registered?.streamSimple,
|
|
115
|
+
registeredApi: registered?.api,
|
|
115
116
|
timeoutMs: options?.timeoutMs ?? DEFAULT_ARBITER_TIMEOUT_MS,
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
async function resolveArbiterAuth(
|
|
120
|
-
ctx:
|
|
121
|
+
ctx: ArbiterModelContext,
|
|
121
122
|
model: RegistryModel,
|
|
122
|
-
): Promise<ArbiterAuth> {
|
|
123
|
+
): Promise<ArbiterAuth | undefined> {
|
|
123
124
|
const registry = ctx.modelRegistry as {
|
|
124
125
|
getApiKeyAndHeaders?: (m: RegistryModel) => Promise<{
|
|
125
126
|
ok: boolean;
|
|
@@ -135,14 +136,14 @@ async function resolveArbiterAuth(
|
|
|
135
136
|
if (!registry.getApiKeyAndHeaders) return {};
|
|
136
137
|
try {
|
|
137
138
|
const auth = await registry.getApiKeyAndHeaders(model);
|
|
138
|
-
if (auth.ok === false) return
|
|
139
|
+
if (auth.ok === false) return undefined;
|
|
139
140
|
return {
|
|
140
141
|
...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
|
|
141
142
|
...(auth.headers ? { headers: auth.headers } : {}),
|
|
142
143
|
...(auth.env ? { env: auth.env } : {}),
|
|
143
144
|
};
|
|
144
145
|
} catch {
|
|
145
|
-
return
|
|
146
|
+
return undefined;
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
@@ -164,6 +165,15 @@ async function runArbitration(
|
|
|
164
165
|
auth: ArbiterAuth,
|
|
165
166
|
task: string,
|
|
166
167
|
): Promise<TaskMutationVerdict> {
|
|
168
|
+
// Keep optional Pi peers out of the detached runner's static import graph.
|
|
169
|
+
const [{ Agent }, { convertToLlm }, { streamSimple }] = await Promise.all([
|
|
170
|
+
import("@earendil-works/pi-agent-core"),
|
|
171
|
+
import("@earendil-works/pi-coding-agent"),
|
|
172
|
+
import("@earendil-works/pi-ai/compat"),
|
|
173
|
+
]);
|
|
174
|
+
const streamFn: StreamFn = runtime.explicitStreamFn
|
|
175
|
+
?? (runtime.registeredApi !== undefined && runtime.registeredApi === runtime.model.api ? runtime.registeredStreamFn : undefined)
|
|
176
|
+
?? streamSimple;
|
|
167
177
|
let decision: DecisionParams | undefined;
|
|
168
178
|
const tool: AgentTool<typeof DecisionParams, { recorded: boolean }> = {
|
|
169
179
|
name: "task_mutation_decision",
|
|
@@ -190,7 +200,7 @@ async function runArbitration(
|
|
|
190
200
|
tools: [tool],
|
|
191
201
|
},
|
|
192
202
|
convertToLlm,
|
|
193
|
-
...agentStreamOptions(authWrappedStreamFn(
|
|
203
|
+
...agentStreamOptions(authWrappedStreamFn(streamFn, auth)),
|
|
194
204
|
getApiKey: (providerName) =>
|
|
195
205
|
providerName === runtime.model.provider ? auth.apiKey : undefined,
|
|
196
206
|
beforeToolCall: async ({ toolCall }) =>
|
|
@@ -221,9 +231,9 @@ async function runArbitration(
|
|
|
221
231
|
}
|
|
222
232
|
}
|
|
223
233
|
|
|
224
|
-
/** Create a memoized arbiter bound to the
|
|
234
|
+
/** Create a memoized arbiter bound to the supplied model services, or undefined when disabled/unavailable. */
|
|
225
235
|
export function createTaskMutationArbiter(
|
|
226
|
-
ctx:
|
|
236
|
+
ctx: ArbiterModelContext,
|
|
227
237
|
options?: TaskMutationArbiterOptions,
|
|
228
238
|
): TaskMutationArbiter | undefined {
|
|
229
239
|
if (process.env.PI_SUBAGENTS_LLM_INTENT_ARBITER === "0") return undefined;
|
|
@@ -235,7 +245,7 @@ export function createTaskMutationArbiter(
|
|
|
235
245
|
const cached = cache.get(key);
|
|
236
246
|
if (cached) return cached;
|
|
237
247
|
const auth = await resolveArbiterAuth(ctx, runtime.model);
|
|
238
|
-
const verdict = await runArbitration(runtime, auth, task);
|
|
248
|
+
const verdict = auth ? await runArbitration(runtime, auth, task) : "unavailable";
|
|
239
249
|
if (cache.size > 200) cache.clear();
|
|
240
250
|
cache.set(key, verdict);
|
|
241
251
|
return verdict;
|
|
@@ -317,6 +317,7 @@ export function parseModelKey(fullId: string): { provider?: string; modelId: str
|
|
|
317
317
|
export function filterFallbackCandidates(candidates: string[], opts?: {
|
|
318
318
|
now?: number;
|
|
319
319
|
onExcluded?: (candidate: string, exclusion: Readonly<ModelExclusion>) => void;
|
|
320
|
+
ignoreExclusion?: (candidate: string, exclusion: Readonly<ModelExclusion>) => boolean;
|
|
320
321
|
}): string[] {
|
|
321
322
|
ensureLoaded();
|
|
322
323
|
invalidateAuthExclusions();
|
|
@@ -326,7 +327,7 @@ export function filterFallbackCandidates(candidates: string[], opts?: {
|
|
|
326
327
|
for (const raw of candidates) {
|
|
327
328
|
if (!raw || seen.has(raw)) continue;
|
|
328
329
|
const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
|
|
329
|
-
const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
|
|
330
|
+
const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp) && opts?.ignoreExclusion?.(raw, entry) !== true);
|
|
330
331
|
if (exclusion) {
|
|
331
332
|
opts?.onExcluded?.(raw, exclusion);
|
|
332
333
|
continue;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { splitKnownThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
|
|
1
|
+
import { splitKnownThinkingSuffix as splitThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
|
|
2
2
|
import type { Usage } from "../../shared/types.ts";
|
|
3
3
|
import { filterFallbackCandidates, findModelExclusion, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
|
|
4
4
|
import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
|
|
@@ -14,13 +14,19 @@ interface ModelAttemptSummary {
|
|
|
14
14
|
usage?: Usage;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export
|
|
18
|
-
return splitKnownThinkingSuffix(model);
|
|
19
|
-
}
|
|
17
|
+
export { splitThinkingSuffix };
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
/** Aliases apply only to the resolved launch candidate (without its thinking suffix) and the exact raw response ID. */
|
|
20
|
+
export function formatSubagentModelVerificationError(
|
|
21
|
+
expectedModel: string,
|
|
22
|
+
observedModel: string,
|
|
23
|
+
availableModels: AvailableModelInfo[] | undefined,
|
|
24
|
+
modelResponseAliases?: Record<string, string[]>,
|
|
25
|
+
): string | undefined {
|
|
22
26
|
if (!availableModels || availableModels.length === 0) return undefined;
|
|
23
27
|
const expectedBase = splitThinkingSuffix(expectedModel).baseModel;
|
|
28
|
+
if (modelResponseAliases && Object.hasOwn(modelResponseAliases, expectedBase)
|
|
29
|
+
&& modelResponseAliases[expectedBase]?.includes(observedModel)) return undefined;
|
|
24
30
|
const observedBase = splitThinkingSuffix(observedModel).baseModel;
|
|
25
31
|
if (expectedBase === observedBase) return undefined;
|
|
26
32
|
const expectedEntry = availableModels.find((entry) => entry.fullId === expectedBase);
|
|
@@ -30,7 +36,7 @@ export function formatSubagentModelVerificationError(expectedModel: string, obse
|
|
|
30
36
|
const expectedFullIdLeaf = expectedEntry.fullId.slice(expectedEntry.fullId.lastIndexOf("/") + 1);
|
|
31
37
|
if (expectedIdLeaf === observedBase || expectedFullIdLeaf === observedBase) return undefined;
|
|
32
38
|
}
|
|
33
|
-
return `model_verification_failed: child reported a different model than the launch candidate. Expected '${expectedModel}' but observed '${observedModel}'.`;
|
|
39
|
+
return `model_verification_failed: native Pi child reported a different model than the launch candidate. Expected '${expectedModel}' but observed '${observedModel}'. If you have independently verified this response ID identifies the requested model, declare the exact mapping in modelResponseAliases in ~/.pi/agent/extensions/subagent/config.json (see docs/configuration.md#modelresponsealiases). Use the resolved provider/model ID without its thinking suffix as the key. This leaves the outgoing request unchanged. Configuration changes affect new independent native runs; resumed native runs retain their launch-time declaration. External CLI adapters do not use this setting.`;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/** Sentinel model value requesting that a subagent inherit the parent session's model. */
|
|
@@ -309,6 +315,24 @@ function formatExcludedCandidateEvidence(candidate: string, exclusion: NonNullab
|
|
|
309
315
|
return `${displayCandidate} — model: ${displayModel}; provider: ${displayProvider}; reason: ${reason}; expires: ${formatModelExclusionExpiry(exclusion.expiresAt)}`;
|
|
310
316
|
}
|
|
311
317
|
|
|
318
|
+
const MODEL_UNAVAILABLE_EXCLUSION_PATTERNS = [
|
|
319
|
+
/model.*not found/i,
|
|
320
|
+
/unknown model/i,
|
|
321
|
+
/model.*unavailable/i,
|
|
322
|
+
/model.*disabled/i,
|
|
323
|
+
];
|
|
324
|
+
|
|
325
|
+
function isCurrentRegistryModel(candidate: string, availableModels: AvailableModelInfo[] | undefined): boolean {
|
|
326
|
+
if (!availableModels || availableModels.length === 0) return false;
|
|
327
|
+
const { baseModel } = splitThinkingSuffix(candidate);
|
|
328
|
+
return availableModels.some((entry) => entry.fullId === baseModel);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function ignoreStaleModelUnavailableExclusion(candidate: string, exclusion: NonNullable<ReturnType<typeof findModelExclusion>>, availableModels: AvailableModelInfo[] | undefined): boolean {
|
|
332
|
+
const reason = exclusion.reason ?? "";
|
|
333
|
+
return MODEL_UNAVAILABLE_EXCLUSION_PATTERNS.some((pattern) => pattern.test(reason)) && isCurrentRegistryModel(candidate, availableModels);
|
|
334
|
+
}
|
|
335
|
+
|
|
312
336
|
function throwForExplicitModelExclusion(model: string): void {
|
|
313
337
|
const exclusion = findModelExclusion(model);
|
|
314
338
|
if (!exclusion) return;
|
|
@@ -487,7 +511,10 @@ export function buildModelCandidates(
|
|
|
487
511
|
seen.add(normalized);
|
|
488
512
|
candidates.push(normalized);
|
|
489
513
|
}
|
|
490
|
-
const resolved = filterFallbackCandidates(candidates, {
|
|
514
|
+
const resolved = filterFallbackCandidates(candidates, {
|
|
515
|
+
onExcluded: warnCachedExclusion,
|
|
516
|
+
ignoreExclusion: (candidate, exclusion) => ignoreStaleModelUnavailableExclusion(candidate, exclusion, availableModels),
|
|
517
|
+
});
|
|
491
518
|
if (resolved.length === 0) {
|
|
492
519
|
if (skippedPrimary) resolveRequiredSubagentModelCandidate(skippedPrimary, availableModels, preferredProvider);
|
|
493
520
|
if (candidates.length === 0 && skippedFallback) resolveRequiredSubagentModelCandidate(skippedFallback, availableModels, preferredProvider);
|
|
@@ -508,6 +535,7 @@ export function buildModelCandidates(
|
|
|
508
535
|
}
|
|
509
536
|
|
|
510
537
|
const RETRYABLE_MODEL_FAILURE_PATTERNS = [
|
|
538
|
+
/^REQUEST_LIMIT_EXCEEDED$/,
|
|
511
539
|
/rate\s*limit/i,
|
|
512
540
|
/usage\s*limit/i,
|
|
513
541
|
/too many requests/i,
|
|
@@ -579,8 +607,13 @@ export function isRetryableModelFailureAttempt(input: { error: string | undefine
|
|
|
579
607
|
return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));
|
|
580
608
|
}
|
|
581
609
|
|
|
610
|
+
// Request-shape failures can match broad fallback signals such as "upstream",
|
|
611
|
+
// but do not establish that the model is unhealthy for subsequent requests.
|
|
612
|
+
const REQUEST_SHAPE_FAILURE_PATTERN = /\b(?:bad[ _]request|invalid[ _]argument|invalid_request_error)\b/i;
|
|
613
|
+
|
|
582
614
|
export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
|
|
583
|
-
if (!model || !isRetryableModelFailure(error)) return;
|
|
615
|
+
if (!model || !error || !isRetryableModelFailure(error) || isContextOverflow(error)) return;
|
|
616
|
+
if (REQUEST_SHAPE_FAILURE_PATTERN.test(error)) return;
|
|
584
617
|
const { provider, modelId } = parseModelKey(model);
|
|
585
618
|
recordModelFailure({ modelId, reason: error, ...(provider ? { provider } : {}) });
|
|
586
619
|
}
|
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
TEMP_ROOT_DIR,
|
|
7
7
|
type AsyncJobState,
|
|
8
8
|
type AsyncStatus,
|
|
9
|
-
type
|
|
10
|
-
type
|
|
9
|
+
type LaunchResolvedChildExtensions,
|
|
10
|
+
type RuntimeAcknowledgedChildExtensions,
|
|
11
11
|
type NestedRouteInfo,
|
|
12
12
|
type TurnBudgetState,
|
|
13
13
|
type NestedRunSummary,
|
|
@@ -217,7 +217,7 @@ function sanitizeCost(value: unknown): NestedRunSummary["totalCost"] | undefined
|
|
|
217
217
|
: undefined;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
function sanitizeLaunchResolvedExtensions(value: unknown):
|
|
220
|
+
function sanitizeLaunchResolvedExtensions(value: unknown): LaunchResolvedChildExtensions | undefined {
|
|
221
221
|
if (!value || typeof value !== "object") return undefined;
|
|
222
222
|
const raw = value as Record<string, unknown>;
|
|
223
223
|
if (raw.version !== 1 || raw.source !== "launch-resolved" || typeof raw.disableAmbientExtensions !== "boolean") return undefined;
|
|
@@ -241,7 +241,7 @@ function sanitizeLaunchResolvedExtensions(value: unknown): LaunchResolvedChildEx
|
|
|
241
241
|
};
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
function sanitizeRuntimeAcknowledgedExtensions(value: unknown):
|
|
244
|
+
function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowledgedChildExtensions | undefined {
|
|
245
245
|
if (!value || typeof value !== "object") return undefined;
|
|
246
246
|
const raw = value as Record<string, unknown>;
|
|
247
247
|
if (raw.version !== 1 || raw.source !== "child-runtime" || !Array.isArray(raw.ids)) return undefined;
|
|
@@ -261,7 +261,7 @@ function sanitizeRuntimeAcknowledgedExtensions(value: unknown): RuntimeAcknowled
|
|
|
261
261
|
};
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
-
function runtimeAcknowledgedEntry(value: unknown): { runtimeAcknowledgedExtensions:
|
|
264
|
+
function runtimeAcknowledgedEntry(value: unknown): { runtimeAcknowledgedExtensions: RuntimeAcknowledgedChildExtensions } | Record<string, never> {
|
|
265
265
|
const sanitized = sanitizeRuntimeAcknowledgedExtensions(value);
|
|
266
266
|
return sanitized ? { runtimeAcknowledgedExtensions: sanitized } : {};
|
|
267
267
|
}
|
|
@@ -286,7 +286,7 @@ function sanitizeTurnBudget(value: unknown): TurnBudgetState | undefined {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
function sanitizeState(value: unknown, fallback: NestedRunState): NestedRunState {
|
|
289
|
-
return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "partial" || value === "paused" || value === "stopped"
|
|
289
|
+
return value === "queued" || value === "running" || value === "complete" || value === "failed" || value === "partial" || value === "paused" || value === "stopped" || value === "rejected"
|
|
290
290
|
? value
|
|
291
291
|
: fallback;
|
|
292
292
|
}
|
|
@@ -296,7 +296,7 @@ function sanitizeStep(input: unknown, depth: number): NestedStepSummary | undefi
|
|
|
296
296
|
const raw = input as Record<string, unknown>;
|
|
297
297
|
const agent = stringValue(raw.agent, 128);
|
|
298
298
|
if (!agent) return undefined;
|
|
299
|
-
const status = raw.status === "pending" || raw.status === "running" || raw.status === "complete" || raw.status === "completed" || raw.status === "failed" || raw.status === "paused" || raw.status === "stopped"
|
|
299
|
+
const status = raw.status === "pending" || raw.status === "running" || raw.status === "complete" || raw.status === "completed" || raw.status === "failed" || raw.status === "partial" || raw.status === "paused" || raw.status === "stopped" || raw.status === "rejected"
|
|
300
300
|
? raw.status
|
|
301
301
|
: "pending";
|
|
302
302
|
const model = stringValue(raw.model);
|
|
@@ -438,7 +438,7 @@ export function parseNestedEventRecords(content: string, route: NestedRoute): Ne
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
function terminal(state: NestedRunState): boolean {
|
|
441
|
-
return state === "complete" || state === "failed" || state === "partial" || state === "paused" || state === "stopped";
|
|
441
|
+
return state === "complete" || state === "failed" || state === "partial" || state === "paused" || state === "rejected" || state === "stopped";
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
function mergeBoundedChildren(existing: NestedRunSummary[] | undefined, incoming: NestedRunSummary[] | undefined): NestedRunSummary[] | undefined {
|
|
@@ -53,6 +53,8 @@ const ORCA_CLEANUP_WATCHDOG_SCRIPT = [
|
|
|
53
53
|
].join("");
|
|
54
54
|
|
|
55
55
|
export interface OrcaProgressTab {
|
|
56
|
+
/** Resolves when the terminal-create watchdog closes, after its final manifest/queue writes (success or failure). Not viewer completion. */
|
|
57
|
+
readonly creationSettled: Promise<void>;
|
|
56
58
|
append(text: string): void;
|
|
57
59
|
section(input: { agent: string; index: number; count: number }): void;
|
|
58
60
|
event(event: { type?: string; message?: Message; toolName?: string; args?: unknown }): void;
|
|
@@ -402,6 +404,8 @@ export function createOrcaProgressTab(input: {
|
|
|
402
404
|
}
|
|
403
405
|
};
|
|
404
406
|
let createSettled = false;
|
|
407
|
+
let resolveCreationSettled!: () => void;
|
|
408
|
+
const creationSettled = new Promise<void>((resolve) => { resolveCreationSettled = resolve; });
|
|
405
409
|
let cleanupPaths: string[] | undefined;
|
|
406
410
|
const scheduleDeferredCleanup = () => {
|
|
407
411
|
if (!createSettled || cleanupPaths === undefined) return;
|
|
@@ -434,6 +438,7 @@ export function createOrcaProgressTab(input: {
|
|
|
434
438
|
createSettled = true;
|
|
435
439
|
if (code !== 0) failObserver();
|
|
436
440
|
scheduleDeferredCleanup();
|
|
441
|
+
resolveCreationSettled();
|
|
437
442
|
});
|
|
438
443
|
watchdog.once("error", () => {
|
|
439
444
|
markCreateReady();
|
|
@@ -450,6 +455,7 @@ export function createOrcaProgressTab(input: {
|
|
|
450
455
|
|
|
451
456
|
let finished = false;
|
|
452
457
|
return {
|
|
458
|
+
creationSettled,
|
|
453
459
|
append(text) {
|
|
454
460
|
if (finished) return;
|
|
455
461
|
writeProgress(text);
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
WorktreeCleanupReport,
|
|
18
18
|
WorktreeDiff,
|
|
19
19
|
WorktreeSetup,
|
|
20
|
+
WorktreeSetupProgress,
|
|
20
21
|
WorktreeCleanupIntent,
|
|
21
22
|
} from "./worktree.ts";
|
|
22
23
|
import { cleanupWorktrees } from "./worktree.ts";
|
|
@@ -614,18 +615,62 @@ export function parallelHandoffPath(baseDir: string, runId?: string): string {
|
|
|
614
615
|
return runId ? path.join(baseDir, "handoffs", `${runId}.json`) : path.join(baseDir, "handoff.json");
|
|
615
616
|
}
|
|
616
617
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
setup
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
618
|
+
/** Synchronous onProgress projection shared by setup owners; snapshots are cumulative. */
|
|
619
|
+
export function writeWorktreeSetupHandoff(input: Omit<Parameters<typeof writeParallelHandoffGroup>[0], "setup" | "diffs" | "results" | "cleanup" | "now"> & {
|
|
620
|
+
progress: WorktreeSetupProgress;
|
|
621
|
+
}): ParallelHandoffReference | undefined {
|
|
622
|
+
const { progress, ...handoff } = input;
|
|
623
|
+
const { setup, attempts, cleanup } = progress;
|
|
624
|
+
// Preflight has no allocation identity yet. Its original error belongs to the caller.
|
|
625
|
+
if (attempts.length === 0 && setup.worktrees.length === 0) return undefined;
|
|
626
|
+
if (!setup.cwd || !setup.baseCommit) throw new Error("Cannot publish worktree allocation evidence without repository and base commit.");
|
|
627
|
+
const diagnostic = (text: string): string => text.slice(0, 512);
|
|
628
|
+
// Never copy argv, environment, Error objects or captured stdout/stderr into artifacts.
|
|
629
|
+
const commandEvidence = (command: WorktreeSetupProgress["command"]) => command && ({
|
|
630
|
+
pid: command.pid,
|
|
631
|
+
processGroupId: command.processGroupId,
|
|
632
|
+
...(command.result ? {
|
|
633
|
+
status: command.result.status,
|
|
634
|
+
signal: command.result.signal,
|
|
635
|
+
failed: Boolean(command.result.error),
|
|
636
|
+
outputIncomplete: command.result.outputIncomplete,
|
|
637
|
+
processTree: command.result.processTree?.state,
|
|
638
|
+
} : {}),
|
|
639
|
+
});
|
|
640
|
+
const errors = [
|
|
641
|
+
`Worktree setup ${JSON.stringify({ runId: input.runId, stepIndex: input.stepIndex, phase: progress.phase,
|
|
642
|
+
unknown: Boolean(progress.unknown), command: commandEvidence(progress.command) })}`,
|
|
643
|
+
...attempts.map((attempt) => {
|
|
644
|
+
const task = cleanup?.tasks.find((candidate) => candidate.index === attempt.index);
|
|
645
|
+
return `Allocation attempt ${JSON.stringify({ index: attempt.index, branch: attempt.branch,
|
|
646
|
+
path: attempt.path ?? null, validated: attempt.validated,
|
|
647
|
+
command: commandEvidence(attempt.command), hookCommand: commandEvidence(attempt.hookCommand),
|
|
648
|
+
...(task ? { worktreeRemoved: task.worktreeRemoved, branchRemoved: task.branchRemoved,
|
|
649
|
+
preserved: task.preserved, reason: task.reason && diagnostic(task.reason), errors: task.errors?.map(diagnostic) } : {}),
|
|
650
|
+
})}`;
|
|
651
|
+
}),
|
|
652
|
+
...(cleanup?.errors?.map(diagnostic) ?? []),
|
|
653
|
+
...(progress.unknown ? ["Setup settlement unknown; manual reconciliation required."] : []),
|
|
654
|
+
];
|
|
655
|
+
return writeParallelHandoffGroup({
|
|
656
|
+
...handoff, setup, diffs: [], results: [],
|
|
657
|
+
laneBindings: input.laneBindings?.filter((binding) => setup.worktrees.some((worktree) => worktree.index === binding.taskIndex)),
|
|
658
|
+
cleanup: {
|
|
659
|
+
state: cleanup?.state ?? "partial",
|
|
660
|
+
pruned: cleanup?.pruned ?? false,
|
|
661
|
+
errors,
|
|
662
|
+
// An attempted native path is not a validated recovery task, even during rollback.
|
|
663
|
+
tasks: setup.worktrees.map((worktree) => {
|
|
664
|
+
const task = cleanup?.tasks.find((candidate) => candidate.index === worktree.index);
|
|
665
|
+
return task ? { ...task, reason: task.reason && diagnostic(task.reason), errors: task.errors?.map(diagnostic) } : {
|
|
666
|
+
index: worktree.index, path: worktree.path, branch: worktree.branch,
|
|
667
|
+
provider: worktree.provider, naming: worktree.naming,
|
|
668
|
+
worktreeRemoved: false, branchRemoved: false, preserved: true,
|
|
669
|
+
reason: "setup pending durable handoff capture",
|
|
670
|
+
};
|
|
671
|
+
}),
|
|
672
|
+
},
|
|
673
|
+
});
|
|
629
674
|
}
|
|
630
675
|
|
|
631
676
|
export function formatParallelHandoffReference(reference: ParallelHandoffReference): string {
|
|
@@ -41,6 +41,7 @@ export interface RunnerSubagentStep {
|
|
|
41
41
|
/** The primary model is inherited from the parent session and should not be verified against the child-reported active registry model. */
|
|
42
42
|
skipPrimaryModelVerification?: boolean;
|
|
43
43
|
modelVerificationRegistry?: Array<{ provider: string; id: string; fullId: string; contextWindow?: number }>;
|
|
44
|
+
modelResponseAliases?: Record<string, string[]>;
|
|
44
45
|
tools?: string[];
|
|
45
46
|
excludeTools?: string[];
|
|
46
47
|
allowNestedSubagents?: boolean;
|
|
@@ -74,8 +75,8 @@ export interface RunnerSubagentStep {
|
|
|
74
75
|
launchBindingTask?: string;
|
|
75
76
|
launchContractDigest?: string;
|
|
76
77
|
extensionBindings?: import("./extension-bindings.ts").ExtensionBindings;
|
|
77
|
-
launchResolvedExtensions?: import("../../shared/types.ts").
|
|
78
|
-
runtimeAcknowledgedExtensions?: import("../../shared/types.ts").
|
|
78
|
+
launchResolvedExtensions?: import("../../shared/types.ts").LaunchResolvedChildExtensions;
|
|
79
|
+
runtimeAcknowledgedExtensions?: import("../../shared/types.ts").RuntimeAcknowledgedChildExtensions;
|
|
79
80
|
effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
|
|
80
81
|
acceptanceInput?: import("../../shared/types.ts").AcceptanceInput;
|
|
81
82
|
acceptanceRole?: import("../../shared/types.ts").AcceptanceRole;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const knownStates = new Set(["queued", "running", "complete", "failed", "partial", "paused", "stopped", "rejected"]);
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Private, installation-owned evidence. Never changes drain/query behavior or retains statuses.
|
|
5
|
+
* Observes the ordinary indexed drain, not historical ownership or outside work appearing later.
|
|
6
|
+
*/
|
|
7
|
+
export class ReadonlyDrainObservation {
|
|
8
|
+
private state: "pending" | "empty" | "denied" = "pending";
|
|
9
|
+
private started = false;
|
|
10
|
+
private first = false;
|
|
11
|
+
private readonly file: string;
|
|
12
|
+
private readonly guard: () => boolean;
|
|
13
|
+
constructor(file: string, guard: () => boolean) { this.file = file; this.guard = guard; }
|
|
14
|
+
deny(): void { this.state = "denied"; }
|
|
15
|
+
check(): boolean {
|
|
16
|
+
try { if (!this.guard()) this.deny(); } catch { this.deny(); }
|
|
17
|
+
return this.state !== "denied";
|
|
18
|
+
}
|
|
19
|
+
begin(file: string | null, native: boolean): void {
|
|
20
|
+
if (this.started || file !== this.file || !native) this.deny();
|
|
21
|
+
this.started = true;
|
|
22
|
+
this.check();
|
|
23
|
+
}
|
|
24
|
+
/** Called at the existing initial read, before reconciliation or filtering. */
|
|
25
|
+
readonly status: RawDrainStatusObserver = (status) => {
|
|
26
|
+
if (!status || typeof status.sessionId !== "string" || !status.sessionId
|
|
27
|
+
|| !knownStates.has(status.state as string)) this.deny();
|
|
28
|
+
else if (status.sessionId === this.file && (status.state === "queued" || status.state === "running")) this.deny();
|
|
29
|
+
};
|
|
30
|
+
predicate(hasWork: boolean): void {
|
|
31
|
+
if (this.first) return;
|
|
32
|
+
this.first = true;
|
|
33
|
+
if (hasWork) this.deny();
|
|
34
|
+
}
|
|
35
|
+
complete(): void {
|
|
36
|
+
if (this.started && this.first && this.check()) this.state = "empty";
|
|
37
|
+
}
|
|
38
|
+
settled(): boolean { return this.check() && this.state === "empty"; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Internal synchronous sink; null means an existing query encountered uncertainty. */
|
|
42
|
+
export type RawDrainStatusObserver = (status: { sessionId?: unknown; state?: unknown } | null) => void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ChildSession } from "./child-session.ts";
|
|
2
|
+
import { getReadonlySessionEvidence, type SettledReadonlyEvidence } from "./readonly-session-evidence.ts";
|
|
3
|
+
|
|
4
|
+
/** Owned by the logical host run, shared with abort recovery; never reset per attempt. */
|
|
5
|
+
export type LogicalRecoveryState = "unused" | "abort-recovery" | "readonly-continuation";
|
|
6
|
+
|
|
7
|
+
export interface ReadonlyContinuationCandidate {
|
|
8
|
+
/** Actual resolved identity, not an alias or a parsed display reference. */
|
|
9
|
+
readonly resolved: { readonly provider: string; readonly model: string; readonly api: string } | undefined;
|
|
10
|
+
readonly tried: boolean;
|
|
11
|
+
/** Host assessment of retained input support AND context capacity, including prompt overhead. */
|
|
12
|
+
readonly compatibility: "compatible" | "incompatible" | "unknown";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ReadonlyContinuationInput {
|
|
16
|
+
/** Retain the source child privately: its live accessor detects revoked receipts. */
|
|
17
|
+
readonly source: ChildSession | undefined;
|
|
18
|
+
readonly recoveryState: LogicalRecoveryState;
|
|
19
|
+
/** Ordered, already authorized and exclusion-filtered. This planner never resolves models. */
|
|
20
|
+
readonly candidates: readonly ReadonlyContinuationCandidate[];
|
|
21
|
+
readonly currentIndex: number;
|
|
22
|
+
/** False includes success, stop/interrupt/detach/handoff, deadline, or workflow-permit veto. */
|
|
23
|
+
readonly lifecycleAllowsContinuation: boolean;
|
|
24
|
+
/** False includes completion/structured/acceptance failures, pending input or other effects. */
|
|
25
|
+
readonly effectsAllowContinuation: boolean;
|
|
26
|
+
/** Configured tool budgets are unsupported; unknown authoritative usage allowance denies. */
|
|
27
|
+
readonly budget: "unconfigured" | "available" | "exhausted" | "unknown" | "tool-budget-configured";
|
|
28
|
+
readonly knownContextOverflow: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const READONLY_CONTINUATION_PROMPT = "The previous provider request failed with HTTP 429 after read-only progress. Continue from the retained transcript and completed tool results. Do not restart or repeat completed work. Use only the existing read-only tools and finish the requested response.";
|
|
32
|
+
|
|
33
|
+
export type ReadonlyContinuationPlan =
|
|
34
|
+
| { readonly kind: "deny"; readonly reason: "recovery-consumed" | "veto" | "no-evidence" | "unresolved-identity" | "incompatible" | "no-sibling" }
|
|
35
|
+
| { readonly kind: "continue"; readonly candidateIndex: number; readonly expected: SettledReadonlyEvidence;
|
|
36
|
+
readonly prompt: typeof READONLY_CONTINUATION_PROMPT; readonly recoveryState: "readonly-continuation" };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pure decision: no disk reads, dispatch, receipt minting, or state mutation.
|
|
40
|
+
* The host MUST recheck live source proof and lifecycle/budget at handoff, then store the
|
|
41
|
+
* returned consumed state BEFORE creation (even if creation subsequently fails),
|
|
42
|
+
* and pass expected to requestReadonlySessionEvidence on the exact-file sibling launch.
|
|
43
|
+
* That factory guard owns checkpoint/configured-provider revalidation before open/prompt/dispatch.
|
|
44
|
+
* The host must also verify the actual sibling model matches the selected resolved identity;
|
|
45
|
+
* a plan is not a dispatch authorization and any create/guard failure terminates recovery.
|
|
46
|
+
*/
|
|
47
|
+
export function planReadonlyModelContinuation(input: ReadonlyContinuationInput): ReadonlyContinuationPlan {
|
|
48
|
+
if (input.recoveryState !== "unused") return { kind: "deny", reason: "recovery-consumed" };
|
|
49
|
+
if (!input.lifecycleAllowsContinuation || !input.effectsAllowContinuation || input.knownContextOverflow
|
|
50
|
+
|| (input.budget !== "unconfigured" && input.budget !== "available")) return { kind: "deny", reason: "veto" };
|
|
51
|
+
const expected = input.source && getReadonlySessionEvidence(input.source);
|
|
52
|
+
if (!expected || input.source?.detached || input.source?.shutDown) return { kind: "deny", reason: "no-evidence" };
|
|
53
|
+
const current = Number.isInteger(input.currentIndex) && input.currentIndex >= 0 ? input.candidates[input.currentIndex]?.resolved : undefined;
|
|
54
|
+
if (!current || current.provider !== expected.provider || current.model !== expected.model || current.api !== expected.api) {
|
|
55
|
+
return { kind: "deny", reason: "unresolved-identity" };
|
|
56
|
+
}
|
|
57
|
+
for (let index = input.currentIndex + 1; index < input.candidates.length; index++) {
|
|
58
|
+
const candidate = input.candidates[index];
|
|
59
|
+
if (!candidate) return { kind: "deny", reason: "unresolved-identity" };
|
|
60
|
+
if (candidate.tried) continue;
|
|
61
|
+
const resolved = candidate.resolved;
|
|
62
|
+
if (!resolved?.provider || !resolved.model || !resolved.api) return { kind: "deny", reason: "unresolved-identity" };
|
|
63
|
+
if (resolved.provider !== expected.provider || resolved.model === expected.model) continue;
|
|
64
|
+
if (input.candidates.some((other) => other.tried && other.resolved?.provider === resolved.provider && other.resolved.model === resolved.model)) continue;
|
|
65
|
+
if (resolved.api !== expected.api || candidate.compatibility !== "compatible") return { kind: "deny", reason: "incompatible" };
|
|
66
|
+
return { kind: "continue", candidateIndex: index, expected, prompt: READONLY_CONTINUATION_PROMPT, recoveryState: "readonly-continuation" };
|
|
67
|
+
}
|
|
68
|
+
return { kind: "deny", reason: "no-sibling" };
|
|
69
|
+
}
|