pi-background-tasks 0.7.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ import {
2
+ FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
3
+ FUSION_EVALUATOR_SYSTEM_PROMPT,
4
+ FUSION_MERGER_SYSTEM_PROMPT,
5
+ FUSION_VALIDATE_EVALUATION_REPAIR_SYSTEM_PROMPT,
6
+ FUSION_VALIDATE_EVALUATOR_SYSTEM_PROMPT,
7
+ FUSION_VALIDATE_MERGER_SYSTEM_PROMPT,
8
+ fusionCandidateSystemPrompt,
9
+ fusionValidateCandidateSystemPrompt,
10
+ } from './prompts.js';
11
+ import {
12
+ FUSION_DEFAULT_CAPABILITY,
13
+ FUSION_VALIDATE_CAPABILITY,
14
+ FusionError,
15
+ type FusionCapability,
16
+ type FusionWorkflowId,
17
+ } from './types.js';
18
+
19
+ export const FUSION_BRAINSTORM_TOOL_NAME = 'fusion_brainstorm';
20
+ export const FUSION_VALIDATE_TOOL_NAME = 'fusion_validate';
21
+
22
+ /**
23
+ * How a workflow decides which capability its candidate children run with.
24
+ *
25
+ * `caller_selected` lets the tool schema offer a capability argument and defaults
26
+ * to the least-privileged profile. `fixed` pins one capability for every run and
27
+ * makes each other value a loud orchestration failure rather than a silent
28
+ * downgrade.
29
+ */
30
+ export type FusionCapabilityPolicy = 'caller_selected' | 'fixed';
31
+
32
+ /**
33
+ * Stage framing for one Fusion workflow.
34
+ *
35
+ * Everything a workflow can vary lives here: the four system prompts, the
36
+ * capability policy, and presentation strings. Everything else - the conversation
37
+ * projection, canonical input schema, budget policy, evaluation schema, artifact
38
+ * store, and state machine - is shared and must never be branched per workflow.
39
+ */
40
+ export interface FusionWorkflowProfile {
41
+ readonly id: FusionWorkflowId;
42
+ readonly toolName: string;
43
+ /** First character of the run id, so artifact directories are self-describing. */
44
+ readonly runIdPrefix: string;
45
+ readonly capabilityPolicy: FusionCapabilityPolicy;
46
+ /** The only capability permitted when `capabilityPolicy` is `fixed`. */
47
+ readonly fixedCapability: FusionCapability | undefined;
48
+ readonly defaultCapability: FusionCapability;
49
+ readonly candidateSystemPrompt: (capability: FusionCapability) => string;
50
+ readonly evaluatorSystemPrompt: string;
51
+ readonly evaluationRepairSystemPrompt: string;
52
+ readonly mergerSystemPrompt: string;
53
+ /** Human-readable noun used in progress lines and rendered results. */
54
+ readonly label: string;
55
+ }
56
+
57
+ export const FUSION_BRAINSTORM_WORKFLOW: FusionWorkflowProfile = Object.freeze({
58
+ id: 'brainstorm',
59
+ toolName: FUSION_BRAINSTORM_TOOL_NAME,
60
+ runIdPrefix: 'f',
61
+ capabilityPolicy: 'caller_selected',
62
+ fixedCapability: undefined,
63
+ defaultCapability: FUSION_DEFAULT_CAPABILITY,
64
+ candidateSystemPrompt: fusionCandidateSystemPrompt,
65
+ evaluatorSystemPrompt: FUSION_EVALUATOR_SYSTEM_PROMPT,
66
+ evaluationRepairSystemPrompt: FUSION_EVALUATION_REPAIR_SYSTEM_PROMPT,
67
+ mergerSystemPrompt: FUSION_MERGER_SYSTEM_PROMPT,
68
+ label: 'fusion',
69
+ });
70
+
71
+ export const FUSION_VALIDATE_WORKFLOW: FusionWorkflowProfile = Object.freeze({
72
+ id: 'validate',
73
+ toolName: FUSION_VALIDATE_TOOL_NAME,
74
+ runIdPrefix: 'v',
75
+ capabilityPolicy: 'fixed',
76
+ fixedCapability: FUSION_VALIDATE_CAPABILITY,
77
+ defaultCapability: FUSION_VALIDATE_CAPABILITY,
78
+ candidateSystemPrompt: fusionValidateCandidateSystemPrompt,
79
+ evaluatorSystemPrompt: FUSION_VALIDATE_EVALUATOR_SYSTEM_PROMPT,
80
+ evaluationRepairSystemPrompt: FUSION_VALIDATE_EVALUATION_REPAIR_SYSTEM_PROMPT,
81
+ mergerSystemPrompt: FUSION_VALIDATE_MERGER_SYSTEM_PROMPT,
82
+ label: 'validate',
83
+ });
84
+
85
+ const PROFILES_BY_ID: Readonly<Record<FusionWorkflowId, FusionWorkflowProfile>> = Object.freeze({
86
+ brainstorm: FUSION_BRAINSTORM_WORKFLOW,
87
+ validate: FUSION_VALIDATE_WORKFLOW,
88
+ });
89
+
90
+ export function fusionWorkflowProfile(id: FusionWorkflowId): FusionWorkflowProfile {
91
+ const profile = PROFILES_BY_ID[id];
92
+ if (profile === undefined) {
93
+ throw new FusionError(`unknown fusion workflow ${String(id)}`, {
94
+ code: 'orchestration_failed',
95
+ childCreated: false,
96
+ });
97
+ }
98
+ return profile;
99
+ }
100
+
101
+ /**
102
+ * Resolve the candidate capability for one run under its workflow's policy.
103
+ *
104
+ * A `fixed` workflow rejects each other capability instead of quietly substituting
105
+ * its own: silently accepting `reason` for a validation run would produce a review
106
+ * that never read the code, which is exactly the failure this workflow exists to
107
+ * prevent.
108
+ */
109
+ export function resolveWorkflowCapability(
110
+ profile: FusionWorkflowProfile,
111
+ requested: FusionCapability | undefined,
112
+ ): FusionCapability {
113
+ if (profile.capabilityPolicy === 'caller_selected') {
114
+ return requested ?? profile.defaultCapability;
115
+ }
116
+ const fixed = profile.fixedCapability;
117
+ if (fixed === undefined) {
118
+ throw new FusionError(
119
+ `fusion workflow ${profile.id} declares a fixed capability policy without a capability`,
120
+ { code: 'orchestration_failed', childCreated: false },
121
+ );
122
+ }
123
+ if (requested !== undefined && requested !== fixed) {
124
+ throw new FusionError(
125
+ `fusion workflow ${profile.id} always runs candidates with the ${fixed} capability; received ${String(requested)}`,
126
+ { code: 'orchestration_failed', childCreated: false },
127
+ );
128
+ }
129
+ return fixed;
130
+ }
@@ -27,6 +27,7 @@ import {
27
27
  type JsonObject,
28
28
  type KillKind,
29
29
  type StartAttestedPiTaskOptions,
30
+ type StartDelegateTaskOptions,
30
31
  type StartTaskOptions,
31
32
  type TaskContextUsage,
32
33
  type TaskStatus,
@@ -52,6 +53,7 @@ import {
52
53
  } from './attested-pi-run.js';
53
54
  import {
54
55
  assertWindowsCommandLineWithinLimit,
56
+ piLaunchArgv,
55
57
  resolvePiLaunch,
56
58
  type PiLaunchSpec,
57
59
  } from './pi-launch.js';
@@ -87,8 +89,15 @@ interface OutputEventSource {
87
89
  on(event: 'data', listener: (data: Buffer | string) => void): unknown;
88
90
  }
89
91
 
92
+ interface ChildStdin {
93
+ write(data: Buffer, callback: (error?: Error | null) => void): boolean;
94
+ end(callback?: () => void): unknown;
95
+ once(event: 'error', listener: (error: Error) => void): unknown;
96
+ }
97
+
90
98
  export interface BackgroundTaskChildProcess {
91
99
  pid?: number | undefined;
100
+ stdin?: ChildStdin | null | undefined;
92
101
  stdout?: OutputEventSource | null | undefined;
93
102
  stderr?: OutputEventSource | null | undefined;
94
103
  kill(signal?: NodeJS.Signals): boolean;
@@ -678,6 +687,32 @@ function noopOnChange(): void {
678
687
  return undefined;
679
688
  }
680
689
 
690
+ /**
691
+ * Deliver the delegate prompt bytes over stdin.
692
+ *
693
+ * A failure to deliver the seed is loud: the caller terminates the task rather
694
+ * than letting a child run without the context it was supposed to receive.
695
+ */
696
+ function writeDelegateStdin(
697
+ child: BackgroundTaskChildProcess,
698
+ bytes: Buffer,
699
+ onError: (error: Error) => void,
700
+ ): void {
701
+ const stdin = child.stdin;
702
+ if (stdin === undefined || stdin === null) {
703
+ onError(new Error('delegate child stdin pipe is unavailable'));
704
+ return;
705
+ }
706
+ stdin.once('error', onError);
707
+ stdin.write(bytes, (error?: Error | null) => {
708
+ if (error !== undefined && error !== null) {
709
+ onError(error);
710
+ return;
711
+ }
712
+ stdin.end();
713
+ });
714
+ }
715
+
681
716
  export class BackgroundTaskRegistry {
682
717
  private readonly tasks = new Map<string, BgTask>();
683
718
  private runtimeDir: RuntimeDir | undefined;
@@ -927,6 +962,145 @@ export class BackgroundTaskRegistry {
927
962
  }
928
963
  }
929
964
 
965
+ /**
966
+ * Start a prepared delegate child.
967
+ *
968
+ * The caller has already completed preflight, so by the time this runs the
969
+ * seed, budget plan, and artifact directory exist and the argv is fixed. The
970
+ * child is launched directly, never through a shell, and its terminal state
971
+ * flows through the same durable notification path as `bg_run`.
972
+ */
973
+ async startDelegateTask(
974
+ ctx: BackgroundTaskContext,
975
+ request: StartDelegateTaskOptions,
976
+ ): Promise<BgTask> {
977
+ if (this.shuttingDown)
978
+ throw new Error('Cannot start a delegate task while Pi is shutting down');
979
+
980
+ const launch = resolvePiLaunch({ platform: this.platform });
981
+ assertWindowsCommandLineWithinLimit(launch, request.argv, this.platform, 'bg-delegate');
982
+
983
+ const dir = await this.ensureRuntimeDir(ctx);
984
+ const id = request.facts.taskId;
985
+ const outputAbsPath = join(dir.abs, `${id}.output`);
986
+ const metadataAbsPath = join(dir.abs, `${id}.json`);
987
+ const outputPath = join(dir.display, `${id}.output`);
988
+
989
+ const task: BgTask = {
990
+ id,
991
+ name: normalizeTaskName(request.name) ?? 'Delegate task',
992
+ command: ['pi', ...request.argv].map(shellQuote).join(' '),
993
+ status: 'running',
994
+ outputPath,
995
+ outputAbsPath,
996
+ metadataAbsPath,
997
+ cwd: ctx.cwd,
998
+ startTime: this.now(),
999
+ exitCode: undefined,
1000
+ pid: undefined,
1001
+ bytesWritten: 0,
1002
+ isAgent: true,
1003
+ notified: false,
1004
+ notifyOnCompletion: request.notifyOnCompletion,
1005
+ triggerOnCompletion: request.triggerOnCompletion,
1006
+ timeoutSeconds: request.timeoutSeconds,
1007
+ model: request.facts.route.qualifiedId,
1008
+ delegate: request.facts,
1009
+ waiters: [],
1010
+ };
1011
+ this.tasks.set(id, task);
1012
+
1013
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
1014
+ task.stream = stream;
1015
+ stream.on('error', (error) => {
1016
+ task.error = `Output file write failed: ${error.message}`;
1017
+ });
1018
+
1019
+ try {
1020
+ const child = this.spawn(launch.executable, piLaunchArgv(launch, [...request.argv]), {
1021
+ cwd: ctx.cwd,
1022
+ detached: this.platform !== 'win32',
1023
+ shell: false,
1024
+ // The seed travels over stdin, never as a shell or positional argument,
1025
+ // so the bytes the child reads are exactly the bytes that were persisted
1026
+ // and hashed, with no quoting or command-line length limit in the path.
1027
+ stdio: ['pipe', 'pipe', 'pipe'],
1028
+ env: request.env,
1029
+ windowsHide: true,
1030
+ });
1031
+ task.child = child;
1032
+ task.pid = child.pid;
1033
+ writeDelegateStdin(child, request.stdinBytes, (error) => {
1034
+ this.writeNotice(task, `\n[delegate stdin write failed: ${error.message}]\n`);
1035
+ if (task.status === 'running') {
1036
+ task.killKind = 'user';
1037
+ task.error = `Delegate seed could not be delivered: ${error.message}`;
1038
+ try {
1039
+ this.requestKill(task, 'SIGTERM');
1040
+ } catch {
1041
+ void this.finalizeTask(task, 'failed', null, undefined, task.error);
1042
+ }
1043
+ }
1044
+ });
1045
+
1046
+ child.stdout?.on('data', (data) => {
1047
+ this.appendChildOutput(task, data, 'stdout');
1048
+ });
1049
+ child.stderr?.on('data', (data) => {
1050
+ this.appendChildOutput(task, data, 'stderr');
1051
+ });
1052
+ child.on('error', (error) => {
1053
+ this.writeNotice(task, `\n[delegate spawn error: ${error.message}]\n`);
1054
+ void this.finalizeTask(task, 'failed', null, undefined, error.message);
1055
+ });
1056
+ child.on('close', (code, signalName) => {
1057
+ let status: TaskStatus;
1058
+ let error: string | undefined;
1059
+ if (task.killKind === 'user' || task.killKind === 'shutdown') {
1060
+ status = 'killed';
1061
+ } else if (task.killKind === 'timeout') {
1062
+ status = 'failed';
1063
+ error = task.error ?? `Timed out after ${String(request.timeoutSeconds ?? 0)}s`;
1064
+ } else if ((code ?? 0) === 0) {
1065
+ status = 'completed';
1066
+ } else {
1067
+ status = 'failed';
1068
+ error = `Exited with code ${code === null ? 'null' : String(code)}${signalName ? ` (${signalName})` : ''}`;
1069
+ }
1070
+ void this.finalizeTask(task, status, code, signalName, error);
1071
+ });
1072
+
1073
+ if (request.timeoutSeconds !== undefined) {
1074
+ task.timeoutHandle = setTimeout(() => {
1075
+ if (task.status !== 'running') return;
1076
+ task.killKind = 'timeout';
1077
+ task.error = `Timed out after ${String(request.timeoutSeconds)}s`;
1078
+ this.writeNotice(task, `\n[delegate timeout: ${task.error}]\n`);
1079
+ try {
1080
+ this.requestKill(task, 'SIGTERM');
1081
+ } catch (error) {
1082
+ void this.finalizeTask(
1083
+ task,
1084
+ 'failed',
1085
+ null,
1086
+ undefined,
1087
+ `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`,
1088
+ );
1089
+ }
1090
+ }, request.timeoutSeconds * 1000);
1091
+ }
1092
+
1093
+ await this.writeMetadata(task);
1094
+ this.onChange();
1095
+ return task;
1096
+ } catch (error) {
1097
+ const message = error instanceof Error ? error.message : String(error);
1098
+ this.writeNotice(task, `\n[delegate spawn exception: ${message}]\n`);
1099
+ await this.finalizeTask(task, 'failed', null, undefined, message);
1100
+ throw new Error(`Failed to start delegate task: ${message}`);
1101
+ }
1102
+ }
1103
+
930
1104
  async startAttestedPiTask(
931
1105
  ctx: BackgroundTaskContext,
932
1106
  request: StartAttestedPiTaskOptions,