@skydiveai/pi-extensions 0.1.0-beta.1602 → 0.1.0-beta.1603

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 (2) hide show
  1. package/dist/index.mjs +145 -21
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -2264,6 +2264,58 @@ async function postBackgroundTaskDone({ messageId, content }) {
2264
2264
  } });
2265
2265
  if (!res.ok) throw new Error(`bg-task-done POST failed: ${res.status}`);
2266
2266
  }
2267
+ async function putBackgroundTaskJournalSpec({ messageId, spec }) {
2268
+ const client = sandboxClient();
2269
+ if (!client) return;
2270
+ try {
2271
+ const res = await client["bg-task-journal"].$put({ json: {
2272
+ messageId,
2273
+ spec
2274
+ } });
2275
+ if (!res.ok) log$10.warn({
2276
+ status: res.status,
2277
+ taskId: spec.id,
2278
+ event: "bg_journal_put_failed"
2279
+ }, "bg-task journal PUT failed");
2280
+ } catch (err) {
2281
+ log$10.warn({
2282
+ err,
2283
+ taskId: spec.id,
2284
+ event: "bg_journal_put_failed"
2285
+ }, "bg-task journal PUT threw");
2286
+ }
2287
+ }
2288
+ async function deleteBackgroundTaskJournalSpec({ messageId, taskId }) {
2289
+ const client = sandboxClient();
2290
+ if (!client) return;
2291
+ try {
2292
+ await client["bg-task-journal"].$delete({ json: {
2293
+ messageId,
2294
+ taskId
2295
+ } });
2296
+ } catch (err) {
2297
+ log$10.debug({
2298
+ err,
2299
+ taskId,
2300
+ event: "bg_journal_delete_failed"
2301
+ }, "bg-task journal DELETE failed");
2302
+ }
2303
+ }
2304
+ async function listBackgroundTaskJournalSpecs({ messageId }) {
2305
+ const client = sandboxClient();
2306
+ if (!client) return [];
2307
+ try {
2308
+ const res = await client["bg-task-journal"].$get({ query: { messageId } });
2309
+ if (!res.ok) return [];
2310
+ return (await res.json()).specs ?? [];
2311
+ } catch (err) {
2312
+ log$10.debug({
2313
+ err,
2314
+ event: "bg_journal_list_failed"
2315
+ }, "bg-task journal GET failed");
2316
+ return [];
2317
+ }
2318
+ }
2267
2319
  function postBackgroundTasksSnapshot({ messageId, tasks }) {
2268
2320
  const client = sandboxClient();
2269
2321
  if (!client || !messageId) return;
@@ -3860,16 +3912,32 @@ const toolCallSummaryExtension = (pi) => {
3860
3912
  * spills to disk (/home/user/.anyone/bg-tasks/<id>.log) to avoid buffering a chatty job
3861
3913
  * in memory; exit code and run state live on the in-memory task.
3862
3914
  *
3863
- * **No cross-restart survival (v1, deliberate).** Task state lives only in
3864
- * the running harness process. A harness restart (crash → supervisord
3865
- * respawn, or `platform harness reload` after the agent edits its own
3866
- * harness) drops the map and pi's exec children are reaped with it. We don't
3867
- * resurrect from disk because the common next-run case cold-provisions a
3868
- * *different* sandbox anyway (warm reuse is the minority in prod), so on-disk
3869
- * state would rarely be the box the next run lands on. The idle-completion wake
3870
- * does cross the sandbox → platform boundary (a fresh run via `bg-task-done`),
3871
- * but a task whose harness dies before it finishes is gone — it is not
3872
- * resurrected, and this stays distinct from the scheduled-run (cron) system.
3915
+ * **Cross-restart survival is OPT-IN, via `bg_run({ resumable: true })`.**
3916
+ * A non-resumable task's state lives only in the running harness process: a
3917
+ * harness restart (crash → supervisord respawn, `platform harness reload`, or
3918
+ * a sandbox recycle on idle timeout / redeploy / template rebuild) drops the
3919
+ * map and pi's exec children are reaped with it, and the task is gone — the
3920
+ * right behavior for a one-shot side-effecting command, which must never
3921
+ * silently re-run.
3922
+ *
3923
+ * A RESUMABLE task additionally checkpoints its *spec* (command, cwd, labels,
3924
+ * origin messageId — not its live output/exit state) to a durable, api-side
3925
+ * journal keyed by conversation (`/sandbox/bg-task-journal`, redis). On the
3926
+ * NEXT run's `session_start` — which usually lands on a *different*,
3927
+ * cold-provisioned sandbox, which is exactly why the journal is api-side and
3928
+ * not on the sandbox disk — the harness lists the journal and relaunches any
3929
+ * spec it isn't already running, keeping the original id and prepending a
3930
+ * `<resumed-after-restart>` banner so the agent knows it re-ran from scratch,
3931
+ * not continued. The spec is dropped from the journal when the task
3932
+ * finishes/kills. Because relaunch RE-EXECUTES the command, resumable is only
3933
+ * for idempotent, long-lived work (pollers, watchers, retry loops); the tool
3934
+ * description enforces this and the default is false.
3935
+ *
3936
+ * The idle-completion wake (a task finishing while the agent is idle) crosses
3937
+ * the sandbox → platform boundary via a fresh run (`bg-task-done`) for both
3938
+ * resumable and non-resumable tasks; the journal is a separate, additive layer
3939
+ * that only handles a task whose harness dies BEFORE it finishes. This stays
3940
+ * distinct from the scheduled-run (cron) system.
3873
3941
  */
3874
3942
  const log = logger.child({ module: "background-tasks-ext" });
3875
3943
  const ops = createLocalBashOperations();
@@ -4148,9 +4216,9 @@ This is a background-task completion, not a message from the user. If it needs n
4148
4216
  }
4149
4217
  await publishSnapshotNow();
4150
4218
  }
