@aitne/daemon 0.1.10 → 0.1.11

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 (305) hide show
  1. package/dist/adapters/adapter-watchdog.d.ts +70 -0
  2. package/dist/adapters/adapter-watchdog.js +115 -0
  3. package/dist/adapters/discord.d.ts +17 -1
  4. package/dist/adapters/discord.js +33 -0
  5. package/dist/adapters/notification-manager.d.ts +27 -1
  6. package/dist/adapters/notification-manager.js +54 -39
  7. package/dist/adapters/slack-adapter.d.ts +26 -1
  8. package/dist/adapters/slack-adapter.js +41 -0
  9. package/dist/adapters/telegram-adapter.d.ts +18 -1
  10. package/dist/adapters/telegram-adapter.js +41 -2
  11. package/dist/adapters/types.d.ts +20 -0
  12. package/dist/adapters/whatsapp-adapter.d.ts +26 -7
  13. package/dist/adapters/whatsapp-adapter.js +74 -21
  14. package/dist/api/env-writer.js +8 -5
  15. package/dist/api/helpers/agent-errors-registry.d.ts +5 -5
  16. package/dist/api/helpers/agent-errors-registry.js +5 -5
  17. package/dist/api/routes/agent.js +33 -12
  18. package/dist/api/routes/agents/index.js +75 -16
  19. package/dist/api/routes/agents/views.d.ts +37 -2
  20. package/dist/api/routes/agents/views.js +64 -2
  21. package/dist/api/routes/background-task.d.ts +22 -0
  22. package/dist/api/routes/background-task.js +338 -0
  23. package/dist/api/routes/browser-history.js +9 -1
  24. package/dist/api/routes/context/permissions.js +3 -2
  25. package/dist/api/routes/context/snapshots.js +0 -3
  26. package/dist/api/routes/context/write.js +3 -17
  27. package/dist/api/routes/dashboard/config.js +48 -12
  28. package/dist/api/routes/dashboard/cost-approvals.js +66 -0
  29. package/dist/api/routes/dashboard/notifications.js +9 -9
  30. package/dist/api/routes/integrations/crud-patch.js +5 -1
  31. package/dist/api/routes/integrations-reconcile.js +2 -2
  32. package/dist/api/routes/notion.d.ts +1 -1
  33. package/dist/api/routes/observations.js +7 -7
  34. package/dist/api/routes/obsidian.d.ts +1 -1
  35. package/dist/api/routes/receipts.js +5 -1
  36. package/dist/api/routes/setup-migrate.js +1 -1
  37. package/dist/api/routes/setup.js +1 -1
  38. package/dist/api/routes/task-flows.d.ts +1 -1
  39. package/dist/api/routes/task-flows.js +1 -1
  40. package/dist/api/routes/tuning.d.ts +29 -0
  41. package/dist/api/routes/tuning.js +304 -0
  42. package/dist/api/server.d.ts +44 -16
  43. package/dist/api/server.js +9 -0
  44. package/dist/bootstrap/adapters.d.ts +19 -0
  45. package/dist/bootstrap/adapters.js +61 -0
  46. package/dist/bootstrap/api.d.ts +5 -3
  47. package/dist/bootstrap/api.js +45 -13
  48. package/dist/bootstrap/catchup.d.ts +1 -1
  49. package/dist/bootstrap/catchup.js +11 -11
  50. package/dist/bootstrap/event-pipeline.d.ts +11 -0
  51. package/dist/bootstrap/event-pipeline.js +245 -7
  52. package/dist/bootstrap/observers.js +9 -6
  53. package/dist/bootstrap/schedule-helpers.d.ts +104 -6
  54. package/dist/bootstrap/schedule-helpers.js +172 -19
  55. package/dist/config.js +26 -12
  56. package/dist/core/agent-core.d.ts +33 -1
  57. package/dist/core/agent-core.js +36 -1
  58. package/dist/core/agents/activity-scan-cadence.d.ts +103 -0
  59. package/dist/core/agents/activity-scan-cadence.js +127 -0
  60. package/dist/core/agents/agent-route-override.d.ts +53 -0
  61. package/dist/core/agents/agent-route-override.js +69 -0
  62. package/dist/core/agents/builtin-registry.d.ts +51 -14
  63. package/dist/core/agents/builtin-registry.js +92 -15
  64. package/dist/core/agents/config-gate-reconcile.d.ts +38 -0
  65. package/dist/core/agents/config-gate-reconcile.js +51 -0
  66. package/dist/core/agents/cron-substitute.d.ts +1 -1
  67. package/dist/core/agents/cron-substitute.js +1 -1
  68. package/dist/core/agents/custom-routine-migration.d.ts +60 -0
  69. package/dist/core/agents/custom-routine-migration.js +149 -0
  70. package/dist/core/agents/firing-blocked.d.ts +1 -1
  71. package/dist/core/agents/hourly-cadence.d.ts +102 -0
  72. package/dist/core/agents/hourly-cadence.js +126 -0
  73. package/dist/core/agents/loader-boot.js +23 -0
  74. package/dist/core/agents/loader.d.ts +19 -0
  75. package/dist/core/agents/loader.js +34 -2
  76. package/dist/core/agents/override-merge.d.ts +1 -1
  77. package/dist/core/agents/override-merge.js +9 -1
  78. package/dist/core/agents/recurrence-convert.d.ts +1 -1
  79. package/dist/core/agents/recurrence-convert.js +1 -1
  80. package/dist/core/agents/recurring-schedule-adapter.js +8 -0
  81. package/dist/core/alerts.js +6 -6
  82. package/dist/core/backends/auth-health-monitor.d.ts +2 -2
  83. package/dist/core/backends/auth-health-monitor.js +1 -1
  84. package/dist/core/backends/backend-router.d.ts +27 -1
  85. package/dist/core/backends/backend-router.js +165 -1
  86. package/dist/core/backends/claude-code-core.d.ts +71 -31
  87. package/dist/core/backends/claude-code-core.js +282 -54
  88. package/dist/core/backends/cli-quota-guards.d.ts +29 -1
  89. package/dist/core/backends/cli-quota-guards.js +40 -5
  90. package/dist/core/backends/codex-core.d.ts +6 -0
  91. package/dist/core/backends/codex-core.js +22 -6
  92. package/dist/core/backends/failure-spend.d.ts +58 -0
  93. package/dist/core/backends/failure-spend.js +137 -0
  94. package/dist/core/backends/gemini-cli-core.d.ts +6 -0
  95. package/dist/core/backends/gemini-cli-core.js +25 -6
  96. package/dist/core/backends/model-registry.d.ts +1 -1
  97. package/dist/core/backends/model-registry.js +4 -4
  98. package/dist/core/backends/opencode-core.d.ts +1 -1
  99. package/dist/core/backends/opencode-core.js +5 -5
  100. package/dist/core/backends/plan-presets.js +39 -15
  101. package/dist/core/bang-commands/commands-cost.js +3 -1
  102. package/dist/core/bang-commands/commands-report.js +4 -3
  103. package/dist/core/bang-commands/commands-research.js +4 -1
  104. package/dist/core/bang-commands/commands-revert-tuning.d.ts +18 -0
  105. package/dist/core/bang-commands/commands-revert-tuning.js +63 -0
  106. package/dist/core/bang-commands/commands-stop-start.js +3 -3
  107. package/dist/core/bang-commands/commands-task-control.d.ts +19 -0
  108. package/dist/core/bang-commands/commands-task-control.js +147 -0
  109. package/dist/core/bang-commands/commands-wiki.js +5 -5
  110. package/dist/core/bang-commands/index.d.ts +2 -0
  111. package/dist/core/bang-commands/index.js +12 -0
  112. package/dist/core/bang-commands/registry.d.ts +12 -0
  113. package/dist/core/browser-history/research-cluster-fanout.d.ts +28 -14
  114. package/dist/core/browser-history/research-cluster-fanout.js +39 -16
  115. package/dist/core/channel-timeline.d.ts +5 -1
  116. package/dist/core/channel-timeline.js +13 -0
  117. package/dist/core/context/index-reconciler.js +5 -2
  118. package/dist/core/context/policy-index-reconciler.d.ts +6 -4
  119. package/dist/core/context/policy-index-runner.js +25 -6
  120. package/dist/core/context-builder-calendar.js +10 -2
  121. package/dist/core/context-builder-conversation.d.ts +8 -1
  122. package/dist/core/context-builder-conversation.js +41 -7
  123. package/dist/core/context-builder-yesterday.js +4 -3
  124. package/dist/core/context-builder.d.ts +7 -2
  125. package/dist/core/context-builder.js +62 -20
  126. package/dist/core/context-file-serializer.d.ts +1 -1
  127. package/dist/core/context-file-serializer.js +1 -1
  128. package/dist/core/context-health.js +2 -2
  129. package/dist/core/context-paths.d.ts +1 -1
  130. package/dist/core/context-paths.js +1 -1
  131. package/dist/core/context-validation/prepare-write.js +1 -1
  132. package/dist/core/context-validation/routine-rulebook.d.ts +1 -1
  133. package/dist/core/context-vault-aliases.d.ts +0 -13
  134. package/dist/core/context-vault-aliases.js +37 -0
  135. package/dist/core/custom-routines.d.ts +99 -0
  136. package/dist/core/custom-routines.js +187 -0
  137. package/dist/core/daemon-api-cli.js +49 -0
  138. package/dist/core/day-boundary.d.ts +46 -0
  139. package/dist/core/day-boundary.js +40 -0
  140. package/dist/core/dispatcher-activity-scan.d.ts +221 -0
  141. package/dist/core/dispatcher-activity-scan.js +775 -0
  142. package/dist/core/dispatcher-error-handling.d.ts +6 -11
  143. package/dist/core/dispatcher-error-handling.js +38 -62
  144. package/dist/core/dispatcher-hourly-check.js +6 -1
  145. package/dist/core/dispatcher-message-handler.d.ts +10 -0
  146. package/dist/core/dispatcher-message-handler.js +17 -0
  147. package/dist/core/dispatcher-morning-routine.d.ts +6 -6
  148. package/dist/core/dispatcher-morning-routine.js +13 -13
  149. package/dist/core/dispatcher-result-processor.d.ts +33 -0
  150. package/dist/core/dispatcher-result-processor.js +167 -11
  151. package/dist/core/dispatcher-scheduled-background-task.d.ts +42 -0
  152. package/dist/core/dispatcher-scheduled-background-task.js +89 -0
  153. package/dist/core/dispatcher-scheduled-tasks.d.ts +63 -1
  154. package/dist/core/dispatcher-scheduled-tasks.js +213 -6
  155. package/dist/core/dispatcher-task-delivery.d.ts +105 -0
  156. package/dist/core/dispatcher-task-delivery.js +555 -0
  157. package/dist/core/dispatcher-types.d.ts +48 -9
  158. package/dist/core/dispatcher-types.js +3 -3
  159. package/dist/core/dispatcher.d.ts +112 -31
  160. package/dist/core/dispatcher.js +284 -59
  161. package/dist/core/dm-freshness-metrics.d.ts +1 -1
  162. package/dist/core/drift-effects.js +2 -2
  163. package/dist/core/feedback/consolidation-prep.js +17 -5
  164. package/dist/core/feedback/eviction-scorer.js +6 -2
  165. package/dist/core/feedback/lesson-format.js +9 -4
  166. package/dist/core/feedback/lesson-injection.d.ts +1 -1
  167. package/dist/core/feedback/lesson-injection.js +17 -2
  168. package/dist/core/feedback/lesson-store-overview.d.ts +8 -4
  169. package/dist/core/feedback/lesson-store-overview.js +8 -4
  170. package/dist/core/feedback/regeneralization-prep.js +29 -16
  171. package/dist/core/feedback/self-performance-prep.d.ts +186 -0
  172. package/dist/core/feedback/self-performance-prep.js +541 -0
  173. package/dist/core/feedback/tuning-actuator.d.ts +198 -0
  174. package/dist/core/feedback/tuning-actuator.js +432 -0
  175. package/dist/core/feedback/tuning-recommender.d.ts +247 -0
  176. package/dist/core/feedback/tuning-recommender.js +580 -0
  177. package/dist/core/feedback/tuning-revert-monitor.d.ts +90 -0
  178. package/dist/core/feedback/tuning-revert-monitor.js +213 -0
  179. package/dist/core/health-monitor.d.ts +6 -0
  180. package/dist/core/health-monitor.js +1 -1
  181. package/dist/core/injection-policy.d.ts +4 -4
  182. package/dist/core/injection-policy.js +4 -4
  183. package/dist/core/integration-main-backend.js +4 -0
  184. package/dist/core/management-md.d.ts +2 -2
  185. package/dist/core/management-md.js +51 -13
  186. package/dist/core/morning/orchestrator.d.ts +2 -2
  187. package/dist/core/morning/orchestrator.js +2 -2
  188. package/dist/core/notification-gate.d.ts +64 -0
  189. package/dist/core/notification-gate.js +51 -0
  190. package/dist/core/notification-rate-limit.d.ts +40 -0
  191. package/dist/core/notification-rate-limit.js +50 -0
  192. package/dist/core/policy-files.d.ts +1 -1
  193. package/dist/core/policy-files.js +2 -2
  194. package/dist/core/pre-pass-freshness.d.ts +4 -4
  195. package/dist/core/retention.d.ts +5 -0
  196. package/dist/core/retention.js +20 -4
  197. package/dist/core/review-context.d.ts +1 -1
  198. package/dist/core/review-context.js +10 -5
  199. package/dist/core/roadmap-write-lock.d.ts +2 -1
  200. package/dist/core/roadmap-write-lock.js +15 -10
  201. package/dist/core/routine-acquisition-plan.d.ts +47 -1
  202. package/dist/core/routine-acquisition-plan.js +78 -20
  203. package/dist/core/routine-fetch-window-retry.js +7 -4
  204. package/dist/core/routine-fetch-window-runner.d.ts +39 -3
  205. package/dist/core/routine-fetch-window-runner.js +264 -13
  206. package/dist/core/routine-windows.d.ts +2 -2
  207. package/dist/core/routine-windows.js +8 -5
  208. package/dist/core/scheduler.d.ts +175 -16
  209. package/dist/core/scheduler.js +559 -102
  210. package/dist/core/signal-detector.d.ts +12 -0
  211. package/dist/core/signal-detector.js +53 -9
  212. package/dist/core/skills-compiler-denied-tools.js +2 -2
  213. package/dist/core/skills-compiler-skill-index.d.ts +2 -2
  214. package/dist/core/skills-compiler-skill-index.js +2 -2
  215. package/dist/core/skills-compiler-variants.d.ts +1 -1
  216. package/dist/core/skills-compiler-variants.js +8 -0
  217. package/dist/core/skills-compiler.d.ts +29 -26
  218. package/dist/core/skills-compiler.js +117 -81
  219. package/dist/core/skills-manifest.d.ts +37 -0
  220. package/dist/core/skills-manifest.js +73 -2
  221. package/dist/core/sleep-inhibitor.d.ts +79 -0
  222. package/dist/core/sleep-inhibitor.js +132 -0
  223. package/dist/core/slim-system-prompt-loader.d.ts +77 -0
  224. package/dist/core/slim-system-prompt-loader.js +141 -0
  225. package/dist/core/spawn-gates.d.ts +126 -0
  226. package/dist/core/spawn-gates.js +180 -0
  227. package/dist/core/today-direct-writer.d.ts +2 -2
  228. package/dist/core/today-direct-writer.js +1 -1
  229. package/dist/core/today-write-lock.d.ts +4 -2
  230. package/dist/core/today-write-lock.js +30 -20
  231. package/dist/core/wake-detector.d.ts +55 -0
  232. package/dist/core/wake-detector.js +80 -0
  233. package/dist/core/wiki/compile-lock.d.ts +1 -1
  234. package/dist/core/wiki/compile-lock.js +1 -1
  235. package/dist/core/workdir.js +15 -6
  236. package/dist/db/activity-scan-signals.d.ts +77 -0
  237. package/dist/db/activity-scan-signals.js +378 -0
  238. package/dist/db/agents-store.d.ts +28 -0
  239. package/dist/db/agents-store.js +62 -0
  240. package/dist/db/background-task-clarifications-store.d.ts +81 -0
  241. package/dist/db/background-task-clarifications-store.js +152 -0
  242. package/dist/db/background-task-store.d.ts +207 -0
  243. package/dist/db/background-task-store.js +380 -0
  244. package/dist/db/browser-history-store.d.ts +39 -6
  245. package/dist/db/browser-history-store.js +51 -7
  246. package/dist/db/browser-task-clarifications-store.d.ts +12 -0
  247. package/dist/db/browser-task-clarifications-store.js +35 -5
  248. package/dist/db/browser-task-store.d.ts +3 -0
  249. package/dist/db/browser-task-store.js +29 -4
  250. package/dist/db/deferred-dm.d.ts +86 -0
  251. package/dist/db/deferred-dm.js +199 -0
  252. package/dist/db/migrations.js +330 -0
  253. package/dist/db/observations.d.ts +2 -2
  254. package/dist/db/observations.js +3 -3
  255. package/dist/db/schema.js +217 -16
  256. package/dist/db/voice-transcripts-store.d.ts +1 -1
  257. package/dist/index.js +86 -29
  258. package/dist/messaging/browser-task-mcp-notifier.d.ts +12 -70
  259. package/dist/messaging/browser-task-mcp-notifier.js +30 -151
  260. package/dist/messaging/browser-task-screenshot-attachment.d.ts +15 -0
  261. package/dist/messaging/browser-task-screenshot-attachment.js +63 -0
  262. package/dist/observers/delegated-sync-worker.d.ts +6 -6
  263. package/dist/observers/delegated-sync-worker.js +10 -10
  264. package/dist/observers/git-delegated-cron.d.ts +1 -1
  265. package/dist/observers/git-delegated-cron.js +2 -2
  266. package/dist/observers/github-poller-classifier.d.ts +3 -3
  267. package/dist/observers/github-poller-classifier.js +3 -3
  268. package/dist/observers/imminent-event-scheduler.d.ts +1 -1
  269. package/dist/observers/imminent-event-scheduler.js +1 -1
  270. package/dist/observers/mail-poller.d.ts +1 -0
  271. package/dist/observers/mail-poller.js +42 -3
  272. package/dist/observers/observation-summarizer/summarizer-client.d.ts +2 -2
  273. package/dist/observers/observation-summarizer/summarizer-client.js +2 -2
  274. package/dist/observers/observation-summarizer/worker.d.ts +2 -2
  275. package/dist/observers/observation-summarizer/worker.js +4 -4
  276. package/dist/observers/obsidian-watcher.d.ts +1 -1
  277. package/dist/observers/obsidian-watcher.js +1 -1
  278. package/dist/safety/agent-write-tracker.d.ts +4 -4
  279. package/dist/safety/agent-write-tracker.js +4 -4
  280. package/dist/safety/audit.d.ts +43 -5
  281. package/dist/safety/audit.js +86 -18
  282. package/dist/safety/risk-classifier.d.ts +6 -0
  283. package/dist/safety/risk-classifier.js +75 -11
  284. package/dist/scheduler/activity-scan-gate.d.ts +86 -0
  285. package/dist/scheduler/activity-scan-gate.js +132 -0
  286. package/dist/services/background-task/background-task-budget.d.ts +80 -0
  287. package/dist/services/background-task/background-task-budget.js +91 -0
  288. package/dist/services/background-task/background-task-driver.d.ts +105 -0
  289. package/dist/services/background-task/background-task-driver.js +416 -0
  290. package/dist/services/background-task/background-task-runner.d.ts +96 -0
  291. package/dist/services/background-task/background-task-runner.js +673 -0
  292. package/dist/services/background-task/background-task-tools.d.ts +84 -0
  293. package/dist/services/background-task/background-task-tools.js +247 -0
  294. package/dist/services/background-task/background-task-transition-events.d.ts +43 -0
  295. package/dist/services/background-task/background-task-transition-events.js +54 -0
  296. package/dist/services/browser-history/automation/egress-denylist.d.ts +1 -1
  297. package/dist/services/browser-history/automation/egress-denylist.js +16 -6
  298. package/dist/services/browser-history/managed-chromium/sandbox-launcher.js +0 -1
  299. package/dist/services/browser-task/browser-task-runner.js +53 -8
  300. package/dist/services/observations-batch.d.ts +1 -1
  301. package/dist/services/observations-batch.js +2 -2
  302. package/dist/settings/runtime-settings.d.ts +38 -11
  303. package/dist/settings/runtime-settings.js +203 -40
  304. package/dist/settings/settings-store.js +11 -3
  305. package/package.json +4 -4
