pi-background-tasks 0.7.4 → 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.
@@ -1,14 +1,24 @@
1
1
  import type { Usage } from '@earendil-works/pi-ai';
2
+ import type {
3
+ EstimateInputTokensResult,
4
+ TokenBudgetByteClassBreakdown,
5
+ TokenBudgetDominantByteClass,
6
+ TokenBudgetFamily,
7
+ TokenBudgetFamilyCalibration,
8
+ TokenBudgetRateSource,
9
+ } from '../context/token-budget.js';
2
10
 
3
11
  export type FusionThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
4
12
 
5
13
  export const FUSION_MODEL_CONFIG_SCHEMA_VERSION = 'pi-background-tasks.fusion-models.v1';
6
- export const FUSION_INPUT_SCHEMA_VERSION = 'pi-background-tasks.fusion-input.v2';
14
+ export const FUSION_INPUT_SCHEMA_VERSION = 'pi-background-tasks.fusion-input.v4';
7
15
  export const FUSION_EVALUATION_SCHEMA_VERSION = 'pi-background-tasks.fusion-evaluation.v1';
8
- export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v2';
16
+ export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v3';
9
17
  export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v2';
10
- export const FUSION_CONTEXT_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.fusion-context-ledger.v1';
11
- export const FUSION_BUDGET_PLAN_SCHEMA_VERSION = 'pi-background-tasks.fusion-budget-plan.v1';
18
+ export const FUSION_CONTEXT_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.fusion-context-ledger.v2';
19
+ export const FUSION_BUDGET_PLAN_SCHEMA_VERSION = 'pi-background-tasks.fusion-budget-plan.v3';
20
+ export const FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION =
21
+ 'pi-background-tasks.fusion-calibration-violation.v1';
12
22
 
13
23
  /**
14
24
  * Conversation-projection transform shared by every Fusion entry point.
@@ -18,12 +28,12 @@ export const FUSION_BUDGET_PLAN_SCHEMA_VERSION = 'pi-background-tasks.fusion-bud
18
28
  * hash-accounted omission receipts. It never truncates retained text and never
19
29
  * forwards raw image bytes.
20
30
  */
21
- export const FUSION_CONTEXT_TRANSFORM_ID = 'visible-conversation-ledger-v1';
31
+ export const FUSION_CONTEXT_TRANSFORM_ID = 'visible-conversation-ledger-v2';
22
32
  export const FUSION_BRANCH_FILTER_ID = 'exclude-active-fusion-subtree-v1';
23
33
 
24
34
  /** Entry-point specific context policies. Both use the same payload-exclusion transform. */
25
- export const FUSION_TOOL_CONTEXT_POLICY_ID = 'fusion-tool-explicit-v1';
26
- export const FUSION_COMMAND_CONTEXT_POLICY_ID = 'fusion-command-conversation-v1';
35
+ export const FUSION_TOOL_CONTEXT_POLICY_ID = 'fusion-tool-explicit-v2';
36
+ export const FUSION_COMMAND_CONTEXT_POLICY_ID = 'fusion-command-conversation-v2';
27
37
 
28
38
  export const FUSION_IMAGE_OMISSION_PREFIX = '[Image omitted from fusion text transcript: ';
29
39
 
