chainlesschain 0.166.2 → 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 (170) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/assets/{AIOps-BnZ24WRk.js → AIOps-DZTvbk_H.js} +1 -1
  3. package/src/assets/web-panel/assets/{ActionButton-ivXY7MGb.js → ActionButton-IaEVUN3o.js} +1 -1
  4. package/src/assets/web-panel/assets/{Analytics-BQS4OD-k.js → Analytics-CCQab0gl.js} +2 -2
  5. package/src/assets/web-panel/assets/{AppLayout-wOBuOKTO.js → AppLayout-Bs_0GVpx.js} +5 -5
  6. package/src/assets/web-panel/assets/{Artifacts-BYuC4nw-.js → Artifacts-Bm83-yJv.js} +1 -1
  7. package/src/assets/web-panel/assets/{Audit-BIeVfBt6.js → Audit-Bah1XDR5.js} +1 -1
  8. package/src/assets/web-panel/assets/{BackgroundAgents-hIuyhmJ7.js → BackgroundAgents-BPZA0B5d.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-B6ohdgPQ.js → Backup-BMydWFiM.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-BzgYCw6h.js → BaseInput-lhopFSZz.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-CwahvZU1.js → Chat-Bbbs5BP1.js} +4 -4
  12. package/src/assets/web-panel/assets/{ChatBubbleRenderer-B-1IZ_k8.js → ChatBubbleRenderer-BIAfOKCj.js} +1 -1
  13. package/src/assets/web-panel/assets/{Checkbox-26EDP2x0.js → Checkbox-CLEMA9J4.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-DYLw1Zvd.js → Codegen-deCSwmnK.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-DH_zAvD-.js → Col-CH-n8haq.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-DURrTXJu.js → Community-DJuAl6dw.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-CQyBqN0p.js → Compact-BMyLBmgs.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compliance-a0DfgKHD.js → Compliance-BK75Ss93.js} +1 -1
  19. package/src/assets/web-panel/assets/{Cowork-C6fY_5Ke.js → Cowork-5XbJwKYq.js} +3 -3
  20. package/src/assets/web-panel/assets/{Cron-DOwmpbTe.js → Cron-Ezgir7IZ.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-DEPOIEUs.js → Crosschain-CKuMoYA7.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-DaKc-CEV.js → DID-CxS244cH.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-C-0KLXq5.js → Dashboard-wrUKrsJy.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-CnsDukzr.js → Dropdown-A6I91Z2J.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-Bi_ACD4N.js → EmailListRenderer-BofaiRYY.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-BZASpnx3.js → FamilyGuardDashboard-D3eeNA64.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-43oTtzyp.js → Federation-DzK-3AjS.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-oyVadnkZ.js → FormItemContext-Dm6mYA8b.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-Cwvfd6TZ.js → GenericCardRenderer-BeWL-Q1b.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-Cgi2wvU0.js → Git-CtGCxg9p.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-l8TWDKZ0.js → Governance-DZvFB46G.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-Dc8j6TaO.js → Inference-ntqp4cmw.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-DgcO-Fwz.js → KnowledgeGraph-CrLYbqLe.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-DjiZpiwa.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-BeDn1hFc.js → Marketplace-B8aPsXYv.js} +1 -1
  37. package/src/assets/web-panel/assets/{McpTools-PGdVfWK6.js → McpTools-DQOgNdGS.js} +3 -3
  38. package/src/assets/web-panel/assets/{Memory-e76lY3a1.js → Memory-i9IK9B0M.js} +2 -2
  39. package/src/assets/web-panel/assets/{MobileBridge-DKlQqot_.js → MobileBridge-BynYvpv9.js} +1 -1
  40. package/src/assets/web-panel/assets/{MobileProjects-C4g6WOEr.js → MobileProjects-DixivGuQ.js} +1 -1
  41. package/src/assets/web-panel/assets/{Mtc-1j3NpRQq.js → Mtc-OWPvWVNr.js} +5 -5
  42. package/src/assets/web-panel/assets/{MtcAudit-DgwiVd1C.js → MtcAudit-Cqpg5C4A.js} +4 -4
  43. package/src/assets/web-panel/assets/{Multisig-Dzg5lZZY.js → Multisig-CT5kMJIZ.js} +3 -3
  44. package/src/assets/web-panel/assets/{NLProgramming-D7GYhst-.js → NLProgramming-BXD0Dz0h.js} +1 -1
  45. package/src/assets/web-panel/assets/{Notes-Blcfvaic.js → Notes-T1w5p8N5.js} +3 -3
  46. package/src/assets/web-panel/assets/{NotificationSettings-Dc4Y6vfr.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-DBXq024L.js → Organization-CSFcw86q.js} +4 -4
  49. package/src/assets/web-panel/assets/{Overflow-CFPbdx1Y.js → Overflow-BxVMuLTk.js} +1 -1
  50. package/src/assets/web-panel/assets/{P2P-w7z61NR5.js → P2P-B8qxY_yX.js} +2 -2
  51. package/src/assets/web-panel/assets/{PdhVaultBrowser-B7eGGZOa.js → PdhVaultBrowser-Bcq27Exy.js} +3 -3
  52. package/src/assets/web-panel/assets/{Permissions-Df5Cl50D.js → Permissions-CGBvJVXP.js} +4 -4
  53. package/src/assets/web-panel/assets/{PersonalDataHub-C-We7RF6.js → PersonalDataHub-DM9IEeJ3.js} +5 -5
  54. package/src/assets/web-panel/assets/{Pipeline-B6fHPnKy.js → Pipeline-wEskx2-p.js} +1 -1
  55. package/src/assets/web-panel/assets/{Privacy-B85BdiIc.js → Privacy-DIA02lLJ.js} +1 -1
  56. package/src/assets/web-panel/assets/{ProjectInit-CcOyazSe.js → ProjectInit-uMXlNLHV.js} +2 -2
  57. package/src/assets/web-panel/assets/{ProjectSettings-BE53KfuU.js → ProjectSettings-Sy3NOtt7.js} +2 -2
  58. package/src/assets/web-panel/assets/{Projects-DOvMOoe_.js → Projects-xFjfOkQM.js} +1 -1
  59. package/src/assets/web-panel/assets/{Providers-5l7sT9lq.js → Providers-CNyOpbg8.js} +1 -1
  60. package/src/assets/web-panel/assets/{QrScannerModal-DE37RXy4.js → QrScannerModal-Dk1UVRwI.js} +1 -1
  61. package/src/assets/web-panel/assets/{QuickAsk-BGmGCo7j.js → QuickAsk-B-UqLoLG.js} +1 -1
  62. package/src/assets/web-panel/assets/{Recommend-BFdx8Nrm.js → Recommend-CiTBVZvN.js} +1 -1
  63. package/src/assets/web-panel/assets/{RemoteSession-C1w-j_hB.js → RemoteSession-BhABd516.js} +2 -2
  64. package/src/assets/web-panel/assets/{Reputation-BOvn-10y.js → Reputation-Dq1xTCcN.js} +1 -1
  65. package/src/assets/web-panel/assets/{Row-db-a1YO9.js → Row-DbFc4sc7.js} +1 -1
  66. package/src/assets/web-panel/assets/{RssFeed-DeS43ZGM.js → RssFeed-D7IBkkLm.js} +3 -3
  67. package/src/assets/web-panel/assets/{Search-BaQYNfeb.js → Search-BVyMlGoa.js} +1 -1
  68. package/src/assets/web-panel/assets/{Security-yt1ESHDg.js → Security-BzMm9Be_.js} +3 -3
  69. package/src/assets/web-panel/assets/{Services-BcNqPUsS.js → Services-Dfk0kied.js} +2 -2
  70. package/src/assets/web-panel/assets/{Skeleton-X3ceUQWf.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-C_fdfbuJ.js → Sla-gnFelKfT.js} +1 -1
  73. package/src/assets/web-panel/assets/{SpeechSettings-CDqohXXQ.js → SpeechSettings-B12gCQkK.js} +1 -1
  74. package/src/assets/web-panel/assets/{SyncSettings-B25maYEY.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-DUH0iuc4.js → Templates-PjdppQrI.js} +1 -1
  77. package/src/assets/web-panel/assets/{Tenant-DrvhHf9T.js → Tenant-CH3aARxE.js} +1 -1
  78. package/src/assets/web-panel/assets/{Terminal-C2lJVYHA.js → Terminal-BqqrYyg3.js} +2 -2
  79. package/src/assets/web-panel/assets/{TimelineRenderer-C1Hf_jYn.js → TimelineRenderer-Dx9soq9f.js} +1 -1
  80. package/src/assets/web-panel/assets/{Tokens-DniS1NpS.js → Tokens-C66KpDDZ.js} +1 -1
  81. package/src/assets/web-panel/assets/{Trigger-4jrcb8oA.js → Trigger-DHxgGsap.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trust-DiBte55w.js → Trust-3cct4QxN.js} +1 -1
  83. package/src/assets/web-panel/assets/{UkeySign-CZRtc4VB.js → UkeySign-Cs6UfDEH.js} +1 -1
  84. package/src/assets/web-panel/assets/{VideoEditing-BtYGJDmg.js → VideoEditing-jbu2EAQa.js} +1 -1
  85. package/src/assets/web-panel/assets/{Wallet-D1nrTm7H.js → Wallet-BIykFB9D.js} +4 -4
  86. package/src/assets/web-panel/assets/{WebAuthn-DI91acpx.js → WebAuthn-DFgqKmHG.js} +5 -5
  87. package/src/assets/web-panel/assets/{WorkflowEditor-BtQjI50d.js → WorkflowEditor-CNg5bBiH.js} +1 -1
  88. package/src/assets/web-panel/assets/{chat-I4A-f4FC.js → chat-B-6wzojE.js} +1 -1
  89. package/src/assets/web-panel/assets/{colors-QZkDkFVt.js → colors-BHAq7y9y.js} +1 -1
  90. package/src/assets/web-panel/assets/{compact-item--FtCdzpr.js → compact-item-CGJnSTnu.js} +1 -1
  91. package/src/assets/web-panel/assets/{createContext-CaIKIKxP.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-DLYUII6-.js → hasIn-DibIw3z7.js} +1 -1
  94. package/src/assets/web-panel/assets/{index-OQPIS_am.js → index-0NWSa3Xq.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-3rVtgqa2.js → index-746-mNhw.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-BxPoPofq.js → index-Aaeca8o4.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-D4CXFU-w.js → index-B1jAbIxx.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-BLGJmgmx.js → index-B28jdxwR.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-CL4c5D6G.js → index-B2rOMBeN.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-vfiDxdN2.js → index-BAoFiV3n.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-Du2wUavy.js → index-BCtW4F8-.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-DAuf8r5o.js → index-BEXM3qSV.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-BGWSmE3G.js → index-BFTGp-aF.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-eTqL91aV.js → index-BJhS6i02.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-CQ7OBHka.js → index-BO-XxBD5.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-hfiZ-i2b.js → index-BR2BiNtx.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-CBtII-nl.js → index-BRflLAdr.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-Ch0IFpGy.js → index-BTWb3sbj.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-B65QxJaG.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-BebTT7iR.js → index-BiMLsIwd.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-Ygi2rYL1.js → index-Bmqjip63.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-DGLHSCHM.js → index-BoySHSgZ.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-DJrd_CR3.js → index-Bz3J0eVB.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-DK2r6Bv7.js → index-C6M2yHVE.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-yayaayEA.js → index-CH2A_Fdn.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-Ooo_5jHo.js → index-CUX9mq3M.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-D2xETCkj.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-CisYjy88.js → index-CoKu7Q9r.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-DA_TPdzX.js → index-CrjkH_QU.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-Bs0qPPFb.js → index-Cuity3gy.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-BkJgWW_L.js → index-CwcWpW1q.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-Deg2S9ZJ.js → index-Cz5ety6j.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-CXaeZCRP.js → index-DXNcm44v.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-DVitI3UQ.js → index-DjCJIttz.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-CgMm85BY.js → index-DnsVRqCa.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-21I79XSf.js → index-SbQacweG.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-BcgtvJbC.js → index-g0V_mLQ9.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-BvdwNnHC.js → index-puIs3mTs.js} +3 -3
  131. package/src/assets/web-panel/assets/{index-0DYBQ-6e.js → index-sI_1E0co.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-C3PnIEOH.js → index-woOjtit-.js} +1 -1
  133. package/src/assets/web-panel/assets/{initDefaultProps-DDkTvw8k.js → initDefaultProps-Gbl9QOqg.js} +1 -1
  134. package/src/assets/web-panel/assets/{motion-fkr96tN5.js → motion-BHh6ZzZu.js} +1 -1
  135. package/src/assets/web-panel/assets/{move-CGBV0SV2.js → move-mX6Rwrd1.js} +1 -1
  136. package/src/assets/web-panel/assets/{mtc-parser-DAsCliFt.js → mtc-parser-CuzZa_kI.js} +1 -1
  137. package/src/assets/web-panel/assets/{omit-BM8PMmKN.js → omit-DjANYkpi.js} +1 -1
  138. package/src/assets/web-panel/assets/{pickAttrs-C6SdWoTK.js → pickAttrs-C_3v40wY.js} +1 -1
  139. package/src/assets/web-panel/assets/{placementArrow-BG0EVu-d.js → placementArrow-L2HgRede.js} +1 -1
  140. package/src/assets/web-panel/assets/{responsiveObserve-L10IvfYC.js → responsiveObserve-DIkGMHyT.js} +1 -1
  141. package/src/assets/web-panel/assets/{slide-TddIkbrn.js → slide-C1hp5tD0.js} +1 -1
  142. package/src/assets/web-panel/assets/{statusUtils-FlveF2Yn.js → statusUtils-DH1W2uFA.js} +1 -1
  143. package/src/assets/web-panel/assets/{styleChecker-BrXRyebg.js → styleChecker-dEaKQcv2.js} +1 -1
  144. package/src/assets/web-panel/assets/{useFlexGapSupport-B7_oiDXZ.js → useFlexGapSupport-CD7j1zxt.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFs-CQYeRYWb.js → useFs-C8jXMvEM.js} +1 -1
  146. package/src/assets/web-panel/assets/{usePersonalDataHub-DspIkhVi.js → usePersonalDataHub-C4r-8mys.js} +1 -1
  147. package/src/assets/web-panel/assets/{vnode-BCwmwMAW.js → vnode-DDuI6wgY.js} +1 -1
  148. package/src/assets/web-panel/assets/{zoom-B0cEGluV.js → zoom-Dd-DteT6.js} +1 -1
  149. package/src/assets/web-panel/index.html +1 -1
  150. package/src/commands/team.js +165 -53
  151. package/src/data/changelog.json +13 -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-message-bridge.js +83 -5
  155. package/src/lib/agent-team/team-message-tools.js +34 -1
  156. package/src/lib/agent-team/team-runner.js +1131 -9
  157. package/src/lib/agent-team/team-session-message-adapter.js +720 -0
  158. package/src/lib/app-server/cli-agent-kernel-adapter.js +1 -0
  159. package/src/lib/app-server/protocol.js +43 -0
  160. package/src/lib/app-server/server.js +8 -11
  161. package/src/lib/approval-grant-ledger.js +187 -0
  162. package/src/lib/session-message-fabric.js +265 -3
  163. package/src/runtime/headless-stream.js +184 -19
  164. package/src/assets/web-panel/assets/MarkdownRenderer-B5MFCi0N.js +0 -1
  165. package/src/assets/web-panel/assets/OrderTableRenderer-dgM87XIn.js +0 -1
  166. package/src/assets/web-panel/assets/Skills-DmYeax6C.js +0 -1
  167. package/src/assets/web-panel/assets/Tasks-3ILwauYw.js +0 -1
  168. package/src/assets/web-panel/assets/devWarning-D7qVrkrO.js +0 -1
  169. package/src/assets/web-panel/assets/index-D9Y_ZwZn.js +0 -1
  170. package/src/assets/web-panel/assets/index-D_cPes4G.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;
