oysterun 1.6.0-beta.2 → 1.6.0-beta.21

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 (242) hide show
  1. package/bin/oysterun.mjs +64 -17
  2. package/dev/client/connectivity/completed-message-neighbor-click.mjs +78 -0
  3. package/dev/client/connectivity/completed-message-neighbor-contract.mjs +205 -0
  4. package/dev/client/connectivity/completed-message-visibility.d.mts +4 -4
  5. package/dev/client/connectivity/completed-message-visibility.mjs +14 -11
  6. package/dev/client/connectivity/desktop-chat-sidebar-preference.mjs +57 -0
  7. package/dev/client/connectivity/host-connection-feedback.mjs +12 -1
  8. package/dev/client/connectivity/managed-host-browser.mjs +10 -0
  9. package/dev/client/connectivity/session-navigation-intent.mjs +189 -0
  10. package/dev/client/connectivity/session-transition-state.mjs +18 -6
  11. package/dev/client/connectivity/test-completed-message-neighbor-click.mjs +180 -0
  12. package/dev/client/web/index.html +5367 -609
  13. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js → ReactPrism-C6YiTZTC.js} +1 -1
  14. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js.map → ReactPrism-C6YiTZTC.js.map} +1 -1
  15. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js +6442 -0
  16. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js.map +1 -0
  17. package/dev/client/web-chat/dist/assets/SessionsSetupApp-Dx09Uz-6.css +1 -0
  18. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js +9 -0
  19. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js.map +1 -0
  20. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js +2 -0
  21. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js.map +1 -0
  22. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js → index-DCQFwgGf.js} +2 -2
  23. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js.map → index-DCQFwgGf.js.map} +1 -1
  24. package/dev/client/web-chat/dist/assets/matrix-entry-BV7G38kH.css +10 -0
  25. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js +594 -0
  26. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js.map +1 -0
  27. package/dev/client/web-chat/dist/assets/{pdf-ZjtalfgI.js → pdf-DN7oVvM6.js} +5 -5
  28. package/dev/client/web-chat/dist/assets/pdf-DN7oVvM6.js.map +1 -0
  29. package/dev/client/web-chat/dist/assets/session-notice-DlyODy1z.css +1 -0
  30. package/dev/client/web-chat/dist/assets/web-BlYuDlOV.js +1 -0
  31. package/dev/client/web-chat/dist/assets/{web-c70YaskK.js.map → web-BlYuDlOV.js.map} +1 -1
  32. package/dev/client/web-chat/dist/assets/web-Dabw6cJi.js +1 -0
  33. package/dev/client/web-chat/dist/assets/{web-BzHcZK2M.js.map → web-Dabw6cJi.js.map} +1 -1
  34. package/dev/client/web-chat/dist/index.html +1 -2
  35. package/docs/routec/dev/cloud_deplyments.md +5 -0
  36. package/docs/routec/dev/npm_native_dependency_install.md +6 -2
  37. package/docs/routec/dev/npmjs_publish.md +55 -19
  38. package/docs/routec/dev/p262_setup_input_file.md +117 -0
  39. package/docs/routec/host2_turn_started_idempotency_diagnostics.md +8 -7
  40. package/docs/routec/host_setup.md +39 -10
  41. package/docs/routec/oysterun_operation_interface_spec.md +78 -2
  42. package/docs/routec/provider_contract.md +35 -5
  43. package/host-service/README.md +36 -0
  44. package/host-service/adapters/claude-code-adapter.mjs +48 -5
  45. package/host-service/adapters/codex-app-server-adapter.mjs +320 -18
  46. package/host-service/adapters/debug-fixture-provider-adapter.mjs +102 -1
  47. package/host-service/adapters/debug-large-tool-spillover-provider-adapter.mjs +2 -0
  48. package/host-service/adapters/debug-p135-codex-replay-provider-adapter.mjs +2 -0
  49. package/host-service/adapters/debug-routec-structural-replay-provider-adapter.mjs +2 -0
  50. package/host-service/agent-config.mjs +16 -5
  51. package/host-service/agent-folder-availability.mjs +41 -0
  52. package/host-service/apns-complete-message-dispatcher.mjs +1 -0
  53. package/host-service/assets/product-skills/Oysterun/modules/oysterun-find-context/SKILL.md +24 -0
  54. package/host-service/assets/product-skills/Oysterun/modules/oysterun-session-chat/SKILL.md +94 -5
  55. package/host-service/assets/product-skills/Oysterun/modules/oysterun-sessions/SKILL.md +103 -0
  56. package/host-service/assets/product-skills/Oysterun/modules/oysterun-website/SKILL.md +7 -0
  57. package/host-service/assets/product-skills/oysterun-find-context/SKILL.md +24 -0
  58. package/host-service/assets/product-skills/oysterun-session-chat/SKILL.md +94 -5
  59. package/host-service/assets/product-skills/oysterun-sessions/SKILL.md +103 -0
  60. package/host-service/assets/product-skills/oysterun-website/SKILL.md +7 -0
  61. package/host-service/callback-durable-store.mjs +354 -47
  62. package/host-service/callback-foundation.mjs +21 -1
  63. package/host-service/callback-host-integration.mjs +345 -30
  64. package/host-service/claude-acp-model-discovery.mjs +23 -18
  65. package/host-service/cli/assert-installer-reinstall-target.mjs +9 -0
  66. package/host-service/cli/installer-recovery-guidance.mjs +20 -0
  67. package/host-service/cli/prepare-restart-restore.mjs +2 -0
  68. package/host-service/cli/product-cli.mjs +34 -33
  69. package/host-service/cli/reconcile-initial-preflight.mjs +19 -0
  70. package/host-service/cli/recover-installer-lifecycle.mjs +24 -0
  71. package/host-service/cli/run-host-lifecycle.mjs +2 -0
  72. package/host-service/cli/run-staged-lifecycle-recovery.mjs +161 -0
  73. package/host-service/codex-model-discovery.mjs +23 -18
  74. package/host-service/complete-cli-uninstall.mjs +176 -0
  75. package/host-service/complete-message-notification-predicate.mjs +8 -6
  76. package/host-service/complete-message-notification-title.mjs +4 -0
  77. package/host-service/completed-session-unread.mjs +425 -42
  78. package/host-service/completed-turn-navigation.mjs +444 -144
  79. package/host-service/completed-turn-notification-observer.mjs +116 -0
  80. package/host-service/completed-turn-service.mjs +445 -0
  81. package/host-service/config.mjs +115 -9
  82. package/host-service/desired-open-session-state.mjs +331 -0
  83. package/host-service/fixtures/p218-completed-turn-notification.json +11 -0
  84. package/host-service/frp-agent.mjs +9 -9
  85. package/host-service/grok-acp-model-discovery.mjs +31 -33
  86. package/host-service/host-lifecycle-recovery.mjs +37 -0
  87. package/host-service/host-port-resolution.mjs +98 -0
  88. package/host-service/host-runtime-diagnostics.mjs +18 -1
  89. package/host-service/initial-preflight-recovery.mjs +71 -0
  90. package/host-service/installer-recovery-guidance.mjs +85 -0
  91. package/host-service/installer-reinstall-bindings.mjs +49 -0
  92. package/host-service/installer-reinstall-target.mjs +49 -0
  93. package/host-service/legacy-continuity-observation.mjs +33 -0
  94. package/host-service/lifecycle-compat-cli.mjs +13 -0
  95. package/host-service/lifecycle-compat-preload.mjs +89 -0
  96. package/host-service/lifecycle-compatibility.mjs +164 -0
  97. package/host-service/lifecycle-continuity-assets.mjs +222 -0
  98. package/host-service/lifecycle-continuity-probe.mjs +97 -0
  99. package/host-service/lifecycle-continuity-projection.mjs +29 -0
  100. package/host-service/lifecycle-continuity-runner.mjs +192 -0
  101. package/host-service/lifecycle-continuity.mjs +493 -0
  102. package/host-service/lifecycle-service-render-bundle.mjs +120 -0
  103. package/host-service/lifecycle-user-path.mjs +34 -0
  104. package/host-service/link-annotations.mjs +10 -0
  105. package/host-service/matrix-credential-issuer.mjs +32 -1
  106. package/host-service/matrix-event-correlator.mjs +166 -27
  107. package/host-service/matrix-facade.mjs +96 -19
  108. package/host-service/matrix-room-binding.mjs +24 -0
  109. package/host-service/matrix-transcript-read-adapter.mjs +17 -3
  110. package/host-service/model-discovery-guardian.mjs +40 -0
  111. package/host-service/model-discovery-process.mjs +141 -0
  112. package/host-service/onboard-lifecycle-status.mjs +28 -0
  113. package/host-service/onboard.mjs +479 -0
  114. package/host-service/package.json +2 -1
  115. package/host-service/portable-scheduler-definitions.mjs +28 -0
  116. package/host-service/product-runtime-host-origin.mjs +22 -0
  117. package/host-service/provider-model-params.mjs +14 -17
  118. package/host-service/provider-model-refresh-runner.mjs +124 -169
  119. package/host-service/provider-model-refresh-schedule.mjs +11 -0
  120. package/host-service/provider-resume-contract.mjs +9 -3
  121. package/host-service/restart-restore-state.mjs +25 -0
  122. package/host-service/retained-host-state-holder.mjs +57 -0
  123. package/host-service/routec-matrix-sqlite-store.mjs +155 -10
  124. package/host-service/routec-matrix-storage-adapter.mjs +208 -25
  125. package/host-service/routec-matrix-storage-paths.mjs +12 -0
  126. package/host-service/routec-request-delay-window.mjs +55 -0
  127. package/host-service/scheduler-acp-provider-runner.mjs +2 -1
  128. package/host-service/scheduler-direct-provider-runner.mjs +71 -9
  129. package/host-service/scheduler-run-timeout-policy.mjs +85 -0
  130. package/host-service/scheduler-service.mjs +96 -9
  131. package/host-service/scheduler-store.mjs +9 -2
  132. package/host-service/server.mjs +2202 -552
  133. package/host-service/service-definition-migration.mjs +10 -3
  134. package/host-service/session-collection-service.mjs +320 -0
  135. package/host-service/session-history.mjs +99 -15
  136. package/host-service/session-list-preview-service.mjs +87 -12
  137. package/host-service/session-list-projection-service.mjs +182 -0
  138. package/host-service/session-loop-definitions.mjs +702 -75
  139. package/host-service/session-manager.mjs +473 -38
  140. package/host-service/session-switcher-projection.mjs +11 -3
  141. package/host-service/session-tags.mjs +451 -0
  142. package/host-service/session-transcript-target.mjs +103 -0
  143. package/host-service/setup-input-file.mjs +91 -0
  144. package/host-service/setup-provider-model-defaults.mjs +53 -0
  145. package/host-service/setup.mjs +133 -101
  146. package/host-service/stopped-user-host-package.mjs +60 -0
  147. package/host-service/test-fixtures/p236-navigation-original.mjs +500 -0
  148. package/host-service/update-reminder-state.mjs +7 -0
  149. package/host-service/voice-input-receipt-store.mjs +60 -0
  150. package/package.json +1 -1
  151. package/static/oysterun-social-preview.png +0 -0
  152. package/static/site/assets/oysterun-social-preview.png +0 -0
  153. package/static/site/blog/en/index.html +17 -0
  154. package/static/site/blog/generated_manifest.json +9 -9
  155. package/static/site/blog/index.html +18 -0
  156. package/static/site/blog/voice-input-model-experiment/en/index.html +17 -0
  157. package/static/site/blog/voice-input-model-experiment/zh/index.html +17 -0
  158. package/static/site/blog/zh/index.html +17 -0
  159. package/static/site/docs/choose-and-recover-a-provider/en/index.html +17 -0
  160. package/static/site/docs/choose-and-recover-a-provider/zh/index.html +17 -0
  161. package/static/site/docs/configure-session-files-and-settings/en/index.html +17 -0
  162. package/static/site/docs/configure-session-files-and-settings/zh/index.html +17 -0
  163. package/static/site/docs/en/index.html +17 -0
  164. package/static/site/docs/generated_manifest.json +60 -60
  165. package/static/site/docs/host-authentication-and-connection/en/index.html +17 -0
  166. package/static/site/docs/host-authentication-and-connection/zh/index.html +17 -0
  167. package/static/site/docs/index.html +18 -0
  168. package/static/site/docs/install-and-use-a-skill/en/index.html +17 -0
  169. package/static/site/docs/install-and-use-a-skill/zh/index.html +17 -0
  170. package/static/site/docs/read-large-tool-results/en/index.html +17 -0
  171. package/static/site/docs/read-large-tool-results/zh/index.html +17 -0
  172. package/static/site/docs/recover-from-a-host-runtime-error/en/index.html +17 -0
  173. package/static/site/docs/recover-from-a-host-runtime-error/zh/index.html +17 -0
  174. package/static/site/docs/restore-work-after-a-host-restart/en/index.html +17 -0
  175. package/static/site/docs/restore-work-after-a-host-restart/zh/index.html +17 -0
  176. package/static/site/docs/send-images-and-media/en/index.html +17 -0
  177. package/static/site/docs/send-images-and-media/zh/index.html +17 -0
  178. package/static/site/docs/set-up-and-understand-a-host/en/index.html +17 -0
  179. package/static/site/docs/set-up-and-understand-a-host/zh/index.html +17 -0
  180. package/static/site/docs/understand-agent-activity/en/index.html +19 -2
  181. package/static/site/docs/understand-agent-activity/en/index.md +2 -2
  182. package/static/site/docs/understand-agent-activity/zh/index.html +19 -2
  183. package/static/site/docs/understand-agent-activity/zh/index.md +2 -2
  184. package/static/site/docs/use-the-oysterun-cli-safely/en/index.html +17 -0
  185. package/static/site/docs/use-the-oysterun-cli-safely/zh/index.html +17 -0
  186. package/static/site/docs/zh/index.html +17 -0
  187. package/static/site/en/index.html +18 -1
  188. package/static/site/en/index.md +1 -1
  189. package/static/site/faq/en/index.html +17 -0
  190. package/static/site/faq/index.html +18 -0
  191. package/static/site/faq/zh/index.html +17 -0
  192. package/static/site/getting-started/en/index.html +23 -1
  193. package/static/site/getting-started/en/index.md +7 -1
  194. package/static/site/getting-started/index.html +18 -0
  195. package/static/site/getting-started/zh/index.html +23 -1
  196. package/static/site/getting-started/zh/index.md +7 -1
  197. package/static/site/index.html +18 -0
  198. package/static/site/install.sh +676 -127
  199. package/static/site/overview/en/index.html +18 -1
  200. package/static/site/overview/en/index.md +2 -2
  201. package/static/site/overview/index.html +18 -0
  202. package/static/site/overview/zh/index.html +18 -1
  203. package/static/site/overview/zh/index.md +2 -2
  204. package/static/site/privacy/en/index.html +17 -0
  205. package/static/site/privacy/index.html +18 -0
  206. package/static/site/privacy/zh/index.html +17 -0
  207. package/static/site/zh/index.html +18 -1
  208. package/static/site/zh/index.md +1 -1
  209. package/static/site_source/content/tutorials/published/understand-agent-activity/en/article.html +2 -2
  210. package/static/site_source/content/tutorials/published/understand-agent-activity/zh/article.html +2 -2
  211. package/static/site_source/downloads/install.sh +676 -127
  212. package/static/site_source/manifests/host_install.json +2 -10
  213. package/static/site_source/pages/getting-started/en.html +6 -1
  214. package/static/site_source/pages/getting-started/zh.html +6 -1
  215. package/static/site_source/pages/home/en.html +1 -1
  216. package/static/site_source/pages/home/zh.html +1 -1
  217. package/static/site_source/pages/home_main.html +3 -2
  218. package/static/site_source/pages/overview/en.html +1 -1
  219. package/static/site_source/pages/overview/zh.html +1 -1
  220. package/tool_scripts/install_oysterun_update.mjs +78 -13
  221. package/tool_scripts/oysterun_continuity_transaction.mjs +438 -0
  222. package/tool_scripts/oysterun_npm_package_manifest.mjs +32 -0
  223. package/tool_scripts/oysterun_release_service.sh +5 -0
  224. package/tool_scripts/oysterun_service_common.sh +59 -24
  225. package/tool_scripts/oysterun_setup_input.sh +97 -0
  226. package/tool_scripts/oysterun_update_restart_runner.mjs +19 -3
  227. package/tool_scripts/p233_legacy_bootstrap.mjs +254 -0
  228. package/tool_scripts/resolve_user_host_port.mjs +15 -0
  229. package/tool_scripts/restart_oysterun.sh +8 -2
  230. package/tool_scripts/stack_dashboard_credentials.mjs +2 -1
  231. package/tool_scripts/status_oysterun_launch_agents.sh +13 -1
  232. package/tool_scripts/sync_host_stack_config.mjs +9 -6
  233. package/tool_scripts/test_environment_remote_cloud_deprovision.mjs +419 -0
  234. package/tool_scripts/uninstall_oysterun_launch_agents.sh +3 -5
  235. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js +0 -129
  236. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js.map +0 -1
  237. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js +0 -2
  238. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js.map +0 -1
  239. package/dev/client/web-chat/dist/assets/index-NYnaSGBb.css +0 -1
  240. package/dev/client/web-chat/dist/assets/pdf-ZjtalfgI.js.map +0 -1
  241. package/dev/client/web-chat/dist/assets/web-BzHcZK2M.js +0 -1
  242. package/dev/client/web-chat/dist/assets/web-c70YaskK.js +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oysterun-host-service",
