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.
- package/package.json +1 -1
- package/src/assets/web-panel/.build-hash +1 -1
- package/src/assets/web-panel/assets/{AIOps-BnZ24WRk.js → AIOps-HcOh0ZbH.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-ivXY7MGb.js → ActionButton-DWjDqBhd.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-BQS4OD-k.js → Analytics-C6FS-y3W.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-wOBuOKTO.js → AppLayout-DCN5cldF.js} +5 -5
- package/src/assets/web-panel/assets/{Artifacts-BYuC4nw-.js → Artifacts-Crd-KE8P.js} +1 -1
- package/src/assets/web-panel/assets/{Audit-BIeVfBt6.js → Audit-BexuTQZc.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-hIuyhmJ7.js → BackgroundAgents-Dydkh-b5.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-B6ohdgPQ.js → Backup-BYq21wMP.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-BzgYCw6h.js → BaseInput-CmNZP6yM.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-CwahvZU1.js → Chat-J6bD0zCl.js} +6 -6
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-B-1IZ_k8.js → ChatBubbleRenderer-MEoaygnL.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-26EDP2x0.js → Checkbox-Ce-9vNmK.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-DYLw1Zvd.js → Codegen-zf7-Y-Eh.js} +1 -1
- package/src/assets/web-panel/assets/{Col-DH_zAvD-.js → Col-D-TkY0FD.js} +1 -1
- package/src/assets/web-panel/assets/{Community-DURrTXJu.js → Community-l300NA-1.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-CQyBqN0p.js → Compact-D-9Q_hxc.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-a0DfgKHD.js → Compliance-DAJ8lLiQ.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-C6fY_5Ke.js → Cowork-xmzZ967S.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-DOwmpbTe.js → Cron-CeGqO5W4.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-DEPOIEUs.js → Crosschain-DXN0AE-4.js} +1 -1
- package/src/assets/web-panel/assets/{DID-DaKc-CEV.js → DID-CtIKASPr.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-C-0KLXq5.js → Dashboard-DqRwT8NX.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-CnsDukzr.js → Dropdown-ddIVljO1.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-Bi_ACD4N.js → EmailListRenderer-CcsFzV4y.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-BZASpnx3.js → FamilyGuardDashboard-PnlAUGcG.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-43oTtzyp.js → Federation-BlmLuQrL.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-oyVadnkZ.js → FormItemContext-dDovBj1Y.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-Cwvfd6TZ.js → GenericCardRenderer-DTvGshFr.js} +1 -1
- package/src/assets/web-panel/assets/{Git-Cgi2wvU0.js → Git-DuFR2xgA.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-l8TWDKZ0.js → Governance-9EaNcJuq.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-Dc8j6TaO.js → Inference-B-iYWH_r.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-DgcO-Fwz.js → KnowledgeGraph-BGBaKiLX.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-DjiZpiwa.js → Logs-G3wmXzfa.js} +2 -2
- package/src/assets/web-panel/assets/{MarkdownRenderer-B5MFCi0N.js → MarkdownRenderer-hk0YwjTR.js} +1 -1
- package/src/assets/web-panel/assets/{Marketplace-BeDn1hFc.js → Marketplace-BNARBeAv.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-PGdVfWK6.js → McpTools-DST2cSSK.js} +3 -3
- package/src/assets/web-panel/assets/{Memory-e76lY3a1.js → Memory-piwDLsVH.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-DKlQqot_.js → MobileBridge-D-W8zHdm.js} +1 -1
- package/src/assets/web-panel/assets/{MobileProjects-C4g6WOEr.js → MobileProjects-CwaYHlVP.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-1j3NpRQq.js → Mtc-C2dxnN7Q.js} +4 -4
- package/src/assets/web-panel/assets/{MtcAudit-DgwiVd1C.js → MtcAudit-BwPZsV3N.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-Dzg5lZZY.js → Multisig-CUt8w2al.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-D7GYhst-.js → NLProgramming-DwxErx27.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-Blcfvaic.js → Notes-B-mXAZ84.js} +3 -3
- package/src/assets/web-panel/assets/{NotificationSettings-Dc4Y6vfr.js → NotificationSettings-Bd1fBAzd.js} +1 -1
- package/src/assets/web-panel/assets/OrderTableRenderer-CCInnavw.js +1 -0
- package/src/assets/web-panel/assets/{Organization-DBXq024L.js → Organization-DTTONYZT.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-CFPbdx1Y.js → Overflow-H6lCZ1rE.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-w7z61NR5.js → P2P-DyBZzeYW.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-B7eGGZOa.js → PdhVaultBrowser-jj7LhT04.js} +3 -3
- package/src/assets/web-panel/assets/{Permissions-Df5Cl50D.js → Permissions-B5nSQwsr.js} +4 -4
- package/src/assets/web-panel/assets/{PersonalDataHub-C-We7RF6.js → PersonalDataHub-CACaQtlY.js} +5 -5
- package/src/assets/web-panel/assets/{Pipeline-B6fHPnKy.js → Pipeline-5y-DR_Bw.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-B85BdiIc.js → Privacy-BMSA3Ehd.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-CcOyazSe.js → ProjectInit-VCOj1dTa.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-BE53KfuU.js → ProjectSettings-CJaXO_iE.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-DOvMOoe_.js → Projects-C21-MQ3H.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-5l7sT9lq.js → Providers-DZ1FNKUR.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-DE37RXy4.js → QrScannerModal-6DKc9UBX.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-BGmGCo7j.js → QuickAsk-Dyd0Ckrk.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-BFdx8Nrm.js → Recommend-CYW4-GJH.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-C1w-j_hB.js → RemoteSession-DLrfPy4F.js} +3 -3
- package/src/assets/web-panel/assets/{Reputation-BOvn-10y.js → Reputation-DfqPo1FI.js} +1 -1
- package/src/assets/web-panel/assets/{Row-db-a1YO9.js → Row-CR89H2x5.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-DeS43ZGM.js → RssFeed-B9QY7R-F.js} +3 -3
- package/src/assets/web-panel/assets/{Search-BaQYNfeb.js → Search-CUDnzu4w.js} +1 -1
- package/src/assets/web-panel/assets/{Security-yt1ESHDg.js → Security-CJ0CtM_Z.js} +3 -3
- package/src/assets/web-panel/assets/{Services-BcNqPUsS.js → Services-CyrQ6F5U.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-X3ceUQWf.js → Skeleton-DcwnKatQ.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-DmYeax6C.js → Skills-BhKkYF3_.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-C_fdfbuJ.js → Sla-CWh-awh7.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-CDqohXXQ.js → SpeechSettings-BaUMNzx7.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-B25maYEY.js → SyncSettings-D1Ijwwxl.js} +2 -2
- package/src/assets/web-panel/assets/Tasks-DFqjHi3T.js +1 -0
- package/src/assets/web-panel/assets/{Templates-DUH0iuc4.js → Templates-Ct-9_ki3.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-DrvhHf9T.js → Tenant-zBkgyilh.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-C2lJVYHA.js → Terminal-VVO2Hjfi.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-C1Hf_jYn.js → TimelineRenderer-CX9pIkx3.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-DniS1NpS.js → Tokens-DHaD6e_m.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-4jrcb8oA.js → Trigger-CFq9j3WV.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-DiBte55w.js → Trust-DuF7gIYX.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-CZRtc4VB.js → UkeySign-DbuidXJX.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-BtYGJDmg.js → VideoEditing-B8IA9_qE.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-D1nrTm7H.js → Wallet-DdCglSIK.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-DI91acpx.js → WebAuthn-CeteRsA8.js} +5 -5
- package/src/assets/web-panel/assets/{WorkflowEditor-BtQjI50d.js → WorkflowEditor-DJhIbG__.js} +1 -1
- package/src/assets/web-panel/assets/{chat-I4A-f4FC.js → chat-srOi_Wef.js} +1 -1
- package/src/assets/web-panel/assets/{colors-QZkDkFVt.js → colors-HpVHsNGK.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item--FtCdzpr.js → compact-item-DsLhrqWe.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-CaIKIKxP.js → createContext-aOasIUuE.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-B_acr36n.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-DLYUII6-.js → hasIn-D5BVMimS.js} +1 -1
- package/src/assets/web-panel/assets/{index-DGLHSCHM.js → index-B615KD2o.js} +1 -1
- package/src/assets/web-panel/assets/{index-3rVtgqa2.js → index-BCp70IlO.js} +1 -1
- package/src/assets/web-panel/assets/{index-Deg2S9ZJ.js → index-BD64r0Tx.js} +1 -1
- package/src/assets/web-panel/assets/{index-yayaayEA.js → index-BGBvkNN3.js} +1 -1
- package/src/assets/web-panel/assets/{index-B65QxJaG.js → index-BNbsjeEc.js} +1 -1
- package/src/assets/web-panel/assets/{index-BxPoPofq.js → index-BTwDhhvl.js} +1 -1
- package/src/assets/web-panel/assets/{index-C3PnIEOH.js → index-BigtYchO.js} +1 -1
- package/src/assets/web-panel/assets/index-BnLYpdox.js +1 -0
- package/src/assets/web-panel/assets/{index-BLGJmgmx.js → index-Bu6TVdgQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-CL4c5D6G.js → index-C4tPtHU0.js} +1 -1
- package/src/assets/web-panel/assets/{index-Du2wUavy.js → index-CGVHW0nF.js} +1 -1
- package/src/assets/web-panel/assets/{index-DA_TPdzX.js → index-CQEoGqMG.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ch0IFpGy.js → index-CQXp_uNd.js} +1 -1
- package/src/assets/web-panel/assets/{index-CBtII-nl.js → index-CSxJ4oRd.js} +1 -1
- package/src/assets/web-panel/assets/{index-21I79XSf.js → index-CUUs12L9.js} +1 -1
- package/src/assets/web-panel/assets/{index-DAuf8r5o.js → index-CVp_dHCs.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bs0qPPFb.js → index-ChGUPEwS.js} +1 -1
- package/src/assets/web-panel/assets/{index-DVitI3UQ.js → index-CvhVCHk-.js} +1 -1
- package/src/assets/web-panel/assets/{index-DK2r6Bv7.js → index-D1xZMQrN.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ooo_5jHo.js → index-D2XRXkZU.js} +1 -1
- package/src/assets/web-panel/assets/{index-DJrd_CR3.js → index-DQ5j1VKg.js} +1 -1
- package/src/assets/web-panel/assets/{index-CXaeZCRP.js → index-DRCIPsdm.js} +1 -1
- package/src/assets/web-panel/assets/{index-CQ7OBHka.js → index-DV4y5rUj.js} +1 -1
- package/src/assets/web-panel/assets/{index-eTqL91aV.js → index-DcLBzIiI.js} +1 -1
- package/src/assets/web-panel/assets/{index-CisYjy88.js → index-DiXsis7m.js} +1 -1
- package/src/assets/web-panel/assets/{index-D4CXFU-w.js → index-Dpj-rEoV.js} +1 -1
- package/src/assets/web-panel/assets/{index-BebTT7iR.js → index-Dum_r_oY.js} +1 -1
- package/src/assets/web-panel/assets/index-Dzmwsl_v.js +1 -0
- package/src/assets/web-panel/assets/{index-vfiDxdN2.js → index-LSz0ZOB8.js} +1 -1
- package/src/assets/web-panel/assets/{index-Ygi2rYL1.js → index-Ow3SRXRX.js} +1 -1
- package/src/assets/web-panel/assets/{index-hfiZ-i2b.js → index-P6IMLmLT.js} +1 -1
- package/src/assets/web-panel/assets/{index-CgMm85BY.js → index-V0gpVTbS.js} +1 -1
- package/src/assets/web-panel/assets/{index-0DYBQ-6e.js → index-b0Y0hDhX.js} +1 -1
- package/src/assets/web-panel/assets/{index-BGWSmE3G.js → index-dPFzlII1.js} +1 -1
- package/src/assets/web-panel/assets/{index-OQPIS_am.js → index-fZnlPk7H.js} +1 -1
- package/src/assets/web-panel/assets/{index-BkJgWW_L.js → index-kYqi6zYp.js} +1 -1
- package/src/assets/web-panel/assets/{index-BvdwNnHC.js → index-lFOfvZDn.js} +3 -3
- package/src/assets/web-panel/assets/{index-BcgtvJbC.js → index-q--LtRh9.js} +1 -1
- package/src/assets/web-panel/assets/{index-D2xETCkj.js → index-vQ6jy8Ew.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-DDkTvw8k.js → initDefaultProps-1NU9yNpr.js} +1 -1
- package/src/assets/web-panel/assets/{motion-fkr96tN5.js → motion-BZk2hQ6c.js} +1 -1
- package/src/assets/web-panel/assets/{move-CGBV0SV2.js → move-BNvublME.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-DAsCliFt.js → mtc-parser-DWk5BUoL.js} +1 -1
- package/src/assets/web-panel/assets/{omit-BM8PMmKN.js → omit-DztYxX3x.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-C6SdWoTK.js → pickAttrs-DURL8mtg.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BG0EVu-d.js → placementArrow-B820R56z.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-L10IvfYC.js → responsiveObserve-DUAkGJL1.js} +1 -1
- package/src/assets/web-panel/assets/{slide-TddIkbrn.js → slide-uZQeNSo6.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-FlveF2Yn.js → statusUtils-CGR1wFVq.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-BrXRyebg.js → styleChecker-BIIPOMsQ.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-B7_oiDXZ.js → useFlexGapSupport-rIB9c0g2.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-CQYeRYWb.js → useFs-CrJe1BJi.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-DspIkhVi.js → usePersonalDataHub-DyEoD3Qm.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-BCwmwMAW.js → vnode-CjohPxI0.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-B0cEGluV.js → zoom-C5POL1_6.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/commands/team.js +165 -53
- package/src/data/changelog.json +26 -0
- package/src/gateways/ws/remote-session-protocol.js +20 -15
- package/src/generated/cc-agent-protocol.schema.json +67 -1
- package/src/lib/agent-team/task-lease.js +718 -6
- package/src/lib/agent-team/team-graph-projection.js +303 -0
- package/src/lib/agent-team/team-message-bridge.js +83 -5
- package/src/lib/agent-team/team-message-tools.js +34 -1
- package/src/lib/agent-team/team-runner.js +1131 -9
- package/src/lib/agent-team/team-session-message-adapter.js +720 -0
- package/src/lib/app-server/cli-agent-kernel-adapter.js +1 -0
- package/src/lib/app-server/protocol.js +44 -8
- package/src/lib/app-server/server.js +8 -11
- package/src/lib/approval-grant-ledger.js +187 -0
- package/src/lib/remote-approval-bridge.js +8 -12
- package/src/lib/remote-approval-decision.js +87 -0
- package/src/lib/session-message-fabric.js +265 -3
- package/src/runtime/headless-stream.js +184 -19
- package/src/assets/web-panel/assets/OrderTableRenderer-dgM87XIn.js +0 -1
- package/src/assets/web-panel/assets/Tasks-3ILwauYw.js +0 -1
- package/src/assets/web-panel/assets/devWarning-D7qVrkrO.js +0 -1
- package/src/assets/web-panel/assets/index-D9Y_ZwZn.js +0 -1
- 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:
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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:
|
|
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);
|