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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -0
  2. package/README_zh.md +1 -0
  3. package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
  4. package/assets/nexusDist/index.html +1 -1
  5. package/cli/a2a.mjs +43 -7
  6. package/lib/a2a-client.js +10 -2
  7. package/lib/admitted-bookkeeping.js +7 -0
  8. package/lib/canvas-discovery.js +11 -1
  9. package/lib/canvas-member.js +43 -0
  10. package/lib/canvas-store.js +315 -17
  11. package/lib/client.js +62 -72
  12. package/lib/client.js.map +1 -1
  13. package/lib/config.js +1 -0
  14. package/lib/direct-forward.js +9 -7
  15. package/lib/faces/admin.js +150 -67
  16. package/lib/faces/files.js +2 -1
  17. package/lib/faces/wire.js +569 -469
  18. package/lib/followup-delivery.js +89 -0
  19. package/lib/idempotency-store.js +8 -0
  20. package/lib/inbound-verification.js +23 -0
  21. package/lib/index.js +534 -166
  22. package/lib/nudge-observability.js +34 -0
  23. package/lib/origin-claim.js +36 -9
  24. package/lib/outbound-scope.js +4 -2
  25. package/lib/peer-directory.js +15 -5
  26. package/lib/peer-store.js +31 -17
  27. package/lib/queue-guard.js +4 -3
  28. package/lib/receipt-ladder.js +1 -1
  29. package/lib/receipt-retry.js +58 -0
  30. package/lib/reconciliation-reminders.js +148 -0
  31. package/lib/reconciliation-state.js +27 -0
  32. package/lib/session-transcript.js +46 -18
  33. package/lib/skill/SKILL.md +5 -0
  34. package/lib/skill/references/cli.md +4 -1
  35. package/lib/skill/references/routing.md +31 -1
  36. package/lib/task-auth.js +3 -3
  37. package/lib/task-delivery.js +23 -0
  38. package/lib/task-ledger-persistence.js +51 -0
  39. package/lib/task-ledger-rows.js +5 -0
  40. package/lib/task-ledger.js +219 -20
  41. package/lib/tools/info.js +37 -7
  42. package/lib/types/a2a-client.d.ts +1 -1
  43. package/lib/types/admitted-bookkeeping.d.ts +5 -0
  44. package/lib/types/canvas-member.d.ts +10 -0
  45. package/lib/types/canvas-store.d.ts +56 -1
  46. package/lib/types/config.d.ts +2 -0
  47. package/lib/types/faces/admin.d.ts +21 -0
  48. package/lib/types/faces/files.d.ts +1 -1
  49. package/lib/types/faces/wire.d.ts +21 -14
  50. package/lib/types/followup-delivery.d.ts +25 -0
  51. package/lib/types/idempotency-store.d.ts +2 -0
  52. package/lib/types/inbound-verification.d.ts +14 -0
  53. package/lib/types/index.d.ts +5 -0
  54. package/lib/types/nudge-observability.d.ts +17 -0
  55. package/lib/types/origin-claim.d.ts +7 -1
  56. package/lib/types/outbound-scope.d.ts +1 -0
  57. package/lib/types/peer-store.d.ts +4 -0
  58. package/lib/types/queue-guard.d.ts +9 -2
  59. package/lib/types/receipt-ladder.d.ts +2 -0
  60. package/lib/types/receipt-retry.d.ts +22 -0
  61. package/lib/types/reconciliation-reminders.d.ts +41 -0
  62. package/lib/types/reconciliation-state.d.ts +32 -0
  63. package/lib/types/session-transcript.d.ts +6 -1
  64. package/lib/types/task-auth.d.ts +7 -0
  65. package/lib/types/task-delivery.d.ts +17 -0
  66. package/lib/types/task-ledger-persistence.d.ts +3 -0
  67. package/lib/types/task-ledger-rows.d.ts +19 -2
  68. package/lib/types/task-ledger.d.ts +48 -1
  69. package/lib/types/tools/info.d.ts +3 -0
  70. package/lib/types/types.d.ts +3 -0
  71. package/nexus-stage/src/federation.ts +1 -0
  72. package/nexus-stage/src/main.ts +17 -2
  73. package/nexus-stage/src/member-provenance-picker.ts +13 -0
  74. package/nexus-stage/src/planning-member-handles.ts +4 -1
  75. package/nexus-stage/src/planning-view.ts +3 -3
  76. package/nexus-stage/src/workbench.ts +51 -7
  77. package/nexus-stage/src/world.ts +1 -0
  78. package/package.json +3 -2
  79. package/assets/nexusDist/assets/index-NkNSeIx6.js +0 -9
package/lib/index.js CHANGED
@@ -30,7 +30,8 @@ export function resolveCallerLabel(input) {
30
30
  }
31
31
  import { encodePublicKey, signCard, verifyCard } from "./card.js";
32
32
  import { GroupStore } from "./group-store.js";
33
- import { CanvasStore, canvasNameReserved, isRemoteMemberRef } from "./canvas-store.js";
33
+ import { CanvasStore, canvasNameReserved, isRemoteMemberRef, legacyBareMember } from "./canvas-store.js";
34
+ import { splitQualifiedMember, memberProvenance, qualifiedSessionMember } from "./canvas-member.js";
34
35
  import { decodeSessionLog, extractTranscript, TRANSCRIPT_MAX_FILE_BYTES } from "./session-transcript.js";
35
36
  import { LayoutStore } from "./layout-store.js";
36
37
  import { peerLinkDisplay } from "./peer-link-display.js";
@@ -47,7 +48,11 @@ import { PeerStore } from "./peer-store.js";
47
48
  import { SelfReferralFilter } from "./self-suppress.js";
48
49
  import { TaskLedger, SUMMARY_CAP, briefOf } from "./task-ledger.js";
49
50
  import { formatReceipt, parseReceipt, taskIdRefusal } from "./receipt.js";
51
+ import { ReconciliationReminders } from "./reconciliation-reminders.js";
52
+ import { REMINDER_SCAN_MS, reminderView } from "./reconciliation-state.js";
53
+ import { AdmittedBookkeepingError, ADMITTED_BOOKKEEPING_NOTE } from "./admitted-bookkeeping.js";
50
54
  import { runReceiptLadder } from "./receipt-ladder.js";
55
+ import { receiptFailure, retryReceiptDelivery, transientReceiptFailure, receiptRetryWait } from "./receipt-retry.js";
51
56
  import { IdempotencyStore, WIRE_ERROR_IDEMPOTENCY_CONFLICT, WIRE_ERROR_REPLAY_REJECTED, peerPayloadFingerprint } from "./idempotency-store.js";
52
57
  import { createFloodGuard } from "./flood-guard.js";
53
58
  import { createQueueGuard, queueFullRefusal, QUEUE_FULL_NEXT, WIRE_ERROR_QUEUE_FULL } from "./queue-guard.js";
@@ -55,8 +60,9 @@ import { resolveZone } from "./zone.js";
55
60
  import { registerA2aNetworkSkill } from "./skill-registration.js";
56
61
  import { isRootOriginUrl, sameMountedUrl, originClaimTeamWarning, signOriginClaim, verifyOriginClaim as verifyOriginClaimOf } from "./origin-claim.js";
57
62
  import { AuthObserver } from "./auth-observer.js";
