@nelsonlongxiang/dsh-open-a2a-net 0.6.37 → 0.6.39
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/README.md +340 -335
- package/README_zh.md +241 -236
- package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
- package/cli/a2a.mjs +44 -6
- package/cli/serve.mjs +132 -0
- package/cordis.patch.yml +21 -21
- package/lib/canvas-discovery.js +29 -0
- package/lib/canvas-id.js +27 -0
- package/lib/canvas-store.js +7 -1
- package/lib/client.js +63 -63
- package/lib/config.js +125 -0
- package/lib/direct-forward.js +105 -37
- package/lib/faces/admin.js +14 -1
- package/lib/faces/wire.js +41 -10
- package/lib/flood-guard.js +3 -1
- package/lib/index.js +116 -155
- package/lib/live-roots.js +39 -0
- package/lib/origin-claim.js +4 -3
- package/lib/peer-directory.js +1 -1
- package/lib/queue-guard.js +3 -1
- package/lib/skill/references/cli.md +214 -199
- package/lib/skill/references/pitfalls.md +1 -1
- package/lib/skill/references/routing.md +4 -0
- package/lib/skill/references/setup.md +5 -9
- package/lib/task-auth.js +15 -11
- package/lib/task-ledger-rows.js +32 -4
- package/lib/task-ledger.js +11 -5
- package/lib/tools/info.js +12 -5
- package/lib/types/canvas-discovery.d.ts +20 -0
- package/lib/types/canvas-id.d.ts +9 -0
- package/lib/types/config.d.ts +278 -0
- package/lib/types/direct-forward.d.ts +33 -2
- package/lib/types/faces/admin.d.ts +12 -0
- package/lib/types/faces/wire.d.ts +10 -1
- package/lib/types/index.d.ts +3 -277
- package/lib/types/live-roots.d.ts +35 -0
- package/lib/types/origin-claim.d.ts +5 -1
- package/lib/types/queue-guard.d.ts +2 -0
- package/lib/types/task-auth.d.ts +5 -0
- package/lib/types/task-ledger-rows.d.ts +16 -5
- package/lib/types/task-ledger.d.ts +3 -0
- package/lib/types/tools/info.d.ts +3 -0
- package/lib/types/types.d.ts +2 -0
- package/nexus-stage/package-lock.json +2262 -2262
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/src/edges.ts +72 -72
- package/nexus-stage/src/layout-wire.ts +158 -158
- package/nexus-stage/src/seat.ts +35 -35
- package/nexus-stage/src/stage-name.ts +13 -13
- package/nexus-stage/src/tokens.ts +31 -31
- package/package.json +218 -218
package/lib/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* inbound direct routes into live agent sessions.
|
|
6
6
|
* @module @nelsonlongxiang/dsh-open-a2a-net
|
|
7
7
|
*/
|
|
8
|
-
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
8
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
9
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
10
10
|
import { networkInterfaces } from 'node:os';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
@@ -26,36 +26,37 @@ export function resolveCallerLabel(input) {
|
|
|
26
26
|
return input.nodeTeam;
|
|
27
27
|
return input.sessionLabel;
|
|
28
28
|
}
|
|
29
|
-
import { signCard, verifyCard } from "./card.js";
|
|
29
|
+
import { encodePublicKey, signCard, verifyCard } from "./card.js";
|
|
30
30
|
import { GroupStore } from "./group-store.js";
|
|
31
31
|
import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
|
|
32
32
|
import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
|
|
33
33
|
import { LayoutStore } from "./layout-store.js";
|
|
34
34
|
import { peerLinkDisplay } from "./peer-link-display.js";
|
|
35
|
+
import { canvasOwners, canvasFailure } from "./canvas-discovery.js";
|
|
35
36
|
import { JoinedSessions } from "./joined-store.js";
|
|
36
37
|
import { ExposureStore } from "./exposure-store.js";
|
|
37
38
|
import { loadSeatNames } from "./owner-settings.js";
|
|
38
39
|
import { CLI_HANDLE_RE, CliDepositStore } from "./cli-deposit.js";
|
|
39
40
|
import { cliHomeChoices, decideCliHome } from "./cli-home.js";
|
|
40
41
|
import { outboundScope } from "./outbound-scope.js";
|
|
41
|
-
import { createDirectForward } from "./direct-forward.js";
|
|
42
|
+
import { createDirectForward, ForwardBirths } from "./direct-forward.js";
|
|
42
43
|
import { TeamMembershipStore } from "./team-store.js";
|
|
43
44
|
import { PeerStore } from "./peer-store.js";
|
|
44
45
|
import { SelfReferralFilter } from "./self-suppress.js";
|
|
45
|
-
import { TaskLedger,
|
|
46
|
+
import { TaskLedger, SUMMARY_CAP, briefOf } from "./task-ledger.js";
|
|
46
47
|
import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
|
|
47
48
|
import { runReceiptLadder } from "./receipt-ladder.js";
|
|
48
49
|
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
|
|
49
50
|
import { createFloodGuard } from "./flood-guard.js";
|
|
50
|
-
import { createQueueGuard,
|
|
51
|
+
import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
|
|
51
52
|
import { resolveZone } from "./zone.js";
|
|
52
53
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
53
54
|
import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
|
|
54
55
|
import { AuthObserver } from "./auth-observer.js";
|
|
55
56
|
import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
|
|
56
|
-
import s from '@deepseek-ai/schemastery';
|
|
57
57
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
58
58
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
59
|
+
import { liveRootAgents } from "./live-roots.js";
|
|
59
60
|
import { A2aClient } from "./a2a-client.js";
|
|
60
61
|
import { WIRE_ERROR_PAYLOAD_TOO_LARGE } from "./transport-caps.js";
|
|
61
62
|
import { registerAdminFaces } from "./faces/admin.js";
|
|
@@ -79,127 +80,10 @@ export { A2aClient } from "./a2a-client.js";
|
|
|
79
80
|
export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
80
81
|
export const name = 'a2a';
|
|
81
82
|
export const inject = ['tools', 'timer', 'systemPrompt'];
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
team: s.string().default('dsh'),
|
|
87
|
-
/** S4 fix ③: host-side (agent-less) routing calls — audit them (default) or refuse outright. */
|
|
88
|
-
gateAgentLessCalls: s.union(['audit', 'refuse']).default('audit'),
|
|
89
|
-
settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
|
|
90
|
-
announce: s.boolean().default(false),
|
|
91
|
-
isolated: s.boolean().default(false),
|
|
92
|
-
forwardDirect: s.boolean().default(false),
|
|
93
|
-
agentName: s.string().default('DeepSeek Harness A2A node'),
|
|
94
|
-
peers: s.array(s.string()).default([]),
|
|
95
|
-
delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
|
|
96
|
-
dshHome: s.string().default(''),
|
|
97
|
-
sessionNodes: s.boolean().default(true),
|
|
98
|
-
wakeJoinedOnBoot: s.boolean().default(false),
|
|
99
|
-
wakePrewarmDelayMs: s.number().default(10_000),
|
|
100
|
-
wakePrewarmQuietMs: s.number().default(5_000),
|
|
101
|
-
wakeBootStaggerMs: s.number().default(3_000),
|
|
102
|
-
wakeReconcile: s.boolean().default(true),
|
|
103
|
-
wakeReconcileIntervalMs: s.number().default(60_000),
|
|
104
|
-
wakeReconcileBackoffBaseMs: s.number().default(5_000),
|
|
105
|
-
wakeReconcileMaxBackoffMs: s.number().default(600_000),
|
|
106
|
-
/**
|
|
107
|
-
* S4 (gh#11 N7): the joinable-team curation list. A model calling
|
|
108
|
-
* a2a_team_join may only declare membership in a team with an EXACT
|
|
109
|
-
* entry here — trailing-`*` patterns are readable but authorize nothing
|
|
110
|
-
* (no mint rights: a pattern pre-authorizing every future name under it
|
|
111
|
-
* would let a model mint teams nobody created). Default empty: the
|
|
112
|
-
* owner's no-self-join ruling, institutionalized as default-deny; a
|
|
113
|
-
* refused join carries guidance instead of failing silently.
|
|
114
|
-
*/
|
|
115
|
-
teamJoinAllowlist: s.array(s.string()).default([]),
|
|
116
|
-
seatNames: s.dict(s.any()).default({}),
|
|
117
|
-
/**
|
|
118
|
-
* S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
|
|
119
|
-
* caller↔target shared team membership before addressing — a session
|
|
120
|
-
* with no declared team has no network (the unteamed-peer defect the
|
|
121
|
-
* owner reproduced on 4080 and locally). Fleet compatibility rides the
|
|
122
|
-
* roster capability flag (0.5.44 cards advertise roster:true; older
|
|
123
|
-
* peers read as legacy). Set false to restore the open outbound seam.
|
|
124
|
-
*/
|
|
125
|
-
teamScopeRouting: s.boolean().default(true),
|
|
126
|
-
/**
|
|
127
|
-
* S5 (default-on, owner directive 2026-10-20): the information-plane
|
|
128
|
-
* tools answer from the joined-team scope only — a2a_teams lists
|
|
129
|
-
* [this host] rows plus peer rows serving a joined team (note field
|
|
130
|
-
* explains the scope); a2a_status narrows peers / in-flight / activity
|
|
131
|
-
* to that scope (local traffic and idempotency aggregates stay);
|
|
132
|
-
* a2a_probe sweeps serving peers and refuses a single URL whose
|
|
133
|
-
* verified card cannot prove service. A host whose nodes declare no
|
|
134
|
-
* teams keeps host-internal facts only. Set false to restore
|
|
135
|
-
* fleet-wide disclosure.
|
|
136
|
-
*/
|
|
137
|
-
teamScopeInfo: s.boolean().default(true),
|
|
138
|
-
/**
|
|
139
|
-
* Peer boundary model — connection layer: an empty list keeps the
|
|
140
|
-
* historical open-referral posture; a non-empty list admits a referral
|
|
141
|
-
* only when its host matches an entry (exact host[:port], bare domain
|
|
142
|
-
* suffix, or full URL). Seeds from `peers` are owner-configured and
|
|
143
|
-
* unaffected.
|
|
144
|
-
*/
|
|
145
|
-
peerAllowlist: s.array(s.string()).default([]),
|
|
146
|
-
/**
|
|
147
|
-
* Peer boundary model — connection layer: when false, referral URLs are
|
|
148
|
-
* neither learned nor offered; the tracked set stays the configured seed
|
|
149
|
-
* set for the lifetime of the config that named them.
|
|
150
|
-
*/
|
|
151
|
-
referralLearning: s.boolean().default(true),
|
|
152
|
-
/**
|
|
153
|
-
* gh#8 (N9): team-name resolution rides the operator-established peer
|
|
154
|
-
* basis (config seeds + delegates) only; the referral-learned tail's
|
|
155
|
-
* self-published card claims do not bind team names. Set true to restore
|
|
156
|
-
* the legacy whole-store basis (fleet migration window).
|
|
157
|
-
*/
|
|
158
|
-
referralTeamResolution: s.boolean().default(false),
|
|
159
|
-
/**
|
|
160
|
-
* #19 D2 (ruling final, owner-level knob): how much of a session team's
|
|
161
|
-
* activity EXCERPT the card face publishes in sessionTeams descriptions.
|
|
162
|
-
* 'full' (default) keeps the verbatim tail excerpt; 'masked' truncates
|
|
163
|
-
* to a shape-only prefix; 'off' omits the description field entirely.
|
|
164
|
-
* Titles and team handles stay in every tier — the knob is the excerpt,
|
|
165
|
-
* not the listing.
|
|
166
|
-
*/
|
|
167
|
-
exposureExcerpt: s.union(['full', 'masked', 'off']).default('full'),
|
|
168
|
-
/**
|
|
169
|
-
* gh#9+gh#10 OriginClaim window: absent envelopes still walk N3 during
|
|
170
|
-
* the window (counted as legacyInbound); closing it rejects them like
|
|
171
|
-
* failed signatures. See docs/protocol/delivery-origin-auth.md §4.
|
|
172
|
-
*/
|
|
173
|
-
originClaimWindow: s.boolean().default(true),
|
|
174
|
-
/**
|
|
175
|
-
* OriginClaim unbound window: also sign/accept the legacy envelope that
|
|
176
|
-
* names no audience (counted as unboundInbound); closing it leaves the
|
|
177
|
-
* audience-bound form only. See docs/protocol/delivery-origin-auth.md §2.1.
|
|
178
|
-
*/
|
|
179
|
-
originClaimUnboundWindow: s.boolean().default(true),
|
|
180
|
-
/** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
|
|
181
|
-
originClaimAudiences: s.array(s.string()).default([]),
|
|
182
|
-
/** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
|
|
183
|
-
originClaimUrl: s.string().default(''),
|
|
184
|
-
/**
|
|
185
|
-
* v0.5.23 (async-stall): delay before a delivered-but-unconsumed async
|
|
186
|
-
* route re-wakes its target with a one-line nudge (defect
|
|
187
|
-
* t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
|
|
188
|
-
*/
|
|
189
|
-
asyncNudgeDelayMs: s.number().default(120_000),
|
|
190
|
-
/** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
|
|
191
|
-
targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
|
|
192
|
-
stateColdRowsTtlMs: s.number().default(5_000),
|
|
193
|
-
cardCacheTtlMs: s.number().default(60_000),
|
|
194
|
-
cardCacheNegativeTtlMs: s.number().default(30_000),
|
|
195
|
-
remoteRowsTtlMs: s.number().default(15_000),
|
|
196
|
-
cardTtlMs: s.number().default(172_800_000),
|
|
197
|
-
flushTimeoutMs: s.number().default(300_000),
|
|
198
|
-
routeTimeoutMs: s.number().default(1_800_000),
|
|
199
|
-
taskStaleTtlMs: s.number().default(TASK_STALE_TTL_MS),
|
|
200
|
-
nativeTeamsInbound: s.boolean().default(false),
|
|
201
|
-
nativeRoundWaitMs: s.number().default(180_000),
|
|
202
|
-
});
|
|
83
|
+
import { Config } from "./config.js";
|
|
84
|
+
import { canvasOwnerFingerprint, splitCanvasOwner } from "./canvas-id.js";
|
|
85
|
+
import { forwardReceiptBasis } from "./direct-forward.js";
|
|
86
|
+
export { Config };
|
|
203
87
|
/**
|
|
204
88
|
* Register the outbound peer tools, the direct-route endpoint, and (when
|
|
205
89
|
* composed) the announced card and session-node control surface.
|
|
@@ -563,6 +447,8 @@ export function apply(ctx, config) {
|
|
|
563
447
|
...(delegate.publicKey !== '' ? { publicKey: delegate.publicKey } : {}),
|
|
564
448
|
}));
|
|
565
449
|
const nodePrivateKey = loadOrCreateNodeKey(join(home, 'a2a', 'node-key.pem'));
|
|
450
|
+
/** #282 S2: this host's canvas owner fingerprint (`<canvas>@<fp8>` names our own canvas). */
|
|
451
|
+
const ownCanvasFingerprint = canvasOwnerFingerprint(encodePublicKey(createPublicKey(nodePrivateKey)));
|
|
566
452
|
/** Our own card URL once the web server settles — the OriginClaim anchor. */
|
|
567
453
|
let ownCardUrl = '';
|
|
568
454
|
/**
|
|
@@ -682,7 +568,13 @@ export function apply(ctx, config) {
|
|
|
682
568
|
// queryable here, and the correlating `[A2A receipt] task <id>` message
|
|
683
569
|
// resolves it — the caller-side half of the receipt contract, persisted so
|
|
684
570
|
// a restart does not orphan the reconciliation.
|
|
685
|
-
const taskLedger = new TaskLedger(join(home, 'a2a', 'tasks.json'), { staleTtlMs: config.taskStaleTtlMs });
|
|
571
|
+
const taskLedger = new TaskLedger(join(home, 'a2a', 'tasks.json'), { staleTtlMs: config.taskStaleTtlMs, warn: message => logger.warn(message) });
|
|
572
|
+
const enforceBlockers = taskLedger.settlementEnforceBlockers();
|
|
573
|
+
const enforceRefused = config.settlementAuth === 'enforce' && enforceBlockers.length > 0 ? { reason: 'legacy forwarded canvas debts lack receiver facts', blockers: enforceBlockers } : undefined;
|
|
574
|
+
if (enforceRefused !== undefined) {
|
|
575
|
+
logger.error('settlementAuth enforce refused; running observe (#125/#275): %s', JSON.stringify(enforceRefused));
|
|
576
|
+
config = { ...config, settlementAuth: 'observe' };
|
|
577
|
+
}
|
|
686
578
|
// Unjoined-node reporting ingress (design: docs/design/unjoined-report-ingress.md):
|
|
687
579
|
// a one-way signal channel for sessions with no network membership — a
|
|
688
580
|
// fallen-off or never-joined session reports "I exist / I have a task"
|
|
@@ -727,6 +619,12 @@ export function apply(ctx, config) {
|
|
|
727
619
|
* the consumer's own discipline — keep seam listeners synchronous.
|
|
728
620
|
* @param message - the inbound or relayed message text.
|
|
729
621
|
*/
|
|
622
|
+
const forwardingBirths = new ForwardBirths();
|
|
623
|
+
async function waitForForwardBirth(message) {
|
|
624
|
+
const receipt = parseReceipt(message);
|
|
625
|
+
if (receipt !== null)
|
|
626
|
+
await forwardingBirths.wait(receipt.taskId);
|
|
627
|
+
}
|
|
730
628
|
function settleAndAnnounce(message, settler) {
|
|
731
629
|
const parsedForJudge = parseReceipt(message);
|
|
732
630
|
const judge = settlementJudgeFor(settler, parsedForJudge);
|
|
@@ -1347,7 +1245,8 @@ export function apply(ctx, config) {
|
|
|
1347
1245
|
// side-effect-only.
|
|
1348
1246
|
ctx.on('agent/created', ({ agent }) => {
|
|
1349
1247
|
const agents = ctx.get('agents');
|
|
1350
|
-
|
|
1248
|
+
// #291: hosts without roots() (0.2.2-rc) resolve top-level via list()+delegation depth; never throws.
|
|
1249
|
+
if (agents === undefined || !liveRootAgents(agents).some(root => root.id === agent.id))
|
|
1351
1250
|
return undefined;
|
|
1352
1251
|
liveRoots.set(String(agent.id), agent);
|
|
1353
1252
|
if (joinedSessions.has(String(agent.id)))
|
|
@@ -1362,7 +1261,7 @@ export function apply(ctx, config) {
|
|
|
1362
1261
|
// the remount above (leave is the only intent remover).
|
|
1363
1262
|
sessionNodes.delete(String(agent.id));
|
|
1364
1263
|
});
|
|
1365
|
-
for (const agent of ctx.get('agents')
|
|
1264
|
+
for (const agent of liveRootAgents(ctx.get('agents'))) {
|
|
1366
1265
|
liveRoots.set(String(agent.id), agent);
|
|
1367
1266
|
if (joinedSessions.has(String(agent.id)))
|
|
1368
1267
|
mountSessionNode(agent);
|
|
@@ -1660,13 +1559,14 @@ export function apply(ctx, config) {
|
|
|
1660
1559
|
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1661
1560
|
registerAdminFaces(webServer, {
|
|
1662
1561
|
ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, 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,
|
|
1663
|
-
indexUrl: import.meta.url,
|
|
1562
|
+
indexUrl: import.meta.url, enforceRefused,
|
|
1664
1563
|
get cardCache() { return cardCache; },
|
|
1665
1564
|
get remoteRowsCache() { return remoteRowsCache; },
|
|
1666
1565
|
get legacyInboundCount() { return legacyInboundCount; },
|
|
1667
1566
|
get unboundInboundCount() { return unboundInboundCount; },
|
|
1668
1567
|
get boundMissInboundCount() { return boundMissInboundCount; },
|
|
1669
|
-
get
|
|
1568
|
+
get boundMissRecent() { return boundMissRecent; },
|
|
1569
|
+
get ownCardUrl() { return ownCardUrl; }, ownCanvasFingerprint,
|
|
1670
1570
|
get ownerSeatNames() { return ownerSeatNames; },
|
|
1671
1571
|
set ownerSeatNames(v) { ownerSeatNames = v; },
|
|
1672
1572
|
get ownerAllowlist() { return ownerAllowlist; },
|
|
@@ -1838,7 +1738,7 @@ export function apply(ctx, config) {
|
|
|
1838
1738
|
return agents.requireInitiator();
|
|
1839
1739
|
}
|
|
1840
1740
|
catch {
|
|
1841
|
-
const roots = agents
|
|
1741
|
+
const roots = liveRootAgents(agents);
|
|
1842
1742
|
return roots.length > 0 ? roots[0] : undefined;
|
|
1843
1743
|
}
|
|
1844
1744
|
}
|
|
@@ -2121,15 +2021,15 @@ export function apply(ctx, config) {
|
|
|
2121
2021
|
* marker instead of a (false) completion.
|
|
2122
2022
|
* @param taskId - the correlation key the steered receipt header carries.
|
|
2123
2023
|
*/
|
|
2124
|
-
async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom) {
|
|
2024
|
+
async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2125
2025
|
const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
|
|
2126
2026
|
if (agent !== undefined) {
|
|
2127
|
-
return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom);
|
|
2027
|
+
return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered);
|
|
2128
2028
|
}
|
|
2129
2029
|
// Wake-on-route: a cold joined team materializes on demand, then steers.
|
|
2130
2030
|
const wake = wakeColdTeam(team);
|
|
2131
2031
|
if (wake.kind === 'flight') {
|
|
2132
|
-
return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
|
|
2032
|
+
return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
|
|
2133
2033
|
}
|
|
2134
2034
|
// Native-teams inbound bridge (opt-in): a registry team claims the
|
|
2135
2035
|
// handle and dispatches through its authoritative seam. Checked before
|
|
@@ -2158,7 +2058,7 @@ export function apply(ctx, config) {
|
|
|
2158
2058
|
if (live !== undefined) {
|
|
2159
2059
|
if (callerIsAgent(live, caller))
|
|
2160
2060
|
return { ok: false, error: selfRouteText(team) };
|
|
2161
|
-
return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader);
|
|
2061
|
+
return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered);
|
|
2162
2062
|
}
|
|
2163
2063
|
return { ok: false, error: 'No live DSH agent is available to accept this message.' };
|
|
2164
2064
|
}
|
|
@@ -2173,7 +2073,7 @@ export function apply(ctx, config) {
|
|
|
2173
2073
|
* @param caller - caller label for the steered prefix.
|
|
2174
2074
|
* @returns the agent's final reply, or the explicit failure.
|
|
2175
2075
|
*/
|
|
2176
|
-
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom) {
|
|
2076
|
+
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2177
2077
|
return new Promise((resolve) => {
|
|
2178
2078
|
const waiter = registerFinalWaiter(agent, (text, placeholder) => {
|
|
2179
2079
|
resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}) });
|
|
@@ -2190,7 +2090,11 @@ export function apply(ctx, config) {
|
|
|
2190
2090
|
: `[A2A direct] ${taskId === undefined ? '' : `(task ${taskId}) `}未验证(in-process) ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
|
|
2191
2091
|
const text = `${headerLine}\n\n${message}`;
|
|
2192
2092
|
try {
|
|
2093
|
+
const floor = sessionEventsOf(agent).length;
|
|
2193
2094
|
steerRelay(agent, text, caller === '' ? undefined : caller, localFrom);
|
|
2095
|
+
onSteered?.(agent, floor);
|
|
2096
|
+
if (onSteered !== undefined && sessionEventsOf(agent).slice(floor).some(event => event?.type === 'assistant/message'))
|
|
2097
|
+
queueMicrotask(() => flushFinals(String(agent.id))); // synchronous final may already have emitted idle
|
|
2194
2098
|
}
|
|
2195
2099
|
catch (error) {
|
|
2196
2100
|
// The waiter was registered above, so the key always holds it; the
|
|
@@ -2223,6 +2127,15 @@ export function apply(ctx, config) {
|
|
|
2223
2127
|
* closing originClaimUnboundWindow; boundMissInbound: those of them whose
|
|
2224
2128
|
* bound form missed (a peer dials us by a name not in our audiences). */
|
|
2225
2129
|
let unboundInboundCount = 0, boundMissInboundCount = 0;
|
|
2130
|
+
/** Which verified callers missed, and the origin they dialled: what to add to originClaimAudiences (#64). */
|
|
2131
|
+
const boundMissRecent = [];
|
|
2132
|
+
const noteBoundMiss = (miss) => {
|
|
2133
|
+
boundMissInboundCount += 1;
|
|
2134
|
+
const at = boundMissRecent.findIndex(row => row.caller === miss.caller && row.audience === miss.audience);
|
|
2135
|
+
const row = at >= 0 ? boundMissRecent.splice(at, 1)[0] : { ...miss, count: 0, lastAt: 0 };
|
|
2136
|
+
boundMissRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
|
|
2137
|
+
boundMissRecent.length = Math.min(boundMissRecent.length, 16);
|
|
2138
|
+
};
|
|
2226
2139
|
function verifyOriginClaim(req, options) {
|
|
2227
2140
|
return verifyOriginClaimOf(req, {
|
|
2228
2141
|
cardCache,
|
|
@@ -2230,7 +2143,7 @@ export function apply(ctx, config) {
|
|
|
2230
2143
|
audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
|
|
2231
2144
|
unboundOpen: config.originClaimUnboundWindow !== false,
|
|
2232
2145
|
onUnbound: () => { unboundInboundCount += 1; },
|
|
2233
|
-
onBoundMiss:
|
|
2146
|
+
onBoundMiss: noteBoundMiss,
|
|
2234
2147
|
loopbackArrival: (config.originClaimUrl ?? '') === '',
|
|
2235
2148
|
now: Date.now(),
|
|
2236
2149
|
...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
|
|
@@ -2566,7 +2479,9 @@ export function apply(ctx, config) {
|
|
|
2566
2479
|
idempotencyStore,
|
|
2567
2480
|
verifyOriginClaim,
|
|
2568
2481
|
remoteCliProof,
|
|
2569
|
-
|
|
2482
|
+
forwardReceiptBasis: (taskId, caller, cliHandle, claim) => forwardReceiptBasis(taskLedger.find(taskId), caller, cliHandle, claim),
|
|
2483
|
+
forwardDirect: request => forwardDirect(request), ownCanvasFingerprint,
|
|
2484
|
+
diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
|
|
2570
2485
|
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
2571
2486
|
recordActivity,
|
|
2572
2487
|
bumpLegacyInbound: () => { legacyInboundCount += 1; },
|
|
@@ -2580,7 +2495,7 @@ export function apply(ctx, config) {
|
|
|
2580
2495
|
taskLedger,
|
|
2581
2496
|
refuseIfFlooded,
|
|
2582
2497
|
refuseIfQueueFull,
|
|
2583
|
-
settleAndAnnounce,
|
|
2498
|
+
settleAndAnnounce, waitForForwardBirth,
|
|
2584
2499
|
touchWakeDemand: () => { lastWakeDemandAt = Date.now(); },
|
|
2585
2500
|
session,
|
|
2586
2501
|
resolveAgentForTeam,
|
|
@@ -3057,7 +2972,7 @@ export function apply(ctx, config) {
|
|
|
3057
2972
|
};
|
|
3058
2973
|
const SWEEP_CONCURRENCY = 6;
|
|
3059
2974
|
const infoTools = createInfoTools({
|
|
3060
|
-
ctx, config, logger, session, lanIp, ownCardUrl, peerStore, taskLedger,
|
|
2975
|
+
ctx, config, logger, session, lanIp, get ownCardUrl() { return ownCardUrl; }, peerStore, taskLedger,
|
|
3061
2976
|
inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
|
|
3062
2977
|
teamMemberships, exposureStore, idempotencyStore, cardCache,
|
|
3063
2978
|
id8, sessionTeamOf,
|
|
@@ -3126,6 +3041,7 @@ export function apply(ctx, config) {
|
|
|
3126
3041
|
team: canvasTeamOf(name),
|
|
3127
3042
|
session,
|
|
3128
3043
|
name,
|
|
3044
|
+
kind: 'canvas', owner: ownCardUrl, memberCount: canvasStore.membersOf(name).length,
|
|
3129
3045
|
description: `canvas team — ${members.length} member${members.length === 1 ? '' : 's'}, ${live} live`,
|
|
3130
3046
|
local: true,
|
|
3131
3047
|
origin: localOrigin,
|
|
@@ -3268,6 +3184,7 @@ export function apply(ctx, config) {
|
|
|
3268
3184
|
};
|
|
3269
3185
|
}
|
|
3270
3186
|
const remote = failures.filter(entry => !entry.startsWith('local: '));
|
|
3187
|
+
const queueFull = failures.some(entry => entry.startsWith('local: target-queue-full'));
|
|
3271
3188
|
const suffix = team.slice(team.indexOf('/') + 1);
|
|
3272
3189
|
const near = [...new Set([
|
|
3273
3190
|
...joinedSessions.list().map(id => `${config.team}/${id8(id)}`),
|
|
@@ -3284,9 +3201,10 @@ export function apply(ctx, config) {
|
|
|
3284
3201
|
`team "${team}" is not published by any configured peer${remote.length > 0 ? ` (unresolved delegations: ${remote.join('; ')})` : ''}`,
|
|
3285
3202
|
...failures.filter(entry => entry.startsWith('local: ')).map(entry => `local reason: ${entry.slice('local: '.length)}`),
|
|
3286
3203
|
near.length > 0 ? `nearby advertised teams: ${near.join(', ')}` : undefined,
|
|
3287
|
-
|
|
3204
|
+
// #282 S1: a full local queue means the target exists and is busy — waiting is the only right next step.
|
|
3205
|
+
queueFull ? `next: ${QUEUE_FULL_NEXT}` : 'next: enumerate live alternatives with a2a_teams; do not retry unchanged; joining is the target session\'s own gesture (its sidebar) — relay that request to the human',
|
|
3288
3206
|
].filter((line) => line !== undefined);
|
|
3289
|
-
return { ok: false, error: lines.join(' — '), code: -32004 };
|
|
3207
|
+
return queueFull ? { ok: false, error: lines.join(' — '), code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT } : { ok: false, error: lines.join(' — '), code: -32004 };
|
|
3290
3208
|
};
|
|
3291
3209
|
actTools.registerRoute();
|
|
3292
3210
|
// #91 file channel F2: a2a_file_send / a2a_file_fetch (src/tools/files.ts).
|
|
@@ -3304,9 +3222,41 @@ export function apply(ctx, config) {
|
|
|
3304
3222
|
* caller label — the "能发即能收" invariant: whatever it addresses can
|
|
3305
3223
|
* answer it with one route.
|
|
3306
3224
|
*/
|
|
3225
|
+
const knownCanvasOwners = () => canvasOwners(canvasStore.list().map(name => ({ name, team: canvasTeamOf(name), members: canvasStore.membersOf(name) })), ownCardUrl, [...cardCache.entries()].flatMap(([url, entry]) => entry.card === undefined ? [] : [{ url, card: entry.card }]));
|
|
3226
|
+
const diagnoseCanvas = async (team, reason, caller) => {
|
|
3227
|
+
if (!new RegExp('^.+/canvas/[^/]+$').test(team))
|
|
3228
|
+
return reason;
|
|
3229
|
+
try {
|
|
3230
|
+
await listDirectoryTeams(false, true);
|
|
3231
|
+
}
|
|
3232
|
+
catch (error) {
|
|
3233
|
+
return reason + ' — canvas discovery unavailable: ' + String(error);
|
|
3234
|
+
}
|
|
3235
|
+
const resolve = localHandleResolver();
|
|
3236
|
+
const localId = caller === undefined ? undefined : resolve(caller);
|
|
3237
|
+
const owners = knownCanvasOwners().filter(row => !ownerTeamScopeInfo || (caller !== undefined && cliDeposits.get(caller) !== undefined) || (row.local ? localId !== undefined && row.members.some(ref => ref === localId || resolve(ref) === localId) : (row.scopeMembers ?? row.members).some(ref => { const id = resolve(ref); return id !== undefined && joinedSessions.has(id); })));
|
|
3238
|
+
const missing = [];
|
|
3239
|
+
if (caller !== undefined && cliDeposits.get(caller) !== undefined) {
|
|
3240
|
+
if (!config.forwardDirect)
|
|
3241
|
+
missing.push('home forwardDirect is off');
|
|
3242
|
+
if (config.isolated)
|
|
3243
|
+
missing.push('home is isolated');
|
|
3244
|
+
const scope = config.teamScopeRouting ? await outboundScope(senderScopeRecords, caller, caller, team) : { ok: true };
|
|
3245
|
+
if (!scope.ok)
|
|
3246
|
+
missing.push('sender scope: ' + scope.error);
|
|
3247
|
+
for (const owner of owners.filter(row => row.team === team && !row.local)) {
|
|
3248
|
+
if (!owner.members.includes(caller))
|
|
3249
|
+
missing.push(owner.owner + ': caller is not an advertised canvas member');
|
|
3250
|
+
// #282 S3: no per-member link here; the owner's signed canvas is the return path.
|
|
3251
|
+
if (owner.members.every(member => member === caller))
|
|
3252
|
+
missing.push(owner.owner + ': the canvas lists no receiving member besides the caller');
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
return canvasFailure(team, owners, reason) + (missing.length === 0 ? '' : ' — missing prerequisites: ' + missing.join('; '));
|
|
3256
|
+
};
|
|
3307
3257
|
const forwardDirect = createDirectForward({
|
|
3258
|
+
births: forwardingBirths,
|
|
3308
3259
|
enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
|
|
3309
|
-
memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
|
|
3310
3260
|
scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
|
|
3311
3261
|
candidates: async (team, failures, caller) => { const fetch = memoizedCardFetch(); return { urls: await directoryPeerCandidates(fetch, team, failures, { id: caller, handle: caller }), card: fetch }; },
|
|
3312
3262
|
dispatch: (url, request, guard) => dispatchPeerCandidate(url, request.team, request.message, request.contextId, new AbortController().signal, request.caller, request.noWait, request.taskId, request.caller, guard),
|
|
@@ -3338,6 +3288,12 @@ export function apply(ctx, config) {
|
|
|
3338
3288
|
// node team when it has joined, else the node label), so the receiver
|
|
3339
3289
|
// can answer with one a2a_route instead of an unroutable display label.
|
|
3340
3290
|
a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
|
|
3291
|
+
// #282 S2: an owner-qualified canvas id. Our own fingerprint is our canvas; a foreign one never
|
|
3292
|
+
// lands on a same-named local canvas, and the directory walk keeps only that owner.
|
|
3293
|
+
const ownerQualified = splitCanvasOwner(String(args.team ?? '').trim());
|
|
3294
|
+
const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
|
|
3295
|
+
if (ownerQualified.owner !== undefined)
|
|
3296
|
+
args = { ...args, team: ownerQualified.team };
|
|
3341
3297
|
// A target-authored receipt attempt answers its own work even if scope/transport later refuses delivery.
|
|
3342
3298
|
const answer = parseReceipt(args.message);
|
|
3343
3299
|
if (agent !== undefined && answer !== null)
|
|
@@ -3363,7 +3319,7 @@ export function apply(ctx, config) {
|
|
|
3363
3319
|
scopedCanvasCaller = { id: scopeCaller, handle: callerHandle };
|
|
3364
3320
|
const scope = await outboundScope(senderScopeRecords, scopeCaller, callerHandle, String(args.team ?? '').trim());
|
|
3365
3321
|
if (!scope.ok)
|
|
3366
|
-
return localRoute(scope);
|
|
3322
|
+
return localRoute({ ...scope, error: await diagnoseCanvas(args.team, scope.error, callerHandle) });
|
|
3367
3323
|
}
|
|
3368
3324
|
}
|
|
3369
3325
|
let callerSession = resolveCallerLabel({
|
|
@@ -3410,7 +3366,7 @@ export function apply(ctx, config) {
|
|
|
3410
3366
|
const failures = [];
|
|
3411
3367
|
if (signal?.aborted)
|
|
3412
3368
|
return localRoute({ ok: false, error: 'route aborted before dispatch', code: -32000 });
|
|
3413
|
-
const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
|
|
3369
|
+
const outcome = foreignOwner !== undefined ? undefined : await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
|
|
3414
3370
|
const localBirth = localBirths.get(taskId);
|
|
3415
3371
|
localBirths.delete(taskId);
|
|
3416
3372
|
if (outcome !== undefined) {
|
|
@@ -3431,7 +3387,7 @@ export function apply(ctx, config) {
|
|
|
3431
3387
|
// union in member order. Non-canvas teams keep the single-walk shape
|
|
3432
3388
|
// unchanged. The ledger stays keyed to the canvas team the caller
|
|
3433
3389
|
// named; the peer request carries the member handle it must resolve.
|
|
3434
|
-
const canvasName = parseCanvasTeamName(args.team);
|
|
3390
|
+
const canvasName = foreignOwner !== undefined ? undefined : parseCanvasTeamName(args.team);
|
|
3435
3391
|
// gh#131: one of this host's own handles is a local member, never a directory target.
|
|
3436
3392
|
const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
|
|
3437
3393
|
const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
|
|
@@ -3447,7 +3403,7 @@ export function apply(ctx, config) {
|
|
|
3447
3403
|
}
|
|
3448
3404
|
const candidatePool = [];
|
|
3449
3405
|
for (const target of targets) {
|
|
3450
|
-
for (const candidate of await directoryPeerCandidates(fetch, target, failures, scopedCanvasCaller)) {
|
|
3406
|
+
for (const candidate of await directoryPeerCandidates(fetch, ownerQualified.owner !== undefined && target === args.team ? `${target}@${ownerQualified.owner}` : target, failures, scopedCanvasCaller)) {
|
|
3451
3407
|
candidatePool.push({ candidate, target });
|
|
3452
3408
|
}
|
|
3453
3409
|
}
|
|
@@ -3491,7 +3447,8 @@ export function apply(ctx, config) {
|
|
|
3491
3447
|
}
|
|
3492
3448
|
if (failures.length > 0)
|
|
3493
3449
|
logger.warn(`route to team "${args.team}" exhausted its candidates: ${failures.join('; ')}`);
|
|
3494
|
-
|
|
3450
|
+
const verdict = routeVerdict(args.team, failures, candidatePool.length);
|
|
3451
|
+
return { ...verdict, error: await diagnoseCanvas(args.team, verdict.error, callerSession) };
|
|
3495
3452
|
}
|
|
3496
3453
|
/**
|
|
3497
3454
|
* The route dispatcher's local half: same-host candidates dial in-process,
|
|
@@ -3530,7 +3487,7 @@ export function apply(ctx, config) {
|
|
|
3530
3487
|
const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, cliDeposits.get(team) !== undefined, ownTask);
|
|
3531
3488
|
if (queueRefusal !== undefined) {
|
|
3532
3489
|
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3533
|
-
return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL };
|
|
3490
|
+
return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT };
|
|
3534
3491
|
}
|
|
3535
3492
|
const canvasName = parseCanvasTeamName(team);
|
|
3536
3493
|
// Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
|
|
@@ -3915,14 +3872,18 @@ export function apply(ctx, config) {
|
|
|
3915
3872
|
// (gh#8: a squatter two gossip hops away captured routes by naming
|
|
3916
3873
|
// itself after the target). referralTeamResolution restores the legacy
|
|
3917
3874
|
// whole-store basis for fleets mid-migration.
|
|
3875
|
+
// #282 S2: `<canvas>@<fp8>` selects only the owner whose signed card key matches; no delegation walk.
|
|
3876
|
+
const qualified = splitCanvasOwner(team);
|
|
3877
|
+
team = qualified.team;
|
|
3878
|
+
const ownerOk = (card) => qualified.owner === undefined || canvasOwnerFingerprint(card?.publicKey) === qualified.owner;
|
|
3918
3879
|
const peers = resolutionPeers();
|
|
3919
3880
|
const candidates = [];
|
|
3920
|
-
const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
|
|
3881
|
+
const cards = (await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }))).map(entry => ownerOk(entry.card) ? entry : { peer: entry.peer, card: undefined });
|
|
3921
3882
|
// Check after fetching all cards: a seeded peer publishing the same id8
|
|
3922
3883
|
// makes the caller handle ambiguous, so do not send a canvas payload.
|
|
3923
3884
|
const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
|
|
3924
3885
|
const resolveLocal = sessions === undefined ? undefined : (ref) => cliDeposits.get(ref) !== undefined ? ref : sessions(ref);
|
|
3925
|
-
const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
|
|
3886
|
+
const resolutions = qualified.owner !== undefined ? [] : await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
|
|
3926
3887
|
if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
|
|
3927
3888
|
failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
|
|
3928
3889
|
return [];
|
|
@@ -4011,7 +3972,7 @@ export function apply(ctx, config) {
|
|
|
4011
3972
|
const card = await fetch(candidate);
|
|
4012
3973
|
peerAsync = card?.capabilities?.async === true;
|
|
4013
3974
|
}
|
|
4014
|
-
const result = await dispatchPeerCandidate(candidate, request.handle, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
|
|
3975
|
+
const result = await dispatchPeerCandidate(candidate, splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
|
|
4015
3976
|
if (!result.ok) {
|
|
4016
3977
|
// Idempotency verdicts (B3) are terminal, not failover: a replay
|
|
4017
3978
|
// says the prior attempt at THIS peer stays authoritative —
|
|
@@ -4044,7 +4005,7 @@ export function apply(ctx, config) {
|
|
|
4044
4005
|
break;
|
|
4045
4006
|
continue;
|
|
4046
4007
|
}
|
|
4047
|
-
trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
|
|
4008
|
+
trackOwedTask(taskId, splitCanvasOwner(request.handle).team, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
|
|
4048
4009
|
const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
|
|
4049
4010
|
if (result.task_status === 'TASK_STATE_COMPLETED') {
|
|
4050
4011
|
return { kind: 'completed', text: result.reply, taskId, contextId };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function safeDepth(value) {
|
|
2
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)
|
|
3
|
+
? value
|
|
4
|
+
: undefined;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Delegation depth of a live agent: 0 = top-level. Mirrors the harness'
|
|
8
|
+
* authoritative max(persisted session header, runtime options); invalid
|
|
9
|
+
* values read as absent (never throwing) — see the module comment.
|
|
10
|
+
*/
|
|
11
|
+
export function delegationDepthOf(agent) {
|
|
12
|
+
const carrier = agent;
|
|
13
|
+
return Math.max(safeDepth(carrier.options?.subagentDepth) ?? 0, safeDepth(carrier.session?.header?.delegationDepth) ?? 0);
|
|
14
|
+
}
|
|
15
|
+
/** Whether a live agent is top-level (not inside any subagent tree). */
|
|
16
|
+
export function isTopLevelAgent(agent) {
|
|
17
|
+
return delegationDepthOf(agent) === 0;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Live top-level agents on this host: `roots()` where the host still has
|
|
21
|
+
* it (exact pre-0.2.2 semantics, so old hosts change nothing), else
|
|
22
|
+
* `list()` filtered by delegation depth. A missing registry, a surface
|
|
23
|
+
* with neither method, or a throwing method all degrade to `[]`.
|
|
24
|
+
*/
|
|
25
|
+
export function liveRootAgents(registry) {
|
|
26
|
+
if (registry === undefined || registry === null)
|
|
27
|
+
return [];
|
|
28
|
+
const surface = registry;
|
|
29
|
+
try {
|
|
30
|
+
if (typeof surface.roots === 'function')
|
|
31
|
+
return [...surface.roots()];
|
|
32
|
+
if (typeof surface.list === 'function')
|
|
33
|
+
return surface.list().filter(isTopLevelAgent);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return [];
|
|
39
|
+
}
|
package/lib/origin-claim.js
CHANGED
|
@@ -210,7 +210,7 @@ export async function verifyOriginClaim(req, d) {
|
|
|
210
210
|
return;
|
|
211
211
|
d.onUnbound();
|
|
212
212
|
if (hasBound)
|
|
213
|
-
d.onBoundMiss?.();
|
|
213
|
+
d.onBoundMiss?.({ caller, audience: originOf(audience) });
|
|
214
214
|
};
|
|
215
215
|
// gh#133: a CLI identity deposited HERE, decided from the deposit table
|
|
216
216
|
// before any fetch (the CLI may reach its home through a proxy the host
|
|
@@ -224,7 +224,7 @@ export async function verifyOriginClaim(req, d) {
|
|
|
224
224
|
if (cli.name !== caller)
|
|
225
225
|
return { ok: false, reason: 'not-derivable' };
|
|
226
226
|
accepted();
|
|
227
|
-
return { ok: true, caller: cli.handle, callerUrl, card: cli.card, bound, cli: true };
|
|
227
|
+
return { ok: true, caller: cli.handle, callerUrl, card: cli.card, bound, cli: true, ...(bound && hasBodySig ? { bodyBound: true } : {}) };
|
|
228
228
|
}
|
|
229
229
|
// Fetch the caller's card PLAIN (REVIEW M2 third-round ruling: the
|
|
230
230
|
// envelope names a BARE ZONE, and derivation is the exact match
|
|
@@ -265,5 +265,6 @@ export async function verifyOriginClaim(req, d) {
|
|
|
265
265
|
if (card.team !== caller)
|
|
266
266
|
return { ok: false, reason: 'not-derivable' };
|
|
267
267
|
accepted();
|
|
268
|
-
|
|
268
|
+
// #282 S3 review: a verified Sig3 means the body itself was signed (stripping it falls back to Sig2, which has no bodyBound).
|
|
269
|
+
return { ok: true, caller, callerUrl, card, bound, ...(bound && hasBodySig ? { bodyBound: true } : {}) };
|
|
269
270
|
}
|
package/lib/peer-directory.js
CHANGED
|
@@ -109,7 +109,7 @@ export function peerDirectoryRows(card, peer, includeCanvas) {
|
|
|
109
109
|
if (includeCanvas)
|
|
110
110
|
for (const entry of advertisedCanvasTeams(card)) {
|
|
111
111
|
const declared = rosterOf.get(entry.team);
|
|
112
|
-
rows.push({ team: entry.team, session: card.session, name: entry.name, description: 'canvas team', origin, via: peer, ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}), canvasMembers: entry.members.filter(ref => !publishedSessions.has(ref)) });
|
|
112
|
+
rows.push({ team: entry.team, session: card.session, name: entry.name, description: 'canvas team', kind: 'canvas', owner: peer, memberCount: entry.members.length, origin, via: peer, ...(legacy ? { legacy } : {}), ...(declared !== undefined && declared.length > 0 ? { teams: declared } : {}), canvasMembers: entry.members.filter(ref => !publishedSessions.has(ref)) });
|
|
113
113
|
}
|
|
114
114
|
return rows;
|
|
115
115
|
}
|
package/lib/queue-guard.js
CHANGED
|
@@ -21,6 +21,8 @@ export function queueFullRefusal(rows, team, message, cap, mailbox, ownTask) {
|
|
|
21
21
|
return undefined;
|
|
22
22
|
return `target-queue-full (gh#224): "${team}" already has ${String(queued)} task(s) not yet answered by the target (cap ${String(limit)}) — nothing was delivered. Wait for a receipt from it (a2a inbox / a2a_tasks), or send again later with a NEW task id (this one is recorded as failed); do not resend in a loop. A task that never gets a receipt turns dead after taskStaleTtlMs and stops counting.`;
|
|
23
23
|
}
|
|
24
|
+
/** #282 S1: the one next step for a full target queue. */
|
|
25
|
+
export const QUEUE_FULL_NEXT = 'wait for a receipt from the target (a2a-net inbox / a2a_tasks), then send again with a new task id; do not resend in a loop';
|
|
24
26
|
/** Wire-face adapter in the flood guard's shape: a refused fresh key is FAILED, not stranded pending. */
|
|
25
27
|
export function createQueueGuard(rows, cap, ownTask, outcomes, recordActivity) {
|
|
26
28
|
return (caller, message, team, taskId, mailbox, res) => {
|
|
@@ -29,7 +31,7 @@ export function createQueueGuard(rows, cap, ownTask, outcomes, recordActivity) {
|
|
|
29
31
|
return false;
|
|
30
32
|
outcomes.recordOutcome(taskId, { status: 'failed', error });
|
|
31
33
|
recordActivity('in', team, caller, false);
|
|
32
|
-
const payload = JSON.stringify({ error, reason: 'target-queue-full', code: WIRE_ERROR_QUEUE_FULL, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED' });
|
|
34
|
+
const payload = JSON.stringify({ error, reason: 'target-queue-full', code: WIRE_ERROR_QUEUE_FULL, task_id: taskId, team, delivered: false, task_status: 'TASK_STATE_FAILED', next: QUEUE_FULL_NEXT });
|
|
33
35
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
34
36
|
res.end(payload);
|
|
35
37
|
return true;
|