pi-background-tasks 2.1.3 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
- package/PUBLISHING.md +2 -0
- package/README.md +16 -9
- package/TESTING.md +15 -10
- package/TEST_PLAN.md +12 -11
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/docs/INDEX.md +8 -4
- package/docs/choose-a-workflow.md +5 -2
- package/docs/commands/claude-cache.md +50 -0
- package/docs/getting-started.md +3 -0
- package/docs/manifest.json +84 -19
- package/docs/operations/configuration.md +15 -1
- package/docs/operations/releasing.md +6 -3
- package/docs/read-before-edit.md +4 -1
- package/docs/reference/runtime-contracts.md +72 -71
- package/docs/subsystems/anthropic-attribution.md +63 -0
- package/docs/subsystems/attested-pi-runs.md +2 -2
- package/docs/subsystems/child-launch-durability-and-safety.md +3 -3
- package/docs/subsystems/delegation.md +12 -4
- package/docs/subsystems/docs-freshness-gate.md +6 -6
- package/docs/subsystems/fusion.md +6 -2
- package/docs/tools/bg_delegate.md +21 -9
- package/docs/tools/bg_result.md +8 -1
- package/docs/tools/bg_run.md +5 -0
- package/extensions/anthropic-attribution.ts +1 -0
- package/package.json +4 -2
- package/src/core/anthropic-attribution-path.ts +26 -0
- package/src/core/{fusion/anthropic-attribution.ts → anthropic-attribution.ts} +61 -8
- package/src/core/attested-pi-run.ts +10 -1
- package/src/core/common.ts +2 -1
- package/src/core/delegate/artifacts.ts +4 -0
- package/src/core/delegate/launch.ts +44 -14
- package/src/core/delegate/runner.ts +24 -0
- package/src/core/delegate/seed.ts +12 -0
- package/src/core/delegate/types.ts +10 -2
- package/src/core/fusion/artifacts.ts +265 -3
- package/src/core/fusion/config.ts +1 -1
- package/src/core/fusion/orchestrator.ts +47 -57
- package/src/core/fusion/pi-child.ts +7 -124
- package/src/core/fusion/result-package.ts +550 -3
- package/src/core/fusion/types.ts +87 -0
- package/src/core/registry.ts +4 -1
- package/src/delegate-child-extension.ts +9 -2
- package/src/delegate-extension.ts +119 -9
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
DELEGATE_TOOL_NAME,
|
|
18
18
|
DelegateError,
|
|
19
19
|
type DelegateCapability,
|
|
20
|
+
type DelegateExtensionMode,
|
|
20
21
|
type DelegateLimits,
|
|
21
22
|
type DelegatePinnedRoute,
|
|
22
23
|
type DelegateRoute,
|
|
@@ -87,7 +88,7 @@ export function resolveDelegateRoute(input: DelegateRouteResolutionInput): Deleg
|
|
|
87
88
|
childCreated: false,
|
|
88
89
|
remediation: [
|
|
89
90
|
'Name a provider/model pair that appears in the current model registry.',
|
|
90
|
-
|
|
91
|
+
"Omit the route argument to use the parent session's current model.",
|
|
91
92
|
],
|
|
92
93
|
},
|
|
93
94
|
);
|
|
@@ -239,12 +240,23 @@ export function delegateToolsFor(capability: DelegateCapability): readonly strin
|
|
|
239
240
|
});
|
|
240
241
|
}
|
|
241
242
|
|
|
243
|
+
function assertDelegateExtensionMode(mode: DelegateExtensionMode): void {
|
|
244
|
+
if (mode === 'isolated' || mode === 'ambient') return;
|
|
245
|
+
throw new DelegateError(`bg_delegate extension mode ${String(mode)} is not supported`, {
|
|
246
|
+
code: 'invalid_arguments',
|
|
247
|
+
childCreated: false,
|
|
248
|
+
remediation: ['Use extensionMode "isolated" or "ambient".'],
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
export interface DelegateChildArgvInput {
|
|
243
253
|
route: DelegatePinnedRoute;
|
|
244
254
|
capability: DelegateCapability;
|
|
255
|
+
extensionMode: DelegateExtensionMode;
|
|
245
256
|
childSessionId: string;
|
|
246
257
|
childSessionDir: string;
|
|
247
258
|
childExtensionPath: string;
|
|
259
|
+
attributionExtensionPath?: string | undefined;
|
|
248
260
|
systemPrompt: string;
|
|
249
261
|
}
|
|
250
262
|
|
|
@@ -252,11 +264,15 @@ export interface DelegateChildArgvInput {
|
|
|
252
264
|
* Build the child argv.
|
|
253
265
|
*
|
|
254
266
|
* The child gets its own `--session-id` and a task-owned `--session-dir`, so it
|
|
255
|
-
* is structurally incapable of opening or mutating the parent session.
|
|
256
|
-
*
|
|
257
|
-
* disabled
|
|
267
|
+
* is structurally incapable of opening or mutating the parent session. Skills,
|
|
268
|
+
* prompt templates, themes, and context files are always disabled. Extension
|
|
269
|
+
* discovery is disabled in isolated mode and deliberately enabled in ambient
|
|
270
|
+
* mode; ambient extensions execute arbitrary code and are not sandboxed by the
|
|
271
|
+
* model-visible tool allowlist. The package guard is always explicit; Anthropic
|
|
272
|
+
* routes first load the package attribution/sanitization extension.
|
|
258
273
|
*/
|
|
259
274
|
export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[] {
|
|
275
|
+
assertDelegateExtensionMode(input.extensionMode);
|
|
260
276
|
const tools = delegateToolsFor(input.capability);
|
|
261
277
|
for (const forbidden of DELEGATE_FORBIDDEN_TOOLS) {
|
|
262
278
|
if (tools.includes(forbidden)) {
|
|
@@ -266,6 +282,20 @@ export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[]
|
|
|
266
282
|
);
|
|
267
283
|
}
|
|
268
284
|
}
|
|
285
|
+
const extensionPaths =
|
|
286
|
+
input.route.provider === 'anthropic'
|
|
287
|
+
? [
|
|
288
|
+
input.attributionExtensionPath ??
|
|
289
|
+
(() => {
|
|
290
|
+
throw new DelegateError(
|
|
291
|
+
'Anthropic delegate launch requires the package attribution extension',
|
|
292
|
+
{ code: 'delegate_isolation_unsupported', childCreated: false },
|
|
293
|
+
);
|
|
294
|
+
})(),
|
|
295
|
+
input.childExtensionPath,
|
|
296
|
+
]
|
|
297
|
+
: [input.childExtensionPath];
|
|
298
|
+
|
|
269
299
|
return [
|
|
270
300
|
'--mode',
|
|
271
301
|
'text',
|
|
@@ -279,13 +309,12 @@ export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[]
|
|
|
279
309
|
tools.join(','),
|
|
280
310
|
'--exclude-tools',
|
|
281
311
|
DELEGATE_FORBIDDEN_TOOLS.join(','),
|
|
282
|
-
'--no-extensions',
|
|
312
|
+
...(input.extensionMode === 'isolated' ? ['--no-extensions'] : []),
|
|
283
313
|
'--no-skills',
|
|
284
314
|
'--no-prompt-templates',
|
|
285
315
|
'--no-themes',
|
|
286
316
|
'--no-context-files',
|
|
287
|
-
'--extension',
|
|
288
|
-
input.childExtensionPath,
|
|
317
|
+
...extensionPaths.flatMap((path) => ['--extension', path]),
|
|
289
318
|
'--provider',
|
|
290
319
|
input.route.provider,
|
|
291
320
|
'--model',
|
|
@@ -362,6 +391,7 @@ export interface DelegatePreflightInput {
|
|
|
362
391
|
toolCallId: string | undefined;
|
|
363
392
|
prompt: string;
|
|
364
393
|
capability: DelegateCapability;
|
|
394
|
+
extensionMode: DelegateExtensionMode;
|
|
365
395
|
route: DelegatePinnedRoute;
|
|
366
396
|
limitOverrides: DelegateLimitOverrides;
|
|
367
397
|
hookEvidence: DelegateHookContractEvidence;
|
|
@@ -411,10 +441,9 @@ export function buildDelegateChildPrompt(seedSerialized: string, directive: stri
|
|
|
411
441
|
* one of these can refuse, and none of them has created a process, a session, or
|
|
412
442
|
* an artifact by the time it does.
|
|
413
443
|
*/
|
|
414
|
-
export function preflightDelegateLaunch(
|
|
415
|
-
input: DelegatePreflightInput,
|
|
416
|
-
): DelegatePreflightResult {
|
|
444
|
+
export function preflightDelegateLaunch(input: DelegatePreflightInput): DelegatePreflightResult {
|
|
417
445
|
assertDelegateHookContract(input.hookEvidence);
|
|
446
|
+
assertDelegateExtensionMode(input.extensionMode);
|
|
418
447
|
// Validates the capability and proves the tool set contains nothing forbidden.
|
|
419
448
|
delegateToolsFor(input.capability);
|
|
420
449
|
const limits = resolveDelegateLimits(input.route, input.limitOverrides);
|
|
@@ -427,10 +456,13 @@ export function preflightDelegateLaunch(
|
|
|
427
456
|
toolCallId: input.toolCallId,
|
|
428
457
|
directive: input.prompt,
|
|
429
458
|
capability: input.capability,
|
|
459
|
+
extensionMode: input.extensionMode,
|
|
430
460
|
route: input.route,
|
|
431
461
|
limits,
|
|
432
462
|
});
|
|
433
|
-
const childSystemPrompt = buildDelegateChildSystemPrompt(
|
|
463
|
+
const childSystemPrompt = buildDelegateChildSystemPrompt(
|
|
464
|
+
'the task seed in your first user message',
|
|
465
|
+
);
|
|
434
466
|
const childPrompt = buildDelegateChildPrompt(seed.serialized, seed.seed.directive.text);
|
|
435
467
|
const plan = planDelegateAdmission({
|
|
436
468
|
route: input.route,
|
|
@@ -454,9 +486,7 @@ export function preflightDelegateLaunch(
|
|
|
454
486
|
}
|
|
455
487
|
|
|
456
488
|
/** Task-owned child session directory. Never the parent's session directory. */
|
|
457
|
-
export async function ensureDelegateChildSessionDir(
|
|
458
|
-
artifactDirAbs: string,
|
|
459
|
-
): Promise<string> {
|
|
489
|
+
export async function ensureDelegateChildSessionDir(artifactDirAbs: string): Promise<string> {
|
|
460
490
|
const dir = join(artifactDirAbs, 'child-session');
|
|
461
491
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
462
492
|
return dir;
|
|
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { canonicalJson } from '../attested-pi-run.js';
|
|
4
4
|
import { replaceFileDurable } from '../durable-fs.js';
|
|
5
|
+
import { resolveAnthropicAttributionExtensionPath } from '../anthropic-attribution-path.js';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { DelegateArtifactStore, discardDelegateArtifactRoot } from './artifacts.js';
|
|
7
8
|
import {
|
|
@@ -20,6 +21,7 @@ import { verifyDelegateResultPackage, type VerifiedDelegateResult } from './resu
|
|
|
20
21
|
import {
|
|
21
22
|
DelegateError,
|
|
22
23
|
type DelegateAutoDeliverMode,
|
|
24
|
+
type DelegateExtensionMode,
|
|
23
25
|
type DelegateResultPackageV1,
|
|
24
26
|
} from './types.js';
|
|
25
27
|
import type { DelegateTaskFacts, DelegateTaskOutcome } from '../common.js';
|
|
@@ -48,7 +50,9 @@ export interface PrepareDelegateLaunchInput extends DelegatePreflightInput {
|
|
|
48
50
|
cwd: string;
|
|
49
51
|
sessionId: string | undefined;
|
|
50
52
|
autoDeliver: DelegateAutoDeliverMode;
|
|
53
|
+
extensionMode: DelegateExtensionMode;
|
|
51
54
|
childExtensionPath?: string | undefined;
|
|
55
|
+
attributionExtensionPath?: string | undefined;
|
|
52
56
|
env?: NodeJS.ProcessEnv | undefined;
|
|
53
57
|
now?: (() => Date) | undefined;
|
|
54
58
|
}
|
|
@@ -68,6 +72,22 @@ export async function prepareDelegateLaunch(
|
|
|
68
72
|
// its child guard must refuse rather than spawn an unguarded child.
|
|
69
73
|
const childExtensionPath =
|
|
70
74
|
input.childExtensionPath ?? resolveDelegateChildExtensionPath();
|
|
75
|
+
let attributionExtensionPath: string | undefined;
|
|
76
|
+
if (input.route.provider === 'anthropic') {
|
|
77
|
+
try {
|
|
78
|
+
attributionExtensionPath =
|
|
79
|
+
input.attributionExtensionPath ?? resolveAnthropicAttributionExtensionPath();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new DelegateError(
|
|
82
|
+
`Anthropic delegate attribution extension could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
|
|
83
|
+
{
|
|
84
|
+
code: 'delegate_isolation_unsupported',
|
|
85
|
+
childCreated: false,
|
|
86
|
+
remediation: ['Reinstall the package; Anthropic delegates require attribution.'],
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
71
91
|
|
|
72
92
|
const preflight = preflightDelegateLaunch(input);
|
|
73
93
|
|
|
@@ -78,6 +98,7 @@ export async function prepareDelegateLaunch(
|
|
|
78
98
|
sessionId: input.sessionId,
|
|
79
99
|
childSessionId: preflight.childSessionId,
|
|
80
100
|
childSessionDir: '',
|
|
101
|
+
extensionMode: input.extensionMode,
|
|
81
102
|
route: input.route,
|
|
82
103
|
limits: preflight.limits,
|
|
83
104
|
seedSha256: preflight.seed.sha256,
|
|
@@ -107,9 +128,11 @@ export async function prepareDelegateLaunch(
|
|
|
107
128
|
const argv = buildDelegateChildArgv({
|
|
108
129
|
route: input.route,
|
|
109
130
|
capability: input.capability,
|
|
131
|
+
extensionMode: input.extensionMode,
|
|
110
132
|
childSessionId: preflight.childSessionId,
|
|
111
133
|
childSessionDir: childSessionDirAbs,
|
|
112
134
|
childExtensionPath,
|
|
135
|
+
attributionExtensionPath,
|
|
113
136
|
systemPrompt: preflight.childSystemPrompt,
|
|
114
137
|
});
|
|
115
138
|
const env = delegateChildEnv(
|
|
@@ -138,6 +161,7 @@ export async function prepareDelegateLaunch(
|
|
|
138
161
|
family: preflight.plan.route.family,
|
|
139
162
|
rate_source: preflight.plan.route.rate_source,
|
|
140
163
|
},
|
|
164
|
+
extensionMode: input.extensionMode,
|
|
141
165
|
autoDeliver: input.autoDeliver,
|
|
142
166
|
};
|
|
143
167
|
const stdinBytes = Buffer.from(preflight.childPrompt, 'utf8');
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type DelegateContextPolicyDescriptor,
|
|
23
23
|
type DelegateConversationProjection,
|
|
24
24
|
type DelegateLedgerV1,
|
|
25
|
+
type DelegateExtensionMode,
|
|
25
26
|
type DelegateLimits,
|
|
26
27
|
type DelegatePinnedRoute,
|
|
27
28
|
type DelegateSeedV1,
|
|
@@ -34,6 +35,7 @@ export interface BuildDelegateSeedOptions {
|
|
|
34
35
|
toolCallId: string | undefined;
|
|
35
36
|
directive: string;
|
|
36
37
|
capability: DelegateCapability;
|
|
38
|
+
extensionMode: DelegateExtensionMode;
|
|
37
39
|
route: DelegatePinnedRoute;
|
|
38
40
|
limits: DelegateLimits;
|
|
39
41
|
}
|
|
@@ -167,6 +169,7 @@ export function buildDelegateSeed(
|
|
|
167
169
|
launch_nonce: options.launchNonce,
|
|
168
170
|
cwd: ctx.cwd,
|
|
169
171
|
capability: options.capability,
|
|
172
|
+
extension_mode: options.extensionMode,
|
|
170
173
|
route: options.route,
|
|
171
174
|
parent_system_prompt: ctx.getSystemPrompt(),
|
|
172
175
|
parent_leaf_id: snapshot.leafId,
|
|
@@ -296,6 +299,14 @@ function rebuildSeed(record: Record<PropertyKey, unknown>, taskId: string): Dele
|
|
|
296
299
|
taskId,
|
|
297
300
|
});
|
|
298
301
|
}
|
|
302
|
+
const extensionMode = requireString(record, 'extension_mode', taskId);
|
|
303
|
+
if (extensionMode !== 'isolated' && extensionMode !== 'ambient') {
|
|
304
|
+
throw new DelegateError(`delegate seed extension mode ${extensionMode} is not recognised`, {
|
|
305
|
+
code: 'seed_hash_mismatch',
|
|
306
|
+
childCreated: true,
|
|
307
|
+
taskId,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
299
310
|
const routeRecord = requireRecord(record, 'route', taskId);
|
|
300
311
|
const routeOrigin = requireString(routeRecord, 'origin', taskId);
|
|
301
312
|
if (routeOrigin !== 'parent_current' && routeOrigin !== 'explicit') {
|
|
@@ -378,6 +389,7 @@ function rebuildSeed(record: Record<PropertyKey, unknown>, taskId: string): Dele
|
|
|
378
389
|
launch_nonce: requireString(record, 'launch_nonce', taskId),
|
|
379
390
|
cwd: requireString(record, 'cwd', taskId),
|
|
380
391
|
capability,
|
|
392
|
+
extension_mode: extensionMode,
|
|
381
393
|
route,
|
|
382
394
|
parent_system_prompt: requireString(record, 'parent_system_prompt', taskId),
|
|
383
395
|
parent_leaf_id: parentLeafId,
|
|
@@ -12,14 +12,14 @@ import type {
|
|
|
12
12
|
ProjectionEntry,
|
|
13
13
|
} from '../context/visible-conversation-v2.js';
|
|
14
14
|
|
|
15
|
-
export const DELEGATE_SEED_SCHEMA_VERSION = 'pi-background-tasks.delegate-seed.
|
|
15
|
+
export const DELEGATE_SEED_SCHEMA_VERSION = 'pi-background-tasks.delegate-seed.v2' as const;
|
|
16
16
|
export const DELEGATE_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.delegate-ledger.v1' as const;
|
|
17
17
|
export const DELEGATE_RESULT_PACKAGE_SCHEMA_VERSION =
|
|
18
18
|
'pi-background-tasks.delegate-result.v1' as const;
|
|
19
19
|
export const DELEGATE_RECEIPT_SCHEMA_VERSION = 'pi-background-tasks.delegate-receipt.v1' as const;
|
|
20
20
|
export const DELEGATE_BUDGET_PLAN_SCHEMA_VERSION =
|
|
21
21
|
'pi-background-tasks.delegate-budget-plan.v2' as const;
|
|
22
|
-
export const DELEGATE_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.delegate-manifest.
|
|
22
|
+
export const DELEGATE_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.delegate-manifest.v2' as const;
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Delegate's own context policy id. It shares the frozen
|
|
@@ -35,6 +35,13 @@ export const DELEGATE_RESULT_TOOL_NAME = 'bg_result';
|
|
|
35
35
|
export const DELEGATE_CAPABILITIES = ['inspect'] as const;
|
|
36
36
|
export type DelegateCapability = (typeof DELEGATE_CAPABILITIES)[number];
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Controls only Pi's ambient extension discovery for delegate children.
|
|
40
|
+
* Tool and project-resource restrictions remain independently enforced.
|
|
41
|
+
*/
|
|
42
|
+
export const DELEGATE_EXTENSION_MODES = ['isolated', 'ambient'] as const;
|
|
43
|
+
export type DelegateExtensionMode = (typeof DELEGATE_EXTENSION_MODES)[number];
|
|
44
|
+
|
|
38
45
|
export const DELEGATE_AUTO_DELIVER_MODES = ['never', 'when_small', 'always'] as const;
|
|
39
46
|
export type DelegateAutoDeliverMode = (typeof DELEGATE_AUTO_DELIVER_MODES)[number];
|
|
40
47
|
|
|
@@ -110,6 +117,7 @@ export interface DelegateSeedV1 {
|
|
|
110
117
|
launch_nonce: string;
|
|
111
118
|
cwd: string;
|
|
112
119
|
capability: DelegateCapability;
|
|
120
|
+
extension_mode: DelegateExtensionMode;
|
|
113
121
|
route: DelegatePinnedRoute;
|
|
114
122
|
parent_system_prompt: string;
|
|
115
123
|
parent_leaf_id: string | null;
|
|
@@ -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
|
-
|
|
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}`);
|
|
@@ -5,7 +5,7 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
|
5
5
|
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
6
6
|
import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
|
|
7
7
|
import { replaceFileDurable } from '../durable-fs.js';
|
|
8
|
-
import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from '
|
|
8
|
+
import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from '../anthropic-attribution.js';
|
|
9
9
|
import {
|
|
10
10
|
FUSION_MODEL_CONFIG_SCHEMA_VERSION,
|
|
11
11
|
FusionError,
|