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,8 +1,11 @@
1
1
  import { createHash, randomUUID } from "crypto";
2
2
  import {
3
3
  chmodSync,
4
+ closeSync,
4
5
  existsSync,
6
+ fsyncSync,
5
7
  mkdirSync,
8
+ openSync,
6
9
  readFileSync,
7
10
  realpathSync,
8
11
  renameSync,
@@ -32,6 +35,8 @@ export const SESSION_LOOP_LIFECYCLE_FILENAME =
32
35
  export const SESSION_LOOP_RECONCILIATION_ERROR_CODE =
33
36
  "session_loop_lifecycle_reconciliation_failed";
34
37
  export const SESSION_LOOP_DEFINITION_REVISION_ALGORITHM = "sha256";
38
+ export const SESSION_LOOP_DEFINITION_UNAVAILABLE_GRACE_MS = 10_000;
39
+ export const SESSION_LOOP_INCIDENT_HISTORY_LIMIT = 16;
35
40
 
36
41
  const LOOP_DEFINITION_ROOT_KEYS = new Set(["version", "loops"]);
37
42
  const LOOP_DEFINITION_ALLOWED_KEYS = new Set([
@@ -81,6 +86,15 @@ const LOOP_LIFECYCLE_STATE_ALLOWED_KEYS = new Set([
81
86
  "dispatch_count",
82
87
  "skip_count",
83
88
  "last_dispatched_message_id",
89
+ "definition_snapshot",
90
+ "definition_generation",
91
+ "availability_state",
92
+ "unavailable_since",
93
+ "unavailable_observations",
94
+ "candidate_definition",
95
+ "candidate_revision",
96
+ "incidents",
97
+ "migration_receipt",
84
98
  "updated_at",
85
99
  "evidence",
86
100
  ]);
@@ -104,6 +118,18 @@ const LOOP_PENDING_OCCURRENCE_ALLOWED_KEYS = new Set([
104
118
  ]);
105
119
  const ACTIVE_AGENT_FOLDER_MUTATIONS = new Set();
106
120
 
121
+ const LOOP_INCIDENT_ALLOWED_KEYS = new Set([
122
+ "id",
123
+ "generation",
124
+ "reason",
125
+ "definition_revision",
126
+ "candidate_revision",
127
+ "created_at",
128
+ "resolved_at",
129
+ "recovery_options",
130
+ "notification_key",
131
+ ]);
132
+
107
133
  const INTERVAL_UNITS_MS = Object.freeze({
108
134
  s: 1000,
109
135
  m: 60 * 1000,
@@ -213,6 +239,60 @@ function normalizeNullableString(value, fieldName) {
213
239
  return value.trim() || null;
214
240
  }
215
241
 
242
+ function normalizeOptionalNonNegativeInteger(value, fieldName, fallback = 0) {
243
+ if (value === undefined || value === null) return fallback;
244
+ return normalizeNonNegativeInteger(value, fieldName);
245
+ }
246
+
247
+ function safeDefinitionCommandSummary(commandText) {
248
+ const normalized = String(commandText || "").replace(/\s+/g, " ").trim();
249
+ if (!normalized) return "[command unavailable]";
250
+ return `[redacted command sha256:${hashText(normalized).slice(0, 16)}]`;
251
+ }
252
+
253
+ function snapshotDefinition(definition) {
254
+ return normalizeLoopDefinition(
255
+ {
256
+ ...definition,
257
+ command_text: safeDefinitionCommandSummary(definition.command_text),
258
+ },
259
+ { existing: definition }
260
+ );
261
+ }
262
+
263
+ function normalizeDefinitionSnapshot(value, fieldName) {
264
+ if (value === null || value === undefined) return null;
265
+ try {
266
+ return snapshotDefinition(value);
267
+ } catch (err) {
268
+ throw new Error(`${fieldName} is invalid: ${err.message}`);
269
+ }
270
+ }
271
+
272
+ function normalizeLoopIncident(value) {
273
+ if (!isObjectRecord(value)) throw new Error("Loop incident must be an object");
274
+ assertAllowedObjectKeys(value, LOOP_INCIDENT_ALLOWED_KEYS, "Loop incident");
275
+ const recoveryOptions = Array.isArray(value.recovery_options)
276
+ ? value.recovery_options.map((entry) =>
277
+ normalizeRequiredString(entry, "loop incident recovery option")
278
+ )
279
+ : [];
280
+ return {
281
+ id: normalizeRequiredString(value.id, "loop incident id"),
282
+ generation: normalizeNonNegativeInteger(value.generation, "loop incident generation"),
283
+ reason: normalizeRequiredString(value.reason, "loop incident reason"),
284
+ definition_revision: normalizeDefinitionRevision(value.definition_revision),
285
+ candidate_revision: normalizeDefinitionRevision(value.candidate_revision),
286
+ created_at: normalizeOptionalIsoTimestamp(value.created_at, "loop incident created_at"),
287
+ resolved_at: normalizeOptionalIsoTimestamp(value.resolved_at, "loop incident resolved_at"),
288
+ recovery_options: recoveryOptions,
289
+ notification_key: normalizeRequiredString(
290
+ value.notification_key,
291
+ "loop incident notification_key"
292
+ ),
293
+ };
294
+ }
295
+
216
296
  export function normalizeSessionLoopCommandText(value) {
217
297
  return normalizeRequiredString(value, "loop command text").replace(/\s+/g, " ");
218
298
  }
@@ -661,6 +741,7 @@ function normalizeLifecycleState(value) {
661
741
  "active",
662
742
  "disabled",
663
743
  "suspended",
744
+ "provisional",
664
745
  "expired",
665
746
  "cancelled",
666
747
  ].includes(status)) {
@@ -701,6 +782,38 @@ function normalizeLifecycleState(value) {
701
782
  value.last_dispatched_message_id,
702
783
  "loop last_dispatched_message_id"
703
784
  ),
785
+ definition_snapshot: normalizeDefinitionSnapshot(
786
+ value.definition_snapshot,
787
+ "loop definition_snapshot"
788
+ ),
789
+ definition_generation: normalizeOptionalNonNegativeInteger(
790
+ value.definition_generation,
791
+ "loop definition_generation"
792
+ ),
793
+ availability_state: normalizeRequiredString(
794
+ value.availability_state || "available",
795
+ "loop availability_state"
796
+ ),
797
+ unavailable_since: normalizeOptionalIsoTimestamp(
798
+ value.unavailable_since,
799
+ "loop unavailable_since"
800
+ ),
801
+ unavailable_observations: normalizeOptionalNonNegativeInteger(
802
+ value.unavailable_observations,
803
+ "loop unavailable_observations"
804
+ ),
805
+ candidate_definition: normalizeDefinitionSnapshot(
806
+ value.candidate_definition,
807
+ "loop candidate_definition"
808
+ ),
809
+ candidate_revision: normalizeDefinitionRevision(value.candidate_revision),
810
+ incidents: Array.isArray(value.incidents)
811
+ ? value.incidents.map(normalizeLoopIncident).slice(-SESSION_LOOP_INCIDENT_HISTORY_LIMIT)
812
+ : [],
813
+ migration_receipt: normalizeNullableString(
814
+ value.migration_receipt,
815
+ "loop migration_receipt"
816
+ ),
704
817
  updated_at: normalizeOptionalIsoTimestamp(value.updated_at, "loop updated_at"),
705
818
  evidence,
706
819
  };
@@ -849,8 +962,20 @@ export class SessionLoopLifecycleStore {
849
962
  writeFileSync(temporaryPath, serializeLifecyclePayload(normalized), {
850
963
  mode: 0o600,
851
964
  });
965
+ const temporaryFd = openSync(temporaryPath, "r");
966
+ try {
967
+ fsyncSync(temporaryFd);
968
+ } finally {
969
+ closeSync(temporaryFd);
970
+ }
852
971
  chmodSync(temporaryPath, 0o600);
853
972
  renameSync(temporaryPath, this.lifecyclePath);
973
+ const directoryFd = openSync(this.configDir, "r");
974
+ try {
975
+ fsyncSync(directoryFd);
976
+ } finally {
977
+ closeSync(directoryFd);
978
+ }
854
979
  chmodSync(this.lifecyclePath, 0o600);
855
980
  return this.read();
856
981
  }
@@ -898,6 +1023,15 @@ function createRuntimeState(
898
1023
  dispatch_count: 0,
899
1024
  skip_count: 0,
900
1025
  last_dispatched_message_id: null,
1026
+ definition_snapshot: snapshotDefinition(definition),
1027
+ definition_generation: 0,
1028
+ availability_state: "available",
1029
+ unavailable_since: null,
1030
+ unavailable_observations: 0,
1031
+ candidate_definition: null,
1032
+ candidate_revision: null,
1033
+ incidents: [],
1034
+ migration_receipt: null,
901
1035
  updated_at: timestamp,
902
1036
  evidence: [
903
1037
  {
@@ -918,12 +1052,22 @@ export class SessionLoopDefinitionService {
918
1052
  lifecycleStore = new SessionLoopLifecycleStore(),
919
1053
  clock = () => new Date(),
920
1054
  recheckMs = 5000,
1055
+ unavailableGraceMs = SESSION_LOOP_DEFINITION_UNAVAILABLE_GRACE_MS,
921
1056
  } = {}) {
922
1057
  this.store = store;
923
1058
  this.lifecycleStore = lifecycleStore;
924
1059
  this.clock = clock;
925
1060
  this.recheckMs = recheckMs;
1061
+ if (
1062
+ !Number.isInteger(unavailableGraceMs) ||
1063
+ unavailableGraceMs < 5_000 ||
1064
+ unavailableGraceMs > 30_000
1065
+ ) {
1066
+ throw new Error("Loop definition unavailable grace must be 5000..30000ms");
1067
+ }
1068
+ this.unavailableGraceMs = unavailableGraceMs;
926
1069
  this.sessions = new Map();
1070
+ this.folderSnapshots = new Map();
927
1071
  }
928
1072
 
929
1073
  createReconciliationError(
@@ -986,6 +1130,21 @@ export class SessionLoopDefinitionService {
986
1130
  }
987
1131
  }
988
1132
 
1133
+ readDefinitionsForReconciliation(agentFolder) {
1134
+ try {
1135
+ return this.store.readDefinitions(agentFolder);
1136
+ } catch (err) {
1137
+ const paths = this.store.resolveLoopsPath(agentFolder);
1138
+ return {
1139
+ ...paths,
1140
+ version: SESSION_LOOP_DEFINITIONS_VERSION,
1141
+ loops: [],
1142
+ file_exists: existsSync(paths.loopsPath),
1143
+ read_error: err?.message || String(err),
1144
+ };
1145
+ }
1146
+ }
1147
+
989
1148
  appendTerminalEvidence(session, entry) {
990
1149
  session.terminal_evidence = [
991
1150
  ...(session.terminal_evidence || []),
@@ -1019,32 +1178,156 @@ export class SessionLoopDefinitionService {
1019
1178
  });
1020
1179
  }
1021
1180
 
1022
- resetStateForDefinitionRevision({ state, definition, reason, timestamp }) {
1181
+ adoptDefinitionRevision({ state, definition, generation, timestamp }) {
1023
1182
  const occurrenceId = state.pending_occurrence?.occurrence_id || null;
1024
1183
  if (occurrenceId) {
1025
1184
  this.appendEvidence(state, "cancelled", {
1026
1185
  occurrenceId,
1027
- reason: `${reason}_pending_cancelled`,
1186
+ reason: "definition_updated_pending_cancelled",
1028
1187
  recordedAt: timestamp,
1029
1188
  });
1030
1189
  }
1031
1190
  state.definition_revision = definitionRevision(definition);
1032
- state.enabled = false;
1033
- state.status = "disabled";
1034
- state.cadence_anchor_at = null;
1035
- state.next_run_at = null;
1191
+ state.definition_snapshot = snapshotDefinition(definition);
1192
+ state.definition_generation = generation;
1193
+ state.availability_state = "available";
1194
+ state.unavailable_since = null;
1195
+ state.unavailable_observations = 0;
1196
+ state.candidate_definition = null;
1197
+ state.candidate_revision = null;
1036
1198
  state.pending_occurrence = null;
1037
- state.suspension_reason = reason;
1038
1199
  state.last_error = null;
1039
- this.appendEvidence(state, "disabled", {
1040
- occurrenceId,
1041
- reason,
1200
+ state.status = state.enabled ? "active" : "disabled";
1201
+ state.suspension_reason = state.enabled ? null : "owner_disabled";
1202
+ state.cadence_anchor_at = state.enabled ? definition.start_at || timestamp : null;
1203
+ state.next_run_at = state.enabled
1204
+ ? Date.parse(definition.start_at || "") > Date.parse(timestamp)
1205
+ ? definition.start_at
1206
+ : computeFirstCadenceBoundaryAfter({
1207
+ anchorAt: state.cadence_anchor_at,
1208
+ intervalMs: definition.interval_ms,
1209
+ after: timestamp,
1210
+ })
1211
+ : null;
1212
+ // Historical review incidents are evidence, never ongoing execution authority.
1213
+ for (const incident of state.incidents || []) {
1214
+ if (!incident.resolved_at) incident.resolved_at = timestamp;
1215
+ }
1216
+ this.appendEvidence(state, state.enabled ? "session-resumed" : "disabled", {
1217
+ reason: "definition_updated_intent_preserved_fresh_cadence",
1042
1218
  recordedAt: timestamp,
1043
1219
  });
1044
- return state;
1045
1220
  }
1046
1221
 
1047
- reconcileLifecycleSession({ session, storage }) {
1222
+ definitionSetSignature(storage) {
1223
+ return hashText(
1224
+ JSON.stringify(
1225
+ {
1226
+ file_exists: storage.file_exists === true,
1227
+ read_error: storage.read_error ? hashText(storage.read_error) : null,
1228
+ definitions: storage.loops
1229
+ .map((definition) => [definition.id, definitionRevision(definition)])
1230
+ .sort((left, right) => left[0].localeCompare(right[0])),
1231
+ }
1232
+ )
1233
+ );
1234
+ }
1235
+
1236
+ nextFolderGeneration(storage, lifecycle) {
1237
+ const signature = this.definitionSetSignature(storage);
1238
+ const current = this.folderSnapshots.get(storage.agentFolder);
1239
+ if (current?.signature === signature) return current.generation;
1240
+ const durableGeneration = lifecycle.sessions
1241
+ .filter((session) => session.agent_folder === storage.agentFolder)
1242
+ .flatMap((session) => session.loops)
1243
+ .reduce((maximum, state) => Math.max(maximum, state.definition_generation || 0), 0);
1244
+ const generation = Math.max(current?.generation || 0, durableGeneration) + 1;
1245
+ this.folderSnapshots.set(storage.agentFolder, { signature, generation });
1246
+ return generation;
1247
+ }
1248
+
1249
+ currentIncident(state) {
1250
+ return [...(state.incidents || [])].reverse().find((incident) => !incident.resolved_at) || null;
1251
+ }
1252
+
1253
+ resolveCurrentIncident(state, timestamp) {
1254
+ const incident = this.currentIncident(state);
1255
+ if (incident) incident.resolved_at = timestamp;
1256
+ return incident;
1257
+ }
1258
+
1259
+ createIncident({ session, state, reason, generation, candidateRevision = null, timestamp }) {
1260
+ const unresolvedEquivalent = (state.incidents || []).find(
1261
+ (incident) =>
1262
+ !incident.resolved_at &&
1263
+ incident.reason === reason &&
1264
+ incident.definition_revision === state.definition_revision &&
1265
+ incident.candidate_revision === candidateRevision
1266
+ );
1267
+ if (unresolvedEquivalent) {
1268
+ return { incident: unresolvedEquivalent, created: false };
1269
+ }
1270
+ const notificationKey = hashText(
1271
+ [generation, session.agent_folder, session.host_session_id, state.id, reason,
1272
+ candidateRevision || state.definition_revision || "none"].join(":")
1273
+ );
1274
+ const existing = (state.incidents || []).find(
1275
+ (incident) => incident.notification_key === notificationKey
1276
+ );
1277
+ if (existing) return { incident: existing, created: false };
1278
+ const incident = normalizeLoopIncident({
1279
+ id: `loop_incident_${notificationKey.slice(0, 24)}`,
1280
+ generation,
1281
+ reason,
1282
+ definition_revision: state.definition_revision,
1283
+ candidate_revision: candidateRevision,
1284
+ created_at: timestamp,
1285
+ resolved_at: null,
1286
+ recovery_options:
1287
+ reason === "legacy_reconciliation_intent_loss"
1288
+ ? ["restore_previous_enabled_state", "keep_disabled", "open_loop"]
1289
+ : ["restore", "keep_disabled", "open_loop"],
1290
+ notification_key: notificationKey,
1291
+ });
1292
+ state.incidents = [...(state.incidents || []), incident].slice(
1293
+ -SESSION_LOOP_INCIDENT_HISTORY_LIMIT
1294
+ );
1295
+ return { incident, created: true };
1296
+ }
1297
+
1298
+ classifyLegacyIntentLoss({ session, state, timestamp, generation }) {
1299
+ if (state.migration_receipt) return null;
1300
+ const discovered = state.evidence?.some(
1301
+ (entry) => entry.reason === "shared_agent_folder_definition_discovered_default_off"
1302
+ );
1303
+ const removed = session.terminal_evidence?.some(
1304
+ (entry) =>
1305
+ entry.loop_id === state.id &&
1306
+ entry.reason === "shared_agent_folder_definition_removed"
1307
+ );
1308
+ if (!discovered || !removed) return null;
1309
+ state.migration_receipt = `p254:${hashText(
1310
+ `${session.host_session_id}:${state.id}:legacy_reconciliation_intent_loss`
1311
+ ).slice(0, 24)}`;
1312
+ state.status = "suspended";
1313
+ state.availability_state = "available";
1314
+ state.suspension_reason = "legacy_reconciliation_intent_loss";
1315
+ const created = this.createIncident({
1316
+ session,
1317
+ state,
1318
+ reason: "legacy_reconciliation_intent_loss",
1319
+ generation,
1320
+ timestamp,
1321
+ });
1322
+ return created.created ? created.incident : null;
1323
+ }
1324
+
1325
+ reconcileLifecycleSession({
1326
+ session,
1327
+ storage,
1328
+ generation,
1329
+ explicitDeletedLoopId = null,
1330
+ }) {
1048
1331
  let storedCanonicalFolder = session.agent_folder;
1049
1332
  if (storedCanonicalFolder !== storage.agentFolder) {
1050
1333
  try {
@@ -1060,6 +1343,7 @@ export class SessionLoopDefinitionService {
1060
1343
  const timestamp = nowIso(this.clock);
1061
1344
  const statesById = new Map(session.loops.map((state) => [state.id, state]));
1062
1345
  const nextStates = [];
1346
+ const createdIncidents = [];
1063
1347
  let mutated = false;
1064
1348
  if (session.agent_folder !== storage.agentFolder) {
1065
1349
  session.agent_folder = storage.agentFolder;
@@ -1074,17 +1358,81 @@ export class SessionLoopDefinitionService {
1074
1358
  timestamp,
1075
1359
  reason: "shared_agent_folder_definition_discovered_default_off",
1076
1360
  });
1361
+ state.definition_generation = generation;
1077
1362
  mutated = true;
1078
- } else if (state.definition_revision !== revision) {
1079
- const reason = state.definition_revision
1080
- ? "shared_agent_folder_definition_changed_default_off"
1081
- : "definition_revision_migrated_default_off";
1082
- this.resetStateForDefinitionRevision({
1083
- state,
1084
- definition,
1085
- reason,
1086
- timestamp,
1087
- });
1363
+ } else if (state.definition_revision !== revision ||
1364
+ state.availability_state === "changed_review" ||
1365
+ state.suspension_reason === "definition_changed_requires_review") {
1366
+ if (!state.definition_revision) {
1367
+ state.definition_revision = revision;
1368
+ state.definition_snapshot = snapshotDefinition(definition);
1369
+ state.definition_generation = generation;
1370
+ state.availability_state = "available";
1371
+ state.suspension_reason = state.enabled ? null : state.suspension_reason;
1372
+ } else {
1373
+ this.adoptDefinitionRevision({ state, definition, generation, timestamp });
1374
+ }
1375
+ mutated = true;
1376
+ } else {
1377
+ const wasUnavailable = state.availability_state !== "available";
1378
+ const generationChanged = state.definition_generation !== generation;
1379
+ const snapshotMissing = !state.definition_snapshot;
1380
+ state.definition_snapshot = snapshotDefinition(definition);
1381
+ state.definition_generation = Math.max(state.definition_generation || 0, generation);
1382
+ state.availability_state = "available";
1383
+ state.unavailable_since = null;
1384
+ state.unavailable_observations = 0;
1385
+ state.candidate_definition = null;
1386
+ state.candidate_revision = null;
1387
+ state.last_error = null;
1388
+ if (generationChanged || snapshotMissing) mutated = true;
1389
+ if (wasUnavailable && state.enabled === true) {
1390
+ const now = nowIso(this.clock);
1391
+ state.status = "active";
1392
+ state.suspension_reason = null;
1393
+ state.cadence_anchor_at = now;
1394
+ state.next_run_at = computeFirstCadenceBoundaryAfter({
1395
+ anchorAt: now,
1396
+ intervalMs: definition.interval_ms,
1397
+ after: now,
1398
+ });
1399
+ state.pending_occurrence = null;
1400
+ this.resolveCurrentIncident(state, timestamp);
1401
+ this.appendEvidence(state, "session-resumed", {
1402
+ reason: "definition_returned_same_revision_fresh_cadence",
1403
+ recordedAt: timestamp,
1404
+ });
1405
+ mutated = true;
1406
+ } else if (
1407
+ state.enabled !== true &&
1408
+ state.status !== "expired" &&
1409
+ state.status !== "cancelled" &&
1410
+ state.evidence.some((entry) =>
1411
+ ["owner_kept_disabled", "owner_disabled"].includes(entry.reason)
1412
+ ) &&
1413
+ (state.status !== "disabled" || state.suspension_reason !== "owner_disabled")
1414
+ ) {
1415
+ state.status = "disabled";
1416
+ state.suspension_reason = "owner_disabled";
1417
+ state.cadence_anchor_at = null;
1418
+ state.next_run_at = null;
1419
+ state.pending_occurrence = null;
1420
+ this.resolveCurrentIncident(state, timestamp);
1421
+ this.appendEvidence(state, "disabled", {
1422
+ reason: "owner_disabled_state_reconciled",
1423
+ recordedAt: timestamp,
1424
+ });
1425
+ mutated = true;
1426
+ }
1427
+ }
1428
+ const migratedIncident = this.classifyLegacyIntentLoss({
1429
+ session,
1430
+ state,
1431
+ timestamp,
1432
+ generation,
1433
+ });
1434
+ if (migratedIncident) {
1435
+ createdIncidents.push(migratedIncident);
1088
1436
  mutated = true;
1089
1437
  }
1090
1438
  nextStates.push(state);
@@ -1092,13 +1440,73 @@ export class SessionLoopDefinitionService {
1092
1440
  }
1093
1441
 
1094
1442
  for (const staleState of statesById.values()) {
1443
+ if (explicitDeletedLoopId === staleState.id) {
1444
+ const occurrenceId = staleState.pending_occurrence?.occurrence_id || null;
1445
+ this.appendEvidence(staleState, "cancelled", {
1446
+ occurrenceId,
1447
+ reason: "deleted_by_owner",
1448
+ recordedAt: timestamp,
1449
+ });
1450
+ this.appendTerminalEvidence(session, staleState.evidence.at(-1));
1451
+ mutated = true;
1452
+ continue;
1453
+ }
1095
1454
  const occurrenceId = staleState.pending_occurrence?.occurrence_id || null;
1096
- this.appendEvidence(staleState, "cancelled", {
1097
- occurrenceId,
1098
- reason: "shared_agent_folder_definition_removed",
1099
- recordedAt: timestamp,
1100
- });
1101
- this.appendTerminalEvidence(session, staleState.evidence.at(-1));
1455
+ const firstUnavailable = !staleState.unavailable_since;
1456
+ staleState.unavailable_since ||= timestamp;
1457
+ staleState.unavailable_observations =
1458
+ (staleState.unavailable_observations || 0) + 1;
1459
+ staleState.availability_state = "provisional";
1460
+ staleState.definition_generation = generation;
1461
+ staleState.status = "provisional";
1462
+ staleState.suspension_reason = "checking_definition_availability";
1463
+ staleState.last_error = storage.read_error || null;
1464
+ staleState.next_run_at = null;
1465
+ if (occurrenceId) {
1466
+ this.appendEvidence(staleState, "cancelled", {
1467
+ occurrenceId,
1468
+ reason: "provisional_definition_unavailable_pending_cancelled",
1469
+ recordedAt: timestamp,
1470
+ });
1471
+ staleState.pending_occurrence = null;
1472
+ }
1473
+ const elapsed = Date.parse(timestamp) - Date.parse(staleState.unavailable_since);
1474
+ if (staleState.enabled !== true) {
1475
+ staleState.status = "disabled";
1476
+ staleState.suspension_reason = "owner_disabled";
1477
+ staleState.availability_state =
1478
+ staleState.unavailable_observations >= 2 && elapsed >= this.unavailableGraceMs
1479
+ ? "unavailable"
1480
+ : "provisional";
1481
+ this.resolveCurrentIncident(staleState, timestamp);
1482
+ nextStates.push(staleState);
1483
+ mutated = true;
1484
+ continue;
1485
+ }
1486
+ if (
1487
+ staleState.unavailable_observations >= 2 &&
1488
+ elapsed >= this.unavailableGraceMs
1489
+ ) {
1490
+ staleState.availability_state = "unavailable";
1491
+ staleState.status = "suspended";
1492
+ staleState.suspension_reason = "definition_unavailable_confirmed";
1493
+ const created = this.createIncident({
1494
+ session,
1495
+ state: staleState,
1496
+ reason: "definition_unavailable_confirmed",
1497
+ generation,
1498
+ timestamp,
1499
+ });
1500
+ if (created.created) createdIncidents.push(created.incident);
1501
+ if (firstUnavailable || created.created) {
1502
+ this.appendEvidence(staleState, "session-suspended", {
1503
+ occurrenceId,
1504
+ reason: "definition_unavailable_confirmed",
1505
+ recordedAt: timestamp,
1506
+ });
1507
+ }
1508
+ }
1509
+ nextStates.push(staleState);
1102
1510
  mutated = true;
1103
1511
  }
1104
1512
 
@@ -1106,7 +1514,7 @@ export class SessionLoopDefinitionService {
1106
1514
  session.loops = nextStates;
1107
1515
  session.updated_at = timestamp;
1108
1516
  }
1109
- return { applies: true, mutated, session };
1517
+ return { applies: true, mutated, session, createdIncidents };
1110
1518
  }
1111
1519
 
1112
1520
  syncLiveRuntimeFromLifecycleSession(session) {
@@ -1123,15 +1531,35 @@ export class SessionLoopDefinitionService {
1123
1531
  runtime.updated_at = session.updated_at;
1124
1532
  }
1125
1533
 
1126
- reconcileLifecycleForFolder({ storage }) {
1534
+ reconcileLifecycleForFolder({ storage, explicitDeletedLoopId = null }) {
1127
1535
  const lifecycle = this.lifecycleStore.read();
1536
+ const generation = this.nextFolderGeneration(storage, lifecycle);
1128
1537
  let mutated = false;
1129
1538
  let matchedSessionCount = 0;
1539
+ const createdIncidents = [];
1130
1540
  for (const session of lifecycle.sessions) {
1131
- const result = this.reconcileLifecycleSession({ session, storage });
1541
+ const result = this.reconcileLifecycleSession({
1542
+ session,
1543
+ storage,
1544
+ generation,
1545
+ explicitDeletedLoopId,
1546
+ });
1132
1547
  if (!result.applies) continue;
1133
1548
  matchedSessionCount += 1;
1134
1549
  mutated = result.mutated || mutated;
1550
+ for (const incident of result.createdIncidents || []) {
1551
+ createdIncidents.push({
1552
+ incident,
1553
+ session: {
1554
+ host_session_id: session.host_session_id,
1555
+ agent_id: session.agent_id,
1556
+ agent_folder: session.agent_folder,
1557
+ },
1558
+ state: session.loops.find((state) =>
1559
+ (state.incidents || []).some((candidate) => candidate.id === incident.id)
1560
+ ) || null,
1561
+ });
1562
+ }
1135
1563
  }
1136
1564
  const committed = mutated
1137
1565
  ? this.lifecycleStore.writeSessions(lifecycle.sessions)
@@ -1145,6 +1573,8 @@ export class SessionLoopDefinitionService {
1145
1573
  lifecycle: committed,
1146
1574
  mutated,
1147
1575
  matched_session_count: matchedSessionCount,
1576
+ definition_generation: generation,
1577
+ incident_count: createdIncidents.length,
1148
1578
  };
1149
1579
  }
1150
1580
 
@@ -1204,6 +1634,14 @@ export class SessionLoopDefinitionService {
1204
1634
  mutatedCount += 1;
1205
1635
  continue;
1206
1636
  }
1637
+ const alreadySuspendedForLifecycle =
1638
+ state.enabled === true &&
1639
+ state.status === "suspended" &&
1640
+ state.suspension_reason === resetReason &&
1641
+ state.cadence_anchor_at === null &&
1642
+ state.next_run_at === null &&
1643
+ state.pending_occurrence === null;
1644
+ if (alreadySuspendedForLifecycle) continue;
1207
1645
  const hadRuntimeAuthority =
1208
1646
  state.enabled === true ||
1209
1647
  state.status === "active" ||
@@ -1220,28 +1658,33 @@ export class SessionLoopDefinitionService {
1220
1658
  });
1221
1659
  cancelledCount += 1;
1222
1660
  }
1223
- state.enabled = false;
1224
- state.status = "disabled";
1661
+ const enabledIntent = state.enabled === true;
1662
+ state.enabled = enabledIntent;
1663
+ state.status = enabledIntent ? "suspended" : "disabled";
1225
1664
  state.cadence_anchor_at = null;
1226
1665
  state.next_run_at = null;
1227
1666
  state.pending_occurrence = null;
1228
- state.suspension_reason = resetReason;
1667
+ state.suspension_reason = enabledIntent ? resetReason : state.suspension_reason;
1229
1668
  state.last_error = null;
1230
- this.appendEvidence(state, "disabled", {
1669
+ this.appendEvidence(state, enabledIntent ? "session-suspended" : "disabled", {
1231
1670
  occurrenceId,
1232
1671
  reason: resetReason,
1233
1672
  recordedAt: timestamp,
1234
1673
  });
1235
1674
  runtime.states.set(state.id, state);
1236
- disabledCount += 1;
1675
+ if (!enabledIntent) disabledCount += 1;
1237
1676
  mutatedCount += 1;
1238
1677
  }
1239
1678
  if (mutatedCount > 0) this.persistRuntime(runtime);
1679
+ this.sessions.delete(runtime.host_session_id);
1240
1680
  return {
1241
- status: "disabled",
1681
+ status: "intent_preserved_runtime_suspended",
1242
1682
  host_session_id: runtime.host_session_id,
1243
1683
  runtime_state_count: runtime.states.size,
1244
1684
  disabled_count: disabledCount,
1685
+ enabled_intent_preserved_count: [...runtime.states.values()].filter(
1686
+ (state) => state.enabled === true
1687
+ ).length,
1245
1688
  cancelled_count: cancelledCount,
1246
1689
  idempotent: mutatedCount === 0,
1247
1690
  reason: resetReason,
@@ -1364,7 +1807,7 @@ export class SessionLoopDefinitionService {
1364
1807
  }) {
1365
1808
  const sessionId = normalizeRequiredString(hostSessionId, "hostSessionId");
1366
1809
  const normalizedAgentId = normalizeRequiredString(agentId, "agentId");
1367
- const storage = this.store.readDefinitions(agentFolder);
1810
+ const storage = this.readDefinitionsForReconciliation(agentFolder);
1368
1811
  const hadLiveRuntime = this.sessions.has(sessionId);
1369
1812
  const runtime = this.buildRuntimeFromLifecycle({
1370
1813
  sessionId,
@@ -1372,14 +1815,44 @@ export class SessionLoopDefinitionService {
1372
1815
  agentFolder,
1373
1816
  storage,
1374
1817
  });
1375
- const defaultOffReset = !hadLiveRuntime
1376
- ? this.resetRuntimeAuthorityForLifecycle({
1377
- runtime,
1378
- reason: defaultOffReason,
1379
- })
1380
- : null;
1818
+ let reconstructed = null;
1819
+ if (!hadLiveRuntime) {
1820
+ const timestamp = nowIso(this.clock);
1821
+ let mutated = false;
1822
+ for (const state of runtime.states.values()) {
1823
+ if (
1824
+ state.enabled !== true ||
1825
+ !["active", "suspended"].includes(state.status) ||
1826
+ state.availability_state !== "available"
1827
+ ) continue;
1828
+ const definition = storage.loops.find((entry) => entry.id === state.id);
1829
+ if (!definition || state.definition_revision !== definitionRevision(definition)) {
1830
+ continue;
1831
+ }
1832
+ state.cadence_anchor_at = timestamp;
1833
+ state.status = "active";
1834
+ state.suspension_reason = null;
1835
+ state.next_run_at = computeFirstCadenceBoundaryAfter({
1836
+ anchorAt: timestamp,
1837
+ intervalMs: definition.interval_ms,
1838
+ after: timestamp,
1839
+ });
1840
+ state.pending_occurrence = null;
1841
+ this.appendEvidence(state, "session-resumed", {
1842
+ reason: "durable_enabled_intent_reconstructed_fresh_cadence",
1843
+ recordedAt: timestamp,
1844
+ });
1845
+ mutated = true;
1846
+ }
1847
+ if (mutated) this.persistRuntime(runtime);
1848
+ reconstructed = {
1849
+ status: "intent_reconstructed",
1850
+ reason: defaultOffReason,
1851
+ lifecycle_mutated: mutated,
1852
+ };
1853
+ }
1381
1854
  this.sessions.set(sessionId, runtime);
1382
- return { runtime, storage, defaultOffReset };
1855
+ return { runtime, storage, defaultOffReset: reconstructed };
1383
1856
  }
1384
1857
 
1385
1858
  ensureRuntimeSession(options) {
@@ -1406,7 +1879,13 @@ export class SessionLoopDefinitionService {
1406
1879
  agentId,
1407
1880
  agentFolder,
1408
1881
  });
1409
- return storage.loops.map((definition) =>
1882
+ const definitions = new Map(storage.loops.map((definition) => [definition.id, definition]));
1883
+ for (const state of runtime.states.values()) {
1884
+ if (!definitions.has(state.id) && state.definition_snapshot) {
1885
+ definitions.set(state.id, state.definition_snapshot);
1886
+ }
1887
+ }
1888
+ return [...definitions.values()].map((definition) =>
1410
1889
  this.serializeForGui({ definition, runtime })
1411
1890
  );
1412
1891
  }
@@ -1599,14 +2078,12 @@ export class SessionLoopDefinitionService {
1599
2078
  }
1600
2079
  const refreshed = { runtime, storage: result.storage };
1601
2080
  if (typeof enabled === "boolean") {
1602
- this.setRuntimeEnabled({
1603
- runtime: refreshed.runtime,
1604
- definition: result.definition,
1605
- enabled,
1606
- rebase: result.status === "updated",
1607
- });
2081
+ this.setRuntimeEnabled({ runtime, definition: result.definition, enabled });
1608
2082
  result.desired_enabled = enabled;
1609
2083
  result.previous_enabled = previousEnabled;
2084
+ } else if (result.status === "updated") {
2085
+ result.desired_enabled = null;
2086
+ result.previous_enabled = previousEnabled;
1610
2087
  }
1611
2088
  return {
1612
2089
  status: result.status,
@@ -1675,7 +2152,11 @@ export class SessionLoopDefinitionService {
1675
2152
  agentFolder,
1676
2153
  loopId: scheduleId,
1677
2154
  },
1678
- () => this.reconcileLifecycleForFolder({ storage: result.storage })
2155
+ () =>
2156
+ this.reconcileLifecycleForFolder({
2157
+ storage: result.storage,
2158
+ explicitDeletedLoopId: scheduleId,
2159
+ })
1679
2160
  );
1680
2161
  return {
1681
2162
  status: "deleted",
@@ -1722,7 +2203,7 @@ export class SessionLoopDefinitionService {
1722
2203
  };
1723
2204
  const removed = this.sessions.delete(sessionId);
1724
2205
  return {
1725
- status: runtime ? "disabled" : "already_clear",
2206
+ status: runtime ? "intent_preserved_runtime_suspended" : "already_clear",
1726
2207
  host_session_id: sessionId,
1727
2208
  removed,
1728
2209
  runtime_state_count: runtimeStateCount,
@@ -1854,6 +2335,12 @@ export class SessionLoopDefinitionService {
1854
2335
  state.pending_occurrence = null;
1855
2336
  }
1856
2337
  state.last_error = null;
2338
+ state.availability_state = "available";
2339
+ state.unavailable_since = null;
2340
+ state.unavailable_observations = 0;
2341
+ state.candidate_definition = null;
2342
+ state.candidate_revision = null;
2343
+ this.resolveCurrentIncident(state, now);
1857
2344
  this.appendEvidence(state, "enabled", {
1858
2345
  reason: wasEnabled && !rebase ? "idempotent_enabled_intent" : "owner_enabled",
1859
2346
  });
@@ -1866,6 +2353,7 @@ export class SessionLoopDefinitionService {
1866
2353
  state.next_run_at = null;
1867
2354
  if (!occurrenceWasDispatching) state.pending_occurrence = null;
1868
2355
  state.suspension_reason = "owner_disabled";
2356
+ this.resolveCurrentIncident(state, nowIso(this.clock));
1869
2357
  if (pendingOccurrenceId && !occurrenceWasDispatching) {
1870
2358
  this.appendEvidence(state, "cancelled", {
1871
2359
  occurrenceId: pendingOccurrenceId,
@@ -1884,6 +2372,63 @@ export class SessionLoopDefinitionService {
1884
2372
  return state;
1885
2373
  }
1886
2374
 
2375
+ resolveIncidentForGui({
2376
+ hostSessionId,
2377
+ agentId,
2378
+ agentFolder,
2379
+ scheduleId,
2380
+ action,
2381
+ }) {
2382
+ return this.withAgentFolderMutation(agentFolder, (canonicalFolder) => {
2383
+ const { runtime, storage } = this.ensureRuntimeSessionUnlocked({
2384
+ hostSessionId,
2385
+ agentId,
2386
+ agentFolder: canonicalFolder,
2387
+ });
2388
+ const state = runtime.states.get(scheduleId);
2389
+ if (!state) throw new Error("Loop lifecycle state not found for recovery");
2390
+ const incident = this.currentIncident(state);
2391
+ if (!incident) throw new Error("Loop has no unresolved incident");
2392
+ const normalizedAction = normalizeRequiredString(action, "loop recovery action");
2393
+ const definition = storage.loops.find((entry) => entry.id === scheduleId) || null;
2394
+ if (normalizedAction === "keep_disabled") {
2395
+ if (definition) {
2396
+ state.definition_revision = definitionRevision(definition);
2397
+ state.definition_snapshot = snapshotDefinition(definition);
2398
+ }
2399
+ state.availability_state = definition ? "available" : state.availability_state;
2400
+ state.candidate_definition = null;
2401
+ state.candidate_revision = null;
2402
+ state.enabled = false;
2403
+ state.status = "disabled";
2404
+ state.cadence_anchor_at = null;
2405
+ state.next_run_at = null;
2406
+ state.pending_occurrence = null;
2407
+ state.suspension_reason = "owner_disabled";
2408
+ this.resolveCurrentIncident(state, nowIso(this.clock));
2409
+ this.appendEvidence(state, "disabled", { reason: "owner_kept_disabled" });
2410
+ this.persistRuntime(runtime);
2411
+ } else if (["restore", "restore_previous_enabled_state"].includes(normalizedAction)) {
2412
+ if (!definition || definitionRevision(definition) !== state.definition_revision) {
2413
+ throw new Error("The original Loop definition must return before it can be restored");
2414
+ }
2415
+ state.availability_state = "available";
2416
+ state.unavailable_since = null;
2417
+ state.unavailable_observations = 0;
2418
+ this.setRuntimeEnabled({ runtime, definition, enabled: true, rebase: true });
2419
+ } else {
2420
+ throw new Error(`Unsupported Loop recovery action ${normalizedAction}`);
2421
+ }
2422
+ const projectionDefinition = definition || state.definition_snapshot;
2423
+ return {
2424
+ status: "incident_resolved",
2425
+ action: normalizedAction,
2426
+ incident_id: incident.id,
2427
+ schedule: this.serializeForGui({ definition: projectionDefinition, runtime }),
2428
+ };
2429
+ });
2430
+ }
2431
+
1887
2432
  buildOccurrence(definition, state) {
1888
2433
  const dueAt = normalizeOptionalIsoTimestamp(
1889
2434
  state.next_run_at,
@@ -1931,7 +2476,7 @@ export class SessionLoopDefinitionService {
1931
2476
  agentFolder: canonicalFolder,
1932
2477
  },
1933
2478
  () => {
1934
- const currentStorage = this.store.readDefinitions(canonicalFolder);
2479
+ const currentStorage = this.readDefinitionsForReconciliation(canonicalFolder);
1935
2480
  this.reconcileLifecycleForFolder({ storage: currentStorage });
1936
2481
  return currentStorage;
1937
2482
  }
@@ -1987,6 +2532,23 @@ export class SessionLoopDefinitionService {
1987
2532
  return due;
1988
2533
  }
1989
2534
 
2535
+ refreshDueLoopForDispatch({ runtime, definition, state }) {
2536
+ const occurrenceId = state.pending_occurrence?.occurrence_id;
2537
+ return this.withAgentFolderMutation(runtime.agent_folder, (canonicalFolder) => {
2538
+ const storage = this.readDefinitionsForReconciliation(canonicalFolder);
2539
+ this.reconcileLifecycleForFolder({ storage });
2540
+ const currentDefinition = storage.loops.find((entry) => entry.id === definition.id);
2541
+ const currentState = runtime.states.get(definition.id);
2542
+ if (!currentDefinition || !currentState || !occurrenceId ||
2543
+ definitionRevision(currentDefinition) !== definitionRevision(definition) ||
2544
+ currentState.definition_revision !== definitionRevision(definition) ||
2545
+ currentState.enabled !== true || currentState.status !== "active" ||
2546
+ currentState.pending_occurrence?.occurrence_id !== occurrenceId ||
2547
+ currentState.pending_occurrence.state === "dispatching") return null;
2548
+ return { runtime, definition: currentDefinition, state: currentState };
2549
+ });
2550
+ }
2551
+
1990
2552
  recordSkipped({ runtime, definition, state, triggeredAt, reason }) {
1991
2553
  if (!state.pending_occurrence) {
1992
2554
  throw new Error("Busy Loop retry requires a pending occurrence");
@@ -2065,26 +2627,34 @@ export class SessionLoopDefinitionService {
2065
2627
  !durableState.pending_occurrence ||
2066
2628
  durableState.pending_occurrence.occurrence_id !== expectedOccurrenceId
2067
2629
  ) {
2068
- if (
2069
- durableState.enabled !== true &&
2070
- durableState.pending_occurrence === null
2071
- ) {
2630
+ if (durableState.pending_occurrence === null) {
2072
2631
  Object.assign(state, durableState);
2073
2632
  state.dispatch_count += 1;
2074
2633
  state.last_run_at = triggeredAt;
2075
- state.last_status = "dispatched-after-lifecycle-disable";
2634
+ state.last_status = durableState.enabled === true
2635
+ ? "dispatched-after-lifecycle-reconstruction"
2636
+ : "dispatched-after-lifecycle-disable";
2076
2637
  state.last_error = null;
2077
2638
  state.last_dispatched_message_id = dispatchedMessageId;
2078
2639
  this.appendEvidence(state, "succeeded", {
2079
2640
  occurrenceId: expectedOccurrenceId,
2080
- reason: "matrix_append_committed_after_lifecycle_default_off",
2641
+ reason: durableState.enabled === true
2642
+ ? "matrix_append_committed_after_intent_preserving_reconstruction"
2643
+ : "matrix_append_committed_after_lifecycle_default_off",
2081
2644
  appendIdentity: matrixEventId || dispatchedMessageId,
2082
2645
  recordedAt: triggeredAt,
2083
2646
  });
2084
2647
  this.persistState(runtime, state);
2085
2648
  return state;
2086
2649
  }
2087
- throw new Error("Loop occurrence authority changed before success commit");
2650
+ this.persistTerminalEvidence(runtime, {
2651
+ transition: "succeeded", recorded_at: triggeredAt, loop_id: definition.id,
2652
+ occurrence_id: expectedOccurrenceId,
2653
+ reason: "matrix_append_committed_after_occurrence_superseded",
2654
+ append_identity_sha256: hashText(matrixEventId || dispatchedMessageId),
2655
+ });
2656
+ Object.assign(state, durableState);
2657
+ return state;
2088
2658
  }
2089
2659
  Object.assign(state, durableState);
2090
2660
  if (!state.pending_occurrence) {
@@ -2139,13 +2709,12 @@ export class SessionLoopDefinitionService {
2139
2709
  !durableState.pending_occurrence ||
2140
2710
  durableState.pending_occurrence.occurrence_id !== expectedOccurrenceId
2141
2711
  ) {
2142
- if (
2143
- durableState.enabled !== true &&
2144
- durableState.pending_occurrence === null
2145
- ) {
2712
+ if (durableState.pending_occurrence === null) {
2146
2713
  Object.assign(state, durableState);
2147
2714
  state.last_run_at = triggeredAt;
2148
- state.last_status = "failed-after-lifecycle-disable";
2715
+ state.last_status = durableState.enabled === true
2716
+ ? "failed-after-lifecycle-reconstruction"
2717
+ : "failed-after-lifecycle-disable";
2149
2718
  state.last_error = reason;
2150
2719
  this.appendEvidence(state, "failed", {
2151
2720
  occurrenceId: expectedOccurrenceId,
@@ -2155,7 +2724,13 @@ export class SessionLoopDefinitionService {
2155
2724
  this.persistState(runtime, state);
2156
2725
  return state;
2157
2726
  }
2158
- throw new Error("Loop occurrence authority changed before failure commit");
2727
+ this.persistTerminalEvidence(runtime, {
2728
+ transition: "failed", recorded_at: triggeredAt, loop_id: definition.id,
2729
+ occurrence_id: expectedOccurrenceId, reason,
2730
+ append_identity_sha256: null,
2731
+ });
2732
+ Object.assign(state, durableState);
2733
+ return state;
2159
2734
  }
2160
2735
  Object.assign(state, durableState);
2161
2736
  if (!state.pending_occurrence) {
@@ -2209,7 +2784,7 @@ export class SessionLoopDefinitionService {
2209
2784
  agentFolder: canonicalFolder,
2210
2785
  },
2211
2786
  () => {
2212
- const currentStorage = this.store.readDefinitions(canonicalFolder);
2787
+ const currentStorage = this.readDefinitionsForReconciliation(canonicalFolder);
2213
2788
  this.reconcileLifecycleForFolder({ storage: currentStorage });
2214
2789
  return currentStorage;
2215
2790
  }
@@ -2221,7 +2796,7 @@ export class SessionLoopDefinitionService {
2221
2796
  const loops = [];
2222
2797
  for (const [loopId, state] of runtime.states.entries()) {
2223
2798
  const definition = definitionsById.get(loopId);
2224
- if (!definition || state.enabled !== true) continue;
2799
+ if (!definition || state.enabled !== true || state.status !== "active") continue;
2225
2800
  loops.push({
2226
2801
  loop_id: loopId,
2227
2802
  enabled: true,
@@ -2235,6 +2810,7 @@ export class SessionLoopDefinitionService {
2235
2810
  cadence_anchor_at: state.cadence_anchor_at || null,
2236
2811
  pending_occurrence: state.pending_occurrence || null,
2237
2812
  interval_ms: definition.interval_ms,
2813
+ definition_revision: state.definition_revision,
2238
2814
  runtime_state_owner: SESSION_LOOP_RUNTIME_OWNER,
2239
2815
  definition_storage_owner: SESSION_LOOP_DEFINITION_STORAGE_OWNER,
2240
2816
  });
@@ -2258,7 +2834,12 @@ export class SessionLoopDefinitionService {
2258
2834
 
2259
2835
  restoreHostRestartRuntimeState(
2260
2836
  snapshot = {},
2261
- { restartId, restoredAt, restoredSessionIds = null } = {}
2837
+ {
2838
+ restartId,
2839
+ restoredAt,
2840
+ restoredSessionIds = null,
2841
+ loopRestorePolicy = "default_off",
2842
+ } = {}
2262
2843
  ) {
2263
2844
  const runtimeSessions = Array.isArray(snapshot.runtime_sessions)
2264
2845
  ? snapshot.runtime_sessions
@@ -2270,6 +2851,7 @@ export class SessionLoopDefinitionService {
2270
2851
  )
2271
2852
  )
2272
2853
  : null;
2854
+ const restored = [];
2273
2855
  const disabled = [];
2274
2856
  const skipped = [];
2275
2857
  const timestamp = restoredAt || nowIso(this.clock);
@@ -2309,6 +2891,42 @@ export class SessionLoopDefinitionService {
2309
2891
  });
2310
2892
  continue;
2311
2893
  }
2894
+ const revisionMatches =
2895
+ typeof loop.definition_revision === "string" &&
2896
+ loop.definition_revision === definitionRevision(definition);
2897
+ if (
2898
+ loopRestorePolicy === "restore_enabled_with_fresh_cadence" &&
2899
+ sessionRuntimeRestored &&
2900
+ revisionMatches
2901
+ ) {
2902
+ this.setRuntimeEnabled({
2903
+ runtime,
2904
+ definition,
2905
+ enabled: true,
2906
+ rebase: true,
2907
+ });
2908
+ restored.push({
2909
+ host_session_id: entry.host_session_id,
2910
+ loop_id: definition.id,
2911
+ restart_id: restartId || null,
2912
+ enabled: true,
2913
+ next_run_at: runtime.states.get(definition.id)?.next_run_at || null,
2914
+ reason: "restore_enabled_with_fresh_cadence",
2915
+ });
2916
+ continue;
2917
+ }
2918
+ const reason = loopRestorePolicy !== "restore_enabled_with_fresh_cadence"
2919
+ ? "legacy_policy_default_off"
2920
+ : !sessionRuntimeRestored
2921
+ ? "session_restore_failed"
2922
+ : "definition_revision_changed";
2923
+ state.enabled = false;
2924
+ state.status = "disabled";
2925
+ state.cadence_anchor_at = null;
2926
+ state.next_run_at = null;
2927
+ state.pending_occurrence = null;
2928
+ state.suspension_reason = reason;
2929
+ this.persistRuntime(runtime);
2312
2930
  disabled.push({
2313
2931
  host_session_id: entry.host_session_id,
2314
2932
  loop_id: definition.id,
@@ -2317,7 +2935,7 @@ export class SessionLoopDefinitionService {
2317
2935
  enabled: false,
2318
2936
  pending_occurrence_id: null,
2319
2937
  next_run_at: null,
2320
- reason: resetReason,
2938
+ reason,
2321
2939
  loops_json_mutated: false,
2322
2940
  });
2323
2941
  }
@@ -2332,11 +2950,12 @@ export class SessionLoopDefinitionService {
2332
2950
  }
2333
2951
  }
2334
2952
  return {
2335
- restored: [],
2953
+ restored,
2336
2954
  disabled,
2337
2955
  skipped,
2338
2956
  restored_at: timestamp,
2339
- default_off: true,
2957
+ default_off: loopRestorePolicy !== "restore_enabled_with_fresh_cadence",
2958
+ loop_restore_policy: loopRestorePolicy,
2340
2959
  };
2341
2960
  }
2342
2961
 
@@ -2436,11 +3055,19 @@ export class SessionLoopDefinitionService {
2436
3055
  definition_revision: state.definition_revision,
2437
3056
  definition_revision_algorithm:
2438
3057
  SESSION_LOOP_DEFINITION_REVISION_ALGORITHM,
2439
- enabled: state.enabled === true && state.status === "active",
3058
+ enabled: state.enabled === true,
2440
3059
  enabled_intent: state.enabled === true,
2441
3060
  runnable: state.enabled === true && state.status === "active",
2442
3061
  status: state.status,
2443
3062
  suspension_reason: state.suspension_reason,
3063
+ definition_generation: state.definition_generation || 0,
3064
+ definition_availability: state.availability_state || "available",
3065
+ unavailable_since: state.unavailable_since || null,
3066
+ unavailable_observations: state.unavailable_observations || 0,
3067
+ candidate_definition_revision: state.candidate_revision || null,
3068
+ incidents: (state.incidents || []).map((incident) => ({ ...incident })),
3069
+ active_incident: this.currentIncident(state),
3070
+ recovery_options: this.currentIncident(state)?.recovery_options || [],
2444
3071
  cadence_anchor_at: state.cadence_anchor_at,
2445
3072
  next_run_at: state.next_run_at,
2446
3073
  pending_occurrence: state.pending_occurrence