chainlesschain 0.166.2 → 0.166.4

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 (173) hide show
  1. package/package.json +1 -1
  2. package/src/assets/web-panel/.build-hash +1 -1
  3. package/src/assets/web-panel/assets/{AIOps-BnZ24WRk.js → AIOps-HcOh0ZbH.js} +1 -1
  4. package/src/assets/web-panel/assets/{ActionButton-ivXY7MGb.js → ActionButton-DWjDqBhd.js} +1 -1
  5. package/src/assets/web-panel/assets/{Analytics-BQS4OD-k.js → Analytics-C6FS-y3W.js} +3 -3
  6. package/src/assets/web-panel/assets/{AppLayout-wOBuOKTO.js → AppLayout-DCN5cldF.js} +5 -5
  7. package/src/assets/web-panel/assets/{Artifacts-BYuC4nw-.js → Artifacts-Crd-KE8P.js} +1 -1
  8. package/src/assets/web-panel/assets/{Audit-BIeVfBt6.js → Audit-BexuTQZc.js} +1 -1
  9. package/src/assets/web-panel/assets/{BackgroundAgents-hIuyhmJ7.js → BackgroundAgents-Dydkh-b5.js} +1 -1
  10. package/src/assets/web-panel/assets/{Backup-B6ohdgPQ.js → Backup-BYq21wMP.js} +1 -1
  11. package/src/assets/web-panel/assets/{BaseInput-BzgYCw6h.js → BaseInput-CmNZP6yM.js} +1 -1
  12. package/src/assets/web-panel/assets/{Chat-CwahvZU1.js → Chat-J6bD0zCl.js} +6 -6
  13. package/src/assets/web-panel/assets/{ChatBubbleRenderer-B-1IZ_k8.js → ChatBubbleRenderer-MEoaygnL.js} +1 -1
  14. package/src/assets/web-panel/assets/{Checkbox-26EDP2x0.js → Checkbox-Ce-9vNmK.js} +1 -1
  15. package/src/assets/web-panel/assets/{Codegen-DYLw1Zvd.js → Codegen-zf7-Y-Eh.js} +1 -1
  16. package/src/assets/web-panel/assets/{Col-DH_zAvD-.js → Col-D-TkY0FD.js} +1 -1
  17. package/src/assets/web-panel/assets/{Community-DURrTXJu.js → Community-l300NA-1.js} +1 -1
  18. package/src/assets/web-panel/assets/{Compact-CQyBqN0p.js → Compact-D-9Q_hxc.js} +1 -1
  19. package/src/assets/web-panel/assets/{Compliance-a0DfgKHD.js → Compliance-DAJ8lLiQ.js} +1 -1
  20. package/src/assets/web-panel/assets/{Cowork-C6fY_5Ke.js → Cowork-xmzZ967S.js} +3 -3
  21. package/src/assets/web-panel/assets/{Cron-DOwmpbTe.js → Cron-CeGqO5W4.js} +2 -2
  22. package/src/assets/web-panel/assets/{Crosschain-DEPOIEUs.js → Crosschain-DXN0AE-4.js} +1 -1
  23. package/src/assets/web-panel/assets/{DID-DaKc-CEV.js → DID-CtIKASPr.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dashboard-C-0KLXq5.js → Dashboard-DqRwT8NX.js} +2 -2
  25. package/src/assets/web-panel/assets/{Dropdown-CnsDukzr.js → Dropdown-ddIVljO1.js} +1 -1
  26. package/src/assets/web-panel/assets/{EmailListRenderer-Bi_ACD4N.js → EmailListRenderer-CcsFzV4y.js} +1 -1
  27. package/src/assets/web-panel/assets/{FamilyGuardDashboard-BZASpnx3.js → FamilyGuardDashboard-PnlAUGcG.js} +1 -1
  28. package/src/assets/web-panel/assets/{Federation-43oTtzyp.js → Federation-BlmLuQrL.js} +1 -1
  29. package/src/assets/web-panel/assets/{FormItemContext-oyVadnkZ.js → FormItemContext-dDovBj1Y.js} +1 -1
  30. package/src/assets/web-panel/assets/{GenericCardRenderer-Cwvfd6TZ.js → GenericCardRenderer-DTvGshFr.js} +1 -1
  31. package/src/assets/web-panel/assets/{Git-Cgi2wvU0.js → Git-DuFR2xgA.js} +2 -2
  32. package/src/assets/web-panel/assets/{Governance-l8TWDKZ0.js → Governance-9EaNcJuq.js} +1 -1
  33. package/src/assets/web-panel/assets/{Inference-Dc8j6TaO.js → Inference-B-iYWH_r.js} +1 -1
  34. package/src/assets/web-panel/assets/{KnowledgeGraph-DgcO-Fwz.js → KnowledgeGraph-BGBaKiLX.js} +1 -1
  35. package/src/assets/web-panel/assets/{Logs-DjiZpiwa.js → Logs-G3wmXzfa.js} +2 -2
  36. package/src/assets/web-panel/assets/{MarkdownRenderer-B5MFCi0N.js → MarkdownRenderer-hk0YwjTR.js} +1 -1
  37. package/src/assets/web-panel/assets/{Marketplace-BeDn1hFc.js → Marketplace-BNARBeAv.js} +1 -1
  38. package/src/assets/web-panel/assets/{McpTools-PGdVfWK6.js → McpTools-DST2cSSK.js} +3 -3
  39. package/src/assets/web-panel/assets/{Memory-e76lY3a1.js → Memory-piwDLsVH.js} +2 -2
  40. package/src/assets/web-panel/assets/{MobileBridge-DKlQqot_.js → MobileBridge-D-W8zHdm.js} +1 -1
  41. package/src/assets/web-panel/assets/{MobileProjects-C4g6WOEr.js → MobileProjects-CwaYHlVP.js} +1 -1
  42. package/src/assets/web-panel/assets/{Mtc-1j3NpRQq.js → Mtc-C2dxnN7Q.js} +4 -4
  43. package/src/assets/web-panel/assets/{MtcAudit-DgwiVd1C.js → MtcAudit-BwPZsV3N.js} +2 -2
  44. package/src/assets/web-panel/assets/{Multisig-Dzg5lZZY.js → Multisig-CUt8w2al.js} +3 -3
  45. package/src/assets/web-panel/assets/{NLProgramming-D7GYhst-.js → NLProgramming-DwxErx27.js} +1 -1
  46. package/src/assets/web-panel/assets/{Notes-Blcfvaic.js → Notes-B-mXAZ84.js} +3 -3
  47. package/src/assets/web-panel/assets/{NotificationSettings-Dc4Y6vfr.js → NotificationSettings-Bd1fBAzd.js} +1 -1
  48. package/src/assets/web-panel/assets/OrderTableRenderer-CCInnavw.js +1 -0
  49. package/src/assets/web-panel/assets/{Organization-DBXq024L.js → Organization-DTTONYZT.js} +4 -4
  50. package/src/assets/web-panel/assets/{Overflow-CFPbdx1Y.js → Overflow-H6lCZ1rE.js} +1 -1
  51. package/src/assets/web-panel/assets/{P2P-w7z61NR5.js → P2P-DyBZzeYW.js} +2 -2
  52. package/src/assets/web-panel/assets/{PdhVaultBrowser-B7eGGZOa.js → PdhVaultBrowser-jj7LhT04.js} +3 -3
  53. package/src/assets/web-panel/assets/{Permissions-Df5Cl50D.js → Permissions-B5nSQwsr.js} +4 -4
  54. package/src/assets/web-panel/assets/{PersonalDataHub-C-We7RF6.js → PersonalDataHub-CACaQtlY.js} +5 -5
  55. package/src/assets/web-panel/assets/{Pipeline-B6fHPnKy.js → Pipeline-5y-DR_Bw.js} +1 -1
  56. package/src/assets/web-panel/assets/{Privacy-B85BdiIc.js → Privacy-BMSA3Ehd.js} +1 -1
  57. package/src/assets/web-panel/assets/{ProjectInit-CcOyazSe.js → ProjectInit-VCOj1dTa.js} +2 -2
  58. package/src/assets/web-panel/assets/{ProjectSettings-BE53KfuU.js → ProjectSettings-CJaXO_iE.js} +2 -2
  59. package/src/assets/web-panel/assets/{Projects-DOvMOoe_.js → Projects-C21-MQ3H.js} +1 -1
  60. package/src/assets/web-panel/assets/{Providers-5l7sT9lq.js → Providers-DZ1FNKUR.js} +1 -1
  61. package/src/assets/web-panel/assets/{QrScannerModal-DE37RXy4.js → QrScannerModal-6DKc9UBX.js} +1 -1
  62. package/src/assets/web-panel/assets/{QuickAsk-BGmGCo7j.js → QuickAsk-Dyd0Ckrk.js} +1 -1
  63. package/src/assets/web-panel/assets/{Recommend-BFdx8Nrm.js → Recommend-CYW4-GJH.js} +1 -1
  64. package/src/assets/web-panel/assets/{RemoteSession-C1w-j_hB.js → RemoteSession-DLrfPy4F.js} +3 -3
  65. package/src/assets/web-panel/assets/{Reputation-BOvn-10y.js → Reputation-DfqPo1FI.js} +1 -1
  66. package/src/assets/web-panel/assets/{Row-db-a1YO9.js → Row-CR89H2x5.js} +1 -1
  67. package/src/assets/web-panel/assets/{RssFeed-DeS43ZGM.js → RssFeed-B9QY7R-F.js} +3 -3
  68. package/src/assets/web-panel/assets/{Search-BaQYNfeb.js → Search-CUDnzu4w.js} +1 -1
  69. package/src/assets/web-panel/assets/{Security-yt1ESHDg.js → Security-CJ0CtM_Z.js} +3 -3
  70. package/src/assets/web-panel/assets/{Services-BcNqPUsS.js → Services-CyrQ6F5U.js} +2 -2
  71. package/src/assets/web-panel/assets/{Skeleton-X3ceUQWf.js → Skeleton-DcwnKatQ.js} +1 -1
  72. package/src/assets/web-panel/assets/{Skills-DmYeax6C.js → Skills-BhKkYF3_.js} +1 -1
  73. package/src/assets/web-panel/assets/{Sla-C_fdfbuJ.js → Sla-CWh-awh7.js} +1 -1
  74. package/src/assets/web-panel/assets/{SpeechSettings-CDqohXXQ.js → SpeechSettings-BaUMNzx7.js} +1 -1
  75. package/src/assets/web-panel/assets/{SyncSettings-B25maYEY.js → SyncSettings-D1Ijwwxl.js} +2 -2
  76. package/src/assets/web-panel/assets/Tasks-DFqjHi3T.js +1 -0
  77. package/src/assets/web-panel/assets/{Templates-DUH0iuc4.js → Templates-Ct-9_ki3.js} +1 -1
  78. package/src/assets/web-panel/assets/{Tenant-DrvhHf9T.js → Tenant-zBkgyilh.js} +1 -1
  79. package/src/assets/web-panel/assets/{Terminal-C2lJVYHA.js → Terminal-VVO2Hjfi.js} +2 -2
  80. package/src/assets/web-panel/assets/{TimelineRenderer-C1Hf_jYn.js → TimelineRenderer-CX9pIkx3.js} +1 -1
  81. package/src/assets/web-panel/assets/{Tokens-DniS1NpS.js → Tokens-DHaD6e_m.js} +1 -1
  82. package/src/assets/web-panel/assets/{Trigger-4jrcb8oA.js → Trigger-CFq9j3WV.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trust-DiBte55w.js → Trust-DuF7gIYX.js} +1 -1
  84. package/src/assets/web-panel/assets/{UkeySign-CZRtc4VB.js → UkeySign-DbuidXJX.js} +1 -1
  85. package/src/assets/web-panel/assets/{VideoEditing-BtYGJDmg.js → VideoEditing-B8IA9_qE.js} +1 -1
  86. package/src/assets/web-panel/assets/{Wallet-D1nrTm7H.js → Wallet-DdCglSIK.js} +4 -4
  87. package/src/assets/web-panel/assets/{WebAuthn-DI91acpx.js → WebAuthn-CeteRsA8.js} +5 -5
  88. package/src/assets/web-panel/assets/{WorkflowEditor-BtQjI50d.js → WorkflowEditor-DJhIbG__.js} +1 -1
  89. package/src/assets/web-panel/assets/{chat-I4A-f4FC.js → chat-srOi_Wef.js} +1 -1
  90. package/src/assets/web-panel/assets/{colors-QZkDkFVt.js → colors-HpVHsNGK.js} +1 -1
  91. package/src/assets/web-panel/assets/{compact-item--FtCdzpr.js → compact-item-DsLhrqWe.js} +1 -1
  92. package/src/assets/web-panel/assets/{createContext-CaIKIKxP.js → createContext-aOasIUuE.js} +1 -1
  93. package/src/assets/web-panel/assets/devWarning-B_acr36n.js +1 -0
  94. package/src/assets/web-panel/assets/{hasIn-DLYUII6-.js → hasIn-D5BVMimS.js} +1 -1
  95. package/src/assets/web-panel/assets/{index-DGLHSCHM.js → index-B615KD2o.js} +1 -1
  96. package/src/assets/web-panel/assets/{index-3rVtgqa2.js → index-BCp70IlO.js} +1 -1
  97. package/src/assets/web-panel/assets/{index-Deg2S9ZJ.js → index-BD64r0Tx.js} +1 -1
  98. package/src/assets/web-panel/assets/{index-yayaayEA.js → index-BGBvkNN3.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-B65QxJaG.js → index-BNbsjeEc.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-BxPoPofq.js → index-BTwDhhvl.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-C3PnIEOH.js → index-BigtYchO.js} +1 -1
  102. package/src/assets/web-panel/assets/index-BnLYpdox.js +1 -0
  103. package/src/assets/web-panel/assets/{index-BLGJmgmx.js → index-Bu6TVdgQ.js} +1 -1
  104. package/src/assets/web-panel/assets/{index-CL4c5D6G.js → index-C4tPtHU0.js} +1 -1
  105. package/src/assets/web-panel/assets/{index-Du2wUavy.js → index-CGVHW0nF.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-DA_TPdzX.js → index-CQEoGqMG.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-Ch0IFpGy.js → index-CQXp_uNd.js} +1 -1
  108. package/src/assets/web-panel/assets/{index-CBtII-nl.js → index-CSxJ4oRd.js} +1 -1
  109. package/src/assets/web-panel/assets/{index-21I79XSf.js → index-CUUs12L9.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-DAuf8r5o.js → index-CVp_dHCs.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-Bs0qPPFb.js → index-ChGUPEwS.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-DVitI3UQ.js → index-CvhVCHk-.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-DK2r6Bv7.js → index-D1xZMQrN.js} +1 -1
  114. package/src/assets/web-panel/assets/{index-Ooo_5jHo.js → index-D2XRXkZU.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-DJrd_CR3.js → index-DQ5j1VKg.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-CXaeZCRP.js → index-DRCIPsdm.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-CQ7OBHka.js → index-DV4y5rUj.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-eTqL91aV.js → index-DcLBzIiI.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-CisYjy88.js → index-DiXsis7m.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-D4CXFU-w.js → index-Dpj-rEoV.js} +1 -1
  121. package/src/assets/web-panel/assets/{index-BebTT7iR.js → index-Dum_r_oY.js} +1 -1
  122. package/src/assets/web-panel/assets/index-Dzmwsl_v.js +1 -0
  123. package/src/assets/web-panel/assets/{index-vfiDxdN2.js → index-LSz0ZOB8.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-Ygi2rYL1.js → index-Ow3SRXRX.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-hfiZ-i2b.js → index-P6IMLmLT.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-CgMm85BY.js → index-V0gpVTbS.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-0DYBQ-6e.js → index-b0Y0hDhX.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-BGWSmE3G.js → index-dPFzlII1.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-OQPIS_am.js → index-fZnlPk7H.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-BkJgWW_L.js → index-kYqi6zYp.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-BvdwNnHC.js → index-lFOfvZDn.js} +3 -3
  132. package/src/assets/web-panel/assets/{index-BcgtvJbC.js → index-q--LtRh9.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-D2xETCkj.js → index-vQ6jy8Ew.js} +1 -1
  134. package/src/assets/web-panel/assets/{initDefaultProps-DDkTvw8k.js → initDefaultProps-1NU9yNpr.js} +1 -1
  135. package/src/assets/web-panel/assets/{motion-fkr96tN5.js → motion-BZk2hQ6c.js} +1 -1
  136. package/src/assets/web-panel/assets/{move-CGBV0SV2.js → move-BNvublME.js} +1 -1
  137. package/src/assets/web-panel/assets/{mtc-parser-DAsCliFt.js → mtc-parser-DWk5BUoL.js} +1 -1
  138. package/src/assets/web-panel/assets/{omit-BM8PMmKN.js → omit-DztYxX3x.js} +1 -1
  139. package/src/assets/web-panel/assets/{pickAttrs-C6SdWoTK.js → pickAttrs-DURL8mtg.js} +1 -1
  140. package/src/assets/web-panel/assets/{placementArrow-BG0EVu-d.js → placementArrow-B820R56z.js} +1 -1
  141. package/src/assets/web-panel/assets/{responsiveObserve-L10IvfYC.js → responsiveObserve-DUAkGJL1.js} +1 -1
  142. package/src/assets/web-panel/assets/{slide-TddIkbrn.js → slide-uZQeNSo6.js} +1 -1
  143. package/src/assets/web-panel/assets/{statusUtils-FlveF2Yn.js → statusUtils-CGR1wFVq.js} +1 -1
  144. package/src/assets/web-panel/assets/{styleChecker-BrXRyebg.js → styleChecker-BIIPOMsQ.js} +1 -1
  145. package/src/assets/web-panel/assets/{useFlexGapSupport-B7_oiDXZ.js → useFlexGapSupport-rIB9c0g2.js} +1 -1
  146. package/src/assets/web-panel/assets/{useFs-CQYeRYWb.js → useFs-CrJe1BJi.js} +1 -1
  147. package/src/assets/web-panel/assets/{usePersonalDataHub-DspIkhVi.js → usePersonalDataHub-DyEoD3Qm.js} +1 -1
  148. package/src/assets/web-panel/assets/{vnode-BCwmwMAW.js → vnode-CjohPxI0.js} +1 -1
  149. package/src/assets/web-panel/assets/{zoom-B0cEGluV.js → zoom-C5POL1_6.js} +1 -1
  150. package/src/assets/web-panel/index.html +1 -1
  151. package/src/commands/team.js +165 -53
  152. package/src/data/changelog.json +26 -0
  153. package/src/gateways/ws/remote-session-protocol.js +20 -15
  154. package/src/generated/cc-agent-protocol.schema.json +67 -1
  155. package/src/lib/agent-team/task-lease.js +718 -6
  156. package/src/lib/agent-team/team-graph-projection.js +303 -0
  157. package/src/lib/agent-team/team-message-bridge.js +83 -5
  158. package/src/lib/agent-team/team-message-tools.js +34 -1
  159. package/src/lib/agent-team/team-runner.js +1131 -9
  160. package/src/lib/agent-team/team-session-message-adapter.js +720 -0
  161. package/src/lib/app-server/cli-agent-kernel-adapter.js +1 -0
  162. package/src/lib/app-server/protocol.js +44 -8
  163. package/src/lib/app-server/server.js +8 -11
  164. package/src/lib/approval-grant-ledger.js +187 -0
  165. package/src/lib/remote-approval-bridge.js +8 -12
  166. package/src/lib/remote-approval-decision.js +87 -0
  167. package/src/lib/session-message-fabric.js +265 -3
  168. package/src/runtime/headless-stream.js +184 -19
  169. package/src/assets/web-panel/assets/OrderTableRenderer-dgM87XIn.js +0 -1
  170. package/src/assets/web-panel/assets/Tasks-3ILwauYw.js +0 -1
  171. package/src/assets/web-panel/assets/devWarning-D7qVrkrO.js +0 -1
  172. package/src/assets/web-panel/assets/index-D9Y_ZwZn.js +0 -1
  173. package/src/assets/web-panel/assets/index-D_cPes4G.js +0 -1
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { SharedTaskList, TASK_STATUS } from "@chainlesschain/session-core";
27
- import { randomUUID } from "node:crypto";
27
+ import { createHash, randomUUID } from "node:crypto";
28
28
 
