@poolzin/pool-bot 2026.2.0 → 2026.2.2

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 (258) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README-header.png +0 -0
  3. package/dist/agents/bash-tools.exec.js +76 -25
  4. package/dist/agents/cli-runner/helpers.js +9 -11
  5. package/dist/agents/context.js +1 -1
  6. package/dist/agents/identity.js +47 -7
  7. package/dist/agents/memory-search.js +25 -8
  8. package/dist/agents/model-catalog.js +1 -1
  9. package/dist/agents/model-selection.js +21 -0
  10. package/dist/agents/pi-embedded-block-chunker.js +117 -42
  11. package/dist/agents/pi-embedded-helpers/errors.js +183 -78
  12. package/dist/agents/pi-embedded-helpers.js +1 -1
  13. package/dist/agents/pi-embedded-runner/compact.js +8 -10
  14. package/dist/agents/pi-embedded-runner/model.js +62 -3
  15. package/dist/agents/pi-embedded-runner/run/attempt.js +21 -11
  16. package/dist/agents/pi-embedded-runner/run.js +199 -46
  17. package/dist/agents/pi-embedded-runner/system-prompt.js +10 -2
  18. package/dist/agents/pi-embedded-subscribe.js +118 -29
  19. package/dist/agents/pi-tools.js +10 -5
  20. package/dist/agents/poolbot-tools.js +15 -10
  21. package/dist/agents/sandbox-paths.js +31 -0
  22. package/dist/agents/session-tool-result-guard.js +94 -15
  23. package/dist/agents/shell-utils.js +51 -0
  24. package/dist/agents/skills/bundled-context.js +23 -0
  25. package/dist/agents/skills/bundled-dir.js +41 -7
  26. package/dist/agents/skills-install.js +60 -23
  27. package/dist/agents/subagent-announce.js +79 -34
  28. package/dist/agents/tool-policy.conformance.js +14 -0
  29. package/dist/agents/tool-policy.js +24 -0
  30. package/dist/agents/tools/cron-tool.js +166 -19
  31. package/dist/agents/tools/discord-actions-presence.js +78 -0
  32. package/dist/agents/tools/image-tool.js +1 -1
  33. package/dist/agents/tools/message-tool.js +56 -2
  34. package/dist/agents/tools/sessions-history-tool.js +69 -1
  35. package/dist/agents/tools/web-search.js +211 -42
  36. package/dist/agents/usage.js +23 -1
  37. package/dist/agents/workspace-run.js +67 -0
  38. package/dist/agents/workspace-templates.js +44 -0
  39. package/dist/auto-reply/command-auth.js +121 -6
  40. package/dist/auto-reply/envelope.js +74 -82
  41. package/dist/auto-reply/reply/commands-compact.js +1 -0
  42. package/dist/auto-reply/reply/commands-context-report.js +1 -0
  43. package/dist/auto-reply/reply/commands-context.js +1 -0
  44. package/dist/auto-reply/reply/commands-models.js +107 -60
  45. package/dist/auto-reply/reply/commands-ptt.js +171 -0
  46. package/dist/auto-reply/reply/get-reply-run.js +2 -1
  47. package/dist/auto-reply/reply/inbound-context.js +5 -1
  48. package/dist/auto-reply/reply/mentions.js +1 -1
  49. package/dist/auto-reply/reply/model-selection.js +3 -3
  50. package/dist/auto-reply/thinking.js +88 -43
  51. package/dist/browser/bridge-server.js +13 -0
  52. package/dist/browser/cdp.helpers.js +38 -24
  53. package/dist/browser/client-fetch.js +50 -7
  54. package/dist/browser/config.js +1 -10
  55. package/dist/browser/extension-relay.js +101 -40
  56. package/dist/browser/pw-ai.js +1 -1
  57. package/dist/browser/pw-session.js +143 -8
  58. package/dist/browser/pw-tools-core.interactions.js +125 -27
  59. package/dist/browser/pw-tools-core.responses.js +1 -1
  60. package/dist/browser/pw-tools-core.state.js +1 -1
  61. package/dist/browser/routes/agent.act.js +86 -41
  62. package/dist/browser/routes/dispatcher.js +4 -4
  63. package/dist/browser/screenshot.js +1 -1
  64. package/dist/browser/server.js +13 -0
  65. package/dist/build-info.json +3 -3
  66. package/dist/canvas-host/a2ui/index.html +28 -28
  67. package/dist/channels/reply-prefix.js +8 -1
  68. package/dist/cli/cron-cli/register.cron-add.js +61 -40
  69. package/dist/cli/cron-cli/register.cron-edit.js +60 -34
  70. package/dist/cli/cron-cli/shared.js +56 -41
  71. package/dist/cli/dns-cli.js +26 -14
  72. package/dist/cli/gateway-cli/register.js +37 -19
  73. package/dist/cli/memory-cli.js +5 -5
  74. package/dist/cli/parse-bytes.js +37 -0
  75. package/dist/cli/update-cli.js +173 -52
  76. package/dist/commands/agent.js +1 -0
  77. package/dist/commands/auth-choice.apply.oauth.js +1 -1
  78. package/dist/commands/doctor-config-flow.js +61 -5
  79. package/dist/commands/doctor-state-migrations.js +1 -1
  80. package/dist/commands/health.js +1 -1
  81. package/dist/commands/model-allowlist.js +29 -0
  82. package/dist/commands/model-picker.js +2 -1
  83. package/dist/commands/models/list.registry.js +1 -1
  84. package/dist/commands/models/list.status-command.js +43 -23
  85. package/dist/commands/models/shared.js +15 -0
  86. package/dist/commands/onboard-custom.js +384 -0
  87. package/dist/commands/onboard-non-interactive/local/auth-choice-inference.js +35 -0
  88. package/dist/commands/onboard-non-interactive/local/auth-choice.js +6 -3
  89. package/dist/commands/onboard-skills.js +63 -38
  90. package/dist/commands/openai-model-default.js +41 -0
  91. package/dist/compat/legacy-names.js +2 -0
  92. package/dist/config/defaults.js +3 -2
  93. package/dist/config/paths.js +136 -35
  94. package/dist/config/plugin-auto-enable.js +21 -5
  95. package/dist/config/redact-snapshot.js +153 -0
  96. package/dist/config/schema.field-metadata.js +590 -0
  97. package/dist/config/schema.js +2 -2
  98. package/dist/config/sessions/store.js +291 -23
  99. package/dist/config/zod-schema.agent-defaults.js +3 -0
  100. package/dist/config/zod-schema.agent-runtime.js +13 -2
  101. package/dist/config/zod-schema.providers-core.js +142 -0
  102. package/dist/config/zod-schema.session.js +3 -0
  103. package/dist/control-ui/assets/{index-CIRDm-Lu.css → index-CSfXd2LO.css} +1 -1
  104. package/dist/control-ui/assets/{index-CmNMuoem.js → index-HRr1grwl.js} +446 -413
  105. package/dist/control-ui/assets/index-HRr1grwl.js.map +1 -0
  106. package/dist/control-ui/index.html +4 -4
  107. package/dist/cron/delivery.js +57 -0
  108. package/dist/cron/isolated-agent/delivery-target.js +18 -3
  109. package/dist/cron/isolated-agent/helpers.js +22 -5
  110. package/dist/cron/isolated-agent/run.js +172 -63
  111. package/dist/cron/isolated-agent/session.js +2 -0
  112. package/dist/cron/normalize.js +356 -28
  113. package/dist/cron/parse.js +10 -5
  114. package/dist/cron/run-log.js +35 -10
  115. package/dist/cron/schedule.js +41 -6
  116. package/dist/cron/service/jobs.js +208 -35
  117. package/dist/cron/service/ops.js +72 -16
  118. package/dist/cron/service/state.js +2 -0
  119. package/dist/cron/service/store.js +386 -14
  120. package/dist/cron/service/timer.js +390 -147
  121. package/dist/cron/session-reaper.js +86 -0
  122. package/dist/cron/store.js +23 -8
  123. package/dist/cron/validate-timestamp.js +43 -0
  124. package/dist/discord/monitor/agent-components.js +438 -0
  125. package/dist/discord/monitor/allow-list.js +28 -5
  126. package/dist/discord/monitor/gateway-registry.js +29 -0
  127. package/dist/discord/monitor/native-command.js +44 -23
  128. package/dist/discord/monitor/sender-identity.js +45 -0
  129. package/dist/discord/pluralkit.js +27 -0
  130. package/dist/discord/send.outbound.js +92 -5
  131. package/dist/discord/send.shared.js +60 -23
  132. package/dist/discord/targets.js +84 -1
  133. package/dist/entry.js +15 -9
  134. package/dist/extensionAPI.js +8 -0
  135. package/dist/gateway/control-ui.js +8 -1
  136. package/dist/gateway/hooks-mapping.js +3 -0
  137. package/dist/gateway/hooks.js +65 -0
  138. package/dist/gateway/net.js +96 -31
  139. package/dist/gateway/node-command-policy.js +50 -15
  140. package/dist/gateway/origin-check.js +56 -0
  141. package/dist/gateway/protocol/client-info.js +9 -0
  142. package/dist/gateway/protocol/index.js +9 -2
  143. package/dist/gateway/protocol/schema/agents-models-skills.js +71 -1
  144. package/dist/gateway/protocol/schema/cron.js +22 -10
  145. package/dist/gateway/protocol/schema/protocol-schemas.js +16 -2
  146. package/dist/gateway/protocol/schema/sessions.js +12 -0
  147. package/dist/gateway/server/hooks.js +1 -1
  148. package/dist/gateway/server-broadcast.js +26 -9
  149. package/dist/gateway/server-chat.js +112 -23
  150. package/dist/gateway/server-discovery-runtime.js +10 -2
  151. package/dist/gateway/server-http.js +109 -11
  152. package/dist/gateway/server-methods/agent-timestamp.js +60 -0
  153. package/dist/gateway/server-methods/agents.js +321 -2
  154. package/dist/gateway/server-methods/usage.js +559 -16
  155. package/dist/gateway/server-runtime-state.js +22 -8
  156. package/dist/gateway/server-startup-memory.js +16 -0
  157. package/dist/gateway/server.impl.js +5 -1
  158. package/dist/gateway/session-utils.fs.js +23 -25
  159. package/dist/gateway/session-utils.js +20 -10
  160. package/dist/gateway/sessions-patch.js +7 -22
  161. package/dist/gateway/test-helpers.mocks.js +11 -7
  162. package/dist/gateway/test-helpers.server.js +35 -2
  163. package/dist/imessage/constants.js +2 -0
  164. package/dist/imessage/monitor/deliver.js +4 -1
  165. package/dist/imessage/monitor/monitor-provider.js +51 -1
  166. package/dist/infra/bonjour-discovery.js +131 -70
  167. package/dist/infra/control-ui-assets.js +134 -12
  168. package/dist/infra/errors.js +12 -0
  169. package/dist/infra/exec-approvals.js +266 -57
  170. package/dist/infra/format-time/format-datetime.js +79 -0
  171. package/dist/infra/format-time/format-duration.js +81 -0
  172. package/dist/infra/format-time/format-relative.js +80 -0
  173. package/dist/infra/heartbeat-runner.js +140 -49
  174. package/dist/infra/home-dir.js +54 -0
  175. package/dist/infra/net/fetch-guard.js +122 -0
  176. package/dist/infra/net/ssrf.js +65 -29
  177. package/dist/infra/outbound/abort.js +14 -0
  178. package/dist/infra/outbound/message-action-runner.js +77 -13
  179. package/dist/infra/outbound/outbound-session.js +143 -37
  180. package/dist/infra/poolbot-root.js +43 -1
  181. package/dist/infra/session-cost-usage.js +631 -41
  182. package/dist/infra/state-migrations.js +317 -47
  183. package/dist/infra/update-global.js +35 -0
  184. package/dist/infra/update-runner.js +149 -43
  185. package/dist/infra/warning-filter.js +65 -0
  186. package/dist/infra/widearea-dns.js +30 -9
  187. package/dist/logging/redact-identifier.js +12 -0
  188. package/dist/media/fetch.js +81 -58
  189. package/dist/media/store.js +2 -0
  190. package/dist/media-understanding/apply.js +403 -3
  191. package/dist/media-understanding/attachments.js +38 -27
  192. package/dist/media-understanding/defaults.js +16 -0
  193. package/dist/media-understanding/providers/deepgram/audio.js +22 -14
  194. package/dist/media-understanding/providers/google/audio.js +24 -17
  195. package/dist/media-understanding/providers/google/video.js +24 -17
  196. package/dist/media-understanding/providers/image.js +3 -3
  197. package/dist/media-understanding/providers/index.js +4 -1
  198. package/dist/media-understanding/providers/openai/audio.js +22 -14
  199. package/dist/media-understanding/providers/shared.js +16 -11
  200. package/dist/media-understanding/providers/zai/index.js +6 -0
  201. package/dist/media-understanding/runner.js +158 -90
  202. package/dist/memory/batch-voyage.js +277 -0
  203. package/dist/memory/embeddings-voyage.js +75 -0
  204. package/dist/memory/embeddings.js +28 -16
  205. package/dist/memory/internal.js +101 -18
  206. package/dist/memory/manager.js +154 -48
  207. package/dist/memory/search-manager.js +173 -0
  208. package/dist/memory/session-files.js +9 -3
  209. package/dist/node-host/runner.js +34 -24
  210. package/dist/node-host/with-timeout.js +27 -0
  211. package/dist/plugins/commands.js +5 -1
  212. package/dist/plugins/config-state.js +86 -7
  213. package/dist/plugins/source-display.js +51 -0
  214. package/dist/process/exec.js +20 -2
  215. package/dist/routing/resolve-route.js +12 -0
  216. package/dist/routing/session-key.js +15 -0
  217. package/dist/runtime.js +2 -0
  218. package/dist/security/audit-extra.async.js +601 -0
  219. package/dist/security/audit-extra.js +2 -830
  220. package/dist/security/audit-extra.sync.js +505 -0
  221. package/dist/security/channel-metadata.js +34 -0
  222. package/dist/security/external-content.js +88 -6
  223. package/dist/security/skill-scanner.js +330 -0
  224. package/dist/sessions/session-key-utils.js +7 -0
  225. package/dist/signal/monitor/event-handler.js +80 -1
  226. package/dist/slack/monitor/media.js +85 -15
  227. package/dist/tailscale/detect.js +1 -2
  228. package/dist/telegram/bot/helpers.js +109 -28
  229. package/dist/telegram/bot-handlers.js +144 -3
  230. package/dist/telegram/bot-message-context.js +37 -10
  231. package/dist/telegram/bot-message-dispatch.js +54 -17
  232. package/dist/telegram/bot-native-commands.js +86 -29
  233. package/dist/telegram/bot.js +30 -29
  234. package/dist/telegram/model-buttons.js +163 -0
  235. package/dist/telegram/monitor.js +110 -85
  236. package/dist/telegram/send.js +129 -47
  237. package/dist/terminal/restore.js +45 -0
  238. package/dist/test-helpers/state-dir-env.js +16 -0
  239. package/dist/tts/tts.js +12 -6
  240. package/dist/tui/tui-session-actions.js +166 -54
  241. package/dist/utils/fetch-timeout.js +20 -0
  242. package/dist/utils/normalize-secret-input.js +19 -0
  243. package/dist/utils/transcript-tools.js +58 -0
  244. package/dist/utils.js +45 -14
  245. package/dist/version.js +42 -5
  246. package/dist/wizard/clack-prompter.js +9 -6
  247. package/extensions/googlechat/node_modules/.bin/poolbot +21 -0
  248. package/extensions/googlechat/package.json +2 -2
  249. package/extensions/line/node_modules/.bin/poolbot +21 -0
  250. package/extensions/line/package.json +1 -1
  251. package/extensions/matrix/node_modules/.bin/poolbot +21 -0
  252. package/extensions/matrix/package.json +1 -1
  253. package/extensions/memory-core/node_modules/.bin/poolbot +21 -0
  254. package/extensions/memory-core/package.json +4 -1
  255. package/extensions/twitch/node_modules/.bin/poolbot +21 -0
  256. package/extensions/twitch/package.json +1 -1
  257. package/package.json +183 -24
  258. package/dist/control-ui/assets/index-CmNMuoem.js.map +0 -1
