dsh-cherry-bridge 0.2.1 → 0.2.2

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.1
43
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.2
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.1
49
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.2
50
50
  ```
51
51
 
52
52
  The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
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.1
41
+ npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.2.2
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.1
47
+ npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.2.2
48
48
  ```
49
49
 
50
50
  本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
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 foreign key that a
2418
- * caller's `limits` object might carry. A whitelist inside the whitelist: the
2419
- * shape is rebuilt field by field rather than spread, so a future addition to
2420
- * `EffectiveLimits` cannot arrive here unvetted.
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 {};
@@ -3704,11 +3705,13 @@ var DeviceConnection = class {
3704
3705
  if (rpcReason$1(error) === "INVALID_CONNECTION_STATE") throw new ProtocolVersionError(String(error));
3705
3706
  throw error instanceof Error ? error : new Error(String(error));
3706
3707
  }
3708
+ const unackedBytes = positive$2(hello.limits?.["unackedBytes"]);
3707
3709
  this.#limits = {
3708
3710
  heartbeatMs: positive$2(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
3709
3711
  subscriptions: positive$2(hello.limits?.["subscriptions"]) ?? FALLBACK_LIMITS.subscriptions,
3710
3712
  pageItems: positive$2(hello.limits?.["pageItems"]) ?? FALLBACK_LIMITS.pageItems,
3711
3713
  inFlightRequests: positive$2(hello.limits?.["inFlightRequests"]) ?? FALLBACK_LIMITS.inFlightRequests,
3714
+ ...unackedBytes === void 0 ? {} : { unackedBytes },
3712
3715
  ...typeof hello.agentMessageSummariesVersion === "number" ? { messageSummaries: hello.agentMessageSummariesVersion } : {}
3713
3716
  };
