pi-crew 0.11.11 → 0.11.13

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 (50) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/index.mjs +551 -519
  3. package/package.json +1 -1
  4. package/src/config/config-validation.ts +9 -0
  5. package/src/config/defaults.ts +7 -0
  6. package/src/config/env-vars.ts +24 -0
  7. package/src/config/types.ts +11 -0
  8. package/src/extension/registration/tool-registration.ts +7 -0
  9. package/src/extension/registration/verify-gate-tool.ts +263 -0
  10. package/src/extension/team-tool/run-deadline.ts +22 -1
  11. package/src/extension/team-tool/run.ts +38 -2
  12. package/src/extension/team-tool-types.ts +4 -0
  13. package/src/extension/team-tool.ts +31 -3
  14. package/src/prompt/inbox-poll.ts +141 -3
  15. package/src/runtime/broker/crew-broker.ts +48 -2
  16. package/src/runtime/broker/protocol/lock-busy.ts +47 -1
  17. package/src/runtime/broker/protocol/request-parsers.ts +8 -1
  18. package/src/runtime/budget-enforcement.ts +36 -20
  19. package/src/runtime/child-pi/child-pi-constants.ts +24 -0
  20. package/src/runtime/child-pi/child-pi-kill.ts +59 -2
  21. package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
  22. package/src/runtime/child-pi/child-pi-steering.ts +10 -8
  23. package/src/runtime/child-pi/child-pi-streams.ts +69 -19
  24. package/src/runtime/child-pi/child-pi.ts +29 -30
  25. package/src/runtime/child-pi/owned-process.ts +434 -0
  26. package/src/runtime/child-pi/postmortem-registry.ts +134 -0
  27. package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
  28. package/src/runtime/classifier/classifier-service.ts +277 -61
  29. package/src/runtime/dispatch-batch.ts +32 -0
  30. package/src/runtime/live-session/live-agent-control.ts +2 -0
  31. package/src/runtime/live-session/live-agent-manager.ts +72 -2
  32. package/src/runtime/live-session/live-session-runtime.ts +435 -40
  33. package/src/runtime/merge-loop.ts +63 -35
  34. package/src/runtime/recovery/crash-recovery.ts +141 -3
  35. package/src/runtime/recovery/retry-executor.ts +29 -0
  36. package/src/runtime/run-tracker.ts +28 -0
  37. package/src/runtime/stale-reconciler.ts +36 -1
  38. package/src/runtime/task-runner.ts +103 -0
  39. package/src/runtime/verification/classifier-pre-gate.ts +262 -0
  40. package/src/runtime/verification/verification-gates.ts +5 -1
  41. package/src/runtime/verification/verify-gate.ts +394 -0
  42. package/src/schema/config-schema.ts +13 -0
  43. package/src/state/contracts.ts +18 -0
  44. package/src/state/coordination/mailbox.ts +203 -55
  45. package/src/state/stores/sqlite-run-state.ts +378 -0
  46. package/src/state/stores/state-store.ts +134 -6
  47. package/src/ui/agents-jobs-browser.ts +154 -8
  48. package/src/ui/events-state-source.ts +358 -0
  49. package/src/ui/run-snapshot-cache.ts +87 -3
  50. package/src/ui/widget/widget-model.ts +13 -2
@@ -116,6 +116,19 @@ export const PiTeamsRuntimeConfigSchema = Type.Object(
116
116
  { additionalProperties: false },
117
117
  ),
118
118
  ),
119
+ // U4 (live-session v2): tunables for the in-process live-session worker —
120
+ // responseTimeoutMs bounds one prompt() wall-clock; hungToolGraceMs is
121
+ // the escape hatch's settle-grace after a cooperative abort. Behavior
122
+ // picks, not authority — no sensitive mark.
123
+ liveSession: Type.Optional(
124
+ Type.Object(
125
+ {
126
+ responseTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
127
+ hungToolGraceMs: Type.Optional(Type.Integer({ minimum: 1 })),
128
+ },
129
+ { additionalProperties: false },
130
+ ),
131
+ ),
119
132
  excludeContextBash: Type.Optional(Type.Boolean()),
120
133
  // Handoff budget: est-token (chars/4) cap on the dynamic.dependencyContext
121
134
  // prompt layer. Matches parseHandoffBudgetTokens in config-validation.ts
