pi-background-tasks 2.1.3 → 2.1.4

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 CHANGED
@@ -26,7 +26,7 @@
26
26
  | Fact | Value |
27
27
  | --- | --- |
28
28
  | Package | `pi-background-tasks` |
29
- | Version | `2.1.3` |
29
+ | Version | `2.1.4` |
30
30
  | Node engine | `>=22.19.0` |
31
31
  | Pi entrypoint | `./extensions/background-tasks.ts` |
32
32
  | Package image | [logo.png](https://raw.githubusercontent.com/ismailsaleekh/pi-background-tasks/main/logo.png) |
@@ -294,7 +294,7 @@ Agent tasks launched through `pi -p ...` or `pi --mode json ...` and marked `isA
294
294
  - Claude Fusion children load the package-owned Claude Code OAuth attribution provider shared with the repo spawn path, request `ttl: "1h"` on system/tool/conversation cache breakpoints before serialization, and preserve provider-reported `cacheWrite1h` evidence. Set `PI_CACHE_RETENTION=short|none|long` to choose explicitly; malformed attribution, policy, or cache evidence fails before transport. Provider usage is preserved verbatim, but subscription OAuth can report `cacheWrite1h = 0` even when a unique cache remains readable beyond five minutes; treat positive `cacheWrite1h` as definitive and zero as inconclusive on that channel. Anthropic budgeting follows the provider's 200K subscription policy.
295
295
  - Fusion research fetches only caller-supplied public `http(s)` URLs with bounded retrieval. It is not web search and not a secret-exfiltration boundary.
296
296
  - Attestation sidecars are local, unsigned, same-user-writable evidence. They are useful for downstream local gates, but not cryptographic proof against local compromise, a compromised Pi binary, or a compromised provider.
297
- - Metadata, attestations, delegate/Fusion artifacts, and configuration replacements use write/fsync/rename durability patterns. Ordinary task output is closed and drained before terminal publication but is not explicitly fsynced. POSIX directory entries are fsynced after atomic replacement; Windows lacks the same portable directory-entry crash-durability guarantee.
297
+ - Metadata, attestations, delegate/Fusion artifacts, and configuration replacements use write/fsync/rename durability patterns. Failed/cancelled stored Fusion runs also have a manifest-bound `failure-summary.json` containing bounded no-answer evidence metadata and artifact refs only; `bg_result` returns it as an answer-free typed terminal view after integrity checks. Ordinary task output is closed and drained before terminal publication but is not explicitly fsynced. POSIX directory entries are fsynced after atomic replacement; Windows lacks the same portable directory-entry crash-durability guarantee.
298
298
 
299
299
  Detailed operations: [Configuration](docs/operations/configuration.md).
300
300
 
package/TESTING.md CHANGED
@@ -187,7 +187,7 @@ npm run test:rpc
187
187
  npm run test:agent-loop
188
188
  ```
189
189
 
190
- The Fusion SDK/RPC/scripted-provider tests install a deterministic fake child `pi` in a temp `PATH` from `tests/helpers/fusion-fake-pi.ts`. Parent Pi remains the real SDK/RPC runtime; only direct child `pi --mode text` calls with the package-owned private compact metadata extension are intercepted. SDK coverage proves the Fusion tools return after durable no-child preflight without waiting for delayed children, transfer cancellation ownership away from the completed tool call, remain tracked through status/dock/kill/notification, verify manifest-bound `result.json` plus `merged.md`, and attach complete usage on the first `bg_result` retrieval exactly once. The scripted provider proves launch → no-poll parent response → terminal wake → `bg_result` → verified answer. `PI_CODING_AGENT_DIR` is pointed at the temp agent directory so `fusion-models.json` is never read from the user's real global Pi directory. Fusion v1 public-surface coverage asserts exactly four tools (`fusion_reason`, `fusion_investigate`, `fusion_research`, `fusion_validate`), no public capability argument, retired-tool active-tool removal, `/fusion` mapping to reason/no-tool candidates, closed schemas with Google-compatible enum status, targeted URL fetch not search, URL exfiltration warnings, strict validation verification rules, historical v4 rendering without old-tool activation, and actionable migration errors for `fusion_validate({prompt})`. Fusion context coverage covers both reason conversation projection and clean-task non-interference invariants, including parent-sentinel absence from every clean downstream prompt/artifact and byte-identical clean inputs across unrelated parent sessions. `tests/unit/fusion-context-prompts.test.ts` verifies that a synthetic session carrying more than 1 MB of tool arguments/results still yields a small canonical input, that user and assistant text survive verbatim, that thinking and tool payloads never appear (including no head/tail/preview sentinel), that omission counts, byte totals, and hashes are exact and stable, that repeated construction is byte-identical, that the active Fusion tool leaf and sibling calls stay scope-excluded, that images remain marker-only or ledger-only with no raw base64 in child prompts, and that every retained source block receives exactly one disposition. `tests/unit/fusion-high-cardinality.test.ts` covers the receipt-cardinality regression: a session of many short interleaved tool events (rather than a few enormous results) pins per-receipt cost, proves the compact `omitted_activity` fields are the only model-facing ones, reconciles every receipt against the ledger through `projection_map`, and proves the whole workflow fits a real route budget. `tests/unit/fusion-budget.test.ts` covers stage budgets and stage-local refusal wording; orchestrator coverage derives terminal run progress from durable attempts after usage persistence so late evaluator/merge refusal reports completed, failed, cancelled, and not-started truth instead of claiming zero children. Each route reserves the larger of Fusion's output contract and the model's declared maximum output, the limiting model is selected by conservative byte capacity (including when it is the evaluator rather than a candidate), unknown or too-small capacities fail before spawn, boundary prompts pass at exactly the limit and fail one byte past it, the child system prompt counts as input, dense multi-byte UTF-8 cannot bypass byte accounting, and candidate, evaluator, evaluation-repair, and merger expansions are each rejected before their child is spawned with zero partial launches. `tests/unit/fusion-pi-child.test.ts` covers the post-launch `fusion-runtime-guard.v2` protocol, BUG-185 removal of live token/output-reservation admission, stable payload normalization, 128-request/192-tool limits, malformed or duplicate evidence rejection, typed parent errors, and failed audit sealing. `tests/unit/fusion-claude-cache.test.ts` pins native pre-serialization `ttl: "1h"` requests, explicit short/none/long policy, call-level compaction opt-out, model compatibility fallback, non-mutation, the four-breakpoint ceiling, subscription prompt-caching-scope beta idempotence, malformed-control refusal, and distinct `child_cache_policy_invalid` parent errors. Child argv tests pin the shared attribution provider → sanitizer → runtime-governor order; root attribution tests pin linked OAuth account/device/session metadata, beta-resource request shape, cache surfaces, one-hour provider usage pricing, and the 200K subscription policy. Compact/result usage tests preserve Anthropic `cacheWrite1h` and provider `reasoning` subsets, and child metadata binds each requested/effective payload observation plus JSON-rendered output accounting in `fusion-child-result.v4`. Live subscription-OAuth acceptance on 2026-08-04 proved cold writes plus exact-repeat reads on Sonnet 4.5 and Opus 5 while both reported `cacheWrite1h = 0`. Separate normal-spawn and exact Fusion-child Opus 5 controls—with unique prompts—still read their caches after 370 idle seconds; the Fusion control wrote and reread 9,922 tokens through the attribution → sanitizer → governor path. Documentation therefore treats positive `cacheWrite1h` as definitive but zero as inconclusive on subscription OAuth; payload intent, provider itemization, and behavioral lifetime remain separate evidence. Terminal `fusion-child-settlement.v3` is published only at `agent_settled`; recovered non-final provider errors must be zero-content/zero-usage retry markers named by that settlement, and one non-final candidate `stop` is accepted only when it is a hash-bound oversized original immediately followed by a same-session replacement. `tests/scripted-provider/fusion-output-recovery.test.ts` drives a real Pi print-mode process and proves one PID/session context, one queued continuation before settlement, tool removal on turn two, original-artifact preservation, and replacement-only stdout. Missing/duplicate/tampered/failed settlements or substantive error records remain fatal. `tests/scripted-provider/fusion-runtime-guard.test.ts` drives a real Pi agent loop through Pi's `openai-codex-responses` adapter against local HTTP and proves provider-payload transforms chain in load order and `ctx.abort()` prevents transport for the execution/cache-policy refusals that remain. `tests/package/typebox-compat.test.ts` pins the TypeBox posture and compiles nullable-array schemas. The release-only `npm run test:compat` packs the package, installs exact supported Pi versions, runs `/jobs`, runs `/fusion` through the installed package entrypoint with the fake child Pi, verifies five child invocations, verifies `/fusion-models` rejects non-TUI mode, requires each supported Pi line to declare terminal `agent_settled` and `before_provider_request`, verifies the installed Anthropic adapter exposes cache breakpoints plus long/tool compatibility controls, asserts the resolved `typebox` is Pi's bundled peer rather than a private or nested copy, and scans the installed package bytes for TypeBox APIs removed in the 1.3.x line. Pi 0.75.5 is intentionally unsupported because it lacks the terminal event required to seal a Fusion audit after retries and compaction. It then drives the current host Pi through a real RPC `fusion_reason` parent-agent loop, checks the persisted tool result carries the complete Pi `Usage.cost` object, invokes `get_session_stats` (the same aggregation boundary used by the TUI footer), reopens the durable session, and verifies identical token/cost totals. All parent and child inference remains deterministic and local.
190
+ The Fusion SDK/RPC/scripted-provider tests install a deterministic fake child `pi` in a temp `PATH` from `tests/helpers/fusion-fake-pi.ts`. Parent Pi remains the real SDK/RPC runtime; only direct child `pi --mode text` calls with the package-owned private compact metadata extension are intercepted. SDK coverage proves the Fusion tools return after durable no-child preflight without waiting for delayed children, transfer cancellation ownership away from the completed tool call, remain tracked through status/dock/kill/notification, verify manifest-bound `result.json` plus `merged.md`, and attach complete usage on the first `bg_result` retrieval exactly once. The scripted provider proves launch → no-poll parent response → terminal wake → `bg_result` → verified answer. `PI_CODING_AGENT_DIR` is pointed at the temp agent directory so `fusion-models.json` is never read from the user's real global Pi directory. Fusion v1 public-surface coverage asserts exactly four tools (`fusion_reason`, `fusion_investigate`, `fusion_research`, `fusion_validate`), no public capability argument, retired-tool active-tool removal, `/fusion` mapping to reason/no-tool candidates, closed schemas with Google-compatible enum status, targeted URL fetch not search, URL exfiltration warnings, strict validation verification rules, historical v4 rendering without old-tool activation, and actionable migration errors for `fusion_validate({prompt})`. Fusion context coverage covers both reason conversation projection and clean-task non-interference invariants, including parent-sentinel absence from every clean downstream prompt/artifact and byte-identical clean inputs across unrelated parent sessions. `tests/unit/fusion-context-prompts.test.ts` verifies that a synthetic session carrying more than 1 MB of tool arguments/results still yields a small canonical input, that user and assistant text survive verbatim, that thinking and tool payloads never appear (including no head/tail/preview sentinel), that omission counts, byte totals, and hashes are exact and stable, that repeated construction is byte-identical, that the active Fusion tool leaf and sibling calls stay scope-excluded, that images remain marker-only or ledger-only with no raw base64 in child prompts, and that every retained source block receives exactly one disposition. `tests/unit/fusion-high-cardinality.test.ts` covers the receipt-cardinality regression: a session of many short interleaved tool events (rather than a few enormous results) pins per-receipt cost, proves the compact `omitted_activity` fields are the only model-facing ones, reconciles every receipt against the ledger through `projection_map`, and proves the whole workflow fits a real route budget. `tests/unit/fusion-budget.test.ts` covers stage budgets and stage-local refusal wording; orchestrator coverage derives terminal run progress from durable attempts after usage persistence so late evaluator/merge refusal reports completed, failed, cancelled, and not-started truth instead of claiming zero children. Each route reserves the larger of Fusion's output contract and the model's declared maximum output, the limiting model is selected by conservative byte capacity (including when it is the evaluator rather than a candidate), unknown or too-small capacities fail before spawn, boundary prompts pass at exactly the limit and fail one byte past it, the child system prompt counts as input, dense multi-byte UTF-8 cannot bypass byte accounting, and candidate, evaluator, evaluation-repair, and merger expansions are each rejected before their child is spawned with zero partial launches. `tests/unit/fusion-pi-child.test.ts` covers the post-launch `fusion-runtime-guard.v2` protocol, BUG-185 removal of live token/output-reservation admission, stable payload normalization, 128-request/192-tool limits, malformed or duplicate evidence rejection, typed parent errors, and failed audit sealing. `tests/unit/fusion-claude-cache.test.ts` pins native pre-serialization `ttl: "1h"` requests, explicit short/none/long policy, call-level compaction opt-out, model compatibility fallback, non-mutation, the four-breakpoint ceiling, subscription prompt-caching-scope beta idempotence, malformed-control refusal, and distinct `child_cache_policy_invalid` parent errors. Child argv tests pin the shared attribution provider → sanitizer → runtime-governor order; root attribution tests pin linked OAuth account/device/session metadata, beta-resource request shape, cache surfaces, one-hour provider usage pricing, and the 200K subscription policy. Compact/result usage tests preserve Anthropic `cacheWrite1h` and provider `reasoning` subsets, and child metadata binds each requested/effective payload observation plus JSON-rendered output accounting in `fusion-child-result.v4`. Live subscription-OAuth acceptance on 2026-08-04 proved cold writes plus exact-repeat reads on Sonnet 4.5 and Opus 5 while both reported `cacheWrite1h = 0`. Separate normal-spawn and exact Fusion-child Opus 5 controls—with unique prompts—still read their caches after 370 idle seconds; the Fusion control wrote and reread 9,922 tokens through the attribution → sanitizer → governor path. Documentation therefore treats positive `cacheWrite1h` as definitive but zero as inconclusive on subscription OAuth; payload intent, provider itemization, and behavioral lifetime remain separate evidence. Terminal `fusion-child-settlement.v3` is published only at `agent_settled`; recovered non-final provider errors must be zero-content/zero-usage retry markers named by that settlement, and one non-final candidate `stop` is accepted only when it is a hash-bound oversized original immediately followed by a same-session replacement. Failed/cancelled Fusion coverage also verifies canonical manifest-bound `failure-summary.json` evidence with no stage-output bodies, truthful classifications/omission receipts, subordinate one-shot summary persistence after `writeError`, and typed answer-free `bg_result` terminal views that never claim usage or expose partial output. `tests/scripted-provider/fusion-output-recovery.test.ts` drives a real Pi print-mode process and proves one PID/session context, one queued continuation before settlement, tool removal on turn two, original-artifact preservation, and replacement-only stdout. Missing/duplicate/tampered/failed settlements or substantive error records remain fatal. `tests/scripted-provider/fusion-runtime-guard.test.ts` drives a real Pi agent loop through Pi's `openai-codex-responses` adapter against local HTTP and proves provider-payload transforms chain in load order and `ctx.abort()` prevents transport for the execution/cache-policy refusals that remain. `tests/package/typebox-compat.test.ts` pins the TypeBox posture and compiles nullable-array schemas. The release-only `npm run test:compat` packs the package, installs exact supported Pi versions, runs `/jobs`, runs `/fusion` through the installed package entrypoint with the fake child Pi, verifies five child invocations, verifies `/fusion-models` rejects non-TUI mode, requires each supported Pi line to declare terminal `agent_settled` and `before_provider_request`, verifies the installed Anthropic adapter exposes cache breakpoints plus long/tool compatibility controls, asserts the resolved `typebox` is Pi's bundled peer rather than a private or nested copy, and scans the installed package bytes for TypeBox APIs removed in the 1.3.x line. Pi 0.75.5 is intentionally unsupported because it lacks the terminal event required to seal a Fusion audit after retries and compaction. It then drives the current host Pi through a real RPC `fusion_reason` parent-agent loop, checks the persisted tool result carries the complete Pi `Usage.cost` object, invokes `get_session_stats` (the same aggregation boundary used by the TUI footer), reopens the durable session, and verifies identical token/cost totals. All parent and child inference remains deterministic and local.
191
191
 
192
192
  ## Coverage summary
193
193
 
package/TEST_PLAN.md CHANGED
@@ -20,7 +20,7 @@ The table below remains the exhaustive acceptance source. Do not replace it with
20
20
  | Shortcuts | `Shift+Down`; optional fallback `Ctrl+Alt+C` |
21
21
  | Custom UI | footer status + focused bottom dock overlay |
22
22
  | Custom provider | no |
23
- | Runtime files/state | `.pi/tasks/<session-id>-<pid>/<task-id>.output`, `.pi/tasks/<session-id>-<pid>/<task-id>.json`; attested Pi opt-in adds `.pi-events.jsonl`, `.stderr`, `.pi-telemetry-wrapper.cjs`, `.attestation.json`; Fusion adds private `.pi/fusion/<session-id>-<pid>/<run-id>/` manifests/prompts/events/stderr/responses/evaluation/merged/error artifacts plus `context-omission-ledger.json` and `budget-plan.json` plus global `fusion-models.json` |
23
+ | Runtime files/state | `.pi/tasks/<session-id>-<pid>/<task-id>.output`, `.pi/tasks/<session-id>-<pid>/<task-id>.json`; attested Pi opt-in adds `.pi-events.jsonl`, `.stderr`, `.pi-telemetry-wrapper.cjs`, `.attestation.json`; Fusion adds private `.pi/fusion/<session-id>-<pid>/<run-id>/` manifests/prompts/events/stderr/responses/evaluation/merged/error artifacts, plus manifest-bound `failure-summary.json` only for failed/cancelled stored runs, `context-omission-ledger.json`, `budget-plan.json`, and global `fusion-models.json` |
24
24
  | Maintainer docs/gateway | `BACKGROUND-TASKS-INSTRUCTIONS.md`, `docs/api/eventbus-v1.md`, `docs/concepts/context-projection-and-budgeting.md`, `docs/subsystems/child-launch-durability-and-safety.md`, `docs/reference/runtime-contracts.md`, `docs/operations/{troubleshooting,testing,releasing}.md`, `TESTING.md`, `TEST_PLAN.md`, `PUBLISHING.md` |
25
25
 
26
26
  ## Required gates
@@ -75,7 +75,7 @@ SDK/RPC/scripted-provider/package/compatibility coverage asserts exactly four to
75
75
  | Read task logs | `bg_logs` | yes | yes | | | | | yes | SDK verifies bounded content and the model-facing no-wait-loop contract. Scripted-provider ordinary waiting makes zero log calls. |
76
76
  | Stop task from LLM tool | `bg_kill` | | yes | | | | | | Covers running kill and already-finished loud failure. |
77
77
  | Fusion command background launch | `/fusion`, managed task, terminal notification, `bg_result` | yes | yes | yes | | yes | yes | | Core unit covers deterministic projection, child argv/stdin/metadata parsing, artifacts, pre-abort handling, and orchestration. SDK verifies `/fusion` returns after durable preflight, all five child invocations continue in the managed task, terminal notification is emitted without a parent rewrite, editor/cancel behavior remains correct, and malformed config launches zero children. RPC verifies command discovery, Unicode request preservation, background terminal delivery, no parent `agent_start`, editor protocol, malformed-config admission failure, child failure notification, and child isolation flags. |
78
- | Fusion v1 background result | `fusion_reason`, `fusion_investigate`, `fusion_research`, `fusion_validate`, `bg_result` | yes | yes | | | current-host stats/replay | yes | yes | Exactly four workflow tools remain registered; each returns a managed-task receipt after durable no-child preflight. `bg_result` verifies manifest-bound `result.json`/`merged.md`, never truncates, and attaches complete `Usage` exactly once. SDK pins non-blocking launch under delayed children, tool-signal ownership handoff, clean-context isolation, failure coordinates, shutdown cancellation, and repeated-retrieval no-double-counting. Scripted-provider coverage proves no polling between launch and terminal wake. |
78
+ | Fusion v1 background result | `fusion_reason`, `fusion_investigate`, `fusion_research`, `fusion_validate`, `bg_result` | yes | yes | | | current-host stats/replay | yes | yes | Exactly four workflow tools remain registered; each returns a managed-task receipt after durable no-child preflight. `bg_result` verifies manifest-bound `result.json`/`merged.md`, never truncates, and attaches complete `Usage` exactly once. Failed/cancelled retrieval returns `delivery:"none"` and a closed no-answer view backed only by verified, bounded `failure-summary.json` metadata/refs; it cannot claim usage or expose partial text. SDK pins non-blocking launch under delayed children, tool-signal ownership handoff, clean-context isolation, failure coordinates, shutdown cancellation, and repeated-retrieval no-double-counting. Scripted-provider coverage proves no polling between launch and terminal wake. |
79
79
  | Fusion Anthropic attribution and sanitization | package-owned `anthropic-attribution.ts`, `@ravshansbox/pi-anthropic-sps`, child `--extension` argv | yes | | | | | yes | | Unit proves a Claude route receives exactly three explicit extensions in fixed order: the package-owned Claude Code OAuth attribution provider, sanitizer, then runtime governor/metadata extension. The repo-local `spawn-anthropic-attribution` entrypoint re-exports the same implementation. Attribution tests pin linked session/account/device metadata, exact model beta policy, beta-resource transport, cache surfaces, one-hour pricing, and the 200K subscription contract; the governor therefore observes and validates the final attributed and sanitized payload. Every non-Anthropic provider still receives exactly one extension and resolves neither Claude dependency. Resolution failures remain loud, and package guards require the sanitizer dependency plus provider gate. |
80
80
  | Fusion validation workflow | structured `fusion_validate`, workflow profiles, `fusion-manifest.v4`, `fusion-result.v5` | yes | yes | | | | yes | | Public validation rejects legacy `{prompt}` with a migration error, enforces non-empty `scope`/`acceptanceCriteria`, and loudly validates `verification` cross-fields (`provided` ↔ evidence, `not_run` ↔ reason). Core validate orchestration is clean/read-only/advisory, enforces source-finding accounting including singleton, duplicate, exclusion, and merger add/drop cases, and remains no build/test substitute claim. |
81
81
  | Fusion context boundaries | canonical input `fusion-input.v5`, reason `context-omission-ledger.json`, clean-task inputs | yes | yes | yes | | | yes | | Unit covers reason/session projection for a >1 MB synthetic tool-heavy session, verbatim user/assistant text, thinking exclusion, zero tool-payload preview bytes, exact and stable omission counts/byte totals/hashes, compact tuple round-trip, receipt-to-ledger reconciliation, active-tool-call-leaf and sibling-call exclusion, and byte-identical repeated construction. Clean-task tests assert investigate/research/validate inputs omit parent system prompt, conversation projection, and omission ledger, stay byte-identical across unrelated parent sessions, and keep parent sentinels out of every clean downstream prompt and artifact. SDK/RPC verify clean validate stdin has no `conversation_projection` or transcript while `/fusion`/reason preserve the projected-conversation path. |
package/docs/INDEX.md CHANGED
@@ -135,10 +135,10 @@ Generated navigation for every package-local documentation page. This index inte
135
135
  | command | `kill` | `command:kill` | `src/extension.ts:649` |
136
136
  | command | `logs` | `command:logs` | `src/extension.ts:618` |
137
137
  | command | `tasks` | `command:tasks` | `src/extension.ts:543` |
138
- | tool | `bg_delegate` | `tool:bg_delegate` | `src/delegate-extension.ts:292` |
138
+ | tool | `bg_delegate` | `tool:bg_delegate` | `src/delegate-extension.ts:307` |
139
139
  | tool | `bg_kill` | `tool:bg_kill` | `src/extension.ts:908` |
140
140
  | tool | `bg_logs` | `tool:bg_logs` | `src/extension.ts:863` |
141
- | tool | `bg_result` | `tool:bg_result` | `src/delegate-extension.ts:453` |
141
+ | tool | `bg_result` | `tool:bg_result` | `src/delegate-extension.ts:468` |
142
142
  | tool | `bg_run` | `tool:bg_run` | `src/extension.ts:682` |
143
143
  | tool | `bg_run_pi_attested` | `tool:bg_run_pi_attested` | `src/extension.ts:767` |
144
144
  | tool | `bg_status` | `tool:bg_status` | `src/extension.ts:832` |
@@ -87,7 +87,7 @@
87
87
  "state": "stale-authored-prose"
88
88
  },
