pi-better-harness 0.6.0 → 0.7.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 +41 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/failures.ts +77 -3
- package/node_modules/pi-better-background-tasks/src/index.ts +5 -0
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +2 -1
- package/node_modules/pi-better-background-tasks/src/output.ts +78 -48
- package/node_modules/pi-better-background-tasks/src/runtime.ts +173 -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 +378 -47
- package/node_modules/pi-better-background-tasks/src/shared-log-utils.ts +114 -0
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +12 -10
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +660 -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-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 +113 -11
- package/node_modules/pi-better-sandbox/index.ts +7 -1
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +68 -4
- package/node_modules/pi-better-sandbox/permissions.ts +42 -6
- package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +660 -17
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +10 -5
- 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 +22 -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 +17 -4
- package/node_modules/pi-better-subagents/failures.ts +38 -5
- package/node_modules/pi-better-subagents/incident-model.ts +64 -53
- package/node_modules/pi-better-subagents/index.ts +314 -16
- package/node_modules/pi-better-subagents/list.mjs +4 -1
- package/node_modules/pi-better-subagents/output-payload.ts +151 -56
- 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 +6 -4
- package/node_modules/pi-better-subagents/registry.ts +62 -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 +378 -47
- package/node_modules/pi-better-subagents/shared-log-utils.ts +114 -0
- package/node_modules/pi-better-subagents/shared-navigator.ts +12 -10
- package/node_modules/pi-better-subagents/shared-sandbox-core.ts +660 -17
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +10 -5
- package/node_modules/pi-better-subagents/spawn.ts +3 -1
- package/node_modules/pi-better-subagents/task-policy.ts +17 -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,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,6 +148,17 @@ 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
|
|
|
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
|
+
|
|
128
162
|
Task failures are labeled **Action required**; the shared labels also include
|
|
129
163
|
**Expected failure** and **Observation incomplete**. Unresolved running failures
|
|
130
164
|
become eligible for attention after 60 seconds; observation gaps are eligible
|
|
@@ -135,6 +169,13 @@ notification delivery does not clear the failure. `callback:false` stays quiet
|
|
|
135
169
|
while all inspection surfaces retain the evidence. Journals follow the task's
|
|
136
170
|
existing retention and explicit-clear behavior.
|
|
137
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
|
+
|
|
138
179
|
## When To Use
|
|
139
180
|
|
|
140
181
|
Use this package for shell commands that need logs, status, cancellation, or completion notifications across a Pi turn.
|
|
@@ -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, pendingAttentionNote, pendingAttentionRows, pendingFailureAttention,
|
|
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,7 +33,79 @@ 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);
|
|
@@ -65,6 +138,7 @@ export function failureAttentionFields(meta: BackgroundTaskMeta, state: FailureS
|
|
|
65
138
|
/** Running incidents get one grace wake. Terminal incidents ride the completion callback. */
|
|
66
139
|
export function scheduleFailureAttention(pi: ExtensionAPI, id: string, getActiveSession?: ActiveSessionProvider): void {
|
|
67
140
|
stopFailureAttention(id);
|
|
141
|
+
if (attentionSuspended) return;
|
|
68
142
|
const meta = readMeta(id);
|
|
69
143
|
if (!meta) {
|
|
70
144
|
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,
|
|
@@ -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 ??
|
|
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" },
|
|
@@ -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,7 +540,13 @@ 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);
|
|
522
551
|
const state = failureStateFor(meta.id);
|
|
523
552
|
const resource = `status:${scopeKey(options)}:${meta.id}`;
|
|
@@ -533,8 +562,8 @@ export function formatStatus(
|
|
|
533
562
|
maxBytes: options.maxBytes,
|
|
534
563
|
sections: {
|
|
535
564
|
identity: `${identityLine(meta)} · unchanged`,
|
|
536
|
-
failure:
|
|
537
|
-
? `${
|
|
565
|
+
failure: actionableFailures(state).length
|
|
566
|
+
? `${actionableFailures(state).length} active failure observation(s), unchanged.`
|
|
538
567
|
: undefined,
|
|
539
568
|
decision: formatUnchangedEvidence(options.cursor),
|
|
540
569
|
},
|
|
@@ -576,7 +605,8 @@ export function formatLog(id: string, options: OutputOptions = {}): string {
|
|
|
576
605
|
if (ownership !== "allow") return formatOwnershipGap(meta.id, ownership, options);
|
|
577
606
|
const fileCursor = cursorKind(options.cursor) === "f";
|
|
578
607
|
const raw = options.raw === true || options.tailLines === 0 || fileCursor;
|
|
579
|
-
|
|
608
|
+
// Raw evidence keeps whole excerpts and evidence paths.
|
|
609
|
+
const failure = incidentSection(id, options, undefined, raw ? "full" : "compact");
|
|
580
610
|
if (raw) {
|
|
581
611
|
return assembleBackgroundContent({
|
|
582
612
|
surface: "rawPage",
|
|
@@ -594,7 +624,7 @@ export function formatLog(id: string, options: OutputOptions = {}): string {
|
|
|
594
624
|
}
|
|
595
625
|
const tailLines = options.tailLines && options.tailLines > 0 ? Math.floor(options.tailLines) : DEFAULT_LOG_TAIL_ROWS;
|
|
596
626
|
const log = readLog(meta.logPath, tailLines);
|
|
597
|
-
const staleCursor = options.cursor ? ["reset=stale-cursor (compact tails have no cursor; pass a raw nextCursor or
|
|
627
|
+
const staleCursor = options.cursor ? ["reset=stale-cursor (compact tails have no cursor; pass a raw nextCursor or lines:0)"] : [];
|
|
598
628
|
if (log.error) {
|
|
599
629
|
return assembleBackgroundContent({
|
|
600
630
|
surface: "log",
|
|
@@ -674,7 +704,7 @@ export function formatList(options: OutputOptions = {}): string {
|
|
|
674
704
|
const resource = `list:${scope}:${statusesKey}`;
|
|
675
705
|
const limit = Math.max(1, Math.min(Math.floor(options.limit ?? DEFAULT_LIST_ENTRIES), MAX_LIST_ENTRIES));
|
|
676
706
|
const states = new Map(allowed.map((meta) => [meta.id, failureStateFor(meta.id)] as const));
|
|
677
|
-
const incidentsOf = (id: string) =>
|
|
707
|
+
const incidentsOf = (id: string) => actionableFailures(states.get(id)!).length;
|
|
678
708
|
const revision = inspectStatusRevision({
|
|
679
709
|
resource,
|
|
680
710
|
contentRevision: revisionOf(allowed.map((meta) => [meta.id, meta.status, meta.endedAt ?? null])),
|