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.
- package/.githooks/pre-commit +22 -0
- package/.githooks/pre-push +56 -0
- package/CHANGELOG.md +216 -1
- package/dist/index.mjs +531 -508
- package/docs/publishing.md +29 -0
- package/index.ts +3 -3
- package/package.json +9 -6
- package/scripts/install-hooks.mjs +32 -0
- package/skills/real-test-pi-crew/SKILL.md +31 -18
- package/src/config/config-validation.ts +35 -0
- package/src/config/env-vars.ts +38 -0
- package/src/config/types.ts +71 -0
- package/src/extension/registration/foreground-run-controller.ts +19 -10
- package/src/extension/registration/lifecycle-handlers.ts +6 -491
- package/src/extension/registration/lifecycle.ts +7 -90
- package/src/extension/registration/render-loop.ts +502 -0
- package/src/extension/team-tool/doctor.ts +6 -0
- package/src/prompt/prompt-runtime.ts +33 -4
- package/src/runtime/child-pi/child-pi-spawn.ts +14 -0
- package/src/runtime/child-pi/child-pi.ts +130 -6
- package/src/runtime/child-pi/pi-version.ts +187 -0
- package/src/runtime/child-pi/session-recovery.ts +334 -0
- package/src/runtime/classifier/classifier-service.ts +298 -0
- package/src/runtime/dispatch-batch.ts +4 -0
- package/src/runtime/live-session/live-session-runtime.ts +31 -0
- package/src/runtime/model/model-scope.ts +141 -21
- package/src/runtime/model/pi-args.ts +166 -6
- package/src/runtime/peer-dep.ts +24 -0
- package/src/runtime/process/zombie-scanner.ts +168 -7
- package/src/runtime/rpc/README.md +139 -0
- package/src/runtime/rpc/frame-client.ts +328 -0
- package/src/runtime/rpc/rpc-worker.ts +271 -0
- package/src/runtime/rpc/ui-request-policy.ts +112 -0
- package/src/runtime/run-worker.ts +91 -3
- package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
- package/src/runtime/skill-instructions.ts +66 -1
- package/src/runtime/surface/resolve-surface.ts +34 -19
- package/src/runtime/surface/surface-spawn.ts +8 -1
- package/src/runtime/task-output-context.ts +191 -32
- package/src/runtime/task-packet.ts +3 -0
- package/src/runtime/task-runner/child-executor.ts +172 -2
- package/src/runtime/task-runner/post-execution.ts +5 -3
- package/src/runtime/task-runner/pre-execution.ts +32 -1
- package/src/runtime/task-runner/prompt-builder.ts +10 -2
- package/src/runtime/task-runner.ts +6 -0
- package/src/schema/config-schema.ts +37 -0
- package/src/schema/team-tool-schema.ts +14 -2
- package/src/state/contracts.ts +5 -0
- package/src/ui/format-helpers.ts +11 -0
- package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
- package/src/ui/pi-ui-compat.ts +18 -9
- package/src/ui/rail.ts +14 -1
- package/src/ui/settings-overlay.ts +11 -2
- package/src/ui/widget/index.ts +0 -1
- package/src/ui/widget/task-list.ts +10 -3
- package/src/ui/widget/widget-renderer.ts +53 -4
- package/src/utils/relative-time.ts +11 -3
- 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,
|