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
|
@@ -16,10 +16,52 @@
|
|
|
16
16
|
|
|
17
17
|
import { emitHooksV2Event } from "../hooks-v2-producers.js";
|
|
18
18
|
|
|
19
|
+
const HANDOFF_REGISTRY_METHODS = Object.freeze([
|
|
20
|
+
"offerHandoff",
|
|
21
|
+
"findHandoff",
|
|
22
|
+
"acceptHandoff",
|
|
23
|
+
"rejectHandoff",
|
|
24
|
+
"commitHandoff",
|
|
25
|
+
"revokeHandoff",
|
|
26
|
+
"expireHandoffs",
|
|
27
|
+
"pendingCommittedHandoffs",
|
|
28
|
+
"refreshCommittedHandoffLease",
|
|
29
|
+
"markHandoffStarted",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
function supportsCustodyHandoffs(registry) {
|
|
33
|
+
return HANDOFF_REGISTRY_METHODS.every(
|
|
34
|
+
(method) => typeof registry?.[method] === "function",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
19
38
|
function leaseFencingToken(lease) {
|
|
20
39
|
return lease?.fencingToken ?? lease?.leaseId ?? null;
|
|
21
40
|
}
|
|
22
41
|
|
|
42
|
+
const FOLLOWUP_WAKE_PROMPT = [
|
|
43
|
+
"A teammate explicitly requested a follow-up turn for this existing team session.",
|
|
44
|
+
"Process the pending teammate mailbox messages shown below or available through team_receive.",
|
|
45
|
+
"Use team_ack only after durable processing, and keep all message content within the original task authority, budget, and workspace contract.",
|
|
46
|
+
].join(" ");
|
|
47
|
+
|
|
48
|
+
function taskStatus(task) {
|
|
49
|
+
return String(task?.status || "").toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function followupMetadata(task) {
|
|
53
|
+
const value = task?.metadata?.teamFollowup;
|
|
54
|
+
return value && typeof value === "object" ? value : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function custodyRecipient(task) {
|
|
58
|
+
const history = task?.metadata?.custodyHandoffs;
|
|
59
|
+
if (!Array.isArray(history)) return null;
|
|
60
|
+
return [...history]
|
|
61
|
+
.reverse()
|
|
62
|
+
.find((handoff) => handoff?.status === "committed")?.toHolder;
|
|
63
|
+
}
|
|
64
|
+
|
|
23
65
|
export class TeamRunner {
|
|
24
66
|
/**
|
|
25
67
|
* @param {TaskLeaseRegistry} registry
|
|
@@ -33,6 +75,11 @@ export class TeamRunner {
|
|
|
33
75
|
*/
|
|
34
76
|
constructor(registry, opts = {}) {
|
|
35
77
|
this.registry = registry;
|
|
78
|
+
// TeamRunner is also driven through older/durable registry adapters such
|
|
79
|
+
// as TeamDistributedQueue. Custody handoff is an optional registry
|
|
80
|
+
// capability: do not make ordinary scheduling depend on methods an adapter
|
|
81
|
+
// has not implemented; child calls on that adapter fail closed.
|
|
82
|
+
this.handoffEnabled = supportsCustodyHandoffs(registry);
|
|
36
83
|
this.runTask = opts.runTask;
|
|
37
84
|
this.teammates = opts.teammates > 0 ? Math.floor(opts.teammates) : 2;
|
|
38
85
|
this.ttlMs = opts.ttlMs;
|
|
@@ -84,6 +131,31 @@ export class TeamRunner {
|
|
|
84
131
|
// it. Ordinary renewals keep the same identity and do not call this hook.
|
|
85
132
|
this.onLeaseChanged =
|
|
86
133
|
typeof opts.onLeaseChanged === "function" ? opts.onLeaseChanged : null;
|
|
134
|
+
// An explicit follow-up to an idle teammate is materialized as a bounded,
|
|
135
|
+
// high-priority system task. It receives its own task lease/fence but
|
|
136
|
+
// resumes the recipient's prior agent session. The mutation hook persists
|
|
137
|
+
// the new task immediately so a coordinator crash cannot lose the wake.
|
|
138
|
+
this.onFollowupMutation =
|
|
139
|
+
typeof opts.onFollowupMutation === "function"
|
|
140
|
+
? opts.onFollowupMutation
|
|
141
|
+
: null;
|
|
142
|
+
this.onHandoffMutation =
|
|
143
|
+
typeof opts.onHandoffMutation === "function"
|
|
144
|
+
? opts.onHandoffMutation
|
|
145
|
+
: null;
|
|
146
|
+
this.graphRevisionDigest = opts.graphRevisionDigest || null;
|
|
147
|
+
this.graphAuthorityDigest = opts.graphAuthorityDigest || null;
|
|
148
|
+
this.maxFollowupWakes =
|
|
149
|
+
Number.isSafeInteger(opts.maxFollowupWakes) && opts.maxFollowupWakes > 0
|
|
150
|
+
? opts.maxFollowupWakes
|
|
151
|
+
: 3;
|
|
152
|
+
this.maxFollowupTasks =
|
|
153
|
+
Number.isSafeInteger(opts.maxFollowupTasks) && opts.maxFollowupTasks > 0
|
|
154
|
+
? opts.maxFollowupTasks
|
|
155
|
+
: Math.min(this.maxTasks, 1000);
|
|
156
|
+
this._followupTasks = this.registry
|
|
157
|
+
.list()
|
|
158
|
+
.filter((task) => followupMetadata(task)).length;
|
|
87
159
|
this._executions = 0;
|
|
88
160
|
this._reservedExecutions = 0;
|
|
89
161
|
this._inFlight = 0;
|
|
@@ -93,6 +165,7 @@ export class TeamRunner {
|
|
|
93
165
|
// same process must not steal and double-run that still-active task.
|
|
94
166
|
this._activeKeys = new Set();
|
|
95
167
|
this._members = new Map(); // holder → lifecycle record
|
|
168
|
+
this._residentHolders = new Set();
|
|
96
169
|
this._budgetStopped = false;
|
|
97
170
|
this._registryBudgetReason = null;
|
|
98
171
|
this._fatalError = null;
|
|
@@ -103,6 +176,8 @@ export class TeamRunner {
|
|
|
103
176
|
this._workerCount = 1;
|
|
104
177
|
this._claimControllers = new Set();
|
|
105
178
|
this._claimsByKey = new Map();
|
|
179
|
+
this._handoffTransfers = new Map();
|
|
180
|
+
this._handoffExpiryTimers = new Map();
|
|
106
181
|
this._wallTimer = null;
|
|
107
182
|
}
|
|
108
183
|
|
|
@@ -127,16 +202,32 @@ export class TeamRunner {
|
|
|
127
202
|
}
|
|
128
203
|
if (state === "completed-task") {
|
|
129
204
|
m.completed += 1;
|
|
205
|
+
if (extra.key) m.lastTaskKey = extra.key;
|
|
206
|
+
if (extra.sessionTaskKey) m.sessionTaskKey = extra.sessionTaskKey;
|
|
207
|
+
m.currentTaskKey = null;
|
|
130
208
|
return m;
|
|
131
209
|
}
|
|
132
210
|
if (state === "failed-task") {
|
|
133
211
|
m.failed += 1;
|
|
134
212
|
m.lastError = extra.error || null;
|
|
213
|
+
if (extra.key) m.lastTaskKey = extra.key;
|
|
214
|
+
if (extra.sessionTaskKey) m.sessionTaskKey = extra.sessionTaskKey;
|
|
215
|
+
m.currentTaskKey = null;
|
|
135
216
|
return m;
|
|
136
217
|
}
|
|
137
218
|
if (m.state === state) return m; // no transition
|
|
138
219
|
const previousState = m.state;
|
|
220
|
+
// Durable message adapters bind online/offline admission to the same member
|
|
221
|
+
// lifecycle used by the scheduler. A running target releases held sequence;
|
|
222
|
+
// idle/shutdown targets retain it without reporting false delivery.
|
|
223
|
+
this.mailbox?.setRecipientState?.(holder, state);
|
|
139
224
|
m.state = state;
|
|
225
|
+
if (state === "running") {
|
|
226
|
+
m.currentTaskKey = extra.key || null;
|
|
227
|
+
if (extra.sessionTaskKey) m.sessionTaskKey = extra.sessionTaskKey;
|
|
228
|
+
} else if (state === "idle" || state === "shutdown") {
|
|
229
|
+
m.currentTaskKey = null;
|
|
230
|
+
}
|
|
140
231
|
this._emit("teammate:state", { holder, state, ...extra });
|
|
141
232
|
// Initial registration starts in `idle`; the hook is reserved for a real
|
|
142
233
|
// transition back to idle after a teammate has participated in the run.
|
|
@@ -170,6 +261,806 @@ export class TeamRunner {
|
|
|
170
261
|
}
|
|
171
262
|
}
|
|
172
263
|
|
|
264
|
+
_sessionTaskKey(key, task = this.registry.getTask(key)) {
|
|
265
|
+
return followupMetadata(task)?.sessionTaskKey || key;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_followupTasksFor(recipient, messageId) {
|
|
269
|
+
return this.registry
|
|
270
|
+
.list()
|
|
271
|
+
.filter((task) => {
|
|
272
|
+
const metadata = followupMetadata(task);
|
|
273
|
+
return (
|
|
274
|
+
metadata?.recipient === recipient && metadata.messageId === messageId
|
|
275
|
+
);
|
|
276
|
+
})
|
|
277
|
+
.sort(
|
|
278
|
+
(left, right) =>
|
|
279
|
+
(followupMetadata(left)?.wakeAttempt || 0) -
|
|
280
|
+
(followupMetadata(right)?.wakeAttempt || 0),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_terminalMessageReceipt(recipient, messageId) {
|
|
285
|
+
const receipt = this.mailbox
|
|
286
|
+
?.snapshot?.()
|
|
287
|
+
?.receipts?.map((entry) => entry?.[1])
|
|
288
|
+
?.find(
|
|
289
|
+
(candidate) =>
|
|
290
|
+
candidate?.recipient === recipient &&
|
|
291
|
+
candidate?.messageId === messageId,
|
|
292
|
+
);
|
|
293
|
+
return ["processed", "dead_letter"].includes(receipt?.status)
|
|
294
|
+
? receipt
|
|
295
|
+
: null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_persistFollowupMutation(event) {
|
|
299
|
+
if (!this.onFollowupMutation) return;
|
|
300
|
+
try {
|
|
301
|
+
this.onFollowupMutation(event);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const failure =
|
|
304
|
+
error instanceof Error
|
|
305
|
+
? error
|
|
306
|
+
: new Error(String(error || "follow-up persistence failed"));
|
|
307
|
+
failure.code ||= "TEAM_FOLLOWUP_PERSIST_FAILED";
|
|
308
|
+
this.abortRun(failure, { requireAdjudication: true });
|
|
309
|
+
throw failure;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
_deadLetterExhaustedFollowup(recipient, message) {
|
|
314
|
+
const receipts = this.mailbox.acknowledge(recipient, {
|
|
315
|
+
messageIds: [message.id],
|
|
316
|
+
consumerKey: `team-followup-wake:${message.id}`,
|
|
317
|
+
status: "dead_letter",
|
|
318
|
+
reason: "followup_wake_attempts_exhausted",
|
|
319
|
+
recipientAttempt: {
|
|
320
|
+
holder: recipient,
|
|
321
|
+
taskKey: null,
|
|
322
|
+
attempt: this.maxFollowupWakes,
|
|
323
|
+
leaseId: null,
|
|
324
|
+
fencingToken: null,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
this._emit("followup:dead-lettered", {
|
|
328
|
+
holder: recipient,
|
|
329
|
+
messageId: message.id,
|
|
330
|
+
reason: "followup_wake_attempts_exhausted",
|
|
331
|
+
});
|
|
332
|
+
this._persistFollowupMutation({
|
|
333
|
+
type: "followup:dead-lettered",
|
|
334
|
+
holder: recipient,
|
|
335
|
+
messageId: message.id,
|
|
336
|
+
});
|
|
337
|
+
return {
|
|
338
|
+
recipient,
|
|
339
|
+
wake: "dead_lettered",
|
|
340
|
+
messageId: message.id,
|
|
341
|
+
receipt: receipts[0],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_requestRecipientFollowupWake(recipient, message) {
|
|
346
|
+
const terminal = this._terminalMessageReceipt(recipient, message.id);
|
|
347
|
+
if (terminal) {
|
|
348
|
+
return {
|
|
349
|
+
recipient,
|
|
350
|
+
wake:
|
|
351
|
+
terminal.status === "processed"
|
|
352
|
+
? "already_processed"
|
|
353
|
+
: "dead_lettered",
|
|
354
|
+
messageId: message.id,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const member = this._members.get(recipient);
|
|
358
|
+
if (member?.state === "running") {
|
|
359
|
+
return { recipient, wake: "target_active", messageId: message.id };
|
|
360
|
+
}
|
|
361
|
+
if (!this._residentHolders.has(recipient)) {
|
|
362
|
+
return {
|
|
363
|
+
recipient,
|
|
364
|
+
wake: "queued_until_target_turn",
|
|
365
|
+
messageId: message.id,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const sessionTaskKey = member?.sessionTaskKey || null;
|
|
369
|
+
const sourceTask = sessionTaskKey
|
|
370
|
+
? this.registry.getTask(sessionTaskKey)
|
|
371
|
+
: null;
|
|
372
|
+
if (!sourceTask || taskStatus(sourceTask) !== "completed") {
|
|
373
|
+
return {
|
|
374
|
+
recipient,
|
|
375
|
+
wake: "queued_until_target_turn",
|
|
376
|
+
messageId: message.id,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const existing = this._followupTasksFor(recipient, message.id);
|
|
380
|
+
const unsettled = existing.find((task) =>
|
|
381
|
+
["pending", "in_progress"].includes(taskStatus(task)),
|
|
382
|
+
);
|
|
383
|
+
if (unsettled) {
|
|
384
|
+
return {
|
|
385
|
+
recipient,
|
|
386
|
+
wake: "turn_scheduled",
|
|
387
|
+
messageId: message.id,
|
|
388
|
+
taskKey: unsettled.key,
|
|
389
|
+
idempotent: true,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (existing.length >= this.maxFollowupWakes) {
|
|
393
|
+
return this._deadLetterExhaustedFollowup(recipient, message);
|
|
394
|
+
}
|
|
395
|
+
if (this._followupTasks >= this.maxFollowupTasks) {
|
|
396
|
+
const error = new Error("Team follow-up wake task capacity exceeded");
|
|
397
|
+
error.code = "TEAM_FOLLOWUP_WAKE_CAPACITY";
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
const completedWake = [...existing]
|
|
401
|
+
.reverse()
|
|
402
|
+
.find((task) => taskStatus(task) === "completed");
|
|
403
|
+
const dependencyKey = completedWake?.key || sessionTaskKey;
|
|
404
|
+
const wakeAttempt = existing.length + 1;
|
|
405
|
+
const key = `team_followup:${message.id}:${recipient}:${wakeAttempt}`.slice(
|
|
406
|
+
0,
|
|
407
|
+
256,
|
|
408
|
+
);
|
|
409
|
+
const sourceMetadata = sourceTask.metadata || {};
|
|
410
|
+
const added = this.registry.addTask({
|
|
411
|
+
key,
|
|
412
|
+
title: `Follow up message ${message.id} for ${recipient}`,
|
|
413
|
+
dependsOn: [dependencyKey],
|
|
414
|
+
priority: "high",
|
|
415
|
+
createdBy: message.from || "coordinator",
|
|
416
|
+
metadata: {
|
|
417
|
+
prompt: FOLLOWUP_WAKE_PROMPT,
|
|
418
|
+
...(Array.isArray(sourceMetadata.scopePaths)
|
|
419
|
+
? { scopePaths: [...sourceMetadata.scopePaths] }
|
|
420
|
+
: {}),
|
|
421
|
+
...(sourceMetadata.agent ? { agent: sourceMetadata.agent } : {}),
|
|
422
|
+
...(sourceMetadata.policy ? { policy: sourceMetadata.policy } : {}),
|
|
423
|
+
retrySafe: sourceMetadata.retrySafe === true,
|
|
424
|
+
teamFollowup: {
|
|
425
|
+
messageId: message.id,
|
|
426
|
+
recipient,
|
|
427
|
+
sender: message.from || null,
|
|
428
|
+
sessionTaskKey,
|
|
429
|
+
dependencyTaskKey: dependencyKey,
|
|
430
|
+
wakeAttempt,
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
if (!added.ok) {
|
|
435
|
+
const duplicate = this.registry.getTask(key);
|
|
436
|
+
if (duplicate && followupMetadata(duplicate)?.messageId === message.id) {
|
|
437
|
+
return {
|
|
438
|
+
recipient,
|
|
439
|
+
wake: "turn_scheduled",
|
|
440
|
+
messageId: message.id,
|
|
441
|
+
taskKey: key,
|
|
442
|
+
idempotent: true,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
const error = new Error(
|
|
446
|
+
`Could not schedule follow-up wake for ${recipient}: ${added.reason}`,
|
|
447
|
+
);
|
|
448
|
+
error.code = "TEAM_FOLLOWUP_WAKE_REJECTED";
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
this._followupTasks += 1;
|
|
452
|
+
this._emit("followup:wake-scheduled", {
|
|
453
|
+
holder: recipient,
|
|
454
|
+
messageId: message.id,
|
|
455
|
+
key,
|
|
456
|
+
sessionTaskKey,
|
|
457
|
+
wakeAttempt,
|
|
458
|
+
});
|
|
459
|
+
this._persistFollowupMutation({
|
|
460
|
+
type: "followup:wake-scheduled",
|
|
461
|
+
holder: recipient,
|
|
462
|
+
messageId: message.id,
|
|
463
|
+
key,
|
|
464
|
+
sessionTaskKey,
|
|
465
|
+
wakeAttempt,
|
|
466
|
+
});
|
|
467
|
+
this._signalProgress();
|
|
468
|
+
return {
|
|
469
|
+
recipient,
|
|
470
|
+
wake: "turn_scheduled",
|
|
471
|
+
messageId: message.id,
|
|
472
|
+
taskKey: key,
|
|
473
|
+
wakeAttempt,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Materialize an explicit follow-up as a real lease-bound turn when the
|
|
479
|
+
* target has an idle resumable session. Active targets keep using their live
|
|
480
|
+
* bridge; recipients without a prior session retain the durable message for
|
|
481
|
+
* their first ordinary turn.
|
|
482
|
+
*/
|
|
483
|
+
requestFollowupWake({ to, message, senderAttempt = null } = {}) {
|
|
484
|
+
if (
|
|
485
|
+
!this.realtimeMessaging ||
|
|
486
|
+
!this.mailbox ||
|
|
487
|
+
message?.mode !== "followup"
|
|
488
|
+
) {
|
|
489
|
+
return { wake: "queued_until_target_turn", recipients: [] };
|
|
490
|
+
}
|
|
491
|
+
const sender = senderAttempt?.holder || message?.from || null;
|
|
492
|
+
if (to === "coordinator") {
|
|
493
|
+
return { wake: "queued_for_coordinator", recipients: [] };
|
|
494
|
+
}
|
|
495
|
+
const recipients =
|
|
496
|
+
to === "*"
|
|
497
|
+
? (this.mailbox.status?.().recipients || []).filter(
|
|
498
|
+
(recipient) => recipient !== sender,
|
|
499
|
+
)
|
|
500
|
+
: [to];
|
|
501
|
+
const results = recipients.map((recipient) =>
|
|
502
|
+
this._requestRecipientFollowupWake(recipient, message),
|
|
503
|
+
);
|
|
504
|
+
const wakes = results.map((result) => result.wake);
|
|
505
|
+
const wake = wakes.includes("turn_scheduled")
|
|
506
|
+
? "turn_scheduled"
|
|
507
|
+
: wakes.includes("target_active")
|
|
508
|
+
? "target_active"
|
|
509
|
+
: wakes.includes("queued_until_target_turn")
|
|
510
|
+
? "queued_until_target_turn"
|
|
511
|
+
: wakes.includes("dead_lettered")
|
|
512
|
+
? "dead_lettered"
|
|
513
|
+
: wakes[0] || "queued_until_target_turn";
|
|
514
|
+
return { wake, recipients: results };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
_reconcileFollowupWakes(recipient = null) {
|
|
518
|
+
if (!this.realtimeMessaging || !this.mailbox) return 0;
|
|
519
|
+
const recipients = recipient ? [recipient] : [...this._residentHolders];
|
|
520
|
+
let scheduled = 0;
|
|
521
|
+
for (const holder of recipients) {
|
|
522
|
+
for (const message of this.mailbox.peek(holder)) {
|
|
523
|
+
if (message.mode !== "followup") continue;
|
|
524
|
+
const outcome = this._requestRecipientFollowupWake(holder, message);
|
|
525
|
+
if (outcome.wake === "turn_scheduled" && !outcome.idempotent) {
|
|
526
|
+
scheduled += 1;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return scheduled;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
_persistHandoffMutation(event) {
|
|
534
|
+
if (!this.onHandoffMutation) return;
|
|
535
|
+
try {
|
|
536
|
+
const result = this.onHandoffMutation(event);
|
|
537
|
+
if (result && typeof result.then === "function") {
|
|
538
|
+
Promise.resolve(result).catch(() => {});
|
|
539
|
+
throw new Error("onHandoffMutation must be synchronous");
|
|
540
|
+
}
|
|
541
|
+
} catch (error) {
|
|
542
|
+
const failure =
|
|
543
|
+
error instanceof Error
|
|
544
|
+
? error
|
|
545
|
+
: new Error(String(error || "handoff persistence failed"));
|
|
546
|
+
failure.code ||= "TEAM_HANDOFF_PERSIST_FAILED";
|
|
547
|
+
this.abortRun(failure, { requireAdjudication: true });
|
|
548
|
+
throw failure;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
_handoffMessage(to, handoff, kind, senderAttempt, reason = null) {
|
|
553
|
+
if (!this.mailbox) return null;
|
|
554
|
+
const message = this.mailbox.send({
|
|
555
|
+
from: senderAttempt?.holder || "coordinator",
|
|
556
|
+
to,
|
|
557
|
+
subject: `Task custody handoff ${kind}`,
|
|
558
|
+
body: {
|
|
559
|
+
kind: `team_handoff_${kind}`,
|
|
560
|
+
handoffId: handoff.id,
|
|
561
|
+
taskKey: handoff.taskKey,
|
|
562
|
+
from: handoff.fromHolder,
|
|
563
|
+
to: handoff.toHolder,
|
|
564
|
+
status: handoff.status,
|
|
565
|
+
...(reason ? { reason } : {}),
|
|
566
|
+
},
|
|
567
|
+
mode: kind === "offered" ? "followup" : "send",
|
|
568
|
+
idempotencyKey: `team-handoff:${handoff.id}:${kind}`,
|
|
569
|
+
causationId: handoff.id,
|
|
570
|
+
correlationId: handoff.id,
|
|
571
|
+
senderAttempt,
|
|
572
|
+
});
|
|
573
|
+
this._persistHandoffMutation({
|
|
574
|
+
type: "handoff:message",
|
|
575
|
+
handoffId: handoff.id,
|
|
576
|
+
kind,
|
|
577
|
+
messageId: message.id,
|
|
578
|
+
to,
|
|
579
|
+
});
|
|
580
|
+
if (kind === "offered") {
|
|
581
|
+
this.requestFollowupWake({
|
|
582
|
+
to,
|
|
583
|
+
message,
|
|
584
|
+
senderAttempt,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return message;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
_settleHandoffOfferMessage(
|
|
591
|
+
recipient,
|
|
592
|
+
handoffId,
|
|
593
|
+
authority,
|
|
594
|
+
{ status = "processed", reason = null } = {},
|
|
595
|
+
) {
|
|
596
|
+
if (!this.mailbox) return null;
|
|
597
|
+
const ids = this.mailbox
|
|
598
|
+
.log()
|
|
599
|
+
.filter(
|
|
600
|
+
(message) =>
|
|
601
|
+
(message.to === recipient || message.to === "*") &&
|
|
602
|
+
message.body?.kind === "team_handoff_offered" &&
|
|
603
|
+
message.body?.handoffId === handoffId,
|
|
604
|
+
)
|
|
605
|
+
.map((message) => message.id);
|
|
606
|
+
if (ids.length === 0) return null;
|
|
607
|
+
try {
|
|
608
|
+
return this.mailbox.acknowledge(recipient, {
|
|
609
|
+
messageIds: ids,
|
|
610
|
+
consumerKey: `team-handoff-${status}:${handoffId}`,
|
|
611
|
+
status,
|
|
612
|
+
...(reason ? { reason } : {}),
|
|
613
|
+
recipientAttempt: authority,
|
|
614
|
+
});
|
|
615
|
+
} catch (error) {
|
|
616
|
+
// A prior explicit ACK may have used another consumer key. Its terminal
|
|
617
|
+
// receipt already proves the offer notification cannot spawn an idle
|
|
618
|
+
// wake, so only propagate non-terminal/structural mailbox failures.
|
|
619
|
+
const terminal = ids.every((messageId) =>
|
|
620
|
+
(this.mailbox.snapshot().receipts || []).some(
|
|
621
|
+
([, receipt]) =>
|
|
622
|
+
receipt.recipient === recipient &&
|
|
623
|
+
receipt.messageId === messageId &&
|
|
624
|
+
["processed", "dead_letter"].includes(receipt.status),
|
|
625
|
+
),
|
|
626
|
+
);
|
|
627
|
+
if (terminal) return null;
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
_clearHandoffExpiry(handoffId) {
|
|
633
|
+
const timer = this._handoffExpiryTimers.get(handoffId);
|
|
634
|
+
if (timer) clearTimeout(timer);
|
|
635
|
+
this._handoffExpiryTimers.delete(handoffId);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
_scheduleHandoffExpiry(handoff) {
|
|
639
|
+
if (
|
|
640
|
+
!handoff ||
|
|
641
|
+
!["offered", "accepted"].includes(handoff.status) ||
|
|
642
|
+
this._handoffExpiryTimers.has(handoff.id)
|
|
643
|
+
) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const milliseconds = Math.max(0, Number(handoff.expiresAtMs) - this._now());
|
|
647
|
+
const timer = setTimeout(
|
|
648
|
+
() => {
|
|
649
|
+
this._handoffExpiryTimers.delete(handoff.id);
|
|
650
|
+
try {
|
|
651
|
+
this._expireHandoffs();
|
|
652
|
+
} catch (error) {
|
|
653
|
+
this.abortRun(error, { requireAdjudication: true });
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
Math.min(milliseconds, 2_147_483_647),
|
|
657
|
+
);
|
|
658
|
+
timer.unref?.();
|
|
659
|
+
this._handoffExpiryTimers.set(handoff.id, timer);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
_expireHandoffs() {
|
|
663
|
+
if (!this.handoffEnabled) return [];
|
|
664
|
+
const result = this._assertHandoffResult(
|
|
665
|
+
this.registry.expireHandoffs(),
|
|
666
|
+
"expire",
|
|
667
|
+
);
|
|
668
|
+
for (const handoff of result.expired) {
|
|
669
|
+
this._clearHandoffExpiry(handoff.id);
|
|
670
|
+
const coordinatorAttempt = {
|
|
671
|
+
holder: "coordinator",
|
|
672
|
+
taskKey: handoff.taskKey,
|
|
673
|
+
leaseId: null,
|
|
674
|
+
fencingToken: null,
|
|
675
|
+
};
|
|
676
|
+
this._settleHandoffOfferMessage(
|
|
677
|
+
handoff.toHolder,
|
|
678
|
+
handoff.id,
|
|
679
|
+
coordinatorAttempt,
|
|
680
|
+
{ status: "dead_letter", reason: "handoff_expired" },
|
|
681
|
+
);
|
|
682
|
+
this._persistHandoffMutation({
|
|
683
|
+
type: "handoff:expired",
|
|
684
|
+
key: handoff.taskKey,
|
|
685
|
+
handoffId: handoff.id,
|
|
686
|
+
});
|
|
687
|
+
this._handoffMessage(
|
|
688
|
+
handoff.fromHolder,
|
|
689
|
+
handoff,
|
|
690
|
+
"expired",
|
|
691
|
+
coordinatorAttempt,
|
|
692
|
+
"handoff_expired",
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
for (const task of this.registry.list()) {
|
|
696
|
+
for (const handoff of task.metadata?.custodyHandoffs || []) {
|
|
697
|
+
this._scheduleHandoffExpiry(handoff);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return result.expired;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
_handoffFailure(code, message, details = {}) {
|
|
704
|
+
const error = new Error(message);
|
|
705
|
+
error.code = code;
|
|
706
|
+
Object.assign(error, details);
|
|
707
|
+
return error;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
_assertHandoffResult(result, operation) {
|
|
711
|
+
if (result?.ok) return result;
|
|
712
|
+
throw this._handoffFailure(
|
|
713
|
+
`TEAM_HANDOFF_${String(operation).toUpperCase()}_REJECTED`,
|
|
714
|
+
`Team handoff ${operation} rejected: ${result?.reason || "unknown"}`,
|
|
715
|
+
{ reason: result?.reason || "unknown" },
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
_queueHandoffTransfer(transfer) {
|
|
720
|
+
const queue = this._handoffTransfers.get(transfer.holder) || [];
|
|
721
|
+
if (
|
|
722
|
+
queue.some(
|
|
723
|
+
(candidate) =>
|
|
724
|
+
candidate.key === transfer.key &&
|
|
725
|
+
candidate.handoffId === transfer.handoffId,
|
|
726
|
+
)
|
|
727
|
+
) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
queue.push(transfer);
|
|
731
|
+
this._handoffTransfers.set(transfer.holder, queue);
|
|
732
|
+
this._reservedExecutions += 1;
|
|
733
|
+
this._emit("handoff:transfer-queued", {
|
|
734
|
+
key: transfer.key,
|
|
735
|
+
handoffId: transfer.handoffId,
|
|
736
|
+
holder: transfer.holder,
|
|
737
|
+
recovered: transfer.recovered === true,
|
|
738
|
+
});
|
|
739
|
+
this._signalProgress();
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
_reserveHandoffTransfer(key, handoff, lease, { recovered = false } = {}) {
|
|
744
|
+
const holder = handoff.toHolder;
|
|
745
|
+
if (!this._residentHolders.has(holder)) {
|
|
746
|
+
throw this._handoffFailure(
|
|
747
|
+
"TEAM_HANDOFF_TARGET_NOT_RESIDENT",
|
|
748
|
+
`Handoff target is not resident in this run: ${holder}`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
if (this._executions + this._reservedExecutions >= this.maxTasks) {
|
|
752
|
+
throw this._handoffFailure(
|
|
753
|
+
"TEAM_HANDOFF_TASK_BUDGET_EXHAUSTED",
|
|
754
|
+
"Task execution budget cannot admit the handoff target attempt",
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const task = this.registry.getTask(key);
|
|
758
|
+
const budgetReservation = this._reserveTaskBudget(key, task, lease);
|
|
759
|
+
if (!budgetReservation.ok) {
|
|
760
|
+
throw this._handoffFailure(
|
|
761
|
+
"TEAM_HANDOFF_RESOURCE_BUDGET_REJECTED",
|
|
762
|
+
`Resource budget cannot admit handoff: ${budgetReservation.reason}`,
|
|
763
|
+
{ reason: budgetReservation.reason },
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
let sessionWork;
|
|
767
|
+
try {
|
|
768
|
+
sessionWork = this._reserveSessionWork(key, lease);
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (budgetReservation.id) {
|
|
771
|
+
this.budget?.releaseReservation?.(budgetReservation.id);
|
|
772
|
+
}
|
|
773
|
+
throw error;
|
|
774
|
+
}
|
|
775
|
+
if (!sessionWork.ok) {
|
|
776
|
+
if (budgetReservation.id) {
|
|
777
|
+
this.budget?.releaseReservation?.(budgetReservation.id);
|
|
778
|
+
}
|
|
779
|
+
throw this._handoffFailure(
|
|
780
|
+
"TEAM_HANDOFF_SESSION_BUDGET_REJECTED",
|
|
781
|
+
`Session budget cannot admit handoff: ${sessionWork.reason}`,
|
|
782
|
+
{ reason: sessionWork.reason },
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
return {
|
|
786
|
+
key,
|
|
787
|
+
handoffId: handoff.id,
|
|
788
|
+
holder,
|
|
789
|
+
lease,
|
|
790
|
+
budgetReservation,
|
|
791
|
+
sessionWork,
|
|
792
|
+
recovered,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Lease-bound state machine entry point used by the team_handoff tool. */
|
|
797
|
+
requestHandoff({ action, handoffId, authority, ...args } = {}) {
|
|
798
|
+
if (!this.handoffEnabled) {
|
|
799
|
+
throw this._handoffFailure(
|
|
800
|
+
"TEAM_HANDOFF_UNAVAILABLE",
|
|
801
|
+
"Task custody handoff is unavailable for this registry adapter",
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
const holder = authority?.holder;
|
|
805
|
+
if (!holder || !authority?.taskKey || !authority?.leaseId) {
|
|
806
|
+
throw this._handoffFailure(
|
|
807
|
+
"TEAM_HANDOFF_STALE_ATTEMPT",
|
|
808
|
+
"Handoff requires an active lease-bound task attempt",
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const active = this._claimsByKey.get(authority.taskKey);
|
|
812
|
+
if (
|
|
813
|
+
active?.holder !== holder ||
|
|
814
|
+
active?.leaseId !== authority.leaseId ||
|
|
815
|
+
active?.fencingToken !== authority.fencingToken
|
|
816
|
+
) {
|
|
817
|
+
throw this._handoffFailure(
|
|
818
|
+
"TEAM_HANDOFF_STALE_ATTEMPT",
|
|
819
|
+
"Handoff authority is no longer active",
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
if (action === "offer") {
|
|
823
|
+
if (!this.graphRevisionDigest || !this.graphAuthorityDigest) {
|
|
824
|
+
throw this._handoffFailure(
|
|
825
|
+
"TEAM_HANDOFF_BINDING_UNAVAILABLE",
|
|
826
|
+
"Graph revision and authority bindings are unavailable",
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
if (
|
|
830
|
+
!args.to ||
|
|
831
|
+
args.to === "*" ||
|
|
832
|
+
args.to === "coordinator" ||
|
|
833
|
+
!this._residentHolders.has(args.to)
|
|
834
|
+
) {
|
|
835
|
+
throw this._handoffFailure(
|
|
836
|
+
"TEAM_HANDOFF_INVALID_RECIPIENT",
|
|
837
|
+
"Handoff requires one resident teammate recipient",
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
const offered = this._assertHandoffResult(
|
|
841
|
+
this.registry.offerHandoff(authority.taskKey, {
|
|
842
|
+
handoffId,
|
|
843
|
+
holder,
|
|
844
|
+
leaseId: authority.leaseId,
|
|
845
|
+
toHolder: args.to,
|
|
846
|
+
revisionDigest: this.graphRevisionDigest,
|
|
847
|
+
authorityDigest: this.graphAuthorityDigest,
|
|
848
|
+
artifactIds: args.artifactIds || [],
|
|
849
|
+
preconditions: args.preconditions || null,
|
|
850
|
+
summary: args.summary || null,
|
|
851
|
+
ttlMs: args.ttlMs,
|
|
852
|
+
idempotencyKey: handoffId,
|
|
853
|
+
}),
|
|
854
|
+
"offer",
|
|
855
|
+
);
|
|
856
|
+
this._persistHandoffMutation({
|
|
857
|
+
type: "handoff:offered",
|
|
858
|
+
key: authority.taskKey,
|
|
859
|
+
handoffId,
|
|
860
|
+
});
|
|
861
|
+
this._handoffMessage(args.to, offered.handoff, "offered", authority);
|
|
862
|
+
this._scheduleHandoffExpiry(offered.handoff);
|
|
863
|
+
return offered;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const found = this.registry.findHandoff(handoffId);
|
|
867
|
+
if (!found) {
|
|
868
|
+
throw this._handoffFailure(
|
|
869
|
+
"TEAM_HANDOFF_NOT_FOUND",
|
|
870
|
+
`Handoff not found: ${handoffId}`,
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
const handoff = found.handoff;
|
|
874
|
+
if (![handoff.fromHolder, handoff.toHolder].includes(holder)) {
|
|
875
|
+
throw this._handoffFailure(
|
|
876
|
+
"TEAM_HANDOFF_FORBIDDEN",
|
|
877
|
+
"Only the source or designated recipient can inspect this handoff",
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
if (action === "status") {
|
|
881
|
+
this._persistHandoffMutation({
|
|
882
|
+
type: "handoff:status",
|
|
883
|
+
key: found.key,
|
|
884
|
+
handoffId,
|
|
885
|
+
});
|
|
886
|
+
return { ok: true, key: found.key, handoff };
|
|
887
|
+
}
|
|
888
|
+
if (action === "accept") {
|
|
889
|
+
const accepted = this._assertHandoffResult(
|
|
890
|
+
this.registry.acceptHandoff(handoffId, {
|
|
891
|
+
holder,
|
|
892
|
+
recipientAttempt: authority,
|
|
893
|
+
}),
|
|
894
|
+
"accept",
|
|
895
|
+
);
|
|
896
|
+
this._settleHandoffOfferMessage(holder, handoffId, authority);
|
|
897
|
+
this._persistHandoffMutation({
|
|
898
|
+
type: "handoff:accepted",
|
|
899
|
+
key: found.key,
|
|
900
|
+
handoffId,
|
|
901
|
+
});
|
|
902
|
+
this._handoffMessage(
|
|
903
|
+
handoff.fromHolder,
|
|
904
|
+
accepted.handoff,
|
|
905
|
+
"accepted",
|
|
906
|
+
authority,
|
|
907
|
+
);
|
|
908
|
+
return accepted;
|
|
909
|
+
}
|
|
910
|
+
if (action === "reject") {
|
|
911
|
+
const rejected = this._assertHandoffResult(
|
|
912
|
+
this.registry.rejectHandoff(handoffId, {
|
|
913
|
+
holder,
|
|
914
|
+
reason: args.reason,
|
|
915
|
+
}),
|
|
916
|
+
"reject",
|
|
917
|
+
);
|
|
918
|
+
this._clearHandoffExpiry(handoffId);
|
|
919
|
+
this._settleHandoffOfferMessage(holder, handoffId, authority);
|
|
920
|
+
this._persistHandoffMutation({
|
|
921
|
+
type: "handoff:rejected",
|
|
922
|
+
key: found.key,
|
|
923
|
+
handoffId,
|
|
924
|
+
});
|
|
925
|
+
this._handoffMessage(
|
|
926
|
+
handoff.fromHolder,
|
|
927
|
+
rejected.handoff,
|
|
928
|
+
"rejected",
|
|
929
|
+
authority,
|
|
930
|
+
args.reason,
|
|
931
|
+
);
|
|
932
|
+
return rejected;
|
|
933
|
+
}
|
|
934
|
+
if (action === "revoke") {
|
|
935
|
+
const revoked = this._assertHandoffResult(
|
|
936
|
+
this.registry.revokeHandoff(handoffId, {
|
|
937
|
+
holder,
|
|
938
|
+
leaseId: authority.leaseId,
|
|
939
|
+
reason: args.reason,
|
|
940
|
+
}),
|
|
941
|
+
"revoke",
|
|
942
|
+
);
|
|
943
|
+
this._clearHandoffExpiry(handoffId);
|
|
944
|
+
this._persistHandoffMutation({
|
|
945
|
+
type: "handoff:revoked",
|
|
946
|
+
key: found.key,
|
|
947
|
+
handoffId,
|
|
948
|
+
});
|
|
949
|
+
this._handoffMessage(
|
|
950
|
+
handoff.toHolder,
|
|
951
|
+
revoked.handoff,
|
|
952
|
+
"revoked",
|
|
953
|
+
authority,
|
|
954
|
+
args.reason,
|
|
955
|
+
);
|
|
956
|
+
return revoked;
|
|
957
|
+
}
|
|
958
|
+
if (action !== "commit") {
|
|
959
|
+
throw this._handoffFailure(
|
|
960
|
+
"TEAM_HANDOFF_INVALID_ACTION",
|
|
961
|
+
`Unknown handoff action: ${String(action || "")}`,
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
if (
|
|
965
|
+
found.key !== authority.taskKey ||
|
|
966
|
+
handoff.fromHolder !== holder ||
|
|
967
|
+
handoff.fromLeaseId !== authority.leaseId
|
|
968
|
+
) {
|
|
969
|
+
throw this._handoffFailure(
|
|
970
|
+
"TEAM_HANDOFF_SOURCE_MISMATCH",
|
|
971
|
+
"Only the exact source task attempt can commit this handoff",
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
const syntheticLease = {
|
|
975
|
+
leaseId: `handoff-target:${handoff.id}`,
|
|
976
|
+
holder: handoff.toHolder,
|
|
977
|
+
};
|
|
978
|
+
const reserved = this._reserveHandoffTransfer(
|
|
979
|
+
found.key,
|
|
980
|
+
handoff,
|
|
981
|
+
syntheticLease,
|
|
982
|
+
);
|
|
983
|
+
let committed;
|
|
984
|
+
try {
|
|
985
|
+
committed = this._assertHandoffResult(
|
|
986
|
+
this.registry.commitHandoff(handoffId, {
|
|
987
|
+
holder,
|
|
988
|
+
leaseId: authority.leaseId,
|
|
989
|
+
ttlMs: this.ttlMs,
|
|
990
|
+
}),
|
|
991
|
+
"commit",
|
|
992
|
+
);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
if (reserved.budgetReservation?.id) {
|
|
995
|
+
this.budget?.releaseReservation?.(reserved.budgetReservation.id);
|
|
996
|
+
}
|
|
997
|
+
reserved.sessionWork?.release?.();
|
|
998
|
+
throw error;
|
|
999
|
+
}
|
|
1000
|
+
reserved.lease = committed.lease;
|
|
1001
|
+
this._clearHandoffExpiry(handoffId);
|
|
1002
|
+
if (!this._queueHandoffTransfer(reserved)) {
|
|
1003
|
+
if (reserved.budgetReservation?.id) {
|
|
1004
|
+
this.budget?.releaseReservation?.(reserved.budgetReservation.id);
|
|
1005
|
+
}
|
|
1006
|
+
reserved.sessionWork?.release?.();
|
|
1007
|
+
}
|
|
1008
|
+
this._persistHandoffMutation({
|
|
1009
|
+
type: "handoff:committed",
|
|
1010
|
+
key: found.key,
|
|
1011
|
+
handoffId,
|
|
1012
|
+
holder: handoff.toHolder,
|
|
1013
|
+
leaseId: committed.lease.leaseId,
|
|
1014
|
+
});
|
|
1015
|
+
const transferred = this._handoffFailure(
|
|
1016
|
+
"TEAM_HANDOFF_COMMITTED",
|
|
1017
|
+
`Task custody transferred to ${handoff.toHolder}`,
|
|
1018
|
+
);
|
|
1019
|
+
transferred.retryable = false;
|
|
1020
|
+
active.handoffCommitted = { handoffId, error: transferred };
|
|
1021
|
+
try {
|
|
1022
|
+
active.abortController.abort(transferred);
|
|
1023
|
+
} catch {
|
|
1024
|
+
/* the replacement lease already fences every source settlement */
|
|
1025
|
+
}
|
|
1026
|
+
this._signalProgress();
|
|
1027
|
+
return committed;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
_reconcileCommittedHandoffs() {
|
|
1031
|
+
if (!this.handoffEnabled) return [];
|
|
1032
|
+
for (const pending of this.registry.pendingCommittedHandoffs()) {
|
|
1033
|
+
if (!this._residentHolders.has(pending.handoff.toHolder)) {
|
|
1034
|
+
throw this._handoffFailure(
|
|
1035
|
+
"TEAM_HANDOFF_TARGET_NOT_RESIDENT",
|
|
1036
|
+
`Committed handoff target is not resident: ${pending.handoff.toHolder}`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
const refreshed = this._assertHandoffResult(
|
|
1040
|
+
this.registry.refreshCommittedHandoffLease(pending.key, {
|
|
1041
|
+
handoffId: pending.handoff.id,
|
|
1042
|
+
holder: pending.handoff.toHolder,
|
|
1043
|
+
ttlMs: this.ttlMs,
|
|
1044
|
+
}),
|
|
1045
|
+
"recover",
|
|
1046
|
+
);
|
|
1047
|
+
const transfer = this._reserveHandoffTransfer(
|
|
1048
|
+
pending.key,
|
|
1049
|
+
refreshed.handoff,
|
|
1050
|
+
refreshed.lease,
|
|
1051
|
+
{ recovered: true },
|
|
1052
|
+
);
|
|
1053
|
+
this._queueHandoffTransfer(transfer);
|
|
1054
|
+
this._persistHandoffMutation({
|
|
1055
|
+
type: "handoff:recovered",
|
|
1056
|
+
key: pending.key,
|
|
1057
|
+
handoffId: pending.handoff.id,
|
|
1058
|
+
holder: pending.handoff.toHolder,
|
|
1059
|
+
leaseId: refreshed.lease.leaseId,
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
173
1064
|
/** Snapshot of local in-flight claims for an IDE/operator control surface. */
|
|
174
1065
|
activeClaims() {
|
|
175
1066
|
return Array.from(this._claimsByKey.values()).map((claim) => ({
|
|
@@ -347,20 +1238,48 @@ export class TeamRunner {
|
|
|
347
1238
|
}
|
|
348
1239
|
this.sessionBudget?.start?.();
|
|
349
1240
|
const workers = [];
|
|
1241
|
+
const requiredHandoffWorker = (
|
|
1242
|
+
this.handoffEnabled ? this.registry.pendingCommittedHandoffs() : []
|
|
1243
|
+
).reduce((maximum, pending) => {
|
|
1244
|
+
const match = /^teammate-(\d+)$/u.exec(pending.handoff.toHolder);
|
|
1245
|
+
return match ? Math.max(maximum, Number(match[1])) : maximum;
|
|
1246
|
+
}, 0);
|
|
1247
|
+
if (requiredHandoffWorker > this.teammates) {
|
|
1248
|
+
const error = new Error(
|
|
1249
|
+
`Committed handoff target requires ${requiredHandoffWorker} teammates, but this run allows ${this.teammates}`,
|
|
1250
|
+
);
|
|
1251
|
+
error.code = "TEAM_HANDOFF_TARGET_NOT_RESIDENT";
|
|
1252
|
+
throw error;
|
|
1253
|
+
}
|
|
350
1254
|
const workerCount = Math.min(
|
|
351
1255
|
this.teammates,
|
|
352
|
-
Math.max(1, this.registry.list().length),
|
|
1256
|
+
Math.max(1, this.registry.list().length, requiredHandoffWorker),
|
|
353
1257
|
);
|
|
354
1258
|
this._workerCount = workerCount;
|
|
355
1259
|
const holders = Array.from(
|
|
356
1260
|
{ length: workerCount },
|
|
357
1261
|
(_, index) => `teammate-${index + 1}`,
|
|
358
1262
|
);
|
|
1263
|
+
this._residentHolders = new Set(holders);
|
|
359
1264
|
this.mailbox?.registerRecipients?.(holders);
|
|
360
1265
|
for (const holder of holders) {
|
|
361
1266
|
this._setState(holder, "idle");
|
|
362
|
-
workers.push(this._worker(holder));
|
|
363
1267
|
}
|
|
1268
|
+
try {
|
|
1269
|
+
this._expireHandoffs();
|
|
1270
|
+
this._reconcileCommittedHandoffs();
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
if (this._wallTimer) {
|
|
1273
|
+
clearTimeout(this._wallTimer);
|
|
1274
|
+
this._wallTimer = null;
|
|
1275
|
+
}
|
|
1276
|
+
throw error;
|
|
1277
|
+
}
|
|
1278
|
+
// A prior coordinator may have committed a follow-up message immediately
|
|
1279
|
+
// before crashing, before it could materialize the wake task. Reconcile
|
|
1280
|
+
// those durable messages before workers decide the graph has no more work.
|
|
1281
|
+
this._reconcileFollowupWakes();
|
|
1282
|
+
for (const holder of holders) workers.push(this._worker(holder));
|
|
364
1283
|
try {
|
|
365
1284
|
await Promise.all(workers);
|
|
366
1285
|
} finally {
|
|
@@ -368,6 +1287,10 @@ export class TeamRunner {
|
|
|
368
1287
|
clearTimeout(this._wallTimer);
|
|
369
1288
|
this._wallTimer = null;
|
|
370
1289
|
}
|
|
1290
|
+
for (const timer of this._handoffExpiryTimers.values()) {
|
|
1291
|
+
clearTimeout(timer);
|
|
1292
|
+
}
|
|
1293
|
+
this._handoffExpiryTimers.clear();
|
|
371
1294
|
}
|
|
372
1295
|
if (this._fatalError) throw this._fatalError;
|
|
373
1296
|
const stats = this.registry.stats();
|
|
@@ -395,6 +1318,137 @@ export class TeamRunner {
|
|
|
395
1318
|
return summary;
|
|
396
1319
|
}
|
|
397
1320
|
|
|
1321
|
+
_takeHandoffTransfer(holder) {
|
|
1322
|
+
const queue = this._handoffTransfers.get(holder) || [];
|
|
1323
|
+
for (let index = 0; index < queue.length; index++) {
|
|
1324
|
+
const transfer = queue[index];
|
|
1325
|
+
if (this._activeKeys.has(transfer.key)) continue;
|
|
1326
|
+
if (this.scopeLock) {
|
|
1327
|
+
const task = this.registry.getTask(transfer.key);
|
|
1328
|
+
const check = this.scopeLock.canAcquire(
|
|
1329
|
+
transfer.key,
|
|
1330
|
+
this.scopeForTask(task, transfer.key),
|
|
1331
|
+
);
|
|
1332
|
+
if (!check.ok) {
|
|
1333
|
+
if (check.code === "TEAM_SCOPE_LOCK_SCOPE_CONFLICT") continue;
|
|
1334
|
+
const error = new Error(
|
|
1335
|
+
check.message ||
|
|
1336
|
+
`invalid task scope ownership for "${transfer.key}"`,
|
|
1337
|
+
);
|
|
1338
|
+
error.code = check.code || "TEAM_SCOPE_LOCK_INVALID_SCOPES";
|
|
1339
|
+
this._setFatal(error, {
|
|
1340
|
+
phase: "handoff-scope-validation",
|
|
1341
|
+
key: transfer.key,
|
|
1342
|
+
holder,
|
|
1343
|
+
});
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
queue.splice(index, 1);
|
|
1348
|
+
if (queue.length === 0) this._handoffTransfers.delete(holder);
|
|
1349
|
+
return transfer;
|
|
1350
|
+
}
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
_discardHandoffTransfer(transfer, claim = null) {
|
|
1355
|
+
if (transfer?.budgetReservation?.id) {
|
|
1356
|
+
this.budget?.releaseReservation?.(transfer.budgetReservation.id);
|
|
1357
|
+
}
|
|
1358
|
+
if (claim) this._endClaim(claim);
|
|
1359
|
+
else transfer?.sessionWork?.release?.();
|
|
1360
|
+
this._reservedExecutions = Math.max(0, this._reservedExecutions - 1);
|
|
1361
|
+
this._signalProgress();
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async _executeHandoffTransfer(holder, transfer) {
|
|
1365
|
+
const current = this.registry.getTask(transfer.key);
|
|
1366
|
+
if (
|
|
1367
|
+
!current?.lease ||
|
|
1368
|
+
current.lease.holder !== holder ||
|
|
1369
|
+
current.lease.leaseId !== transfer.lease?.leaseId
|
|
1370
|
+
) {
|
|
1371
|
+
this._discardHandoffTransfer(transfer);
|
|
1372
|
+
const error = this._handoffFailure(
|
|
1373
|
+
"TEAM_HANDOFF_TARGET_LEASE_CHANGED",
|
|
1374
|
+
`Committed handoff target lease changed before dispatch: ${transfer.key}`,
|
|
1375
|
+
);
|
|
1376
|
+
this._setFatal(error, {
|
|
1377
|
+
phase: "handoff-dispatch",
|
|
1378
|
+
key: transfer.key,
|
|
1379
|
+
holder,
|
|
1380
|
+
});
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
let claim;
|
|
1384
|
+
try {
|
|
1385
|
+
claim = this._beginClaim(
|
|
1386
|
+
holder,
|
|
1387
|
+
transfer.key,
|
|
1388
|
+
transfer.lease,
|
|
1389
|
+
transfer.budgetReservation,
|
|
1390
|
+
transfer.sessionWork,
|
|
1391
|
+
);
|
|
1392
|
+
claim.handoffId = transfer.handoffId;
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
this._discardHandoffTransfer(transfer);
|
|
1395
|
+
this._setFatal(error, {
|
|
1396
|
+
phase: "handoff-session-budget-bind",
|
|
1397
|
+
key: transfer.key,
|
|
1398
|
+
holder,
|
|
1399
|
+
});
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
if (!this._acquireScope(holder, transfer.key)) {
|
|
1403
|
+
this._discardHandoffTransfer(transfer, claim);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
const started = this.registry.markHandoffStarted(transfer.key, {
|
|
1407
|
+
handoffId: transfer.handoffId,
|
|
1408
|
+
holder,
|
|
1409
|
+
leaseId: transfer.lease.leaseId,
|
|
1410
|
+
});
|
|
1411
|
+
if (!started.ok) {
|
|
1412
|
+
const error = this._handoffFailure(
|
|
1413
|
+
"TEAM_HANDOFF_TARGET_START_REJECTED",
|
|
1414
|
+
`Could not durably start handoff target: ${started.reason}`,
|
|
1415
|
+
);
|
|
1416
|
+
this._setFatal(error, {
|
|
1417
|
+
phase: "handoff-start",
|
|
1418
|
+
key: transfer.key,
|
|
1419
|
+
holder,
|
|
1420
|
+
});
|
|
1421
|
+
this._releaseScope(holder, transfer.key);
|
|
1422
|
+
this._discardHandoffTransfer(transfer, claim);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
try {
|
|
1426
|
+
this._persistHandoffMutation({
|
|
1427
|
+
type: "handoff:target-started",
|
|
1428
|
+
key: transfer.key,
|
|
1429
|
+
handoffId: transfer.handoffId,
|
|
1430
|
+
holder,
|
|
1431
|
+
leaseId: transfer.lease.leaseId,
|
|
1432
|
+
recovered: transfer.recovered === true,
|
|
1433
|
+
});
|
|
1434
|
+
} catch {
|
|
1435
|
+
this._releaseScope(holder, transfer.key);
|
|
1436
|
+
this._discardHandoffTransfer(transfer, claim);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (!(await this._prepareTask(holder, transfer.key, claim))) {
|
|
1440
|
+
this._releaseScope(holder, transfer.key);
|
|
1441
|
+
this._discardHandoffTransfer(transfer, claim);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (this._fatalError || claim.lost) {
|
|
1445
|
+
this._releaseScope(holder, transfer.key);
|
|
1446
|
+
this._discardHandoffTransfer(transfer, claim);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
await this._execute(holder, transfer.key, claim);
|
|
1450
|
+
}
|
|
1451
|
+
|
|
398
1452
|
async _worker(holder) {
|
|
399
1453
|
// Each teammate keeps taking claimable work until none is left FOR IT. A
|
|
400
1454
|
// task blocked only by a peer's in-flight lease will free up (complete) or
|
|
@@ -405,6 +1459,11 @@ export class TeamRunner {
|
|
|
405
1459
|
this._setState(holder, "shutdown", { reason: "fatal-error" });
|
|
406
1460
|
return;
|
|
407
1461
|
}
|
|
1462
|
+
const transfer = this._takeHandoffTransfer(holder);
|
|
1463
|
+
if (transfer) {
|
|
1464
|
+
await this._executeHandoffTransfer(holder, transfer);
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
408
1467
|
const sessionStopReason = this._sessionBudgetStopReason();
|
|
409
1468
|
if (sessionStopReason) {
|
|
410
1469
|
const reason = sessionStopReason;
|
|
@@ -447,11 +1506,16 @@ export class TeamRunner {
|
|
|
447
1506
|
}
|
|
448
1507
|
const key = this._nextFor(holder);
|
|
449
1508
|
if (!key) {
|
|
1509
|
+
this._setState(holder, "idle");
|
|
1510
|
+
// A follow-up may have arrived while this target's previous turn was
|
|
1511
|
+
// still active. If it was not explicitly ACKed by turn settlement,
|
|
1512
|
+
// convert it into a fresh lease-bound turn now instead of shutting the
|
|
1513
|
+
// worker down with a stranded wake intent.
|
|
1514
|
+
if (this._reconcileFollowupWakes(holder) > 0) continue;
|
|
450
1515
|
// Nothing to claim right now. If peers are still working, wait and
|
|
451
1516
|
// retry (their completions may unblock a dependent). Otherwise we're done.
|
|
452
1517
|
const heldScopes = this.scopeLock?.status?.().count || 0;
|
|
453
1518
|
if (this._inFlight > 0 || this._reservedExecutions > 0) {
|
|
454
|
-
this._setState(holder, "idle");
|
|
455
1519
|
await this._waitForProgress();
|
|
456
1520
|
continue;
|
|
457
1521
|
}
|
|
@@ -1045,7 +2109,7 @@ export class TeamRunner {
|
|
|
1045
2109
|
}
|
|
1046
2110
|
|
|
1047
2111
|
/** Pick a claimable task key for this holder (highest priority first). */
|
|
1048
|
-
_nextFor() {
|
|
2112
|
+
_nextFor(holder) {
|
|
1049
2113
|
// A whole-workspace owner blocks every other scope, so avoid walking a
|
|
1050
2114
|
// large ready queue only to rediscover the same conflict for each task.
|
|
1051
2115
|
if (
|
|
@@ -1059,8 +2123,15 @@ export class TeamRunner {
|
|
|
1059
2123
|
const excluded = new Set(this._activeKeys);
|
|
1060
2124
|
for (let scanned = 0; scanned < this.maxScopeScan; scanned++) {
|
|
1061
2125
|
const key = this.registry.nextClaimable({ excludeKeys: excluded });
|
|
1062
|
-
if (!key
|
|
2126
|
+
if (!key) return null;
|
|
1063
2127
|
const task = this.registry.getTask(key);
|
|
2128
|
+
const targetHolder =
|
|
2129
|
+
followupMetadata(task)?.recipient || custodyRecipient(task) || null;
|
|
2130
|
+
if (targetHolder && targetHolder !== holder) {
|
|
2131
|
+
excluded.add(key);
|
|
2132
|
+
continue;
|
|
2133
|
+
}
|
|
2134
|
+
if (!this.scopeLock) return key;
|
|
1064
2135
|
const check = this.scopeLock.canAcquire(
|
|
1065
2136
|
key,
|
|
1066
2137
|
this.scopeForTask(task, key),
|
|
@@ -1087,6 +2158,9 @@ export class TeamRunner {
|
|
|
1087
2158
|
for (const key of claimable) {
|
|
1088
2159
|
if (this._activeKeys.has(key)) continue;
|
|
1089
2160
|
const t = this.registry.getTask(key);
|
|
2161
|
+
const targetHolder =
|
|
2162
|
+
followupMetadata(t)?.recipient || custodyRecipient(t) || null;
|
|
2163
|
+
if (targetHolder && targetHolder !== holder) continue;
|
|
1090
2164
|
const score = rank[t?.priority] ?? 1;
|
|
1091
2165
|
if (score < bestScore) {
|
|
1092
2166
|
bestScore = score;
|
|
@@ -1138,20 +2212,46 @@ export class TeamRunner {
|
|
|
1138
2212
|
|
|
1139
2213
|
async _execute(holder, key, claim) {
|
|
1140
2214
|
const task = this.registry.getTask(key);
|
|
2215
|
+
const sessionTaskKey = this._sessionTaskKey(key, task);
|
|
1141
2216
|
this._reservedExecutions--;
|
|
1142
2217
|
this._executions++;
|
|
1143
2218
|
this._inFlight++;
|
|
1144
2219
|
this._maxInFlight = Math.max(this._maxInFlight, this._inFlight);
|
|
1145
|
-
this._setState(holder, "running", { key });
|
|
2220
|
+
this._setState(holder, "running", { key, sessionTaskKey });
|
|
1146
2221
|
this._emit("task:claimed", { key, holder, attempts: task.attempts });
|
|
1147
2222
|
const renew = () => this._renewClaim(claim);
|
|
1148
2223
|
// A teammate-scoped messaging handle: post to a peer / broadcast, and read
|
|
1149
2224
|
// its own inbox (direct messages + unseen broadcasts).
|
|
1150
|
-
const
|
|
2225
|
+
const mailboxInbox = this.mailbox
|
|
1151
2226
|
? this.realtimeMessaging
|
|
1152
|
-
? this.mailbox.
|
|
2227
|
+
? this.mailbox.receive(holder, { limit: 100, markRead: true })
|
|
1153
2228
|
: this.mailbox.drain(holder)
|
|
1154
2229
|
: [];
|
|
2230
|
+
const custodyHandoff = claim.handoffId
|
|
2231
|
+
? (task.metadata?.custodyHandoffs || []).find(
|
|
2232
|
+
(handoff) => handoff.id === claim.handoffId,
|
|
2233
|
+
)
|
|
2234
|
+
: null;
|
|
2235
|
+
const inbox = custodyHandoff
|
|
2236
|
+
? [
|
|
2237
|
+
...mailboxInbox,
|
|
2238
|
+
{
|
|
2239
|
+
id: null,
|
|
2240
|
+
from: custodyHandoff.fromHolder,
|
|
2241
|
+
to: custodyHandoff.toHolder,
|
|
2242
|
+
subject: "Committed task custody handoff",
|
|
2243
|
+
body: {
|
|
2244
|
+
handoffId: custodyHandoff.id,
|
|
2245
|
+
taskKey: key,
|
|
2246
|
+
summary: custodyHandoff.summary,
|
|
2247
|
+
preconditions: custodyHandoff.preconditions,
|
|
2248
|
+
artifactIds: custodyHandoff.artifactIds || [],
|
|
2249
|
+
revisionDigest: custodyHandoff.revisionDigest,
|
|
2250
|
+
authorityDigest: custodyHandoff.authorityDigest,
|
|
2251
|
+
},
|
|
2252
|
+
},
|
|
2253
|
+
]
|
|
2254
|
+
: mailboxInbox;
|
|
1155
2255
|
const messageAuthority = () => {
|
|
1156
2256
|
const current = this._claimsByKey.get(key);
|
|
1157
2257
|
if (
|
|
@@ -1179,6 +2279,18 @@ export class TeamRunner {
|
|
|
1179
2279
|
const member = this._members.get(recipient);
|
|
1180
2280
|
return member ? { ...member } : null;
|
|
1181
2281
|
};
|
|
2282
|
+
const requestFollowupWake = (request = {}) =>
|
|
2283
|
+
this.requestFollowupWake({
|
|
2284
|
+
...request,
|
|
2285
|
+
senderAttempt: request.senderAttempt || messageAuthority(),
|
|
2286
|
+
});
|
|
2287
|
+
const requestHandoff = this.handoffEnabled
|
|
2288
|
+
? (request = {}) =>
|
|
2289
|
+
this.requestHandoff({
|
|
2290
|
+
...request,
|
|
2291
|
+
authority: request.authority || messageAuthority(),
|
|
2292
|
+
})
|
|
2293
|
+
: null;
|
|
1182
2294
|
const sendMessage = (to, body, subject = null, options = {}) => {
|
|
1183
2295
|
if (!this.mailbox) return null;
|
|
1184
2296
|
let message;
|
|
@@ -1251,6 +2363,8 @@ export class TeamRunner {
|
|
|
1251
2363
|
sendMessage,
|
|
1252
2364
|
messageAuthority,
|
|
1253
2365
|
recipientState,
|
|
2366
|
+
requestFollowupWake,
|
|
2367
|
+
requestHandoff,
|
|
1254
2368
|
mailbox: this.mailbox,
|
|
1255
2369
|
budget: this.budget,
|
|
1256
2370
|
budgetReservation: claim.budgetReservation,
|
|
@@ -1266,6 +2380,9 @@ export class TeamRunner {
|
|
|
1266
2380
|
if (claim.coordinatorAbort) {
|
|
1267
2381
|
throw claim.coordinatorAbort;
|
|
1268
2382
|
}
|
|
2383
|
+
if (claim.handoffCommitted) {
|
|
2384
|
+
throw claim.handoffCommitted.error;
|
|
2385
|
+
}
|
|
1269
2386
|
this._recordSessionUsage(claim, result);
|
|
1270
2387
|
if (
|
|
1271
2388
|
claim.sessionBudgetUsageUnknown ||
|
|
@@ -1322,7 +2439,10 @@ export class TeamRunner {
|
|
|
1322
2439
|
});
|
|
1323
2440
|
if (done.ok) {
|
|
1324
2441
|
if (span) span.end();
|
|
1325
|
-
this._setState(holder, "completed-task"
|
|
2442
|
+
this._setState(holder, "completed-task", {
|
|
2443
|
+
key,
|
|
2444
|
+
sessionTaskKey,
|
|
2445
|
+
});
|
|
1326
2446
|
this._emit("task:completed", {
|
|
1327
2447
|
key,
|
|
1328
2448
|
holder,
|
|
@@ -1390,6 +2510,8 @@ export class TeamRunner {
|
|
|
1390
2510
|
if (outcome?.ok) {
|
|
1391
2511
|
this._setState(holder, "failed-task", {
|
|
1392
2512
|
error: failure?.message || String(failure),
|
|
2513
|
+
key,
|
|
2514
|
+
sessionTaskKey,
|
|
1393
2515
|
});
|
|
1394
2516
|
this._emit("task:failed", {
|
|
1395
2517
|
key,
|