@@ -112,7 +122,7 @@ export interface ResolvedFusionModels {
112
122
 
113
123
  export type FusionRequestAuthority = 'explicit_text' | 'directive_over_projected_conversation';
114
124
 
115
- export interface FusionCanonicalRequestV2 {
125
+ export interface FusionCanonicalRequestV3 {
116
126
  /** Entry point that produced this request. */
117
127
  source: FusionSource;
118
128
  /** How children must weigh `text` against the projected conversation. */
@@ -144,59 +154,60 @@ export interface FusionOmittedEventRecord {
144
154
  mime_type?: string;
145
155
  }
146
156
 
147
- export interface FusionContextOmissionLedgerV1 {
157
+ export interface FusionOmittedActivityProjectionMapEntry {
158
+ canonical_entry_index: number;
159
+ entry_kind: 'omitted_activity';
160
+ ledger_index_first: number;
161
+ ledger_index_last: number;
162
+ }
163
+
164
+ export interface FusionLedgerOnlyImageProjectionMapEntry {
165
+ entry_kind: 'ledger_only_tool_result_image';
166
+ ledger_index_first: number;
167
+ ledger_index_last: number;
168
+ }
169
+
170
+ export type FusionContextProjectionMapEntry =
171
+ | FusionOmittedActivityProjectionMapEntry
172
+ | FusionLedgerOnlyImageProjectionMapEntry;
173
+
174
+ export interface FusionContextOmissionLedgerV2 {
148
175
  schema_version: typeof FUSION_CONTEXT_LEDGER_SCHEMA_VERSION;
149
176
  policy_id: string;
150
177
  transform: typeof FUSION_CONTEXT_TRANSFORM_ID;
151
178
  entries: readonly FusionOmittedEventRecord[];
179
+ projection_map: readonly FusionContextProjectionMapEntry[];
152
180
  root_sha256: string;
153
181
  }
154
182
 
155
- export interface FusionProjectionTextEntry {
156
- kind: 'text';
157
- source_ordinal: number;
158
- block_ordinal: number;
159
- role: 'user' | 'assistant';
160
- text: string;
161
- }
162
-
163
- /**
164
- * Per-kind counts for one omitted run. Zero-valued kinds are omitted from the
165
- * serialized receipt by a fixed policy rule so receipt size does not scale with
166
- * the number of tracked kinds; absent means exactly zero.
167
- */
168
- export interface FusionOmittedRunCounts {
169
- assistant_thinking?: number;
170
- tool_calls?: number;
171
- tool_result_texts?: number;
172
- tool_result_images?: number;
173
- }
183
+ export type FusionProjectionTextEntry = [
184
+ tag: 't',
185
+ role: 'u' | 'a',
186
+ sourceOrdinal: number,
187
+ blockOrdinal: number,
188
+ text: string,
189
+ ];
174
190
 
175
- /** Byte totals for one omitted run, using the same omit-when-zero rule. */
176
- export interface FusionOmittedRunBytes {
177
- assistant_thinking?: number;
178
- tool_call_arguments?: number;
179
- tool_result_text?: number;
180
- tool_result_image?: number;
181
- }
191
+ export type FusionProjectionOmissionCounts = [
192
+ assistantThinking: number,
193
+ toolCalls: number,
194
+ toolResults: number,
195
+ ];
182
196
 
183
- export interface FusionProjectionOmissionEntry {
184
- kind: 'omitted_activity';
185
- source_ordinal_first: number;
186
- source_ordinal_last: number;
187
- ledger_index_first: number;
188
- ledger_index_last: number;
189
- counts: FusionOmittedRunCounts;
190
- payload_bytes: FusionOmittedRunBytes;
191
- ledger_run_sha256: string;
192
- }
197
+ export type FusionProjectionOmissionEntry = [
198
+ tag: 'o',
199
+ sourceOrdinalSpan: [first: number, last: number],
200
+ bytes: number,
201
+ counts: FusionProjectionOmissionCounts,
202
+ ];
193
203
 
194
204
  export type FusionProjectionEntry = FusionProjectionTextEntry | FusionProjectionOmissionEntry;
195
205
 
196
206
  export interface FusionContextPolicyDescriptor {
197
207
  id: string;
198
208
  transform: typeof FUSION_CONTEXT_TRANSFORM_ID;
199
- version: 1;
209
+ version: 2;
210
+ receipt_format: 'omitted_activity.v2';
200
211
  user_text: 'verbatim';
201
212
  assistant_text: 'verbatim';
202
213
  assistant_thinking: 'ledger_only';
@@ -238,23 +249,28 @@ export interface FusionProjectionAccounting {
238
249
  tool_call_names: readonly FusionToolCallNameCount[];
239
250
  ledger_entry_count: number;
240
251
  ledger_root_sha256: string;
252
+ omission_receipt_utf8_bytes: number;
241
253
  }
242
254
 
243
- export interface FusionConversationProjectionV2 {
255
+ export interface FusionConversationProjectionV4 {
244
256
  policy: FusionContextPolicyDescriptor;
245
257
  branch_filter: FusionBranchFilterDescriptor;
246
258
  entries: readonly FusionProjectionEntry[];
247
259
  accounting: FusionProjectionAccounting;
248
260
  }
249
261
 
250
- export interface FusionCanonicalInputV2 {
262
+ export type FusionConversationProjectionV3 = FusionConversationProjectionV4;
263
+
264
+ export interface FusionCanonicalInputV4 {
251
265
  schema_version: typeof FUSION_INPUT_SCHEMA_VERSION;
252
266
  cwd: string;
253
267
  system_prompt: string;
254
- request: FusionCanonicalRequestV2;
255
- conversation_projection: FusionConversationProjectionV2;
268
+ request: FusionCanonicalRequestV3;
269
+ conversation_projection: FusionConversationProjectionV4;
256
270
  }
257
271
 
272
+ export type FusionCanonicalInputV3 = FusionCanonicalInputV4;
273
+
258
274
  export interface CandidateAssessment {
259
275
  candidate_id: FusionCandidateId;
260
276
  summary: string;
@@ -348,6 +364,15 @@ export function addFusionUsage(target: FusionUsage, delta: FusionUsage): void {
348
364
  target.cost.total += delta.cost.total;
349
365
  }
350
366
 
367
+ export interface FusionResultBudgetDetails {
368
+ policy_id: string;
369
+ calibration_version: string;
370
+ route_table: readonly FusionRouteCapacity[];
371
+ rate_sources: readonly TokenBudgetRateSource[];
372
+ unknown_provider_warnings: readonly string[];
373
+ calibration_warnings: readonly FusionCalibrationViolation[];
374
+ }
375
+
351
376
  export interface FusionResultDetails {
352
377
  schema_version: typeof FUSION_RESULT_SCHEMA_VERSION;
353
378
  run_id: string;
@@ -362,6 +387,7 @@ export interface FusionResultDetails {
362
387
  };
363
388
  evaluator_attempts: number;
364
389
  usage: FusionUsage;
390
+ budget: FusionResultBudgetDetails;
365
391
  }
366
392
 
367
393
  export type FusionProgressEvent =
@@ -370,6 +396,8 @@ export type FusionProgressEvent =
370
396
  | { type: 'candidate_completed'; slot: 1 | 2 | 3; completed: number; total: 3 }
371
397
  | { type: 'evaluation_started'; attempt: 1 | 2; repair: boolean }
372
398
  | { type: 'evaluation_retry'; errors: readonly string[] }
399
+ | { type: 'budget_warning'; warnings: readonly FusionBudgetWarning[]; error: string }
400
+ | { type: 'calibration_warning'; warning: FusionCalibrationViolation; artifact: string }
373
401
  | { type: 'merge_started' }
374
402
  | { type: 'completed'; runId: string; artifactDir: string }
375
403
  | { type: 'failed'; runId: string; artifactDir: string; error: string }
@@ -381,7 +409,8 @@ export type FusionErrorCode =
381
409
  | 'model_unavailable'
382
410
  | 'context_capture_failed'
383
411
  | 'context_policy_unsupported_block'
384
- | 'prompt_budget_exceeded'
412
+ | 'prompt_budget_exceeded_forecast'
413
+ | 'prompt_budget_exceeded_measured'
385
414
  | 'model_capacity_unknown'
386
415
  | 'child_spawn_failed'
387
416
  | 'child_stdin_failed'
@@ -395,14 +424,53 @@ export type FusionErrorCode =
395
424
  | 'state_transition_invalid'
396
425
  | 'orchestration_failed';
397
426
 
398
- /**
399
- * Structured detail attached to a `prompt_budget_exceeded` failure so the caller
400
- * can see exactly which stage, which measured size, which allowed size, and
401
- * which configured model was the limiting participant.
402
- */
427
+ export type FusionBudgetCheckKind = 'input_only_preflight' | 'rendered_prompt';
428
+
429
+ export interface FusionBudgetComponentBreakdown {
430
+ visible_text: { bytes: number; tokens: number };
431
+ omission_receipts: { bytes: number; tokens: number };
432
+ projection_metadata: { bytes: number; tokens: number };
433
+ request: { bytes: number; tokens: number };
434
+ static_stage_framing: { bytes: number; tokens: number };
435
+ upstream_output_contracts: { bytes: number; tokens: number };
436
+ }
437
+
438
+ export interface FusionBudgetDenseRegion {
439
+ offset: number;
440
+ len: number;
441
+ detector: 'not_implemented_step_6';
442
+ }
443
+
444
+ export interface FusionBudgetRouteTableEntry {
445
+ role: FusionRouteCapacity['role'];
446
+ qualified_id: string;
447
+ allowed_input_tokens: number;
448
+ family: TokenBudgetFamily;
449
+ effective_rate_bytes_per_token_x100: number;
450
+ byte_capacity_utf8_bytes: number;
451
+ backed: boolean;
452
+ }
453
+
454
+ export interface FusionBudgetCounterfactuals {
455
+ empty_request: FusionBudgetEmptyRequestVerdict;
456
+ without_reservation: {
457
+ forecast_input_tokens_upper_bound: number;
458
+ signed_headroom_tokens: number;
459
+ fits: boolean;
460
+ };
461
+ at_median_rate: {
462
+ forecast_input_tokens_upper_bound: number | null;
463
+ signed_headroom_tokens: number | null;
464
+ fits: boolean | null;
465
+ };
466
+ }
467
+
468
+ /** Structured detail attached to a split prompt-budget failure. */
403
469
  export interface FusionBudgetErrorDetail {
404
470
  budget_stage: FusionBudgetStage;
405
- measurement_kind: 'worst_case_envelope' | 'rendered_prompt';
471
+ slot?: 1 | 2 | 3;
472
+ measurement_kind: 'stage_forecast' | 'rendered_prompt';
473
+ check_kind: FusionBudgetCheckKind;
406
474
  measured_utf8_bytes: number;
407
475
  measured_input_tokens_upper_bound: number;
408
476
  allowed_input_tokens: number;
@@ -412,8 +480,24 @@ export interface FusionBudgetErrorDetail {
412
480
  qualified_id: string;
413
481
  context_window_tokens: number;
414
482
  };
483
+ rate_source: TokenBudgetRateSource;
484
+ backed: boolean;
485
+ dominant_byte_class: TokenBudgetDominantByteClass;
486
+ component_breakdown: FusionBudgetComponentBreakdown;
487
+ byte_class_breakdown: TokenBudgetByteClassBreakdown;
488
+ dense_regions: readonly FusionBudgetDenseRegion[];
489
+ bytes_over: number;
490
+ tokens_over: number;
491
+ required_allowed_tokens: number;
492
+ route_table: readonly FusionBudgetRouteTableEntry[];
493
+ counterfactuals: FusionBudgetCounterfactuals;
494
+ stage_upstream_actuals: readonly { stage: FusionStage; bytes: number }[];
495
+ policy_id: string;
496
+ calibration_version: string;
415
497
  context_policy_id: string;
416
498
  remediation: readonly string[];
499
+ blockers: readonly FusionBudgetBlocker[];
500
+ artifact_dir: string;
417
501
  }
418
502
 
419
503
  export interface FusionErrorDetails {
@@ -533,50 +617,110 @@ export interface FusionRouteCapacity {
533
617
  framing_reserve_tokens: number;
534
618
  safety_reserve_tokens: number;
535
619
  allowed_input_tokens: number;
620
+ family: TokenBudgetFamily;
621
+ rate_source: TokenBudgetRateSource;
622
+ byte_capacity_utf8_bytes: number;
623
+ }
624
+
625
+ export interface FusionBudgetStageComposition {
626
+ visible_text_bytes: number;
627
+ omission_receipt_bytes: number;
628
+ projection_metadata_bytes: number;
629
+ request_bytes: number;
630
+ static_stage_framing_bytes: number;
631
+ upstream_output_contract_bytes: number;
536
632
  }
537
633
 
538
634
  export interface FusionStageBudgetPlanEntry {
539
635
  budget_stage: FusionBudgetStage;
540
- measurement_kind: 'worst_case_envelope';
541
- measured_utf8_bytes: number;
542
- measured_input_tokens_upper_bound: number;
636
+ slot?: 1 | 2 | 3;
637
+ route: FusionRouteCapacity;
638
+ conditional: boolean;
639
+ check_kind: 'input_only_preflight';
640
+ input_utf8_bytes: number;
641
+ upstream_output_contract_bytes: number;
642
+ forecast_utf8_bytes: number;
643
+ input_only_input_tokens_upper_bound: number;
644
+ forecast_input_tokens_upper_bound: number;
543
645
  allowed_input_tokens: number;
544
- limiting_qualified_id: string;
545
- slack_tokens: number;
646
+ input_only_signed_headroom_tokens: number;
647
+ signed_headroom_tokens: number;
648
+ input_only_utilization_basis_points: number;
649
+ utilization_basis_points: number;
650
+ input_only_estimate: EstimateInputTokensResult;
651
+ reservation_estimate: EstimateInputTokensResult;
652
+ fits: boolean;
653
+ reservation_fits: boolean;
654
+ }
655
+
656
+ export interface FusionBudgetBlocker extends FusionStageBudgetPlanEntry {
657
+ overage_tokens: number;
658
+ bytes_over: number;
659
+ }
660
+
661
+ export interface FusionBudgetWarning extends FusionStageBudgetPlanEntry {
662
+ warning_kind: 'input_utilization' | 'worst_case_reservation';
663
+ threshold_basis_points: number;
664
+ }
665
+
666
+ export interface FusionBudgetEmptyRequestVerdict {
667
+ request_utf8_bytes: number;
668
+ still_fails_with_empty_request: boolean;
669
+ shortening_request_can_help: boolean;
670
+ minimum_request_byte_reduction: number;
671
+ maximum_safe_request_utf8_bytes: number;
672
+ blockers_with_empty_request: readonly FusionBudgetBlocker[];
546
673
  }
547
674
 
548
675
  export interface FusionBudgetPlanV1 {
549
676
  schema_version: typeof FUSION_BUDGET_PLAN_SCHEMA_VERSION;
550
677
  policy: FusionBudgetPolicyDescriptor;
551
678
  routes: readonly FusionRouteCapacity[];
552
- limiting_qualified_id: string;
553
- /** Base-context feasibility check performed before the first candidate spawns. */
554
- base_context: FusionStageBudgetPlanEntry;
679
+ stages: readonly FusionStageBudgetPlanEntry[];
680
+ blockers: readonly FusionBudgetBlocker[];
681
+ primary_blocker?: FusionBudgetBlocker;
682
+ primary_blocker_composition?: FusionBudgetStageComposition;
683
+ empty_request: FusionBudgetEmptyRequestVerdict;
684
+ warnings: readonly FusionBudgetWarning[];
555
685
  }
556
686
 
557
- /**
558
- * Documented, versioned budget policy.
559
- *
560
- * `bytes_per_token_divisor` is a conservative lower bound on UTF-8 bytes per
561
- * token: token upper bound = ceil(utf8Bytes / divisor). It is deliberately far
562
- * below the smallest ratio measured across real Fusion prompts so dense
563
- * non-ASCII input cannot silently exceed a route's window.
564
- *
565
- * `downstream_reserve_bytes` is withheld from the canonical input so the
566
- * evaluator, evaluation-repair, and merger prompts provably have room for the
567
- * child outputs they embed. It is derived from the enforced per-stage output
568
- * byte contracts, so it is a guarantee rather than an estimate: a response over
569
- * its contract fails loudly instead of being embedded. `downstream_reserve_tokens`
570
- * converts it with the same `bytes_per_token_divisor` used to measure prompts,
571
- * because reserving output tokens directly would understate the cost of
572
- * re-embedding those bytes. Neither value is adjusted to fit a particular input.
573
- */
687
+ /** Documented, versioned budget policy. */
574
688
  export interface FusionBudgetPolicyDescriptor {
575
- id: 'fusion-budget-policy-v1';
576
- bytes_per_token_divisor: number;
689
+ id: 'fusion-budget-policy-v3';
690
+ calibration_version: string;
691
+ calibration_table: Readonly<Record<TokenBudgetFamily, TokenBudgetFamilyCalibration>>;
577
692
  reserved_output_tokens: number;
578
693
  framing_reserve_tokens: number;
579
694
  safety_reserve_tokens: number;
580
- downstream_reserve_bytes: number;
581
- downstream_reserve_tokens: number;
695
+ candidate_output_contract_bytes: number;
696
+ evaluation_output_contract_bytes: number;
697
+ merge_output_contract_bytes: number;
698
+ diagnostics_contract_bytes: number;
699
+ utilization_warning_threshold_basis_points: 8000;
700
+ }
701
+
702
+ export interface FusionCalibrationViolation {
703
+ schema_version: typeof FUSION_CALIBRATION_VIOLATION_SCHEMA_VERSION;
704
+ stage: FusionStage;
705
+ slot?: 1 | 2 | 3;
706
+ attempt: number;
707
+ route: {
708
+ provider: string;
709
+ model: string;
710
+ qualified_id: string;
711
+ };
712
+ family: TokenBudgetFamily;
713
+ rate_source: TokenBudgetRateSource;
714
+ prompt_utf8_bytes: number;
715
+ prompt_sha256: string;
716
+ forecast_input_tokens: number;
717
+ billed_input_tokens: number;
718
+ billed_input_breakdown: {
719
+ input: number;
720
+ cache_read: number;
721
+ cache_write: number;
722
+ };
723
+ under_forecast_tokens: number;
724
+ byte_class_breakdown: TokenBudgetByteClassBreakdown;
725
+ dominant_byte_class: TokenBudgetDominantByteClass;
582
726
  }
@@ -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,