lastlight 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (385) hide show
  1. package/README.md +71 -692
  2. package/dist/cli-config.js.map +1 -0
  3. package/dist/cli-format.js.map +1 -0
  4. package/dist/{cli/cli-server.d.ts → cli-server.d.ts} +39 -1
  5. package/dist/{cli/cli-server.js → cli-server.js} +105 -5
  6. package/dist/cli-server.js.map +1 -0
  7. package/dist/cli-timeline.js.map +1 -0
  8. package/dist/{cli/cli.js → cli.js} +9 -7
  9. package/dist/cli.js.map +1 -0
  10. package/dist/{cli/fork-cli.d.ts → fork-cli.d.ts} +3 -3
  11. package/dist/{cli/fork-cli.js → fork-cli.js} +28 -27
  12. package/dist/fork-cli.js.map +1 -0
  13. package/dist/{cli/oauth-cli.js → oauth-cli.js} +1 -1
  14. package/dist/oauth-cli.js.map +1 -0
  15. package/dist/{cli/setup.js → setup.js} +10 -4
  16. package/dist/setup.js.map +1 -0
  17. package/dist/{cli/skills-install.d.ts → skills-install.d.ts} +6 -0
  18. package/dist/{cli/skills-install.js → skills-install.js} +6 -2
  19. package/dist/skills-install.js.map +1 -0
  20. package/package.json +20 -65
  21. package/plugins/lastlight/.claude-plugin/plugin.json +1 -1
  22. package/agent-context/rules.md +0 -80
  23. package/agent-context/security.md +0 -69
  24. package/agent-context/soul.md +0 -21
  25. package/config/default.yaml +0 -119
  26. package/deploy/.env.production.example +0 -100
  27. package/deploy/entrypoint.sh +0 -82
  28. package/deploy/native/README.md +0 -160
  29. package/deploy/native/install.sh +0 -110
  30. package/deploy/native/lastlight.env.example +0 -95
  31. package/deploy/native/lastlight.service +0 -50
  32. package/deploy/sandbox-entrypoint.sh +0 -93
  33. package/dist/admin/auth.d.ts +0 -51
  34. package/dist/admin/auth.js +0 -121
  35. package/dist/admin/auth.js.map +0 -1
  36. package/dist/admin/chat-session-reader.d.ts +0 -39
  37. package/dist/admin/chat-session-reader.js +0 -84
  38. package/dist/admin/chat-session-reader.js.map +0 -1
  39. package/dist/admin/docker.d.ts +0 -61
  40. package/dist/admin/docker.js +0 -187
  41. package/dist/admin/docker.js.map +0 -1
  42. package/dist/admin/index.d.ts +0 -9
  43. package/dist/admin/index.js +0 -59
  44. package/dist/admin/index.js.map +0 -1
  45. package/dist/admin/routes.d.ts +0 -81
  46. package/dist/admin/routes.js +0 -1381
  47. package/dist/admin/routes.js.map +0 -1
  48. package/dist/admin/sessions.d.ts +0 -89
  49. package/dist/admin/sessions.js +0 -204
  50. package/dist/admin/sessions.js.map +0 -1
  51. package/dist/admin/tail.d.ts +0 -12
  52. package/dist/admin/tail.js +0 -145
  53. package/dist/admin/tail.js.map +0 -1
  54. package/dist/admin/version.d.ts +0 -22
  55. package/dist/admin/version.js +0 -92
  56. package/dist/admin/version.js.map +0 -1
  57. package/dist/cli/cli-config.js.map +0 -1
  58. package/dist/cli/cli-format.js.map +0 -1
  59. package/dist/cli/cli-server.js.map +0 -1
  60. package/dist/cli/cli-timeline.js.map +0 -1
  61. package/dist/cli/cli.js.map +0 -1
  62. package/dist/cli/fork-cli.js.map +0 -1
  63. package/dist/cli/oauth-cli.js.map +0 -1
  64. package/dist/cli/setup.js.map +0 -1
  65. package/dist/cli/skills-install.js.map +0 -1
  66. package/dist/config/config-resolve.d.ts +0 -28
  67. package/dist/config/config-resolve.js +0 -47
  68. package/dist/config/config-resolve.js.map +0 -1
  69. package/dist/config/config.d.ts +0 -157
  70. package/dist/config/config.js +0 -581
  71. package/dist/config/config.js.map +0 -1
  72. package/dist/config/core-pin.d.ts +0 -16
  73. package/dist/config/core-pin.js +0 -85
  74. package/dist/config/core-pin.js.map +0 -1
  75. package/dist/config/overlay-assets.d.ts +0 -20
  76. package/dist/config/overlay-assets.js +0 -114
  77. package/dist/config/overlay-assets.js.map +0 -1
  78. package/dist/config/overlay-bootstrap.d.ts +0 -44
  79. package/dist/config/overlay-bootstrap.js +0 -304
  80. package/dist/config/overlay-bootstrap.js.map +0 -1
  81. package/dist/connectors/github-webhook.d.ts +0 -45
  82. package/dist/connectors/github-webhook.js +0 -321
  83. package/dist/connectors/github-webhook.js.map +0 -1
  84. package/dist/connectors/index.d.ts +0 -18
  85. package/dist/connectors/index.js +0 -41
  86. package/dist/connectors/index.js.map +0 -1
  87. package/dist/connectors/messaging/base.d.ts +0 -42
  88. package/dist/connectors/messaging/base.js +0 -139
  89. package/dist/connectors/messaging/base.js.map +0 -1
  90. package/dist/connectors/messaging/delivery.d.ts +0 -14
  91. package/dist/connectors/messaging/delivery.js +0 -38
  92. package/dist/connectors/messaging/delivery.js.map +0 -1
  93. package/dist/connectors/messaging/index.d.ts +0 -4
  94. package/dist/connectors/messaging/index.js +0 -4
  95. package/dist/connectors/messaging/index.js.map +0 -1
  96. package/dist/connectors/messaging/session-manager.d.ts +0 -53
  97. package/dist/connectors/messaging/session-manager.js +0 -255
  98. package/dist/connectors/messaging/session-manager.js.map +0 -1
  99. package/dist/connectors/messaging/types.d.ts +0 -58
  100. package/dist/connectors/messaging/types.js +0 -7
  101. package/dist/connectors/messaging/types.js.map +0 -1
  102. package/dist/connectors/slack/connector.d.ts +0 -142
  103. package/dist/connectors/slack/connector.js +0 -494
  104. package/dist/connectors/slack/connector.js.map +0 -1
  105. package/dist/connectors/slack/index.d.ts +0 -2
  106. package/dist/connectors/slack/index.js +0 -3
  107. package/dist/connectors/slack/index.js.map +0 -1
  108. package/dist/connectors/slack/mrkdwn.d.ts +0 -19
  109. package/dist/connectors/slack/mrkdwn.js +0 -240
  110. package/dist/connectors/slack/mrkdwn.js.map +0 -1
  111. package/dist/connectors/types.d.ts +0 -61
  112. package/dist/connectors/types.js +0 -2
  113. package/dist/connectors/types.js.map +0 -1
  114. package/dist/cron/fanout.d.ts +0 -33
  115. package/dist/cron/fanout.js +0 -55
  116. package/dist/cron/fanout.js.map +0 -1
  117. package/dist/cron/jobs.d.ts +0 -18
  118. package/dist/cron/jobs.js +0 -37
  119. package/dist/cron/jobs.js.map +0 -1
  120. package/dist/cron/scheduler.d.ts +0 -45
  121. package/dist/cron/scheduler.js +0 -105
  122. package/dist/cron/scheduler.js.map +0 -1
  123. package/dist/engine/agent-executor.d.ts +0 -34
  124. package/dist/engine/agent-executor.js +0 -246
  125. package/dist/engine/agent-executor.js.map +0 -1
  126. package/dist/engine/chat/chat-runner.d.ts +0 -132
  127. package/dist/engine/chat/chat-runner.js +0 -424
  128. package/dist/engine/chat/chat-runner.js.map +0 -1
  129. package/dist/engine/chat/chat-skills.d.ts +0 -45
  130. package/dist/engine/chat/chat-skills.js +0 -184
  131. package/dist/engine/chat/chat-skills.js.map +0 -1
  132. package/dist/engine/chat/chat.d.ts +0 -54
  133. package/dist/engine/chat/chat.js +0 -316
  134. package/dist/engine/chat/chat.js.map +0 -1
  135. package/dist/engine/chat/message-batcher.d.ts +0 -32
  136. package/dist/engine/chat/message-batcher.js +0 -109
  137. package/dist/engine/chat/message-batcher.js.map +0 -1
  138. package/dist/engine/dispatcher.d.ts +0 -73
  139. package/dist/engine/dispatcher.js +0 -663
  140. package/dist/engine/dispatcher.js.map +0 -1
  141. package/dist/engine/event-shim.d.ts +0 -117
  142. package/dist/engine/event-shim.js +0 -435
  143. package/dist/engine/event-shim.js.map +0 -1
  144. package/dist/engine/executors/orchestrator.d.ts +0 -81
  145. package/dist/engine/executors/orchestrator.js +0 -365
  146. package/dist/engine/executors/orchestrator.js.map +0 -1
  147. package/dist/engine/executors/shared.d.ts +0 -189
  148. package/dist/engine/executors/shared.js +0 -616
  149. package/dist/engine/executors/shared.js.map +0 -1
  150. package/dist/engine/github/git-auth.d.ts +0 -62
  151. package/dist/engine/github/git-auth.js +0 -161
  152. package/dist/engine/github/git-auth.js.map +0 -1
  153. package/dist/engine/github/github-app-client.d.ts +0 -23
  154. package/dist/engine/github/github-app-client.js +0 -33
  155. package/dist/engine/github/github-app-client.js.map +0 -1
  156. package/dist/engine/github/github-tools.d.ts +0 -18
  157. package/dist/engine/github/github-tools.js +0 -263
  158. package/dist/engine/github/github-tools.js.map +0 -1
  159. package/dist/engine/github/github.d.ts +0 -1193
  160. package/dist/engine/github/github.js +0 -357
  161. package/dist/engine/github/github.js.map +0 -1
  162. package/dist/engine/github/profiles.d.ts +0 -279
  163. package/dist/engine/github/profiles.js +0 -42
  164. package/dist/engine/github/profiles.js.map +0 -1
  165. package/dist/engine/github/review-poster.d.ts +0 -98
  166. package/dist/engine/github/review-poster.js +0 -177
  167. package/dist/engine/github/review-poster.js.map +0 -1
  168. package/dist/engine/llm.d.ts +0 -79
  169. package/dist/engine/llm.js +0 -269
  170. package/dist/engine/llm.js.map +0 -1
  171. package/dist/engine/oauth.d.ts +0 -43
  172. package/dist/engine/oauth.js +0 -103
  173. package/dist/engine/oauth.js.map +0 -1
  174. package/dist/engine/router.d.ts +0 -29
  175. package/dist/engine/router.js +0 -680
  176. package/dist/engine/router.js.map +0 -1
  177. package/dist/engine/screen/classifier.d.ts +0 -93
  178. package/dist/engine/screen/classifier.js +0 -215
  179. package/dist/engine/screen/classifier.js.map +0 -1
  180. package/dist/engine/screen/screen.d.ts +0 -41
  181. package/dist/engine/screen/screen.js +0 -93
  182. package/dist/engine/screen/screen.js.map +0 -1
  183. package/dist/evals-api.d.ts +0 -24
  184. package/dist/evals-api.js +0 -22
  185. package/dist/evals-api.js.map +0 -1
  186. package/dist/index.d.ts +0 -1
  187. package/dist/index.js +0 -940
  188. package/dist/index.js.map +0 -1
  189. package/dist/managed-repos.d.ts +0 -30
  190. package/dist/managed-repos.js +0 -72
  191. package/dist/managed-repos.js.map +0 -1
  192. package/dist/notify/blocks.d.ts +0 -23
  193. package/dist/notify/blocks.js +0 -118
  194. package/dist/notify/blocks.js.map +0 -1
  195. package/dist/notify/index.d.ts +0 -17
  196. package/dist/notify/index.js +0 -7
  197. package/dist/notify/index.js.map +0 -1
  198. package/dist/notify/model.d.ts +0 -57
  199. package/dist/notify/model.js +0 -89
  200. package/dist/notify/model.js.map +0 -1
  201. package/dist/notify/notifier.d.ts +0 -18
  202. package/dist/notify/notifier.js +0 -100
  203. package/dist/notify/notifier.js.map +0 -1
  204. package/dist/notify/render.d.ts +0 -21
  205. package/dist/notify/render.js +0 -59
  206. package/dist/notify/render.js.map +0 -1
  207. package/dist/notify/transports/github.d.ts +0 -26
  208. package/dist/notify/transports/github.js +0 -27
  209. package/dist/notify/transports/github.js.map +0 -1
  210. package/dist/notify/transports/slack.d.ts +0 -27
  211. package/dist/notify/transports/slack.js +0 -37
  212. package/dist/notify/transports/slack.js.map +0 -1
  213. package/dist/notify/types.d.ts +0 -119
  214. package/dist/notify/types.js +0 -16
  215. package/dist/notify/types.js.map +0 -1
  216. package/dist/providers.d.ts +0 -125
  217. package/dist/providers.js +0 -279
  218. package/dist/providers.js.map +0 -1
  219. package/dist/sandbox/docker.d.ts +0 -180
  220. package/dist/sandbox/docker.js +0 -517
  221. package/dist/sandbox/docker.js.map +0 -1
  222. package/dist/sandbox/egress-allowlist.d.ts +0 -74
  223. package/dist/sandbox/egress-allowlist.js +0 -164
  224. package/dist/sandbox/egress-allowlist.js.map +0 -1
  225. package/dist/sandbox/egress-firewall-config.d.ts +0 -127
  226. package/dist/sandbox/egress-firewall-config.js +0 -434
  227. package/dist/sandbox/egress-firewall-config.js.map +0 -1
  228. package/dist/sandbox/images.d.ts +0 -23
  229. package/dist/sandbox/images.js +0 -55
  230. package/dist/sandbox/images.js.map +0 -1
  231. package/dist/sandbox/index.d.ts +0 -112
  232. package/dist/sandbox/index.js +0 -332
  233. package/dist/sandbox/index.js.map +0 -1
  234. package/dist/sandbox/sandbox.d.ts +0 -214
  235. package/dist/sandbox/sandbox.js +0 -453
  236. package/dist/sandbox/sandbox.js.map +0 -1
  237. package/dist/sandbox/smol.d.ts +0 -162
  238. package/dist/sandbox/smol.js +0 -485
  239. package/dist/sandbox/smol.js.map +0 -1
  240. package/dist/session-log.d.ts +0 -63
  241. package/dist/session-log.js +0 -290
  242. package/dist/session-log.js.map +0 -1
  243. package/dist/state/approval-store.d.ts +0 -88
  244. package/dist/state/approval-store.js +0 -149
  245. package/dist/state/approval-store.js.map +0 -1
  246. package/dist/state/build-assets.d.ts +0 -50
  247. package/dist/state/build-assets.js +0 -154
  248. package/dist/state/build-assets.js.map +0 -1
  249. package/dist/state/db.d.ts +0 -81
  250. package/dist/state/db.js +0 -155
  251. package/dist/state/db.js.map +0 -1
  252. package/dist/state/execution-store.d.ts +0 -257
  253. package/dist/state/execution-store.js +0 -527
  254. package/dist/state/execution-store.js.map +0 -1
  255. package/dist/state/migrate.d.ts +0 -15
  256. package/dist/state/migrate.js +0 -175
  257. package/dist/state/migrate.js.map +0 -1
  258. package/dist/state/workflow-run-store.d.ts +0 -216
  259. package/dist/state/workflow-run-store.js +0 -403
  260. package/dist/state/workflow-run-store.js.map +0 -1
  261. package/dist/telemetry/index.d.ts +0 -38
  262. package/dist/telemetry/index.js +0 -253
  263. package/dist/telemetry/index.js.map +0 -1
  264. package/dist/telemetry/pi-events.d.ts +0 -12
  265. package/dist/telemetry/pi-events.js +0 -134
  266. package/dist/telemetry/pi-events.js.map +0 -1
  267. package/dist/workflows/dag.d.ts +0 -37
  268. package/dist/workflows/dag.js +0 -118
  269. package/dist/workflows/dag.js.map +0 -1
  270. package/dist/workflows/loader.d.ts +0 -53
  271. package/dist/workflows/loader.js +0 -421
  272. package/dist/workflows/loader.js.map +0 -1
  273. package/dist/workflows/loop-eval.d.ts +0 -28
  274. package/dist/workflows/loop-eval.js +0 -74
  275. package/dist/workflows/loop-eval.js.map +0 -1
  276. package/dist/workflows/phase-executor.d.ts +0 -222
  277. package/dist/workflows/phase-executor.js +0 -1153
  278. package/dist/workflows/phase-executor.js.map +0 -1
  279. package/dist/workflows/phase-ref.d.ts +0 -50
  280. package/dist/workflows/phase-ref.js +0 -91
  281. package/dist/workflows/phase-ref.js.map +0 -1
  282. package/dist/workflows/resume.d.ts +0 -58
  283. package/dist/workflows/resume.js +0 -313
  284. package/dist/workflows/resume.js.map +0 -1
  285. package/dist/workflows/runner.d.ts +0 -63
  286. package/dist/workflows/runner.js +0 -381
  287. package/dist/workflows/runner.js.map +0 -1
  288. package/dist/workflows/schema.d.ts +0 -344
  289. package/dist/workflows/schema.js +0 -460
  290. package/dist/workflows/schema.js.map +0 -1
  291. package/dist/workflows/simple.d.ts +0 -90
  292. package/dist/workflows/simple.js +0 -393
  293. package/dist/workflows/simple.js.map +0 -1
  294. package/dist/workflows/target-policy.d.ts +0 -74
  295. package/dist/workflows/target-policy.js +0 -80
  296. package/dist/workflows/target-policy.js.map +0 -1
  297. package/dist/workflows/templates.d.ts +0 -88
  298. package/dist/workflows/templates.js +0 -127
  299. package/dist/workflows/templates.js.map +0 -1
  300. package/dist/workflows/triggers.d.ts +0 -21
  301. package/dist/workflows/triggers.js +0 -45
  302. package/dist/workflows/triggers.js.map +0 -1
  303. package/dist/workflows/verdict.d.ts +0 -19
  304. package/dist/workflows/verdict.js +0 -30
  305. package/dist/workflows/verdict.js.map +0 -1
  306. package/dist/worktree/manager.d.ts +0 -43
  307. package/dist/worktree/manager.js +0 -127
  308. package/dist/worktree/manager.js.map +0 -1
  309. package/docker-compose.yml +0 -274
  310. package/sandbox.Dockerfile +0 -68
  311. package/skills/README.md +0 -42
  312. package/skills/browser-qa/SKILL.md +0 -208
  313. package/skills/browser-qa/scripts/agent-browser.mjs +0 -531
  314. package/skills/building/SKILL.md +0 -95
  315. package/skills/chat/SKILL.md +0 -36
  316. package/skills/code-review/SKILL.md +0 -82
  317. package/skills/debug-production/SKILL.md +0 -108
  318. package/skills/demo/SKILL.md +0 -228
  319. package/skills/demo/scripts/compose-demo.sh +0 -243
  320. package/skills/issue-answer/SKILL.md +0 -91
  321. package/skills/issue-comment/SKILL.md +0 -49
  322. package/skills/issue-triage/SKILL.md +0 -149
  323. package/skills/issue-triage/references/AGENT-BRIEF.md +0 -65
  324. package/skills/pr-comment/SKILL.md +0 -64
  325. package/skills/pr-review/SKILL.md +0 -148
  326. package/skills/pr-review/references/findings-schema.md +0 -89
  327. package/skills/qa-test/SKILL.md +0 -97
  328. package/skills/repo-health/SKILL.md +0 -67
  329. package/skills/security-feedback/SKILL.md +0 -173
  330. package/skills/security-feedback/references/templates.md +0 -77
  331. package/skills/security-review/SKILL.md +0 -215
  332. package/skills/security-review/references/issue-format.md +0 -302
  333. package/skills/verify/SKILL.md +0 -118
  334. package/workflows/answer.yaml +0 -44
  335. package/workflows/build.yaml +0 -129
  336. package/workflows/cron-health.yaml +0 -6
  337. package/workflows/cron-review.yaml +0 -8
  338. package/workflows/cron-security.yaml +0 -6
  339. package/workflows/cron-triage.yaml +0 -8
  340. package/workflows/demo.yaml +0 -62
  341. package/workflows/examples/bash-smoke.yaml +0 -41
  342. package/workflows/examples/parallel-review.yaml +0 -36
  343. package/workflows/examples/tdd-loop.yaml +0 -17
  344. package/workflows/explore.yaml +0 -80
  345. package/workflows/issue-comment.yaml +0 -15
  346. package/workflows/issue-triage.yaml +0 -22
  347. package/workflows/pr-comment.yaml +0 -16
  348. package/workflows/pr-fix.yaml +0 -17
  349. package/workflows/pr-review.yaml +0 -50
  350. package/workflows/prompts/answer.md +0 -68
  351. package/workflows/prompts/architect.md +0 -44
  352. package/workflows/prompts/classifier.md +0 -60
  353. package/workflows/prompts/classify-adds-info.md +0 -17
  354. package/workflows/prompts/demo.md +0 -125
  355. package/workflows/prompts/executor.md +0 -42
  356. package/workflows/prompts/explore-ask.md +0 -99
  357. package/workflows/prompts/explore-publish.md +0 -31
  358. package/workflows/prompts/explore-read.md +0 -120
  359. package/workflows/prompts/explore-synthesize.md +0 -88
  360. package/workflows/prompts/fix.md +0 -18
  361. package/workflows/prompts/guardrails.md +0 -96
  362. package/workflows/prompts/pr-fix.md +0 -26
  363. package/workflows/prompts/pr.md +0 -39
  364. package/workflows/prompts/qa-browser.md +0 -95
  365. package/workflows/prompts/qa-synth.md +0 -44
  366. package/workflows/prompts/qa-test.md +0 -60
  367. package/workflows/prompts/re-reviewer.md +0 -33
  368. package/workflows/prompts/reviewer.md +0 -60
  369. package/workflows/prompts/verify-browser.md +0 -93
  370. package/workflows/prompts/verify-synth.md +0 -44
  371. package/workflows/prompts/verify.md +0 -63
  372. package/workflows/qa-test.yaml +0 -94
  373. package/workflows/repo-health.yaml +0 -15
  374. package/workflows/security-feedback.yaml +0 -25
  375. package/workflows/security-review.yaml +0 -37
  376. package/workflows/verify.yaml +0 -93
  377. /package/dist/{cli/cli-config.d.ts → cli-config.d.ts} +0 -0
  378. /package/dist/{cli/cli-config.js → cli-config.js} +0 -0
  379. /package/dist/{cli/cli-format.d.ts → cli-format.d.ts} +0 -0
  380. /package/dist/{cli/cli-format.js → cli-format.js} +0 -0
  381. /package/dist/{cli/cli-timeline.d.ts → cli-timeline.d.ts} +0 -0
  382. /package/dist/{cli/cli-timeline.js → cli-timeline.js} +0 -0
  383. /package/dist/{cli/cli.d.ts → cli.d.ts} +0 -0
  384. /package/dist/{cli/oauth-cli.d.ts → oauth-cli.d.ts} +0 -0
  385. /package/dist/{cli/setup.d.ts → setup.d.ts} +0 -0
