@stigmer/runner 3.40.0 → 3.41.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 (278) hide show
  1. package/README.md +3 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/__test-utils__/execution-record-fixture.d.ts +2 -1
  4. package/dist/__test-utils__/execution-record-fixture.js.map +1 -1
  5. package/dist/activities/call-agent.js +1 -1
  6. package/dist/activities/discover-mcp-server.d.ts +7 -1
  7. package/dist/activities/discover-mcp-server.js +13 -29
  8. package/dist/activities/discover-mcp-server.js.map +1 -1
  9. package/dist/activities/execute-cursor/__test-utils__/fold.d.ts +4 -2
  10. package/dist/activities/execute-cursor/__test-utils__/fold.js +3 -4
  11. package/dist/activities/execute-cursor/__test-utils__/fold.js.map +1 -1
  12. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +1 -0
  13. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-policy.d.ts +1 -29
  15. package/dist/activities/execute-cursor/approval-policy.js +11 -35
  16. package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
  17. package/dist/activities/execute-cursor/boundary-rows.js +4 -4
  18. package/dist/activities/execute-cursor/boundary-rows.js.map +1 -1
  19. package/dist/activities/execute-cursor/translator.d.ts +12 -17
  20. package/dist/activities/execute-cursor/translator.js +13 -30
  21. package/dist/activities/execute-cursor/translator.js.map +1 -1
  22. package/dist/activities/execute-cursor/turn-stream.js +4 -2
  23. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  24. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.d.ts +11 -1
  25. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js +22 -3
  26. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +33 -2
  28. package/dist/activities/execute-deep-agent/inline-publisher.js +33 -7
  29. package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/platform-route.d.ts +48 -13
  31. package/dist/activities/execute-deep-agent/platform-route.js +87 -19
  32. package/dist/activities/execute-deep-agent/platform-route.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +70 -35
  34. package/dist/activities/execute-deep-agent/prompt-builder.js +70 -55
  35. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  36. package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +20 -3
  37. package/dist/activities/execute-deep-agent/streaming-side-effects.js +26 -4
  38. package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
  39. package/dist/activities/execute-deep-agent/translator.d.ts +5 -4
  40. package/dist/activities/execute-deep-agent/translator.js +5 -4
  41. package/dist/activities/execute-deep-agent/translator.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/turn-settle.js +6 -6
  43. package/dist/activities/execute-deep-agent/turn-settle.js.map +1 -1
  44. package/dist/activities/execute-deep-agent/turn-setup.d.ts +18 -13
  45. package/dist/activities/execute-deep-agent/turn-setup.js +42 -36
  46. package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
  47. package/dist/activities/execute-deep-agent/turn-stream.d.ts +10 -6
  48. package/dist/activities/execute-deep-agent/turn-stream.js +6 -16
  49. package/dist/activities/execute-deep-agent/turn-stream.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/turn.d.ts +4 -1
  51. package/dist/activities/execute-deep-agent/turn.js +15 -6
  52. package/dist/activities/execute-deep-agent/turn.js.map +1 -1
  53. package/dist/activities/workflow-event-activities.js +2 -0
  54. package/dist/activities/workflow-event-activities.js.map +1 -1
  55. package/dist/config.d.ts +9 -0
  56. package/dist/config.js +2 -0
  57. package/dist/config.js.map +1 -1
  58. package/dist/exit-backstop.d.ts +64 -0
  59. package/dist/exit-backstop.js +79 -0
  60. package/dist/exit-backstop.js.map +1 -0
  61. package/dist/harness/terminal-table.d.ts +12 -5
  62. package/dist/harness/terminal-table.js +13 -13
  63. package/dist/harness/terminal-table.js.map +1 -1
  64. package/dist/harness/transcript/builder.d.ts +21 -0
  65. package/dist/harness/transcript/builder.js +53 -31
  66. package/dist/harness/transcript/builder.js.map +1 -1
  67. package/dist/harness/transcript/events.d.ts +22 -26
  68. package/dist/harness/transcript/events.js +6 -5
  69. package/dist/harness/transcript/events.js.map +1 -1
  70. package/dist/harness/turn-context.d.ts +3 -1
  71. package/dist/harness/turn-context.js +5 -3
  72. package/dist/harness/turn-context.js.map +1 -1
  73. package/dist/main.js +10 -4
  74. package/dist/main.js.map +1 -1
  75. package/dist/middleware/cost-advisory.d.ts +6 -3
  76. package/dist/middleware/cost-advisory.js +6 -3
  77. package/dist/middleware/cost-advisory.js.map +1 -1
  78. package/dist/middleware/execution-budget.d.ts +22 -7
  79. package/dist/middleware/execution-budget.js +57 -35
  80. package/dist/middleware/execution-budget.js.map +1 -1
  81. package/dist/middleware/index.d.ts +10 -7
  82. package/dist/middleware/index.js +10 -7
  83. package/dist/middleware/index.js.map +1 -1
  84. package/dist/middleware/types.d.ts +6 -1
  85. package/dist/runner-manager.js +3 -2
  86. package/dist/runner-manager.js.map +1 -1
  87. package/dist/runner.d.ts +25 -0
  88. package/dist/runner.js +41 -14
  89. package/dist/runner.js.map +1 -1
  90. package/dist/shared/approval-policy.d.ts +23 -13
  91. package/dist/shared/approval-policy.js +47 -14
  92. package/dist/shared/approval-policy.js.map +1 -1
  93. package/dist/shared/caller-identity.d.ts +4 -5
  94. package/dist/shared/caller-identity.js +4 -5
  95. package/dist/shared/caller-identity.js.map +1 -1
  96. package/dist/shared/channel-attachment.js +3 -2
  97. package/dist/shared/channel-attachment.js.map +1 -1
  98. package/dist/shared/connect-backfill.d.ts +2 -1
  99. package/dist/shared/connect-backfill.js +2 -2
  100. package/dist/shared/connect-backfill.js.map +1 -1
  101. package/dist/shared/mcp-resolver.d.ts +10 -1
  102. package/dist/shared/mcp-resolver.js +11 -2
  103. package/dist/shared/mcp-resolver.js.map +1 -1
  104. package/dist/shared/memory-attachment.js +3 -2
  105. package/dist/shared/memory-attachment.js.map +1 -1
  106. package/dist/shared/model-error.d.ts +25 -3
  107. package/dist/shared/model-error.js +78 -10
  108. package/dist/shared/model-error.js.map +1 -1
  109. package/dist/shared/platform-server-address.d.ts +71 -0
  110. package/dist/shared/platform-server-address.js +103 -0
  111. package/dist/shared/platform-server-address.js.map +1 -0
  112. package/dist/shared/prompt-sections.d.ts +6 -28
  113. package/dist/shared/prompt-sections.js +6 -35
  114. package/dist/shared/prompt-sections.js.map +1 -1
  115. package/dist/shared/skill-resolver.d.ts +2 -3
  116. package/dist/shared/skill-resolver.js +2 -3
  117. package/dist/shared/skill-resolver.js.map +1 -1
  118. package/dist/shared/synthesized-attachment.d.ts +0 -5
  119. package/dist/shared/synthesized-attachment.js +0 -12
  120. package/dist/shared/synthesized-attachment.js.map +1 -1
  121. package/dist/shared/tool-rounds.d.ts +51 -23
  122. package/dist/shared/tool-rounds.js +67 -24
  123. package/dist/shared/tool-rounds.js.map +1 -1
  124. package/dist/shared/workspace/platform-dir.d.ts +15 -0
  125. package/dist/shared/workspace/platform-dir.js +18 -1
  126. package/dist/shared/workspace/platform-dir.js.map +1 -1
  127. package/dist/shared/workspace/sources/git.js +13 -6
  128. package/dist/shared/workspace/sources/git.js.map +1 -1
  129. package/dist/shared/workspace/stigmer-link.d.ts +21 -14
  130. package/dist/shared/workspace/stigmer-link.js +91 -29
  131. package/dist/shared/workspace/stigmer-link.js.map +1 -1
  132. package/dist/tools/url-guard.d.ts +5 -1
  133. package/dist/tools/url-guard.js +9 -2
  134. package/dist/tools/url-guard.js.map +1 -1
  135. package/dist/worker.d.ts +11 -2
  136. package/dist/worker.js +1 -1
  137. package/dist/worker.js.map +1 -1
  138. package/dist/workflow-engine/tasks/human-input.d.ts +16 -0
  139. package/dist/workflow-engine/tasks/human-input.js +79 -37
  140. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  141. package/dist/workflow-engine/types.d.ts +14 -4
  142. package/dist/workflow-engine/types.js.map +1 -1
  143. package/dist/workflows/call-agent-orchestrator.d.ts +13 -0
  144. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  145. package/dist/workflows/engine-core.d.ts +25 -0
  146. package/dist/workflows/engine-core.js.map +1 -1
  147. package/dist/workflows/execute-from-execution.d.ts +3 -0
  148. package/dist/workflows/execute-from-execution.js.map +1 -1
  149. package/dist/workflows/human-input-orchestrator.d.ts +9 -12
  150. package/dist/workflows/human-input-orchestrator.js +12 -44
  151. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  152. package/package.json +5 -5
  153. package/src/__test-utils__/config-fixture.ts +1 -0
  154. package/src/__test-utils__/execution-record-fixture.ts +2 -1
  155. package/src/__test-utils__/harness-contract/runtime-contract.ts +5 -3
  156. package/src/__test-utils__/test-gate.ts +8 -0
  157. package/src/__tests__/exit-backstop.test.ts +91 -0
  158. package/src/__tests__/golden-e2e.test.ts +80 -62
  159. package/src/__tests__/history-encryption-e2e.test.ts +51 -27
  160. package/src/__tests__/runner.test.ts +60 -2
  161. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  162. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +5 -1
  163. package/src/activities/__tests__/discover-mcp-server.test.ts +90 -49
  164. package/src/activities/__tests__/workflow-event-activities.test.ts +28 -7
  165. package/src/activities/call-agent.ts +1 -1
  166. package/src/activities/discover-mcp-server.ts +27 -37
  167. package/src/activities/execute-cursor/__test-utils__/fold.ts +6 -4
  168. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +1 -0
  169. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +4 -29
  170. package/src/activities/execute-cursor/__tests__/hermetic/bypassed-tool-call.test.ts +120 -0
  171. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +5 -5
  172. package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +0 -2
  173. package/src/activities/execute-cursor/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
  174. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.error-delta.status.json +0 -3
  175. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.output-first.status.json +0 -2
  176. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.status.json +0 -2
  177. package/src/activities/execute-cursor/__tests__/hermetic/goldens/stall.status.json +0 -2
  178. package/src/activities/execute-cursor/__tests__/hermetic/goldens/unattributed-hook-block.status.json +0 -3
  179. package/src/activities/execute-cursor/__tests__/hermetic/shell-output-delta.test.ts +9 -7
  180. package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +6 -1
  181. package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +8 -11
  182. package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +1 -3
  183. package/src/activities/execute-cursor/__tests__/translator.test.ts +68 -15
  184. package/src/activities/execute-cursor/approval-policy.ts +11 -36
  185. package/src/activities/execute-cursor/boundary-rows.ts +3 -3
  186. package/src/activities/execute-cursor/translator.ts +15 -32
  187. package/src/activities/execute-cursor/turn-stream.ts +4 -1
  188. package/src/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.ts +33 -2
  189. package/src/activities/execute-deep-agent/__tests__/adapter.test.ts +1 -0
  190. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.build-from-plan.prompt.md +1 -27
  191. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.everything.prompt.md +26 -30
  192. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.plan-mode.prompt.md +1 -26
  193. package/src/activities/execute-deep-agent/__tests__/goldens/user-message.everything.md +36 -0
  194. package/src/activities/execute-deep-agent/__tests__/hermetic/file-review-capture.test.ts +4 -0
  195. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/file-review-capture.status.json +1 -1
  196. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn1.status.json +1 -1
  197. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn2.status.json +1 -1
  198. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/recursion-limit.persisted.status.json +8 -73
  199. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
  200. package/src/activities/execute-deep-agent/__tests__/hermetic/inline-artifact-across-gate.test.ts +17 -0
  201. package/src/activities/execute-deep-agent/__tests__/hermetic/recursion-limit.test.ts +86 -26
  202. package/src/activities/execute-deep-agent/__tests__/hermetic/resolution-error.test.ts +9 -5
  203. package/src/activities/execute-deep-agent/__tests__/hermetic/thrown-turn-drains-publishes.test.ts +159 -0
  204. package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +84 -0
  205. package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +47 -10
  206. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +127 -199
  207. package/src/activities/execute-deep-agent/__tests__/prompt-goldens.test.ts +31 -26
  208. package/src/activities/execute-deep-agent/__tests__/streaming-side-effects.test.ts +34 -0
  209. package/src/activities/execute-deep-agent/__tests__/turn-stream.test.ts +13 -5
  210. package/src/activities/execute-deep-agent/inline-publisher.ts +47 -7
  211. package/src/activities/execute-deep-agent/platform-route.ts +109 -20
  212. package/src/activities/execute-deep-agent/prompt-builder.ts +104 -92
  213. package/src/activities/execute-deep-agent/streaming-side-effects.ts +27 -4
  214. package/src/activities/execute-deep-agent/translator.ts +5 -4
  215. package/src/activities/execute-deep-agent/turn-settle.ts +6 -6
  216. package/src/activities/execute-deep-agent/turn-setup.ts +43 -38
  217. package/src/activities/execute-deep-agent/turn-stream.ts +15 -22
  218. package/src/activities/execute-deep-agent/turn.ts +16 -7
  219. package/src/activities/workflow-event-activities.ts +2 -0
  220. package/src/config.ts +11 -0
  221. package/src/exit-backstop.ts +97 -0
  222. package/src/harness/README.md +5 -5
  223. package/src/harness/terminal-table.ts +13 -13
  224. package/src/harness/transcript/__tests__/builder.test.ts +93 -21
  225. package/src/harness/transcript/builder.ts +53 -30
  226. package/src/harness/transcript/events.ts +22 -27
  227. package/src/harness/turn-context.ts +5 -3
  228. package/src/main.ts +9 -5
  229. package/src/middleware/__tests__/execution-budget.test.ts +181 -103
  230. package/src/middleware/cost-advisory.ts +6 -3
  231. package/src/middleware/execution-budget.ts +60 -34
  232. package/src/middleware/index.ts +10 -7
  233. package/src/middleware/types.ts +6 -1
  234. package/src/runner-manager.ts +3 -2
  235. package/src/runner.ts +57 -15
  236. package/src/shared/__tests__/approval-policy.test.ts +41 -0
  237. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  238. package/src/shared/__tests__/connect-backfill.test.ts +15 -11
  239. package/src/shared/__tests__/mcp-resolver.test.ts +72 -10
  240. package/src/shared/__tests__/model-error.test.ts +71 -3
  241. package/src/shared/__tests__/platform-server-address.test.ts +122 -0
  242. package/src/shared/__tests__/prompt-sections.test.ts +2 -54
  243. package/src/shared/__tests__/tool-rounds.test.ts +64 -14
  244. package/src/shared/approval-policy.ts +57 -14
  245. package/src/shared/caller-identity.ts +4 -5
  246. package/src/shared/channel-attachment.ts +2 -2
  247. package/src/shared/connect-backfill.ts +5 -1
  248. package/src/shared/mcp-resolver.ts +11 -1
  249. package/src/shared/memory-attachment.ts +2 -2
  250. package/src/shared/model-error.ts +87 -9
  251. package/src/shared/platform-server-address.ts +123 -0
  252. package/src/shared/prompt-sections.ts +6 -55
  253. package/src/shared/skill-resolver.ts +2 -3
  254. package/src/shared/synthesized-attachment.ts +0 -12
  255. package/src/shared/tool-rounds.ts +70 -24
  256. package/src/shared/workspace/__tests__/git-source.test.ts +11 -5
  257. package/src/shared/workspace/__tests__/stigmer-link.test.ts +107 -10
  258. package/src/shared/workspace/platform-dir.ts +19 -1
  259. package/src/shared/workspace/sources/git.ts +14 -8
  260. package/src/shared/workspace/stigmer-link.ts +99 -29
  261. package/src/tools/__tests__/url-guard.test.ts +19 -1
  262. package/src/tools/url-guard.ts +9 -1
  263. package/src/worker.ts +12 -2
  264. package/src/workflow-engine/__tests__/golden-execution.test.ts +2 -1
  265. package/src/workflow-engine/__tests__/loader.test.ts +1 -1
  266. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +133 -57
  267. package/src/workflow-engine/tasks/human-input.ts +92 -42
  268. package/src/workflow-engine/types.ts +16 -4
  269. package/src/workflows/call-agent-orchestrator.ts +7 -0
  270. package/src/workflows/engine-core.ts +18 -0
  271. package/src/workflows/execute-from-execution.ts +2 -1
  272. package/src/workflows/human-input-orchestrator.ts +15 -56
  273. package/dist/shared/skill-relevance.d.ts +0 -65
  274. package/dist/shared/skill-relevance.js +0 -175
  275. package/dist/shared/skill-relevance.js.map +0 -1
  276. package/src/activities/execute-deep-agent/__tests__/goldens/system-prompt.skills-below-threshold.prompt.md +0 -146
  277. package/src/shared/__tests__/skill-relevance.test.ts +0 -292
  278. package/src/shared/skill-relevance.ts +0 -222
