@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.
- package/dist/index.mjs +145 -21
- 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
|
-
* **
|
|
3864
|
-
*
|
|
3865
|
-
* respawn,
|
|
3866
|
-
*
|
|
3867
|
-
*
|
|
3868
|
-
*
|
|
3869
|
-
*
|
|
3870
|
-
*
|
|
3871
|
-
*
|
|
3872
|
-
*
|
|
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
|
};
|