3
- "version": "1.6.0-beta.2",
3
+ "version": "1.6.0-beta.21",
4
4
  "type": "module",
5
5
  "description": "Oysterun host service — runs on Mac mini, manages Claude Code sessions",
6
6
  "scripts": {
@@ -45,6 +45,7 @@
45
45
  "test:p118": "node test-p118-exact-matrix-event-context.mjs",
46
46
  "test:p119": "node test-p119-telegram-multi-chat-origin-routing.mjs",
47
47
  "test:p208": "node --test test-p208-reasoning-effort-configuration-precedence.mjs",
48
+ "test:p220": "node test-p220-session-tags.mjs && node test-p220-session-tags-lifecycle.mjs",
48
49
  "test:p226": "node --test test-p226-lifecycle-diagnostics.mjs test-p226-service-definition-migration.mjs"
49
50
  },
50
51
  "dependencies": {
@@ -16,6 +16,7 @@ import {
16
16
  getHostSystemTimezone,
17
17
  normalizeScheduleRule,
18
18
  } from "./scheduler-rule-model.mjs";
19
+ import { resolveSchedulerRunTimeout } from "./scheduler-run-timeout-policy.mjs";
19
20
 
20
21
  export const PORTABLE_SCHEDULER_STORAGE_OWNER =
21
22
  "agent_folder_oysterun_schedulers_json";
@@ -200,6 +201,14 @@ function normalizePortableScheduleForFile(input, { now, agentFolder }) {
200
201
  const metadata = isObjectRecord(input.metadata)
201
202
  ? normalizePortableMetadata(input.metadata)
202
203
  : {};
204
+ const timeoutPresent = Object.prototype.hasOwnProperty.call(
205
+ input,
206
+ "run_timeout_seconds"
207
+ );
208
+ const timeout = resolveSchedulerRunTimeout(input.run_timeout_seconds, {
209
+ present: timeoutPresent,
210
+ source: normalizeOptionalString(input.run_timeout_source),
211
+ });
203
212
  return {
204
213
  id,
205
214
  agent_id: projectId,
@@ -217,6 +226,12 @@ function normalizePortableScheduleForFile(input, { now, agentFolder }) {
217
226
  setup_snapshot: setupSnapshot,
218
227
  portable_setup_snapshot: portableSetupSnapshot,
219
228
  metadata,
229
+ ...(timeoutPresent
230
+ ? {
231
+ run_timeout_seconds: timeout.seconds,
232
+ run_timeout_source: timeout.source,
233
+ }
234
+ : {}),
220
235
  created_at: createdAt,
221
236
  updated_at: normalizeOptionalString(input.updated_at) || now,
222
237
  storage_owner: PORTABLE_SCHEDULER_STORAGE_OWNER,
@@ -772,6 +787,16 @@ export class PortableSchedulerDefinitionStore {
772
787
  const validationFailed =
773
788
  definition.metadata?.portable_scheduler_validation_failed === true;
774
789
  const effectiveEnabled = definition.enabled === true;
790
+ const runTimeout = resolveSchedulerRunTimeout(
791
+ definition.run_timeout_seconds,
792
+ {
793
+ present: Object.prototype.hasOwnProperty.call(
794
+ definition,
795
+ "run_timeout_seconds"
796
+ ),
797
+ source: definition.run_timeout_source,
798
+ }
799
+ );
775
800
  const normalizedRule =
776
801
  validationFailed && !isObjectRecord(definition.schedule_rule)
777
802
  ? null
@@ -815,6 +840,9 @@ export class PortableSchedulerDefinitionStore {
815
840
  next_run_at: nextRunAt,
816
841
  availability_state: availabilityState,
817
842
  availability_reason: availabilityReason,
843
+ run_timeout_seconds: runTimeout.seconds,
844
+ run_timeout_source: runTimeout.source,
845
+ run_timeout_explicit: runTimeout.explicit,
818
846
  metadata: {
819
847
  ...definition.metadata,
820
848
  source: "browser_root_project_schedulers_json",
@@ -0,0 +1,22 @@
1
+ function normalizeOrigin(value) {
2
+ return typeof value === "string" ? value.trim().replace(/\/+$/, "") : "";
3
+ }
4
+
5
+ export function resolveProductRuntimeHostOrigin({
6
+ config,
7
+ directHostUrl,
8
+ port,
9
+ }) {
10
+ if (config?.connection_mode === "cloud") {
11
+ return normalizeOrigin(config.managed_endpoint) || `http://127.0.0.1:${port}`;
12
+ }
13
+ if (config?.connection_mode === "direct") {
14
+ return (
15
+ normalizeOrigin(directHostUrl) ||
16
+ normalizeOrigin(config.direct_host_url) ||
17
+ normalizeOrigin(config.public_base_url) ||
18
+ `http://127.0.0.1:${port}`
19
+ );
20
+ }
21
+ return `http://127.0.0.1:${port}`;
22
+ }
@@ -431,25 +431,10 @@ function statusTimestamp(params, providerId) {
431
431
  normalizeString(providerModels.refreshed_at) ||
432
432
  normalizeString(providerModels.last_success_at) ||
433
433
  normalizeString(providerRuntime.last_success_at) ||
434
- normalizeString(providerRuntime.last_attempt_at) ||
435
- normalizeString(params.refresh?.last_success_at) ||
436
434
  null
437
435
  );
438
436
  }
439
437
 
440
- function parseStatusTimestamp(value) {
441
- const normalized = normalizeString(value);
442
- if (!normalized) return null;
443
- const ms = Date.parse(normalized);
444
- return Number.isFinite(ms) ? ms : null;
445
- }
446
-
447
- function nextProviderRefreshAt(params, providerId) {
448
- const refreshedAtMs = parseStatusTimestamp(statusTimestamp(params, providerId));
449
- if (!refreshedAtMs) return null;
450
- return new Date(refreshedAtMs + PROVIDER_MODEL_REFRESH_INTERVAL_MS).toISOString();
451
- }
452
-
453
438
  export function loadProviderModelCatalogState(providerId, options = {}) {
454
439
  if (
455
440
  providerId === "debug-fixture" ||
@@ -500,10 +485,22 @@ export function loadProviderModelCatalogState(providerId, options = {}) {
500
485
  normalizeString(providerRuntime?.last_error) ||
501
486
  null,
502
487
  refreshed_at: statusTimestamp(result.params, providerId),
488
+ last_success_at: statusTimestamp(result.params, providerId),
489
+ last_attempt_at: normalizeString(providerRuntime.last_attempt_at),
490
+ last_attempt_source: normalizeString(providerRuntime.last_attempt_source),
491
+ last_attempt_status: normalizeString(providerRuntime.probe_status),
492
+ process_cleanup: cloneValue(providerRuntime.process_cleanup || null),
493
+ last_attempt_reason: [
494
+ normalizeString(providerRuntime.refresh_skip_reason) || normalizeString(providerRuntime.last_error),
495
+ ["unknown", "failed"].includes(providerRuntime.process_cleanup?.status)
496
+ ? `Process cleanup ${providerRuntime.process_cleanup.status}: owned descendant absence is not confirmed.`
497
+ : null,
498
+ ].filter(Boolean).join(" ") || null,
499
+ using_previous_catalog: models.length > 0 && ["error", "skipped"].includes(providerRuntime.probe_status),
503
500
  next_refresh_at:
504
- nextProviderRefreshAt(result.params, providerId) ||
505
501
  normalizeString(result.params.refresh?.next_refresh_at),
506
- refresh_scheduling_authority: "provider_last_refresh_plus_interval",
502
+ refresh_scheduling_authority: result.params.refresh?.scheduling_authority || null,
503
+ refresh_time_zone: result.params.refresh?.time_zone || null,
507
504
  runtime: cloneValue(providerRuntime),
508
505
  },
509
506
  };
@@ -1,3 +1,8 @@
1
+ import {
2
+ nextProviderModelRefreshTime,
3
+ providerModelRefreshZone,
4
+ PROVIDER_MODEL_REFRESH_SCHEDULING_AUTHORITY,
5
+ } from "./provider-model-refresh-schedule.mjs";
1
6
  import { appendFileSync, mkdirSync } from "fs";
2
7
  import { dirname, join } from "path";
3
8
  import {
@@ -9,7 +14,6 @@ import { getConfigDir, readConfig } from "./config.mjs";
9
14
  import { discoverCodexProviderModels } from "./codex-model-discovery.mjs";
10
15
  import { discoverGrokProviderModels } from "./grok-acp-model-discovery.mjs";
11
16
  import {
12
- PROVIDER_MODEL_REFRESH_INTERVAL_MS,
13
17
  readProviderModelParams,
14
18
  writeProviderModelParams,
15
19
  } from "./provider-model-params.mjs";
@@ -20,7 +24,6 @@ import {
20
24
  requireProvider,
21
25
  } from "./provider-registry.mjs";
22
26
 
23
- const DEFAULT_STARTUP_DELAY_MS = 5 * 60 * 1000;
24
27
  const MAX_ERROR_LENGTH = 500;
25
28
  const PROVIDER_MODEL_REFRESH_OPERATION_LOG_NAME =
26
29
  "provider_model_refresh.jsonl";
@@ -67,12 +70,6 @@ function appendProviderModelRefreshOperationLog(row, options = {}) {
67
70
  return logPath;
68
71
  }
69
72
 
70
- function parseTime(value) {
71
- if (typeof value !== "string" || !value.trim()) return null;
72
- const ms = Date.parse(value);
73
- return Number.isFinite(ms) ? ms : null;
74
- }
75
-
76
73
  function normalizeRefreshProviderId(providerId) {
77
74
  if (providerId === undefined || providerId === null) return null;
78
75
  const normalized = String(providerId).trim().toLowerCase();
@@ -82,29 +79,6 @@ function normalizeRefreshProviderId(providerId) {
82
79
  return normalized;
83
80
  }
84
81
 
85
- function providerRefreshTimestamp(params, providerId) {
86
- const providerModels = params?.provider_models?.[providerId] || {};
87
- const providerRuntime = params?.provider_runtime?.[providerId] || {};
88
- return (
89
- parseTime(providerModels.refreshed_at) ||
90
- parseTime(providerModels.last_success_at) ||
91
- parseTime(providerRuntime.last_success_at) ||
92
- parseTime(providerRuntime.last_attempt_at) ||
93
- null
94
- );
95
- }
96
-
97
- function providerNextRefreshAt(params, providerId, refreshIntervalMs) {
98
- const lastRefreshMs = providerRefreshTimestamp(params, providerId);
99
- if (!lastRefreshMs) return null;
100
- return new Date(lastRefreshMs + refreshIntervalMs).toISOString();
101
- }
102
-
103
- function isProviderRefreshDue(params, providerId, nowMs, refreshIntervalMs) {
104
- const lastRefreshMs = providerRefreshTimestamp(params, providerId);
105
- return !lastRefreshMs || nowMs - lastRefreshMs >= refreshIntervalMs;
106
- }
107
-
108
82
  export class ProviderModelRefreshRunner {
109
83
  constructor(options = {}) {
110
84
  this.configDir = options.configDir || null;
@@ -143,20 +117,18 @@ export class ProviderModelRefreshRunner {
143
117
  this.setTimeoutFn = options.setTimeoutFn || setTimeout;
144
118
  this.clearTimeoutFn = options.clearTimeoutFn || clearTimeout;
145
119
  this.nowFn = options.nowFn || Date.now;
146
- this.startupDelayMs = Number.isInteger(options.startupDelayMs)
147
- ? options.startupDelayMs
148
- : DEFAULT_STARTUP_DELAY_MS;
149
- this.refreshIntervalMs = Number.isInteger(options.refreshIntervalMs)
150
- ? options.refreshIntervalMs
151
- : PROVIDER_MODEL_REFRESH_INTERVAL_MS;
152
120
  this.logger = options.logger || console;
153
121
  this.timer = null;
154
122
  this.started = false;
155
- this.running = false;
123
+ this.activeProviders = new Set();
124
+ this.nextRefreshAt = null;
125
+ this.scheduleZone = providerModelRefreshZone();
126
+ this.clockTimer = null;
156
127
  }
157
128
 
158
129
  start() {
159
130
  if (!this.backgroundRefreshEnabled) {
131
+ this.persistSchedule();
160
132
  return {
161
133
  status: "disabled",
162
134
  reason: this.backgroundRefreshDisabledReason,
@@ -167,103 +139,60 @@ export class ProviderModelRefreshRunner {
167
139
  return { status: "already_started" };
168
140
  }
169
141
  this.started = true;
170
- this.scheduleNext({ startup: true });
142
+ this.scheduleNext();
143
+ this.watchClock();
171
144
  return { status: "started" };
172
145
  }
173
146
 
174
147
  stop() {
175
148
  this.started = false;
149
+ if (this.clockTimer) this.clearTimeoutFn(this.clockTimer);
150
+ this.clockTimer = null;
151
+ this.nextRefreshAt = null;
152
+ this.persistSchedule();
176
153
  if (this.timer) {
177
154
  this.clearTimeoutFn(this.timer);
178
155
  this.timer = null;
179
156
  }
180
157
  }
181
158
 
182
- computeNextDelay({ startup = false } = {}) {
183
- if (startup) {
184
- return Math.max(0, this.startupDelayMs);
185
- }
186
- try {
187
- const result = this.readParamsFn({ configDir: this.configDir });
188
- const nowMs = this.nowFn();
189
- const nextRefreshTimes = PRODUCT_PROVIDER_REFRESH_IDS.map((providerId) =>
190
- providerRefreshTimestamp(result.params, providerId)
191
- )
192
- .filter((timeMs) => Number.isFinite(timeMs))
193
- .map((timeMs) => timeMs + this.refreshIntervalMs);
194
- if (nextRefreshTimes.length < PRODUCT_PROVIDER_REFRESH_IDS.length) {
195
- return 0;
196
- }
197
- const dueNow = nextRefreshTimes.some((timeMs) => timeMs <= nowMs);
198
- if (dueNow) return 0;
199
- return Math.max(0, Math.min(...nextRefreshTimes) - nowMs);
200
- } catch {
201
- // Bad params should not create a timer storm; use the normal interval.
202
- }
203
- return Math.max(0, this.refreshIntervalMs);
159
+ computeNextDelay() {
160
+ const now = this.nowFn();
161
+ return nextProviderModelRefreshTime(now) - now;
204
162
  }
205
163
 
206
164
  getProviderRefreshStatus(providerId) {
207
165
  const provider = normalizeRefreshProviderId(providerId);
208
- const result = this.readParamsFn({ configDir: this.configDir });
209
- const refreshedAtMs = providerRefreshTimestamp(result.params, provider);
166
+ const { params } = this.readParamsFn({ configDir: this.configDir });
167
+ const runtime = params.provider_runtime?.[provider] || {};
210
168
  return {
211
169
  provider,
212
- refreshed_at: refreshedAtMs
213
- ? new Date(refreshedAtMs).toISOString()
214
- : null,
215
- next_refresh_at: providerNextRefreshAt(
216
- result.params,
217
- provider,
218
- this.refreshIntervalMs
219
- ),
220
- refresh_due:
221
- isProviderRefreshDue(
222
- result.params,
223
- provider,
224
- this.nowFn(),
225
- this.refreshIntervalMs
226
- ) === true,
227
- scheduling_authority: "provider_last_refresh_plus_interval",
170
+ refreshed_at: params.provider_models?.[provider]?.refreshed_at || runtime.last_success_at || null,
171
+ last_attempt_at: runtime.last_attempt_at || null,
172
+ last_attempt_source: runtime.last_attempt_source || null,
173
+ last_attempt_status: runtime.probe_status || null,
174
+ process_cleanup: runtime.process_cleanup || null,
175
+ next_refresh_at: this.nextRefreshAt,
176
+ refresh_due: this.nextRefreshAt ? Date.parse(this.nextRefreshAt) <= this.nowFn() : false,
177
+ scheduling_authority: PROVIDER_MODEL_REFRESH_SCHEDULING_AUTHORITY,
228
178
  };
229
179
  }
230
180
 
231
181
  getAllProviderRefreshStatus() {
232
- try {
233
- return PRODUCT_PROVIDER_REFRESH_IDS.map((providerId) =>
234
- this.getProviderRefreshStatus(providerId)
235
- );
236
- } catch {
237
- return PRODUCT_PROVIDER_REFRESH_IDS.map((providerId) => ({
238
- provider: providerId,
239
- refreshed_at: null,
240
- next_refresh_at: null,
241
- refresh_due: true,
242
- scheduling_authority: "provider_last_refresh_plus_interval",
243
- }));
244
- }
182
+ return PRODUCT_PROVIDER_REFRESH_IDS.map((providerId) => this.getProviderRefreshStatus(providerId));
245
183
  }
246
184
 
247
- getTargetProviderIds({ provider = null, force = true } = {}) {
185
+ getTargetProviderIds({ provider = null } = {}) {
248
186
  const normalizedProvider = normalizeRefreshProviderId(provider);
249
- const providerIds = normalizedProvider
250
- ? [normalizedProvider]
251
- : [...PRODUCT_PROVIDER_REFRESH_IDS];
252
- if (force) return providerIds;
253
- try {
254
- const result = this.readParamsFn({ configDir: this.configDir });
255
- const nowMs = this.nowFn();
256
- return providerIds.filter((providerId) =>
257
- isProviderRefreshDue(
258
- result.params,
259
- providerId,
260
- nowMs,
261
- this.refreshIntervalMs
262
- )
263
- );
264
- } catch {
265
- return providerIds;
266
- }
187
+ return normalizedProvider ? [normalizedProvider] : [...PRODUCT_PROVIDER_REFRESH_IDS];
188
+ }
189
+
190
+ persistSchedule() {
191
+ this.writeParamsFn({ refresh: {
192
+ next_refresh_at: this.nextRefreshAt,
193
+ scheduling_authority: PROVIDER_MODEL_REFRESH_SCHEDULING_AUTHORITY,
194
+ time_zone: this.scheduleZone,
195
+ } }, { configDir: this.configDir });
267
196
  }
268
197
 
269
198
  assertProviderAvailable(config, providerId) {
@@ -512,7 +441,8 @@ export class ProviderModelRefreshRunner {
512
441
  if (outcomes.some((outcome) => outcome.status === "success")) {
513
442
  return { status: "refreshed", reason: null };
514
443
  }
515
- return { status: "skipped", reason: "all_providers_unavailable" };
444
+ return { status: "skipped", reason: outcomes.every((outcome) => outcome.reason === "refresh_already_running")
445
+ ? "refresh_already_running" : "all_providers_unavailable" };
516
446
  }
517
447
 
518
448
  async refreshProvider(providerId, options = {}) {
@@ -538,7 +468,6 @@ export class ProviderModelRefreshRunner {
538
468
 
539
469
  async runOnce(options = {}) {
540
470
  const targetProvider = normalizeRefreshProviderId(options.provider);
541
- const force = options.force !== false;
542
471
  const refreshSource =
543
472
  typeof options.source === "string" && options.source.trim()
544
473
  ? options.source.trim()
@@ -547,56 +476,50 @@ export class ProviderModelRefreshRunner {
547
476
  : "explicit";
548
477
  const startedMs = this.nowFn();
549
478
  const startedAt = operationLogTimestamp(startedMs);
550
- if (this.running) {
551
- const finishedMs = this.nowFn();
552
- this.appendOperationLogFn(
553
- {
554
- schema_version: 1,
555
- operation: "provider_model_refresh",
556
- provider: targetProvider || "all",
557
- status: "skipped",
558
- reason: "refresh_already_running",
559
- source: refreshSource,
560
- started_at: startedAt,
561
- finished_at: operationLogTimestamp(finishedMs),
562
- duration_ms: durationMs(startedMs, finishedMs),
563
- },
564
- { configDir: this.configDir || getConfigDir() }
565
- );
566
- return { status: "skipped", reason: "refresh_already_running" };
567
- }
568
- this.running = true;
479
+ // Reserve the batch synchronously: overlapping calls never queue a second
480
+ // discovery, and one busy provider does not suppress the others.
481
+ const targetProviders = this.getTargetProviderIds({ provider: targetProvider });
482
+ const busyProviders = new Set(targetProviders.filter((id) => this.activeProviders.has(id)));
483
+ const reservedProviders = targetProviders.filter((id) => !busyProviders.has(id));
484
+ for (const id of reservedProviders) this.activeProviders.add(id);
569
485
  const attemptedAt = startedAt;
570
486
  try {
571
487
  const config = this.readConfigFn();
572
- const targetProviders = this.getTargetProviderIds({
573
- provider: targetProvider,
574
- force,
575
- });
576
- if (targetProviders.length === 0) {
577
- return {
578
- status: "skipped",
579
- reason: "refresh_not_due",
580
- providers: [],
581
- };
582
- }
583
- const nextRefreshAt = new Date(
584
- this.nowFn() + this.refreshIntervalMs
585
- ).toISOString();
586
488
  const aggregatePatch = {
587
489
  provider_models: {},
588
490
  provider_runtime: {},
589
491
  refresh: {
590
492
  last_attempt_at: attemptedAt,
591
- next_refresh_at: nextRefreshAt,
592
- scheduling_authority: "provider_last_refresh_plus_interval",
593
493
  },
594
494
  };
495
+ // A skip owns admission evidence, not a later discovery completion.
496
+ // Publish all skips before the first await (including providers later in
497
+ // batch order), preserve their existing discovery metadata, and never
498
+ // carry those non-owned runtime blocks into the delayed batch write.
499
+ if (busyProviders.size) {
500
+ const { params } = this.readParamsFn({ configDir: this.configDir });
501
+ const skippedRuntime = {};
502
+ for (const providerId of busyProviders) {
503
+ skippedRuntime[providerId] = {
504
+ ...params.provider_runtime?.[providerId],
505
+ probe_status: 'skipped', refresh_skip_reason: 'refresh_already_running',
506
+ last_attempt_at: attemptedAt, last_attempt_source: refreshSource,
507
+ last_error: null,
508
+ };
509
+ }
510
+ this.writeParamsFn({ provider_runtime: skippedRuntime }, { configDir: this.configDir });
511
+ }
595
512
  const specs = this.buildProviderSpecs(config, attemptedAt);
596
513
  const outcomes = [];
597
514
  for (const providerId of targetProviders) {
598
515
  const spec = specs.get(providerId);
599
516
  const providerStartedMs = this.nowFn();
517
+ if (busyProviders.has(providerId)) {
518
+ outcomes.push({ provider: providerId, status: 'skipped', reason: 'refresh_already_running',
519
+ model_count: 0, started_ms: providerStartedMs });
520
+ continue;
521
+ }
522
+
600
523
  try {
601
524
  this.assertProviderAvailable(config, providerId);
602
525
  } catch (err) {
@@ -619,6 +542,7 @@ export class ProviderModelRefreshRunner {
619
542
  outcomes.push({
620
543
  provider: spec.provider,
621
544
  status: "success",
545
+ process_cleanup: discovery.process_cleanup,
622
546
  model_count: Array.isArray(discovery.models)
623
547
  ? discovery.models.length
624
548
  : 0,
@@ -635,12 +559,18 @@ export class ProviderModelRefreshRunner {
635
559
  outcomes.push({
636
560
  provider: spec.provider,
637
561
  status: "error",
562
+ process_cleanup: err.details?.process_cleanup,
638
563
  model_count: 0,
639
564
  error: err,
640
565
  started_ms: providerStartedMs,
641
566
  });
642
567
  }
643
568
  }
569
+ for (const outcome of outcomes) {
570
+ if (busyProviders.has(outcome.provider)) continue;
571
+ aggregatePatch.provider_runtime[outcome.provider].last_attempt_source = refreshSource;
572
+ aggregatePatch.provider_runtime[outcome.provider].process_cleanup = outcome.process_cleanup || null;
573
+ }
644
574
  const overall = this.buildOverallStatus(outcomes);
645
575
  if (overall.status === "refreshed") {
646
576
  aggregatePatch.refresh.last_success_at = attemptedAt;
@@ -663,15 +593,16 @@ export class ProviderModelRefreshRunner {
663
593
  duration_ms: durationMs(outcome.started_ms, finishedMs),
664
594
  model_count: outcome.model_count,
665
595
  config_options_count: outcome.config_options_count ?? null,
596
+ process_cleanup: outcome.process_cleanup,
666
597
  last_success_at:
667
598
  outcome.status === "success" ? attemptedAt : undefined,
668
- next_refresh_at: nextRefreshAt,
669
- scheduling_authority: "provider_last_refresh_plus_interval",
599
+ next_refresh_at: this.nextRefreshAt,
600
+ scheduling_authority: PROVIDER_MODEL_REFRESH_SCHEDULING_AUTHORITY,
670
601
  params_path: writeResult.path,
671
602
  error:
672
603
  outcome.status === "success"
673
604
  ? null
674
- : sanitizeOperationLogError(outcome.error),
605
+ : outcome.error ? sanitizeOperationLogError(outcome.error) : null,
675
606
  },
676
607
  { configDir: this.configDir || getConfigDir() }
677
608
  );
@@ -699,14 +630,15 @@ export class ProviderModelRefreshRunner {
699
630
  status: outcome.status,
700
631
  reason: outcome.reason || null,
701
632
  model_count: outcome.model_count,
633
+ process_cleanup: outcome.process_cleanup || null,
702
634
  error:
703
635
  outcome.status === "success"
704
636
  ? null
705
- : truncateError(outcome.error),
637
+ : outcome.error ? truncateError(outcome.error) : null,
706
638
  })),
707
639
  };
708
640
  } finally {
709
- this.running = false;
641
+ for (const id of reservedProviders) this.activeProviders.delete(id);
710
642
  }
711
643
  }
712
644
 
@@ -721,28 +653,51 @@ export class ProviderModelRefreshRunner {
721
653
  background_refresh_started: this.started === true,
722
654
  background_refresh_timer_active: Boolean(this.timer),
723
655
  explicit_refresh_available: true,
724
- refresh_running: this.running === true,
725
- startup_delay_ms: this.startupDelayMs,
726
- refresh_interval_ms: this.refreshIntervalMs,
727
- scheduling_authority: "provider_last_refresh_plus_interval",
656
+ refresh_running: this.activeProviders.size > 0,
657
+ next_refresh_at: this.nextRefreshAt,
658
+ time_zone: this.scheduleZone,
659
+ scheduling_authority: PROVIDER_MODEL_REFRESH_SCHEDULING_AUTHORITY,
728
660
  providers: this.getAllProviderRefreshStatus(),
729
661
  };
730
662
  }
731
663
 
732
- scheduleNext(options = {}) {
733
- if (!this.started || this.timer) return;
734
- const delayMs = this.computeNextDelay(options);
735
- this.timer = this.setTimeoutFn(async () => {
736
- this.timer = null;
737
- try {
738
- await this.runOnce({ source: "background", force: false });
739
- } finally {
664
+ // A bounded clock check only recalculates after a zone change. It never
665
+ // retries discovery and does not append an operation result.
666
+ watchClock() {
667
+ if (!this.started) return;
668
+ this.clockTimer = this.setTimeoutFn(() => {
669
+ this.clockTimer = null;
670
+ const zone = providerModelRefreshZone();
671
+ if (zone !== this.scheduleZone) {
672
+ if (this.timer) this.clearTimeoutFn(this.timer);
673
+ this.timer = null;
740
674
  this.scheduleNext();
741
675
  }
742
- }, delayMs);
743
- if (typeof this.timer?.unref === "function") {
744
- this.timer.unref();
745
- }
676
+ this.watchClock();
677
+ }, 60_000);
678
+ this.clockTimer?.unref?.();
746
679
  }
747
680
 
681
+ scheduleNext() {
682
+ if (!this.started || this.timer) return;
683
+ const now = this.nowFn();
684
+ const next = nextProviderModelRefreshTime(now);
685
+ this.scheduleZone = providerModelRefreshZone();
686
+ this.nextRefreshAt = new Date(next).toISOString();
687
+ this.persistSchedule();
688
+ const timer = this.setTimeoutFn(async () => {
689
+ if (!this.started || this.timer !== timer) return;
690
+ this.timer = null;
691
+ // Advance the calendar before discovery so results and Preferences share
692
+ // the next real timer even while a provider is unavailable or slow.
693
+ this.scheduleNext();
694
+ // Timers are notifications, not catch-up authority. Permit only normal
695
+ // sub-second dispatch jitter at the scheduled instant; a suspended or
696
+ // delayed Host skips the expired slot and keeps tomorrow's timer.
697
+ if (this.nowFn() - next > 1000 || this.nowFn() < next) return;
698
+ await this.runOnce({ source: "background", force: true });
699
+ }, next - now);
700
+ this.timer = timer;
701
+ this.timer?.unref?.();
702
+ }
748
703
  }
@@ -0,0 +1,11 @@
1
+ // Calendar arithmetic intentionally uses the Host's local zone (including DST).
2
+ export const PROVIDER_MODEL_REFRESH_SCHEDULING_AUTHORITY = "host_local_daily_0900";
3
+ export function nextProviderModelRefreshTime(nowMs) {
4
+ const next = new Date(nowMs);
5
+ next.setHours(9, 0, 0, 0);
6
+ if (next.getTime() <= nowMs) next.setDate(next.getDate() + 1);
7
+ return next.getTime();
8
+ }
9
+ export function providerModelRefreshZone() {
10
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
11
+ }
@@ -39,6 +39,7 @@ export function assertHostSessionFixtureStart({
39
39
  sessionId,
40
40
  resumeSessionId,
41
41
  fixtureResumeGeneration,
42
+ fork = false,
42
43
  }) {
43
44
  const normalizedSessionId = normalizeResumeIdentity(sessionId);
44
45
  if (!normalizedSessionId) {
@@ -49,20 +50,25 @@ export function assertHostSessionFixtureStart({
49
50
  fixtureResumeGeneration,
50
51
  normalizedResumeSessionId ? 1 : 0
51
52
  );
53
+ const branchStart = fork === true;
54
+ if (branchStart && !normalizedResumeSessionId) {
55
+ throw new Error("host-session fixture branch requires a source sessionId");
56
+ }
52
57
  if (
53
58
  normalizedResumeSessionId &&
54
- normalizedResumeSessionId !== normalizedSessionId
59
+ normalizedResumeSessionId !== normalizedSessionId &&
60
+ !branchStart
55
61
  ) {
56
62
  throw new Error(
57
63
  "host-session fixture resume identity must equal sessionId"
58
64
  );
59
65
  }
60
- if (normalizedResumeSessionId && generation < 1) {
66
+ if (normalizedResumeSessionId && !branchStart && generation < 1) {
61
67
  throw new Error(
62
68
  "resumed host-session fixture requires a positive generation"
63
69
  );
64
70
  }
65
- if (!normalizedResumeSessionId && generation !== 0) {
71
+ if ((!normalizedResumeSessionId || branchStart) && generation !== 0) {
66
72
  throw new Error("fresh host-session fixture requires generation zero");
67
73
  }
68
74
  return generation;