@indigoai-us/hq-cloud 6.14.49 → 6.15.0

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 (126) hide show
  1. package/.github/workflows/unreleased-commits-nag.yml +256 -0
  2. package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
  3. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-watch-loop.js +153 -25
  5. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
  7. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-routes.js +11 -0
  9. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
  11. package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
  12. package/dist/bin/sync-runner-watch-routes.test.js +51 -0
  13. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
  14. package/dist/bin/sync-runner.d.ts +11 -12
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +173 -52
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/reindex-knowledge.d.ts +7 -0
  20. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  21. package/dist/cli/reindex-knowledge.js +389 -0
  22. package/dist/cli/reindex-knowledge.js.map +1 -0
  23. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  24. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  25. package/dist/cli/reindex-knowledge.test.js +260 -0
  26. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  27. package/dist/cli/reindex.d.ts.map +1 -1
  28. package/dist/cli/reindex.js +8 -0
  29. package/dist/cli/reindex.js.map +1 -1
  30. package/dist/cli/rescue-classify-ordering.test.js +8 -2
  31. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  32. package/dist/cli/rescue-core.d.ts +85 -0
  33. package/dist/cli/rescue-core.d.ts.map +1 -1
  34. package/dist/cli/rescue-core.js +390 -1
  35. package/dist/cli/rescue-core.js.map +1 -1
  36. package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
  37. package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
  38. package/dist/cli/rescue-settings-reconcile.test.js +523 -0
  39. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/journal.d.ts.map +1 -1
  45. package/dist/journal.js +81 -51
  46. package/dist/journal.js.map +1 -1
  47. package/dist/journal.test.js +11 -19
  48. package/dist/journal.test.js.map +1 -1
  49. package/dist/sync/event-sync.d.ts +8 -25
  50. package/dist/sync/event-sync.d.ts.map +1 -1
  51. package/dist/sync/event-sync.js +23 -48
  52. package/dist/sync/event-sync.js.map +1 -1
  53. package/dist/sync/event-sync.test.js +6 -32
  54. package/dist/sync/event-sync.test.js.map +1 -1
  55. package/dist/sync/feature-flags.d.ts +20 -117
  56. package/dist/sync/feature-flags.d.ts.map +1 -1
  57. package/dist/sync/feature-flags.js +24 -147
  58. package/dist/sync/feature-flags.js.map +1 -1
  59. package/dist/sync/feature-flags.test.js +15 -61
  60. package/dist/sync/feature-flags.test.js.map +1 -1
  61. package/dist/sync/index.d.ts +1 -1
  62. package/dist/sync/index.d.ts.map +1 -1
  63. package/dist/sync/index.js +1 -1
  64. package/dist/sync/index.js.map +1 -1
  65. package/dist/sync/lease-client.d.ts +67 -0
  66. package/dist/sync/lease-client.d.ts.map +1 -0
  67. package/dist/sync/lease-client.js +158 -0
  68. package/dist/sync/lease-client.js.map +1 -0
  69. package/dist/sync/lease-client.test.d.ts +2 -0
  70. package/dist/sync/lease-client.test.d.ts.map +1 -0
  71. package/dist/sync/lease-client.test.js +119 -0
  72. package/dist/sync/lease-client.test.js.map +1 -0
  73. package/dist/sync/push-receiver.d.ts +8 -19
  74. package/dist/sync/push-receiver.d.ts.map +1 -1
  75. package/dist/sync/push-receiver.js +6 -50
  76. package/dist/sync/push-receiver.js.map +1 -1
  77. package/dist/sync/push-receiver.test.js +6 -57
  78. package/dist/sync/push-receiver.test.js.map +1 -1
  79. package/dist/sync/realtime-rollout.d.ts +85 -0
  80. package/dist/sync/realtime-rollout.d.ts.map +1 -0
  81. package/dist/sync/realtime-rollout.js +206 -0
  82. package/dist/sync/realtime-rollout.js.map +1 -0
  83. package/dist/sync/realtime-rollout.test.d.ts +2 -0
  84. package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
  85. package/dist/sync/realtime-rollout.test.js +71 -0
  86. package/dist/sync/realtime-rollout.test.js.map +1 -0
  87. package/dist/sync/state-store.d.ts +40 -0
  88. package/dist/sync/state-store.d.ts.map +1 -0
  89. package/dist/sync/state-store.js +680 -0
  90. package/dist/sync/state-store.js.map +1 -0
  91. package/dist/sync/state-store.test.d.ts +2 -0
  92. package/dist/sync/state-store.test.d.ts.map +1 -0
  93. package/dist/sync/state-store.test.js +159 -0
  94. package/dist/sync/state-store.test.js.map +1 -0
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/bin/sync-runner-watch-loop.ts +176 -32
  99. package/src/bin/sync-runner-watch-routes.test.ts +71 -0
  100. package/src/bin/sync-runner-watch-routes.ts +27 -0
  101. package/src/bin/sync-runner.test.ts +188 -53
  102. package/src/bin/sync-runner.ts +11 -12
  103. package/src/cli/reindex-knowledge.test.ts +307 -0
  104. package/src/cli/reindex-knowledge.ts +450 -0
  105. package/src/cli/reindex.ts +9 -0
  106. package/src/cli/rescue-classify-ordering.test.ts +9 -3
  107. package/src/cli/rescue-core.ts +459 -1
  108. package/src/cli/rescue-settings-reconcile.test.ts +637 -0
  109. package/src/index.ts +2 -4
  110. package/src/journal.test.ts +19 -21
  111. package/src/journal.ts +87 -54
  112. package/src/sync/event-sync.test.ts +9 -44
  113. package/src/sync/event-sync.ts +26 -52
  114. package/src/sync/feature-flags.test.ts +22 -72
  115. package/src/sync/feature-flags.ts +32 -199
  116. package/src/sync/index.ts +2 -4
  117. package/src/sync/lease-client.test.ts +128 -0
  118. package/src/sync/lease-client.ts +207 -0
  119. package/src/sync/push-receiver.test.ts +6 -58
  120. package/src/sync/push-receiver.ts +9 -74
  121. package/src/sync/realtime-rollout.test.ts +86 -0
  122. package/src/sync/realtime-rollout.ts +262 -0
  123. package/src/sync/state-store.test.ts +194 -0
  124. package/src/sync/state-store.ts +727 -0
  125. package/src/types.ts +14 -0
  126. package/test/e2e/sync/transient-company-leg.test.ts +8 -4
