@stigmer/runner 3.40.1 → 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 (248) 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/streaming-side-effects.d.ts +20 -3
  34. package/dist/activities/execute-deep-agent/streaming-side-effects.js +26 -4
  35. package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
  36. package/dist/activities/execute-deep-agent/translator.d.ts +5 -4
  37. package/dist/activities/execute-deep-agent/translator.js +5 -4
  38. package/dist/activities/execute-deep-agent/translator.js.map +1 -1
  39. package/dist/activities/execute-deep-agent/turn-settle.js +6 -6
  40. package/dist/activities/execute-deep-agent/turn-settle.js.map +1 -1
  41. package/dist/activities/execute-deep-agent/turn-setup.js +7 -7
  42. package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
  43. package/dist/activities/execute-deep-agent/turn-stream.d.ts +10 -6
  44. package/dist/activities/execute-deep-agent/turn-stream.js +6 -16
  45. package/dist/activities/execute-deep-agent/turn-stream.js.map +1 -1
  46. package/dist/activities/execute-deep-agent/turn.d.ts +4 -1
  47. package/dist/activities/execute-deep-agent/turn.js +15 -6
  48. package/dist/activities/execute-deep-agent/turn.js.map +1 -1
  49. package/dist/activities/workflow-event-activities.js +2 -0
  50. package/dist/activities/workflow-event-activities.js.map +1 -1
  51. package/dist/config.d.ts +9 -0
  52. package/dist/config.js +2 -0
  53. package/dist/config.js.map +1 -1
  54. package/dist/exit-backstop.d.ts +64 -0
  55. package/dist/exit-backstop.js +79 -0
  56. package/dist/exit-backstop.js.map +1 -0
  57. package/dist/harness/terminal-table.d.ts +12 -5
  58. package/dist/harness/terminal-table.js +13 -13
  59. package/dist/harness/terminal-table.js.map +1 -1
  60. package/dist/harness/transcript/builder.d.ts +21 -0
  61. package/dist/harness/transcript/builder.js +53 -31
  62. package/dist/harness/transcript/builder.js.map +1 -1
  63. package/dist/harness/transcript/events.d.ts +22 -26
  64. package/dist/harness/transcript/events.js +6 -5
  65. package/dist/harness/transcript/events.js.map +1 -1
  66. package/dist/harness/turn-context.d.ts +3 -1
  67. package/dist/harness/turn-context.js +5 -3
  68. package/dist/harness/turn-context.js.map +1 -1
  69. package/dist/main.js +10 -4
  70. package/dist/main.js.map +1 -1
  71. package/dist/middleware/cost-advisory.d.ts +6 -3
  72. package/dist/middleware/cost-advisory.js +6 -3
  73. package/dist/middleware/cost-advisory.js.map +1 -1
  74. package/dist/middleware/execution-budget.d.ts +22 -7
  75. package/dist/middleware/execution-budget.js +57 -35
  76. package/dist/middleware/execution-budget.js.map +1 -1
  77. package/dist/middleware/index.d.ts +10 -7
  78. package/dist/middleware/index.js +10 -7
  79. package/dist/middleware/index.js.map +1 -1
  80. package/dist/middleware/types.d.ts +6 -1
  81. package/dist/runner-manager.js +3 -2
  82. package/dist/runner-manager.js.map +1 -1
  83. package/dist/runner.d.ts +25 -0
  84. package/dist/runner.js +41 -14
  85. package/dist/runner.js.map +1 -1
  86. package/dist/shared/approval-policy.d.ts +23 -13
  87. package/dist/shared/approval-policy.js +47 -14
  88. package/dist/shared/approval-policy.js.map +1 -1
  89. package/dist/shared/caller-identity.d.ts +4 -5
  90. package/dist/shared/caller-identity.js +4 -5
  91. package/dist/shared/caller-identity.js.map +1 -1
  92. package/dist/shared/channel-attachment.js +3 -2
  93. package/dist/shared/channel-attachment.js.map +1 -1
  94. package/dist/shared/connect-backfill.d.ts +2 -1
  95. package/dist/shared/connect-backfill.js +2 -2
  96. package/dist/shared/connect-backfill.js.map +1 -1
  97. package/dist/shared/mcp-resolver.d.ts +10 -1
  98. package/dist/shared/mcp-resolver.js +11 -2
  99. package/dist/shared/mcp-resolver.js.map +1 -1
  100. package/dist/shared/memory-attachment.js +3 -2
  101. package/dist/shared/memory-attachment.js.map +1 -1
  102. package/dist/shared/model-error.d.ts +25 -3
  103. package/dist/shared/model-error.js +78 -10
  104. package/dist/shared/model-error.js.map +1 -1
  105. package/dist/shared/platform-server-address.d.ts +71 -0
  106. package/dist/shared/platform-server-address.js +103 -0
  107. package/dist/shared/platform-server-address.js.map +1 -0
  108. package/dist/shared/synthesized-attachment.d.ts +0 -5
  109. package/dist/shared/synthesized-attachment.js +0 -12
  110. package/dist/shared/synthesized-attachment.js.map +1 -1
  111. package/dist/shared/tool-rounds.d.ts +51 -23
  112. package/dist/shared/tool-rounds.js +67 -24
  113. package/dist/shared/tool-rounds.js.map +1 -1
  114. package/dist/shared/workspace/platform-dir.d.ts +15 -0
  115. package/dist/shared/workspace/platform-dir.js +18 -1
  116. package/dist/shared/workspace/platform-dir.js.map +1 -1
  117. package/dist/shared/workspace/stigmer-link.d.ts +21 -14
  118. package/dist/shared/workspace/stigmer-link.js +91 -29
  119. package/dist/shared/workspace/stigmer-link.js.map +1 -1
  120. package/dist/tools/url-guard.d.ts +5 -1
  121. package/dist/tools/url-guard.js +9 -2
  122. package/dist/tools/url-guard.js.map +1 -1
  123. package/dist/worker.d.ts +11 -2
  124. package/dist/worker.js +1 -1
  125. package/dist/worker.js.map +1 -1
  126. package/dist/workflow-engine/tasks/human-input.d.ts +16 -0
  127. package/dist/workflow-engine/tasks/human-input.js +79 -37
  128. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  129. package/dist/workflow-engine/types.d.ts +14 -4
  130. package/dist/workflow-engine/types.js.map +1 -1
  131. package/dist/workflows/call-agent-orchestrator.d.ts +13 -0
  132. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  133. package/dist/workflows/engine-core.d.ts +25 -0
  134. package/dist/workflows/engine-core.js.map +1 -1
  135. package/dist/workflows/execute-from-execution.d.ts +3 -0
  136. package/dist/workflows/execute-from-execution.js.map +1 -1
  137. package/dist/workflows/human-input-orchestrator.d.ts +9 -12
  138. package/dist/workflows/human-input-orchestrator.js +12 -44
  139. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  140. package/package.json +5 -5
  141. package/src/__test-utils__/config-fixture.ts +1 -0
  142. package/src/__test-utils__/execution-record-fixture.ts +2 -1
  143. package/src/__test-utils__/harness-contract/runtime-contract.ts +5 -3
  144. package/src/__test-utils__/test-gate.ts +8 -0
  145. package/src/__tests__/exit-backstop.test.ts +91 -0
  146. package/src/__tests__/golden-e2e.test.ts +80 -62
  147. package/src/__tests__/history-encryption-e2e.test.ts +51 -27
  148. package/src/__tests__/runner.test.ts +60 -2
  149. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  150. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +5 -1
  151. package/src/activities/__tests__/discover-mcp-server.test.ts +90 -49
  152. package/src/activities/__tests__/workflow-event-activities.test.ts +28 -7
  153. package/src/activities/call-agent.ts +1 -1
  154. package/src/activities/discover-mcp-server.ts +27 -37
  155. package/src/activities/execute-cursor/__test-utils__/fold.ts +6 -4
  156. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +1 -0
  157. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +4 -29
  158. package/src/activities/execute-cursor/__tests__/hermetic/bypassed-tool-call.test.ts +120 -0
  159. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +5 -5
  160. package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +0 -2
  161. package/src/activities/execute-cursor/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
  162. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.error-delta.status.json +0 -3
  163. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.output-first.status.json +0 -2
  164. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.status.json +0 -2
  165. package/src/activities/execute-cursor/__tests__/hermetic/goldens/stall.status.json +0 -2
  166. package/src/activities/execute-cursor/__tests__/hermetic/goldens/unattributed-hook-block.status.json +0 -3
  167. package/src/activities/execute-cursor/__tests__/hermetic/shell-output-delta.test.ts +9 -7
  168. package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +6 -1
  169. package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +8 -11
  170. package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +1 -3
  171. package/src/activities/execute-cursor/__tests__/translator.test.ts +68 -15
  172. package/src/activities/execute-cursor/approval-policy.ts +11 -36
  173. package/src/activities/execute-cursor/boundary-rows.ts +3 -3
  174. package/src/activities/execute-cursor/translator.ts +15 -32
  175. package/src/activities/execute-cursor/turn-stream.ts +4 -1
  176. package/src/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.ts +33 -2
  177. package/src/activities/execute-deep-agent/__tests__/adapter.test.ts +1 -0
  178. package/src/activities/execute-deep-agent/__tests__/hermetic/file-review-capture.test.ts +4 -0
  179. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/file-review-capture.status.json +1 -1
  180. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn1.status.json +1 -1
  181. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn2.status.json +1 -1
  182. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/recursion-limit.persisted.status.json +8 -73
  183. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/resolution-error.status.json +1 -1
  184. package/src/activities/execute-deep-agent/__tests__/hermetic/inline-artifact-across-gate.test.ts +17 -0
  185. package/src/activities/execute-deep-agent/__tests__/hermetic/recursion-limit.test.ts +86 -26
  186. package/src/activities/execute-deep-agent/__tests__/hermetic/resolution-error.test.ts +9 -5
  187. package/src/activities/execute-deep-agent/__tests__/hermetic/thrown-turn-drains-publishes.test.ts +159 -0
  188. package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +84 -0
  189. package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +47 -10
  190. package/src/activities/execute-deep-agent/__tests__/streaming-side-effects.test.ts +34 -0
  191. package/src/activities/execute-deep-agent/__tests__/turn-stream.test.ts +13 -5
  192. package/src/activities/execute-deep-agent/inline-publisher.ts +47 -7
  193. package/src/activities/execute-deep-agent/platform-route.ts +109 -20
  194. package/src/activities/execute-deep-agent/streaming-side-effects.ts +27 -4
  195. package/src/activities/execute-deep-agent/translator.ts +5 -4
  196. package/src/activities/execute-deep-agent/turn-settle.ts +6 -6
  197. package/src/activities/execute-deep-agent/turn-setup.ts +7 -7
  198. package/src/activities/execute-deep-agent/turn-stream.ts +15 -22
  199. package/src/activities/execute-deep-agent/turn.ts +16 -7
  200. package/src/activities/workflow-event-activities.ts +2 -0
  201. package/src/config.ts +11 -0
  202. package/src/exit-backstop.ts +97 -0
  203. package/src/harness/README.md +5 -5
  204. package/src/harness/terminal-table.ts +13 -13
  205. package/src/harness/transcript/__tests__/builder.test.ts +93 -21
  206. package/src/harness/transcript/builder.ts +53 -30
  207. package/src/harness/transcript/events.ts +22 -27
  208. package/src/harness/turn-context.ts +5 -3
  209. package/src/main.ts +9 -5
  210. package/src/middleware/__tests__/execution-budget.test.ts +181 -103
  211. package/src/middleware/cost-advisory.ts +6 -3
  212. package/src/middleware/execution-budget.ts +60 -34
  213. package/src/middleware/index.ts +10 -7
  214. package/src/middleware/types.ts +6 -1
  215. package/src/runner-manager.ts +3 -2
  216. package/src/runner.ts +57 -15
  217. package/src/shared/__tests__/approval-policy.test.ts +41 -0
  218. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  219. package/src/shared/__tests__/connect-backfill.test.ts +15 -11
  220. package/src/shared/__tests__/mcp-resolver.test.ts +72 -10
  221. package/src/shared/__tests__/model-error.test.ts +71 -3
  222. package/src/shared/__tests__/platform-server-address.test.ts +122 -0
  223. package/src/shared/__tests__/tool-rounds.test.ts +64 -14
  224. package/src/shared/approval-policy.ts +57 -14
  225. package/src/shared/caller-identity.ts +4 -5
  226. package/src/shared/channel-attachment.ts +2 -2
  227. package/src/shared/connect-backfill.ts +5 -1
  228. package/src/shared/mcp-resolver.ts +11 -1
  229. package/src/shared/memory-attachment.ts +2 -2
  230. package/src/shared/model-error.ts +87 -9
  231. package/src/shared/platform-server-address.ts +123 -0
  232. package/src/shared/synthesized-attachment.ts +0 -12
  233. package/src/shared/tool-rounds.ts +70 -24
  234. package/src/shared/workspace/__tests__/stigmer-link.test.ts +107 -10
  235. package/src/shared/workspace/platform-dir.ts +19 -1
  236. package/src/shared/workspace/stigmer-link.ts +99 -29
  237. package/src/tools/__tests__/url-guard.test.ts +19 -1
  238. package/src/tools/url-guard.ts +9 -1
  239. package/src/worker.ts +12 -2
  240. package/src/workflow-engine/__tests__/golden-execution.test.ts +2 -1
  241. package/src/workflow-engine/__tests__/loader.test.ts +1 -1
  242. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +133 -57
  243. package/src/workflow-engine/tasks/human-input.ts +92 -42
  244. package/src/workflow-engine/types.ts +16 -4
  245. package/src/workflows/call-agent-orchestrator.ts +7 -0
  246. package/src/workflows/engine-core.ts +18 -0
  247. package/src/workflows/execute-from-execution.ts +2 -1
  248. package/src/workflows/human-input-orchestrator.ts +15 -56
