pi-better-background-tasks 0.3.0 → 0.5.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 +43 -0
- package/package.json +1 -1
- package/src/failures.ts +85 -3
- package/src/index.ts +5 -0
- package/src/navigator-provider.ts +11 -7
- package/src/output.ts +83 -49
- package/src/runtime.ts +189 -17
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +34 -3
- package/src/shared-failure-observations.ts +414 -47
- package/src/shared-log-utils.ts +108 -0
- package/src/shared-navigator.ts +180 -134
- package/src/shared-sandbox-core.ts +724 -17
- package/src/tools.ts +107 -32
- package/src/types.ts +7 -0
package/src/tools.ts
CHANGED
|
@@ -2,13 +2,17 @@ import { Type } from "typebox";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { refreshBackgroundTasksNavigator } from "./navigator-provider.js";
|
|
4
4
|
import {
|
|
5
|
+
classifyOwnership,
|
|
5
6
|
formatLaunch,
|
|
6
7
|
formatList,
|
|
7
8
|
formatLog,
|
|
9
|
+
formatMissingTask,
|
|
10
|
+
formatMutationRefusal,
|
|
8
11
|
formatStatus,
|
|
9
12
|
formatStopResult,
|
|
10
13
|
type OutputOptions,
|
|
11
14
|
} from "./output.js";
|
|
15
|
+
import { readOutputControls } from "./shared-log-utils.js";
|
|
12
16
|
import { cancelCallbackBatch } from "./shared-callback-batcher.js";
|
|
13
17
|
import { inspectMeta, listMetasForOrigin, writeMeta } from "./registry.js";
|
|
14
18
|
import { resumeRunningTask, spawnTask, startWatchTask, stopTask } from "./runtime.js";
|
|
@@ -32,13 +36,22 @@ const ConditionSchema = Type.Union([
|
|
|
32
36
|
Type.Object({ type: Type.Literal("json_path_exists"), path: JsonPathSchema }),
|
|
33
37
|
]);
|
|
34
38
|
|
|
39
|
+
/**
|
|
40
|
+
* String-to-string map declared with additionalProperties. Equivalent to
|
|
41
|
+
* Type.Record(Type.String(), Type.String()) for accepted values; kept as the
|
|
42
|
+
* plainer JSON Schema form.
|
|
43
|
+
*/
|
|
44
|
+
function StringMap(description: string) {
|
|
45
|
+
return Type.Unsafe<Record<string, string>>({ type: "object", additionalProperties: { type: "string" }, description });
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
const SshSchema = Type.Object({
|
|
36
49
|
host: Type.String({ description: "SSH host. Required when ssh is set." }),
|
|
37
50
|
user: Type.Optional(Type.String({ description: "SSH user." })),
|
|
38
51
|
port: Type.Optional(Type.Integer({ minimum: 1, maximum: 65_535, description: "SSH port." })),
|
|
39
52
|
identity_file: Type.Optional(Type.String({ description: "SSH identity file path." })),
|
|
40
53
|
jump: Type.Optional(Type.String({ description: "SSH jump host passed with -J." })),
|
|
41
|
-
options: Type.Optional(
|
|
54
|
+
options: Type.Optional(StringMap("Additional SSH -o key/value options. Agent-safe defaults remain enforced.")),
|
|
42
55
|
}, {
|
|
43
56
|
description: "Structured SSH connection for remote background tasks. Set ssh instead of wrapping command in a hand-written ssh command; command is the remote command, and Pi keeps durable local logs, status, callbacks, and remote stop semantics.",
|
|
44
57
|
});
|
|
@@ -51,18 +64,29 @@ const RemoteSchema = Type.Object({
|
|
|
51
64
|
description: "Remote execution controls used with ssh. Omit session for SSH spawn to get the durable tmux default; SSH watch runs direct one-shot polls regardless of session and does not install tmux.",
|
|
52
65
|
});
|
|
53
66
|
|
|
67
|
+
// Structured intent (#325). Validated in code before launch (no uniqueItems/pattern: some providers reject them).
|
|
68
|
+
// Each field also admits null, which means "not declared": models send optional fields as explicit
|
|
69
|
+
// null, and readTaskIntent treats null exactly like an omitted field on every Pi version. Plain
|
|
70
|
+
// JSON Schema `anyOf` (Type.Unsafe), not Type.Union: Pi 0.82 converts a null to "" or [] through a
|
|
71
|
+
// TypeBox union, and a Null-first union reports a malformed array as "must be null".
|
|
72
|
+
const IntentFields = {
|
|
73
|
+
operation_id: Type.Optional(Type.Unsafe<string | null>({ anyOf: [{ type: "null" }, { type: "string" }], description: "Optional stable id (letters, digits, . _ : / -; up to 64) for one logical operation. Reuse it when you retry with a changed command, scope, or timeout: when a later task with the same operation_id succeeds, earlier failures of that operation in this session are recovered." })),
|
|
74
|
+
expected_exit_codes: Type.Optional(Type.Unsafe<number[] | null>({ anyOf: [{ type: "array", items: { type: "integer", minimum: 0, maximum: 255 }, minItems: 1, maxItems: 16 }, { type: "null" }], description: "Optional distinct non-zero exit codes that are intentional for this command (e.g. [1] for a no-match probe). 0 is allowed and ignored: exit 0 is already success. Declared before launch; recorded as expected failures, not incidents needing action. Signals and timeouts are never expected." })),
|
|
75
|
+
};
|
|
76
|
+
|
|
54
77
|
const CommandFields = {
|
|
55
78
|
name: Type.Optional(Type.String({ description: "Human-readable task label." })),
|
|
56
79
|
command: Type.Optional(Type.String({ description: "Shell command to run, or the remote command when ssh is set. Required unless shell:false with argv is used." })),
|
|
57
80
|
argv: Type.Optional(Type.Array(Type.String(), { description: "Argument vector. Use with shell:false to avoid shell parsing." })),
|
|
58
81
|
shell: Type.Optional(Type.Boolean({ description: "Run command through the package's bash-compatible shell. Default true." })),
|
|
59
82
|
cwd: Type.Optional(Type.String({ description: "Working directory. Defaults to the current pi cwd." })),
|
|
60
|
-
env: Type.Optional(
|
|
83
|
+
env: Type.Optional(StringMap("Extra environment variables.")),
|
|
61
84
|
max_log_bytes: Type.Optional(Type.Number({ description: "Maximum retained raw-log bytes. Default 4194304 (4 MiB). Older output is compacted while the task runs." })),
|
|
62
85
|
callback: Type.Optional(Type.Boolean({ description: "Queue a follow-up when the task reaches a terminal state. Default true." })),
|
|
63
86
|
timeout_seconds: Type.Optional(Type.Number({ description: "Optional timeout in seconds. Command watchers default to 900 seconds when omitted; pass 0 to disable. Spawned processes have no default timeout." })),
|
|
64
87
|
ssh: Type.Optional(SshSchema),
|
|
65
88
|
remote: Type.Optional(RemoteSchema),
|
|
89
|
+
...IntentFields,
|
|
66
90
|
};
|
|
67
91
|
|
|
68
92
|
const SpawnParams = Type.Object(CommandFields);
|
|
@@ -76,8 +100,9 @@ const WatchParams = Type.Object({
|
|
|
76
100
|
|
|
77
101
|
const CursorFields = {
|
|
78
102
|
cursor: Type.Optional(Type.String({ description: "Caller-owned continuation cursor from a previous page. Replay returns the same page or a no-change/failure-only notice; nextCursor continues. Independent callers do not consume each other. Cursors are bound to the selected session scope." })),
|
|
79
|
-
max_bytes: Type.Optional(Type.Number({ description: "Optional UTF-8 byte budget. Defaults: status/log/list 1 KiB, raw evidence 16 KiB. Larger explicit pages are allowed up to the shared hard cap (status 2 KiB, log/list 4 KiB, raw 64 KiB) Hard caps are OUTPUT_BUDGET_MAX_BYTES." })),
|
|
80
|
-
|
|
103
|
+
max_bytes: Type.Optional(Type.Number({ description: "Optional UTF-8 byte budget. Defaults: status/log/list 1 KiB, raw evidence 16 KiB. Larger explicit pages are allowed up to the shared hard cap (status 2 KiB, log/list 4 KiB, raw 64 KiB). Hard caps are OUTPUT_BUDGET_MAX_BYTES." })),
|
|
104
|
+
maxBytes: Type.Optional(Type.Number({ description: "Deprecated alias for max_bytes. max_bytes wins when both are given." })),
|
|
105
|
+
all: Type.Optional(Type.Boolean({ description: "Inspect, list, stop, or clear-by-id tasks across every session. Default false is current-session only. Unknown ownership is reported as a gap, never as missing or healthy; stop and clear refuse tasks outside the current session." })),
|
|
81
106
|
};
|
|
82
107
|
|
|
83
108
|
const IdParams = Type.Object({
|
|
@@ -85,6 +110,15 @@ const IdParams = Type.Object({
|
|
|
85
110
|
verbose: Type.Optional(Type.Boolean({ description: "Return raw metadata JSON with environment values omitted, bounded like raw evidence (16 KiB default, max_bytes up to 64 KiB, paged with cursor when larger). Default false returns the compact model-facing summary. Use true only for debugging or explicit recovery." })),
|
|
86
111
|
...CursorFields,
|
|
87
112
|
});
|
|
113
|
+
const HistoryField = {
|
|
114
|
+
history: Type.Optional(Type.Boolean({ description: "If true, return an incident page that also lists failure history: expected failures and recovered or superseded incidents. Default output lists only failures that need action and counts the rest." })),
|
|
115
|
+
};
|
|
116
|
+
const StatusParams = Type.Object({
|
|
117
|
+
id: Type.String({ description: "Background task id." }),
|
|
118
|
+
verbose: Type.Optional(Type.Boolean({ description: "Return raw metadata JSON with environment values omitted, bounded like raw evidence (16 KiB default, max_bytes up to 64 KiB, paged with cursor when larger). Default false returns the compact model-facing summary. Use true only for debugging or explicit recovery." })),
|
|
119
|
+
...HistoryField,
|
|
120
|
+
...CursorFields,
|
|
121
|
+
});
|
|
88
122
|
const ListParams = Type.Object({
|
|
89
123
|
status: Type.Optional(Type.Array(Type.String({ description: "Statuses to include." }))),
|
|
90
124
|
limit: Type.Optional(Type.Number({ description: "Maximum tasks to show. Default 10, max 100." })),
|
|
@@ -92,7 +126,8 @@ const ListParams = Type.Object({
|
|
|
92
126
|
});
|
|
93
127
|
const LogParams = Type.Object({
|
|
94
128
|
id: Type.String({ description: "Background task id." }),
|
|
95
|
-
|
|
129
|
+
lines: Type.Optional(Type.Number({ description: "Number of trailing display rows. Default 10 for compact model ingestion. Set 0 to page retained raw bytes from the oldest retained offset." })),
|
|
130
|
+
tail_lines: Type.Optional(Type.Number({ description: "Deprecated alias for lines. lines wins when both are given." })),
|
|
96
131
|
raw: Type.Optional(Type.Boolean({ description: "Page retained raw log bytes (16 KiB default, 64 KiB hard cap) instead of the compact excerpt. Capture and retention loss are disclosed; this is not a full-history archive." })),
|
|
97
132
|
...CursorFields,
|
|
98
133
|
});
|
|
@@ -110,13 +145,15 @@ const ActionParams = Type.Object({
|
|
|
110
145
|
id: Type.Optional(Type.String()),
|
|
111
146
|
status: Type.Optional(Type.Array(Type.String())),
|
|
112
147
|
limit: Type.Optional(Type.Number()),
|
|
113
|
-
|
|
148
|
+
lines: Type.Optional(Type.Number({ description: "action:log trailing display rows (default 10; 0 pages raw bytes)." })),
|
|
149
|
+
tail_lines: Type.Optional(Type.Number({ description: "Deprecated alias for lines." })),
|
|
114
150
|
verbose: Type.Optional(Type.Boolean()),
|
|
115
151
|
...CommandFields,
|
|
116
152
|
interval_seconds: Type.Optional(Type.Number()),
|
|
117
153
|
success_when: Type.Optional(ConditionSchema),
|
|
118
154
|
failure_when: Type.Optional(ConditionSchema),
|
|
119
155
|
raw: Type.Optional(Type.Boolean()),
|
|
156
|
+
...HistoryField,
|
|
120
157
|
...CursorFields,
|
|
121
158
|
});
|
|
122
159
|
|
|
@@ -125,9 +162,11 @@ const StatusActionParams = Type.Object({
|
|
|
125
162
|
id: Type.Optional(Type.String()),
|
|
126
163
|
status: Type.Optional(Type.Array(Type.String())),
|
|
127
164
|
limit: Type.Optional(Type.Number()),
|
|
128
|
-
|
|
165
|
+
lines: Type.Optional(Type.Number({ description: "action:log trailing display rows (default 10; 0 pages raw bytes)." })),
|
|
166
|
+
tail_lines: Type.Optional(Type.Number({ description: "Deprecated alias for lines." })),
|
|
129
167
|
verbose: Type.Optional(Type.Boolean()),
|
|
130
168
|
raw: Type.Optional(Type.Boolean()),
|
|
169
|
+
...HistoryField,
|
|
131
170
|
...CursorFields,
|
|
132
171
|
});
|
|
133
172
|
|
|
@@ -201,8 +240,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
201
240
|
pi.registerTool({
|
|
202
241
|
name: "bg_task_status",
|
|
203
242
|
label: "BG Status",
|
|
204
|
-
description: "Inspect one background task. Default output is a compact model-facing summary (1 KiB UTF-8) with matched condition, exit/signal, stop error, and failure counts before progress. Current-session only unless all:true; unknown ownership is a gap, not missing or healthy. Pass verbose:true only when full raw metadata is explicitly needed. Environment values are omitted. After a terminal callback, call this first and call bg_task_log only if the summary is insufficient.",
|
|
205
|
-
parameters:
|
|
243
|
+
description: "Inspect one background task. Default output is a compact model-facing summary (1 KiB UTF-8) with matched condition, exit/signal, stop error, and failure counts before progress. Current-session only unless all:true; unknown ownership is a gap, not missing or healthy. Pass verbose:true only when full raw metadata is explicitly needed. Environment values are omitted. A raw log nextCursor passed as cursor continues the raw log page; an incidentCursor pages incidents that need action; history:true lists failure history too. After a terminal callback, call this first and call bg_task_log only if the summary is insufficient.",
|
|
244
|
+
parameters: StatusParams,
|
|
206
245
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
207
246
|
activeSession = getCallbackOrigin(ctx);
|
|
208
247
|
return text(formatStatus(inspectMeta(params.id), statusOptions(params, activeSession)));
|
|
@@ -212,7 +251,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
212
251
|
pi.registerTool({
|
|
213
252
|
name: "bg_task_log",
|
|
214
253
|
label: "BG Log",
|
|
215
|
-
description: "Read a background task log. Default output is a compact 10-line terminal-aware tail for model ingestion (1 KiB UTF-8). Current-session only unless all:true. Pass
|
|
254
|
+
description: "Read a background task log. Default output is a compact 10-line terminal-aware tail for model ingestion (1 KiB UTF-8). Current-session only unless all:true. Pass lines for a bounded tail (tail_lines is a deprecated alias); lines:0 pages the retained raw log from the oldest retained offset (16 KiB pages, 64 KiB hard cap) with a caller-owned cursor. Capture and retention loss are disclosed; this is not a full-history archive. Nonblocking.",
|
|
216
255
|
parameters: LogParams,
|
|
217
256
|
renderResult(result: unknown, options: unknown, theme: unknown) {
|
|
218
257
|
return renderBackgroundTaskLogDisplay(result, options, theme);
|
|
@@ -226,7 +265,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
226
265
|
pi.registerTool({
|
|
227
266
|
name: "bg_task_stop",
|
|
228
267
|
label: "BG Stop",
|
|
229
|
-
description: "Cancel a watcher or terminate a background task. For a tmux-backed SSH task, stop kills its remote tmux session before marking it cancelled. Direct SSH stop only tears down the local client and may leave the remote process running.",
|
|
268
|
+
description: "Cancel a watcher or terminate a background task. Only a task owned by the current session stops; a foreign-session or unverifiable task is refused unless all:true. For a tmux-backed SSH task, stop kills its remote tmux session before marking it cancelled. Direct SSH stop only tears down the local client and may leave the remote process running.",
|
|
230
269
|
parameters: IdParams,
|
|
231
270
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
232
271
|
activeSession = getCallbackOrigin(ctx);
|
|
@@ -239,7 +278,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
239
278
|
pi.registerTool({
|
|
240
279
|
name: "bg_task",
|
|
241
280
|
label: "BG Task",
|
|
242
|
-
description: "Action wrapper for background tasks: spawn, watch, list, status, log, stop, or clear. For remote work, prefer structured ssh: pass ssh:{host,user} and provide the remote command in command. For short synchronous remote commands that should return output now, use remote_bash from pi-better-ssh. SSH spawn defaults to durable tmux; SSH watches use direct one-shot polls without tmux installation; remote.session=direct is a weaker-stop spawn escape hatch. Spawn/watch return immediately; do not poll in foreground. For action:status, default compact output and use verbose:true only for full metadata. For action:log, default compact tail and use
|
|
281
|
+
description: "Action wrapper for background tasks: spawn, watch, list, status, log, stop, or clear. For remote work, prefer structured ssh: pass ssh:{host,user} and provide the remote command in command. For short synchronous remote commands that should return output now, use remote_bash from pi-better-ssh. SSH spawn defaults to durable tmux; SSH watches use direct one-shot polls without tmux installation; remote.session=direct is a weaker-stop spawn escape hatch. Spawn/watch return immediately; do not poll in foreground. For action:status, default compact output and use verbose:true only for full metadata. For action:log, default compact tail and use lines:0 to page retained raw bytes. Standalone tools and these wrappers share the same output assembler. List/status/log default to the current session; pass all:true to override. Stop and clear change only current-session tasks: clear dismisses every owned terminal task, or one task with id (all:true allows another session's task by id).",
|
|
243
282
|
promptGuidelines: BACKGROUND_ORCHESTRATION_GUIDELINES,
|
|
244
283
|
parameters: ActionParams,
|
|
245
284
|
renderResult(result: unknown, options: unknown, theme: unknown) {
|
|
@@ -254,7 +293,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
254
293
|
pi.registerTool({
|
|
255
294
|
name: "bg_status",
|
|
256
295
|
label: "BG Status",
|
|
257
|
-
description: "Action wrapper for inspecting background tasks: list, status, log, stop, or clear. Nonblocking. Status is compact by default; log returns a compact tail by default. Use verbose:true or
|
|
296
|
+
description: "Action wrapper for inspecting background tasks: list, status, log, stop, or clear. Nonblocking. Status is compact by default; log returns a compact tail by default. Use verbose:true or lines:0 only for explicit full-data recovery. Shares the same output assembler as the standalone tools. Current-session default; pass all:true to override. Stop and clear change only current-session tasks (clear with id and all:true for another session's task).",
|
|
258
297
|
parameters: StatusActionParams,
|
|
259
298
|
renderResult(result: unknown, options: unknown, theme: unknown) {
|
|
260
299
|
return renderBackgroundTaskLogDisplay(result, options, theme);
|
|
@@ -270,22 +309,31 @@ export function text(textValue: string, details?: unknown) {
|
|
|
270
309
|
return { content: [{ type: "text" as const, text: textValue }], details };
|
|
271
310
|
}
|
|
272
311
|
|
|
312
|
+
/** Requested byte budget: canonical `max_bytes`, else the deprecated `maxBytes` alias (#321). */
|
|
313
|
+
function requestedMaxBytes(params: Record<string, unknown>): number | undefined {
|
|
314
|
+
const value = readOutputControls(params).maxBytes;
|
|
315
|
+
return typeof value === "number" ? value : undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
273
318
|
function statusOptions(params: Record<string, unknown>, origin?: BackgroundTaskCallbackOrigin): OutputOptions {
|
|
274
319
|
return {
|
|
275
320
|
verbose: params.verbose === true,
|
|
321
|
+
history: params.history === true,
|
|
276
322
|
cursor: typeof params.cursor === "string" ? params.cursor : undefined,
|
|
277
|
-
maxBytes:
|
|
323
|
+
maxBytes: requestedMaxBytes(params),
|
|
278
324
|
all: params.all === true,
|
|
279
325
|
...scopeOptions(origin),
|
|
280
326
|
};
|
|
281
327
|
}
|
|
282
328
|
|
|
283
329
|
function logOptions(params: Record<string, unknown>, origin?: BackgroundTaskCallbackOrigin): OutputOptions {
|
|
330
|
+
// Canonical `lines`, else the deprecated `tail_lines` alias (#321).
|
|
331
|
+
const lines = readOutputControls(params).lines;
|
|
284
332
|
return {
|
|
285
|
-
tailLines: typeof
|
|
333
|
+
tailLines: typeof lines === "number" ? lines : undefined,
|
|
286
334
|
raw: params.raw === true,
|
|
287
335
|
cursor: typeof params.cursor === "string" ? params.cursor : undefined,
|
|
288
|
-
maxBytes:
|
|
336
|
+
maxBytes: requestedMaxBytes(params),
|
|
289
337
|
all: params.all === true,
|
|
290
338
|
...scopeOptions(origin),
|
|
291
339
|
};
|
|
@@ -296,7 +344,7 @@ function listOptions(params: Record<string, unknown>, origin?: BackgroundTaskCal
|
|
|
296
344
|
statuses: params.status as string[] | undefined,
|
|
297
345
|
limit: typeof params.limit === "number" ? params.limit : undefined,
|
|
298
346
|
cursor: typeof params.cursor === "string" ? params.cursor : undefined,
|
|
299
|
-
maxBytes:
|
|
347
|
+
maxBytes: requestedMaxBytes(params),
|
|
300
348
|
all: params.all === true,
|
|
301
349
|
...scopeOptions(origin),
|
|
302
350
|
};
|
|
@@ -340,7 +388,7 @@ async function runAction(
|
|
|
340
388
|
if (!params.id) return "Invalid parameters: stop requires id.";
|
|
341
389
|
return withNavigatorRefresh(ctx, await formatStop(pi, String(params.id), ctx, getActiveSession, statusOptions(params, callbackOrigin)));
|
|
342
390
|
case "clear":
|
|
343
|
-
return withNavigatorRefresh(ctx, formatClear(params
|
|
391
|
+
return withNavigatorRefresh(ctx, formatClear(params, statusOptions(params, callbackOrigin)));
|
|
344
392
|
default:
|
|
345
393
|
return `Unknown action: ${String(params.action)}`;
|
|
346
394
|
}
|
|
@@ -500,35 +548,62 @@ async function formatStop(
|
|
|
500
548
|
): Promise<string> {
|
|
501
549
|
const existing = inspectMeta(id);
|
|
502
550
|
if (!existing.meta && !existing.found) return formatStopResult(existing, options);
|
|
551
|
+
if (!existing.meta) return formatMissingTask(existing, options);
|
|
552
|
+
// Same ownership rule as reads (#322): only a task owned by the current
|
|
553
|
+
// session stops unless the caller passes all:true.
|
|
554
|
+
const ownership = classifyOwnership(existing.meta, options);
|
|
555
|
+
if (ownership !== "allow") return formatMutationRefusal(id, ownership, "stop", options);
|
|
503
556
|
const meta = await stopTask(pi, id, getActiveSession);
|
|
504
557
|
if (!meta) return formatStopResult({ id, found: existing.found, readable: false, error: existing.error }, options);
|
|
505
558
|
return formatStopResult({ id: meta.id, meta, found: true, readable: true }, options);
|
|
506
559
|
}
|
|
507
560
|
|
|
508
|
-
|
|
561
|
+
/**
|
|
562
|
+
* Dismiss terminal tasks with the same ownership rule as reads (#322). Bulk
|
|
563
|
+
* clear never crosses the current session scope (stricter than reads, which
|
|
564
|
+
* accept all:true for a list); clear with an id honours all:true like a read
|
|
565
|
+
* by id. Tasks whose ownership cannot be verified are counted, not dismissed.
|
|
566
|
+
*/
|
|
567
|
+
function formatClear(params: Record<string, unknown>, options: OutputOptions): string {
|
|
568
|
+
if (typeof params.id === "string" && params.id) return formatClearById(params.id, options);
|
|
569
|
+
const statuses = params.status as string[] | undefined;
|
|
509
570
|
const wanted = statuses && statuses.length > 0 ? new Set(statuses) : undefined;
|
|
571
|
+
const statusLabel = wanted ? ` matching ${Array.from(wanted).join(",")}` : "";
|
|
572
|
+
const origin = options.origin;
|
|
573
|
+
if (!origin || options.sessionUnavailable) {
|
|
574
|
+
return "Dismissed 0 terminal background tasks: the current session identity is unavailable, so no task's ownership can be verified. Clear one task with id and all:true.";
|
|
575
|
+
}
|
|
576
|
+
const scoped: OutputOptions = { ...options, all: false };
|
|
510
577
|
const now = Date.now();
|
|
511
578
|
let cleared = 0;
|
|
512
|
-
|
|
579
|
+
let unverified = 0;
|
|
580
|
+
for (const meta of listMetasForOrigin(origin)) {
|
|
513
581
|
if (meta.dismissedAt !== undefined) continue;
|
|
514
582
|
if (!isTerminalStatus(meta.status)) continue;
|
|
515
583
|
if (wanted && !wanted.has(meta.status)) continue;
|
|
516
|
-
if (
|
|
584
|
+
if (classifyOwnership(meta, scoped) !== "allow") {
|
|
585
|
+
unverified += 1;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
517
588
|
meta.dismissedAt = now;
|
|
518
589
|
writeMeta(meta);
|
|
519
590
|
cleared += 1;
|
|
520
591
|
}
|
|
521
|
-
const
|
|
522
|
-
|
|
592
|
+
const skipped = unverified
|
|
593
|
+
? ` ${unverified} terminal task${unverified === 1 ? "" : "s"} with unverifiable ownership ${unverified === 1 ? "was" : "were"} not dismissed; clear one with id and all:true.`
|
|
594
|
+
: "";
|
|
595
|
+
return `Dismissed ${cleared} terminal background task${cleared === 1 ? "" : "s"}${statusLabel}.${skipped}`;
|
|
523
596
|
}
|
|
524
597
|
|
|
525
|
-
function
|
|
526
|
-
const
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
if (
|
|
533
|
-
|
|
598
|
+
function formatClearById(id: string, options: OutputOptions): string {
|
|
599
|
+
const inspection = inspectMeta(id);
|
|
600
|
+
if (!inspection.meta) return formatMissingTask(inspection, options);
|
|
601
|
+
const meta = inspection.meta;
|
|
602
|
+
const ownership = classifyOwnership(meta, options);
|
|
603
|
+
if (ownership !== "allow") return formatMutationRefusal(id, ownership, "clear", options);
|
|
604
|
+
if (!isTerminalStatus(meta.status)) return `Background task ${id} is ${meta.status}; only terminal tasks can be dismissed. Stop it first.`;
|
|
605
|
+
if (meta.dismissedAt !== undefined) return `Background task ${id} was already dismissed.`;
|
|
606
|
+
meta.dismissedAt = Date.now();
|
|
607
|
+
writeMeta(meta);
|
|
608
|
+
return `Dismissed terminal background task ${id} (${meta.status}).`;
|
|
534
609
|
}
|
package/src/types.ts
CHANGED
|
@@ -124,6 +124,13 @@ export interface BackgroundTaskMeta {
|
|
|
124
124
|
spawnPidStartTime?: string;
|
|
125
125
|
successWhen?: Condition;
|
|
126
126
|
failureWhen?: Condition;
|
|
127
|
+
/**
|
|
128
|
+
* Caller-declared structured intent (#325), validated before launch. A later task of the same
|
|
129
|
+
* kind, cwd, SSH target, and session with the same operationId that succeeds recovers this
|
|
130
|
+
* task's earlier failures; exit codes in expectedExitCodes are recorded as expected failures.
|
|
131
|
+
*/
|
|
132
|
+
operationId?: string;
|
|
133
|
+
expectedExitCodes?: number[];
|
|
127
134
|
notifyOn?: "terminal";
|
|
128
135
|
stopRequestedAt?: number;
|
|
129
136
|
error?: string;
|