@nelsonlongxiang/dsh-open-a2a-net 0.6.42 → 0.7.0

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 (79) hide show
  1. package/README.md +1 -0
  2. package/README_zh.md +1 -0
  3. package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/cli/a2a.mjs +43 -7
  6. package/lib/a2a-client.js +10 -2
  7. package/lib/admitted-bookkeeping.js +7 -0
  8. package/lib/canvas-discovery.js +11 -1
  9. package/lib/canvas-member.js +43 -0
  10. package/lib/canvas-store.js +315 -17
  11. package/lib/client.js +62 -72
  12. package/lib/client.js.map +1 -1
  13. package/lib/config.js +1 -0
  14. package/lib/direct-forward.js +9 -7
  15. package/lib/faces/admin.js +150 -67
  16. package/lib/faces/files.js +2 -1
  17. package/lib/faces/wire.js +569 -469
  18. package/lib/followup-delivery.js +89 -0
  19. package/lib/idempotency-store.js +8 -0
  20. package/lib/inbound-verification.js +23 -0
  21. package/lib/index.js +534 -166
  22. package/lib/nudge-observability.js +34 -0
  23. package/lib/origin-claim.js +36 -9
  24. package/lib/outbound-scope.js +4 -2
  25. package/lib/peer-directory.js +15 -5
  26. package/lib/peer-store.js +31 -17
  27. package/lib/queue-guard.js +4 -3
  28. package/lib/receipt-ladder.js +1 -1
  29. package/lib/receipt-retry.js +58 -0
  30. package/lib/reconciliation-reminders.js +148 -0
  31. package/lib/reconciliation-state.js +27 -0
  32. package/lib/session-transcript.js +46 -18
  33. package/lib/skill/SKILL.md +5 -0
  34. package/lib/skill/references/cli.md +4 -1
  35. package/lib/skill/references/routing.md +31 -1
  36. package/lib/task-auth.js +3 -3
  37. package/lib/task-delivery.js +23 -0
  38. package/lib/task-ledger-persistence.js +51 -0
  39. package/lib/task-ledger-rows.js +5 -0
  40. package/lib/task-ledger.js +219 -20
  41. package/lib/tools/info.js +37 -7
  42. package/lib/types/a2a-client.d.ts +1 -1
  43. package/lib/types/admitted-bookkeeping.d.ts +5 -0
  44. package/lib/types/canvas-member.d.ts +10 -0
  45. package/lib/types/canvas-store.d.ts +56 -1
  46. package/lib/types/config.d.ts +2 -0
  47. package/lib/types/faces/admin.d.ts +21 -0
  48. package/lib/types/faces/files.d.ts +1 -1
  49. package/lib/types/faces/wire.d.ts +21 -14
  50. package/lib/types/followup-delivery.d.ts +25 -0
  51. package/lib/types/idempotency-store.d.ts +2 -0
  52. package/lib/types/inbound-verification.d.ts +14 -0
  53. package/lib/types/index.d.ts +5 -0
  54. package/lib/types/nudge-observability.d.ts +17 -0
  55. package/lib/types/origin-claim.d.ts +7 -1
  56. package/lib/types/outbound-scope.d.ts +1 -0
  57. package/lib/types/peer-store.d.ts +4 -0
  58. package/lib/types/queue-guard.d.ts +9 -2
  59. package/lib/types/receipt-ladder.d.ts +2 -0
  60. package/lib/types/receipt-retry.d.ts +22 -0
  61. package/lib/types/reconciliation-reminders.d.ts +41 -0
  62. package/lib/types/reconciliation-state.d.ts +32 -0
  63. package/lib/types/session-transcript.d.ts +6 -1
  64. package/lib/types/task-auth.d.ts +7 -0
  65. package/lib/types/task-delivery.d.ts +17 -0
  66. package/lib/types/task-ledger-persistence.d.ts +3 -0
  67. package/lib/types/task-ledger-rows.d.ts +19 -2
  68. package/lib/types/task-ledger.d.ts +48 -1
  69. package/lib/types/tools/info.d.ts +3 -0
  70. package/lib/types/types.d.ts +3 -0
  71. package/nexus-stage/src/federation.ts +1 -0
  72. package/nexus-stage/src/main.ts +17 -2
  73. package/nexus-stage/src/member-provenance-picker.ts +13 -0
  74. package/nexus-stage/src/planning-member-handles.ts +4 -1
  75. package/nexus-stage/src/planning-view.ts +3 -3
  76. package/nexus-stage/src/workbench.ts +51 -7
  77. package/nexus-stage/src/world.ts +1 -0
  78. package/package.json +3 -2
  79. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
