pi-crew 0.11.21 → 0.11.23

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 (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/index.mjs +542 -531
  3. package/package.json +2 -1
  4. package/src/errors.ts +14 -1
  5. package/src/extension/async-notifier.ts +42 -17
  6. package/src/extension/cross-extension-rpc.ts +42 -5
  7. package/src/extension/notification-router.ts +15 -2
  8. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  9. package/src/extension/registration/runtime-cleanup.ts +17 -0
  10. package/src/extension/registration/wire-cross-extension.ts +26 -0
  11. package/src/extension/rpc-hmac.ts +53 -2
  12. package/src/extension/team-tool/cancel.ts +34 -21
  13. package/src/extension/team-tool/dispatch/index.ts +6 -5
  14. package/src/extension/team-tool/dispatch/status.ts +42 -2
  15. package/src/extension/team-tool/respond.ts +8 -6
  16. package/src/extension/team-tool/run.ts +4 -4
  17. package/src/extension/team-tool.ts +24 -2
  18. package/src/prompt/inbox-poll.ts +77 -25
  19. package/src/prompt/message-tool.ts +62 -4
  20. package/src/prompt/prompt-runtime.ts +82 -32
  21. package/src/prompt/surface-worker.ts +13 -1
  22. package/src/runtime/broker/crew-broker-client.ts +74 -16
  23. package/src/runtime/broker/crew-broker.ts +120 -97
  24. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  25. package/src/runtime/broker/protocol/request-parsers.ts +68 -0
  26. package/src/runtime/broker/wait-status-cache.ts +14 -5
  27. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  28. package/src/runtime/child-pi/child-pi.ts +20 -2
  29. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  30. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  31. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  32. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  33. package/src/runtime/crew-agent-records.ts +32 -14
  34. package/src/runtime/delivery-coordinator.ts +30 -0
  35. package/src/runtime/dispatch-batch.ts +9 -6
  36. package/src/runtime/dwf-state-store.ts +41 -7
  37. package/src/runtime/finalize-run.ts +13 -2
  38. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  39. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  40. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  41. package/src/runtime/live-session/embedded-tier.ts +3 -1
  42. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  43. package/src/runtime/model/model-fallback.ts +23 -1
  44. package/src/runtime/output/pi-json-output.ts +26 -1
  45. package/src/runtime/policy-engine.ts +18 -0
  46. package/src/runtime/process/zombie-scanner.ts +84 -16
  47. package/src/runtime/process-status.ts +6 -1
  48. package/src/runtime/recovery/crash-recovery.ts +170 -28
  49. package/src/runtime/rpc/rpc-worker.ts +44 -1
  50. package/src/runtime/scheduling/run-coalesced-task-group.ts +73 -6
  51. package/src/runtime/scheduling/scheduler.ts +74 -11
  52. package/src/runtime/scratchpad/engine.ts +26 -3
  53. package/src/runtime/settings-store.ts +22 -2
  54. package/src/runtime/stale-reconciler.ts +4 -1
  55. package/src/runtime/surface/tmux-provider.ts +24 -3
  56. package/src/runtime/task-health.ts +16 -1
  57. package/src/runtime/task-output-context.ts +143 -71
  58. package/src/runtime/task-packet.ts +70 -17
  59. package/src/runtime/task-runner/child-executor.ts +27 -0
  60. package/src/runtime/task-runner/output-splitter.ts +79 -16
  61. package/src/runtime/task-runner/post-execution.ts +75 -15
  62. package/src/runtime/task-runner/state-helpers.ts +92 -2
  63. package/src/runtime/usage-tracker.ts +11 -0
  64. package/src/runtime/verification/green-contract.ts +17 -0
  65. package/src/runtime/verification/verification-gates.ts +24 -6
  66. package/src/runtime/workspace-lock.ts +53 -0
  67. package/src/schema/team-tool-schema.ts +10 -4
  68. package/src/state/coordination/mailbox.ts +133 -38
  69. package/src/state/decision-ledger.ts +56 -8
  70. package/src/state/event-log/cursor.ts +14 -2
  71. package/src/state/event-log/event-log-rotation.ts +83 -5
  72. package/src/state/event-log/event-log.ts +181 -12
  73. package/src/state/stores/active-run-registry.ts +10 -3
  74. package/src/state/types.ts +9 -1
  75. package/src/ui/agents-jobs-browser.ts +22 -8
  76. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  77. package/src/ui/events-state-source.ts +67 -28
  78. package/src/ui/inline-panel/agent-pane.ts +13 -6
  79. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  80. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  81. package/src/ui/inline-panel/index.ts +21 -1
  82. package/src/ui/live-run-sidebar.ts +14 -3
  83. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  84. package/src/ui/powerbar-publisher.ts +6 -3
  85. package/src/ui/rail.ts +7 -1
  86. package/src/ui/render-scheduler.ts +53 -1
  87. package/src/ui/run-action-dispatcher.ts +68 -20
  88. package/src/ui/status-colors.ts +9 -0
  89. package/src/utils/env-filter.ts +8 -0
@@ -24,6 +24,8 @@ import { dirname } from "node:path";
24
24
  import { createInterface } from "node:readline";
25
25
  import { fileURLToPath } from "node:url";
26
26
 
27
+ import { logInternalError } from "../../utils/internal-error.ts";
28
+
27
29
  /** D6 read-side cap (Checkpoint B, MINOR-2): restoreState refuses a file larger
28
30
  * than this — an independent guard from the lifecycle cap (same value, no
29
31
  * import to keep engine free of a lifecycle cycle). Bounds v8.deserialize
@@ -80,6 +82,10 @@ export interface RestoreResult {
80
82
  path: string;
81
83
  restored: string[];
82
84
  failed: { name: string; reason: string }[];
85
+ /** P-5 (DR-04 round-7): set when the snapshot was REJECTED before restore —
86
+ * e.g. `unsupported-snapshot-version:<v>`. Distinguishes "restored 0 vars
87
+ * from a valid v1 snapshot" from "refused an unknown format". */
88
+ reason?: string;
83
89
  }