@@ -111,6 +111,14 @@ export const TEAM_EVENT_TYPES = [
111
111
  // classifier answer informed the retry-vs-skip decision (dormant unless
112
112
  // runtime.classifierEnabled). Diagnostic only — never transitions state.
113
113
  "task.retry_triage",
114
+ // U6A (upgrade spec 2026-10-09 §U6 Phase A): dispatch-batch ambient-noise
115
+ // gate decision — fired by classifyAmbientNoiseGate (retry-executor
116
+ // shouldRetry hook) ONLY when a real classifier answer informed the
117
+ // retry-vs-give-up decision (dormant unless runtime.classifierEnabled —
118
+ // same flag as task.retry_triage). Diagnostic only, never transitions
119
+ // state; every classifier failure mode falls back to proceed-with-retry
120
+ // without emitting this event.
121
+ "task.noise_gate",
114
122
  // WP-2/R2 waiting-producer (ADR-0 2026-08-17-waiting-producer-ask item 10):
115
123
  // `ask` tool lifecycle — requested on park acceptance, answered on delivery
116
124
  // (mailbox or requeue+inject), timedout on deadline expiry (both the
@@ -246,6 +254,11 @@ export const TEAM_EVENT_TYPES = [
246
254
  "run.exported",
247
255
  "run.forget_requested",
248
256
  "run.goal_achievement",
257
+ // RELIABILITY FIX 2026-10-10 (run team_20261010100956 incident): run.lock
258
+ // contention observed by a scheduler merge site (mergeUnitResult /
259
+ // terminaliseRunWithDrain) that is being retried with back-off — diagnostic
260
+ // only; never transitions run state.
261
+ "run.lock_retry",
249
262
  "run.resume_requested",
250
263
  // SDD-3 W-C G4 (team-tool.ts handleResume): forced resume of a FOREIGN-owned
251
264
  // run — ownership was bypassed with force:true (liveness was NOT bypassable;
@@ -271,6 +284,11 @@ export const TEAM_EVENT_TYPES = [
271
284
  "task.model_dropped",
272
285
  "task.mcp_enforcement_degraded",
273
286
  "task.output_validation",
287
+ // U13/U6B (upgrade wave 3, 2026-10-10): deterministic verifier pre-gate
288
+ // decision — emitted by verify-gate/classifier-pre-gate when a task's
289
+ // verifier spawn was skipped (deterministic pass / classifier block) or
290
+ // carried gate context. Observability only; no run/task state transition.
291
+ "task.verifier_pre_gate",
274
292
  "task.steer_queued",
275
293
  "task.steer_dropped",
276
294
  "task.surface_lost",
@@ -72,6 +72,13 @@ export interface MailboxMessage {
72
72
  * Carried by `kind:"response"` entries; matches task.waiting.questionId —
73
73
  * matching must be exact equality (never prefix/substring). */
74
74
  questionId?: string;
75
+ /** U5 (2026-10-10): stable per-send idempotency key — uuid or
76
+ * `{runId}:{taskId}:{seq}`. Every append auto-stamps one when the caller
77
+ * omits it; resend-safe callers (broker msg.send) pass an explicit one so
78
+ * `appendMailboxMessageIdempotent` can dedup by (direction, requestId) in
79
+ * the SAME mailbox file (the durable analog of pi-durable's
80
+ * `tx.submissionByRequest()` exactly-once root-submit). */
81
+ requestId?: string;
75
82
  acknowledgedAt?: string;
76
83
  data?: Record<string, unknown>;
77
84
  /** ID of the original message this is a reply to. */
@@ -89,6 +96,13 @@ export interface MailboxMessage {
89
96
  export interface MailboxDeliveryState {
90
97
  messages: Record<string, MailboxMessageStatus>;
91
98
  updatedAt: string;
99
+ /** U5 (2026-10-10): durable (direction, requestId) delivered index —
100
+ * key `${direction}:${requestId}` → id of the first message row that was
101
+ * consumed under it. Written by the worker inbox pickup
102
+ * (recordMailboxRequestIdDelivery) and read back after a restart so a
103
+ * redelivered/resent row is dropped (exactly-once pickup). Optional so
104
+ * pre-U5 delivery.json files parse unchanged. */
105
+ requestIds?: Record<string, string>;
92
106
  }
93
107
 
94
108
  export interface MailboxValidationIssue {
@@ -307,6 +321,7 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
307
321
  deliveryMode: isDeliveryMode(obj.deliveryMode) ? obj.deliveryMode : undefined,
308
322
  taskId: typeof obj.taskId === "string" ? obj.taskId : undefined,
309
323
  questionId: typeof obj.questionId === "string" ? obj.questionId : undefined,
324
+ requestId: typeof obj.requestId === "string" ? obj.requestId : undefined,
310
325
  acknowledgedAt: typeof obj.acknowledgedAt === "string" ? obj.acknowledgedAt : undefined,
311
326
  data,
312
327
  replyTo: typeof obj.replyTo === "string" ? obj.replyTo : undefined,
@@ -519,6 +534,14 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
519
534
  messages,
520
535
  updatedAt: typeof obj.updatedAt === "string" ? obj.updatedAt : new Date().toISOString(),
521
536
  };
537
+ // U5: optional durable (direction, requestId) delivered index — absent
538
+ // in pre-U5 delivery.json files (treated as "nothing delivered yet").
539
+ if (obj.requestIds && typeof obj.requestIds === "object" && !Array.isArray(obj.requestIds)) {
540
+ const requestIds: Record<string, string> = {};
541
+ for (const [key, id] of Object.entries(obj.requestIds as Record<string, unknown>))
542
+ if (typeof id === "string") requestIds[key] = id;
543
+ if (Object.keys(requestIds).length > 0) state.requestIds = requestIds;
544
+ }
522
545
  setDeliveryCacheEntry(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, state });
523
546
  return state;
524
547
  } catch (error) {
@@ -640,6 +663,7 @@ function pruneDeliveryMessages(manifest: TeamRunManifest, state: MailboxDelivery
640
663
  const entries = Object.entries(state.messages);
641
664
  const ackedCount = entries.reduce((count, [, status]) => (status === "acknowledged" ? count + 1 : count), 0);
642
665
  if (ackedCount > ACK_SWEEP_MIN_ACKS) sweepDeadAcknowledgements(manifest, state);
666
+ pruneDeliveredRequestIds(state);
643
667
  const remaining = Object.entries(state.messages);
644
668
  if (remaining.length <= MAX_DELIVERY_MESSAGES) return;
645
669
  // Stable sort: within a status tier the previous insertion order (which is
@@ -677,6 +701,19 @@ function pruneDeliveryMessages(manifest: TeamRunManifest, state: MailboxDelivery
677
701
  state.messages = Object.fromEntries(remaining.filter(([id]) => keptIds.has(id)));
678
702
  }
679
703
 
704
+ /** U5: bound the durable (direction, requestId) delivered index. Keys insert
705
+ * chronologically (first delivery under that requestId), so eviction keeps
706
+ * the most recent MAX_DELIVERY_MESSAGES keys — dropping a key can only
707
+ * re-enable delivery of an extremely old duplicate row, the same bounded
708
+ * risk the messages map already accepts. */
709
+ function pruneDeliveredRequestIds(state: MailboxDeliveryState): void {
710
+ if (!state.requestIds) return;
711
+ const keys = Object.keys(state.requestIds);
712
+ if (keys.length <= MAX_DELIVERY_MESSAGES) return;
713
+ const keep = new Set(keys.slice(keys.length - MAX_DELIVERY_MESSAGES));
714
+ for (const key of keys) if (!keep.has(key)) delete state.requestIds[key];
715
+ }
716
+
680
717
  function writeDeliveryState(
681
718
  manifest: TeamRunManifest,
682
719
  state: MailboxDeliveryState,
@@ -704,29 +741,17 @@ function writeDeliveryState(
704
741
  }
705
742
  }
706
743
 
707
- /**
708
- * Append a message to a run's or task's mailbox.
709
- *
710
- * SECURITY NOTE: The `from` field is caller-declared — there is no cryptographic
711
- * sender authentication. This is acceptable because `appendMailboxMessage` is an
712
- * internal API only callable from within the pi-crew process (no external input).
713
- * All callers (handleSteer, handleRespond, handleFollowUp) derive `from` from
714
- * authenticated context (session role, task assignment).
715
- *
716
- * If pi-crew ever exposes mailbox writes to external/untrusted input, sender
717
- * authentication (HMAC or session key) must be added.
718
- */
719
- export function appendMailboxMessage(
720
- manifest: TeamRunManifest,
721
- message: Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & {
722
- id?: string;
723
- status?: MailboxMessageStatus;
724
- },
725
- ): MailboxMessage {
726
- if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
727
- else ensureRunMailbox(manifest);
728
- const createdAt = new Date().toISOString();
729
- const complete: MailboxMessage = {
744
+ /** U5 (2026-10-10): shared message construction for every append path.
745
+ * Auto-stamps a unique `requestId` (`req_<uuid>`) when the caller omits one
746
+ * so EVERY mailbox row carries one (receiver dedup keys on it); callers
747
+ * that need resend idempotency pass an explicit stable id instead. */
748
+ type MailboxAppendInput = Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & {
749
+ id?: string;
750
+ status?: MailboxMessageStatus;
751
+ };
752
+
753
+ function buildMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendInput, createdAt: string): MailboxMessage {
754
+ return {
730
755
  // RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
731
756
  // collision-free under the msg_ prefix, no clock-ordering leakage.
732
757
  id: message.id ?? `msg_${randomUUID()}`,
@@ -742,6 +767,10 @@ export function appendMailboxMessage(
742
767
  deliveryMode: message.deliveryMode,
743
768
  taskId: message.taskId,
744
769
  questionId: message.questionId,
770
+ // U5: every send carries a requestId — explicit (idempotent senders)
771
+ // or auto-stamped uuid (one per row; still unique per send).
772
+ requestId: message.requestId ?? `req_${randomUUID()}`,
773
+ acknowledgedAt: message.acknowledgedAt,
745
774
  data: message.data,
746
775
  replyTo: message.replyTo,
747
776
  replyFrom: message.replyFrom,
@@ -749,6 +778,25 @@ export function appendMailboxMessage(
749
778
  repliedAt: message.repliedAt,
750
779
  replyContent: message.replyContent,
751
780
  };
781
+ }
782
+
783
+ /**
784
+ * Append a message to a run's or task's mailbox.
785
+ *
786
+ * SECURITY NOTE: The `from` field is caller-declared — there is no cryptographic
787
+ * sender authentication. This is acceptable because `appendMailboxMessage` is an
788
+ * internal API only callable from within the pi-crew process (no external input).
789
+ * All callers (handleSteer, handleRespond, handleFollowUp) derive `from` from
790
+ * authenticated context (session role, task assignment).
791
+ *
792
+ * If pi-crew ever exposes mailbox writes to external/untrusted input, sender
793
+ * authentication (HMAC or session key) must be added.
794
+ */
795
+ export function appendMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendInput): MailboxMessage {
796
+ if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
797
+ else ensureRunMailbox(manifest);
798
+ const createdAt = new Date().toISOString();
799
+ const complete = buildMailboxMessage(manifest, message, createdAt);
752
800
  // ST-3: collapse to ONE lock namespace (.flock) for ALL mailbox-file
753
801
  // operations — sync append, async append, and full-file rewrite. Previously
754
802
  // this used withEventLogLockSync (.mkdirlock) while async append used an
@@ -846,39 +894,11 @@ export function appendFollowUpMessage(
846
894
  * steering/follow-up delivery. readDeliveryState/writeDeliveryState remain
847
895
  * sync but are cheap thanks to the FIND-01 delivery cache.
848
896
  */
849
- export async function appendMailboxMessageAsync(
850
- manifest: TeamRunManifest,
851
- message: Omit<MailboxMessage, "id" | "runId" | "createdAt" | "status"> & {
852
- id?: string;
853
- status?: MailboxMessageStatus;
854
- },
855
- ): Promise<MailboxMessage> {
897
+ export async function appendMailboxMessageAsync(manifest: TeamRunManifest, message: MailboxAppendInput): Promise<MailboxMessage> {
856
898
  if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
857
899
  else ensureRunMailbox(manifest);
858
900
  const createdAt = new Date().toISOString();
859
- const complete: MailboxMessage = {
860
- // RR-021 WI-4.3j: randomUUID instead of Date.now()+Math.random() —
861
- // collision-free under the msg_ prefix, no clock-ordering leakage.
862
- id: message.id ?? `msg_${randomUUID()}`,
863
- runId: manifest.runId,
864
- direction: message.direction,
865
- from: message.from,
866
- to: message.to,
867
- body: message.body,
868
- createdAt,
869
- status: message.status ?? "queued",
870
- kind: message.kind,
871
- priority: message.priority,
872
- deliveryMode: message.deliveryMode,
873
- taskId: message.taskId,
874
- questionId: message.questionId,
875
- data: message.data,
876
- replyTo: message.replyTo,
877
- replyFrom: message.replyFrom,
878
- replyDeadline: message.replyDeadline,
879
- repliedAt: message.repliedAt,
880
- replyContent: message.replyContent,
881
- };
901
+ const complete = buildMailboxMessage(manifest, message, createdAt);
882
902
  const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
883
903
  await withFileLockAsync(mbFile, async () => {
884
904
  await fs.promises.appendFile(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
@@ -955,6 +975,126 @@ export async function appendFollowUpMessageAsync(
955
975
  });
956
976
  }
957
977
 
978
+ // ============================================================================
979
+ // U5 (2026-10-10): requestId idempotency — every send carries a unique
980
+ // requestId; resend-safe senders reuse the SAME one and are deduped per
981
+ // target mailbox by (direction, requestId) via an in-lock row scan (the
982
+ // durable analog of pi-durable's `tx.submissionByRequest()` exactly-once
983
+ // root-submit, per upgrade-spec U5).
984
+ // ============================================================================
985
+
986
+ export interface IdempotentMailboxAppendResult {
987
+ message: MailboxMessage;
988
+ /** true when an existing (direction, requestId) row in the SAME mailbox
989
+ * file was reused — NO new row, NO delivery-state write, NO observer
990
+ * notification (the original append already did all three). */
991
+ deduped: boolean;
992
+ }
993
+
994
+ /** Row-level (direction, requestId) match against one mailbox file — the
995
+ * same keying findMailboxMessageByRequestId exposes. Checks the U5
996
+ * top-level field and the legacy `data.requestId` shape (group_join). */
997
+ function findRequestIdRowInFile(filePath: string, direction: MailboxDirection, requestId: string): MailboxMessage | undefined {
998
+ return safeReadMailboxFile(filePath, direction).find(
999
+ (message) => message.requestId === requestId || message.data?.requestId === requestId,
1000
+ );
1001
+ }
1002
+
1003
+ function recordDeliveryEntry(manifest: TeamRunManifest, complete: MailboxMessage, createdAt: string): void {
1004
+ withFileLockSync(deliveryFile(manifest, true), () => {
1005
+ const delivery = readDeliveryState(manifest);
1006
+ delivery.messages[complete.id] = complete.status;
1007
+ delivery.updatedAt = createdAt;
1008
+ writeDeliveryState(manifest, delivery);
1009
+ });
1010
+ }
1011
+
1012
+ /** U5: idempotent SYNC append — if the target mailbox file already holds a
1013
+ * row with this (direction, requestId), return that row (deduped) without
1014
+ * writing anything. The check-then-append runs INSIDE the mailbox file lock
1015
+ * so two concurrent sends with the same requestId cannot both append. */
1016
+ export function appendMailboxMessageIdempotent(
1017
+ manifest: TeamRunManifest,
1018
+ message: MailboxAppendInput & { requestId: string },
1019
+ ): IdempotentMailboxAppendResult {
1020
+ if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
1021
+ else ensureRunMailbox(manifest);
1022
+ const createdAt = new Date().toISOString();
1023
+ const complete = buildMailboxMessage(manifest, message, createdAt);
1024
+ const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
1025
+ let stored: MailboxMessage | undefined;
1026
+ withFileLockSync(mbFile, () => {
1027
+ const existing = findRequestIdRowInFile(mbFile, complete.direction, message.requestId);
1028
+ if (existing) {
1029
+ stored = existing;
1030
+ return;
1031
+ }
1032
+ fs.appendFileSync(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
1033
+ rotateMailboxFileIfNeeded(mbFile);
1034
+ });
1035
+ if (stored) return { message: stored, deduped: true };
1036
+ recordDeliveryEntry(manifest, complete, createdAt);
1037
+ notifyMailboxAppended(complete);
1038
+ return { message: complete, deduped: false };
1039
+ }
1040
+
1041
+ /** U5: async twin of appendMailboxMessageIdempotent for the broker fan-out
1042
+ * path (withFileLockAsync — promise-chain, no sleepSync stall). The
1043
+ * delivery RMW keeps the cross-process sync lock, mirroring
1044
+ * appendMailboxMessageAsync. */
1045
+ export async function appendMailboxMessageIdempotentAsync(
1046
+ manifest: TeamRunManifest,
1047
+ message: MailboxAppendInput & { requestId: string },
1048
+ ): Promise<IdempotentMailboxAppendResult> {
1049
+ if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
1050
+ else ensureRunMailbox(manifest);
1051
+ const createdAt = new Date().toISOString();
1052
+ const complete = buildMailboxMessage(manifest, message, createdAt);
1053
+ const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
1054
+ let stored: MailboxMessage | undefined;
1055
+ await withFileLockAsync(mbFile, async () => {
1056
+ const existing = findRequestIdRowInFile(mbFile, complete.direction, message.requestId);
1057
+ if (existing) {
1058
+ stored = existing;
1059
+ return;
1060
+ }
1061
+ await fs.promises.appendFile(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
1062
+ rotateMailboxFileIfNeeded(mbFile);
1063
+ });
1064
+ if (stored) return { message: stored, deduped: true };
1065
+ recordDeliveryEntry(manifest, complete, createdAt);
1066
+ notifyMailboxAppended(complete);
1067
+ return { message: complete, deduped: false };
1068
+ }
1069
+
1070
+ /** U5: durable receiver-side dedup record — the worker inbox pickup marks the
1071
+ * messages it surfaced as consumed: `messages[id] = "acknowledged"` (the F09
1072
+ * durable record; the inbox line itself never flips status) plus the
1073
+ * `${direction}:${requestId}` index. Both survive restart, so a later poll
1074
+ * (fresh process, empty seen-set) still drops them. */
1075
+ export interface MailboxRequestIdDeliveryRecord {
1076
+ direction: MailboxDirection;
1077
+ messageId: string;
1078
+ /** Optional: rows without a requestId (pre-U5 legacy) only get the
1079
+ * by-id acknowledged record. */
1080
+ requestId?: string;
1081
+ }
1082
+
1083
+ export function recordMailboxRequestIdDelivery(manifest: TeamRunManifest, records: MailboxRequestIdDeliveryRecord[]): MailboxDeliveryState {
1084
+ return withFileLockSync(deliveryFile(manifest, true), () => {
1085
+ const delivery = readDeliveryState(manifest);
1086
+ for (const record of records) {
1087
+ delivery.messages[record.messageId] = "acknowledged";
1088
+ if (record.requestId) (delivery.requestIds ??= {})[`${record.direction}:${record.requestId}`] = record.messageId;
1089
+ }
1090
+ delivery.updatedAt = new Date().toISOString();
1091
+ // Pickup is terminal for that message on the worker side — full durability
1092
+ // (same rationale as acknowledgeMailboxMessage).
1093
+ writeDeliveryState(manifest, delivery, { durability: "full" });
1094
+ return delivery;
1095
+ });
1096
+ }
1097
+
958
1098
  export function listMailboxByKind(manifest: TeamRunManifest, kind: MailboxMessageKind, direction?: MailboxDirection): MailboxMessage[] {
959
1099
  const messages = direction
960
1100
  ? readAllMessages(manifest, direction)
@@ -964,8 +1104,16 @@ export function listMailboxByKind(manifest: TeamRunManifest, kind: MailboxMessag
964
1104
  return messages.filter((message) => message.kind === kind || message.data?.kind === kind);
965
1105
  }
966
1106
 
967
- export function findMailboxMessageByRequestId(manifest: TeamRunManifest, requestId: string): MailboxMessage | undefined {
968
- return readMailbox(manifest).find((message) => message.data?.requestId === requestId);
1107
+ export function findMailboxMessageByRequestId(
1108
+ manifest: TeamRunManifest,
1109
+ requestId: string,
1110
+ scope?: { direction?: MailboxDirection; taskId?: string },
1111
+ ): MailboxMessage | undefined {
1112
+ // U5: also match the top-level `requestId` field (new sends) alongside the
1113
+ // legacy `data.requestId` shape (group_join) so one lookup covers both.
1114
+ const matches = (message: MailboxMessage) => message.requestId === requestId || message.data?.requestId === requestId;
1115
+ if (scope) return readMailbox(manifest, scope.direction, scope.taskId).find(matches);
1116
+ return readMailbox(manifest).find(matches);
969
1117
  }
970
1118
 
971
1119
  export function readMailboxMessage(manifest: TeamRunManifest, messageId: string): MailboxMessage | undefined {