@nelsonlongxiang/dsh-open-a2a-net 0.6.38 → 0.6.40
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 +342 -335
- package/README_zh.md +243 -236
- package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
- package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
- package/cli/a2a.mjs +20 -5
- package/cli/serve.mjs +132 -0
- package/cordis.patch.yml +21 -21
- package/lib/a2a-client.js +1 -0
- package/lib/canvas-id.js +27 -0
- package/lib/canvas-store.js +7 -1
- package/lib/cli-deposit.js +2 -2
- package/lib/client.js +63 -63
- package/lib/config.js +125 -0
- package/lib/direct-forward.js +64 -18
- package/lib/faces/admin.js +4 -1
- package/lib/faces/wire.js +77 -7
- package/lib/flood-guard.js +3 -1
- package/lib/index.js +84 -172
- package/lib/lan-ip.js +20 -0
- package/lib/live-roots.js +39 -0
- package/lib/origin-claim.js +3 -2
- package/lib/peer-link-display.js +3 -2
- package/lib/queue-guard.js +3 -1
- package/lib/skill/references/cli.md +215 -203
- package/lib/skill/references/routing.md +4 -0
- package/lib/skill/references/setup.md +156 -153
- package/lib/task-auth.js +14 -10
- package/lib/types/canvas-id.d.ts +9 -0
- package/lib/types/cli-deposit.d.ts +6 -1
- package/lib/types/config.d.ts +278 -0
- package/lib/types/direct-forward.d.ts +25 -3
- package/lib/types/faces/admin.d.ts +2 -0
- package/lib/types/faces/wire.d.ts +26 -0
- package/lib/types/index.d.ts +3 -277
- package/lib/types/lan-ip.d.ts +7 -0
- package/lib/types/live-roots.d.ts +35 -0
- package/lib/types/origin-claim.d.ts +1 -0
- package/lib/types/peer-link-display.d.ts +2 -0
- package/lib/types/queue-guard.d.ts +2 -0
- package/lib/types/task-auth.d.ts +3 -0
- package/lib/types/types.d.ts +7 -0
- package/nexus-stage/package-lock.json +2262 -2262
- 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 +219 -218
package/lib/index.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
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
|
+
import { selectLanIPv4 } from "./lan-ip.js";
|
|
11
12
|
import { dirname, join } from 'node:path';
|
|
12
13
|
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
13
14
|
// The package own version, read from package.json at load time so the
|
|
@@ -26,7 +27,7 @@ export function resolveCallerLabel(input) {
|
|
|
26
27
|
return input.nodeTeam;
|
|
27
28
|
return input.sessionLabel;
|
|
28
29
|
}
|
|
29
|
-
import { signCard, verifyCard } from "./card.js";
|
|
30
|
+
import { encodePublicKey, signCard, verifyCard } from "./card.js";
|
|
30
31
|
import { GroupStore } from "./group-store.js";
|
|
31
32
|
import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
|
|
32
33
|
import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
|
|
@@ -43,20 +44,20 @@ import { createDirectForward, ForwardBirths } from "./direct-forward.js";
|
|
|
43
44
|
import { TeamMembershipStore } from "./team-store.js";
|
|
44
45
|
import { PeerStore } from "./peer-store.js";
|
|
45
46
|
import { SelfReferralFilter } from "./self-suppress.js";
|
|
46
|
-
import { TaskLedger,
|
|
47
|
+
import { TaskLedger, SUMMARY_CAP, briefOf } from "./task-ledger.js";
|
|
47
48
|
import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
|
|
48
49
|
import { runReceiptLadder } from "./receipt-ladder.js";
|
|
49
50
|
import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
|
|
50
51
|
import { createFloodGuard } from "./flood-guard.js";
|
|
51
|
-
import { createQueueGuard,
|
|
52
|
+
import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
|
|
52
53
|
import { resolveZone } from "./zone.js";
|
|
53
54
|
import { registerA2aNetworkSkill } from "./skill-registration.js";
|
|
54
55
|
import { isRootOriginUrl, sameMountedUrl, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
|
|
55
56
|
import { AuthObserver } from "./auth-observer.js";
|
|
56
57
|
import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
|
|
57
|
-
import s from '@deepseek-ai/schemastery';
|
|
58
58
|
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
59
59
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
60
|
+
import { liveRootAgents } from "./live-roots.js";
|
|
60
61
|
import { A2aClient } from "./a2a-client.js";
|
|
61
62
|
import { WIRE_ERROR_PAYLOAD_TOO_LARGE } from "./transport-caps.js";
|
|
62
63
|
import { registerAdminFaces } from "./faces/admin.js";
|
|
@@ -80,127 +81,10 @@ export { A2aClient } from "./a2a-client.js";
|
|
|
80
81
|
export { NATIVE_TEAMS_A2A_FACE_KEY } from "./teams-bridge.js";
|
|
81
82
|
export const name = 'a2a';
|
|
82
83
|
export const inject = ['tools', 'timer', 'systemPrompt'];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
team: s.string().default('dsh'),
|
|
88
|
-
/** S4 fix ③: host-side (agent-less) routing calls — audit them (default) or refuse outright. */
|
|
89
|
-
gateAgentLessCalls: s.union(['audit', 'refuse']).default('audit'),
|
|
90
|
-
settlementAuth: s.union(['off', 'observe', 'enforce']).default('observe'),
|
|
91
|
-
announce: s.boolean().default(false),
|
|
92
|
-
isolated: s.boolean().default(false),
|
|
93
|
-
forwardDirect: s.boolean().default(true),
|
|
94
|
-
agentName: s.string().default('DeepSeek Harness A2A node'),
|
|
95
|
-
peers: s.array(s.string()).default([]),
|
|
96
|
-
delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
|
|
97
|
-
dshHome: s.string().default(''),
|
|
98
|
-
sessionNodes: s.boolean().default(true),
|
|
99
|
-
wakeJoinedOnBoot: s.boolean().default(false),
|
|
100
|
-
wakePrewarmDelayMs: s.number().default(10_000),
|
|
101
|
-
wakePrewarmQuietMs: s.number().default(5_000),
|
|
102
|
-
wakeBootStaggerMs: s.number().default(3_000),
|
|
103
|
-
wakeReconcile: s.boolean().default(true),
|
|
104
|
-
wakeReconcileIntervalMs: s.number().default(60_000),
|
|
105
|
-
wakeReconcileBackoffBaseMs: s.number().default(5_000),
|
|
106
|
-
wakeReconcileMaxBackoffMs: s.number().default(600_000),
|
|
107
|
-
/**
|
|
108
|
-
* S4 (gh#11 N7): the joinable-team curation list. A model calling
|
|
109
|
-
* a2a_team_join may only declare membership in a team with an EXACT
|
|
110
|
-
* entry here — trailing-`*` patterns are readable but authorize nothing
|
|
111
|
-
* (no mint rights: a pattern pre-authorizing every future name under it
|
|
112
|
-
* would let a model mint teams nobody created). Default empty: the
|
|
113
|
-
* owner's no-self-join ruling, institutionalized as default-deny; a
|
|
114
|
-
* refused join carries guidance instead of failing silently.
|
|
115
|
-
*/
|
|
116
|
-
teamJoinAllowlist: s.array(s.string()).default([]),
|
|
117
|
-
seatNames: s.dict(s.any()).default({}),
|
|
118
|
-
/**
|
|
119
|
-
* S3 (phase-2 default, owner ruling 2026-09-01): route dispatch checks
|
|
120
|
-
* caller↔target shared team membership before addressing — a session
|
|
121
|
-
* with no declared team has no network (the unteamed-peer defect the
|
|
122
|
-
* owner reproduced on 4080 and locally). Fleet compatibility rides the
|
|
123
|
-
* roster capability flag (0.5.44 cards advertise roster:true; older
|
|
124
|
-
* peers read as legacy). Set false to restore the open outbound seam.
|
|
125
|
-
*/
|
|
126
|
-
teamScopeRouting: s.boolean().default(true),
|
|
127
|
-
/**
|
|
128
|
-
* S5 (default-on, owner directive 2026-10-20): the information-plane
|
|
129
|
-
* tools answer from the joined-team scope only — a2a_teams lists
|
|
130
|
-
* [this host] rows plus peer rows serving a joined team (note field
|
|
131
|
-
* explains the scope); a2a_status narrows peers / in-flight / activity
|
|
132
|
-
* to that scope (local traffic and idempotency aggregates stay);
|
|
133
|
-
* a2a_probe sweeps serving peers and refuses a single URL whose
|
|
134
|
-
* verified card cannot prove service. A host whose nodes declare no
|
|
135
|
-
* teams keeps host-internal facts only. Set false to restore
|
|
136
|
-
* fleet-wide disclosure.
|
|
137
|
-
*/
|
|
138
|
-
teamScopeInfo: s.boolean().default(true),
|
|
139
|
-
/**
|
|
140
|
-
* Peer boundary model — connection layer: an empty list keeps the
|
|
141
|
-
* historical open-referral posture; a non-empty list admits a referral
|
|
142
|
-
* only when its host matches an entry (exact host[:port], bare domain
|
|
143
|
-
* suffix, or full URL). Seeds from `peers` are owner-configured and
|
|
144
|
-
* unaffected.
|
|
145
|
-
*/
|
|
146
|
-
peerAllowlist: s.array(s.string()).default([]),
|
|
147
|
-
/**
|
|
148
|
-
* Peer boundary model — connection layer: when false, referral URLs are
|
|
149
|
-
* neither learned nor offered; the tracked set stays the configured seed
|
|
150
|
-
* set for the lifetime of the config that named them.
|
|
151
|
-
*/
|
|
152
|
-
referralLearning: s.boolean().default(true),
|
|
153
|
-
/**
|
|
154
|
-
* gh#8 (N9): team-name resolution rides the operator-established peer
|
|
155
|
-
* basis (config seeds + delegates) only; the referral-learned tail's
|
|
156
|
-
* self-published card claims do not bind team names. Set true to restore
|
|
157
|
-
* the legacy whole-store basis (fleet migration window).
|
|
158
|
-
*/
|
|
159
|
-
referralTeamResolution: s.boolean().default(false),
|
|
160
|
-
/**
|
|
161
|
-
* #19 D2 (ruling final, owner-level knob): how much of a session team's
|
|
162
|
-
* activity EXCERPT the card face publishes in sessionTeams descriptions.
|
|
163
|
-
* 'full' (default) keeps the verbatim tail excerpt; 'masked' truncates
|
|
164
|
-
* to a shape-only prefix; 'off' omits the description field entirely.
|
|
165
|
-
* Titles and team handles stay in every tier — the knob is the excerpt,
|
|
166
|
-
* not the listing.
|
|
167
|
-
*/
|
|
168
|
-
exposureExcerpt: s.union(['full', 'masked', 'off']).default('full'),
|
|
169
|
-
/**
|
|
170
|
-
* gh#9+gh#10 OriginClaim window: absent envelopes still walk N3 during
|
|
171
|
-
* the window (counted as legacyInbound); closing it rejects them like
|
|
172
|
-
* failed signatures. See docs/protocol/delivery-origin-auth.md §4.
|
|
173
|
-
*/
|
|
174
|
-
originClaimWindow: s.boolean().default(true),
|
|
175
|
-
/**
|
|
176
|
-
* OriginClaim unbound window: also sign/accept the legacy envelope that
|
|
177
|
-
* names no audience (counted as unboundInbound); closing it leaves the
|
|
178
|
-
* audience-bound form only. See docs/protocol/delivery-origin-auth.md §2.1.
|
|
179
|
-
*/
|
|
180
|
-
originClaimUnboundWindow: s.boolean().default(true),
|
|
181
|
-
/** Extra OriginClaim audience origins (port-mapped/proxied hosts). */
|
|
182
|
-
originClaimAudiences: s.array(s.string()).default([]),
|
|
183
|
-
/** OriginClaim callerUrl override for pocket-tunneled hosts; '' = auto. */
|
|
184
|
-
originClaimUrl: s.string().default(''),
|
|
185
|
-
/**
|
|
186
|
-
* v0.5.23 (async-stall): delay before a delivered-but-unconsumed async
|
|
187
|
-
* route re-wakes its target with a one-line nudge (defect
|
|
188
|
-
* t-mt6nd0sq-hxuhj6). 0 disables the nudge entirely.
|
|
189
|
-
*/
|
|
190
|
-
asyncNudgeDelayMs: s.number().default(120_000),
|
|
191
|
-
/** gh#224: at most this many receipt-owing tasks queue for one target; the next is refused (-32009). 0 disables. */
|
|
192
|
-
targetQueueCap: s.number().default(DEFAULT_TARGET_QUEUE_CAP),
|
|
193
|
-
stateColdRowsTtlMs: s.number().default(5_000),
|
|
194
|
-
cardCacheTtlMs: s.number().default(60_000),
|
|
195
|
-
cardCacheNegativeTtlMs: s.number().default(30_000),
|
|
196
|
-
remoteRowsTtlMs: s.number().default(15_000),
|
|
197
|
-
cardTtlMs: s.number().default(172_800_000),
|
|
198
|
-
flushTimeoutMs: s.number().default(300_000),
|
|
199
|
-
routeTimeoutMs: s.number().default(1_800_000),
|
|
200
|
-
taskStaleTtlMs: s.number().default(TASK_STALE_TTL_MS),
|
|
201
|
-
nativeTeamsInbound: s.boolean().default(false),
|
|
202
|
-
nativeRoundWaitMs: s.number().default(180_000),
|
|
203
|
-
});
|
|
84
|
+
import { Config } from "./config.js";
|
|
85
|
+
import { canvasOwnerFingerprint, splitCanvasOwner } from "./canvas-id.js";
|
|
86
|
+
import { forwardReceiptBasis } from "./direct-forward.js";
|
|
87
|
+
export { Config };
|
|
204
88
|
/**
|
|
205
89
|
* Register the outbound peer tools, the direct-route endpoint, and (when
|
|
206
90
|
* composed) the announced card and session-node control surface.
|
|
@@ -342,6 +226,11 @@ export function apply(ctx, config) {
|
|
|
342
226
|
const site = req.headers['sec-fetch-site'];
|
|
343
227
|
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none')
|
|
344
228
|
return false;
|
|
229
|
+
// A reverse proxy or tunnel on this host (Caddy, nginx, a tunnel agent) reaches us over loopback for EVERY
|
|
230
|
+
// remote client: a forwarding header says the real caller is elsewhere, so loopback trust does not apply —
|
|
231
|
+
// otherwise the workbench shell would hand the apiKey to anyone on the internet. Forging one only denies trust.
|
|
232
|
+
if (req.headers['x-forwarded-for'] !== undefined || req.headers.forwarded !== undefined || req.headers['x-real-ip'] !== undefined)
|
|
233
|
+
return false;
|
|
345
234
|
const remote = req.socket.remoteAddress;
|
|
346
235
|
return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
347
236
|
}
|
|
@@ -564,6 +453,8 @@ export function apply(ctx, config) {
|
|
|
564
453
|
...(delegate.publicKey !== '' ? { publicKey: delegate.publicKey } : {}),
|
|
565
454
|
}));
|
|
566
455
|
const nodePrivateKey = loadOrCreateNodeKey(join(home, 'a2a', 'node-key.pem'));
|
|
456
|
+
/** #282 S2: this host's canvas owner fingerprint (`<canvas>@<fp8>` names our own canvas). */
|
|
457
|
+
const ownCanvasFingerprint = canvasOwnerFingerprint(encodePublicKey(createPublicKey(nodePrivateKey)));
|
|
567
458
|
/** Our own card URL once the web server settles — the OriginClaim anchor. */
|
|
568
459
|
let ownCardUrl = '';
|
|
569
460
|
/**
|
|
@@ -652,20 +543,12 @@ export function apply(ctx, config) {
|
|
|
652
543
|
recentActivity.splice(0, recentActivity.length - ACTIVITY_CAP);
|
|
653
544
|
};
|
|
654
545
|
/**
|
|
655
|
-
* This host's primary LAN IPv4 (
|
|
546
|
+
* This host's primary LAN IPv4 (RFC1918 > other external IPv4 > CGNAT), computed once:
|
|
656
547
|
* a node fact the card and state route publish so peers and operators can
|
|
657
548
|
* tell machines apart in a same-team fleet. Empty when no external address
|
|
658
549
|
* exists (loopback-only deployments).
|
|
659
550
|
*/
|
|
660
|
-
const lanIp = (()
|
|
661
|
-
for (const entries of Object.values(networkInterfaces())) {
|
|
662
|
-
for (const entry of entries ?? []) {
|
|
663
|
-
if (entry.family === 'IPv4' && !entry.internal)
|
|
664
|
-
return entry.address;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
return '';
|
|
668
|
-
})();
|
|
551
|
+
const lanIp = selectLanIPv4(networkInterfaces());
|
|
669
552
|
// Session groups: user-named buckets for the panel's session listing,
|
|
670
553
|
// persisted beside the join intents so they survive restarts.
|
|
671
554
|
// Deprecated as of 0.6.6 — canvas teams are the grouping face now; this
|
|
@@ -1360,7 +1243,8 @@ export function apply(ctx, config) {
|
|
|
1360
1243
|
// side-effect-only.
|
|
1361
1244
|
ctx.on('agent/created', ({ agent }) => {
|
|
1362
1245
|
const agents = ctx.get('agents');
|
|
1363
|
-
|
|
1246
|
+
// #291: hosts without roots() (0.2.2-rc) resolve top-level via list()+delegation depth; never throws.
|
|
1247
|
+
if (agents === undefined || !liveRootAgents(agents).some(root => root.id === agent.id))
|
|
1364
1248
|
return undefined;
|
|
1365
1249
|
liveRoots.set(String(agent.id), agent);
|
|
1366
1250
|
if (joinedSessions.has(String(agent.id)))
|
|
@@ -1375,7 +1259,7 @@ export function apply(ctx, config) {
|
|
|
1375
1259
|
// the remount above (leave is the only intent remover).
|
|
1376
1260
|
sessionNodes.delete(String(agent.id));
|
|
1377
1261
|
});
|
|
1378
|
-
for (const agent of ctx.get('agents')
|
|
1262
|
+
for (const agent of liveRootAgents(ctx.get('agents'))) {
|
|
1379
1263
|
liveRoots.set(String(agent.id), agent);
|
|
1380
1264
|
if (joinedSessions.has(String(agent.id)))
|
|
1381
1265
|
mountSessionNode(agent);
|
|
@@ -1680,7 +1564,7 @@ export function apply(ctx, config) {
|
|
|
1680
1564
|
get unboundInboundCount() { return unboundInboundCount; },
|
|
1681
1565
|
get boundMissInboundCount() { return boundMissInboundCount; },
|
|
1682
1566
|
get boundMissRecent() { return boundMissRecent; },
|
|
1683
|
-
get ownCardUrl() { return ownCardUrl; },
|
|
1567
|
+
get ownCardUrl() { return ownCardUrl; }, ownCanvasFingerprint,
|
|
1684
1568
|
get ownerSeatNames() { return ownerSeatNames; },
|
|
1685
1569
|
set ownerSeatNames(v) { ownerSeatNames = v; },
|
|
1686
1570
|
get ownerAllowlist() { return ownerAllowlist; },
|
|
@@ -1852,7 +1736,7 @@ export function apply(ctx, config) {
|
|
|
1852
1736
|
return agents.requireInitiator();
|
|
1853
1737
|
}
|
|
1854
1738
|
catch {
|
|
1855
|
-
const roots = agents
|
|
1739
|
+
const roots = liveRootAgents(agents);
|
|
1856
1740
|
return roots.length > 0 ? roots[0] : undefined;
|
|
1857
1741
|
}
|
|
1858
1742
|
}
|
|
@@ -2189,8 +2073,20 @@ export function apply(ctx, config) {
|
|
|
2189
2073
|
*/
|
|
2190
2074
|
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2191
2075
|
return new Promise((resolve) => {
|
|
2076
|
+
const floorAtRegistration = sessionEventsOf(agent).length;
|
|
2192
2077
|
const waiter = registerFinalWaiter(agent, (text, placeholder) => {
|
|
2193
|
-
|
|
2078
|
+
// #298 (review of #305 F4): the sync wait may close on a busy target's OTHER turn. `ownTurn` states,
|
|
2079
|
+
// from this session's own events, that the steer carrying this task was read and that the reply is the
|
|
2080
|
+
// reading turn's own last text — the only reply a peer may declare final (`reply_final`).
|
|
2081
|
+
let ownTurn = false;
|
|
2082
|
+
if (placeholder !== true && taskId !== undefined && taskId !== '') {
|
|
2083
|
+
const events = sessionEventsOf(agent);
|
|
2084
|
+
const read = consumptionIndex(events, taskId, floorAtRegistration);
|
|
2085
|
+
// The receipt's own window (review of #305 F5): a back-to-back next turn's text is not this turn's.
|
|
2086
|
+
const window = read === undefined ? undefined : readingTurnReply(events, read);
|
|
2087
|
+
ownTurn = window !== undefined && window.completed && window.reply !== '' && window.reply === text;
|
|
2088
|
+
}
|
|
2089
|
+
resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}), ...(ownTurn ? { ownTurn: true } : {}) });
|
|
2194
2090
|
});
|
|
2195
2091
|
// The header names the sender first: `caller` is the routing node's
|
|
2196
2092
|
// own label (the session that issued the route), while `team` is this
|
|
@@ -2593,7 +2489,8 @@ export function apply(ctx, config) {
|
|
|
2593
2489
|
idempotencyStore,
|
|
2594
2490
|
verifyOriginClaim,
|
|
2595
2491
|
remoteCliProof,
|
|
2596
|
-
|
|
2492
|
+
forwardReceiptBasis: (taskId, caller, cliHandle, claim) => forwardReceiptBasis(taskLedger.find(taskId), caller, cliHandle, claim),
|
|
2493
|
+
forwardDirect: request => forwardDirect(request), ownCanvasFingerprint,
|
|
2597
2494
|
diagnoseCanvas: (team, reason, caller) => diagnoseCanvas(team, reason, caller),
|
|
2598
2495
|
callerLinkedToThisHost, get ownCardUrl() { return ownCardUrl; },
|
|
2599
2496
|
recordActivity,
|
|
@@ -2875,7 +2772,7 @@ export function apply(ctx, config) {
|
|
|
2875
2772
|
*/
|
|
2876
2773
|
function peerLinkState(peerUrl) {
|
|
2877
2774
|
return peerLinkDisplay(peerUrl, {
|
|
2878
|
-
card: cardCache.get(peerUrl)?.card, ownUrl: ownCardUrl, seeds: config.peers,
|
|
2775
|
+
card: cardCache.get(peerUrl)?.card, ownUrl: ownCardUrl, ownAudiences: config.originClaimAudiences, seeds: config.peers,
|
|
2879
2776
|
refs: canvasStore.list().flatMap(name => [...canvasStore.remoteMembersOf(name)]),
|
|
2880
2777
|
ownHandles: new Set([...joinedSessions.list(), ...liveRoots.keys()].map(id => `${config.team}/${id8(id)}`)),
|
|
2881
2778
|
cards: [...cardCache.values()].flatMap(entry => entry.card === undefined ? [] : [entry.card]),
|
|
@@ -3297,6 +3194,7 @@ export function apply(ctx, config) {
|
|
|
3297
3194
|
};
|
|
3298
3195
|
}
|
|
3299
3196
|
const remote = failures.filter(entry => !entry.startsWith('local: '));
|
|
3197
|
+
const queueFull = failures.some(entry => entry.startsWith('local: target-queue-full'));
|
|
3300
3198
|
const suffix = team.slice(team.indexOf('/') + 1);
|
|
3301
3199
|
const near = [...new Set([
|
|
3302
3200
|
...joinedSessions.list().map(id => `${config.team}/${id8(id)}`),
|
|
@@ -3313,9 +3211,10 @@ export function apply(ctx, config) {
|
|
|
3313
3211
|
`team "${team}" is not published by any configured peer${remote.length > 0 ? ` (unresolved delegations: ${remote.join('; ')})` : ''}`,
|
|
3314
3212
|
...failures.filter(entry => entry.startsWith('local: ')).map(entry => `local reason: ${entry.slice('local: '.length)}`),
|
|
3315
3213
|
near.length > 0 ? `nearby advertised teams: ${near.join(', ')}` : undefined,
|
|
3316
|
-
|
|
3214
|
+
// #282 S1: a full local queue means the target exists and is busy — waiting is the only right next step.
|
|
3215
|
+
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',
|
|
3317
3216
|
].filter((line) => line !== undefined);
|
|
3318
|
-
return { ok: false, error: lines.join(' — '), code: -32004 };
|
|
3217
|
+
return queueFull ? { ok: false, error: lines.join(' — '), code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT } : { ok: false, error: lines.join(' — '), code: -32004 };
|
|
3319
3218
|
};
|
|
3320
3219
|
actTools.registerRoute();
|
|
3321
3220
|
// #91 file channel F2: a2a_file_send / a2a_file_fetch (src/tools/files.ts).
|
|
@@ -3358,9 +3257,9 @@ export function apply(ctx, config) {
|
|
|
3358
3257
|
for (const owner of owners.filter(row => row.team === team && !row.local)) {
|
|
3359
3258
|
if (!owner.members.includes(caller))
|
|
3360
3259
|
missing.push(owner.owner + ': caller is not an advertised canvas member');
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3260
|
+
// #282 S3: no per-member link here; the owner's signed canvas is the return path.
|
|
3261
|
+
if (owner.members.every(member => member === caller))
|
|
3262
|
+
missing.push(owner.owner + ': the canvas lists no receiving member besides the caller');
|
|
3364
3263
|
}
|
|
3365
3264
|
}
|
|
3366
3265
|
return canvasFailure(team, owners, reason) + (missing.length === 0 ? '' : ' — missing prerequisites: ' + missing.join('; '));
|
|
@@ -3368,7 +3267,6 @@ export function apply(ctx, config) {
|
|
|
3368
3267
|
const forwardDirect = createDirectForward({
|
|
3369
3268
|
births: forwardingBirths,
|
|
3370
3269
|
enabled: () => config.forwardDirect === true, isolated: () => config.isolated, deposited: handle => cliDeposits.get(handle) !== undefined,
|
|
3371
|
-
memberLinked: handle => canvasStore.list().some(name => canvasStore.remoteMembersOf(name).includes(handle)),
|
|
3372
3270
|
scope: (handle, team) => config.teamScopeRouting ? outboundScope(senderScopeRecords, handle, handle, team) : Promise.resolve({ ok: true }),
|
|
3373
3271
|
candidates: async (team, failures, caller) => { const fetch = memoizedCardFetch(); return { urls: await directoryPeerCandidates(fetch, team, failures, { id: caller, handle: caller }), card: fetch }; },
|
|
3374
3272
|
dispatch: (url, request, guard) => dispatchPeerCandidate(url, request.team, request.message, request.contextId, new AbortController().signal, request.caller, request.noWait, request.taskId, request.caller, guard),
|
|
@@ -3400,6 +3298,12 @@ export function apply(ctx, config) {
|
|
|
3400
3298
|
// node team when it has joined, else the node label), so the receiver
|
|
3401
3299
|
// can answer with one a2a_route instead of an unroutable display label.
|
|
3402
3300
|
a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
|
|
3301
|
+
// #282 S2: an owner-qualified canvas id. Our own fingerprint is our canvas; a foreign one never
|
|
3302
|
+
// lands on a same-named local canvas, and the directory walk keeps only that owner.
|
|
3303
|
+
const ownerQualified = splitCanvasOwner(String(args.team ?? '').trim());
|
|
3304
|
+
const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
|
|
3305
|
+
if (ownerQualified.owner !== undefined)
|
|
3306
|
+
args = { ...args, team: ownerQualified.team };
|
|
3403
3307
|
// A target-authored receipt attempt answers its own work even if scope/transport later refuses delivery.
|
|
3404
3308
|
const answer = parseReceipt(args.message);
|
|
3405
3309
|
if (agent !== undefined && answer !== null)
|
|
@@ -3472,7 +3376,7 @@ export function apply(ctx, config) {
|
|
|
3472
3376
|
const failures = [];
|
|
3473
3377
|
if (signal?.aborted)
|
|
3474
3378
|
return localRoute({ ok: false, error: 'route aborted before dispatch', code: -32000 });
|
|
3475
|
-
const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, signal, args.async === true, taskId, callbackAddress, agent === undefined ? undefined : String(agent.id));
|
|
3379
|
+
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));
|
|
3476
3380
|
const localBirth = localBirths.get(taskId);
|
|
3477
3381
|
localBirths.delete(taskId);
|
|
3478
3382
|
if (outcome !== undefined) {
|
|
@@ -3493,7 +3397,7 @@ export function apply(ctx, config) {
|
|
|
3493
3397
|
// union in member order. Non-canvas teams keep the single-walk shape
|
|
3494
3398
|
// unchanged. The ledger stays keyed to the canvas team the caller
|
|
3495
3399
|
// named; the peer request carries the member handle it must resolve.
|
|
3496
|
-
const canvasName = parseCanvasTeamName(args.team);
|
|
3400
|
+
const canvasName = foreignOwner !== undefined ? undefined : parseCanvasTeamName(args.team);
|
|
3497
3401
|
// gh#131: one of this host's own handles is a local member, never a directory target.
|
|
3498
3402
|
const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
|
|
3499
3403
|
const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
|
|
@@ -3509,7 +3413,7 @@ export function apply(ctx, config) {
|
|
|
3509
3413
|
}
|
|
3510
3414
|
const candidatePool = [];
|
|
3511
3415
|
for (const target of targets) {
|
|
3512
|
-
for (const candidate of await directoryPeerCandidates(fetch, target, failures, scopedCanvasCaller)) {
|
|
3416
|
+
for (const candidate of await directoryPeerCandidates(fetch, ownerQualified.owner !== undefined && target === args.team ? `${target}@${ownerQualified.owner}` : target, failures, scopedCanvasCaller)) {
|
|
3513
3417
|
candidatePool.push({ candidate, target });
|
|
3514
3418
|
}
|
|
3515
3419
|
}
|
|
@@ -3593,7 +3497,7 @@ export function apply(ctx, config) {
|
|
|
3593
3497
|
const queueRefusal = queueFullRefusal(taskLedger.list(), team, message, config.targetQueueCap, cliDeposits.get(team) !== undefined, ownTask);
|
|
3594
3498
|
if (queueRefusal !== undefined) {
|
|
3595
3499
|
recordActivity('out', team, callerSession ?? 'local', false);
|
|
3596
|
-
return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL };
|
|
3500
|
+
return { ok: false, error: queueRefusal, code: WIRE_ERROR_QUEUE_FULL, next: QUEUE_FULL_NEXT };
|
|
3597
3501
|
}
|
|
3598
3502
|
const canvasName = parseCanvasTeamName(team);
|
|
3599
3503
|
// Issue #41 shape note: the caller label is the WIRE form (<zone>/<id8>
|
|
@@ -3978,14 +3882,18 @@ export function apply(ctx, config) {
|
|
|
3978
3882
|
// (gh#8: a squatter two gossip hops away captured routes by naming
|
|
3979
3883
|
// itself after the target). referralTeamResolution restores the legacy
|
|
3980
3884
|
// whole-store basis for fleets mid-migration.
|
|
3885
|
+
// #282 S2: `<canvas>@<fp8>` selects only the owner whose signed card key matches; no delegation walk.
|
|
3886
|
+
const qualified = splitCanvasOwner(team);
|
|
3887
|
+
team = qualified.team;
|
|
3888
|
+
const ownerOk = (card) => qualified.owner === undefined || canvasOwnerFingerprint(card?.publicKey) === qualified.owner;
|
|
3981
3889
|
const peers = resolutionPeers();
|
|
3982
3890
|
const candidates = [];
|
|
3983
|
-
const cards = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
|
|
3891
|
+
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 });
|
|
3984
3892
|
// Check after fetching all cards: a seeded peer publishing the same id8
|
|
3985
3893
|
// makes the caller handle ambiguous, so do not send a canvas payload.
|
|
3986
3894
|
const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
|
|
3987
3895
|
const resolveLocal = sessions === undefined ? undefined : (ref) => cliDeposits.get(ref) !== undefined ? ref : sessions(ref);
|
|
3988
|
-
const resolutions = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
|
|
3896
|
+
const resolutions = qualified.owner !== undefined ? [] : await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, outcome: await resolveZone(fetch, peer, team) }));
|
|
3989
3897
|
if (canvasTargetAmbiguous(cards.map(entry => entry.card), team, scopedCanvasCaller, resolveLocal, resolutions.some(entry => entry.outcome.ok))) {
|
|
3990
3898
|
failures.push(`ambiguous: team "${team}" has conflicting owners, identities, or a colliding caller; no payload sent`);
|
|
3991
3899
|
return [];
|
|
@@ -4074,7 +3982,7 @@ export function apply(ctx, config) {
|
|
|
4074
3982
|
const card = await fetch(candidate);
|
|
4075
3983
|
peerAsync = card?.capabilities?.async === true;
|
|
4076
3984
|
}
|
|
4077
|
-
const result = await dispatchPeerCandidate(candidate, request.handle, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
|
|
3985
|
+
const result = await dispatchPeerCandidate(candidate, splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
|
|
4078
3986
|
if (!result.ok) {
|
|
4079
3987
|
// Idempotency verdicts (B3) are terminal, not failover: a replay
|
|
4080
3988
|
// says the prior attempt at THIS peer stays authoritative —
|
|
@@ -4107,7 +4015,7 @@ export function apply(ctx, config) {
|
|
|
4107
4015
|
break;
|
|
4108
4016
|
continue;
|
|
4109
4017
|
}
|
|
4110
|
-
trackOwedTask(taskId, request.handle, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
|
|
4018
|
+
trackOwedTask(taskId, splitCanvasOwner(request.handle).team, candidate, result, callbackAddress, briefOf(request.message), sender, await peerBirth(fetch, candidate, undefined));
|
|
4111
4019
|
const contextId = request.contextId ?? (result.context_id !== '' ? result.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`);
|
|
4112
4020
|
if (result.task_status === 'TASK_STATE_COMPLETED') {
|
|
4113
4021
|
return { kind: 'completed', text: result.reply, taskId, contextId };
|
|
@@ -4260,24 +4168,14 @@ export function apply(ctx, config) {
|
|
|
4260
4168
|
entry.scannedTo = events.length;
|
|
4261
4169
|
continue;
|
|
4262
4170
|
}
|
|
4263
|
-
|
|
4264
|
-
//
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
let completed = true;
|
|
4268
|
-
if (turnMarked) {
|
|
4269
|
-
const close = turnEndIndex(events, entry.consumedAt + 1);
|
|
4270
|
-
if (close === undefined) {
|
|
4271
|
-
// The consuming turn is still open: wait for its turn/end.
|
|
4272
|
-
continue;
|
|
4273
|
-
}
|
|
4274
|
-
end = close;
|
|
4275
|
-
completed = events[close]?.data?.reason?.kind === 'completed';
|
|
4276
|
-
}
|
|
4171
|
+
const window = readingTurnReply(events, entry.consumedAt, turnMarked);
|
|
4172
|
+
// The consuming turn is still open: wait for its turn/end.
|
|
4173
|
+
if (window === undefined)
|
|
4174
|
+
continue;
|
|
4277
4175
|
// The waiter closes HERE with that turn's last text. A turn that said
|
|
4278
4176
|
// nothing, or ended aborted/failed/blocked, did no work this host can
|
|
4279
4177
|
// vouch for: close as a placeholder — no receipt, the row stays owed.
|
|
4280
|
-
const reply
|
|
4178
|
+
const { reply, completed } = window;
|
|
4281
4179
|
answered.push(reply !== '' && completed ? { entry, reply } : { entry, reply: '', placeholder: true });
|
|
4282
4180
|
continue;
|
|
4283
4181
|
}
|
|
@@ -4300,6 +4198,20 @@ export function apply(ctx, config) {
|
|
|
4300
4198
|
entry.answer(reply);
|
|
4301
4199
|
}
|
|
4302
4200
|
}
|
|
4201
|
+
/**
|
|
4202
|
+
* #114 S5: the consuming turn's window — from the read at `read` up to its own `turn/end`, never into a
|
|
4203
|
+
* following turn (back-to-back turns can run with no idle between); without turn markers, up to now (the
|
|
4204
|
+
* idle is the turn's end). Undefined while a marked turn is still open. The receipt waiter and the sync
|
|
4205
|
+
* answer's `ownTurn` (#298) both read this one window.
|
|
4206
|
+
*/
|
|
4207
|
+
function readingTurnReply(events, read, turnMarked = events.some(event => event?.type === 'turn/start' || event?.type === 'turn/end')) {
|
|
4208
|
+
if (!turnMarked)
|
|
4209
|
+
return { reply: latestAssistantText(events, read + 1), completed: true };
|
|
4210
|
+
const close = turnEndIndex(events, read + 1);
|
|
4211
|
+
if (close === undefined)
|
|
4212
|
+
return undefined;
|
|
4213
|
+
return { reply: latestAssistantText(events, read + 1, close), completed: events[close]?.data?.reason?.kind === 'completed' };
|
|
4214
|
+
}
|
|
4303
4215
|
/** #114 S5: the index of the first `turn/end` at or after `from`, or undefined while the turn is open. */
|
|
4304
4216
|
function turnEndIndex(events, from) {
|
|
4305
4217
|
for (let index = from; index < events.length; index++) {
|
package/lib/lan-ip.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** #311: prefer LAN addresses over overlay CGNAT without depending on adapter names. */
|
|
2
|
+
import { isIPv4 } from 'node:net';
|
|
3
|
+
/** RFC1918 > other external IPv4 > CGNAT (100.64/10); ties retain enumeration order. */
|
|
4
|
+
export function selectLanIPv4(interfaces) {
|
|
5
|
+
let selected = '', best = Infinity;
|
|
6
|
+
for (const entries of Object.values(interfaces)) {
|
|
7
|
+
for (const entry of entries ?? []) {
|
|
8
|
+
if (entry.family !== 'IPv4' || entry.internal || !isIPv4(entry.address))
|
|
9
|
+
continue;
|
|
10
|
+
const [a, b] = entry.address.split('.').map(Number);
|
|
11
|
+
const rank = a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
|
12
|
+
? 0 : a === 100 && b >= 64 && b <= 127 ? 2 : 1;
|
|
13
|
+
if (rank < best) {
|
|
14
|
+
selected = entry.address;
|
|
15
|
+
best = rank;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return selected;
|
|
20
|
+
}
|
|
@@ -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
|
@@ -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-link-display.js
CHANGED
|
@@ -8,8 +8,9 @@ export function peerLinkDisplay(peerUrl, d) {
|
|
|
8
8
|
catch {
|
|
9
9
|
return '';
|
|
10
10
|
} };
|
|
11
|
-
const peerOrigin = origin(peerUrl)
|
|
12
|
-
const
|
|
11
|
+
const peerOrigin = origin(peerUrl);
|
|
12
|
+
const ownOrigins = new Set([d.ownUrl, ...d.ownAudiences ?? []].map(origin).filter(value => value !== ''));
|
|
13
|
+
const bilateral = peerOrigin !== '' && d.seeds.some(url => origin(url) === peerOrigin) && (card.peers ?? []).some(url => ownOrigins.has(origin(url)));
|
|
13
14
|
const exactMember = d.refs.some(ref => !d.ownHandles.has(ref) && (card.sessionTeams ?? []).some(row => row.team === ref) && !d.cards.some(other => other.publicKey !== card.publicKey && (other.sessionTeams ?? []).some(row => row.team === ref)));
|
|
14
15
|
return { link: bilateral || exactMember ? 'linked' : 'stranger', verified: true };
|
|
15
16
|
}
|
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;
|