@@ -1,51 +1,316 @@
1
- import { computeJobNextRunAtMs, nextWakeAtMs, resolveJobPayloadTextForMain } from "./jobs.js";
1
+ import { DEFAULT_AGENT_ID } from "../../routing/session-key.js";
2
+ import { resolveCronDeliveryPlan } from "../delivery.js";
3
+ import { sweepCronRunSessions } from "../session-reaper.js";
4
+ import { computeJobNextRunAtMs, nextWakeAtMs, recomputeNextRuns, resolveJobPayloadTextForMain, } from "./jobs.js";
2
5
  import { locked } from "./locked.js";
3
6
  import { ensureLoaded, persist } from "./store.js";
4
- const MAX_TIMEOUT_MS = 2 ** 31 - 1;
7
+ const MAX_TIMER_DELAY_MS = 60_000;
8
+ /**
9
+ * Maximum wall-clock time for a single job execution. Acts as a safety net
10
+ * on top of the per-provider / per-agent timeouts to prevent one stuck job
11
+ * from wedging the entire cron lane.
12
+ */
13
+ const DEFAULT_JOB_TIMEOUT_MS = 10 * 60_000; // 10 minutes
14
+ /**
15
+ * Exponential backoff delays (in ms) indexed by consecutive error count.
16
+ * After the last entry the delay stays constant.
17
+ */
18
+ const ERROR_BACKOFF_SCHEDULE_MS = [
19
+ 30_000, // 1st error → 30 s
20
+ 60_000, // 2nd error → 1 min
21
+ 5 * 60_000, // 3rd error → 5 min
22
+ 15 * 60_000, // 4th error → 15 min
23
+ 60 * 60_000, // 5th+ error → 60 min
24
+ ];
25
+ function errorBackoffMs(consecutiveErrors) {
26
+ const idx = Math.min(consecutiveErrors - 1, ERROR_BACKOFF_SCHEDULE_MS.length - 1);
27
+ return ERROR_BACKOFF_SCHEDULE_MS[Math.max(0, idx)];
28
+ }
29
+ /**
30
+ * Apply the result of a job execution to the job's state.
31
+ * Handles consecutive error tracking, exponential backoff, one-shot disable,
32
+ * and nextRunAtMs computation. Returns `true` if the job should be deleted.
33
+ */
34
+ function applyJobResult(state, job, result) {
35
+ job.state.runningAtMs = undefined;
36
+ job.state.lastRunAtMs = result.startedAt;
37
+ job.state.lastStatus = result.status;
38
+ job.state.lastDurationMs = Math.max(0, result.endedAt - result.startedAt);
39
+ job.state.lastError = result.error;
40
+ job.updatedAtMs = result.endedAt;
41
+ // Track consecutive errors for backoff / auto-disable.
42
+ if (result.status === "error") {
43
+ job.state.consecutiveErrors = (job.state.consecutiveErrors ?? 0) + 1;
44
+ }
45
+ else {
46
+ job.state.consecutiveErrors = 0;
47
+ }
48
+ const shouldDelete = job.schedule.kind === "at" && result.status === "ok" && job.deleteAfterRun === true;
49
+ if (!shouldDelete) {
50
+ if (job.schedule.kind === "at") {
51
+ // One-shot jobs are always disabled after ANY terminal status
52
+ // (ok, error, or skipped). This prevents tight-loop rescheduling
53
+ // when computeJobNextRunAtMs returns the past atMs value (#11452).
54
+ job.enabled = false;
55
+ job.state.nextRunAtMs = undefined;
56
+ if (result.status === "error") {
57
+ state.deps.log.warn({
58
+ jobId: job.id,
59
+ jobName: job.name,
60
+ consecutiveErrors: job.state.consecutiveErrors,
61
+ error: result.error,
62
+ }, "cron: disabling one-shot job after error");
63
+ }
64
+ }
65
+ else if (result.status === "error" && job.enabled) {
66
+ // Apply exponential backoff for errored jobs to prevent retry storms.
67
+ const backoff = errorBackoffMs(job.state.consecutiveErrors ?? 1);
68
+ const normalNext = computeJobNextRunAtMs(job, result.endedAt);
69
+ const backoffNext = result.endedAt + backoff;
70
+ // Use whichever is later: the natural next run or the backoff delay.
71
+ job.state.nextRunAtMs =
72
+ normalNext !== undefined ? Math.max(normalNext, backoffNext) : backoffNext;
73
+ state.deps.log.info({
74
+ jobId: job.id,
75
+ consecutiveErrors: job.state.consecutiveErrors,
76
+ backoffMs: backoff,
77
+ nextRunAtMs: job.state.nextRunAtMs,
78
+ }, "cron: applying error backoff");
79
+ }
80
+ else if (job.enabled) {
81
+ job.state.nextRunAtMs = computeJobNextRunAtMs(job, result.endedAt);
82
+ }
83
+ else {
84
+ job.state.nextRunAtMs = undefined;
85
+ }
86
+ }
87
+ return shouldDelete;
88
+ }
5
89
  export function armTimer(state) {
6
- if (state.timer)
90
+ if (state.timer) {
7
91
  clearTimeout(state.timer);
92
+ }
8
93
  state.timer = null;
9
- if (!state.deps.cronEnabled)
94
+ if (!state.deps.cronEnabled) {
95
+ state.deps.log.debug({}, "cron: armTimer skipped - scheduler disabled");
10
96
  return;
97
+ }
11
98
  const nextAt = nextWakeAtMs(state);
12
- if (!nextAt)
99
+ if (!nextAt) {
100
+ const jobCount = state.store?.jobs.length ?? 0;
101
+ const enabledCount = state.store?.jobs.filter((j) => j.enabled).length ?? 0;
102
+ const withNextRun = state.store?.jobs.filter((j) => j.enabled && typeof j.state.nextRunAtMs === "number")
103
+ .length ?? 0;
104
+ state.deps.log.debug({ jobCount, enabledCount, withNextRun }, "cron: armTimer skipped - no jobs with nextRunAtMs");
13
105
  return;
14
- const delay = Math.max(nextAt - state.deps.nowMs(), 0);
15
- // Avoid TimeoutOverflowWarning when a job is far in the future.
16
- const clampedDelay = Math.min(delay, MAX_TIMEOUT_MS);
17
- state.timer = setTimeout(() => {
18
- void onTimer(state).catch((err) => {
106
+ }
107
+ const now = state.deps.nowMs();
108
+ const delay = Math.max(nextAt - now, 0);
109
+ // Wake at least once a minute to avoid schedule drift and recover quickly
110
+ // when the process was paused or wall-clock time jumps.
111
+ const clampedDelay = Math.min(delay, MAX_TIMER_DELAY_MS);
112
+ state.timer = setTimeout(async () => {
113
+ try {
114
+ await onTimer(state);
115
+ }
116
+ catch (err) {
19
117
  state.deps.log.error({ err: String(err) }, "cron: timer tick failed");
20
- });
118
+ }
21
119
  }, clampedDelay);
22
- state.timer.unref?.();
120
+ state.deps.log.debug({ nextAt, delayMs: clampedDelay, clamped: delay > MAX_TIMER_DELAY_MS }, "cron: timer armed");
23
121
  }
24
122
  export async function onTimer(state) {
25
- if (state.running)
123
+ if (state.running) {
26
124
  return;
125
+ }
27
126
  state.running = true;
28
127
  try {
29
- await locked(state, async () => {
30
- await ensureLoaded(state);
31
- await runDueJobs(state);
128
+ const dueJobs = await locked(state, async () => {
129
+ await ensureLoaded(state, { forceReload: true, skipRecompute: true });
130
+ const due = findDueJobs(state);
131
+ if (due.length === 0) {
132
+ const changed = recomputeNextRuns(state);
133
+ if (changed) {
134
+ await persist(state);
135
+ }
136
+ return [];
137
+ }
138
+ const now = state.deps.nowMs();
139
+ for (const job of due) {
140
+ job.state.runningAtMs = now;
141
+ job.state.lastError = undefined;
142
+ }
32
143
  await persist(state);
33
- armTimer(state);
144
+ return due.map((j) => ({
145
+ id: j.id,
146
+ job: j,
147
+ }));
34
148
  });
149
+ const results = [];
150
+ for (const { id, job } of dueJobs) {
151
+ const startedAt = state.deps.nowMs();
152
+ job.state.runningAtMs = startedAt;
153
+ emit(state, { jobId: job.id, action: "started", runAtMs: startedAt });
154
+ const jobTimeoutMs = job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number"
155
+ ? job.payload.timeoutSeconds * 1_000
156
+ : DEFAULT_JOB_TIMEOUT_MS;
157
+ try {
158
+ let timeoutId;
159
+ const result = await Promise.race([
160
+ executeJobCore(state, job),
161
+ new Promise((_, reject) => {
162
+ timeoutId = setTimeout(() => reject(new Error("cron: job execution timed out")), jobTimeoutMs);
163
+ }),
164
+ ]).finally(() => clearTimeout(timeoutId));
165
+ results.push({ jobId: id, ...result, startedAt, endedAt: state.deps.nowMs() });
166
+ }
167
+ catch (err) {
168
+ state.deps.log.warn({ jobId: id, jobName: job.name, timeoutMs: jobTimeoutMs }, `cron: job failed: ${String(err)}`);
169
+ results.push({
170
+ jobId: id,
171
+ status: "error",
172
+ error: String(err),
173
+ startedAt,
174
+ endedAt: state.deps.nowMs(),
175
+ });
176
+ }
177
+ }
178
+ if (results.length > 0) {
179
+ await locked(state, async () => {
180
+ await ensureLoaded(state, { forceReload: true, skipRecompute: true });
181
+ for (const result of results) {
182
+ const job = state.store?.jobs.find((j) => j.id === result.jobId);
183
+ if (!job) {
184
+ continue;
185
+ }
186
+ const shouldDelete = applyJobResult(state, job, {
187
+ status: result.status,
188
+ error: result.error,
189
+ startedAt: result.startedAt,
190
+ endedAt: result.endedAt,
191
+ });
192
+ emit(state, {
193
+ jobId: job.id,
194
+ action: "finished",
195
+ status: result.status,
196
+ error: result.error,
197
+ summary: result.summary,
198
+ sessionId: result.sessionId,
199
+ sessionKey: result.sessionKey,
200
+ runAtMs: result.startedAt,
201
+ durationMs: job.state.lastDurationMs,
202
+ nextRunAtMs: job.state.nextRunAtMs,
203
+ });
204
+ if (shouldDelete && state.store) {
205
+ state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id);
206
+ emit(state, { jobId: job.id, action: "removed" });
207
+ }
208
+ }
209
+ recomputeNextRuns(state);
210
+ await persist(state);
211
+ });
212
+ }
213
+ // Piggyback session reaper on timer tick (self-throttled to every 5 min).
214
+ const storePaths = new Set();
215
+ if (state.deps.resolveSessionStorePath) {
216
+ const defaultAgentId = state.deps.defaultAgentId ?? DEFAULT_AGENT_ID;
217
+ if (state.store?.jobs?.length) {
218
+ for (const job of state.store.jobs) {
219
+ const agentId = typeof job.agentId === "string" && job.agentId.trim() ? job.agentId : defaultAgentId;
220
+ storePaths.add(state.deps.resolveSessionStorePath(agentId));
221
+ }
222
+ }
223
+ else {
224
+ storePaths.add(state.deps.resolveSessionStorePath(defaultAgentId));
225
+ }
226
+ }
227
+ else if (state.deps.sessionStorePath) {
228
+ storePaths.add(state.deps.sessionStorePath);
229
+ }
230
+ if (storePaths.size > 0) {
231
+ const nowMs = state.deps.nowMs();
232
+ for (const storePath of storePaths) {
233
+ try {
234
+ await sweepCronRunSessions({
235
+ cronConfig: state.deps.cronConfig,
236
+ sessionStorePath: storePath,
237
+ nowMs,
238
+ log: state.deps.log,
239
+ });
240
+ }
241
+ catch (err) {
242
+ state.deps.log.warn({ err: String(err), storePath }, "cron: session reaper sweep failed");
243
+ }
244
+ }
245
+ }
35
246
  }
36
247
  finally {
37
248
  state.running = false;
249
+ armTimer(state);
250
+ }
251
+ }
252
+ function findDueJobs(state) {
253
+ if (!state.store) {
254
+ return [];
255
+ }
256
+ const now = state.deps.nowMs();
257
+ return state.store.jobs.filter((j) => {
258
+ if (!j.state) {
259
+ j.state = {};
260
+ }
261
+ if (!j.enabled) {
262
+ return false;
263
+ }
264
+ if (typeof j.state.runningAtMs === "number") {
265
+ return false;
266
+ }
267
+ const next = j.state.nextRunAtMs;
268
+ return typeof next === "number" && now >= next;
269
+ });
270
+ }
271
+ export async function runMissedJobs(state) {
272
+ if (!state.store) {
273
+ return;
274
+ }
275
+ const now = state.deps.nowMs();
276
+ const missed = state.store.jobs.filter((j) => {
277
+ if (!j.state) {
278
+ j.state = {};
279
+ }
280
+ if (!j.enabled) {
281
+ return false;
282
+ }
283
+ if (typeof j.state.runningAtMs === "number") {
284
+ return false;
285
+ }
286
+ const next = j.state.nextRunAtMs;
287
+ if (j.schedule.kind === "at" && j.state.lastStatus === "ok") {
288
+ return false;
289
+ }
290
+ return typeof next === "number" && now >= next;
291
+ });
292
+ if (missed.length > 0) {
293
+ state.deps.log.info({ count: missed.length, jobIds: missed.map((j) => j.id) }, "cron: running missed jobs after restart");
294
+ for (const job of missed) {
295
+ await executeJob(state, job, now, { forced: false });
296
+ }
38
297
  }
39
298
  }
40
299
  export async function runDueJobs(state) {
41
- if (!state.store)
300
+ if (!state.store) {
42
301
  return;
302
+ }
43
303
  const now = state.deps.nowMs();
44
304
  const due = state.store.jobs.filter((j) => {
45
- if (!j.enabled)
305
+ if (!j.state) {
306
+ j.state = {};
307
+ }
308
+ if (!j.enabled) {
46
309
  return false;
47
- if (typeof j.state.runningAtMs === "number")
310
+ }
311
+ if (typeof j.state.runningAtMs === "number") {
48
312
  return false;
313
+ }
49
314
  const next = j.state.nextRunAtMs;
50
315
  return typeof next === "number" && now >= next;
51
316
  });
@@ -53,149 +318,126 @@ export async function runDueJobs(state) {
53
318
  await executeJob(state, job, now, { forced: false });
54
319
  }
55
320
  }
56
- export async function executeJob(state, job, nowMs, opts) {
57
- const startedAt = state.deps.nowMs();
58
- job.state.runningAtMs = startedAt;
59
- job.state.lastError = undefined;
60
- emit(state, { jobId: job.id, action: "started", runAtMs: startedAt });
61
- let deleted = false;
62
- const finish = async (status, err, summary, outputText) => {
63
- const endedAt = state.deps.nowMs();
64
- job.state.runningAtMs = undefined;
65
- job.state.lastRunAtMs = startedAt;
66
- job.state.lastStatus = status;
67
- job.state.lastDurationMs = Math.max(0, endedAt - startedAt);
68
- job.state.lastError = err;
69
- const shouldDelete = job.schedule.kind === "at" && status === "ok" && job.deleteAfterRun === true;
70
- if (!shouldDelete) {
71
- if (job.schedule.kind === "at" && status === "ok") {
72
- // One-shot job completed successfully; disable it.
73
- job.enabled = false;
74
- job.state.nextRunAtMs = undefined;
75
- }
76
- else if (job.enabled) {
77
- job.state.nextRunAtMs = computeJobNextRunAtMs(job, endedAt);
78
- }
79
- else {
80
- job.state.nextRunAtMs = undefined;
81
- }
321
+ async function executeJobCore(state, job) {
322
+ if (job.sessionTarget === "main") {
323
+ const text = resolveJobPayloadTextForMain(job);
324
+ if (!text) {
325
+ const kind = job.payload.kind;
326
+ return {
327
+ status: "skipped",
328
+ error: kind === "systemEvent"
329
+ ? "main job requires non-empty systemEvent text"
330
+ : 'main job requires payload.kind="systemEvent"',
331
+ };
82
332
  }
83
- emit(state, {
84
- jobId: job.id,
85
- action: "finished",
86
- status,
87
- error: err,
88
- summary,
89
- runAtMs: startedAt,
90
- durationMs: job.state.lastDurationMs,
91
- nextRunAtMs: job.state.nextRunAtMs,
92
- });
93
- if (shouldDelete && state.store) {
94
- state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id);
95
- deleted = true;
96
- emit(state, { jobId: job.id, action: "removed" });
97
- }
98
- if (job.sessionTarget === "isolated") {
99
- const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
100
- const mode = job.isolation?.postToMainMode ?? "summary";
101
- let body = (summary ?? err ?? status).trim();
102
- if (mode === "full") {
103
- // Prefer full agent output if available; fall back to summary.
104
- const maxCharsRaw = job.isolation?.postToMainMaxChars;
105
- const maxChars = Number.isFinite(maxCharsRaw) ? Math.max(0, maxCharsRaw) : 8000;
106
- const fullText = (outputText ?? "").trim();
107
- if (fullText) {
108
- body = fullText.length > maxChars ? `${fullText.slice(0, maxChars)}…` : fullText;
333
+ state.deps.enqueueSystemEvent(text, { agentId: job.agentId });
334
+ if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) {
335
+ const reason = `cron:${job.id}`;
336
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
337
+ const maxWaitMs = 2 * 60_000;
338
+ const waitStartedAt = state.deps.nowMs();
339
+ let heartbeatResult;
340
+ for (;;) {
341
+ heartbeatResult = await state.deps.runHeartbeatOnce({ reason });
342
+ if (heartbeatResult.status !== "skipped" ||
343
+ heartbeatResult.reason !== "requests-in-flight") {
344
+ break;
109
345
  }
346
+ if (state.deps.nowMs() - waitStartedAt > maxWaitMs) {
347
+ state.deps.requestHeartbeatNow({ reason });
348
+ return { status: "ok", summary: text };
349
+ }
350
+ await delay(250);
110
351
  }
111
- const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
112
- state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
113
- agentId: job.agentId,
114
- });
115
- if (job.wakeMode === "now") {
116
- state.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
117
- }
118
- }
119
- };
120
- try {
121
- if (job.sessionTarget === "main") {
122
- const text = resolveJobPayloadTextForMain(job);
123
- if (!text) {
124
- const kind = job.payload.kind;
125
- await finish("skipped", kind === "systemEvent"
126
- ? "main job requires non-empty systemEvent text"
127
- : 'main job requires payload.kind="systemEvent"');
128
- return;
352
+ if (heartbeatResult.status === "ran") {
353
+ return { status: "ok", summary: text };
129
354
  }
130
- state.deps.enqueueSystemEvent(text, { agentId: job.agentId });
131
- if (job.wakeMode === "now" && state.deps.runHeartbeatOnce) {
132
- const reason = `cron:${job.id}`;
133
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
134
- const maxWaitMs = 2 * 60_000;
135
- const waitStartedAt = state.deps.nowMs();
136
- let heartbeatResult;
137
- for (;;) {
138
- heartbeatResult = await state.deps.runHeartbeatOnce({ reason });
139
- if (heartbeatResult.status !== "skipped" ||
140
- heartbeatResult.reason !== "requests-in-flight") {
141
- break;
142
- }
143
- if (state.deps.nowMs() - waitStartedAt > maxWaitMs) {
144
- heartbeatResult = {
145
- status: "skipped",
146
- reason: "timeout waiting for main lane to become idle",
147
- };
148
- break;
149
- }
150
- await delay(250);
151
- }
152
- if (heartbeatResult.status === "ran") {
153
- await finish("ok", undefined, text);
154
- }
155
- else if (heartbeatResult.status === "skipped") {
156
- await finish("skipped", heartbeatResult.reason, text);
157
- }
158
- else {
159
- await finish("error", heartbeatResult.reason, text);
160
- }
355
+ else if (heartbeatResult.status === "skipped") {
356
+ return { status: "skipped", error: heartbeatResult.reason, summary: text };
161
357
  }
162
358
  else {
163
- // wakeMode is "next-heartbeat" or runHeartbeatOnce not available
164
- state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
165
- await finish("ok", undefined, text);
359
+ return { status: "error", error: heartbeatResult.reason, summary: text };
166
360
  }
167
- return;
168
361
  }
169
- if (job.payload.kind !== "agentTurn") {
170
- await finish("skipped", "isolated job requires payload.kind=agentTurn");
171
- return;
362
+ else {
363
+ state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
364
+ return { status: "ok", summary: text };
172
365
  }
173
- const res = await state.deps.runIsolatedAgentJob({
174
- job,
175
- message: job.payload.message,
176
- });
177
- if (res.status === "ok")
178
- await finish("ok", undefined, res.summary, res.outputText);
179
- else if (res.status === "skipped")
180
- await finish("skipped", undefined, res.summary, res.outputText);
181
- else
182
- await finish("error", res.error ?? "cron job failed", res.summary, res.outputText);
183
366
  }
184
- catch (err) {
185
- await finish("error", String(err));
367
+ if (job.payload.kind !== "agentTurn") {
368
+ return { status: "skipped", error: "isolated job requires payload.kind=agentTurn" };
186
369
  }
187
- finally {
188
- job.updatedAtMs = nowMs;
189
- if (!opts.forced && job.enabled && !deleted) {
190
- // Keep nextRunAtMs in sync in case the schedule advanced during a long run.
191
- job.state.nextRunAtMs = computeJobNextRunAtMs(job, state.deps.nowMs());
370
+ const res = await state.deps.runIsolatedAgentJob({
371
+ job,
372
+ message: job.payload.message,
373
+ });
374
+ // Post a short summary back to the main session.
375
+ const summaryText = res.summary?.trim();
376
+ const deliveryPlan = resolveCronDeliveryPlan(job);
377
+ if (summaryText && deliveryPlan.requested) {
378
+ const prefix = "Cron";
379
+ const label = res.status === "error" ? `${prefix} (error): ${summaryText}` : `${prefix}: ${summaryText}`;
380
+ state.deps.enqueueSystemEvent(label, { agentId: job.agentId });
381
+ if (job.wakeMode === "now") {
382
+ state.deps.requestHeartbeatNow({ reason: `cron:${job.id}` });
192
383
  }
193
384
  }
385
+ return {
386
+ status: res.status,
387
+ error: res.error,
388
+ summary: res.summary,
389
+ sessionId: res.sessionId,
390
+ sessionKey: res.sessionKey,
391
+ };
392
+ }
393
+ /**
394
+ * Execute a job. This version is used by the `run` command and other
395
+ * places that need the full execution with state updates.
396
+ */
397
+ export async function executeJob(state, job, _nowMs, _opts) {
398
+ if (!job.state) {
399
+ job.state = {};
400
+ }
401
+ const startedAt = state.deps.nowMs();
402
+ job.state.runningAtMs = startedAt;
403
+ job.state.lastError = undefined;
404
+ emit(state, { jobId: job.id, action: "started", runAtMs: startedAt });
405
+ let coreResult;
406
+ try {
407
+ coreResult = await executeJobCore(state, job);
408
+ }
409
+ catch (err) {
410
+ coreResult = { status: "error", error: String(err) };
411
+ }
412
+ const endedAt = state.deps.nowMs();
413
+ const shouldDelete = applyJobResult(state, job, {
414
+ status: coreResult.status,
415
+ error: coreResult.error,
416
+ startedAt,
417
+ endedAt,
418
+ });
419
+ emit(state, {
420
+ jobId: job.id,
421
+ action: "finished",
422
+ status: coreResult.status,
423
+ error: coreResult.error,
424
+ summary: coreResult.summary,
425
+ sessionId: coreResult.sessionId,
426
+ sessionKey: coreResult.sessionKey,
427
+ runAtMs: startedAt,
428
+ durationMs: job.state.lastDurationMs,
429
+ nextRunAtMs: job.state.nextRunAtMs,
430
+ });
431
+ if (shouldDelete && state.store) {
432
+ state.store.jobs = state.store.jobs.filter((j) => j.id !== job.id);
433
+ emit(state, { jobId: job.id, action: "removed" });
434
+ }
194
435
  }
195
436
  export function wake(state, opts) {
196
437
  const text = opts.text.trim();
197
- if (!text)
438
+ if (!text) {
198
439
  return { ok: false };
440
+ }
199
441
  state.deps.enqueueSystemEvent(text);
200
442
  if (opts.mode === "now") {
201
443
  state.deps.requestHeartbeatNow({ reason: "wake" });
@@ -203,8 +445,9 @@ export function wake(state, opts) {
203
445
  return { ok: true };
204
446
  }
205
447
  export function stopTimer(state) {
206
- if (state.timer)
448
+ if (state.timer) {
207
449
  clearTimeout(state.timer);
450
+ }
208
451
  state.timer = null;
209
452
  }
210
453
  export function emit(state, evt) {
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Cron session reaper — prunes completed isolated cron run sessions
3
+ * from the session store after a configurable retention period.
4
+ *
5
+ * Pattern: sessions keyed as `...:cron:<jobId>:run:<uuid>` are ephemeral
6
+ * run records. The base session (`...:cron:<jobId>`) is kept as-is.
7
+ */
8
+ import { parseDurationMs } from "../cli/parse-duration.js";
9
+ import { updateSessionStore } from "../config/sessions.js";
10
+ import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
11
+ const DEFAULT_RETENTION_MS = 24 * 3_600_000; // 24 hours
12
+ /** Minimum interval between reaper sweeps (avoid running every timer tick). */
13
+ const MIN_SWEEP_INTERVAL_MS = 5 * 60_000; // 5 minutes
14
+ const lastSweepAtMsByStore = new Map();
15
+ export function resolveRetentionMs(cronConfig) {
16
+ if (cronConfig?.sessionRetention === false) {
17
+ return null; // pruning disabled
18
+ }
19
+ const raw = cronConfig?.sessionRetention;
20
+ if (typeof raw === "string" && raw.trim()) {
21
+ try {
22
+ return parseDurationMs(raw.trim(), { defaultUnit: "h" });
23
+ }
24
+ catch {
25
+ return DEFAULT_RETENTION_MS;
26
+ }
27
+ }
28
+ return DEFAULT_RETENTION_MS;
29
+ }
30
+ /**
31
+ * Sweep the session store and prune expired cron run sessions.
32
+ * Designed to be called from the cron timer tick — self-throttles via
33
+ * MIN_SWEEP_INTERVAL_MS to avoid excessive I/O.
34
+ *
35
+ * Lock ordering: this function acquires the session-store file lock via
36
+ * `updateSessionStore`. It must be called OUTSIDE of the cron service's
37
+ * own `locked()` section to avoid lock-order inversions. The cron timer
38
+ * calls this after all `locked()` sections have been released.
39
+ */
40
+ export async function sweepCronRunSessions(params) {
41
+ const now = params.nowMs ?? Date.now();
42
+ const storePath = params.sessionStorePath;
43
+ const lastSweepAtMs = lastSweepAtMsByStore.get(storePath) ?? 0;
44
+ // Throttle: don't sweep more often than every 5 minutes.
45
+ if (!params.force && now - lastSweepAtMs < MIN_SWEEP_INTERVAL_MS) {
46
+ return { swept: false, pruned: 0 };
47
+ }
48
+ const retentionMs = resolveRetentionMs(params.cronConfig);
49
+ if (retentionMs === null) {
50
+ lastSweepAtMsByStore.set(storePath, now);
51
+ return { swept: false, pruned: 0 };
52
+ }
53
+ let pruned = 0;
54
+ try {
55
+ await updateSessionStore(storePath, (store) => {
56
+ const cutoff = now - retentionMs;
57
+ for (const key of Object.keys(store)) {
58
+ if (!isCronRunSessionKey(key)) {
59
+ continue;
60
+ }
61
+ const entry = store[key];
62
+ if (!entry) {
63
+ continue;
64
+ }
65
+ const updatedAt = entry.updatedAt ?? 0;
66
+ if (updatedAt < cutoff) {
67
+ delete store[key];
68
+ pruned++;
69
+ }
70
+ }
71
+ });
72
+ }
73
+ catch (err) {
74
+ params.log.warn({ err: String(err) }, "cron-reaper: failed to sweep session store");
75
+ return { swept: false, pruned: 0 };
76
+ }
77
+ lastSweepAtMsByStore.set(storePath, now);
78
+ if (pruned > 0) {
79
+ params.log.info({ pruned, retentionMs }, `cron-reaper: pruned ${pruned} expired cron run session(s)`);
80
+ }
81
+ return { swept: true, pruned };
82
+ }
83
+ /** Reset the throttle timer (for tests). */
84
+ export function resetReaperThrottle() {
85
+ lastSweepAtMsByStore.clear();
86
+ }