@mono-agent/agent-runtime 0.20.11 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +50 -11
- package/MIGRATION.md +288 -26
- package/README.md +352 -477
- package/package.json +13 -44
- package/src/agent/tool-bloat.js +145 -9
- package/src/agent/tools/agent-tool.js +108 -9
- package/src/agent/tools/bash.js +11 -26
- package/src/agent/tools/codex-subscription-search.js +123 -29
- package/src/agent/tools/exec.js +10 -2
- package/src/agent/tools/index.js +7 -0
- package/src/agent/tools/monitor.js +149 -0
- package/src/agent/tools/pi-bridge.js +123 -19
- package/src/agent/tools/shared/bash-environment.js +31 -0
- package/src/agent/tools/shared/monitors.js +293 -0
- package/src/agent/tools/shared/path-resolver.js +25 -6
- package/src/agent/tools/shared/process-jobs.js +6 -1
- package/src/agent/tools/shared/process-runner.js +26 -6
- package/src/agent/tools/shared/tool-context.js +8 -0
- package/src/agent/tools/web-access-interstitial.js +70 -0
- package/src/agent/tools/web-browser-render.js +83 -58
- package/src/agent/tools/web-controller.js +112 -21
- package/src/agent/tools/web-document-extractor.js +379 -0
- package/src/agent/tools/web-fetch.js +271 -243
- package/src/agent/tools/web-request.js +65 -0
- package/src/agent/tools/web-search-output.js +165 -0
- package/src/agent/tools/web-search-state.js +75 -0
- package/src/agent/tools/web-search.js +532 -71
- package/src/ai/cost.js +13 -68
- package/src/ai/failure.js +3 -3
- package/src/ai/index.js +5 -17
- package/src/ai/observer.js +8 -0
- package/src/ai/pi-interop.js +221 -1
- package/src/ai/pi-oauth-compat.js +1 -1
- package/src/ai/provider-check.js +131 -0
- package/src/ai/providers/codex/app-server-client.js +592 -0
- package/src/ai/providers/pi-models.js +18 -10
- package/src/ai/providers/pi-native/compaction-driver.js +94 -42
- package/src/ai/providers/pi-native/compaction-summary.js +140 -0
- package/src/ai/providers/pi-native/harness-adapter.js +376 -0
- package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
- package/src/ai/providers/pi-native/provider-attribution.js +102 -0
- package/src/ai/providers/pi-native/result-builder.js +38 -14
- package/src/ai/providers/pi-native/session-lifecycle.js +253 -55
- package/src/ai/providers/pi-native/stream-subscriber.js +52 -6
- package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
- package/src/ai/providers/pi-native/turn-runner.js +279 -28
- package/src/ai/providers/pi-native.js +206 -61
- package/src/ai/runtime/capabilities.js +11 -56
- package/src/ai/runtime/live-input-events.js +250 -54
- package/src/ai/runtime/model-refs.js +118 -153
- package/src/ai/runtime/registry.js +22 -56
- package/src/ai/runtime/router.js +76 -417
- package/src/ai/runtime/session-liveness.js +3 -4
- package/src/ai/runtime/sessions.js +4 -5
- package/src/ai/runtime/tool-policy.js +0 -2
- package/src/ai/tool-lifecycle.js +32 -18
- package/src/ai/types.js +37 -112
- package/src/index.js +0 -6
- package/src/runtime.js +29 -16
- package/types/agent/tool-bloat.d.ts +1 -1
- package/types/agent/tools/agent-tool.d.ts +4 -2
- package/types/agent/tools/bash.d.ts +5 -3
- package/types/agent/tools/codex-subscription-search.d.ts +7 -3
- package/types/agent/tools/exec.d.ts +5 -3
- package/types/agent/tools/index.d.ts +1 -0
- package/types/agent/tools/monitor.d.ts +47 -0
- package/types/agent/tools/pi-bridge.d.ts +7 -4
- package/types/agent/tools/shared/bash-environment.d.ts +4 -0
- package/types/agent/tools/shared/monitors.d.ts +98 -0
- package/types/agent/tools/shared/process-jobs.d.ts +5 -1
- package/types/agent/tools/shared/process-runner.d.ts +14 -4
- package/types/agent/tools/shared/tool-context.d.ts +2 -0
- package/types/agent/tools/web-access-interstitial.d.ts +23 -0
- package/types/agent/tools/web-browser-render.d.ts +4 -1
- package/types/agent/tools/web-controller.d.ts +4 -2
- package/types/agent/tools/web-document-extractor.d.ts +27 -0
- package/types/agent/tools/web-fetch.d.ts +19 -24
- package/types/agent/tools/web-request.d.ts +20 -0
- package/types/agent/tools/web-search-output.d.ts +31 -0
- package/types/agent/tools/web-search-state.d.ts +21 -0
- package/types/agent/tools/web-search.d.ts +10 -45
- package/types/ai/cost.d.ts +1 -2
- package/types/ai/index.d.ts +2 -4
- package/types/ai/observer.d.ts +6 -0
- package/types/ai/pi-interop.d.ts +81 -0
- package/types/ai/provider-check.d.ts +53 -0
- package/types/ai/providers/codex/app-server-client.d.ts +37 -0
- package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
- package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
- package/types/ai/providers/pi-native/harness-adapter.d.ts +58 -0
- package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
- package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
- package/types/ai/providers/pi-native/result-builder.d.ts +14 -4
- package/types/ai/providers/pi-native/session-lifecycle.d.ts +25 -6
- package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -2
- package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +68 -10
- package/types/ai/providers/pi-native.d.ts +21 -4
- package/types/ai/runtime/capabilities.d.ts +21 -70
- package/types/ai/runtime/live-input-events.d.ts +32 -8
- package/types/ai/runtime/model-refs.d.ts +0 -24
- package/types/ai/runtime/router.d.ts +3 -10
- package/types/ai/runtime/tool-policy.d.ts +0 -2
- package/types/ai/tool-lifecycle.d.ts +4 -3
- package/types/ai/types.d.ts +162 -256
- package/types/index.d.ts +0 -1
- package/src/ai/providers/acp-client.js +0 -1149
- package/src/ai/providers/acp-privacy.js +0 -124
- package/src/ai/providers/acp-public.js +0 -21
- package/src/ai/providers/acp-session-tokens.js +0 -282
- package/src/ai/providers/acp-transport.js +0 -356
- package/src/ai/providers/acp.js +0 -543
- package/src/ai/providers/claude-cli.js +0 -883
- package/src/ai/providers/claude-sandbox.js +0 -71
- package/src/ai/providers/claude-sdk-discovery-worker.js +0 -53
- package/src/ai/providers/claude-sdk-discovery.js +0 -352
- package/src/ai/providers/claude-sdk.js +0 -1127
- package/src/ai/providers/claude-subagent-activity.js +0 -719
- package/src/ai/providers/claude-subagents.js +0 -88
- package/src/ai/providers/codex-app.js +0 -2946
- package/src/ai/providers/opencode-app.js +0 -1109
- package/src/ai/providers/opencode-discovery.js +0 -39
- package/src/ai/providers/opencode-server.js +0 -508
- package/src/ai/runtime/context-windows.js +0 -46
- package/src/ai/runtime/fast-mode.js +0 -8
- package/src/ai/streaming/codex-events.js +0 -146
- package/src/ai/streaming/opencode-events.js +0 -59
- package/types/ai/providers/acp-client.d.ts +0 -227
- package/types/ai/providers/acp-privacy.d.ts +0 -25
- package/types/ai/providers/acp-public.d.ts +0 -7
- package/types/ai/providers/acp-session-tokens.d.ts +0 -41
- package/types/ai/providers/acp-transport.d.ts +0 -45
- package/types/ai/providers/acp.d.ts +0 -93
- package/types/ai/providers/claude-cli.d.ts +0 -305
- package/types/ai/providers/claude-sandbox.d.ts +0 -79
- package/types/ai/providers/claude-sdk-discovery-worker.d.ts +0 -1
- package/types/ai/providers/claude-sdk-discovery.d.ts +0 -97
- package/types/ai/providers/claude-sdk.d.ts +0 -138
- package/types/ai/providers/claude-subagent-activity.d.ts +0 -53
- package/types/ai/providers/claude-subagents.d.ts +0 -18
- package/types/ai/providers/codex-app.d.ts +0 -151
- package/types/ai/providers/opencode-app.d.ts +0 -96
- package/types/ai/providers/opencode-discovery.d.ts +0 -4
- package/types/ai/providers/opencode-server.d.ts +0 -20
- package/types/ai/runtime/context-windows.d.ts +0 -9
- package/types/ai/runtime/fast-mode.d.ts +0 -2
- package/types/ai/streaming/codex-events.d.ts +0 -40
- package/types/ai/streaming/opencode-events.d.ts +0 -42
|
@@ -32,6 +32,24 @@ export function usageFromMessages(messages = []) {
|
|
|
32
32
|
return usage;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Pi initializes failed assistant messages with an all-zero usage object before
|
|
37
|
+
* any provider telemetry arrives. A successful response makes those zeros
|
|
38
|
+
* measured; a failed response is measured only when real usage is non-zero.
|
|
39
|
+
* @param {Array<any>} [messages]
|
|
40
|
+
*/
|
|
41
|
+
export function hasMeasuredUsage(messages = []) {
|
|
42
|
+
return messages.some((message) => {
|
|
43
|
+
if (message?.role !== "assistant" || !message.usage) return false;
|
|
44
|
+
if (message.stopReason !== "error" && message.stopReason !== "aborted") return true;
|
|
45
|
+
const usage = message.usage;
|
|
46
|
+
return [usage.input, usage.output, usage.cacheRead, usage.cacheWrite, usage.totalTokens,
|
|
47
|
+
usage.cost?.input, usage.cost?.output, usage.cost?.cacheRead,
|
|
48
|
+
usage.cost?.cacheWrite, usage.cost?.total]
|
|
49
|
+
.some((value) => Number.isFinite(Number(value)) && Number(value) > 0);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
/**
|
|
36
54
|
* Add what this run's subagents spent to the run's own usage.
|
|
37
55
|
*
|
|
@@ -70,7 +88,7 @@ export function withSubagentUsage(usage, delegated, estimatedCost) {
|
|
|
70
88
|
* requests in the run: the last assistant usage is the same provider-counted
|
|
71
89
|
* value Pi's compaction logic trusts, so it can decrease after compaction.
|
|
72
90
|
* @param {any} assistantMessage
|
|
73
|
-
* @returns {{input: number, output: number, cacheRead: number, cacheCreation: number, total: number}|null}
|
|
91
|
+
* @returns {{input: number, output: number, cacheRead: number, cacheCreation: number, total: number, costUsd: number}|null}
|
|
74
92
|
*/
|
|
75
93
|
export function contextUsageFromAssistantMessage(assistantMessage) {
|
|
76
94
|
if (assistantMessage?.role !== "assistant" || !assistantMessage.usage) return null;
|
|
@@ -83,6 +101,7 @@ export function contextUsageFromAssistantMessage(assistantMessage) {
|
|
|
83
101
|
cacheRead: Number(assistantMessage.usage.cacheRead) || 0,
|
|
84
102
|
cacheCreation: Number(assistantMessage.usage.cacheWrite) || 0,
|
|
85
103
|
total,
|
|
104
|
+
costUsd: Number(assistantMessage.usage.cost?.total) || 0,
|
|
86
105
|
};
|
|
87
106
|
}
|
|
88
107
|
|
|
@@ -118,14 +137,14 @@ export function emitUsageCostEvents({
|
|
|
118
137
|
externalAbort,
|
|
119
138
|
}) {
|
|
120
139
|
if (usage.cacheRead > 0) {
|
|
121
|
-
onEvent({ type: "cache_hit", sdk:
|
|
140
|
+
onEvent({ type: "cache_hit", sdk: "pi", model: reference, tokens: usage.cacheRead, source: "prompt_cache" });
|
|
122
141
|
}
|
|
123
142
|
if (usage.cacheWrite > 0) {
|
|
124
|
-
onEvent({ type: "cache_miss", sdk:
|
|
143
|
+
onEvent({ type: "cache_miss", sdk: "pi", model: reference, tokens: usage.cacheWrite, source: "prompt_cache" });
|
|
125
144
|
}
|
|
126
145
|
onEvent({
|
|
127
146
|
type: "cost_accumulated",
|
|
128
|
-
sdk:
|
|
147
|
+
sdk: "pi",
|
|
129
148
|
model: reference,
|
|
130
149
|
cumulativeUsd: Number(usage.cost) || Number(estimatedCost) || 0,
|
|
131
150
|
tokens: {
|
|
@@ -137,7 +156,7 @@ export function emitUsageCostEvents({
|
|
|
137
156
|
});
|
|
138
157
|
onEvent({
|
|
139
158
|
type: "provider_request_completed",
|
|
140
|
-
sdk:
|
|
159
|
+
sdk: "pi",
|
|
141
160
|
model: reference,
|
|
142
161
|
runtime: "pi",
|
|
143
162
|
timestamp: Date.now(),
|
|
@@ -167,9 +186,9 @@ export function abortedResult({ resolved, options, events, runtimeWarnings, star
|
|
|
167
186
|
usage: {},
|
|
168
187
|
durationMs: Date.now() - start,
|
|
169
188
|
numTurns: 0,
|
|
170
|
-
model: resolved?.reference || resolved?.model
|
|
189
|
+
model: resolved?.reference || (resolved?.provider && resolved?.model ? `${resolved.provider}:${resolved.model}` : null),
|
|
171
190
|
effort: options.effort || null,
|
|
172
|
-
sdk:
|
|
191
|
+
sdk: "pi",
|
|
173
192
|
cancelled: true,
|
|
174
193
|
error: null,
|
|
175
194
|
failureKind: null,
|
|
@@ -211,6 +230,8 @@ export function buildSuccessResult(params) {
|
|
|
211
230
|
runtimeWarnings,
|
|
212
231
|
capabilitiesUsed,
|
|
213
232
|
structuredResult,
|
|
233
|
+
usageMeasured = true,
|
|
234
|
+
effectiveEffort,
|
|
214
235
|
} = params;
|
|
215
236
|
return {
|
|
216
237
|
text: finalText,
|
|
@@ -219,16 +240,17 @@ export function buildSuccessResult(params) {
|
|
|
219
240
|
usage: {
|
|
220
241
|
input_tokens: usage.input || null,
|
|
221
242
|
output_tokens: usage.output || null,
|
|
222
|
-
cache_read_tokens: usage.cacheRead
|
|
223
|
-
cache_creation_tokens: usage.cacheWrite
|
|
224
|
-
cache_write_tokens: usage.cacheWrite
|
|
243
|
+
cache_read_tokens: usageMeasured ? usage.cacheRead : null,
|
|
244
|
+
cache_creation_tokens: usageMeasured ? usage.cacheWrite : null,
|
|
245
|
+
cache_write_tokens: usageMeasured ? usage.cacheWrite : null,
|
|
225
246
|
cost_usd: usage.cost || estimatedCost,
|
|
226
247
|
},
|
|
227
248
|
durationMs: Date.now() - start,
|
|
228
249
|
numTurns: turnCount || runAssistantCount,
|
|
229
|
-
model: resolved.reference ||
|
|
250
|
+
model: resolved.reference || `${resolved.provider}:${resolved.model}`,
|
|
230
251
|
effort: options.effort || null,
|
|
231
|
-
|
|
252
|
+
effectiveEffort: effectiveEffort || null,
|
|
253
|
+
sdk: "pi",
|
|
232
254
|
cancelled: externalAbort,
|
|
233
255
|
error: errorMessage,
|
|
234
256
|
errorDetails,
|
|
@@ -266,6 +288,7 @@ export function buildErrorResult(params) {
|
|
|
266
288
|
runtimeWarnings,
|
|
267
289
|
isRetryable,
|
|
268
290
|
piTransport,
|
|
291
|
+
effectiveEffort,
|
|
269
292
|
} = params;
|
|
270
293
|
return {
|
|
271
294
|
text: assistantTexts.join("") || null,
|
|
@@ -273,9 +296,10 @@ export function buildErrorResult(params) {
|
|
|
273
296
|
usage: {},
|
|
274
297
|
durationMs: Date.now() - start,
|
|
275
298
|
numTurns: turnCount,
|
|
276
|
-
model: resolved?.reference || resolved?.model
|
|
299
|
+
model: resolved?.reference || (resolved?.provider && resolved?.model ? `${resolved.provider}:${resolved.model}` : null),
|
|
277
300
|
effort: options.effort || null,
|
|
278
|
-
|
|
301
|
+
effectiveEffort: effectiveEffort || null,
|
|
302
|
+
sdk: "pi",
|
|
279
303
|
cancelled: externalAbort,
|
|
280
304
|
error: externalAbort ? null : errorMessage,
|
|
281
305
|
errorDetails: externalAbort ? null : {
|
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
// createSessionLiveness primitives so the await-free spans are enforced by
|
|
11
11
|
// construction rather than by inline sequencing.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { JsonlSessionRepo, MemorySessionRepo, laneConfig, laneState, operationMeta, operationResult, operationState } from "@earendil-works/pi-agent-core";
|
|
14
|
+
import { validRecoveryProjection } from "./terminal-recovery.js";
|
|
14
15
|
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { access, open, readdir, unlink } from "node:fs/promises";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
19
|
import { createSessionRegistry } from "../../runtime/sessions.js";
|
|
18
20
|
import { createSessionLiveness } from "../../runtime/session-liveness.js";
|
|
21
|
+
import { buildPiSessionContext, createPiSessionAdapter, PI_CONTEXT } from "./harness-adapter.js";
|
|
19
22
|
|
|
20
23
|
async function syncPath(path) {
|
|
21
24
|
const handle = await open(path, "r");
|
|
@@ -27,6 +30,7 @@ async function syncPath(path) {
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
async function syncDurableTranscript(entry) {
|
|
33
|
+
if (entry.recoveryPending) throw new Error("Pi terminal recovery is pending");
|
|
30
34
|
if (!entry.durable) return;
|
|
31
35
|
const path = entry.metadata?.path;
|
|
32
36
|
if (typeof path !== "string" || !path) {
|
|
@@ -40,31 +44,61 @@ async function syncDurableTranscript(entry) {
|
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
async function invalidateNativeSession(entry) {
|
|
43
|
-
await entry.repo.delete(entry.metadata);
|
|
44
47
|
if (entry.durable) {
|
|
45
48
|
const path = entry.metadata?.path;
|
|
46
49
|
if (typeof path !== "string" || !path) {
|
|
47
50
|
throw new Error("Durable Pi session metadata is missing its JSONL path");
|
|
48
51
|
}
|
|
52
|
+
// Explicit invalidation is idempotent. A preceding failed sync may have
|
|
53
|
+
// observed that the transcript was already removed outside this process.
|
|
54
|
+
try {
|
|
55
|
+
await access(path);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error?.code === "ENOENT") return;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await entry.repo.delete(entry.metadata, PI_CONTEXT);
|
|
62
|
+
if (entry.durable) {
|
|
63
|
+
const path = entry.metadata.path;
|
|
49
64
|
// Make the unlink durable before the registry forgets the busy marker.
|
|
50
65
|
await syncPath(dirname(path));
|
|
51
66
|
}
|
|
52
67
|
}
|
|
53
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Release a Pi session handle and remove its repository record as independent
|
|
71
|
+
* best-effort operations. A close failure must not prevent deletion of a fresh
|
|
72
|
+
* or otherwise poisoned transcript.
|
|
73
|
+
* @param {any} session
|
|
74
|
+
* @param {any} repo
|
|
75
|
+
* @param {any} [knownMetadata]
|
|
76
|
+
*/
|
|
77
|
+
async function closeAndDeleteSession(session, repo, knownMetadata) {
|
|
78
|
+
let metadata = knownMetadata;
|
|
79
|
+
if (!metadata) {
|
|
80
|
+
try { metadata = await session.getMetadata(); } catch { /* best-effort */ }
|
|
81
|
+
}
|
|
82
|
+
try { await session.close(); } catch { /* best-effort */ }
|
|
83
|
+
if (metadata) {
|
|
84
|
+
try { await repo.delete(metadata, PI_CONTEXT); } catch { /* best-effort */ }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
54
88
|
// Live pi-native sessions, keyed by provider session id. Entries are
|
|
55
|
-
// {
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
89
|
+
// { metadata, repo, durable, busy }. Pi 0.85 sessions are closed after every
|
|
90
|
+
// turn so their repository record can be reopened safely; the registry owns
|
|
91
|
+
// only liveness and metadata. In-memory transcripts are freed when the registry
|
|
92
|
+
// evicts them; durable (jsonl) transcripts survive eviction. Registering here gives
|
|
59
93
|
// runtime.disposeSession / disposeProviderSession + idle-TTL eviction the same
|
|
60
94
|
// reach over native pi sessions that the legacy bridge had.
|
|
61
|
-
const nativeSessionRepo = new
|
|
95
|
+
const nativeSessionRepo = new MemorySessionRepo();
|
|
62
96
|
const nativeSessions = createSessionRegistry({
|
|
63
|
-
isBusy: (entry) => entry.busy === true,
|
|
97
|
+
isBusy: (entry) => entry.busy === true || entry.recoveryPending === true,
|
|
64
98
|
onSync: syncDurableTranscript,
|
|
65
99
|
onEvict: async (entry, reason) => {
|
|
66
|
-
// Ordinary disposal/TTL only drops
|
|
67
|
-
// reopen
|
|
100
|
+
// Ordinary disposal/TTL only drops registry metadata so durable sessions
|
|
101
|
+
// can reopen later. Explicit invalidation means the host rejected the
|
|
68
102
|
// turn before canonical history commit; that poisoned transcript must be
|
|
69
103
|
// deleted too or it could silently reappear on the next stable-id resume.
|
|
70
104
|
if (reason === "invalidated") {
|
|
@@ -75,7 +109,7 @@ const nativeSessions = createSessionRegistry({
|
|
|
75
109
|
return;
|
|
76
110
|
}
|
|
77
111
|
if (entry.durable) return;
|
|
78
|
-
await entry.repo.delete(entry.metadata);
|
|
112
|
+
await entry.repo.delete(entry.metadata, PI_CONTEXT);
|
|
79
113
|
},
|
|
80
114
|
});
|
|
81
115
|
const liveness = createSessionLiveness(nativeSessions);
|
|
@@ -88,7 +122,7 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
|
|
|
88
122
|
let repo = durableNativeSessionRepos.get(root);
|
|
89
123
|
if (!repo) {
|
|
90
124
|
repo = new JsonlSessionRepo({
|
|
91
|
-
|
|
125
|
+
fileSystem: new NodeExecutionEnv({ cwd: process.cwd() }),
|
|
92
126
|
sessionsRoot: root,
|
|
93
127
|
});
|
|
94
128
|
durableNativeSessionRepos.set(root, repo);
|
|
@@ -97,11 +131,13 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
|
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
/**
|
|
100
|
-
*
|
|
101
|
-
* This is intentionally stronger than live-session invalidation:
|
|
102
|
-
* rotation and retention can retire an epoch after its registry entry
|
|
103
|
-
* already evicted or after a process restart.
|
|
104
|
-
*
|
|
134
|
+
* Retire every currently materialized durable Pi transcript with this exact
|
|
135
|
+
* logical id. This is intentionally stronger than live-session invalidation:
|
|
136
|
+
* history rotation and retention can retire an epoch after its registry entry
|
|
137
|
+
* was already evicted or after a process restart. When an active old run later
|
|
138
|
+
* recreates its pathname, its post-runtime caller retries this operation. The
|
|
139
|
+
* canonical epoch has already rotated, so that old id is never resumable in the
|
|
140
|
+
* interim. Absence is success; cleanup or verification uncertainty rejects.
|
|
105
141
|
*/
|
|
106
142
|
export async function retireDurableNativeSession(providerSessionId, piSessionsRoot) {
|
|
107
143
|
if (!isSafeSessionId(providerSessionId)) {
|
|
@@ -111,29 +147,87 @@ export async function retireDurableNativeSession(providerSessionId, piSessionsRo
|
|
|
111
147
|
throw new TypeError("piSessionsRoot must be a non-empty path");
|
|
112
148
|
}
|
|
113
149
|
|
|
114
|
-
// First guarantee this process cannot
|
|
150
|
+
// First guarantee this process cannot resume through a stale registry entry.
|
|
151
|
+
// A host cancellation can rotate canonical history while the provider is
|
|
152
|
+
// still unwinding an open Pi session. The Pi repo deliberately refuses to
|
|
153
|
+
// delete an open session, so detach the registry and unlink its current file.
|
|
154
|
+
// Pi appends by pathname and can recreate a headerless orphan if the old run
|
|
155
|
+
// writes later; the raw exact-name sweep below removes those files on the
|
|
156
|
+
// post-runtime retry. The rotated canonical epoch never refers to this id.
|
|
157
|
+
const liveEntry = nativeSessions.get(providerSessionId);
|
|
158
|
+
const providerStillUnwinding = liveEntry?.busy === true;
|
|
115
159
|
// Other processes are serialized by the history coordinator and must pass
|
|
116
160
|
// the same cold-refresh barrier before their next turn.
|
|
117
161
|
await nativeSessions.refresh(providerSessionId);
|
|
118
162
|
|
|
119
163
|
const repo = resolveDurableNativeSessionRepo(piSessionsRoot);
|
|
120
164
|
if (!repo) throw new Error("Durable Pi session repository is unavailable");
|
|
121
|
-
const matches = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
|
|
165
|
+
const matches = (await repo.list(undefined, PI_CONTEXT)).filter((entry) => entry?.id === providerSessionId);
|
|
122
166
|
const changedDirectories = new Set();
|
|
123
167
|
for (const metadata of matches) {
|
|
124
168
|
if (typeof metadata?.path !== "string" || !metadata.path) {
|
|
125
169
|
throw new Error(`Durable Pi session ${providerSessionId} has invalid metadata`);
|
|
126
170
|
}
|
|
127
|
-
|
|
171
|
+
if (providerStillUnwinding) {
|
|
172
|
+
try {
|
|
173
|
+
await unlink(metadata.path);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error?.code !== "ENOENT") throw error;
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
await repo.delete(metadata, PI_CONTEXT);
|
|
179
|
+
}
|
|
128
180
|
changedDirectories.add(dirname(metadata.path));
|
|
129
181
|
}
|
|
182
|
+
// A prior active-session unlink can be followed by Pi recreating the same
|
|
183
|
+
// pathname with a transaction line but no header. JsonlSessionRepo.list()
|
|
184
|
+
// intentionally ignores that invalid file, so scan only Pi's fixed
|
|
185
|
+
// root/directory/filename layout to make the later retirement retry reclaim
|
|
186
|
+
// it. Safe ids are single filename components and symlink directories/files
|
|
187
|
+
// are ignored.
|
|
188
|
+
for (const path of await exactDurableSessionFiles(piSessionsRoot, providerSessionId)) {
|
|
189
|
+
try {
|
|
190
|
+
await unlink(path);
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error?.code !== "ENOENT") throw error;
|
|
193
|
+
}
|
|
194
|
+
changedDirectories.add(dirname(path));
|
|
195
|
+
}
|
|
130
196
|
for (const directory of changedDirectories) await syncPath(directory);
|
|
131
197
|
if (changedDirectories.size > 0) await syncPath(resolve(piSessionsRoot));
|
|
132
198
|
|
|
133
|
-
const remaining = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
|
|
134
|
-
|
|
199
|
+
const remaining = (await repo.list(undefined, PI_CONTEXT)).filter((entry) => entry?.id === providerSessionId);
|
|
200
|
+
const remainingPaths = await exactDurableSessionFiles(piSessionsRoot, providerSessionId);
|
|
201
|
+
if (!providerStillUnwinding && (remaining.length > 0 || remainingPaths.length > 0)) {
|
|
135
202
|
throw new Error(`Durable Pi session ${providerSessionId} could not be retired completely`);
|
|
136
203
|
}
|
|
204
|
+
// An active Pi write can race the final exact-name check after the sweep.
|
|
205
|
+
// Canonical history is already preparing a fresh epoch, so the retired id is
|
|
206
|
+
// unreachable; the harness retries retirement when that old run returns.
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function exactDurableSessionFiles(piSessionsRoot, providerSessionId) {
|
|
210
|
+
const root = resolve(piSessionsRoot);
|
|
211
|
+
const suffix = `_${encodeURIComponent(providerSessionId)}.jsonl`;
|
|
212
|
+
let rootEntries;
|
|
213
|
+
try {
|
|
214
|
+
rootEntries = await readdir(root, { withFileTypes: true });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error?.code === "ENOENT") return [];
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
const paths = [];
|
|
220
|
+
for (const directory of rootEntries) {
|
|
221
|
+
if (!directory.isDirectory()) continue;
|
|
222
|
+
const directoryPath = join(root, directory.name);
|
|
223
|
+
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (entry.isFile() && entry.name.endsWith(suffix)) {
|
|
226
|
+
paths.push(join(directoryPath, entry.name));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return paths;
|
|
137
231
|
}
|
|
138
232
|
|
|
139
233
|
// Defense in depth (R4): create-on-miss passes the caller-controlled session id
|
|
@@ -158,10 +252,9 @@ function isSafeSessionId(id) {
|
|
|
158
252
|
|
|
159
253
|
async function reopenDurableNativeSession(repo, sessionId) {
|
|
160
254
|
try {
|
|
161
|
-
const metadata = (await repo.list()).find((entry) => entry?.id === sessionId);
|
|
255
|
+
const metadata = (await repo.list(undefined, PI_CONTEXT)).find((entry) => entry?.id === sessionId);
|
|
162
256
|
if (!metadata) return null;
|
|
163
|
-
|
|
164
|
-
return { session, metadata, repo, durable: true, busy: false };
|
|
257
|
+
return { metadata, repo, durable: true, busy: false };
|
|
165
258
|
} catch {
|
|
166
259
|
return null;
|
|
167
260
|
}
|
|
@@ -185,9 +278,9 @@ function sessionUnavailableResult({
|
|
|
185
278
|
usage: {},
|
|
186
279
|
durationMs: Date.now() - start,
|
|
187
280
|
numTurns: 0,
|
|
188
|
-
model: resolved?.reference || resolved?.model
|
|
281
|
+
model: resolved?.reference || (resolved?.provider && resolved?.model ? `${resolved.provider}:${resolved.model}` : null),
|
|
189
282
|
effort: options.effort || null,
|
|
190
|
-
sdk:
|
|
283
|
+
sdk: "pi",
|
|
191
284
|
cancelled: false,
|
|
192
285
|
error: errorMessage,
|
|
193
286
|
failureKind,
|
|
@@ -290,11 +383,14 @@ export async function resolveSession(runState, {
|
|
|
290
383
|
} else {
|
|
291
384
|
// Reserved the id with a busy placeholder BEFORE the create await so a
|
|
292
385
|
// second concurrent first turn observes busy and returns session_busy.
|
|
293
|
-
// The keep-alive success path
|
|
294
|
-
//
|
|
295
|
-
// release it. Keyed by requestedSessionId === providerSessionId.
|
|
386
|
+
// The keep-alive success path overwrites this placeholder with an
|
|
387
|
+
// entry that remains busy until its harness closes; drop/abort/catch
|
|
388
|
+
// paths release it. Keyed by requestedSessionId === providerSessionId.
|
|
296
389
|
runState.reservation = reservation;
|
|
297
|
-
runState.session = await durableRepo.create(
|
|
390
|
+
runState.session = createPiSessionAdapter(await durableRepo.create(
|
|
391
|
+
{ id: providerSessionId, cwd: cwd || process.cwd() },
|
|
392
|
+
PI_CONTEXT,
|
|
393
|
+
));
|
|
298
394
|
runState.createdOnMiss = true;
|
|
299
395
|
}
|
|
300
396
|
} else {
|
|
@@ -322,7 +418,7 @@ export async function resolveSession(runState, {
|
|
|
322
418
|
// (F4) safe. Do not introduce any await in this span or the TOCTOU window
|
|
323
419
|
// reopens. `claim` re-reads the same registry entry and sets busy in one
|
|
324
420
|
// await-free step; a busy entry loses and returns session_busy.
|
|
325
|
-
const claimed = liveness.claim(requestedSessionId);
|
|
421
|
+
const claimed = entry.recoveryPending ? { ok: /** @type {const} */ (false), reason: "busy" } : liveness.claim(requestedSessionId);
|
|
326
422
|
if (!claimed.ok) {
|
|
327
423
|
// claim() can lose two ways: "busy" (the entry adopted above is
|
|
328
424
|
// mid-turn) or "missing" (no live entry). "missing" is UNREACHABLE on
|
|
@@ -353,14 +449,30 @@ export async function resolveSession(runState, {
|
|
|
353
449
|
};
|
|
354
450
|
}
|
|
355
451
|
runState.sessionEntry = claimed.entry;
|
|
356
|
-
|
|
452
|
+
delete claimed.entry.recovery;
|
|
453
|
+
try {
|
|
454
|
+
runState.session = createPiSessionAdapter(await claimed.entry.repo.open(
|
|
455
|
+
claimed.entry.metadata,
|
|
456
|
+
PI_CONTEXT,
|
|
457
|
+
));
|
|
458
|
+
} catch (error) {
|
|
459
|
+
// The claim made this registry entry busy. An open failure means the
|
|
460
|
+
// entry cannot be driven, so remove its liveness record before
|
|
461
|
+
// propagating; otherwise every later resume retries the same broken
|
|
462
|
+
// entry forever. Preserve the durable transcript for a later cold
|
|
463
|
+
// reopen/recovery attempt.
|
|
464
|
+
liveness.release(requestedSessionId);
|
|
465
|
+
runState.sessionEntry = null;
|
|
466
|
+
throw error;
|
|
467
|
+
}
|
|
357
468
|
}
|
|
358
469
|
} else {
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
runState.
|
|
363
|
-
|
|
470
|
+
// Attribution may equal a live primary's id on a stateless retry/backup.
|
|
471
|
+
// A private ephemeral repo prevents both create collisions and cleanup of
|
|
472
|
+
// that primary's transcript. Only keep-alive calls use a shared repository.
|
|
473
|
+
if (options.sessionKeepAlive !== true) runState.ephemeralSessionRepo = new MemorySessionRepo();
|
|
474
|
+
runState.session = createPiSessionAdapter(await (runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo)
|
|
475
|
+
.create({ id: providerSessionId, cwd: cwd || process.cwd() }, PI_CONTEXT));
|
|
364
476
|
}
|
|
365
477
|
return { done: false };
|
|
366
478
|
}
|
|
@@ -379,7 +491,10 @@ export async function discardUncommittedSession(runState, { durableRepo }) {
|
|
|
379
491
|
// transcript was appended yet (prompt never ran), so the live session is
|
|
380
492
|
// already at its pre-turn leaf and needs no rollback.
|
|
381
493
|
if (runState.session && !runState.sessionEntry) {
|
|
382
|
-
|
|
494
|
+
await closeAndDeleteSession(
|
|
495
|
+
runState.session,
|
|
496
|
+
runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo,
|
|
497
|
+
);
|
|
383
498
|
}
|
|
384
499
|
// Drop the create-on-miss BUSY reservation too, else the busy placeholder
|
|
385
500
|
// leaks and every future resume of this conversation's stable id returns
|
|
@@ -407,7 +522,7 @@ export async function commitSession(runState, {
|
|
|
407
522
|
onEvent,
|
|
408
523
|
}) {
|
|
409
524
|
const { session, sessionEntry, baselineLeafId, reservation } = runState;
|
|
410
|
-
if (options.sessionKeepAlive === true && !externalAbort && !errorMessage) {
|
|
525
|
+
if (options.sessionKeepAlive === true && ((!externalAbort && !errorMessage) || runState.retainRecoveryTail)) {
|
|
411
526
|
try {
|
|
412
527
|
if (sessionEntry) {
|
|
413
528
|
// Resumed run: the harness appended this run's turns onto the live
|
|
@@ -419,17 +534,19 @@ export async function commitSession(runState, {
|
|
|
419
534
|
} else {
|
|
420
535
|
const metadata = await session.getMetadata();
|
|
421
536
|
const entry = {
|
|
422
|
-
session,
|
|
423
537
|
metadata,
|
|
424
538
|
repo: durableRepo || nativeSessionRepo,
|
|
425
539
|
durable: !!durableRepo,
|
|
426
|
-
busy:
|
|
540
|
+
busy: true,
|
|
427
541
|
};
|
|
428
542
|
// A create-on-miss reservation is overwritten by its commit (same id);
|
|
429
543
|
// a plain fresh keep-alive run registers directly.
|
|
430
544
|
if (reservation) reservation.commit(entry);
|
|
431
545
|
else nativeSessions.set(providerSessionId, entry, { idleTimeoutMs: sessionTtlMs });
|
|
546
|
+
runState.registeredSessionEntry = entry;
|
|
432
547
|
}
|
|
548
|
+
const retained = runState.sessionEntry || runState.registeredSessionEntry;
|
|
549
|
+
if (runState.retainRecoveryTail && retained) retained.recoveryPending = !!externalAbort || !!errorMessage;
|
|
433
550
|
} catch (err) {
|
|
434
551
|
// Session persistence must never fail the run; drop the (now
|
|
435
552
|
// inconsistent) session instead of resuming from a broken transcript.
|
|
@@ -442,7 +559,7 @@ export async function commitSession(runState, {
|
|
|
442
559
|
if (requestedSessionId) nativeSessions.delete(requestedSessionId);
|
|
443
560
|
const broken = sessionEntry;
|
|
444
561
|
if (broken) {
|
|
445
|
-
|
|
562
|
+
await closeAndDeleteSession(session, broken.repo, broken.metadata);
|
|
446
563
|
}
|
|
447
564
|
}
|
|
448
565
|
} else if (sessionEntry) {
|
|
@@ -463,9 +580,7 @@ export async function commitSession(runState, {
|
|
|
463
580
|
// (the success keep-alive path overwrites it with the finalized entry, so
|
|
464
581
|
// it is only this drop branch that must clean it up).
|
|
465
582
|
if (reservation) reservation.release();
|
|
466
|
-
|
|
467
|
-
await (durableRepo || nativeSessionRepo).delete(await session.getMetadata());
|
|
468
|
-
} catch { /* best-effort */ }
|
|
583
|
+
await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
|
|
469
584
|
}
|
|
470
585
|
}
|
|
471
586
|
|
|
@@ -486,8 +601,9 @@ export async function rollbackAbortedTurn(runState, { requestedSessionId, provid
|
|
|
486
601
|
}
|
|
487
602
|
nativeSessions.delete(requestedSessionId);
|
|
488
603
|
} else {
|
|
489
|
-
|
|
490
|
-
|
|
604
|
+
// A stateless call never registered this id; it may belong to the primary.
|
|
605
|
+
if (!runState.ephemeralSessionRepo) nativeSessions.delete(providerSessionId);
|
|
606
|
+
await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
|
|
491
607
|
}
|
|
492
608
|
}
|
|
493
609
|
|
|
@@ -495,7 +611,8 @@ export async function rollbackAbortedTurn(runState, { requestedSessionId, provid
|
|
|
495
611
|
* Outer-catch session cleanup: drop a just-created fresh durable session, drop a
|
|
496
612
|
* create-on-miss reservation placeholder, and roll a resumed session back to its
|
|
497
613
|
* pre-turn leaf for host/runtime-side throws that landed after the harness
|
|
498
|
-
* already mutated the live session.
|
|
614
|
+
* already mutated the live session. Resumed handles are always closed here so
|
|
615
|
+
* setup failures before a harness is returned cannot leave the repo wedged.
|
|
499
616
|
* @param {any} runState
|
|
500
617
|
* @param {{durableRepo: any}} params
|
|
501
618
|
*/
|
|
@@ -509,7 +626,7 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
|
|
|
509
626
|
// resumed user session here would be data loss) and never when the throw
|
|
510
627
|
// preceded session create.
|
|
511
628
|
if (session && !sessionEntry) {
|
|
512
|
-
|
|
629
|
+
await closeAndDeleteSession(session, runState.ephemeralSessionRepo || durableRepo || nativeSessionRepo);
|
|
513
630
|
}
|
|
514
631
|
// Drop a create-on-miss BUSY placeholder (R8) left in the registry by a throw
|
|
515
632
|
// during/after the reservation — including a throw inside the create await
|
|
@@ -522,8 +639,89 @@ export async function cleanupSessionOnThrow(runState, { durableRepo }) {
|
|
|
522
639
|
// harness already mutated the live session. Mirrors the success-path
|
|
523
640
|
// rollback: move the live session back to the pre-turn leaf so the failed
|
|
524
641
|
// turn never leaks into a later resume. Gated on `sessionEntry &&
|
|
525
|
-
// baselineLeafId` so
|
|
526
|
-
|
|
527
|
-
|
|
642
|
+
// baselineLeafId` so rollback only fires for resumes that captured a baseline.
|
|
643
|
+
// Closing is independently gated on the resumed session existing: a failure
|
|
644
|
+
// may land before the baseline was readable, but that handle must still be
|
|
645
|
+
// released without deleting the user-owned transcript.
|
|
646
|
+
if (sessionEntry && session) {
|
|
647
|
+
if (baselineLeafId) {
|
|
648
|
+
try { await session.moveTo(baselineLeafId); } catch { /* best-effort */ }
|
|
649
|
+
}
|
|
650
|
+
try { await session.close(); } catch { /* best-effort */ }
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Capture only after close; pending entries cannot be driven by another turn. */
|
|
655
|
+
export async function captureSessionRecovery(runState, { options, providerSessionId, modelKey, model, pending }) {
|
|
656
|
+
const entry = runState.sessionEntry || runState.registeredSessionEntry;
|
|
657
|
+
if (!entry?.durable) return undefined;
|
|
658
|
+
try {
|
|
659
|
+
if (!Array.isArray(runState.recoveryInputIds)) throw new Error("Pi session recovery input identities are unavailable");
|
|
660
|
+
const tipId = await runState.session.getLeafId();
|
|
661
|
+
if (typeof tipId !== "string" || !tipId) throw new Error("Pi session recovery tip is unavailable");
|
|
662
|
+
const ancestry = createHash("sha256").update(JSON.stringify(await runState.session.getEntries())).digest("hex");
|
|
663
|
+
await runState.session.close();
|
|
664
|
+
const receipt = { runId: options.sessionRecovery.runId, revision: options.sessionRecovery.revision, providerSessionId, modelKey, tipId };
|
|
665
|
+
entry.recovery = { receipt: { ...receipt }, model: { ...model, input: [...model.input] }, ancestry, operationId: runState.recoveryOperationId, baselineTipId: runState.recoveryBaselineTipId, inputIds: runState.recoveryInputIds };
|
|
666
|
+
entry.recoveryPending = pending || !!options.abortSignal?.aborted;
|
|
667
|
+
return receipt;
|
|
668
|
+
} catch (error) {
|
|
669
|
+
// The run's outer catch performs legacy rollback/close or fresh deletion.
|
|
670
|
+
// Release provisional recovery state first so failed capture cannot strand
|
|
671
|
+
// an entry as busy without a receipt that could settle it.
|
|
672
|
+
entry.recoveryPending = false;
|
|
673
|
+
delete entry.recovery;
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Read-only settlement: never drive an operation or append host-authored prose. */
|
|
679
|
+
export async function recoverDurableNativeSession(receipt, context) {
|
|
680
|
+
const entry = nativeSessions.get(receipt?.providerSessionId);
|
|
681
|
+
const proof = entry?.recovery;
|
|
682
|
+
if (!entry?.durable || entry.busy || !proof
|
|
683
|
+
|| !["runId", "revision", "providerSessionId", "modelKey", "tipId"].every((key) => receipt[key] === proof.receipt[key])
|
|
684
|
+
|| !Array.isArray(context?.appliedInputIds)
|
|
685
|
+
|| proof.inputIds.some((id) => typeof id !== "string")
|
|
686
|
+
|| JSON.stringify([...proof.inputIds].sort()) !== JSON.stringify([...context.appliedInputIds].sort())) return false;
|
|
687
|
+
entry.busy = true;
|
|
688
|
+
let raw;
|
|
689
|
+
try {
|
|
690
|
+
const matches = (await entry.repo.list(undefined, PI_CONTEXT)).filter((record) => record.id === receipt.providerSessionId);
|
|
691
|
+
if (matches.length !== 1 || matches[0].path !== entry.metadata.path) return false;
|
|
692
|
+
raw = await entry.repo.open(matches[0], PI_CONTEXT);
|
|
693
|
+
const branch = await raw.branch("main", PI_CONTEXT);
|
|
694
|
+
if (!branch || await branch.getTipId(PI_CONTEXT) !== receipt.tipId) return false;
|
|
695
|
+
const state = (await raw.getValue(laneState("main"), PI_CONTEXT))?.value;
|
|
696
|
+
const config = (await raw.getValue(laneConfig("main"), PI_CONTEXT))?.value;
|
|
697
|
+
const terminal = (await raw.getValue(operationResult(proof.operationId), PI_CONTEXT))?.value;
|
|
698
|
+
const meta = (await raw.getValue(operationMeta(proof.operationId), PI_CONTEXT))?.value;
|
|
699
|
+
if (!state || state.currentOperationId !== null || state.lastOperationId !== proof.operationId || state.inbox.length !== 0
|
|
700
|
+
|| config?.model.provider !== proof.model.provider || config?.model.modelId !== proof.model.id
|
|
701
|
+
|| !terminal || !["completed", "failed", "aborted"].includes(terminal.status) || terminal.tipId !== receipt.tipId
|
|
702
|
+
|| meta !== undefined || terminal.kind !== "run" || terminal.fromTipId !== proof.baselineTipId
|
|
703
|
+
|| await raw.getValue(operationState(proof.operationId), PI_CONTEXT)) return false;
|
|
704
|
+
const entries = await branch.findEntries({ order: "oldestFirst" }, PI_CONTEXT);
|
|
705
|
+
if (createHash("sha256").update(JSON.stringify(entries)).digest("hex") !== proof.ancestry) return false;
|
|
706
|
+
const baseline = proof.baselineTipId === null ? -1 : entries.findIndex((item) => item.id === proof.baselineTipId);
|
|
707
|
+
if (proof.baselineTipId !== null && baseline < 0) return false;
|
|
708
|
+
const tail = entries.slice(baseline + 1);
|
|
709
|
+
if (tail[0]?.type !== "message" || tail[0].message.role !== "user"
|
|
710
|
+
|| tail.filter((item) => item.type === "message" && item.message.role === "user").length !== 1 + proof.inputIds.length) return false;
|
|
711
|
+
if (!validRecoveryProjection(buildPiSessionContext(entries), proof.model)) return false;
|
|
712
|
+
await raw.close(PI_CONTEXT);
|
|
713
|
+
raw = undefined;
|
|
714
|
+
// Pending is cleared only after persistence is certain. Bypass the ordinary
|
|
715
|
+
// sync guard while keeping the public busy reservation throughout the fsync.
|
|
716
|
+
await syncPath(entry.metadata.path);
|
|
717
|
+
await syncPath(dirname(entry.metadata.path));
|
|
718
|
+
entry.recoveryPending = false;
|
|
719
|
+
delete entry.recovery;
|
|
720
|
+
return true;
|
|
721
|
+
} catch {
|
|
722
|
+
return false;
|
|
723
|
+
} finally {
|
|
724
|
+
try { await raw?.close(PI_CONTEXT); } catch { /* already failed closed */ }
|
|
725
|
+
entry.busy = false;
|
|
528
726
|
}
|
|
529
727
|
}
|