@@ -0,0 +1,256 @@
1
+ name: Nag unreleased commits on main
2
+
3
+ # Two weekly release nags for hq-cloud:
4
+ #
5
+ # nag — `main` is carrying work nobody has released (below).
6
+ # pin-lag — the latest hq-cloud release is not pinned by hq-cli and
7
+ # hq-desktop-app `main`. See that job's own header.
8
+ #
9
+ # A commit counts as UNRELEASED when it is reachable from `main` but not from
10
+ # any STABLE tag. Stable means `vMAJOR.MINOR.PATCH` with no prerelease suffix —
11
+ # `-rc.N`, `-beta.N` and `-alpha.N` tags deliberately do NOT silence the nag,
12
+ # because a prerelease is not a release.
13
+ #
14
+ # Release sync-back commits (`chore(release): …`) are ignored. The publish
15
+ # workflow pushes them to `main` *after* cutting the tag, so they are
16
+ # unreachable from that tag by construction and would nag forever otherwise.
17
+ #
18
+ # Releasing this repo is one command — `git push origin v<next>` — so the fix
19
+ # for a nag is always to cut the tag (or to say out loud why you are holding).
20
+ #
21
+ # Requires repo secret SLACK_BOT_TOKEN_RELEASE_MANAGER (the same release-manager
22
+ # bot hq-core's `release-pr-nag.yml` uses). The bot MUST be a member of #hq-dev.
23
+ # Without the secret the job logs a warning and exits 0 rather than failing.
24
+
25
+ on:
26
+ schedule:
27
+ # Mondays 15:00 UTC (~08:00 PT). Staggered against the sibling repos'
28
+ # nags so all four do not land in #hq-dev in the same second.
29
+ - cron: '10 15 * * 1'
30
+ workflow_dispatch:
31
+ inputs:
32
+ threshold_hours:
33
+ description: 'Age of the OLDEST unreleased commit, in hours, before nagging (default 24)'
34
+ required: false
35
+ default: '24'
36
+
37
+ permissions:
38
+ contents: read
39
+
40
+ jobs:
41
+ nag:
42
+ name: Nag when main has unreleased commits
43
+ if: github.repository == 'indigoai-us/hq-cloud'
44
+ runs-on: ubuntu-latest
45
+ env:
46
+ SLACK_TOKEN: ${{ secrets.SLACK_BOT_TOKEN_RELEASE_MANAGER }}
47
+ SLACK_CHANNEL: '#hq-dev'
48
+ THRESHOLD: ${{ github.event.inputs.threshold_hours || '24' }}
49
+ steps:
50
+ - name: Check out full history and tags
51
+ uses: actions/checkout@v4
52
+ with:
53
+ ref: main
54
+ fetch-depth: 0
55
+
56
+ - name: Find unreleased commits on main and nag
57
+ run: |
58
+ set -euo pipefail
59
+
60
+ if [ -z "${SLACK_TOKEN:-}" ]; then
61
+ echo "::warning::SLACK_BOT_TOKEN_RELEASE_MANAGER is not set on this repository; cannot post the nag."
62
+ exit 0
63
+ fi
64
+
65
+ git fetch --force --tags --quiet origin main
66
+
67
+ # Newest stable tag reachable from main — display only.
68
+ LAST_TAG="$(git tag --list 'v*' --merged origin/main --sort=-v:refname \
69
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)"
70
+
71
+ # Commits on main not reachable from ANY stable tag. `--exclude` applies
72
+ # to the `--tags` glob that follows it, so `v*-*` drops every prerelease.
73
+ git log origin/main --format='%H%x09%ct%x09%s' \
74
+ --not --exclude='v*-*' --tags='v*' > /tmp/unreleased_all.tsv
75
+
76
+ # Drop the post-tag release sync-back commits.
77
+ awk -F'\t' '$3 !~ /^chore\(release\)/' /tmp/unreleased_all.tsv > /tmp/unreleased.tsv
78
+
79
+ COUNT="$(wc -l < /tmp/unreleased.tsv | tr -d ' ')"
80
+ echo "Last stable tag: ${LAST_TAG:-<none>}"
81
+ echo "Unreleased commits on main: ${COUNT}"
82
+
83
+ if [ "$COUNT" -eq 0 ]; then
84
+ echo "main is fully released. Nothing to nag about."
85
+ exit 0
86
+ fi
87
+
88
+ OLDEST_TS="$(tail -1 /tmp/unreleased.tsv | cut -f2)"
89
+ NOW="$(date -u +%s)"
90
+ AGE_H=$(( (NOW - OLDEST_TS) / 3600 ))
91
+ echo "Oldest unreleased commit age: ${AGE_H}h (threshold ${THRESHOLD}h)"
92
+
93
+ if [ "$AGE_H" -lt "$THRESHOLD" ]; then
94
+ echo "Under threshold. Skipping."
95
+ exit 0
96
+ fi
97
+
98
+ REPO_NAME="${GITHUB_REPOSITORY##*/}"
99
+ DAYS=$(( AGE_H / 24 ))
100
+ if [ -n "$LAST_TAG" ]; then
101
+ LINK="https://github.com/${GITHUB_REPOSITORY}/compare/${LAST_TAG}...main"
102
+ TAG_LABEL="\`${LAST_TAG}\`"
103
+ else
104
+ LINK="https://github.com/${GITHUB_REPOSITORY}/commits/main"
105
+ TAG_LABEL="_never released_"
106
+ fi
107
+
108
+ # Oldest-first sample of what is waiting, capped at 5 lines.
109
+ SAMPLE="$(tail -5 /tmp/unreleased.tsv | tac | cut -f3 | sed 's/^/• /')"
110
+ if [ "$COUNT" -gt 5 ]; then
111
+ SAMPLE="${SAMPLE}"$'\n'"• …and $(( COUNT - 5 )) more"
112
+ fi
113
+
114
+ TEXT=":package: *\`${REPO_NAME}\`: ${COUNT} commit(s) have been sitting unreleased on \`main\` for ~${AGE_H}h (${DAYS}d).* Last stable release: ${TAG_LABEL} — prereleases don't count. Shipping is one command: \`git push origin v<next>\`. <${LINK}|See what's waiting>."$'\n'"${SAMPLE}"
115
+
116
+ RESP="$(curl -s -X POST \
117
+ -H "Authorization: Bearer ${SLACK_TOKEN}" \
118
+ -H 'Content-type: application/json; charset=utf-8' \
119
+ --data "$(jq -nc --arg c "$SLACK_CHANNEL" --arg t "$TEXT" '{channel:$c,text:$t,unfurl_links:false}')" \
120
+ https://slack.com/api/chat.postMessage)"
121
+ OK="$(echo "$RESP" | jq -r '.ok')"
122
+ echo "Slack post ok=${OK} err=$(echo "$RESP" | jq -r '.error // "-"')"
123
+ if [ "$OK" != "true" ]; then
124
+ echo "::error::Slack rejected the nag: $(echo "$RESP" | jq -r '.error // "unknown"')"
125
+ exit 1
126
+ fi
127
+
128
+ pin-lag:
129
+ name: Nag when consumers lag the latest hq-cloud release
130
+ # hq-cloud ships the sync runner that hq-cli and hq-desktop-app spawn. A
131
+ # release nobody has pinned is a release nobody is running, so this job
132
+ # compares the newest STABLE hq-cloud tag against the version each
133
+ # consumer's `main` actually declares:
134
+ #
135
+ # hq-cli pnpm-lock.yaml -> resolved @indigoai-us/hq-cloud version
136
+ # hq-desktop-app crates/hq-desktop-core/src/hq_cloud.rs -> HQ_CLOUD_VERSION
137
+ #
138
+ # Comparison is EXACT on the version string (the desktop pin's leading `~`
139
+ # is stripped first). A tilde range like `~6.14.47` does resolve to 6.14.48
140
+ # at npx spawn time, so this will flag a pin that is already functionally
141
+ # current — that is deliberate: the written pin is kept literally in step
142
+ # with the latest release.
143
+ #
144
+ # Reading the two sibling repos needs a cross-repo token, so this mints an
145
+ # installation token from the hq-audit-bot App (same pattern as
146
+ # hq-core's promote-to-hq-core.yml). The App must be installed on
147
+ # indigoai-us/hq-cli and indigoai-us/hq-desktop-app with `contents: read`.
148
+ if: github.repository == 'indigoai-us/hq-cloud'
149
+ runs-on: ubuntu-latest
150
+ env:
151
+ SLACK_TOKEN: ${{ secrets.SLACK_BOT_TOKEN_RELEASE_MANAGER }}
152
+ SLACK_CHANNEL: '#hq-dev'
153
+ HQ_AUDIT_BOT_APP_ID: ${{ secrets.HQ_AUDIT_BOT_APP_ID }}
154
+ HQ_AUDIT_BOT_PRIVATE_KEY: ${{ secrets.HQ_AUDIT_BOT_PRIVATE_KEY }}
155
+ steps:
156
+ - name: Check out full history and tags
157
+ uses: actions/checkout@v4
158
+ with:
159
+ ref: main
160
+ fetch-depth: 0
161
+
162
+ - name: Preflight credentials
163
+ id: pre
164
+ run: |
165
+ set -euo pipefail
166
+ if [ -z "${SLACK_TOKEN:-}" ]; then
167
+ echo "::warning::SLACK_BOT_TOKEN_RELEASE_MANAGER is not set on this repository; cannot post the nag."
168
+ echo "ready=false" >> "$GITHUB_OUTPUT"
169
+ exit 0
170
+ fi
171
+ if [ -z "${HQ_AUDIT_BOT_APP_ID:-}" ] || [ -z "${HQ_AUDIT_BOT_PRIVATE_KEY:-}" ]; then
172
+ echo "::warning::hq-audit-bot App credentials are not set; cannot read hq-cli / hq-desktop-app main."
173
+ echo "ready=false" >> "$GITHUB_OUTPUT"
174
+ exit 0
175
+ fi
176
+ echo "ready=true" >> "$GITHUB_OUTPUT"
177
+
178
+ - name: Mint installation token for the consumer repos
179
+ id: app-token
180
+ if: steps.pre.outputs.ready == 'true'
181
+ uses: actions/create-github-app-token@v1
182
+ with:
183
+ app-id: ${{ env.HQ_AUDIT_BOT_APP_ID }}
184
+ private-key: ${{ env.HQ_AUDIT_BOT_PRIVATE_KEY }}
185
+ owner: indigoai-us
186
+ repositories: |
187
+ hq-cli
188
+ hq-desktop-app
189
+
190
+ - name: Compare consumer pins against the latest hq-cloud release
191
+ if: steps.pre.outputs.ready == 'true'
192
+ env:
193
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
194
+ run: |
195
+ set -euo pipefail
196
+
197
+ git fetch --force --tags --quiet origin main
198
+
199
+ LATEST_TAG="$(git tag --list 'v*' --merged origin/main --sort=-v:refname \
200
+ | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)"
201
+ if [ -z "$LATEST_TAG" ]; then
202
+ echo "No stable hq-cloud release yet; nothing to compare against."
203
+ exit 0
204
+ fi
205
+ LATEST="${LATEST_TAG#v}"
206
+ echo "Latest stable hq-cloud release: ${LATEST}"
207
+
208
+ # --- hq-cli: resolved version in the lockfile's importers section ---
209
+ gh api "repos/indigoai-us/hq-cli/contents/pnpm-lock.yaml?ref=main" \
210
+ -H 'Accept: application/vnd.github.raw' > /tmp/hq-cli-lock.yaml
211
+ CLI_PIN="$(grep -A2 "^ '@indigoai-us/hq-cloud':" /tmp/hq-cli-lock.yaml \
212
+ | awk '/^ version:/{print $2; exit}' | sed 's/(.*//')"
213
+ if [ -z "$CLI_PIN" ]; then
214
+ echo "::error::Could not read the resolved @indigoai-us/hq-cloud version from hq-cli's pnpm-lock.yaml. The lockfile layout changed — fix this parser rather than letting the check pass silently."
215
+ exit 1
216
+ fi
217
+ echo "hq-cli pins: ${CLI_PIN}"
218
+
219
+ # --- hq-desktop-app: HQ_CLOUD_VERSION constant, tilde stripped ---
220
+ gh api "repos/indigoai-us/hq-desktop-app/contents/crates/hq-desktop-core/src/hq_cloud.rs?ref=main" \
221
+ -H 'Accept: application/vnd.github.raw' > /tmp/hq_cloud.rs
222
+ DESK_RAW="$(grep -oP 'HQ_CLOUD_VERSION\s*:\s*&str\s*=\s*"\K[^"]+' /tmp/hq_cloud.rs | head -1)"
223
+ if [ -z "$DESK_RAW" ]; then
224
+ echo "::error::Could not read HQ_CLOUD_VERSION from hq-desktop-app's crates/hq-desktop-core/src/hq_cloud.rs. The constant moved or was renamed — fix this parser rather than letting the check pass silently."
225
+ exit 1
226
+ fi
227
+ DESK_PIN="${DESK_RAW#\~}"
228
+ DESK_PIN="${DESK_PIN#^}"
229
+ echo "hq-desktop-app pins: ${DESK_RAW} (compared as ${DESK_PIN})"
230
+
231
+ LAGGARDS=""
232
+ if [ "$CLI_PIN" != "$LATEST" ]; then
233
+ LAGGARDS="${LAGGARDS}"$'\n'"• \`hq-cli\` is on \`${CLI_PIN}\` (pnpm-lock.yaml)"
234
+ fi
235
+ if [ "$DESK_PIN" != "$LATEST" ]; then
236
+ LAGGARDS="${LAGGARDS}"$'\n'"• \`hq-desktop-app\` is on \`${DESK_RAW}\` (HQ_CLOUD_VERSION)"
237
+ fi
238
+
239
+ if [ -z "$LAGGARDS" ]; then
240
+ echo "Both consumers are pinned to ${LATEST}. Nothing to nag about."
241
+ exit 0
242
+ fi
243
+
244
+ TEXT=":link: *hq-cloud \`${LATEST}\` is released but not pinned everywhere.* A runner nobody pins is a runner nobody runs:${LAGGARDS}"$'\n'"Bump the pin(s) to \`${LATEST}\` and ship. <https://github.com/indigoai-us/hq-cloud/releases/tag/${LATEST_TAG}|Release ${LATEST_TAG}>."
245
+
246
+ RESP="$(curl -s -X POST \
247
+ -H "Authorization: Bearer ${SLACK_TOKEN}" \
248
+ -H 'Content-type: application/json; charset=utf-8' \
249
+ --data "$(jq -nc --arg c "$SLACK_CHANNEL" --arg t "$TEXT" '{channel:$c,text:$t,unfurl_links:false}')" \
250
+ https://slack.com/api/chat.postMessage)"
251
+ OK="$(echo "$RESP" | jq -r '.ok')"
252
+ echo "Slack post ok=${OK} err=$(echo "$RESP" | jq -r '.error // "-"')"
253
+ if [ "$OK" != "true" ]; then
254
+ echo "::error::Slack rejected the nag: $(echo "$RESP" | jq -r '.error // "unknown"')"
255
+ exit 1
256
+ fi
@@ -10,9 +10,21 @@ import type { Direction, RunnerLoopDeps, RunnerPassOutcome } from "./sync-runner
10
10
  * can be unit-tested without a real host or a 10-minute wait.
11
11
  */
12
12
  export declare function adaptivePollMs(load1: number, cpuCount: number, floorMs?: number, ceilMs?: number): number;
13
+ /**
14
+ * Advance the cadence from its previous monotonic deadline, rather than from
15
+ * the end of a pass. A slow pass is allowed one immediate catch-up pass; the
16
+ * returned deadline is always the first future slot, so missed slots cannot
17
+ * turn into an unbounded burst.
18
+ */
19
+ export declare function advanceMonotonicDeadline(previousDueAt: number, now: number, intervalMs: number): {
20
+ nextDueAt: number;
21
+ delayMs: number;
22
+ };
13
23
  export interface ParsedLoopArgs {
14
24
  hqRoot: string;
15
25
  lockTimeoutSec?: number;
26
+ /** Resolved parser flag; environment policy is applied by resolveSkipPersonal. */
27
+ skipPersonal?: boolean;
16
28
  /**
17
29
  * The run's resolved sync direction, straight from `parseArgs` (the single
18
30
  * source of truth — same default as the parser). The drain reads this to
@@ -1 +1 @@
1
- {"version":3,"file":"sync-runner-watch-loop.d.ts","sourceRoot":"","sources":["../../src/bin/sync-runner-watch-loop.ts"],"names":[],"mappings":"AAwBA,OAAO,EAIL,WAAW,EAEZ,MAAM,eAAe,CAAC;AAWvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAU9D,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,iBAAiB,EAElB,MAAM,kBAAkB,CAAC;AAiB1B;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,MAA+B,EACxC,MAAM,GAAE,MAA8B,GACrC,MAAM,CAKR;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,mCAAmC,EAAE,CACnC,QAAQ,EAAE,MAAM,EAAE,KACf,OAAO,CAAC,MAAM,GAAG,iBAAiB,CAAC,CAAC;IACzC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,uBAAuB,EAAE,MAAM,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,eAAe,CAAC,GAAG,IAAI,CAAC;IAC7E,qBAAqB,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,OAAO,CAAC,CAAC;AAE5D;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,WAAW,GAC3B,KAAK,CAAC,CAAC,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,CASvC;AAWD;;;;;;;;;;;;GAYG;AACH,wBAAgB,oCAAoC,CAClD,eAAe,EAAE,MAAM,EACvB,cAAc,EAAE,MAAM,GACrB,OAAO,CAIT;AAuBD,wBAAsB,2BAA2B,CAC/C,IAAI,EAAE,MAAM,EAAE,EACd,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,MAAM,CAAC,CAgBjB;AAED,wBAAsB,YAAY,CAChC,IAAI,EAAE,MAAM,EAAE,EACd,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,MAAM,CAAC,CAw2BjB"}
1
+ {"version":3,"file":"sync-runner-watch-loop.d.ts","sourceRoot":"","sources":["../../src/bin/sync-runner-watch-loop.ts"],"names":[],"mappings":"AAyBA,OAAO,EAIL,WAAW,EAEZ,MAAM,eAAe,CAAC;AAWvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAW9D,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,iBAAiB,EAElB,MAAM,kBAAkB,CAAC;AAiB1B;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,MAA+B,EACxC,MAAM,GAAE,MAA8B,GACrC,MAAM,CAKR;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,aAAa,EAAE,MAAM,EACrB,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GACjB;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAYxC;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kFAAkF;IAClF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,mCAAmC,EAAE,CACnC,QAAQ,EAAE,MAAM,EAAE,KACf,OAAO,CAAC,MAAM,GAAG,iBAAiB,CAAC,CAAC;IACzC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,uBAAuB,EAAE,MAAM,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,eAAe,CAAC,GAAG,IAAI,CAAC;IAC7E,qBAAqB,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC,OAAO,CAAC,CAAC;AAE5D;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,QAAQ,GAAG,WAAW,GAC3B,KAAK,CAAC,CAAC,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,CASvC;AAWD;;;;;;;;;;;;GAYG;AACH,wBAAgB,oCAAoC,CAClD,eAAe,EAAE,MAAM,EACvB,cAAc,EAAE,MAAM,GACrB,OAAO,CAIT;AAuBD,wBAAsB,2BAA2B,CAC/C,IAAI,EAAE,MAAM,EAAE,EACd,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,MAAM,CAAC,CAgBjB;AAED,wBAAsB,YAAY,CAChC,IAAI,EAAE,MAAM,EAAE,EACd,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE,cAAc,EACpB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,MAAM,CAAC,CA49BjB"}
@@ -1,6 +1,7 @@
1
1
  import * as os from "os";
2
2
  import * as path from "path";
3
3
  import * as fs from "fs";
4
+ import { performance } from "node:perf_hooks";
4
5
  import { inventoryManagedSnapshots } from "../backup-prune.js";
5
6
  import { VaultClient } from "../index.js";
6
7
  import { pickCanonicalPersonEntity } from "../vault-client.js";
@@ -14,7 +15,7 @@ import { TreeWatcher, systemClock, } from "../watcher.js";
14
15
  import { clearLocalDeleteIntent, markLocalDeleteIntent, PERSONAL_VAULT_JOURNAL_SLUG, readJournal, writeJournal, } from "../journal.js";
15
16
  import { NoopPushReceiver, } from "../sync/push-receiver.js";
16
17
  import { resolveEventSync, startEventSync as defaultStartEventSync, } from "../sync/event-sync.js";
17
- import { buildScopedDrainPullArgv, buildScopedPushArgv, buildTargetedPullArgv, routeChangeToTarget, routeKey, } from "./sync-runner-watch-routes.js";
18
+ import { buildScopedDrainPullArgv, buildScopedPushArgv, buildTargetedPullArgv, isWatchRouteSelected, routeChangeToTarget, routeKey, } from "./sync-runner-watch-routes.js";
18
19
  import { resolveSkipCompanies } from "./sync-runner-planning.js";
19
20
  const FULL_RECONCILE_EVERY_TICKS = 10;
20
21
  /**
@@ -43,6 +44,24 @@ export function adaptivePollMs(load1, cpuCount, floorMs = ADAPTIVE_POLL_FLOOR_MS
43
44
  const ratio = Math.max(0, Math.min(1, saturation));
44
45
  return Math.round(floorMs + ratio * (ceilMs - floorMs));
45
46
  }
47
+ /**
48
+ * Advance the cadence from its previous monotonic deadline, rather than from
49
+ * the end of a pass. A slow pass is allowed one immediate catch-up pass; the
50
+ * returned deadline is always the first future slot, so missed slots cannot
51
+ * turn into an unbounded burst.
52
+ */
53
+ export function advanceMonotonicDeadline(previousDueAt, now, intervalMs) {
54
+ let nextDueAt = previousDueAt + intervalMs;
55
+ if (nextDueAt > now) {
56
+ return { nextDueAt, delayMs: nextDueAt - now };
57
+ }
58
+ // The pass crossed at least one deadline. Run exactly one catch-up now,
59
+ // then retain only the first future cadence slot.
60
+ do {
61
+ nextDueAt += intervalMs;
62
+ } while (nextDueAt <= now);
63
+ return { nextDueAt, delayMs: 0 };
64
+ }
46
65
  /**
47
66
  * Select journal entries covered by one watcher delete. Exact file unlinks
48
67
  * deliberately avoid enumeration; only directory unlinks expand descendants.
@@ -116,6 +135,7 @@ export async function runOneShotWithOperationLock(argv, parsed, deps, runtime) {
116
135
  export async function runWatchLoop(argv, parsed, deps, runtime) {
117
136
  const sleep = deps.sleep ??
118
137
  ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
138
+ const monotonicNow = deps.monotonicNow ?? (() => performance.now());
119
139
  const runPass = deps.runPass ?? runtime.runPassWithOperationLockAlreadyHeld;
120
140
  const pollIdx = argv.indexOf("--poll-remote-ms");
121
141
  // An explicit --poll-remote-ms pins a fixed interval (menubar/CLI still set
@@ -126,6 +146,21 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
126
146
  const cpuCount = os.cpus().length;
127
147
  const eventPush = argv.includes("--event-push");
128
148
  const companiesMode = argv.includes("--companies");
149
+ const companyMode = argv.includes("--company");
150
+ const personalMode = argv.includes("--personal");
151
+ // Keep this at the watcher ingress rather than importing the runner's
152
+ // resolver: sync-runner imports this loop, and a reverse value import would
153
+ // create a runtime cycle. These are the parser's documented truthy values.
154
+ const skipPersonalEnv = (process.env.HQ_SYNC_SKIP_PERSONAL ?? "").toLowerCase();
155
+ const watchRouteSelection = {
156
+ companies: companiesMode,
157
+ company: companyMode,
158
+ personal: personalMode,
159
+ skipPersonal: parsed.skipPersonal === true ||
160
+ skipPersonalEnv === "1" ||
161
+ skipPersonalEnv === "true" ||
162
+ skipPersonalEnv === "yes",
163
+ };
129
164
  const hqRoot = parsed.hqRoot;
130
165
  // Delayed and daily: this must never be attached to the 15s poll cadence.
131
166
  // Stage 0 remains dry-run only; the standalone hq-backup-prune command is
@@ -220,6 +255,45 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
220
255
  }
221
256
  };
222
257
  let stopped = false;
258
+ let resolveStopped = null;
259
+ const stoppedSignal = new Promise((resolve) => {
260
+ resolveStopped = resolve;
261
+ });
262
+ // A signal can arrive while a guarded pass is running, before the next wait
263
+ // has registered. Keep a monotonic generation so that event is retained and
264
+ // consumed by the next scheduler wait instead of being lost.
265
+ let interruptGeneration = 0;
266
+ let observedInterruptGeneration = 0;
267
+ let resolveInterruptWait = null;
268
+ const interruptWait = () => {
269
+ if (stopped)
270
+ return;
271
+ interruptGeneration += 1;
272
+ const resolve = resolveInterruptWait;
273
+ resolveInterruptWait = null;
274
+ resolve?.();
275
+ };
276
+ const waitForNextPass = async (delayMs) => {
277
+ if (interruptGeneration !== observedInterruptGeneration) {
278
+ observedInterruptGeneration = interruptGeneration;
279
+ return;
280
+ }
281
+ await Promise.race([
282
+ sleep(delayMs),
283
+ new Promise((resolve) => {
284
+ resolveInterruptWait = resolve;
285
+ // Do not lose an event that arrived between the first generation
286
+ // check and registration of this wait.
287
+ if (interruptGeneration !== observedInterruptGeneration) {
288
+ resolveInterruptWait = null;
289
+ resolve();
290
+ }
291
+ }),
292
+ stoppedSignal,
293
+ ]);
294
+ resolveInterruptWait = null;
295
+ observedInterruptGeneration = interruptGeneration;
296
+ };
223
297
  let activePass = null;
224
298
  const pendingPasses = [];
225
299
  const resolveStoppedQueue = () => {
@@ -269,11 +343,45 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
269
343
  const pendingWatcherPaths = new Map();
270
344
  const pendingWatcherChanges = new Map();
271
345
  let pendingWatcherOriginalBatch = null;
346
+ // Every coalesced watcher batch gets one durable in-process generation.
347
+ // A wake can cause the scheduler to revisit a drained batch, but an event
348
+ // announcement is an external side effect and must occur at most once for
349
+ // that batch after its targeted push succeeds.
350
+ let nextWatcherBatchGeneration = 0;
351
+ let pendingWatcherBatchGeneration = null;
352
+ const lastPublishedWatcherBatchGenerationByRoute = new Map();
272
353
  let pendingWatcherBareChange = false;
273
354
  let pendingWatcherOverflowed = false;
274
355
  let pendingWatcherDroppedPaths = 0;
275
356
  let pendingWatcherDroppedBytes = 0;
276
357
  const addPendingWatcherChange = (changedRelPath, batch) => {
358
+ if (batch) {
359
+ const paths = new Map();
360
+ const changes = new Map();
361
+ for (const [absolutePath, relativePath] of batch.paths.entries()) {
362
+ const route = routeChangeToTarget(relativePath);
363
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection))
364
+ continue;
365
+ paths.set(absolutePath, relativePath);
366
+ const change = batch.changes?.get(absolutePath);
367
+ if (change)
368
+ changes.set(absolutePath, change);
369
+ }
370
+ if (paths.size === 0)
371
+ return false;
372
+ batch = {
373
+ ...batch,
374
+ paths,
375
+ changes,
376
+ };
377
+ }
378
+ else if (changedRelPath) {
379
+ const route = routeChangeToTarget(changedRelPath);
380
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection)) {
381
+ return false;
382
+ }
383
+ }
384
+ pendingWatcherBatchGeneration ??= ++nextWatcherBatchGeneration;
277
385
  if (batch) {
278
386
  pendingWatcherOriginalBatch =
279
387
  pendingWatcherPaths.size === 0 &&
@@ -293,7 +401,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
293
401
  pendingWatcherDroppedPaths += batch.droppedPaths ?? 0;
294
402
  pendingWatcherDroppedBytes += batch.droppedBytes ?? 0;
295
403
  }
296
- return;
404
+ return true;
297
405
  }
298
406
  if (changedRelPath) {
299
407
  pendingWatcherOriginalBatch = null;
@@ -307,10 +415,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
307
415
  // a prior batch left for this path so the stale intent cannot leak
308
416
  // into the coalesced batch (#227).
309
417
  pendingWatcherChanges.delete(absolutePath);
310
- return;
418
+ return true;
311
419
  }
312
420
  pendingWatcherOriginalBatch = null;
313
421
  pendingWatcherBareChange = true;
422
+ return true;
314
423
  };
315
424
  const takePendingWatcherChange = () => {
316
425
  const batch = pendingWatcherOriginalBatch !== null && !pendingWatcherOverflowed
@@ -331,6 +440,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
331
440
  }
332
441
  : null;
333
442
  const bare = pendingWatcherBareChange;
443
+ const generation = pendingWatcherBatchGeneration;
334
444
  pendingWatcherPaths.clear();
335
445
  pendingWatcherChanges.clear();
336
446
  pendingWatcherOriginalBatch = null;
@@ -338,9 +448,12 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
338
448
  pendingWatcherOverflowed = false;
339
449
  pendingWatcherDroppedPaths = 0;
340
450
  pendingWatcherDroppedBytes = 0;
341
- return { batch, bare };
451
+ pendingWatcherBatchGeneration = null;
452
+ return { batch, bare, generation };
342
453
  };
343
- const restorePendingWatcherPaths = (paths) => {
454
+ const restorePendingWatcherPaths = (paths, generation) => {
455
+ pendingWatcherBatchGeneration ??=
456
+ generation ?? ++nextWatcherBatchGeneration;
344
457
  pendingWatcherOriginalBatch = null;
345
458
  for (const [absolutePath, relativePath] of paths.entries()) {
346
459
  pendingWatcherPaths.set(absolutePath, relativePath);
@@ -382,7 +495,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
382
495
  };
383
496
  const journalCoordinates = (relativePath) => {
384
497
  const route = routeChangeToTarget(relativePath);
385
- if (!route)
498
+ if (!route || !isWatchRouteSelected(route, watchRouteSelection))
386
499
  return null;
387
500
  if (route.kind === "personal") {
388
501
  return { slug: PERSONAL_VAULT_JOURNAL_SLUG, key: relativePath };
@@ -572,9 +685,11 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
572
685
  const publishBatchIfAvailable = (paths) => {
573
686
  if (!stopped && eventSync && paths.size > 0) {
574
687
  eventSync.publishBatch({ paths: new Map(paths) });
688
+ return true;
575
689
  }
690
+ return false;
576
691
  };
577
- const runScopedDrain = async (batch) => {
692
+ const runScopedDrain = async (batch, watcherBatchGeneration) => {
578
693
  if (drainRunsPush && batch && batch.paths.size > 0) {
579
694
  const skipCompanies = resolveSkipCompanies();
580
695
  const grouped = groupBatchByRoute(batch);
@@ -596,21 +711,28 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
596
711
  : (result.result?.pushPathResults ?? [])
597
712
  .filter((pathResult) => pathResult.status === "refused")
598
713
  .map((pathResult) => pathResult.relativePath));
599
- publishBatchIfAvailable(new Map([...group.paths.entries()].filter(([absolutePath, relativePath]) => {
600
- if (refusedPaths.has(relativePath))
601
- return false;
602
- if (group.changes.get(absolutePath)?.kind !== "unlinkDir") {
603
- return true;
714
+ const route = routeKey(group.route);
715
+ const generation = watcherBatchGeneration ?? 0;
716
+ if (lastPublishedWatcherBatchGenerationByRoute.get(route) !== generation) {
717
+ const published = publishBatchIfAvailable(new Map([...group.paths.entries()].filter(([absolutePath, relativePath]) => {
718
+ if (refusedPaths.has(relativePath))
719
+ return false;
720
+ if (group.changes.get(absolutePath)?.kind !== "unlinkDir") {
721
+ return true;
722
+ }
723
+ return ![...refusedPaths].some((refusedPath) => refusedPath.startsWith(`${relativePath}/`));
724
+ })));
725
+ if (published) {
726
+ lastPublishedWatcherBatchGenerationByRoute.set(route, generation);
604
727
  }
605
- return ![...refusedPaths].some((refusedPath) => refusedPath.startsWith(`${relativePath}/`));
606
- })));
728
+ }
607
729
  }
608
730
  else {
609
- restorePendingWatcherPaths(group.paths);
731
+ restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
610
732
  }
611
733
  }
612
734
  catch (err) {
613
- restorePendingWatcherPaths(group.paths);
735
+ restorePendingWatcherPaths(group.paths, watcherBatchGeneration);
614
736
  process.stderr.write(`watch scoped push failed, will retry next tick: ${describeError(err)}\n`);
615
737
  }
616
738
  }
@@ -654,12 +776,18 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
654
776
  watcher.onChange((changedRelPath, batch) => {
655
777
  if (stopped)
656
778
  return;
657
- addPendingWatcherChange(changedRelPath, batch);
779
+ if (addPendingWatcherChange(changedRelPath, batch))
780
+ interruptWait();
658
781
  });
659
782
  watcher.start();
660
783
  const receiverSyncFn = async (ctx) => {
661
784
  if (stopped)
662
785
  return;
786
+ // A received wake/high-water signal means there is bounded work now.
787
+ // It wakes the scheduler but is still funneled through runGuarded below,
788
+ // so it cannot overlap the pass that may currently own the operation
789
+ // lock.
790
+ interruptWait();
663
791
  const route = routeChangeToTarget(ctx.event.relativePath);
664
792
  if (!route)
665
793
  return;
@@ -672,8 +800,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
672
800
  const createReceiver = deps.createReceiver ?? (() => new NoopPushReceiver());
673
801
  receiver = createReceiver({ syncFn: receiverSyncFn, hqRoot });
674
802
  void Promise.resolve(receiver.start()).catch(() => undefined);
675
- const email = claims?.email;
676
- if (resolveEventSync(email, process.env.HQ_SYNC_EVENT_SYNC)) {
803
+ if (resolveEventSync(process.env.HQ_SYNC_EVENT_SYNC)) {
677
804
  const startES = deps.startEventSync ?? defaultStartEventSync;
678
805
  void (async () => {
679
806
  const eventSyncClient = new VaultClient({
@@ -711,10 +838,6 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
711
838
  });
712
839
  }
713
840
  }
714
- let resolveStopped = null;
715
- const stoppedSignal = new Promise((resolve) => {
716
- resolveStopped = resolve;
717
- });
718
841
  const shutdown = () => {
719
842
  if (stopped)
720
843
  return;
@@ -752,6 +875,9 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
752
875
  };
753
876
  });
754
877
  detachSignal = onShutdownSignal(shutdown);
878
+ // The initial reconcile is due immediately. Later passes advance from this
879
+ // deadline, not from pass completion, so cadence stays monotonic.
880
+ let nextDueAt = monotonicNow();
755
881
  let pollTick = 0;
756
882
  try {
757
883
  while (!stopped) {
@@ -772,7 +898,7 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
772
898
  pollTick % FULL_RECONCILE_EVERY_TICKS === 0;
773
899
  const result = shouldRunFull
774
900
  ? await runGuarded(passArgv)
775
- : await runScopedDrain(pending.batch);
901
+ : await runScopedDrain(pending.batch, pending.generation);
776
902
  // This batch's episode has now been prepared and pushed, so a later
777
903
  // removal under the same path is judged fresh — a root that never
778
904
  // expired would silently refuse ordinary deletes from here on.
@@ -829,7 +955,9 @@ export async function runWatchLoop(argv, parsed, deps, runtime) {
829
955
  }
830
956
  }
831
957
  const nextPollMs = explicitPollMs ?? adaptivePollMs(sampleLoadAvg(), cpuCount);
832
- await Promise.race([sleep(nextPollMs), stoppedSignal]);
958
+ const schedule = advanceMonotonicDeadline(nextDueAt, monotonicNow(), nextPollMs);
959
+ nextDueAt = schedule.nextDueAt;
960
+ await waitForNextPass(schedule.delayMs);
833
961
  }
834
962
  return 0;
835
963
  }