pi-crew 0.11.21 → 0.11.22

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 (82) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.mjs +537 -526
  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.ts +24 -2
  16. package/src/prompt/inbox-poll.ts +77 -25
  17. package/src/prompt/message-tool.ts +62 -4
  18. package/src/prompt/prompt-runtime.ts +82 -32
  19. package/src/runtime/broker/crew-broker-client.ts +74 -16
  20. package/src/runtime/broker/crew-broker.ts +111 -77
  21. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  22. package/src/runtime/broker/wait-status-cache.ts +14 -5
  23. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  24. package/src/runtime/child-pi/child-pi.ts +20 -2
  25. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  26. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  27. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  28. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  29. package/src/runtime/crew-agent-records.ts +7 -1
  30. package/src/runtime/delivery-coordinator.ts +30 -0
  31. package/src/runtime/dwf-state-store.ts +41 -7
  32. package/src/runtime/finalize-run.ts +13 -2
  33. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  34. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  35. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  36. package/src/runtime/live-session/embedded-tier.ts +3 -1
  37. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  38. package/src/runtime/model/model-fallback.ts +23 -1
  39. package/src/runtime/output/pi-json-output.ts +26 -1
  40. package/src/runtime/policy-engine.ts +18 -0
  41. package/src/runtime/process/zombie-scanner.ts +78 -14
  42. package/src/runtime/process-status.ts +6 -1
  43. package/src/runtime/recovery/crash-recovery.ts +170 -28
  44. package/src/runtime/rpc/rpc-worker.ts +44 -1
  45. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
  46. package/src/runtime/scheduling/scheduler.ts +74 -11
  47. package/src/runtime/scratchpad/engine.ts +26 -3
  48. package/src/runtime/settings-store.ts +22 -2
  49. package/src/runtime/surface/tmux-provider.ts +24 -3
  50. package/src/runtime/task-health.ts +16 -1
  51. package/src/runtime/task-output-context.ts +143 -71
  52. package/src/runtime/task-packet.ts +70 -17
  53. package/src/runtime/task-runner/child-executor.ts +27 -0
  54. package/src/runtime/task-runner/output-splitter.ts +79 -16
  55. package/src/runtime/task-runner/post-execution.ts +75 -15
  56. package/src/runtime/task-runner/state-helpers.ts +92 -2
  57. package/src/runtime/usage-tracker.ts +11 -0
  58. package/src/runtime/verification/green-contract.ts +17 -0
  59. package/src/runtime/verification/verification-gates.ts +24 -6
  60. package/src/runtime/workspace-lock.ts +53 -0
  61. package/src/schema/team-tool-schema.ts +10 -4
  62. package/src/state/coordination/mailbox.ts +133 -38
  63. package/src/state/decision-ledger.ts +56 -8
  64. package/src/state/event-log/event-log-rotation.ts +83 -5
  65. package/src/state/event-log/event-log.ts +181 -12
  66. package/src/state/stores/active-run-registry.ts +10 -3
  67. package/src/state/types.ts +9 -1
  68. package/src/ui/agents-jobs-browser.ts +22 -8
  69. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  70. package/src/ui/events-state-source.ts +67 -28
  71. package/src/ui/inline-panel/agent-pane.ts +13 -6
  72. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  73. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  74. package/src/ui/inline-panel/index.ts +21 -1
  75. package/src/ui/live-run-sidebar.ts +14 -3
  76. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  77. package/src/ui/powerbar-publisher.ts +6 -3
  78. package/src/ui/rail.ts +7 -1
  79. package/src/ui/render-scheduler.ts +53 -1
  80. package/src/ui/run-action-dispatcher.ts +68 -20
  81. package/src/ui/status-colors.ts +9 -0
  82. package/src/utils/env-filter.ts +8 -0
