pi-better-harness 0.31.1 → 0.32.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/node_modules/pi-better-background-tasks/README.md +88 -35
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/adapter-contract.ts +55 -0
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +5 -6
- package/node_modules/pi-better-background-tasks/src/output.ts +61 -20
- package/node_modules/pi-better-background-tasks/src/runtime.ts +20 -8
- package/node_modules/pi-better-background-tasks/src/sandbox.ts +60 -15
- package/node_modules/pi-better-background-tasks/src/stall-attention.ts +31 -0
- package/node_modules/pi-better-background-tasks/src/stall.ts +14 -2
- package/node_modules/pi-better-background-tasks/src/task-diagnostics.ts +71 -0
- package/node_modules/pi-better-background-tasks/src/tools.ts +36 -12
- package/node_modules/pi-better-background-tasks/src/types.ts +12 -0
- package/node_modules/pi-better-sandbox/README.md +81 -21
- package/node_modules/pi-better-sandbox/background-admission.ts +74 -0
- package/node_modules/pi-better-sandbox/commands.ts +5 -5
- package/node_modules/pi-better-sandbox/events.ts +21 -3
- package/node_modules/pi-better-sandbox/index.ts +17 -7
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permission-settings.ts +1 -0
- package/node_modules/pi-better-sandbox/permissions-page.ts +23 -20
- package/node_modules/pi-better-sandbox/permissions.ts +14 -7
- package/node_modules/pi-better-sandbox/state.ts +21 -8
- package/node_modules/pi-better-subagents/README.md +6 -4
- package/node_modules/pi-better-subagents/agent-inspection.ts +18 -14
- package/node_modules/pi-better-subagents/catalog-runtime.ts +3 -1
- package/node_modules/pi-better-subagents/docs/usage.md +15 -11
- package/node_modules/pi-better-subagents/goal-provider.ts +52 -0
- package/node_modules/pi-better-subagents/index.ts +6 -43
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/permission-policy.ts +2 -2
- package/node_modules/pi-better-subagents/sandbox.ts +2 -2
- package/package.json +4 -4
|
@@ -17,47 +17,45 @@ Use `pi-better-background-tasks` when a command should keep running while the fo
|
|
|
17
17
|
- Keep task metadata and logs available across reloads.
|
|
18
18
|
- Retain completed task artifacts for seven days, then remove them during rate-limited maintenance.
|
|
19
19
|
- Show active work in Pi's background-work navigator.
|
|
20
|
-
-
|
|
20
|
+
- Report quiet work and suspected stalls separately from task lifecycle.
|
|
21
21
|
- Confine local task writes to the project directory when `pi-better-sandbox` is enabled.
|
|
22
22
|
|
|
23
23
|
## Write Sandbox
|
|
24
24
|
|
|
25
25
|
When [`pi-better-sandbox`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme)
|
|
26
|
-
is installed
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
If the
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
26
|
+
is installed, local tasks follow the independent **Background tasks** column in
|
|
27
|
+
`/sandbox`. Its Sandbox switch starts Off for fresh settings. When enabled,
|
|
28
|
+
commands run under macOS Seatbelt or Linux Bubblewrap with that column's project,
|
|
29
|
+
outside-project, credential-file, command and network permissions. Protected
|
|
30
|
+
paths remain denied.
|
|
31
|
+
|
|
32
|
+
The policy is captured once at launch. Later settings changes affect only new
|
|
33
|
+
tasks. An existing process, including a watcher resumed in a later Pi session,
|
|
34
|
+
keeps its launch policy. `/sandbox on|off` and `/sandbox default on|off` now
|
|
35
|
+
change Main only; use the Background tasks column to change new task launches.
|
|
36
|
+
Older settings migrate by copying Main's values into Background tasks once.
|
|
37
|
+
|
|
38
|
+
If the Background policy is `unavailable` or `failed`, launch is refused rather
|
|
39
|
+
than retried unconfined. Turning its Sandbox switch Off deliberately permits
|
|
40
|
+
unconfined local launches.
|
|
41
|
+
|
|
42
|
+
With Background tasks' Outside project = Write, the task can write across home
|
|
43
|
+
and temp but can remove files outside the project only in temp, hidden home
|
|
44
|
+
directories, and worktree folders (see
|
|
45
45
|
[pi-better-sandbox](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme)).
|
|
46
|
-
On macOS,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Structured
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
installs the sandbox extension, but leaves foreground tools and local background
|
|
60
|
-
tasks inactive until a human opts in.
|
|
46
|
+
On macOS, Outside project = Write or Write & delete starts an APFS local snapshot
|
|
47
|
+
(`tmutil localsnapshot`) as a recovery aid. A failed snapshot is noted in the log
|
|
48
|
+
and never blocks execution.
|
|
49
|
+
|
|
50
|
+
Structured SSH cannot apply local file and credential permissions remotely, so
|
|
51
|
+
an enabled permission-aware Background profile refuses those launches. When
|
|
52
|
+
unconfined, SSH retains its documented remote behavior; local policy never
|
|
53
|
+
establishes remote confinement. Without the sandbox extension, local tasks
|
|
54
|
+
retain their historical unconfined behavior.
|
|
55
|
+
|
|
56
|
+
Pi's own process, provider transport, `pi.exec` calls, and unrelated runtime
|
|
57
|
+
extensions remain outside the task boundary. Installing the harness does not
|
|
58
|
+
activate Main or Background confinement by default.
|
|
61
59
|
|
|
62
60
|
## Remote SSH
|
|
63
61
|
|
|
@@ -105,6 +103,61 @@ is recorded as finished, and its callback is delivered when its session resumes.
|
|
|
105
103
|
If you quit Pi first, nothing records that exit: when the session is resumed in a
|
|
106
104
|
new Pi process, a task whose process is gone is marked lost.
|
|
107
105
|
|
|
106
|
+
## Progress And Suspected Stalls
|
|
107
|
+
|
|
108
|
+
A task can remain `running` without recent progress evidence. Compact status and
|
|
109
|
+
navigator use the same observation. Process tasks use recorded progress and the
|
|
110
|
+
log's modification time. Watches use recorded progress and completed polls. Before
|
|
111
|
+
any progress, the task's launch time anchors the silence duration. This does not
|
|
112
|
+
measure CPU activity, network waits, or useful application work.
|
|
113
|
+
|
|
114
|
+
At 60 seconds without observed progress, work is `quiet`. At 5 minutes, it has a
|
|
115
|
+
`suspected stall`. Both thresholds are inclusive. Set `PI_BETTER_STALL_QUIET_MS`
|
|
116
|
+
and `PI_BETTER_STALL_MS` to positive millisecond durations to override them.
|
|
117
|
+
Compact status shows the last observed time, silence duration, and a warning:
|
|
118
|
+
silence alone does not prove the task is hung. Terminal tasks are not reported as
|
|
119
|
+
suspected stalls. New output or a completed watch poll refreshes the observation.
|
|
120
|
+
|
|
121
|
+
A status cursor changes when the observation crosses either threshold, even if
|
|
122
|
+
metadata and log bytes have not changed. Within the same observation state,
|
|
123
|
+
unchanged status still shows the current silence duration. A suspected stall
|
|
124
|
+
records an observation, not a process failure. After the existing 60-second
|
|
125
|
+
attention grace, callback-enabled tasks wake the parent once per silent spell.
|
|
126
|
+
New progress closes the observation and a later spell can notify again. Quiet
|
|
127
|
+
work does not alert, `callback:false` suppresses notification, and no silence
|
|
128
|
+
threshold stops a task or changes its lifecycle. The active session's runtime
|
|
129
|
+
checks observations once per second, including an in-flight watch poll. Reload
|
|
130
|
+
restores monitoring and the persisted attention receipts. Log-retention writes
|
|
131
|
+
are excluded from task progress.
|
|
132
|
+
|
|
133
|
+
## Launch Policy And Diagnostics
|
|
134
|
+
|
|
135
|
+
`/sandbox` has an independent **Background tasks** column. New tasks capture
|
|
136
|
+
that column at launch; subsequent changes do not alter an existing process or
|
|
137
|
+
its future watch polls. Older saved settings copy Main into Background tasks
|
|
138
|
+
once so the migration preserves behavior. Older sandbox publishers without the
|
|
139
|
+
Background field retain their Main-policy behavior.
|
|
140
|
+
|
|
141
|
+
Launch and status show `confined`, `unconfined`, or `unknown`, plus the backend
|
|
142
|
+
when known and the policy source. `verbose:true` includes the persisted policy
|
|
143
|
+
summary. Restored tasks without recorded launch evidence report `unknown`;
|
|
144
|
+
missing profile files or current process-tool denials are not confinement proof.
|
|
145
|
+
Remote execution also reports unknown remote confinement.
|
|
146
|
+
|
|
147
|
+
Use `bg_task_status` with `diagnostics:true` (or either wrapper's `status`
|
|
148
|
+
action) for a bounded, current process snapshot. Both Main and Background tasks
|
|
149
|
+
must currently permit **Process access: Read**, even when sandbox switches are
|
|
150
|
+
Off. The result checks the recorded PID and start identity, then reports process
|
|
151
|
+
state, accumulated CPU time, and available wait evidence. It never reads command
|
|
152
|
+
arguments or environment values and never probes a remote host or descendants.
|
|
153
|
+
A snapshot cannot establish useful work or a hang. Watch poll PIDs are not
|
|
154
|
+
retained, so their activity evidence is explicitly unavailable.
|
|
155
|
+
|
|
156
|
+
The process-activity adapter is unavailable under Main confinement; it never
|
|
157
|
+
retries `ps` unconfined. Unsupported systems, absent launch identities, reused
|
|
158
|
+
PIDs, and blocked diagnostics each receive a separate explanation. Diagnostics
|
|
159
|
+
permissions do not change or describe the task's launch-time confinement.
|
|
160
|
+
|
|
108
161
|
## Watch conditions
|
|
109
162
|
|
|
110
163
|
JSON conditions require a root-prefixed path, such as `$.status` or
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { inspectMeta } from "./registry.js";
|
|
3
|
+
|
|
4
|
+
/** Optional-peer contract, mirrored by pi-better-sandbox/background-admission.ts. */
|
|
5
|
+
export const BACKGROUND_ADAPTER_REQUEST = "pi-better-background-tasks:adapter-request";
|
|
6
|
+
export const BACKGROUND_ADAPTER_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
type Admission = "local-launch" | "registry";
|
|
9
|
+
type Request = {
|
|
10
|
+
version: number;
|
|
11
|
+
name: string;
|
|
12
|
+
input: unknown;
|
|
13
|
+
reply(value: { version: number; parameters: object; admission: Admission }): void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The schemas belong to one registerTools invocation, never to a module singleton. */
|
|
17
|
+
export function advertiseBackgroundAdapter(pi: ExtensionAPI, schemas: ReadonlyMap<string, object>): void {
|
|
18
|
+
const unsubscribe = pi.events?.on(BACKGROUND_ADAPTER_REQUEST, (data: unknown) => {
|
|
19
|
+
const request = data as Partial<Request> | undefined;
|
|
20
|
+
if (request?.version !== BACKGROUND_ADAPTER_VERSION || typeof request.name !== "string" ||
|
|
21
|
+
typeof request.reply !== "function") return;
|
|
22
|
+
const parameters = schemas.get(request.name);
|
|
23
|
+
if (!parameters) return;
|
|
24
|
+
const admission = classify(request.name, request.input);
|
|
25
|
+
if (admission) request.reply({ version: BACKGROUND_ADAPTER_VERSION, parameters, admission });
|
|
26
|
+
});
|
|
27
|
+
pi.on("session_shutdown", () => { if (typeof unsubscribe === "function") unsubscribe(); });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function classify(name: string, input: unknown): Admission | undefined {
|
|
31
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return;
|
|
32
|
+
const params = input as Record<string, unknown>;
|
|
33
|
+
let action: unknown;
|
|
34
|
+
switch (name) {
|
|
35
|
+
case "bg_task_spawn": action = "spawn"; break;
|
|
36
|
+
case "bg_task_watch": action = "watch"; break;
|
|
37
|
+
case "bg_task_list": action = "list"; break;
|
|
38
|
+
case "bg_task_status": action = "status"; break;
|
|
39
|
+
case "bg_task_log": action = "log"; break;
|
|
40
|
+
case "bg_task_stop": action = "stop"; break;
|
|
41
|
+
case "bg_task": action = params.action; break;
|
|
42
|
+
case "bg_status":
|
|
43
|
+
if (params.action === "spawn" || params.action === "watch") return;
|
|
44
|
+
action = params.action;
|
|
45
|
+
break;
|
|
46
|
+
default: return;
|
|
47
|
+
}
|
|
48
|
+
if (action === "spawn" || action === "watch") return params.ssh ? undefined : "local-launch";
|
|
49
|
+
if (action === "list" || action === "status" || action === "log" || action === "clear") return "registry";
|
|
50
|
+
if (action !== "stop" || typeof params.id !== "string" || !params.id) return;
|
|
51
|
+
// Never trust caller flags about locality. Missing/corrupt metadata is not proof
|
|
52
|
+
// of a local stop; a remote stop can execute SSH in the trusted Pi process.
|
|
53
|
+
const meta = inspectMeta(params.id).meta;
|
|
54
|
+
if (meta && !meta.ssh && !meta.remote && (meta.kind === "process" || meta.kind === "command_watch")) return "registry";
|
|
55
|
+
}
|
|
@@ -15,7 +15,7 @@ import { failurePath, failureView } from "./failures.js";
|
|
|
15
15
|
import { readLog } from "./logs.js";
|
|
16
16
|
import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
|
|
17
17
|
import { stopTask } from "./runtime.js";
|
|
18
|
-
import { observeBackgroundTaskStall } from "./stall.js";
|
|
18
|
+
import { formatBackgroundTaskStall, observeBackgroundTaskStall } from "./stall.js";
|
|
19
19
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, BackgroundTaskStatus } from "./types.js";
|
|
20
20
|
|
|
21
21
|
let unregister: (() => void) | undefined;
|
|
@@ -152,8 +152,8 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
|
|
|
152
152
|
if (meta.deadlineAt) metadata.push({ label: "deadline", value: formatDuration(meta.deadlineAt - now) });
|
|
153
153
|
if (meta.lastCheckedAt) metadata.push({ label: "checked", value: `${formatDuration(now - meta.lastCheckedAt)} ago` });
|
|
154
154
|
if (meta.status === "running") {
|
|
155
|
-
const
|
|
156
|
-
if (
|
|
155
|
+
const activity = formatBackgroundTaskStall(observeBackgroundTaskStall(meta, now));
|
|
156
|
+
if (activity) metadata.push({ label: "activity", value: activity });
|
|
157
157
|
}
|
|
158
158
|
if (meta.lastExitCode !== undefined) metadata.push({ label: "exit", value: String(meta.lastExitCode) });
|
|
159
159
|
if (meta.error) metadata.push({ label: "error", value: meta.error });
|
|
@@ -223,9 +223,8 @@ function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
|
|
|
223
223
|
const incident = actionableFailures(readFailureState(failurePath(meta.id)))[0];
|
|
224
224
|
if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
|
|
225
225
|
if (meta.status === "running") {
|
|
226
|
-
const
|
|
227
|
-
if (
|
|
228
|
-
else if (stall.state === "quiet") facts.push("quiet");
|
|
226
|
+
const activity = formatBackgroundTaskStall(observeBackgroundTaskStall(meta, now));
|
|
227
|
+
if (activity) facts.push(activity);
|
|
229
228
|
}
|
|
230
229
|
if (meta.kind === "command_watch" && meta.intervalMs) facts.push(`every ${formatDuration(meta.intervalMs)}`);
|
|
231
230
|
if (meta.deadlineAt && meta.status === "running") facts.push(`${formatDuration(meta.deadlineAt - now)} left`);
|
|
@@ -10,8 +10,11 @@ import {
|
|
|
10
10
|
incidentResource,
|
|
11
11
|
incidentVerbatimPage,
|
|
12
12
|
isIncidentCursor,
|
|
13
|
+
pendingAttentionNote,
|
|
14
|
+
pendingFailureAttention,
|
|
13
15
|
quietFailureLine,
|
|
14
16
|
readFailureState,
|
|
17
|
+
terminalFailureParts,
|
|
15
18
|
scopedFailures,
|
|
16
19
|
type FailureState,
|
|
17
20
|
type IncidentDetail,
|
|
@@ -40,6 +43,9 @@ import {
|
|
|
40
43
|
import { failurePath } from "./failures.js";
|
|
41
44
|
import { captureGapsFor, pageTaskLog, readLog, type LogRead } from "./logs.js";
|
|
42
45
|
import { belongsToOrigin, inspectMeta, listTaskRecords, originOf, type MetaInspection } from "./registry.js";
|
|
46
|
+
import { formatBackgroundTaskStall, observeBackgroundTaskStall } from "./stall.js";
|
|
47
|
+
import type { TaskProcessDiagnostics } from "./task-diagnostics.js";
|
|
48
|
+
import type { StallObservation } from "./shared-stall-detector.js";
|
|
43
49
|
import type { BackgroundTaskCallbackOrigin, BackgroundTaskMeta, Condition, FirstWatchCheck } from "./types.js";
|
|
44
50
|
|
|
45
51
|
/** The launch tool stopped waiting while a watch's first check was still running (#359). */
|
|
@@ -75,6 +81,7 @@ const LIST_LEAD_PREVIEW_BYTES = 200;
|
|
|
75
81
|
export type BackgroundOutputSurface = keyof typeof BACKGROUND_OUTPUT_BUDGET_BYTES;
|
|
76
82
|
|
|
77
83
|
export interface OutputOptions {
|
|
84
|
+
processDiagnostics?: TaskProcessDiagnostics;
|
|
78
85
|
cursor?: string;
|
|
79
86
|
maxBytes?: number;
|
|
80
87
|
verbose?: boolean;
|
|
@@ -237,7 +244,9 @@ export function formatCallbackFacts(meta: BackgroundTaskMeta): {
|
|
|
237
244
|
incidentCount?: number;
|
|
238
245
|
} {
|
|
239
246
|
const state = failureStateFor(meta.id);
|
|
240
|
-
const
|
|
247
|
+
const pending = pendingFailureAttention(state, Date.now(), { terminal: true });
|
|
248
|
+
const incidents = pending?.incidents ?? [];
|
|
249
|
+
const rows = terminalFailureParts(state, incidents).rows;
|
|
241
250
|
const gapLines = [
|
|
242
251
|
meta.logDiscardedBytes ? `retention discarded ${meta.logDiscardedBytes} bytes; not recoverable` : undefined,
|
|
243
252
|
meta.captureDiscardedBytes ? `capture overflow discarded ${meta.captureDiscardedBytes} bytes; not full history` : undefined,
|
|
@@ -245,8 +254,8 @@ export function formatCallbackFacts(meta: BackgroundTaskMeta): {
|
|
|
245
254
|
// History (expected and closed incidents) is not a row, but the callback still says it exists, so a
|
|
246
255
|
// declared expected exit is not read as a plain failure. It leads the decision: a tight callback
|
|
247
256
|
// budget keeps a prefix of the decision, and this short line must survive it (#332).
|
|
248
|
-
const history =
|
|
249
|
-
const decision = [history, formatDecision(meta), ...gapLines].filter(Boolean).join("\n") || undefined;
|
|
257
|
+
const history = actionableFailures(state).length ? historyTailLine(state) : quietFailureLine(state);
|
|
258
|
+
const decision = [history, pendingAttentionNote(state, incidents), formatDecision(meta), ...gapLines].filter(Boolean).join("\n") || undefined;
|
|
250
259
|
return {
|
|
251
260
|
outcome: meta.status,
|
|
252
261
|
...(rows.length ? { failureRows: rows, incidentCount: rows.length } : {}),
|
|
@@ -271,6 +280,28 @@ function formatDiagnostics(meta: BackgroundTaskMeta, extra: string[] = []): stri
|
|
|
271
280
|
return lines.length ? lines.join("\n") : undefined;
|
|
272
281
|
}
|
|
273
282
|
|
|
283
|
+
function formatProcessDiagnostics(diagnostics: TaskProcessDiagnostics | undefined): string | undefined {
|
|
284
|
+
if (!diagnostics) return undefined;
|
|
285
|
+
const facts = diagnostics.availability === "available" && diagnostics.identity === "matches"
|
|
286
|
+
? `identity matches; state=${diagnostics.state}; CPU=${diagnostics.cpuTime}; wait=${diagnostics.waitChannel}`
|
|
287
|
+
: diagnostics.reason ?? diagnostics.availability;
|
|
288
|
+
return `process diagnostics: ${facts}\nA snapshot cannot prove useful work or a hang.`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function formatLaunchSandbox(meta: BackgroundTaskMeta): string | undefined {
|
|
292
|
+
const sandbox = meta.sandbox;
|
|
293
|
+
if (!sandbox) return "sandbox: unknown; launch-time policy was not recorded.";
|
|
294
|
+
const backend = sandbox.backend ? `; ${oneLine(sandbox.backend, 40)}` : "";
|
|
295
|
+
return `sandbox: ${sandbox.confinement}${backend}; source: ${oneLine(sandbox.policySource, 80)}; later changes do not affect this task.`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function formatStaleProgress(stall: StallObservation | undefined): string | undefined {
|
|
299
|
+
if (!stall) return undefined;
|
|
300
|
+
const summary = formatBackgroundTaskStall(stall);
|
|
301
|
+
if (!summary) return undefined;
|
|
302
|
+
return `progress: ${summary}; last observed: ${new Date(stall.observedAt!).toISOString()}.\nSilence alone does not prove the task is hung.`;
|
|
303
|
+
}
|
|
304
|
+
|
|
274
305
|
function formatProgress(meta: BackgroundTaskMeta): string | undefined {
|
|
275
306
|
const lines = [`kind: ${meta.kind}`];
|
|
276
307
|
if (meta.name) lines.push(`name: ${oneLine(meta.name, 80)}`);
|
|
@@ -289,8 +320,10 @@ function identityLine(meta: BackgroundTaskMeta): string {
|
|
|
289
320
|
}
|
|
290
321
|
|
|
291
322
|
/** Lifecycle, result, and retained-log facts. A deleted or rewritten log is a change. */
|
|
292
|
-
function contentRevision(meta: BackgroundTaskMeta): string {
|
|
323
|
+
function contentRevision(meta: BackgroundTaskMeta, stall?: StallObservation): string {
|
|
293
324
|
return lifecycleContentRevision([
|
|
325
|
+
stall ? [stall.state, stall.observedAt ?? null] : null,
|
|
326
|
+
meta.sandbox ?? null,
|
|
294
327
|
meta.status,
|
|
295
328
|
meta.endedAt ?? null,
|
|
296
329
|
meta.lastCheckedAt ?? null,
|
|
@@ -460,7 +493,7 @@ export function formatLaunch(meta: BackgroundTaskMeta, firstCheck?: FirstWatchCh
|
|
|
460
493
|
identity: `Started background ${meta.kind} ${label}. Status: ${meta.status}.`,
|
|
461
494
|
failure: incidentSection(meta.id, {}),
|
|
462
495
|
decision: formatDecision(meta),
|
|
463
|
-
diagnostics: [...remoteLines, ...(checkText ? [checkText] : [])].join("\n") || undefined,
|
|
496
|
+
diagnostics: [formatLaunchSandbox(meta), ...remoteLines, ...(checkText ? [checkText] : [])].filter(Boolean).join("\n") || undefined,
|
|
464
497
|
progress: withLog ? logLine : undefined,
|
|
465
498
|
},
|
|
466
499
|
gaps: taskGaps(meta),
|
|
@@ -520,7 +553,11 @@ export function formatFirstWatchCheck(meta: BackgroundTaskMeta, check: FirstWatc
|
|
|
520
553
|
...(keptOut.length ? [`stdout tail:\n${keptOut.map((line) => ` ${line}`).join("\n")}`] : []),
|
|
521
554
|
...(keptEmptyStdout ? ["stdout: (empty)"] : []),
|
|
522
555
|
].join("\n");
|
|
523
|
-
const
|
|
556
|
+
const omissionNote = () => {
|
|
557
|
+
const omitted = (stderr.length - keptErr.length) + (stdout.length - keptOut.length);
|
|
558
|
+
return omitted > 0 ? `(${omitted} output line${omitted === 1 ? "" : "s"} omitted; see bg_task_log)` : undefined;
|
|
559
|
+
};
|
|
560
|
+
const fits = () => utf8ByteLength([render(), omissionNote()].filter(Boolean).join("\n")) <= maxBytes;
|
|
524
561
|
// Newest first within each stream; each kept line goes in front to stay in order.
|
|
525
562
|
for (const line of [...stderr].reverse()) {
|
|
526
563
|
keptErr.unshift(line);
|
|
@@ -534,21 +571,17 @@ export function formatFirstWatchCheck(meta: BackgroundTaskMeta, check: FirstWatc
|
|
|
534
571
|
keptEmptyStdout = true;
|
|
535
572
|
if (!fits()) keptEmptyStdout = false;
|
|
536
573
|
}
|
|
537
|
-
|
|
538
|
-
if (omitted > 0) {
|
|
539
|
-
const note = `(${omitted} output line${omitted === 1 ? "" : "s"} omitted; see bg_task_log)`;
|
|
540
|
-
const text = `${render()}\n${note}`;
|
|
541
|
-
if (utf8ByteLength(text) <= maxBytes) return text;
|
|
542
|
-
}
|
|
543
|
-
return render();
|
|
574
|
+
return [render(), omissionNote()].filter(Boolean).join("\n");
|
|
544
575
|
}
|
|
545
576
|
|
|
546
|
-
function redactedVerbose(meta: BackgroundTaskMeta): unknown {
|
|
577
|
+
function redactedVerbose(meta: BackgroundTaskMeta, options: OutputOptions): unknown {
|
|
547
578
|
const { env, ...rest } = meta;
|
|
548
579
|
const state = failureStateFor(meta.id);
|
|
549
580
|
const observations = Object.values(state.observations);
|
|
550
581
|
const body = {
|
|
551
582
|
...rest,
|
|
583
|
+
sandbox: meta.sandbox ?? { confinement: "unknown", policySummary: "Launch-time policy was not recorded." },
|
|
584
|
+
...(options.processDiagnostics ? { processDiagnostics: options.processDiagnostics } : {}),
|
|
552
585
|
...(env ? { env: { omitted: true, keyCount: Object.keys(env).length } } : {}),
|
|
553
586
|
};
|
|
554
587
|
if (!observations.length) return body;
|
|
@@ -569,16 +602,20 @@ function redactedVerbose(meta: BackgroundTaskMeta): unknown {
|
|
|
569
602
|
* with a caller cursor like any other retained evidence.
|
|
570
603
|
*/
|
|
571
604
|
function formatVerbose(meta: BackgroundTaskMeta, options: OutputOptions): string {
|
|
572
|
-
const json = JSON.stringify(redactedVerbose(meta), null, 2);
|
|
605
|
+
const json = JSON.stringify(redactedVerbose(meta, options), null, 2);
|
|
573
606
|
const resource = `verbose:${scopeKey(options)}:${meta.id}`;
|
|
574
607
|
if (!options.cursor && utf8ByteLength(json) <= backgroundBudget("rawPage", options.maxBytes)) return json;
|
|
608
|
+
// Live process samples are not part of the paged document: CPU/state changes
|
|
609
|
+
// must not invalidate continuation, even when diagnostics is omitted next time.
|
|
610
|
+
const stableJson = JSON.stringify(redactedVerbose(meta, { ...options, processDiagnostics: undefined }), null, 2);
|
|
575
611
|
return assembleBackgroundContent({
|
|
576
612
|
surface: "rawPage",
|
|
577
613
|
maxBytes: options.maxBytes,
|
|
578
614
|
sections: {
|
|
579
615
|
identity: `Background task ${meta.id} metadata (environment values omitted).`,
|
|
616
|
+
diagnostics: formatProcessDiagnostics(options.processDiagnostics),
|
|
580
617
|
},
|
|
581
|
-
verbatim: (budget) => pageVerbatimText(
|
|
618
|
+
verbatim: (budget) => pageVerbatimText(stableJson, { cursor: options.cursor, maxBytes: budget, resource }),
|
|
582
619
|
});
|
|
583
620
|
}
|
|
584
621
|
|
|
@@ -641,10 +678,13 @@ export function formatStatus(
|
|
|
641
678
|
// stale status cursor (#332).
|
|
642
679
|
const listCursor = pageKind === "l";
|
|
643
680
|
const state = failureStateFor(meta.id);
|
|
681
|
+
const stall = meta.status === "running" ? observeBackgroundTaskStall(meta) : undefined;
|
|
682
|
+
const staleProgress = formatStaleProgress(stall);
|
|
683
|
+
const sandbox = formatLaunchSandbox(meta);
|
|
644
684
|
const resource = `status:${scopeKey(options)}:${meta.id}`;
|
|
645
685
|
const revision = inspectStatusRevision({
|
|
646
686
|
resource,
|
|
647
|
-
contentRevision: contentRevision(meta),
|
|
687
|
+
contentRevision: contentRevision(meta, stall),
|
|
648
688
|
failureRevision: failureRevision(state),
|
|
649
689
|
cursor: listCursor ? undefined : options.cursor,
|
|
650
690
|
});
|
|
@@ -657,7 +697,8 @@ export function formatStatus(
|
|
|
657
697
|
failure: actionableFailures(state).length
|
|
658
698
|
? `${actionableFailures(state).length} active failure observation(s), unchanged.`
|
|
659
699
|
: undefined,
|
|
660
|
-
decision: formatUnchangedEvidence(options.cursor),
|
|
700
|
+
decision: [staleProgress, formatUnchangedEvidence(options.cursor)].filter(Boolean).join("\n"),
|
|
701
|
+
diagnostics: sandbox,
|
|
661
702
|
},
|
|
662
703
|
statusCursor: revision.nextCursor,
|
|
663
704
|
});
|
|
@@ -677,8 +718,8 @@ export function formatStatus(
|
|
|
677
718
|
sections: {
|
|
678
719
|
identity: identityLine(meta),
|
|
679
720
|
failure: incidentSection(meta.id, options, state),
|
|
680
|
-
decision: [...changeFacts, formatDecision(meta)].filter(Boolean).join("\n") || undefined,
|
|
681
|
-
diagnostics: formatDiagnostics(meta),
|
|
721
|
+
decision: [...changeFacts, formatDecision(meta), staleProgress].filter(Boolean).join("\n") || undefined,
|
|
722
|
+
diagnostics: formatDiagnostics(meta, [formatProcessDiagnostics(options.processDiagnostics), sandbox].filter((line): line is string => !!line)),
|
|
682
723
|
progress: formatProgress(meta),
|
|
683
724
|
},
|
|
684
725
|
verbatim: log.error ? undefined : (budget) => excerptPage(meta, options, log, budget),
|
|
@@ -7,7 +7,7 @@ import { currentProcessStartToken, readProcessStartToken } from "./process-ident
|
|
|
7
7
|
import { DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS, expandSshRemoteTaskPreset } from "./remote-task-preset.js";
|
|
8
8
|
import type { RemoteRunner, ResolvedSshRemoteTask } from "./remote-task-preset.js";
|
|
9
9
|
import { ensureTaskDir, logPathFor, nextTaskId, readMeta, sandboxProfilePathFor, writeMeta } from "./registry.js";
|
|
10
|
-
import { confineCommandSpec, resolveForegroundSandboxPlan } from "./sandbox.js";
|
|
10
|
+
import { confineCommandSpec, resolveForegroundSandboxPlan, sandboxProvenance } from "./sandbox.js";
|
|
11
11
|
import { takeRecoverySnapshot } from "./shared-sandbox-core.js";
|
|
12
12
|
import { failurePath, readTaskIntent, recordExitFailure, recordFailure, recoverDeclaredOperation, recoverFailure, resumeFailureAttention, scheduleFailureAttention, stopFailureAttention, suspendFailureAttention, terminalFailureAttention } from "./failures.js";
|
|
13
13
|
import { markFailureAttentionDelivered } from "./shared-failure-observations.js";
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
SshConnectionParams,
|
|
25
25
|
TerminalResult,
|
|
26
26
|
} from "./types.js";
|
|
27
|
+
import { observeTaskStallAttention } from "./stall-attention.js";
|
|
27
28
|
import { isTerminalStatus } from "./types.js";
|
|
28
29
|
|
|
29
30
|
const watcherTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -262,6 +263,7 @@ export function spawnTask(
|
|
|
262
263
|
cwd,
|
|
263
264
|
env: params.env,
|
|
264
265
|
launchArgv: launchArgvOf(commandSpec, launchSpec),
|
|
266
|
+
sandbox: sandboxProvenance(sandboxPlan, !!remoteTask),
|
|
265
267
|
maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
|
|
266
268
|
pid: spawned?.child.pid,
|
|
267
269
|
pidStartTime: spawned?.child.pid ? readProcessStartToken(spawned.child.pid) : undefined,
|
|
@@ -273,7 +275,7 @@ export function spawnTask(
|
|
|
273
275
|
...intent,
|
|
274
276
|
};
|
|
275
277
|
writeMeta(meta);
|
|
276
|
-
scheduleLogRetention(id);
|
|
278
|
+
scheduleLogRetention(pi, id, getActiveSession);
|
|
277
279
|
if (tmuxBacked && remoteTask) {
|
|
278
280
|
activeRemoteTasks.set(id, remoteTask);
|
|
279
281
|
appendLine(logPath, `--- remote tmux bootstrap ${new Date(now).toISOString()} target=${remoteTask.metadata.ssh.target} session=${remoteTask.metadata.remote.sessionName} ---`);
|
|
@@ -533,6 +535,7 @@ export function startWatchTask(
|
|
|
533
535
|
cwd,
|
|
534
536
|
env: params.env,
|
|
535
537
|
launchArgv: launchArgvOf(commandSpec, launchSpec),
|
|
538
|
+
sandbox: sandboxProvenance(sandboxPlan, !!remoteTask),
|
|
536
539
|
maxLogBytes: resolveMaxLogBytes(params.max_log_bytes),
|
|
537
540
|
spawnPid: process.pid,
|
|
538
541
|
spawnPidStartTime: currentProcessStartToken(),
|
|
@@ -548,6 +551,7 @@ export function startWatchTask(
|
|
|
548
551
|
appendLine(meta.logPath, `--- watch ${new Date(now).toISOString()} interval_ms=${meta.intervalMs} ---`);
|
|
549
552
|
for (const line of sandboxNotices) appendLine(meta.logPath, `--- ${line} ---`);
|
|
550
553
|
writeMeta(meta);
|
|
554
|
+
scheduleLogRetention(pi, meta.id, getActiveSession);
|
|
551
555
|
let resolveFirst!: (check: FirstWatchCheck | "suspended" | undefined) => void;
|
|
552
556
|
const firstCheck = new Promise<FirstWatchCheck | "suspended" | undefined>((resolve) => { resolveFirst = resolve; });
|
|
553
557
|
firstCheckWaiters.set(id, { promise: firstCheck, resolve: resolveFirst });
|
|
@@ -592,6 +596,7 @@ export function resumeRunningTask(
|
|
|
592
596
|
writeMeta(meta);
|
|
593
597
|
}
|
|
594
598
|
|
|
599
|
+
scheduleLogRetention(pi, meta.id, getActiveSession);
|
|
595
600
|
const remoteTask = resolvePersistedRemoteTask(meta, dependencies.remoteRunner);
|
|
596
601
|
if (meta.kind === "command_watch") {
|
|
597
602
|
scheduleWatch(pi, meta.id, 0, getActiveSession, remoteTask
|
|
@@ -600,7 +605,6 @@ export function resumeRunningTask(
|
|
|
600
605
|
return meta;
|
|
601
606
|
}
|
|
602
607
|
|
|
603
|
-
scheduleLogRetention(meta.id);
|
|
604
608
|
if (meta.remote?.session === "tmux" && meta.remote.sessionStarted !== false && remoteTask) {
|
|
605
609
|
activeRemoteTasks.set(meta.id, remoteTask);
|
|
606
610
|
scheduleRemoteSessionPoll(pi, meta.id, 0, getActiveSession);
|
|
@@ -1155,6 +1159,7 @@ async function notifyTerminal(
|
|
|
1155
1159
|
meta: BackgroundTaskMeta,
|
|
1156
1160
|
getActiveSession?: ActiveSessionProvider,
|
|
1157
1161
|
): Promise<void> {
|
|
1162
|
+
observeTaskStallAttention(readMeta(meta.id) ?? meta);
|
|
1158
1163
|
// A suspended instance has no active session: delivering or suppressing here would
|
|
1159
1164
|
// be wrong either way. The instance that resumes the task delivers it (#324).
|
|
1160
1165
|
if (scheduledWorkSuspended) return;
|
|
@@ -1260,16 +1265,17 @@ function launchArgvOf(commandSpec: CommandSpec, launchSpec: CommandSpec): string
|
|
|
1260
1265
|
return launchSpec === commandSpec ? undefined : launchSpec.argv;
|
|
1261
1266
|
}
|
|
1262
1267
|
|
|
1263
|
-
function scheduleLogRetention(id: string): void {
|
|
1268
|
+
function scheduleLogRetention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
|
|
1264
1269
|
stopLogRetention(id);
|
|
1265
1270
|
if (scheduledWorkSuspended) return;
|
|
1266
1271
|
const timer = setInterval(() => {
|
|
1267
1272
|
const meta = readMeta(id);
|
|
1268
|
-
if (!meta || meta.status !== "running"
|
|
1273
|
+
if (!meta || meta.status !== "running") {
|
|
1269
1274
|
stopLogRetention(id);
|
|
1270
1275
|
return;
|
|
1271
1276
|
}
|
|
1272
|
-
enforceLogRetention(meta);
|
|
1277
|
+
if (meta.kind === "process") enforceLogRetention(meta);
|
|
1278
|
+
if (observeTaskStallAttention(meta)) scheduleFailureAttention(pi, id, getActiveSession);
|
|
1273
1279
|
}, LOG_RETENTION_CHECK_MS);
|
|
1274
1280
|
timer.unref();
|
|
1275
1281
|
logRetentionTimers.set(id, timer);
|
|
@@ -1283,8 +1289,10 @@ function stopLogRetention(id: string): void {
|
|
|
1283
1289
|
|
|
1284
1290
|
function enforceLogRetention(meta: BackgroundTaskMeta): void {
|
|
1285
1291
|
try {
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1292
|
+
const log = statSync(meta.logPath);
|
|
1293
|
+
const mtimeMs = Math.trunc(log.mtimeMs);
|
|
1294
|
+
const maintenance = meta.logMaintenance?.mtimeMs === mtimeMs && meta.logMaintenance.size === log.size;
|
|
1295
|
+
if (!maintenance && mtimeMs > (meta.lastProgressAt ?? meta.startedAt)) {
|
|
1288
1296
|
meta.lastProgressAt = mtimeMs;
|
|
1289
1297
|
writeMeta(meta);
|
|
1290
1298
|
}
|
|
@@ -1296,6 +1304,10 @@ function enforceLogRetention(meta: BackgroundTaskMeta): void {
|
|
|
1296
1304
|
meta.logDiscardedBytes = (meta.logDiscardedBytes ?? 0) + compacted.discardedBytes;
|
|
1297
1305
|
meta.logRetentionEvents = (meta.logRetentionEvents ?? 0) + 1;
|
|
1298
1306
|
meta.logGeneration = (meta.logGeneration ?? 0) + 1;
|
|
1307
|
+
try {
|
|
1308
|
+
const log = statSync(meta.logPath);
|
|
1309
|
+
meta.logMaintenance = { mtimeMs: Math.trunc(log.mtimeMs), size: log.size };
|
|
1310
|
+
} catch { /* Missing log remains an evidence gap. */ }
|
|
1299
1311
|
writeMeta(meta);
|
|
1300
1312
|
}
|
|
1301
1313
|
|