3714
3717
  link.client.updateMaxInFlight(businessWindowFor(this.#limits.inFlightRequests));
@@ -4716,6 +4719,26 @@ const DEFAULT_CHECKPOINT_MS = 3e5;
4716
4719
  * not a tuning knob.
4717
4720
  */
4718
4721
  const MAX_RECOVERY_ATTEMPTS = 4;
4722
+ /**
4723
+ * ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
4724
+ * bytes it has sent since our last `agent.subscriptions.ack` stay inside
4725
+ * `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
4726
+ * the subscription still live — and a held batch can be a run's terminal.
4727
+ *
4728
+ * This layer used to ack ONLY from `cherry_events`, so a waiter (which by
4729
+ * design consumes without acking) parked on a buffer that stopped advancing
4730
+ * just under one window: a real 240 s wait answered `timeout` with the finished
4731
+ * text, its cursor frozen at 666 events ≈ 262144 bytes.
4732
+ *
4733
+ * The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
4734
+ * memory; acking does not lose them) once a quarter of the peer's window has
4735
+ * accumulated. A retry cooldown keeps a refusing peer from turning every batch
4736
+ * into a round trip.
4737
+ */
4738
+ /** First retry delay after a refused auto-ack; doubles per attempt. */
4739
+ const AUTO_ACK_RETRY_MS = 3e3;
4740
+ /** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
4741
+ const AUTO_ACK_RETRY_MAX_MS = 3e4;
4719
4742
  function positive$1(value, fallback) {
4720
4743
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
4721
4744
  }
@@ -4753,6 +4776,8 @@ var SessionSubscriptions = class {
4753
4776
  #limit;
4754
4777
  #perSessionCacheBytes;
4755
4778
  #checkpointMs;
4779
+ /** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
4780
+ #unackedSource;
4756
4781
  #sessions = /* @__PURE__ */ new Map();
4757
4782
  /**
4758
4783
  * Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
@@ -4774,6 +4799,7 @@ var SessionSubscriptions = class {
4774
4799
  this.#limit = Math.max(1, Math.floor(positive$1(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)));
4775
4800
  this.#perSessionCacheBytes = positive$1(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES);
4776
4801
  this.#checkpointMs = positive$1(opts.checkpointMs, DEFAULT_CHECKPOINT_MS);
4802
+ this.#unackedSource = opts.unackedBytes;
4777
4803
  }
4778
4804
  /**
4779
4805
  * Session ids with a live subscription, LRU-first. Task 10's reconnect
@@ -4873,6 +4899,7 @@ var SessionSubscriptions = class {
4873
4899
  if (existing !== void 0) {
4874
4900
  console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`);
4875
4901
  await this.#closeSubscription(existing.subscriptionId);
4902
+ this.#cancelAckRetry(existing);
4876
4903
  this.#rejectWaiters(existing, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
4877
4904
  this.#sessions.delete(sessionId);
4878
4905
  }
@@ -4948,13 +4975,18 @@ var SessionSubscriptions = class {
4948
4975
  /**
4949
4976
  * Advances the peer's view of this session: `agent.subscriptions.ack` with
4950
4977
  * the delivered cursor. The peer only accepts a cursor inside the window it
4951
- * actually delivered (an out-of-window ack is its CONFLICT), so this is
4952
- * called with the cursor a consumer has durably processed.
4978
+ * actually delivered (an out-of-window ack is its CONFLICT).
4979
+ *
4980
+ * Two callers, one contract — "this position is accounted for, keep sending":
4981
+ * `cherry_events` passes the cursor a CONSUMER has processed, and
4982
+ * {@link #maybeAutoAck} passes the BUFFER frontier because the events are
4983
+ * retained here (see that method for why an ack cannot lose replay).
4953
4984
  */
4954
4985
  async ack(sessionId, cursor) {
4955
4986
  const state = this.#sessions.get(sessionId);
4956
4987
  if (state === void 0) return;
4957
- const applied = cursor ?? state.cursor;
4988
+ const requested = cursor === void 0 ? state.cursor : cursor;
4989
+ const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor;
4958
4990
  await this.#rpc.request("agent.subscriptions.ack", {
4959
4991
  subscriptionId: state.subscriptionId,
4960
4992
  cursor: applied
@@ -5006,6 +5038,7 @@ var SessionSubscriptions = class {
5006
5038
  this.#closed = true;
5007
5039
  const ids = [...this.#sessions.values()].map((state) => state.subscriptionId);
5008
5040
  for (const state of this.#sessions.values()) {
5041
+ this.#cancelAckRetry(state);
5009
5042
  const error = /* @__PURE__ */ new Error("SessionSubscriptions disposed");
5010
5043
  for (const waiter of state.waiters.splice(0)) waiter.reject(error);
5011
5044
  }
@@ -5088,13 +5121,21 @@ var SessionSubscriptions = class {
5088
5121
  */
5089
5122
  #installReplay(sessionId, descriptor) {
5090
5123
  const previous = this.#sessions.get(sessionId);
5091
- if (previous !== void 0) this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
5124
+ if (previous !== void 0) {
5125
+ this.#cancelAckRetry(previous);
5126
+ this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} re-subscribed`));
5127
+ }
5092
5128
  this.#sessions.delete(sessionId);
5093
5129
  this.#sessions.set(sessionId, {
5094
5130
  subscriptionId: descriptor.subscriptionId,
5095
5131
  cursor: descriptor.highWatermark,
5096
5132
  buffer: [],
5097
5133
  bytes: 0,
5134
+ bytesSinceAck: 0,
5135
+ ackInFlight: false,
5136
+ ackRetryAt: 0,
5137
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
5138
+ ackRetryTimer: void 0,
5098
5139
  windowStart: Number(descriptor.highWatermark.seq),
5099
5140
  evicted: false,
5100
5141
  waiters: [],
@@ -5174,13 +5215,21 @@ var SessionSubscriptions = class {
5174
5215
  */
5175
5216
  #installCheckpoint(sessionId, descriptor, items) {
5176
5217
  const previous = this.#sessions.get(sessionId);
5177
- if (previous !== void 0) this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
5218
+ if (previous !== void 0) {
5219
+ this.#cancelAckRetry(previous);
5220
+ this.#rejectWaiters(previous, /* @__PURE__ */ new Error(`session ${sessionId} recovered`));
5221
+ }
5178
5222
  this.#sessions.delete(sessionId);
5179
5223
  this.#sessions.set(sessionId, {
5180
5224
  subscriptionId: descriptor.subscriptionId,
5181
5225
  cursor: descriptor.checkpoint.cursor,
5182
5226
  buffer: [],
5183
5227
  bytes: 0,
5228
+ bytesSinceAck: 0,
5229
+ ackInFlight: false,
5230
+ ackRetryAt: 0,
5231
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
5232
+ ackRetryTimer: void 0,
5184
5233
  windowStart: Number(descriptor.checkpoint.cursor.seq),
5185
5234
  evicted: false,
5186
5235
  waiters: [],
@@ -5226,6 +5275,7 @@ var SessionSubscriptions = class {
5226
5275
  for (const [sessionId, state] of this.#sessions) {
5227
5276
  if (sessionId === protectedSessionId) continue;
5228
5277
  console.warn(`[cherry-remote] subscription limit (${this.#limit}) reached; evicting least-recently-used session ${sessionId}`);
5278
+ this.#cancelAckRetry(state);
5229
5279
  this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} evicted by LRU`));
5230
5280
  this.#sessions.delete(sessionId);
5231
5281
  await this.#closeSubscription(state.subscriptionId);
@@ -5307,11 +5357,86 @@ var SessionSubscriptions = class {
5307
5357
  bytes
5308
5358
  });
5309
5359
  state.bytes += bytes;
5360
+ state.bytesSinceAck += bytes;
5310
5361
  state.cursor = cursor;
5311
5362
  appended += 1;
5312
5363
  }
5313
5364
  this.#trimToBudget(sessionId, state);
5314
5365
  if (appended > 0) this.#wakeWaiters(state);
5366
+ if (appended > 0) this.#maybeAutoAck(sessionId, state);
5367
+ }
5368
+ /**
5369
+ * Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
5370
+ * window, so the peer keeps delivering (issue #212).
5371
+ *
5372
+ * WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
5373
+ * a caller has consumed". That is safe against the pinned peer because an ack
5374
+ * does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
5375
+ * un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
5376
+ * retention lives on the journal (`replayBytes`/`replayMs`) and is not
5377
+ * touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
5378
+ * below the acked position is still served — by the local buffer here and by
5379
+ * the journal there. The ack's own doc comment states the same contract.
5380
+ *
5381
+ * The ack is fire-and-forget with its failure SURFACED (a refused ack must be
5382
+ * visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
5383
+ * holding at its window no further event arrives, so a retry that waited for
5384
+ * traffic would never fire and the wait would time out exactly as in issue
5385
+ * #212.
5386
+ */
5387
+ #maybeAutoAck(sessionId, state) {
5388
+ if (state.bytesSinceAck < this.#ackWatermark()) return;
5389
+ if (state.ackInFlight) return;
5390
+ if (Date.now() < state.ackRetryAt) return;
5391
+ state.ackInFlight = true;
5392
+ const covered = state.bytesSinceAck;
5393
+ this.ack(sessionId).then(() => {
5394
+ state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered);
5395
+ state.ackRetryDelay = AUTO_ACK_RETRY_MS;
5396
+ }).catch((error) => {
5397
+ const delay = state.ackRetryDelay;
5398
+ state.ackRetryAt = Date.now() + delay;
5399
+ state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS);
5400
+ console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`);
5401
+ this.#armAckRetry(sessionId, state, delay);
5402
+ }).finally(() => {
5403
+ state.ackInFlight = false;
5404
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return;
5405
+ this.#maybeAutoAck(sessionId, state);
5406
+ });
5407
+ }
5408
+ /**
5409
+ * Quarter of the peer's window, read from the CURRENT source so a layer that
5410
+ * outlives a reconnect follows the new hello instead of its first answer.
5411
+ * Falls back to the pinned protocol constant when nothing is advertised.
5412
+ */
5413
+ #ackWatermark() {
5414
+ const source = this.#unackedSource;
5415
+ const advertised = typeof source === "function" ? source() : source;
5416
+ return Math.max(1, Math.floor(positive$1(advertised, remoteLimits.unackedBytes) / 4));
5417
+ }
5418
+ /** Disarms the retry, if any (idempotent; safe on a state being replaced). */
5419
+ #cancelAckRetry(state) {
5420
+ if (state.ackRetryTimer === void 0) return;
5421
+ clearTimeout(state.ackRetryTimer);
5422
+ state.ackRetryTimer = void 0;
5423
+ }
5424
+ /**
5425
+ * One timer-driven retry after a refused ack. At most one is armed per
5426
+ * session; it no-ops when the state it was armed for is no longer current
5427
+ * (a re-subscribe, a checkpoint recovery or `dispose`).
5428
+ */
5429
+ #armAckRetry(sessionId, state, delayMs) {
5430
+ if (this.#closed) return;
5431
+ if (state.ackRetryTimer !== void 0) return;
5432
+ const timer = setTimeout(() => {
5433
+ state.ackRetryTimer = void 0;
5434
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return;
5435
+ state.ackRetryAt = 0;
5436
+ this.#maybeAutoAck(sessionId, state);
5437
+ }, delayMs);
5438
+ timer.unref?.();
5439
+ state.ackRetryTimer = timer;
5315
5440
  }
5316
5441
  /**
5317
5442
  * `agent.subscriptions.resetRequired`: the peer tore the subscription down
@@ -5330,6 +5455,7 @@ var SessionSubscriptions = class {
5330
5455
  for (const [sessionId, state] of this.#sessions) {
5331
5456
  if (state.subscriptionId !== subscriptionId) continue;
5332
5457
  console.warn(`[cherry-remote] subscription for session ${sessionId} reset by the peer (${String(params["reason"] ?? "unspecified")}); re-subscribe required`);
5458
+ this.#cancelAckRetry(state);
5333
5459
  this.#rejectWaiters(state, /* @__PURE__ */ new Error(`session ${sessionId} reset by the peer`));
