pi-crew 0.11.20 → 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 (84) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/index.mjs +537 -526
  3. package/package.json +2 -1
  4. package/skills/real-test-pi-crew/SKILL.md +21 -0
  5. package/src/errors.ts +14 -1
  6. package/src/extension/async-notifier.ts +42 -17
  7. package/src/extension/cross-extension-rpc.ts +42 -5
  8. package/src/extension/notification-router.ts +15 -2
  9. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  10. package/src/extension/registration/runtime-cleanup.ts +17 -0
  11. package/src/extension/registration/wire-cross-extension.ts +26 -0
  12. package/src/extension/rpc-hmac.ts +53 -2
  13. package/src/extension/team-tool/cancel.ts +34 -21
  14. package/src/extension/team-tool/dispatch/index.ts +6 -5
  15. package/src/extension/team-tool/dispatch/status.ts +42 -2
  16. package/src/extension/team-tool.ts +24 -2
  17. package/src/prompt/inbox-poll.ts +77 -25
  18. package/src/prompt/message-tool.ts +62 -4
  19. package/src/prompt/prompt-runtime.ts +82 -32
  20. package/src/runtime/broker/crew-broker-client.ts +74 -16
  21. package/src/runtime/broker/crew-broker.ts +111 -77
  22. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  23. package/src/runtime/broker/wait-status-cache.ts +14 -5
  24. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  25. package/src/runtime/child-pi/child-pi.ts +20 -2
  26. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  27. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  28. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  29. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  30. package/src/runtime/crew-agent-records.ts +7 -1
  31. package/src/runtime/delivery-coordinator.ts +30 -0
  32. package/src/runtime/dwf-state-store.ts +41 -7
  33. package/src/runtime/finalize-run.ts +13 -2
  34. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  35. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  36. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  37. package/src/runtime/live-session/embedded-tier.ts +3 -1
  38. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  39. package/src/runtime/model/model-fallback.ts +23 -1
  40. package/src/runtime/output/pi-json-output.ts +26 -1
  41. package/src/runtime/policy-engine.ts +18 -0
  42. package/src/runtime/process/zombie-scanner.ts +78 -14
  43. package/src/runtime/process-status.ts +6 -1
  44. package/src/runtime/recovery/crash-recovery.ts +170 -28
  45. package/src/runtime/rpc/rpc-worker.ts +44 -1
  46. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
  47. package/src/runtime/scheduling/scheduler.ts +74 -11
  48. package/src/runtime/scratchpad/engine.ts +26 -3
  49. package/src/runtime/settings-store.ts +22 -2
  50. package/src/runtime/surface/tmux-provider.ts +24 -3
  51. package/src/runtime/task-health.ts +16 -1
  52. package/src/runtime/task-output-context.ts +143 -71
  53. package/src/runtime/task-packet.ts +70 -17
  54. package/src/runtime/task-runner/child-executor.ts +27 -0
  55. package/src/runtime/task-runner/output-splitter.ts +79 -16
  56. package/src/runtime/task-runner/post-execution.ts +75 -15
  57. package/src/runtime/task-runner/state-helpers.ts +92 -2
  58. package/src/runtime/usage-tracker.ts +11 -0
  59. package/src/runtime/verification/green-contract.ts +17 -0
  60. package/src/runtime/verification/verification-gates.ts +24 -6
  61. package/src/runtime/workspace-lock.ts +53 -0
  62. package/src/schema/team-tool-schema.ts +10 -4
  63. package/src/state/coordination/mailbox.ts +133 -38
  64. package/src/state/coordination/process-lock.ts +22 -2
  65. package/src/state/decision-ledger.ts +56 -8
  66. package/src/state/event-log/event-log-rotation.ts +83 -5
  67. package/src/state/event-log/event-log.ts +181 -12
  68. package/src/state/stores/active-run-registry.ts +10 -3
  69. package/src/state/types.ts +9 -1
  70. package/src/ui/agents-jobs-browser.ts +22 -8
  71. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  72. package/src/ui/events-state-source.ts +67 -28
  73. package/src/ui/inline-panel/agent-pane.ts +13 -6
  74. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  75. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  76. package/src/ui/inline-panel/index.ts +21 -1
  77. package/src/ui/live-run-sidebar.ts +14 -3
  78. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  79. package/src/ui/powerbar-publisher.ts +6 -3
  80. package/src/ui/rail.ts +7 -1
  81. package/src/ui/render-scheduler.ts +53 -1
  82. package/src/ui/run-action-dispatcher.ts +68 -20
  83. package/src/ui/status-colors.ts +9 -0
  84. package/src/utils/env-filter.ts +8 -0