@@ -84,6 +131,31 @@ export class TeamRunner {
84
131
  // it. Ordinary renewals keep the same identity and do not call this hook.
85
132
  this.onLeaseChanged =
86
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;
87
159
  this._executions = 0;
88
160
  this._reservedExecutions = 0;
89
161
  this._inFlight = 0;
@@ -93,6 +165,7 @@ export class TeamRunner {
93
165
  // same process must not steal and double-run that still-active task.
94
166
  this._activeKeys = new Set();
95
167
  this._members = new Map(); // holder → lifecycle record
168
+ this._residentHolders = new Set();
96
169
  this._budgetStopped = false;
97
170
  this._registryBudgetReason = null;
98
171
  this._fatalError = null;
@@ -103,6 +176,8 @@ export class TeamRunner {
103
176
  this._workerCount = 1;
104
177
  this._claimControllers = new Set();
105
178
  this._claimsByKey = new Map();
179
+ this._handoffTransfers = new Map();
180
+ this._handoffExpiryTimers = new Map();
106
181
  this._wallTimer = null;
107
182
  }
108
183
 
@@ -127,16 +202,32 @@ export class TeamRunner {
127
202
  }
128
203
  if (state === "completed-task") {
129
204
  m.completed += 1;
205
+ if (extra.key) m.lastTaskKey = extra.key;
206
+ if (extra.sessionTaskKey) m.sessionTaskKey = extra.sessionTaskKey;
207
+ m.currentTaskKey = null;
130
208
  return m;
131
209
  }
132
210
  if (state === "failed-task") {
133
211
  m.failed += 1;
134
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;
135
216
  return m;
136
217
  }
137
218
  if (m.state === state) return m; // no transition
138
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);
139
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
+ }
140
231
  this._emit("teammate:state", { holder, state, ...extra });
