pi-better-harness 0.39.2 → 0.40.1

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.
Files changed (87) hide show
  1. package/extensions/execution-policy.ts +2 -0
  2. package/node_modules/pi-better-background-tasks/README.md +67 -0
  3. package/node_modules/pi-better-background-tasks/package.json +3 -1
  4. package/node_modules/pi-better-background-tasks/src/adapter-contract.ts +16 -2
  5. package/node_modules/pi-better-background-tasks/src/batch.ts +108 -0
  6. package/node_modules/pi-better-background-tasks/src/delivery-observer.ts +42 -0
  7. package/node_modules/pi-better-background-tasks/src/goal-provider.ts +72 -5
  8. package/node_modules/pi-better-background-tasks/src/index.ts +39 -0
  9. package/node_modules/pi-better-background-tasks/src/navigator-provider.ts +196 -6
  10. package/node_modules/pi-better-background-tasks/src/observed-waits.ts +3679 -0
  11. package/node_modules/pi-better-background-tasks/src/process-identity.ts +108 -31
  12. package/node_modules/pi-better-background-tasks/src/runtime.ts +10 -0
  13. package/node_modules/pi-better-background-tasks/src/sandbox.ts +276 -2
  14. package/node_modules/pi-better-background-tasks/src/shared-execution-policy.ts +2 -0
  15. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/index.ts +113 -0
  16. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/errors.ts +75 -0
  17. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/operations.ts +376 -0
  18. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/outbox.ts +431 -0
  19. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/rows.ts +439 -0
  20. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/schema.ts +244 -0
  21. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/sqlite.ts +239 -0
  22. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/store.ts +397 -0
  23. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/types.ts +648 -0
  24. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/validate.ts +239 -0
  25. package/node_modules/pi-better-background-tasks/src/shared-operation-coordinator/src/waits.ts +1378 -0
  26. package/node_modules/pi-better-background-tasks/src/supervisor/README.md +111 -0
  27. package/node_modules/pi-better-background-tasks/src/supervisor/emit-runner.mjs +101 -0
  28. package/node_modules/pi-better-background-tasks/src/supervisor/generated/conditions.mjs +96 -0
  29. package/node_modules/pi-better-background-tasks/src/supervisor/generated/identity.mjs +95 -0
  30. package/node_modules/pi-better-background-tasks/src/supervisor/generated/process-identity.mjs +113 -0
  31. package/node_modules/pi-better-background-tasks/src/supervisor/generated/process.mjs +514 -0
  32. package/node_modules/pi-better-background-tasks/src/supervisor/generated/runner.mjs +872 -0
  33. package/node_modules/pi-better-background-tasks/src/supervisor/generated/store.mjs +605 -0
  34. package/node_modules/pi-better-background-tasks/src/supervisor/generated/types.mjs +50 -0
  35. package/node_modules/pi-better-background-tasks/src/supervisor/identity.ts +99 -0
  36. package/node_modules/pi-better-background-tasks/src/supervisor/index.ts +37 -0
  37. package/node_modules/pi-better-background-tasks/src/supervisor/invocation.ts +44 -0
  38. package/node_modules/pi-better-background-tasks/src/supervisor/runner.ts +982 -0
  39. package/node_modules/pi-better-background-tasks/src/supervisor/store.ts +649 -0
  40. package/node_modules/pi-better-background-tasks/src/supervisor/supervisor.ts +804 -0
  41. package/node_modules/pi-better-background-tasks/src/supervisor/types.ts +394 -0
  42. package/node_modules/pi-better-background-tasks/src/tools.ts +396 -9
  43. package/node_modules/pi-better-background-tasks/src/types.ts +8 -0
  44. package/node_modules/pi-better-goal/package.json +1 -1
  45. package/node_modules/pi-better-goal/src/index.ts +50 -1
  46. package/node_modules/pi-better-goal/src/operation-admission.ts +16 -0
  47. package/node_modules/pi-better-goal/src/workflow-gates.ts +15 -0
  48. package/node_modules/pi-better-sandbox/background-admission.ts +2 -2
  49. package/node_modules/pi-better-sandbox/package.json +1 -1
  50. package/node_modules/pi-better-sandbox/shared-task-exec.ts +9 -0
  51. package/node_modules/pi-better-subagents/README.md +23 -6
  52. package/node_modules/pi-better-subagents/child-operations.ts +1109 -0
  53. package/node_modules/pi-better-subagents/child-ownership.ts +12 -1
  54. package/node_modules/pi-better-subagents/cleanup.ts +11 -2
  55. package/node_modules/pi-better-subagents/docs/usage.md +44 -3
  56. package/node_modules/pi-better-subagents/effective-settings.ts +26 -5
  57. package/node_modules/pi-better-subagents/finalization.ts +53 -8
  58. package/node_modules/pi-better-subagents/goal-provider.ts +42 -4
  59. package/node_modules/pi-better-subagents/index.ts +554 -25
  60. package/node_modules/pi-better-subagents/lifecycle.ts +26 -0
  61. package/node_modules/pi-better-subagents/navigator.mjs +13 -2
  62. package/node_modules/pi-better-subagents/navigator.ts +1 -0
  63. package/node_modules/pi-better-subagents/operation-bridge-types.ts +1109 -0
  64. package/node_modules/pi-better-subagents/package.json +2 -1
  65. package/node_modules/pi-better-subagents/park-checkpoint.ts +61 -0
  66. package/node_modules/pi-better-subagents/parse.ts +19 -1
  67. package/node_modules/pi-better-subagents/registry.ts +192 -6
  68. package/node_modules/pi-better-subagents/session-store.ts +3 -0
  69. package/node_modules/pi-better-subagents/shared-execution-policy.ts +2 -0
  70. package/node_modules/pi-better-subagents/shared-operation-coordinator/index.ts +113 -0
  71. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/errors.ts +75 -0
  72. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/operations.ts +376 -0
  73. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/outbox.ts +431 -0
  74. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/rows.ts +439 -0
  75. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/schema.ts +244 -0
  76. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/sqlite.ts +239 -0
  77. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/store.ts +397 -0
  78. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/types.ts +648 -0
  79. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/validate.ts +239 -0
  80. package/node_modules/pi-better-subagents/shared-operation-coordinator/src/waits.ts +1378 -0
  81. package/node_modules/pi-better-subagents/shared-task-exec.ts +9 -0
  82. package/node_modules/pi-better-subagents/stop.ts +7 -3
  83. package/node_modules/pi-better-subagents/task-guard.ts +22 -3
  84. package/node_modules/pi-better-subagents/task-policy.ts +7 -0
  85. package/node_modules/pi-better-subagents/worker-operations-host.ts +1226 -0
  86. package/node_modules/pi-better-subagents/worker-waits.ts +2549 -0
  87. 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.13.1",
3
+ "version": "0.14.1",
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: metas
39
- .filter((meta) => isVisibleGoalActivity(meta, ctx, now))
40
- .map(backgroundTaskToGoalItem),
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) => collectBackgroundTaskGoalActivity(listMetasForOrigin(getGoalActivityOrigin(ctx)), ctx),
50
- onActivityChanged: onMetaChanged,
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
  }