@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
|
@@ -7,7 +7,7 @@ Files do not go through session messages. Send with `a2a_file_send { paths, team
|
|
|
7
7
|
## The receipt contract
|
|
8
8
|
|
|
9
9
|
Every async route (`wait:false`) registers the task on the peer and returns
|
|
10
|
-
immediately. The peer's target session
|
|
10
|
+
immediately. The peer's target session queues a sourced `agent.followup` as its own next turn; when
|
|
11
11
|
the turn settles, the peer routes a receipt envelope back to the caller's
|
|
12
12
|
team:
|
|
13
13
|
|
|
@@ -19,6 +19,28 @@ Correlated receipts also carry `{"outcome":"completed","idempotencyKey":"..."}`
|
|
|
19
19
|
on a following line. Receipts are the ONLY completion signal for async work —
|
|
20
20
|
polling `a2a_teams` cannot see another host's ledger.
|
|
21
21
|
|
|
22
|
+
## Delivery and cancellation (#356)
|
|
23
|
+
|
|
24
|
+
Task delivery (local/remote, sync/async) preserves A2A source metadata and queues a separate next turn. The host atomically stores the taskId → full SessionId + MessageId correspondence before submitting. A missing `Agent.followup` is an explicit refusal, never an instruction to use `steer`. Cold targets first pass the existing resolver. Admission, OriginClaim, team scope, taint and the default three-slot cap run before submission.
|
|
25
|
+
|
|
26
|
+
`delivered` means inbox acceptance, not consumption. Pending rows may expose `consumed:false` and `deliveryObservation:delivered-unconsumed`; CLI `tasks` and `tasks --json` show this. Inbound later claims can become true, while an outbound value is only the selected peer response snapshot, not live remote telemetry. Neither value is an acknowledgement or completion, and it never changes queue counting. Older peers omitting the field remain unknown, not falsely unconsumed.
|
|
27
|
+
|
|
28
|
+
Cancellation removes the persisted MessageId through `updateQueue remove` if still unclaimed; a claim/removal race becomes the claimed path. Claimed tasks retain an immediate sourced `steer` stop notice, never a queued stop that would wait behind the work. Cancellation bookkeeping keeps priority: commit the original caller cancellation first, then attempt the stop notification. Cross-host peers with the signed queued-cancellation capability receive the body-bound control field tied to the original verified caller key; older peers receive the legacy `[A2A cancel]` text without a task id, best-effort. An unavailable target or failed notification does not undo cancellation, and `removed`/`steered` only report what actually happened. This is stop-notice compatibility, never permission to fall back to steer for task delivery when followup is missing. A reserved/uncertain mapping is not blindly redelivered. Resolve that existing occurrence instead of changing ids to force a second execution.
|
|
29
|
+
|
|
30
|
+
## Receipt delivery observation (#362)
|
|
31
|
+
|
|
32
|
+
A formal receipt for your own pending inbound task may return only along that task's frozen receipt target; it does not grant permission to send unrelated traffic. The receiver still completes current source verification and checks the frozen task authority. Do not join extra teams merely to return that correlated receipt.
|
|
33
|
+
|
|
34
|
+
`answeredAt` means the receiving session has answered locally, not that its receipt arrived. A pending row with `receiptDelivery:receipt-undelivered` explicitly records that delivery is not confirmed; tasks, state and CLI JSON preserve this across restart. The row remains owed even though answered work no longer occupies an execution slot. A successful formal receipt settles the original row; a rejected or failed send must not be reported as completed.
|
|
35
|
+
|
|
36
|
+
## Temporary receipt delivery failures (#357)
|
|
37
|
+
|
|
38
|
+
For an automatic receipt, a recognized temporary delivery failure keeps the original task pending with `receiptDelivery:receipt-undelivered`. The host retries the same receipt body and transport id with bounded backoff (1, 2, 4, 8, 16, 30 seconds; at most six retries). Its owner receives a notification, not a substitute receipt. Exhaustion or plugin disposal stops this runtime retry budget without settling the debt; timers are not resumed on restart. Permanent refusals retain the existing receipt-ladder policy. Reconciliation reminders still ask the session to reconcile; host retransmissions do not count as reminder attempts or new work.
|
|
39
|
+
|
|
40
|
+
## Status diagnostics (#355 / #358②)
|
|
41
|
+
|
|
42
|
+
`a2a-net status` and `status --json` show `ledgerPersistence` and `nudgeEscalations`. A persistence failure is prominently flagged; `lastError` contains only time/code, and a later successful commit clears it. Nudge counts distinguish a successful queue-call return, an unavailable prompt seam, and a failed call. They are host-local runtime counters, reset with the plugin instance, not receipt or consumption evidence. Missing seams and failures emit safe warnings without task text. These observations never change routing, settlement, retry budgets or capacity.
|
|
43
|
+
|
|
22
44
|
## The ledger
|
|
23
45
|
|
|
24
46
|
`__dsh_a2a/state` splits the ledger into separate fields:
|
|
@@ -44,6 +66,14 @@ polling `a2a_teams` cannot see another host's ledger.
|
|
|
44
66
|
|
|
45
67
|
A pending row with `answeredAt` records that its actual local target produced a task-attributed final or attempted its own formal receipt. It no longer occupies the `targetQueueCap` limit, even if the caller CLI expired and receipt delivery failed. It is **still pending receipt debt**: do not abandon it just to free capacity, or claim the sender received it. Dead-letter expiry, late receipts and lifetime conservation continue unchanged. State session `queued` and the board both use the same unanswered-row count; unfinished work still counts against the default cap of three. Unrelated agents, unrelated finals and host timeout placeholders cannot mark an answer.
|
|
46
68
|
|
|
69
|
+
## Idle reconciliation reminders (#351, sourced followup)
|
|
70
|
+
|
|
71
|
+
The host may send a sourced `[A2A reconciliation reminder]` while the session is truly idle. It lists existing pending task ids, senders, bounded original-message briefs and ages. **It is not a new task, acknowledgement, or completion evidence**: no slot is released and no receipt is owed to the reminder itself. Check each original task and send your own formal receipt to its original sender when the outcome is known (completed / failed / rejected / abandoned). If still working, use an ordinary follow-up; do not use a terminal receipt header merely to mean “in progress”.
|
|
72
|
+
|
|
73
|
+
Reminder attempts are persisted before injection, merged per session, skipped without counting while busy, and backed off after three attempts to about two hours without stopping. They stop only when the original row leaves pending by a real receipt, cancellation, expiry or another existing terminal path. Counters do not prove the model read a reminder. Local senders may receive one warning; remote push notifications are not shipped. Tasks/state and CLI show attempts, last/next times and “多次提醒未对账”.
|
|
74
|
+
|
|
75
|
+
#356 queues both idle reminders and local sender notices through `agent.followup`, preserving the declared `a2a-relay` source and lineage. Busy sessions are still skipped by the reminder scheduler; this does not make a reminder an acknowledgement or a new debt. There is no unlabelled human `kind:user` prompt and no fallback to `steer` when `followup` is missing. Receipt authorization, queue counting and synchronous reply ownership do not change. An unwritable ledger fails closed; the host does not invent completion or settle production backlog for you.
|
|
76
|
+
|
|
47
77
|
## Idempotency
|
|
48
78
|
|
|
49
79
|
A route carries a fingerprint (caller + message + team + wait-mode). Re-
|
package/lib/task-auth.js
CHANGED
|
@@ -53,7 +53,7 @@ export function sameZoneAdmission(r, caller, targetTeam) {
|
|
|
53
53
|
const declare = (team, rule) => { if (!declared.has(team))
|
|
54
54
|
declared.set(team, rule); };
|
|
55
55
|
let callerSessionId;
|
|
56
|
-
for (const entry of r.memberships()) {
|
|
56
|
+
for (const entry of r.callerMemberships?.() ?? r.memberships()) {
|
|
57
57
|
if (`${r.zone}/${r.id8(entry.session)}` === caller) {
|
|
58
58
|
callerSessionId = entry.session;
|
|
59
59
|
for (const t of entry.teams)
|
|
@@ -61,7 +61,7 @@ export function sameZoneAdmission(r, caller, targetTeam) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
if (callerSessionId === undefined) {
|
|
64
|
-
for (const id of r.joined()) {
|
|
64
|
+
for (const id of r.callerJoined?.() ?? r.joined()) {
|
|
65
65
|
if (`${r.zone}/${r.id8(id)}` === caller) {
|
|
66
66
|
callerSessionId = id;
|
|
67
67
|
break;
|
|
@@ -76,7 +76,7 @@ export function sameZoneAdmission(r, caller, targetTeam) {
|
|
|
76
76
|
for (const name of r.canvasTeamsOf(callerSessionId))
|
|
77
77
|
declare(r.canvasTeamOf(name), 'canvas-shared');
|
|
78
78
|
for (const name of r.canvasNames()) {
|
|
79
|
-
if (r.canvasMembersOf(name).includes(caller))
|
|
79
|
+
if ((r.callerCanvasMembersOf?.(name) ?? r.canvasMembersOf(name)).includes(caller))
|
|
80
80
|
declare(r.canvasTeamOf(name), 'canvas-shared');
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** #356 correspondence between one admitted task and one durable Session inbox occurrence. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
export const TASK_DELIVERY_CAP = 512;
|
|
4
|
+
/** Keep completed/cancelled occurrence identities through the server idempotency window. */
|
|
5
|
+
export const TASK_DELIVERY_RETENTION_MS = 24 * 60 * 60_000;
|
|
6
|
+
export function deliveryFingerprint(sessionId, text, sender, localFrom) {
|
|
7
|
+
return createHash('sha256').update(JSON.stringify([sessionId, text, sender ?? '', localFrom ?? ''])).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
export function parseTaskDelivery(raw) {
|
|
10
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
11
|
+
return undefined;
|
|
12
|
+
const r = raw;
|
|
13
|
+
for (const k of ['taskId', 'messageId', 'sessionId'])
|
|
14
|
+
if (typeof r[k] !== 'string' || r[k].length < 1 || r[k].length > 256)
|
|
15
|
+
return undefined;
|
|
16
|
+
if (r.version !== 1 || typeof r.fingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(r.fingerprint) || !Number.isSafeInteger(r.createdAt) || r.createdAt < 0 || !['reserved', 'enqueued', 'claimed', 'cancelled', 'failed'].includes(String(r.state)))
|
|
17
|
+
return undefined;
|
|
18
|
+
if (r.taskExpected !== undefined && r.taskExpected !== true)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (r.callerKey !== undefined && (typeof r.callerKey !== 'string' || r.callerKey.length > 512))
|
|
21
|
+
return undefined;
|
|
22
|
+
return { ...(r.taskExpected === true ? { taskExpected: true } : {}), ...(typeof r.callerKey === 'string' ? { callerKey: r.callerKey } : {}), version: 1, taskId: r.taskId, messageId: r.messageId, sessionId: r.sessionId, fingerprint: r.fingerprint, createdAt: r.createdAt, state: r.state };
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Atomic single-writer ledger replacement. Never truncate committed bytes on failure. */
|
|
2
|
+
import { openSync, writeFileSync, fsyncSync, closeSync, renameSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
/** Brief AV/indexer contention must not turn into an unbounded synchronous stall. */
|
|
6
|
+
export const LEDGER_RENAME_RETRY_MS = [2, 4];
|
|
7
|
+
const retryWait = new Int32Array(new SharedArrayBuffer(4));
|
|
8
|
+
function renameCommittedFile(from, to) {
|
|
9
|
+
for (let attempt = 0;; attempt++) {
|
|
10
|
+
try {
|
|
11
|
+
renameSync(from, to);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
const code = error?.code;
|
|
16
|
+
if ((code !== 'EPERM' && code !== 'EBUSY') || attempt >= LEDGER_RENAME_RETRY_MS.length)
|
|
17
|
+
throw error;
|
|
18
|
+
// The ledger API is synchronous; at most two waits (6ms total), never delete the target.
|
|
19
|
+
Atomics.wait(retryWait, 0, 0, LEDGER_RENAME_RETRY_MS[attempt]);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function writeTaskLedger(path, bytes) {
|
|
24
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
25
|
+
const temporary = path + '.' + randomUUID() + '.tmp';
|
|
26
|
+
let fd, owned = false, renamed = false;
|
|
27
|
+
try {
|
|
28
|
+
fd = openSync(temporary, 'wx', 0o600);
|
|
29
|
+
owned = true;
|
|
30
|
+
writeFileSync(fd, bytes, { encoding: 'utf8' });
|
|
31
|
+
fsyncSync(fd);
|
|
32
|
+
closeSync(fd);
|
|
33
|
+
fd = undefined;
|
|
34
|
+
renameCommittedFile(temporary, path);
|
|
35
|
+
renamed = true;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
if (fd !== undefined) {
|
|
39
|
+
try {
|
|
40
|
+
closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
catch { /* preserve producer error */ }
|
|
43
|
+
}
|
|
44
|
+
if (owned && !renamed) {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(temporary);
|
|
47
|
+
}
|
|
48
|
+
catch { /* preserve producer error */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/lib/task-ledger-rows.js
CHANGED
|
@@ -118,6 +118,8 @@ export function birthFacts(peer, birth, team = '', sender, now = Date.now(), war
|
|
|
118
118
|
const receiverMembers = remote && birth.forwarded === true && fact(birth.peerKey) ? parseReceiverMembers(birth.receiverMembers, warn) : undefined;
|
|
119
119
|
return {
|
|
120
120
|
...withAuth(birth.auth, { direction: 'outbound', team, peer, sender, forwarded: birth.forwarded }, now),
|
|
121
|
+
...(typeof birth.consumed === 'boolean' ? { consumed: birth.consumed } : {}),
|
|
122
|
+
...(peer === 'local' && fact(birth.receiverSessionId) ? { receiverSessionId: birth.receiverSessionId } : {}),
|
|
121
123
|
...(receiverMembers !== undefined ? { receiverMembers } : {}),
|
|
122
124
|
...(remote && birth.forwarded === true ? { forwarded: true } : {}),
|
|
123
125
|
...(remote && fact(birth.peerKey) ? { peerKey: birth.peerKey } : {}),
|
|
@@ -158,6 +160,7 @@ export function withRouting(entry, warn = console.warn) {
|
|
|
158
160
|
const receiverMembers = remoteOutbound && entry.forwarded === true && fact(entry.peerKey) ? parseReceiverMembers(entry.receiverMembers, warn) : undefined;
|
|
159
161
|
return {
|
|
160
162
|
...(typeof entry.answeredAt === 'number' && Number.isFinite(entry.answeredAt) && entry.answeredAt >= 0 ? { answeredAt: entry.answeredAt } : {}),
|
|
163
|
+
...(entry.receiptDelivery === 'receipt-undelivered' ? { receiptDelivery: 'receipt-undelivered' } : {}),
|
|
161
164
|
...(receiverMembers !== undefined ? { receiverMembers } : {}),
|
|
162
165
|
...(remoteOutbound && entry.forwarded === true ? { forwarded: true } : {}),
|
|
163
166
|
...(remoteOutbound && fact(entry.peerKey) ? { peerKey: entry.peerKey } : {}),
|
|
@@ -165,6 +168,8 @@ export function withRouting(entry, warn = console.warn) {
|
|
|
165
168
|
...(fact(entry.deliveredTo) ? { deliveredTo: entry.deliveredTo } : {}),
|
|
166
169
|
};
|
|
167
170
|
}
|
|
171
|
+
export const WITHDRAWAL_RESULTS = ['removed', 'claimed-steered', 'withdraw-failed-steered', 'no-agent'];
|
|
172
|
+
export function isWithdrawalResult(value) { return typeof value === 'string' && WITHDRAWAL_RESULTS.includes(value); }
|
|
168
173
|
/**
|
|
169
174
|
* How one archived row ended (gh#31). The recorded field wins; legacy rows
|
|
170
175
|
* fall back to the summary markers this ledger writes itself. The fallback
|
package/lib/task-ledger.js
CHANGED
|
@@ -18,11 +18,14 @@
|
|
|
18
18
|
* illusion: settlements vanished while debts piled up).
|
|
19
19
|
* @module @nelsonlongxiang/dsh-open-a2a-net/task-ledger
|
|
20
20
|
*/
|
|
21
|
-
import { existsSync,
|
|
22
|
-
import {
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { writeTaskLedger } from "./task-ledger-persistence.js";
|
|
23
|
+
import { safeObservationCode } from "./nudge-observability.js";
|
|
24
|
+
import { parseTaskDelivery, TASK_DELIVERY_CAP, TASK_DELIVERY_RETENTION_MS } from "./task-delivery.js";
|
|
25
|
+
import { parseReminder, REMINDER_LONG_MS } from "./reconciliation-state.js";
|
|
23
26
|
import { parseReceipt, RECEIPT_OUTCOMES } from "./receipt.js";
|
|
24
27
|
import { classifyConservationBlock, ConservationCounters } from "./ledger-conservation.js";
|
|
25
|
-
import { CALLER_ENDED_PREFIX, TASK_CAP, ARCHIVE_CAP, SUMMARY_CAP, TASK_STALE_TTL_MS, ARCHIVE_ENDS, ECHO_SUMMARY, ROUTING_FACT_CAP, SETTLEMENT_ID_CAP, SETTLEMENT_REFUSAL_CAP, withSender, withAuth, routingOf, birthFacts, withRouting, withSettledBy, parseRefusal, archivedEndedBy } from "./task-ledger-rows.js";
|
|
28
|
+
import { CALLER_ENDED_PREFIX, TASK_CAP, ARCHIVE_CAP, SUMMARY_CAP, TASK_STALE_TTL_MS, ARCHIVE_ENDS, ECHO_SUMMARY, ROUTING_FACT_CAP, SETTLEMENT_ID_CAP, SETTLEMENT_REFUSAL_CAP, withSender, withAuth, routingOf, birthFacts, withRouting, withSettledBy, parseRefusal, archivedEndedBy, WITHDRAWAL_RESULTS, isWithdrawalResult } from "./task-ledger-rows.js";
|
|
26
29
|
export * from "./task-ledger-rows.js";
|
|
27
30
|
/**
|
|
28
31
|
* Bounded outbound-task ledger with whole-file persistence. Pure of any
|
|
@@ -47,6 +50,111 @@ export class TaskLedger {
|
|
|
47
50
|
/** Injectable clock (option `now`). */
|
|
48
51
|
now;
|
|
49
52
|
warn;
|
|
53
|
+
/** Last successful commit, for rollback when a mutation cannot persist. */
|
|
54
|
+
committed = '';
|
|
55
|
+
deliveries = [];
|
|
56
|
+
deliveryRestoreError = false;
|
|
57
|
+
markConsumed(taskId, consumed) {
|
|
58
|
+
const row = this.tasks.find(r => r.taskId === taskId);
|
|
59
|
+
if (!row || row.consumed === true || row.consumed === consumed)
|
|
60
|
+
return;
|
|
61
|
+
row.consumed = consumed;
|
|
62
|
+
this.persist();
|
|
63
|
+
}
|
|
64
|
+
deliveryFor(taskId) {
|
|
65
|
+
if (this.deliveryRestoreError)
|
|
66
|
+
throw Error('task delivery correspondence is corrupt; automatic re-delivery refused');
|
|
67
|
+
const row = this.deliveries.find(r => r.taskId === taskId);
|
|
68
|
+
return row ? { ...row } : undefined;
|
|
69
|
+
}
|
|
70
|
+
deliveryByMessage(sessionId, messageId) {
|
|
71
|
+
if (this.deliveryRestoreError)
|
|
72
|
+
throw Error('task delivery correspondence is corrupt');
|
|
73
|
+
const row = this.deliveries.find(r => r.sessionId === sessionId && r.messageId === messageId);
|
|
74
|
+
return row ? { ...row } : undefined;
|
|
75
|
+
}
|
|
76
|
+
reserveDelivery(record) {
|
|
77
|
+
const parsed = parseTaskDelivery(record);
|
|
78
|
+
if (!parsed)
|
|
79
|
+
throw Error('invalid task delivery correspondence');
|
|
80
|
+
const old = this.deliveryFor(record.taskId);
|
|
81
|
+
if (old) {
|
|
82
|
+
if (old.sessionId !== record.sessionId || old.fingerprint !== record.fingerprint)
|
|
83
|
+
throw Error('task delivery identity conflict');
|
|
84
|
+
return old;
|
|
85
|
+
}
|
|
86
|
+
this.deliveries = this.deliveries.filter(row => this.tasks.some(t => t.taskId === row.taskId) || this.now() - row.createdAt <= TASK_DELIVERY_RETENTION_MS);
|
|
87
|
+
if (this.deliveries.length >= TASK_DELIVERY_CAP)
|
|
88
|
+
throw Error('task delivery correspondence capacity reached; refusing untracked delivery');
|
|
89
|
+
this.deliveries.push(parsed);
|
|
90
|
+
this.persist();
|
|
91
|
+
return { ...parsed };
|
|
92
|
+
}
|
|
93
|
+
updateDelivery(taskId, state) {
|
|
94
|
+
const row = this.deliveries.find(r => r.taskId === taskId);
|
|
95
|
+
if (!row)
|
|
96
|
+
throw Error('unknown task delivery correspondence');
|
|
97
|
+
row.state = state;
|
|
98
|
+
if (state === 'claimed') {
|
|
99
|
+
const task = this.tasks.find(r => r.taskId === taskId);
|
|
100
|
+
if (task)
|
|
101
|
+
task.consumed = true;
|
|
102
|
+
}
|
|
103
|
+
this.persist();
|
|
104
|
+
}
|
|
105
|
+
reminderRecoveryPending = false;
|
|
106
|
+
reminders = Object.create(null);
|
|
107
|
+
reminderSessions = Object.create(null);
|
|
108
|
+
reminderSessionAt(id) { return Object.hasOwn(this.reminderSessions, id) ? this.reminderSessions[id] : undefined; }
|
|
109
|
+
reminderState(taskId) { const r = Object.hasOwn(this.reminders, taskId) ? this.reminders[taskId] : undefined; return r ? { ...r } : undefined; }
|
|
110
|
+
/** Atomic observation update; no task status, answer, or conservation mutation. */
|
|
111
|
+
saveReminders(updates, session) {
|
|
112
|
+
// Validate all input before touching either observation table.
|
|
113
|
+
if (session && (session.id.length < 1 || session.id.length > 256 || (session.at !== undefined && (!Number.isSafeInteger(session.at) || session.at < 0))))
|
|
114
|
+
throw Error('invalid reminder session throttle');
|
|
115
|
+
for (const update of updates)
|
|
116
|
+
if (update.state && !parseReminder(update.state))
|
|
117
|
+
throw Error('invalid reminder state');
|
|
118
|
+
this.reminders = Object.assign(Object.create(null), Object.fromEntries(Object.entries(this.reminders).filter(([id]) => this.tasks.some(row => row.taskId === id && row.status === 'pending'))));
|
|
119
|
+
if (session) {
|
|
120
|
+
if (session.at === undefined)
|
|
121
|
+
delete this.reminderSessions[session.id];
|
|
122
|
+
else if (session.id.length <= 256 && Number.isSafeInteger(session.at) && session.at >= 0)
|
|
123
|
+
this.reminderSessions[session.id] = session.at;
|
|
124
|
+
else
|
|
125
|
+
throw Error('invalid reminder session throttle');
|
|
126
|
+
this.reminderSessions = Object.fromEntries(Object.entries(this.reminderSessions).sort((a, b) => b[1] - a[1]).slice(0, TASK_CAP));
|
|
127
|
+
}
|
|
128
|
+
for (const update of updates) {
|
|
129
|
+
const row = this.tasks.find(r => r.taskId === update.taskId && r.status === 'pending');
|
|
130
|
+
if (!row)
|
|
131
|
+
continue;
|
|
132
|
+
if (update.state) {
|
|
133
|
+
const parsed = parseReminder(update.state);
|
|
134
|
+
if (!parsed)
|
|
135
|
+
throw Error('invalid reminder state');
|
|
136
|
+
this.reminders[update.taskId] = parsed;
|
|
137
|
+
}
|
|
138
|
+
else
|
|
139
|
+
delete this.reminders[update.taskId];
|
|
140
|
+
}
|
|
141
|
+
this.persist();
|
|
142
|
+
}
|
|
143
|
+
admittedFailure;
|
|
144
|
+
admittedFailures = 0;
|
|
145
|
+
noteAdmittedBookkeepingFailure(error) { this.admittedFailures++; this.admittedFailure = { at: this.now(), code: safeObservationCode(error, 'LEDGER_WRITE_FAILED') }; }
|
|
146
|
+
orphanDelivery(taskId) {
|
|
147
|
+
const row = this.deliveryFor(taskId);
|
|
148
|
+
return row?.taskExpected === true && (row.state === 'enqueued' || row.state === 'claimed') && !this.tasks.some(r => r.taskId === taskId) && !this.archived.some(r => r.taskId === taskId);
|
|
149
|
+
}
|
|
150
|
+
deliveryBookkeepingState() {
|
|
151
|
+
return { orphaned: this.deliveries.filter(r => r.taskExpected === true && (r.state === 'enqueued' || r.state === 'claimed') && !this.tasks.some(t => t.taskId === r.taskId) && !this.archived.some(t => t.taskId === r.taskId)).length, failures: this.admittedFailures, ...(this.admittedFailure ? { lastError: { ...this.admittedFailure } } : {}) };
|
|
152
|
+
}
|
|
153
|
+
writeFailure;
|
|
154
|
+
/** Diagnostic-only; never implies that a failed mutation committed. No paths or payloads exposed. */
|
|
155
|
+
persistenceState() {
|
|
156
|
+
return this.writeFailure ? { ok: false, lastError: { ...this.writeFailure } } : { ok: true };
|
|
157
|
+
}
|
|
50
158
|
/**
|
|
51
159
|
* @param path - persistence file (`<dsh-home>/a2a/tasks.json`); empty = no persistence.
|
|
52
160
|
* @param options - TTL/capacity overrides; defaults come from the exported constants.
|
|
@@ -58,6 +166,14 @@ export class TaskLedger {
|
|
|
58
166
|
this.now = options?.now ?? (() => Date.now());
|
|
59
167
|
this.warn = options?.warn ?? console.warn;
|
|
60
168
|
this.restore();
|
|
169
|
+
this.committed = JSON.stringify(this.snapshot());
|
|
170
|
+
if (this.reminderRecoveryPending) {
|
|
171
|
+
// Save the recovery deadline once: repeated restarts must not postpone it forever.
|
|
172
|
+
try {
|
|
173
|
+
this.persist();
|
|
174
|
+
}
|
|
175
|
+
catch { /* diagnostics/last committed debt survive; no send on failure */ }
|
|
176
|
+
}
|
|
61
177
|
}
|
|
62
178
|
/**
|
|
63
179
|
* Every unsettled task (pending + dead-lettered), most recently tracked first.
|
|
@@ -65,7 +181,12 @@ export class TaskLedger {
|
|
|
65
181
|
* @returns the owed-book records.
|
|
66
182
|
*/
|
|
67
183
|
list() {
|
|
68
|
-
|
|
184
|
+
try {
|
|
185
|
+
this.sweep(this.now());
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// sweep already restored the last commit and recorded the write error. Read faces stay available.
|
|
189
|
+
}
|
|
69
190
|
return [...this.tasks];
|
|
70
191
|
}
|
|
71
192
|
/** #275: old forwarded canvas debts cannot be safely upgraded by inventing receiver authority. */
|
|
@@ -162,7 +283,7 @@ export class TaskLedger {
|
|
|
162
283
|
* @param from - the caller's receipt target (label or callback address).
|
|
163
284
|
* @param contextId - the delivery's conversation id, kept for follow-up routes (empty omits it).
|
|
164
285
|
*/
|
|
165
|
-
trackInbound(taskId, team, from, contextId, receiptExpected, brief, auth, deliveredTo) {
|
|
286
|
+
trackInbound(taskId, team, from, contextId, receiptExpected, brief, auth, deliveredTo, receiverSessionId, consumed) {
|
|
166
287
|
if (taskId === '')
|
|
167
288
|
return;
|
|
168
289
|
this.sweep(this.now());
|
|
@@ -177,6 +298,8 @@ export class TaskLedger {
|
|
|
177
298
|
peer: `local:${from}`,
|
|
178
299
|
startedAt: this.now(),
|
|
179
300
|
direction: 'inbound',
|
|
301
|
+
...(typeof consumed === 'boolean' ? { consumed } : {}),
|
|
302
|
+
...(receiverSessionId ? { receiverSessionId } : {}),
|
|
180
303
|
...(receiptExpected === false ? { receiptExpected: false } : {}),
|
|
181
304
|
...(contextId !== undefined && contextId !== '' ? { contextId } : {}),
|
|
182
305
|
...(brief !== undefined && brief !== '' ? { brief } : {}),
|
|
@@ -194,6 +317,7 @@ export class TaskLedger {
|
|
|
194
317
|
if (row === undefined || !(row.direction === 'inbound' || row.peer === 'local') || (row.deliveredTo ?? row.team) !== target || row.answeredAt !== undefined)
|
|
195
318
|
return false;
|
|
196
319
|
row.answeredAt = this.now();
|
|
320
|
+
row.receiptDelivery = 'receipt-undelivered';
|
|
197
321
|
this.persist();
|
|
198
322
|
return true;
|
|
199
323
|
}
|
|
@@ -271,6 +395,15 @@ export class TaskLedger {
|
|
|
271
395
|
* the route layer reads team/contextId from here for its best-effort
|
|
272
396
|
* steer, which is the documented boundary until origin-auth lands.
|
|
273
397
|
*/
|
|
398
|
+
recordWithdrawalResult(taskId, result) {
|
|
399
|
+
if (!WITHDRAWAL_RESULTS.includes(result))
|
|
400
|
+
throw Error('invalid withdrawal result');
|
|
401
|
+
const row = this.archived.find(entry => entry.taskId === taskId && entry.endedBy === 'cancelled');
|
|
402
|
+
if (!row || row.withdrawalResult === result)
|
|
403
|
+
return;
|
|
404
|
+
this.archived = this.archived.map(entry => entry === row ? { ...entry, withdrawalResult: result } : entry);
|
|
405
|
+
this.persist();
|
|
406
|
+
}
|
|
274
407
|
cancel(taskId, reason) {
|
|
275
408
|
if (taskId === '')
|
|
276
409
|
return { outcome: 'unknown', taskId };
|
|
@@ -603,6 +736,36 @@ export class TaskLedger {
|
|
|
603
736
|
if (snapshot.archived !== undefined && !Array.isArray(snapshot.archived)) {
|
|
604
737
|
this.counters.damaged = 'snapshot-shape-invalid';
|
|
605
738
|
}
|
|
739
|
+
if (snapshot.reconciliationReminders && typeof snapshot.reconciliationReminders === 'object') {
|
|
740
|
+
for (const [id, raw] of Object.entries(snapshot.reconciliationReminders).slice(0, TASK_CAP)) {
|
|
741
|
+
const parsed = parseReminder(raw);
|
|
742
|
+
if (parsed)
|
|
743
|
+
this.reminders[id] = parsed;
|
|
744
|
+
else
|
|
745
|
+
this.reminderRecoveryPending = true;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (snapshot.reconciliationSessions && typeof snapshot.reconciliationSessions === 'object') {
|
|
749
|
+
this.reminderSessions = Object.fromEntries(Object.entries(snapshot.reconciliationSessions).filter(([id, at]) => id.length > 0 && id.length <= 256 && Number.isSafeInteger(at) && at >= 0).slice(0, TASK_CAP));
|
|
750
|
+
}
|
|
751
|
+
if (snapshot.taskDeliveryRestoreError === true)
|
|
752
|
+
this.deliveryRestoreError = true;
|
|
753
|
+
if (snapshot.taskDeliveries !== undefined) {
|
|
754
|
+
if (!Array.isArray(snapshot.taskDeliveries) || snapshot.taskDeliveries.length > TASK_DELIVERY_CAP)
|
|
755
|
+
this.deliveryRestoreError = true;
|
|
756
|
+
else {
|
|
757
|
+
const seen = new Set();
|
|
758
|
+
for (const raw of snapshot.taskDeliveries) {
|
|
759
|
+
const parsed = parseTaskDelivery(raw);
|
|
760
|
+
if (!parsed || seen.has(parsed.taskId) || this.deliveries.some(row => row.sessionId === parsed.sessionId && row.messageId === parsed.messageId)) {
|
|
761
|
+
this.deliveryRestoreError = true;
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
seen.add(parsed.taskId);
|
|
765
|
+
this.deliveries.push(parsed);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
606
769
|
const entries = snapshot.tasks;
|
|
607
770
|
const restored = [];
|
|
608
771
|
if (Array.isArray(entries)) {
|
|
@@ -637,6 +800,7 @@ export class TaskLedger {
|
|
|
637
800
|
...withAuth(entry.auth, entry, this.now()),
|
|
638
801
|
...withRouting(entry, this.warn),
|
|
639
802
|
...withSettledBy(entry.settledBy),
|
|
803
|
+
...(isWithdrawalResult(entry.withdrawalResult) ? { withdrawalResult: entry.withdrawalResult } : {}),
|
|
640
804
|
});
|
|
641
805
|
continue;
|
|
642
806
|
}
|
|
@@ -655,6 +819,8 @@ export class TaskLedger {
|
|
|
655
819
|
...withSender(entry.sender, entry.direction),
|
|
656
820
|
...withAuth(entry.auth, entry, this.now()),
|
|
657
821
|
...withRouting(entry, this.warn),
|
|
822
|
+
...(typeof entry.receiverSessionId === 'string' && entry.receiverSessionId.length > 0 && entry.receiverSessionId.length <= 256 ? { receiverSessionId: entry.receiverSessionId } : {}),
|
|
823
|
+
...(typeof entry.consumed === 'boolean' ? { consumed: entry.consumed } : {}),
|
|
658
824
|
status: entry.status === 'dead' ? 'dead' : 'pending',
|
|
659
825
|
...(entry.status === 'dead'
|
|
660
826
|
? {
|
|
@@ -694,6 +860,7 @@ export class TaskLedger {
|
|
|
694
860
|
...withAuth(entry.auth, entry, this.now()),
|
|
695
861
|
...withRouting(entry, this.warn),
|
|
696
862
|
...withSettledBy(entry.settledBy),
|
|
863
|
+
...(isWithdrawalResult(entry.withdrawalResult) ? { withdrawalResult: entry.withdrawalResult } : {}),
|
|
697
864
|
});
|
|
698
865
|
}
|
|
699
866
|
}
|
|
@@ -701,6 +868,14 @@ export class TaskLedger {
|
|
|
701
868
|
// snapshot carried beyond the caps leave the live view but remain in the
|
|
702
869
|
// identity through restoreTrimmed.
|
|
703
870
|
this.tasks = restored.slice(0, TASK_CAP);
|
|
871
|
+
if (this.reminderRecoveryPending) {
|
|
872
|
+
const recoveredAt = this.now();
|
|
873
|
+
for (const row of this.tasks) {
|
|
874
|
+
if (row.status !== 'pending' || !Object.hasOwn(snapshot.reconciliationReminders ?? {}, row.taskId) || this.reminders[row.taskId])
|
|
875
|
+
continue;
|
|
876
|
+
this.reminders[row.taskId] = { version: 1, targetId: row.receiverSessionId ?? 'unresolved-metadata-reset', attempts: 3, deliveredCount: 0, lastAttemptAt: recoveredAt, nextAt: recoveredAt + REMINDER_LONG_MS, requestId: 'metadata-reset', lastResult: 'unknown', diagnostic: 'metadata-reset' };
|
|
877
|
+
}
|
|
878
|
+
}
|
|
704
879
|
this.counters.restoreTrimmed += Math.max(0, restored.length - this.tasks.length);
|
|
705
880
|
this.counters.restoreTrimmed += Math.max(0, this.archived.length - this.archiveCap);
|
|
706
881
|
this.archived = this.archived.slice(0, this.archiveCap);
|
|
@@ -738,23 +913,47 @@ export class TaskLedger {
|
|
|
738
913
|
persist() {
|
|
739
914
|
if (this.path === '')
|
|
740
915
|
return;
|
|
916
|
+
const bytes = JSON.stringify(this.snapshot());
|
|
741
917
|
try {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
...(this.archived.length > 0 ? { archived: this.archived.map(entry => ({ ...entry })) } : {}),
|
|
746
|
-
...(this.refusals.length > 0 ? { settlementRefusals: this.refusals.map(entry => ({ ...entry, settler: { ...entry.settler } })) } : {}),
|
|
747
|
-
...(this.refusalsTotal > 0 ? { settlementRefusalsTotal: this.refusalsTotal } : {}),
|
|
748
|
-
// gh#154: the conservation counters ride every snapshot once any work
|
|
749
|
-
// has been tracked (zero-work ledgers stay byte-identical to before).
|
|
750
|
-
// A damage marker rides too — review r2: it must survive restarts.
|
|
751
|
-
...(this.counters.trackedTotal > 0 || this.counters.damaged !== undefined ? { conservation: this.counters.toSnapshot() } : {}),
|
|
752
|
-
};
|
|
753
|
-
writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
|
|
918
|
+
writeTaskLedger(this.path, bytes);
|
|
919
|
+
this.committed = bytes;
|
|
920
|
+
this.writeFailure = undefined;
|
|
754
921
|
}
|
|
755
|
-
catch {
|
|
756
|
-
|
|
757
|
-
|
|
922
|
+
catch (error) {
|
|
923
|
+
const becameUnavailable = this.writeFailure === undefined;
|
|
924
|
+
this.writeFailure = { at: this.now(), code: safeObservationCode(error, 'LEDGER_WRITE_FAILED') };
|
|
925
|
+
const previous = JSON.parse(this.committed);
|
|
926
|
+
this.reminders = Object.assign(Object.create(null), previous.reconciliationReminders ?? {});
|
|
927
|
+
this.reminderSessions = previous.reconciliationSessions ?? Object.create(null);
|
|
928
|
+
this.deliveryRestoreError = previous.taskDeliveryRestoreError === true;
|
|
929
|
+
this.deliveries = previous.taskDeliveries ? previous.taskDeliveries.map(r => ({ ...r })) : [];
|
|
930
|
+
this.tasks = [...previous.tasks];
|
|
931
|
+
this.archived = [...previous.archived ?? []];
|
|
932
|
+
this.refusals = [...previous.settlementRefusals ?? []];
|
|
933
|
+
this.refusalsTotal = previous.settlementRefusalsTotal ?? 0;
|
|
934
|
+
Object.assign(this.counters, new ConservationCounters(), previous.conservation ?? {});
|
|
935
|
+
if (becameUnavailable)
|
|
936
|
+
try {
|
|
937
|
+
this.warn('a2a: ledgerPersistence FAILED at=' + this.writeFailure.at + ' code=' + this.writeFailure.code);
|
|
938
|
+
}
|
|
939
|
+
catch { /* diagnostic only */ }
|
|
940
|
+
throw error;
|
|
758
941
|
}
|
|
759
942
|
}
|
|
943
|
+
snapshot() {
|
|
944
|
+
return {
|
|
945
|
+
tasks: this.tasks.map(task => ({ ...task })),
|
|
946
|
+
...(this.deliveryRestoreError ? { taskDeliveryRestoreError: true } : {}),
|
|
947
|
+
...(this.deliveries.length ? { taskDeliveries: this.deliveries.map(row => ({ ...row })) } : {}),
|
|
948
|
+
...(Object.keys(this.reminderSessions).length ? { reconciliationSessions: { ...this.reminderSessions } } : {}),
|
|
949
|
+
...(Object.keys(this.reminders).length ? { reconciliationReminders: Object.fromEntries(Object.entries(this.reminders).filter(([id]) => this.tasks.some(row => row.taskId === id && row.status === 'pending'))) } : {}),
|
|
950
|
+
...(this.archived.length > 0 ? { archived: this.archived.map(entry => ({ ...entry })) } : {}),
|
|
951
|
+
...(this.refusals.length > 0 ? { settlementRefusals: this.refusals.map(entry => ({ ...entry, settler: { ...entry.settler } })) } : {}),
|
|
952
|
+
...(this.refusalsTotal > 0 ? { settlementRefusalsTotal: this.refusalsTotal } : {}),
|
|
953
|
+
// gh#154: the conservation counters ride every snapshot once any work
|
|
954
|
+
// has been tracked (zero-work ledgers stay byte-identical to before).
|
|
955
|
+
// A damage marker rides too — review r2: it must survive restarts.
|
|
956
|
+
...(this.counters.trackedTotal > 0 || this.counters.damaged !== undefined ? { conservation: this.counters.toSnapshot() } : {}),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
760
959
|
}
|