@@ -7,7 +7,10 @@ import { discardStalePendingSchedules } from "./schedule-maintenance.js";
7
7
  import { createLogger } from "../logging.js";
8
8
  import { reconcileRecurringSchedules } from "../db/recurring-schedules.js";
9
9
  import { recordAgentFiringBlocked } from "./agents/firing-blocked.js";
10
- import { getStalledMorningRoutineWake, readMorningRoutineStallThresholdMinutes, } from "../bootstrap/schedule-helpers.js";
10
+ import { resolveActivityScanCadence } from "./agents/activity-scan-cadence.js";
11
+ import { getRuntimeWindow } from "../db/agents-store.js";
12
+ import { getDueCatchupRoutines, getRecoverableStalledMorningWake, getStalledMorningRoutineWake, MORNING_MISSED_FIRE_GRACE_MINUTES, morningRoutineRanToday, readMorningRoutineStallThresholdMinutes, shouldCatchUpActivityScan, shouldQueueMissedMorningFire, } from "../bootstrap/schedule-helpers.js";
13
+ import { WakeDetector } from "./wake-detector.js";
11
14
  import { readRuntimeState, writeRuntimeState } from "../db/runtime-state.js";
12
15
  import { recordProactiveForwardDeliveries } from "./channel-timeline.js";
