@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.
- package/README.md +1 -0
- package/README_zh.md +1 -0
- package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/cli/a2a.mjs +43 -7
- 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 +315 -17
- package/lib/client.js +62 -72
- package/lib/client.js.map +1 -1
- package/lib/config.js +1 -0
- package/lib/direct-forward.js +9 -7
- package/lib/faces/admin.js +150 -67
- package/lib/faces/files.js +2 -1
- package/lib/faces/wire.js +569 -469
- 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 +534 -166
- 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 +56 -1
- package/lib/types/config.d.ts +2 -0
- package/lib/types/faces/admin.d.ts +21 -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 +17 -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 +51 -7
- package/nexus-stage/src/world.ts +1 -0
- package/package.json +3 -2
- 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.
|
|
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
|
-
|
|
509
|
-
|
|
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 =>
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1049
|
-
|
|
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
|
-
|
|
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} (
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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)
|
|
2034
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =>
|
|
2405
|
-
canvasMembersOf: 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
|
-
|
|
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
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
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
|
-
|
|
2673
|
-
|
|
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
|
|
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
|
-
|
|
3394
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
4527
|
+
if (!taskId)
|
|
4166
4528
|
return false;
|
|
4167
4529
|
const row = taskLedger.list().find(entry => entry.taskId === taskId);
|
|
4168
|
-
if (row
|
|
4530
|
+
if (!row)
|
|
4169
4531
|
return false;
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
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
|
-
|
|
4190
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
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
|
-
|
|
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
|
/**
|