@@ -2,16 +2,25 @@
2
2
  * crew-broker-client.ts — Child-side connector for the local broker.
3
3
  * PHASE 0 skeleton (sub-task 0.3).
4
4
  *
5
- * - Connects only on the first request/subscribe.
5
+ * - Connects only on the first request/subscribe. The connect is
6
+ * SINGLE-FLIGHT (H7, DR-03 round-6 2026-10-16): concurrent request()s
7
+ * on a fresh client share one in-flight connectAndHello() instead of
8
+ * racing N sockets into existence.
6
9
  * - Sends `hello` first; any other method before ack is a protocol error.
7
10
  * - Bounded backoff (50/100/200/400/800 ms + jitter, max 4 attempts).
8
11
  * After exhaustion, no hot reconnect loop is scheduled. The only way back
9
12
  * to the live path is an explicit `reconnect()` (or a new worker lifecycle).
10
13
  * - On ANY connect/auth/timeout/close error, transitions to `fallback` mode
11
- * and never lets an exception escape `request()` / `subscribe()`. The
12
- * caller can read `client.mode` or the `{ok:false, fallback:true}` result
13
- * and continue using today's file-based paths.
14
- * - `close()` removes all listeners and pending requests.
14
+ * and never lets an exception escape `request()`. The caller can read
15
+ * `client.mode` or the `{ok:false, fallback:true}` result and continue
16
+ * using today's file-based paths. (`subscribe()` is the ONE deliberate
17
+ * exception — it throws "not supported" (H8) because a silent no-op
18
+ * unsubscribe let callers believe they were subscribed; it has zero
19
+ * production callers.)
20
+ * - `close()` removes all listeners and pending requests. Any teardown of
21
+ * the current socket (close/reconnect) rejects stranded pending entries
22
+ * IMMEDIATELY with a typed `connection-closed` error (H8) instead of
23
+ * leaving them to their request timers.
15
24
  * - All diagnostic strings pass through `redactSecretString` from
16
25
  * `src/utils/redaction.ts`. Token and payload bytes are never logged.
17
26
  *
@@ -112,6 +121,13 @@ export class CrewBrokerClient {
112
121
  }> = [];
113
122
  /** Set in close(); gates reconnect attempts and backoff loops. */
114
123
  private closed = false;
124
+ /** H7: in-flight connect (single-flight). Concurrent request()s on an
125
+ * unstarted client await the SAME connectAndHello() promise instead of
126
+ * each opening its own socket (previously two concurrent first-requests
127
+ * raced two sockets; the loser's socket leaked until its hello deadline).
128
+ * Cleared on settle — success moves `mode` to "connected" and failure to
129
+ * "fallback", so a null value never masks a needed reconnect. */
130
+ private connecting: Promise<BrokerClientResult<true>> | null = null;
115
131
  /** Handle to the in-flight backoff timer so close() can cancel it. */
116
132
  private backoffTimer: NodeJS.Timeout | null = null;