13
16
  import { isInQuietHoursAt, nextQuietHoursEndMs } from "./quiet-hours.js";
@@ -18,6 +21,47 @@ import { isInQuietHoursAt, nextQuietHoursEndMs } from "./quiet-hours.js";
18
21
  * agent-day even if the cron tick that owns it runs every minute.
19
22
  */
20
23
  const MORNING_ROUTINE_STALL_ALERT_KEY = "morning_routine.stall_alert_day";
24
+ /**
25
+ * Cadence of the morning self-heal tick. The tick is cheap (three indexed
26
+ * SQLite reads in the common healthy case), and 10 minutes bounds the
27
+ * worst-case detection latency for a swallowed 04:00 cron fire at
28
+ * `MORNING_MISSED_FIRE_GRACE_MINUTES + 10`. Deliberately a dedicated
29
+ * interval rather than a rider on the activity-scan cron: the watchdog
30
+ * historically rode that cron and went silently dead the moment an
31
+ * operator set `activityScanEnabled=false` — exactly the configuration
32
+ * where the morning routine has no other safety net.
33
+ */
34
+ export const MORNING_SELF_HEAL_INTERVAL_MS = 10 * 60_000;
35
+ /**
36
+ * Per-agent-day cap on hung-run recovery flips. Each flip re-runs the
37
+ * morning pipeline (pre-pass + Stage A — real backend spend), so a
38
+ * deterministically-wedging environment must not be retried every
39
+ * stall-threshold window all day. Past the cap the self-heal degrades to
40
+ * alert-only; the owner DM and a daemon restart are the escape hatches.
41
+ */
42
+ export const MAX_SELFHEAL_REQUEUES_PER_AGENT_DAY = 2;
43
+ /**
44
+ * Missed-fire suppression window after process start. Boot catchup owns
45
+ * stale-today.md recovery at startup and runs the morning routine
46
+ * INLINE — no wake row exists for `shouldQueueMissedMorningFire` to
47
+ * dedup against, and its attempt audit row only appears once the
48
+ * fetch-window pre-pass hands over to Stage A. A pathologically slow
49
+ * pre-pass must not read as a missed fire, so the layer stays quiet
50
+ * until the boot path has long since either produced attempt rows or
51
+ * died (in which case the next tick after the window picks it up).
52
+ */
53
+ export const MISSED_FIRE_BOOT_SUPPRESSION_MS = 30 * 60_000;
54
+ /**
55
+ * Routine name → built-in Agent slug, for the per-agent enabled gate on the
56
+ * wake catch-up path. Must match the slugs the corresponding cron callbacks
57
+ * pass to `isAgentEnabledForFiring` so a disabled Agent is suppressed
58
+ * identically whether its trigger arrives via cron or via wake catch-up.
59
+ */
60
+ const WAKE_CATCHUP_AGENT_SLUGS = {
61
+ evening_review: "evening-review",
62
+ weekly_review: "weekly-review",
63
+ monthly_review: "monthly-review",
64
+ };
21
65
  const logger = createLogger("scheduler");
22
66
  /**
23
67
  * True iff `intervalMinutes` cleanly fits inside an hour, so the firing
@@ -29,7 +73,7 @@ function isDivisorOfHour(intervalMinutes) {
29
73
  return intervalMinutes >= 1 && intervalMinutes <= 60 && 60 % intervalMinutes === 0;
30
74
  }
31
75
  /**
32
- * Build the cron expression that drives the hourly check.
76
+ * Build the cron expression that drives the activity scan.
33
77
  *
34
78
  * Two regimes:
35
79
  *
@@ -42,7 +86,7 @@ function isDivisorOfHour(intervalMinutes) {
42
86
  * 2. **Arbitrary interval** (anything else, e.g. 7, 45, 90, 120, 720,
43
87
  * 1440): we emit `"* <hourRange> * * *"` (every minute within active
44
88
  * hours). The caller is expected to gate each tick with
45
- * `shouldFireHourlyTickAt(...)`, which anchors the cadence to
89
+ * `shouldFireActivityScanTickAt(...)`, which anchors the cadence to
46
90
  * `activeStartHour` via `((h*60 + m) - activeStartHour*60) %
47
91
  * intervalMinutes`. This anchor matters: a midnight-anchored modulo
48
92
  * plus `activeStartHour > 0` would silently drop intervals where the
@@ -54,9 +98,9 @@ function isDivisorOfHour(intervalMinutes) {
54
98
  *
55
99
  * The minute-tick cron does fire 60× per hour even when most ticks are
56
100
  * no-ops, but the callback's first action is the modulo check — overhead
57
- * is negligible compared to the actual hourly-check work.
101
+ * is negligible compared to the actual activity-scan work.
58
102
  */
