pi-crew 0.11.6 → 0.11.8

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.
Files changed (58) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +216 -1
  4. package/dist/index.mjs +531 -508
  5. package/docs/publishing.md +29 -0
  6. package/index.ts +3 -3
  7. package/package.json +9 -6
  8. package/scripts/install-hooks.mjs +32 -0
  9. package/skills/real-test-pi-crew/SKILL.md +31 -18
  10. package/src/config/config-validation.ts +35 -0
  11. package/src/config/env-vars.ts +38 -0
  12. package/src/config/types.ts +71 -0
  13. package/src/extension/registration/foreground-run-controller.ts +19 -10
  14. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  15. package/src/extension/registration/lifecycle.ts +7 -90
  16. package/src/extension/registration/render-loop.ts +502 -0
  17. package/src/extension/team-tool/doctor.ts +6 -0
  18. package/src/prompt/prompt-runtime.ts +33 -4
  19. package/src/runtime/child-pi/child-pi-spawn.ts +14 -0
  20. package/src/runtime/child-pi/child-pi.ts +130 -6
  21. package/src/runtime/child-pi/pi-version.ts +187 -0
  22. package/src/runtime/child-pi/session-recovery.ts +334 -0
  23. package/src/runtime/classifier/classifier-service.ts +298 -0
  24. package/src/runtime/dispatch-batch.ts +4 -0
  25. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  26. package/src/runtime/model/model-scope.ts +141 -21
  27. package/src/runtime/model/pi-args.ts +166 -6
  28. package/src/runtime/peer-dep.ts +24 -0
  29. package/src/runtime/process/zombie-scanner.ts +168 -7
  30. package/src/runtime/rpc/README.md +139 -0
  31. package/src/runtime/rpc/frame-client.ts +328 -0
  32. package/src/runtime/rpc/rpc-worker.ts +271 -0
  33. package/src/runtime/rpc/ui-request-policy.ts +112 -0
  34. package/src/runtime/run-worker.ts +91 -3
  35. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  36. package/src/runtime/skill-instructions.ts +66 -1
  37. package/src/runtime/surface/resolve-surface.ts +34 -19
  38. package/src/runtime/surface/surface-spawn.ts +8 -1
  39. package/src/runtime/task-output-context.ts +191 -32
  40. package/src/runtime/task-packet.ts +3 -0
  41. package/src/runtime/task-runner/child-executor.ts +172 -2
  42. package/src/runtime/task-runner/post-execution.ts +5 -3
  43. package/src/runtime/task-runner/pre-execution.ts +32 -1
  44. package/src/runtime/task-runner/prompt-builder.ts +10 -2
  45. package/src/runtime/task-runner.ts +6 -0
  46. package/src/schema/config-schema.ts +37 -0
  47. package/src/schema/team-tool-schema.ts +14 -2
  48. package/src/state/contracts.ts +5 -0
  49. package/src/ui/format-helpers.ts +11 -0
  50. package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
  51. package/src/ui/pi-ui-compat.ts +18 -9
  52. package/src/ui/rail.ts +14 -1
  53. package/src/ui/settings-overlay.ts +11 -2
  54. package/src/ui/widget/index.ts +0 -1
  55. package/src/ui/widget/task-list.ts +10 -3
  56. package/src/ui/widget/widget-renderer.ts +53 -4
  57. package/src/utils/relative-time.ts +11 -3
  58. package/src/utils/visual.ts +32 -2
@@ -41,6 +41,12 @@ export interface DependencyContextEntry {
41
41
  * the workflow (earlier deps = higher relevance because they were
42
42
  * explicitly listed first). Defaults to 0.5 (neutral) when unknown. */
43
43
  relevance?: number;
44
+ /** Handoff-budget marker: set by {@link applyHandoffBudget} when this
45
+ * entry was downgraded to the compact form (taskId/role/status + summary
46
+ * head ≤240 chars + artifact pointer). The render path emits the compact
47
+ * block for marked entries; `writeTaskInputsArtifact` still serializes the
48
+ * ORIGINAL (untrimmed) context — the trim is render-only, never persisted. */
49
+ budgetTrimmed?: boolean;
44
50
  }
45
51
 
46
52
  export interface DependencyOutputContext {
@@ -58,6 +64,12 @@ export interface DependencyOutputContext {
58
64
  content: string;
59
65
  fullOutputPath?: string;
60
66
  }>;
67
+ /** Run id (from the team manifest). Populated by
68
+ * `collectDependencyOutputContext` so the handoff-budget trim can build
69
+ * artifact pointers (`artifacts/<runId>/results/<taskId>.txt`). Optional
70
+ * for backward-compat with hand-built fixtures; without it trimmed deps
71
+ * simply omit the pointer line. */
72
+ runId?: string;
61
73
  }
62
74
 
63
75
  function containedExists(filePath: string, baseDir?: string): boolean {
@@ -523,7 +535,7 @@ export function collectDependencyOutputContext(
523
535
  // outputs with compact digest notices before injecting into the worker
524
536
  // prompt. OPT-IN: default config protects recent results.
525
537
  const sharedReads = pruneSharedReads(rawSharedReads, trimmedDependencies, manifest.artifactsRoot);
526
- return { dependencies: trimmedDependencies, sharedReads };
538
+ return { dependencies: trimmedDependencies, sharedReads, runId: manifest.runId };
527
539
  }
528
540
 
529
541
  // ADR-5 §trust-fence: dependency output is DATA, never instructions. Mirror the
@@ -536,46 +548,193 @@ function sanitizeFencedBody(body: string): string {
536
548
  return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "&lt;/dependency-context");
537
549
  }
