pi-harness-runtime 1.1.439 → 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 (359) hide show
  1. package/harness/e2e/auto-test-loop.ts +8 -2
  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/index.ts +2 -1
  7. package/harness/key-loader.ts +2 -0
  8. package/harness/langchain/checkpointer.ts +2 -0
  9. package/harness/langchain/cron-cli.ts +2 -0
  10. package/harness/langchain/graph.ts +2 -0
  11. package/harness/master-planner.ts +1 -1
  12. package/harness/notification-center.ts +2 -0
  13. package/harness/output-limit-handler.ts +1 -1
  14. package/harness/project-detector/detector.ts +2 -4
  15. package/harness/secret-provider.ts +2 -0
  16. package/harness/task-graph.ts +460 -113
  17. package/index.ts +16 -7
  18. package/package.json +7 -1
  19. package/packages/a2a-adapter/package.json +1 -1
  20. package/packages/agent-policy/dist/extension.d.ts.map +1 -1
  21. package/packages/agent-policy/dist/extension.js +2 -0
  22. package/packages/agent-policy/dist/extension.js.map +1 -1
  23. package/packages/agent-policy/package.json +1 -1
  24. package/packages/agent-policy/src/extension.ts +2 -0
  25. package/packages/architecture-generator/package.json +1 -1
  26. package/packages/auth/dist/minimax-browser-auth.d.ts.map +1 -1
  27. package/packages/auth/dist/minimax-browser-auth.js +23 -1
  28. package/packages/auth/dist/minimax-browser-auth.js.map +1 -1
  29. package/packages/auth/package.json +1 -1
  30. package/packages/auth/src/minimax-browser-auth.ts +30 -5
  31. package/packages/autonomous-refactor/package.json +1 -1
  32. package/packages/autonomous-runtime/dist/inbox.d.ts.map +1 -1
  33. package/packages/autonomous-runtime/dist/inbox.js +2 -0
  34. package/packages/autonomous-runtime/dist/inbox.js.map +1 -1
  35. package/packages/autonomous-runtime/package.json +1 -1
  36. package/packages/autonomous-runtime/src/inbox.ts +2 -0
  37. package/packages/cache-strategy/package.json +1 -1
  38. package/packages/capability-registry/package.json +1 -1
  39. package/packages/checkpoint/package.json +1 -1
  40. package/packages/cli-plugin-sdk/package.json +1 -1
  41. package/packages/clipboard/dist/src/clipboard.d.ts.map +1 -1
  42. package/packages/clipboard/dist/src/clipboard.js +2 -0
  43. package/packages/clipboard/dist/src/clipboard.js.map +1 -1
  44. package/packages/clipboard/dist/src/copy-sync.d.ts.map +1 -1
  45. package/packages/clipboard/dist/src/copy-sync.js +2 -0
  46. package/packages/clipboard/dist/src/copy-sync.js.map +1 -1
  47. package/packages/clipboard/dist/src/gist-relay.d.ts.map +1 -1
  48. package/packages/clipboard/dist/src/gist-relay.js +2 -0
  49. package/packages/clipboard/dist/src/gist-relay.js.map +1 -1
  50. package/packages/clipboard/package.json +1 -1
  51. package/packages/clipboard/src/clipboard.ts +2 -0
  52. package/packages/clipboard/src/copy-sync.ts +2 -0
  53. package/packages/clipboard/src/gist-relay.ts +2 -0
  54. package/packages/clipboard-plugin/dist/types.d.ts +9 -2
  55. package/packages/clipboard-plugin/dist/types.d.ts.map +1 -1
  56. package/packages/clipboard-plugin/package.json +1 -1
  57. package/packages/clipboard-plugin/src/types.ts +9 -2
  58. package/packages/code-generation/package.json +1 -1
  59. package/packages/code-review/package.json +1 -1
  60. package/packages/codex-adapter/package.json +1 -1
  61. package/packages/codex-watcher/package.json +1 -1
  62. package/packages/config-capture/package.json +1 -1
  63. package/packages/connection-runtime/package.json +1 -1
  64. package/packages/context-compiler/package.json +1 -1
  65. package/packages/context-discovery/package.json +1 -1
  66. package/packages/context-manager/package.json +1 -1
  67. package/packages/contracts/dist/delegation-store.js +1 -4
  68. package/packages/contracts/dist/workspace-env.js +28 -1
  69. package/packages/contracts/package.json +1 -1
  70. package/packages/contracts/src/delegation-store.ts +9 -4
  71. package/packages/contracts/src/workspace-env.ts +29 -4
  72. package/packages/cookie-sanitizer/package.json +1 -1
  73. package/packages/cost-optimizer/package.json +1 -1
  74. package/packages/dependency-analyzer/package.json +1 -1
  75. package/packages/django-plugin/package.json +1 -1
  76. package/packages/doc-generator/package.json +1 -1
  77. package/packages/durable-review/package.json +1 -1
  78. package/packages/e2e-tester/package.json +1 -1
  79. package/packages/evaluation-engine/package.json +1 -1
  80. package/packages/evaluation-runner/package.json +1 -1
  81. package/packages/event-bus/package.json +1 -1
  82. package/packages/event-sink/package.json +1 -1
  83. package/packages/event-store/dist/src/store.d.ts.map +1 -1
  84. package/packages/event-store/dist/src/store.js +2 -0
  85. package/packages/event-store/dist/src/store.js.map +1 -1
  86. package/packages/event-store/package.json +1 -1
  87. package/packages/event-store/src/store.ts +3 -0
  88. package/packages/experience-replay/package.json +1 -1
  89. package/packages/feedback-collector/package.json +1 -1
  90. package/packages/file-copy-helper/package.json +1 -1
  91. package/packages/framework-detector/package.json +1 -1
  92. package/packages/framework-plugin-sdk/package.json +1 -1
  93. package/packages/frappe-plugin/package.json +1 -1
  94. package/packages/generic-web-plugin/dist/analyzer.d.ts.map +1 -1
  95. package/packages/generic-web-plugin/dist/analyzer.js +2 -0
  96. package/packages/generic-web-plugin/dist/analyzer.js.map +1 -1
  97. package/packages/generic-web-plugin/package.json +1 -1
  98. package/packages/generic-web-plugin/src/analyzer.ts +3 -0
  99. package/packages/health-monitor/package.json +1 -1
  100. package/packages/intent-analyzer/package.json +1 -1
  101. package/packages/jev-judge/package.json +1 -1
  102. package/packages/knowledge-graph/package.json +1 -1
  103. package/packages/knowledge-retrieval/dist/mcp-client.d.ts.map +1 -1
  104. package/packages/knowledge-retrieval/dist/mcp-client.js +0 -1
  105. package/packages/knowledge-retrieval/dist/mcp-client.js.map +1 -1
  106. package/packages/knowledge-retrieval/package.json +1 -4
  107. package/packages/knowledge-retrieval/src/mcp-client.ts +0 -1
  108. package/packages/laravel-plugin/package.json +1 -1
  109. package/packages/learning-engine/package.json +1 -1
  110. package/packages/logging/package.json +1 -1
  111. package/packages/mcp-adapter/dist/server.d.ts.map +1 -1
  112. package/packages/mcp-adapter/dist/server.js +2 -0
  113. package/packages/mcp-adapter/dist/server.js.map +1 -1
  114. package/packages/mcp-adapter/package.json +1 -1
  115. package/packages/mcp-adapter/src/server.ts +3 -0
  116. package/packages/memory-engine/package.json +1 -1
  117. package/packages/milestone-manager/package.json +1 -1
  118. package/packages/model-registry/package.json +1 -1
  119. package/packages/nextjs-plugin/package.json +1 -1
  120. package/packages/notification/package.json +1 -1
  121. package/packages/observability/package.json +1 -1
  122. package/packages/okf-indexer/package.json +1 -1
  123. package/packages/paperclip-integration/dist/index.d.ts.map +1 -1
  124. package/packages/paperclip-integration/dist/index.js +2 -0
  125. package/packages/paperclip-integration/dist/index.js.map +1 -1
  126. package/packages/paperclip-integration/package.json +1 -1
  127. package/packages/paperclip-integration/src/index.ts +2 -0
  128. package/packages/performance-optimizer/package.json +1 -1
  129. package/packages/permission-mode/package.json +1 -1
  130. package/packages/privilege-broker/dist/audit.d.ts.map +1 -1
  131. package/packages/privilege-broker/dist/audit.js +2 -0
  132. package/packages/privilege-broker/dist/audit.js.map +1 -1
  133. package/packages/privilege-broker/package.json +1 -1
  134. package/packages/privilege-broker/src/audit.ts +2 -0
  135. package/packages/project-analyzer/package.json +1 -1
  136. package/packages/project-bootstrap/package.json +1 -1
  137. package/packages/projection-engine/package.json +1 -1
  138. package/packages/prompt-compiler/dist/compiler.d.ts.map +1 -1
  139. package/packages/prompt-compiler/dist/compiler.js +2 -0
  140. package/packages/prompt-compiler/dist/compiler.js.map +1 -1
  141. package/packages/prompt-compiler/package.json +1 -1
  142. package/packages/prompt-compiler/src/compiler.ts +2 -0
  143. package/packages/prompt-versioning/package.json +1 -1
  144. package/packages/provider-adapter-sdk/package.json +1 -1
  145. package/packages/provider-router/package.json +1 -1
  146. package/packages/provider-selector/package.json +1 -1
  147. package/packages/providers/package.json +1 -1
  148. package/packages/qdrant-skills/package.json +1 -1
  149. package/packages/quota-manager/package.json +1 -1
  150. package/packages/rate-limiter/package.json +1 -1
  151. package/packages/react-vite-plugin/package.json +1 -1
  152. package/packages/relay/package.json +1 -1
  153. package/packages/release-manager/package.json +1 -1
  154. package/packages/requirement-compiler/package.json +1 -1
  155. package/packages/runtime/package.json +1 -1
  156. package/packages/scheduler/package.json +1 -1
  157. package/packages/scheduler-adapter/package.json +1 -1
  158. package/packages/session/package.json +1 -1
  159. package/packages/session-api/package.json +1 -1
  160. package/packages/session-export/package.json +1 -1
  161. package/packages/shared-context/package.json +1 -1
  162. package/packages/skill-mcp-client/dist/client.d.ts.map +1 -1
  163. package/packages/skill-mcp-client/dist/client.js +0 -1
  164. package/packages/skill-mcp-client/dist/client.js.map +1 -1
  165. package/packages/skill-mcp-client/package.json +1 -4
  166. package/packages/skill-mcp-client/src/client.ts +0 -1
  167. package/packages/skill-registry/package.json +1 -1
  168. package/packages/skills/dist/parser.d.ts.map +1 -1
  169. package/packages/skills/dist/parser.js +2 -0
  170. package/packages/skills/dist/parser.js.map +1 -1
  171. package/packages/skills/dist/skill-versioning.d.ts.map +1 -1
  172. package/packages/skills/dist/skill-versioning.js +2 -0
  173. package/packages/skills/dist/skill-versioning.js.map +1 -1
  174. package/packages/skills/package.json +1 -1
  175. package/packages/skills/src/parser.ts +3 -0
  176. package/packages/skills/src/skill-versioning.ts +2 -0
  177. package/packages/sprint-planner/package.json +1 -1
  178. package/packages/subscription-engine/package.json +1 -1
  179. package/packages/task-compiler/package.json +1 -1
  180. package/packages/tencentdb-memory/package.json +1 -1
  181. package/packages/tencentdb-sync/package.json +1 -1
  182. package/packages/test-data-generator/package.json +1 -1
  183. package/packages/test-generator/package.json +1 -1
  184. package/packages/todo-bd-sync/package.json +1 -1
  185. package/packages/token-estimation/package.json +1 -1
  186. package/packages/token-optimizer/package.json +1 -1
  187. package/packages/tooling-preference/package.json +1 -1
  188. package/packages/trajectory/package.json +1 -1
  189. package/packages/tui/package.json +1 -1
  190. package/packages/two-phase-review/package.json +1 -1
  191. package/packages/types/package.json +1 -1
  192. package/packages/voice-api/package.json +1 -1
  193. package/packages/webmcp-tester/dist/index.js +0 -2
  194. package/packages/webmcp-tester/package.json +1 -1
  195. package/packages/webmcp-tester/src/index.ts +0 -3
  196. package/packages/wiki/package.json +1 -1
  197. package/packages/workflow-events/package.json +1 -1
  198. package/packages/workspace-scanner/package.json +1 -1
  199. package/packages/worktree/package.json +1 -1
  200. package/packages/write-review/dist/trigger.d.ts.map +1 -1
  201. package/packages/write-review/dist/trigger.js +1 -2
  202. package/packages/write-review/dist/trigger.js.map +1 -1
  203. package/packages/write-review/package.json +1 -1
  204. package/packages/write-review/src/trigger.ts +1 -2
  205. package/scripts/lib/runtime-imports.ts +189 -3
  206. package/scripts/release-all.ts +5 -3
  207. package/scripts/telegram-webhook-server.ts +2 -0
  208. package/scripts/typecheck-packages.mjs +7 -4
  209. package/scripts/verify-package-contents.ts +161 -2
  210. package/harness/e2e/tools-detector.test.ts +0 -156
  211. package/harness/glm-quota-countdown.test.ts +0 -323
  212. package/harness/langchain/agents.test.ts +0 -39
  213. package/harness/langchain/checkpointer.test.ts +0 -243
  214. package/harness/langchain/daemon.test.ts +0 -459
  215. package/harness/langchain/model-router.test.ts +0 -191
  216. package/harness/langchain/ping-pong-middleware.test.ts +0 -69
  217. package/harness/langchain/scoreboard.test.ts +0 -245
  218. package/harness/langchain/status-line.test.ts +0 -106
  219. package/harness/langchain/surge.test.ts +0 -467
  220. package/harness/langchain/widget.test.ts +0 -251
  221. package/harness/skill-sync.test.ts +0 -57
  222. package/harness/ssh-detach-interceptor.test.ts +0 -263
  223. package/harness/ssh-hang-recovery.test.ts +0 -86
  224. package/packages/a2a-adapter/test/a2a.test.ts +0 -151
  225. package/packages/agent-policy/test/interlock.test.ts +0 -101
  226. package/packages/agent-policy/test/policy-retention.test.ts +0 -380
  227. package/packages/agent-policy/test/policy.test.ts +0 -387
  228. package/packages/auth/test/auth.test.ts +0 -238
  229. package/packages/autonomous-refactor/test/refactor.test.ts +0 -202
  230. package/packages/autonomous-runtime/test/types.test.ts +0 -95
  231. package/packages/cache-strategy/test/cache.test.ts +0 -137
  232. package/packages/capability-registry/test/registry.test.ts +0 -204
  233. package/packages/checkpoint/test/engine.test.ts +0 -265
  234. package/packages/cli-plugin-sdk/test/cli.test.ts +0 -45
  235. package/packages/code-generation/test/generator.test.ts +0 -238
  236. package/packages/code-review/test/engine.test.ts +0 -302
  237. package/packages/codex-adapter/test/provider.test.ts +0 -142
  238. package/packages/codex-watcher/test/watch.test.ts +0 -453
  239. package/packages/context-compiler/test/context-compiler.test.ts +0 -746
  240. package/packages/context-manager/test/context.test.ts +0 -198
  241. package/packages/contracts/test/delegation.test.ts +0 -350
  242. package/packages/contracts/test/workspace-env.test.ts +0 -802
  243. package/packages/cookie-sanitizer/test/infer.test.ts +0 -110
  244. package/packages/cookie-sanitizer/test/normalize.test.ts +0 -113
  245. package/packages/cookie-sanitizer/test/parse.test.ts +0 -204
  246. package/packages/cookie-sanitizer/test/sync.test.ts +0 -237
  247. package/packages/cookie-sanitizer/test/watcher.test.ts +0 -27
  248. package/packages/cost-optimizer/test/cost-optimizer.test.ts +0 -438
  249. package/packages/dependency-analyzer/test/analyzer.test.ts +0 -49
  250. package/packages/django-plugin/test/analyzer.test.ts +0 -195
  251. package/packages/doc-generator/test/docs.test.ts +0 -168
  252. package/packages/durable-review/test/acceptance.test.ts +0 -357
  253. package/packages/durable-review/test/smoke.test.ts +0 -107
  254. package/packages/evaluation-engine/test/engine.test.ts +0 -277
  255. package/packages/evaluation-runner/test/runner.test.ts +0 -168
  256. package/packages/event-bus/test/bus.test.ts +0 -100
  257. package/packages/event-store/test/store.test.ts +0 -115
  258. package/packages/experience-replay/test/replay.test.ts +0 -267
  259. package/packages/feedback-collector/test/collector.test.ts +0 -142
  260. package/packages/framework-detector/test/detector.test.ts +0 -75
  261. package/packages/framework-plugin-sdk/test/manager.test.ts +0 -320
  262. package/packages/frappe-plugin/test/analyzer.test.ts +0 -250
  263. package/packages/generic-web-plugin/test/analyzer.test.ts +0 -134
  264. package/packages/health-monitor/test/health.test.ts +0 -231
  265. package/packages/intent-analyzer/test/analyzer.test.ts +0 -72
  266. package/packages/intent-analyzer/test/ping-pong.test.ts +0 -96
  267. package/packages/jev-judge/tests/auto-continue-context.test.ts +0 -280
  268. package/packages/jev-judge/tests/continuation-controller.test.ts +0 -1396
  269. package/packages/jev-judge/tests/jev-provider-chain.test.ts +0 -536
  270. package/packages/jev-judge/tests/outcome-application.test.ts +0 -132
  271. package/packages/jev-judge/tests/question-risk.test.ts +0 -129
  272. package/packages/jev-judge/tests/typesafe-provider.test.ts +0 -640
  273. package/packages/jev-judge/tests/validation-ledger.test.ts +0 -181
  274. package/packages/jev-judge/tests/validation-plan.test.ts +0 -170
  275. package/packages/jev-judge/tests/validation-tie-breaker.test.ts +0 -88
  276. package/packages/laravel-plugin/test/analyzer.test.ts +0 -220
  277. package/packages/learning-engine/test/engine.test.ts +0 -331
  278. package/packages/logging/test/buffer.test.ts +0 -44
  279. package/packages/logging/test/classification.test.ts +0 -137
  280. package/packages/logging/test/config.test.ts +0 -97
  281. package/packages/logging/test/fanout.test.ts +0 -186
  282. package/packages/logging/test/identity-transport.test.ts +0 -197
  283. package/packages/logging/test/incidents.test.ts +0 -404
  284. package/packages/logging/test/ingest-schema.test.ts +0 -184
  285. package/packages/logging/test/integration.test.ts +0 -140
  286. package/packages/logging/test/log-http.test.ts +0 -246
  287. package/packages/logging/test/redact.test.ts +0 -109
  288. package/packages/logging/test/sampler.test.ts +0 -57
  289. package/packages/logging/test/server.test.ts +0 -444
  290. package/packages/logging/test/shipper.test.ts +0 -152
  291. package/packages/logging/test/spool.test.ts +0 -96
  292. package/packages/mcp-adapter/test/server.test.ts +0 -91
  293. package/packages/memory-engine/test/engine.test.ts +0 -456
  294. package/packages/model-registry/test/registry.test.ts +0 -342
  295. package/packages/nextjs-plugin/test/analyzer.test.ts +0 -149
  296. package/packages/notification/test/approval-payload.test.ts +0 -269
  297. package/packages/notification/test/detail-format.test.ts +0 -106
  298. package/packages/notification/test/notification.test.ts +0 -375
  299. package/packages/notification/test/pending-question-lifecycle.test.ts +0 -195
  300. package/packages/notification/test/pending-question.test.ts +0 -331
  301. package/packages/notification/test/tooling-proposal-payload.test.ts +0 -212
  302. package/packages/observability/test/observability.test.ts +0 -1350
  303. package/packages/performance-optimizer/test/optimizer.test.ts +0 -267
  304. package/packages/project-analyzer/test/index.test.ts +0 -1719
  305. package/packages/prompt-compiler/test/prompt-compiler.test.ts +0 -813
  306. package/packages/prompt-versioning/test/versioning.test.ts +0 -112
  307. package/packages/provider-adapter-sdk/test/sdk.test.ts +0 -220
  308. package/packages/provider-router/test/provider-router.test.ts +0 -405
  309. package/packages/provider-selector/test/selector.test.ts +0 -192
  310. package/packages/providers/test/providers.test.ts +0 -225
  311. package/packages/qdrant-skills/test/collection-info.test.ts +0 -165
  312. package/packages/qdrant-skills/test/embedder-batch.test.ts +0 -184
  313. package/packages/qdrant-skills/test/embedder.test.ts +0 -97
  314. package/packages/quota-manager/test/quota.test.ts +0 -432
  315. package/packages/rate-limiter/test/limiter.test.ts +0 -128
  316. package/packages/react-vite-plugin/test/analyzer.test.ts +0 -139
  317. package/packages/relay/test/client.test.ts +0 -446
  318. package/packages/relay/test/relay-e2e.test.ts +0 -299
  319. package/packages/relay/test/relay-endpoint.test.ts +0 -278
  320. package/packages/release-manager/test/release.test.ts +0 -215
  321. package/packages/requirement-compiler/test/requirement-compiler.test.ts +0 -743
  322. package/packages/runtime/test/runtime-api.test.ts +0 -169
  323. package/packages/runtime/test/runtime.test.ts +0 -297
  324. package/packages/scheduler/test/scheduler.test.ts +0 -125
  325. package/packages/session/test/manager.test.ts +0 -233
  326. package/packages/shared-context/test/shared-context.test.ts +0 -114
  327. package/packages/skill-registry/test/registry.test.ts +0 -508
  328. package/packages/skills/test/init-skills.test.ts +0 -114
  329. package/packages/skills/test/remote-skills.test.ts +0 -360
  330. package/packages/skills/test/remote-source.test.ts +0 -248
  331. package/packages/skills/test/sanitize.test.ts +0 -250
  332. package/packages/skills/test/vector-indexing.test.ts +0 -205
  333. package/packages/skills/test/vector-schema.test.ts +0 -180
  334. package/packages/skills/test/vector-threshold.test.ts +0 -108
  335. package/packages/sprint-planner/test/planner.test.ts +0 -187
  336. package/packages/task-compiler/test/task-compiler.test.ts +0 -628
  337. package/packages/test-data-generator/test/generator.test.ts +0 -265
  338. package/packages/test-generator/test/generator.test.ts +0 -156
  339. package/packages/token-estimation/test/token-estimation.test.ts +0 -207
  340. package/packages/token-optimizer/test/optimizer.test.ts +0 -141
  341. package/packages/tooling-preference/tests/changed-files.test.ts +0 -124
  342. package/packages/tooling-preference/tests/detect.test.ts +0 -188
  343. package/packages/tooling-preference/tests/proposal.test.ts +0 -107
  344. package/packages/tooling-preference/tests/quality-check.test.ts +0 -272
  345. package/packages/tooling-preference/tests/registry.test.ts +0 -51
  346. package/packages/tooling-preference/tests/store.test.ts +0 -99
  347. package/packages/trajectory/test/store.test.ts +0 -250
  348. package/packages/tui/test/harness-footer.test.ts +0 -559
  349. package/packages/tui/test/harness-header.test.ts +0 -590
  350. package/packages/two-phase-review/test/classify.test.ts +0 -102
  351. package/packages/two-phase-review/test/helpers.ts +0 -72
  352. package/packages/two-phase-review/test/rules.test.ts +0 -150
  353. package/packages/two-phase-review/test/runtime.test.ts +0 -362
  354. package/packages/two-phase-review/test/scope.test.ts +0 -186
  355. package/packages/two-phase-review/test/signals.test.ts +0 -183
  356. package/packages/two-phase-review/test/state.test.ts +0 -201
  357. package/packages/wiki/test/basic.test.ts +0 -242
  358. package/packages/workspace-scanner/test/scanner.test.ts +0 -43
  359. 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
- });