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