@rivet-dev/pi 0.5.0 → 0.5.2

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