@rivet-dev/pi 0.1.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +190 -0
  2. package/dist/actions.d.ts +188 -77
  3. package/dist/actions.d.ts.map +1 -1
  4. package/dist/actions.js +276 -103
  5. package/dist/actions.js.map +1 -1
  6. package/dist/actor-config.d.ts +32 -0
  7. package/dist/actor-config.d.ts.map +1 -0
  8. package/dist/actor-config.js +86 -0
  9. package/dist/actor-config.js.map +1 -0
  10. package/dist/actor-sqlite.d.ts +10 -0
  11. package/dist/actor-sqlite.d.ts.map +1 -0
  12. package/dist/actor-sqlite.js +32 -0
  13. package/dist/actor-sqlite.js.map +1 -0
  14. package/dist/actor-types.d.ts +34 -0
  15. package/dist/actor-types.d.ts.map +1 -0
  16. package/dist/actor-types.js +2 -0
  17. package/dist/actor-types.js.map +1 -0
  18. package/dist/actor.d.ts +27 -15
  19. package/dist/actor.d.ts.map +1 -1
  20. package/dist/actor.js +71 -78
  21. package/dist/actor.js.map +1 -1
  22. package/dist/credentials.d.ts +3 -4
  23. package/dist/credentials.d.ts.map +1 -1
  24. package/dist/credentials.js +11 -38
  25. package/dist/credentials.js.map +1 -1
  26. package/dist/drain.d.ts +12 -0
  27. package/dist/drain.d.ts.map +1 -0
  28. package/dist/drain.js +20 -0
  29. package/dist/drain.js.map +1 -0
  30. package/dist/durable.d.ts +24 -0
  31. package/dist/durable.d.ts.map +1 -0
  32. package/dist/durable.js +5 -0
  33. package/dist/durable.js.map +1 -0
  34. package/dist/errors.d.ts +8 -0
  35. package/dist/errors.d.ts.map +1 -0
  36. package/dist/errors.js +19 -0
  37. package/dist/errors.js.map +1 -0
  38. package/dist/index.d.ts +5 -4
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/models.d.ts +7 -33
  43. package/dist/models.d.ts.map +1 -1
  44. package/dist/models.js +4 -67
  45. package/dist/models.js.map +1 -1
  46. package/dist/runtime.d.ts +111 -43
  47. package/dist/runtime.d.ts.map +1 -1
  48. package/dist/runtime.js +444 -225
  49. package/dist/runtime.js.map +1 -1
  50. package/dist/storage.d.ts +27 -28
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +33 -55
  53. package/dist/storage.js.map +1 -1
  54. package/dist/tracing.d.ts +7 -4
  55. package/dist/tracing.d.ts.map +1 -1
  56. package/dist/tracing.js +11 -12
  57. package/dist/tracing.js.map +1 -1
  58. package/dist/watches.d.ts +77 -0
  59. package/dist/watches.d.ts.map +1 -0
  60. package/dist/watches.js +245 -0
  61. package/dist/watches.js.map +1 -0
  62. package/package.json +38 -15
  63. package/dist/sandbox.d.ts +0 -21
  64. package/dist/sandbox.d.ts.map +0 -1
  65. package/dist/sandbox.js +0 -185
  66. package/dist/sandbox.js.map +0 -1
package/dist/runtime.js CHANGED
@@ -1,12 +1,25 @@
1
- import { isAbsolute } from "node:path";
2
- import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
1
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
+ import { AgentDoc, configure, Harness, } from "@earendil-works/pi-durable";
3
+ import { SqliteStorage } from "@earendil-works/pi-durable/storage/sqlite";
4
+ import { databaseEnv, sandboxEnv } from "@rivet-dev/sandbox-adapter/pi";
5
+ import { UserError } from "rivetkit";
6
+ import { actorSqlite } from "./actor-sqlite.js";
3
7
  import { SourceCredentialStore } from "./credentials.js";
