pi-better-background-tasks 0.2.20 → 0.3.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
@@ -125,8 +125,11 @@ an explicitly configured nonzero success exit is treated as expected. Verbose
125
125
  status includes observation details and the journal path. Corrupt or unreadable
126
126
  evidence is reported as **observation incomplete**.
127
127
 
128
- Unresolved running failures become eligible for attention after 60 seconds;
129
- observation gaps are eligible immediately. Terminal failures use the normal
128
+ Task failures are labeled **Action required**; the shared labels also include
129
+ **Expected failure** and **Observation incomplete**. Unresolved running failures
130
+ become eligible for attention after 60 seconds; observation gaps are eligible
131
+ immediately. Each notification lists only the incidents it is delivering;
132
+ earlier ones are counted, not repeated. Terminal failures use the normal
130
133
  completion notification. A delivery receipt is stored only after handoff;
131
134
  notification delivery does not clear the failure. `callback:false` stays quiet
132
135
  while all inspection surfaces retain the evidence. Journals follow the task's
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.3.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
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
4
  failureAttentionHandled, failureIdentity, formatFailureSummary, markFailureAttentionDelivered,
5
- observeFailures, pendingFailureAttention, readFailureState,
5
+ observeFailures, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention, readFailureState, type FailureState,
6
6
  } from "./shared-failure-observations.js";
7
7
  import { getCallbackBatcher } from "./shared-callback-batcher.js";
8
8
  import { readMeta, taskDir } from "./registry.js";
@@ -39,6 +39,29 @@ export function stopFailureAttention(id: string): void {
39
39
  attentionTimers.delete(id);
40
40
  }
41
41
 
42
+ /**
43
+ * Model-facing fields of a running task's failure attention. The notification
44
+ * identity is per incident set; the inspect target is the real task id.
45
+ * Only the pending incidents are rows; earlier deliveries are counted, never repeated (#315).
46
+ */
47
+ export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureState, pending: { key: string; incidents: string[] }) {
48
+ const rows = pendingAttentionRows(state, pending.incidents);
49
+ const note = pendingAttentionNote(state, pending.incidents);
50
+ const due = pending.incidents.length;
51
+ return {
52
+ source: "background-task" as const,
53
+ id: `failure:${meta.id}:${pending.key}`,
54
+ inspectId: meta.id,
55
+ label: meta.name ?? meta.id,
56
+ status: "failure",
57
+ customType: "background-task-failure",
58
+ content: `Background task ${meta.id} is still running with ${due} failure observation${due === 1 ? "" : "s"} that need attention.${note ? ` ${note}` : ""}`,
59
+ detailTool: "bg_task_status" as const,
60
+ failureRows: rows,
61
+ incidentCount: rows.length || undefined,
62
+ };
63
+ }
64
+
42
65
  /** Running incidents get one grace wake. Terminal incidents ride the completion callback. */
43
66
  export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
44
67
  stopFailureAttention(id);
@@ -54,9 +77,7 @@ export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActive
54
77
  const pending = pendingFailureAttention(state, Date.now());
55
78
  if (pending) {
56
79
  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}`,
80
+ ...failureAttentionFields(meta, state, pending),
60
81
  isDelivered: () => {
61
82
  const current = readMeta(id);
62
83
  if (!current) throw new Error("Task metadata is unavailable; defer failure notification");
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));
@@ -9,7 +9,7 @@ import {
9
9
  } from "./shared-navigator.ts";
10
10
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
- import { activeFailures, readFailureState } from "./shared-failure-observations.js";
12
+ import { activeFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
13
13
  import { failurePath, failureSummary } from "./failures.js";
14
14
  import { readLog } from "./logs.js";
15
15
  import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
@@ -217,7 +217,7 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
217
217
  function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
218
218
  const facts: string[] = [];
219
219
  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}`);
220
+ if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
221
221
  if (meta.status === "running") {
222
222
  const stall = observeBackgroundTaskStall(meta, now);
223
223
  if (stall.state === "stalled") facts.push("stalled");