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.
- package/PUBLISHING.md +10 -7
- package/README.md +98 -6
- package/TESTING.md +13 -3
- package/TEST_PLAN.md +10 -5
- package/package.json +10 -8
- package/src/core/attested-pi-run.ts +19 -35
- package/src/core/common.ts +124 -8
- package/src/core/durable-fs.ts +400 -0
- package/src/core/fusion/artifacts.ts +20 -36
- package/src/core/fusion/budget.ts +656 -0
- package/src/core/fusion/config.ts +4 -36
- package/src/core/fusion/context.ts +507 -47
- package/src/core/fusion/orchestrator.ts +43 -4
- package/src/core/fusion/pi-child.ts +28 -6
- package/src/core/fusion/prompts.ts +21 -7
- package/src/core/fusion/types.ts +284 -4
- package/src/core/pi-launch.ts +225 -0
- package/src/core/registry.ts +507 -56
- package/src/core/windows-taskkill.ts +250 -0
- package/src/fusion-extension.ts +6 -1
package/src/core/common.ts
CHANGED
|
@@ -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
|
-
):
|
|
532
|
-
if (platform
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
}
|