dsh-cherry-bridge 0.2.2 → 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 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.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.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 the oldest events are dropped LRU-style with a `warn`. |
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.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.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) | 每个会话的**本地**订阅事件缓存上限,超出按 LRU 丢弃最旧事件并 `warn`。 |
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
@@ -2433,6 +2433,14 @@ function projectLimits(raw) {
2433
2433
  function numberOr(value, fallback) {
2434
2434
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
2435
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
+ }
2436
2444
  /** The loose-bag view every projection in this module takes of its input. */
2437
2445
  function bag(raw) {
2438
2446
  return raw ?? {};
@@ -2550,7 +2558,10 @@ function sanitizeDiagnostics(source) {
2550
2558
  sessionId: typeof cursor["sessionId"] === "string" ? cursor["sessionId"] : sessionId,
2551
2559
  streamEpoch: typeof cursor["streamEpoch"] === "string" ? cursor["streamEpoch"] : "",
2552
2560
  seq: typeof cursor["seq"] === "string" ? cursor["seq"] : ""
2553
- } }
2561
+ } },
2562
+ ...countEntry("deliveredBytes", value["deliveredBytes"]),
2563
+ ...countEntry("unackedBytes", value["unackedBytes"]),
2564
+ ...countEntry("acks", value["acks"])
2554
2565
  };
2555
2566
  }),
2556
2567
  recentErrors: (source.recentErrors ?? []).map((raw) => {
@@ -2611,7 +2622,10 @@ function buildDiagnostics(sources, identity = DEFAULT_IDENTITY) {
2611
2622
  sessionId: entry.sessionId,
2612
2623
  deviceId: entry.deviceId,
2613
2624
  subscriptions: entry.subscriptions,
2614
- ...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 }
2615
2629
  });
2616
2630
  } catch {}
2617
2631
  let recentErrors = [];
