chainlesschain 0.166.1 → 0.166.3

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 (174) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/{AIOps-DwdhBUBc.js → AIOps-DZTvbk_H.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-Dd1lBEeo.js → ActionButton-IaEVUN3o.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-DuGuuI36.js → Analytics-CCQab0gl.js} +3 -3
  5. package/src/assets/web-panel/assets/{AppLayout-CvHpSs3M.js → AppLayout-Bs_0GVpx.js} +5 -5
  6. package/src/assets/web-panel/assets/{Artifacts-BU9F3wJh.js → Artifacts-Bm83-yJv.js} +1 -1
  7. package/src/assets/web-panel/assets/{Audit-DK_n_WFm.js → Audit-Bah1XDR5.js} +1 -1
  8. package/src/assets/web-panel/assets/{BackgroundAgents-qKsxF5tv.js → BackgroundAgents-BPZA0B5d.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-JS58pSyV.js → Backup-BMydWFiM.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-gGl6Ray1.js → BaseInput-lhopFSZz.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-B2FZNq3U.js → Chat-Bbbs5BP1.js} +5 -5
  12. package/src/assets/web-panel/assets/{ChatBubbleRenderer-CZS_bLAr.js → ChatBubbleRenderer-BIAfOKCj.js} +1 -1
  13. package/src/assets/web-panel/assets/{Checkbox-tsI0TE22.js → Checkbox-CLEMA9J4.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-D5kqhfAr.js → Codegen-deCSwmnK.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-DdN1TrqS.js → Col-CH-n8haq.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-DgbEeh6V.js → Community-DJuAl6dw.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-FIm-fRB_.js → Compact-BMyLBmgs.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compliance-B-N0pudf.js → Compliance-BK75Ss93.js} +1 -1
  19. package/src/assets/web-panel/assets/{Cowork-CuMafBpi.js → Cowork-5XbJwKYq.js} +2 -2
  20. package/src/assets/web-panel/assets/{Cron-BTwzNFHR.js → Cron-Ezgir7IZ.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-DRoYbLOd.js → Crosschain-CKuMoYA7.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-CPhnNkz_.js → DID-CxS244cH.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-DJQftix2.js → Dashboard-wrUKrsJy.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-PSAH1Kfy.js → Dropdown-A6I91Z2J.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-PVRqeMiZ.js → EmailListRenderer-BofaiRYY.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-CYtnjqjl.js → FamilyGuardDashboard-D3eeNA64.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-_-53kTDz.js → Federation-DzK-3AjS.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-DTQ4qGuE.js → FormItemContext-Dm6mYA8b.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-ON_NfX8k.js → GenericCardRenderer-BeWL-Q1b.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-DUc3w_Bf.js → Git-CtGCxg9p.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-CktsJ2ku.js → Governance-DZvFB46G.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-4wUHQvju.js → Inference-ntqp4cmw.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-Dq4saPnZ.js → KnowledgeGraph-CrLYbqLe.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-Btb50m5s.js → Logs-CKA1PRhC.js} +2 -2
  35. package/src/assets/web-panel/assets/MarkdownRenderer-DtwoKxDJ.js +1 -0
  36. package/src/assets/web-panel/assets/{Marketplace-D3rSncxx.js → Marketplace-B8aPsXYv.js} +1 -1
  37. package/src/assets/web-panel/assets/{McpTools-Bc6Mori_.js → McpTools-DQOgNdGS.js} +3 -3
  38. package/src/assets/web-panel/assets/{Memory-C-ottUdN.js → Memory-i9IK9B0M.js} +2 -2
  39. package/src/assets/web-panel/assets/{MobileBridge-C0DITBmP.js → MobileBridge-BynYvpv9.js} +1 -1
  40. package/src/assets/web-panel/assets/{MobileProjects-O6cr0Nal.js → MobileProjects-DixivGuQ.js} +1 -1
  41. package/src/assets/web-panel/assets/{Mtc-B_MXKXvY.js → Mtc-OWPvWVNr.js} +4 -4
  42. package/src/assets/web-panel/assets/{MtcAudit-BDkUqWIT.js → MtcAudit-Cqpg5C4A.js} +5 -5
  43. package/src/assets/web-panel/assets/{Multisig-BaxNWm8c.js → Multisig-CT5kMJIZ.js} +3 -3
  44. package/src/assets/web-panel/assets/{NLProgramming-UD2GndZc.js → NLProgramming-BXD0Dz0h.js} +1 -1
  45. package/src/assets/web-panel/assets/{Notes-pU7wtMwB.js → Notes-T1w5p8N5.js} +3 -3
  46. package/src/assets/web-panel/assets/{NotificationSettings-BIs6ULr3.js → NotificationSettings-DFCThpKJ.js} +1 -1
  47. package/src/assets/web-panel/assets/OrderTableRenderer-BJOuM0Kl.js +1 -0
  48. package/src/assets/web-panel/assets/{Organization-C9o-n2lq.js → Organization-CSFcw86q.js} +4 -4
  49. package/src/assets/web-panel/assets/{Overflow-CEvMXxCS.js → Overflow-BxVMuLTk.js} +1 -1
  50. package/src/assets/web-panel/assets/{P2P-C3gQMTK3.js → P2P-B8qxY_yX.js} +2 -2
  51. package/src/assets/web-panel/assets/{PdhVaultBrowser-D1LVpBOr.js → PdhVaultBrowser-Bcq27Exy.js} +3 -3
  52. package/src/assets/web-panel/assets/{Permissions-Dj5nIi0Y.js → Permissions-CGBvJVXP.js} +3 -3
  53. package/src/assets/web-panel/assets/{PersonalDataHub-uiLYuFcu.js → PersonalDataHub-DM9IEeJ3.js} +6 -6
  54. package/src/assets/web-panel/assets/{Pipeline-CIrmF89x.js → Pipeline-wEskx2-p.js} +1 -1
  55. package/src/assets/web-panel/assets/{Privacy-B3K1CUBa.js → Privacy-DIA02lLJ.js} +1 -1
  56. package/src/assets/web-panel/assets/{ProjectInit-C_wCZ0zW.js → ProjectInit-uMXlNLHV.js} +2 -2
  57. package/src/assets/web-panel/assets/{ProjectSettings-iCitfuNJ.js → ProjectSettings-Sy3NOtt7.js} +2 -2
  58. package/src/assets/web-panel/assets/{Projects-BwimeXkW.js → Projects-xFjfOkQM.js} +1 -1
  59. package/src/assets/web-panel/assets/{Providers-BLpepgr6.js → Providers-CNyOpbg8.js} +1 -1
  60. package/src/assets/web-panel/assets/{QrScannerModal-DPy8tjXV.js → QrScannerModal-Dk1UVRwI.js} +1 -1
  61. package/src/assets/web-panel/assets/{QuickAsk-CRq8VhID.js → QuickAsk-B-UqLoLG.js} +1 -1
  62. package/src/assets/web-panel/assets/{Recommend-CirlWIiK.js → Recommend-CiTBVZvN.js} +1 -1
  63. package/src/assets/web-panel/assets/{RemoteSession-nlnJlKXO.js → RemoteSession-BhABd516.js} +3 -3
  64. package/src/assets/web-panel/assets/{Reputation-B5aaxee5.js → Reputation-Dq1xTCcN.js} +1 -1
  65. package/src/assets/web-panel/assets/{Row-lzsTQZlq.js → Row-DbFc4sc7.js} +1 -1
  66. package/src/assets/web-panel/assets/{RssFeed-C41AzjgC.js → RssFeed-D7IBkkLm.js} +3 -3
  67. package/src/assets/web-panel/assets/{Search-1Hxx3TZ3.js → Search-BVyMlGoa.js} +1 -1
  68. package/src/assets/web-panel/assets/{Security-8VqwRxAp.js → Security-BzMm9Be_.js} +3 -3
  69. package/src/assets/web-panel/assets/{Services-DCm0HHAD.js → Services-Dfk0kied.js} +2 -2
  70. package/src/assets/web-panel/assets/{Skeleton-D6w5gpmW.js → Skeleton-C8B5mUit.js} +1 -1
  71. package/src/assets/web-panel/assets/Skills-BNTPoJn0.js +1 -0
  72. package/src/assets/web-panel/assets/{Sla-BkMj8Dxu.js → Sla-gnFelKfT.js} +1 -1
  73. package/src/assets/web-panel/assets/{SpeechSettings-BOmLqW44.js → SpeechSettings-B12gCQkK.js} +1 -1
  74. package/src/assets/web-panel/assets/{SyncSettings-2xKSqFDO.js → SyncSettings-CHWk9cpj.js} +2 -2
  75. package/src/assets/web-panel/assets/Tasks-B_zs9jkJ.js +1 -0
  76. package/src/assets/web-panel/assets/{Templates-CcOGWgZz.js → Templates-PjdppQrI.js} +1 -1
  77. package/src/assets/web-panel/assets/{Tenant-CAyzzQWN.js → Tenant-CH3aARxE.js} +1 -1
  78. package/src/assets/web-panel/assets/{Terminal-Daagerkm.js → Terminal-BqqrYyg3.js} +2 -2
  79. package/src/assets/web-panel/assets/{TimelineRenderer-1jGbMj7I.js → TimelineRenderer-Dx9soq9f.js} +1 -1
  80. package/src/assets/web-panel/assets/{Tokens-CYQ-FQju.js → Tokens-C66KpDDZ.js} +1 -1
  81. package/src/assets/web-panel/assets/{Trigger-C70zRwuR.js → Trigger-DHxgGsap.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trust-CeqXfiX_.js → Trust-3cct4QxN.js} +1 -1
  83. package/src/assets/web-panel/assets/{UkeySign-D0j6m1bU.js → UkeySign-Cs6UfDEH.js} +1 -1
  84. package/src/assets/web-panel/assets/{VideoEditing-DFHpM7Yd.js → VideoEditing-jbu2EAQa.js} +1 -1
  85. package/src/assets/web-panel/assets/{Wallet-BVC2J0Yx.js → Wallet-BIykFB9D.js} +4 -4
  86. package/src/assets/web-panel/assets/{WebAuthn-BceIN2Gn.js → WebAuthn-DFgqKmHG.js} +4 -4
  87. package/src/assets/web-panel/assets/{WorkflowEditor-Bgeb-i-S.js → WorkflowEditor-CNg5bBiH.js} +1 -1
  88. package/src/assets/web-panel/assets/{chat-BmMBqgvz.js → chat-B-6wzojE.js} +1 -1
  89. package/src/assets/web-panel/assets/{colors-sjRzwnyw.js → colors-BHAq7y9y.js} +1 -1
  90. package/src/assets/web-panel/assets/{compact-item-DNt6m2bM.js → compact-item-CGJnSTnu.js} +1 -1
  91. package/src/assets/web-panel/assets/{createContext-DELaLit3.js → createContext-Cx0ZF512.js} +1 -1
  92. package/src/assets/web-panel/assets/devWarning-CNq_RRw4.js +1 -0
  93. package/src/assets/web-panel/assets/{hasIn-dfwk9O8x.js → hasIn-DibIw3z7.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-Du62m6pG.js → index-0NWSa3Xq.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-LMfsEdLk.js → index-746-mNhw.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-Cbu3-zFd.js → index-Aaeca8o4.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-BXJ68ObJ.js → index-B1jAbIxx.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-B4Z4HZcf.js → index-B28jdxwR.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-Bs-mWOWt.js → index-B2rOMBeN.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-B0wkOqE9.js → index-BAoFiV3n.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-CtfYPnsY.js → index-BCtW4F8-.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-CpVt6M_8.js → index-BEXM3qSV.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-1w7dha_a.js → index-BFTGp-aF.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-C2Sxf7yC.js → index-BJhS6i02.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-B7qiZc2Z.js → index-BO-XxBD5.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-DlYL8VP8.js → index-BR2BiNtx.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-dAF9qRVa.js → index-BRflLAdr.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-BMnvNNEd.js → index-BTWb3sbj.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-BvIQ9sZa.js → index-BZ7GbrgF.js} +1 -1
  110. package/src/assets/web-panel/assets/index-B_H-RZ9i.js +1 -0
  111. package/src/assets/web-panel/assets/{index-DC6FlHlP.js → index-BiMLsIwd.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-6bjBUhoH.js → index-Bmqjip63.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-BSB9Gin8.js → index-BoySHSgZ.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-fNwu9CU_.js → index-Bz3J0eVB.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-dpDeFZNt.js → index-C6M2yHVE.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-WpEDoyTv.js → index-CH2A_Fdn.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-CujMHspH.js → index-CUX9mq3M.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-DFOYbzg-.js → index-CY2E4Y99.js} +1 -1
  119. package/src/assets/web-panel/assets/index-CkH9UQQL.js +1 -0
  120. package/src/assets/web-panel/assets/{index-CAIn5cS9.js → index-CoKu7Q9r.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-OC3qYV4-.js → index-CrjkH_QU.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-BzddvOOb.js → index-Cuity3gy.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-Rt-fym5b.js → index-CwcWpW1q.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-BLbvXsxf.js → index-Cz5ety6j.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-aj9ObF2R.js → index-DXNcm44v.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-vCQ3l6DX.js → index-DjCJIttz.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-Clrqtycd.js → index-DnsVRqCa.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-DnQOc5wo.js → index-SbQacweG.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-CLSqyAcE.js → index-g0V_mLQ9.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-DX3wwu3G.js → index-puIs3mTs.js} +3 -3
  131. package/src/assets/web-panel/assets/{index-DY2G1Th2.js → index-sI_1E0co.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-DV8q_Hki.js → index-woOjtit-.js} +1 -1
  133. package/src/assets/web-panel/assets/{initDefaultProps-DUt9Xj8y.js → initDefaultProps-Gbl9QOqg.js} +1 -1
  134. package/src/assets/web-panel/assets/{motion-DdiFPnv7.js → motion-BHh6ZzZu.js} +1 -1
  135. package/src/assets/web-panel/assets/{move-BJ2VH7Y2.js → move-mX6Rwrd1.js} +1 -1
  136. package/src/assets/web-panel/assets/{mtc-parser-BCnX6oOJ.js → mtc-parser-CuzZa_kI.js} +1 -1
  137. package/src/assets/web-panel/assets/{omit-CyHxlDLV.js → omit-DjANYkpi.js} +1 -1
  138. package/src/assets/web-panel/assets/{pickAttrs-BJiGW3bY.js → pickAttrs-C_3v40wY.js} +1 -1
  139. package/src/assets/web-panel/assets/{placementArrow-DhJ9Rhx8.js → placementArrow-L2HgRede.js} +1 -1
  140. package/src/assets/web-panel/assets/{responsiveObserve-C6zpL9bC.js → responsiveObserve-DIkGMHyT.js} +1 -1
  141. package/src/assets/web-panel/assets/{slide-DCbMFRF-.js → slide-C1hp5tD0.js} +1 -1
  142. package/src/assets/web-panel/assets/{statusUtils-CNtVLsIq.js → statusUtils-DH1W2uFA.js} +1 -1
  143. package/src/assets/web-panel/assets/{styleChecker-Z078OtUc.js → styleChecker-dEaKQcv2.js} +1 -1
  144. package/src/assets/web-panel/assets/{useFlexGapSupport-w4RVmcGs.js → useFlexGapSupport-CD7j1zxt.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFs-D4Xw1XRt.js → useFs-C8jXMvEM.js} +1 -1
  146. package/src/assets/web-panel/assets/{usePersonalDataHub-Bnbd1PgY.js → usePersonalDataHub-C4r-8mys.js} +1 -1
  147. package/src/assets/web-panel/assets/{vnode-DSOQxMFN.js → vnode-DDuI6wgY.js} +1 -1
  148. package/src/assets/web-panel/assets/{zoom-DNPE1Wca.js → zoom-Dd-DteT6.js} +1 -1
  149. package/src/assets/web-panel/index.html +1 -1
  150. package/src/commands/team.js +239 -55
  151. package/src/data/changelog.json +26 -0
  152. package/src/lib/agent-team/task-lease.js +718 -6
  153. package/src/lib/agent-team/team-graph-projection.js +303 -0
  154. package/src/lib/agent-team/team-mailbox.js +508 -2
  155. package/src/lib/agent-team/team-message-bridge.js +722 -0
  156. package/src/lib/agent-team/team-message-tools.js +231 -0
  157. package/src/lib/agent-team/team-runner.js +1186 -10
  158. package/src/lib/agent-team/team-session-message-adapter.js +720 -0
  159. package/src/lib/app-server/cli-agent-kernel-adapter.js +1 -0
  160. package/src/lib/app-server/protocol.js +43 -0
  161. package/src/lib/app-server/server.js +8 -11
  162. package/src/lib/approval-grant-ledger.js +187 -0
  163. package/src/lib/session-message-fabric.js +265 -3
  164. package/src/lib/subagent-inheritance.js +5 -3
  165. package/src/runtime/agent-core.js +38 -0
  166. package/src/runtime/headless-runner.js +36 -3
  167. package/src/runtime/headless-stream.js +184 -19
  168. package/src/assets/web-panel/assets/MarkdownRenderer-Cre8P8HG.js +0 -1
  169. package/src/assets/web-panel/assets/OrderTableRenderer-0NuumnfG.js +0 -1
  170. package/src/assets/web-panel/assets/Skills-Bw_pUTJB.js +0 -1
  171. package/src/assets/web-panel/assets/Tasks-BPAnzVau.js +0 -1
  172. package/src/assets/web-panel/assets/devWarning-Ct8o3bu_.js +0 -1
  173. package/src/assets/web-panel/assets/index-D685kO6L.js +0 -1
  174. package/src/assets/web-panel/assets/index-DTlIRMqX.js +0 -1
