dsh-cherry-bridge 0.1.8 → 0.1.10
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 +14 -8
- package/README.md +10 -7
- package/cordis.patch.yml +3 -2
- package/dist/index.d.mts +36 -22
- package/dist/index.mjs +365 -60
- package/docs/e2e-checklist.md +8 -8
- package/lib/client.js +2 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
- package/src/connection.ts +213 -21
- package/src/defaults.ts +61 -19
- package/src/diagnostics.ts +17 -10
- package/src/index.ts +7 -3
- package/src/tools.ts +240 -36
- package/ui/index.html +1 -1
- package/ui/src/i18n.ts +2 -2
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;
|
|
@@ -2695,6 +2772,13 @@ var DeviceConnection = class {
|
|
|
2695
2772
|
#transportRetryWaitMs;
|
|
2696
2773
|
#stateListeners = /* @__PURE__ */ new Set();
|
|
2697
2774
|
/**
|
|
2775
|
+
* Callers parked in {@link waitForSettled}. Separate from the state listeners
|
|
2776
|
+
* because their lifetime is ONE wait: a listener stays for the lifecycle, a
|
|
2777
|
+
* settle waiter removes itself the moment it is answered (or times out), and
|
|
2778
|
+
* mixing them would leave every answered wait in the multicast set.
|
|
2779
|
+
*/
|
|
2780
|
+
#settleWaiters = /* @__PURE__ */ new Set();
|
|
2781
|
+
/**
|
|
2698
2782
|
* Waits parked inside `#awaitReconnect`. close() drains them: a shutdown has
|
|
2699
2783
|
* no reconnect to wait for, and — the case that matters — a close() followed
|
|
2700
2784
|
* by a fresh connect() must not let an OLD lifecycle's replay ride the new
|
|
@@ -2819,6 +2903,46 @@ var DeviceConnection = class {
|
|
|
2819
2903
|
this.#stateListeners.add(cb);
|
|
2820
2904
|
}
|
|
2821
2905
|
/**
|
|
2906
|
+
* Waits for an attempt that is ALREADY IN FLIGHT to settle, bounded by
|
|
2907
|
+
* `timeoutMs`, and answers with the state it ends on.
|
|
2908
|
+
*
|
|
2909
|
+
* The only state this waits on is `connecting`: an attempt is running, and
|
|
2910
|
+
* its outcome is what the caller asked about. `connected` is answered at
|
|
2911
|
+
* once, and so are `offline` (parked in backoff), `disabled` and
|
|
2912
|
+
* `identity-mismatch` — no wait can change those, and waiting on them would
|
|
2913
|
+
* merely delay the caller's answer by the whole bound. `offline` is the
|
|
2914
|
+
* common case for a genuinely powered-off desktop, so it is the one that
|
|
2915
|
+
* matters: the tools stay fail-fast there.
|
|
2916
|
+
*
|
|
2917
|
+
* This NEVER starts an attempt — it observes one the pool already started.
|
|
2918
|
+
* That is the line the tool face has always drawn (a tool must not dial); the
|
|
2919
|
+
* wait only stops the tool from calling a live dial a failure.
|
|
2920
|
+
*
|
|
2921
|
+
* The bound expiring is not an error: the current state (`connecting`) is
|
|
2922
|
+
* returned, and the caller judges it exactly as it would have without the
|
|
2923
|
+
* wait.
|
|
2924
|
+
*/
|
|
2925
|
+
async waitForSettled(timeoutMs) {
|
|
2926
|
+
if (this.#state !== "connecting" || timeoutMs <= 0) return this.#state;
|
|
2927
|
+
return new Promise((resolve) => {
|
|
2928
|
+
let settled = false;
|
|
2929
|
+
let timer;
|
|
2930
|
+
const finish = (state) => {
|
|
2931
|
+
if (settled) return;
|
|
2932
|
+
settled = true;
|
|
2933
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2934
|
+
this.#settleWaiters.delete(onState);
|
|
2935
|
+
resolve(state);
|
|
2936
|
+
};
|
|
2937
|
+
const onState = (state) => {
|
|
2938
|
+
if (state !== "connecting") finish(state);
|
|
2939
|
+
};
|
|
2940
|
+
timer = setTimeout(() => finish(this.#state), timeoutMs);
|
|
2941
|
+
this.#settleWaiters.add(onState);
|
|
2942
|
+
if (this.#state !== "connecting") finish(this.#state);
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
/**
|
|
2822
2946
|
* Starts (or joins) the supervised lifecycle. Resolves once this cycle
|
|
2823
2947
|
* reaches `connected`; rejects with the cycle's failure. Transient
|
|
2824
2948
|
* failures keep being retried in the background by the backoff loop even
|
|
@@ -3218,6 +3342,8 @@ var DeviceConnection = class {
|
|
|
3218
3342
|
try {
|
|
3219
3343
|
return await link.client.request(method, params, opts);
|
|
3220
3344
|
} catch (error) {
|
|
3345
|
+
const refusal = rateLimitRefusal(error);
|
|
3346
|
+
if (refusal !== void 0) return await this.#replayAfterRateLimit(refusal, method, params, opts);
|
|
3221
3347
|
if (!this.#isTransportLoss(error)) throw error;
|
|
3222
3348
|
const lost = new TransportLostError(this.record.deviceId, "connected", method, { cause: error });
|
|
3223
3349
|
if (!RETRYABLE_READ_METHODS.has(method) || !this.#running) throw lost;
|
|
@@ -3244,6 +3370,51 @@ var DeviceConnection = class {
|
|
|
3244
3370
|
}
|
|
3245
3371
|
}
|
|
3246
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
|
+
}
|
|
3247
3418
|
/** Whether a rejection is the dead channel, as opposed to a peer's own answer. */
|
|
3248
3419
|
#isTransportLoss(error) {
|
|
3249
3420
|
return error instanceof Error && !(error instanceof JsonRpcRequestError) && isChannelClosedError(error);
|
|
@@ -3308,6 +3479,11 @@ var DeviceConnection = class {
|
|
|
3308
3479
|
} catch (error) {
|
|
3309
3480
|
console.warn(`[cherry-remote] state listener threw for ${this.record.deviceId}`, error);
|
|
3310
3481
|
}
|
|
3482
|
+
for (const cb of [...this.#settleWaiters]) try {
|
|
3483
|
+
cb(next);
|
|
3484
|
+
} catch (error) {
|
|
3485
|
+
console.warn(`[cherry-remote] settle waiter threw for ${this.record.deviceId}`, error);
|
|
3486
|
+
}
|
|
3311
3487
|
}
|
|
3312
3488
|
};
|
|
3313
3489
|
//#endregion
|
|
@@ -4466,6 +4642,35 @@ var ContentReadGate = class {
|
|
|
4466
4642
|
};
|
|
4467
4643
|
//#endregion
|
|
4468
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
|
+
}
|
|
4469
4674
|
/** The four terminal execution statuses the waiter predicate covers. */
|
|
4470
4675
|
const TERMINAL_EXECUTION_STATUSES = [
|
|
4471
4676
|
"completed",
|
|
@@ -4905,6 +5110,79 @@ function withIdempotencyHint(error) {
|
|
|
4905
5110
|
error.message = `${error.message} — ${IDEMPOTENCY_HINT}`;
|
|
4906
5111
|
return error;
|
|
4907
5112
|
}
|
|
5113
|
+
/**
|
|
5114
|
+
* The phrase this hint leaves behind, so applying it twice reads once. A phrase
|
|
5115
|
+
* from the ADVICE rather than the advice itself, because the wait clause varies
|
|
5116
|
+
* with the peer's `retryAfterMs`; matching the peer's own text would match every
|
|
5117
|
+
* rate-limit refusal, hinted or not.
|
|
5118
|
+
*/
|
|
5119
|
+
const RATE_LIMIT_HINT_MARK = "refuses BEFORE dispatch";
|
|
5120
|
+
/**
|
|
5121
|
+
* How long a refused COMMAND was asked to wait, or undefined when the peer sent
|
|
5122
|
+
* nothing a caller can act on.
|
|
5123
|
+
*
|
|
5124
|
+
* `<= 0` counts as absent rather than "retry immediately": a zero delay is
|
|
5125
|
+
* noise in an error message, and the desktop clamps its own value to `>= 1`
|
|
5126
|
+
* (`RemoteAccessService.ts`). CEILED so a fractional remainder is never
|
|
5127
|
+
* reported as a delay shorter than the one the peer asked for.
|
|
5128
|
+
*/
|
|
5129
|
+
function rateLimitedWaitMs(error) {
|
|
5130
|
+
if (!(error instanceof JsonRpcRequestError)) return void 0;
|
|
5131
|
+
const data = error.data;
|
|
5132
|
+
if (!isRecord(data)) return void 0;
|
|
5133
|
+
const raw = data["retryAfterMs"];
|
|
5134
|
+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return void 0;
|
|
5135
|
+
return Math.ceil(raw);
|
|
5136
|
+
}
|
|
5137
|
+
/**
|
|
5138
|
+
* The recovery advice appended to a command's `RATE_LIMITED` refusal (desktop
|
|
5139
|
+
* >= 2.1.7; see the transport-loss section of `connection.ts`), issue #109.
|
|
5140
|
+
*
|
|
5141
|
+
* Two facts decide the retry and NEITHER survives the tool boundary on its own:
|
|
5142
|
+
*
|
|
5143
|
+
* - **THIS attempt was not applied.** The desktop refuses BEFORE dispatch — its
|
|
5144
|
+
* gateway calls `remote.rpc.refuse(input, …)` INSTEAD of `receive(input)` —
|
|
5145
|
+
* so, unlike a reply lost on a dead link, this is a certain non-outcome.
|
|
5146
|
+
* That is what makes a retry safe, and it is why the SAME `commandId` may be
|
|
5147
|
+
* reused: nothing reached the peer's command ledger, so no
|
|
5148
|
+
* `IDEMPOTENCY_CONFLICT` is waiting for the retry. The sentence says THIS
|
|
5149
|
+
* attempt, not "the command", because an earlier attempt under the same
|
|
5150
|
+
* `commandId` may itself have lost its reply after dispatch: the retry advice
|
|
5151
|
+
* is right in that sequence too (the ledger replays the original receipt),
|
|
5152
|
+
* but "never landed" would be a claim about a run this error knows nothing
|
|
5153
|
+
* of.
|
|
5154
|
+
* - **How long the peer asked us to wait** — `retryAfterMs`, derived from its
|
|
5155
|
+
* own 1 s window and already on the error we are holding. The wait clause
|
|
5156
|
+
* comes BEFORE the retry blessing, so a skimming reader cannot take "safe to
|
|
5157
|
+
* retry" and act on it without waiting.
|
|
5158
|
+
*
|
|
5159
|
+
* The cause is named because at the DEFAULT pacing this refusal is unreachable:
|
|
5160
|
+
* the desktop's counter is per SOCKET (locals inside its `run()` loop, one per
|
|
5161
|
+
* accepted connection) and we pace 32/s shared / 256/s content against its
|
|
5162
|
+
* 64-or-256/s shared and 512/s content lines. Seeing one at all therefore
|
|
5163
|
+
* normally means the configured budget is above the peer's allowance — the knob
|
|
5164
|
+
* to look at, not a transient blip — and a caller told to "retry later" without
|
|
5165
|
+
* that pointer would keep retrying into the same wall. Only the 256/s figure is
|
|
5166
|
+
* quoted: this refusal exists only from 2.1.7, so the older 64/s line cannot be
|
|
5167
|
+
* the one an operator has to compare against.
|
|
5168
|
+
*
|
|
5169
|
+
* Like {@link withIdempotencyHint} this only ever EXTENDS `message`: the reason,
|
|
5170
|
+
* the `data` and the `JsonRpcRequestError` identity are the peer's, and one
|
|
5171
|
+
* command is still never replayed (the advice is for the CALLER's retry).
|
|
5172
|
+
*/
|
|
5173
|
+
function withRateLimitHint(error) {
|
|
5174
|
+
if (!(error instanceof Error)) return error;
|
|
5175
|
+
if (failureReason(error) !== "RATE_LIMITED") return error;
|
|
5176
|
+
if (error.message.includes(RATE_LIMIT_HINT_MARK)) return error;
|
|
5177
|
+
const waitMs = rateLimitedWaitMs(error);
|
|
5178
|
+
const advice = [
|
|
5179
|
+
"this attempt was not applied: the desktop refuses BEFORE dispatch, so nothing reached its command ledger",
|
|
5180
|
+
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`,
|
|
5181
|
+
"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)"
|
|
5182
|
+
];
|
|
5183
|
+
error.message = `${error.message} — ${advice.join(". ")}.`;
|
|
5184
|
+
return error;
|
|
5185
|
+
}
|
|
4908
5186
|
function optionalString(value) {
|
|
4909
5187
|
return typeof value === "string" && value !== "" ? value : void 0;
|
|
4910
5188
|
}
|
|
@@ -5200,8 +5478,12 @@ var CherryToolEngine = class {
|
|
|
5200
5478
|
* ⑤): the candidate list is the answer. A MISS is a verdict too — see
|
|
5201
5479
|
* {@link CherryDeviceNotFound} — and it carries the ids the caller could have
|
|
5202
5480
|
* meant so the answer stays actionable.
|
|
5481
|
+
*
|
|
5482
|
+
* ASYNC because of the `connecting` wait below: the pool dials on first touch
|
|
5483
|
+
* and on every reconnect, so a tool called in that window used to be refused
|
|
5484
|
+
* for a connection that comes up a moment later (issue #99).
|
|
5203
5485
|
*/
|
|
5204
|
-
#resolve(device) {
|
|
5486
|
+
async #resolve(device) {
|
|
5205
5487
|
const resolution = this.#deps.store.resolve(device);
|
|
5206
5488
|
if ("ambiguous" in resolution) return {
|
|
5207
5489
|
error: "ambiguous-device",
|
|
@@ -5211,10 +5493,11 @@ var CherryToolEngine = class {
|
|
|
5211
5493
|
if ("notFound" in resolution) return this.#notFoundVerdict(device);
|
|
5212
5494
|
const connection = this.#deps.connect(resolution.deviceId);
|
|
5213
5495
|
this.#watchConnection(resolution.deviceId, connection);
|
|
5214
|
-
|
|
5496
|
+
const state = connection.state === "connecting" ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? 5e3) : connection.state;
|
|
5497
|
+
if (state !== "connected") return {
|
|
5215
5498
|
error: "device-unavailable",
|
|
5216
5499
|
device: resolution.deviceId,
|
|
5217
|
-
state
|
|
5500
|
+
state
|
|
5218
5501
|
};
|
|
5219
5502
|
return {
|
|
5220
5503
|
record: resolution,
|
|
@@ -5250,6 +5533,10 @@ var CherryToolEngine = class {
|
|
|
5250
5533
|
/**
|
|
5251
5534
|
* Resolve-or-verdict: every non-`cherry_devices` tool needs a live link.
|
|
5252
5535
|
*
|
|
5536
|
+
* `connecting` is waited out inside {@link #resolve} first, so the
|
|
5537
|
+
* `DeviceUnavailableError` below is only ever raised for a state the wait
|
|
5538
|
+
* could not improve on (bounded by `deviceSettleWaitMs`).
|
|
5539
|
+
*
|
|
5253
5540
|
* The failure faces are deliberately DIFFERENT (controller ruling ⑤): an
|
|
5254
5541
|
* ambiguous selector and a MISS are ANSWERS — the candidate list, or the
|
|
5255
5542
|
* known ids, returned in band, because the caller can act on them — while a
|
|
@@ -5262,8 +5549,8 @@ var CherryToolEngine = class {
|
|
|
5262
5549
|
* `cherry_devices` does not come through this method — it goes through
|
|
5263
5550
|
* {@link #selectRecords}, which returns the verdict in band.
|
|
5264
5551
|
*/
|
|
5265
|
-
#requireConnection(device) {
|
|
5266
|
-
const resolved = this.#resolve(device);
|
|
5552
|
+
async #requireConnection(device) {
|
|
5553
|
+
const resolved = await this.#resolve(device);
|
|
5267
5554
|
if (!this.#isVerdict(resolved)) return resolved;
|
|
5268
5555
|
if (resolved.error === "ambiguous-device") return resolved;
|
|
5269
5556
|
if (resolved.error === "not-found") {
|
|
@@ -5282,10 +5569,10 @@ var CherryToolEngine = class {
|
|
|
5282
5569
|
* replace it with a weaker, wrong one — a miss under a READ-ONLY archive
|
|
5283
5570
|
* would describe a store that still holds a valid token as "no such device".
|
|
5284
5571
|
*/
|
|
5285
|
-
#linkOr(device) {
|
|
5572
|
+
async #linkOr(device) {
|
|
5286
5573
|
const quarantined = this.#quarantineVerdict();
|
|
5287
5574
|
if (quarantined !== void 0) return { verdict: quarantined };
|
|
5288
|
-
const resolved = this.#requireConnection(device);
|
|
5575
|
+
const resolved = await this.#requireConnection(device);
|
|
5289
5576
|
if ("error" in resolved) return { verdict: resolved };
|
|
5290
5577
|
return { link: resolved };
|
|
5291
5578
|
}
|
|
@@ -5507,10 +5794,13 @@ var CherryToolEngine = class {
|
|
|
5507
5794
|
const selector = optionalString(args["device"]);
|
|
5508
5795
|
const quarantined = this.#quarantineVerdict();
|
|
5509
5796
|
if (quarantined !== void 0) return quarantined;
|
|
5510
|
-
if (selector === void 0)
|
|
5797
|
+
if (selector === void 0) {
|
|
5798
|
+
const records = this.#deps.store.list();
|
|
5799
|
+
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) };
|
|
5800
|
+
}
|
|
5511
5801
|
const selected = this.#selectRecords(selector);
|
|
5512
5802
|
if (!Array.isArray(selected)) return selected;
|
|
5513
|
-
return { devices: selected.map((record) => this.#deviceView(record)) };
|
|
5803
|
+
return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) };
|
|
5514
5804
|
}
|
|
5515
5805
|
});
|
|
5516
5806
|
}
|
|
@@ -5540,7 +5830,9 @@ var CherryToolEngine = class {
|
|
|
5540
5830
|
return [resolution];
|
|
5541
5831
|
}
|
|
5542
5832
|
/**
|
|
5543
|
-
* One device's view WITHOUT dialing
|
|
5833
|
+
* One device's view WITHOUT dialing — but after waiting out an attempt that is
|
|
5834
|
+
* already in flight, exactly as {@link #resolve} does, so a cold-start row
|
|
5835
|
+
* does not present a transient `connecting` as the device's answer.
|
|
5544
5836
|
*
|
|
5545
5837
|
* A disabled record is authoritative: the pooled connection is constructed
|
|
5546
5838
|
* with its own `record.enabled` snapshot and reports `disabled` only once
|
|
@@ -5548,10 +5840,11 @@ var CherryToolEngine = class {
|
|
|
5548
5840
|
* for a device whose connection was never started. Everything else falls back
|
|
5549
5841
|
* to the connection's own CLOSED state set.
|
|
5550
5842
|
*/
|
|
5551
|
-
#deviceView(record) {
|
|
5843
|
+
async #deviceView(record) {
|
|
5552
5844
|
let state = record.enabled ? "offline" : "disabled";
|
|
5553
5845
|
if (record.enabled) try {
|
|
5554
|
-
|
|
5846
|
+
const connection = this.#deps.connect(record.deviceId);
|
|
5847
|
+
state = connection.state === "connecting" ? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? 5e3) : connection.state;
|
|
5555
5848
|
} catch (error) {
|
|
5556
5849
|
console.warn(`[cherry-remote] cherry_devices could not read the connection for ${record.deviceId}`, error);
|
|
5557
5850
|
}
|
|
@@ -5587,7 +5880,7 @@ var CherryToolEngine = class {
|
|
|
5587
5880
|
additionalProperties: false
|
|
5588
5881
|
},
|
|
5589
5882
|
execute: async (args) => {
|
|
5590
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5883
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
5591
5884
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5592
5885
|
const { connection, record } = resolved.link;
|
|
5593
5886
|
const binding = {
|
|
@@ -5634,7 +5927,7 @@ var CherryToolEngine = class {
|
|
|
5634
5927
|
additionalProperties: false
|
|
5635
5928
|
},
|
|
5636
5929
|
execute: async (args) => {
|
|
5637
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5930
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
5638
5931
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5639
5932
|
const { connection, record } = resolved.link;
|
|
5640
5933
|
const params = { agentId: requiredString(args, "agentId") };
|
|
@@ -5693,7 +5986,7 @@ var CherryToolEngine = class {
|
|
|
5693
5986
|
additionalProperties: false
|
|
5694
5987
|
},
|
|
5695
5988
|
execute: async (args) => {
|
|
5696
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5989
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
5697
5990
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5698
5991
|
const { connection, record } = resolved.link;
|
|
5699
5992
|
const agentId = optionalString(args["agentId"]);
|
|
@@ -5722,7 +6015,7 @@ var CherryToolEngine = class {
|
|
|
5722
6015
|
#readTool() {
|
|
5723
6016
|
return defineCherryTool({
|
|
5724
6017
|
name: "cherry_read",
|
|
5725
|
-
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. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — 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. 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.",
|
|
6018
|
+
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. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — 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.",
|
|
5726
6019
|
parameters: {
|
|
5727
6020
|
type: "object",
|
|
5728
6021
|
properties: {
|
|
@@ -5751,7 +6044,7 @@ var CherryToolEngine = class {
|
|
|
5751
6044
|
},
|
|
5752
6045
|
execute: async (args) => {
|
|
5753
6046
|
const resume = parseMessageResume(args["messagesResumeCursor"]);
|
|
5754
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6047
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
5755
6048
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5756
6049
|
const { connection, record } = resolved.link;
|
|
5757
6050
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -5920,11 +6213,13 @@ var CherryToolEngine = class {
|
|
|
5920
6213
|
const partsTruncated = page.truncated ? page.truncationReason ?? "safety-bound" : void 0;
|
|
5921
6214
|
const unavailableParts = parts.filter((part) => part.unavailable !== void 0).length;
|
|
5922
6215
|
const incomplete = unavailableParts > 0 || partsTruncated !== void 0;
|
|
6216
|
+
const failure = projectMessageFailure(raw);
|
|
5923
6217
|
return {
|
|
5924
6218
|
messageId,
|
|
5925
6219
|
revision,
|
|
5926
6220
|
role: String(raw["role"] ?? ""),
|
|
5927
6221
|
status: String(raw["status"] ?? ""),
|
|
6222
|
+
...failure === void 0 ? {} : { failure },
|
|
5928
6223
|
parts,
|
|
5929
6224
|
...partsTruncated === void 0 ? {} : {
|
|
5930
6225
|
partsTruncated,
|
|
@@ -6037,13 +6332,23 @@ var CherryToolEngine = class {
|
|
|
6037
6332
|
* back a snapshot that looks complete while containing content the peer
|
|
6038
6333
|
* never certified. That is worse than an error: the caller cannot tell it
|
|
6039
6334
|
* apart from a good read. It keeps failing loudly.
|
|
6335
|
+
* - **NOT degradable — a RATE LIMIT.** A `RATE_LIMITED` refusal is
|
|
6336
|
+
* SESSION-wide, not part-specific: the peer refused the arrival, it did not
|
|
6337
|
+
* fail to serve THIS part. Degrading would hand back a "complete" message
|
|
6338
|
+
* whose text is empty for a reason the caller could act on (wait and ask
|
|
6339
|
+
* again) — and if one slice is refused the others will be too, so the
|
|
6340
|
+
* snapshot would be missing precisely what was requested. The connection
|
|
6341
|
+
* already waited out `retryAfterMs` and replayed the slice ONCE, so a
|
|
6342
|
+
* second refusal is a real failure and the whole read fails with it.
|
|
6040
6343
|
*
|
|
6041
6344
|
* The discriminator is the DIGEST CHECK specifically, which is why
|
|
6042
6345
|
* {@link CherryToolEngine.#readExternalized} marks it: matching on message
|
|
6043
|
-
* text would break the moment the wording changed.
|
|
6346
|
+
* text would break the moment the wording changed. The rate-limit arm keys on
|
|
6347
|
+
* the peer's REASON, never on wording.
|
|
6044
6348
|
*/
|
|
6045
6349
|
#degradableReadFailure(error) {
|
|
6046
|
-
|
|
6350
|
+
if (error instanceof ContentIntegrityError) return false;
|
|
6351
|
+
return failureReason(error) !== "RATE_LIMITED";
|
|
6047
6352
|
}
|
|
6048
6353
|
/**
|
|
6049
6354
|
* The pre-flight refusal of a `{ref}` whose DECLARED size is above the cap —
|
|
@@ -6171,7 +6476,7 @@ var CherryToolEngine = class {
|
|
|
6171
6476
|
additionalProperties: false
|
|
6172
6477
|
},
|
|
6173
6478
|
execute: async (args) => {
|
|
6174
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6479
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6175
6480
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6176
6481
|
const { record } = resolved.link;
|
|
6177
6482
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6234,7 +6539,7 @@ var CherryToolEngine = class {
|
|
|
6234
6539
|
additionalProperties: false
|
|
6235
6540
|
},
|
|
6236
6541
|
execute: async (args) => {
|
|
6237
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6542
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6238
6543
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6239
6544
|
const { connection, record } = resolved.link;
|
|
6240
6545
|
const agentId = requiredString(args, "agentId");
|
|
@@ -6319,7 +6624,7 @@ var CherryToolEngine = class {
|
|
|
6319
6624
|
additionalProperties: false
|
|
6320
6625
|
},
|
|
6321
6626
|
execute: async (args) => {
|
|
6322
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6627
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6323
6628
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6324
6629
|
const { connection, record } = resolved.link;
|
|
6325
6630
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6443,7 +6748,7 @@ var CherryToolEngine = class {
|
|
|
6443
6748
|
additionalProperties: false
|
|
6444
6749
|
},
|
|
6445
6750
|
execute: async (args) => {
|
|
6446
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6751
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6447
6752
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6448
6753
|
const { connection, record } = resolved.link;
|
|
6449
6754
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6525,7 +6830,7 @@ var CherryToolEngine = class {
|
|
|
6525
6830
|
additionalProperties: false
|
|
6526
6831
|
},
|
|
6527
6832
|
execute: async (args) => {
|
|
6528
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6833
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6529
6834
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6530
6835
|
const { connection, record } = resolved.link;
|
|
6531
6836
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6625,7 +6930,7 @@ var CherryToolEngine = class {
|
|
|
6625
6930
|
additionalProperties: false
|
|
6626
6931
|
},
|
|
6627
6932
|
execute: async (args) => {
|
|
6628
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
6933
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6629
6934
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6630
6935
|
const { connection, record } = resolved.link;
|
|
6631
6936
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6702,7 +7007,7 @@ var CherryToolEngine = class {
|
|
|
6702
7007
|
additionalProperties: false
|
|
6703
7008
|
},
|
|
6704
7009
|
execute: async (args) => {
|
|
6705
|
-
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
7010
|
+
const resolved = await this.#linkOr(requiredString(args, "device"));
|
|
6706
7011
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6707
7012
|
const { connection, record } = resolved.link;
|
|
6708
7013
|
const sessionId = requiredString(args, "sessionId");
|
|
@@ -6845,18 +7150,18 @@ var CherryToolEngine = class {
|
|
|
6845
7150
|
* subscription failures still reject this call: the two surfaces stay
|
|
6846
7151
|
* distinct all the way to the caller (spec §6).
|
|
6847
7152
|
*
|
|
6848
|
-
*
|
|
7153
|
+
* TWO exceptions, and both are HINTS rather than translations: the peer's
|
|
6849
7154
|
* `IDEMPOTENCY_CONFLICT` — the same `commandId` re-used with a DIFFERENT body
|
|
6850
|
-
* —
|
|
6851
|
-
*
|
|
6852
|
-
*
|
|
6853
|
-
* half.
|
|
7155
|
+
* — and its `RATE_LIMITED` refusal (desktop >= 2.1.7) each carry their
|
|
7156
|
+
* recovery advice on the rethrown error (see {@link withIdempotencyHint} /
|
|
7157
|
+
* {@link withRateLimitHint}). The reason, the `data` and the failure currency
|
|
7158
|
+
* are untouched; only the prose a caller reads grows the actionable half.
|
|
6854
7159
|
*/
|
|
6855
7160
|
async #command(connection, method, params) {
|
|
6856
7161
|
try {
|
|
6857
7162
|
return await connection.rpc.request(method, params);
|
|
6858
7163
|
} catch (error) {
|
|
6859
|
-
throw withIdempotencyHint(error);
|
|
7164
|
+
throw withRateLimitHint(withIdempotencyHint(error));
|
|
6860
7165
|
}
|
|
6861
7166
|
}
|
|
6862
7167
|
/**
|