@nelsonlongxiang/dsh-open-a2a-net 0.6.43 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/nexusDist/assets/index-CA_YZTWG.js +9 -0
- package/assets/nexusDist/index.html +1 -1
- package/cli/a2a.mjs +19 -3
- package/lib/a2a-client.js +10 -2
- package/lib/admitted-bookkeeping.js +7 -0
- package/lib/canvas-discovery.js +11 -1
- package/lib/canvas-member.js +43 -0
- package/lib/canvas-store.js +70 -4
- package/lib/direct-forward.js +9 -7
- package/lib/faces/admin.js +50 -7
- package/lib/faces/files.js +2 -1
- package/lib/faces/wire.js +566 -468
- package/lib/followup-delivery.js +89 -0
- package/lib/idempotency-store.js +8 -0
- package/lib/inbound-verification.js +23 -0
- package/lib/index.js +478 -156
- package/lib/nudge-observability.js +34 -0
- package/lib/origin-claim.js +36 -9
- package/lib/outbound-scope.js +4 -2
- package/lib/peer-directory.js +15 -5
- package/lib/peer-store.js +31 -17
- package/lib/queue-guard.js +4 -3
- package/lib/receipt-ladder.js +1 -1
- package/lib/receipt-retry.js +58 -0
- package/lib/reconciliation-reminders.js +148 -0
- package/lib/reconciliation-state.js +27 -0
- package/lib/session-transcript.js +46 -18
- package/lib/skill/SKILL.md +5 -0
- package/lib/skill/references/cli.md +4 -1
- package/lib/skill/references/routing.md +31 -1
- package/lib/task-auth.js +3 -3
- package/lib/task-delivery.js +23 -0
- package/lib/task-ledger-persistence.js +51 -0
- package/lib/task-ledger-rows.js +5 -0
- package/lib/task-ledger.js +219 -20
- package/lib/tools/info.js +37 -7
- package/lib/types/a2a-client.d.ts +1 -1
- package/lib/types/admitted-bookkeeping.d.ts +5 -0
- package/lib/types/canvas-member.d.ts +10 -0
- package/lib/types/canvas-store.d.ts +8 -0
- package/lib/types/faces/admin.d.ts +19 -0
- package/lib/types/faces/files.d.ts +1 -1
- package/lib/types/faces/wire.d.ts +21 -14
- package/lib/types/followup-delivery.d.ts +25 -0
- package/lib/types/idempotency-store.d.ts +2 -0
- package/lib/types/inbound-verification.d.ts +14 -0
- package/lib/types/index.d.ts +5 -0
- package/lib/types/nudge-observability.d.ts +17 -0
- package/lib/types/origin-claim.d.ts +7 -1
- package/lib/types/outbound-scope.d.ts +1 -0
- package/lib/types/peer-store.d.ts +4 -0
- package/lib/types/queue-guard.d.ts +9 -2
- package/lib/types/receipt-ladder.d.ts +2 -0
- package/lib/types/receipt-retry.d.ts +22 -0
- package/lib/types/reconciliation-reminders.d.ts +41 -0
- package/lib/types/reconciliation-state.d.ts +32 -0
- package/lib/types/session-transcript.d.ts +6 -1
- package/lib/types/task-auth.d.ts +7 -0
- package/lib/types/task-delivery.d.ts +17 -0
- package/lib/types/task-ledger-persistence.d.ts +3 -0
- package/lib/types/task-ledger-rows.d.ts +19 -2
- package/lib/types/task-ledger.d.ts +48 -1
- package/lib/types/tools/info.d.ts +3 -0
- package/lib/types/types.d.ts +3 -0
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +10 -2
- package/nexus-stage/src/member-provenance-picker.ts +13 -0
- package/nexus-stage/src/planning-member-handles.ts +4 -1
- package/nexus-stage/src/planning-view.ts +3 -3
- package/nexus-stage/src/workbench.ts +28 -6
- package/nexus-stage/src/world.ts +1 -0
- package/package.json +3 -2
- package/assets/nexusDist/assets/index-CaA76j0F.js +0 -9
package/lib/index.js
CHANGED
|
@@ -31,6 +31,7 @@ export function resolveCallerLabel(input) {
|
|
|
31
31
|
import { encodePublicKey, signCard, verifyCard } from "./card.js";
|
|
32
32
|
import { GroupStore } from "./group-store.js";
|
|
33
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'), { legacyBareMembers: config.canvasLegacyBareMembers !== false });
|
|
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
|
|
@@ -1030,7 +1053,14 @@ export function apply(ctx, config) {
|
|
|
1030
1053
|
published.add(row.team);
|
|
1031
1054
|
}
|
|
1032
1055
|
return (ref) => {
|
|
1033
|
-
|
|
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
|
+
}
|
|
1034
1064
|
if (!legacyOverride && config.canvasLegacyBareMembers === false)
|
|
1035
1065
|
return undefined;
|
|
1036
1066
|
const hits = byHandle.get(ref);
|
|
@@ -1076,6 +1106,10 @@ export function apply(ctx, config) {
|
|
|
1076
1106
|
legacyInferredMembers: raw.filter(r => canvasStore.membersOf(r.name).includes(r.member) && resolve(r.member) !== undefined).length,
|
|
1077
1107
|
legacyInferenceResolutions, legacyCollisionWarnings, legacyCollisionRecent: [...legacyCollisionRecent],
|
|
1078
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
|
+
})),
|
|
1079
1113
|
...canvasStore.state() };
|
|
1080
1114
|
}
|
|
1081
1115
|
function ownCanvasHandleOf(id, includeArchived = false) {
|
|
@@ -1091,12 +1125,8 @@ export function apply(ctx, config) {
|
|
|
1091
1125
|
* canvas a handle-stored membership puts the session in.
|
|
1092
1126
|
*/
|
|
1093
1127
|
function canvasTeamsOfSession(id) {
|
|
1094
|
-
const
|
|
1095
|
-
|
|
1096
|
-
if (own !== undefined)
|
|
1097
|
-
for (const name of canvasStore.teamsOf(own))
|
|
1098
|
-
teams.add(name);
|
|
1099
|
-
return [...teams];
|
|
1128
|
+
const resolve = localHandleResolver();
|
|
1129
|
+
return canvasStore.list().filter(name => canvasStore.membersOf(name).some(ref => ref === id || resolve(ref) === id));
|
|
1100
1130
|
}
|
|
1101
1131
|
/**
|
|
1102
1132
|
* gh#131: a canvas team's members as the local routing tiers read them —
|
|
@@ -1631,7 +1661,7 @@ export function apply(ctx, config) {
|
|
|
1631
1661
|
at: now,
|
|
1632
1662
|
rows: all
|
|
1633
1663
|
.filter(row => row.local !== true)
|
|
1634
|
-
.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] } : {}) })),
|
|
1635
1665
|
};
|
|
1636
1666
|
})
|
|
1637
1667
|
.catch(() => { })
|
|
@@ -1642,7 +1672,9 @@ export function apply(ctx, config) {
|
|
|
1642
1672
|
whenWebServerSettled((webServer) => {
|
|
1643
1673
|
// gh#77 B1: the admin faces live in src/faces/admin.ts.
|
|
1644
1674
|
registerAdminFaces(webServer, {
|
|
1645
|
-
|
|
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,
|
|
1646
1678
|
indexUrl: import.meta.url, enforceRefused,
|
|
1647
1679
|
get cardCache() { return cardCache; },
|
|
1648
1680
|
get remoteRowsCache() { return remoteRowsCache; },
|
|
@@ -1650,6 +1682,8 @@ export function apply(ctx, config) {
|
|
|
1650
1682
|
get unboundInboundCount() { return unboundInboundCount; },
|
|
1651
1683
|
get boundMissInboundCount() { return boundMissInboundCount; },
|
|
1652
1684
|
get boundMissRecent() { return boundMissRecent; },
|
|
1685
|
+
get claimRejectsInboundCount() { return claimRejectsInboundCount; },
|
|
1686
|
+
get claimRejectsRecent() { return claimRejectsRecent; },
|
|
1653
1687
|
get ownCardUrl() { return ownCardUrl; }, ownCanvasFingerprint,
|
|
1654
1688
|
get ownerSeatNames() { return ownerSeatNames; },
|
|
1655
1689
|
set ownerSeatNames(v) { ownerSeatNames = v; },
|
|
@@ -1683,7 +1717,7 @@ export function apply(ctx, config) {
|
|
|
1683
1717
|
// roster: the team-roster face (a2a_team_join/leave, card
|
|
1684
1718
|
// teamMemberships, team-scope routing admission) ships — peers read
|
|
1685
1719
|
// its absence as a pre-roster legacy node (0.5.43 and older).
|
|
1686
|
-
capabilities: { route: true, async: true, roster: true },
|
|
1720
|
+
capabilities: { route: true, async: true, roster: true, canvasMemberProvenance: true, sourcedFollowup: true, queuedTaskCancellation: true },
|
|
1687
1721
|
expiresAt: Date.now() + config.cardTtlMs,
|
|
1688
1722
|
...(records.length > 0 ? { records: [...records] } : {}),
|
|
1689
1723
|
});
|
|
@@ -1854,6 +1888,45 @@ export function apply(ctx, config) {
|
|
|
1854
1888
|
* so ui-chat renders the relay body ("来自 X" + full text) instead of an
|
|
1855
1889
|
* opaque row; omitted when the sender is not meaningfully addressable.
|
|
1856
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
|
+
};
|
|
1857
1930
|
function steerRelay(agent, text, sender, localFrom) {
|
|
1858
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.
|
|
1859
1932
|
if (localFrom === undefined || nodeTaint.of(localFrom) !== '')
|
|
@@ -1868,7 +1941,7 @@ export function apply(ctx, config) {
|
|
|
1868
1941
|
/** The cooperative stop-notice text, shared by the tasks/cancel control
|
|
1869
1942
|
* route and the transport face's cancel half (one contract, one wording). */
|
|
1870
1943
|
function cancelNoticeText(taskId, reason) {
|
|
1871
|
-
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.`;
|
|
1872
1945
|
}
|
|
1873
1946
|
/**
|
|
1874
1947
|
* v0.5.23 (async-stall): whether the steered agent actually left idle. A
|
|
@@ -1933,20 +2006,23 @@ export function apply(ctx, config) {
|
|
|
1933
2006
|
const escalateQueuePrompt = (agent, team, taskId) => {
|
|
1934
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.
|
|
1935
2008
|
const controller = ctx.get('sessionController');
|
|
1936
|
-
if (typeof controller?.prompt !== 'function')
|
|
2009
|
+
if (typeof controller?.prompt !== 'function') {
|
|
2010
|
+
nudgeObservations.note('unavailable', team, nudgeRef(taskId));
|
|
1937
2011
|
return;
|
|
2012
|
+
}
|
|
2013
|
+
idleReminders.noteNudge(String(agent.id));
|
|
1938
2014
|
markLineage(agent, 'nudge');
|
|
1939
|
-
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)})`);
|
|
1940
2016
|
try {
|
|
1941
2017
|
void controller.prompt({
|
|
1942
2018
|
requestId: `a2a-nudge-${nudgeRef(taskId)}`,
|
|
1943
2019
|
sessionId: agent.id,
|
|
1944
2020
|
mode: 'queue',
|
|
1945
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).` }],
|
|
1946
|
-
}, 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));
|
|
1947
2023
|
}
|
|
1948
|
-
catch {
|
|
1949
|
-
|
|
2024
|
+
catch (error) {
|
|
2025
|
+
nudgeObservations.note('failed', team, nudgeRef(taskId), error);
|
|
1950
2026
|
}
|
|
1951
2027
|
};
|
|
1952
2028
|
function armAsyncNudge(agent, team, taskId, delayMs) {
|
|
@@ -1960,6 +2036,10 @@ export function apply(ctx, config) {
|
|
|
1960
2036
|
nudgeBudget.delete(key);
|
|
1961
2037
|
return;
|
|
1962
2038
|
}
|
|
2039
|
+
if (!idleReminders.nudgeAllowed(key)) {
|
|
2040
|
+
armAsyncNudge(agent, team, taskId, Math.max(delayMs, 120_000));
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
1963
2043
|
if (agent.status !== 'idle') {
|
|
1964
2044
|
// Busy on its own turn (or wedged): spend the bounded re-arm, then
|
|
1965
2045
|
// climb to the queue-mode seam. Re-resolve on the steer path below
|
|
@@ -1983,6 +2063,7 @@ export function apply(ctx, config) {
|
|
|
1983
2063
|
// afresh.
|
|
1984
2064
|
const fresh = resolveAgentForTeam(team) ?? agent;
|
|
1985
2065
|
logger.info(`a2a: async nudge re-waking ${team} (task ${taskId}, ref ${nudgeRef(taskId)}, delivered but not consumed)`);
|
|
2066
|
+
idleReminders.noteNudge(String(fresh.id));
|
|
1986
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.`);
|
|
1987
2068
|
}, delayMs);
|
|
1988
2069
|
ctx.effect(() => () => clearTimeout(timer), `a2a: async nudge ${taskId}`);
|
|
@@ -2000,6 +2081,34 @@ export function apply(ctx, config) {
|
|
|
2000
2081
|
* receipts themselves are never tracked as new debts.
|
|
2001
2082
|
*/
|
|
2002
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');
|
|
2003
2112
|
function armReceiptAutosend(target, taskId, callerTeam, deliveredText, steerFloor) {
|
|
2004
2113
|
if (!callerTeam || callerTeam === '')
|
|
2005
2114
|
return;
|
|
@@ -2054,19 +2163,17 @@ export function apply(ctx, config) {
|
|
|
2054
2163
|
// closure would silently no-op there — runReceiptLadder reads a
|
|
2055
2164
|
// settled undefined as tier-1 success, so the receipt would vanish
|
|
2056
2165
|
// without a log.
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
settleOwnInbound(receipt, callerTeam, { caller: session, proof: 'in-process', ingress: 'local' });
|
|
2069
|
-
},
|
|
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,
|
|
2070
2177
|
ownerTeam: config.team,
|
|
2071
2178
|
log: (stage, error) => {
|
|
2072
2179
|
// N2 (observability): throttled per task to avoid ping-pong noise.
|
|
@@ -2076,9 +2183,13 @@ export function apply(ctx, config) {
|
|
|
2076
2183
|
receiptWarnAt.set(key, Date.now());
|
|
2077
2184
|
logger.warn(`a2a: receipt autosend ${stage} for task ${taskId} failed: ${String(error).slice(0, 120)}`);
|
|
2078
2185
|
},
|
|
2079
|
-
}, callerTeam)
|
|
2080
|
-
|
|
2081
|
-
|
|
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(() => { });
|
|
2082
2193
|
}, { taskId, steerFloor });
|
|
2083
2194
|
}
|
|
2084
2195
|
/**
|
|
@@ -2111,15 +2222,15 @@ export function apply(ctx, config) {
|
|
|
2111
2222
|
* marker instead of a (false) completion.
|
|
2112
2223
|
* @param taskId - the correlation key the steered receipt header carries.
|
|
2113
2224
|
*/
|
|
2114
|
-
async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2225
|
+
async function routeIntoAgent(team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey) {
|
|
2115
2226
|
const agent = resolveAgentForTeam(team) ?? canvasLiveAgent(parseCanvasTeamName(team));
|
|
2116
2227
|
if (agent !== undefined) {
|
|
2117
|
-
return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered);
|
|
2228
|
+
return routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey);
|
|
2118
2229
|
}
|
|
2119
2230
|
// Wake-on-route: a cold joined team materializes on demand, then steers.
|
|
2120
2231
|
const wake = wakeColdTeam(team);
|
|
2121
2232
|
if (wake.kind === 'flight') {
|
|
2122
|
-
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)}` }));
|
|
2123
2234
|
}
|
|
2124
2235
|
// Native-teams inbound bridge (opt-in): a registry team claims the
|
|
2125
2236
|
// handle and dispatches through its authoritative seam. Checked before
|
|
@@ -2148,7 +2259,7 @@ export function apply(ctx, config) {
|
|
|
2148
2259
|
if (live !== undefined) {
|
|
2149
2260
|
if (callerIsAgent(live, caller))
|
|
2150
2261
|
return { ok: false, error: selfRouteText(team) };
|
|
2151
|
-
return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered);
|
|
2262
|
+
return routeIntoAgentFor(live, team, message, caller, taskId, proofClause, buildHeader, undefined, onSteered, callerKey);
|
|
2152
2263
|
}
|
|
2153
2264
|
return { ok: false, error: 'No live DSH agent is available to accept this message.' };
|
|
2154
2265
|
}
|
|
@@ -2163,11 +2274,13 @@ export function apply(ctx, config) {
|
|
|
2163
2274
|
* @param caller - caller label for the steered prefix.
|
|
2164
2275
|
* @returns the agent's final reply, or the explicit failure.
|
|
2165
2276
|
*/
|
|
2166
|
-
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered) {
|
|
2277
|
+
function routeIntoAgentFor(agent, team, message, caller, taskId, proofClause, buildHeader, localFrom, onSteered, callerKey) {
|
|
2167
2278
|
return new Promise((resolve) => {
|
|
2168
2279
|
// #306: synchronous replies use the same reading-turn attribution as automatic receipts.
|
|
2169
2280
|
// Registering and steering are synchronous, so this is the exact pre-steer floor.
|
|
2170
2281
|
const floorAtRegistration = sessionEventsOf(agent).length;
|
|
2282
|
+
let admissionFinished = false;
|
|
2283
|
+
let earlyReply;
|
|
2171
2284
|
const waiter = registerFinalWaiter(agent, (text, placeholder) => {
|
|
2172
2285
|
// Preserve #305's reply_final contract: only this task's own completed reading turn qualifies.
|
|
2173
2286
|
let ownTurn = false;
|
|
@@ -2177,7 +2290,11 @@ export function apply(ctx, config) {
|
|
|
2177
2290
|
const window = read === undefined ? undefined : readingTurnReply(events, read);
|
|
2178
2291
|
ownTurn = window !== undefined && window.completed && window.reply !== '' && window.reply === text;
|
|
2179
2292
|
}
|
|
2180
|
-
|
|
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;
|
|
2181
2298
|
}, taskId !== undefined && taskId !== '' ? { taskId, steerFloor: floorAtRegistration } : undefined);
|
|
2182
2299
|
// The header names the sender first: `caller` is the routing node's
|
|
2183
2300
|
// own label (the session that issued the route), while `team` is this
|
|
@@ -2192,8 +2309,11 @@ export function apply(ctx, config) {
|
|
|
2192
2309
|
const text = `${headerLine}\n\n${message}`;
|
|
2193
2310
|
try {
|
|
2194
2311
|
const floor = sessionEventsOf(agent).length;
|
|
2195
|
-
|
|
2312
|
+
followupRelay(agent, text, caller === '' ? undefined : caller, localFrom, taskId, callerKey, onSteered !== undefined);
|
|
2196
2313
|
onSteered?.(agent, floor);
|
|
2314
|
+
admissionFinished = true;
|
|
2315
|
+
if (earlyReply)
|
|
2316
|
+
resolve(earlyReply);
|
|
2197
2317
|
if (onSteered !== undefined && sessionEventsOf(agent).slice(floor).some(event => event?.type === 'assistant/message'))
|
|
2198
2318
|
queueMicrotask(() => flushFinals(String(agent.id))); // synchronous final may already have emitted idle
|
|
2199
2319
|
}
|
|
@@ -2201,13 +2321,17 @@ export function apply(ctx, config) {
|
|
|
2201
2321
|
// The waiter was registered above, so the key always holds it; the
|
|
2202
2322
|
// map-of-arrays stays a plain read.
|
|
2203
2323
|
const key = String(agent.id);
|
|
2204
|
-
const entries = pendingFinals.get(key);
|
|
2324
|
+
const entries = pendingFinals.get(key) ?? [];
|
|
2205
2325
|
const kept = entries.filter(entry => entry !== waiter);
|
|
2206
2326
|
if (kept.length === 0)
|
|
2207
2327
|
pendingFinals.delete(key);
|
|
2208
2328
|
else
|
|
2209
2329
|
pendingFinals.set(key, kept);
|
|
2210
2330
|
waiter.timeoutDisposer?.();
|
|
2331
|
+
if (error instanceof AdmittedBookkeepingError) {
|
|
2332
|
+
resolve({ ok: true, reply: ADMITTED_BOOKKEEPING_NOTE, receiverBookkeeping: 'failed', placeholder: true });
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2211
2335
|
resolve({ ok: false, error: `The DSH session rejected the message: ${String(error)}` });
|
|
2212
2336
|
}
|
|
2213
2337
|
});
|
|
@@ -2237,6 +2361,23 @@ export function apply(ctx, config) {
|
|
|
2237
2361
|
boundMissRecent.unshift({ ...row, count: row.count + 1, lastAt: Date.now() });
|
|
2238
2362
|
boundMissRecent.length = Math.min(boundMissRecent.length, 16);
|
|
2239
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
|
+
};
|
|
2240
2381
|
/** Our configured seeds anchor origin claims; mounted claims require exact mount matching. */
|
|
2241
2382
|
function configuredSeedCaller(callerUrl) {
|
|
2242
2383
|
return config.peers.some(peer => {
|
|
@@ -2254,6 +2395,7 @@ export function apply(ctx, config) {
|
|
|
2254
2395
|
const claim = await verifyOriginClaimOf(req, {
|
|
2255
2396
|
cardCache,
|
|
2256
2397
|
fetch: fetchImpl,
|
|
2398
|
+
freshCard: true,
|
|
2257
2399
|
audiences: [ownCardUrl, ...config.originClaimAudiences ?? []],
|
|
2258
2400
|
unboundOpen: config.originClaimUnboundWindow !== false,
|
|
2259
2401
|
onUnbound: () => { unboundInboundCount += 1; },
|
|
@@ -2263,6 +2405,8 @@ export function apply(ctx, config) {
|
|
|
2263
2405
|
...(options?.cli === true ? { cliDeposit: cliDepositCard } : {}),
|
|
2264
2406
|
...(options?.rawBody !== undefined ? { rawBody: options.rawBody } : {}),
|
|
2265
2407
|
});
|
|
2408
|
+
if (!claim.ok && claim.reason !== 'absent')
|
|
2409
|
+
noteClaimReject(req, claim.reason);
|
|
2266
2410
|
// #312: recover bilateral seed evidence from a signed, reduction-only card,
|
|
2267
2411
|
// never from a caller-selected unseeded URL and never recursively.
|
|
2268
2412
|
const discovery = new URL(req.url ?? '/', 'http://x').searchParams.get('discovery') === '1';
|
|
@@ -2382,6 +2526,9 @@ export function apply(ctx, config) {
|
|
|
2382
2526
|
for (const ref of canvasStore.remoteMembersOf(name)) {
|
|
2383
2527
|
if (ref === caller)
|
|
2384
2528
|
return true;
|
|
2529
|
+
const qualified = splitQualifiedMember(ref);
|
|
2530
|
+
if (qualified?.handle === caller && callerCard?.publicKey === canvasStore.ownerKeyOf(ref))
|
|
2531
|
+
return true;
|
|
2385
2532
|
}
|
|
2386
2533
|
}
|
|
2387
2534
|
// Basis 3, BILATERAL (review M1 fix): OUR config.peers seeds a URL
|
|
@@ -2447,14 +2594,14 @@ export function apply(ctx, config) {
|
|
|
2447
2594
|
memberships: () => teamMemberships.list(),
|
|
2448
2595
|
joined: () => joinedSessions.list(),
|
|
2449
2596
|
canvasNames: () => canvasStore.list(),
|
|
2450
|
-
canvasTeamsOf: sessionId =>
|
|
2451
|
-
canvasMembersOf: name =>
|
|
2597
|
+
canvasTeamsOf: sessionId => canvasTeamsOfSession(sessionId),
|
|
2598
|
+
canvasMembersOf: name => canvasLocalMembersOf(name),
|
|
2452
2599
|
canvasRemoteMembersOf: name => canvasStore.remoteMembersOf(name),
|
|
2453
2600
|
peerOrgs: function* () {
|
|
2454
2601
|
for (const cached of cardCache.values()) {
|
|
2455
2602
|
const org = cached.card?.canvasOrg;
|
|
2456
2603
|
if (Array.isArray(org))
|
|
2457
|
-
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 }));
|
|
2458
2605
|
}
|
|
2459
2606
|
},
|
|
2460
2607
|
liveNodeIds: () => [...sessionNodes.values()].map(agent => String(agent.id)),
|
|
@@ -2471,8 +2618,43 @@ export function apply(ctx, config) {
|
|
|
2471
2618
|
return verdict === undefined ? undefined : refusalOf(verdict);
|
|
2472
2619
|
}
|
|
2473
2620
|
/** Inbound admission with the rule and shared teams that admitted the caller (gh#114 S1). */
|
|
2474
|
-
function a2aCallerAdmission(caller, targetTeam, cliProof) {
|
|
2475
|
-
|
|
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);
|
|
2476
2658
|
}
|
|
2477
2659
|
function a2aCallerRefusal(caller, targetTeam) {
|
|
2478
2660
|
return refusalOf(a2aCallerAdmission(caller, targetTeam));
|
|
@@ -2612,7 +2794,7 @@ export function apply(ctx, config) {
|
|
|
2612
2794
|
logger,
|
|
2613
2795
|
config,
|
|
2614
2796
|
cliDeposits,
|
|
2615
|
-
canvasStore,
|
|
2797
|
+
canvasStore, canvasMemberForWrite,
|
|
2616
2798
|
canvasNameReserved: name => canvasNameReserved(name, config.team, ownerAllowlist, teamMemberships),
|
|
2617
2799
|
sessionNodes,
|
|
2618
2800
|
joinedSessions,
|
|
@@ -2633,6 +2815,18 @@ export function apply(ctx, config) {
|
|
|
2633
2815
|
scanSessionLogList,
|
|
2634
2816
|
readSessionLogTail,
|
|
2635
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
|
+
},
|
|
2636
2830
|
refuseIfFlooded,
|
|
2637
2831
|
refuseIfQueueFull,
|
|
2638
2832
|
settleAndAnnounce, waitForForwardBirth,
|
|
@@ -2645,7 +2839,7 @@ export function apply(ctx, config) {
|
|
|
2645
2839
|
probeConsumption,
|
|
2646
2840
|
sessionTeamOf,
|
|
2647
2841
|
localSessionOfHandle,
|
|
2648
|
-
steerRelay,
|
|
2842
|
+
steerRelay, followupRelay, cancelQueuedTask,
|
|
2649
2843
|
fromClause,
|
|
2650
2844
|
sanitizeFromClause,
|
|
2651
2845
|
armReceiptAutosend,
|
|
@@ -2696,28 +2890,42 @@ export function apply(ctx, config) {
|
|
|
2696
2890
|
path: '/__dsh_a2a/tasks/cancel',
|
|
2697
2891
|
handler: controlRoute((req, res) => {
|
|
2698
2892
|
readJsonBody(req, res, (body) => {
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
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
|
+
}
|
|
2715
2915
|
}
|
|
2716
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' }));
|
|
2717
2926
|
}
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
res.end(payload);
|
|
2927
|
+
else if (!res.writableEnded)
|
|
2928
|
+
res.end(); });
|
|
2721
2929
|
});
|
|
2722
2930
|
}),
|
|
2723
2931
|
}), 'a2a: task cancel route');
|
|
@@ -2955,6 +3163,14 @@ export function apply(ctx, config) {
|
|
|
2955
3163
|
if (!linked && handles.size > 0 && zone !== '') {
|
|
2956
3164
|
outer: for (const name of canvasStore.list()) {
|
|
2957
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
|
+
}
|
|
2958
3174
|
if (handles.has(ref)) {
|
|
2959
3175
|
linked = true;
|
|
2960
3176
|
break outer;
|
|
@@ -2979,6 +3195,51 @@ export function apply(ctx, config) {
|
|
|
2979
3195
|
return { url, card, linked: card === undefined || peerAdmissionLinkState(url).link === 'linked' };
|
|
2980
3196
|
});
|
|
2981
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
|
+
}
|
|
2982
3243
|
async function cliHomeForAdd(handle, requested) {
|
|
2983
3244
|
const localHousehold = () => cliDeposits.directoryRows().some(row => row.team === handle);
|
|
2984
3245
|
if (localHousehold())
|
|
@@ -3112,6 +3373,7 @@ export function apply(ctx, config) {
|
|
|
3112
3373
|
};
|
|
3113
3374
|
const SWEEP_CONCURRENCY = 6;
|
|
3114
3375
|
const infoTools = createInfoTools({
|
|
3376
|
+
nudgeEscalations: () => nudgeObservations.snapshot(),
|
|
3115
3377
|
ctx, config, logger, session, lanIp, get ownCardUrl() { return ownCardUrl; }, peerStore, taskLedger,
|
|
3116
3378
|
inFlightRoutes, recentActivity, joinedSessions, sessionNodes, canvasStore,
|
|
3117
3379
|
teamMemberships, exposureStore, idempotencyStore, cardCache,
|
|
@@ -3123,7 +3385,7 @@ export function apply(ctx, config) {
|
|
|
3123
3385
|
get ownerAllowlist() { return ownerAllowlist; },
|
|
3124
3386
|
get ownerTeamScopeInfo() { return ownerTeamScopeInfo; },
|
|
3125
3387
|
teamAuditRing, reportRing, a2aJoinGateRefusal, observeAuthorization,
|
|
3126
|
-
settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver,
|
|
3388
|
+
settlePeerCard, client, listDirectoryTeams, canvasTeamsOfSession, localHandleResolver, canvasMigrationState,
|
|
3127
3389
|
SWEEP_CONCURRENCY,
|
|
3128
3390
|
});
|
|
3129
3391
|
infoTools.registerWhoami();
|
|
@@ -3253,13 +3515,13 @@ export function apply(ctx, config) {
|
|
|
3253
3515
|
* by task id and taken (deleted) by whoever dispatched.
|
|
3254
3516
|
*/
|
|
3255
3517
|
const localBirths = new Map();
|
|
3256
|
-
const localBirthOf = (team, callerSession, receiptTarget, deliveredTo) => {
|
|
3518
|
+
const localBirthOf = (team, callerSession, receiptTarget, deliveredTo, receiverSessionId) => {
|
|
3257
3519
|
const admission = localCandidateAdmission(admissionRecords, callerSession, team);
|
|
3258
3520
|
const caller = callerSession ?? session;
|
|
3259
3521
|
const auth = admission.ok && caller !== ''
|
|
3260
3522
|
? buildAuthorization(admission, { caller, proof: 'in-process', ingress: 'local', allowlistVersion: allowlistVersionNow(), target: team, receiptTarget: receiptTarget ?? caller, receivedAt: Date.now() })
|
|
3261
3523
|
: undefined;
|
|
3262
|
-
return { ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
|
|
3524
|
+
return { receiverSessionId, ...(auth !== undefined ? { auth } : {}), ...(deliveredTo !== team ? { deliveredTo } : {}) };
|
|
3263
3525
|
};
|
|
3264
3526
|
/**
|
|
3265
3527
|
* #139: the peer facts a remote outbound row is born with — the signed
|
|
@@ -3278,12 +3540,13 @@ export function apply(ctx, config) {
|
|
|
3278
3540
|
const trackOwedTask = (taskId, team, peer, result, callbackAddress, brief, sender, birth) => {
|
|
3279
3541
|
if (!result.ok)
|
|
3280
3542
|
return;
|
|
3543
|
+
birth = { ...birth, ...(typeof result.consumed === 'boolean' ? { consumed: result.consumed } : {}) };
|
|
3281
3544
|
// Native-teams bridge rounds emit no A2A receipt in this slice: booking
|
|
3282
3545
|
// them as receipt-owed would fill the owed book with rows no receipt can
|
|
3283
3546
|
// ever settle (evicting genuinely receivable peer rows past TASK_CAP).
|
|
3284
3547
|
if (result.bridge !== undefined)
|
|
3285
3548
|
return;
|
|
3286
|
-
if (result.task_status !== 'TASK_STATE_DELIVERED')
|
|
3549
|
+
if (result.task_status !== 'TASK_STATE_DELIVERED' && result.receiverBookkeeping !== 'failed')
|
|
3287
3550
|
return;
|
|
3288
3551
|
// Fix ② (ghost-debt stop): a delivered async dispatch with no routable
|
|
3289
3552
|
// callback address can never be settled — register it receiptless
|
|
@@ -3405,9 +3668,10 @@ export function apply(ctx, config) {
|
|
|
3405
3668
|
const senderScopeRecords = {
|
|
3406
3669
|
zone: config.team, id8, teamsOf: id => teamMemberships.teamsOf(id), membersOf: team => teamMemberships.membersOf(team),
|
|
3407
3670
|
canvasNames: () => canvasStore.list(), canvasMembers: name => canvasStore.membersOf(name), canvasTeam: canvasTeamOf,
|
|
3671
|
+
memberHandle: ref => splitQualifiedMember(ref)?.handle ?? ref,
|
|
3408
3672
|
peerOrgs: function* () { for (const hit of cardCache.values())
|
|
3409
3673
|
if (hit.card?.canvasOrg)
|
|
3410
|
-
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) })); },
|
|
3411
3675
|
warmPeers: () => mapBounded(resolutionPeers(), SWEEP_CONCURRENCY, cachedCardFetch),
|
|
3412
3676
|
};
|
|
3413
3677
|
async function routeTrunk(args, agent, signal, hostCaller) {
|
|
@@ -3430,14 +3694,35 @@ export function apply(ctx, config) {
|
|
|
3430
3694
|
a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
|
|
3431
3695
|
// #282 S2: an owner-qualified canvas id. Our own fingerprint is our canvas; a foreign one never
|
|
3432
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 };
|
|
3433
3700
|
const ownerQualified = splitCanvasOwner(String(args.team ?? '').trim());
|
|
3434
3701
|
const foreignOwner = ownerQualified.owner !== undefined && ownerQualified.owner !== ownCanvasFingerprint ? ownerQualified.owner : undefined;
|
|
3435
3702
|
if (ownerQualified.owner !== undefined)
|
|
3436
3703
|
args = { ...args, team: ownerQualified.team };
|
|
3437
|
-
// A
|
|
3704
|
+
// A receipt may use only its own frozen return lane; local answer state is not delivery proof.
|
|
3438
3705
|
const answer = parseReceipt(args.message);
|
|
3439
|
-
|
|
3440
|
-
|
|
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
|
+
}
|
|
3441
3726
|
// S3 (team-scoped routing, phase-2 default ON per the 2026-09-01 owner
|
|
3442
3727
|
// ruling; set false to restore the open outbound seam): a session
|
|
3443
3728
|
// caller must have declared membership in the target team before it
|
|
@@ -3529,7 +3814,7 @@ export function apply(ctx, config) {
|
|
|
3529
3814
|
// named; the peer request carries the member handle it must resolve.
|
|
3530
3815
|
const canvasName = foreignOwner !== undefined ? undefined : parseCanvasTeamName(args.team);
|
|
3531
3816
|
// gh#131: one of this host's own handles is a local member, never a directory target.
|
|
3532
|
-
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()) : [];
|
|
3533
3818
|
const targets = remoteTargets.length > 0 ? [...remoteTargets] : [args.team];
|
|
3534
3819
|
if (remoteTargets.length > 0) {
|
|
3535
3820
|
failures.push(`local: canvas team "${args.team}" has no live or wakeable local member`);
|
|
@@ -3544,7 +3829,7 @@ export function apply(ctx, config) {
|
|
|
3544
3829
|
const candidatePool = [];
|
|
3545
3830
|
for (const target of targets) {
|
|
3546
3831
|
for (const candidate of await directoryPeerCandidates(fetch, ownerQualified.owner !== undefined && target === args.team ? `${target}@${ownerQualified.owner}` : target, failures, scopedCanvasCaller)) {
|
|
3547
|
-
candidatePool.push({ candidate, target });
|
|
3832
|
+
candidatePool.push({ candidate, target: splitQualifiedMember(target)?.handle ?? target });
|
|
3548
3833
|
}
|
|
3549
3834
|
}
|
|
3550
3835
|
// Failover: try every candidate in order — an offline path falls back
|
|
@@ -3566,8 +3851,8 @@ export function apply(ctx, config) {
|
|
|
3566
3851
|
if (result.ok || signal.aborted) {
|
|
3567
3852
|
// A receipt the creditor refused (its enforce verdict) pays nothing here either (review of PR #149 P2-2).
|
|
3568
3853
|
if (result.ok && routedReceipt) {
|
|
3569
|
-
if (result.settlementRefused !== true)
|
|
3570
|
-
|
|
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' };
|
|
3571
3856
|
}
|
|
3572
3857
|
else if (result.ok)
|
|
3573
3858
|
trackOwedTask(taskId, args.team, candidate, result, callbackAddress, briefOf(args.message), sender, await peerBirth(fetch, candidate, target === args.team ? undefined : target));
|
|
@@ -3750,11 +4035,11 @@ export function apply(ctx, config) {
|
|
|
3750
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>" }.)`;
|
|
3751
4036
|
// #114 S5: the consumption search starts at the pre-steer log length.
|
|
3752
4037
|
const steerFloor = sessionEventsOf(target).length;
|
|
3753
|
-
|
|
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);
|
|
3754
4039
|
armReceiptAutosend(target, taskId, callerTeam, message, steerFloor);
|
|
3755
4040
|
// #139: the caller's outbound row is born with this admission and the member that received it.
|
|
3756
4041
|
if (taskIdFromCaller !== undefined)
|
|
3757
|
-
localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target)));
|
|
4042
|
+
localBirths.set(taskIdFromCaller, localBirthOf(team, callerSession, callerTeam, sessionTeamOf(target), String(target.id)));
|
|
3758
4043
|
endRoute(flight);
|
|
3759
4044
|
recordActivity('out', team, 'local', true);
|
|
3760
4045
|
return {
|
|
@@ -3764,6 +4049,7 @@ export function apply(ctx, config) {
|
|
|
3764
4049
|
task_id: taskId,
|
|
3765
4050
|
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
3766
4051
|
task_status: 'TASK_STATE_DELIVERED',
|
|
4052
|
+
consumed: taskLedger.deliveryFor(taskId)?.state === 'claimed',
|
|
3767
4053
|
};
|
|
3768
4054
|
}
|
|
3769
4055
|
catch (error) {
|
|
@@ -3946,7 +4232,7 @@ export function apply(ctx, config) {
|
|
|
3946
4232
|
throw new Error(`CLI receipt home is not pinned for ${team}`);
|
|
3947
4233
|
const delivered = await dispatchPeerCandidate(home, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
|
|
3948
4234
|
if (!delivered.ok)
|
|
3949
|
-
throw
|
|
4235
|
+
throw receiptFailure(delivered);
|
|
3950
4236
|
return delivered;
|
|
3951
4237
|
}
|
|
3952
4238
|
const local = await dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode, taskId);
|
|
@@ -3955,7 +4241,7 @@ export function apply(ctx, config) {
|
|
|
3955
4241
|
localBirths.delete(taskId);
|
|
3956
4242
|
if (local !== undefined) {
|
|
3957
4243
|
if (!local.ok)
|
|
3958
|
-
throw
|
|
4244
|
+
throw receiptFailure(local);
|
|
3959
4245
|
return local;
|
|
3960
4246
|
}
|
|
3961
4247
|
if (cliReceipt)
|
|
@@ -3966,15 +4252,19 @@ export function apply(ctx, config) {
|
|
|
3966
4252
|
throw new Error(`no route to "${team}": not a local team and not published by any tracked peer`);
|
|
3967
4253
|
}
|
|
3968
4254
|
let lastError = 'no candidate delivered';
|
|
4255
|
+
let receiptError;
|
|
3969
4256
|
for (const candidate of candidates) {
|
|
3970
4257
|
const result = await dispatchPeerCandidate(candidate, team, message, contextId, signal, peerCaller ?? callerSession, asyncMode, taskId);
|
|
3971
4258
|
if (result.ok)
|
|
3972
4259
|
return result;
|
|
3973
4260
|
lastError = result.error;
|
|
4261
|
+
const failure = receiptFailure(result);
|
|
4262
|
+
if (receiptError === undefined || failure.transient)
|
|
4263
|
+
receiptError = failure;
|
|
3974
4264
|
if (signal.aborted)
|
|
3975
4265
|
break;
|
|
3976
4266
|
}
|
|
3977
|
-
throw new Error(`delivery to "${team}" failed on every candidate: ${lastError}`);
|
|
4267
|
+
throw receiptError ?? new Error(`delivery to "${team}" failed on every candidate: ${lastError}`);
|
|
3978
4268
|
}
|
|
3979
4269
|
/**
|
|
3980
4270
|
* The directory's candidate walk for one team: direct publishers first,
|
|
@@ -4013,12 +4303,34 @@ export function apply(ctx, config) {
|
|
|
4013
4303
|
// itself after the target). referralTeamResolution restores the legacy
|
|
4014
4304
|
// whole-store basis for fleets mid-migration.
|
|
4015
4305
|
// #282 S2: `<canvas>@<fp8>` selects only the owner whose signed card key matches; no delegation walk.
|
|
4016
|
-
|
|
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
|
+
}
|
|
4017
4324
|
team = qualified.team;
|
|
4018
|
-
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);
|
|
4019
4326
|
const peers = resolutionPeers();
|
|
4020
4327
|
const candidates = [];
|
|
4021
|
-
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 });
|
|
4022
4334
|
// Check after fetching all cards: a seeded peer publishing the same id8
|
|
4023
4335
|
// makes the caller handle ambiguous, so do not send a canvas payload.
|
|
4024
4336
|
const sessions = scopedCanvasCaller === undefined ? undefined : localHandleResolver();
|
|
@@ -4031,6 +4343,10 @@ export function apply(ctx, config) {
|
|
|
4031
4343
|
for (const entry of cards) {
|
|
4032
4344
|
if (entry.card === undefined)
|
|
4033
4345
|
continue;
|
|
4346
|
+
if (member && entry.card.capabilities?.canvasMemberProvenance !== true) {
|
|
4347
|
+
failures.push('selected member host needs a provenance-capable upgrade');
|
|
4348
|
+
continue;
|
|
4349
|
+
}
|
|
4034
4350
|
if (entry.card.team === team || sessionCandidateMatches(entry.card, team) || canvasCandidateMatches(entry.card, team, scopedCanvasCaller, resolveLocal))
|
|
4035
4351
|
candidates.push(entry.peer);
|
|
4036
4352
|
}
|
|
@@ -4112,7 +4428,7 @@ export function apply(ctx, config) {
|
|
|
4112
4428
|
const card = await fetch(candidate);
|
|
4113
4429
|
peerAsync = card?.capabilities?.async === true;
|
|
4114
4430
|
}
|
|
4115
|
-
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);
|
|
4116
4432
|
if (!result.ok) {
|
|
4117
4433
|
// Idempotency verdicts (B3) are terminal, not failover: a replay
|
|
4118
4434
|
// says the prior attempt at THIS peer stays authoritative —
|
|
@@ -4208,45 +4524,31 @@ export function apply(ctx, config) {
|
|
|
4208
4524
|
},
|
|
4209
4525
|
cancel: async (ref, reason) => {
|
|
4210
4526
|
const taskId = ref.taskId;
|
|
4211
|
-
if (taskId
|
|
4527
|
+
if (!taskId)
|
|
4212
4528
|
return false;
|
|
4213
4529
|
const row = taskLedger.list().find(entry => entry.taskId === taskId);
|
|
4214
|
-
if (row
|
|
4530
|
+
if (!row)
|
|
4215
4531
|
return false;
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
catch {
|
|
4231
|
-
// Bookkeeping below settles regardless; steering is best-effort.
|
|
4232
|
-
}
|
|
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);
|
|
4233
4546
|
}
|
|
4234
4547
|
}
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
// on the dispatcher side), so the peer's ledger-cancel route would
|
|
4238
|
-
// deterministically answer 'unknown'. Drive the stop-notice through
|
|
4239
|
-
// the wire to the team itself instead. The notice deliberately
|
|
4240
|
-
// carries NO task_id: the original id is already claimed at the
|
|
4241
|
-
// peer's idempotency ledger with a different payload, so reusing it
|
|
4242
|
-
// would 409-conflict before any steer happens (a bodyless key is
|
|
4243
|
-
// always fresh there). The original id rides the notice text.
|
|
4244
|
-
const result = await client.routeDirect(row.peer, row.team, notice, undefined, undefined, session, true);
|
|
4245
|
-
if (!result.ok) {
|
|
4246
|
-
logger.warn(`a2a: cancel stop-notice for task ${taskId} failed to deliver to ${row.team}: ${result.error}`);
|
|
4247
|
-
}
|
|
4548
|
+
catch (error) {
|
|
4549
|
+
logger.warn('a2a: cancellation committed; stop notice was best-effort: %s', String(error));
|
|
4248
4550
|
}
|
|
4249
|
-
return
|
|
4551
|
+
return true;
|
|
4250
4552
|
},
|
|
4251
4553
|
};
|
|
4252
4554
|
ctx.reflect.provide(NATIVE_TEAMS_A2A_FACE_KEY, bridgeFace);
|
|
@@ -4254,6 +4556,16 @@ export function apply(ctx, config) {
|
|
|
4254
4556
|
infoTools.registerTasks();
|
|
4255
4557
|
infoTools.registerProbe();
|
|
4256
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
|
+
}
|
|
4257
4569
|
const armFlushTimeout = (agentId, waiter) => {
|
|
4258
4570
|
return ctx.timer.timeout(() => {
|
|
4259
4571
|
const entries = pendingFinals.get(agentId);
|
|
@@ -4265,7 +4577,7 @@ export function apply(ctx, config) {
|
|
|
4265
4577
|
pendingFinals.delete(agentId);
|
|
4266
4578
|
else
|
|
4267
4579
|
pendingFinals.set(agentId, kept);
|
|
4268
|
-
waiter
|
|
4580
|
+
answerFinalSafely(waiter, 'The DSH session produced no final reply within the configured window.', true);
|
|
4269
4581
|
}, config.flushTimeoutMs);
|
|
4270
4582
|
};
|
|
4271
4583
|
function flushFinals(agentId) {
|
|
@@ -4276,7 +4588,7 @@ export function apply(ctx, config) {
|
|
|
4276
4588
|
if (agent === undefined) {
|
|
4277
4589
|
for (const entry of entries) {
|
|
4278
4590
|
entry.timeoutDisposer?.();
|
|
4279
|
-
entry
|
|
4591
|
+
answerFinalSafely(entry, 'The target DSH session is no longer live.', true);
|
|
4280
4592
|
}
|
|
4281
4593
|
pendingFinals.delete(agentId);
|
|
4282
4594
|
return;
|
|
@@ -4315,17 +4627,25 @@ export function apply(ctx, config) {
|
|
|
4315
4627
|
}
|
|
4316
4628
|
if (answered.length === 0)
|
|
4317
4629
|
return;
|
|
4318
|
-
const kept = entries.filter(entry => !answered.some(done => done.entry === entry));
|
|
4319
|
-
if (kept.length === 0)
|
|
4320
|
-
pendingFinals.delete(agentId);
|
|
4321
|
-
else
|
|
4322
|
-
pendingFinals.set(agentId, kept);
|
|
4323
4630
|
for (const { entry, reply, placeholder } of answered) {
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
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);
|
|
4327
4638
|
else
|
|
4328
|
-
|
|
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
|
+
}
|
|
4329
4649
|
}
|
|
4330
4650
|
}
|
|
4331
4651
|
/**
|
|
@@ -4399,8 +4719,10 @@ export function apply(ctx, config) {
|
|
|
4399
4719
|
// no-op. Node facts need no refresh here: the card serves them fresh at
|
|
4400
4720
|
// read time.
|
|
4401
4721
|
ctx.on('agent/status', (payload) => {
|
|
4402
|
-
if (payload.status === 'idle')
|
|
4722
|
+
if (payload.status === 'idle') {
|
|
4403
4723
|
flushFinals(String(payload.agent.id));
|
|
4724
|
+
requestReminderScan();
|
|
4725
|
+
}
|
|
4404
4726
|
});
|
|
4405
4727
|
}
|
|
4406
4728
|
/**
|