@mono-agent/agent-runtime 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/agent/prompt/skill-index.js +1 -1
- package/src/ai/index.js +3 -0
- package/src/ai/providers/pi-native/session-lifecycle.js +90 -4
- package/src/ai/runtime/router.js +33 -1
- package/src/ai/runtime/sessions.js +209 -8
- package/src/ai/types.js +5 -1
- package/src/runtime.js +20 -1
- package/types/ai/index.d.ts +1 -1
- package/types/ai/providers/pi-native/session-lifecycle.d.ts +8 -0
- package/types/ai/runtime/sessions.d.ts +27 -3
- package/types/ai/types.d.ts +16 -2
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Provides five runtime bridges (Claude SDK, Claude Code CLI, Codex app-server, Op
|
|
|
16
16
|
- `ai/providers/claude-sdk-discovery.js` — isolated Claude SDK model discovery without importing ambient auth/config
|
|
17
17
|
- `createRouterRuntime({ chain, routeSafety, resolveAttempt })` — ordered fallback routing with exact route effort and bounded safety/failover telemetry
|
|
18
18
|
- Provider bridges for `claude` (SDK + CLI), `codex` (app-server), `pi` (Pi SDK), and `opencode`
|
|
19
|
-
- Provider session support: bridges accept `sessionId` in run options and report `provider_session_id`; the runtime exposes
|
|
19
|
+
- Provider session support: bridges accept `sessionId` in run options and report `provider_session_id`; the runtime exposes best-effort disposal, strict cold-refresh, exact-id durable retirement, invalidation, and whole-runtime shutdown operations
|
|
20
20
|
- Sandbox-aware built-in tools and stdio MCP startup through an injectable `RuntimeSandbox` seam (`agent/sandbox-seam.js`) — no direct dependency on `@mono-agent/runtime-adapter`
|
|
21
21
|
|
|
22
22
|
## Dependency Boundary
|
|
@@ -219,6 +219,10 @@ Returns:
|
|
|
219
219
|
|
|
220
220
|
- `run(systemPrompt, options)` — async, runs one agent turn against the chosen backend.
|
|
221
221
|
- `configureTools(next)` — update the tool runtime context after construction.
|
|
222
|
+
- `syncSession(id)` — fsync provider-owned durable state before canonical history commits.
|
|
223
|
+
- `refreshSession(id)` — guarantee the next resume cannot reuse process-local state; absence succeeds and cleanup uncertainty rejects.
|
|
224
|
+
- `retireDurableSession(id, sessionsRoot)` — delete and verify every exact-id durable Pi transcript, including cold duplicates.
|
|
225
|
+
- `disposeSession(id)` / `invalidateSession(id)` / `disposeAllSessions()` — ordinary best-effort eviction, destructive live invalidation, and shutdown cleanup.
|
|
222
226
|
|
|
223
227
|
### `runtime.run(systemPrompt, options)`
|
|
224
228
|
|
package/package.json
CHANGED
|
@@ -37,7 +37,7 @@ export function buildSkillPathNote({ assetsPath, skillsRoot } = {}) {
|
|
|
37
37
|
/**
|
|
38
38
|
* @param {{body?: string, assetsPath?: string, skillsRoot?: any, maxChars?: number}} [options]
|
|
39
39
|
*/
|
|
40
|
-
export function formatSkillBodyWithPathNote({ body, assetsPath, skillsRoot, maxChars
|
|
40
|
+
export function formatSkillBodyWithPathNote({ body, assetsPath, skillsRoot, maxChars } = {}) {
|
|
41
41
|
const text = [
|
|
42
42
|
buildSkillPathNote({ assetsPath, skillsRoot }),
|
|
43
43
|
String(body || "").trim(),
|
package/src/ai/index.js
CHANGED
|
@@ -7,6 +7,9 @@ export {
|
|
|
7
7
|
createSessionRegistry,
|
|
8
8
|
disposeAllProviderSessions,
|
|
9
9
|
disposeProviderSession,
|
|
10
|
+
invalidateProviderSession,
|
|
11
|
+
refreshProviderSession,
|
|
12
|
+
syncProviderSession,
|
|
10
13
|
} from "./runtime/sessions.js";
|
|
11
14
|
export { createMetricsObserver, createObserverHub } from "./observer.js";
|
|
12
15
|
export { generatePiNativeResponse, piNativeRuntimeBridge } from "./providers/pi-native.js";
|
|
@@ -12,9 +12,45 @@
|
|
|
12
12
|
|
|
13
13
|
import { InMemorySessionRepo, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
|
|
14
14
|
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
|
|
15
|
+
import { open } from "node:fs/promises";
|
|
16
|
+
import { dirname, resolve } from "node:path";
|
|
15
17
|
import { createSessionRegistry } from "../../runtime/sessions.js";
|
|
16
18
|
import { createSessionLiveness } from "../../runtime/session-liveness.js";
|
|
17
19
|
|
|
20
|
+
async function syncPath(path) {
|
|
21
|
+
const handle = await open(path, "r");
|
|
22
|
+
try {
|
|
23
|
+
await handle.sync();
|
|
24
|
+
} finally {
|
|
25
|
+
await handle.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function syncDurableTranscript(entry) {
|
|
30
|
+
if (!entry.durable) return;
|
|
31
|
+
const path = entry.metadata?.path;
|
|
32
|
+
if (typeof path !== "string" || !path) {
|
|
33
|
+
throw new Error("Durable Pi session metadata is missing its JSONL path");
|
|
34
|
+
}
|
|
35
|
+
// pi-core appends through short-lived file descriptors. Re-open the live
|
|
36
|
+
// JSONL and fsync it, then fsync its containing directory so both transcript
|
|
37
|
+
// bytes and the directory entry are stable before host history commits.
|
|
38
|
+
await syncPath(path);
|
|
39
|
+
await syncPath(dirname(path));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function invalidateNativeSession(entry) {
|
|
43
|
+
await entry.repo.delete(entry.metadata);
|
|
44
|
+
if (entry.durable) {
|
|
45
|
+
const path = entry.metadata?.path;
|
|
46
|
+
if (typeof path !== "string" || !path) {
|
|
47
|
+
throw new Error("Durable Pi session metadata is missing its JSONL path");
|
|
48
|
+
}
|
|
49
|
+
// Make the unlink durable before the registry forgets the busy marker.
|
|
50
|
+
await syncPath(dirname(path));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
// Live pi-native sessions, keyed by provider session id. Entries are
|
|
19
55
|
// { session, metadata, repo, durable, busy } — identical shape and lifecycle
|
|
20
56
|
// policy to the (now-retired) pi-sdk bridge: in-memory transcripts are freed
|
|
@@ -25,11 +61,21 @@ import { createSessionLiveness } from "../../runtime/session-liveness.js";
|
|
|
25
61
|
const nativeSessionRepo = new InMemorySessionRepo();
|
|
26
62
|
const nativeSessions = createSessionRegistry({
|
|
27
63
|
isBusy: (entry) => entry.busy === true,
|
|
28
|
-
|
|
64
|
+
onSync: syncDurableTranscript,
|
|
65
|
+
onEvict: async (entry, reason) => {
|
|
66
|
+
// Ordinary disposal/TTL only drops the live handle so durable sessions can
|
|
67
|
+
// reopen after restart. Explicit invalidation means the host rejected the
|
|
68
|
+
// turn before canonical history commit; that poisoned transcript must be
|
|
69
|
+
// deleted too or it could silently reappear on the next stable-id resume.
|
|
70
|
+
if (reason === "invalidated") {
|
|
71
|
+
// Destructive invalidation is an honest API: deletion (and, for JSONL,
|
|
72
|
+
// parent-directory fsync) must finish before registry removal, and any
|
|
73
|
+
// failure must reach the caller so it cannot assume cleanup succeeded.
|
|
74
|
+
await invalidateNativeSession(entry);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
29
77
|
if (entry.durable) return;
|
|
30
|
-
|
|
31
|
-
await entry.repo.delete(entry.metadata);
|
|
32
|
-
} catch { /* best-effort */ }
|
|
78
|
+
await entry.repo.delete(entry.metadata);
|
|
33
79
|
},
|
|
34
80
|
});
|
|
35
81
|
const liveness = createSessionLiveness(nativeSessions);
|
|
@@ -50,6 +96,46 @@ export function resolveDurableNativeSessionRepo(piSessionsRoot) {
|
|
|
50
96
|
return repo;
|
|
51
97
|
}
|
|
52
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Permanently retire every durable Pi transcript with this exact logical id.
|
|
101
|
+
* This is intentionally stronger than live-session invalidation: history
|
|
102
|
+
* rotation and retention can retire an epoch after its registry entry was
|
|
103
|
+
* already evicted or after a process restart. Absence is success; any cleanup
|
|
104
|
+
* or verification uncertainty rejects so canonical history remains reachable.
|
|
105
|
+
*/
|
|
106
|
+
export async function retireDurableNativeSession(providerSessionId, piSessionsRoot) {
|
|
107
|
+
if (!isSafeSessionId(providerSessionId)) {
|
|
108
|
+
throw new TypeError("providerSessionId must be a safe, non-empty session id");
|
|
109
|
+
}
|
|
110
|
+
if (typeof piSessionsRoot !== "string" || !piSessionsRoot.trim()) {
|
|
111
|
+
throw new TypeError("piSessionsRoot must be a non-empty path");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// First guarantee this process cannot keep writing through a stale handle.
|
|
115
|
+
// Other processes are serialized by the history coordinator and must pass
|
|
116
|
+
// the same cold-refresh barrier before their next turn.
|
|
117
|
+
await nativeSessions.refresh(providerSessionId);
|
|
118
|
+
|
|
119
|
+
const repo = resolveDurableNativeSessionRepo(piSessionsRoot);
|
|
120
|
+
if (!repo) throw new Error("Durable Pi session repository is unavailable");
|
|
121
|
+
const matches = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
|
|
122
|
+
const changedDirectories = new Set();
|
|
123
|
+
for (const metadata of matches) {
|
|
124
|
+
if (typeof metadata?.path !== "string" || !metadata.path) {
|
|
125
|
+
throw new Error(`Durable Pi session ${providerSessionId} has invalid metadata`);
|
|
126
|
+
}
|
|
127
|
+
await repo.delete(metadata);
|
|
128
|
+
changedDirectories.add(dirname(metadata.path));
|
|
129
|
+
}
|
|
130
|
+
for (const directory of changedDirectories) await syncPath(directory);
|
|
131
|
+
if (changedDirectories.size > 0) await syncPath(resolve(piSessionsRoot));
|
|
132
|
+
|
|
133
|
+
const remaining = (await repo.list()).filter((entry) => entry?.id === providerSessionId);
|
|
134
|
+
if (remaining.length > 0) {
|
|
135
|
+
throw new Error(`Durable Pi session ${providerSessionId} could not be retired completely`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
53
139
|
// Defense in depth (R4): create-on-miss passes the caller-controlled session id
|
|
54
140
|
// straight to durableRepo.create({ id }), and JsonlSessionRepo writes
|
|
55
141
|
// `${createdAt}_${id}.jsonl` — so an id like "../../../../tmp/pwn" would escape
|
package/src/ai/runtime/router.js
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
// API:
|
|
15
15
|
// createRouterRuntime({ host, chain })
|
|
16
16
|
// returns { run(systemPrompt, options) } plus configureTools /
|
|
17
|
-
//
|
|
17
|
+
// syncSession / refreshSession / retireDurableSession / disposeSession /
|
|
18
|
+
// invalidateSession / disposeAllSessions
|
|
19
|
+
// delegated to the inner runtime,
|
|
18
20
|
// so the router is a drop-in replacement for createRuntime(host).
|
|
19
21
|
//
|
|
20
22
|
// chain entries:
|
|
@@ -388,6 +390,29 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
|
|
|
388
390
|
// attempts use their isolated route runtime, and applying one route's
|
|
389
391
|
// policy to this shared standby would defeat that isolation.
|
|
390
392
|
},
|
|
393
|
+
async syncSession(providerSessionId) {
|
|
394
|
+
let synced = false;
|
|
395
|
+
for (const runtime of allRuntimes(inner, routeRuntimes)) {
|
|
396
|
+
synced = Boolean(await runtime.syncSession?.(providerSessionId)) || synced;
|
|
397
|
+
}
|
|
398
|
+
return synced;
|
|
399
|
+
},
|
|
400
|
+
async refreshSession(providerSessionId) {
|
|
401
|
+
for (const runtime of allRuntimes(inner, routeRuntimes)) {
|
|
402
|
+
if (typeof runtime.refreshSession !== "function") {
|
|
403
|
+
throw new Error("A routed runtime cannot guarantee a cold provider-session reopen");
|
|
404
|
+
}
|
|
405
|
+
await runtime.refreshSession(providerSessionId);
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
async retireDurableSession(providerSessionId, sessionsRoot) {
|
|
409
|
+
for (const runtime of allRuntimes(inner, routeRuntimes)) {
|
|
410
|
+
if (typeof runtime.retireDurableSession !== "function") {
|
|
411
|
+
throw new Error("A routed runtime cannot retire durable provider-session state");
|
|
412
|
+
}
|
|
413
|
+
await runtime.retireDurableSession(providerSessionId, sessionsRoot);
|
|
414
|
+
}
|
|
415
|
+
},
|
|
391
416
|
async disposeSession(providerSessionId) {
|
|
392
417
|
let disposed = false;
|
|
393
418
|
for (const runtime of allRuntimes(inner, routeRuntimes)) {
|
|
@@ -395,6 +420,13 @@ export function createRouterRuntime({ host = {}, chain = [], routeSafety = "unif
|
|
|
395
420
|
}
|
|
396
421
|
return disposed;
|
|
397
422
|
},
|
|
423
|
+
async invalidateSession(providerSessionId) {
|
|
424
|
+
let invalidated = false;
|
|
425
|
+
for (const runtime of allRuntimes(inner, routeRuntimes)) {
|
|
426
|
+
invalidated = Boolean(await runtime.invalidateSession?.(providerSessionId)) || invalidated;
|
|
427
|
+
}
|
|
428
|
+
return invalidated;
|
|
429
|
+
},
|
|
398
430
|
async disposeAllSessions() {
|
|
399
431
|
await Promise.all(allRuntimes(inner, routeRuntimes).map(async (runtime) => runtime.disposeAllSessions?.()));
|
|
400
432
|
},
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// sleep) still cannot resurrect an expired session.
|
|
12
12
|
//
|
|
13
13
|
// `createSessionRegistry` instances self-register in a module-level set so
|
|
14
|
-
// the runtime surface can expose `
|
|
14
|
+
// the runtime surface can expose `syncSession(id)` / `refreshSession(id)` /
|
|
15
|
+
// `disposeSession(id)` / `disposeAllSessions()`
|
|
15
16
|
// without knowing which bridge owns the id. Provider session ids are unique
|
|
16
17
|
// across bridges (codex thread ids, pi uuids), so fan-out dispose is safe.
|
|
17
18
|
|
|
@@ -29,16 +30,34 @@ function normalizeTtl(value, fallback) {
|
|
|
29
30
|
* @param {object} [options]
|
|
30
31
|
* @param {number} [options.idleTimeoutMs]
|
|
31
32
|
* @param {(value: any, reason: string) => (void | Promise<void>)} [options.onEvict]
|
|
33
|
+
* @param {(value: any) => (void | Promise<void>)} [options.onSync]
|
|
32
34
|
* @param {() => number} [options.now]
|
|
33
35
|
* @param {(value: any) => boolean} [options.isBusy]
|
|
34
36
|
*/
|
|
35
|
-
export function createSessionRegistry({
|
|
37
|
+
export function createSessionRegistry({
|
|
38
|
+
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
39
|
+
onEvict,
|
|
40
|
+
onSync,
|
|
41
|
+
now = Date.now,
|
|
42
|
+
isBusy,
|
|
43
|
+
} = {}) {
|
|
36
44
|
const entries = new Map();
|
|
37
45
|
const defaultTtlMs = normalizeTtl(idleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS);
|
|
46
|
+
// A destructive invalidation must keep the id occupied until provider
|
|
47
|
+
// cleanup is durably complete. Returning a busy placeholder makes existing
|
|
48
|
+
// await-free liveness claims fail closed instead of treating the id as a
|
|
49
|
+
// cold-reopen miss while its JSONL is still being unlinked.
|
|
50
|
+
const unavailable = Object.freeze({ busy: true });
|
|
38
51
|
|
|
39
|
-
async function
|
|
52
|
+
async function evictBestEffort(id, reason) {
|
|
40
53
|
const entry = entries.get(id);
|
|
41
54
|
if (!entry) return false;
|
|
55
|
+
if (entry.state !== "active") {
|
|
56
|
+
if ((entry.state === "invalidating" || entry.state === "refreshing") && entry.operation) {
|
|
57
|
+
try { return await entry.operation; } catch { return false; }
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
42
61
|
if (reason === "idle_timeout" && isBusy?.(entry.value)) {
|
|
43
62
|
// A session executing a turn must not be torn down by the idle timer;
|
|
44
63
|
// give it a fresh TTL window. Explicit dispose still wins.
|
|
@@ -59,10 +78,125 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
|
59
78
|
return true;
|
|
60
79
|
}
|
|
61
80
|
|
|
81
|
+
async function sync(id) {
|
|
82
|
+
const entry = entries.get(id);
|
|
83
|
+
if (!entry) return false;
|
|
84
|
+
if (entry.state === "syncing" && entry.operation) return entry.operation;
|
|
85
|
+
if (entry.state === "invalidating" && entry.operation) {
|
|
86
|
+
await entry.operation;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (entry.state === "refreshing" && entry.operation) {
|
|
90
|
+
await entry.operation;
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (entry.state === "invalidation_failed") return false;
|
|
94
|
+
|
|
95
|
+
clearTimeout(entry.timer);
|
|
96
|
+
entry.state = "syncing";
|
|
97
|
+
const operation = (async () => {
|
|
98
|
+
try {
|
|
99
|
+
await onSync?.(entry.value);
|
|
100
|
+
if (entries.get(id) === entry) {
|
|
101
|
+
entry.state = "active";
|
|
102
|
+
entry.operation = null;
|
|
103
|
+
entry.lastActivityAt = now();
|
|
104
|
+
armTimer(id, entry);
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// A provider that cannot prove its durable transcript is on disk must
|
|
109
|
+
// not become resumable. Keep the busy marker in place so the host can
|
|
110
|
+
// follow with destructive invalidation (or retry sync) honestly.
|
|
111
|
+
if (entries.get(id) === entry) {
|
|
112
|
+
entry.state = "sync_failed";
|
|
113
|
+
entry.operation = null;
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
})();
|
|
118
|
+
entry.operation = operation;
|
|
119
|
+
return operation;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function invalidate(id) {
|
|
123
|
+
const entry = entries.get(id);
|
|
124
|
+
if (!entry) return false;
|
|
125
|
+
if (entry.state === "invalidating" && entry.operation) return entry.operation;
|
|
126
|
+
if (entry.state === "syncing" && entry.operation) {
|
|
127
|
+
try { await entry.operation; } catch { /* invalidation supersedes sync */ }
|
|
128
|
+
return invalidate(id);
|
|
129
|
+
}
|
|
130
|
+
if (entry.state === "refreshing" && entry.operation) {
|
|
131
|
+
try { await entry.operation; } catch { /* invalidation supersedes refresh */ }
|
|
132
|
+
return invalidate(id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
clearTimeout(entry.timer);
|
|
136
|
+
entry.state = "invalidating";
|
|
137
|
+
const operation = (async () => {
|
|
138
|
+
try {
|
|
139
|
+
// Unlike ordinary eviction, destructive provider cleanup is the
|
|
140
|
+
// operation being promised to the caller. Run it before removal and
|
|
141
|
+
// let every error propagate; forgetting the entry first would report a
|
|
142
|
+
// lie and permit a poisoned durable transcript to cold-reopen.
|
|
143
|
+
await onEvict?.(entry.value, "invalidated");
|
|
144
|
+
if (entries.get(id) === entry) entries.delete(id);
|
|
145
|
+
return true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (entries.get(id) === entry) {
|
|
148
|
+
entry.state = "invalidation_failed";
|
|
149
|
+
entry.operation = null;
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
entry.operation = operation;
|
|
155
|
+
return operation;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function refresh(id) {
|
|
159
|
+
const entry = entries.get(id);
|
|
160
|
+
if (!entry) return;
|
|
161
|
+
if (entry.state === "refreshing" && entry.operation) return entry.operation;
|
|
162
|
+
if (entry.state === "invalidating" && entry.operation) {
|
|
163
|
+
await entry.operation;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (entry.state === "syncing" && entry.operation) {
|
|
167
|
+
await entry.operation;
|
|
168
|
+
return refresh(id);
|
|
169
|
+
}
|
|
170
|
+
if (entry.state === "invalidation_failed") {
|
|
171
|
+
throw new Error(`Provider session ${String(id)} is awaiting destructive invalidation`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
clearTimeout(entry.timer);
|
|
175
|
+
entry.state = "refreshing";
|
|
176
|
+
const operation = (async () => {
|
|
177
|
+
try {
|
|
178
|
+
// A refresh is stronger than ordinary best-effort disposal: callers use
|
|
179
|
+
// its successful completion as proof that a subsequent resume cannot
|
|
180
|
+
// adopt stale process memory. Preserve provider-owned durable state,
|
|
181
|
+
// but propagate cleanup failures and retain the unavailable marker.
|
|
182
|
+
await onEvict?.(entry.value, "refreshed");
|
|
183
|
+
if (entries.get(id) === entry) entries.delete(id);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (entries.get(id) === entry) {
|
|
186
|
+
entry.state = "refresh_failed";
|
|
187
|
+
entry.operation = null;
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
entry.operation = operation;
|
|
193
|
+
return operation;
|
|
194
|
+
}
|
|
195
|
+
|
|
62
196
|
function armTimer(id, entry) {
|
|
63
197
|
clearTimeout(entry.timer);
|
|
64
198
|
entry.timer = setTimeout(() => {
|
|
65
|
-
void
|
|
199
|
+
void evictBestEffort(id, "idle_timeout");
|
|
66
200
|
}, entry.ttlMs);
|
|
67
201
|
entry.timer.unref?.();
|
|
68
202
|
}
|
|
@@ -71,8 +205,9 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
|
71
205
|
get(id) {
|
|
72
206
|
const entry = entries.get(id);
|
|
73
207
|
if (!entry) return undefined;
|
|
208
|
+
if (entry.state !== "active") return unavailable;
|
|
74
209
|
if (now() - entry.lastActivityAt > entry.ttlMs && !isBusy?.(entry.value)) {
|
|
75
|
-
void
|
|
210
|
+
void evictBestEffort(id, "idle_timeout");
|
|
76
211
|
return undefined;
|
|
77
212
|
}
|
|
78
213
|
return entry.value;
|
|
@@ -84,10 +219,22 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
|
84
219
|
*/
|
|
85
220
|
set(id, value, { idleTimeoutMs: entryTtl } = {}) {
|
|
86
221
|
const previous = entries.get(id);
|
|
222
|
+
// Never overwrite an in-flight/failed durability operation. The marker
|
|
223
|
+
// deliberately owns this id until sync recovers or invalidation removes
|
|
224
|
+
// it, preventing a cold reopen from winning an unlink race.
|
|
225
|
+
if (previous && previous.state !== "active") return false;
|
|
87
226
|
if (previous) clearTimeout(previous.timer);
|
|
88
|
-
const entry = {
|
|
227
|
+
const entry = {
|
|
228
|
+
value,
|
|
229
|
+
lastActivityAt: now(),
|
|
230
|
+
timer: null,
|
|
231
|
+
ttlMs: normalizeTtl(entryTtl, defaultTtlMs),
|
|
232
|
+
state: "active",
|
|
233
|
+
operation: null,
|
|
234
|
+
};
|
|
89
235
|
entries.set(id, entry);
|
|
90
236
|
armTimer(id, entry);
|
|
237
|
+
return true;
|
|
91
238
|
},
|
|
92
239
|
/**
|
|
93
240
|
* @param {any} id
|
|
@@ -96,6 +243,7 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
|
96
243
|
touch(id, { idleTimeoutMs: entryTtl } = {}) {
|
|
97
244
|
const entry = entries.get(id);
|
|
98
245
|
if (!entry) return;
|
|
246
|
+
if (entry.state !== "active") return;
|
|
99
247
|
if (entryTtl !== undefined) entry.ttlMs = normalizeTtl(entryTtl, entry.ttlMs);
|
|
100
248
|
entry.lastActivityAt = now();
|
|
101
249
|
armTimer(id, entry);
|
|
@@ -107,16 +255,26 @@ export function createSessionRegistry({ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
|
107
255
|
delete(id) {
|
|
108
256
|
const entry = entries.get(id);
|
|
109
257
|
if (!entry) return false;
|
|
258
|
+
if (entry.state !== "active") return false;
|
|
110
259
|
entries.delete(id);
|
|
111
260
|
clearTimeout(entry.timer);
|
|
112
261
|
return true;
|
|
113
262
|
},
|
|
114
263
|
async dispose(id) {
|
|
115
|
-
return
|
|
264
|
+
return evictBestEffort(id, "disposed");
|
|
265
|
+
},
|
|
266
|
+
async sync(id) {
|
|
267
|
+
return sync(id);
|
|
268
|
+
},
|
|
269
|
+
async refresh(id) {
|
|
270
|
+
return refresh(id);
|
|
271
|
+
},
|
|
272
|
+
async invalidate(id) {
|
|
273
|
+
return invalidate(id);
|
|
116
274
|
},
|
|
117
275
|
async disposeAll() {
|
|
118
276
|
const ids = [...entries.keys()];
|
|
119
|
-
for (const id of ids) await
|
|
277
|
+
for (const id of ids) await evictBestEffort(id, "disposed");
|
|
120
278
|
},
|
|
121
279
|
size() {
|
|
122
280
|
return entries.size;
|
|
@@ -136,6 +294,49 @@ export async function disposeProviderSession(providerSessionId) {
|
|
|
136
294
|
return disposed;
|
|
137
295
|
}
|
|
138
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Flush provider-owned durable session state after a successful run and before
|
|
299
|
+
* the host commits canonical history. Providers without durable live state
|
|
300
|
+
* acknowledge the barrier immediately; provider sync errors propagate.
|
|
301
|
+
*/
|
|
302
|
+
export async function syncProviderSession(providerSessionId) {
|
|
303
|
+
if (typeof providerSessionId !== "string" || !providerSessionId.trim()) return false;
|
|
304
|
+
let synced = false;
|
|
305
|
+
for (const registry of allRegistries) {
|
|
306
|
+
if (await registry.sync(providerSessionId)) synced = true;
|
|
307
|
+
}
|
|
308
|
+
return synced;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Guarantee that no provider registry can reuse process-local state for this
|
|
313
|
+
* id. Durable provider transcripts are preserved so the next resume reopens
|
|
314
|
+
* them from disk. Absence is success; cleanup failure rejects.
|
|
315
|
+
*/
|
|
316
|
+
export async function refreshProviderSession(providerSessionId) {
|
|
317
|
+
if (typeof providerSessionId !== "string" || !providerSessionId.trim()) {
|
|
318
|
+
throw new TypeError("providerSessionId must be a non-empty string");
|
|
319
|
+
}
|
|
320
|
+
for (const registry of allRegistries) {
|
|
321
|
+
await registry.refresh(providerSessionId);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Irreversibly discard a provider session whose transcript is not represented
|
|
327
|
+
* by canonical host history. Unlike ordinary disposal, providers with durable
|
|
328
|
+
* session caches must remove the persisted transcript as well as the live
|
|
329
|
+
* registry entry.
|
|
330
|
+
*/
|
|
331
|
+
export async function invalidateProviderSession(providerSessionId) {
|
|
332
|
+
if (typeof providerSessionId !== "string" || !providerSessionId.trim()) return false;
|
|
333
|
+
let invalidated = false;
|
|
334
|
+
for (const registry of allRegistries) {
|
|
335
|
+
if (await registry.invalidate(providerSessionId)) invalidated = true;
|
|
336
|
+
}
|
|
337
|
+
return invalidated;
|
|
338
|
+
}
|
|
339
|
+
|
|
139
340
|
export async function disposeAllProviderSessions() {
|
|
140
341
|
for (const registry of allRegistries) {
|
|
141
342
|
await registry.disposeAll();
|
package/src/ai/types.js
CHANGED
|
@@ -322,7 +322,11 @@
|
|
|
322
322
|
* The object `createRuntime`/`createRouterRuntime` return.
|
|
323
323
|
* @property {(systemPrompt: string, options: RuntimeRunOptions) => Promise<RuntimeResult>} run
|
|
324
324
|
* @property {(next?: AgentRuntimeToolOptions) => void} configureTools
|
|
325
|
-
* @property {(providerSessionId: string) => Promise<boolean
|
|
325
|
+
* @property {(providerSessionId: string) => Promise<boolean>} syncSession
|
|
326
|
+
* @property {(providerSessionId: string) => Promise<void>} refreshSession Guarantees the id has no reusable process-local handle; rejects on failure.
|
|
327
|
+
* @property {(providerSessionId: string, sessionsRoot: string) => Promise<void>} retireDurableSession Permanently deletes every durable transcript with the exact id; absence is success.
|
|
328
|
+
* @property {(providerSessionId: string) => Promise<boolean>} disposeSession
|
|
329
|
+
* @property {(providerSessionId: string) => Promise<boolean>} invalidateSession
|
|
326
330
|
* @property {() => Promise<void>} disposeAllSessions
|
|
327
331
|
*/
|
|
328
332
|
|
package/src/runtime.js
CHANGED
|
@@ -30,9 +30,16 @@
|
|
|
30
30
|
|
|
31
31
|
import { resolveRuntimeBridge } from "./ai/runtime/registry.js";
|
|
32
32
|
import { createObserverHub } from "./ai/observer.js";
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
disposeAllProviderSessions,
|
|
35
|
+
disposeProviderSession,
|
|
36
|
+
invalidateProviderSession,
|
|
37
|
+
refreshProviderSession,
|
|
38
|
+
syncProviderSession,
|
|
39
|
+
} from "./ai/runtime/sessions.js";
|
|
34
40
|
import { createToolContext, updateToolContext } from "./agent/tools/shared/tool-context.js";
|
|
35
41
|
import { resolveRuntimeBrand } from "./runtime-brand.js";
|
|
42
|
+
import { retireDurableNativeSession } from "./ai/providers/pi-native/session-lifecycle.js";
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
45
|
* @typedef {import('./ai/types.js').AgentRuntimeHostOptions} AgentRuntimeHostOptions
|
|
@@ -175,9 +182,21 @@ export function createRuntime(host = {}) {
|
|
|
175
182
|
configureTools(next = {}) {
|
|
176
183
|
updateToolContext(toolContext, pickPresent(next, TOOL_RUNTIME_KEYS));
|
|
177
184
|
},
|
|
185
|
+
async syncSession(providerSessionId) {
|
|
186
|
+
return syncProviderSession(providerSessionId);
|
|
187
|
+
},
|
|
188
|
+
async refreshSession(providerSessionId) {
|
|
189
|
+
return refreshProviderSession(providerSessionId);
|
|
190
|
+
},
|
|
191
|
+
async retireDurableSession(providerSessionId, sessionsRoot) {
|
|
192
|
+
return retireDurableNativeSession(providerSessionId, sessionsRoot);
|
|
193
|
+
},
|
|
178
194
|
async disposeSession(providerSessionId) {
|
|
179
195
|
return disposeProviderSession(providerSessionId);
|
|
180
196
|
},
|
|
197
|
+
async invalidateSession(providerSessionId) {
|
|
198
|
+
return invalidateProviderSession(providerSessionId);
|
|
199
|
+
},
|
|
181
200
|
async disposeAllSessions() {
|
|
182
201
|
return disposeAllProviderSessions();
|
|
183
202
|
},
|
package/types/ai/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * from "./registry.js";
|
|
2
2
|
export * from "./runtime/model-refs.js";
|
|
3
3
|
export * from "./runtime/registry.js";
|
|
4
|
-
export { createSessionRegistry, disposeAllProviderSessions, disposeProviderSession } from "./runtime/sessions.js";
|
|
4
|
+
export { createSessionRegistry, disposeAllProviderSessions, disposeProviderSession, invalidateProviderSession, refreshProviderSession, syncProviderSession } from "./runtime/sessions.js";
|
|
5
5
|
export { createMetricsObserver, createObserverHub } from "./observer.js";
|
|
6
6
|
export { generatePiNativeResponse, piNativeRuntimeBridge } from "./providers/pi-native.js";
|
|
7
7
|
export { CLAUDE_SDK_CATALOG_VERSION, createClaudeSdkDiscoveryIsolation, curatedClaudeSdkModels, discoverClaudeSdkModels, normalizeClaudeSdkCatalog, normalizeClaudeSdkModelId } from "./providers/claude-sdk-discovery.js";
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export function resolveDurableNativeSessionRepo(piSessionsRoot: any): any;
|
|
2
|
+
/**
|
|
3
|
+
* Permanently retire every durable Pi transcript with this exact logical id.
|
|
4
|
+
* This is intentionally stronger than live-session invalidation: history
|
|
5
|
+
* rotation and retention can retire an epoch after its registry entry was
|
|
6
|
+
* already evicted or after a process restart. Absence is success; any cleanup
|
|
7
|
+
* or verification uncertainty rejects so canonical history remains reachable.
|
|
8
|
+
*/
|
|
9
|
+
export function retireDurableNativeSession(providerSessionId: any, piSessionsRoot: any): Promise<void>;
|
|
2
10
|
/**
|
|
3
11
|
* Resolve the session for this run: warm registry hit, durable cold reopen,
|
|
4
12
|
* create-on-miss (durable resume only), or fresh create. Mutates runState
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
* @param {object} [options]
|
|
3
3
|
* @param {number} [options.idleTimeoutMs]
|
|
4
4
|
* @param {(value: any, reason: string) => (void | Promise<void>)} [options.onEvict]
|
|
5
|
+
* @param {(value: any) => (void | Promise<void>)} [options.onSync]
|
|
5
6
|
* @param {() => number} [options.now]
|
|
6
7
|
* @param {(value: any) => boolean} [options.isBusy]
|
|
7
8
|
*/
|
|
8
|
-
export function createSessionRegistry({ idleTimeoutMs, onEvict, now, isBusy }?: {
|
|
9
|
+
export function createSessionRegistry({ idleTimeoutMs, onEvict, onSync, now, isBusy, }?: {
|
|
9
10
|
idleTimeoutMs?: number;
|
|
10
11
|
onEvict?: (value: any, reason: string) => (void | Promise<void>);
|
|
12
|
+
onSync?: (value: any) => (void | Promise<void>);
|
|
11
13
|
now?: () => number;
|
|
12
14
|
isBusy?: (value: any) => boolean;
|
|
13
15
|
}): {
|
|
@@ -19,7 +21,7 @@ export function createSessionRegistry({ idleTimeoutMs, onEvict, now, isBusy }?:
|
|
|
19
21
|
*/
|
|
20
22
|
set(id: any, value: any, { idleTimeoutMs: entryTtl }?: {
|
|
21
23
|
idleTimeoutMs?: number;
|
|
22
|
-
}):
|
|
24
|
+
}): boolean;
|
|
23
25
|
/**
|
|
24
26
|
* @param {any} id
|
|
25
27
|
* @param {{idleTimeoutMs?: number}} [options]
|
|
@@ -30,9 +32,31 @@ export function createSessionRegistry({ idleTimeoutMs, onEvict, now, isBusy }?:
|
|
|
30
32
|
has(id: any): boolean;
|
|
31
33
|
/** Remove without running onEvict — for callers that already cleaned up. */
|
|
32
34
|
delete(id: any): boolean;
|
|
33
|
-
dispose(id: any): Promise<
|
|
35
|
+
dispose(id: any): Promise<any>;
|
|
36
|
+
sync(id: any): Promise<any>;
|
|
37
|
+
refresh(id: any): Promise<any>;
|
|
38
|
+
invalidate(id: any): Promise<any>;
|
|
34
39
|
disposeAll(): Promise<void>;
|
|
35
40
|
size(): number;
|
|
36
41
|
};
|
|
37
42
|
export function disposeProviderSession(providerSessionId: any): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* Flush provider-owned durable session state after a successful run and before
|
|
45
|
+
* the host commits canonical history. Providers without durable live state
|
|
46
|
+
* acknowledge the barrier immediately; provider sync errors propagate.
|
|
47
|
+
*/
|
|
48
|
+
export function syncProviderSession(providerSessionId: any): Promise<boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* Guarantee that no provider registry can reuse process-local state for this
|
|
51
|
+
* id. Durable provider transcripts are preserved so the next resume reopens
|
|
52
|
+
* them from disk. Absence is success; cleanup failure rejects.
|
|
53
|
+
*/
|
|
54
|
+
export function refreshProviderSession(providerSessionId: any): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Irreversibly discard a provider session whose transcript is not represented
|
|
57
|
+
* by canonical host history. Unlike ordinary disposal, providers with durable
|
|
58
|
+
* session caches must remove the persisted transcript as well as the live
|
|
59
|
+
* registry entry.
|
|
60
|
+
*/
|
|
61
|
+
export function invalidateProviderSession(providerSessionId: any): Promise<boolean>;
|
|
38
62
|
export function disposeAllProviderSessions(): Promise<void>;
|
package/types/ai/types.d.ts
CHANGED
|
@@ -282,7 +282,11 @@
|
|
|
282
282
|
* The object `createRuntime`/`createRouterRuntime` return.
|
|
283
283
|
* @property {(systemPrompt: string, options: RuntimeRunOptions) => Promise<RuntimeResult>} run
|
|
284
284
|
* @property {(next?: AgentRuntimeToolOptions) => void} configureTools
|
|
285
|
-
* @property {(providerSessionId: string) => Promise<boolean
|
|
285
|
+
* @property {(providerSessionId: string) => Promise<boolean>} syncSession
|
|
286
|
+
* @property {(providerSessionId: string) => Promise<void>} refreshSession Guarantees the id has no reusable process-local handle; rejects on failure.
|
|
287
|
+
* @property {(providerSessionId: string, sessionsRoot: string) => Promise<void>} retireDurableSession Permanently deletes every durable transcript with the exact id; absence is success.
|
|
288
|
+
* @property {(providerSessionId: string) => Promise<boolean>} disposeSession
|
|
289
|
+
* @property {(providerSessionId: string) => Promise<boolean>} invalidateSession
|
|
286
290
|
* @property {() => Promise<void>} disposeAllSessions
|
|
287
291
|
*/
|
|
288
292
|
export const PROVIDER_KIND_VALUES: string[];
|
|
@@ -770,6 +774,16 @@ export type AgentRuntimeToolOptions = {
|
|
|
770
774
|
export type AgentRuntimeInstance = {
|
|
771
775
|
run: (systemPrompt: string, options: RuntimeRunOptions) => Promise<RuntimeResult>;
|
|
772
776
|
configureTools: (next?: AgentRuntimeToolOptions) => void;
|
|
773
|
-
|
|
777
|
+
syncSession: (providerSessionId: string) => Promise<boolean>;
|
|
778
|
+
/**
|
|
779
|
+
* Guarantees the id has no reusable process-local handle; rejects on failure.
|
|
780
|
+
*/
|
|
781
|
+
refreshSession: (providerSessionId: string) => Promise<void>;
|
|
782
|
+
/**
|
|
783
|
+
* Permanently deletes every durable transcript with the exact id; absence is success.
|
|
784
|
+
*/
|
|
785
|
+
retireDurableSession: (providerSessionId: string, sessionsRoot: string) => Promise<void>;
|
|
786
|
+
disposeSession: (providerSessionId: string) => Promise<boolean>;
|
|
787
|
+
invalidateSession: (providerSessionId: string) => Promise<boolean>;
|
|
774
788
|
disposeAllSessions: () => Promise<void>;
|
|
775
789
|
};
|