pi-better-background-tasks 0.2.20 → 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 CHANGED
@@ -39,6 +39,14 @@ If the foreground sandbox reports `unavailable` or `failed`, a local launch is
39
39
  refused with an explanation instead of running unconfined. `/sandbox off` is the
40
40
  deliberate way to run local tasks unsandboxed.
41
41
 
42
+ A task follows Main's permission profile. With Main's Outside project = Write, the
43
+ task writes across home and temp but can remove files outside the project only in
44
+ temp, hidden home directories, and worktree folders (see
45
+ [pi-better-sandbox](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme)).
46
+ On macOS, a task launched with Outside project = Write or Write & delete starts an
47
+ APFS local snapshot (`tmutil localsnapshot`) in the background as a recovery aid.
48
+ A failed snapshot is noted in the task log and never blocks the task.
49
+
42
50
  Structured remote SSH tasks are unaffected: the foreground sandbox describes this
43
51
  machine, and remote work keeps its existing remote semantics. Without
44
52
  `pi-better-sandbox` installed, local tasks behave exactly as they always have.
@@ -80,6 +88,21 @@ timeout can terminate the local SSH client but the remote process may still be
80
88
  running. See the detailed usage notes for bootstrap policy, watch conditions,
81
89
  timeouts, and v1 non-goals.
82
90
 
91
+ ## Reloads and session switches
92
+
93
+ Tasks belong to the Pi session that started them. After `/reload`, the same
94
+ session picks every task back up: watches keep polling, remote tmux output keeps
95
+ being collected, and a task that finished during the reload (or exits later) gets
96
+ its completion callback exactly once.
97
+
98
+ After `/new`, `/resume`, fork, or switching to another session, the previous
99
+ session's tasks keep running but are paused from Pi's side: watches do not poll,
100
+ remote tmux output is not collected, and `timeout_seconds` deadlines are not
101
+ enforced until that session is active again. An overdue deadline is enforced as
102
+ soon as the session resumes, so a timeout can land late but is never skipped. A
103
+ local process that exits in the meantime is recorded as finished, and its
104
+ callback is delivered when its session resumes.
105
+
83
106
  ## Watch conditions
84
107
 
85
108
  JSON conditions require a root-prefixed path, such as `$.status` or
@@ -125,13 +148,34 @@ an explicitly configured nonzero success exit is treated as expected. Verbose
125
148
  status includes observation details and the journal path. Corrupt or unreadable
126
149
  evidence is reported as **observation incomplete**.
127
150
 
128
- Unresolved running failures become eligible for attention after 60 seconds;
129
- observation gaps are eligible immediately. Terminal failures use the normal
151
+ Pass `operation_id` and `expected_exit_codes` on `bg_task_spawn`, `bg_task_watch`,
152
+ or `bg_task` to declare intent before launch; a malformed declaration starts
153
+ nothing. An exit code in `expected_exit_codes` (distinct integers 1-255, such as
154
+ `[1]` for a no-match probe) is recorded as an **Expected failure** that needs no
155
+ action; signals and timeouts never are. When a task with an `operation_id`
156
+ succeeds, earlier failed tasks with the same `operation_id`, kind, cwd, SSH
157
+ target, and owner (the same session id, or for sessionless tasks the same Pi
158
+ process) recover, so a retry with a changed command or timeout
159
+ closes the original incident. Observation gaps are never recovered this way, and
160
+ no command text is compared.
161
+
162
+ Task failures are labeled **Action required**; the shared labels also include
163
+ **Expected failure** and **Observation incomplete**. Unresolved running failures
164
+ become eligible for attention after 60 seconds; observation gaps are eligible
165
+ immediately. Each notification lists only the incidents it is delivering;
166
+ earlier ones are counted, not repeated. Terminal failures use the normal
130
167
  completion notification. A delivery receipt is stored only after handoff;
131
168
  notification delivery does not clear the failure. `callback:false` stays quiet
132
169
  while all inspection surfaces retain the evidence. Journals follow the task's
133
170
  existing retention and explicit-clear behavior.
134
171
 
172
+ Status, log, and list count and list only failures that need action (**Action
173
+ required** and **Observation incomplete**). Expected and closed failures are one
174
+ history count line with no incident cursor; pass `history: true` to
175
+ `bg_task_status` (or `action:status`) to page them. Incident rows are compact
176
+ (120-byte excerpt, evidence such as `output.log#poll=3`); the raw log keeps the
177
+ full evidence path.
178
+
135
179
  ## When To Use
