@united-workforce/cli 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/README.md +45 -11
  2. package/dist/.build-fingerprint +1 -0
  3. package/dist/__tests__/adapter-json-roundtrip.test.js +17 -7
  4. package/dist/__tests__/adapter-json-roundtrip.test.js.map +1 -1
  5. package/dist/__tests__/agent-resolution-llm-free.test.d.ts +2 -0
  6. package/dist/__tests__/agent-resolution-llm-free.test.d.ts.map +1 -0
  7. package/dist/__tests__/agent-resolution-llm-free.test.js +30 -0
  8. package/dist/__tests__/agent-resolution-llm-free.test.js.map +1 -0
  9. package/dist/__tests__/build-step-entry.test.d.ts +2 -0
  10. package/dist/__tests__/build-step-entry.test.d.ts.map +1 -0
  11. package/dist/__tests__/build-step-entry.test.js +173 -0
  12. package/dist/__tests__/build-step-entry.test.js.map +1 -0
  13. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts +2 -0
  14. package/dist/__tests__/clear-thread-failed-attempts.test.d.ts.map +1 -0
  15. package/dist/__tests__/clear-thread-failed-attempts.test.js +93 -0
  16. package/dist/__tests__/clear-thread-failed-attempts.test.js.map +1 -0
  17. package/dist/__tests__/concurrency.test.d.ts +2 -0
  18. package/dist/__tests__/concurrency.test.d.ts.map +1 -0
  19. package/dist/__tests__/concurrency.test.js +196 -0
  20. package/dist/__tests__/concurrency.test.js.map +1 -0
  21. package/dist/__tests__/config.test.js +26 -302
  22. package/dist/__tests__/config.test.js.map +1 -1
  23. package/dist/__tests__/current-role.test.js +7 -6
  24. package/dist/__tests__/current-role.test.js.map +1 -1
  25. package/dist/__tests__/e2e-mock-agent.test.js +43 -30
  26. package/dist/__tests__/e2e-mock-agent.test.js.map +1 -1
  27. package/dist/__tests__/format-text-default.test.d.ts +2 -0
  28. package/dist/__tests__/format-text-default.test.d.ts.map +1 -0
  29. package/dist/__tests__/format-text-default.test.js +43 -0
  30. package/dist/__tests__/format-text-default.test.js.map +1 -0
  31. package/dist/__tests__/format-text-registry.test.d.ts +2 -0
  32. package/dist/__tests__/format-text-registry.test.d.ts.map +1 -0
  33. package/dist/__tests__/format-text-registry.test.js +158 -0
  34. package/dist/__tests__/format-text-registry.test.js.map +1 -0
  35. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts +2 -0
  36. package/dist/__tests__/issue-180-workflow-ref-removed.test.d.ts.map +1 -0
  37. package/dist/__tests__/issue-180-workflow-ref-removed.test.js +40 -0
  38. package/dist/__tests__/issue-180-workflow-ref-removed.test.js.map +1 -0
  39. package/dist/__tests__/log-text-renderer.test.d.ts +2 -0
  40. package/dist/__tests__/log-text-renderer.test.d.ts.map +1 -0
  41. package/dist/__tests__/log-text-renderer.test.js +265 -0
  42. package/dist/__tests__/log-text-renderer.test.js.map +1 -0
  43. package/dist/__tests__/moderator-evaluate.test.js +9 -50
  44. package/dist/__tests__/moderator-evaluate.test.js.map +1 -1
  45. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts +2 -0
  46. package/dist/__tests__/output-mapper-thread-list-startedat.test.d.ts.map +1 -0
  47. package/dist/__tests__/output-mapper-thread-list-startedat.test.js +102 -0
  48. package/dist/__tests__/output-mapper-thread-list-startedat.test.js.map +1 -0
  49. package/dist/__tests__/output-mapper-workflow-add.test.d.ts +2 -0
  50. package/dist/__tests__/output-mapper-workflow-add.test.d.ts.map +1 -0
  51. package/dist/__tests__/output-mapper-workflow-add.test.js +22 -0
  52. package/dist/__tests__/output-mapper-workflow-add.test.js.map +1 -0
  53. package/dist/__tests__/pid-recycling.test.d.ts +2 -0
  54. package/dist/__tests__/pid-recycling.test.d.ts.map +1 -0
  55. package/dist/__tests__/pid-recycling.test.js +273 -0
  56. package/dist/__tests__/pid-recycling.test.js.map +1 -0
  57. package/dist/__tests__/prompt.test.js +365 -2
  58. package/dist/__tests__/prompt.test.js.map +1 -1
  59. package/dist/__tests__/resolve-head-hash.test.js +12 -4
  60. package/dist/__tests__/resolve-head-hash.test.js.map +1 -1
  61. package/dist/__tests__/setup-agent-discovery.test.js +21 -30
  62. package/dist/__tests__/setup-agent-discovery.test.js.map +1 -1
  63. package/dist/__tests__/setup-complexity.test.js +2 -168
  64. package/dist/__tests__/setup-complexity.test.js.map +1 -1
  65. package/dist/__tests__/setup-no-llm.test.d.ts +2 -0
  66. package/dist/__tests__/setup-no-llm.test.d.ts.map +1 -0
  67. package/dist/__tests__/setup-no-llm.test.js +52 -0
  68. package/dist/__tests__/setup-no-llm.test.js.map +1 -0
  69. package/dist/__tests__/solve-issue-tea-worktree.test.js +27 -28
  70. package/dist/__tests__/solve-issue-tea-worktree.test.js.map +1 -1
  71. package/dist/__tests__/step-ask.test.d.ts +2 -0
  72. package/dist/__tests__/step-ask.test.d.ts.map +1 -0
  73. package/dist/__tests__/step-ask.test.js +507 -0
  74. package/dist/__tests__/step-ask.test.js.map +1 -0
  75. package/dist/__tests__/step-show-json.test.js +1 -0
  76. package/dist/__tests__/step-show-json.test.js.map +1 -1
  77. package/dist/__tests__/step-timing.test.js +2 -0
  78. package/dist/__tests__/step-timing.test.js.map +1 -1
  79. package/dist/__tests__/store-global-cas.test.js +2 -2
  80. package/dist/__tests__/store-global-cas.test.js.map +1 -1
  81. package/dist/__tests__/store-unified-threads.test.js +28 -26
  82. package/dist/__tests__/store-unified-threads.test.js.map +1 -1
  83. package/dist/__tests__/thread-cancel-status.test.js +25 -19
  84. package/dist/__tests__/thread-cancel-status.test.js.map +1 -1
  85. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts +2 -0
  86. package/dist/__tests__/thread-cancel-text-renderer.test.d.ts.map +1 -0
  87. package/dist/__tests__/thread-cancel-text-renderer.test.js +110 -0
  88. package/dist/__tests__/thread-cancel-text-renderer.test.js.map +1 -0
  89. package/dist/__tests__/thread-list-filters.test.js +354 -17
  90. package/dist/__tests__/thread-list-filters.test.js.map +1 -1
  91. package/dist/__tests__/thread-list-template-ms-date.test.d.ts +2 -0
  92. package/dist/__tests__/thread-list-template-ms-date.test.d.ts.map +1 -0
  93. package/dist/__tests__/thread-list-template-ms-date.test.js +102 -0
  94. package/dist/__tests__/thread-list-template-ms-date.test.js.map +1 -0
  95. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts +2 -0
  96. package/dist/__tests__/thread-list-workflow-corrupt.test.d.ts.map +1 -0
  97. package/dist/__tests__/thread-list-workflow-corrupt.test.js +157 -0
  98. package/dist/__tests__/thread-list-workflow-corrupt.test.js.map +1 -0
  99. package/dist/__tests__/thread-poke.test.d.ts +2 -0
  100. package/dist/__tests__/thread-poke.test.d.ts.map +1 -0
  101. package/dist/__tests__/thread-poke.test.js +422 -0
  102. package/dist/__tests__/thread-poke.test.js.map +1 -0
  103. package/dist/__tests__/thread-read-xml-tags.test.js +10 -9
  104. package/dist/__tests__/thread-read-xml-tags.test.js.map +1 -1
  105. package/dist/__tests__/thread-resume.test.js +21 -15
  106. package/dist/__tests__/thread-resume.test.js.map +1 -1
  107. package/dist/__tests__/thread-show-status.test.js +17 -28
  108. package/dist/__tests__/thread-show-status.test.js.map +1 -1
  109. package/dist/__tests__/thread-start-cwd-cli.test.js +15 -3
  110. package/dist/__tests__/thread-start-cwd-cli.test.js.map +1 -1
  111. package/dist/__tests__/thread-stop-text-renderer.test.d.ts +2 -0
  112. package/dist/__tests__/thread-stop-text-renderer.test.d.ts.map +1 -0
  113. package/dist/__tests__/thread-stop-text-renderer.test.js +148 -0
  114. package/dist/__tests__/thread-stop-text-renderer.test.js.map +1 -0
  115. package/dist/__tests__/thread-suspend-step.test.js +13 -16
  116. package/dist/__tests__/thread-suspend-step.test.js.map +1 -1
  117. package/dist/__tests__/thread-suspended-display.test.js +10 -22
  118. package/dist/__tests__/thread-suspended-display.test.js.map +1 -1
  119. package/dist/__tests__/thread-test-helpers.d.ts +7 -0
  120. package/dist/__tests__/thread-test-helpers.d.ts.map +1 -1
  121. package/dist/__tests__/thread-test-helpers.js +13 -0
  122. package/dist/__tests__/thread-test-helpers.js.map +1 -1
  123. package/dist/__tests__/thread.test.js +15 -13
  124. package/dist/__tests__/thread.test.js.map +1 -1
  125. package/dist/__tests__/validate-semantic.test.js +105 -23
  126. package/dist/__tests__/validate-semantic.test.js.map +1 -1
  127. package/dist/__tests__/workflow-list-recursive.test.d.ts +2 -0
  128. package/dist/__tests__/workflow-list-recursive.test.d.ts.map +1 -0
  129. package/dist/__tests__/workflow-list-recursive.test.js +286 -0
  130. package/dist/__tests__/workflow-list-recursive.test.js.map +1 -0
  131. package/dist/__tests__/workflow-resolution.test.js +46 -28
  132. package/dist/__tests__/workflow-resolution.test.js.map +1 -1
  133. package/dist/__tests__/workflow-show-resolution.test.d.ts +2 -0
  134. package/dist/__tests__/workflow-show-resolution.test.d.ts.map +1 -0
  135. package/dist/__tests__/workflow-show-resolution.test.js +213 -0
  136. package/dist/__tests__/workflow-show-resolution.test.js.map +1 -0
  137. package/dist/__tests__/workflow-validate.test.d.ts +2 -0
  138. package/dist/__tests__/workflow-validate.test.d.ts.map +1 -0
  139. package/dist/__tests__/workflow-validate.test.js +707 -0
  140. package/dist/__tests__/workflow-validate.test.js.map +1 -0
  141. package/dist/__tests__/write-envelope.test.d.ts +2 -0
  142. package/dist/__tests__/write-envelope.test.d.ts.map +1 -0
  143. package/dist/__tests__/write-envelope.test.js +201 -0
  144. package/dist/__tests__/write-envelope.test.js.map +1 -0
  145. package/dist/background/background.d.ts +22 -1
  146. package/dist/background/background.d.ts.map +1 -1
  147. package/dist/background/background.js +83 -6
  148. package/dist/background/background.js.map +1 -1
  149. package/dist/background/index.d.ts +1 -1
  150. package/dist/background/index.d.ts.map +1 -1
  151. package/dist/background/index.js +1 -1
  152. package/dist/background/index.js.map +1 -1
  153. package/dist/background/types.d.ts +1 -0
  154. package/dist/background/types.d.ts.map +1 -1
  155. package/dist/cli.js +120 -62
  156. package/dist/cli.js.map +1 -1
  157. package/dist/commands/config.d.ts +3 -1
  158. package/dist/commands/config.d.ts.map +1 -1
  159. package/dist/commands/config.js +17 -31
  160. package/dist/commands/config.js.map +1 -1
  161. package/dist/commands/prompt.d.ts.map +1 -1
  162. package/dist/commands/prompt.js +57 -31
  163. package/dist/commands/prompt.js.map +1 -1
  164. package/dist/commands/setup.d.ts +12 -39
  165. package/dist/commands/setup.d.ts.map +1 -1
  166. package/dist/commands/setup.js +72 -303
  167. package/dist/commands/setup.js.map +1 -1
  168. package/dist/commands/step.d.ts +44 -1
  169. package/dist/commands/step.d.ts.map +1 -1
  170. package/dist/commands/step.js +255 -11
  171. package/dist/commands/step.js.map +1 -1
  172. package/dist/commands/thread.d.ts +16 -3
  173. package/dist/commands/thread.d.ts.map +1 -1
  174. package/dist/commands/thread.js +423 -142
  175. package/dist/commands/thread.js.map +1 -1
  176. package/dist/commands/workflow.d.ts +9 -1
  177. package/dist/commands/workflow.d.ts.map +1 -1
  178. package/dist/commands/workflow.js +126 -6
  179. package/dist/commands/workflow.js.map +1 -1
  180. package/dist/concurrency/concurrency.d.ts +34 -0
  181. package/dist/concurrency/concurrency.d.ts.map +1 -0
  182. package/dist/concurrency/concurrency.js +216 -0
  183. package/dist/concurrency/concurrency.js.map +1 -0
  184. package/dist/concurrency/index.d.ts +3 -0
  185. package/dist/concurrency/index.d.ts.map +1 -0
  186. package/dist/concurrency/index.js +2 -0
  187. package/dist/concurrency/index.js.map +1 -0
  188. package/dist/concurrency/types.d.ts +19 -0
  189. package/dist/concurrency/types.d.ts.map +1 -0
  190. package/dist/concurrency/types.js +2 -0
  191. package/dist/concurrency/types.js.map +1 -0
  192. package/dist/format.d.ts +69 -2
  193. package/dist/format.d.ts.map +1 -1
  194. package/dist/format.js +198 -1
  195. package/dist/format.js.map +1 -1
  196. package/dist/moderator/__tests__/evaluate.test.js +31 -17
  197. package/dist/moderator/__tests__/evaluate.test.js.map +1 -1
  198. package/dist/moderator/evaluate.d.ts.map +1 -1
  199. package/dist/moderator/evaluate.js +4 -16
  200. package/dist/moderator/evaluate.js.map +1 -1
  201. package/dist/moderator/index.d.ts +1 -2
  202. package/dist/moderator/index.d.ts.map +1 -1
  203. package/dist/moderator/index.js +0 -1
  204. package/dist/moderator/index.js.map +1 -1
  205. package/dist/moderator/types.d.ts +6 -10
  206. package/dist/moderator/types.d.ts.map +1 -1
  207. package/dist/moderator/types.js +1 -3
  208. package/dist/moderator/types.js.map +1 -1
  209. package/dist/output-mappers.d.ts +122 -0
  210. package/dist/output-mappers.d.ts.map +1 -0
  211. package/dist/output-mappers.js +134 -0
  212. package/dist/output-mappers.js.map +1 -0
  213. package/dist/schemas.d.ts +6 -1
  214. package/dist/schemas.d.ts.map +1 -1
  215. package/dist/schemas.js +34 -5
  216. package/dist/schemas.js.map +1 -1
  217. package/dist/store.d.ts +28 -9
  218. package/dist/store.d.ts.map +1 -1
  219. package/dist/store.js +75 -16
  220. package/dist/store.js.map +1 -1
  221. package/dist/text-renderers.d.ts +30 -0
  222. package/dist/text-renderers.d.ts.map +1 -0
  223. package/dist/text-renderers.js +251 -0
  224. package/dist/text-renderers.js.map +1 -0
  225. package/dist/validate-semantic.d.ts.map +1 -1
  226. package/dist/validate-semantic.js +95 -61
  227. package/dist/validate-semantic.js.map +1 -1
  228. package/dist/validate.d.ts +6 -0
  229. package/dist/validate.d.ts.map +1 -1
  230. package/dist/validate.js +24 -0
  231. package/dist/validate.js.map +1 -1
  232. package/examples/brainstorm.yaml +130 -0
  233. package/examples/debate.yaml +169 -0
  234. package/examples/socratic-questioning.yaml +112 -0
  235. package/package.json +9 -10
  236. package/src/__tests__/adapter-json-roundtrip.test.ts +16 -7
  237. package/src/__tests__/agent-resolution-llm-free.test.ts +39 -0
  238. package/src/__tests__/build-step-entry.test.ts +203 -0
  239. package/src/__tests__/clear-thread-failed-attempts.test.ts +122 -0
  240. package/src/__tests__/concurrency.test.ts +266 -0
  241. package/src/__tests__/config.test.ts +33 -321
  242. package/src/__tests__/current-role.test.ts +7 -6
  243. package/src/__tests__/e2e-mock-agent.test.ts +65 -30
  244. package/src/__tests__/fixtures/e2e-count.workflow.yaml +1 -0
  245. package/src/__tests__/fixtures/e2e-linear.workflow.yaml +1 -0
  246. package/src/__tests__/fixtures/{e2e-mustache.workflow.yaml → e2e-liquid.workflow.yaml} +3 -2
  247. package/src/__tests__/fixtures/e2e-loop.workflow.yaml +1 -0
  248. package/src/__tests__/fixtures/e2e-suspend.mock.yaml +2 -2
  249. package/src/__tests__/fixtures/e2e-suspend.workflow.yaml +6 -10
  250. package/src/__tests__/format-text-default.test.ts +49 -0
  251. package/src/__tests__/format-text-registry.test.ts +173 -0
  252. package/src/__tests__/issue-180-workflow-ref-removed.test.ts +43 -0
  253. package/src/__tests__/log-text-renderer.test.ts +294 -0
  254. package/src/__tests__/moderator-evaluate.test.ts +9 -52
  255. package/src/__tests__/output-mapper-thread-list-startedat.test.ts +124 -0
  256. package/src/__tests__/output-mapper-workflow-add.test.ts +24 -0
  257. package/src/__tests__/pid-recycling.test.ts +329 -0
  258. package/src/__tests__/prompt.test.ts +443 -2
  259. package/src/__tests__/resolve-head-hash.test.ts +11 -4
  260. package/src/__tests__/setup-agent-discovery.test.ts +26 -51
  261. package/src/__tests__/setup-complexity.test.ts +1 -203
  262. package/src/__tests__/setup-no-llm.test.ts +68 -0
  263. package/src/__tests__/solve-issue-tea-worktree.test.ts +27 -31
  264. package/src/__tests__/step-ask.test.ts +677 -0
  265. package/src/__tests__/step-show-json.test.ts +1 -0
  266. package/src/__tests__/step-timing.test.ts +2 -0
  267. package/src/__tests__/store-global-cas.test.ts +2 -2
  268. package/src/__tests__/store-unified-threads.test.ts +30 -27
  269. package/src/__tests__/thread-cancel-status.test.ts +27 -20
  270. package/src/__tests__/thread-cancel-text-renderer.test.ts +125 -0
  271. package/src/__tests__/thread-list-filters.test.ts +443 -17
  272. package/src/__tests__/thread-list-template-ms-date.test.ts +110 -0
  273. package/src/__tests__/thread-list-workflow-corrupt.test.ts +198 -0
  274. package/src/__tests__/thread-poke.test.ts +554 -0
  275. package/src/__tests__/thread-read-xml-tags.test.ts +9 -11
  276. package/src/__tests__/thread-resume.test.ts +20 -15
  277. package/src/__tests__/thread-show-status.test.ts +17 -29
  278. package/src/__tests__/thread-start-cwd-cli.test.ts +15 -3
  279. package/src/__tests__/thread-stop-text-renderer.test.ts +168 -0
  280. package/src/__tests__/thread-suspend-step.test.ts +13 -16
  281. package/src/__tests__/thread-suspended-display.test.ts +10 -22
  282. package/src/__tests__/thread-test-helpers.ts +15 -1
  283. package/src/__tests__/thread.test.ts +14 -14
  284. package/src/__tests__/validate-semantic.test.ts +118 -33
  285. package/src/__tests__/workflow-list-recursive.test.ts +370 -0
  286. package/src/__tests__/workflow-resolution.test.ts +48 -29
  287. package/src/__tests__/workflow-show-resolution.test.ts +286 -0
  288. package/src/__tests__/workflow-validate.test.ts +828 -0
  289. package/src/__tests__/write-envelope.test.ts +257 -0
  290. package/src/background/background.ts +88 -6
  291. package/src/background/index.ts +2 -0
  292. package/src/background/types.ts +1 -0
  293. package/src/cli.ts +184 -77
  294. package/src/commands/config.ts +16 -33
  295. package/src/commands/prompt.ts +57 -31
  296. package/src/commands/setup.ts +80 -358
  297. package/src/commands/step.ts +339 -12
  298. package/src/commands/thread.ts +511 -171
  299. package/src/commands/workflow.ts +155 -4
  300. package/src/concurrency/concurrency.ts +245 -0
  301. package/src/concurrency/index.ts +10 -0
  302. package/src/concurrency/types.ts +19 -0
  303. package/src/format.ts +282 -2
  304. package/src/moderator/__tests__/evaluate.test.ts +34 -17
  305. package/src/moderator/evaluate.ts +5 -17
  306. package/src/moderator/index.ts +1 -6
  307. package/src/moderator/types.ts +6 -14
  308. package/src/output-mappers.ts +254 -0
  309. package/src/schemas.ts +51 -5
  310. package/src/store.ts +86 -20
  311. package/src/text-renderers.ts +355 -0
  312. package/src/validate-semantic.ts +125 -73
  313. package/src/validate.ts +27 -0
  314. package/dist/__tests__/setup-validate.test.d.ts +0 -2
  315. package/dist/__tests__/setup-validate.test.d.ts.map +0 -1
  316. package/dist/__tests__/setup-validate.test.js +0 -108
  317. package/dist/__tests__/setup-validate.test.js.map +0 -1
  318. package/src/__tests__/setup-validate.test.ts +0 -148
  319. /package/src/__tests__/fixtures/{e2e-mustache.mock.yaml → e2e-liquid.mock.yaml} +0 -0
