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.
@@ -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,45 @@ 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
+ /**
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
168
+ /** One auto-ack in flight at a time; a second would only duplicate a cursor. */
169
+ ackInFlight: boolean
170
+ /** Cooldown after a refused auto-ack (`AUTO_ACK_RETRY_MS`, doubling). */
171
+ ackRetryAt: number
172
+ /**
173
+ * Current retry delay: `AUTO_ACK_RETRY_MS` after the first refusal, doubled
174
+ * on every further one up to `AUTO_ACK_RETRY_MAX_MS`. Reset on a success, so
175
+ * a peer that recovers is back to prompt retries.
176
+ */
177
+ ackRetryDelay: number
178
+ /**
179
+ * The ARMED retry behind `ackRetryAt`. An ack failure is the one case where
180
+ * no further event will arrive to re-trigger the watermark check — the peer
181
+ * is holding at its window — so the retry cannot wait for traffic (issue
182
+ * #212's own failure mode, one ack later). Cleared by {@link #installReplay},
183
+ * {@link #installCheckpoint} and {@link SessionSubscriptions.dispose}.
184
+ */
185
+ ackRetryTimer?: ReturnType<typeof setTimeout>
111
186
  /**
112
187
  * The oldest stream position this session still TRACKS: the baseline the
113
188
  * subscription started from, advanced whenever old events are dropped. It is
@@ -189,6 +264,8 @@ export class SessionSubscriptions {
189
264
  readonly #limit: number
190
265
  readonly #perSessionCacheBytes: number
191
266
  readonly #checkpointMs: number
267
+ /** Where the peer's window comes from; see {@link SessionSubscriptionsOptions.unackedBytes}. */
268
+ readonly #unackedSource: number | (() => number | undefined) | undefined
192
269
  readonly #sessions = new Map<string, SessionState>()
193
270
  /**
194
271
  * Insertion order IS the eviction order, and it is SUBSCRIPTION recency, not
@@ -211,6 +288,7 @@ export class SessionSubscriptions {
211
288
  this.#limit = Math.max(1, Math.floor(positive(opts.subscriptionsLimit, DEFAULT_SUBSCRIPTIONS_LIMIT)))
212
289
  this.#perSessionCacheBytes = positive(opts.perSessionCacheBytes, DEFAULT_PER_SESSION_CACHE_BYTES)
213
290
  this.#checkpointMs = positive(opts.checkpointMs, DEFAULT_CHECKPOINT_MS)
291
+ this.#unackedSource = opts.unackedBytes
214
292
  }
215
293
 
216
294
  /**
@@ -256,6 +334,31 @@ export class SessionSubscriptions {
256
334
  return this.#sessions.get(sessionId)?.cursor
257
335
  }
258
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
+
259
362
  /**
260
363
  * The session's cache WINDOW START — the retention BOUNDARY of this layer's
261
364
  * cache: the oldest position it still tracks — or undefined when the session
@@ -316,6 +419,7 @@ export class SessionSubscriptions {
316
419
  // session that will not stay subscribed.
317
420
  console.info(`[cherry-remote] re-subscribing session ${sessionId} (replacing its live subscription)`)
318
421
  await this.#closeSubscription(existing.subscriptionId)
422
+ this.#cancelAckRetry(existing)
319
423
  this.#rejectWaiters(existing, new Error(`session ${sessionId} re-subscribed`))
320
424
  this.#sessions.delete(sessionId)
321
425
  }
@@ -424,17 +528,39 @@ export class SessionSubscriptions {
424
528
  /**
425
529
  * Advances the peer's view of this session: `agent.subscriptions.ack` with
426
530
  * 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.
531
+ * actually delivered (an out-of-window ack is its CONFLICT).
532
+ *
533
+ * Two callers, one contract — "this position is accounted for, keep sending":
534
+ * `cherry_events` passes the cursor a CONSUMER has processed, and
535
+ * {@link #maybeAutoAck} passes the BUFFER frontier because the events are
536
+ * retained here (see that method for why an ack cannot lose replay).
429
537
  */