59
- export function buildHourlyCronExpr(intervalMinutes, startHour, endHourExclusive) {
103
+ export function buildActivityScanCronExpr(intervalMinutes, startHour, endHourExclusive) {
60
104
  const endHour = Math.max(startHour, endHourExclusive - 1);
61
105
  const hourRange = startHour === endHour ? `${startHour}` : `${startHour}-${endHour}`;
62
106
  if (isDivisorOfHour(intervalMinutes)) {
@@ -86,7 +130,7 @@ export function buildHourlyCronExpr(intervalMinutes, startHour, endHourExclusive
86
130
  * so the divisor early-return doesn't change behavior — it's just
87
131
  * explicit about which path the cron expression itself handles.
88
132
  */
89
- export function shouldFireHourlyTickAt(localHour, localMinute, intervalMinutes, activeStartHour) {
133
+ export function shouldFireActivityScanTickAt(localHour, localMinute, intervalMinutes, activeStartHour) {
90
134
  if (isDivisorOfHour(intervalMinutes))
91
135
  return true;
92
136
  const minutesSinceMidnight = localHour * 60 + localMinute;
@@ -163,12 +207,12 @@ export class AgentScheduler {
163
207
  noFutureTasksWarned = false;
164
208
  onDayBoundary = null;
165
209
  sendDm = null;
166
- onHourlyCheck = null;
210
+ onActivityScan = null;
167
211
  /**
168
212
  * Phase 4 auth probe hook — fired on every hourly cron tick BEFORE
169
- * `onHourlyCheck` so the probe gets a chance to refresh DB cache +
213
+ * `onActivityScan` so the probe gets a chance to refresh DB cache +
170
214
  * emit DMs even when the observation-threshold gate would skip the
171
- * hourly check itself. The AuthHealthMonitor.checkAll() method owns
215
+ * activity scan itself. The AuthHealthMonitor.checkAll() method owns
172
216
  * its own kill-switch and morning-routine skip; the scheduler only
173
217
  * applies the same `autonomousGate` short-circuit that protects the
174
218
  * other cron callbacks.
@@ -176,10 +220,24 @@ export class AgentScheduler {
176
220
  * See `docs/design/09-safety-cost.md` §9.5.4 for the gate
177
221
  * ordering: morning-routine → hourly-already-running → auth probe
178
222
  * → observation-threshold. Steps 1 + 2 are handled inside
179
- * `triggerHourlyCheck`; step 3 is this callback; step 4 is the
180
- * threshold gate inside `triggerHourlyCheck`.
223
+ * `triggerActivityScan`; step 3 is this callback; step 4 is the
224
+ * threshold gate inside `triggerActivityScan`.
181
225
  */
182
226
  onAuthProbe = null;
227
+ /**
228
+ * SELF_TUNING_REVIEW_CYCLE_DESIGN.md §3.4 Phase 3 — auto-revert monitor.
229
+ * Piggybacks the hourly cron tick (P2 — zero new scheduled sessions),
230
+ * fired AHEAD of the per-agent enabled gate and the autonomous setup
231
+ * gate so rollback safety survives the owner disabling the activity-scan
232
+ * Agent or a setup-gated daemon; the callback owns its own 1/day
233
+ * throttle, per-entry isolation, and DM emission. Remaining coupling:
234
+ * with `activityScanEnabled=false` this cron is never registered and
235
+ * applied changes stay unverified until the check is re-enabled —
236
+ * acceptable because R1/R3 govern the (now-idle) hourly pipeline
237
+ * itself; an applied R5 (`feedbackLessonMaxBytesGlobal`) change would
238
+ * sit unverified, with `!revert tuning` as the manual escape hatch.
239
+ */
240
+ onSelfTuningRevertMonitor = null;
183
241
  /**
184
242
  * B-004 Phase 2a — nightly context-index reconciler callback (§4.1).
185
243
  * Fires at 03:45 local (dayBoundaryHour - 15 min) via an internal cron
@@ -255,6 +313,27 @@ export class AgentScheduler {
255
313
  nudgeSeq = 0;
256
314
  observedSeq = 0;
257
315
  sleepWaiter = null;
316
+ /**
317
+ * Detects machine sleep / forward clock jumps and replays the cron
318
+ * triggers the sleep swallowed (node-cron never fires missed ticks).
319
+ * See {@link runWakeCatchup}.
320
+ */
321
+ wakeDetector = new WakeDetector({
322
+ onWake: (gapMs) => this.runWakeCatchup(gapMs),
323
+ });
324
+ /**
325
+ * Independent self-heal tick for the morning routine (alert + recover +
326
+ * missed-fire re-queue). See {@link runMorningSelfHeal}. Kept off the
327
+ * activity-scan cron on purpose — that cron is operator-disableable.
328
+ */
329
+ morningSelfHealTimer = null;
330
+ /**
331
+ * Wall-clock instant this scheduler instance was constructed (one
332
+ * instance per daemon process). Drives the missed-fire boot
333
+ * suppression; tests override via cast to simulate a long-lived
334
+ * process.
335
+ */
336
+ startedAtMs = Date.now();
258
337
  constructor(eventBus, db, config) {
259
338
  this.eventBus = eventBus;
260
339
  this.db = db;
@@ -274,18 +353,27 @@ export class AgentScheduler {
274
353
  setSendDmCallback(fn) {
275
354
  this.sendDm = fn;
276
355
  }
277
- setHourlyCheckCallback(fn) {
278
- this.onHourlyCheck = fn;
356
+ setActivityScanCallback(fn) {
357
+ this.onActivityScan = fn;
279
358
  }
280
359
  /**
281
360
  * Register the Phase 4 auth probe callback. Called on each hourly
282
- * cron tick BEFORE the hourly-check observation threshold gate so
283
- * the probe continues to run even when the hourly check itself
361
+ * cron tick BEFORE the activity-scan observation threshold gate so
362
+ * the probe continues to run even when the activity scan itself
284
363
  * would be skipped for lack of pending observations.
285
364
  */
286
365
  setAuthProbeCallback(fn) {
287
366
  this.onAuthProbe = fn;
288
367
  }
368
+ /**
369
+ * Register the Phase 3 self-tuning auto-revert monitor. Called on each
370
+ * hourly cron tick alongside the auth probe; the monitor throttles
371
+ * itself to one pass per day and is a no-op until the actuator has
372
+ * written ledger entries.
373
+ */
374
+ setSelfTuningRevertMonitorCallback(fn) {
375
+ this.onSelfTuningRevertMonitor = fn;
376
+ }
289
377
  /**
290
378
  * Register the context-index reconciler cron callback. Called every
291
379
  * night at 03:45 local; the callback is expected to be fire-and-forget
@@ -380,9 +468,9 @@ export class AgentScheduler {
380
468
  * (CLAUDE.md "morning_routine wake stall"). When the morning routine
381
469
  * never writes an `agent_actions.result='success'` row, the dedup
382
470
  * inside `queueMorningRoutineWake` keeps the stuck wake row pinned in
383
- * `pending`/`running` and the hourly-check pre-routine gate silently
471
+ * `pending`/`running` and the activity-scan pre-routine gate silently
384
472
  * skips every subsequent autonomous tick. The user gets no morning
385
- * brief, no evening review, no hourly check, and no error — the
473
+ * brief, no evening review, no activity scan, and no error — the
386
474
  * system is functionally dead until the wake row clears.
387
475
  *
388
476
  * Detection: oldest `task_type='wake'` row tied to
@@ -446,7 +534,7 @@ export class AgentScheduler {
446
534
  return;
447
535
  }
448
536
  const message = `Aitne: morning routine stalled ${stalled.ageMinutes} min `
449
- + `(wake #${stalled.id}, status=${stalled.status}). Hourly check + `
537
+ + `(wake #${stalled.id}, status=${stalled.status}). Activity scan + `
450
538
  + `evening review blocked. Check logs or \`aitne restart\`.`;
451
539
  try {
452
540
  await this.sendDm(message);
@@ -470,13 +558,251 @@ export class AgentScheduler {
470
558
  this.morningStallWatchdogRunning = false;
471
559
  }
472
560
  }
561
+ /**
562
+ * Self-heal tick for the morning routine. Three layers:
563
+ *
564
+ * 1. **Recover** — a wake row stuck in `running` whose claim
565
+ * (`task_context.claimedAt`) is ≥ stall-threshold minutes old with
566
+ * no success today (machine slept mid-run, the backend stream died)
567
+ * is flipped back to `pending` so the ScheduleWatcher re-claims it.
568
+ * Without this, `queueMorningRoutineWake` dedups into the corpse
569
+ * forever and the day stays frozen until a daemon restart. Runs
570
+ * BEFORE the alert so a stall the self-heal is about to fix doesn't
571
+ * burn the once-per-day DM budget on a misleading "restart the
572
+ * daemon" message; if the re-run wedges too, the next tick still
573
+ * alerts (the row's created_at age keeps growing). Capped at
574
+ * {@link MAX_SELFHEAL_REQUEUES_PER_AGENT_DAY} re-runs per agent-day
575
+ * so a deterministic hang cannot burn backend spend every
576
+ * threshold-window all day. Worst case if the original execution is
577
+ * alive after all: one duplicate morning run, serialized by the
578
+ * today-write-lock.
579
+ * 2. **Alert** — the stall watchdog ({@link checkMorningRoutineStall}).
580
+ * Previously this only ran on activity-scan cron ticks, so
581
+ * `activityScanEnabled=false` silently disabled it; this timer is the
582
+ * guaranteed host now (the cron-tick invocation remains, made safe
583
+ * by the watchdog's mutex + per-day DM dedup).
584
+ * 3. **Missed fire** — no attempt, no wake row, agent-day older than the
585
+ * grace window: the boundary cron tick was swallowed (sleep shorter
586
+ * than the WakeDetector's gap threshold straddling 04:00, or a
587
+ * detector failure) — open the day exactly the way the cron and the
588
+ * wake catch-up do (day-boundary callback → daily cleanup → wake row
589
+ * with due reviews riding the post-catchup context). Never resurrects
590
+ * an exhausted retry chain: failed attempts leave audit rows, which
591
+ * `shouldQueueMissedMorningFire` treats as "attempted". Suppressed
592
+ * during the first {@link MISSED_FIRE_BOOT_SUPPRESSION_MS} of process
593
+ * life: that window belongs to the boot catchup, whose INLINE morning
594
+ * run leaves no wake row for the predicate to dedup against (a slow
595
+ * pre-pass there must not look like a missed fire).
596
+ *
597
+ * Fire-and-forget; each layer owns its own error containment.
598
+ */
599
+ async runMorningSelfHeal(now) {
600
+ const gateReason = this.autonomousGate();
601
+ if (gateReason !== null) {
602
+ this.logGateBlock(gateReason, { timer: "morning_self_heal" });
603
+ return;
604
+ }
605
+ const agentDayConfig = {
606
+ timezone: this.config.timezone || undefined,
607
+ dayBoundaryHour: this.config.dayBoundaryHour,
608
+ };
609
+ // Layer 1 — hung-claim recovery.
610
+ try {
611
+ const thresholdMinutes = readMorningRoutineStallThresholdMinutes(this.db);
612
+ const recoverable = getRecoverableStalledMorningWake(this.db, agentDayConfig, thresholdMinutes, now);
613
+ if (recoverable
614
+ && this.countSelfHealRequeuesToday(now) < MAX_SELFHEAL_REQUEUES_PER_AGENT_DAY) {
615
+ const flipped = this.db
616
+ .prepare(`UPDATE agent_schedule
617
+ SET status = 'pending', scheduled_for = ?
618
+ WHERE id = ? AND status = 'running'`)
619
+ .run(formatSqliteDatetime(now), recoverable.id);
620
+ if (flipped.changes > 0) {
621
+ logger.warn({
622
+ scheduleId: recoverable.id,
623
+ claimedAgeMinutes: recoverable.claimedAgeMinutes,
624
+ thresholdMinutes,
625
+ }, "Morning routine wake stuck in 'running' — flipped back to pending for re-claim");
626
+ try {
627
+ this.db
628
+ .prepare(`INSERT INTO agent_actions
629
+ (action_type, detail, result, started_at, completed_at)
630
+ VALUES (?, ?, 'success', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
631
+ .run("morning_routine.selfheal_requeued", JSON.stringify({
632
+ scheduleId: recoverable.id,
633
+ claimedAgeMinutes: recoverable.claimedAgeMinutes,
634
+ thresholdMinutes,
635
+ }));
636
+ }
637
+ catch (auditErr) {
638
+ logger.warn({ err: auditErr, scheduleId: recoverable.id }, "Failed to record morning_routine.selfheal_requeued audit");
639
+ }
640
+ this.nudgeWatcher();
641
+ // Skip the alert for this tick — the recovery is the response.
642
+ // The missed-fire layer cannot apply (a wake row exists).
643
+ return;
644
+ }
645
+ }
646
+ // Recoverable-but-capped (or lost the flip race) falls through to
647
+ // the alert so the operator hears about a hang the self-heal is no
648
+ // longer allowed to chase.
649
+ }
650
+ catch (err) {
651
+ logger.warn({ err }, "Morning self-heal recovery step failed");
652
+ }
653
+ // Layer 2 — alert-only watchdog.
654
+ await this.checkMorningRoutineStall(now).catch((err) => {
655
+ logger.warn({ err }, "Morning routine stall watchdog threw (self-heal tick)");
656
+ });
657
+ // Layer 3 — missed boundary fire.
658
+ try {
659
+ if (Date.now() - this.startedAtMs < MISSED_FIRE_BOOT_SUPPRESSION_MS) {
660
+ return;
661
+ }
662
+ if (!shouldQueueMissedMorningFire(this.db, agentDayConfig, MORNING_MISSED_FIRE_GRACE_MINUTES, now)) {
663
+ return;
664
+ }
665
+ if (!this.isAgentEnabledForFiring("morning-routine", "morning_self_heal")) {
666
+ return;
667
+ }
668
+ // Mirror runWakeCatchup's morning branch: open the day properly and
669
+ // let any due reviews / activity scan ride the wake row's
670
+ // post-catchup context instead of being skipped by the dispatcher's
671
+ // morning-pending gate.
672
+ const tz = this.config.timezone || undefined;
673
+ const { start, end } = getAgentDayBoundsUtc(tz, this.config.dayBoundaryHour, now);
674
+ const dueRoutines = getDueCatchupRoutines(this.db, this.config, start, end, now).filter((routine) => this.isAgentEnabledForFiring(WAKE_CATCHUP_AGENT_SLUGS[routine] ?? routine, `${routine}_self_heal`));
675
+ const needsActivityScan = shouldCatchUpActivityScan(this.db, this.config, now)
676
+ && this.isAgentEnabledForFiring("activity-scan", "activity_scan_self_heal");
677
+ try {
678
+ if (this.onDayBoundary) {
679
+ await this.onDayBoundary();
680
+ }
681
+ }
682
+ catch (err) {
683
+ logger.error({ err }, "Day boundary callback failed during morning self-heal");
684
+ }
685
+ this.dailyCleanup();
686
+ const queued = this.queueMorningRoutineWake("missed_cron_selfheal", {
687
+ postCatchupRoutines: dueRoutines,
688
+ postCatchupActivityScan: needsActivityScan,
689
+ });
690
+ this.nudgeWatcher();
691
+ logger.warn({ queued, dueRoutines, needsActivityScan }, "Morning routine fire was missed (sleep swallowed the boundary cron tick) — self-heal queued wake");
692
+ }
693
+ catch (err) {
694
+ logger.warn({ err }, "Morning self-heal missed-fire step failed");
695
+ }
696
+ }
697
+ /**
698
+ * Number of self-heal re-queues already performed this agent-day,
699
+ * counted from the `morning_routine.selfheal_requeued` audit rows the
700
+ * recovery layer writes. Throws propagate to the recovery layer's
701
+ * catch (fail-closed: an unreadable counter must not unlock unlimited
702
+ * re-runs).
703
+ */
704
+ countSelfHealRequeuesToday(now) {
705
+ const { start } = getAgentDayBoundsUtc(this.config.timezone || undefined, this.config.dayBoundaryHour, now);
706
+ const row = this.db
707
+ .prepare(`SELECT COUNT(*) AS cnt
708
+ FROM agent_actions
709
+ WHERE action_type = 'morning_routine.selfheal_requeued'
710
+ AND started_at >= ?`)
711
+ .get(start);
712
+ return row.cnt;
713
+ }
714
+ /**
715
+ * Replay cron triggers swallowed by a machine sleep (or forward clock
716
+ * jump). node-cron does not fire ticks whose time passed while the
717
+ * process was suspended, so without this a daemon that sleeps through
718
+ * 04:00 / 18:00 / a Friday 19:00 recovers those routines only on the
719
+ * next daemon RESTART (`bootstrap/catchup.ts`) — possibly days later.
720
+ *
721
+ * Reuses the boot-time catchup's decision predicates so the two paths
722
+ * cannot drift: `getDueCatchupRoutines` dedups against `agent_actions`,
723
+ * `shouldCatchUpActivityScan` replays at most the current slot, and the
724
+ * morning routine goes through `queueMorningRoutineWake`'s DB-backed
725
+ * dedup. Downstream dispatch-time gates (autonomous setup gate,
726
+ * morning-pending review gate) still apply.
727
+ *
728
+ * Ordering: when the morning routine has not completed for the current
729
+ * agent-day, the review routines and the activity scan ride along on the
730
+ * wake row's `postCatchupRoutines` / `postCatchupActivityScan` context
731
+ * (same replay mechanism the boot catchup uses) so they run AFTER the
732
+ * day is opened instead of being skipped by the dispatcher's
733
+ * pre-routine gate.
734
+ */
735
+ async runWakeCatchup(gapMs) {
736
+ const gapMinutes = Math.round(gapMs / 60_000);
737
+ const gateReason = this.autonomousGate();
738
+ if (gateReason !== null) {
739
+ this.logGateBlock(gateReason, { cron: "wake_catchup", gapMinutes });
740
+ return;
741
+ }
742
+ const now = new Date();
743
+ const tz = this.config.timezone || undefined;
744
+ const { start, end } = getAgentDayBoundsUtc(tz, this.config.dayBoundaryHour, now);
745
+ const dueRoutines = getDueCatchupRoutines(this.db, this.config, start, end, now).filter((routine) => this.isAgentEnabledForFiring(WAKE_CATCHUP_AGENT_SLUGS[routine] ?? routine, `${routine}_wake_catchup`));
746
+ const needsActivityScan = shouldCatchUpActivityScan(this.db, this.config, now)
747
+ && this.isAgentEnabledForFiring("activity-scan", "activity_scan_wake_catchup");
748
+ if (!morningRoutineRanToday(this.db, { timezone: tz, dayBoundaryHour: this.config.dayBoundaryHour }, now)) {
749
+ // Slept across the day boundary (or the morning routine never
750
+ // succeeded today) — re-run the full 04:00 flow. The wake row
751
+ // dedups against an already-pending/running morning run, merging
752
+ // the post-catchup context instead of double-firing.
753
+ if (!this.isAgentEnabledForFiring("morning-routine", "morning_routine_wake_catchup")) {
754
+ return;
755
+ }
756
+ try {
757
+ if (this.onDayBoundary) {
758
+ await this.onDayBoundary();
759
+ }
760
+ }
761
+ catch (err) {
762
+ logger.error({ err }, "Day boundary callback failed during wake catch-up");
763
+ }
764
+ this.dailyCleanup();
765
+ const queued = this.queueMorningRoutineWake("wake_catchup", {
766
+ postCatchupRoutines: dueRoutines,
767
+ postCatchupActivityScan: needsActivityScan,
768
+ });
769
+ this.nudgeWatcher();
770
+ logger.info({ gapMinutes, queued, dueRoutines, needsActivityScan }, "Wake catch-up queued morning routine");
771
+ return;
772
+ }
773
+ for (const routine of dueRoutines) {
774
+ logger.info({ routine, gapMinutes }, "Wake catch-up replaying missed routine");
775
+ this.emitRoutine(routine);
776
+ }
777
+ if (needsActivityScan && this.onActivityScan) {
778
+ logger.info({ gapMinutes }, "Wake catch-up triggering missed activity scan");
779
+ void Promise.resolve(this.onActivityScan("wake_catchup")).catch((err) => {
780
+ logger.warn({ err }, "Wake catch-up activity scan failed");
781
+ });
782
+ }
783
+ if (dueRoutines.length === 0 && !needsActivityScan) {
784
+ logger.info({ gapMinutes }, "Wake catch-up: nothing missed");
785
+ }
786
+ }
473
787
  start() {
474
788
  this.setupRecurringJobs();
475
789
  this.startScheduleWatcher();
790
+ this.wakeDetector.start();
791
+ this.morningSelfHealTimer = setInterval(() => {
792
+ void this.runMorningSelfHeal(new Date()).catch((err) => {
793
+ logger.warn({ err }, "Morning self-heal tick threw");
794
+ });
795
+ }, MORNING_SELF_HEAL_INTERVAL_MS);
796
+ this.morningSelfHealTimer.unref?.();
476
797
  logger.info("Scheduler started");
477
798
  }
478
799
  stop() {
479
800
  this.shutdown = true;
801
+ this.wakeDetector.stop();
802
+ if (this.morningSelfHealTimer) {
803
+ clearInterval(this.morningSelfHealTimer);
804
+ this.morningSelfHealTimer = null;
805
+ }
480
806
  // Wake up the ScheduleWatcher's poll sleep so the loop returns
481
807
  // immediately instead of waiting for the next interval tick. The
482
808
  // sleepInterruptible body re-checks `shutdown` before re-entering
@@ -647,17 +973,16 @@ export class AgentScheduler {
647
973
  // node-cron doesn't directly support "last day of month",
648
974
  // so we run daily at 18:00 and check if tomorrow is the 1st.
649
975
  //
650
- // Default OFF pre-release (see runtime-settings.ts:monthlyReviewEnabled).
651
- // The cron is always registered, but the callback consults
652
- // `this.config.monthlyReviewEnabled` at fire time so a runtime PATCH
653
- // takes effect on the next month-end without restart — that is also
654
- // why this key is intentionally absent from SCHEDULE_KEYS in
655
- // dashboard/config.ts (no cron rebuild needed). The routine itself
656
- // (task-flow, context-builder branch, retention coupling) stays in
657
- // tree as a concept pending the Mirror+Prune redesign.
976
+ // Default OFF pre-release: the monthly-review AGENT row ships
977
+ // `enabled: false`, and `isAgentEnabledForFiring` below is the single
978
+ // fire-time switch (AGENTS_HUB_REDESIGN_PLAN.md §2 the legacy
979
+ // `monthlyReviewEnabled` config gate was unified into it; a one-time
980
+ // boot reconcile carries an operator's old `true` forward). A toggle
981
+ // takes effect on the next month-end without restart or cron rebuild.
982
+ // The routine itself (task-flow, context-builder branch, retention
983
+ // coupling) stays in tree as a concept pending the Mirror+Prune
984
+ // redesign.
658
985
  const monthlyJob = cron.schedule("0 18 * * *", () => {
659
- if (!this.config.monthlyReviewEnabled)
660
- return;
661
986
  // Check if tomorrow (in configured timezone) is the 1st
662
987
  const tomorrow = new Date();
663
988
  tomorrow.setDate(tomorrow.getDate() + 1);
@@ -725,9 +1050,19 @@ export class AgentScheduler {
725
1050
  }
726
1051
  }, { timezone: tz });
727
1052
  this.cronJobs.push(browserDigestJob);
728
- if (this.config.hourlyCheckEnabled) {
729
- const hourlyExpr = buildHourlyCronExpr(this.config.hourlyCheckIntervalMinutes, this.config.hourlyCheckActiveStartHour, this.config.hourlyCheckActiveEndHour);
730
- const hourlyJob = cron.schedule(hourlyExpr, () => {
1053
+ {
1054
+ // Cadence is owned by the activity-scan AGENT ROW (metadata_json.
1055
+ // runtime_window, edited via PATCH /api/agents/activity-scan) with the
1056
+ // legacy `activityScan*` config keys as per-field fallback —
1057
+ // AGENTS_HUB_REDESIGN_PLAN.md §2. Resolved once at registration; the
1058
+ // agents PATCH route triggers `reloadCrons()` on a cadence change, so
1059
+ // the closure below never goes stale. The job is registered
1060
+ // UNCONDITIONALLY: `agents.enabled` (fire-time `isAgentEnabledForFiring`
1061
+ // gate below) is the single on/off switch — the legacy
1062
+ // `activityScanEnabled` registration gate was unified into it.
1063
+ const activityScanCadence = resolveActivityScanCadence(getRuntimeWindow(this.db, "activity-scan"), this.config);
1064
+ const activityScanExpr = buildActivityScanCronExpr(activityScanCadence.intervalMinutes, activityScanCadence.activeStartHour, activityScanCadence.activeEndHour);
1065
+ const activityScanJob = cron.schedule(activityScanExpr, () => {
731
1066
  const now = new Date();
732
1067
  // Pull both hour and minute from the canonical timezone helper
733
1068
  // so the day-boundary skip and the interval gate observe the
@@ -744,22 +1079,34 @@ export class AgentScheduler {
744
1079
  // of each agent-day lands at the start of the active window —
745
1080
  // critical for intervals near or equal to the window length.
746
1081
  // Divisor-of-60 cases short-circuit inside the helper.
747
- if (!shouldFireHourlyTickAt(local.hours, local.minutes, this.config.hourlyCheckIntervalMinutes, this.config.hourlyCheckActiveStartHour)) {
1082
+ if (!shouldFireActivityScanTickAt(local.hours, local.minutes, activityScanCadence.intervalMinutes, activityScanCadence.activeStartHour)) {
748
1083
  return;
749
1084
  }
1085
+ // Self-tuning auto-revert monitor — ahead of BOTH the per-agent
1086
+ // enabled gate and the autonomous setup gate below: rollback
1087
+ // safety must survive the owner disabling the activity-scan
1088
+ // Agent (a plausible cost-saving move while a tuned knob sits
1089
+ // unverified) and a degraded/setup-gated daemon. It is pure
1090
+ // daemon code (no LLM dispatch), owns its own 1/day throttle,
1091
+ // and is a no-op until the actuator has written ledger entries.
1092
+ if (this.onSelfTuningRevertMonitor) {
1093
+ void this.onSelfTuningRevertMonitor().catch((err) => {
1094
+ logger.warn({ err }, "Self-tuning revert monitor failed");
1095
+ });
1096
+ }
750
1097
  // Per-built-in enabled gate, AFTER the interval gate so a
751
1098
  // per-minute non-firing tick never inflates the suppressed count.
752
- if (!this.isAgentEnabledForFiring("hourly-check", "hourly_check"))
1099
+ if (!this.isAgentEnabledForFiring("activity-scan", "activity_scan"))
753
1100
  return;
754
- // triggerHourlyCheck has its own setup gate, but short-circuit
1101
+ // triggerActivityScan has its own setup gate, but short-circuit
755
1102
  // here to avoid the in-progress flag toggling for no reason.
756
1103
  const gateReason = this.autonomousGate();
757
1104
  if (gateReason !== null) {
758
- this.logGateBlock(gateReason, { cron: "hourly_check" });
1105
+ this.logGateBlock(gateReason, { cron: "activity_scan" });
759
1106
  return;
760
1107
  }
761
- // Phase 4 auth probe runs BEFORE the hourly check so that the
762
- // observation-threshold gate (which can skip `onHourlyCheck`
1108
+ // Phase 4 auth probe runs BEFORE the activity scan so that the
1109
+ // observation-threshold gate (which can skip `onActivityScan`
763
1110
  // entirely when there's no pending user activity) does not
764
1111
  // also stall auth health detection. The probe owns its own
765
1112
  // morning-routine / probe-disabled gating; we only respect
@@ -771,16 +1118,16 @@ export class AgentScheduler {
771
1118
  }
772
1119
  // Morning-routine stall watchdog. Runs alongside the auth probe
773
1120
  // because both are observability hooks that should fire even
774
- // when the hourly check itself gets gated (e.g., the gate
1121
+ // when the activity scan itself gets gated (e.g., the gate
775
1122
  // skip is the *symptom* the watchdog needs to catch).
776
1123
  void this.checkMorningRoutineStall(now).catch((err) => {
777
1124
  logger.warn({ err }, "Morning routine stall watchdog threw");
778
1125
  });
779
- if (this.onHourlyCheck) {
780
- void this.onHourlyCheck("cron");
1126
+ if (this.onActivityScan) {
1127
+ void this.onActivityScan("cron");
781
1128
  }
782
1129
  }, { timezone: tz });
783
- this.cronJobs.push(hourlyJob);
1130
+ this.cronJobs.push(activityScanJob);
784
1131
  }
785
1132
  // P22 §6.3, §6.4 — skill curation. Registered only when the operator
786
1133
  // has opted in via /settings/self-learning (`enabled=true`). Always
@@ -814,12 +1161,15 @@ export class AgentScheduler {
814
1161
  }, { timezone: tz });
815
1162
  this.cronJobs.push(skillCurationJob);
816
1163
  }
817
- logger.info({
818
- morningHour: this.config.dayBoundaryHour,
819
- timezone: tz ?? "system",
820
- hourlyCheckEnabled: this.config.hourlyCheckEnabled,
821
- hourlyCheckIntervalMinutes: this.config.hourlyCheckIntervalMinutes,
822
- }, "Recurring cron jobs configured");
1164
+ {
1165
+ const cadence = resolveActivityScanCadence(getRuntimeWindow(this.db, "activity-scan"), this.config);
1166
+ logger.info({
1167
+ morningHour: this.config.dayBoundaryHour,
1168
+ timezone: tz ?? "system",
1169
+ activityScanIntervalMinutes: cadence.intervalMinutes,
1170
+ activityScanActiveHours: `${cadence.activeStartHour}-${cadence.activeEndHour}`,
1171
+ }, "Recurring cron jobs configured");
1172
+ }
823
1173
  }
824
1174
  emitRoutine(routineName, data) {
825
1175
  const event = {
@@ -855,7 +1205,7 @@ export class AgentScheduler {
855
1205
  routine: "morning_routine",
856
1206
  source,
857
1207
  postCatchupRoutines: options?.postCatchupRoutines ?? [],
858
- postCatchupHourlyCheck: options?.postCatchupHourlyCheck ?? false,
1208
+ postCatchupActivityScan: options?.postCatchupActivityScan ?? false,
859
1209
  importance: "low",
860
1210
  });
861
1211
  const insertTxn = this.db.transaction(() => {
@@ -875,13 +1225,19 @@ export class AgentScheduler {
875
1225
  : []),
876
1226
  ...(options?.postCatchupRoutines ?? []),
877
1227
  ]));
878
- const mergedHourlyCheck = existingContext.postCatchupHourlyCheck === true ||
879
- options?.postCatchupHourlyCheck === true;
1228
+ const mergedActivityScan = existingContext.postCatchupActivityScan === true ||
1229
+ options?.postCatchupActivityScan === true;
1230
+ // Spread the existing context FIRST so keys this merge doesn't
1231
+ // know about survive — in particular the ScheduleWatcher's
1232
+ // `claimedAt` stamp on a running row: dropping it would blind
1233
+ // the self-heal recovery predicate exactly when the 04:00 cron
1234
+ // merges into a hung overnight run.
880
1235
  const mergedContext = {
1236
+ ...existingContext,
881
1237
  routine: "morning_routine",
882
1238
  source: existingContext.source ?? source,
883
1239
  postCatchupRoutines: mergedRoutines,
884
- postCatchupHourlyCheck: mergedHourlyCheck,
1240
+ postCatchupActivityScan: mergedActivityScan,
885
1241
  importance: "low",
886
1242
  };
887
1243
  // Bump `scheduled_for` forward when the new caller's NOW lies
@@ -990,6 +1346,25 @@ export class AgentScheduler {
990
1346
  .run(row.id);
991
1347
  if (result.changes === 0)
992
1348
  continue;
1349
+ // Stamp the claim time on morning-routine wake rows. This is
1350
+ // the staleness signal the self-heal recovery predicate
1351
+ // (`getRecoverableStalledMorningWake`) measures from —
1352
+ // `created_at` and `scheduled_for` both lie after sleeps and
1353
+ // dedup merges. Best-effort and morning-scoped: a failure
1354
+ // here only demotes that row from auto-recovery to the
1355
+ // alert-only watchdog path.
1356
+ try {
1357
+ this.db
1358
+ .prepare(`UPDATE agent_schedule
1359
+ SET task_context = json_set(COALESCE(task_context, '{}'), '$.claimedAt', ?)
1360
+ WHERE id = ?
1361
+ AND json_valid(COALESCE(task_context, '{}'))
1362
+ AND json_extract(task_context, '$.routine') = 'morning_routine'`)
1363
+ .run(formatSqliteDatetime(new Date()), row.id);
1364
+ }
1365
+ catch (stampErr) {
1366
+ logger.warn({ err: stampErr, taskId: row.id }, "Failed to stamp claimedAt on claimed schedule row");
1367
+ }
993
1368
  // Per-row try/catch: if the row body throws (e.g. malformed
994
1369
  // task_context JSON), flip the claim to 'failed' so the row
995
1370
  // doesn't stay 'running' forever and the watcher can move on.
@@ -1073,57 +1448,10 @@ export class AgentScheduler {
1073
1448
  // delay; the row's status is reverted to `pending` so
1074
1449
  // the next ScheduleWatcher tick re-evaluates.
1075
1450
  if (row.task_type === "browser_task") {
1076
- const fireAt = new Date();
1077
1451
  const respectQuietHours = this.config.browserTaskRespectQuietHours !== false;
1078
- if (respectQuietHours) {
1079
- const quietHoursWindow = {
1080
- start: this.config.quietHoursStart,
1081
- end: this.config.quietHoursEnd,
1082
- timezone: this.config.timezone || undefined,
1083
- };
1084
- if (isInQuietHoursAt(fireAt, quietHoursWindow)) {
1085
- const deferUntilMs = nextQuietHoursEndMs(fireAt, quietHoursWindow);
1086
- if (deferUntilMs !== null) {
1087
- const deferredFor = formatSqliteDatetime(new Date(deferUntilMs));
1088
- this.db
1089
- .prepare(`UPDATE agent_schedule
1090
- SET scheduled_for = ?, status = 'pending'
1091
- WHERE id = ?`)
1092
- .run(deferredFor, row.id);
1093
- try {
1094
- this.db
1095
- .prepare(`INSERT INTO agent_actions
1096
- (action_type, detail, result, started_at, completed_at)
1097
- VALUES (?, ?, 'success', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
1098
- .run("browser_task.deferred_for_quiet_hours", JSON.stringify({
1099
- scheduleId: row.id,
1100
- originalScheduledFor: row.scheduled_for,
1101
- deferredUntil: deferredFor,
1102
- quietHoursStart: this.config.quietHoursStart,
1103
- quietHoursEnd: this.config.quietHoursEnd,
1104
- }));
1105
- }
1106
- catch (auditErr) {
1107
- /* c8 ignore start -- defensive against schema partials */
1108
- logger.warn({ err: auditErr, scheduleId: row.id }, "Failed to record browser_task.deferred_for_quiet_hours audit");
1109
- /* c8 ignore stop */
1110
- }
1111
- logger.info({
1112
- scheduleId: row.id,
1113
- deferredUntil: deferredFor,
1114
- quietHoursStart: this.config.quietHoursStart,
1115
- quietHoursEnd: this.config.quietHoursEnd,
1116
- }, "scheduled.browser_task deferred for quiet hours");
1117
- continue;
1118
- }
1119
- // `nextQuietHoursEndMs` returning null inside a quiet-
1120
- // hours predicate that just returned true would mean
1121
- // a 24-hour window — the runtime-settings schema
1122
- // disallows this (equal start/end short-circuits the
1123
- // predicate), so it cannot occur in normal operation.
1124
- // Fall through to dispatch rather than re-deferring
1125
- // forever.
1126
- }
1452
+ if (respectQuietHours &&
1453
+ this.deferClaimedRowForQuietHours(row, "browser_task.deferred_for_quiet_hours")) {
1454
+ continue;
1127
1455
  }
1128
1456
  const base = createEvent({
1129
1457
  type: "scheduled.browser_task",
@@ -1151,12 +1479,64 @@ export class AgentScheduler {
1151
1479
  logger.info({ scheduleId: row.id, taskType: row.task_type }, "Scheduled browser-task dispatched");
1152
1480
  continue;
1153
1481
  }
1482
+ // BACKGROUND_TASK_RUNNER_DESIGN.md §4.2 — generic background
1483
+ // task firing at its scheduled time. Body lives in
1484
+ // `task_context` (frozen at schedule time); the dispatcher's
1485
+ // `scheduled.background_task` handler creates the row at fire
1486
+ // time and hands off to the runner. No quiet-hours deferral
1487
+ // on dispatch — the worker may run at any hour; the DELIVERY
1488
+ // boundary quiet-hours-gates the owner-facing DM (§10.6).
1489
+ if (row.task_type === "background_task") {
1490
+ const base = createEvent({
1491
+ type: "scheduled.background_task",
1492
+ source: row.task_type,
1493
+ priority: EventPriority.NORMAL,
1494
+ });
1495
+ let parsedContext;
1496
+ try {
1497
+ parsedContext = JSON.parse(row.task_context ?? "{}");
1498
+ }
1499
+ catch (parseErr) {
1500
+ logger.error({ err: parseErr, scheduleId: row.id }, "scheduled.background_task: task_context JSON parse failed — marking row failed");
1501
+ this.db
1502
+ .prepare("UPDATE agent_schedule SET status = 'failed' WHERE id = ? AND status = 'running'")
1503
+ .run(row.id);
1504
+ continue;
1505
+ }
1506
+ const event = {
1507
+ ...base,
1508
+ taskContext: parsedContext,
1509
+ correlationId: row.correlation_id ?? base.correlationId,
1510
+ scheduleId: row.id,
1511
+ };
1512
+ await this.eventBus.put(event);
1513
+ logger.info({ scheduleId: row.id, taskType: row.task_type }, "Scheduled background-task dispatched");
1514
+ continue;
1515
+ }
1516
+ const parsedTaskContext = JSON.parse(row.task_context ?? "{}");
1517
+ // QUIET_HOURS_HARDENING_PLAN.md §6 — per-row opt-in quiet-hours
1518
+ // deferral for user-Agent firings. The Agent loader copies the
1519
+ // definition's `schedule.defer_in_quiet_hours: true` into the
1520
+ // recurring row's task_context and `generateNextScheduleRow`
1521
+ // spreads it into every materialised row, so the check is
1522
+ // row-local (no `agents` join). The whole RUN moves past the
1523
+ // quiet window (fresh data at delivery time, no wasted 03:00
1524
+ // session), mirroring the browser_task deferral above. Built-ins
1525
+ // fire outside `recurring_schedules` and never carry the flag;
1526
+ // manual run-now rows omit it too (an explicit "run now" click
1527
+ // must fire immediately).
1528
+ if (row.task_type === "agent.task" &&
1529
+ parsedTaskContext.defer_in_quiet_hours === true &&
1530
+ this.deferClaimedRowForQuietHours(row, "agent.task.deferred_for_quiet_hours", typeof parsedTaskContext.agent_id === "string"
1531
+ ? parsedTaskContext.agent_id
1532
+ : null)) {
1533
+ continue;
1534
+ }
1154
1535
  const base = createEvent({
1155
1536
  type: "scheduled.task",
1156
1537
  source: row.task_type,
1157
1538
  priority: EventPriority.NORMAL,
1158
1539
  });
1159
- const parsedTaskContext = JSON.parse(row.task_context ?? "{}");
1160
1540
  // WIKI_BUILDER_DESIGN.md §3.4-bis — bang-spawned approval rows
1161
1541
  // (today: wiki.compile via `!compile full` above threshold;
1162
1542
  // generalisable to any future bang→approval path) carry a
@@ -1243,6 +1623,83 @@ export class AgentScheduler {
1243
1623
  };
1244
1624
  void loop();
1245
1625
  }
1626
+ /**
1627
+ * Quiet-hours deferral for a claimed `agent_schedule` row (shared by the
1628
+ * `browser_task` always-on-by-config path and the `agent.task` per-row
1629
+ * opt-in, QUIET_HOURS_HARDENING_PLAN.md §6). When the current wall-clock
1630
+ * instant falls inside the configured quiet-hours window, the row is pushed
1631
+ * forward to the next quiet-hours-end boundary (status reverted to
1632
+ * `pending` so the next ScheduleWatcher tick re-evaluates), one
1633
+ * `agent_actions` audit row is written per deferral so the user can see the
1634
+ * delay, and `true` is returned. Returns `false` when outside the window —
1635
+ * or when `nextQuietHoursEndMs` cannot resolve a boundary, which inside a
1636
+ * quiet-hours predicate that just returned true would mean a 24-hour
1637
+ * window; the runtime-settings schema disallows this (equal start/end
1638
+ * short-circuits the predicate), so it cannot occur in normal operation.
1639
+ * Falling through to dispatch beats re-deferring forever.
1640
+ *
1641
+ * `agentId` (the owning user Agent's slug from `task_context.agent_id`)
1642
+ * stamps the audit row's `agent_id` column so the deferral is attributable
1643
+ * per Agent; the browser_task path has no owning Agent and passes none.
1644
+ */
1645
+ deferClaimedRowForQuietHours(row, actionType, agentId = null) {
1646
+ const fireAt = new Date();
1647
+ const quietHoursWindow = {
1648
+ start: this.config.quietHoursStart,
1649
+ end: this.config.quietHoursEnd,
1650
+ timezone: this.config.timezone || undefined,
1651
+ };
1652
+ if (!isInQuietHoursAt(fireAt, quietHoursWindow))
1653
+ return false;
1654
+ const deferUntilMs = nextQuietHoursEndMs(fireAt, quietHoursWindow);
1655
+ if (deferUntilMs === null)
1656
+ return false;
1657
+ const deferredFor = formatSqliteDatetime(new Date(deferUntilMs));
1658
+ // `quiet_hours_deferred` marks the row as ACTUALLY deferred (vs merely
1659
+ // carrying the `defer_in_quiet_hours` opt-in on a future cron slot) so a
1660
+ // quiet-hours config change can retime exactly these rows
1661
+ // (`retimeDeferredRunRows` in db/deferred-dm.ts, the sibling of the
1662
+ // Phase-1 deferred-DM retime). Invalid task_context JSON is left
1663
+ // untouched — stamping must never destroy a browser_task's frozen body.
1664
+ this.db
1665
+ .prepare(`UPDATE agent_schedule
1666
+ SET scheduled_for = ?, status = 'pending',
1667
+ task_context = CASE
1668
+ WHEN task_context IS NULL
1669
+ THEN json_object('quiet_hours_deferred', json('true'))
1670
+ WHEN json_valid(task_context)
1671
+ THEN json_set(task_context, '$.quiet_hours_deferred', json('true'))
1672
+ ELSE task_context
1673
+ END
1674
+ WHERE id = ?`)
1675
+ .run(deferredFor, row.id);
1676
+ try {
1677
+ this.db
1678
+ .prepare(`INSERT INTO agent_actions
1679
+ (action_type, detail, result, agent_id, started_at, completed_at)
1680
+ VALUES (?, ?, 'success', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`)
1681
+ .run(actionType, JSON.stringify({
1682
+ scheduleId: row.id,
1683
+ originalScheduledFor: row.scheduled_for,
1684
+ deferredUntil: deferredFor,
1685
+ quietHoursStart: this.config.quietHoursStart,
1686
+ quietHoursEnd: this.config.quietHoursEnd,
1687
+ }), agentId);
1688
+ }
1689
+ catch (auditErr) {
1690
+ /* c8 ignore start -- defensive against schema partials */
1691
+ logger.warn({ err: auditErr, scheduleId: row.id, actionType }, "Failed to record quiet-hours deferral audit");
1692
+ /* c8 ignore stop */
1693
+ }
1694
+ logger.info({
1695
+ scheduleId: row.id,
1696
+ taskType: row.task_type,
1697
+ deferredUntil: deferredFor,
1698
+ quietHoursStart: this.config.quietHoursStart,
1699
+ quietHoursEnd: this.config.quietHoursEnd,
1700
+ }, "Scheduled row deferred for quiet hours");
1701
+ return true;
1702
+ }
1246
1703
  /**
1247
1704
  * Sleep for `ms` milliseconds, but resolve early when `stop()` or
1248
1705
  * `nudgeWatcher()` fire. Used by the ScheduleWatcher between polls so