pi-background-tasks 1.0.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +7 -7
  2. package/TESTING.md +3 -3
  3. package/TEST_PLAN.md +2 -2
  4. package/docs/INDEX.md +25 -25
  5. package/docs/choose-a-workflow.md +4 -4
  6. package/docs/commands/bg-clear.md +1 -1
  7. package/docs/commands/bg-update.md +1 -1
  8. package/docs/commands/bg.md +1 -1
  9. package/docs/commands/fusion-models.md +1 -1
  10. package/docs/commands/fusion.md +5 -8
  11. package/docs/commands/jobs.md +1 -1
  12. package/docs/commands/kill.md +1 -1
  13. package/docs/commands/logs.md +1 -1
  14. package/docs/commands/task-manager.md +2 -2
  15. package/docs/concepts/completion-delivery.md +1 -0
  16. package/docs/getting-started.md +1 -1
  17. package/docs/manifest.json +59 -50
  18. package/docs/read-before-edit.md +1 -0
  19. package/docs/reference/runtime-contracts.md +55 -51
  20. package/docs/reference/shortcuts-and-dock.md +2 -2
  21. package/docs/subsystems/background-task-runtime.md +7 -1
  22. package/docs/subsystems/docs-freshness-gate.md +4 -4
  23. package/docs/subsystems/fusion.md +15 -11
  24. package/docs/subsystems/host-ui-and-telemetry.md +1 -1
  25. package/docs/tools/bg_delegate.md +1 -1
  26. package/docs/tools/bg_kill.md +1 -1
  27. package/docs/tools/bg_logs.md +1 -1
  28. package/docs/tools/bg_result.md +14 -10
  29. package/docs/tools/bg_run.md +1 -1
  30. package/docs/tools/bg_run_pi_attested.md +1 -1
  31. package/docs/tools/bg_status.md +1 -1
  32. package/docs/tools/fusion_investigate.md +6 -4
  33. package/docs/tools/fusion_reason.md +5 -5
  34. package/docs/tools/fusion_research.md +6 -2
  35. package/docs/tools/fusion_validate.md +5 -3
  36. package/package.json +1 -1
  37. package/src/core/common.ts +50 -2
  38. package/src/core/fusion/artifacts.ts +106 -13
  39. package/src/core/fusion/budget.ts +12 -4
  40. package/src/core/fusion/evaluation.ts +61 -0
  41. package/src/core/fusion/orchestrator.ts +270 -73
  42. package/src/core/fusion/pi-child.ts +6 -0
  43. package/src/core/fusion/prompts.ts +1 -0
  44. package/src/core/fusion/result-package.ts +385 -0
  45. package/src/core/fusion/types.ts +19 -1
  46. package/src/core/registry.ts +187 -20
  47. package/src/delegate-extension.ts +130 -24
  48. package/src/extension.ts +17 -6
  49. package/src/fusion-extension.ts +308 -154
