@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/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" || !Array.isArray(message.content)) continue;
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
  }
@@ -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 environment-variable name, non-integer/out-of-range timeout
34
- * or unsupported candidate count. Callers (`src/config.ts`) catch that message and expose
35
- * it as a config error; no credential or provider catalog is read.
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 credential *value* must never be stored in config: only the environment-variable name
41
- * is parsed and returned. The parser rejects unknown fields, so an accidental `apiKey` or
42
- * `apiKeyValue` field is an error rather than silently ignored.
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
- function parseApiKeyEnv(value: unknown, source: string): string {
80
- if (value === undefined) return DEFAULT_API_KEY_ENV;
81
- if (typeof value !== "string" || !ENV_VAR_NAME.test(value.trim())) {
82
- invalid(source, "apiKeyEnv must be an environment-variable name such as TYPESAFE_API_KEY (the credential value must not be stored in config)");
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 value.trim();
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
- rejectUnknownKeys(record, ["selectorModel", "apiKeyEnv", "timeoutMs", "models"], source, "jevRouting");
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
- apiKeyEnv: parseApiKeyEnv(record.apiKeyEnv, source),
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
- apiKeyEnv: DEFAULT_API_KEY_ENV,
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
- `Credential: environment variable ${config.apiKeyEnv}, read locally at request time; the value is never stored in config, prompts, logs or results.`,
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 one execution model and an individual include/exclude decision per eligible tool. Unknown, unsafe or unavailable choices are rejected locally, and required Pi control-plane tools are added locally rather than chosen by the selector.",
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, apiKeyEnv and 1-255 candidate model entries (exact provider/model IDs plus user-written characteristics, including Chinese). The credential itself is read from the named environment variable at request time and is never stored in the config file.",
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");
@@ -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
- * The credential value never appears here only the environment-variable name.
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
- readonly apiKeyEnv: string;
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
- if (spec.outputSchema && !requestedStop && !pendingBudgetStop) {
567
- const extracted = extractStructuredResult(parser.getLiveText());
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
- throw startupFailure(startupOutcome.code, startupOutcome.detail);
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
- model: finalized.model ?? result.model ?? spec.model,
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
- if (spec.outputSchema) {
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
- if (spec.maxTurns !== undefined && usage.turns > spec.maxTurns)
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 && usage.cost > spec.maxCost)
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 routing has no emergency or fallback model." })),
62
- max_retries: Type.Optional(Type.Number({ minimum: 0, maximum: 5, description: "Extra same-model attempts on transient child failures; never reselects Jev or broadens tools. Defaults to config maxRetries (1). Task-quality failures never retry." })),
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; validation failures get one steer-based repair round, then end 'partial' with the errors reported.",
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
  ),