pi-better-background-tasks 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/package.json +1 -1
- package/src/failures.ts +77 -3
- package/src/index.ts +5 -0
- package/src/navigator-provider.ts +2 -1
- package/src/output.ts +78 -48
- package/src/runtime.ts +173 -17
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +34 -3
- package/src/shared-failure-observations.ts +378 -47
- package/src/shared-log-utils.ts +114 -0
- package/src/shared-navigator.ts +12 -10
- package/src/shared-sandbox-core.ts +660 -17
- package/src/tools.ts +107 -32
- package/src/types.ts +7 -0
package/src/runtime.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./
|
|
|
8
8
|
import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
|
|
9
9
|
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
|
|
10
10
|
import { confineCommandSpec, resolveForegroundSandboxPlan } from "./sandbox.js";
|
|
11
|
-
import {
|
|
11
|
+
import { takeRecoverySnapshot } from "./shared-sandbox-core.js";
|
|
12
|
+
import { failurePath, readTaskIntent, recordExitFailure, recordFailure, recoverDeclaredOperation, recoverFailure, resumeFailureAttention, scheduleFailureAttention, stopFailureAttention, suspendFailureAttention, terminalFailureAttention } from "./failures.js";
|
|
12
13
|
import { markFailureAttentionDelivered } from "./shared-failure-observations.js";
|
|
13
14
|
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
14
15
|
import { formatCallbackFacts } from "./output.js";
|
|
@@ -33,16 +34,68 @@ const remoteSessionStarts = new Map<string, Promise<CommandResult>>();
|
|
|
33
34
|
const activePolls = new Set<string>();
|
|
34
35
|
const logRetentionTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
35
36
|
const LOG_RETENTION_CHECK_MS = 1000;
|
|
37
|
+
const handoffTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
38
|
+
const HANDOFF_CHECK_MS = 250;
|
|
39
|
+
const HANDOFF_LOST_GRACE_MS = 5_000;
|
|
40
|
+
let scheduledWorkSuspended = false;
|
|
36
41
|
const REMOTE_SESSION_POLL_MS = 100;
|
|
37
42
|
|
|
38
43
|
export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
|
|
39
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Stop this extension instance's scheduled work when its session shuts down (#324).
|
|
47
|
+
*
|
|
48
|
+
* Pi emits session_shutdown for /reload, /new, /resume, fork, session switch,
|
|
49
|
+
* and quit, and the next session loads a fresh extension instance. Work left in
|
|
50
|
+
* the old instance runs with no active session, so a task finishing there used to
|
|
51
|
+
* have its completion callback durably suppressed ("active session identity is
|
|
52
|
+
* unavailable") and the next session never heard about it.
|
|
53
|
+
*
|
|
54
|
+
* After suspension this instance schedules nothing and notifies nothing. Work
|
|
55
|
+
* already in flight (a child's close listener, a remote tmux bootstrap/start, a
|
|
56
|
+
* timeout kill) still records its result in metadata, but leaves the callback to
|
|
57
|
+
* the instance that resumes the task. Processes are not stopped.
|
|
58
|
+
*
|
|
59
|
+
* Resume is per callback origin: session_start resumes only tasks whose origin
|
|
60
|
+
* (cwd and session id) matches the new session. After /reload that is every task
|
|
61
|
+
* of the session. After switching to another session, the previous session's
|
|
62
|
+
* tasks keep running, but their watches do not poll, remote tmux output is not
|
|
63
|
+
* collected, and their `timeout_seconds` deadlines are not enforced until that
|
|
64
|
+
* session is active again; an overdue deadline is enforced immediately on resume.
|
|
65
|
+
* A local process that exits meanwhile is recorded as terminal and its callback
|
|
66
|
+
* is delivered when its session resumes.
|
|
67
|
+
*/
|
|
68
|
+
export function suspendScheduledWork(): void {
|
|
69
|
+
scheduledWorkSuspended = true;
|
|
70
|
+
for (const timer of watcherTimers.values()) clearTimeout(timer);
|
|
71
|
+
for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
|
|
72
|
+
for (const timer of processTimeoutTimers.values()) clearTimeout(timer);
|
|
73
|
+
for (const timer of logRetentionTimers.values()) clearInterval(timer);
|
|
74
|
+
for (const timer of handoffTimers.values()) clearInterval(timer);
|
|
75
|
+
handoffTimers.clear();
|
|
76
|
+
watcherTimers.clear();
|
|
77
|
+
remoteSessionTimers.clear();
|
|
78
|
+
processTimeoutTimers.clear();
|
|
79
|
+
logRetentionTimers.clear();
|
|
80
|
+
suspendFailureAttention();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Allow scheduling again; called at session_start before running tasks are resumed. */
|
|
84
|
+
export function resumeScheduledWork(): void {
|
|
85
|
+
scheduledWorkSuspended = false;
|
|
86
|
+
resumeFailureAttention();
|
|
87
|
+
}
|
|
88
|
+
|
|
40
89
|
|
|
41
90
|
|
|
42
91
|
export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
|
|
43
92
|
|
|
44
93
|
export interface SpawnTaskParams extends CommandSpec {
|
|
45
94
|
name?: string;
|
|
95
|
+
/** Structured intent (#325): stable id shared by modified retries of one operation. */
|
|
96
|
+
operation_id?: string | null;
|
|
97
|
+
/** Structured intent (#325): non-zero exit codes declared intentional before launch. */
|
|
98
|
+
expected_exit_codes?: number[] | null;
|
|
46
99
|
callback?: boolean;
|
|
47
100
|
timeout_seconds?: number;
|
|
48
101
|
max_log_bytes?: number;
|
|
@@ -52,6 +105,10 @@ export interface SpawnTaskParams extends CommandSpec {
|
|
|
52
105
|
|
|
53
106
|
export interface WatchTaskParams extends CommandSpec {
|
|
54
107
|
name?: string;
|
|
108
|
+
/** Structured intent (#325): stable id shared by modified retries of one operation. */
|
|
109
|
+
operation_id?: string | null;
|
|
110
|
+
/** Structured intent (#325): non-zero exit codes declared intentional before launch. */
|
|
111
|
+
expected_exit_codes?: number[] | null;
|
|
55
112
|
callback?: boolean;
|
|
56
113
|
interval_seconds?: number;
|
|
57
114
|
timeout_seconds?: number;
|
|
@@ -78,6 +135,7 @@ export function spawnTask(
|
|
|
78
135
|
): BackgroundTaskMeta {
|
|
79
136
|
// Resolved before any task directory, log, or metadata exists so a blocked
|
|
80
137
|
// launch leaves nothing behind. Structured SSH must also honor launch restrictions.
|
|
138
|
+
const intent = readTaskIntent(params);
|
|
81
139
|
const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
|
|
82
140
|
const id = nextTaskId();
|
|
83
141
|
const cwd = params.cwd ?? defaultCwd;
|
|
@@ -99,11 +157,21 @@ export function spawnTask(
|
|
|
99
157
|
? commandSpec
|
|
100
158
|
: confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id));
|
|
101
159
|
const tmuxBacked = remoteTask?.metadata.remote.session === "tmux";
|
|
160
|
+
// Outside project = Write or Write & delete can change files outside the
|
|
161
|
+
// project: start an APFS local snapshot (macOS, background, never blocks).
|
|
162
|
+
const snapshot = !remoteTask && sandboxPlan.confined ? takeRecoverySnapshot(sandboxPlan.permissions) : undefined;
|
|
102
163
|
const spawned = tmuxBacked
|
|
103
164
|
? undefined
|
|
104
165
|
: remoteTask
|
|
105
166
|
? remoteTask.spawn(logPath, true)
|
|
106
167
|
: spawnCommand(launchSpec, logPath, true);
|
|
168
|
+
if (snapshot?.started) {
|
|
169
|
+
void snapshot.done.then((outcome) => {
|
|
170
|
+
if (!outcome.ok) {
|
|
171
|
+
try { appendLine(logPath, `--- recovery snapshot failed (task continues): ${outcome.detail} ---`); } catch { /* log gone */ }
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
107
175
|
const now = Date.now();
|
|
108
176
|
const meta: BackgroundTaskMeta = {
|
|
109
177
|
id,
|
|
@@ -130,6 +198,7 @@ export function spawnTask(
|
|
|
130
198
|
spawnPidStartTime: currentProcessStartToken(),
|
|
131
199
|
ssh: remoteTask?.metadata.ssh,
|
|
132
200
|
remote: remoteTask?.metadata.remote,
|
|
201
|
+
...intent,
|
|
133
202
|
};
|
|
134
203
|
writeMeta(meta);
|
|
135
204
|
scheduleLogRetention(id);
|
|
@@ -146,8 +215,11 @@ export function spawnTask(
|
|
|
146
215
|
if (!latest) return;
|
|
147
216
|
enforceLogRetention(latest);
|
|
148
217
|
if (isTerminalStatus(latest.status)) return;
|
|
149
|
-
if (exitCode !== 0)
|
|
150
|
-
else
|
|
218
|
+
if (exitCode !== 0) recordExitFailure(latest, "execution", `Process exited with code ${exitCode ?? "unknown"}${signal ? ` (${signal})` : ""}`, exitCode, "close", { at: Date.now() });
|
|
219
|
+
else {
|
|
220
|
+
recoverFailure(latest, "execution", "close");
|
|
221
|
+
recoverDeclaredOperation(latest);
|
|
222
|
+
}
|
|
151
223
|
latest.status = exitCode === 0 ? "succeeded" : "failed";
|
|
152
224
|
latest.endedAt = Date.now();
|
|
153
225
|
latest.lastExitCode = exitCode;
|
|
@@ -227,6 +299,7 @@ function scheduleRemoteSessionPoll(
|
|
|
227
299
|
getActiveSession?: ActiveSessionProvider,
|
|
228
300
|
): void {
|
|
229
301
|
clearRemoteSessionTimer(id);
|
|
302
|
+
if (scheduledWorkSuspended) return;
|
|
230
303
|
const timer = setTimeout(() => void pollRemoteSession(pi, id, getActiveSession), delayMs);
|
|
231
304
|
timer.unref();
|
|
232
305
|
remoteSessionTimers.set(id, timer);
|
|
@@ -250,6 +323,7 @@ async function pollRemoteSession(
|
|
|
250
323
|
const remoteTask = activeRemoteTasks.get(id);
|
|
251
324
|
if (!meta || meta.status !== "running" || meta.remote?.session !== "tmux" || !remoteTask) return;
|
|
252
325
|
const poll = await remoteTask.pollTmuxSession(meta.remote.logOffset ?? 0, remainingDeadlineMs(meta.deadlineAt));
|
|
326
|
+
if (scheduledWorkSuspended) return;
|
|
253
327
|
appendTaskOutput(meta.logPath, poll.output);
|
|
254
328
|
if (poll.status === "timed_out") {
|
|
255
329
|
clearProcessTimeout(id);
|
|
@@ -280,7 +354,7 @@ async function pollRemoteSession(
|
|
|
280
354
|
}
|
|
281
355
|
const commandResult = { ...poll.commandResult, exitCode: poll.status, stdout: poll.output };
|
|
282
356
|
latest.lastExitCode = poll.status;
|
|
283
|
-
if (poll.status !== 0)
|
|
357
|
+
if (poll.status !== 0) recordExitFailure(latest, "execution", `Remote command exited with code ${poll.status}`, poll.status, "exit");
|
|
284
358
|
else recoverFailure(latest, "execution", "exit");
|
|
285
359
|
finalize(latest, {
|
|
286
360
|
status: poll.status === 0 ? "succeeded" : "failed",
|
|
@@ -326,6 +400,7 @@ export function startWatchTask(
|
|
|
326
400
|
const error = condition && validateCondition(condition);
|
|
327
401
|
if (error) throw new Error(`${name}: ${error}`);
|
|
328
402
|
}
|
|
403
|
+
const intent = readTaskIntent(params);
|
|
329
404
|
const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
|
|
330
405
|
const id = nextTaskId();
|
|
331
406
|
const cwd = params.cwd ?? defaultCwd;
|
|
@@ -371,6 +446,7 @@ export function startWatchTask(
|
|
|
371
446
|
notifyOn: "terminal",
|
|
372
447
|
ssh: remoteTask?.metadata.ssh,
|
|
373
448
|
remote: remoteTask?.metadata.remote,
|
|
449
|
+
...intent,
|
|
374
450
|
};
|
|
375
451
|
ensureTaskDir(id);
|
|
376
452
|
appendLine(meta.logPath, `--- watch ${new Date(now).toISOString()} interval_ms=${meta.intervalMs} ---`);
|
|
@@ -399,6 +475,9 @@ export function resumeRunningTask(
|
|
|
399
475
|
}
|
|
400
476
|
scheduleFailureAttention(pi, meta.id, getActiveSession);
|
|
401
477
|
|
|
478
|
+
// Same Pi process: an earlier (reloaded or switched-away) extension instance may
|
|
479
|
+
// still hold this task's child or in-flight remote work and record its result.
|
|
480
|
+
const ownedByThisProcess = meta.spawnPid === process.pid && meta.spawnPidStartTime === currentProcessStartToken();
|
|
402
481
|
if (meta.spawnPid !== process.pid || meta.spawnPidStartTime !== currentProcessStartToken()) {
|
|
403
482
|
meta.spawnPid = process.pid;
|
|
404
483
|
meta.spawnPidStartTime = currentProcessStartToken();
|
|
@@ -418,22 +497,86 @@ export function resumeRunningTask(
|
|
|
418
497
|
activeRemoteTasks.set(meta.id, remoteTask);
|
|
419
498
|
scheduleRemoteSessionPoll(pi, meta.id, 0, getActiveSession);
|
|
420
499
|
if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
|
|
500
|
+
// An earlier instance's in-flight timeout kill may still finalize it.
|
|
501
|
+
if (ownedByThisProcess) scheduleHandoff(pi, meta.id, getActiveSession);
|
|
421
502
|
return meta;
|
|
422
503
|
}
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
meta.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
writeMeta(meta);
|
|
430
|
-
void notifyTerminal(pi, meta, getActiveSession);
|
|
504
|
+
if (ownedByThisProcess) {
|
|
505
|
+
// A dead pid is not yet "lost" here: the earlier instance's close listener may
|
|
506
|
+
// still record the real exit. The handoff marks it lost after a grace period.
|
|
507
|
+
scheduleHandoff(pi, meta.id, getActiveSession, remoteTask);
|
|
508
|
+
} else if (meta.pid && !processExists(meta.pid)) {
|
|
509
|
+
markProcessLost(pi, meta, getActiveSession);
|
|
431
510
|
return meta;
|
|
432
511
|
}
|
|
433
512
|
if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
|
|
434
513
|
return meta;
|
|
435
514
|
}
|
|
436
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Watch a task whose driver may still be an earlier instance in this process: a
|
|
518
|
+
* local child's close listener, or a remote tmux launch that was mid-bootstrap.
|
|
519
|
+
* Deliver the completion once it is recorded terminal, and take over remote
|
|
520
|
+
* polling once the tmux session is recorded as started (#324).
|
|
521
|
+
*/
|
|
522
|
+
function scheduleHandoff(
|
|
523
|
+
pi: ExtensionAPI,
|
|
524
|
+
id: string,
|
|
525
|
+
getActiveSession?: ActiveSessionProvider,
|
|
526
|
+
remoteTask?: ResolvedSshRemoteTask,
|
|
527
|
+
): void {
|
|
528
|
+
stopHandoff(id);
|
|
529
|
+
if (scheduledWorkSuspended) return;
|
|
530
|
+
let deadSince: number | undefined;
|
|
531
|
+
const timer = setInterval(() => {
|
|
532
|
+
const meta = readMeta(id);
|
|
533
|
+
if (!meta) {
|
|
534
|
+
stopHandoff(id);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (meta.status !== "running") {
|
|
538
|
+
stopHandoff(id);
|
|
539
|
+
clearProcessTimeout(id);
|
|
540
|
+
stopLogRetention(id);
|
|
541
|
+
void notifyTerminal(pi, meta, getActiveSession);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (meta.remote?.session === "tmux" && meta.remote.sessionStarted === true && remoteTask && !activeRemoteTasks.has(id)) {
|
|
545
|
+
stopHandoff(id);
|
|
546
|
+
activeRemoteTasks.set(id, remoteTask);
|
|
547
|
+
scheduleRemoteSessionPoll(pi, id, 0, getActiveSession);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (meta.remote?.session !== "tmux" && meta.pid && !processExists(meta.pid)) {
|
|
551
|
+
deadSince ??= Date.now();
|
|
552
|
+
if (Date.now() - deadSince >= HANDOFF_LOST_GRACE_MS) {
|
|
553
|
+
stopHandoff(id);
|
|
554
|
+
clearProcessTimeout(id);
|
|
555
|
+
stopLogRetention(id);
|
|
556
|
+
markProcessLost(pi, meta, getActiveSession);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}, HANDOFF_CHECK_MS);
|
|
560
|
+
timer.unref();
|
|
561
|
+
handoffTimers.set(id, timer);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function stopHandoff(id: string): void {
|
|
565
|
+
const timer = handoffTimers.get(id);
|
|
566
|
+
if (timer) clearInterval(timer);
|
|
567
|
+
handoffTimers.delete(id);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function markProcessLost(pi: ExtensionAPI, meta: BackgroundTaskMeta, getActiveSession?: ActiveSessionProvider): void {
|
|
571
|
+
meta.status = "failed";
|
|
572
|
+
meta.endedAt = Date.now();
|
|
573
|
+
meta.error = "process is no longer alive; exit result was not captured by this pi session";
|
|
574
|
+
meta.result = { reason: meta.error };
|
|
575
|
+
recordFailure(meta, "execution", meta.error, "lost", { incomplete: true });
|
|
576
|
+
writeMeta(meta);
|
|
577
|
+
void notifyTerminal(pi, meta, getActiveSession);
|
|
578
|
+
}
|
|
579
|
+
|
|
437
580
|
function resolvePersistedRemoteTask(
|
|
438
581
|
meta: BackgroundTaskMeta,
|
|
439
582
|
remoteRunner?: RemoteRunner,
|
|
@@ -550,6 +693,7 @@ function scheduleWatch(
|
|
|
550
693
|
runOnce?: WatchPollRunner,
|
|
551
694
|
): void {
|
|
552
695
|
clearWatchTimer(id);
|
|
696
|
+
if (scheduledWorkSuspended) return;
|
|
553
697
|
const timer = setTimeout(() => void pollWatch(pi, id, getActiveSession, runOnce), delayMs);
|
|
554
698
|
timer.unref();
|
|
555
699
|
watcherTimers.set(id, timer);
|
|
@@ -581,6 +725,8 @@ async function pollWatch(
|
|
|
581
725
|
const result = runOnce
|
|
582
726
|
? await runOnce(timeoutMs)
|
|
583
727
|
: await runCommandOnce(commandSpecFromMeta(meta), undefined, timeoutMs);
|
|
728
|
+
// A poll that was in flight when the session shut down belongs to a stale instance.
|
|
729
|
+
if (scheduledWorkSuspended) return;
|
|
584
730
|
appendWatchResult(meta.logPath, result);
|
|
585
731
|
const latest = readMeta(id);
|
|
586
732
|
if (!latest || latest.status !== "running") return;
|
|
@@ -604,8 +750,8 @@ async function pollWatch(
|
|
|
604
750
|
const error = condition && validateCondition(condition);
|
|
605
751
|
if (error) {
|
|
606
752
|
latest.error = `${name}: ${error}`;
|
|
607
|
-
if (result.exitCode !== 0)
|
|
608
|
-
{
|
|
753
|
+
if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
|
|
754
|
+
{ at: result.endedAt });
|
|
609
755
|
recordFailure(latest, name, latest.error, pollKey, { incomplete: true, at: result.endedAt });
|
|
610
756
|
finalize(latest, { status: "failed", reason: latest.error, commandResult: result }, pi, getActiveSession);
|
|
611
757
|
return;
|
|
@@ -628,8 +774,8 @@ async function pollWatch(
|
|
|
628
774
|
success?.matched === true && latest.successWhen?.type === "exit_code";
|
|
629
775
|
if (expectedPollExit) recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
|
|
630
776
|
if (transportFailure) recordFailure(latest, "watch-poll", transportFailure, pollKey, { category: "ssh", at: result.endedAt });
|
|
631
|
-
else if (result.exitCode !== 0)
|
|
632
|
-
{
|
|
777
|
+
else if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
|
|
778
|
+
{ expected: expectedPollExit, at: result.endedAt });
|
|
633
779
|
else recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
|
|
634
780
|
if (conditionErrors.length) latest.error = conditionErrors.join("; ");
|
|
635
781
|
if (failure?.matched) {
|
|
@@ -692,6 +838,7 @@ function finalize(
|
|
|
692
838
|
): void {
|
|
693
839
|
stopFailureAttention(meta.id);
|
|
694
840
|
if (terminal.status === "timed_out") recordFailure(meta, "timeout", terminal.reason, "deadline", { category: "timeout" });
|
|
841
|
+
if (terminal.status === "succeeded") recoverDeclaredOperation(meta);
|
|
695
842
|
meta.status = terminal.status;
|
|
696
843
|
meta.endedAt = Date.now();
|
|
697
844
|
meta.result = {
|
|
@@ -750,6 +897,7 @@ function scheduleProcessTimeout(
|
|
|
750
897
|
getActiveSession?: ActiveSessionProvider,
|
|
751
898
|
): void {
|
|
752
899
|
clearProcessTimeout(id);
|
|
900
|
+
if (scheduledWorkSuspended) return;
|
|
753
901
|
const delay = Math.max(0, deadlineAt - Date.now());
|
|
754
902
|
const timer = setTimeout(() => void timeoutProcess(pi, id, getActiveSession), delay);
|
|
755
903
|
timer.unref();
|
|
@@ -839,6 +987,9 @@ async function notifyTerminal(
|
|
|
839
987
|
meta: BackgroundTaskMeta,
|
|
840
988
|
getActiveSession?: ActiveSessionProvider,
|
|
841
989
|
): Promise<void> {
|
|
990
|
+
// A suspended instance has no active session: delivering or suppressing here would
|
|
991
|
+
// be wrong either way. The instance that resumes the task delivers it (#324).
|
|
992
|
+
if (scheduledWorkSuspended) return;
|
|
842
993
|
if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
|
|
843
994
|
const latest = readMeta(meta.id) ?? meta;
|
|
844
995
|
if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
|
|
@@ -858,7 +1009,11 @@ async function notifyTerminal(
|
|
|
858
1009
|
source: "background-task",
|
|
859
1010
|
id: latest.id,
|
|
860
1011
|
label,
|
|
861
|
-
|
|
1012
|
+
// Lifecycle plus an attention count; the incident text rides failureRows
|
|
1013
|
+
// with exact counts, so the status field is never a clipped summary (#323).
|
|
1014
|
+
status: pending
|
|
1015
|
+
? `${latest.status}; ${pending.incidents.length} incident${pending.incidents.length === 1 ? "" : "s"} need${pending.incidents.length === 1 ? "s" : ""} attention`
|
|
1016
|
+
: latest.status,
|
|
862
1017
|
detailTool: "bg_task_status",
|
|
863
1018
|
outcome: facts.outcome,
|
|
864
1019
|
failureRows: facts.failureRows,
|
|
@@ -939,6 +1094,7 @@ function launchArgvOf(commandSpec: CommandSpec, launchSpec: CommandSpec): string
|
|
|
939
1094
|
|
|
940
1095
|
function scheduleLogRetention(id: string): void {
|
|
941
1096
|
stopLogRetention(id);
|
|
1097
|
+
if (scheduledWorkSuspended) return;
|
|
942
1098
|
const timer = setInterval(() => {
|
|
943
1099
|
const meta = readMeta(id);
|
|
944
1100
|
if (!meta || meta.status !== "running" || meta.kind !== "process") {
|
package/src/sandbox.ts
CHANGED
|
@@ -32,8 +32,9 @@ import { baseDir } from "./registry.js";
|
|
|
32
32
|
import { compileWritePolicy, maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
|
|
33
33
|
|
|
34
34
|
type SandboxPermissions = {
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
/** `write` = read, create and overwrite in place; `read-write` = Write & delete. */
|
|
36
|
+
projectFiles: "off" | "read" | "write" | "read-write";
|
|
37
|
+
outsideProject: "off" | "read" | "write" | "read-write";
|
|
37
38
|
storedCredentials: "off" | "read" | "read-write";
|
|
38
39
|
commands: boolean;
|
|
39
40
|
network: boolean;
|
|
@@ -160,10 +161,11 @@ function readPolicy(data: unknown): ForegroundSandboxPolicy | undefined {
|
|
|
160
161
|
function readPermissions(value: unknown): ForegroundSandboxPolicy["permissions"] | undefined {
|
|
161
162
|
if (!value || typeof value !== "object") return undefined;
|
|
162
163
|
const p = value as Record<string, unknown>;
|
|
163
|
-
const
|
|
164
|
+
const credential = (v: unknown) => v === "off" || v === "read" || v === "read-write";
|
|
165
|
+
const access = (v: unknown) => credential(v) || v === "write";
|
|
164
166
|
if (typeof p.enabled !== "boolean" || typeof p.commands !== "boolean" ||
|
|
165
167
|
typeof p.network !== "boolean" || !access(p.projectFiles) ||
|
|
166
|
-
!access(p.outsideProject) || !
|
|
168
|
+
!access(p.outsideProject) || !credential(p.storedCredentials)) return undefined;
|
|
167
169
|
return Object.freeze({
|
|
168
170
|
enabled: p.enabled, commands: p.commands, network: p.network,
|
|
169
171
|
projectFiles: p.projectFiles as SandboxPermissions["projectFiles"],
|
|
@@ -102,7 +102,11 @@ const DEFAULT_WINDOW_MS = 100;
|
|
|
102
102
|
const DEFAULT_RETRY_MS = 1_000;
|
|
103
103
|
const MAX_LABEL_BYTES = 160;
|
|
104
104
|
const MAX_ID_BYTES = 200;
|
|
105
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Status field bound. Long statuses keep whole `; `-separated notes and say
|
|
107
|
+
* how many were left out, instead of cutting mid-word (#323).
|
|
108
|
+
*/
|
|
109
|
+
const MAX_STATUS_BYTES = 160;
|
|
106
110
|
const MAX_FAILURE_BYTES = 400;
|
|
107
111
|
const MAX_DECISION_BYTES = 400;
|
|
108
112
|
const encoder = new TextEncoder();
|
|
@@ -169,6 +173,33 @@ function boundedField(value: unknown, maxBytes: number): string {
|
|
|
169
173
|
return `${clipUtf8Prefix(oneLine, Math.max(0, maxBytes - utf8ByteLength(ellipsis)))}${ellipsis}`;
|
|
170
174
|
}
|
|
171
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The status field under MAX_STATUS_BYTES without losing meaning: the leading
|
|
178
|
+
* lifecycle note is always kept, later `; `-separated notes are kept whole
|
|
179
|
+
* while they fit, and anything left out is named with a count and the inspect
|
|
180
|
+
* tool rather than an ellipsis.
|
|
181
|
+
*/
|
|
182
|
+
function boundedStatus(value: unknown): string {
|
|
183
|
+
const status = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
184
|
+
if (utf8ByteLength(status) <= MAX_STATUS_BYTES) return status;
|
|
185
|
+
const notes = status.split(/;\s+/).filter(Boolean);
|
|
186
|
+
const omission = (count: number) => ` (+${count} more status note${count === 1 ? "" : "s"}; see inspect)`;
|
|
187
|
+
const reserve = utf8ByteLength(omission(notes.length));
|
|
188
|
+
let kept = notes[0] ?? "";
|
|
189
|
+
if (utf8ByteLength(kept) + reserve > MAX_STATUS_BYTES) {
|
|
190
|
+
const suffix = " (clipped; see inspect)";
|
|
191
|
+
return `${clipUtf8Prefix(kept, MAX_STATUS_BYTES - utf8ByteLength(suffix))}${suffix}`;
|
|
192
|
+
}
|
|
193
|
+
let count = 1;
|
|
194
|
+
for (const note of notes.slice(1)) {
|
|
195
|
+
const next = `${kept}; ${note}`;
|
|
196
|
+
if (utf8ByteLength(next) + reserve > MAX_STATUS_BYTES) break;
|
|
197
|
+
kept = next;
|
|
198
|
+
count += 1;
|
|
199
|
+
}
|
|
200
|
+
return count < notes.length ? `${kept}${omission(notes.length - count)}` : kept;
|
|
201
|
+
}
|
|
202
|
+
|
|
172
203
|
function inspectFor(event: Pick<CallbackBatchEvent, "id" | "detailTool">): string {
|
|
173
204
|
const id = boundedField(event.id, MAX_ID_BYTES);
|
|
174
205
|
return event.detailTool === "bg_task_status"
|
|
@@ -232,7 +263,7 @@ function formatRow(event: CallbackBatchEvent, detailBytes = MAX_FAILURE_BYTES +
|
|
|
232
263
|
const source = boundedField(event.source, 40);
|
|
233
264
|
const id = boundedField(event.id, MAX_ID_BYTES);
|
|
234
265
|
const label = boundedField(event.label, MAX_LABEL_BYTES);
|
|
235
|
-
const status =
|
|
266
|
+
const status = boundedStatus(event.status);
|
|
236
267
|
const inspect = inspectFor(event);
|
|
237
268
|
const lines = [
|
|
238
269
|
`- source=${source} | id=${id} | label=${JSON.stringify(label)} | status=${status} | inspect: ${inspect}`,
|
|
@@ -291,7 +322,7 @@ export function formatUrgentCallback(
|
|
|
291
322
|
const target = event.inspectId ?? event.id;
|
|
292
323
|
const id = boundedField(target, MAX_ID_BYTES);
|
|
293
324
|
const label = boundedField(event.label, MAX_LABEL_BYTES);
|
|
294
|
-
const status =
|
|
325
|
+
const status = boundedStatus(event.status);
|
|
295
326
|
const tool = event.detailTool
|
|
296
327
|
?? (event.source === "background-task" ? "bg_task_status" : "subagent_result");
|
|
297
328
|
const inspectTarget = tool === "bg_task_status" ? `bg_task_status id=${id}` : `subagent_result id=${JSON.stringify(id)}`;
|