@cr1ms0n/pi-subagent 0.8.9 → 0.10.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.
@@ -1,335 +1,643 @@
1
- import { Buffer } from "node:buffer";
2
- import type { SubagentConfig } from "./config.js";
3
- import type { ChildProcessIdentity, RunMode, RunSnapshot, RunState, TaskProfile, TaskSpec, TimeoutPhase, UsageStats } from "./types.js";
4
- import { emptyUsage } from "./types.js";
5
- import { isThinkingLevel } from "./thinking.js";
6
-
7
- export const RUN_ENTRY_TYPE = "subagent-run-v1";
8
-
9
- export interface PersistenceAdapter {
10
- /** Append to the currently-owned parent session. Implementations must reject stale ownership. */
11
- appendEntry(type: string, payload: unknown): void;
12
- /** Active-branch entries only, in branch order. */
13
- getEntries(): Array<{ type?: string; customType?: string; data?: unknown; timestamp?: number }>;
14
- }
15
-
16
- export interface PersistedResult {
17
- backend?: import("./types.js").BackendName;
18
- label: string;
19
- task: string;
20
- state: RunState;
21
- exitCode: number | null;
22
- stopReason?: string;
23
- timeoutPhase?: TimeoutPhase;
24
- errorMessage?: string;
25
- usage: UsageStats;
26
- model?: string;
27
- thinking?: TaskSpec["thinking"];
28
- profile?: TaskProfile;
29
- canWrite?: boolean;
30
- outputFile?: string;
31
- outputMode?: "inline" | "file-only";
32
- worktree?: { cwd: string; branch: string; baseCommit: string; changed: boolean; diffSummary?: string };
33
- sessionId?: string;
34
- process?: ChildProcessIdentity;
35
- finalOutput?: string;
36
- transcript?: string;
37
- /** Budget-stopped child that wrapped up gracefully within its grace turns. */
38
- wrappedUp?: boolean;
39
- /** Set while no protocol activity has been seen for the stall window. */
40
- stalledSince?: number;
41
- /** Total attempts including retries (present when > 1). */
42
- attempts?: number;
43
- /** Models tried across attempts, in order. */
44
- attemptedModels?: string[];
45
- /** Parsed structured result when output_schema validated. */
46
- structuredOutput?: unknown;
47
- /** Validation errors when output_schema was requested but failed. */
48
- structuredError?: string;
49
- }
50
-
51
- export interface PersistenceEventData {
52
- mode?: RunMode;
53
- state?: RunState;
54
- startedAt?: number;
55
- endedAt?: number;
56
- taskPreviews?: string[];
57
- summary?: string;
58
- delivered?: boolean;
59
- resumeBlocked?: boolean;
60
- results?: PersistedResult[];
61
- resultIndex?: number;
62
- childSessionId?: string;
63
- progress?: string;
64
- turn?: number;
65
- }
66
-
67
- export interface PersistenceEvent {
68
- schemaVersion: 1;
69
- id: string;
70
- sessionKey: string;
71
- timestamp: number;
72
- sequence: number;
73
- type: "start" | "checkpoint" | "terminal" | "delivered" | "dismissed";
74
- data: PersistenceEventData;
75
- }
76
-
77
- function isRunState(value: unknown): value is RunState {
78
- return ["queued", "running", "completed", "partial", "failed", "cancelled", "lost", "timeout"].includes(
79
- String(value),
80
- );
81
- }
82
-
83
- function isTimeoutPhase(value: unknown): value is TimeoutPhase {
84
- return ["queued", "starting", "running", "cancelling"].includes(String(value));
85
- }
86
-
87
- function normalizeProcess(value: unknown): ChildProcessIdentity | undefined {
88
- if (!value || typeof value !== "object") return undefined;
89
- const p = value as Partial<ChildProcessIdentity>;
90
- if (typeof p.pid !== "number" || !Number.isFinite(p.pid) || p.pid <= 0) return undefined;
91
- return {
92
- pid: p.pid,
93
- startTime: typeof p.startTime === "number" && Number.isFinite(p.startTime) ? p.startTime : 0,
94
- pgid: typeof p.pgid === "number" && Number.isFinite(p.pgid) ? p.pgid : undefined,
95
- hostname: typeof p.hostname === "string" ? p.hostname : undefined,
96
- };
97
- }
98
-
99
- function isRunMode(value: unknown): value is RunMode {
100
- return value === "single" || value === "parallel";
101
- }
102
-
103
- function normalizeUsage(value: unknown): UsageStats {
104
- if (!value || typeof value !== "object") return emptyUsage();
105
- const input = value as Partial<UsageStats>;
106
- const finite = (n: unknown) => (typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : 0);
107
- return {
108
- input: finite(input.input),
109
- output: finite(input.output),
110
- cacheRead: finite(input.cacheRead),
111
- cacheWrite: finite(input.cacheWrite),
112
- reasoning: finite(input.reasoning),
113
- cost: finite(input.cost),
114
- costInput: finite(input.costInput),
115
- costOutput: finite(input.costOutput),
116
- costCacheRead: finite(input.costCacheRead),
117
- costCacheWrite: finite(input.costCacheWrite),
118
- contextTokens: finite(input.contextTokens),
119
- turns: finite(input.turns),
120
- };
121
- }
122
-
123
- function utf8Prefix(value: string, maxBytes: number): string {
124
- const buffer = Buffer.from(value, "utf8");
125
- if (buffer.length <= maxBytes) return value;
126
- let end = maxBytes;
127
- while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
128
- return buffer.subarray(0, end).toString("utf8");
129
- }
130
-
131
- function normalizeResult(value: unknown): PersistedResult | undefined {
132
- if (!value || typeof value !== "object") return undefined;
133
- const r = value as Partial<PersistedResult>;
134
- if (typeof r.label !== "string" || typeof r.task !== "string") return undefined;
135
- return {
136
- label: r.label,
137
- task: r.task,
138
- state: isRunState(r.state) ? r.state : "running",
139
- exitCode: typeof r.exitCode === "number" || r.exitCode === null ? r.exitCode : null,
140
- stopReason: typeof r.stopReason === "string" ? r.stopReason : undefined,
141
- timeoutPhase: isTimeoutPhase(r.timeoutPhase) ? r.timeoutPhase : undefined,
142
- errorMessage: typeof r.errorMessage === "string" ? utf8Prefix(r.errorMessage, 2_000) : undefined,
143
- usage: normalizeUsage(r.usage),
144
- model: typeof r.model === "string" ? r.model : undefined,
145
- thinking: isThinkingLevel(r.thinking) ? r.thinking : undefined,
146
- profile: ["explore", "review", "general"].includes(String(r.profile)) ? r.profile : undefined,
147
- canWrite: typeof r.canWrite === "boolean" ? r.canWrite : undefined,
148
- outputFile: typeof r.outputFile === "string" ? r.outputFile : undefined,
149
- outputMode: r.outputMode === "file-only" ? "file-only" : r.outputMode === "inline" ? "inline" : undefined,
150
- worktree:
151
- r.worktree &&
152
- typeof r.worktree.cwd === "string" &&
153
- typeof r.worktree.branch === "string" &&
154
- typeof r.worktree.baseCommit === "string"
155
- ? { ...r.worktree, changed: r.worktree.changed === true }
156
- : undefined,
157
- sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
158
- process: normalizeProcess(r.process),
159
- finalOutput: typeof r.finalOutput === "string" ? utf8Prefix(r.finalOutput, 16_384) : undefined,
160
- transcript: typeof r.transcript === "string" ? utf8Prefix(r.transcript, 32_768) : undefined,
161
- wrappedUp: r.wrappedUp === true ? true : undefined,
162
- stalledSince: typeof r.stalledSince === "number" && Number.isFinite(r.stalledSince) ? r.stalledSince : undefined,
163
- attempts: typeof r.attempts === "number" && Number.isInteger(r.attempts) && r.attempts > 1 ? r.attempts : undefined,
164
- attemptedModels: Array.isArray(r.attemptedModels)
165
- ? r.attemptedModels.filter((m): m is string => typeof m === "string").slice(0, 10)
166
- : undefined,
167
- structuredOutput: r.structuredOutput !== undefined && Buffer.byteLength(JSON.stringify(r.structuredOutput) ?? "", "utf8") <= 32_768
168
- ? r.structuredOutput
169
- : undefined,
170
- structuredError: typeof r.structuredError === "string" ? utf8Prefix(r.structuredError, 1_000) : undefined,
171
- };
172
- }
173
-
174
- function unwrapEvent(entry: { type?: string; customType?: string; data?: unknown }): PersistenceEvent | undefined {
175
- if (entry.customType !== RUN_ENTRY_TYPE && entry.type !== RUN_ENTRY_TYPE) return undefined;
176
- const raw = entry.data as Partial<PersistenceEvent> | undefined;
177
- if (!raw || raw.schemaVersion !== 1 || typeof raw.id !== "string" || typeof raw.sessionKey !== "string") {
178
- return undefined;
179
- }
180
- if (!["start", "checkpoint", "terminal", "delivered", "dismissed"].includes(String(raw.type))) {
181
- return undefined;
182
- }
183
- return {
184
- schemaVersion: 1,
185
- id: raw.id,
186
- sessionKey: raw.sessionKey,
187
- timestamp: typeof raw.timestamp === "number" ? raw.timestamp : 0,
188
- sequence: typeof raw.sequence === "number" ? raw.sequence : 0,
189
- type: raw.type as PersistenceEvent["type"],
190
- data: raw.data && typeof raw.data === "object" ? raw.data : {},
191
- };
192
- }
193
-
194
- /**
195
- * Versioned event persistence. Restoration folds every event on the active branch,
196
- * rather than treating the latest delta as a complete snapshot.
197
- */
198
- export class PersistenceLayer {
199
- private sequence = 0;
200
-
201
- constructor(
202
- private readonly adapter: PersistenceAdapter,
203
- private readonly config: SubagentConfig,
204
- ) {
205
- for (const entry of adapter.getEntries()) {
206
- const event = unwrapEvent(entry);
207
- if (event) this.sequence = Math.max(this.sequence, event.sequence);
208
- }
209
- }
210
-
211
- persist(
212
- id: string,
213
- sessionKey: string,
214
- type: PersistenceEvent["type"],
215
- data: PersistenceEventData,
216
- _terminalHint?: boolean,
217
- ): void {
218
- if (!id || !sessionKey) return;
219
- const event: PersistenceEvent = {
220
- schemaVersion: 1,
221
- id,
222
- sessionKey,
223
- timestamp: Date.now(),
224
- sequence: ++this.sequence,
225
- type,
226
- data,
227
- };
228
- this.adapter.appendEntry(RUN_ENTRY_TYPE, event);
229
- }
230
-
231
- /** Fold active-branch events in their session order. */
232
- rebuild(sessionKey: string): Map<string, RunSnapshot> {
233
- const snapshots = new Map<string, RunSnapshot>();
234
-
235
- for (const entry of this.adapter.getEntries()) {
236
- const event = unwrapEvent(entry);
237
- if (!event || event.sessionKey !== sessionKey) continue;
238
-
239
- let snapshot = snapshots.get(event.id);
240
- if (!snapshot) {
241
- snapshot = {
242
- schemaVersion: 1,
243
- id: event.id,
244
- sessionKey,
245
- mode: "single",
246
- state: "running",
247
- startedAt: event.timestamp || Date.now(),
248
- taskPreviews: [],
249
- delivered: false,
250
- resumeBlocked: false,
251
- results: [],
252
- };
253
- }
254
-
255
- const d = event.data;
256
- if (typeof d.resumeBlocked === "boolean") snapshot.resumeBlocked = d.resumeBlocked;
257
- if (isRunMode(d.mode)) snapshot.mode = d.mode;
258
- if (isRunState(d.state)) snapshot.state = d.state;
259
- if (typeof d.startedAt === "number") snapshot.startedAt = d.startedAt;
260
- if (typeof d.endedAt === "number") snapshot.endedAt = d.endedAt;
261
- if (Array.isArray(d.taskPreviews) && d.taskPreviews.every((x) => typeof x === "string")) {
262
- snapshot.taskPreviews = [...d.taskPreviews];
263
- }
264
- if (typeof d.summary === "string") snapshot.summary = d.summary;
265
- if (typeof d.delivered === "boolean") snapshot.delivered = d.delivered;
266
- if (Array.isArray(d.results)) {
267
- snapshot.results = d.results.map(normalizeResult).filter((r): r is PersistedResult => !!r);
268
- }
269
-
270
- if (event.type === "checkpoint" && typeof d.childSessionId === "string") {
271
- const index = typeof d.resultIndex === "number" ? d.resultIndex : 0;
272
- const result = snapshot.results[index];
273
- if (result) result.sessionId = d.childSessionId;
274
- }
275
- if (event.type === "delivered" || event.type === "dismissed") snapshot.delivered = true;
276
-
277
- snapshots.set(event.id, snapshot);
278
- }
279
-
280
- for (const [id, snapshot] of snapshots) {
281
- if (snapshot.state === "running" || snapshot.state === "queued") {
282
- // "lost" means ownership cannot be proven after a parent disruption.
283
- // Orphan reconcile (ProcessLockManager) must have run before callers
284
- // consider the session safe to resume; rebuild keeps resumeBlocked set
285
- // until a later reconciler clears it.
286
- snapshots.set(id, {
287
- ...snapshot,
288
- state: "lost",
289
- endedAt: Date.now(),
290
- resumeBlocked: true,
291
- summary:
292
- snapshot.summary ||
293
- "Run ownership was lost (parent interrupted). Resume is blocked until orphan reconciliation confirms the child is dead.",
294
- });
295
- }
296
- }
297
-
298
- return snapshots;
299
- }
300
-
301
- markDelivered(id: string, sessionKey: string): void {
302
- this.persist(id, sessionKey, "delivered", { delivered: true });
303
- }
304
-
305
- /** All child session ids referenced by any run event on the active branch. */
306
- referencedSessionIds(): Set<string> {
307
- const ids = new Set<string>();
308
- for (const entry of this.adapter.getEntries()) {
309
- const event = unwrapEvent(entry);
310
- if (!event) continue;
311
- if (typeof event.data.childSessionId === "string") ids.add(event.data.childSessionId);
312
- if (Array.isArray(event.data.results)) {
313
- for (const result of event.data.results) {
314
- const sessionId = (result as Partial<PersistedResult>)?.sessionId;
315
- if (typeof sessionId === "string" && sessionId) ids.add(sessionId);
316
- }
317
- }
318
- }
319
- return ids;
320
- }
321
-
322
- /**
323
- * Non-destructive planner. Filesystem scanning/deletion is intentionally
324
- * outside persistence (see maintenance.ts). "keep" is the union of caller
325
- * references and every child session referenced on the active branch.
326
- */
327
- planRetention(referencedSessionIds: Set<string>): { keep: string[]; candidates: string[] } {
328
- const keep = new Set([...referencedSessionIds, ...this.referencedSessionIds()]);
329
- if (!this.config.sessionRetentionDays || this.config.sessionRetentionDays <= 0) {
330
- return { keep: [...keep], candidates: [] };
331
- }
332
- // Candidates are resolved by the filesystem sweep; the planner only fixes the keep set.
333
- return { keep: [...keep], candidates: [] };
334
- }
335
- }
1
+ import { Buffer } from "node:buffer";
2
+ import type { SubagentConfig } from "./config.js";
3
+ import { MAX_ROUTING_TOOL_QUESTIONS, type RoutingReceipt } from "./routing-types.js";
4
+ import type { ChildProcessIdentity, RunMode, RunSnapshot, RunState, TaskProfile, TaskRouting, TaskSpec, TimeoutPhase, UsageStats } from "./types.js";
5
+ import { emptyUsage } from "./types.js";
6
+ import { isThinkingLevel } from "./thinking.js";
7
+
8
+ export const RUN_ENTRY_TYPE = "subagent-run-v1";
9
+ /** Versioned persistence record for one selector HTTP receipt (upserted by full request ID). */
10
+ export const ROUTING_ENTRY_TYPE = "subagent-routing-v1";
11
+ /** Shared producer/replay bound for one atomic native delivery attachment. */
12
+ export const MAX_ROUTING_DELIVERY_IDS = 1024;
13
+
14
+ // ---- Bounded routing decode -------------------------------------------------
15
+ //
16
+ // Routing events and route metadata cross the persistence boundary as untrusted
17
+ // data (old snapshots, user-edited session files, concurrent writers). Decode
18
+ // field-by-field, cap every string/array, and never surface a value we could not
19
+ // validate. Unknown currency is always forced to the honest `"unknown"`.
20
+
21
+ const ROUTING_PURPOSES = ["plan", "dispatch", "synthesis"] as const;
22
+ const ROUTING_OUTCOMES = ["success", "error", "timeout", "aborted"] as const;
23
+ const ROUTING_USAGE_STATUSES = ["reported", "unknown"] as const;
24
+ const ROUTING_FAILURE_CODES = [
25
+ "invalid_input",
26
+ "missing_api_key",
27
+ "no_candidate_models",
28
+ "too_many_models",
29
+ "too_many_tools",
30
+ "request_too_large",
31
+ "response_too_large",
32
+ "transport_error",
33
+ "timeout",
34
+ "aborted",
35
+ "unauthorized",
36
+ "invalid_request",
37
+ "rate_limited",
38
+ "overloaded",
39
+ "http_error",
40
+ "malformed_response",
41
+ "invalid_decision",
42
+ ] as const;
43
+
44
+ const MAX_ROUTING_ID_LENGTH = 256;
45
+ const MAX_ROUTING_VERSION_LENGTH = 128;
46
+ const MAX_ROUTING_MODEL_LENGTH = 256;
47
+ const MAX_ROUTING_LIST = 256;
48
+
49
+ function routingString(value: unknown, max = MAX_ROUTING_ID_LENGTH): string | undefined {
50
+ if (typeof value !== "string") return undefined;
51
+ const trimmed = value.trim();
52
+ if (!trimmed || trimmed.length > max || /[\u0000-\u001f\u007f]/.test(trimmed)) return undefined;
53
+ return trimmed;
54
+ }
55
+
56
+ function routingNonNegativeInt(value: unknown): number | undefined {
57
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
58
+ }
59
+
60
+ function routingNonNegativeNumber(value: unknown): number | undefined {
61
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
62
+ }
63
+
64
+ function routingOneOf<T extends readonly string[]>(allowed: T, value: unknown): T[number] | undefined {
65
+ return typeof value === "string" && (allowed as readonly string[]).includes(value) ? (value as T[number]) : undefined;
66
+ }
67
+
68
+ function routingStringArray(value: unknown, max = MAX_ROUTING_LIST): readonly string[] | undefined {
69
+ if (!Array.isArray(value) || value.length > max) return undefined;
70
+ const out: string[] = [];
71
+ for (const item of value.slice(0, max)) {
72
+ const name = routingString(item, MAX_ROUTING_MODEL_LENGTH);
73
+ if (!name) return undefined;
74
+ out.push(name);
75
+ }
76
+ return Object.freeze(out);
77
+ }
78
+
79
+ /** Bounded decode of one receipt. Returns undefined for malformed untrusted input. */
80
+ export function normalizeRoutingReceipt(value: unknown): RoutingReceipt | undefined {
81
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
82
+ const r = value as Record<string, unknown>;
83
+
84
+ const requestId = routingString(r.requestId);
85
+ const purpose = routingOneOf(ROUTING_PURPOSES, r.purpose);
86
+ const selectorModel = routingString(r.selectorModel, MAX_ROUTING_VERSION_LENGTH);
87
+ const outcome = routingOneOf(ROUTING_OUTCOMES, r.outcome);
88
+ const usageStatus = routingOneOf(ROUTING_USAGE_STATUSES, r.usageStatus);
89
+ const durationMs = routingNonNegativeNumber(r.durationMs);
90
+ if (!requestId || !purpose || !selectorModel || !outcome || !usageStatus || durationMs === undefined) return undefined;
91
+
92
+ const code = routingOneOf(ROUTING_FAILURE_CODES, r.code);
93
+ const httpStatus = routingNonNegativeInt(r.httpStatus);
94
+ const taskIndex = routingNonNegativeInt(r.taskIndex);
95
+ const selectorVersion = routingString(r.selectorVersion, MAX_ROUTING_VERSION_LENGTH);
96
+ const inputTokens = routingNonNegativeInt(r.inputTokens);
97
+ const outputTokens = routingNonNegativeInt(r.outputTokens);
98
+
99
+ return Object.freeze({
100
+ requestId,
101
+ purpose,
102
+ ...(taskIndex === undefined ? {} : { taskIndex }),
103
+ selectorModel,
104
+ ...(selectorVersion === undefined ? {} : { selectorVersion }),
105
+ outcome,
106
+ ...(code === undefined ? {} : { code }),
107
+ ...(httpStatus === undefined ? {} : { httpStatus }),
108
+ durationMs,
109
+ ...(inputTokens === undefined ? {} : { inputTokens }),
110
+ ...(outputTokens === undefined ? {} : { outputTokens }),
111
+ usageStatus: usageStatus === "reported" && inputTokens !== undefined && outputTokens !== undefined ? "reported" : "unknown",
112
+ currency: "unknown" as const,
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Bounded decode of the routing metadata attached to a task result. Requires the full
118
+ * `RoutingDecision` core (`decisionId`/`purpose`/`selectedModel`/`selectorModel`/
119
+ * `selectedTools`) plus the locally added `mandatoryTools` list. Anything malformed,
120
+ * partial or oversized returns `undefined` so old/legacy snapshots stay readable.
121
+ */
122
+ export function normalizeTaskRouting(value: unknown): TaskRouting | undefined {
123
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
124
+ const r = value as Record<string, unknown>;
125
+
126
+ const decisionId = routingString(r.decisionId);
127
+ const purpose = routingOneOf(ROUTING_PURPOSES, r.purpose);
128
+ const selectedModel = routingString(r.selectedModel, MAX_ROUTING_MODEL_LENGTH);
129
+ const selectorModel = routingString(r.selectorModel, MAX_ROUTING_VERSION_LENGTH);
130
+ const selectedTools = routingStringArray(r.selectedTools);
131
+ const mandatoryTools = routingStringArray(r.mandatoryTools);
132
+ if (!decisionId || !purpose || !selectedModel || !selectorModel || !selectedTools || !mandatoryTools) return undefined;
133
+
134
+ const taskIndex = routingNonNegativeInt(r.taskIndex);
135
+ const confidence = routingNonNegativeNumber(r.confidence);
136
+ if (r.confidence !== undefined && (confidence === undefined || confidence > 1)) return undefined;
137
+ const selectorVersion = routingString(r.selectorVersion, MAX_ROUTING_VERSION_LENGTH);
138
+ const selectorVersions = routingStringArray(r.selectorVersions, MAX_ROUTING_TOOL_QUESTIONS + 1);
139
+ const receiptIds = routingStringArray(r.receiptIds, MAX_ROUTING_TOOL_QUESTIONS + 1);
140
+ const latencyMs = routingNonNegativeNumber(r.latencyMs);
141
+
142
+ return Object.freeze({
143
+ decisionId,
144
+ purpose,
145
+ ...(taskIndex === undefined ? {} : { taskIndex }),
146
+ selectedModel,
147
+ selectedTools,
148
+ ...(confidence === undefined ? {} : { confidence }),
149
+ selectorModel,
150
+ ...(selectorVersion === undefined ? {} : { selectorVersion }),
151
+ selectorVersions: selectorVersions ?? (selectorVersion ? Object.freeze([selectorVersion]) : Object.freeze([])),
152
+ latencyMs: latencyMs ?? 0,
153
+ receiptIds: receiptIds ?? Object.freeze([]),
154
+ mandatoryTools,
155
+ outcome: "success" as const,
156
+ });
157
+ }
158
+
159
+ /**
160
+ * One persisted routing receipt. `schemaVersion:1` mirrors the run-event contract; the
161
+ * receipt keeps its full unique `requestId` so replay/upsert deduplicates by identity.
162
+ */
163
+ export interface PersistedRoutingEvent {
164
+ schemaVersion: 1;
165
+ sessionKey: string;
166
+ timestamp: number;
167
+ receipt: RoutingReceipt;
168
+ runId?: string;
169
+ delivered?: boolean;
170
+ }
171
+
172
+ /** Build a persistable routing event; the caller appends it through its adapter. */
173
+ export function buildRoutingEvent(
174
+ sessionKey: string,
175
+ receipt: RoutingReceipt,
176
+ runId?: string,
177
+ delivered?: boolean,
178
+ ): PersistedRoutingEvent {
179
+ return {
180
+ schemaVersion: 1,
181
+ sessionKey,
182
+ timestamp: Date.now(),
183
+ receipt,
184
+ ...(runId === undefined ? {} : { runId }),
185
+ ...(delivered === undefined ? {} : { delivered }),
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Bounded decode of a persisted routing event. Accepts either the raw payload or an
191
+ * adapter-style custom entry wrapper (`{ customType/type, data }`) so callers can pass
192
+ * `getEntries()` output and in-memory pending payloads through the same fold.
193
+ */
194
+ export function normalizeRoutingEvent(value: unknown): PersistedRoutingEvent | undefined {
195
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
196
+ const wrapper = value as { customType?: unknown; type?: unknown; data?: unknown };
197
+ let raw: unknown = value;
198
+ if (wrapper.customType === ROUTING_ENTRY_TYPE || (wrapper.data !== undefined && wrapper.type === ROUTING_ENTRY_TYPE)) {
199
+ raw = wrapper.data;
200
+ }
201
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
202
+ const event = raw as Record<string, unknown>;
203
+ if (event.schemaVersion !== 1) return undefined;
204
+ const sessionKey = routingString(event.sessionKey);
205
+ const timestamp = routingNonNegativeNumber(event.timestamp);
206
+ const receipt = normalizeRoutingReceipt(event.receipt);
207
+ if (!sessionKey || timestamp === undefined || !receipt) return undefined;
208
+ const runId = routingString(event.runId);
209
+ const delivered = event.delivered === true ? true : undefined;
210
+ return Object.freeze({
211
+ schemaVersion: 1 as const,
212
+ sessionKey,
213
+ timestamp,
214
+ receipt,
215
+ ...(runId === undefined ? {} : { runId }),
216
+ ...(delivered === undefined ? {} : { delivered }),
217
+ });
218
+ }
219
+
220
+ /** A receipt folded across the active branch plus pending in-memory upserts. */
221
+ export interface FoldedRoutingReceipt {
222
+ requestId: string;
223
+ sessionKey: string;
224
+ timestamp: number;
225
+ receipt: RoutingReceipt;
226
+ runId?: string;
227
+ delivered: boolean;
228
+ }
229
+
230
+ /**
231
+ * Fold routing events in branch order (persisted entries first, then newer in-memory
232
+ * pending upserts), keeping the latest receipt per full unique `requestId`. A later
233
+ * upsert may refresh outcome/runId; `delivered:true` is sticky and never reset.
234
+ * When `sessionKey` is given, only that branch's events participate.
235
+ */
236
+ export function foldRoutingReceipts(
237
+ entries: readonly unknown[],
238
+ pending: readonly unknown[] = [],
239
+ sessionKey?: string,
240
+ ): Map<string, FoldedRoutingReceipt> {
241
+ const folded = new Map<string, FoldedRoutingReceipt>();
242
+ const deliveredIds = new Set<string>();
243
+ const deliveredRuns = new Set<string>();
244
+ // A whole native delivery is one append: run delivery covers linked receipts;
245
+ // plan/async-start use one bounded request-ID batch, never per-receipt partial commits.
246
+ for (const entry of entries) {
247
+ if (!entry || typeof entry !== "object") continue;
248
+ const wrapper = entry as { customType?: unknown; type?: unknown; data?: unknown };
249
+ const raw = wrapper.data;
250
+ if (!raw || typeof raw !== "object") continue;
251
+ const value = raw as Record<string, unknown>;
252
+ if (value.schemaVersion !== 1 || !routingString(value.sessionKey)
253
+ || (sessionKey !== undefined && value.sessionKey !== sessionKey)) continue;
254
+ if (wrapper.customType === RUN_ENTRY_TYPE && value.type === "delivered") {
255
+ const id = routingString(value.id);
256
+ if (id) deliveredRuns.add(id);
257
+ }
258
+ if (wrapper.customType === ROUTING_ENTRY_TYPE && value.kind === "native-delivery"
259
+ && Array.isArray(value.requestIds) && value.requestIds.length <= MAX_ROUTING_DELIVERY_IDS
260
+ && value.requestIds.every((id) => routingString(id) !== undefined)) {
261
+ for (const id of value.requestIds) deliveredIds.add(id as string);
262
+ }
263
+ }
264
+
265
+ const apply = (event: PersistedRoutingEvent): void => {
266
+ if (sessionKey !== undefined && event.sessionKey !== sessionKey) return;
267
+ const previous = folded.get(event.receipt.requestId);
268
+ const runId = event.runId !== undefined ? event.runId : previous?.runId;
269
+ folded.set(event.receipt.requestId, Object.freeze({
270
+ requestId: event.receipt.requestId,
271
+ sessionKey: event.sessionKey,
272
+ timestamp: event.timestamp,
273
+ receipt: event.receipt,
274
+ ...(runId === undefined ? {} : { runId }),
275
+ delivered: (previous?.delivered ?? false) || event.delivered === true,
276
+ }));
277
+ };
278
+ for (const entry of entries) {
279
+ const event = normalizeRoutingEvent(entry);
280
+ if (event) apply(event);
281
+ }
282
+ for (const entry of pending) {
283
+ const event = normalizeRoutingEvent(entry);
284
+ if (event) apply(event);
285
+ }
286
+ for (const [id, entry] of folded) {
287
+ if (!entry.delivered && (deliveredIds.has(id) || (entry.runId !== undefined && deliveredRuns.has(entry.runId)))) {
288
+ folded.set(id, Object.freeze({ ...entry, delivered: true }));
289
+ }
290
+ }
291
+ return folded;
292
+ }
293
+
294
+ /** Undelivered receipts, optionally restricted to one run, in timestamp order. */
295
+ export function undeliveredRoutingReceipts(
296
+ folded: ReadonlyMap<string, FoldedRoutingReceipt>,
297
+ runId?: string,
298
+ ): FoldedRoutingReceipt[] {
299
+ const out: FoldedRoutingReceipt[] = [];
300
+ for (const entry of folded.values()) {
301
+ if (entry.delivered) continue;
302
+ if (runId !== undefined && entry.runId !== runId) continue;
303
+ out.push(entry);
304
+ }
305
+ return out.sort((a, b) => a.timestamp - b.timestamp);
306
+ }
307
+
308
+ export interface PersistenceAdapter {
309
+ /** Append to the currently-owned parent session. Implementations must reject stale ownership. */
310
+ appendEntry(type: string, payload: unknown): void;
311
+ /** Active-branch entries only, in branch order. */
312
+ getEntries(): Array<{ type?: string; customType?: string; data?: unknown; timestamp?: number }>;
313
+ }
314
+
315
+ export interface PersistedResult {
316
+ backend?: import("./types.js").BackendName;
317
+ label: string;
318
+ task: string;
319
+ state: RunState;
320
+ exitCode: number | null;
321
+ stopReason?: string;
322
+ timeoutPhase?: TimeoutPhase;
323
+ errorMessage?: string;
324
+ usage: UsageStats;
325
+ model?: string;
326
+ thinking?: TaskSpec["thinking"];
327
+ profile?: TaskProfile;
328
+ canWrite?: boolean;
329
+ outputFile?: string;
330
+ outputMode?: "inline" | "file-only";
331
+ worktree?: { cwd: string; branch: string; baseCommit: string; changed: boolean; diffSummary?: string };
332
+ sessionId?: string;
333
+ process?: ChildProcessIdentity;
334
+ finalOutput?: string;
335
+ transcript?: string;
336
+ /** Bounded Jev route metadata; absent on legacy snapshots. */
337
+ routing?: TaskRouting;
338
+ /** Budget-stopped child that wrapped up gracefully within its grace turns. */
339
+ wrappedUp?: boolean;
340
+ /** Set while no protocol activity has been seen for the stall window. */
341
+ stalledSince?: number;
342
+ /** Total attempts including retries (present when > 1). */
343
+ attempts?: number;
344
+ /** Models tried across attempts, in order. */
345
+ attemptedModels?: string[];
346
+ /** Parsed structured result when output_schema validated. */
347
+ structuredOutput?: unknown;
348
+ /** Validation errors when output_schema was requested but failed. */
349
+ structuredError?: string;
350
+ }
351
+
352
+ export interface PersistenceEventData {
353
+ mode?: RunMode;
354
+ state?: RunState;
355
+ startedAt?: number;
356
+ endedAt?: number;
357
+ taskPreviews?: string[];
358
+ summary?: string;
359
+ delivered?: boolean;
360
+ resumeBlocked?: boolean;
361
+ results?: PersistedResult[];
362
+ resultIndex?: number;
363
+ childSessionId?: string;
364
+ progress?: string;
365
+ turn?: number;
366
+ }
367
+
368
+ export interface PersistenceEvent {
369
+ schemaVersion: 1;
370
+ id: string;
371
+ sessionKey: string;
372
+ timestamp: number;
373
+ sequence: number;
374
+ type: "start" | "checkpoint" | "terminal" | "delivered" | "dismissed";
375
+ data: PersistenceEventData;
376
+ }
377
+
378
+ function isRunState(value: unknown): value is RunState {
379
+ return ["queued", "running", "completed", "partial", "failed", "cancelled", "lost", "timeout"].includes(
380
+ String(value),
381
+ );
382
+ }
383
+
384
+ function isTimeoutPhase(value: unknown): value is TimeoutPhase {
385
+ return ["queued", "starting", "running", "cancelling"].includes(String(value));
386
+ }
387
+
388
+ function normalizeProcess(value: unknown): ChildProcessIdentity | undefined {
389
+ if (!value || typeof value !== "object") return undefined;
390
+ const p = value as Partial<ChildProcessIdentity>;
391
+ if (typeof p.pid !== "number" || !Number.isFinite(p.pid) || p.pid <= 0) return undefined;
392
+ return {
393
+ pid: p.pid,
394
+ startTime: typeof p.startTime === "number" && Number.isFinite(p.startTime) ? p.startTime : 0,
395
+ pgid: typeof p.pgid === "number" && Number.isFinite(p.pgid) ? p.pgid : undefined,
396
+ hostname: typeof p.hostname === "string" ? p.hostname : undefined,
397
+ };
398
+ }
399
+
400
+ function isRunMode(value: unknown): value is RunMode {
401
+ return value === "single" || value === "parallel";
402
+ }
403
+
404
+ function normalizeUsage(value: unknown): UsageStats {
405
+ if (!value || typeof value !== "object") return emptyUsage();
406
+ const input = value as Partial<UsageStats>;
407
+ const finite = (n: unknown) => (typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : 0);
408
+ return {
409
+ input: finite(input.input),
410
+ output: finite(input.output),
411
+ cacheRead: finite(input.cacheRead),
412
+ cacheWrite: finite(input.cacheWrite),
413
+ reasoning: finite(input.reasoning),
414
+ cost: finite(input.cost),
415
+ costInput: finite(input.costInput),
416
+ costOutput: finite(input.costOutput),
417
+ costCacheRead: finite(input.costCacheRead),
418
+ costCacheWrite: finite(input.costCacheWrite),
419
+ contextTokens: finite(input.contextTokens),
420
+ turns: finite(input.turns),
421
+ };
422
+ }
423
+
424
+ function utf8Prefix(value: string, maxBytes: number): string {
425
+ const buffer = Buffer.from(value, "utf8");
426
+ if (buffer.length <= maxBytes) return value;
427
+ let end = maxBytes;
428
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
429
+ return buffer.subarray(0, end).toString("utf8");
430
+ }
431
+
432
+ function normalizeResult(value: unknown): PersistedResult | undefined {
433
+ if (!value || typeof value !== "object") return undefined;
434
+ const r = value as Partial<PersistedResult>;
435
+ if (typeof r.label !== "string" || typeof r.task !== "string") return undefined;
436
+ const routing = normalizeTaskRouting((r as { routing?: unknown }).routing);
437
+ return {
438
+ label: r.label,
439
+ task: r.task,
440
+ state: isRunState(r.state) ? r.state : "running",
441
+ exitCode: typeof r.exitCode === "number" || r.exitCode === null ? r.exitCode : null,
442
+ stopReason: typeof r.stopReason === "string" ? r.stopReason : undefined,
443
+ timeoutPhase: isTimeoutPhase(r.timeoutPhase) ? r.timeoutPhase : undefined,
444
+ errorMessage: typeof r.errorMessage === "string" ? utf8Prefix(r.errorMessage, 2_000) : undefined,
445
+ usage: normalizeUsage(r.usage),
446
+ model: typeof r.model === "string" ? r.model : undefined,
447
+ thinking: isThinkingLevel(r.thinking) ? r.thinking : undefined,
448
+ profile: ["explore", "review", "general"].includes(String(r.profile)) ? r.profile : undefined,
449
+ canWrite: typeof r.canWrite === "boolean" ? r.canWrite : undefined,
450
+ outputFile: typeof r.outputFile === "string" ? r.outputFile : undefined,
451
+ outputMode: r.outputMode === "file-only" ? "file-only" : r.outputMode === "inline" ? "inline" : undefined,
452
+ worktree:
453
+ r.worktree &&
454
+ typeof r.worktree.cwd === "string" &&
455
+ typeof r.worktree.branch === "string" &&
456
+ typeof r.worktree.baseCommit === "string"
457
+ ? { ...r.worktree, changed: r.worktree.changed === true }
458
+ : undefined,
459
+ sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
460
+ process: normalizeProcess(r.process),
461
+ ...(routing === undefined ? {} : { routing }),
462
+ finalOutput: typeof r.finalOutput === "string" ? utf8Prefix(r.finalOutput, 16_384) : undefined,
463
+ transcript: typeof r.transcript === "string" ? utf8Prefix(r.transcript, 32_768) : undefined,
464
+ wrappedUp: r.wrappedUp === true ? true : undefined,
465
+ stalledSince: typeof r.stalledSince === "number" && Number.isFinite(r.stalledSince) ? r.stalledSince : undefined,
466
+ attempts: typeof r.attempts === "number" && Number.isInteger(r.attempts) && r.attempts > 1 ? r.attempts : undefined,
467
+ attemptedModels: Array.isArray(r.attemptedModels)
468
+ ? r.attemptedModels.filter((m): m is string => typeof m === "string").slice(0, 10)
469
+ : undefined,
470
+ structuredOutput: r.structuredOutput !== undefined && Buffer.byteLength(JSON.stringify(r.structuredOutput) ?? "", "utf8") <= 32_768
471
+ ? r.structuredOutput
472
+ : undefined,
473
+ structuredError: typeof r.structuredError === "string" ? utf8Prefix(r.structuredError, 1_000) : undefined,
474
+ };
475
+ }
476
+
477
+ function unwrapEvent(entry: { type?: string; customType?: string; data?: unknown }): PersistenceEvent | undefined {
478
+ if (entry.customType !== RUN_ENTRY_TYPE && entry.type !== RUN_ENTRY_TYPE) return undefined;
479
+ const raw = entry.data as Partial<PersistenceEvent> | undefined;
480
+ if (!raw || raw.schemaVersion !== 1 || typeof raw.id !== "string" || typeof raw.sessionKey !== "string") {
481
+ return undefined;
482
+ }
483
+ if (!["start", "checkpoint", "terminal", "delivered", "dismissed"].includes(String(raw.type))) {
484
+ return undefined;
485
+ }
486
+ return {
487
+ schemaVersion: 1,
488
+ id: raw.id,
489
+ sessionKey: raw.sessionKey,
490
+ timestamp: typeof raw.timestamp === "number" ? raw.timestamp : 0,
491
+ sequence: typeof raw.sequence === "number" ? raw.sequence : 0,
492
+ type: raw.type as PersistenceEvent["type"],
493
+ data: raw.data && typeof raw.data === "object" ? raw.data : {},
494
+ };
495
+ }
496
+
497
+ /**
498
+ * Versioned event persistence. Restoration folds every event on the active branch,
499
+ * rather than treating the latest delta as a complete snapshot.
500
+ */
501
+ export class PersistenceLayer {
502
+ private sequence = 0;
503
+
504
+ constructor(
505
+ private readonly adapter: PersistenceAdapter,
506
+ private readonly config: SubagentConfig,
507
+ ) {
508
+ for (const entry of adapter.getEntries()) {
509
+ const event = unwrapEvent(entry);
510
+ if (event) this.sequence = Math.max(this.sequence, event.sequence);
511
+ }
512
+ }
513
+
514
+ persist(
515
+ id: string,
516
+ sessionKey: string,
517
+ type: PersistenceEvent["type"],
518
+ data: PersistenceEventData,
519
+ _terminalHint?: boolean,
520
+ ): void {
521
+ if (!id || !sessionKey) return;
522
+ const event: PersistenceEvent = {
523
+ schemaVersion: 1,
524
+ id,
525
+ sessionKey,
526
+ timestamp: Date.now(),
527
+ sequence: ++this.sequence,
528
+ type,
529
+ data,
530
+ };
531
+ this.adapter.appendEntry(RUN_ENTRY_TYPE, event);
532
+ }
533
+
534
+ /** Fold active-branch events in their session order. */
535
+ rebuild(sessionKey: string): Map<string, RunSnapshot> {
536
+ const snapshots = new Map<string, RunSnapshot>();
537
+
538
+ for (const entry of this.adapter.getEntries()) {
539
+ const event = unwrapEvent(entry);
540
+ if (!event || event.sessionKey !== sessionKey) continue;
541
+
542
+ let snapshot = snapshots.get(event.id);
543
+ if (!snapshot) {
544
+ snapshot = {
545
+ schemaVersion: 1,
546
+ id: event.id,
547
+ sessionKey,
548
+ mode: "single",
549
+ state: "running",
550
+ startedAt: event.timestamp || Date.now(),
551
+ taskPreviews: [],
552
+ delivered: false,
553
+ resumeBlocked: false,
554
+ results: [],
555
+ };
556
+ }
557
+
558
+ const d = event.data;
559
+ if (typeof d.resumeBlocked === "boolean") snapshot.resumeBlocked = d.resumeBlocked;
560
+ if (isRunMode(d.mode)) snapshot.mode = d.mode;
561
+ if (isRunState(d.state)) snapshot.state = d.state;
562
+ if (typeof d.startedAt === "number") snapshot.startedAt = d.startedAt;
563
+ if (typeof d.endedAt === "number") snapshot.endedAt = d.endedAt;
564
+ if (Array.isArray(d.taskPreviews) && d.taskPreviews.every((x) => typeof x === "string")) {
565
+ snapshot.taskPreviews = [...d.taskPreviews];
566
+ }
567
+ if (typeof d.summary === "string") snapshot.summary = d.summary;
568
+ if (typeof d.delivered === "boolean") snapshot.delivered = d.delivered;
569
+ if (Array.isArray(d.results)) {
570
+ snapshot.results = d.results.map(normalizeResult).filter((r): r is PersistedResult => !!r);
571
+ }
572
+
573
+ if (event.type === "checkpoint" && typeof d.childSessionId === "string") {
574
+ const index = typeof d.resultIndex === "number" ? d.resultIndex : 0;
575
+ const result = snapshot.results[index];
576
+ if (result) result.sessionId = d.childSessionId;
577
+ }
578
+ if (event.type === "delivered" || event.type === "dismissed") snapshot.delivered = true;
579
+
580
+ snapshots.set(event.id, snapshot);
581
+ }
582
+
583
+ for (const [id, snapshot] of snapshots) {
584
+ if (snapshot.state === "running" || snapshot.state === "queued") {
585
+ // "lost" means ownership cannot be proven after a parent disruption.
586
+ // Orphan reconcile (ProcessLockManager) must have run before callers
587
+ // consider the session safe to resume; rebuild keeps resumeBlocked set
588
+ // until a later reconciler clears it.
589
+ snapshots.set(id, {
590
+ ...snapshot,
591
+ state: "lost",
592
+ endedAt: Date.now(),
593
+ resumeBlocked: true,
594
+ summary:
595
+ snapshot.summary ||
596
+ "Run ownership was lost (parent interrupted). Resume is blocked until orphan reconciliation confirms the child is dead.",
597
+ });
598
+ }
599
+ }
600
+
601
+ return snapshots;
602
+ }
603
+
604
+ markDelivered(id: string, sessionKey: string): void {
605
+ this.persist(id, sessionKey, "delivered", { delivered: true });
606
+ }
607
+
608
+ /** Fold routing receipts across this session's active branch plus pending upserts. */
609
+ foldRouting(sessionKey: string, pending: readonly unknown[] = []): Map<string, FoldedRoutingReceipt> {
610
+ return foldRoutingReceipts(this.adapter.getEntries(), pending, sessionKey);
611
+ }
612
+
613
+ /** All child session ids referenced by any run event on the active branch. */
614
+ referencedSessionIds(): Set<string> {
615
+ const ids = new Set<string>();
616
+ for (const entry of this.adapter.getEntries()) {
617
+ const event = unwrapEvent(entry);
618
+ if (!event) continue;
619
+ if (typeof event.data.childSessionId === "string") ids.add(event.data.childSessionId);
620
+ if (Array.isArray(event.data.results)) {
621
+ for (const result of event.data.results) {
622
+ const sessionId = (result as Partial<PersistedResult>)?.sessionId;
623
+ if (typeof sessionId === "string" && sessionId) ids.add(sessionId);
624
+ }
625
+ }
626
+ }
627
+ return ids;
628
+ }
629
+
630
+ /**
631
+ * Non-destructive planner. Filesystem scanning/deletion is intentionally
632
+ * outside persistence (see maintenance.ts). "keep" is the union of caller
633
+ * references and every child session referenced on the active branch.
634
+ */
635
+ planRetention(referencedSessionIds: Set<string>): { keep: string[]; candidates: string[] } {
636
+ const keep = new Set([...referencedSessionIds, ...this.referencedSessionIds()]);
637
+ if (!this.config.sessionRetentionDays || this.config.sessionRetentionDays <= 0) {
638
+ return { keep: [...keep], candidates: [] };
639
+ }
640
+ // Candidates are resolved by the filesystem sweep; the planner only fixes the keep set.
641
+ return { keep: [...keep], candidates: [] };
642
+ }
643
+ }