pi-better-harness 0.6.1 → 0.8.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 +43 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/failures.ts +85 -3
- package/node_modules/pi-better-background-tasks/src/index.ts +5 -0
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +11 -7
- package/node_modules/pi-better-background-tasks/src/output.ts +83 -49
- package/node_modules/pi-better-background-tasks/src/runtime.ts +189 -17
- package/node_modules/pi-better-background-tasks/src/sandbox.ts +6 -4
- package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +34 -3
- package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +414 -47
- package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +108 -0
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +180 -134
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +724 -17
- package/node_modules/pi-better-background-tasks/src/tools.ts +107 -32
- package/node_modules/pi-better-background-tasks/src/types.ts +7 -0
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-plan/README.md +30 -5
- package/node_modules/pi-better-plan/package.json +1 -1
- package/node_modules/pi-better-plan/src/index.ts +88 -16
- package/node_modules/pi-better-plan/src/workflow-plan-update.ts +503 -0
- package/node_modules/pi-better-plan/src/workflow-plan.ts +24 -6
- package/node_modules/pi-better-sandbox/README.md +143 -15
- package/node_modules/pi-better-sandbox/index.ts +11 -1
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +141 -10
- package/node_modules/pi-better-sandbox/permissions.ts +54 -7
- package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +724 -17
- package/node_modules/pi-better-sandbox/shared-task-apply-patch.ts +409 -0
- package/node_modules/pi-better-sandbox/shared-task-files.ts +69 -7
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +21 -5
- package/node_modules/pi-better-sandbox/shared-task-tools.ts +111 -0
- package/node_modules/pi-better-sandbox/state.ts +5 -1
- package/node_modules/pi-better-subagents/README.md +13 -1
- package/node_modules/pi-better-subagents/agent-commands.ts +26 -7
- package/node_modules/pi-better-subagents/agent-inspection.ts +4 -1
- package/node_modules/pi-better-subagents/batch.mjs +7 -0
- package/node_modules/pi-better-subagents/callback-fields.ts +4 -1
- package/node_modules/pi-better-subagents/catalog-ui.ts +167 -0
- package/node_modules/pi-better-subagents/child-incidents.ts +20 -6
- package/node_modules/pi-better-subagents/child-steer.ts +102 -0
- package/node_modules/pi-better-subagents/cleanup.ts +18 -2
- package/node_modules/pi-better-subagents/config.json +1 -0
- package/node_modules/pi-better-subagents/config.ts +5 -1
- package/node_modules/pi-better-subagents/delegation.ts +22 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +2 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog.md +9 -0
- package/node_modules/pi-better-subagents/docs/failure-observations.md +18 -5
- package/node_modules/pi-better-subagents/failures.ts +83 -8
- package/node_modules/pi-better-subagents/incident-model.ts +73 -55
- package/node_modules/pi-better-subagents/index.ts +352 -25
- package/node_modules/pi-better-subagents/list.mjs +4 -1
- package/node_modules/pi-better-subagents/output-payload.ts +152 -58
- package/node_modules/pi-better-subagents/package.json +4 -4
- package/node_modules/pi-better-subagents/parse.ts +6 -0
- package/node_modules/pi-better-subagents/permission-policy.ts +15 -7
- package/node_modules/pi-better-subagents/registry.ts +79 -1
- package/node_modules/pi-better-subagents/roles/role.architect.md +3 -3
- package/node_modules/pi-better-subagents/roles/role.developer.md +3 -3
- package/node_modules/pi-better-subagents/roles/role.explorer.md +3 -4
- package/node_modules/pi-better-subagents/roles/role.product-manager.md +3 -4
- package/node_modules/pi-better-subagents/roles/role.researcher.md +3 -4
- package/node_modules/pi-better-subagents/roles/role.reviewer.md +3 -3
- package/node_modules/pi-better-subagents/shared-callback-batcher.ts +34 -3
- package/node_modules/pi-better-subagents/shared-failure-observations.ts +414 -47
- package/node_modules/pi-better-subagents/shared-log-utils.ts +108 -0
- package/node_modules/pi-better-subagents/shared-navigator.ts +180 -134
- package/node_modules/pi-better-subagents/shared-sandbox-core.ts +724 -17
- package/node_modules/pi-better-subagents/shared-task-apply-patch.ts +409 -0
- package/node_modules/pi-better-subagents/shared-task-files.ts +69 -7
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +21 -5
- package/node_modules/pi-better-subagents/shared-task-tools.ts +111 -0
- package/node_modules/pi-better-subagents/spawn.ts +3 -1
- package/node_modules/pi-better-subagents/subagent-tools.ts +95 -0
- package/node_modules/pi-better-subagents/task-guard.ts +42 -7
- package/node_modules/pi-better-subagents/task-policy.ts +45 -5
- package/node_modules/pi-better-subagents/timing.ts +425 -0
- package/node_modules/pi-better-subagents/tools.ts +52 -14
- package/package.json +5 -5
|
@@ -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,23 @@ 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.
|
|
103
|
+
While the same Pi process stays open, a local process that exits in the meantime
|
|
104
|
+
is recorded as finished, and its callback is delivered when its session resumes.
|
|
105
|
+
If you quit Pi first, nothing records that exit: when the session is resumed in a
|
|
106
|
+
new Pi process, a task whose process is gone is marked lost.
|
|
107
|
+
|
|
83
108
|
## Watch conditions
|
|
84
109
|
|
|
85
110
|
JSON conditions require a root-prefixed path, such as `$.status` or
|
|
@@ -125,6 +150,17 @@ an explicitly configured nonzero success exit is treated as expected. Verbose
|
|
|
125
150
|
status includes observation details and the journal path. Corrupt or unreadable
|
|
126
151
|
evidence is reported as **observation incomplete**.
|
|
127
152
|
|
|
153
|
+
Pass `operation_id` and `expected_exit_codes` on `bg_task_spawn`, `bg_task_watch`,
|
|
154
|
+
or `bg_task` to declare intent before launch; a malformed declaration starts
|
|
155
|
+
nothing. An exit code in `expected_exit_codes` (distinct integers 1-255, such as
|
|
156
|
+
`[1]` for a no-match probe; a `0` is ignored) is recorded as an **Expected failure** that needs no
|
|
157
|
+
action; signals and timeouts never are. When a task with an `operation_id`
|
|
158
|
+
succeeds, earlier failed tasks with the same `operation_id`, kind, cwd, SSH
|
|
159
|
+
target, and owner (the same session id, or for sessionless tasks the same Pi
|
|
160
|
+
process) recover, so a retry with a changed command or timeout
|
|
161
|
+
closes the original incident. Observation gaps are never recovered this way, and
|
|
162
|
+
no command text is compared.
|
|
163
|
+
|
|
128
164
|
Task failures are labeled **Action required**; the shared labels also include
|
|
129
165
|
**Expected failure** and **Observation incomplete**. Unresolved running failures
|
|
130
166
|
become eligible for attention after 60 seconds; observation gaps are eligible
|
|
@@ -135,6 +171,13 @@ notification delivery does not clear the failure. `callback:false` stays quiet
|
|
|
135
171
|
while all inspection surfaces retain the evidence. Journals follow the task's
|
|
136
172
|
existing retention and explicit-clear behavior.
|
|
137
173
|
|
|
174
|
+
Status, log, and list count and list only failures that need action (**Action
|
|
175
|
+
required** and **Observation incomplete**). Expected and closed failures are one
|
|
176
|
+
history count line with no incident cursor; pass `history: true` to
|
|
177
|
+
`bg_task_status` (or `action:status`) to page them. Incident rows are compact
|
|
178
|
+
(120-byte excerpt, evidence such as `output.log#poll=3`); the raw log keeps the
|
|
179
|
+
full evidence path.
|
|
180
|
+
|
|
138
181
|
## When To Use
|
|
139
182
|
|
|
140
183
|
Use this package for shell commands that need logs, status, cancellation, or completion notifications across a Pi turn.
|
|
@@ -1,16 +1,25 @@
|
|
|
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, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention,
|
|
4
|
+
actionableFailures, 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
|
|
|
12
13
|
export const failurePath = (id: string): string => join(taskDir(id), "failures.jsonl");
|
|
13
14
|
export const failureSummary = (id: string): string => formatFailureSummary(readFailureState(failurePath(id)));
|
|
15
|
+
/**
|
|
16
|
+
* The failure summary plus whether anything needs action. A navigator row leads with failure text
|
|
17
|
+
* only when something does; the quiet history line never displaces the command (#332).
|
|
18
|
+
*/
|
|
19
|
+
export function failureView(id: string): { text: string; actionable: boolean } {
|
|
20
|
+
const state = readFailureState(failurePath(id));
|
|
21
|
+
return { text: formatFailureSummary(state), actionable: actionableFailures(state).length > 0 };
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
export function recordFailure(meta: BackgroundTaskMeta, operation: string, summary: string, eventKey: unknown,
|
|
16
25
|
options: { category?: string; expected?: boolean; incomplete?: boolean; evidence?: string; at?: number } = {}): void {
|
|
@@ -32,7 +41,79 @@ export function recoverFailure(meta: BackgroundTaskMeta, operation: string, even
|
|
|
32
41
|
}]);
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Validate `operation_id` / `expected_exit_codes` before anything is launched (#325), with the same
|
|
46
|
+
* validator the subagent task runtime's bash uses. Background commands are launched by the parent
|
|
47
|
+
* agent itself, so the declaration is trusted; a malformed one refuses the launch.
|
|
48
|
+
*/
|
|
49
|
+
export function readTaskIntent(params: { operation_id?: unknown; expected_exit_codes?: unknown }): Pick<BackgroundTaskMeta, "operationId" | "expectedExitCodes"> {
|
|
50
|
+
const { intent, error } = readCommandIntent({ operationId: params.operation_id, expectedExitCodes: params.expected_exit_codes },
|
|
51
|
+
{ operationId: "operation_id", expectedExitCodes: "expected_exit_codes" });
|
|
52
|
+
if (error) throw new Error(`Invalid command intent: ${error}. The task was not started.`);
|
|
53
|
+
return { ...(intent.operationId ? { operationId: intent.operationId } : {}), ...(intent.expectedExitCodes ? { expectedExitCodes: intent.expectedExitCodes } : {}) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A structured exit code the caller declared intentional before launch. Signals and timeouts never are. */
|
|
57
|
+
export function isDeclaredExpectedExit(meta: BackgroundTaskMeta, exitCode: number | null | undefined): exitCode is number {
|
|
58
|
+
return typeof exitCode === "number" && exitCode !== 0 && (meta.expectedExitCodes ?? []).includes(exitCode);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Record a non-zero command exit, classified as expected only when its code was declared before launch. */
|
|
62
|
+
export function recordExitFailure(meta: BackgroundTaskMeta, operation: string, summary: string, exitCode: number | null | undefined, eventKey: unknown,
|
|
63
|
+
options: { at?: number; expected?: boolean } = {}): void {
|
|
64
|
+
const declared = isDeclaredExpectedExit(meta, exitCode);
|
|
65
|
+
recordFailure(meta, operation, declared ? `${summary} (declared expected)` : summary, eventKey,
|
|
66
|
+
{ category: "exit", expected: declared || options.expected === true, at: options.at });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function declaredOperation(meta: BackgroundTaskMeta): string | undefined {
|
|
70
|
+
return meta.operationId ? failureIdentity("bg-operation", meta.kind, meta.operationId, meta.cwd, meta.ssh?.target ?? null) : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Recovery crosses tasks only within one owner (#325): the same non-empty session id, or, for
|
|
75
|
+
* sessionless tasks, the same spawning process (#312's sessionless ownership rule). The origin
|
|
76
|
+
* index alone treats two sessionless sessions in one cwd as one origin.
|
|
77
|
+
*/
|
|
78
|
+
function sameRecoveryOwner(a: BackgroundTaskMeta, b: BackgroundTaskMeta): boolean {
|
|
79
|
+
const sa = a.callbackOrigin?.sessionId, sb = b.callbackOrigin?.sessionId;
|
|
80
|
+
if (sa || sb) return Boolean(sa) && sa === sb;
|
|
81
|
+
return a.spawnPid === b.spawnPid && a.spawnPidStartTime === b.spawnPidStartTime;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A task that succeeded with a declared `operationId` recovers the unresolved failures of earlier
|
|
86
|
+
* tasks of the same owner (session, or spawning process when sessionless) that declared the same operation (same kind, cwd, and SSH target) and
|
|
87
|
+
* failed before this task started: a modified retry as a new task. Observation gaps and expected
|
|
88
|
+
* failures are left alone; nothing is matched on command text.
|
|
89
|
+
*/
|
|
90
|
+
export function recoverDeclaredOperation(meta: BackgroundTaskMeta, at = Date.now()): void {
|
|
91
|
+
const operation = declaredOperation(meta);
|
|
92
|
+
if (!operation) return;
|
|
93
|
+
let earlier: BackgroundTaskMeta[];
|
|
94
|
+
try { earlier = listMetasForOrigin(originOf(meta)); } catch { return; }
|
|
95
|
+
for (const task of earlier) {
|
|
96
|
+
if (task.id === meta.id || task.startedAt >= meta.startedAt || declaredOperation(task) !== operation || !sameRecoveryOwner(task, meta)) continue;
|
|
97
|
+
const path = failurePath(task.id);
|
|
98
|
+
const events: FailureEvent[] = activeFailures(readFailureState(path))
|
|
99
|
+
.filter((x) => x.status === "unresolved" && x.category !== "observation-incomplete" && x.lastObservedAt <= meta.startedAt)
|
|
100
|
+
.map((x) => ({ id: failureIdentity(task.id, x.id, "recovered-by", meta.id), operation: x.operation, kind: "recovered", incidents: [x.id], at,
|
|
101
|
+
evidence: `task ${meta.id} (operation ${meta.operationId}) succeeded` }));
|
|
102
|
+
if (events.length) observeFailures(path, events);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
35
106
|
const attentionTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
107
|
+
let attentionSuspended = false;
|
|
108
|
+
/** Session shutdown: stop this instance's attention timers; the next session_start reschedules (#324). */
|
|
109
|
+
export function suspendFailureAttention(): void {
|
|
110
|
+
attentionSuspended = true;
|
|
111
|
+
for (const timer of attentionTimers.values()) clearTimeout(timer);
|
|
112
|
+
attentionTimers.clear();
|
|
113
|
+
}
|
|
114
|
+
export function resumeFailureAttention(): void {
|
|
115
|
+
attentionSuspended = false;
|
|
116
|
+
}
|
|
36
117
|
export function stopFailureAttention(id: string): void {
|
|
37
118
|
const timer = attentionTimers.get(id);
|
|
38
119
|
if (timer) clearTimeout(timer);
|
|
@@ -65,6 +146,7 @@ export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureS
|
|
|
65
146
|
/** Running incidents get one grace wake. Terminal incidents ride the completion callback. */
|
|
66
147
|
export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
|
|
67
148
|
stopFailureAttention(id);
|
|
149
|
+
if (attentionSuspended) return;
|
|
68
150
|
const meta = readMeta(id);
|
|
69
151
|
if (!meta) {
|
|
70
152
|
const timer = setTimeout(() => scheduleFailureAttention(pi, id, getActiveSession), 1_000);
|
|
@@ -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
|
});
|
|
@@ -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,8 +10,8 @@ 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 {
|
|
13
|
-
import { failurePath,
|
|
13
|
+
import { actionableFailures, failureLabel, readFailureState } from "./shared-failure-observations.js";
|
|
14
|
+
import { failurePath, failureView } from "./failures.js";
|
|
14
15
|
import { readLog } from "./logs.js";
|
|
15
16
|
import { listMetasForOrigin, onMetaChanged, readMeta, writeMeta } from "./registry.js";
|
|
16
17
|
import { stopTask } from "./runtime.js";
|
|
@@ -104,7 +105,9 @@ function isExpiredTerminalNavigatorRow(meta: BackgroundTaskMeta, now: number): b
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
|
|
107
|
-
|
|
108
|
+
// Only a failure that needs action replaces the row's command; history stays in the detail view (#332).
|
|
109
|
+
const view = failureView(meta.id);
|
|
110
|
+
const failure = view.actionable ? view.text : "";
|
|
108
111
|
const elapsed = formatDuration((meta.endedAt ?? now) - meta.startedAt);
|
|
109
112
|
return {
|
|
110
113
|
providerId: "background-tasks",
|
|
@@ -128,8 +131,9 @@ function rowFromMeta(meta: BackgroundTaskMeta, now: number): BackgroundWorkRow {
|
|
|
128
131
|
|
|
129
132
|
function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
|
|
130
133
|
if (!meta) return null;
|
|
131
|
-
const
|
|
132
|
-
const
|
|
134
|
+
const view = failureView(meta.id);
|
|
135
|
+
const failure = view.text;
|
|
136
|
+
const log = readLog(meta.logPath, options?.logTailLines ?? NAVIGATOR_DETAIL_ROWS);
|
|
133
137
|
const command = commandLabel(meta);
|
|
134
138
|
const metadata = [
|
|
135
139
|
{ label: "provider", value: "Background Tasks" },
|
|
@@ -163,7 +167,7 @@ function detailFromMeta(meta: BackgroundTaskMeta | undefined, now: number, optio
|
|
|
163
167
|
title: meta.name || meta.id,
|
|
164
168
|
status: meta.status,
|
|
165
169
|
statusTone: toneForStatus(meta.status),
|
|
166
|
-
subtitle:
|
|
170
|
+
subtitle: view.actionable ? failure.split("\n")[0]! : compactCommandLabel(meta),
|
|
167
171
|
metadata,
|
|
168
172
|
foldedSections: [{
|
|
169
173
|
id: "command",
|
|
@@ -216,7 +220,7 @@ function secondaryLabel(meta: BackgroundTaskMeta): string | undefined {
|
|
|
216
220
|
|
|
217
221
|
function factsForMeta(meta: BackgroundTaskMeta, now: number): string[] {
|
|
218
222
|
const facts: string[] = [];
|
|
219
|
-
const incident =
|
|
223
|
+
const incident = actionableFailures(readFailureState(failurePath(meta.id)))[0];
|
|
220
224
|
if (incident) facts.push(`${failureLabel(incident)}: ${incident.summary}`);
|
|
221
225
|
if (meta.status === "running") {
|
|
222
226
|
const stall = observeBackgroundTaskStall(meta, now);
|
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import { statSync } from "node:fs";
|
|
2
1
|
import {
|
|
3
|
-
|
|
2
|
+
actionableFailures,
|
|
4
3
|
failureRevision,
|
|
5
4
|
formatFailureLines,
|
|
6
5
|
formatFailureSummary,
|
|
7
6
|
formatIncidentSummary,
|
|
7
|
+
historyTailLine,
|
|
8
|
+
incidentCursorScope,
|
|
9
|
+
incidentPageHeading,
|
|
10
|
+
incidentResource,
|
|
11
|
+
incidentVerbatimPage,
|
|
8
12
|
isIncidentCursor,
|
|
9
|
-
|
|
13
|
+
quietFailureLine,
|
|
10
14
|
readFailureState,
|
|
15
|
+
scopedFailures,
|
|
11
16
|
type FailureState,
|
|
17
|
+
type IncidentDetail,
|
|
18
|
+
type IncidentScope,
|
|
12
19
|
} from "./shared-failure-observations.js";
|
|
13
20
|
import {
|
|
14
21
|
assemblePriorityEnvelope,
|
|
@@ -16,9 +23,11 @@ import {
|
|
|
16
23
|
cursorKind,
|
|
17
24
|
formatUnchangedEvidence,
|
|
18
25
|
inspectStatusRevision,
|
|
26
|
+
lifecycleContentRevision,
|
|
19
27
|
pageRows,
|
|
20
28
|
pageVerbatimText,
|
|
21
29
|
revisionOf,
|
|
30
|
+
sessionScopeKey,
|
|
22
31
|
sliceUtf8Bytes,
|
|
23
32
|
utf8ByteLength,
|
|
24
33
|
OUTPUT_BUDGET_BYTES,
|
|
@@ -66,6 +75,8 @@ export interface OutputOptions {
|
|
|
66
75
|
cursor?: string;
|
|
67
76
|
maxBytes?: number;
|
|
68
77
|
verbose?: boolean;
|
|
78
|
+
/** Status: return an incident page that also lists failure history (expected and closed incidents). */
|
|
79
|
+
history?: boolean;
|
|
69
80
|
tailLines?: number;
|
|
70
81
|
raw?: boolean;
|
|
71
82
|
statuses?: string[];
|
|
@@ -112,11 +123,7 @@ function stringifyObserved(value: unknown): string {
|
|
|
112
123
|
|
|
113
124
|
/** Cursor scope: pagination and revision cursors never cross session scopes. */
|
|
114
125
|
function scopeKey(options: OutputOptions): string {
|
|
115
|
-
|
|
116
|
-
if (options.sessionUnavailable) return "unavailable";
|
|
117
|
-
const origin = options.origin;
|
|
118
|
-
if (!origin) return "none";
|
|
119
|
-
return revisionOf([origin.cwd, origin.sessionId ?? ""]);
|
|
126
|
+
return sessionScopeKey({ all: options.all, unavailable: options.sessionUnavailable, origin: options.origin });
|
|
120
127
|
}
|
|
121
128
|
|
|
122
129
|
function taskGaps(meta: BackgroundTaskMeta): EvidenceGap[] {
|
|
@@ -143,39 +150,31 @@ function failureStateFor(id: string): FailureState {
|
|
|
143
150
|
* incident rows when they fit, otherwise a count line (total / shown /
|
|
144
151
|
* omitted) with an incident cursor that resumes at the first byte not shown.
|
|
145
152
|
*/
|
|
146
|
-
function incidentSection(id: string, options: OutputOptions, state = failureStateFor(id)): ((budget: number) => string | undefined) | undefined {
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
function incidentSection(id: string, options: OutputOptions, state = failureStateFor(id), detail: IncidentDetail = "compact"): ((budget: number) => string | undefined) | undefined {
|
|
154
|
+
// Only what needs action is listed; history (expected, closed) is one count line without a cursor.
|
|
155
|
+
if (!formatIncidentSummary(state, { maxBytes: Number.MAX_SAFE_INTEGER }).text) return undefined;
|
|
156
|
+
const resource = incidentResource(scopeKey(options), id);
|
|
149
157
|
return (budget) => formatIncidentSummary(state, {
|
|
150
158
|
maxBytes: budget,
|
|
151
159
|
resource,
|
|
152
160
|
retrieval: `pass as cursor to bg_task_status id=${id}`,
|
|
161
|
+
detail,
|
|
153
162
|
}).text || undefined;
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
function assembleIncidentPage(meta: BackgroundTaskMeta, options: OutputOptions): string {
|
|
157
166
|
const state = failureStateFor(meta.id);
|
|
158
|
-
const resource =
|
|
159
|
-
const
|
|
167
|
+
const resource = incidentResource(scopeKey(options), meta.id);
|
|
168
|
+
const cursor = isIncidentCursor(options.cursor) ? options.cursor : undefined;
|
|
169
|
+
const scope: IncidentScope = options.history ? "all" : incidentCursorScope(cursor) ?? "actionable";
|
|
160
170
|
return assembleBackgroundContent({
|
|
161
171
|
surface: "status",
|
|
162
172
|
maxBytes: options.maxBytes,
|
|
163
173
|
sections: {
|
|
164
|
-
identity: `${identityLine(meta)}
|
|
174
|
+
identity: `${identityLine(meta)} ${incidentPageHeading(scopedFailures(state, scope).length, scope)}`,
|
|
165
175
|
decision: formatDecision(meta),
|
|
166
176
|
},
|
|
167
|
-
verbatim: (budget) => {
|
|
168
|
-
const page = pageFailureIncidents(state, { cursor: options.cursor, maxBytes: budget, resource });
|
|
169
|
-
return {
|
|
170
|
-
text: page.text || (page.total === 0 ? "No active failure observations." : ""),
|
|
171
|
-
hasMore: page.hasMore,
|
|
172
|
-
cursor: page.cursor,
|
|
173
|
-
nextCursor: page.nextCursor,
|
|
174
|
-
omittedBytes: 0,
|
|
175
|
-
omittedRows: page.omitted,
|
|
176
|
-
reset: page.reset,
|
|
177
|
-
};
|
|
178
|
-
},
|
|
177
|
+
verbatim: (budget) => incidentVerbatimPage(state, { cursor, scope, maxBytes: budget, resource }),
|
|
179
178
|
});
|
|
180
179
|
}
|
|
181
180
|
|
|
@@ -240,7 +239,10 @@ export function formatCallbackFacts(meta: BackgroundTaskMeta): {
|
|
|
240
239
|
meta.logDiscardedBytes ? `retention discarded ${meta.logDiscardedBytes} bytes; not recoverable` : undefined,
|
|
241
240
|
meta.captureDiscardedBytes ? `capture overflow discarded ${meta.captureDiscardedBytes} bytes; not full history` : undefined,
|
|
242
241
|
].filter((line): line is string => Boolean(line));
|
|
243
|
-
|
|
242
|
+
// History (expected and closed incidents) is not a row, but the callback still says it exists, so a
|
|
243
|
+
// declared expected exit is not read as a plain failure.
|
|
244
|
+
const history = rows.length ? historyTailLine(state) : quietFailureLine(state);
|
|
245
|
+
const decision = [formatDecision(meta), ...gapLines, history].filter(Boolean).join("\n") || undefined;
|
|
244
246
|
return {
|
|
245
247
|
outcome: meta.status,
|
|
246
248
|
...(rows.length ? { failureRows: rows, incidentCount: rows.length } : {}),
|
|
@@ -284,14 +286,7 @@ function identityLine(meta: BackgroundTaskMeta): string {
|
|
|
284
286
|
|
|
285
287
|
/** Lifecycle, result, and retained-log facts. A deleted or rewritten log is a change. */
|
|
286
288
|
function contentRevision(meta: BackgroundTaskMeta): string {
|
|
287
|
-
|
|
288
|
-
try {
|
|
289
|
-
const stats = statSync(meta.logPath);
|
|
290
|
-
log = [stats.dev, stats.ino, stats.size, Math.trunc(stats.mtimeMs)];
|
|
291
|
-
} catch (error) {
|
|
292
|
-
log = ["unreadable", (error as NodeJS.ErrnoException).code ?? String(error)];
|
|
293
|
-
}
|
|
294
|
-
return revisionOf([
|
|
289
|
+
return lifecycleContentRevision([
|
|
295
290
|
meta.status,
|
|
296
291
|
meta.endedAt ?? null,
|
|
297
292
|
meta.lastCheckedAt ?? null,
|
|
@@ -307,8 +302,7 @@ function contentRevision(meta: BackgroundTaskMeta): string {
|
|
|
307
302
|
meta.lastState ?? null,
|
|
308
303
|
meta.remote?.bootstrapStatus ?? null,
|
|
309
304
|
meta.remote?.stopMessage ?? null,
|
|
310
|
-
|
|
311
|
-
]);
|
|
305
|
+
], meta.logPath);
|
|
312
306
|
}
|
|
313
307
|
|
|
314
308
|
export function assembleBackgroundContent(input: {
|
|
@@ -376,14 +370,43 @@ function formatOwnershipGap(id: string, kind: "foreign" | "unknown", options: Ou
|
|
|
376
370
|
});
|
|
377
371
|
}
|
|
378
372
|
|
|
379
|
-
type Ownership = "allow" | "foreign" | "unknown";
|
|
373
|
+
export type Ownership = "allow" | "foreign" | "unknown";
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Refusal for a mutation (stop, clear) of a task outside the current session
|
|
377
|
+
* scope. Mutations use the same ownership rule as reads (#322): only an owned
|
|
378
|
+
* task changes; nothing about a foreign or unverifiable task is disclosed.
|
|
379
|
+
*/
|
|
380
|
+
export function formatMutationRefusal(
|
|
381
|
+
id: string,
|
|
382
|
+
kind: Exclude<Ownership, "allow">,
|
|
383
|
+
action: "stop" | "clear",
|
|
384
|
+
options: OutputOptions,
|
|
385
|
+
): string {
|
|
386
|
+
const verb = action === "stop" ? "stopped" : "dismissed";
|
|
387
|
+
const detail = kind === "foreign"
|
|
388
|
+
? `This task belongs to another session, so it was not ${verb}. Pass all:true to ${action} it explicitly.`
|
|
389
|
+
: options.sessionUnavailable
|
|
390
|
+
? `The current session identity is unavailable, so ownership cannot be verified and the task was not ${verb}. Pass all:true to ${action} it explicitly.`
|
|
391
|
+
: `Task ownership is unavailable or unreadable, so the task was not ${verb}. Pass all:true to ${action} it explicitly.`;
|
|
392
|
+
return assembleBackgroundContent({
|
|
393
|
+
surface: "status",
|
|
394
|
+
maxBytes: options.maxBytes,
|
|
395
|
+
sections: {
|
|
396
|
+
identity: `Background task ${id} is outside the current session scope; not ${verb}.`,
|
|
397
|
+
diagnostics: detail,
|
|
398
|
+
},
|
|
399
|
+
gaps: [{ kind: "read", detail: kind === "foreign" ? "foreign-session" : "ownership-unavailable" }],
|
|
400
|
+
});
|
|
401
|
+
}
|
|
380
402
|
|
|
381
403
|
/**
|
|
382
|
-
* Current-session ownership
|
|
383
|
-
* verified for a task this process launched with
|
|
384
|
-
* a legacy task with no recorded origin is never
|
|
404
|
+
* Current-session ownership, shared by reads and mutations. Without a current
|
|
405
|
+
* session id, ownership is only verified for a task this process launched with
|
|
406
|
+
* the same sessionless origin; a legacy task with no recorded origin is never
|
|
407
|
+
* assumed to be ours.
|
|
385
408
|
*/
|
|
386
|
-
function classifyOwnership(meta: BackgroundTaskMeta, options: OutputOptions): Ownership {
|
|
409
|
+
export function classifyOwnership(meta: BackgroundTaskMeta, options: OutputOptions): Ownership {
|
|
387
410
|
if (options.all === true) return "allow";
|
|
388
411
|
if (options.sessionUnavailable) return "unknown";
|
|
389
412
|
const origin = options.origin;
|
|
@@ -517,15 +540,24 @@ export function formatStatus(
|
|
|
517
540
|
const meta = inspectionValue.meta;
|
|
518
541
|
const ownership = classifyOwnership(meta, options);
|
|
519
542
|
if (ownership !== "allow") return formatOwnershipGap(meta.id, ownership, options);
|
|
520
|
-
if (isIncidentCursor(options.cursor)) return assembleIncidentPage(meta, options);
|
|
543
|
+
if (isIncidentCursor(options.cursor) || options.history) return assembleIncidentPage(meta, options);
|
|
544
|
+
// A page cursor from another view of this task continues that view instead
|
|
545
|
+
// of resetting a status revision (#323): a raw/file cursor pages the log,
|
|
546
|
+
// a text cursor pages verbose metadata.
|
|
547
|
+
const pageKind = cursorKind(options.cursor);
|
|
548
|
+
if (pageKind === "f") return formatLog(meta.id, { ...options, raw: true });
|
|
549
|
+
if (pageKind === "t") return formatVerbose(meta, options);
|
|
521
550
|
if (options.verbose) return formatVerbose(meta, options);
|
|
551
|
+
// A bg_task_list page cursor pages the list, not this task: say so rather than report it as a
|
|
552
|
+
// stale status cursor (#332).
|
|
553
|
+
const listCursor = pageKind === "l";
|
|
522
554
|
const state = failureStateFor(meta.id);
|
|
523
555
|
const resource = `status:${scopeKey(options)}:${meta.id}`;
|
|
524
556
|
const revision = inspectStatusRevision({
|
|
525
557
|
resource,
|
|
526
558
|
contentRevision: contentRevision(meta),
|
|
527
559
|
failureRevision: failureRevision(state),
|
|
528
|
-
cursor: options.cursor,
|
|
560
|
+
cursor: listCursor ? undefined : options.cursor,
|
|
529
561
|
});
|
|
530
562
|
if (options.cursor && revision.change === "none") {
|
|
531
563
|
return assembleBackgroundContent({
|
|
@@ -533,8 +565,8 @@ export function formatStatus(
|
|
|
533
565
|
maxBytes: options.maxBytes,
|
|
534
566
|
sections: {
|
|
535
567
|
identity: `${identityLine(meta)} · unchanged`,
|
|
536
|
-
failure:
|
|
537
|
-
? `${
|
|
568
|
+
failure: actionableFailures(state).length
|
|
569
|
+
? `${actionableFailures(state).length} active failure observation(s), unchanged.`
|
|
538
570
|
: undefined,
|
|
539
571
|
decision: formatUnchangedEvidence(options.cursor),
|
|
540
572
|
},
|
|
@@ -545,6 +577,7 @@ export function formatStatus(
|
|
|
545
577
|
// Change/reset and read-gap facts are short and decision-relevant: they are
|
|
546
578
|
// budgeted with the decision section, ahead of long incident rows.
|
|
547
579
|
const changeFacts = [
|
|
580
|
+
...(listCursor ? ["cursor ignored: it is a bg_task_list page cursor; pass it to bg_task_list, or pass this status's statusCursor here."] : []),
|
|
548
581
|
...(revision.change === "failure" ? ["change=failure"] : []),
|
|
549
582
|
...(revision.reset ? [`reset=${revision.reset}`] : []),
|
|
550
583
|
...(log.error ? [`log unreadable: ${oneLine(log.error, 200)}; cannot treat this as an empty healthy log.`] : []),
|
|
@@ -576,7 +609,8 @@ export function formatLog(id: string, options: OutputOptions = {}): string {
|
|
|
576
609
|
if (ownership !== "allow") return formatOwnershipGap(meta.id, ownership, options);
|
|
577
610
|
const fileCursor = cursorKind(options.cursor) === "f";
|
|
578
611
|
const raw = options.raw === true || options.tailLines === 0 || fileCursor;
|
|
579
|
-
|
|
612
|
+
// Raw evidence keeps whole excerpts and evidence paths.
|
|
613
|
+
const failure = incidentSection(id, options, undefined, raw ? "full" : "compact");
|
|
580
614
|
if (raw) {
|
|
581
615
|
return assembleBackgroundContent({
|
|
582
616
|
surface: "rawPage",
|
|
@@ -594,7 +628,7 @@ export function formatLog(id: string, options: OutputOptions = {}): string {
|
|
|
594
628
|
}
|
|
595
629
|
const tailLines = options.tailLines && options.tailLines > 0 ? Math.floor(options.tailLines) : DEFAULT_LOG_TAIL_ROWS;
|
|
596
630
|
const log = readLog(meta.logPath, tailLines);
|
|
597
|
-
const staleCursor = options.cursor ? ["reset=stale-cursor (compact tails have no cursor; pass a raw nextCursor or
|
|
631
|
+
const staleCursor = options.cursor ? ["reset=stale-cursor (compact tails have no cursor; pass a raw nextCursor or lines:0)"] : [];
|
|
598
632
|
if (log.error) {
|
|
599
633
|
return assembleBackgroundContent({
|
|
600
634
|
surface: "log",
|
|
@@ -674,7 +708,7 @@ export function formatList(options: OutputOptions = {}): string {
|
|
|
674
708
|
const resource = `list:${scope}:${statusesKey}`;
|
|
675
709
|
const limit = Math.max(1, Math.min(Math.floor(options.limit ?? DEFAULT_LIST_ENTRIES), MAX_LIST_ENTRIES));
|
|
676
710
|
const states = new Map(allowed.map((meta) => [meta.id, failureStateFor(meta.id)] as const));
|
|
677
|
-
const incidentsOf = (id: string) =>
|
|
711
|
+
const incidentsOf = (id: string) => actionableFailures(states.get(id)!).length;
|
|
678
712
|
const revision = inspectStatusRevision({
|
|
679
713
|
resource,
|
|
680
714
|
contentRevision: revisionOf(allowed.map((meta) => [meta.id, meta.status, meta.endedAt ?? null])),
|