@indigoai-us/hq-cloud 6.11.11 → 6.11.12

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.
Files changed (160) hide show
  1. package/dist/bin/sync-runner.d.ts +2 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +231 -52
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +265 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  8. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  9. package/dist/cli/rescue-core.js +138 -15
  10. package/dist/cli/rescue-core.js.map +1 -1
  11. package/dist/cli/share.d.ts +2 -1
  12. package/dist/cli/share.d.ts.map +1 -1
  13. package/dist/cli/share.js +100 -32
  14. package/dist/cli/share.js.map +1 -1
  15. package/dist/cli/share.test.js +30 -0
  16. package/dist/cli/share.test.js.map +1 -1
  17. package/dist/cli/sync.d.ts +28 -1
  18. package/dist/cli/sync.d.ts.map +1 -1
  19. package/dist/cli/sync.js +178 -58
  20. package/dist/cli/sync.js.map +1 -1
  21. package/dist/cli/sync.test.js +362 -1
  22. package/dist/cli/sync.test.js.map +1 -1
  23. package/dist/cognito-auth.d.ts.map +1 -1
  24. package/dist/cognito-auth.js +55 -10
  25. package/dist/cognito-auth.js.map +1 -1
  26. package/dist/cognito-auth.test.js +61 -0
  27. package/dist/cognito-auth.test.js.map +1 -1
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/journal.d.ts.map +1 -1
  33. package/dist/journal.js +93 -6
  34. package/dist/journal.js.map +1 -1
  35. package/dist/journal.test.js +59 -0
  36. package/dist/journal.test.js.map +1 -1
  37. package/dist/machine-auth.test.js +60 -2
  38. package/dist/machine-auth.test.js.map +1 -1
  39. package/dist/object-io.d.ts +37 -1
  40. package/dist/object-io.d.ts.map +1 -1
  41. package/dist/object-io.js +148 -29
  42. package/dist/object-io.js.map +1 -1
  43. package/dist/object-io.test.js +121 -0
  44. package/dist/object-io.test.js.map +1 -1
  45. package/dist/operation-lock.d.ts +8 -8
  46. package/dist/operation-lock.d.ts.map +1 -1
  47. package/dist/operation-lock.js +99 -32
  48. package/dist/operation-lock.js.map +1 -1
  49. package/dist/operation-lock.test.js +51 -4
  50. package/dist/operation-lock.test.js.map +1 -1
  51. package/dist/personal-vault.d.ts.map +1 -1
  52. package/dist/personal-vault.js +8 -2
  53. package/dist/personal-vault.js.map +1 -1
  54. package/dist/personal-vault.test.js +34 -0
  55. package/dist/personal-vault.test.js.map +1 -1
  56. package/dist/prefix-coalesce.d.ts +20 -9
  57. package/dist/prefix-coalesce.d.ts.map +1 -1
  58. package/dist/prefix-coalesce.js +124 -28
  59. package/dist/prefix-coalesce.js.map +1 -1
  60. package/dist/prefix-coalesce.test.js +57 -2
  61. package/dist/prefix-coalesce.test.js.map +1 -1
  62. package/dist/remote-pull.d.ts +6 -1
  63. package/dist/remote-pull.d.ts.map +1 -1
  64. package/dist/remote-pull.js +62 -13
  65. package/dist/remote-pull.js.map +1 -1
  66. package/dist/remote-pull.test.js +189 -0
  67. package/dist/remote-pull.test.js.map +1 -1
  68. package/dist/s3.d.ts +2 -0
  69. package/dist/s3.d.ts.map +1 -1
  70. package/dist/s3.js +197 -116
  71. package/dist/s3.js.map +1 -1
  72. package/dist/s3.test.js +109 -0
  73. package/dist/s3.test.js.map +1 -1
  74. package/dist/scope-shrink.d.ts +3 -2
  75. package/dist/scope-shrink.d.ts.map +1 -1
  76. package/dist/scope-shrink.js +1 -1
  77. package/dist/scope-shrink.js.map +1 -1
  78. package/dist/skill-telemetry.d.ts +1 -1
  79. package/dist/skill-telemetry.d.ts.map +1 -1
  80. package/dist/skill-telemetry.js +69 -9
  81. package/dist/skill-telemetry.js.map +1 -1
  82. package/dist/skill-telemetry.test.js +86 -0
  83. package/dist/skill-telemetry.test.js.map +1 -1
  84. package/dist/sync/event-sync.d.ts +6 -0
  85. package/dist/sync/event-sync.d.ts.map +1 -1
  86. package/dist/sync/event-sync.js +34 -1
  87. package/dist/sync/event-sync.js.map +1 -1
  88. package/dist/sync/event-sync.test.js +73 -0
  89. package/dist/sync/event-sync.test.js.map +1 -1
  90. package/dist/sync/metrics.d.ts +17 -1
  91. package/dist/sync/metrics.d.ts.map +1 -1
  92. package/dist/sync/metrics.js +32 -1
  93. package/dist/sync/metrics.js.map +1 -1
  94. package/dist/sync/metrics.test.js +74 -1
  95. package/dist/sync/metrics.test.js.map +1 -1
  96. package/dist/sync/pull-scope.d.ts.map +1 -1
  97. package/dist/sync/pull-scope.js +15 -7
  98. package/dist/sync/pull-scope.js.map +1 -1
  99. package/dist/sync/push-receiver.d.ts +6 -5
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +13 -15
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +36 -1
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/telemetry.d.ts +1 -1
  106. package/dist/telemetry.d.ts.map +1 -1
  107. package/dist/telemetry.js +59 -6
  108. package/dist/telemetry.js.map +1 -1
  109. package/dist/telemetry.test.js +74 -0
  110. package/dist/telemetry.test.js.map +1 -1
  111. package/dist/types.d.ts +8 -0
  112. package/dist/types.d.ts.map +1 -1
  113. package/dist/watcher.d.ts +36 -0
  114. package/dist/watcher.d.ts.map +1 -1
  115. package/dist/watcher.js +152 -30
  116. package/dist/watcher.js.map +1 -1
  117. package/dist/watcher.test.js +103 -0
  118. package/dist/watcher.test.js.map +1 -1
  119. package/package.json +1 -1
  120. package/src/bin/sync-runner.test.ts +298 -11
  121. package/src/bin/sync-runner.ts +254 -52
  122. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  123. package/src/cli/rescue-core.ts +174 -15
  124. package/src/cli/share.test.ts +38 -0
  125. package/src/cli/share.ts +103 -34
  126. package/src/cli/sync.test.ts +435 -1
  127. package/src/cli/sync.ts +217 -64
  128. package/src/cognito-auth.test.ts +77 -0
  129. package/src/cognito-auth.ts +73 -11
  130. package/src/index.ts +8 -0
  131. package/src/journal.test.ts +72 -0
  132. package/src/journal.ts +95 -8
  133. package/src/machine-auth.test.ts +64 -2
  134. package/src/object-io.test.ts +142 -0
  135. package/src/object-io.ts +182 -30
  136. package/src/operation-lock.test.ts +63 -4
  137. package/src/operation-lock.ts +99 -31
  138. package/src/personal-vault.test.ts +42 -0
  139. package/src/personal-vault.ts +8 -2
  140. package/src/prefix-coalesce.test.ts +71 -1
  141. package/src/prefix-coalesce.ts +155 -30
  142. package/src/remote-pull.test.ts +205 -0
  143. package/src/remote-pull.ts +77 -14
  144. package/src/s3.test.ts +126 -0
  145. package/src/s3.ts +237 -122
  146. package/src/scope-shrink.ts +6 -3
  147. package/src/skill-telemetry.test.ts +109 -0
  148. package/src/skill-telemetry.ts +82 -14
  149. package/src/sync/event-sync.test.ts +75 -0
  150. package/src/sync/event-sync.ts +54 -1
  151. package/src/sync/metrics.test.ts +81 -0
  152. package/src/sync/metrics.ts +59 -4
  153. package/src/sync/pull-scope.ts +23 -7
  154. package/src/sync/push-receiver.test.ts +38 -1
  155. package/src/sync/push-receiver.ts +15 -18
  156. package/src/telemetry.test.ts +85 -0
  157. package/src/telemetry.ts +69 -6
  158. package/src/types.ts +8 -0
  159. package/src/watcher.test.ts +117 -0
  160. package/src/watcher.ts +209 -33