5334
5460
  this.#sessions.delete(sessionId);
5335
5461
  return;
@@ -9160,8 +9286,9 @@ var CherryToolEngine = class {
9160
9286
  * dimension and deliberately does NOT settle a wait (Task 13 asserts the two
9161
9287
  * layers separately).
9162
9288
  *
9163
- * Waiters never ack: they consume the projection and leave the peer's view
9164
- * untouched.
9289
+ * Waiters do not ack what they fold. The subscription LAYER acks its own
9290
+ * buffer on a byte watermark (issue #212) so the peer keeps delivering past
9291
+ * its un-acked window; that ack is about flow control, not consumption.
9165
9292
  *
9166
9293
  * A wait can also end because the LINK went away — the plugin was torn down,
9167
9294
  * the device was unpaired, or the connection closed. That is not a timeout and
@@ -12671,7 +12798,8 @@ function subsFor(deviceId) {
12671
12798
  const connection = connectionFor(deviceId);
12672
12799
  const created = new SessionSubscriptions(connection.rpc, {
12673
12800
  subscriptionsLimit: connection.limits.subscriptions,
12674
- perSessionCacheBytes: state.perSessionCacheBytes
12801
+ perSessionCacheBytes: state.perSessionCacheBytes,
12802
+ unackedBytes: () => connection.limits.unackedBytes
12675
12803
  });
12676
12804
  state.subscriptions.set(deviceId, created);
12677
12805
  console.info(`[cherry-remote] subscription layer established for ${deviceId} (peer limit ${connection.limits.subscriptions})`);
@@ -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,25 @@ 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 才会出现,本轮没有。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",
package/src/AGENTS.md CHANGED
@@ -36,6 +36,7 @@ in [`docs/e2e-checklist.md`](../docs/e2e-checklist.md).
36
36
  | `recordBytes` | 65,536 (64 KiB) | one JSON-RPC record above it **aborts the connection** — the transport answers by killing it, so every in-flight request fails |
37
37
  | `contentBytes` | 24,576 (24 KiB) | the slice size for one `agent.content.read` |
38
38
  | `pageItems` | 100 | the protocol's single-page hard cap; `hello` may negotiate smaller |
39
+ | `unackedBytes` | 262,144 (256 KiB) | the peer HOLDS `agent.events` beyond this much un-acked delivery — terminal included; the layer acks at ¼ of it (hello value, else this) (issue #212) |
39
40
 
40
41
  Through 2.1.7 the desktop's `pageOf` pages by **count alone** (2.1.8's byte
41
42
  budget aside, still count-paged): `pageItems: 100` fat parts produced a record
@@ -187,8 +188,7 @@ projection. An unchanged message costs no repeat `agent.parts.list`
187
188
  RPC on send → wait → read rounds, while every read still re-fetches and
188
189
  sha256-verifies every `{ref}`'s bytes (a corrupted ref behind an unchanged
189
190
  revision fails with `ContentIntegrityError`; `toolsReadCache.test.ts` T1b pins
190
- this beside the cache). A hit is still not a zero-RPC read: 2.1.6+ externalizes
191
- every part over 4,096 characters.
191
+ this beside the cache).
192
192
 
193
193
  - **Entry & key**: the `agent.parts.list` result — `{revision, page}` — keyed
194
194
  `<deviceId>\0<messageId>` (device-scoped: a message id is the peer's; two
@@ -654,8 +654,7 @@ one host; whole-replace `hosts` defaults new hosts to
654
654
  `ws`/port; a CHANGED `port` re-points EVERY final host, the current value a map
655
655
  no-op (the settings UI sends both on save). `GET /devices` reports both maps and
656
656
  `forgottenHosts` (addresses and intent, not credentials); tools/diagnostics show
657
- only `port`. The settings
658
- page shows the per-host ports only when they differ from the record-level one.
657
+ only `port`.
659
658
  TLS trust is operator config (`wsTls`: FLAT `tls.connect` `rejectUnauthorized`/
660
659
  `ca` only), forwarded to both dial sites (pool, pairing). `resolveBridgeConfig`
661
660
  validates it: unknown key or unusable value warns by FIELD NAME, WHOLE object
@@ -785,9 +784,8 @@ that is not valid URL encoding) · `last-address` 400 (the action would leave no
785
784
  to dial; nothing written) · `invalid-device` 400 (the store refused) · `not-found`
786
785
  404 (unknown device OR action — also what a QUARANTINED archive answers, its view
787
786
  being empty) · `method-not-allowed` 405 (before the body) · `archive-quarantined`
788
- 503 (`{file, corrupt, recovery}`; a damaged archive empties its view and 404s first,
789
- so this arm only covers a store that refuses after that read — unreachable for the
790
- shipped store, kept so the arm exists); anything else is the generic 500
787
+ 503 (`{file, corrupt, recovery}`; a damaged archive 404s first, so this arm covers
788
+ only a store that refuses after that read); anything else is the generic 500
791
789
  `internal-error`.
792
790
 
793
791
  > The minimal `pairing-progress` frame shape is `{stage, detail?}`; this plugin
@@ -844,9 +842,8 @@ the error message).
844
842
  construction**, not by a deny-list. `limits` is re-projected from its four
