dsh-cherry-bridge 0.2.1 → 0.2.3
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 +3 -3
- package/README.md +3 -3
- package/dist/index.mjs +253 -20
- package/docs/e2e-checklist.md +46 -0
- package/package.json +1 -1
- package/src/AGENTS.md +12 -16
- package/src/connection.ts +73 -5
- package/src/diagnostics.ts +37 -5
- package/src/index.ts +15 -1
- package/src/subscriptions.ts +280 -8
- package/src/tools.ts +10 -5
package/README.en.md
CHANGED
|
@@ -40,13 +40,13 @@ flowchart TB
|
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
# From npm (recommended, pinned)
|
|
43
|
-
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.
|
|
43
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.3
|
|
44
44
|
|
|
45
45
|
# Install the latest npm release
|
|
46
46
|
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
47
47
|
|
|
48
48
|
# From GitHub source (same commit as the npm release)
|
|
49
|
-
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.
|
|
49
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.3
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
|
|
@@ -93,7 +93,7 @@ cherry_interactions → see pending question/approval cards; cherry_respond answ
|
|
|
93
93
|
| Key | Type | Default | Notes |
|
|
94
94
|
| --- | --- | --- | --- |
|
|
95
95
|
| `dataDir` | string | `$DSH_HOME/cherry-remote` (fallback `~/.dsh/cherry-remote`) | Plugin data directory holding `identity.key` and `devices.json` (both `0600`). |
|
|
96
|
-
| `perSessionCacheBytes` | number | `2097152` (2 MiB) | Per-session cap on the **local** subscription event cache; beyond it
|
|
96
|
+
| `perSessionCacheBytes` | number | `2097152` (2 MiB) | Per-session cap on the **local** subscription event cache; beyond it events are dropped from the oldest end (FIFO) with a `warn`, but the **newest single event is always kept** — even when it alone exceeds the cap (no drop line is logged then, and the window stays over budget). |
|
|
97
97
|
| `rateBudgetPerSecond` | number | `32` | Per-device smoothed request budget (req/s). The desktop's shared allowance is 64 req/s through 2.1.6 and 256 req/s from 2.1.7, and only the newer generation downgrades an overrun to a single `RATE_LIMITED` refusal (the older one terminates the session with close `1011`) — hence a value sized for the harsher generation. Content reads (`agent.content.read`) derive their own tier at 8:1, **capped at 512/s** (that allowance is the same in both generations). |
|
|
98
98
|
| `wsTls` | object | — | **Flat** `tls.connect` options for every `wss` dial (`rejectUnauthorized` / `ca` only): operator-supplied trust for a desktop behind a private CA or a self-signed TLS front. Applied to BOTH dial sites — pairing and reconnect. **Never written to the device archive, never logged, never in diagnostics.** The whole object applies as written or is ignored entirely: an unknown key or unusable value warns by name and falls back to Node's default trust store (no partial application). |
|
|
99
99
|
|
package/README.md
CHANGED
|
@@ -38,13 +38,13 @@ flowchart TB
|
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
# 从 npm 安装(推荐,钉住版本)
|
|
41
|
-
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.
|
|
41
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.3
|
|
42
42
|
|
|
43
43
|
# 安装 npm 最新发布版
|
|
44
44
|
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
45
45
|
|
|
46
46
|
# 从 GitHub 源码安装(与 npm 发布同一提交)
|
|
47
|
-
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.
|
|
47
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.3
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
|
|
@@ -85,7 +85,7 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
|
|
|
85
85
|
| 键 | 类型 | 默认 | 说明 |
|
|
86
86
|
| --- | --- | --- | --- |
|
|
87
87
|
| `dataDir` | string | `$DSH_HOME/cherry-remote`(回退 `~/.dsh/cherry-remote`) | 插件数据目录,存放 `identity.key` 与 `devices.json`(均 `0600`)。 |
|
|
88
|
-
| `perSessionCacheBytes` | number | `2097152`(2 MiB) |
|
|
88
|
+
| `perSessionCacheBytes` | number | `2097152`(2 MiB) | 每个会话的**本地**订阅事件缓存上限;超出后从最旧一端丢弃(FIFO)并 `warn`,但**最新的单条事件总会保留**——即使它自己就超过上限(此时不打丢弃日志,窗口保持超预算)。 |
|
|
89
89
|
| `rateBudgetPerSecond` | number | `32` | 每设备平滑请求预算(请求/秒)。桌面端共享额度在 ≤2.1.6 是 64 req/s、≥2.1.7 是 256 req/s,且只有新版本才把超限降级为单请求 `RATE_LIMITED`(旧版本直接 close `1011` 终止会话),故按更严格的一代取保守值。内容读取(`agent.content.read`)按 8:1 派生独立一档,且**封顶 512/s**(该额度两代一致)。 |
|
|
90
90
|
| `wsTls` | object | — | 每次 `wss` 拨号的**扁平** `tls.connect` 选项(只接受 `rejectUnauthorized` / `ca`),供桌面端位于私有 CA 或自签 TLS 前置之后时由运维提供信任;配对与重连**两个拨号点**共用。**不写入设备档案、不写日志、不进诊断报告。** 整个对象要么按原样生效、要么整体忽略:未知键或不可用值按名 `warn` 后回退 Node 默认信任库(不部分应用)。 |
|
|
91
91
|
|
package/dist/index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { RemoteSocketStream, connectSecureChannel, createDeviceIdentity, deviceIdentityId } from "@cherrystudio/remote-transport";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { WebSocket } from "ws";
|
|
8
|
-
import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema } from "@cherrystudio/remote-protocol";
|
|
8
|
+
import { advertisedEndpointSchema, directEndpointUrl, jsonRpcNotificationSchema, jsonRpcRequestSchema, jsonRpcResponseSchema, pairingQrSchema, remoteFailureSchema, remoteLimits } from "@cherrystudio/remote-protocol";
|
|
9
9
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
10
10
|
/**
|
|
11
11
|
* Adds the candidate addresses a peer ADVERTISED that this record does not
|
|
@@ -2414,10 +2414,11 @@ function projectMessageSummaries(raw) {
|
|
|
2414
2414
|
return typeof raw === "number" && Number.isFinite(raw) ? { messageSummaries: raw } : {};
|
|
2415
2415
|
}
|
|
2416
2416
|
/**
|
|
2417
|
-
* Re-projects the four numeric limit keys, dropping any
|
|
2418
|
-
* caller's `limits` object might carry
|
|
2419
|
-
*
|
|
2420
|
-
*
|
|
2417
|
+
* Re-projects the four numeric limit keys this report carries, dropping any
|
|
2418
|
+
* foreign key that a caller's `limits` object might carry — including
|
|
2419
|
+
* `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
|
|
2420
|
+
* A whitelist inside the whitelist: the shape is rebuilt field by field rather
|
|
2421
|
+
* than spread, so a future addition cannot arrive here unvetted.
|
|
2421
2422
|
*/
|
|
2422
2423
|
function projectLimits(raw) {
|
|
2423
2424
|
if (raw === null || typeof raw !== "object") return {};
|
|
@@ -2432,6 +2433,14 @@ function projectLimits(raw) {
|
|
|
2432
2433
|
function numberOr(value, fallback) {
|
|
2433
2434
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
2434
2435
|
}
|
|
2436
|
+
/**
|
|
2437
|
+
* A delivery-ledger counter (issue #217): a NON-NEGATIVE finite number, or
|
|
2438
|
+
* nothing at all. Absence means "not reported"; a fabricated zero would read as
|
|
2439
|
+
* "nothing was delivered", which is a claim this projection has no evidence for.
|
|
2440
|
+
*/
|
|
2441
|
+
function countEntry(key, raw) {
|
|
2442
|
+
return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? { [key]: raw } : {};
|
|
2443
|
+
}
|
|
2435
2444
|
/** The loose-bag view every projection in this module takes of its input. */
|
|
2436
2445
|
function bag(raw) {
|
|
2437
2446
|
return raw ?? {};
|
|
@@ -2549,7 +2558,10 @@ function sanitizeDiagnostics(source) {
|
|
|
2549
2558
|
sessionId: typeof cursor["sessionId"] === "string" ? cursor["sessionId"] : sessionId,
|
|
2550
2559
|
streamEpoch: typeof cursor["streamEpoch"] === "string" ? cursor["streamEpoch"] : "",
|
|
2551
2560
|
seq: typeof cursor["seq"] === "string" ? cursor["seq"] : ""
|
|
2552
|
-
} }
|
|
2561
|
+
} },
|
|
2562
|
+
...countEntry("deliveredBytes", value["deliveredBytes"]),
|
|
2563
|
+
...countEntry("unackedBytes", value["unackedBytes"]),
|
|
2564
|
+
...countEntry("acks", value["acks"])
|
|
2553
2565
|
};
|
|
2554
2566
|
}),
|
|
2555
2567
|
recentErrors: (source.recentErrors ?? []).map((raw) => {
|
|
@@ -2610,7 +2622,10 @@ function buildDiagnostics(sources, identity = DEFAULT_IDENTITY) {
|
|
|
2610
2622
|
sessionId: entry.sessionId,
|
|
2611
2623
|
deviceId: entry.deviceId,
|
|
2612
2624
|
subscriptions: entry.subscriptions,
|
|
2613
|
-
...entry.cursor === void 0 ? {} : { cursor: { ...entry.cursor } }
|
|
2625
|
+
...entry.cursor === void 0 ? {} : { cursor: { ...entry.cursor } },
|
|
2626
|
+
...entry.deliveredBytes === void 0 ? {} : { deliveredBytes: entry.deliveredBytes },
|
|
2627
|
+
...entry.unackedBytes === void 0 ? {} : { unackedBytes: entry.unackedBytes },
|
|
2628
|
+
...entry.acks === void 0 ? {} : { acks: entry.acks }
|
|
2614
2629
|
});
|
|
2615
2630
|
} catch {}
|
|
2616
2631
|
let recentErrors = [];
|
|
@@ -3379,6 +3394,29 @@ var DeviceConnection = class {
|
|
|
3379
3394
|
#pendingPings = /* @__PURE__ */ new Set();
|
|
3380
3395
|
#token;
|
|
3381
3396
|
#tokenExpiresAt;
|
|
3397
|
+
/**
|
|
3398
|
+
* Hosts whose LAST attempt failed for that ADDRESS's own reason (issue #220).
|
|
3399
|
+
* A pass dials the hosts it has not failed yet FIRST, so a stale candidate
|
|
3400
|
+
* sitting first in `record.hosts` cannot cost its dial timeout on every
|
|
3401
|
+
* reconnect — the rule the official client states as "failed candidates
|
|
3402
|
+
* rotate behind untried addresses across retries, so the round budget cannot
|
|
3403
|
+
* permanently starve a later route"
|
|
3404
|
+
* (`.scratch-csa/docs/references/remote-access/connectivity.md` §Connection
|
|
3405
|
+
* lifetime; the tree is gitignored, and its remote-agent directory is the
|
|
3406
|
+
* same `16e505fe` at app tags v0.5.2 and v0.5.3, so this is not a rule that
|
|
3407
|
+
* arrived with a newer app).
|
|
3408
|
+
*
|
|
3409
|
+
* Demotion is ORDER, never exclusion: with every host marked the list falls
|
|
3410
|
+
* back to record order, so a record whose only address is marked still dials
|
|
3411
|
+
* it. A mark is cleared when that host itself connects, so a repaired address
|
|
3412
|
+
* returns to the front on its own — no expiry timer, no persistence, no
|
|
3413
|
+
* config key. A pairing-level verdict and a cancellation never mark (they are
|
|
3414
|
+
* not this address's fault: the line `isPerHostFailure` draws for the
|
|
3415
|
+
* handshake face, mirrored explicitly in the dial face, where only
|
|
3416
|
+
* `IdentityMismatchError` can arise today), and `close()` keeps the marks
|
|
3417
|
+
* because they describe addresses, not this lifecycle.
|
|
3418
|
+
*/
|
|
3419
|
+
#demotedHosts = /* @__PURE__ */ new Set();
|
|
3382
3420
|
#wsOptions;
|
|
3383
3421
|
constructor(opts) {
|
|
3384
3422
|
this.record = opts.record;
|
|
@@ -3613,7 +3651,7 @@ var DeviceConnection = class {
|
|
|
3613
3651
|
this.#setState("connecting");
|
|
3614
3652
|
if (this.record.hosts.length === 0) throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, /* @__PURE__ */ new Error("the device record has no candidate hosts"));
|
|
3615
3653
|
let lastError = /* @__PURE__ */ new Error(`device ${this.record.deviceId}: all candidate hosts unreachable`);
|
|
3616
|
-
for (const host of this
|
|
3654
|
+
for (const host of this.#dialOrder()) {
|
|
3617
3655
|
if (!this.#running || epoch !== this.#epoch) throw new CancelledError();
|
|
3618
3656
|
let link;
|
|
3619
3657
|
try {
|
|
@@ -3621,16 +3659,20 @@ var DeviceConnection = class {
|
|
|
3621
3659
|
link = await this.#dial(host, port, epoch);
|
|
3622
3660
|
} catch (error) {
|
|
3623
3661
|
if (error instanceof IdentityMismatchError) throw error;
|
|
3662
|
+
if (error instanceof CancelledError) throw error;
|
|
3624
3663
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
3664
|
+
this.#demotedHosts.add(host);
|
|
3625
3665
|
continue;
|
|
3626
3666
|
}
|
|
3627
3667
|
this.#link = link;
|
|
3628
3668
|
try {
|
|
3629
3669
|
await this.#handshake(link, epoch);
|
|
3670
|
+
this.#demotedHosts.delete(host);
|
|
3630
3671
|
return;
|
|
3631
3672
|
} catch (error) {
|
|
3632
3673
|
if (error instanceof CancelledError || !this.#running || epoch !== this.#epoch) throw new CancelledError();
|
|
3633
3674
|
if (!isPerHostFailure(error)) throw error;
|
|
3675
|
+
this.#demotedHosts.add(host);
|
|
3634
3676
|
this.#teardownLink();
|
|
3635
3677
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
3636
3678
|
continue;
|
|
@@ -3638,6 +3680,24 @@ var DeviceConnection = class {
|
|
|
3638
3680
|
}
|
|
3639
3681
|
throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError);
|
|
3640
3682
|
}
|
|
3683
|
+
/**
|
|
3684
|
+
* The pass's dial order (issue #220): hosts this connection has NOT failed
|
|
3685
|
+
* yet, in record order, then the demoted ones, in record order.
|
|
3686
|
+
*
|
|
3687
|
+
* Record order is preserved INSIDE each half, so an ordinary single-address
|
|
3688
|
+
* record, a fresh record and an all-demoted record all dial exactly the list
|
|
3689
|
+
* the record carries — the demotion can only move an address that failed
|
|
3690
|
+
* behind addresses that did not, and can never drop one.
|
|
3691
|
+
*/
|
|
3692
|
+
#dialOrder() {
|
|
3693
|
+
const hosts = this.record.hosts;
|
|
3694
|
+
if (this.#demotedHosts.size === 0) return hosts;
|
|
3695
|
+
const untried = [];
|
|
3696
|
+
const demoted = [];
|
|
3697
|
+
for (const host of hosts) if (this.#demotedHosts.has(host)) demoted.push(host);
|
|
3698
|
+
else untried.push(host);
|
|
3699
|
+
return [...untried, ...demoted];
|
|
3700
|
+
}
|
|
3641
3701
|
async #dial(host, port, epoch) {
|
|
3642
3702
|
const ws = new WebSocket(directEndpointUrl({
|
|
3643
3703
|
host,
|
|
@@ -3704,11 +3764,13 @@ var DeviceConnection = class {
|
|
|
3704
3764
|
if (rpcReason$1(error) === "INVALID_CONNECTION_STATE") throw new ProtocolVersionError(String(error));
|
|
3705
3765
|
throw error instanceof Error ? error : new Error(String(error));
|
|
3706
3766
|
}
|
|
3767
|
+
const unackedBytes = positive$2(hello.limits?.["unackedBytes"]);
|
|
3707
3768
|
this.#limits = {
|
|
3708
3769
|
heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
|
|
3709
3770
|
subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
|
|
3710
3771
|
pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
|
|
3711
3772
|
inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
|
|
3773
|
+
...unackedBytes === void 0 ? {} : { unackedBytes },
|
|
3712
3774
|
...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
|
|
3713
3775
|
};
|
|
3714
3776
|
link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
|
|
@@ -4716,6 +4778,26 @@ const DEFAULT_CHECKPOINT_MS = 3e5;
|
|
|
4716
4778
|
* not a tuning knob.
|
|
4717
4779
|
*/
|
|
4718
4780
|
const MAX_RECOVERY_ATTEMPTS = 4;
|
|
4781
|
+
/**
|
|
4782
|
+
* ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
|
|
4783
|
+
* bytes it has sent since our last `agent.subscriptions.ack` stay inside
|
|
4784
|
+
* `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
|
|
4785
|
+
* the subscription still live — and a held batch can be a run's terminal.
|
|
4786
|
+
*
|
|
4787
|
+
* This layer used to ack ONLY from `cherry_events`, so a waiter (which by
|
|
4788
|
+
* design consumes without acking) parked on a buffer that stopped advancing
|
|
4789
|
+
* just under one window: a real 240 s wait answered `timeout` with the finished
|
|
4790
|
+
* text, its cursor frozen at 666 events ≈ 262144 bytes.
|
|
4791
|
+
*
|
|
4792
|
+
* The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
|
|
4793
|
+
* memory; acking does not lose them) once a quarter of the peer's window has
|
|
4794
|
+
* accumulated. A retry cooldown keeps a refusing peer from turning every batch
|
|
4795
|
+
* into a round trip.
|
|
4796
|
+
*/
|
|
4797
|
+
/** First retry delay after a refused auto-ack; doubles per attempt. */
|
|
4798
|
+
const AUTO_ACK_RETRY_MS = 3e3;
|
|
4799
|
+
/** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
|
|
4800
|
+
const AUTO_ACK_RETRY_MAX_MS = 3e4;
|
|
4719
4801
|
function positive$1(value, fallback) {
|
|
4720
4802
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
4721
4803
|
}
|
|
@@ -4753,6 +4835,8 @@ var SessionSubscriptions = class {
|
|
|
4753
4835
|
#limit;
|
|
4754
4836
|
#perSessionCacheBytes;
|
|
4755
4837
|
#checkpointMs;
|
|
4838
|
+
/** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
|
|
4839
|
+
#unackedSource;
|
|
4756
4840
|
#sessions = /* @__PURE__ */ new Map();
|
|
4757
4841
|
/**
|
|
4758
4842
|
* Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
|
|
@@ -4774,6 +4858,7 @@ var SessionSubscriptions = class {
|
|
|
4774
4858
|
this.#limit = Math.max(1, Math.floor(positive$1(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)));
|
|
4775
4859
|
this.#perSessionCacheBytes = positive$1(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES);
|
|
4776
4860
|
this.#checkpointMs = positive$1(opts.checkpointMs, DEFAULT_CHECKPOINT_MS);
|
|
4861
|
+
this.#unackedSource = opts.unackedBytes;
|
|
4777
4862
|
}
|
|
4778
4863
|
/**
|
|
4779
4864
|
* Session ids with a live subscription, LRU-first. Task 10's reconnect
|
|
@@ -4816,6 +4901,28 @@ var SessionSubscriptions = class {
|
|
|
4816
4901
|
return this.#sessions.get(sessionId)?.cursor;
|
|
4817
4902
|
}
|
|
4818
4903
|
/**
|
|
4904
|
+
* The DELIVERY LEDGER of one subscription (issue #217): the payload bytes it
|
|
4905
|
+
* has received since it was installed, how much of that the peer may still
|
|
4906
|
+
* count as un-acknowledged, and how many acks it has taken. Undefined for a
|
|
4907
|
+
* session this layer does not hold — never a fabricated zero.
|
|
4908
|
+
*
|
|
4909
|
+
* Diagnostics is the caller: these three numbers are what turns 「the stream
|
|
4910
|
+
* exceeded the peer's window」 from an extrapolation into a measurement.
|
|
4911
|
+
*
|
|
4912
|
+
* A CLIENT-SIDE ledger, never the peer's: `unackedBytes` is this layer's
|
|
4913
|
+
* model of what the peer may still count (bytes since its last accepted
|
|
4914
|
+
* ack), not a reading of the peer's own window or ack position.
|
|
4915
|
+
*/
|
|
4916
|
+
deliveryOf(sessionId) {
|
|
4917
|
+
const state = this.#sessions.get(sessionId);
|
|
4918
|
+
if (state === void 0) return void 0;
|
|
4919
|
+
return {
|
|
4920
|
+
deliveredBytes: state.deliveredBytes,
|
|
4921
|
+
unackedBytes: state.bytesSinceAck,
|
|
4922
|
+
acks: state.acks
|
|
4923
|
+
};
|
|
4924
|
+
}
|
|
4925
|
+
/**
|
|
4819
4926
|
* The session's cache WINDOW START — the retention BOUNDARY of this layer's
|
|
4820
4927
|
* cache: the oldest position it still tracks — or undefined when the session
|
|
4821
4928
|
* is untracked.
|
|
@@ -4873,6 +4980,7 @@ var SessionSubscriptions = class {
|
|
|
4873
4980
|
if (existing !== void 0) {
|
|
4874
4981
|
console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`);
|
|
4875
4982
|
await this.#closeSubscription(existing.subscriptionId);
|
|
4983
|
+
this.#cancelAckRetry(existing);
|
|
4876
4984
|
this.#rejectWaiters(existing, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
|
|
4877
4985
|
this.#sessions.delete(sessionId);
|
|
4878
4986
|
}
|
|
@@ -4948,17 +5056,25 @@ var SessionSubscriptions = class {
|
|
|
4948
5056
|
/**
|
|
4949
5057
|
* Advances the peer's view of this session: `agent.subscriptions.ack` with
|
|
4950
5058
|
* the delivered cursor. The peer only accepts a cursor inside the window it
|
|
4951
|
-
* actually delivered (an out-of-window ack is its CONFLICT)
|
|
4952
|
-
*
|
|
5059
|
+
* actually delivered (an out-of-window ack is its CONFLICT).
|
|
5060
|
+
*
|
|
5061
|
+
* Two callers, one contract — "this position is accounted for, keep sending":
|
|
5062
|
+
* `cherry_events` passes the cursor a CONSUMER has processed, and
|
|
5063
|
+
* {@link #maybeAutoAck} passes the BUFFER frontier because the events are
|
|
5064
|
+
* retained here (see that method for why an ack cannot lose replay).
|
|
4953
5065
|
*/
|
|
4954
5066
|
async ack(sessionId, cursor) {
|
|
4955
5067
|
const state = this.#sessions.get(sessionId);
|
|
4956
5068
|
if (state === void 0) return;
|
|
4957
|
-
const
|
|
5069
|
+
const requested = cursor === void 0 ? state.cursor : cursor;
|
|
5070
|
+
const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor;
|
|
5071
|
+
const covered = state.bytesSinceAck;
|
|
4958
5072
|
await this.#rpc.request("agent.subscriptions.ack", {
|
|
4959
5073
|
subscriptionId: state.subscriptionId,
|
|
4960
5074
|
cursor: applied
|
|
4961
5075
|
});
|
|
5076
|
+
state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
|
|
5077
|
+
state.acks += 1;
|
|
4962
5078
|
}
|
|
4963
5079
|
/**
|
|
4964
5080
|
* Parks a resolver that settles when the session buffer advances past
|
|
@@ -5006,6 +5122,7 @@ var SessionSubscriptions = class {
|
|
|
5006
5122
|
this.#closed = true;
|
|
5007
5123
|
const ids = [...this.#sessions.values()].map((state) => state.subscriptionId);
|
|
5008
5124
|
for (const state of this.#sessions.values()) {
|
|
5125
|
+
this.#cancelAckRetry(state);
|
|
5009
5126
|
const error = /* @__PURE__ */ new Error("SessionSubscriptions disposed");
|
|
5010
5127
|
for (const waiter of state.waiters.splice(0)) waiter.reject(error);
|
|
5011
5128
|
}
|
|
@@ -5088,13 +5205,23 @@ var SessionSubscriptions = class {
|
|
|
5088
5205
|
*/
|
|
5089
5206
|
#installReplay(sessionId, descriptor) {
|
|
5090
5207
|
const previous = this.#sessions.get(sessionId);
|
|
5091
|
-
if (previous !== void 0)
|
|
5208
|
+
if (previous !== void 0) {
|
|
5209
|
+
this.#cancelAckRetry(previous);
|
|
5210
|
+
this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
|
|
5211
|
+
}
|
|
5092
5212
|
this.#sessions.delete(sessionId);
|
|
5093
5213
|
this.#sessions.set(sessionId, {
|
|
5094
5214
|
subscriptionId: descriptor.subscriptionId,
|
|
5095
5215
|
cursor: descriptor.highWatermark,
|
|
5096
5216
|
buffer: [],
|
|
5097
5217
|
bytes: 0,
|
|
5218
|
+
bytesSinceAck: 0,
|
|
5219
|
+
deliveredBytes: 0,
|
|
5220
|
+
acks: 0,
|
|
5221
|
+
ackInFlight: false,
|
|
5222
|
+
ackRetryAt: 0,
|
|
5223
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
5224
|
+
ackRetryTimer: void 0,
|
|
5098
5225
|
windowStart: Number(descriptor.highWatermark.seq),
|
|
5099
5226
|
evicted: false,
|
|
5100
5227
|
waiters: [],
|
|
@@ -5174,13 +5301,23 @@ var SessionSubscriptions = class {
|
|
|
5174
5301
|
*/
|
|
5175
5302
|
#installCheckpoint(sessionId, descriptor, items) {
|
|
5176
5303
|
const previous = this.#sessions.get(sessionId);
|
|
5177
|
-
if (previous !== void 0)
|
|
5304
|
+
if (previous !== void 0) {
|
|
5305
|
+
this.#cancelAckRetry(previous);
|
|
5306
|
+
this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
|
|
5307
|
+
}
|
|
5178
5308
|
this.#sessions.delete(sessionId);
|
|
5179
5309
|
this.#sessions.set(sessionId, {
|
|
5180
5310
|
subscriptionId: descriptor.subscriptionId,
|
|
5181
5311
|
cursor: descriptor.checkpoint.cursor,
|
|
5182
5312
|
buffer: [],
|
|
5183
5313
|
bytes: 0,
|
|
5314
|
+
bytesSinceAck: 0,
|
|
5315
|
+
deliveredBytes: 0,
|
|
5316
|
+
acks: 0,
|
|
5317
|
+
ackInFlight: false,
|
|
5318
|
+
ackRetryAt: 0,
|
|
5319
|
+
ackRetryDelay: AUTO_ACK_RETRY_MS,
|
|
5320
|
+
ackRetryTimer: void 0,
|
|
5184
5321
|
windowStart: Number(descriptor.checkpoint.cursor.seq),
|
|
5185
5322
|
evicted: false,
|
|
5186
5323
|
waiters: [],
|
|
@@ -5226,6 +5363,7 @@ var SessionSubscriptions = class {
|
|
|
5226
5363
|
for (const [sessionId, state] of this.#sessions) {
|
|
5227
5364
|
if (sessionId === protectedSessionId) continue;
|
|
5228
5365
|
console.warn(`[cherry-remote] subscription limit (${this.#limit}) reached; evicting least-recently-used session ${sessionId}`);
|
|
5366
|
+
this.#cancelAckRetry(state);
|
|
5229
5367
|
this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} evicted by LRU`));
|
|
5230
5368
|
this.#sessions.delete(sessionId);
|
|
5231
5369
|
await this.#closeSubscription(state.subscriptionId);
|
|
@@ -5239,7 +5377,9 @@ var SessionSubscriptions = class {
|
|
|
5239
5377
|
}
|
|
5240
5378
|
/**
|
|
5241
5379
|
* `agent.events`: append to the session buffer, enforce the byte budget by
|
|
5242
|
-
* dropping the OLDEST events (logged
|
|
5380
|
+
* dropping the OLDEST events (logged, and never the last remaining one — see
|
|
5381
|
+
* `#trimToBudget`'s floor, issue #221), advance the cursor, then wake
|
|
5382
|
+
* waiters.
|
|
5243
5383
|
*
|
|
5244
5384
|
* SEQ MONOTONICITY IS ENFORCED HERE, per (sessionId, streamEpoch). The peer is
|
|
5245
5385
|
* supposed to deliver each stream position exactly once and in order, and the
|
|
@@ -5307,11 +5447,85 @@ var SessionSubscriptions = class {
|
|
|
5307
5447
|
bytes
|
|
5308
5448
|
});
|
|
5309
5449
|
state.bytes += bytes;
|
|
5450
|
+
state.bytesSinceAck += bytes;
|
|
5451
|
+
state.deliveredBytes += bytes;
|
|
5310
5452
|
state.cursor = cursor;
|
|
5311
5453
|
appended += 1;
|
|
5312
5454
|
}
|
|
5313
5455
|
this.#trimToBudget(sessionId, state);
|
|
5314
5456
|
if (appended > 0) this.#wakeWaiters(state);
|
|
5457
|
+
if (appended > 0) this.#maybeAutoAck(sessionId, state);
|
|
5458
|
+
}
|
|
5459
|
+
/**
|
|
5460
|
+
* Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
|
|
5461
|
+
* window, so the peer keeps delivering (issue #212).
|
|
5462
|
+
*
|
|
5463
|
+
* WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
|
|
5464
|
+
* a caller has consumed". That is safe against the pinned peer because an ack
|
|
5465
|
+
* does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
|
|
5466
|
+
* un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
|
|
5467
|
+
* retention lives on the journal (`replayBytes`/`replayMs`) and is not
|
|
5468
|
+
* touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
|
|
5469
|
+
* below the acked position is still served — by the local buffer here and by
|
|
5470
|
+
* the journal there. The ack's own doc comment states the same contract.
|
|
5471
|
+
*
|
|
5472
|
+
* The ack is fire-and-forget with its failure SURFACED (a refused ack must be
|
|
5473
|
+
* visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
|
|
5474
|
+
* holding at its window no further event arrives, so a retry that waited for
|
|
5475
|
+
* traffic would never fire and the wait would time out exactly as in issue
|
|
5476
|
+
* #212.
|
|
5477
|
+
*/
|
|
5478
|
+
#maybeAutoAck(sessionId, state) {
|
|
5479
|
+
if (state.bytesSinceAck < this.#ackWatermark()) return;
|
|
5480
|
+
if (state.ackInFlight) return;
|
|
5481
|
+
if (Date.now() < state.ackRetryAt) return;
|
|
5482
|
+
state.ackInFlight = true;
|
|
5483
|
+
this.ack(sessionId).then(() => {
|
|
5484
|
+
state.ackRetryDelay = AUTO_ACK_RETRY_MS;
|
|
5485
|
+
}).catch((error) => {
|
|
5486
|
+
const delay = state.ackRetryDelay;
|
|
5487
|
+
state.ackRetryAt = Date.now() + delay;
|
|
5488
|
+
state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS);
|
|
5489
|
+
console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`);
|
|
5490
|
+
this.#armAckRetry(sessionId, state, delay);
|
|
5491
|
+
}).finally(() => {
|
|
5492
|
+
state.ackInFlight = false;
|
|
5493
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return;
|
|
5494
|
+
this.#maybeAutoAck(sessionId, state);
|
|
5495
|
+
});
|
|
5496
|
+
}
|
|
5497
|
+
/**
|
|
5498
|
+
* Quarter of the peer's window, read from the CURRENT source so a layer that
|
|
5499
|
+
* outlives a reconnect follows the new hello instead of its first answer.
|
|
5500
|
+
* Falls back to the pinned protocol constant when nothing is advertised.
|
|
5501
|
+
*/
|
|
5502
|
+
#ackWatermark() {
|
|
5503
|
+
const source = this.#unackedSource;
|
|
5504
|
+
const advertised = typeof source === "function" ? source() : source;
|
|
5505
|
+
return Math.max(1, Math.floor(positive$1(advertised, remoteLimits.unackedBytes) / 4));
|
|
5506
|
+
}
|
|
5507
|
+
/** Disarms the retry, if any (idempotent; safe on a state being replaced). */
|
|
5508
|
+
#cancelAckRetry(state) {
|
|
5509
|
+
if (state.ackRetryTimer === void 0) return;
|
|
5510
|
+
clearTimeout(state.ackRetryTimer);
|
|
5511
|
+
state.ackRetryTimer = void 0;
|
|
5512
|
+
}
|
|
5513
|
+
/**
|
|
5514
|
+
* One timer-driven retry after a refused ack. At most one is armed per
|
|
5515
|
+
* session; it no-ops when the state it was armed for is no longer current
|
|
5516
|
+
* (a re-subscribe, a checkpoint recovery or `dispose`).
|
|
5517
|
+
*/
|
|
5518
|
+
#armAckRetry(sessionId, state, delayMs) {
|
|
5519
|
+
if (this.#closed) return;
|
|
5520
|
+
if (state.ackRetryTimer !== void 0) return;
|
|
5521
|
+
const timer = setTimeout(() => {
|
|
5522
|
+
state.ackRetryTimer = void 0;
|
|
5523
|
+
if (this.#closed || this.#sessions.get(sessionId) !== state) return;
|
|
5524
|
+
state.ackRetryAt = 0;
|
|
5525
|
+
this.#maybeAutoAck(sessionId, state);
|
|
5526
|
+
}, delayMs);
|
|
5527
|
+
timer.unref?.();
|
|
5528
|
+
state.ackRetryTimer = timer;
|
|
5315
5529
|
}
|
|
5316
5530
|
/**
|
|
5317
5531
|
* `agent.subscriptions.resetRequired`: the peer tore the subscription down
|
|
@@ -5330,22 +5544,37 @@ var SessionSubscriptions = class {
|
|
|
5330
5544
|
for (const [sessionId, state] of this.#sessions) {
|
|
5331
5545
|
if (state.subscriptionId !== subscriptionId) continue;
|
|
5332
5546
|
console.warn(`[cherry-remote] subscription for session ${sessionId} reset by the peer (${String(params["reason"] ?? "unspecified")}); re-subscribe required`);
|
|
5547
|
+
this.#cancelAckRetry(state);
|
|
5333
5548
|
this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} reset by the peer`));
|
|
5334
5549
|
this.#sessions.delete(sessionId);
|
|
5335
5550
|
return;
|
|
5336
5551
|
}
|
|
5337
5552
|
}
|
|
5338
|
-
/**
|
|
5553
|
+
/**
|
|
5554
|
+
* Drops the OLDEST buffered events until the session fits its byte budget.
|
|
5555
|
+
*
|
|
5556
|
+
* The loop stops at ONE remaining event (issue #221): the budget governs the
|
|
5557
|
+
* window's TAIL, never its head. A single event above the budget — only
|
|
5558
|
+
* reachable when `perSessionCacheBytes` is set below one record's size, since
|
|
5559
|
+
* the transport caps a record at 65,536 bytes — used to be evicted too,
|
|
5560
|
+
* leaving an EMPTY window whose `windowStart` had advanced past it: the event
|
|
5561
|
+
* that just happened, the one thing a reader wants, became unreachable and
|
|
5562
|
+
* every read from the baseline answered `cursor-out-of-window` about content
|
|
5563
|
+
* this layer had held a moment earlier. Keeping it is also the honest
|
|
5564
|
+
* reading of `evicted`: nothing was discarded, so the baseline stays a
|
|
5565
|
+
* servable resume point.
|
|
5566
|
+
*/
|
|
5339
5567
|
#trimToBudget(sessionId, state) {
|
|
5340
5568
|
if (state.bytes <= this.#perSessionCacheBytes) return;
|
|
5341
5569
|
let dropped = 0;
|
|
5342
|
-
while (state.bytes > this.#perSessionCacheBytes && state.buffer.length >
|
|
5570
|
+
while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 1) {
|
|
5343
5571
|
const removed = state.buffer.shift();
|
|
5344
5572
|
state.bytes -= removed.bytes;
|
|
5345
5573
|
dropped += 1;
|
|
5346
5574
|
state.windowStart = Math.max(state.windowStart, Number(removed.cursor.seq));
|
|
5347
5575
|
state.evicted = true;
|
|
5348
5576
|
}
|
|
5577
|
+
if (dropped === 0) return;
|
|
5349
5578
|
console.warn(`[cherry-remote] session ${sessionId} cache exceeded ${this.#perSessionCacheBytes} bytes; dropped ${dropped} oldest event(s)`);
|
|
5350
5579
|
}
|
|
5351
5580
|
/** Coalesced wake: each parked waiter settles exactly once. */
|
|
@@ -9160,8 +9389,9 @@ var CherryToolEngine = class {
|
|
|
9160
9389
|
* dimension and deliberately does NOT settle a wait (Task 13 asserts the two
|
|
9161
9390
|
* layers separately).
|
|
9162
9391
|
*
|
|
9163
|
-
* Waiters
|
|
9164
|
-
*
|
|
9392
|
+
* Waiters do not ack what they fold. The subscription LAYER acks its own
|
|
9393
|
+
* buffer on a byte watermark (issue #212) so the peer keeps delivering past
|
|
9394
|
+
* its un-acked window; that ack is about flow control, not consumption.
|
|
9165
9395
|
*
|
|
9166
9396
|
* A wait can also end because the LINK went away — the plugin was torn down,
|
|
9167
9397
|
* the device was unpaired, or the connection closed. That is not a timeout and
|
|
@@ -12671,7 +12901,8 @@ function subsFor(deviceId) {
|
|
|
12671
12901
|
const connection = connectionFor(deviceId);
|
|
12672
12902
|
const created = new SessionSubscriptions(connection.rpc, {
|
|
12673
12903
|
subscriptionsLimit: connection.limits.subscriptions,
|
|
12674
|
-
perSessionCacheBytes: state.perSessionCacheBytes
|
|
12904
|
+
perSessionCacheBytes: state.perSessionCacheBytes,
|
|
12905
|
+
unackedBytes: () => connection.limits.unackedBytes
|
|
12675
12906
|
});
|
|
12676
12907
|
state.subscriptions.set(deviceId, created);
|
|
12677
12908
|
console.info(`[cherry-remote] subscription layer established for ${deviceId} (peer limit ${connection.limits.subscriptions})`);
|
|
@@ -12796,11 +13027,13 @@ function assemblyDeps() {
|
|
|
12796
13027
|
}),
|
|
12797
13028
|
diagnosticsSessions: () => [...state.subscriptions.entries()].flatMap(([deviceId, layer]) => layer.activeSessions().map((sessionId) => {
|
|
12798
13029
|
const cursor = layer.cursorOf(sessionId);
|
|
13030
|
+
const delivery = layer.deliveryOf(sessionId);
|
|
12799
13031
|
return {
|
|
12800
13032
|
deviceId,
|
|
12801
13033
|
sessionId,
|
|
12802
13034
|
subscriptions: 1,
|
|
12803
|
-
...cursor === void 0 ? {} : { cursor }
|
|
13035
|
+
...cursor === void 0 ? {} : { cursor },
|
|
13036
|
+
...delivery === void 0 ? {} : delivery
|
|
12804
13037
|
};
|
|
12805
13038
|
})),
|
|
12806
13039
|
diagnosticsErrors: () => [...state.recentErrors],
|
package/docs/e2e-checklist.md
CHANGED
|
@@ -137,6 +137,7 @@ schema 已允许 `interrupted`,所以客户端**必须**处理,但当前服
|
|
|
137
137
|
- [ ] 长回复(>4096 单元)经 `{ref}` 外置,聚合结果仍包含完整文本。
|
|
138
138
|
- [ ] `cherry_read` 能按 messageId 读到该轮消息与 parts。
|
|
139
139
|
- [ ] `wait: false` 立即返回命令回执,随后 `cherry_wait` / `cherry_events` 能续查到同一终态。
|
|
140
|
+
- [ ] 长回答(事件流超过 256 KiB,约 30 小节的一次长输出)时 `wait: true` 仍在终态后立即返回 `completed`;期间每 5 s 采一次 `GET /diagnostics` 的 `sessions[].cursor.seq`,确认它没有停住(修复前会冻结在窗口处,最终报 `timeout`)。
|
|
140
141
|
- [ ] 同 `commandId` 同参重发 → 返回**同一回执**(幂等);异参重发 → `IDEMPOTENCY_CONFLICT` 透传。
|
|
141
142
|
|
|
142
143
|
### C. 交互应答
|
|
@@ -337,3 +338,48 @@ A–G 各节要真人 + Electron 端批准,无法被 CI 化;下表只记录
|
|
|
337
338
|
被测设备是单主机同端口;需要造一条混合端口记录才能走。
|
|
338
339
|
- **#161 `failureText`**:要制造一个 peer 可达的 caught failure(如 ack 失败)才会出现,
|
|
339
340
|
本轮 `recentErrors` 为空。
|
|
341
|
+
|
|
342
|
+
### 0.2.1(2026-11-01 · DSH profile `web` · 桌面端 ISV-ZYX 2.1.10)
|
|
343
|
+
|
|
344
|
+
| 项 | 结果 | 观测 |
|
|
345
|
+
| --- | --- | --- |
|
|
346
|
+
| 装机生效 | 通过 | 版本 `0.2.1`;包 mtime `23:19:54` 早于宿主 PID 1 启动 `23:20:24`;装机 `dist/index.mjs`、`dist/index.d.mts`、`lib/client.js`、`locale/zh.json` 与 npm `dsh-cherry-bridge@0.2.1` tarball(shasum `ae7ed02b…`)sha256 全同 |
|
|
347
|
+
| 档案无迁移 | 通过 | `devices.json` 仍是 `schemaVersion: 4`(本轮没有触发任何迁移);`forgottenHosts` 是**可选键、不升版本**(代码依据:档案校验器与视图),首次 `forget` 后出现在文件里。注:捕获里**没有 forget 之前的档案快照**,所以「之前不在文件里」不算本轮观测 |
|
|
348
|
+
| `GET /cherry-remote/api/devices` 新键 | 通过 | 每台设备多出 `forgottenHosts`(无标记时是空数组 `[]`,不是缺键);本机设备 `hosts` 两条、`security` / `ports` 各两条 |
|
|
349
|
+
| 忘记一条非末位地址(#185 / #204) | 通过 | `POST …/devices/<id>/forget {"hosts":["172.17.14.36"]}` → 200;同一响应里 `hosts` 少一条、`forgottenHosts` 多一条、`state` 变 `connecting`(驱逐),`security` / `ports` 同步删掉该键;档案已落盘。注:forget 之前的「两条地址」由收尾/live 的原状锚定,捕获的第一条事件就是 forget |
|
|
350
|
+
| **重连后不会学回来** | 通过 | 21 s 后 `state: connected`,`hosts` **仍是单条**(观测)。**推断**:桌面一直在广播两条、学习合并跳过了被标记者 —— 依据是下一行「恢复只摘标记」之后 18 s 地址就自己回来了,中间没有别的写入 |
|
|
351
|
+
| 恢复只摘标记 | 通过 | `POST …/remember` → 200,`forgottenHosts: []` 而 `hosts` **未变**;再 18 s 后 `hosts` 回到两条,`security` / `ports` 里重新出现该键(`ws` / 23333,这两个值是否对端所报见下方「未覆盖」) |
|
|
352
|
+
| 拒绝忘记最后一条 | 通过 | 只剩一条时 `forget` → `400` `{"error":"last-address","message":"… refusing to forget the last dial candidate — the record would have nothing to dial. Keep at least one address."}`;档案**未变**(`hosts` 与标记都保持原样;代码在持久化**之前**抛出) |
|
|
353
|
+
| 驱逐的代价可观测 | 通过 | 四次改动 = 四次重建:`stats.connectionsBuilt` 收尾 **5**,两次改动后读到 **3**(故基线推为 1,`1` 本身没有直接读到);`connections` / `sockets` 两次读到 1/1(23:25:00 那次没取到 `stats`);`recentErrors` 恰好 **4** 条 `CONNECTION_OFFLINE`(`device transitioned to offline`,每次驱逐一条,无其他;捕获里第 4 条被截断,`/diagnostics` 现读为 4);收尾回到原状(两条地址、无标记、`connected`) |
|
|
354
|
+
|
|
355
|
+
**本轮未覆盖**(**不是通过**):
|
|
356
|
+
|
|
357
|
+
- **恢复后的端口 / 协议是否真来自对端**:本环境桌面广播的就是记录级默认(`ws` / 23333),
|
|
358
|
+
观测无法区分「对端说的」与「默认值」;由 `DeviceStore.learnEndpoints` 与
|
|
359
|
+
`advertisedEndpoints` 的单测覆盖(每个端点自带 port/scheme)。
|
|
360
|
+
- **设置页那对按钮的点击**:需要能打开宿主的浏览器;本机渲染由 `tests/unit/ui.test.ts`
|
|
361
|
+
覆盖,地址编辑器打开时两个控件冻结由 `#204m` 覆盖。本轮走的是页面调用的同两条 HTTP 路由。
|
|
362
|
+
- **驱逐时 in-flight 命令丢失**:需要一个正在执行的 command 才会出现,本轮没有。
|
|
363
|
+
|
|
364
|
+
### 0.2.2(2026-11-03 · DSH profile `web` · 桌面端 ISV-ZYX)
|
|
365
|
+
|
|
366
|
+
| 项 | 结果 | 观测 |
|
|
367
|
+
| --- | --- | --- |
|
|
368
|
+
| 装机生效 | 通过 | 包版本 `0.2.2`;`dist/index.mjs` mtime `00:13:42` 早于宿主 PID 1 启动 `00:13:54`;装机 `dist/index.mjs`、`dist/index.d.mts`、`lib/client.js`、`locale/zh.json`、`locale/en.json` 与发行 tarball(sha1 `1ac80762…`,等于 registry 的 `dist.shasum`)逐文件 sha256 **全同** |
|
|
369
|
+
| 超窗长回答 `wait: true` 仍返回 `completed`(§B 新增项) | 通过 | 同一会话(`dsh-bridge 2.1.10 smoke`)连发两轮 30 节长回答,会话 `seq` 从 341 推到 **1374**、再从 1374 推到 **2204**;两轮 `cherry_send` 都返回 `status: "completed"` 并带回助手全文。对端提交时刻是 `00:18:05.797` / `00:19:18.597`,而提交后 1.4 s 的首个采样点已经读到终值 1374 / 2204;第三轮的下一次工具调用发生在提交后 6.6 s,故返回延迟的上界是 6.6 s(含 agent 侧一次调度) |
|
|
370
|
+
| 窗口不冻结(§B 新增项) | 通过 | 每 5 s 采一次 `GET …/diagnostics` 的 `sessions[].cursor.seq`:`00:17:32→00:18:07` 为 346→**918**→993→1074→1153→1227→1308→**1374**,`00:18:49→00:19:19` 为 1374→**1854**→1930→1994→2065→2144→**2204**;两段单调推进、终值后保持,**没有任何一段平台期**(修复前同一会话在整个 240 s 等待窗口里冻结在 666,见 v0.2.2 release 正文) |
|
|
371
|
+
|
|
372
|
+
**本轮未覆盖**(**不是通过**):
|
|
373
|
+
|
|
374
|
+
- **字节级的窗口证据**:`GET …/diagnostics` 按设计只带四个数字 limit 键(`projectLimits`
|
|
375
|
+
的白名单刻意剔掉 `unackedBytes`),也不报 ack 次数,所以「事件流累计 > 262144 B」是
|
|
376
|
+
**外推**:同一会话修复前的实测是 666 个事件到窗口(≈394 B/event),本轮 1374 / 2204 事件
|
|
377
|
+
对应约 0.54 / 0.87 MB。要直接测字节,得先给诊断报告加字段。
|
|
378
|
+
- **ack 是否从未失败**:失败只会 `console.warn`,不进 `recentErrors`;本环境的宿主日志不可读
|
|
379
|
+
(`/proc/1/fd/1` 拒读)。本轮能断言的是「没有出现冻结与告警级停滞」,不是「ack 零失败」。
|
|
380
|
+
- **桌面端版本号**:本轮没有重读(上一轮记录为 2.1.10);本客户端不从握手读桌面端版本号
|
|
381
|
+
(`EffectiveLimits` 只有心跳/订阅/分页/并发/未确认字节五个窗口参数加 `messageSummaries`),`/devices`
|
|
382
|
+
与 `/diagnostics` 也都不带。
|
|
383
|
+
- **`wait: true` 的毫秒级时延**:只有上界,没有工具级计时器。
|
|
384
|
+
- 第二部分其余小节(配对、交互应答、断连恢复、多设备、`/scope`、语言切换)本轮没有重跑:
|
|
385
|
+
它们不在 0.2.2 的改动面上。
|