136
180
 
137
181
  Use this package for shell commands that need logs, status, cancellation, or completion notifications across a Pi turn.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.20",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/failures.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { join } from "node:path";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
- failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
5
- observeFailures, pendingFailureAttention, readFailureState,
4
+ activeFailures, failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
5
+ observeFailures, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention, readCommandIntent, readFailureState,
6
+ type FailureEvent, type FailureState,
6
7
  } from "./shared-failure-observations.js";
7
8
  import { getCallbackBatcher } from "./shared-callback-batcher.js";
8
- import { readMeta, taskDir } from "./registry.js";
9
+ import { listMetasForOrigin, originOf, readMeta, taskDir } from "./registry.js";
9
10
  import type { ActiveSessionProvider } from "./runtime.js";
10
11
  import type { BackgroundTaskMeta } from "./types.js";
11
12
 
@@ -32,16 +33,112 @@ export function recoverFailure(meta: BackgroundTaskMeta, operation: string, even
32
33
  }]);
33
34
  }
34
35
 
36
+ /**
37
+ * Validate `operation_id` / `expected_exit_codes` before anything is launched (#325), with the same
38
+ * validator the subagent task runtime's bash uses. Background commands are launched by the parent
39
+ * agent itself, so the declaration is trusted; a malformed one refuses the launch.
40
+ */
41
+ export function readTaskIntent(params: { operation_id?: unknown; expected_exit_codes?: unknown }): Pick<BackgroundTaskMeta, "operationId" | "expectedExitCodes"> {
42
+ const { intent, error } = readCommandIntent({ operationId: params.operation_id, expectedExitCodes: params.expected_exit_codes },
43
+ { operationId: "operation_id", expectedExitCodes: "expected_exit_codes" });
44
+ if (error) throw new Error(`Invalid command intent: ${error}. The task was not started.`);
45
+ return { ...(intent.operationId ? { operationId: intent.operationId } : {}), ...(intent.expectedExitCodes ? { expectedExitCodes: intent.expectedExitCodes } : {}) };
46
+ }
47
+
48
+ /** A structured exit code the caller declared intentional before launch. Signals and timeouts never are. */
49
+ export function isDeclaredExpectedExit(meta: BackgroundTaskMeta, exitCode: number | null | undefined): exitCode is number {
50
+ return typeof exitCode === "number" && exitCode !== 0 && (meta.expectedExitCodes ?? []).includes(exitCode);
51
+ }
52
+
53
+ /** Record a non-zero command exit, classified as expected only when its code was declared before launch. */
54
+ export function recordExitFailure(meta: BackgroundTaskMeta, operation: string, summary: string, exitCode: number | null | undefined, eventKey: unknown,
55
+ options: { at?: number; expected?: boolean } = {}): void {
56
+ const declared = isDeclaredExpectedExit(meta, exitCode);
57
+ recordFailure(meta, operation, declared ? `${summary} (declared expected)` : summary, eventKey,
58
+ { category: "exit", expected: declared || options.expected === true, at: options.at });
59
+ }
60
+
61
+ function declaredOperation(meta: BackgroundTaskMeta): string | undefined {
62
+ return meta.operationId ? failureIdentity("bg-operation", meta.kind, meta.operationId, meta.cwd, meta.ssh?.target ?? null) : undefined;
63
+ }
64
+
65
+ /**
66
+ * Recovery crosses tasks only within one owner (#325): the same non-empty session id, or, for
67
+ * sessionless tasks, the same spawning process (#312's sessionless ownership rule). The origin
68
+ * index alone treats two sessionless sessions in one cwd as one origin.
69
+ */
70
+ function sameRecoveryOwner(a: BackgroundTaskMeta, b: BackgroundTaskMeta): boolean {
71
+ const sa = a.callbackOrigin?.sessionId, sb = b.callbackOrigin?.sessionId;
72
+ if (sa || sb) return Boolean(sa) && sa === sb;
73
+ return a.spawnPid === b.spawnPid && a.spawnPidStartTime === b.spawnPidStartTime;
74
+ }
75
+
76
+ /**
77
+ * A task that succeeded with a declared `operationId` recovers the unresolved failures of earlier
78
+ * tasks of the same owner (session, or spawning process when sessionless) that declared the same operation (same kind, cwd, and SSH target) and
79
+ * failed before this task started: a modified retry as a new task. Observation gaps and expected
80
+ * failures are left alone; nothing is matched on command text.
81
+ */
82
+ export function recoverDeclaredOperation(meta: BackgroundTaskMeta, at = Date.now()): void {
83
+ const operation = declaredOperation(meta);
84
+ if (!operation) return;
85
+ let earlier: BackgroundTaskMeta[];
86
+ try { earlier = listMetasForOrigin(originOf(meta)); } catch { return; }
87
+ for (const task of earlier) {
88
+ if (task.id === meta.id || task.startedAt >= meta.startedAt || declaredOperation(task) !== operation || !sameRecoveryOwner(task, meta)) continue;
89
+ const path = failurePath(task.id);
90
+ const events: FailureEvent[] = activeFailures(readFailureState(path))
91
+ .filter((x) => x.status === "unresolved" && x.category !== "observation-incomplete" && x.lastObservedAt <= meta.startedAt)
92
+ .map((x) => ({ id: failureIdentity(task.id, x.id, "recovered-by", meta.id), operation: x.operation, kind: "recovered", incidents: [x.id], at,
93
+ evidence: `task ${meta.id} (operation ${meta.operationId}) succeeded` }));
94
+ if (events.length) observeFailures(path, events);
95
+ }
96
+ }
97
+
35
98
  const attentionTimers = new Map<string, ReturnType<typeof setTimeout>>();
