@cr1ms0n/pi-subagent 0.9.0 → 0.11.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 +23 -2
- package/README.md +40 -672
- package/README.zh-CN.md +42 -0
- package/docs/ARCHITECTURE.md +12 -24
- package/docs/COST-ACCOUNTING.md +6 -7
- package/docs/DEVELOPMENT.md +124 -0
- package/docs/PLAN.md +2 -0
- package/docs/REFERENCE.md +454 -0
- package/docs/RELEASING.md +151 -32
- package/docs/ROADMAP.md +2 -0
- package/docs/SECURITY.md +17 -18
- package/docs/UX.md +8 -12
- package/package.json +9 -1
- package/skills/subagent/SKILL.md +17 -12
- package/src/backend.ts +16 -1
- package/src/config.ts +1 -1
- package/src/extension.ts +33 -15
- package/src/format.ts +98 -3
- package/src/jev-router.ts +63 -27
- package/src/model-failover.ts +445 -0
- package/src/notifications.ts +2 -0
- package/src/orchestrator.ts +522 -303
- package/src/output.ts +9 -4
- package/src/persistence.ts +127 -5
- package/src/policy.ts +26 -3
- package/src/process-lock.ts +16 -0
- package/src/protocol.ts +208 -11
- package/src/registry.ts +33 -7
- package/src/routing-policy.ts +27 -19
- package/src/routing-types.ts +26 -4
- package/src/runner.ts +101 -15
- package/src/schema.ts +3 -3
- package/src/types.ts +88 -1
package/src/registry.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import type { Message } from "@earendil-works/pi-ai";
|
|
4
4
|
import type { SubagentConfig } from "./config.js";
|
|
5
5
|
import type { PersistenceAdapter, PersistedResult } from "./persistence.js";
|
|
6
|
-
import { normalizeTaskRouting, PersistenceLayer } from "./persistence.js";
|
|
6
|
+
import { normalizeAttemptedModels, normalizeModelAttempts, normalizeTaskRouting, PersistenceLayer } from "./persistence.js";
|
|
7
7
|
import type { ProcessLockManager } from "./process-lock.js";
|
|
8
8
|
import type { RunMode, RunSnapshot, RunState, TaskResult, TaskSpec } from "./types.js";
|
|
9
9
|
import { emptyUsage } from "./types.js";
|
|
@@ -49,15 +49,16 @@ const terminalStates = new Set<RunState>(["completed", "partial", "failed", "can
|
|
|
49
49
|
/** Trailing coalesce window for high-frequency "changed" events. */
|
|
50
50
|
const EMIT_COALESCE_MS = 100;
|
|
51
51
|
|
|
52
|
-
function finalText(messages: Message[], fallback?: string): string | undefined {
|
|
52
|
+
function finalText(messages: Message[], fallback?: string, latestOnly = false): string | undefined {
|
|
53
53
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
54
54
|
const message = messages[i];
|
|
55
|
-
if (message?.role !== "assistant"
|
|
55
|
+
if (message?.role !== "assistant") continue;
|
|
56
|
+
if (!Array.isArray(message.content)) { if (latestOnly) return undefined; continue; }
|
|
56
57
|
const text = message.content
|
|
57
58
|
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
58
59
|
.map((part: any) => part.text)
|
|
59
60
|
.join("");
|
|
60
|
-
if (text) return text;
|
|
61
|
+
if (text || latestOnly) return text || undefined;
|
|
61
62
|
}
|
|
62
63
|
return fallback;
|
|
63
64
|
}
|
|
@@ -96,13 +97,17 @@ export function toPersistedResult(result: TaskResult): PersistedResult {
|
|
|
96
97
|
sessionId: result.sessionId,
|
|
97
98
|
process: result.process,
|
|
98
99
|
...(routing === undefined ? {} : { routing }),
|
|
99
|
-
finalOutput: utf8Prefix(finalText(result.messages, result.liveText), 16_384),
|
|
100
|
+
finalOutput: utf8Prefix(finalText(result.messages, result.liveText, !!routing?.rankedModels), 16_384),
|
|
100
101
|
transcript: utf8Prefix(result.transcript, 32_768),
|
|
101
102
|
worktree: result.worktree,
|
|
102
103
|
wrappedUp: result.wrappedUp,
|
|
103
104
|
stalledSince: result.stalledSince,
|
|
104
105
|
attempts: result.attempts,
|
|
105
|
-
attemptedModels: result.attemptedModels,
|
|
106
|
+
attemptedModels: normalizeAttemptedModels(result.attemptedModels),
|
|
107
|
+
toolActivity: result.toolActivity,
|
|
108
|
+
// Clone/validate at the projection boundary as well as reload, preserving
|
|
109
|
+
// immutable snapshots and the same producer/decoder preview limits.
|
|
110
|
+
modelAttempts: normalizeModelAttempts(result.modelAttempts),
|
|
106
111
|
structuredOutput: result.structuredOutput,
|
|
107
112
|
structuredError: result.structuredError,
|
|
108
113
|
};
|
|
@@ -132,6 +137,13 @@ function resultFingerprint(result: TaskResult): string {
|
|
|
132
137
|
result.structuredOutput !== undefined ? 1 : 0,
|
|
133
138
|
result.structuredError?.length ?? 0,
|
|
134
139
|
(result as { routing?: { decisionId?: unknown } }).routing?.decisionId ?? "",
|
|
140
|
+
// Model/attempt revision: a stable decision ID with same-length text must
|
|
141
|
+
// not keep stale UI state when the actual model, activity latch or attempt
|
|
142
|
+
// history changed between attempts.
|
|
143
|
+
result.model ?? "",
|
|
144
|
+
result.toolActivity ?? "",
|
|
145
|
+
JSON.stringify(normalizeModelAttempts(result.modelAttempts)) ?? "",
|
|
146
|
+
JSON.stringify(normalizeAttemptedModels(result.attemptedModels)) ?? "",
|
|
135
147
|
].join("|");
|
|
136
148
|
}
|
|
137
149
|
|
|
@@ -173,7 +185,21 @@ export function toCheckpointResult(result: TaskResult): PersistedResult {
|
|
|
173
185
|
wrappedUp: result.wrappedUp,
|
|
174
186
|
stalledSince: result.stalledSince,
|
|
175
187
|
attempts: result.attempts,
|
|
176
|
-
attemptedModels: result.attemptedModels,
|
|
188
|
+
attemptedModels: normalizeAttemptedModels(result.attemptedModels),
|
|
189
|
+
toolActivity: result.toolActivity,
|
|
190
|
+
// Checkpoints carry attempt metadata/pointers only — preview TEXT is
|
|
191
|
+
// persisted exactly once at terminal, so repeated checkpoints stay small.
|
|
192
|
+
modelAttempts: normalizeModelAttempts(result.modelAttempts)?.map((record) => ({
|
|
193
|
+
attempt: record.attempt,
|
|
194
|
+
rank: record.rank,
|
|
195
|
+
model: record.model,
|
|
196
|
+
probability: record.probability,
|
|
197
|
+
outcome: record.outcome,
|
|
198
|
+
...(record.stopReason === undefined ? {} : { stopReason: record.stopReason }),
|
|
199
|
+
...(record.failureCategory === undefined ? {} : { failureCategory: record.failureCategory }),
|
|
200
|
+
...(record.toolActivity === undefined ? {} : { toolActivity: record.toolActivity }),
|
|
201
|
+
...(record.sessionId === undefined ? {} : { sessionId: record.sessionId }),
|
|
202
|
+
})),
|
|
177
203
|
...(routing === undefined ? {} : { routing }),
|
|
178
204
|
};
|
|
179
205
|
}
|
package/src/routing-policy.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { isThinkingLevel } from "./thinking.js";
|
|
2
2
|
import {
|
|
3
|
-
DEFAULT_API_KEY_ENV,
|
|
4
3
|
DEFAULT_ROUTING_TIMEOUT_MS,
|
|
5
4
|
DEFAULT_SELECTOR_MODEL,
|
|
6
5
|
MAX_ROUTING_MODEL_ID_LENGTH,
|
|
@@ -30,22 +29,21 @@ export type {
|
|
|
30
29
|
* - `parseJevRouting(raw, source?)` parses the `jevRouting` **subtree** (not the whole
|
|
31
30
|
* `~/.pi/subagent.json` file) and returns an immutable snapshot. It throws a plain
|
|
32
31
|
* `Error` with an actionable message on any unknown field, duplicate/blank model ID,
|
|
33
|
-
* blank description, invalid
|
|
34
|
-
* or unsupported candidate count. Callers (`src/config.ts`)
|
|
35
|
-
*
|
|
32
|
+
* blank description, missing/invalid credential, non-integer/out-of-range timeout
|
|
33
|
+
* or unsupported candidate count. Callers (`src/config.ts`) expose safe error messages;
|
|
34
|
+
* the parser never reads the environment or a provider catalog.
|
|
36
35
|
* - `formatJevRoutingPrompt` renders model-facing guidance and works with **no** config, no
|
|
37
36
|
* credential and no inference, so management actions stay independent of routing setup.
|
|
38
37
|
* - The candidate helpers are pure; they never contact Pi or TypeSafe.
|
|
39
38
|
*
|
|
40
|
-
* The
|
|
41
|
-
*
|
|
42
|
-
* `
|
|
39
|
+
* The private snapshot contains the credential from `apiKey`. Never serialize it into
|
|
40
|
+
* model-facing guidance or selector input. Credential diagnostics contain no supplied
|
|
41
|
+
* values. Legacy `apiKeyEnv` is rejected with manual migration guidance, without lookup.
|
|
43
42
|
*/
|
|
44
43
|
|
|
45
44
|
/** Default config-file label used in prose (owned by `config.ts` for file reads). */
|
|
46
45
|
export const JEV_ROUTING_CONFIG_FILE = "~/.pi/subagent.json";
|
|
47
46
|
|
|
48
|
-
const ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
49
47
|
/** Exact provider/model ID: at least one slash, further ID slashes allowed; no whitespace, control chars or globs. */
|
|
50
48
|
const MODEL_ID = /^[^\s\u0000-\u001f\u007f/*?]+(?:\/[^\s\u0000-\u001f\u007f/*?]+)+$/u;
|
|
51
49
|
const MAX_GUIDANCE_MODEL_LINES = 50;
|
|
@@ -76,12 +74,19 @@ function parseSelectorModel(value: unknown, source: string): string {
|
|
|
76
74
|
return model;
|
|
77
75
|
}
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (typeof value !== "string"
|
|
82
|
-
|
|
77
|
+
/** Shared credential normalization for config parsing and defensive transport validation. */
|
|
78
|
+
export function normalizeRoutingApiKey(value: unknown): string | undefined {
|
|
79
|
+
if (typeof value !== "string") return undefined;
|
|
80
|
+
const key = value.trim();
|
|
81
|
+
return key && !/[\s\u0000-\u001f\u007f-\u009f]/u.test(key) ? key : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseApiKey(value: unknown, source: string): string {
|
|
85
|
+
const key = normalizeRoutingApiKey(value);
|
|
86
|
+
if (key === undefined) {
|
|
87
|
+
invalid(source, "apiKey is required and must be a non-blank key without embedded whitespace or control characters; store it only in your private user config");
|
|
83
88
|
}
|
|
84
|
-
return
|
|
89
|
+
return key;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
function parseTimeoutMs(value: unknown, source: string): number {
|
|
@@ -144,11 +149,14 @@ function parseModels(value: unknown, source: string): readonly JevRoutingModelEn
|
|
|
144
149
|
*/
|
|
145
150
|
export function parseJevRouting(raw: unknown, source = JEV_ROUTING_CONFIG_FILE): JevRoutingConfig {
|
|
146
151
|
const record = requireObject(raw, source, "jevRouting");
|
|
147
|
-
|
|
152
|
+
if (Object.prototype.hasOwnProperty.call(record, "apiKeyEnv")) {
|
|
153
|
+
invalid(source, "apiKeyEnv is no longer supported; remove it and set jevRouting.apiKey to the credential in your private user config. No environment fallback or automatic migration is performed");
|
|
154
|
+
}
|
|
155
|
+
rejectUnknownKeys(record, ["selectorModel", "apiKey", "timeoutMs", "models"], source, "jevRouting");
|
|
148
156
|
|
|
149
157
|
const snapshot: JevRoutingConfig = {
|
|
150
158
|
selectorModel: parseSelectorModel(record.selectorModel, source),
|
|
151
|
-
|
|
159
|
+
apiKey: parseApiKey(record.apiKey, source),
|
|
152
160
|
timeoutMs: parseTimeoutMs(record.timeoutMs, source),
|
|
153
161
|
models: parseModels(record.models, source),
|
|
154
162
|
};
|
|
@@ -160,7 +168,7 @@ export function jevRoutingTemplate(): string {
|
|
|
160
168
|
return JSON.stringify({
|
|
161
169
|
jevRouting: {
|
|
162
170
|
selectorModel: DEFAULT_SELECTOR_MODEL,
|
|
163
|
-
|
|
171
|
+
apiKey: "<your-typesafe-api-key>",
|
|
164
172
|
timeoutMs: DEFAULT_ROUTING_TIMEOUT_MS,
|
|
165
173
|
models: [
|
|
166
174
|
{
|
|
@@ -230,7 +238,7 @@ function routingSummary(config: JevRoutingConfig): string[] {
|
|
|
230
238
|
"Every new task/tasks[] spawn, action:\"plan\" request, /btw, resume, fork and synthesis is routed by the Jev selector against the user's candidate-model list.",
|
|
231
239
|
"Do not pass model or fallback_models: those fields no longer select a route on new work and are rejected. Management actions (status/wait/cancel/steer/diff/apply/discard) never call the selector and need no credential.",
|
|
232
240
|
`Selector: ${config.selectorModel} (pin an exact version instead of the moving alias to make selection reproducible).`,
|
|
233
|
-
|
|
241
|
+
"Credential: jevRouting.apiKey in the private ~/.pi/subagent.json config file. Never read or copy its value into prompts, logs or results; the routing transport uses it only for the Authorization header.",
|
|
234
242
|
`Logical selection deadline: ${config.timeoutMs} ms, covering all selector requests and waiting for one invocation.`,
|
|
235
243
|
"Only Pi-backed new dispatch is supported; native Codex/Claude new dispatches are rejected rather than routed.",
|
|
236
244
|
"Candidate models (exact IDs; the user's per-model characteristics are the matching criteria):",
|
|
@@ -243,7 +251,7 @@ function routingSummary(config: JevRoutingConfig): string[] {
|
|
|
243
251
|
lines.push(`- …and ${config.models.length - listed.length} more configured candidate(s); every configured candidate is eligible.`);
|
|
244
252
|
}
|
|
245
253
|
lines.push(
|
|
246
|
-
"The selector returns
|
|
254
|
+
"The selector returns probability-ranked model candidates and one task-based, model-independent include/exclude decision per eligible tool. Unknown, unsafe or unavailable choices are rejected locally, and required Pi control-plane tools are added locally. Before any tool starts, a recognized settled model-availability failure can advance through this ranking without another selector request, under the total max_retries extra-attempt budget (0 = initial attempt only; default 1). Started or uncertain tool activity, auth/quota/context/schema failures, cancellation and exhausted task budgets stop switching. Confidence is answer-level; priorities use option probabilities, with no threshold.",
|
|
247
255
|
);
|
|
248
256
|
return lines;
|
|
249
257
|
}
|
|
@@ -259,7 +267,7 @@ export function formatJevRoutingPrompt(config: JevRoutingConfig | undefined, err
|
|
|
259
267
|
"## Subagent routing (Jev / TypeSafe)",
|
|
260
268
|
error || `No valid jevRouting configuration was found in ${JEV_ROUTING_CONFIG_FILE}.`,
|
|
261
269
|
"Management actions (status/wait/cancel/steer/diff/apply/discard) remain available, but every new task/tasks[] spawn, plan, /btw, resume, fork and synthesis is rejected until jevRouting is configured.",
|
|
262
|
-
"Add jevRouting with selectorModel,
|
|
270
|
+
"Add jevRouting with selectorModel, apiKey and 1-255 candidate model entries (exact provider/model IDs plus user-written characteristics, including Chinese). The user must store the credential in the private config file, not in chat or source control. Do not read or display the key. Legacy apiKeyEnv is rejected; there is no environment fallback.",
|
|
263
271
|
"Do not pass model or fallback_models; the selector chooses the execution model and tools.",
|
|
264
272
|
"Use the package routing template; do not invent model IDs or import legacy modelPolicy entries automatically.",
|
|
265
273
|
].join("\n");
|
package/src/routing-types.ts
CHANGED
|
@@ -18,8 +18,6 @@ export const TYPESAFE_SYSTEMONE_ENDPOINT = "https://api.typesafe.ai/v1/systemone
|
|
|
18
18
|
|
|
19
19
|
/** Stable alias default. An exact supported version may be pinned through config. */
|
|
20
20
|
export const DEFAULT_SELECTOR_MODEL = "jev-latest";
|
|
21
|
-
/** Environment variable that holds the TypeSafe credential. Never the credential value. */
|
|
22
|
-
export const DEFAULT_API_KEY_ENV = "TYPESAFE_API_KEY";
|
|
23
21
|
/** Default logical selection deadline in milliseconds. */
|
|
24
22
|
export const DEFAULT_ROUTING_TIMEOUT_MS = 15_000;
|
|
25
23
|
export const ROUTING_TIMEOUT_MIN_MS = 100;
|
|
@@ -86,11 +84,13 @@ export interface JevRoutingModelEntry {
|
|
|
86
84
|
|
|
87
85
|
/**
|
|
88
86
|
* Immutable `jevRouting` snapshot parsed from the user config subtree.
|
|
89
|
-
*
|
|
87
|
+
* Contains a private credential. Never log this snapshot or serialize it into prompts,
|
|
88
|
+
* selector bodies, task specs, receipts or results; use explicit non-secret projections.
|
|
90
89
|
*/
|
|
91
90
|
export interface JevRoutingConfig {
|
|
92
91
|
readonly selectorModel: string;
|
|
93
|
-
|
|
92
|
+
/** User-configured TypeSafe credential; transport Authorization header only. */
|
|
93
|
+
readonly apiKey: string;
|
|
94
94
|
readonly timeoutMs: number;
|
|
95
95
|
readonly models: readonly JevRoutingModelEntry[];
|
|
96
96
|
}
|
|
@@ -102,6 +102,20 @@ export interface RoutingModelCandidate {
|
|
|
102
102
|
readonly thinking?: string;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* One probability-ranked candidate as returned by the model Choice answer.
|
|
107
|
+
* `probability` is the validated per-option value from TypeSafe's full
|
|
108
|
+
* distribution — not the answer-level `confidence` and not a measured
|
|
109
|
+
* availability or quality score. Entries are ordered by descending
|
|
110
|
+
* probability; the returned choice leads a tied maximum and remaining ties
|
|
111
|
+
* keep the configured candidate order. Zero and low probabilities remain
|
|
112
|
+
* valid candidates; no threshold is applied.
|
|
113
|
+
*/
|
|
114
|
+
export interface RankedModelOption {
|
|
115
|
+
readonly model: string;
|
|
116
|
+
readonly probability: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
/**
|
|
106
120
|
* A tool offered to the selector. The caller must have already removed mandatory local
|
|
107
121
|
* additions (Pi control-plane tools) — those are never Jev questions.
|
|
@@ -181,6 +195,14 @@ export interface RoutingDecision {
|
|
|
181
195
|
readonly selectedTools: readonly string[];
|
|
182
196
|
/** Confidence of the model Choice. Diagnostic only; never a permission threshold. */
|
|
183
197
|
readonly confidence?: number;
|
|
198
|
+
/**
|
|
199
|
+
* Full probability-ranked candidate list for automatic pre-tool availability
|
|
200
|
+
* failover. New router decisions always carry the complete ordered ranking.
|
|
201
|
+
* Optional only at legacy/persistence boundaries: a decoded decision without
|
|
202
|
+
* a ranking is display metadata and is never re-materialized into an
|
|
203
|
+
* executable attempt plan.
|
|
204
|
+
*/
|
|
205
|
+
readonly rankedModels?: readonly RankedModelOption[];
|
|
184
206
|
readonly selectorModel: string;
|
|
185
207
|
/** Primary selector version: the version reported by the model response. */
|
|
186
208
|
readonly selectorVersion?: string;
|
package/src/runner.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
UsageStats,
|
|
11
11
|
} from "./types.js";
|
|
12
12
|
import { emptyUsage } from "./types.js";
|
|
13
|
+
import { addUsage } from "./usage.js";
|
|
13
14
|
import { ProtocolParser, type ProtocolUpdate } from "./protocol.js";
|
|
14
15
|
import { Semaphore } from "./semaphore.js";
|
|
15
16
|
import { defaultConfig } from "./config.js";
|
|
@@ -85,6 +86,15 @@ export interface RunnerOptions {
|
|
|
85
86
|
startupTimeoutMs?: number;
|
|
86
87
|
/** Backend adapter override (defaults to the spec's backend, then pi). */
|
|
87
88
|
backend?: BackendAdapter;
|
|
89
|
+
/**
|
|
90
|
+
* Usage already billed by prior ranked attempts of the same task. Affects
|
|
91
|
+
* in-attempt budget COMPARISONS only (so `max_cost`/`max_turns` never reset
|
|
92
|
+
* per model); the runner still reports only this attempt's own usage. The
|
|
93
|
+
* orchestrator alone produces the cumulative figure for checkpoints/results.
|
|
94
|
+
*/
|
|
95
|
+
priorUsage?: UsageStats;
|
|
96
|
+
/** Internal ranked-task ownership: orchestrator terminalizes after all attempts/cleanup. */
|
|
97
|
+
deferRunTerminal?: boolean;
|
|
88
98
|
}
|
|
89
99
|
|
|
90
100
|
type StopReason =
|
|
@@ -149,6 +159,9 @@ export class ChildRunner {
|
|
|
149
159
|
private readonly stallKillAfterMs: number;
|
|
150
160
|
private readonly startupTimeoutMs: number;
|
|
151
161
|
private readonly backendOverride?: BackendAdapter;
|
|
162
|
+
/** Prior-attempt usage included in budget comparisons (never in returned usage). */
|
|
163
|
+
private readonly budgetOffset?: UsageStats;
|
|
164
|
+
private readonly deferRunTerminal: boolean;
|
|
152
165
|
/** Backend for the in-flight run; set at spawn so steer() uses the right dialect. */
|
|
153
166
|
private backend: BackendAdapter = resolveBackend("pi");
|
|
154
167
|
|
|
@@ -167,10 +180,12 @@ export class ChildRunner {
|
|
|
167
180
|
private readonly maxTaskBytes = DEFAULT_MAX_TASK_BYTES,
|
|
168
181
|
options: Pick<
|
|
169
182
|
RunnerOptions,
|
|
170
|
-
"graceTurns" | "stallAfterMs" | "stallKillAfterMs" | "startupTimeoutMs" | "backend"
|
|
183
|
+
"graceTurns" | "stallAfterMs" | "stallKillAfterMs" | "startupTimeoutMs" | "backend" | "priorUsage" | "deferRunTerminal"
|
|
171
184
|
> = {},
|
|
172
185
|
) {
|
|
173
186
|
this.backendOverride = options.backend;
|
|
187
|
+
this.budgetOffset = options.priorUsage;
|
|
188
|
+
this.deferRunTerminal = options.deferRunTerminal === true;
|
|
174
189
|
this.graceTurns = options.graceTurns ?? defaultConfig.graceTurns;
|
|
175
190
|
this.stallAfterMs = options.stallAfterMs ?? defaultConfig.stallAfterMs;
|
|
176
191
|
this.stallKillAfterMs =
|
|
@@ -222,6 +237,10 @@ export class ChildRunner {
|
|
|
222
237
|
};
|
|
223
238
|
|
|
224
239
|
let processHandle: ChildProcess | undefined;
|
|
240
|
+
const failedBeforeSpawn = (error: unknown): boolean => {
|
|
241
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
242
|
+
return !processHandle?.pid && (code === "ENOENT" || code === "EPERM" || code === "EACCES");
|
|
243
|
+
};
|
|
225
244
|
let slotHeld = false;
|
|
226
245
|
let globalSlot: SlotToken | undefined;
|
|
227
246
|
let forceKillTimer: NodeJS.Timeout | undefined;
|
|
@@ -256,6 +275,12 @@ export class ChildRunner {
|
|
|
256
275
|
// `spec.routing` is added by the extension only for Jev-routed dispatches; the
|
|
257
276
|
// trusted low-level SDK never sets it, so unrouted runs keep the old lifecycle.
|
|
258
277
|
const routed = spec.routing !== undefined;
|
|
278
|
+
// Ranked extension runs carry a locally finalized probability plan. They get
|
|
279
|
+
// the stricter structured-output contract (no repair prompt and no final
|
|
280
|
+
// structuredOutput publication after a terminal provider error/abort or a
|
|
281
|
+
// failed/cancelled/timed-out settle). Trusted unranked SDK semantics stay
|
|
282
|
+
// exactly as before.
|
|
283
|
+
const ranked = routed && Array.isArray(spec.modelAttemptPlan) && spec.modelAttemptPlan.length > 0;
|
|
259
284
|
let taskPromptSent = false;
|
|
260
285
|
const absoluteDeadline =
|
|
261
286
|
typeof spec.deadline === "number" && Number.isFinite(spec.deadline) ? spec.deadline : undefined;
|
|
@@ -473,6 +498,9 @@ export class ChildRunner {
|
|
|
473
498
|
...result,
|
|
474
499
|
liveText: parser.getLiveText(),
|
|
475
500
|
};
|
|
501
|
+
// Live sticky tool activity when the parser observes it (Pi adapter).
|
|
502
|
+
const liveActivity = parser.getToolActivity?.();
|
|
503
|
+
if (liveActivity !== undefined) checkpoint.toolActivity = liveActivity;
|
|
476
504
|
if (withTranscript) checkpoint.transcript = parser.getTranscript();
|
|
477
505
|
else delete checkpoint.transcript;
|
|
478
506
|
this.onCheckpoint?.(checkpoint);
|
|
@@ -563,8 +591,16 @@ export class ChildRunner {
|
|
|
563
591
|
// Structured-output gate: validate before letting the child exit.
|
|
564
592
|
// Invalid → one steer-based repair round (a fresh prompt keeps the
|
|
565
593
|
// RPC child alive and produces a new settle when it finishes).
|
|
566
|
-
|
|
567
|
-
|
|
594
|
+
// Ranked exception: a settled assistant provider error/abort must NOT
|
|
595
|
+
// receive an extra same-model repair prompt — that prompt would add
|
|
596
|
+
// unintended work ahead of the ranked failover decision. A ranked
|
|
597
|
+
// parser without this observation capability is also not proof of a
|
|
598
|
+
// clean settle (fail closed); the unranked path is untouched.
|
|
599
|
+
const settledAssistantStop = parser.getAssistantStopReason?.();
|
|
600
|
+
const providerTerminated = settledAssistantStop === "error" || settledAssistantStop === "aborted"
|
|
601
|
+
|| (ranked && settledAssistantStop === undefined);
|
|
602
|
+
if (spec.outputSchema && !requestedStop && !pendingBudgetStop && !(ranked && providerTerminated)) {
|
|
603
|
+
const extracted = extractStructuredResult(ranked ? parser.getAssistantText?.() : parser.getLiveText());
|
|
568
604
|
const check =
|
|
569
605
|
extracted.value !== undefined
|
|
570
606
|
? checkAgainstSchema(extracted.value, spec.outputSchema)
|
|
@@ -608,6 +644,9 @@ export class ChildRunner {
|
|
|
608
644
|
state: "timeout",
|
|
609
645
|
stopReason: "timeout",
|
|
610
646
|
timeoutPhase: phase,
|
|
647
|
+
// A "queued" phase is runner-owned proof the slot was never held, so no
|
|
648
|
+
// child could have begun work; other phases are not pre-work conclusive.
|
|
649
|
+
preWorkInfraFailure: phase === "queued" ? true : base.preWorkInfraFailure,
|
|
611
650
|
errorMessage:
|
|
612
651
|
phase === "queued"
|
|
613
652
|
? "Timed out waiting for a process slot (never started)"
|
|
@@ -623,6 +662,7 @@ export class ChildRunner {
|
|
|
623
662
|
result.stopReason = requestedStop ?? "cancelled";
|
|
624
663
|
result.timeoutPhase =
|
|
625
664
|
requestedStop === "timeout" ? (timeoutPhase ?? "queued") : undefined;
|
|
665
|
+
result.preWorkInfraFailure = result.timeoutPhase === "queued" ? true : undefined;
|
|
626
666
|
result.exitCode = 1;
|
|
627
667
|
result.endedAt = Date.now();
|
|
628
668
|
if (result.state === "timeout" && !result.errorMessage) {
|
|
@@ -675,6 +715,10 @@ export class ChildRunner {
|
|
|
675
715
|
result.errorMessage = error?.message ?? String(error);
|
|
676
716
|
result.exitCode = 1;
|
|
677
717
|
result.endedAt = Date.now();
|
|
718
|
+
// Admission rejection happens before any process exists: conclusive
|
|
719
|
+
// runner-owned proof that no child/task work began.
|
|
720
|
+
result.preWorkInfraFailure = true;
|
|
721
|
+
result.toolActivity = "none";
|
|
678
722
|
return result;
|
|
679
723
|
}
|
|
680
724
|
}
|
|
@@ -770,6 +814,7 @@ export class ChildRunner {
|
|
|
770
814
|
runId: this.runId,
|
|
771
815
|
parentSessionKey: this.parentSessionKey ?? "",
|
|
772
816
|
childSessionId: result.sessionId,
|
|
817
|
+
...(this.deferRunTerminal ? { childSessionIds: [] } : {}),
|
|
773
818
|
// Worktree-isolated runs record their checkout so concurrent Pi
|
|
774
819
|
// processes' machine-wide GC sweeps can shield it while we live.
|
|
775
820
|
worktreeCwd: spec.isolation === "worktree" ? spec.cwd : undefined,
|
|
@@ -1097,12 +1142,17 @@ export class ChildRunner {
|
|
|
1097
1142
|
// Capability mismatch is not transient: never compensate by broadening tools,
|
|
1098
1143
|
// choosing another model or retrying into an unverified launch.
|
|
1099
1144
|
await stopChildForStartupFailure();
|
|
1100
|
-
|
|
1145
|
+
// If the OS never created a process, no capability check could run.
|
|
1146
|
+
// Preserve this positive pre-work spawn proof for the same-model retry
|
|
1147
|
+
// path; an actually launched child's mismatch still refuses outright.
|
|
1148
|
+
if (!(ranked && failedBeforeSpawn(childExited?.error))) {
|
|
1149
|
+
throw startupFailure(startupOutcome.code, startupOutcome.detail);
|
|
1150
|
+
}
|
|
1101
1151
|
}
|
|
1102
1152
|
if (startupOutcome.kind === "cancelled") {
|
|
1103
1153
|
// Cancelled/timed out during startup: never send the real task prompt.
|
|
1104
1154
|
if (!requestedStop) requestStop("cancelled");
|
|
1105
|
-
} else {
|
|
1155
|
+
} else if (startupOutcome.kind === "ok") {
|
|
1106
1156
|
this.sendCommand = send;
|
|
1107
1157
|
taskPromptSent = send({ type: "prompt", message: spec.task });
|
|
1108
1158
|
// RPC mode has no session header line; get_state supplies the session id.
|
|
@@ -1131,7 +1181,11 @@ export class ChildRunner {
|
|
|
1131
1181
|
thinking: spec.thinking,
|
|
1132
1182
|
profile: spec.profile,
|
|
1133
1183
|
backend: spec.backend ?? "pi",
|
|
1134
|
-
|
|
1184
|
+
// Routed children were startup-verified against the exact `provider/model`
|
|
1185
|
+
// identity; provider message payloads may echo a bare ID, which must never
|
|
1186
|
+
// become the recorded actual model of an attempt.
|
|
1187
|
+
model: routed && spec.model ? spec.model : (finalized.model ?? result.model ?? spec.model),
|
|
1188
|
+
liveText: ranked ? parser.getAssistantText?.() || undefined : finalized.liveText,
|
|
1135
1189
|
canWrite: spec.canWrite,
|
|
1136
1190
|
process: result.process,
|
|
1137
1191
|
startedAt,
|
|
@@ -1143,6 +1197,15 @@ export class ChildRunner {
|
|
|
1143
1197
|
result.state = "failed";
|
|
1144
1198
|
result.stopReason = "spawn_error";
|
|
1145
1199
|
result.errorMessage = closed.error.message;
|
|
1200
|
+
// Only spawn-stage failures where the OS never produced a process are
|
|
1201
|
+
// conclusive pre-work proof. Any other child error leaves uncertainty:
|
|
1202
|
+
// a ranked attempt must not restart on it, so the activity latch rises
|
|
1203
|
+
// to `unknown` instead of staying `none`.
|
|
1204
|
+
const neverStarted = failedBeforeSpawn(closed.error);
|
|
1205
|
+
result.preWorkInfraFailure = neverStarted;
|
|
1206
|
+
if (!neverStarted && ranked && result.toolActivity !== "started") {
|
|
1207
|
+
result.toolActivity = "unknown";
|
|
1208
|
+
}
|
|
1146
1209
|
} else if (requestedStop) {
|
|
1147
1210
|
if (requestedStop === "timeout") {
|
|
1148
1211
|
Object.assign(result, applyTimeoutSemantics(result));
|
|
@@ -1163,12 +1226,19 @@ export class ChildRunner {
|
|
|
1163
1226
|
result.stopReason = requestedStop;
|
|
1164
1227
|
result.exitCode = closed.code;
|
|
1165
1228
|
result.errorMessage = `Stopped by ${requestedStop.replace("_", " ")} budget after the wrap-up grace period; partial output preserved`;
|
|
1166
|
-
} else {
|
|
1229
|
+
} else if (requestedStop === "fatal") {
|
|
1230
|
+
// A fatal RPC rejection supersedes any earlier assistant error: the
|
|
1231
|
+
// final settled outcome is a protocol failure, not provider evidence.
|
|
1232
|
+
// Stale evidence must never authorize cross-model advancement.
|
|
1233
|
+
result.providerError = undefined;
|
|
1167
1234
|
result.state = "failed";
|
|
1168
|
-
result.stopReason =
|
|
1169
|
-
requestedStop === "fatal" ? "error" : requestedStop;
|
|
1235
|
+
result.stopReason = "error";
|
|
1170
1236
|
result.exitCode = closed.code ?? 1;
|
|
1171
1237
|
if (fatalError) result.errorMessage = fatalError;
|
|
1238
|
+
} else {
|
|
1239
|
+
result.state = "failed";
|
|
1240
|
+
result.stopReason = requestedStop;
|
|
1241
|
+
result.exitCode = closed.code ?? 1;
|
|
1172
1242
|
}
|
|
1173
1243
|
} else if (
|
|
1174
1244
|
pendingBudgetStop &&
|
|
@@ -1185,7 +1255,17 @@ export class ChildRunner {
|
|
|
1185
1255
|
// Structured-output verdict: validate the final text once, after any
|
|
1186
1256
|
// repair round. Failure downgrades completed → partial (paid work is
|
|
1187
1257
|
// still delivered; the parent sees why it is not machine-readable).
|
|
1188
|
-
|
|
1258
|
+
// Ranked exception: a failed/cancelled/timed-out terminal attempt, or one
|
|
1259
|
+
// whose latest completed assistant message ended in a provider
|
|
1260
|
+
// error/abort, must never publish structuredOutput even when its text
|
|
1261
|
+
// contains a valid, schema-matching json:result block — that text stays
|
|
1262
|
+
// ordinary failed-attempt output/preview. Successful and legitimate
|
|
1263
|
+
// budget-limited "partial" attempts keep the existing validation.
|
|
1264
|
+
const settledAssistantStop = parser.getAssistantStopReason?.();
|
|
1265
|
+
const rankedAssistantTerminated = ranked && (settledAssistantStop === "error"
|
|
1266
|
+
|| settledAssistantStop === "aborted"
|
|
1267
|
+
|| settledAssistantStop === undefined);
|
|
1268
|
+
if (spec.outputSchema && !(ranked && (["failed", "cancelled", "timeout", "lost"].includes(result.state as TaskResult["state"]) || rankedAssistantTerminated))) {
|
|
1189
1269
|
const extracted = extractStructuredResult(result.liveText);
|
|
1190
1270
|
const check =
|
|
1191
1271
|
extracted.value !== undefined
|
|
@@ -1210,7 +1290,7 @@ export class ChildRunner {
|
|
|
1210
1290
|
}
|
|
1211
1291
|
}
|
|
1212
1292
|
|
|
1213
|
-
if (this.locks && this.runId) {
|
|
1293
|
+
if (!this.deferRunTerminal && this.locks && this.runId) {
|
|
1214
1294
|
this.locks.markRunTerminal(this.runId, result.state);
|
|
1215
1295
|
}
|
|
1216
1296
|
return result;
|
|
@@ -1220,7 +1300,7 @@ export class ChildRunner {
|
|
|
1220
1300
|
const startupFailureInfo = readStartupFailure(error);
|
|
1221
1301
|
if (startupFailureInfo && requestedStop !== "timeout" && !abortSignal?.aborted) {
|
|
1222
1302
|
markStartupFailure(result, startupFailureInfo.code, startupFailureInfo.detail);
|
|
1223
|
-
if (this.locks && this.runId)
|
|
1303
|
+
if (!this.deferRunTerminal && this.locks && this.runId)
|
|
1224
1304
|
this.locks.markRunTerminal(this.runId, result.state);
|
|
1225
1305
|
return result;
|
|
1226
1306
|
}
|
|
@@ -1232,6 +1312,7 @@ export class ChildRunner {
|
|
|
1232
1312
|
result.stopReason = "timeout";
|
|
1233
1313
|
result.timeoutPhase =
|
|
1234
1314
|
timeoutPhase ?? (!slotHeld ? "queued" : "running");
|
|
1315
|
+
result.preWorkInfraFailure = result.timeoutPhase === "queued" ? true : undefined;
|
|
1235
1316
|
result.errorMessage =
|
|
1236
1317
|
result.timeoutPhase === "queued"
|
|
1237
1318
|
? "Timed out waiting for a process slot (never started)"
|
|
@@ -1245,7 +1326,7 @@ export class ChildRunner {
|
|
|
1245
1326
|
}
|
|
1246
1327
|
result.exitCode ??= 1;
|
|
1247
1328
|
result.endedAt = Date.now();
|
|
1248
|
-
if (this.locks && this.runId)
|
|
1329
|
+
if (!this.deferRunTerminal && this.locks && this.runId)
|
|
1249
1330
|
this.locks.markRunTerminal(this.runId, result.state);
|
|
1250
1331
|
return result;
|
|
1251
1332
|
} finally {
|
|
@@ -1258,9 +1339,12 @@ export class ChildRunner {
|
|
|
1258
1339
|
usage: UsageStats,
|
|
1259
1340
|
): "max_turns" | "max_cost" | undefined {
|
|
1260
1341
|
// Stop only after a completed turn has pushed usage beyond the configured ceiling.
|
|
1261
|
-
|
|
1342
|
+
// Prior-attempt usage is included in this comparison so a replacement model does
|
|
1343
|
+
// not reset max_cost/max_turns, while returned usage stays attempt-local.
|
|
1344
|
+
const compared = this.budgetOffset ? addUsage(this.budgetOffset, usage) : usage;
|
|
1345
|
+
if (spec.maxTurns !== undefined && compared.turns > spec.maxTurns)
|
|
1262
1346
|
return "max_turns";
|
|
1263
|
-
if (spec.maxCost !== undefined &&
|
|
1347
|
+
if (spec.maxCost !== undefined && compared.cost > spec.maxCost)
|
|
1264
1348
|
return "max_cost";
|
|
1265
1349
|
return undefined;
|
|
1266
1350
|
}
|
|
@@ -1294,6 +1378,8 @@ export function runSubagent(
|
|
|
1294
1378
|
stallKillAfterMs: options.stallKillAfterMs,
|
|
1295
1379
|
startupTimeoutMs: options.startupTimeoutMs,
|
|
1296
1380
|
backend: options.backend,
|
|
1381
|
+
priorUsage: options.priorUsage,
|
|
1382
|
+
deferRunTerminal: options.deferRunTerminal,
|
|
1297
1383
|
},
|
|
1298
1384
|
).run(spec, options.signal);
|
|
1299
1385
|
}
|
package/src/schema.ts
CHANGED
|
@@ -58,8 +58,8 @@ export const TaskFields = {
|
|
|
58
58
|
max_turns: Type.Optional(Type.Number({ minimum: 1, maximum: 500, description: "Budget: at this many turns the child is steered to wrap up and given grace turns for a final answer; ends as 'partial' with output preserved." })),
|
|
59
59
|
max_cost: Type.Optional(Type.Number({ minimum: 0, description: "Soft provider-reported execution cost ceiling in dollars, checked after each turn. TypeSafe routing currency is unreported and NOT capped by max_cost." })),
|
|
60
60
|
grace_turns: Type.Optional(Type.Number({ minimum: 0, maximum: 20, description: "Wrap-up turns allowed after a budget breach before hard stop. 0 = immediate stop. Default from config (2)." })),
|
|
61
|
-
fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Legacy field: omit on new work, including an empty list. Jev
|
|
62
|
-
max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "
|
|
61
|
+
fallback_models: Type.Optional(Type.Array(Type.String(), { maxItems: 5, description: "Legacy field: omit on new work, including an empty list. Ranked alternatives come only from Jev; manual fallback and emergency models are not accepted." })),
|
|
62
|
+
max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "Total extra child attempts (0 = initial attempt only; default 1). Before any tool execution, a recognized availability failure advances to the next Jev probability-ranked model with the same tools and no new selection. Auth/quota/context/task-quality failures never switch." })),
|
|
63
63
|
context: Type.Optional(
|
|
64
64
|
Type.Union([Type.Literal("fresh"), Type.Literal("fork")], {
|
|
65
65
|
description: "fork starts the child from a branched copy of the parent conversation (needs a persisted parent session); fresh (default) starts clean. Fork is single-task only.",
|
|
@@ -70,7 +70,7 @@ export const TaskFields = {
|
|
|
70
70
|
Type.Unsafe<Record<string, unknown>>(
|
|
71
71
|
Type.Object({}, {
|
|
72
72
|
additionalProperties: true,
|
|
73
|
-
description: "JSON Schema the child's final result must satisfy. The child ends with a fenced json:result block;
|
|
73
|
+
description: "JSON Schema the child's final result must satisfy. The child ends with a fenced json:result block; successful but invalid answers get one repair round, then end 'partial' with the errors reported. Provider errors/aborts are not repaired and failed attempts cannot publish structured output.",
|
|
74
74
|
}),
|
|
75
75
|
),
|
|
76
76
|
),
|