@@ -16,10 +16,52 @@
16
16
 
17
17
  import { emitHooksV2Event } from "../hooks-v2-producers.js";
18
18
 
19
+ const HANDOFF_REGISTRY_METHODS = Object.freeze([
20
+ "offerHandoff",
21
+ "findHandoff",
22
+ "acceptHandoff",
23
+ "rejectHandoff",
24
+ "commitHandoff",
25
+ "revokeHandoff",
26
+ "expireHandoffs",
27
+ "pendingCommittedHandoffs",
28
+ "refreshCommittedHandoffLease",
29
+ "markHandoffStarted",
30
+ ]);
31
+
32
+ function supportsCustodyHandoffs(registry) {
33
+ return HANDOFF_REGISTRY_METHODS.every(
34
+ (method) => typeof registry?.[method] === "function",
35
+ );
36
+ }
37
+
19
38
  function leaseFencingToken(lease) {
20
39
  return lease?.fencingToken ?? lease?.leaseId ?? null;
21
40
  }
22
41
 
42
+ const FOLLOWUP_WAKE_PROMPT = [
43
+ "A teammate explicitly requested a follow-up turn for this existing team session.",
44
+ "Process the pending teammate mailbox messages shown below or available through team_receive.",
45
+ "Use team_ack only after durable processing, and keep all message content within the original task authority, budget, and workspace contract.",
46
+ ].join(" ");
47
+
48
+ function taskStatus(task) {
49
+ return String(task?.status || "").toLowerCase();
50
+ }
51
+
52
+ function followupMetadata(task) {
53
+ const value = task?.metadata?.teamFollowup;
54
+ return value && typeof value === "object" ? value : null;
55
+ }
56
+
57
+ function custodyRecipient(task) {
58
+ const history = task?.metadata?.custodyHandoffs;
59
+ if (!Array.isArray(history)) return null;
60
+ return [...history]
61
+ .reverse()
62
+ .find((handoff) => handoff?.status === "committed")?.toHolder;
63
+ }
64
+
23
65
  export class TeamRunner {
24
66
  /**
25
67
  * @param {TaskLeaseRegistry} registry
@@ -33,6 +75,11 @@ export class TeamRunner {
33
75
  */
34
76
  constructor(registry, opts = {}) {
35
77
  this.registry = registry;
78
+ // TeamRunner is also driven through older/durable registry adapters such
79
+ // as TeamDistributedQueue. Custody handoff is an optional registry
80
+ // capability: do not make ordinary scheduling depend on methods an adapter
81
+ // has not implemented; child calls on that adapter fail closed.
82
+ this.handoffEnabled = supportsCustodyHandoffs(registry);
36
83
  this.runTask = opts.runTask;
37
84
  this.teammates = opts.teammates > 0 ? Math.floor(opts.teammates) : 2;
38
85
  this.ttlMs = opts.ttlMs;
@@ -54,6 +101,9 @@ export class TeamRunner {
54
101
  typeof opts.budgetForTask === "function" ? opts.budgetForTask : null;
55
102
  // Directed/broadcast messaging between teammates (null → disabled).
56
103
  this.mailbox = opts.mailbox || null;
104
+ // Real agent children keep messages pending until their explicit ACK tool
105
+ // succeeds. Legacy/dry-run executors retain drain-on-dispatch semantics.
106
+ this.realtimeMessaging = opts.realtimeMessaging === true;
57
107
  // Optional conservative path ownership. Tasks with overlapping scopes are
58
108
  // serialized in a shared workspace; an empty scope means the whole repo.
59
109
  this.scopeLock = opts.scopeLock || null;
@@ -81,6 +131,31 @@ export class TeamRunner {
81
131
  // it. Ordinary renewals keep the same identity and do not call this hook.
82
132
  this.onLeaseChanged =
83
133
  typeof opts.onLeaseChanged === "function" ? opts.onLeaseChanged : null;
134
+ // An explicit follow-up to an idle teammate is materialized as a bounded,
135
+ // high-priority system task. It receives its own task lease/fence but
136
+ // resumes the recipient's prior agent session. The mutation hook persists
137
+ // the new task immediately so a coordinator crash cannot lose the wake.
138
+ this.onFollowupMutation =
139
+ typeof opts.onFollowupMutation === "function"
140
+ ? opts.onFollowupMutation
141
+ : null;
142
+ this.onHandoffMutation =
143
+ typeof opts.onHandoffMutation === "function"
144
+ ? opts.onHandoffMutation
145
+ : null;
146
+ this.graphRevisionDigest = opts.graphRevisionDigest || null;
147
+ this.graphAuthorityDigest = opts.graphAuthorityDigest || null;
148
+ this.maxFollowupWakes =
149
+ Number.isSafeInteger(opts.maxFollowupWakes) && opts.maxFollowupWakes > 0
150
+ ? opts.maxFollowupWakes
151
+ : 3;
152
+ this.maxFollowupTasks =
153
+ Number.isSafeInteger(opts.maxFollowupTasks) && opts.maxFollowupTasks > 0
154
+ ? opts.maxFollowupTasks
155
+ : Math.min(this.maxTasks, 1000);
156
+ this._followupTasks = this.registry
157
+ .list()
158
+ .filter((task) => followupMetadata(task)).length;
84
159
  this._executions = 0;
85
160
  this._reservedExecutions = 0;
86
161
  this._inFlight = 0;
@@ -90,6 +165,7 @@ export class TeamRunner {
90
165
  // same process must not steal and double-run that still-active task.
91
166
  this._activeKeys = new Set();
92
167
  this._members = new Map(); // holder → lifecycle record
168
+ this._residentHolders = new Set();
93
169
  this._budgetStopped = false;
94
170
  this._registryBudgetReason = null;
95
171
  this._fatalError = null;
@@ -100,6 +176,8 @@ export class TeamRunner {
100
176
  this._workerCount = 1;
101
177
  this._claimControllers = new Set();
102
178
  this._claimsByKey = new Map();
179
+ this._handoffTransfers = new Map();
180
+ this._handoffExpiryTimers = new Map();
103
181
  this._wallTimer = null;
104
182
  }
105
183
 
@@ -124,16 +202,32 @@ export class TeamRunner {
124
202
  }
125
203
  if (state === "completed-task") {
126
204
  m.completed += 1;
205
+ if (extra.key) m.lastTaskKey = extra.key;
206
+ if (extra.sessionTaskKey) m.sessionTaskKey = extra.sessionTaskKey;
207
+ m.currentTaskKey = null;
127
208
  return m;
128
209
  }
129
210
  if (state === "failed-task") {
130
211
  m.failed += 1;
131
212
  m.lastError = extra.error || null;
213
+ if (extra.key) m.lastTaskKey = extra.key;
214
+ if (extra.sessionTaskKey) m.sessionTaskKey = extra.sessionTaskKey;
215
+ m.currentTaskKey = null;
132
216
  return m;
133
217
  }
134
218
  if (m.state === state) return m; // no transition
135
219
  const previousState = m.state;
220
+ // Durable message adapters bind online/offline admission to the same member
221
+ // lifecycle used by the scheduler. A running target releases held sequence;
222
+ // idle/shutdown targets retain it without reporting false delivery.
223
+ this.mailbox?.setRecipientState?.(holder, state);
136
224
  m.state = state;
225
+ if (state === "running") {
226
+ m.currentTaskKey = extra.key || null;
227
+ if (extra.sessionTaskKey) m.sessionTaskKey = extra.sessionTaskKey;
228
+ } else if (state === "idle" || state === "shutdown") {
229
+ m.currentTaskKey = null;
230
+ }
137
231
  this._emit("teammate:state", { holder, state, ...extra });
138
232
  // Initial registration starts in `idle`; the hook is reserved for a real
139
233
  // transition back to idle after a teammate has participated in the run.
@@ -167,6 +261,806 @@ export class TeamRunner {
167
261
  }
168
262
  }
169
263
 
264
+ _sessionTaskKey(key, task = this.registry.getTask(key)) {
265
+ return followupMetadata(task)?.sessionTaskKey || key;
266
+ }
267
+
268
+ _followupTasksFor(recipient, messageId) {
269
+ return this.registry
270
+ .list()
271
+ .filter((task) => {
272
+ const metadata = followupMetadata(task);
273
+ return (
274
+ metadata?.recipient === recipient && metadata.messageId === messageId
275
+ );
276
+ })
277
+ .sort(
278
+ (left, right) =>
279
+ (followupMetadata(left)?.wakeAttempt || 0) -
280
+ (followupMetadata(right)?.wakeAttempt || 0),
281
+ );
282
+ }
283
+
284
+ _terminalMessageReceipt(recipient, messageId) {
285
+ const receipt = this.mailbox
286
+ ?.snapshot?.()
287
+ ?.receipts?.map((entry) => entry?.[1])
288
+ ?.find(
289
+ (candidate) =>
290
+ candidate?.recipient === recipient &&
291
+ candidate?.messageId === messageId,
292
+ );
293
+ return ["processed", "dead_letter"].includes(receipt?.status)
294
+ ? receipt
295
+ : null;
296
+ }
297
+
298
+ _persistFollowupMutation(event) {
299
+ if (!this.onFollowupMutation) return;
300
+ try {
301
+ this.onFollowupMutation(event);
302
+ } catch (error) {
303
+ const failure =
304
+ error instanceof Error
305
+ ? error
306
+ : new Error(String(error || "follow-up persistence failed"));
307
+ failure.code ||= "TEAM_FOLLOWUP_PERSIST_FAILED";
308
+ this.abortRun(failure, { requireAdjudication: true });
309
+ throw failure;
310
+ }
311
+ }
312
+
313
+ _deadLetterExhaustedFollowup(recipient, message) {
314
+ const receipts = this.mailbox.acknowledge(recipient, {
315
+ messageIds: [message.id],
316
+ consumerKey: `team-followup-wake:${message.id}`,
317
+ status: "dead_letter",
318
+ reason: "followup_wake_attempts_exhausted",
319
+ recipientAttempt: {
320
+ holder: recipient,
321
+ taskKey: null,
322
+ attempt: this.maxFollowupWakes,
323
+ leaseId: null,
324
+ fencingToken: null,
325
+ },
326
+ });
327
+ this._emit("followup:dead-lettered", {
328
+ holder: recipient,
329
+ messageId: message.id,
330
+ reason: "followup_wake_attempts_exhausted",
331
+ });
332
+ this._persistFollowupMutation({
333
+ type: "followup:dead-lettered",
334
+ holder: recipient,
335
+ messageId: message.id,
336
+ });
337
+ return {
338
+ recipient,
339
+ wake: "dead_lettered",
340
+ messageId: message.id,
341
+ receipt: receipts[0],
342
+ };
343
+ }
344
+
345
+ _requestRecipientFollowupWake(recipient, message) {
346
+ const terminal = this._terminalMessageReceipt(recipient, message.id);
347
+ if (terminal) {
348
+ return {
349
+ recipient,
350
+ wake:
351
+ terminal.status === "processed"
352
+ ? "already_processed"
353
+ : "dead_lettered",
354
+ messageId: message.id,
355
+ };
356
+ }
357
+ const member = this._members.get(recipient);
358
+ if (member?.state === "running") {
359
+ return { recipient, wake: "target_active", messageId: message.id };
360
+ }
361
+ if (!this._residentHolders.has(recipient)) {
362
+ return {
363
+ recipient,
364
+ wake: "queued_until_target_turn",
365
+ messageId: message.id,
366
+ };
367
+ }
368
+ const sessionTaskKey = member?.sessionTaskKey || null;
369
+ const sourceTask = sessionTaskKey
370
+ ? this.registry.getTask(sessionTaskKey)
371
+ : null;
372
+ if (!sourceTask || taskStatus(sourceTask) !== "completed") {
373
+ return {
374
+ recipient,
375
+ wake: "queued_until_target_turn",
376
+ messageId: message.id,
377
+ };
378
+ }
379
+ const existing = this._followupTasksFor(recipient, message.id);
380
+ const unsettled = existing.find((task) =>
381
+ ["pending", "in_progress"].includes(taskStatus(task)),
382
+ );
383
+ if (unsettled) {
384
+ return {
385
+ recipient,
386
+ wake: "turn_scheduled",
387
+ messageId: message.id,
388
+ taskKey: unsettled.key,
389
+ idempotent: true,
390
+ };
391
+ }
392
+ if (existing.length >= this.maxFollowupWakes) {
393
+ return this._deadLetterExhaustedFollowup(recipient, message);
394
+ }
395
+ if (this._followupTasks >= this.maxFollowupTasks) {
396
+ const error = new Error("Team follow-up wake task capacity exceeded");
397
+ error.code = "TEAM_FOLLOWUP_WAKE_CAPACITY";
398
+ throw error;
399
+ }
400
+ const completedWake = [...existing]
401
+ .reverse()
402
+ .find((task) => taskStatus(task) === "completed");
403
+ const dependencyKey = completedWake?.key || sessionTaskKey;
404
+ const wakeAttempt = existing.length + 1;
405
+ const key = `team_followup:${message.id}:${recipient}:${wakeAttempt}`.slice(
406
+ 0,
407
+ 256,
408
+ );
409
+ const sourceMetadata = sourceTask.metadata || {};
410
+ const added = this.registry.addTask({
411
+ key,
412
+ title: `Follow up message ${message.id} for ${recipient}`,
413
+ dependsOn: [dependencyKey],
414
+ priority: "high",
415
+ createdBy: message.from || "coordinator",
416
+ metadata: {
417
+ prompt: FOLLOWUP_WAKE_PROMPT,
418
+ ...(Array.isArray(sourceMetadata.scopePaths)
419
+ ? { scopePaths: [...sourceMetadata.scopePaths] }
420
+ : {}),
421
+ ...(sourceMetadata.agent ? { agent: sourceMetadata.agent } : {}),
422
+ ...(sourceMetadata.policy ? { policy: sourceMetadata.policy } : {}),
423
+ retrySafe: sourceMetadata.retrySafe === true,
424
+ teamFollowup: {
425
+ messageId: message.id,
426
+ recipient,
427
+ sender: message.from || null,
428
+ sessionTaskKey,
429
+ dependencyTaskKey: dependencyKey,
430
+ wakeAttempt,
431
+ },
432
+ },
433
+ });
434
+ if (!added.ok) {
435
+ const duplicate = this.registry.getTask(key);
436
+ if (duplicate && followupMetadata(duplicate)?.messageId === message.id) {
437
+ return {
438
+ recipient,
439
+ wake: "turn_scheduled",
440
+ messageId: message.id,
441
+ taskKey: key,
442
+ idempotent: true,
443
+ };
444
+ }
445
+ const error = new Error(
446
+ `Could not schedule follow-up wake for ${recipient}: ${added.reason}`,
447
+ );
448
+ error.code = "TEAM_FOLLOWUP_WAKE_REJECTED";
449
+ throw error;
450
+ }
451
+ this._followupTasks += 1;
452
+ this._emit("followup:wake-scheduled", {
453
+ holder: recipient,
454
+ messageId: message.id,
455
+ key,
456
+ sessionTaskKey,
457
+ wakeAttempt,
458
+ });
459
+ this._persistFollowupMutation({
460
+ type: "followup:wake-scheduled",
461
+ holder: recipient,
462
+ messageId: message.id,
463
+ key,
464
+ sessionTaskKey,
465
+ wakeAttempt,
466
+ });
467
+ this._signalProgress();
468
+ return {
469
+ recipient,
470
+ wake: "turn_scheduled",
471
+ messageId: message.id,
472
+ taskKey: key,
473
+ wakeAttempt,
474
+ };
475
+ }
476
+
477
+ /**
478
+ * Materialize an explicit follow-up as a real lease-bound turn when the
479
+ * target has an idle resumable session. Active targets keep using their live
480
+ * bridge; recipients without a prior session retain the durable message for
481
+ * their first ordinary turn.
482
+ */
483
+ requestFollowupWake({ to, message, senderAttempt = null } = {}) {
484
+ if (
485
+ !this.realtimeMessaging ||
486
+ !this.mailbox ||
487
+ message?.mode !== "followup"
488
+ ) {
489
+ return { wake: "queued_until_target_turn", recipients: [] };
490
+ }
491
+ const sender = senderAttempt?.holder || message?.from || null;
492
+ if (to === "coordinator") {
493
+ return { wake: "queued_for_coordinator", recipients: [] };
494
+ }
495
+ const recipients =
496
+ to === "*"
497
+ ? (this.mailbox.status?.().recipients || []).filter(
498
+ (recipient) => recipient !== sender,
499
+ )
500
+ : [to];
501
+ const results = recipients.map((recipient) =>
502
+ this._requestRecipientFollowupWake(recipient, message),
503
+ );
504
+ const wakes = results.map((result) => result.wake);
505
+ const wake = wakes.includes("turn_scheduled")
506
+ ? "turn_scheduled"
507
+ : wakes.includes("target_active")
508
+ ? "target_active"
509
+ : wakes.includes("queued_until_target_turn")
510
+ ? "queued_until_target_turn"
511
+ : wakes.includes("dead_lettered")
512
+ ? "dead_lettered"
513
+ : wakes[0] || "queued_until_target_turn";
514
+ return { wake, recipients: results };
515
+ }
516
+
517
+ _reconcileFollowupWakes(recipient = null) {
518
+ if (!this.realtimeMessaging || !this.mailbox) return 0;
519
+ const recipients = recipient ? [recipient] : [...this._residentHolders];
520
+ let scheduled = 0;
521
+ for (const holder of recipients) {
522
+ for (const message of this.mailbox.peek(holder)) {
523
+ if (message.mode !== "followup") continue;
524
+ const outcome = this._requestRecipientFollowupWake(holder, message);
525
+ if (outcome.wake === "turn_scheduled" && !outcome.idempotent) {
526
+ scheduled += 1;
527
+ }
528
+ }
529
+ }
530
+ return scheduled;
531
+ }
532
+
533
+ _persistHandoffMutation(event) {
534
+ if (!this.onHandoffMutation) return;
535
+ try {
536
+ const result = this.onHandoffMutation(event);
537
+ if (result && typeof result.then === "function") {
538
+ Promise.resolve(result).catch(() => {});
539
+ throw new Error("onHandoffMutation must be synchronous");
540
+ }
541
+ } catch (error) {
542
+ const failure =
543
+ error instanceof Error
544
+ ? error
545
+ : new Error(String(error || "handoff persistence failed"));
546
+ failure.code ||= "TEAM_HANDOFF_PERSIST_FAILED";
547
+ this.abortRun(failure, { requireAdjudication: true });
548
+ throw failure;
549
+ }
550
+ }
551
+
552
+ _handoffMessage(to, handoff, kind, senderAttempt, reason = null) {
553
+ if (!this.mailbox) return null;
554
+ const message = this.mailbox.send({
555
+ from: senderAttempt?.holder || "coordinator",
556
+ to,
557
+ subject: `Task custody handoff ${kind}`,
558
+ body: {
559
+ kind: `team_handoff_${kind}`,
560
+ handoffId: handoff.id,
561
+ taskKey: handoff.taskKey,
562
+ from: handoff.fromHolder,
563
+ to: handoff.toHolder,
564
+ status: handoff.status,
565
+ ...(reason ? { reason } : {}),
566
+ },
567
+ mode: kind === "offered" ? "followup" : "send",
568
+ idempotencyKey: `team-handoff:${handoff.id}:${kind}`,
569
+ causationId: handoff.id,
570
+ correlationId: handoff.id,
571
+ senderAttempt,
572
+ });
573
+ this._persistHandoffMutation({
574
+ type: "handoff:message",
575
+ handoffId: handoff.id,
576
+ kind,
577
+ messageId: message.id,
578
+ to,
579
+ });
580
+ if (kind === "offered") {
581
+ this.requestFollowupWake({
582
+ to,
583
+ message,
584
+ senderAttempt,
585
+ });
586
+ }
587
+ return message;
588
+ }
589
+
590
+ _settleHandoffOfferMessage(
591
+ recipient,
592
+ handoffId,
593
+ authority,
594
+ { status = "processed", reason = null } = {},
595
+ ) {
596
+ if (!this.mailbox) return null;
597
+ const ids = this.mailbox
598
+ .log()
599
+ .filter(
600
+ (message) =>
601
+ (message.to === recipient || message.to === "*") &&
602
+ message.body?.kind === "team_handoff_offered" &&
603
+ message.body?.handoffId === handoffId,
604
+ )
605
+ .map((message) => message.id);
606
+ if (ids.length === 0) return null;
607
+ try {
608
+ return this.mailbox.acknowledge(recipient, {
609
+ messageIds: ids,
610
+ consumerKey: `team-handoff-${status}:${handoffId}`,
611
+ status,
612
+ ...(reason ? { reason } : {}),
613
+ recipientAttempt: authority,
614
+ });
615
+ } catch (error) {
616
+ // A prior explicit ACK may have used another consumer key. Its terminal
617
+ // receipt already proves the offer notification cannot spawn an idle
618
+ // wake, so only propagate non-terminal/structural mailbox failures.
619
+ const terminal = ids.every((messageId) =>
620
+ (this.mailbox.snapshot().receipts || []).some(
621
+ ([, receipt]) =>
622
+ receipt.recipient === recipient &&
623
+ receipt.messageId === messageId &&
624
+ ["processed", "dead_letter"].includes(receipt.status),
625
+ ),
626
+ );
627
+ if (terminal) return null;
628
+ throw error;
629
+ }
630
+ }
631
+
632
+ _clearHandoffExpiry(handoffId) {
633
+ const timer = this._handoffExpiryTimers.get(handoffId);
634
+ if (timer) clearTimeout(timer);
635
+ this._handoffExpiryTimers.delete(handoffId);
636
+ }
637
+
638
+ _scheduleHandoffExpiry(handoff) {
639
+ if (
640
+ !handoff ||
641
+ !["offered", "accepted"].includes(handoff.status) ||
642
+ this._handoffExpiryTimers.has(handoff.id)
643
+ ) {
644
+ return;
645
+ }
646
+ const milliseconds = Math.max(0, Number(handoff.expiresAtMs) - this._now());
647
+ const timer = setTimeout(
648
+ () => {
649
+ this._handoffExpiryTimers.delete(handoff.id);
650
+ try {
651
+ this._expireHandoffs();
652
+ } catch (error) {
653
+ this.abortRun(error, { requireAdjudication: true });
654
+ }
655
+ },
656
+ Math.min(milliseconds, 2_147_483_647),
657
+ );
658
+ timer.unref?.();
659
+ this._handoffExpiryTimers.set(handoff.id, timer);
660
+ }
661
+
662
+ _expireHandoffs() {
663
+ if (!this.handoffEnabled) return [];
664
+ const result = this._assertHandoffResult(
665
+ this.registry.expireHandoffs(),
666
+ "expire",
667
+ );
668
+ for (const handoff of result.expired) {
669
+ this._clearHandoffExpiry(handoff.id);
670
+ const coordinatorAttempt = {
671
+ holder: "coordinator",
672
+ taskKey: handoff.taskKey,
673
+ leaseId: null,
674
+ fencingToken: null,
675
+ };
676
+ this._settleHandoffOfferMessage(
677
+ handoff.toHolder,
678
+ handoff.id,
679
+ coordinatorAttempt,
680
+ { status: "dead_letter", reason: "handoff_expired" },
681
+ );
682
+ this._persistHandoffMutation({
683
+ type: "handoff:expired",
684
+ key: handoff.taskKey,
685
+ handoffId: handoff.id,
686
+ });
687
+ this._handoffMessage(
688
+ handoff.fromHolder,
689
+ handoff,
690
+ "expired",
691
+ coordinatorAttempt,
692
+ "handoff_expired",
693
+ );
694
+ }
695
+ for (const task of this.registry.list()) {
696
+ for (const handoff of task.metadata?.custodyHandoffs || []) {
697
+ this._scheduleHandoffExpiry(handoff);
698
+ }
699
+ }
700
+ return result.expired;
701
+ }
702
+
703
+ _handoffFailure(code, message, details = {}) {
704
+ const error = new Error(message);
705
+ error.code = code;
706
+ Object.assign(error, details);
707
+ return error;
708
+ }
709
+
710
+ _assertHandoffResult(result, operation) {
711
+ if (result?.ok) return result;
712
+ throw this._handoffFailure(
713
+ `TEAM_HANDOFF_${String(operation).toUpperCase()}_REJECTED`,
714
+ `Team handoff ${operation} rejected: ${result?.reason || "unknown"}`,
715
+ { reason: result?.reason || "unknown" },
716
+ );
717
+ }
718
+
719
+ _queueHandoffTransfer(transfer) {
720
+ const queue = this._handoffTransfers.get(transfer.holder) || [];
721
+ if (
722
+ queue.some(
723
+ (candidate) =>
724
+ candidate.key === transfer.key &&
725
+ candidate.handoffId === transfer.handoffId,
726
+ )
727
+ ) {
728
+ return false;
729
+ }
730
+ queue.push(transfer);
731
+ this._handoffTransfers.set(transfer.holder, queue);
732
+ this._reservedExecutions += 1;
733
+ this._emit("handoff:transfer-queued", {
734
+ key: transfer.key,
735
+ handoffId: transfer.handoffId,
736
+ holder: transfer.holder,
737
+ recovered: transfer.recovered === true,
738
+ });
739
+ this._signalProgress();
740
+ return true;
741
+ }
742
+
743
+ _reserveHandoffTransfer(key, handoff, lease, { recovered = false } = {}) {
744
+ const holder = handoff.toHolder;
745
+ if (!this._residentHolders.has(holder)) {
746
+ throw this._handoffFailure(
747
+ "TEAM_HANDOFF_TARGET_NOT_RESIDENT",
748
+ `Handoff target is not resident in this run: ${holder}`,
749
+ );
750
+ }
751
+ if (this._executions + this._reservedExecutions >= this.maxTasks) {
752
+ throw this._handoffFailure(
753
+ "TEAM_HANDOFF_TASK_BUDGET_EXHAUSTED",
754
+ "Task execution budget cannot admit the handoff target attempt",
755
+ );
756
+ }
757
+ const task = this.registry.getTask(key);
758
+ const budgetReservation = this._reserveTaskBudget(key, task, lease);
759
+ if (!budgetReservation.ok) {
760
+ throw this._handoffFailure(
761
+ "TEAM_HANDOFF_RESOURCE_BUDGET_REJECTED",
762
+ `Resource budget cannot admit handoff: ${budgetReservation.reason}`,
763
+ { reason: budgetReservation.reason },
764
+ );
765
+ }
766
+ let sessionWork;
767
+ try {
768
+ sessionWork = this._reserveSessionWork(key, lease);
769
+ } catch (error) {
770
+ if (budgetReservation.id) {
771
+ this.budget?.releaseReservation?.(budgetReservation.id);
772
+ }
773
+ throw error;
774
+ }
775
+ if (!sessionWork.ok) {
776
+ if (budgetReservation.id) {
777
+ this.budget?.releaseReservation?.(budgetReservation.id);
778
+ }
779
+ throw this._handoffFailure(
780
+ "TEAM_HANDOFF_SESSION_BUDGET_REJECTED",
781
+ `Session budget cannot admit handoff: ${sessionWork.reason}`,
782
+ { reason: sessionWork.reason },
783
+ );
784
+ }
785
+ return {
786
+ key,
787
+ handoffId: handoff.id,
788
+ holder,
789
+ lease,
790
+ budgetReservation,
791
+ sessionWork,
792
+ recovered,
793
+ };
794
+ }
795
+
796
+ /** Lease-bound state machine entry point used by the team_handoff tool. */
797
+ requestHandoff({ action, handoffId, authority, ...args } = {}) {
798
+ if (!this.handoffEnabled) {
799
+ throw this._handoffFailure(
800
+ "TEAM_HANDOFF_UNAVAILABLE",
801
+ "Task custody handoff is unavailable for this registry adapter",
802
+ );
803
+ }
804
+ const holder = authority?.holder;
805
+ if (!holder || !authority?.taskKey || !authority?.leaseId) {
806
+ throw this._handoffFailure(
807
+ "TEAM_HANDOFF_STALE_ATTEMPT",
808
+ "Handoff requires an active lease-bound task attempt",
809
+ );
810
+ }
811
+ const active = this._claimsByKey.get(authority.taskKey);
812
+ if (
813
+ active?.holder !== holder ||
814
+ active?.leaseId !== authority.leaseId ||
815
+ active?.fencingToken !== authority.fencingToken
816
+ ) {
817
+ throw this._handoffFailure(
818
+ "TEAM_HANDOFF_STALE_ATTEMPT",
819
+ "Handoff authority is no longer active",
820
+ );
821
+ }
822
+ if (action === "offer") {
823
+ if (!this.graphRevisionDigest || !this.graphAuthorityDigest) {
824
+ throw this._handoffFailure(
825
+ "TEAM_HANDOFF_BINDING_UNAVAILABLE",
826
+ "Graph revision and authority bindings are unavailable",
827
+ );
828
+ }
829
+ if (
830
+ !args.to ||
831
+ args.to === "*" ||
832
+ args.to === "coordinator" ||
833
+ !this._residentHolders.has(args.to)
834
+ ) {
835
+ throw this._handoffFailure(
836
+ "TEAM_HANDOFF_INVALID_RECIPIENT",
837
+ "Handoff requires one resident teammate recipient",
838
+ );
839
+ }
840
+ const offered = this._assertHandoffResult(
841
+ this.registry.offerHandoff(authority.taskKey, {
842
+ handoffId,
843
+ holder,
844
+ leaseId: authority.leaseId,
845
+ toHolder: args.to,
846
+ revisionDigest: this.graphRevisionDigest,
847
+ authorityDigest: this.graphAuthorityDigest,
848
+ artifactIds: args.artifactIds || [],
849
+ preconditions: args.preconditions || null,
850
+ summary: args.summary || null,
851
+ ttlMs: args.ttlMs,
852
+ idempotencyKey: handoffId,
853
+ }),
854
+ "offer",
855
+ );
856
+ this._persistHandoffMutation({
857
+ type: "handoff:offered",
858
+ key: authority.taskKey,
859
+ handoffId,
860
+ });
861
+ this._handoffMessage(args.to, offered.handoff, "offered", authority);
862
+ this._scheduleHandoffExpiry(offered.handoff);
863
+ return offered;
864
+ }
865
+
866
+ const found = this.registry.findHandoff(handoffId);
867
+ if (!found) {
868
+ throw this._handoffFailure(
869
+ "TEAM_HANDOFF_NOT_FOUND",
870
+ `Handoff not found: ${handoffId}`,
871
+ );
872
+ }
873
+ const handoff = found.handoff;
874
+ if (![handoff.fromHolder, handoff.toHolder].includes(holder)) {
875
+ throw this._handoffFailure(
876
+ "TEAM_HANDOFF_FORBIDDEN",
877
+ "Only the source or designated recipient can inspect this handoff",
878
+ );
879
+ }
880
+ if (action === "status") {
881
+ this._persistHandoffMutation({
882
+ type: "handoff:status",
883
+ key: found.key,
884
+ handoffId,
885
+ });
886
+ return { ok: true, key: found.key, handoff };
887
+ }
888
+ if (action === "accept") {
889
+ const accepted = this._assertHandoffResult(
890
+ this.registry.acceptHandoff(handoffId, {
891
+ holder,
892
+ recipientAttempt: authority,
893
+ }),
894
+ "accept",
895
+ );
896
+ this._settleHandoffOfferMessage(holder, handoffId, authority);
897
+ this._persistHandoffMutation({
898
+ type: "handoff:accepted",
899
+ key: found.key,
900
+ handoffId,
901
+ });
902
+ this._handoffMessage(
903
+ handoff.fromHolder,
904
+ accepted.handoff,
905
+ "accepted",
906
+ authority,
907
+ );
908
+ return accepted;
909
+ }
910
+ if (action === "reject") {
911
+ const rejected = this._assertHandoffResult(
912
+ this.registry.rejectHandoff(handoffId, {
913
+ holder,
914
+ reason: args.reason,
915
+ }),
916
+ "reject",
917
+ );
918
+ this._clearHandoffExpiry(handoffId);
919
+ this._settleHandoffOfferMessage(holder, handoffId, authority);
920
+ this._persistHandoffMutation({
921
+ type: "handoff:rejected",
922
+ key: found.key,
923
+ handoffId,
924
+ });
925
+ this._handoffMessage(
926
+ handoff.fromHolder,
927
+ rejected.handoff,
928
+ "rejected",
929
+ authority,
930
+ args.reason,
931
+ );
932
+ return rejected;
933
+ }
934
+ if (action === "revoke") {
935
+ const revoked = this._assertHandoffResult(
936
+ this.registry.revokeHandoff(handoffId, {
937
+ holder,
938
+ leaseId: authority.leaseId,
939
+ reason: args.reason,
940
+ }),
941
+ "revoke",
942
+ );
943
+ this._clearHandoffExpiry(handoffId);
944
+ this._persistHandoffMutation({
945
+ type: "handoff:revoked",
946
+ key: found.key,
947
+ handoffId,
948
+ });
949
+ this._handoffMessage(
950
+ handoff.toHolder,
951
+ revoked.handoff,
952
+ "revoked",
953
+ authority,
954
+ args.reason,
955
+ );
956
+ return revoked;
957
+ }
958
+ if (action !== "commit") {
959
+ throw this._handoffFailure(
960
+ "TEAM_HANDOFF_INVALID_ACTION",
961
+ `Unknown handoff action: ${String(action || "")}`,
962
+ );
963
+ }
964
+ if (
965
+ found.key !== authority.taskKey ||
966
+ handoff.fromHolder !== holder ||
967
+ handoff.fromLeaseId !== authority.leaseId
968
+ ) {
969
+ throw this._handoffFailure(
970
+ "TEAM_HANDOFF_SOURCE_MISMATCH",
971
+ "Only the exact source task attempt can commit this handoff",
972
+ );
973
+ }
974
+ const syntheticLease = {
975
+ leaseId: `handoff-target:${handoff.id}`,
976
+ holder: handoff.toHolder,
977
+ };
978
+ const reserved = this._reserveHandoffTransfer(
979
+ found.key,
980
+ handoff,
981
+ syntheticLease,
982
+ );
983
+ let committed;
984
+ try {
985
+ committed = this._assertHandoffResult(
986
+ this.registry.commitHandoff(handoffId, {
987
+ holder,
988
+ leaseId: authority.leaseId,
989
+ ttlMs: this.ttlMs,
990
+ }),
991
+ "commit",
992
+ );
993
+ } catch (error) {
994
+ if (reserved.budgetReservation?.id) {
995
+ this.budget?.releaseReservation?.(reserved.budgetReservation.id);
996
+ }
997
+ reserved.sessionWork?.release?.();
998
+ throw error;
999
+ }
1000
+ reserved.lease = committed.lease;
1001
+ this._clearHandoffExpiry(handoffId);
1002
+ if (!this._queueHandoffTransfer(reserved)) {
1003
+ if (reserved.budgetReservation?.id) {
1004
+ this.budget?.releaseReservation?.(reserved.budgetReservation.id);
1005
+ }
1006
+ reserved.sessionWork?.release?.();
1007
+ }
1008
+ this._persistHandoffMutation({
1009
+ type: "handoff:committed",
1010
+ key: found.key,
1011
+ handoffId,
1012
+ holder: handoff.toHolder,
1013
+ leaseId: committed.lease.leaseId,
1014
+ });
1015
+ const transferred = this._handoffFailure(
1016
+ "TEAM_HANDOFF_COMMITTED",
1017
+ `Task custody transferred to ${handoff.toHolder}`,
1018
+ );
1019
+ transferred.retryable = false;
1020
+ active.handoffCommitted = { handoffId, error: transferred };
1021
+ try {
1022
+ active.abortController.abort(transferred);
1023
+ } catch {
1024
+ /* the replacement lease already fences every source settlement */
1025
+ }
1026
+ this._signalProgress();
1027
+ return committed;
1028
+ }
1029
+
1030
+ _reconcileCommittedHandoffs() {
1031
+ if (!this.handoffEnabled) return [];
1032
+ for (const pending of this.registry.pendingCommittedHandoffs()) {
1033
+ if (!this._residentHolders.has(pending.handoff.toHolder)) {
1034
+ throw this._handoffFailure(
1035
+ "TEAM_HANDOFF_TARGET_NOT_RESIDENT",
1036
+ `Committed handoff target is not resident: ${pending.handoff.toHolder}`,
1037
+ );
1038
+ }
1039
+ const refreshed = this._assertHandoffResult(
1040
+ this.registry.refreshCommittedHandoffLease(pending.key, {
1041
+ handoffId: pending.handoff.id,
1042
+ holder: pending.handoff.toHolder,
1043
+ ttlMs: this.ttlMs,
1044
+ }),
1045
+ "recover",
1046
+ );
1047
+ const transfer = this._reserveHandoffTransfer(
1048
+ pending.key,
1049
+ refreshed.handoff,
1050
+ refreshed.lease,
1051
+ { recovered: true },
1052
+ );
1053
+ this._queueHandoffTransfer(transfer);
1054
+ this._persistHandoffMutation({
1055
+ type: "handoff:recovered",
1056
+ key: pending.key,
1057
+ handoffId: pending.handoff.id,
1058
+ holder: pending.handoff.toHolder,
1059
+ leaseId: refreshed.lease.leaseId,
1060
+ });
1061
+ }
1062
+ }
1063
+
170
1064
  /** Snapshot of local in-flight claims for an IDE/operator control surface. */
171
1065
  activeClaims() {
172
1066
  return Array.from(this._claimsByKey.values()).map((claim) => ({
@@ -344,20 +1238,48 @@ export class TeamRunner {
344
1238
  }
345
1239
  this.sessionBudget?.start?.();
346
1240
  const workers = [];
1241
+ const requiredHandoffWorker = (
1242
+ this.handoffEnabled ? this.registry.pendingCommittedHandoffs() : []
1243
+ ).reduce((maximum, pending) => {
1244
+ const match = /^teammate-(\d+)$/u.exec(pending.handoff.toHolder);
1245
+ return match ? Math.max(maximum, Number(match[1])) : maximum;
1246
+ }, 0);
1247
+ if (requiredHandoffWorker > this.teammates) {
1248
+ const error = new Error(
1249
+ `Committed handoff target requires ${requiredHandoffWorker} teammates, but this run allows ${this.teammates}`,
1250
+ );
1251
+ error.code = "TEAM_HANDOFF_TARGET_NOT_RESIDENT";
1252
+ throw error;
1253
+ }
347
1254
  const workerCount = Math.min(
348
1255
  this.teammates,
349
- Math.max(1, this.registry.list().length),
1256
+ Math.max(1, this.registry.list().length, requiredHandoffWorker),
350
1257
  );
351
1258
  this._workerCount = workerCount;
352
1259
  const holders = Array.from(
353
1260
  { length: workerCount },
354
1261
  (_, index) => `teammate-${index + 1}`,
355
1262
  );
1263
+ this._residentHolders = new Set(holders);
356
1264
  this.mailbox?.registerRecipients?.(holders);
357
1265
  for (const holder of holders) {
358
1266
  this._setState(holder, "idle");
359
- workers.push(this._worker(holder));
360
1267
  }
1268
+ try {
1269
+ this._expireHandoffs();
1270
+ this._reconcileCommittedHandoffs();
1271
+ } catch (error) {
1272
+ if (this._wallTimer) {
1273
+ clearTimeout(this._wallTimer);
1274
+ this._wallTimer = null;
1275
+ }
1276
+ throw error;
1277
+ }
1278
+ // A prior coordinator may have committed a follow-up message immediately
1279
+ // before crashing, before it could materialize the wake task. Reconcile
1280
+ // those durable messages before workers decide the graph has no more work.
1281
+ this._reconcileFollowupWakes();
1282
+ for (const holder of holders) workers.push(this._worker(holder));
361
1283
  try {
362
1284
  await Promise.all(workers);
363
1285
  } finally {
@@ -365,6 +1287,10 @@ export class TeamRunner {
365
1287
  clearTimeout(this._wallTimer);
366
1288
  this._wallTimer = null;
367
1289
  }
1290
+ for (const timer of this._handoffExpiryTimers.values()) {
1291
+ clearTimeout(timer);
1292
+ }
1293
+ this._handoffExpiryTimers.clear();
368
1294
  }
369
1295
  if (this._fatalError) throw this._fatalError;
370
1296
  const stats = this.registry.stats();
@@ -392,6 +1318,137 @@ export class TeamRunner {
392
1318
  return summary;
393
1319
  }
394
1320
 
1321
+ _takeHandoffTransfer(holder) {
1322
+ const queue = this._handoffTransfers.get(holder) || [];
1323
+ for (let index = 0; index < queue.length; index++) {
1324
+ const transfer = queue[index];
1325
+ if (this._activeKeys.has(transfer.key)) continue;
1326
+ if (this.scopeLock) {
1327
+ const task = this.registry.getTask(transfer.key);
1328
+ const check = this.scopeLock.canAcquire(
1329
+ transfer.key,
1330
+ this.scopeForTask(task, transfer.key),
1331
+ );
1332
+ if (!check.ok) {
1333
+ if (check.code === "TEAM_SCOPE_LOCK_SCOPE_CONFLICT") continue;
1334
+ const error = new Error(
1335
+ check.message ||
1336
+ `invalid task scope ownership for "${transfer.key}"`,
1337
+ );
1338
+ error.code = check.code || "TEAM_SCOPE_LOCK_INVALID_SCOPES";
1339
+ this._setFatal(error, {
1340
+ phase: "handoff-scope-validation",
1341
+ key: transfer.key,
1342
+ holder,
1343
+ });
1344
+ return null;
1345
+ }
1346
+ }
1347
+ queue.splice(index, 1);
1348
+ if (queue.length === 0) this._handoffTransfers.delete(holder);
1349
+ return transfer;
1350
+ }
1351
+ return null;
1352
+ }
1353
+
1354
+ _discardHandoffTransfer(transfer, claim = null) {
1355
+ if (transfer?.budgetReservation?.id) {
1356
+ this.budget?.releaseReservation?.(transfer.budgetReservation.id);
1357
+ }
1358
+ if (claim) this._endClaim(claim);
1359
+ else transfer?.sessionWork?.release?.();
1360
+ this._reservedExecutions = Math.max(0, this._reservedExecutions - 1);
1361
+ this._signalProgress();
1362
+ }
1363
+
1364
+ async _executeHandoffTransfer(holder, transfer) {
1365
+ const current = this.registry.getTask(transfer.key);
1366
+ if (
1367
+ !current?.lease ||
1368
+ current.lease.holder !== holder ||
1369
+ current.lease.leaseId !== transfer.lease?.leaseId
1370
+ ) {
1371
+ this._discardHandoffTransfer(transfer);
1372
+ const error = this._handoffFailure(
1373
+ "TEAM_HANDOFF_TARGET_LEASE_CHANGED",
1374
+ `Committed handoff target lease changed before dispatch: ${transfer.key}`,
1375
+ );
1376
+ this._setFatal(error, {
1377
+ phase: "handoff-dispatch",
1378
+ key: transfer.key,
1379
+ holder,
1380
+ });
1381
+ return;
1382
+ }
1383
+ let claim;
1384
+ try {
1385
+ claim = this._beginClaim(
1386
+ holder,
1387
+ transfer.key,
1388
+ transfer.lease,
1389
+ transfer.budgetReservation,
1390
+ transfer.sessionWork,
1391
+ );
1392
+ claim.handoffId = transfer.handoffId;
1393
+ } catch (error) {
1394
+ this._discardHandoffTransfer(transfer);
1395
+ this._setFatal(error, {
1396
+ phase: "handoff-session-budget-bind",
1397
+ key: transfer.key,
1398
+ holder,
1399
+ });
1400
+ return;
1401
+ }
1402
+ if (!this._acquireScope(holder, transfer.key)) {
1403
+ this._discardHandoffTransfer(transfer, claim);
1404
+ return;
1405
+ }
1406
+ const started = this.registry.markHandoffStarted(transfer.key, {
1407
+ handoffId: transfer.handoffId,
1408
+ holder,
1409
+ leaseId: transfer.lease.leaseId,
1410
+ });
1411
+ if (!started.ok) {
1412
+ const error = this._handoffFailure(
1413
+ "TEAM_HANDOFF_TARGET_START_REJECTED",
1414
+ `Could not durably start handoff target: ${started.reason}`,
1415
+ );
1416
+ this._setFatal(error, {
1417
+ phase: "handoff-start",
1418
+ key: transfer.key,
1419
+ holder,
1420
+ });
1421
+ this._releaseScope(holder, transfer.key);
1422
+ this._discardHandoffTransfer(transfer, claim);
1423
+ return;
1424
+ }
1425
+ try {
1426
+ this._persistHandoffMutation({
1427
+ type: "handoff:target-started",
1428
+ key: transfer.key,
1429
+ handoffId: transfer.handoffId,
1430
+ holder,
1431
+ leaseId: transfer.lease.leaseId,
1432
+ recovered: transfer.recovered === true,
1433
+ });
1434
+ } catch {
1435
+ this._releaseScope(holder, transfer.key);
1436
+ this._discardHandoffTransfer(transfer, claim);
1437
+ return;
1438
+ }
1439
+ if (!(await this._prepareTask(holder, transfer.key, claim))) {
1440
+ this._releaseScope(holder, transfer.key);
1441
+ this._discardHandoffTransfer(transfer, claim);
1442
+ return;
1443
+ }
1444
+ if (this._fatalError || claim.lost) {
1445
+ this._releaseScope(holder, transfer.key);
1446
+ this._discardHandoffTransfer(transfer, claim);
1447
+ return;
1448
+ }
1449
+ await this._execute(holder, transfer.key, claim);
1450
+ }
1451
+
395
1452
  async _worker(holder) {
396
1453
  // Each teammate keeps taking claimable work until none is left FOR IT. A
397
1454
  // task blocked only by a peer's in-flight lease will free up (complete) or
@@ -402,6 +1459,11 @@ export class TeamRunner {
402
1459
  this._setState(holder, "shutdown", { reason: "fatal-error" });
403
1460
  return;
404
1461
  }
1462
+ const transfer = this._takeHandoffTransfer(holder);
1463
+ if (transfer) {
1464
+ await this._executeHandoffTransfer(holder, transfer);
1465
+ continue;
1466
+ }
405
1467
  const sessionStopReason = this._sessionBudgetStopReason();
406
1468
  if (sessionStopReason) {
407
1469
  const reason = sessionStopReason;
@@ -444,11 +1506,16 @@ export class TeamRunner {
444
1506
  }
445
1507
  const key = this._nextFor(holder);
446
1508
  if (!key) {
1509
+ this._setState(holder, "idle");
1510
+ // A follow-up may have arrived while this target's previous turn was
1511
+ // still active. If it was not explicitly ACKed by turn settlement,
1512
+ // convert it into a fresh lease-bound turn now instead of shutting the
1513
+ // worker down with a stranded wake intent.
1514
+ if (this._reconcileFollowupWakes(holder) > 0) continue;
447
1515
  // Nothing to claim right now. If peers are still working, wait and
448
1516
  // retry (their completions may unblock a dependent). Otherwise we're done.
449
1517
  const heldScopes = this.scopeLock?.status?.().count || 0;
450
1518
  if (this._inFlight > 0 || this._reservedExecutions > 0) {
451
- this._setState(holder, "idle");
452
1519
  await this._waitForProgress();
453
1520
  continue;
454
1521
  }
@@ -1042,7 +2109,7 @@ export class TeamRunner {
1042
2109
  }
1043
2110
 
1044
2111
  /** Pick a claimable task key for this holder (highest priority first). */
1045
- _nextFor() {
2112
+ _nextFor(holder) {
1046
2113
  // A whole-workspace owner blocks every other scope, so avoid walking a
1047
2114
  // large ready queue only to rediscover the same conflict for each task.
1048
2115
  if (
@@ -1056,8 +2123,15 @@ export class TeamRunner {
1056
2123
  const excluded = new Set(this._activeKeys);
1057
2124
  for (let scanned = 0; scanned < this.maxScopeScan; scanned++) {
1058
2125
  const key = this.registry.nextClaimable({ excludeKeys: excluded });
1059
- if (!key || !this.scopeLock) return key;
2126
+ if (!key) return null;
1060
2127
  const task = this.registry.getTask(key);
2128
+ const targetHolder =
2129
+ followupMetadata(task)?.recipient || custodyRecipient(task) || null;
2130
+ if (targetHolder && targetHolder !== holder) {
2131
+ excluded.add(key);
2132
+ continue;
2133
+ }
2134
+ if (!this.scopeLock) return key;
1061
2135
  const check = this.scopeLock.canAcquire(
1062
2136
  key,
1063
2137
  this.scopeForTask(task, key),
@@ -1084,6 +2158,9 @@ export class TeamRunner {
1084
2158
  for (const key of claimable) {
1085
2159
  if (this._activeKeys.has(key)) continue;
1086
2160
  const t = this.registry.getTask(key);
2161
+ const targetHolder =
2162
+ followupMetadata(t)?.recipient || custodyRecipient(t) || null;
2163
+ if (targetHolder && targetHolder !== holder) continue;
1087
2164
  const score = rank[t?.priority] ?? 1;
1088
2165
  if (score < bestScore) {
1089
2166
  bestScore = score;
@@ -1135,21 +2212,101 @@ export class TeamRunner {
1135
2212
 
1136
2213
  async _execute(holder, key, claim) {
1137
2214
  const task = this.registry.getTask(key);
2215
+ const sessionTaskKey = this._sessionTaskKey(key, task);
1138
2216
  this._reservedExecutions--;
1139
2217
  this._executions++;
1140
2218
  this._inFlight++;
1141
2219
  this._maxInFlight = Math.max(this._maxInFlight, this._inFlight);
1142
- this._setState(holder, "running", { key });
2220
+ this._setState(holder, "running", { key, sessionTaskKey });
1143
2221
  this._emit("task:claimed", { key, holder, attempts: task.attempts });
1144
2222
  const renew = () => this._renewClaim(claim);
1145
2223
  // A teammate-scoped messaging handle: post to a peer / broadcast, and read
1146
2224
  // its own inbox (direct messages + unseen broadcasts).
1147
- const inbox = this.mailbox ? this.mailbox.drain(holder) : [];
1148
- const sendMessage = (to, body, subject = null) => {
2225
+ const mailboxInbox = this.mailbox
2226
+ ? this.realtimeMessaging
2227
+ ? this.mailbox.receive(holder, { limit: 100, markRead: true })
2228
+ : this.mailbox.drain(holder)
2229
+ : [];
2230
+ const custodyHandoff = claim.handoffId
2231
+ ? (task.metadata?.custodyHandoffs || []).find(
2232
+ (handoff) => handoff.id === claim.handoffId,
2233
+ )
2234
+ : null;
2235
+ const inbox = custodyHandoff
2236
+ ? [
2237
+ ...mailboxInbox,
2238
+ {
2239
+ id: null,
2240
+ from: custodyHandoff.fromHolder,
2241
+ to: custodyHandoff.toHolder,
2242
+ subject: "Committed task custody handoff",
2243
+ body: {
2244
+ handoffId: custodyHandoff.id,
2245
+ taskKey: key,
2246
+ summary: custodyHandoff.summary,
2247
+ preconditions: custodyHandoff.preconditions,
2248
+ artifactIds: custodyHandoff.artifactIds || [],
2249
+ revisionDigest: custodyHandoff.revisionDigest,
2250
+ authorityDigest: custodyHandoff.authorityDigest,
2251
+ },
2252
+ },
2253
+ ]
2254
+ : mailboxInbox;
2255
+ const messageAuthority = () => {
2256
+ const current = this._claimsByKey.get(key);
2257
+ if (
2258
+ current !== claim ||
2259
+ claim.lost ||
2260
+ claim.interruption ||
2261
+ claim.coordinatorAbort ||
2262
+ claim.abortController.signal.aborted
2263
+ ) {
2264
+ const error = new Error(
2265
+ `Team message authority for task "${key}" is no longer active`,
2266
+ );
2267
+ error.code = "TEAM_MESSAGE_BRIDGE_STALE_ATTEMPT";
2268
+ throw error;
2269
+ }
2270
+ return {
2271
+ holder,
2272
+ taskKey: key,
2273
+ attempt: task.attempts,
2274
+ leaseId: claim.leaseId,
2275
+ fencingToken: claim.fencingToken,
2276
+ };
2277
+ };
2278
+ const recipientState = (recipient) => {
2279
+ const member = this._members.get(recipient);
2280
+ return member ? { ...member } : null;
2281
+ };
2282
+ const requestFollowupWake = (request = {}) =>
2283
+ this.requestFollowupWake({
2284
+ ...request,
2285
+ senderAttempt: request.senderAttempt || messageAuthority(),
2286
+ });
2287
+ const requestHandoff = this.handoffEnabled
2288
+ ? (request = {}) =>
2289
+ this.requestHandoff({
2290
+ ...request,
2291
+ authority: request.authority || messageAuthority(),
2292
+ })
2293
+ : null;
2294
+ const sendMessage = (to, body, subject = null, options = {}) => {
1149
2295
  if (!this.mailbox) return null;
1150
2296
  let message;
1151
2297
  try {
1152
- message = this.mailbox.send({ from: holder, to, subject, body });
2298
+ const authority = messageAuthority();
2299
+ message = this.mailbox.send({
2300
+ from: holder,
2301
+ to,
2302
+ subject,
2303
+ body,
2304
+ mode: options.mode,
2305
+ idempotencyKey: options.idempotencyKey,
2306
+ causationId: options.causationId,
2307
+ correlationId: options.correlationId,
2308
+ senderAttempt: authority,
2309
+ });
1153
2310
  } catch (error) {
1154
2311
  this._emit("mailbox:backpressure", {
1155
2312
  holder,
@@ -1167,6 +2324,13 @@ export class TeamRunner {
1167
2324
  messages: this.mailbox.size(),
1168
2325
  });
1169
2326
  }
2327
+ this._emit("mailbox:message-sent", {
2328
+ holder,
2329
+ key,
2330
+ messageId: message.id,
2331
+ to: message.to,
2332
+ mode: message.mode || "send",
2333
+ });
1170
2334
  return message;
1171
2335
  };
1172
2336
  const startedAt = this._now();
@@ -1197,6 +2361,10 @@ export class TeamRunner {
1197
2361
  renew,
1198
2362
  inbox,
1199
2363
  sendMessage,
2364
+ messageAuthority,
2365
+ recipientState,
2366
+ requestFollowupWake,
2367
+ requestHandoff,
1200
2368
  mailbox: this.mailbox,
1201
2369
  budget: this.budget,
1202
2370
  budgetReservation: claim.budgetReservation,
@@ -1212,6 +2380,9 @@ export class TeamRunner {
1212
2380
  if (claim.coordinatorAbort) {
1213
2381
  throw claim.coordinatorAbort;
1214
2382
  }
2383
+ if (claim.handoffCommitted) {
2384
+ throw claim.handoffCommitted.error;
2385
+ }
1215
2386
  this._recordSessionUsage(claim, result);
1216
2387
  if (
1217
2388
  claim.sessionBudgetUsageUnknown ||
@@ -1268,7 +2439,10 @@ export class TeamRunner {
1268
2439
  });
1269
2440
  if (done.ok) {
1270
2441
  if (span) span.end();
1271
- this._setState(holder, "completed-task");
2442
+ this._setState(holder, "completed-task", {
2443
+ key,
2444
+ sessionTaskKey,
2445
+ });
1272
2446
  this._emit("task:completed", {
1273
2447
  key,
1274
2448
  holder,
@@ -1336,6 +2510,8 @@ export class TeamRunner {
1336
2510
  if (outcome?.ok) {
1337
2511
  this._setState(holder, "failed-task", {
1338
2512
  error: failure?.message || String(failure),
2513
+ key,
2514
+ sessionTaskKey,
1339
2515
  });
1340
2516
  this._emit("task:failed", {
1341
2517
  key,