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