430
538
  async ack(sessionId: string, cursor?: AgentCursor): Promise<void> {
431
539
  const state = this.#sessions.get(sessionId)
432
540
  if (state === undefined) return
433
- const applied = cursor ?? state.cursor
541
+ // COALESCED TO THE FRONTIER. The peer keeps ONE `lastAck` per subscription
542
+ // and refuses a lower position, so an explicit ack carrying what a consumer
543
+ // has processed would lose a race against the layer's own ack and be
544
+ // answered CONFLICT — which the caller treats as a broken subscription.
545
+ // The buffer already holds everything up to `state.cursor`, so acking the
546
+ // frontier is both safe (an ack only frees flow control) and monotone.
547
+ const requested = cursor === undefined ? state.cursor : cursor
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
434
553
  await this.#rpc.request('agent.subscriptions.ack', {
435
554
  subscriptionId: state.subscriptionId,
436
555
  cursor: applied,
437
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
438
564
  }
439
565
 
440
566
  /**
@@ -484,6 +610,7 @@ export class SessionSubscriptions {
484
610
  this.#closed = true
485
611
  const ids = [...this.#sessions.values()].map((state) => state.subscriptionId)
486
612
  for (const state of this.#sessions.values()) {
613
+ this.#cancelAckRetry(state)
487
614
  const error = new Error('SessionSubscriptions disposed')
488
615
  for (const waiter of state.waiters.splice(0)) waiter.reject(error)
489
616
  }
@@ -590,7 +717,10 @@ export class SessionSubscriptions {
590
717
  */
591
718
  #installReplay(sessionId: string, descriptor: Extract<SubscribeDescriptor, { mode: 'replay' }>): void {
592
719
  const previous = this.#sessions.get(sessionId)
593
- if (previous !== undefined) this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
720
+ if (previous !== undefined) {
721
+ this.#cancelAckRetry(previous)
722
+ this.#rejectWaiters(previous, new Error(`session ${sessionId} re-subscribed`))
723
+ }
594
724
  this.#sessions.delete(sessionId)
595
725
  // Re-inserting moves the session to the MRU end of the Map's order.
596
726
  this.#sessions.set(sessionId, {
