pi-better-background-tasks 0.2.19 → 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 +5 -2
- package/package.json +1 -1
- package/src/failures.ts +25 -4
- package/src/logs.ts +51 -7
- package/src/navigator-provider.ts +2 -2
- package/src/output.ts +753 -0
- package/src/process.ts +95 -8
- package/src/registry.ts +84 -7
- package/src/runtime.ts +55 -16
- package/src/shared-callback-batcher.ts +327 -29
- package/src/shared-failure-observations.ts +501 -22
- package/src/shared-log-utils.ts +1025 -2
- package/src/shared-sandbox-core.ts +11 -8
- package/src/tools.ts +105 -135
- package/src/types.ts +16 -0
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
|
-
|
|
129
|
-
|
|
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
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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 (
|
|
34
|
-
|
|
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 ||
|
|
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
|
|
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");
|