58
- import { allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
63
+ import { claimCovers, allowlistVersion, authorizationRef, buildAuthorization, callerAdmission, localCandidateAdmission, canonicalAllowlist, judgeSettlement, joinedFaceAdmission, refusalOf, sameZoneAdmission, recheckRemoteCliAuthorization } from "./task-auth.js";
59
64
  import { MessageId } from '@deepseek-ai/dsh-llm';
65
+ import { FollowupDelivery } from "./followup-delivery.js";
60
66
  import { SessionId } from '@deepseek-ai/dsh-session';
61
67
  import { liveRootAgents } from "./live-roots.js";
62
68
  import { A2aClient } from "./a2a-client.js";
@@ -85,6 +91,7 @@ export const inject = ['tools', 'timer', 'systemPrompt'];
85
91
  import { Config } from "./config.js";
86
92
  import { canvasOwnerFingerprint, splitCanvasOwner } from "./canvas-id.js";
87
93
  import { forwardReceiptBasis } from "./direct-forward.js";
94
+ import { NudgeObservability } from "./nudge-observability.js";
88
95
  export { Config };
89
96
  /**
90
97
  * Register the outbound peer tools, the direct-route endpoint, and (when
@@ -481,7 +488,7 @@ export function apply(ctx, config) {
481
488
  };
482
489
  // Graceful disposal lands the debounced peer state on disk before a
483
490
  // restart reads it back (fiber teardown awaits effect disposers).
484
- ctx.effect(() => () => peerStore.flush());
491
+ ctx.effect(() => () => peerStore.close());
485
492
  // Slice 3: an unset session derives from a per-home node id, and the
486
493
  // configured delegations become signed zone records on every card.
487
494
  const session = config.session === '' ? deriveSession(home) : config.session;
@@ -505,8 +512,9 @@ export function apply(ctx, config) {
505
512
  */
506
513
  function originClaimHeaders(target) {
507
514
  // No local web/card anchor yet: keep the preexisting absent-envelope migration posture, not a malformed Sig3.
508
- const signedTarget = ownCardUrl === '' ? { url: target.url, method: target.method } : target;
509
- return signOriginClaim(signedTarget, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
515
+ if (ownCardUrl === '')
516
+ return {};
517
+ return signOriginClaim(target, { caller: config.team, callerUrl: ownCardUrl, privateKey: nodePrivateKey, unbound: config.originClaimUnboundWindow !== false, now: Date.now() });
510
518
  }
511
519
  const client = new A2aClient({
512
520
  apiKey: config.apiKey,
@@ -597,7 +605,7 @@ export function apply(ctx, config) {
597
605
  // Canvas teams: user-composed multi-member routing groups. A session node
598
606
  // may sit in many teams; routing to <team>/canvas/<name> resolves the
599
607
  // first live member or wakes the first cold one (member order = priority).
600
- const canvasStore = new CanvasStore(join(home, 'a2a', 'canvas.json'));
608
+ const canvasStore = new CanvasStore(join(home, 'a2a', 'canvas.json'), { legacyBareMembers: config.canvasLegacyBareMembers !== false, ownPublicKey: encodePublicKey(createPublicKey(nodePrivateKey)) });
601
609
  // #91 file channel: the sender host's blob store (docs/design/file-channel.md).
602
610
  const blobStore = new BlobStore(join(home, 'a2a'));
603
611
  // Spatial presentation state for the full-page stage (contract v2).
@@ -606,6 +614,7 @@ export function apply(ctx, config) {
606
614
  // queryable here, and the correlating `[A2A receipt] task <id>` message
607
615
  // resolves it — the caller-side half of the receipt contract, persisted so
608
616
  // a restart does not orphan the reconciliation.
617
+ const nudgeObservations = new NudgeObservability(message => logger.warn(message));
609
618
  const taskLedger = new TaskLedger(join(home, 'a2a', 'tasks.json'), { staleTtlMs: config.taskStaleTtlMs, warn: message => logger.warn(message) });
610
619
  const enforceBlockers = taskLedger.settlementEnforceBlockers();
611
620
  const enforceRefused = config.settlementAuth === 'enforce' && enforceBlockers.length > 0 ? { reason: 'legacy forwarded canvas debts lack receiver facts', blockers: enforceBlockers } : undefined;
@@ -640,7 +649,14 @@ export function apply(ctx, config) {
640
649
  if (name === undefined)
641
650
  return undefined;
642
651
  // Canvas members by session id and by handle, as the faces store them.
643
- return canvasStore.membersOf(name).flatMap(m => (m.includes('/') ? [m] : [m, `${config.team}/${id8(m)}`]));
652
+ return canvasStore.membersOf(name).flatMap(m => {
653
+ const qualified = splitQualifiedMember(m);
654
+ if (qualified) {
655
+ const local = localHandleResolver()(m);
656
+ return local !== undefined || settler.claimKey === canvasStore.ownerKeyOf(m) ? [m, qualified.handle] : [m];
657
+ }
658
+ return m.includes('/') ? [m] : [m, `${config.team}/${id8(m)}`];
659
+ });
644
660
  },
645
661
  ...(parsedForJudge?.envelope?.onBehalfOf !== undefined ? { onBehalfOf: parsedForJudge.envelope.onBehalfOf } : {}),
646
662
  echo,
@@ -676,18 +692,6 @@ export function apply(ctx, config) {
676
692
  // must not be recorded as one. First-write wins inside the store, so a
677
693
  // late receipt never overwrites a precise sync-hook record.
678
694
  const parsed = parsedForJudge;
679
- if (parsed !== null && parsed.taskId !== '') {
680
- const line = parsed.summary.trim();
681
- // #114 S4 (design §4.2.2): under enforce, the receipt hook writes the
682
- // outcome a /a2a/query answers only for a row THIS ledger owes and
683
- // whose settler is on the chain — anyone sending a receipt line could
684
- // otherwise rewrite another caller's answer.
685
- const row = judge?.enforce === true ? taskLedger.find(parsed.taskId) : undefined;
686
- const allowed = judge?.enforce !== true || (row !== undefined && judge.check(row, false).ok);
687
- if (line !== '' && allowed) {
688
- idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
689
- }
690
- }
691
695
  // A header that names an owed row and comes back unresolved was refused
692
696
  // (enforce): that is the verdict — the echo fallback must not judge the
693
697
  // same message a second time.
@@ -696,7 +700,19 @@ export function apply(ctx, config) {
696
700
  // #139 (design §4.4): an enforce refusal of the row the header names is
697
701
  // told to the sender on the wire response, not only in /state.
698
702
  const headerVerdict = judge?.enforce === true && owedRow !== undefined ? judge.check(owedRow, false) : undefined;
703
+ // Capture authority while the owed row is still present; successful resolve archives it.
704
+ const outcomeAllowed = judge?.enforce !== true || (owedRow !== undefined && headerVerdict?.ok === true);
699
705
  const resolved = taskLedger.resolveFromMessage(message, judge);
706
+ if (parsed !== null && parsed.taskId !== '') {
707
+ const line = parsed.summary.trim();
708
+ // #114 S4 (design §4.2.2): under enforce, the receipt hook writes the
709
+ // outcome a /a2a/query answers only for a row THIS ledger owes and
710
+ // whose settler is on the chain — anyone sending a receipt line could
711
+ // otherwise rewrite another caller's answer.
712
+ if (line !== '' && outcomeAllowed) {
713
+ idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
714
+ }
715
+ }
700
716
  if (resolved === undefined && owedByHeader) {
701
717
  return headerVerdict !== undefined && !headerVerdict.ok ? { status: 'refused', reason: headerVerdict.reason, task_id: parsed.taskId } : undefined;
702
718
  }
@@ -741,11 +757,18 @@ export function apply(ctx, config) {
741
757
  function settleOwnInbound(message, creditor, payer) {
742
758
  const parsed = parseReceipt(message);
743
759
  if (parsed === null || parsed.taskId === '')
744
- return;
745
- const settled = taskLedger.resolveFromMessage(message, settlementJudgeFor(payer, parsed, true), row => row.direction === 'inbound' && row.peer === `local:${creditor}`);
746
- const line = parsed.summary.trim();
747
- if (settled !== undefined && line !== '')
748
- idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
760
+ return true;
761
+ try {
762
+ const settled = taskLedger.resolveFromMessage(message, settlementJudgeFor(payer, parsed, true), row => row.direction === 'inbound' && row.peer === `local:${creditor}`);
763
+ const line = parsed.summary.trim();
764
+ if (settled !== undefined && line !== '')
765
+ idempotencyStore.recordOutcome(parsed.taskId, { status: 'completed', reply: line.slice(0, SUMMARY_CAP) });
766
+ return true;
767
+ }
768
+ catch {
769
+ logger.warn('a2a: own inbound settlement commit failed; task remains owed');
770
+ return false;
771
+ }
749
772
  }
750
773
  // Server-side idempotency keys (work-order P3): a caller-born task id
751
774
  // executes at most once inside the 24h window — same-key replays answer
@@ -753,7 +776,7 @@ export function apply(ctx, config) {
753
776
  const idempotencyStore = new IdempotencyStore(join(home, 'a2a', 'idempotency.json'));
754
777
  const refuseIfFlooded = createFloodGuard(idempotencyStore, recordActivity);
755
778
  const ownTask = (taskId) => taskLedger.list().some(r => r.taskId === taskId) || taskLedger.archive().some(r => r.taskId === taskId);
756
- const refuseIfQueueFull = createQueueGuard(() => taskLedger.list(), () => config.targetQueueCap, ownTask, idempotencyStore, recordActivity);
779
+ const refuseIfQueueFull = createQueueGuard(() => taskLedger.list(), () => config.targetQueueCap, ownTask, idempotencyStore, recordActivity, id => reminderView(taskLedger.reminderState(id)));
757
780
  // Unmasked-identity suppression: gossip mirrors peer lists back and forth,
758
781
  // so a URL whose fetch just proved it serves this node — or an already
759
782
  // tracked node (an alias spelling) — keeps coming home as an inbound
@@ -918,7 +941,13 @@ export function apply(ctx, config) {
918
941
  for (const id of joinedSessions.list()) {
919
942
  if (!isArchived(id))
920
943
  continue;
921
- const ownHandle = ownCanvasHandleOf(id, true);
944
+ try {
945
+ revokeCanvasMember(id, 'archive');
946
+ }
947
+ catch (error) {
948
+ logger.error('canvas archive suspension failed: %s', String(error));
949
+ continue;
950
+ }
922
951
  sessionNodes.delete(id);
923
952
  // Sections come back at the next mount (re-publish) if this archived reading was transient; the row stays.
924
953
  nodeBinder.suspend(id);
@@ -928,9 +957,6 @@ export function apply(ctx, config) {
928
957
  // joined session matches"). Safety without the deletion: wake refuses
929
958
  // archived intents (:804), card assembly and the state cold rows filter
930
959
  // them, the reconciler skips them, and JOIN_CAP bounds the file.
931
- canvasStore.dropMember(id);
932
- if (ownHandle !== undefined)
933
- canvasStore.dropMember(ownHandle);
934
960
  // S2 consistency: an archived node's roster declarations die with it
935
961
  // — a left node keeps no team membership.
936
962
  teamMemberships.dropSession(id);
@@ -961,7 +987,13 @@ export function apply(ctx, config) {
961
987
  continue;
962
988
  if (sessionNodes.has(id) || joinedSessions.has(id))
963
989
  continue;
964
- canvasStore.removeMember(name, id);
990
+ try {
991
+ canvasStore.removeMember(name, id);
992
+ }
993
+ catch (error) {
994
+ logger.error('canvas stale-member prune failed (%s/%s): %s', name, id, String(error));
995
+ continue;
996
+ }
965
997
  logger.info(`a2a: canvas team "${name}" dropped stale member ${id8(id)} (no live node, no join intent)`);
966
998
  }
967
999
  }
@@ -1004,7 +1036,7 @@ export function apply(ctx, config) {
1004
1036
  * @param includeArchived - count archived sessions too (the archive prune
1005
1037
  * drops an archived session's own handle).
1006
1038
  */
1007
- function localHandleResolver(includeArchived = false) {
1039
+ function localHandleResolver(includeArchived = false, count = false, legacyOverride = false) {
1008
1040
  const isArchived = includeArchived ? undefined : archivedSessionFilter();
1009
1041
  const byHandle = new Map();
1010
1042
  for (const id of new Set([...joinedSessions.list(), ...sessionNodes.keys()])) {
@@ -1021,7 +1053,27 @@ export function apply(ctx, config) {
1021
1053
  published.add(row.team);
1022
1054
  }
1023
1055
  return (ref) => {
1056
+ const qualified = splitQualifiedMember(ref);
1057
+ if (qualified !== undefined) {
1058
+ const ownKey = encodePublicKey(createPublicKey(nodePrivateKey));
1059
+ if (memberProvenance(ref, canvasStore.ownerKeyOf(ref), ownKey) !== 'confirmed-local')
1060
+ return undefined;
1061
+ const hits = byHandle.get(qualified.handle);
1062
+ return hits?.length === 1 ? hits[0] : undefined;
1063
+ }
1064
+ if (!legacyOverride && config.canvasLegacyBareMembers === false)
1065
+ return undefined;
1024
1066
  const hits = byHandle.get(ref);
1067
+ const collision = (hits?.length ?? 0) > 1 || (hits?.length === 1 && published.has(ref));
1068
+ if (count && legacyBareMember(ref)) {
1069
+ legacyInferenceResolutions++;
1070
+ if (collision) {
1071
+ legacyCollisionWarnings++;
1072
+ if (legacyCollisionRecent.length >= 16)
1073
+ legacyCollisionRecent.shift();
1074
+ legacyCollisionRecent.push({ member: ref, at: Date.now() });
1075
+ }
1076
+ }
1025
1077
  return hits?.length === 1 && !published.has(ref) ? hits[0] : undefined;
1026
1078
  };
1027
1079
  }
@@ -1032,6 +1084,34 @@ export function apply(ctx, config) {
1032
1084
  * handle-stored membership must not outlive the session's consent).
1033
1085
  * @param id - the local session id (still joined when this is read).
1034
1086
  */
1087
+ const canvasMigrationEpoch = new Date().toISOString();
1088
+ let legacyInferenceResolutions = 0, legacyCollisionWarnings = 0;
1089
+ const legacyCollisionRecent = [];
1090
+ function revokeCanvasMember(id, reason, name) {
1091
+ if (!joinedSessions.has(id) && !sessionNodes.has(id))
1092
+ return false;
1093
+ const bare = config.team + '/' + id8(id);
1094
+ const inferred = localHandleResolver(true, false, true)(bare) === id ? bare : undefined;
1095
+ const qualified = bare + '@' + ownCanvasFingerprint;
1096
+ const sameId8 = new Set([...joinedSessions.list(), ...sessionNodes.keys()].filter(local => id8(local) === id8(id)));
1097
+ const pinned = canvasStore.ownerKeyOf(qualified);
1098
+ const proof = sameId8.size === 1 && (pinned === undefined || pinned === encodePublicKey(createPublicKey(nodePrivateKey))) ? qualified : undefined;
1099
+ return canvasStore.revokeLocal(id, proof, inferred, reason, name);
1100
+ }
1101
+ function canvasMigrationState() {
1102
+ const raw = canvasStore.list().flatMap(name => canvasStore.rawMembersOf(name).filter(legacyBareMember).map(member => ({ name, member })));
1103
+ const resolve = localHandleResolver(false, false);
1104
+ return { canvasLegacyBareMembers: config.canvasLegacyBareMembers !== false, epoch: canvasMigrationEpoch,
1105
+ legacyBareMembers: raw.length,
1106
+ legacyInferredMembers: raw.filter(r => canvasStore.membersOf(r.name).includes(r.member) && resolve(r.member) !== undefined).length,
1107
+ legacyInferenceResolutions, legacyCollisionWarnings, legacyCollisionRecent: [...legacyCollisionRecent],
1108
+ suspendedMembersCount: new Set(canvasStore.state().suspendedMembers.map(r => JSON.stringify([r.team, r.member]))).size,
1109
+ memberSlots: canvasStore.list().flatMap(name => canvasStore.rawMembersOf(name).map(member => {
1110
+ const provenance = memberProvenance(member, canvasStore.ownerKeyOf(member), encodePublicKey(createPublicKey(nodePrivateKey)));
1111
+ return { name, member, ownerKey: canvasStore.ownerKeyOf(member) ?? null, provenance, suspended: canvasStore.isSuspended(name, member), compatibilityInference: provenance === 'unknown' && canvasStore.membersOf(name).includes(member) && resolve(member) !== undefined };
1112
+ })),
1113
+ ...canvasStore.state() };
1114
+ }
1035
1115
  function ownCanvasHandleOf(id, includeArchived = false) {
1036
1116
  const handle = `${config.team}/${id8(id)}`;
1037
1117
  return localHandleResolver(includeArchived)(handle) === id ? handle : undefined;
@@ -1045,12 +1125,8 @@ export function apply(ctx, config) {
1045
1125
  * canvas a handle-stored membership puts the session in.
1046
1126
  */
1047
1127
  function canvasTeamsOfSession(id) {
1048
- const teams = new Set(canvasStore.teamsOf(id));
1049
- const own = ownCanvasHandleOf(id);
1050
- if (own !== undefined)
1051
- for (const name of canvasStore.teamsOf(own))
1052
- teams.add(name);
1053
- return [...teams];
1128
+ const resolve = localHandleResolver();
1129
+ return canvasStore.list().filter(name => canvasStore.membersOf(name).some(ref => ref === id || resolve(ref) === id));
1054
1130
  }
1055
1131
  /**
1056
1132
  * gh#131: a canvas team's members as the local routing tiers read them —
@@ -1059,8 +1135,8 @@ export function apply(ctx, config) {
1059
1135
  * reading that depends on which peer cards are cached must not rewrite
1060
1136
  * the store (review of PR #151 P2-2).
1061
1137
  */
1062
- function canvasLocalMembersOf(name) {
1063
- const resolve = localHandleResolver();
1138
+ function canvasLocalMembersOf(name, count = false) {
1139
+ const resolve = localHandleResolver(false, count);
1064
1140
  return canvasStore.membersOf(name).map(id => (isRemoteMemberRef(id) ? resolve(id) ?? id : id));
1065
1141
  }
1066
1142
  /** The 8-char suffix of one agent's id. */
@@ -1111,7 +1187,7 @@ export function apply(ctx, config) {
1111
1187
  function canvasLiveAgent(name, skipId) {
1112
1188
  if (name === undefined)
1113
1189
  return undefined;
1114
- for (const id of canvasLocalMembersOf(name)) {
1190
+ for (const id of canvasLocalMembersOf(name, true)) {
1115
1191
  if (id === skipId)
1116
1192
  continue;
1117
1193
  const agent = sessionNodes.get(id);
@@ -1131,7 +1207,7 @@ export function apply(ctx, config) {
1131
1207
  if (name === undefined)
1132
1208
  return undefined;
1133
1209
  const isArchived = archivedSessionFilter();
1134
- return canvasLocalMembersOf(name).find(id => id !== skipId && joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
1210
+ return canvasLocalMembersOf(name, true).find(id => id !== skipId && joinedSessions.has(id) && !liveRoots.has(id) && isArchived?.(id) !== true);
1135
1211
  }
1136
1212
  /**
1137
1213
  * Human-facing node facts for one session: the session title (fallback:
@@ -1585,7 +1661,7 @@ export function apply(ctx, config) {
1585
1661
  at: now,
1586
1662
  rows: all
1587
1663
  .filter(row => row.local !== true)
1588
- .map(row => ({ team: row.team, name: row.name, ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.via !== undefined ? { via: row.via } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}), ...(row.legacy === true ? { legacy: true } : {}), ...(row.unreachable === true ? { unreachable: true } : {}), ...(row.teams !== undefined && row.teams.length > 0 ? { teams: [...row.teams] } : {}) })),
1664
+ .map(row => ({ team: row.team, name: row.name, ...(row.memberRef !== undefined ? { memberRef: row.memberRef } : {}), ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.via !== undefined ? { via: row.via } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}), ...(row.legacy === true ? { legacy: true } : {}), ...(row.unreachable === true ? { unreachable: true } : {}), ...(row.teams !== undefined && row.teams.length > 0 ? { teams: [...row.teams] } : {}) })),
1589
1665
  };
1590
1666
  })
1591
1667
  .catch(() => { })
@@ -1596,7 +1672,9 @@ export function apply(ctx, config) {
1596
1672
  whenWebServerSettled((webServer) => {
1597
1673
  // gh#77 B1: the admin faces live in src/faces/admin.ts.
1598
1674
  registerAdminFaces(webServer, {
1599
- ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
1675
+ reminderDiagnostics: () => ({ ...idleReminders.diagnostics }),
1676
+ nudgeEscalations: () => nudgeObservations.snapshot(),
1677
+ ctx, config, logger, home, session, lanIp, PLUGIN_VERSION, controlRoute, controlTrustedWithoutKey, readJsonBody, peerFetch, peerStore, peerLinkState, originClaimHeaders, refreshRemoteRows, joinedSessions, exposureStore, teamMemberships, cliDeposits, groupStore, canvasStore, revokeCanvasMember, canvasMigrationState, canvasMemberForWrite, cliHomeForAdd, cliHomeOptions, layoutStore, taskLedger, idempotencyStore, inFlightRoutes, recentActivity, teamAuditRing, reportRing, sessionNodes, liveRoots, mountSessionNode, coldJoinedIds, prewarmStatus, reconcileStatus, id8, sessionLabelOf, sessionTeamOf, ownCanvasHandleOf, localSessionOfHandle, canvasTeamsOfSession, nodeMetadataOf, canvasTeamOf, archivedSessionFilter, pruneArchivedJoins, pruneCanvasMemberships, verifyOriginClaim, callerLinkedToThisHost, cachedCardFetch, authorizationView,
1600
1678
  indexUrl: import.meta.url, enforceRefused,
1601
1679
  get cardCache() { return cardCache; },
1602
1680
  get remoteRowsCache() { return remoteRowsCache; },
@@ -1604,6 +1682,8 @@ export function apply(ctx, config) {
1604
1682
  get unboundInboundCount() { return unboundInboundCount; },
1605
1683
  get boundMissInboundCount() { return boundMissInboundCount; },
1606
1684
  get boundMissRecent() { return boundMissRecent; },
1685
+ get claimRejectsInboundCount() { return claimRejectsInboundCount; },
1686
+ get claimRejectsRecent() { return claimRejectsRecent; },
1607
1687
  get ownCardUrl() { return ownCardUrl; }, ownCanvasFingerprint,
1608
1688
  get ownerSeatNames() { return ownerSeatNames; },
1609
1689
  set ownerSeatNames(v) { ownerSeatNames = v; },
@@ -1637,7 +1717,7 @@ export function apply(ctx, config) {
1637
1717
  // roster: the team-roster face (a2a_team_join/leave, card
1638
1718
  // teamMemberships, team-scope routing admission) ships — peers read
1639
1719
  // its absence as a pre-roster legacy node (0.5.43 and older).
1640
- capabilities: { route: true, async: true, roster: true },
1720
+ capabilities: { route: true, async: true, roster: true, canvasMemberProvenance: true, sourcedFollowup: true, queuedTaskCancellation: true },
1641
1721
  expiresAt: Date.now() + config.cardTtlMs,
1642
1722
  ...(records.length > 0 ? { records: [...records] } : {}),
1643
1723
  });
@@ -1808,6 +1888,45 @@ export function apply(ctx, config) {
1808
1888
  * so ui-chat renders the relay body ("来自 X" + full text) instead of an
1809
1889
  * opaque row; omitted when the sender is not meaningfully addressable.
1810
1890
  */
1891
+ const followupDelivery = new FollowupDelivery({
1892
+ ledger: taskLedger,
1893
+ taint: (target, localFrom) => { if (localFrom === undefined || nodeTaint.of(localFrom) !== '')
1894
+ markLineage(target, 'relay'); },
1895
+ warn: code => logger.warn('a2a: sourced followup (%s); check ledger persistence and original task', code),
1896
+ });
1897
+ const followupRelay = (agent, text, sender, localFrom, taskId, callerKey, taskExpected = true) => followupDelivery.deliver(agent, text, sender, localFrom, taskId, callerKey, taskExpected);
1898
+ ctx.on('agent/inbox/claimed', ({ agent, message }) => followupDelivery.claimed(agent, String(message.id)));
1899
+ const withdrawQueuedTask = (taskId) => {
1900
+ const controller = ctx.get('sessionController');
1901
+ return followupDelivery.withdraw(taskId, controller, id => ctx.get('agents')?.get(SessionId(id)));
1902
+ };
1903
+ const cancelQueuedTask = async (taskId, text, sender, original) => {
1904
+ let record, withdrawFailed = false, removed = false;
1905
+ try {
1906
+ record = taskLedger.deliveryFor(taskId);
1907
+ removed = await withdrawQueuedTask(taskId) === 'removed';
1908
+ }
1909
+ catch (error) {
1910
+ withdrawFailed = true;
1911
+ logger.warn('a2a: queue withdrawal failed; sending immediate cancellation notice: %s', String(error));
1912
+ }
1913
+ if (removed) {
1914
+ taskLedger.recordWithdrawalResult(taskId, 'removed');
1915
+ return { removed: true, steered: false, withdrawalResult: 'removed' };
1916
+ }
1917
+ const row = original ?? taskLedger.find(taskId);
1918
+ const receiverId = record?.sessionId ?? row?.receiverSessionId;
1919
+ const agent = receiverId ? ctx.get('agents')?.get(SessionId(receiverId)) : row ? (resolveAgentForTeam(row.deliveredTo ?? row.team) ?? canvasLiveAgent(parseCanvasTeamName(row.team))) : undefined;
1920
+ if (!agent) {
1921
+ taskLedger.recordWithdrawalResult(taskId, 'no-agent');
1922
+ return { removed: false, steered: false, withdrawalResult: 'no-agent' };
1923
+ }
1924
+ const notice = cancelNoticeText(taskId);
1925
+ steerRelay(agent, text.includes('If this task reaches you later from the queue, do not execute it.') ? text : notice, sender);
1926
+ const withdrawalResult = withdrawFailed ? 'withdraw-failed-steered' : 'claimed-steered';
1927
+ taskLedger.recordWithdrawalResult(taskId, withdrawalResult);
1928
+ return { removed: false, steered: true, withdrawalResult };
1929
+ };
1811
1930
  function steerRelay(agent, text, sender, localFrom) {
1812
1931
  // N4/gh#198: a relay steer carries content from outside this host — unless it is an in-process delivery from a clean local session (localFrom), whose taint the target then takes.
1813
1932
  if (localFrom === undefined || nodeTaint.of(localFrom) !== '')
@@ -1822,7 +1941,7 @@ export function apply(ctx, config) {
1822
1941
  /** The cooperative stop-notice text, shared by the tasks/cancel control
1823
1942
  * route and the transport face's cancel half (one contract, one wording). */
1824
1943
  function cancelNoticeText(taskId, reason) {
1825
- return `[A2A cancel] (task ${taskId}) the caller cancelled this task${reason !== undefined && reason !== '' ? `: ${reason}` : ''}. Stop any work tied to it and acknowledge via [A2A receipt] task ${taskId} cancelled.`;
1944
+ return `[A2A cancel] (task ${taskId}) the caller cancelled this task${reason !== undefined && reason !== '' ? `: ${reason}` : ''}. This task has been cancelled. If this task reaches you later from the queue, do not execute it. Stop any work tied to it and acknowledge via [A2A receipt] task ${taskId} cancelled.`;
1826
1945
  }
1827
1946
  /**
1828
1947
  * v0.5.23 (async-stall): whether the steered agent actually left idle. A
@@ -1887,20 +2006,23 @@ export function apply(ctx, config) {
1887
2006
  const escalateQueuePrompt = (agent, team, taskId) => {
1888
2007
  // Queue-mode prompt seam (the session controller owns prompt admission); #183: it stamps kind:'user', so nudgeRef stands in for the caller's task id.
1889
2008
  const controller = ctx.get('sessionController');
1890
- if (typeof controller?.prompt !== 'function')
2009
+ if (typeof controller?.prompt !== 'function') {
2010
+ nudgeObservations.note('unavailable', team, nudgeRef(taskId));
1891
2011
  return;
2012
+ }
2013
+ idleReminders.noteNudge(String(agent.id));
1892
2014
  markLineage(agent, 'nudge');
1893
- logger.info(`a2a: async nudge escalates to queue-mode prompt for ${team} (task ${taskId}, ref ${nudgeRef(taskId)})`);
2015
+ logger.info(`a2a: async nudge escalates to queue-mode prompt for ${team} (ref ${nudgeRef(taskId)})`);
1894
2016
  try {
1895
2017
  void controller.prompt({
1896
2018
  requestId: `a2a-nudge-${nudgeRef(taskId)}`,
1897
2019
  sessionId: agent.id,
1898
2020
  mode: 'queue',
1899
2021
  content: [{ type: 'text', text: `[A2A nudge] (ref ${nudgeRef(taskId)}) an earlier A2A delivery reached this session while it could not start a turn — please consume the inbox backlog now (ignore if there is nothing pending).` }],
1900
- }, new AbortController().signal).catch(() => { });
2022
+ }, new AbortController().signal).then(result => { const reply = result; nudgeObservations.note(reply?.accepted === false || reply?.ok === false ? 'failed' : 'queued', team, nudgeRef(taskId)); }, error => nudgeObservations.note('failed', team, nudgeRef(taskId), error));
1901
2023
  }
1902
- catch {
1903
- /* queueing is best-effort: the ledger row keeps the debt visible */
2024
+ catch (error) {
2025
+ nudgeObservations.note('failed', team, nudgeRef(taskId), error);
1904
2026
  }
1905
2027
  };
1906
2028
  function armAsyncNudge(agent, team, taskId, delayMs) {
@@ -1914,6 +2036,10 @@ export function apply(ctx, config) {
1914
2036
  nudgeBudget.delete(key);
1915
2037
  return;
1916
2038
  }
2039
+ if (!idleReminders.nudgeAllowed(key)) {
2040
+ armAsyncNudge(agent, team, taskId, Math.max(delayMs, 120_000));
2041
+ return;
2042
+ }
1917
2043
  if (agent.status !== 'idle') {
1918
2044
  // Busy on its own turn (or wedged): spend the bounded re-arm, then
1919
2045
  // climb to the queue-mode seam. Re-resolve on the steer path below
@@ -1937,6 +2063,7 @@ export function apply(ctx, config) {
1937
2063
  // afresh.
1938
2064
  const fresh = resolveAgentForTeam(team) ?? agent;
1939
2065
  logger.info(`a2a: async nudge re-waking ${team} (task ${taskId}, ref ${nudgeRef(taskId)}, delivered but not consumed)`);
2066
+ idleReminders.noteNudge(String(fresh.id));
1940
2067
  steerRelay(fresh, `[A2A nudge] (ref ${nudgeRef(taskId)}) an earlier A2A delivery reached this session while it could not start a turn — please consume the inbox backlog now.`);
1941
2068
  }, delayMs);
1942
2069
  ctx.effect(() => () => clearTimeout(timer), `a2a: async nudge ${taskId}`);
@@ -1954,6 +2081,34 @@ export function apply(ctx, config) {
1954
2081
  * receipts themselves are never tracked as new debts.
1955
2082
  */
1956
2083
  const receiptWarnAt = new Map();
2084
+ const reminderAgents = () => {
2085
+ const service = ctx.get('agents');
2086
+ return service ? liveRootAgents(service).filter(a => archivedSessionFilter()?.(String(a.id)) !== true) : [];
2087
+ };
2088
+ const exactReminderTarget = (row) => {
2089
+ const live = reminderAgents();
2090
+ if (row.receiverSessionId)
2091
+ return live.find(a => String(a.id) === row.receiverSessionId);
2092
+ // Legacy concrete face only; never reassign a canvas/process slot to its current roster.
2093
+ const face = row.deliveredTo ?? row.team;
2094
+ if (face.includes('/canvas/') || face === config.team)
2095
+ return undefined;
2096
+ const matches = live.filter(a => sessionTeamOf(a) === face);
2097
+ return matches.length === 1 ? matches[0] : undefined;
2098
+ };
2099
+ const idleReminders = new ReconciliationReminders({
2100
+ ledger: taskLedger, agents: reminderAgents, resolve: exactReminderTarget,
2101
+ sender: row => {
2102
+ if (row.peer !== 'local' || row.sender?.kind !== 'session')
2103
+ return undefined;
2104
+ const matches = reminderAgents().filter(a => sessionTeamOf(a) === row.sender.id);
2105
+ return matches.length === 1 ? matches[0] : undefined;
2106
+ },
2107
+ followup: (agent, text) => followupRelay(agent, text, config.team),
2108
+ warn: code => logger.warn('a2a: idle reconciliation reminder failed (%s)', code),
2109
+ });
2110
+ const requestReminderScan = () => { queueMicrotask(() => idleReminders.scan()); };
2111
+ ctx.effect(() => { const timer = ctx.timer.interval(() => idleReminders.scan(), REMINDER_SCAN_MS); requestReminderScan(); return () => { idleReminders.stop(); timer(); }; }, 'a2a: persistent idle reconciliation reminders');
1957
2112
  function armReceiptAutosend(target, taskId, callerTeam, deliveredText, steerFloor) {
1958
2113
  if (!callerTeam || callerTeam === '')
1959
2114
  return;
@@ -2008,19 +2163,17 @@ export function apply(ctx, config) {
2008
2163
  // closure would silently no-op there — runReceiptLadder reads a
2009
2164
  // settled undefined as tier-1 success, so the receipt would vanish
2010
2165
  // without a log.
2011
- void runReceiptLadder({
2012
- deliver: async (team) => {
2013
- // gh#217: across hosts the caller admits a session face it linked, never this host's process label; a
2014
- // task for the bare process team keeps the label, the only host face the settlement judge accepts (N1).
2015
- const peerCaller = row?.auth?.target.includes('/') === true ? sessionTeamOf(target) : undefined;
2016
- const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true, undefined, peerCaller);
2017
- // gh#141: the caller lane pays this delivery's inbound row across hosts (a
2018
- // same-host receipt already settled it in-process), unless the caller
2019
- // refused it. The owner tier is no payment here — but an owner mailbox
2020
- // on THIS host settles the row in-process, as it always has.
2021
- if (team === callerTeam && delivered.settlementRefused !== true)
2022
- settleOwnInbound(receipt, callerTeam, { caller: session, proof: 'in-process', ingress: 'local' });
2023
- },
2166
+ const transportId = 'r-' + createHash('sha256').update(taskId + '\0' + callerTeam + '\0' + receipt).digest('base64url').slice(0, 12);
2167
+ const deliverReceipt = async (team) => {
2168
+ const peerCaller = row?.auth?.target.includes('/') === true ? sessionTeamOf(target) : undefined;
2169
+ const delivered = await dispatchAnywhere(team, receipt, session, undefined, new AbortController().signal, true, transportId, peerCaller);
2170
+ if (team === callerTeam && delivered.settlementRefused !== true && !settleOwnInbound(receipt, callerTeam, { caller: session, proof: 'in-process', ingress: 'local' }))
2171
+ throw receiptFailure({ error: 'task ledger write failed after receipt delivery', code: -32011 });
2172
+ };
2173
+ /* Legacy permanent-failure ladder remains the owner-settlement policy. */
2174
+ const ladder = () => runReceiptLadder({
2175
+ deliver: deliverReceipt,
2176
+ transient: transientReceiptFailure,
2024
2177
  ownerTeam: config.team,
2025
2178
  log: (stage, error) => {
2026
2179
  // N2 (observability): throttled per task to avoid ping-pong noise.
@@ -2030,9 +2183,13 @@ export function apply(ctx, config) {
2030
2183
  receiptWarnAt.set(key, Date.now());
2031
2184
  logger.warn(`a2a: receipt autosend ${stage} for task ${taskId} failed: ${String(error).slice(0, 120)}`);
2032
2185
  },
2033
- }, callerTeam).catch(() => {
2034
- // Ladder exhausted: the throttled breadcrumbs above already said why.
2035
- });
2186
+ }, callerTeam);
2187
+ const wait = (ms) => receiptRetryWait(ctx, ms);
2188
+ void retryReceiptDelivery({
2189
+ attempt: () => ladder(),
2190
+ notify: async () => { await dispatchAnywhere(config.team, '[A2A receipt delivery notice] ref ' + nudgeRef(taskId) + '; delivery to the original caller is temporarily unconfirmed; host retry is responsible. This notice is not a receipt and does not settle work.', session, undefined, new AbortController().signal, true); },
2191
+ pending: () => row === undefined || taskLedger.isPending(taskId), wait, log: () => { logger.warn('a2a: receipt delivery temporarily unconfirmed ref=%s', nudgeRef(taskId)); },
2192
+ }).catch(() => { });
2036
2193
  }, { taskId, steerFloor });
2037
2194
  }
2038
2195
  /**
@@ -2065,15 +2222,15 @@ export function apply(ctx, config) {
2065
2222
  * marker instead of a (false) completion.
2066
2223
  * @param taskId - the correlation key the steered receipt header carries.
2067
2224
  */
2068
- async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2225
+ async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey) {
2069
2226
  const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
2070
2227
  if (agent !== undefined) {
2071
- return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered);
2228
+ return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey);
2072
2229
  }
2073
2230
  // Wake-on-route: a cold joined team materializes on demand, then steers.
2074
2231
  const wake = wakeColdTeam(team);
2075
2232
  if (wake.kind === 'flight') {
2076
- return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2233
+ return wake.flight.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
2077
2234
  }
2078
2235
  // Native-teams inbound bridge (opt-in): a registry team claims the
2079
2236
  // handle and dispatches through its authoritative seam. Checked before
@@ -2102,7 +2259,7 @@ export function apply(ctx, config) {
2102
2259
  if (live !== undefined) {
2103
2260
  if (callerIsAgent(live, caller))
2104
2261
  return { ok: false, error: selfRouteText(team) };
2105
- return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered);
2262
+ return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered, callerKey);
2106
2263
  }
2107
2264
  return { ok: false, error: 'No live DSH agent is available to accept this message.' };
2108
2265
  }
@@ -2117,11 +2274,13 @@ export function apply(ctx, config) {
2117
2274
  * @param caller - caller label for the steered prefix.
2118
2275
  * @returns the agent's final reply, or the explicit failure.
2119
2276
  */
2120
- function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
2277
+ function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey) {
2121
2278
  return new Promise((resolve) => {
2122
2279
  // #306: synchronous replies use the same reading-turn attribution as automatic receipts.
2123
2280
  // Registering and steering are synchronous, so this is the exact pre-steer floor.
2124
2281
  const floorAtRegistration = sessionEventsOf(agent).length;
2282
+ let admissionFinished = false;
2283
+ let earlyReply;
2125
2284
  const waiter = registerFinalWaiter(agent, (text, placeholder) => {
2126
2285
  // Preserve #305's reply_final contract: only this task's own completed reading turn qualifies.
2127
2286
  let ownTurn = false;
@@ -2131,7 +2290,11 @@ export function apply(ctx, config) {
2131
2290
  const window = read === undefined ? undefined : readingTurnReply(events, read);
2132
2291
  ownTurn = window !== undefined && window.completed && window.reply !== '' && window.reply === text;
2133
2292
  }
2134
- resolve({ ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}), ...(ownTurn ? { ownTurn: true } : {}) });
2293
+ const result = { ok: true, reply: text, ...(placeholder === true ? { placeholder: true } : {}), ...(ownTurn ? { ownTurn: true } : {}) };
2294
+ if (admissionFinished)
2295
+ resolve(result);
2296
+ else
2297
+ earlyReply = result;
2135
2298
  }, taskId !== undefined && taskId !== '' ? { taskId, steerFloor: floorAtRegistration } : undefined);
2136
2299
  // The header names the sender first: `caller` is the routing node's
2137
2300
  // own label (the session that issued the route), while `team` is this
@@ -2146,8 +2309,11 @@ export function apply(ctx, config) {
2146
2309
  const text = `${headerLine}\n\n${message}`;
2147
2310
  try {
2148
2311
  const floor = sessionEventsOf(agent).length;
2149
- steerRelay(agent, text, caller === '' ? undefined : caller, localFrom);
2312
+ followupRelay(agent, text, caller === '' ? undefined : caller, localFrom, taskId, callerKey, onSteered !== undefined);
2150
2313
  onSteered?.(agent, floor);
2314
+ admissionFinished = true;
2315
+ if (earlyReply)
2316
+ resolve(earlyReply);
2151
2317
  if (onSteered !== undefined && sessionEventsOf(agent).slice(floor).some(event => event?.type === 'assistant/message'))
2152
2318
  queueMicrotask(() => flushFinals(String(agent.id))); // synchronous final may already have emitted idle
2153
2319
  }
@@ -2155,13 +2321,17 @@ export function apply(ctx, config) {
2155
2321
  // The waiter was registered above, so the key always holds it; the
2156
2322
  // map-of-arrays stays a plain read.
2157
2323
  const key = String(agent.id);
2158
- const entries = pendingFinals.get(key);
2324
+ const entries = pendingFinals.get(key) ?? [];
2159
2325
  const kept = entries.filter(entry => entry !== waiter);
2160
2326
  if (kept.length === 0)
2161
2327
  pendingFinals.delete(key);
2162
2328
  else
2163
2329
  pendingFinals.set(key, kept);
2164
2330
  waiter.timeoutDisposer?.();
2331
+ if (error instanceof AdmittedBookkeepingError) {
2332
+ resolve({ ok: true, reply: ADMITTED_BOOKKEEPING_NOTE, receiverBookkeeping: 'failed', placeholder: true });
2333
+ return;
2334
+ }
2165
2335
  resolve({ ok: false, error: `The DSH session rejected the message: ${String(error)}` });
2166
2336
  }
2167
2337
  });
@@ -2191,6 +2361,23 @@ export function apply(ctx, config) {
2191
2361
  boundMissRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
2192
2362
  boundMissRecent.length = Math.min(boundMissRecent.length, 16);
2193
2363
  };
2364
+ /**
2365
+ * Envelopes that were present but failed verification, by claimed caller, claimed URL and reason —
2366
+ * `bad-ts` is a sender clock off by more than ±5 min, `no-card`/`bad-sig` a key or reachability fault.
2367
+ * Observation only: the faces' refusals are unchanged. Caller fields are the sender's unverified
2368
+ * header values (capped), so read them as claims, not identities. Counts verification attempts.
2369
+ */
2370
+ let claimRejectsInboundCount = 0;
2371
+ const claimRejectsRecent = [];
2372
+ const noteClaimReject = (req, reason) => {
2373
+ claimRejectsInboundCount += 1;
2374
+ const header = (name) => String(req.headers[name] ?? '').slice(0, 200);
2375
+ const caller = header('x-a2a-caller'), callerUrl = header('x-a2a-caller-url');
2376
+ const at = claimRejectsRecent.findIndex(row => row.caller === caller && row.callerUrl === callerUrl && row.reason === reason);
2377
+ const row = at >= 0 ? claimRejectsRecent.splice(at, 1)[0] : { caller, callerUrl, reason, count: 0, lastAt: 0 };
2378
+ claimRejectsRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
2379
+ claimRejectsRecent.length = Math.min(claimRejectsRecent.length, 16);
2380
+ };
2194
2381
  /** Our configured seeds anchor origin claims; mounted claims require exact mount matching. */
2195
2382
  function configuredSeedCaller(callerUrl) {
2196
2383
  return config.peers.some(peer => {
@@ -2208,6 +2395,7 @@ export function apply(ctx, config) {
2208
2395
  const claim = await verifyOriginClaimOf(req, {
2209
2396
  cardCache,
2210
2397
  fetch: fetchImpl,
2398
+ freshCard: true,
2211
2399
  audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
2212
2400
  unboundOpen: config.originClaimUnboundWindow !== false,
2213
2401
  onUnbound: () => { unboundInboundCount += 1; },
@@ -2217,6 +2405,8 @@ export function apply(ctx, config) {
2217
2405
  ...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
2218
2406
  ...(options?.rawBody !== undefined ? { rawBody: options.rawBody } : {}),
2219
2407
  });
2408
+ if (!claim.ok && claim.reason !== 'absent')
2409
+ noteClaimReject(req, claim.reason);
2220
2410
  // #312: recover bilateral seed evidence from a signed, reduction-only card,
2221
2411
  // never from a caller-selected unseeded URL and never recursively.
2222
2412
  const discovery = new URL(req.url ?? '/', 'http://x').searchParams.get('discovery') === '1';
@@ -2336,6 +2526,9 @@ export function apply(ctx, config) {
2336
2526
  for (const ref of canvasStore.remoteMembersOf(name)) {
2337
2527
  if (ref === caller)
2338
2528
  return true;
2529
+ const qualified = splitQualifiedMember(ref);
2530
+ if (qualified?.handle === caller && callerCard?.publicKey === canvasStore.ownerKeyOf(ref))
2531
+ return true;
2339
2532
  }
2340
2533
  }
2341
2534
  // Basis 3, BILATERAL (review M1 fix): OUR config.peers seeds a URL
@@ -2401,14 +2594,14 @@ export function apply(ctx, config) {
2401
2594
  memberships: () => teamMemberships.list(),
2402
2595
  joined: () => joinedSessions.list(),
2403
2596
  canvasNames: () => canvasStore.list(),
2404
- canvasTeamsOf: sessionId => canvasStore.teamsOf(sessionId),
2405
- canvasMembersOf: name => canvasStore.membersOf(name),
2597
+ canvasTeamsOf: sessionId => canvasTeamsOfSession(sessionId),
2598
+ canvasMembersOf: name => canvasLocalMembersOf(name),
2406
2599
  canvasRemoteMembersOf: name => canvasStore.remoteMembersOf(name),
2407
2600
  peerOrgs: function* () {
2408
2601
  for (const cached of cardCache.values()) {
2409
2602
  const org = cached.card?.canvasOrg;
2410
2603
  if (Array.isArray(org))
2411
- yield org;
2604
+ yield org.map(row => ({ ...row, members: Array.isArray(row.members) ? row.members.map((ref) => splitQualifiedMember(ref) && localHandleResolver()(ref) !== undefined ? splitQualifiedMember(ref).handle : ref) : row.members }));
2412
2605
  }
2413
2606
  },
2414
2607
  liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
@@ -2425,8 +2618,43 @@ export function apply(ctx, config) {
2425
2618
  return verdict === undefined ? undefined : refusalOf(verdict);
2426
2619
  }
2427
2620
  /** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
2428
- function a2aCallerAdmission(caller, targetTeam, cliProof) {
2429
- return callerAdmission(admissionRecords, caller, targetTeam, cliProof);
2621
+ function a2aCallerAdmission(caller, targetTeam, cliProof, signerKey) {
2622
+ const ownKey = encodePublicKey(createPublicKey(nodePrivateKey));
2623
+ const members = (name) => canvasStore.membersOf(name).flatMap(ref => {
2624
+ const qualified = splitQualifiedMember(ref);
2625
+ if (!qualified)
2626
+ return [ref];
2627
+ const provenance = memberProvenance(ref, canvasStore.ownerKeyOf(ref), ownKey);
2628
+ if (provenance === 'confirmed-local') {
2629
+ const local = localHandleResolver()(ref);
2630
+ return local ? [local, qualified.handle] : [];
2631
+ }
2632
+ return provenance === 'confirmed-remote' && signerKey === canvasStore.ownerKeyOf(ref) ? [qualified.handle] : [];
2633
+ });
2634
+ const foreignSigner = signerKey !== undefined && signerKey !== ownKey;
2635
+ const records = { ...admissionRecords,
2636
+ callerJoined: () => admissionRecords.joined().filter(id => !foreignSigner || config.team + '/' + id8(id) !== caller),
2637
+ callerMemberships: () => admissionRecords.memberships().filter(row => !foreignSigner || config.team + '/' + id8(row.session) !== caller),
2638
+ callerCanvasMembersOf: (name) => {
2639
+ const effective = new Set(members(name));
2640
+ return canvasStore.membersOf(name).flatMap(ref => {
2641
+ const qualified = splitQualifiedMember(ref);
2642
+ if (qualified?.handle === caller && foreignSigner && canvasStore.ownerKeyOf(ref) !== signerKey)
2643
+ return [];
2644
+ if (effective.has(ref))
2645
+ return [ref];
2646
+ return qualified && effective.has(qualified.handle) ? [qualified.handle] : [];
2647
+ });
2648
+ },
2649
+ canvasMembersOf: members,
2650
+ canvasRemoteMembersOf: (name) => canvasStore.remoteMembersOf(name).flatMap(ref => {
2651
+ const qualified = splitQualifiedMember(ref);
2652
+ if (!qualified)
2653
+ return [ref];
2654
+ return canvasStore.ownerKeyOf(ref) === signerKey && memberProvenance(ref, canvasStore.ownerKeyOf(ref), ownKey) === 'confirmed-remote' ? [qualified.handle] : [];
2655
+ }),
2656
+ };
2657
+ return callerAdmission(records, caller, targetTeam, cliProof);
2430
2658
  }
2431
2659
  function a2aCallerRefusal(caller, targetTeam) {
2432
2660
  return refusalOf(a2aCallerAdmission(caller, targetTeam));
@@ -2566,7 +2794,7 @@ export function apply(ctx, config) {
2566
2794
  logger,
2567
2795
  config,
2568
2796
  cliDeposits,
2569
- canvasStore,
2797
+ canvasStore, canvasMemberForWrite,
2570
2798
  canvasNameReserved: name => canvasNameReserved(name, config.team, ownerAllowlist, teamMemberships),
2571
2799
  sessionNodes,
2572
2800
  joinedSessions,
@@ -2587,6 +2815,18 @@ export function apply(ctx, config) {
2587
2815
  scanSessionLogList,
2588
2816
  readSessionLogTail,
2589
2817
  taskLedger,
2818
+ receiptReplayAllowed: (message, caller, team, claim) => {
2819
+ const parsed = parseReceipt(message);
2820
+ if (!parsed || !claim.bound || claim.bodyBound !== true || !claimCovers(caller, claim.caller) || !isRootOriginUrl(claim.callerUrl))
2821
+ return false;
2822
+ const row = taskLedger.archive().find(r => r.taskId === parsed.taskId);
2823
+ if (!row || row.endedBy !== 'receipt' || row.peerKey !== claim.card.publicKey)
2824
+ return false;
2825
+ const creditor = row.auth?.receipt_target ?? row.sender?.id;
2826
+ if (creditor !== team)
2827
+ return false;
2828
+ return judgeSettlement(row, { caller, proof: 'origin-claim', claimKey: claim.card.publicKey, claimOrigin: originOf(claim.callerUrl), ingress: 'wire' }, { hostLabel: session, membersOf: () => undefined, ...(parsed.envelope?.onBehalfOf ? { onBehalfOf: parsed.envelope.onBehalfOf } : {}) }).ok;
2829
+ },
2590
2830
  refuseIfFlooded,
2591
2831
  refuseIfQueueFull,
2592
2832
  settleAndAnnounce, waitForForwardBirth,
@@ -2599,7 +2839,7 @@ export function apply(ctx, config) {
2599
2839
  probeConsumption,
2600
2840
  sessionTeamOf,
2601
2841
  localSessionOfHandle,
2602
- steerRelay,
2842
+ steerRelay, followupRelay, cancelQueuedTask,
2603
2843
  fromClause,
2604
2844
  sanitizeFromClause,
2605
2845
  armReceiptAutosend,
@@ -2650,28 +2890,42 @@ export function apply(ctx, config) {
2650
2890
  path: '/__dsh_a2a/tasks/cancel',
2651
2891
  handler: controlRoute((req, res) => {
2652
2892
  readJsonBody(req, res, (body) => {
2653
- const fields = body;
2654
- const taskId = typeof fields.task_id === 'string' ? fields.task_id : '';
2655
- const reason = typeof fields.reason === 'string' ? fields.reason : undefined;
2656
- const result = taskLedger.cancel(taskId, reason);
2657
- let steered = false;
2658
- if (result.outcome === 'cleared' && result.team !== undefined) {
2659
- const live = resolveAgentForTeam(result.team)
2660
- ?? canvasLiveAgent(parseCanvasTeamName(result.team))
2661
- ?? (result.team === config.team ? liveAgent() : undefined);
2662
- if (live !== undefined) {
2663
- try {
2664
- steerRelay(live, cancelNoticeText(taskId, reason));
2665
- steered = true;
2666
- }
2667
- catch {
2668
- // Bookkeeping already settled; steering is best-effort only.
2893
+ void (async () => {
2894
+ const fields = body;
2895
+ const taskId = typeof fields.task_id === 'string' ? fields.task_id : '';
2896
+ const reason = typeof fields.reason === 'string' ? fields.reason : undefined;
2897
+ const row = taskLedger.find(taskId);
2898
+ const result = taskLedger.cancel(taskId, reason);
2899
+ let removed = false, steered = false;
2900
+ try {
2901
+ if (row && result.outcome === 'cleared') {
2902
+ const notice = cancelNoticeText(taskId, reason);
2903
+ if (row.peer === 'local' || row.direction === 'inbound') {
2904
+ const notification = await cancelQueuedTask(taskId, notice, session, row);
2905
+ removed = notification.removed;
2906
+ steered = notification.steered;
2907
+ }
2908
+ else {
2909
+ const card = await cachedCardFetch(row.peer);
2910
+ const supportsQueuedCancel = card?.capabilities?.queuedTaskCancellation === true;
2911
+ const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, supportsQueuedCancel ? (row.sender?.kind === 'session' ? row.sender.id : row.auth?.caller ?? session) : session, true, undefined, undefined, undefined, supportsQueuedCancel ? taskId : undefined);
2912
+ if (!result.ok)
2913
+ throw Error('remote queued cancellation was not confirmed');
2914
+ }
2669
2915
  }
2670
2916
  }
2917
+ catch (error) {
2918
+ logger.warn('a2a: cancellation committed; stop notice was best-effort: %s', String(error));
2919
+ }
2920
+ const payload = JSON.stringify({ ...result, steered, removed });
2921
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2922
+ res.end(payload);
2923
+ })().catch(() => { if (!res.headersSent) {
2924
+ res.writeHead(503, { 'Content-Type': 'application/json' });
2925
+ res.end(JSON.stringify({ error: 'queued task cancellation could not be committed' }));
2671
2926
  }
2672
- const payload = JSON.stringify({ ...result, steered });
2673
- res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
2674
- res.end(payload);
2927
+ else if (!res.writableEnded)
2928
+ res.end(); });
2675
2929
  });
2676
2930
  }),
2677
2931
  }), 'a2a: task cancel route');
@@ -2909,6 +3163,14 @@ export function apply(ctx, config) {
2909
3163
  if (!linked && handles.size > 0 && zone !== '') {
2910
3164
  outer: for (const name of canvasStore.list()) {
2911
3165
  for (const ref of canvasStore.remoteMembersOf(name)) {
3166
+ const qualified = splitQualifiedMember(ref);
3167
+ if (qualified) {
3168
+ if (cached?.publicKey === canvasStore.ownerKeyOf(ref) && handles.has(qualified.handle)) {
3169
+ linked = true;
3170
+ break outer;
3171
+ }
3172
+ continue;
3173
+ }
2912
3174
  if (handles.has(ref)) {
2913
3175
  linked = true;
2914
3176
  break outer;
@@ -2933,6 +3195,51 @@ export function apply(ctx, config) {
2933
3195
  return { url, card, linked: card === undefined || peerAdmissionLinkState(url).link === 'linked' };
2934
3196
  });
2935
3197
  }
3198
+ async function canvasMemberForWrite(ref, requestedPeer, editorCard) {
3199
+ if (CLI_HANDLE_RE.test(ref))
3200
+ return { ok: true, member: ref };
3201
+ const selected = splitQualifiedMember(ref), handle = selected?.handle ?? ref;
3202
+ if (!ref.includes('/')) {
3203
+ if (editorCard)
3204
+ return { ok: false, error: 'peer writes must use owner-qualified session members; upgrade the editor' };
3205
+ return (joinedSessions.has(ref) || sessionNodes.has(ref)) && archivedSessionFilter()?.(ref) !== true ? { ok: true, member: ref } : { ok: false, error: 'local session must be joined and not archived' };
3206
+ }
3207
+ if (editorCard && !selected)
3208
+ return { ok: false, error: 'new bare session member refused; upgrade or confirm locally' };
3209
+ const ownKey = encodePublicKey(createPublicKey(nodePrivateKey));
3210
+ if (selected && memberProvenance(ref, canvasStore.ownerKeyOf(ref), ownKey) === 'confirmed-local') {
3211
+ const locals = [...new Set([...joinedSessions.list(), ...sessionNodes.keys()])].filter(id => config.team + '/' + id8(id) === handle && archivedSessionFilter()?.(id) !== true);
3212
+ if (locals.length === 1)
3213
+ return { ok: true, member: ref, ownerKey: ownKey };
3214
+ return { ok: false, error: 'local qualified member is not uniquely joined' };
3215
+ }
3216
+ const peers = [...new Set([...peerStore.listSeeded(), ...config.delegates.map(row => row.url)])];
3217
+ if (requestedPeer !== undefined && (typeof requestedPeer !== 'string' || !peers.includes(requestedPeer)))
3218
+ return { ok: false, error: 'select an existing linked seed/delegate owner' };
3219
+ const cards = [];
3220
+ for (const peer of peers) {
3221
+ const card = await fetchPeerCard(peer);
3222
+ if (!card || !verifyCard(card, Date.now()).ok)
3223
+ continue;
3224
+ cardCache.set(peer, { at: Date.now(), card });
3225
+ if (requestedPeer !== undefined && peer !== requestedPeer)
3226
+ continue;
3227
+ if (peerAdmissionLinkState(peer).link === 'linked')
3228
+ cards.push(card);
3229
+ }
3230
+ if (editorCard && verifyCard(editorCard, Date.now()).ok)
3231
+ cards.push(editorCard);
3232
+ if (selected && new Set(cards.filter(card => canvasOwnerFingerprint(card.publicKey) === selected.fingerprint).map(card => card.publicKey)).size > 1)
3233
+ return { ok: false, error: 'owner fingerprint collision; no member written' };
3234
+ const matching = cards.filter(card => handle.startsWith(card.team + '/') && /^[0-9a-f]{8}$/.test(handle.slice(card.team.length + 1)) && card.sessionTeams?.some(row => row.team === handle) && (!selected || canvasOwnerFingerprint(card.publicKey) === selected.fingerprint));
3235
+ const keys = [...new Set(matching.map(card => card.publicKey))];
3236
+ if (keys.length !== 1)
3237
+ return { ok: false, error: 'member owner is unconfirmed, hidden, offline or ambiguous; select a verifiable host' };
3238
+ const member = qualifiedSessionMember(handle, keys[0]);
3239
+ if (!member || (selected && member !== ref))
3240
+ return { ok: false, error: 'member fingerprint/key mismatch' };
3241
+ return { ok: true, member, ownerKey: keys[0] };
3242
+ }
2936
3243
  async function cliHomeForAdd(handle, requested) {
2937
3244
  const localHousehold = () => cliDeposits.directoryRows().some(row => row.team === handle);
2938
3245
  if (localHousehold())
@@ -3066,6 +3373,7 @@ export function apply(ctx, config) {
3066
3373
  };
3067
3374
  const SWEEP_CONCURRENCY = 6;
3068
3375
  const infoTools = createInfoTools({
3376
+ nudgeEscalations: () => nudgeObservations.snapshot(),
3069
3377
  ctx, config, logger, session, lanIp, get ownCardUrl() { return ownCardUrl; }, peerStore, taskLedger,
3070
3378
  inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
3071
3379
  teamMemberships, exposureStore, idempotencyStore, cardCache,
@@ -3077,7 +3385,7 @@ export function apply(ctx, config) {
3077
3385
  get ownerAllowlist() { return ownerAllowlist; },
3078
3386
  get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
3079
3387
  teamAuditRing, reportRing, a2aJoinGateRefusal, observeAuthorization,
3080
- settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver,
3388
+ settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver, canvasMigrationState,
3081
3389
  SWEEP_CONCURRENCY,
3082
3390
  });
3083
3391
  infoTools.registerWhoami();
@@ -3207,13 +3515,13 @@ export function apply(ctx, config) {
3207
3515
  * by task id and taken (deleted) by whoever dispatched.
3208
3516
  */
3209
3517
  const localBirths = new Map();
3210
- const localBirthOf = (team, callerSession, receiptTarget, deliveredTo) => {
3518
+ const localBirthOf = (team, callerSession, receiptTarget, deliveredTo, receiverSessionId) => {
3211
3519
  const admission = localCandidateAdmission(admissionRecords, callerSession, team);
3212
3520
  const caller = callerSession ?? session;
3213
3521
  const auth = admission.ok && caller !== ''
3214
3522
  ? buildAuthorization(admission, { caller, proof: 'in-process', ingress: 'local', allowlistVersion: allowlistVersionNow(), target: team, receiptTarget: receiptTarget ?? caller, receivedAt: Date.now() })
3215
3523
  : undefined;
3216
- return { ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
3524
+ return { receiverSessionId, ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
3217
3525
  };
3218
3526
  /**
3219
3527
  * #139: the peer facts a remote outbound row is born with — the signed
@@ -3232,12 +3540,13 @@ export function apply(ctx, config) {
3232
3540
  const trackOwedTask = (taskId, team, peer, result, callbackAddress, brief, sender, birth) => {
3233
3541
  if (!result.ok)
3234
3542
  return;
3543
+ birth = { ...birth, ...(typeof result.consumed === 'boolean' ? { consumed: result.consumed } : {}) };
3235
3544
  // Native-teams bridge rounds emit no A2A receipt in this slice: booking
3236
3545
  // them as receipt-owed would fill the owed book with rows no receipt can
3237
3546
  // ever settle (evicting genuinely receivable peer rows past TASK_CAP).
3238
3547
  if (result.bridge !== undefined)
3239
3548
  return;
3240
- if (result.task_status !== 'TASK_STATE_DELIVERED')
3549
+ if (result.task_status !== 'TASK_STATE_DELIVERED' && result.receiverBookkeeping !== 'failed')
3241
3550
  return;
3242
3551
  // Fix ② (ghost-debt stop): a delivered async dispatch with no routable
3243
3552
  // callback address can never be settled — register it receiptless
@@ -3359,9 +3668,10 @@ export function apply(ctx, config) {
3359
3668
  const senderScopeRecords = {
3360
3669
  zone: config.team, id8, teamsOf: id => teamMemberships.teamsOf(id), membersOf: team => teamMemberships.membersOf(team),
3361
3670
  canvasNames: () => canvasStore.list(), canvasMembers: name => canvasStore.membersOf(name), canvasTeam: canvasTeamOf,
3671
+ memberHandle: ref => splitQualifiedMember(ref)?.handle ?? ref,
3362
3672
  peerOrgs: function* () { for (const hit of cardCache.values())
3363
3673
  if (hit.card?.canvasOrg)
3364
- yield hit.card.canvasOrg; },
3674
+ yield hit.card.canvasOrg.map(row => ({ ...row, members: row.members.map(ref => splitQualifiedMember(ref) && localHandleResolver()(ref) !== undefined ? splitQualifiedMember(ref).handle : ref) })); },
3365
3675
  warmPeers: () => mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch),
3366
3676
  };
3367
3677
  async function routeTrunk(args, agent, signal, hostCaller) {
@@ -3384,14 +3694,35 @@ export function apply(ctx, config) {
3384
3694
  a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
3385
3695
  // #282 S2: an owner-qualified canvas id. Our own fingerprint is our canvas; a foreign one never
3386
3696
  // lands on a same-named local canvas, and the directory walk keeps only that owner.
3697
+ const requestedMember = splitQualifiedMember(String(args.team ?? '').trim());
3698
+ if (requestedMember && localHandleResolver()(String(args.team).trim()) !== undefined)
3699
+ args = { ...args, team: requestedMember.handle };
3387
3700
  const ownerQualified = splitCanvasOwner(String(args.team ?? '').trim());
3388
3701
  const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
3389
3702
  if (ownerQualified.owner !== undefined)
3390
3703
  args = { ...args, team: ownerQualified.team };
3391
- // A target-authored receipt attempt answers its own work even if scope/transport later refuses delivery.
3704
+ // A receipt may use only its own frozen return lane; local answer state is not delivery proof.
3392
3705
  const answer = parseReceipt(args.message);
3393
- if (agent !== undefined && answer !== null)
3394
- taskLedger.markAnswered(answer.taskId, sessionTeamOf(agent));
3706
+ const receiptRow = answer === null ? undefined : taskLedger.find(answer.taskId);
3707
+ if (agent !== undefined && answer !== null) {
3708
+ try {
3709
+ taskLedger.markAnswered(answer.taskId, sessionTeamOf(agent));
3710
+ }
3711
+ catch {
3712
+ return { ok: false, error: 'task ledger write failed; receipt was not sent', code: -32000, peer_content: false };
3713
+ }
3714
+ }
3715
+ const ownReceipt = agent !== undefined && receiptRow?.status === 'pending' && receiptRow.direction === 'inbound' && receiptRow.receiptExpected !== false && receiptRow.auth?.receipt_target === args.team && (receiptRow.deliveredTo ?? receiptRow.team) === sessionTeamOf(agent) && (receiptRow.receiverSessionId === undefined || receiptRow.receiverSessionId === String(agent.id));
3716
+ if (ownReceipt && receiptRow.auth?.proof === 'origin-claim' && receiptRow.auth.claim !== undefined) {
3717
+ const birthHome = receiptRow.auth.carrier_url ?? receiptRow.auth.claim.origin;
3718
+ const home = CLI_HANDLE_RE.test(args.team) ? canvasStore.cliHomeOf(args.team) ?? birthHome : birthHome;
3719
+ if (originOf(home) !== originOf(birthHome))
3720
+ return { ok: false, error: 'receipt return home no longer matches the task birth', code: -32000, receipt: 'none' };
3721
+ const result = await dispatchPeerCandidate(home, args.team, args.message, args.context_id, signal, sessionTeamOf(agent), true);
3722
+ if (result.ok && result.settlementRefused !== true && !settleOwnInbound(args.message, args.team, { caller: sessionTeamOf(agent), proof: 'in-process', ingress: 'local' }))
3723
+ return { ok: false, error: 'receipt reached the peer but local settlement commit failed; task remains owed', code: -32000, receipt: 'none' };
3724
+ return result;
3725
+ }
3395
3726
  // S3 (team-scoped routing, phase-2 default ON per the 2026-09-01 owner
3396
3727
  // ruling; set false to restore the open outbound seam): a session
3397
3728
  // caller must have declared membership in the target team before it
@@ -3483,7 +3814,7 @@ export function apply(ctx, config) {
3483
3814
  // named; the peer request carries the member handle it must resolve.
3484
3815
  const canvasName = foreignOwner !== undefined ? undefined : parseCanvasTeamName(args.team);
3485
3816
  // gh#131: one of this host's own handles is a local member, never a directory target.
3486
- const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined))(localHandleResolver()) : [];
3817
+ const remoteTargets = canvasName !== undefined ? ((resolve) => canvasStore.remoteMembersOf(canvasName).filter(ref => resolve(ref) === undefined && (memberProvenance(ref, canvasStore.ownerKeyOf(ref), encodePublicKey(createPublicKey(nodePrivateKey))) !== 'conflict')))(localHandleResolver()) : [];
3487
3818
  const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
3488
3819
  if (remoteTargets.length > 0) {
3489
3820
  failures.push(`local: canvas team "${args.team}" has no live or wakeable local member`);
@@ -3498,7 +3829,7 @@ export function apply(ctx, config) {
3498
3829
  const candidatePool = [];
3499
3830
  for (const target of targets) {
3500
3831
  for (const candidate of await directoryPeerCandidates(fetch, ownerQualified.owner !== undefined && target === args.team ? `${target}@${ownerQualified.owner}` : target, failures, scopedCanvasCaller)) {
3501
- candidatePool.push({ candidate, target });
3832
+ candidatePool.push({ candidate, target: splitQualifiedMember(target)?.handle ?? target });
3502
3833
  }
3503
3834
  }
3504
3835
  // Failover: try every candidate in order — an offline path falls back
@@ -3520,8 +3851,8 @@ export function apply(ctx, config) {
3520
3851
  if (result.ok || signal.aborted) {
3521
3852
  // A receipt the creditor refused (its enforce verdict) pays nothing here either (review of PR #149 P2-2).
3522
3853
  if (result.ok && routedReceipt) {
3523
- if (result.settlementRefused !== true)
3524
- settleOwnInbound(args.message, args.team, { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' });
3854
+ if (result.settlementRefused !== true && !settleOwnInbound(args.message, args.team, { caller: callerSession ?? '', proof: 'in-process', ingress: 'local' }))
3855
+ return { ok: false, error: 'receipt reached the peer but local settlement commit failed; task remains owed', code: -32000, receipt: 'none' };
3525
3856
  }
3526
3857
  else if (result.ok)
3527
3858
  trackOwedTask(taskId, args.team, candidate, result, callbackAddress, briefOf(args.message), sender, await peerBirth(fetch, candidate, target === args.team ? undefined : target));
@@ -3704,11 +4035,11 @@ export function apply(ctx, config) {
3704
4035
  const receiptHint = callerTeam === undefined ? '' : `\n\n(When done, route your outcome back with one call — a2a_route { team: "${callerTeam}", message: "[A2A receipt] task ${taskId} <one-line outcome>" }.)`;
3705
4036
  // #114 S5: the consumption search starts at the pre-steer log length.
3706
4037
  const steerFloor = sessionEventsOf(target).length;
3707
- steerRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId);
4038
+ followupRelay(target, `[A2A direct] (task ${taskId}) 未验证(in-process) ${sanitizeFromClause(callerSession ?? session)} (routed to ${team}) sent:\n\n${message}${receiptHint}`, callerTeam, originAgentId, taskId, undefined, parseReceipt(message) === null);
3708
4039
  armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
3709
4040
  // #139: the caller's outbound row is born with this admission and the member that received it.
3710
4041
  if (taskIdFromCaller !== undefined)
3711
- localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target)));
4042
+ localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target), String(target.id)));
3712
4043
  endRoute(flight);
3713
4044
  recordActivity('out', team, 'local', true);
3714
4045
  return {
@@ -3718,6 +4049,7 @@ export function apply(ctx, config) {
3718
4049
  task_id: taskId,
3719
4050
  context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
3720
4051
  task_status: 'TASK_STATE_DELIVERED',
4052
+ consumed: taskLedger.deliveryFor(taskId)?.state === 'claimed',
3721
4053
  };
3722
4054
  }
3723
4055
  catch (error) {
@@ -3900,7 +4232,7 @@ export function apply(ctx, config) {
3900
4232
  throw new Error(`CLI receipt home is not pinned for ${team}`);
3901
4233
  const delivered = await dispatchPeerCandidate(home, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
3902
4234
  if (!delivered.ok)
3903
- throw new Error(`CLI receipt to pinned home failed: ${delivered.error}`);
4235
+ throw receiptFailure(delivered);
3904
4236
  return delivered;
3905
4237
  }
3906
4238
  const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
@@ -3909,7 +4241,7 @@ export function apply(ctx, config) {
3909
4241
  localBirths.delete(taskId);
3910
4242
  if (local !== undefined) {
3911
4243
  if (!local.ok)
3912
- throw new Error(local.error);
4244
+ throw receiptFailure(local);
3913
4245
  return local;
3914
4246
  }
3915
4247
  if (cliReceipt)
@@ -3920,15 +4252,19 @@ export function apply(ctx, config) {
3920
4252
  throw new Error(`no route to "${team}": not a local team and not published by any tracked peer`);
3921
4253
  }
3922
4254
  let lastError = 'no candidate delivered';
4255
+ let receiptError;
3923
4256
  for (const candidate of candidates) {
3924
4257
  const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
3925
4258
  if (result.ok)
3926
4259
  return result;
3927
4260
  lastError = result.error;
4261
+ const failure = receiptFailure(result);
4262
+ if (receiptError === undefined || failure.transient)
4263
+ receiptError = failure;
3928
4264
  if (signal.aborted)
3929
4265
  break;
3930
4266
  }
3931
- throw new Error(`delivery to "${team}" failed on every candidate: ${lastError}`);
4267
+ throw receiptError ?? new Error(`delivery to "${team}" failed on every candidate: ${lastError}`);
3932
4268
  }
3933
4269
  /**
3934
4270
  * The directory's candidate walk for one team: direct publishers first,
@@ -3967,12 +4303,34 @@ export function apply(ctx, config) {
3967
4303
  // itself after the target). referralTeamResolution restores the legacy
3968
4304
  // whole-store basis for fleets mid-migration.
3969
4305
  // #282 S2: `<canvas>@<fp8>` selects only the owner whose signed card key matches; no delegation walk.
3970
- const qualified = splitCanvasOwner(team);
4306
+ if (!splitQualifiedMember(team)) {
4307
+ const qualifiedRefs = [...new Set(canvasStore.list().flatMap(name => canvasStore.membersOf(name)).filter(ref => splitQualifiedMember(ref)?.handle === team))];
4308
+ const keys = new Set(qualifiedRefs.map(ref => canvasStore.ownerKeyOf(ref)).filter(Boolean));
4309
+ if (keys.size > 1) {
4310
+ failures.push('ambiguous member owners; select an owner-qualified handle');
4311
+ return [];
4312
+ }
4313
+ if (qualifiedRefs.length === 1)
4314
+ team = qualifiedRefs[0];
4315
+ }
4316
+ const member = splitQualifiedMember(team);
4317
+ const storedMember = team;
4318
+ const qualified = member ? { team: member.handle, owner: member.fingerprint } : splitCanvasOwner(team);
4319
+ const ownerKey = member ? canvasStore.ownerKeyOf(storedMember) : undefined;
4320
+ if (member && !ownerKey) {
4321
+ failures.push('member provenance is unconfirmed; owner must confirm the full key first');
4322
+ return [];
4323
+ }
3971
4324
  team = qualified.team;
3972
- const ownerOk = (card) => qualified.owner === undefined || canvasOwnerFingerprint(card?.publicKey) === qualified.owner;
4325
+ const ownerOk = (card) => (qualified.owner === undefined || canvasOwnerFingerprint(card?.publicKey) === qualified.owner) && (member === undefined || card?.publicKey === ownerKey);
3973
4326
  const peers = resolutionPeers();
3974
4327
  const candidates = [];
3975
- const cards = (await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }))).map(entry => ownerOk(entry.card) ? entry : { peer: entry.peer, card: undefined });
4328
+ const fetched = await mapBounded(peers, CANDIDATE_CONCURRENCY, async (peer) => ({ peer, card: await fetch(peer) }));
4329
+ if (member && fetched.some(entry => entry.card && canvasOwnerFingerprint(entry.card.publicKey) === member.fingerprint && entry.card.publicKey !== ownerKey)) {
4330
+ failures.push('member owner fingerprint collision or key change; no payload sent');
4331
+ return [];
4332
+ }
4333
+ const cards = fetched.map(entry => ownerOk(entry.card) ? entry : { peer: entry.peer, card: undefined });
3976
4334
  // Check after fetching all cards: a seeded peer publishing the same id8
3977
4335
  // makes the caller handle ambiguous, so do not send a canvas payload.
3978
4336
  const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
@@ -3985,6 +4343,10 @@ export function apply(ctx, config) {
3985
4343
  for (const entry of cards) {
3986
4344
  if (entry.card === undefined)
3987
4345
  continue;
4346
+ if (member && entry.card.capabilities?.canvasMemberProvenance !== true) {
4347
+ failures.push('selected member host needs a provenance-capable upgrade');
4348
+ continue;
4349
+ }
3988
4350
  if (entry.card.team === team || sessionCandidateMatches(entry.card, team) || canvasCandidateMatches(entry.card, team, scopedCanvasCaller, resolveLocal))
3989
4351
  candidates.push(entry.peer);
3990
4352
  }
@@ -4066,7 +4428,7 @@ export function apply(ctx, config) {
4066
4428
  const card = await fetch(candidate);
4067
4429
  peerAsync = card?.capabilities?.async === true;
4068
4430
  }
4069
- const result = await dispatchPeerCandidate(candidate, splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
4431
+ const result = await dispatchPeerCandidate(candidate, splitQualifiedMember(request.handle)?.handle ?? splitCanvasOwner(request.handle).team, request.message, request.contextId, abort, session, request.delivery === 'async' && peerAsync, taskId, callbackAddress);
4070
4432
  if (!result.ok) {
4071
4433
  // Idempotency verdicts (B3) are terminal, not failover: a replay
4072
4434
  // says the prior attempt at THIS peer stays authoritative —
@@ -4162,45 +4524,31 @@ export function apply(ctx, config) {
4162
4524
  },
4163
4525
  cancel: async (ref, reason) => {
4164
4526
  const taskId = ref.taskId;
4165
- if (taskId === undefined || taskId === '')
4527
+ if (!taskId)
4166
4528
  return false;
4167
4529
  const row = taskLedger.list().find(entry => entry.taskId === taskId);
4168
- if (row === undefined)
4530
+ if (!row)
4169
4531
  return false;
4170
- const notice = cancelNoticeText(taskId, reason);
4171
- if (row.peer === 'local') {
4172
- // Mirror the tasks/cancel control route: the cooperative stop-notice
4173
- // steers the live local target, not just the ledger row. The target
4174
- // resolution spans every kind the dispatcher can book — session
4175
- // node, canvas team, bare process team — a row's team is whatever
4176
- // the dispatch accepted, so the cancel must accept the same set.
4177
- const live = resolveAgentForTeam(row.team)
4178
- ?? canvasLiveAgent(parseCanvasTeamName(row.team))
4179
- ?? (row.team === config.team ? liveAgent() : undefined);
4180
- if (live !== undefined) {
4181
- try {
4182
- steerRelay(live, notice);
4183
- }
4184
- catch {
4185
- // Bookkeeping below settles regardless; steering is best-effort.
4186
- }
4532
+ // Caller bookkeeping has priority. Notification compatibility never gates cancellation.
4533
+ const cleared = taskLedger.cancel(taskId, reason).outcome === 'cleared';
4534
+ if (!cleared)
4535
+ return false;
4536
+ try {
4537
+ const notice = cancelNoticeText(taskId, reason);
4538
+ if (row.peer === 'local' || row.direction === 'inbound')
4539
+ await cancelQueuedTask(taskId, notice, session, row);
4540
+ else {
4541
+ const card = await cachedCardFetch(row.peer);
4542
+ const supportsQueuedCancel = card?.capabilities?.queuedTaskCancellation === true;
4543
+ const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, supportsQueuedCancel ? (row.sender?.kind === 'session' ? row.sender.id : row.auth?.caller ?? session) : session, true, undefined, undefined, undefined, supportsQueuedCancel ? taskId : undefined);
4544
+ if (!result.ok)
4545
+ throw Error(result.error);
4187
4546
  }
4188
4547
  }
4189
- else {
4190
- // Peers do NOT track inbound task ids (the owed ledger's writer runs
4191
- // on the dispatcher side), so the peer's ledger-cancel route would
4192
- // deterministically answer 'unknown'. Drive the stop-notice through
4193
- // the wire to the team itself instead. The notice deliberately
4194
- // carries NO task_id: the original id is already claimed at the
4195
- // peer's idempotency ledger with a different payload, so reusing it
4196
- // would 409-conflict before any steer happens (a bodyless key is
4197
- // always fresh there). The original id rides the notice text.
4198
- const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, session, true);
4199
- if (!result.ok) {
4200
- logger.warn(`a2a: cancel stop-notice for task ${taskId} failed to deliver to ${row.team}: ${result.error}`);
4201
- }
4548
+ catch (error) {
4549
+ logger.warn('a2a: cancellation committed; stop notice was best-effort: %s', String(error));
4202
4550
  }
4203
- return taskLedger.cancel(taskId, reason).outcome === 'cleared';
4551
+ return true;
4204
4552
  },
4205
4553
  };
4206
4554
  ctx.reflect.provide(NATIVE_TEAMS_A2A_FACE_KEY, bridgeFace);
@@ -4208,6 +4556,16 @@ export function apply(ctx, config) {
4208
4556
  infoTools.registerTasks();
4209
4557
  infoTools.registerProbe();
4210
4558
  const pendingFinals = new Map();
4559
+ function answerFinalSafely(entry, text, placeholder) {
4560
+ try {
4561
+ entry.answer(text, placeholder);
4562
+ return true;
4563
+ }
4564
+ catch (error) {
4565
+ logger.warn('a2a: final callback failed; task remains owed: %s', String(error));
4566
+ return false;
4567
+ }
4568
+ }
4211
4569
  const armFlushTimeout = (agentId, waiter) => {
4212
4570
  return ctx.timer.timeout(() => {
4213
4571
  const entries = pendingFinals.get(agentId);
@@ -4219,7 +4577,7 @@ export function apply(ctx, config) {
4219
4577
  pendingFinals.delete(agentId);
4220
4578
  else
4221
4579
  pendingFinals.set(agentId, kept);
4222
- waiter.answer('The DSH session produced no final reply within the configured window.', true);
4580
+ answerFinalSafely(waiter, 'The DSH session produced no final reply within the configured window.', true);
4223
4581
  }, config.flushTimeoutMs);
4224
4582
  };
4225
4583
  function flushFinals(agentId) {
@@ -4230,7 +4588,7 @@ export function apply(ctx, config) {
4230
4588
  if (agent === undefined) {
4231
4589
  for (const entry of entries) {
4232
4590
  entry.timeoutDisposer?.();
4233
- entry.answer('The target DSH session is no longer live.', true);
4591
+ answerFinalSafely(entry, 'The target DSH session is no longer live.', true);
4234
4592
  }
4235
4593
  pendingFinals.delete(agentId);
4236
4594
  return;
@@ -4269,17 +4627,25 @@ export function apply(ctx, config) {
4269
4627
  }
4270
4628
  if (answered.length === 0)
4271
4629
  return;
4272
- const kept = entries.filter(entry => !answered.some(done => done.entry === entry));
4273
- if (kept.length === 0)
4274
- pendingFinals.delete(agentId);
4275
- else
4276
- pendingFinals.set(agentId, kept);
4277
4630
  for (const { entry, reply, placeholder } of answered) {
4278
- entry.timeoutDisposer?.();
4279
- if (placeholder === true)
4280
- entry.answer('The consuming turn produced no completed reply.', true);
4631
+ // Remove one callback before invoking (it may synchronously re-enter), never the whole batch.
4632
+ const current = pendingFinals.get(agentId) ?? [];
4633
+ if (!current.includes(entry))
4634
+ continue;
4635
+ const kept = current.filter(other => other !== entry);
4636
+ if (kept.length)
4637
+ pendingFinals.set(agentId, kept);
4281
4638
  else
4282
- entry.answer(reply);
4639
+ pendingFinals.delete(agentId);
4640
+ if (answerFinalSafely(entry, placeholder ? 'The consuming turn produced no completed reply.' : reply, placeholder)) {
4641
+ entry.timeoutDisposer?.();
4642
+ }
4643
+ else {
4644
+ // A failed commit is still owed. Keep its original floor/consuming turn for a later idle retry.
4645
+ const now = pendingFinals.get(agentId) ?? [];
4646
+ if (!now.includes(entry))
4647
+ pendingFinals.set(agentId, [entry, ...now]);
4648
+ }
4283
4649
  }
4284
4650
  }
4285
4651
  /**
@@ -4353,8 +4719,10 @@ export function apply(ctx, config) {
4353
4719
  // no-op. Node facts need no refresh here: the card serves them fresh at
4354
4720
  // read time.
4355
4721
  ctx.on('agent/status', (payload) => {
4356
- if (payload.status === 'idle')
4722
+ if (payload.status === 'idle') {
4357
4723
  flushFinals(String(payload.agent.id));
4724
+ requestReminderScan();
4725
+ }
4358
4726
  });
4359
4727
  }
4360
4728
  /**