pi-harness-runtime 1.1.440 → 1.1.441

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 (349) hide show
  1. package/harness/e2e/auto-test-loop.ts +2 -0
  2. package/harness/e2e/browser-scrapers/base-browser-scraper.ts +2 -0
  3. package/harness/e2e/browser-scrapers/chrome-profile-manager.ts +2 -0
  4. package/harness/e2e/browser-scrapers/codex-browser-scraper.ts +2 -0
  5. package/harness/e2e/tools-detector.ts +2 -0
  6. package/harness/key-loader.ts +2 -0
  7. package/harness/langchain/checkpointer.ts +2 -0
  8. package/harness/langchain/cron-cli.ts +2 -0
  9. package/harness/langchain/graph.ts +2 -0
  10. package/harness/notification-center.ts +2 -0
  11. package/harness/secret-provider.ts +2 -0
  12. package/index.ts +2 -0
  13. package/package.json +7 -1
  14. package/packages/a2a-adapter/package.json +1 -1
  15. package/packages/agent-policy/dist/extension.d.ts.map +1 -1
  16. package/packages/agent-policy/dist/extension.js +2 -0
  17. package/packages/agent-policy/dist/extension.js.map +1 -1
  18. package/packages/agent-policy/package.json +1 -1
  19. package/packages/agent-policy/src/extension.ts +2 -0
  20. package/packages/architecture-generator/package.json +1 -1
  21. package/packages/auth/dist/minimax-browser-auth.d.ts.map +1 -1
  22. package/packages/auth/dist/minimax-browser-auth.js +23 -1
  23. package/packages/auth/dist/minimax-browser-auth.js.map +1 -1
  24. package/packages/auth/package.json +1 -1
  25. package/packages/auth/src/minimax-browser-auth.ts +30 -5
  26. package/packages/autonomous-refactor/package.json +1 -1
  27. package/packages/autonomous-runtime/dist/inbox.d.ts.map +1 -1
  28. package/packages/autonomous-runtime/dist/inbox.js +2 -0
  29. package/packages/autonomous-runtime/dist/inbox.js.map +1 -1
  30. package/packages/autonomous-runtime/package.json +1 -1
  31. package/packages/autonomous-runtime/src/inbox.ts +2 -0
  32. package/packages/cache-strategy/package.json +1 -1
  33. package/packages/capability-registry/package.json +1 -1
  34. package/packages/checkpoint/package.json +1 -1
  35. package/packages/cli-plugin-sdk/package.json +1 -1
  36. package/packages/clipboard/dist/src/clipboard.d.ts.map +1 -1
  37. package/packages/clipboard/dist/src/clipboard.js +2 -0
  38. package/packages/clipboard/dist/src/clipboard.js.map +1 -1
  39. package/packages/clipboard/dist/src/copy-sync.d.ts.map +1 -1
  40. package/packages/clipboard/dist/src/copy-sync.js +2 -0
  41. package/packages/clipboard/dist/src/copy-sync.js.map +1 -1
  42. package/packages/clipboard/dist/src/gist-relay.d.ts.map +1 -1
  43. package/packages/clipboard/dist/src/gist-relay.js +2 -0
  44. package/packages/clipboard/dist/src/gist-relay.js.map +1 -1
  45. package/packages/clipboard/package.json +1 -1
  46. package/packages/clipboard/src/clipboard.ts +2 -0
  47. package/packages/clipboard/src/copy-sync.ts +2 -0
  48. package/packages/clipboard/src/gist-relay.ts +2 -0
  49. package/packages/clipboard-plugin/package.json +1 -1
  50. package/packages/code-generation/package.json +1 -1
  51. package/packages/code-review/package.json +1 -1
  52. package/packages/codex-adapter/package.json +1 -1
  53. package/packages/codex-watcher/package.json +1 -1
  54. package/packages/config-capture/package.json +1 -1
  55. package/packages/connection-runtime/package.json +1 -1
  56. package/packages/context-compiler/package.json +1 -1
  57. package/packages/context-discovery/package.json +1 -1
  58. package/packages/context-manager/package.json +1 -1
  59. package/packages/contracts/dist/delegation-store.js +1 -4
  60. package/packages/contracts/dist/workspace-env.js +28 -1
  61. package/packages/contracts/package.json +1 -1
  62. package/packages/contracts/src/delegation-store.ts +9 -4
  63. package/packages/contracts/src/workspace-env.ts +29 -4
  64. package/packages/cookie-sanitizer/package.json +1 -1
  65. package/packages/cost-optimizer/package.json +1 -1
  66. package/packages/dependency-analyzer/package.json +1 -1
  67. package/packages/django-plugin/package.json +1 -1
  68. package/packages/doc-generator/package.json +1 -1
  69. package/packages/durable-review/package.json +1 -1
  70. package/packages/e2e-tester/package.json +1 -1
  71. package/packages/evaluation-engine/package.json +1 -1
  72. package/packages/evaluation-runner/package.json +1 -1
  73. package/packages/event-bus/package.json +1 -1
  74. package/packages/event-sink/package.json +1 -1
  75. package/packages/event-store/dist/src/store.d.ts.map +1 -1
  76. package/packages/event-store/dist/src/store.js +2 -0
  77. package/packages/event-store/dist/src/store.js.map +1 -1
  78. package/packages/event-store/package.json +1 -1
  79. package/packages/event-store/src/store.ts +3 -0
  80. package/packages/experience-replay/package.json +1 -1
  81. package/packages/feedback-collector/package.json +1 -1
  82. package/packages/file-copy-helper/package.json +1 -1
  83. package/packages/framework-detector/package.json +1 -1
  84. package/packages/framework-plugin-sdk/package.json +1 -1
  85. package/packages/frappe-plugin/package.json +1 -1
  86. package/packages/generic-web-plugin/dist/analyzer.d.ts.map +1 -1
  87. package/packages/generic-web-plugin/dist/analyzer.js +2 -0
  88. package/packages/generic-web-plugin/dist/analyzer.js.map +1 -1
  89. package/packages/generic-web-plugin/package.json +1 -1
  90. package/packages/generic-web-plugin/src/analyzer.ts +3 -0
  91. package/packages/health-monitor/package.json +1 -1
  92. package/packages/intent-analyzer/package.json +1 -1
  93. package/packages/jev-judge/package.json +1 -1
  94. package/packages/knowledge-graph/package.json +1 -1
  95. package/packages/knowledge-retrieval/dist/mcp-client.d.ts.map +1 -1
  96. package/packages/knowledge-retrieval/dist/mcp-client.js +0 -1
  97. package/packages/knowledge-retrieval/dist/mcp-client.js.map +1 -1
  98. package/packages/knowledge-retrieval/package.json +1 -4
  99. package/packages/knowledge-retrieval/src/mcp-client.ts +0 -1
  100. package/packages/laravel-plugin/package.json +1 -1
  101. package/packages/learning-engine/package.json +1 -1
  102. package/packages/logging/package.json +1 -1
  103. package/packages/mcp-adapter/dist/server.d.ts.map +1 -1
  104. package/packages/mcp-adapter/dist/server.js +2 -0
  105. package/packages/mcp-adapter/dist/server.js.map +1 -1
  106. package/packages/mcp-adapter/package.json +1 -1
  107. package/packages/mcp-adapter/src/server.ts +3 -0
  108. package/packages/memory-engine/package.json +1 -1
  109. package/packages/milestone-manager/package.json +1 -1
  110. package/packages/model-registry/package.json +1 -1
  111. package/packages/nextjs-plugin/package.json +1 -1
  112. package/packages/notification/package.json +1 -1
  113. package/packages/observability/package.json +1 -1
  114. package/packages/okf-indexer/package.json +1 -1
  115. package/packages/paperclip-integration/dist/index.d.ts.map +1 -1
  116. package/packages/paperclip-integration/dist/index.js +2 -0
  117. package/packages/paperclip-integration/dist/index.js.map +1 -1
  118. package/packages/paperclip-integration/package.json +1 -1
  119. package/packages/paperclip-integration/src/index.ts +2 -0
  120. package/packages/performance-optimizer/package.json +1 -1
  121. package/packages/permission-mode/package.json +1 -1
  122. package/packages/privilege-broker/dist/audit.d.ts.map +1 -1
  123. package/packages/privilege-broker/dist/audit.js +2 -0
  124. package/packages/privilege-broker/dist/audit.js.map +1 -1
  125. package/packages/privilege-broker/package.json +1 -1
  126. package/packages/privilege-broker/src/audit.ts +2 -0
  127. package/packages/project-analyzer/package.json +1 -1
  128. package/packages/project-bootstrap/package.json +1 -1
  129. package/packages/projection-engine/package.json +1 -1
  130. package/packages/prompt-compiler/dist/compiler.d.ts.map +1 -1
  131. package/packages/prompt-compiler/dist/compiler.js +2 -0
  132. package/packages/prompt-compiler/dist/compiler.js.map +1 -1
  133. package/packages/prompt-compiler/package.json +1 -1
  134. package/packages/prompt-compiler/src/compiler.ts +2 -0
  135. package/packages/prompt-versioning/package.json +1 -1
  136. package/packages/provider-adapter-sdk/package.json +1 -1
  137. package/packages/provider-router/package.json +1 -1
  138. package/packages/provider-selector/package.json +1 -1
  139. package/packages/providers/package.json +1 -1
  140. package/packages/qdrant-skills/package.json +1 -1
  141. package/packages/quota-manager/package.json +1 -1
  142. package/packages/rate-limiter/package.json +1 -1
  143. package/packages/react-vite-plugin/package.json +1 -1
  144. package/packages/relay/package.json +1 -1
  145. package/packages/release-manager/package.json +1 -1
  146. package/packages/requirement-compiler/package.json +1 -1
  147. package/packages/runtime/package.json +1 -1
  148. package/packages/scheduler/package.json +1 -1
  149. package/packages/scheduler-adapter/package.json +1 -1
  150. package/packages/session/package.json +1 -1
  151. package/packages/session-api/package.json +1 -1
  152. package/packages/session-export/package.json +1 -1
  153. package/packages/shared-context/package.json +1 -1
  154. package/packages/skill-mcp-client/dist/client.d.ts.map +1 -1
  155. package/packages/skill-mcp-client/dist/client.js +0 -1
  156. package/packages/skill-mcp-client/dist/client.js.map +1 -1
  157. package/packages/skill-mcp-client/package.json +1 -4
  158. package/packages/skill-mcp-client/src/client.ts +0 -1
  159. package/packages/skill-registry/package.json +1 -1
  160. package/packages/skills/dist/parser.d.ts.map +1 -1
  161. package/packages/skills/dist/parser.js +2 -0
  162. package/packages/skills/dist/parser.js.map +1 -1
  163. package/packages/skills/dist/skill-versioning.d.ts.map +1 -1
  164. package/packages/skills/dist/skill-versioning.js +2 -0
  165. package/packages/skills/dist/skill-versioning.js.map +1 -1
  166. package/packages/skills/package.json +1 -1
  167. package/packages/skills/src/parser.ts +3 -0
  168. package/packages/skills/src/skill-versioning.ts +2 -0
  169. package/packages/sprint-planner/package.json +1 -1
  170. package/packages/subscription-engine/package.json +1 -1
  171. package/packages/task-compiler/package.json +1 -1
  172. package/packages/tencentdb-memory/package.json +1 -1
  173. package/packages/tencentdb-sync/package.json +1 -1
  174. package/packages/test-data-generator/package.json +1 -1
  175. package/packages/test-generator/package.json +1 -1
  176. package/packages/todo-bd-sync/package.json +1 -1
  177. package/packages/token-estimation/package.json +1 -1
  178. package/packages/token-optimizer/package.json +1 -1
  179. package/packages/tooling-preference/package.json +1 -1
  180. package/packages/trajectory/package.json +1 -1
  181. package/packages/tui/package.json +1 -1
  182. package/packages/two-phase-review/package.json +1 -1
  183. package/packages/types/package.json +1 -1
  184. package/packages/voice-api/package.json +1 -1
  185. package/packages/webmcp-tester/dist/index.js +0 -2
  186. package/packages/webmcp-tester/package.json +1 -1
  187. package/packages/webmcp-tester/src/index.ts +0 -3
  188. package/packages/wiki/package.json +1 -1
  189. package/packages/workflow-events/package.json +1 -1
  190. package/packages/workspace-scanner/package.json +1 -1
  191. package/packages/worktree/package.json +1 -1
  192. package/packages/write-review/dist/trigger.d.ts.map +1 -1
  193. package/packages/write-review/dist/trigger.js +1 -2
  194. package/packages/write-review/dist/trigger.js.map +1 -1
  195. package/packages/write-review/package.json +1 -1
  196. package/packages/write-review/src/trigger.ts +1 -2
  197. package/scripts/lib/runtime-imports.ts +189 -3
  198. package/scripts/telegram-webhook-server.ts +2 -0
  199. package/scripts/verify-package-contents.ts +148 -1
  200. package/harness/e2e/tools-detector.test.ts +0 -155
  201. package/harness/glm-quota-countdown.test.ts +0 -323
  202. package/harness/langchain/agents.test.ts +0 -39
  203. package/harness/langchain/checkpointer.test.ts +0 -243
  204. package/harness/langchain/daemon.test.ts +0 -459
  205. package/harness/langchain/model-router.test.ts +0 -191
  206. package/harness/langchain/ping-pong-middleware.test.ts +0 -69
  207. package/harness/langchain/scoreboard.test.ts +0 -267
  208. package/harness/langchain/status-line.test.ts +0 -106
  209. package/harness/langchain/surge.test.ts +0 -467
  210. package/harness/langchain/widget.test.ts +0 -251
  211. package/harness/skill-sync.test.ts +0 -57
  212. package/harness/ssh-detach-interceptor.test.ts +0 -263
  213. package/harness/ssh-hang-recovery.test.ts +0 -86
  214. package/packages/a2a-adapter/test/a2a.test.ts +0 -151
  215. package/packages/agent-policy/test/interlock.test.ts +0 -101
  216. package/packages/agent-policy/test/policy-retention.test.ts +0 -380
  217. package/packages/agent-policy/test/policy.test.ts +0 -387
  218. package/packages/auth/test/auth.test.ts +0 -238
  219. package/packages/autonomous-refactor/test/refactor.test.ts +0 -202
  220. package/packages/autonomous-runtime/test/types.test.ts +0 -95
  221. package/packages/cache-strategy/test/cache.test.ts +0 -137
  222. package/packages/capability-registry/test/registry.test.ts +0 -204
  223. package/packages/checkpoint/test/engine.test.ts +0 -265
  224. package/packages/cli-plugin-sdk/test/cli.test.ts +0 -45
  225. package/packages/code-generation/test/generator.test.ts +0 -238
  226. package/packages/code-review/test/engine.test.ts +0 -302
  227. package/packages/codex-adapter/test/provider.test.ts +0 -142
  228. package/packages/codex-watcher/test/watch.test.ts +0 -453
  229. package/packages/context-compiler/test/context-compiler.test.ts +0 -746
  230. package/packages/context-manager/test/context.test.ts +0 -198
  231. package/packages/contracts/test/delegation.test.ts +0 -350
  232. package/packages/contracts/test/workspace-env.test.ts +0 -802
  233. package/packages/cookie-sanitizer/test/infer.test.ts +0 -110
  234. package/packages/cookie-sanitizer/test/normalize.test.ts +0 -113
  235. package/packages/cookie-sanitizer/test/parse.test.ts +0 -204
  236. package/packages/cookie-sanitizer/test/sync.test.ts +0 -237
  237. package/packages/cookie-sanitizer/test/watcher.test.ts +0 -27
  238. package/packages/cost-optimizer/test/cost-optimizer.test.ts +0 -438
  239. package/packages/dependency-analyzer/test/analyzer.test.ts +0 -49
  240. package/packages/django-plugin/test/analyzer.test.ts +0 -195
  241. package/packages/doc-generator/test/docs.test.ts +0 -168
  242. package/packages/durable-review/test/acceptance.test.ts +0 -357
  243. package/packages/durable-review/test/smoke.test.ts +0 -107
  244. package/packages/evaluation-engine/test/engine.test.ts +0 -277
  245. package/packages/evaluation-runner/test/runner.test.ts +0 -168
  246. package/packages/event-bus/test/bus.test.ts +0 -100
  247. package/packages/event-store/test/store.test.ts +0 -115
  248. package/packages/experience-replay/test/replay.test.ts +0 -267
  249. package/packages/feedback-collector/test/collector.test.ts +0 -142
  250. package/packages/framework-detector/test/detector.test.ts +0 -75
  251. package/packages/framework-plugin-sdk/test/manager.test.ts +0 -320
  252. package/packages/frappe-plugin/test/analyzer.test.ts +0 -250
  253. package/packages/generic-web-plugin/test/analyzer.test.ts +0 -134
  254. package/packages/health-monitor/test/health.test.ts +0 -231
  255. package/packages/intent-analyzer/test/analyzer.test.ts +0 -72
  256. package/packages/intent-analyzer/test/ping-pong.test.ts +0 -96
  257. package/packages/jev-judge/tests/auto-continue-context.test.ts +0 -280
  258. package/packages/jev-judge/tests/continuation-controller.test.ts +0 -1396
  259. package/packages/jev-judge/tests/jev-provider-chain.test.ts +0 -536
  260. package/packages/jev-judge/tests/outcome-application.test.ts +0 -132
  261. package/packages/jev-judge/tests/question-risk.test.ts +0 -129
  262. package/packages/jev-judge/tests/typesafe-provider.test.ts +0 -640
  263. package/packages/jev-judge/tests/validation-ledger.test.ts +0 -181
  264. package/packages/jev-judge/tests/validation-plan.test.ts +0 -170
  265. package/packages/jev-judge/tests/validation-tie-breaker.test.ts +0 -88
  266. package/packages/laravel-plugin/test/analyzer.test.ts +0 -220
  267. package/packages/learning-engine/test/engine.test.ts +0 -331
  268. package/packages/logging/test/buffer.test.ts +0 -44
  269. package/packages/logging/test/classification.test.ts +0 -137
  270. package/packages/logging/test/config.test.ts +0 -97
  271. package/packages/logging/test/fanout.test.ts +0 -186
  272. package/packages/logging/test/identity-transport.test.ts +0 -197
  273. package/packages/logging/test/incidents.test.ts +0 -404
  274. package/packages/logging/test/ingest-schema.test.ts +0 -184
  275. package/packages/logging/test/integration.test.ts +0 -140
  276. package/packages/logging/test/log-http.test.ts +0 -246
  277. package/packages/logging/test/redact.test.ts +0 -109
  278. package/packages/logging/test/sampler.test.ts +0 -57
  279. package/packages/logging/test/server.test.ts +0 -444
  280. package/packages/logging/test/shipper.test.ts +0 -152
  281. package/packages/logging/test/spool.test.ts +0 -96
  282. package/packages/mcp-adapter/test/server.test.ts +0 -91
  283. package/packages/memory-engine/test/engine.test.ts +0 -456
  284. package/packages/model-registry/test/registry.test.ts +0 -342
  285. package/packages/nextjs-plugin/test/analyzer.test.ts +0 -149
  286. package/packages/notification/test/approval-payload.test.ts +0 -269
  287. package/packages/notification/test/detail-format.test.ts +0 -106
  288. package/packages/notification/test/notification.test.ts +0 -375
  289. package/packages/notification/test/pending-question-lifecycle.test.ts +0 -195
  290. package/packages/notification/test/pending-question.test.ts +0 -331
  291. package/packages/notification/test/tooling-proposal-payload.test.ts +0 -212
  292. package/packages/observability/test/observability.test.ts +0 -1350
  293. package/packages/performance-optimizer/test/optimizer.test.ts +0 -267
  294. package/packages/project-analyzer/test/index.test.ts +0 -1719
  295. package/packages/prompt-compiler/test/prompt-compiler.test.ts +0 -813
  296. package/packages/prompt-versioning/test/versioning.test.ts +0 -112
  297. package/packages/provider-adapter-sdk/test/sdk.test.ts +0 -220
  298. package/packages/provider-router/test/provider-router.test.ts +0 -405
  299. package/packages/provider-selector/test/selector.test.ts +0 -192
  300. package/packages/providers/test/providers.test.ts +0 -225
  301. package/packages/qdrant-skills/test/collection-info.test.ts +0 -165
  302. package/packages/qdrant-skills/test/embedder-batch.test.ts +0 -184
  303. package/packages/qdrant-skills/test/embedder.test.ts +0 -97
  304. package/packages/quota-manager/test/quota.test.ts +0 -432
  305. package/packages/rate-limiter/test/limiter.test.ts +0 -128
  306. package/packages/react-vite-plugin/test/analyzer.test.ts +0 -139
  307. package/packages/relay/test/client.test.ts +0 -446
  308. package/packages/relay/test/relay-e2e.test.ts +0 -299
  309. package/packages/relay/test/relay-endpoint.test.ts +0 -278
  310. package/packages/release-manager/test/release.test.ts +0 -215
  311. package/packages/requirement-compiler/test/requirement-compiler.test.ts +0 -743
  312. package/packages/runtime/test/runtime-api.test.ts +0 -169
  313. package/packages/runtime/test/runtime.test.ts +0 -297
  314. package/packages/scheduler/test/scheduler.test.ts +0 -125
  315. package/packages/session/test/manager.test.ts +0 -233
  316. package/packages/shared-context/test/shared-context.test.ts +0 -114
  317. package/packages/skill-registry/test/registry.test.ts +0 -508
  318. package/packages/skills/test/init-skills.test.ts +0 -114
  319. package/packages/skills/test/remote-skills.test.ts +0 -360
  320. package/packages/skills/test/remote-source.test.ts +0 -248
  321. package/packages/skills/test/sanitize.test.ts +0 -250
  322. package/packages/skills/test/vector-indexing.test.ts +0 -205
  323. package/packages/skills/test/vector-schema.test.ts +0 -180
  324. package/packages/skills/test/vector-threshold.test.ts +0 -108
  325. package/packages/sprint-planner/test/planner.test.ts +0 -187
  326. package/packages/task-compiler/test/task-compiler.test.ts +0 -628
  327. package/packages/test-data-generator/test/generator.test.ts +0 -265
  328. package/packages/test-generator/test/generator.test.ts +0 -156
  329. package/packages/token-estimation/test/token-estimation.test.ts +0 -207
  330. package/packages/token-optimizer/test/optimizer.test.ts +0 -141
  331. package/packages/tooling-preference/tests/changed-files.test.ts +0 -124
  332. package/packages/tooling-preference/tests/detect.test.ts +0 -188
  333. package/packages/tooling-preference/tests/proposal.test.ts +0 -107
  334. package/packages/tooling-preference/tests/quality-check.test.ts +0 -272
  335. package/packages/tooling-preference/tests/registry.test.ts +0 -51
  336. package/packages/tooling-preference/tests/store.test.ts +0 -99
  337. package/packages/trajectory/test/store.test.ts +0 -250
  338. package/packages/tui/test/harness-footer.test.ts +0 -559
  339. package/packages/tui/test/harness-header.test.ts +0 -590
  340. package/packages/two-phase-review/test/classify.test.ts +0 -102
  341. package/packages/two-phase-review/test/helpers.ts +0 -72
  342. package/packages/two-phase-review/test/rules.test.ts +0 -150
  343. package/packages/two-phase-review/test/runtime.test.ts +0 -362
  344. package/packages/two-phase-review/test/scope.test.ts +0 -186
  345. package/packages/two-phase-review/test/signals.test.ts +0 -183
  346. package/packages/two-phase-review/test/state.test.ts +0 -201
  347. package/packages/wiki/test/basic.test.ts +0 -242
  348. package/packages/workspace-scanner/test/scanner.test.ts +0 -43
  349. package/packages/worktree/test/worktree.test.ts +0 -163
