pi-crew 0.11.6 → 0.11.8

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.
Files changed (58) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +216 -1
  4. package/dist/index.mjs +531 -508
  5. package/docs/publishing.md +29 -0
  6. package/index.ts +3 -3
  7. package/package.json +9 -6
  8. package/scripts/install-hooks.mjs +32 -0
  9. package/skills/real-test-pi-crew/SKILL.md +31 -18
  10. package/src/config/config-validation.ts +35 -0
  11. package/src/config/env-vars.ts +38 -0
  12. package/src/config/types.ts +71 -0
  13. package/src/extension/registration/foreground-run-controller.ts +19 -10
  14. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  15. package/src/extension/registration/lifecycle.ts +7 -90
  16. package/src/extension/registration/render-loop.ts +502 -0
  17. package/src/extension/team-tool/doctor.ts +6 -0
  18. package/src/prompt/prompt-runtime.ts +33 -4
  19. package/src/runtime/child-pi/child-pi-spawn.ts +14 -0
  20. package/src/runtime/child-pi/child-pi.ts +130 -6
  21. package/src/runtime/child-pi/pi-version.ts +187 -0
  22. package/src/runtime/child-pi/session-recovery.ts +334 -0
  23. package/src/runtime/classifier/classifier-service.ts +298 -0
  24. package/src/runtime/dispatch-batch.ts +4 -0
  25. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  26. package/src/runtime/model/model-scope.ts +141 -21
  27. package/src/runtime/model/pi-args.ts +166 -6
  28. package/src/runtime/peer-dep.ts +24 -0
  29. package/src/runtime/process/zombie-scanner.ts +168 -7
  30. package/src/runtime/rpc/README.md +139 -0
  31. package/src/runtime/rpc/frame-client.ts +328 -0
  32. package/src/runtime/rpc/rpc-worker.ts +271 -0
  33. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  34. package/src/runtime/run-worker.ts +91 -3
  35. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  36. package/src/runtime/skill-instructions.ts +66 -1
  37. package/src/runtime/surface/resolve-surface.ts +34 -19
  38. package/src/runtime/surface/surface-spawn.ts +8 -1
  39. package/src/runtime/task-output-context.ts +191 -32
  40. package/src/runtime/task-packet.ts +3 -0
  41. package/src/runtime/task-runner/child-executor.ts +172 -2
  42. package/src/runtime/task-runner/post-execution.ts +5 -3
  43. package/src/runtime/task-runner/pre-execution.ts +32 -1
  44. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  45. package/src/runtime/task-runner.ts +6 -0
  46. package/src/schema/config-schema.ts +37 -0
  47. package/src/schema/team-tool-schema.ts +14 -2
  48. package/src/state/contracts.ts +5 -0
  49. package/src/ui/format-helpers.ts +11 -0
  50. package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
  51. package/src/ui/pi-ui-compat.ts +18 -9
  52. package/src/ui/rail.ts +14 -1
  53. package/src/ui/settings-overlay.ts +11 -2
  54. package/src/ui/widget/index.ts +0 -1
  55. package/src/ui/widget/task-list.ts +10 -3
  56. package/src/ui/widget/widget-renderer.ts +53 -4
  57. package/src/utils/relative-time.ts +11 -3
  58. package/src/utils/visual.ts +32 -2
