dsh-cherry-bridge 0.1.9 → 0.1.11
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.en.md +13 -7
- package/README.md +9 -6
- package/cordis.patch.yml +3 -2
- package/dist/index.d.mts +36 -22
- package/dist/index.mjs +732 -63
- package/docs/e2e-checklist.md +14 -7
- package/package.json +1 -1
- package/src/connection.ts +152 -21
- package/src/defaults.ts +43 -19
- package/src/diagnostics.ts +17 -10
- package/src/index.ts +7 -3
- package/src/tools.ts +799 -52
- package/static/settings-devices.jpg +0 -0
- package/static/settings-pairing-scope.jpg +0 -0
package/src/tools.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from './defaults.js'
|
|
13
13
|
import { JsonRpcRequestError, RequestTimeoutError } from './rpc/jsonRpcClient.js'
|
|
14
14
|
import { ContentReadGate } from './contentGate.js'
|
|
15
|
+
import { truncateErrorMessage } from './diagnostics.js'
|
|
15
16
|
import type { ReadResult, SessionSubscriptions } from './subscriptions.js'
|
|
16
17
|
import { PairingError } from './pairing.js'
|
|
17
18
|
import type { PairingErrorKind } from './pairing.js'
|
|
@@ -290,21 +291,92 @@ export interface CherryReadPart {
|
|
|
290
291
|
toolName?: string
|
|
291
292
|
toolCallId?: string
|
|
292
293
|
/**
|
|
293
|
-
* The DESCRIPTIVE face of
|
|
294
|
-
*
|
|
295
|
-
*
|
|
294
|
+
* The DESCRIPTIVE face of an attachment — a `kind: 'file'` part, or the
|
|
295
|
+
* legacy `kind: 'data'` part the desktop labels `name: 'file'` and whose
|
|
296
|
+
* metadata is JSON in `content.text`: what the file is, never what it holds.
|
|
297
|
+
* `content` stays empty for such a part unless its record carries a key this
|
|
298
|
+
* face does not project — see {@link CherryToolEngine.#attachmentFace}.
|
|
296
299
|
*/
|
|
297
300
|
name?: string
|
|
298
301
|
mediaType?: string
|
|
299
302
|
byteLength?: string
|
|
300
303
|
}
|
|
301
304
|
|
|
305
|
+
/**
|
|
306
|
+
* The peer's verdict on a FAILED message, narrowed to what a caller can act on.
|
|
307
|
+
*
|
|
308
|
+
* The wire carries more (`failure.failure.source`, `context.responseBody`, the
|
|
309
|
+
* provider's status code); none of it is projected. `cherry_read` is a text
|
|
310
|
+
* channel for the conversation, and the provider's raw error body is neither
|
|
311
|
+
* conversation nor the caller's business — the peer already classified the
|
|
312
|
+
* cause into `reasonCode`, which is the actionable half.
|
|
313
|
+
*/
|
|
314
|
+
export interface CherryReadFailure {
|
|
315
|
+
/**
|
|
316
|
+
* The peer's classification (`provider_unavailable`, `rate_limit`,
|
|
317
|
+
* `context_length`, … — the protocol's versioned vocabulary).
|
|
318
|
+
*
|
|
319
|
+
* Relayed as a SHAPE-checked string rather than matched against a closed
|
|
320
|
+
* enum: the vocabulary belongs to the peer and grows with it, and relabelling
|
|
321
|
+
* a new code `'unknown'` would hide a real cause behind a value that means
|
|
322
|
+
* "the peer could not classify". Absent or unusable values become
|
|
323
|
+
* `'unknown'`, which is exactly what the contract uses for that case.
|
|
324
|
+
*/
|
|
325
|
+
reasonCode: string
|
|
326
|
+
/** Whether the peer judged a retry of the same work worthwhile. */
|
|
327
|
+
retryable: boolean
|
|
328
|
+
/**
|
|
329
|
+
* The peer's one-line explanation, bounded by the diagnostics error cap
|
|
330
|
+
* (200 characters) with a `…[truncated]` marker when it was cut.
|
|
331
|
+
*/
|
|
332
|
+
message: string
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** `provider_unavailable`-shaped: lowercase words separated by underscores. */
|
|
336
|
+
const REASON_CODE_SHAPE = /^[a-z][a-z0-9_]{1,63}$/
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Projects the wire's `failure` object (present on `status: 'error'` messages)
|
|
340
|
+
* into {@link CherryReadFailure}, or `undefined` when there is none.
|
|
341
|
+
*
|
|
342
|
+
* Deliberately tolerant of a malformed payload: a peer that sends
|
|
343
|
+
* `failure: { retryable: false }` with no nested record still yields a usable
|
|
344
|
+
* verdict (`reasonCode: 'unknown'`, empty message) instead of failing the whole
|
|
345
|
+
* read — the message's TEXT is already in hand, and losing it over a metadata
|
|
346
|
+
* field would trade the content for the annotation. A degenerate object
|
|
347
|
+
* (`{}`, `[]`) projects the same way: the peer DID attach a verdict, just an
|
|
348
|
+
* empty one, and saying so is more honest than reporting "no verdict". Only an
|
|
349
|
+
* ABSENT or non-object `failure` yields `undefined`.
|
|
350
|
+
*/
|
|
351
|
+
export function projectMessageFailure(raw: Record<string, unknown>): CherryReadFailure | undefined {
|
|
352
|
+
const failure = raw['failure']
|
|
353
|
+
if (typeof failure !== 'object' || failure === null) return undefined
|
|
354
|
+
const record = failure as Record<string, unknown>
|
|
355
|
+
const nested = record['failure']
|
|
356
|
+
const detail = typeof nested === 'object' && nested !== null ? (nested as Record<string, unknown>) : {}
|
|
357
|
+
const rawReason = detail['reasonCode']
|
|
358
|
+
const reasonCode =
|
|
359
|
+
typeof rawReason === 'string' && REASON_CODE_SHAPE.test(rawReason) ? rawReason : 'unknown'
|
|
360
|
+
const rawMessage = record['message']
|
|
361
|
+
return {
|
|
362
|
+
reasonCode,
|
|
363
|
+
retryable: record['retryable'] === true,
|
|
364
|
+
message: truncateErrorMessage(typeof rawMessage === 'string' ? rawMessage : ''),
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
302
368
|
/** One message in a `cherry_read` snapshot. */
|
|
303
369
|
export interface CherryReadMessage {
|
|
304
370
|
messageId: string
|
|
305
371
|
revision: string
|
|
306
372
|
role: string
|
|
307
373
|
status: string
|
|
374
|
+
/**
|
|
375
|
+
* Present exactly when the peer sent a `failure` for this message (in
|
|
376
|
+
* practice: `status: 'error'`). A successful message never carries it, so a
|
|
377
|
+
* caller can test for the key rather than compare `status` strings.
|
|
378
|
+
*/
|
|
379
|
+
failure?: CherryReadFailure
|
|
308
380
|
parts: CherryReadPart[]
|
|
309
381
|
/**
|
|
310
382
|
* Present only when this message's parts could NOT all be read: the safety
|
|
@@ -792,6 +864,80 @@ const MESSAGE_CACHE_ENTRY_BYTES = 1_048_576
|
|
|
792
864
|
*/
|
|
793
865
|
const MESSAGE_CACHE_BYTES_PER_DEVICE = 33_554_432
|
|
794
866
|
|
|
867
|
+
/**
|
|
868
|
+
* One command the engine issued under a CALLER-SUPPLIED `commandId`, kept so a
|
|
869
|
+
* retry can replay it by receipt (issues #115, #118).
|
|
870
|
+
*
|
|
871
|
+
* The three fields answer the three questions a retry raises:
|
|
872
|
+
*
|
|
873
|
+
* - `method` — is this id being replayed by the tool that minted it? An id
|
|
874
|
+
* remembered for a send says nothing about a respond retry, and the reverse;
|
|
875
|
+
* - `fingerprint` — did the caller ask for the SAME command? The caller-owned
|
|
876
|
+
* arguments only, compared with {@link sameCommandValue}; the DERIVED body
|
|
877
|
+
* fields are deliberately excluded, because they are exactly what moved;
|
|
878
|
+
* - `body` — what the peer's ledger recorded, reproduced verbatim.
|
|
879
|
+
*/
|
|
880
|
+
interface RememberedCommand {
|
|
881
|
+
method: string
|
|
882
|
+
fingerprint: unknown
|
|
883
|
+
body: Record<string, unknown>
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* How many command bodies the engine remembers PER DEVICE, keyed by the
|
|
888
|
+
* CALLER-SUPPLIED `commandId` (issues #115, #118).
|
|
889
|
+
*
|
|
890
|
+
* Why a memory exists at all: a command's recovery path is RECEIPT. A reply
|
|
891
|
+
* lost on the wire is answered by re-sending the ORIGINAL body under the
|
|
892
|
+
* original id, and the desktop's ledger replays the recorded outcome. That
|
|
893
|
+
* promise was impossible for `cherry_send`, whose body carries a DERIVED field
|
|
894
|
+
* (`expectedIdleRevision`) that moves with every successful send: the retry
|
|
895
|
+
* quoted a fresh baseline, the body differed from the recorded one, and the peer
|
|
896
|
+
* answered IDEMPOTENCY_CONFLICT — on the one path where the caller cannot tell
|
|
897
|
+
* what already landed. So the engine remembers the EXACT body it emitted, per
|
|
898
|
+
* (device, caller id, method), and a same-id retry replays it verbatim. The
|
|
899
|
+
* same defect had a harder shape in `cherry_respond` / `cherry_cancel` (issue
|
|
900
|
+
* #118): their DERIVED fields were re-derived, and the retry usually met a
|
|
901
|
+
* pre-check (`unknown-interaction`, `no-active-execution`) before the body was
|
|
902
|
+
* ever compared — the memory is consulted before those, which is why it holds
|
|
903
|
+
* the whole issued body rather than a way to rebuild one.
|
|
904
|
+
*
|
|
905
|
+
* What an entry holds is one short body: a session id, the caller's text (at
|
|
906
|
+
* most the protocol's 32,768 characters, pre-validated before it is ever
|
|
907
|
+
* stored), one revision string — or, for respond/cancel, the interaction's
|
|
908
|
+
* staleness triple / the execution to abort, plus the caller-owned arguments
|
|
909
|
+
* the fingerprint compares. 128 is "more than any live retry window needs,
|
|
910
|
+
* small enough to be trivial": a caller retries a handful of command ids around
|
|
911
|
+
* a lost reply, and each is recoverable while it stays in this FIFO.
|
|
912
|
+
* Eviction costs a REQUEST, never correctness — an evicted id has NO entry, so
|
|
913
|
+
* a same-id retry re-derives a fresh baseline. For a SEND the re-derived body
|
|
914
|
+
* reaches the peer, which refuses the differing body exactly as it would
|
|
915
|
+
* without this memory; for a RESPOND or CANCEL the retry usually dies earlier,
|
|
916
|
+
* at the LOCAL pre-checks (`unknown-interaction`, `no-active-execution`,
|
|
917
|
+
* `execution-mismatch`) — the pre-fix failure mode, which names real state.
|
|
918
|
+
*
|
|
919
|
+
* PER DEVICE, like every engine cache: the id is the caller's, but the peer
|
|
920
|
+
* that settled it is not, and a second desktop must never be sent a body minted
|
|
921
|
+
* against the first.
|
|
922
|
+
*
|
|
923
|
+
* INJECTABLE for tests (`commandBodyEntriesPerDevice`), because the production
|
|
924
|
+
* value needs 129 live command ids before it evicts.
|
|
925
|
+
*/
|
|
926
|
+
const COMMAND_BODY_ENTRIES_PER_DEVICE = 128
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The three command methods whose bodies the engine remembers (see
|
|
930
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE}).
|
|
931
|
+
*
|
|
932
|
+
* Named once, because they are used twice each by design: as the remembered
|
|
933
|
+
* entry's `method`, and as the method the entry is compared against on lookup.
|
|
934
|
+
* Spelling them at both ends is how a lookup could start matching the wrong
|
|
935
|
+
* tool without any type noticing.
|
|
936
|
+
*/
|
|
937
|
+
const SEND_METHOD = 'agent.messages.send'
|
|
938
|
+
const RESPOND_METHOD = 'agent.interactions.respond'
|
|
939
|
+
const CANCEL_METHOD = 'agent.executions.cancel'
|
|
940
|
+
|
|
795
941
|
/**
|
|
796
942
|
* EMPTY-PAGE RUN limit for every aggregate walk. The desktop's pinned-list walk
|
|
797
943
|
* legally answers ONE empty page with an advancing cursor (the pin→entity
|
|
@@ -1196,6 +1342,89 @@ const IDEMPOTENCY_HINT =
|
|
|
1196
1342
|
'a retry whose parameters changed — never reuse an id with a different body (re-send the ' +
|
|
1197
1343
|
'ORIGINAL body to replay the original receipt).'
|
|
1198
1344
|
|
|
1345
|
+
/**
|
|
1346
|
+
* The peer's own wording for `IDEMPOTENCY_CONFLICT` (its `agentHandlers` throws
|
|
1347
|
+
* exactly this), used when the ENGINE is the one that knows the body differs —
|
|
1348
|
+
* a caller-supplied `commandId` it has already emitted with other arguments
|
|
1349
|
+
* (issue #115).
|
|
1350
|
+
*
|
|
1351
|
+
* The refusal is LOCAL because the outcome is already decided: the peer's ledger
|
|
1352
|
+
* holds the recorded body, so a forwarded body would come back as this same
|
|
1353
|
+
* conflict, one round trip later, at the cost of a wire call that can never
|
|
1354
|
+
* succeed. Reusing the sentence keeps ONE vocabulary for one condition; callers
|
|
1355
|
+
* (and the existing conflict tests) match on the prefix.
|
|
1356
|
+
*/
|
|
1357
|
+
const COMMAND_BODY_DIFFERS_MESSAGE = 'Command body differs from the recorded command'
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* The JSON-RPC error code the transport reserves for protocol failures (the
|
|
1361
|
+
* server answers every peer-refused request with it). Used for the LOCAL
|
|
1362
|
+
* {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal so it is the same currency a
|
|
1363
|
+
* forwarded one would have been: a `JsonRpcRequestError` carrying
|
|
1364
|
+
* `{reason: 'IDEMPOTENCY_CONFLICT'}`, distinguishable only by the fact that no
|
|
1365
|
+
* request was ever sent.
|
|
1366
|
+
*
|
|
1367
|
+
* A LOCAL twin of the literal `src/rpc/jsonRpcClient.ts` documents in its own
|
|
1368
|
+
* `JsonRpcRequestError` docblock: that module exports no code constant, and
|
|
1369
|
+
* adding one only to name this number would widen the published surface for it.
|
|
1370
|
+
*/
|
|
1371
|
+
const JSON_RPC_PROTOCOL_ERROR_CODE = 1000
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* The LOCAL {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal — the peer's own
|
|
1375
|
+
* reason and sentence, wrapped with the same hints a forwarded conflict earns,
|
|
1376
|
+
* and never sent anywhere.
|
|
1377
|
+
*
|
|
1378
|
+
* ONE constructor for every command that remembers its body (send, respond,
|
|
1379
|
+
* cancel): the caller must not have to learn a second vocabulary for one
|
|
1380
|
+
* condition, and a test matches the prefix `Command body differs` whichever
|
|
1381
|
+
* tool produced it.
|
|
1382
|
+
*/
|
|
1383
|
+
function commandBodyDiffers(): Error {
|
|
1384
|
+
const conflict = new JsonRpcRequestError(JSON_RPC_PROTOCOL_ERROR_CODE, COMMAND_BODY_DIFFERS_MESSAGE, {
|
|
1385
|
+
reason: 'IDEMPOTENCY_CONFLICT',
|
|
1386
|
+
message: COMMAND_BODY_DIFFERS_MESSAGE,
|
|
1387
|
+
})
|
|
1388
|
+
// The two hint helpers pass an error through UNCHANGED (they only append to
|
|
1389
|
+
// a conflict's / a rate-limit's prose), so the identity is the object built
|
|
1390
|
+
// above; the return type keeps the `JsonRpcRequestError` the callers branch
|
|
1391
|
+
// on (the engine's command-failure currency).
|
|
1392
|
+
withRateLimitHint(withIdempotencyHint(conflict))
|
|
1393
|
+
return conflict
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* DEEP structural equality, for the caller-owned half of a remembered command
|
|
1398
|
+
* body (issue #118).
|
|
1399
|
+
*
|
|
1400
|
+
* `cherry_respond`'s `response.answers` is a RECORD: two callers sending the
|
|
1401
|
+
* same answers in a different KEY ORDER are sending the SAME answer, and a
|
|
1402
|
+
* plain `!==` (or a JSON string comparison) would refuse the retry as a
|
|
1403
|
+
* different body — turning the receipt recovery into a local conflict on the
|
|
1404
|
+
* exact shapes it exists for. Arrays stay ORDERED: `questions` is a list, and
|
|
1405
|
+
* reordering it really is a different value.
|
|
1406
|
+
*
|
|
1407
|
+
* Deliberately small: the values compared here are tool arguments decoded from
|
|
1408
|
+
* JSON, so only JSON's two container kinds, the primitives, and `undefined`
|
|
1409
|
+
* (an omitted optional argument) need to be understood.
|
|
1410
|
+
*/
|
|
1411
|
+
function sameCommandValue(left: unknown, right: unknown): boolean {
|
|
1412
|
+
if (left === right) return true
|
|
1413
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
1414
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false
|
|
1415
|
+
if (left.length !== right.length) return false
|
|
1416
|
+
return left.every((value, index) => sameCommandValue(value, right[index]))
|
|
1417
|
+
}
|
|
1418
|
+
if (isRecord(left) && isRecord(right)) {
|
|
1419
|
+
const leftKeys = Object.keys(left)
|
|
1420
|
+
if (leftKeys.length !== Object.keys(right).length) return false
|
|
1421
|
+
return leftKeys.every(
|
|
1422
|
+
(key) => Object.hasOwn(right, key) && sameCommandValue(left[key], right[key]),
|
|
1423
|
+
)
|
|
1424
|
+
}
|
|
1425
|
+
return false
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1199
1428
|
/**
|
|
1200
1429
|
* Passes a command failure through UNCHANGED, except that `IDEMPOTENCY_CONFLICT`
|
|
1201
1430
|
* gets {@link IDEMPOTENCY_HINT} appended to its message.
|
|
@@ -1215,6 +1444,87 @@ function withIdempotencyHint(error: unknown): unknown {
|
|
|
1215
1444
|
return error
|
|
1216
1445
|
}
|
|
1217
1446
|
|
|
1447
|
+
/**
|
|
1448
|
+
* The phrase this hint leaves behind, so applying it twice reads once. A phrase
|
|
1449
|
+
* from the ADVICE rather than the advice itself, because the wait clause varies
|
|
1450
|
+
* with the peer's `retryAfterMs`; matching the peer's own text would match every
|
|
1451
|
+
* rate-limit refusal, hinted or not.
|
|
1452
|
+
*/
|
|
1453
|
+
const RATE_LIMIT_HINT_MARK = 'refuses BEFORE dispatch'
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* How long a refused COMMAND was asked to wait, or undefined when the peer sent
|
|
1457
|
+
* nothing a caller can act on.
|
|
1458
|
+
*
|
|
1459
|
+
* `<= 0` counts as absent rather than "retry immediately": a zero delay is
|
|
1460
|
+
* noise in an error message, and the desktop clamps its own value to `>= 1`
|
|
1461
|
+
* (`RemoteAccessService.ts`). CEILED so a fractional remainder is never
|
|
1462
|
+
* reported as a delay shorter than the one the peer asked for.
|
|
1463
|
+
*/
|
|
1464
|
+
function rateLimitedWaitMs(error: Error): number | undefined {
|
|
1465
|
+
if (!(error instanceof JsonRpcRequestError)) return undefined
|
|
1466
|
+
const data = error.data
|
|
1467
|
+
if (!isRecord(data)) return undefined
|
|
1468
|
+
const raw = data['retryAfterMs']
|
|
1469
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) return undefined
|
|
1470
|
+
return Math.ceil(raw)
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* The recovery advice appended to a command's `RATE_LIMITED` refusal (desktop
|
|
1475
|
+
* >= 2.1.7; see the transport-loss section of `connection.ts`), issue #109.
|
|
1476
|
+
*
|
|
1477
|
+
* Two facts decide the retry and NEITHER survives the tool boundary on its own:
|
|
1478
|
+
*
|
|
1479
|
+
* - **THIS attempt was not applied.** The desktop refuses BEFORE dispatch — its
|
|
1480
|
+
* gateway calls `remote.rpc.refuse(input, …)` INSTEAD of `receive(input)` —
|
|
1481
|
+
* so, unlike a reply lost on a dead link, this is a certain non-outcome.
|
|
1482
|
+
* That is what makes a retry safe, and it is why the SAME `commandId` may be
|
|
1483
|
+
* reused: nothing reached the peer's command ledger, so no
|
|
1484
|
+
* `IDEMPOTENCY_CONFLICT` is waiting for the retry. The sentence says THIS
|
|
1485
|
+
* attempt, not "the command", because an earlier attempt under the same
|
|
1486
|
+
* `commandId` may itself have lost its reply after dispatch: the retry advice
|
|
1487
|
+
* is right in that sequence too (the ledger replays the original receipt),
|
|
1488
|
+
* but "never landed" would be a claim about a run this error knows nothing
|
|
1489
|
+
* of.
|
|
1490
|
+
* - **How long the peer asked us to wait** — `retryAfterMs`, derived from its
|
|
1491
|
+
* own 1 s window and already on the error we are holding. The wait clause
|
|
1492
|
+
* comes BEFORE the retry blessing, so a skimming reader cannot take "safe to
|
|
1493
|
+
* retry" and act on it without waiting.
|
|
1494
|
+
*
|
|
1495
|
+
* The cause is named because at the DEFAULT pacing this refusal is unreachable:
|
|
1496
|
+
* the desktop's counter is per SOCKET (locals inside its `run()` loop, one per
|
|
1497
|
+
* accepted connection) and we pace 32/s shared / 256/s content against its
|
|
1498
|
+
* 64-or-256/s shared and 512/s content lines. Seeing one at all therefore
|
|
1499
|
+
* normally means the configured budget is above the peer's allowance — the knob
|
|
1500
|
+
* to look at, not a transient blip — and a caller told to "retry later" without
|
|
1501
|
+
* that pointer would keep retrying into the same wall. Only the 256/s figure is
|
|
1502
|
+
* quoted: this refusal exists only from 2.1.7, so the older 64/s line cannot be
|
|
1503
|
+
* the one an operator has to compare against.
|
|
1504
|
+
*
|
|
1505
|
+
* Like {@link withIdempotencyHint} this only ever EXTENDS `message`: the reason,
|
|
1506
|
+
* the `data` and the `JsonRpcRequestError` identity are the peer's, and one
|
|
1507
|
+
* command is still never replayed (the advice is for the CALLER's retry).
|
|
1508
|
+
*/
|
|
1509
|
+
export function withRateLimitHint(error: unknown): unknown {
|
|
1510
|
+
if (!(error instanceof Error)) return error
|
|
1511
|
+
if (failureReason(error) !== 'RATE_LIMITED') return error
|
|
1512
|
+
if (error.message.includes(RATE_LIMIT_HINT_MARK)) return error
|
|
1513
|
+
const waitMs = rateLimitedWaitMs(error)
|
|
1514
|
+
const advice = [
|
|
1515
|
+
'this attempt was not applied: the desktop refuses BEFORE dispatch, so nothing reached its ' +
|
|
1516
|
+
'command ledger',
|
|
1517
|
+
waitMs === undefined
|
|
1518
|
+
? 'A retry is safe, and it may reuse the SAME commandId'
|
|
1519
|
+
: `Wait about ${waitMs} ms first (the peer's retryAfterMs); a retry is then safe, and it may ` +
|
|
1520
|
+
'reuse the SAME commandId',
|
|
1521
|
+
'The default budget (32/s) does not come close to the desktop\'s allowance, so a refusal ' +
|
|
1522
|
+
'normally means `rateBudgetPerSecond` was raised too far (2.1.7+ allows 256/s shared)',
|
|
1523
|
+
]
|
|
1524
|
+
error.message = `${error.message} — ${advice.join('. ')}.`
|
|
1525
|
+
return error
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1218
1528
|
function optionalString(value: unknown): string | undefined {
|
|
1219
1529
|
return typeof value === 'string' && value !== '' ? value : undefined
|
|
1220
1530
|
}
|
|
@@ -1327,6 +1637,15 @@ export interface CherryToolEngineOptions {
|
|
|
1327
1637
|
* which no loopback suite should spend its run on.
|
|
1328
1638
|
*/
|
|
1329
1639
|
messageCacheBytesPerDevice?: number
|
|
1640
|
+
/**
|
|
1641
|
+
* Cap on the remembered `cherry_send` bodies for one device — issue #115.
|
|
1642
|
+
* Omitted uses {@link COMMAND_BODY_ENTRIES_PER_DEVICE}.
|
|
1643
|
+
*
|
|
1644
|
+
* Injectable so a test can drive the bound to one and observe the FIFO
|
|
1645
|
+
* arithmetic: at the production 128 a proof needs 129 live command ids, which
|
|
1646
|
+
* no loopback suite should spend its run on.
|
|
1647
|
+
*/
|
|
1648
|
+
commandBodyEntriesPerDevice?: number
|
|
1330
1649
|
/**
|
|
1331
1650
|
* How many `{ref}` contents may be assembled at once across the WHOLE engine
|
|
1332
1651
|
* — every tool call, every device. Omitted uses
|
|
@@ -1337,6 +1656,20 @@ export interface CherryToolEngineOptions {
|
|
|
1337
1656
|
* property under test ("no more than the limit at once") is the same at 1.
|
|
1338
1657
|
*/
|
|
1339
1658
|
contentReadConcurrency?: number
|
|
1659
|
+
/**
|
|
1660
|
+
* The `maxBytes` every `agent.content.read` slice asks for. Omitted uses
|
|
1661
|
+
* {@link CONTENT_READ_MAX_BYTES}, the protocol's `contentBytes`.
|
|
1662
|
+
*
|
|
1663
|
+
* TEST-ONLY, and it exists because the loopback fan-out caps a record at
|
|
1664
|
+
* 32 KiB: at the production 24 KiB no honest ref fits in TWO slices (one
|
|
1665
|
+
* full slice plus the `< 24 KiB` remainder), so a walk's SECOND slice — the
|
|
1666
|
+
* one that proves the ref's digest describes the whole of what is served,
|
|
1667
|
+
* rather than merely its first slice — is unreachable through the fake. A
|
|
1668
|
+
* smaller slice size makes any multi-slice behaviour testable. Never lowered
|
|
1669
|
+
* in production: the peer caps the slice anyway, and asking for less only
|
|
1670
|
+
* costs round trips.
|
|
1671
|
+
*/
|
|
1672
|
+
contentReadSliceBytes?: number
|
|
1340
1673
|
}
|
|
1341
1674
|
|
|
1342
1675
|
/**
|
|
@@ -1385,18 +1718,42 @@ export function registerCherryTools(
|
|
|
1385
1718
|
deps: CherryToolsDeps,
|
|
1386
1719
|
options: CherryToolEngineOptions = {},
|
|
1387
1720
|
): () => void {
|
|
1721
|
+
return registerCherryToolsWithEngine(ctx, deps, options).dispose
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* The same registration, with the ENGINE it built handed back.
|
|
1726
|
+
*
|
|
1727
|
+
* Exists for TESTS: the engine's memory bookkeeping (the remembered command
|
|
1728
|
+
* bodies, the bounds they obey) is private to it on purpose, and the only
|
|
1729
|
+
* honest way to pin "bounded" and "dropped with the device" is to observe it
|
|
1730
|
+
* through this one seam instead of inferring it from retry outcomes that are
|
|
1731
|
+
* identical for an evicted entry and for one that never existed.
|
|
1732
|
+
*
|
|
1733
|
+
* Both this function and `export class CherryToolEngine` are SHIPPED names
|
|
1734
|
+
* (`src` is published on purpose, see AGENTS.md → Build facts), so they are a
|
|
1735
|
+
* compatibility commitment — which is why this seam is kept minimal.
|
|
1736
|
+
*/
|
|
1737
|
+
export function registerCherryToolsWithEngine(
|
|
1738
|
+
ctx: CherryToolsContext,
|
|
1739
|
+
deps: CherryToolsDeps,
|
|
1740
|
+
options: CherryToolEngineOptions = {},
|
|
1741
|
+
): { dispose: () => void; engine: CherryToolEngine | undefined } {
|
|
1388
1742
|
const tools = ctx.tools
|
|
1389
1743
|
if (tools === undefined) {
|
|
1390
1744
|
// Headless deployments have no tool registry. Degrade to a warning rather
|
|
1391
1745
|
// than throwing: the assembly's HTTP/UI half must still come up.
|
|
1392
1746
|
console.warn('[cherry-remote] ctx.tools is unavailable; the cherry_* tools were not registered')
|
|
1393
|
-
return () => {}
|
|
1747
|
+
return { dispose: () => {}, engine: undefined }
|
|
1394
1748
|
}
|
|
1395
1749
|
|
|
1396
1750
|
const engine = new CherryToolEngine(deps, options)
|
|
1397
1751
|
const disposers = engine.definitions().map((definition) => tools.register(definition))
|
|
1398
|
-
return
|
|
1399
|
-
|
|
1752
|
+
return {
|
|
1753
|
+
engine,
|
|
1754
|
+
dispose: () => {
|
|
1755
|
+
for (const dispose of disposers) dispose()
|
|
1756
|
+
},
|
|
1400
1757
|
}
|
|
1401
1758
|
}
|
|
1402
1759
|
|
|
@@ -1423,7 +1780,7 @@ function defineCherryTool(definition: {
|
|
|
1423
1780
|
* share. Kept as one class (rather than per-face closures) so the per-session
|
|
1424
1781
|
* and per-device state the later faces add has an obvious home.
|
|
1425
1782
|
*/
|
|
1426
|
-
class CherryToolEngine {
|
|
1783
|
+
export class CherryToolEngine {
|
|
1427
1784
|
readonly #deps: CherryToolsDeps
|
|
1428
1785
|
/**
|
|
1429
1786
|
* Circuit-breaker for the parts walk. A LOCAL policy value, injectable so a
|
|
@@ -1498,6 +1855,12 @@ class CherryToolEngine {
|
|
|
1498
1855
|
* being read, shared by every tool call and every device this engine serves.
|
|
1499
1856
|
*/
|
|
1500
1857
|
readonly #contentGate: ContentReadGate
|
|
1858
|
+
/**
|
|
1859
|
+
* The `maxBytes` one `agent.content.read` slice asks for
|
|
1860
|
+
* ({@link CONTENT_READ_MAX_BYTES} in production; injectable so a test can
|
|
1861
|
+
* reach a walk's second slice, see `CherryToolEngineOptions`).
|
|
1862
|
+
*/
|
|
1863
|
+
readonly #contentReadSliceBytes: number
|
|
1501
1864
|
/**
|
|
1502
1865
|
* The parts pages already walked, keyed `<deviceId>\0<messageId>`.
|
|
1503
1866
|
*
|
|
@@ -1568,7 +1931,49 @@ class CherryToolEngine {
|
|
|
1568
1931
|
* not pinned by bookkeeping.
|
|
1569
1932
|
*/
|
|
1570
1933
|
readonly #watchedConnections = new WeakSet<DeviceConnection>()
|
|
1571
|
-
|
|
1934
|
+
/**
|
|
1935
|
+
* The EXACT command body emitted for each (device, caller id, method), so a
|
|
1936
|
+
* lost reply can be recovered by receipt (issues #115, #118).
|
|
1937
|
+
*
|
|
1938
|
+
* Keyed `<deviceId>\0<commandId>`, exactly like every other engine cache. The
|
|
1939
|
+
* value is a {@link RememberedCommand}: the METHOD it was minted for, the
|
|
1940
|
+
* caller-owned FINGERPRINT a retry must match, and the BODY the retry
|
|
1941
|
+
* replays verbatim. The fingerprint is what must MATCH — a same-id call whose
|
|
1942
|
+
* arguments differ is not a retry of this command at all, and is refused
|
|
1943
|
+
* locally rather than sent. The body is what must be REPLAYED, derived
|
|
1944
|
+
* fields included: that is the whole point, because those fields are the ones
|
|
1945
|
+
* a retry cannot re-derive the same way.
|
|
1946
|
+
*
|
|
1947
|
+
* Three tools remember: `agent.messages.send` (the baseline it quoted),
|
|
1948
|
+
* `agent.interactions.respond` (the staleness triple) and
|
|
1949
|
+
* `agent.executions.cancel` (the execution it was told to abort). Two of
|
|
1950
|
+
* them run PRE-CHECKS before composing a body — respond's
|
|
1951
|
+
* `unknown-interaction`, cancel's `no-active-execution`/`execution-mismatch` —
|
|
1952
|
+
* and after a lost reply those pre-checks are usually the first thing the
|
|
1953
|
+
* retry meets, because the first attempt is what SETTLED the interaction and
|
|
1954
|
+
* ENDED the run. So a caller-supplied id is consulted BEFORE any derivation:
|
|
1955
|
+
* a remembered retry is an answer, not a fresh call.
|
|
1956
|
+
*
|
|
1957
|
+
* WHAT IS REMEMBERED: only CALLER-SUPPLIED ids. An auto-generated uuid is a
|
|
1958
|
+
* one-shot identity — nobody can retry it, so an entry for it is pure leak.
|
|
1959
|
+
* The entry is written the moment the command is ISSUED, before the reply is
|
|
1960
|
+
* awaited: a reply lost on the wire must not cost the only copy of the bytes
|
|
1961
|
+
* the peer's ledger will accept.
|
|
1962
|
+
*
|
|
1963
|
+
* Which body an id holds: the LATEST attempt issued for it — the first
|
|
1964
|
+
* attempt, or the self-heal's fresh-uuid retry when one ran. After a heal, a
|
|
1965
|
+
* same-id retry is therefore answered with the peer's conflict, deliberately:
|
|
1966
|
+
* replaying the ledger's rejected receipt would re-run the healed admission
|
|
1967
|
+
* and duplicate the run, and a conflict is the safer failure.
|
|
1968
|
+
*
|
|
1969
|
+
* Bounded FIFO, per device; dropped when the PAIRING is invalidated (unpair,
|
|
1970
|
+
* identity-mismatch) but KEPT across an ordinary reconnect — see
|
|
1971
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE} and
|
|
1972
|
+
* {@link CherryToolEngine.#forgetDevice}.
|
|
1973
|
+
*/
|
|
1974
|
+
readonly #commandBodies = new Map<string, RememberedCommand>()
|
|
1975
|
+
/** The per-device cap on {@link CherryToolEngine.#commandBodies}. */
|
|
1976
|
+
readonly #commandBodyEntriesPerDevice: number
|
|
1572
1977
|
constructor(deps: CherryToolsDeps, options: CherryToolEngineOptions = {}) {
|
|
1573
1978
|
this.#deps = deps
|
|
1574
1979
|
this.#partsSafetyBound = Math.max(
|
|
@@ -1592,7 +1997,15 @@ class CherryToolEngine {
|
|
|
1592
1997
|
1,
|
|
1593
1998
|
Math.floor(options.messageCacheBytesPerDevice ?? MESSAGE_CACHE_BYTES_PER_DEVICE),
|
|
1594
1999
|
)
|
|
2000
|
+
this.#commandBodyEntriesPerDevice = Math.max(
|
|
2001
|
+
1,
|
|
2002
|
+
Math.floor(options.commandBodyEntriesPerDevice ?? COMMAND_BODY_ENTRIES_PER_DEVICE),
|
|
2003
|
+
)
|
|
1595
2004
|
this.#contentGate = new ContentReadGate(options.contentReadConcurrency ?? DEFAULT_CONTENT_READ_CONCURRENCY)
|
|
2005
|
+
this.#contentReadSliceBytes = Math.max(
|
|
2006
|
+
1,
|
|
2007
|
+
Math.floor(options.contentReadSliceBytes ?? CONTENT_READ_MAX_BYTES),
|
|
2008
|
+
)
|
|
1596
2009
|
}
|
|
1597
2010
|
|
|
1598
2011
|
definitions(): CherryToolDefinition[] {
|
|
@@ -2445,12 +2858,19 @@ class CherryToolEngine {
|
|
|
2445
2858
|
'sha256-verified through `agent.content.read`. An unchanged message (same message ' +
|
|
2446
2859
|
'revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a ' +
|
|
2447
2860
|
'quiet session costs no `agent.parts.list` RPC; externalized content is still fetched ' +
|
|
2448
|
-
'and sha256-verified on every read, so unverified bytes are never served from memory.
|
|
2449
|
-
'
|
|
2861
|
+
'and sha256-verified on every read, so unverified bytes are never served from memory. An ' +
|
|
2862
|
+
'attachment is reported as METADATA, never as its bytes: a `kind: "file"` part, or a ' +
|
|
2863
|
+
'legacy `data` part named `file` (the metadata as JSON in `content.text`), comes back as ' +
|
|
2864
|
+
'`name`/`mediaType` (plus `byteLength` for the `file` kind) with `content` emptied — or ' +
|
|
2865
|
+
'kept verbatim when that record carries a key this projection does not report. The bytes ' +
|
|
2450
2866
|
'are deliberately not fetched into a text channel. Messages come NEWEST-first, so the ' +
|
|
2451
2867
|
'first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts ' +
|
|
2452
2868
|
'inside a retained message\'s parts; a message whose own parts could not be fully read ' +
|
|
2453
|
-
'carries `incomplete`, and may carry `unavailableParts` when parts were unreadable.
|
|
2869
|
+
'carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. A ' +
|
|
2870
|
+
'failed message (`status: "error"`) may carry `failure: {reasonCode, retryable, ' +
|
|
2871
|
+
'message}` when the peer sent one — its classification, whether it judged a retry ' +
|
|
2872
|
+
'worthwhile, and a bounded summary; the peer\'s `source` and `context.responseBody` ' +
|
|
2873
|
+
'are never projected. The ' +
|
|
2454
2874
|
'answer is ALSO bounded by an output budget: `truncated` with `truncationReason: ' +
|
|
2455
2875
|
'"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
|
|
2456
2876
|
'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
|
|
@@ -2779,11 +3199,13 @@ class CherryToolEngine {
|
|
|
2779
3199
|
// stays absent rather than claiming a zero.
|
|
2780
3200
|
const unavailableParts = parts.filter((part) => part.unavailable !== undefined).length
|
|
2781
3201
|
const incomplete = unavailableParts > 0 || partsTruncated !== undefined
|
|
3202
|
+
const failure = projectMessageFailure(raw)
|
|
2782
3203
|
return {
|
|
2783
3204
|
messageId,
|
|
2784
3205
|
revision,
|
|
2785
3206
|
role: String(raw['role'] ?? ''),
|
|
2786
3207
|
status: String(raw['status'] ?? ''),
|
|
3208
|
+
...(failure === undefined ? {} : { failure }),
|
|
2787
3209
|
parts,
|
|
2788
3210
|
// A circuit-broken part list advertises itself: the caller can see that
|
|
2789
3211
|
// this message is incomplete and that later parts are still unread, so
|
|
@@ -2896,52 +3318,152 @@ class CherryToolEngine {
|
|
|
2896
3318
|
text = ''
|
|
2897
3319
|
}
|
|
2898
3320
|
|
|
3321
|
+
// A LEGACY ATTACHMENT is a `data` part whose metadata is JSON TEXT, not an
|
|
3322
|
+
// object (see {@link CherryToolEngine.#attachmentFace}). The face is lifted
|
|
3323
|
+
// here, from the ALREADY-RESOLVED text — the same text a `{ref}` payload
|
|
3324
|
+
// resolves to — so an externalized metadata record lifts identically.
|
|
3325
|
+
const legacy = this.#legacyAttachment(kind, raw, text)
|
|
2899
3326
|
return {
|
|
2900
3327
|
partId,
|
|
2901
3328
|
kind,
|
|
2902
3329
|
revision,
|
|
2903
|
-
|
|
2904
|
-
//
|
|
2905
|
-
//
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
3330
|
+
// THE CONTENT RULE: the JSON text is emptied only when EVERY key of the
|
|
3331
|
+
// record is one the face projects; a record carrying keys we do not
|
|
3332
|
+
// project keeps its raw text, so nothing is silently dropped.
|
|
3333
|
+
content: legacy === undefined ? text : legacy.remainder,
|
|
3334
|
+
...(legacy === undefined
|
|
3335
|
+
? this.#attachmentFace(kind, raw, content)
|
|
3336
|
+
: this.#attachmentFace(kind, raw, content, legacy.record)),
|
|
2909
3337
|
...(typeof raw['state'] === 'string' ? { state: raw['state'] } : {}),
|
|
2910
3338
|
...(typeof raw['toolName'] === 'string' ? { toolName: raw['toolName'] } : {}),
|
|
2911
3339
|
...(typeof raw['toolCallId'] === 'string' ? { toolCallId: raw['toolCallId'] } : {}),
|
|
2912
3340
|
}
|
|
2913
3341
|
}
|
|
2914
3342
|
|
|
3343
|
+
/**
|
|
3344
|
+
* The ONE place a legacy `data` part is recognised as an attachment: the
|
|
3345
|
+
* structural discriminator, the JSON parse, and the lossless CONTENT rule.
|
|
3346
|
+
*
|
|
3347
|
+
* The legal legacy face is `{kind: 'data', name: 'file', content: {text:
|
|
3348
|
+
* '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
|
|
3349
|
+
* `content.text`, observed on the wire (2.1.7 desktop, image upload,
|
|
3350
|
+
* 2026-10-22). An OBJECT inside `content` is not producible by a conforming
|
|
3351
|
+
* peer at all: the pinned protocol 0.5.0 types `content` as a strict
|
|
3352
|
+
* `{text}` | `{ref}` union, so the old documentation described a shape no
|
|
3353
|
+
* peer can emit — and the real one was never lifted, which is how an image
|
|
3354
|
+
* upload reached a model as a JSON blob.
|
|
3355
|
+
*
|
|
3356
|
+
* WHY THE NAME IS THE DISCRIMINATOR, and why it is not a heuristic: the
|
|
3357
|
+
* desktop labels every `data` part with its data KIND in `name` — an
|
|
3358
|
+
* attachment is `'file'`, a task event is `'data-compaction-anchor'`, an
|
|
3359
|
+
* error is `'data-error'` — and the protocol types that field as an
|
|
3360
|
+
* `opaqueId`. This lift therefore REQUIRES the label to be the literal
|
|
3361
|
+
* `'file'` rather than ASSUMING every peer sets one: a `data` part that
|
|
3362
|
+
* carries no name reads as no name, is not lifted, and passes through
|
|
3363
|
+
* untouched. Several of those payloads are JSON too, so "the text parses"
|
|
3364
|
+
* proves nothing; `'file'` is the one label that means "this is an
|
|
3365
|
+
* attachment".
|
|
3366
|
+
*
|
|
3367
|
+
* NOTHING IS EVER DEGRADED: a text that does not parse, parses to a
|
|
3368
|
+
* non-record, or carries neither `mediaType` nor `filename` as a string is
|
|
3369
|
+
* not an attachment, and the part passes through COMPLETELY unchanged
|
|
3370
|
+
* (`remainder` is the raw text, no field is lifted) — this runs on every
|
|
3371
|
+
* `data` part of every read, so a throw here would take down the whole
|
|
3372
|
+
* snapshot.
|
|
3373
|
+
*
|
|
3374
|
+
* THE CONTENT RULE: `remainder` is EMPTY when every key of the record is one
|
|
3375
|
+
* this face projects (`mediaType`, `filename`) — the metadata is fully
|
|
3376
|
+
* represented, and a model sees structure rather than a blob. A record
|
|
3377
|
+
* carrying ANY other key keeps its raw text, so a field we do not project is
|
|
3378
|
+
* never silently dropped (lossless by default). That promise is about KEYS: a
|
|
3379
|
+
* key we DO project whose value is not a string (say `mediaType: 123`) is
|
|
3380
|
+
* counted as represented — the key survives — and reports no field, because
|
|
3381
|
+
* this face lifts strings only. Emptied text therefore means "every key was
|
|
3382
|
+
* one of ours", not "every value produced a field".
|
|
3383
|
+
*/
|
|
3384
|
+
#legacyAttachment(
|
|
3385
|
+
kind: string,
|
|
3386
|
+
raw: Record<string, unknown>,
|
|
3387
|
+
text: string,
|
|
3388
|
+
): { record: Record<string, unknown>; remainder: string } | undefined {
|
|
3389
|
+
if (kind !== 'data' || optionalString(raw['name']) !== 'file') return undefined
|
|
3390
|
+
let parsed: unknown
|
|
3391
|
+
try {
|
|
3392
|
+
parsed = JSON.parse(text) as unknown
|
|
3393
|
+
} catch {
|
|
3394
|
+
return undefined
|
|
3395
|
+
}
|
|
3396
|
+
if (!isRecord(parsed)) return undefined
|
|
3397
|
+
// At least ONE projected key must be a string: a record naming neither
|
|
3398
|
+
// `mediaType` nor `filename` says nothing about an attachment, whatever
|
|
3399
|
+
// its name label claims.
|
|
3400
|
+
if (optionalString(parsed['filename']) === undefined && optionalString(parsed['mediaType']) === undefined) {
|
|
3401
|
+
return undefined
|
|
3402
|
+
}
|
|
3403
|
+
const everythingLifted = Object.keys(parsed).every((key) => key === 'mediaType' || key === 'filename')
|
|
3404
|
+
return { record: parsed, remainder: everythingLifted ? '' : text }
|
|
3405
|
+
}
|
|
3406
|
+
|
|
2915
3407
|
/**
|
|
2916
3408
|
* The descriptive metadata of an attachment part — and deliberately NOT a
|
|
2917
3409
|
* way to reach its bytes.
|
|
2918
3410
|
*
|
|
2919
|
-
* Two
|
|
2920
|
-
*
|
|
3411
|
+
* Two legal faces are covered, because both are inside the verified range and
|
|
3412
|
+
* they disagree about where the metadata lives:
|
|
2921
3413
|
*
|
|
2922
3414
|
* - **Cherry ≥ 2.1.5** — `{kind: 'file', name, ref}`, ref at the TOP level.
|
|
2923
|
-
* - **
|
|
3415
|
+
* - **legacy** — `{kind: 'data', name: 'file', content: {text: '<JSON>'}}`,
|
|
3416
|
+
* the metadata as JSON TEXT. {@link CherryToolEngine.#legacyAttachment}
|
|
3417
|
+
* parses it and passes the record in here.
|
|
3418
|
+
*
|
|
3419
|
+
* A THIRD arm — a `data` part carrying `{mediaType, filename}` as an OBJECT
|
|
3420
|
+
* inside `content` — is schema-ILLEGAL under the pinned protocol (see the
|
|
3421
|
+
* docblock above) and stays as a DEFENSIVE lift for a peer that violates it.
|
|
3422
|
+
* It is not the 2.1.4 projection: that story was wrong, and a misbehaving
|
|
3423
|
+
* peer's parts are not re-validated client-side, so the guarded arm costs
|
|
3424
|
+
* nothing and covers exactly the peer it was written for.
|
|
2924
3425
|
*
|
|
2925
3426
|
* Only string fields are lifted: a null `filename` (the peer's explicit "no
|
|
2926
3427
|
* name") stays absent rather than becoming the string "null". Anything a
|
|
2927
3428
|
* caller needs beyond this — the bytes — stays behind `agent.content.read`,
|
|
2928
3429
|
* which this plugin does not call for a file part.
|
|
3430
|
+
*
|
|
3431
|
+
* WHEN A PARSED `metadata` RECORD IS PRESENT IT IS AUTHORITATIVE. A legacy
|
|
3432
|
+
* attachment's record can arrive externalized as `{ref}`, and that ref is the
|
|
3433
|
+
* peer's serialization ENVELOPE, not the attachment's description: its
|
|
3434
|
+
* `mediaType` is the literal `'text/plain'` the envelope is labelled with and
|
|
3435
|
+
* its `byteLength` is the JSON metadata's own byte count. Letting either
|
|
3436
|
+
* outrank the record would report `text/plain` as an image's media type and a
|
|
3437
|
+
* size that is not the file's — so the record wins the media type, and this
|
|
3438
|
+
* face reports NO `byteLength` at all (a legacy `data` part carries none). The
|
|
3439
|
+
* `file` arm is untouched: there the ref IS the file's own description.
|
|
2929
3440
|
*/
|
|
2930
3441
|
#attachmentFace(
|
|
2931
3442
|
kind: string,
|
|
2932
3443
|
raw: Record<string, unknown>,
|
|
2933
3444
|
content: Record<string, unknown>,
|
|
3445
|
+
metadata?: Record<string, unknown>,
|
|
2934
3446
|
): { name?: string; mediaType?: string; byteLength?: string } {
|
|
2935
3447
|
if (kind !== 'file' && kind !== 'data') return {}
|
|
2936
3448
|
const ref = isRecord(raw['ref']) ? raw['ref'] : isRecord(content['ref']) ? content['ref'] : {}
|
|
2937
3449
|
// WHERE the name lives is part of the shape, not a matter of preference: on
|
|
2938
3450
|
// a `file` part `name` IS the filename, while a `data` part's `name` is the
|
|
2939
|
-
// data KIND label
|
|
2940
|
-
//
|
|
2941
|
-
// the data kind would report "file" as the attachment's name.
|
|
2942
|
-
const name =
|
|
2943
|
-
|
|
2944
|
-
|
|
3451
|
+
// data KIND label (the attachment's is the literal `'file'`), so a legacy
|
|
3452
|
+
// filename can only come from the metadata record's `filename`. Reading
|
|
3453
|
+
// `name` for the data kind would report "file" as the attachment's name.
|
|
3454
|
+
const name =
|
|
3455
|
+
kind === 'file'
|
|
3456
|
+
? optionalString(raw['name'])
|
|
3457
|
+
: (optionalString(metadata?.['filename']) ?? optionalString(content['filename']))
|
|
3458
|
+
const mediaType =
|
|
3459
|
+
kind === 'data' && metadata !== undefined
|
|
3460
|
+
? (optionalString(metadata['mediaType']) ?? optionalString(content['mediaType']))
|
|
3461
|
+
: (optionalString(ref['mediaType']) ??
|
|
3462
|
+
optionalString(metadata?.['mediaType']) ??
|
|
3463
|
+
optionalString(content['mediaType']))
|
|
3464
|
+
// A legacy `data` part carries no `byteLength`: the only ref it can have is
|
|
3465
|
+
// the metadata envelope's, whose length describes the RECORD.
|
|
3466
|
+
const byteLength = kind === 'file' ? optionalString(ref['byteLength']) : undefined
|
|
2945
3467
|
return {
|
|
2946
3468
|
...(name === undefined ? {} : { name }),
|
|
2947
3469
|
...(mediaType === undefined ? {} : { mediaType }),
|
|
@@ -2967,13 +3489,23 @@ class CherryToolEngine {
|
|
|
2967
3489
|
* back a snapshot that looks complete while containing content the peer
|
|
2968
3490
|
* never certified. That is worse than an error: the caller cannot tell it
|
|
2969
3491
|
* apart from a good read. It keeps failing loudly.
|
|
3492
|
+
* - **NOT degradable — a RATE LIMIT.** A `RATE_LIMITED` refusal is
|
|
3493
|
+
* SESSION-wide, not part-specific: the peer refused the arrival, it did not
|
|
3494
|
+
* fail to serve THIS part. Degrading would hand back a "complete" message
|
|
3495
|
+
* whose text is empty for a reason the caller could act on (wait and ask
|
|
3496
|
+
* again) — and if one slice is refused the others will be too, so the
|
|
3497
|
+
* snapshot would be missing precisely what was requested. The connection
|
|
3498
|
+
* already waited out `retryAfterMs` and replayed the slice ONCE, so a
|
|
3499
|
+
* second refusal is a real failure and the whole read fails with it.
|
|
2970
3500
|
*
|
|
2971
3501
|
* The discriminator is the DIGEST CHECK specifically, which is why
|
|
2972
3502
|
* {@link CherryToolEngine.#readExternalized} marks it: matching on message
|
|
2973
|
-
* text would break the moment the wording changed.
|
|
3503
|
+
* text would break the moment the wording changed. The rate-limit arm keys on
|
|
3504
|
+
* the peer's REASON, never on wording.
|
|
2974
3505
|
*/
|
|
2975
3506
|
#degradableReadFailure(error: unknown): boolean {
|
|
2976
|
-
|
|
3507
|
+
if (error instanceof ContentIntegrityError) return false
|
|
3508
|
+
return failureReason(error) !== 'RATE_LIMITED'
|
|
2977
3509
|
}
|
|
2978
3510
|
|
|
2979
3511
|
/**
|
|
@@ -3042,7 +3574,7 @@ class CherryToolEngine {
|
|
|
3042
3574
|
const refusal = this.#externalizedRefusal(ref)
|
|
3043
3575
|
if (refusal !== undefined) throw refusal
|
|
3044
3576
|
const maxSlices = Number.isSafeInteger(declaredBytes)
|
|
3045
|
-
? Math.max(1, Math.ceil(declaredBytes /
|
|
3577
|
+
? Math.max(1, Math.ceil(declaredBytes / this.#contentReadSliceBytes))
|
|
3046
3578
|
: DEFAULT_CONTENT_SLICE_BOUND
|
|
3047
3579
|
|
|
3048
3580
|
let offset = '0'
|
|
@@ -3071,7 +3603,9 @@ class CherryToolEngine {
|
|
|
3071
3603
|
contentId,
|
|
3072
3604
|
revision,
|
|
3073
3605
|
offset,
|
|
3074
|
-
|
|
3606
|
+
// The protocol's slice size in production; injectable for tests that
|
|
3607
|
+
// must reach a walk's SECOND slice (see the option's docblock).
|
|
3608
|
+
maxBytes: this.#contentReadSliceBytes,
|
|
3075
3609
|
},
|
|
3076
3610
|
deadline === undefined ? undefined : { deadline: deadline.instant },
|
|
3077
3611
|
)) as Record<string, unknown>
|
|
@@ -3607,12 +4141,37 @@ class CherryToolEngine {
|
|
|
3607
4141
|
request: { args: Record<string, unknown>; text: string },
|
|
3608
4142
|
): Promise<{ receipt: Record<string, unknown>; baselineSeq: number }> {
|
|
3609
4143
|
const { args, text } = request
|
|
4144
|
+
const callerId = optionalString(args['commandId'])
|
|
3610
4145
|
const attempt = async (
|
|
3611
4146
|
commandId: string,
|
|
3612
4147
|
revisionOverride?: string,
|
|
3613
4148
|
): Promise<{ receipt: Record<string, unknown>; baselineSeq: number }> => {
|
|
4149
|
+
// The memory belongs to the id it was MINTED for, so the lookup is gated
|
|
4150
|
+
// on `commandId === callerId`: the self-heal's fresh-uuid attempt derives
|
|
4151
|
+
// its own baseline instead of inheriting the caller's.
|
|
4152
|
+
const remembered =
|
|
4153
|
+
callerId === undefined || commandId !== callerId
|
|
4154
|
+
? undefined
|
|
4155
|
+
: this.#recallCommandBody(deviceId, callerId)
|
|
4156
|
+
// A remembered id that belongs to ANOTHER method is a mismatch, not a
|
|
4157
|
+
// miss: refusing locally keeps the decision off the wire.
|
|
4158
|
+
if (remembered !== undefined && remembered.method !== SEND_METHOD) throw commandBodyDiffers()
|
|
4159
|
+
// A same-id call whose caller-owned arguments differ from the remembered
|
|
4160
|
+
// body is not a retry of that command: the peer holds the original body,
|
|
4161
|
+
// so the conflict is already decided and is answered LOCALLY, in the
|
|
4162
|
+
// peer's own words, with no wire call.
|
|
4163
|
+
const rememberedBody = remembered?.body
|
|
4164
|
+
const mismatched =
|
|
4165
|
+
remembered !== undefined &&
|
|
4166
|
+
(rememberedBody?.['sessionId'] !== sessionId || rememberedBody['text'] !== text)
|
|
4167
|
+
if (mismatched) throw commandBodyDiffers()
|
|
4168
|
+
// THE REMEMBERED BODY IS REPLAYED, derived field included. Rebuilding it
|
|
4169
|
+
// from a derived `expectedIdleRevision` instead is what #115's memory
|
|
4170
|
+
// exists to stop, so the value is read back rather than recomputed.
|
|
3614
4171
|
const expectedIdleRevision =
|
|
3615
|
-
|
|
4172
|
+
optionalString(rememberedBody?.['expectedIdleRevision']) ??
|
|
4173
|
+
revisionOverride ??
|
|
4174
|
+
(await this.#idleRevision(connection, subscriptions, deviceId, sessionId))
|
|
3616
4175
|
// THE BASELINE IS TAKEN BEFORE THE COMMAND IS ISSUED, and that ordering
|
|
3617
4176
|
// is what makes a fast run observable at all. The waiter's baseline is
|
|
3618
4177
|
// the cut that decides which events belong to THIS run; taking it after
|
|
@@ -3624,7 +4183,16 @@ class CherryToolEngine {
|
|
|
3624
4183
|
// Nothing before this point can emit this run's events: the command has
|
|
3625
4184
|
// not been sent yet.
|
|
3626
4185
|
const baselineSeq = this.#newestSeq(subscriptions, sessionId)
|
|
3627
|
-
|
|
4186
|
+
// The body is remembered the moment it is ISSUED — a reply lost on the wire
|
|
4187
|
+
// must not cost the only copy of the bytes the peer's ledger will accept.
|
|
4188
|
+
if (callerId !== undefined) {
|
|
4189
|
+
this.#rememberCommandBody(deviceId, callerId, {
|
|
4190
|
+
method: SEND_METHOD,
|
|
4191
|
+
fingerprint: { sessionId, text },
|
|
4192
|
+
body: { commandId, sessionId, text, expectedIdleRevision },
|
|
4193
|
+
})
|
|
4194
|
+
}
|
|
4195
|
+
const receipt = await this.#command(connection, SEND_METHOD, {
|
|
3628
4196
|
commandId,
|
|
3629
4197
|
sessionId,
|
|
3630
4198
|
text,
|
|
@@ -4020,6 +4588,26 @@ class CherryToolEngine {
|
|
|
4020
4588
|
}
|
|
4021
4589
|
}
|
|
4022
4590
|
|
|
4591
|
+
const callerId = optionalString(args['commandId'])
|
|
4592
|
+
const answer: Record<string, unknown> = response === undefined ? { decision } : { response }
|
|
4593
|
+
// A RETRY IS ANSWERED BEFORE ANYTHING IS DERIVED. The staleness triple
|
|
4594
|
+
// below is derived from what the plugin has observed, and after a lost
|
|
4595
|
+
// reply that observation has MOVED (the first answer settled the card)
|
|
4596
|
+
// or vanished (the peer no longer serves it). Re-deriving would either
|
|
4597
|
+
// quote a body the ledger never recorded or — the usual case — stop at
|
|
4598
|
+
// the `unknown-interaction` verdict below, before the body is ever
|
|
4599
|
+
// compared. So a caller-supplied id is consulted FIRST: same id, same
|
|
4600
|
+
// caller-owned arguments and same method replays the remembered body
|
|
4601
|
+
// verbatim, and the snapshot is not asked at all.
|
|
4602
|
+
const remembered =
|
|
4603
|
+
callerId === undefined ? undefined : this.#recallCommandBody(record.deviceId, callerId)
|
|
4604
|
+
if (remembered !== undefined && remembered.method !== RESPOND_METHOD) throw commandBodyDiffers()
|
|
4605
|
+
if (remembered !== undefined) {
|
|
4606
|
+
const fingerprint = { sessionId, interactionId, ...answer }
|
|
4607
|
+
if (!sameCommandValue(remembered.fingerprint, fingerprint)) throw commandBodyDiffers()
|
|
4608
|
+
return this.#command(connection, RESPOND_METHOD, remembered.body)
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4023
4611
|
await this.#ensureSubscription(record.deviceId, sessionId)
|
|
4024
4612
|
const subscriptions = this.#deps.subs(record.deviceId)
|
|
4025
4613
|
|
|
@@ -4042,15 +4630,27 @@ class CherryToolEngine {
|
|
|
4042
4630
|
} satisfies Record<string, unknown>
|
|
4043
4631
|
}
|
|
4044
4632
|
|
|
4045
|
-
|
|
4046
|
-
|
|
4633
|
+
const commandId = this.#commandId(args)
|
|
4634
|
+
const body = {
|
|
4635
|
+
commandId,
|
|
4047
4636
|
sessionId,
|
|
4048
4637
|
interactionId,
|
|
4049
4638
|
expectedRevision: snapshot.revision,
|
|
4050
4639
|
expectedExecutionId: snapshot.executionId,
|
|
4051
4640
|
inputDigest: snapshot.inputDigest,
|
|
4052
|
-
...
|
|
4053
|
-
}
|
|
4641
|
+
...answer,
|
|
4642
|
+
}
|
|
4643
|
+
// ISSUE-TIME WRITE: the body is fully composed here, so it is remembered
|
|
4644
|
+
// before the reply is awaited — a reply lost on the wire must not cost
|
|
4645
|
+
// the only copy of the bytes the peer's ledger will accept.
|
|
4646
|
+
if (callerId !== undefined) {
|
|
4647
|
+
this.#rememberCommandBody(record.deviceId, callerId, {
|
|
4648
|
+
method: RESPOND_METHOD,
|
|
4649
|
+
fingerprint: { sessionId, interactionId, ...answer },
|
|
4650
|
+
body,
|
|
4651
|
+
})
|
|
4652
|
+
}
|
|
4653
|
+
return this.#command(connection, RESPOND_METHOD, body)
|
|
4054
4654
|
},
|
|
4055
4655
|
})
|
|
4056
4656
|
}
|
|
@@ -4092,6 +4692,27 @@ class CherryToolEngine {
|
|
|
4092
4692
|
if (resolved.link === undefined) return resolved.verdict
|
|
4093
4693
|
const { connection, record } = resolved.link
|
|
4094
4694
|
const sessionId = requiredString(args, 'sessionId')
|
|
4695
|
+
const callerId = optionalString(args['commandId'])
|
|
4696
|
+
// The caller's own value is a PRE-CHECK, not a body field — but it is
|
|
4697
|
+
// part of what the caller ASKED FOR, so it belongs in the fingerprint: a
|
|
4698
|
+
// retry asserting a different execution is not a retry of this cancel.
|
|
4699
|
+
const supplied = optionalString(args['expectedExecutionId'])
|
|
4700
|
+
|
|
4701
|
+
// A RETRY IS ANSWERED BEFORE THE PRE-CHECKS — the same ordering (and
|
|
4702
|
+
// the same reason) as `agent.interactions.respond`'s consult above;
|
|
4703
|
+
// what differs here is only which verdicts would otherwise eat the
|
|
4704
|
+
// retry: `no-active-execution` and `execution-mismatch` describe the
|
|
4705
|
+
// state RIGHT NOW, and a lost reply left the run the first attempt
|
|
4706
|
+
// cancelled already finished.
|
|
4707
|
+
const remembered =
|
|
4708
|
+
callerId === undefined ? undefined : this.#recallCommandBody(record.deviceId, callerId)
|
|
4709
|
+
if (remembered !== undefined && remembered.method !== CANCEL_METHOD) throw commandBodyDiffers()
|
|
4710
|
+
if (remembered !== undefined) {
|
|
4711
|
+
if (!sameCommandValue(remembered.fingerprint, { sessionId, expectedExecutionId: supplied })) {
|
|
4712
|
+
throw commandBodyDiffers()
|
|
4713
|
+
}
|
|
4714
|
+
return this.#command(connection, CANCEL_METHOD, remembered.body)
|
|
4715
|
+
}
|
|
4095
4716
|
|
|
4096
4717
|
await this.#ensureSubscription(record.deviceId, sessionId)
|
|
4097
4718
|
const subscriptions = this.#deps.subs(record.deviceId)
|
|
@@ -4107,7 +4728,6 @@ class CherryToolEngine {
|
|
|
4107
4728
|
message: `session ${sessionId} has no in-flight execution to cancel`,
|
|
4108
4729
|
} satisfies Record<string, unknown>
|
|
4109
4730
|
}
|
|
4110
|
-
const supplied = optionalString(args['expectedExecutionId'])
|
|
4111
4731
|
if (supplied !== undefined && supplied !== expectedExecutionId) {
|
|
4112
4732
|
return {
|
|
4113
4733
|
error: 'execution-mismatch',
|
|
@@ -4119,11 +4739,18 @@ class CherryToolEngine {
|
|
|
4119
4739
|
} satisfies Record<string, unknown>
|
|
4120
4740
|
}
|
|
4121
4741
|
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4742
|
+
const commandId = this.#commandId(args)
|
|
4743
|
+
const body = { commandId, sessionId, expectedExecutionId }
|
|
4744
|
+
// ISSUE-TIME WRITE, as respond's: the derived execution id is exactly
|
|
4745
|
+
// the field a retry cannot re-derive once the run has ended.
|
|
4746
|
+
if (callerId !== undefined) {
|
|
4747
|
+
this.#rememberCommandBody(record.deviceId, callerId, {
|
|
4748
|
+
method: CANCEL_METHOD,
|
|
4749
|
+
fingerprint: { sessionId, expectedExecutionId: supplied },
|
|
4750
|
+
body,
|
|
4751
|
+
})
|
|
4752
|
+
}
|
|
4753
|
+
return this.#command(connection, CANCEL_METHOD, body)
|
|
4127
4754
|
},
|
|
4128
4755
|
})
|
|
4129
4756
|
}
|
|
@@ -4284,7 +4911,7 @@ class CherryToolEngine {
|
|
|
4284
4911
|
// pairing's idle revisions and execution ids, and would grow the maps
|
|
4285
4912
|
// for the lifetime of the process. The store record is untouched by
|
|
4286
4913
|
// this — the caches are pure derived state and can always be rebuilt.
|
|
4287
|
-
this.#forgetDevice(record.deviceId)
|
|
4914
|
+
this.#forgetDevice(record.deviceId, { dropCommandBodies: true })
|
|
4288
4915
|
await this.#deps.evict(record.deviceId)
|
|
4289
4916
|
// The remove is GUARDED, not merely ordered. `#selectRecords` already
|
|
4290
4917
|
// rejects a quarantined store before a record can be named, so reaching
|
|
@@ -4330,12 +4957,12 @@ class CherryToolEngine {
|
|
|
4330
4957
|
* subscription failures still reject this call: the two surfaces stay
|
|
4331
4958
|
* distinct all the way to the caller (spec §6).
|
|
4332
4959
|
*
|
|
4333
|
-
*
|
|
4960
|
+
* TWO exceptions, and both are HINTS rather than translations: the peer's
|
|
4334
4961
|
* `IDEMPOTENCY_CONFLICT` — the same `commandId` re-used with a DIFFERENT body
|
|
4335
|
-
* —
|
|
4336
|
-
*
|
|
4337
|
-
*
|
|
4338
|
-
* half.
|
|
4962
|
+
* — and its `RATE_LIMITED` refusal (desktop >= 2.1.7) each carry their
|
|
4963
|
+
* recovery advice on the rethrown error (see {@link withIdempotencyHint} /
|
|
4964
|
+
* {@link withRateLimitHint}). The reason, the `data` and the failure currency
|
|
4965
|
+
* are untouched; only the prose a caller reads grows the actionable half.
|
|
4339
4966
|
*/
|
|
4340
4967
|
async #command(
|
|
4341
4968
|
connection: DeviceConnection,
|
|
@@ -4345,7 +4972,7 @@ class CherryToolEngine {
|
|
|
4345
4972
|
try {
|
|
4346
4973
|
return (await connection.rpc.request(method, params)) as Record<string, unknown>
|
|
4347
4974
|
} catch (error) {
|
|
4348
|
-
throw withIdempotencyHint(error)
|
|
4975
|
+
throw withRateLimitHint(withIdempotencyHint(error))
|
|
4349
4976
|
}
|
|
4350
4977
|
}
|
|
4351
4978
|
|
|
@@ -4645,7 +5272,8 @@ class CherryToolEngine {
|
|
|
4645
5272
|
* the stored credential no longer describes a valid pairing. A transient
|
|
4646
5273
|
* network drop ALSO lands here and clears the caches unnecessarily; that
|
|
4647
5274
|
* is accepted, because the cost is one round trip per session on reuse and
|
|
4648
|
-
* the alternative is keeping snapshots across an identity change.
|
|
5275
|
+
* the alternative is keeping snapshots across an identity change. (The
|
|
5276
|
+
* remembered command BODIES are the one exception: see `#forgetDevice`.)
|
|
4649
5277
|
* - `identity-mismatch` — the peer's pinned key changed: the pairing is over.
|
|
4650
5278
|
*
|
|
4651
5279
|
* {@link CherryToolEngine.#resolve} installs the watch on every tool call that
|
|
@@ -4673,7 +5301,8 @@ class CherryToolEngine {
|
|
|
4673
5301
|
this.#watchedConnections.add(connection)
|
|
4674
5302
|
watch(deviceId, (state) => {
|
|
4675
5303
|
if (state !== 'offline' && state !== 'identity-mismatch') return
|
|
4676
|
-
|
|
5304
|
+
// A plain `offline` keeps the remembered command bodies: see `#forgetDevice`.
|
|
5305
|
+
this.#forgetDevice(deviceId, { dropCommandBodies: state === 'identity-mismatch' })
|
|
4677
5306
|
})
|
|
4678
5307
|
}
|
|
4679
5308
|
|
|
@@ -4686,8 +5315,16 @@ class CherryToolEngine {
|
|
|
4686
5315
|
* leaves the archive (`cherry_unpair`), so a re-paired device cannot inherit
|
|
4687
5316
|
* the previous pairing's snapshots and a long-lived process does not
|
|
4688
5317
|
* accumulate entries for devices that no longer exist.
|
|
5318
|
+
*
|
|
5319
|
+
* The remembered command bodies obey a NARROWER seam than the read caches:
|
|
5320
|
+
* `dropCommandBodies` is passed by the callers that invalidate the PAIRING
|
|
5321
|
+
* (unpair, identity-mismatch), never by an ordinary `offline`. What a body
|
|
5322
|
+
* replays against — the peer's command ledger — survives a reconnect, and the
|
|
5323
|
+
* body is the only local copy of the exact bytes that ledger will accept, so
|
|
5324
|
+
* a link drop that emptied it would cost the recovery itself, where the read
|
|
5325
|
+
* caches merely pay one re-walk.
|
|
4689
5326
|
*/
|
|
4690
|
-
#forgetDevice(deviceId: string): void {
|
|
5327
|
+
#forgetDevice(deviceId: string, options: { dropCommandBodies?: boolean } = {}): void {
|
|
4691
5328
|
const prefix = `${deviceId}\u0000`
|
|
4692
5329
|
for (const key of [...this.#revisionCache.keys()]) {
|
|
4693
5330
|
if (key.startsWith(prefix)) this.#forget(this.#revisionCache, key)
|
|
@@ -4708,6 +5345,116 @@ class CherryToolEngine {
|
|
|
4708
5345
|
for (const key of [...this.#messageCache.keys()]) {
|
|
4709
5346
|
if (key.startsWith(prefix)) this.#dropCachedMessage(key)
|
|
4710
5347
|
}
|
|
5348
|
+
// The remembered command bodies (issues #115/#118) differ from every cache above:
|
|
5349
|
+
// what they replay against is the peer's command LEDGER, and that survives
|
|
5350
|
+
// an ordinary reconnect — so they are dropped only when the PAIRING is
|
|
5351
|
+
// invalidated (`dropCommandBodies`: unpair, identity-mismatch), because a
|
|
5352
|
+
// body minted against one pairing must not be replayed to whatever desktop
|
|
5353
|
+
// answers under this device id next.
|
|
5354
|
+
//
|
|
5355
|
+
// The residual, stated rather than closed: GRANT_REVOKED also surfaces as
|
|
5356
|
+
// `offline`, so a voided pairing's entries survive until the re-pair path
|
|
5357
|
+
// (unpair / identity-mismatch) or the 128-entry FIFO retires them; a same-id
|
|
5358
|
+
// retry across a silent re-pair replays a body the new peer admits as a NEW
|
|
5359
|
+
// command — and the caller asked for that send.
|
|
5360
|
+
if (options.dropCommandBodies === true) {
|
|
5361
|
+
for (const key of [...this.#commandBodies.keys()]) {
|
|
5362
|
+
if (key.startsWith(prefix)) this.#forget(this.#commandBodies, key)
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
|
|
5367
|
+
/**
|
|
5368
|
+
* The command remembered for one caller-supplied `commandId`, keyed
|
|
5369
|
+
* `<deviceId>\0<commandId>` — or `undefined` when this device has never
|
|
5370
|
+
* emitted one (a first attempt, or an entry the FIFO evicted).
|
|
5371
|
+
*
|
|
5372
|
+
* The METHOD is the caller's to judge, and it is a THREE-WAY decision, never
|
|
5373
|
+
* a miss:
|
|
5374
|
+
*
|
|
5375
|
+
* - no entry → a fresh call;
|
|
5376
|
+
* - an entry under the caller's own method → match the fingerprint, then
|
|
5377
|
+
* replay or refuse;
|
|
5378
|
+
* - an entry under a DIFFERENT method → a MISMATCH, refused locally. An id
|
|
5379
|
+
* minted for `agent.messages.send` is not a respond retry, and collapsing
|
|
5380
|
+
* that case into "no entry" would send a respond under a send's id and
|
|
5381
|
+
* let the PEER answer the conflict one round trip later.
|
|
5382
|
+
*/
|
|
5383
|
+
#recallCommandBody(deviceId: string, commandId: string): RememberedCommand | undefined {
|
|
5384
|
+
return this.#commandBodies.get(this.#cacheKey(deviceId, commandId))
|
|
5385
|
+
}
|
|
5386
|
+
|
|
5387
|
+
/**
|
|
5388
|
+
* TEST SEAM — the remembered bodies, as one `commandId` per device.
|
|
5389
|
+
*
|
|
5390
|
+
* The memory itself is PRIVATE, and stays that way for callers. What a test
|
|
5391
|
+
* needs to assert is its bookkeeping — that the bound evicts the oldest entry
|
|
5392
|
+
* and that {@link CherryToolEngine.#forgetDevice} drops a device's entries —
|
|
5393
|
+
* and neither can be observed through a real retry: a retry of an evicted id
|
|
5394
|
+
* is indistinguishable from a retry of an absent one, because both are refused
|
|
5395
|
+
* locally by the same rule. Keys are rendered WITHOUT the device component so
|
|
5396
|
+
* a test reads ids, not cache internals.
|
|
5397
|
+
*/
|
|
5398
|
+
rememberedCommandIds(deviceId: string): string[] {
|
|
5399
|
+
const prefix = `${deviceId}${CACHE_KEY_SEPARATOR}`
|
|
5400
|
+
return [...this.#commandBodies.keys()]
|
|
5401
|
+
.filter((key) => key.startsWith(prefix))
|
|
5402
|
+
.map((key) => key.slice(prefix.length))
|
|
5403
|
+
}
|
|
5404
|
+
|
|
5405
|
+
/**
|
|
5406
|
+
* TEST SEAM — fill the memory the way a long-lived process would: one entry
|
|
5407
|
+
* per id, WITHOUT spending a wire call on each. The bound and the FIFO order
|
|
5408
|
+
* are the engine's own ({@link CherryToolEngine.#rememberCommandBody}), which
|
|
5409
|
+
* is what makes this a test of the real policy rather than of a mock of it.
|
|
5410
|
+
*/
|
|
5411
|
+
rememberCommandBodyForTest(deviceId: string, commandId: string): void {
|
|
5412
|
+
this.#rememberCommandBody(deviceId, commandId, {
|
|
5413
|
+
method: 'agent.messages.send',
|
|
5414
|
+
fingerprint: { sessionId: 'session-for-test', text: 'text-for-test' },
|
|
5415
|
+
body: {
|
|
5416
|
+
commandId,
|
|
5417
|
+
sessionId: 'session-for-test',
|
|
5418
|
+
text: 'text-for-test',
|
|
5419
|
+
expectedIdleRevision: '0',
|
|
5420
|
+
},
|
|
5421
|
+
})
|
|
5422
|
+
}
|
|
5423
|
+
|
|
5424
|
+
/**
|
|
5425
|
+
* Remembers the exact body one caller-supplied `commandId` was ISSUED with,
|
|
5426
|
+
* evicting this device's oldest entry when it is at the bound.
|
|
5427
|
+
*
|
|
5428
|
+
* FIFO, per device: `Map` insertion order IS the policy, the same deliberate
|
|
5429
|
+
* choice the session caches document, and a `set` on an EXISTING key keeps its
|
|
5430
|
+
* position — a refresh is a rewrite, not a use, so the policy stays
|
|
5431
|
+
* insert-order rather than becoming LRU. One line per eviction, naming the
|
|
5432
|
+
* device and the bound only.
|
|
5433
|
+
*/
|
|
5434
|
+
#rememberCommandBody(
|
|
5435
|
+
deviceId: string,
|
|
5436
|
+
commandId: string,
|
|
5437
|
+
remembered: RememberedCommand,
|
|
5438
|
+
): void {
|
|
5439
|
+
const key = this.#cacheKey(deviceId, commandId)
|
|
5440
|
+
if (!this.#commandBodies.has(key)) {
|
|
5441
|
+
let own = 0
|
|
5442
|
+
let oldest: string | undefined
|
|
5443
|
+
for (const candidate of this.#commandBodies.keys()) {
|
|
5444
|
+
if (!candidate.startsWith(`${deviceId}\u0000`)) continue
|
|
5445
|
+
own += 1
|
|
5446
|
+
if (oldest === undefined) oldest = candidate
|
|
5447
|
+
}
|
|
5448
|
+
if (own >= this.#commandBodyEntriesPerDevice && oldest !== undefined) {
|
|
5449
|
+
this.#commandBodies.delete(oldest)
|
|
5450
|
+
console.warn(
|
|
5451
|
+
`[cherry-remote] remembered command bodies for device ${deviceId} reached their ` +
|
|
5452
|
+
`${this.#commandBodyEntriesPerDevice}-commandId bound; evicted the oldest (a retry of ` +
|
|
5453
|
+
'that commandId now re-derives its body, and the peer decides whether it replays)',
|
|
5454
|
+
)
|
|
5455
|
+
}
|
|
5456
|
+
}
|
|
5457
|
+
this.#commandBodies.set(key, remembered)
|
|
4711
5458
|
}
|
|
4712
5459
|
|
|
4713
5460
|
/**
|