@@ -1,73 +1,71 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
2
  import { access, readFile } from "node:fs/promises";
3
- import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
3
+ import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
4
4
  import { validate } from "@ocas/core";
5
- import { createThreadIndexEntry, markThreadSuspended, updateThreadHead, } from "@united-workforce/protocol";
5
+ import { createThreadIndexEntry, markThreadSuspended, SUSPEND_STATUS, updateThreadHead, } from "@united-workforce/protocol";
6
6
  import { createProcessLogger, extractUlidTimestamp, generateUlid, } from "@united-workforce/util";
7
7
  import { getEnvPath, loadWorkflowConfig } from "@united-workforce/util-agent";
8
8
  import { config as loadDotenv } from "dotenv";
9
9
  import { parse } from "yaml";
10
- import { createMarker, deleteMarker, isThreadRunning } from "../background/index.js";
10
+ import { createMarker, deleteMarker, getProcessStartTime, isMarkerValid, isThreadRunning, readMarker, } from "../background/index.js";
11
+ import { acquireSlot, DEFAULT_MAX_RUNNING, installSlotCleanup } from "../concurrency/index.js";
11
12
  import { createIncludeTag } from "../include.js";
12
- import { evaluate, isSuspendResult } from "../moderator/index.js";
13
- import { completeThread, createUwfStore, getThread, loadActiveThreads, loadHistoryThreads, loadWorkflowRegistry, resolveWorkflowHash, setThread, } from "../store.js";
13
+ import { evaluate } from "../moderator/index.js";
14
+ import { completeThread, createUwfStore, findRegistryName, getThread, loadActiveThreads, loadHistoryThreads, loadWorkflowRegistry, resolveWorkflowHash, setThread, } from "../store.js";
14
15
  import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js";
