@herdctl/core 1.3.0 → 2.0.0

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 (181) hide show
  1. package/dist/config/__tests__/agent.test.js +12 -12
  2. package/dist/config/__tests__/agent.test.js.map +1 -1
  3. package/dist/config/__tests__/loader.test.js +201 -4
  4. package/dist/config/__tests__/loader.test.js.map +1 -1
  5. package/dist/config/__tests__/merge.test.js +29 -4
  6. package/dist/config/__tests__/merge.test.js.map +1 -1
  7. package/dist/config/__tests__/parser.test.js +13 -13
  8. package/dist/config/__tests__/parser.test.js.map +1 -1
  9. package/dist/config/__tests__/schema.test.js +10 -10
  10. package/dist/config/__tests__/schema.test.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.d.ts.map +1 -1
  13. package/dist/config/index.js +2 -2
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/config/loader.d.ts.map +1 -1
  16. package/dist/config/loader.js +71 -0
  17. package/dist/config/loader.js.map +1 -1
  18. package/dist/config/merge.d.ts +4 -1
  19. package/dist/config/merge.d.ts.map +1 -1
  20. package/dist/config/merge.js +16 -0
  21. package/dist/config/merge.js.map +1 -1
  22. package/dist/config/schema.d.ts +906 -89
  23. package/dist/config/schema.d.ts.map +1 -1
  24. package/dist/config/schema.js +109 -7
  25. package/dist/config/schema.js.map +1 -1
  26. package/dist/fleet-manager/__tests__/coverage.test.js +25 -24
  27. package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -1
  28. package/dist/fleet-manager/__tests__/discord-manager.test.js +9 -2
  29. package/dist/fleet-manager/__tests__/discord-manager.test.js.map +1 -1
  30. package/dist/fleet-manager/__tests__/integration.test.js +27 -0
  31. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
  32. package/dist/fleet-manager/__tests__/job-control.test.js +66 -0
  33. package/dist/fleet-manager/__tests__/job-control.test.js.map +1 -1
  34. package/dist/fleet-manager/__tests__/status-queries.test.js +12 -11
  35. package/dist/fleet-manager/__tests__/status-queries.test.js.map +1 -1
  36. package/dist/fleet-manager/config-reload.js +9 -9
  37. package/dist/fleet-manager/config-reload.js.map +1 -1
  38. package/dist/fleet-manager/discord-manager.d.ts.map +1 -1
  39. package/dist/fleet-manager/discord-manager.js +27 -4
  40. package/dist/fleet-manager/discord-manager.js.map +1 -1
  41. package/dist/fleet-manager/fleet-manager.d.ts +11 -0
  42. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  43. package/dist/fleet-manager/fleet-manager.js +27 -0
  44. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  45. package/dist/fleet-manager/job-control.d.ts +1 -1
  46. package/dist/fleet-manager/job-control.d.ts.map +1 -1
  47. package/dist/fleet-manager/job-control.js +36 -14
  48. package/dist/fleet-manager/job-control.js.map +1 -1
  49. package/dist/fleet-manager/schedule-executor.d.ts +1 -1
  50. package/dist/fleet-manager/schedule-executor.d.ts.map +1 -1
  51. package/dist/fleet-manager/schedule-executor.js +11 -14
  52. package/dist/fleet-manager/schedule-executor.js.map +1 -1
  53. package/dist/fleet-manager/status-queries.js +7 -7
  54. package/dist/fleet-manager/status-queries.js.map +1 -1
  55. package/dist/fleet-manager/types.d.ts +10 -2
  56. package/dist/fleet-manager/types.d.ts.map +1 -1
  57. package/dist/fleet-manager/working-directory-helper.d.ts +29 -0
  58. package/dist/fleet-manager/working-directory-helper.d.ts.map +1 -0
  59. package/dist/fleet-manager/working-directory-helper.js +36 -0
  60. package/dist/fleet-manager/working-directory-helper.js.map +1 -0
  61. package/dist/index.d.ts +2 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +4 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/runner/__tests__/job-executor.test.js +449 -118
  66. package/dist/runner/__tests__/job-executor.test.js.map +1 -1
  67. package/dist/runner/__tests__/sdk-adapter.test.js +147 -23
  68. package/dist/runner/__tests__/sdk-adapter.test.js.map +1 -1
  69. package/dist/runner/index.d.ts +2 -0
  70. package/dist/runner/index.d.ts.map +1 -1
  71. package/dist/runner/index.js +1 -0
  72. package/dist/runner/index.js.map +1 -1
  73. package/dist/runner/job-executor.d.ts +12 -8
  74. package/dist/runner/job-executor.d.ts.map +1 -1
  75. package/dist/runner/job-executor.js +257 -126
  76. package/dist/runner/job-executor.js.map +1 -1
  77. package/dist/runner/runtime/__tests__/cli-session-path.test.d.ts +2 -0
  78. package/dist/runner/runtime/__tests__/cli-session-path.test.d.ts.map +1 -0
  79. package/dist/runner/runtime/__tests__/cli-session-path.test.js +150 -0
  80. package/dist/runner/runtime/__tests__/cli-session-path.test.js.map +1 -0
  81. package/dist/runner/runtime/__tests__/docker-config.test.d.ts +2 -0
  82. package/dist/runner/runtime/__tests__/docker-config.test.d.ts.map +1 -0
  83. package/dist/runner/runtime/__tests__/docker-config.test.js +352 -0
  84. package/dist/runner/runtime/__tests__/docker-config.test.js.map +1 -0
  85. package/dist/runner/runtime/__tests__/docker-security.test.d.ts +2 -0
  86. package/dist/runner/runtime/__tests__/docker-security.test.d.ts.map +1 -0
  87. package/dist/runner/runtime/__tests__/docker-security.test.js +384 -0
  88. package/dist/runner/runtime/__tests__/docker-security.test.js.map +1 -0
  89. package/dist/runner/runtime/__tests__/factory.test.d.ts +2 -0
  90. package/dist/runner/runtime/__tests__/factory.test.d.ts.map +1 -0
  91. package/dist/runner/runtime/__tests__/factory.test.js +149 -0
  92. package/dist/runner/runtime/__tests__/factory.test.js.map +1 -0
  93. package/dist/runner/runtime/__tests__/integration.test.d.ts +2 -0
  94. package/dist/runner/runtime/__tests__/integration.test.d.ts.map +1 -0
  95. package/dist/runner/runtime/__tests__/integration.test.js +274 -0
  96. package/dist/runner/runtime/__tests__/integration.test.js.map +1 -0
  97. package/dist/runner/runtime/cli-runtime.d.ts +107 -0
  98. package/dist/runner/runtime/cli-runtime.d.ts.map +1 -0
  99. package/dist/runner/runtime/cli-runtime.js +335 -0
  100. package/dist/runner/runtime/cli-runtime.js.map +1 -0
  101. package/dist/runner/runtime/cli-session-path.d.ts +108 -0
  102. package/dist/runner/runtime/cli-session-path.d.ts.map +1 -0
  103. package/dist/runner/runtime/cli-session-path.js +173 -0
  104. package/dist/runner/runtime/cli-session-path.js.map +1 -0
  105. package/dist/runner/runtime/cli-session-watcher.d.ts +55 -0
  106. package/dist/runner/runtime/cli-session-watcher.d.ts.map +1 -0
  107. package/dist/runner/runtime/cli-session-watcher.js +187 -0
  108. package/dist/runner/runtime/cli-session-watcher.js.map +1 -0
  109. package/dist/runner/runtime/container-manager.d.ts +76 -0
  110. package/dist/runner/runtime/container-manager.d.ts.map +1 -0
  111. package/dist/runner/runtime/container-manager.js +229 -0
  112. package/dist/runner/runtime/container-manager.js.map +1 -0
  113. package/dist/runner/runtime/container-runner.d.ts +62 -0
  114. package/dist/runner/runtime/container-runner.d.ts.map +1 -0
  115. package/dist/runner/runtime/container-runner.js +235 -0
  116. package/dist/runner/runtime/container-runner.js.map +1 -0
  117. package/dist/runner/runtime/docker-config.d.ts +100 -0
  118. package/dist/runner/runtime/docker-config.d.ts.map +1 -0
  119. package/dist/runner/runtime/docker-config.js +98 -0
  120. package/dist/runner/runtime/docker-config.js.map +1 -0
  121. package/dist/runner/runtime/factory.d.ts +63 -0
  122. package/dist/runner/runtime/factory.d.ts.map +1 -0
  123. package/dist/runner/runtime/factory.js +68 -0
  124. package/dist/runner/runtime/factory.js.map +1 -0
  125. package/dist/runner/runtime/index.d.ts +20 -0
  126. package/dist/runner/runtime/index.d.ts.map +1 -0
  127. package/dist/runner/runtime/index.js +21 -0
  128. package/dist/runner/runtime/index.js.map +1 -0
  129. package/dist/runner/runtime/interface.d.ts +59 -0
  130. package/dist/runner/runtime/interface.d.ts.map +1 -0
  131. package/dist/runner/runtime/interface.js +12 -0
  132. package/dist/runner/runtime/interface.js.map +1 -0
  133. package/dist/runner/runtime/sdk-runtime.d.ts +46 -0
  134. package/dist/runner/runtime/sdk-runtime.d.ts.map +1 -0
  135. package/dist/runner/runtime/sdk-runtime.js +63 -0
  136. package/dist/runner/runtime/sdk-runtime.js.map +1 -0
  137. package/dist/runner/sdk-adapter.d.ts +4 -0
  138. package/dist/runner/sdk-adapter.d.ts.map +1 -1
  139. package/dist/runner/sdk-adapter.js +35 -16
  140. package/dist/runner/sdk-adapter.js.map +1 -1
  141. package/dist/runner/types.d.ts +12 -6
  142. package/dist/runner/types.d.ts.map +1 -1
  143. package/dist/scheduler/__tests__/schedule-runner.test.js +61 -50
  144. package/dist/scheduler/__tests__/schedule-runner.test.js.map +1 -1
  145. package/dist/scheduler/schedule-runner.d.ts +1 -4
  146. package/dist/scheduler/schedule-runner.d.ts.map +1 -1
  147. package/dist/scheduler/schedule-runner.js +40 -8
  148. package/dist/scheduler/schedule-runner.js.map +1 -1
  149. package/dist/state/__tests__/session-schema.test.js +4 -0
  150. package/dist/state/__tests__/session-schema.test.js.map +1 -1
  151. package/dist/state/__tests__/session-validation.test.d.ts +2 -0
  152. package/dist/state/__tests__/session-validation.test.d.ts.map +1 -0
  153. package/dist/state/__tests__/session-validation.test.js +446 -0
  154. package/dist/state/__tests__/session-validation.test.js.map +1 -0
  155. package/dist/state/__tests__/session.test.js +68 -0
  156. package/dist/state/__tests__/session.test.js.map +1 -1
  157. package/dist/state/__tests__/working-directory-validation.test.d.ts +5 -0
  158. package/dist/state/__tests__/working-directory-validation.test.d.ts.map +1 -0
  159. package/dist/state/__tests__/working-directory-validation.test.js +101 -0
  160. package/dist/state/__tests__/working-directory-validation.test.js.map +1 -0
  161. package/dist/state/index.d.ts +2 -0
  162. package/dist/state/index.d.ts.map +1 -1
  163. package/dist/state/index.js +4 -0
  164. package/dist/state/index.js.map +1 -1
  165. package/dist/state/schemas/session-info.d.ts +32 -0
  166. package/dist/state/schemas/session-info.d.ts.map +1 -1
  167. package/dist/state/schemas/session-info.js +22 -0
  168. package/dist/state/schemas/session-info.js.map +1 -1
  169. package/dist/state/session-validation.d.ts +202 -0
  170. package/dist/state/session-validation.d.ts.map +1 -0
  171. package/dist/state/session-validation.js +407 -0
  172. package/dist/state/session-validation.js.map +1 -0
  173. package/dist/state/session.d.ts +23 -3
  174. package/dist/state/session.d.ts.map +1 -1
  175. package/dist/state/session.js +41 -6
  176. package/dist/state/session.js.map +1 -1
  177. package/dist/state/working-directory-validation.d.ts +52 -0
  178. package/dist/state/working-directory-validation.d.ts.map +1 -0
  179. package/dist/state/working-directory-validation.js +81 -0
  180. package/dist/state/working-directory-validation.js.map +1 -0
  181. package/package.json +7 -2