@@ -1,18 +1,28 @@
1
1
  /**
2
- * ExecutionConfig.max_tool_rounds → LangGraph recursion_limit resolution.
2
+ * ExecutionConfig.max_tool_rounds: the round budget, its backstop, and the
3
+ * signal that the budget ended a turn.
3
4
  *
4
5
  * Implements the proto contract (agentexecution/v1/spec.proto):
5
- * - 0 / unset = unlimited — no recursionLimit is placed on the invoke
6
- * config, preserving the run-until-done + loop-detection posture.
6
+ * - 0 / unset = unlimited: no round limit and no recursionLimit, preserving
7
+ * the run-until-done + loop-detection posture.
7
8
  * - When set, valid range is 10–1000 rounds; out-of-range values are
8
9
  * clamped to the nearest bound with a warning log.
9
- * - recursion_limit = rounds × 6: each model-tool round consumes multiple
10
- * graph super-steps because every active middleware hook is its own
11
- * node; ×6 is the contract's floor estimate.
10
+ * - A round is one model response that proposes one or more tool calls,
11
+ * followed by their execution; parallel calls are one round.
12
12
  *
13
- * The SAME resolved limit must feed both LangGraph's hard stop (the invoke
14
- * config) and the ExecutionBudgetMiddleware's wrap-up advisory, so the
15
- * "~80% of budget" warning and the enforcement point can never disagree.
13
+ * The budget is COUNTED, not estimated. `middleware/execution-budget.ts`
14
+ * counts rounds where the model is called and throws
15
+ * {@link ToolRoundLimitError} before round N+1's model call, so N rounds
16
+ * run on every middleware stack. The count and the "~80% of budget"
17
+ * advisory live in that one middleware, so they can never disagree. Until
18
+ * #1113 the budget was LangGraph's recursion limit at rounds × 6 super-steps,
19
+ * and because every middleware hook is its own graph node, how many rounds
20
+ * fitted moved whenever the stack did.
21
+ *
22
+ * LangGraph's recursion limit stays only as a backstop
23
+ * ({@link backstopRecursionLimit}): generous enough never to bind before the
24
+ * round count on any stack, it halts a graph that loops without calling the
25
+ * model. Either stop is the same outcome ({@link isToolCallBudgetStop}).
16
26
  *
17
27
  * The knob and its terminal copy live together (the `cost-guard.ts` shape):
18
28
  * when the budget is exhausted the adapter ends its turn `tool_call_limit`
@@ -21,26 +31,39 @@
21
31
  * and the conversation continues on the next message.
22
32
  */