package/lib/faces/wire.js CHANGED
@@ -7,7 +7,9 @@ import { peerPayloadFingerprint, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REP
7
7
  import { briefOf } from "../task-ledger.js";
8
8
  import { parseReceipt, taskIdRefusal } from "../receipt.js";
9
9
  import { buildAuthorization, claimCovers, nextOf, refusalOf } from "../task-auth.js";
10
+ import { AdmittedBookkeepingError, ADMITTED_BOOKKEEPING_NOTE } from "../admitted-bookkeeping.js";
10
11
  import { isRootOriginUrl } from "../origin-claim.js";
12
+ import { hasOriginClaimFields, completeInboundVerification, verifiedInboundAdmission, WIRE_ERROR_INBOUND_VERIFICATION } from "../inbound-verification.js";
11
13
  import { withResponseFields } from "../response-fields.js";
12
14
  import { WIRE_ERROR_PAYLOAD_TOO_LARGE, withinRouteBodyCap } from "../transport-caps.js";
13
15
  /**
@@ -92,7 +94,7 @@ export class SyncAnswered {
92
94
  }
93
95
  }
94
96
  export function registerWireFaces(webServer, d) {
95
- const { ctx, logger, config, cliDeposits, canvasStore, canvasNameReserved, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, remoteCliProof, callerLinkedToThisHost, recordActivity, peerKeyAdmits, a2aCallerRefusal, a2aCallerAdmission, allowlistVersionNow, observeAuthorization, scanSessionLogList, readSessionLogTail, taskLedger, refuseIfFlooded, refuseIfQueueFull, settleAndAnnounce, waitForForwardBirth, session, resolveAgentForTeam, parseCanvasTeamName, canvasLiveAgent, wakeColdTeam, probeConsumption, sessionTeamOf, localSessionOfHandle, steerRelay, fromClause, sanitizeFromClause, armReceiptAutosend, eventCountOf, receiptExpectationOf, armAsyncNudge, liveAgent, callerIsAgent, selfRouteText, noWakeFaceText, archivedColdMatchText, noColdMatchText, nativeTeamsPrepare, nativeTeamsRound, routeIntoAgent, } = d;
97
+ const { ctx, logger, config, cliDeposits, canvasStore, canvasNameReserved, sessionNodes, joinedSessions, idempotencyStore, verifyOriginClaim, remoteCliProof, callerLinkedToThisHost, recordActivity, peerKeyAdmits, a2aCallerRefusal, a2aCallerAdmission, allowlistVersionNow, observeAuthorization, scanSessionLogList, readSessionLogTail, taskLedger, refuseIfFlooded, refuseIfQueueFull, settleAndAnnounce, waitForForwardBirth, session, resolveAgentForTeam, parseCanvasTeamName, canvasLiveAgent, wakeColdTeam, probeConsumption, sessionTeamOf, localSessionOfHandle, steerRelay, followupRelay, cancelQueuedTask, fromClause, sanitizeFromClause, armReceiptAutosend, eventCountOf, receiptExpectationOf, armAsyncNudge, liveAgent, callerIsAgent, selfRouteText, noWakeFaceText, archivedColdMatchText, noColdMatchText, nativeTeamsPrepare, nativeTeamsRound, routeIntoAgent, } = d;
96
98
  const syncAnswered = new SyncAnswered();
97
99
  // gh#45 CLI 户口卡面 (peer face, unsigned card serve): the deposited
98
100
  // CLI's SIGNED card is served verbatim under its own base URL — an
@@ -182,7 +184,9 @@ export function registerWireFaces(webServer, d) {
182
184
  const name = typeof body.name === 'string' ? body.name : '';
183
185
  const rawId = typeof body.id === 'string' ? body.id : '';
184
186
  // gh#131: a peer names our sessions by handle — the session it names, for removal.
185
- const id = (isRemoteMemberRef(rawId) ? localSessionOfHandle(rawId) : undefined) ?? rawId;
187
+ // A bare handle may still infer for routing, never for deleting another stored slot.
188
+ const provenOwn = rawId.endsWith('@' + d.ownCanvasFingerprint);
189
+ const id = (provenOwn ? localSessionOfHandle(rawId) : undefined) ?? rawId;
186
190
  let payload;
187
191
  if (op === 'create') {
188
192
  const stored = canvasNameReserved(name) ? undefined : canvasStore.create(name);
@@ -201,8 +205,13 @@ export function registerWireFaces(webServer, d) {
201
205
  if (body.home !== undefined)
202
206
  payload = { ok: false, error: 'CLI home pins may only be set by this host local control route' };
203
207
  else {
204
- canvasStore.create(name);
205
- payload = { ok: canvasStore.addMember(name, rawId), members: canvasStore.membersOf(name) };
208
+ const proof = await d.canvasMemberForWrite(rawId, undefined, claim.card);
209
+ if (!proof.ok)
210
+ payload = proof;
211
+ else {
212
+ canvasStore.create(name);
213
+ payload = { ok: canvasStore.addMember(name, proof.member, undefined, { ownerKey: proof.ownerKey }), members: canvasStore.membersOf(name) };
214
+ }
206
215
  }
207
216
  }
208
217
  }
@@ -435,503 +444,594 @@ export function registerWireFaces(webServer, d) {
435
444
  res.end();
436
445
  }
437
446
  });
438
- req.on('end', async () => {
439
- if (overflowed) {
440
- if (!res.writableEnded && !res.headersSent) {
441
- const payload = JSON.stringify({ error: 'payload too large', code: WIRE_ERROR_PAYLOAD_TOO_LARGE });
442
- res.writeHead(413, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
447
+ req.on('end', () => {
448
+ void (async () => {
449
+ if (overflowed) {
450
+ if (!res.writableEnded && !res.headersSent) {
451
+ const payload = JSON.stringify({ error: 'payload too large', code: WIRE_ERROR_PAYLOAD_TOO_LARGE });
452
+ res.writeHead(413, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
453
+ res.end(payload);
454
+ }
455
+ return;
456
+ }
457
+ const rawBody = Buffer.concat(chunks);
458
+ let body;
459
+ try {
460
+ body = JSON.parse(rawBody.toString('utf8'));
461
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
462
+ throw new TypeError('direct body must be a JSON object'); // #168: `null` read body.team in this async 'end' callback → unhandled rejection → host exit
463
+ }
464
+ catch {
465
+ const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
466
+ res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
443
467
  res.end(payload);
468
+ return;
444
469
  }
445
- return;
446
- }
447
- const rawBody = Buffer.concat(chunks);
448
- let body;
449
- try {
450
- body = JSON.parse(rawBody.toString('utf8'));
451
- if (body === null || typeof body !== 'object' || Array.isArray(body))
452
- throw new TypeError('direct body must be a JSON object'); // #168: `null` read body.team in this async 'end' callback → unhandled rejection → host exit
453
- }
454
- catch {
455
- const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
456
- res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
457
- res.end(payload);
458
- return;
459
- }
460
- // V2 (gh#7, S1): the delivery face honors the apiKey gate exactly
461
- // like the control face — a configured key without this header
462
- // used to let anyone reachable steer sessions while the control
463
- // routes demanded the key, making it decorative for delivery.
464
- // Empty-key deployments keep the documented open posture.
465
- // #317: a link key admits only its own verified, body-bound peer.
466
- if (!await peerKeyAdmits(req, rawBody)) {
467
- const payload = JSON.stringify({ error: 'unauthorized: X-API-Key required on the delivery face (apiKey is configured)', code: -32000 });
468
- res.writeHead(401, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
469
- res.end(payload);
470
- return;
471
- }
472
- // #282 S2: our own `<canvas>@<fp8>` is our canvas; a foreign suffix stays for the forward walk.
473
- const ownerQualified = splitCanvasOwner(typeof body.team === 'string' ? body.team : '');
474
- const team = ownerQualified.owner !== undefined && ownerQualified.owner === d.ownCanvasFingerprint ? ownerQualified.team : typeof body.team === 'string' ? body.team : '';
475
- const message = typeof body.message === 'string' ? body.message : '';
476
- const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
477
- // P2 receipt-callback: where THIS node's receipt should route on
478
- // the caller's side — the caller knows its routable address best
479
- // (a joined session's node team, e.g. via the transport face's
480
- // callbackTarget mapping). Bounded; absent → the caller label
481
- // plays its usual role.
482
- const callback = typeof body.callback === 'string' && body.callback !== '' && body.callback.length <= 128 ? body.callback : '';
483
- const noWait = body.wait === false;
484
- const contextId = typeof body.context_id === 'string' && body.context_id !== '' ? body.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`;
485
- // The caller-born task id (idempotency key): echo it when present
486
- // so the receipt header correlates with the caller's own result;
487
- // pre-0.5.3 callers carry none and this node mints one.
488
- const taskId = typeof body.task_id === 'string' && body.task_id !== '' ? body.task_id : `direct-${Math.random().toString(16).slice(2, 10)}`;
489
- if (team === '' || message === '') {
490
- const payload = JSON.stringify({ error: 'team and message are required', code: -32000 });
491
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
492
- res.end(payload);
493
- return;
494
- }
495
- // #114 S5: a caller-born id must be header-safe — refused before
496
- // admission, claim or steer, so nothing is booked under it.
497
- const idRefusal = taskIdRefusal(taskId);
498
- if (idRefusal !== undefined) {
499
- const payload = JSON.stringify({ error: idRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
500
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
501
- res.end(payload);
502
- return;
503
- }
504
- // Phase-3 admission (owner ruling 2026-09-01, decision-seat
505
- // filed): an inbound direct delivery must show a SHARED team.
506
- // Local-zone callers are judged synchronously and exactly — the
507
- // declaration lives in this host's own roster store, so the
508
- // Anonymous-delivery refusal (S3 phase-3b, test-seat finding
509
- // direct-001649ab): a delivery with NO caller_session field
510
- // skips the admission gate entirely (nothing to judge) — that
511
- // anonymous hole let any local process or reachable peer inject
512
- // into a live session. The protocol contract is caller_session;
513
- // its absence is now refused instead of silently waved through.
514
- // (0.6.15: the rule now lives in a2aCallerRefusal so the
515
- // read-only /a2a/log face admits callers by exactly the same
516
- // standard as message delivery.)
517
- // F1: only a FOREIGN CLI handle needs a bound host claim and live home card
518
- // before admission. Every other caller keeps the existing admission-first order.
519
- const foreignCli = config.teamScopeRouting && CLI_HANDLE_RE.test(caller) && cliDeposits.get(caller) === undefined;
520
- const earlyClaim = foreignCli ? await verifyOriginClaim(req, { cli: true, rawBody }) : undefined;
521
- const cliProof = foreignCli && earlyClaim?.ok ? await remoteCliProof(caller, earlyClaim).catch(() => undefined) : undefined;
522
- let admission = a2aCallerAdmission(caller, team, cliProof);
523
- // #282 S3 (same team authorizes): a receipt for this host's own forward, to the CLI that sent it, from a
524
- // receiver frozen at birth on the selected peer's key — the owner's signed canvas is the return path.
525
- const echoed = !admission.ok && cliDeposits.get(team) !== undefined ? parseReceipt(message) : null;
526
- if (echoed !== null) {
527
- // Verify first, so an unsigned or Sig2-only caller cannot hold the connection on an in-flight forward (#287 review P3);
528
- // then a body-bound receipt may beat the dispatch's HTTP answer: wait for this forward's row to be born (#287 P2).
529
- const receiptClaim = earlyClaim ?? await verifyOriginClaim(req, { cli: true, rawBody });
530
- if (receiptClaim.ok && receiptClaim.bodyBound === true)
531
- await waitForForwardBirth(message);
532
- const basis = receiptClaim.ok ? d.forwardReceiptBasis(echoed.taskId, caller, team, receiptClaim) : undefined;
533
- if (basis !== undefined)
534
- admission = { ok: true, via: 'peer-org', rule: 'peer-org', basis: [basis] };
535
- }
536
- const callerRefusal = refusalOf(admission);
537
- if (callerRefusal !== undefined || !admission.ok) {
538
- recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
539
- const next = nextOf(admission);
540
- const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED', ...(next !== undefined ? { next } : {}) });
541
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
542
- res.end(payload);
543
- return;
544
- }
545
- // #114 S6: a version an inbound record is born under is always in the history.
546
- observeAuthorization('lazy');
547
- // #114 S2: the version is read at admission time, beside the decision it explains.
548
- const admittedAt = { version: allowlistVersionNow(), at: Date.now() };
549
- // gh#9+gh#10 OriginClaim on the delivery face (M1): an envelope
550
- // that is present but unverifiable rejects untrusted-origin; an
551
- // ABSENT envelope during the window walks on with a counted
552
- // legacyInbound warn (the operator watches the tail drain), and
553
- // after the window closes it rejects like a failed signature —
554
- // N3 proves a NAME's linkability, never key possession.
555
- // gh#133: the delivery face is where a deposited CLI identity signs its routes.
556
- const claim = earlyClaim ?? await verifyOriginClaim(req, { cli: true, rawBody });
557
- if (!claim.ok) {
558
- if (claim.reason !== 'absent' || config.originClaimWindow === false) {
470
+ // V2 (gh#7, S1): the delivery face honors the apiKey gate exactly
471
+ // like the control face — a configured key without this header
472
+ // used to let anyone reachable steer sessions while the control
473
+ // routes demanded the key, making it decorative for delivery.
474
+ // Empty-key deployments keep the documented open posture.
475
+ // #317: a link key admits only its own verified, body-bound peer.
476
+ if (!await peerKeyAdmits(req, rawBody)) {
477
+ const payload = JSON.stringify({ error: 'unauthorized: X-API-Key required on the delivery face (apiKey is configured)', code: -32000 });
478
+ res.writeHead(401, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
479
+ res.end(payload);
480
+ return;
481
+ }
482
+ // #282 S2: our own `<canvas>@<fp8>` is our canvas; a foreign suffix stays for the forward walk.
483
+ const ownerQualified = splitCanvasOwner(typeof body.team === 'string' ? body.team : '');
484
+ const team = ownerQualified.owner !== undefined && ownerQualified.owner === d.ownCanvasFingerprint ? ownerQualified.team : typeof body.team === 'string' ? body.team : '';
485
+ const message = typeof body.message === 'string' ? body.message : '';
486
+ const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
487
+ // P2 receipt-callback: where THIS node's receipt should route on
488
+ // the caller's side — the caller knows its routable address best
489
+ // (a joined session's node team, e.g. via the transport face's
490
+ // callbackTarget mapping). Bounded; absent → the caller label
491
+ // plays its usual role.
492
+ const callback = typeof body.callback === 'string' && body.callback !== '' && body.callback.length <= 128 ? body.callback : '';
493
+ const noWait = body.wait === false;
494
+ const contextId = typeof body.context_id === 'string' && body.context_id !== '' ? body.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`;
495
+ // The caller-born task id (idempotency key): echo it when present
496
+ // so the receipt header correlates with the caller's own result;
497
+ // pre-0.5.3 callers carry none and this node mints one.
498
+ const taskId = typeof body.task_id === 'string' && body.task_id !== '' ? body.task_id : `direct-${Math.random().toString(16).slice(2, 10)}`;
499
+ if (team === '' || message === '') {
500
+ const payload = JSON.stringify({ error: 'team and message are required', code: -32000 });
501
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
502
+ res.end(payload);
503
+ return;
504
+ }
505
+ // #114 S5: a caller-born id must be header-safe — refused before
506
+ // admission, claim or steer, so nothing is booked under it.
507
+ const idRefusal = taskIdRefusal(taskId);
508
+ if (idRefusal !== undefined) {
509
+ const payload = JSON.stringify({ error: idRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
510
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
511
+ res.end(payload);
512
+ return;
513
+ }
514
+ // Phase-3 admission (owner ruling 2026-09-01, decision-seat
515
+ // filed): an inbound direct delivery must show a SHARED team.
516
+ // Local-zone callers are judged synchronously and exactly — the
517
+ // declaration lives in this host's own roster store, so the
518
+ // Anonymous-delivery refusal (S3 phase-3b, test-seat finding
519
+ // direct-001649ab): a delivery with NO caller_session field
520
+ // skips the admission gate entirely (nothing to judge) — that
521
+ // anonymous hole let any local process or reachable peer inject
522
+ // into a live session. The protocol contract is caller_session;
523
+ // its absence is now refused instead of silently waved through.
524
+ // (0.6.15: the rule now lives in a2aCallerRefusal so the
525
+ // read-only /a2a/log face admits callers by exactly the same
526
+ // standard as message delivery.)
527
+ // Complete present-envelope validation before evaluating request admission.
528
+ // The fully absent-envelope migration policy remains independent.
529
+ const foreignCli = config.teamScopeRouting && CLI_HANDLE_RE.test(caller) && cliDeposits.get(caller) === undefined;
530
+ const envelopePresent = hasOriginClaimFields(req);
531
+ const earlyClaim = envelopePresent ? await completeInboundVerification(() => verifyOriginClaim(req, { cli: true, rawBody })) : { ok: false, reason: 'absent' };
532
+ const memberClaim = /^[^/\s]+\/[0-9a-f]{8}(?:@[0-9a-f]{8})?$/.test(caller) || caller === config.team || caller.startsWith(config.team + '/') || canvasStore.list().some(name => canvasStore.rawMembersOf(name).some(ref => ref === caller || ref.startsWith(caller + '@'))) || String(req.headers['x-a2a-caller'] ?? '') === config.team;
533
+ const gate = verifiedInboundAdmission(earlyClaim, envelopePresent, memberClaim, key => a2aCallerAdmission(caller, team, undefined, key));
534
+ if (!gate.ok) {
559
535
  recordActivity('in', team, caller, false);
560
- const payload = JSON.stringify({ error: `untrusted-origin: OriginClaim verification failed (${claim.reason}) — present a signed envelope (X-A2A-Caller/-Url/-Ts/-Sig with your card key) or upgrade your node`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
536
+ const payload = JSON.stringify({ error: 'untrusted-origin: OriginClaim verification failed (' + gate.reason + ')', code: WIRE_ERROR_INBOUND_VERIFICATION, reason: gate.reason, team, task_status: 'TASK_STATE_FAILED', delivered: false });
561
537
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
562
538
  res.end(payload);
563
539
  return;
564
540
  }
565
- d.bumpLegacyInbound();
566
- }
567
- // A verified claim proves the caller only when its signed zone covers it (review of PR #134 P1),
568
- // and the host face only when it was signed from this very host (round 2: a zone name is not an origin).
569
- const callerProof = !claim.ok ? 'legacy-window'
570
- : admission.rule === 'remote-ref-cli' ? 'origin-claim'
571
- : claim.cli === true ? (claimCovers(caller, claim.caller) ? 'cli-identity' : 'claim-mismatch')
572
- : claim.bound !== true ? 'unbound'
573
- : !isRootOriginUrl(claim.callerUrl) ? 'claim-mismatch'
574
- : admission.rule === 'host-face' && originOf(claim.callerUrl) !== originOf(d.ownCardUrl) ? 'claim-mismatch'
575
- : claimCovers(caller, claim.caller) ? 'origin-claim' : 'claim-mismatch';
576
- const proofClause = callerProof === 'origin-claim' && claim.ok ? `已验证来源 ${originOf(claim.callerUrl)}`
577
- : callerProof === 'cli-identity' ? '未验证(cli-identity)' : `未验证(${callerProof})`;
578
- const directHeader = (from) => `[A2A direct] (task ${taskId}) ${proofClause} ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
579
- if (refuseIfBudgetExhausted(req, res, team, caller, taskId, taskLedger))
580
- return;
581
- // Server-side idempotency (P3/B3): one key, one execution inside
582
- // the window. Same key + same payload ⇒ replay; different payload
583
- // ⇒ hard conflict (both 409). A replay must never re-enter the
584
- // target session, no matter how it settles.
585
- const idemFingerprint = peerPayloadFingerprint({ caller, message, noWait, team });
586
- const idemVerdict = idempotencyStore.claim(taskId, idemFingerprint);
587
- if (idemVerdict !== 'fresh') {
588
- const replay = idemVerdict === 'replay';
589
- // Conflicts warn (caller key reuse bugs); harmless replays stay silent.
590
- if (!replay)
591
- logger.warn(`a2a: idempotency CONFLICT on task ${taskId.slice(0, 80)} — same key reused with a different payload (caller bug; never auto-retry)`);
592
- const payload = JSON.stringify({
593
- error: replay ? 'duplicate task id within the idempotency window' : 'task id reused with a different payload',
594
- code: replay ? WIRE_ERROR_REPLAY_REJECTED : WIRE_ERROR_IDEMPOTENCY_CONFLICT,
595
- task_id: taskId,
596
- replay,
597
- });
598
- res.writeHead(409, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
599
- res.end(payload);
600
- return;
601
- }
602
- if (refuseIfFlooded(caller, message, team, taskId, res))
603
- return;
604
- if (refuseIfQueueFull(caller, message, team, taskId, cliDeposits.get(team) !== undefined, res))
605
- return;
606
- // The caller-side receipt correlation: a message shaped as a
607
- // receipt resolves the task it echoes, whichever wait semantics
608
- // carried it here. Correlation is bookkeeping only — the message
609
- // steers on exactly as before.
610
- // #114 S4: the settler is this delivery's caller, as proven here. A CLI identity's claim URL
611
- // is not a host's origin (its origin is unconstrained), so it never vouches for one (review of PR #140 P1-2).
612
- // #139: the claim's card key names the host whatever address spelling it signed with.
613
- // The response can race a fast peer receipt. Wait for this exact forwarding birth before judging or acknowledging it.
614
- await waitForForwardBirth(message);
615
- const refusedSettlement = settleAndAnnounce(message, { caller, proof: callerProof, ...settlerClaimFields(claim, originOf), ingress: 'wire' });
616
- // #139 (design §4.4): every answer this delivery gets carries the refusal.
617
- if (refusedSettlement !== undefined)
618
- withSettlementField(res, refusedSettlement);
619
- // gh#45 CLI mailbox (wire face): a delivery targeting a DEPOSITED
620
- // CLI handle never resolves a session and never wakes — it lands
621
- // in the deposit's mailbox and answers delivered. Placement here
622
- // keeps the idempotency claim (above) exactly as for sessions.
623
- const cliRow = cliDeposits.get(team);
624
- if (cliRow !== undefined) {
625
- const answered = parseReceipt(message);
626
- // #298: only the answering member's own, unrefused receipt is the duplicate of a sync answer.
627
- const duplicate = answered !== null && refusedSettlement === undefined && syncAnswered.take(answered.taskId, team, caller);
628
- cliDeposits.mail(team, caller, `${directHeader(caller)}\n\n${message}`, duplicate ? 'sync-answered' : undefined);
629
- recordActivity('in', team, caller, true);
630
- logger.info(`a2a: cli mailbox delivery for ${team} (from ${caller})`);
631
- const note = `delivered to the mailbox of ${team} — the CLI polls while online`;
632
- // gh#112 review: a mailbox delivery is final from this host's
633
- // side, so a resend of this id reads `completed`. A receipt the
634
- // CLI routes back later still reaches its caller, but does not
635
- // replace this outcome (recordOutcome is first-writer-wins).
636
- idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: note });
637
- const payload = JSON.stringify({ routed: true, delivered: true, receipt: 'mailbox', team, task_id: taskId, task_status: 'TASK_STATE_COMPLETED', note });
638
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
639
- res.end(payload);
640
- return;
641
- }
642
- // Inbound routing is foreground demand: boot prewarm yields for a
643
- // quiet window around it.
644
- d.touchWakeDemand();
645
- const forwardMiss = async (original) => {
646
- // #282 S2 review P1: forward with the id the caller sent — our own `@fp8` must find no other owner.
647
- const result = await d.forwardDirect({ team: typeof body.team === 'string' && ownerQualified.owner !== undefined ? body.team : team, message, caller, taskId, contextId, noWait,
648
- auth: buildAuthorization(admission, { caller, proof: callerProof, ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}), ingress: 'wire', allowlistVersion: admittedAt.version, target: team, receiptTarget: caller, receivedAt: admittedAt.at }),
649
- localCliProof: claim.ok && claim.cli === true && claim.bound && claim.caller === caller, guarded: req.headers['x-a2a-guard'] !== undefined }).catch((error) => ({ ok: false, error: `forwarding unavailable: ${String(error)}`, code: -32000, task_id: taskId }));
650
- const failure = result.ok ? '' : await d.diagnoseCanvas(team, original + ' — ' + result.error, claim.ok && claim.cli === true && claim.bound && claim.caller === caller ? caller : undefined);
651
- if (!result.ok && result.code !== -32005)
652
- idempotencyStore.recordOutcome(taskId, { status: 'failed', error: failure });
653
- else if (result.ok && result.task_status === 'TASK_STATE_COMPLETED')
654
- idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: result.reply });
655
- const payload = JSON.stringify(result.ok ? { routed: true, ...(result.task_status === 'TASK_STATE_DELIVERED' ? { delivered: true } : { result: { text: result.reply } }), team, task_id: taskId, context_id: contextId, task_status: result.task_status, consumed: result.consumed, bridge: result.bridge, receipt: result.receipt, forwarded: true } : { error: failure, code: result.code ?? -32000, receipt: result.receipt, task_id: taskId, team, task_status: result.code === -32005 ? 'TASK_STATE_DELIVERED' : 'TASK_STATE_FAILED', ...(result.next !== undefined ? { next: result.next } : {}) });
656
- // #298: only a reply the peer itself declares final (`reply_final`; never a host placeholder, review of
657
- // #305 F3), handed to the CLI in full, marks that member's later receipt a duplicate — never a delivered,
658
- // mailbox or bridge shape (F1), and never when the CLI gave up and closed the socket ('finish' does not fire).
659
- const answeredBy = result.ok ? result.deliveredTo : undefined;
660
- if (!noWait && result.ok && result.replyFinal === true && result.task_status === 'TASK_STATE_COMPLETED' && result.receipt === undefined && result.bridge === undefined && answeredBy !== undefined
661
- && claim.ok && claim.cli === true && claim.bound && claim.caller === caller && cliDeposits.get(caller) !== undefined)
662
- res.once('finish', () => syncAnswered.note(taskId, caller, answeredBy));
663
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
664
- res.end(payload);
665
- };
666
- // wait:false delivers without the final-reply hold: steer resolves
667
- // synchronously inside routeIntoAgentFor, so by the time the agent
668
- // lookup settles the message is already in — answer delivered and
669
- // let the receipt contract carry the reply.
670
- if (noWait) {
671
- // gh#112 review P1: a no-wait refusal AFTER the idempotency
672
- // claim records its failed outcome — otherwise a resend of the
673
- // same id reads `pending` from /a2a/query forever and the
674
- // caller is told an undelivered message already landed.
675
- const refuseNoWait = (error, activity = true) => {
676
- if (activity)
677
- recordActivity('in', team, caller, false);
678
- idempotencyStore.recordOutcome(taskId, { status: 'failed', error });
679
- const payload = JSON.stringify({ error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
541
+ const priorFingerprint = peerPayloadFingerprint({ caller, message, noWait, team });
542
+ const priorTransport = idempotencyStore.query(taskId, priorFingerprint);
543
+ if (earlyClaim.ok && priorTransport.found && priorTransport.status === 'completed' && d.receiptReplayAllowed(message, caller, team, earlyClaim)) {
544
+ const payload = JSON.stringify({ routed: true, delivered: true, receipt: 'none', team, task_id: taskId, task_status: 'TASK_STATE_COMPLETED', note: 'receipt delivery already confirmed' });
680
545
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
681
546
  res.end(payload);
682
- };
683
- // Issue #41 companion fix: the no-wait inbound branch resolved
684
- // only bare session teams — a canvas team delivery (wait:false)
685
- // fell through to the cold tier and answered a bogus
686
- // no-match although a live member existed. Same resolution as
687
- // the waiting face (routeIntoAgent), and likewise NO initiator
688
- // skip: the wire face delivers faithfully to the first live
689
- // member, whoever the caller is.
690
- const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
691
- const wake = agent !== undefined ? undefined : wakeColdTeam(team);
692
- const deliver = (target) => {
693
- const from = caller === '' ? 'an unknown node' : caller;
694
- // The receipt target: the caller's callback address when it
695
- // supplied one (a session node team — wake-on-route covers a
696
- // cold one), else the caller label as before.
697
- const receiptTarget = callback !== '' ? callback : from;
698
- // gh#112 review: only a failed steer is a refusal. Once the
699
- // message is in the session, a failure in the bookkeeping that
700
- // follows must not record `failed` (a resend would read
701
- // "already ran and failed" and invite a second execution).
702
- let steered = false;
703
- try {
704
- // The receipt header carries the task id: the target echoes
705
- // it verbatim in "[A2A receipt] task <id> ...", closing the
706
- // correlation loop with the caller's own route result.
707
- // F1' (consumed-probe prior-running): capture the pre-steer
708
- // status first — a target that was already running (its own
709
- // turn / a brand-new session's activation window) reads
710
- // `running` after the steer whether or not our wake was
711
- // latched, so the post-steer read alone would answer a false
712
- // consumed:true and strand the message.
713
- const runningBeforeSteer = probeConsumption(target, taskId);
714
- // #114 S5: the receipt's consumption search starts here, so an
715
- // older steer of a reused id in the history never anchors it.
716
- const steerFloor = eventCountOf(target);
717
- const deliveredReceipt = parseReceipt(message) !== null;
718
- // gh#141: a receipt is answered by nobody — its steer asks for no receipt back.
719
- const receiptRequest = deliveredReceipt ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${receiptTarget}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
720
- steerRelay(target, `${directHeader(from)}\n\n${message}${receiptRequest}`, from === 'an unknown node' ? undefined : from);
721
- steered = true;
722
- recordActivity('in', team, caller, true);
723
- armReceiptAutosend(target, taskId, receiptTarget, message, steerFloor);
724
- // v0.5.23 (async-stall): "delivered" only proves the steer call
725
- // returned — not that a turn started. An idle-phase steer wakes
726
- // the driver, but a maintenance/abort window latches the wake for
727
- // replay that may never fire (defect t-mt6nd0sq-hxuhj6: seven
728
- // delivered:true routes with zero log growth). Probe the driver
729
- // shortly after: running means the message is being consumed;
730
- // still-idle means the wake was latched or dropped — surface that
731
- // honestly and arm a delayed nudge retry below.
732
- // F1': a prior-running target never trusts the single
733
- // post-steer read — answer consumed:false conservatively and
734
- // arm the nudge (a false negative costs one harmless nudge; a
735
- // false positive strands the delivered message).
736
- // F2' (receipt-settlement): the delivery is born into the
737
- // ledger — an inbound row owes a receipt only this node can
738
- // produce, and before this row existed the nudge's isPending
739
- // gate no-op'd for exactly the deliveries that stall (08-30:
740
- // zero ledger records for latched activations). Arming is
741
- // unconditional now; settlement evidence disarms in the
742
- // callback.
743
- const consumedProbe = runningBeforeSteer ? false : probeConsumption(target, taskId);
744
- // F5 (settlement integrity): the debt gate — a process-label
745
- // caller cannot receive receipts, so this delivery is born
746
- // fire-and-forget (receiptExpected:false, answered as
747
- // receipt:"none") instead of manufacturing a debt nobody can
748
- // settle. The row itself stays: stall visibility and the
749
- // nudge still apply to the TARGET side.
750
- const receiptExpectation = receiptExpectationOf(receiptTarget);
751
- // #114 S2: the row is born with its immutable authorization record.
752
- const auth = buildAuthorization(admission, {
753
- caller,
754
- proof: callerProof,
755
- ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}),
756
- ...(admission.rule === 'remote-ref-cli' && claim.ok ? { carrierUrl: originOf(claim.callerUrl) } : {}),
757
- ingress: 'wire',
758
- allowlistVersion: admittedAt.version,
759
- target: team,
760
- receiptTarget,
761
- receivedAt: admittedAt.at,
762
- // Audit only: a cold session's activation window also reads as running.
763
- targetBusy: runningBeforeSteer,
764
- });
765
- // #139: the member that actually received it answers for a canvas or bare-zone team.
766
- const deliveredTo = sessionTeamOf(target);
767
- // gh#141: a receipt owes no receipt — it is settlement traffic,
768
- // recorded on the row it settled. Booking it would leave a debt
769
- // nothing can pay, and a nudge chasing an answer nobody owes.
770
- if (!deliveredReceipt) {
771
- taskLedger.trackInbound(taskId, team, receiptTarget, contextId === '' ? undefined : contextId, receiptExpectation !== 'process-label', briefOf(message), auth, deliveredTo === team ? undefined : deliveredTo);
772
- armAsyncNudge(target, team, taskId, config.asyncNudgeDelayMs);
773
- }
774
- const payload = JSON.stringify({
775
- routed: true,
776
- delivered: true,
777
- team,
778
- session,
779
- task_id: taskId,
780
- context_id: contextId,
781
- task_status: 'TASK_STATE_DELIVERED',
782
- artifacts: [],
783
- consumed: consumedProbe,
784
- ...(req.headers['x-a2a-guard'] !== undefined ? { delivered_to: deliveredTo } : {}),
785
- receipt: receiptExpectation === 'process-label' || deliveredReceipt ? 'none' : 'routed',
786
- });
547
+ return;
548
+ }
549
+ const cliProof = foreignCli && earlyClaim?.ok ? await remoteCliProof(caller, earlyClaim).catch(() => undefined) : undefined;
550
+ let admission = foreignCli ? a2aCallerAdmission(caller, team, cliProof, earlyClaim?.ok ? earlyClaim.card.publicKey : undefined) : gate.admission;
551
+ // #282 S3 (same team authorizes): a receipt for this host's own forward, to the CLI that sent it, from a
552
+ // receiver frozen at birth on the selected peer's key — the owner's signed canvas is the return path.
553
+ const echoed = !admission.ok && cliDeposits.get(team) !== undefined ? parseReceipt(message) : null;
554
+ if (echoed !== null) {
555
+ // Verify first, so an unsigned or Sig2-only caller cannot hold the connection on an in-flight forward (#287 review P3);
556
+ // then a body-bound receipt may beat the dispatch's HTTP answer: wait for this forward's row to be born (#287 P2).
557
+ const receiptClaim = earlyClaim;
558
+ if (receiptClaim.ok && receiptClaim.bodyBound === true)
559
+ await waitForForwardBirth(message);
560
+ const basis = receiptClaim.ok ? d.forwardReceiptBasis(echoed.taskId, caller, team, receiptClaim) : undefined;
561
+ if (basis !== undefined)
562
+ admission = { ok: true, via: 'peer-org', rule: 'peer-org', basis: [basis] };
563
+ }
564
+ const callerRefusal = refusalOf(admission);
565
+ if (callerRefusal !== undefined || !admission.ok) {
566
+ recordActivity('in', team, caller === '' ? 'anonymous' : caller, false);
567
+ const next = nextOf(admission);
568
+ const payload = JSON.stringify({ error: callerRefusal, code: -32000, team, task_status: 'TASK_STATE_FAILED', ...(next !== undefined ? { next } : {}) });
569
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
570
+ res.end(payload);
571
+ return;
572
+ }
573
+ // #114 S6: a version an inbound record is born under is always in the history.
574
+ observeAuthorization('lazy');
575
+ // #114 S2: the version is read at admission time, beside the decision it explains.
576
+ const admittedAt = { version: allowlistVersionNow(), at: Date.now() };
577
+ // gh#9+gh#10 OriginClaim on the delivery face (M1): an envelope
578
+ // that is present but unverifiable rejects untrusted-origin; an
579
+ // ABSENT envelope during the window walks on with a counted
580
+ // legacyInbound warn (the operator watches the tail drain), and
581
+ // after the window closes it rejects like a failed signature —
582
+ // N3 proves a NAME's linkability, never key possession.
583
+ // gh#133: the delivery face is where a deposited CLI identity signs its routes.
584
+ const claim = earlyClaim;
585
+ if (!claim.ok) {
586
+ if (claim.reason !== 'absent' || config.originClaimWindow === false) {
587
+ recordActivity('in', team, caller, false);
588
+ const payload = JSON.stringify({ error: `untrusted-origin: OriginClaim verification failed (${claim.reason}) — present a signed envelope (X-A2A-Caller/-Url/-Ts/-Sig with your card key) or upgrade your node`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
787
589
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
788
590
  res.end(payload);
591
+ return;
592
+ }
593
+ d.bumpLegacyInbound();
594
+ }
595
+ // A verified claim proves the caller only when its signed zone covers it (review of PR #134 P1),
596
+ // and the host face only when it was signed from this very host (round 2: a zone name is not an origin).
597
+ const callerProof = !claim.ok ? 'legacy-window'
598
+ : admission.rule === 'remote-ref-cli' ? 'origin-claim'
599
+ : claim.cli === true ? (claimCovers(caller, claim.caller) ? 'cli-identity' : 'claim-mismatch')
600
+ : claim.bound !== true ? 'unbound'
601
+ : !isRootOriginUrl(claim.callerUrl) ? 'claim-mismatch'
602
+ : admission.rule === 'host-face' && originOf(claim.callerUrl) !== originOf(d.ownCardUrl) ? 'claim-mismatch'
603
+ : claimCovers(caller, claim.caller) ? 'origin-claim' : 'claim-mismatch';
604
+ const proofClause = callerProof === 'origin-claim' && claim.ok ? `已验证来源 ${originOf(claim.callerUrl)}`
605
+ : callerProof === 'cli-identity' ? '未验证(cli-identity)' : `未验证(${callerProof})`;
606
+ const directHeader = (from) => `[A2A direct] (task ${taskId}) ${proofClause} ${sanitizeFromClause(from)} (routed to ${team}) sent:`;
607
+ if (refuseIfBudgetExhausted(req, res, team, caller, taskId, taskLedger))
608
+ return;
609
+ // Server-side idempotency (P3/B3): one key, one execution inside
610
+ // the window. Same key + same payload ⇒ replay; different payload
611
+ // ⇒ hard conflict (both 409). A replay must never re-enter the
612
+ // target session, no matter how it settles.
613
+ const idemFingerprint = peerPayloadFingerprint({ caller, message, noWait, team });
614
+ const priorDelivery = taskLedger.deliveryFor(taskId);
615
+ if (taskLedger.orphanDelivery(taskId) && priorDelivery?.callerKey === (claim.ok ? claim.card.publicKey : undefined) && idempotencyStore.query(taskId, idemFingerprint).found) {
616
+ const payload = JSON.stringify({ routed: true, delivered: true, team, task_id: taskId, context_id: contextId, task_status: 'TASK_STATE_DELIVERED', receipt: 'routed', receiverBookkeeping: 'failed', note: ADMITTED_BOOKKEEPING_NOTE });
617
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
618
+ res.end(payload);
619
+ return;
620
+ }
621
+ const idemVerdict = idempotencyStore.claim(taskId, idemFingerprint);
622
+ if (idemVerdict !== 'fresh') {
623
+ const replay = idemVerdict === 'replay';
624
+ // Conflicts warn (caller key reuse bugs); harmless replays stay silent.
625
+ if (!replay)
626
+ logger.warn(`a2a: idempotency CONFLICT on task ${taskId.slice(0, 80)} — same key reused with a different payload (caller bug; never auto-retry)`);
627
+ const payload = JSON.stringify({
628
+ error: replay ? 'duplicate task id within the idempotency window' : 'task id reused with a different payload',
629
+ code: replay ? WIRE_ERROR_REPLAY_REJECTED : WIRE_ERROR_IDEMPOTENCY_CONFLICT,
630
+ task_id: taskId,
631
+ replay,
632
+ });
633
+ res.writeHead(409, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
634
+ res.end(payload);
635
+ return;
636
+ }
637
+ if (body.cancel_task_id !== undefined) {
638
+ const cancelledId = typeof body.cancel_task_id === 'string' ? body.cancel_task_id : '';
639
+ const original = taskLedger.find(cancelledId), originalClaim = original?.auth?.claim;
640
+ const allowed = claim.ok && claim.bodyBound === true && callerProof === 'origin-claim' && original?.direction === 'inbound' && original.auth?.caller === caller && originalClaim?.caller === claim.caller && originalClaim.origin === originOf(claim.callerUrl) && taskLedger.deliveryFor(cancelledId)?.callerKey === claim.card.publicKey;
641
+ if (!allowed) {
642
+ res.writeHead(403, { 'Content-Type': 'application/json' });
643
+ res.end(JSON.stringify({ error: 'cancellation does not match the original authenticated caller', task_status: 'TASK_STATE_FAILED' }));
644
+ return;
645
+ }
646
+ taskLedger.cancel(cancelledId, 'authenticated caller stop notice');
647
+ let removed = false, steered = false;
648
+ try {
649
+ const notification = await cancelQueuedTask(cancelledId, message, caller, original);
650
+ removed = notification.removed;
651
+ steered = notification.steered;
789
652
  }
790
653
  catch (error) {
791
- if (!steered) {
792
- refuseNoWait(`The DSH session rejected the message: ${String(error)}`);
793
- return;
794
- }
795
- logger.warn(`a2a: task ${taskId.slice(0, 80)} was delivered to ${team}, but its bookkeeping failed: ${String(error)}`);
796
- if (!res.headersSent) {
797
- const payload = JSON.stringify({ routed: true, delivered: true, team, task_id: taskId, context_id: contextId, task_status: 'TASK_STATE_DELIVERED', artifacts: [], consumed: false, receipt: 'routed' });
798
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
799
- res.end(payload);
800
- }
654
+ logger.warn('a2a: cancellation committed; stop notice was best-effort: %s', String(error));
801
655
  }
802
- };
803
- if (agent !== undefined) {
804
- deliver(agent);
656
+ const payload = JSON.stringify({ routed: true, delivered: true, removed, steered, receipt: 'none', team, task_id: taskId, task_status: 'TASK_STATE_COMPLETED', result: { text: 'cancellation admitted for original task' } });
657
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
658
+ res.end(payload);
805
659
  return;
806
660
  }
807
- if (wake?.kind === 'flight') {
808
- void wake.flight.then(deliver, (error) => {
809
- refuseNoWait(`waking the session for team "${team}" failed: ${String(error)}`);
810
- });
661
+ if (refuseIfFlooded(caller, message, team, taskId, res))
662
+ return;
663
+ if (refuseIfQueueFull(caller, message, team, taskId, cliDeposits.get(team) !== undefined, res))
811
664
  return;
665
+ // The caller-side receipt correlation: a message shaped as a
666
+ // receipt resolves the task it echoes, whichever wait semantics
667
+ // carried it here. Correlation is bookkeeping only — the message
668
+ // steers on exactly as before.
669
+ // #114 S4: the settler is this delivery's caller, as proven here. A CLI identity's claim URL
670
+ // is not a host's origin (its origin is unconstrained), so it never vouches for one (review of PR #140 P1-2).
671
+ // #139: the claim's card key names the host whatever address spelling it signed with.
672
+ // The response can race a fast peer receipt. Wait for this exact forwarding birth before judging or acknowledging it.
673
+ await waitForForwardBirth(message);
674
+ let refusedSettlement;
675
+ try {
676
+ refusedSettlement = settleAndAnnounce(message, { caller, proof: callerProof, ...settlerClaimFields(claim, originOf), ingress: 'wire' });
812
677
  }
813
- // Remaining candidates need one async classification (the
814
- // native-teams bridge), so the tail answers inside an IIFE.
815
- void (async () => {
816
- // Native-teams inbound bridge (opt-in): prepare BEFORE answering
817
- // delivered — claim, seam, and parent initiator are everything
818
- // that can fail fast, and a phantom dispatch must never answer
819
- // success. Past prepare, the round fires detached (noWait means
820
- // delivery, not settlement); its late outcome corrects the
821
- // activity ring and the log.
822
- const standardMiss = async () => {
823
- if (team === config.team) {
824
- const live = liveAgent();
825
- if (live !== undefined) {
826
- // Self-route refusal (mirrors the sync face): a caller
827
- // naming the very agent the fallback resolves to must
828
- // get an honest failure, never its own dispatch echoed
829
- // home plus an auto-receipt for work nobody did.
830
- if (callerIsAgent(live, caller)) {
831
- refuseNoWait(selfRouteText(team));
832
- return;
833
- }
834
- deliver(live);
835
- return;
678
+ catch {
679
+ // A failed receipt settlement did not deliver: keep its fixed transport identity retryable.
680
+ if (parseReceipt(message) !== null)
681
+ idempotencyStore.releaseUncompleted(taskId, idemFingerprint);
682
+ // Body end runs outside the webServer guard; a failed commit must not escape or steer.
683
+ const payload = JSON.stringify({ error: 'task ledger write failed; settlement was not committed', code: -32000, task_id: taskId, task_status: 'TASK_STATE_FAILED', delivered: false });
684
+ res.writeHead(503, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
685
+ res.end(payload);
686
+ return;
687
+ }
688
+ // #139 (design §4.4): every answer this delivery gets carries the refusal.
689
+ if (refusedSettlement !== undefined)
690
+ withSettlementField(res, refusedSettlement);
691
+ // gh#45 CLI mailbox (wire face): a delivery targeting a DEPOSITED
692
+ // CLI handle never resolves a session and never wakes — it lands
693
+ // in the deposit's mailbox and answers delivered. Placement here
694
+ // keeps the idempotency claim (above) exactly as for sessions.
695
+ const cliRow = cliDeposits.get(team);
696
+ if (cliRow !== undefined) {
697
+ const answered = parseReceipt(message);
698
+ // #298: only the answering member's own, unrefused receipt is the duplicate of a sync answer.
699
+ const duplicate = answered !== null && refusedSettlement === undefined && syncAnswered.take(answered.taskId, team, caller);
700
+ cliDeposits.mail(team, caller, `${directHeader(caller)}\n\n${message}`, duplicate ? 'sync-answered' : undefined);
701
+ recordActivity('in', team, caller, true);
702
+ logger.info(`a2a: cli mailbox delivery for ${team} (from ${caller})`);
703
+ const note = `delivered to the mailbox of ${team} — the CLI polls while online`;
704
+ // gh#112 review: a mailbox delivery is final from this host's
705
+ // side, so a resend of this id reads `completed`. A receipt the
706
+ // CLI routes back later still reaches its caller, but does not
707
+ // replace this outcome (recordOutcome is first-writer-wins).
708
+ idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: note });
709
+ const payload = JSON.stringify({ routed: true, delivered: true, receipt: 'mailbox', team, task_id: taskId, task_status: 'TASK_STATE_COMPLETED', note });
710
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
711
+ res.end(payload);
712
+ return;
713
+ }
714
+ // Inbound routing is foreground demand: boot prewarm yields for a
715
+ // quiet window around it.
716
+ d.touchWakeDemand();
717
+ const forwardMiss = async (original) => {
718
+ // #282 S2 review P1: forward with the id the caller sent — our own `@fp8` must find no other owner.
719
+ const result = await d.forwardDirect({ team: typeof body.team === 'string' && ownerQualified.owner !== undefined ? body.team : team, message, caller, taskId, contextId, noWait,
720
+ auth: buildAuthorization(admission, { caller, proof: callerProof, ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}), ingress: 'wire', allowlistVersion: admittedAt.version, target: team, receiptTarget: caller, receivedAt: admittedAt.at }),
721
+ localCliProof: claim.ok && claim.cli === true && claim.bound && claim.caller === caller, guarded: req.headers['x-a2a-guard'] !== undefined }).catch((error) => ({ ok: false, error: `forwarding unavailable: ${String(error)}`, code: -32000, task_id: taskId }));
722
+ const failure = result.ok ? '' : await d.diagnoseCanvas(team, original + ' — ' + result.error, claim.ok && claim.cli === true && claim.bound && claim.caller === caller ? caller : undefined);
723
+ if (!result.ok && result.code !== -32005)
724
+ idempotencyStore.recordOutcome(taskId, { status: 'failed', error: failure });
725
+ else if (result.ok && result.task_status === 'TASK_STATE_COMPLETED')
726
+ idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: result.reply });
727
+ const payload = JSON.stringify(result.ok ? { routed: true, ...(result.task_status === 'TASK_STATE_DELIVERED' ? { delivered: true } : { result: { text: result.reply } }), team, task_id: taskId, context_id: contextId, task_status: result.task_status, consumed: result.consumed, bridge: result.bridge, receipt: result.receipt, ...(result.receiverBookkeeping === 'failed' ? { receiverBookkeeping: 'failed', note: ADMITTED_BOOKKEEPING_NOTE } : {}), forwarded: true } : { error: failure, code: result.code ?? -32000, receipt: result.receipt, task_id: taskId, team, task_status: result.code === -32005 ? 'TASK_STATE_DELIVERED' : 'TASK_STATE_FAILED', ...(result.next !== undefined ? { next: result.next } : {}) });
728
+ // #298: only a reply the peer itself declares final (`reply_final`; never a host placeholder, review of
729
+ // #305 F3), handed to the CLI in full, marks that member's later receipt a duplicate — never a delivered,
730
+ // mailbox or bridge shape (F1), and never when the CLI gave up and closed the socket ('finish' does not fire).
731
+ const answeredBy = result.ok ? result.deliveredTo : undefined;
732
+ if (!noWait && result.ok && result.replyFinal === true && result.task_status === 'TASK_STATE_COMPLETED' && result.receipt === undefined && result.bridge === undefined && answeredBy !== undefined
733
+ && claim.ok && claim.cli === true && claim.bound && claim.caller === caller && cliDeposits.get(caller) !== undefined)
734
+ res.once('finish', () => syncAnswered.note(taskId, caller, answeredBy));
735
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
736
+ res.end(payload);
737
+ };
738
+ // wait:false delivers without the final-reply hold: steer resolves
739
+ // synchronously inside routeIntoAgentFor, so by the time the agent
740
+ // lookup settles the message is already in — answer delivered and
741
+ // let the receipt contract carry the reply.
742
+ if (noWait) {
743
+ // gh#112 review P1: a no-wait refusal AFTER the idempotency
744
+ // claim records its failed outcome — otherwise a resend of the
745
+ // same id reads `pending` from /a2a/query forever and the
746
+ // caller is told an undelivered message already landed.
747
+ const refuseNoWait = (error, activity = true) => {
748
+ if (activity)
749
+ recordActivity('in', team, caller, false);
750
+ idempotencyStore.recordOutcome(taskId, { status: 'failed', error });
751
+ const payload = JSON.stringify({ error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
752
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
753
+ res.end(payload);
754
+ };
755
+ // Issue #41 companion fix: the no-wait inbound branch resolved
756
+ // only bare session teams — a canvas team delivery (wait:false)
757
+ // fell through to the cold tier and answered a bogus
758
+ // no-match although a live member existed. Same resolution as
759
+ // the waiting face (routeIntoAgent), and likewise NO initiator
760
+ // skip: the wire face delivers faithfully to the first live
761
+ // member, whoever the caller is.
762
+ const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
763
+ const wake = agent !== undefined ? undefined : wakeColdTeam(team);
764
+ const deliver = (target) => {
765
+ const from = caller === '' ? 'an unknown node' : caller;
766
+ // The receipt target: the caller's callback address when it
767
+ // supplied one (a session node team — wake-on-route covers a
768
+ // cold one), else the caller label as before.
769
+ const receiptTarget = callback !== '' ? callback : from;
770
+ // gh#112 review: only a failed steer is a refusal. Once the
771
+ // message is in the session, a failure in the bookkeeping that
772
+ // follows must not record `failed` (a resend would read
773
+ // "already ran and failed" and invite a second execution).
774
+ let steered = false;
775
+ try {
776
+ // The receipt header carries the task id: the target echoes
777
+ // it verbatim in "[A2A receipt] task <id> ...", closing the
778
+ // correlation loop with the caller's own route result.
779
+ // F1' (consumed-probe prior-running): capture the pre-steer
780
+ // status first — a target that was already running (its own
781
+ // turn / a brand-new session's activation window) reads
782
+ // `running` after the steer whether or not our wake was
783
+ // latched, so the post-steer read alone would answer a false
784
+ // consumed:true and strand the message.
785
+ const runningBeforeSteer = probeConsumption(target, taskId);
786
+ // #114 S5: the receipt's consumption search starts here, so an
787
+ // older steer of a reused id in the history never anchors it.
788
+ const steerFloor = eventCountOf(target);
789
+ const deliveredReceipt = parseReceipt(message) !== null;
790
+ // gh#141: a receipt is answered by nobody — its steer asks for no receipt back.
791
+ const receiptRequest = deliveredReceipt ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${receiptTarget}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
792
+ followupRelay(target, `${directHeader(from)}\n\n${message}${receiptRequest}`, from === 'an unknown node' ? undefined : from, undefined, taskId, claim.ok ? claim.card.publicKey : undefined, !deliveredReceipt);
793
+ steered = true;
794
+ if (deliveredReceipt && refusedSettlement === undefined)
795
+ idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: 'receipt delivered' });
796
+ recordActivity('in', team, caller, true);
797
+ // v0.5.23 (async-stall): "delivered" only proves the steer call
798
+ // returned — not that a turn started. An idle-phase steer wakes
799
+ // the driver, but a maintenance/abort window latches the wake for
800
+ // replay that may never fire (defect t-mt6nd0sq-hxuhj6: seven
801
+ // delivered:true routes with zero log growth). Probe the driver
802
+ // shortly after: running means the message is being consumed;
803
+ // still-idle means the wake was latched or dropped — surface that
804
+ // honestly and arm a delayed nudge retry below.
805
+ // F1': a prior-running target never trusts the single
806
+ // post-steer read — answer consumed:false conservatively and
807
+ // arm the nudge (a false negative costs one harmless nudge; a
808
+ // false positive strands the delivered message).
809
+ // F2' (receipt-settlement): the delivery is born into the
810
+ // ledger — an inbound row owes a receipt only this node can
811
+ // produce, and before this row existed the nudge's isPending
812
+ // gate no-op'd for exactly the deliveries that stall (08-30:
813
+ // zero ledger records for latched activations). Arming is
814
+ // unconditional now; settlement evidence disarms in the
815
+ // callback.
816
+ const consumedProbe = taskLedger.deliveryFor(taskId)?.state === 'claimed';
817
+ // F5 (settlement integrity): the debt gate — a process-label
818
+ // caller cannot receive receipts, so this delivery is born
819
+ // fire-and-forget (receiptExpected:false, answered as
820
+ // receipt:"none") instead of manufacturing a debt nobody can
821
+ // settle. The row itself stays: stall visibility and the
822
+ // nudge still apply to the TARGET side.
823
+ const receiptExpectation = receiptExpectationOf(receiptTarget);
824
+ // #114 S2: the row is born with its immutable authorization record.
825
+ const auth = buildAuthorization(admission, {
826
+ caller,
827
+ proof: callerProof,
828
+ ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}),
829
+ ...(admission.rule === 'remote-ref-cli' && claim.ok ? { carrierUrl: originOf(claim.callerUrl) } : {}),
830
+ ingress: 'wire',
831
+ allowlistVersion: admittedAt.version,
832
+ target: team,
833
+ receiptTarget,
834
+ receivedAt: admittedAt.at,
835
+ // Audit only: a cold session's activation window also reads as running.
836
+ targetBusy: runningBeforeSteer,
837
+ });
838
+ // #139: the member that actually received it answers for a canvas or bare-zone team.
839
+ const deliveredTo = sessionTeamOf(target);
840
+ // gh#141: a receipt owes no receipt — it is settlement traffic,
841
+ // recorded on the row it settled. Booking it would leave a debt
842
+ // nothing can pay, and a nudge chasing an answer nobody owes.
843
+ if (!deliveredReceipt) {
844
+ taskLedger.trackInbound(taskId, team, receiptTarget, contextId === '' ? undefined : contextId, receiptExpectation !== 'process-label', briefOf(message), auth, deliveredTo === team ? undefined : deliveredTo, String(target.id), consumedProbe);
845
+ armReceiptAutosend(target, taskId, receiptTarget, message, steerFloor);
846
+ armAsyncNudge(target, team, taskId, config.asyncNudgeDelayMs);
836
847
  }
848
+ const payload = JSON.stringify({
849
+ routed: true,
850
+ delivered: true,
851
+ team,
852
+ session,
853
+ task_id: taskId,
854
+ context_id: contextId,
855
+ task_status: 'TASK_STATE_DELIVERED',
856
+ artifacts: [],
857
+ consumed: consumedProbe,
858
+ ...(req.headers['x-a2a-guard'] !== undefined ? { delivered_to: deliveredTo } : {}),
859
+ receipt: receiptExpectation === 'process-label' || deliveredReceipt ? 'none' : 'routed',
860
+ });
861
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
862
+ res.end(payload);
837
863
  }
838
- if (wake?.kind === 'no-match') {
839
- await forwardMiss(noColdMatchText(team));
840
- return;
864
+ catch (error) {
865
+ if (error instanceof AdmittedBookkeepingError)
866
+ steered = true;
867
+ if (!steered) {
868
+ refuseNoWait(`The DSH session rejected the message: ${String(error)}`);
869
+ return;
870
+ }
871
+ if (!(error instanceof AdmittedBookkeepingError))
872
+ taskLedger.noteAdmittedBookkeepingFailure(error);
873
+ logger.warn('a2a: admitted task bookkeeping failed; reconcile original delivery');
874
+ if (!res.headersSent) {
875
+ const payload = JSON.stringify({ routed: true, delivered: true, team, task_id: taskId, context_id: contextId, task_status: 'TASK_STATE_DELIVERED', artifacts: [], consumed: false, receipt: 'routed', receiverBookkeeping: 'failed', note: ADMITTED_BOOKKEEPING_NOTE });
876
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
877
+ res.end(payload);
878
+ }
841
879
  }
842
- refuseNoWait(wake?.kind === 'no-face' ? noWakeFaceText(team) : wake?.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team), false);
843
880
  };
844
- const prepared = await nativeTeamsPrepare(team);
845
- if (!prepared.ok) {
846
- if (prepared.reason === 'error') {
847
- refuseNoWait(prepared.error);
881
+ if (agent !== undefined) {
882
+ deliver(agent);
883
+ return;
884
+ }
885
+ if (wake?.kind === 'flight') {
886
+ void wake.flight.then(deliver, (error) => {
887
+ refuseNoWait(`waking the session for team "${team}" failed: ${String(error)}`);
888
+ });
889
+ return;
890
+ }
891
+ // Remaining candidates need one async classification (the
892
+ // native-teams bridge), so the tail answers inside an IIFE.
893
+ await (async () => {
894
+ // Native-teams inbound bridge (opt-in): prepare BEFORE answering
895
+ // delivered — claim, seam, and parent initiator are everything
896
+ // that can fail fast, and a phantom dispatch must never answer
897
+ // success. Past prepare, the round fires detached (noWait means
898
+ // delivery, not settlement); its late outcome corrects the
899
+ // activity ring and the log.
900
+ const standardMiss = async () => {
901
+ if (team === config.team) {
902
+ const live = liveAgent();
903
+ if (live !== undefined) {
904
+ // Self-route refusal (mirrors the sync face): a caller
905
+ // naming the very agent the fallback resolves to must
906
+ // get an honest failure, never its own dispatch echoed
907
+ // home plus an auto-receipt for work nobody did.
908
+ if (callerIsAgent(live, caller)) {
909
+ refuseNoWait(selfRouteText(team));
910
+ return;
911
+ }
912
+ deliver(live);
913
+ return;
914
+ }
915
+ }
916
+ if (wake?.kind === 'no-match') {
917
+ await forwardMiss(noColdMatchText(team));
918
+ return;
919
+ }
920
+ refuseNoWait(wake?.kind === 'no-face' ? noWakeFaceText(team) : wake?.kind === 'archived' ? archivedColdMatchText(team) : noColdMatchText(team), false);
921
+ };
922
+ const prepared = await nativeTeamsPrepare(team);
923
+ if (!prepared.ok) {
924
+ if (prepared.reason === 'error') {
925
+ refuseNoWait(prepared.error);
926
+ return;
927
+ }
928
+ await standardMiss();
848
929
  return;
849
930
  }
850
- await standardMiss();
931
+ recordActivity('in', team, caller, true);
932
+ void nativeTeamsRound(prepared, team, caller, message, taskId, proofClause, undefined, (_team, _taskId, from) => directHeader(from))
933
+ .then(outcome => {
934
+ if (!outcome.ok) {
935
+ recordActivity('in', team, caller, false);
936
+ logger.warn(`a2a: detached native-teams round for "${team}" failed: ${outcome.error}`);
937
+ }
938
+ // W7 slice-2 hook 2: only a genuinely settled detached round
939
+ // records an outcome. The deadline's settled:false
940
+ // projection is a placeholder, not a product — the same
941
+ // ruling as hook 1's DELIVERED skip; placeholder prose never
942
+ // enters the ledger a replay could adopt from.
943
+ if (taskId !== '') {
944
+ if (!outcome.ok)
945
+ idempotencyStore.recordOutcome(taskId, { status: 'failed', error: outcome.error });
946
+ else if (outcome.settled)
947
+ idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: outcome.reply });
948
+ }
949
+ });
950
+ const payload = JSON.stringify({ routed: true, delivered: true, team, session, task_id: taskId, context_id: contextId, task_status: 'TASK_STATE_DELIVERED', artifacts: [], consumed: false, bridge: 'native-teams' });
951
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
952
+ res.end(payload);
953
+ })();
954
+ return;
955
+ }
956
+ let syncTarget;
957
+ // A forwarded synchronous delivery still owes its automatic receipt after the HTTP waiter expires.
958
+ const syncSteered = req.headers['x-a2a-guard'] === undefined || parseReceipt(message) !== null ? undefined : (target, floor) => {
959
+ syncTarget = sessionTeamOf(target);
960
+ const receiptTarget = callback !== '' ? callback : caller;
961
+ const auth = buildAuthorization(admission, { caller, proof: callerProof, ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}), ...(admission.rule === 'remote-ref-cli' && claim.ok ? { carrierUrl: originOf(claim.callerUrl) } : {}), ingress: 'wire', allowlistVersion: admittedAt.version, target: team, receiptTarget, receivedAt: admittedAt.at });
962
+ try {
963
+ taskLedger.trackInbound(taskId, team, receiptTarget, contextId, receiptExpectationOf(receiptTarget) !== 'process-label', briefOf(message), auth, syncTarget === team ? undefined : syncTarget, String(target.id), taskLedger.deliveryFor(taskId)?.state === 'claimed');
964
+ }
965
+ catch (error) {
966
+ taskLedger.noteAdmittedBookkeepingFailure(error);
967
+ throw new AdmittedBookkeepingError(error);
968
+ }
969
+ armReceiptAutosend(target, taskId, receiptTarget, message, floor);
970
+ };
971
+ await routeIntoAgent(team, message, caller, taskId, proofClause, directHeader, undefined, syncSteered, claim.ok ? claim.card.publicKey : undefined).then(async (outcome) => {
972
+ if (outcome.ok && outcome.receiverBookkeeping === 'failed') {
973
+ const payload = JSON.stringify({ routed: true, delivered: true, team, task_id: taskId, context_id: contextId, task_status: 'TASK_STATE_DELIVERED', receipt: 'routed', receiverBookkeeping: 'failed', note: ADMITTED_BOOKKEEPING_NOTE });
974
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
975
+ res.end(payload);
851
976
  return;
852
977
  }
853
- recordActivity('in', team, caller, true);
854
- void nativeTeamsRound(prepared, team, caller, message, taskId, proofClause, undefined, (_team, _taskId, from) => directHeader(from))
855
- .then(outcome => {
978
+ if (!outcome.ok && outcome.localMiss) {
979
+ await forwardMiss(outcome.error);
980
+ return;
981
+ }
982
+ recordActivity('in', team, caller, outcome.ok);
983
+ // W7 slice-2 hook 1: record the settled outcome on the claim row.
984
+ // Ruling: a non-COMPLETED status (TASK_STATE_DELIVERED — the
985
+ // bridge-round deadline placeholder, ABORTED_WAIT likewise) is
986
+ // NOT a settled outcome, and neither is a host-authored
987
+ // placeholder on the reply channel (flush timeout, dead session)
988
+ // — the row stays pending, because an adopted replay must never
989
+ // settle a node on placeholder text (the exact hazard W7 gap B
990
+ // closed, barred from resurrecting through the outcome ledger).
991
+ if (taskId !== '') {
856
992
  if (!outcome.ok) {
857
- recordActivity('in', team, caller, false);
858
- logger.warn(`a2a: detached native-teams round for "${team}" failed: ${outcome.error}`);
993
+ idempotencyStore.recordOutcome(taskId, { status: 'failed', error: outcome.error });
859
994
  }
860
- // W7 slice-2 hook 2: only a genuinely settled detached round
861
- // records an outcome. The deadline's settled:false
862
- // projection is a placeholder, not a product — the same
863
- // ruling as hook 1's DELIVERED skip; placeholder prose never
864
- // enters the ledger a replay could adopt from.
865
- if (taskId !== '') {
866
- if (!outcome.ok)
867
- idempotencyStore.recordOutcome(taskId, { status: 'failed', error: outcome.error });
868
- else if (outcome.settled)
869
- idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: outcome.reply });
995
+ else if (outcome.placeholder === true) {
996
+ // placeholder prose never enters the ledger
870
997
  }
871
- });
872
- const payload = JSON.stringify({ routed: true, delivered: true, team, session, task_id: taskId, context_id: contextId, task_status: 'TASK_STATE_DELIVERED', artifacts: [], consumed: false, bridge: 'native-teams' });
998
+ else if (outcome.status === undefined || outcome.status === 'TASK_STATE_COMPLETED') {
999
+ idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: outcome.reply });
1000
+ }
1001
+ }
1002
+ const payload = outcome.ok
1003
+ ? JSON.stringify({
1004
+ routed: true,
1005
+ team,
1006
+ session,
1007
+ result: { text: outcome.reply },
1008
+ ...(syncTarget !== undefined ? { delivered_to: syncTarget } : {}),
1009
+ // #298: a positive, cross-host statement that this text is the member's own final reply — the turn
1010
+ // that read this task's steer (`ownTurn`), never a placeholder, a bridge round or another turn (#305 F3/F4).
1011
+ ...(outcome.ownTurn === true && outcome.placeholder !== true && outcome.bridge === undefined && (outcome.status ?? 'TASK_STATE_COMPLETED') === 'TASK_STATE_COMPLETED' ? { reply_final: true } : {}),
1012
+ task_id: taskId,
1013
+ context_id: contextId,
1014
+ task_status: outcome.status ?? 'TASK_STATE_COMPLETED',
1015
+ artifacts: [],
1016
+ // The bridge marker rides the wire so the caller's
1017
+ // trackOwedTask skips a receipt-less native round — without
1018
+ // it, a cross-node unsettled round books an unpayable owed
1019
+ // row and re-creates the TASK_CAP backlog.
1020
+ ...(outcome.bridge !== undefined ? { bridge: outcome.bridge } : {}),
1021
+ })
1022
+ : JSON.stringify({ error: outcome.error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
873
1023
  res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
874
1024
  res.end(payload);
875
- })();
876
- return;
877
- }
878
- let syncTarget;
879
- // A forwarded synchronous delivery still owes its automatic receipt after the HTTP waiter expires.
880
- const syncSteered = req.headers['x-a2a-guard'] === undefined || parseReceipt(message) !== null ? undefined : (target, floor) => {
881
- syncTarget = sessionTeamOf(target);
882
- const receiptTarget = callback !== '' ? callback : caller;
883
- const auth = buildAuthorization(admission, { caller, proof: callerProof, ...(claim.ok ? { claim: { caller: claim.caller, origin: originOf(claim.callerUrl) } } : {}), ...(admission.rule === 'remote-ref-cli' && claim.ok ? { carrierUrl: originOf(claim.callerUrl) } : {}), ingress: 'wire', allowlistVersion: admittedAt.version, target: team, receiptTarget, receivedAt: admittedAt.at });
884
- taskLedger.trackInbound(taskId, team, receiptTarget, contextId, receiptExpectationOf(receiptTarget) !== 'process-label', briefOf(message), auth, syncTarget === team ? undefined : syncTarget);
885
- armReceiptAutosend(target, taskId, receiptTarget, message, floor);
886
- };
887
- void routeIntoAgent(team, message, caller, taskId, proofClause, directHeader, undefined, syncSteered).then(async (outcome) => {
888
- if (!outcome.ok && outcome.localMiss) {
889
- await forwardMiss(outcome.error);
890
- return;
891
- }
892
- recordActivity('in', team, caller, outcome.ok);
893
- // W7 slice-2 hook 1: record the settled outcome on the claim row.
894
- // Ruling: a non-COMPLETED status (TASK_STATE_DELIVERED — the
895
- // bridge-round deadline placeholder, ABORTED_WAIT likewise) is
896
- // NOT a settled outcome, and neither is a host-authored
897
- // placeholder on the reply channel (flush timeout, dead session)
898
- // — the row stays pending, because an adopted replay must never
899
- // settle a node on placeholder text (the exact hazard W7 gap B
900
- // closed, barred from resurrecting through the outcome ledger).
901
- if (taskId !== '') {
902
- if (!outcome.ok) {
903
- idempotencyStore.recordOutcome(taskId, { status: 'failed', error: outcome.error });
904
- }
905
- else if (outcome.placeholder === true) {
906
- // placeholder prose never enters the ledger
907
- }
908
- else if (outcome.status === undefined || outcome.status === 'TASK_STATE_COMPLETED') {
909
- idempotencyStore.recordOutcome(taskId, { status: 'completed', reply: outcome.reply });
910
- }
1025
+ });
1026
+ })().catch(error => {
1027
+ // EventEmitter ignores promises returned from body-end callbacks. Own this boundary.
1028
+ logger.warn('a2a: direct request callback failed: %s', String(error));
1029
+ if (!res.headersSent) {
1030
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1031
+ res.end(JSON.stringify({ error: 'direct request failed before confirmation', code: -32000, task_status: 'TASK_STATE_FAILED' }));
911
1032
  }
912
- const payload = outcome.ok
913
- ? JSON.stringify({
914
- routed: true,
915
- team,
916
- session,
917
- result: { text: outcome.reply },
918
- ...(syncTarget !== undefined ? { delivered_to: syncTarget } : {}),
919
- // #298: a positive, cross-host statement that this text is the member's own final reply — the turn
920
- // that read this task's steer (`ownTurn`), never a placeholder, a bridge round or another turn (#305 F3/F4).
921
- ...(outcome.ownTurn === true && outcome.placeholder !== true && outcome.bridge === undefined && (outcome.status ?? 'TASK_STATE_COMPLETED') === 'TASK_STATE_COMPLETED' ? { reply_final: true } : {}),
922
- task_id: taskId,
923
- context_id: contextId,
924
- task_status: outcome.status ?? 'TASK_STATE_COMPLETED',
925
- artifacts: [],
926
- // The bridge marker rides the wire so the caller's
927
- // trackOwedTask skips a receipt-less native round — without
928
- // it, a cross-node unsettled round books an unpayable owed
929
- // row and re-creates the TASK_CAP backlog.
930
- ...(outcome.bridge !== undefined ? { bridge: outcome.bridge } : {}),
931
- })
932
- : JSON.stringify({ error: outcome.error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
933
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
934
- res.end(payload);
1033
+ else if (!res.writableEnded)
1034
+ res.end();
935
1035
  });
936
1036
  });
937
1037
  },