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
@@ -954,11 +954,33 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
954
954
  );
955
955
  }
956
956
 
957
+ // R5-6 (DR-02 round-5): send-side clamp for `team steer` bodies. The
958
+ // worker-side receiver (prompt-runtime sanitizeSteerMessage) clamps too (its
959
+ // rejection of >4096-char steers was terminal — the poll cursor advanced
960
+ // past the line, so an oversized leader steer was silently dropped), but the
961
+ // send-side clamp means pendingSteers, the steering JSONL, and the
962
+ // task.steer_queued event all already carry a receiver-safe body with the
963
+ // truncation marker visible to the leader's audit trail. Deliberately
964
+ // duplicated from prompt-runtime's clampSteerMessage (same rationale as the
965
+ // estimateHandoffTokens duplication in task-output-context.ts): importing
966
+ // prompt-runtime here would pull the broker-client/scratchpad/surface-worker
967
+ // graph into the extension registration path (cold-start). Keep the two
968
+ // clamps in sync.
969
+ const TEAM_STEER_MAX_MESSAGE_LENGTH = 4096;
970
+ const TEAM_STEER_TRUNCATION_MARKER = `\n[message truncated at ${TEAM_STEER_MAX_MESSAGE_LENGTH} chars]`;
971
+ function clampSteerBody(message: string): string {
972
+ if (message.length <= TEAM_STEER_MAX_MESSAGE_LENGTH) return message;
973
+ return `${message.slice(0, TEAM_STEER_MAX_MESSAGE_LENGTH - TEAM_STEER_TRUNCATION_MARKER.length)}${TEAM_STEER_TRUNCATION_MARKER}`;
974
+ }
975
+
957
976
  export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