89
89
  {
90
- "authored_body_sha256": "sha256:b9cc1885689abbe978fd4fd5306e11e4526324da10ef0d42cb6c90841ea61925",
90
+ "authored_body_sha256": "sha256:e7cbe5b10fd56a28e6d23511db5500ee05b000be507fd50ac035d9757e0e2083",
91
91
  "covers_sources": [
92
92
  "extensions/fusion-child.ts",
93
93
  "src/core/fusion/anthropic-attribution.ts",
@@ -791,7 +791,7 @@
791
791
  "image": "https://raw.githubusercontent.com/ismailsaleekh/pi-background-tasks/main/logo.png",
792
792
  "name": "pi-background-tasks",
793
793
  "type": "module",
794
- "version": "2.1.3"
794
+ "version": "2.1.4"
795
795
  },
796
796
  "public_surface_ids": [
797
797
  "command:bg",
@@ -1028,7 +1028,7 @@
1028
1028
  ],
1029
1029
  "type": "object"
1030
1030
  },
1031
- "source": "src/delegate-extension.ts:292"
1031
+ "source": "src/delegate-extension.ts:307"
1032
1032
  },
1033
1033
  {
1034
1034
  "description": "Stop a running background task by ID. Fails loudly if the task is unknown or already finished.",
@@ -1116,7 +1116,7 @@
1116
1116
  ],