99
+ let attentionSuspended = false;
100
+ /** Session shutdown: stop this instance's attention timers; the next session_start reschedules (#324). */
101
+ export function suspendFailureAttention(): void {
102
+ attentionSuspended = true;
103
+ for (const timer of attentionTimers.values()) clearTimeout(timer);
104
+ attentionTimers.clear();
105
+ }
106
+ export function resumeFailureAttention(): void {
107
+ attentionSuspended = false;
108
+ }
36
109
  export function stopFailureAttention(id: string): void {
37
110
  const timer = attentionTimers.get(id);
38
111
  if (timer) clearTimeout(timer);
39
112
  attentionTimers.delete(id);
40
113
  }
41
114
 
115
+ /**
116
+ * Model-facing fields of a running task's failure attention. The notification
117
+ * identity is per incident set; the inspect target is the real task id.
118
+ * Only the pending incidents are rows; earlier deliveries are counted, never repeated (#315).
119
+ */
120
+ export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureState, pending: { key: string; incidents: string[] }) {
121
+ const rows = pendingAttentionRows(state, pending.incidents);
122
+ const note = pendingAttentionNote(state, pending.incidents);
123
+ const due = pending.incidents.length;
124
+ return {
125
+ source: "background-task" as const,
126
+ id: `failure:${meta.id}:${pending.key}`,
127
+ inspectId: meta.id,
128
+ label: meta.name ?? meta.id,
129
+ status: "failure",
130
+ customType: "background-task-failure",
131
+ content: `Background task ${meta.id} is still running with ${due} failure observation${due === 1 ? "" : "s"} that need attention.${note ? ` ${note}` : ""}`,
132
+ detailTool: "bg_task_status" as const,
133
+ failureRows: rows,
134
+ incidentCount: rows.length || undefined,
135
+ };
136
+ }
137
+
42
138
  /** Running incidents get one grace wake. Terminal incidents ride the completion callback. */
43
139
  export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
44
140
  stopFailureAttention(id);
141
+ if (attentionSuspended) return;
45
142
  const meta = readMeta(id);
