@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.
Files changed (52) hide show
  1. package/README.md +340 -335
  2. package/README_zh.md +241 -236
  3. package/assets/nexusDist/assets/index-C7HagaGy.css +1 -1
  4. package/assets/nexusDist/assets/index-NkNSeIx6.js +9 -9
  5. package/cli/a2a.mjs +44 -6
  6. package/cli/serve.mjs +132 -0
  7. package/cordis.patch.yml +21 -21
  8. package/lib/canvas-discovery.js +29 -0
  9. package/lib/canvas-id.js +27 -0
  10. package/lib/canvas-store.js +7 -1
  11. package/lib/client.js +63 -63
  12. package/lib/config.js +125 -0
  13. package/lib/direct-forward.js +105 -37
  14. package/lib/faces/admin.js +14 -1
  15. package/lib/faces/wire.js +41 -10
  16. package/lib/flood-guard.js +3 -1
  17. package/lib/index.js +116 -155
  18. package/lib/live-roots.js +39 -0
  19. package/lib/origin-claim.js +4 -3
  20. package/lib/peer-directory.js +1 -1
  21. package/lib/queue-guard.js +3 -1
  22. package/lib/skill/references/cli.md +214 -199
  23. package/lib/skill/references/pitfalls.md +1 -1
  24. package/lib/skill/references/routing.md +4 -0
  25. package/lib/skill/references/setup.md +5 -9
  26. package/lib/task-auth.js +15 -11
  27. package/lib/task-ledger-rows.js +32 -4
  28. package/lib/task-ledger.js +11 -5
  29. package/lib/tools/info.js +12 -5
  30. package/lib/types/canvas-discovery.d.ts +20 -0
  31. package/lib/types/canvas-id.d.ts +9 -0
  32. package/lib/types/config.d.ts +278 -0
  33. package/lib/types/direct-forward.d.ts +33 -2
  34. package/lib/types/faces/admin.d.ts +12 -0
  35. package/lib/types/faces/wire.d.ts +10 -1
  36. package/lib/types/index.d.ts +3 -277
  37. package/lib/types/live-roots.d.ts +35 -0
  38. package/lib/types/origin-claim.d.ts +5 -1
  39. package/lib/types/queue-guard.d.ts +2 -0
  40. package/lib/types/task-auth.d.ts +5 -0
  41. package/lib/types/task-ledger-rows.d.ts +16 -5
  42. package/lib/types/task-ledger.d.ts +3 -0
  43. package/lib/types/tools/info.d.ts +3 -0
  44. package/lib/types/types.d.ts +2 -0
  45. package/nexus-stage/package-lock.json +2262 -2262
  46. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  47. package/nexus-stage/src/edges.ts +72 -72
  48. package/nexus-stage/src/layout-wire.ts +158 -158
  49. package/nexus-stage/src/seat.ts +35 -35
  50. package/nexus-stage/src/stage-name.ts +13 -13
  51. package/nexus-stage/src/tokens.ts +31 -31
  52. 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, TASK_STALE_TTL_MS, SUMMARY_CAP, briefOf } from "./task-ledger.js";
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, DEFAULT_TARGET_QUEUE_CAP, queueFullRefusal, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
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
- /** Schemastery configuration for the A2A client plugin row. */
83
- export const Config = s.object({
84
- apiKey: s.string().default(''),
85
- session: s.string().default(''),
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
- if (agents === undefined || !agents.roots().some(root => root.id === agent.id))
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')?.roots() ?? []) {
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 ownCardUrl() { return ownCardUrl; },
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.roots();
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: () => { boundMissInboundCount += 1; },
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
- forwardDirect: request => forwardDirect(request),
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
- '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',
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
- return routeVerdict(args.team, failures, candidatePool.length);
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
+ }
@@ -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
- return { ok: true, caller, callerUrl, card, bound };
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
  }
@@ -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
  }
@@ -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;