@@ -191,8 +191,8 @@ export interface PushReceiverContext {
191
191
  * company/path; in tests it's a fake recording invocations.
192
192
  *
193
193
  * Errors from `syncFn` are CAUGHT by the receiver — they log and the loop
194
- * continues. A misbehaving sync fn cannot crash the receiver. The poll/cadence
195
- * safety net is the recovery path for a thrown pull.
194
+ * continues. A failed sync is left unacknowledged in SQS so the queue can
195
+ * redeliver it after the visibility timeout.
196
196
  */
197
197
  export type SyncEngineFn = (ctx: PushReceiverContext) => Promise<void>;
198
198
 
@@ -613,18 +613,18 @@ export class SqsPushReceiver implements PushReceiver {
613
613
 
614
614
  const handled = await this.dispatch(validated);
615
615
  if (handled) {
616
- // Delete only after the handoff (success OR dedupe-skip both mean we
617
- // don't need this message again). A syncFn throw still counts as handled
618
- // for delete purposes: the seen-counter advanced, so a redelivery would
619
- // dedupe; the poll/cadence safety net is the recovery path for the throw.
616
+ // Delete only after a successful handoff or dedupe-skip. A syncFn throw
617
+ // leaves the message undeleted so SQS redelivery can retry the targeted
618
+ // pull.
620
619
  await this.safeDelete(msg);
621
620
  }
622
621
  }
623
622
 
624
623
  /**
625
- * Dedupe + invoke `syncFn`. Returns true once the event has been accounted
626
- * for (deduped, or syncFn settled) so the caller can delete the message.
627
- * Stores the in-flight promise so `dispose()` can drain it.
624
+ * Dedupe + invoke `syncFn`. Returns true only once the event has been
625
+ * accounted for successfully (deduped, or syncFn completed) so the caller can
626
+ * delete the message. Stores the in-flight promise so `dispose()` can drain
627
+ * it.
628
628
  */
629
629
  private async dispatch(event: PushEvent): Promise<boolean> {
630
630
  if (this.disposing || this.disposed) return false;
@@ -645,18 +645,13 @@ export class SqsPushReceiver implements PushReceiver {
645
645
  return true;
646
646
  }
647
647
 
648
- // Record BEFORE invoking syncFn — a back-to-back event for the same path
649
- // must see this sequence in its dedupe check. The trade-off: a syncFn that
650
- // throws still advances the counter ("latest we KNOW about"); the safety
651
- // net poll is the recovery path for the throw.
652
- this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
653
-
654
648
  const controller = new AbortController();
655
649
  this.inFlightAbort = controller;
656
650
  const ctx: PushReceiverContext = { event, signal: controller.signal };
657
651
 
658
652
  const holder: { p: Promise<void> | null } = { p: null };
659
653
  const startMs = this.now();
654
+ let handled = false;
660
655
  const p: Promise<void> = (async () => {
661
656
  try {
662
657
  this.logger.debug(
@@ -669,7 +664,9 @@ export class SqsPushReceiver implements PushReceiver {
669
664
  "push receiver invoking sync engine",
670
665
  );
671
666
  await this.syncFn(ctx);
667
+ this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
672
668
  this._processedCount += 1;
669
+ handled = true;
673
670
  this.logger.debug(
674
671
  {
675
672
  event: "receiver.sync.completed",
@@ -715,8 +712,8 @@ export class SqsPushReceiver implements PushReceiver {
715
712
  });
716
713
  } catch (err) {
717
714
  // Critical: catch, log, return. A misbehaving sync engine must never
718
- // crash the receiver loop. The safety-net poll handles eventual
719
- // consistency for failed pulls.
715
+ // crash the receiver loop. Leaving the SQS message undeleted lets
716
+ // redelivery retry the failed targeted pull.
720
717
  const e = err as NodeJS.ErrnoException;
721
718
  this.logger.error(
722
719
  {
@@ -736,7 +733,7 @@ export class SqsPushReceiver implements PushReceiver {
736
733
  holder.p = p;
737
734
  this.inFlightSync = p;
738
735
  await p;
739
- return true;
736
+ return handled;
740
737
  }
741
738
 
742
739
  /** Delete a message, swallowing transport errors (redelivery is harmless). */
@@ -292,6 +292,91 @@ describe("collectAndSendTelemetry", () => {
292
292
  expect(lastWire).toBeLessThan(1_000_000);
293
293
  });
294
294
 
295
+ it("F19: caps usage telemetry batches at 100 events", async () => {
296
+ const client = makeClient();
297
+ const lines = Array.from({ length: 101 }, (_, i) => JSON.stringify({
298
+ type: "user",
299
+ timestamp: `2026-04-25T10:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}Z`,
300
+ sessionId: "s-f19-row-cap",
301
+ uuid: `u-f19-row-${i}`,
302
+ cwd: "/Users/x",
303
+ gitBranch: "main",
304
+ userType: "human",
305
+ message: { role: "user", content: [{ type: "text", text: "hi" }], id: `m${i}` },
306
+ }));
307
+ writeJsonl(env, "proj", "f19-row-cap.jsonl", lines);
308
+
309
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
310
+
311
+ expect(result.eventsSent).toBe(101);
312
+ expect(result.batchesSent).toBe(2);
313
+ expect(client.posts.map((post) => post.events.length)).toEqual([100, 1]);
314
+ for (const post of client.posts) {
315
+ expect(post.events.length).toBeLessThanOrEqual(100);
316
+ }
317
+ });
318
+
319
+ it("F19: caps usage telemetry POST bodies at 240 KiB", async () => {
320
+ const client = makeClient();
321
+ const branch = "x".repeat(9_000);
322
+ const lines = Array.from({ length: 30 }, (_, i) => JSON.stringify({
323
+ type: "user",
324
+ timestamp: `2026-04-25T11:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}Z`,
325
+ sessionId: "s-f19-byte-cap",
326
+ uuid: `u-f19-byte-${i}`,
327
+ cwd: "/Users/x",
328
+ gitBranch: branch,
329
+ userType: "human",
330
+ message: { role: "user", content: [{ type: "text", text: "hi" }], id: `m${i}` },
331
+ }));
332
+ writeJsonl(env, "proj", "f19-byte-cap.jsonl", lines);
333
+
334
+ const result = await collectAndSendTelemetry(makeOpts(env, client));
335
+ const wireSizes = client.posts.map((post) => Buffer.byteLength(JSON.stringify(post), "utf-8"));
336
+
337
+ expect(result.eventsSent).toBe(30);
338
+ expect(result.batchesSent).toBeGreaterThan(1);
339
+ for (const wireSize of wireSizes) {
340
+ expect(wireSize).toBeLessThanOrEqual(240 * 1024);
341
+ }
342
+ });
343
+
344
+ it("R-F19: bounds a single oversized sanitized usage row before POST", async () => {
345
+ const client = makeClient();
346
+ const logs: string[] = [];
347
+ const hugeBranch = "x".repeat(400_000);
348
+ writeJsonl(env, "proj", "r-f19-singleton.jsonl", [
349
+ JSON.stringify({
350
+ type: "user",
351
+ timestamp: "2026-06-19T10:00:00.000Z",
352
+ sessionId: "s-r-f19-singleton",
353
+ uuid: "u-r-f19-singleton",
354
+ cwd: "/Users/x",
355
+ gitBranch: hugeBranch,
356
+ userType: "human",
357
+ message: {
358
+ role: "user",
359
+ content: [{ type: "text", text: "hi" }],
360
+ id: "m-r-f19-singleton",
361
+ },
362
+ }),
363
+ ]);
364
+
365
+ const result = await collectAndSendTelemetry({
366
+ ...makeOpts(env, client),
367
+ log: (msg) => logs.push(msg),
368
+ });
369
+
370
+ expect(result.eventsSent).toBe(1);
371
+ expect(client.posts).toHaveLength(1);
372
+ const wireSize = Buffer.byteLength(JSON.stringify(client.posts[0]), "utf-8");
373
+ expect(wireSize).toBeLessThanOrEqual(240 * 1024);
374
+ expect(client.posts[0].events[0].gitBranch).not.toBe(hugeBranch);
375
+ expect(logs.some((line) => line.includes("oversized row truncated"))).toBe(
376
+ true,
377
+ );
378
+ });
379
+
295
380
  it("(e) POST 500 → cursor NOT advanced", async () => {
296
381
  const client = makeClient({ postResponse: new Error("server 500") });
297
382
  writeJsonl(env, "proj", "s.jsonl", [USER_ROW, ASST_ROW, USER_ROW]);
package/src/telemetry.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * file against a persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`,
12
12
  * sanitizes new rows through a tight allowlist that matches the server's
13
13
  * KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
14
- * batches into ≤1 MiB POST bodies, and ships them to `/v1/usage`.
14
+ * batches into server-sized POST bodies, and ships them to `/v1/usage`.
15
15
  *
16
16
  * Trust model: the caller's `personUid` is resolved on the server from the
17
17
  * Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies,
@@ -245,7 +245,9 @@ async function listJsonlFiles(root: string): Promise<string[]> {
245
245
 
246
246
  // ── Batching primitives ───────────────────────────────────────────────────────
247
247
 
248
- const MAX_BATCH_BYTES = 1_000_000;
248
+ const MAX_BATCH_EVENTS = 100;
249
+ const MAX_BATCH_BYTES = 240 * 1024;
250
+ const ROW_TRUNCATION_SUFFIX = "...[truncated]";
249
251
 
250
252
  interface RowSource {
251
253
  filePath: string;
@@ -273,6 +275,49 @@ function envelopeBytes(machineId: string, installerVersion: string): number {
273
275
  );
274
276
  }
275
277
 
278
+ function jsonBytes(value: unknown): number {
279
+ return Buffer.byteLength(JSON.stringify(value), "utf-8");
280
+ }
281
+
282
+ function truncateLongestStringField(row: Record<string, unknown>): boolean {
283
+ let longestKey: string | undefined;
284
+ let longestBytes = 0;
285
+ for (const [key, value] of Object.entries(row)) {
286
+ if (typeof value !== "string" || value.length === 0) continue;
287
+ const bytes = Buffer.byteLength(value, "utf-8");
288
+ if (bytes > longestBytes) {
289
+ longestBytes = bytes;
290
+ longestKey = key;
291
+ }
292
+ }
293
+ if (longestKey === undefined) return false;
294
+
295
+ const value = row[longestKey] as string;
296
+ const keepChars =
297
+ value.length > ROW_TRUNCATION_SUFFIX.length
298
+ ? Math.floor((value.length - ROW_TRUNCATION_SUFFIX.length) / 2)
299
+ : 0;
300
+ const next =
301
+ keepChars > 0
302
+ ? `${value.slice(0, keepChars)}${ROW_TRUNCATION_SUFFIX}`
303
+ : "";
304
+ if (next === value) return false;
305
+ row[longestKey] = next;
306
+ return true;
307
+ }
308
+
309
+ function boundRowForPost(
310
+ row: Record<string, unknown>,
311
+ maxRowBytes: number,
312
+ ): Record<string, unknown> | null {
313
+ if (maxRowBytes < 0) return null;
314
+ const bounded = { ...row };
315
+ while (jsonBytes(bounded) > maxRowBytes) {
316
+ if (!truncateLongestStringField(bounded)) return null;
317
+ }
318
+ return bounded;
319
+ }
320
+
276
321
  // ── Main entry point ──────────────────────────────────────────────────────────
277
322
 
278
323
  /**
@@ -324,7 +369,7 @@ export async function collectAndSendTelemetry(
324
369
 
325
370
  const files = await listJsonlFiles(claudeProjectsRoot);
326
371
 
327
- // 3. Walk each file, sanitize new rows, batch, flush at 1 MiB.
372
+ // 3. Walk each file, sanitize new rows, batch, flush at the server contract.
328
373
  //
329
374
  // Byte accounting is incremental: we track `batchBytes` as the projected
330
375
  // serialized size of the current batch (envelope + per-row JSON + commas).
@@ -440,15 +485,33 @@ export async function collectAndSendTelemetry(
440
485
  );
441
486
  const sanitized = sanitizeRow(parsed, companyUid);
442
487
  if (!sanitized) continue;
488
+ const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
489
+ const wasOversized = jsonBytes(sanitized) > maxRowBytes;
490
+ const bounded = boundRowForPost(sanitized, maxRowBytes);
491
+ if (!bounded) {
492
+ log(
493
+ `[telemetry] oversized row dropped before send (${filePath}:${i + 1})`,
494
+ );
495
+ continue;
496
+ }
497
+ if (wasOversized) {
498
+ log(
499
+ `[telemetry] oversized row truncated before send (${filePath}:${i + 1})`,
500
+ );
501
+ }
443
502
 
444
503
  // Cost of appending this row to the current batch: the row's JSON
445
504
  // length plus 1 byte for the leading comma when there's already at
446
505
  // least one row. (No comma when the batch is empty — the row sits
447
506
  // alone inside the events array.)
448
- const rowJsonBytes = Buffer.byteLength(JSON.stringify(sanitized), "utf-8");
507
+ const rowJsonBytes = Buffer.byteLength(JSON.stringify(bounded), "utf-8");
449
508
  const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
450
509
 
451
- if (batchEvents.length > 0 && batchBytes + addCost > MAX_BATCH_BYTES) {
510
+ if (
511
+ batchEvents.length > 0 &&
512
+ (batchEvents.length >= MAX_BATCH_EVENTS ||
513
+ batchBytes + addCost > MAX_BATCH_BYTES)
514
+ ) {
452
515
  await flush();
453
516
  // After flush, batchEvents is empty → no comma needed for the first row.
454
517
  batchBytes = ENVELOPE_BYTES + rowJsonBytes;
@@ -456,7 +519,7 @@ export async function collectAndSendTelemetry(
456
519
  batchBytes += addCost;
457
520
  }
458
521
 
459
- batchEvents.push(sanitized);
522
+ batchEvents.push(bounded);
460
523
  batchSources.push({
461
524
  filePath,
462
525
  endOffset: lineEndOffsets[i],
package/src/types.ts CHANGED
@@ -72,6 +72,14 @@ export interface JournalEntry {
72
72
  */
73
73
  removedAt?: string;
74
74
  removedReason?: "scope_shrink" | "narrow_apply" | "manual";
75
+ /**
76
+ * Durable automatic-pull retention marker. Set when a scope shrink keeps an
77
+ * out-of-scope caller-authored or unknown-author entry on disk instead of
78
+ * pruning it. Subsequent pulls under the already-narrowed scope use this to
79
+ * exclude the survivor from remote-missing local deletes; it is cleared if
80
+ * the entry becomes in-scope again.
81
+ */
82
+ outOfScopeProtected?: boolean;
75
83
  }
76
84
 
77
85
  /**
@@ -49,6 +49,10 @@ function makeHarness(opts?: { debounceMs?: number }) {
49
49
  };
50
50
  }
51
51
 
52
+ async function flushImmediate(): Promise<void> {
53
+ await new Promise<void>((resolve) => setImmediate(resolve));
54
+ }
55
+
52
56
  describe("FakeClock", () => {
53
57
  it("fires a timer exactly when its deadline is reached", () => {
54
58
  const clock = new FakeClock();
@@ -166,6 +170,34 @@ describe("US-001: WatchPushDriver — debounced push seam", () => {
166
170
  driver.dispose();
167
171
  });
168
172
 
173
+ it("F17: rejected watch push is caught instead of surfacing as an unhandled rejection", async () => {
174
+ const clock = new FakeClock();
175
+ const rejection = new Error("push failed");
176
+ const push = vi.fn(async () => {
177
+ throw rejection;
178
+ });
179
+ const driver = new WatchPushDriver({ debounceMs: DEBOUNCE, clock, push });
180
+ const unhandled: unknown[] = [];
181
+ const onUnhandled = (reason: unknown) => {
182
+ unhandled.push(reason);
183
+ };
184
+
185
+ process.prependListener("unhandledRejection", onUnhandled);
186
+ try {
187
+ driver.notifyChange();
188
+ clock.advance(DEBOUNCE);
189
+ await Promise.resolve();
190
+ await flushImmediate();
191
+
192
+ expect(push).toHaveBeenCalledTimes(1);
193
+ expect(driver.isPushing()).toBe(false);
194
+ expect(unhandled).toEqual([]);
195
+ } finally {
196
+ process.removeListener("unhandledRejection", onUnhandled);
197
+ driver.dispose();
198
+ }
199
+ });
200
+
169
201
  it("respects a custom debounce window", async () => {
170
202
  const h = makeHarness({ debounceMs: 500 });
171
203
  h.emitChange();
@@ -374,6 +406,42 @@ describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
374
406
  clock.advance(DEBOUNCE);
375
407
  expect(changed).toHaveBeenCalledTimes(2);
376
408
  });
409
+
410
+ it("R-F13: caps watcher backlog and keeps EMFILE polling fallback removed", () => {
411
+ const clock = new FakeClock();
412
+ const changed = vi.fn();
413
+ const overflow = vi.fn();
414
+ const watcher = new TreeWatcher({
415
+ hqRoot: ROOT,
416
+ debounceMs: DEBOUNCE,
417
+ clock,
418
+ pathFilter: () => true,
419
+ maxPendingPaths: 3,
420
+ maxPendingBytes: 10_000,
421
+ onBacklogOverflow: overflow,
422
+ });
423
+ watcher.onChange(changed);
424
+
425
+ for (let i = 0; i < 10; i++) {
426
+ watcher.handleEvent(path.join(ROOT, `bulk-${i}.md`));
427
+ }
428
+
429
+ expect(overflow).toHaveBeenCalledTimes(1);
430
+ clock.advance(DEBOUNCE);
431
+ expect(changed).toHaveBeenCalledTimes(1);
432
+ const batch = changed.mock.calls[0][1] as {
433
+ paths: Map<string, string>;
434
+ overflowed?: boolean;
435
+ droppedPaths?: number;
436
+ };
437
+ expect(batch.paths.size).toBe(3);
438
+ expect(batch.overflowed).toBe(true);
439
+ expect(batch.droppedPaths).toBe(7);
440
+
441
+ const source = fs.readFileSync(path.join(process.cwd(), "src/watcher.ts"), "utf8");
442
+ expect(source).not.toContain("startPollingTreeWatch");
443
+ expect(source).not.toContain("snapshotWatchTree");
444
+ });
377
445
  });
378
446
 
379
447
  describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", () => {
@@ -468,6 +536,55 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
468
536
  });
469
537
  }
470
538
 
539
+ it("F13: watcher batch event publishing applies bounded backpressure", async () => {
540
+ const paths = new Map<string, string>();
541
+ for (let i = 0; i < 32; i++) {
542
+ const rel = `bulk-${i}.md`;
543
+ const abs = path.join(dir, rel);
544
+ fs.writeFileSync(abs, `file ${i}`);
545
+ paths.set(abs, rel);
546
+ }
547
+
548
+ let inFlight = 0;
549
+ let maxInFlight = 0;
550
+ let releaseGate!: () => void;
551
+ const gate = new Promise<void>((resolve) => {
552
+ releaseGate = resolve;
553
+ });
554
+ const transport: PushTransport = {
555
+ start: async () => {},
556
+ dispose: async () => {},
557
+ connected: true,
558
+ publish: async () => {
559
+ inFlight += 1;
560
+ maxInFlight = Math.max(maxInFlight, inFlight);
561
+ await gate;
562
+ inFlight -= 1;
563
+ },
564
+ };
565
+ const emitter = new PushEventEmitter({
566
+ originTenantId: "tenant-indigo",
567
+ originDeviceId: "device-a",
568
+ transport,
569
+ flagProvider: new StaticFlagProvider(["tenant-indigo"]),
570
+ now: () => new Date("2026-06-18T12:00:00.000Z"),
571
+ });
572
+
573
+ const run = emitter.emitForBatch({ paths });
574
+ try {
575
+ for (let i = 0; i < 200 && maxInFlight <= 16; i++) {
576
+ await flushImmediate();
577
+ }
578
+ releaseGate();
579
+ await run;
580
+
581
+ expect(maxInFlight).toBeLessThanOrEqual(16);
582
+ } finally {
583
+ releaseGate();
584
+ await run.catch(() => {});
585
+ }
586
+ });
587
+
471
588
  it("skips directory changes silently without publishing or reporting an error", async () => {
472
589
  const published: PushEvent[] = [];
473
590
  const onError = vi.fn();