141
232
  // Initial registration starts in `idle`; the hook is reserved for a real
142
233
  // transition back to idle after a teammate has participated in the run.
@@ -170,6 +261,806 @@ export class TeamRunner {
170
261
  }
171
262
  }
172
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
+
173
1064
  /** Snapshot of local in-flight claims for an IDE/operator control surface. */
174
1065
  activeClaims() {
175
1066
  return Array.from(this._claimsByKey.values()).map((claim) => ({
@@ -347,20 +1238,48 @@ export class TeamRunner {
347
1238
  }
348
1239
  this.sessionBudget?.start?.();
349
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
+ }
350
1254
  const workerCount = Math.min(
351
1255
  this.teammates,
352
- Math.max(1, this.registry.list().length),
1256
+ Math.max(1, this.registry.list().length, requiredHandoffWorker),
353
1257
  );
354
1258
  this._workerCount = workerCount;
355
1259
  const holders = Array.from(
356
1260
  { length: workerCount },
357
1261
  (_, index) => `teammate-${index + 1}`,
358
1262
  );
1263
+ this._residentHolders = new Set(holders);
359
1264
  this.mailbox?.registerRecipients?.(holders);
360
1265
  for (const holder of holders) {
361
1266
  this._setState(holder, "idle");
362
- workers.push(this._worker(holder));
363
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));
364
1283
  try {
365
1284
  await Promise.all(workers);
366
1285
  } finally {
@@ -368,6 +1287,10 @@ export class TeamRunner {
368
1287
  clearTimeout(this._wallTimer);
369
1288
  this._wallTimer = null;
370
1289
  }
1290
+ for (const timer of this._handoffExpiryTimers.values()) {
1291
+ clearTimeout(timer);
1292
+ }
1293
+ this._handoffExpiryTimers.clear();
371
1294
  }
372
1295
  if (this._fatalError) throw this._fatalError;
373
1296
  const stats = this.registry.stats();
@@ -395,6 +1318,137 @@ export class TeamRunner {
395
1318
  return summary;
396
1319
  }
397
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
+
398
1452
  async _worker(holder) {
399
1453
  // Each teammate keeps taking claimable work until none is left FOR IT. A
400
1454
  // task blocked only by a peer's in-flight lease will free up (complete) or
@@ -405,6 +1459,11 @@ export class TeamRunner {
405
1459
  this._setState(holder, "shutdown", { reason: "fatal-error" });
406
1460
  return;
407
1461
  }
1462
+ const transfer = this._takeHandoffTransfer(holder);
1463
+ if (transfer) {
1464
+ await this._executeHandoffTransfer(holder, transfer);
1465
+ continue;
1466
+ }
408
1467
  const sessionStopReason = this._sessionBudgetStopReason();
409
1468
  if (sessionStopReason) {
410
1469
  const reason = sessionStopReason;
@@ -447,11 +1506,16 @@ export class TeamRunner {
447
1506
  }
448
1507
  const key = this._nextFor(holder);
449
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;
450
1515
  // Nothing to claim right now. If peers are still working, wait and
451
1516
  // retry (their completions may unblock a dependent). Otherwise we're done.
452
1517
  const heldScopes = this.scopeLock?.status?.().count || 0;
453
1518
  if (this._inFlight > 0 || this._reservedExecutions > 0) {
454
- this._setState(holder, "idle");
455
1519
  await this._waitForProgress();
456
1520
  continue;
457
1521
  }
@@ -1045,7 +2109,7 @@ export class TeamRunner {
1045
2109
  }
1046
2110
 
1047
2111
  /** Pick a claimable task key for this holder (highest priority first). */
1048
- _nextFor() {
2112
+ _nextFor(holder) {
1049
2113
  // A whole-workspace owner blocks every other scope, so avoid walking a
1050
2114
  // large ready queue only to rediscover the same conflict for each task.
1051
2115
  if (
@@ -1059,8 +2123,15 @@ export class TeamRunner {
1059
2123
  const excluded = new Set(this._activeKeys);
1060
2124
  for (let scanned = 0; scanned < this.maxScopeScan; scanned++) {
1061
2125
  const key = this.registry.nextClaimable({ excludeKeys: excluded });
1062
- if (!key || !this.scopeLock) return key;
2126
+ if (!key) return null;
1063
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;
1064
2135
  const check = this.scopeLock.canAcquire(
1065
2136
  key,
1066
2137
  this.scopeForTask(task, key),
@@ -1087,6 +2158,9 @@ export class TeamRunner {
1087
2158
  for (const key of claimable) {
1088
2159
  if (this._activeKeys.has(key)) continue;
1089
2160
  const t = this.registry.getTask(key);
2161
+ const targetHolder =
2162
+ followupMetadata(t)?.recipient || custodyRecipient(t) || null;
2163
+ if (targetHolder && targetHolder !== holder) continue;
1090
2164
  const score = rank[t?.priority] ?? 1;
1091
2165
  if (score < bestScore) {
1092
2166
  bestScore = score;
@@ -1138,20 +2212,46 @@ export class TeamRunner {
1138
2212
 
1139
2213
  async _execute(holder, key, claim) {
1140
2214
  const task = this.registry.getTask(key);
2215
+ const sessionTaskKey = this._sessionTaskKey(key, task);
1141
2216
  this._reservedExecutions--;
1142
2217
  this._executions++;
1143
2218
  this._inFlight++;
1144
2219
  this._maxInFlight = Math.max(this._maxInFlight, this._inFlight);
1145
- this._setState(holder, "running", { key });
2220
+ this._setState(holder, "running", { key, sessionTaskKey });
1146
2221
  this._emit("task:claimed", { key, holder, attempts: task.attempts });
1147
2222
  const renew = () => this._renewClaim(claim);
1148
2223
  // A teammate-scoped messaging handle: post to a peer / broadcast, and read
1149
2224
  // its own inbox (direct messages + unseen broadcasts).
1150
- const inbox = this.mailbox
2225
+ const mailboxInbox = this.mailbox
1151
2226
  ? this.realtimeMessaging
1152
- ? this.mailbox.peek(holder)
2227
+ ? this.mailbox.receive(holder, { limit: 100, markRead: true })
1153
2228
  : this.mailbox.drain(holder)
1154
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;
1155
2255
  const messageAuthority = () => {
1156
2256
  const current = this._claimsByKey.get(key);
1157
2257
  if (
@@ -1179,6 +2279,18 @@ export class TeamRunner {
1179
2279
  const member = this._members.get(recipient);
1180
2280
  return member ? { ...member } : null;
1181
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;
1182
2294
  const sendMessage = (to, body, subject = null, options = {}) => {
1183
2295
  if (!this.mailbox) return null;
1184
2296
  let message;
@@ -1251,6 +2363,8 @@ export class TeamRunner {
1251
2363
  sendMessage,
1252
2364
  messageAuthority,
1253
2365
  recipientState,
2366
+ requestFollowupWake,
2367
+ requestHandoff,
1254
2368
  mailbox: this.mailbox,
1255
2369
  budget: this.budget,
1256
2370
  budgetReservation: claim.budgetReservation,
@@ -1266,6 +2380,9 @@ export class TeamRunner {
1266
2380
  if (claim.coordinatorAbort) {
1267
2381
  throw claim.coordinatorAbort;
1268
2382
  }
2383
+ if (claim.handoffCommitted) {
2384
+ throw claim.handoffCommitted.error;
2385
+ }
1269
2386
  this._recordSessionUsage(claim, result);
1270
2387
  if (
1271
2388
  claim.sessionBudgetUsageUnknown ||
@@ -1322,7 +2439,10 @@ export class TeamRunner {
1322
2439
  });
1323
2440
  if (done.ok) {
1324
2441
  if (span) span.end();
1325
- this._setState(holder, "completed-task");
2442
+ this._setState(holder, "completed-task", {
2443
+ key,
2444
+ sessionTaskKey,
2445
+ });
1326
2446
  this._emit("task:completed", {
1327
2447
  key,
1328
2448
  holder,
@@ -1390,6 +2510,8 @@ export class TeamRunner {
1390
2510
  if (outcome?.ok) {
1391
2511
  this._setState(holder, "failed-task", {
1392
2512
  error: failure?.message || String(failure),
2513
+ key,
2514
+ sessionTaskKey,
1393
2515
  });
1394
2516
  this._emit("task:failed", {
1395
2517
  key,