4151
- async function launchTask({ command, description, cwd }) {
4219
+ async function launchTask({ command, description, cwd, resumable = false, resumedFromJournal = false, id: providedId, startedAt: providedStartedAt }) {
4152
4220
  taskCounter += 1;
4153
- const id = `bg-${process.pid.toString(36)}-${taskCounter}`;
4221
+ const id = providedId ?? `bg-${process.pid.toString(36)}-${taskCounter}`;
4154
4222
  try {
4155
4223
  await mkdir(tasksDir(), { recursive: true });
4156
4224
  } catch (err) {
@@ -4166,7 +4234,7 @@ This is a background-task completion, not a message from the user. If it needs n
4166
4234
  taskId: id
4167
4235
  }, "bg task log write failed");
4168
4236
  });
4169
- const startedAt = Date.now();
4237
+ const startedAt = providedStartedAt ?? Date.now();
4170
4238
  const meta = {
4171
4239
  id,
4172
4240
  command: stripPlatformExportsForDisplay(command),
@@ -4182,9 +4250,23 @@ This is a background-task completion, not a message from the user. If it needs n
4182
4250
  controller: new AbortController(),
4183
4251
  running: true,
4184
4252
  exitCode: null,
4185
- error: null
4253
+ error: null,
4254
+ resumable,
4255
+ cwd,
4256
+ resumedFromJournal
4186
4257
  };
4187
4258
  tasks.set(id, meta);
4259
+ if (resumable && messageId) putBackgroundTaskJournalSpec({
4260
+ messageId,
4261
+ spec: {
4262
+ id,
4263
+ command,
4264
+ cwd,
4265
+ description,
4266
+ startedAt,
4267
+ messageId
4268
+ }
4269
+ });
4188
4270
  ops.exec(command, cwd, {
4189
4271
  onData: (chunk) => {
4190
4272
  meta.logBytes += chunk.length;
@@ -4210,6 +4292,10 @@ This is a background-task completion, not a message from the user. If it needs n
4210
4292
  taskId: id,
4211
4293
  exitCode: meta.exitCode
4212
4294
  }, "bg task finished");
4295
+ if (meta.resumable && meta.messageId) deleteBackgroundTaskJournalSpec({
4296
+ messageId: meta.messageId,
4297
+ taskId: id
4298
+ });
4213
4299
  schedulePublishSnapshot();
4214
4300
  await notifyCompletion(meta);
4215
4301
  });
@@ -4224,9 +4310,44 @@ This is a background-task completion, not a message from the user. If it needs n
4224
4310
  function knownTaskIds() {
4225
4311
  return [...tasks.values()].filter((t) => sameScope(t, scopeKey())).map((t) => t.id).join(", ") || "(none)";
4226
4312
  }