4
- import { createActorModelRuntime, emptyCredentialStore, openingModel, } from "./models.js";
5
- import { createSandboxBashOperations, createSandboxTools } from "./sandbox.js";
6
- import { appendPiEntry, createPiSession, loadPiSandbox, loadPiSession, savePiSandbox, savePiSettings, toFileEntries, } from "./storage.js";
8
+ import { toClientError } from "./errors.js";
9
+ import { createActorModelRuntime, emptyCredentialStore, } from "./models.js";
10
+ import { loadPiSandbox, savePiSandbox } from "./storage.js";
11
+ import { createConnectionWatches, reattachWatches, stopAllWatches, } from "./watches.js";
12
+ /**
13
+ * One minute, as in Cloudflare's Pi harness. A retry or poll wait longer than
14
+ * this lets the actor sleep, and a scheduled wake reopens Pi Durable at the
15
+ * deadline.
16
+ */
17
+ const LONG_WAIT_MS = 60_000;
18
+ /** The internal action a scheduled wake calls. */
19
+ const WAKE_ACTION = "pi.wake";
7
20
  export const PI_RUNTIME = Symbol.for("@rivet-dev/pi/runtime");
8
21
  export function createPiRuntime() {
9
- return { writes: Promise.resolve() };
22
+ return { closing: false, watches: createConnectionWatches() };
10
23
  }
11
24
  export function piRuntime(c) {
12
25
  const runtime = c.vars?.[PI_RUNTIME];
@@ -15,109 +28,430 @@ export function piRuntime(c) {
15
28
  }
16
29
  return runtime;
17
30
  }