@@ -0,0 +1,385 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { sha256Buffer } from '../attested-pi-run.js';
4
+ import { parseJsonText, type JsonObject } from '../common.js';
5
+ import {
6
+ FUSION_COMMITTED_RESULT_SCHEMA_VERSION,
7
+ FUSION_MANIFEST_SCHEMA_VERSION,
8
+ FUSION_RESULT_SCHEMA_VERSION,
9
+ FusionError,
10
+ type FusionArtifactRef,
11
+ type FusionResultDetails,
12
+ type FusionRunResult,
13
+ type FusionUsage,
14
+ type FusionWorkflowId,
15
+ } from './types.js';
16
+
17
+ const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
18
+
19
+ function isRecord(value: unknown): value is JsonObject {
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ function fail(message: string, artifactDir: string): never {
24
+ throw new FusionError(`fusion committed result invalid: ${message}`, {
25
+ code: 'artifact_error',
26
+ childCreated: true,
27
+ artifactDir,
28
+ });
29
+ }
30
+
31
+ function assertOnlyKeys(
32
+ value: JsonObject,
33
+ allowed: readonly string[],
34
+ label: string,
35
+ artifactDir: string,
36
+ ): void {
37
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
38
+ if (unexpected.length > 0)
39
+ fail(`${label} contains unexpected keys: ${unexpected.join(', ')}`, artifactDir);
40
+ }
41
+
42
+ function artifactRef(value: unknown, label: string, artifactDir: string): FusionArtifactRef {
43
+ if (!isRecord(value)) fail(`${label} must be an object`, artifactDir);
44
+ assertOnlyKeys(value, ['path', 'byte_length', 'sha256'], label, artifactDir);
45
+ const path = value['path'];
46
+ const byteLength = value['byte_length'];
47
+ const sha256 = value['sha256'];
48
+ if (typeof path !== 'string' || path.length === 0 || path.includes('/') || path.includes('\\')) {
49
+ fail(`${label}.path is invalid`, artifactDir);
50
+ }
51
+ if (!Number.isSafeInteger(byteLength) || Number(byteLength) < 0) {
52
+ fail(`${label}.byte_length is invalid`, artifactDir);
53
+ }
54
+ if (typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256)) {
55
+ fail(`${label}.sha256 is invalid`, artifactDir);
56
+ }
57
+ return { path, byte_length: Number(byteLength), sha256 };
58
+ }
59
+
60
+ function usage(value: unknown, artifactDir: string): FusionUsage {
61
+ if (!isRecord(value)) fail('details.usage must be an object', artifactDir);
62
+ assertOnlyKeys(
63
+ value,
64
+ ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens', 'cost'],
65
+ 'details.usage',
66
+ artifactDir,
67
+ );
68
+ const cost = value['cost'];
69
+ if (!isRecord(cost)) fail('details.usage.cost must be an object', artifactDir);
70
+ assertOnlyKeys(
71
+ cost,
72
+ ['input', 'output', 'cacheRead', 'cacheWrite', 'total'],
73
+ 'details.usage.cost',
74
+ artifactDir,
75
+ );
76
+ const finiteNonnegative = (entry: unknown, label: string): number => {
77
+ if (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0)
78
+ fail(`${label} is invalid`, artifactDir);
79
+ return entry;
80
+ };
81
+ return {
82
+ input: finiteNonnegative(value['input'], 'details.usage.input'),
83
+ output: finiteNonnegative(value['output'], 'details.usage.output'),
84
+ cacheRead: finiteNonnegative(value['cacheRead'], 'details.usage.cacheRead'),
85
+ cacheWrite: finiteNonnegative(value['cacheWrite'], 'details.usage.cacheWrite'),
86
+ totalTokens: finiteNonnegative(value['totalTokens'], 'details.usage.totalTokens'),
87
+ cost: {
88
+ input: finiteNonnegative(cost['input'], 'details.usage.cost.input'),
89
+ output: finiteNonnegative(cost['output'], 'details.usage.cost.output'),
90
+ cacheRead: finiteNonnegative(cost['cacheRead'], 'details.usage.cost.cacheRead'),
91
+ cacheWrite: finiteNonnegative(cost['cacheWrite'], 'details.usage.cost.cacheWrite'),
92
+ total: finiteNonnegative(cost['total'], 'details.usage.cost.total'),
93
+ },
94
+ };
95
+ }
96
+
97
+ function resultDetails(
98
+ value: unknown,
99
+ expected: { runId: string; workflow: FusionWorkflowId; artifactDir: string },
100
+ ): FusionResultDetails {
101
+ if (!isRecord(value)) fail('details must be an object', expected.artifactDir);
102
+ assertOnlyKeys(
103
+ value,
104
+ [
105
+ 'schema_version',
106
+ 'run_id',
107
+ 'workflow',
108
+ 'source',
109
+ 'status',
110
+ 'context',
111
+ 'tool_policy',
112
+ 'artifact_dir',
113
+ 'models',
114
+ 'evaluator_attempts',
115
+ 'usage',
116
+ 'budget',
117
+ ],
118
+ 'details',
119
+ expected.artifactDir,
120
+ );
121
+ if (value['schema_version'] !== FUSION_RESULT_SCHEMA_VERSION)
122
+ fail('details schema version mismatch', expected.artifactDir);
123
+ if (value['run_id'] !== expected.runId) fail('details run id mismatch', expected.artifactDir);
124
+ if (value['workflow'] !== expected.workflow)
125
+ fail('details workflow mismatch', expected.artifactDir);
126
+ if (value['source'] !== 'command' && value['source'] !== 'tool')
127
+ fail('details source is invalid', expected.artifactDir);
128
+ if (value['status'] !== 'completed')
129
+ fail('details status is not completed', expected.artifactDir);
130
+ if (value['artifact_dir'] !== expected.artifactDir)
131
+ fail('details artifact directory mismatch', expected.artifactDir);
132
+ const context = value['context'];
133
+ const toolPolicy = value['tool_policy'];
134
+ const models = value['models'];
135
+ const budget = value['budget'];
136
+ if (!isRecord(context) || !isRecord(toolPolicy) || !isRecord(models) || !isRecord(budget)) {
137
+ fail('details nested contract is malformed', expected.artifactDir);
138
+ }
139
+ assertOnlyKeys(context, ['kind', 'policy_id'], 'details.context', expected.artifactDir);
140
+ if (
141
+ (context['kind'] !== 'session_projection' && context['kind'] !== 'clean_task') ||
142
+ typeof context['policy_id'] !== 'string'
143
+ ) {
144
+ fail('details.context is invalid', expected.artifactDir);
145
+ }
146
+ assertOnlyKeys(
147
+ toolPolicy,
148
+ ['candidate_tools', 'evaluation_tools', 'merge_tools'],
149
+ 'details.tool_policy',
150
+ expected.artifactDir,
151
+ );
152
+ const stringArray = (entry: unknown): entry is string[] =>
153
+ Array.isArray(entry) && entry.every((item) => typeof item === 'string');
154
+ if (
155
+ !stringArray(toolPolicy['candidate_tools']) ||
156
+ !Array.isArray(toolPolicy['evaluation_tools']) ||
157
+ toolPolicy['evaluation_tools'].length !== 0 ||
158
+ !Array.isArray(toolPolicy['merge_tools']) ||
159
+ toolPolicy['merge_tools'].length !== 0
160
+ ) {
161
+ fail('details.tool_policy is invalid', expected.artifactDir);
162
+ }
163
+ assertOnlyKeys(
164
+ models,
165
+ ['candidates', 'evaluator', 'merger', 'thinking_level'],
166
+ 'details.models',
167
+ expected.artifactDir,
168
+ );
169
+ if (
170
+ !stringArray(models['candidates']) ||
171
+ models['candidates'].length !== 3 ||
172
+ typeof models['evaluator'] !== 'string' ||
173
+ typeof models['merger'] !== 'string' ||
174
+ typeof models['thinking_level'] !== 'string'
175
+ ) {
176
+ fail('details.models is invalid', expected.artifactDir);
177
+ }
178
+ assertOnlyKeys(
179
+ budget,
180
+ [
181
+ 'policy_id',
182
+ 'calibration_version',
183
+ 'route_table',
184
+ 'rate_sources',
185
+ 'unknown_provider_warnings',
186
+ 'calibration_warnings',
187
+ ],
188
+ 'details.budget',
189
+ expected.artifactDir,
190
+ );
191
+ if (
192
+ typeof budget['policy_id'] !== 'string' ||
193
+ typeof budget['calibration_version'] !== 'string' ||
194
+ !Array.isArray(budget['route_table']) ||
195
+ !Array.isArray(budget['rate_sources']) ||
196
+ !stringArray(budget['unknown_provider_warnings']) ||
197
+ !Array.isArray(budget['calibration_warnings'])
198
+ ) {
199
+ fail('details.budget is invalid', expected.artifactDir);
200
+ }
201
+ if (
202
+ !Number.isSafeInteger(value['evaluator_attempts']) ||
203
+ ![1, 2].includes(Number(value['evaluator_attempts']))
204
+ ) {
205
+ fail('details evaluator_attempts is invalid', expected.artifactDir);
206
+ }
207
+ const checkedUsage = usage(value['usage'], expected.artifactDir);
208
+ const candidates = models['candidates'];
209
+ if (!stringArray(candidates) || candidates.length !== 3)
210
+ fail('details.models candidates are invalid', expected.artifactDir);
211
+ const candidate1 = candidates[0];
212
+ const candidate2 = candidates[1];
213
+ const candidate3 = candidates[2];
214
+ if (candidate1 === undefined || candidate2 === undefined || candidate3 === undefined) {
215
+ fail('details.models candidates are incomplete', expected.artifactDir);
216
+ }
217
+ const source = value['source'];
218
+ const contextKind = context['kind'];
219
+ return {
220
+ schema_version: FUSION_RESULT_SCHEMA_VERSION,
221
+ run_id: expected.runId,
222
+ workflow: expected.workflow,
223
+ source,
224
+ status: 'completed',
225
+ context: { kind: contextKind, policy_id: context['policy_id'] },
226
+ tool_policy: {
227
+ candidate_tools: [...toolPolicy['candidate_tools']],
228
+ evaluation_tools: [],
229
+ merge_tools: [],
230
+ },
231
+ artifact_dir: expected.artifactDir,
232
+ models: {
233
+ candidates: [candidate1, candidate2, candidate3],
234
+ evaluator: models['evaluator'],
235
+ merger: models['merger'],
236
+ thinking_level: models['thinking_level'],
237
+ },
238
+ evaluator_attempts: Number(value['evaluator_attempts']),
239
+ usage: checkedUsage,
240
+ budget: {
241
+ policy_id: budget['policy_id'],
242
+ calibration_version: budget['calibration_version'],
243
+ route_table: budget['route_table'] as FusionResultDetails['budget']['route_table'],
244
+ rate_sources: budget['rate_sources'] as FusionResultDetails['budget']['rate_sources'],
245
+ unknown_provider_warnings: [...budget['unknown_provider_warnings']],
246
+ calibration_warnings: budget[
247
+ 'calibration_warnings'
248
+ ] as FusionResultDetails['budget']['calibration_warnings'],
249
+ },
250
+ };
251
+ }
252
+
253
+ function sameRef(left: FusionArtifactRef, right: FusionArtifactRef): boolean {
254
+ return (
255
+ left.path === right.path &&
256
+ left.byte_length === right.byte_length &&
257
+ left.sha256 === right.sha256
258
+ );
259
+ }
260
+
261
+ async function readUtf8(
262
+ path: string,
263
+ label: string,
264
+ artifactDir: string,
265
+ ): Promise<{ bytes: Buffer; text: string }> {
266
+ let bytes: Buffer;
267
+ try {
268
+ bytes = await readFile(path);
269
+ } catch (error) {
270
+ fail(
271
+ `${label} is unreadable: ${error instanceof Error ? error.message : String(error)}`,
272
+ artifactDir,
273
+ );
274
+ }
275
+ let text: string;
276
+ try {
277
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
278
+ } catch {
279
+ fail(`${label} is not well-formed UTF-8`, artifactDir);
280
+ }
281
+ return { bytes, text };
282
+ }
283
+
284
+ export interface ReadFusionCommittedResultOptions {
285
+ artifactDirAbs: string;
286
+ artifactDir: string;
287
+ runId: string;
288
+ workflow: FusionWorkflowId;
289
+ }
290
+
291
+ /** Verify the manifest-bound Fusion commit before returning merged bytes. */
292
+ export async function readFusionCommittedResult(
293
+ options: ReadFusionCommittedResultOptions,
294
+ ): Promise<FusionRunResult> {
295
+ const manifestFile = await readUtf8(
296
+ join(options.artifactDirAbs, 'manifest.json'),
297
+ 'manifest.json',
298
+ options.artifactDir,
299
+ );
300
+ let manifestValue: unknown;
301
+ try {
302
+ manifestValue = parseJsonText(manifestFile.text);
303
+ } catch (error) {
304
+ fail(
305
+ `manifest.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
306
+ options.artifactDir,
307
+ );
308
+ }
309
+ if (!isRecord(manifestValue)) fail('manifest.json must be an object', options.artifactDir);
310
+ if (manifestValue['schema_version'] !== FUSION_MANIFEST_SCHEMA_VERSION)
311
+ fail('manifest schema version mismatch', options.artifactDir);
312
+ if (manifestValue['run_id'] !== options.runId || manifestValue['workflow'] !== options.workflow)
313
+ fail('manifest identity mismatch', options.artifactDir);
314
+ if (manifestValue['state'] !== 'completed')
315
+ fail('manifest is not committed', options.artifactDir);
316
+ const artifacts = manifestValue['artifacts'];
317
+ if (!isRecord(artifacts)) fail('manifest artifacts map is invalid', options.artifactDir);
318
+ const manifestMerged = artifactRef(
319
+ artifacts['merged.md'],
320
+ 'manifest artifacts merged.md',
321
+ options.artifactDir,
322
+ );
323
+ const manifestResult = artifactRef(
324
+ artifacts['result.json'],
325
+ 'manifest artifacts result.json',
326
+ options.artifactDir,
327
+ );
328
+ if (manifestMerged.path !== 'merged.md' || manifestResult.path !== 'result.json')
329
+ fail('manifest fixed artifact paths are invalid', options.artifactDir);
330
+
331
+ const resultFile = await readUtf8(
332
+ join(options.artifactDirAbs, 'result.json'),
333
+ 'result.json',
334
+ options.artifactDir,
335
+ );
336
+ if (
337
+ resultFile.bytes.length !== manifestResult.byte_length ||
338
+ sha256Buffer(resultFile.bytes) !== manifestResult.sha256
339
+ ) {
340
+ fail('result.json does not match its manifest hash and length', options.artifactDir);
341
+ }
342
+ let resultValue: unknown;
343
+ try {
344
+ resultValue = parseJsonText(resultFile.text);
345
+ } catch (error) {
346
+ fail(
347
+ `result.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
348
+ options.artifactDir,
349
+ );
350
+ }
351
+ if (!isRecord(resultValue)) fail('result.json must be an object', options.artifactDir);
352
+ assertOnlyKeys(
353
+ resultValue,
354
+ ['schema_version', 'run_id', 'merged', 'details'],
355
+ 'result.json',
356
+ options.artifactDir,
357
+ );
358
+ if (
359
+ resultValue['schema_version'] !== FUSION_COMMITTED_RESULT_SCHEMA_VERSION ||
360
+ resultValue['run_id'] !== options.runId
361
+ ) {
362
+ fail('result.json identity mismatch', options.artifactDir);
363
+ }
364
+ const committedMerged = artifactRef(
365
+ resultValue['merged'],
366
+ 'result.json merged',
367
+ options.artifactDir,
368
+ );
369
+ if (!sameRef(committedMerged, manifestMerged))
370
+ fail('result.json merged reference does not match manifest', options.artifactDir);
371
+ const details = resultDetails(resultValue['details'], options);
372
+
373
+ const mergedFile = await readUtf8(
374
+ join(options.artifactDirAbs, 'merged.md'),
375
+ 'merged.md',
376
+ options.artifactDir,
377
+ );
378
+ if (
379
+ mergedFile.bytes.length !== committedMerged.byte_length ||
380
+ sha256Buffer(mergedFile.bytes) !== committedMerged.sha256
381
+ ) {
382
+ fail('merged.md does not match its committed hash and length', options.artifactDir);
383
+ }
384
+ return { mergedText: mergedFile.text, details };
385
+ }
@@ -18,13 +18,17 @@ export const FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION =
18
18
  'pi-background-tasks.fusion-validation-candidate.v1';
