pi-background-tasks 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,20 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { closeSync, fsyncSync, openSync, renameSync } from 'node:fs';
3
- import { chmod, mkdir, open, rm } from 'node:fs/promises';
4
- import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
2
+ import { chmod, mkdir } from 'node:fs/promises';
3
+ import { basename, isAbsolute, join, relative, sep } from 'node:path';
5
4
  import { canonicalJson, sha256Buffer } from '../attested-pi-run.js';
6
5
  import { sanitizePathSegment } from '../common.js';
6
+ import { replaceFileDurable } from '../durable-fs.js';
7
7
  import {
8
8
  EMPTY_FUSION_USAGE,
9
9
  FUSION_MANIFEST_SCHEMA_VERSION,
10
10
  FusionError,
11
+ cloneFusionUsage,
11
12
  type FusionArtifactManifest,
12
13
  type FusionArtifactRef,
13
14
  type FusionAttemptArtifactRecord,
15
+ type FusionBudgetPlanV1,
14
16
  type FusionCandidateId,
17
+ type FusionContextOmissionLedgerV1,
15
18
  type FusionChildRunResult,
16
19
  type FusionModelConfigV1,
17
20
  type FusionSource,
@@ -83,18 +86,6 @@ function makeRunId(): string {
83
86
  return `f${randomBytes(16).toString('hex')}`;
84
87
  }
85
88
 
86
- function usageClone(usage: FusionUsage): FusionUsage {
87
- const out: FusionUsage = {
88
- input: usage.input,
89
- output: usage.output,
90
- cacheRead: usage.cacheRead,
91
- cacheWrite: usage.cacheWrite,
92
- totalTokens: usage.totalTokens,
93
- };
94
- if (usage.costTotal !== undefined) out.costTotal = usage.costTotal;
95
- return out;
96
- }
97
-
98
89
  function modelsForManifest(models: ResolvedFusionModels): MutableFusionArtifactManifest['models'] {
99
90
  const first = models.candidates[0].qualifiedId;
100
91
  const second = models.candidates[1].qualifiedId;
@@ -129,16 +120,6 @@ function canTransition(from: FusionState, to: FusionState): boolean {
129
120
  return NEXT_STATES[from].includes(to);
130
121
  }
131
122
 
132
- function fsyncDirectory(path: string): void {
133
- if (process.platform === 'win32') return;
134
- const fd = openSync(path, 'r');
135
- try {
136
- fsyncSync(fd);
137
- } finally {
138
- closeSync(fd);
139
- }
140
- }
141
-
142
123
  function pathInside(parent: string, child: string): boolean {
143
124
  const rel = relative(parent, child);
144
125
  return (
@@ -150,34 +131,12 @@ function errorForArtifact(message: string): FusionError {
150
131
  return new FusionError(message, { code: 'artifact_error', childCreated: false });
151
132
  }
152
133
 
153
- async function writeTempFile(absPath: string, data: Buffer | string): Promise<void> {
154
- const handle = await open(absPath, 'wx', 0o600);
155
- try {
156
- await handle.writeFile(data);
157
- await handle.sync();
158
- } finally {
159
- await handle.close();
160
- }
161
- }
162
-
163
134
  async function writePrivateFile(
164
135
  absPath: string,
165
136
  data: Buffer | string,
166
137
  ): Promise<FusionArtifactRef> {
167
- const dir = dirname(absPath);
168
- const tmp = join(
169
- dir,
170
- `.${basename(absPath)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
171
- );
172
138
  const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
173
- try {
174
- await writeTempFile(tmp, data);
175
- renameSync(tmp, absPath);
176
- fsyncDirectory(dir);
177
- } catch (error) {
178
- await rm(tmp, { force: true });
179
- throw error;
180
- }
139
+ await replaceFileDurable(absPath, data);
181
140
  return { path: basename(absPath), byte_length: bytes.length, sha256: sha256Buffer(bytes) };
182
141
  }
183
142
 
@@ -196,7 +155,7 @@ function publicManifest(manifest: MutableFusionArtifactManifest): FusionArtifact
196
155
  cwd: manifest.cwd,
197
156
  config: manifest.config,
198
157
  models: manifest.models,
199
- usage: usageClone(manifest.usage),
158
+ usage: cloneFusionUsage(manifest.usage),
200
159
  attempts: [...manifest.attempts],
201
160
  artifacts: { ...manifest.artifacts },
202
161
  };
@@ -260,7 +219,7 @@ export class FusionArtifactStore {
260
219
  cwd: options.cwd,
261
220
  config: options.config,
262
221
  models: modelsForManifest(options.models),
263
- usage: usageClone(EMPTY_FUSION_USAGE),
222
+ usage: cloneFusionUsage(EMPTY_FUSION_USAGE),
264
223
  attempts: [],
265
224
  artifacts: {},
266
225
  };
@@ -316,7 +275,7 @@ export class FusionArtifactStore {
316
275
 
317
276
  async setUsage(usage: FusionUsage): Promise<void> {
318
277
  await this.updateManifest((manifest) => {
319
- manifest.usage = usageClone(usage);
278
+ manifest.usage = cloneFusionUsage(usage);
320
279
  });
321
280
  }
322
281
 
@@ -324,6 +283,20 @@ export class FusionArtifactStore {
324
283
  await this.writeArtifact('canonical-input.json', serialized);
325
284
  }
326
285
 
286
+ /**
287
+ * Complete, source-ordered ledger of every omitted conversation event. Kept in
288
+ * a separate artifact so canonical input carries only compact run receipts
289
+ * while the full omission accounting stays locally auditable.
290
+ */
291
+ async writeContextLedger(ledger: FusionContextOmissionLedgerV1): Promise<void> {
292
+ await this.writeArtifact('context-omission-ledger.json', canonicalJson(ledger));
293
+ }
294
+
295
+ /** Route capacities and the pre-candidate whole-workflow feasibility decision. */
296
+ async writeBudgetPlan(plan: FusionBudgetPlanV1): Promise<void> {
297
+ await this.writeArtifact('budget-plan.json', canonicalJson(plan));
298
+ }
299
+
327
300
  async writeBlindCandidates(serialized: string): Promise<void> {
328
301
  await this.writeArtifact('blind-candidates.json', serialized);
329
302
  }
@@ -372,7 +345,7 @@ export class FusionArtifactStore {
372
345
  provider: input.result.provider,
373
346
  model: input.result.model,
374
347
  qualifiedId: input.result.qualifiedId,
375
- usage: usageClone(input.result.usage),
348
+ usage: cloneFusionUsage(input.result.usage),
376
349
  };
377
350
  if (input.result.slot !== undefined) record.slot = input.result.slot;
378
351
  manifest.attempts.push(record);
@@ -403,12 +376,11 @@ export class FusionArtifactStore {
403
376
  response_path: responseRef.path,
404
377
  error: input.error,
405
378
  };
406
- if (partialResponseRef !== undefined)
407
- record.partial_response_path = partialResponseRef.path;
379
+ if (partialResponseRef !== undefined) record.partial_response_path = partialResponseRef.path;
408
380
  if (input.provider !== undefined) record.provider = input.provider;
409
381
  if (input.model !== undefined) record.model = input.model;
410
382
  if (input.qualifiedId !== undefined) record.qualifiedId = input.qualifiedId;
411
- if (input.usage !== undefined) record.usage = usageClone(input.usage);
383
+ if (input.usage !== undefined) record.usage = cloneFusionUsage(input.usage);
412
384
  if (input.slot !== undefined) record.slot = input.slot;
413
385
  manifest.attempts.push(record);
414
386
  });
@@ -0,0 +1,372 @@
1
+ import {
2
+ FUSION_BUDGET_PLAN_SCHEMA_VERSION,
3
+ FusionError,
4
+ type FusionBudgetErrorDetail,
5
+ type FusionBudgetPlanV1,
6
+ type FusionBudgetPolicyDescriptor,
7
+ type FusionBudgetStage,
8
+ type FusionRouteCapacity,
9
+ type FusionStage,
10
+ type FusionStageBudgetPlanEntry,
11
+ type ResolvedFusionModel,
12
+ type ResolvedFusionModels,
13
+ } from './types.js';
14
+
15
+ /**
16
+ * Conservative lower bound on UTF-8 bytes per input token.
17
+ *
18
+ * Token upper bound = ceil(utf8Bytes / FUSION_BYTES_PER_TOKEN_DIVISOR).
19
+ *
20
+ * Across 159 real large Fusion prompts (50 KB–1.4 MB) recorded in
21
+ * `.pi/fusion/**\/manifest.json`, the smallest observed ratio was 3.552 bytes
22
+ * per reported input token. A divisor of 2 therefore leaves roughly a 1.7x
23
+ * margin against the densest real prompt and still bounds pathological input
24
+ * such as dense CJK (3 UTF-8 bytes producing at most ~1.5 tokens) or long runs
25
+ * of punctuation. It is not an estimate of typical usage; it is a ceiling.
26
+ */
27
+ export const FUSION_BYTES_PER_TOKEN_DIVISOR = 2;
28
+
29
+ /**
30
+ * Enforced maximum size of one child's response, measured in **JSON-rendered
31
+ * transfer bytes** — the bytes the response actually contributes when a later
32
+ * stage embeds it, escaping included.
33
+ *
34
+ * Measuring the rendered form rather than the raw form removes the need to
35
+ * guess an escaping factor: quotes, backslashes, and newlines expand 2x and
36
+ * control characters up to 6x, so a raw-byte contract would not bound the
37
+ * embedded size. `assertChildOutputWithinContract` rejects a response that
38
+ * exceeds its stage bound, so a later stage can never be handed more embedded
39
+ * text than is budgeted here. Nothing is truncated to fit: an oversized
40
+ * response is a loud failure.
41
+ *
42
+ * Sized above the largest real responses recorded across `.pi/fusion`:
43
+ * candidate 45,434 B, evaluator 54,829 B, merged 56,846 B.
44
+ */
45
+ export const FUSION_CANDIDATE_MAX_OUTPUT_BYTES = 48 * 1024;
46
+ export const FUSION_EVALUATION_MAX_OUTPUT_BYTES = 64 * 1024;
47
+ export const FUSION_MERGE_MAX_OUTPUT_BYTES = 64 * 1024;
48
+
49
+ /** `boundedEvaluationErrors` caps repair diagnostics far below this. */
50
+ export const FUSION_DIAGNOSTICS_MAX_BYTES = 8 * 1024;
51
+
52
+ /**
53
+ * Tokens reserved so a child can emit a response up to its byte contract.
54
+ *
55
+ * Derived from the largest output contract through the same conservative
56
+ * byte-to-token conversion used to measure prompts, so it cannot be understated.
57
+ */
58
+ export const FUSION_RESERVED_OUTPUT_TOKENS = Math.ceil(
59
+ FUSION_MERGE_MAX_OUTPUT_BYTES / FUSION_BYTES_PER_TOKEN_DIVISOR,
60
+ );
61
+
62
+ /**
63
+ * Upper bound on Pi/provider framing that is not represented in the prompt
64
+ * bytes Fusion renders: the child system prompt is passed via argv (counted
65
+ * separately below), but chat framing, tool-free scaffolding, and provider
66
+ * envelope overhead are not visible to this package.
67
+ */
68
+ export const FUSION_FRAMING_RESERVE_TOKENS = 4_096;
69
+
70
+ /** Additional margin for provider-side tokenizer differences. */
71
+ export const FUSION_SAFETY_RESERVE_TOKENS = 4_096;
72
+
73
+ /**
74
+ * Fixed structural overhead of the blind-candidate and repair wrappers: schema
75
+ * version strings, candidate_id keys, JSON punctuation, and the evaluation
76
+ * object's own keys. These are constant-size, not content-dependent; 16 KiB is
77
+ * an order of magnitude above the real wrapper size.
78
+ */
79
+ export const FUSION_WRAPPER_OVERHEAD_BYTES = 16 * 1024;
80
+
81
+ /**
82
+ * Bytes of downstream growth the canonical input must leave room for.
83
+ *
84
+ * The widest stage prompt is the evaluation repair, which embeds the canonical
85
+ * input plus three candidate answers, the invalid evaluator output, and bounded
86
+ * validation errors. The merge prompt is strictly smaller.
87
+ *
88
+ * Because the output contracts above are enforced against JSON-rendered bytes,
89
+ * this is an exact sum rather than a raw size inflated by an estimated escaping
90
+ * factor. No content can expand past it, and the exact rendered prompt is still
91
+ * re-measured before every spawn as defence in depth.
92
+ */
93
+ export const FUSION_DOWNSTREAM_RESERVE_BYTES =
94
+ 3 * FUSION_CANDIDATE_MAX_OUTPUT_BYTES +
95
+ FUSION_EVALUATION_MAX_OUTPUT_BYTES +
96
+ FUSION_DIAGNOSTICS_MAX_BYTES +
97
+ FUSION_WRAPPER_OVERHEAD_BYTES;
98
+
99
+ /**
100
+ * Tokens withheld from the canonical input for that growth.
101
+ *
102
+ * This converts the reserve through the same byte-to-token function used to
103
+ * measure prompts. Reserving output *tokens* directly would understate the cost
104
+ * of re-embedding those bytes by the ratio between a provider's real
105
+ * tokenization and this conservative ceiling.
106
+ */
107
+ export const FUSION_DOWNSTREAM_RESERVE_TOKENS = Math.ceil(
108
+ FUSION_DOWNSTREAM_RESERVE_BYTES / FUSION_BYTES_PER_TOKEN_DIVISOR,
109
+ );
110
+
111
+ /** Minimum usable canonical-input room a configured route must still offer. */
112
+ export const FUSION_MIN_CANONICAL_INPUT_TOKENS = 8_192;
113
+
114
+ /**
115
+ * Smallest context window that can host the complete workflow under this policy.
116
+ *
117
+ * This is a consequence of uniformly conservative accounting, not a preference
118
+ * for large models: the downstream reserve, the output reserve, framing, safety,
119
+ * and a usable amount of canonical input must all fit. Routes below this are
120
+ * rejected at configuration time with an actionable error rather than being
121
+ * silently accepted and failing later at the provider.
122
+ */
123
+ export const FUSION_MIN_CONTEXT_WINDOW_TOKENS =
124
+ FUSION_DOWNSTREAM_RESERVE_TOKENS +
125
+ FUSION_MIN_CANONICAL_INPUT_TOKENS +
126
+ FUSION_RESERVED_OUTPUT_TOKENS +
127
+ FUSION_FRAMING_RESERVE_TOKENS +
128
+ FUSION_SAFETY_RESERVE_TOKENS;
129
+
130
+ export const FUSION_BUDGET_POLICY: FusionBudgetPolicyDescriptor = {
131
+ id: 'fusion-budget-policy-v1',
132
+ bytes_per_token_divisor: FUSION_BYTES_PER_TOKEN_DIVISOR,
133
+ reserved_output_tokens: FUSION_RESERVED_OUTPUT_TOKENS,
134
+ framing_reserve_tokens: FUSION_FRAMING_RESERVE_TOKENS,
135
+ safety_reserve_tokens: FUSION_SAFETY_RESERVE_TOKENS,
136
+ downstream_reserve_bytes: FUSION_DOWNSTREAM_RESERVE_BYTES,
137
+ downstream_reserve_tokens: FUSION_DOWNSTREAM_RESERVE_TOKENS,
138
+ };
139
+
140
+ const REMEDIATION: readonly string[] = Object.freeze([
141
+ 'Start a fresh Pi conversation, or run Fusion earlier in the session.',
142
+ 'Provide a shorter, self-contained fusion_brainstorm prompt.',
143
+ 'Restate only the required prior findings as visible conversation text.',
144
+ 'Configure a larger-context model for the limiting Fusion slot via /fusion-models.',
145
+ ]);
146
+
147
+ export function fusionTokenUpperBound(utf8Bytes: number): number {
148
+ return Math.ceil(utf8Bytes / FUSION_BYTES_PER_TOKEN_DIVISOR);
149
+ }
150
+
151
+ /** Enforced response-size contract for one stage, in UTF-8 bytes. */
152
+ export function fusionOutputContractBytes(stage: FusionStage): number {
153
+ if (stage === 'candidate') return FUSION_CANDIDATE_MAX_OUTPUT_BYTES;
154
+ if (stage === 'evaluation') return FUSION_EVALUATION_MAX_OUTPUT_BYTES;
155
+ return FUSION_MERGE_MAX_OUTPUT_BYTES;
156
+ }
157
+
158
+ /**
159
+ * Reject a child response larger than its stage contract.
160
+ *
161
+ * This is what makes the downstream reserve a guarantee rather than a hope: a
162
+ * later stage can never embed more bytes than were budgeted. The oversized text
163
+ * is never sliced and never forwarded; the run fails loudly instead.
164
+ */
165
+ export function assertChildOutputWithinContract(stage: FusionStage, text: string): void {
166
+ // Measure what the response costs once embedded, escaping included, so the
167
+ // downstream reserve is an exact bound rather than an estimate.
168
+ const bytes = Buffer.byteLength(JSON.stringify(text), 'utf8');
169
+ const allowed = fusionOutputContractBytes(stage);
170
+ if (bytes <= allowed) return;
171
+ throw new FusionError(
172
+ `fusion ${stage} response is ${String(bytes)} JSON-rendered bytes, exceeding the ${String(allowed)}-byte output contract for that stage; the response is preserved in the run artifacts and is not forwarded or truncated`,
173
+ { code: 'child_output_cap', stage, childCreated: true },
174
+ );
175
+ }
176
+
177
+ function requirePositiveContextWindow(model: ResolvedFusionModel, role: string): number {
178
+ const value = model.contextWindow;
179
+ if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
180
+ throw new FusionError(
181
+ `fusion ${role} route ${model.qualifiedId} has no usable context window capacity`,
182
+ { code: 'model_capacity_unknown', childCreated: false },
183
+ );
184
+ }
185
+ return value;
186
+ }
187
+
188
+ function routeCapacity(
189
+ model: ResolvedFusionModel,
190
+ role: FusionRouteCapacity['role'],
191
+ ): FusionRouteCapacity {
192
+ const contextWindow = requirePositiveContextWindow(model, role);
193
+ const allowed =
194
+ contextWindow -
195
+ FUSION_RESERVED_OUTPUT_TOKENS -
196
+ FUSION_FRAMING_RESERVE_TOKENS -
197
+ FUSION_SAFETY_RESERVE_TOKENS;
198
+ // The route must hold the downstream reserve plus a usable amount of canonical
199
+ // input, otherwise the configured panel can never complete a workflow.
200
+ if (allowed < FUSION_DOWNSTREAM_RESERVE_TOKENS + FUSION_MIN_CANONICAL_INPUT_TOKENS) {
201
+ throw new FusionError(
202
+ `fusion ${role} route ${model.qualifiedId} has a ${String(contextWindow)}-token context window, but the Fusion workflow requires at least ${String(FUSION_MIN_CONTEXT_WINDOW_TOKENS)} tokens per configured route: ${String(FUSION_RESERVED_OUTPUT_TOKENS)} output + ${String(FUSION_FRAMING_RESERVE_TOKENS)} framing + ${String(FUSION_SAFETY_RESERVE_TOKENS)} safety + ${String(FUSION_DOWNSTREAM_RESERVE_TOKENS)} for evaluator/repair/merger expansion + ${String(FUSION_MIN_CANONICAL_INPUT_TOKENS)} usable canonical input. Choose a larger-context model for this slot with /fusion-models.`,
203
+ { code: 'model_capacity_unknown', childCreated: false },
204
+ );
205
+ }
206
+ return {
207
+ role,
208
+ provider: model.provider,
209
+ model: model.model,
210
+ qualified_id: model.qualifiedId,
211
+ context_window_tokens: contextWindow,
212
+ reserved_output_tokens: FUSION_RESERVED_OUTPUT_TOKENS,
213
+ framing_reserve_tokens: FUSION_FRAMING_RESERVE_TOKENS,
214
+ safety_reserve_tokens: FUSION_SAFETY_RESERVE_TOKENS,
215
+ allowed_input_tokens: allowed,
216
+ };
217
+ }
218
+
219
+ export function fusionRouteCapacities(models: ResolvedFusionModels): readonly FusionRouteCapacity[] {
220
+ return [
221
+ routeCapacity(models.candidates[0], 'candidate-1'),
222
+ routeCapacity(models.candidates[1], 'candidate-2'),
223
+ routeCapacity(models.candidates[2], 'candidate-3'),
224
+ routeCapacity(models.evaluator, 'evaluator'),
225
+ routeCapacity(models.merger, 'merger'),
226
+ ];
227
+ }
228
+
229
+ /**
230
+ * The limiting route is the configured participant with the smallest input
231
+ * budget — never the largest-context model. Ties resolve to the first route in
232
+ * declaration order so the plan is deterministic.
233
+ */
234
+ export function fusionLimitingRoute(
235
+ routes: readonly FusionRouteCapacity[],
236
+ ): FusionRouteCapacity {
237
+ let limiting: FusionRouteCapacity | undefined;
238
+ for (const route of routes) {
239
+ if (limiting === undefined || route.allowed_input_tokens < limiting.allowed_input_tokens) {
240
+ limiting = route;
241
+ }
242
+ }
243
+ if (limiting === undefined) {
244
+ throw new FusionError('fusion budget planning received no configured routes', {
245
+ code: 'model_capacity_unknown',
246
+ childCreated: false,
247
+ });
248
+ }
249
+ return limiting;
250
+ }
251
+
252
+ export class FusionBudget {
253
+ readonly routes: readonly FusionRouteCapacity[];
254
+ readonly limiting: FusionRouteCapacity;
255
+ private readonly contextPolicyId: string;
256
+
257
+ constructor(models: ResolvedFusionModels, contextPolicyId: string) {
258
+ this.routes = fusionRouteCapacities(models);
259
+ this.limiting = fusionLimitingRoute(this.routes);
260
+ this.contextPolicyId = contextPolicyId;
261
+ }
262
+
263
+ /** Full input budget of the limiting route, in tokens. */
264
+ get allowedInputTokens(): number {
265
+ return this.limiting.allowed_input_tokens;
266
+ }
267
+
268
+ /**
269
+ * Budget for the canonical input alone, holding back the derived reserve that
270
+ * downstream evaluator/repair/merger expansion provably needs.
271
+ */
272
+ get allowedCanonicalInputTokens(): number {
273
+ return this.allowedInputTokens - FUSION_DOWNSTREAM_RESERVE_TOKENS;
274
+ }
275
+
276
+ private failure(
277
+ stage: FusionBudgetStage,
278
+ measurementKind: FusionBudgetErrorDetail['measurement_kind'],
279
+ utf8Bytes: number,
280
+ allowedTokens: number,
281
+ label: string,
282
+ ): FusionError {
283
+ const tokens = fusionTokenUpperBound(utf8Bytes);
284
+ const budget: FusionBudgetErrorDetail = {
285
+ budget_stage: stage,
286
+ measurement_kind: measurementKind,
287
+ measured_utf8_bytes: utf8Bytes,
288
+ measured_input_tokens_upper_bound: tokens,
289
+ allowed_input_tokens: allowedTokens,
290
+ limiting_model: {
291
+ provider: this.limiting.provider,
292
+ model: this.limiting.model,
293
+ qualified_id: this.limiting.qualified_id,
294
+ context_window_tokens: this.limiting.context_window_tokens,
295
+ },
296
+ context_policy_id: this.contextPolicyId,
297
+ remediation: REMEDIATION,
298
+ };
299
+ const message =
300
+ `fusion ${label} exceeds the safe input budget before child creation: ` +
301
+ `measured ${String(utf8Bytes)} UTF-8 bytes (<= ${String(tokens)} input tokens) ` +
302
+ `against ${String(allowedTokens)} allowed input tokens for the limiting configured model ` +
303
+ `${this.limiting.qualified_id} (context window ${String(this.limiting.context_window_tokens)} tokens, ` +
304
+ `reserving ${String(this.limiting.reserved_output_tokens)} output + ` +
305
+ `${String(this.limiting.framing_reserve_tokens)} framing + ` +
306
+ `${String(this.limiting.safety_reserve_tokens)} safety tokens). ` +
307
+ `Remediation: ${REMEDIATION.join(' ')}`;
308
+ return new FusionError(message, {
309
+ code: 'prompt_budget_exceeded',
310
+ childCreated: false,
311
+ budget,
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Whole-DAG feasibility check run before the first candidate spawns. Proving the
317
+ * canonical input fits within its reserved share proves every downstream stage
318
+ * has room for its expansion, because the reserved remainder exceeds the
319
+ * largest possible candidate/evaluation growth by construction.
320
+ */
321
+ assertBaseContext(canonicalInputSerialized: string, systemPromptBytes: number): void {
322
+ const bytes = Buffer.byteLength(canonicalInputSerialized, 'utf8') + systemPromptBytes;
323
+ if (fusionTokenUpperBound(bytes) > this.allowedCanonicalInputTokens) {
324
+ throw this.failure(
325
+ 'candidate',
326
+ 'worst_case_envelope',
327
+ bytes,
328
+ this.allowedCanonicalInputTokens,
329
+ 'conversation projection plus request',
330
+ );
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Exact preflight for one rendered stage prompt, measured on the same bytes
336
+ * that will be written to the child's stdin and persisted as the artifact.
337
+ */
338
+ assertStagePrompt(stage: FusionBudgetStage, systemPrompt: string, userPrompt: string): void {
339
+ const bytes =
340
+ Buffer.byteLength(systemPrompt, 'utf8') + Buffer.byteLength(userPrompt, 'utf8');
341
+ if (fusionTokenUpperBound(bytes) > this.allowedInputTokens) {
342
+ throw this.failure(
343
+ stage,
344
+ 'rendered_prompt',
345
+ bytes,
346
+ this.allowedInputTokens,
347
+ `${stage} prompt`,
348
+ );
349
+ }
350
+ }
351
+
352
+ plan(canonicalInputSerialized: string, systemPromptBytes: number): FusionBudgetPlanV1 {
353
+ const bytes = Buffer.byteLength(canonicalInputSerialized, 'utf8') + systemPromptBytes;
354
+ const tokens = fusionTokenUpperBound(bytes);
355
+ const base: FusionStageBudgetPlanEntry = {
356
+ budget_stage: 'candidate',
357
+ measurement_kind: 'worst_case_envelope',
358
+ measured_utf8_bytes: bytes,
359
+ measured_input_tokens_upper_bound: tokens,
360
+ allowed_input_tokens: this.allowedCanonicalInputTokens,
361
+ limiting_qualified_id: this.limiting.qualified_id,
362
+ slack_tokens: this.allowedCanonicalInputTokens - tokens,
363
+ };
364
+ return {
365
+ schema_version: FUSION_BUDGET_PLAN_SCHEMA_VERSION,
366
+ policy: FUSION_BUDGET_POLICY,
367
+ routes: this.routes,
368
+ limiting_qualified_id: this.limiting.qualified_id,
369
+ base_context: base,
370
+ };
371
+ }
372
+ }
@@ -1,10 +1,10 @@
1
- import { createHash, randomBytes } from 'node:crypto';
2
- import { closeSync, fsyncSync, openSync, renameSync } from 'node:fs';
3
- import { chmod, mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, mkdir, open, readFile, rm } from 'node:fs/promises';
4
3
  import { basename, dirname, join } from 'node:path';
5
4
  import { getAgentDir } from '@earendil-works/pi-coding-agent';
6
5
  import type { Api, Model } from '@earendil-works/pi-ai';
7
6
  import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
7
+ import { replaceFileDurable } from '../durable-fs.js';
8
8
  import {
9
9
  FUSION_MODEL_CONFIG_SCHEMA_VERSION,
10
10
  FusionError,
@@ -260,25 +260,6 @@ export function resolveFusionModels(input: ResolveFusionModelsInput): ResolvedFu
260
260
  };
261
261
  }
262
262
 
263
- async function fsyncFile(path: string): Promise<void> {
264
- const handle = await open(path, 'r');
265
- try {
266
- await handle.sync();
267
- } finally {
268
- await handle.close();
269
- }
270
- }
271
-
272
- async function fsyncDirectory(path: string): Promise<void> {
273
- if (process.platform === 'win32') return;
274
- const fd = openSync(path, 'r');
275
- try {
276
- fsyncSync(fd);
277
- } finally {
278
- closeSync(fd);
279
- }
280
- }
281
-
282
263
  async function delay(ms: number): Promise<void> {
283
264
  await new Promise((resolve) => setTimeout(resolve, ms));
284
265
  }
@@ -348,20 +329,7 @@ export async function saveFusionModelConfig(
348
329
  childCreated: false,
349
330
  });
350
331
  }
351
- const tmp = join(
352
- dir,
353
- `.${basename(path)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
354
- );
355
- const text = prettyConfig(config);
356
- try {
357
- await writeFile(tmp, text, { encoding: 'utf8', mode: 0o600 });
358
- await fsyncFile(tmp);
359
- renameSync(tmp, path);
360
- await fsyncDirectory(dir);
361
- } catch (error) {
362
- await rm(tmp, { force: true });
363
- throw error;
364
- }
332
+ await replaceFileDurable(path, prettyConfig(config));
365
333
  return revisionForPath(path);
366
334
  });
367
335
  }