@herdctl/core 0.0.1 → 0.0.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 (275) hide show
  1. package/dist/config/__tests__/agent.test.js +31 -13
  2. package/dist/config/__tests__/agent.test.js.map +1 -1
  3. package/dist/config/__tests__/merge.test.js +9 -2
  4. package/dist/config/__tests__/merge.test.js.map +1 -1
  5. package/dist/config/__tests__/schema.test.js +350 -1
  6. package/dist/config/__tests__/schema.test.js.map +1 -1
  7. package/dist/config/index.d.ts +1 -1
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +3 -1
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/config/schema.d.ts +828 -24
  12. package/dist/config/schema.d.ts.map +1 -1
  13. package/dist/config/schema.js +118 -6
  14. package/dist/config/schema.js.map +1 -1
  15. package/dist/fleet-manager/__tests__/coverage.test.js +11 -332
  16. package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -1
  17. package/dist/fleet-manager/__tests__/errors.test.js +1 -49
  18. package/dist/fleet-manager/__tests__/errors.test.js.map +1 -1
  19. package/dist/fleet-manager/__tests__/integration.test.js +109 -0
  20. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
  21. package/dist/fleet-manager/__tests__/reload.test.js +1 -1
  22. package/dist/fleet-manager/__tests__/reload.test.js.map +1 -1
  23. package/dist/fleet-manager/config-reload.d.ts +164 -0
  24. package/dist/fleet-manager/config-reload.d.ts.map +1 -0
  25. package/dist/fleet-manager/config-reload.js +445 -0
  26. package/dist/fleet-manager/config-reload.js.map +1 -0
  27. package/dist/fleet-manager/context.d.ts +76 -0
  28. package/dist/fleet-manager/context.d.ts.map +1 -0
  29. package/dist/fleet-manager/context.js +11 -0
  30. package/dist/fleet-manager/context.js.map +1 -0
  31. package/dist/fleet-manager/errors.d.ts +0 -25
  32. package/dist/fleet-manager/errors.d.ts.map +1 -1
  33. package/dist/fleet-manager/errors.js +0 -38
  34. package/dist/fleet-manager/errors.js.map +1 -1
  35. package/dist/fleet-manager/event-emitters.d.ts +123 -0
  36. package/dist/fleet-manager/event-emitters.d.ts.map +1 -0
  37. package/dist/fleet-manager/event-emitters.js +136 -0
  38. package/dist/fleet-manager/event-emitters.js.map +1 -0
  39. package/dist/fleet-manager/event-types.d.ts +0 -15
  40. package/dist/fleet-manager/event-types.d.ts.map +1 -1
  41. package/dist/fleet-manager/fleet-manager.d.ts +40 -653
  42. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  43. package/dist/fleet-manager/fleet-manager.js +95 -1720
  44. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  45. package/dist/fleet-manager/index.d.ts +13 -2
  46. package/dist/fleet-manager/index.d.ts.map +1 -1
  47. package/dist/fleet-manager/index.js +19 -6
  48. package/dist/fleet-manager/index.js.map +1 -1
  49. package/dist/fleet-manager/job-control.d.ts +64 -0
  50. package/dist/fleet-manager/job-control.d.ts.map +1 -0
  51. package/dist/fleet-manager/job-control.js +296 -0
  52. package/dist/fleet-manager/job-control.js.map +1 -0
  53. package/dist/fleet-manager/log-streaming.d.ts +171 -0
  54. package/dist/fleet-manager/log-streaming.d.ts.map +1 -0
  55. package/dist/fleet-manager/log-streaming.js +503 -0
  56. package/dist/fleet-manager/log-streaming.js.map +1 -0
  57. package/dist/fleet-manager/schedule-executor.d.ts +63 -0
  58. package/dist/fleet-manager/schedule-executor.d.ts.map +1 -0
  59. package/dist/fleet-manager/schedule-executor.js +209 -0
  60. package/dist/fleet-manager/schedule-executor.js.map +1 -0
  61. package/dist/fleet-manager/schedule-management.d.ts +71 -0
  62. package/dist/fleet-manager/schedule-management.d.ts.map +1 -0
  63. package/dist/fleet-manager/schedule-management.js +171 -0
  64. package/dist/fleet-manager/schedule-management.js.map +1 -0
  65. package/dist/fleet-manager/status-queries.d.ts +105 -0
  66. package/dist/fleet-manager/status-queries.d.ts.map +1 -0
  67. package/dist/fleet-manager/status-queries.js +247 -0
  68. package/dist/fleet-manager/status-queries.js.map +1 -0
  69. package/dist/fleet-manager/types.d.ts +0 -39
  70. package/dist/fleet-manager/types.d.ts.map +1 -1
  71. package/dist/runner/__tests__/job-executor.test.js +206 -1
  72. package/dist/runner/__tests__/job-executor.test.js.map +1 -1
  73. package/dist/runner/job-executor.d.ts +9 -0
  74. package/dist/runner/job-executor.d.ts.map +1 -1
  75. package/dist/runner/job-executor.js +78 -4
  76. package/dist/runner/job-executor.js.map +1 -1
  77. package/dist/runner/types.d.ts +2 -0
  78. package/dist/runner/types.d.ts.map +1 -1
  79. package/dist/scheduler/__tests__/cron.test.d.ts +2 -0
  80. package/dist/scheduler/__tests__/cron.test.d.ts.map +1 -0
  81. package/dist/scheduler/__tests__/cron.test.js +867 -0
  82. package/dist/scheduler/__tests__/cron.test.js.map +1 -0
  83. package/dist/scheduler/__tests__/scheduler.test.js +164 -5
  84. package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
  85. package/dist/scheduler/cron.d.ts +126 -0
  86. package/dist/scheduler/cron.d.ts.map +1 -0
  87. package/dist/scheduler/cron.js +390 -0
  88. package/dist/scheduler/cron.js.map +1 -0
  89. package/dist/scheduler/errors.d.ts +81 -1
  90. package/dist/scheduler/errors.d.ts.map +1 -1
  91. package/dist/scheduler/errors.js +81 -6
  92. package/dist/scheduler/errors.js.map +1 -1
  93. package/dist/scheduler/index.d.ts +1 -0
  94. package/dist/scheduler/index.d.ts.map +1 -1
  95. package/dist/scheduler/index.js +2 -0
  96. package/dist/scheduler/index.js.map +1 -1
  97. package/dist/scheduler/schedule-runner.d.ts +2 -2
  98. package/dist/scheduler/schedule-runner.d.ts.map +1 -1
  99. package/dist/scheduler/schedule-runner.js +20 -8
  100. package/dist/scheduler/schedule-runner.js.map +1 -1
  101. package/dist/scheduler/scheduler.d.ts +4 -4
  102. package/dist/scheduler/scheduler.d.ts.map +1 -1
  103. package/dist/scheduler/scheduler.js +86 -20
  104. package/dist/scheduler/scheduler.js.map +1 -1
  105. package/dist/scheduler/types.d.ts +1 -1
  106. package/dist/scheduler/types.d.ts.map +1 -1
  107. package/dist/state/schemas/job-metadata.d.ts +2 -2
  108. package/package.json +33 -8
  109. package/.turbo/turbo-build.log +0 -4
  110. package/.turbo/turbo-test.log +0 -219
  111. package/.turbo/turbo-typecheck.log +0 -4
  112. package/coverage/base.css +0 -224
  113. package/coverage/block-navigation.js +0 -87
  114. package/coverage/coverage-final.json +0 -51
  115. package/coverage/favicon.png +0 -0
  116. package/coverage/index.html +0 -251
  117. package/coverage/prettify.css +0 -1
  118. package/coverage/prettify.js +0 -2
  119. package/coverage/sort-arrow-sprite.png +0 -0
  120. package/coverage/sorter.js +0 -210
  121. package/coverage/src/config/index.html +0 -191
  122. package/coverage/src/config/index.ts.html +0 -442
  123. package/coverage/src/config/interpolate.ts.html +0 -652
  124. package/coverage/src/config/loader.ts.html +0 -1501
  125. package/coverage/src/config/merge.ts.html +0 -823
  126. package/coverage/src/config/parser.ts.html +0 -1213
  127. package/coverage/src/config/schema.ts.html +0 -1123
  128. package/coverage/src/fleet-manager/errors.ts.html +0 -2326
  129. package/coverage/src/fleet-manager/event-types.ts.html +0 -1219
  130. package/coverage/src/fleet-manager/fleet-manager.ts.html +0 -7030
  131. package/coverage/src/fleet-manager/index.html +0 -206
  132. package/coverage/src/fleet-manager/index.ts.html +0 -469
  133. package/coverage/src/fleet-manager/job-manager.ts.html +0 -2074
  134. package/coverage/src/fleet-manager/job-queue.ts.html +0 -2479
  135. package/coverage/src/fleet-manager/types.ts.html +0 -2602
  136. package/coverage/src/index.html +0 -116
  137. package/coverage/src/index.ts.html +0 -181
  138. package/coverage/src/runner/errors.ts.html +0 -1006
  139. package/coverage/src/runner/index.html +0 -191
  140. package/coverage/src/runner/index.ts.html +0 -256
  141. package/coverage/src/runner/job-executor.ts.html +0 -1429
  142. package/coverage/src/runner/message-processor.ts.html +0 -1150
  143. package/coverage/src/runner/sdk-adapter.ts.html +0 -658
  144. package/coverage/src/runner/types.ts.html +0 -559
  145. package/coverage/src/scheduler/errors.ts.html +0 -388
  146. package/coverage/src/scheduler/index.html +0 -206
  147. package/coverage/src/scheduler/index.ts.html +0 -244
  148. package/coverage/src/scheduler/interval.ts.html +0 -652
  149. package/coverage/src/scheduler/schedule-runner.ts.html +0 -1411
  150. package/coverage/src/scheduler/schedule-state.ts.html +0 -718
  151. package/coverage/src/scheduler/scheduler.ts.html +0 -1795
  152. package/coverage/src/scheduler/types.ts.html +0 -733
  153. package/coverage/src/state/directory.ts.html +0 -736
  154. package/coverage/src/state/errors.ts.html +0 -376
  155. package/coverage/src/state/fleet-state.ts.html +0 -937
  156. package/coverage/src/state/index.html +0 -221
  157. package/coverage/src/state/index.ts.html +0 -322
  158. package/coverage/src/state/job-metadata.ts.html +0 -1420
  159. package/coverage/src/state/job-output.ts.html +0 -1033
  160. package/coverage/src/state/schemas/fleet-state.ts.html +0 -445
  161. package/coverage/src/state/schemas/index.html +0 -176
  162. package/coverage/src/state/schemas/index.ts.html +0 -286
  163. package/coverage/src/state/schemas/job-metadata.ts.html +0 -628
  164. package/coverage/src/state/schemas/job-output.ts.html +0 -616
  165. package/coverage/src/state/schemas/session-info.ts.html +0 -361
  166. package/coverage/src/state/session.ts.html +0 -844
  167. package/coverage/src/state/types.ts.html +0 -262
  168. package/coverage/src/state/utils/atomic.ts.html +0 -748
  169. package/coverage/src/state/utils/index.html +0 -146
  170. package/coverage/src/state/utils/index.ts.html +0 -103
  171. package/coverage/src/state/utils/reads.ts.html +0 -1621
  172. package/coverage/src/work-sources/adapters/github.ts.html +0 -3583
  173. package/coverage/src/work-sources/adapters/index.html +0 -131
  174. package/coverage/src/work-sources/adapters/index.ts.html +0 -277
  175. package/coverage/src/work-sources/errors.ts.html +0 -298
  176. package/coverage/src/work-sources/index.html +0 -176
  177. package/coverage/src/work-sources/index.ts.html +0 -529
  178. package/coverage/src/work-sources/manager.ts.html +0 -1324
  179. package/coverage/src/work-sources/registry.ts.html +0 -619
  180. package/coverage/src/work-sources/types.ts.html +0 -568
  181. package/dist/fleet-manager/__tests__/event-helpers.test.d.ts +0 -7
  182. package/dist/fleet-manager/__tests__/event-helpers.test.d.ts.map +0 -1
  183. package/dist/fleet-manager/__tests__/event-helpers.test.js +0 -368
  184. package/dist/fleet-manager/__tests__/event-helpers.test.js.map +0 -1
  185. package/src/config/__tests__/agent.test.ts +0 -864
  186. package/src/config/__tests__/interpolate.test.ts +0 -644
  187. package/src/config/__tests__/loader.test.ts +0 -784
  188. package/src/config/__tests__/merge.test.ts +0 -751
  189. package/src/config/__tests__/parser.test.ts +0 -533
  190. package/src/config/__tests__/schema.test.ts +0 -873
  191. package/src/config/index.ts +0 -119
  192. package/src/config/interpolate.ts +0 -189
  193. package/src/config/loader.ts +0 -472
  194. package/src/config/merge.ts +0 -246
  195. package/src/config/parser.ts +0 -376
  196. package/src/config/schema.ts +0 -346
  197. package/src/fleet-manager/__tests__/coverage.test.ts +0 -2869
  198. package/src/fleet-manager/__tests__/errors.test.ts +0 -660
  199. package/src/fleet-manager/__tests__/event-helpers.test.ts +0 -448
  200. package/src/fleet-manager/__tests__/integration.test.ts +0 -1209
  201. package/src/fleet-manager/__tests__/job-control.test.ts +0 -283
  202. package/src/fleet-manager/__tests__/job-manager.test.ts +0 -869
  203. package/src/fleet-manager/__tests__/job-queue.test.ts +0 -401
  204. package/src/fleet-manager/__tests__/reload.test.ts +0 -751
  205. package/src/fleet-manager/__tests__/status-queries.test.ts +0 -595
  206. package/src/fleet-manager/__tests__/trigger.test.ts +0 -601
  207. package/src/fleet-manager/errors.ts +0 -747
  208. package/src/fleet-manager/event-types.ts +0 -378
  209. package/src/fleet-manager/fleet-manager.ts +0 -2315
  210. package/src/fleet-manager/index.ts +0 -128
  211. package/src/fleet-manager/job-manager.ts +0 -663
  212. package/src/fleet-manager/job-queue.ts +0 -798
  213. package/src/fleet-manager/types.ts +0 -839
  214. package/src/index.ts +0 -32
  215. package/src/runner/__tests__/errors.test.ts +0 -382
  216. package/src/runner/__tests__/job-executor.test.ts +0 -1708
  217. package/src/runner/__tests__/message-processor.test.ts +0 -960
  218. package/src/runner/__tests__/sdk-adapter.test.ts +0 -626
  219. package/src/runner/errors.ts +0 -307
  220. package/src/runner/index.ts +0 -57
  221. package/src/runner/job-executor.ts +0 -448
  222. package/src/runner/message-processor.ts +0 -355
  223. package/src/runner/sdk-adapter.ts +0 -191
  224. package/src/runner/types.ts +0 -158
  225. package/src/scheduler/__tests__/errors.test.ts +0 -159
  226. package/src/scheduler/__tests__/interval.test.ts +0 -515
  227. package/src/scheduler/__tests__/schedule-runner.test.ts +0 -798
  228. package/src/scheduler/__tests__/schedule-state.test.ts +0 -671
  229. package/src/scheduler/__tests__/scheduler.test.ts +0 -1280
  230. package/src/scheduler/errors.ts +0 -101
  231. package/src/scheduler/index.ts +0 -53
  232. package/src/scheduler/interval.ts +0 -189
  233. package/src/scheduler/schedule-runner.ts +0 -442
  234. package/src/scheduler/schedule-state.ts +0 -211
  235. package/src/scheduler/scheduler.ts +0 -570
  236. package/src/scheduler/types.ts +0 -216
  237. package/src/state/__tests__/directory.test.ts +0 -595
  238. package/src/state/__tests__/fleet-state.test.ts +0 -868
  239. package/src/state/__tests__/job-metadata-schema.test.ts +0 -414
  240. package/src/state/__tests__/job-metadata.test.ts +0 -831
  241. package/src/state/__tests__/job-output.test.ts +0 -856
  242. package/src/state/__tests__/session-schema.test.ts +0 -378
  243. package/src/state/__tests__/session.test.ts +0 -604
  244. package/src/state/directory.ts +0 -217
  245. package/src/state/errors.ts +0 -97
  246. package/src/state/fleet-state.ts +0 -284
  247. package/src/state/index.ts +0 -79
  248. package/src/state/job-metadata.ts +0 -445
  249. package/src/state/job-output.ts +0 -316
  250. package/src/state/schemas/__tests__/job-output.test.ts +0 -338
  251. package/src/state/schemas/fleet-state.ts +0 -120
  252. package/src/state/schemas/index.ts +0 -67
  253. package/src/state/schemas/job-metadata.ts +0 -181
  254. package/src/state/schemas/job-output.ts +0 -177
  255. package/src/state/schemas/session-info.ts +0 -92
  256. package/src/state/session.ts +0 -253
  257. package/src/state/types.ts +0 -59
  258. package/src/state/utils/__tests__/atomic.test.ts +0 -723
  259. package/src/state/utils/__tests__/reads.test.ts +0 -1071
  260. package/src/state/utils/atomic.ts +0 -221
  261. package/src/state/utils/index.ts +0 -6
  262. package/src/state/utils/reads.ts +0 -512
  263. package/src/work-sources/__tests__/github.test.ts +0 -1800
  264. package/src/work-sources/__tests__/manager.test.ts +0 -529
  265. package/src/work-sources/__tests__/registry.test.ts +0 -477
  266. package/src/work-sources/__tests__/types.test.ts +0 -479
  267. package/src/work-sources/adapters/github.ts +0 -1166
  268. package/src/work-sources/adapters/index.ts +0 -64
  269. package/src/work-sources/errors.ts +0 -71
  270. package/src/work-sources/index.ts +0 -148
  271. package/src/work-sources/manager.ts +0 -413
  272. package/src/work-sources/registry.ts +0 -178
  273. package/src/work-sources/types.ts +0 -161
  274. package/tsconfig.json +0 -9
  275. package/vitest.config.ts +0 -19
