pi-better-harness 0.39.2 → 0.40.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/extensions/execution-policy.ts +2 -0
- package/node_modules/pi-better-background-tasks/README.md +67 -0
- package/node_modules/pi-better-background-tasks/package.json +3 -1
- package/node_modules/pi-better-background-tasks/src/adapter-contract.ts +16 -2
- package/node_modules/pi-better-background-tasks/src/batch.ts +108 -0
- package/node_modules/pi-better-background-tasks/src/delivery-observer.ts +42 -0
- package/node_modules/pi-better-background-tasks/src/goal-provider.ts +72 -5
- package/node_modules/pi-better-background-tasks/src/index.ts +39 -0
- package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +175 -6
- package/node_modules/pi-better-background-tasks/src/observed-waits.ts +3677 -0
- package/node_modules/pi-better-background-tasks/src/runtime.ts +10 -0
- package/node_modules/pi-better-background-tasks/src/sandbox.ts +276 -2
- package/node_modules/pi-better-background-tasks/src/shared-execution-policy.ts +2 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/index.ts +113 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/errors.ts +75 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/operations.ts +376 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/outbox.ts +431 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/rows.ts +439 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/schema.ts +244 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/sqlite.ts +239 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/store.ts +397 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/types.ts +648 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/validate.ts +239 -0
- package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/waits.ts +1378 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/README.md +111 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/emit-runner.mjs +101 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/conditions.mjs +96 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/identity.mjs +95 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/process-identity.mjs +43 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/process.mjs +514 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/runner.mjs +872 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/store.mjs +605 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/generated/types.mjs +50 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/identity.ts +99 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/index.ts +37 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/invocation.ts +44 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/runner.ts +982 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/store.ts +649 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/supervisor.ts +730 -0
- package/node_modules/pi-better-background-tasks/src/supervisor/types.ts +384 -0
- package/node_modules/pi-better-background-tasks/src/tools.ts +396 -9
- package/node_modules/pi-better-background-tasks/src/types.ts +8 -0
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +50 -1
- package/node_modules/pi-better-goal/src/operation-admission.ts +16 -0
- package/node_modules/pi-better-goal/src/workflow-gates.ts +15 -0
- package/node_modules/pi-better-sandbox/background-admission.ts +2 -2
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/shared-task-exec.ts +9 -0
- package/node_modules/pi-better-subagents/README.md +23 -6
- package/node_modules/pi-better-subagents/child-operations.ts +1109 -0
- package/node_modules/pi-better-subagents/child-ownership.ts +12 -1
- package/node_modules/pi-better-subagents/cleanup.ts +11 -2
- package/node_modules/pi-better-subagents/docs/usage.md +44 -3
- package/node_modules/pi-better-subagents/finalization.ts +53 -8
- package/node_modules/pi-better-subagents/goal-provider.ts +42 -4
- package/node_modules/pi-better-subagents/index.ts +549 -14
- package/node_modules/pi-better-subagents/lifecycle.ts +26 -0
- package/node_modules/pi-better-subagents/navigator.mjs +13 -2
- package/node_modules/pi-better-subagents/navigator.ts +1 -0
- package/node_modules/pi-better-subagents/operation-bridge-types.ts +1109 -0
- package/node_modules/pi-better-subagents/package.json +2 -1
- package/node_modules/pi-better-subagents/park-checkpoint.ts +61 -0
- package/node_modules/pi-better-subagents/parse.ts +19 -1
- package/node_modules/pi-better-subagents/registry.ts +192 -6
- package/node_modules/pi-better-subagents/session-store.ts +3 -0
- package/node_modules/pi-better-subagents/shared-execution-policy.ts +2 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/index.ts +113 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/errors.ts +75 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/operations.ts +376 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/outbox.ts +431 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/rows.ts +439 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/schema.ts +244 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/sqlite.ts +239 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/store.ts +397 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/types.ts +648 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/validate.ts +239 -0
- package/node_modules/pi-better-subagents/shared-operation-coordinator/src/waits.ts +1378 -0
- package/node_modules/pi-better-subagents/shared-task-exec.ts +9 -0
- package/node_modules/pi-better-subagents/stop.ts +7 -3
- package/node_modules/pi-better-subagents/task-guard.ts +22 -3
- package/node_modules/pi-better-subagents/task-policy.ts +7 -0
- package/node_modules/pi-better-subagents/worker-operations-host.ts +1226 -0
- package/node_modules/pi-better-subagents/worker-waits.ts +2549 -0
- package/package.json +5 -5
|
@@ -6,6 +6,8 @@ const EXECUTION_POLICY = [
|
|
|
6
6
|
"Serialize only for a concrete dependency, conflicting writes or ownership, resource or concurrency limit, documented rollout gate, or explicit availability requirement. Record the reason in the plan or explain it to the user. Do not invent a canary or host-by-host availability requirement.",
|
|
7
7
|
"If concurrency raises an unresolved material operational trade-off (for example, draining all hosts could leave no available runners), ask the user once before the affected operations. Continue independent preparation meanwhile. Existing authorization, safety checks, workflow prerequisites, and human-approved rollout constraints remain binding; urgency does not waive them.",
|
|
8
8
|
"Choose the execution mechanism after deciding what can run together: foreground tools for small, tightly coupled, or interactive work; subagents for bounded independent reasoning or implementation when the active delegation mode permits; background tasks for independent long-running commands or waits. Manual delegation forbids proactive subagents, not concurrent foreground tools or background commands. Use available batch or parallel tool interfaces where appropriate and inspect every launch receipt for failures or skipped work.",
|
|
9
|
+
"Established command-only jobs and external waits belong to background operations, including in coordinator mode. Use bg_task_spawn for a long command and bg_task_watch for a repeated check; use watch_delivery for supported merge delivery so a stateful domain watcher observes the queue. A subagent assignment must require reasoning or implementation beyond launching and waiting. Workers retain their assigned validation: use supervised background tools and wait_for_operations to park, then resume the same conversation to interpret and acknowledge the terminal result. A linked external wait must have a registered observer and an execution owner. Continue independent work while it runs; a task launch or a completed parked attempt is not completion of the assignment.",
|
|
10
|
+
"If a worker hands back a command, the coordinator launches it through background tools with the supplied cwd and inspects its terminal result before claiming verification. Workers may run focused implementation checks; moving a command to a worker does not make it a background task.",
|
|
9
11
|
"A unit waiting or failing does not block other ready independent units unless the evidence shows a shared blocker. Preserve each unit's guards and rollback boundary. Before completing verification, the plan, or the goal, inspect and integrate every relevant terminal result or failure; launch is not completion.",
|
|
10
12
|
].join("\n");
|
|
11
13
|
|
|
@@ -255,6 +255,39 @@ items.", and kubectl and npm print warnings), which can raise a false alarm.
|
|
|
255
255
|
If the stderr is expected, redirect it (`2>/dev/null`) or set
|
|
256
256
|
`blind_checks: 0`. Set `blind_checks` to another number to change the count.
|
|
257
257
|
|
|
258
|
+
## Batches
|
|
259
|
+
|
|
260
|
+
`bg_task_spawn_batch` starts 1 to 100 independent background tasks at once and
|
|
261
|
+
returns immediately with a `batchId`, one task id per launched job, and any job
|
|
262
|
+
that was refused. Every job is an ordinary background task with its own process,
|
|
263
|
+
log, and metadata; the batch only groups them.
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"batchName": "local builds",
|
|
268
|
+
"shared": { "cwd": "/repo", "env": { "CI": "1" } },
|
|
269
|
+
"jobs": [
|
|
270
|
+
{ "name": "api", "command": "npm run build" },
|
|
271
|
+
{ "name": "web", "command": "npm run build:web" }
|
|
272
|
+
]
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
- `jobs` is required. `shared` applies launch options to every job and cannot
|
|
277
|
+
carry `name`, `command`, `argv`, `batchId`, or `batchName`: those belong to a
|
|
278
|
+
single job, and the launcher refuses a batch that puts one in `shared`.
|
|
279
|
+
- A job's own value wins over `shared`. An explicit `false`, `0`, or `null`
|
|
280
|
+
overrides the shared value instead of inheriting it.
|
|
281
|
+
- The whole batch is preflighted before anything starts. One invalid job refuses
|
|
282
|
+
the batch, so a partial launch never happens.
|
|
283
|
+
- Each launched task records `batchId` and `batchName` in its metadata, so a
|
|
284
|
+
reloaded task still says which batch it came from.
|
|
285
|
+
- The result lists `launched` and `failed` with 1-based job positions, so a
|
|
286
|
+
refused entry points at the job in the request. Stopping one task does not
|
|
287
|
+
stop the others.
|
|
288
|
+
- Do not wait or poll for the batch. Inspect, log, or stop each task by its id,
|
|
289
|
+
or watch one with `bg_task_watch`.
|
|
290
|
+
|
|
258
291
|
## Install
|
|
259
292
|
|
|
260
293
|
```sh
|
|
@@ -267,6 +300,40 @@ Try it for one run:
|
|
|
267
300
|
pi -e npm:pi-better-background-tasks
|
|
268
301
|
```
|
|
269
302
|
|
|
303
|
+
## Observed operations and waits
|
|
304
|
+
|
|
305
|
+
Supervised local tasks use `bgs_` identifiers and a detached runner. The runner
|
|
306
|
+
keeps observing after the worker or Pi process exits. Status and logs recover
|
|
307
|
+
from durable evidence; an ambiguous launch becomes `lost` and is not relaunched.
|
|
308
|
+
SSH tasks and older `bg_` identifiers use the existing runtime.
|
|
309
|
+
|
|
310
|
+
Stop and timeout evidence can contain `groupCleanup=unproven` when the command's
|
|
311
|
+
leader exits before cleanup can be verified. Descendants may remain running;
|
|
312
|
+
inspect that evidence before interpreting the result.
|
|
313
|
+
|
|
314
|
+
Call `wait_for_operations` with `task_ids` and an optional `continuation_note` to
|
|
315
|
+
register an all-settled wait. The parent remains responsive. A notification means
|
|
316
|
+
every dependency is terminal, including failed, cancelled, timed-out or lost
|
|
317
|
+
operations; it does not mean the command succeeded.
|
|
318
|
+
|
|
319
|
+
Read each operation's status or log, interpret its terminal revision, then call
|
|
320
|
+
`acknowledge_operation_result` with the wait identity, exact revisions, disposition
|
|
321
|
+
and interpretation note. The acknowledgement releases the wait's evidence pins.
|
|
322
|
+
Reading evidence or receiving a callback alone does not consume the wait.
|
|
323
|
+
`bg_task_wait_list` shows owned generations. `bg_task_wait_cancel` cancels a wait;
|
|
324
|
+
use `bg_task_stop` separately when the command itself should stop.
|
|
325
|
+
|
|
326
|
+
`watch_delivery` registers the installed stateful Mergify delivery watcher and its
|
|
327
|
+
wait. The watcher observes delivery; the model interprets its result. The tool
|
|
328
|
+
does not enqueue or merge a pull request.
|
|
329
|
+
|
|
330
|
+
With Subagents loaded, workers can use the guarded operation bridge and park
|
|
331
|
+
while waiting. Parking requires SDK 0.82.1 or later. The worker saves its wait
|
|
332
|
+
result, exits cleanly and releases its capacity slot. The same conversation can
|
|
333
|
+
resume after settlement when its original parent session and branch are active
|
|
334
|
+
and current capacity, Goal, workflow and permission gates allow it. Restarting Pi
|
|
335
|
+
does not start an unattended model. Uncertain recovery stays held with a reason.
|
|
336
|
+
|
|
270
337
|
## Failure observations
|
|
271
338
|
|
|
272
339
|
Task lifecycle and failure evidence are reported separately. A watch can remain
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-background-tasks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
46
46
|
"src/**/*.ts",
|
|
47
|
+
"src/supervisor/**/*.mjs",
|
|
48
|
+
"src/supervisor/**/*.js",
|
|
47
49
|
"!src/**/*.test.ts",
|
|
48
50
|
"!src/test-support/**",
|
|
49
51
|
"!src/shared-ssh-core/test-support/**",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getObservedWaitHost } from "./observed-waits.js";
|
|
2
3
|
import { inspectMeta } from "./registry.js";
|
|
3
4
|
|
|
4
5
|
/** Optional-peer contract, mirrored by pi-better-sandbox/background-admission.ts. */
|
|
@@ -22,18 +23,26 @@ export function advertiseBackgroundAdapter(pi: ExtensionAPI, schemas: ReadonlyMa
|
|
|
22
23
|
typeof request.reply !== "function") return;
|
|
23
24
|
const parameters = schemas.get(request.name);
|
|
24
25
|
if (!parameters) return;
|
|
25
|
-
const admission = classify(request.name, request.input);
|
|
26
|
+
const admission = classify(pi, request.name, request.input);
|
|
26
27
|
if (admission) request.reply({ version: BACKGROUND_ADAPTER_VERSION, parameters, admission, executionControls: 1 });
|
|
27
28
|
});
|
|
28
29
|
pi.on("session_shutdown", () => { if (typeof unsubscribe === "function") unsubscribe(); });
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function classify(name: string, input: unknown): Admission | undefined {
|
|
32
|
+
function classify(pi: ExtensionAPI, name: string, input: unknown): Admission | undefined {
|
|
32
33
|
if (!input || typeof input !== "object" || Array.isArray(input)) return;
|
|
33
34
|
const params = input as Record<string, unknown>;
|
|
34
35
|
let action: unknown;
|
|
35
36
|
switch (name) {
|
|
36
37
|
case "bg_task_spawn": action = "spawn"; break;
|
|
38
|
+
case "bg_task_spawn_batch": {
|
|
39
|
+
// A batch is a local launch only when no job is remote: per-job ssh options
|
|
40
|
+
// are decided by the batch launcher, not in this trusted process.
|
|
41
|
+
const jobs = Array.isArray(params.jobs) ? params.jobs : [];
|
|
42
|
+
const remote = jobs.some((job) => job !== null && typeof job === "object" &&
|
|
43
|
+
Boolean((job as Record<string, unknown>).ssh));
|
|
44
|
+
return remote ? undefined : "local-launch";
|
|
45
|
+
}
|
|
37
46
|
case "bg_task_watch": action = "watch"; break;
|
|
38
47
|
case "bg_task_list": action = "list"; break;
|
|
39
48
|
case "bg_task_status": action = "status"; break;
|
|
@@ -53,4 +62,9 @@ function classify(name: string, input: unknown): Admission | undefined {
|
|
|
53
62
|
// of a local stop; a remote stop can execute SSH in the trusted Pi process.
|
|
54
63
|
const meta = inspectMeta(params.id).meta;
|
|
55
64
|
if (meta && !meta.ssh && !meta.remote && (meta.kind === "process" || meta.kind === "command_watch")) return "registry";
|
|
65
|
+
// A durable local task has no legacy registry meta. Only the currently active
|
|
66
|
+
// owning session/workspace may admit it as a registry operation; the exact tool
|
|
67
|
+
// source and schema are still verified by the caller before this reply is
|
|
68
|
+
// trusted. A foreign session or worker-owned id stays unauthorized.
|
|
69
|
+
if (getObservedWaitHost(pi)?.ownsDurableTask(params.id) === true) return "registry";
|
|
56
70
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { readTaskIntent } from "./failures.js";
|
|
4
|
+
import { validateCommandSpec } from "./process.js";
|
|
5
|
+
import { resolveSshCommand } from "./shared-ssh-core/index.js";
|
|
6
|
+
import { spawnTask, type ActiveSessionProvider, type SpawnTaskParams, type TaskRuntimeDependencies } from "./runtime.js";
|
|
7
|
+
import type { BackgroundTaskCallbackOrigin } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export interface SpawnBatchParams {
|
|
10
|
+
batchName?: string;
|
|
11
|
+
jobs: SpawnTaskParams[];
|
|
12
|
+
shared?: Omit<SpawnTaskParams, "name" | "command" | "argv" | "batchId" | "batchName">;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SpawnBatchResult {
|
|
16
|
+
batchId: string;
|
|
17
|
+
batchName?: string;
|
|
18
|
+
launched: Array<{ job: number; id: string; name?: string }>;
|
|
19
|
+
failed: Array<{ job: number; name?: string; error: string }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Undefined inherits; false, zero, and explicit-null intent declarations override. */
|
|
23
|
+
function mergeJob(shared: SpawnBatchParams["shared"], job: SpawnTaskParams): SpawnTaskParams {
|
|
24
|
+
return { ...shared, ...Object.fromEntries(Object.entries(job).filter(([, value]) => value !== undefined)) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function spawnTaskBatch(
|
|
28
|
+
pi: ExtensionAPI,
|
|
29
|
+
params: SpawnBatchParams,
|
|
30
|
+
defaultCwd: string,
|
|
31
|
+
callbackOrigin?: BackgroundTaskCallbackOrigin,
|
|
32
|
+
getActiveSession?: ActiveSessionProvider,
|
|
33
|
+
dependencies: TaskRuntimeDependencies = {},
|
|
34
|
+
): SpawnBatchResult {
|
|
35
|
+
if (!Array.isArray(params.jobs) || params.jobs.length < 1 || params.jobs.length > 100) {
|
|
36
|
+
throw new Error("jobs must contain between 1 and 100 background tasks.");
|
|
37
|
+
}
|
|
38
|
+
for (const field of ["name", "command", "argv", "batchId", "batchName"]) {
|
|
39
|
+
if (params.shared && Object.hasOwn(params.shared, field)) {
|
|
40
|
+
throw new Error(`Invalid batch shared field ${field}: each job must supply its own command, argv, and name; batch identity is assigned by the launcher.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Preflight the whole batch before any process, task directory, or SSH connection exists.
|
|
44
|
+
const jobs = params.jobs.map((job, index) => {
|
|
45
|
+
try {
|
|
46
|
+
if (!job || typeof job !== "object" || Array.isArray(job)) throw new Error("job must be an object");
|
|
47
|
+
const merged = mergeJob(params.shared, job);
|
|
48
|
+
readTaskIntent(merged);
|
|
49
|
+
validateLaunchStrings({
|
|
50
|
+
command: merged.command,
|
|
51
|
+
argv: merged.argv,
|
|
52
|
+
cwd: merged.cwd ?? defaultCwd,
|
|
53
|
+
env: merged.env,
|
|
54
|
+
ssh: merged.ssh,
|
|
55
|
+
remote: merged.remote,
|
|
56
|
+
});
|
|
57
|
+
if (merged.ssh) {
|
|
58
|
+
resolveSshCommand({ command: merged.command, cwd: merged.cwd ?? defaultCwd, env: merged.env, ssh: merged.ssh });
|
|
59
|
+
} else {
|
|
60
|
+
validateCommandSpec(merged);
|
|
61
|
+
}
|
|
62
|
+
return merged;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw new Error(`Invalid batch job ${index + 1}: ${errorMessage(error)}`);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const result: SpawnBatchResult = {
|
|
68
|
+
batchId: `bg_batch_${randomUUID()}`,
|
|
69
|
+
...(params.batchName !== undefined ? { batchName: params.batchName } : {}),
|
|
70
|
+
launched: [],
|
|
71
|
+
failed: [],
|
|
72
|
+
};
|
|
73
|
+
for (const [index, job] of jobs.entries()) {
|
|
74
|
+
try {
|
|
75
|
+
const meta = spawnTask(pi, { ...job, batchId: result.batchId, batchName: result.batchName }, defaultCwd, callbackOrigin, getActiveSession, dependencies);
|
|
76
|
+
result.launched.push({ job: index + 1, id: meta.id, ...(meta.name !== undefined ? { name: meta.name } : {}) });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
result.failed.push({ job: index + 1, ...(job.name !== undefined ? { name: job.name } : {}), error: errorMessage(error) });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function formatBatchLaunch(result: SpawnBatchResult): string {
|
|
85
|
+
const label = result.batchName ? `${result.batchName} (${result.batchId})` : result.batchId;
|
|
86
|
+
return [
|
|
87
|
+
`Batch ${label}: ${result.launched.length} launched, ${result.failed.length} failed to launch.`,
|
|
88
|
+
...result.launched.map((job) => `Job ${job.job}${job.name ? ` (${job.name})` : ""}: ${job.id}`),
|
|
89
|
+
...result.failed.map((job) => `Job ${job.job}${job.name ? ` (${job.name})` : ""}: failed to launch: ${job.error}`),
|
|
90
|
+
"Inspect, log, or stop each task by its id. Do not wait or poll in the foreground.",
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Node rejects NULs synchronously, even when the rest of the command spec is valid.
|
|
95
|
+
function validateLaunchStrings(value: unknown, path = "launch"): void {
|
|
96
|
+
if (typeof value === "string") {
|
|
97
|
+
if (value.includes("\0")) throw new Error(`${path} must not contain NUL bytes`);
|
|
98
|
+
} else if (value && typeof value === "object") {
|
|
99
|
+
for (const [key, child] of Object.entries(value)) {
|
|
100
|
+
if (key.includes("\0")) throw new Error(`${path} keys must not contain NUL bytes`);
|
|
101
|
+
validateLaunchStrings(child, `${path}.${key}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorMessage(error: unknown): string {
|
|
107
|
+
return error instanceof Error ? error.message : String(error);
|
|
108
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {existsSync,realpathSync,statSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {homedir} from 'node:os';
|
|
4
|
+
import {Type} from 'typebox';
|
|
5
|
+
import type {ExtensionAPI,ExtensionContext} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
|
|
7
|
+
/** Domain observation uses the installed stateful watcher, never a generated polling predicate. */
|
|
8
|
+
export function resolveDeliveryWatcher(agentDir=process.env.PI_CODING_AGENT_DIR??join(homedir(),'.pi','agent')): string {
|
|
9
|
+
const candidates=[
|
|
10
|
+
join(agentDir,'skills','mergify','scripts','watch-pr-delivery.mjs'),
|
|
11
|
+
join(homedir(),'.agents','skills','mergify','scripts','watch-pr-delivery.mjs'),
|
|
12
|
+
join(agentDir,'npm','node_modules','@1aboveio','skills','skills','cicd','mergify','scripts','watch-pr-delivery.mjs'),
|
|
13
|
+
];
|
|
14
|
+
for(const candidate of candidates)if(existsSync(candidate)&&statSync(candidate).isFile())return realpathSync.native(candidate);
|
|
15
|
+
throw new Error('The installed Mergify delivery watcher is unavailable. Install the mergify skill; no replacement polling script was launched.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function deliveryObserverArguments(repo:string,pr:number,watcher:string): string[] {
|
|
19
|
+
if(!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)||!Number.isSafeInteger(pr)||pr<1)throw new Error('A repository owner/name and positive PR number are required');
|
|
20
|
+
if(!watcher.endsWith('/watch-pr-delivery.mjs'))throw new Error('Unsupported delivery observer implementation');
|
|
21
|
+
return [process.execPath,watcher,'--repo',repo,'--pr',String(pr)];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DeliveryObserverDependencies {
|
|
25
|
+
resolveWatcher?:()=>string;
|
|
26
|
+
launch:(params:{argv:string[];shell:false;cwd:string;name:string;callback:true},ctx:ExtensionContext)=>Promise<{id:string}>|{id:string};
|
|
27
|
+
wait:(taskIds:string[],note:string,ctx:ExtensionContext)=>Promise<unknown>|unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerDeliveryObserver(pi:Pick<ExtensionAPI,'registerTool'>,dependencies:DeliveryObserverDependencies):void {
|
|
31
|
+
pi.registerTool({
|
|
32
|
+
name:'watch_delivery',label:'Watch delivery',
|
|
33
|
+
description:'Observe an already-enqueued GitHub pull request through Mergify with the installed stateful delivery watcher. Starts a supervised background task and registers its wait. This does not enqueue, merge, push, edit the PR, or delegate polling to an LLM. Inspect the terminal verdict on completion before reporting delivery.',
|
|
34
|
+
parameters:Type.Object({repo:Type.String({description:'Repository owner/name'}),pr:Type.Integer({minimum:1})}),
|
|
35
|
+
async execute(_id,params,_signal,_update,ctx){
|
|
36
|
+
const argv=deliveryObserverArguments(params.repo,params.pr,(dependencies.resolveWatcher??resolveDeliveryWatcher)());
|
|
37
|
+
const task=await dependencies.launch({argv,shell:false,cwd:ctx.cwd,name:`Mergify delivery ${params.repo}#${params.pr}`,callback:true},ctx);
|
|
38
|
+
const wait=await dependencies.wait([task.id],`Interpret the Mergify terminal verdict for ${params.repo}#${params.pr}; inspect merge/dequeue evidence before reporting delivery.`,ctx);
|
|
39
|
+
return {content:[{type:'text',text:JSON.stringify({taskId:task.id,wait,status:'observing',mutationPerformed:false})}],details:{taskId:task.id,wait}};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { listMetas, listMetasForOrigin, onMetaChanged } from "./registry.js";
|
|
3
|
+
import { getObservedWaitHost, type DurableTaskProjection } from "./observed-waits.js";
|
|
3
4
|
import type { BackgroundTaskMeta } from "./types.js";
|
|
4
5
|
import { isTerminalStatus } from "./types.js";
|
|
5
6
|
import { backgroundTaskProgressAt, observeBackgroundTaskStall } from "./stall.js";
|
|
@@ -31,13 +32,19 @@ export function collectBackgroundTaskGoalActivity(
|
|
|
31
32
|
metas: BackgroundTaskMeta[] = listMetas(),
|
|
32
33
|
ctx?: ExtensionContext,
|
|
33
34
|
now = Date.now(),
|
|
35
|
+
durable: readonly DurableTaskProjection[] = [],
|
|
34
36
|
): GoalBackgroundProviderSnapshot {
|
|
35
37
|
return {
|
|
36
38
|
providerId: "background-tasks",
|
|
37
39
|
label: "Background Tasks",
|
|
38
|
-
items:
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
items: [
|
|
41
|
+
...metas
|
|
42
|
+
.filter((meta) => isVisibleGoalActivity(meta, ctx, now))
|
|
43
|
+
.map(backgroundTaskToGoalItem),
|
|
44
|
+
...durable
|
|
45
|
+
.filter((task) => isVisibleDurableGoalActivity(task, ctx, now))
|
|
46
|
+
.map(durableTaskToGoalItem),
|
|
47
|
+
],
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -46,8 +53,23 @@ export function registerBackgroundTasksGoalProvider(pi: ExtensionAPI): void {
|
|
|
46
53
|
pi.events?.emit(GOAL_REGISTER_PROVIDER_EVENT, {
|
|
47
54
|
id: "background-tasks",
|
|
48
55
|
label: "Background Tasks",
|
|
49
|
-
getActivity: (ctx: ExtensionContext) =>
|
|
50
|
-
|
|
56
|
+
getActivity: (ctx: ExtensionContext) => {
|
|
57
|
+
const origin = getGoalActivityOrigin(ctx);
|
|
58
|
+
const durable = getObservedWaitHost(pi)?.listDurableTasks(origin, {
|
|
59
|
+
retentionMs: TERMINAL_GOAL_ACTIVITY_RETENTION_MS,
|
|
60
|
+
}) ?? [];
|
|
61
|
+
return collectBackgroundTaskGoalActivity(listMetasForOrigin(origin), ctx, Date.now(), durable);
|
|
62
|
+
},
|
|
63
|
+
// A durable operation change must refresh Goal activity even though it
|
|
64
|
+
// never writes a mutable legacy registry meta.
|
|
65
|
+
onActivityChanged: (notify: () => void) => {
|
|
66
|
+
const offMeta = onMetaChanged(notify);
|
|
67
|
+
const offDurable = getObservedWaitHost(pi)?.onDurableTaskChange(notify);
|
|
68
|
+
return () => {
|
|
69
|
+
offMeta();
|
|
70
|
+
offDurable?.();
|
|
71
|
+
};
|
|
72
|
+
},
|
|
51
73
|
});
|
|
52
74
|
};
|
|
53
75
|
|
|
@@ -91,6 +113,51 @@ function getGoalActivityOrigin(ctx: ExtensionContext) {
|
|
|
91
113
|
return { cwd: ctx.cwd, sessionId };
|
|
92
114
|
}
|
|
93
115
|
|
|
116
|
+
function isVisibleDurableGoalActivity(
|
|
117
|
+
task: DurableTaskProjection,
|
|
118
|
+
ctx: ExtensionContext | undefined,
|
|
119
|
+
now: number,
|
|
120
|
+
): boolean {
|
|
121
|
+
if (task.dismissedAt !== undefined) return false;
|
|
122
|
+
if (ctx) {
|
|
123
|
+
const active = getGoalActivityOrigin(ctx);
|
|
124
|
+
if (task.owner.workspace !== active.cwd) return false;
|
|
125
|
+
if (active.sessionId || task.owner.sessionId) {
|
|
126
|
+
if (task.owner.sessionId !== active.sessionId) return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!task.terminal) return true;
|
|
130
|
+
if (!ctx) return true;
|
|
131
|
+
if (task.endedAt === undefined) return true;
|
|
132
|
+
return now - task.endedAt < TERMINAL_GOAL_ACTIVITY_RETENTION_MS;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function durableTaskToGoalItem(task: DurableTaskProjection): GoalBackgroundWorkItem {
|
|
136
|
+
const item: GoalBackgroundWorkItem = {
|
|
137
|
+
id: task.id,
|
|
138
|
+
status: task.status,
|
|
139
|
+
active: task.active,
|
|
140
|
+
terminal: task.terminal,
|
|
141
|
+
attention: task.attention,
|
|
142
|
+
startedAt: task.startedAt,
|
|
143
|
+
details: {
|
|
144
|
+
durable: true,
|
|
145
|
+
operationId: task.operationId,
|
|
146
|
+
kind: task.kind,
|
|
147
|
+
cwd: task.cwd,
|
|
148
|
+
command: task.command,
|
|
149
|
+
argv: task.argv,
|
|
150
|
+
logPath: task.logPaths.stdout,
|
|
151
|
+
lastProgressAt: task.endedAt ?? task.startedAt,
|
|
152
|
+
waits: task.waits.map((wait) => `${wait.waitId}:${wait.phase}`),
|
|
153
|
+
...(task.reason ? { reason: task.reason } : {}),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
if (task.name !== undefined) item.label = task.name;
|
|
157
|
+
if (task.endedAt !== undefined) item.endedAt = task.endedAt;
|
|
158
|
+
return item;
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
function backgroundTaskToGoalItem(meta: BackgroundTaskMeta): GoalBackgroundWorkItem {
|
|
95
162
|
const active = meta.status === "running";
|
|
96
163
|
const terminal = isTerminalStatus(meta.status);
|
|
@@ -4,11 +4,37 @@ import { initializeNavigatorPanels } from "./shared-navigator-panels.ts";
|
|
|
4
4
|
import { registerBackgroundTasksGoalProvider } from "./goal-provider.js";
|
|
5
5
|
import { clearBackgroundTasksNavigatorSession, ensureBackgroundTasksNavigator, ensureBackgroundTasksNavigatorProvider } from "./navigator-provider.js";
|
|
6
6
|
import { resumeScheduledWork, suspendScheduledWork } from "./runtime.js";
|
|
7
|
+
import { createObservedWaitHost } from "./observed-waits.js";
|
|
8
|
+
import { getCallbackBatcher } from "./shared-callback-batcher.js";
|
|
7
9
|
import { observeForegroundSandboxPolicy } from "./sandbox.js";
|
|
8
10
|
import { registerTools } from "./tools.js";
|
|
9
11
|
import { appendExecutionPolicy } from "./shared-execution-policy.js";
|
|
10
12
|
|
|
11
13
|
export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
14
|
+
// Created before registerTools so the tools can resolve it lazily; the durable
|
|
15
|
+
// store opens on session activation and closes on switch/unload.
|
|
16
|
+
const observedWaits = createObservedWaitHost(pi, {
|
|
17
|
+
notifyParent: (notification, delivery) => {
|
|
18
|
+
getCallbackBatcher(pi).enqueue({
|
|
19
|
+
source: "background-task",
|
|
20
|
+
id: notification.taskId,
|
|
21
|
+
label: notification.taskId,
|
|
22
|
+
status: notification.waitId
|
|
23
|
+
? `${notification.status}; wait ${notification.waitId} ready`
|
|
24
|
+
: notification.status,
|
|
25
|
+
detailTool: "bg_task_status",
|
|
26
|
+
outcome: notification.outcome,
|
|
27
|
+
callback: true,
|
|
28
|
+
// The batcher can hold the event across a busy turn or a session switch.
|
|
29
|
+
// Receipt is the durable outbox/sidecar acknowledgement, not enqueue.
|
|
30
|
+
getSuppressionReason: () => observedWaits.isParentOriginActive(notification.origin, notification.branchId)
|
|
31
|
+
? undefined
|
|
32
|
+
: "the originating session or branch is no longer active",
|
|
33
|
+
onDelivered: () => delivery.delivered(),
|
|
34
|
+
onSuppressed: (reason) => delivery.defer(reason ?? "callback delivery was suppressed"),
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
});
|
|
12
38
|
initializeNavigatorPanels(pi);
|
|
13
39
|
pi.on("before_agent_start", (event) => ({ systemPrompt: appendExecutionPolicy(event.systemPrompt) }));
|
|
14
40
|
registerBackgroundTasksGoalProvider(pi);
|
|
@@ -23,14 +49,27 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
23
49
|
ensureBackgroundTasksNavigator(ctx);
|
|
24
50
|
});
|
|
25
51
|
pi.on("session_before_switch", async () => {
|
|
52
|
+
observedWaits.close();
|
|
26
53
|
clearBackgroundTasksNavigatorSession();
|
|
27
54
|
disposeBackgroundWorkNavigator();
|
|
28
55
|
});
|
|
29
56
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
30
57
|
// /reload, session replacement, and quit load a fresh instance; stop this one's timers (#324).
|
|
58
|
+
observedWaits.close();
|
|
31
59
|
suspendScheduledWork();
|
|
32
60
|
clearBackgroundTasksNavigatorSession();
|
|
33
61
|
disposeBackgroundWorkNavigator(ctx);
|
|
34
62
|
});
|
|
35
63
|
registerTools(pi);
|
|
64
|
+
// Registered after registerTools so the callback batcher has its session
|
|
65
|
+
// context before a reconciled delivery can enqueue a parent notification.
|
|
66
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
67
|
+
observedWaits.start(ctx);
|
|
68
|
+
});
|
|
69
|
+
// A fork or branch navigation does not restart the session, but it changes
|
|
70
|
+
// which continuation may receive a queued callback. Update the host snapshot
|
|
71
|
+
// on the same event the callback batcher uses.
|
|
72
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
73
|
+
observedWaits.noteParentContext(ctx);
|
|
74
|
+
});
|
|
36
75
|
}
|