pi-better-background-tasks 0.3.0 → 0.5.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 +43 -0
- package/package.json +1 -1
- package/src/failures.ts +85 -3
- package/src/index.ts +5 -0
- package/src/navigator-provider.ts +11 -7
- package/src/output.ts +83 -49
- package/src/runtime.ts +189 -17
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +34 -3
- package/src/shared-failure-observations.ts +414 -47
- package/src/shared-log-utils.ts +108 -0
- package/src/shared-navigator.ts +180 -134
- package/src/shared-sandbox-core.ts +724 -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,72 @@ 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 setTimeout>>();
|
|
38
|
+
// The handoff check backs off from 250 ms to 4 s while the task keeps running,
|
|
39
|
+
// and returns to 250 ms once its process is gone so the grace period is timed closely.
|
|
40
|
+
const HANDOFF_CHECK_MS = 250;
|
|
41
|
+
const HANDOFF_MAX_CHECK_MS = 4_000;
|
|
42
|
+
const HANDOFF_LOST_GRACE_MS = 5_000;
|
|
43
|
+
let scheduledWorkSuspended = false;
|
|
36
44
|
const REMOTE_SESSION_POLL_MS = 100;
|
|
37
45
|
|
|
38
46
|
export const DEFAULT_WATCH_TIMEOUT_SECONDS = 15 * 60;
|
|
39
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Stop this extension instance's scheduled work when its session shuts down (#324).
|
|
50
|
+
*
|
|
51
|
+
* Pi emits session_shutdown for /reload, /new, /resume, fork, session switch,
|
|
52
|
+
* and quit, and the next session loads a fresh extension instance. Work left in
|
|
53
|
+
* the old instance runs with no active session, so a task finishing there used to
|
|
54
|
+
* have its completion callback durably suppressed ("active session identity is
|
|
55
|
+
* unavailable") and the next session never heard about it.
|
|
56
|
+
*
|
|
57
|
+
* After suspension this instance schedules nothing and notifies nothing. Work
|
|
58
|
+
* already in flight (a child's close listener, a remote tmux bootstrap/start, a
|
|
59
|
+
* timeout kill) still records its result in metadata, but leaves the callback to
|
|
60
|
+
* the instance that resumes the task. Processes are not stopped.
|
|
61
|
+
*
|
|
62
|
+
* Resume is per callback origin: session_start resumes only tasks whose origin
|
|
63
|
+
* (cwd and session id) matches the new session. After /reload that is every task
|
|
64
|
+
* of the session. After switching to another session, the previous session's
|
|
65
|
+
* tasks keep running, but their watches do not poll, remote tmux output is not
|
|
66
|
+
* collected, and their `timeout_seconds` deadlines are not enforced until that
|
|
67
|
+
* session is active again; an overdue deadline is enforced immediately on resume.
|
|
68
|
+
* A local process that exits meanwhile, while this Pi process is still running,
|
|
69
|
+
* is recorded as terminal and its callback is delivered when its session resumes;
|
|
70
|
+
* after Pi quits, a resumed task whose process is gone is marked lost instead.
|
|
71
|
+
*/
|
|
72
|
+
export function suspendScheduledWork(): void {
|
|
73
|
+
scheduledWorkSuspended = true;
|
|
74
|
+
for (const timer of watcherTimers.values()) clearTimeout(timer);
|
|
75
|
+
for (const timer of remoteSessionTimers.values()) clearTimeout(timer);
|
|
76
|
+
for (const timer of processTimeoutTimers.values()) clearTimeout(timer);
|
|
77
|
+
for (const timer of logRetentionTimers.values()) clearInterval(timer);
|
|
78
|
+
for (const timer of handoffTimers.values()) clearTimeout(timer);
|
|
79
|
+
handoffTimers.clear();
|
|
80
|
+
watcherTimers.clear();
|
|
81
|
+
remoteSessionTimers.clear();
|
|
82
|
+
processTimeoutTimers.clear();
|
|
83
|
+
logRetentionTimers.clear();
|
|
84
|
+
suspendFailureAttention();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Allow scheduling again; called at session_start before running tasks are resumed. */
|
|
88
|
+
export function resumeScheduledWork(): void {
|
|
89
|
+
scheduledWorkSuspended = false;
|
|
90
|
+
resumeFailureAttention();
|
|
91
|
+
}
|
|
92
|
+
|
|
40
93
|
|
|
41
94
|
|
|
42
95
|
export type ActiveSessionProvider = () => BackgroundTaskCallbackOrigin | undefined;
|
|
43
96
|
|
|
44
97
|
export interface SpawnTaskParams extends CommandSpec {
|
|
45
98
|
name?: string;
|
|
99
|
+
/** Structured intent (#325): stable id shared by modified retries of one operation. */
|
|
100
|
+
operation_id?: string | null;
|
|
101
|
+
/** Structured intent (#325): non-zero exit codes declared intentional before launch. */
|
|
102
|
+
expected_exit_codes?: number[] | null;
|
|
46
103
|
callback?: boolean;
|
|
47
104
|
timeout_seconds?: number;
|
|
48
105
|
max_log_bytes?: number;
|
|
@@ -52,6 +109,10 @@ export interface SpawnTaskParams extends CommandSpec {
|
|
|
52
109
|
|
|
53
110
|
export interface WatchTaskParams extends CommandSpec {
|
|
54
111
|
name?: string;
|
|
112
|
+
/** Structured intent (#325): stable id shared by modified retries of one operation. */
|
|
113
|
+
operation_id?: string | null;
|
|
114
|
+
/** Structured intent (#325): non-zero exit codes declared intentional before launch. */
|
|
115
|
+
expected_exit_codes?: number[] | null;
|
|
55
116
|
callback?: boolean;
|
|
56
117
|
interval_seconds?: number;
|
|
57
118
|
timeout_seconds?: number;
|
|
@@ -78,6 +139,7 @@ export function spawnTask(
|
|
|
78
139
|
): BackgroundTaskMeta {
|
|
79
140
|
// Resolved before any task directory, log, or metadata exists so a blocked
|
|
80
141
|
// launch leaves nothing behind. Structured SSH must also honor launch restrictions.
|
|
142
|
+
const intent = readTaskIntent(params);
|
|
81
143
|
const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
|
|
82
144
|
const id = nextTaskId();
|
|
83
145
|
const cwd = params.cwd ?? defaultCwd;
|
|
@@ -99,11 +161,21 @@ export function spawnTask(
|
|
|
99
161
|
? commandSpec
|
|
100
162
|
: confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id));
|
|
101
163
|
const tmuxBacked = remoteTask?.metadata.remote.session === "tmux";
|
|
164
|
+
// Outside project = Write or Write & delete can change files outside the
|
|
165
|
+
// project: start an APFS local snapshot (macOS, background, never blocks).
|
|
166
|
+
const snapshot = !remoteTask && sandboxPlan.confined ? takeRecoverySnapshot(sandboxPlan.permissions) : undefined;
|
|
102
167
|
const spawned = tmuxBacked
|
|
103
168
|
? undefined
|
|
104
169
|
: remoteTask
|
|
105
170
|
? remoteTask.spawn(logPath, true)
|
|
106
171
|
: spawnCommand(launchSpec, logPath, true);
|
|
172
|
+
if (snapshot?.started) {
|
|
173
|
+
void snapshot.done.then((outcome) => {
|
|
174
|
+
if (!outcome.ok) {
|
|
175
|
+
try { appendLine(logPath, `--- recovery snapshot failed (task continues): ${outcome.detail} ---`); } catch { /* log gone */ }
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
107
179
|
const now = Date.now();
|
|
108
180
|
const meta: BackgroundTaskMeta = {
|
|
109
181
|
id,
|
|
@@ -130,6 +202,7 @@ export function spawnTask(
|
|
|
130
202
|
spawnPidStartTime: currentProcessStartToken(),
|
|
131
203
|
ssh: remoteTask?.metadata.ssh,
|
|
132
204
|
remote: remoteTask?.metadata.remote,
|
|
205
|
+
...intent,
|
|
133
206
|
};
|
|
134
207
|
writeMeta(meta);
|
|
135
208
|
scheduleLogRetention(id);
|
|
@@ -146,8 +219,11 @@ export function spawnTask(
|
|
|
146
219
|
if (!latest) return;
|
|
147
220
|
enforceLogRetention(latest);
|
|
148
221
|
if (isTerminalStatus(latest.status)) return;
|
|
149
|
-
if (exitCode !== 0)
|
|
150
|
-
else
|
|
222
|
+
if (exitCode !== 0) recordExitFailure(latest, "execution", `Process exited with code ${exitCode ?? "unknown"}${signal ? ` (${signal})` : ""}`, exitCode, "close", { at: Date.now() });
|
|
223
|
+
else {
|
|
224
|
+
recoverFailure(latest, "execution", "close");
|
|
225
|
+
recoverDeclaredOperation(latest);
|
|
226
|
+
}
|
|
151
227
|
latest.status = exitCode === 0 ? "succeeded" : "failed";
|
|
152
228
|
latest.endedAt = Date.now();
|
|
153
229
|
latest.lastExitCode = exitCode;
|
|
@@ -227,6 +303,7 @@ function scheduleRemoteSessionPoll(
|
|
|
227
303
|
getActiveSession?: ActiveSessionProvider,
|
|
228
304
|
): void {
|
|
229
305
|
clearRemoteSessionTimer(id);
|
|
306
|
+
if (scheduledWorkSuspended) return;
|
|
230
307
|
const timer = setTimeout(() => void pollRemoteSession(pi, id, getActiveSession), delayMs);
|
|
231
308
|
timer.unref();
|
|
232
309
|
remoteSessionTimers.set(id, timer);
|
|
@@ -250,6 +327,7 @@ async function pollRemoteSession(
|
|
|
250
327
|
const remoteTask = activeRemoteTasks.get(id);
|
|
251
328
|
if (!meta || meta.status !== "running" || meta.remote?.session !== "tmux" || !remoteTask) return;
|
|
252
329
|
const poll = await remoteTask.pollTmuxSession(meta.remote.logOffset ?? 0, remainingDeadlineMs(meta.deadlineAt));
|
|
330
|
+
if (scheduledWorkSuspended) return;
|
|
253
331
|
appendTaskOutput(meta.logPath, poll.output);
|
|
254
332
|
if (poll.status === "timed_out") {
|
|
255
333
|
clearProcessTimeout(id);
|
|
@@ -280,7 +358,7 @@ async function pollRemoteSession(
|
|
|
280
358
|
}
|
|
281
359
|
const commandResult = { ...poll.commandResult, exitCode: poll.status, stdout: poll.output };
|
|
282
360
|
latest.lastExitCode = poll.status;
|
|
283
|
-
if (poll.status !== 0)
|
|
361
|
+
if (poll.status !== 0) recordExitFailure(latest, "execution", `Remote command exited with code ${poll.status}`, poll.status, "exit");
|
|
284
362
|
else recoverFailure(latest, "execution", "exit");
|
|
285
363
|
finalize(latest, {
|
|
286
364
|
status: poll.status === 0 ? "succeeded" : "failed",
|
|
@@ -326,6 +404,7 @@ export function startWatchTask(
|
|
|
326
404
|
const error = condition && validateCondition(condition);
|
|
327
405
|
if (error) throw new Error(`${name}: ${error}`);
|
|
328
406
|
}
|
|
407
|
+
const intent = readTaskIntent(params);
|
|
329
408
|
const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
|
|
330
409
|
const id = nextTaskId();
|
|
331
410
|
const cwd = params.cwd ?? defaultCwd;
|
|
@@ -371,6 +450,7 @@ export function startWatchTask(
|
|
|
371
450
|
notifyOn: "terminal",
|
|
372
451
|
ssh: remoteTask?.metadata.ssh,
|
|
373
452
|
remote: remoteTask?.metadata.remote,
|
|
453
|
+
...intent,
|
|
374
454
|
};
|
|
375
455
|
ensureTaskDir(id);
|
|
376
456
|
appendLine(meta.logPath, `--- watch ${new Date(now).toISOString()} interval_ms=${meta.intervalMs} ---`);
|
|
@@ -399,6 +479,9 @@ export function resumeRunningTask(
|
|
|
399
479
|
}
|
|
400
480
|
scheduleFailureAttention(pi, meta.id, getActiveSession);
|
|
401
481
|
|
|
482
|
+
// Same Pi process: an earlier (reloaded or switched-away) extension instance may
|
|
483
|
+
// still hold this task's child or in-flight remote work and record its result.
|
|
484
|
+
const ownedByThisProcess = meta.spawnPid === process.pid && meta.spawnPidStartTime === currentProcessStartToken();
|
|
402
485
|
if (meta.spawnPid !== process.pid || meta.spawnPidStartTime !== currentProcessStartToken()) {
|
|
403
486
|
meta.spawnPid = process.pid;
|
|
404
487
|
meta.spawnPidStartTime = currentProcessStartToken();
|
|
@@ -418,22 +501,98 @@ export function resumeRunningTask(
|
|
|
418
501
|
activeRemoteTasks.set(meta.id, remoteTask);
|
|
419
502
|
scheduleRemoteSessionPoll(pi, meta.id, 0, getActiveSession);
|
|
420
503
|
if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
|
|
504
|
+
// An earlier instance's in-flight timeout kill may still finalize it.
|
|
505
|
+
if (ownedByThisProcess) scheduleHandoff(pi, meta.id, getActiveSession);
|
|
421
506
|
return meta;
|
|
422
507
|
}
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
meta.
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
writeMeta(meta);
|
|
430
|
-
void notifyTerminal(pi, meta, getActiveSession);
|
|
508
|
+
if (ownedByThisProcess) {
|
|
509
|
+
// A dead pid is not yet "lost" here: the earlier instance's close listener may
|
|
510
|
+
// still record the real exit. The handoff marks it lost after a grace period.
|
|
511
|
+
scheduleHandoff(pi, meta.id, getActiveSession, remoteTask);
|
|
512
|
+
} else if (meta.pid && !processExists(meta.pid)) {
|
|
513
|
+
markProcessLost(pi, meta, getActiveSession);
|
|
431
514
|
return meta;
|
|
432
515
|
}
|
|
433
516
|
if (meta.deadlineAt) scheduleProcessTimeout(pi, meta.id, meta.deadlineAt, getActiveSession);
|
|
434
517
|
return meta;
|
|
435
518
|
}
|
|
436
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Watch a task whose driver may still be an earlier instance in this process: a
|
|
522
|
+
* local child's close listener, or a remote tmux launch that was mid-bootstrap.
|
|
523
|
+
* Deliver the completion once it is recorded terminal, and take over remote
|
|
524
|
+
* polling once the tmux session is recorded as started (#324).
|
|
525
|
+
*/
|
|
526
|
+
function scheduleHandoff(
|
|
527
|
+
pi: ExtensionAPI,
|
|
528
|
+
id: string,
|
|
529
|
+
getActiveSession?: ActiveSessionProvider,
|
|
530
|
+
remoteTask?: ResolvedSshRemoteTask,
|
|
531
|
+
): void {
|
|
532
|
+
stopHandoff(id);
|
|
533
|
+
if (scheduledWorkSuspended) return;
|
|
534
|
+
let deadSince: number | undefined;
|
|
535
|
+
let delayMs = HANDOFF_CHECK_MS;
|
|
536
|
+
const check = () => {
|
|
537
|
+
handoffTimers.delete(id);
|
|
538
|
+
const meta = readMeta(id);
|
|
539
|
+
if (!meta) {
|
|
540
|
+
stopHandoff(id);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (meta.status !== "running") {
|
|
544
|
+
stopHandoff(id);
|
|
545
|
+
clearProcessTimeout(id);
|
|
546
|
+
stopLogRetention(id);
|
|
547
|
+
void notifyTerminal(pi, meta, getActiveSession);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (meta.remote?.session === "tmux" && meta.remote.sessionStarted === true && remoteTask && !activeRemoteTasks.has(id)) {
|
|
551
|
+
stopHandoff(id);
|
|
552
|
+
activeRemoteTasks.set(id, remoteTask);
|
|
553
|
+
scheduleRemoteSessionPoll(pi, id, 0, getActiveSession);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (meta.remote?.session !== "tmux" && meta.pid && !processExists(meta.pid)) {
|
|
557
|
+
deadSince ??= Date.now();
|
|
558
|
+
if (Date.now() - deadSince >= HANDOFF_LOST_GRACE_MS) {
|
|
559
|
+
stopHandoff(id);
|
|
560
|
+
clearProcessTimeout(id);
|
|
561
|
+
stopLogRetention(id);
|
|
562
|
+
markProcessLost(pi, meta, getActiveSession);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
delayMs = HANDOFF_CHECK_MS;
|
|
566
|
+
} else {
|
|
567
|
+
delayMs = Math.min(HANDOFF_MAX_CHECK_MS, delayMs * 2);
|
|
568
|
+
}
|
|
569
|
+
arm();
|
|
570
|
+
};
|
|
571
|
+
const arm = () => {
|
|
572
|
+
if (scheduledWorkSuspended) return;
|
|
573
|
+
const timer = setTimeout(check, delayMs);
|
|
574
|
+
timer.unref();
|
|
575
|
+
handoffTimers.set(id, timer);
|
|
576
|
+
};
|
|
577
|
+
arm();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function stopHandoff(id: string): void {
|
|
581
|
+
const timer = handoffTimers.get(id);
|
|
582
|
+
if (timer) clearTimeout(timer);
|
|
583
|
+
handoffTimers.delete(id);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function markProcessLost(pi: ExtensionAPI, meta: BackgroundTaskMeta, getActiveSession?: ActiveSessionProvider): void {
|
|
587
|
+
meta.status = "failed";
|
|
588
|
+
meta.endedAt = Date.now();
|
|
589
|
+
meta.error = "process is no longer alive; exit result was not captured by this pi session";
|
|
590
|
+
meta.result = { reason: meta.error };
|
|
591
|
+
recordFailure(meta, "execution", meta.error, "lost", { incomplete: true });
|
|
592
|
+
writeMeta(meta);
|
|
593
|
+
void notifyTerminal(pi, meta, getActiveSession);
|
|
594
|
+
}
|
|
595
|
+
|
|
437
596
|
function resolvePersistedRemoteTask(
|
|
438
597
|
meta: BackgroundTaskMeta,
|
|
439
598
|
remoteRunner?: RemoteRunner,
|
|
@@ -550,6 +709,7 @@ function scheduleWatch(
|
|
|
550
709
|
runOnce?: WatchPollRunner,
|
|
551
710
|
): void {
|
|
552
711
|
clearWatchTimer(id);
|
|
712
|
+
if (scheduledWorkSuspended) return;
|
|
553
713
|
const timer = setTimeout(() => void pollWatch(pi, id, getActiveSession, runOnce), delayMs);
|
|
554
714
|
timer.unref();
|
|
555
715
|
watcherTimers.set(id, timer);
|
|
@@ -581,6 +741,8 @@ async function pollWatch(
|
|
|
581
741
|
const result = runOnce
|
|
582
742
|
? await runOnce(timeoutMs)
|
|
583
743
|
: await runCommandOnce(commandSpecFromMeta(meta), undefined, timeoutMs);
|
|
744
|
+
// A poll that was in flight when the session shut down belongs to a stale instance.
|
|
745
|
+
if (scheduledWorkSuspended) return;
|
|
584
746
|
appendWatchResult(meta.logPath, result);
|
|
585
747
|
const latest = readMeta(id);
|
|
586
748
|
if (!latest || latest.status !== "running") return;
|
|
@@ -604,8 +766,8 @@ async function pollWatch(
|
|
|
604
766
|
const error = condition && validateCondition(condition);
|
|
605
767
|
if (error) {
|
|
606
768
|
latest.error = `${name}: ${error}`;
|
|
607
|
-
if (result.exitCode !== 0)
|
|
608
|
-
{
|
|
769
|
+
if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
|
|
770
|
+
{ at: result.endedAt });
|
|
609
771
|
recordFailure(latest, name, latest.error, pollKey, { incomplete: true, at: result.endedAt });
|
|
610
772
|
finalize(latest, { status: "failed", reason: latest.error, commandResult: result }, pi, getActiveSession);
|
|
611
773
|
return;
|
|
@@ -628,8 +790,8 @@ async function pollWatch(
|
|
|
628
790
|
success?.matched === true && latest.successWhen?.type === "exit_code";
|
|
629
791
|
if (expectedPollExit) recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
|
|
630
792
|
if (transportFailure) recordFailure(latest, "watch-poll", transportFailure, pollKey, { category: "ssh", at: result.endedAt });
|
|
631
|
-
else if (result.exitCode !== 0)
|
|
632
|
-
{
|
|
793
|
+
else if (result.exitCode !== 0) recordExitFailure(latest, "watch-poll", `Watch poll exited with code ${result.exitCode ?? "unknown"}`, result.exitCode, pollKey,
|
|
794
|
+
{ expected: expectedPollExit, at: result.endedAt });
|
|
633
795
|
else recoverFailure(latest, "watch-poll", pollKey, result.endedAt);
|
|
634
796
|
if (conditionErrors.length) latest.error = conditionErrors.join("; ");
|
|
635
797
|
if (failure?.matched) {
|
|
@@ -692,6 +854,7 @@ function finalize(
|
|
|
692
854
|
): void {
|
|
693
855
|
stopFailureAttention(meta.id);
|
|
694
856
|
if (terminal.status === "timed_out") recordFailure(meta, "timeout", terminal.reason, "deadline", { category: "timeout" });
|
|
857
|
+
if (terminal.status === "succeeded") recoverDeclaredOperation(meta);
|
|
695
858
|
meta.status = terminal.status;
|
|
696
859
|
meta.endedAt = Date.now();
|
|
697
860
|
meta.result = {
|
|
@@ -750,6 +913,7 @@ function scheduleProcessTimeout(
|
|
|
750
913
|
getActiveSession?: ActiveSessionProvider,
|
|
751
914
|
): void {
|
|
752
915
|
clearProcessTimeout(id);
|
|
916
|
+
if (scheduledWorkSuspended) return;
|
|
753
917
|
const delay = Math.max(0, deadlineAt - Date.now());
|
|
754
918
|
const timer = setTimeout(() => void timeoutProcess(pi, id, getActiveSession), delay);
|
|
755
919
|
timer.unref();
|
|
@@ -839,6 +1003,9 @@ async function notifyTerminal(
|
|
|
839
1003
|
meta: BackgroundTaskMeta,
|
|
840
1004
|
getActiveSession?: ActiveSessionProvider,
|
|
841
1005
|
): Promise<void> {
|
|
1006
|
+
// A suspended instance has no active session: delivering or suppressing here would
|
|
1007
|
+
// be wrong either way. The instance that resumes the task delivers it (#324).
|
|
1008
|
+
if (scheduledWorkSuspended) return;
|
|
842
1009
|
if (meta.callback === false || meta.callbackSentAt || meta.callbackSuppressedAt) return;
|
|
843
1010
|
const latest = readMeta(meta.id) ?? meta;
|
|
844
1011
|
if (latest.callback === false || latest.callbackSentAt || latest.callbackSuppressedAt) return;
|
|
@@ -858,7 +1025,11 @@ async function notifyTerminal(
|
|
|
858
1025
|
source: "background-task",
|
|
859
1026
|
id: latest.id,
|
|
860
1027
|
label,
|
|
861
|
-
|
|
1028
|
+
// Lifecycle plus an attention count; the incident text rides failureRows
|
|
1029
|
+
// with exact counts, so the status field is never a clipped summary (#323).
|
|
1030
|
+
status: pending
|
|
1031
|
+
? `${latest.status}; ${pending.incidents.length} incident${pending.incidents.length === 1 ? "" : "s"} need${pending.incidents.length === 1 ? "s" : ""} attention`
|
|
1032
|
+
: latest.status,
|
|
862
1033
|
detailTool: "bg_task_status",
|
|
863
1034
|
outcome: facts.outcome,
|
|
864
1035
|
failureRows: facts.failureRows,
|
|
@@ -939,6 +1110,7 @@ function launchArgvOf(commandSpec: CommandSpec, launchSpec: CommandSpec): string
|
|
|
939
1110
|
|
|
940
1111
|
function scheduleLogRetention(id: string): void {
|
|
941
1112
|
stopLogRetention(id);
|
|
1113
|
+
if (scheduledWorkSuspended) return;
|
|
942
1114
|
const timer = setInterval(() => {
|
|
943
1115
|
const meta = readMeta(id);
|
|
944
1116
|
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)}`;
|