958
- const { runId, taskId, message } = params;
959
- if (!runId || !taskId || !message) {
977
+ const { runId, taskId } = params;
978
+ const rawMessage = params.message;
979
+ if (!runId || !taskId || !rawMessage) {
960
980
  return result("steer requires runId, taskId, and message", { action: "steer", status: "error" }, true);
961
981
  }
982
+ // R5-6: clamp BEFORE any sink (pending queue, JSONL, event) sees the body.
983
+ const message = clampSteerBody(String(rawMessage));
962
984
  const runCwd = locateRunCwd(runId, ctx.cwd);
963
985
  if (!runCwd) return result(`Run '${runId}' not found`, { action: "steer", status: "error" }, true);
964
986
  const loaded = loadRunManifestById(runCwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
@@ -23,12 +23,19 @@
23
23
  * must be dropped at consume time);
24
24
  * - dedup by message id across polls via a caller-owned seen-set and/or a
25
25
  * `sinceTs` watermark — one message delivers once;
26
- * - U5 (2026-10-10): dedup is ALSO durable across restarts — every picked
27
- * row is recorded in delivery.json (`messages[id]="acknowledged"` +
26
+ * - U5 (2026-10-10): dedup is ALSO durable across restarts — delivered
27
+ * rows are recorded in delivery.json (`messages[id]="acknowledged"` +
28
28
  * `${direction}:${requestId}` index) and re-read on the next poll, so a
29
29
  * worker that restarts mid-run (fresh process, EMPTY seen-set) never
30
30
  * re-delivers. A resend carrying the same requestId is dropped by the
31
31
  * same index; duplicate rows under one requestId deliver exactly once.
32
+ * - R5-1 (DR-02, 2026-10-15): the durable record is written in TWO PHASES.
33
+ * `pollWorkerInbox` only picks rows and marks the IN-MEMORY seen-set;
34
+ * the durable "acknowledged" write lives in
35
+ * `acknowledgeWorkerInboxDelivery`, which the delivery loop calls ONLY
36
+ * after `pi.sendMessage` succeeded. A send that throws must instead call
37
+ * `unmarkSeenInboxIds` so the next tick re-picks the batch — at-least-once
38
+ * beats the pre-fix at-most-once-with-a-false-"delivered"-audit-record.
32
39
  */
33
40
  import * as path from "node:path";
34
41
  import {
@@ -125,13 +132,22 @@ function emitPickupEvent(stateRoot: string, runId: string, taskId: string, picke
125
132
 
126
133
  /**
127
134
  * Read the worker's inbox mailbox and return the messages that should be
128
- * surfaced as fenced context on the next turn.
135
+ * surfaced as fenced context on the next turn. PICKUP PHASE ONLY (R5-1):
136
+ * picked rows are marked in the caller-owned in-memory seen-set, but the
137
+ * durable delivery record is NOT written here — the caller must run
138
+ * {@link acknowledgeWorkerInboxDelivery} after its send succeeded, and
139
+ * {@link unmarkSeenInboxIds} when the send threw.
140
+ *
141
+ * `limit` (R5-2, DR-02): pick at most this many rows per call (callers pass
142
+ * their per-tick batch cap). Leftover rows are simply not picked — not
143
+ * marked, in memory or durably — and flow on a later tick.
129
144
  *
130
145
  * Stateless apart from the optional caller-owned `seenIds`/`sinceTs` — safe
131
- * to call on every 500ms poll tick. U5: picked rows are ALSO recorded in the
132
- * durable delivery state (delivery.json), so dedup survives a restart.
146
+ * to call on every 500ms poll tick. U5: the durable dedup-read checks
147
+ * (acknowledged rows / delivered requestIds) still run here so dedup
148
+ * survives a restart.
133
149
  */
134
- export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
150
+ export function pollWorkerInbox(pickup: WorkerInboxPickup, limit?: number): MailboxMessage[] {
135
151
  const { stateRoot, runId, taskId } = pickup;
136
152
  if (!stateRoot || !runId || !taskId) return [];
137
153
  const manifest = { stateRoot, runId } as unknown as TeamRunManifest;
@@ -157,6 +173,11 @@ export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
157
173
  const byId = new Set<string>();
158
174
  const byRequestId = new Set<string>();
159
175
  for (const m of messages) {
176
+ // R5-2 (DR-02): enforce the batch cap AT PICKUP, not at the send site —
177
+ // every picked row must also be a row the caller can actually send (and
178
+ // later acknowledge). The pre-fix shape picked N rows, durably acked all
179
+ // N, then sliced 8 for the send — the surplus was never re-delivered.
180
+ if (limit !== undefined && picked.length >= limit) break;
160
181
  // Kind gate: only durable `message`s (per §15.2). Notify is
161
182
  // fire-and-forget (its own channel), steer/response/follow-up are
162
183
  // other delivery paths.
@@ -204,25 +225,56 @@ export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
204
225
  seen?.add(m.id);
205
226
  picked.push(m);
206
227
  }
207
- if (picked.length > 0) {
208
- // U5: persist the pickup — the durable record that powers the two
209
- // layers above after a restart. Best-effort on failure: the in-memory
210
- // seen-set still dedups within THIS process, and logInternalError
211
- // makes the lost-durability window visible.
212
- try {
213
- recordMailboxRequestIdDelivery(
214
- manifest,
215
- picked.map((m) => ({
216
- direction: m.direction,
217
- messageId: m.id,
218
- requestId: messageIdempotencyKey(m),
219
- })),
220
- );
221
- emitPickupEvent(stateRoot, runId, taskId, picked);
222
- } catch (error) {
223
- logInternalError("inbox-poll.record-delivery", error, `runId=${runId} taskId=${taskId}`);
224
- }
225
- }
226
228
  // Deterministic delivery order (same sort readAllMailboxMessages applies).
227
229
  return picked;
228
230
  }
231
+
232
+ /**
233
+ * R5-1 (DR-02, 2026-10-15): durable-ack phase of the pickup, split OUT of
234
+ * `pollWorkerInbox`. The caller invokes this ONLY after the picked rows were
235
+ * successfully delivered (`pi.sendMessage` returned) — the durable
236
+ * "acknowledged" record and the pickup audit event must never precede a send
237
+ * that can fail (the pre-fix bug wrote them BEFORE the send, so a send
238
+ * failure produced a permanent false "delivered" record plus silent message
239
+ * loss). Mid-batch all-or-nothing: recordMailboxRequestIdDelivery is a single
240
+ * locked RMW, so acks are atomic per call.
241
+ *
242
+ * Best-effort on failure (never throws into the poll tick): the in-memory
243
+ * seen-set still dedups within THIS process, and logInternalError makes the
244
+ * lost-durability window visible.
245
+ */
246
+ export function acknowledgeWorkerInboxDelivery(
247
+ pickup: Pick<WorkerInboxPickup, "stateRoot" | "runId" | "taskId">,
248
+ picked: MailboxMessage[],
249
+ ): void {
250
+ const { stateRoot, runId, taskId } = pickup;
251
+ if (!stateRoot || !runId || !taskId || picked.length === 0) return;
252
+ const manifest = { stateRoot, runId } as unknown as TeamRunManifest;
253
+ try {
254
+ // U5: persist the delivery — the durable record that powers the two
255
+ // dedup layers above after a restart.
256
+ recordMailboxRequestIdDelivery(
257
+ manifest,
258
+ picked.map((m) => ({
259
+ direction: m.direction,
260
+ messageId: m.id,
261
+ requestId: messageIdempotencyKey(m),
262
+ })),
263
+ );
264
+ emitPickupEvent(stateRoot, runId, taskId, picked);
265
+ } catch (error) {
266
+ logInternalError("inbox-poll.record-delivery", error, `runId=${runId} taskId=${taskId}`);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * R5-1 (DR-02): release the in-memory seen marks for a batch whose
272
+ * `pi.sendMessage` THREW, so the next poll tick re-picks and re-delivers it
273
+ * (at-least-once beats at-most-once with a false "delivered" audit record).
274
+ * Mirrors the steer channel's `seenSteers.unmark` + offset-rewind
275
+ * compensation in prompt-runtime.ts pollSteering.
276
+ */
277
+ export function unmarkSeenInboxIds(seen: Set<string> | undefined, picked: MailboxMessage[]): void {
278
+ if (!seen) return;
279
+ for (const m of picked) seen.delete(m.id);
280
+ }
@@ -24,6 +24,7 @@
24
24
  * sending, so two workers cannot loop message each other to death.
25
25
  */
26
26
 
27
+ import { createHash } from "node:crypto";
27
28
  import { type Static, Type } from "@sinclair/typebox";
28
29
  import { getCrewEnv } from "../config/env-vars.ts";
29
30
  import { CrewBrokerClient } from "../runtime/broker/crew-broker-client.ts";
@@ -38,6 +39,14 @@ const MSG_TO_MAX_CHARS = 256;
38
39
  /** §15.2 rate-limit: 10 messages/min/task, sliding window. */
39
40
  const MSG_RATE_LIMIT_MAX = 10;
40
41
  const MSG_RATE_LIMIT_WINDOW_MS = 60_000;
42
+ /** R6-6 (DR-03 round-6 2026-10-16): explicit per-request timeout for
43
+ * msg.send. The client's 15s default (REQUEST_TIMEOUT_DEFAULT_MS) turns a
44
+ * merely-slow durable send (multi-recipient chunked appends + fsync + audit
45
+ * event) into a reported "NOT sent" — and the model's natural retry into a
46
+ * duplicate delivery. 60s matches the worst bounded fanout (64 recipients,
47
+ * 8-wide chunks) with headroom while staying below the tool's own "never a
48
+ * hang" budget (the request still resolves via this timeout, never hangs). */
49
+ export const MSG_SEND_TIMEOUT_MS = 60_000;
41
50
 
42
51
  export const MessageToolParamsSchema = Type.Object({
43
52
  to: Type.Union([Type.Literal("parent"), Type.Literal("group"), Type.String({ minLength: 1, maxLength: MSG_TO_MAX_CHARS })]),
@@ -49,12 +58,46 @@ export const MessageToolParamsSchema = Type.Object({
49
58
  export type MessageToolParams = Static<typeof MessageToolParamsSchema>;
50
59
 
51
60
  /** Minimal broker-client surface the message tool needs (structural subset of
52
- * CrewBrokerClient — tests substitute a recorder). */
61
+ * CrewBrokerClient — tests substitute a recorder). R6-6: the optional third
62
+ * argument carries the per-request timeout; existing 2-arg recorders remain
63
+ * structurally assignable. */
53
64
  export interface MessageBrokerClientSurface {
54
- request(method: string, params: unknown): Promise<{ ok: true; value: unknown } | { ok: false; fallback?: boolean; errorCode?: string }>;
65
+ request(
66
+ method: string,
67
+ params: unknown,
68
+ opts?: { timeoutMs?: number },
69
+ ): Promise<{ ok: true; value: unknown } | { ok: false; fallback?: boolean; errorCode?: string }>;
55
70
  close?(): Promise<void>;
56
71
  }
57
72
 
73
+ /** R6-6: stable idempotency key derived from message identity. A retry of
74
+ * the same logical message (same sender, target, kind, content — e.g. the
75
+ * model re-invoking `message` after a lost/slow ack) computes the SAME
76
+ * requestId, and the broker's U5 durable (direction, requestId) index in
77
+ * delivery.json + appendMailboxMessageIdempotentAsync turn the resend into
78
+ * a no-op instead of a duplicate row. Pure content hash: matches the
79
+ * request-parsers `^[\\w:.-]{1,256}$` requestId charset (hex + `-`). */
80
+ export function computeMessageRequestId(identity: {
81
+ runId: string;
82
+ taskId: string;
83
+ to: string;
84
+ kind: string;
85
+ body: string;
86
+ subject?: string;
87
+ priority?: string;
88
+ }): string {
89
+ const material = JSON.stringify([
90
+ identity.runId,
91
+ identity.taskId,
92
+ identity.to,
93
+ identity.kind,
94
+ identity.body,
95
+ identity.subject ?? null,
96
+ identity.priority ?? null,
97
+ ]);
98
+ return `msgtool-${createHash("sha256").update(material).digest("hex").slice(0, 32)}`;
99
+ }
100
+
58
101
  export interface MessageToolDeps {
59
102
  /** Env source override (tests). Production reads via getCrewEnv. */
60
103
  env?: NodeJS.ProcessEnv;
@@ -137,11 +180,26 @@ export function createMessageTool(deps: MessageToolDeps = {}): MessageTool {
137
180
  return new CrewBrokerClient({ runId, taskId, socketPath, token });
138
181
  })();
139
182
  if (!client) return brokerUnavailable();
140
- const requestParams: Record<string, unknown> = { to: params.to, kind: params.kind, body: params.body };
183
+ // R6-6: stable requestId (idempotency across retries) + explicit
184
+ // timeoutMs (a slow durable ack is NOT a failure).
185
+ const requestParams: Record<string, unknown> = {
186
+ to: params.to,
187
+ kind: params.kind,
188
+ body: params.body,
189
+ requestId: computeMessageRequestId({
190
+ runId,
191
+ taskId,
192
+ to: params.to,
193
+ kind: params.kind,
194
+ body: params.body,
195
+ subject: params.subject,
196
+ priority: params.priority,
197
+ }),
198
+ };
141
199
  if (params.subject) requestParams.subject = params.subject;
142
200
  if (params.priority) requestParams.priority = params.priority;
143
201
  try {
144
- const sent = await client.request("msg.send", requestParams);
202
+ const sent = await client.request("msg.send", requestParams, { timeoutMs: MSG_SEND_TIMEOUT_MS });
145
203
  if (!sent.ok) {
146
204
  // Broker rejection (role gate / policy / auth / fallback) — all
147
205
  // fast-fail, non-blocking.
@@ -12,7 +12,7 @@ import { appendEventFireAndForget } from "../state/event-log/event-log.ts";
12
12
  import type { TeamRunManifest } from "../state/types.ts";
13
13
  import { logInternalError } from "../utils/internal-error.ts";
14
14
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
15
- import { pollWorkerInbox } from "./inbox-poll.ts";
15
+ import { acknowledgeWorkerInboxDelivery, pollWorkerInbox, unmarkSeenInboxIds } from "./inbox-poll.ts";
16
16
  import { createMessageTool, type MessageToolParams as MessageToolInputs, shouldRegisterMessageTool } from "./message-tool.ts";
17
17
  import { registerScratchpadLifecycle } from "./scratchpad-lifecycle.ts";
18
18
  import { createWorkerActivityTracker, registerSurfaceWorkerLifecycle, trackToolActivity } from "./surface-worker.ts";
@@ -32,13 +32,31 @@ const DATE_HEADER = "\nCurrent date:";
32
32
  // ── FIX-02: Steering content sanitization limits ──────────────────────────
33
33
  // Bounded to keep a malformed/malicious steer entry from blowing up the
34
34
  // worker's prompt budget or smuggling control sequences into the agent.
35
- const MAX_STEER_MESSAGE_LENGTH = 4096;
35
+ export const MAX_STEER_MESSAGE_LENGTH = 4096;
36
36
  const MAX_STEER_MESSAGE_NEWLINES = 50;
37
37
  // C0 control characters minus the printable whitespace (\t \n \r). These
38
38
  // are the bytes most useful for ANSI escapes, terminal-control tricks, and
39
39
  // NUL-injection attacks when steer content reaches the worker's UI.
40
40
  const STEER_CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
41
41
 
42
+ /** R5-6 (DR-02 round-5): truncation marker for oversized steer bodies —
43
+ * mirrors renderInboxMessage's 8192-char marker pattern below. The clamp
44
+ * slices so the FINAL message (slice + marker) stays ≤
45
+ * {@link MAX_STEER_MESSAGE_LENGTH}, keeping the receiver's length invariant
46
+ * honest. */
47
+ const STEER_TRUNCATION_MARKER = `\n[message truncated at ${MAX_STEER_MESSAGE_LENGTH} chars]`;
48
+
49
+ /** R5-6 (DR-02 round-5): clamp a steer body to
50
+ * {@link MAX_STEER_MESSAGE_LENGTH} with a truncation marker. The send side
51
+ * (team-tool handleSteer) clamps at dispatch; this helper is the receiver's
52
+ * clean seam — every delivery path (file poll AND broker push) funnels
53
+ * through `sanitizeSteerMessage`, so an oversized steer from ANY writer is
54
+ * delivered truncated-with-marker instead of rejected-and-dropped. */
55
+ export function clampSteerMessage(message: string): string {
56
+ if (message.length <= MAX_STEER_MESSAGE_LENGTH) return message;
57
+ return `${message.slice(0, MAX_STEER_MESSAGE_LENGTH - STEER_TRUNCATION_MARKER.length)}${STEER_TRUNCATION_MARKER}`;
58
+ }
59
+
42
60
  export interface SteerSanitizeResult {
43
61
  valid: boolean;
44
62
  reason?: string;
@@ -118,25 +136,28 @@ export function createSeenSteerIdSet(): {
118
136
 
119
137
  /**
120
138
  * Validate a single steering-file entry before forwarding it to
121
- * `pi.sendMessage`. FIX-02: reject oversized payloads, excessive newlines,
122
- * or control characters that could be used to confuse the worker UI.
123
- */
139
+ * `pi.sendMessage`. FIX-02: reject excessive newlines or control characters
140
+ * that could be used to confuse the worker UI. R5-6 (DR-02 round-5): an
141
+ * OVERSIZED payload (>4096 chars) is no longer rejected-and-dropped — the
142
+ * rejection was terminal (the poll cursor advanced past the line, warn-only)
143
+ * and the send side had no clamp, so a leader steer over the limit silently
144
+ * vanished. It is now truncated-with-marker ({@link clampSteerMessage}) and
145
+ * delivered; the newline/control-char rejections remain terminal (different
146
+ * failure modes — UI confusion / escape smuggling, not length). */
124
147
  export function sanitizeSteerMessage(entry: SteerEntry): SteerSanitizeResult {
125
148
  const message = entry.message;
126
149
  if (typeof message !== "string" || message.length === 0) {
127
150
  return { valid: false, reason: "missing-or-empty-message" };
128
151
  }
129
- if (message.length > MAX_STEER_MESSAGE_LENGTH) {
130
- return { valid: false, reason: `message-too-long:${message.length}` };
131
- }
132
- const newlineCount = (message.match(/\n/g) ?? []).length;
152
+ const clamped = clampSteerMessage(message);
153
+ const newlineCount = (clamped.match(/\n/g) ?? []).length;
133
154
  if (newlineCount > MAX_STEER_MESSAGE_NEWLINES) {
134
155
  return { valid: false, reason: `too-many-newlines:${newlineCount}` };
135
156
  }
136
- if (STEER_CONTROL_CHAR_PATTERN.test(message)) {
157
+ if (STEER_CONTROL_CHAR_PATTERN.test(clamped)) {
137
158
  return { valid: false, reason: "contains-control-characters" };
138
159
  }
139
- return { valid: true, message };
160
+ return { valid: true, message: clamped };
140
161
  }
141
162
 
142
163
  // ── FIX-03: Steering file path containment validation ─────────────────────
@@ -1067,45 +1088,74 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
1067
1088
  // (`deliverAs:"steer"`, customType "crew-inbox") so the model sees
1068
1089
  // conversations between agents. Dormant-until-env: only when this worker
1069
1090
  // carries broker credentials (child-pi-spawn sets them for every worker)
1070
- // + a state root. A message is delivered EXACTLY ONCE via a seen-id set
1071
- // owned by this loop (the same keying that guards cross-channel steer
1072
- // dedup; here the durable mailbox is the only channel).
1091
+ // + a state root. R5-1/R5-2 (DR-02, 2026-10-15): delivery is ack-AFTER-send
1092
+ // — the poll picks (in-memory seen-set) up to INBOX_BATCH_MAX rows, the
1093
+ // DURABLE "acknowledged" record is written (acknowledgeWorkerInboxDelivery)
1094
+ // only after pi.sendMessage succeeded; a send throw unmarks the batch
1095
+ // (unmarkSeenInboxIds) so the next tick re-delivers it. Pickup is thus
1096
+ // at-least-once across send failures and exactly-once once the durable
1097
+ // ack lands (it also survives a restart — U5 semantics).
1073
1098
  const inboxBrokerRunId = getCrewEnv(PI_CREW_BROKER_RUN_ID_ASK_ENV);
1074
1099
  const inboxStateRoot = getCrewEnv(PI_CREW_STATE_ROOT_ENV);
1075
1100
  const inboxTaskId = getCrewEnv(PI_CREW_TASK_ID_ASK_ENV) ?? getCrewEnv(PI_CREW_BROKER_TASK_ID_ASK_ENV);
1076
1101
  if (inboxBrokerRunId && inboxStateRoot && inboxTaskId) {
1077
1102
  const seenInboxIds = new Set<string>();
1078
1103
  // Batch cap per tick: bounds a single steer frame that could otherwise
1079
- // pile up many fenced messages in one delivery.
1104
+ // pile up many fenced messages in one delivery. R5-2 (DR-02): the cap
1105
+ // is enforced AT PICKUP (pollWorkerInbox `limit`) so every picked row is
1106
+ // also a row actually sent + acked — the pre-fix shape acked N rows
1107
+ // durably but sliced 8 for the send, orphaning the surplus forever.
1080
1108
  const INBOX_BATCH_MAX = 8;
1081
1109
  const pollInbox = (): void => {
1082
1110
  try {
1083
- const picked = pollWorkerInbox({
1084
- stateRoot: inboxStateRoot,
1085
- runId: inboxBrokerRunId,
1086
- taskId: inboxTaskId,
1087
- seenIds: seenInboxIds,
1088
- });
1111
+ const picked = pollWorkerInbox(
1112
+ {
1113
+ stateRoot: inboxStateRoot,
1114
+ runId: inboxBrokerRunId,
1115
+ taskId: inboxTaskId,
1116
+ seenIds: seenInboxIds,
1117
+ },
1118
+ INBOX_BATCH_MAX,
1119
+ );
1089
1120
  if (picked.length === 0) return;
1090
- const batch = picked.slice(0, INBOX_BATCH_MAX);
1091
1121
  // §15.2 trust boundary: the sender's body is DATA, never
1092
1122
  // instructions. pollWorkerInbox hands back raw mailbox entries —
1093
1123
  // fence each body (control chars stripped, closing fence
1094
1124
  // neutralized, length capped) before it reaches the agent.
1095
- const fenced = batch.map((m) => ({
1125
+ const fenced = picked.map((m) => ({
1096
1126
  from: m.from,
1097
1127
  to: m.to,
1098
1128
  body: renderInboxMessage(m),
1099
1129
  }));
1100
- pi.sendMessage(
1101
- {
1102
- customType: "crew-inbox",
1103
- content: fenced.map((e) => e.body).join("\n"),
1104
- display: false,
1105
- details: { messages: fenced, count: batch.length },
1106
- },
1107
- { deliverAs: "steer" },
1108
- );
1130
+ try {
1131
+ pi.sendMessage(
1132
+ {
1133
+ customType: "crew-inbox",
1134
+ content: fenced.map((e) => e.body).join("\n"),
1135
+ display: false,
1136
+ details: { messages: fenced, count: picked.length },
1137
+ },
1138
+ { deliverAs: "steer" },
1139
+ );
1140
+ } catch (sendErr) {
1141
+ // R5-1: the send FAILED (e.g. pre-bind notInitialized — exactly
1142
+ // what the registration-time synchronous pollInbox() below can
1143
+ // hit). No durable ack was written yet, so there is no false
1144
+ // "delivered" record; release the seen marks so the next tick
1145
+ // re-picks and re-delivers the batch — mirrors seenSteers.unmark
1146
+ // + offset rewind in pollSteering above (at-least-once).
1147
+ unmarkSeenInboxIds(seenInboxIds, picked);
1148
+ logInternalError(
1149
+ "prompt-runtime.inbox-delivery-failed",
1150
+ sendErr instanceof Error ? sendErr : new Error(String(sendErr)),
1151
+ `runId=${inboxBrokerRunId} taskId=${inboxTaskId} count=${picked.length}`,
1152
+ "warn",
1153
+ );
1154
+ return;
1155
+ }
1156
+ // R5-1: the durable "acknowledged" record + pickup audit event
1157
+ // happen ONLY after a successful send — never before it.
1158
+ acknowledgeWorkerInboxDelivery({ stateRoot: inboxStateRoot, runId: inboxBrokerRunId, taskId: inboxTaskId }, picked);
1109
1159
  } catch {
1110
1160
  // A transient mailbox read error must never break the tick — the
1111
1161
  // next 500ms (or 50ms under live-session realtime) poll retries.
@@ -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;