@@ -1,1396 +0,0 @@
1
- /**
2
- * Regression tests for the continuation guard lifecycle.
3
- *
4
- * Bug under test: the dispatch guard was set on a successful dispatch and only
5
- * released via clearQueue() on a non-continue path, so the *next* dispatch was
6
- * blocked forever - while the retry budget was still consumed by every blocked
7
- * attempt. Result: one continuation, then a silent stop.
8
- */
9
-
10
- import { describe, it, expect } from "bun:test";
11
- import { mkdtempSync, rmSync } from "node:fs";
12
- import { tmpdir } from "node:os";
13
- import { join } from "node:path";
14
- import { TodoContinuationController } from "../src/continuation-controller.js";
15
- import { MockTodoProvider, type TodoContext } from "../src/todo-provider.js";
16
- import { ValidationLedger } from "../src/validation-ledger.js";
17
-
18
- function makeCtx(overrides: Partial<TodoContext> = {}): TodoContext {
19
- return {
20
- sessionManager: {
21
- getSessionId: () => "session-test",
22
- getBranch: () => [],
23
- },
24
- isIdle: () => true,
25
- hasPendingMessages: () => false,
26
- ...overrides,
27
- };
28
- }
29
-
30
- function makeController(opts: {
31
- provider: MockTodoProvider;
32
- dispatch: (content: string) => Promise<void>;
33
- ctx?: TodoContext;
34
- maxSteerAttempts?: number;
35
- circuitBreakerCooldownMs?: number;
36
- guardReleaseTimeoutMs?: number;
37
- guardReleaseAfterDropMs?: number;
38
- inFlightTimeoutMs?: number;
39
- maxConsecutiveFailures?: number;
40
- }) {
41
- return new TodoContinuationController(opts.ctx ?? makeCtx(), {
42
- dispatch: opts.dispatch,
43
- todoProvider: opts.provider,
44
- maxSteerAttempts: opts.maxSteerAttempts ?? 3,
45
- circuitBreakerCooldownMs: opts.circuitBreakerCooldownMs,
46
- guardReleaseTimeoutMs: opts.guardReleaseTimeoutMs,
47
- guardReleaseAfterDropMs: opts.guardReleaseAfterDropMs,
48
- inFlightTimeoutMs: opts.inFlightTimeoutMs,
49
- maxConsecutiveFailures: opts.maxConsecutiveFailures,
50
- });
51
- }
52
-
53
- describe("TodoContinuationController - guard lifecycle", () => {
54
- it("dispatches on every settled cycle until the attempt budget is spent", async () => {
55
- const provider = new MockTodoProvider();
56
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
57
-
58
- const sent: string[] = [];
59
- const controller = makeController({
60
- provider,
61
- dispatch: async (content) => {
62
- sent.push(content);
63
- },
64
- });
65
-
66
- // Four consecutive idle cycles: decide -> dispatch -> (agent settles)
67
- for (let cycle = 1; cycle <= 4; cycle++) {
68
- controller.releaseContinuationGuard(`settled ${cycle}`);
69
- const result = await controller.decide();
70
-
71
- if (result.action === "continue" && result.nextItemId) {
72
- const steer = await controller.steerToContinue(result.nextItemId);
73
- expect(steer.dispatched).toBe(true);
74
- } else {
75
- expect(result.action).toBe("stop");
76
- expect(result.reason).toContain("max attempts");
77
- }
78
- }
79
-
80
- // 3 successful dispatches, then the budget stops further attempts
81
- expect(sent.length).toBe(3);
82
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(3);
83
- });
84
-
85
- it("does not consume the attempt budget when a dispatch is blocked", async () => {
86
- const provider = new MockTodoProvider();
87
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
88
-
89
- let busy = false;
90
- const sent: string[] = [];
91
- const controller = makeController({
92
- provider,
93
- ctx: makeCtx({ isIdle: () => !busy }),
94
- dispatch: async (content) => {
95
- sent.push(content);
96
- },
97
- });
98
-
99
- // One real dispatch spends one attempt.
100
- const first = await controller.steerToContinue(1);
101
- expect(first.dispatched).toBe(true);
102
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(1);
103
-
104
- // The guard blocks the next dispatch because the session is busy.
105
- busy = true;
106
- const blocked = await controller.steerToContinue(1);
107
- expect(blocked.dispatched).toBe(false);
108
- expect(blocked.reason).toContain("session not idle");
109
- expect(blocked.blockedDispatches).toBeGreaterThan(0);
110
- // The counter is untouched: a blocked attempt is not an attempt.
111
- expect(blocked.attempts).toBe(1);
112
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(1);
113
- expect(sent.length).toBe(1);
114
- });
115
-
116
- it("blocks a duplicate dispatch until the guard is released", async () => {
117
- const provider = new MockTodoProvider();
118
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
119
-
120
- const sent: string[] = [];
121
- const controller = makeController({
122
- provider,
123
- dispatch: async (content) => {
124
- sent.push(content);
125
- },
126
- });
127
-
128
- const first = await controller.steerToContinue(1);
129
- expect(first.dispatched).toBe(true);
130
-
131
- // A second settle without releasing the guard must not dispatch again
132
- const second = await controller.steerToContinue(1);
133
- expect(second.dispatched).toBe(false);
134
- expect(sent.length).toBe(1);
135
-
136
- // Releasing the guard (the verified lifecycle transition) allows the next one
137
- controller.releaseContinuationGuard("agent_settled");
138
- const third = await controller.steerToContinue(1);
139
- expect(third.dispatched).toBe(true);
140
- expect(sent.length).toBe(2);
141
- expect(third.attempts).toBe(2);
142
- });
143
-
144
- it("does not dispatch while explicitly paused and resumes afterwards", async () => {
145
- const provider = new MockTodoProvider();
146
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
147
-
148
- const sent: string[] = [];
149
- const controller = makeController({
150
- provider,
151
- dispatch: async (content) => {
152
- sent.push(content);
153
- },
154
- });
155
-
156
- controller.explicitPause();
157
- const paused = await controller.steerToContinue(1);
158
- expect(paused.dispatched).toBe(false);
159
- expect(sent.length).toBe(0);
160
-
161
- controller.resume();
162
- const resumed = await controller.steerToContinue(1);
163
- expect(resumed.dispatched).toBe(true);
164
- expect(sent.length).toBe(1);
165
- });
166
-
167
- it("skips an item that was completed before the dispatch", async () => {
168
- const provider = new MockTodoProvider();
169
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
170
-
171
- const controller = makeController({
172
- provider,
173
- dispatch: async () => {
174
- throw new Error("must not dispatch");
175
- },
176
- });
177
-
178
- provider.setStatus(1, "completed");
179
- const result = await controller.steerToContinue(1);
180
- expect(result.dispatched).toBe(false);
181
- expect(result.blockedDispatches).toBeGreaterThan(0);
182
- });
183
-
184
- it("reports a failed send with the real reason and spends no attempt", async () => {
185
- const provider = new MockTodoProvider();
186
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
187
-
188
- const controller = makeController({
189
- provider,
190
- dispatch: async () => {
191
- throw new Error("transport is down");
192
- },
193
- });
194
-
195
- const result = await controller.steerToContinue(1);
196
- expect(result.dispatched).toBe(false);
197
- // The reason must name the real failure, not "blocked by guard".
198
- expect(result.reason).toContain("dispatch failed");
199
- expect(result.reason).toContain("transport is down");
200
- expect(result.attempts).toBe(0);
201
- expect(result.blockedDispatches).toBe(0);
202
- expect(controller.getDiagnostics().continuationQueued).toBe(false);
203
- });
204
-
205
- it("keeps the in-flight guard while a dispatch is on the wire", async () => {
206
- const provider = new MockTodoProvider();
207
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
208
-
209
- let releaseDispatch: (() => void) | null = null;
210
- const sent: string[] = [];
211
- const controller = makeController({
212
- provider,
213
- dispatch: async (content) => {
214
- sent.push(content);
215
- await new Promise<void>((resolve) => {
216
- releaseDispatch = resolve;
217
- });
218
- },
219
- });
220
-
221
- const first = controller.steerToContinue(1);
222
- // Let the dispatch reach the in-flight state.
223
- await new Promise((r) => setTimeout(r, 5));
224
- expect(controller.getDiagnostics().inFlight).toBe(true);
225
-
226
- // A settle arriving while the dispatch is in flight must NOT release the
227
- // in-flight guard - otherwise a duplicate continuation can be sent.
228
- controller.releaseContinuationGuard("agent_settled");
229
- expect(controller.getDiagnostics().inFlight).toBe(true);
230
-
231
- const second = await controller.steerToContinue(1);
232
- expect(second.dispatched).toBe(false);
233
- expect(second.reason).toContain("in flight");
234
- expect(sent.length).toBe(1);
235
-
236
- releaseDispatch?.();
237
- const firstResult = await first;
238
- expect(firstResult.dispatched).toBe(true);
239
- expect(controller.getDiagnostics().inFlight).toBe(false);
240
- });
241
-
242
- it("routes continueItem() through the guarded dispatch", async () => {
243
- const provider = new MockTodoProvider();
244
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
245
-
246
- const sent: string[] = [];
247
- const controller = makeController({
248
- provider,
249
- dispatch: async (content) => {
250
- sent.push(content);
251
- },
252
- });
253
-
254
- const item = { id: 1, subject: "keep going", status: "in_progress" } as never;
255
- await controller.continueItem(item);
256
- expect(sent.length).toBe(1);
257
- expect(sent[0]).toContain("keep going");
258
- // A successful continuation consumes one attempt.
259
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(1);
260
- // The guard is armed: a second continuation is blocked.
261
- await controller.continueItem(item);
262
- expect(sent.length).toBe(1);
263
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(1);
264
-
265
- // After the guard is released the next continuation is allowed again.
266
- controller.releaseContinuationGuard("agent_settled");
267
- await controller.continueItem(item);
268
- expect(sent.length).toBe(2);
269
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(2);
270
- });
271
-
272
- it("does not spend an attempt when continueItem() is blocked", async () => {
273
- const provider = new MockTodoProvider();
274
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
275
-
276
- const sent: string[] = [];
277
- const controller = makeController({
278
- provider,
279
- ctx: makeCtx({ isIdle: () => false }),
280
- dispatch: async (content) => {
281
- sent.push(content);
282
- },
283
- });
284
-
285
- const item = { id: 1, subject: "keep going", status: "in_progress" } as never;
286
- await controller.continueItem(item);
287
- expect(sent.length).toBe(0);
288
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(0);
289
- expect(controller.getDiagnostics().blockedDispatches).toBeGreaterThan(0);
290
- });
291
-
292
- it("cancels the guard timer on pause", async () => {
293
- const provider = new MockTodoProvider();
294
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
295
-
296
- const controller = makeController({
297
- provider,
298
- dispatch: async () => {},
299
- });
300
-
301
- await controller.steerToContinue(1);
302
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
303
-
304
- controller.explicitPause();
305
- // A pause must disarm the guard completely: a queued-but-cancelled
306
- // release timer would leave the queue flag stuck at true and block
307
- // every later dispatch.
308
- expect(controller.getDiagnostics().continuationQueued).toBe(false);
309
-
310
- // A paused controller must not dispatch again.
311
- const paused = await controller.steerToContinue(1);
312
- expect(paused.dispatched).toBe(false);
313
- expect(paused.reason).toContain("paused");
314
- });
315
-
316
- it("keeps the in-flight guard when the queue is cleared mid-dispatch", async () => {
317
- const provider = new MockTodoProvider();
318
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
319
-
320
- let releaseDispatch: (() => void) | null = null;
321
- const sent: string[] = [];
322
- const controller = makeController({
323
- provider,
324
- dispatch: async (content) => {
325
- sent.push(content);
326
- await new Promise<void>((resolve) => {
327
- releaseDispatch = resolve;
328
- });
329
- },
330
- });
331
-
332
- const first = controller.steerToContinue(1);
333
- await new Promise((r) => setTimeout(r, 5));
334
- expect(controller.getDiagnostics().inFlight).toBe(true);
335
-
336
- // clearQueue() must NOT release the in-flight guard - otherwise two
337
- // continuations can be on the wire at the same time.
338
- controller.clearQueue();
339
- expect(controller.getDiagnostics().inFlight).toBe(true);
340
-
341
- const second = await controller.steerToContinue(1);
342
- expect(second.dispatched).toBe(false);
343
- expect(second.reason).toContain("in flight");
344
- expect(sent.length).toBe(1);
345
-
346
- releaseDispatch?.();
347
- await first;
348
- expect(controller.getDiagnostics().inFlight).toBe(false);
349
- // The cancelled dispatch must not leave the guard armed.
350
- expect(controller.getDiagnostics().continuationQueued).toBe(false);
351
- });
352
-
353
- it("does not disarm the guard of a later dispatch after an idle clearQueue()", async () => {
354
- const provider = new MockTodoProvider();
355
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
356
-
357
- const sent: string[] = [];
358
- const controller = makeController({
359
- provider,
360
- dispatch: async (content) => {
361
- sent.push(content);
362
- },
363
- });
364
-
365
- // A clearQueue() while NO dispatch is running must not cancel the guard
366
- // of the NEXT dispatch.
367
- controller.clearQueue();
368
-
369
- await controller.steerToContinue(1);
370
- expect(sent.length).toBe(1);
371
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
372
-
373
- // The guard is armed: a second continuation is refused.
374
- const second = await controller.steerToContinue(1);
375
- expect(second.dispatched).toBe(false);
376
- expect(sent.length).toBe(1);
377
- });
378
-
379
- it("drops a guard release that arrives while a dispatch is in flight", async () => {
380
- const provider = new MockTodoProvider();
381
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
382
-
383
- let releaseDispatch: (() => void) | null = null;
384
- const controller = makeController({
385
- provider,
386
- dispatch: async () => {
387
- await new Promise<void>((resolve) => {
388
- releaseDispatch = resolve;
389
- });
390
- },
391
- });
392
-
393
- const first = controller.steerToContinue(1);
394
- await new Promise((r) => setTimeout(r, 5));
395
-
396
- // A release that arrives mid-dispatch belongs to the turn that was
397
- // already running - it must NOT disarm the guard of the dispatch that
398
- // is settling now.
399
- controller.releaseContinuationGuard("agent_settled");
400
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
401
-
402
- releaseDispatch?.();
403
- await first;
404
-
405
- // The guard of the completed dispatch is still armed; the next settle
406
- // releases it.
407
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
408
- controller.releaseContinuationGuard("agent_settled");
409
- expect(controller.getDiagnostics().continuationQueued).toBe(false);
410
- });
411
-
412
- it("opens the circuit breaker after repeated send failures", async () => {
413
- const provider = new MockTodoProvider();
414
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
415
-
416
- let fail = true;
417
- let sends = 0;
418
- const controller = makeController({
419
- provider,
420
- dispatch: async () => {
421
- sends++;
422
- if (fail) throw new Error("transport is down");
423
- },
424
- });
425
-
426
- const reasons: string[] = [];
427
- for (let i = 0; i < 3; i++) {
428
- const result = await controller.steerToContinue(1);
429
- expect(result.dispatched).toBe(false);
430
- reasons.push(result.reason ?? "");
431
- }
432
-
433
- // The first failures are reported verbatim; the third one trips the
434
- // breaker.
435
- expect(reasons[0]).toContain("dispatch failed");
436
- expect(reasons[2]).toContain("circuit breaker open after 3 consecutive failures");
437
- // A failing transport never consumes the attempt budget.
438
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(0);
439
- expect(controller.getDiagnostics().circuitBreakerOpen).toBe(true);
440
-
441
- // The breaker really breaks: the next continuation is refused BEFORE
442
- // the transport is used at all.
443
- const blocked = await controller.steerToContinue(1);
444
- expect(blocked.dispatched).toBe(false);
445
- expect(blocked.reason).toContain("circuit breaker open");
446
- expect(sends).toBe(3);
447
-
448
- // An explicit resume closes the breaker and a dispatch succeeds again.
449
- fail = false;
450
- controller.resume();
451
- const ok = await controller.steerToContinue(1);
452
- expect(ok.dispatched).toBe(true);
453
- expect(sends).toBe(4);
454
- });
455
-
456
- it("keeps the attempt counter when a later attempt is blocked", async () => {
457
- const provider = new MockTodoProvider();
458
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
459
- provider.addItem({ subject: "other work", status: "pending" } as never);
460
-
461
- let busy = false;
462
- const sent: string[] = [];
463
- const controller = makeController({
464
- provider,
465
- ctx: makeCtx({ isIdle: () => !busy }),
466
- dispatch: async (content) => {
467
- sent.push(content);
468
- },
469
- });
470
-
471
- // One real dispatch consumes one attempt.
472
- const first = await controller.steerToContinue(1);
473
- expect(first.dispatched).toBe(true);
474
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(1);
475
-
476
- // A blocked attempt must not change the counter.
477
- busy = true;
478
- const blocked = await controller.steerToContinue(1);
479
- expect(blocked.dispatched).toBe(false);
480
- expect(blocked.reason).toContain("not idle");
481
- expect(controller.getDiagnostics().steerAttemptsForCurrentItem).toBe(1);
482
- expect(sent.length).toBe(1);
483
-
484
- // The budget is per item: another item starts with a fresh budget.
485
- busy = false;
486
- controller.releaseContinuationGuard("agent settled");
487
- const other = await controller.steerToContinue(2);
488
- expect(other.dispatched).toBe(true);
489
- expect(other.attempts).toBe(1);
490
- });
491
-
492
- it("stops after the session-wide attempt cap even when items alternate", async () => {
493
- const provider = new MockTodoProvider();
494
- // Four items: the per-item budget alone (3 each) would allow 12
495
- // dispatches, the session-wide cap (9) stops the run.
496
- for (let i = 0; i < 4; i++) {
497
- provider.addItem({ subject: `item ${i}`, status: "pending" } as never);
498
- }
499
-
500
- const sent: string[] = [];
501
- const controller = makeController({
502
- provider,
503
- maxSteerAttempts: 3,
504
- dispatch: async (content) => {
505
- sent.push(content);
506
- },
507
- });
508
-
509
- // Alternate between the items: the per-item budget never runs out.
510
- let stopReason = "";
511
- for (let i = 0; i < 20; i++) {
512
- controller.releaseContinuationGuard(`settled ${i}`);
513
- const result = await controller.steerToContinue((i % 4) + 1);
514
- if (!result.dispatched) {
515
- stopReason = result.reason ?? "";
516
- break;
517
- }
518
- }
519
-
520
- expect(controller.getDiagnostics().totalAttempts).toBe(9);
521
- expect(sent.length).toBe(9);
522
- expect(stopReason).toContain("max total attempts");
523
- });
524
-
525
- it("releases the in-flight guard when a dispatch hangs and keeps a newer dispatch safe", async () => {
526
- const provider = new MockTodoProvider();
527
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
528
-
529
- let releaseFirst: (() => void) | null = null;
530
- let sends = 0;
531
- const controller = makeController({
532
- provider,
533
- inFlightTimeoutMs: 30,
534
- dispatch: async () => {
535
- sends++;
536
- if (sends === 1) {
537
- await new Promise<void>((resolve) => {
538
- releaseFirst = resolve;
539
- });
540
- }
541
- },
542
- });
543
-
544
- const first = controller.steerToContinue(1);
545
- await new Promise((r) => setTimeout(r, 60));
546
-
547
- // The watchdog released the hung dispatch.
548
- expect(controller.getDiagnostics().inFlight).toBe(false);
549
-
550
- // The next continuation is dispatched again.
551
- const second = await controller.steerToContinue(1);
552
- expect(second.dispatched).toBe(true);
553
- expect(sends).toBe(2);
554
-
555
- // The stale dispatch settles late: it must not clear the guard of the
556
- // newer dispatch.
557
- releaseFirst?.();
558
- await first;
559
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
560
- expect(controller.getDiagnostics().inFlight).toBe(false);
561
- });
562
-
563
- it("aborts a dispatch that was cancelled while the snapshot was read", async () => {
564
- const provider = new MockTodoProvider();
565
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
566
-
567
- let releaseSnapshot: (() => void) | null = null;
568
- const original = provider.getSnapshot.bind(provider);
569
- (provider as unknown as { getSnapshot: () => Promise<unknown> }).getSnapshot = async () => {
570
- await new Promise<void>((resolve) => {
571
- releaseSnapshot = resolve;
572
- });
573
- return original();
574
- };
575
-
576
- const sent: string[] = [];
577
- const controller = makeController({
578
- provider,
579
- dispatch: async (content) => {
580
- sent.push(content);
581
- },
582
- });
583
-
584
- const pending = controller.steerToContinue(1);
585
- await new Promise((r) => setTimeout(r, 5));
586
-
587
- // The queue is cleared while the snapshot read is still pending.
588
- controller.clearQueue();
589
- releaseSnapshot?.();
590
- const result = await pending;
591
-
592
- expect(result.dispatched).toBe(false);
593
- expect(result.reason).toContain("cancelled before send");
594
- expect(sent.length).toBe(0);
595
- });
596
-
597
- it("aborts a dispatch that was paused while the snapshot was read", async () => {
598
- const provider = new MockTodoProvider();
599
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
600
-
601
- let releaseSnapshot: (() => void) | null = null;
602
- const original = provider.getSnapshot.bind(provider);
603
- (provider as unknown as { getSnapshot: () => Promise<unknown> }).getSnapshot = async () => {
604
- await new Promise<void>((resolve) => {
605
- releaseSnapshot = resolve;
606
- });
607
- return original();
608
- };
609
-
610
- const sent: string[] = [];
611
- const controller = makeController({
612
- provider,
613
- dispatch: async (content) => {
614
- sent.push(content);
615
- },
616
- });
617
-
618
- const pending = controller.steerToContinue(1);
619
- await new Promise((r) => setTimeout(r, 5));
620
-
621
- // The user sends a message while the snapshot read is pending.
622
- controller.tempPause();
623
- releaseSnapshot?.();
624
- const result = await pending;
625
-
626
- expect(result.dispatched).toBe(false);
627
- expect(result.reason).toContain("paused before send");
628
- expect(sent.length).toBe(0);
629
- });
630
-
631
- it("releases the guard when the todo read hangs", async () => {
632
- const provider = new MockTodoProvider();
633
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
634
-
635
- // The provider never answers the FIRST read: the dispatch must not hold
636
- // the guard forever.
637
- const original = provider.getSnapshot.bind(provider);
638
- let hang = true;
639
- (provider as unknown as { getSnapshot: () => Promise<unknown> }).getSnapshot = async () => {
640
- if (hang) {
641
- hang = false;
642
- await new Promise<void>(() => {});
643
- }
644
- return original();
645
- };
646
-
647
- const controller = makeController({
648
- provider,
649
- inFlightTimeoutMs: 30,
650
- dispatch: async () => {},
651
- });
652
-
653
- const pending = controller.steerToContinue(1);
654
- pending.catch(() => {}); // The read never answers: the promise stays pending
655
- await new Promise((r) => setTimeout(r, 60));
656
-
657
- // The watchdog released the guard even though the read never finished.
658
- expect(controller.getDiagnostics().inFlight).toBe(false);
659
- expect(controller.getDiagnostics().continuationQueued).toBe(false);
660
-
661
- // A later dispatch is possible again.
662
- const next = await controller.steerToContinue(1);
663
- expect(next.dispatched).toBe(true);
664
- });
665
-
666
- it("does not let a stale failure clear the guard of a newer dispatch", async () => {
667
- const provider = new MockTodoProvider();
668
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
669
-
670
- let rejectFirst: ((err: Error) => void) | null = null;
671
- let sends = 0;
672
- const controller = makeController({
673
- provider,
674
- inFlightTimeoutMs: 30,
675
- dispatch: async () => {
676
- sends++;
677
- if (sends === 1) {
678
- await new Promise<void>((_resolve, reject) => {
679
- rejectFirst = reject;
680
- });
681
- }
682
- },
683
- });
684
-
685
- const first = controller.steerToContinue(1);
686
- await new Promise((r) => setTimeout(r, 60));
687
-
688
- // The watchdog released the hung dispatch; a new one takes over.
689
- const second = await controller.steerToContinue(1);
690
- expect(second.dispatched).toBe(true);
691
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
692
-
693
- // The stale dispatch fails late: it must not disarm the new guard.
694
- rejectFirst?.(new Error("transport died"));
695
- const firstResult = await first;
696
- expect(firstResult.dispatched).toBe(false);
697
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
698
- });
699
-
700
- it("clears a cancel intent raised while the dispatch hung", async () => {
701
- const provider = new MockTodoProvider();
702
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
703
-
704
- let hangSend = true;
705
- const sent: string[] = [];
706
- const controller = makeController({
707
- provider,
708
- inFlightTimeoutMs: 30,
709
- dispatch: async (content) => {
710
- sent.push(content);
711
- if (hangSend) await new Promise<void>(() => {});
712
- },
713
- });
714
-
715
- const stalled = controller.steerToContinue(1);
716
- stalled.catch(() => {});
717
- await new Promise((r) => setTimeout(r, 5));
718
-
719
- // A cancel intent for the hung dispatch.
720
- controller.clearQueue();
721
- expect(controller.getDiagnostics().cancelRequested).toBe(true);
722
-
723
- // The watchdog force-releases the dispatch and drops the intent: the
724
- // next legitimate dispatch must not be cancelled by it.
725
- await new Promise((r) => setTimeout(r, 60));
726
- expect(controller.getDiagnostics().cancelRequested).toBe(false);
727
-
728
- hangSend = false;
729
- const next = await controller.steerToContinue(1);
730
- expect(next.dispatched).toBe(true);
731
- });
732
-
733
- it("does not let a stale failure trip the circuit breaker", async () => {
734
- const provider = new MockTodoProvider();
735
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
736
-
737
- let rejectStale: ((err: Error) => void) | null = null;
738
- let call = 0;
739
- const controller = makeController({
740
- provider,
741
- inFlightTimeoutMs: 30,
742
- dispatch: async () => {
743
- call++;
744
- if (call === 1) {
745
- // The first send hangs and fails LATE.
746
- await new Promise<void>((_resolve, reject) => {
747
- rejectStale = reject;
748
- });
749
- }
750
- throw new Error("transport is down");
751
- },
752
- });
753
-
754
- const stale = controller.steerToContinue(1);
755
- stale.catch(() => {});
756
- await new Promise((r) => setTimeout(r, 60));
757
- // The watchdog released the first dispatch.
758
- expect(controller.getDiagnostics().consecutiveFailures).toBe(0);
759
-
760
- // The second dispatch fails for real: one failure.
761
- const second = await controller.steerToContinue(1);
762
- expect(second.dispatched).toBe(false);
763
- expect(controller.getDiagnostics().consecutiveFailures).toBe(1);
764
-
765
- // The stale failure arrives late: it must not count.
766
- rejectStale?.(new Error("late failure"));
767
- await new Promise((r) => setTimeout(r, 10));
768
- expect(controller.getDiagnostics().consecutiveFailures).toBe(1);
769
- });
770
-
771
- it("does not let a stale dispatch consume the cancel intent of a newer one", async () => {
772
- const provider = new MockTodoProvider();
773
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
774
-
775
- const original = provider.getSnapshot.bind(provider);
776
- const resolvers: (() => void)[] = [];
777
- let reads = 0;
778
- (provider as unknown as { getSnapshot: () => Promise<unknown> }).getSnapshot = async () => {
779
- reads++;
780
- if (reads <= 2) {
781
- // The first two reads hang: dispatch A and dispatch B.
782
- await new Promise<void>((resolve) => {
783
- resolvers.push(resolve);
784
- });
785
- }
786
- return original();
787
- };
788
-
789
- const sent: string[] = [];
790
- const controller = makeController({
791
- provider,
792
- inFlightTimeoutMs: 30,
793
- dispatch: async (content) => {
794
- sent.push(content);
795
- },
796
- });
797
-
798
- // Dispatch A hangs in the todo read.
799
- const pendingA = controller.steerToContinue(1);
800
- pendingA.catch(() => {});
801
- await new Promise((r) => setTimeout(r, 60));
802
- // The watchdog released A: it is now a stale dispatch.
803
- expect(controller.getDiagnostics().inFlight).toBe(false);
804
-
805
- // Dispatch B takes the guard and hangs in the todo read as well.
806
- const pendingB = controller.steerToContinue(1);
807
- pendingB.catch(() => {});
808
- await new Promise((r) => setTimeout(r, 5));
809
-
810
- // A cancel intent is raised for dispatch B.
811
- controller.clearQueue();
812
- expect(controller.getDiagnostics().cancelRequested).toBe(true);
813
-
814
- // A wakes up: it must report "superseded" and must NOT steal B's
815
- // cancel intent.
816
- resolvers[0]?.();
817
- const staleResult = await pendingA;
818
- expect(staleResult.dispatched).toBe(false);
819
- expect(staleResult.reason).toContain("superseded");
820
- expect(controller.getDiagnostics().cancelRequested).toBe(true);
821
- expect(sent.length).toBe(0);
822
- });
823
-
824
- it("marks a retry after a stalled dispatch and clears the stall afterwards", async () => {
825
- const provider = new MockTodoProvider();
826
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
827
-
828
- let hangSend = true;
829
- const sent: string[] = [];
830
- const controller = makeController({
831
- provider,
832
- inFlightTimeoutMs: 30,
833
- dispatch: async (content) => {
834
- sent.push(content);
835
- if (hangSend) await new Promise<void>(() => {});
836
- },
837
- });
838
-
839
- // The send hangs: the watchdog force-releases the dispatch.
840
- const stalled = controller.steerToContinue(1);
841
- stalled.catch(() => {});
842
- await new Promise((r) => setTimeout(r, 60));
843
- expect(sent.length).toBe(1);
844
- expect(sent[0]).not.toContain("retry after a stalled dispatch");
845
-
846
- // The retry warns about the possible duplicate.
847
- hangSend = false;
848
- const retry = await controller.steerToContinue(1);
849
- expect(retry.dispatched).toBe(true);
850
- expect(sent.length).toBe(2);
851
- expect(sent[1]).toContain("retry after a stalled dispatch");
852
-
853
- // The stall is cleared: a third attempt is a normal continuation.
854
- controller.releaseContinuationGuard("settled");
855
- const third = await controller.steerToContinue(1);
856
- expect(third.dispatched).toBe(true);
857
- expect(sent[2]).not.toContain("retry after a stalled dispatch");
858
- });
859
-
860
- it("marks a retry after a stalled dispatch on the continueItem path too", async () => {
861
- const provider = new MockTodoProvider();
862
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
863
-
864
- let hangSend = true;
865
- const sent: string[] = [];
866
- const controller = makeController({
867
- provider,
868
- inFlightTimeoutMs: 30,
869
- dispatch: async (content) => {
870
- sent.push(content);
871
- if (hangSend) await new Promise<void>(() => {});
872
- },
873
- });
874
-
875
- const item = (await provider.getSnapshot()).items[0];
876
- const stalled = controller.continueItem(item);
877
- stalled.catch(() => {});
878
- await new Promise((r) => setTimeout(r, 60));
879
-
880
- hangSend = false;
881
- await controller.continueItem(item);
882
- expect(sent.length).toBe(2);
883
- expect(sent[1]).toContain("retry after a stalled dispatch");
884
-
885
- // The stall marker is cleared: the next continuation is a normal one.
886
- controller.releaseContinuationGuard("settled");
887
- await controller.continueItem(item);
888
- expect(sent.length).toBe(3);
889
- expect(sent[2]).not.toContain("retry after a stalled dispatch");
890
- });
891
-
892
- it("does not clear the temporary pause while a dispatch is in flight", async () => {
893
- const provider = new MockTodoProvider();
894
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
895
-
896
- let releaseSnapshot: (() => void) | null = null;
897
- const original = provider.getSnapshot.bind(provider);
898
- (provider as unknown as { getSnapshot: () => Promise<unknown> }).getSnapshot = async () => {
899
- await new Promise<void>((resolve) => {
900
- releaseSnapshot = resolve;
901
- });
902
- return original();
903
- };
904
-
905
- const controller = makeController({ provider, dispatch: async () => {} });
906
-
907
- const pending = controller.steerToContinue(1);
908
- await new Promise((r) => setTimeout(r, 5));
909
-
910
- // A user message arrives while the dispatch reads the snapshot.
911
- controller.tempPause();
912
- // A decision that runs concurrently must not drop the pause.
913
- await controller.decide();
914
- expect(controller.getDiagnostics().userPaused).toBe(true);
915
-
916
- releaseSnapshot?.();
917
- const result = await pending;
918
- expect(result.dispatched).toBe(false);
919
- expect(result.reason).toContain("paused before send");
920
- });
921
-
922
- it("records the dropped guard release in the diagnostics", async () => {
923
- const provider = new MockTodoProvider();
924
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
925
-
926
- let releaseSend: (() => void) | null = null;
927
- const controller = makeController({
928
- provider,
929
- dispatch: async () => {
930
- await new Promise<void>((resolve) => {
931
- releaseSend = resolve;
932
- });
933
- },
934
- });
935
-
936
- const pending = controller.steerToContinue(1);
937
- await new Promise((r) => setTimeout(r, 5));
938
-
939
- // A release arrives while the dispatch is on the wire.
940
- controller.releaseContinuationGuard("agent settled");
941
- expect(controller.getDiagnostics().pendingGuardRelease).toBe("agent settled");
942
-
943
- releaseSend?.();
944
- await pending;
945
-
946
- expect(controller.getDiagnostics().pendingGuardRelease).toBeNull();
947
- expect(controller.getDiagnostics().lastDroppedGuardRelease).toBe("agent settled");
948
- });
949
-
950
- it("releases the guard shortly after a dropped mid-dispatch release", async () => {
951
- const provider = new MockTodoProvider();
952
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
953
-
954
- let releaseSend: (() => void) | null = null;
955
- const controller = makeController({
956
- provider,
957
- guardReleaseAfterDropMs: 30,
958
- guardReleaseTimeoutMs: 60_000,
959
- dispatch: async () => {
960
- await new Promise<void>((resolve) => {
961
- releaseSend = resolve;
962
- });
963
- },
964
- });
965
-
966
- const pending = controller.steerToContinue(1);
967
- await new Promise((r) => setTimeout(r, 5));
968
- controller.releaseContinuationGuard("agent settled");
969
- releaseSend?.();
970
- await pending;
971
-
972
- // Right after the drop the guard is still armed...
973
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
974
- // ...but it is released after the short delay, not after the 60s safety
975
- // net: the auto-continue must not stall for a minute.
976
- await new Promise((r) => setTimeout(r, 60));
977
- expect(controller.getDiagnostics().continuationQueued).toBe(false);
978
- });
979
-
980
- it("does not mark an item as stalled when the todo read hangs", async () => {
981
- const provider = new MockTodoProvider();
982
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
983
-
984
- const original = provider.getSnapshot.bind(provider);
985
- let hang = true;
986
- (provider as unknown as { getSnapshot: () => Promise<unknown> }).getSnapshot = async () => {
987
- if (hang) {
988
- hang = false;
989
- await new Promise<void>(() => {});
990
- }
991
- return original();
992
- };
993
-
994
- const sent: string[] = [];
995
- const controller = makeController({
996
- provider,
997
- inFlightTimeoutMs: 30,
998
- dispatch: async (content) => {
999
- sent.push(content);
1000
- },
1001
- });
1002
-
1003
- const pending = controller.steerToContinue(1);
1004
- pending.catch(() => {});
1005
- await new Promise((r) => setTimeout(r, 60));
1006
-
1007
- // The read hung: no message was ever on the wire, so the retry must
1008
- // NOT warn about a possible duplicate.
1009
- const next = await controller.steerToContinue(1);
1010
- expect(next.dispatched).toBe(true);
1011
- expect(sent[0]).not.toContain("retry after a stalled dispatch");
1012
- });
1013
-
1014
- it("does not let a stale success close an open circuit breaker", async () => {
1015
- const provider = new MockTodoProvider();
1016
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1017
-
1018
- let releaseStale: (() => void) | null = null;
1019
- let call = 0;
1020
- const controller = makeController({
1021
- provider,
1022
- inFlightTimeoutMs: 30,
1023
- maxConsecutiveFailures: 1,
1024
- dispatch: async () => {
1025
- call++;
1026
- if (call === 1) {
1027
- // The first send hangs and succeeds LATE.
1028
- await new Promise<void>((resolve) => {
1029
- releaseStale = resolve;
1030
- });
1031
- return;
1032
- }
1033
- throw new Error("transport is down");
1034
- },
1035
- });
1036
-
1037
- const stale = controller.steerToContinue(1);
1038
- stale.catch(() => {});
1039
- await new Promise((r) => setTimeout(r, 60));
1040
- expect(controller.getDiagnostics().consecutiveFailures).toBe(0);
1041
-
1042
- // The second dispatch fails: with maxConsecutiveFailures = 1 the
1043
- // breaker opens.
1044
- const second = await controller.steerToContinue(1);
1045
- expect(second.dispatched).toBe(false);
1046
- expect(controller.getDiagnostics().consecutiveFailures).toBe(1);
1047
- expect(controller.getDiagnostics().circuitBreakerOpen).toBe(true);
1048
-
1049
- // The stale success arrives late: it must not close the breaker.
1050
- releaseStale?.();
1051
- await new Promise((r) => setTimeout(r, 10));
1052
- expect(controller.getDiagnostics().consecutiveFailures).toBe(1);
1053
- expect(controller.getDiagnostics().circuitBreakerOpen).toBe(true);
1054
- });
1055
-
1056
- it("reports a todo item with id 0 in the state", async () => {
1057
- const provider = new MockTodoProvider();
1058
- provider.addItem({ subject: "zero", status: "in_progress" } as never);
1059
- // A provider whose item has the id 0 (a falsy but valid id).
1060
- const original = provider.getSnapshot.bind(provider);
1061
- const originalInProgress = provider.getInProgress.bind(provider);
1062
- const originalReady = provider.getReady.bind(provider);
1063
- const zeroed = <T extends { id: number }>(items: T[]): T[] =>
1064
- items.map((i) => ({ ...i, id: 0 }));
1065
- (provider as unknown as { getSnapshot: () => Promise<unknown> }).getSnapshot = async () => {
1066
- const snapshot = await original();
1067
- return { ...snapshot, items: zeroed(snapshot.items) };
1068
- };
1069
- (provider as unknown as { getInProgress: () => Promise<unknown> }).getInProgress = async () =>
1070
- zeroed(await originalInProgress());
1071
- (provider as unknown as { getReady: () => Promise<unknown> }).getReady = async () =>
1072
- zeroed(await originalReady());
1073
-
1074
- const controller = makeController({ provider, dispatch: async () => {} });
1075
- const result = await controller.decide();
1076
- expect(result.action).toBe("continue");
1077
- // id 0 must not be reported as "no item".
1078
- expect(controller.getState().current).toEqual({ id: 0 });
1079
- expect(controller.getState().next).toEqual({ id: 0 });
1080
- });
1081
-
1082
- it("clears the selected item from the state when there is nothing to do", async () => {
1083
- const provider = new MockTodoProvider();
1084
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1085
-
1086
- const controller = makeController({ provider, dispatch: async () => {} });
1087
- const result = await controller.decide();
1088
- expect(result.action).toBe("continue");
1089
- expect(controller.getState().current).not.toBeNull();
1090
-
1091
- // The work is done: the next decision stops and the state no longer
1092
- // reports the last continued item.
1093
- provider.setStatus(1, "completed");
1094
- const stopped = await controller.decide();
1095
- expect(stopped.action).toBe("stop");
1096
- expect(controller.getState().current).toBeNull();
1097
- expect(controller.getState().next).toBeNull();
1098
- });
1099
-
1100
- it("reports the delivered message only after a successful send", async () => {
1101
- const provider = new MockTodoProvider();
1102
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1103
-
1104
- const controller = makeController({
1105
- provider,
1106
- dispatch: async () => {
1107
- throw new Error("transport died");
1108
- },
1109
- });
1110
-
1111
- await controller.steerToContinue(1);
1112
- expect(controller.getDiagnostics().lastSteerMessage).toBeNull();
1113
- });
1114
-
1115
- it("resets the cancel intent and the diagnostics on dispose()", async () => {
1116
- const provider = new MockTodoProvider();
1117
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1118
-
1119
- let releaseSnapshot: (() => void) | null = null;
1120
- const original = provider.getSnapshot.bind(provider);
1121
- (provider as unknown as { getSnapshot: () => Promise<unknown> }).getSnapshot = async () => {
1122
- await new Promise<void>((resolve) => {
1123
- releaseSnapshot = resolve;
1124
- });
1125
- return original();
1126
- };
1127
-
1128
- const controller = makeController({ provider, dispatch: async () => {} });
1129
-
1130
- const pending = controller.steerToContinue(1);
1131
- await new Promise((r) => setTimeout(r, 5));
1132
- controller.clearQueue();
1133
- expect(controller.getDiagnostics().cancelRequested).toBe(true);
1134
-
1135
- controller.dispose();
1136
- expect(controller.getDiagnostics().cancelRequested).toBe(false);
1137
- expect(controller.getDiagnostics().disposed).toBe(true);
1138
-
1139
- releaseSnapshot?.();
1140
- await pending;
1141
- });
1142
-
1143
- it("auto-releases the guard when the release timer expires", async () => {
1144
- const provider = new MockTodoProvider();
1145
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1146
-
1147
- const controller = makeController({
1148
- provider,
1149
- guardReleaseTimeoutMs: 30,
1150
- dispatch: async () => {},
1151
- });
1152
-
1153
- await controller.steerToContinue(1);
1154
- expect(controller.getDiagnostics().continuationQueued).toBe(true);
1155
-
1156
- // The safety net for a lost agent_settled event.
1157
- await new Promise((r) => setTimeout(r, 60));
1158
- expect(controller.getDiagnostics().continuationQueued).toBe(false);
1159
- });
1160
-
1161
- it("does not dispatch while a temporary pause is active and resumes afterwards", async () => {
1162
- const provider = new MockTodoProvider();
1163
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1164
-
1165
- const sent: string[] = [];
1166
- const controller = makeController({
1167
- provider,
1168
- dispatch: async (content) => {
1169
- sent.push(content);
1170
- },
1171
- });
1172
-
1173
- controller.tempPause();
1174
- const blocked = await controller.steerToContinue(1);
1175
- expect(blocked.dispatched).toBe(false);
1176
- expect(blocked.reason).toContain("user input pending");
1177
- expect(sent.length).toBe(0);
1178
-
1179
- // The next decision clears the temporary pause.
1180
- await controller.decide();
1181
- const ok = await controller.steerToContinue(1);
1182
- expect(ok.dispatched).toBe(true);
1183
- expect(sent.length).toBe(1);
1184
- });
1185
-
1186
- it("does not dispatch after dispose()", async () => {
1187
- const provider = new MockTodoProvider();
1188
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1189
-
1190
- const sent: string[] = [];
1191
- const controller = makeController({
1192
- provider,
1193
- dispatch: async (content) => {
1194
- sent.push(content);
1195
- },
1196
- });
1197
-
1198
- controller.dispose();
1199
- const result = await controller.steerToContinue(1);
1200
- expect(result.dispatched).toBe(false);
1201
- expect(result.reason).toContain("disposed");
1202
- expect(sent.length).toBe(0);
1203
- });
1204
-
1205
- it("allows a trial dispatch after the circuit breaker cooldown", async () => {
1206
- const provider = new MockTodoProvider();
1207
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1208
-
1209
- let fail = true;
1210
- let sends = 0;
1211
- const controller = makeController({
1212
- provider,
1213
- circuitBreakerCooldownMs: 30,
1214
- dispatch: async () => {
1215
- sends++;
1216
- if (fail) throw new Error("transport is down");
1217
- },
1218
- });
1219
-
1220
- for (let i = 0; i < 3; i++) await controller.steerToContinue(1);
1221
- expect(controller.getDiagnostics().circuitBreakerOpen).toBe(true);
1222
-
1223
- await new Promise((r) => setTimeout(r, 60));
1224
- expect(controller.getDiagnostics().circuitBreakerOpen).toBe(false);
1225
-
1226
- // The transport is still broken: the trial fails and the breaker opens
1227
- // again.
1228
- fail = true;
1229
- const trial = await controller.steerToContinue(1);
1230
- expect(trial.dispatched).toBe(false);
1231
- expect(sends).toBe(4);
1232
- });
1233
- });
1234
-
1235
- describe("TodoContinuationController - staged validation context", () => {
1236
- it("adds the declared validation plan to the continuation message", async () => {
1237
- const provider = new MockTodoProvider();
1238
- provider.addItem({
1239
- subject: "seed data",
1240
- status: "in_progress",
1241
- metadata: {
1242
- validationPlan: {
1243
- fast: { command: "bun test tests/fast", expectedDurationMs: 18_000, datasetSize: 100 },
1244
- full: { command: "bun test tests/full", expectedDurationMs: 2_400_000, datasetSize: 10_000 },
1245
- budgetMinutes: 90,
1246
- finalTierRequired: true,
1247
- },
1248
- },
1249
- } as never);
1250
-
1251
- const sent: string[] = [];
1252
- const controller = makeController({
1253
- provider,
1254
- dispatch: async (content) => {
1255
- sent.push(content);
1256
- },
1257
- });
1258
-
1259
- const result = await controller.steerToContinue(1);
1260
- expect(result.dispatched).toBe(true);
1261
- expect(sent.length).toBe(1);
1262
-
1263
- const message = sent[0];
1264
- expect(message).toContain("Continue todo item ID 1.");
1265
- expect(message).toContain("VALIDATION PLAN (staged)");
1266
- expect(message).toContain("bun test tests/fast");
1267
- expect(message).toContain("bun test tests/full");
1268
- expect(message).toContain("Run the fast tier after every edit");
1269
- expect(message).toContain("Time budget: 90m");
1270
- });
1271
-
1272
- it("leaves the message untouched when no plan is declared", async () => {
1273
- const provider = new MockTodoProvider();
1274
- provider.addItem({ subject: "keep going", status: "in_progress" } as never);
1275
-
1276
- const sent: string[] = [];
1277
- const controller = makeController({
1278
- provider,
1279
- dispatch: async (content) => {
1280
- sent.push(content);
1281
- },
1282
- });
1283
-
1284
- await controller.steerToContinue(1);
1285
- expect(sent[0]).toBe("Continue todo item ID 1.");
1286
- expect(sent[0]).not.toContain("VALIDATION PLAN");
1287
- });
1288
- });
1289
-
1290
- describe("TodoContinuationController - validation budget and ledger", () => {
1291
- it("pauses the item when the declared time budget is spent", async () => {
1292
- const provider = new MockTodoProvider();
1293
- provider.addItem({
1294
- subject: "budgeted work",
1295
- status: "in_progress",
1296
- metadata: {
1297
- validationPlan: {
1298
- fast: { command: "bun test tests/fast" },
1299
- budgetMinutes: 0,
1300
- },
1301
- },
1302
- } as never);
1303
-
1304
- const sent: string[] = [];
1305
- const controller = makeController({
1306
- provider,
1307
- dispatch: async (content) => {
1308
- sent.push(content);
1309
- },
1310
- });
1311
-
1312
- const result = await controller.decide();
1313
- expect(result.action).toBe("stop");
1314
- expect(result.reason).toContain("budget exhausted");
1315
- expect(result.budget?.exhausted).toBe(true);
1316
- expect(sent.length).toBe(0);
1317
- });
1318
-
1319
- it("does not put a continuation on the wire when the budget is spent", async () => {
1320
- const provider = new MockTodoProvider();
1321
- provider.addItem({
1322
- subject: "budgeted work",
1323
- status: "in_progress",
1324
- metadata: {
1325
- validationPlan: {
1326
- fast: { command: "bun test tests/fast" },
1327
- budgetMinutes: 0.001, // 60ms
1328
- },
1329
- },
1330
- } as never);
1331
-
1332
- const sent: string[] = [];
1333
- const controller = makeController({
1334
- provider,
1335
- dispatch: async (content) => {
1336
- sent.push(content);
1337
- },
1338
- });
1339
-
1340
- // The first continuation is inside the budget.
1341
- const first = await controller.steerToContinue(1);
1342
- expect(first.dispatched).toBe(true);
1343
- expect(sent.length).toBe(1);
1344
-
1345
- // After the budget is spent the item pauses at a safe boundary.
1346
- await new Promise((resolve) => setTimeout(resolve, 90));
1347
- controller.releaseContinuationGuard("settled");
1348
- const second = await controller.steerToContinue(1);
1349
- expect(second.dispatched).toBe(false);
1350
- expect(second.reason).toContain("budget exhausted");
1351
- expect(sent.length).toBe(1);
1352
- });
1353
-
1354
- it("reports the recorded tier state in the continuation message", async () => {
1355
- const dir = mkdtempSync(join(tmpdir(), "validation-ledger-"));
1356
- try {
1357
- const provider = new MockTodoProvider();
1358
- provider.addItem({
1359
- subject: "staged work",
1360
- status: "in_progress",
1361
- metadata: {
1362
- validationPlan: {
1363
- fast: { command: "bun test tests/fast" },
1364
- full: { command: "bun test tests/full" },
1365
- finalTierRequired: true,
1366
- },
1367
- },
1368
- } as never);
1369
-
1370
- const ledger = new ValidationLedger({ path: join(dir, "runs.jsonl") });
1371
- ledger.record({ tier: "fast", status: "passed", itemId: 1 });
1372
-
1373
- const sent: string[] = [];
1374
- const controller = new TodoContinuationController(makeCtx(), {
1375
- dispatch: async (content) => {
1376
- sent.push(content);
1377
- },
1378
- todoProvider: provider,
1379
- validationLedger: ledger,
1380
- });
1381
-
1382
- await controller.steerToContinue(1);
1383
- expect(sent[0]).toContain("Fast tier: passed");
1384
- expect(sent[0]).toContain("do not complete this todo while it is pending or failed");
1385
-
1386
- // Once the final tier passed, the message says so.
1387
- ledger.record({ tier: "full", status: "passed", itemId: 1 });
1388
- controller.releaseContinuationGuard("settled");
1389
- await controller.steerToContinue(1);
1390
- expect(sent[1]).toContain("Full tier: passed");
1391
- expect(sent[1]).toContain("The final tier already passed");
1392
- } finally {
1393
- rmSync(dir, { recursive: true, force: true });
1394
- }
1395
- });
1396
- });