538
550
 
539
- export function renderDependencyOutputContext(context: DependencyOutputContext): string {
551
+ // ── Handoff budget (est-token cap on this layer) ─────────────────────────────
552
+
553
+ /** Default est-token budget for the dynamic.dependencyContext layer. */
554
+ export const DEFAULT_HANDOFF_BUDGET_TOKENS = 1800;
555
+ /** Budget ceiling: a resolved budget above this is treated as the off-switch
556
+ * (same semantics as ≤0 — clearly not a real handoff cap, so render
557
+ * untrimmed instead of guessing). */
558
+ export const HANDOFF_BUDGET_CEILING_TOKENS = 1_000_000;
559
+ /** Summary head kept per dependency when the budget trims it. */
560
+ export const HANDOFF_SUMMARY_HEAD_CHARS = 240;
561
+
562
+ /** Strict integer env syntax — "12abc" must NOT parse as 12. */
563
+ const HANDOFF_BUDGET_ENV_RE = /^[+-]?\d+$/;
564
+
565
+ /** Est tokens = chars/4 — the same heuristic as `estimateTokens` in
566
+ * src/runtime/task-runner/prompt-builder.ts (SR-02 breakdown). Duplicated
567
+ * here instead of imported: importing prompt-builder would pull its
568
+ * retrieval/workspace-tree graph into every task-output-context consumer
569
+ * (post-execution, aggregate outputs) for a one-line heuristic. Keep the
570
+ * two in sync if either changes. */
571
+ function estimateHandoffTokens(chars: number): number {
572
+ return Math.round(chars / 4);
573
+ }
574
+
575
+ /** Budget active iff it is a positive finite number at or below the ceiling;
576
+ * ≤0 or >ceiling = off-switch (render untrimmed). */
577
+ function handoffBudgetActive(budgetTokens: number): boolean {
578
+ return Number.isFinite(budgetTokens) && budgetTokens > 0 && budgetTokens <= HANDOFF_BUDGET_CEILING_TOKENS;
579
+ }
580
+
581
+ /** Resolve the handoff budget: env PI_CREW_HANDOFF_BUDGET_TOKENS beats the
582
+ * configured value, which beats the default (1800). A non-numeric/invalid
583
+ * env string NEVER throws — it falls through to config/default. The resolved
584
+ * number may still be ≤0 or >1_000_000 (off-switch at the render site). */
585
+ export function resolveHandoffBudgetTokens(configured: number | undefined): number {
586
+ const raw = getCrewEnv("PI_CREW_HANDOFF_BUDGET_TOKENS");
587
+ if (raw !== undefined) {
588
+ const trimmed = raw.trim();
589
+ if (HANDOFF_BUDGET_ENV_RE.test(trimmed)) {
590
+ const parsed = Number.parseInt(trimmed, 10);
591
+ if (Number.isFinite(parsed)) return parsed;
592
+ }
593
+ }
594
+ return configured ?? DEFAULT_HANDOFF_BUDGET_TOKENS;
595
+ }
596
+
597
+ /** Full-form render lines for one dependency (pre-sanitize). */
598
+ function dependencyEntryLines(dep: DependencyContextEntry): string[] {
599
+ const lines: string[] = [
600
+ `## ${dep.taskId} (${dep.role})`,
601
+ `Status: ${dep.status}`,
602
+ dep.resultPath ? `Result artifact: ${dep.resultPath}` : "",
603
+ "",
604
+ dep.resultSummary?.trim() || "(no result output)",
605
+ "",
606
+ ];
607
+ // P1-A dependency tee-recovery hint: when the dependency's result was
608
+ // materially truncated (>1.25× MAX_RESULT_INLINE_BYTES) the full RAW
609
+ // content was teed to fullOutputPath. Mirrors the sharedReads hint so the
610
+ // downstream worker can read the dropped middle instead of re-deriving.
611
+ if (dep.fullOutputPath) lines.push(`Full output (if you need the missing middle): ${dep.fullOutputPath}`, "");
612
+ if (dep.structuredResults) lines.push("Structured results:", JSON.stringify(dep.structuredResults, null, 2), "");
613
+ if (dep.artifactsProduced?.length) lines.push(`Artifacts produced: ${dep.artifactsProduced.join(", ")}`, "");
614
+ if (dep.usage)
615
+ lines.push(`Usage: ${dep.usage.inputTokens} input tokens, ${dep.usage.outputTokens} output tokens, ${dep.usage.durationMs}ms`, "");
616
+ return lines;
617
+ }
618
+
619
+ /** Compact handoff-budget form for one dependency (pre-sanitize): keeps the
620
+ * taskId/role/status lines + a ≤240-char summary head (with a truncation
621
+ * marker when the summary was longer) + a relative artifact pointer the
622
+ * worker can `read`. Drops structuredResults, usage, artifactsProduced and
623
+ * the absolute-path lines — all recoverable via the pointer. The pointer is
624
+ * built ONLY from manifest-derived runId/taskId (same trust level as the
625
+ * existing `## <taskId>` headers and the results/<taskId>.txt artifact
626
+ * naming); dep-controlled content never reaches it. */
627
+ function budgetTrimmedDependencyEntryLines(dep: DependencyContextEntry, runId: string | undefined): string[] {
628
+ const summary = dep.resultSummary?.trim() ?? "";
629
+ const head = summary.slice(0, HANDOFF_SUMMARY_HEAD_CHARS);
630
+ const body =
631
+ summary.length > HANDOFF_SUMMARY_HEAD_CHARS ? `${head}\n[trimmed, ${summary.length} chars total]` : summary || "(no result output)";
632
+ return [
633
+ `## ${dep.taskId} (${dep.role})`,
634
+ `Status: ${dep.status}`,
635
+ body,
636
+ ...(runId ? [`full output: artifacts/${runId}/results/${dep.taskId}.txt`] : []),
637
+ "",
638
+ ];
639
+ }
640
+
641
+ /** Full-form render lines for one shared read (pre-sanitize). */
642
+ function sharedReadLines(read: { name: string; path: string; content: string; fullOutputPath?: string }): string[] {
643
+ const lines = [`## shared/${read.name}`, `Path: ${read.path}`];
644
+ // P1-A tee-recovery hint: when the file was materially truncated
645
+ // (>2× threshold) the full content was teed to fullOutputPath so the
646
+ // worker can read the dropped middle if needed. The path is inside
647
+ // artifactsRoot/tee/ and goes through the normal permission gate.
648
+ if (read.fullOutputPath) lines.push(`Full output (if you need the missing middle): ${read.fullOutputPath}`);
649
+ lines.push("", read.content.trim(), "");
650
+ return lines;
651
+ }
652
+
653
+ /** Build the unsanitized body parts for a (possibly budget-trimmed) context.
654
+ * Shared by the renderer (sanitize path) and the budget estimator so the
655
+ * estimate can never drift from what is actually rendered. */
656
+ function buildDependencyOutputBodyParts(context: DependencyOutputContext): string[] {
540
657
  const parts: string[] = [];
541
658
  if (context.dependencies.length) {
542
659
  parts.push("# Dependency Outputs", "");
543
660
  for (const dep of context.dependencies) {
544
- parts.push(
545
- `## ${dep.taskId} (${dep.role})`,
546
- `Status: ${dep.status}`,
547
- dep.resultPath ? `Result artifact: ${dep.resultPath}` : "",
548
- "",
549
- dep.resultSummary?.trim() || "(no result output)",
550
- "",
551
- );
552
- // P1-A dependency tee-recovery hint: when the dependency's result was
553
- // materially truncated (>1.25× MAX_RESULT_INLINE_BYTES) the full RAW
554
- // content was teed to fullOutputPath. Mirrors the sharedReads hint so the
555
- // downstream worker can read the dropped middle instead of re-deriving.
556
- if (dep.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${dep.fullOutputPath}`, "");
557
- if (dep.structuredResults) parts.push("Structured results:", JSON.stringify(dep.structuredResults, null, 2), "");
558
- if (dep.artifactsProduced?.length) parts.push(`Artifacts produced: ${dep.artifactsProduced.join(", ")}`, "");
559
- if (dep.usage)
560
- parts.push(
561
- `Usage: ${dep.usage.inputTokens} input tokens, ${dep.usage.outputTokens} output tokens, ${dep.usage.durationMs}ms`,
562
- "",
563
- );
661
+ parts.push(...(dep.budgetTrimmed ? budgetTrimmedDependencyEntryLines(dep, context.runId) : dependencyEntryLines(dep)));
564
662
  }
565
663
  }
566
664
  if (context.sharedReads.length) {
567
665
  parts.push("# Shared Run Context Reads", "");
568
- for (const read of context.sharedReads) {
569
- parts.push(`## shared/${read.name}`, `Path: ${read.path}`);
570
- // P1-A tee-recovery hint: when the file was materially truncated
571
- // (>2× threshold) the full content was teed to fullOutputPath so the
572
- // worker can read the dropped middle if needed. The path is inside
573
- // artifactsRoot/tee/ and goes through the normal permission gate.
574
- if (read.fullOutputPath) parts.push(`Full output (if you need the missing middle): ${read.fullOutputPath}`);
575
- parts.push("", read.content.trim(), "");
576
- }
666
+ for (const read of context.sharedReads) parts.push(...sharedReadLines(read));
577
667
  }
578
- return sanitizeFencedBody(parts.join("\n").trim());
668
+ return parts;
669
+ }
670
+
671
+ /** Measure a candidate context in est tokens (chars/4), mirroring the render
672
+ * byte-for-byte (same parts builder; `.trim()` matches the render join). */
673
+ function measureHandoffTokens(context: DependencyOutputContext): number {
674
+ return estimateHandoffTokens(buildDependencyOutputBodyParts(context).join("\n").trim().length);
675
+ }
676
+
677
+ /**
678
+ * Handoff budget: trim the dependency-output context to an est-token
679
+ * (chars/4) budget. PURE — no I/O, no env reads (callers pass the resolved
680
+ * budget from {@link resolveHandoffBudgetTokens}); returns the input
681
+ * reference unchanged when the budget is off (≤0 or >1_000_000) or the
682
+ * context already fits.
683
+ *
684
+ * Trim policy (declaration order): dependencies are probed from ALL-full
685
+ * downward — the largest prefix of dependencies that still fits keeps its
686
+ * FULL output; every dependency after the cutoff is downgraded to the
687
+ * compact form (taskId/role/status + ≤240-char summary head + truncation
688
+ * marker + `artifacts/<runId>/results/<taskId>.txt` pointer). Earlier deps
689
+ * therefore always keep ≥ as much as later ones. Long (>240 chars) sharedRead
690
+ * bodies are replaced by a trim marker (the Path pointer is already
691
+ * rendered); short shared reads pass through.
692
+ *
693
+ * When even the fully-trimmed form exceeds the budget (extreme dependency
694
+ * count), the fully-trimmed context is returned as the bounded best effort —
695
+ * every entry is then ≤ ~240 chars + pointer, so the layer stays small and
696
+ * deterministic.
697
+ *
698
+ * Composes AFTER the L4 byte trim (`enforceDependencyInlineBudget`, 96KB,
699
+ * priority-ordered path-only downgrade) which runs at collect time and is
700
+ * untouched by this function.
701
+ */
702
+ export function applyHandoffBudget(context: DependencyOutputContext, budgetTokens: number, runId?: string): DependencyOutputContext {
703
+ if (!handoffBudgetActive(budgetTokens)) return context;
704
+ const effectiveRunId = runId ?? context.runId;
705
+ const base: DependencyOutputContext =
706
+ effectiveRunId !== undefined && context.runId !== effectiveRunId ? { ...context, runId: effectiveRunId } : context;
707
+ if (measureHandoffTokens(base) <= budgetTokens) return base;
708
+ const trimmedRead = (read: { name: string; path: string; content: string; fullOutputPath?: string }) => {
709
+ const content = read.content.trim();
710
+ return content.length > HANDOFF_SUMMARY_HEAD_CHARS
711
+ ? { ...read, content: `[content trimmed, ${content.length} chars total — read Path above for the full file]` }
712
+ : read;
713
+ };
714
+ const candidateAt = (fullCount: number): DependencyOutputContext => ({
715
+ ...base,
716
+ dependencies: base.dependencies.map((dep, index) => (index < fullCount ? dep : { ...dep, budgetTrimmed: true })),
717
+ sharedReads: base.sharedReads.map(trimmedRead),
718
+ });
719
+ for (let fullCount = base.dependencies.length; fullCount >= 0; fullCount--) {
720
+ const candidate = candidateAt(fullCount);
721
+ if (measureHandoffTokens(candidate) <= budgetTokens) return candidate;
722
+ }
723
+ return candidateAt(0);
724
+ }
725
+
726
+ export interface RenderDependencyOutputContextOptions {
727
+ /** Configured budget (runtime.handoffBudgetTokens). Env
728
+ * PI_CREW_HANDOFF_BUDGET_TOKENS beats this value; when neither is set the
729
+ * default is 1800. A resolved value ≤0 or >1_000_000 disables the trim
730
+ * (off-switch). */
731
+ budgetTokens?: number;
732
+ }
733
+
734
+ export function renderDependencyOutputContext(context: DependencyOutputContext, opts: RenderDependencyOutputContextOptions = {}): string {
735
+ const budgetTokens = resolveHandoffBudgetTokens(opts.budgetTokens);
736
+ const effective = applyHandoffBudget(context, budgetTokens);
737
+ return sanitizeFencedBody(buildDependencyOutputBodyParts(effective).join("\n").trim());
579
738
  }
580
739
 
581
740
  export function writeTaskSharedOutput(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): ArtifactDescriptor | undefined {
@@ -203,6 +203,9 @@ export const HANDOFF_TEMPLATE = [
203
203
  "",
204
204
  "### Follow-ups",
205
205
  "<!-- Any remaining issues or next steps -->",
206
+ "",
207
+ "### Provenance",
208
+ "<!-- Self-report your runtime identity (pre-set in your bash tool env, one line): PI_SESSION_ID, PI_SESSION_FILE, PI_MODEL, PI_PROVIDER, PI_REASONING_LEVEL — e.g. `session=<id> model=<provider>/<model> reasoning=<level>` -->",
206
209
  ].join("\n");
207
210
 
208
211
  export interface ParsedHandoff {
@@ -30,6 +30,7 @@ import * as path from "node:path";
30
30
  import { loadConfig } from "../../config/config.ts";
31
31
  import { getCrewEnv, getCrewEnvInt } from "../../config/env-vars.ts";
32
32
  import { errors } from "../../errors.ts";
33
+ import type { MetricRegistry } from "../../observability/metric-registry.ts";
33
34
  import { appendEventAsync, appendEventBuffered } from "../../state/event-log/event-log.ts";
34
35
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
35
36
  import { upsertOwnershipEntry } from "../../state/stores/ownership-map.ts";
@@ -38,6 +39,8 @@ import { type FatalFsCause, failureCauseForAttempt } from "../../utils/fs-errno.
38
39
  import { logInternalError } from "../../utils/internal-error.ts";
39
40
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
40
41
  import type { ChildPiLifecycleEvent, ChildPiRunResult } from "../child-pi/child-pi.ts";
42
+ import type { SessionRecoveryInfo } from "../child-pi/session-recovery.ts";
43
+ import { classifyBool, resolveClassifierEnabled, resolveClassifierModel } from "../classifier/classifier-service.ts";
41
44
  import {
42
45
  appendCrewAgentEventBuffered,
43
46
  appendCrewAgentOutputBuffered,
@@ -393,12 +396,113 @@ export function attemptErrorFor(
393
396
  return err;
394
397
  }
395
398
 
399
+ /** Outcome of the P2-1 retry-triage classifier consultation. */
400
+ export interface RetryTriageResult {
401
+ /** false → skip the queued re-attempt (classifier judged the failure permanent). */
402
+ proceedWithRetry: boolean;
403
+ /** true ONLY when a real classifier answer informed the decision (event + decision metric emitted). */
404
+ consulted: boolean;
405
+ }
406
+
407
+ export interface RetryTriageArgs {
408
+ /** Pre-resolved gate (resolveClassifierEnabled) — false short-circuits before ANY registry touch. */
409
+ enabled: boolean;
410
+ /** Host-process model registry (ctx.modelRegistry, threaded unknown). */
411
+ modelRegistry: unknown;
412
+ /** Pre-resolved classifier model id (resolveClassifierModel). */
413
+ classifierModel: string;
414
+ /** Failure summary the attempt loop already computed (task error string). */
415
+ failureSummary: string;
416
+ /** Model the failed attempt used. */
417
+ failedModel: string;
418
+ /** Failed attempt's exit code (null = signal death). */
419
+ exitCode: number | null;
420
+ /** Event-log identity for the task.retry_triage diagnostic event. */
421
+ eventsPath: string;
422
+ runId: string;
423
+ taskId: string;
424
+ /** Optional host metric registry (crew.classifier.decisions_total). */
425
+ metricRegistry?: MetricRegistry;
426
+ }
427
+
428
+ /**
429
+ * P2-1 (pi 1.0.4 adoption) spike consumer: transient-vs-permanent triage for
430
+ * a queued model-fallback re-attempt, asked BEFORE burning the next full
431
+ * worker spawn. ONE bool question built from the failure summary; the answer
432
+ * is honored ONLY when `enabled` (runtime.classifierEnabled, default FALSE →
433
+ * dormant → zero behavior change: the helper returns without touching the
434
+ * registry). Fallback on every classifier failure mode is `proceedWithRetry:
435
+ * true` — the pre-existing retry behavior (classifyBool never-rejects).
436
+ *
437
+ * Emits `crew.classifier.decisions_total{consumer,decision}` + a
438
+ * `task.retry_triage` event ONLY when a real classifier answer informed the
439
+ * decision (fallback paths are already counted by the service's
440
+ * calls_total metric and must not double-report a decision they did not make).
441
+ */
442
+ export async function triageRetryWithClassifier(args: RetryTriageArgs): Promise<RetryTriageResult> {
443
+ if (!args.enabled) return { proceedWithRetry: true, consulted: false };
444
+ const result = await classifyBool({
445
+ modelRegistry: args.modelRegistry,
446
+ classifierModel: args.classifierModel,
447
+ questionKey: "transient",
448
+ question: {
449
+ type: "bool",
450
+ instructions:
451
+ "A worker task failed and a retry on a different model is queued. Read the failure state. Is the failure TRANSIENT (a retry has a realistic chance of success, e.g. rate limit, provider hiccup, timeout, network error) rather than PERMANENT (a retry will fail the same way, e.g. auth/permission denied, invalid request, missing resource, deterministic error)?",
452
+ criteria: {
453
+ true: "Transient — the queued retry may succeed",
454
+ false: "Permanent — the queued retry will fail the same way",
455
+ },
456
+ },
457
+ state: {
458
+ // Cap the summary: classifiers reason over small JSON state — the raw
459
+ // error can carry multi-KB stderr tails.
460
+ failureSummary: args.failureSummary.slice(0, 2000),
461
+ failedModel: args.failedModel,
462
+ exitCode: args.exitCode,
463
+ },
464
+ fallback: true,
465
+ metricRegistry: args.metricRegistry,
466
+ metricLabels: { consumer: "retry_triage" },
467
+ });
468
+ if (!result.fromClassifier) return { proceedWithRetry: true, consulted: false };
469
+ const decision = result.decision ? "transient" : "permanent";
470
+ try {
471
+ args.metricRegistry?.counter("crew.classifier.decisions_total", "Classifier-informed decisions by consumer and verdict").inc({
472
+ consumer: "retry_triage",
473
+ decision: result.decision ? "transient_retry" : "permanent_skip",
474
+ });
475
+ } catch (metricError) {
476
+ logInternalError("child-executor.retry-triage.metric", metricError, `decision=${decision}`, "warn");
477
+ }
478
+ void appendEventAsync(args.eventsPath, {
479
+ type: "task.retry_triage",
480
+ runId: args.runId,
481
+ taskId: args.taskId,
482
+ message: `Retry triage classified the failure as ${decision} (classifier ${result.usedModel ?? args.classifierModel})`,
483
+ data: {
484
+ decision,
485
+ classifierModel: result.usedModel,
486
+ configuredModel: args.classifierModel,
487
+ failedModel: args.failedModel,
488
+ stopReason: result.stopReason,
489
+ },
490
+ }).catch(() => {
491
+ /* no-op: best-effort diagnostic append, ignore delivery errors */
492
+ });
493
+ return { proceedWithRetry: result.decision, consulted: true };
494
+ }
495
+
396
496
  export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<TaskExecutionResult> {
397
497
  const input = ctx.input;
398
498
  const manifest: TeamRunManifest = ctx.manifest;
399
499
  let task = ctx.task;
400
500
  let tasks = ctx.tasks;
401
- const prompt = ctx.prompt;
501
+ // R3-1: the child-pi worker's USER message is the dynamic suffix — the
502
+ // run-static header rides --append-system-prompt (compaction-safe).
503
+ // Fallback to the full prompt keeps hand-built contexts (tests, custom
504
+ // callers that skip pre-execution) on the old single-channel behavior.
505
+ const userPrompt = ctx.userPrompt ?? ctx.prompt;
402
506
  const skillPaths = ctx.skillPaths;
403
507
  const collectedJsonEvents = ctx.collectedJsonEvents;
404
508
  const streamBridge: StreamBridgeHandle | undefined = ctx.streamBridge;
@@ -409,6 +513,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
409
513
  let modelAttempts: ModelAttemptSummary[] | undefined;
410
514
  let parsedOutput: ParsedPiJsonOutput | undefined;
411
515
  let rawFinalText: string | undefined;
516
+ // W2 (P1-1): last attempt's session-recovery provenance (thread-level,
517
+ // mirrors rawFinalText — escapes the attempt loop's block scope).
518
+ let recoveredFromSession: SessionRecoveryInfo | undefined;
412
519
  let intermediateFindings: string | undefined;
413
520
  let finalStdout = "";
414
521
  let transcriptPath: string | undefined;
@@ -417,6 +524,12 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
417
524
 
418
525
  const modelFallbackPolicy = resolveTaskModelFallbackPolicy(task.cwd);
419
526
  const defaultSubagentModel = resolveTaskDefaultSubagentModel(task.cwd);
527
+ // P2-1 (pi 1.0.4 adoption): retry-triage classifier gate — resolved once
528
+ // per task (env PI_CREW_CLASSIFIER_ENABLED beats runtime.classifierEnabled
529
+ // beats default FALSE — dormant). The disabled path never touches the
530
+ // registry, so default-off is a zero behavior change.
531
+ const classifierTriageEnabled = resolveClassifierEnabled(input.runtimeConfig?.classifierEnabled);
532
+ const classifierTriageModel = resolveClassifierModel(input.runtimeConfig?.classifierModel);
420
533
  const modelRoutingPlan = buildConfiguredModelRouting({
421
534
  overrideModel: input.modelOverride,
422
535
  stepModel: input.step.model,
@@ -652,7 +765,15 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
652
765
  try {
653
766
  childResult = await runWorker({
654
767
  cwd: task.cwd,
655
- task: prompt,
768
+ task: userPrompt,
769
+ systemPromptAppend: ctx.systemPromptAppend,
770
+ // R3-19/D5: thread the runtime.hermeticWorkers config gate to the
771
+ // worker argv (--no-extensions). Env PI_CREW_HERMETIC_WORKERS still
772
+ // overrides either way (resolveHermeticWorkers).
773
+ hermeticWorkers: input.runtimeConfig?.hermeticWorkers,
774
+ // DR2/D1: thread runtime.workerTransport to the run-worker seam (env
775
+ // PI_CREW_WORKER_TRANSPORT still wins — resolveWorkerTransport).
776
+ workerTransport: input.runtimeConfig?.workerTransport,
656
777
  agent: input.agent,
657
778
  model,
658
779
  signal: timeoutController.signal,
@@ -665,6 +786,10 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
665
786
  parentContext: input.parentContext,
666
787
  excludeContextBash: input.runtimeConfig?.excludeContextBash,
667
788
  sessionId: manifest.sessionId,
789
+ // W2 (P1-1): thread the runtime.sessionRecovery flag so the config
790
+ // gate reaches the crash-path tail-replay (env/default still work
791
+ // without this — resolveSessionRecoveryEnabled precedence).
792
+ sessionRecovery: input.runtimeConfig?.sessionRecovery,
668
793
  role: task.role,
669
794
  thinkingOverride: input.teamRoleThinking,
670
795
  runId: manifest.runId,
@@ -997,6 +1122,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
997
1122
  const transcriptText = tailReadWithLineSnap(transcriptPath, MAX_TRANSCRIPT_PARSE_BYTES, childResult.stdout);
998
1123
  parsedOutput = parsePiJsonOutput(transcriptText);
999
1124
  rawFinalText = childResult.rawFinalText;
1125
+ // W2 (P1-1): keep the most recent recovery info (crash-path only —
1126
+ // undefined on clean exits, so this stays dormant in the happy path).
1127
+ recoveredFromSession = childResult.recoveredFromSession ?? recoveredFromSession;
1000
1128
  intermediateFindings = childResult.intermediateFindings;
1001
1129
  error = attemptErrorFor(childResult, parsedOutput, task.id);
1002
1130
  // bug-026 sub-issue B: classify fatal fs errnos (ENOSPC/EDQUOT/EMFILE/
@@ -1074,6 +1202,30 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
1074
1202
  }
1075
1203
  }
1076
1204
  if (!nextModel || !isRetryableModelFailure(error)) break;
1205
+ // P2-1 spike: before burning the queued (expensive) re-attempt, ask the
1206
+ // host classifier whether the failure looks transient. Dormant unless
1207
+ // classifierTriageEnabled — the disabled path returns without touching
1208
+ // the registry (zero behavior change), and every classifier failure
1209
+ // mode falls back to proceeding with the retry (never-rejects contract).
1210
+ const triage = await triageRetryWithClassifier({
1211
+ enabled: classifierTriageEnabled,
1212
+ modelRegistry: input.modelRegistry,
1213
+ classifierModel: classifierTriageModel,
1214
+ failureSummary: error,
1215
+ failedModel: attempt.model,
1216
+ exitCode,
1217
+ eventsPath: manifest.eventsPath,
1218
+ runId: manifest.runId,
1219
+ taskId: task.id,
1220
+ metricRegistry: input.metricRegistry,
1221
+ });
1222
+ if (!triage.proceedWithRetry) {
1223
+ logs.push(
1224
+ `CLASSIFIER TRIAGE: failure classified PERMANENT — skipping retry on ${nextModel} (classifier ${classifierTriageModel})`,
1225
+ "",
1226
+ );
1227
+ break;
1228
+ }
1077
1229
  logs.push(formatModelAttemptNote(attempt, nextModel), "");
1078
1230
  }
1079
1231
  // E2 (Round 15): when the fallback chain was used and STILL failed, surface
@@ -1121,6 +1273,12 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
1121
1273
  const resultCandidates: ReadonlyArray<{ source: ResultSource; text: string | undefined }> = [
1122
1274
  { source: "rawFinalText", text: cleanResultText(rawFinalText) },
1123
1275
  { source: "finalText", text: cleanResultText(parsedOutput?.finalText) },
1276
+ // W2 (P1-1): crash-path session tail-recovery. Ranks BELOW live captures
1277
+ // (raw/finalText — the stream is authoritative when it survived) but
1278
+ // ABOVE stdout/stderr noise: on signal death (exitCode null) the live
1279
+ // captures are empty and this is the recovered last complete assistant
1280
+ // message from the worker's session JSONL.
1281
+ { source: "session", text: cleanResultText(recoveredFromSession?.text) },
1124
1282
  { source: "stdout", text: cleanResultText(finalStdout) },
1125
1283
  { source: "stderr", text: cleanResultText(finalStderr) },
1126
1284
  { source: "findings", text: cleanResultText(intermediateFindings) },
@@ -1142,6 +1300,18 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
1142
1300
  "(no output)",
1143
1301
  producer: task.id,
1144
1302
  });
1303
+ // W2 (P1-1): serialize the recovery provenance as a result sidecar — the
1304
+ // recovered text itself flows through the "session" candidate above; this
1305
+ // records WHERE it came from (session id/file, timestamp, stopReason) so
1306
+ // crash-path recovery is auditable after the fact.
1307
+ if (recoveredFromSession) {
1308
+ writeArtifact(manifest.artifactsRoot, {
1309
+ kind: "result",
1310
+ relativePath: `results/${task.id}.session-recovery.json`,
1311
+ content: JSON.stringify(recoveredFromSession, null, "\t"),
1312
+ producer: task.id,
1313
+ });
1314
+ }
1145
1315
  const logArtifact = writeArtifact(manifest.artifactsRoot, {
1146
1316
  kind: "log",
1147
1317
  relativePath: `logs/${task.id}.log`,
@@ -86,16 +86,18 @@ export interface TaskExecutionResult {
86
86
  terminalEvidence: OperationTerminalEvidence[];
87
87
  startupEvidence: import("../heartbeat/worker-startup.ts").WorkerStartupEvidence;
88
88
  /** Which child-executor fallback branch produced the result artifact
89
- * content (rawFinalText → finalText → stdout → stderr → findings →
89
+ * content (rawFinalText → finalText → session → stdout → stderr → findings →
90
90
  * "(no output)"). Finding #2 (2026-09-29 battery): "stderr"/"none" mean
91
91
  * every authoritative worker output source was empty — finalizeTaskResult
92
92
  * fails those DETERMINISTICALLY; the stderr-noise classifier stays a
93
- * defense layer, never the verdict oracle. */
93
+ * defense layer, never the verdict oracle. "session" (W2/P1-1) = the text
94
+ * was tail-recovered from the worker's session JSONL on a signal-death
95
+ * crash path — authoritative recovered output, not a failure source. */
94
96
  resultSource?: ResultSource;
95
97
  }
96
98
 
97
99
  /** Result-artifact provenance tags (see {@link TaskExecutionResult.resultSource}). */
98
- export type ResultSource = "rawFinalText" | "finalText" | "stdout" | "stderr" | "findings" | "none";
100
+ export type ResultSource = "rawFinalText" | "finalText" | "session" | "stdout" | "stderr" | "findings" | "none";
99
101
 
100
102
  /** Final persisted task state returned by finalizeTaskResult. */
101
103
  export interface FinalTaskResult {
@@ -69,6 +69,17 @@ export interface TaskExecutionContext {
69
69
  skillNames: string[] | undefined;
70
70
  skillPaths: string[] | undefined;
71
71
  prompt: string;
72
+ /** R3-1: the USER-message half of the rendered prompt (dynamicSuffix) —
73
+ * what the worker's task.md actually carries when the run-static header
74
+ * moves to the --append-system-prompt channel. `prompt` stays the FULL
75
+ * rendered prompt (stablePrefix + dynamicSuffix) for artifacts and the
76
+ * live-session path. Optional so hand-built contexts (tests, custom
77
+ * callers) keep the old single-channel behavior via the ?? fallback. */
78
+ userPrompt?: string;
79
+ /** R3-1: run-static worker header (stablePrefix) for the append-system-prompt
80
+ * channel — threaded to buildPiWorkerArgs via ChildPiRunInput. Undefined on
81
+ * hand-built contexts → single-channel legacy behavior. */
82
+ systemPromptAppend?: string;
72
83
  promptArtifact: ArtifactDescriptor;
73
84
  /** G18: prompt-breakdown artifact descriptor (undefined when PI_CREW_PROMPT_BREAKDOWN off). */
74
85
  breakdownArtifact?: ArtifactDescriptor;
@@ -141,7 +152,14 @@ export async function prepareTaskExecutionContext(
141
152
  // caller provided one) into the dep-context collection — cache hits reuse
142
153
  // the closeout's reads byte-identically; undefined keeps uncached behavior.
143
154
  const dependencyContext = collectDependencyOutputContext(manifest, input.tasks, input.task, input.step, input.resultReadCache);
144
- const dependencyContextText = input.dependencyContextText ?? renderDependencyOutputContext(dependencyContext);
155
+ // Handoff budget: caps the dynamic.dependencyContext layer at an est-token
156
+ // (chars/4) budget (env PI_CREW_HANDOFF_BUDGET_TOKENS > runtime config >
157
+ // default 1800). IMPORTANT: only the RENDER path is budgeted — an explicit
158
+ // input.dependencyContextText override (leader-provided text) passes through
159
+ // UNCHANGED.
160
+ const dependencyContextText =
161
+ input.dependencyContextText ??
162
+ renderDependencyOutputContext(dependencyContext, { budgetTokens: input.runtimeConfig?.handoffBudgetTokens });
145
163
  let task: TeamTaskState = {
146
164
  ...input.task,
147
165
  cwd: workspace.cwd,
@@ -238,6 +256,14 @@ export async function prepareTaskExecutionContext(
238
256
  step: input.step,
239
257
  override: input.skillOverride,
240
258
  runId: manifest.runId,
259
+ // R3-23: the child-process spawn path feeds every resolved skill to
260
+ // the worker via --skill (ctx.skillPaths → buildPiWorkerArgs), so the
261
+ // host advertises them in <available_skills> — drop the duplicated
262
+ // per-skill entries from the prompt block. Live-session/scaffold
263
+ // workers get no --skill channel and keep the full block. Externally
264
+ // supplied skillPaths may diverge from this render — stay safe and
265
+ // keep the full block for those callers.
266
+ advertisedByHost: runtimeKind === "child-process" && input.skillPaths === undefined,
241
267
  })
242
268
  : undefined;
243
269
  const skillBlock = input.skillBlock ?? renderedSkills?.block;
@@ -435,6 +461,11 @@ export async function prepareTaskExecutionContext(
435
461
  collectedJsonEvents,
436
462
  streamBridge,
437
463
  startupEvidence,
464
+ // R3-1: channel split for child-pi workers — user message = dynamic
465
+ // suffix; run-static header = system-prompt append channel (compaction
466
+ // summarizes the user-message span, compaction.md:150-160).
467
+ userPrompt: promptResult.dynamicSuffix,
468
+ systemPromptAppend: promptResult.stablePrefix,
438
469
  },
439
470
  };
440
471
  }
@@ -275,11 +275,14 @@ export interface RenderedTaskPrompt {
275
275
  /** Full rendered prompt (stablePrefix + dynamicSuffix). */
276
276
  full: string;
277
277
  /**
278
- * SR-02 phase 1 (2026-09-23): per-section char counts of the USER prompt,
278
+ * SR-02 phase 1 (2026-09-23): per-section char counts of the worker prompt,
279
279
  * keyed by section name — the token-breakdown instrumentation. Populated
280
280
  * only when PI_CREW_PROMPT_BREAKDOWN=1 (off by default; zero cost when off:
281
281
  * the sections object is built lazily). Pre-execution adds the SYSTEM-side
282
282
  * pieces (agent definition, skills) and writes the JSON artifact.
283
+ * R3-1 note: stable.* sections ride the --append-system-prompt channel;
284
+ * total.userPrompt counts the user message (dynamic suffix) only, while
285
+ * total.systemAppend counts the system-channel header.
283
286
  */
284
287
  sections?: Record<string, number>;
285
288
  }
@@ -424,6 +427,8 @@ export async function renderTaskPrompt(
424
427
  const full = [stablePrefix, "", dynamicSuffix].join("\n");
425
428
  const sections: Record<string, number> | undefined = promptBreakdownEnabled()
426
429
  ? {
430
+ // R3-1: the stable.* sections now ride the --append-system-prompt
431
+ // channel; the worker's user message is the dynamic suffix only.
427
432
  "stable.runtimeHeader": headerBlock.length,
428
433
  "stable.protocol": protocolBlock.length,
429
434
  "stable.roleInstructions": roleInstructions.length,
@@ -440,7 +445,10 @@ export async function renderTaskPrompt(
440
445
  "dynamic.memory": memoryBlock.length,
441
446
  "dynamic.outputSchema": outputSchemaBlock.length,
442
447
  "dynamic.taskAndHandoff": taskAndHandoff.length,
443
- "total.userPrompt": full.length,
448
+ // R3-1: channel split — the user message carries ONLY the dynamic
449
+ // suffix; the stable prefix went to the system-prompt append channel.
450
+ "total.systemAppend": stablePrefix.length,
451
+ "total.userPrompt": dynamicSuffix.length,
444
452
  }
445
453
  : undefined;
446
454
  return { stablePrefix, dynamicSuffix, full, sections };
@@ -1,5 +1,6 @@
1
1
  import type { AgentConfig } from "../agents/agent-config.ts";
2
2
  import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
3
+ import type { MetricRegistry } from "../observability/metric-registry.ts";
3
4
  import { writeArtifact } from "../state/stores/artifact-store.ts";
4
5
  import type { ArtifactDescriptor, OperationTerminalEvidence, TeamRunManifest, TeamTaskState } from "../state/types.ts";
5
6
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
@@ -51,6 +52,11 @@ export interface TaskRunnerInput {
51
52
  parentContext?: string;
52
53
  parentModel?: unknown;
53
54
  modelRegistry?: unknown;
55
+ /** P2-1 (pi 1.0.4 adoption): host-process metric registry threaded from
56
+ * ExecuteTeamRunInput so the retry-triage classifier seam can count its
57
+ * calls/decisions (crew.classifier.* counters). Optional — undefined keeps
58
+ * the uncounted behavior (e.g. tests, hand-built contexts). */
59
+ metricRegistry?: MetricRegistry;
54
60
  modelOverride?: string;
55
61
  teamRoleModel?: string;
56
62
  teamRoleFallbackModels?: string[];