@@ -3,7 +3,7 @@ import { mkdir, rm, realpath, readFile, stat } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { JobExecutor, executeJob } from "../job-executor.js";
6
- import { getJob, readJobOutputAll, initStateDirectory, getSessionInfo, } from "../../state/index.js";
6
+ import { getJob, readJobOutputAll, initStateDirectory, getSessionInfo, updateSessionInfo, } from "../../state/index.js";
7
7
  // =============================================================================
8
8
  // Test Helpers
9
9
  // =============================================================================
@@ -35,28 +35,34 @@ function createMockLogger() {
35
35
  },
36
36
  };
37
37
  }
38
- // Helper to create a mock SDK query function
39
- function createMockSDKQuery(messages) {
40
- return async function* mockQuery() {
38
+ // Helper to create mock RuntimeInterface
39
+ function createMockRuntime(handler) {
40
+ return {
41
+ execute: handler,
42
+ };
43
+ }
44
+ // Helper to create a mock runtime with predefined messages
45
+ function createMockRuntimeWithMessages(messages) {
46
+ return createMockRuntime(async function* () {
41
47
  for (const message of messages) {
42
48
  yield message;
43
49
  }
44
- };
50
+ });
45
51
  }
46
- // Helper to create a mock SDK query that yields messages with delays
47
- function createDelayedSDKQuery(messages, delayMs = 10) {
48
- return async function* mockQuery() {
52
+ // Helper to create a mock runtime that yields messages with delays
53
+ function createDelayedMockRuntime(messages, delayMs = 10) {
54
+ return createMockRuntime(async function* () {
49
55
  for (const message of messages) {
50
56
  await new Promise((resolve) => setTimeout(resolve, delayMs));
51
57
  yield message;
52
58
  }
53
- };
59
+ });
54
60
  }
55
- // Helper to create a mock SDK query that throws an error
56
- function createErrorSDKQuery(error) {
57
- return async function* mockQuery() {
61
+ // Helper to create a mock runtime that throws an error
62
+ function createErrorMockRuntime(error) {
63
+ return createMockRuntime(async function* () {
58
64
  throw error;
59
- };
65
+ });
60
66
  }
61
67
  // =============================================================================
62
68
  // JobExecutor tests
@@ -78,7 +84,7 @@ describe("JobExecutor", () => {
78
84
  { type: "system", content: "Initialized" },
79
85
  { type: "assistant", content: "Done" },
80
86
  ];
81
- const executor = new JobExecutor(createMockSDKQuery(messages), {
87
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
82
88
  logger: createMockLogger(),
83
89
  });
84
90
  const result = await executor.execute({
@@ -93,11 +99,11 @@ describe("JobExecutor", () => {
93
99
  });
94
100
  it("updates job status to running", async () => {
95
101
  let jobIdDuringExecution;
96
- const sdkQuery = async function* () {
102
+ const runtime = createMockRuntime(async function* (options) {
97
103
  // During execution, we can check the job status
98
104
  yield { type: "system", content: "Running" };
99
- };
100
- const executor = new JobExecutor(sdkQuery, {
105
+ });
106
+ const executor = new JobExecutor(runtime, {
101
107
  logger: createMockLogger(),
102
108
  });
103
109
  const result = await executor.execute({
@@ -114,7 +120,7 @@ describe("JobExecutor", () => {
114
120
  { type: "system", content: "Start" },
115
121
  { type: "assistant", content: "Task completed!" },
116
122
  ];
117
- const executor = new JobExecutor(createMockSDKQuery(messages), {
123
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
118
124
  logger: createMockLogger(),
119
125
  });
120
126
  const result = await executor.execute({
@@ -134,7 +140,7 @@ describe("JobExecutor", () => {
134
140
  { type: "system", content: "Start" },
135
141
  { type: "error", message: "Something went wrong" },
136
142
  ];
137
- const executor = new JobExecutor(createMockSDKQuery(messages), {
143
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
138
144
  logger: createMockLogger(),
139
145
  });
140
146
  const result = await executor.execute({
@@ -149,7 +155,7 @@ describe("JobExecutor", () => {
149
155
  expect(job?.exit_reason).toBe("error");
150
156
  });
151
157
  it("handles SDK query throwing an error", async () => {
152
- const executor = new JobExecutor(createErrorSDKQuery(new Error("SDK error")), { logger: createMockLogger() });
158
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("SDK error")), { logger: createMockLogger() });
153
159
  const result = await executor.execute({
154
160
  agent: createTestAgent(),
155
161
  prompt: "Test prompt",
@@ -170,7 +176,7 @@ describe("JobExecutor", () => {
170
176
  { type: "tool_result", result: "file1\nfile2", success: true },
171
177
  { type: "assistant", content: "Done" },
172
178
  ];
173
- const executor = new JobExecutor(createMockSDKQuery(messages), {
179
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
174
180
  logger: createMockLogger(),
175
181
  });
176
182
  const result = await executor.execute({
@@ -189,14 +195,14 @@ describe("JobExecutor", () => {
189
195
  it("writes output immediately without buffering", async () => {
190
196
  let outputCountDuringExecution = 0;
191
197
  const jobsDir = join(stateDir, "jobs");
192
- const sdkQuery = async function* () {
198
+ const runtime = createMockRuntime(async function* (options) {
193
199
  yield { type: "system", content: "First" };
194
200
  yield { type: "assistant", content: "Second" };
195
201
  yield { type: "assistant", content: "Third" };
196
- };
202
+ });
197
203
  // We can't easily verify real-time writing in a unit test,
198
204
  // but we can verify all messages are written
199
- const executor = new JobExecutor(sdkQuery, {
205
+ const executor = new JobExecutor(runtime, {
200
206
  logger: createMockLogger(),
201
207
  });
202
208
  const result = await executor.execute({
@@ -228,7 +234,7 @@ describe("JobExecutor", () => {
228
234
  success: true,
229
235
  },
230
236
  ];
231
- const executor = new JobExecutor(createMockSDKQuery(messages), {
237
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
232
238
  logger: createMockLogger(),
233
239
  });
234
240
  const result = await executor.execute({
@@ -266,7 +272,7 @@ describe("JobExecutor", () => {
266
272
  }
267
273
  });
268
274
  it("writes error message to output when SDK throws", async () => {
269
- const executor = new JobExecutor(createErrorSDKQuery(new Error("Connection failed")), { logger: createMockLogger() });
275
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("Connection failed")), { logger: createMockLogger() });
270
276
  const result = await executor.execute({
271
277
  agent: createTestAgent(),
272
278
  prompt: "Test prompt",
@@ -289,7 +295,7 @@ describe("JobExecutor", () => {
289
295
  { type: "tool_result", result: "output", success: true },
290
296
  { type: "error", message: "Error message", code: "ERR_TEST" },
291
297
  ];
292
- const executor = new JobExecutor(createMockSDKQuery(messages), {
298
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
293
299
  logger: createMockLogger(),
294
300
  });
295
301
  const result = await executor.execute({
@@ -310,7 +316,7 @@ describe("JobExecutor", () => {
310
316
  { type: "assistant", content: "Part 1... Part 2...", partial: true },
311
317
  { type: "assistant", content: "Part 1... Part 2... Done!", partial: false },
312
318
  ];
313
- const executor = new JobExecutor(createMockSDKQuery(messages), {
319
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
314
320
  logger: createMockLogger(),
315
321
  });
316
322
  const result = await executor.execute({
@@ -332,7 +338,7 @@ describe("JobExecutor", () => {
332
338
  { type: "tool_use", tool_name: "read_file", input: { path: "/nope" } },
333
339
  { type: "tool_result", success: false, error: "File not found" },
334
340
  ];
335
- const executor = new JobExecutor(createMockSDKQuery(messages), {
341
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
336
342
  logger: createMockLogger(),
337
343
  });
338
344
  const result = await executor.execute({
@@ -353,7 +359,7 @@ describe("JobExecutor", () => {
353
359
  { type: "system", content: "Init", subtype: "init", session_id: "session-abc123" },
354
360
  { type: "assistant", content: "Done" },
355
361
  ];
356
- const executor = new JobExecutor(createMockSDKQuery(messages), {
362
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
357
363
  logger: createMockLogger(),
358
364
  });
359
365
  const result = await executor.execute({
@@ -370,7 +376,7 @@ describe("JobExecutor", () => {
370
376
  { type: "system", content: "Progress", subtype: "progress", session_id: "should-ignore" },
371
377
  { type: "assistant", content: "Done" },
372
378
  ];
373
- const executor = new JobExecutor(createMockSDKQuery(messages), {
379
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
374
380
  logger: createMockLogger(),
375
381
  });
376
382
  const result = await executor.execute({
@@ -387,7 +393,7 @@ describe("JobExecutor", () => {
387
393
  { type: "system", content: "Init", subtype: "init", session_id: "session-persist-123" },
388
394
  { type: "assistant", content: "Done" },
389
395
  ];
390
- const executor = new JobExecutor(createMockSDKQuery(messages), {
396
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
391
397
  logger: createMockLogger(),
392
398
  });
393
399
  await executor.execute({
@@ -408,7 +414,7 @@ describe("JobExecutor", () => {
408
414
  { type: "system", content: "Init", subtype: "init", session_id: "session-multi-123" },
409
415
  { type: "assistant", content: "Done" },
410
416
  ];
411
- const executor = new JobExecutor(createMockSDKQuery(messages), {
417
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
412
418
  logger: createMockLogger(),
413
419
  });
414
420
  // Run twice
@@ -431,7 +437,7 @@ describe("JobExecutor", () => {
431
437
  { type: "system", content: "Init", subtype: "init", session_id: "session-ts-123" },
432
438
  { type: "assistant", content: "Done" },
433
439
  ];
434
- const executor = new JobExecutor(createMockSDKQuery(messages), {
440
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
435
441
  logger: createMockLogger(),
436
442
  });
437
443
  await executor.execute({
@@ -451,7 +457,7 @@ describe("JobExecutor", () => {
451
457
  { type: "system", content: "Init", subtype: "init", session_id: "session-result-123" },
452
458
  { type: "assistant", content: "Done" },
453
459
  ];
454
- const executor = new JobExecutor(createMockSDKQuery(messages), {
460
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
455
461
  logger: createMockLogger(),
456
462
  });
457
463
  const result = await executor.execute({
@@ -463,12 +469,18 @@ describe("JobExecutor", () => {
463
469
  expect(result.sessionId).toBe("session-result-123");
464
470
  });
465
471
  it("passes resume option to SDK", async () => {
472
+ // Create a valid session so resume isn't cleared
473
+ const sessionsDir = join(stateDir, "sessions");
474
+ await updateSessionInfo(sessionsDir, "test-agent", {
475
+ session_id: "session-to-resume",
476
+ mode: "autonomous",
477
+ });
466
478
  let receivedOptions;
467
- const sdkQuery = async function* (params) {
468
- receivedOptions = params.options;
479
+ const runtime = createMockRuntime(async function* (options) {
480
+ receivedOptions = options;
469
481
  yield { type: "assistant", content: "Resumed" };
470
- };
471
- const executor = new JobExecutor(sdkQuery, {
482
+ });
483
+ const executor = new JobExecutor(runtime, {
472
484
  logger: createMockLogger(),
473
485
  });
474
486
  await executor.execute({
@@ -481,11 +493,11 @@ describe("JobExecutor", () => {
481
493
  });
482
494
  it("passes fork option to SDK", async () => {
483
495
  let receivedOptions;
484
- const sdkQuery = async function* (params) {
485
- receivedOptions = params.options;
496
+ const runtime = createMockRuntime(async function* (options) {
497
+ receivedOptions = options;
486
498
  yield { type: "assistant", content: "Forked" };
487
- };
488
- const executor = new JobExecutor(sdkQuery, {
499
+ });
500
+ const executor = new JobExecutor(runtime, {
489
501
  logger: createMockLogger(),
490
502
  });
491
503
  await executor.execute({
@@ -494,14 +506,14 @@ describe("JobExecutor", () => {
494
506
  stateDir,
495
507
  fork: "session-to-fork",
496
508
  });
497
- expect(receivedOptions?.forkSession).toBe(true);
509
+ expect(receivedOptions?.fork).toBe(true);
498
510
  });
499
511
  it("creates job with trigger_type 'fork' and forked_from when forking", async () => {
500
512
  const messages = [
501
513
  { type: "system", content: "Init", subtype: "init", session_id: "forked-session-123" },
502
514
  { type: "assistant", content: "Forked session started" },
503
515
  ];
504
- const executor = new JobExecutor(createMockSDKQuery(messages), {
516
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
505
517
  logger: createMockLogger(),
506
518
  });
507
519
  const result = await executor.execute({
@@ -520,7 +532,7 @@ describe("JobExecutor", () => {
520
532
  const messages = [
521
533
  { type: "assistant", content: "Forked" },
522
534
  ];
523
- const executor = new JobExecutor(createMockSDKQuery(messages), {
535
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
524
536
  logger: createMockLogger(),
525
537
  });
526
538
  const result = await executor.execute({
@@ -537,7 +549,7 @@ describe("JobExecutor", () => {
537
549
  const messages = [
538
550
  { type: "assistant", content: "Normal run" },
539
551
  ];
540
- const executor = new JobExecutor(createMockSDKQuery(messages), {
552
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
541
553
  logger: createMockLogger(),
542
554
  });
543
555
  const result = await executor.execute({
@@ -554,7 +566,7 @@ describe("JobExecutor", () => {
554
566
  { type: "system", content: "Init", subtype: "init", session_id: "resume-session-123" },
555
567
  { type: "assistant", content: "Resumed" },
556
568
  ];
557
- const executor = new JobExecutor(createMockSDKQuery(messages), {
569
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
558
570
  logger: createMockLogger(),
559
571
  });
560
572
  // First run to establish the session
@@ -582,7 +594,7 @@ describe("JobExecutor", () => {
582
594
  { type: "system", content: "Init", subtype: "init", session_id: "fork-session-123" },
583
595
  { type: "assistant", content: "Forked" },
584
596
  ];
585
- const executor = new JobExecutor(createMockSDKQuery(messages), {
597
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
586
598
  logger: createMockLogger(),
587
599
  });
588
600
  // First run to establish a session
@@ -611,7 +623,7 @@ describe("JobExecutor", () => {
611
623
  { type: "system", content: "No session" },
612
624
  { type: "assistant", content: "Done" },
613
625
  ];
614
- const executor = new JobExecutor(createMockSDKQuery(messages), {
626
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
615
627
  logger: createMockLogger(),
616
628
  });
617
629
  await executor.execute({
@@ -630,7 +642,7 @@ describe("JobExecutor", () => {
630
642
  { type: "assistant", content: "Working..." },
631
643
  { type: "assistant", content: "Done!", summary: "Task completed" },
632
644
  ];
633
- const executor = new JobExecutor(createMockSDKQuery(messages), {
645
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
634
646
  logger: createMockLogger(),
635
647
  });
636
648
  const result = await executor.execute({
@@ -647,7 +659,7 @@ describe("JobExecutor", () => {
647
659
  { type: "assistant", content: "Working..." },
648
660
  { type: "assistant", content: "All tasks finished successfully." },
649
661
  ];
650
- const executor = new JobExecutor(createMockSDKQuery(messages), {
662
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
651
663
  logger: createMockLogger(),
652
664
  });
653
665
  const result = await executor.execute({
@@ -663,7 +675,7 @@ describe("JobExecutor", () => {
663
675
  { type: "tool_use", tool_name: "bash", input: { command: "ls" } },
664
676
  { type: "tool_result", result: "file1\nfile2", success: true },
665
677
  ];
666
- const executor = new JobExecutor(createMockSDKQuery(messages), {
678
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
667
679
  logger: createMockLogger(),
668
680
  });
669
681
  const result = await executor.execute({
@@ -680,7 +692,7 @@ describe("JobExecutor", () => {
680
692
  { type: "assistant", content: "Partial 1...", partial: true },
681
693
  { type: "assistant", content: "Partial 2...", partial: true },
682
694
  ];
683
- const executor = new JobExecutor(createMockSDKQuery(messages), {
695
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
684
696
  logger: createMockLogger(),
685
697
  });
686
698
  const result = await executor.execute({
@@ -696,7 +708,7 @@ describe("JobExecutor", () => {
696
708
  { type: "assistant", content: longContent },
697
709
  { type: "assistant", content: longContent },
698
710
  ];
699
- const executor = new JobExecutor(createMockSDKQuery(messages), {
711
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
700
712
  logger: createMockLogger(),
701
713
  });
702
714
  const result = await executor.execute({
@@ -711,7 +723,7 @@ describe("JobExecutor", () => {
711
723
  const messages = [
712
724
  { type: "assistant", content: "Done!", summary: longSummary },
713
725
  ];
714
- const executor = new JobExecutor(createMockSDKQuery(messages), {
726
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
715
727
  logger: createMockLogger(),
716
728
  });
717
729
  const result = await executor.execute({
@@ -731,7 +743,7 @@ describe("JobExecutor", () => {
731
743
  { type: "assistant", content: "Second", summary: "Second summary" },
732
744
  { type: "assistant", content: "Third", summary: "Final summary" },
733
745
  ];
734
- const executor = new JobExecutor(createMockSDKQuery(messages), {
746
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
735
747
  logger: createMockLogger(),
736
748
  });
737
749
  const result = await executor.execute({
@@ -743,7 +755,7 @@ describe("JobExecutor", () => {
743
755
  });
744
756
  it("handles empty message stream with undefined summary", async () => {
745
757
  const messages = [];
746
- const executor = new JobExecutor(createMockSDKQuery(messages), {
758
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
747
759
  logger: createMockLogger(),
748
760
  });
749
761
  const result = await executor.execute({
@@ -765,7 +777,7 @@ describe("JobExecutor", () => {
765
777
  const onMessage = vi.fn((msg) => {
766
778
  receivedMessages.push(msg);
767
779
  });
768
- const executor = new JobExecutor(createMockSDKQuery(messages), {
780
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
769
781
  logger: createMockLogger(),
770
782
  });
771
783
  await executor.execute({
@@ -788,7 +800,7 @@ describe("JobExecutor", () => {
788
800
  const onMessage = vi.fn(() => {
789
801
  throw new Error("Callback error");
790
802
  });
791
- const executor = new JobExecutor(createMockSDKQuery(messages), {
803
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
792
804
  logger: createMockLogger(),
793
805
  });
794
806
  const result = await executor.execute({
@@ -808,7 +820,7 @@ describe("JobExecutor", () => {
808
820
  const onMessage = vi.fn(async () => {
809
821
  await new Promise((resolve) => setTimeout(resolve, 10));
810
822
  });
811
- const executor = new JobExecutor(createMockSDKQuery(messages), {
823
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
812
824
  logger: createMockLogger(),
813
825
  });
814
826
  await executor.execute({
@@ -823,7 +835,7 @@ describe("JobExecutor", () => {
823
835
  describe("trigger types", () => {
824
836
  it("sets trigger type to manual by default", async () => {
825
837
  const messages = [{ type: "assistant", content: "Done" }];
826
- const executor = new JobExecutor(createMockSDKQuery(messages), {
838
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
827
839
  logger: createMockLogger(),
828
840
  });
829
841
  const result = await executor.execute({
@@ -836,7 +848,7 @@ describe("JobExecutor", () => {
836
848
  });
837
849
  it("sets trigger type from options", async () => {
838
850
  const messages = [{ type: "assistant", content: "Done" }];
839
- const executor = new JobExecutor(createMockSDKQuery(messages), {
851
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
840
852
  logger: createMockLogger(),
841
853
  });
842
854
  const result = await executor.execute({
@@ -857,7 +869,7 @@ describe("JobExecutor", () => {
857
869
  { type: "system", content: "Start" },
858
870
  { type: "assistant", content: "End" },
859
871
  ];
860
- const executor = new JobExecutor(createDelayedSDKQuery(messages, 50), { logger: createMockLogger() });
872
+ const executor = new JobExecutor(createDelayedMockRuntime(messages, 50), { logger: createMockLogger() });
861
873
  const result = await executor.execute({
862
874
  agent: createTestAgent(),
863
875
  prompt: "Test prompt",
@@ -888,7 +900,7 @@ describe("executeJob", () => {
888
900
  { type: "system", content: "Init" },
889
901
  { type: "assistant", content: "Done" },
890
902
  ];
891
- const result = await executeJob(createMockSDKQuery(messages), {
903
+ const result = await executeJob(createMockRuntimeWithMessages(messages), {
892
904
  agent: createTestAgent({ name: "convenience-agent" }),
893
905
  prompt: "Test prompt",
894
906
  stateDir,
@@ -901,7 +913,7 @@ describe("executeJob", () => {
901
913
  it("passes executor options", async () => {
902
914
  const messages = [{ type: "assistant", content: "Done" }];
903
915
  const logger = createMockLogger();
904
- await executeJob(createMockSDKQuery(messages), {
916
+ await executeJob(createMockRuntimeWithMessages(messages), {
905
917
  agent: createTestAgent(),
906
918
  prompt: "Test prompt",
907
919
  stateDir,
@@ -924,7 +936,7 @@ describe("edge cases", () => {
924
936
  await rm(tempDir, { recursive: true, force: true });
925
937
  });
926
938
  it("handles empty message stream", async () => {
927
- const executor = new JobExecutor(createMockSDKQuery([]), {
939
+ const executor = new JobExecutor(createMockRuntimeWithMessages([]), {
928
940
  logger: createMockLogger(),
929
941
  });
930
942
  const result = await executor.execute({
@@ -941,7 +953,7 @@ describe("edge cases", () => {
941
953
  const messages = [
942
954
  { type: "assistant", content: longContent },
943
955
  ];
944
- const executor = new JobExecutor(createMockSDKQuery(messages), {
956
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
945
957
  logger: createMockLogger(),
946
958
  });
947
959
  const result = await executor.execute({
@@ -958,7 +970,7 @@ describe("edge cases", () => {
958
970
  const messages = [
959
971
  { type: "assistant", content: "Hello 世界! 🌍 Γεια σου κόσμε" },
960
972
  ];
961
- const executor = new JobExecutor(createMockSDKQuery(messages), {
973
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
962
974
  logger: createMockLogger(),
963
975
  });
964
976
  const result = await executor.execute({
@@ -978,7 +990,7 @@ describe("edge cases", () => {
978
990
  content: 'Content with "quotes", \\backslashes\\, and\nnewlines',
979
991
  },
980
992
  ];
981
- const executor = new JobExecutor(createMockSDKQuery(messages), {
993
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
982
994
  logger: createMockLogger(),
983
995
  });
984
996
  const result = await executor.execute({
@@ -998,7 +1010,7 @@ describe("edge cases", () => {
998
1010
  type: "assistant",
999
1011
  content: `Message ${i}`,
1000
1012
  }));
1001
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1013
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1002
1014
  logger: createMockLogger(),
1003
1015
  });
1004
1016
  const result = await executor.execute({
@@ -1026,11 +1038,11 @@ describe("error handling (US-7)", () => {
1026
1038
  });
1027
1039
  describe("SDK initialization errors", () => {
1028
1040
  it("catches SDK initialization errors (e.g., missing API key)", async () => {
1029
- // Simulates SDK throwing immediately when query is created
1030
- const sdkQuery = () => {
1041
+ // Simulates SDK throwing immediately when execute is called
1042
+ const runtime = createMockRuntime(() => {
1031
1043
  throw new Error("ANTHROPIC_API_KEY environment variable is not set");
1032
- };
1033
- const executor = new JobExecutor(sdkQuery, {
1044
+ });
1045
+ const executor = new JobExecutor(runtime, {
1034
1046
  logger: createMockLogger(),
1035
1047
  });
1036
1048
  const result = await executor.execute({
@@ -1043,10 +1055,10 @@ describe("error handling (US-7)", () => {
1043
1055
  expect(result.errorDetails?.type).toBe("initialization");
1044
1056
  });
1045
1057
  it("provides context (job ID, agent name) in initialization error", async () => {
1046
- const sdkQuery = () => {
1058
+ const runtime = createMockRuntime(() => {
1047
1059
  throw new Error("SDK init failed");
1048
- };
1049
- const executor = new JobExecutor(sdkQuery, {
1060
+ });
1061
+ const executor = new JobExecutor(runtime, {
1050
1062
  logger: createMockLogger(),
1051
1063
  });
1052
1064
  const result = await executor.execute({
@@ -1060,12 +1072,12 @@ describe("error handling (US-7)", () => {
1060
1072
  });
1061
1073
  describe("SDK streaming errors", () => {
1062
1074
  it("catches SDK streaming errors during execution", async () => {
1063
- const sdkQuery = async function* () {
1075
+ const runtime = createMockRuntime(async function* (options) {
1064
1076
  yield { type: "system", content: "Init" };
1065
1077
  yield { type: "assistant", content: "Working..." };
1066
1078
  throw new Error("Connection reset by peer");
1067
- };
1068
- const executor = new JobExecutor(sdkQuery, {
1079
+ });
1080
+ const executor = new JobExecutor(runtime, {
1069
1081
  logger: createMockLogger(),
1070
1082
  });
1071
1083
  const result = await executor.execute({
@@ -1078,13 +1090,13 @@ describe("error handling (US-7)", () => {
1078
1090
  expect(result.errorDetails?.type).toBe("streaming");
1079
1091
  });
1080
1092
  it("tracks messages received before streaming error", async () => {
1081
- const sdkQuery = async function* () {
1093
+ const runtime = createMockRuntime(async function* (options) {
1082
1094
  yield { type: "system", content: "Init" };
1083
1095
  yield { type: "assistant", content: "Message 1" };
1084
1096
  yield { type: "assistant", content: "Message 2" };
1085
1097
  throw new Error("Stream interrupted");
1086
- };
1087
- const executor = new JobExecutor(sdkQuery, {
1098
+ });
1099
+ const executor = new JobExecutor(runtime, {
1088
1100
  logger: createMockLogger(),
1089
1101
  });
1090
1102
  const result = await executor.execute({
@@ -1096,11 +1108,11 @@ describe("error handling (US-7)", () => {
1096
1108
  expect(result.errorDetails?.messagesReceived).toBe(3);
1097
1109
  });
1098
1110
  it("identifies recoverable errors (rate limit)", async () => {
1099
- const sdkQuery = async function* () {
1111
+ const runtime = createMockRuntime(async function* (options) {
1100
1112
  yield { type: "system", content: "Init" };
1101
1113
  throw new Error("Rate limit exceeded, please retry");
1102
- };
1103
- const executor = new JobExecutor(sdkQuery, {
1114
+ });
1115
+ const executor = new JobExecutor(runtime, {
1104
1116
  logger: createMockLogger(),
1105
1117
  });
1106
1118
  const result = await executor.execute({
@@ -1111,11 +1123,11 @@ describe("error handling (US-7)", () => {
1111
1123
  expect(result.errorDetails?.recoverable).toBe(true);
1112
1124
  });
1113
1125
  it("identifies non-recoverable errors", async () => {
1114
- const sdkQuery = async function* () {
1126
+ const runtime = createMockRuntime(async function* (options) {
1115
1127
  yield { type: "system", content: "Init" };
1116
1128
  throw new Error("Invalid request format");
1117
- };
1118
- const executor = new JobExecutor(sdkQuery, {
1129
+ });
1130
+ const executor = new JobExecutor(runtime, {
1119
1131
  logger: createMockLogger(),
1120
1132
  });
1121
1133
  const result = await executor.execute({
@@ -1128,7 +1140,7 @@ describe("error handling (US-7)", () => {
1128
1140
  });
1129
1141
  describe("error logging to job output", () => {
1130
1142
  it("logs error messages to job output as error type messages", async () => {
1131
- const executor = new JobExecutor(createErrorSDKQuery(new Error("Test error for logging")), { logger: createMockLogger() });
1143
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("Test error for logging")), { logger: createMockLogger() });
1132
1144
  const result = await executor.execute({
1133
1145
  agent: createTestAgent(),
1134
1146
  prompt: "Test prompt",
@@ -1145,7 +1157,7 @@ describe("error handling (US-7)", () => {
1145
1157
  it("includes error code in job output when available", async () => {
1146
1158
  const errorWithCode = new Error("Network error");
1147
1159
  errorWithCode.code = "ECONNRESET";
1148
- const executor = new JobExecutor(createErrorSDKQuery(errorWithCode), {
1160
+ const executor = new JobExecutor(createErrorMockRuntime(errorWithCode), {
1149
1161
  logger: createMockLogger(),
1150
1162
  });
1151
1163
  const result = await executor.execute({
@@ -1161,7 +1173,7 @@ describe("error handling (US-7)", () => {
1161
1173
  });
1162
1174
  it("includes stack trace in job output", async () => {
1163
1175
  const error = new Error("Stack trace test");
1164
- const executor = new JobExecutor(createErrorSDKQuery(error), {
1176
+ const executor = new JobExecutor(createErrorMockRuntime(error), {
1165
1177
  logger: createMockLogger(),
1166
1178
  });
1167
1179
  const result = await executor.execute({
@@ -1179,7 +1191,7 @@ describe("error handling (US-7)", () => {
1179
1191
  });
1180
1192
  describe("job status updates", () => {
1181
1193
  it("updates job status to failed with error exit_reason", async () => {
1182
- const executor = new JobExecutor(createErrorSDKQuery(new Error("Failure")), { logger: createMockLogger() });
1194
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("Failure")), { logger: createMockLogger() });
1183
1195
  const result = await executor.execute({
1184
1196
  agent: createTestAgent(),
1185
1197
  prompt: "Test prompt",
@@ -1191,7 +1203,7 @@ describe("error handling (US-7)", () => {
1191
1203
  });
1192
1204
  it("sets exit_reason to timeout for timeout errors", async () => {
1193
1205
  const timeoutError = new Error("Request timed out");
1194
- const executor = new JobExecutor(createErrorSDKQuery(timeoutError), {
1206
+ const executor = new JobExecutor(createErrorMockRuntime(timeoutError), {
1195
1207
  logger: createMockLogger(),
1196
1208
  });
1197
1209
  const result = await executor.execute({
@@ -1204,7 +1216,7 @@ describe("error handling (US-7)", () => {
1204
1216
  });
1205
1217
  it("sets exit_reason to cancelled for abort errors", async () => {
1206
1218
  const abortError = new Error("Operation aborted by user");
1207
- const executor = new JobExecutor(createErrorSDKQuery(abortError), {
1219
+ const executor = new JobExecutor(createErrorMockRuntime(abortError), {
1208
1220
  logger: createMockLogger(),
1209
1221
  });
1210
1222
  const result = await executor.execute({
@@ -1217,7 +1229,7 @@ describe("error handling (US-7)", () => {
1217
1229
  });
1218
1230
  it("sets exit_reason to max_turns for turn limit errors", async () => {
1219
1231
  const maxTurnsError = new Error("Maximum turns exceeded");
1220
- const executor = new JobExecutor(createErrorSDKQuery(maxTurnsError), {
1232
+ const executor = new JobExecutor(createErrorMockRuntime(maxTurnsError), {
1221
1233
  logger: createMockLogger(),
1222
1234
  });
1223
1235
  const result = await executor.execute({
@@ -1231,7 +1243,7 @@ describe("error handling (US-7)", () => {
1231
1243
  });
1232
1244
  describe("error details in RunnerResult", () => {
1233
1245
  it("provides descriptive error message with context", async () => {
1234
- const executor = new JobExecutor(createErrorSDKQuery(new Error("API connection failed")), { logger: createMockLogger() });
1246
+ const executor = new JobExecutor(createErrorMockRuntime(new Error("API connection failed")), { logger: createMockLogger() });
1235
1247
  const result = await executor.execute({
1236
1248
  agent: createTestAgent({ name: "descriptive-agent" }),
1237
1249
  prompt: "Test prompt",
@@ -1245,7 +1257,7 @@ describe("error handling (US-7)", () => {
1245
1257
  it("returns error details in RunnerResult", async () => {
1246
1258
  const errorWithCode = new Error("Network timeout");
1247
1259
  errorWithCode.code = "ETIMEDOUT";
1248
- const executor = new JobExecutor(createErrorSDKQuery(errorWithCode), {
1260
+ const executor = new JobExecutor(createErrorMockRuntime(errorWithCode), {
1249
1261
  logger: createMockLogger(),
1250
1262
  });
1251
1263
  const result = await executor.execute({
@@ -1261,13 +1273,13 @@ describe("error handling (US-7)", () => {
1261
1273
  });
1262
1274
  describe("malformed SDK responses", () => {
1263
1275
  it("does not crash on malformed SDK messages", async () => {
1264
- const sdkQuery = async function* () {
1276
+ const runtime = createMockRuntime(async function* (options) {
1265
1277
  yield { type: "system", content: "Init" };
1266
1278
  // Yield a malformed message (null)
1267
1279
  yield null;
1268
1280
  yield { type: "assistant", content: "Continuing after malformed" };
1269
- };
1270
- const executor = new JobExecutor(sdkQuery, {
1281
+ });
1282
+ const executor = new JobExecutor(runtime, {
1271
1283
  logger: createMockLogger(),
1272
1284
  });
1273
1285
  const result = await executor.execute({
@@ -1285,12 +1297,12 @@ describe("error handling (US-7)", () => {
1285
1297
  expect(malformedMsg).toBeDefined();
1286
1298
  });
1287
1299
  it("handles messages with missing type field", async () => {
1288
- const sdkQuery = async function* () {
1300
+ const runtime = createMockRuntime(async function* (options) {
1289
1301
  yield { type: "system", content: "Init" };
1290
1302
  yield { content: "Missing type" };
1291
1303
  yield { type: "assistant", content: "Done" };
1292
- };
1293
- const executor = new JobExecutor(sdkQuery, {
1304
+ });
1305
+ const executor = new JobExecutor(runtime, {
1294
1306
  logger: createMockLogger(),
1295
1307
  });
1296
1308
  const result = await executor.execute({
@@ -1305,12 +1317,12 @@ describe("error handling (US-7)", () => {
1305
1317
  expect(unknownTypeMsg).toBeDefined();
1306
1318
  });
1307
1319
  it("handles messages with unexpected type values", async () => {
1308
- const sdkQuery = async function* () {
1320
+ const runtime = createMockRuntime(async function* (options) {
1309
1321
  yield { type: "system", content: "Init" };
1310
1322
  yield { type: "unexpected_type", content: "Unknown" };
1311
1323
  yield { type: "assistant", content: "Done" };
1312
- };
1313
- const executor = new JobExecutor(sdkQuery, {
1324
+ });
1325
+ const executor = new JobExecutor(runtime, {
1314
1326
  logger: createMockLogger(),
1315
1327
  });
1316
1328
  const result = await executor.execute({
@@ -1328,7 +1340,7 @@ describe("error handling (US-7)", () => {
1328
1340
  { type: "system", content: "Start" },
1329
1341
  { type: "error", message: "SDK reported error", code: "SDK_ERR" },
1330
1342
  ];
1331
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1343
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1332
1344
  logger: createMockLogger(),
1333
1345
  });
1334
1346
  const result = await executor.execute({
@@ -1361,7 +1373,7 @@ describe("outputToFile (US-9)", () => {
1361
1373
  { type: "system", content: "Init" },
1362
1374
  { type: "assistant", content: "Hello" },
1363
1375
  ];
1364
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1376
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1365
1377
  logger: createMockLogger(),
1366
1378
  });
1367
1379
  const result = await executor.execute({
@@ -1380,7 +1392,7 @@ describe("outputToFile (US-9)", () => {
1380
1392
  { type: "system", content: "Init" },
1381
1393
  { type: "assistant", content: "Hello world" },
1382
1394
  ];
1383
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1395
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1384
1396
  logger: createMockLogger(),
1385
1397
  });
1386
1398
  const result = await executor.execute({
@@ -1400,7 +1412,7 @@ describe("outputToFile (US-9)", () => {
1400
1412
  { type: "system", content: "Init" },
1401
1413
  { type: "assistant", content: "Hello" },
1402
1414
  ];
1403
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1415
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1404
1416
  logger: createMockLogger(),
1405
1417
  });
1406
1418
  const result = await executor.execute({
@@ -1424,7 +1436,7 @@ describe("outputToFile (US-9)", () => {
1424
1436
  { type: "system", content: "Init" },
1425
1437
  { type: "assistant", content: "Hello" },
1426
1438
  ];
1427
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1439
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1428
1440
  logger: createMockLogger(),
1429
1441
  });
1430
1442
  const result = await executor.execute({
@@ -1451,7 +1463,7 @@ describe("outputToFile (US-9)", () => {
1451
1463
  { type: "tool_result", result: "localhost", success: true },
1452
1464
  { type: "error", message: "An error occurred" },
1453
1465
  ];
1454
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1466
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1455
1467
  logger: createMockLogger(),
1456
1468
  });
1457
1469
  const result = await executor.execute({
@@ -1472,7 +1484,7 @@ describe("outputToFile (US-9)", () => {
1472
1484
  const messages = [
1473
1485
  { type: "assistant", content: "Hello" },
1474
1486
  ];
1475
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1487
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1476
1488
  logger: createMockLogger(),
1477
1489
  });
1478
1490
  const result = await executor.execute({
@@ -1491,7 +1503,7 @@ describe("outputToFile (US-9)", () => {
1491
1503
  { type: "system", content: "Init" },
1492
1504
  { type: "assistant", content: "Response" },
1493
1505
  ];
1494
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1506
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1495
1507
  logger: createMockLogger(),
1496
1508
  });
1497
1509
  const result = await executor.execute({
@@ -1510,7 +1522,7 @@ describe("outputToFile (US-9)", () => {
1510
1522
  const messages = [
1511
1523
  { type: "tool_result", result: "File not found", success: false },
1512
1524
  ];
1513
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1525
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1514
1526
  logger: createMockLogger(),
1515
1527
  });
1516
1528
  const result = await executor.execute({
@@ -1532,7 +1544,7 @@ describe("outputToFile (US-9)", () => {
1532
1544
  { type: "system", content: "Init" },
1533
1545
  { type: "assistant", content: "Hello" },
1534
1546
  ];
1535
- const executor = new JobExecutor(createMockSDKQuery(messages), {
1547
+ const executor = new JobExecutor(createMockRuntimeWithMessages(messages), {
1536
1548
  logger: createMockLogger(),
1537
1549
  });
1538
1550
  await executor.execute({
@@ -1547,4 +1559,323 @@ describe("outputToFile (US-9)", () => {
1547
1559
  expect(receivedMessages).toHaveLength(2);
1548
1560
  });
1549
1561
  });
1562
+ // =============================================================================
1563
+ // Session expiration handling tests (fixes unexpected logout bug)
1564
+ // =============================================================================
1565
+ describe("session expiration handling", () => {
1566
+ let tempDir;
1567
+ let stateDir;
1568
+ beforeEach(async () => {
1569
+ tempDir = await createTempDir();
1570
+ stateDir = join(tempDir, ".herdctl");
1571
+ await initStateDirectory({ path: stateDir });
1572
+ });
1573
+ afterEach(async () => {
1574
+ await rm(tempDir, { recursive: true, force: true });
1575
+ });
1576
+ it("starts fresh session when existing session is expired", async () => {
1577
+ const sessionsDir = join(stateDir, "sessions");
1578
+ // Create an expired session (last used 25 hours ago, default timeout is 24h)
1579
+ const expiredLastUsed = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
1580
+ await updateSessionInfo(sessionsDir, "expiry-test-agent", {
1581
+ session_id: "old-expired-session",
1582
+ mode: "autonomous",
1583
+ });
1584
+ // Manually update last_used_at to make it expired
1585
+ const { writeFile } = await import("node:fs/promises");
1586
+ const sessionPath = join(sessionsDir, "expiry-test-agent.json");
1587
+ const sessionData = {
1588
+ agent_name: "expiry-test-agent",
1589
+ session_id: "old-expired-session",
1590
+ created_at: expiredLastUsed,
1591
+ last_used_at: expiredLastUsed,
1592
+ job_count: 1,
1593
+ mode: "autonomous",
1594
+ };
1595
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1596
+ let receivedOptions;
1597
+ const runtime = createMockRuntime(async function* (options) {
1598
+ receivedOptions = options;
1599
+ yield { type: "system", content: "Init", subtype: "init", session_id: "new-fresh-session" };
1600
+ yield { type: "assistant", content: "Started fresh" };
1601
+ });
1602
+ const executor = new JobExecutor(runtime, {
1603
+ logger: createMockLogger(),
1604
+ });
1605
+ const result = await executor.execute({
1606
+ agent: createTestAgent({ name: "expiry-test-agent" }),
1607
+ prompt: "Test prompt",
1608
+ stateDir,
1609
+ resume: "old-expired-session", // Try to resume expired session
1610
+ });
1611
+ expect(result.success).toBe(true);
1612
+ // Should NOT have passed resume option since session was expired
1613
+ expect(receivedOptions?.resume).toBeUndefined();
1614
+ // New session should be created
1615
+ expect(result.sessionId).toBe("new-fresh-session");
1616
+ });
1617
+ it("resumes valid session that is not expired", async () => {
1618
+ const sessionsDir = join(stateDir, "sessions");
1619
+ // Create a valid session (last used 1 hour ago, default timeout is 24h)
1620
+ await updateSessionInfo(sessionsDir, "valid-session-agent", {
1621
+ session_id: "valid-session-id",
1622
+ mode: "autonomous",
1623
+ });
1624
+ let receivedOptions;
1625
+ const runtime = createMockRuntime(async function* (options) {
1626
+ receivedOptions = options;
1627
+ yield { type: "assistant", content: "Resumed successfully" };
1628
+ });
1629
+ const executor = new JobExecutor(runtime, {
1630
+ logger: createMockLogger(),
1631
+ });
1632
+ await executor.execute({
1633
+ agent: createTestAgent({ name: "valid-session-agent" }),
1634
+ prompt: "Test prompt",
1635
+ stateDir,
1636
+ resume: "valid-session-id",
1637
+ });
1638
+ // Should have passed resume option since session is valid
1639
+ expect(receivedOptions?.resume).toBe("valid-session-id");
1640
+ });
1641
+ it("uses stored session_id from disk, not the options.resume value", async () => {
1642
+ const sessionsDir = join(stateDir, "sessions");
1643
+ // Create a valid session with a DIFFERENT session_id than what options.resume will provide
1644
+ // This simulates the case where the stored session has been updated but the caller
1645
+ // is using an outdated or generic resume value (like a boolean or old session ID)
1646
+ await updateSessionInfo(sessionsDir, "session-mismatch-agent", {
1647
+ session_id: "stored-session-abc123", // The actual session ID stored on disk
1648
+ mode: "autonomous",
1649
+ });
1650
+ let receivedOptions;
1651
+ const runtime = createMockRuntime(async function* (options) {
1652
+ receivedOptions = options;
1653
+ yield { type: "assistant", content: "Resumed with correct session" };
1654
+ });
1655
+ const executor = new JobExecutor(runtime, {
1656
+ logger: createMockLogger(),
1657
+ });
1658
+ await executor.execute({
1659
+ agent: createTestAgent({ name: "session-mismatch-agent" }),
1660
+ prompt: "Test prompt",
1661
+ stateDir,
1662
+ // Pass a DIFFERENT value than what's stored - this could be an old session ID
1663
+ // or any truthy value indicating "please resume"
1664
+ resume: "outdated-session-xyz789",
1665
+ });
1666
+ // CRITICAL: Should use the session_id from the stored session file, NOT the options.resume value
1667
+ // This is the fix for the unexpected logout bug - we must use the actual stored session ID
1668
+ expect(receivedOptions?.resume).toBe("stored-session-abc123");
1669
+ });
1670
+ it("respects custom session timeout from agent config", async () => {
1671
+ const sessionsDir = join(stateDir, "sessions");
1672
+ // Create a session that is 2 hours old
1673
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
1674
+ const { writeFile } = await import("node:fs/promises");
1675
+ const sessionPath = join(sessionsDir, "custom-timeout-agent.json");
1676
+ const sessionData = {
1677
+ agent_name: "custom-timeout-agent",
1678
+ session_id: "two-hour-old-session",
1679
+ created_at: twoHoursAgo,
1680
+ last_used_at: twoHoursAgo,
1681
+ job_count: 1,
1682
+ mode: "autonomous",
1683
+ };
1684
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1685
+ let receivedOptions;
1686
+ const runtime = createMockRuntime(async function* (options) {
1687
+ receivedOptions = options;
1688
+ yield { type: "assistant", content: "Done" };
1689
+ });
1690
+ const executor = new JobExecutor(runtime, {
1691
+ logger: createMockLogger(),
1692
+ });
1693
+ // With 1 hour timeout, 2-hour-old session should be expired
1694
+ await executor.execute({
1695
+ agent: createTestAgent({
1696
+ name: "custom-timeout-agent",
1697
+ session: { timeout: "1h" },
1698
+ }),
1699
+ prompt: "Test prompt",
1700
+ stateDir,
1701
+ resume: "two-hour-old-session",
1702
+ });
1703
+ // Should NOT have passed resume since session exceeded custom timeout
1704
+ expect(receivedOptions?.resume).toBeUndefined();
1705
+ });
1706
+ it("writes system message to job output when session expires", async () => {
1707
+ const sessionsDir = join(stateDir, "sessions");
1708
+ // Create an expired session
1709
+ const expiredLastUsed = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
1710
+ const { writeFile } = await import("node:fs/promises");
1711
+ const sessionPath = join(sessionsDir, "output-test-agent.json");
1712
+ const sessionData = {
1713
+ agent_name: "output-test-agent",
1714
+ session_id: "expired-session",
1715
+ created_at: expiredLastUsed,
1716
+ last_used_at: expiredLastUsed,
1717
+ job_count: 1,
1718
+ mode: "autonomous",
1719
+ };
1720
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1721
+ const runtime = createMockRuntime(async function* () {
1722
+ yield { type: "assistant", content: "Fresh start" };
1723
+ });
1724
+ const executor = new JobExecutor(runtime, {
1725
+ logger: createMockLogger(),
1726
+ });
1727
+ const result = await executor.execute({
1728
+ agent: createTestAgent({ name: "output-test-agent" }),
1729
+ prompt: "Test prompt",
1730
+ stateDir,
1731
+ resume: "expired-session",
1732
+ });
1733
+ // Check job output for session expiry message
1734
+ const output = await readJobOutputAll(join(stateDir, "jobs"), result.jobId);
1735
+ const systemMsg = output.find((m) => m.type === "system" && m.content?.includes("session"));
1736
+ expect(systemMsg).toBeDefined();
1737
+ });
1738
+ it("clears expired session file when detected", async () => {
1739
+ const sessionsDir = join(stateDir, "sessions");
1740
+ // Create an expired session
1741
+ const expiredLastUsed = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
1742
+ const { writeFile } = await import("node:fs/promises");
1743
+ const sessionPath = join(sessionsDir, "clear-test-agent.json");
1744
+ const sessionData = {
1745
+ agent_name: "clear-test-agent",
1746
+ session_id: "expired-to-clear",
1747
+ created_at: expiredLastUsed,
1748
+ last_used_at: expiredLastUsed,
1749
+ job_count: 1,
1750
+ mode: "autonomous",
1751
+ };
1752
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1753
+ // Verify session exists
1754
+ let sessionBefore = await getSessionInfo(sessionsDir, "clear-test-agent");
1755
+ expect(sessionBefore).not.toBeNull();
1756
+ const runtime = createMockRuntime(async function* () {
1757
+ yield { type: "system", content: "Init", subtype: "init", session_id: "new-session" };
1758
+ yield { type: "assistant", content: "Fresh" };
1759
+ });
1760
+ const executor = new JobExecutor(runtime, {
1761
+ logger: createMockLogger(),
1762
+ });
1763
+ await executor.execute({
1764
+ agent: createTestAgent({ name: "clear-test-agent" }),
1765
+ prompt: "Test prompt",
1766
+ stateDir,
1767
+ resume: "expired-to-clear",
1768
+ });
1769
+ // Session should now have the new ID (old one was cleared and new one created)
1770
+ const sessionAfter = await getSessionInfo(sessionsDir, "clear-test-agent");
1771
+ expect(sessionAfter?.session_id).toBe("new-session");
1772
+ });
1773
+ it("retries with fresh session when server-side session expiration detected", async () => {
1774
+ const sessionsDir = join(stateDir, "sessions");
1775
+ // Create a valid session (not locally expired)
1776
+ await updateSessionInfo(sessionsDir, "server-expiry-agent", {
1777
+ session_id: "valid-local-session",
1778
+ mode: "autonomous",
1779
+ });
1780
+ let attemptCount = 0;
1781
+ let lastResumeValue;
1782
+ // First attempt throws session expired error, second succeeds
1783
+ const runtime = createMockRuntime(async function* (options) {
1784
+ attemptCount++;
1785
+ lastResumeValue = options.resume;
1786
+ if (attemptCount === 1 && options.resume) {
1787
+ // First attempt with resume - server says session expired
1788
+ throw new Error("Session expired on server");
1789
+ }
1790
+ // Second attempt or fresh session - succeed
1791
+ yield { type: "system", content: "Init", subtype: "init", session_id: "new-server-session" };
1792
+ yield { type: "assistant", content: "Success after retry" };
1793
+ });
1794
+ const executor = new JobExecutor(runtime, {
1795
+ logger: createMockLogger(),
1796
+ });
1797
+ const result = await executor.execute({
1798
+ agent: createTestAgent({ name: "server-expiry-agent" }),
1799
+ prompt: "Test prompt",
1800
+ stateDir,
1801
+ resume: "valid-local-session",
1802
+ });
1803
+ // Should have succeeded after retry
1804
+ expect(result.success).toBe(true);
1805
+ expect(result.sessionId).toBe("new-server-session");
1806
+ // Should have attempted twice: first with resume, then fresh
1807
+ expect(attemptCount).toBe(2);
1808
+ // Second attempt should NOT have resume (fresh session)
1809
+ expect(lastResumeValue).toBeUndefined();
1810
+ // Check job output includes retry message
1811
+ const output = await readJobOutputAll(join(stateDir, "jobs"), result.jobId);
1812
+ const retryMsg = output.find((m) => m.type === "system" && m.content?.includes("Retrying with fresh session"));
1813
+ expect(retryMsg).toBeDefined();
1814
+ });
1815
+ it("does not retry infinitely on persistent server errors", async () => {
1816
+ const sessionsDir = join(stateDir, "sessions");
1817
+ await updateSessionInfo(sessionsDir, "no-infinite-retry-agent", {
1818
+ session_id: "some-session",
1819
+ mode: "autonomous",
1820
+ });
1821
+ let attemptCount = 0;
1822
+ // Always throw session expired error
1823
+ const runtime = createMockRuntime(async function* (options) {
1824
+ attemptCount++;
1825
+ throw new Error("Session expired on server");
1826
+ });
1827
+ const executor = new JobExecutor(runtime, {
1828
+ logger: createMockLogger(),
1829
+ });
1830
+ const result = await executor.execute({
1831
+ agent: createTestAgent({ name: "no-infinite-retry-agent" }),
1832
+ prompt: "Test prompt",
1833
+ stateDir,
1834
+ resume: "some-session",
1835
+ });
1836
+ // Should fail after at most 2 attempts (initial + 1 retry)
1837
+ expect(result.success).toBe(false);
1838
+ expect(attemptCount).toBeLessThanOrEqual(2);
1839
+ expect(result.error?.message).toContain("Session expired");
1840
+ });
1841
+ it("updates last_used_at before execution to prevent mid-job session expiry", async () => {
1842
+ const sessionsDir = join(stateDir, "sessions");
1843
+ // Create a session that is 23 hours old (close to default 24h expiry)
1844
+ const twentyThreeHoursAgo = new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString();
1845
+ const { writeFile } = await import("node:fs/promises");
1846
+ const sessionPath = join(sessionsDir, "refresh-test-agent.json");
1847
+ const sessionData = {
1848
+ agent_name: "refresh-test-agent",
1849
+ session_id: "almost-expired-session",
1850
+ created_at: twentyThreeHoursAgo,
1851
+ last_used_at: twentyThreeHoursAgo,
1852
+ job_count: 5,
1853
+ mode: "autonomous",
1854
+ };
1855
+ await writeFile(sessionPath, JSON.stringify(sessionData));
1856
+ // Record when the test starts
1857
+ const testStartTime = Date.now();
1858
+ const runtime = createMockRuntime(async function* () {
1859
+ // Simulate a delay to ensure last_used_at was updated BEFORE we got here
1860
+ yield { type: "assistant", content: "Working..." };
1861
+ });
1862
+ const executor = new JobExecutor(runtime, {
1863
+ logger: createMockLogger(),
1864
+ });
1865
+ await executor.execute({
1866
+ agent: createTestAgent({ name: "refresh-test-agent" }),
1867
+ prompt: "Test prompt",
1868
+ stateDir,
1869
+ resume: "almost-expired-session",
1870
+ });
1871
+ // Check that last_used_at was updated to a recent time (not 23 hours ago)
1872
+ const sessionAfter = await getSessionInfo(sessionsDir, "refresh-test-agent");
1873
+ expect(sessionAfter).not.toBeNull();
1874
+ const lastUsedMs = new Date(sessionAfter.last_used_at).getTime();
1875
+ // Should be updated to approximately now (within last few seconds)
1876
+ expect(lastUsedMs).toBeGreaterThanOrEqual(testStartTime - 1000);
1877
+ // And not still 23 hours ago
1878
+ expect(lastUsedMs).toBeGreaterThan(new Date(twentyThreeHoursAgo).getTime());
1879
+ });
1880
+ });
1550
1881
  //# sourceMappingURL=job-executor.test.js.map