117
133
  /** Resolver for the in-flight backoff await so close() can unblock it.
@@ -171,9 +187,10 @@ export class CrewBrokerClient {
171
187
  return { ok: false, fallback: true, errorCode: "bad-method" };
172
188
  }
173
189
 
174
- // Lazy connect + hello on first use.
190
+ // Lazy connect + hello on first use. H7: single-flight — concurrent
191
+ // requests share one connectAndHello() (one socket), not N.
175
192
  if (this._mode === "unstarted" || !this.socket) {
176
- const connectResult = await this.connectAndHello();
193
+ const connectResult = await this.ensureConnected();
177
194
  if (!connectResult.ok) {
178
195
  return connectResult;
179
196
  }
@@ -246,18 +263,17 @@ export class CrewBrokerClient {
246
263
  }
247
264
 
248
265
  /**
249
- * Subscribe to a per-run event stream. Phase 0 does not implement
250
- * events.since — the subscription returns a no-op unsubscribe and
251
- * transitions to fallback if the connection is lost. The caller can
252
- * continue using the file poll path.
266
+ * Subscribe to a per-run event stream. NOT SUPPORTED (H8, DR-03
267
+ * round-6 2026-10-16): Phase 0 never implemented the event stream, and
268
+ * the old silent no-op unsubscribe let a caller believe it was
269
+ * subscribed while frames went nowhere. Zero production callers exist,
270
+ * so this now throws instead of silently misbehaving. Live frames are
271
+ * available via the `onEvent` constructor option; durable resync via
272
+ * `msg.inbox` / `events.since` polling.
253
273
  */
254
274
  subscribe(options: { runId: string; sinceSeq: number; onEvent: (event: unknown) => void }): () => void {
255
- // Phase 0: subscription is a typed not-implemented. We register a
256
- // no-op unsubscribe so the caller can call it without errors.
257
275
  void options;
258
- return () => {
259
- /* no-op */
260
- };
276
+ throw new BrokerError("not-implemented", "subscribe is not supported; use onEvent / events.since polling");
261
277
  }
262
278
 
263
279
  /**
@@ -331,6 +347,19 @@ export class CrewBrokerClient {
331
347
  // Connect + hello with bounded backoff
332
348
  // ------------------------------------------------------------------------
333
349
 
350
+ /** H7: single-flight wrapper around connectAndHello. The first caller
351
+ * creates the in-flight promise; concurrent callers await the same one.
352
+ * Settled promises are dropped so a later reconnect() (explicit
353
+ * `unstarted` reset) starts a genuinely fresh connect. */
354
+ private ensureConnected(): Promise<BrokerClientResult<true>> {
355
+ if (!this.connecting) {
356
+ this.connecting = this.connectAndHello().finally(() => {
357
+ this.connecting = null;
358
+ });
359
+ }
360
+ return this.connecting;
361
+ }
362
+
334
363
  private async connectAndHello(): Promise<BrokerClientResult<true>> {
335
364
  if (!this.options.socketPath || !this.options.token) {
336
365
  this.enterFallbackOnce("missing-credentials", new Error("socket or token not provided"));
@@ -642,6 +671,35 @@ export class CrewBrokerClient {
642
671
  }
643
672
 
644
673
  private teardownSocket(): void {
674
+ // H8: reject stranded pending entries IMMEDIATELY. A teardown means
675
+ // the socket they were written to is going away (close() semantics, a
676
+ // reconnect() replacing it, or the closed-race in connectAndHello) —
677
+ // their responses can never arrive on it. Leaving them to their
678
+ // request timers kept callers (e.g. an await inside reconnect())
679
+ // hanging for up to the full request timeout on a dead connection.
680
+ // The typed `connection-closed` error funnels into request()'s catch →
681
+ // {ok:false, fallback:true, errorCode:"connection-closed"}.
682
+ if (this.pending.size > 0) {
683
+ const clearTimeoutFn = this.options.clearTimeoutFn ?? ((t: NodeJS.Timeout) => clearTimeout(t));
684
+ for (const [, p] of this.pending) {
685
+ if (p.timer) {
686
+ try {
687
+ clearTimeoutFn(p.timer);
688
+ } catch {
689
+ /* best-effort */
690
+ }
691
+ }
692
+ p.reject(
693
+ // Code outside the closed BrokerErrorCode union (which lives in
694
+ // ndjson.ts and gates protocol responses); same cast precedent as
695
+ // the wire-error path in attemptHello. Surfaces to callers as
696
+ // errorCode "connection-closed" (BrokerClientResult.errorCode is a
697
+ // plain string).
698
+ new BrokerError("connection-closed" as never, "connection torn down before response"),
699
+ );
700
+ }
701
+ this.pending.clear();
702
+ }
645
703
  if (!this.socket) {
646
704
  this.socket = null;
647
705
  this.decoder = null;
@@ -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;