23
33
 
24
- export const SUPER_STEPS_PER_ROUND = 6;
34
+ import { unwrapModelError } from "./model-error.js";
35
+
25
36
  export const MIN_TOOL_ROUNDS = 10;
26
37
  export const MAX_TOOL_ROUNDS = 1000;
27
38
 
28
39
  /**
29
- * The advisory-only budget used when max_tool_rounds is unset: high enough
30
- * to never fire on a healthy run. Deliberately NOT enforced as a hard
31
- * recursionLimit — unset means unlimited per the proto contract.
40
+ * Super-steps per round the backstop allows: well above the graph nodes one
41
+ * round costs on any stack the runner builds (about five today), so the
42
+ * round count, never the backstop, is what a bounded run meets.
43
+ */
44
+ export const SUPER_STEPS_BACKSTOP_PER_ROUND = 20;
45
+
46
+ /**
47
+ * The advisory-only round budget used when max_tool_rounds is unset: high
48
+ * enough to never fire on a healthy run. Deliberately NOT enforced — unset
49
+ * means unlimited per the proto contract.
32
50
  */
33
- export const UNBOUNDED_ADVISORY_RECURSION_LIMIT = 6000;
51
+ export const UNBOUNDED_ADVISORY_TOOL_ROUNDS = MAX_TOOL_ROUNDS;
34
52
 