84
90
 
85
91
  export interface EngineOptions {
@@ -353,7 +359,14 @@ export class EngineManager {
353
359
  */
354
360
  async dispose(snapshotPath?: string): Promise<void> {
355
361
  if (snapshotPath && this.engineState === "running") {
356
- await this.snapshotState(snapshotPath).catch(() => null);
362
+ await this.snapshotState(snapshotPath).catch((error: unknown) => {
363
+ // E2 (DR-02): the guest is SIGKILLed below regardless and dispose
364
+ // must never throw — but silently discarding the FINAL snapshot made
365
+ // crash-recovery quietly lose engine state. Surface the loss at
366
+ // warn before dropping it. Keep returning null (no rethrow).
367
+ logInternalError("scratchpad.final-snapshot-failed", error, `snapshotPath=${snapshotPath}`, "warn");
368
+ return null;
369
+ });
357
370
  }
358
371
  await this.kill();
359
372
  }
@@ -620,11 +633,21 @@ export class EngineManager {
620
633
  const st = fstatSync(fd);
621
634
  if (!st.isFile() || st.isSymbolicLink()) return null;
622
635
  if (st.size > SNAPSHOT_MAX_BYTES) return null;
623
- // file validated → lazily start the engine, then read+restore via fd.
624
- await this.start();
625
636
  const payload = JSON.parse(readFileSync(fd, { encoding: "utf8" })) as {
637
+ version?: number;
626
638
  vars?: Record<string, string>;
627
639
  };
640
+ // P-5 (DR-04 round-7): version gate BEFORE starting the engine — an
641
+ // unknown snapshot format must fail LOUDLY (warn + explicit result with
642
+ // restored:[] and a reason), not silently fail-open into an empty
643
+ // namespace (which reads as "nothing to restore" to every caller).
644
+ if (payload.version !== 1) {
645
+ const reason = `unsupported-snapshot-version:${String(payload.version)}`;
646
+ logInternalError("scratchpad.restoreState", new Error(`${reason} (expected 1) — refusing restore`), path, "warn");
647
+ return { path, restored: [], failed: [], reason };
648
+ }
649
+ // file validated → lazily start the engine, then restore.
650
+ await this.start();
628
651
  const vars = payload.vars ?? {};
629
652
  const reply = await this.request({ type: "restore", id: randomUUID(), vars }, SNAPSHOT_REQUEST_TIMEOUT_MS);
630
653
  if (reply.type !== "restore_result") return null;
@@ -39,6 +39,25 @@ function validateScheduledJob(job: unknown): boolean {
39
39
  return typeof obj.id === "string" && obj.id.length > 0 && typeof obj.scheduleType === "string" && typeof obj.enabled === "boolean";
40
40
  }
41
41
 
42
+ /**
43
+ * R6-2(c) (round-6 audit): a persisted `lastStatus:"running"` is an in-flight
44
+ * flag written by a process that may already be dead when the file is next
45
+ * read. It is meaningless at load time and lethal downstream: the scheduler's
46
+ * fire() treats "running" as backpressure (in-flight guard), so re-loading the
47
+ * flag verbatim made a job that was mid-run at crash/restart time skip EVERY
48
+ * later occurrence — scheduled jobs died after restart. Strip only the stale
49
+ * "running" flag at load; terminal statuses ("success"/"error") survive
50
+ * untouched, as does every other field.
51
+ */
52
+ function normalizeStaleRunningJob(job: unknown): unknown {
53
+ if (!job || typeof job !== "object") return job;
54
+ const obj = job as Record<string, unknown>;
55
+ if (obj.lastStatus !== "running") return job;
56
+ const copy: Record<string, unknown> = { ...obj };
57
+ delete copy.lastStatus;
58
+ return copy;
59
+ }
60
+
42
61
  function sanitizeSettings(raw: unknown): CrewSettings {
43
62
  if (!raw || typeof raw !== "object") return {};
44
63
  const r = raw as Record<string, unknown>;
@@ -74,9 +93,10 @@ function sanitizeSettings(raw: unknown): CrewSettings {
74
93
  if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
75
94
  out.notifierIntervalMs = r.notifierIntervalMs;
76
95
  }
77
- // Pass through scheduledJobs after basic validation
96
+ // Pass through scheduledJobs after basic validation + stale-flag
97
+ // normalization (R6-2c — see normalizeStaleRunningJob).
78
98
  if (Array.isArray(r.scheduledJobs)) {
79
- out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob);
99
+ out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob).map(normalizeStaleRunningJob);
80
100
  }
81
101
  return out;
82
102
  }
@@ -713,7 +713,10 @@ export function reconcileOrphanedTempWorkspaces(
713
713
  const result = reconcileStaleRun(manifest, tasks, now);
714
714
  if (result.repaired && result.repairedTasks) {
715
715
  // Persist repaired tasks (atomic — temp+rename to survive mid-write crash)
716
- atomicWriteJson(tasksPath, result.repairedTasks);
716
+ // R2-H1 (fix): persist the FULL array (NEW-1 persistTasks), not the filtered
717
+ // stale subset — the subset write destroyed every sibling record (mirror
718
+ // crash-recovery.ts:1066).
719
+ atomicWriteJson(tasksPath, result.persistTasks ?? result.repairedTasks);
717
720
  // Update manifest status
718
721
  const updated = {
719
722
  ...manifest,
@@ -12,12 +12,14 @@
12
12
  * onExit: MỘT interval 2s cho cả provider — mỗi tick gọi list-panes đúng
13
13
  * một lần rồi đối chiếu mọi pane đang được theo dõi (không spawn N subprocess
14
14
  * cho N pane). Pane biến khỏi list hoặc pane_dead=1 → "pane-closed" bắn đúng
15
- * một lần. tmux call throw (binary mất đột ngột, server chết) → "mux-dead"
15
+ * một lần. tmux call throw HAI lần liên tiếp (binary mất đột ngột, server chết
16
+ * — R7-13 strike counter; transient EAGAIN/EMFILE chỉ warn) → "mux-dead"
16
17
  * cho mọi handle còn sống.
17
18
  */
18
19
 
19
20
  import { execFileSync } from "node:child_process";
20
21
 
22
+ import { logInternalError } from "../../utils/internal-error.ts";
21
23
  import type { SurfaceDetection, SurfaceExitReason, SurfaceHandle, SurfaceProvider, SurfaceSpawnOpts } from "./surface-provider.ts";
22
24
  import { MAX_PANES_PER_TAB, splitDirectionFor } from "./surface-provider.ts";
23
25
 
@@ -111,6 +113,11 @@ export function createTmuxProvider(deps: TmuxProviderDeps = {}): SurfaceProvider
111
113
  // Watcher theo pane id — MỘT timer chung cho mọi pane đang theo dõi.
112
114
  const watchers = new Map<string, PaneWatcher>();
113
115
  let timer: SurfaceTimer | null = null;
116
+ // R7-13 (DR-04 round-7): consecutive-failure strike counter cho poll list-panes.
117
+ // 1 lỗi transient (EAGAIN/EMFILE, binary hog dụng một chốc) KHÔNG được giết
118
+ // hết watcher — chỉ tăng strike + log warn; đến strike thứ 2 liên tiếp mới
119
+ // kết luận mux chết thật (semantics cũ). Poll thành công bất kỳ → reset về 0.
120
+ let muxFailStrikes = 0;
114
121
 
115
122
  // Tab-layout (spec 2026-08-27-surface-tab-layout): tabKey(run) → MỌI window
116
123
  // của run + số pane đã spawn trong window đang nhận. Tab mở khi run spawn
@@ -153,11 +160,25 @@ export function createTmuxProvider(deps: TmuxProviderDeps = {}): SurfaceProvider
153
160
  // còn không -a thì chỉ thấy current window (pane-closed giả khi user
154
161
  // switch window giữa chừng).
155
162
  stdout = tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"]);
156
- } catch {
157
- // tmux binary mất đột ngột / server chết — mọi handle còn sống thành mux-dead.
163
+ } catch (error) {
164
+ // R7-13: strike counter — ≥2 lỗi list-panes LIÊN TIẾP mới coi là mux chết.
165
+ // Lỗi đơn lẻ (transient EAGAIN/EMFILE…) → giữ nguyên watchers + warn,
166
+ // tick kế vẫn poll (counter được reset bởi bất kỳ poll thành công nào).
167
+ muxFailStrikes += 1;
168
+ if (muxFailStrikes < 2) {
169
+ logInternalError(
170
+ "tmux-provider",
171
+ error instanceof Error ? error : new Error(String(error)),
172
+ `list-panes failed (strike ${muxFailStrikes}/2) — keeping pane watchers alive`,
173
+ "warn",
174
+ );
175
+ return;
176
+ }
177
+ // tmux binary mất đột ngột / server chết thật sự — mọi handle còn sống thành mux-dead.
158
178
  for (const paneId of [...watchers.keys()]) fire(paneId, "mux-dead");
159
179
  return;
160
180
  }
181
+ muxFailStrikes = 0;
161
182
  const status = parsePaneStatus(stdout);
162
183
  for (const [paneId, watcher] of [...watchers]) {
163
184
  // Không có trong list (đã dọn) hoặc pane_dead=1 → đóng.
@@ -32,6 +32,10 @@ export interface TaskSummary {
32
32
  id: string;
33
33
  status: string;
34
34
  stalledSince?: number;
35
+ /** L-6 (2026-10-16): last worker heartbeat (tasks already carry
36
+ * `heartbeat.lastSeenAt` — no new writer needed). Preferred over
37
+ * `stalledSince`, which has no writer. */
38
+ heartbeat?: { lastSeenAt: string };
35
39
  }
36
40
 
37
41
  export interface ManifestSummary {
@@ -47,7 +51,18 @@ export function computeRunHealth(manifest: ManifestSummary): RunHealth {
47
51
  if (taskCount === 0) return { score: 100, grade: "A", penalties: [], deltas: [] };
48
52
 
49
53
  const failedCount = tasks.filter((t) => t.status === "failed").length;
50
- const stalledCount = tasks.filter((t) => t.stalledSince !== undefined && Date.now() - t.stalledSince > STALLED_THRESHOLD_MS).length;
54
+ // L-6: stall detection derived from the task's own heartbeat (already
55
+ // written by worker heartbeat tracking — no new writer). Only RUNNING
56
+ // tasks can stall. `stalledSince` (no known writer) stays as a fallback
57
+ // for summaries without heartbeat data.
58
+ const stalledCount = tasks.filter((t) => {
59
+ if (t.status !== "running") return false;
60
+ if (t.heartbeat) {
61
+ const lastSeenAt = Date.parse(t.heartbeat.lastSeenAt);
62
+ return Number.isFinite(lastSeenAt) && Date.now() - lastSeenAt > STALLED_THRESHOLD_MS;
63
+ }
64
+ return t.stalledSince !== undefined && Date.now() - t.stalledSince > STALLED_THRESHOLD_MS;
65
+ }).length;
51
66
 
52
67
  const failureRate = failedCount / taskCount;
53
68
  if (failureRate > 0.2) {
@@ -6,9 +6,11 @@ import { atomicWriteFile } from "../state/atomic-write.ts";
6
6
  import { writeArtifact } from "../state/stores/artifact-store.ts";
7
7
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
8
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
9
+ import { countTokens } from "../utils/token-counter.ts";
9
10
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
10
11
  import { applyCompactPipeline } from "./compaction/compact-pipeline.ts";
11
12
  import { ANSI_STRIP_STAGE, BLANK_COLLAPSE_STAGE, TruncationStage } from "./compaction/compact-stages/index.ts";
13
+ import { splitWithImportantLines } from "./compaction/important-line-classifier.ts";
12
14
  import { DEFAULT_PRUNE_CONFIG, type FileEditEvent, pruneToolOutputs, type ToolResultEntry } from "./compaction/tool-output-pruner.ts";
13
15
 
14
16
  export interface DependencyContextEntry {
@@ -47,6 +49,13 @@ export interface DependencyContextEntry {
47
49
  * block for marked entries; `writeTaskInputsArtifact` still serializes the
48
50
  * ORIGINAL (untrimmed) context — the trim is render-only, never persisted. */
49
51
  budgetTrimmed?: boolean;
52
+ /** G6 (DR-02 round-5): set by {@link enforceDependencyInlineBudget} when the
53
+ * 96KB layer downgraded this dep to path-only (`resultSummary: ""`). The
54
+ * renderer uses it to emit "[result withheld by inline budget — see
55
+ * artifact path above]" instead of the false "(no result output)" — the
56
+ * output EXISTS on disk at `resultPath`; only the inline copy was
57
+ * budgeted away. */
58
+ resultWithheldByBudget?: boolean;
50
59
  }
51
60
 
52
61
  export interface DependencyOutputContext {
@@ -302,6 +311,22 @@ function aggregateUsage(task: TeamTaskState): DependencyContextEntry["usage"] {
302
311
  return { inputTokens, outputTokens, durationMs };
303
312
  }
304
313
 
314
+ /** L4 + handoff-budget shared priority score (higher = keep inline / keep
315
+ * FULL form longer). R5-5 (DR-02): the handoff-budget trim
316
+ * (`applyHandoffBudget`) reuses the exact scoring the 96KB L4 layer
317
+ * (`enforceDependencyInlineBudget`) uses so a failed/needs-attention dep is
318
+ * the LAST thing either budget layer trims.
319
+ *
320
+ * Score: (highPriority?1000:0) + (recency/1e10) + (relevance*10) - (index*0.001)
321
+ * The 1000 base ensures high-priority wins even with no recency/relevance.
322
+ * recency/1e10 keeps it in [0, 1] for ms timestamps up to 1e13 (~year 2286).
323
+ * relevance*10 puts it in [0, 10].
324
+ * index*0.001 keeps stable order within the same bucket. */
325
+ function inlinePriorityScore(dep: DependencyContextEntry, originalIndex: number): number {
326
+ const highPriority = dep.status === "failed" || dep.status === "needs-attention";
327
+ return (highPriority ? 1000 : 0) + (dep.recency ?? 0) / 1e10 + (dep.relevance ?? 0) * 10 - originalIndex * 0.001;
328
+ }
329
+
305
330
  /**
306
331
  * L4 total-budget trim. When the SUM of `inlineBytes` across all deps
307
332
  * exceeds {@link MAX_TOTAL_DEP_INLINE_BYTES}, downgrade the lowest-priority
@@ -330,16 +355,11 @@ export function enforceDependencyInlineBudget(dependencies: DependencyContextEnt
330
355
  // Build an index-keyed array so we can sort AND preserve the original
331
356
  // position (for stable output order). Each entry gets a numeric
332
357
  // priority score (higher = more important) and a stable tie-breaker.
333
- const indexed = dependencies.map((dep, originalIndex) => {
334
- const highPriority = dep.status === "failed" || dep.status === "needs-attention";
335
- // Score: (highPriority?1000:0) + (recency/1e10) + (relevance*10) - (index*0.001)
336
- // The 1000 base ensures high-priority wins even with no recency/relevance.
337
- // recency/1e10 keeps it in [0, 1] for ms timestamps up to 1e13 (~year 2286).
338
- // relevance*10 puts it in [0, 10].
339
- // index*0.001 keeps stable order within the same bucket.
340
- const score = (highPriority ? 1000 : 0) + (dep.recency ?? 0) / 1e10 + (dep.relevance ?? 0) * 10 - originalIndex * 0.001;
341
- return { dep, originalIndex, score };
342
- });
358
+ const indexed = dependencies.map((dep, originalIndex) => ({
359
+ dep,
360
+ originalIndex,
361
+ score: inlinePriorityScore(dep, originalIndex),
362
+ }));
343
363
  const sorted = [...indexed].sort((a, b) => b.score - a.score);
344
364
  let used = 0;
345
365
  const keepSet = new Set<number>(); // originalIndex values to KEEP inline
@@ -358,6 +378,10 @@ export function enforceDependencyInlineBudget(dependencies: DependencyContextEnt
358
378
  resultSummary: "",
359
379
  structuredResults: undefined,
360
380
  inlineBytes: 0,
381
+ // G6 (DR-02): flag the path-only downgrade so the renderer says the
382
+ // output was withheld by the inline budget instead of falling into the
383
+ // false "(no result output)" (the output exists at resultPath).
384
+ resultWithheldByBudget: true,
361
385
  };
362
386
  });
363
387
  }
@@ -548,34 +572,34 @@ function sanitizeFencedBody(body: string): string {
548
572
  return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "&lt;/dependency-context");
549
573
  }
550
574
 
551
- // ── Handoff budget (effective-token cap on this layer: chars/4 heuristic ──
552
- // anchored upward by measured worker usage — U10) ─────────────────────────
575
+ // ── Handoff budget (effective-token cap on this layer: countTokens — ──
576
+ // CJK-aware, in-tree utils/token-counter.ts — anchored upward by measured
577
+ // worker usage, U10) ───────────────────────────────────────────────────────
553
578
 
554
579
  /** Default effective-token budget for the dynamic.dependencyContext layer
555
- * (tokens as measured by the chars/4 heuristic, floored by measured worker
556
- * usage where available — U10). */
580
+ * (tokens as measured by `countTokens` (utils/token-counter.ts — R5-7,
581
+ * DR-02: CJK-aware; the old chars/4 heuristic underestimated CJK ~2-3x),
582
+ * floored by measured worker usage where available — U10). */
557
583
  export const DEFAULT_HANDOFF_BUDGET_TOKENS = 1800;
558
584
  /** Budget ceiling: a resolved budget above this is treated as the off-switch
559
585
  * (same semantics as ≤0 — clearly not a real handoff cap, so render
560
586
  * untrimmed instead of guessing). */
561
587
  export const HANDOFF_BUDGET_CEILING_TOKENS = 1_000_000;
562
- /** Summary head kept per dependency when the budget trims it. */
588
+ /** Summary head+tail budget kept per dependency when the budget trims it.
589
+ * R5-5 (DR-02): the compact form is no longer a plain head slice — the
590
+ * summary runs through `splitWithImportantLines` (75% head + important
591
+ * middle lines + 25% tail) so error/failed/exception lines from ANYWHERE in
592
+ * the summary survive, matching the full-form TruncationStage behavior. */
563
593
  export const HANDOFF_SUMMARY_HEAD_CHARS = 240;
594
+ /** Slack fraction of {@link HANDOFF_SUMMARY_HEAD_CHARS} available for
595
+ * important middle lines in the compact form (R5-5). The classifier default
596
+ * (0.15 = 36 chars) is too tight for realistic diagnostics; 0.25 (60 chars)
597
+ * admits 1-2 typical error lines while keeping the compact entry small. */
598
+ const HANDOFF_COMPACT_IMPORTANT_SLACK = 0.25;
564
599
 
565
600
  /** Strict integer env syntax — "12abc" must NOT parse as 12. */
566
601
  const HANDOFF_BUDGET_ENV_RE = /^[+-]?\d+$/;
567
602
 
568
- /** Est tokens = chars/4 — the same heuristic as `estimateTokens(chars)` in
569
- * src/runtime/task-runner/prompt-builder.ts (SR-02 breakdown). Duplicated
570
- * here instead of imported: importing prompt-builder would pull its
571
- * retrieval/workspace-tree graph into every task-output-context consumer
572
- * (post-execution, aggregate outputs) for a one-line heuristic. Keep the
573
- * two in sync if either changes; the U10 max(heuristic, measured) combine
574
- * rule mirrors prompt-builder's `estimateTokens(chars, measuredTokens)`. */
575
- function estimateHandoffTokens(chars: number): number {
576
- return Math.round(chars / 4);
577
- }
578
-
579
603
  /** U10 measured-token anchor: Σ measured `usage.outputTokens` over the deps
580
604
  * still rendered in FULL form. The usage record is the worker's REAL
581
605
  * tokenizer measurement (message_end usage persisted on the task state via
@@ -590,9 +614,10 @@ function estimateHandoffTokens(chars: number): number {
590
614
  * path is the compact form's artifact pointer.
591
615
  *
592
616
  * Compact (`budgetTrimmed`) deps are deliberately NOT charged: their inline
593
- * body is a ≤240-char head + pointer, no longer the worker's output, so the
594
- * chars/4 heuristic on the small head is the honest measure — otherwise an
595
- * output-heavy dep could never fit any budget even fully trimmed. */
617
+ * body is a ≤240-char head/tail + pointer, no longer the worker's output, so
618
+ * the countTokens heuristic on the small body is the honest measure —
619
+ * otherwise an output-heavy dep could never fit any budget even fully
620
+ * trimmed. */
596
621
  function measuredHandoffTokens(context: DependencyOutputContext): number {
597
622
  let measured = 0;
598
623
  for (const dep of context.dependencies) {
@@ -602,19 +627,18 @@ function measuredHandoffTokens(context: DependencyOutputContext): number {
602
627
  return measured;
603
628
  }
604
629
 
605
- /** Measure a candidate context in EFFECTIVE tokens (U10): the chars/4
606
- * heuristic on the rendered body, floored by the measured-token anchor —
607
- * `max(heuristic, Σ measured output tokens of full-form deps)`, mirroring
608
- * the render byte-for-byte (same parts builder; `.trim()` matches the render
609
- * join). The max rule mirrors `estimateTokens(chars, measuredTokens)` in
610
- * prompt-builder.ts: a real measurement can only TIGHTEN the estimate
611
- * (spec U10 — the budget must never be exceeded when measured usage is
612
- * larger than the heuristic), never loosen it below the heuristic. */
630
+ /** Measure a candidate context in EFFECTIVE tokens (U10): the
631
+ * `countTokens` heuristic on the rendered body (R5-7, DR-02: CJK-aware —
632
+ * replaces the chars/4 estimate that under-counted CJK summaries ~2-3x),
633
+ * floored by the measured-token anchor — `max(heuristic, Σ measured output
634
+ * tokens of full-form deps)`, mirroring the render byte-for-byte (same parts
635
+ * builder; `.trim()` matches the render join). The max rule mirrors
636
+ * `estimateTokens(chars, measuredTokens)` in prompt-builder.ts: a real
637
+ * measurement can only TIGHTEN the estimate (spec U10 — the budget must
638
+ * never be exceeded when measured usage is larger than the heuristic),
639
+ * never loosen it below the heuristic. */
613
640
  function measureHandoffTokens(context: DependencyOutputContext): number {
614
- return Math.max(
615
- estimateHandoffTokens(buildDependencyOutputBodyParts(context).join("\n").trim().length),
616
- measuredHandoffTokens(context),
617
- );
641
+ return Math.max(countTokens(buildDependencyOutputBodyParts(context).join("\n").trim()), measuredHandoffTokens(context));
618
642
  }
619
643
 
620
644
  /** Budget active iff it is a positive finite number at or below the ceiling;
@@ -646,7 +670,11 @@ function dependencyEntryLines(dep: DependencyContextEntry): string[] {
646
670
  `Status: ${dep.status}`,
647
671
  dep.resultPath ? `Result artifact: ${dep.resultPath}` : "",
648
672
  "",
649
- dep.resultSummary?.trim() || "(no result output)",
673
+ // G6 (DR-02 round-5): an L4 path-only downgrade has output on disk —
674
+ // never fall into the "(no result output)" lie for it.
675
+ dep.resultWithheldByBudget
676
+ ? "[result withheld by inline budget — see artifact path above]"
677
+ : dep.resultSummary?.trim() || "(no result output)",
650
678
  "",
651
679
  ];
652
680
  // P1-A dependency tee-recovery hint: when the dependency's result was
@@ -662,18 +690,35 @@ function dependencyEntryLines(dep: DependencyContextEntry): string[] {
662
690
  }
663
691
 
664
692
  /** Compact handoff-budget form for one dependency (pre-sanitize): keeps the
665
- * taskId/role/status lines + a ≤240-char summary head (with a truncation
666
- * marker when the summary was longer) + a relative artifact pointer the
667
- * worker can `read`. Drops structuredResults, usage, artifactsProduced and
668
- * the absolute-path lines — all recoverable via the pointer. The pointer is
669
- * built ONLY from manifest-derived runId/taskId (same trust level as the
670
- * existing `## <taskId>` headers and the results/<taskId>.txt artifact
671
- * naming); dep-controlled content never reaches it. */
693
+ * taskId/role/status lines + a head/tail summary budget of
694
+ * {@link HANDOFF_SUMMARY_HEAD_CHARS} chars with important-line preservation
695
+ * (R5-5, DR-02: the old plain 240-char HEAD slice dropped a failed dep's
696
+ * error tail and any diagnostic beyond char 240 — the summary now runs
697
+ * through the same `splitWithImportantLines` the full-form TruncationStage
698
+ * path uses, so error/failed/exception lines from ANYWHERE in the summary
699
+ * survive) + a `[trimmed, N chars total]` marker when the summary was longer
700
+ * + a relative artifact pointer the worker can `read`. Drops
701
+ * structuredResults, usage, artifactsProduced and the absolute-path lines —
702
+ * all recoverable via the pointer. The pointer is built ONLY from
703
+ * manifest-derived runId/taskId (same trust level as the existing
704
+ * `## <taskId>` headers and the results/<taskId>.txt artifact naming);
705
+ * dep-controlled content never reaches it. */
672
706
  function budgetTrimmedDependencyEntryLines(dep: DependencyContextEntry, runId: string | undefined): string[] {
673
707
  const summary = dep.resultSummary?.trim() ?? "";
674
- const head = summary.slice(0, HANDOFF_SUMMARY_HEAD_CHARS);
675
- const body =
676
- summary.length > HANDOFF_SUMMARY_HEAD_CHARS ? `${head}\n[trimmed, ${summary.length} chars total]` : summary || "(no result output)";
708
+ let body: string;
709
+ if (dep.resultWithheldByBudget) {
710
+ // G6 (DR-02): compact form of an L4-downgraded dep — same anti-lie
711
+ // marker; the pointer line below is the recovery path.
712
+ body = "[result withheld by inline budget — see full output path below]";
713
+ } else if (summary.length <= HANDOFF_SUMMARY_HEAD_CHARS) {
714
+ body = summary || "(no result output)";
715
+ } else {
716
+ const split = splitWithImportantLines(summary, HANDOFF_SUMMARY_HEAD_CHARS, {
717
+ slackFactor: HANDOFF_COMPACT_IMPORTANT_SLACK,
718
+ });
719
+ const segments = [split.head, ...split.importantLines, split.tail].filter((segment) => segment.length > 0);
720
+ body = `${segments.join("\n")}\n[trimmed, ${summary.length} chars total]`;
721
+ }
677
722
  return [
678
723
  `## ${dep.taskId} (${dep.role})`,
679
724
  `Status: ${dep.status}`,
@@ -715,9 +760,10 @@ function buildDependencyOutputBodyParts(context: DependencyOutputContext): strin
715
760
 
716
761
  /**
717
762
  * Handoff budget: trim the dependency-output context to an EFFECTIVE-token
718
- * budget (U10). Effective tokens = max(chars/4 heuristic on the rendered
719
- * body, Σ measured `usage.outputTokens` of full-form deps — real tokenizer
720
- * numbers from message_end usage persisted on the task state). Measured
763
+ * budget (U10). Effective tokens = max(countTokens heuristic on the rendered
764
+ * body — R5-7 (DR-02): CJK-aware, from utils/token-counter.ts — Σ measured
765
+ * `usage.outputTokens` of full-form deps — real tokenizer numbers from
766
+ * message_end usage persisted on the task state). Measured
721
767
  * usage can only tighten: when a worker reports MORE tokens than the
722
768
  * heuristic estimates, the measured number governs and the trim fires at
723
769
  * least as early as before (spec U10 acceptance — the budget is never
@@ -727,14 +773,20 @@ function buildDependencyOutputBodyParts(context: DependencyOutputContext): strin
727
773
  * input reference unchanged when the budget is off (≤0 or >1_000_000) or
728
774
  * the context already fits.
729
775
  *
730
- * Trim policy (declaration order): dependencies are probed from ALL-full
731
- * downward — the largest prefix of dependencies that still fits keeps its
732
- * FULL output; every dependency after the cutoff is downgraded to the
733
- * compact form (taskId/role/status + ≤240-char summary head + truncation
734
- * marker + `artifacts/<runId>/results/<taskId>.txt` pointer). Earlier deps
735
- * therefore always keep ≥ as much as later ones. Long (>240 chars) sharedRead
736
- * bodies are replaced by a trim marker (the Path pointer is already
737
- * rendered); short shared reads pass through.
776
+ * Trim policy (R5-5, DR-02 — failed-priority, mirroring the 96KB layer):
777
+ * dependencies are probed from ALL-full downward in DOWNGRADE-PRIORITY
778
+ * order — the same score `enforceDependencyInlineBudget` uses
779
+ * (`inlinePriorityScore`: status failed/needs-attention gets the +1000
780
+ * boost, then recency, then relevance, then declaration index). The
781
+ * lowest-keep-priority deps are downgraded to the compact form
782
+ * (taskId/role/status + head/tail summary ≤240 chars with important-line
783
+ * preservation + `artifacts/<runId>/results/<taskId>.txt` pointer) FIRST;
784
+ * failed/needs-attention deps therefore keep their FULL form longest — the
785
+ * downstream worker must see the failure. Declaration order still
786
+ * guarantees that among same-priority deps, earlier ones keep ≥ as much as
787
+ * later ones. Long (>240 chars) sharedRead bodies are replaced by a trim
788
+ * marker (the Path pointer is already rendered); short shared reads pass
789
+ * through.
738
790
  *
739
791
  * When even the fully-trimmed form exceeds the budget (extreme dependency
740
792
  * count), the fully-trimmed context is returned as the bounded best effort —
@@ -757,16 +809,36 @@ export function applyHandoffBudget(context: DependencyOutputContext, budgetToken
757
809
  ? { ...read, content: `[content trimmed, ${content.length} chars total — read Path above for the full file]` }
758
810
  : read;
759
811
  };
760
- const candidateAt = (fullCount: number): DependencyOutputContext => ({
761
- ...base,
762
- dependencies: base.dependencies.map((dep, index) => (index < fullCount ? dep : { ...dep, budgetTrimmed: true })),
763
- sharedReads: base.sharedReads.map(trimmedRead),
764
- });
765
- for (let fullCount = base.dependencies.length; fullCount >= 0; fullCount--) {
766
- const candidate = candidateAt(fullCount);
812
+ // R5-5 (DR-02): downgrade ORDER — ascending keep-priority (the FIRST
813
+ // entries are downgraded first). Reuses the exact scoring the 96KB L4
814
+ // layer uses (`inlinePriorityScore`) so failed/needs-attention deps land
815
+ // LAST and keep their FULL form longest; within the same priority bucket
816
+ // later-declared deps are downgraded before earlier ones.
817
+ const downgradeOrder = base.dependencies
818
+ .map((dep, originalIndex) => ({ originalIndex, score: inlinePriorityScore(dep, originalIndex) }))
819
+ .sort((a, b) => a.score - b.score)
820
+ .map((item) => item.originalIndex);
821
+ const candidateAt = (downgradedCount: number): DependencyOutputContext => {
822
+ // Downgrade the `downgradedCount` LOWEST-keep-priority deps first
823
+ // (R5-5): failed/needs-attention deps are last in `downgradeOrder`, so
824
+ // they keep their FULL form longest.
825
+ const downgradedIndexes = new Set(downgradeOrder.slice(0, downgradedCount));
826
+ return {
827
+ ...base,
828
+ dependencies: base.dependencies.map((dep, index) => (downgradedIndexes.has(index) ? { ...dep, budgetTrimmed: true } : dep)),
829
+ sharedReads: base.sharedReads.map(trimmedRead),
830
+ };
831
+ };
832
+ // Probe from fewest-downgraded upward and return the first candidate that
833
+ // fits — the maximum FULL-form set under the failed-priority order. More
834
+ // downgrades strictly shrink the measure (compact form < full form for
835
+ // both the heuristic and the measured anchor), so the first fit is the
836
+ // best fit.
837
+ for (let downgradedCount = 0; downgradedCount <= base.dependencies.length; downgradedCount++) {
838
+ const candidate = candidateAt(downgradedCount);
767
839
  if (measureHandoffTokens(candidate) <= budgetTokens) return candidate;
768
840
  }
769
- return candidateAt(0);
841
+ return candidateAt(base.dependencies.length);
770
842
  }
771
843
 
772
844
  export interface RenderDependencyOutputContextOptions {