@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.
- package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/cli/a2a.mjs +19 -3
- package/lib/a2a-client.js +10 -2
- package/lib/admitted-bookkeeping.js +7 -0
- package/lib/canvas-discovery.js +11 -1
- package/lib/canvas-member.js +43 -0
- package/lib/canvas-store.js +70 -4
- package/lib/direct-forward.js +9 -7
- package/lib/faces/admin.js +50 -7
- package/lib/faces/files.js +2 -1
- package/lib/faces/wire.js +566 -468
- package/lib/followup-delivery.js +89 -0
- package/lib/idempotency-store.js +8 -0
- package/lib/inbound-verification.js +23 -0
- package/lib/index.js +478 -156
- package/lib/nudge-observability.js +34 -0
- package/lib/origin-claim.js +36 -9
- package/lib/outbound-scope.js +4 -2
- package/lib/peer-directory.js +15 -5
- package/lib/peer-store.js +31 -17
- package/lib/queue-guard.js +4 -3
- package/lib/receipt-ladder.js +1 -1
- package/lib/receipt-retry.js +58 -0
- package/lib/reconciliation-reminders.js +148 -0
- package/lib/reconciliation-state.js +27 -0
- package/lib/session-transcript.js +46 -18
- package/lib/skill/SKILL.md +5 -0
- package/lib/skill/references/cli.md +4 -1
- package/lib/skill/references/routing.md +31 -1
- package/lib/task-auth.js +3 -3
- package/lib/task-delivery.js +23 -0
- package/lib/task-ledger-persistence.js +51 -0
- package/lib/task-ledger-rows.js +5 -0
- package/lib/task-ledger.js +219 -20
- package/lib/tools/info.js +37 -7
- package/lib/types/a2a-client.d.ts +1 -1
- package/lib/types/admitted-bookkeeping.d.ts +5 -0
- package/lib/types/canvas-member.d.ts +10 -0
- package/lib/types/canvas-store.d.ts +8 -0
- package/lib/types/faces/admin.d.ts +19 -0
- package/lib/types/faces/files.d.ts +1 -1
- package/lib/types/faces/wire.d.ts +21 -14
- package/lib/types/followup-delivery.d.ts +25 -0
- package/lib/types/idempotency-store.d.ts +2 -0
- package/lib/types/inbound-verification.d.ts +14 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/nudge-observability.d.ts +17 -0
- package/lib/types/origin-claim.d.ts +7 -1
- package/lib/types/outbound-scope.d.ts +1 -0
- package/lib/types/peer-store.d.ts +4 -0
- package/lib/types/queue-guard.d.ts +9 -2
- package/lib/types/receipt-ladder.d.ts +2 -0
- package/lib/types/receipt-retry.d.ts +22 -0
- package/lib/types/reconciliation-reminders.d.ts +41 -0
- package/lib/types/reconciliation-state.d.ts +32 -0
- package/lib/types/session-transcript.d.ts +6 -1
- package/lib/types/task-auth.d.ts +7 -0
- package/lib/types/task-delivery.d.ts +17 -0
- package/lib/types/task-ledger-persistence.d.ts +3 -0
- package/lib/types/task-ledger-rows.d.ts +19 -2
- package/lib/types/task-ledger.d.ts +48 -1
- package/lib/types/tools/info.d.ts +3 -0
- package/lib/types/types.d.ts +3 -0
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +10 -2
- package/nexus-stage/src/member-provenance-picker.ts +13 -0
- package/nexus-stage/src/planning-member-handles.ts +4 -1
- package/nexus-stage/src/planning-view.ts +3 -3
- package/nexus-stage/src/workbench.ts +28 -6
- package/nexus-stage/src/world.ts +1 -0
- package/package.json +3 -2
- 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
|
-
|
|
207
|
-
|
|
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',
|
|
441
|
-
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
body
|
|
453
|
-
if (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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:
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
//
|
|
689
|
-
// the
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
recordActivity('in', team, caller,
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
|
|
806
|
-
|
|
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 (
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
915
|
-
|
|
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
|
},
|