845
843
  keys: a whitelist inside the whitelist.
846
844
  - `stats.connectionsBuilt` counts connections built since the last `apply`, not
847
- RPC requests — those belong to the transport; a same-named metric would be
848
- misread. Counters and the error ring are zeroed on every `apply`: the
849
- statistics describe **only** the current assembly.
845
+ RPC requests (those belong to the transport). Counters and the error ring are
846
+ zeroed on every `apply`: they describe **only** the current assembly.
850
847
 
851
848
  ### Logging: levels, identifiers, and what never appears
852
849
 
@@ -944,6 +941,4 @@ faces:
944
941
  answer is the byte-safe page **clamp**: never ask for a page that cannot fit.
945
942
  - Its read path refuses an unusable `byteLength`; we fall back to a slice bound
946
943
  plus the cumulative arm.
947
- - Its read path never replays a read; we replay reads **once** (deliberate
948
- enhancement) while both sides keep commands receipt-recovered.
949
944
 
package/src/connection.ts CHANGED
@@ -38,6 +38,17 @@ export interface EffectiveLimits {
38
38
  subscriptions: number
39
39
  pageItems: number
40
40
  inFlightRequests: number
41
+ /**
42
+ * The peer's hello-advertised UN-ACKED DELIVERY WINDOW (protocol
43
+ * `remoteLimits.unackedBytes`). Past this much delivered-but-unacknowledged
44
+ * traffic the desktop holds its event stream (issue #212), so the
45
+ * subscription layer sizes its own ack watermark from it.
46
+ *
47
+ * ABSENT, never defaulted — same rule as `messageSummaries` below: a hello
48
+ * that does not carry the key leaves the layer on the pinned protocol
49
+ * constant rather than on a guess about this peer.
50
+ */
51
+ unackedBytes?: number
41
52
  /**
42
53
  * The peer's hello-advertised `agentMessageSummariesVersion` (issue #105),
43
54
  * stored BESIDE the limits because it is re-read by that same handshake.
@@ -1402,11 +1413,13 @@ export class DeviceConnection {
1402
1413
  // reconnect to a peer that no longer advertises the capability (or a
1403
1414
  // downgrade) cannot inherit a stale `messageSummaries` from the previous
1404
1415
  // link — the exact failure mode that would send `partsMode` to a 2.1.7 peer.
1416
+ const unackedBytes = positive(hello.limits?.['unackedBytes'])
1405
1417
  this.#limits = {
1406
1418
  heartbeatMs: positive(hello.heartbeatMs) ?? FALLBACK_LIMITS.heartbeatMs,
1407
1419
  subscriptions: positive(hello.limits?.['subscriptions']) ?? FALLBACK_LIMITS.subscriptions,
1408
1420
  pageItems: positive(hello.limits?.['pageItems']) ?? FALLBACK_LIMITS.pageItems,
1409
1421
  inFlightRequests: positive(hello.limits?.['inFlightRequests']) ?? FALLBACK_LIMITS.inFlightRequests,
1422
+ ...(unackedBytes === undefined ? {} : { unackedBytes }),
1410
1423
  ...(typeof hello.agentMessageSummariesVersion === 'number'
1411
1424
  ? { messageSummaries: hello.agentMessageSummariesVersion }
1412
1425
  : {}),
@@ -199,7 +199,7 @@ export interface DiagnosticsDevice {
199
199
  * `partsMode: 'paged'`.
200
200
  *
201
201
  * Its own field, not a fifth key inside `limits`: it is a VERSION, not a
202
- * count, and `limits` is documented as the four numeric caps. Present only
202
+ * count, and `limits` here is the four numeric caps this report CARRIES. Present only
203
203
  * when a LIVE connection reported it (the same rule as `limits`: the endpoint
204
204
  * never dials to find out). ABSENT therefore means "not advertised **or** not
205
205
  * connected" — every desktop through 2.1.7, and any device whose link is
@@ -555,10 +555,11 @@ function projectMessageSummaries(raw: unknown): { messageSummaries?: number } {
555
555
  }
556
556
 
557
557
  /**
558
- * Re-projects the four numeric limit keys, dropping any foreign key that a
559
- * caller's `limits` object might carry. A whitelist inside the whitelist: the
560
- * shape is rebuilt field by field rather than spread, so a future addition to
561
- * `EffectiveLimits` cannot arrive here unvetted.
558
+ * Re-projects the four numeric limit keys this report carries, dropping any
559
+ * foreign key that a caller's `limits` object might carry — including
560
+ * `EffectiveLimits`' newer `unackedBytes`, which no diagnostics consumer needs.
561
+ * A whitelist inside the whitelist: the shape is rebuilt field by field rather
562
+ * than spread, so a future addition cannot arrive here unvetted.
562
563
  */
563
564
  function projectLimits(raw: unknown): { limits?: EffectiveLimits } {
564
565
  if (raw === null || typeof raw !== 'object') return {}
package/src/index.ts CHANGED
@@ -664,6 +664,11 @@ function subsFor(deviceId: string): SessionSubscriptions {
664
664
  const created = new SessionSubscriptions(connection.rpc, {
665
665
  subscriptionsLimit: connection.limits.subscriptions,
666
666
  perSessionCacheBytes: state.perSessionCacheBytes,
667
+ // The peer's OWN un-acked window when hello advertised it (protocol 0.7.0
668
+ // peers do); absent falls back to the pinned constant inside the layer.
669
+ // A PROVIDER, not a value: the layer outlives a reconnect, and `limits` is
670
+ // rebuilt by every hello — a pinned number would freeze the first answer.
671
+ unackedBytes: () => connection.limits.unackedBytes,
667
672
  })
668
673
  state.subscriptions.set(deviceId, created)
669
674
  // INFO (spec §5 「订阅建立」): the layer is announced with the cap it was
@@ -2,6 +2,7 @@ import { JsonRpcRequestError } from './rpc/jsonRpcClient.js'
2
2
  import type { JsonRpcClient } from './rpc/jsonRpcClient.js'
3
3
  import { DEFAULT_PER_SESSION_CACHE_BYTES } from './defaults.js'
4
4
  import { failureText } from './text.js'
5
+ import { remoteLimits } from '@cherrystudio/remote-protocol'
5
6
  import type { AgentCursor, AgentEvent } from '@cherrystudio/remote-protocol/agent'
6
7
 
7
8
  /**
@@ -40,6 +41,27 @@ const DEFAULT_CHECKPOINT_MS = 300_000
40
41
  */
41
42
  const MAX_RECOVERY_ATTEMPTS = 4
42
43
 
44
+ /**
45
+ * ACK WATERMARK (issue #212). The peer delivers `agent.events` only while the
46
+ * bytes it has sent since our last `agent.subscriptions.ack` stay inside
47
+ * `remoteLimits.unackedBytes`; past that it HOLDS the stream — silently, with
48
+ * the subscription still live — and a held batch can be a run's terminal.
49
+ *
50
+ * This layer used to ack ONLY from `cherry_events`, so a waiter (which by
51
+ * design consumes without acking) parked on a buffer that stopped advancing
52
+ * just under one window: a real 240 s wait answered `timeout` with the finished
53
+ * text, its cursor frozen at 666 events ≈ 262144 bytes.
54
+ *
55
+ * The layer therefore acks what it has ALREADY BUFFERED (the bytes are in
56
+ * memory; acking does not lose them) once a quarter of the peer's window has
57
+ * accumulated. A retry cooldown keeps a refusing peer from turning every batch
58
+ * into a round trip.
59
+ */
60
+ /** First retry delay after a refused auto-ack; doubles per attempt. */
61
+ const AUTO_ACK_RETRY_MS = 3_000
62
+ /** Ceiling for the doubling retry, so a permanently refusing peer is quiet. */
63
+ const AUTO_ACK_RETRY_MAX_MS = 30_000
64
+
43
65
  export type ReadResult = { events: AgentEvent[]; nextCursor: AgentCursor } | { error: 'cursor-out-of-window' }
44
66
 
45
67
  export interface SessionSubscriptionsOptions {
@@ -60,6 +82,20 @@ export interface SessionSubscriptionsOptions {
60
82
  * default 300_000). Only bounds recovery retries; the peer is authoritative.
61
83
  */
62
84
  checkpointMs?: number
85
+ /**
86
+ * The peer's hello-advertised un-acked delivery window in bytes (protocol
87
+ * `remoteLimits.unackedBytes`, 262144 in the pinned package). Past that much
88
+ * delivered-but-unacknowledged traffic the desktop HOLDS its event stream
89
+ * (issue #212), so the layer acks its buffer at a quarter of this value.
90
+ *
91
+ * Omitted falls back to the pinned protocol constant. A peer that does not
92
+ * advertise the key is treated as the pinned generation — the same
93
+ * conservative rule the other negotiated limits follow.
94
+ *
95
+ * A FUNCTION is read at every watermark check, so a layer that outlives a
96
+ * reconnect picks up the new hello's value instead of pinning the first one.
97
+ */
98
+ unackedBytes?: number | (() => number | undefined)
63
99
  }
64
100
 
65
101
  /** Peer-side subscription phases (mirrors the real peer's internal state). */
@@ -108,6 +144,31 @@ interface SessionState {
108
144
  cursor: AgentCursor
109
145
  buffer: CachedEvent[]
110
146
  bytes: number
147
+ /**
148
+ * Bytes appended since the last ack we sent (issue #212). Drives
149
+ * {@link SessionSubscriptions.#maybeAutoAck}; kept separate from `bytes`,
150
+ * which measures what the CACHE holds rather than what the peer may still
151
+ * consider un-acknowledged.
152
+ */
153
+ bytesSinceAck: number
154
+ /** One auto-ack in flight at a time; a second would only duplicate a cursor. */
155
+ ackInFlight: boolean
156
+ /** Cooldown after a refused auto-ack (`AUTO_ACK_RETRY_MS`, doubling). */
157
+ ackRetryAt: number
158
+ /**
159
+ * Current retry delay: `AUTO_ACK_RETRY_MS` after the first refusal, doubled
160
+ * on every further one up to `AUTO_ACK_RETRY_MAX_MS`. Reset on a success, so
161
+ * a peer that recovers is back to prompt retries.
162
+ */
163
+ ackRetryDelay: number
164
+ /**
165
+ * The ARMED retry behind `ackRetryAt`. An ack failure is the one case where
166
+ * no further event will arrive to re-trigger the watermark check — the peer
167
+ * is holding at its window — so the retry cannot wait for traffic (issue
168
+ * #212's own failure mode, one ack later). Cleared by {@link #installReplay},
169
+ * {@link #installCheckpoint} and {@link SessionSubscriptions.dispose}.
170
+ */
171
+ ackRetryTimer?: ReturnType<typeof setTimeout>
111
172
  /**
112
173
  * The oldest stream position this session still TRACKS: the baseline the
113
174
  * subscription started from, advanced whenever old events are dropped. It is
@@ -189,6 +250,8 @@ export class SessionSubscriptions {
189
250
  readonly #limit: number
190
251
  readonly #perSessionCacheBytes: number
191
252
  readonly #checkpointMs: number
253
+ /** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
254
+ readonly #unackedSource: number | (() => number | undefined) | undefined
192
255
  readonly #sessions = new Map<string, SessionState>()
193
256
  /**
194
257
  * Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
@@ -211,6 +274,7 @@ export class SessionSubscriptions {
211
274
  this.#limit = Math.max(1, Math.floor(positive(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)))
212
275
  this.#perSessionCacheBytes = positive(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
213
276
  this.#checkpointMs = positive(opts.checkpointMs, DEFAULT_CHECKPOINT_MS)
277
+ this.#unackedSource = opts.unackedBytes
214
278
  }
215
279
 
216
280
  /**
@@ -316,6 +380,7 @@ export class SessionSubscriptions {
316
380
  // session that will not stay subscribed.
317
381
  console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`)
318
382
  await this.#closeSubscription(existing.subscriptionId)
383
+ this.#cancelAckRetry(existing)
319
384
  this.#rejectWaiters(existing, new Error(`session ${sessionId} re-subscribed`))
320
385
  this.#sessions.delete(sessionId)
321
386
  }
@@ -424,13 +489,24 @@ export class SessionSubscriptions {
424
489
  /**
425
490
  * Advances the peer's view of this session: `agent.subscriptions.ack` with
426
491
  * the delivered cursor. The peer only accepts a cursor inside the window it
427
- * actually delivered (an out-of-window ack is its CONFLICT), so this is
428
- * called with the cursor a consumer has durably processed.
492
+ * actually delivered (an out-of-window ack is its CONFLICT).
493
+ *
494
+ * Two callers, one contract — "this position is accounted for, keep sending":
495
+ * `cherry_events` passes the cursor a CONSUMER has processed, and
496
+ * {@link #maybeAutoAck} passes the BUFFER frontier because the events are
497
+ * retained here (see that method for why an ack cannot lose replay).
429
498
  */
430
499
  async ack(sessionId: string, cursor?: AgentCursor): Promise<void> {
431
500
  const state = this.#sessions.get(sessionId)
432
501
  if (state === undefined) return
433
- const applied = cursor ?? state.cursor
502
+ // COALESCED TO THE FRONTIER. The peer keeps ONE `lastAck` per subscription
503
+ // and refuses a lower position, so an explicit ack carrying what a consumer
504
+ // has processed would lose a race against the layer's own ack and be
505
+ // answered CONFLICT — which the caller treats as a broken subscription.
506
+ // The buffer already holds everything up to `state.cursor`, so acking the
507
+ // frontier is both safe (an ack only frees flow control) and monotone.
508
+ const requested = cursor === undefined ? state.cursor : cursor
509
+ const applied = Number(requested.seq) > Number(state.cursor.seq) ? requested : state.cursor
434
510
  await this.#rpc.request('agent.subscriptions.ack', {
435
511
  subscriptionId: state.subscriptionId,
436
512
  cursor: applied,
@@ -484,6 +560,7 @@ export class SessionSubscriptions {
484
560
  this.#closed = true
485
561
  const ids = [...this.#sessions.values()].map((state) => state.subscriptionId)
486
562
  for (const state of this.#sessions.values()) {
563
+ this.#cancelAckRetry(state)
487
564
  const error = new Error('SessionSubscriptions disposed')
488
565
  for (const waiter of state.waiters.splice(0)) waiter.reject(error)
489
566
  }
@@ -590,7 +667,10 @@ export class SessionSubscriptions {
590
667
  */
591
668
  #installReplay(sessionId: string, descriptor: Extract<SubscribeDescriptor, { mode: 'replay' }>): void {
592
669
  const previous = this.#sessions.get(sessionId)
593
- if (previous !== undefined) this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
670
+ if (previous !== undefined) {
671
+ this.#cancelAckRetry(previous)
672
+ this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
673
+ }
594
674
  this.#sessions.delete(sessionId)
595
675
  // Re-inserting moves the session to the MRU end of the Map's order.
596
676
  this.#sessions.set(sessionId, {
@@ -598,6 +678,11 @@ export class SessionSubscriptions {
598
678
  cursor: descriptor.highWatermark,
599
679
  buffer: [],
600
680
  bytes: 0,
681
+ bytesSinceAck: 0,
682
+ ackInFlight: false,
683
+ ackRetryAt: 0,
684
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
685
+ ackRetryTimer: undefined,
601
686
  windowStart: Number(descriptor.highWatermark.seq),
602
687
  // A fresh window: nothing has been dropped from it, so the baseline is a
603
688
  // position this layer may serve (issue #2).
@@ -723,13 +808,21 @@ export class SessionSubscriptions {
723
808
  items: Array<{ kind: string; value: unknown }>,
724
809
  ): void {
725
810
  const previous = this.#sessions.get(sessionId)
726
- if (previous !== undefined) this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
811
+ if (previous !== undefined) {
812
+ this.#cancelAckRetry(previous)
813
+ this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
814
+ }
727
815
  this.#sessions.delete(sessionId)
728
816
  this.#sessions.set(sessionId, {
729
817
  subscriptionId: descriptor.subscriptionId,
730
818
  cursor: descriptor.checkpoint.cursor,
731
819
  buffer: [],
732
820
  bytes: 0,
821
+ bytesSinceAck: 0,
822
+ ackInFlight: false,
823
+ ackRetryAt: 0,
824
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
825
+ ackRetryTimer: undefined,
733
826
  windowStart: Number(descriptor.checkpoint.cursor.seq),
734
827
  // A fresh window: nothing has been dropped from it, so the baseline is a
735
828
  // position this layer may serve (issue #2).
@@ -801,6 +894,7 @@ export class SessionSubscriptions {
801
894
  `${sessionId}`,
802
895
  )
803
896
  // The entry is removed outright, so no `live` flag needs clearing.
897
+ this.#cancelAckRetry(state)
804
898
  this.#rejectWaiters(state, new Error(`session ${sessionId} evicted by LRU`))
805
899
  this.#sessions.delete(sessionId)
806
900
  // AWAITED: the peer must observe the close before the next subscribe.
@@ -907,6 +1001,7 @@ export class SessionSubscriptions {
907
1001
  const bytes = eventBytes(event)
908
1002
  state.buffer.push({ cursor, event, bytes })
909
1003
  state.bytes += bytes
1004
+ state.bytesSinceAck += bytes
910
1005
  state.cursor = cursor
911
1006
  appended += 1
912
1007
  }
@@ -915,6 +1010,106 @@ export class SessionSubscriptions {
915
1010
  // Waking on a wholly stale batch would make a parked waiter re-judge state
916
1011
  // that has not moved; only real progress is a wake.
917
1012
  if (appended > 0) this.#wakeWaiters(state)
1013
+ // The wake comes FIRST: a waiter that can settle from what just arrived
1014
+ // must not wait behind an ack round trip.
1015
+ if (appended > 0) this.#maybeAutoAck(sessionId, state)
1016
+ }
1017
+
1018
+ /**
1019
+ * Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
1020
+ * window, so the peer keeps delivering (issue #212).
1021
+ *
1022
+ * WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
1023
+ * a caller has consumed". That is safe against the pinned peer because an ack
1024
+ * does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
1025
+ * un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
1026
+ * retention lives on the journal (`replayBytes`/`replayMs`) and is not
1027
+ * touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
1028
+ * below the acked position is still served — by the local buffer here and by
1029
+ * the journal there. The ack's own doc comment states the same contract.
1030
+ *
1031
+ * The ack is fire-and-forget with its failure SURFACED (a refused ack must be
1032
+ * visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
1033
+ * holding at its window no further event arrives, so a retry that waited for
1034
+ * traffic would never fire and the wait would time out exactly as in issue
1035
+ * #212.
1036
+ */
1037
+ #maybeAutoAck(sessionId: string, state: SessionState): void {
1038
+ if (state.bytesSinceAck < this.#ackWatermark()) return
1039
+ if (state.ackInFlight) return
1040
+ if (Date.now() < state.ackRetryAt) return
1041
+ 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
+ void this.ack(sessionId)
1046
+ .then(() => {
1047
+ state.bytesSinceAck = Math.max(0, state.bytesSinceAck - covered)
1048
+ // A peer that answers ack again gets prompt retries next time.
1049
+ state.ackRetryDelay = AUTO_ACK_RETRY_MS
1050
+ })
1051
+ .catch((error: unknown) => {
1052
+ // THIS attempt's delay, then the next one doubles: a peer that refuses
1053
+ // every ack (a wedged controller, a link about to drop) must not log a
1054
+ // line every few seconds for the life of the layer.
1055
+ const delay = state.ackRetryDelay
1056
+ state.ackRetryAt = Date.now() + delay
1057
+ state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS)
1058
+ console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`)
1059
+ // ARMED HERE, not on the next batch: a peer holding at its window sends
1060
+ // nothing more, so traffic-driven retry is no retry at all.
1061
+ this.#armAckRetry(sessionId, state, delay)
1062
+ })
1063
+ .finally(() => {
1064
+ state.ackInFlight = false
1065
+ // RE-JUDGE: batches can arrive WHILE the ack was in flight (the peer
1066
+ // flushes on the ack, so this is the common case), and those batches
1067
+ // were refused a trigger by `ackInFlight`. Without this second look the
1068
+ // layer could sit with bytes over the watermark and no event left to
1069
+ // trigger it — the peer holding, exactly as in issue #212.
1070
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return
1071
+ this.#maybeAutoAck(sessionId, state)
1072
+ })
1073
+ }
1074
+
1075
+ /**
1076
+ * Quarter of the peer's window, read from the CURRENT source so a layer that
1077
+ * outlives a reconnect follows the new hello instead of its first answer.
1078
+ * Falls back to the pinned protocol constant when nothing is advertised.
1079
+ */
1080
+ #ackWatermark(): number {
1081
+ const source = this.#unackedSource
1082
+ const advertised = typeof source === 'function' ? source() : source
1083
+ return Math.max(1, Math.floor(positive(advertised, remoteLimits.unackedBytes) / 4))
1084
+ }
1085
+
1086
+ /** Disarms the retry, if any (idempotent; safe on a state being replaced). */
1087
+ #cancelAckRetry(state: SessionState): void {
1088
+ if (state.ackRetryTimer === undefined) return
1089
+ clearTimeout(state.ackRetryTimer)
1090
+ state.ackRetryTimer = undefined
1091
+ }
1092
+
1093
+ /**
1094
+ * One timer-driven retry after a refused ack. At most one is armed per
1095
+ * session; it no-ops when the state it was armed for is no longer current
1096
+ * (a re-subscribe, a checkpoint recovery or `dispose`).
1097
+ */
1098
+ #armAckRetry(sessionId: string, state: SessionState, delayMs: number): void {
1099
+ if (this.#closed) return
1100
+ if (state.ackRetryTimer !== undefined) return
1101
+ const timer = setTimeout(() => {
1102
+ state.ackRetryTimer = undefined
1103
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return
1104
+ // CLEARED HERE, not left to the wall clock: a clock stepped backwards
1105
+ // would otherwise make `#maybeAutoAck` return early and arm nothing — a
1106
+ // retry silently lost while the peer holds, which is #212 again.
1107
+ state.ackRetryAt = 0
1108
+ this.#maybeAutoAck(sessionId, state)
1109
+ }, delayMs)
1110
+ // Never hold the process open on a retry that is only about flow control.
1111
+ timer.unref?.()
1112
+ state.ackRetryTimer = timer
918
1113
  }
919
1114
 
920
1115
  /**
@@ -937,6 +1132,7 @@ export class SessionSubscriptions {
937
1132
  `[cherry-remote] subscription for session ${sessionId} reset by the peer ` +
938
1133
  `(${String(params['reason'] ?? 'unspecified')}); re-subscribe required`,
939
1134
  )
1135
+ this.#cancelAckRetry(state)
940
1136
  this.#rejectWaiters(state, new Error(`session ${sessionId} reset by the peer`))
941
1137
  this.#sessions.delete(sessionId)
942
1138
  return
package/src/tools.ts CHANGED
@@ -4077,9 +4077,13 @@ export class CherryToolEngine {
4077
4077
  } satisfies CherryCursorOutOfWindow
4078
4078
  }
4079
4079
 
4080
- // ACK AFTER DELIVERY (controller ruling ④): the peer may only advance
4081
- // its view once the caller actually holds these events. A failing ack
4082
- // is logged, never allowed to swallow the delivered events.
4080
+ // ACK AFTER DELIVERY (controller ruling ④): this EXPLICIT ack reports
4081
+ // the position the caller has just been handed. The layer ALSO acks its
4082
+ // own buffer on a byte watermark (issue #212, subscriptions
4083
+ // `#maybeAutoAck`), because the peer holds the stream past its un-acked
4084
+ // window. The peer keeps ONE `lastAck` and refuses a lower position, so
4085
+ // both sites coalesce to the buffer frontier — see `ack()`.
4086
+ // A failing ack is logged, never allowed to swallow the delivered events.
4083
4087
  // A FAILING ACK ALSO RE-ENSURES THE SESSION (v0.1.3 UltraReview P3-3).
4084
4088
  // The ack is the peer's verdict on the SUBSCRIPTION, not on the
4085
4089
  // delivery: a refused ack says the cursor is outside the window this
@@ -6041,8 +6045,9 @@ export class CherryToolEngine {
6041
6045
  * dimension and deliberately does NOT settle a wait (Task 13 asserts the two
6042
6046
  * layers separately).
6043
6047
  *
6044
- * Waiters never ack: they consume the projection and leave the peer's view
6045
- * untouched.
6048
+ * Waiters do not ack what they fold. The subscription LAYER acks its own
6049
+ * buffer on a byte watermark (issue #212) so the peer keeps delivering past
6050
+ * its un-acked window; that ack is about flow control, not consumption.
6046
6051
  *
6047
6052
  * A wait can also end because the LINK went away — the plugin was torn down,
6048
6053
  * the device was unpaired, or the connection closed. That is not a timeout and