@@ -0,0 +1,334 @@
1
+ /**
2
+ * W2 (Pi 1.0.0 adoption — session-file recovery).
3
+ *
4
+ * Workers run with a DETERMINISTIC pi session identity: `--session-id` +
5
+ * `--session-dir` pointing under the run's artifacts root
6
+ * (`<artifactsRoot>/sessions/<safeTaskId>/`), so session JSONL lives and dies
7
+ * with run artifacts (auto-prune) instead of `~/.pi`. When a worker dies
8
+ * WITHOUT a final assistant event (exitCode null / killed — SIGKILL between
9
+ * turns), the session file still contains every COMPLETED turn (the SDK
10
+ * persists records at message_end; a kill can only tear the line being
11
+ * written). This module tail-replays that file and surfaces the last complete
12
+ * assistant record as `recoveredFromSession` on the run result.
13
+ *
14
+ * Contract (per verified packet):
15
+ * - Entry parsing is DELEGATED to the SDK (`parseSessionEntries` /
16
+ * `migrateSessionEntries`) — no hand-rolled parser. The only local IO
17
+ * defense is dropping a torn trailing fragment line (no final `\n`).
18
+ * - A trailing user/toolResult record with no assistant after it means the
19
+ * worker died mid-turn → the last COMPLETE assistant BEFORE it is recovered.
20
+ * - Recovery AUGMENTS the settle-time result; manifest polling is untouched.
21
+ * - Default ON; `PI_CREW_SESSION_RECOVERY` env overrides; explicit
22
+ * `sessionRecovery: false` (runtime config) is the opt-out below env.
23
+ *
24
+ * SDK import is a LAZY dynamic import (first value-import of the SDK in
25
+ * src/runtime): recovery only runs on crash paths, so module init must never
26
+ * pay the SDK load cost.
27
+ */
28
+
29
+ import * as fs from "node:fs";
30
+ import * as path from "node:path";
31
+ import { getCrewEnvBool } from "../../config/env-vars.ts";
32
+ import { isSafePathId } from "../../utils/safe-paths.ts";
33
+
34
+ /** Recovered last-complete-assistant payload attached to the run result. */
35
+ export interface SessionRecoveryInfo {
36
+ /** The session id the worker ran with (`--session-id`). */
37
+ sessionId: string;
38
+ /** Absolute path of the session JSONL the text was recovered from. */
39
+ sessionFile: string;
40
+ /** Text content of the last complete assistant message (text parts joined). */
41
+ text: string;
42
+ /** Timestamp of the recovered session entry (ISO). */
43
+ timestamp: string;
44
+ /** Stop reason of the recovered assistant message (e.g. "stop", "toolUse"). */
45
+ stopReason?: string;
46
+ }
47
+
48
+ /** Per-worker session identity resolved from the run input. */
49
+ export interface WorkerSessionContext {
50
+ sessionId: string;
51
+ sessionDir: string;
52
+ }
53
+
54
+ /** Identity fields read off `ChildPiRunInput` (kept structural for tests). */
55
+ export interface SessionIdentityInput {
56
+ sessionId?: string;
57
+ sessionDir?: string;
58
+ agentId?: string;
59
+ artifactsRoot?: string;
60
+ }
61
+
62
+ /**
63
+ * Sanitize a task/session id the same way the state store does
64
+ * (crew-agent-records.ts safeAgentTaskId — module-private there, mirrored
65
+ * here): delegate ids "parent:child" collapse to the last segment, then the
66
+ * result must be path-safe. Returns null when unsafe — callers fail closed
67
+ * (no session flags, no recovery) rather than spawning with a broken id.
68
+ * [A-Za-z0-9_-]+ is a strict subset of the SDK's session-id charset.
69
+ */
70
+ function sanitizeSessionIdentity(rawId: string): string | null {
71
+ const leaf = rawId.includes(":") ? (rawId.split(":").pop() ?? "") : rawId;
72
+ return isSafePathId(leaf) ? leaf : null;
73
+ }
74
+
75
+ /**
76
+ * Derive the worker's deterministic session identity.
77
+ *
78
+ * Layout (packet §R2.2 + fact pack §3): session files land in a PER-WORKER
79
+ * directory `<artifactsRoot>/sessions/<safeTaskId>/`; the session id rides
80
+ * `--session-id` (explicit `sessionId` — e.g. manifest.sessionId already
81
+ * threaded by child-executor — preferred, else the safe task id). Same id
82
+ * across retries appends to one file = cross-attempt resume for free.
83
+ *
84
+ * Returns null (→ no session flags, recovery silently impossible) when the
85
+ * artifacts root is unknown or no sanitizable identity exists.
86
+ */
87
+ export function deriveSessionPaths(input: SessionIdentityInput): WorkerSessionContext | null {
88
+ const safeAgent = input.agentId ? sanitizeSessionIdentity(input.agentId) : null;
89
+ const safeSession = input.sessionId ? sanitizeSessionIdentity(input.sessionId) : null;
90
+ // File id: explicit session id (manifest.sessionId) preferred, else task id.
91
+ const sessionId = safeSession ?? safeAgent;
92
+ // Dir leaf: PER-WORKER (safe task id) so same-run siblings never share a
93
+ // session file; fall back to the session id for agent-id-less custom spawns.
94
+ const dirLeaf = safeAgent ?? safeSession;
95
+ if (!sessionId || !dirLeaf) return null;
96
+ if (input.sessionDir) return { sessionId, sessionDir: input.sessionDir };
97
+ if (!input.artifactsRoot) return null;
98
+ return { sessionId, sessionDir: path.join(input.artifactsRoot, "sessions", dirLeaf) };
99
+ }
100
+
101
+ /**
102
+ * Append the session flags to the two argv views produced by
103
+ * `prepareSpawnContext`: `builtArgs` (raw worker argv — also consumed by the
104
+ * surface branch) and the headless `spawnArgs` ([script, ...builtArgs]).
105
+ * Flags are inserted right after the `--mode json -p` cluster so they never
106
+ * trail the task-file positional. Idempotent: if the flags are already
107
+ * present (builder forwarding, future wiring), this is a no-op.
108
+ */
109
+ export function appendWorkerSessionArgs(spawnArgs: string[], builtArgs: string[], ctx: WorkerSessionContext): boolean {
110
+ // PER-FLAG idempotency (battery fix 2026-10-04): the builder may forward a
111
+ // PARTIAL identity (e.g. manifest sessionId without sessionDir — the
112
+ // child-executor runWorker call passes sessionId only). The old
113
+ // both-or-nothing guard ("if --session-id present, skip everything") let
114
+ // that partial arming self-disarm the full activation: --session-id went
115
+ // out, --session-dir never did, and worker session files silently landed
116
+ // in the cwd-keyed default dir instead of the per-worker artifacts dir —
117
+ // breaking crash recovery in the live path. Fill in each missing flag.
118
+ let changed = false;
119
+ const missing: string[] = [];
120
+ if (!builtArgs.includes("--session-id")) missing.push("--session-id", ctx.sessionId);
121
+ if (!builtArgs.includes("--session-dir")) missing.push("--session-dir", ctx.sessionDir);
122
+ if (missing.length > 0) {
123
+ insertAfterHeadlessCluster(builtArgs, missing);
124
+ changed = true;
125
+ }
126
+ // Mirror onto the headless spawnArgs view (independent array) with the
127
+ // same per-flag check.
128
+ const missingSpawn: string[] = [];
129
+ if (!spawnArgs.includes("--session-id")) missingSpawn.push("--session-id", ctx.sessionId);
130
+ if (!spawnArgs.includes("--session-dir")) missingSpawn.push("--session-dir", ctx.sessionDir);
131
+ if (missingSpawn.length > 0) {
132
+ insertAfterHeadlessCluster(spawnArgs, missingSpawn);
133
+ changed = true;
134
+ }
135
+ return changed;
136
+ }
137
+
138
+ function insertAfterHeadlessCluster(arr: string[], flags: string[]): void {
139
+ const idx = arr.indexOf("--mode");
140
+ if (idx !== -1 && arr[idx + 1] === "json" && arr[idx + 2] === "-p") {
141
+ arr.splice(idx + 3, 0, ...flags);
142
+ } else {
143
+ arr.push(...flags);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Recovery gate: explicit flag (runtime.sessionRecovery → ChildPiRunInput)
149
+ * with env override. Default ON (W2 scope). The env var is the operator
150
+ * kill-switch and wins in EITHER direction.
151
+ */
152
+ export function resolveSessionRecoveryEnabled(explicit?: boolean): boolean {
153
+ const env = getCrewEnvBool("PI_CREW_SESSION_RECOVERY");
154
+ if (env !== undefined) return env;
155
+ return explicit ?? true;
156
+ }
157
+
158
+ /**
159
+ * Crash-path predicate (packet: exitCode === null OR killed). Structural so
160
+ * both the pre-resolve result and a materialized WorkerExitStatus feed it.
161
+ */
162
+ export function shouldAttemptSessionRecovery(
163
+ result: { exitCode: number | null; exitStatus?: { exitCode?: number | null; killed?: boolean } },
164
+ killed: boolean,
165
+ ): boolean {
166
+ // NB: explicit `exitCode: null` in exitStatus IS the signal-death signal —
167
+ // `??` would collapse it onto the outer exitCode, so branch on presence.
168
+ const status = result.exitStatus;
169
+ const exitCode = status && status.exitCode !== undefined ? status.exitCode : result.exitCode;
170
+ const wasKilled = status && status.killed !== undefined ? status.killed : killed;
171
+ return exitCode === null || wasKilled === true;
172
+ }
173
+
174
+ /** Bounded crash-path work: never let a pathological read hang task settle. */
175
+ const RECOVERY_DEADLINE_MS = 5_000;
176
+ /** Scan at most this many candidate files (id-matched first, then newest). */
177
+ const RECOVERY_MAX_FILES = 3;
178
+ /** Skip absurdly large session files rather than blocking settle for them. */
179
+ const RECOVERY_MAX_FILE_BYTES = 64 * 1024 * 1024;
180
+
181
+ interface SessionParserModule {
182
+ parseSessionEntries(content: string): unknown[];
183
+ migrateSessionEntries(entries: unknown[]): void;
184
+ }
185
+
186
+ let sessionParserCache: SessionParserModule | null | undefined;
187
+
188
+ async function loadSessionParser(): Promise<SessionParserModule | null> {
189
+ if (sessionParserCache !== undefined) return sessionParserCache;
190
+ try {
191
+ // First value-import of the SDK inside src/runtime — recovery is a
192
+ // crash-path concern; child-pi module init must never pay SDK load cost.
193
+ // LAZY: SDK session parsers (parseSessionEntries/migrateSessionEntries)
194
+ const mod = (await import("@earendil-works/pi-coding-agent")) as unknown as SessionParserModule;
195
+ sessionParserCache = typeof mod.parseSessionEntries === "function" && typeof mod.migrateSessionEntries === "function" ? mod : null;
196
+ } catch {
197
+ sessionParserCache = null;
198
+ }
199
+ return sessionParserCache;
200
+ }
201
+
202
+ /**
203
+ * Candidate session files, best-first: id-suffixed matches
204
+ * (`<ISO-ts>_<sessionId>.jsonl`) newest-mtime first, then any other .jsonl
205
+ * (fallback for id-less derivation). Missing/unreadable dir → [].
206
+ */
207
+ function listSessionCandidateFiles(sessionDir: string, sessionId?: string): string[] {
208
+ let names: string[];
209
+ try {
210
+ names = fs.readdirSync(sessionDir);
211
+ } catch {
212
+ return [];
213
+ }
214
+ const jsonl = names.filter((n) => n.endsWith(".jsonl"));
215
+ const withMtime = (list: string[]): { name: string; mtimeMs: number }[] => {
216
+ const out: { name: string; mtimeMs: number }[] = [];
217
+ for (const name of list) {
218
+ try {
219
+ out.push({ name, mtimeMs: fs.statSync(path.join(sessionDir, name)).mtimeMs });
220
+ } catch {
221
+ /* raced away — ignore */
222
+ }
223
+ }
224
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
225
+ };
226
+ const matched = sessionId ? withMtime(jsonl.filter((n) => n.endsWith(`_${sessionId}.jsonl`))) : [];
227
+ const matchedSet = new Set(matched.map((x) => x.name));
228
+ const rest = withMtime(jsonl.filter((n) => !matchedSet.has(n)));
229
+ return [...matched, ...rest].map((x) => path.join(sessionDir, x.name));
230
+ }
231
+
232
+ interface AssistantMessageLike {
233
+ role?: unknown;
234
+ content?: unknown;
235
+ stopReason?: unknown;
236
+ }
237
+
238
+ /** Join the text parts of an assistant message's content array. */
239
+ function extractAssistantText(content: unknown): string {
240
+ if (!Array.isArray(content)) return typeof content === "string" ? content : "";
241
+ const parts: string[] = [];
242
+ for (const part of content) {
243
+ if (part && typeof part === "object" && (part as { type?: unknown }).type === "text") {
244
+ const text = (part as { text?: unknown }).text;
245
+ if (typeof text === "string" && text.length > 0) parts.push(text);
246
+ }
247
+ }
248
+ return parts.join("\n");
249
+ }
250
+
251
+ async function readLastCompleteAssistant(sessionFile: string, sessionId: string): Promise<SessionRecoveryInfo | null> {
252
+ let size: number;
253
+ try {
254
+ size = fs.statSync(sessionFile).size;
255
+ } catch {
256
+ return null;
257
+ }
258
+ if (size <= 0 || size > RECOVERY_MAX_FILE_BYTES) return null;
259
+ let content: string;
260
+ try {
261
+ content = fs.readFileSync(sessionFile, "utf8");
262
+ } catch {
263
+ return null;
264
+ }
265
+ // Defensive torn-tail: a SIGKILL can interrupt the final line mid-write
266
+ // (no trailing newline). parseSessionEntries already skips malformed
267
+ // lines, but drop the unterminated fragment explicitly so it can never be
268
+ // resurrected as a partial record by migration.
269
+ if (content.length > 0 && !content.endsWith("\n")) {
270
+ content = content.slice(0, content.lastIndexOf("\n") + 1);
271
+ }
272
+ const parser = await loadSessionParser();
273
+ if (!parser) return null;
274
+ let entries: unknown[];
275
+ try {
276
+ entries = parser.parseSessionEntries(content);
277
+ parser.migrateSessionEntries(entries);
278
+ } catch {
279
+ return null;
280
+ }
281
+ // Backward scan for the last COMPLETE assistant record: role assistant,
282
+ // stopReason present and not "pending" (dangling), and it must carry
283
+ // replayable text — tool-call-only turns recover nothing useful, keep
284
+ // scanning for the last turn that actually spoke.
285
+ for (let i = entries.length - 1; i >= 0; i--) {
286
+ const entry = entries[i];
287
+ if (!entry || typeof entry !== "object" || (entry as { type?: unknown }).type !== "message") continue;
288
+ const message = (entry as { message?: unknown }).message;
289
+ if (!message || typeof message !== "object") continue;
290
+ const assistant = message as AssistantMessageLike;
291
+ if (assistant.role !== "assistant") continue;
292
+ const stopReason = assistant.stopReason;
293
+ if (typeof stopReason !== "string" || stopReason === "pending") continue;
294
+ const text = extractAssistantText(assistant.content);
295
+ if (!text) continue;
296
+ return {
297
+ sessionId,
298
+ sessionFile,
299
+ text,
300
+ timestamp:
301
+ typeof (entry as { timestamp?: unknown }).timestamp === "string"
302
+ ? ((entry as { timestamp?: unknown }).timestamp as string)
303
+ : "",
304
+ stopReason,
305
+ };
306
+ }
307
+ return null;
308
+ }
309
+
310
+ async function doRecoverLastAssistant(sessionDir: string, sessionId?: string): Promise<SessionRecoveryInfo | null> {
311
+ for (const file of listSessionCandidateFiles(sessionDir, sessionId).slice(0, RECOVERY_MAX_FILES)) {
312
+ const info = await readLastCompleteAssistant(file, sessionId ?? path.basename(file).replace(/\.jsonl$/, ""));
313
+ if (info) return info;
314
+ }
315
+ return null;
316
+ }
317
+
318
+ /**
319
+ * Tail-replay a worker's session directory: recover the last COMPLETE
320
+ * assistant record from the best candidate session JSONL. Returns null when
321
+ * nothing recoverable exists (no dir, no files, no complete assistant turn,
322
+ * SDK unresolvable). Best-effort by contract — never throws, never hangs
323
+ * past RECOVERY_DEADLINE_MS.
324
+ */
325
+ export async function recoverLastAssistantFromSession(sessionDir: string, sessionId?: string): Promise<SessionRecoveryInfo | null> {
326
+ const attempt = doRecoverLastAssistant(sessionDir, sessionId);
327
+ return await Promise.race([
328
+ attempt,
329
+ new Promise<SessionRecoveryInfo | null>((resolve) => {
330
+ const timer = setTimeout(() => resolve(null), RECOVERY_DEADLINE_MS);
331
+ timer.unref();
332
+ }),
333
+ ]);
334
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * P2-1 (pi 1.0.4 adoption, 2026-10-07): host-process classifier seam.
3
+ *
4
+ * Thin never-rejects wrapper around the Pi extension-surface classifier API
5
+ * (`ctx.modelRegistry.classify()` — docs/models.md:137-138 of the 1.0.4
6
+ * release: "Extensions call classifiers through ctx.modelRegistry.classify(),
7
+ * without codemode"). HOST-PROCESS ONLY: the registry handle lives on the
8
+ * extension ctx in this process — child `pi -p` workers are separate
9
+ * processes with no ctx and must never import this path.
10
+ *
11
+ * Contract (adoption review §R2.3, R2-V probe p3b):
12
+ * - classifier unavailable (`getAvailableOfType("classifier")` → [] — the
13
+ * catalog lists models but no provider is credentialed) → caller fallback;
14
+ * - classify() throws → caller fallback;
15
+ * - result.stopReason !== "stop" (e.g. "error" + errorMessage
16
+ * "Provider is not configured: opencode" on this host) → caller fallback;
17
+ * - answer missing / not boolean-shaped → caller fallback;
18
+ * - classify() must NEVER throw up to the orchestrator.
19
+ *
20
+ * Every fallback mode logs ONCE per (mode, model) per process (log-once set)
21
+ * and increments `crew.classifier.calls_total{outcome}` when a metric
22
+ * registry is threaded — mirrors the dispatch-batch retry-counter pattern
23
+ * (dispatch-batch.ts:812). Defaults are DORMANT: callers gate on
24
+ * resolveClassifierEnabled() (runtime.classifierEnabled, default false).
25
+ */
26
+ import { getCrewEnvBool, getCrewEnvString } from "../../config/env-vars.ts";
27
+ import type { MetricRegistry } from "../../observability/metric-registry.ts";
28
+ import { logInternalError } from "../../utils/internal-error.ts";
29
+ import { modelStringFromUnknown } from "../model/model-fallback.ts";
30
+
31
+ /** Default classifier model (runtime.classifierModel default). */
32
+ export const DEFAULT_CLASSIFIER_MODEL = "opencode/jev-1.13-free";
33
+
34
+ /** One typed yes/no question (codemode classify question shape, bool type). */
35
+ export interface ClassifierBoolQuestion {
36
+ type: "bool";
37
+ instructions: string;
38
+ /** Label per answer — helps the classifier ground the decision. */
39
+ criteria?: { true: string; false: string };
40
+ }
41
+
42
+ /** Request body for modelRegistry.classify(model, request). */
43
+ export interface ClassifyRequest {
44
+ state: Record<string, unknown>;
45
+ questions: Record<string, ClassifierBoolQuestion>;
46
+ }
47
+
48
+ /** Structured result shape returned by modelRegistry.classify (never throws
49
+ * per model-registry.d.ts — but we still guard a throwing implementation). */
50
+ export interface ClassifyCallResult {
51
+ answers?: Record<string, unknown>;
52
+ stopReason?: string;
53
+ errorMessage?: string;
54
+ }
55
+
56
+ /** Duck-typed host registry handle (mirrors ModelRegistryLike in
57
+ * model-fallback.ts — the runtime threads the real ctx.modelRegistry as
58
+ * `unknown`). NOTE: on the real 1.0.4 ModelRegistry, getAvailableOfType is
59
+ * ASYNC (returns a Promise — model-registry.d.ts) while getModelsOfType is
60
+ * sync; the duck type accepts both shapes and classifyBool awaits. */
61
+ export interface ClassifierModelRegistryLike {
62
+ getAvailableOfType?: (type: string) => Promise<unknown[]> | unknown[];
63
+ classify?: (model: unknown, request: ClassifyRequest) => Promise<ClassifyCallResult> | ClassifyCallResult;
64
+ }
65
+
66
+ export type ClassifierOutcomeReason =
67
+ | "classified"
68
+ | "registry_missing"
69
+ | "no_classifier_available"
70
+ | "classify_threw"
71
+ | "stop_reason_error"
72
+ | "answer_missing";
73
+
74
+ export interface ClassifyBoolInput {
75
+ /** Host-process model registry handle (ctx.modelRegistry, threaded unknown). */
76
+ modelRegistry: unknown;
77
+ /** Configured classifier model id ("provider/id"). */
78
+ classifierModel: string;
79
+ /** Key under `questions` carrying the bool question. */
80
+ questionKey: string;
81
+ question: ClassifierBoolQuestion;
82
+ /** JSON state the classifier reasons about (keep small + low-cardinality). */
83
+ state: Record<string, unknown>;
84
+ /** Caller-provided fallback decision — returned on EVERY failure mode. */
85
+ fallback: boolean;
86
+ /** Optional metric registry (counter created on demand, dispatch-batch style). */
87
+ metricRegistry?: MetricRegistry;
88
+ /** Extra low-cardinality labels for the calls_total counter. */
89
+ metricLabels?: Record<string, string>;
90
+ }
91
+
92
+ export interface ClassifyBoolResult {
93
+ /** Effective decision: the classifier answer, or the caller fallback. */
94
+ decision: boolean;
95
+ /** True ONLY when a real classifier answer informed the decision. */
96
+ fromClassifier: boolean;
97
+ /** Which branch produced the outcome (diagnostics + metric label). */
98
+ reason: ClassifierOutcomeReason;
99
+ /** Classifier model id actually used (undefined on every fallback path). */
100
+ usedModel: string | undefined;
101
+ /** Raw stopReason / errorMessage from the classify call, when one ran. */
102
+ stopReason: string | undefined;
103
+ errorMessage: string | undefined;
104
+ }
105
+
106
+ /**
107
+ * Log-once state: `${reason}|${classifierModel}`. Module-level on purpose —
108
+ * per-call-site instances would re-log on every consumer invocation; the
109
+ * process-wide set matches the "log-once per failure mode" contract.
110
+ * @internal — test introspection/reset.
111
+ */
112
+ const loggedFailureModes = new Set<string>();
113
+
114
+ /** @internal — test hook: reset the log-once set between test cases. */
115
+ export function __test__resetClassifierLogOnce(): void {
116
+ loggedFailureModes.clear();
117
+ }
118
+
119
+ /** @internal — test hook: observe the log-once set (assert no re-log). */
120
+ export function __test__classifierLoggedFailureModes(): string[] {
121
+ return [...loggedFailureModes];
122
+ }
123
+
124
+ function noteFailureMode(reason: ClassifierOutcomeReason, classifierModel: string, detail: string): void {
125
+ const key = `${reason}|${classifierModel}`;
126
+ if (loggedFailureModes.has(key)) return;
127
+ loggedFailureModes.add(key);
128
+ logInternalError("classifier-service", new Error(`classifier fallback: ${reason}`), detail, "warn");
129
+ }
130
+
131
+ function outcomeMetric(
132
+ metricRegistry: MetricRegistry | undefined,
133
+ reason: ClassifierOutcomeReason,
134
+ labels: Record<string, string> | undefined,
135
+ ): void {
136
+ try {
137
+ metricRegistry?.counter("crew.classifier.calls_total", "Classifier calls by outcome").inc({
138
+ outcome: reason,
139
+ ...labels,
140
+ });
141
+ } catch (error) {
142
+ // Metric plumbing must never break the never-rejects contract.
143
+ logInternalError("classifier-service.metric", error, `outcome=${reason}`, "warn");
144
+ }
145
+ }
146
+
147
+ /** Normalize a registry handle into the duck-typed classifier surface. */
148
+ function asClassifierRegistry(modelRegistry: unknown): ClassifierModelRegistryLike | undefined {
149
+ if (!modelRegistry || typeof modelRegistry !== "object" || Array.isArray(modelRegistry)) return undefined;
150
+ return modelRegistry as ClassifierModelRegistryLike;
151
+ }
152
+
153
+ /**
154
+ * Resolve classifier-enabled precedence: env PI_CREW_CLASSIFIER_ENABLED beats
155
+ * runtime.classifierEnabled beats default FALSE (dormant). Mirrors
156
+ * resolveHermeticWorkers (pi-args.ts) — env wins in either direction.
157
+ */
158
+ export function resolveClassifierEnabled(explicit?: boolean): boolean {
159
+ const env = getCrewEnvBool("PI_CREW_CLASSIFIER_ENABLED");
160
+ if (env !== undefined) return env;
161
+ return explicit ?? false;
162
+ }
163
+
164
+ /**
165
+ * Resolve classifier-model precedence: env PI_CREW_CLASSIFIER_MODEL beats
166
+ * runtime.classifierModel beats DEFAULT_CLASSIFIER_MODEL. Blank env values
167
+ * are ignored (fall through to config/default) — conservative.
168
+ */
169
+ export function resolveClassifierModel(explicit?: string): string {
170
+ const env = getCrewEnvString("PI_CREW_CLASSIFIER_MODEL");
171
+ if (env !== undefined && env.trim().length > 0) return env.trim();
172
+ return explicit && explicit.trim().length > 0 ? explicit.trim() : DEFAULT_CLASSIFIER_MODEL;
173
+ }
174
+
175
+ /**
176
+ * Ask ONE bool question of a host-process classifier. NEVER REJECTS — every
177
+ * failure mode (no registry, no credentialed classifier, thrown error,
178
+ * stopReason !== "stop", missing/non-boolean answer) returns the
179
+ * caller-provided fallback with {@link ClassifyBoolResult.fromClassifier}
180
+ * false, after a log-once note + metric counter increment.
181
+ */
182
+ export async function classifyBool(input: ClassifyBoolInput): Promise<ClassifyBoolResult> {
183
+ const registry = asClassifierRegistry(input.modelRegistry);
184
+ if (!registry || typeof registry.getAvailableOfType !== "function" || typeof registry.classify !== "function") {
185
+ noteFailureMode("registry_missing", input.classifierModel, "model registry absent or lacks the classifier surface");
186
+ outcomeMetric(input.metricRegistry, "registry_missing", input.metricLabels);
187
+ return {
188
+ decision: input.fallback,
189
+ fromClassifier: false,
190
+ reason: "registry_missing",
191
+ usedModel: undefined,
192
+ stopReason: undefined,
193
+ errorMessage: undefined,
194
+ };
195
+ }
196
+ // Credentialed classifiers only — the catalog (getModelsOfType) can list
197
+ // models whose provider has no key on this host (R2.3: 6 catalog entries,
198
+ // 0 available). getAvailableOfType is the availability oracle (ASYNC on
199
+ // the real registry — model-registry.d.ts).
200
+ let available: unknown[];
201
+ try {
202
+ available = (await registry.getAvailableOfType("classifier")) ?? [];
203
+ } catch (error) {
204
+ noteFailureMode("classify_threw", input.classifierModel, `getAvailableOfType threw: ${String(error)}`);
205
+ outcomeMetric(input.metricRegistry, "classify_threw", input.metricLabels);
206
+ return {
207
+ decision: input.fallback,
208
+ fromClassifier: false,
209
+ reason: "classify_threw",
210
+ usedModel: undefined,
211
+ stopReason: undefined,
212
+ errorMessage: undefined,
213
+ };
214
+ }
215
+ if (!Array.isArray(available) || available.length === 0) {
216
+ noteFailureMode(
217
+ "no_classifier_available",
218
+ input.classifierModel,
219
+ "getAvailableOfType('classifier') is empty — no credentialed classifier",
220
+ );
221
+ outcomeMetric(input.metricRegistry, "no_classifier_available", input.metricLabels);
222
+ return {
223
+ decision: input.fallback,
224
+ fromClassifier: false,
225
+ reason: "no_classifier_available",
226
+ usedModel: undefined,
227
+ stopReason: undefined,
228
+ errorMessage: undefined,
229
+ };
230
+ }
231
+ // Prefer the configured model id; fall back to the first available
232
+ // candidate so a renamed catalog id still gets a usable classifier.
233
+ const requested = input.classifierModel.trim();
234
+ const normalized = available.map((entry) => modelStringFromUnknown(entry)).filter((id): id is string => id !== undefined);
235
+ const matchedIndex = normalized.indexOf(requested);
236
+ const chosenIndex = matchedIndex >= 0 ? matchedIndex : 0;
237
+ const chosen = available[chosenIndex];
238
+ const usedModel = normalized[chosenIndex] ?? requested;
239
+ try {
240
+ const result = await registry.classify(chosen, {
241
+ state: input.state,
242
+ questions: { [input.questionKey]: input.question },
243
+ });
244
+ const stopReason = typeof result?.stopReason === "string" ? result.stopReason : undefined;
245
+ const errorMessage = typeof result?.errorMessage === "string" ? result.errorMessage : undefined;
246
+ if (stopReason !== "stop") {
247
+ noteFailureMode(
248
+ "stop_reason_error",
249
+ input.classifierModel,
250
+ `classify stopReason=${stopReason ?? "undefined"}${errorMessage ? ` errorMessage=${errorMessage}` : ""}`,
251
+ );
252
+ outcomeMetric(input.metricRegistry, "stop_reason_error", input.metricLabels);
253
+ return {
254
+ decision: input.fallback,
255
+ fromClassifier: false,
256
+ reason: "stop_reason_error",
257
+ usedModel: undefined,
258
+ stopReason,
259
+ errorMessage,
260
+ };
261
+ }
262
+ const answer = result?.answers?.[input.questionKey];
263
+ if (typeof answer !== "boolean") {
264
+ noteFailureMode("answer_missing", input.classifierModel, `answers[${input.questionKey}] is ${typeof answer}, expected boolean`);
265
+ outcomeMetric(input.metricRegistry, "answer_missing", input.metricLabels);
266
+ return {
267
+ decision: input.fallback,
268
+ fromClassifier: false,
269
+ reason: "answer_missing",
270
+ usedModel: undefined,
271
+ stopReason,
272
+ errorMessage,
273
+ };
274
+ }
275
+ outcomeMetric(input.metricRegistry, "classified", input.metricLabels);
276
+ return {
277
+ decision: answer,
278
+ fromClassifier: true,
279
+ reason: "classified",
280
+ usedModel,
281
+ stopReason,
282
+ errorMessage,
283
+ };
284
+ } catch (error) {
285
+ // The registry contract says classify never rejects, but a misbehaving
286
+ // implementation must still not take the orchestrator down with it.
287
+ noteFailureMode("classify_threw", input.classifierModel, `classify threw: ${String(error)}`);
288
+ outcomeMetric(input.metricRegistry, "classify_threw", input.metricLabels);
289
+ return {
290
+ decision: input.fallback,
291
+ fromClassifier: false,
292
+ reason: "classify_threw",
293
+ usedModel: undefined,
294
+ stopReason: undefined,
295
+ errorMessage: undefined,
296
+ };
297
+ }
298
+ }
@@ -778,6 +778,10 @@ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBat
778
778
  parentContext: input.parentContext,
779
779
  parentModel: input.parentModel,
780
780
  modelRegistry: input.modelRegistry,
781
+ // P2-1: thread the host metric registry to the task-runner seam so the
782
+ // retry-triage classifier can count calls/decisions (same
783
+ // ExecuteTeamRunInput handle dispatch-batch itself uses at :812).
784
+ metricRegistry: input.metricRegistry,
781
785
  modelOverride: input.modelOverride,
782
786
  teamRoleModel: teamRole?.model,
783
787
  teamRoleThinking: teamRole?.thinking,