@pi-unipi/background-tasks 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/extensions/anthropic-attribution.ts +1 -0
- package/extensions/delegate-child.ts +1 -0
- package/extensions/fusion-child.ts +1 -0
- package/package.json +40 -0
- package/src/__tests__/anthropic-attribution.test.ts +195 -0
- package/src/__tests__/config.test.ts +137 -0
- package/src/__tests__/core.test.ts +493 -0
- package/src/__tests__/delegate-artifacts.test.ts +528 -0
- package/src/__tests__/delegate-budget.test.ts +456 -0
- package/src/__tests__/delegate-launch.test.ts +676 -0
- package/src/__tests__/delegate-result-package.test.ts +350 -0
- package/src/__tests__/delegate-seed.test.ts +392 -0
- package/src/__tests__/durable-fs.test.ts +559 -0
- package/src/__tests__/extension-api.test.ts +579 -0
- package/src/__tests__/fusion-artifacts.test.ts +1039 -0
- package/src/__tests__/fusion-budget.test.ts +1356 -0
- package/src/__tests__/fusion-claude-cache.test.ts +320 -0
- package/src/__tests__/fusion-config.test.ts +335 -0
- package/src/__tests__/fusion-context-prompts.test.ts +670 -0
- package/src/__tests__/fusion-evaluation.test.ts +315 -0
- package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
- package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
- package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
- package/src/__tests__/fusion-model-selector.test.ts +205 -0
- package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
- package/src/__tests__/fusion-rpc.test.ts +369 -0
- package/src/__tests__/fusion-sdk.test.ts +1226 -0
- package/src/__tests__/fusion-v5-core.test.ts +219 -0
- package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
- package/src/__tests__/fusion-web-fetch.test.ts +485 -0
- package/src/__tests__/fusion-workflows.test.ts +59 -0
- package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
- package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
- package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
- package/src/__tests__/helpers/fusion-canonical.ts +140 -0
- package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
- package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
- package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
- package/src/__tests__/helpers/normalize.ts +22 -0
- package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/pi-launch.test.ts +202 -0
- package/src/__tests__/registry.test.ts +1580 -0
- package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
- package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
- package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
- package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
- package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
- package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
- package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
- package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
- package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
- package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
- package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
- package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
- package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
- package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
- package/src/__tests__/task-manager.test.ts +479 -0
- package/src/__tests__/windows-taskkill.test.ts +161 -0
- package/src/anthropic-attribution-path.ts +21 -0
- package/src/anthropic-attribution.ts +1983 -0
- package/src/attested-pi-run.ts +612 -0
- package/src/child-process.ts +55 -0
- package/src/common.ts +8 -0
- package/src/config.ts +292 -0
- package/src/context-parent-snapshot.ts +142 -0
- package/src/context-token-budget.ts +903 -0
- package/src/context-visible-conversation-v2.ts +551 -0
- package/src/delegate/artifacts.ts +487 -0
- package/src/delegate/budget.ts +415 -0
- package/src/delegate/hook-contract-evidence.json +18 -0
- package/src/delegate/hook-contract.ts +154 -0
- package/src/delegate/launch.ts +497 -0
- package/src/delegate/result-package.ts +459 -0
- package/src/delegate/runner.ts +449 -0
- package/src/delegate/seed.ts +423 -0
- package/src/delegate/types.ts +323 -0
- package/src/delegate-child-extension.ts +978 -0
- package/src/delegate-extension.ts +806 -0
- package/src/durable-fs.ts +386 -0
- package/src/extension-api.ts +548 -0
- package/src/fixtures/delegate-context-incident.json +17 -0
- package/src/fixtures/fusion-golden-bytes.json +310 -0
- package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
- package/src/fusion/artifacts.ts +967 -0
- package/src/fusion/budget.ts +1162 -0
- package/src/fusion/child-protocol.ts +305 -0
- package/src/fusion/claude-cache.ts +207 -0
- package/src/fusion/clean-context.ts +91 -0
- package/src/fusion/config.ts +449 -0
- package/src/fusion/context.ts +265 -0
- package/src/fusion/evaluation.ts +800 -0
- package/src/fusion/orchestrator.ts +1288 -0
- package/src/fusion/output-contract.ts +34 -0
- package/src/fusion/pi-child.ts +2373 -0
- package/src/fusion/prompts.ts +345 -0
- package/src/fusion/result-package.ts +959 -0
- package/src/fusion/source-policy.ts +257 -0
- package/src/fusion/types.ts +1139 -0
- package/src/fusion/web-fetch.ts +1060 -0
- package/src/fusion/workflows.ts +184 -0
- package/src/fusion-child-extension.ts +1052 -0
- package/src/fusion-extension.ts +1293 -0
- package/src/index.ts +295 -0
- package/src/pi-launch.ts +225 -0
- package/src/registry.ts +2424 -0
- package/src/settings-overlay.ts +208 -0
- package/src/task-manager.ts +774 -0
- package/src/tools.ts +530 -0
- package/src/turndown.d.ts +15 -0
- package/src/types.ts +963 -0
- package/src/ui/fusion-model-selector.ts +322 -0
- package/src/windows-taskkill.ts +250 -0
|
@@ -0,0 +1,1194 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { parseJsonText } from '../types.js';
|
|
7
|
+
import { FusionOrchestrator, type FusionChildRunner } from '../fusion/orchestrator.js';
|
|
8
|
+
import { FusionArtifactStore } from '../fusion/artifacts.js';
|
|
9
|
+
import { buildFusionCleanTaskCanonicalInput } from '../fusion/clean-context.js';
|
|
10
|
+
import { FUSION_RESEARCH_WORKFLOW } from '../fusion/workflows.js';
|
|
11
|
+
import { defaultFusionModelConfig } from '../fusion/config.js';
|
|
12
|
+
import {
|
|
13
|
+
FUSION_CANDIDATE_RESEARCH_SYSTEM_PROMPT,
|
|
14
|
+
FUSION_CANDIDATE_SYSTEM_PROMPT,
|
|
15
|
+
} from '../fusion/prompts.js';
|
|
16
|
+
import {
|
|
17
|
+
FUSION_COMMAND_CONTEXT_POLICY_ID,
|
|
18
|
+
FUSION_CONTEXT_TRANSFORM_ID,
|
|
19
|
+
FUSION_EVALUATION_SCHEMA_VERSION,
|
|
20
|
+
FUSION_INPUT_SCHEMA_VERSION,
|
|
21
|
+
FusionError,
|
|
22
|
+
type FusionCanonicalInputV3,
|
|
23
|
+
type FusionChildRunResult,
|
|
24
|
+
type FusionContextOmissionLedgerV2,
|
|
25
|
+
type FusionEvaluationV1,
|
|
26
|
+
type FusionUsage,
|
|
27
|
+
type ResolvedFusionModel,
|
|
28
|
+
type ResolvedFusionModels,
|
|
29
|
+
} from '../fusion/types.js';
|
|
30
|
+
import {
|
|
31
|
+
FusionChildRunError,
|
|
32
|
+
buildFusionPiChildArgv,
|
|
33
|
+
type RunPiChildOptions,
|
|
34
|
+
} from '../fusion/pi-child.js';
|
|
35
|
+
import { emptyLedger } from './helpers/fusion-canonical.js';
|
|
36
|
+
|
|
37
|
+
const ledger: FusionContextOmissionLedgerV2 = emptyLedger(FUSION_COMMAND_CONTEXT_POLICY_ID);
|
|
38
|
+
|
|
39
|
+
function resolved(qualifiedId: string, contextWindow = 200_000): ResolvedFusionModel {
|
|
40
|
+
const slash = qualifiedId.indexOf('/');
|
|
41
|
+
const provider = qualifiedId.slice(0, slash);
|
|
42
|
+
const model = qualifiedId.slice(slash + 1);
|
|
43
|
+
return {
|
|
44
|
+
selection: '$current',
|
|
45
|
+
source: 'current',
|
|
46
|
+
provider,
|
|
47
|
+
model,
|
|
48
|
+
qualifiedId,
|
|
49
|
+
thinkingLevel: 'high',
|
|
50
|
+
contextWindow,
|
|
51
|
+
maxOutputTokens: 32_768,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function models(): ResolvedFusionModels {
|
|
56
|
+
return {
|
|
57
|
+
candidates: [resolved('p/c1'), resolved('p/c2'), resolved('p/c3')],
|
|
58
|
+
evaluator: resolved('p/eval'),
|
|
59
|
+
merger: resolved('p/merge'),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function canonicalInput(text = 'hello'): FusionCanonicalInputV3 {
|
|
64
|
+
return {
|
|
65
|
+
schema_version: FUSION_INPUT_SCHEMA_VERSION,
|
|
66
|
+
cwd: '/tmp/project',
|
|
67
|
+
system_prompt: 'system',
|
|
68
|
+
request: {
|
|
69
|
+
source: 'command',
|
|
70
|
+
authority: 'directive_over_projected_conversation',
|
|
71
|
+
text: 'solve',
|
|
72
|
+
sha256: 'b'.repeat(64),
|
|
73
|
+
},
|
|
74
|
+
conversation_projection: {
|
|
75
|
+
policy: {
|
|
76
|
+
id: FUSION_COMMAND_CONTEXT_POLICY_ID,
|
|
77
|
+
transform: FUSION_CONTEXT_TRANSFORM_ID,
|
|
78
|
+
version: 2,
|
|
79
|
+
receipt_format: 'omitted_activity.v2',
|
|
80
|
+
user_text: 'verbatim',
|
|
81
|
+
assistant_text: 'verbatim',
|
|
82
|
+
assistant_thinking: 'ledger_only',
|
|
83
|
+
tool_call_arguments: 'ledger_only',
|
|
84
|
+
tool_results: 'ledger_only',
|
|
85
|
+
tool_payload_preview_bytes: 0,
|
|
86
|
+
images: 'marker_or_ledger_only',
|
|
87
|
+
unknown_block_behavior: 'error',
|
|
88
|
+
},
|
|
89
|
+
branch_filter: {
|
|
90
|
+
id: 'exclude-active-fusion-subtree-v1',
|
|
91
|
+
tool_name: 'fusion_reason',
|
|
92
|
+
tool_call_id: null,
|
|
93
|
+
active_tool_call_leaf_excluded: false,
|
|
94
|
+
},
|
|
95
|
+
entries: [['t', 'u', 0, 0, text]],
|
|
96
|
+
accounting: {
|
|
97
|
+
message_count: 1,
|
|
98
|
+
included_text_entry_count: 1,
|
|
99
|
+
included_user_text_bytes: Buffer.byteLength(text, 'utf8'),
|
|
100
|
+
included_assistant_text_bytes: 0,
|
|
101
|
+
included_image_marker_count: 0,
|
|
102
|
+
empty_text_block_count: 0,
|
|
103
|
+
omitted_run_count: 0,
|
|
104
|
+
omitted_event_count: 0,
|
|
105
|
+
omitted_thinking_bytes: 0,
|
|
106
|
+
omitted_tool_call_count: 0,
|
|
107
|
+
omitted_tool_call_argument_bytes: 0,
|
|
108
|
+
omitted_tool_result_text_count: 0,
|
|
109
|
+
omitted_tool_result_text_bytes: 0,
|
|
110
|
+
omitted_tool_result_image_count: 0,
|
|
111
|
+
omitted_tool_result_image_bytes: 0,
|
|
112
|
+
tool_call_names: [],
|
|
113
|
+
ledger_entry_count: 0,
|
|
114
|
+
ledger_root_sha256: 'a'.repeat(64),
|
|
115
|
+
omission_receipt_utf8_bytes: 0,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function evaluation(): FusionEvaluationV1 {
|
|
122
|
+
return {
|
|
123
|
+
schema_version: FUSION_EVALUATION_SCHEMA_VERSION,
|
|
124
|
+
candidate_assessments: [
|
|
125
|
+
{
|
|
126
|
+
candidate_id: 'A',
|
|
127
|
+
summary: 'a',
|
|
128
|
+
strengths: ['a'],
|
|
129
|
+
limitations: ['a'],
|
|
130
|
+
useful_contributions: ['a'],
|
|
131
|
+
risks: ['a'],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
candidate_id: 'B',
|
|
135
|
+
summary: 'b',
|
|
136
|
+
strengths: ['b'],
|
|
137
|
+
limitations: ['b'],
|
|
138
|
+
useful_contributions: ['b'],
|
|
139
|
+
risks: ['b'],
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
candidate_id: 'C',
|
|
143
|
+
summary: 'c',
|
|
144
|
+
strengths: ['c'],
|
|
145
|
+
limitations: ['c'],
|
|
146
|
+
useful_contributions: ['c'],
|
|
147
|
+
risks: ['c'],
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
agreements: ['agree'],
|
|
151
|
+
conflicts: [],
|
|
152
|
+
synthesis_plan: {
|
|
153
|
+
must_include: [{ candidate_id: 'A', contribution: 'a' }],
|
|
154
|
+
must_resolve: [],
|
|
155
|
+
must_avoid: [],
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function childResult(options: RunPiChildOptions, text: string): FusionChildRunResult {
|
|
161
|
+
const result: FusionChildRunResult = {
|
|
162
|
+
stage: options.stage,
|
|
163
|
+
attempt: options.attempt,
|
|
164
|
+
provider: options.model.provider,
|
|
165
|
+
model: options.model.model,
|
|
166
|
+
qualifiedId: options.model.qualifiedId,
|
|
167
|
+
text,
|
|
168
|
+
usage: {
|
|
169
|
+
input: 1,
|
|
170
|
+
output: 1,
|
|
171
|
+
cacheRead: 0,
|
|
172
|
+
cacheWrite: 0,
|
|
173
|
+
totalTokens: 2,
|
|
174
|
+
cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0, total: 0.03 },
|
|
175
|
+
},
|
|
176
|
+
events: Buffer.from('{"schema_version":"pi-background-tasks.fusion-child-result.v4"}\n'),
|
|
177
|
+
stderr: Buffer.alloc(0),
|
|
178
|
+
exitCode: 0,
|
|
179
|
+
signal: null,
|
|
180
|
+
};
|
|
181
|
+
if (options.slot !== undefined) result.slot = options.slot;
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function observedUsage(totalTokens: number): FusionUsage {
|
|
186
|
+
const costUnit = totalTokens / 1000;
|
|
187
|
+
return {
|
|
188
|
+
input: totalTokens,
|
|
189
|
+
output: totalTokens + 1,
|
|
190
|
+
cacheRead: totalTokens + 2,
|
|
191
|
+
cacheWrite: totalTokens + 3,
|
|
192
|
+
totalTokens,
|
|
193
|
+
cost: {
|
|
194
|
+
input: costUnit,
|
|
195
|
+
output: costUnit * 2,
|
|
196
|
+
cacheRead: costUnit * 3,
|
|
197
|
+
cacheWrite: costUnit * 4,
|
|
198
|
+
total: costUnit * 10,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function childRunError(
|
|
204
|
+
options: RunPiChildOptions,
|
|
205
|
+
message: string,
|
|
206
|
+
totalTokens: number,
|
|
207
|
+
code: FusionError['code'] = 'child_exit_failed',
|
|
208
|
+
): FusionChildRunError {
|
|
209
|
+
const details = {
|
|
210
|
+
code,
|
|
211
|
+
stage: options.stage,
|
|
212
|
+
attempt: options.attempt,
|
|
213
|
+
};
|
|
214
|
+
const fusionError = new FusionError(
|
|
215
|
+
message,
|
|
216
|
+
options.slot === undefined ? details : { ...details, slot: options.slot },
|
|
217
|
+
);
|
|
218
|
+
return new FusionChildRunError(
|
|
219
|
+
fusionError,
|
|
220
|
+
Buffer.from('{"schema_version":"pi-background-tasks.fusion-child-result.v4"}\n'),
|
|
221
|
+
Buffer.alloc(0),
|
|
222
|
+
Buffer.alloc(0),
|
|
223
|
+
{
|
|
224
|
+
code: code === 'child_exit_failed' ? 1 : null,
|
|
225
|
+
signal: code === 'child_cancelled' ? 'SIGTERM' : null,
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
usage: observedUsage(totalTokens),
|
|
229
|
+
provider: options.model.provider,
|
|
230
|
+
model: options.model.model,
|
|
231
|
+
qualifiedId: options.model.qualifiedId,
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function parseObject(text: string): object {
|
|
237
|
+
const parsed = parseJsonText(text);
|
|
238
|
+
assert.ok(typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed));
|
|
239
|
+
return parsed;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function field(record: object, key: string): unknown {
|
|
243
|
+
return Reflect.get(record, key);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function objectValue(value: unknown, label: string): object {
|
|
247
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
248
|
+
throw new Error(`${label} must be an object`);
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function objectField(record: object, key: string): object {
|
|
253
|
+
return objectValue(field(record, key), key);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function numberField(record: object, key: string): number {
|
|
257
|
+
const value = field(record, key);
|
|
258
|
+
if (typeof value !== 'number') throw new Error(`${key} must be a number`);
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function stringField(record: object, key: string): string {
|
|
263
|
+
const value = field(record, key);
|
|
264
|
+
if (typeof value !== 'string') throw new Error(`${key} must be a string`);
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function attemptRecords(manifest: object): object[] {
|
|
269
|
+
const attempts = field(manifest, 'attempts');
|
|
270
|
+
if (!Array.isArray(attempts)) throw new Error('attempts must be an array');
|
|
271
|
+
return attempts.map((attempt, index) => objectValue(attempt, `attempt ${String(index)}`));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function usageFromRecord(record: object): FusionUsage {
|
|
275
|
+
const usage = objectField(record, 'usage');
|
|
276
|
+
const cost = objectField(usage, 'cost');
|
|
277
|
+
return {
|
|
278
|
+
input: numberField(usage, 'input'),
|
|
279
|
+
output: numberField(usage, 'output'),
|
|
280
|
+
cacheRead: numberField(usage, 'cacheRead'),
|
|
281
|
+
cacheWrite: numberField(usage, 'cacheWrite'),
|
|
282
|
+
totalTokens: numberField(usage, 'totalTokens'),
|
|
283
|
+
cost: {
|
|
284
|
+
input: numberField(cost, 'input'),
|
|
285
|
+
output: numberField(cost, 'output'),
|
|
286
|
+
cacheRead: numberField(cost, 'cacheRead'),
|
|
287
|
+
cacheWrite: numberField(cost, 'cacheWrite'),
|
|
288
|
+
total: numberField(cost, 'total'),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function addExpectedUsage(target: FusionUsage, delta: FusionUsage): void {
|
|
294
|
+
target.input += delta.input;
|
|
295
|
+
target.output += delta.output;
|
|
296
|
+
target.cacheRead += delta.cacheRead;
|
|
297
|
+
target.cacheWrite += delta.cacheWrite;
|
|
298
|
+
target.totalTokens += delta.totalTokens;
|
|
299
|
+
target.cost.input += delta.cost.input;
|
|
300
|
+
target.cost.output += delta.cost.output;
|
|
301
|
+
target.cost.cacheRead += delta.cost.cacheRead;
|
|
302
|
+
target.cost.cacheWrite += delta.cost.cacheWrite;
|
|
303
|
+
target.cost.total += delta.cost.total;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function assertCostClose(actual: FusionUsage['cost'], expected: FusionUsage['cost']): void {
|
|
307
|
+
for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'total'] as const) {
|
|
308
|
+
assert.ok(
|
|
309
|
+
Math.abs(actual[key] - expected[key]) < 1e-12,
|
|
310
|
+
`aggregate cost.${key} must equal attempt sum`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function assertManifestUsageEqualsAttemptSum(manifest: object): void {
|
|
316
|
+
const expected: FusionUsage = {
|
|
317
|
+
input: 0,
|
|
318
|
+
output: 0,
|
|
319
|
+
cacheRead: 0,
|
|
320
|
+
cacheWrite: 0,
|
|
321
|
+
totalTokens: 0,
|
|
322
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
323
|
+
};
|
|
324
|
+
for (const attempt of attemptRecords(manifest))
|
|
325
|
+
addExpectedUsage(expected, usageFromRecord(attempt));
|
|
326
|
+
const actual = usageFromRecord(manifest);
|
|
327
|
+
assert.deepEqual(
|
|
328
|
+
{
|
|
329
|
+
input: actual.input,
|
|
330
|
+
output: actual.output,
|
|
331
|
+
cacheRead: actual.cacheRead,
|
|
332
|
+
cacheWrite: actual.cacheWrite,
|
|
333
|
+
totalTokens: actual.totalTokens,
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
input: expected.input,
|
|
337
|
+
output: expected.output,
|
|
338
|
+
cacheRead: expected.cacheRead,
|
|
339
|
+
cacheWrite: expected.cacheWrite,
|
|
340
|
+
totalTokens: expected.totalTokens,
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
assertCostClose(actual.cost, expected.cost);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function failedManifest(root: string, runner: FusionChildRunner): Promise<object> {
|
|
347
|
+
const orchestrator = new FusionOrchestrator({ childRunner: runner });
|
|
348
|
+
const canonical = canonicalInput();
|
|
349
|
+
let thrown: unknown;
|
|
350
|
+
try {
|
|
351
|
+
await orchestrator.run({
|
|
352
|
+
source: 'command',
|
|
353
|
+
cwd: root,
|
|
354
|
+
canonicalInput: canonical,
|
|
355
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
356
|
+
contextLedger: ledger,
|
|
357
|
+
config: defaultFusionModelConfig(),
|
|
358
|
+
models: models(),
|
|
359
|
+
});
|
|
360
|
+
} catch (error) {
|
|
361
|
+
thrown = error;
|
|
362
|
+
}
|
|
363
|
+
assert.ok(thrown instanceof FusionError);
|
|
364
|
+
assert.ok(thrown.artifactDir, 'failed fusion error must include artifact dir');
|
|
365
|
+
const manifest = parseObject(
|
|
366
|
+
await readFile(join(root, thrown.artifactDir, 'manifest.json'), 'utf8'),
|
|
367
|
+
);
|
|
368
|
+
assert.equal(field(manifest, 'state'), 'failed');
|
|
369
|
+
const artifacts = objectField(manifest, 'artifacts');
|
|
370
|
+
assert.ok(field(artifacts, 'failure-summary.json'), 'failed runs must bind one failure summary');
|
|
371
|
+
return manifest;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function waitForCalls(calls: readonly RunPiChildOptions[], count: number): Promise<void> {
|
|
375
|
+
const start = Date.now();
|
|
376
|
+
while (Date.now() - start < 2000) {
|
|
377
|
+
if (calls.length >= count) return;
|
|
378
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
379
|
+
}
|
|
380
|
+
throw new Error(`timed out waiting for ${String(count)} child calls`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
void describe('fusion orchestrator', () => {
|
|
384
|
+
void it('runs parallel candidates, schema repair, and final merge', async () => {
|
|
385
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-orchestrator-'));
|
|
386
|
+
try {
|
|
387
|
+
const calls: RunPiChildOptions[] = [];
|
|
388
|
+
let releaseCandidates: () => void = () => undefined;
|
|
389
|
+
const candidatesStarted = new Promise<void>((resolve) => {
|
|
390
|
+
releaseCandidates = resolve;
|
|
391
|
+
});
|
|
392
|
+
const runner: FusionChildRunner = async (options) => {
|
|
393
|
+
calls.push(options);
|
|
394
|
+
if (options.stage === 'candidate') {
|
|
395
|
+
if (calls.filter((call) => call.stage === 'candidate').length === 3) releaseCandidates();
|
|
396
|
+
await candidatesStarted;
|
|
397
|
+
return childResult(options, `candidate-${String(options.slot)}`);
|
|
398
|
+
}
|
|
399
|
+
if (options.stage === 'evaluation') {
|
|
400
|
+
if (options.attempt === 1) return childResult(options, '{"not":"valid"}');
|
|
401
|
+
return childResult(options, JSON.stringify(evaluation()));
|
|
402
|
+
}
|
|
403
|
+
assert.match(options.userPrompt, /candidate-/);
|
|
404
|
+
assert.match(options.userPrompt, /synthesis_plan/);
|
|
405
|
+
return childResult(options, 'merged final');
|
|
406
|
+
};
|
|
407
|
+
const orchestrator = new FusionOrchestrator({
|
|
408
|
+
childRunner: runner,
|
|
409
|
+
randomBytes: () => Buffer.from([0, 0, 0, 0]),
|
|
410
|
+
now: () => new Date('2026-01-01T00:00:00.000Z'),
|
|
411
|
+
});
|
|
412
|
+
const canonical = canonicalInput();
|
|
413
|
+
const result = await orchestrator.run({
|
|
414
|
+
source: 'command',
|
|
415
|
+
cwd: root,
|
|
416
|
+
sessionId: 's1',
|
|
417
|
+
canonicalInput: canonical,
|
|
418
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
419
|
+
contextLedger: ledger,
|
|
420
|
+
config: defaultFusionModelConfig(),
|
|
421
|
+
models: models(),
|
|
422
|
+
});
|
|
423
|
+
assert.equal(result.mergedText, 'merged final');
|
|
424
|
+
assert.equal(result.details.evaluator_attempts, 2);
|
|
425
|
+
assert.equal(result.details.usage.totalTokens, 12);
|
|
426
|
+
assertCostClose(result.details.usage.cost, {
|
|
427
|
+
input: 0.06,
|
|
428
|
+
output: 0.12,
|
|
429
|
+
cacheRead: 0,
|
|
430
|
+
cacheWrite: 0,
|
|
431
|
+
total: 0.18,
|
|
432
|
+
});
|
|
433
|
+
assert.deepEqual(
|
|
434
|
+
calls.slice(0, 3).map((call) => call.slot),
|
|
435
|
+
[1, 2, 3],
|
|
436
|
+
);
|
|
437
|
+
assert.equal(calls[3]?.stage, 'evaluation');
|
|
438
|
+
assert.equal(calls[4]?.attempt, 2);
|
|
439
|
+
assert.equal(calls[5]?.stage, 'merge');
|
|
440
|
+
const defaultCandidates = calls.filter((call) => call.stage === 'candidate');
|
|
441
|
+
assert.deepEqual(
|
|
442
|
+
defaultCandidates.map((call) => call.capability),
|
|
443
|
+
['reason', 'reason', 'reason'],
|
|
444
|
+
);
|
|
445
|
+
for (const call of defaultCandidates) {
|
|
446
|
+
assert.equal(call.systemPrompt, FUSION_CANDIDATE_SYSTEM_PROMPT);
|
|
447
|
+
assert.equal(call.toolCallLogPath, undefined);
|
|
448
|
+
}
|
|
449
|
+
assert.deepEqual(
|
|
450
|
+
calls.filter((call) => call.stage !== 'candidate').map((call) => call.capability),
|
|
451
|
+
['reason', 'reason', 'reason'],
|
|
452
|
+
);
|
|
453
|
+
const candidatePrompts = calls
|
|
454
|
+
.filter((call) => call.stage === 'candidate')
|
|
455
|
+
.map((call) => call.userPrompt);
|
|
456
|
+
assert.equal(candidatePrompts[0], candidatePrompts[1]);
|
|
457
|
+
assert.equal(candidatePrompts[1], candidatePrompts[2]);
|
|
458
|
+
const manifestPath = join(root, result.details.artifact_dir, 'manifest.json');
|
|
459
|
+
const manifest = parseObject(await readFile(manifestPath, 'utf8'));
|
|
460
|
+
assert.equal(field(manifest, 'state'), 'completed');
|
|
461
|
+
const completedArtifacts = objectField(manifest, 'artifacts');
|
|
462
|
+
assert.equal(field(completedArtifacts, 'failure-summary.json'), undefined);
|
|
463
|
+
const attempts = field(manifest, 'attempts');
|
|
464
|
+
assert.ok(Array.isArray(attempts));
|
|
465
|
+
assert.equal(attempts.length, 6);
|
|
466
|
+
const map = field(manifest, 'anonymous_map');
|
|
467
|
+
assert.ok(typeof map === 'object' && map !== null);
|
|
468
|
+
assert.equal(field(map, 'A'), 2);
|
|
469
|
+
assert.equal(field(map, 'B'), 3);
|
|
470
|
+
assert.equal(field(map, 'C'), 1);
|
|
471
|
+
assert.equal(
|
|
472
|
+
await readFile(join(root, result.details.artifact_dir, 'merged.md'), 'utf8'),
|
|
473
|
+
'merged final',
|
|
474
|
+
);
|
|
475
|
+
} finally {
|
|
476
|
+
await rm(root, { recursive: true, force: true });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
void it('rejects validation accounting from non-validation evaluators instead of replacing the merge', async () => {
|
|
481
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-accounting-scope-'));
|
|
482
|
+
try {
|
|
483
|
+
const calls: RunPiChildOptions[] = [];
|
|
484
|
+
const runner: FusionChildRunner = async (options) => {
|
|
485
|
+
calls.push(options);
|
|
486
|
+
if (options.stage === 'candidate') return childResult(options, `candidate-${String(options.slot)}`);
|
|
487
|
+
if (options.stage === 'evaluation') {
|
|
488
|
+
const value =
|
|
489
|
+
options.attempt === 1
|
|
490
|
+
? {
|
|
491
|
+
...evaluation(),
|
|
492
|
+
validation_accounting: { findings: [], decisions: [], groups: [] },
|
|
493
|
+
}
|
|
494
|
+
: evaluation();
|
|
495
|
+
return childResult(options, JSON.stringify(value));
|
|
496
|
+
}
|
|
497
|
+
return childResult(options, 'normal merged answer');
|
|
498
|
+
};
|
|
499
|
+
const canonical = canonicalInput();
|
|
500
|
+
const result = await new FusionOrchestrator({ childRunner: runner }).run({
|
|
501
|
+
source: 'command',
|
|
502
|
+
cwd: root,
|
|
503
|
+
canonicalInput: canonical,
|
|
504
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
505
|
+
contextLedger: ledger,
|
|
506
|
+
config: defaultFusionModelConfig(),
|
|
507
|
+
models: models(),
|
|
508
|
+
});
|
|
509
|
+
assert.equal(result.mergedText, 'normal merged answer');
|
|
510
|
+
assert.equal(result.details.evaluator_attempts, 2);
|
|
511
|
+
assert.equal(
|
|
512
|
+
calls.filter((call) => call.stage === 'evaluation').length,
|
|
513
|
+
2,
|
|
514
|
+
'unexpected validation accounting must trigger schema repair',
|
|
515
|
+
);
|
|
516
|
+
} finally {
|
|
517
|
+
await rm(root, { recursive: true, force: true });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
void it('keeps evaluator and merger reasoning-only when candidates research', async () => {
|
|
522
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-orchestrator-capability-'));
|
|
523
|
+
try {
|
|
524
|
+
const calls: RunPiChildOptions[] = [];
|
|
525
|
+
const runner: FusionChildRunner = async (options) => {
|
|
526
|
+
calls.push(options);
|
|
527
|
+
if (options.stage === 'evaluation')
|
|
528
|
+
return childResult(options, JSON.stringify(evaluation()));
|
|
529
|
+
if (options.stage === 'merge') return childResult(options, 'merged final');
|
|
530
|
+
return childResult(options, `candidate-${String(options.slot)}`);
|
|
531
|
+
};
|
|
532
|
+
const orchestrator = new FusionOrchestrator({
|
|
533
|
+
childRunner: runner,
|
|
534
|
+
randomBytes: () => Buffer.from([0, 0, 0, 0]),
|
|
535
|
+
});
|
|
536
|
+
const built = buildFusionCleanTaskCanonicalInput({
|
|
537
|
+
cwd: root,
|
|
538
|
+
source: 'tool',
|
|
539
|
+
workflow: 'research',
|
|
540
|
+
request: 'research declared source',
|
|
541
|
+
declaredSources: [{ url: 'https://example.com/source', purpose: 'unit' }],
|
|
542
|
+
});
|
|
543
|
+
await orchestrator.run({
|
|
544
|
+
source: 'tool',
|
|
545
|
+
cwd: root,
|
|
546
|
+
canonicalInput: built.input,
|
|
547
|
+
canonicalInputSerialized: built.serialized,
|
|
548
|
+
config: defaultFusionModelConfig(),
|
|
549
|
+
models: models(),
|
|
550
|
+
profile: FUSION_RESEARCH_WORKFLOW,
|
|
551
|
+
});
|
|
552
|
+
const candidateCalls = calls.filter((call) => call.stage === 'candidate');
|
|
553
|
+
assert.deepEqual(
|
|
554
|
+
candidateCalls.map((call) => call.capability),
|
|
555
|
+
['research', 'research', 'research'],
|
|
556
|
+
);
|
|
557
|
+
assert.deepEqual(
|
|
558
|
+
candidateCalls.map((call) => call.systemPrompt),
|
|
559
|
+
[
|
|
560
|
+
FUSION_CANDIDATE_RESEARCH_SYSTEM_PROMPT,
|
|
561
|
+
FUSION_CANDIDATE_RESEARCH_SYSTEM_PROMPT,
|
|
562
|
+
FUSION_CANDIDATE_RESEARCH_SYSTEM_PROMPT,
|
|
563
|
+
],
|
|
564
|
+
);
|
|
565
|
+
assert.notEqual(candidateCalls[0]?.systemPrompt, FUSION_CANDIDATE_SYSTEM_PROMPT);
|
|
566
|
+
for (const [index, call] of candidateCalls.entries()) {
|
|
567
|
+
assert.match(
|
|
568
|
+
(call.toolCallLogPath ?? '').replaceAll('\\', '/'),
|
|
569
|
+
new RegExp(`candidate-${String(index + 1)}\\.attempt-1\\.tool-calls\\.jsonl$`),
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const evaluationCalls = calls.filter((call) => call.stage === 'evaluation');
|
|
573
|
+
const mergeCalls = calls.filter((call) => call.stage === 'merge');
|
|
574
|
+
assert.deepEqual(evaluationCalls.map((call) => call.capability), ['reason']);
|
|
575
|
+
assert.deepEqual(mergeCalls.map((call) => call.capability), ['reason']);
|
|
576
|
+
assert.deepEqual(evaluationCalls.map((call) => call.toolCallLogPath), [undefined]);
|
|
577
|
+
assert.deepEqual(mergeCalls.map((call) => call.toolCallLogPath), [undefined]);
|
|
578
|
+
for (const call of [...evaluationCalls, ...mergeCalls]) {
|
|
579
|
+
const argv = buildFusionPiChildArgv(
|
|
580
|
+
call.model,
|
|
581
|
+
call.systemPrompt,
|
|
582
|
+
'extension.js',
|
|
583
|
+
call.capability,
|
|
584
|
+
);
|
|
585
|
+
assert.ok(argv.includes('--no-tools'));
|
|
586
|
+
assert.equal(argv.includes('--no-builtin-tools'), false);
|
|
587
|
+
}
|
|
588
|
+
} finally {
|
|
589
|
+
await rm(root, { recursive: true, force: true });
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
void it('does not launch candidates when the workflow signal is already aborted', async () => {
|
|
594
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-orchestrator-abort-'));
|
|
595
|
+
try {
|
|
596
|
+
const calls: RunPiChildOptions[] = [];
|
|
597
|
+
const controller = new AbortController();
|
|
598
|
+
controller.abort();
|
|
599
|
+
const orchestrator = new FusionOrchestrator({
|
|
600
|
+
childRunner: async (options) => {
|
|
601
|
+
calls.push(options);
|
|
602
|
+
return childResult(options, 'unexpected');
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
const canonical = canonicalInput();
|
|
606
|
+
await assert.rejects(
|
|
607
|
+
orchestrator.run({
|
|
608
|
+
source: 'tool',
|
|
609
|
+
cwd: root,
|
|
610
|
+
canonicalInput: canonical,
|
|
611
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
612
|
+
contextLedger: ledger,
|
|
613
|
+
config: defaultFusionModelConfig(),
|
|
614
|
+
models: models(),
|
|
615
|
+
signal: controller.signal,
|
|
616
|
+
}),
|
|
617
|
+
/cancelled before launch/,
|
|
618
|
+
);
|
|
619
|
+
assert.equal(calls.length, 0);
|
|
620
|
+
} finally {
|
|
621
|
+
await rm(root, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
void it('writes one manifest-bound summary for a stored cancellation', async () => {
|
|
626
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-orchestrator-cancel-summary-'));
|
|
627
|
+
try {
|
|
628
|
+
const controller = new AbortController();
|
|
629
|
+
controller.abort();
|
|
630
|
+
const canonical = canonicalInput();
|
|
631
|
+
let thrown: unknown;
|
|
632
|
+
try {
|
|
633
|
+
await new FusionOrchestrator().run({
|
|
634
|
+
source: 'tool',
|
|
635
|
+
cwd: root,
|
|
636
|
+
canonicalInput: canonical,
|
|
637
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
638
|
+
contextLedger: ledger,
|
|
639
|
+
config: defaultFusionModelConfig(),
|
|
640
|
+
models: models(),
|
|
641
|
+
signal: controller.signal,
|
|
642
|
+
});
|
|
643
|
+
} catch (error) {
|
|
644
|
+
thrown = error;
|
|
645
|
+
}
|
|
646
|
+
assert.ok(thrown instanceof FusionError);
|
|
647
|
+
assert.equal(thrown.code, 'child_cancelled');
|
|
648
|
+
assert.ok(thrown.artifactDir);
|
|
649
|
+
const manifest = parseObject(
|
|
650
|
+
await readFile(join(root, thrown.artifactDir, 'manifest.json'), 'utf8'),
|
|
651
|
+
);
|
|
652
|
+
assert.equal(field(manifest, 'state'), 'cancelled');
|
|
653
|
+
assert.ok(field(objectField(manifest, 'artifacts'), 'failure-summary.json'));
|
|
654
|
+
} finally {
|
|
655
|
+
await rm(root, { recursive: true, force: true });
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
void it('cancels sibling candidates on first failure and never degrades to evaluation', async () => {
|
|
660
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-orchestrator-fail-'));
|
|
661
|
+
try {
|
|
662
|
+
const calls: RunPiChildOptions[] = [];
|
|
663
|
+
const runner: FusionChildRunner = async (options) => {
|
|
664
|
+
calls.push(options);
|
|
665
|
+
if (options.stage !== 'candidate') return childResult(options, 'unexpected later stage');
|
|
666
|
+
if (options.slot === 1) {
|
|
667
|
+
await waitForCalls(calls, 3);
|
|
668
|
+
throw new FusionError('candidate one failed', {
|
|
669
|
+
code: 'child_exit_failed',
|
|
670
|
+
stage: 'candidate',
|
|
671
|
+
slot: 1,
|
|
672
|
+
attempt: 1,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
const slot = options.slot;
|
|
676
|
+
if (slot === undefined) throw new Error('candidate slot is required');
|
|
677
|
+
await new Promise<void>((_resolve, reject) => {
|
|
678
|
+
const signal = options.signal;
|
|
679
|
+
if (signal?.aborted === true) {
|
|
680
|
+
reject(
|
|
681
|
+
new FusionError('candidate cancelled', {
|
|
682
|
+
code: 'child_cancelled',
|
|
683
|
+
stage: 'candidate',
|
|
684
|
+
slot,
|
|
685
|
+
attempt: 1,
|
|
686
|
+
}),
|
|
687
|
+
);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
signal?.addEventListener(
|
|
691
|
+
'abort',
|
|
692
|
+
() => {
|
|
693
|
+
reject(
|
|
694
|
+
new FusionError('candidate cancelled', {
|
|
695
|
+
code: 'child_cancelled',
|
|
696
|
+
stage: 'candidate',
|
|
697
|
+
slot,
|
|
698
|
+
attempt: 1,
|
|
699
|
+
}),
|
|
700
|
+
);
|
|
701
|
+
},
|
|
702
|
+
{ once: true },
|
|
703
|
+
);
|
|
704
|
+
});
|
|
705
|
+
return childResult(options, 'unreachable');
|
|
706
|
+
};
|
|
707
|
+
const orchestrator = new FusionOrchestrator({ childRunner: runner });
|
|
708
|
+
const canonical = canonicalInput();
|
|
709
|
+
await assert.rejects(
|
|
710
|
+
orchestrator.run({
|
|
711
|
+
source: 'tool',
|
|
712
|
+
cwd: root,
|
|
713
|
+
canonicalInput: canonical,
|
|
714
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
715
|
+
contextLedger: ledger,
|
|
716
|
+
config: defaultFusionModelConfig(),
|
|
717
|
+
models: models(),
|
|
718
|
+
}),
|
|
719
|
+
/candidate one failed/,
|
|
720
|
+
);
|
|
721
|
+
assert.equal(calls.filter((call) => call.stage === 'candidate').length, 3);
|
|
722
|
+
assert.equal(
|
|
723
|
+
calls.some((call) => call.stage === 'evaluation' || call.stage === 'merge'),
|
|
724
|
+
false,
|
|
725
|
+
);
|
|
726
|
+
} finally {
|
|
727
|
+
await rm(root, { recursive: true, force: true });
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
void it('aggregates observed usage exactly once for failed and cancelled candidate attempts', async () => {
|
|
732
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-orchestrator-candidate-usage-'));
|
|
733
|
+
try {
|
|
734
|
+
const calls: RunPiChildOptions[] = [];
|
|
735
|
+
const runner: FusionChildRunner = async (options) => {
|
|
736
|
+
calls.push(options);
|
|
737
|
+
if (options.stage !== 'candidate') return childResult(options, 'unexpected later stage');
|
|
738
|
+
if (options.slot === 1) {
|
|
739
|
+
await waitForCalls(calls, 3);
|
|
740
|
+
throw childRunError(options, 'candidate one failed after usage', 5);
|
|
741
|
+
}
|
|
742
|
+
const slot = options.slot;
|
|
743
|
+
if (slot === undefined) throw new Error('candidate slot is required');
|
|
744
|
+
await new Promise<void>((_resolve, reject) => {
|
|
745
|
+
const failCancelled = () => {
|
|
746
|
+
reject(
|
|
747
|
+
childRunError(
|
|
748
|
+
options,
|
|
749
|
+
`candidate ${String(slot)} cancelled after usage`,
|
|
750
|
+
slot === 2 ? 7 : 11,
|
|
751
|
+
'child_cancelled',
|
|
752
|
+
),
|
|
753
|
+
);
|
|
754
|
+
};
|
|
755
|
+
if (options.signal?.aborted === true) {
|
|
756
|
+
failCancelled();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
options.signal?.addEventListener('abort', failCancelled, { once: true });
|
|
760
|
+
});
|
|
761
|
+
return childResult(options, 'unreachable');
|
|
762
|
+
};
|
|
763
|
+
const manifest = await failedManifest(root, runner);
|
|
764
|
+
const attempts = attemptRecords(manifest);
|
|
765
|
+
assert.equal(attempts.length, 3);
|
|
766
|
+
assert.equal(
|
|
767
|
+
attempts.filter((attempt) => stringField(attempt, 'stage') === 'candidate').length,
|
|
768
|
+
3,
|
|
769
|
+
);
|
|
770
|
+
assert.equal(
|
|
771
|
+
attempts.filter((attempt) => stringField(attempt, 'status') === 'cancelled').length,
|
|
772
|
+
2,
|
|
773
|
+
);
|
|
774
|
+
assertManifestUsageEqualsAttemptSum(manifest);
|
|
775
|
+
assert.equal(numberField(objectField(manifest, 'usage'), 'totalTokens'), 23);
|
|
776
|
+
} finally {
|
|
777
|
+
await rm(root, { recursive: true, force: true });
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
void it('aggregates observed usage exactly once for failed evaluator, repair, and merger attempts', async () => {
|
|
782
|
+
const cases: Array<{ name: string; expectedTotalTokens: number; runner: FusionChildRunner }> = [
|
|
783
|
+
{
|
|
784
|
+
name: 'evaluator',
|
|
785
|
+
expectedTotalTokens: 19,
|
|
786
|
+
runner: async (options) => {
|
|
787
|
+
if (options.stage === 'candidate')
|
|
788
|
+
return childResult(options, `candidate-${String(options.slot)}`);
|
|
789
|
+
if (options.stage === 'evaluation')
|
|
790
|
+
throw childRunError(options, 'evaluator failed after usage', 13);
|
|
791
|
+
return childResult(options, 'unexpected merge');
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: 'repair',
|
|
796
|
+
expectedTotalTokens: 25,
|
|
797
|
+
runner: async (options) => {
|
|
798
|
+
if (options.stage === 'candidate')
|
|
799
|
+
return childResult(options, `candidate-${String(options.slot)}`);
|
|
800
|
+
if (options.stage === 'evaluation' && options.attempt === 1)
|
|
801
|
+
return childResult(options, '{"not":"valid"}');
|
|
802
|
+
if (options.stage === 'evaluation')
|
|
803
|
+
throw childRunError(options, 'repair failed after usage', 17);
|
|
804
|
+
return childResult(options, 'unexpected merge');
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
name: 'merger',
|
|
809
|
+
expectedTotalTokens: 27,
|
|
810
|
+
runner: async (options) => {
|
|
811
|
+
if (options.stage === 'candidate')
|
|
812
|
+
return childResult(options, `candidate-${String(options.slot)}`);
|
|
813
|
+
if (options.stage === 'evaluation')
|
|
814
|
+
return childResult(options, JSON.stringify(evaluation()));
|
|
815
|
+
throw childRunError(options, 'merge failed after usage', 19);
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
];
|
|
819
|
+
for (const item of cases) {
|
|
820
|
+
const root = await mkdtemp(join(tmpdir(), `pi-fusion-orchestrator-${item.name}-usage-`));
|
|
821
|
+
try {
|
|
822
|
+
const manifest = await failedManifest(root, item.runner);
|
|
823
|
+
assertManifestUsageEqualsAttemptSum(manifest);
|
|
824
|
+
assert.equal(
|
|
825
|
+
numberField(objectField(manifest, 'usage'), 'totalTokens'),
|
|
826
|
+
item.expectedTotalTokens,
|
|
827
|
+
item.name,
|
|
828
|
+
);
|
|
829
|
+
} finally {
|
|
830
|
+
await rm(root, { recursive: true, force: true });
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
void it('persists usage and bounded schema errors when both evaluator attempts fail', async () => {
|
|
836
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-orchestrator-eval-fail-'));
|
|
837
|
+
try {
|
|
838
|
+
const runner: FusionChildRunner = async (options) => {
|
|
839
|
+
if (options.stage === 'candidate')
|
|
840
|
+
return childResult(options, `candidate-${String(options.slot)}`);
|
|
841
|
+
if (options.stage === 'evaluation')
|
|
842
|
+
return childResult(
|
|
843
|
+
options,
|
|
844
|
+
JSON.stringify({ schema_version: FUSION_EVALUATION_SCHEMA_VERSION, bad: true }),
|
|
845
|
+
);
|
|
846
|
+
return childResult(options, 'unexpected merge');
|
|
847
|
+
};
|
|
848
|
+
const orchestrator = new FusionOrchestrator({ childRunner: runner });
|
|
849
|
+
const canonical = canonicalInput();
|
|
850
|
+
let thrown: unknown;
|
|
851
|
+
try {
|
|
852
|
+
await orchestrator.run({
|
|
853
|
+
source: 'command',
|
|
854
|
+
cwd: root,
|
|
855
|
+
canonicalInput: canonical,
|
|
856
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
857
|
+
contextLedger: ledger,
|
|
858
|
+
config: defaultFusionModelConfig(),
|
|
859
|
+
models: models(),
|
|
860
|
+
});
|
|
861
|
+
} catch (error) {
|
|
862
|
+
thrown = error;
|
|
863
|
+
}
|
|
864
|
+
assert.ok(thrown instanceof FusionError);
|
|
865
|
+
assert.equal(thrown.code, 'evaluation_invalid');
|
|
866
|
+
assert.match(thrown.message, /schema repair failed/);
|
|
867
|
+
assert.doesNotMatch(thrown.message, /unexpected merge/);
|
|
868
|
+
const artifactDir = thrown.artifactDir;
|
|
869
|
+
assert.ok(artifactDir);
|
|
870
|
+
const manifest = parseObject(
|
|
871
|
+
await readFile(join(root, artifactDir, 'manifest.json'), 'utf8'),
|
|
872
|
+
);
|
|
873
|
+
assert.equal(field(manifest, 'state'), 'failed');
|
|
874
|
+
const usageRecord = field(manifest, 'usage');
|
|
875
|
+
assert.ok(typeof usageRecord === 'object' && usageRecord !== null);
|
|
876
|
+
assert.equal(field(usageRecord, 'totalTokens'), 10);
|
|
877
|
+
const attempts = field(manifest, 'attempts');
|
|
878
|
+
assert.ok(Array.isArray(attempts));
|
|
879
|
+
assert.equal(attempts.length, 5);
|
|
880
|
+
for (const attempt of attempts) {
|
|
881
|
+
assert.ok(typeof attempt === 'object' && attempt !== null);
|
|
882
|
+
assert.ok(typeof field(attempt, 'usage') === 'object');
|
|
883
|
+
}
|
|
884
|
+
} finally {
|
|
885
|
+
await rm(root, { recursive: true, force: true });
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
void it('reports durable completed, failed, and not-started stage truth on late budget failures', async () => {
|
|
890
|
+
const cases: Array<{
|
|
891
|
+
name: string;
|
|
892
|
+
models: ResolvedFusionModels;
|
|
893
|
+
runner: FusionChildRunner;
|
|
894
|
+
blockedStage: 'evaluation' | 'merge';
|
|
895
|
+
expectedEvaluationStatus: 'not_started' | 'incomplete' | 'completed';
|
|
896
|
+
expectedUsage: number;
|
|
897
|
+
}> = [
|
|
898
|
+
{
|
|
899
|
+
name: 'evaluation launch',
|
|
900
|
+
models: {
|
|
901
|
+
...models(),
|
|
902
|
+
evaluator: resolved('p/eval', 180_000),
|
|
903
|
+
},
|
|
904
|
+
runner: (options) =>
|
|
905
|
+
Promise.resolve(
|
|
906
|
+
childResult(
|
|
907
|
+
options,
|
|
908
|
+
options.stage === 'candidate' ? 'c'.repeat(48_000) : 'unexpected downstream child',
|
|
909
|
+
),
|
|
910
|
+
),
|
|
911
|
+
blockedStage: 'evaluation',
|
|
912
|
+
expectedEvaluationStatus: 'not_started',
|
|
913
|
+
expectedUsage: 6,
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
name: 'evaluation repair launch',
|
|
917
|
+
models: {
|
|
918
|
+
...models(),
|
|
919
|
+
evaluator: resolved('p/eval', 100_000),
|
|
920
|
+
},
|
|
921
|
+
runner: (options) =>
|
|
922
|
+
Promise.resolve(
|
|
923
|
+
childResult(
|
|
924
|
+
options,
|
|
925
|
+
options.stage === 'candidate'
|
|
926
|
+
? 'candidate'
|
|
927
|
+
: options.stage === 'evaluation'
|
|
928
|
+
? 'x'.repeat(60_000)
|
|
929
|
+
: 'unexpected merge',
|
|
930
|
+
),
|
|
931
|
+
),
|
|
932
|
+
blockedStage: 'evaluation',
|
|
933
|
+
expectedEvaluationStatus: 'incomplete',
|
|
934
|
+
expectedUsage: 8,
|
|
935
|
+
},
|
|
936
|
+
{
|
|
937
|
+
name: 'merge launch',
|
|
938
|
+
models: {
|
|
939
|
+
...models(),
|
|
940
|
+
evaluator: resolved('p/eval', 300_000),
|
|
941
|
+
merger: resolved('p/merge', 180_000),
|
|
942
|
+
},
|
|
943
|
+
runner: (options) =>
|
|
944
|
+
Promise.resolve(
|
|
945
|
+
childResult(
|
|
946
|
+
options,
|
|
947
|
+
options.stage === 'candidate'
|
|
948
|
+
? 'c'.repeat(48_000)
|
|
949
|
+
: options.stage === 'evaluation'
|
|
950
|
+
? JSON.stringify(evaluation())
|
|
951
|
+
: 'unexpected merge',
|
|
952
|
+
),
|
|
953
|
+
),
|
|
954
|
+
blockedStage: 'merge',
|
|
955
|
+
expectedEvaluationStatus: 'completed',
|
|
956
|
+
expectedUsage: 8,
|
|
957
|
+
},
|
|
958
|
+
];
|
|
959
|
+
|
|
960
|
+
for (const item of cases) {
|
|
961
|
+
const root = await mkdtemp(
|
|
962
|
+
join(tmpdir(), `pi-fusion-progress-${item.name.replaceAll(' ', '-')}-`),
|
|
963
|
+
);
|
|
964
|
+
try {
|
|
965
|
+
const calls: RunPiChildOptions[] = [];
|
|
966
|
+
let failedProgressMessage = '';
|
|
967
|
+
const canonical = canonicalInput();
|
|
968
|
+
let thrown: unknown;
|
|
969
|
+
try {
|
|
970
|
+
await new FusionOrchestrator({
|
|
971
|
+
childRunner: async (options) => {
|
|
972
|
+
calls.push(options);
|
|
973
|
+
return item.runner(options);
|
|
974
|
+
},
|
|
975
|
+
}).run({
|
|
976
|
+
source: 'command',
|
|
977
|
+
cwd: root,
|
|
978
|
+
canonicalInput: canonical,
|
|
979
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
980
|
+
contextLedger: ledger,
|
|
981
|
+
config: defaultFusionModelConfig(),
|
|
982
|
+
models: item.models,
|
|
983
|
+
onProgress: (event) => {
|
|
984
|
+
if (event.type === 'failed') failedProgressMessage = event.error;
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
} catch (error) {
|
|
988
|
+
thrown = error;
|
|
989
|
+
}
|
|
990
|
+
assert.ok(thrown instanceof FusionError, item.name);
|
|
991
|
+
assert.equal(thrown.code, 'prompt_budget_exceeded_measured', item.name);
|
|
992
|
+
assert.doesNotMatch(thrown.message, /No child was created\./, item.name);
|
|
993
|
+
assert.match(
|
|
994
|
+
thrown.message,
|
|
995
|
+
item.blockedStage === 'merge'
|
|
996
|
+
? /The merger child was not created\./
|
|
997
|
+
: /The evaluator(?:-repair)? child was not created\./,
|
|
998
|
+
item.name,
|
|
999
|
+
);
|
|
1000
|
+
assert.equal(failedProgressMessage, thrown.message, item.name);
|
|
1001
|
+
const progress = thrown.runProgress;
|
|
1002
|
+
assert.ok(progress, item.name);
|
|
1003
|
+
assert.equal(progress.candidates.status, 'completed', item.name);
|
|
1004
|
+
assert.equal(progress.candidates.children_created, 3, item.name);
|
|
1005
|
+
assert.equal(progress.candidates.children_completed, 3, item.name);
|
|
1006
|
+
assert.equal(progress.evaluation.status, item.expectedEvaluationStatus, item.name);
|
|
1007
|
+
assert.equal(progress.merge.status, 'not_started', item.name);
|
|
1008
|
+
assert.equal(progress.usage_so_far.totalTokens, item.expectedUsage, item.name);
|
|
1009
|
+
assert.match(thrown.message, new RegExp(`totalTokens=${String(item.expectedUsage)}`));
|
|
1010
|
+
assert.equal(
|
|
1011
|
+
calls.some(
|
|
1012
|
+
(call) =>
|
|
1013
|
+
call.stage === item.blockedStage &&
|
|
1014
|
+
(item.name !== 'evaluation repair launch' || call.attempt === 2),
|
|
1015
|
+
),
|
|
1016
|
+
false,
|
|
1017
|
+
item.name,
|
|
1018
|
+
);
|
|
1019
|
+
assert.ok(thrown.artifactDir);
|
|
1020
|
+
const manifest = parseObject(
|
|
1021
|
+
await readFile(join(root, thrown.artifactDir, 'manifest.json'), 'utf8'),
|
|
1022
|
+
);
|
|
1023
|
+
assertManifestUsageEqualsAttemptSum(manifest);
|
|
1024
|
+
assert.equal(
|
|
1025
|
+
stringField(manifest, 'error'),
|
|
1026
|
+
thrown.message,
|
|
1027
|
+
'durable error and thrown error must carry identical run truth',
|
|
1028
|
+
);
|
|
1029
|
+
} finally {
|
|
1030
|
+
await rm(root, { recursive: true, force: true });
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
void it('keeps terminal publication when one subordinate summary write fails', async () => {
|
|
1036
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-summary-write-failure-'));
|
|
1037
|
+
try {
|
|
1038
|
+
let summaryWrites = 0;
|
|
1039
|
+
let reported = '';
|
|
1040
|
+
const orchestrator = new FusionOrchestrator({
|
|
1041
|
+
childRunner: async (options) => {
|
|
1042
|
+
throw new FusionError('candidate failed', {
|
|
1043
|
+
code: 'child_exit_failed',
|
|
1044
|
+
stage: 'candidate',
|
|
1045
|
+
attempt: options.attempt,
|
|
1046
|
+
childCreated: true,
|
|
1047
|
+
});
|
|
1048
|
+
},
|
|
1049
|
+
createArtifactStore: async (options) => {
|
|
1050
|
+
const store = await FusionArtifactStore.create(options);
|
|
1051
|
+
store.writeFailureSummary = async () => {
|
|
1052
|
+
summaryWrites += 1;
|
|
1053
|
+
throw new Error('summary disk failure');
|
|
1054
|
+
};
|
|
1055
|
+
return store;
|
|
1056
|
+
},
|
|
1057
|
+
});
|
|
1058
|
+
const canonical = canonicalInput();
|
|
1059
|
+
let thrown: unknown;
|
|
1060
|
+
try {
|
|
1061
|
+
await orchestrator.run({
|
|
1062
|
+
source: 'tool',
|
|
1063
|
+
cwd: root,
|
|
1064
|
+
canonicalInput: canonical,
|
|
1065
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
1066
|
+
contextLedger: ledger,
|
|
1067
|
+
config: defaultFusionModelConfig(),
|
|
1068
|
+
models: models(),
|
|
1069
|
+
onProgress: (event) => {
|
|
1070
|
+
if (event.type === 'failed') reported = event.error;
|
|
1071
|
+
},
|
|
1072
|
+
});
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
thrown = error;
|
|
1075
|
+
}
|
|
1076
|
+
assert.ok(thrown instanceof FusionError);
|
|
1077
|
+
assert.equal(thrown.code, 'child_exit_failed');
|
|
1078
|
+
assert.match(thrown.message, /failure-summary\.json unavailable after terminal publication/);
|
|
1079
|
+
assert.equal(reported, thrown.message);
|
|
1080
|
+
assert.equal(summaryWrites, 1);
|
|
1081
|
+
assert.ok(thrown.artifactDir);
|
|
1082
|
+
const manifest = parseObject(
|
|
1083
|
+
await readFile(join(root, thrown.artifactDir, 'manifest.json'), 'utf8'),
|
|
1084
|
+
);
|
|
1085
|
+
assert.equal(field(manifest, 'state'), 'failed');
|
|
1086
|
+
assert.equal(field(objectField(manifest, 'artifacts'), 'failure-summary.json'), undefined);
|
|
1087
|
+
assert.doesNotMatch(stringField(manifest, 'error'), /failure-summary\.json unavailable/);
|
|
1088
|
+
} finally {
|
|
1089
|
+
await rm(root, { recursive: true, force: true });
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
void it('preserves writeError failure semantics and never attempts a summary afterward', async () => {
|
|
1094
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-write-error-failure-'));
|
|
1095
|
+
try {
|
|
1096
|
+
let summaryWrites = 0;
|
|
1097
|
+
const orchestrator = new FusionOrchestrator({
|
|
1098
|
+
childRunner: async (options) => {
|
|
1099
|
+
throw new FusionError('candidate failed', {
|
|
1100
|
+
code: 'child_exit_failed',
|
|
1101
|
+
stage: 'candidate',
|
|
1102
|
+
attempt: options.attempt,
|
|
1103
|
+
childCreated: true,
|
|
1104
|
+
});
|
|
1105
|
+
},
|
|
1106
|
+
createArtifactStore: async (options) => {
|
|
1107
|
+
const store = await FusionArtifactStore.create(options);
|
|
1108
|
+
store.writeError = async () => {
|
|
1109
|
+
throw new Error('writeError persistence failure');
|
|
1110
|
+
};
|
|
1111
|
+
store.writeFailureSummary = async () => {
|
|
1112
|
+
summaryWrites += 1;
|
|
1113
|
+
throw new Error('must not be called');
|
|
1114
|
+
};
|
|
1115
|
+
return store;
|
|
1116
|
+
},
|
|
1117
|
+
});
|
|
1118
|
+
const canonical = canonicalInput();
|
|
1119
|
+
await assert.rejects(
|
|
1120
|
+
orchestrator.run({
|
|
1121
|
+
source: 'tool',
|
|
1122
|
+
cwd: root,
|
|
1123
|
+
canonicalInput: canonical,
|
|
1124
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
1125
|
+
contextLedger: ledger,
|
|
1126
|
+
config: defaultFusionModelConfig(),
|
|
1127
|
+
models: models(),
|
|
1128
|
+
}),
|
|
1129
|
+
/candidate failed; additionally failed to write terminal fusion artifacts: writeError persistence failure/,
|
|
1130
|
+
);
|
|
1131
|
+
assert.equal(summaryWrites, 0);
|
|
1132
|
+
} finally {
|
|
1133
|
+
await rm(root, { recursive: true, force: true });
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
void it('retries only transient pre-creation spawn failures and supports concurrent workflows', async () => {
|
|
1138
|
+
const root = await mkdtemp(join(tmpdir(), 'pi-fusion-orchestrator-concurrent-'));
|
|
1139
|
+
try {
|
|
1140
|
+
const calls: RunPiChildOptions[] = [];
|
|
1141
|
+
let transientThrown = false;
|
|
1142
|
+
const runner: FusionChildRunner = async (options) => {
|
|
1143
|
+
calls.push(options);
|
|
1144
|
+
if (!transientThrown && options.stage === 'candidate' && options.slot === 1) {
|
|
1145
|
+
transientThrown = true;
|
|
1146
|
+
throw new FusionError('spawn EAGAIN', {
|
|
1147
|
+
code: 'child_spawn_failed',
|
|
1148
|
+
stage: 'candidate',
|
|
1149
|
+
slot: 1,
|
|
1150
|
+
attempt: 1,
|
|
1151
|
+
transient: true,
|
|
1152
|
+
childCreated: false,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
if (options.stage === 'candidate')
|
|
1156
|
+
return childResult(options, `candidate-${String(options.slot)}`);
|
|
1157
|
+
if (options.stage === 'evaluation')
|
|
1158
|
+
return childResult(options, JSON.stringify(evaluation()));
|
|
1159
|
+
return childResult(options, `merged ${options.cwd.endsWith('a') ? 'a' : 'b'}`);
|
|
1160
|
+
};
|
|
1161
|
+
const orchestrator = new FusionOrchestrator({ childRunner: runner });
|
|
1162
|
+
const canonical = canonicalInput();
|
|
1163
|
+
const firstCwd = join(root, 'a');
|
|
1164
|
+
const secondCwd = join(root, 'b');
|
|
1165
|
+
await Promise.all([mkdir(firstCwd), mkdir(secondCwd)]);
|
|
1166
|
+
const [first, second] = await Promise.all([
|
|
1167
|
+
orchestrator.run({
|
|
1168
|
+
source: 'command',
|
|
1169
|
+
cwd: firstCwd,
|
|
1170
|
+
canonicalInput: canonical,
|
|
1171
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
1172
|
+
contextLedger: ledger,
|
|
1173
|
+
config: defaultFusionModelConfig(),
|
|
1174
|
+
models: models(),
|
|
1175
|
+
}),
|
|
1176
|
+
orchestrator.run({
|
|
1177
|
+
source: 'command',
|
|
1178
|
+
cwd: secondCwd,
|
|
1179
|
+
canonicalInput: canonical,
|
|
1180
|
+
canonicalInputSerialized: JSON.stringify(canonical),
|
|
1181
|
+
contextLedger: ledger,
|
|
1182
|
+
config: defaultFusionModelConfig(),
|
|
1183
|
+
models: models(),
|
|
1184
|
+
}),
|
|
1185
|
+
]);
|
|
1186
|
+
assert.equal(first.mergedText, 'merged a');
|
|
1187
|
+
assert.equal(second.mergedText, 'merged b');
|
|
1188
|
+
assert.equal(calls.filter((call) => call.stage === 'candidate' && call.slot === 1).length, 3);
|
|
1189
|
+
assert.equal(calls.filter((call) => call.stage === 'merge').length, 2);
|
|
1190
|
+
} finally {
|
|
1191
|
+
await rm(root, { recursive: true, force: true });
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
});
|