46
143
  if (!meta) {
47
144
  const timer = setTimeout(() => scheduleFailureAttention(pi, id, getActiveSession), 1_000);
@@ -54,9 +151,7 @@ export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActive
54
151
  const pending = pendingFailureAttention(state, Date.now());
55
152
  if (pending) {
56
153
  const delivery = getCallbackBatcher(pi).deliverUrgent({
57
- source: "background-task", id: `failure:${id}:${pending.key}`, label: meta.name ?? id,
58
- status: "failure", customType: "background-task-failure",
59
- content: `Background task ${id}: ${pending.summary}\nInspect: bg_task_status id=${id}`,
154
+ ...failureAttentionFields(meta, state, pending),
60
155
  isDelivered: () => {
61
156
  const current = readMeta(id);
62
157
  if (!current) throw new Error("Task metadata is unavailable; defer failure notification");
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { disposeBackgroundWorkNavigator } from "./shared-navigator.ts";
3
3
  import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
4
4
  import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, ensureBackgroundTasksNavigatorProvider } from "./navigator-provider.js";
5
+ import { resumeScheduledWork, suspendScheduledWork } from "./runtime.js";
5
6
  import { observeForegroundSandboxPolicy } from "./sandbox.js";
6
7
  import { registerTools } from "./tools.js";
7
8
 
@@ -11,7 +12,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
11
12
  // Subscribed at load so a sandbox extension that publishes later is heard, and
12
13
  // asked for a snapshot in case one published before this extension existed.
13
14
  observeForegroundSandboxPolicy(pi);
15
+ // Registered before registerTools(pi), so this runs before running tasks are resumed.
14
16
  pi.on("session_start", async (_event, ctx) => {
17
+ resumeScheduledWork();
15
18
  registerBackgroundTasksGoalProvider(pi);
16
19
  ensureBackgroundTasksNavigator(ctx);
17
20
  });
@@ -20,6 +23,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
20
23
  disposeBackgroundWorkNavigator();
21
24
  });
22
25
  pi.on("session_shutdown", async (_event, ctx) => {
26
+ // /reload, session replacement, and quit load a fresh instance; stop this one's timers (#324).
27
+ suspendScheduledWork();
23
28
  clearBackgroundTasksNavigatorSession();
24
29
  disposeBackgroundWorkNavigator(ctx);
25
30
  });
package/src/logs.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { appendFileSync, closeSync, openSync, readSync, statSync, truncateSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { mkdirSync } from "node:fs";
4
- import { readBoundedTail, tailTerminalDisplay, terminalDisplayRows } from "./shared-log-utils.js";
5
- import type { CommandResult } from "./types.js";
4
+ import { pageRetainedFile, readBoundedTail, tailTerminalDisplay, terminalDisplayRows } from "./shared-log-utils.js";
5
+ import type { FilePageRequest, PageResult } from "./shared-log-utils.js";
6
+ import type { BackgroundTaskMeta, CommandResult } from "./types.js";
6
7
 
7
8
  export const DEFAULT_MAX_LOG_BYTES = 4 * 1024 * 1024;
8
9
  export const MAX_LOG_TAIL_READ_BYTES = 512 * 1024;
@@ -10,7 +11,10 @@ const RETAINED_LOG_FRACTION = 0.75;
10
11
 
11
12
  export function appendWatchResult(logPath: string, result: CommandResult): void {
12
13
  mkdirSync(dirname(logPath), { recursive: true });
13
- const header = `\n--- check ${new Date(result.startedAt).toISOString()} exit=${result.exitCode ?? "null"} signal=${result.signal ?? "null"} duration_ms=${result.endedAt - result.startedAt} ---\n`;
14
+ const capture = result.captureTruncated
15
+ ? ` capture_discarded_stdout=${result.stdoutDiscardedBytes ?? 0} capture_discarded_stderr=${result.stderrDiscardedBytes ?? 0}`
16
+ : "";
17
+ const header = `\n--- check ${new Date(result.startedAt).toISOString()} exit=${result.exitCode ?? "null"} signal=${result.signal ?? "null"} duration_ms=${result.endedAt - result.startedAt}${capture} ---\n`;
14
18
  appendFileSync(logPath, header);
15
19
  if (result.stdout) appendFileSync(logPath, result.stdout.endsWith("\n") ? result.stdout : `${result.stdout}\n`);
16
20
  if (result.stderr) appendFileSync(logPath, `[stderr]\n${result.stderr.endsWith("\n") ? result.stderr : `${result.stderr}\n`}`);
@@ -27,18 +31,58 @@ export function appendTaskOutput(logPath: string, output: string): void {
27
31
  appendFileSync(logPath, output);
28
32
  }
29
33
 
30
- export function readLog(logPath: string, tailLines?: number): { text: string; truncated: boolean } {
34
+ export interface LogRead {
35
+ text: string;
36
+ truncated: boolean;
37
+ totalBytes?: number;
38
+ error?: string;
39
+ /** Display rows before the returned tail within the bounded read window. */
40
+ omittedRows?: number;
41
+ }
42
+
43
+ export function readLog(logPath: string, tailLines?: number): LogRead {
31
44
  const requestedRows = tailLines && tailLines > 0 ? Math.floor(tailLines) : undefined;
32
45
  const tail = readBoundedTail(logPath, MAX_LOG_TAIL_READ_BYTES);
33
- if (!tail.text) return { text: "", truncated: tail.truncated };
34
- if (!requestedRows) return { text: tail.text, truncated: tail.truncated };
46
+ if (tail.error) {
47
+ return { text: "", truncated: tail.truncated, totalBytes: tail.totalBytes, error: tail.error };
48
+ }
49
+ if (!tail.text) return { text: "", truncated: false, totalBytes: tail.totalBytes };
50
+ if (!requestedRows) return { text: tail.text, truncated: tail.truncated, totalBytes: tail.totalBytes };
35
51
  const rows = terminalDisplayRows(tail.text);
52
+ const omittedRows = Math.max(0, rows.length - requestedRows);
36
53
  return {
37
54
  text: tailTerminalDisplay(tail.text, requestedRows),
38
- truncated: tail.truncated || rows.length > requestedRows,
55
+ truncated: tail.truncated || omittedRows > 0,
56
+ totalBytes: tail.totalBytes,
57
+ ...(omittedRows > 0 ? { omittedRows } : {}),
39
58
  };
40
59
  }
41
60
 
61
+ export function captureGapsFor(meta: BackgroundTaskMeta): Array<{ bytes: number; detail?: string }> {
62
+ const gaps: Array<{ bytes: number; detail?: string }> = [];
63
+ if (meta.stdoutDiscardedBytes) {
64
+ gaps.push({ bytes: meta.stdoutDiscardedBytes, detail: "stdout capture overflow" });
65
+ }
66
+ if (meta.stderrDiscardedBytes) {
67
+ gaps.push({ bytes: meta.stderrDiscardedBytes, detail: "stderr capture overflow" });
68
+ }
69
+ if (!gaps.length && meta.captureDiscardedBytes) {
70
+ gaps.push({ bytes: meta.captureDiscardedBytes, detail: `${meta.captureOverflowEvents ?? 1} capture overflow(s)` });
71
+ }
72
+ return gaps;
73
+ }
74
+
75
+ /** Page retained raw bytes with this task's generation and disclosed capture/retention loss. */
76
+ export function pageTaskLog(meta: BackgroundTaskMeta, request: FilePageRequest = {}): PageResult {
77
+ return pageRetainedFile(meta.logPath, {
78
+ ...request,
79
+ resource: request.resource ?? meta.id,
80
+ generation: request.generation ?? meta.logGeneration ?? 0,
81
+ discardedBytes: request.discardedBytes ?? meta.logDiscardedBytes,
82
+ captureGaps: request.captureGaps ?? captureGapsFor(meta),
83
+ });
84
+ }
85
+
42
86
  export function resolveMaxLogBytes(value: number | undefined): number {
43
87
  if (value === undefined || !Number.isFinite(value) || value <= 0) return DEFAULT_MAX_LOG_BYTES;
44
88
  return Math.max(64 * 1024, Math.floor(value));
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
+ DEFAULT_LOG_TAIL_ROWS as NAVIGATOR_DETAIL_ROWS,
3
4
  ensureBackgroundWorkNavigator,
4
5
  refreshBackgroundWorkNavigator,
5
6
  registerBackgroundWorkProvider,
@@ -9,7 +10,7 @@ import {
9
10
  } from "./shared-navigator.ts";
10
11
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
12
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
- import { activeFailures, readFailureState } from "./shared-failure-observations.js";
13
+ import { activeFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
13
14
  import { failurePath, failureSummary } from "./failures.js";
14
15
  import { readLog } from "./logs.js";
15
16
  import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
@@ -129,7 +130,7 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
129
130
  function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
130
131
  if (!meta) return null;
131
132
  const failure = failureSummary(meta.id);
132
- const log = readLog(meta.logPath, options?.logTailLines ?? 10);
133
+ const log = readLog(meta.logPath, options?.logTailLines ?? NAVIGATOR_DETAIL_ROWS);
133
134
  const command = commandLabel(meta);
134
135
  const metadata = [
135
136
  { label: "provider", value: "Background Tasks" },
@@ -217,7 +218,7 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
217
218
  function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
218
219
  const facts: string[] = [];
219
220
  const incident = activeFailures(readFailureState(failurePath(meta.id)))[0];
220
- if (incident) facts.push(`${incident.category === "observation-incomplete" ? "Observation incomplete" : incident.status === "expected" ? "Expected failure" : "Unresolved failure"}: ${incident.summary}`);
221
+ if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
221
222
  if (meta.status === "running") {
222
223
  const stall = observeBackgroundTaskStall(meta, now);
223
224
  if (stall.state === "stalled") facts.push("stalled");