@@ -1,1153 +0,0 @@
1
- import { randomUUID } from "crypto";
2
- import { execFileSync } from "node:child_process";
3
- import { existsSync, readFileSync } from "node:fs";
4
- import { join, resolve } from "node:path";
5
- import { executeAgent, executeCommand } from "../engine/agent-executor.js";
6
- import { GitHubClient } from "../engine/github/github.js";
7
- import { buildReview, buildBodyOnlyReview, parseDiff, } from "../engine/github/review-poster.js";
8
- import { BuildAssetStore } from "../state/build-assets.js";
9
- import { phaseSkillNames } from "./schema.js";
10
- import { loadPromptTemplate, resolveSkillPaths } from "./loader.js";
11
- import { getRuntimeConfig, getBotName } from "../config/config.js";
12
- import { renderTemplate } from "./templates.js";
13
- import { evalUntilExpression } from "./loop-eval.js";
14
- import { parseReviewerVerdict } from "./verdict.js";
15
- import { PhaseRef } from "./phase-ref.js";
16
- import { recordError, recordExecutionMetrics, withSpan } from "../telemetry/index.js";
17
- import { listRunningContainers } from "../admin/docker.js";
18
- // ── Module-level helpers (moved here from runner.ts) ─────────────────────────
19
- /**
20
- * Reject shell commands containing mustache template markers to prevent
21
- * accidental template injection into until_bash values.
22
- */
23
- function validateShellCommand(cmd) {
24
- if (cmd.includes("{{")) {
25
- throw new Error(`until_bash command rejected: contains template marker '{{'. Render templates before passing to shell.`);
26
- }
27
- }
28
- /**
29
- * Forward upstream phase outputs to a `bash`/`script` command as env vars
30
- * (`LL_OUT_<PHASE>`), so a script can read them via `process.env` / `os.environ`
31
- * without any shell-injection risk. Only simple single-line, reasonably-sized
32
- * string outputs are forwarded; larger / multi-line outputs are still reachable
33
- * via `{{phaseOutputs.<name>.output}}` template substitution in the command.
34
- */
35
- function upstreamOutputsEnv(outputs) {
36
- const env = {};
37
- for (const [k, v] of Object.entries(outputs)) {
38
- if (typeof v !== "string" || v.length === 0 || v.length > 4096)
39
- continue;
40
- if (/[\n\r]/.test(v))
41
- continue;
42
- const key = `LL_OUT_${k.toUpperCase().replace(/[^A-Z0-9_]/g, "_")}`;
43
- if (/^LL_OUT_[A-Z0-9_]+$/.test(key))
44
- env[key] = v;
45
- }
46
- return env;
47
- }
48
- /**
49
- * Build the agent prompt for a phase, handling both `prompt:` (template file)
50
- * and `skill:`/`skills:` (skill references) phase definitions.
51
- */
52
- export function buildPhasePrompt(phase, ctx, extraCtx) {
53
- const fullCtx = extraCtx ? { ...ctx, ...extraCtx } : ctx;
54
- if (phase.prompt) {
55
- const template = loadPromptTemplate(phase.prompt);
56
- return renderTemplate(template, fullCtx);
57
- }
58
- const skills = phaseSkillNames(phase);
59
- if (skills.length) {
60
- const [primary, ...rest] = skills;
61
- const contextLines = Object.entries(fullCtx)
62
- .filter(([, v]) => v !== undefined && v !== null)
63
- .map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`)
64
- .join("\n");
65
- const others = rest.length
66
- ? `Other skills available if you need them: ${rest.join(", ")}.`
67
- : "";
68
- return [
69
- `Use the **${primary}** skill to handle this request.`,
70
- others,
71
- "",
72
- "Context:",
73
- contextLines,
74
- ]
75
- .filter((line) => line !== "")
76
- .join("\n");
77
- }
78
- throw new Error(`Phase "${phase.name}" has neither prompt: nor skills: — cannot build prompt`);
79
- }
80
- /**
81
- * Overlay per-phase executor config fields (`unrestricted_egress`,
82
- * `web_search`, `sandbox_image`, resolved skill paths) onto the run-level
83
- * config.
84
- */
85
- export function phaseConfigFor(config, phase) {
86
- const skills = phaseSkillNames(phase);
87
- const skillPaths = skills.length ? resolveSkillPaths(skills) : undefined;
88
- if (phase.unrestricted_egress === undefined &&
89
- phase.web_search === undefined &&
90
- phase.sandbox_image === undefined &&
91
- !skillPaths) {
92
- return config;
93
- }
94
- const next = { ...config };
95
- if (phase.unrestricted_egress !== undefined) {
96
- next.unrestrictedEgress = phase.unrestricted_egress;
97
- }
98
- if (phase.web_search !== undefined) {
99
- next.webSearch = phase.web_search;
100
- }
101
- if (phase.sandbox_image !== undefined) {
102
- next.sandboxImage = phase.sandbox_image;
103
- }
104
- if (skillPaths) {
105
- next.skillPaths = skillPaths;
106
- }
107
- return next;
108
- }
109
- /** Check if an error was caused by manual termination (OOM/cancel/kill). */
110
- export function isTerminated(error) {
111
- if (!error)
112
- return false;
113
- const lower = error.toLowerCase();
114
- return (lower.includes("terminated") ||
115
- lower.includes("killed") ||
116
- lower.includes("exit undefined") ||
117
- (lower.includes("container") && lower.includes("not running")));
118
- }
119
- /**
120
- * Classify an execution outcome as *soft* — a clean exit that produced no
121
- * usable output — versus *hard* (a real crash). Generic and phase-agnostic:
122
- * it reads only the result fields, with no notion of reviewer/socratic. Both
123
- * the reviewer loop and the generic loop consume it so their recovery logic
124
- * stays in lockstep. A soft outcome is recoverable (retry / trust an on-disk
125
- * artifact / advance); a hard one — terminated, fatal, tool error, non-zero
126
- * exit — is not. See `mapStopReason` in the executors for the stop-reason
127
- * vocabulary this keys off (`unknown` / `error_truncated` are the soft ones).
128
- */
129
- export function isSoftOutcome(r) {
130
- if (r.success)
131
- return true;
132
- if (isTerminated(r.error))
133
- return false;
134
- return r.stopReason === "unknown" || r.stopReason === "error_truncated";
135
- }
136
- function pickResult(r) {
137
- return { success: r.success, output: r.output, error: r.error };
138
- }
139
- function issueNumberFromTrigger(triggerId) {
140
- const m = triggerId.match(/#(\d+)$/);
141
- return m ? Number(m[1]) : undefined;
142
- }
143
- /** Check if a sandbox container is actually running for a given taskId prefix. */
144
- async function isContainerAlive(taskId) {
145
- try {
146
- const containers = await listRunningContainers();
147
- return containers.some((c) => c.taskId === taskId);
148
- }
149
- catch {
150
- return false;
151
- }
152
- }
153
- /**
154
- * Run a single agent phase with DB-tracked deduplication. Moved verbatim from
155
- * runner.ts — the dedup ledger (`shouldRunPhase`) is the single source of
156
- * truth for resume, so re-running from the top skips completed phases here.
157
- */
158
- /**
159
- * The DB-tracked dedup ledger shared by agent phases ({@link runPhase}) and
160
- * deterministic command phases ({@link runCommandPhase}). The `run` callback
161
- * does the actual work and receives a session-id sink so the executions row can
162
- * be linked to its session jsonl mid-run. `shouldRunPhase` is the single source
163
- * of truth for resume — re-running from the top skips completed phases here.
164
- */
165
- async function runPhaseLedger(attrs, meta, db, run) {
166
- const { dedupKey, phaseName, taskId, triggerId, repo, workflowRunId } = meta;
167
- return withSpan("lastlight.workflow.phase", attrs, async (span) => {
168
- if (db) {
169
- const status = db.executions.shouldRunPhase(dedupKey, triggerId, workflowRunId);
170
- if (status === "running") {
171
- const alive = await isContainerAlive(taskId);
172
- if (alive) {
173
- console.log(`[runner] Phase ${phaseName} is already running (container alive) — skipping`);
174
- span?.addEvent("lastlight.workflow.phase.skipped", { reason: "running" });
175
- return { skipped: true, reason: "running" };
176
- }
177
- console.log(`[runner] Phase ${phaseName} was running but container is dead — cleaning up`);
178
- db.executions.markStaleAsFailed(dedupKey, triggerId, workflowRunId);
179
- }
180
- else if (status === "done") {
181
- console.log(`[runner] Phase ${phaseName} already completed successfully — skipping`);
182
- span?.addEvent("lastlight.workflow.phase.skipped", { reason: "done" });
183
- return { skipped: true, reason: "done" };
184
- }
185
- const executionId = randomUUID();
186
- db.executions.recordStart({
187
- id: executionId,
188
- triggerType: "webhook",
189
- triggerId,
190
- skill: dedupKey,
191
- repo,
192
- issueNumber: issueNumberFromTrigger(triggerId),
193
- startedAt: new Date().toISOString(),
194
- workflowRunId,
195
- });
196
- try {
197
- const result = await run((sessionId) => {
198
- try {
199
- db.executions.recordSessionId(executionId, sessionId);
200
- }
201
- catch (err) {
202
- console.warn(`[runner] Failed to persist session id mid-run for ${phaseName}:`, err);
203
- }
204
- });
205
- db.executions.recordFinish(executionId, {
206
- success: result.success,
207
- error: result.error,
208
- turns: result.turns,
209
- durationMs: result.durationMs,
210
- sessionId: result.sessionId,
211
- costUsd: result.costUsd,
212
- inputTokens: result.inputTokens,
213
- cacheCreationInputTokens: result.cacheCreationInputTokens,
214
- cacheReadInputTokens: result.cacheReadInputTokens,
215
- outputTokens: result.outputTokens,
216
- apiDurationMs: result.apiDurationMs,
217
- stopReason: result.stopReason,
218
- extensionStatus: result.extensions ? JSON.stringify(result.extensions) : undefined,
219
- skillsStatus: result.skills ? JSON.stringify(result.skills) : undefined,
220
- });
221
- span?.setAttributes({ success: result.success, stop_reason: result.stopReason ?? "unknown" });
222
- recordExecutionMetrics("phase", { ...attrs, success: result.success, stop_reason: result.stopReason, durationMs: result.durationMs, costUsd: result.costUsd, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
223
- return { result, executionId, skipped: false };
224
- }
225
- catch (err) {
226
- recordError("phase", err, attrs);
227
- throw err;
228
- }
229
- }
230
- const result = await run(() => { });
231
- recordExecutionMetrics("phase", { ...attrs, success: result.success, stop_reason: result.stopReason, durationMs: result.durationMs, costUsd: result.costUsd, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
232
- return { result, skipped: false };
233
- });
234
- }
235
- /**
236
- * Run a single agent phase with DB-tracked deduplication. The dedup ledger
237
- * (`shouldRunPhase`) is the single source of truth for resume, so re-running
238
- * from the top skips completed phases here.
239
- */
240
- export async function runPhase(workflowName, phaseName, taskId, triggerId, prompt, config, db, modelOverride, workflowRunId, githubAccess, variantOverride) {
241
- const dedupKey = `${workflowName}:${phaseName}`;
242
- const attrs = {
243
- "workflow.name": workflowName,
244
- "phase.name": phaseName,
245
- "workflow.run_id": workflowRunId,
246
- "trigger.id": triggerId,
247
- "task.id": taskId,
248
- repo: githubAccess?.repo,
249
- "issue.number": issueNumberFromTrigger(triggerId),
250
- "sandbox.backend": config.sandbox,
251
- model: modelOverride || config.model,
252
- };
253
- const baseConfig = modelOverride ? { ...config, model: modelOverride } : config;
254
- const phaseConfigBase = variantOverride ? { ...baseConfig, variant: variantOverride } : baseConfig;
255
- const phaseConfig = {
256
- ...phaseConfigBase,
257
- telemetry: { workflowName, phaseName, triggerId, workflowRunId },
258
- };
259
- return runPhaseLedger(attrs, { dedupKey, phaseName, taskId, triggerId, repo: githubAccess?.repo, workflowRunId }, db, (onSessionId) => executeAgent(prompt, phaseConfig, { taskId, githubAccess, onSessionId }));
260
- }
261
- /**
262
- * Run a single deterministic `bash`/`script` phase, sharing the agent phase's
263
- * dedup ledger + executions row so it appears (and dedups on resume) exactly
264
- * like an agent phase — just with `turns: 0` and no model cost.
265
- */
266
- export async function runCommandPhase(workflowName, phaseName, taskId, triggerId, spec, config, db, workflowRunId, githubAccess, timeoutSeconds, sandboxEnv) {
267
- const dedupKey = `${workflowName}:${phaseName}`;
268
- const attrs = {
269
- "workflow.name": workflowName,
270
- "phase.name": phaseName,
271
- "workflow.run_id": workflowRunId,
272
- "trigger.id": triggerId,
273
- "task.id": taskId,
274
- repo: githubAccess?.repo,
275
- "issue.number": issueNumberFromTrigger(triggerId),
276
- "sandbox.backend": config.sandbox,
277
- model: spec.kind,
278
- };
279
- const phaseConfig = { ...config, telemetry: { workflowName, phaseName, triggerId, workflowRunId } };
280
- return runPhaseLedger(attrs, { dedupKey, phaseName, taskId, triggerId, repo: githubAccess?.repo, workflowRunId }, db, (onSessionId) => executeCommand(spec, phaseConfig, { taskId, githubAccess, onSessionId, timeoutSeconds, sandboxEnv }));
281
- }
282
- // ── PhaseExecutor ────────────────────────────────────────────────────────────
283
- const MAX_PREV_OUTPUT_BYTES = 10 * 1024; // cap accumulated generic-loop output at 10KB
284
- export class PhaseExecutor {
285
- run;
286
- reporter;
287
- resolver;
288
- constructor(run, reporter, resolver) {
289
- this.run = run;
290
- this.reporter = reporter;
291
- this.resolver = resolver;
292
- }
293
- /**
294
- * Execute one DAG node and return its delta. The scheduler accumulates
295
- * `results` into `phases[]`, merges `outputVars` into the shared outputs
296
- * map, and maps `status`/`paused`/`aborted` onto node state + run control.
297
- */
298
- async execute(node, outputs) {
299
- const phase = this.run.definition.phases.find((p) => p.name === node.name);
300
- if (!phase) {
301
- // Unknown node — shouldn't happen; treat as a no-op success.
302
- return { results: [{ phase: node.name, success: true, output: "" }], status: "succeeded" };
303
- }
304
- const phaseType = phase.type ?? "agent";
305
- if (phaseType === "context")
306
- return this.runContext(phase);
307
- if (phaseType === "post-review")
308
- return this.runPostReview(phase);
309
- if (phaseType === "bash" || phaseType === "script")
310
- return this.runCommandBody(phase, outputs);
311
- if (!phase.prompt && phaseSkillNames(phase).length === 0) {
312
- console.warn(`[runner] Phase "${phase.name}" has type=agent but neither prompt: nor skills: — skipping`);
313
- return { results: [], status: "succeeded" };
314
- }
315
- if (phase.loop)
316
- return this.runReviewerLoop(phase, outputs);
317
- if (phase.generic_loop)
318
- return this.runGenericLoop(phase, outputs);
319
- return this.runStandard(phase, outputs);
320
- }
321
- // ── Per-phase bodies ───────────────────────────────────────────────────────
322
- async runContext(phase) {
323
- await this.reporter.onStart(phase.name);
324
- const result = { phase: phase.name, success: true, output: "Context assembled" };
325
- // Persist a phase_history entry so the dashboard marks context phases done.
326
- this.reporter.persistPhase(phase.name, "Context assembled");
327
- await this.reporter.onEnd(phase.name, result);
328
- return { results: [result], status: "succeeded" };
329
- }
330
- /**
331
- * First-class, in-process PR-review submission (`type: post-review`). The
332
- * reviewer agent writes only *content* to `.lastlight/pr-review/findings.json`
333
- * (`{ skip?, summary, event, findings[] }`); THIS action supplies every fact
334
- * the harness already knows — the PR number (from the run context), the base
335
- * ref, the head SHA and the diff (from the pre-cloned checkout) — anchors each
336
- * finding to a changed line, and posts one formal review via `GitHubClient`.
337
- *
338
- * This replaces the ~150-line in-sandbox `type: script` blob that depended on
339
- * the AI hand-copying `pr_number`/`base_ref`/`head_sha` into the JSON and
340
- * silently `exit 0`'d on any mismatch (the "ran but posted nothing" bug).
341
- * Here a genuine failure — missing findings after a real review, or a GitHub
342
- * error that survives the body-only retry — FAILS the phase visibly; only a
343
- * legitimate `skip` succeeds without posting. Idempotent on resume: it no-ops
344
- * when a bot review already exists on the current head SHA.
345
- */
346
- async runPostReview(phase) {
347
- const phaseName = phase.name;
348
- await this.reporter.onStart(phaseName);
349
- await this.reporter.step(phaseName, "running", phase.messages?.on_start);
350
- const succeed = async (summary) => {
351
- const result = { phase: phaseName, success: true, output: summary };
352
- this.reporter.persistPhase(phaseName, summary);
353
- await this.reporter.onEnd(phaseName, result);
354
- await this.reporter.step(phaseName, "done", phase.messages?.on_success);
355
- return { results: [result], status: "succeeded" };
356
- };
357
- const fail = async (error) => {
358
- const result = { phase: phaseName, success: false, output: "", error };
359
- await this.reporter.onEnd(phaseName, result);
360
- await this.reporter.step(phaseName, "failed", phase.messages?.on_failure);
361
- // Record a failed phase_history entry so the dashboard pipeline renders
362
- // this node red — the action has no `executions` row (it runs in-process),
363
- // and `persistPhase` only writes success entries, so without this a failed
364
- // post-review would show as "pending" despite the run being marked failed.
365
- if (this.run.db && this.run.workflowId) {
366
- this.run.db.runs.appendPhase(this.run.workflowId, phaseName, {
367
- phase: phaseName,
368
- timestamp: new Date().toISOString(),
369
- success: false,
370
- summary: error,
371
- });
372
- }
373
- this.reporter.failWorkflow(error);
374
- console.error(`[post-review] ${error}`);
375
- return { results: [result], status: "failed" };
376
- };
377
- const ctx = this.run.ctx;
378
- const owner = String(ctx.owner);
379
- const repo = String(ctx.repo);
380
- const prNumber = (typeof ctx.prNumber === "number" ? ctx.prNumber : undefined) ??
381
- (typeof ctx.issueNumber === "number" && ctx.issueNumber > 0 ? ctx.issueNumber : undefined);
382
- if (!prNumber)
383
- return fail("post-review: no PR number in run context; cannot post review");
384
- // Read the agent's findings from the host checkout. The review phase writes
385
- // it at `.lastlight/pr-review/findings.json` relative to the repo cwd; the
386
- // workspace persists on the host between phases (see sandbox/index.ts).
387
- const hostRepoDir = this.resolveHostRepoDir(repo);
388
- const findingsPath = join(hostRepoDir, ".lastlight", "pr-review", "findings.json");
389
- let doc;
390
- try {
391
- doc = JSON.parse(readFileSync(findingsPath, "utf8"));
392
- }
393
- catch (err) {
394
- const msg = err instanceof Error ? err.message : String(err);
395
- // A missing/unreadable file after a completed review means the review
396
- // phase didn't honour its contract — surface it, don't post silently.
397
- return fail(`post-review: could not read findings (${findingsPath}): ${msg}`);
398
- }
399
- if (doc.skip) {
400
- return succeed(`skipped: ${doc.summary || "agent skipped review"}`);
401
- }
402
- const github = this.buildReviewClient();
403
- // Head SHA + base ref come from the checkout / run context, never the agent.
404
- const baseRef = typeof ctx.baseBranch === "string" && ctx.baseBranch ? ctx.baseBranch : undefined;
405
- const headSha = this.gitHeadSha(hostRepoDir);
406
- // Idempotency: skip if a bot review already exists on this head SHA (guards
407
- // resume / re-entry from double-posting).
408
- if (headSha) {
409
- try {
410
- const existing = await github.getLatestBotReview(owner, repo, prNumber, headSha, getRuntimeConfig()?.botLogin);
411
- if (existing)
412
- return succeed(`already reviewed head ${headSha.slice(0, 7)} (${existing.state})`);
413
- }
414
- catch {
415
- /* best-effort — fall through and attempt the post */
416
- }
417
- }
418
- // Commentable line set from the local checkout diff. Failure → null → all
419
- // findings demoted to the body (the review still posts).
420
- const commentable = baseRef ? this.gitCommentableDiff(hostRepoDir, baseRef) : null;
421
- const review = buildReview(doc, commentable);
422
- try {
423
- await github.createPullRequestReview(owner, repo, prNumber, {
424
- body: review.body,
425
- event: review.event,
426
- comments: review.comments,
427
- commitId: headSha,
428
- });
429
- return succeed(`posted review: ${review.inlineCount} inline, ${review.demotedCount} in body, event=${review.event}`);
430
- }
431
- catch (err) {
432
- const msg = err instanceof Error ? err.message : String(err);
433
- console.warn(`[post-review] inline review POST failed: ${msg}; retrying body-only`);
434
- // Off-diff anchors (e.g. a stale diff) 422 — retry with everything in the
435
- // body so the review still lands.
436
- const bodyOnly = buildBodyOnlyReview(doc);
437
- try {
438
- await github.createPullRequestReview(owner, repo, prNumber, {
439
- body: bodyOnly.body,
440
- event: bodyOnly.event,
441
- commitId: headSha,
442
- });
443
- return succeed(`posted review (body-only fallback): ${bodyOnly.demotedCount} findings, event=${bodyOnly.event}`);
444
- }
445
- catch (err2) {
446
- const msg2 = err2 instanceof Error ? err2.message : String(err2);
447
- return fail(`post-review: GitHub rejected the review (inline: ${msg}; body-only: ${msg2})`);
448
- }
449
- }
450
- }
451
- /** Host path of the run's repo checkout — mirrors sandbox/index.ts layout. */
452
- /**
453
- * The reviewer's authoritative verdict lives in reviewer-verdict.md (its
454
- * OUTPUT CONTRACT), so recover it when the stdout marker is missing. Server
455
- * mode reads the harvested doc from the build-asset store; repo mode reads the
456
- * committed doc from the host checkout under issueDir. Returns the parsed
457
- * verdict only when the file actually carries a `VERDICT:` marker (else
458
- * undefined — we don't want to launder the same fragile fallback through a
459
- * different source).
460
- */
461
- readReviewerVerdictFromFile() {
462
- const config = this.run.config;
463
- let text;
464
- if (config.buildAssets === "server" && config.buildAssetsDir && config.buildAssetsKey) {
465
- try {
466
- text = new BuildAssetStore(config.buildAssetsDir).read(config.buildAssetsKey, "reviewer-verdict.md");
467
- }
468
- catch {
469
- /* fall through to the workspace copy */
470
- }
471
- }
472
- if (!text) {
473
- const issueDir = typeof this.run.ctx.issueDir === "string" ? this.run.ctx.issueDir : "";
474
- if (issueDir) {
475
- const path = join(this.resolveHostRepoDir(String(this.run.ctx.repo)), issueDir, "reviewer-verdict.md");
476
- try {
477
- if (existsSync(path))
478
- text = readFileSync(path, "utf8");
479
- }
480
- catch {
481
- /* ignore — no recoverable verdict */
482
- }
483
- }
484
- }
485
- if (!text)
486
- return undefined;
487
- const parsed = parseReviewerVerdict(text);
488
- return parsed.viaFallback ? undefined : parsed;
489
- }
490
- resolveHostRepoDir(repo) {
491
- const config = this.run.config;
492
- const sandboxBase = resolve(config.sandboxDir || join(config.stateDir || "data", "sandboxes"));
493
- const workDir = join(sandboxBase, this.run.taskId);
494
- // pr-review pre-clones into a `<repo>/` subdir (a sibling of the workspace
495
- // root's AGENTS.md / skill bundle). Fall back to the workspace root if the
496
- // repo subdir has no findings (defensive — should not happen for pr-review).
497
- const repoDir = join(workDir, repo);
498
- if (existsSync(join(repoDir, ".lastlight", "pr-review")))
499
- return repoDir;
500
- if (existsSync(join(workDir, ".lastlight", "pr-review")))
501
- return workDir;
502
- return repoDir;
503
- }
504
- /** Build the GitHub client for the post: token+baseUrl in evals, App auth in prod. */
505
- buildReviewClient() {
506
- const baseUrl = this.run.config.githubApiBaseUrl;
507
- if (baseUrl) {
508
- // Eval / test path: the mock ignores auth; any bearer token works.
509
- const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "eval-fake-token";
510
- return GitHubClient.withToken(token, baseUrl);
511
- }
512
- return new GitHubClient({
513
- appId: process.env.GITHUB_APP_ID || "",
514
- privateKeyPath: process.env.GITHUB_APP_PRIVATE_KEY_PATH || "",
515
- installationId: process.env.GITHUB_APP_INSTALLATION_ID || "",
516
- });
517
- }
518
- gitHeadSha(repoDir) {
519
- try {
520
- return execFileSync("git", ["-C", repoDir, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
521
- }
522
- catch {
523
- return undefined;
524
- }
525
- }
526
- /** Compute the base…head diff locally and parse it into a commentable set. */
527
- gitCommentableDiff(repoDir, baseRef) {
528
- const diff = () => execFileSync("git", ["-C", repoDir, "diff", `origin/${baseRef}...HEAD`], {
529
- encoding: "utf8",
530
- maxBuffer: 64 * 1024 * 1024,
531
- });
532
- try {
533
- try {
534
- execFileSync("git", ["-C", repoDir, "fetch", "origin", baseRef, "--depth", "50"], { stdio: "ignore" });
535
- }
536
- catch {
537
- /* offline / already present — fall through to diff */
538
- }
539
- try {
540
- return parseDiff(diff());
541
- }
542
- catch (err) {
543
- // The `--depth 50` fetch above shallow-clones the base branch. When a PR
544
- // forked far behind the branch's current tip (a long-lived / stale PR),
545
- // the merge-base sits beyond that shallow boundary and the three-dot
546
- // diff dies with "no merge base" — which would demote EVERY finding to
547
- // the body. Deepen the base history and retry once so the review still
548
- // anchors inline. Only on this rare failure path do we pay the full
549
- // fetch. (`--unshallow` no-ops-then-throws on an already-complete repo,
550
- // hence best-effort.)
551
- const msg = err instanceof Error ? err.message : String(err);
552
- if (!/no merge base|shallow/i.test(msg))
553
- throw err;
554
- try {
555
- execFileSync("git", ["-C", repoDir, "fetch", "origin", baseRef, "--unshallow"], { stdio: "ignore" });
556
- }
557
- catch {
558
- /* already complete / offline — retry the diff regardless */
559
- }
560
- return parseDiff(diff());
561
- }
562
- }
563
- catch (err) {
564
- const msg = err instanceof Error ? err.message : String(err);
565
- console.warn(`[post-review] git diff failed (${msg}); demoting all findings to the body`);
566
- return null;
567
- }
568
- }
569
- /** Resolve model + variant for a phase/task, honouring YAML templates first. */
570
- resolveModelVariant(template, variantTemplate, taskName, fallbackTask) {
571
- const ctx = this.run.ctx;
572
- const modelRaw = template ? renderTemplate(template, ctx) : undefined;
573
- const model = modelRaw || this.resolver.modelFor(taskName)
574
- || (fallbackTask ? this.resolver.modelFor(fallbackTask) : undefined);
575
- const variantRaw = variantTemplate ? renderTemplate(variantTemplate, ctx) : undefined;
576
- const variant = variantRaw || this.resolver.variantFor(taskName)
577
- || (fallbackTask ? this.resolver.variantFor(fallbackTask) : undefined);
578
- return { model, variant };
579
- }
580
- async runPhaseCall(label, prompt, phase, model, variant) {
581
- const { definition, config, db, workflowId, githubAccess, taskId, triggerId } = this.run;
582
- return runPhase(definition.name, label, taskId, triggerId, prompt, phaseConfigFor(config, phase), db, model, workflowId, githubAccess, variant);
583
- }
584
- async runStandard(phase, outputs) {
585
- const phaseName = phase.name;
586
- await this.reporter.onStart(phaseName);
587
- await this.reporter.step(phaseName, "running", phase.messages?.on_start);
588
- const { model, variant } = this.resolveModelVariant(phase.model, phase.variant, phaseName);
589
- const prompt = buildPhasePrompt(phase, this.run.ctx, { phaseOutputs: outputs });
590
- const pr = await this.runPhaseCall(phaseName, prompt, phase, model, variant);
591
- if (pr.skipped) {
592
- if (pr.reason === "running") {
593
- await this.reporter.message(phase.messages?.on_skipped_done);
594
- return { results: [], status: "failed", aborted: true };
595
- }
596
- const result = { phase: phaseName, success: true, output: "Already completed" };
597
- this.reporter.persistPhase(phaseName, "Already completed (deduplicated)");
598
- await this.reporter.onEnd(phaseName, result);
599
- await this.reporter.step(phaseName, "done", phase.messages?.on_skipped_done);
600
- return { results: [result], status: "succeeded" };
601
- }
602
- const result = { phase: phaseName, ...pickResult(pr.result) };
603
- await this.reporter.onEnd(phaseName, result);
604
- const rawOutput = pr.result.output ?? "";
605
- const outputVars = { [phaseName]: rawOutput };
606
- if (phase.output_var)
607
- outputVars[phase.output_var] = rawOutput;
608
- if (!pr.result.success) {
609
- if (!isTerminated(pr.result.error)) {
610
- await this.reporter.step(phaseName, "failed", phase.messages?.on_failure);
611
- }
612
- this.reporter.failWorkflow(pr.result.error);
613
- return { results: [result], status: "failed", outputVars };
614
- }
615
- // on_output rules (BLOCKED).
616
- if (phase.on_output) {
617
- const blocked = await this.evaluateBlocked(phase, pr.result.output ?? "");
618
- if (blocked === "fail") {
619
- const failResult = {
620
- phase: phaseName,
621
- success: false,
622
- output: pr.result.output ?? "",
623
- error: "BLOCKED",
624
- };
625
- return { results: [failResult], status: "failed", outputVars };
626
- }
627
- }
628
- // Approval gate.
629
- if (phase.approval_gate && this.resolver.gateEnabled(phase.approval_gate) && this.run.db && this.run.workflowId) {
630
- await this.pauseForApproval(phaseName, phase.approval_gate, `${phaseName} complete — awaiting ${phase.approval_gate} approval.`, "approve", phase.approval_gate_message, { gateKey: phase.approval_gate }, undefined, phase.approval_artifact);
631
- return { results: [result], status: "succeeded", paused: true, outputVars };
632
- }
633
- this.reporter.persistPhase(phaseName);
634
- // Make THIS phase's own output available to its on_success message. The
635
- // scheduler only merges `outputVars` into the shared outputs map after
636
- // execute() returns, so without this a phase referencing its own output
637
- // (e.g. answer's `on_success: "{{answerResult}}"`, explore's publishResult)
638
- // would render empty. Merge the just-produced vars into the render context.
639
- await this.reporter.step(phaseName, "done", phase.messages?.on_success, {
640
- phaseOutputs: { ...outputs, ...outputVars },
641
- });
642
- return { results: [result], status: "succeeded", outputVars };
643
- }
644
- /**
645
- * Body for `type: bash` / `type: script` phases. Runs a deterministic command
646
- * in the sandbox (no LLM), then mirrors the agent-phase lifecycle: report
647
- * progress, expose stdout downstream via `output_var` →
648
- * `{{phaseOutputs.<name>.output}}`, fail the workflow on a non-zero exit, and
649
- * honour an optional `approval_gate`.
650
- */
651
- async runCommandBody(phase, outputs) {
652
- const phaseName = phase.name;
653
- await this.reporter.onStart(phaseName);
654
- await this.reporter.step(phaseName, "running", phase.messages?.on_start);
655
- const spec = this.buildCommandSpec(phase, outputs);
656
- const sandboxEnv = upstreamOutputsEnv(outputs);
657
- const pr = await this.runCommandPhaseCall(phaseName, phase, spec, sandboxEnv);
658
- if (pr.skipped) {
659
- if (pr.reason === "running") {
660
- await this.reporter.message(phase.messages?.on_skipped_done);
661
- return { results: [], status: "failed", aborted: true };
662
- }
663
- const result = { phase: phaseName, success: true, output: "Already completed" };
664
- this.reporter.persistPhase(phaseName, "Already completed (deduplicated)");
665
- await this.reporter.onEnd(phaseName, result);
666
- await this.reporter.step(phaseName, "done", phase.messages?.on_skipped_done);
667
- return { results: [result], status: "succeeded" };
668
- }
669
- const result = { phase: phaseName, ...pickResult(pr.result) };
670
- await this.reporter.onEnd(phaseName, result);
671
- const rawOutput = pr.result.output ?? "";
672
- const outputVars = { [phaseName]: rawOutput };
673
- if (phase.output_var)
674
- outputVars[phase.output_var] = rawOutput;
675
- if (!pr.result.success) {
676
- if (!isTerminated(pr.result.error)) {
677
- await this.reporter.step(phaseName, "failed", phase.messages?.on_failure);
678
- }
679
- this.reporter.failWorkflow(pr.result.error);
680
- return { results: [result], status: "failed", outputVars };
681
- }
682
- if (phase.approval_gate && this.resolver.gateEnabled(phase.approval_gate) && this.run.db && this.run.workflowId) {
683
- await this.pauseForApproval(phaseName, phase.approval_gate, `${phaseName} complete — awaiting ${phase.approval_gate} approval.`, "approve", phase.approval_gate_message, { gateKey: phase.approval_gate }, undefined, phase.approval_artifact);
684
- return { results: [result], status: "succeeded", paused: true, outputVars };
685
- }
686
- this.reporter.persistPhase(phaseName);
687
- await this.reporter.step(phaseName, "done", phase.messages?.on_success, {
688
- phaseOutputs: { ...outputs, ...outputVars },
689
- });
690
- return { results: [result], status: "succeeded", outputVars };
691
- }
692
- /** Render a command/script phase's template fields into a {@link CommandSpec}. */
693
- buildCommandSpec(phase, outputs) {
694
- const ctx = { ...this.run.ctx, phaseOutputs: outputs };
695
- if ((phase.type ?? "agent") === "script") {
696
- const script = renderTemplate(phase.script ?? "", ctx);
697
- return { kind: "script", script, runtime: phase.runtime ?? "js", name: phase.name.replace(/[^A-Za-z0-9_-]/g, "_") };
698
- }
699
- const command = renderTemplate(phase.command ?? "", ctx);
700
- // Guard against an unresolved template marker reaching the shell.
701
- validateShellCommand(command);
702
- return { kind: "bash", command };
703
- }
704
- async runCommandPhaseCall(label, phase, spec, sandboxEnv) {
705
- const { definition, config, db, workflowId, githubAccess, taskId, triggerId } = this.run;
706
- return runCommandPhase(definition.name, label, taskId, triggerId, spec, phaseConfigFor(config, phase), db, workflowId, githubAccess, phase.timeout_seconds, sandboxEnv);
707
- }
708
- /**
709
- * Evaluate a `generic_loop.until_bash` condition INSIDE the sandbox (against
710
- * the persisted workspace), replacing the old harness-host `execSync`. Exit 0
711
- * ⇒ loop complete. The check inherits the phase's egress; no session log is
712
- * written (it's an internal loop condition, not a user-facing phase).
713
- */
714
- async runUntilBash(command, phase) {
715
- const { config, githubAccess, taskId, triggerId, definition, workflowId } = this.run;
716
- try {
717
- validateShellCommand(command);
718
- const res = await executeCommand({ kind: "bash", command }, { ...phaseConfigFor(config, phase), telemetry: { workflowName: definition.name, phaseName: `${phase.name}_until`, triggerId, workflowRunId: workflowId } }, { taskId, githubAccess, timeoutSeconds: phase.timeout_seconds ?? 30, writeSession: false });
719
- return res.success;
720
- }
721
- catch {
722
- return false;
723
- }
724
- }
725
- /**
726
- * Evaluate the `contains_BLOCKED` rule. Returns "fail" when the workflow
727
- * should fail, or "continue" when the marker is absent or bypassed.
728
- */
729
- async evaluateBlocked(phase, output) {
730
- const rule = phase.on_output?.contains_BLOCKED;
731
- if (!rule)
732
- return "continue";
733
- if (!output.toUpperCase().includes("BLOCKED"))
734
- return "continue";
735
- const ctx = this.run.ctx;
736
- const hasUnlessLabel = rule.unless_label && ctx.issueLabels.includes(rule.unless_label);
737
- const titleMatches = (() => {
738
- if (!rule.unless_title_matches)
739
- return false;
740
- if (rule.unless_title_matches.length > 200)
741
- return false;
742
- if (/[+*]\{0,\}.*[+*]/.test(rule.unless_title_matches) || /(\([^)]*[+*][^)]*\))[+*?]/.test(rule.unless_title_matches))
743
- return false;
744
- try {
745
- return new RegExp(rule.unless_title_matches, "i").test(ctx.issueTitle || "");
746
- }
747
- catch {
748
- return false;
749
- }
750
- })();
751
- if (hasUnlessLabel || titleMatches) {
752
- await this.reporter.message(rule.bypass_message || phase.messages?.on_blocked_bypassed);
753
- return "continue";
754
- }
755
- if (rule.action === "fail") {
756
- this.run.db?.executions.markLatestAsFailed(`${this.run.definition.name}:${phase.name}`, this.run.triggerId, rule.message || "BLOCKED", this.run.workflowId);
757
- this.reporter.failWorkflow(rule.message || "BLOCKED");
758
- const blockedTemplate = rule.message || phase.messages?.on_blocked;
759
- if (blockedTemplate)
760
- await this.reporter.step(phase.name, "blocked", blockedTemplate);
761
- return "fail";
762
- }
763
- return "continue";
764
- }
765
- /**
766
- * Persist + pause for an approval/reply gate, atomically. The approval
767
- * insert, the `waiting_approval` phase marker, the optional scratch persist
768
- * (loops record their iteration/cycle state here), and the run pause all
769
- * happen in one transaction via `db.runs.pauseForApproval` — a partial
770
- * failure can't leave the run paused without its gate, or vice versa.
771
- */
772
- async pauseForApproval(stepKey, gate, summary, kind, message, extraCtx, scratchPatch, artifact) {
773
- const { db, workflowId, ctx } = this.run;
774
- if (!db || !workflowId)
775
- return;
776
- const approvalId = randomUUID();
777
- db.runs.pauseForApproval(workflowId, {
778
- id: approvalId,
779
- workflowRunId: workflowId,
780
- gate,
781
- summary,
782
- kind,
783
- artifact,
784
- requestedBy: ctx.sender,
785
- createdAt: new Date().toISOString(),
786
- }, {
787
- phase: "waiting_approval",
788
- summary: `Waiting for ${kind === "reply" ? "reply" : "approval"}: ${gate} (${approvalId})`,
789
- }, scratchPatch);
790
- // Expose `approvalId` to the gate message template so it can deep-link to
791
- // the focused approval view via `{{approvalUrl}}`. Transition the checklist
792
- // step to "awaiting" (with a collapsed detail), then post the full prompt
793
- // as a standalone note: approve gates get interactive Approve/Reject buttons
794
- // (Slack) via `approvalNote`; reply gates stay a plain message.
795
- await this.reporter.step(stepKey, "awaiting", message, { ...extraCtx, approvalId });
796
- if (kind === "approve") {
797
- await this.reporter.approvalNote(message, { ...extraCtx, approvalId }, { workflowRunId: workflowId });
798
- }
799
- else {
800
- await this.reporter.message(message, { ...extraCtx, approvalId });
801
- }
802
- }
803
- async runReviewerLoop(phase, outputs) {
804
- const loop = phase.loop;
805
- const phaseName = phase.name;
806
- const MAX_CYCLES = loop.max_cycles;
807
- const { db, workflowId, triggerId, scratch } = this.run;
808
- const wf = this.run.definition.name;
809
- const results = [];
810
- let approved = false;
811
- let fixCycles = 0;
812
- // Loop resume state. When the loop pauses at `loop.approval_gate` after a
813
- // REQUEST_CHANGES verdict, we persist the cycle we paused at so that on
814
- // resume we can tell "approved — continue with the fix cycle" apart from
815
- // "review approved → done". Without this, a dedup-`done` review on resume
816
- // would be misread as APPROVED and skip the required fix. (#94 follow-up)
817
- const loopKey = `rloop:${phaseName}`;
818
- const slot = scratch[loopKey] ?? {};
819
- const pausedAtCycle = typeof slot.pausedAtCycle === "number" ? slot.pausedAtCycle : undefined;
820
- while (!approved && fixCycles <= MAX_CYCLES) {
821
- const reviewLabel = fixCycles === 0
822
- ? PhaseRef.review(phaseName).format()
823
- : PhaseRef.recheck(phaseName, fixCycles).format();
824
- await this.reporter.onStart(reviewLabel);
825
- await this.reporter.step(reviewLabel, "running", loop.messages?.on_cycle_start, { cycle: fixCycles + 1, maxCycles: MAX_CYCLES }, fixCycles === 0
826
- ? undefined
827
- : { insert: true, label: `${phase.label ?? phaseName} (cycle ${fixCycles + 1})` });
828
- const reviewPrompt = fixCycles === 0
829
- ? buildPhasePrompt(phase, this.run.ctx, { phaseOutputs: outputs, fixCycle: fixCycles })
830
- : this.resolver.renderPrompt(loop.on_request_changes.re_review_prompt, { phaseOutputs: outputs, fixCycle: fixCycles });
831
- const { model, variant } = this.resolveModelVariant(phase.model, phase.variant, phaseName);
832
- const rr = await this.runPhaseCall(reviewLabel, reviewPrompt, phase, model, variant);
833
- // Derive this review's verdict. A dedup-`done` review (resume) is NOT
834
- // assumed approved — re-parse the verdict from its persisted output.
835
- let verdict;
836
- const reviewRan = !rr.skipped;
837
- if (rr.skipped) {
838
- if (rr.reason === "running") {
839
- await this.reporter.message(phase.messages?.on_skipped_done);
840
- return { results, status: "failed", aborted: true };
841
- }
842
- const prevOutput = db?.executions.getPhaseOutput(`${wf}:${reviewLabel}`, triggerId, workflowId) ?? "";
843
- verdict = parseReviewerVerdict(prevOutput).verdict;
844
- results.push({ phase: reviewLabel, success: true, output: "Already completed" });
845
- }
846
- else {
847
- const reviewerOutput = (rr.result.output || "").trim();
848
- let parsed = parseReviewerVerdict(reviewerOutput);
849
- let resultOverride = pickResult(rr.result);
850
- // Some models (e.g. gpt-5-codex) end their final turn on the
851
- // reviewer-verdict.md write with no trailing stdout, so the VERDICT:
852
- // marker is absent from stdout even though the review completed cleanly
853
- // — the run then mislabels a clean APPROVED as a failure AND runs a
854
- // needless fix cycle. When the marker is missing from stdout, trust the
855
- // verdict FILE (the reviewer's OUTPUT CONTRACT) and treat the phase as
856
- // successful. Gated to soft outcomes (clean exit / "unknown" /
857
- // truncated) so a hard sandbox failure can't be masked by a stale
858
- // verdict doc left in the persistent per-issue store.
859
- const soft = isSoftOutcome(rr.result);
860
- if (parsed.viaFallback && soft) {
861
- const fromFile = this.readReviewerVerdictFromFile();
862
- if (fromFile) {
863
- parsed = fromFile;
864
- resultOverride = { ...resultOverride, success: true, error: undefined };
865
- console.warn(`[runner] Reviewer stdout missing VERDICT: marker — recovered ${fromFile.verdict} from reviewer-verdict.md`);
866
- }
867
- }
868
- verdict = parsed.verdict;
869
- if (parsed.viaFallback) {
870
- console.warn(`[runner] Reviewer output missing VERDICT: marker — using fallback detection (isApproved=${verdict === "APPROVED"})`);
871
- }
872
- results.push({ phase: reviewLabel, ...resultOverride });
873
- await this.reporter.onEnd(reviewLabel, results[results.length - 1]);
874
- // Persist a marker-bearing output so a resumed run re-derives this exact
875
- // verdict (stdout may have been empty when recovered from the file).
876
- const persistText = /^\s*VERDICT:/im.test(reviewerOutput)
877
- ? reviewerOutput
878
- : `VERDICT: ${verdict}\n${reviewerOutput}`;
879
- const execId = "executionId" in rr ? rr.executionId : undefined;
880
- if (execId && db)
881
- db.executions.recordOutputText(execId, persistText);
882
- }
883
- const isApproved = verdict === "APPROVED";
884
- if (isApproved) {
885
- approved = true;
886
- if (reviewRan)
887
- this.reporter.persistPhase(reviewLabel, "APPROVED");
888
- await this.reporter.step(reviewLabel, "done", loop.messages?.on_approved, { cycle: fixCycles + 1 });
889
- }
890
- else if (fixCycles < MAX_CYCLES) {
891
- fixCycles++;
892
- if (reviewRan)
893
- this.reporter.persistPhase(reviewLabel, "REQUEST_CHANGES");
894
- // Approval gate before the fix loop. Pause only on a *fresh* gate hit:
895
- // not when the fix for this cycle has already run (gate was approved in
896
- // a prior entry), and not when we're resuming right after approving
897
- // exactly this cycle's gate.
898
- if (this.resolver.gateEnabled(loop.approval_gate) && db && workflowId) {
899
- const fixLabel = PhaseRef.fix(phaseName, fixCycles).format();
900
- const fixAlreadyDone = db.executions.shouldRunPhase(`${wf}:${fixLabel}`, triggerId, workflowId) === "done";
901
- const resumingThisGate = pausedAtCycle === fixCycles;
902
- if (!fixAlreadyDone && !resumingThisGate) {
903
- scratch[loopKey] = { ...slot, pausedAtCycle: fixCycles };
904
- await this.pauseForApproval(reviewLabel, loop.approval_gate, `Reviewer requested changes (cycle ${fixCycles}/${MAX_CYCLES}) on phase ${phaseName}.`, "approve", loop.messages?.on_pause_for_approval, { cycle: fixCycles, maxCycles: MAX_CYCLES, gateKey: loop.approval_gate }, { [loopKey]: scratch[loopKey] }, loop.approval_artifact);
905
- return { results, status: "succeeded", paused: true };
906
- }
907
- }
908
- await this.reporter.step(reviewLabel, "done", loop.messages?.on_request_changes, {
909
- cycle: fixCycles,
910
- maxCycles: MAX_CYCLES,
911
- });
912
- // Fix phase.
913
- const fixLabel = PhaseRef.fix(phaseName, fixCycles).format();
914
- await this.reporter.onStart(fixLabel);
915
- await this.reporter.step(fixLabel, "running", loop.messages?.on_fix_start, { cycle: fixCycles, maxCycles: MAX_CYCLES }, { insert: true, label: `Fix (cycle ${fixCycles})` });
916
- const { model: fixModel, variant: fixVariant } = this.resolveModelVariant(loop.on_request_changes.fix_model, loop.on_request_changes.fix_variant, `${phaseName}_fix`, phaseName);
917
- const fixPromptRendered = this.resolver.renderPrompt(loop.on_request_changes.fix_prompt, {
918
- phaseOutputs: outputs,
919
- fixCycle: fixCycles,
920
- });
921
- const fr = await this.runPhaseCall(fixLabel, fixPromptRendered, phase, fixModel, fixVariant);
922
- if (fr.skipped) {
923
- if (fr.reason === "running") {
924
- await this.reporter.message(phase.messages?.on_skipped_done);
925
- return { results, status: "failed", aborted: true };
926
- }
927
- results.push({ phase: fixLabel, success: true, output: "Already completed" });
928
- }
929
- else {
930
- results.push({ phase: fixLabel, ...pickResult(fr.result) });
931
- await this.reporter.onEnd(fixLabel, results[results.length - 1]);
932
- if (!fr.result.success) {
933
- if (!isTerminated(fr.result.error)) {
934
- await this.reporter.step(fixLabel, "failed", loop.messages?.on_fix_failed, {
935
- cycle: fixCycles,
936
- maxCycles: MAX_CYCLES,
937
- });
938
- }
939
- break;
940
- }
941
- this.reporter.persistPhase(fixLabel);
942
- await this.reporter.step(fixLabel, "done");
943
- }
944
- }
945
- else {
946
- this.reporter.persistPhase(reviewLabel, "REQUEST_CHANGES — max cycles reached");
947
- await this.reporter.step(reviewLabel, "blocked", loop.messages?.on_max_cycles, {
948
- cycle: fixCycles,
949
- maxCycles: MAX_CYCLES,
950
- }, { alsoNote: true });
951
- break;
952
- }
953
- }
954
- const outputVars = {};
955
- if (phase.output_var)
956
- outputVars[phase.output_var] = { approved, cycles: fixCycles };
957
- // The loop node itself succeeds as a scheduling unit — even at max cycles
958
- // the linear runner continued to the next phase. Individual review/fix
959
- // results carry their own success flags for the run-level rollup.
960
- return { results, status: "succeeded", outputVars };
961
- }
962
- async runGenericLoop(phase, outputs) {
963
- const loop = phase.generic_loop;
964
- const phaseName = phase.name;
965
- const MAX_ITER = loop.max_iterations;
966
- const { db, workflowId, scratch, config } = this.run;
967
- const results = [];
968
- // Reply-gate loops resume mid-flight: read the saved iteration from scratch.
969
- const scratchKey = loop.scratch_key;
970
- const scratchSlot = (scratchKey
971
- ? scratch[scratchKey] ?? {}
972
- : {});
973
- const resumeFromIter = loop.gate_kind === "reply" && typeof scratchSlot.iteration === "number"
974
- ? Math.min(scratchSlot.iteration, MAX_ITER)
975
- : 0;
976
- let iteration = resumeFromIter;
977
- let complete = false;
978
- let previousOutput = (scratchSlot.lastOutputExecutionId && db
979
- ? db.executions.getExecutionOutput(scratchSlot.lastOutputExecutionId) ?? ""
980
- : scratchSlot.lastOutput ?? "");
981
- await this.reporter.step(phaseName, "running");
982
- while (!complete && iteration < MAX_ITER) {
983
- iteration++;
984
- const iterLabel = PhaseRef.iter(phaseName, iteration).format();
985
- await this.reporter.onStart(iterLabel);
986
- const iterCtx = {
987
- iteration,
988
- maxIterations: MAX_ITER,
989
- previousOutput: loop.fresh_context ? "" : previousOutput,
990
- phaseOutputs: outputs,
991
- scratch,
992
- };
993
- const prompt = buildPhasePrompt(phase, this.run.ctx, iterCtx);
994
- const { model, variant } = this.resolveModelVariant(phase.model, phase.variant, phaseName);
995
- let ir = await this.runPhaseCall(iterLabel, prompt, phase, model, variant);
996
- if (ir.skipped) {
997
- if (ir.reason === "running") {
998
- await this.reporter.message(phase.messages?.on_skipped_done, { iteration });
999
- return { results, status: "failed", aborted: true };
1000
- }
1001
- results.push({ phase: iterLabel, success: true, output: "Already completed" });
1002
- complete = true;
1003
- break;
1004
- }
1005
- // Soft-outcome retry: a clean-but-empty turn (stop reason unknown /
1006
- // truncated, no usable output — not a real crash) re-runs the same round
1007
- // up to `on_soft_failure.retries` times, under a distinct `_iter_n_retry`
1008
- // ledger label so resume/dedup treats it as its own step. The round
1009
- // number (`scratch.iteration`) is NOT advanced by a retry.
1010
- const softPolicy = loop.on_soft_failure ?? { retries: 0, then: "fail" };
1011
- let softAttempts = 0;
1012
- while (!ir.result.success && isSoftOutcome(ir.result) && softAttempts < softPolicy.retries) {
1013
- softAttempts++;
1014
- const retryLabel = PhaseRef.iterRetry(phaseName, iteration).format();
1015
- await this.reporter.onStart(retryLabel);
1016
- const retry = await this.runPhaseCall(retryLabel, prompt, phase, model, variant);
1017
- if (retry.skipped)
1018
- break;
1019
- await this.reporter.onEnd(retryLabel, { phase: retryLabel, ...pickResult(retry.result) });
1020
- ir = retry;
1021
- }
1022
- // A soft outcome that survived the retries: honour the loop's policy.
1023
- // `then: complete` treats the loop as finished (as if the `until`
1024
- // condition matched) and advances downstream with the work gathered so
1025
- // far — recording the iteration as a success so the run-level rollup
1026
- // (`anyFailed` in runner.ts) stays green. `then: fail` (the default)
1027
- // drops through to the hard-fail path below, preserving old behavior.
1028
- if (!ir.result.success && isSoftOutcome(ir.result) && softPolicy.then === "complete") {
1029
- const iterExecutionId = "executionId" in ir ? ir.executionId : undefined;
1030
- results.push({ phase: iterLabel, success: true, output: ir.result.output || previousOutput || "" });
1031
- await this.reporter.onEnd(iterLabel, results[results.length - 1]);
1032
- complete = true;
1033
- if (scratchKey && db && workflowId) {
1034
- const slot = { ...scratchSlot, iteration, ready: true };
1035
- if (iterExecutionId)
1036
- slot.lastOutputExecutionId = iterExecutionId;
1037
- delete slot.lastOutput;
1038
- db.runs.mergeScratch(workflowId, { [scratchKey]: slot });
1039
- scratch[scratchKey] = slot;
1040
- }
1041
- this.reporter.persistPhase(iterLabel, `iteration ${iteration} — soft outcome, advancing`);
1042
- await this.reporter.step(phaseName, "done");
1043
- break;
1044
- }
1045
- results.push({ phase: iterLabel, ...pickResult(ir.result) });
1046
- await this.reporter.onEnd(iterLabel, results[results.length - 1]);
1047
- if (!ir.result.success) {
1048
- if (!isTerminated(ir.result.error)) {
1049
- await this.reporter.step(phaseName, "failed", phase.messages?.on_failure, { iteration });
1050
- }
1051
- this.reporter.failWorkflow(ir.result.error);
1052
- const outputVars = phase.output_var
1053
- ? { [phase.output_var]: { completed: false, iterations: iteration } }
1054
- : undefined;
1055
- return { results, status: "failed", outputVars };
1056
- }
1057
- const iterOutput = ir.result.output || "";
1058
- if (!loop.fresh_context) {
1059
- const combined = previousOutput ? `${previousOutput}\n${iterOutput}` : iterOutput;
1060
- previousOutput = combined.length > MAX_PREV_OUTPUT_BYTES ? combined.slice(-MAX_PREV_OUTPUT_BYTES) : combined;
1061
- }
1062
- let conditionMet = false;
1063
- if (loop.until) {
1064
- conditionMet = evalUntilExpression(loop.until, {
1065
- output: iterOutput,
1066
- scratch,
1067
- ...Object.fromEntries(Object.entries(this.run.ctx)
1068
- .filter(([, v]) => typeof v === "string")
1069
- .map(([k, v]) => [k, v])),
1070
- });
1071
- }
1072
- if (!conditionMet && loop.until_bash) {
1073
- conditionMet = await this.runUntilBash(loop.until_bash, phase);
1074
- }
1075
- const iterExecutionId = "executionId" in ir ? ir.executionId : undefined;
1076
- if (iterExecutionId && db)
1077
- db.executions.recordOutputText(iterExecutionId, iterOutput);
1078
- if (conditionMet) {
1079
- complete = true;
1080
- if (scratchKey && db && workflowId) {
1081
- const slot = { ...scratchSlot, iteration, ready: true };
1082
- if (iterExecutionId)
1083
- slot.lastOutputExecutionId = iterExecutionId;
1084
- delete slot.lastOutput;
1085
- db.runs.mergeScratch(workflowId, { [scratchKey]: slot });
1086
- scratch[scratchKey] = slot;
1087
- }
1088
- this.reporter.persistPhase(iterLabel, `iteration ${iteration} — condition met`);
1089
- await this.reporter.step(phaseName, "done");
1090
- break;
1091
- }
1092
- // Interactive gate between iterations.
1093
- if (loop.interactive && !complete && db && workflowId) {
1094
- const isReply = loop.gate_kind === "reply";
1095
- const gateMsg = loop.gate_message
1096
- ? renderTemplate(loop.gate_message, { ...this.run.ctx, phaseOutputs: outputs, iteration, maxIterations: MAX_ITER, scratch })
1097
- : `Loop iteration ${iteration}/${MAX_ITER} complete.`;
1098
- let gateScratchPatch;
1099
- if (scratchKey) {
1100
- const slot = {
1101
- ...scratch[scratchKey],
1102
- iteration,
1103
- };
1104
- if (iterExecutionId)
1105
- slot.lastOutputExecutionId = iterExecutionId;
1106
- delete slot.lastOutput;
1107
- scratch[scratchKey] = slot;
1108
- gateScratchPatch = { [scratchKey]: slot };
1109
- }
1110
- const approvalId = randomUUID();
1111
- // One transaction: persist the iteration scratch, create the pending
1112
- // gate, append the waiting_approval marker, and pause the run.
1113
- db.runs.pauseForApproval(workflowId, {
1114
- id: approvalId,
1115
- workflowRunId: workflowId,
1116
- gate: iterLabel,
1117
- summary: gateMsg,
1118
- kind: isReply ? "reply" : "approve",
1119
- requestedBy: this.run.ctx.sender,
1120
- createdAt: new Date().toISOString(),
1121
- }, {
1122
- phase: "waiting_approval",
1123
- summary: `Waiting for ${isReply ? "reply" : "approval"}: ${iterLabel} (${approvalId})`,
1124
- }, gateScratchPatch);
1125
- await this.reporter.step(phaseName, "awaiting");
1126
- if (isReply) {
1127
- const parts = [iterOutput.trim(), gateMsg.trim()].filter(Boolean);
1128
- if (parts.length > 0)
1129
- await this.reporter.postNote(parts.join("\n\n---\n\n"));
1130
- }
1131
- else {
1132
- await this.reporter.postNote(`**${phaseName} iteration ${iteration}/${MAX_ITER} complete** — approval required to continue.\n\n` +
1133
- `${gateMsg}\n\n` +
1134
- `**To continue:** comment \`@${getBotName()} approve\`\n` +
1135
- `**To abort:** comment \`@${getBotName()} reject [reason]\``);
1136
- }
1137
- return { results, status: "succeeded", paused: true };
1138
- }
1139
- this.reporter.persistPhase(iterLabel);
1140
- }
1141
- if (!complete) {
1142
- await this.reporter.step(phaseName, "failed", phase.messages?.on_failure, {
1143
- iteration,
1144
- maxIterations: MAX_ITER,
1145
- });
1146
- }
1147
- const outputVars = phase.output_var
1148
- ? { [phase.output_var]: { completed: complete, iterations: iteration } }
1149
- : undefined;
1150
- return { results, status: "succeeded", outputVars };
1151
- }
1152
- }
1153
- //# sourceMappingURL=phase-executor.js.map