15
16
  import { validateWorkflow } from "../validate-semantic.js";
17
+ import { getConfigPath, getNestedValue, loadConfig, parseDotPath } from "./config.js";
16
18
  import { collectOrderedSteps, expandOutput, fail, walkChain, } from "./shared.js";
17
19
  import { materializeWorkflowPayload } from "./workflow.js";
18
20
  const END_ROLE = "$END";
19
21
  const START_ROLE = "$START";
20
22
  export const THREAD_READ_DEFAULT_QUOTA = 4000;
21
- function buildStepOutputFromEvaluation(workflowHash, threadId, head, status, evaluation, background) {
22
- const done = status === "completed";
23
- let currentRole = null;
24
- let suspendedRole = null;
25
- let suspendMessage = null;
26
- if (evaluation.ok) {
27
- if (isSuspendResult(evaluation.value)) {
28
- suspendedRole = evaluation.value.suspendedRole;
29
- suspendMessage = evaluation.value.prompt;
30
- }
31
- else if (evaluation.value.role !== END_ROLE) {
32
- currentRole = evaluation.value.role;
33
- }
23
+ /**
24
+ * Read the suspend reason from an agent output if it is an engine-level suspend
25
+ * (coroutine yield). Returns the reason string when `$status === "$SUSPEND"`,
26
+ * or `null` otherwise. A suspend output with no `reason` yields an empty string.
27
+ */
28
+ function readSuspendReason(lastOutput) {
29
+ if (lastOutput[STATUS_KEY] !== SUSPEND_STATUS) {
30
+ return null;
34
31
  }
32
+ const reason = lastOutput.reason;
33
+ return typeof reason === "string" ? reason : "";
34
+ }
35
+ function buildSuspendStepOutput(workflowHash, threadId, head, suspendedRole, suspendMessage) {
35
36
  return {
36
37
  workflow: workflowHash,
37
38
  thread: threadId,
38
39
  head,
39
- status,
40
- currentRole,
40
+ status: "suspended",
41
+ currentRole: null,
41
42
  suspendedRole,
42
43
  suspendMessage,
43
- done,
44
- background,
44
+ done: false,
45
+ background: null,
46
+ error: null,
45
47
  };
46
48
  }
47
- function resolveSuspendFieldsFromGraph(uwf, head, workflowRef) {
49
+ function resolveSuspendFieldsFromOutput(uwf, head) {
48
50
  const chain = walkChain(uwf, head);
49
51
  const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
50
- const workflow = loadWorkflowPayload(uwf, workflowRef);
51
- const result = evaluate(workflow.graph, lastRole, lastOutput);
52
- if (result.ok && isSuspendResult(result.value)) {
53
- return {
54
- suspendedRole: result.value.suspendedRole,
55
- suspendMessage: result.value.prompt,
56
- };
52
+ const reason = readSuspendReason(lastOutput);
53
+ if (reason !== null) {
54
+ return { suspendedRole: lastRole, suspendMessage: reason };
57
55
  }
58
56
  return { suspendedRole: null, suspendMessage: null };
59
57
  }
60
- function resolveSuspendFieldsForShow(entry, status, uwf, head, workflowRef) {
58
+ function resolveSuspendFieldsForShow(entry, status, uwf, head) {
61
59
  if (status !== "suspended") {
62
60
  return { suspendedRole: null, suspendMessage: null };
63
61
  }
64
62
  if (entry.suspendedRole !== null && entry.suspendMessage !== null) {
65
63
  return { suspendedRole: entry.suspendedRole, suspendMessage: entry.suspendMessage };
66
64
  }
67
- const fromGraph = resolveSuspendFieldsFromGraph(uwf, head, workflowRef);
65
+ const fromOutput = resolveSuspendFieldsFromOutput(uwf, head);
68
66
  return {
69
- suspendedRole: entry.suspendedRole ?? fromGraph.suspendedRole,
70
- suspendMessage: entry.suspendMessage ?? fromGraph.suspendMessage,
67
+ suspendedRole: entry.suspendedRole ?? fromOutput.suspendedRole,
68
+ suspendMessage: entry.suspendMessage ?? fromOutput.suspendMessage,
71
69
  };
72
70
  }
73
71
  async function ensureThreadSuspendMetadata(varStore, threadId, entry, suspendedRole, suspendMessage) {
@@ -78,16 +76,14 @@ async function ensureThreadSuspendMetadata(varStore, threadId, entry, suspendedR
78
76
  setThread(varStore, threadId, updated);
79
77
  return updated;
80
78
  }
81
- async function resolveActiveThreadStatus(storageRoot, threadId, uwf, head, workflowRef) {
79
+ async function resolveActiveThreadStatus(storageRoot, threadId, uwf, head) {
82
80
  const runningMarker = await isThreadRunning(storageRoot, threadId);
83
81
  if (runningMarker !== null) {
84
82
  return "running";
85
83
  }
86
84
  const chain = walkChain(uwf, head);
87
- const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
88
- const workflow = loadWorkflowPayload(uwf, workflowRef);
89
- const result = evaluate(workflow.graph, lastRole, lastOutput);
90
- if (result.ok && isSuspendResult(result.value)) {
85
+ const { lastOutput } = resolveEvaluateArgs(uwf, chain);
86
+ if (readSuspendReason(lastOutput) !== null) {
91
87
  return "suspended";
92
88
  }
93
89
  return "idle";
@@ -99,12 +95,15 @@ async function resolveActiveThreadStatus(storageRoot, threadId, uwf, head, workf
99
95
  function resolveCurrentRole(uwf, head, workflowRef) {
100
96
  const chain = walkChain(uwf, head);
101
97
  const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
98
+ if (readSuspendReason(lastOutput) !== null) {
99
+ return null;
100
+ }
102
101
  const workflow = loadWorkflowPayload(uwf, workflowRef);
103
102
  const result = evaluate(workflow.graph, lastRole, lastOutput);
104
103
  if (!result.ok) {
105
104
  return null;
106
105
  }
107
- if (isSuspendResult(result.value) || result.value.role === END_ROLE) {
106
+ if (result.value.role === END_ROLE) {
108
107
  return null;
109
108
  }
110
109
  return result.value.role;
@@ -113,10 +112,12 @@ const PL_THREAD_START = "7HNQ4B2X";
113
112
  const PL_MODERATOR = "M3K8V9T1";
114
113
  const PL_AGENT_SPAWN = "R5J2W8N4";
115
114
  const PL_AGENT_DONE = "C6P9E3H7";
115
+ const PL_AGENT_ERROR = "Z3F7K8M2";
116
116
  const PL_THREAD_ARCHIVED = "F4D8Q2K5";
117
117
  const PL_STEP_ERROR = "B8T5N1V6";
118
118
  const PL_BACKGROUND_START = "X7Q4W9M2";
119
119
  const PL_THREAD_RESUME = "K2R7M4N8";
120
+ const PL_THREAD_POKE = "P4Q9R3X7";
120
121
  function buildResumePrompt(graphPrompt, supplement) {
121
122
  if (supplement === null || supplement === "") {
122
123
  return graphPrompt;
@@ -147,18 +148,19 @@ async function workflowFileExists(dir, name, ext) {
147
148
  }
148
149
  }
149
150
  /**
150
- * Search for a workflow file in a given directory (checks both .workflow/ and .workflows/).
151
+ * Search for a workflow file in a given directory (checks both .workflows/ and .workflow/).
152
+ * `.workflows/` (primary) takes priority over `.workflow/` (legacy fallback).
151
153
  */
152
154
  async function findWorkflowInDir(dir, name) {
153
- // Check .workflow/ directory first (preferred)
155
+ // Check .workflows/ directory first (primary)
154
156
  for (const ext of [".yaml", ".yml"]) {
155
- const result = await workflowFileExists(resolvePath(dir, ".workflow"), name, ext);
157
+ const result = await workflowFileExists(resolvePath(dir, ".workflows"), name, ext);
156
158
  if (result !== null) {
157
159
  return result;
158
160
  }
159
161
  }
160
162
  for (const indexName of ["index.yaml", "index.yml"]) {
161
- const candidate = resolvePath(dir, ".workflow", name, indexName);
163
+ const candidate = resolvePath(dir, ".workflows", name, indexName);
162
164
  try {
163
165
  await access(candidate);
164
166
  return candidate;
@@ -167,15 +169,15 @@ async function findWorkflowInDir(dir, name) {
167
169
  /* not found */
168
170
  }
169
171
  }
170
- // Check .workflows/ directory as fallback (legacy)
172
+ // Check .workflow/ directory as fallback (legacy)
171
173
  for (const ext of [".yaml", ".yml"]) {
172
- const result = await workflowFileExists(resolvePath(dir, ".workflows"), name, ext);
174
+ const result = await workflowFileExists(resolvePath(dir, ".workflow"), name, ext);
173
175
  if (result !== null) {
174
176
  return result;
175
177
  }
176
178
  }
177
179
  for (const indexName of ["index.yaml", "index.yml"]) {
178
- const candidate = resolvePath(dir, ".workflows", name, indexName);
180
+ const candidate = resolvePath(dir, ".workflow", name, indexName);
179
181
  try {
180
182
  await access(candidate);
181
183
  return candidate;
@@ -186,8 +188,21 @@ async function findWorkflowInDir(dir, name) {
186
188
  }
187
189
  return null;
188
190
  }
191
+ /** Check if a directory contains a .git marker (directory or file). */
192
+ async function hasGitMarker(dir) {
193
+ try {
194
+ await access(join(dir, ".git"));
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
189
201
  /**
190
- * Traverse parent directories looking for `.workflow/<name>.yaml` or `.workflow/<name>.yml`.
202
+ * Traverse parent directories looking for a workflow named `name` under
203
+ * `.workflows/` (primary) or `.workflow/` (legacy fallback). Within each
204
+ * directory the lookup checks flat YAML files (`<name>.yaml`/`.yml`) and
205
+ * folder-based layouts (`<name>/index.yaml`/`.yml`).
191
206
  * Returns the absolute path if found, otherwise null.
192
207
  * Stops at filesystem root or .git directory.
193
208
  */
@@ -199,6 +214,10 @@ async function findWorkflowInParents(startDir, name) {
199
214
  if (found !== null) {
200
215
  return found;
201
216
  }
217
+ // Stop at .git boundary (repo root)
218
+ if (await hasGitMarker(currentDir)) {
219
+ break;
220
+ }
202
221
  // Stop at filesystem root
203
222
  if (currentDir === root) {
204
223
  break;
@@ -346,8 +365,8 @@ export async function cmdThreadShow(storageRoot, threadId) {
346
365
  if (workflow === null) {
347
366
  fail(`failed to resolve workflow from head: ${activeHead}`);
348
367
  }
349
- // Determine if this is a completed/cancelled thread
350
- if (entry.status === "completed" || entry.status === "cancelled") {
368
+ // Determine if this is an ended/cancelled thread
369
+ if (entry.status === "end" || entry.status === "cancelled") {
351
370
  const hint = null;
352
371
  return {
353
372
  workflow,
@@ -359,13 +378,14 @@ export async function cmdThreadShow(storageRoot, threadId) {
359
378
  suspendMessage: null,
360
379
  done: true,
361
380
  background: null,
381
+ error: null,
362
382
  hint,
363
383
  };
364
384
  }
365
385
  // Active thread
366
- const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, activeHead, workflow);
386
+ const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, activeHead);
367
387
  const currentRole = resolveCurrentRole(uwf, activeHead, workflow);
368
- const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, activeHead, workflow);
388
+ const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, activeHead);
369
389
  const hint = status === "suspended"
370
390
  ? `Thread is suspended. Resume with: uwf thread resume ${threadId}`
371
391
  : null;
@@ -379,15 +399,25 @@ export async function cmdThreadShow(storageRoot, threadId) {
379
399
  suspendMessage: suspendFields.suspendMessage,
380
400
  done: false,
381
401
  background: null,
402
+ error: null,
382
403
  hint,
383
404
  };
384
405
  }
385
- async function threadListItemFromActive(storageRoot, uwf, threadId, head) {
406
+ async function threadListItemFromActive(storageRoot, uwf, threadId, head, registry) {
386
407
  const workflow = resolveWorkflowFromHead(uwf, head);
387
408
  if (workflow === null) {
388
- return null;
409
+ // Head CAS node missing or unrecognized — treat as corrupt rather than silently skipping
410
+ return {
411
+ thread: threadId,
412
+ workflow: "",
413
+ head,
414
+ status: "corrupt",
415
+ currentRole: null,
416
+ statusDisplay: "corrupt",
417
+ workflowName: null,
418
+ };
389
419
  }
390
- const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, head, workflow);
420
+ const status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, head);
391
421
  const statusDisplay = status === "suspended" ? `${status} [suspended]` : status;
392
422
  return {
393
423
  thread: threadId,
@@ -396,35 +426,67 @@ async function threadListItemFromActive(storageRoot, uwf, threadId, head) {
396
426
  status,
397
427
  currentRole: resolveCurrentRole(uwf, head, workflow),
398
428
  statusDisplay,
429
+ workflowName: findRegistryName(registry, workflow),
399
430
  };
400
431
  }
401
- async function collectActiveThreads(storageRoot, uwf, index) {
432
+ async function collectActiveThreads(storageRoot, uwf, index, registry) {
402
433
  const items = [];
403
434
  for (const [threadId, entry] of Object.entries(index)) {
404
- const item = await threadListItemFromActive(storageRoot, uwf, threadId, entry.head);
405
- if (item !== null) {
406
- items.push(item);
435
+ try {
436
+ const item = await threadListItemFromActive(storageRoot, uwf, threadId, entry.head, registry);
437
+ if (item !== null) {
438
+ items.push(item);
439
+ }
440
+ }
441
+ catch (err) {
442
+ const message = err instanceof Error ? err.message : String(err);
443
+ process.stderr.write(`warning: thread ${threadId} is corrupt: ${message}\n`);
444
+ items.push({
445
+ thread: threadId,
446
+ workflow: "",
447
+ head: entry.head,
448
+ status: "corrupt",
449
+ currentRole: null,
450
+ statusDisplay: "corrupt",
451
+ workflowName: null,
452
+ });
407
453
  }
408
454
  }
409
455
  return items;
410
456
  }
411
- function collectCompletedThreads(uwf, activeIds) {
457
+ function collectCompletedThreads(uwf, activeIds, registry) {
412
458
  const items = [];
413
459
  const history = loadHistoryThreads(uwf.varStore);
414
460
  const seen = new Set(); // Deduplication (issue #470)
415
461
  for (const [threadId, entry] of Object.entries(history)) {
416
462
  if (!activeIds.has(threadId) && !seen.has(threadId)) {
417
463
  seen.add(threadId);
418
- const status = entry.status;
419
- const workflow = resolveWorkflowFromHead(uwf, entry.head);
420
- items.push({
421
- thread: threadId,
422
- workflow: workflow ?? "",
423
- head: entry.head,
424
- status,
425
- currentRole: null,
426
- statusDisplay: status,
427
- });
464
+ try {
465
+ const status = entry.status;
466
+ const workflow = resolveWorkflowFromHead(uwf, entry.head);
467
+ items.push({
468
+ thread: threadId,
469
+ workflow: workflow ?? "",
470
+ head: entry.head,
471
+ status,
472
+ currentRole: null,
473
+ statusDisplay: status,
474
+ workflowName: workflow !== null ? findRegistryName(registry, workflow) : null,
475
+ });
476
+ }
477
+ catch (err) {
478
+ const message = err instanceof Error ? err.message : String(err);
479
+ process.stderr.write(`warning: completed thread ${threadId} is corrupt: ${message}\n`);
480
+ items.push({
481
+ thread: threadId,
482
+ workflow: "",
483
+ head: entry.head,
484
+ status: "corrupt",
485
+ currentRole: null,
486
+ statusDisplay: "corrupt",
487
+ workflowName: null,
488
+ });
489
+ }
428
490
  }
429
491
  }
430
492
  return items;
@@ -455,23 +517,28 @@ function applyPagination(items, skip, take) {
455
517
  const takeCount = take ?? items.length;
456
518
  return items.slice(skipCount, skipCount + takeCount);
457
519
  }
458
- export async function cmdThreadList(storageRoot, statusFilter, afterMs, beforeMs, skip, take) {
520
+ export async function cmdThreadList(storageRoot, statusFilter, afterMs, beforeMs, skip, take, showAll = false) {
459
521
  const uwf = await createUwfStore(storageRoot);
460
522
  const index = loadActiveThreads(uwf.varStore);
523
+ const registry = loadWorkflowRegistry(uwf.varStore);
524
+ // Resolve the effective filter:
525
+ // - explicit --status wins (showAll has no effect)
526
+ // - otherwise: --all → no filter; default → ["idle", "running"]
527
+ const effectiveFilter = statusFilter !== null ? statusFilter : showAll ? null : ["idle", "running", "corrupt"];
461
528
  // Collect active threads
462
- let items = await collectActiveThreads(storageRoot, uwf, index);
529
+ let items = await collectActiveThreads(storageRoot, uwf, index, registry);
463
530
  // Collect completed threads (if relevant for status filter)
464
- const includeCompleted = statusFilter === null ||
465
- statusFilter.includes("completed") ||
466
- statusFilter.includes("cancelled");
531
+ const includeCompleted = effectiveFilter === null ||
532
+ effectiveFilter.includes("end") ||
533
+ effectiveFilter.includes("cancelled");
467
534
  if (includeCompleted) {
468
535
  const activeIds = new Set(items.map((i) => i.thread));
469
- const completedItems = collectCompletedThreads(uwf, activeIds);
536
+ const completedItems = collectCompletedThreads(uwf, activeIds, registry);
470
537
  items = items.concat(completedItems);
471
538
  }
472
539
  // Apply status filter
473
- if (statusFilter !== null) {
474
- items = items.filter((item) => statusFilter.includes(item.status));
540
+ if (effectiveFilter !== null) {
541
+ items = items.filter((item) => effectiveFilter.includes(item.status));
475
542
  }
476
543
  // Apply time range filters
477
544
  items = applyTimeFilters(items, afterMs, beforeMs);
@@ -636,6 +703,14 @@ function formatThreadReadMarkdown(options) {
636
703
  return parts.join("\n\n---\n\n");
637
704
  }
638
705
  const STATUS_KEY = "$status";
706
+ /**
707
+ * Strip YAML frontmatter (---...---) from a raw markdown string,
708
+ * returning only the body portion.
709
+ */
710
+ function stripFrontmatter(raw) {
711
+ const match = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
712
+ return match ? raw.slice(match[0].length).trim() : raw.trim();
713
+ }
639
714
  function resolveEvaluateArgs(uwf, chain) {
640
715
  if (chain.headIsStart) {
641
716
  return { lastRole: START_ROLE, lastOutput: { [STATUS_KEY]: "new" } };
@@ -648,6 +723,12 @@ function resolveEvaluateArgs(uwf, chain) {
648
723
  const base = typeof raw === "object" && raw !== null && !Array.isArray(raw)
649
724
  ? raw
650
725
  : {};
726
+ // Inject _body — the markdown body (after frontmatter) from the last step's
727
+ // assistant output. Workflow edge prompts can reference it via {{ _body }}.
728
+ const content = extractLastAssistantContent(uwf, lastStep.detail);
729
+ if (content !== null) {
730
+ base._body = stripFrontmatter(content);
731
+ }
651
732
  return {
652
733
  lastRole: lastStep.role,
653
734
  lastOutput: base,
@@ -656,10 +737,10 @@ function resolveEvaluateArgs(uwf, chain) {
656
737
  function loadWorkflowPayload(uwf, workflowRef) {
657
738
  const node = uwf.store.cas.get(workflowRef);
658
739
  if (node === null) {
659
- fail(`workflow CAS node not found: ${workflowRef}`);
740
+ throw new Error(`workflow CAS node not found: ${workflowRef}`);
660
741
  }
661
742
  if (node.type !== uwf.schemas.workflow) {
662
- fail(`node ${workflowRef} is not a Workflow`);
743
+ throw new Error(`node ${workflowRef} is not a Workflow`);
663
744
  }
664
745
  return node.payload;
665
746
  }
@@ -697,11 +778,9 @@ function resolveAgentConfig(config, workflow, role, agentOverride) {
697
778
  }
698
779
  return agentConfig;
699
780
  }
700
- function spawnAgent(plog, agent, threadId, role, edgePrompt, cwd) {
701
- const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
702
- let stdout;
781
+ function executeAgentCommand(agent, argv, cwd, plog) {
703
782
  try {
704
- stdout = execFileSync(agent.command, argv, {
783
+ return execFileSync(agent.command, argv, {
705
784
  encoding: "utf8",
706
785
  stdio: ["ignore", "pipe", "pipe"],
707
786
  maxBuffer: 50 * 1024 * 1024, // 50 MB — stream-json output can be large
@@ -721,14 +800,17 @@ function spawnAgent(plog, agent, threadId, role, edgePrompt, cwd) {
721
800
  const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
722
801
  failStep(plog, `agent command failed (${agent.command})${detail}`);
723
802
  }
803
+ }
804
+ function parseAgentOutput(stdout, plog) {
724
805
  const line = stdout.trim().split("\n").pop()?.trim() ?? "";
725
- let parsed;
726
806
  try {
727
- parsed = JSON.parse(line);
807
+ return JSON.parse(line);
728
808
  }
729
809
  catch {
730
810
  failStep(plog, `agent stdout last line is not valid JSON: ${line || "(empty)"}`);
731
811
  }
812
+ }
813
+ function validateAndNormalizeOutput(parsed, line, plog) {
732
814
  const obj = parsed;
733
815
  if (typeof obj !== "object" ||
734
816
  obj === null ||
@@ -736,10 +818,33 @@ function spawnAgent(plog, agent, threadId, role, edgePrompt, cwd) {
736
818
  !isCasRef(obj.stepHash)) {
737
819
  failStep(plog, `agent stdout JSON missing valid stepHash: ${line}`);
738
820
  }
821
+ // Normalize isError / errorMessage so downstream code can rely on them.
822
+ // Legacy adapters that don't emit these fields default to isError=false.
823
+ if (obj.isError !== undefined && typeof obj.isError !== "boolean") {
824
+ failStep(plog, `agent stdout JSON has non-boolean isError: ${line}`);
825
+ }
826
+ if (obj.isError === undefined) {
827
+ obj.isError = false;
828
+ }
829
+ if (obj.errorMessage !== undefined &&
830
+ obj.errorMessage !== null &&
831
+ typeof obj.errorMessage !== "string") {
832
+ failStep(plog, `agent stdout JSON has non-string errorMessage: ${line}`);
833
+ }
834
+ if (obj.errorMessage === undefined) {
835
+ obj.errorMessage = null;
836
+ }
739
837
  return obj;
740
838
  }
839
+ function spawnAgent(plog, agent, threadId, role, edgePrompt, cwd) {
840
+ const argv = [...agent.args, "--thread", threadId, "--role", role, "--prompt", edgePrompt];
841
+ const stdout = executeAgentCommand(agent, argv, cwd, plog);
842
+ const line = stdout.trim().split("\n").pop()?.trim() ?? "";
843
+ const parsed = parseAgentOutput(stdout, plog);
844
+ return validateAndNormalizeOutput(parsed, line, plog);
845
+ }
741
846
  function archiveThread(uwf, threadId, _workflow, _head) {
742
- completeThread(uwf.varStore, threadId, "completed");
847
+ completeThread(uwf.varStore, threadId, "end");
743
848
  }
744
849
  export async function cmdThreadResume(storageRoot, threadId, supplement, agentOverride) {
745
850
  const runningMarker = await isThreadRunning(storageRoot, threadId);
@@ -754,15 +859,15 @@ export async function cmdThreadResume(storageRoot, threadId, supplement, agentOv
754
859
  const headHash = entry.head;
755
860
  const chain = walkChain(uwf, headHash);
756
861
  const workflowHash = chain.start.workflow;
757
- // Check entry.status first for completed/cancelled (like in cmdThreadShow)
862
+ // Check entry.status first for end/cancelled (like in cmdThreadShow)
758
863
  let status;
759
- if (entry.status === "completed" || entry.status === "cancelled") {
864
+ if (entry.status === "end" || entry.status === "cancelled") {
760
865
  status = entry.status;
761
866
  }
762
867
  else {
763
- status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, headHash, workflowHash);
868
+ status = await resolveActiveThreadStatus(storageRoot, threadId, uwf, headHash);
764
869
  }
765
- if (status !== "suspended" && status !== "completed") {
870
+ if (status !== "suspended" && status !== "end") {
766
871
  fail(`thread cannot be resumed: ${threadId} (status: ${status})`);
767
872
  }
768
873
  const plog = createProcessLogger({
@@ -770,7 +875,7 @@ export async function cmdThreadResume(storageRoot, threadId, supplement, agentOv
770
875
  context: { thread: threadId, workflow: workflowHash },
771
876
  });
772
877
  if (status === "suspended") {
773
- const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, headHash, workflowHash);
878
+ const suspendFields = resolveSuspendFieldsForShow(entry, status, uwf, headHash);
774
879
  if (suspendFields.suspendedRole === null) {
775
880
  fail(`thread is suspended but suspendedRole is missing: ${threadId}`);
776
881
  }
@@ -784,40 +889,169 @@ export async function cmdThreadResume(storageRoot, threadId, supplement, agentOv
784
889
  prompt: resumePrompt,
785
890
  });
786
891
  }
787
- // status === "completed"
892
+ // status === "end"
788
893
  const workflow = loadWorkflowPayload(uwf, workflowHash);
789
894
  const startResult = evaluate(workflow.graph, START_ROLE, { [STATUS_KEY]: "resume" });
790
895
  if (!startResult.ok) {
791
896
  fail(`failed to evaluate $START: ${startResult.error.message}`);
792
897
  }
793
- if (isSuspendResult(startResult.value)) {
794
- fail("workflow cannot start with $SUSPEND");
795
- }
796
898
  if (startResult.value.role === END_ROLE) {
797
899
  fail("workflow cannot start with $END");
798
900
  }
799
901
  const startRole = startResult.value.role;
800
- const completedResumePrompt = buildResumePrompt(startResult.value.prompt, supplement);
902
+ const endResumePrompt = buildResumePrompt(startResult.value.prompt, supplement);
801
903
  const updatedEntry = { ...entry, status: "idle", completedAt: null };
802
904
  setThread(uwf.varStore, threadId, updatedEntry);
803
905
  plog.log(PL_THREAD_RESUME, `resume completed role=${startRole} supplement=${supplement !== null}`, null);
804
906
  return cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog, {
805
907
  role: startRole,
806
- prompt: completedResumePrompt,
908
+ prompt: endResumePrompt,
909
+ });
910
+ }
911
+ /**
912
+ * Validate that a thread can be poked. Returns the existing entry and the head StepNode payload.
913
+ * Fails (process exit) when the thread is missing, running, completed, cancelled, or has no
914
+ * StepNode at its head.
915
+ */
916
+ async function validatePokePreconditions(storageRoot, uwf, threadId) {
917
+ const runningMarker = await isThreadRunning(storageRoot, threadId);
918
+ if (runningMarker !== null) {
919
+ fail(`thread already executing in background (PID: ${runningMarker.pid})`);
920
+ }
921
+ const entry = getThread(uwf.varStore, threadId);
922
+ if (entry === null) {
923
+ fail(`thread not active: ${threadId}`);
924
+ }
925
+ if (entry.status === "end" || entry.status === "cancelled") {
926
+ fail(`thread cannot be poked: ${threadId} (status: ${entry.status})`);
927
+ }
928
+ const oldHead = entry.head;
929
+ const oldHeadNode = uwf.store.cas.get(oldHead);
930
+ if (oldHeadNode === null) {
931
+ fail(`CAS node not found: ${oldHead}`);
932
+ }
933
+ if (oldHeadNode.type !== uwf.schemas.stepNode) {
934
+ fail("thread cannot be poked: no step to replace (head is StartNode)");
935
+ }
936
+ return { entry, oldHead, oldHeadPayload: oldHeadNode.payload };
937
+ }
938
+ /**
939
+ * Resolve the next role from the post-poke chain state, used for the StepOutput.currentRole field.
940
+ * Returns null when the next role is $END, evaluation fails, or the result is a suspend.
941
+ */
942
+ function resolveCurrentRoleFromChain(uwfAfter, workflow, replacedHash) {
943
+ const chainAfter = walkChain(uwfAfter, replacedHash);
944
+ const { lastRole, lastOutput } = resolveEvaluateArgs(uwfAfter, chainAfter);
945
+ if (readSuspendReason(lastOutput) !== null) {
946
+ return null;
947
+ }
948
+ const afterResult = evaluate(workflow.graph, lastRole, lastOutput);
949
+ if (!afterResult.ok) {
950
+ return null;
951
+ }
952
+ if (afterResult.value.role === END_ROLE) {
953
+ return null;
954
+ }
955
+ return afterResult.value.role;
956
+ }
957
+ /**
958
+ * Poke a thread: re-run the agent on the head step with a supplementary prompt,
959
+ * replacing the head step's output. The new step's `prev` points to the OLD head's
960
+ * `prev` — semantically replacing (not appending to) the head. The moderator is NOT
961
+ * re-evaluated for routing; the role of the head step is re-used.
962
+ */
963
+ export async function cmdThreadPoke(storageRoot, threadId, prompt, agentOverride) {
964
+ const uwf = await createUwfStore(storageRoot);
965
+ const { entry, oldHeadPayload } = await validatePokePreconditions(storageRoot, uwf, threadId);
966
+ const chain = walkChain(uwf, entry.head);
967
+ const workflowHash = chain.start.workflow;
968
+ const threadCwd = chain.start.cwd;
969
+ const plog = createProcessLogger({
970
+ storageRoot,
971
+ context: { thread: threadId, workflow: workflowHash },
807
972
  });
973
+ // Resolve the agent: --agent override wins; otherwise read from old head step's `agent` field.
974
+ const config = await loadWorkflowConfig(storageRoot);
975
+ const workflow = loadWorkflowPayload(uwf, workflowHash);
976
+ const role = oldHeadPayload.role;
977
+ const agent = agentOverride !== null
978
+ ? resolveAgentConfig(config, workflow, role, agentOverride)
979
+ : parseAgentOverride(oldHeadPayload.agent);
980
+ const effectiveCwd = oldHeadPayload.cwd !== "" ? oldHeadPayload.cwd : threadCwd;
981
+ plog.log(PL_THREAD_POKE, `poke role=${role} agent=${agent.command}`, null);
982
+ plog.log(PL_AGENT_SPAWN, `spawning agent command=${agent.command}`, {
983
+ args: [...agent.args, threadId, role].join(" "),
984
+ });
985
+ loadDotenv({ path: getEnvPath(storageRoot) });
986
+ // Spawn the agent. The agent will create a new StepNode with prev=oldHead (it reads
987
+ // the active thread head). After the agent returns, we rewrite that node's prev so
988
+ // that the new head replaces the old head instead of appending after it.
989
+ const agentResult = spawnAgent(plog, agent, threadId, role, prompt, effectiveCwd);
990
+ const agentStepHash = agentResult.stepHash;
991
+ plog.log(PL_AGENT_DONE, `agent returned head=${agentStepHash}`, null);
992
+ const uwfAfter = await createUwfStore(storageRoot);
993
+ const agentNode = uwfAfter.store.cas.get(agentStepHash);
994
+ if (agentNode === null || agentNode.type !== uwfAfter.schemas.stepNode) {
995
+ failStep(plog, `agent returned hash that is not a StepNode: ${agentStepHash}`);
996
+ }
997
+ const agentPayload = agentNode.payload;
998
+ // Rewrite the new step so that its `prev` points to the OLD head's prev (replace semantics).
999
+ const replacedPayload = {
1000
+ ...agentPayload,
1001
+ prev: oldHeadPayload.prev,
1002
+ };
1003
+ const replacedHash = await uwfAfter.store.cas.put(uwfAfter.schemas.stepNode, replacedPayload);
1004
+ const replacedNode = uwfAfter.store.cas.get(replacedHash);
1005
+ if (replacedNode === null || !validate(uwfAfter.store, replacedNode)) {
1006
+ failStep(plog, "rewritten StepNode failed schema validation");
1007
+ }
1008
+ // Update thread head to the replaced step. Status becomes idle (no moderator re-route).
1009
+ setThread(uwfAfter.varStore, threadId, updateThreadHead(entry, replacedHash));
1010
+ return {
1011
+ workflow: workflowHash,
1012
+ thread: threadId,
1013
+ head: replacedHash,
1014
+ status: "idle",
1015
+ currentRole: resolveCurrentRoleFromChain(uwfAfter, workflow, replacedHash),
1016
+ suspendedRole: null,
1017
+ suspendMessage: null,
1018
+ done: false,
1019
+ background: null,
1020
+ error: null,
1021
+ };
808
1022
  }
809
1023
  export function validateCount(count) {
810
1024
  if (count < 1 || !Number.isInteger(count)) {
811
1025
  throw new Error(`--count must be a positive integer, got: ${count}`);
812
1026
  }
813
1027
  }
1028
+ /**
1029
+ * Resolve the effective maxRunning limit.
1030
+ * Priority: config file > DEFAULT_MAX_RUNNING (2).
1031
+ */
1032
+ async function resolveMaxRunning(storageRoot) {
1033
+ try {
1034
+ const configPath = getConfigPath(storageRoot);
1035
+ const config = loadConfig(configPath);
1036
+ const path = parseDotPath("concurrency.maxRunning");
1037
+ const value = getNestedValue(config, path);
1038
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) {
1039
+ return value;
1040
+ }
1041
+ }
1042
+ catch {
1043
+ // Config file missing or invalid — fall through to default
1044
+ }
1045
+ return DEFAULT_MAX_RUNNING;
1046
+ }
814
1047
  export async function cmdThreadExec(storageRoot, threadId, agentOverride, count, background, backgroundWorker) {
815
1048
  validateCount(count);
816
- // Check if thread is already running in background (unless we ARE the background worker)
1049
+ // Reject concurrent exec on the same thread (unless we ARE the background worker,
1050
+ // which hasn't created its own marker yet at this point).
817
1051
  if (!backgroundWorker) {
818
1052
  const runningMarker = await isThreadRunning(storageRoot, threadId);
819
1053
  if (runningMarker !== null) {
820
- fail(`thread already executing in background (PID: ${runningMarker.pid})`);
1054
+ fail(`thread ${threadId} is already being executed by PID ${runningMarker.pid}`);
821
1055
  }
822
1056
  }
823
1057
  const workflowHash = await resolveActiveThreadWorkflowHash(storageRoot, threadId);
@@ -829,17 +1063,20 @@ export async function cmdThreadExec(storageRoot, threadId, agentOverride, count,
829
1063
  // Spawn background process
830
1064
  return cmdThreadStepBackground(storageRoot, threadId, agentOverride, count, plog, workflowHash);
831
1065
  }
832
- // If we're the background worker, create marker before execution
833
- let markerCreated = false;
834
- if (backgroundWorker) {
835
- await createMarker(storageRoot, {
836
- thread: threadId,
837
- workflow: workflowHash,
838
- pid: process.pid,
839
- startedAt: Date.now(),
840
- });
841
- markerCreated = true;
842
- }
1066
+ // Create running marker so `thread list` shows "running" during execution
1067
+ // and concurrent `exec` on the same thread is rejected (see check above).
1068
+ await createMarker(storageRoot, {
1069
+ thread: threadId,
1070
+ workflow: workflowHash,
1071
+ pid: process.pid,
1072
+ startedAt: Date.now(),
1073
+ processStartTime: getProcessStartTime(process.pid),
1074
+ });
1075
+ // Resolve concurrency limit: config > default
1076
+ const effectiveMaxRunning = await resolveMaxRunning(storageRoot);
1077
+ // Acquire concurrency slot (blocks if at capacity)
1078
+ const slotHandle = await acquireSlot(storageRoot, effectiveMaxRunning);
1079
+ const uninstallCleanup = installSlotCleanup(slotHandle);
843
1080
  try {
844
1081
  const results = [];
845
1082
  for (let i = 0; i < count; i++) {
@@ -852,10 +1089,9 @@ export async function cmdThreadExec(storageRoot, threadId, agentOverride, count,
852
1089
  return results;
853
1090
  }
854
1091
  finally {
855
- // Cleanup marker if we created one
856
- if (markerCreated) {
857
- await deleteMarker(storageRoot, threadId);
858
- }
1092
+ uninstallCleanup();
1093
+ await slotHandle.release();
1094
+ await deleteMarker(storageRoot, threadId);
859
1095
  }
860
1096
  }
861
1097
  async function resolveActiveThreadWorkflowHash(storageRoot, threadId) {
@@ -903,6 +1139,7 @@ async function cmdThreadStepBackground(storageRoot, threadId, agentOverride, cou
903
1139
  suspendMessage: null,
904
1140
  done: false,
905
1141
  background: true,
1142
+ error: null,
906
1143
  },
907
1144
  ];
908
1145
  }
@@ -917,17 +1154,19 @@ function resolveResumeStepTarget(resume, chain, threadCwd, plog) {
917
1154
  }
918
1155
  async function resolveModeratorStepTarget(_storageRoot, threadId, entry, headHash, workflowHash, workflow, uwf, chain, threadCwd, plog) {
919
1156
  const { lastRole, lastOutput } = resolveEvaluateArgs(uwf, chain);
1157
+ // Intercept an already-suspended head before the moderator: a thread whose
1158
+ // head step yielded `$status: "$SUSPEND"` stays suspended (idempotent re-exec).
1159
+ const suspendReason = readSuspendReason(lastOutput);
1160
+ if (suspendReason !== null) {
1161
+ await ensureThreadSuspendMetadata(uwf.varStore, threadId, entry, lastRole, suspendReason);
1162
+ plog.log(PL_MODERATOR, `moderator action=suspend suspendedRole=${lastRole}`, null);
1163
+ return buildSuspendStepOutput(workflowHash, threadId, headHash, lastRole, suspendReason);
1164
+ }
920
1165
  const nextResult = evaluate(workflow.graph, lastRole, lastOutput);
921
1166
  if (!nextResult.ok) {
922
1167
  failStep(plog, `moderator evaluate failed: ${nextResult.error.message}`);
923
1168
  }
924
- plog.log(PL_MODERATOR, `moderator ${isSuspendResult(nextResult.value)
925
- ? `action=suspend suspendedRole=${nextResult.value.suspendedRole}`
926
- : `role=${nextResult.value.role}`} prompt=${nextResult.value.prompt}`, null);
927
- if (isSuspendResult(nextResult.value)) {
928
- await ensureThreadSuspendMetadata(uwf.varStore, threadId, entry, nextResult.value.suspendedRole, nextResult.value.prompt);
929
- return buildStepOutputFromEvaluation(workflowHash, threadId, headHash, "suspended", nextResult, null);
930
- }
1169
+ plog.log(PL_MODERATOR, `moderator role=${nextResult.value.role} prompt=${nextResult.value.prompt}`, null);
931
1170
  if (nextResult.value.role === END_ROLE) {
932
1171
  plog.log(PL_THREAD_ARCHIVED, `thread archived head=${headHash}`, null);
933
1172
  archiveThread(uwf, threadId, workflowHash, headHash);
@@ -935,12 +1174,13 @@ async function resolveModeratorStepTarget(_storageRoot, threadId, entry, headHas
935
1174
  workflow: workflowHash,
936
1175
  thread: threadId,
937
1176
  head: headHash,
938
- status: "completed",
1177
+ status: "end",
939
1178
  currentRole: null,
940
1179
  suspendedRole: null,
941
1180
  suspendMessage: null,
942
1181
  done: true,
943
1182
  background: null,
1183
+ error: null,
944
1184
  };
945
1185
  }
946
1186
  return {
@@ -954,20 +1194,24 @@ async function finalizeAgentStep(_storageRoot, threadId, workflowHash, workflow,
954
1194
  setThread(uwfAfter.varStore, threadId, updateThreadHead(priorEntry, newHead));
955
1195
  const chainAfter = walkChain(uwfAfter, newHead);
956
1196
  const { lastRole: lastRoleAfter, lastOutput: lastOutputAfter } = resolveEvaluateArgs(uwfAfter, chainAfter);
1197
+ // Intercept `$status: "$SUSPEND"` before the moderator (coroutine yield): the
1198
+ // step is already in CAS and the head has advanced — mark the thread suspended
1199
+ // and return without routing through the graph.
1200
+ const suspendReason = readSuspendReason(lastOutputAfter);
1201
+ if (suspendReason !== null) {
1202
+ setThread(uwfAfter.varStore, threadId, markThreadSuspended(getThread(uwfAfter.varStore, threadId) ?? createThreadIndexEntry(newHead), lastRoleAfter, suspendReason));
1203
+ return buildSuspendStepOutput(workflowHash, threadId, newHead, lastRoleAfter, suspendReason);
1204
+ }
957
1205
  const afterResult = evaluate(workflow.graph, lastRoleAfter, lastOutputAfter);
958
1206
  if (!afterResult.ok) {
959
1207
  failStep(plog, `post-step moderator evaluate failed: ${afterResult.error.message}`);
960
1208
  }
961
- if (isSuspendResult(afterResult.value)) {
962
- setThread(uwfAfter.varStore, threadId, markThreadSuspended(getThread(uwfAfter.varStore, threadId) ?? createThreadIndexEntry(newHead), afterResult.value.suspendedRole, afterResult.value.prompt));
963
- return buildStepOutputFromEvaluation(workflowHash, threadId, newHead, "suspended", afterResult, null);
964
- }
965
1209
  const done = afterResult.value.role === END_ROLE;
966
1210
  if (done) {
967
1211
  plog.log(PL_THREAD_ARCHIVED, `thread archived head=${newHead}`, null);
968
1212
  archiveThread(uwfAfter, threadId, workflowHash, newHead);
969
1213
  }
970
- const status = done ? "completed" : "idle";
1214
+ const status = done ? "end" : "idle";
971
1215
  const currentRole = done ? null : afterResult.value.role;
972
1216
  return {
973
1217
  workflow: workflowHash,
@@ -979,6 +1223,7 @@ async function finalizeAgentStep(_storageRoot, threadId, workflowHash, workflow,
979
1223
  suspendMessage: null,
980
1224
  done,
981
1225
  background: null,
1226
+ error: null,
982
1227
  };
983
1228
  }
984
1229
  async function cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog, resume = null) {
@@ -1013,6 +1258,26 @@ async function cmdThreadStepOnce(storageRoot, threadId, agentOverride, plog, res
1013
1258
  if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
1014
1259
  failStep(plog, `agent returned hash that is not a StepNode: ${newHead}`);
1015
1260
  }
1261
+ // Recoverable failure: agent persisted a failed StepNode (e.g. frontmatter
1262
+ // validation exhausted retries) but the engine MUST NOT advance head. The
1263
+ // moderator graph is also untouched — the same role will be replayed on the
1264
+ // next exec (until eventual success records `previousAttempts` linking the
1265
+ // failed step hashes).
1266
+ if (agentResult.isError === true) {
1267
+ plog.log(PL_AGENT_ERROR, `agent reported recoverable failure stepHash=${newHead} message=${agentResult.errorMessage ?? ""}`, null);
1268
+ return {
1269
+ workflow: workflowHash,
1270
+ thread: threadId,
1271
+ head: headHash,
1272
+ status: "idle",
1273
+ currentRole: role,
1274
+ suspendedRole: null,
1275
+ suspendMessage: null,
1276
+ done: false,
1277
+ background: null,
1278
+ error: { stepHash: newHead, message: agentResult.errorMessage ?? "agent reported error" },
1279
+ };
1280
+ }
1016
1281
  return finalizeAgentStep(storageRoot, threadId, workflowHash, workflow, newHead, uwfAfter, plog);
1017
1282
  }
1018
1283
  async function resolveHeadHash(storageRoot, threadId) {
@@ -1043,7 +1308,9 @@ export async function cmdThreadRead(storageRoot, threadId, quota = THREAD_READ_D
1043
1308
  });
1044
1309
  }
1045
1310
  /**
1046
- * Stop background execution of a thread (but keep thread active)
1311
+ * Stop background execution of a thread (but keep thread active).
1312
+ * Validates process identity before sending signals to prevent killing
1313
+ * unrelated processes when PIDs are recycled.
1047
1314
  */
1048
1315
  export async function cmdThreadStop(storageRoot, threadId) {
1049
1316
  const uwf = await createUwfStore(storageRoot);
@@ -1051,14 +1318,22 @@ export async function cmdThreadStop(storageRoot, threadId) {
1051
1318
  if (entry === null) {
1052
1319
  fail(`thread not active: ${threadId}`);
1053
1320
  }
1054
- // Check if thread is running in background and terminate it
1055
- const runningMarker = await isThreadRunning(storageRoot, threadId);
1056
- if (runningMarker === null) {
1321
+ // Read the raw marker to check process identity
1322
+ const marker = await readMarker(storageRoot, threadId);
1323
+ if (marker === null) {
1057
1324
  process.stderr.write(`Warning: thread ${threadId} is not currently running\n`);
1058
1325
  return { thread: threadId, stopped: false };
1059
1326
  }
1327
+ // Validate that the marker's PID still belongs to the same process
1328
+ if (!isMarkerValid(marker)) {
1329
+ // Stale marker — PID was recycled or process died. Do NOT send a signal.
1330
+ process.stderr.write(`Warning: thread ${threadId} was not actually running (stale marker cleaned up)\n`);
1331
+ await deleteMarker(storageRoot, threadId);
1332
+ return { thread: threadId, stopped: false };
1333
+ }
1334
+ // Process identity confirmed — safe to send SIGTERM
1060
1335
  try {
1061
- process.kill(runningMarker.pid, "SIGTERM");
1336
+ process.kill(marker.pid, "SIGTERM");
1062
1337
  }
1063
1338
  catch {
1064
1339
  // Process may have already exited, ignore error
@@ -1067,7 +1342,9 @@ export async function cmdThreadStop(storageRoot, threadId) {
1067
1342
  return { thread: threadId, stopped: true };
1068
1343
  }
1069
1344
  /**
1070
- * Cancel a thread (stop execution + move to history)
1345
+ * Cancel a thread (stop execution + move to history).
1346
+ * Validates process identity before sending signals to prevent killing
1347
+ * unrelated processes when PIDs are recycled.
1071
1348
  */
1072
1349
  export async function cmdThreadCancel(storageRoot, threadId) {
1073
1350
  const uwf = await createUwfStore(storageRoot);
@@ -1075,15 +1352,19 @@ export async function cmdThreadCancel(storageRoot, threadId) {
1075
1352
  if (entry === null) {
1076
1353
  fail(`thread not active: ${threadId}`);
1077
1354
  }
1078
- // Check if thread is running in background and terminate it
1079
- const runningMarker = await isThreadRunning(storageRoot, threadId);
1080
- if (runningMarker !== null) {
1081
- try {
1082
- process.kill(runningMarker.pid, "SIGTERM");
1083
- }
1084
- catch {
1085
- // Process may have already exited, ignore error
1355
+ // Read the raw marker and validate process identity before sending signals
1356
+ const marker = await readMarker(storageRoot, threadId);
1357
+ if (marker !== null) {
1358
+ if (isMarkerValid(marker)) {
1359
+ // Process identity confirmed — safe to send SIGTERM
1360
+ try {
1361
+ process.kill(marker.pid, "SIGTERM");
1362
+ }
1363
+ catch {
1364
+ // Process may have already exited, ignore error
1365
+ }
1086
1366
  }
1367
+ // Always delete the marker (stale or not) — cancellation proceeds
1087
1368
  await deleteMarker(storageRoot, threadId);
1088
1369
  }
1089
1370
  completeThread(uwf.varStore, threadId, "cancelled");