@@ -3380,6 +3394,29 @@ var DeviceConnection = class {
3380
3394
  #pendingPings = /* @__PURE__ */ new Set();
3381
3395
  #token;
3382
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();
3383
3420
  #wsOptions;
3384
3421
  constructor(opts) {
3385
3422
  this.record = opts.record;
@@ -3614,7 +3651,7 @@ var DeviceConnection = class {
3614
3651
  this.#setState("connecting");
3615
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"));
3616
3653
  let lastError = /* @__PURE__ */ new Error(`device ${this.record.deviceId}: all candidate hosts unreachable`);
3617
- for (const host of this.record.hosts) {
3654
+ for (const host of this.#dialOrder()) {
3618
3655
  if (!this.#running || epoch !== this.#epoch) throw new CancelledError();
3619
3656
  let link;
3620
3657
  try {
@@ -3622,16 +3659,20 @@ var DeviceConnection = class {
3622
3659
  link = await this.#dial(host, port, epoch);
3623
3660
  } catch (error) {
3624
3661
  if (error instanceof IdentityMismatchError) throw error;
3662
+ if (error instanceof CancelledError) throw error;
3625
3663
  lastError = error instanceof Error ? error : new Error(String(error));
3664
+ this.#demotedHosts.add(host);
3626
3665
  continue;
3627
3666
  }
3628
3667
  this.#link = link;
3629
3668
  try {
3630
3669
  await this.#handshake(link, epoch);
3670
+ this.#demotedHosts.delete(host);
3631
3671
  return;
3632
3672
  } catch (error) {
3633
3673
  if (error instanceof CancelledError || !this.#running || epoch !== this.#epoch) throw new CancelledError();
3634
3674
  if (!isPerHostFailure(error)) throw error;
3675
+ this.#demotedHosts.add(host);
3635
3676
  this.#teardownLink();
3636
3677
  lastError = error instanceof Error ? error : new Error(String(error));
3637
3678
  continue;
@@ -3639,6 +3680,24 @@ var DeviceConnection = class {
3639
3680
  }
3640
3681
  throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError);
3641
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
+ }
3642
3701
  async #dial(host, port, epoch) {
3643
3702
  const ws = new WebSocket(directEndpointUrl({
3644
3703
  host,
@@ -4842,6 +4901,28 @@ var SessionSubscriptions = class {
4842
4901
  return this.#sessions.get(sessionId)?.cursor;
4843
4902
  }
4844
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
+ /**
4845
4926
  * The session's cache WINDOW START — the retention BOUNDARY of this layer's
4846
4927
  * cache: the oldest position it still tracks — or undefined when the session
4847
4928
  * is untracked.
@@ -4987,10 +5068,13 @@ var SessionSubscriptions = class {
4987
5068
  if (state === void 0) return;
4988
5069
  const requested = cursor === void 0 ? state.cursor : cursor;
4989
5070
  const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor;
5071
+ const covered = state.bytesSinceAck;
4990
5072
  await this.#rpc.request("agent.subscriptions.ack", {
4991
5073
  subscriptionId: state.subscriptionId,
4992
5074
  cursor: applied
4993
5075
  });
5076
+ state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
5077
+ state.acks += 1;
4994
5078
  }
4995
5079
  /**
4996
5080
  * Parks a resolver that settles when the session buffer advances past
@@ -5132,6 +5216,8 @@ var SessionSubscriptions = class {
5132
5216
  buffer: [],
5133
5217
  bytes: 0,
5134
5218
  bytesSinceAck: 0,
5219
+ deliveredBytes: 0,
5220
+ acks: 0,
5135
5221
  ackInFlight: false,
5136
5222
  ackRetryAt: 0,
5137
5223
  ackRetryDelay: AUTO_ACK_RETRY_MS,
@@ -5226,6 +5312,8 @@ var SessionSubscriptions = class {
5226
5312
  buffer: [],
5227
5313
  bytes: 0,
5228
5314
  bytesSinceAck: 0,
5315
+ deliveredBytes: 0,
5316
+ acks: 0,
5229
5317
  ackInFlight: false,
5230
5318
  ackRetryAt: 0,
5231
5319
  ackRetryDelay: AUTO_ACK_RETRY_MS,
@@ -5289,7 +5377,9 @@ var SessionSubscriptions = class {
5289
5377
  }
5290
5378
  /**
5291
5379
  * `agent.events`: append to the session buffer, enforce the byte budget by
5292
- * dropping the OLDEST events (logged), advance the cursor, then wake waiters.
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.
5293
5383
  *
5294
5384
  * SEQ MONOTONICITY IS ENFORCED HERE, per (sessionId, streamEpoch). The peer is
5295
5385
  * supposed to deliver each stream position exactly once and in order, and the
@@ -5358,6 +5448,7 @@ var SessionSubscriptions = class {
5358
5448
  });
5359
5449
  state.bytes += bytes;
5360
5450
  state.bytesSinceAck += bytes;
5451
+ state.deliveredBytes += bytes;
5361
5452
  state.cursor = cursor;
5362
5453
  appended += 1;
5363
5454
  }
@@ -5389,9 +5480,7 @@ var SessionSubscriptions = class {
5389
5480
  if (state.ackInFlight) return;
5390
5481
  if (Date.now() < state.ackRetryAt) return;
5391
5482
  state.ackInFlight = true;
5392
- const covered = state.bytesSinceAck;
5393
5483
  this.ack(sessionId).then(() => {
5394
- state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
5395
5484
  state.ackRetryDelay = AUTO_ACK_RETRY_MS;
5396
5485
  }).catch((error) => {
5397
5486
  const delay = state.ackRetryDelay;
@@ -5461,17 +5550,31 @@ var SessionSubscriptions = class {
5461
5550
  return;
5462
5551
  }
5463
5552
  }
5464
- /** Drops the OLDEST buffered events until the session fits its byte budget. */
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
+ */
5465
5567
  #trimToBudget(sessionId, state) {
5466
5568
  if (state.bytes <= this.#perSessionCacheBytes) return;
5467
5569
  let dropped = 0;
5468
- while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 0) {
5570
+ while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 1) {
5469
5571
  const removed = state.buffer.shift();
5470
5572
  state.bytes -= removed.bytes;
5471
5573
  dropped += 1;
5472
5574
  state.windowStart = Math.max(state.windowStart, Number(removed.cursor.seq));
5473
5575
  state.evicted = true;
5474
5576
  }
5577
+ if (dropped === 0) return;
5475
5578
  console.warn(`[cherry-remote] session ${sessionId} cache exceeded ${this.#perSessionCacheBytes} bytes; dropped ${dropped} oldest event(s)`);
5476
5579
  }
5477
5580
  /** Coalesced wake: each parked waiter settles exactly once. */
@@ -12924,11 +13027,13 @@ function assemblyDeps() {
12924
13027
  }),
12925
13028
  diagnosticsSessions: () => [...state.subscriptions.entries()].flatMap(([deviceId, layer]) => layer.activeSessions().map((sessionId) => {
12926
13029
  const cursor = layer.cursorOf(sessionId);
13030
+ const delivery = layer.deliveryOf(sessionId);
12927
13031
  return {
12928
13032
  deviceId,
12929
13033
  sessionId,
12930
13034
  subscriptions: 1,
12931
- ...cursor === void 0 ? {} : { cursor }
13035
+ ...cursor === void 0 ? {} : { cursor },
13036
+ ...delivery === void 0 ? {} : delivery
12932
13037
  };
12933
13038
  })),
12934
13039
  diagnosticsErrors: () => [...state.recentErrors],
@@ -360,3 +360,26 @@ A–G 各节要真人 + Electron 端批准,无法被 CI 化;下表只记录
360
360
  - **设置页那对按钮的点击**:需要能打开宿主的浏览器;本机渲染由 `tests/unit/ui.test.ts`
361
361
  覆盖,地址编辑器打开时两个控件冻结由 `#204m` 覆盖。本轮走的是页面调用的同两条 HTTP 路由。
362
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 的改动面上。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",
package/src/AGENTS.md CHANGED
@@ -830,10 +830,11 @@ the error message).
830
830
  - **May be emitted** (`sanitizeDevice`): `deviceId`, `displayName`, `state`,
831
831
  `enabled`, `hosts`, `port`, a **12-char digest** of `desktopIdentity`, the
832
832
  hello-negotiated `limits` (`heartbeatMs`/`subscriptions`/`pageItems`/
833
- `inFlightRequests` — **only with a live connection**; the endpoint never
834
- dials and never wakes a device to fetch fallbacks), `messageSummaries` (the
835
- hello capability bit, absent when not advertised), `sessionId` and session
836
- cursors (unsubscribed sessions have none), the `stats` counters
833
+ `inFlightRequests` — **only with a live connection**; it never dials or wakes
834
+ a device for fallbacks), `messageSummaries` (the
835
+ hello capability bit, absent when not advertised), `sessionId`, session
836
+ cursors (none when unsubscribed) and their ledger (`deliveredBytes`/
837
+ `unackedBytes`/`acks`, #217), the `stats` counters
837
838
  (`connections`/`sockets`/`connectionsBuilt`/`subscriptions`), the two resolved
838
839
  `config` keys, and error `reason`/`message`.
839
840
  - **Never emitted**: `accessToken`, `tokenExpiresAt`, `invitationSecret`, the
package/src/connection.ts CHANGED
@@ -896,6 +896,29 @@ export class DeviceConnection {
896
896
  #pendingPings = new Set<{ nonce: string; sentAt: number }>()
897
897
  #token: string | undefined
898
898
  #tokenExpiresAt: number | undefined
899
+ /**
900
+ * Hosts whose LAST attempt failed for that ADDRESS's own reason (issue #220).
901
+ * A pass dials the hosts it has not failed yet FIRST, so a stale candidate
902
+ * sitting first in `record.hosts` cannot cost its dial timeout on every
903
+ * reconnect — the rule the official client states as "failed candidates
904
+ * rotate behind untried addresses across retries, so the round budget cannot
905
+ * permanently starve a later route"
906
+ * (`.scratch-csa/docs/references/remote-access/connectivity.md` §Connection
907
+ * lifetime; the tree is gitignored, and its remote-agent directory is the
908
+ * same `16e505fe` at app tags v0.5.2 and v0.5.3, so this is not a rule that
909
+ * arrived with a newer app).
910
+ *
911
+ * Demotion is ORDER, never exclusion: with every host marked the list falls
912
+ * back to record order, so a record whose only address is marked still dials
913
+ * it. A mark is cleared when that host itself connects, so a repaired address
914
+ * returns to the front on its own — no expiry timer, no persistence, no
915
+ * config key. A pairing-level verdict and a cancellation never mark (they are
916
+ * not this address's fault: the line `isPerHostFailure` draws for the
917
+ * handshake face, mirrored explicitly in the dial face, where only
918
+ * `IdentityMismatchError` can arise today), and `close()` keeps the marks
919
+ * because they describe addresses, not this lifecycle.
920
+ */
921
+ readonly #demotedHosts = new Set<string>()
899
922
 
900
923
  readonly #wsOptions: WsDialOptions | undefined
901
924
 
@@ -1255,7 +1278,7 @@ export class DeviceConnection {
1255
1278
  }
1256
1279
 
1257
1280
  let lastError: Error = new Error(`device ${this.record.deviceId}: all candidate hosts unreachable`)
1258
- for (const host of this.record.hosts) {
1281
+ for (const host of this.#dialOrder()) {
1259
1282
  // CANCELLATION GATE, at the top of every candidate. `close()` bumps the
1260
1283
  // epoch and clears `#running`, so either check is enough — both are here
1261
1284
  // because they answer different halves of the same question ("is this
@@ -1279,7 +1302,15 @@ export class DeviceConnection {
1279
1302
  link = await this.#dial(host, port, epoch)
1280
1303
  } catch (error) {
1281
1304
  if (error instanceof IdentityMismatchError) throw error
1305
+ // A cancellation is not this ADDRESS's fault, so it neither marks it
1306
+ // nor continues the pass. UNREACHABLE today — `#dial`'s AbortController
1307
+ // is aborted only through `#teardownLink`, which needs `#link`, and
1308
+ // `#link` is not committed until the dial returns — so this is a
1309
+ // DEFENSIVE arm, not a pinned one: it keeps the rule true if the dial
1310
+ // ever grows a close path, at the cost of two lines and no behaviour.
1311
+ if (error instanceof CancelledError) throw error
1282
1312
  lastError = error instanceof Error ? error : new Error(String(error))
1313
+ this.#demotedHosts.add(host)
1283
1314
  continue
1284
1315
  }
1285
1316
  // Commit the link before the RPC handshake so every failure path below
@@ -1288,6 +1319,8 @@ export class DeviceConnection {
1288
1319
  this.#link = link
1289
1320
  try {
1290
1321
  await this.#handshake(link, epoch)
1322
+ // This address works: any mark an earlier pass left on it is stale.
1323
+ this.#demotedHosts.delete(host)
1291
1324
  return
1292
1325
  } catch (error) {
1293
1326
  // A close() that landed mid-handshake surfaces here as the failure of
@@ -1299,10 +1332,11 @@ export class DeviceConnection {
1299
1332
  // The terminal faces end the pass: they describe the PAIRING, not this
1300
1333
  // address, so trying the next candidate would only repeat them.
1301
1334
  if (!isPerHostFailure(error)) throw error
1302
- // A channel-level failure (socket dropped mid-handshake) is this
1303
- // ADDRESS's problem: tear the link down here, since the loop's next
1304
- // iteration would otherwise overwrite `#link` and leak this socket, and
1305
- // fall through to the next candidate.
1335
+ // A peer that answers hello/authenticate with a per-address failure is
1336
+ // this ADDRESS's problem too: demote it for the NEXT pass, tear the
1337
+ // link down here (the loop's next iteration would otherwise overwrite
1338
+ // `#link` and leak this socket), and fall through to the next candidate.
1339
+ this.#demotedHosts.add(host)
1306
1340
  this.#teardownLink()
1307
1341
  lastError = error instanceof Error ? error : new Error(String(error))
1308
1342
  continue
@@ -1311,6 +1345,27 @@ export class DeviceConnection {
1311
1345
  throw new AllHostsUnreachableError(this.record.deviceId, this.record.hosts, lastError)
1312
1346
  }
1313
1347
 
1348
+ /**
1349
+ * The pass's dial order (issue #220): hosts this connection has NOT failed
1350
+ * yet, in record order, then the demoted ones, in record order.
1351
+ *
1352
+ * Record order is preserved INSIDE each half, so an ordinary single-address
1353
+ * record, a fresh record and an all-demoted record all dial exactly the list
1354
+ * the record carries — the demotion can only move an address that failed
1355
+ * behind addresses that did not, and can never drop one.
1356
+ */
1357
+ #dialOrder(): readonly string[] {
1358
+ const hosts = this.record.hosts
1359
+ if (this.#demotedHosts.size === 0) return hosts
1360
+ const untried: string[] = []
1361
+ const demoted: string[] = []
1362
+ for (const host of hosts) {
1363
+ if (this.#demotedHosts.has(host)) demoted.push(host)
1364
+ else untried.push(host)
1365
+ }
1366
+ return [...untried, ...demoted]
1367
+ }
1368
+
1314
1369
  async #dial(host: string, port: number, epoch: number): Promise<LiveLink> {
1315
1370
  // (see {@link DeviceConnectionOptions.wsOptions} for TLS trust)
1316
1371
  // The URL comes from the SAME helper the pairing dial uses (issue #126):
@@ -216,6 +216,16 @@ export interface DiagnosticsSession {
216
216
  deviceId: string
217
217
  subscriptions: number
218
218
  cursor?: { sessionId: string; streamEpoch: string; seq: string }
219
+ /**
220
+ * Issue #217: the layer's own delivery ledger for THIS subscription — payload
221
+ * bytes received since it was installed, how many of them the peer may still
222
+ * count as un-acknowledged, and successful acks. Absent when the layer does
223
+ * not report it (an unsubscribed session has no ledger); counts describe the
224
+ * current subscription, so a re-subscribe restarts them.
225
+ */
226
+ deliveredBytes?: number
227
+ unackedBytes?: number
228
+ acks?: number
219
229
  }
220
230
 
221
231
  /** One recent error summary (spec §5: 「最近错误摘要」). */
@@ -324,6 +334,10 @@ export interface SubscriptionStat {
324
334
  sessionId: string
325
335
  subscriptions: number
326
336
  cursor?: { sessionId: string; streamEpoch: string; seq: string }
337
+ /** See {@link DiagnosticsSession.deliveredBytes} (issue #217). */
338
+ deliveredBytes?: number
339
+ unackedBytes?: number
340
+ acks?: number
327
341
  }
328
342
 
329
343
  /**
@@ -577,6 +591,17 @@ function numberOr(value: unknown, fallback: number): number {
577
591
  return typeof value === 'number' && Number.isFinite(value) ? value : fallback
578
592
  }
579
593
 
594
+ /**
595
+ * A delivery-ledger counter (issue #217): a NON-NEGATIVE finite number, or
596
+ * nothing at all. Absence means "not reported"; a fabricated zero would read as
597
+ * "nothing was delivered", which is a claim this projection has no evidence for.
598
+ */
599
+ function countEntry(key: 'deliveredBytes' | 'unackedBytes' | 'acks', raw: unknown): Partial<DiagnosticsSession> {
600
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0
601
+ ? ({ [key]: raw } as Partial<DiagnosticsSession>)
602
+ : {}
603
+ }
604
+
580
605
  /** The loose-bag view every projection in this module takes of its input. */
581
606
  function bag(raw: unknown): Record<string, unknown> {
582
607
  return (raw ?? {}) as Record<string, unknown>
@@ -728,6 +753,9 @@ export function sanitizeDiagnostics(source: {
728
753
  seq: typeof cursor['seq'] === 'string' ? cursor['seq'] : '',
729
754
  },
730
755
  }),
756
+ ...countEntry('deliveredBytes', value['deliveredBytes']),
757
+ ...countEntry('unackedBytes', value['unackedBytes']),
758
+ ...countEntry('acks', value['acks']),
731
759
  }
732
760
  }),
733
761
  recentErrors: (source.recentErrors ?? []).map((raw): DiagnosticsError => {
@@ -808,6 +836,9 @@ export function buildDiagnostics(
808
836
  deviceId: entry.deviceId,
809
837
  subscriptions: entry.subscriptions,
810
838
  ...(entry.cursor === undefined ? {} : { cursor: { ...entry.cursor } }),
839
+ ...(entry.deliveredBytes === undefined ? {} : { deliveredBytes: entry.deliveredBytes }),
840
+ ...(entry.unackedBytes === undefined ? {} : { unackedBytes: entry.unackedBytes }),
841
+ ...(entry.acks === undefined ? {} : { acks: entry.acks }),
811
842
  })
812
843
  }
813
844
  } catch {
package/src/index.ts CHANGED
@@ -878,7 +878,16 @@ function assemblyDeps(): CherryHttpDeps & CherryToolsDeps {
878
878
  [...state.subscriptions.entries()].flatMap(([deviceId, layer]) =>
879
879
  layer.activeSessions().map((sessionId) => {
880
880
  const cursor = layer.cursorOf(sessionId)
881
- return { deviceId, sessionId, subscriptions: 1, ...(cursor === undefined ? {} : { cursor }) }
881
+ // The layer's own delivery ledger (issue #217), beside the cursor it
882
+ // is measured against: both come from ONE session state read.
883
+ const delivery = layer.deliveryOf(sessionId)
884
+ return {
885
+ deviceId,
886
+ sessionId,
887
+ subscriptions: 1,
888
+ ...(cursor === undefined ? {} : { cursor }),
889
+ ...(delivery === undefined ? {} : delivery),
890
+ }
882
891
  }),
883
892
  ),
884
893
  diagnosticsErrors: () => [...state.recentErrors],
@@ -151,6 +151,20 @@ interface SessionState {
151
151
  * consider un-acknowledged.
152
152
  */
153
153
  bytesSinceAck: number
154
+ /**
155
+ * Event payload bytes received since this subscription was INSTALLED
156
+ * (issue #217). Monotone for the life of one subscription — unlike `bytes`
157
+ * (what the cache still holds) and `bytesSinceAck` (what the peer may still
158
+ * count) — so 「the stream exceeded the window」 becomes a measurement in
159
+ * diagnostics instead of an extrapolation from a frozen cursor.
160
+ */
161
+ deliveredBytes: number
162
+ /**
163
+ * Successful `agent.subscriptions.ack` calls for this subscription (issue
164
+ * #217). NOT a progress metric: every accepted call counts, including a
165
+ * repeated explicit ack at the cursor the peer already holds.
166
+ */
167
+ acks: number
154
168
  /** One auto-ack in flight at a time; a second would only duplicate a cursor. */
155
169
  ackInFlight: boolean
156
170
  /** Cooldown after a refused auto-ack (`AUTO_ACK_RETRY_MS`, doubling). */
@@ -320,6 +334,31 @@ export class SessionSubscriptions {
320
334
  return this.#sessions.get(sessionId)?.cursor
321
335
  }
322
336
 
337
+ /**
338
+ * The DELIVERY LEDGER of one subscription (issue #217): the payload bytes it
339
+ * has received since it was installed, how much of that the peer may still
340
+ * count as un-acknowledged, and how many acks it has taken. Undefined for a
341
+ * session this layer does not hold — never a fabricated zero.
342
+ *
343
+ * Diagnostics is the caller: these three numbers are what turns 「the stream
344
+ * exceeded the peer's window」 from an extrapolation into a measurement.
345
+ *
346
+ * A CLIENT-SIDE ledger, never the peer's: `unackedBytes` is this layer's
347
+ * model of what the peer may still count (bytes since its last accepted
348
+ * ack), not a reading of the peer's own window or ack position.
349
+ */
350
+ deliveryOf(
351
+ sessionId: string,
352
+ ): { deliveredBytes: number; unackedBytes: number; acks: number } | undefined {
353
+ const state = this.#sessions.get(sessionId)
354
+ if (state === undefined) return undefined
355
+ return {
356
+ deliveredBytes: state.deliveredBytes,
357
+ unackedBytes: state.bytesSinceAck,
358
+ acks: state.acks,
359
+ }
360
+ }
361
+
323
362
  /**
324
363
  * The session's cache WINDOW START — the retention BOUNDARY of this layer's
325
364
  * cache: the oldest position it still tracks — or undefined when the session
@@ -507,10 +546,21 @@ export class SessionSubscriptions {
507
546
  // frontier is both safe (an ack only frees flow control) and monotone.
508
547
  const requested = cursor === undefined ? state.cursor : cursor
509
548
  const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor
549
+ // What THIS ack covers: everything received up to the moment it is sent.
550
+ // Events arriving while it is in flight sit past the applied cursor, so
551
+ // they stay counted.
552
+ const covered = state.bytesSinceAck
510
553
  await this.#rpc.request('agent.subscriptions.ack', {
511
554
  subscriptionId: state.subscriptionId,
512
555
  cursor: applied,
513
556
  })
557
+ // COUNTED ONLY AFTER the peer accepted it: a refused ack frees no window,
558
+ // so a ledger that moved optimistically would claim progress the peer never
559
+ // granted. The SAME rule governs the byte side, which is why BOTH live here
560
+ // rather than in the auto-ack caller: an explicit ack frees the window just
561
+ // as well, and the ledger must not disagree with it.
562
+ state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered)
563
+ state.acks += 1
514
564
  }
515
565
 
516
566
  /**
@@ -679,6 +729,10 @@ export class SessionSubscriptions {
679
729
  buffer: [],
680
730
  bytes: 0,
681
731
  bytesSinceAck: 0,
732
+ // A fresh subscription owns a fresh ledger (issue #217): the counters
733
+ // describe THIS install, never a previous one.
734
+ deliveredBytes: 0,
735
+ acks: 0,
682
736
  ackInFlight: false,
683
737
  ackRetryAt: 0,
684
738
  ackRetryDelay: AUTO_ACK_RETRY_MS,
@@ -819,6 +873,10 @@ export class SessionSubscriptions {
819
873
  buffer: [],
820
874
  bytes: 0,
821
875
  bytesSinceAck: 0,
876
+ // A fresh subscription owns a fresh ledger (issue #217): the counters
877
+ // describe THIS install, never a previous one.
878
+ deliveredBytes: 0,
879
+ acks: 0,
822
880
  ackInFlight: false,
823
881
  ackRetryAt: 0,
824
882
  ackRetryDelay: AUTO_ACK_RETRY_MS,
@@ -915,7 +973,9 @@ export class SessionSubscriptions {
915
973
 
916
974
  /**
917
975
  * `agent.events`: append to the session buffer, enforce the byte budget by
918
- * dropping the OLDEST events (logged), advance the cursor, then wake waiters.
976
+ * dropping the OLDEST events (logged, and never the last remaining one — see
977
+ * `#trimToBudget`'s floor, issue #221), advance the cursor, then wake
978
+ * waiters.
919
979
  *
920
980
  * SEQ MONOTONICITY IS ENFORCED HERE, per (sessionId, streamEpoch). The peer is
921
981
  * supposed to deliver each stream position exactly once and in order, and the
@@ -1002,6 +1062,7 @@ export class SessionSubscriptions {
1002
1062
  state.buffer.push({ cursor, event, bytes })
1003
1063
  state.bytes += bytes
1004
1064
  state.bytesSinceAck += bytes
1065
+ state.deliveredBytes += bytes
1005
1066
  state.cursor = cursor
1006
1067
  appended += 1
1007
1068
  }
@@ -1039,12 +1100,10 @@ export class SessionSubscriptions {
1039
1100
  if (state.ackInFlight) return
1040
1101
  if (Date.now() < state.ackRetryAt) return
1041
1102
  state.ackInFlight = true
1042
- // The bytes covered by THIS ack. Events that arrive while it is in flight
1043
- // stay counted, so the next window starts from the right place.
1044
- const covered = state.bytesSinceAck
1045
1103
  void this.ack(sessionId)
1046
1104
  .then(() => {
1047
- state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered)
1105
+ // The BYTE ledger moved inside `ack()` itself — one place for remarks
1106
+ // and explicit acks alike — so only the retry state is the auto-ack's.
1048
1107
  // A peer that answers ack again gets prompt retries next time.
1049
1108
  state.ackRetryDelay = AUTO_ACK_RETRY_MS
1050
1109
  })
@@ -1139,11 +1198,24 @@ export class SessionSubscriptions {
1139
1198
  }
1140
1199
  }
1141
1200
 
1142
- /** Drops the OLDEST buffered events until the session fits its byte budget. */
1201
+ /**
1202
+ * Drops the OLDEST buffered events until the session fits its byte budget.
1203
+ *
1204
+ * The loop stops at ONE remaining event (issue #221): the budget governs the
1205
+ * window's TAIL, never its head. A single event above the budget — only
1206
+ * reachable when `perSessionCacheBytes` is set below one record's size, since
1207
+ * the transport caps a record at 65,536 bytes — used to be evicted too,
1208
+ * leaving an EMPTY window whose `windowStart` had advanced past it: the event
1209
+ * that just happened, the one thing a reader wants, became unreachable and
1210
+ * every read from the baseline answered `cursor-out-of-window` about content
1211
+ * this layer had held a moment earlier. Keeping it is also the honest
1212
+ * reading of `evicted`: nothing was discarded, so the baseline stays a
1213
+ * servable resume point.
1214
+ */
1143
1215
  #trimToBudget(sessionId: string, state: SessionState): void {
1144
1216
  if (state.bytes <= this.#perSessionCacheBytes) return
1145
1217
  let dropped = 0
1146
- while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 0) {
1218
+ while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 1) {
1147
1219
  const removed = state.buffer.shift()!
1148
1220
  state.bytes -= removed.bytes
1149
1221
  dropped += 1
@@ -1154,6 +1226,10 @@ export class SessionSubscriptions {
1154
1226
  state.windowStart = Math.max(state.windowStart, Number(removed.cursor.seq))
1155
1227
  state.evicted = true
1156
1228
  }
1229
+ if (dropped === 0) return
1230
+ // Only a REAL drop is logged: an over-budget window holding one event has
1231
+ // discarded nothing, and "dropped 0 oldest event(s)" would send an operator
1232
+ // looking for a gap that does not exist.
1157
1233
  console.warn(
1158
1234
  `[cherry-remote] session ${sessionId} cache exceeded ${this.#perSessionCacheBytes} bytes; ` +
1159
1235
  `dropped ${dropped} oldest event(s)`,