@nelsonlongxiang/dsh-open-a2a-net 0.7.0 → 0.8.1-preview.1
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/LICENSE +21 -0
- package/README.md +109 -94
- package/README_zh.md +64 -66
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/assets/nexusDist/assets/index-BuidhKVr.css +1 -0
- package/assets/nexusDist/assets/index-YtYrkDJi.js +9 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +349 -20
- package/cordis.patch.yml +167 -0
- package/docs/licenses/dsh-superagent-MIT.txt +21 -0
- package/lib/a2a-client.js +12 -4
- package/lib/card.js +2 -0
- package/lib/cli-deposit.js +75 -4
- package/lib/config.js +1 -2
- package/lib/direct-forward.js +23 -1
- package/lib/faces/admin.js +31 -48
- package/lib/faces/wire.js +110 -71
- package/lib/followup-admission.js +47 -0
- package/lib/followup-delivery.js +19 -2
- package/lib/index.js +618 -389
- package/lib/node-self.js +1 -1
- package/lib/outbound-scope.js +10 -0
- package/lib/peer-directory.js +3 -1
- package/lib/queue-guard.js +1 -1
- package/lib/receipt.js +24 -2
- package/lib/skill/SKILL.md +1 -0
- package/lib/skill/references/cli.md +193 -21
- package/lib/skill/references/collaboration.md +3 -2
- package/lib/skill/references/superagent.md +106 -0
- package/lib/superagent/a2a-watch.js +587 -0
- package/lib/superagent/contract.js +1 -0
- package/lib/superagent/host/a2a-source.js +251 -0
- package/lib/superagent/host/executor.js +520 -0
- package/lib/superagent/host/index.js +1107 -0
- package/lib/superagent/host/policy.js +308 -0
- package/lib/superagent/host/pr-follow.js +158 -0
- package/lib/superagent/host/review-requests.js +77 -0
- package/lib/superagent/host/role-tools.js +137 -0
- package/lib/superagent/host/store.js +136 -0
- package/lib/superagent/host/tools.js +297 -0
- package/lib/superagent/source-types.js +22 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/assignments.js +160 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/engine.js +244 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/errors.js +63 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/evals.js +381 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/ids.js +5 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/index.js +9 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/intake.js +68 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/memory.js +51 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/model.js +79 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/part.js +71 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/ports.js +1 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/reasons.js +35 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/replies.js +94 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/stage.js +27 -0
- package/lib/superagent/upstream/dsh-superagent/src/core/tickets.js +487 -0
- package/lib/superagent/upstream/snapshot-8c32a9d.js +62 -0
- package/lib/task-auth.js +21 -0
- package/lib/task-ledger-rows.js +4 -0
- package/lib/task-ledger.js +69 -3
- package/lib/tools/info.js +13 -7
- package/lib/types/a2a-client.d.ts +0 -7
- package/lib/types/cli-deposit.d.ts +65 -1
- package/lib/types/config.d.ts +6 -20
- package/lib/types/direct-forward.d.ts +14 -0
- package/lib/types/faces/admin.d.ts +5 -0
- package/lib/types/faces/wire.d.ts +20 -57
- package/lib/types/followup-admission.d.ts +13 -0
- package/lib/types/followup-delivery.d.ts +1 -1
- package/lib/types/index.d.ts +0 -2
- package/lib/types/outbound-scope.d.ts +1 -0
- package/lib/types/queue-guard.d.ts +2 -0
- package/lib/types/receipt.d.ts +21 -1
- package/lib/types/superagent/a2a-watch.d.ts +227 -0
- package/lib/types/superagent/contract.d.ts +36 -0
- package/lib/types/superagent/host/a2a-source.d.ts +90 -0
- package/lib/types/superagent/host/executor.d.ts +132 -0
- package/lib/types/superagent/host/index.d.ts +340 -0
- package/lib/types/superagent/host/policy.d.ts +128 -0
- package/lib/types/superagent/host/pr-follow.d.ts +51 -0
- package/lib/types/superagent/host/review-requests.d.ts +36 -0
- package/lib/types/superagent/host/role-tools.d.ts +85 -0
- package/lib/types/superagent/host/store.d.ts +61 -0
- package/lib/types/superagent/host/tools.d.ts +32 -0
- package/lib/types/superagent/source-types.d.ts +218 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/assignments.d.ts +53 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/engine.d.ts +191 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/errors.d.ts +58 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/evals.d.ts +99 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/ids.d.ts +1 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/index.d.ts +9 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/intake.d.ts +15 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/memory.d.ts +40 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/model.d.ts +432 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/part.d.ts +49 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/ports.d.ts +217 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/reasons.d.ts +36 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/replies.d.ts +55 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/stage.d.ts +18 -0
- package/lib/types/superagent/upstream/dsh-superagent/src/core/tickets.d.ts +151 -0
- package/lib/types/superagent/upstream/snapshot-8c32a9d.d.ts +317 -0
- package/lib/types/task-auth.d.ts +9 -0
- package/lib/types/task-ledger-rows.d.ts +21 -4
- package/lib/types/task-ledger.d.ts +9 -0
- package/lib/types/types.d.ts +7 -4
- package/nexus-stage/scripts/mockhost.py +2 -2
- package/nexus-stage/src/main.ts +27 -0
- package/nexus-stage/src/planning-card-text.ts +1 -1
- package/nexus-stage/src/planning-team-roles.ts +80 -0
- package/nexus-stage/src/planning-types.ts +2 -0
- package/nexus-stage/src/planning-view.ts +1 -0
- package/nexus-stage/src/planning.css +29 -9
- package/nexus-stage/src/wb-superagent.ts +518 -0
- package/nexus-stage/src/workbench.css +32 -0
- package/nexus-stage/src/workbench.ts +25 -3
- package/nexus-stage/src/world.ts +1 -1
- package/package.json +8 -2
- package/assets/nexusDist/assets/index-C7HagaGy.css +0 -1
- package/assets/nexusDist/assets/index-CA_YZTWG.js +0 -9
- package/assets/stageDist/assets/index-Bj7S2pA1.css +0 -1
- package/assets/stageDist/assets/index-C-28k_o5.js +0 -974
- package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
- package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
- package/assets/stageDist/index.html +0 -13
- package/lib/legacy/teams-bridge-inbound.js +0 -156
- package/lib/teams-bridge.js +0 -18
- package/lib/types/legacy/teams-bridge-inbound.d.ts +0 -94
- package/lib/types/teams-bridge.d.ts +0 -112
package/lib/index.js
CHANGED
|
@@ -46,21 +46,25 @@ import { createDirectForward, ForwardBirths } from "./direct-forward.js";
|
|
|
46
46
|
import { TeamMembershipStore } from "./team-store.js";
|
|
47
47
|
import { PeerStore } from "./peer-store.js";
|
|
48
48
|
import { SelfReferralFilter } from "./self-suppress.js";
|
|
49
|
-
import { TaskLedger, SUMMARY_CAP, briefOf } from "./task-ledger.js";
|
|
49
|
+
import { TaskLedger, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
|
|
50
50
|
import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
|
|
51
|
+
import { SuperagentHost } from "./superagent/host/index.js";
|
|
52
|
+
import { registerSuperagentTools } from "./superagent/host/tools.js";
|
|
53
|
+
import { RoleTools } from "./superagent/host/role-tools.js";
|
|
51
54
|
import { ReconciliationReminders } from "./reconciliation-reminders.js";
|
|
52
55
|
import { REMINDER_SCAN_MS, reminderView } from "./reconciliation-state.js";
|
|
53
56
|
import { AdmittedBookkeepingError, ADMITTED_BOOKKEEPING_NOTE } from "./admitted-bookkeeping.js";
|
|
57
|
+
import { FollowupAcceptanceUnknown, ACCEPTANCE_UNKNOWN_NOTE, ACCEPTANCE_INSERTED_NOTE } from "./followup-admission.js";
|
|
54
58
|
import { runReceiptLadder } from "./receipt-ladder.js";
|
|
55
59
|
import { receiptFailure, retryReceiptDelivery, transientReceiptFailure, receiptRetryWait } from "./receipt-retry.js";
|
|
56
|
-
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT,
|
|
60
|
+
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, peerPayloadFingerprint } from "./idempotency-store.js";
|
|
57
61
|
import { createFloodGuard } from "./flood-guard.js";
|
|
58
62
|
import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
|
|
59
63
|
import { resolveZone } from "./zone.js";
|
|
60
64
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
61
65
|
import { isRootOriginUrl, sameMountedUrl, originClaimTeamWarning, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
|
|
62
66
|
import { AuthObserver } from "./auth-observer.js";
|
|
63
|
-
import { claimCovers, allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
|
|
67
|
+
import { claimCovers, allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization, recheckCallerAdmission } from "./task-auth.js";
|
|
64
68
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
65
69
|
import { FollowupDelivery } from "./followup-delivery.js";
|
|
66
70
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
@@ -81,16 +85,13 @@ import { registerFileFaces } from "./faces/files.js";
|
|
|
81
85
|
import { createFileTools } from "./tools/files.js";
|
|
82
86
|
import { BlobStore } from "./blob-store.js";
|
|
83
87
|
import { canvasCandidateMatches, canvasTargetAmbiguous, peerDirectoryRows, routableCallerLabel, sessionCandidateMatches, unroutableCallerNote } from "./peer-directory.js";
|
|
84
|
-
import { createTeamsBridgeInbound } from "./legacy/teams-bridge-inbound.js";
|
|
85
88
|
import { createActTools } from "./tools/act.js";
|
|
86
|
-
import { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
87
89
|
export { A2aClient } from "./a2a-client.js";
|
|
88
|
-
export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
89
90
|
export const name = 'a2a';
|
|
90
91
|
export const inject = ['tools', 'timer', 'systemPrompt'];
|
|
91
92
|
import { Config } from "./config.js";
|
|
92
93
|
import { canvasOwnerFingerprint, splitCanvasOwner } from "./canvas-id.js";
|
|
93
|
-
import { forwardReceiptBasis } from "./direct-forward.js";
|
|
94
|
+
import { forwardReceiptBasis, teamReceiptBasis } from "./direct-forward.js";
|
|
94
95
|
import { NudgeObservability } from "./nudge-observability.js";
|
|
95
96
|
export { Config };
|
|
96
97
|
/**
|
|
@@ -665,7 +666,7 @@ export function apply(ctx, config) {
|
|
|
665
666
|
}
|
|
666
667
|
/**
|
|
667
668
|
* Settle one message through the ledger and announce what it correlated
|
|
668
|
-
* on `a2a/receipt-resolved` — the seam peer plugins
|
|
669
|
+
* on `a2a/receipt-resolved` — the seam peer plugins
|
|
669
670
|
* consume to settle their outstanding async submissions. Fence honesty:
|
|
670
671
|
* the try/catch contains SYNCHRONOUS listener throws (cordis dispatch is
|
|
671
672
|
* synchronous, so a throw lands here and the routing path survives); an
|
|
@@ -696,6 +697,11 @@ export function apply(ctx, config) {
|
|
|
696
697
|
// (enforce): that is the verdict — the echo fallback must not judge the
|
|
697
698
|
// same message a second time.
|
|
698
699
|
const owedRow = parsed !== null && parsed.taskId !== '' ? taskLedger.find(parsed.taskId) : undefined;
|
|
700
|
+
// p1-mailbox-owes (S33, a): a row owed by a CLI identity on this host (the inbound row an owing deposit keeps, or a
|
|
701
|
+
// same-host caller's local row) is answered by that identity alone — keyed on the row, so it holds after the deposit
|
|
702
|
+
// lapses too. A receipt header from anyone else names it without touching it: no settlement, no echo, no outcome.
|
|
703
|
+
if (owedRow !== undefined && (owedRow.direction === 'inbound' || owedRow.peer === 'local') && CLI_HANDLE_RE.test(owedRow.team) && settler?.caller !== owedRow.team)
|
|
704
|
+
return undefined;
|
|
699
705
|
const owedByHeader = owedRow !== undefined;
|
|
700
706
|
// #139 (design §4.4): an enforce refusal of the row the header names is
|
|
701
707
|
// told to the sender on the wire response, not only in /state.
|
|
@@ -703,6 +709,15 @@ export function apply(ctx, config) {
|
|
|
703
709
|
// Capture authority while the owed row is still present; successful resolve archives it.
|
|
704
710
|
const outcomeAllowed = judge?.enforce !== true || (owedRow !== undefined && headerVerdict?.ok === true);
|
|
705
711
|
const resolved = taskLedger.resolveFromMessage(message, judge);
|
|
712
|
+
// An acknowledgement marks the owed row and settles nothing: no waiter is answered, no outcome is recorded, and the
|
|
713
|
+
// settlement seam is not told (its consumers treat a resolution as the end of the task).
|
|
714
|
+
if (parsed?.envelope?.disposition === 'acknowledged') {
|
|
715
|
+
if (resolved?.acknowledged === true)
|
|
716
|
+
logger.info(`a2a: task ${resolved.taskId} acknowledged by its target; it stays owed until the result`);
|
|
717
|
+
return undefined;
|
|
718
|
+
}
|
|
719
|
+
if (resolved !== undefined)
|
|
720
|
+
pruneReceiptWaiters();
|
|
706
721
|
if (parsed !== null && parsed.taskId !== '') {
|
|
707
722
|
const line = parsed.summary.trim();
|
|
708
723
|
// #114 S4 (design §4.2.2): under enforce, the receipt hook writes the
|
|
@@ -726,6 +741,7 @@ export function apply(ctx, config) {
|
|
|
726
741
|
// settles. Formal receipt headers keep observing; settlementAuth 'off' still judges nothing.
|
|
727
742
|
const echoed = taskLedger.resolveEcho(message, judge !== undefined && !judge.enforce ? { ...judge, enforce: true } : judge);
|
|
728
743
|
if (echoed.length > 0) {
|
|
744
|
+
pruneReceiptWaiters();
|
|
729
745
|
logger.info(`a2a: ${String(echoed.length)} pending task(s) settled by content echo (${echoed.join(', ')})`);
|
|
730
746
|
}
|
|
731
747
|
return undefined;
|
|
@@ -760,7 +776,12 @@ export function apply(ctx, config) {
|
|
|
760
776
|
return true;
|
|
761
777
|
try {
|
|
762
778
|
const settled = taskLedger.resolveFromMessage(message, settlementJudgeFor(payer, parsed, true), row => row.direction === 'inbound' && row.peer === `local:${creditor}`);
|
|
779
|
+
// An acknowledgement only marks the row it was born with (it stays owed until the result).
|
|
780
|
+
if (settled?.acknowledged === true)
|
|
781
|
+
return true;
|
|
763
782
|
const line = parsed.summary.trim();
|
|
783
|
+
if (settled !== undefined)
|
|
784
|
+
pruneReceiptWaiters();
|
|
764
785
|
if (settled !== undefined && line !== '')
|
|
765
786
|
idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
|
|
766
787
|
return true;
|
|
@@ -790,6 +811,22 @@ export function apply(ctx, config) {
|
|
|
790
811
|
// Live top-level agents: the join surface's candidates and the cold-row
|
|
791
812
|
// complement (apply scope — the direct-route wake below reads it too).
|
|
792
813
|
const liveRoots = new Map();
|
|
814
|
+
/**
|
|
815
|
+
* The agent preset a live session runs (the preset registry's inspection of its exact revision); undefined when the
|
|
816
|
+
* session is not loaded or the host has no preset registry.
|
|
817
|
+
*/
|
|
818
|
+
const sessionPresetOf = (id) => {
|
|
819
|
+
const agent = liveRoots.get(id);
|
|
820
|
+
const presets = ctx.get('agentPresets');
|
|
821
|
+
if (agent === undefined || presets?.inspectCompositions === undefined)
|
|
822
|
+
return undefined;
|
|
823
|
+
try {
|
|
824
|
+
return presets.inspectCompositions(agent.ctx)[0]?.id;
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
return undefined;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
793
830
|
// Node identity N2+N3: a joined session that carries a node gets that node's layers on its own scope.
|
|
794
831
|
const nodeBindings = new NodeBindings(join(home, 'a2a'));
|
|
795
832
|
const nodeReviews = new NodeReviewQueue(join(home, 'a2a'));
|
|
@@ -1676,6 +1713,8 @@ export function apply(ctx, config) {
|
|
|
1676
1713
|
nudgeEscalations: () => nudgeObservations.snapshot(),
|
|
1677
1714
|
ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, revokeCanvasMember, canvasMigrationState, canvasMemberForWrite, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
|
|
1678
1715
|
indexUrl: import.meta.url, enforceRefused,
|
|
1716
|
+
teamCanvasRefusal: (canvas, member) => superagentHost?.canvasMemberRefusal(canvas, member, sessionPresetOf),
|
|
1717
|
+
admissionRecheck: (caller, handle, local) => recheckCallerAdmission(linkRecheckRecords, caller, handle, local),
|
|
1679
1718
|
get cardCache() { return cardCache; },
|
|
1680
1719
|
get remoteRowsCache() { return remoteRowsCache; },
|
|
1681
1720
|
get legacyInboundCount() { return legacyInboundCount; },
|
|
@@ -1836,7 +1875,7 @@ export function apply(ctx, config) {
|
|
|
1836
1875
|
// gh#152: members are read through the local tiers' lens — one of
|
|
1837
1876
|
// this host's own handles resolves to the session it names, and
|
|
1838
1877
|
// the two stored shapes of one session publish as ONE handle.
|
|
1839
|
-
...(named ? { canvasOrg: canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: [...new Set(canvasLocalMembersOf(name))].map(id => sessionNodes.has(id) || joinedSessions.has(id) ? `${config.team}/${id8(id)}` : id) })) } : {}),
|
|
1878
|
+
...(named ? { canvasOrg: canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: [...new Set(canvasLocalMembersOf(name))].map(id => sessionNodes.has(id) || joinedSessions.has(id) ? `${config.team}/${id8(id)}` : id), ...(superagentHost?.a2aAddress !== undefined && superagentHost.settings.canvas === name ? { superagentTeam: true } : {}) })) } : {}),
|
|
1840
1879
|
...(named && lanIp !== '' ? { lanIp } : {}),
|
|
1841
1880
|
...(PLUGIN_VERSION !== '' ? { version: PLUGIN_VERSION } : {}),
|
|
1842
1881
|
description: `A2A node exposing team ${config.team}${named ? '' : ' (minimal card — OriginClaim envelope required for the full card: X-A2A-Caller/-Url/-Ts/-Sig signed with your card key)'}`,
|
|
@@ -1895,12 +1934,216 @@ export function apply(ctx, config) {
|
|
|
1895
1934
|
warn: code => logger.warn('a2a: sourced followup (%s); check ledger persistence and original task', code),
|
|
1896
1935
|
});
|
|
1897
1936
|
const followupRelay = (agent, text, sender, localFrom, taskId, callerKey, taskExpected = true) => followupDelivery.deliver(agent, text, sender, localFrom, taskId, callerKey, taskExpected);
|
|
1937
|
+
// superagent design v4, PR-B: superagent's domain engine in this host (off unless config.superagent). The router gets
|
|
1938
|
+
// candidate cards in its own session (host relay, not A2A) and decides only through the superagent_* tools.
|
|
1939
|
+
const superagentHost = config.superagent === true ? new SuperagentHost({
|
|
1940
|
+
home: join(home, 'a2a'), logger,
|
|
1941
|
+
deliverToSession: (id, text) => { const agent = sessionNodes.get(id); if (agent === undefined)
|
|
1942
|
+
return false; followupRelay(agent, text, config.team); return true; },
|
|
1943
|
+
interval: (fn, ms) => ctx.timer.interval(fn, ms),
|
|
1944
|
+
// The team's A2A address (PR-B2b) is a CLI identity the a2a-net CLI runs against this very host.
|
|
1945
|
+
homeUrl: () => superagentHomeUrl,
|
|
1946
|
+
apiKey: () => config.apiKey,
|
|
1947
|
+
// PR-C2: remote members and served teams talk host to host, signed with this host's card key and bound to the body.
|
|
1948
|
+
signedPost: async (baseUrl, path, body) => {
|
|
1949
|
+
const target = baseUrl.replace(/\/$/, '') + path;
|
|
1950
|
+
const text = JSON.stringify(body);
|
|
1951
|
+
const reply = await peerFetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json', ...originClaimHeaders({ url: target, method: 'POST', body: text }) }, body: text, signal: AbortSignal.timeout(15_000) });
|
|
1952
|
+
if (!reply.ok) {
|
|
1953
|
+
void reply.body?.cancel().catch(() => { });
|
|
1954
|
+
return { ok: false, status: reply.status, error: `HTTP ${String(reply.status)}` };
|
|
1955
|
+
}
|
|
1956
|
+
try {
|
|
1957
|
+
return JSON.parse(await reply.text());
|
|
1958
|
+
}
|
|
1959
|
+
catch {
|
|
1960
|
+
return { ok: false, error: 'not JSON' };
|
|
1961
|
+
}
|
|
1962
|
+
},
|
|
1963
|
+
handleOf: id => `${config.team}/${id8(id)}`,
|
|
1964
|
+
localSessionOf: handle => localSessionOfHandle(handle),
|
|
1965
|
+
onRolesChanged: () => { syncRoleTools(); },
|
|
1966
|
+
// Design v5: the team is a canvas team this host owns. Its local sessions in canvas order, and its remote session slots
|
|
1967
|
+
// with the key the canvas pinned and the base URL of the linked peer whose signed card carries that key.
|
|
1968
|
+
canvasTeam: name => {
|
|
1969
|
+
if (!canvasStore.hasTeam(name))
|
|
1970
|
+
return undefined;
|
|
1971
|
+
const ownKey = encodePublicKey(createPublicKey(nodePrivateKey));
|
|
1972
|
+
const local = canvasLocalMembersOf(name, true).filter(id => !id.includes('/'));
|
|
1973
|
+
const remote = canvasStore.membersOf(name).flatMap(ref => {
|
|
1974
|
+
if (CLI_HANDLE_RE.test(ref) || splitQualifiedMember(ref) === undefined)
|
|
1975
|
+
return [];
|
|
1976
|
+
const key = canvasStore.ownerKeyOf(ref);
|
|
1977
|
+
if (key === undefined || key === ownKey || memberProvenance(ref, key, ownKey) !== 'confirmed-remote')
|
|
1978
|
+
return [];
|
|
1979
|
+
const url = [...cardCache.entries()].find(([, entry]) => entry.card?.publicKey === key)?.[0];
|
|
1980
|
+
return [{ ref, key, url: url !== undefined ? new URL(url).origin : '' }];
|
|
1981
|
+
});
|
|
1982
|
+
return { local, remote };
|
|
1983
|
+
},
|
|
1984
|
+
canvasesOf: id => canvasTeamsOfSession(id),
|
|
1985
|
+
canvasNames: () => canvasStore.list(),
|
|
1986
|
+
presetOf: id => sessionPresetOf(id),
|
|
1987
|
+
cancelTurn: id => {
|
|
1988
|
+
const agent = sessionNodes.get(id);
|
|
1989
|
+
if (agent === undefined || agent.status === 'idle' || typeof agent.cancel !== 'function')
|
|
1990
|
+
return false;
|
|
1991
|
+
agent.cancel({ kind: 'user' }, { keepInbox: false });
|
|
1992
|
+
return true;
|
|
1993
|
+
},
|
|
1994
|
+
memberTurn: id => {
|
|
1995
|
+
const agent = sessionNodes.get(id);
|
|
1996
|
+
if (agent === undefined)
|
|
1997
|
+
return undefined;
|
|
1998
|
+
const jobs = ctx.get('jobs');
|
|
1999
|
+
const quiet = !(jobs?.list?.(id) ?? []).some(job => job.owner === id && (job.status === 'running' || job.status === 'stopping'));
|
|
2000
|
+
const last = [...sessionEventsOf(agent)].reverse().find(event => event?.type === 'assistant/message');
|
|
2001
|
+
return { quiet, text: (last?.data?.message?.content ?? []).filter(block => block.type === 'text').map(block => block.text ?? '').join('\n').trim() };
|
|
2002
|
+
},
|
|
2003
|
+
teamSend: args => teamCanvasSend(args),
|
|
2004
|
+
}) : undefined;
|
|
2005
|
+
// PR-D: the router and the members call only their role's tools — a host guard, whatever preset the session runs.
|
|
2006
|
+
const superagentRoleTools = new RoleTools({
|
|
2007
|
+
roleOf: id => superagentHost?.roleOf(id),
|
|
2008
|
+
agentOf: id => ctx.get('agents')?.get(SessionId(id)),
|
|
2009
|
+
logger,
|
|
2010
|
+
});
|
|
2011
|
+
function syncRoleTools() { for (const agent of liveRootAgents(ctx.get('agents')))
|
|
2012
|
+
superagentRoleTools.sync(agent); }
|
|
2013
|
+
let superagentHomeUrl;
|
|
2014
|
+
if (superagentHost !== undefined) {
|
|
2015
|
+
whenWebServerSettled(webServer => { superagentHomeUrl = `http://127.0.0.1:${String(webServer.port)}`; });
|
|
2016
|
+
ctx.effect(() => { superagentHost.start(); return () => { void superagentHost.stop(); }; }, 'a2a: superagent engine passes');
|
|
2017
|
+
registerSuperagentTools(ctx, () => superagentHost);
|
|
2018
|
+
// A member's turn ending is the executor's turn-end fact (PR-C1).
|
|
2019
|
+
ctx.on('agent/status', payload => { if (payload.status === 'idle')
|
|
2020
|
+
superagentHost.memberIdle(String(payload.agent.id)); });
|
|
2021
|
+
// A failed turn is retried or fails the ticket for a person (upstream); without this it read as still working.
|
|
2022
|
+
ctx.on('agent/error', ({ agent, error }) => { superagentHost.memberFailed(String(agent.id), error); });
|
|
2023
|
+
// PR-F: an Input counts as read once the member's session takes the message carrying it into its history.
|
|
2024
|
+
ctx.on('agent/inbox/claimed', ({ agent }) => { superagentHost.memberClaimed(String(agent.id)); });
|
|
2025
|
+
ctx.tools.guard(exec => {
|
|
2026
|
+
const refusal = superagentRoleTools.guard(exec.name, exec.agent);
|
|
2027
|
+
if (refusal !== undefined)
|
|
2028
|
+
superagentHost.noteRefusal('superagent-role-tools', { ...(exec.agent !== undefined ? { caller: String(exec.agent.id) } : {}), target: exec.name });
|
|
2029
|
+
return refusal;
|
|
2030
|
+
});
|
|
2031
|
+
ctx.on('agent/created', ({ agent }) => { superagentRoleTools.sync(agent); return undefined; });
|
|
2032
|
+
ctx.on('agent/disposed', ({ agent }) => { superagentRoleTools.forget(String(agent.id)); });
|
|
2033
|
+
syncRoleTools();
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* The router acts only through superagent tools and takes work only as candidates from the team's Sources, so A2A
|
|
2037
|
+
* neither reaches it nor leaves it (hard rule, every route seam).
|
|
2038
|
+
* @returns the refusal, or undefined when the router is not involved.
|
|
2039
|
+
*/
|
|
2040
|
+
function superagentRouterRefusal(callerSessionId, target) {
|
|
2041
|
+
const refusal = superagentRefusalText(callerSessionId, target);
|
|
2042
|
+
// Every caller returns this refusal to the route it refused, so each refused route counts once (PR-F: acceptance item 7).
|
|
2043
|
+
if (refusal !== undefined)
|
|
2044
|
+
superagentHost?.noteRefusal(/^\[([a-z-]+)\]/.exec(refusal)?.[1] ?? 'superagent', { ...(callerSessionId !== undefined ? { caller: callerSessionId } : {}), target });
|
|
2045
|
+
return refusal;
|
|
2046
|
+
}
|
|
2047
|
+
function superagentRefusalText(callerSessionId, target) {
|
|
2048
|
+
if (superagentHost === undefined)
|
|
2049
|
+
return undefined;
|
|
2050
|
+
const callerRole = callerSessionId === undefined ? undefined : superagentHost.roleOf(callerSessionId);
|
|
2051
|
+
if (callerRole === 'router')
|
|
2052
|
+
return '[superagent-router-outbound] the team router does not route A2A; it decides with superagent_route and replies with superagent_reply';
|
|
2053
|
+
if (callerRole === 'member')
|
|
2054
|
+
return '[superagent-member-outbound] a team member does not route A2A; it works on its assigned ticket and reports with a2a_ticket_report';
|
|
2055
|
+
const bare = target.replace(/@[0-9a-f]{8}$/, '');
|
|
2056
|
+
const named = (id) => { const handle = `${config.team}/${id8(id)}`; return target === id || (bare === handle && (target === bare || target.endsWith('@' + ownCanvasFingerprint))); };
|
|
2057
|
+
const router = superagentHost.routerSession;
|
|
2058
|
+
if (router !== undefined && named(router))
|
|
2059
|
+
return '[superagent-router-inbound] the team router takes no A2A work; send requests to the team\'s Sources (the workbench or its A2A address), where the gate admits them';
|
|
2060
|
+
if (superagentHost.settings.members.some(named))
|
|
2061
|
+
return '[superagent-member-inbound] a team member takes work only as assignments from the team\'s executor; send requests to the team\'s Sources';
|
|
2062
|
+
return undefined;
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* Design v5 (D2): the team's address is its canvas team. A route to the bound canvas — bare, or qualified with this
|
|
2066
|
+
* host's own fingerprint — lands in the team's mailbox (the A2A Source reads it), never in a member session.
|
|
2067
|
+
*/
|
|
2068
|
+
function teamMailboxOf(team) {
|
|
2069
|
+
const bound = superagentHost?.settings.canvas;
|
|
2070
|
+
const address = superagentHost?.a2aAddress;
|
|
2071
|
+
if (bound === undefined || address === undefined)
|
|
2072
|
+
return undefined;
|
|
2073
|
+
const split = splitCanvasOwner(team.trim());
|
|
2074
|
+
if (split.owner !== undefined && split.owner !== ownCanvasFingerprint)
|
|
2075
|
+
return undefined;
|
|
2076
|
+
return split.team === `${config.team}/canvas/${bound}` ? address : undefined;
|
|
2077
|
+
}
|
|
2078
|
+
/** Design v5 (D2): a route straight to the team identity's handle is refused (a receipt to it still lands): the address is the canvas. */
|
|
2079
|
+
function teamAddressRefusal(team, message) {
|
|
2080
|
+
const address = superagentHost?.a2aAddress;
|
|
2081
|
+
if (address === undefined || team.trim() !== address || parseReceipt(message) !== null)
|
|
2082
|
+
return undefined;
|
|
2083
|
+
const bound = superagentHost?.settings.canvas;
|
|
2084
|
+
superagentHost?.noteRefusal('superagent-team-address', { target: team });
|
|
2085
|
+
return `[superagent-team-address] ${address} is the team's internal mailbox; send requests to the team's canvas address${bound !== undefined ? ` ${config.team}/canvas/${bound}` : ' (the team is bound to no canvas team yet)'}`;
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Design v5 (D2, review R1): the team's own messages to a requester that came through its canvas address — the formal
|
|
2089
|
+
* receipt (result, attention or acknowledgement) and follow-ups in the same conversation — go out from this host's
|
|
2090
|
+
* process face on the canvas team's behalf, with onBehalfOf spelled exactly as the requester addressed it, so the
|
|
2091
|
+
* requester's ledger settles them as host-face. Speaks the a2a-net CLI's `receipt` / `route` JSON contract (stdout, exit
|
|
2092
|
+
* codes 4 gone / 5 another receipt) for the A2A Source; undefined when the task did not come through the canvas.
|
|
2093
|
+
*/
|
|
2094
|
+
async function teamCanvasSend(args) {
|
|
2095
|
+
const verb = args[0];
|
|
2096
|
+
if (verb !== 'receipt' && verb !== 'route')
|
|
2097
|
+
return undefined;
|
|
2098
|
+
const dash = args.indexOf('--');
|
|
2099
|
+
const text = dash >= 0 ? args.slice(dash + 1).join(' ') : '';
|
|
2100
|
+
const flagOf = (name) => { const i = args.indexOf(name); return i >= 0 && (dash < 0 || i < dash) ? args[i + 1] : undefined; };
|
|
2101
|
+
const fail = (code, error) => { const e = new Error(error); e.exitCode = code; throw e; };
|
|
2102
|
+
if (verb === 'receipt') {
|
|
2103
|
+
const task = args[1] ?? '', to = flagOf('--to') ?? '';
|
|
2104
|
+
const row = taskLedger.find(task);
|
|
2105
|
+
if (row === undefined || teamMailboxOf(row.team) === undefined || to === '')
|
|
2106
|
+
return undefined;
|
|
2107
|
+
// Review of #442: switches are read before `--` only — the text after it may be anything.
|
|
2108
|
+
const switches = dash >= 0 ? args.slice(0, dash) : args;
|
|
2109
|
+
const acknowledged = switches.includes('--acknowledged'), attention = switches.includes('--attention');
|
|
2110
|
+
const outcome = flagOf('--outcome');
|
|
2111
|
+
const fields = acknowledged ? { idempotencyKey: task, disposition: 'acknowledged', onBehalfOf: row.team }
|
|
2112
|
+
: attention ? { idempotencyKey: task, disposition: 'attention', onBehalfOf: row.team }
|
|
2113
|
+
: { outcome: (outcome ?? 'completed'), idempotencyKey: task, disposition: 'result', onBehalfOf: row.team };
|
|
2114
|
+
const receipt = formatReceipt(task, text.replace(/\s+/g, ' ').trim(), fields);
|
|
2115
|
+
const receiptTask = `${acknowledged ? 'ack' : 'receipt'}-${task}`;
|
|
2116
|
+
try {
|
|
2117
|
+
const out = await dispatchAnywhere(to, receipt, session, row.contextId, new AbortController().signal, true, receiptTask);
|
|
2118
|
+
// This host's own inbound row (a requester on another host or a CLI here): the receipt it sent settles it.
|
|
2119
|
+
if (row.direction === 'inbound' && !acknowledged)
|
|
2120
|
+
settleOwnInbound(receipt, to, { caller: session, proof: 'in-process', ingress: 'local' });
|
|
2121
|
+
return JSON.stringify({ delivered: true, class: 'sent', task_id: receiptTask, team: to, ...(typeof out.context_id === 'string' ? { context_id: out.context_id } : {}) });
|
|
2122
|
+
}
|
|
2123
|
+
catch (error) {
|
|
2124
|
+
const code = error.code;
|
|
2125
|
+
return fail(code === WIRE_ERROR_IDEMPOTENCY_CONFLICT ? 5 : 1, String(error instanceof Error ? error.message : error));
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
const to = args[1] ?? '', context = flagOf('--context') ?? '', followTask = flagOf('--task');
|
|
2129
|
+
const origin = taskLedger.list().find(r => r.contextId === context && teamMailboxOf(r.team) !== undefined);
|
|
2130
|
+
if (origin === undefined || to === '')
|
|
2131
|
+
return undefined;
|
|
2132
|
+
try {
|
|
2133
|
+
await dispatchAnywhere(to, text, session, context, new AbortController().signal, true, followTask);
|
|
2134
|
+
return JSON.stringify({ delivered: true, class: 'sent', ...(followTask !== undefined ? { task_id: followTask } : {}), team: to, context_id: context });
|
|
2135
|
+
}
|
|
2136
|
+
catch (error) {
|
|
2137
|
+
return fail(1, String(error instanceof Error ? error.message : error));
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
1898
2140
|
ctx.on('agent/inbox/claimed', ({ agent, message }) => followupDelivery.claimed(agent, String(message.id)));
|
|
1899
2141
|
const withdrawQueuedTask = (taskId) => {
|
|
1900
2142
|
const controller = ctx.get('sessionController');
|
|
1901
2143
|
return followupDelivery.withdraw(taskId, controller, id => ctx.get('agents')?.get(SessionId(id)));
|
|
1902
2144
|
};
|
|
1903
2145
|
const cancelQueuedTask = async (taskId, text, sender, original) => {
|
|
2146
|
+
pruneReceiptWaiters();
|
|
1904
2147
|
let record, withdrawFailed = false, removed = false;
|
|
1905
2148
|
try {
|
|
1906
2149
|
record = taskLedger.deliveryFor(taskId);
|
|
@@ -2026,6 +2269,9 @@ export function apply(ctx, config) {
|
|
|
2026
2269
|
}
|
|
2027
2270
|
};
|
|
2028
2271
|
function armAsyncNudge(agent, team, taskId, delayMs) {
|
|
2272
|
+
// asyncNudgeDelayMs 0 (or less) turns the nudge off, as the config schema and README promise (#453).
|
|
2273
|
+
if (!(delayMs > 0))
|
|
2274
|
+
return;
|
|
2029
2275
|
const key = String(agent.id);
|
|
2030
2276
|
const spent = nudgeBudget.get(key);
|
|
2031
2277
|
if (spent !== undefined && spent > 1)
|
|
@@ -2127,6 +2373,13 @@ export function apply(ctx, config) {
|
|
|
2127
2373
|
// target produced after consuming `[A2A direct] (task <id>)` answers it
|
|
2128
2374
|
// (flushFinals). A busy target's other work line can no longer settle
|
|
2129
2375
|
// a task it has not read yet (the #61 direct-bb27ec94 shape).
|
|
2376
|
+
// Defensive only: no current normal replay/reentry path reaches a second
|
|
2377
|
+
// registration. Both wire sites follow the idempotency claim; local tools
|
|
2378
|
+
// mint an id per call, background notices mint one, and receipt retries keep
|
|
2379
|
+
// their id but exit above (receipts owe no receipts). No present-path mutation
|
|
2380
|
+
// coverage claim: this check guards future/internal callers, not replay admission.
|
|
2381
|
+
if ((pendingFinals.get(String(target.id)) ?? []).some(entry => entry.purpose === 'receipt' && entry.taskId === taskId))
|
|
2382
|
+
return;
|
|
2130
2383
|
registerFinalWaiter(target, (finalText, placeholder) => {
|
|
2131
2384
|
// W7 slice-2: a host-authored placeholder (flush timeout, dead
|
|
2132
2385
|
// session) must not synthesize a COMPLETION receipt — a wedged
|
|
@@ -2173,7 +2426,9 @@ export function apply(ctx, config) {
|
|
|
2173
2426
|
/* Legacy permanent-failure ladder remains the owner-settlement policy. */
|
|
2174
2427
|
const ladder = () => runReceiptLadder({
|
|
2175
2428
|
deliver: deliverReceipt,
|
|
2176
|
-
transient
|
|
2429
|
+
// Unknown admission must stop the owner lane; unlike a transport transient,
|
|
2430
|
+
// it is not retryable by retryReceiptDelivery and retains the original debt.
|
|
2431
|
+
transient: error => error instanceof FollowupAcceptanceUnknown || transientReceiptFailure(error),
|
|
2177
2432
|
ownerTeam: config.team,
|
|
2178
2433
|
log: (stage, error) => {
|
|
2179
2434
|
// N2 (observability): throttled per task to avoid ping-pong noise.
|
|
@@ -2190,10 +2445,10 @@ export function apply(ctx, config) {
|
|
|
2190
2445
|
notify: async () => { await dispatchAnywhere(config.team, '[A2A receipt delivery notice] ref ' + nudgeRef(taskId) + '; delivery to the original caller is temporarily unconfirmed; host retry is responsible. This notice is not a receipt and does not settle work.', session, undefined, new AbortController().signal, true); },
|
|
2191
2446
|
pending: () => row === undefined || taskLedger.isPending(taskId), wait, log: () => { logger.warn('a2a: receipt delivery temporarily unconfirmed ref=%s', nudgeRef(taskId)); },
|
|
2192
2447
|
}).catch(() => { });
|
|
2193
|
-
}, { taskId, steerFloor });
|
|
2448
|
+
}, { taskId, steerFloor }, 'receipt');
|
|
2194
2449
|
}
|
|
2195
2450
|
/**
|
|
2196
|
-
* Register one
|
|
2451
|
+
* Register one attributed waiter: HTTP has a flush window, receipt tracking the original task TTL.
|
|
2197
2452
|
* @param agent - the agent whose next assistant message answers the waiter.
|
|
2198
2453
|
* @param answer - how the waiter's reply is delivered.
|
|
2199
2454
|
* @param attribution - #114 S5: attribute by consumption — only the turn
|
|
@@ -2203,14 +2458,20 @@ export function apply(ctx, config) {
|
|
|
2203
2458
|
* anchors it.
|
|
2204
2459
|
* @returns the registered waiter (for callers that may retract it).
|
|
2205
2460
|
*/
|
|
2206
|
-
function registerFinalWaiter(agent, answer, attribution) {
|
|
2461
|
+
function registerFinalWaiter(agent, answer, attribution, purpose = 'http') {
|
|
2207
2462
|
const key = String(agent.id);
|
|
2208
2463
|
const waiter = {
|
|
2209
|
-
answer,
|
|
2464
|
+
answer, purpose,
|
|
2210
2465
|
sinceEvents: sessionEventsOf(agent).length,
|
|
2211
2466
|
...(attribution !== undefined && attribution.taskId !== '' ? { taskId: attribution.taskId, scannedTo: attribution.steerFloor } : {}),
|
|
2212
2467
|
};
|
|
2213
|
-
|
|
2468
|
+
if (purpose === 'receipt') {
|
|
2469
|
+
const row = attribution === undefined ? undefined : taskLedger.find(attribution.taskId);
|
|
2470
|
+
const remaining = Math.max(0, (row?.startedAt ?? Date.now()) + (config.taskStaleTtlMs ?? TASK_STALE_TTL_MS) - Date.now());
|
|
2471
|
+
waiter.timeoutDisposer = ctx.timer.timeout(() => removeFinalWaiter(key, waiter), remaining);
|
|
2472
|
+
}
|
|
2473
|
+
else
|
|
2474
|
+
waiter.timeoutDisposer = armFlushTimeout(key, waiter);
|
|
2214
2475
|
pendingFinals.set(key, [...(pendingFinals.get(key) ?? []), waiter]);
|
|
2215
2476
|
return waiter;
|
|
2216
2477
|
}
|
|
@@ -2232,22 +2493,6 @@ export function apply(ctx, config) {
|
|
|
2232
2493
|
if (wake.kind === 'flight') {
|
|
2233
2494
|
return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
|
|
2234
2495
|
}
|
|
2235
|
-
// Native-teams inbound bridge (opt-in): a registry team claims the
|
|
2236
|
-
// handle and dispatches through its authoritative seam. Checked before
|
|
2237
|
-
// the bare-team fallback — a team named exactly like this node's team
|
|
2238
|
-
// resolves to the registry's claim, not the initiator redirect.
|
|
2239
|
-
if (config.nativeTeamsInbound === true) {
|
|
2240
|
-
const prepared = await nativeTeamsPrepare(team);
|
|
2241
|
-
if (prepared.ok) {
|
|
2242
|
-
const bridged = await nativeTeamsRound(prepared, team, caller, message, taskId, proofClause ?? '未验证(in-process)', undefined, buildHeader === undefined ? undefined : (_team, _taskId, from) => buildHeader(from));
|
|
2243
|
-
if (bridged.ok) {
|
|
2244
|
-
if (bridged.settled)
|
|
2245
|
-
return { ok: true, reply: bridged.reply };
|
|
2246
|
-
return { ok: true, reply: bridged.reply, status: 'TASK_STATE_DELIVERED', bridge: 'native-teams' };
|
|
2247
|
-
}
|
|
2248
|
-
return { ok: false, error: bridged.error };
|
|
2249
|
-
}
|
|
2250
|
-
}
|
|
2251
2496
|
// Only the bare process team falls back to the live (initiator) agent.
|
|
2252
2497
|
// A session-node-shaped team that misses is an error, never a silent
|
|
2253
2498
|
// redirect: a mistyped or stale short id must not reach another session
|
|
@@ -2275,6 +2520,10 @@ export function apply(ctx, config) {
|
|
|
2275
2520
|
* @returns the agent's final reply, or the explicit failure.
|
|
2276
2521
|
*/
|
|
2277
2522
|
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey) {
|
|
2523
|
+
// superagent v4: whatever address resolved to it (a canvas team, the node team), the router takes no A2A work.
|
|
2524
|
+
if (superagentHost?.roleOf(String(agent.id)) !== undefined) {
|
|
2525
|
+
return Promise.resolve({ ok: false, error: superagentRouterRefusal(undefined, String(agent.id)) ?? 'superagent team sessions take no A2A work' });
|
|
2526
|
+
}
|
|
2278
2527
|
return new Promise((resolve) => {
|
|
2279
2528
|
// #306: synchronous replies use the same reading-turn attribution as automatic receipts.
|
|
2280
2529
|
// Registering and steering are synchronous, so this is the exact pre-steer floor.
|
|
@@ -2309,8 +2558,13 @@ export function apply(ctx, config) {
|
|
|
2309
2558
|
const text = `${headerLine}\n\n${message}`;
|
|
2310
2559
|
try {
|
|
2311
2560
|
const floor = sessionEventsOf(agent).length;
|
|
2312
|
-
followupRelay(agent, text, caller === '' ? undefined : caller, localFrom, taskId, callerKey, onSteered !== undefined);
|
|
2313
|
-
onSteered?.(agent, floor);
|
|
2561
|
+
const acceptance = followupRelay(agent, text, caller === '' ? undefined : caller, localFrom, taskId, callerKey, onSteered !== undefined);
|
|
2562
|
+
onSteered?.(agent, floor, acceptance);
|
|
2563
|
+
if (acceptance === 'inserted') {
|
|
2564
|
+
removeFinalWaiter(String(agent.id), waiter);
|
|
2565
|
+
resolve({ ok: true, reply: ACCEPTANCE_INSERTED_NOTE, acceptance, placeholder: true });
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2314
2568
|
admissionFinished = true;
|
|
2315
2569
|
if (earlyReply)
|
|
2316
2570
|
resolve(earlyReply);
|
|
@@ -2332,15 +2586,14 @@ export function apply(ctx, config) {
|
|
|
2332
2586
|
resolve({ ok: true, reply: ADMITTED_BOOKKEEPING_NOTE, receiverBookkeeping: 'failed', placeholder: true });
|
|
2333
2587
|
return;
|
|
2334
2588
|
}
|
|
2589
|
+
if (error instanceof FollowupAcceptanceUnknown) {
|
|
2590
|
+
resolve({ ok: false, error: ACCEPTANCE_UNKNOWN_NOTE, acceptance: 'unknown' });
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2335
2593
|
resolve({ ok: false, error: `The DSH session rejected the message: ${String(error)}` });
|
|
2336
2594
|
}
|
|
2337
2595
|
});
|
|
2338
2596
|
}
|
|
2339
|
-
// gh#77 G-in (S23 repayment): the native-teams inbound bridge moved verbatim
|
|
2340
|
-
// to src/legacy/teams-bridge-inbound.ts — the registry probe, claim memo,
|
|
2341
|
-
// prepare and the bounded round. The outbound bridgeFace stays here until
|
|
2342
|
-
// the G-out slice (it submits through the dispatch trunk).
|
|
2343
|
-
const { teamsRegistry, nativeTeamsClaims, nativeTeamsPrepare, nativeTeamsRound } = createTeamsBridgeInbound({ ctx, config, liveAgent, fromClause, sanitizeFromClause, proofHeader: hostDirectHeader, taint: agent => { markLineage(agent, 'native-teams'); } });
|
|
2344
2597
|
/* ── gh#9+gh#10 OriginClaim (docs/protocol/delivery-origin-auth.md,
|
|
2345
2598
|
src/origin-claim.ts): the caller's card key signs the envelope; the
|
|
2346
2599
|
bound form must name one of OUR origins and the route it hits. ts skew
|
|
@@ -2608,6 +2861,21 @@ export function apply(ctx, config) {
|
|
|
2608
2861
|
cliDeposited: handle => cliDeposits.get(handle) !== undefined,
|
|
2609
2862
|
cliHomeOf: handle => canvasStore.cliHomeOf(handle),
|
|
2610
2863
|
};
|
|
2864
|
+
/**
|
|
2865
|
+
* v5-B2: an identity's `admission` query rechecks a caller it already took (its key was verified when the delivery came
|
|
2866
|
+
* in): only that the operator's link still stands. A confirmed canvas remote reference pinned with its key is that link
|
|
2867
|
+
* for the bare handle too — the delivery-time records match a qualified reference only under the signer's key, which a
|
|
2868
|
+
* recheck does not carry.
|
|
2869
|
+
*/
|
|
2870
|
+
const linkRecheckRecords = {
|
|
2871
|
+
...admissionRecords,
|
|
2872
|
+
canvasRemoteMembersOf: name => canvasStore.remoteMembersOf(name).flatMap(ref => {
|
|
2873
|
+
const qualified = splitQualifiedMember(ref);
|
|
2874
|
+
if (qualified === undefined)
|
|
2875
|
+
return [ref];
|
|
2876
|
+
return memberProvenance(ref, canvasStore.ownerKeyOf(ref), encodePublicKey(createPublicKey(nodePrivateKey))) === 'confirmed-remote' ? [ref, qualified.handle] : [ref];
|
|
2877
|
+
}),
|
|
2878
|
+
};
|
|
2611
2879
|
/** W1/W2 shared-team admission (see sameZoneAdmission in src/task-auth.ts). */
|
|
2612
2880
|
function sameZoneSharedTeamRefusal(caller, targetTeam) {
|
|
2613
2881
|
return refusalOf(sameZoneAdmission(admissionRecords, caller, targetTeam));
|
|
@@ -2731,38 +2999,45 @@ export function apply(ctx, config) {
|
|
|
2731
2999
|
identical semantics — artifact existence is the only gate (archived
|
|
2732
3000
|
and never-joined sessions stay readable), caps mirror
|
|
2733
3001
|
session-transcript.ts. */
|
|
3002
|
+
/** `dshHome` may move only the plugin's own state, with the host still writing session logs under its own home. */
|
|
3003
|
+
function sessionLogRoots() {
|
|
3004
|
+
const own = join(home, 'sessions');
|
|
3005
|
+
const root = existsSync(own) ? own : join(resolveDshHome(), 'sessions');
|
|
3006
|
+
return existsSync(root) ? [root] : [];
|
|
3007
|
+
}
|
|
2734
3008
|
function scanSessionLogList() {
|
|
2735
3009
|
const rows = [];
|
|
2736
|
-
const
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
if (!bucket.isDirectory())
|
|
2741
|
-
continue;
|
|
2742
|
-
for (const child of readdirSync(join(sessionsRoot, bucket.name), { withFileTypes: true })) {
|
|
2743
|
-
if (!child.isDirectory())
|
|
2744
|
-
continue;
|
|
2745
|
-
const dir = join(sessionsRoot, bucket.name, child.name);
|
|
2746
|
-
const files = readdirSync(dir).filter(name => name.startsWith('session.') && (name.endsWith('.jsonl.zstd') || name.endsWith('.jsonl')));
|
|
2747
|
-
if (files.length === 0)
|
|
3010
|
+
const seen = new Set();
|
|
3011
|
+
for (const sessionsRoot of sessionLogRoots()) {
|
|
3012
|
+
for (const bucket of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
|
3013
|
+
if (!bucket.isDirectory())
|
|
2748
3014
|
continue;
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
3015
|
+
for (const child of readdirSync(join(sessionsRoot, bucket.name), { withFileTypes: true })) {
|
|
3016
|
+
if (!child.isDirectory() || seen.has(child.name))
|
|
3017
|
+
continue;
|
|
3018
|
+
const dir = join(sessionsRoot, bucket.name, child.name);
|
|
3019
|
+
const files = readdirSync(dir).filter(name => name.startsWith('session.') && (name.endsWith('.jsonl.zstd') || name.endsWith('.jsonl')));
|
|
3020
|
+
if (files.length === 0)
|
|
3021
|
+
continue;
|
|
3022
|
+
let newest;
|
|
3023
|
+
for (const name of files) {
|
|
3024
|
+
const st = statSync(join(dir, name));
|
|
3025
|
+
if (newest === undefined || st.mtimeMs > newest.mtimeMs)
|
|
3026
|
+
newest = { mtimeMs: st.mtimeMs, sizeBytes: st.size };
|
|
3027
|
+
}
|
|
3028
|
+
if (newest !== undefined) {
|
|
3029
|
+
seen.add(child.name);
|
|
3030
|
+
rows.push({ id: child.name, ...newest });
|
|
3031
|
+
}
|
|
2754
3032
|
}
|
|
2755
|
-
if (newest !== undefined)
|
|
2756
|
-
rows.push({ id: child.name, ...newest });
|
|
2757
3033
|
}
|
|
2758
3034
|
}
|
|
2759
3035
|
rows.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2760
3036
|
return rows;
|
|
2761
3037
|
}
|
|
2762
3038
|
function readSessionLogTail(id, limit) {
|
|
2763
|
-
const sessionsRoot = join(home, 'sessions');
|
|
2764
3039
|
let logPath;
|
|
2765
|
-
|
|
3040
|
+
search: for (const sessionsRoot of sessionLogRoots()) {
|
|
2766
3041
|
for (const entry of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
|
2767
3042
|
if (!entry.isDirectory())
|
|
2768
3043
|
continue;
|
|
@@ -2777,7 +3052,7 @@ export function apply(ctx, config) {
|
|
|
2777
3052
|
const rank = (name) => generation(name) * 2 + (name.endsWith('.zstd') ? 1 : 0);
|
|
2778
3053
|
files.sort((a, b) => rank(b) - rank(a));
|
|
2779
3054
|
logPath = files[0] !== undefined ? join(candidate, files[0]) : undefined;
|
|
2780
|
-
break;
|
|
3055
|
+
break search;
|
|
2781
3056
|
}
|
|
2782
3057
|
}
|
|
2783
3058
|
if (logPath === undefined)
|
|
@@ -2795,6 +3070,12 @@ export function apply(ctx, config) {
|
|
|
2795
3070
|
config,
|
|
2796
3071
|
cliDeposits,
|
|
2797
3072
|
canvasStore, canvasMemberForWrite,
|
|
3073
|
+
teamCanvasRefusal: (canvas, member) => superagentHost?.canvasMemberRefusal(canvas, member, sessionPresetOf),
|
|
3074
|
+
teamCanvasBound: (canvas) => superagentHost?.settings.canvas === canvas,
|
|
3075
|
+
superagentInbound: team => superagentRouterRefusal(undefined, team),
|
|
3076
|
+
superagentIntake: team => superagentHost?.a2aAddress === team,
|
|
3077
|
+
teamMailboxOf: team => teamMailboxOf(team),
|
|
3078
|
+
teamAddressRefusal: (team, message) => teamAddressRefusal(team, message),
|
|
2798
3079
|
canvasNameReserved: name => canvasNameReserved(name, config.team, ownerAllowlist, teamMemberships),
|
|
2799
3080
|
sessionNodes,
|
|
2800
3081
|
joinedSessions,
|
|
@@ -2803,6 +3084,7 @@ export function apply(ctx, config) {
|
|
|
2803
3084
|
peerKeyAdmits,
|
|
2804
3085
|
remoteCliProof,
|
|
2805
3086
|
forwardReceiptBasis: (taskId, caller, cliHandle, claim) => forwardReceiptBasis(taskLedger.find(taskId), caller, cliHandle, claim),
|
|
3087
|
+
teamReceiptBasis: (taskId, caller, target, onBehalfOf, claim) => teamReceiptBasis(taskLedger.find(taskId), caller, target, onBehalfOf, claim),
|
|
2806
3088
|
forwardDirect: request => forwardDirect(request), ownCanvasFingerprint,
|
|
2807
3089
|
diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
|
|
2808
3090
|
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
@@ -2852,11 +3134,162 @@ export function apply(ctx, config) {
|
|
|
2852
3134
|
noWakeFaceText,
|
|
2853
3135
|
archivedColdMatchText,
|
|
2854
3136
|
noColdMatchText,
|
|
2855
|
-
nativeTeamsPrepare,
|
|
2856
|
-
nativeTeamsRound,
|
|
2857
3137
|
routeIntoAgent,
|
|
2858
3138
|
});
|
|
2859
3139
|
});
|
|
3140
|
+
if (superagentHost !== undefined)
|
|
3141
|
+
whenWebServerSettled((webServer) => {
|
|
3142
|
+
// superagent design v4, PR-B: the owner's workbench face of the engine (owner control route only).
|
|
3143
|
+
// GET: the engine state; POST: settings (router, allowed senders, Sources, pause), request (the workbench Source),
|
|
3144
|
+
// stop / retry a ticket (upstream's people commands).
|
|
3145
|
+
ctx.effect(() => webServer.register({
|
|
3146
|
+
kind: 'exact',
|
|
3147
|
+
path: '/__dsh_a2a/superagent',
|
|
3148
|
+
handler: controlRoute((req, res) => {
|
|
3149
|
+
const send = (status, value) => { const payload = JSON.stringify(value); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }); res.end(payload); };
|
|
3150
|
+
if (req.method === 'GET') {
|
|
3151
|
+
send(200, superagentHost.state());
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
3154
|
+
readJsonBody(req, res, (body) => {
|
|
3155
|
+
const fields = body;
|
|
3156
|
+
try {
|
|
3157
|
+
if (fields.action === 'settings') {
|
|
3158
|
+
send(200, superagentHost.saveSettings(fields.settings, id => joinedSessions.has(id) && archivedSessionFilter()?.(id) !== true, sessionPresetOf));
|
|
3159
|
+
}
|
|
3160
|
+
else if (fields.action === 'request') {
|
|
3161
|
+
send(200, superagentHost.request(typeof fields.text === 'string' ? fields.text : '', typeof fields.thread === 'string' ? fields.thread : undefined, fields.via === 'cli' ? 'cli' : undefined));
|
|
3162
|
+
}
|
|
3163
|
+
else if (fields.action === 'answer') {
|
|
3164
|
+
send(200, superagentHost.answer(typeof fields.ticket_id === 'string' ? fields.ticket_id : '', typeof fields.text === 'string' ? fields.text : ''));
|
|
3165
|
+
}
|
|
3166
|
+
else if (fields.action === 'add-remote-member' || fields.action === 'remove-remote-member') {
|
|
3167
|
+
// Design v5: a remote member is a remote slot of the bound canvas team — added on the canvas, which pins its key.
|
|
3168
|
+
send(200, { ok: false, error: 'the team is a canvas team: add or remove a remote member on the canvas (/__dsh_a2a/canvas add-member / remove-member)' });
|
|
3169
|
+
}
|
|
3170
|
+
else if (fields.action === 'serve-team') {
|
|
3171
|
+
// Design v5 (D4): a team is served by its canvas id, which names its owner by key fingerprint.
|
|
3172
|
+
send(200, { ok: false, error: "serve a team by its canvas id: POST { action: 'serve', team: '<zone>/canvas/<name>@<fp8>', members: [<local session ids>] } (team: null leaves)" });
|
|
3173
|
+
}
|
|
3174
|
+
else if (fields.action === 'serve') {
|
|
3175
|
+
// Design v5 (D4): serve another host's team — the canvas team `<zone>/canvas/<name>@<fp8>`. The fingerprint picks
|
|
3176
|
+
// the linked peer whose signed card key it names and which advertises that canvas; that key is pinned, so only it
|
|
3177
|
+
// may assign these sessions work. null leaves. The member host's owner consents here; the team host cannot
|
|
3178
|
+
// take another host's sessions on its own (U17).
|
|
3179
|
+
if (fields.team === null) {
|
|
3180
|
+
send(200, superagentHost.serveTeam(undefined, [], () => false));
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
const team = typeof fields.team === 'string' ? fields.team.trim() : '';
|
|
3184
|
+
const members = Array.isArray(fields.members) ? fields.members.filter((m) => typeof m === 'string') : [];
|
|
3185
|
+
void (async () => {
|
|
3186
|
+
const split = splitCanvasOwner(team);
|
|
3187
|
+
if (split.owner === undefined || !/^[^/\s]+\/canvas\/[^/\s]+$/.test(split.team)) {
|
|
3188
|
+
send(200, { ok: false, error: 'team must be a canvas id with its owner: <zone>/canvas/<name>@<fp8>' });
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
if (split.owner === ownCanvasFingerprint) {
|
|
3192
|
+
send(200, { ok: false, error: "that canvas is this host's own: its sessions join it as members on the canvas" });
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
let found;
|
|
3196
|
+
for (const url of new Set([...peerStore.list(), ...cardCache.keys()])) {
|
|
3197
|
+
const card = await fetchPeerCard(url).catch(() => undefined);
|
|
3198
|
+
if (card === undefined || !verifyCard(card, Date.now()).ok || typeof card.publicKey !== 'string' || canvasOwnerFingerprint(card.publicKey) !== split.owner)
|
|
3199
|
+
continue;
|
|
3200
|
+
if (!(card.canvasOrg ?? []).some(entry => entry.team === split.team && entry.team === card.team + '/canvas/' + entry.name))
|
|
3201
|
+
continue;
|
|
3202
|
+
found = { url, card };
|
|
3203
|
+
break;
|
|
3204
|
+
}
|
|
3205
|
+
if (found === undefined) {
|
|
3206
|
+
send(200, { ok: false, error: `no linked peer's signed card is the owner of ${team} (key fingerprint ${split.owner}) and advertises it` });
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
// The link judgement reads the cached card: cache the verified one first, as the canvas member proof does.
|
|
3210
|
+
cardCache.set(found.url, { at: Date.now(), card: found.card });
|
|
3211
|
+
if (peerAdmissionLinkState(found.url).link !== 'linked') {
|
|
3212
|
+
send(200, { ok: false, error: 'the team host must be a linked peer' });
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
send(200, superagentHost.serveTeam({ url: new URL(found.url).origin, key: found.card.publicKey, canvas: team }, members, id => joinedSessions.has(id) && archivedSessionFilter()?.(id) !== true, sessionPresetOf));
|
|
3216
|
+
})().catch(error => send(200, { ok: false, error: String(error) }));
|
|
3217
|
+
}
|
|
3218
|
+
else if (fields.action === 'reevaluate') {
|
|
3219
|
+
// Design superagent-reevaluate-note D1: the owner asks for a fresh judgement of a stored message.
|
|
3220
|
+
send(200, superagentHost.reevaluate(typeof fields.trigger_id === 'string' ? fields.trigger_id : ''));
|
|
3221
|
+
}
|
|
3222
|
+
else if (fields.action === 'stop' || fields.action === 'retry') {
|
|
3223
|
+
const ticketId = typeof fields.ticket_id === 'string' ? fields.ticket_id : '';
|
|
3224
|
+
const ticket = fields.action === 'stop'
|
|
3225
|
+
? superagentHost.engine.stopTicket(ticketId, typeof fields.reason === 'string' ? fields.reason : undefined)
|
|
3226
|
+
: superagentHost.engine.retryTicket(ticketId);
|
|
3227
|
+
superagentHost.pass();
|
|
3228
|
+
send(200, { ok: true, ticket });
|
|
3229
|
+
}
|
|
3230
|
+
else
|
|
3231
|
+
send(200, { ok: false, error: 'action must be settings, request, answer, serve, stop, retry or reevaluate' });
|
|
3232
|
+
}
|
|
3233
|
+
catch (error) {
|
|
3234
|
+
send(200, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
3235
|
+
}
|
|
3236
|
+
}, 64_000);
|
|
3237
|
+
}),
|
|
3238
|
+
}), 'a2a: superagent control route');
|
|
3239
|
+
// PR-C2: the two host-to-host faces. Each takes a signed, body-bound OriginClaim and judges the signer's card key against
|
|
3240
|
+
// the key the owner pinned: the team host's key on a member host, the member host's key on the team host.
|
|
3241
|
+
const signedFace = (path, judge, label) => {
|
|
3242
|
+
ctx.effect(() => webServer.register({
|
|
3243
|
+
kind: 'exact',
|
|
3244
|
+
path,
|
|
3245
|
+
handler: (req, res) => {
|
|
3246
|
+
const send = (value) => { const payload = JSON.stringify(value); res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }); res.end(payload); };
|
|
3247
|
+
if (req.method !== 'POST') {
|
|
3248
|
+
send({ ok: false, error: 'POST required' });
|
|
3249
|
+
return;
|
|
3250
|
+
}
|
|
3251
|
+
const chunks = [];
|
|
3252
|
+
let size = 0;
|
|
3253
|
+
req.on('data', (chunk) => { size += chunk.length; if (size <= 64 * 1024)
|
|
3254
|
+
chunks.push(chunk); });
|
|
3255
|
+
req.on('end', () => {
|
|
3256
|
+
void (async () => {
|
|
3257
|
+
if (size > 64 * 1024) {
|
|
3258
|
+
send({ ok: false, error: 'payload too large' });
|
|
3259
|
+
return;
|
|
3260
|
+
}
|
|
3261
|
+
const rawBody = Buffer.concat(chunks);
|
|
3262
|
+
let body;
|
|
3263
|
+
try {
|
|
3264
|
+
body = JSON.parse(rawBody.toString('utf8'));
|
|
3265
|
+
}
|
|
3266
|
+
catch {
|
|
3267
|
+
send({ ok: false, error: 'not JSON' });
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
const claim = await verifyOriginClaim(req, { rawBody });
|
|
3271
|
+
if (!claim.ok || !claim.bound || claim.bodyBound !== true || typeof claim.card.publicKey !== 'string') {
|
|
3272
|
+
recordActivity('in', path, String(req.headers['x-a2a-caller'] ?? 'unknown'), false);
|
|
3273
|
+
superagentHost.noteRefusal('superagent-untrusted-origin', { caller: String(req.headers['x-a2a-caller'] ?? 'unknown'), target: path });
|
|
3274
|
+
send({ ok: false, code: 'untrusted-origin', error: `a signed, body-bound OriginClaim is required (${claim.ok ? 'unbound' : claim.reason})` });
|
|
3275
|
+
return;
|
|
3276
|
+
}
|
|
3277
|
+
const out = judge(body, { caller: claim.caller, key: claim.card.publicKey });
|
|
3278
|
+
recordActivity('in', path, claim.caller, out.ok === true);
|
|
3279
|
+
// A signer that is not the pinned key (an impostor sharing a handle) is refused by identity; a malformed body is not counted.
|
|
3280
|
+
if (out.ok !== true && ['not-signer', 'not-team', 'not-member'].includes(String(out.code)))
|
|
3281
|
+
superagentHost.noteRefusal(`superagent-${String(out.code)}`, { caller: claim.caller, target: path });
|
|
3282
|
+
send(out);
|
|
3283
|
+
})().catch(error => send({ ok: false, error: String(error) }));
|
|
3284
|
+
});
|
|
3285
|
+
},
|
|
3286
|
+
}), label);
|
|
3287
|
+
};
|
|
3288
|
+
signedFace('/a2a/superagent-member', (body, claim) => superagentHost.assignedWork(body.op, body.member, claim.key, body.text), 'a2a: superagent member face');
|
|
3289
|
+
signedFace('/a2a/superagent-team', (body, claim) => typeof body.member === 'string' && claimCovers(body.member, claim.caller)
|
|
3290
|
+
? superagentHost.remoteEvent(body.op, body.member, claim.key, body.report)
|
|
3291
|
+
: { ok: false, code: 'not-signer', error: 'the signer does not speak for that member' }, 'a2a: superagent team face');
|
|
3292
|
+
});
|
|
2860
3293
|
whenWebServerSettled((webServer) => {
|
|
2861
3294
|
// Task-ledger control: explicit caller-side abandonment of one owed
|
|
2862
3295
|
// async task — the orchestrator's give-up path (work-order P1a).
|
|
@@ -2873,6 +3306,7 @@ export function apply(ctx, config) {
|
|
|
2873
3306
|
const taskId = typeof fields.task_id === 'string' ? fields.task_id : '';
|
|
2874
3307
|
const reason = typeof fields.reason === 'string' ? fields.reason : undefined;
|
|
2875
3308
|
const result = taskLedger.abandon(taskId, reason);
|
|
3309
|
+
pruneReceiptWaiters();
|
|
2876
3310
|
const payload = JSON.stringify(result);
|
|
2877
3311
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
2878
3312
|
res.end(payload);
|
|
@@ -3075,6 +3509,9 @@ export function apply(ctx, config) {
|
|
|
3075
3509
|
// The cache is still empty here, so the observer skips cache-resting rows
|
|
3076
3510
|
// on 'boot' (#138 R1); the first lazy read rechecks them.
|
|
3077
3511
|
ctx.effect(() => canvasStore.onChange(() => observeAuthorization('canvas')), 'a2a: S6 canvas observer');
|
|
3512
|
+
// Design v5: the team's pool is its canvas team's; a canvas change is a pool change.
|
|
3513
|
+
if (superagentHost !== undefined)
|
|
3514
|
+
ctx.effect(() => canvasStore.onChange(() => { superagentHost.poolChanged(); }), 'a2a: superagent team canvas');
|
|
3078
3515
|
ctx.effect(() => joinedSessions.onChange(() => observeAuthorization('joined')), 'a2a: S6 joined observer');
|
|
3079
3516
|
ctx.effect(() => teamMemberships.onChange(() => observeAuthorization('roster')), 'a2a: S6 roster observer');
|
|
3080
3517
|
observeAuthorization('boot');
|
|
@@ -3449,19 +3886,6 @@ export function apply(ctx, config) {
|
|
|
3449
3886
|
origin: localOrigin,
|
|
3450
3887
|
};
|
|
3451
3888
|
}),
|
|
3452
|
-
// Native-teams registry rows (opt-in bridge): local teams this node
|
|
3453
|
-
// can dispatch inbound. They list only when the operator opted in —
|
|
3454
|
-
// registry presence alone is never network exposure.
|
|
3455
|
-
...(config.nativeTeamsInbound === true
|
|
3456
|
-
? (teamsRegistry()?.listTeams() ?? []).map(entry => ({
|
|
3457
|
-
team: entry.name,
|
|
3458
|
-
session,
|
|
3459
|
-
name: entry.name,
|
|
3460
|
-
description: entry.description,
|
|
3461
|
-
local: true,
|
|
3462
|
-
origin: localOrigin,
|
|
3463
|
-
}))
|
|
3464
|
-
: []),
|
|
3465
3889
|
];
|
|
3466
3890
|
const fetch = memoizedCardFetch();
|
|
3467
3891
|
// Collect per peer in store order (concurrent fetches, ordered merge):
|
|
@@ -3541,19 +3965,19 @@ export function apply(ctx, config) {
|
|
|
3541
3965
|
if (!result.ok)
|
|
3542
3966
|
return;
|
|
3543
3967
|
birth = { ...birth, ...(typeof result.consumed === 'boolean' ? { consumed: result.consumed } : {}) };
|
|
3544
|
-
//
|
|
3545
|
-
// them as receipt-owed would fill the owed book with rows no receipt can
|
|
3968
|
+
// A peer still running the retired native-teams bridge (0.7.0 and older) marks those rounds: they emit no A2A
|
|
3969
|
+
// receipt, so booking them as receipt-owed would fill the owed book with rows no receipt can
|
|
3546
3970
|
// ever settle (evicting genuinely receivable peer rows past TASK_CAP).
|
|
3547
3971
|
if (result.bridge !== undefined)
|
|
3548
3972
|
return;
|
|
3549
|
-
if (result.task_status !== 'TASK_STATE_DELIVERED' && result.receiverBookkeeping !== 'failed')
|
|
3973
|
+
if (result.task_status !== 'TASK_STATE_DELIVERED' && result.receiverBookkeeping !== 'failed' && !(result.task_status === 'TASK_STATE_COMPLETED' && result.replyFinal !== true && result.receipt !== 'mailbox'))
|
|
3550
3974
|
return;
|
|
3551
3975
|
// Fix ② (ghost-debt stop): a delivered async dispatch with no routable
|
|
3552
3976
|
// callback address can never be settled — register it receiptless
|
|
3553
3977
|
// (visible, non-debt; reconcile via context_id) instead of stranding a
|
|
3554
3978
|
// row whose only exit was the dead-letter sweep (57 dead letters, half
|
|
3555
3979
|
// this shape).
|
|
3556
|
-
if (callbackAddress === undefined) {
|
|
3980
|
+
if (callbackAddress === undefined || result.receipt === 'none') {
|
|
3557
3981
|
taskLedger.trackReceiptless(taskId, team, peer, result.context_id === '' ? undefined : result.context_id, brief, sender, birth);
|
|
3558
3982
|
return;
|
|
3559
3983
|
}
|
|
@@ -3701,6 +4125,13 @@ export function apply(ctx, config) {
|
|
|
3701
4125
|
const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
|
|
3702
4126
|
if (ownerQualified.owner !== undefined)
|
|
3703
4127
|
args = { ...args, team: ownerQualified.team };
|
|
4128
|
+
{
|
|
4129
|
+
const refusal = superagentRouterRefusal(agent === undefined ? undefined : String(agent.id), String(args.team ?? '').trim());
|
|
4130
|
+
if (refusal !== undefined) {
|
|
4131
|
+
recordActivity('out', String(args.team ?? ''), agent !== undefined ? sessionTeamOf(agent) : 'local', false);
|
|
4132
|
+
return { ok: false, error: refusal, code: -32000 };
|
|
4133
|
+
}
|
|
4134
|
+
}
|
|
3704
4135
|
// A receipt may use only its own frozen return lane; local answer state is not delivery proof.
|
|
3705
4136
|
const answer = parseReceipt(args.message);
|
|
3706
4137
|
const receiptRow = answer === null ? undefined : taskLedger.find(answer.taskId);
|
|
@@ -3739,7 +4170,9 @@ export function apply(ctx, config) {
|
|
|
3739
4170
|
initiator = ctx.get('agents')?.requireInitiator() === agent;
|
|
3740
4171
|
}
|
|
3741
4172
|
catch { /* no initiator in this fiber */ }
|
|
3742
|
-
|
|
4173
|
+
// Design v5 (Q1): the bound team canvas takes requests from any session of this host; the team's Policy decides
|
|
4174
|
+
// whether it works on them, so team scope does not gate the route.
|
|
4175
|
+
if (!initiator && teamMailboxOf(String(args.team ?? '')) === undefined) {
|
|
3743
4176
|
const callerHandle = `${config.team}/${id8(scopeCaller)}`;
|
|
3744
4177
|
scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
|
|
3745
4178
|
const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
|
|
@@ -3802,6 +4235,8 @@ export function apply(ctx, config) {
|
|
|
3802
4235
|
// gh#198: host-built delivery notes, or a reply from a clean local session (resolved as routeIntoAgent does), are this host's alone.
|
|
3803
4236
|
return ((outcome.bridge === undefined && (outcome.task_status === 'TASK_STATE_DELIVERED' || outcome.task_status === 'TASK_STATE_ABORTED_WAIT' || (t => t !== undefined && nodeTaint.of(String(t.id)) === '')(resolveAgentForTeam(args.team) ?? canvasLiveAgent(parseCanvasTeamName(args.team))))) ? localRoute(outcome) : outcome);
|
|
3804
4237
|
}
|
|
4238
|
+
if (outcome.acceptance === 'unknown')
|
|
4239
|
+
return localRoute(outcome);
|
|
3805
4240
|
failures.push(`local: ${outcome.error}`);
|
|
3806
4241
|
}
|
|
3807
4242
|
// Failover seam for canvas teams: after the local tiers miss, the
|
|
@@ -3848,6 +4283,8 @@ export function apply(ctx, config) {
|
|
|
3848
4283
|
peerAsync = card !== undefined && caps?.async === true;
|
|
3849
4284
|
}
|
|
3850
4285
|
const result = await dispatchPeerCandidate(candidate, target, args.message, args.context_id, signal, callerSession, peerAsync, taskId, callbackAddress);
|
|
4286
|
+
if (!result.ok && result.acceptance === 'unknown')
|
|
4287
|
+
return result;
|
|
3851
4288
|
if (result.ok || signal.aborted) {
|
|
3852
4289
|
// A receipt the creditor refused (its enforce verdict) pays nothing here either (review of PR #149 P2-2).
|
|
3853
4290
|
if (result.ok && routedReceipt) {
|
|
@@ -3897,11 +4334,18 @@ export function apply(ctx, config) {
|
|
|
3897
4334
|
// the cheapest path. The initiator face (bare node team) and
|
|
3898
4335
|
// foreign-zone labels are NOT gated here (F1b host-face rules, R-C
|
|
3899
4336
|
// pending) — only the joined face closes.
|
|
3900
|
-
|
|
4337
|
+
// Design v5 (D2, Q1): a route to the bound team canvas is a route to the team's mailbox, admitted as one.
|
|
4338
|
+
const mailTeam = teamMailboxOf(team) ?? team;
|
|
4339
|
+
const joinedRefusal = joinedFaceAdmissionRefusal(callerSession, mailTeam);
|
|
3901
4340
|
if (joinedRefusal !== undefined) {
|
|
3902
4341
|
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3903
4342
|
return { ok: false, error: joinedRefusal, code: -32000 };
|
|
3904
4343
|
}
|
|
4344
|
+
const routerRefusal = superagentRouterRefusal(undefined, team) ?? teamAddressRefusal(team, message);
|
|
4345
|
+
if (routerRefusal !== undefined) {
|
|
4346
|
+
recordActivity('out', team, callerSession ?? 'local', false);
|
|
4347
|
+
return { ok: false, error: routerRefusal, code: -32000 };
|
|
4348
|
+
}
|
|
3905
4349
|
// #114 S5: the same header-safe id rule the wire face enforces.
|
|
3906
4350
|
const idRefusal = taskIdFromCaller === undefined ? undefined : taskIdRefusal(taskIdFromCaller);
|
|
3907
4351
|
if (idRefusal !== undefined) {
|
|
@@ -3909,7 +4353,9 @@ export function apply(ctx, config) {
|
|
|
3909
4353
|
return { ok: false, error: idRefusal, code: -32000 };
|
|
3910
4354
|
}
|
|
3911
4355
|
// gh#224: a full target queue refuses a same-host delivery as the wire face does (receipts, CLI mailboxes pass).
|
|
3912
|
-
|
|
4356
|
+
// p1-mailbox-owes (S33): a deposit that declares it owes receipts is under the cap like a session.
|
|
4357
|
+
// superagent v4: a request to the team's A2A address is a Trigger; the engine's worker slots govern it, not this cap.
|
|
4358
|
+
const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, (cliDeposits.get(mailTeam) !== undefined && !cliDeposits.owes(mailTeam)) || superagentHost?.a2aAddress === mailTeam, ownTask);
|
|
3913
4359
|
if (queueRefusal !== undefined) {
|
|
3914
4360
|
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3915
4361
|
return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT };
|
|
@@ -3934,22 +4380,36 @@ export function apply(ctx, config) {
|
|
|
3934
4380
|
// polls while alive; receipts and messages never wake anything).
|
|
3935
4381
|
// The settler of any receipt on this path: this host's own caller (in-process).
|
|
3936
4382
|
const localSettler = { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' };
|
|
3937
|
-
const cliRow = cliDeposits.get(
|
|
4383
|
+
const cliRow = cliDeposits.get(mailTeam);
|
|
3938
4384
|
if (cliRow !== undefined) {
|
|
3939
4385
|
// #114 S4 (design §4.5): a receipt dropped into a local CLI mailbox is
|
|
3940
4386
|
// exactly the settlement a CLI-originated task waits for — settle it,
|
|
3941
4387
|
// as the wire face settles before ITS mailbox split.
|
|
3942
4388
|
settleAndAnnounce(message, localSettler);
|
|
3943
|
-
|
|
4389
|
+
const answered = parseReceipt(message);
|
|
4390
|
+
const disposition = answered?.envelope?.disposition;
|
|
4391
|
+
// p1-mail-admission: the same-host admission this delivery passed (a joined face's rule, else local-exempt).
|
|
4392
|
+
const localAdmission = localCandidateAdmission(admissionRecords, callerSession, mailTeam);
|
|
4393
|
+
const evicted = cliDeposits.mail(mailTeam, callerSession ?? 'unknown', message, undefined, { taskId: taskIdFromCaller, contextId, proof: 'in-process', ...(answered !== null ? { receiptFor: answered.taskId } : {}), ...(disposition !== undefined ? { disposition } : {}),
|
|
4394
|
+
...(localAdmission.ok ? { admission: localAdmission.rule } : {}) });
|
|
4395
|
+
// p1-mailbox-owes (D-5): a row the cap pushed out takes its owed inbound row with it.
|
|
4396
|
+
for (const gone of evicted)
|
|
4397
|
+
if (gone.task_id !== undefined)
|
|
4398
|
+
taskLedger.evictInbound(gone.task_id, taskLedger.find(gone.task_id)?.team ?? mailTeam);
|
|
3944
4399
|
recordActivity('in', team, callerSession ?? 'peer', true);
|
|
3945
|
-
logger.info(`a2a: cli mailbox delivery for ${
|
|
3946
|
-
|
|
4400
|
+
logger.info(`a2a: cli mailbox delivery for ${mailTeam} (from ${callerSession ?? 'unknown'}${mailTeam !== team ? `, addressed to ${team}` : ''})`);
|
|
4401
|
+
// Design v5 (R5): the team works asynchronously — a sync route to it answers at once, delivered.
|
|
4402
|
+
if (mailTeam !== team && answered === null && taskIdFromCaller !== undefined)
|
|
4403
|
+
return { ok: true, team, reply: `delivered to the superagent team ${team} — it works asynchronously; its result arrives as this task's receipt`, task_id: taskIdFromCaller, context_id: contextId ?? '', task_status: 'TASK_STATE_DELIVERED', receipt: 'mailbox' };
|
|
4404
|
+
if (answered === null && taskIdFromCaller !== undefined && cliDeposits.owes(team)) {
|
|
4405
|
+
// p1-mailbox-owes (S33, D-2): delivered, not final. On one host the caller and the target share this ledger: the
|
|
4406
|
+
// caller's own owed row (peer 'local', booked by trackOwedTask) is the one the per-target cap counts, as for a
|
|
4407
|
+
// same-host session, so no separate inbound row is kept.
|
|
4408
|
+
return { ok: true, team, reply: `delivered to the mailbox of ${team} — it owes a receipt`, task_id: taskIdFromCaller, context_id: contextId ?? '', task_status: 'TASK_STATE_DELIVERED', receipt: 'mailbox' };
|
|
4409
|
+
}
|
|
4410
|
+
// S29 / #404: a plain mailbox is final for the caller, as on the wire face (receipt 'mailbox'), so no debt is booked.
|
|
4411
|
+
return { ok: true, team, reply: `delivered to the mailbox of ${team} — the CLI polls while online`, task_id: taskIdFromCaller ?? '', context_id: contextId ?? '', task_status: 'TASK_STATE_COMPLETED', receipt: 'mailbox' };
|
|
3947
4412
|
}
|
|
3948
|
-
// The native-teams claim probe is an await: pay it only when the four
|
|
3949
|
-
// cheap synchronous locality checks missed (a registry team is a local
|
|
3950
|
-
// candidate even though no session node backs it).
|
|
3951
|
-
if (!teamIsLocal && team !== config.team)
|
|
3952
|
-
teamIsLocal = await nativeTeamsClaims(team);
|
|
3953
4413
|
if (!teamIsLocal || webServer === undefined)
|
|
3954
4414
|
return undefined;
|
|
3955
4415
|
// A receipt relayed between same-host sessions (the answering session
|
|
@@ -3971,42 +4431,7 @@ export function apply(ctx, config) {
|
|
|
3971
4431
|
? Promise.resolve(agent)
|
|
3972
4432
|
: wake?.kind === 'flight' ? wake.flight : undefined;
|
|
3973
4433
|
if (woken === undefined) {
|
|
3974
|
-
//
|
|
3975
|
-
// redirect, the same rule routeIntoAgent follows (a bare team name
|
|
3976
|
-
// claimed by the registry must not fork its destination by wait
|
|
3977
|
-
// semantics). Prepare-first: claim, seam, and parent initiator are
|
|
3978
|
-
// everything that can fail fast, so a phantom delivery never answers
|
|
3979
|
-
// success. The fire is truly detached (no caller signal — the turn
|
|
3980
|
-
// ending must not silently retract an already-announced delivery);
|
|
3981
|
-
// rounds emit no receipt in this slice (the `bridge` marker keeps
|
|
3982
|
-
// the caller's ledger honest).
|
|
3983
|
-
const prepared = await nativeTeamsPrepare(team);
|
|
3984
|
-
if (prepared.ok) {
|
|
3985
|
-
endRoute(flight);
|
|
3986
|
-
recordActivity('out', team, 'local', true);
|
|
3987
|
-
void nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', undefined, hostDirectHeader)
|
|
3988
|
-
.then(outcome => {
|
|
3989
|
-
if (!outcome.ok) {
|
|
3990
|
-
recordActivity('out', team, 'local', false);
|
|
3991
|
-
logger.warn(`a2a: detached native-teams round for "${team}" failed: ${outcome.error}`);
|
|
3992
|
-
}
|
|
3993
|
-
});
|
|
3994
|
-
return {
|
|
3995
|
-
ok: true,
|
|
3996
|
-
team,
|
|
3997
|
-
reply: `Delivered to ${team} (async native-teams round dispatched). The round settles through the team's own routing chain; it routes no A2A receipt in this slice — reconcile via context_id follow-ups.`,
|
|
3998
|
-
task_id: taskId,
|
|
3999
|
-
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
4000
|
-
task_status: 'TASK_STATE_DELIVERED',
|
|
4001
|
-
bridge: 'native-teams',
|
|
4002
|
-
};
|
|
4003
|
-
}
|
|
4004
|
-
if (prepared.reason === 'error') {
|
|
4005
|
-
endRoute(flight);
|
|
4006
|
-
recordActivity('out', team, 'local', false);
|
|
4007
|
-
return { ok: false, error: prepared.error, code: -32000 };
|
|
4008
|
-
}
|
|
4009
|
-
// Claim declined: the bare process team still steers the live initiator.
|
|
4434
|
+
// The bare process team still steers the live initiator.
|
|
4010
4435
|
const live = team === config.team ? liveAgent() : undefined;
|
|
4011
4436
|
if (live !== undefined && callerIsAgent(live, callerSession ?? session)) {
|
|
4012
4437
|
// Self-route refusal (same rule as the sync faces): the initiator
|
|
@@ -4024,6 +4449,12 @@ export function apply(ctx, config) {
|
|
|
4024
4449
|
}
|
|
4025
4450
|
try {
|
|
4026
4451
|
const target = await woken;
|
|
4452
|
+
// superagent v4: whatever address resolved to it (a canvas team, the node team), the router takes no A2A work.
|
|
4453
|
+
if (superagentHost?.roleOf(String(target.id)) !== undefined) {
|
|
4454
|
+
endRoute(flight);
|
|
4455
|
+
recordActivity('out', team, callerSession ?? 'local', false);
|
|
4456
|
+
return { ok: false, error: superagentRouterRefusal(undefined, String(target.id)) ?? 'superagent team sessions take no A2A work', code: -32000 };
|
|
4457
|
+
}
|
|
4027
4458
|
// N1 (review seat 7e4cf94a): dashed composite labels are NOT routable;
|
|
4028
4459
|
// accept only <zone>/<short-id> shapes so envelopes stop bouncing -32004.
|
|
4029
4460
|
// An explicit callback address (P2) wins over the caller-label
|
|
@@ -4035,8 +4466,9 @@ export function apply(ctx, config) {
|
|
|
4035
4466
|
const receiptHint = callerTeam === undefined ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${callerTeam}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
|
|
4036
4467
|
// #114 S5: the consumption search starts at the pre-steer log length.
|
|
4037
4468
|
const steerFloor = sessionEventsOf(target).length;
|
|
4038
|
-
followupRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId, taskId, undefined, parseReceipt(message) === null);
|
|
4039
|
-
|
|
4469
|
+
const acceptance = followupRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId, taskId, undefined, parseReceipt(message) === null);
|
|
4470
|
+
if (acceptance !== 'inserted')
|
|
4471
|
+
armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
|
|
4040
4472
|
// #139: the caller's outbound row is born with this admission and the member that received it.
|
|
4041
4473
|
if (taskIdFromCaller !== undefined)
|
|
4042
4474
|
localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target), String(target.id)));
|
|
@@ -4045,76 +4477,22 @@ export function apply(ctx, config) {
|
|
|
4045
4477
|
return {
|
|
4046
4478
|
ok: true,
|
|
4047
4479
|
team,
|
|
4048
|
-
|
|
4480
|
+
...(acceptance === 'inserted' ? { acceptance, consumed: false } : {}),
|
|
4481
|
+
reply: acceptance === 'inserted' ? ACCEPTANCE_INSERTED_NOTE : `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.${routable ? '' : unroutableCallerNote(String(callerSession ?? session))}`,
|
|
4049
4482
|
task_id: taskId,
|
|
4050
4483
|
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
4051
4484
|
task_status: 'TASK_STATE_DELIVERED',
|
|
4052
|
-
consumed: taskLedger.deliveryFor(taskId)?.state === 'claimed',
|
|
4485
|
+
consumed: acceptance === 'inserted' ? false : taskLedger.deliveryFor(taskId)?.state === 'claimed',
|
|
4053
4486
|
};
|
|
4054
4487
|
}
|
|
4055
4488
|
catch (error) {
|
|
4056
4489
|
endRoute(flight);
|
|
4057
4490
|
recordActivity('out', team, 'local', false);
|
|
4491
|
+
if (error instanceof FollowupAcceptanceUnknown)
|
|
4492
|
+
return { ok: false, error: ACCEPTANCE_UNKNOWN_NOTE, acceptance: 'unknown', task_id: taskId, code: -32000 };
|
|
4058
4493
|
return { ok: false, error: `waking or steering the session for team "${team}" failed: ${String(error)}`, code: -32000 };
|
|
4059
4494
|
}
|
|
4060
4495
|
}
|
|
4061
|
-
if (!asyncMode) {
|
|
4062
|
-
// Bridge only on a steer-path miss, matching the documented chain
|
|
4063
|
-
// (session node → canvas → cold wake → native-teams claim → bare): a
|
|
4064
|
-
// registry claim must never shadow a live local session team. The
|
|
4065
|
-
// caller's abort cancels the round through its own signal; the 180s
|
|
4066
|
-
// deadline restores the bound the steer machinery observes.
|
|
4067
|
-
// Issue #41: same initiator exemption on the sync path — the canvas
|
|
4068
|
-
// live/cold tiers skip the dispatching session before steerable is
|
|
4069
|
-
// decided, so a self-member falls through to remote members.
|
|
4070
|
-
const steerable = resolveAgentForTeam(team) !== undefined
|
|
4071
|
-
|| canvasLiveAgent(canvasName, callerNodeId) !== undefined
|
|
4072
|
-
|| joinedSessions.list().some(id => `${config.team}/${id8(id)}` === team)
|
|
4073
|
-
|| wakeColdTeam(team, callerNodeId).kind === 'flight';
|
|
4074
|
-
if (!steerable && team !== config.team) {
|
|
4075
|
-
const prepared = await nativeTeamsPrepare(team);
|
|
4076
|
-
if (prepared.ok) {
|
|
4077
|
-
const outcome = await nativeTeamsRound(prepared, team, callerSession ?? session, message, taskId, '未验证(in-process)', signal, hostDirectHeader);
|
|
4078
|
-
endRoute(flight);
|
|
4079
|
-
if (!outcome.ok) {
|
|
4080
|
-
// The aborted-wait shape is earned only by an actual round
|
|
4081
|
-
// cancellation (the caller's abort reached the seam). A
|
|
4082
|
-
// pre-dispatch abort means the message never left the node, and
|
|
4083
|
-
// a failed round means it never answered — both answer the
|
|
4084
|
-
// honest error instead of claiming a delivery.
|
|
4085
|
-
if (outcome.aborted === true) {
|
|
4086
|
-
recordActivity('out', team, 'local', true);
|
|
4087
|
-
return {
|
|
4088
|
-
ok: true,
|
|
4089
|
-
team,
|
|
4090
|
-
reply: `The message was delivered to ${team} (native-teams round), but the wait was aborted (the round was cancelled with it).`,
|
|
4091
|
-
task_id: taskId,
|
|
4092
|
-
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
4093
|
-
task_status: 'TASK_STATE_ABORTED_WAIT',
|
|
4094
|
-
bridge: 'native-teams',
|
|
4095
|
-
};
|
|
4096
|
-
}
|
|
4097
|
-
recordActivity('out', team, 'local', false);
|
|
4098
|
-
return { ok: false, error: outcome.error, code: -32000 };
|
|
4099
|
-
}
|
|
4100
|
-
recordActivity('out', team, 'local', true);
|
|
4101
|
-
return {
|
|
4102
|
-
ok: true,
|
|
4103
|
-
team,
|
|
4104
|
-
reply: outcome.reply,
|
|
4105
|
-
task_id: taskId,
|
|
4106
|
-
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
4107
|
-
task_status: outcome.settled ? 'TASK_STATE_COMPLETED' : 'TASK_STATE_DELIVERED',
|
|
4108
|
-
...(outcome.settled ? {} : { bridge: 'native-teams' }),
|
|
4109
|
-
};
|
|
4110
|
-
}
|
|
4111
|
-
if (prepared.reason === 'error') {
|
|
4112
|
-
endRoute(flight);
|
|
4113
|
-
recordActivity('out', team, 'local', false);
|
|
4114
|
-
return { ok: false, error: prepared.error, code: -32000 };
|
|
4115
|
-
}
|
|
4116
|
-
}
|
|
4117
|
-
}
|
|
4118
4496
|
const wait = routeIntoAgent(team, message, callerSession ?? session, taskId, undefined, undefined, originAgentId);
|
|
4119
4497
|
const aborted = new Promise((_, reject) => {
|
|
4120
4498
|
signal.addEventListener('abort', () => { reject(new Error('aborted')); }, { once: true });
|
|
@@ -4167,14 +4545,16 @@ export function apply(ctx, config) {
|
|
|
4167
4545
|
return {
|
|
4168
4546
|
ok: true,
|
|
4169
4547
|
team,
|
|
4170
|
-
reply: outcome.reply,
|
|
4548
|
+
reply: outcome.placeholder === true ? '已送达、未得到最终答复,请按 task id 对账。 ' + outcome.reply : outcome.reply,
|
|
4171
4549
|
task_id: taskId,
|
|
4172
4550
|
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
4173
|
-
task_status: outcome.status ?? 'TASK_STATE_COMPLETED',
|
|
4174
|
-
...(outcome.
|
|
4551
|
+
task_status: outcome.placeholder === true || outcome.ownTurn !== true && outcome.status === undefined ? 'TASK_STATE_DELIVERED' : outcome.status ?? 'TASK_STATE_COMPLETED',
|
|
4552
|
+
...(outcome.acceptance === 'inserted' ? { acceptance: 'inserted', consumed: false } : {}),
|
|
4553
|
+
...(outcome.ownTurn === true && outcome.placeholder !== true ? { replyFinal: true } : {}),
|
|
4554
|
+
...(outcome.placeholder === true || outcome.ownTurn !== true ? { receipt: 'none' } : {}),
|
|
4175
4555
|
};
|
|
4176
4556
|
}
|
|
4177
|
-
return { ok: false, error: outcome.error, code: -32000 };
|
|
4557
|
+
return { ok: false, error: outcome.error, code: -32000, ...(outcome.acceptance === 'unknown' ? { acceptance: 'unknown', task_id: taskId } : {}) };
|
|
4178
4558
|
}
|
|
4179
4559
|
/**
|
|
4180
4560
|
* The route dispatcher's peer half: one candidate dial with the in-flight
|
|
@@ -4231,8 +4611,11 @@ export function apply(ctx, config) {
|
|
|
4231
4611
|
if (home === undefined)
|
|
4232
4612
|
throw new Error(`CLI receipt home is not pinned for ${team}`);
|
|
4233
4613
|
const delivered = await dispatchPeerCandidate(home, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
|
|
4234
|
-
if (!delivered.ok)
|
|
4614
|
+
if (!delivered.ok) {
|
|
4615
|
+
if (delivered.acceptance === 'unknown')
|
|
4616
|
+
throw new FollowupAcceptanceUnknown();
|
|
4235
4617
|
throw receiptFailure(delivered);
|
|
4618
|
+
}
|
|
4236
4619
|
return delivered;
|
|
4237
4620
|
}
|
|
4238
4621
|
const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
|
|
@@ -4240,8 +4623,11 @@ export function apply(ctx, config) {
|
|
|
4240
4623
|
if (taskId !== undefined)
|
|
4241
4624
|
localBirths.delete(taskId);
|
|
4242
4625
|
if (local !== undefined) {
|
|
4243
|
-
if (!local.ok)
|
|
4626
|
+
if (!local.ok) {
|
|
4627
|
+
if (local.acceptance === 'unknown')
|
|
4628
|
+
throw new FollowupAcceptanceUnknown();
|
|
4244
4629
|
throw receiptFailure(local);
|
|
4630
|
+
}
|
|
4245
4631
|
return local;
|
|
4246
4632
|
}
|
|
4247
4633
|
if (cliReceipt)
|
|
@@ -4257,6 +4643,8 @@ export function apply(ctx, config) {
|
|
|
4257
4643
|
const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
|
|
4258
4644
|
if (result.ok)
|
|
4259
4645
|
return result;
|
|
4646
|
+
if (result.acceptance === 'unknown')
|
|
4647
|
+
throw new FollowupAcceptanceUnknown();
|
|
4260
4648
|
lastError = result.error;
|
|
4261
4649
|
const failure = receiptFailure(result);
|
|
4262
4650
|
if (receiptError === undefined || failure.transient)
|
|
@@ -4362,200 +4750,37 @@ export function apply(ctx, config) {
|
|
|
4362
4750
|
}
|
|
4363
4751
|
return candidates;
|
|
4364
4752
|
}
|
|
4365
|
-
// ── Outbound transport face: the `nativeTeamsA2a` bridge ─────────────
|
|
4366
|
-
//
|
|
4367
|
-
// Contract: @nelsonlongxiang/dsh-native-teams/src/a2a-face.ts (0.14.0,
|
|
4368
|
-
// structural mirror in src/teams-bridge.ts). This plugin mounts the
|
|
4369
|
-
// implementation under the face's frozen service key — protocol
|
|
4370
|
-
// primitives live on the protocol face (three-party consensus), and
|
|
4371
|
-
// native-teams probes it presence-guarded (unmounted ⇒ local-only
|
|
4372
|
-
// routing, never a crash). The face ships only what this plugin already
|
|
4373
|
-
// does for a2a_route: resolve via the ONE directory walk (same matcher
|
|
4374
|
-
// submit uses — no drift-prone double implementation), submit via the
|
|
4375
|
-
// direct-route dispatcher (per-candidate async gate, failover included),
|
|
4376
|
-
// cancel via the cooperative stop-notice. It exposes nothing new — only
|
|
4377
|
-
// teams the tracked peer network already publishes resolve here.
|
|
4378
|
-
const bridgeFace = {
|
|
4379
|
-
resolve: async (handle) => {
|
|
4380
|
-
const failures = [];
|
|
4381
|
-
const candidates = await directoryPeerCandidates(memoizedCardFetch(), handle, failures);
|
|
4382
|
-
if (candidates.length === 0)
|
|
4383
|
-
return undefined;
|
|
4384
|
-
return { kind: 'node', hops: 1, url: candidates[0] };
|
|
4385
|
-
},
|
|
4386
|
-
submit: async (request, signal) => {
|
|
4387
|
-
const abort = signal ?? new AbortController().signal;
|
|
4388
|
-
const failures = [];
|
|
4389
|
-
const fetch = memoizedCardFetch();
|
|
4390
|
-
const candidates = await directoryPeerCandidates(fetch, request.handle, failures);
|
|
4391
|
-
if (candidates.length === 0) {
|
|
4392
|
-
throw new Error(`team "${request.handle}" is not published by any tracked peer${failures.length === 0 ? '' : ` (unresolved delegations: ${failures.join('; ')})`}`);
|
|
4393
|
-
}
|
|
4394
|
-
// The caller-born task id (B3): the orchestrator's dedup key IS the
|
|
4395
|
-
// wire task id, so the peer's echo correlates with the caller's ledger.
|
|
4396
|
-
const taskId = request.idempotencyKey !== undefined && request.idempotencyKey !== ''
|
|
4397
|
-
? request.idempotencyKey
|
|
4398
|
-
: `direct-${Math.random().toString(16).slice(2, 10)}`;
|
|
4399
|
-
// P2 receipt-callback: the receipt routes back to the submitting
|
|
4400
|
-
// session's own node team, but ONLY when that session is a joined
|
|
4401
|
-
// node. Without a joined parent there is no routable address that
|
|
4402
|
-
// means "this session" on the peer — omitting the callback keeps the
|
|
4403
|
-
// P1 behavior (the caller label cannot resolve on the peer, so the
|
|
4404
|
-
// receipt is lost honestly); falling back to `config.team` would be
|
|
4405
|
-
// actively harmful: a same-named peer resolves it LOCAL-first and the
|
|
4406
|
-
// receipt steers the peer's own initiator, never leaving that host.
|
|
4407
|
-
let callbackAddress;
|
|
4408
|
-
if (request.callbackTarget) {
|
|
4409
|
-
const parent = request.callbackTarget.parentSessionId;
|
|
4410
|
-
if (sessionNodes.has(parent) || joinedSessions.has(parent))
|
|
4411
|
-
callbackAddress = `${config.team}/${id8(parent)}`;
|
|
4412
|
-
}
|
|
4413
|
-
// #114 S3: the submitting session's node team, but only when the
|
|
4414
|
-
// transport names a session this host knows (a node, a join, or a live
|
|
4415
|
-
// root) — an unchecked id would let any submitter mint a seat. Anything
|
|
4416
|
-
// else (absent, unknown, non-string) is attributed to the host label.
|
|
4417
|
-
const parentSession = request.callbackTarget?.parentSessionId;
|
|
4418
|
-
const knownParent = typeof parentSession === 'string' && parentSession !== ''
|
|
4419
|
-
&& (sessionNodes.has(parentSession) || joinedSessions.has(parentSession) || liveRoots.has(parentSession));
|
|
4420
|
-
const sender = { kind: 'bridge', id: knownParent ? `${config.team}/${id8(parentSession)}` : session };
|
|
4421
|
-
for (const candidate of candidates) {
|
|
4422
|
-
// Capability gate per candidate, like a2a_route's loop: a
|
|
4423
|
-
// delivery:async submit never dials a peer whose signed card does
|
|
4424
|
-
// not declare async — failover must not silently degrade into the
|
|
4425
|
-
// minutes-long blocking wait the gate exists to prevent.
|
|
4426
|
-
let peerAsync = false;
|
|
4427
|
-
if (request.delivery === 'async') {
|
|
4428
|
-
const card = await fetch(candidate);
|
|
4429
|
-
peerAsync = card?.capabilities?.async === true;
|
|
4430
|
-
}
|
|
4431
|
-
const result = await dispatchPeerCandidate(candidate, splitQualifiedMember(request.handle)?.handle ?? splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
|
|
4432
|
-
if (!result.ok) {
|
|
4433
|
-
// Idempotency verdicts (B3) are terminal, not failover: a replay
|
|
4434
|
-
// says the prior attempt at THIS peer stays authoritative —
|
|
4435
|
-
// re-dispatching the same dedup key at another peer would execute
|
|
4436
|
-
// the task twice; a conflict is a caller bug (same key, different
|
|
4437
|
-
// payload) and must surface as a failure, not a redirect.
|
|
4438
|
-
if (result.code === WIRE_ERROR_REPLAY_REJECTED) {
|
|
4439
|
-
// async: acceptance IS the honest outcome — the submission is
|
|
4440
|
-
// fire-and-forget and the prior attempt settles via the receipt
|
|
4441
|
-
// contract.
|
|
4442
|
-
if (request.delivery === 'async') {
|
|
4443
|
-
return { kind: 'accepted', taskId, acceptedAt: new Date().toISOString(), contextId: request.contextId ?? '' };
|
|
4444
|
-
}
|
|
4445
|
-
// sync (W7 recovery table, S1): a sync caller cannot consume an
|
|
4446
|
-
// acceptance — its verdict contract needs round text, and the
|
|
4447
|
-
// handle-ack prose would settle a null verdict as a normal node.
|
|
4448
|
-
// Throw instead, and the frozen -32003 literal MUST ride the
|
|
4449
|
-
// message: structured codes have no survival channel through the
|
|
4450
|
-
// downstream submitFailedError wrap (message-only), so the
|
|
4451
|
-
// graph-loop classifier's probeText match on the code value is
|
|
4452
|
-
// the only programmatic channel (adjudicated exception to
|
|
4453
|
-
// "message 禁止下游解析").
|
|
4454
|
-
throw new Error(`A2A submit for "${request.handle}" replayed at the peer's idempotency ledger (task ${taskId} duplicate within the idempotency window, -32003: the prior attempt stays authoritative — fail-closed; outcome retrieval needs a query face)`);
|
|
4455
|
-
}
|
|
4456
|
-
if (result.code === WIRE_ERROR_IDEMPOTENCY_CONFLICT) {
|
|
4457
|
-
// -32002 literal on the message: same classification channel as the sync replay above.
|
|
4458
|
-
throw new Error(`A2A submit for "${request.handle}" conflicts at the peer's idempotency ledger (task ${taskId} reused with a different payload, -32002)`);
|
|
4459
|
-
}
|
|
4460
|
-
if (abort.aborted)
|
|
4461
|
-
break;
|
|
4462
|
-
continue;
|
|
4463
|
-
}
|
|
4464
|
-
trackOwedTask(taskId, splitCanvasOwner(request.handle).team, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
|
|
4465
|
-
const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
|
|
4466
|
-
if (result.task_status === 'TASK_STATE_COMPLETED') {
|
|
4467
|
-
return { kind: 'completed', text: result.reply, taskId, contextId };
|
|
4468
|
-
}
|
|
4469
|
-
// DELIVERED / ABORTED_WAIT: the message is in and the receipt
|
|
4470
|
-
// contract carries the outcome — the face maps both to the
|
|
4471
|
-
// accepted shape (the submission stays visible in a2a_tasks).
|
|
4472
|
-
return { kind: 'accepted', taskId, acceptedAt: new Date().toISOString(), contextId };
|
|
4473
|
-
}
|
|
4474
|
-
throw new Error(`A2A submit for "${request.handle}" failed on every candidate`);
|
|
4475
|
-
},
|
|
4476
|
-
queryOutcome: async (request, signal) => {
|
|
4477
|
-
const abort = signal ?? new AbortController().signal;
|
|
4478
|
-
if (request.taskId === '')
|
|
4479
|
-
return undefined;
|
|
4480
|
-
const failures = [];
|
|
4481
|
-
const fetch = memoizedCardFetch();
|
|
4482
|
-
const candidates = await directoryPeerCandidates(fetch, request.handle, failures);
|
|
4483
|
-
// No candidate to ask: no increment of information — undefined, never
|
|
4484
|
-
// a synthetic verdict.
|
|
4485
|
-
if (candidates.length === 0)
|
|
4486
|
-
return undefined;
|
|
4487
|
-
let mismatch = false;
|
|
4488
|
-
let answeredUnknown = false;
|
|
4489
|
-
for (const candidate of candidates) {
|
|
4490
|
-
// A caller cancel mid-fan-out ends the probe: answers collected
|
|
4491
|
-
// before the abort must not aggregate into a verdict the caller
|
|
4492
|
-
// already walked away from.
|
|
4493
|
-
if (abort.aborted)
|
|
4494
|
-
return undefined;
|
|
4495
|
-
// Recompute the fingerprint EXACTLY as the submit pass computed it:
|
|
4496
|
-
// the same shared implementation, and the same per-candidate async
|
|
4497
|
-
// gate (delivery async × that peer's declared capability). Sync
|
|
4498
|
-
// delivery — every graph-loop dispatch — is noWait:false throughout.
|
|
4499
|
-
let peerAsync = false;
|
|
4500
|
-
if (request.delivery === 'async') {
|
|
4501
|
-
const card = await fetch(candidate);
|
|
4502
|
-
peerAsync = card?.capabilities?.async === true;
|
|
4503
|
-
}
|
|
4504
|
-
const fingerprint = peerPayloadFingerprint({ caller: session, message: request.message, noWait: request.delivery === 'async' && peerAsync, team: request.handle });
|
|
4505
|
-
const answer = await client.queryOutcome(candidate, request.taskId, fingerprint, abort);
|
|
4506
|
-
if (answer === undefined)
|
|
4507
|
-
continue; // transport miss — probe the next candidate (read-only fan-out)
|
|
4508
|
-
if (!answer.found) {
|
|
4509
|
-
if (answer.reason === 'payload-mismatch')
|
|
4510
|
-
mismatch = true;
|
|
4511
|
-
else
|
|
4512
|
-
answeredUnknown = true;
|
|
4513
|
-
continue;
|
|
4514
|
-
}
|
|
4515
|
-
return answer;
|
|
4516
|
-
}
|
|
4517
|
-
// A mismatch anywhere wins the aggregation: the key exists at a peer
|
|
4518
|
-
// with a different payload — the most diagnostic negative there is.
|
|
4519
|
-
if (mismatch)
|
|
4520
|
-
return { found: false, reason: 'payload-mismatch' };
|
|
4521
|
-
if (answeredUnknown)
|
|
4522
|
-
return { found: false, reason: 'unknown-task' };
|
|
4523
|
-
return undefined;
|
|
4524
|
-
},
|
|
4525
|
-
cancel: async (ref, reason) => {
|
|
4526
|
-
const taskId = ref.taskId;
|
|
4527
|
-
if (!taskId)
|
|
4528
|
-
return false;
|
|
4529
|
-
const row = taskLedger.list().find(entry => entry.taskId === taskId);
|
|
4530
|
-
if (!row)
|
|
4531
|
-
return false;
|
|
4532
|
-
// Caller bookkeeping has priority. Notification compatibility never gates cancellation.
|
|
4533
|
-
const cleared = taskLedger.cancel(taskId, reason).outcome === 'cleared';
|
|
4534
|
-
if (!cleared)
|
|
4535
|
-
return false;
|
|
4536
|
-
try {
|
|
4537
|
-
const notice = cancelNoticeText(taskId, reason);
|
|
4538
|
-
if (row.peer === 'local' || row.direction === 'inbound')
|
|
4539
|
-
await cancelQueuedTask(taskId, notice, session, row);
|
|
4540
|
-
else {
|
|
4541
|
-
const card = await cachedCardFetch(row.peer);
|
|
4542
|
-
const supportsQueuedCancel = card?.capabilities?.queuedTaskCancellation === true;
|
|
4543
|
-
const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, supportsQueuedCancel ? (row.sender?.kind === 'session' ? row.sender.id : row.auth?.caller ?? session) : session, true, undefined, undefined, undefined, supportsQueuedCancel ? taskId : undefined);
|
|
4544
|
-
if (!result.ok)
|
|
4545
|
-
throw Error(result.error);
|
|
4546
|
-
}
|
|
4547
|
-
}
|
|
4548
|
-
catch (error) {
|
|
4549
|
-
logger.warn('a2a: cancellation committed; stop notice was best-effort: %s', String(error));
|
|
4550
|
-
}
|
|
4551
|
-
return true;
|
|
4552
|
-
},
|
|
4553
|
-
};
|
|
4554
|
-
ctx.reflect.provide(NATIVE_TEAMS_A2A_FACE_KEY, bridgeFace);
|
|
4555
4753
|
infoTools.registerStatus();
|
|
4556
4754
|
infoTools.registerTasks();
|
|
4557
4755
|
infoTools.registerProbe();
|
|
4558
4756
|
const pendingFinals = new Map();
|
|
4757
|
+
function removeFinalWaiter(agentId, entry) {
|
|
4758
|
+
const kept = (pendingFinals.get(agentId) ?? []).filter(other => other !== entry);
|
|
4759
|
+
if (kept.length)
|
|
4760
|
+
pendingFinals.set(agentId, kept);
|
|
4761
|
+
else
|
|
4762
|
+
pendingFinals.delete(agentId);
|
|
4763
|
+
entry.timeoutDisposer?.();
|
|
4764
|
+
}
|
|
4765
|
+
function pruneReceiptWaiters() {
|
|
4766
|
+
for (const [id, entries] of pendingFinals) {
|
|
4767
|
+
for (const entry of entries) {
|
|
4768
|
+
if (entry.purpose === 'receipt' && entry.taskId !== undefined && !taskLedger.isPending(entry.taskId))
|
|
4769
|
+
removeFinalWaiter(id, entry);
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
ctx.effect(() => {
|
|
4774
|
+
// Also cover task eviction/expiry independently of session idle events.
|
|
4775
|
+
const stop = ctx.timer.interval(pruneReceiptWaiters, 1000);
|
|
4776
|
+
return () => {
|
|
4777
|
+
stop();
|
|
4778
|
+
for (const entries of pendingFinals.values())
|
|
4779
|
+
for (const entry of entries)
|
|
4780
|
+
entry.timeoutDisposer?.();
|
|
4781
|
+
pendingFinals.clear();
|
|
4782
|
+
};
|
|
4783
|
+
}, 'a2a: final waiter lifecycle');
|
|
4559
4784
|
function answerFinalSafely(entry, text, placeholder) {
|
|
4560
4785
|
try {
|
|
4561
4786
|
entry.answer(text, placeholder);
|
|
@@ -4581,16 +4806,17 @@ export function apply(ctx, config) {
|
|
|
4581
4806
|
}, config.flushTimeoutMs);
|
|
4582
4807
|
};
|
|
4583
4808
|
function flushFinals(agentId) {
|
|
4809
|
+
pruneReceiptWaiters();
|
|
4584
4810
|
const entries = pendingFinals.get(agentId);
|
|
4585
4811
|
if (entries === undefined || entries.length === 0)
|
|
4586
4812
|
return;
|
|
4587
4813
|
const agent = ctx.get('agents')?.get(SessionId(agentId));
|
|
4588
4814
|
if (agent === undefined) {
|
|
4589
|
-
for (const entry of entries)
|
|
4590
|
-
entry.
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4815
|
+
for (const entry of entries)
|
|
4816
|
+
if (entry.purpose === 'http') {
|
|
4817
|
+
removeFinalWaiter(agentId, entry);
|
|
4818
|
+
answerFinalSafely(entry, 'The target DSH session is no longer live.', true);
|
|
4819
|
+
}
|
|
4594
4820
|
return;
|
|
4595
4821
|
}
|
|
4596
4822
|
// #114 S5: each waiter is answered from its OWN floor. The old shared
|
|
@@ -4628,6 +4854,9 @@ export function apply(ctx, config) {
|
|
|
4628
4854
|
if (answered.length === 0)
|
|
4629
4855
|
return;
|
|
4630
4856
|
for (const { entry, reply, placeholder } of answered) {
|
|
4857
|
+
// A closed empty/failed reading has no later attributable final: remove it,
|
|
4858
|
+
// invoke the existing placeholder no-op, and dispose its timer. HTTP expiry
|
|
4859
|
+
// itself does not enter this receipt path (receipt uses the original TTL).
|
|
4631
4860
|
// Remove one callback before invoking (it may synchronously re-enter), never the whole batch.
|
|
4632
4861
|
const current = pendingFinals.get(agentId) ?? [];
|
|
4633
4862
|
if (!current.includes(entry))
|