29
29
  export const DEFAULT_LEASE_TTL_MS = 60000;
30
30
  export const DEFAULT_MAX_ATTEMPTS = 3;
@@ -34,6 +34,120 @@ export const ADJUDICATION_DECISIONS = Object.freeze({
34
34
  CANCEL: "cancel",
35
35
  });
36
36
 
37
+ export const TEAM_CUSTODY_HANDOFF_SCHEMA =
38
+ "chainlesschain.team-custody-handoff/v1";
39
+ export const TEAM_CUSTODY_HANDOFF_STATUSES = Object.freeze([
40
+ "offered",
41
+ "accepted",
42
+ "rejected",
43
+ "committed",
44
+ "revoked",
45
+ "expired",
46
+ ]);
47
+ export const DEFAULT_HANDOFF_TTL_MS = 5 * 60 * 1000;
48
+ export const MAX_HANDOFF_TTL_MS = 24 * 60 * 60 * 1000;
49
+ export const MAX_HANDOFF_HISTORY = 64;
50
+
51
+ const HANDOFF_ACTIVE_STATUSES = new Set(["offered", "accepted"]);
52
+ const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/u;
53
+ const SAFE_HANDOFF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/u;
54
+
55
+ function stableValue(value) {
56
+ if (Array.isArray(value)) return value.map(stableValue);
57
+ if (!value || typeof value !== "object") return value;
58
+ return Object.fromEntries(
59
+ Object.keys(value)
60
+ .sort()
61
+ .map((key) => [key, stableValue(value[key])]),
62
+ );
63
+ }
64
+
65
+ function digestValue(value) {
66
+ return `sha256:${createHash("sha256")
67
+ .update(JSON.stringify(stableValue(value)), "utf8")
68
+ .digest("hex")}`;
69
+ }
70
+
71
+ function cloneValue(value) {
72
+ return value == null ? value : JSON.parse(JSON.stringify(value));
73
+ }
74
+
75
+ function handoffHistory(task) {
76
+ return Array.isArray(task?.metadata?.custodyHandoffs)
77
+ ? task.metadata.custodyHandoffs.map((handoff) => cloneValue(handoff))
78
+ : [];
79
+ }
80
+
81
+ function currentCommittedHandoff(task) {
82
+ return [...handoffHistory(task)]
83
+ .reverse()
84
+ .find((handoff) => handoff?.status === "committed");
85
+ }
86
+
87
+ function settleHandoffHistory(task, holder, leaseId, settlement, now) {
88
+ const history = handoffHistory(task);
89
+ const handoff = [...history]
90
+ .reverse()
91
+ .find(
92
+ (entry) =>
93
+ entry?.status === "committed" &&
94
+ entry.toHolder === holder &&
95
+ entry.targetLease?.leaseId === leaseId,
96
+ );
97
+ if (!handoff || handoff.targetSettledAt != null) return null;
98
+ handoff.targetSettledAt = now;
99
+ handoff.targetSettlement = String(settlement || "unknown").slice(0, 128);
100
+ handoff.updatedAt = now;
101
+ return history;
102
+ }
103
+
104
+ function refreshHandoffTargetLease(task, lease, now) {
105
+ if (!lease?.handoffId) return null;
106
+ const history = handoffHistory(task);
107
+ const handoff = [...history]
108
+ .reverse()
109
+ .find(
110
+ (entry) =>
111
+ entry?.id === lease.handoffId &&
112
+ entry.status === "committed" &&
113
+ entry.toHolder === lease.holder &&
114
+ entry.targetSettledAt == null,
115
+ );
116
+ if (!handoff) return null;
117
+ handoff.targetLease = cloneValue(lease);
118
+ handoff.targetLeaseRefreshedAt = now;
119
+ handoff.updatedAt = now;
120
+ return history;
121
+ }
122
+
123
+ function normalizedHandoffId(value, createId = randomUUID) {
124
+ const id = value == null ? createId() : String(value).trim();
125
+ return SAFE_HANDOFF_ID_PATTERN.test(id) ? id : null;
126
+ }
127
+
128
+ function normalizedHolder(value) {
129
+ const holder = String(value || "").trim();
130
+ return SAFE_HANDOFF_ID_PATTERN.test(holder) ? holder : null;
131
+ }
132
+
133
+ function normalizedDigest(value) {
134
+ const digest = String(value || "").trim();
135
+ return SHA256_PATTERN.test(digest) ? digest : null;
136
+ }
137
+
138
+ function normalizedArtifactIds(value) {
139
+ if (value == null) return [];
140
+ if (!Array.isArray(value) || value.length > 64) return null;
141
+ const ids = value.map((entry) => String(entry || "").trim());
142
+ if (
143
+ ids.some((id) => !SAFE_HANDOFF_ID_PATTERN.test(id)) ||
144
+ new Set(ids).size !== ids.length
145
+ ) {
146
+ return null;
147
+ }
148
+ return ids;
149
+ }
150
+
37
151
  export class TaskLeaseRegistry {
38
152
  constructor({
39
153
  groupId = null,
@@ -316,6 +430,15 @@ export class TaskLeaseRegistry {
316
430
  ) {
317
431
  return { ok: false, reason: "terminal" };
318
432
  }
433
+ const committedCustody = currentCommittedHandoff(task);
434
+ if (committedCustody && committedCustody.toHolder !== holder) {
435
+ return {
436
+ ok: false,
437
+ reason: "custody_transferred",
438
+ holder: committedCustody.toHolder,
439
+ handoffId: committedCustody.id,
440
+ };
441
+ }
319
442
  const unmet = this.unmetDependencies(key);
320
443
  if (unmet.length > 0) {
321
444
  return { ok: false, reason: "blocked_by_deps", unmet };
@@ -337,18 +460,34 @@ export class TaskLeaseRegistry {
337
460
  }
338
461
  const ttl = ttlMs > 0 ? ttlMs : this.defaultTtlMs;
339
462
  const renewing = existingValid;
463
+ const nextLeaseId = renewing ? existing.leaseId : this._nextLeaseId();
340
464
  const lease = {
341
465
  holder,
342
- leaseId: renewing ? existing.leaseId : this._nextLeaseId(),
466
+ leaseId: nextLeaseId,
467
+ fencingToken: renewing
468
+ ? (existing.fencingToken ?? existing.leaseId)
469
+ : nextLeaseId,
343
470
  acquiredAt: renewing ? existing.acquiredAt : now,
344
471
  expiresAt: now + ttl,
345
472
  renewals: renewing ? (existing.renewals || 0) + 1 : 0,
346
473
  stolen: !renewing && !!existing,
474
+ ...(!renewing && existing?.handoffId
475
+ ? {
476
+ handoffId: existing.handoffId,
477
+ transferredFromLeaseId: existing.leaseId,
478
+ recovered: true,
479
+ }
480
+ : {}),
347
481
  };
482
+ const custodyHandoffs = refreshHandoffTargetLease(task, lease, now);
348
483
  const ok = this._write(task, {
349
484
  status: TASK_STATUS.IN_PROGRESS,
350
485
  assignee: holder,
351
- metadata: { ...task.metadata, lease },
486
+ metadata: {
487
+ ...task.metadata,
488
+ lease,
489
+ ...(custodyHandoffs ? { custodyHandoffs } : {}),
490
+ },
352
491
  });
353
492
  if (!ok) return { ok: false, reason: "concurrent" };
354
493
  this._removeReady(key);
@@ -356,6 +495,516 @@ export class TaskLeaseRegistry {
356
495
  return { ok: true, lease };
357
496
  }
358
497
 
498
+ /**
499
+ * Offer the current task lease to one specific teammate. The immutable
500
+ * revision/authority binding and current lease fence are captured in the same
501
+ * optimistic task write as the state transition, so a snapshot can never
502
+ * contain an offer detached from its source custody.
503
+ */
504
+ offerHandoff(
505
+ key,
506
+ {
507
+ handoffId = null,
508
+ holder,
509
+ leaseId,
510
+ toHolder,
511
+ revisionDigest,
512
+ authorityDigest,
513
+ artifactIds = [],
514
+ preconditions = null,
515
+ summary = null,
516
+ ttlMs = DEFAULT_HANDOFF_TTL_MS,
517
+ idempotencyKey = null,
518
+ now = this._now(),
519
+ } = {},
520
+ ) {
521
+ const id = normalizedHandoffId(handoffId);
522
+ const source = normalizedHolder(holder);
523
+ const recipient = normalizedHolder(toHolder);
524
+ const revision = normalizedDigest(revisionDigest);
525
+ const authority = normalizedDigest(authorityDigest);
526
+ const artifacts = normalizedArtifactIds(artifactIds);
527
+ if (!id) return { ok: false, reason: "invalid_handoff_id" };
528
+ if (!source || !recipient || source === recipient) {
529
+ return { ok: false, reason: "invalid_recipient" };
530
+ }
531
+ if (!revision) return { ok: false, reason: "invalid_revision_digest" };
532
+ if (!authority) return { ok: false, reason: "invalid_authority_digest" };
533
+ if (!artifacts) return { ok: false, reason: "invalid_artifact_ids" };
534
+ const ttl = Number(ttlMs);
535
+ if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > MAX_HANDOFF_TTL_MS) {
536
+ return { ok: false, reason: "invalid_ttl" };
537
+ }
538
+ const idempotency =
539
+ idempotencyKey == null
540
+ ? id
541
+ : normalizedHandoffId(idempotencyKey, () => id);
542
+ if (!idempotency) {
543
+ return { ok: false, reason: "invalid_idempotency_key" };
544
+ }
545
+ let cleanSummary;
546
+ let cleanPreconditions;
547
+ try {
548
+ cleanSummary = cloneValue(summary);
549
+ cleanPreconditions = cloneValue(preconditions);
550
+ } catch {
551
+ return { ok: false, reason: "invalid_payload" };
552
+ }
553
+ const taskId = this._byKey.get(key);
554
+ const task = taskId ? this._tasks.get(taskId) : null;
555
+ if (!task) return { ok: false, reason: "not_found" };
556
+ if (
557
+ task.status === TASK_STATUS.COMPLETED ||
558
+ task.status === TASK_STATUS.CANCELLED
559
+ ) {
560
+ return { ok: false, reason: "terminal" };
561
+ }
562
+ const lease = task.metadata?.lease || null;
563
+ if (!this._leaseOwned(lease, { holder: source, leaseId, now })) {
564
+ return { ok: false, reason: "not_holder_or_expired" };
565
+ }
566
+ const history = handoffHistory(task);
567
+ let changedByExpiry = false;
568
+ for (const handoff of history) {
569
+ if (
570
+ HANDOFF_ACTIVE_STATUSES.has(handoff.status) &&
571
+ handoff.expiresAtMs <= now
572
+ ) {
573
+ handoff.status = "expired";
574
+ handoff.expiredAt = now;
575
+ handoff.updatedAt = now;
576
+ changedByExpiry = true;
577
+ }
578
+ }
579
+ const binding = {
580
+ schema: TEAM_CUSTODY_HANDOFF_SCHEMA,
581
+ id,
582
+ taskKey: key,
583
+ fromHolder: source,
584
+ fromLeaseId: lease.leaseId,
585
+ fromFence: lease.fencingToken ?? lease.leaseId,
586
+ toHolder: recipient,
587
+ revisionDigest: revision,
588
+ authorityDigest: authority,
589
+ artifactIds: artifacts,
590
+ preconditions: cleanPreconditions,
591
+ summary: cleanSummary,
592
+ idempotencyKey: idempotency,
593
+ ttlMs: ttl,
594
+ };
595
+ const bindingDigest = digestValue(binding);
596
+ const duplicate = history.find((handoff) => handoff.id === id);
597
+ if (duplicate) {
598
+ if (duplicate.bindingDigest !== bindingDigest) {
599
+ return { ok: false, reason: "handoff_id_conflict" };
600
+ }
601
+ if (changedByExpiry) {
602
+ const written = this._write(task, {
603
+ metadata: { ...task.metadata, custodyHandoffs: history },
604
+ });
605
+ if (!written) return { ok: false, reason: "concurrent" };
606
+ }
607
+ return { ok: true, idempotent: true, handoff: cloneValue(duplicate) };
608
+ }
609
+ const active = history.find((handoff) =>
610
+ HANDOFF_ACTIVE_STATUSES.has(handoff.status),
611
+ );
612
+ if (active) {
613
+ return {
614
+ ok: false,
615
+ reason: "handoff_active",
616
+ handoffId: active.id,
617
+ };
618
+ }
619
+ const handoff = {
620
+ ...binding,
621
+ bindingDigest,
622
+ expiresAtMs: now + ttl,
623
+ status: "offered",
624
+ offeredAt: now,
625
+ acceptedAt: null,
626
+ rejectedAt: null,
627
+ committedAt: null,
628
+ revokedAt: null,
629
+ expiredAt: null,
630
+ targetStartedAt: null,
631
+ targetSettledAt: null,
632
+ targetSettlement: null,
633
+ reason: null,
634
+ acceptedByAttempt: null,
635
+ targetLease: null,
636
+ updatedAt: now,
637
+ };
638
+ history.push(handoff);
639
+ while (history.length > MAX_HANDOFF_HISTORY) history.shift();
640
+ const ok = this._write(task, {
641
+ metadata: { ...task.metadata, custodyHandoffs: history },
642
+ });
643
+ return ok
644
+ ? { ok: true, handoff: cloneValue(handoff) }
645
+ : { ok: false, reason: "concurrent" };
646
+ }
647
+
648
+ /** Return one handoff with its owning task, expiring overdue offers first. */
649
+ findHandoff(handoffId, { now = this._now() } = {}) {
650
+ const id = normalizedHandoffId(handoffId, () => null);
651
+ if (!id) return null;
652
+ this.expireHandoffs({ now });
653
+ for (const task of this.list()) {
654
+ const handoff = handoffHistory(task).find((entry) => entry.id === id);
655
+ if (handoff) return { key: task.key, handoff };
656
+ }
657
+ return null;
658
+ }
659
+
660
+ listHandoffs({ now = this._now() } = {}) {
661
+ this.expireHandoffs({ now });
662
+ return this.list().flatMap((task) =>
663
+ handoffHistory(task).map((handoff) => ({
664
+ key: task.key,
665
+ handoff,
666
+ })),
667
+ );
668
+ }
669
+
670
+ expireHandoffs({ now = this._now() } = {}) {
671
+ const expired = [];
672
+ for (const taskView of this.list()) {
673
+ const taskId = this._byKey.get(taskView.key);
674
+ const task = taskId ? this._tasks.get(taskId) : null;
675
+ if (!task) continue;
676
+ const history = handoffHistory(task);
677
+ let changed = false;
678
+ for (const handoff of history) {
679
+ if (
680
+ HANDOFF_ACTIVE_STATUSES.has(handoff.status) &&
681
+ handoff.expiresAtMs <= now
682
+ ) {
683
+ handoff.status = "expired";
684
+ handoff.expiredAt = now;
685
+ handoff.updatedAt = now;
686
+ expired.push(cloneValue(handoff));
687
+ changed = true;
688
+ }
689
+ }
690
+ if (
691
+ changed &&
692
+ !this._write(task, {
693
+ metadata: { ...task.metadata, custodyHandoffs: history },
694
+ })
695
+ ) {
696
+ return { ok: false, reason: "concurrent", expired };
697
+ }
698
+ }
699
+ return { ok: true, expired };
700
+ }
701
+
702
+ acceptHandoff(
703
+ handoffId,
704
+ { holder, recipientAttempt = null, now = this._now() } = {},
705
+ ) {
706
+ const found = this.findHandoff(handoffId, { now });
707
+ if (!found) return { ok: false, reason: "not_found" };
708
+ const taskId = this._byKey.get(found.key);
709
+ const task = taskId ? this._tasks.get(taskId) : null;
710
+ if (!task) return { ok: false, reason: "not_found" };
711
+ const history = handoffHistory(task);
712
+ const index = history.findIndex((entry) => entry.id === found.handoff.id);
713
+ const handoff = history[index];
714
+ if (handoff.toHolder !== holder) {
715
+ return { ok: false, reason: "wrong_recipient" };
716
+ }
717
+ if (handoff.status === "accepted") {
718
+ return { ok: true, idempotent: true, handoff: cloneValue(handoff) };
719
+ }
720
+ if (handoff.status !== "offered") {
721
+ return { ok: false, reason: `handoff_${handoff.status}` };
722
+ }
723
+ handoff.status = "accepted";
724
+ handoff.acceptedAt = now;
725
+ handoff.acceptedByAttempt = cloneValue(recipientAttempt);
726
+ handoff.updatedAt = now;
727
+ const ok = this._write(task, {
728
+ metadata: { ...task.metadata, custodyHandoffs: history },
729
+ });
730
+ return ok
731
+ ? { ok: true, key: found.key, handoff: cloneValue(handoff) }
732
+ : { ok: false, reason: "concurrent" };
733
+ }
734
+
735
+ rejectHandoff(
736
+ handoffId,
737
+ { holder, reason = "rejected", now = this._now() } = {},
738
+ ) {
739
+ const found = this.findHandoff(handoffId, { now });
740
+ if (!found) return { ok: false, reason: "not_found" };
741
+ const taskId = this._byKey.get(found.key);
742
+ const task = taskId ? this._tasks.get(taskId) : null;
743
+ if (!task) return { ok: false, reason: "not_found" };
744
+ const history = handoffHistory(task);
745
+ const handoff = history.find((entry) => entry.id === found.handoff.id);
746
+ if (handoff.toHolder !== holder) {
747
+ return { ok: false, reason: "wrong_recipient" };
748
+ }
749
+ if (handoff.status === "rejected") {
750
+ return { ok: true, idempotent: true, handoff: cloneValue(handoff) };
751
+ }
752
+ if (handoff.status !== "offered") {
753
+ return { ok: false, reason: `handoff_${handoff.status}` };
754
+ }
755
+ handoff.status = "rejected";
756
+ handoff.rejectedAt = now;
757
+ handoff.reason = String(reason || "rejected").slice(0, 1024);
758
+ handoff.updatedAt = now;
759
+ const ok = this._write(task, {
760
+ metadata: { ...task.metadata, custodyHandoffs: history },
761
+ });
762
+ return ok
763
+ ? { ok: true, key: found.key, handoff: cloneValue(handoff) }
764
+ : { ok: false, reason: "concurrent" };
765
+ }
766
+
767
+ commitHandoff(handoffId, { holder, leaseId, ttlMs, now = this._now() } = {}) {
768
+ const found = this.findHandoff(handoffId, { now });
769
+ if (!found) return { ok: false, reason: "not_found" };
770
+ const taskId = this._byKey.get(found.key);
771
+ const task = taskId ? this._tasks.get(taskId) : null;
772
+ if (!task) return { ok: false, reason: "not_found" };
773
+ const history = handoffHistory(task);
774
+ const handoff = history.find((entry) => entry.id === found.handoff.id);
775
+ if (
776
+ handoff.status === "committed" &&
777
+ handoff.fromHolder === holder &&
778
+ handoff.fromLeaseId === leaseId
779
+ ) {
780
+ const current = currentCommittedHandoff(task);
781
+ if (current?.id !== handoff.id) {
782
+ return { ok: false, reason: "handoff_superseded" };
783
+ }
784
+ return {
785
+ ok: true,
786
+ idempotent: true,
787
+ key: found.key,
788
+ handoff: cloneValue(handoff),
789
+ lease: cloneValue(handoff.targetLease),
790
+ };
791
+ }
792
+ if (handoff.status !== "accepted") {
793
+ return { ok: false, reason: `handoff_${handoff.status}` };
794
+ }
795
+ const lease = task.metadata?.lease || null;
796
+ if (!this._leaseOwned(lease, { holder, leaseId, now })) {
797
+ return { ok: false, reason: "not_holder_or_expired" };
798
+ }
799
+ if (
800
+ handoff.fromHolder !== holder ||
801
+ handoff.fromLeaseId !== leaseId ||
802
+ handoff.fromFence !== (lease.fencingToken ?? lease.leaseId)
803
+ ) {
804
+ return { ok: false, reason: "source_binding_changed" };
805
+ }
806
+ const ttl = ttlMs > 0 ? ttlMs : this.defaultTtlMs;
807
+ const targetLeaseId = this._nextLeaseId();
808
+ const targetLease = {
809
+ holder: handoff.toHolder,
810
+ leaseId: targetLeaseId,
811
+ fencingToken: targetLeaseId,
812
+ acquiredAt: now,
813
+ expiresAt: now + ttl,
814
+ renewals: 0,
815
+ stolen: false,
816
+ handoffId: handoff.id,
817
+ transferredFromLeaseId: lease.leaseId,
818
+ };
819
+ handoff.status = "committed";
820
+ handoff.committedAt = now;
821
+ handoff.targetLease = cloneValue(targetLease);
822
+ handoff.updatedAt = now;
823
+ const ok = this._write(task, {
824
+ status: TASK_STATUS.IN_PROGRESS,
825
+ assignee: handoff.toHolder,
826
+ metadata: {
827
+ ...task.metadata,
828
+ lease: targetLease,
829
+ custodyHandoffs: history,
830
+ },
831
+ });
832
+ if (!ok) return { ok: false, reason: "concurrent" };
833
+ this._removeReady(found.key);
834
+ this._leasedKeys.add(found.key);
835
+ return {
836
+ ok: true,
837
+ key: found.key,
838
+ handoff: cloneValue(handoff),
839
+ lease: cloneValue(targetLease),
840
+ };
841
+ }
842
+
843
+ revokeHandoff(
844
+ handoffId,
845
+ { holder, leaseId, reason = "revoked", now = this._now() } = {},
846
+ ) {
847
+ const found = this.findHandoff(handoffId, { now });
848
+ if (!found) return { ok: false, reason: "not_found" };
849
+ const taskId = this._byKey.get(found.key);
850
+ const task = taskId ? this._tasks.get(taskId) : null;
851
+ if (!task) return { ok: false, reason: "not_found" };
852
+ const history = handoffHistory(task);
853
+ const handoff = history.find((entry) => entry.id === found.handoff.id);
854
+ if (handoff.status === "revoked") {
855
+ return { ok: true, idempotent: true, handoff: cloneValue(handoff) };
856
+ }
857
+ if (!HANDOFF_ACTIVE_STATUSES.has(handoff.status)) {
858
+ return { ok: false, reason: `handoff_${handoff.status}` };
859
+ }
860
+ const lease = task.metadata?.lease || null;
861
+ if (
862
+ handoff.fromHolder !== holder ||
863
+ handoff.fromLeaseId !== leaseId ||
864
+ !this._leaseOwned(lease, { holder, leaseId, now })
865
+ ) {
866
+ return { ok: false, reason: "not_holder_or_expired" };
867
+ }
868
+ handoff.status = "revoked";
869
+ handoff.revokedAt = now;
870
+ handoff.reason = String(reason || "revoked").slice(0, 1024);
871
+ handoff.updatedAt = now;
872
+ const ok = this._write(task, {
873
+ metadata: { ...task.metadata, custodyHandoffs: history },
874
+ });
875
+ return ok
876
+ ? { ok: true, key: found.key, handoff: cloneValue(handoff) }
877
+ : { ok: false, reason: "concurrent" };
878
+ }
879
+
880
+ pendingCommittedHandoffs() {
881
+ return this.list().flatMap((task) => {
882
+ const handoff = currentCommittedHandoff(task);
883
+ if (
884
+ !handoff ||
885
+ handoff.targetStartedAt != null ||
886
+ handoff.targetSettledAt != null ||
887
+ [TASK_STATUS.COMPLETED, TASK_STATUS.CANCELLED].includes(task.status)
888
+ ) {
889
+ return [];
890
+ }
891
+ return [{ key: task.key, handoff, lease: cloneValue(task.lease) }];
892
+ });
893
+ }
894
+
895
+ refreshCommittedHandoffLease(
896
+ key,
897
+ { handoffId, holder, ttlMs, now = this._now() } = {},
898
+ ) {
899
+ const taskId = this._byKey.get(key);
900
+ const task = taskId ? this._tasks.get(taskId) : null;
901
+ if (!task) return { ok: false, reason: "not_found" };
902
+ const history = handoffHistory(task);
903
+ const handoff = history.find((entry) => entry.id === handoffId);
904
+ if (
905
+ !handoff ||
906
+ handoff.status !== "committed" ||
907
+ handoff.toHolder !== holder ||
908
+ handoff.targetStartedAt != null ||
909
+ handoff.targetSettledAt != null ||
910
+ [TASK_STATUS.COMPLETED, TASK_STATUS.CANCELLED].includes(task.status)
911
+ ) {
912
+ return { ok: false, reason: "handoff_not_recoverable" };
913
+ }
914
+ const ttl = ttlMs > 0 ? ttlMs : this.defaultTtlMs;
915
+ const leaseId = this._nextLeaseId();
916
+ const lease = {
917
+ holder,
918
+ leaseId,
919
+ fencingToken: leaseId,
920
+ acquiredAt: now,
921
+ expiresAt: now + ttl,
922
+ renewals: 0,
923
+ stolen: false,
924
+ handoffId,
925
+ transferredFromLeaseId: handoff.targetLease?.leaseId || null,
926
+ recovered: true,
927
+ };
928
+ handoff.targetLease = cloneValue(lease);
929
+ handoff.recoveredAt = now;
930
+ handoff.updatedAt = now;
931
+ const ok = this._write(task, {
932
+ status: TASK_STATUS.IN_PROGRESS,
933
+ assignee: holder,
934
+ metadata: { ...task.metadata, lease, custodyHandoffs: history },
935
+ });
936
+ if (!ok) return { ok: false, reason: "concurrent" };
937
+ this._removeReady(key);
938
+ this._leasedKeys.add(key);
939
+ return { ok: true, key, handoff: cloneValue(handoff), lease };
940
+ }
941
+
942
+ markHandoffStarted(
943
+ key,
944
+ { handoffId, holder, leaseId, now = this._now() } = {},
945
+ ) {
946
+ const taskId = this._byKey.get(key);
947
+ const task = taskId ? this._tasks.get(taskId) : null;
948
+ if (!task) return { ok: false, reason: "not_found" };
949
+ const lease = task.metadata?.lease || null;
950
+ if (!this._leaseOwned(lease, { holder, leaseId, now })) {
951
+ return { ok: false, reason: "not_holder_or_expired" };
952
+ }
953
+ const history = handoffHistory(task);
954
+ const handoff = history.find((entry) => entry.id === handoffId);
955
+ if (
956
+ !handoff ||
957
+ handoff.status !== "committed" ||
958
+ handoff.toHolder !== holder ||
959
+ handoff.targetLease?.leaseId !== leaseId
960
+ ) {
961
+ return { ok: false, reason: "handoff_not_committed" };
962
+ }
963
+ if (handoff.targetStartedAt != null) {
964
+ return { ok: true, idempotent: true, handoff: cloneValue(handoff) };
965
+ }
966
+ handoff.targetStartedAt = now;
967
+ handoff.updatedAt = now;
968
+ const ok = this._write(task, {
969
+ metadata: { ...task.metadata, custodyHandoffs: history },
970
+ });
971
+ return ok
972
+ ? { ok: true, handoff: cloneValue(handoff) }
973
+ : { ok: false, reason: "concurrent" };
974
+ }
975
+
976
+ settleCommittedHandoff(
977
+ key,
978
+ { handoffId, holder, leaseId, settlement, now = this._now() } = {},
979
+ ) {
980
+ const taskId = this._byKey.get(key);
981
+ const task = taskId ? this._tasks.get(taskId) : null;
982
+ if (!task) return { ok: false, reason: "not_found" };
983
+ const history = handoffHistory(task);
984
+ const handoff = history.find((entry) => entry.id === handoffId);
985
+ if (!handoff || handoff.status !== "committed") {
986
+ return { ok: false, reason: "handoff_not_committed" };
987
+ }
988
+ if (
989
+ handoff.toHolder !== holder ||
990
+ handoff.targetLease?.leaseId !== leaseId
991
+ ) {
992
+ return { ok: false, reason: "target_binding_changed" };
993
+ }
994
+ if (handoff.targetSettledAt != null) {
995
+ return { ok: true, idempotent: true, handoff: cloneValue(handoff) };
996
+ }
997
+ handoff.targetSettledAt = now;
998
+ handoff.targetSettlement = String(settlement || "unknown").slice(0, 128);
999
+ handoff.updatedAt = now;
1000
+ const ok = this._write(task, {
1001
+ metadata: { ...task.metadata, custodyHandoffs: history },
1002
+ });
1003
+ return ok
1004
+ ? { ok: true, handoff: cloneValue(handoff) }
1005
+ : { ok: false, reason: "concurrent" };
1006
+ }
1007
+
359
1008
  /** Extend the lease you hold. Fails if you're not the current valid holder. */
360
1009
  renew(key, { holder, leaseId, ttlMs, now = this._now() } = {}) {
361
1010
  const id = this._byKey.get(key);
@@ -407,9 +1056,21 @@ export class TaskLeaseRegistry {
407
1056
  if (!this._leaseOwned(lease, { holder, leaseId, now })) {
408
1057
  return { ok: false, reason: "not_holder_or_expired" };
409
1058
  }
1059
+ const custodyHandoffs = settleHandoffHistory(
1060
+ task,
1061
+ holder,
1062
+ leaseId,
1063
+ "completed",
1064
+ now,
1065
+ );
410
1066
  const ok = this._write(task, {
411
1067
  status: TASK_STATUS.COMPLETED,
412
- metadata: { ...task.metadata, lease: null, result },
1068
+ metadata: {
1069
+ ...task.metadata,
1070
+ lease: null,
1071
+ result,
1072
+ ...(custodyHandoffs ? { custodyHandoffs } : {}),
1073
+ },
413
1074
  });
414
1075
  if (!ok) return { ok: false, reason: "concurrent" };
415
1076
  this._leasedKeys.delete(key);
@@ -463,6 +1124,13 @@ export class TaskLeaseRegistry {
463
1124
  decision: null,
464
1125
  }
465
1126
  : task.metadata?.adjudication || null;
1127
+ const custodyHandoffs = settleHandoffHistory(
1128
+ task,
1129
+ holder,
1130
+ leaseId,
1131
+ willRetry ? "retry" : "failed",
1132
+ now,
1133
+ );
466
1134
  const ok = this._write(task, {
467
1135
  status: willRetry ? TASK_STATUS.PENDING : TASK_STATUS.CANCELLED,
468
1136
  assignee: null,
@@ -472,6 +1140,7 @@ export class TaskLeaseRegistry {
472
1140
  attempts,
473
1141
  lastError: error ? String(error) : null,
474
1142
  ...(adjudicationRequest ? { adjudication: adjudicationRequest } : {}),
1143
+ ...(custodyHandoffs ? { custodyHandoffs } : {}),
475
1144
  },
476
1145
  });
477
1146
  if (!ok) return { ok: false, reason: "concurrent" };
@@ -490,13 +1159,29 @@ export class TaskLeaseRegistry {
490
1159
  const reclaimed = [];
491
1160
  for (const t of this._tasks.list()) {
492
1161
  const lease = t.metadata?.lease || null;
1162
+ const custody = currentCommittedHandoff(t);
493
1163
  if (lease && !this._leaseValid(lease, now)) {
1164
+ // A committed but not-yet-started transfer is a durable dispatch
1165
+ // journal entry. The designated recipient must recover it with a fresh
1166
+ // fence; generic expiry reclamation may not erase or steal custody.
1167
+ if (custody && custody.targetStartedAt == null) continue;
494
1168
  const fresh = this._tasks.get(t.id); // re-read for current rev
1169
+ const custodyHandoffs = settleHandoffHistory(
1170
+ fresh,
1171
+ lease.holder,
1172
+ lease.leaseId,
1173
+ "lease_expired_retry",
1174
+ now,
1175
+ );
495
1176
  if (
496
1177
  this._write(fresh, {
497
1178
  status: TASK_STATUS.PENDING,
498
1179
  assignee: null,
499
- metadata: { ...fresh.metadata, lease: null },
1180
+ metadata: {
1181
+ ...fresh.metadata,
1182
+ lease: null,
1183
+ ...(custodyHandoffs ? { custodyHandoffs } : {}),
1184
+ },
500
1185
  })
501
1186
  ) {
502
1187
  this._leasedKeys.delete(fresh.metadata?.key);
@@ -540,6 +1225,14 @@ export class TaskLeaseRegistry {
540
1225
  for (const t of this._tasks.list()) {
541
1226
  const lease = t.metadata?.lease || null;
542
1227
  if (!lease) continue;
1228
+ const custody = currentCommittedHandoff(t);
1229
+ if (
1230
+ custody &&
1231
+ custody.targetStartedAt == null &&
1232
+ custody.targetSettledAt == null
1233
+ ) {
1234
+ continue;
1235
+ }
543
1236
  candidates.push({
544
1237
  key: t.metadata?.key,
545
1238
  retry: shouldRetry(t) === true,
@@ -552,9 +1245,19 @@ export class TaskLeaseRegistry {
552
1245
  const id = this._byKey.get(candidate.key);
553
1246
  const fresh = id ? this._tasks.get(id) : null;
554
1247
  if (!fresh?.metadata?.lease) continue;
1248
+ const lease = fresh.metadata.lease;
1249
+ const now = this._now();
1250
+ const custodyHandoffs = settleHandoffHistory(
1251
+ fresh,
1252
+ lease.holder,
1253
+ lease.leaseId,
1254
+ candidate.retry ? "recovery_retry" : "recovery_adjudication",
1255
+ now,
1256
+ );
555
1257
  const metadata = {
556
1258
  ...fresh.metadata,
557
1259
  lease: null,
1260
+ ...(custodyHandoffs ? { custodyHandoffs } : {}),
558
1261
  };
559
1262
  if (!candidate.retry) {
560
1263
  metadata.attempts = (fresh.metadata?.attempts || 0) + 1;
@@ -564,7 +1267,7 @@ export class TaskLeaseRegistry {
564
1267
  code: "TEAM_TASK_ABANDONED_ADJUDICATION_REQUIRED",
565
1268
  reason: String(error),
566
1269
  evidenceDigest: null,
567
- requestedAt: this._now(),
1270
+ requestedAt: now,
568
1271
  decision: null,
569
1272
  };
570
1273
  }
@@ -975,6 +1678,15 @@ export class TaskLeaseRegistry {
975
1678
  this._leasedKeys.delete(key);
976
1679
  return false;
977
1680
  }
1681
+ const custody = currentCommittedHandoff(task);
1682
+ if (custody && custody.targetSettledAt == null) {
1683
+ // A committed transfer is a durable dispatch journal, including after
1684
+ // its target lease expires. It is recovered or adjudicated explicitly;
1685
+ // the generic ready frontier must never expose it for lease stealing.
1686
+ this._removeReady(key);
1687
+ this._leasedKeys.add(key);
1688
+ return false;
1689
+ }
978
1690
  if (this._leaseValid(task.lease, now)) {
979
1691
  this._removeReady(key);
980
1692
  this._leasedKeys.add(key);