pi-background-tasks 0.7.6 → 0.7.7

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.
@@ -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,