35
53
  /**
36
- * The recursionLimit for the invoke config, or null when the execution is
37
- * unbounded (max_tool_rounds 0/unset — the pre-existing default posture).
54
+ * The round budget for a turn, clamped to the valid range, or null when the
55
+ * execution is unbounded (max_tool_rounds 0/unset — the default posture).
38
56
  */
39
- export function resolveRecursionLimit(maxToolRounds: number | undefined): number | null {
57
+ export function resolveToolRoundLimit(maxToolRounds: number | undefined): number | null {
40
58
  if (!maxToolRounds || maxToolRounds <= 0) {
41
59
  return null;
42
60
  }
43
- return clampToolRounds(maxToolRounds) * SUPER_STEPS_PER_ROUND;
61
+ return clampToolRounds(maxToolRounds);
62
+ }
63
+
64
+ /** The recursionLimit backstop for a bounded turn's invoke config; see {@link SUPER_STEPS_BACKSTOP_PER_ROUND}. */
65
+ export function backstopRecursionLimit(toolRoundLimit: number): number {
66
+ return toolRoundLimit * SUPER_STEPS_BACKSTOP_PER_ROUND;
44
67
  }
45
68
 
46
69
  function clampToolRounds(requested: number): number {
@@ -88,13 +111,36 @@ export const TOOL_CALL_LIMIT_USER_COPY =
88
111
  "Send another message to continue where the agent left off.";
89
112
 
90
113
  /**
91
- * Whether an error thrown out of a LangGraph run is the graph exhausting the
92
- * recursion limit `resolveRecursionLimit` placed on it — the budget's own
93
- * signal, not a failure: the adapter ends the turn `tool_call_limit`.
94
- * Matched by class name and message because the error class is not
95
- * exported on a stable path across `@langchain/langgraph` versions.
114
+ * The execution budget's stop: thrown by `middleware/execution-budget.ts` in
115
+ * place of round N+1's model call, so the graph ends on round N's tool
116
+ * results with no proposed call left unexecuted.
117
+ */
118
+ export class ToolRoundLimitError extends Error {
119
+ constructor(readonly toolRoundLimit: number) {
120
+ super(`Tool-round limit reached: ${toolRoundLimit} rounds`);
121
+ this.name = "ToolRoundLimitError";
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Whether an error thrown out of a LangGraph run is the tool-call budget
127
+ * ending the turn — the budget's own signal, not a failure: the adapter ends
128
+ * the turn `tool_call_limit`. Two producers, one outcome: the round count's
129
+ * {@link ToolRoundLimitError}, which reaches the turn wrapped in LangChain's
130
+ * `MiddlewareError` once per `wrapModelCall` layer (so the cause chain is
131
+ * walked, as `unwrapModelError` does for a model error), and the backstop's
132
+ * `GraphRecursionError`.
133
+ */
134
+ export function isToolCallBudgetStop(err: unknown): boolean {
135
+ return unwrapModelError(err) instanceof ToolRoundLimitError || isRecursionLimitError(err);
136
+ }
137
+
138
+ /**
139
+ * LangGraph exhausting the backstop recursion limit. Matched by class name and
140
+ * message because the error class is not exported on a stable path across
141
+ * `@langchain/langgraph` versions.
96
142
  */
97
- export function isGraphRecursionError(err: unknown): boolean {
143
+ function isRecursionLimitError(err: unknown): boolean {
98
144
  if (err instanceof Error) {
99
145
  return err.constructor.name === "GraphRecursionError" ||
100
146
  err.message.includes("GraphRecursionError") ||
@@ -1,8 +1,15 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { mkdtemp, mkdir, rm, writeFile, readFile, lstat, readlink } from "node:fs/promises";
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtemp, mkdir, rm, writeFile, readFile, readdir, rename, lstat, readlink } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
+ // `rename` passes through to the real one; the cross-filesystem case rejects
7
+ // it once with EXDEV, as a move from a cloud workspace volume to `$HOME` does.
8
+ vi.mock("node:fs/promises", async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import("node:fs/promises")>();
10
+ return { ...actual, rename: vi.fn(actual.rename) };
11
+ });
12
+
6
13
  import {
7
14
  ensureStigmerSymlink,
8
15
  removeStigmerSymlink,
@@ -16,7 +23,9 @@ import { LocalWorkspaceBackend } from "../local-backend.js";
16
23
  * (approved plan, skills, attachment inputs) readable at `.stigmer/…` by
17
24
  * whatever reads from the workspace: the Cursor SDK and shell commands on
18
25
  * both harnesses. The lifecycle tests pin the ownership contract (ensure
19
- * replaces, remove is symlink-only); the convergence tests pin the physics:
26
+ * moves a real entry out of the workspace and never deletes one, refuses a
27
+ * `.stigmer` that holds the platform dir, and remove is symlink-only); the
28
+ * convergence tests pin the physics:
20
29
  * content written through the platform-routing LocalWorkspaceBackend is
21
30
  * readable at the same `.stigmer/…` path from inside the workspace once the
22
31
  * link exists, and not before (the "plan file doesn't exist" bug).
@@ -69,20 +78,108 @@ describe("stigmer-link", () => {
69
78
  }
70
79
  });
71
80
 
72
- it("replaces a real .stigmer directory (platform owns the name)", async () => {
73
- // The ownership sharp edge, pinned deliberately: a non-symlink
74
- // `.stigmer` (left behind by an older runner) must never shadow the
75
- // platform mount. See the module header for why this is safe.
76
- await mkdir(join(linkPath(), "old"), { recursive: true });
77
- await writeFile(join(linkPath(), "old", "stale.txt"), "stale");
81
+ it("removes an empty real .stigmer directory, then links", async () => {
82
+ await mkdir(linkPath());
78
83
 
79
84
  await ensureStigmerSymlink(workspaceDir, platformDir);
80
85
 
81
- expect((await lstat(linkPath())).isSymbolicLink()).toBe(true);
82
86
  expect(await readlink(linkPath())).toBe(platformDir);
83
87
  });
84
88
  });
85
89
 
90
+ // ── A real `.stigmer` in the link's place (#1123, #1424) ─────────
91
+
92
+ describe("ensureStigmerSymlink over a real .stigmer", () => {
93
+ // The platform dir sits in a session tree here, as `getPlatformDir` lays
94
+ // it out, so the displaced dir lands beside it and not in the shared tmp.
95
+ let sessionDir: string;
96
+ let sessionPlatformDir: string;
97
+
98
+ beforeEach(async () => {
99
+ sessionDir = await mkdtemp(join(tmpdir(), "stigmer-link-session-"));
100
+ sessionPlatformDir = join(sessionDir, "platform");
101
+ await mkdir(sessionPlatformDir);
102
+ });
103
+
104
+ afterEach(async () => {
105
+ await rm(sessionDir, { recursive: true, force: true });
106
+ });
107
+
108
+ /** The one entry under the session's `displaced/` dir. */
109
+ async function displacedEntry(): Promise<string> {
110
+ const entries = await readdir(join(sessionDir, "displaced"));
111
+ expect(entries).toHaveLength(1);
112
+ expect(entries[0]).toMatch(/^\d{8}T\d{9}Z$/);
113
+ return join(sessionDir, "displaced", entries[0]);
114
+ }
115
+
116
+ it("moves a non-empty directory to the session's displaced dir, then links", async () => {
117
+ // An agent write on a turn with no link, or an older runner's leftover:
118
+ // kept, never deleted.
119
+ await mkdir(join(linkPath(), "notes"), { recursive: true });
120
+ await writeFile(join(linkPath(), "notes", "draft.md"), "the agent's draft");
121
+
122
+ await ensureStigmerSymlink(workspaceDir, sessionPlatformDir);
123
+
124
+ expect(await readlink(linkPath())).toBe(sessionPlatformDir);
125
+ expect(await readFile(join(await displacedEntry(), "notes", "draft.md"), "utf-8")).toBe("the agent's draft");
126
+ expect(await readdir(workspaceDir), "nothing else is left in the workspace").toEqual([STIGMER_LOCAL_STATE_DIR]);
127
+ });
128
+
129
+ it("moves a real .stigmer file the same way", async () => {
130
+ await writeFile(linkPath(), "not a directory");
131
+
132
+ await ensureStigmerSymlink(workspaceDir, sessionPlatformDir);
133
+
134
+ expect(await readlink(linkPath())).toBe(sessionPlatformDir);
135
+ expect(await readFile(await displacedEntry(), "utf-8")).toBe("not a directory");
136
+ });
137
+
138
+ it("copies then removes when the session dir is on another filesystem (EXDEV)", async () => {
139
+ await mkdir(join(linkPath(), "notes"), { recursive: true });
140
+ await writeFile(join(linkPath(), "notes", "draft.md"), "across mounts");
141
+ vi.mocked(rename).mockRejectedValueOnce(
142
+ Object.assign(new Error("EXDEV: cross-device link not permitted"), { code: "EXDEV" }),
143
+ );
144
+
145
+ await ensureStigmerSymlink(workspaceDir, sessionPlatformDir);
146
+
147
+ expect(vi.mocked(rename)).toHaveBeenCalled();
148
+ expect(await readlink(linkPath())).toBe(sessionPlatformDir);
149
+ expect(await readFile(join(await displacedEntry(), "notes", "draft.md"), "utf-8")).toBe("across mounts");
150
+ });
151
+
152
+ it("refuses a .stigmer that holds the platform dir, touching nothing (a workspace at the home directory)", async () => {
153
+ // The workspace IS the home: its `.stigmer` is the Stigmer home, where
154
+ // the CLI's config and every session live, this one's platform dir too.
155
+ const home = await mkdtemp(join(tmpdir(), "stigmer-link-home-"));
156
+ try {
157
+ const stigmerHome = join(home, STIGMER_LOCAL_STATE_DIR);
158
+ const homePlatformDir = join(stigmerHome, "sessions", "s1", "platform");
159
+ await mkdir(homePlatformDir, { recursive: true });
160
+ await writeFile(join(stigmerHome, "config.yaml"), "context: local\n");
161
+
162
+ await expect(ensureStigmerSymlink(home, homePlatformDir)).rejects.toThrow(/is the Stigmer home/);
163
+
164
+ expect((await lstat(stigmerHome)).isDirectory(), "still the real directory").toBe(true);
165
+ expect(await readFile(join(stigmerHome, "config.yaml"), "utf-8")).toBe("context: local\n");
166
+ expect((await lstat(homePlatformDir)).isDirectory()).toBe(true);
167
+ await expect(lstat(join(stigmerHome, "sessions", "s1", "displaced"))).rejects.toMatchObject({ code: "ENOENT" });
168
+ } finally {
169
+ await rm(home, { recursive: true, force: true });
170
+ }
171
+ });
172
+
173
+ it("refuses when the platform dir is the .stigmer itself", async () => {
174
+ await mkdir(linkPath());
175
+ await writeFile(join(linkPath(), "keep.txt"), "keep");
176
+
177
+ await expect(ensureStigmerSymlink(workspaceDir, linkPath())).rejects.toThrow(/is the Stigmer home/);
178
+
179
+ expect(await readFile(join(linkPath(), "keep.txt"), "utf-8")).toBe("keep");
180
+ });
181
+ });
182
+
86
183
  describe("removeStigmerSymlink", () => {
87
184
  it("removes the symlink and leaves the platform dir intact", async () => {
88
185
  await writeFile(join(platformDir, "keep.txt"), "keep");
@@ -10,12 +10,14 @@
10
10
  * a real repo never leaves Stigmer files behind in it (see issue #173); only
11
11
  * a minimal, transient `.cursor/hooks.json` referencing the absolute script
12
12
  * path is written into the workspace itself.
13
+ * - `displaced/` — a real `.stigmer` entry found in the workspace when the
14
+ * platform link takes the name, kept rather than deleted (stigmer-link.ts).
13
15
  *
14
16
  * This module replaces the duplicated `getPlatformDir` helpers in
15
17
  * execute-cursor/skill-resolver.ts and shared/attachment-resolver.ts.
16
18
  */
17
19
 
18
- import { join } from "node:path";
20
+ import { dirname, join } from "node:path";
19
21
  import { mkdir } from "node:fs/promises";
20
22
  import { homedir } from "node:os";
21
23
  import { createHash } from "node:crypto";
@@ -110,6 +112,22 @@ export async function ensurePlatformDir(sessionId: string): Promise<string> {
110
112
  return dir;
111
113
  }
112
114
 
115
+ /**
116
+ * Compute where a real `.stigmer` entry found in a workspace is moved when the
117
+ * platform link takes the name — `displaced/<stamp>` beside the session's
118
+ * `platform/` and `hitl/` trees (see stigmer-link.ts). Outside the workspace,
119
+ * so a real repo is never left holding it (issue #173) and a write-back never
120
+ * commits it; outside `platform/`, so the agent never reads it back as
121
+ * platform content.
122
+ *
123
+ * Takes the platform dir rather than a session id, unlike its siblings: the
124
+ * link step knows only the dir it links to, and the session dir is its parent.
125
+ * Pure function — performs no I/O.
126
+ */
127
+ export function getDisplacedDir(platformDir: string, stamp: string): string {
128
+ return join(dirname(platformDir), "displaced", stamp);
129
+ }
130
+
113
131
  /**
114
132
  * Compute the HITL directory path for a session — the runner-owned home for the
115
133
  * approval gate's hook script, approval-state file, and denial ledger.
@@ -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
  });