@@ -11,10 +11,10 @@
11
11
  * `.stigmer/…` paths those agents are prompted with (skill locations,
12
12
  * `.stigmer/inputs/…` attachments) actually resolve. The native harness's
13
13
  * FILE tools are the exception: their virtual-rooted backend refuses a path
14
- * whose real location leaves the workspace, so they read the platform dir
15
- * through a read-only `.stigmer/` route instead
16
- * (`activities/execute-deep-agent/platform-route.ts`), mounted on exactly
17
- * the turns this link exists ({@link stigmerSymlinkPointsAt}).
14
+ * whose real location leaves the workspace, so they reach `.stigmer/` through
15
+ * a route instead (`activities/execute-deep-agent/platform-route.ts`),
16
+ * mounted on every turn and exposing the platform dir on exactly the turns
17
+ * this link exists ({@link stigmerSymlinkPointsAt}).
18
18
  *
19
19
  * Lifecycle contract (same for both harnesses):
20
20
  * - Created per turn, under the workspace turn lock — the link is a
@@ -29,26 +29,35 @@
29
29
  * - {@link ensureStigmerSymlink} is idempotent, so multiple populaters may
30
30
  * run in any order within a turn.
31
31
  *
32
- * OWNERSHIP SHARP EDGE: the platform owns the `.stigmer` name inside a
33
- * workspace. {@link ensureStigmerSymlink} REPLACES a real (non-symlink)
34
- * `.stigmer` directory it finds there — deliberate, so a directory left
35
- * behind by an older runner can never shadow the platform mount. (A user's
36
- * own `.stigmer` content is unreachable anyway: the runner's
37
- * `LocalWorkspaceBackend` routes every `.stigmer/…` path to the platform
38
- * dir.) Turn-end removal is the conservative half: it only ever removes a
39
- * SYMLINK.
32
+ * OWNERSHIP: the platform owns the `.stigmer` name inside a workspace, but
33
+ * never deletes what it did not make. A real (non-symlink) `.stigmer` entry
34
+ * found where the link goes — left by an older runner, or written by a shell
35
+ * command or a Cursor edit on a turn with no link, where nothing sits between
36
+ * those tools and the disk (#1123) — is MOVED out of the workspace to the
37
+ * session's `displaced/` dir (platform-dir.ts `getDisplacedDir`) and named in
38
+ * a warning; an empty directory is simply removed. Out of the workspace
39
+ * because a write-back commits everything the tree holds (`git add -A`) and
40
+ * git excludes only the exact `.stigmer` name. One case is refused outright:
41
+ * a `.stigmer` that is, or holds, the platform dir itself — a workspace
42
+ * rooted at the home directory, whose `.stigmer` is the Stigmer home (#1424).
43
+ * Turn-end removal is the conservative half: it only ever removes a SYMLINK.
40
44
  */
41
45
 
42
- import { symlink, readlink, unlink, rm, lstat } from "node:fs/promises";
43
- import { join } from "node:path";
46
+ import { cp, lstat, mkdir, readdir, readlink, realpath, rename, rm, rmdir, symlink, unlink } from "node:fs/promises";
47
+ import type { Stats } from "node:fs";
48
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
49
+ import { getDisplacedDir } from "./platform-dir.js";
44
50
 
45
51
  /** The workspace-visible name of the platform namespace. */
46
52
  export const STIGMER_LOCAL_STATE_DIR = ".stigmer";
47
53
 
48
54
  /**
49
55
  * Ensure the workspace `.stigmer` symlink points to the platform dir.
50
- * Idempotent: an existing correct link is kept; a stale link (or a non-link
51
- * left behind by an older runner) is replaced.
56
+ * Idempotent: an existing correct link is kept and a stale link is
57
+ * re-pointed. A real entry in the link's place is moved to the session's
58
+ * `displaced/` dir first (an empty directory is removed); a real `.stigmer`
59
+ * that holds the platform dir is refused with an error naming it, before
60
+ * anything is touched. See the module header, OWNERSHIP.
52
61
  */
53
62
  export async function ensureStigmerSymlink(
54
63
  workspaceDir: string,
@@ -56,24 +65,85 @@ export async function ensureStigmerSymlink(
56
65
  ): Promise<void> {
57
66
  const linkPath = join(workspaceDir, STIGMER_LOCAL_STATE_DIR);
58
67
 
68
+ let entry: Stats | undefined;
59
69
  try {
60
- const existing = await readlink(linkPath);
61
- if (existing === platformDir) return;
70
+ entry = await lstat(linkPath);
71
+ } catch (err) {
72
+ if (errnoCode(err) !== "ENOENT") throw err;
73
+ }
74
+
75
+ if (entry?.isSymbolicLink()) {
76
+ if ((await readlink(linkPath)) === platformDir) return;
62
77
  await unlink(linkPath);
63
- } catch (err: any) {
64
- if (err.code === "ENOENT") {
65
- // No existing symlink
66
- } else if (err.code === "EINVAL") {
67
- // Exists but is not a symlink — remove the directory
68
- await rm(linkPath, { recursive: true, force: true });
69
- } else {
70
- throw err;
71
- }
78
+ } else if (entry !== undefined) {
79
+ await displaceRealEntry(linkPath, platformDir, entry);
72
80
  }
73
81
 
74
82
  await symlink(platformDir, linkPath, "dir");
75
83
  }
76
84
 
85
+ /**
86
+ * Clear a real `.stigmer` entry out of the link's place without losing it:
87
+ * refuse the Stigmer home, remove an empty directory, move anything else.
88
+ */
89
+ async function displaceRealEntry(linkPath: string, platformDir: string, entry: Stats): Promise<void> {
90
+ if (entry.isDirectory()) {
91
+ await refuseDirHoldingPlatform(linkPath, platformDir);
92
+ if ((await readdir(linkPath)).length === 0) {
93
+ await rmdir(linkPath);
94
+ return;
95
+ }
96
+ }
97
+ const destination = getDisplacedDir(platformDir, displacementStamp(new Date()));
98
+ await mkdir(dirname(destination), { recursive: true });
99
+ await moveEntry(linkPath, destination);
100
+ console.warn(
101
+ `ensureStigmerSymlink: moved the real ${linkPath} out of the workspace to ${destination}; ` +
102
+ "the platform owns the .stigmer name, so the link to the session's platform dir takes its place",
103
+ );
104
+ }
105
+
106
+ /**
107
+ * Throw when the real `.stigmer` directory is, or contains, the platform dir:
108
+ * a workspace rooted at the home directory resolves `.stigmer` to the Stigmer
109
+ * home, where the platform dir, the CLI's config and every session live.
110
+ * Compared by real path, so a symlinked or `HOME`-overridden spelling of
111
+ * either side cannot slip past.
112
+ */
113
+ async function refuseDirHoldingPlatform(linkPath: string, platformDir: string): Promise<void> {
114
+ const [realLink, realPlatform] = await Promise.all([realpath(linkPath), realpath(platformDir)]);
115
+ const fromLink = relative(realLink, realPlatform);
116
+ const outside = fromLink === ".." || fromLink.startsWith(`..${sep}`) || isAbsolute(fromLink);
117
+ if (!outside) {
118
+ throw new Error(
119
+ `The workspace's ${linkPath} is the Stigmer home (it holds this session's platform dir, ${platformDir}); ` +
120
+ "use a project directory as the workspace, not the home directory",
121
+ );
122
+ }
123
+ }
124
+
125
+ /** Rename, or copy then remove when the destination is on another filesystem (a cloud workspace volume and `$HOME` are). */
126
+ async function moveEntry(source: string, destination: string): Promise<void> {
127
+ try {
128
+ await rename(source, destination);
129
+ } catch (err) {
130
+ if (errnoCode(err) !== "EXDEV") throw err;
131
+ await cp(source, destination, { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true });
132
+ await rm(source, { recursive: true, force: true });
133
+ }
134
+ }
135
+
136
+ /** A compact UTC stamp with milliseconds (`20260929T101500123Z`): sortable, and free of the colons a path should not carry. */
137
+ function displacementStamp(now: Date): string {
138
+ return now.toISOString().replace(/[-:.]/g, "");
139
+ }
140
+
141
+ /** The errno code of a Node filesystem error, or `undefined` for anything else. */
142
+ function errnoCode(err: unknown): string | undefined {
143
+ if (typeof err !== "object" || err === null || !("code" in err)) return undefined;
144
+ return typeof err.code === "string" ? err.code : undefined;
145
+ }
146
+
77
147
  /**
78
148
  * Whether the workspace's `.stigmer` is, right now, the link
79
149
  * {@link ensureStigmerSymlink} makes to `platformDir`. A turn that mounts no
@@ -105,8 +175,8 @@ export async function removeStigmerSymlink(workspaceDir: string): Promise<void>
105
175
  if (stat.isSymbolicLink()) {
106
176
  await unlink(linkPath);
107
177
  }
108
- } catch (err: any) {
109
- if (err?.code !== "ENOENT") {
178
+ } catch (err) {
179
+ if (errnoCode(err) !== "ENOENT") {
110
180
  console.warn(
111
181
  `removeStigmerSymlink: failed to remove ${linkPath} (non-fatal): ` +
112
182
  `${err instanceof Error ? err.message : err}`,
@@ -131,7 +131,25 @@ describe("validateFetchUrl", () => {
131
131
 
132
132
  it("rejects unresolvable hostnames", async () => {
133
133
  await expect(
134
- validateFetchUrl("http://definitely-not-a-real-host.stigmer.invalid/", "strict"),
134
+ validateFetchUrl("http://definitely-not-a-real-host.stigmer.invalid/", "relaxed"),
135
135
  ).rejects.toThrow(/Could not resolve/);
136
136
  });
137
+
138
+ it("on a strict runner, a name that does not resolve and one that resolves to a refused address read alike and name no address", async () => {
139
+ const refusals = await Promise.all(
140
+ ["http://localhost:9999/", "http://definitely-not-a-real-host.stigmer.invalid/"].map((url) =>
141
+ validateFetchUrl(url, "strict").then(
142
+ () => {
143
+ throw new Error(`expected ${url} to be refused`);
144
+ },
145
+ (error: unknown) => (error instanceof UrlGuardError ? error.message : String(error)),
146
+ ),
147
+ ),
148
+ );
149
+ expect(refusals).toEqual([
150
+ "Refusing to fetch localhost: it does not resolve to a public address.",
151
+ "Refusing to fetch definitely-not-a-real-host.stigmer.invalid: it does not resolve to a public address.",
152
+ ]);
153
+ expect(refusals.join(" ")).not.toMatch(/127\.0\.0\.1|::1|loopback/);
154
+ });
137
155
  });
@@ -12,7 +12,11 @@
12
12
  * IPv4-mapped IPv6 is unwrapped, how every resolved address is judged) has
13
13
  * one home, `@stigmer/outbound/egress`, shared with the control plane, which
14
14
  * dials user-supplied URLs too. This module keeps what is the runner's:
15
- * deriving the posture from its mode, and the sentence a model reads.
15
+ * deriving the posture from its mode, and the sentence a model reads. What
16
+ * that sentence may say is the shared rule too (`refusalWithholdsResolution`):
17
+ * on a strict runner a name that does not resolve and one that resolves to
18
+ * a refused address read alike and name no address, because the runner
19
+ * resolves through a network its caller does not own.
16
20
  *
17
21
  * Posture is mode-aware because locality differs, not trust:
18
22
  *
@@ -41,6 +45,7 @@ import {
41
45
  checkEgress,
42
46
  egressPolicyForPosture,
43
47
  nodeLookup,
48
+ refusalWithholdsResolution,
44
49
  blockedReason as sharedBlockedReason,
45
50
  type EgressPosture,
46
51
  } from "@stigmer/outbound/egress";
@@ -91,6 +96,9 @@ export async function validateFetchUrl(
91
96
  if (check.ok) return check.url;
92
97
 
93
98
  const refusal = check.refusal;
99
+ if (refusalWithholdsResolution(refusal) && (refusal.kind === "unresolvable" || refusal.kind === "blocked")) {
100
+ throw new UrlGuardError(`Refusing to fetch ${refusal.hostname}: it does not resolve to a public address.`);
101
+ }
94
102
  switch (refusal.kind) {
95
103
  case "invalid-url":
96
104
  throw new UrlGuardError(`Invalid URL: ${refusal.url}`);
package/src/worker.ts CHANGED
@@ -34,7 +34,17 @@ export interface StartWorkerOptions {
34
34
  payloadCodecs?: PayloadCodec[];
35
35
  }
36
36
 
37
- export async function startWorker(opts: StartWorkerOptions): Promise<Worker> {
37
+ /**
38
+ * The worker and the connection it polls on. The caller owns both: it runs
39
+ * and shuts down the worker, then closes the connection once `run()` has
40
+ * resolved (the SDK refuses to close a connection a worker still holds).
41
+ */
42
+ export interface StartedWorker {
43
+ readonly worker: Worker;
44
+ readonly connection: NativeConnection;
45
+ }
46
+
47
+ export async function startWorker(opts: StartWorkerOptions): Promise<StartedWorker> {
38
48
  const { config, activities, payloadCodecs } = opts;
39
49
 
40
50
  const connection = await NativeConnection.connect({
@@ -109,5 +119,5 @@ export async function startWorker(opts: StartWorkerOptions): Promise<Worker> {
109
119
  },
110
120
  });
111
121
 
112
- return worker;
122
+ return { worker, connection };
113
123
  }
@@ -164,7 +164,8 @@ describe("Golden Execution — Tier 1c: External Calls", () => {
164
164
  it("#03 foreach-loop — for loop with HTTP call per item", async () => {
165
165
  const model = loadWorkflowFromYaml(loadGolden("03-foreach-loop.yaml"));
166
166
  const state = createState();
167
- state.data = { items: ["item1", "item2", "item3"] };
167
+ // The workflow's input, where the engine puts it (engine-core.ts: state.input = workflow_input).
168
+ state.input = { items: ["item1", "item2", "item3"] };
168
169
 
169
170
  const mockCallHttp = vi.fn(async () => fakeHttpResponse());
170
171
  const ctx = makeCtx({ callHttp: mockCallHttp });
@@ -391,7 +391,7 @@ do:
391
391
  expect(forEntry).toBeDefined();
392
392
  if (forEntry?.task.kind === "for") {
393
393
  expect(forEntry.task.for.each).toBe("item");
394
- expect(forEntry.task.for.in).toBe("${ $data.items }");
394
+ expect(forEntry.task.for.in).toBe("${ $input.items }");
395
395
  expect(forEntry.task.do.length).toBeGreaterThan(0);
396
396
  }
397
397
  });
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { executeHumanInputTask } from "../../tasks/human-input.js";
2
+ import {
3
+ HUMAN_INPUT_FAIL_TIMEOUT_RESOLVED_PATCH,
4
+ executeHumanInputTask,
5
+ } from "../../tasks/human-input.js";
3
6
  import { createState } from "../../state.js";
4
7
  import { evaluateExpressionBatch } from "../../expression.js";
5
8
  import { TaskStatusAccumulator } from "../../task-status-accumulator.js";
@@ -8,6 +11,7 @@ import type {
8
11
  TaskExecutionContext,
9
12
  HumanInputExecutionConfig,
10
13
  HumanInputResult,
14
+ HumanInputTimeout,
11
15
  AwaitHumanInputFn,
12
16
  EmitEventsFn,
13
17
  ExpressionEvaluator,
@@ -17,12 +21,16 @@ import type {
17
21
 
18
22
  const notAvailable = () => { throw new Error("not available in test"); };
19
23
 
24
+ /** What the workflow layer reports when the gate's timer wins. */
25
+ const TIMED_OUT: HumanInputTimeout = { timedOut: true };
26
+
20
27
  interface CtxOptions {
21
28
  readonly awaitFn?: AwaitHumanInputFn;
22
29
  readonly emitFn?: EmitEventsFn;
23
30
  readonly evaluateExpressions?: ExpressionEvaluator;
24
31
  readonly promoteTaskOutput?: PromoteTaskOutputFn;
25
32
  readonly taskStatusAccumulator?: TaskStatusAccumulator;
33
+ readonly isPatched?: (changeId: string) => boolean;
26
34
  }
27
35
 
28
36
  function makeCtx(
@@ -45,6 +53,7 @@ function makeCtx(
45
53
  emitEvents: emitFn,
46
54
  promoteTaskOutput: options?.promoteTaskOutput,
47
55
  taskStatusAccumulator: options?.taskStatusAccumulator,
56
+ isPatched: options?.isPatched,
48
57
  };
49
58
  }
50
59
 
@@ -156,11 +165,11 @@ describe("executeHumanInputTask", () => {
156
165
  expect(capturedConfig!.timeoutSeconds).toBe(0);
157
166
  });
158
167
 
159
- it("passes onTimeout policy from config", async () => {
168
+ it("hands the wait only the signal and the timeout; the policy stays with the kernel", async () => {
160
169
  let capturedConfig: HumanInputExecutionConfig | undefined;
161
170
  const awaitFn: AwaitHumanInputFn = async (config) => {
162
171
  capturedConfig = config;
163
- return { outcome: "approve", auto_resolved: true, reason: "timeout" };
172
+ return { outcome: "approve" };
164
173
  };
165
174
 
166
175
  const taskDef: HumanInputTaskDef = {
@@ -170,31 +179,27 @@ describe("executeHumanInputTask", () => {
170
179
 
171
180
  await executeHumanInputTask(taskDef, "t", createState(), makeCtx(awaitFn));
172
181
 
173
- expect(capturedConfig!.onTimeout).toBe("approve");
182
+ expect(capturedConfig).toEqual({ signalName: "human_input_t", timeoutSeconds: 60 });
174
183
  });
175
184
 
176
- it("defaults onTimeout to 'fail' when not set", async () => {
177
- let capturedConfig: HumanInputExecutionConfig | undefined;
178
- const awaitFn: AwaitHumanInputFn = async (config) => {
179
- capturedConfig = config;
180
- return { outcome: "approve" };
181
- };
182
-
185
+ it("fails the task on a timeout when onTimeout is not set", async () => {
183
186
  const taskDef: HumanInputTaskDef = {
184
187
  kind: "human_input",
185
188
  humanInput: { prompt: "Review", timeout: 60 },
186
189
  };
187
190
 
188
- await executeHumanInputTask(taskDef, "t", createState(), makeCtx(awaitFn));
189
-
190
- expect(capturedConfig!.onTimeout).toBe("fail");
191
+ await expect(
192
+ executeHumanInputTask(taskDef, "t", createState(), makeCtx(async () => TIMED_OUT)),
193
+ ).rejects.toThrow("human_input task timed out waiting for signal 'human_input_t'");
191
194
  });
192
195
  });
193
196
 
194
197
  describe("error propagation", () => {
195
- it("propagates timeout failure errors from orchestrator", async () => {
198
+ it("propagates an error from the wait untouched and logs no resolution", async () => {
199
+ const emitted: WorkflowEventDescriptor[][] = [];
200
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
196
201
  const awaitFn: AwaitHumanInputFn = async () => {
197
- throw new Error("human_input task timed out");
202
+ throw new Error("workflow cancelled");
198
203
  };
199
204
 
200
205
  const taskDef: HumanInputTaskDef = {
@@ -203,18 +208,86 @@ describe("executeHumanInputTask", () => {
203
208
  };
204
209
 
205
210
  await expect(
206
- executeHumanInputTask(taskDef, "t", createState(), makeCtx(awaitFn)),
211
+ executeHumanInputTask(taskDef, "t", createState(), makeCtx(awaitFn, emitFn)),
212
+ ).rejects.toThrow("workflow cancelled");
213
+ expect(emitted.flat().map((e) => e.type)).toEqual(["approval_requested"]);
214
+ });
215
+ });
216
+
217
+ // A fail-policy timeout ends the gate without a decision. It still closes
218
+ // the approval in the event log, so a reader never sees a request left
219
+ // open, and then fails the task with the error it always raised
220
+ // (stigmer/stigmer#1442).
221
+ describe("fail-policy timeout", () => {
222
+ const taskDef: HumanInputTaskDef = {
223
+ kind: "human_input",
224
+ humanInput: { prompt: "Review", timeout: 10, onTimeout: "fail" },
225
+ };
226
+
227
+ it("logs one approval_resolved with auto_resolved and no outcome, then fails the task", async () => {
228
+ const emitted: WorkflowEventDescriptor[][] = [];
229
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
230
+ const state = createState();
231
+
232
+ await expect(
233
+ executeHumanInputTask(taskDef, "gate", state, makeCtx(async () => TIMED_OUT, emitFn)),
234
+ ).rejects.toThrow("human_input task timed out waiting for signal 'human_input_gate'");
235
+
236
+ const resolutions = emitted.flat().filter((e) => e.type === "approval_resolved");
237
+ expect(resolutions).toHaveLength(1);
238
+ expect(resolutions[0]).toMatchObject({
239
+ taskName: "gate",
240
+ outcome: "",
241
+ resolvedBy: "",
242
+ resolvedByActor: undefined,
243
+ comment: "",
244
+ autoResolved: true,
245
+ });
246
+ expect(state.data.gate, "a failed gate records no decision").toBeUndefined();
247
+ });
248
+
249
+ it("asks the patch gate by its pinned id before logging", async () => {
250
+ const isPatched = vi.fn(() => true);
251
+
252
+ await expect(
253
+ executeHumanInputTask(taskDef, "gate", createState(), makeCtx(async () => TIMED_OUT, undefined, { isPatched })),
207
254
  ).rejects.toThrow("timed out");
255
+
256
+ expect(HUMAN_INPUT_FAIL_TIMEOUT_RESOLVED_PATCH).toBe("human-input-fail-timeout-resolved");
257
+ expect(isPatched).toHaveBeenCalledWith(HUMAN_INPUT_FAIL_TIMEOUT_RESOLVED_PATCH);
258
+ });
259
+
260
+ it("replays a history from before the patch in the old order: no resolution, the same failure", async () => {
261
+ const emitted: WorkflowEventDescriptor[][] = [];
262
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
263
+
264
+ await expect(
265
+ executeHumanInputTask(
266
+ taskDef, "gate", createState(),
267
+ makeCtx(async () => TIMED_OUT, emitFn, { isPatched: () => false }),
268
+ ),
269
+ ).rejects.toThrow("human_input task timed out waiting for signal 'human_input_gate'");
270
+
271
+ expect(emitted.flat().map((e) => e.type)).toEqual(["approval_requested"]);
272
+ });
273
+
274
+ it("never asks the patch gate for a gate a policy or a reviewer decided", async () => {
275
+ const isPatched = vi.fn(() => true);
276
+ const approveOnTimeout: HumanInputTaskDef = {
277
+ kind: "human_input",
278
+ humanInput: { prompt: "Review", timeout: 10, onTimeout: "approve" },
279
+ };
280
+
281
+ await executeHumanInputTask(approveOnTimeout, "a", createState(), makeCtx(async () => TIMED_OUT, undefined, { isPatched }));
282
+ await executeHumanInputTask(taskDef, "b", createState(), makeCtx(async () => ({ outcome: "approve" }), undefined, { isPatched }));
283
+
284
+ expect(isPatched).not.toHaveBeenCalled();
208
285
  });
209
286
  });
210
287
 
211
288
  describe("auto-resolved results", () => {
212
289
  it("handles auto-approve timeout result", async () => {
213
- const awaitFn: AwaitHumanInputFn = async () => ({
214
- outcome: "approve",
215
- auto_resolved: true,
216
- reason: "timeout",
217
- });
290
+ const awaitFn: AwaitHumanInputFn = async () => TIMED_OUT;
218
291
 
219
292
  const taskDef: HumanInputTaskDef = {
220
293
  kind: "human_input",
@@ -229,11 +302,7 @@ describe("executeHumanInputTask", () => {
229
302
  });
230
303
 
231
304
  it("handles auto-deny timeout result", async () => {
232
- const awaitFn: AwaitHumanInputFn = async () => ({
233
- outcome: "deny",
234
- auto_resolved: true,
235
- reason: "timeout",
236
- });
305
+ const awaitFn: AwaitHumanInputFn = async () => TIMED_OUT;
237
306
 
238
307
  const taskDef: HumanInputTaskDef = {
239
308
  kind: "human_input",
@@ -260,11 +329,7 @@ describe("executeHumanInputTask", () => {
260
329
  ];
261
330
 
262
331
  it("maps timeout auto-approve to the FIRST outcome and routes its then", async () => {
263
- const awaitFn: AwaitHumanInputFn = async () => ({
264
- outcome: "approve",
265
- auto_resolved: true,
266
- reason: "timeout",
267
- });
332
+ const awaitFn: AwaitHumanInputFn = async () => TIMED_OUT;
268
333
 
269
334
  const taskDef: HumanInputTaskDef = {
270
335
  kind: "human_input",
@@ -288,11 +353,7 @@ describe("executeHumanInputTask", () => {
288
353
  });
289
354
 
290
355
  it("maps timeout auto-deny to the LAST outcome", async () => {
291
- const awaitFn: AwaitHumanInputFn = async () => ({
292
- outcome: "deny",
293
- auto_resolved: true,
294
- reason: "timeout",
295
- });
356
+ const awaitFn: AwaitHumanInputFn = async () => TIMED_OUT;
296
357
 
297
358
  const taskDef: HumanInputTaskDef = {
298
359
  kind: "human_input",
@@ -310,11 +371,7 @@ describe("executeHumanInputTask", () => {
310
371
  // — the ordinary name lookup finds the "escalate" outcome and routes its
311
372
  // `then`, regardless of the outcome's position in the list.
312
373
  it("resolves timeout escalate to the escalate-named outcome and routes its then", async () => {
313
- const awaitFn: AwaitHumanInputFn = async () => ({
314
- outcome: "escalate",
315
- auto_resolved: true,
316
- reason: "timeout",
317
- });
374
+ const awaitFn: AwaitHumanInputFn = async () => TIMED_OUT;
318
375
 
319
376
  const escalateOutcomes = [
320
377
  { name: "proceed", label: "Proceed", then: "deployStep" },
@@ -347,11 +404,7 @@ describe("executeHumanInputTask", () => {
347
404
  it("reports the mapped outcome on the approval_resolved event", async () => {
348
405
  const emitted: WorkflowEventDescriptor[][] = [];
349
406
  const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
350
- const awaitFn: AwaitHumanInputFn = async () => ({
351
- outcome: "approve",
352
- auto_resolved: true,
353
- reason: "timeout",
354
- });
407
+ const awaitFn: AwaitHumanInputFn = async () => TIMED_OUT;
355
408
 
356
409
  const taskDef: HumanInputTaskDef = {
357
410
  kind: "human_input",
@@ -385,11 +438,7 @@ describe("executeHumanInputTask", () => {
385
438
  });
386
439
 
387
440
  it("keeps plain approve/deny for binary gates without custom outcomes", async () => {
388
- const awaitFn: AwaitHumanInputFn = async () => ({
389
- outcome: "approve",
390
- auto_resolved: true,
391
- reason: "timeout",
392
- });
441
+ const awaitFn: AwaitHumanInputFn = async () => TIMED_OUT;
393
442
 
394
443
  const taskDef: HumanInputTaskDef = {
395
444
  kind: "human_input",
@@ -474,10 +523,40 @@ describe("executeHumanInputTask", () => {
474
523
  expect(resolved.outcome).toBe("reject");
475
524
  expect(resolved.resolvedBy).toBe("carol");
476
525
  expect(resolved.resolvedByActor).toBeUndefined();
526
+ expect(resolved.comment).toBe("");
477
527
  expect(resolved.autoResolved).toBe(false);
478
528
  expect(resolved.waitDurationMs).toBeGreaterThanOrEqual(0);
479
529
  });
480
530
 
531
+ it("carries the reviewer's comment into approval_resolved and the task output", async () => {
532
+ const emitted: WorkflowEventDescriptor[][] = [];
533
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
534
+ const awaitFn: AwaitHumanInputFn = async () => ({
535
+ outcome: "deny",
536
+ reviewer: "ida_01abc",
537
+ comment: "Rollback plan is missing",
538
+ responded_at: "2026-05-20T10:00:00Z",
539
+ });
540
+
541
+ const taskDef: HumanInputTaskDef = {
542
+ kind: "human_input",
543
+ humanInput: { prompt: "Confirm?" },
544
+ };
545
+
546
+ const state = createState();
547
+ await executeHumanInputTask(taskDef, "gate", state, makeCtx(awaitFn, emitFn));
548
+
549
+ const resolved = emitted[1][0];
550
+ if (resolved.type !== "approval_resolved") throw new Error("unexpected");
551
+ expect(resolved.comment).toBe("Rollback plan is missing");
552
+ expect(state.data.gate).toEqual({
553
+ outcome: "deny",
554
+ reviewer: "ida_01abc",
555
+ comment: "Rollback plan is missing",
556
+ responded_at: "2026-05-20T10:00:00Z",
557
+ });
558
+ });
559
+
481
560
  it("carries the reviewer_actor display snapshot into approval_resolved and the task output", async () => {
482
561
  const emitted: WorkflowEventDescriptor[][] = [];
483
562
  const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
@@ -607,11 +686,7 @@ describe("executeHumanInputTask", () => {
607
686
  it("emits approval_resolved with autoResolved true on timeout auto-approve", async () => {
608
687
  const emitted: WorkflowEventDescriptor[][] = [];
609
688
  const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
610
- const awaitFn: AwaitHumanInputFn = async () => ({
611
- outcome: "approve",
612
- auto_resolved: true,
613
- reason: "timeout",
614
- });
689
+ const awaitFn: AwaitHumanInputFn = async () => TIMED_OUT;
615
690
 
616
691
  const taskDef: HumanInputTaskDef = {
617
692
  kind: "human_input",
@@ -625,6 +700,7 @@ describe("executeHumanInputTask", () => {
625
700
 
626
701
  expect(resolved.autoResolved).toBe(true);
627
702
  expect(resolved.outcome).toBe("approve");
703
+ expect(resolved.comment).toBe("");
628
704
  });
629
705
  });
630
706