@@ -1,24 +1,39 @@
1
1
  /**
2
2
  * crew-broker.ts — Root-only local socket server for cross-process pi-worker
3
- * message transport. PHASE 0 skeleton (sub-task 0.4).
3
+ * message transport (grew out of the Phase 0 skeleton,
4
+ * sub-task 0.4 — see history note below).
4
5
  *
5
6
  * - Bound to a `node:net` Unix domain socket or Windows named pipe.
6
7
  * - One broker per root session; per-run token auth on first `hello`.
7
8
  * - NDJSON framing, 256 KiB UTF-8 cap, 1s hello deadline.
8
- * - Per-connection outbound queue cap (default 256) with drop-newest +
9
- * `needsResync` marker.
10
- * - Phase 0 dispatches ONLY `hello` and `ping`. All other methods return
11
- * a typed `not-implemented` response (preserves forward-compat without
12
- * pretending Phase 1 methods are live).
9
+ * - Per-connection outbound queue cap (default 256). Fanout/event frames
10
+ * are drop-newest at cap, and the drop now CLOSES the connection with a
11
+ * typed `protocol.close` reason ("outbound-overflow-resync") so the
12
+ * client reconnects and replays (R6-5). Request RESPONSE frames
13
+ * (sendResult/sendError acks) bypass the cap — bounded by in-flight
14
+ * request count, never the fanout storm.
15
+ * - Dispatches 12 methods post-hello: `ping`, `hello`, `msg.send`,
16
+ * `msg.inbox`, `events.since`, `events.subscribe`, `task.waitStatus`,
17
+ * `steer.push`, `escalate`, `wait.request`/`wait.resolve`
18
+ * (capability-gated via `options.waitMethodsEnabled`, fail-closed), and
19
+ * `delegate.request` (governed-nesting grandchild spawn through
20
+ * `delegate-spawn.ts`, capability-gated via `options.nestingEnabled`,
21
+ * fail-closed). Unknown methods return a typed `not-implemented`.
22
+ * - Mailbox/event persistence flows through the `src/state/` stores
23
+ * (`appendMailboxMessage*`, event-log) — the broker itself keeps no
24
+ * durable state of its own.
13
25
  * - Token map is HEAP ONLY; cleared on `stop()`. Never serialized.
14
26
  * - `stop()` is idempotent. Never calls `process.kill`.
15
27
  * - All log/error scopes use `crew-broker.*` prefix; every diagnostic
16
28
  * passes through `redactSecretString` from `src/utils/redaction.ts`.
17
29
  *
18
- * NO outbound TCP. NO persistence. NO children. NO process killing.
30
+ * NO outbound TCP. NO process killing.
19
31
  *
20
- * See `reports/inter-pi-broker-impl-plan-2026-07-21.md` §"0.4" for the
21
- * full contract and acceptance criteria.
32
+ * History: the Phase 0 contract (hello/ping only, no persistence, no
33
+ * children) lives in `reports/inter-pi-broker-impl-plan-2026-07-21.md` §0.4;
34
+ * the 12-method surface above supersedes it. DR-01 (2026-10-14) rewrote
35
+ * this header after it had drifted ~1900 LOC past the skeleton it
36
+ * described.
22
37
  */
23
38
 
24
39
  import { randomUUID } from "node:crypto";
@@ -53,6 +68,7 @@ import { handleEventsSince } from "./protocol/events-replay.ts";
53
68
  import { DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, type LockBusyOutcome, withRunLockBusyRetry } from "./protocol/lock-busy.ts";
54
69
  import { loadRunForHello } from "./protocol/manifest-loader.ts";
55
70
  import { handleMsgInbox } from "./protocol/msg-inbox.ts";