@@ -598,6 +728,15 @@ export class SessionSubscriptions {
598
728
  cursor: descriptor.highWatermark,
599
729
  buffer: [],
600
730
  bytes: 0,
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,
736
+ ackInFlight: false,
737
+ ackRetryAt: 0,
738
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
739
+ ackRetryTimer: undefined,
601
740
  windowStart: Number(descriptor.highWatermark.seq),
602
741
  // A fresh window: nothing has been dropped from it, so the baseline is a
603
742
  // position this layer may serve (issue #2).
@@ -723,13 +862,25 @@ export class SessionSubscriptions {
723
862
  items: Array<{ kind: string; value: unknown }>,
724
863
  ): void {
725
864
  const previous = this.#sessions.get(sessionId)
726
- if (previous !== undefined) this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
865
+ if (previous !== undefined) {
866
+ this.#cancelAckRetry(previous)
867
+ this.#rejectWaiters(previous, new Error(`session ${sessionId} recovered`))
868
+ }
727
869
  this.#sessions.delete(sessionId)
728
870
  this.#sessions.set(sessionId, {
729
871
  subscriptionId: descriptor.subscriptionId,
730
872
  cursor: descriptor.checkpoint.cursor,
731
873
  buffer: [],
732
874
  bytes: 0,
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,
880
+ ackInFlight: false,
881
+ ackRetryAt: 0,
882
+ ackRetryDelay: AUTO_ACK_RETRY_MS,
883
+ ackRetryTimer: undefined,
733
884
  windowStart: Number(descriptor.checkpoint.cursor.seq),
734
885
  // A fresh window: nothing has been dropped from it, so the baseline is a
735
886
  // position this layer may serve (issue #2).
@@ -801,6 +952,7 @@ export class SessionSubscriptions {
801
952
  `${sessionId}`,
802
953
  )
803
954
  // The entry is removed outright, so no `live` flag needs clearing.
955
+ this.#cancelAckRetry(state)
804
956
  this.#rejectWaiters(state, new Error(`session ${sessionId} evicted by LRU`))
805
957
  this.#sessions.delete(sessionId)
806
958
  // AWAITED: the peer must observe the close before the next subscribe.
@@ -821,7 +973,9 @@ export class SessionSubscriptions {
821
973
 
822
974
  /**
823
975
  * `agent.events`: append to the session buffer, enforce the byte budget by
824
- * 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.
825
979
  *
826
980
  * SEQ MONOTONICITY IS ENFORCED HERE, per (sessionId, streamEpoch). The peer is
827
981
  * supposed to deliver each stream position exactly once and in order, and the
@@ -907,6 +1061,8 @@ export class SessionSubscriptions {
907
1061
  const bytes = eventBytes(event)
908
1062
  state.buffer.push({ cursor, event, bytes })
909
1063
  state.bytes += bytes
1064
+ state.bytesSinceAck += bytes
1065
+ state.deliveredBytes += bytes
910
1066
  state.cursor = cursor
911
1067
  appended += 1
912
1068
  }
@@ -915,6 +1071,104 @@ export class SessionSubscriptions {
915
1071
  // Waking on a wholly stale batch would make a parked waiter re-judge state
916
1072
  // that has not moved; only real progress is a wake.
917
1073
  if (appended > 0) this.#wakeWaiters(state)
1074
+ // The wake comes FIRST: a waiter that can settle from what just arrived
1075
+ // must not wait behind an ack round trip.
1076
+ if (appended > 0) this.#maybeAutoAck(sessionId, state)
1077
+ }
1078
+
1079
+ /**
1080
+ * Sends `agent.subscriptions.ack` once the un-acked bytes approach the peer's
1081
+ * window, so the peer keeps delivering (issue #212).
1082
+ *
1083
+ * WHAT IS BEING ACKED is "everything in this layer's buffer", not "everything
1084
+ * a caller has consumed". That is safe against the pinned peer because an ack
1085
+ * does exactly one thing: it frees FLOW CONTROL — the desktop decrements its
1086
+ * un-acked budget and advances `lastAck` (`agentSubscriptions.ts`). Replay
1087
+ * retention lives on the journal (`replayBytes`/`replayMs`) and is not
1088
+ * touched by an ack, so a `read`/`cherry_events`/re-subscribe from a cursor
1089
+ * below the acked position is still served — by the local buffer here and by
1090
+ * the journal there. The ack's own doc comment states the same contract.
1091
+ *
1092
+ * The ack is fire-and-forget with its failure SURFACED (a refused ack must be
1093
+ * visible, not swallowed). A refusal also ARMS A TIMER: once the peer is
1094
+ * holding at its window no further event arrives, so a retry that waited for
1095
+ * traffic would never fire and the wait would time out exactly as in issue
1096
+ * #212.
1097
+ */
1098
+ #maybeAutoAck(sessionId: string, state: SessionState): void {
1099
+ if (state.bytesSinceAck < this.#ackWatermark()) return
1100
+ if (state.ackInFlight) return
1101
+ if (Date.now() < state.ackRetryAt) return
1102
+ state.ackInFlight = true
1103
+ void this.ack(sessionId)
1104
+ .then(() => {
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.
1107
+ // A peer that answers ack again gets prompt retries next time.
1108
+ state.ackRetryDelay = AUTO_ACK_RETRY_MS
1109
+ })
1110
+ .catch((error: unknown) => {
1111
+ // THIS attempt's delay, then the next one doubles: a peer that refuses
1112
+ // every ack (a wedged controller, a link about to drop) must not log a
1113
+ // line every few seconds for the life of the layer.
1114
+ const delay = state.ackRetryDelay
1115
+ state.ackRetryAt = Date.now() + delay
1116
+ state.ackRetryDelay = Math.min(delay * 2, AUTO_ACK_RETRY_MAX_MS)
1117
+ console.warn(`[cherry-remote] auto-ack for session ${sessionId} failed: ${failureText(error)}`)
1118
+ // ARMED HERE, not on the next batch: a peer holding at its window sends
1119
+ // nothing more, so traffic-driven retry is no retry at all.
1120
+ this.#armAckRetry(sessionId, state, delay)
1121
+ })
1122
+ .finally(() => {
1123
+ state.ackInFlight = false
1124
+ // RE-JUDGE: batches can arrive WHILE the ack was in flight (the peer
1125
+ // flushes on the ack, so this is the common case), and those batches
1126
+ // were refused a trigger by `ackInFlight`. Without this second look the
1127
+ // layer could sit with bytes over the watermark and no event left to
1128
+ // trigger it — the peer holding, exactly as in issue #212.
1129
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return
1130
+ this.#maybeAutoAck(sessionId, state)
1131
+ })
1132
+ }
1133
+
1134
+ /**
1135
+ * Quarter of the peer's window, read from the CURRENT source so a layer that
1136
+ * outlives a reconnect follows the new hello instead of its first answer.
1137
+ * Falls back to the pinned protocol constant when nothing is advertised.
1138
+ */
1139
+ #ackWatermark(): number {
1140
+ const source = this.#unackedSource
1141
+ const advertised = typeof source === 'function' ? source() : source
1142
+ return Math.max(1, Math.floor(positive(advertised, remoteLimits.unackedBytes) / 4))
1143
+ }
1144
+
1145
+ /** Disarms the retry, if any (idempotent; safe on a state being replaced). */
1146
+ #cancelAckRetry(state: SessionState): void {
1147
+ if (state.ackRetryTimer === undefined) return
1148
+ clearTimeout(state.ackRetryTimer)
1149
+ state.ackRetryTimer = undefined
1150
+ }
1151
+
1152
+ /**
1153
+ * One timer-driven retry after a refused ack. At most one is armed per
1154
+ * session; it no-ops when the state it was armed for is no longer current
1155
+ * (a re-subscribe, a checkpoint recovery or `dispose`).
1156
+ */
1157
+ #armAckRetry(sessionId: string, state: SessionState, delayMs: number): void {
1158
+ if (this.#closed) return
1159
+ if (state.ackRetryTimer !== undefined) return
1160
+ const timer = setTimeout(() => {
1161
+ state.ackRetryTimer = undefined
1162
+ if (this.#closed || this.#sessions.get(sessionId) !== state) return
1163
+ // CLEARED HERE, not left to the wall clock: a clock stepped backwards
1164
+ // would otherwise make `#maybeAutoAck` return early and arm nothing — a
1165
+ // retry silently lost while the peer holds, which is #212 again.
1166
+ state.ackRetryAt = 0
1167
+ this.#maybeAutoAck(sessionId, state)
1168
+ }, delayMs)
1169
+ // Never hold the process open on a retry that is only about flow control.
1170
+ timer.unref?.()
1171
+ state.ackRetryTimer = timer
918
1172
  }
919
1173
 
920
1174
  /**
@@ -937,17 +1191,31 @@ export class SessionSubscriptions {
937
1191
  `[cherry-remote] subscription for session ${sessionId} reset by the peer ` +
938
1192
  `(${String(params['reason'] ?? 'unspecified')}); re-subscribe required`,
939
1193
  )
1194
+ this.#cancelAckRetry(state)
940
1195
  this.#rejectWaiters(state, new Error(`session ${sessionId} reset by the peer`))
941
1196
  this.#sessions.delete(sessionId)
942
1197
  return
943
1198
  }
944
1199
  }
945
1200
 
946
- /** 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
+ */
947
1215
  #trimToBudget(sessionId: string, state: SessionState): void {
948
1216
  if (state.bytes <= this.#perSessionCacheBytes) return
949
1217
  let dropped = 0
950
- while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 0) {
1218
+ while (state.bytes > this.#perSessionCacheBytes && state.buffer.length > 1) {
951
1219
  const removed = state.buffer.shift()!
952
1220
  state.bytes -= removed.bytes
953
1221
  dropped += 1
@@ -958,6 +1226,10 @@ export class SessionSubscriptions {
958
1226
  state.windowStart = Math.max(state.windowStart, Number(removed.cursor.seq))
959
1227
  state.evicted = true
960
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.
961
1233
  console.warn(
962
1234
  `[cherry-remote] session ${sessionId} cache exceeded ${this.#perSessionCacheBytes} bytes; ` +
963
1235
  `dropped ${dropped} oldest event(s)`,
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