1117
1117
  "type": "object"
1118
1118
  },
1119
- "source": "src/delegate-extension.ts:453"
1119
+ "source": "src/delegate-extension.ts:468"
1120
1120
  },
1121
1121
  {
1122
1122
  "description": "Start a named long-running shell command in the background and return immediately with a task ID and output path. By default, completed, failed, or killed terminal state is delivered automatically as <background-task-notification> and starts a follow-up agent turn; do not sleep or poll merely to wait. Output is written to .pi/tasks and model-visible logs are bounded to 50.0KB.",
@@ -83,27 +83,27 @@ This generated registry lists production environment-variable references, runtim
83
83
  | delegate-artifact | `seed.json` | `src/core/delegate/artifacts.ts:40` |
84
84
  | delegate-artifact | `spill/<receipt-named-file>` | `src/core/delegate/artifacts.ts:53` |
85
85
  | directory | `.pi/delegate/<session-id>-<pid>/<task-id>/` | `src/core/delegate/artifacts.ts:157` |
86
- | directory | `.pi/fusion/<session-id>-<pid>/<run-id>/` | `src/core/fusion/artifacts.ts:353` |
86
+ | directory | `.pi/fusion/<session-id>-<pid>/<run-id>/` | `src/core/fusion/artifacts.ts:563` |
87
87
  | directory | `.pi/tasks/<session-id>-<pid>/` | `src/core/registry.ts:783` |
88
- | fusion-artifact | `<attempt-prefix> = candidate-<slot>.attempt-<n> \| evaluation.attempt-<n> \| merge.attempt-<n>` | `src/core/fusion/artifacts.ts:242` |
89
- | fusion-artifact | `<attempt-prefix>.calibration-violation.json` | `src/core/fusion/artifacts.ts:257` |
90
- | fusion-artifact | `<attempt-prefix>.events.jsonl` | `src/core/fusion/artifacts.ts:541` |
91
- | fusion-artifact | `<attempt-prefix>.prompt.txt` | `src/core/fusion/artifacts.ts:540` |
92
- | fusion-artifact | `<attempt-prefix>.stderr.txt` | `src/core/fusion/artifacts.ts:542` |
93
- | fusion-artifact | `blind-candidates.json` | `src/core/fusion/artifacts.ts:498` |
94
- | fusion-artifact | `budget-plan.json` | `src/core/fusion/artifacts.ts:494` |
95
- | fusion-artifact | `candidate-<slot>.attempt-<n>.response.md \| candidate-<slot>.attempt-<n>.response.partial.md` | `src/core/fusion/artifacts.ts:252` |
96
- | fusion-artifact | `candidate-<slot>.attempt-<n>.tool-calls.jsonl` | `src/core/fusion/artifacts.ts:413` |
88
+ | fusion-artifact | `<attempt-prefix> = candidate-<slot>.attempt-<n> \| evaluation.attempt-<n> \| merge.attempt-<n>` | `src/core/fusion/artifacts.ts:248` |
89
+ | fusion-artifact | `<attempt-prefix>.calibration-violation.json` | `src/core/fusion/artifacts.ts:263` |
90
+ | fusion-artifact | `<attempt-prefix>.events.jsonl` | `src/core/fusion/artifacts.ts:804` |
91
+ | fusion-artifact | `<attempt-prefix>.prompt.txt` | `src/core/fusion/artifacts.ts:803` |
92
+ | fusion-artifact | `<attempt-prefix>.stderr.txt` | `src/core/fusion/artifacts.ts:805` |
93
+ | fusion-artifact | `blind-candidates.json` | `src/core/fusion/artifacts.ts:708` |
94
+ | fusion-artifact | `budget-plan.json` | `src/core/fusion/artifacts.ts:704` |
95
+ | fusion-artifact | `candidate-<slot>.attempt-<n>.response.md \| candidate-<slot>.attempt-<n>.response.partial.md` | `src/core/fusion/artifacts.ts:258` |
96
+ | fusion-artifact | `candidate-<slot>.attempt-<n>.tool-calls.jsonl` | `src/core/fusion/artifacts.ts:623` |
97
97
  | fusion-artifact | `candidate-<slot>.attempt-<n>.tool-calls.jsonl.seal.json` | `src/core/fusion/child-protocol.ts:22` |
98
- | fusion-artifact | `canonical-input.json` | `src/core/fusion/artifacts.ts:464` |
99
- | fusion-artifact | `context-omission-ledger.json` | `src/core/fusion/artifacts.ts:473` |
100
- | fusion-artifact | `error.json` | `src/core/fusion/artifacts.ts:523` |
101
- | fusion-artifact | `evaluation.attempt-<n>.response.txt \| evaluation.attempt-<n>.response.partial.txt` | `src/core/fusion/artifacts.ts:252` |
102
- | fusion-artifact | `evaluation.json` | `src/core/fusion/artifacts.ts:502` |
103
- | fusion-artifact | `merge.attempt-<n>.response.md \| merge.attempt-<n>.response.partial.md` | `src/core/fusion/artifacts.ts:252` |
104
- | fusion-artifact | `merged.md` | `src/core/fusion/artifacts.ts:436` |
105
- | fusion-artifact | `result.json` | `src/core/fusion/artifacts.ts:437` |
106
- | fusion-artifact | `source-policy.private.json` | `src/core/fusion/artifacts.ts:480` |
98
+ | fusion-artifact | `canonical-input.json` | `src/core/fusion/artifacts.ts:674` |
99
+ | fusion-artifact | `context-omission-ledger.json` | `src/core/fusion/artifacts.ts:683` |
100
+ | fusion-artifact | `error.json` | `src/core/fusion/artifacts.ts:733` |
101
+ | fusion-artifact | `evaluation.attempt-<n>.response.txt \| evaluation.attempt-<n>.response.partial.txt` | `src/core/fusion/artifacts.ts:258` |
102
+ | fusion-artifact | `evaluation.json` | `src/core/fusion/artifacts.ts:383` |
103
+ | fusion-artifact | `merge.attempt-<n>.response.md \| merge.attempt-<n>.response.partial.md` | `src/core/fusion/artifacts.ts:258` |
104
+ | fusion-artifact | `merged.md` | `src/core/fusion/artifacts.ts:387` |
105
+ | fusion-artifact | `result.json` | `src/core/fusion/artifacts.ts:647` |
106
+ | fusion-artifact | `source-policy.private.json` | `src/core/fusion/artifacts.ts:690` |
107
107
  | task-file | `.pi/tasks/<session-id>-<pid>/<task-id>.attestation.json` | `src/core/attested-pi-run.ts:583` |
108
108
  | task-file | `.pi/tasks/<session-id>-<pid>/<task-id>.json` | `src/core/registry.ts:811` |
109
109
  | task-file | `.pi/tasks/<session-id>-<pid>/<task-id>.output` | `src/core/registry.ts:810` |
@@ -119,12 +119,12 @@ This generated registry lists production environment-variable references, runtim
119
119
  | `pi-background-tasks.delegate-budget-plan.v2` | `src/core/delegate/types.ts:21` |
120
120
  | `pi-background-tasks.delegate-child-terminal.v1` | `src/delegate-child-extension.ts:380` |
121
121
  | `pi-background-tasks.delegate-hook-contract.v1` | `src/core/delegate/hook-contract.ts:15` |
122
- | `pi-background-tasks.delegate-launch.v1` | `src/delegate-extension.ts:398` |
122
+ | `pi-background-tasks.delegate-launch.v1` | `src/delegate-extension.ts:413` |
123
123
  | `pi-background-tasks.delegate-ledger.v1` | `src/core/delegate/types.ts:16` |
124
124
  | `pi-background-tasks.delegate-manifest.v1` | `src/core/delegate/types.ts:22` |
125
125
  | `pi-background-tasks.delegate-outcome.v1` | `src/core/delegate/runner.ts:200` |
126
126
  | `pi-background-tasks.delegate-receipt.v1` | `src/core/delegate/types.ts:19` |
127
- | `pi-background-tasks.delegate-result-view.v1` | `src/delegate-extension.ts:629` |
127
+ | `pi-background-tasks.delegate-result-view.v1` | `src/delegate-extension.ts:683` |
128
128
  | `pi-background-tasks.delegate-result.v1` | `src/core/delegate/types.ts:18` |
129
129
  | `pi-background-tasks.delegate-seed.v1` | `src/core/delegate/types.ts:15` |
130
130
  | `pi-background-tasks.extension-request.v1` | `src/core/extension-api.ts:15` |
@@ -140,6 +140,7 @@ This generated registry lists production environment-variable references, runtim
140
140
  | `pi-background-tasks.fusion-context-ledger.v2` | `src/core/fusion/types.ts:25` |
141
141
  | `pi-background-tasks.fusion-evaluation-repair-input.v1` | `src/core/fusion/prompts.ts:289` |
142
142
  | `pi-background-tasks.fusion-evaluation.v1` | `src/core/fusion/types.ts:16` |
143
+ | `pi-background-tasks.fusion-failure-summary.v1` | `src/core/fusion/types.ts:34` |
143
144
  | `pi-background-tasks.fusion-input.v4` | `src/core/fusion/types.ts:14` |
144
145
  | `pi-background-tasks.fusion-input.v5` | `src/core/fusion/types.ts:15` |
145
146
  | `pi-background-tasks.fusion-launch.v1` | `src/fusion-extension.ts:1125` |
@@ -148,7 +149,7 @@ This generated registry lists production environment-variable references, runtim
148
149
  | `pi-background-tasks.fusion-merge-input.v1` | `src/core/fusion/prompts.ts:336` |
149
150
  | `pi-background-tasks.fusion-models.v1` | `src/core/fusion/types.ts:13` |
150
151
  | `pi-background-tasks.fusion-progress.v1` | `src/fusion-extension.ts:59` |
151
- | `pi-background-tasks.fusion-result-view.v1` | `src/delegate-extension.ts:669` |
152
+ | `pi-background-tasks.fusion-result-view.v1` | `src/delegate-extension.ts:723` |
152
153
  | `pi-background-tasks.fusion-result.v4` | `src/core/fusion/types.ts:19` |
153
154
  | `pi-background-tasks.fusion-result.v5` | `src/core/fusion/types.ts:20` |
154
155
  | `pi-background-tasks.fusion-runtime-guard.v2` | `src/core/fusion/child-protocol.ts:24` |
@@ -12,12 +12,12 @@ covers_sources: []
12
12
  This authored section defines the boundary: documentation facts are extracted from package metadata and TypeScript ASTs, then generated into docs and the manifest. Unsupported syntax fails the gate rather than falling back to regex or stale hand-maintained inventories. Public registrations must remain unconditional top-level direct calls or use the one validated local tool-wrapper shape; host/method aliases, computed access, nested or conditional registration, wrapper chaining/passing, constructor helpers, ambiguous public metadata, destructured Pi parameters, and repeated imported registrars are rejected.
13
13
 
14
14
  <!-- pi-docs:begin name="docs-freshness-gate" generator="scripts/docs/generate.mjs" -->
15
- - Canonical package version: `2.1.3`
15
+ - Canonical package version: `2.1.4`
16
16
  - Governed markdown docs: 40
17
17
  - Public surfaces extracted: 30
18
18
  - Governed production sources: 48
19
19
  - Tool contracts extracted: 11
20
- - Schema IDs extracted: 43
20
+ - Schema IDs extracted: 44
21
21
  - Environment variable references extracted: 49
22
22
  - Behavioral attestation receipts not passing: 4
23
23
  - Receipt store: `docs/attestations.json`
@@ -110,6 +110,10 @@ When a candidate's first complete `stop` response exceeds 48 KiB, the private ch
110
110
 
111
111
  Run artifacts are private local evidence under `.pi/fusion/<session-id>-<pid>/<run-id>/`. They include `manifest.json`, `canonical-input.json`, `budget-plan.json`, per-attempt prompts/events/stderr/responses, optional partial responses for failed attempts, optional tool-call logs/seals, `blind-candidates.json`, `evaluation.json`, `merged.md`, manifest-bound `result.json`, `error.json`, and workflow-specific context/source-policy artifacts. `bg_result` verifies manifest state, fixed artifact references, byte lengths, SHA-256 values, UTF-8, run/workflow identity, and result details before returning merged bytes.
112
112
 
113
+ Failed and cancelled stored runs additionally write the canonical `pi-background-tasks.fusion-failure-summary.v1` `failure-summary.json` after `error.json` and the terminal manifest transition. The manifest binds that summary's exact basename, byte length, and SHA-256; the summary never hashes `manifest.json`. It is evidence metadata, not an answer: it contains a closed no-answer assertion, bounded terminal-error metadata, durable progress and usage, capped attempt metadata, manifest-bound evidence refs, classifications, remediation identifiers, and explicit omission counts. It contains no candidate/evaluator/merger response text, partial response text, tool-result payload, or fetched content. Completion never writes this artifact.
114
+
115
+ Summary persistence is subordinate: Fusion writes terminal usage, error evidence, and the failed/cancelled manifest first, then attempts the summary exactly once from a fresh terminal manifest snapshot. A summary-write failure does not retry or suppress terminal publication; the failure channel records one bounded summary-unavailable note. A pre-store refusal has no summary, and an after-store/pre-registration refusal may leave its run directory without inventing a task index.
116
+
113
117
  Artifact writes use durable private temp-file/fsync/rename. Manifests enforce legal state transitions and record config, resolved models, fixed capabilities, context policy, tool policy, anonymous map, attempts, artifact refs, cumulative usage, and errors. Successful, failed, and cancelled observed attempts preserve complete Pi usage/cost components, including optional `cacheWrite1h` and `reasoning` subsets; same-session compression includes both provider turns in that one attempt's aggregate; public tool results clone the same `Usage` shape without counting either subset as additional tokens. Terminal failures enrich their stage-local cause from the durable manifest after usage persistence: candidate/evaluator/merger progress reports completed, failed, cancelled, and not-started child facts plus exact usage so far. A late evaluator or merger budget refusal never claims that no child anywhere in the run was created.
114
118
 
115
119
  For tool-enabled children, the private audit journal remains open across every low-level `agent_end`, because Pi may still retry, compact and retry, or process a queued continuation. Only terminal `agent_settled` can exclusively publish the complete hash/count/byte seal. Runtime-guard refusal latches process failure, makes that seal incomplete, and forces the result settlement to failed. The child emits one closed `pi-background-tasks.fusion-runtime-guard.v2` stderr frame for malformed provider payloads, malformed Claude cache policy, provider-request loops, or tool-call loops. The frame contains the refusal code, route, request/tool ordinals, bounded payload byte/hash evidence where applicable, and a bounded message; it never emits the payload itself. The parent validates this frame and reports typed `child_runtime_limit_exceeded`, `child_runtime_payload_invalid`, or `child_cache_policy_invalid` instead of accepting a later clean-looking result or reducing it to an unexplained exit code. Tool activity after finalization, duplicate settlement, pre-settlement shutdown, extension diagnostics, malformed/duplicate runtime-guard frames, and missing/failed/stale seals are fatal. This lifecycle requires Pi 0.81.1 or newer; older Pi lines do not expose the required terminal event and are not claimed as compatible.
@@ -11,7 +11,7 @@ covers_sources: []
11
11
 
12
12
  <!-- pi-docs:begin name="tool-contract-bg_delegate" generator="scripts/docs/generate.mjs" -->
13
13
  - Label: **Background Delegate**
14
- - Source: `src/delegate-extension.ts:292`
14
+ - Source: `src/delegate-extension.ts:307`
15
15
  - Description: Launch one background Pi agent seeded with a frozen projection of the current conversation, then return a launch receipt immediately. The child has its own session, a route pinned at launch that is never substituted, and read-only tools. Retrieve its verified answer with bg_result.
16
16
  - Root schema: `object`; additionalProperties: `false`
17
17
 
@@ -11,7 +11,7 @@ covers_sources: []
11
11
 
12
12
  <!-- pi-docs:begin name="tool-contract-bg_result" generator="scripts/docs/generate.mjs" -->
13
13
  - Label: **Background Result**
14
- - Source: `src/delegate-extension.ts:453`
14
+ - Source: `src/delegate-extension.ts:468`
15
15
  - Description: Retrieve a hash-verified result from a bg_delegate or background Fusion task. Never blocks: a running task returns a typed not-ready result. Oversized answers are never truncated.
16
16
  - Root schema: `object`; additionalProperties: `false`
17
17
 
@@ -94,6 +94,12 @@ The returned text is decoded from the same aggregate buffer that was hashed. Cor
94
94
 
95
95
  A completed Fusion task is accepted only when `manifest.json` is terminal `completed`, its `result.json` and `merged.md` fixed references match, both files match manifest-bound byte lengths and SHA-256 values, run/workflow/artifact identity matches the task, result details carry the current schema, usage is complete, and merged bytes are well-formed UTF-8. The first successful retrieval attaches complete Fusion usage exactly once; later retrievals omit usage to prevent double-counting.
96
96
 
97
+ ## Fusion failed/cancelled terminal view
98
+
99
+ A failed or cancelled Fusion task returns successfully as an answer-free typed terminal view rather than exposing partial output or throwing a plain failure string. It always has `state:"failed" | "cancelled"`, `delivery:"none"`, and `answer:{present:false,reason:"run_did_not_commit"}`; a requested inline or artifact delivery cannot override this. It includes workflow/artifact location where known, bounded progress/failure/count metadata, and manifest-bound evidence references only. It never includes merged text, partial response text, answer bytes/hash, or delivered-answer usage, and it never claims Fusion usage for these views.
100
+
101
+ For current runs, `summary_status:"verified"` means `failure-summary.json` was manifest-bound and its exact bytes/hash, UTF-8, closed schema, identity/state, no-answer assertion, and surfaced evidence refs were checked. Referenced stage-output bodies are never read; refs are honestly manifest-bound rather than freshly rehashed. `legacy_manifest_only` describes a validated historical terminal manifest with no summary and never backfills it. `integrity_failed` exposes no summary-derived metadata; `unavailable` exposes no untrusted refs. Failure rendering is bounded to the diagnostics-scale 8 KiB budget by deterministically dropping whole optional rows with exact omission counts, never cutting strings.
102
+
97
103
  ## Inline/artifact delivery and no truncation
98
104
 
99
105
  `bg_result` never truncates an answer.
@@ -116,6 +122,7 @@ Common delegate retrieval outcomes:
116
122
  - `seed_hash_mismatch`, `answer_hash_mismatch`, `child_result_invalid`, `child_result_encoding_invalid` — integrity or encoding failure.
117
123
  - `artifact_read_failed`, `artifact_spill_failed`, `artifact_error` — artifact I/O failure.
118
124
  - `result_too_large_for_inline` — explicit inline request exceeded the inline cap.
125
+ - Fusion `summary_status:"integrity_failed"` — a terminal summary or its manifest binding failed verification; no summary metadata is trusted.
119
126
 
120
127
  Delegate errors include whether a child process was created, preserved artifact hints when known, and remediation text. Usage missing from the provider is reported as `unavailable`, not synthesized as zero. Fusion retrieval additionally fails on non-completed manifests, identity/schema drift, malformed usage/details, invalid UTF-8, or any manifest/result/merged hash or byte-length mismatch; failed/cancelled runs return their preserved terminal error rather than partial output.
121
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-background-tasks",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "Pi extension for durable background shell tasks, read-only delegated agents, local attested Pi runs, and fixed-purpose Fusion workflows through child Pi processes.",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -7,12 +7,18 @@ import { replaceFileDurable } from '../durable-fs.js';
7
7
  import {
8
8
  EMPTY_FUSION_USAGE,
9
9
  FUSION_COMMITTED_RESULT_SCHEMA_VERSION,
10
+ FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
10
11
  FUSION_MANIFEST_SCHEMA_VERSION,
11
12
  FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
12
13
  FusionError,
13
14
  cloneFusionUsage,
14
15
  type FusionArtifactManifest,
15
16
  type FusionArtifactRef,
17
+ type FusionFailureArtifactClassification,
18
+ type FusionFailureAttemptMetadata,
19
+ type FusionFailureEvidenceArtifact,
20
+ type FusionFailureSummaryV1,
21
+ type FusionRunProgress,
16
22
  type FusionAttemptArtifactRecord,
17
23
  type FusionBudgetPlanV1,
18
24
  type FusionCalibrationViolation,
@@ -318,6 +324,210 @@ function artifactRefSha256Hex(value: string): string {
318
324
  return hex;
319
325
  }
320
326
 
327
+ /** A manifest artifact reference is always one safe basename below its run directory. */
328
+ export function assertFusionArtifactBasename(name: string): string {
329
+ if (
330
+ name.length === 0 ||
331
+ name !== basename(name) ||
332
+ name.includes('/') ||
333
+ name.includes('\\') ||
334
+ name === '.' ||
335
+ name === '..' ||
336
+ Buffer.byteLength(name, 'utf8') > 255
337
+ ) {
338
+ throw errorForArtifact(`invalid fusion artifact name: ${name}`);
339
+ }
340
+ return name;
341
+ }
342
+
343
+ export const FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES = 1024;
344
+ export const FUSION_FAILURE_SUMMARY_ATTEMPT_CAP = 12;
345
+ export const FUSION_FAILURE_SUMMARY_EVIDENCE_CAP = 24;
346
+ export const FUSION_FAILURE_SUMMARY_MAX_BYTES = 32 * 1024;
347
+
348
+ function compareArtifactText(left: string, right: string): number {
349
+ return left < right ? -1 : left > right ? 1 : 0;
350
+ }
351
+
352
+ interface FusionProgressManifest {
353
+ state: FusionState;
354
+ artifacts: Readonly<Record<string, FusionArtifactRef>>;
355
+ attempts: readonly {
356
+ stage: FusionStage;
357
+ slot?: 1 | 2 | 3 | undefined;
358
+ status: 'completed' | 'failed' | 'cancelled';
359
+ child_created: boolean;
360
+ }[];
361
+ usage: FusionUsage;
362
+ }
363
+
364
+ function failureStageProgress(
365
+ manifest: FusionProgressManifest,
366
+ stage: FusionStage,
367
+ ): FusionRunProgress['candidates'] {
368
+ const attempts = manifest.attempts.filter((attempt) => attempt.stage === stage);
369
+ const created = attempts.filter((attempt) => attempt.child_created).length;
370
+ const completed = attempts.filter(
371
+ (attempt) => attempt.child_created && attempt.status === 'completed',
372
+ ).length;
373
+ const failed = attempts.filter(
374
+ (attempt) => attempt.child_created && attempt.status === 'failed',
375
+ ).length;
376
+ const cancelled = attempts.filter(
377
+ (attempt) => attempt.child_created && attempt.status === 'cancelled',
378
+ ).length;
379
+ const completedByState =
380
+ stage === 'candidate'
381
+ ? completed >= 3
382
+ : stage === 'evaluation'
383
+ ? manifest.artifacts['evaluation.json'] !== undefined ||
384
+ manifest.state === 'evaluation_complete' ||
385
+ manifest.state === 'merging' ||
386
+ manifest.state === 'completed'
387
+ : manifest.artifacts['merged.md'] !== undefined || manifest.state === 'completed';
388
+ const progress: FusionRunProgress['candidates'] = {
389
+ status: completedByState ? 'completed' : created === 0 ? 'not_started' : 'incomplete',
390
+ attempts_recorded: attempts.length,
391
+ children_created: created,
392
+ children_completed: completed,
393
+ children_failed: failed,
394
+ children_cancelled: cancelled,
395
+ };
396
+ if (stage === 'candidate') {
397
+ const createdSlots = new Set(
398
+ attempts.flatMap((attempt) =>
399
+ attempt.child_created && attempt.slot !== undefined ? [attempt.slot] : [],
400
+ ),
401
+ );
402
+ progress.not_started_slots = 3 - createdSlots.size;
403
+ }
404
+ return progress;
405
+ }
406
+
407
+ /** Derive terminal progress solely from the durable manifest. */
408
+ export function buildFusionRunProgress(manifest: FusionProgressManifest): FusionRunProgress {
409
+ return {
410
+ manifest_state: manifest.state,
411
+ candidates: failureStageProgress(manifest, 'candidate'),
412
+ evaluation: failureStageProgress(manifest, 'evaluation'),
413
+ merge: failureStageProgress(manifest, 'merge'),
414
+ usage_so_far: cloneFusionUsage(manifest.usage),
415
+ };
416
+ }
417
+
418
+ function terminalMessageMetadata(message: string): FusionFailureSummaryV1['failure']['message'] {
419
+ const bytes = Buffer.from(message, 'utf8');
420
+ return {
421
+ byte_length: bytes.length,
422
+ sha256: sha256Buffer(bytes),
423
+ ...(bytes.length <= FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES
424
+ ? { inline_message: message }
425
+ : { omission_reason: 'exceeds_inline_message_bytes_cap' as const }),
426
+ };
427
+ }
428
+
429
+ function failureArtifactClassification(
430
+ name: string,
431
+ ref: FusionArtifactRef,
432
+ manifest: FusionArtifactManifest,
433
+ ): FusionFailureArtifactClassification {
434
+ for (const attempt of manifest.attempts) {
435
+ if (attempt.response_path === name) {
436
+ return ref.byte_length === 0 && attempt.status !== 'completed'
437
+ ? 'empty_rejected_output'
438
+ : 'complete_stage_output';
439
+ }
440
+ if (attempt.partial_response_path === name) return 'partial_stage_output';
441
+ if (attempt.output_recovery?.original_response_path === name) return 'oversized_original';
442
+ }
443
+ return 'evidence_only';
444
+ }
445
+
446
+ function failureAttemptMetadata(manifest: FusionArtifactManifest): readonly FusionFailureAttemptMetadata[] {
447
+ return manifest.attempts
448
+ .map((attempt) => ({
449
+ stage: attempt.stage,
450
+ ...(attempt.slot === undefined ? {} : { slot: attempt.slot }),
451
+ attempt: attempt.attempt,
452
+ status: attempt.status,
453
+ child_created: attempt.child_created,
454
+ }))
455
+ .sort((left, right) =>
456
+ compareArtifactText(left.stage, right.stage) ||
457
+ (left.slot ?? 0) - (right.slot ?? 0) ||
458
+ left.attempt - right.attempt,
459
+ );
460
+ }
461
+
462
+ export function buildFusionFailureSummary(input: {
463
+ manifest: FusionArtifactManifest;
464
+ terminalError: FusionError;
465
+ progress: FusionRunProgress;
466
+ terminalState: Exclude<FusionTerminalState, 'completed'>;
467
+ createdAt: string;
468
+ }): FusionFailureSummaryV1 {
469
+ if (
470
+ (input.terminalState !== 'failed' && input.terminalState !== 'cancelled') ||
471
+ input.manifest.state !== input.terminalState
472
+ ) {
473
+ throw errorForArtifact('failure summary requires a matching failed/cancelled terminal manifest');
474
+ }
475
+ if (input.manifest.error !== input.terminalError.message) {
476
+ throw errorForArtifact('failure summary terminal error does not match the durable manifest');
477
+ }
478
+ if (canonicalJson(input.progress) !== canonicalJson(buildFusionRunProgress(input.manifest))) {
479
+ throw errorForArtifact('failure summary progress does not match the durable terminal manifest');
480
+ }
481
+ if (input.manifest.artifacts['failure-summary.json'] !== undefined) {
482
+ throw errorForArtifact('failure summary already exists in the terminal manifest');
483
+ }
484
+ const attempts = failureAttemptMetadata(input.manifest);
485
+ const evidence: FusionFailureEvidenceArtifact[] = Object.entries(input.manifest.artifacts)
486
+ .map(([name, ref]) => ({
487
+ name: assertFusionArtifactBasename(name),
488
+ classification: failureArtifactClassification(name, ref, input.manifest),
489
+ ref: { ...ref },
490
+ }))
491
+ .sort((left, right) => compareArtifactText(left.name, right.name));
492
+ return {
493
+ schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
494
+ run_id: input.manifest.run_id,
495
+ workflow: input.manifest.workflow,
496
+ source: input.manifest.source,
497
+ terminal_state: input.terminalState,
498
+ created_at: input.createdAt,
499
+ answer: { present: false, reason: 'run_did_not_commit' },
500
+ failure: {
501
+ code: input.terminalError.code,
502
+ ...(input.terminalError.stage === undefined ? {} : { stage: input.terminalError.stage }),
503
+ ...(input.terminalError.slot === undefined ? {} : { slot: input.terminalError.slot }),
504
+ ...(input.terminalError.attempt === undefined
505
+ ? {}
506
+ : { attempt: input.terminalError.attempt }),
507
+ child_created: input.terminalError.childCreated,
508
+ message: terminalMessageMetadata(input.terminalError.message),
509
+ },
510
+ progress: input.progress,
511
+ usage_so_far: cloneFusionUsage(input.manifest.usage),
512
+ attempts: {
513
+ listed: attempts.filter((_attempt, index) => index < FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
514
+ omitted_count:
515
+ attempts.length - Math.min(attempts.length, FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
516
+ },
517
+ evidence_artifacts: {
518
+ listed: evidence.filter((_artifact, index) => index < FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
519
+ omitted_count:
520
+ evidence.length - Math.min(evidence.length, FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
521
+ },
522
+ remediation_ids: [
523
+ 'inspect_manifest_bound_evidence',
524
+ 'inspect_terminal_error',
525
+ 'split_or_reduce_work',
526
+ 'retry_same_route_after_operator_review',
527
+ ],
528
+ };
529
+ }
530
+
321
531
  export class FusionArtifactStore {
322
532
  private readonly runDirAbs: string;
323
533
  private readonly runDirDisplay: string;
@@ -534,6 +744,59 @@ export class FusionArtifactStore {
534
744
  });
535
745
  }
536
746
 
747
+ /**
748
+ * Writes the terminal evidence summary exactly once after writeError has made
749
+ * the manifest terminal. The summary deliberately contains refs only, never
750
+ * stage-output bodies.
751
+ */
752
+ async writeFailureSummary(summary: FusionFailureSummaryV1): Promise<FusionArtifactRef> {
753
+ if (
754
+ this.manifest.state !== 'failed' &&
755
+ this.manifest.state !== 'cancelled'
756
+ ) {
757
+ throw errorForArtifact('failure summary requires a failed/cancelled terminal manifest');
758
+ }
759
+ if (summary.terminal_state !== this.manifest.state) {
760
+ throw errorForArtifact('failure summary terminal state does not match the manifest');
761
+ }
762
+ if (
763
+ summary.run_id !== this.manifest.run_id ||
764
+ summary.workflow !== this.manifest.workflow ||
765
+ summary.source !== this.manifest.source
766
+ ) {
767
+ throw errorForArtifact('failure summary identity does not match the terminal manifest');
768
+ }
769
+ if (
770
+ summary.answer?.present !== false ||
771
+ summary.answer.reason !== 'run_did_not_commit'
772
+ ) {
773
+ throw errorForArtifact('failure summary must assert that no answer was committed');
774
+ }
775
+ if (this.manifest.error === undefined || this.manifest.artifacts['error.json'] === undefined) {
776
+ throw errorForArtifact('failure summary requires durable terminal error evidence');
777
+ }
778
+ if (
779
+ canonicalJson(summary.failure.message) !==
780
+ canonicalJson(terminalMessageMetadata(this.manifest.error))
781
+ ) {
782
+ throw errorForArtifact('failure summary terminal error metadata does not match the manifest');
783
+ }
784
+ if (canonicalJson(summary.progress) !== canonicalJson(buildFusionRunProgress(this.snapshot()))) {
785
+ throw errorForArtifact('failure summary progress does not match the terminal manifest');
786
+ }
787
+ if (canonicalJson(summary.usage_so_far) !== canonicalJson(this.manifest.usage)) {
788
+ throw errorForArtifact('failure summary usage does not match the terminal manifest');
789
+ }
790
+ if (this.manifest.artifacts['failure-summary.json'] !== undefined) {
791
+ throw errorForArtifact('failure summary is already bound in the manifest');
792
+ }
793
+ const bytes = Buffer.from(`${canonicalJson(summary)}\n`, 'utf8');
794
+ if (bytes.length > FUSION_FAILURE_SUMMARY_MAX_BYTES) {
795
+ throw errorForArtifact('failure summary exceeds its bounded diagnostics artifact limit');
796
+ }
797
+ return this.writeArtifact('failure-summary.json', bytes);
798
+ }
799
+
537
800
  async recordChildAttempt(input: RecordFusionChildAttemptInput): Promise<void> {
538
801
  const prefix = attemptPrefix(input.result.stage, input.result.slot, input.result.attempt);
539
802
  await this.writeArtifact(`${prefix}.system-prompt.txt`, input.systemPrompt);
@@ -664,6 +927,7 @@ export class FusionArtifactStore {
664
927
  });
665
928
  }
666
929
 
930
+ /** Writes a durable artifact then binds its exact bytes in the manifest. */
667
931
  private async writeArtifact(name: string, data: Buffer | string): Promise<FusionArtifactRef> {
668
932
  const absPath = this.artifactPath(name);
669
933
  const ref = await writePrivateFile(absPath, data);
@@ -674,9 +938,7 @@ export class FusionArtifactStore {
674
938
  }
675
939
 
676
940
  private artifactPath(name: string): string {
677
- if (name.length === 0 || name.includes('/') || name.includes('\\')) {
678
- throw errorForArtifact(`invalid fusion artifact name: ${name}`);
679
- }
941
+ assertFusionArtifactBasename(name);
680
942
  const absPath = join(this.runDirAbs, name);
681
943
  if (!pathInside(this.runDirAbs, absPath)) {
682
944
  throw errorForArtifact(`fusion artifact path escapes run directory: ${name}`);