71
+ import { resolveMsgSendTargets } from "./protocol/msg-send-targets.ts";
56
72
  import {
57
73
  BROKER_PROTOCOL,
58
74
  isHelloParams,
@@ -93,6 +109,26 @@ const STALE_RUN_STATUSES: ReadonlySet<string> = new Set(["completed", "failed",
93
109
  /** Default per-connection outbound queue cap (events). */
94
110
  const DEFAULT_OUTBOUND_QUEUE_CAP = 256;
95
111
 
112
+ /** R6-5 (2026-10-16): typed protocol close-reason sent as a best-effort
113
+ * `protocol.close` event frame (direct write, jumps the saturated queue)
114
+ * before the connection is closed after a fanout/event frame was dropped
115
+ * at the queue cap. Clients observe the reason, reconnect, and replay via
116
+ * `events.since` (Phase 1.5). */
117
+ export const OUTBOUND_OVERFLOW_RESYNC_CLOSE_REASON = "outbound-overflow-resync";
118
+
119
+ /** R6-5: admission class for an outbound frame.
120
+ * - `false` — fanout/event class: drop-newest at cap → resync close.
121
+ * - `true` — close-reason class: bypass the cap, attempt an immediate
122
+ * direct write (existing `sendErrorAndClose` behavior).
123
+ * - `"response"` — request-response class (sendResult/sendError acks):
124
+ * bypass the drop-newest cap but stay IN the queue (ordering preserved,
125
+ * backpressure honored). Bounded by the client's in-flight request count
126
+ * — one response frame per request — so it can never storm the way a
127
+ * fanout burst can. Kept a `boolean | "response"` union (not a fresh
128
+ * enum) so the external FanoutWriters interface (mailbox-fanout.ts) keeps
129
+ * its `(conn, buf, force: boolean)` shape. */
130
+ type OutboundAdmission = boolean | "response";
131
+
96
132
  export class CrewBroker {
97
133
  private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled" | "waitMethodsEnabled" | "nestingEnabled">> &
98
134
  Pick<
@@ -750,7 +786,7 @@ export class CrewBroker {
750
786
  * truth for authentication). */
751
787
  // loadRunForHello: inlined at the 2 call sites (was a 5-line method; M4/WI-4.1).
752
788
 
753
- // Outbound queue + drop-newest + needsResync
789
+ // Outbound queue + drop-newest + resync-close (R6-5)
754
790
 
755
791
  private sendResult(conn: ServerConnection, id: string, result: unknown): void {
756
792
  this.enqueueFrame(conn, { id, result });
@@ -787,13 +823,27 @@ export class CrewBroker {
787
823
  );
788
824
  return;
789
825
  }
790
- this.writeOrQueue(conn, buf, /*force*/ false);
826
+ // R6-5: request responses (the acks for ping/msg.send/wait.request/…)
827
+ // are response-class — they bypass the drop-newest cap so a saturated
828
+ // fanout queue can no longer swallow an ack and leave the caller to its
829
+ // request-timeout (a delivered send reported "NOT sent").
830
+ this.writeOrQueue(conn, buf, "response");
791
831
  }
792
832
 
793
- private writeOrQueue(conn: ServerConnection, buf: Buffer, force: boolean): void {
833
+ private writeOrQueue(conn: ServerConnection, buf: Buffer, force: OutboundAdmission): void {
794
834
  if (conn.closed) return;
795
835
  const cap = this.options.outboundQueueCap ?? DEFAULT_OUTBOUND_QUEUE_CAP;
796
836
  if (conn.outbound.length >= cap) {
837
+ if (force === "response") {
838
+ // R6-5(a): response frames queue PAST the cap instead of being
839
+ // drop-newest'd. Bounded: at most one response per in-flight
840
+ // request, and the client's request pipeline is serial — this can
841
+ // never grow unbounded the way a fanout burst can. Ordering is
842
+ // preserved (pushed at the tail; drainOutbound honors backpressure).
843
+ conn.outbound.push(buf);
844
+ this.drainOutbound(conn);
845
+ return;
846
+ }
797
847
  if (force) {
798
848
  // Forced sends (e.g. close-reason) bypass the cap and attempt
799
849
  // to flush directly; if the socket is busy they may still drop.
@@ -804,13 +854,13 @@ export class CrewBroker {
804
854
  }
805
855
  return;
806
856
  }
807
- // Drop-newest: do NOT add the new frame, mark needsResync, and stop
808
- // further live fanout for this connection. The client must reconnect
809
- // and replay (Phase 1: via events.since; Phase 0: protocol error).
810
- conn.needsResync = true;
811
- // We do NOT revoke auth — the connection is still authenticated; we
812
- // simply pause live frame production. The client is expected to
813
- // notice the queue-depth and resync.
857
+ // R6-5(b): fanout/event frame dropped at cap (drop-newest). The old
858
+ // `needsResync` marker was write-only (set, never read) — the claimed
859
+ // "pause + client notices" recovery did not exist, so live frames
860
+ // were silently dropped for the connection's remaining lifetime.
861
+ // Now the drop CLOSES the connection with a typed close-reason so
862
+ // the client reconnects and replays (events.since / msg.inbox).
863
+ this.handleOutboundOverflow(conn);
814
864
  return;
815
865
  }
816
866
  conn.outbound.push(buf);
@@ -841,6 +891,33 @@ export class CrewBroker {
841
891
  }
842
892
  }
843
893
 
894
+ /** R6-5(b): a fanout/event frame was dropped at the outbound queue cap.
895
+ * Close the connection with a typed protocol close-reason so the client
896
+ * can reconnect + replay instead of silently missing live frames
897
+ * forever. The reason frame is written DIRECTLY (jumps the saturated
898
+ * queue — those frames are already doomed once we close) and delivery
899
+ * is best-effort, exactly like `sendErrorAndClose`. */
900
+ private handleOutboundOverflow(conn: ServerConnection): void {
901
+ // Single-flight: fanoutMailboxMessage iterates a snapshot of the run's
902
+ // connections, so one burst can re-enter here for an already-overflowing
903
+ // conn before `closeConnection`'s `closed` guard becomes visible. The
904
+ // flag is the "close already initiated" bit (its only read).
905
+ if (conn.needsResync) return;
906
+ conn.needsResync = true;
907
+ logInternalError(
908
+ "crew-broker.outbound.overflow",
909
+ new Error("outbound queue overflow — closing connection for resync"),
910
+ `sessionId=${this.options.sessionId} runId=${conn.runId ?? "?"} queued=${conn.outbound.length} seq=${conn.outboundSeq}`,
911
+ );
912
+ try {
913
+ const buf = encodeBrokerFrame({ event: "protocol.close", data: { reason: OUTBOUND_OVERFLOW_RESYNC_CLOSE_REASON } });
914
+ conn.socket.write(buf);
915
+ } catch {
916
+ /* socket may be dead; closeConnection below sweeps regardless */
917
+ }
918
+ this.closeConnection(conn);
919
+ }
920
+
844
921
  // Phase 1: msg.send + msg.inbox handlers
845
922
 
846
923
  /** Phase 1.1: direct or broadcast mailbox write via the durable append path. */
@@ -894,67 +971,25 @@ export class CrewBroker {
894
971
  this.sendError(conn, id, "no-manifest", (err as Error).message);
895
972
  return;
896
973
  }
897
- // ── Recipient resolution ──────────────────────────────────────────────
898
- // Each target is {label, mailboxTaskId}: `label` is echoed in the ack
899
- // and message id, `mailboxTaskId` is the mailbox file the append lands
900
- // in (undefined = run-level inbox, which the orchestrator consumes).
901
- let targets: Array<{ label: string; mailboxTaskId: string | undefined }>;
902
- // Task 5b (spec §15.2 wake): set when a worker addresses the parent —
903
- // the durable write alone would sit unread in the run-level inbox.
904
- let sentToParent = false;
905
- if (isWorker) {
906
- // Worker constraint (1): from is ALWAYS the authenticated taskId.
907
- // Worker constraint (2): to is limited to parent | valid sibling
908
- // taskId | group.
909
- if (!conn.taskId) {
910
- this.sendError(conn, id, "forbidden", "msg.send worker requires a task-scoped identity");
911
- return;
912
- }
913
- const to = typeof parsed.to === "string" ? parsed.to : undefined;
914
- if (to === "parent") {
915
- // Run-level inbox (taskId undefined) → the orchestrator session.
916
- targets = [{ label: "parent", mailboxTaskId: undefined }];
917
- sentToParent = true;
918
- } else if (to === "group") {
919
- targets = taskIds.map((t) => ({ label: t, mailboxTaskId: t }));
920
- } else if (to !== undefined && taskIds.includes(to)) {
921
- targets = [{ label: to, mailboxTaskId: to }];
922
- } else {
923
- this.sendError(conn, id, "forbidden", `msg.send: worker cannot target '${to}'`);
924
- return;
925
- }
926
- if (targets.length === 0 || targets.length > 64) {
927
- this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
928
- return;
929
- }
930
- } else {
931
- const recipients: string[] = Array.isArray(parsed.to)
932
- ? (parsed.to as string[])
933
- : parsed.to === "all"
934
- ? taskIds
935
- : [parsed.to as string];
936
- if (recipients.length === 0 || recipients.length > 64) {
937
- this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
938
- return;
939
- }
940
- targets = recipients.map((recipient) => ({ label: recipient, mailboxTaskId: recipient }));
974
+ // ── Recipient resolution: pure D9/§15.2 matrix in protocol/msg-send-targets.ts (wc-gate).
975
+ const resolved = resolveMsgSendTargets({ isWorker, connTaskId: conn.taskId, to: parsed.to, taskIds });
976
+ if (!resolved.ok) {
977
+ this.sendError(conn, id, resolved.code, resolved.message);
978
+ return;
941
979
  }
980
+ const { targets, sentToParent } = resolved;
942
981
  const messageId = `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
943
982
  const fromField = isWorker ? conn.taskId! : (conn.taskId ?? conn.runId);
944
- // U5 (2026-10-10): every send carries a unique requestId — client-supplied
945
- // (idempotent retries reuse the SAME one) or broker-generated
946
- // `{runId}:{sender}:{uuid}`. The durable (direction, requestId) index in
947
- // delivery.json + the idempotent append below make resend-after-resume a
948
- // no-op instead of a duplicate delivery.
983
+ // U5: every send carries a unique requestId — client-supplied (idempotent
984
+ // retries reuse the SAME one) or broker-generated. The durable
985
+ // (direction, requestId) index makes resend-after-resume a no-op.
949
986
  const requestId = parsed.requestId ?? `${manifest.runId}:${fromField}:${randomUUID()}`;
950
987
  let dedupedRecipients = 0;
951
988
  let durable = false;
952
989
  try {
953
- // PERF (2026-08-24): to:"all" with 50 tasks used to run 50 sequential
954
- // awaited locked appends (~70 syscalls + 2 fsync each) while the
955
- // connection's frames queued behind it. Chunked fan-out — independent
956
- // mailbox files append concurrently; delivery.json stays serialized by
957
- // its own lock.
990
+ // PERF: to:"all" with 50 tasks used to run 50 sequential awaited locked
991
+ // appends while the connection's frames queued behind it. Chunked
992
+ // fan-out — independent mailbox files append concurrently.
958
993
  const CHUNK = 8;
959
994
  for (let i = 0; i < targets.length; i += CHUNK) {
960
995
  const results = await Promise.allSettled(
@@ -985,10 +1020,9 @@ export class CrewBroker {
985
1020
  this.sendError(conn, id, "durable-failed", (err as Error).message);
986
1021
  return;
987
1022
  }
988
- // U5: one bounded audit event per send — requestId + dedup count make the
989
- // exactly-once chain auditable end-to-end (mailbox.send → worker
990
- // mailbox.pickup → mailbox.pickup_deduped). Never the body. Failure is
991
- // non-fatal: the mailbox rows are the source of truth.
1023
+ // U5: one bounded audit event per send — requestId + dedup count make
1024
+ // the exactly-once chain auditable. Never the body; mailbox rows are
1025
+ // the source of truth.
992
1026
  try {
993
1027
  await appendEventAsync(manifest.eventsPath, {
994
1028
  type: "mailbox.send",
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pure recipient resolution for the broker's `msg.send` handler.
3
+ *
4
+ * Extracted from crew-broker.ts (DR-03 wave, wc-gate: no module over 2000
5
+ * lines — same precedent as live-session/usage-bridge.ts): the worker/
6
+ * orchestrator recipient matrices are pure functions of
7
+ * {role, taskId, parsed.to, taskIds} — no broker state involved.
8
+ *
9
+ * Worker constraints (D9/§15.2): from is always the authenticated taskId;
10
+ * to is limited to `parent` | valid sibling taskId | `group`; kind limited
11
+ * to notify|message (checked by the caller BEFORE resolution).
12
+ * Orchestrator keeps the full prior surface (arrays / "all" / arbitrary
13
+ * recipient sets).
14
+ */
15
+
16
+ export type MsgSendTarget = { label: string; mailboxTaskId: string | undefined };
17
+
18
+ export type MsgSendTargetResolution =
19
+ | { ok: true; targets: MsgSendTarget[]; sentToParent: boolean }
20
+ | { ok: false; code: "forbidden" | "bad-params"; message: string };
21
+
22
+ /** Max recipients per send (both roles) — matches the historical inline check. */
23
+ export const MSG_SEND_MAX_RECIPIENTS = 64;
24
+
25
+ /**
26
+ * Resolve `parsed.to` into mailbox targets.
27
+ *
28
+ * Each target is `{label, mailboxTaskId}`: `label` is echoed in the ack and
29
+ * message id; `mailboxTaskId` is the mailbox file the append lands in
30
+ * (undefined = run-level inbox, which the orchestrator consumes).
31
+ * `sentToParent` is set when a worker addresses the parent — the durable
32
+ * write alone would sit unread in the run-level inbox (spec §15.2 wake).
33
+ */
34
+ export function resolveMsgSendTargets(input: {
35
+ isWorker: boolean;
36
+ connTaskId: string | undefined;
37
+ to: unknown;
38
+ taskIds: string[];
39
+ }): MsgSendTargetResolution {
40
+ const { isWorker, connTaskId, taskIds } = input;
41
+
42
+ if (isWorker) {
43
+ // Worker constraint (1): from is ALWAYS the authenticated taskId.
44
+ if (!connTaskId) {
45
+ return { ok: false, code: "forbidden", message: "msg.send worker requires a task-scoped identity" };
46
+ }
47
+ // Worker constraint (2): to is limited to parent | valid sibling
48
+ // taskId | group.
49
+ const to = typeof input.to === "string" ? input.to : undefined;
50
+ let targets: MsgSendTarget[];
51
+ let sentToParent = false;
52
+ if (to === "parent") {
53
+ // Run-level inbox (taskId undefined) → the orchestrator session.
54
+ targets = [{ label: "parent", mailboxTaskId: undefined }];
55
+ sentToParent = true;
56
+ } else if (to === "group") {
57
+ targets = taskIds.map((t) => ({ label: t, mailboxTaskId: t }));
58
+ } else if (to !== undefined && taskIds.includes(to)) {
59
+ targets = [{ label: to, mailboxTaskId: to }];
60
+ } else {
61
+ return { ok: false, code: "forbidden", message: `msg.send: worker cannot target '${to}'` };
62
+ }
63
+ if (targets.length === 0 || targets.length > MSG_SEND_MAX_RECIPIENTS) {
64
+ return { ok: false, code: "bad-params", message: "msg.send: recipient count out of range" };
65
+ }
66
+ return { ok: true, targets, sentToParent };
67
+ }
68
+
69
+ const recipients: string[] = Array.isArray(input.to) ? (input.to as string[]) : input.to === "all" ? taskIds : [input.to as string];
70
+ if (recipients.length === 0 || recipients.length > MSG_SEND_MAX_RECIPIENTS) {
71
+ return { ok: false, code: "bad-params", message: "msg.send: recipient count out of range" };
72
+ }
73
+ return { ok: true, targets: recipients.map((recipient) => ({ label: recipient, mailboxTaskId: recipient })), sentToParent: false };
74
+ }
@@ -119,9 +119,18 @@ export class WaitStatusCache {
119
119
  /**
120
120
  * Stat-gated load. Fast path (stamp unchanged): 2 statSync calls, zero
121
121
  * parses. Slow path (first sight or changed mtime/size): delegate to the
122
- * loader, then record the POST-load stamp so a concurrent writer that
123
- * landed between our pre-load stat and the parse forces the next tick to
124
- * reload instead of serving a stale entry.
122
+ * loader, then record the PRE-load stamp (H5, DR-03 round-6 2026-10-16).
123
+ *
124
+ * Why PRE-load: keying the entry on the POST-load stamp had a
125
+ * parse→stat TOCTOU bug — a writer landing between the parse and the
126
+ * after-stat produced an entry whose stamp was FRESH while its cached
127
+ * value was STALE, and every later tick with an unchanged disk served
128
+ * the stale value forever (stale-under-fresh pinning). With the PRE-load
129
+ * stamp, a writer that lands anywhere between the pre-stat and the parse
130
+ * leaves the entry keyed on the OLDER stamp, so the very next tick sees
131
+ * the stamp change and reloads. Worst case is one spurious reload
132
+ * (writer landed before the parse — the loaded value was already fresh),
133
+ * never a missed one.
125
134
  */
126
135
  load(cwd: string, runId: string): LoadedRun {
127
136
  // Same path derivation as resolveRunStateRoot inside
@@ -135,13 +144,13 @@ export class WaitStatusCache {
135
144
  }
136
145
  const loaded = this.loader(cwd, runId);
137
146
  this.reloadsValue += 1;
138
- const after = statStamp(paths.manifestPath, paths.tasksPath);
139
147
  if (this.entries.size >= WAIT_STATUS_CACHE_MAX && !this.entries.has(runId)) {
140
148
  // Map preserves insertion order — evict the oldest entry.
141
149
  const oldest = this.entries.keys().next().value;
142
150
  if (oldest !== undefined) this.entries.delete(oldest);
143
151
  }
144
- this.entries.set(runId, { ...after, loaded });
152
+ // H5: key on the PRE-load stamp (`before`), NOT a post-load re-stat.
153
+ this.entries.set(runId, { ...before, loaded });
145
154
  return loaded;
146
155
  }
147
156
 
@@ -21,7 +21,7 @@ import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
22
  import { childPiBaseAllowlist } from "../../config/env-allowlist-registry.ts";
23
23
  import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
24
- import { buildScopedAllowList, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
24
+ import { buildScopedAllowList, providerEnvKeys, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
25
25
  import { logInternalError } from "../../utils/internal-error.ts";
26
26
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
27
27
  import { buildPiWorkerArgs, createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
@@ -48,8 +48,23 @@ export const BASE_ALLOWLIST: string[] = childPiBaseAllowlist();
48
48
  * 4. Add PI_CREW_PARENT_PID for the child-side parent-guard (consumed by the
49
49
  * reactive zombie-scanner + background-runner parent-guard; unused only by
50
50
  * the pi dist binary — see note at the PI_CREW_PARENT_PID assignment below).
51
+ *
52
+ * R6-8 (DR-03 round-6): `modelChain` threads the task's model CANDIDATE CHAIN
53
+ * (primary + fallbacks, e.g. modelRouting.candidates) so the allowlist is
54
+ * scoped to the UNION of the chain's providers. Previously only the single
55
+ * current model was passed: a bare-id primary (no "provider/" prefix)
56
+ * resolved to ZERO provider env keys → the child failed auth with a
57
+ * non-retryable /api key/i error → the loop broke even though qualified
58
+ * fallbacks were queued (the env-filter.ts doc contract already promised
59
+ * "primary + fallbacks"). Scoping is NEVER weakened by this: keys stay
60
+ * limited to the chain's providers ONLY — never all-providers.
51
61
  */
52
- export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
62
+ export function buildChildPiSpawnOptions(
63
+ cwd: string,
64
+ env: NodeJS.ProcessEnv,
65
+ model?: string,
66
+ modelChain?: (string | undefined)[],
67
+ ): SpawnOptions {
53
68
  // SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
54
69
  // If cwd comes from an untrusted source (user input, workspace config), a malicious cwd
55
70
  // could cause the child process to operate in an attacker-controlled directory,
@@ -81,9 +96,33 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, mo
81
96
  // Bug #12 fix: essential env vars (PATH, HOME, etc.) are always preserved so child can find npm/node.
82
97
  //
83
98
  // PER-TASK KEY SCOPING: when a model is provided, only the env keys for that
84
- // provider are injected (via buildScopedAllowList). When no model is given,
85
- // only BASE_ALLOWLIST system vars pass through — no provider keys leak.
86
- const allowList = model ? buildScopedAllowList(BASE_ALLOWLIST, [model]) : BASE_ALLOWLIST;
99
+ // provider are injected (via buildScopedAllowList). R6-8: when the caller
100
+ // threads the model CANDIDATE CHAIN (primary + fallbacks), keys are scoped
101
+ // to the UNION of the chain's providers so a fallback attempt on a second
102
+ // provider does not spawn keyless. When no model is given, only
103
+ // BASE_ALLOWLIST system vars pass through — no provider keys leak.
104
+ const scopeModels: (string | undefined)[] = modelChain && modelChain.length > 0 ? modelChain : model !== undefined ? [model] : [];
105
+ const allowList = scopeModels.length > 0 ? buildScopedAllowList(BASE_ALLOWLIST, scopeModels) : BASE_ALLOWLIST;
106
+ // R6-8: warn when the scoped models resolve to ZERO provider env keys AND at
107
+ // least one is a truly-bare id (no "provider/" prefix) — env-key auth + bare
108
+ // id means the child worker will very likely fail authentication (observed as
109
+ // the R6-8 stall: bare id → zero keys → /api key/i non-retryable → chain break
110
+ // even though qualified fallbacks were queued). NOT an error, and it never
111
+ // widens the allowlist: users authenticating via auth.json (or custom
112
+ // providers with non-env credentials) are unaffected — the child reads
113
+ // auth.json credentials exactly like the parent process does. Qualified
114
+ // unknown providers ("custom/foo") do not warn: that is a deliberate
115
+ // custom-provider choice, not a likely misconfiguration.
116
+ const anyProviderKeys = scopeModels.some((scoped) => providerEnvKeys(scoped).length > 0);
117
+ const anyBareId = scopeModels.some((scoped) => typeof scoped === "string" && !scoped.includes("/"));
118
+ if (scopeModels.length > 0 && anyBareId && !anyProviderKeys) {
119
+ logInternalError(
120
+ "child-pi-spawn.bare-model-no-provider-keys",
121
+ undefined,
122
+ `model "${model ?? scopeModels.find((scoped) => typeof scoped === "string")}" is a bare id (no provider prefix) and resolved zero provider env keys — the child worker gets no provider API key via env; env-key users should qualify the model id (e.g. "openai/<id>") or configure a fallback chain (auth.json users are unaffected)`,
123
+ "warn",
124
+ );
125
+ }
87
126
  const filteredEnv = sanitizeEnvSecrets(env, { allowList });
88
127
  // FIX: Removed delete workarounds — with explicit allowlist, these vars
89
128
  // are no longer auto-leaked. The wildcard approach was fragile.
@@ -192,12 +231,14 @@ export function buildFinalChildPiSpawnOptions(
192
231
  mergedEnv: NodeJS.ProcessEnv,
193
232
  builtEnv: Record<string, string | undefined>,
194
233
  model?: string,
234
+ modelChain?: (string | undefined)[],
195
235
  ): SpawnOptions {
196
236
  // (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
197
237
  assertOnlyControlEnvKeys(builtEnv);
198
238
  // (b) Build the allowlist-filtered base SpawnOptions (cwd validation, env
199
239
  // filtering, provider-key scoping when model is set, NODE_PATH guard).
200
- const spawnOptions = buildChildPiSpawnOptions(cwd, mergedEnv, model);
240
+ // R6-8: modelChain threads the candidate chain for provider-key scoping.
241
+ const spawnOptions = buildChildPiSpawnOptions(cwd, mergedEnv, model, modelChain);
201
242
  // (c) Spread builtEnv back on top — the allowlist in step (b) intentionally
202
243
  // strips PI_CREW_*/PI_TEAMS_* keys, so without this spread the child
203
244
  // process would never see steering file, kind, role, or broker creds.
@@ -395,6 +436,3 @@ export function prepareSpawnContext(
395
436
  },
396
437
  };
397
438
  }
398
-
399
- // Silence unused-import warning for logInternalError if not consumed by future helpers.
400
- void logInternalError;
@@ -170,6 +170,18 @@ export interface ChildPiRunInput {
170
170
  task: string;
171
171
  agent: AgentConfig;
172
172
  model?: string;
173
+ /**
174
+ * R6-8 (DR-03 round-6): the task's model CANDIDATE CHAIN (primary +
175
+ * fallbacks, e.g. modelRouting.candidates / the executor's attemptModels)
176
+ * for provider env-key scoping in buildChildPiSpawnOptions. Scopes the
177
+ * allowlist to the UNION of the chain's providers — never all-providers —
178
+ * so a fallback attempt on a second provider does not spawn keyless, and
179
+ * a bare-id primary no longer means zero provider keys when a qualified
180
+ * fallback exists. Absent/empty → single-model scoping (previous
181
+ * behavior); a truly-bare single model then warns (env-key auth + bare id
182
+ * is a likely misconfiguration; auth.json users are unaffected).
183
+ */
184
+ modelChain?: (string | undefined)[];
173
185
  skillPaths?: string[];
174
186
  signal?: AbortSignal;
175
187
  transcriptPath?: string;
@@ -474,7 +486,12 @@ async function trySurfaceBranch(
474
486
  // child's built env whose PI_CREW_DEPTH is parentDepth+1 — layer-1
475
487
  // guard would misfire on our own tier-1 workers.
476
488
  env: depthEnv ?? process.env,
477
- workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model).env as Record<string, string>,
489
+ // R6-8: thread the model candidate chain so the surface pane's worker env
490
+ // is provider-key scoped to primary + fallbacks (same as the headless path).
491
+ workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain).env as Record<
492
+ string,
493
+ string
494
+ >,
478
495
  config: surfaceConfig,
479
496
  role: input.role ?? input.agent.name,
480
497
  livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
@@ -801,7 +818,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
801
818
  return await new Promise<ChildPiRunResult>((resolve) => {
802
819
  // Compose the final SpawnOptions: canary + filter + spread are now
803
820
  // owned by buildFinalChildPiSpawnOptions (see child-pi-spawn.ts, BLOCKER 2 / S5).
804
- const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model);
821
+ // R6-8: modelChain threads the candidate chain for provider-key scoping.
822
+ const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain);
805
823
  const child = spawn(spawnSpec.command, spawnSpec.args, spawnOptions);
806
824
  if (child.pid) {
807
825
  registerActiveChild(child.pid, child);
@@ -68,6 +68,29 @@ export async function runMockChildPi(
68
68
  return { exitCode: 0, stdout, stderr: "" };
69
69
  }
70
70
 
71
+ if (mock === "json-429-empty-settle") {
72
+ // DR-01 P0 test seam: reproduces the live 429-exhaustion shape (runs
73
+ // team_20261014082741 / team_20261014093334): opening assistant text +
74
+ // tool results, then EMPTY assistant message_end events between
75
+ // auto-retry cycles, ending agent_settled — exit 0, NO stopReason error,
76
+ // and finalText keeps the early opening sentence ("finalText lies").
77
+ const opening = JSON.stringify({
78
+ type: "message_end",
79
+ message: { role: "assistant", content: [{ type: "text", text: `[MOCK] Starting work for ${input.agent.name}` }] },
80
+ });
81
+ const toolResult = JSON.stringify({
82
+ type: "message_end",
83
+ message: { role: "toolResult", content: [{ type: "text", text: "[MOCK] tool output" }] },
84
+ });
85
+ const emptyAssistant = JSON.stringify({
86
+ type: "message_end",
87
+ message: { role: "assistant", content: [], usage: { input: 0, output: 0 } },
88
+ });
89
+ const stdout = [opening, toolResult, emptyAssistant, emptyAssistant, JSON.stringify({ type: "agent_settled" })].join("\n") + "\n";
90
+ await observe(input, stdout);
91
+ return { exitCode: 0, stdout, stderr: "" };
92
+ }
93
+
71
94
  if (mock === "json-success" || mock === "adaptive-plan") {
72
95
  const text =
73
96
  mock === "adaptive-plan" && effectiveTask.includes("ADAPTIVE_PLAN_JSON_START")
@@ -12,6 +12,17 @@
12
12
  * their distinct separators. Defaults match `compactString`'s pre-P0-A output
13
13
  * exactly so that callers that do not opt into additional stages get
14
14
  * bit-identical output (L4 backward-compat safety).
15
+ *
16
+ * K6 (DR-03 round-6): budget accounting. The kept important lines are part
17
+ * of the OUTPUT, so their joined length (+ the "\n" seam) is reserved OUT
18
+ * of the head+tail budget (`splitWithImportantLines` `reserveChars`). The
19
+ * composed result therefore fits `maxChars` + {@link TRUNCATION_MARKER_ALLOWANCE}
20
+ * (marker budget) instead of the pre-K6 ~1.15×maxChars, where head+tail
21
+ * alone consumed the full budget and the important-line slack stacked on
22
+ * top unbudgeted. When no important lines are kept, sizing is bit-identical
23
+ * to the legacy behavior (no reserve). The stage's own monotonic-shrink
24
+ * invariant is preserved: if the composed result would be ≥ the input, the
25
+ * input is returned unchanged.
15
26
  */
16
27
  import type { ICompactStage } from "../compact-pipeline.ts";
17
28
  import { splitWithImportantLines } from "../important-line-classifier.ts";
@@ -34,6 +45,15 @@ const DEFAULT_MARKER: TruncationMarkerConfig = {
34
45
  tailSeparator: "\n",
35
46
  };
36
47
 
48
+ /**
49
+ * K6 "small constant" = the marker budget: the longest marker template
50
+ * (with-important-lines variant, 6-digit counters) plus separators plus
51
+ * digit drift. The head+tail budget absorbs the kept-important-line content
52
+ * (joined length + seam), so the composed result exceeds `maxChars` by at
53
+ * most this marker budget — never by the old unbudgeted ~15% slack.
54
+ */
55
+ export const TRUNCATION_MARKER_ALLOWANCE = 160;
56
+
37
57
  export class TruncationStage implements ICompactStage {
38
58
  readonly id = "truncation";
39
59
  private readonly maxChars: number;
@@ -55,19 +75,41 @@ export class TruncationStage implements ICompactStage {
55
75
  }
56
76
  apply(text: string): string {
57
77
  if (text.length <= this.maxChars) return text;
58
- const { head, tail, importantLines, baseDropped } = splitWithImportantLines(text, this.maxChars, {
78
+ // K6: two-pass budget accounting.
79
+ // Pass 1 (today's semantics, full-budget split) learns which important
80
+ // lines are kept — their joined length is part of the OUTPUT and must be
81
+ // reserved out of the head+tail budget instead of stacking on top.
82
+ const pass1 = splitWithImportantLines(text, this.maxChars, {
59
83
  preserveImportant: this.preserveImportant,
60
84
  });
85
+ const joined = pass1.importantLines.join("\n");
86
+ // Reserve exactly the kept content (+ the "\n" seam between marker and
87
+ // joined lines). The marker text + separators stay as the tolerated
88
+ // TRUNCATION_MARKER_ALLOWANCE overhead; when no important lines are
89
+ // kept the reserve is 0 and sizing is bit-identical to legacy.
90
+ const reserve = joined.length > 0 ? joined.length + 1 : 0;
91
+ // Pass 2 re-slices head+tail against the shrunken budget. It is a pure
92
+ // budgeted slice: preserveImportant:false skips the (already done)
93
+ // scan, and pass-1's lines are guaranteed to sit inside pass-2's
94
+ // dropped middle — the pass-2 middle is a superset of pass-1's because
95
+ // the shrunken head/tail regions are subsets of pass-1's.
96
+ const { head, tail, baseDropped } = splitWithImportantLines(text, this.maxChars, {
97
+ preserveImportant: false,
98
+ reserveChars: reserve,
99
+ });
100
+ const importantLines = pass1.importantLines;
61
101
  let result: string;
62
102
  if (importantLines.length === 0) {
63
103
  result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail preserved]...${this.marker.tailSeparator}${tail}`;
64
104
  } else {
65
- const joined = importantLines.join("\n");
66
105
  const remaining = text.length - head.length - tail.length - joined.length;
67
106
  result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail + ${importantLines.length} important lines preserved, ${remaining} ${this.marker.unit} remaining dropped]...\n${joined}${this.marker.tailSeparator}${tail}`;
68
107
  }
69
108
  // Defense-in-depth: this stage's own monotonic-shrink invariant. The
70
- // pipeline gate is a SECOND line of defense.
109
+ // pipeline gate is a SECOND line of defense. With the K6 budget the
110
+ // composed result is ≤ maxChars + marker budget, so this guard only
111
+ // fires for inputs barely over maxChars (where returning the input
112
+ // verbatim is still within the allowance) or arithmetic bugs.
71
113
  if (result.length >= text.length) return text;
72
114
  return result;
73
115
  }