pi-background-tasks 0.7.3 → 0.7.6

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,5 +1,6 @@
1
1
  import { statSync, type WriteStream } from 'node:fs';
2
2
  import { open } from 'node:fs/promises';
3
+ import { extname, isAbsolute, join, win32 } from 'node:path';
3
4
  import { DEFAULT_MAX_BYTES } from '@earendil-works/pi-coding-agent';
4
5
  import type { BackgroundTaskChildProcess } from './registry.js';
5
6
 
@@ -57,6 +58,7 @@ export interface BgTaskSnapshot {
57
58
  tokenUsage?: TaskTokenUsage | undefined;
58
59
  toolUsage?: TaskToolUsage | undefined;
59
60
  model?: string | undefined;
61
+ telemetryUnavailableReason?: string | undefined;
60
62
  attestationPath?: string | undefined;
61
63
  }
62
64
 
@@ -84,6 +86,7 @@ export interface BgTask extends Omit<BgTaskSnapshot, 'name'> {
84
86
  timeoutHandle?: NodeJS.Timeout | undefined;
85
87
  killKind?: KillKind | undefined;
86
88
  killSignalSent?: boolean | undefined;
89
+ killEscalationTimer?: NodeJS.Timeout | undefined;
87
90
  capExceeded?: boolean | undefined;
88
91
  finalized?: boolean | undefined;
89
92
  terminalPublished?: boolean | undefined;
@@ -96,6 +99,7 @@ export interface BgTask extends Omit<BgTaskSnapshot, 'name'> {
96
99
  telemetryWrapped?: boolean | undefined;
97
100
  /** Partial trailing stdout line held between chunks while reconstructing wrapped-agent control lines. */
98
101
  agentStdoutBuffer?: string | undefined;
102
+ telemetryUnavailableReason?: string | undefined;
99
103
  attestationPath?: string | undefined;
100
104
  attestedPi?: AttestedPiTaskFiles | undefined;
101
105
  metadataWriteChain?: Promise<void> | undefined;
@@ -524,20 +528,131 @@ export function shellQuote(value: string): string {
524
528
  return `'${value.replace(/'/g, `'"'"'`)}'`;
525
529
  }
526
530
 
531
+ export type ShellDialect = 'cmd' | 'posix';
532
+
533
+ export interface ShellInvocation {
534
+ shell: string;
535
+ args: string[];
536
+ dialect: ShellDialect;
537
+ windowsVerbatimArguments: boolean;
538
+ }
539
+
540
+ export class ShellInvocationError extends Error {
541
+ readonly code = 'pi_bg_shell_invalid';
542
+
543
+ constructor(message: string) {
544
+ super(`pi_bg_shell_invalid: ${message}`);
545
+ this.name = 'ShellInvocationError';
546
+ }
547
+ }
548
+
549
+ type ShellCandidateResult =
550
+ | { readonly found: true }
551
+ | { readonly found: false; readonly diagnostic: string };
552
+
553
+ function failShellInvocation(message: string): never {
554
+ throw new ShellInvocationError(message);
555
+ }
556
+
557
+ function shellErrorMessage(error: unknown): string {
558
+ return error instanceof Error ? error.message : String(error);
559
+ }
560
+
561
+ function isWindowsExecutablePath(path: string): boolean {
562
+ const extension = extname(path).toLowerCase();
563
+ return extension === '.exe' || extension === '.com';
564
+ }
565
+
566
+ function validateWindowsShellPath(path: string, label: string): string {
567
+ if (path.length === 0) failShellInvocation(`${label} is empty`);
568
+ if (!isAbsolute(path) && !win32.isAbsolute(path)) {
569
+ failShellInvocation(`${label} must be an absolute path`);
570
+ }
571
+ if (!isWindowsExecutablePath(path)) {
572
+ failShellInvocation(`${label} must point to a .exe or .com file`);
573
+ }
574
+ let stats: ReturnType<typeof statSync>;
575
+ try {
576
+ stats = statSync(path);
577
+ } catch (error) {
578
+ failShellInvocation(`${label} stat failed: ${shellErrorMessage(error)}`);
579
+ }
580
+ if (!stats.isFile()) failShellInvocation(`${label} must point to a regular file`);
581
+ return path;
582
+ }
583
+
584
+ function inspectWindowsShellCandidate(path: string): ShellCandidateResult {
585
+ if (!isWindowsExecutablePath(path)) {
586
+ return { found: false, diagnostic: `${path} is not a .exe or .com path` };
587
+ }
588
+ try {
589
+ const stats = statSync(path);
590
+ if (stats.isFile()) return { found: true };
591
+ return { found: false, diagnostic: `${path} is not a regular file` };
592
+ } catch (error) {
593
+ return { found: false, diagnostic: `${path}: ${shellErrorMessage(error)}` };
594
+ }
595
+ }
596
+
597
+ function windowsPathValue(env: NodeJS.ProcessEnv): string {
598
+ return env['PATH'] ?? env['Path'] ?? env['path'] ?? '';
599
+ }
600
+
601
+ function resolveWindowsBash(env: NodeJS.ProcessEnv): string {
602
+ const pathValue = windowsPathValue(env);
603
+ const diagnostics: string[] = [];
604
+ for (const dir of pathValue.split(';').filter((entry) => entry.length > 0)) {
605
+ for (const name of ['bash.exe', 'bash.com']) {
606
+ const candidate = join(dir, name);
607
+ const result = inspectWindowsShellCandidate(candidate);
608
+ if (result.found) return candidate;
609
+ diagnostics.push(result.diagnostic);
610
+ }
611
+ }
612
+ const suffix = diagnostics.length > 0 ? `: ${diagnostics.join('; ')}` : '';
613
+ failShellInvocation(`PI_BG_SHELL=bash could not resolve bash.exe or bash.com on PATH${suffix}`);
614
+ }
615
+
616
+ function cmdShellInvocation(command: string, shell: string): ShellInvocation {
617
+ return {
618
+ shell,
619
+ args: ['/d', '/s', '/c', `"${command}"`],
620
+ dialect: 'cmd',
621
+ windowsVerbatimArguments: true,
622
+ };
623
+ }
624
+
625
+ function posixShellInvocation(command: string, shell: string): ShellInvocation {
626
+ return { shell, args: ['-c', command], dialect: 'posix', windowsVerbatimArguments: false };
627
+ }
628
+
527
629
  export function shellInvocation(
528
630
  command: string,
529
631
  platform: NodeJS.Platform = process.platform,
530
632
  env: NodeJS.ProcessEnv = process.env,
531
- ): { shell: string; args: string[] } {
532
- if (platform === 'win32') {
633
+ ): ShellInvocation {
634
+ if (platform !== 'win32') {
635
+ const shell = env['SHELL'];
636
+ return posixShellInvocation(command, shell && shell.length > 0 ? shell : '/bin/sh');
637
+ }
638
+
639
+ const requestedShell = env['PI_BG_SHELL'];
640
+ const requestedPath = env['PI_BG_SHELL_PATH'];
641
+ if (requestedShell === undefined) {
642
+ if (requestedPath !== undefined) failShellInvocation('PI_BG_SHELL_PATH requires PI_BG_SHELL');
533
643
  const comSpec = env['ComSpec'];
534
- return {
535
- shell: comSpec && comSpec.length > 0 ? comSpec : 'cmd.exe',
536
- args: ['/d', '/s', '/c', command],
537
- };
644
+ return cmdShellInvocation(command, comSpec && comSpec.length > 0 ? comSpec : 'cmd.exe');
645
+ }
646
+ if (requestedShell !== 'cmd' && requestedShell !== 'bash') {
647
+ failShellInvocation('PI_BG_SHELL must be exactly cmd or bash');
648
+ }
649
+ const explicitPath =
650
+ requestedPath !== undefined ? validateWindowsShellPath(requestedPath, 'PI_BG_SHELL_PATH') : undefined;
651
+ if (requestedShell === 'cmd') {
652
+ const comSpec = env['ComSpec'];
653
+ return cmdShellInvocation(command, explicitPath ?? (comSpec && comSpec.length > 0 ? comSpec : 'cmd.exe'));
538
654
  }
539
- const shell = env['SHELL'];
540
- return { shell: shell && shell.length > 0 ? shell : '/bin/sh', args: ['-c', command] };
655
+ return posixShellInvocation(command, explicitPath ?? resolveWindowsBash(env));
541
656
  }
542
657
 
543
658
  export function normalizeMaxBytes(value: unknown, fallback = DEFAULT_LOG_BYTES): number {
@@ -570,6 +685,7 @@ export function snapshot(task: BgTask): BgTaskSnapshot {
570
685
  tokenUsage: task.tokenUsage,
571
686
  toolUsage: task.toolUsage,
572
687
  model: task.model,
688
+ telemetryUnavailableReason: task.telemetryUnavailableReason,
573
689
  attestationPath: task.attestationPath,
574
690
  };
575
691
  }
@@ -0,0 +1,400 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { open as nodeOpen, rename as nodeRename, rm as nodeRm } from 'node:fs/promises';
3
+ import { basename, dirname, join } from 'node:path';
4
+
5
+ export type DurableData = Buffer | string;
6
+ export type DurableWriteFlag = 'w' | 'wx';
7
+ export type DurableOperation =
8
+ | 'open_file'
9
+ | 'write_file'
10
+ | 'sync_file'
11
+ | 'close_file'
12
+ | 'rename_file'
13
+ | 'remove_temp'
14
+ | 'open_directory'
15
+ | 'sync_directory'
16
+ | 'close_directory';
17
+
18
+ export interface DurableFailure {
19
+ operation: DurableOperation;
20
+ path: string;
21
+ cause: unknown;
22
+ }
23
+
24
+ export interface DurableWritableHandle {
25
+ writeFile(data: DurableData): Promise<void>;
26
+ sync(): Promise<void>;
27
+ close(): Promise<void>;
28
+ }
29
+
30
+ export interface DurableDirectoryHandle {
31
+ sync(): Promise<void>;
32
+ close(): Promise<void>;
33
+ }
34
+
35
+ export interface DurableFileOperations {
36
+ platform: NodeJS.Platform;
37
+ openWritable(
38
+ path: string,
39
+ flag: DurableWriteFlag,
40
+ mode?: number,
41
+ ): Promise<DurableWritableHandle>;
42
+ openDirectory(path: string): Promise<DurableDirectoryHandle>;
43
+ rename(source: string, target: string): Promise<void>;
44
+ remove(path: string): Promise<void>;
45
+ temporaryPath(target: string): string;
46
+ }
47
+
48
+ export interface DurableFileWriter {
49
+ write(path: string, data: DurableData): Promise<void>;
50
+ replace(path: string, data: DurableData): Promise<void>;
51
+ }
52
+
53
+ interface DurableErrorInput {
54
+ operation: DurableOperation;
55
+ path: string;
56
+ cause: unknown;
57
+ cleanupFailures: readonly DurableFailure[];
58
+ targetPath: string | undefined;
59
+ temporaryPath: string | undefined;
60
+ renameCompleted: boolean;
61
+ }
62
+
63
+ interface WritableSequenceResult {
64
+ primaryFailure: DurableFailure | undefined;
65
+ cleanupFailures: DurableFailure[];
66
+ }
67
+
68
+ export class DurableFileError extends Error {
69
+ readonly operation: DurableOperation;
70
+ readonly path: string;
71
+ readonly nativeCode: string | undefined;
72
+ readonly primaryCause: unknown;
73
+ readonly cleanupFailures: readonly DurableFailure[];
74
+ readonly targetPath: string | undefined;
75
+ readonly temporaryPath: string | undefined;
76
+ readonly renameCompleted: boolean;
77
+
78
+ constructor(input: {
79
+ operation: DurableOperation;
80
+ path: string;
81
+ cause: unknown;
82
+ cleanupFailures: readonly DurableFailure[];
83
+ targetPath: string | undefined;
84
+ temporaryPath: string | undefined;
85
+ renameCompleted: boolean;
86
+ }) {
87
+ super(formatDurableMessage(input));
88
+ this.name = 'DurableFileError';
89
+ this.operation = input.operation;
90
+ this.path = input.path;
91
+ this.nativeCode = nativeCodeForCause(input.cause);
92
+ this.primaryCause = input.cause;
93
+ this.cleanupFailures = [...input.cleanupFailures];
94
+ this.targetPath = input.targetPath;
95
+ this.temporaryPath = input.temporaryPath;
96
+ this.renameCompleted = input.renameCompleted;
97
+ }
98
+ }
99
+
100
+ function failure(operation: DurableOperation, path: string, cause: unknown): DurableFailure {
101
+ return { operation, path, cause };
102
+ }
103
+
104
+ function durableError(input: DurableErrorInput): DurableFileError {
105
+ return new DurableFileError(input);
106
+ }
107
+
108
+ function nativeCodeForCause(cause: unknown): string | undefined {
109
+ if (typeof cause !== 'object' || cause === null) return undefined;
110
+ const code = Reflect.get(cause, 'code');
111
+ return typeof code === 'string' ? code : undefined;
112
+ }
113
+
114
+ function describeCause(cause: unknown): string {
115
+ if (cause instanceof Error) {
116
+ const message = cause.message.length > 0 ? cause.message : String(cause);
117
+ return `${cause.name}: ${message}`;
118
+ }
119
+ return String(cause);
120
+ }
121
+
122
+ function formatFailureForMessage(entry: DurableFailure): string {
123
+ const code = nativeCodeForCause(entry.cause);
124
+ const codeText = code === undefined ? '' : ` (code ${code})`;
125
+ return `${entry.operation} ${entry.path}${codeText}: ${describeCause(entry.cause)}`;
126
+ }
127
+
128
+ function formatDurableMessage(input: DurableErrorInput): string {
129
+ const primary = formatFailureForMessage({
130
+ operation: input.operation,
131
+ path: input.path,
132
+ cause: input.cause,
133
+ });
134
+ const segments = [`Durable file operation failed: ${primary}`];
135
+ if (input.targetPath !== undefined) segments.push(`target: ${input.targetPath}`);
136
+ if (input.temporaryPath !== undefined) segments.push(`temporary: ${input.temporaryPath}`);
137
+ if (input.renameCompleted) {
138
+ const target = input.targetPath ?? input.path;
139
+ segments.push(`Replacement may already be visible at ${target}.`);
140
+ }
141
+ if (input.cleanupFailures.length > 0) {
142
+ const cleanupText = input.cleanupFailures.map(formatFailureForMessage).join('; ');
143
+ segments.push(`Cleanup failures: ${cleanupText}`);
144
+ }
145
+ return segments.join(' ');
146
+ }
147
+
148
+ async function writeSyncClose(
149
+ handle: DurableWritableHandle,
150
+ path: string,
151
+ data: DurableData,
152
+ ): Promise<WritableSequenceResult> {
153
+ const cleanupFailures: DurableFailure[] = [];
154
+ let primaryFailure: DurableFailure | undefined;
155
+ try {
156
+ await handle.writeFile(data);
157
+ } catch (error) {
158
+ primaryFailure = failure('write_file', path, error);
159
+ }
160
+ if (primaryFailure === undefined) {
161
+ try {
162
+ await handle.sync();
163
+ } catch (error) {
164
+ primaryFailure = failure('sync_file', path, error);
165
+ }
166
+ }
167
+ try {
168
+ await handle.close();
169
+ } catch (error) {
170
+ const closeFailure = failure('close_file', path, error);
171
+ if (primaryFailure === undefined) primaryFailure = closeFailure;
172
+ else cleanupFailures.push(closeFailure);
173
+ }
174
+ return { primaryFailure, cleanupFailures };
175
+ }
176
+
177
+ async function syncCloseDirectory(
178
+ handle: DurableDirectoryHandle,
179
+ path: string,
180
+ ): Promise<WritableSequenceResult> {
181
+ const cleanupFailures: DurableFailure[] = [];
182
+ let primaryFailure: DurableFailure | undefined;
183
+ try {
184
+ await handle.sync();
185
+ } catch (error) {
186
+ primaryFailure = failure('sync_directory', path, error);
187
+ }
188
+ try {
189
+ await handle.close();
190
+ } catch (error) {
191
+ const closeFailure = failure('close_directory', path, error);
192
+ if (primaryFailure === undefined) primaryFailure = closeFailure;
193
+ else cleanupFailures.push(closeFailure);
194
+ }
195
+ return { primaryFailure, cleanupFailures };
196
+ }
197
+
198
+ function throwDurable(
199
+ primaryFailure: DurableFailure,
200
+ cleanupFailures: readonly DurableFailure[],
201
+ targetPath: string | undefined,
202
+ temporaryPath: string | undefined,
203
+ renameCompleted: boolean,
204
+ ): never {
205
+ throw durableError({
206
+ operation: primaryFailure.operation,
207
+ path: primaryFailure.path,
208
+ cause: primaryFailure.cause,
209
+ cleanupFailures,
210
+ targetPath,
211
+ temporaryPath,
212
+ renameCompleted,
213
+ });
214
+ }
215
+
216
+ async function removeTemporary(
217
+ operations: DurableFileOperations,
218
+ temporaryPath: string,
219
+ cleanupFailures: DurableFailure[],
220
+ ): Promise<void> {
221
+ try {
222
+ await operations.remove(temporaryPath);
223
+ } catch (error) {
224
+ cleanupFailures.push(failure('remove_temp', temporaryPath, error));
225
+ }
226
+ }
227
+
228
+ async function writeWithOperations(
229
+ operations: DurableFileOperations,
230
+ path: string,
231
+ data: DurableData,
232
+ ): Promise<void> {
233
+ let handle: DurableWritableHandle;
234
+ try {
235
+ handle = await operations.openWritable(path, 'w');
236
+ } catch (error) {
237
+ throwDurable(failure('open_file', path, error), [], path, undefined, false);
238
+ }
239
+ const result = await writeSyncClose(handle, path, data);
240
+ if (result.primaryFailure !== undefined) {
241
+ throwDurable(result.primaryFailure, result.cleanupFailures, path, undefined, false);
242
+ }
243
+ }
244
+
245
+ async function syncDirectoryAfterRename(
246
+ operations: DurableFileOperations,
247
+ targetPath: string,
248
+ temporaryPath: string,
249
+ ): Promise<void> {
250
+ if (operations.platform === 'win32') return;
251
+ const directoryPath = dirname(targetPath);
252
+ let handle: DurableDirectoryHandle;
253
+ try {
254
+ handle = await operations.openDirectory(directoryPath);
255
+ } catch (error) {
256
+ throwDurable(failure('open_directory', directoryPath, error), [], targetPath, temporaryPath, true);
257
+ }
258
+ const result = await syncCloseDirectory(handle, directoryPath);
259
+ if (result.primaryFailure !== undefined) {
260
+ throwDurable(result.primaryFailure, result.cleanupFailures, targetPath, temporaryPath, true);
261
+ }
262
+ }
263
+
264
+ async function replaceWithOperations(
265
+ operations: DurableFileOperations,
266
+ path: string,
267
+ data: DurableData,
268
+ ): Promise<void> {
269
+ const temporaryPath = operations.temporaryPath(path);
270
+ let handle: DurableWritableHandle;
271
+ try {
272
+ handle = await operations.openWritable(temporaryPath, 'wx', 0o600);
273
+ } catch (error) {
274
+ throwDurable(failure('open_file', temporaryPath, error), [], path, temporaryPath, false);
275
+ }
276
+ const writeResult = await writeSyncClose(handle, temporaryPath, data);
277
+ if (writeResult.primaryFailure !== undefined) {
278
+ await removeTemporary(operations, temporaryPath, writeResult.cleanupFailures);
279
+ throwDurable(writeResult.primaryFailure, writeResult.cleanupFailures, path, temporaryPath, false);
280
+ }
281
+ const renameError = await renameWithWindowsContention(operations, temporaryPath, path);
282
+ if (renameError !== undefined) {
283
+ const cleanupFailures: DurableFailure[] = [];
284
+ await removeTemporary(operations, temporaryPath, cleanupFailures);
285
+ throwDurable(
286
+ failure('rename_file', path, renameError),
287
+ cleanupFailures,
288
+ path,
289
+ temporaryPath,
290
+ false,
291
+ );
292
+ }
293
+ await syncDirectoryAfterRename(operations, path, temporaryPath);
294
+ }
295
+
296
+ /**
297
+ * Windows sharing-violation codes for a replace-rename.
298
+ *
299
+ * POSIX `rename(2)` atomically replaces the target. Windows `MoveFileEx` fails
300
+ * with a sharing violation when another process momentarily holds the target
301
+ * open, including transient scanners, indexers, and concurrent writers. The
302
+ * operation is legitimate and simply needs to be reattempted.
303
+ */
304
+ const WINDOWS_RENAME_CONTENTION_CODES: ReadonlySet<string> = new Set([
305
+ 'EPERM',
306
+ 'EACCES',
307
+ 'EBUSY',
308
+ ]);
309
+
310
+ const WINDOWS_RENAME_ATTEMPTS = 10;
311
+ const WINDOWS_RENAME_RETRY_DELAY_MS = 20;
312
+
313
+ function delay(ms: number): Promise<void> {
314
+ return new Promise((resolve) => {
315
+ setTimeout(resolve, ms);
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Rename the temporary file over the target, retrying only Windows sharing
321
+ * violations.
322
+ *
323
+ * This is a bounded retry of a transient OS condition, not a fallback: the
324
+ * final failure is still returned and raised loudly, no alternative write path
325
+ * is taken, and no other platform or error code is retried.
326
+ *
327
+ * Returns the failure cause, or undefined on success.
328
+ */
329
+ async function renameWithWindowsContention(
330
+ operations: DurableFileOperations,
331
+ temporaryPath: string,
332
+ targetPath: string,
333
+ ): Promise<unknown> {
334
+ const attempts = operations.platform === 'win32' ? WINDOWS_RENAME_ATTEMPTS : 1;
335
+ let lastError: unknown;
336
+ for (let attempt = 1; attempt <= attempts; attempt++) {
337
+ try {
338
+ await operations.rename(temporaryPath, targetPath);
339
+ return undefined;
340
+ } catch (error) {
341
+ lastError = error;
342
+ const code = nativeCodeForCause(error);
343
+ const retryable =
344
+ operations.platform === 'win32' &&
345
+ code !== undefined &&
346
+ WINDOWS_RENAME_CONTENTION_CODES.has(code);
347
+ if (!retryable || attempt === attempts) return error;
348
+ await delay(WINDOWS_RENAME_RETRY_DELAY_MS * attempt);
349
+ }
350
+ }
351
+ return lastError;
352
+ }
353
+
354
+ function temporaryPathForTarget(target: string): string {
355
+ return join(
356
+ dirname(target),
357
+ `.${basename(target)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
358
+ );
359
+ }
360
+
361
+ const nodeOperations: DurableFileOperations = {
362
+ platform: process.platform,
363
+ async openWritable(path: string, flag: DurableWriteFlag, mode?: number) {
364
+ if (mode === undefined) return nodeOpen(path, flag);
365
+ return nodeOpen(path, flag, mode);
366
+ },
367
+ async openDirectory(path: string) {
368
+ return nodeOpen(path, 'r');
369
+ },
370
+ async rename(source: string, target: string) {
371
+ await nodeRename(source, target);
372
+ },
373
+ async remove(path: string) {
374
+ // `force` ignores a missing path only. Permission and other failures still
375
+ // throw and are surfaced as `remove_temp` cleanup failures.
376
+ await nodeRm(path, { force: true });
377
+ },
378
+ temporaryPath: temporaryPathForTarget,
379
+ };
380
+
381
+ const defaultWriter = createDurableFileWriter(nodeOperations);
382
+
383
+ export function createDurableFileWriter(operations: DurableFileOperations): DurableFileWriter {
384
+ return {
385
+ async write(path: string, data: DurableData): Promise<void> {
386
+ await writeWithOperations(operations, path, data);
387
+ },
388
+ async replace(path: string, data: DurableData): Promise<void> {
389
+ await replaceWithOperations(operations, path, data);
390
+ },
391
+ };
392
+ }
393
+
394
+ export async function writeFileDurable(path: string, data: DurableData): Promise<void> {
395
+ await defaultWriter.write(path, data);
396
+ }
397
+
398
+ export async function replaceFileDurable(path: string, data: DurableData): Promise<void> {
399
+ await defaultWriter.replace(path, data);
400
+ }
@@ -1,9 +1,9 @@
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,
@@ -12,7 +12,9 @@ import {
12
12
  type FusionArtifactManifest,
13
13
  type FusionArtifactRef,
14
14
  type FusionAttemptArtifactRecord,
15
+ type FusionBudgetPlanV1,
15
16
  type FusionCandidateId,
17
+ type FusionContextOmissionLedgerV2,
16
18
  type FusionChildRunResult,
17
19
  type FusionModelConfigV1,
18
20
  type FusionSource,
@@ -118,16 +120,6 @@ function canTransition(from: FusionState, to: FusionState): boolean {
118
120
  return NEXT_STATES[from].includes(to);
119
121
  }
120
122
 
121
- function fsyncDirectory(path: string): void {
122
- if (process.platform === 'win32') return;
123
- const fd = openSync(path, 'r');
124
- try {
125
- fsyncSync(fd);
126
- } finally {
127
- closeSync(fd);
128
- }
129
- }
130
-
131
123
  function pathInside(parent: string, child: string): boolean {
132
124
  const rel = relative(parent, child);
133
125
  return (
@@ -139,34 +131,12 @@ function errorForArtifact(message: string): FusionError {
139
131
  return new FusionError(message, { code: 'artifact_error', childCreated: false });
140
132
  }
141
133
 
142
- async function writeTempFile(absPath: string, data: Buffer | string): Promise<void> {
143
- const handle = await open(absPath, 'wx', 0o600);
144
- try {
145
- await handle.writeFile(data);
146
- await handle.sync();
147
- } finally {
148
- await handle.close();
149
- }
150
- }
151
-
152
134
  async function writePrivateFile(
153
135
  absPath: string,
154
136
  data: Buffer | string,
155
137
  ): Promise<FusionArtifactRef> {
156
- const dir = dirname(absPath);
157
- const tmp = join(
158
- dir,
159
- `.${basename(absPath)}.${String(process.pid)}.${randomBytes(6).toString('hex')}.tmp`,
160
- );
161
138
  const bytes = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
162
- try {
163
- await writeTempFile(tmp, data);
164
- renameSync(tmp, absPath);
165
- fsyncDirectory(dir);
166
- } catch (error) {
167
- await rm(tmp, { force: true });
168
- throw error;
169
- }
139
+ await replaceFileDurable(absPath, data);
170
140
  return { path: basename(absPath), byte_length: bytes.length, sha256: sha256Buffer(bytes) };
171
141
  }
172
142
 
@@ -313,6 +283,20 @@ export class FusionArtifactStore {
313
283
  await this.writeArtifact('canonical-input.json', serialized);
314
284
  }
315
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: FusionContextOmissionLedgerV2): 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
+
316
300
  async writeBlindCandidates(serialized: string): Promise<void> {
317
301
  await this.writeArtifact('blind-candidates.json', serialized);
318
302
  }