@@ -1,1280 +0,0 @@
1
- import {
2
- describe,
3
- it,
4
- expect,
5
- beforeEach,
6
- afterEach,
7
- vi,
8
- type MockInstance,
9
- } from "vitest";
10
- import { mkdir, rm, realpath } from "node:fs/promises";
11
- import { join } from "node:path";
12
- import { tmpdir } from "node:os";
13
- import { Scheduler } from "../scheduler.js";
14
- import { SchedulerShutdownError } from "../errors.js";
15
- import type {
16
- SchedulerOptions,
17
- SchedulerLogger,
18
- TriggerInfo,
19
- StopOptions,
20
- } from "../types.js";
21
- import type { ResolvedAgent } from "../../config/index.js";
22
- import { writeFleetState, readFleetState } from "../../state/fleet-state.js";
23
- import type { FleetState } from "../../state/schemas/fleet-state.js";
24
-
25
- // Helper to create a temp directory
26
- async function createTempDir(): Promise<string> {
27
- const baseDir = join(
28
- tmpdir(),
29
- `herdctl-scheduler-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
30
- );
31
- await mkdir(baseDir, { recursive: true });
32
- // Resolve to real path to handle macOS /var -> /private/var symlink
33
- return await realpath(baseDir);
34
- }
35
-
36
- // Helper to create a mock logger
37
- function createMockLogger(): SchedulerLogger & {
38
- debugs: string[];
39
- infos: string[];
40
- warnings: string[];
41
- errors: string[];
42
- } {
43
- const debugs: string[] = [];
44
- const infos: string[] = [];
45
- const warnings: string[] = [];
46
- const errors: string[] = [];
47
- return {
48
- debugs,
49
- infos,
50
- warnings,
51
- errors,
52
- debug: (message: string) => debugs.push(message),
53
- info: (message: string) => infos.push(message),
54
- warn: (message: string) => warnings.push(message),
55
- error: (message: string) => errors.push(message),
56
- };
57
- }
58
-
59
- // Helper to create a test agent
60
- function createTestAgent(
61
- name: string,
62
- schedules?: Record<string, { type: string; interval?: string; prompt?: string }>
63
- ): ResolvedAgent {
64
- return {
65
- name,
66
- configPath: `/fake/path/${name}.yaml`,
67
- schedules,
68
- } as ResolvedAgent;
69
- }
70
-
71
- // Helper to wait for a short period
72
- function wait(ms: number): Promise<void> {
73
- return new Promise((resolve) => setTimeout(resolve, ms));
74
- }
75
-
76
- describe("Scheduler", () => {
77
- let tempDir: string;
78
- let mockLogger: ReturnType<typeof createMockLogger>;
79
-
80
- beforeEach(async () => {
81
- tempDir = await createTempDir();
82
- mockLogger = createMockLogger();
83
- });
84
-
85
- afterEach(async () => {
86
- await rm(tempDir, { recursive: true, force: true });
87
- });
88
-
89
- describe("constructor", () => {
90
- it("creates scheduler with default check interval", () => {
91
- const scheduler = new Scheduler({
92
- stateDir: tempDir,
93
- logger: mockLogger,
94
- });
95
-
96
- expect(scheduler.getStatus()).toBe("stopped");
97
- expect(scheduler.isRunning()).toBe(false);
98
- });
99
-
100
- it("creates scheduler with custom check interval", () => {
101
- const scheduler = new Scheduler({
102
- stateDir: tempDir,
103
- checkInterval: 500,
104
- logger: mockLogger,
105
- });
106
-
107
- expect(scheduler.getStatus()).toBe("stopped");
108
- });
109
- });
110
-
111
- describe("isRunning", () => {
112
- it("returns false when stopped", () => {
113
- const scheduler = new Scheduler({
114
- stateDir: tempDir,
115
- logger: mockLogger,
116
- });
117
-
118
- expect(scheduler.isRunning()).toBe(false);
119
- });
120
-
121
- it("returns true when running", async () => {
122
- const scheduler = new Scheduler({
123
- stateDir: tempDir,
124
- checkInterval: 100,
125
- logger: mockLogger,
126
- });
127
-
128
- // Start in background
129
- const startPromise = scheduler.start([]);
130
-
131
- // Wait a tick for status to update
132
- await wait(10);
133
- expect(scheduler.isRunning()).toBe(true);
134
-
135
- // Stop the scheduler
136
- await scheduler.stop();
137
- await startPromise;
138
- });
139
- });
140
-
141
- describe("getStatus", () => {
142
- it("returns stopped initially", () => {
143
- const scheduler = new Scheduler({
144
- stateDir: tempDir,
145
- logger: mockLogger,
146
- });
147
-
148
- expect(scheduler.getStatus()).toBe("stopped");
149
- });
150
-
151
- it("returns running when started", async () => {
152
- const scheduler = new Scheduler({
153
- stateDir: tempDir,
154
- checkInterval: 100,
155
- logger: mockLogger,
156
- });
157
-
158
- const startPromise = scheduler.start([]);
159
- await wait(10);
160
-
161
- expect(scheduler.getStatus()).toBe("running");
162
-
163
- await scheduler.stop();
164
- await startPromise;
165
- });
166
-
167
- it("returns stopped after stop", async () => {
168
- const scheduler = new Scheduler({
169
- stateDir: tempDir,
170
- checkInterval: 100,
171
- logger: mockLogger,
172
- });
173
-
174
- const startPromise = scheduler.start([]);
175
- await wait(10);
176
- await scheduler.stop();
177
- await startPromise;
178
-
179
- expect(scheduler.getStatus()).toBe("stopped");
180
- });
181
- });
182
-
183
- describe("getState", () => {
184
- it("returns initial state", () => {
185
- const scheduler = new Scheduler({
186
- stateDir: tempDir,
187
- logger: mockLogger,
188
- });
189
-
190
- const state = scheduler.getState();
191
-
192
- expect(state.status).toBe("stopped");
193
- expect(state.startedAt).toBeNull();
194
- expect(state.checkCount).toBe(0);
195
- expect(state.triggerCount).toBe(0);
196
- expect(state.lastCheckAt).toBeNull();
197
- });
198
-
199
- it("updates startedAt when started", async () => {
200
- const scheduler = new Scheduler({
201
- stateDir: tempDir,
202
- checkInterval: 100,
203
- logger: mockLogger,
204
- });
205
-
206
- const beforeStart = new Date().toISOString();
207
- const startPromise = scheduler.start([]);
208
- await wait(10);
209
-
210
- const state = scheduler.getState();
211
- expect(state.startedAt).not.toBeNull();
212
- expect(new Date(state.startedAt!).getTime()).toBeGreaterThanOrEqual(
213
- new Date(beforeStart).getTime()
214
- );
215
-
216
- await scheduler.stop();
217
- await startPromise;
218
- });
219
-
220
- it("increments checkCount", async () => {
221
- const scheduler = new Scheduler({
222
- stateDir: tempDir,
223
- checkInterval: 50,
224
- logger: mockLogger,
225
- });
226
-
227
- const startPromise = scheduler.start([]);
228
-
229
- // Wait for a few checks
230
- await wait(180);
231
-
232
- const state = scheduler.getState();
233
- expect(state.checkCount).toBeGreaterThan(0);
234
-
235
- await scheduler.stop();
236
- await startPromise;
237
- });
238
- });
239
-
240
- describe("start", () => {
241
- it("starts the scheduler", async () => {
242
- const scheduler = new Scheduler({
243
- stateDir: tempDir,
244
- checkInterval: 100,
245
- logger: mockLogger,
246
- });
247
-
248
- const startPromise = scheduler.start([]);
249
- await wait(10);
250
-
251
- expect(scheduler.isRunning()).toBe(true);
252
- expect(mockLogger.infos.some((m) => m.includes("started"))).toBe(true);
253
-
254
- await scheduler.stop();
255
- await startPromise;
256
- });
257
-
258
- it("throws if already running", async () => {
259
- const scheduler = new Scheduler({
260
- stateDir: tempDir,
261
- checkInterval: 100,
262
- logger: mockLogger,
263
- });
264
-
265
- const startPromise = scheduler.start([]);
266
- await wait(10);
267
-
268
- await expect(scheduler.start([])).rejects.toThrow("already running");
269
-
270
- await scheduler.stop();
271
- await startPromise;
272
- });
273
-
274
- it("logs the number of agents and check interval", async () => {
275
- const scheduler = new Scheduler({
276
- stateDir: tempDir,
277
- checkInterval: 500,
278
- logger: mockLogger,
279
- });
280
-
281
- const agents = [
282
- createTestAgent("agent-1"),
283
- createTestAgent("agent-2"),
284
- ];
285
-
286
- const startPromise = scheduler.start(agents);
287
- await wait(10);
288
-
289
- expect(
290
- mockLogger.infos.some(
291
- (m) => m.includes("2 agents") && m.includes("500ms")
292
- )
293
- ).toBe(true);
294
-
295
- await scheduler.stop();
296
- await startPromise;
297
- });
298
- });
299
-
300
- describe("stop", () => {
301
- it("stops the scheduler", async () => {
302
- const scheduler = new Scheduler({
303
- stateDir: tempDir,
304
- checkInterval: 100,
305
- logger: mockLogger,
306
- });
307
-
308
- const startPromise = scheduler.start([]);
309
- await wait(10);
310
-
311
- await scheduler.stop();
312
- await startPromise;
313
-
314
- expect(scheduler.isRunning()).toBe(false);
315
- expect(mockLogger.infos.some((m) => m.includes("stopped"))).toBe(true);
316
- });
317
-
318
- it("does nothing if already stopped", async () => {
319
- const scheduler = new Scheduler({
320
- stateDir: tempDir,
321
- logger: mockLogger,
322
- });
323
-
324
- await scheduler.stop(); // Should not throw
325
-
326
- expect(scheduler.getStatus()).toBe("stopped");
327
- });
328
- });
329
-
330
- describe("setAgents", () => {
331
- it("updates the agents list", async () => {
332
- const scheduler = new Scheduler({
333
- stateDir: tempDir,
334
- checkInterval: 100,
335
- logger: mockLogger,
336
- });
337
-
338
- const startPromise = scheduler.start([]);
339
- await wait(10);
340
-
341
- const newAgents = [createTestAgent("new-agent")];
342
- scheduler.setAgents(newAgents);
343
-
344
- expect(
345
- mockLogger.debugs.some((m) => m.includes("Updated agents list"))
346
- ).toBe(true);
347
-
348
- await scheduler.stop();
349
- await startPromise;
350
- });
351
- });
352
-
353
- describe("schedule checking", () => {
354
- it("skips agents without schedules", async () => {
355
- const scheduler = new Scheduler({
356
- stateDir: tempDir,
357
- checkInterval: 50,
358
- logger: mockLogger,
359
- });
360
-
361
- const agents = [createTestAgent("no-schedules-agent")];
362
-
363
- const startPromise = scheduler.start(agents);
364
- await wait(100);
365
-
366
- // Should complete check without errors
367
- const state = scheduler.getState();
368
- expect(state.checkCount).toBeGreaterThan(0);
369
- expect(state.triggerCount).toBe(0);
370
-
371
- await scheduler.stop();
372
- await startPromise;
373
- });
374
-
375
- it("skips non-interval schedule types", async () => {
376
- const scheduler = new Scheduler({
377
- stateDir: tempDir,
378
- checkInterval: 50,
379
- logger: mockLogger,
380
- });
381
-
382
- const agents = [
383
- createTestAgent("cron-agent", {
384
- hourly: { type: "cron", interval: "0 * * * *" },
385
- }),
386
- createTestAgent("webhook-agent", {
387
- webhook: { type: "webhook" },
388
- }),
389
- createTestAgent("chat-agent", {
390
- chat: { type: "chat" },
391
- }),
392
- ];
393
-
394
- const startPromise = scheduler.start(agents);
395
- await wait(100);
396
-
397
- // Should not trigger any schedules
398
- const state = scheduler.getState();
399
- expect(state.triggerCount).toBe(0);
400
-
401
- await scheduler.stop();
402
- await startPromise;
403
- });
404
-
405
- it("skips disabled schedules", async () => {
406
- const stateFile = join(tempDir, "state.yaml");
407
- const initialState: FleetState = {
408
- fleet: {},
409
- agents: {
410
- "test-agent": {
411
- status: "idle",
412
- schedules: {
413
- hourly: {
414
- last_run_at: null,
415
- next_run_at: null,
416
- status: "disabled",
417
- last_error: null,
418
- },
419
- },
420
- },
421
- },
422
- };
423
- await writeFleetState(stateFile, initialState);
424
-
425
- const scheduler = new Scheduler({
426
- stateDir: tempDir,
427
- checkInterval: 50,
428
- logger: mockLogger,
429
- });
430
-
431
- const agents = [
432
- createTestAgent("test-agent", {
433
- hourly: { type: "interval", interval: "1s" },
434
- }),
435
- ];
436
-
437
- const startPromise = scheduler.start(agents);
438
- await wait(100);
439
-
440
- expect(
441
- mockLogger.debugs.some((m) => m.includes("disabled"))
442
- ).toBe(true);
443
- expect(scheduler.getState().triggerCount).toBe(0);
444
-
445
- await scheduler.stop();
446
- await startPromise;
447
- });
448
-
449
- it("skips schedules missing interval value", async () => {
450
- const scheduler = new Scheduler({
451
- stateDir: tempDir,
452
- checkInterval: 50,
453
- logger: mockLogger,
454
- });
455
-
456
- const agents = [
457
- createTestAgent("test-agent", {
458
- broken: { type: "interval" }, // Missing interval value
459
- }),
460
- ];
461
-
462
- const startPromise = scheduler.start(agents);
463
- await wait(100);
464
-
465
- expect(
466
- mockLogger.warnings.some((m) => m.includes("missing interval value"))
467
- ).toBe(true);
468
- expect(scheduler.getState().triggerCount).toBe(0);
469
-
470
- await scheduler.stop();
471
- await startPromise;
472
- });
473
-
474
- it("triggers due interval schedules", async () => {
475
- const triggers: TriggerInfo[] = [];
476
-
477
- const scheduler = new Scheduler({
478
- stateDir: tempDir,
479
- checkInterval: 50,
480
- logger: mockLogger,
481
- onTrigger: async (info) => {
482
- triggers.push(info);
483
- },
484
- });
485
-
486
- const agents = [
487
- createTestAgent("test-agent", {
488
- hourly: { type: "interval", interval: "1s", prompt: "test prompt" },
489
- }),
490
- ];
491
-
492
- const startPromise = scheduler.start(agents);
493
- await wait(150);
494
-
495
- // First run should trigger immediately (no last_run_at)
496
- expect(triggers.length).toBeGreaterThan(0);
497
- expect(triggers[0].agent.name).toBe("test-agent");
498
- expect(triggers[0].scheduleName).toBe("hourly");
499
-
500
- await scheduler.stop();
501
- await startPromise;
502
- });
503
-
504
- it("updates schedule state on trigger", async () => {
505
- const scheduler = new Scheduler({
506
- stateDir: tempDir,
507
- checkInterval: 50,
508
- logger: mockLogger,
509
- onTrigger: async () => {
510
- // Simulate work
511
- await wait(10);
512
- },
513
- });
514
-
515
- const agents = [
516
- createTestAgent("test-agent", {
517
- hourly: { type: "interval", interval: "1s" },
518
- }),
519
- ];
520
-
521
- const startPromise = scheduler.start(agents);
522
- await wait(150);
523
-
524
- // Check state was updated
525
- const stateFile = join(tempDir, "state.yaml");
526
- const fleetState = await readFleetState(stateFile);
527
- const scheduleState = fleetState.agents["test-agent"]?.schedules?.hourly;
528
-
529
- expect(scheduleState).toBeDefined();
530
- expect(scheduleState?.last_run_at).not.toBeNull();
531
- expect(scheduleState?.status).toBe("idle"); // Should be idle after completion
532
-
533
- await scheduler.stop();
534
- await startPromise;
535
- });
536
-
537
- it("records error in schedule state on trigger failure", async () => {
538
- const scheduler = new Scheduler({
539
- stateDir: tempDir,
540
- checkInterval: 50,
541
- logger: mockLogger,
542
- onTrigger: async () => {
543
- throw new Error("Trigger failed!");
544
- },
545
- });
546
-
547
- const agents = [
548
- createTestAgent("test-agent", {
549
- hourly: { type: "interval", interval: "1s" },
550
- }),
551
- ];
552
-
553
- const startPromise = scheduler.start(agents);
554
- await wait(150);
555
-
556
- // Check error was recorded
557
- const stateFile = join(tempDir, "state.yaml");
558
- const fleetState = await readFleetState(stateFile);
559
- const scheduleState = fleetState.agents["test-agent"]?.schedules?.hourly;
560
-
561
- expect(scheduleState?.last_error).toBe("Trigger failed!");
562
- expect(mockLogger.errors.some((m) => m.includes("Trigger failed!"))).toBe(
563
- true
564
- );
565
-
566
- await scheduler.stop();
567
- await startPromise;
568
- });
569
-
570
- it("does not trigger already running schedule", async () => {
571
- let triggerCount = 0;
572
- let isRunning = false;
573
-
574
- const scheduler = new Scheduler({
575
- stateDir: tempDir,
576
- checkInterval: 30,
577
- logger: mockLogger,
578
- onTrigger: async () => {
579
- if (isRunning) {
580
- throw new Error("Should not trigger while running!");
581
- }
582
- isRunning = true;
583
- triggerCount++;
584
- // Simulate long-running job
585
- await wait(100);
586
- isRunning = false;
587
- },
588
- });
589
-
590
- const agents = [
591
- createTestAgent("test-agent", {
592
- hourly: { type: "interval", interval: "1s" },
593
- }),
594
- ];
595
-
596
- const startPromise = scheduler.start(agents);
597
-
598
- // Wait long enough for multiple checks but only one trigger should complete
599
- await wait(150);
600
-
601
- // Should only have triggered once due to running check
602
- expect(triggerCount).toBe(1);
603
-
604
- await scheduler.stop();
605
- await startPromise;
606
- });
607
- });
608
-
609
- describe("error handling", () => {
610
- it("continues checking after error in check cycle", async () => {
611
- // Create scheduler that will encounter an error reading state
612
- // by using a non-existent state dir initially
613
- const scheduler = new Scheduler({
614
- stateDir: "/nonexistent/path",
615
- checkInterval: 50,
616
- logger: mockLogger,
617
- });
618
-
619
- const agents = [
620
- createTestAgent("test-agent", {
621
- hourly: { type: "interval", interval: "1s" },
622
- }),
623
- ];
624
-
625
- const startPromise = scheduler.start(agents);
626
- await wait(150);
627
-
628
- // Should have logged errors but kept running
629
- expect(scheduler.isRunning()).toBe(true);
630
- expect(scheduler.getState().checkCount).toBeGreaterThan(0);
631
-
632
- await scheduler.stop();
633
- await startPromise;
634
- });
635
- });
636
-
637
- describe("concurrent execution", () => {
638
- it("tracks running schedules per agent", async () => {
639
- const runningAgents = new Set<string>();
640
- let maxConcurrent = 0;
641
-
642
- // Use a barrier to ensure both triggers are running at the same time
643
- let triggered = 0;
644
- let resolveBarrier: () => void;
645
- const bothStarted = new Promise<void>((resolve) => {
646
- resolveBarrier = resolve;
647
- });
648
-
649
- const checkBoth = () => {
650
- triggered++;
651
- if (triggered >= 2) resolveBarrier();
652
- };
653
-
654
- const scheduler = new Scheduler({
655
- stateDir: tempDir,
656
- checkInterval: 30,
657
- logger: mockLogger,
658
- onTrigger: async (info) => {
659
- runningAgents.add(`${info.agent.name}/${info.scheduleName}`);
660
- maxConcurrent = Math.max(maxConcurrent, runningAgents.size);
661
- // Signal that this trigger started
662
- checkBoth();
663
- // Wait long enough for both to be running
664
- await wait(100);
665
- runningAgents.delete(`${info.agent.name}/${info.scheduleName}`);
666
- },
667
- });
668
-
669
- const agents = [
670
- createTestAgent("agent-1", {
671
- schedule1: { type: "interval", interval: "1s" },
672
- }),
673
- createTestAgent("agent-2", {
674
- schedule2: { type: "interval", interval: "1s" },
675
- }),
676
- ];
677
-
678
- const startPromise = scheduler.start(agents);
679
-
680
- // Wait for both to have started running
681
- await Promise.race([bothStarted, wait(300)]);
682
-
683
- // Check max concurrent - may be 1 or 2 depending on timing
684
- // The important thing is both agents were triggered
685
- expect(maxConcurrent).toBeGreaterThanOrEqual(1);
686
-
687
- await scheduler.stop();
688
- await startPromise;
689
- });
690
- });
691
-
692
- describe("max_concurrent limit", () => {
693
- it("respects max_concurrent from agent instances config", async () => {
694
- const triggerCounts = new Map<string, number>();
695
- let concurrentForAgent2 = 0;
696
- let maxConcurrentForAgent2 = 0;
697
-
698
- const scheduler = new Scheduler({
699
- stateDir: tempDir,
700
- checkInterval: 30,
701
- logger: mockLogger,
702
- onTrigger: async (info) => {
703
- const key = info.agent.name;
704
- const count = (triggerCounts.get(key) || 0) + 1;
705
- triggerCounts.set(key, count);
706
-
707
- if (info.agent.name === "agent-2") {
708
- concurrentForAgent2++;
709
- maxConcurrentForAgent2 = Math.max(
710
- maxConcurrentForAgent2,
711
- concurrentForAgent2
712
- );
713
- }
714
-
715
- // Simulate work
716
- await wait(80);
717
-
718
- if (info.agent.name === "agent-2") {
719
- concurrentForAgent2--;
720
- }
721
- },
722
- });
723
-
724
- // Agent with max_concurrent: 2
725
- const agentWithMaxConcurrent2 = {
726
- ...createTestAgent("agent-2", {
727
- schedule1: { type: "interval", interval: "1s" },
728
- schedule2: { type: "interval", interval: "1s" },
729
- schedule3: { type: "interval", interval: "1s" },
730
- }),
731
- instances: { max_concurrent: 2 },
732
- } as ResolvedAgent;
733
-
734
- const startPromise = scheduler.start([agentWithMaxConcurrent2]);
735
-
736
- // Wait enough time for multiple checks
737
- await wait(200);
738
-
739
- // Should not exceed max_concurrent of 2
740
- expect(maxConcurrentForAgent2).toBeLessThanOrEqual(2);
741
-
742
- await scheduler.stop();
743
- await startPromise;
744
- });
745
-
746
- it("defaults max_concurrent to 1 when not specified", async () => {
747
- let concurrentForAgent = 0;
748
- let maxConcurrentForAgent = 0;
749
-
750
- const scheduler = new Scheduler({
751
- stateDir: tempDir,
752
- checkInterval: 30,
753
- logger: mockLogger,
754
- onTrigger: async () => {
755
- concurrentForAgent++;
756
- maxConcurrentForAgent = Math.max(
757
- maxConcurrentForAgent,
758
- concurrentForAgent
759
- );
760
-
761
- await wait(80);
762
-
763
- concurrentForAgent--;
764
- },
765
- });
766
-
767
- // Agent without instances config - should default to max_concurrent: 1
768
- const agent = createTestAgent("test-agent", {
769
- schedule1: { type: "interval", interval: "1s" },
770
- schedule2: { type: "interval", interval: "1s" },
771
- });
772
-
773
- const startPromise = scheduler.start([agent]);
774
-
775
- await wait(200);
776
-
777
- // Should not exceed default max_concurrent of 1
778
- expect(maxConcurrentForAgent).toBe(1);
779
-
780
- await scheduler.stop();
781
- await startPromise;
782
- });
783
-
784
- it("skips second schedule when agent is at capacity", async () => {
785
- // This test verifies that with max_concurrent: 1 and one schedule already
786
- // running, additional schedules are not triggered until the first completes.
787
- //
788
- // Note: Due to how the scheduler iterates through schedules synchronously
789
- // within a single check cycle, both schedules will be checked and triggered
790
- // if they're both due at the same time before either is marked as running.
791
- // The capacity check applies WITHIN each check cycle iteration, so the
792
- // second schedule is only skipped if a trigger is already in progress
793
- // from a PREVIOUS check cycle.
794
-
795
- let triggerCount = 0;
796
-
797
- const scheduler = new Scheduler({
798
- stateDir: tempDir,
799
- checkInterval: 30,
800
- logger: mockLogger,
801
- onTrigger: async () => {
802
- triggerCount++;
803
- // Long running job to span multiple check cycles
804
- await wait(150);
805
- },
806
- });
807
-
808
- // Agent with max_concurrent: 1 and one schedule
809
- // A second schedule that becomes due AFTER the first is running
810
- // will be skipped
811
- const agent = createTestAgent("test-agent", {
812
- schedule1: { type: "interval", interval: "1s" },
813
- });
814
-
815
- const startPromise = scheduler.start([agent]);
816
-
817
- // Wait for first trigger to happen and start running
818
- await wait(100);
819
-
820
- // With 1 schedule, it should trigger once initially
821
- // Then subsequent checks should show "already_running" until it completes
822
- expect(triggerCount).toBe(1);
823
- expect(scheduler.getRunningJobCount("test-agent")).toBe(1);
824
-
825
- // Wait for the job to complete
826
- await wait(200);
827
-
828
- // After completion, running count should be 0
829
- expect(scheduler.getRunningJobCount("test-agent")).toBe(0);
830
-
831
- await scheduler.stop();
832
- await startPromise;
833
- });
834
- });
835
-
836
- describe("getRunningJobCount", () => {
837
- it("returns 0 for agents with no running jobs", () => {
838
- const scheduler = new Scheduler({
839
- stateDir: tempDir,
840
- logger: mockLogger,
841
- });
842
-
843
- expect(scheduler.getRunningJobCount("non-existent-agent")).toBe(0);
844
- });
845
-
846
- it("returns correct count during job execution", async () => {
847
- let runningCountDuringExecution = -1;
848
-
849
- const scheduler = new Scheduler({
850
- stateDir: tempDir,
851
- checkInterval: 30,
852
- logger: mockLogger,
853
- onTrigger: async (info) => {
854
- // Check count while job is running
855
- runningCountDuringExecution = scheduler.getRunningJobCount(
856
- info.agent.name
857
- );
858
- await wait(50);
859
- },
860
- });
861
-
862
- const agent = createTestAgent("test-agent", {
863
- hourly: { type: "interval", interval: "1s" },
864
- });
865
-
866
- const startPromise = scheduler.start([agent]);
867
-
868
- await wait(100);
869
-
870
- // Count should have been 1 during execution
871
- expect(runningCountDuringExecution).toBe(1);
872
-
873
- await scheduler.stop();
874
- await startPromise;
875
- });
876
-
877
- it("returns 0 after job completes", async () => {
878
- const scheduler = new Scheduler({
879
- stateDir: tempDir,
880
- checkInterval: 50,
881
- logger: mockLogger,
882
- onTrigger: async () => {
883
- await wait(20);
884
- },
885
- });
886
-
887
- const agent = createTestAgent("test-agent", {
888
- hourly: { type: "interval", interval: "10s" }, // Long interval to prevent re-trigger
889
- });
890
-
891
- const startPromise = scheduler.start([agent]);
892
-
893
- // Wait for trigger and completion
894
- await wait(150);
895
-
896
- // Count should be 0 after completion
897
- expect(scheduler.getRunningJobCount("test-agent")).toBe(0);
898
-
899
- await scheduler.stop();
900
- await startPromise;
901
- });
902
-
903
- it("tracks multiple schedules for the same agent", async () => {
904
- let maxCount = 0;
905
-
906
- const scheduler = new Scheduler({
907
- stateDir: tempDir,
908
- checkInterval: 20,
909
- logger: mockLogger,
910
- onTrigger: async (info) => {
911
- const count = scheduler.getRunningJobCount(info.agent.name);
912
- maxCount = Math.max(maxCount, count);
913
- await wait(100);
914
- },
915
- });
916
-
917
- // Agent with max_concurrent: 3 and multiple schedules
918
- const agent = {
919
- ...createTestAgent("test-agent", {
920
- schedule1: { type: "interval", interval: "1s" },
921
- schedule2: { type: "interval", interval: "1s" },
922
- schedule3: { type: "interval", interval: "1s" },
923
- }),
924
- instances: { max_concurrent: 3 },
925
- } as ResolvedAgent;
926
-
927
- const startPromise = scheduler.start([agent]);
928
-
929
- await wait(150);
930
-
931
- // Should have tracked multiple concurrent jobs
932
- expect(maxCount).toBeGreaterThanOrEqual(1);
933
-
934
- await scheduler.stop();
935
- await startPromise;
936
- });
937
-
938
- it("decrements count on job failure", async () => {
939
- let countAfterError = -1;
940
-
941
- const scheduler = new Scheduler({
942
- stateDir: tempDir,
943
- checkInterval: 50,
944
- logger: mockLogger,
945
- onTrigger: async () => {
946
- throw new Error("Job failed!");
947
- },
948
- });
949
-
950
- const agent = createTestAgent("test-agent", {
951
- hourly: { type: "interval", interval: "10s" },
952
- });
953
-
954
- const startPromise = scheduler.start([agent]);
955
-
956
- // Wait for trigger and error handling
957
- await wait(150);
958
-
959
- countAfterError = scheduler.getRunningJobCount("test-agent");
960
-
961
- // Count should be 0 even after error
962
- expect(countAfterError).toBe(0);
963
-
964
- await scheduler.stop();
965
- await startPromise;
966
- });
967
- });
968
-
969
- describe("graceful shutdown", () => {
970
- it("waits for running jobs to complete by default", async () => {
971
- let jobStarted = false;
972
- let jobCompleted = false;
973
- let resolveJob: () => void;
974
- const jobPromise = new Promise<void>((resolve) => {
975
- resolveJob = resolve;
976
- });
977
-
978
- const scheduler = new Scheduler({
979
- stateDir: tempDir,
980
- checkInterval: 30,
981
- logger: mockLogger,
982
- onTrigger: async () => {
983
- jobStarted = true;
984
- await jobPromise;
985
- jobCompleted = true;
986
- },
987
- });
988
-
989
- const agent = createTestAgent("test-agent", {
990
- hourly: { type: "interval", interval: "1s" },
991
- });
992
-
993
- const startPromise = scheduler.start([agent]);
994
- await wait(100);
995
-
996
- // Job should be running
997
- expect(jobStarted).toBe(true);
998
- expect(jobCompleted).toBe(false);
999
-
1000
- // Start shutdown (don't await yet)
1001
- const stopPromise = scheduler.stop();
1002
-
1003
- // Give stop a moment to set status
1004
- await wait(10);
1005
-
1006
- // Scheduler should be in "stopping" state, waiting for job
1007
- expect(scheduler.getStatus()).toBe("stopping");
1008
-
1009
- // Complete the job
1010
- resolveJob!();
1011
-
1012
- // Now stop should complete
1013
- await stopPromise;
1014
- await startPromise;
1015
-
1016
- expect(jobCompleted).toBe(true);
1017
- expect(scheduler.getStatus()).toBe("stopped");
1018
- expect(mockLogger.infos.some((m) => m.includes("All running jobs completed"))).toBe(true);
1019
- });
1020
-
1021
- it("does not wait for jobs when waitForJobs is false", async () => {
1022
- let jobStarted = false;
1023
- let jobCompleted = false;
1024
-
1025
- const scheduler = new Scheduler({
1026
- stateDir: tempDir,
1027
- checkInterval: 30,
1028
- logger: mockLogger,
1029
- onTrigger: async () => {
1030
- jobStarted = true;
1031
- await wait(500); // Long-running job
1032
- jobCompleted = true;
1033
- },
1034
- });
1035
-
1036
- const agent = createTestAgent("test-agent", {
1037
- hourly: { type: "interval", interval: "1s" },
1038
- });
1039
-
1040
- const startPromise = scheduler.start([agent]);
1041
- await wait(100);
1042
-
1043
- // Job should be running
1044
- expect(jobStarted).toBe(true);
1045
- expect(jobCompleted).toBe(false);
1046
-
1047
- // Stop without waiting for jobs
1048
- await scheduler.stop({ waitForJobs: false });
1049
-
1050
- // Scheduler should be stopped immediately, job may still be running
1051
- expect(scheduler.getStatus()).toBe("stopped");
1052
-
1053
- // Clean up the start promise
1054
- await startPromise;
1055
- });
1056
-
1057
- it("throws SchedulerShutdownError on timeout", async () => {
1058
- let resolveJob: () => void;
1059
- const jobBlocker = new Promise<void>((resolve) => {
1060
- resolveJob = resolve;
1061
- });
1062
-
1063
- const scheduler = new Scheduler({
1064
- stateDir: tempDir,
1065
- checkInterval: 30,
1066
- logger: mockLogger,
1067
- onTrigger: async () => {
1068
- // Job that blocks until we release it
1069
- await jobBlocker;
1070
- },
1071
- });
1072
-
1073
- const agent = createTestAgent("test-agent", {
1074
- hourly: { type: "interval", interval: "1s" },
1075
- });
1076
-
1077
- const startPromise = scheduler.start([agent]);
1078
- await wait(100);
1079
-
1080
- // Stop with a short timeout
1081
- let shutdownError: SchedulerShutdownError | null = null;
1082
- try {
1083
- await scheduler.stop({ timeout: 50 });
1084
- } catch (error) {
1085
- if (error instanceof SchedulerShutdownError) {
1086
- shutdownError = error;
1087
- }
1088
- }
1089
-
1090
- expect(shutdownError).not.toBeNull();
1091
- expect(shutdownError!.timedOut).toBe(true);
1092
- expect(shutdownError!.runningJobCount).toBe(1);
1093
- expect(shutdownError!.name).toBe("SchedulerShutdownError");
1094
- expect(mockLogger.errors.some((m) => m.includes("timed out"))).toBe(true);
1095
-
1096
- // Clean up - release the job so the test can complete cleanly
1097
- resolveJob!();
1098
- await startPromise;
1099
- });
1100
-
1101
- it("stops new triggers immediately when stop is called", async () => {
1102
- let triggerCount = 0;
1103
-
1104
- const scheduler = new Scheduler({
1105
- stateDir: tempDir,
1106
- checkInterval: 20,
1107
- logger: mockLogger,
1108
- onTrigger: async () => {
1109
- triggerCount++;
1110
- await wait(200); // Long-running job
1111
- },
1112
- });
1113
-
1114
- const agent = createTestAgent("test-agent", {
1115
- hourly: { type: "interval", interval: "1s" },
1116
- });
1117
-
1118
- const startPromise = scheduler.start([agent]);
1119
- await wait(50);
1120
-
1121
- // First trigger should start
1122
- const countAtStop = triggerCount;
1123
- expect(countAtStop).toBe(1);
1124
-
1125
- // Stop scheduler (don't wait for jobs to test that no new triggers happen)
1126
- await scheduler.stop({ waitForJobs: false });
1127
- await startPromise;
1128
-
1129
- // Wait a bit to see if any new triggers would have happened
1130
- await wait(100);
1131
-
1132
- // No additional triggers should have started after stop
1133
- expect(triggerCount).toBe(countAtStop);
1134
- });
1135
-
1136
- it("handles multiple concurrent running jobs during shutdown", async () => {
1137
- let runningCount = 0;
1138
- let maxRunningDuringShutdown = 0;
1139
- let resolveAll: () => void;
1140
- const allJobsCanComplete = new Promise<void>((resolve) => {
1141
- resolveAll = resolve;
1142
- });
1143
-
1144
- const scheduler = new Scheduler({
1145
- stateDir: tempDir,
1146
- checkInterval: 20,
1147
- logger: mockLogger,
1148
- onTrigger: async () => {
1149
- runningCount++;
1150
- maxRunningDuringShutdown = Math.max(maxRunningDuringShutdown, runningCount);
1151
- await allJobsCanComplete;
1152
- runningCount--;
1153
- },
1154
- });
1155
-
1156
- // Agent with multiple concurrent schedules
1157
- const agent = {
1158
- ...createTestAgent("test-agent", {
1159
- schedule1: { type: "interval", interval: "1s" },
1160
- schedule2: { type: "interval", interval: "1s" },
1161
- schedule3: { type: "interval", interval: "1s" },
1162
- }),
1163
- instances: { max_concurrent: 3 },
1164
- } as ResolvedAgent;
1165
-
1166
- const startPromise = scheduler.start([agent]);
1167
- await wait(100);
1168
-
1169
- // Multiple jobs should be running
1170
- expect(runningCount).toBeGreaterThanOrEqual(1);
1171
-
1172
- // Start shutdown
1173
- const stopPromise = scheduler.stop();
1174
-
1175
- await wait(10);
1176
- expect(scheduler.getStatus()).toBe("stopping");
1177
-
1178
- // Release all jobs
1179
- resolveAll!();
1180
-
1181
- await stopPromise;
1182
- await startPromise;
1183
-
1184
- expect(scheduler.getStatus()).toBe("stopped");
1185
- expect(runningCount).toBe(0);
1186
- });
1187
-
1188
- it("returns immediately when there are no running jobs", async () => {
1189
- const scheduler = new Scheduler({
1190
- stateDir: tempDir,
1191
- checkInterval: 100,
1192
- logger: mockLogger,
1193
- });
1194
-
1195
- // Start with no agents (no jobs will be triggered)
1196
- const startPromise = scheduler.start([]);
1197
- await wait(50);
1198
-
1199
- const stopStart = Date.now();
1200
- await scheduler.stop();
1201
- const stopDuration = Date.now() - stopStart;
1202
-
1203
- // Should complete quickly (not wait for timeout)
1204
- expect(stopDuration).toBeLessThan(100);
1205
-
1206
- await startPromise;
1207
- });
1208
-
1209
- it("updates fleet state on shutdown", async () => {
1210
- const scheduler = new Scheduler({
1211
- stateDir: tempDir,
1212
- checkInterval: 50,
1213
- logger: mockLogger,
1214
- onTrigger: async () => {
1215
- await wait(10);
1216
- },
1217
- });
1218
-
1219
- const agent = createTestAgent("test-agent", {
1220
- hourly: { type: "interval", interval: "1s" },
1221
- });
1222
-
1223
- const startPromise = scheduler.start([agent]);
1224
- await wait(150);
1225
-
1226
- // Trigger should have completed and updated state
1227
- const stateFile = join(tempDir, "state.yaml");
1228
- let fleetState = await readFleetState(stateFile);
1229
- const scheduleState = fleetState.agents["test-agent"]?.schedules?.hourly;
1230
-
1231
- expect(scheduleState?.status).toBe("idle");
1232
- expect(scheduleState?.last_run_at).not.toBeNull();
1233
-
1234
- await scheduler.stop();
1235
- await startPromise;
1236
-
1237
- // State should still be valid after shutdown
1238
- fleetState = await readFleetState(stateFile);
1239
- expect(fleetState.agents["test-agent"]?.schedules?.hourly?.status).toBe("idle");
1240
- });
1241
-
1242
- it("getTotalRunningJobCount returns correct count", async () => {
1243
- let resolveJob: () => void;
1244
- const jobPromise = new Promise<void>((resolve) => {
1245
- resolveJob = resolve;
1246
- });
1247
-
1248
- const scheduler = new Scheduler({
1249
- stateDir: tempDir,
1250
- checkInterval: 30,
1251
- logger: mockLogger,
1252
- onTrigger: async () => {
1253
- await jobPromise;
1254
- },
1255
- });
1256
-
1257
- // Initially should be 0
1258
- expect(scheduler.getTotalRunningJobCount()).toBe(0);
1259
-
1260
- const agent = createTestAgent("test-agent", {
1261
- hourly: { type: "interval", interval: "1s" },
1262
- });
1263
-
1264
- const startPromise = scheduler.start([agent]);
1265
- await wait(100);
1266
-
1267
- // Should have 1 running job
1268
- expect(scheduler.getTotalRunningJobCount()).toBe(1);
1269
-
1270
- resolveJob!();
1271
- await wait(50);
1272
-
1273
- // Should be back to 0
1274
- expect(scheduler.getTotalRunningJobCount()).toBe(0);
1275
-
1276
- await scheduler.stop();
1277
- await startPromise;
1278
- });
1279
- });
1280
- });