19
19
  export const FUSION_LEGACY_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v4';
20
20
  export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v5';
21
+ export const FUSION_COMMITTED_RESULT_SCHEMA_VERSION =
22
+ 'pi-background-tasks.fusion-committed-result.v1';
21
23
  export const FUSION_LEGACY_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v3';
22
24
  export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v4';
23
25
  export const FUSION_CONTEXT_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.fusion-context-ledger.v2';
24
26
  export const FUSION_SOURCE_POLICY_SCHEMA_VERSION = 'pi-background-tasks.fusion-source-policy.v1';
25
27
  export const FUSION_BUDGET_PLAN_SCHEMA_VERSION = 'pi-background-tasks.fusion-budget-plan.v4';
26
28
  export const FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION =
27
- 'pi-background-tasks.fusion-calibration-violation.v1';
29
+ 'pi-background-tasks.fusion-calibration-violation.v2';
30
+ export const FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION =
31
+ 'pi-background-tasks.fusion-validation-candidate-contract-event.v1';
28
32
  export const FUSION_TOOL_CALL_LOG_SCHEMA_VERSION = 'pi-background-tasks.fusion-tool-call.v1';
29
33
 
30
34
  /**
@@ -551,6 +555,13 @@ export interface FusionResultBudgetDetails {
551
555
  calibration_warnings: readonly FusionCalibrationViolation[];
552
556
  }
553
557
 
558
+ export interface FusionCommittedResultV1 {
559
+ schema_version: typeof FUSION_COMMITTED_RESULT_SCHEMA_VERSION;
560
+ run_id: string;
561
+ merged: FusionArtifactRef;
562
+ details: FusionResultDetails;
563
+ }
564
+
554
565
  export interface FusionResultDetails {
555
566
  schema_version: typeof FUSION_RESULT_SCHEMA_VERSION;
556
567
  run_id: string;
@@ -769,7 +780,12 @@ export interface FusionChildRunResult {
769
780
  model: string;
770
781
  qualifiedId: string;
771
782
  text: string;
783
+ /** Aggregate usage across the complete child agent loop. */
772
784
  usage: FusionUsage;
785
+ /** First provider request, used for like-for-like prompt forecast calibration. */
786
+ firstRequestUsage?: FusionUsage;
787
+ /** Number of provider requests represented by aggregate usage. */
788
+ providerRequestCount?: number;
773
789
  events: Buffer;
774
790
  stderr: Buffer;
775
791
  exitCode: number;
@@ -962,6 +978,8 @@ export interface FusionCalibrationViolation {
962
978
  rate_source: TokenBudgetRateSource;
963
979
  prompt_utf8_bytes: number;
964
980
  prompt_sha256: string;
981
+ observation_scope: 'first_provider_request';
982
+ provider_request_count: number;
965
983
  forecast_input_tokens: number;
966
984
  billed_input_tokens: number;
967
985
  billed_input_breakdown: {