18
- /** Returns the actor's Pi session, opening it from SQLite on first use. */
19
- export function ensurePiSession(c, options) {
31
+ /**
32
+ * Opens the harness on wake without resuming interrupted work, so the app's
33
+ * `onWake` runs first. The caller calls `harness.resume()` after it. The wake
34
+ * context becomes the context of all background work.
35
+ */
36
+ export async function openOnWake(c, options) {
37
+ piRuntime(c).actor = c;
38
+ return ensureHarness(c, options, { resume: false });
39
+ }
40
+ /** Returns the actor's Pi Durable harness, opening it from SQLite on first use. */
41
+ async function ensureHarness(c, options, open = { resume: true }) {
20
42
  const runtime = piRuntime(c);
21
- if (!runtime.ready) {
22
- runtime.ready = openPiSession(c, runtime, options).catch((error) => {
23
- runtime.ready = undefined;
24
- throw error;
25
- });
43
+ if (runtime.closing)
44
+ throw harnessClosed();
45
+ runtime.harness ??= openHarness(runtime.actor ?? c, runtime, options, open).catch((error) => {
46
+ runtime.harness = undefined;
47
+ throw toClientError(error);
48
+ });
49
+ return (await runtime.harness).harness;
50
+ }
51
+ /**
52
+ * Runs an action against the harness. A failed call that left Pi Durable
53
+ * unusable reopens it before the error reaches the client, so the next call
54
+ * works. Calls use `BACKGROUND_CONTEXT`, so a wait such as `prompt` lasts
55
+ * until the run settles or the action times out, including while a stopping
56
+ * actor drains the run. RivetKit's action signal also fires when the actor
57
+ * starts to stop, so it cannot cancel a wait.
58
+ */
59
+ export async function withHarness(c, options, run) {
60
+ const harness = await ensureHarness(c, options);
61
+ try {
62
+ return await run(harness, BACKGROUND_CONTEXT);
63
+ }
64
+ catch (error) {
65
+ const runtime = piRuntime(c);
66
+ await recoverHarness(runtime.actor ?? c, runtime, options);
67
+ throw toClientError(error);
26
68
  }
27
- return runtime.ready;
28
69
  }
29
- /** Pi's built-in tools, which all run on the actor host. */
30
- const PI_BUILT_IN_TOOLS = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"];
31
- async function openPiSession(c, runtime, options) {
32
- const { settings, sandbox: sandboxProvider, providers, apiKeys, model, scopedModels, credentials: credentialSource, ...sessionOptions } = options;
33
- const stored = await loadPiSession(c.db);
34
- const connected = sandboxProvider
35
- ? await connectSandbox(c, sandboxProvider)
36
- : undefined;
37
- const sandbox = connected?.sandbox;
38
- const cwd = sandbox?.cwd ?? stored?.cwd ?? sessionOptions.cwd ?? process.cwd();
39
- if (!isAbsolute(cwd)) {
40
- throw new Error(`pi() cwd must be an absolute path, received ${cwd}`);
70
+ /**
71
+ * Runs one of the app's own actions or hooks with the harness on `c.pi`. A
72
+ * failure that left Pi Durable unusable reopens it. The error reaches the
73
+ * client as from a built-in action. Code that never reads `c.pi` runs even
74
+ * when Pi Durable is closed or cannot open; reading `c.pi` throws the cause.
75
+ * Shutdown hooks pass `opensHarness: false`, so they use a harness that is
76
+ * already open and never open and resume one only to close it.
77
+ */
78
+ export async function withPiAccess(c, options, body, { opensHarness = true } = {}) {
79
+ const runtime = piRuntime(c);
80
+ // Hooks such as onDisconnect still run after Pi Durable closed; only using c.pi fails.
81
+ if (runtime.closing)
82
+ return withUnavailablePi(c, harnessClosed(), body);
83
+ if (!opensHarness && !runtime.harness) {
84
+ return withUnavailablePi(c, harnessNotOpen(), body);
41
85
  }
42
- if (stored && stored.cwd !== cwd) {
86
+ let harness;
87
+ try {
88
+ harness = await ensureHarness(c, options);
89
+ }
90
+ catch (error) {
43
91
  c.log.warn({
44
- msg: "pi session cwd changed since it was stored",
45
- storedCwd: stored.cwd,
46
- cwd,
92
+ msg: "pi durable could not open; reading c.pi throws this error",
93
+ error,
47
94
  });
95
+ return withUnavailablePi(c, error, body);
96
+ }
97
+ Object.defineProperty(c, "pi", { value: harness, configurable: true });
98
+ try {
99
+ return await body();
100
+ }
101
+ catch (error) {
102
+ await recoverHarness(runtime.actor ?? c, runtime, options);
103
+ throw toClientError(error);
104
+ }
105
+ }
106
+ function harnessNotOpen() {
107
+ return new UserError("Pi Durable is not open in this actor generation, so this shutdown hook cannot use c.pi.");
108
+ }
109
+ function harnessClosed() {
110
+ return new UserError("Pi Durable is closed because the actor is stopping.");
111
+ }
112
+ async function openHarness(c, runtime, options, open = { resume: true }) {
113
+ const storage = await SqliteStorage.open(actorSqlite(c.db));
114
+ const now = options.now ?? Date.now;
115
+ const harness = await Harness.open(storage, {
116
+ models: options.models ?? (await createModels(c, options)),
117
+ registry: options.registry,
118
+ settings: options.settings,
119
+ env: options.env ??
120
+ (options.sandbox
121
+ ? sandboxEnvBuilder(c, runtime, options.sandbox)
122
+ : (target) => databaseEnv(c.db, {
123
+ id: `pi-files:${c.actorId}`,
124
+ cwd: target.cwd,
125
+ })),
126
+ conversationCreated: startingAgent(options),
127
+ now: options.now,
128
+ onReport: (error) => {
129
+ // A commit that failed after storage admitted it leaves Pi Durable
130
+ // unusable. Background work reports it here, with no action to catch it.
131
+ queueMicrotask(() => void recoverHarness(c, runtime, options));
132
+ options.onReport?.(error);
133
+ },
134
+ }, BACKGROUND_CONTEXT);
135
+ reportBlockedTasks(c, await harness.inspect(BACKGROUND_CONTEXT));
136
+ if (open.resume)
137
+ harness.resume();
138
+ const isBusy = async () => busyState(await harness.inspect(BACKGROUND_CONTEXT), now(), LONG_WAIT_MS)
139
+ .kind === "busy";
140
+ const stopBusyWatch = watchBusy(c, harness, now, LONG_WAIT_MS);
141
+ await reattachWatches(c, runtime.watches, harness, documentsByKind(options));
142
+ return { harness, stopBusyWatch, isBusy };
143
+ }
144
+ /** App documents by `kind`, for clients that name a document. */
145
+ export function documentsByKind(options) {
146
+ return new Map((options.documents ?? []).map((doc) => [doc.definition.kind, doc]));
147
+ }
148
+ /** Pi Durable refuses every call once a commit failed after storage admitted it. */
149
+ function isUsable(harness) {
150
+ try {
151
+ harness.subscribeClose(() => { })();
152
+ return true;
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ /** Closes and reopens a harness that Pi Durable refuses to use. The reopened harness resumes from storage. */
159
+ async function recoverHarness(c, runtime, options) {
160
+ const current = runtime.harness;
161
+ if (!current || runtime.closing)
162
+ return;
163
+ let open;
164
+ try {
165
+ open = await current;
166
+ }
167
+ catch {
168
+ return;
48
169
  }
49
- const settingsManager = SettingsManager.inMemory(stored?.settings ?? settings ?? {});
50
- const sessionManager = stored
51
- ? SessionManager.inMemory(cwd, undefined, toFileEntries(stored))
52
- : SessionManager.inMemory(cwd);
53
- const credentials = credentialSource
54
- ? new SourceCredentialStore(credentialSource(c))
55
- : undefined;
56
- const modelRuntime = await createActorModelRuntime({ providers, apiKeys }, credentials ?? emptyCredentialStore);
57
- const resourceLoader = sessionOptions.resourceLoader ??
58
- (await isolatedResourceLoader(cwd, sessionOptions.agentDir, settingsManager));
59
- const { session, modelFallbackMessage } = await createAgentSession({
60
- ...sessionOptions,
61
- cwd,
62
- modelRuntime,
63
- model: openingModel({ model, scopedModels }, modelRuntime, sessionManager.buildSessionContext().model),
64
- settingsManager,
65
- sessionManager,
66
- resourceLoader,
67
- customTools: sandbox
68
- ? [...(sessionOptions.customTools ?? []), ...createSandboxTools(sandbox)]
69
- : sessionOptions.customTools,
70
- excludeTools: [
71
- ...new Set([
72
- ...(sessionOptions.excludeTools ?? []),
73
- ...(sandbox ? ["powershell"] : PI_BUILT_IN_TOOLS),
74
- ]),
75
- ],
170
+ if (runtime.harness !== current || runtime.closing || isUsable(open.harness))
171
+ return;
172
+ c.log.warn({
173
+ msg: "pi durable stopped after a failed commit, reopening it from storage",
174
+ });
175
+ const reopened = (async () => {
176
+ await closeOpenHarness(open, runtime.watches);
177
+ return openHarness(c, runtime, options);
178
+ })().catch((error) => {
179
+ if (runtime.harness === reopened)
180
+ runtime.harness = undefined;
181
+ c.log.error({
182
+ msg: "pi durable could not reopen after a failed commit",
183
+ error,
184
+ });
185
+ throw toClientError(error);
76
186
  });
77
- if (modelFallbackMessage) {
78
- c.log.warn({ msg: "pi model fallback", detail: modelFallbackMessage });
187
+ runtime.harness = reopened;
188
+ await reopened.catch(() => { });
189
+ }
190
+ async function closeOpenHarness(open, watches) {
191
+ open.stopBusyWatch();
192
+ await stopAllWatches(watches);
193
+ await open.harness.close(BACKGROUND_CONTEXT);
194
+ }
195
+ async function createModels(c, options) {
196
+ const credentials = options.credentials
197
+ ? new SourceCredentialStore(options.credentials(c))
198
+ : emptyCredentialStore;
199
+ return createActorModelRuntime({ providers: options.providers, apiKeys: options.apiKeys }, credentials);
200
+ }
201
+ /** Parses `provider/modelId`. */
202
+ export function parseModelRef(name) {
203
+ const slash = name.indexOf("/");
204
+ if (slash <= 0 || slash === name.length - 1) {
205
+ throw new Error(`pi() model must be provider/modelId, received ${name}`);
79
206
  }
80
- const handle = {
81
- session,
82
- settingsManager,
83
- cwd,
84
- sandbox: connected,
85
- bashOperations: sandbox ? createSandboxBashOperations(sandbox) : undefined,
86
- persistedSettings: JSON.stringify(settingsManager.getGlobalSettings()),
87
- persistedEntryCount: stored?.entries.length ?? 0,
88
- credentials,
207
+ return { provider: name.slice(0, slash), modelId: name.slice(slash + 1) };
208
+ }
209
+ /**
210
+ * `model` and `thinkingLevel` start every new conversation: root, created, and
211
+ * forked. A fork already has its parent's agent, so it keeps it. The app's own
212
+ * `conversationCreated` runs after this.
213
+ */
214
+ function startingAgent(options) {
215
+ const model = options.model === undefined ? undefined : parseModelRef(options.model);
216
+ const thinkingLevel = options.thinkingLevel;
217
+ const created = options.conversationCreated;
218
+ if (model === undefined && thinkingLevel === undefined)
219
+ return created;
220
+ return async (tx, conversation) => {
221
+ const agent = await tx.doc(AgentDoc, conversation.id);
222
+ await configure(tx, conversation.id, {
223
+ model: agent.model === undefined ? model : undefined,
224
+ thinkingLevel: agent.thinkingLevel === undefined ? thinkingLevel : undefined,
225
+ });
226
+ await created?.(tx, conversation);
89
227
  };
90
- if (!stored) {
91
- const header = sessionManager.getHeader();
92
- if (!header) {
93
- throw new Error("Pi did not create a session header");
94
- }
95
- await createPiSession(c.db, {
96
- header,
97
- cwd,
98
- settings: JSON.parse(handle.persistedSettings),
228
+ }
229
+ /**
230
+ * Builds the sandbox env without connecting, so a model request or a custom
231
+ * tool call never creates a sandbox. The sandbox connects on the first file
232
+ * or shell operation. A provider that does not report its working directory
233
+ * connects first, because the env needs that directory.
234
+ */
235
+ function sandboxEnvBuilder(c, runtime, provider) {
236
+ const connect = async () => {
237
+ runtime.sandbox ??= connectSandbox(c, provider).catch((error) => {
238
+ runtime.sandbox = undefined;
239
+ throw error;
99
240
  });
241
+ return (await runtime.sandbox).sandbox;
242
+ };
243
+ return async (target) => {
244
+ const root = provider.cwd ?? (await connect()).cwd;
245
+ return sandboxEnv(`${provider.name}:${c.actorId}`, root, connect, target.cwd);
246
+ };
247
+ }
248
+ function reportBlockedTasks(c, inspection) {
249
+ for (const task of inspection.tasks) {
250
+ if (task.state.kind !== "blocked")
251
+ continue;
252
+ c.log.warn({
253
+ msg: "pi durable task is blocked and does not keep the actor awake; inspect it with harness.inspect",
254
+ taskId: task.record.id,
255
+ kind: task.record.kind,
256
+ reason: task.state.reason,
257
+ error: task.state.error,
258
+ });
259
+ }
260
+ }
261
+ /**
262
+ * Whether live work should keep the actor awake. Tasks that run or are ready
263
+ * to run do. A task in a retry or poll wait longer than `longWaitMs` does not;
264
+ * the actor sleeps and a scheduled wake reopens it at the deadline. Waiting
265
+ * and completing tasks depend on other tasks, which decide. Blocked tasks
266
+ * never run, so they never keep the actor awake.
267
+ */
268
+ function busyState(inspection, now, longWaitMs) {
269
+ let until;
270
+ for (const task of inspection.tasks) {
271
+ switch (task.state.kind) {
272
+ case "running":
273
+ case "ready": {
274
+ const deadline = waitDeadline(task.record.state.checkpoint);
275
+ if (deadline === undefined || deadline - now <= longWaitMs)
276
+ return { kind: "busy" };
277
+ until = Math.min(until ?? deadline, deadline);
278
+ break;
279
+ }
280
+ case "waiting":
281
+ case "completing":
282
+ case "blocked":
283
+ break;
284
+ }
100
285
  }
101
- await flushPiEntries(c, runtime, handle);
102
- session.subscribe((event) => {
103
- broadcastSessionEvent(c, event);
104
- if (!isStreamingDelta(event)) {
105
- flushPiEntries(c, runtime, handle).catch((error) => {
106
- c.log.error({
107
- msg: "pi session entry write failed, retrying on the next flush",
108
- error,
109
- });
286
+ return until === undefined ? { kind: "idle" } : { kind: "waiting", until };
287
+ }
288
+ /** The deadline of a task checkpoint in Pi's retry or poll phase. */
289
+ function waitDeadline(checkpoint) {
290
+ if (typeof checkpoint !== "object" ||
291
+ checkpoint === null ||
292
+ Array.isArray(checkpoint))
293
+ return undefined;
294
+ if (checkpoint.phase === "retry" && typeof checkpoint.until === "number")
295
+ return checkpoint.until;
296
+ if (checkpoint.phase === "poll" && typeof checkpoint.pollAt === "number")
297
+ return checkpoint.pollAt;
298
+ return undefined;
299
+ }
300
+ /**
301
+ * Keeps the actor awake while Pi Durable has live work, rechecking after
302
+ * every commit. Returns a function that stops watching.
303
+ */
304
+ function watchBusy(c, harness, now, longWaitMs) {
305
+ let release;
306
+ let scheduledWakeAt;
307
+ let checking = false;
308
+ let dirty = false;
309
+ let stopped = false;
310
+ const stayAwake = () => {
311
+ if (release)
312
+ return;
313
+ const { promise, resolve } = Promise.withResolvers();
314
+ release = resolve;
315
+ void c.keepAwake(promise);
316
+ };
317
+ const letGo = () => {
318
+ release?.();
319
+ release = undefined;
320
+ };
321
+ const check = async () => {
322
+ if (checking) {
323
+ dirty = true;
324
+ return;
325
+ }
326
+ checking = true;
327
+ try {
328
+ do {
329
+ dirty = false;
330
+ const busy = busyState(await harness.inspect(BACKGROUND_CONTEXT), now(), longWaitMs);
331
+ if (stopped)
332
+ return;
333
+ if (busy.kind === "waiting" && busy.until !== scheduledWakeAt) {
334
+ // The actor may sleep through a long wait only once its wake is written.
335
+ stayAwake();
336
+ try {
337
+ await c.schedule.at(busy.until, WAKE_ACTION);
338
+ scheduledWakeAt = busy.until;
339
+ }
340
+ catch (error) {
341
+ // Pi's own timer then ends the wait, and its commit checks again.
342
+ c.log.warn({
343
+ msg: "pi durable could not schedule its wake, so the actor stays awake through the wait",
344
+ error,
345
+ });
346
+ }
347
+ if (stopped)
348
+ return;
349
+ }
350
+ const canSleep = busy.kind === "idle" ||
351
+ (busy.kind === "waiting" && busy.until === scheduledWakeAt);
352
+ if (canSleep)
353
+ letGo();
354
+ else
355
+ stayAwake();
356
+ } while (dirty && !stopped);
357
+ }
358
+ catch (error) {
359
+ if (!stopped)
360
+ c.log.warn({ msg: "pi durable busy check failed", error });
361
+ }
362
+ finally {
363
+ checking = false;
364
+ }
365
+ };
366
+ // Commit listeners must not call Pi Durable, so the check runs after the listener returns.
367
+ const unsubscribe = harness.subscribeCommits(() => queueMicrotask(() => void check()));
368
+ const stop = () => {
369
+ stopped = true;
370
+ unsubscribe();
371
+ letGo();
372
+ };
373
+ // Keep-awake only blocks idle sleep. Once shutdown starts it would only delay it.
374
+ c.abortSignal.addEventListener("abort", stop, { once: true });
375
+ void check();
376
+ return () => {
377
+ c.abortSignal.removeEventListener("abort", stop);
378
+ stop();
379
+ };
380
+ }
381
+ /**
382
+ * Waits until no run is active, or until `drainUntil` passes. Idle sleep
383
+ * never has live work, so this only waits on a forced stop, such as an upgrade.
384
+ * The deadline ends before RivetKit's, so closing still fits in the grace
385
+ * period. Pi's records are already stored, and the next wake resumes the work.
386
+ */
387
+ async function drain(c, open, drainUntil) {
388
+ let changed = () => { };
389
+ let timedOut = false;
390
+ const unsubscribe = open.harness.subscribeCommits(() => changed());
391
+ const timer = setTimeout(() => {
392
+ timedOut = true;
393
+ changed();
394
+ }, Math.max(0, drainUntil - Date.now()));
395
+ try {
396
+ while (!timedOut) {
397
+ // Armed before the check, so a commit during the check is not missed.
398
+ const next = new Promise((resolve) => {
399
+ changed = resolve;
110
400
  });
401
+ if (!(await open.isBusy()))
402
+ return;
403
+ await next;
111
404
  }
405
+ c.log.warn({
406
+ msg: "pi durable run still active at the end of the grace period; it resumes on wake",
407
+ });
408
+ }
409
+ finally {
410
+ clearTimeout(timer);
411
+ unsubscribe();
412
+ }
413
+ }
414
+ /**
415
+ * Closes Pi Durable for this actor generation. On sleep it drains first. On
416
+ * destroy it stops running work at once. Records of interrupted work stay
417
+ * running, and the next open resumes them. Then suspends the sandbox on
418
+ * sleep, or destroys it on destroy.
419
+ */
420
+ export async function closeHarness(c, options, stop) {
421
+ const runtime = piRuntime(c);
422
+ const errors = [];
423
+ let open;
424
+ try {
425
+ open = await runtime.harness;
426
+ }
427
+ catch { }
428
+ // Destroy has no next generation to resume in, so it stops running work at once.
429
+ if (open && stop.reason === "sleep") {
430
+ await drain(c, open, stop.drainUntil).catch((error) => errors.push(error));
431
+ }
432
+ runtime.closing = true;
433
+ runtime.harness = undefined;
434
+ if (open) {
435
+ await closeOpenHarness(open, runtime.watches).catch((error) => errors.push(error));
436
+ }
437
+ const provider = options.sandbox;
438
+ if (provider) {
439
+ await closeSandbox(c, provider, runtime.sandbox, stop).catch((error) => errors.push(error));
440
+ }
441
+ if (errors.length === 1)
442
+ throw errors[0];
443
+ if (errors.length > 1)
444
+ throw new AggregateError(errors, "pi durable shutdown failed");
445
+ }
446
+ /** Runs `body` with a `c.pi` that throws `error` when read. */
447
+ export function withUnavailablePi(c, error, body) {
448
+ Object.defineProperty(c, "pi", {
449
+ get: () => {
450
+ throw error;
451
+ },
452
+ configurable: true,
112
453
  });
113
- c.log.info({
114
- msg: "pi session opened",
115
- sessionId: session.sessionId,
116
- restored: stored !== undefined,
117
- entryCount: sessionManager.getEntries().length,
118
- messageCount: session.messages.length,
119
- });
120
- return handle;
454
+ return body();
121
455
  }
122
456
  /**
123
457
  * Connects to the actor's sandbox, creating one when none is stored or the
@@ -126,7 +460,7 @@ async function openPiSession(c, runtime, options) {
126
460
  * other connect failure is thrown, so a temporary outage never replaces a
127
461
  * sandbox.
128
462
  */
129
- async function connectSandbox(c, provider) {
463
+ export async function connectSandbox(c, provider) {
130
464
  const existing = await loadPiSandbox(c.db);
131
465
  if (existing && existing.provider !== provider.name) {
132
466
  throw new Error(`pi sandbox was created by provider ${existing.provider}, but the actor now uses ${provider.name}`);
@@ -150,138 +484,23 @@ async function connectSandbox(c, provider) {
150
484
  return { provider, id, sandbox };
151
485
  }
152
486
  /**
153
- * Pi's resource discovery reads the actor host's filesystem and loads host
154
- * code as extensions. Extensions, skills, prompt templates, context files, and
155
- * themes stay off unless the developer passes a loader.
156
- */
157
- async function isolatedResourceLoader(cwd, agentDir, settingsManager) {
158
- const loader = new DefaultResourceLoader({
159
- cwd,
160
- agentDir: agentDir ?? getAgentDir(),
161
- settingsManager,
162
- noExtensions: true,
163
- noSkills: true,
164
- noPromptTemplates: true,
165
- noContextFiles: true,
166
- noThemes: true,
167
- });
168
- await loader.reload();
169
- return loader;
170
- }
171
- /** Token and output deltas never append entries, so they skip the entry diff. */
172
- function isStreamingDelta(event) {
173
- return (event.type === "message_update" ||
174
- event.type === "tool_execution_update" ||
175
- event.type === "bash_execution_update");
176
- }
177
- /**
178
- * Writes every entry Pi appended since the last successful write, in append
179
- * order. The count only advances after an insert succeeds, so a failed write
180
- * is retried by the next flush. Returns the write so actions can await it.
181
- */
182
- function flushPiEntries(c, runtime, handle) {
183
- const write = runtime.writes.then(async () => {
184
- const entries = handle.session.sessionManager.getEntries();
185
- while (handle.persistedEntryCount < entries.length) {
186
- await appendPiEntry(c.db, entries[handle.persistedEntryCount]);
187
- handle.persistedEntryCount += 1;
188
- }
189
- });
190
- runtime.writes = write.catch(() => { });
191
- return write;
192
- }
193
- function broadcastSessionEvent(c, event) {
194
- try {
195
- c.broadcast("event", event);
196
- }
197
- catch (error) {
198
- if (isActorStoppingError(error))
199
- return;
200
- c.log.error({ msg: "failed to broadcast pi session event", error });
201
- }
202
- }
203
- function isActorStoppingError(error) {
204
- if (!error || typeof error !== "object")
205
- return false;
206
- const candidate = error;
207
- return candidate.group === "actor" && candidate.code === "stopping";
208
- }
209
- /**
210
- * Queues entries appended since the last flush and writes Pi's mutable settings
211
- * when they changed. Called after every action and on shutdown.
487
+ * Suspends the sandbox this generation connected when the actor sleeps. On
488
+ * destroy it destroys the actor's sandbox, also one stored by an earlier
489
+ * generation that this one never connected.
212
490
  */
213
- export async function persistPiState(c, handle) {
214
- await flushPiEntries(c, piRuntime(c), handle);
215
- const settings = handle.settingsManager.getGlobalSettings();
216
- const encoded = JSON.stringify(settings);
217
- if (encoded === handle.persistedSettings)
491
+ export async function closeSandbox(c, provider, connection, stop) {
492
+ const connected = await connection?.catch(() => undefined);
493
+ if (stop.reason === "sleep") {
494
+ if (connected && provider.suspend)
495
+ await provider.suspend(c, connected.id);
218
496
  return;
219
- await savePiSettings(c.db, settings);
220
- handle.persistedSettings = encoded;
221
- }
222
- /**
223
- * Stops the Pi session for this actor generation: aborts any run, lets
224
- * extensions shut down, and flushes settings and entries. Then suspends the
225
- * sandbox on sleep, or destroys it on destroy.
226
- */
227
- export async function closePiSession(c, options, reason) {
228
- const runtime = piRuntime(c);
229
- const ready = runtime.ready;
230
- runtime.ready = undefined;
231
- const errors = [];
232
- let handle;
233
- try {
234
- handle = await ready;
235
- }
236
- catch {
237
- }
238
- if (handle) {
239
- const open = handle;
240
- await attempt(errors, () => open.session.abort());
241
- await attempt(errors, async () => {
242
- if (open.session.hasExtensionHandlers("session_shutdown")) {
243
- await open.session.extensionRunner.emit({
244
- type: "session_shutdown",
245
- reason: "quit",
246
- });
247
- }
248
- });
249
- await attempt(errors, () => persistPiState(c, open));
250
- open.session.dispose();
251
- c.log.info({ msg: "pi session closed", sessionId: open.session.sessionId });
252
- }
253
- const provider = options.sandbox;
254
- if (provider) {
255
- await attempt(errors, async () => {
256
- if (reason === "sleep") {
257
- if (handle?.sandbox && provider.suspend) {
258
- await provider.suspend(c, handle.sandbox.id);
259
- }
260
- return;
261
- }
262
- const sandbox = handle?.sandbox ?? (await storedSandbox(c, provider));
263
- if (sandbox && provider.destroy) {
264
- await provider.destroy(c, sandbox.id);
265
- }
266
- });
267
- }
268
- if (errors.length === 1)
269
- throw errors[0];
270
- if (errors.length > 1) {
271
- throw new AggregateError(errors, "pi session shutdown failed");
272
- }
273
- }
274
- /** The stored sandbox, when it belongs to `provider`. */
275
- async function storedSandbox(c, provider) {
276
- const sandbox = await loadPiSandbox(c.db);
277
- return sandbox?.provider === provider.name ? sandbox : undefined;
278
- }
279
- async function attempt(errors, operation) {
280
- try {
281
- await operation();
282
- }
283
- catch (error) {
284
- errors.push(error);
285
497
  }
498
+ if (!provider.destroy)
499
+ return;
500
+ const stored = connected ? undefined : await loadPiSandbox(c.db);
501
+ const id = connected?.id ??
502
+ (stored?.provider === provider.name ? stored.id : undefined);
503
+ if (id !== undefined)
504
+ await provider.destroy(c, id);
286
505
  }
287
506
  //# sourceMappingURL=runtime.js.map