4313
+ let journalChecked = false;
4314
+ async function rehydrateJournaledTasks() {
4315
+ if (!messageId || journalChecked) return;
4316
+ journalChecked = true;
4317
+ const specs = await listBackgroundTaskJournalSpecs({ messageId });
4318
+ if (specs.length === 0) return;
4319
+ for (const spec of specs) {
4320
+ const live = tasks.get(spec.id);
4321
+ if (live && sameScope(live, scopeKey())) continue;
4322
+ log.info({
4323
+ taskId: spec.id,
4324
+ conversationId
4325
+ }, "relaunching journaled resumable bg task after restart");
4326
+ const meta = await launchTask({
4327
+ command: spec.command,
4328
+ description: spec.description,
4329
+ cwd: spec.cwd,
4330
+ resumable: true,
4331
+ resumedFromJournal: true,
4332
+ id: spec.id,
4333
+ startedAt: spec.startedAt
4334
+ });
4335
+ try {
4336
+ const stream = createWriteStream(logPath(meta.id), { flags: "a" });
4337
+ stream.write(`<resumed-after-restart>requeued and relaunched on a new sandbox after the previous one was drained; this is a fresh execution of the command from the start, not a continuation</resumed-after-restart>\n`);
4338
+ stream.end();
4339
+ } catch (err) {
4340
+ log.debug({
4341
+ err,
4342
+ taskId: meta.id
4343
+ }, "resume banner write failed");
4344
+ }
4345
+ }
4346
+ }
4227
4347
  pi.on("session_start", async () => {
4228
- if (tasks.size === 0) return;
4348
+ if (tasks.size === 0 && (!messageId || journalChecked)) return;
4229
4349
  await ensureConversationId();
4350
+ await rehydrateJournaledTasks();
4230
4351
  for (const [id, meta] of tasks) if (sameScope(meta, scopeKey()) && !meta.running && meta.notified) {
4231
4352
  tasks.delete(id);
4232
4353
  await unlink(logPath(id)).catch(() => {});
@@ -4285,24 +4406,27 @@ This is a background-task completion, not a message from the user. If it needs n
4285
4406
  const bgRun = {
4286
4407
  name: "bg_run",
4287
4408
  label: "Run in background",
4288
- description: "Run a bash command in the background. Returns immediately with a task id; output streams to a log file. You are sent a message when it finishes — keep working or end your turn meanwhile. Use for anything over a couple of minutes (builds, batch jobs, retry loops, downloads). Inspect with bg_status / bg_logs, stop with bg_kill.",
4289
- promptSnippet: "bg_run — run a long command without blocking; you are notified on completion",
4409
+ description: "Run a bash command in the background. Returns immediately with a task id; output streams to a log file. You are sent a message when it finishes — keep working or end your turn meanwhile. Use for anything over a couple of minutes (builds, batch jobs, retry loops, downloads). Inspect with bg_status / bg_logs, stop with bg_kill. Pass resumable:true ONLY for an idempotent, long-lived command (a poller/watcher/retry loop) that should be requeued and relaunched on your next run if the sandbox goes away before it finishes (a sandbox is drained and replaced with a fresh one, not restarted in place) — the requeue re-runs the command from scratch, so never mark a one-shot side-effecting job (a migration, an apply, a send) resumable.",
4410
+ promptSnippet: "bg_run — run a long command without blocking; you are notified on completion (resumable:true requeues the task onto a fresh sandbox if the current one is drained, idempotent commands only)",
4290
4411
  parameters: Type.Object({
4291
4412
  command: Type.String({ description: "Bash command to execute" }),
4292
- description: Type.Optional(Type.String({ description: "Clear, concise description of what this command does in active voice (2-6 words)." }))
4413
+ description: Type.Optional(Type.String({ description: "Clear, concise description of what this command does in active voice (2-6 words)." })),
4414
+ resumable: Type.Optional(Type.Boolean({ description: "When true, this task is requeued and relaunched on your next run if the sandbox it is running on goes away before it finishes (a sandbox is drained and replaced with a fresh one, rather than restarted in place, so an in-flight task would otherwise be lost). Use ONLY for idempotent, long-lived commands (pollers, watchers, retry loops) — the requeue re-runs the command from scratch on the new sandbox, so never set this on a one-shot side-effecting command." }))
4293
4415
  }),
4294
4416
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
4295
4417
  await ensureConversationId();
4296
- const { command, description = null } = params;
4418
+ const { command, description = null, resumable = false } = params;
4297
4419
  const meta = await launchTask({
4298
4420
  command,
4299
4421
  description,
4300
- cwd: ctx.cwd
4422
+ cwd: ctx.cwd,
4423
+ resumable
4301
4424
  });
4425
+ const resumeNote = resumable && meta.messageId ? "\nResumable: if this sandbox is drained before the task finishes, it will be requeued and relaunched from the start on your next run." : resumable ? "\nNote: resumable was requested but this session has no durable conversation, so it will NOT be requeued if the sandbox is drained." : "";
4302
4426
  return {
4303
4427
  content: [{
4304
4428
  type: "text",
4305
- text: `Started background task ${taskLabel(meta)}.\nlog: ${logPath(meta.id)}\nYou will get a message when it finishes. Check on it with bg_status {"taskId":"${meta.id}"}.`
4429
+ text: `Started background task ${taskLabel(meta)}.\nlog: ${logPath(meta.id)}\nYou will get a message when it finishes. Check on it with bg_status {"taskId":"${meta.id}"}.${resumeNote}`
4306
4430
  }],
4307
4431
  details: {}
4308
4432
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skydiveai/pi-extensions",
3
- "version": "0.1.0-beta.1602",
3
+ "version": "0.1.0-beta.1603",
4
4
  "homepage": "https://skydive.com",
5
5
  "license": "MIT",
6
6
  "author": "Create, Inc.",