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/dist/index.mjs
CHANGED
|
@@ -1737,11 +1737,11 @@ const CONTRACT_DIFFERENCES = [
|
|
|
1737
1737
|
},
|
|
1738
1738
|
{
|
|
1739
1739
|
id: "rate-limit-terminates-session",
|
|
1740
|
-
title: "Over-limit rate limiting
|
|
1741
|
-
realPeer: "
|
|
1742
|
-
fakePeer: "The fake closes the session with 1011 once `rateLimitPerSecond` is exceeded,
|
|
1743
|
-
schemaAnchor: "remoteLimits + peer close code 1011 (implementation
|
|
1744
|
-
checklistStep: "rate-limit-terminates-session:
|
|
1740
|
+
title: "Over-limit rate limiting is generation-dependent: a whole-session 1011 close (≤2.1.6) or a per-request refusal (≥2.1.7)",
|
|
1741
|
+
realPeer: "Through 2.1.6, exceeding the desktop's per-socket budget (64 req/s shared, 512 req/s content; implementation values with no negotiation channel) terminated the ENTIRE session with close code 1011. From 2.1.7 (PR #21687) the shared allowance is 256 req/s and an over-budget request is answered with ONE structured `RATE_LIMITED` failure carrying `retryAfterMs`, while the session survives.",
|
|
1742
|
+
fakePeer: "The fake models BOTH generations: `rateLimitMode` `close` (the ≤2.1.6 default) closes the session with 1011 once `rateLimitPerSecond` is exceeded, exercising the offline→backoff→reconnect path; `refuse` answers one structured `RATE_LIMITED` failure with `retryAfterMs` and keeps the session, exercising the bounded replay.",
|
|
1743
|
+
schemaAnchor: "remoteLimits + peer close code 1011 (≤2.1.6) / failure data reason RATE_LIMITED + retryAfterMs (≥2.1.7); implementation values, not protocol promises",
|
|
1744
|
+
checklistStep: "rate-limit-terminates-session: on a ≥2.1.7 desktop, raise `rateBudgetPerSecond` (or use an unpaced probe) and drive a burst above the allowance; confirm each over-budget request is refused with `RATE_LIMITED` + `retryAfterMs` while the session stays live, a refused READ succeeds after ONE replay, and a refused COMMAND surfaces as a verdict and is not replayed. On ≤2.1.6, the same burst closes the socket with 1011 (state offline, backoff restart)."
|
|
1745
1745
|
},
|
|
1746
1746
|
{
|
|
1747
1747
|
id: "execution-terminal-no-interrupted",
|
|
@@ -2176,18 +2176,21 @@ function buildDiagnostics(sources, identity = DEFAULT_IDENTITY) {
|
|
|
2176
2176
|
* This is the SHARED tier: it covers every business RPC EXCEPT the content-read
|
|
2177
2177
|
* tier below (see {@link DEFAULT_CONTENT_READ_BUDGET_PER_SECOND}).
|
|
2178
2178
|
*
|
|
2179
|
-
* WHY 32: the desktop's
|
|
2180
|
-
* (`RemoteAccessService.ts
|
|
2181
|
-
* `DesktopSession.ts:
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
2184
|
-
*
|
|
2185
|
-
*
|
|
2179
|
+
* WHY 32: the desktop's allowance for "other" RPCs is 64/s through 2.1.6 and
|
|
2180
|
+
* 256/s from 2.1.7 (`RemoteAccessService.ts`; the official client paces them at
|
|
2181
|
+
* 40/s, `DesktopSession.ts:36-37`). What an over-budget request does also
|
|
2182
|
+
* changed: through 2.1.6 it closes the WHOLE session (1011), while 2.1.7 (PR
|
|
2183
|
+
* #21687) refuses that one request (`RATE_LIMITED` + `retryAfterMs`) and keeps
|
|
2184
|
+
* the session. The generations cannot be told apart before one answers, so the
|
|
2185
|
+
* default is sized for the older, harsher peer: 32 is half of 64 and an eighth
|
|
2186
|
+
* of 256, leaving room for the housekeeping traffic that shares the socket
|
|
2187
|
+
* (hello/authenticate/ping/refresh bypass pacing by design) and for arrival
|
|
2188
|
+
* compression between our admission times and the peer's arrival times.
|
|
2186
2189
|
*
|
|
2187
2190
|
* SOURCES (neither is in this repository): the desktop lines are
|
|
2188
|
-
* `_research/cherry-studio` @ v2.1.6 (90b234e)
|
|
2189
|
-
* CherryHQ/cherry-studio-app @
|
|
2190
|
-
*
|
|
2191
|
+
* `_research/cherry-studio` @ v2.1.6 (90b234e) and CherryHQ/cherry-studio @
|
|
2192
|
+
* v2.1.7; the official-client lines are CherryHQ/cherry-studio-app @ v0.4.0,
|
|
2193
|
+
* both read on 2026-10-22 (issue #102).
|
|
2191
2194
|
*/
|
|
2192
2195
|
const DEFAULT_RATE_BUDGET_PER_SECOND = 32;
|
|
2193
2196
|
/**
|
|
@@ -2203,9 +2206,10 @@ const DEFAULT_RATE_BUDGET_PER_SECOND = 32;
|
|
|
2203
2206
|
* (`RemoteAccessService.ts:223-225`) and the official client issues them at
|
|
2204
2207
|
* 480/s (`DesktopSession.ts:33-37`). Pacing them with the 32/s shared tier
|
|
2205
2208
|
* bound a multi-slice walk to ~670 KiB/s and queued it behind unrelated RPCs;
|
|
2206
|
-
* 256 is half the
|
|
2207
|
-
*
|
|
2208
|
-
* client while leaving the desktop a
|
|
2209
|
+
* 256 is half the 512/s content allowance in BOTH desktop generations (the
|
|
2210
|
+
* shared-tier allowance changed in 2.1.7; this one did not) and lifts the bound
|
|
2211
|
+
* to the multi-MiB/s range of the official client while leaving the desktop a
|
|
2212
|
+
* session-sized headroom.
|
|
2209
2213
|
*
|
|
2210
2214
|
* NOT a plugin-config key: the two tiers derive from ONE injected shared budget
|
|
2211
2215
|
* at the ratio below, so an operator who lowers `rateBudgetPerSecond` lowers
|
|
@@ -2214,14 +2218,33 @@ const DEFAULT_RATE_BUDGET_PER_SECOND = 32;
|
|
|
2214
2218
|
*
|
|
2215
2219
|
* THE MARGINS ARE A DEFAULT-INJECTION CLAIM: the 2× headroom above holds at
|
|
2216
2220
|
* the default injection (32/s), where this tier is exactly 256/s. An injected
|
|
2217
|
-
* budget consumes BOTH tiers' headroom
|
|
2218
|
-
*
|
|
2219
|
-
*
|
|
2220
|
-
*
|
|
2221
|
-
*
|
|
2222
|
-
*
|
|
2221
|
+
* budget consumes BOTH tiers' headroom up to the point where this tier reaches
|
|
2222
|
+
* the PEER'S CEILING — at 64/s the shared tier sits on the ≤2.1.6 line (the
|
|
2223
|
+
* harsher generation, where overrun still closes the session) and this tier
|
|
2224
|
+
* derives to exactly 512/s, the content allowance BOTH generations share. Above
|
|
2225
|
+
* 64/s the clamp holds it at 512/s: `contentBudgetFor` (issue #111) still
|
|
2226
|
+
* derives the ratio, then bounds it — because a knob copied from 2.1.7's shared
|
|
2227
|
+
* figure (256)
|
|
2228
|
+
* would otherwise derive 2,048/s and spend the tier being refused. The
|
|
2229
|
+
* limiter's integer gap means the ACHIEVED rate stays below the nominal one in
|
|
2230
|
+
* that case (≤ 334/s on this tier, 55.6/s on the shared one; see
|
|
2231
|
+
* `SecondWindowRateLimiter` in `connection.ts`), which is margin by accident
|
|
2232
|
+
* rather than by design.
|
|
2223
2233
|
*/
|
|
2224
2234
|
const DEFAULT_CONTENT_READ_BUDGET_PER_SECOND = 256;
|
|
2235
|
+
/**
|
|
2236
|
+
* The longest a READ may wait for the peer's rate window to clear before its
|
|
2237
|
+
* ONE bounded replay (`RATE_LIMITED` + `retryAfterMs`, desktop >= 2.1.7).
|
|
2238
|
+
*
|
|
2239
|
+
* Why the peer's own window and not a separate budget: the desktop derives
|
|
2240
|
+
* `retryAfterMs` from its 1 s accounting window, so a larger value cannot come
|
|
2241
|
+
* from the peer, and waiting longer would hold a tool call for no reason. The
|
|
2242
|
+
* caller's own deadline (`JsonRpcRequestOptions.deadline`) still clamps it, and
|
|
2243
|
+
* a wait answer's `AssemblyDeadline` is what makes a refusal near the budget a
|
|
2244
|
+
* `'deadline'` stop rather than an overrun. A COMMAND never takes this wait: its
|
|
2245
|
+
* refusal is a verdict on that arrival, and commands are never replayed.
|
|
2246
|
+
*/
|
|
2247
|
+
const RATE_LIMIT_MAX_WAIT_MS = 1e3;
|
|
2225
2248
|
/** 2 MiB of event text cached per session (spec §3.5). */
|
|
2226
2249
|
const DEFAULT_PER_SESSION_CACHE_BYTES = 2097152;
|
|
2227
2250
|
/**
|
|
@@ -2399,6 +2422,29 @@ const SILENCE_FACTOR = 2;
|
|
|
2399
2422
|
/** The deliberate local-shutdown face of a disposed client (see the replay path). */
|
|
2400
2423
|
const DISPOSED_RE = /^JSON-RPC client disposed$/;
|
|
2401
2424
|
/**
|
|
2425
|
+
* The peer's structured over-budget refusal (desktop >= 2.1.7), or undefined.
|
|
2426
|
+
*
|
|
2427
|
+
* 2.1.7 answers an over-budget request with ONE `RATE_LIMITED` failure carrying
|
|
2428
|
+
* `retryAfterMs` and keeps the session; <=2.1.6 instead closed the whole socket
|
|
2429
|
+
* with 1011, which is the transport-loss path and never reaches this check.
|
|
2430
|
+
*/
|
|
2431
|
+
function rateLimitRefusal(error) {
|
|
2432
|
+
if (!(error instanceof JsonRpcRequestError)) return void 0;
|
|
2433
|
+
const data = error.data;
|
|
2434
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
2435
|
+
return data["reason"] === "RATE_LIMITED" ? error : void 0;
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* How long a refusal asks the caller to wait, bounded by
|
|
2439
|
+
* {@link RATE_LIMIT_MAX_WAIT_MS}. An absent or unusable value is 0: retry at
|
|
2440
|
+
* the next pacing slot rather than invent a delay the peer never asked for.
|
|
2441
|
+
*/
|
|
2442
|
+
function rateLimitWaitMs(refusal) {
|
|
2443
|
+
const raw = refusal.data["retryAfterMs"];
|
|
2444
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return 0;
|
|
2445
|
+
return Math.min(raw, RATE_LIMIT_MAX_WAIT_MS);
|
|
2446
|
+
}
|
|
2447
|
+
/**
|
|
2402
2448
|
* The read methods whose transport loss may be replayed automatically.
|
|
2403
2449
|
*
|
|
2404
2450
|
* Two entries are DEAD today and are kept deliberately, so a future call site
|
|
@@ -2483,9 +2529,9 @@ var SecondWindowRateLimiter = class {
|
|
|
2483
2529
|
* Which tier paces `method`. The rule is EXACT-MATCH: `agent.content.read` is
|
|
2484
2530
|
* the only method on the content tier, and every other method — a command, a
|
|
2485
2531
|
* list read, a different `agent.*.read` — keeps the shared tier. A prefix or
|
|
2486
|
-
* suffix match would silently widen the
|
|
2487
|
-
* allowance is the 64/s
|
|
2488
|
-
* so a new content-plane method must be named
|
|
2532
|
+
* suffix match would silently widen the content tier to methods whose peer-side
|
|
2533
|
+
* allowance is the shared one — 64/s through 2.1.6, where exceeding it closes
|
|
2534
|
+
* the SESSION (1011) — so a new content-plane method must be named deliberately.
|
|
2489
2535
|
*/
|
|
2490
2536
|
function classifyRateTier(method) {
|
|
2491
2537
|
return method === "agent.content.read" ? "content" : "shared";
|
|
@@ -2644,6 +2690,27 @@ function businessWindowFor(reportedInFlightRequests) {
|
|
|
2644
2690
|
return Math.max(1, Math.min(BUSINESS_MAX_IN_FLIGHT, Math.floor(reportedInFlightRequests / 2)));
|
|
2645
2691
|
}
|
|
2646
2692
|
/**
|
|
2693
|
+
* The desktop's content-read allowance: 512/s, and the SAME in both generations
|
|
2694
|
+
* (`RemoteAccessService.ts` counts `agent.content.read` on its own counter,
|
|
2695
|
+
* 512 in v2.1.6 and v2.1.7 alike; only the shared line moved, 64 ⇒ 256).
|
|
2696
|
+
*
|
|
2697
|
+
* A CEILING on the derived tier, not a third knob. The 8 : 1 ratio exists so an
|
|
2698
|
+
* operator who LOWERS the shared budget lowers both tiers; upward, the ratio
|
|
2699
|
+
* would spend headroom the peer does not have — `rateBudgetPerSecond: 256`
|
|
2700
|
+
* (2.1.7's SHARED figure, an easy number to copy) derives 2,048/s, which the
|
|
2701
|
+
* limiter's 1 ms gap compresses to ~1,000/s against this allowance, so roughly
|
|
2702
|
+
* every other content read is refused. On >=2.1.7 each refusal costs up to a
|
|
2703
|
+
* second plus one replay and a second refusal fails the whole read; on <=2.1.6
|
|
2704
|
+
* an overrun closes the SESSION (1011). Because this line is
|
|
2705
|
+
* generation-INDEPENDENT, bounding it needs no generation detection — the one
|
|
2706
|
+
* bound in this file that does not.
|
|
2707
|
+
*
|
|
2708
|
+
* 512 is the ceiling and 333/s is what the limiter then ACHIEVES from it
|
|
2709
|
+
* (`(1,000 + 150) / 512` ceils to a 3 ms gap), so the saturated tier still
|
|
2710
|
+
* keeps a 1.5x margin on arrival times.
|
|
2711
|
+
*/
|
|
2712
|
+
const CONTENT_BUDGET_CEILING_PER_SECOND = 512;
|
|
2713
|
+
/**
|
|
2647
2714
|
* The content-read tier's budget for a given SHARED budget.
|
|
2648
2715
|
*
|
|
2649
2716
|
* ONE injected knob, two tiers: an operator who lowers `rateBudgetPerSecond`
|
|
@@ -2651,9 +2718,19 @@ function businessWindowFor(reportedInFlightRequests) {
|
|
|
2651
2718
|
* them rather than staying pinned at 256 while the shared tier shrinks. An
|
|
2652
2719
|
* absent injection is the production default (which yields exactly
|
|
2653
2720
|
* `DEFAULT_CONTENT_READ_BUDGET_PER_SECOND`).
|
|
2721
|
+
*
|
|
2722
|
+
* Upward the ratio stops at {@link CONTENT_BUDGET_CEILING_PER_SECOND}: past a
|
|
2723
|
+
* shared budget of 64 the derived value would exceed an allowance that never
|
|
2724
|
+
* changed, so it is clamped instead of trying to pace past it.
|
|
2725
|
+
*
|
|
2726
|
+
* Exported for `tests/unit/contentBudgetCeiling.test.ts`, the same reason
|
|
2727
|
+
* `reconnectWait` is: the ceiling is a property of this mapping, and asserting
|
|
2728
|
+
* it through the limiter's timing would be a slower, flakier version of the
|
|
2729
|
+
* same claim.
|
|
2654
2730
|
*/
|
|
2655
2731
|
function contentBudgetFor(sharedBudgetPerSecond) {
|
|
2656
|
-
|
|
2732
|
+
const derived = Math.ceil((sharedBudgetPerSecond ?? 32) * 8);
|
|
2733
|
+
return Math.min(CONTENT_BUDGET_CEILING_PER_SECOND, derived);
|
|
2657
2734
|
}
|
|
2658
2735
|
function defer() {
|
|
2659
2736
|
let resolve;
|
|
@@ -3265,6 +3342,8 @@ var DeviceConnection = class {
|
|
|
3265
3342
|
try {
|
|
3266
3343
|
return await link.client.request(method, params, opts);
|
|
3267
3344
|
} catch (error) {
|
|
3345
|
+
const refusal = rateLimitRefusal(error);
|
|
3346
|
+
if (refusal !== void 0) return await this.#replayAfterRateLimit(refusal, method, params, opts);
|
|
3268
3347
|
if (!this.#isTransportLoss(error)) throw error;
|
|
3269
3348
|
const lost = new TransportLostError(this.record.deviceId, "connected", method, { cause: error });
|
|
3270
3349
|
if (!RETRYABLE_READ_METHODS.has(method) || !this.#running) throw lost;
|
|
@@ -3291,6 +3370,51 @@ var DeviceConnection = class {
|
|
|
3291
3370
|
}
|
|
3292
3371
|
}
|
|
3293
3372
|
}
|
|
3373
|
+
/**
|
|
3374
|
+
* ONE bounded replay for a READ the peer refused because the socket is over
|
|
3375
|
+
* its rate window.
|
|
3376
|
+
*
|
|
3377
|
+
* 2.1.7 answers that request with a structured `RATE_LIMITED` failure and a
|
|
3378
|
+
* `retryAfterMs` instead of closing the session (see `rateLimitRefusal`), so
|
|
3379
|
+
* the caller no longer learns it was throttled by losing its connection:
|
|
3380
|
+
* without this, a transient refusal became a failed tool call whose part text
|
|
3381
|
+
* the projection would even degrade.
|
|
3382
|
+
*
|
|
3383
|
+
* The shape mirrors the transport-loss replay, for the same reasons:
|
|
3384
|
+
*
|
|
3385
|
+
* - only a READ is replayed (a command's refusal is a verdict on that
|
|
3386
|
+
* arrival, and commands are never replayed — receipts recover them);
|
|
3387
|
+
* - the wait is charged to the caller's deadline and never extends it, so a
|
|
3388
|
+
* wait answer's budget turns a refusal near its bound into a `'deadline'`
|
|
3389
|
+
* stop instead of an overrun;
|
|
3390
|
+
* - the replay takes a FRESH pacing admission: it is a new arrival at the
|
|
3391
|
+
* peer, and skipping the admission would replay into the same full window.
|
|
3392
|
+
*
|
|
3393
|
+
* A SECOND refusal is a real failure and propagates as itself (the fresh
|
|
3394
|
+
* `retryAfterMs` is the more useful one); a link lost under the replay is
|
|
3395
|
+
* reported as the classified `TransportLostError`, never as bare text.
|
|
3396
|
+
*/
|
|
3397
|
+
async #replayAfterRateLimit(refusal, method, params, opts) {
|
|
3398
|
+
if (!RETRYABLE_READ_METHODS.has(method) || !this.#running) throw refusal;
|
|
3399
|
+
const remaining = this.#remainingMs(opts);
|
|
3400
|
+
if (remaining !== void 0 && remaining <= 0) throw new RpcDeadlineExceededError(this.record.deviceId, method);
|
|
3401
|
+
const requested = rateLimitWaitMs(refusal);
|
|
3402
|
+
const waitMs = Math.min(requested, remaining ?? Number.POSITIVE_INFINITY);
|
|
3403
|
+
const deadlineSpent = remaining !== void 0 && requested >= remaining;
|
|
3404
|
+
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
3405
|
+
if (!this.#running || this.#state !== "connected") throw refusal;
|
|
3406
|
+
if (deadlineSpent || this.#deadlinePassed(opts)) throw new RpcDeadlineExceededError(this.record.deviceId, method);
|
|
3407
|
+
await this.#limiter.acquire(method);
|
|
3408
|
+
const link = this.#link;
|
|
3409
|
+
if (!this.#running || this.#state !== "connected" || link === void 0) throw refusal;
|
|
3410
|
+
if (this.#deadlinePassed(opts)) throw new RpcDeadlineExceededError(this.record.deviceId, method);
|
|
3411
|
+
try {
|
|
3412
|
+
return await link.client.request(method, params, opts);
|
|
3413
|
+
} catch (error) {
|
|
3414
|
+
if (this.#isTransportLoss(error)) throw new TransportLostError(this.record.deviceId, "connected", method, { cause: error });
|
|
3415
|
+
throw error;
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3294
3418
|
/** Whether a rejection is the dead channel, as opposed to a peer's own answer. */
|
|
3295
3419
|
#isTransportLoss(error) {
|
|
3296
3420
|
return error instanceof Error && !(error instanceof JsonRpcRequestError) && isChannelClosedError(error);
|
|
@@ -4518,6 +4642,35 @@ var ContentReadGate = class {
|
|
|
4518
4642
|
};
|
|
4519
4643
|
//#endregion
|
|
4520
4644
|
//#region src/tools.ts
|
|
4645
|
+
/** `provider_unavailable`-shaped: lowercase words separated by underscores. */
|
|
4646
|
+
const REASON_CODE_SHAPE = /^[a-z][a-z0-9_]{1,63}$/;
|
|
4647
|
+
/**
|
|
4648
|
+
* Projects the wire's `failure` object (present on `status: 'error'` messages)
|
|
4649
|
+
* into {@link CherryReadFailure}, or `undefined` when there is none.
|
|
4650
|
+
*
|
|
4651
|
+
* Deliberately tolerant of a malformed payload: a peer that sends
|
|
4652
|
+
* `failure: { retryable: false }` with no nested record still yields a usable
|
|
4653
|
+
* verdict (`reasonCode: 'unknown'`, empty message) instead of failing the whole
|
|
4654
|
+
* read — the message's TEXT is already in hand, and losing it over a metadata
|
|
4655
|
+
* field would trade the content for the annotation. A degenerate object
|
|
4656
|
+
* (`{}`, `[]`) projects the same way: the peer DID attach a verdict, just an
|
|
4657
|
+
* empty one, and saying so is more honest than reporting "no verdict". Only an
|
|
4658
|
+
* ABSENT or non-object `failure` yields `undefined`.
|
|
4659
|
+
*/
|
|
4660
|
+
function projectMessageFailure(raw) {
|
|
4661
|
+
const failure = raw["failure"];
|
|
4662
|
+
if (typeof failure !== "object" || failure === null) return void 0;
|
|
4663
|
+
const record = failure;
|
|
4664
|
+
const nested = record["failure"];
|
|
4665
|
+
const rawReason = (typeof nested === "object" && nested !== null ? nested : {})["reasonCode"];
|
|
4666
|
+
const reasonCode = typeof rawReason === "string" && REASON_CODE_SHAPE.test(rawReason) ? rawReason : "unknown";
|
|
4667
|
+
const rawMessage = record["message"];
|
|
4668
|
+
return {
|
|
4669
|
+
reasonCode,
|
|
4670
|
+
retryable: record["retryable"] === true,
|
|
4671
|
+
message: truncateErrorMessage(typeof rawMessage === "string" ? rawMessage : "")
|
|
4672
|
+
};
|
|
4673
|
+
}
|
|
4521
4674
|
/** The four terminal execution statuses the waiter predicate covers. */
|
|
4522
4675
|
const TERMINAL_EXECUTION_STATUSES = [
|
|
4523
4676
|
"completed",
|
|
@@ -4728,6 +4881,59 @@ const MESSAGE_CACHE_ENTRY_BYTES = 1048576;
|
|
|
4728
4881
|
*/
|
|
4729
4882
|
const MESSAGE_CACHE_BYTES_PER_DEVICE = 33554432;
|
|
4730
4883
|
/**
|
|
4884
|
+
* How many command bodies the engine remembers PER DEVICE, keyed by the
|
|
4885
|
+
* CALLER-SUPPLIED `commandId` (issues #115, #118).
|
|
4886
|
+
*
|
|
4887
|
+
* Why a memory exists at all: a command's recovery path is RECEIPT. A reply
|
|
4888
|
+
* lost on the wire is answered by re-sending the ORIGINAL body under the
|
|
4889
|
+
* original id, and the desktop's ledger replays the recorded outcome. That
|
|
4890
|
+
* promise was impossible for `cherry_send`, whose body carries a DERIVED field
|
|
4891
|
+
* (`expectedIdleRevision`) that moves with every successful send: the retry
|
|
4892
|
+
* quoted a fresh baseline, the body differed from the recorded one, and the peer
|
|
4893
|
+
* answered IDEMPOTENCY_CONFLICT — on the one path where the caller cannot tell
|
|
4894
|
+
* what already landed. So the engine remembers the EXACT body it emitted, per
|
|
4895
|
+
* (device, caller id, method), and a same-id retry replays it verbatim. The
|
|
4896
|
+
* same defect had a harder shape in `cherry_respond` / `cherry_cancel` (issue
|
|
4897
|
+
* #118): their DERIVED fields were re-derived, and the retry usually met a
|
|
4898
|
+
* pre-check (`unknown-interaction`, `no-active-execution`) before the body was
|
|
4899
|
+
* ever compared — the memory is consulted before those, which is why it holds
|
|
4900
|
+
* the whole issued body rather than a way to rebuild one.
|
|
4901
|
+
*
|
|
4902
|
+
* What an entry holds is one short body: a session id, the caller's text (at
|
|
4903
|
+
* most the protocol's 32,768 characters, pre-validated before it is ever
|
|
4904
|
+
* stored), one revision string — or, for respond/cancel, the interaction's
|
|
4905
|
+
* staleness triple / the execution to abort, plus the caller-owned arguments
|
|
4906
|
+
* the fingerprint compares. 128 is "more than any live retry window needs,
|
|
4907
|
+
* small enough to be trivial": a caller retries a handful of command ids around
|
|
4908
|
+
* a lost reply, and each is recoverable while it stays in this FIFO.
|
|
4909
|
+
* Eviction costs a REQUEST, never correctness — an evicted id has NO entry, so
|
|
4910
|
+
* a same-id retry re-derives a fresh baseline. For a SEND the re-derived body
|
|
4911
|
+
* reaches the peer, which refuses the differing body exactly as it would
|
|
4912
|
+
* without this memory; for a RESPOND or CANCEL the retry usually dies earlier,
|
|
4913
|
+
* at the LOCAL pre-checks (`unknown-interaction`, `no-active-execution`,
|
|
4914
|
+
* `execution-mismatch`) — the pre-fix failure mode, which names real state.
|
|
4915
|
+
*
|
|
4916
|
+
* PER DEVICE, like every engine cache: the id is the caller's, but the peer
|
|
4917
|
+
* that settled it is not, and a second desktop must never be sent a body minted
|
|
4918
|
+
* against the first.
|
|
4919
|
+
*
|
|
4920
|
+
* INJECTABLE for tests (`commandBodyEntriesPerDevice`), because the production
|
|
4921
|
+
* value needs 129 live command ids before it evicts.
|
|
4922
|
+
*/
|
|
4923
|
+
const COMMAND_BODY_ENTRIES_PER_DEVICE = 128;
|
|
4924
|
+
/**
|
|
4925
|
+
* The three command methods whose bodies the engine remembers (see
|
|
4926
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE}).
|
|
4927
|
+
*
|
|
4928
|
+
* Named once, because they are used twice each by design: as the remembered
|
|
4929
|
+
* entry's `method`, and as the method the entry is compared against on lookup.
|
|
4930
|
+
* Spelling them at both ends is how a lookup could start matching the wrong
|
|
4931
|
+
* tool without any type noticing.
|
|
4932
|
+
*/
|
|
4933
|
+
const SEND_METHOD = "agent.messages.send";
|
|
4934
|
+
const RESPOND_METHOD = "agent.interactions.respond";
|
|
4935
|
+
const CANCEL_METHOD = "agent.executions.cancel";
|
|
4936
|
+
/**
|
|
4731
4937
|
* EMPTY-PAGE RUN limit for every aggregate walk. The desktop's pinned-list walk
|
|
4732
4938
|
* legally answers ONE empty page with an advancing cursor (the pin→entity
|
|
4733
4939
|
* section transition, `{items: [], nextCursor: 'entity:'}`), so a single empty
|
|
@@ -4940,6 +5146,79 @@ function unavailableReason(error) {
|
|
|
4940
5146
|
*/
|
|
4941
5147
|
const IDEMPOTENCY_HINT = "the peer already recorded this commandId with a different body; regenerate the commandId for a retry whose parameters changed — never reuse an id with a different body (re-send the ORIGINAL body to replay the original receipt).";
|
|
4942
5148
|
/**
|
|
5149
|
+
* The peer's own wording for `IDEMPOTENCY_CONFLICT` (its `agentHandlers` throws
|
|
5150
|
+
* exactly this), used when the ENGINE is the one that knows the body differs —
|
|
5151
|
+
* a caller-supplied `commandId` it has already emitted with other arguments
|
|
5152
|
+
* (issue #115).
|
|
5153
|
+
*
|
|
5154
|
+
* The refusal is LOCAL because the outcome is already decided: the peer's ledger
|
|
5155
|
+
* holds the recorded body, so a forwarded body would come back as this same
|
|
5156
|
+
* conflict, one round trip later, at the cost of a wire call that can never
|
|
5157
|
+
* succeed. Reusing the sentence keeps ONE vocabulary for one condition; callers
|
|
5158
|
+
* (and the existing conflict tests) match on the prefix.
|
|
5159
|
+
*/
|
|
5160
|
+
const COMMAND_BODY_DIFFERS_MESSAGE = "Command body differs from the recorded command";
|
|
5161
|
+
/**
|
|
5162
|
+
* The JSON-RPC error code the transport reserves for protocol failures (the
|
|
5163
|
+
* server answers every peer-refused request with it). Used for the LOCAL
|
|
5164
|
+
* {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal so it is the same currency a
|
|
5165
|
+
* forwarded one would have been: a `JsonRpcRequestError` carrying
|
|
5166
|
+
* `{reason: 'IDEMPOTENCY_CONFLICT'}`, distinguishable only by the fact that no
|
|
5167
|
+
* request was ever sent.
|
|
5168
|
+
*
|
|
5169
|
+
* A LOCAL twin of the literal `src/rpc/jsonRpcClient.ts` documents in its own
|
|
5170
|
+
* `JsonRpcRequestError` docblock: that module exports no code constant, and
|
|
5171
|
+
* adding one only to name this number would widen the published surface for it.
|
|
5172
|
+
*/
|
|
5173
|
+
const JSON_RPC_PROTOCOL_ERROR_CODE = 1e3;
|
|
5174
|
+
/**
|
|
5175
|
+
* The LOCAL {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal — the peer's own
|
|
5176
|
+
* reason and sentence, wrapped with the same hints a forwarded conflict earns,
|
|
5177
|
+
* and never sent anywhere.
|
|
5178
|
+
*
|
|
5179
|
+
* ONE constructor for every command that remembers its body (send, respond,
|
|
5180
|
+
* cancel): the caller must not have to learn a second vocabulary for one
|
|
5181
|
+
* condition, and a test matches the prefix `Command body differs` whichever
|
|
5182
|
+
* tool produced it.
|
|
5183
|
+
*/
|
|
5184
|
+
function commandBodyDiffers() {
|
|
5185
|
+
const conflict = new JsonRpcRequestError(JSON_RPC_PROTOCOL_ERROR_CODE, COMMAND_BODY_DIFFERS_MESSAGE, {
|
|
5186
|
+
reason: "IDEMPOTENCY_CONFLICT",
|
|
5187
|
+
message: COMMAND_BODY_DIFFERS_MESSAGE
|
|
5188
|
+
});
|
|
5189
|
+
withRateLimitHint(withIdempotencyHint(conflict));
|
|
5190
|
+
return conflict;
|
|
5191
|
+
}
|
|
5192
|
+
/**
|
|
5193
|
+
* DEEP structural equality, for the caller-owned half of a remembered command
|
|
5194
|
+
* body (issue #118).
|
|
5195
|
+
*
|
|
5196
|
+
* `cherry_respond`'s `response.answers` is a RECORD: two callers sending the
|
|
5197
|
+
* same answers in a different KEY ORDER are sending the SAME answer, and a
|
|
5198
|
+
* plain `!==` (or a JSON string comparison) would refuse the retry as a
|
|
5199
|
+
* different body — turning the receipt recovery into a local conflict on the
|
|
5200
|
+
* exact shapes it exists for. Arrays stay ORDERED: `questions` is a list, and
|
|
5201
|
+
* reordering it really is a different value.
|
|
5202
|
+
*
|
|
5203
|
+
* Deliberately small: the values compared here are tool arguments decoded from
|
|
5204
|
+
* JSON, so only JSON's two container kinds, the primitives, and `undefined`
|
|
5205
|
+
* (an omitted optional argument) need to be understood.
|
|
5206
|
+
*/
|
|
5207
|
+
function sameCommandValue(left, right) {
|
|
5208
|
+
if (left === right) return true;
|
|
5209
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
5210
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
5211
|
+
if (left.length !== right.length) return false;
|
|
5212
|
+
return left.every((value, index) => sameCommandValue(value, right[index]));
|
|
5213
|
+
}
|
|
5214
|
+
if (isRecord(left) && isRecord(right)) {
|
|
5215
|
+
const leftKeys = Object.keys(left);
|
|
5216
|
+
if (leftKeys.length !== Object.keys(right).length) return false;
|
|
5217
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) && sameCommandValue(left[key], right[key]));
|
|
5218
|
+
}
|
|
5219
|
+
return false;
|
|
5220
|
+
}
|
|
5221
|
+
/**
|
|
4943
5222
|
* Passes a command failure through UNCHANGED, except that `IDEMPOTENCY_CONFLICT`
|
|
4944
5223
|
* gets {@link IDEMPOTENCY_HINT} appended to its message.
|
|
4945
5224
|
*
|
|
@@ -4957,6 +5236,79 @@ function withIdempotencyHint(error) {
|
|
|
4957
5236
|
error.message = `${error.message} — ${IDEMPOTENCY_HINT}`;
|
|
4958
5237
|
return error;
|
|
4959
5238
|
}
|
|
5239
|
+
/**
|
|
5240
|
+
* The phrase this hint leaves behind, so applying it twice reads once. A phrase
|
|
5241
|
+
* from the ADVICE rather than the advice itself, because the wait clause varies
|
|
5242
|
+
* with the peer's `retryAfterMs`; matching the peer's own text would match every
|
|
5243
|
+
* rate-limit refusal, hinted or not.
|
|
5244
|
+
*/
|
|
5245
|
+
const RATE_LIMIT_HINT_MARK = "refuses BEFORE dispatch";
|
|
5246
|
+
/**
|
|
5247
|
+
* How long a refused COMMAND was asked to wait, or undefined when the peer sent
|
|
5248
|
+
* nothing a caller can act on.
|
|
5249
|
+
*
|
|
5250
|
+
* `<= 0` counts as absent rather than "retry immediately": a zero delay is
|
|
5251
|
+
* noise in an error message, and the desktop clamps its own value to `>= 1`
|
|
5252
|
+
* (`RemoteAccessService.ts`). CEILED so a fractional remainder is never
|
|
5253
|
+
* reported as a delay shorter than the one the peer asked for.
|
|
5254
|
+
*/
|
|
5255
|
+
function rateLimitedWaitMs(error) {
|
|
5256
|
+
if (!(error instanceof JsonRpcRequestError)) return void 0;
|
|
5257
|
+
const data = error.data;
|
|
5258
|
+
if (!isRecord(data)) return void 0;
|
|
5259
|
+
const raw = data["retryAfterMs"];
|
|
5260
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return void 0;
|
|
5261
|
+
return Math.ceil(raw);
|
|
5262
|
+
}
|
|
5263
|
+
/**
|
|
5264
|
+
* The recovery advice appended to a command's `RATE_LIMITED` refusal (desktop
|
|
5265
|
+
* >= 2.1.7; see the transport-loss section of `connection.ts`), issue #109.
|
|
5266
|
+
*
|
|
5267
|
+
* Two facts decide the retry and NEITHER survives the tool boundary on its own:
|
|
5268
|
+
*
|
|
5269
|
+
* - **THIS attempt was not applied.** The desktop refuses BEFORE dispatch — its
|
|
5270
|
+
* gateway calls `remote.rpc.refuse(input, …)` INSTEAD of `receive(input)` —
|
|
5271
|
+
* so, unlike a reply lost on a dead link, this is a certain non-outcome.
|
|
5272
|
+
* That is what makes a retry safe, and it is why the SAME `commandId` may be
|
|
5273
|
+
* reused: nothing reached the peer's command ledger, so no
|
|
5274
|
+
* `IDEMPOTENCY_CONFLICT` is waiting for the retry. The sentence says THIS
|
|
5275
|
+
* attempt, not "the command", because an earlier attempt under the same
|
|
5276
|
+
* `commandId` may itself have lost its reply after dispatch: the retry advice
|
|
5277
|
+
* is right in that sequence too (the ledger replays the original receipt),
|
|
5278
|
+
* but "never landed" would be a claim about a run this error knows nothing
|
|
5279
|
+
* of.
|
|
5280
|
+
* - **How long the peer asked us to wait** — `retryAfterMs`, derived from its
|
|
5281
|
+
* own 1 s window and already on the error we are holding. The wait clause
|
|
5282
|
+
* comes BEFORE the retry blessing, so a skimming reader cannot take "safe to
|
|
5283
|
+
* retry" and act on it without waiting.
|
|
5284
|
+
*
|
|
5285
|
+
* The cause is named because at the DEFAULT pacing this refusal is unreachable:
|
|
5286
|
+
* the desktop's counter is per SOCKET (locals inside its `run()` loop, one per
|
|
5287
|
+
* accepted connection) and we pace 32/s shared / 256/s content against its
|
|
5288
|
+
* 64-or-256/s shared and 512/s content lines. Seeing one at all therefore
|
|
5289
|
+
* normally means the configured budget is above the peer's allowance — the knob
|
|
5290
|
+
* to look at, not a transient blip — and a caller told to "retry later" without
|
|
5291
|
+
* that pointer would keep retrying into the same wall. Only the 256/s figure is
|
|
5292
|
+
* quoted: this refusal exists only from 2.1.7, so the older 64/s line cannot be
|
|
5293
|
+
* the one an operator has to compare against.
|
|
5294
|
+
*
|
|
5295
|
+
* Like {@link withIdempotencyHint} this only ever EXTENDS `message`: the reason,
|
|
5296
|
+
* the `data` and the `JsonRpcRequestError` identity are the peer's, and one
|
|
5297
|
+
* command is still never replayed (the advice is for the CALLER's retry).
|
|
5298
|
+
*/
|
|
5299
|
+
function withRateLimitHint(error) {
|
|
5300
|
+
if (!(error instanceof Error)) return error;
|
|
5301
|
+
if (failureReason(error) !== "RATE_LIMITED") return error;
|
|
5302
|
+
if (error.message.includes(RATE_LIMIT_HINT_MARK)) return error;
|
|
5303
|
+
const waitMs = rateLimitedWaitMs(error);
|
|
5304
|
+
const advice = [
|
|
5305
|
+
"this attempt was not applied: the desktop refuses BEFORE dispatch, so nothing reached its command ledger",
|
|
5306
|
+
waitMs === void 0 ? "A retry is safe, and it may reuse the SAME commandId" : `Wait about ${waitMs} ms first (the peer's retryAfterMs); a retry is then safe, and it may reuse the SAME commandId`,
|
|
5307
|
+
"The default budget (32/s) does not come close to the desktop's allowance, so a refusal normally means `rateBudgetPerSecond` was raised too far (2.1.7+ allows 256/s shared)"
|
|
5308
|
+
];
|
|
5309
|
+
error.message = `${error.message} — ${advice.join(". ")}.`;
|
|
5310
|
+
return error;
|
|
5311
|
+
}
|
|
4960
5312
|
function optionalString(value) {
|
|
4961
5313
|
return typeof value === "string" && value !== "" ? value : void 0;
|
|
4962
5314
|
}
|
|
@@ -5049,14 +5401,37 @@ const CHERRY_TOOL_NAMES = [
|
|
|
5049
5401
|
* those tasks add mutating tools without touching the host seam.
|
|
5050
5402
|
*/
|
|
5051
5403
|
function registerCherryTools(ctx, deps, options = {}) {
|
|
5404
|
+
return registerCherryToolsWithEngine(ctx, deps, options).dispose;
|
|
5405
|
+
}
|
|
5406
|
+
/**
|
|
5407
|
+
* The same registration, with the ENGINE it built handed back.
|
|
5408
|
+
*
|
|
5409
|
+
* Exists for TESTS: the engine's memory bookkeeping (the remembered command
|
|
5410
|
+
* bodies, the bounds they obey) is private to it on purpose, and the only
|
|
5411
|
+
* honest way to pin "bounded" and "dropped with the device" is to observe it
|
|
5412
|
+
* through this one seam instead of inferring it from retry outcomes that are
|
|
5413
|
+
* identical for an evicted entry and for one that never existed.
|
|
5414
|
+
*
|
|
5415
|
+
* Both this function and `export class CherryToolEngine` are SHIPPED names
|
|
5416
|
+
* (`src` is published on purpose, see AGENTS.md → Build facts), so they are a
|
|
5417
|
+
* compatibility commitment — which is why this seam is kept minimal.
|
|
5418
|
+
*/
|
|
5419
|
+
function registerCherryToolsWithEngine(ctx, deps, options = {}) {
|
|
5052
5420
|
const tools = ctx.tools;
|
|
5053
5421
|
if (tools === void 0) {
|
|
5054
5422
|
console.warn("[cherry-remote] ctx.tools is unavailable; the cherry_* tools were not registered");
|
|
5055
|
-
return
|
|
5423
|
+
return {
|
|
5424
|
+
dispose: () => {},
|
|
5425
|
+
engine: void 0
|
|
5426
|
+
};
|
|
5056
5427
|
}
|
|
5057
|
-
const
|
|
5058
|
-
|
|
5059
|
-
|
|
5428
|
+
const engine = new CherryToolEngine(deps, options);
|
|
5429
|
+
const disposers = engine.definitions().map((definition) => tools.register(definition));
|
|
5430
|
+
return {
|
|
5431
|
+
engine,
|
|
5432
|
+
dispose: () => {
|
|
5433
|
+
for (const dispose of disposers) dispose();
|
|
5434
|
+
}
|
|
5060
5435
|
};
|
|
5061
5436
|
}
|
|
5062
5437
|
/** One tool declaration, in the exact shape the host registry consumes. */
|
|
@@ -5152,6 +5527,12 @@ var CherryToolEngine = class {
|
|
|
5152
5527
|
*/
|
|
5153
5528
|
#contentGate;
|
|
5154
5529
|
/**
|
|
5530
|
+
* The `maxBytes` one `agent.content.read` slice asks for
|
|
5531
|
+
* ({@link CONTENT_READ_MAX_BYTES} in production; injectable so a test can
|
|
5532
|
+
* reach a walk's second slice, see `CherryToolEngineOptions`).
|
|
5533
|
+
*/
|
|
5534
|
+
#contentReadSliceBytes;
|
|
5535
|
+
/**
|
|
5155
5536
|
* The parts pages already walked, keyed `<deviceId>\0<messageId>`.
|
|
5156
5537
|
*
|
|
5157
5538
|
* WHAT IS HELD is the RESULT of `agent.parts.list` — the raw part records plus
|
|
@@ -5218,6 +5599,49 @@ var CherryToolEngine = class {
|
|
|
5218
5599
|
* not pinned by bookkeeping.
|
|
5219
5600
|
*/
|
|
5220
5601
|
#watchedConnections = /* @__PURE__ */ new WeakSet();
|
|
5602
|
+
/**
|
|
5603
|
+
* The EXACT command body emitted for each (device, caller id, method), so a
|
|
5604
|
+
* lost reply can be recovered by receipt (issues #115, #118).
|
|
5605
|
+
*
|
|
5606
|
+
* Keyed `<deviceId>\0<commandId>`, exactly like every other engine cache. The
|
|
5607
|
+
* value is a {@link RememberedCommand}: the METHOD it was minted for, the
|
|
5608
|
+
* caller-owned FINGERPRINT a retry must match, and the BODY the retry
|
|
5609
|
+
* replays verbatim. The fingerprint is what must MATCH — a same-id call whose
|
|
5610
|
+
* arguments differ is not a retry of this command at all, and is refused
|
|
5611
|
+
* locally rather than sent. The body is what must be REPLAYED, derived
|
|
5612
|
+
* fields included: that is the whole point, because those fields are the ones
|
|
5613
|
+
* a retry cannot re-derive the same way.
|
|
5614
|
+
*
|
|
5615
|
+
* Three tools remember: `agent.messages.send` (the baseline it quoted),
|
|
5616
|
+
* `agent.interactions.respond` (the staleness triple) and
|
|
5617
|
+
* `agent.executions.cancel` (the execution it was told to abort). Two of
|
|
5618
|
+
* them run PRE-CHECKS before composing a body — respond's
|
|
5619
|
+
* `unknown-interaction`, cancel's `no-active-execution`/`execution-mismatch` —
|
|
5620
|
+
* and after a lost reply those pre-checks are usually the first thing the
|
|
5621
|
+
* retry meets, because the first attempt is what SETTLED the interaction and
|
|
5622
|
+
* ENDED the run. So a caller-supplied id is consulted BEFORE any derivation:
|
|
5623
|
+
* a remembered retry is an answer, not a fresh call.
|
|
5624
|
+
*
|
|
5625
|
+
* WHAT IS REMEMBERED: only CALLER-SUPPLIED ids. An auto-generated uuid is a
|
|
5626
|
+
* one-shot identity — nobody can retry it, so an entry for it is pure leak.
|
|
5627
|
+
* The entry is written the moment the command is ISSUED, before the reply is
|
|
5628
|
+
* awaited: a reply lost on the wire must not cost the only copy of the bytes
|
|
5629
|
+
* the peer's ledger will accept.
|
|
5630
|
+
*
|
|
5631
|
+
* Which body an id holds: the LATEST attempt issued for it — the first
|
|
5632
|
+
* attempt, or the self-heal's fresh-uuid retry when one ran. After a heal, a
|
|
5633
|
+
* same-id retry is therefore answered with the peer's conflict, deliberately:
|
|
5634
|
+
* replaying the ledger's rejected receipt would re-run the healed admission
|
|
5635
|
+
* and duplicate the run, and a conflict is the safer failure.
|
|
5636
|
+
*
|
|
5637
|
+
* Bounded FIFO, per device; dropped when the PAIRING is invalidated (unpair,
|
|
5638
|
+
* identity-mismatch) but KEPT across an ordinary reconnect — see
|
|
5639
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE} and
|
|
5640
|
+
* {@link CherryToolEngine.#forgetDevice}.
|
|
5641
|
+
*/
|
|
5642
|
+
#commandBodies = /* @__PURE__ */ new Map();
|
|
5643
|
+
/** The per-device cap on {@link CherryToolEngine.#commandBodies}. */
|
|
5644
|
+
#commandBodyEntriesPerDevice;
|
|
5221
5645
|
constructor(deps, options = {}) {
|
|
5222
5646
|
this.#deps = deps;
|
|
5223
5647
|
this.#partsSafetyBound = Math.max(1, Math.floor(options.partsSafetyBound ?? DEFAULT_PARTS_SAFETY_BOUND));
|
|
@@ -5226,7 +5650,9 @@ var CherryToolEngine = class {
|
|
|
5226
5650
|
this.#cacheEntriesPerDevice = Math.max(1, Math.floor(options.cacheEntriesPerDevice ?? CACHE_ENTRIES_PER_DEVICE));
|
|
5227
5651
|
this.#messageCacheEntryBytes = Math.max(1, Math.floor(options.messageCacheEntryBytes ?? MESSAGE_CACHE_ENTRY_BYTES));
|
|
5228
5652
|
this.#messageCacheBytesPerDevice = Math.max(1, Math.floor(options.messageCacheBytesPerDevice ?? MESSAGE_CACHE_BYTES_PER_DEVICE));
|
|
5653
|
+
this.#commandBodyEntriesPerDevice = Math.max(1, Math.floor(options.commandBodyEntriesPerDevice ?? COMMAND_BODY_ENTRIES_PER_DEVICE));
|
|
5229
5654
|
this.#contentGate = new ContentReadGate(options.contentReadConcurrency ?? DEFAULT_CONTENT_READ_CONCURRENCY);
|
|
5655
|
+
this.#contentReadSliceBytes = Math.max(1, Math.floor(options.contentReadSliceBytes ?? CONTENT_READ_MAX_BYTES));
|
|
5230
5656
|
}
|
|
5231
5657
|
definitions() {
|
|
5232
5658
|
return [
|
|
@@ -5789,7 +6215,7 @@ var CherryToolEngine = class {
|
|
|
5789
6215
|
#readTool() {
|
|
5790
6216
|
return defineCherryTool({
|
|
5791
6217
|
name: "cherry_read",
|
|
5792
|
-
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory.
|
|
6218
|
+
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. An attachment is reported as METADATA, never as its bytes: a `kind: \"file\"` part, or a legacy `data` part named `file` (the metadata as JSON in `content.text`), comes back as `name`/`mediaType` (plus `byteLength` for the `file` kind) with `content` emptied — or kept verbatim when that record carries a key this projection does not report. The bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. A failed message (`status: \"error\"`) may carry `failure: {reasonCode, retryable, message}` when the peer sent one — its classification, whether it judged a retry worthwhile, and a bounded summary; the peer's `source` and `context.responseBody` are never projected. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
|
|
5793
6219
|
parameters: {
|
|
5794
6220
|
type: "object",
|
|
5795
6221
|
properties: {
|
|
@@ -5987,11 +6413,13 @@ var CherryToolEngine = class {
|
|
|
5987
6413
|
const partsTruncated = page.truncated ? page.truncationReason ?? "safety-bound" : void 0;
|
|
5988
6414
|
const unavailableParts = parts.filter((part) => part.unavailable !== void 0).length;
|
|
5989
6415
|
const incomplete = unavailableParts > 0 || partsTruncated !== void 0;
|
|
6416
|
+
const failure = projectMessageFailure(raw);
|
|
5990
6417
|
return {
|
|
5991
6418
|
messageId,
|
|
5992
6419
|
revision,
|
|
5993
6420
|
role: String(raw["role"] ?? ""),
|
|
5994
6421
|
status: String(raw["status"] ?? ""),
|
|
6422
|
+
...failure === void 0 ? {} : { failure },
|
|
5995
6423
|
parts,
|
|
5996
6424
|
...partsTruncated === void 0 ? {} : {
|
|
5997
6425
|
partsTruncated,
|
|
@@ -6048,38 +6476,115 @@ var CherryToolEngine = class {
|
|
|
6048
6476
|
releaseSlot?.();
|
|
6049
6477
|
}
|
|
6050
6478
|
} else text = "";
|
|
6479
|
+
const legacy = this.#legacyAttachment(kind, raw, text);
|
|
6051
6480
|
return {
|
|
6052
6481
|
partId,
|
|
6053
6482
|
kind,
|
|
6054
6483
|
revision,
|
|
6055
|
-
content: text,
|
|
6056
|
-
...this.#attachmentFace(kind, raw, content),
|
|
6484
|
+
content: legacy === void 0 ? text : legacy.remainder,
|
|
6485
|
+
...legacy === void 0 ? this.#attachmentFace(kind, raw, content) : this.#attachmentFace(kind, raw, content, legacy.record),
|
|
6057
6486
|
...typeof raw["state"] === "string" ? { state: raw["state"] } : {},
|
|
6058
6487
|
...typeof raw["toolName"] === "string" ? { toolName: raw["toolName"] } : {},
|
|
6059
6488
|
...typeof raw["toolCallId"] === "string" ? { toolCallId: raw["toolCallId"] } : {}
|
|
6060
6489
|
};
|
|
6061
6490
|
}
|
|
6062
6491
|
/**
|
|
6492
|
+
* The ONE place a legacy `data` part is recognised as an attachment: the
|
|
6493
|
+
* structural discriminator, the JSON parse, and the lossless CONTENT rule.
|
|
6494
|
+
*
|
|
6495
|
+
* The legal legacy face is `{kind: 'data', name: 'file', content: {text:
|
|
6496
|
+
* '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
|
|
6497
|
+
* `content.text`, observed on the wire (2.1.7 desktop, image upload,
|
|
6498
|
+
* 2026-10-22). An OBJECT inside `content` is not producible by a conforming
|
|
6499
|
+
* peer at all: the pinned protocol 0.5.0 types `content` as a strict
|
|
6500
|
+
* `{text}` | `{ref}` union, so the old documentation described a shape no
|
|
6501
|
+
* peer can emit — and the real one was never lifted, which is how an image
|
|
6502
|
+
* upload reached a model as a JSON blob.
|
|
6503
|
+
*
|
|
6504
|
+
* WHY THE NAME IS THE DISCRIMINATOR, and why it is not a heuristic: the
|
|
6505
|
+
* desktop labels every `data` part with its data KIND in `name` — an
|
|
6506
|
+
* attachment is `'file'`, a task event is `'data-compaction-anchor'`, an
|
|
6507
|
+
* error is `'data-error'` — and the protocol types that field as an
|
|
6508
|
+
* `opaqueId`. This lift therefore REQUIRES the label to be the literal
|
|
6509
|
+
* `'file'` rather than ASSUMING every peer sets one: a `data` part that
|
|
6510
|
+
* carries no name reads as no name, is not lifted, and passes through
|
|
6511
|
+
* untouched. Several of those payloads are JSON too, so "the text parses"
|
|
6512
|
+
* proves nothing; `'file'` is the one label that means "this is an
|
|
6513
|
+
* attachment".
|
|
6514
|
+
*
|
|
6515
|
+
* NOTHING IS EVER DEGRADED: a text that does not parse, parses to a
|
|
6516
|
+
* non-record, or carries neither `mediaType` nor `filename` as a string is
|
|
6517
|
+
* not an attachment, and the part passes through COMPLETELY unchanged
|
|
6518
|
+
* (`remainder` is the raw text, no field is lifted) — this runs on every
|
|
6519
|
+
* `data` part of every read, so a throw here would take down the whole
|
|
6520
|
+
* snapshot.
|
|
6521
|
+
*
|
|
6522
|
+
* THE CONTENT RULE: `remainder` is EMPTY when every key of the record is one
|
|
6523
|
+
* this face projects (`mediaType`, `filename`) — the metadata is fully
|
|
6524
|
+
* represented, and a model sees structure rather than a blob. A record
|
|
6525
|
+
* carrying ANY other key keeps its raw text, so a field we do not project is
|
|
6526
|
+
* never silently dropped (lossless by default). That promise is about KEYS: a
|
|
6527
|
+
* key we DO project whose value is not a string (say `mediaType: 123`) is
|
|
6528
|
+
* counted as represented — the key survives — and reports no field, because
|
|
6529
|
+
* this face lifts strings only. Emptied text therefore means "every key was
|
|
6530
|
+
* one of ours", not "every value produced a field".
|
|
6531
|
+
*/
|
|
6532
|
+
#legacyAttachment(kind, raw, text) {
|
|
6533
|
+
if (kind !== "data" || optionalString(raw["name"]) !== "file") return void 0;
|
|
6534
|
+
let parsed;
|
|
6535
|
+
try {
|
|
6536
|
+
parsed = JSON.parse(text);
|
|
6537
|
+
} catch {
|
|
6538
|
+
return;
|
|
6539
|
+
}
|
|
6540
|
+
if (!isRecord(parsed)) return void 0;
|
|
6541
|
+
if (optionalString(parsed["filename"]) === void 0 && optionalString(parsed["mediaType"]) === void 0) return;
|
|
6542
|
+
const everythingLifted = Object.keys(parsed).every((key) => key === "mediaType" || key === "filename");
|
|
6543
|
+
return {
|
|
6544
|
+
record: parsed,
|
|
6545
|
+
remainder: everythingLifted ? "" : text
|
|
6546
|
+
};
|
|
6547
|
+
}
|
|
6548
|
+
/**
|
|
6063
6549
|
* The descriptive metadata of an attachment part — and deliberately NOT a
|
|
6064
6550
|
* way to reach its bytes.
|
|
6065
6551
|
*
|
|
6066
|
-
* Two
|
|
6067
|
-
*
|
|
6552
|
+
* Two legal faces are covered, because both are inside the verified range and
|
|
6553
|
+
* they disagree about where the metadata lives:
|
|
6068
6554
|
*
|
|
6069
6555
|
* - **Cherry ≥ 2.1.5** — `{kind: 'file', name, ref}`, ref at the TOP level.
|
|
6070
|
-
* - **
|
|
6556
|
+
* - **legacy** — `{kind: 'data', name: 'file', content: {text: '<JSON>'}}`,
|
|
6557
|
+
* the metadata as JSON TEXT. {@link CherryToolEngine.#legacyAttachment}
|
|
6558
|
+
* parses it and passes the record in here.
|
|
6559
|
+
*
|
|
6560
|
+
* A THIRD arm — a `data` part carrying `{mediaType, filename}` as an OBJECT
|
|
6561
|
+
* inside `content` — is schema-ILLEGAL under the pinned protocol (see the
|
|
6562
|
+
* docblock above) and stays as a DEFENSIVE lift for a peer that violates it.
|
|
6563
|
+
* It is not the 2.1.4 projection: that story was wrong, and a misbehaving
|
|
6564
|
+
* peer's parts are not re-validated client-side, so the guarded arm costs
|
|
6565
|
+
* nothing and covers exactly the peer it was written for.
|
|
6071
6566
|
*
|
|
6072
6567
|
* Only string fields are lifted: a null `filename` (the peer's explicit "no
|
|
6073
6568
|
* name") stays absent rather than becoming the string "null". Anything a
|
|
6074
6569
|
* caller needs beyond this — the bytes — stays behind `agent.content.read`,
|
|
6075
6570
|
* which this plugin does not call for a file part.
|
|
6076
|
-
|
|
6077
|
-
|
|
6571
|
+
*
|
|
6572
|
+
* WHEN A PARSED `metadata` RECORD IS PRESENT IT IS AUTHORITATIVE. A legacy
|
|
6573
|
+
* attachment's record can arrive externalized as `{ref}`, and that ref is the
|
|
6574
|
+
* peer's serialization ENVELOPE, not the attachment's description: its
|
|
6575
|
+
* `mediaType` is the literal `'text/plain'` the envelope is labelled with and
|
|
6576
|
+
* its `byteLength` is the JSON metadata's own byte count. Letting either
|
|
6577
|
+
* outrank the record would report `text/plain` as an image's media type and a
|
|
6578
|
+
* size that is not the file's — so the record wins the media type, and this
|
|
6579
|
+
* face reports NO `byteLength` at all (a legacy `data` part carries none). The
|
|
6580
|
+
* `file` arm is untouched: there the ref IS the file's own description.
|
|
6581
|
+
*/
|
|
6582
|
+
#attachmentFace(kind, raw, content, metadata) {
|
|
6078
6583
|
if (kind !== "file" && kind !== "data") return {};
|
|
6079
6584
|
const ref = isRecord(raw["ref"]) ? raw["ref"] : isRecord(content["ref"]) ? content["ref"] : {};
|
|
6080
|
-
const name = kind === "file" ? optionalString(raw["name"]) : optionalString(content["filename"]);
|
|
6081
|
-
const mediaType = optionalString(ref["mediaType"]) ?? optionalString(content["mediaType"]);
|
|
6082
|
-
const byteLength = optionalString(ref["byteLength"]);
|
|
6585
|
+
const name = kind === "file" ? optionalString(raw["name"]) : optionalString(metadata?.["filename"]) ?? optionalString(content["filename"]);
|
|
6586
|
+
const mediaType = kind === "data" && metadata !== void 0 ? optionalString(metadata["mediaType"]) ?? optionalString(content["mediaType"]) : optionalString(ref["mediaType"]) ?? optionalString(metadata?.["mediaType"]) ?? optionalString(content["mediaType"]);
|
|
6587
|
+
const byteLength = kind === "file" ? optionalString(ref["byteLength"]) : void 0;
|
|
6083
6588
|
return {
|
|
6084
6589
|
...name === void 0 ? {} : { name },
|
|
6085
6590
|
...mediaType === void 0 ? {} : { mediaType },
|
|
@@ -6104,13 +6609,23 @@ var CherryToolEngine = class {
|
|
|
6104
6609
|
* back a snapshot that looks complete while containing content the peer
|
|
6105
6610
|
* never certified. That is worse than an error: the caller cannot tell it
|
|
6106
6611
|
* apart from a good read. It keeps failing loudly.
|
|
6612
|
+
* - **NOT degradable — a RATE LIMIT.** A `RATE_LIMITED` refusal is
|
|
6613
|
+
* SESSION-wide, not part-specific: the peer refused the arrival, it did not
|
|
6614
|
+
* fail to serve THIS part. Degrading would hand back a "complete" message
|
|
6615
|
+
* whose text is empty for a reason the caller could act on (wait and ask
|
|
6616
|
+
* again) — and if one slice is refused the others will be too, so the
|
|
6617
|
+
* snapshot would be missing precisely what was requested. The connection
|
|
6618
|
+
* already waited out `retryAfterMs` and replayed the slice ONCE, so a
|
|
6619
|
+
* second refusal is a real failure and the whole read fails with it.
|
|
6107
6620
|
*
|
|
6108
6621
|
* The discriminator is the DIGEST CHECK specifically, which is why
|
|
6109
6622
|
* {@link CherryToolEngine.#readExternalized} marks it: matching on message
|
|
6110
|
-
* text would break the moment the wording changed.
|
|
6623
|
+
* text would break the moment the wording changed. The rate-limit arm keys on
|
|
6624
|
+
* the peer's REASON, never on wording.
|
|
6111
6625
|
*/
|
|
6112
6626
|
#degradableReadFailure(error) {
|
|
6113
|
-
|
|
6627
|
+
if (error instanceof ContentIntegrityError) return false;
|
|
6628
|
+
return failureReason(error) !== "RATE_LIMITED";
|
|
6114
6629
|
}
|
|
6115
6630
|
/**
|
|
6116
6631
|
* The pre-flight refusal of a `{ref}` whose DECLARED size is above the cap —
|
|
@@ -6157,7 +6672,7 @@ var CherryToolEngine = class {
|
|
|
6157
6672
|
const declaredBytes = Number(ref["byteLength"]);
|
|
6158
6673
|
const refusal = this.#externalizedRefusal(ref);
|
|
6159
6674
|
if (refusal !== void 0) throw refusal;
|
|
6160
|
-
const maxSlices = Number.isSafeInteger(declaredBytes) ? Math.max(1, Math.ceil(declaredBytes /
|
|
6675
|
+
const maxSlices = Number.isSafeInteger(declaredBytes) ? Math.max(1, Math.ceil(declaredBytes / this.#contentReadSliceBytes)) : DEFAULT_CONTENT_SLICE_BOUND;
|
|
6161
6676
|
let offset = "0";
|
|
6162
6677
|
let sha;
|
|
6163
6678
|
let complete = false;
|
|
@@ -6170,7 +6685,7 @@ var CherryToolEngine = class {
|
|
|
6170
6685
|
contentId,
|
|
6171
6686
|
revision,
|
|
6172
6687
|
offset,
|
|
6173
|
-
maxBytes:
|
|
6688
|
+
maxBytes: this.#contentReadSliceBytes
|
|
6174
6689
|
}, deadline === void 0 ? void 0 : { deadline: deadline.instant });
|
|
6175
6690
|
const data = typeof slice["dataBase64"] === "string" ? slice["dataBase64"] : "";
|
|
6176
6691
|
const cap = this.#externalizedByteCap;
|
|
@@ -6463,11 +6978,29 @@ var CherryToolEngine = class {
|
|
|
6463
6978
|
*/
|
|
6464
6979
|
async #admitSend(connection, subscriptions, deviceId, sessionId, request) {
|
|
6465
6980
|
const { args, text } = request;
|
|
6981
|
+
const callerId = optionalString(args["commandId"]);
|
|
6466
6982
|
const attempt = async (commandId, revisionOverride) => {
|
|
6467
|
-
const
|
|
6983
|
+
const remembered = callerId === void 0 || commandId !== callerId ? void 0 : this.#recallCommandBody(deviceId, callerId);
|
|
6984
|
+
if (remembered !== void 0 && remembered.method !== SEND_METHOD) throw commandBodyDiffers();
|
|
6985
|
+
const rememberedBody = remembered?.body;
|
|
6986
|
+
if (remembered !== void 0 && (rememberedBody?.["sessionId"] !== sessionId || rememberedBody["text"] !== text)) throw commandBodyDiffers();
|
|
6987
|
+
const expectedIdleRevision = optionalString(rememberedBody?.["expectedIdleRevision"]) ?? revisionOverride ?? await this.#idleRevision(connection, subscriptions, deviceId, sessionId);
|
|
6468
6988
|
const baselineSeq = this.#newestSeq(subscriptions, sessionId);
|
|
6989
|
+
if (callerId !== void 0) this.#rememberCommandBody(deviceId, callerId, {
|
|
6990
|
+
method: SEND_METHOD,
|
|
6991
|
+
fingerprint: {
|
|
6992
|
+
sessionId,
|
|
6993
|
+
text
|
|
6994
|
+
},
|
|
6995
|
+
body: {
|
|
6996
|
+
commandId,
|
|
6997
|
+
sessionId,
|
|
6998
|
+
text,
|
|
6999
|
+
expectedIdleRevision
|
|
7000
|
+
}
|
|
7001
|
+
});
|
|
6469
7002
|
return {
|
|
6470
|
-
receipt: await this.#command(connection,
|
|
7003
|
+
receipt: await this.#command(connection, SEND_METHOD, {
|
|
6471
7004
|
commandId,
|
|
6472
7005
|
sessionId,
|
|
6473
7006
|
text,
|
|
@@ -6718,6 +7251,19 @@ var CherryToolEngine = class {
|
|
|
6718
7251
|
message: "`response` must be {kind:\"answer\", answers:{<question text>: <answer>}}"
|
|
6719
7252
|
};
|
|
6720
7253
|
}
|
|
7254
|
+
const callerId = optionalString(args["commandId"]);
|
|
7255
|
+
const answer = response === void 0 ? { decision } : { response };
|
|
7256
|
+
const remembered = callerId === void 0 ? void 0 : this.#recallCommandBody(record.deviceId, callerId);
|
|
7257
|
+
if (remembered !== void 0 && remembered.method !== RESPOND_METHOD) throw commandBodyDiffers();
|
|
7258
|
+
if (remembered !== void 0) {
|
|
7259
|
+
const fingerprint = {
|
|
7260
|
+
sessionId,
|
|
7261
|
+
interactionId,
|
|
7262
|
+
...answer
|
|
7263
|
+
};
|
|
7264
|
+
if (!sameCommandValue(remembered.fingerprint, fingerprint)) throw commandBodyDiffers();
|
|
7265
|
+
return this.#command(connection, RESPOND_METHOD, remembered.body);
|
|
7266
|
+
}
|
|
6721
7267
|
await this.#ensureSubscription(record.deviceId, sessionId);
|
|
6722
7268
|
const subscriptions = this.#deps.subs(record.deviceId);
|
|
6723
7269
|
const snapshot = await this.#interactionSnapshot(connection, subscriptions, record.deviceId, sessionId, interactionId);
|
|
@@ -6725,15 +7271,25 @@ var CherryToolEngine = class {
|
|
|
6725
7271
|
error: "unknown-interaction",
|
|
6726
7272
|
message: `no interaction ${interactionId} is known for session ${sessionId}`
|
|
6727
7273
|
};
|
|
6728
|
-
|
|
7274
|
+
const body = {
|
|
6729
7275
|
commandId: this.#commandId(args),
|
|
6730
7276
|
sessionId,
|
|
6731
7277
|
interactionId,
|
|
6732
7278
|
expectedRevision: snapshot.revision,
|
|
6733
7279
|
expectedExecutionId: snapshot.executionId,
|
|
6734
7280
|
inputDigest: snapshot.inputDigest,
|
|
6735
|
-
...
|
|
7281
|
+
...answer
|
|
7282
|
+
};
|
|
7283
|
+
if (callerId !== void 0) this.#rememberCommandBody(record.deviceId, callerId, {
|
|
7284
|
+
method: RESPOND_METHOD,
|
|
7285
|
+
fingerprint: {
|
|
7286
|
+
sessionId,
|
|
7287
|
+
interactionId,
|
|
7288
|
+
...answer
|
|
7289
|
+
},
|
|
7290
|
+
body
|
|
6736
7291
|
});
|
|
7292
|
+
return this.#command(connection, RESPOND_METHOD, body);
|
|
6737
7293
|
}
|
|
6738
7294
|
});
|
|
6739
7295
|
}
|
|
@@ -6773,6 +7329,17 @@ var CherryToolEngine = class {
|
|
|
6773
7329
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6774
7330
|
const { connection, record } = resolved.link;
|
|
6775
7331
|
const sessionId = requiredString(args, "sessionId");
|
|
7332
|
+
const callerId = optionalString(args["commandId"]);
|
|
7333
|
+
const supplied = optionalString(args["expectedExecutionId"]);
|
|
7334
|
+
const remembered = callerId === void 0 ? void 0 : this.#recallCommandBody(record.deviceId, callerId);
|
|
7335
|
+
if (remembered !== void 0 && remembered.method !== CANCEL_METHOD) throw commandBodyDiffers();
|
|
7336
|
+
if (remembered !== void 0) {
|
|
7337
|
+
if (!sameCommandValue(remembered.fingerprint, {
|
|
7338
|
+
sessionId,
|
|
7339
|
+
expectedExecutionId: supplied
|
|
7340
|
+
})) throw commandBodyDiffers();
|
|
7341
|
+
return this.#command(connection, CANCEL_METHOD, remembered.body);
|
|
7342
|
+
}
|
|
6776
7343
|
await this.#ensureSubscription(record.deviceId, sessionId);
|
|
6777
7344
|
const subscriptions = this.#deps.subs(record.deviceId);
|
|
6778
7345
|
const expectedExecutionId = await this.#activeExecutionId(connection, subscriptions, record.deviceId, sessionId);
|
|
@@ -6780,18 +7347,26 @@ var CherryToolEngine = class {
|
|
|
6780
7347
|
error: "no-active-execution",
|
|
6781
7348
|
message: `session ${sessionId} has no in-flight execution to cancel`
|
|
6782
7349
|
};
|
|
6783
|
-
const supplied = optionalString(args["expectedExecutionId"]);
|
|
6784
7350
|
if (supplied !== void 0 && supplied !== expectedExecutionId) return {
|
|
6785
7351
|
error: "execution-mismatch",
|
|
6786
7352
|
expectedExecutionId,
|
|
6787
7353
|
supplied,
|
|
6788
7354
|
message: `the cached active execution is ${expectedExecutionId}, not ${supplied}; re-read the session before cancelling`
|
|
6789
7355
|
};
|
|
6790
|
-
|
|
7356
|
+
const body = {
|
|
6791
7357
|
commandId: this.#commandId(args),
|
|
6792
7358
|
sessionId,
|
|
6793
7359
|
expectedExecutionId
|
|
7360
|
+
};
|
|
7361
|
+
if (callerId !== void 0) this.#rememberCommandBody(record.deviceId, callerId, {
|
|
7362
|
+
method: CANCEL_METHOD,
|
|
7363
|
+
fingerprint: {
|
|
7364
|
+
sessionId,
|
|
7365
|
+
expectedExecutionId: supplied
|
|
7366
|
+
},
|
|
7367
|
+
body
|
|
6794
7368
|
});
|
|
7369
|
+
return this.#command(connection, CANCEL_METHOD, body);
|
|
6795
7370
|
}
|
|
6796
7371
|
});
|
|
6797
7372
|
}
|
|
@@ -6882,7 +7457,7 @@ var CherryToolEngine = class {
|
|
|
6882
7457
|
const selected = this.#selectRecords(selector);
|
|
6883
7458
|
if (!Array.isArray(selected)) return selected;
|
|
6884
7459
|
const record = selected[0];
|
|
6885
|
-
this.#forgetDevice(record.deviceId);
|
|
7460
|
+
this.#forgetDevice(record.deviceId, { dropCommandBodies: true });
|
|
6886
7461
|
await this.#deps.evict(record.deviceId);
|
|
6887
7462
|
try {
|
|
6888
7463
|
this.#deps.store.remove(record.deviceId);
|
|
@@ -6912,18 +7487,18 @@ var CherryToolEngine = class {
|
|
|
6912
7487
|
* subscription failures still reject this call: the two surfaces stay
|
|
6913
7488
|
* distinct all the way to the caller (spec §6).
|
|
6914
7489
|
*
|
|
6915
|
-
*
|
|
7490
|
+
* TWO exceptions, and both are HINTS rather than translations: the peer's
|
|
6916
7491
|
* `IDEMPOTENCY_CONFLICT` — the same `commandId` re-used with a DIFFERENT body
|
|
6917
|
-
* —
|
|
6918
|
-
*
|
|
6919
|
-
*
|
|
6920
|
-
* half.
|
|
7492
|
+
* — and its `RATE_LIMITED` refusal (desktop >= 2.1.7) each carry their
|
|
7493
|
+
* recovery advice on the rethrown error (see {@link withIdempotencyHint} /
|
|
7494
|
+
* {@link withRateLimitHint}). The reason, the `data` and the failure currency
|
|
7495
|
+
* are untouched; only the prose a caller reads grows the actionable half.
|
|
6921
7496
|
*/
|
|
6922
7497
|
async #command(connection, method, params) {
|
|
6923
7498
|
try {
|
|
6924
7499
|
return await connection.rpc.request(method, params);
|
|
6925
7500
|
} catch (error) {
|
|
6926
|
-
throw withIdempotencyHint(error);
|
|
7501
|
+
throw withRateLimitHint(withIdempotencyHint(error));
|
|
6927
7502
|
}
|
|
6928
7503
|
}
|
|
6929
7504
|
/**
|
|
@@ -7150,7 +7725,8 @@ var CherryToolEngine = class {
|
|
|
7150
7725
|
* the stored credential no longer describes a valid pairing. A transient
|
|
7151
7726
|
* network drop ALSO lands here and clears the caches unnecessarily; that
|
|
7152
7727
|
* is accepted, because the cost is one round trip per session on reuse and
|
|
7153
|
-
* the alternative is keeping snapshots across an identity change.
|
|
7728
|
+
* the alternative is keeping snapshots across an identity change. (The
|
|
7729
|
+
* remembered command BODIES are the one exception: see `#forgetDevice`.)
|
|
7154
7730
|
* - `identity-mismatch` — the peer's pinned key changed: the pairing is over.
|
|
7155
7731
|
*
|
|
7156
7732
|
* {@link CherryToolEngine.#resolve} installs the watch on every tool call that
|
|
@@ -7173,7 +7749,7 @@ var CherryToolEngine = class {
|
|
|
7173
7749
|
this.#watchedConnections.add(connection);
|
|
7174
7750
|
watch(deviceId, (state) => {
|
|
7175
7751
|
if (state !== "offline" && state !== "identity-mismatch") return;
|
|
7176
|
-
this.#forgetDevice(deviceId);
|
|
7752
|
+
this.#forgetDevice(deviceId, { dropCommandBodies: state === "identity-mismatch" });
|
|
7177
7753
|
});
|
|
7178
7754
|
}
|
|
7179
7755
|
/**
|
|
@@ -7185,13 +7761,106 @@ var CherryToolEngine = class {
|
|
|
7185
7761
|
* leaves the archive (`cherry_unpair`), so a re-paired device cannot inherit
|
|
7186
7762
|
* the previous pairing's snapshots and a long-lived process does not
|
|
7187
7763
|
* accumulate entries for devices that no longer exist.
|
|
7764
|
+
*
|
|
7765
|
+
* The remembered command bodies obey a NARROWER seam than the read caches:
|
|
7766
|
+
* `dropCommandBodies` is passed by the callers that invalidate the PAIRING
|
|
7767
|
+
* (unpair, identity-mismatch), never by an ordinary `offline`. What a body
|
|
7768
|
+
* replays against — the peer's command ledger — survives a reconnect, and the
|
|
7769
|
+
* body is the only local copy of the exact bytes that ledger will accept, so
|
|
7770
|
+
* a link drop that emptied it would cost the recovery itself, where the read
|
|
7771
|
+
* caches merely pay one re-walk.
|
|
7188
7772
|
*/
|
|
7189
|
-
#forgetDevice(deviceId) {
|
|
7773
|
+
#forgetDevice(deviceId, options = {}) {
|
|
7190
7774
|
const prefix = `${deviceId}\u0000`;
|
|
7191
7775
|
for (const key of [...this.#revisionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#revisionCache, key);
|
|
7192
7776
|
for (const key of [...this.#executionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#executionCache, key);
|
|
7193
7777
|
for (const key of [...this.#interactionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#interactionCache, key);
|
|
7194
7778
|
for (const key of [...this.#messageCache.keys()]) if (key.startsWith(prefix)) this.#dropCachedMessage(key);
|
|
7779
|
+
if (options.dropCommandBodies === true) {
|
|
7780
|
+
for (const key of [...this.#commandBodies.keys()]) if (key.startsWith(prefix)) this.#forget(this.#commandBodies, key);
|
|
7781
|
+
}
|
|
7782
|
+
}
|
|
7783
|
+
/**
|
|
7784
|
+
* The command remembered for one caller-supplied `commandId`, keyed
|
|
7785
|
+
* `<deviceId>\0<commandId>` — or `undefined` when this device has never
|
|
7786
|
+
* emitted one (a first attempt, or an entry the FIFO evicted).
|
|
7787
|
+
*
|
|
7788
|
+
* The METHOD is the caller's to judge, and it is a THREE-WAY decision, never
|
|
7789
|
+
* a miss:
|
|
7790
|
+
*
|
|
7791
|
+
* - no entry → a fresh call;
|
|
7792
|
+
* - an entry under the caller's own method → match the fingerprint, then
|
|
7793
|
+
* replay or refuse;
|
|
7794
|
+
* - an entry under a DIFFERENT method → a MISMATCH, refused locally. An id
|
|
7795
|
+
* minted for `agent.messages.send` is not a respond retry, and collapsing
|
|
7796
|
+
* that case into "no entry" would send a respond under a send's id and
|
|
7797
|
+
* let the PEER answer the conflict one round trip later.
|
|
7798
|
+
*/
|
|
7799
|
+
#recallCommandBody(deviceId, commandId) {
|
|
7800
|
+
return this.#commandBodies.get(this.#cacheKey(deviceId, commandId));
|
|
7801
|
+
}
|
|
7802
|
+
/**
|
|
7803
|
+
* TEST SEAM — the remembered bodies, as one `commandId` per device.
|
|
7804
|
+
*
|
|
7805
|
+
* The memory itself is PRIVATE, and stays that way for callers. What a test
|
|
7806
|
+
* needs to assert is its bookkeeping — that the bound evicts the oldest entry
|
|
7807
|
+
* and that {@link CherryToolEngine.#forgetDevice} drops a device's entries —
|
|
7808
|
+
* and neither can be observed through a real retry: a retry of an evicted id
|
|
7809
|
+
* is indistinguishable from a retry of an absent one, because both are refused
|
|
7810
|
+
* locally by the same rule. Keys are rendered WITHOUT the device component so
|
|
7811
|
+
* a test reads ids, not cache internals.
|
|
7812
|
+
*/
|
|
7813
|
+
rememberedCommandIds(deviceId) {
|
|
7814
|
+
const prefix = `${deviceId}${CACHE_KEY_SEPARATOR}`;
|
|
7815
|
+
return [...this.#commandBodies.keys()].filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length));
|
|
7816
|
+
}
|
|
7817
|
+
/**
|
|
7818
|
+
* TEST SEAM — fill the memory the way a long-lived process would: one entry
|
|
7819
|
+
* per id, WITHOUT spending a wire call on each. The bound and the FIFO order
|
|
7820
|
+
* are the engine's own ({@link CherryToolEngine.#rememberCommandBody}), which
|
|
7821
|
+
* is what makes this a test of the real policy rather than of a mock of it.
|
|
7822
|
+
*/
|
|
7823
|
+
rememberCommandBodyForTest(deviceId, commandId) {
|
|
7824
|
+
this.#rememberCommandBody(deviceId, commandId, {
|
|
7825
|
+
method: "agent.messages.send",
|
|
7826
|
+
fingerprint: {
|
|
7827
|
+
sessionId: "session-for-test",
|
|
7828
|
+
text: "text-for-test"
|
|
7829
|
+
},
|
|
7830
|
+
body: {
|
|
7831
|
+
commandId,
|
|
7832
|
+
sessionId: "session-for-test",
|
|
7833
|
+
text: "text-for-test",
|
|
7834
|
+
expectedIdleRevision: "0"
|
|
7835
|
+
}
|
|
7836
|
+
});
|
|
7837
|
+
}
|
|
7838
|
+
/**
|
|
7839
|
+
* Remembers the exact body one caller-supplied `commandId` was ISSUED with,
|
|
7840
|
+
* evicting this device's oldest entry when it is at the bound.
|
|
7841
|
+
*
|
|
7842
|
+
* FIFO, per device: `Map` insertion order IS the policy, the same deliberate
|
|
7843
|
+
* choice the session caches document, and a `set` on an EXISTING key keeps its
|
|
7844
|
+
* position — a refresh is a rewrite, not a use, so the policy stays
|
|
7845
|
+
* insert-order rather than becoming LRU. One line per eviction, naming the
|
|
7846
|
+
* device and the bound only.
|
|
7847
|
+
*/
|
|
7848
|
+
#rememberCommandBody(deviceId, commandId, remembered) {
|
|
7849
|
+
const key = this.#cacheKey(deviceId, commandId);
|
|
7850
|
+
if (!this.#commandBodies.has(key)) {
|
|
7851
|
+
let own = 0;
|
|
7852
|
+
let oldest;
|
|
7853
|
+
for (const candidate of this.#commandBodies.keys()) {
|
|
7854
|
+
if (!candidate.startsWith(`${deviceId}\u0000`)) continue;
|
|
7855
|
+
own += 1;
|
|
7856
|
+
if (oldest === void 0) oldest = candidate;
|
|
7857
|
+
}
|
|
7858
|
+
if (own >= this.#commandBodyEntriesPerDevice && oldest !== void 0) {
|
|
7859
|
+
this.#commandBodies.delete(oldest);
|
|
7860
|
+
console.warn(`[cherry-remote] remembered command bodies for device ${deviceId} reached their ${this.#commandBodyEntriesPerDevice}-commandId bound; evicted the oldest (a retry of that commandId now re-derives its body, and the peer decides whether it replays)`);
|
|
7861
|
+
}
|
|
7862
|
+
}
|
|
7863
|
+
this.#commandBodies.set(key, remembered);
|
|
7195
7864
|
}
|
|
7196
7865
|
/**
|
|
7197
7866
|
* The session's current idle revision, from the SUBSCRIPTION CACHE.
|