@stigmer/runner 3.14.0 → 3.15.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 (331) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/__test-utils__/execution-record-fixture.d.ts +47 -0
  3. package/dist/__test-utils__/execution-record-fixture.js +68 -0
  4. package/dist/__test-utils__/execution-record-fixture.js.map +1 -0
  5. package/dist/__test-utils__/harness-contract/types.d.ts +166 -0
  6. package/dist/__test-utils__/harness-contract/types.js +49 -0
  7. package/dist/__test-utils__/harness-contract/types.js.map +1 -0
  8. package/dist/__test-utils__/hermetic-activity.d.ts +268 -0
  9. package/dist/__test-utils__/hermetic-activity.js +385 -0
  10. package/dist/__test-utils__/hermetic-activity.js.map +1 -0
  11. package/dist/__test-utils__/mock-client.d.ts +13 -0
  12. package/dist/__test-utils__/mock-client.js +45 -0
  13. package/dist/__test-utils__/mock-client.js.map +1 -0
  14. package/dist/__test-utils__/mock-workspace.d.ts +15 -0
  15. package/dist/__test-utils__/mock-workspace.js +21 -0
  16. package/dist/__test-utils__/mock-workspace.js.map +1 -0
  17. package/dist/__test-utils__/model-registry-fixture.d.ts +56 -0
  18. package/dist/__test-utils__/model-registry-fixture.js +66 -0
  19. package/dist/__test-utils__/model-registry-fixture.js.map +1 -0
  20. package/dist/__test-utils__/turn-input-fixture.d.ts +60 -0
  21. package/dist/__test-utils__/turn-input-fixture.js +118 -0
  22. package/dist/__test-utils__/turn-input-fixture.js.map +1 -0
  23. package/dist/activities/execute-cursor/__test-utils__/contract-subject.d.ts +107 -0
  24. package/dist/activities/execute-cursor/__test-utils__/contract-subject.js +344 -0
  25. package/dist/activities/execute-cursor/__test-utils__/contract-subject.js.map +1 -0
  26. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +20 -0
  27. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +58 -0
  28. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  29. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js +2 -32
  30. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js.map +1 -1
  31. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +148 -0
  32. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +257 -0
  33. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -0
  34. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +183 -0
  35. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +262 -0
  36. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -0
  37. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.d.ts +138 -0
  38. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js +191 -0
  39. package/dist/activities/execute-cursor/__test-utils__/scripted-sdk.js.map +1 -0
  40. package/dist/activities/execute-cursor/adapter.d.ts +55 -0
  41. package/dist/activities/execute-cursor/adapter.js +119 -0
  42. package/dist/activities/execute-cursor/adapter.js.map +1 -0
  43. package/dist/activities/execute-cursor/approval-state.d.ts +2 -2
  44. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  45. package/dist/activities/execute-cursor/capture-flow.d.ts +15 -30
  46. package/dist/activities/execute-cursor/capture-flow.js +17 -26
  47. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  48. package/dist/activities/execute-cursor/cursor-capabilities.d.ts +23 -0
  49. package/dist/activities/execute-cursor/cursor-capabilities.js +33 -0
  50. package/dist/activities/execute-cursor/cursor-capabilities.js.map +1 -0
  51. package/dist/activities/execute-cursor/cursor-mcp-config.d.ts +2 -2
  52. package/dist/activities/execute-cursor/cursor-mcp-config.js.map +1 -1
  53. package/dist/activities/execute-cursor/fetch-interceptor.d.ts +15 -25
  54. package/dist/activities/execute-cursor/fetch-interceptor.js +39 -33
  55. package/dist/activities/execute-cursor/fetch-interceptor.js.map +1 -1
  56. package/dist/activities/execute-cursor/http2-interceptor.d.ts +21 -17
  57. package/dist/activities/execute-cursor/http2-interceptor.js +38 -31
  58. package/dist/activities/execute-cursor/http2-interceptor.js.map +1 -1
  59. package/dist/activities/execute-cursor/message-translator.d.ts +28 -19
  60. package/dist/activities/execute-cursor/message-translator.js +76 -27
  61. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  62. package/dist/activities/execute-cursor/prompt-builder.d.ts +180 -11
  63. package/dist/activities/execute-cursor/prompt-builder.js +171 -0
  64. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  65. package/dist/activities/execute-cursor/rejection-capture.d.ts +3 -6
  66. package/dist/activities/execute-cursor/rejection-capture.js +5 -7
  67. package/dist/activities/execute-cursor/rejection-capture.js.map +1 -1
  68. package/dist/activities/execute-cursor/session-lifecycle.d.ts +1 -4
  69. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  70. package/dist/activities/execute-cursor/turn-boundary.d.ts +1 -1
  71. package/dist/activities/execute-cursor/turn-settle.d.ts +49 -0
  72. package/dist/activities/execute-cursor/turn-settle.js +507 -0
  73. package/dist/activities/execute-cursor/turn-settle.js.map +1 -0
  74. package/dist/activities/execute-cursor/turn-setup.d.ts +173 -0
  75. package/dist/activities/execute-cursor/turn-setup.js +496 -0
  76. package/dist/activities/execute-cursor/turn-setup.js.map +1 -0
  77. package/dist/activities/execute-cursor/turn-stream.d.ts +41 -71
  78. package/dist/activities/execute-cursor/turn-stream.js +70 -135
  79. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  80. package/dist/activities/execute-cursor/turn.d.ts +43 -0
  81. package/dist/activities/execute-cursor/turn.js +222 -0
  82. package/dist/activities/execute-cursor/turn.js.map +1 -0
  83. package/dist/activities/execute-cursor/usage-pricing.d.ts +38 -0
  84. package/dist/activities/execute-cursor/usage-pricing.js +50 -0
  85. package/dist/activities/execute-cursor/usage-pricing.js.map +1 -0
  86. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +1 -1
  87. package/dist/activities/execute-deep-agent/approval-file-change.js +1 -1
  88. package/dist/activities/execute-deep-agent/environment.js +1 -1
  89. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  90. package/dist/activities/execute-deep-agent/setup.js +3 -2
  91. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  92. package/dist/activities/hydrate-workflow-execution.js +1 -1
  93. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  94. package/dist/config.d.ts +15 -0
  95. package/dist/config.js.map +1 -1
  96. package/dist/harness/capabilities.d.ts +87 -0
  97. package/dist/harness/capabilities.js +37 -0
  98. package/dist/harness/capabilities.js.map +1 -0
  99. package/dist/harness/persist-chokepoint.d.ts +74 -0
  100. package/dist/harness/persist-chokepoint.js +88 -0
  101. package/dist/harness/persist-chokepoint.js.map +1 -0
  102. package/dist/harness/registry.d.ts +106 -0
  103. package/dist/harness/registry.js +168 -0
  104. package/dist/harness/registry.js.map +1 -0
  105. package/dist/harness/run-turn.d.ts +53 -0
  106. package/dist/harness/run-turn.js +528 -0
  107. package/dist/harness/run-turn.js.map +1 -0
  108. package/dist/harness/stop-controller.d.ts +50 -0
  109. package/dist/harness/stop-controller.js +62 -0
  110. package/dist/harness/stop-controller.js.map +1 -0
  111. package/dist/harness/terminal-table.d.ts +141 -0
  112. package/dist/harness/terminal-table.js +239 -0
  113. package/dist/harness/terminal-table.js.map +1 -0
  114. package/dist/harness/turn-context.d.ts +523 -0
  115. package/dist/harness/turn-context.js +739 -0
  116. package/dist/harness/turn-context.js.map +1 -0
  117. package/dist/harness/types.d.ts +448 -0
  118. package/dist/harness/types.js +64 -0
  119. package/dist/harness/types.js.map +1 -0
  120. package/dist/harness/usage-accumulator.d.ts +68 -0
  121. package/dist/harness/usage-accumulator.js +101 -0
  122. package/dist/harness/usage-accumulator.js.map +1 -0
  123. package/dist/harness-adapters.d.ts +27 -0
  124. package/dist/harness-adapters.js +28 -0
  125. package/dist/harness-adapters.js.map +1 -0
  126. package/dist/main.js.map +1 -1
  127. package/dist/runner-manager.js +64 -48
  128. package/dist/runner-manager.js.map +1 -1
  129. package/dist/runner.d.ts +5 -4
  130. package/dist/runner.js +65 -64
  131. package/dist/runner.js.map +1 -1
  132. package/dist/shared/approval-policy.d.ts +1 -1
  133. package/dist/shared/approval-policy.js.map +1 -1
  134. package/dist/{activities/execute-cursor → shared}/attachment-resolver.d.ts +2 -2
  135. package/dist/{activities/execute-cursor → shared}/attachment-resolver.js +5 -5
  136. package/dist/shared/attachment-resolver.js.map +1 -0
  137. package/dist/{activities/execute-cursor → shared}/blueprint-resolver.d.ts +7 -5
  138. package/dist/{activities/execute-cursor → shared}/blueprint-resolver.js +3 -63
  139. package/dist/shared/blueprint-resolver.js.map +1 -0
  140. package/dist/shared/channel-attachment.d.ts +1 -1
  141. package/dist/shared/channel-attachment.js.map +1 -1
  142. package/dist/{activities/execute-cursor → shared}/cost-guard.d.ts +5 -1
  143. package/dist/{activities/execute-cursor → shared}/cost-guard.js +5 -1
  144. package/dist/shared/cost-guard.js.map +1 -0
  145. package/dist/{activities/execute-cursor → shared}/env-resolver.d.ts +1 -1
  146. package/dist/shared/env-resolver.js.map +1 -0
  147. package/dist/{activities/execute-cursor → shared}/exact-apply.d.ts +1 -1
  148. package/dist/{activities/execute-cursor → shared}/exact-apply.js +6 -6
  149. package/dist/shared/exact-apply.js.map +1 -0
  150. package/dist/shared/execution-context.d.ts +35 -0
  151. package/dist/shared/execution-context.js +38 -0
  152. package/dist/shared/execution-context.js.map +1 -0
  153. package/dist/{activities/execute-cursor → shared}/extract-structured-output.d.ts +5 -2
  154. package/dist/{activities/execute-cursor → shared}/extract-structured-output.js +9 -6
  155. package/dist/shared/extract-structured-output.js.map +1 -0
  156. package/dist/shared/filereview/capture.d.ts +1 -1
  157. package/dist/shared/filereview/capture.js +1 -1
  158. package/dist/shared/heartbeat.d.ts +13 -11
  159. package/dist/shared/heartbeat.js +14 -29
  160. package/dist/shared/heartbeat.js.map +1 -1
  161. package/dist/{activities/execute-cursor → shared}/persist-decision.d.ts +5 -2
  162. package/dist/{activities/execute-cursor → shared}/persist-decision.js +4 -1
  163. package/dist/shared/persist-decision.js.map +1 -0
  164. package/dist/{activities/execute-cursor → shared}/skill-resolver.d.ts +16 -3
  165. package/dist/{activities/execute-cursor → shared}/skill-resolver.js +12 -4
  166. package/dist/shared/skill-resolver.js.map +1 -0
  167. package/dist/shared/subagent-rows.d.ts +21 -0
  168. package/dist/shared/subagent-rows.js +34 -0
  169. package/dist/shared/subagent-rows.js.map +1 -0
  170. package/dist/shared/worker-shutdown.d.ts +43 -16
  171. package/dist/shared/worker-shutdown.js +28 -13
  172. package/dist/shared/worker-shutdown.js.map +1 -1
  173. package/dist/shared/workspace/platform-dir.d.ts +1 -1
  174. package/dist/shared/workspace/platform-dir.js +1 -1
  175. package/dist/shared/workspace/session-provision.d.ts +54 -0
  176. package/dist/{activities/execute-cursor/workspace-provision.js → shared/workspace/session-provision.js} +15 -12
  177. package/dist/shared/workspace/session-provision.js.map +1 -0
  178. package/package.json +4 -4
  179. package/src/__test-utils__/__tests__/harness-contract-self-check.test.ts +325 -0
  180. package/src/__test-utils__/config-fixture.ts +63 -0
  181. package/src/__test-utils__/execution-record-fixture.ts +104 -0
  182. package/src/__test-utils__/harness-boot-order-child.ts +58 -0
  183. package/src/__test-utils__/harness-contract/contract.ts +581 -0
  184. package/src/__test-utils__/harness-contract/recording-sink.ts +107 -0
  185. package/src/__test-utils__/harness-contract/runtime-contract.ts +666 -0
  186. package/src/__test-utils__/harness-contract/scripted-adapter.ts +349 -0
  187. package/src/__test-utils__/harness-contract/types.ts +176 -0
  188. package/src/__test-utils__/hermetic-activity.ts +502 -0
  189. package/src/__test-utils__/model-registry-fixture.ts +72 -0
  190. package/src/__test-utils__/module-specifiers.ts +82 -0
  191. package/src/__test-utils__/proto-helpers.ts +25 -0
  192. package/src/__test-utils__/turn-input-fixture.ts +155 -0
  193. package/src/__tests__/harness-boot-order.test.ts +77 -0
  194. package/src/__tests__/harness-contract.test.ts +27 -0
  195. package/src/__tests__/runner-manager.test.ts +18 -0
  196. package/src/activities/execute-cursor/__test-utils__/__tests__/scripted-double.test.ts +102 -0
  197. package/src/activities/execute-cursor/__test-utils__/contract-subject.ts +396 -0
  198. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +66 -0
  199. package/src/activities/execute-cursor/__test-utils__/gateway-substrate.ts +2 -43
  200. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +342 -0
  201. package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +368 -0
  202. package/src/activities/execute-cursor/__test-utils__/scripted-sdk.ts +254 -0
  203. package/src/activities/execute-cursor/__tests__/adapter-is-temporal-free.test.ts +45 -0
  204. package/src/activities/execute-cursor/__tests__/approval-decisions-agree.test.ts +90 -0
  205. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -8
  206. package/src/activities/execute-cursor/__tests__/capture-flow.test.ts +28 -1
  207. package/src/activities/execute-cursor/__tests__/deny-gate-exact-apply.test.ts +1 -1
  208. package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +64 -6
  209. package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +228 -0
  210. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +180 -0
  211. package/src/activities/execute-cursor/__tests__/hermetic/goldens/cost-cap.status.json +53 -0
  212. package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn1.status.json +55 -0
  213. package/src/activities/execute-cursor/__tests__/hermetic/goldens/deny-and-retry.turn2.status.json +77 -0
  214. package/src/activities/execute-cursor/__tests__/hermetic/goldens/file-review-capture.status.json +126 -0
  215. package/src/activities/execute-cursor/__tests__/hermetic/goldens/pause.status.json +40 -0
  216. package/src/activities/execute-cursor/__tests__/hermetic/goldens/plain-turn.status.json +48 -0
  217. package/src/activities/execute-cursor/__tests__/hermetic/goldens/platform-stop.status.json +56 -0
  218. package/src/activities/execute-cursor/__tests__/hermetic/goldens/recovery-fresh-agent.status.json +53 -0
  219. package/src/activities/execute-cursor/__tests__/hermetic/goldens/recovery-transport-timeout.status.json +48 -0
  220. package/src/activities/execute-cursor/__tests__/hermetic/goldens/resolution-error.status.json +18 -0
  221. package/src/activities/execute-cursor/__tests__/hermetic/goldens/run-cancelled.status.json +36 -0
  222. package/src/activities/execute-cursor/__tests__/hermetic/goldens/run-error-non-retryable.status.json +37 -0
  223. package/src/activities/execute-cursor/__tests__/hermetic/goldens/sdk-error-at-create.status.json +38 -0
  224. package/src/activities/execute-cursor/__tests__/hermetic/goldens/stall.status.json +59 -0
  225. package/src/activities/execute-cursor/__tests__/hermetic/goldens/tool-call.status.json +68 -0
  226. package/src/activities/execute-cursor/__tests__/hermetic/goldens/unattributed-hook-block.status.json +79 -0
  227. package/src/activities/execute-cursor/__tests__/hermetic/goldens/worker-shutdown.status.json +42 -0
  228. package/src/activities/execute-cursor/__tests__/hermetic/harness-contract.test.ts +170 -0
  229. package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +205 -0
  230. package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +171 -0
  231. package/src/activities/execute-cursor/__tests__/hermetic/recovery-fresh-agent.test.ts +156 -0
  232. package/src/activities/execute-cursor/__tests__/hermetic/run-wait-arms.test.ts +286 -0
  233. package/src/activities/execute-cursor/__tests__/hermetic/stream-self-stop-arms.test.ts +332 -0
  234. package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +193 -0
  235. package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +137 -0
  236. package/src/activities/execute-cursor/__tests__/hermetic/unattributed-hook-block.test.ts +185 -0
  237. package/src/activities/execute-cursor/__tests__/hermetic/workspace-lock-timeout.test.ts +151 -0
  238. package/src/activities/execute-cursor/__tests__/http2-interceptor.test.ts +65 -35
  239. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +0 -40
  240. package/src/activities/execute-cursor/__tests__/same-identity-reproposal.test.ts +171 -0
  241. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +118 -243
  242. package/src/activities/execute-cursor/__tests__/usage-pricing.test.ts +84 -0
  243. package/src/activities/execute-cursor/adapter.ts +138 -0
  244. package/src/activities/execute-cursor/approval-state.ts +2 -2
  245. package/src/activities/execute-cursor/capture-flow.ts +18 -41
  246. package/src/activities/execute-cursor/cursor-capabilities.ts +35 -0
  247. package/src/activities/execute-cursor/cursor-mcp-config.ts +2 -2
  248. package/src/activities/execute-cursor/fetch-interceptor.ts +48 -45
  249. package/src/activities/execute-cursor/http2-interceptor.ts +47 -34
  250. package/src/activities/execute-cursor/message-translator.ts +90 -45
  251. package/src/activities/execute-cursor/prompt-builder.ts +327 -12
  252. package/src/activities/execute-cursor/rejection-capture.ts +5 -12
  253. package/src/activities/execute-cursor/session-lifecycle.ts +1 -5
  254. package/src/activities/execute-cursor/turn-boundary.ts +1 -1
  255. package/src/activities/execute-cursor/turn-settle.ts +601 -0
  256. package/src/activities/execute-cursor/turn-setup.ts +645 -0
  257. package/src/activities/execute-cursor/turn-stream.ts +84 -210
  258. package/src/activities/execute-cursor/turn.ts +240 -0
  259. package/src/activities/execute-cursor/usage-pricing.ts +67 -0
  260. package/src/activities/execute-deep-agent/approval-file-change.ts +1 -1
  261. package/src/activities/execute-deep-agent/environment.ts +1 -1
  262. package/src/activities/execute-deep-agent/setup.ts +3 -2
  263. package/src/activities/hydrate-workflow-execution.ts +1 -1
  264. package/src/config.ts +13 -0
  265. package/src/harness/__tests__/import-direction.test.ts +168 -0
  266. package/src/harness/__tests__/persist-chokepoint.test.ts +146 -0
  267. package/src/harness/__tests__/registry.test.ts +168 -0
  268. package/src/harness/__tests__/run-turn.test.ts +165 -0
  269. package/src/harness/__tests__/stop-controller.test.ts +49 -0
  270. package/src/harness/__tests__/turn-context.test.ts +178 -0
  271. package/src/harness/__tests__/usage-accumulator.test.ts +83 -0
  272. package/src/harness/capabilities.ts +92 -0
  273. package/src/harness/persist-chokepoint.ts +112 -0
  274. package/src/harness/registry.ts +197 -0
  275. package/src/harness/run-turn.ts +618 -0
  276. package/src/harness/stop-controller.ts +83 -0
  277. package/src/harness/terminal-table.ts +276 -0
  278. package/src/harness/turn-context.ts +1028 -0
  279. package/src/harness/types.ts +473 -0
  280. package/src/harness/usage-accumulator.ts +122 -0
  281. package/src/harness-adapters.ts +30 -0
  282. package/src/main.ts +1 -1
  283. package/src/runner-manager.ts +68 -61
  284. package/src/runner.ts +67 -78
  285. package/src/shared/__tests__/attachment-naming.test.ts +1 -1
  286. package/src/{activities/execute-cursor → shared}/__tests__/attachment-resolver.test.ts +6 -4
  287. package/src/{activities/execute-cursor → shared}/__tests__/cost-guard.test.ts +2 -1
  288. package/src/{activities/execute-cursor → shared}/__tests__/exact-apply.test.ts +3 -3
  289. package/src/{activities/execute-cursor → shared}/__tests__/extract-structured-output.test.ts +8 -8
  290. package/src/{activities/execute-cursor → shared}/__tests__/persist-decision.test.ts +4 -2
  291. package/src/{activities/execute-cursor → shared}/__tests__/skill-resolver.test.ts +1 -1
  292. package/src/shared/__tests__/subagent-rows.test.ts +34 -0
  293. package/src/shared/__tests__/worker-shutdown.test.ts +44 -39
  294. package/src/shared/approval-policy.ts +1 -1
  295. package/src/{activities/execute-cursor → shared}/attachment-resolver.ts +6 -6
  296. package/src/{activities/execute-cursor → shared}/blueprint-resolver.ts +7 -78
  297. package/src/shared/channel-attachment.ts +1 -1
  298. package/src/{activities/execute-cursor → shared}/cost-guard.ts +5 -1
  299. package/src/{activities/execute-cursor → shared}/env-resolver.ts +1 -1
  300. package/src/{activities/execute-cursor → shared}/exact-apply.ts +7 -7
  301. package/src/shared/execution-context.ts +45 -0
  302. package/src/{activities/execute-cursor → shared}/extract-structured-output.ts +10 -7
  303. package/src/shared/filereview/capture.ts +1 -1
  304. package/src/shared/heartbeat.ts +13 -33
  305. package/src/{activities/execute-cursor → shared}/persist-decision.ts +5 -2
  306. package/src/{activities/execute-cursor → shared}/skill-resolver.ts +20 -6
  307. package/src/shared/subagent-rows.ts +39 -0
  308. package/src/shared/worker-shutdown.ts +55 -25
  309. package/src/{activities/execute-cursor/__tests__/workspace-provision.test.ts → shared/workspace/__tests__/session-provision.test.ts} +25 -28
  310. package/src/shared/workspace/platform-dir.ts +1 -1
  311. package/src/{activities/execute-cursor/workspace-provision.ts → shared/workspace/session-provision.ts} +29 -16
  312. package/dist/activities/execute-cursor/attachment-resolver.js.map +0 -1
  313. package/dist/activities/execute-cursor/blueprint-resolver.js.map +0 -1
  314. package/dist/activities/execute-cursor/cost-guard.js.map +0 -1
  315. package/dist/activities/execute-cursor/env-resolver.js.map +0 -1
  316. package/dist/activities/execute-cursor/exact-apply.js.map +0 -1
  317. package/dist/activities/execute-cursor/extract-structured-output.js.map +0 -1
  318. package/dist/activities/execute-cursor/index.d.ts +0 -209
  319. package/dist/activities/execute-cursor/index.js +0 -2345
  320. package/dist/activities/execute-cursor/index.js.map +0 -1
  321. package/dist/activities/execute-cursor/persist-decision.js.map +0 -1
  322. package/dist/activities/execute-cursor/skill-resolver.js.map +0 -1
  323. package/dist/activities/execute-cursor/usage-accumulator.d.ts +0 -89
  324. package/dist/activities/execute-cursor/usage-accumulator.js +0 -121
  325. package/dist/activities/execute-cursor/usage-accumulator.js.map +0 -1
  326. package/dist/activities/execute-cursor/workspace-provision.d.ts +0 -42
  327. package/dist/activities/execute-cursor/workspace-provision.js.map +0 -1
  328. package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +0 -164
  329. package/src/activities/execute-cursor/index.ts +0 -2876
  330. package/src/activities/execute-cursor/usage-accumulator.ts +0 -169
  331. /package/dist/{activities/execute-cursor → shared}/env-resolver.js +0 -0
@@ -1,2876 +0,0 @@
1
- /**
2
- * ExecuteCursor Temporal activity — the core of the cursor-runner service.
3
- *
4
- * Implements the same Slim-Payload Pattern as ExecuteGraphton:
5
- * - Receives only executionId + harnessStateId (Cursor agentId)
6
- * - Hydrates execution from DB via gRPC
7
- * - Resolves full agent blueprint (instructions, MCP servers, skills, sub-agents)
8
- * - Runs the Cursor agent, streams events, reports status
9
- * - Returns slim AgentExecutionStatus to workflow
10
- *
11
- * Durable HITL Model:
12
- * When a tool requires approval, the preToolUse hook denies it. The activity
13
- * captures the denied tool details, reports WAITING_FOR_APPROVAL, and RETURNS
14
- * to the workflow. The workflow waits for the approvalGateResolved signal,
15
- * then reinvokes this activity. On reinvocation, the activity resumes the
16
- * Cursor Agent and prompts it to execute the approved tool.
17
- *
18
- * This is identical to the LangGraph flow from the workflow's perspective.
19
- *
20
- * Durable Continuation Model:
21
- * Conversation continuity is carried by the Cursor SDK's native local agent
22
- * state, whose SQLite store is persisted on the durable workspace volume
23
- * (see resolvePlatformOptions) so Agent.resume() survives pod restart,
24
- * reschedule, and snapshot restore. When resume fails (store lost/corrupted
25
- * or agent unknown), resolveAgent() creates a fresh agent and the next turn
26
- * starts from the user message plus re-injected instructions.
27
- */
28
-
29
- import { heartbeat, Context, CancelledFailure } from "@temporalio/activity";
30
- import { create, clone, type JsonObject } from "@bufbuild/protobuf";
31
- import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
32
- import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
33
- import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
34
- import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
35
- import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
36
- import type { AgentExecution, AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
37
- import { ExecutionControlSignal, ExecutionPhase, FileChangeSetStatus, InteractionMode, MessageType, ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
38
- import type { Run, ConversationTurn, SDKUserMessage } from "@cursor/sdk";
39
-
40
- import type { Config } from "../../config.js";
41
- import { StigmerClient } from "../../client/stigmer-client.js";
42
- import { describeExecutionError } from "../../shared/model-error.js";
43
- import { resolveAgentWithTransportRecovery } from "./session-lifecycle.js";
44
- import { cacheSessionAgent, computeAgentFingerprint, takeCachedAgent } from "./agent-session-cache.js";
45
- import type { AgentResolution, AgentResolutionReason, CreateAgentOptions, CreateCloudAgentOptions } from "./session-lifecycle.js";
46
- import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
47
- import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
48
- import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
49
- import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
50
- import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js";
51
- import { readContextBridge } from "../../shared/context-bridge.js";
52
- import { readConversationCatchup } from "../../shared/conversation-catchup.js";
53
- import { readSenderIdentity } from "../../shared/sender-identity.js";
54
- import {
55
- injectCallerIdentityEnv,
56
- resolveCallerIdentity,
57
- } from "../../shared/caller-identity.js";
58
- import { readSessionContext } from "../../shared/session-context.js";
59
- import { readDeclaredPreferences } from "../../shared/declared-preferences.js";
60
- import type { RecalledMemoriesContent } from "../../shared/recalled-memories.js";
61
- import { selectRecalledFacts } from "../../shared/memory-retrieval.js";
62
- import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
63
- import { StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
64
- import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
65
- import {
66
- CURSOR_VISION_PROFILE,
67
- VisionBudget,
68
- toCursorImages,
69
- type NotViewableEntry,
70
- } from "../../shared/attachment-vision.js";
71
- import { getModelVisionCapability } from "../../shared/model-registry.js";
72
- import { publishPlanArtifact } from "../../shared/plan-artifact.js";
73
- import { DeltaEnricher } from "./delta-enricher.js";
74
- import { TodoTracker } from "./todo-tracker.js";
75
- import { StreamingUpdateScheduler, loadStreamingConfig } from "../../shared/streaming-scheduler.js";
76
- import { createCursorEventRecorder } from "./cursor-event-recorder.js";
77
- import { resolveMcpServers } from "../../shared/mcp-resolver.js";
78
- import { toCursorMcpConfig, validateMcpServerEnv } from "./cursor-mcp-config.js";
79
- import { resolveMcpTransportPosture } from "../../shared/mcp-transport-guard.js";
80
- import {
81
- discoverChannelMessaging,
82
- synthesizeChannelAttachment,
83
- } from "../../shared/channel-attachment.js";
84
- import {
85
- readChannelConversationId,
86
- synthesizeConversationAttachment,
87
- } from "../../shared/conversation-attachment.js";
88
- import { synthesizeMemoryAttachment } from "../../shared/memory-attachment.js";
89
- import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
90
- import { mergeApprovalPolicies } from "./approval-policy.js";
91
- import { deriveActiveLeases, isUnattendedApprovalMode } from "../../shared/approval-policy.js";
92
- import { enabledToolsBySlug } from "../../shared/mcp-enabled-tools.js";
93
- import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
94
- import { resolveExecutionEnv } from "./env-resolver.js";
95
- import { resolveBlueprint } from "./blueprint-resolver.js";
96
- import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
97
- import { resolveSkills } from "./skill-resolver.js";
98
- import { removeStigmerSymlink } from "../../shared/workspace/stigmer-link.js";
99
- import { resolveAttachments } from "./attachment-resolver.js";
100
- import { buildEnhancedPrompt, buildHitlRecoveryPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInputFiles, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
101
- import { composeTurnRecoveryDigest } from "./turn-recovery.js";
102
- import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
103
- import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
104
- import {
105
- acquireWorkspaceLock,
106
- WorkspaceLockCancelledError,
107
- WorkspaceLockTimeoutError,
108
- type ReleaseWorkspaceLock,
109
- } from "../../shared/workspace/workspace-lock.js";
110
- import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
111
- import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts, reconstructAdjudicatedApprovals, watchDenialLedger } from "./approval-state.js";
112
- import { applyApprovedWholeFileWrites, excludeAppliedFromGrants } from "./exact-apply.js";
113
- import { isGitWorkTree } from "../../shared/filereview/git-substrate.js";
114
- import {
115
- captureBaselineToLedger,
116
- buildCursorProgressSubstrate,
117
- applyCaptureDecisions,
118
- deriveCaptureMode,
119
- } from "./capture-flow.js";
120
- import { runTurnBoundary, type TurnBoundaryResult } from "./turn-boundary.js";
121
- import {
122
- consumeCursorTurnStream,
123
- makeCursorTurnOnDelta,
124
- newTurnStreamState,
125
- type CursorTurnStreamDeps,
126
- type TurnOnDeltaDeps,
127
- } from "./turn-stream.js";
128
- import { formatCostLimitError, COST_LIMIT_USER_COPY } from "./cost-guard.js";
129
- import {
130
- captureFileChangeProgress,
131
- newProgressCaptureState,
132
- type ProgressCaptureState,
133
- type ProgressSubstrate,
134
- } from "../../shared/filereview/progress.js";
135
- import { deriveExecutionFingerprintKey } from "../../shared/approval-fingerprint.js";
136
- import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
137
- import { provisionCursorWorkspace } from "./workspace-provision.js";
138
- import { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
139
- import { statusProtoWriter } from "../../shared/execution-status-writer.js";
140
- import { setInterceptorExecutionId, runWithExecutionContext } from "./fetch-interceptor.js";
141
- import { closeProxySessions } from "./http2-interceptor.js";
142
- import { resolveModelId, ensureLoaded as ensurePricingLoaded } from "./model-pricing.js";
143
- import { resolveEffectiveServiceTier } from "../../shared/service-tier.js";
144
- import { resolveEffectiveThinkingMode } from "../../shared/thinking-mode.js";
145
- import { resolveServiceTierParams } from "./service-tier.js";
146
- import { UsageAccumulator } from "./usage-accumulator.js";
147
- import { StreamingUsageSummarySchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/usage_pb";
148
- import { activityStarted, activityFinished } from "../../idle-watchdog.js";
149
- import { normalizeActivityInput, type ExecuteActivityInput } from "../../shared/activity-input.js";
150
- import { getCapturedRejection, clearCapturedRejection } from "./rejection-capture.js";
151
- import { synthesizeError, formatClassifiedError, shouldRetryWithFreshAgent, extractRunErrorSources } from "./error-classifier.js";
152
- import type { ClassifiedError } from "./error-classifier.js";
153
- import { createAgent, createCloudAgent } from "./session-lifecycle.js";
154
- import { setMaxListeners } from "node:events";
155
- import { startHeartbeat } from "../../shared/heartbeat.js";
156
- import { classifyTurnInterruption, getShutdownSignalForQueue } from "../../shared/worker-shutdown.js";
157
-
158
- /**
159
- * Creates the activity functions bound to the runner config.
160
- * Returned object is passed to Temporal Worker.create({ activities }).
161
- */
162
- export function createCursorActivities(config: Config) {
163
- const client = new StigmerClient({
164
- endpoint: config.stigmerBackendEndpoint,
165
- token: config.stigmerToken,
166
- tokenRef: config.stigmerTokenRef,
167
- runnerTokenRef: config.stigmerRunnerTokenRef,
168
- });
169
-
170
- return {
171
- // Accepts the new typed object OR the legacy positional args (transitional
172
- // dual-shape so the runner can deploy before the control planes — see
173
- // shared/activity-input.ts). Drop the positional arm once both control
174
- // planes send the object.
175
- ExecuteCursor: async (
176
- arg0: ExecuteActivityInput | string,
177
- arg1?: string,
178
- ): Promise<unknown> => {
179
- const { executionId, threadId, turnSeq } = normalizeActivityInput(arg0, arg1);
180
- activityStarted();
181
- try {
182
- return await executeCursor(config, client, executionId, threadId, turnSeq);
183
- } finally {
184
- activityFinished();
185
- }
186
- },
187
- };
188
- }
189
-
190
- async function executeCursor(
191
- config: Config,
192
- client: StigmerClient,
193
- executionId: string,
194
- threadId: string,
195
- turnSeq: number,
196
- ): Promise<unknown> {
197
- console.log(`ExecuteCursor started: execution=${executionId}, threadId=${threadId || "(new)"}, turnSeq=${turnSeq}`);
198
-
199
- // Ensure fresh HTTP/2 transport — prevents a degraded session from a
200
- // prior workflow task from poisoning this execution's agent stream.
201
- closeProxySessions();
202
-
203
- setInterceptorExecutionId(executionId);
204
- return runWithExecutionContext(executionId, () => executeCursorInner(config, client, executionId, threadId, turnSeq));
205
- }
206
-
207
- async function executeCursorInner(
208
- config: Config,
209
- client: StigmerClient,
210
- executionId: string,
211
- threadId: string,
212
- // turnSeq is the monotonic HITL-cycle index (0 on the first turn). The
213
- // file-review producer consumes it to mint the deterministic change-set id
214
- // (executionId:turnSeq) in the capture phase.
215
- turnSeq: number,
216
- ): Promise<unknown> {
217
-
218
- const status = create(AgentExecutionStatusSchema, {
219
- phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
220
- startedAt: utcTimestamp(),
221
- });
222
-
223
- // Cold-start timeline of this turn's setup (warm-agent-surfaces Phase 0):
224
- // one mark after each phase, emitted as a single structured log line once
225
- // the Cursor agent is resolved (early returns skip it — partial setups are
226
- // not comparable cold-start samples).
227
- const setupTiming = new TimingRecorder();
228
-
229
- // Artifact storage for offloading oversized tool outputs (screenshots, giant
230
- // dumps) out of the persisted status, and for publishing the plan artifact.
231
- // Resolved once here so it is available to EVERY persist below. Best-effort via
232
- // the shared resolver (identical to the deep-agent harness): `undefined` — never
233
- // a throw — when there is no working substrate (proxy misconfig OR an unwritable
234
- // local path). An absent store disables offload (persistStatus still enforces
235
- // the aggregate size cap) and flips capture mode off (deny-gate fallback).
236
- const artifactStorage: ArtifactStorage | undefined =
237
- await resolveUsableArtifactStorage(loadArtifactStorageConfig(config), { executionId });
238
- setupTiming.mark("resolve_artifact_storage");
239
- const statusOffload = artifactStorage
240
- ? { artifactStorage, executionId }
241
- : undefined;
242
- // ALL status persistence in this activity flows through `persist`, so the
243
- // single size-bounding guard (offload + aggregate elision) is unforgeable and
244
- // a future call site cannot accidentally skip it.
245
- const persist = (s: AgentExecutionStatus = status) => {
246
- // Never-persist-secret backstop (DD-26 #2): before EVERY persist, withhold
247
- // content from any built-in write row targeting a secret-like path (top-level
248
- // + sub-agent). This is the single airtight choke point for the Cursor harness
249
- // — the deny-gate analog of capture mode's stamping scrub, and the only
250
- // guarantee under auto_approve_all (where the hook installs no gate). Safe on
251
- // every call: Cursor sets tool args atomically from the SDK tool_call event
252
- // (buildToolCallProto), so there is no mid-stream partial-args hazard, and the
253
- // pass only ever touches secret-like write rows (idempotent, else a no-op).
254
- withholdSecretContentFromMessages(s.messages, s.subAgentExecutions);
255
- return persistStatus(client, executionId, s, { offload: statusOffload });
256
- };
257
-
258
- let sessionId: string | undefined;
259
- let session: import("@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb").Session | undefined;
260
- // Single owner for every flag the turn's stream produces (pause, stall,
261
- // first-denial, platform-stop, event count, the stall watchdog, …). Created
262
- // once here — before the fs denial-watcher, the SDK onDelta, the stall
263
- // watchdog, and the stream loop are wired — so all four producers plus the
264
- // epilogue and the outer catch/finally share ONE source of truth. The primary
265
- // turn and both recovery retries drive the same stream code against this
266
- // object (see turn-stream.ts for per-field ownership).
267
- const turnState = newTurnStreamState();
268
- // NOT part of turnState: derived post-loop from the periodic heartbeat +
269
- // shutdown signal (a runner-manager shutdown, not a stream event), and read by
270
- // the epilogue + outer catch. Kept as a plain let alongside the stream flags.
271
- let workerShutdownDetected = false;
272
- let stopDenialWatcher: (() => void) | undefined;
273
- let periodicHeartbeat: ReturnType<typeof startHeartbeat> | undefined;
274
- // Ends the OTel turn span + records turn metrics with the FINAL token snapshot.
275
- // Hoisted and invoked from the finally so the span is closed exactly once on
276
- // EVERY exit path — a happy completion, an approval pause, an early return, a
277
- // throw, or a recovery retry (whose tokens accrue AFTER the primary stream
278
- // ends). Ending it inline in the epilogue leaked the span on every non-happy
279
- // path and excluded retry tokens/duration. Assigned when the span is created
280
- // (once usageAccumulator exists); undefined — a no-op — before then (e.g. a
281
- // pure-reconcile resume that returns before the agent runs) or when OTel is
282
- // off. Idempotent: safe to call more than once.
283
- let finishTurnTelemetry: (() => Promise<void>) | undefined;
284
- // Session HITL directory (runner-owned, outside the workspace) where the hook
285
- // script, approval-state file, and denial ledger live. Set once the gate is
286
- // installed; the WAITING_FOR_APPROVAL path reads the denial ledger from here.
287
- let hitlDir: string | undefined;
288
- // Teardown for the HITL gate: restores the workspace's .cursor/hooks.json and
289
- // removes the .stigmer symlink so attaching a real repo leaves it untouched
290
- // (issue #173). Runs in the finally, covering every success/error/approval
291
- // exit path. Undefined until the gate is installed.
292
- let hitlCleanup: (() => Promise<void>) | undefined;
293
- // Exclusive turn lock on the primary workspace working tree. Held across the
294
- // ENTIRE tree-mutating window (decision reconcile, HITL gate install, the
295
- // agent's own writes, candidate capture) so a concurrent execution sharing
296
- // this directory can never write between this turn's baseline and candidate
297
- // snapshots — the misattribution that showed another session's file as this
298
- // turn's change. Released in the finally AFTER hitlCleanup (which still
299
- // mutates the tree). See shared/workspace/workspace-lock.ts.
300
- let releaseWorkspaceLock: ReleaseWorkspaceLock | undefined;
301
- // Carries model/mode/agentId out to the outer catch so a thrown CursorSdkError
302
- // can be classified with the same context as the run.wait() error path.
303
- let errorContext = { model: "default", mode: "local", agentId: "" };
304
-
305
- // Periodic heartbeat for the ENTIRE activity, started before any phase runs.
306
- // Setup phases make network calls (blueprint resolution, workspace clone, MCP
307
- // backfill, Agent.create) that can stall; the scattered manual heartbeat()
308
- // pulses between them leave every individual call uncovered. The production
309
- // stale-proxy incident hung inside Agent.create with zero heartbeats and
310
- // surfaced as an opaque 5-minute Temporal timeout. The label names the
311
- // current phase so a stall is attributed in Temporal heartbeat details, and
312
- // cancellation stays observable throughout. Safe ONLY because every SDK call
313
- // below is itself bounded (agentResolveTimeoutMs, stall watchdog) — an
314
- // unbounded hang under a live heartbeat would keep a dead activity alive
315
- // forever.
316
- let heartbeatPhase = "setup";
317
- const taskQueue = Context.current().info.taskQueue;
318
- const shutdownSignal = getShutdownSignalForQueue(taskQueue);
319
- periodicHeartbeat = startHeartbeat(30_000, () => ({
320
- phase: heartbeatPhase,
321
- execution: executionId,
322
- }), { shutdownSignal });
323
-
324
- try {
325
- // Phase 1: Hydrate execution from DB
326
- await reportSetupProgress(client, executionId, "Fetching execution");
327
- const execution = await client.getExecution(executionId);
328
- const spec = execution.spec!;
329
- sessionId = spec.sessionId;
330
- setupTiming.mark("fetch_execution");
331
-
332
- // Phase 2: Load session and resolve full agent blueprint
333
- await reportSetupProgress(client, executionId, "Resolving agent blueprint");
334
- session = await client.getSession(sessionId);
335
- const blueprint = await resolveBlueprint(client, session, config.workspaceRootDir);
336
- setupTiming.mark("resolve_blueprint");
337
-
338
- // Phase 2b: Resolve execution environment (MCP server credentials)
339
- heartbeatPhase = "resolving_environment";
340
- await reportSetupProgress(client, executionId, "Resolving environment");
341
- const { envVars, secretKeys } = await resolveExecutionEnv(client, executionId);
342
- heartbeat();
343
- setupTiming.mark("resolve_environment");
344
-
345
- // Phase 2c: Provision the workspace (clone git repos / mount local paths)
346
- // so the LOCAL Cursor agent operates on the actual repo. Cursor previously
347
- // relied on cloud agents to clone git-repo workspace entries; with cloud
348
- // disabled the runner must provision the workspace itself, mirroring the
349
- // native harness. Git provisioning is idempotent across multi-turn and
350
- // HITL reinvocations.
351
- heartbeatPhase = "provisioning_workspace";
352
- await reportSetupProgress(client, executionId, "Provisioning workspace");
353
- const workspaceProvision = await provisionCursorWorkspace(
354
- config, session, envVars, sessionId ?? "",
355
- );
356
- blueprint.workspaceDirs = workspaceProvision.workspaceDirs;
357
- heartbeat();
358
- setupTiming.mark("provision_workspace");
359
-
360
- // Git write-back: pushes the session's APPROVED tree to the session
361
- // branch (stigmer/<session-id>) and keeps one PR open — the same
362
- // approval-gated model as the deep-agent harness (its
363
- // processCaptureWriteback). Finalize runs at exactly two seams below:
364
- // the pure-file-review resume (after decisions reconcile) and terminal
365
- // completion. Never mid-turn: the working tree is speculative until
366
- // reviewed. Non-eligible workspaces (local paths, no credentials) make
367
- // this a no-op coordinator.
368
- const writebackCoordinator = workspaceProvision.provisionResults.length > 0
369
- ? new WriteBackCoordinator({
370
- statusWriter: statusProtoWriter(status),
371
- executionId,
372
- sessionId: sessionId ?? "",
373
- githubToken: envVars.GITHUB_TOKEN ?? "",
374
- provisionResults: workspaceProvision.provisionResults,
375
- workspaceEntries: session.spec?.workspaceEntries ?? [],
376
- workspaceBackend: workspaceProvision.workspaceBackend,
377
- })
378
- : null;
379
-
380
- // Apply-then-review is the universal file-review model (Slice 2c). When the
381
- // primary workspace is a real git work tree, file edits flow during the turn
382
- // and are captured per-file from the git diff at the turn boundary
383
- // (capture-flow.ts / shared/filereview/git-substrate.ts). A NON-git workspace
384
- // has no git snapshot, so it captures every file write via the path-scoped CAS
385
- // substrate instead — which requires artifact storage to persist blobs; when
386
- // storage is unavailable a non-git workspace falls back to the classic
387
- // deny-gate (no regression). `gitWorkspace` selects the substrate; both flow
388
- // file edits and review post-hoc, and the deny-gate then survives only for
389
- // shell/MCP/irreversible tools. Detected once from the provisioned primary root.
390
- const primaryWorkspaceDir = blueprint.workspaceDirs[0];
391
- const gitWorkspace = primaryWorkspaceDir
392
- ? await isGitWorkTree(primaryWorkspaceDir)
393
- : false;
394
- const captureMode = deriveCaptureMode(primaryWorkspaceDir, gitWorkspace, !!artifactStorage);
395
- // Pre-turn baseline tree, pinned before the agent runs (capture mode only)
396
- // so the turn-end capture diffs against it and the tree restores exactly.
397
- let baselineTree: string | undefined;
398
- // Per-turn state for mid-run live capture (DD-32): the last progress tree sha
399
- // (short-circuit) + last capture time (floor), threaded across persists.
400
- const progressState: ProgressCaptureState = newProgressCaptureState();
401
- // Deterministic id of the change set this turn may produce:
402
- // `${executionId}:${turnSeq}`. Minted from the workflow-threaded turn index
403
- // so it is stable across a Temporal retry (idempotent ledger authoring) and
404
- // unique per turn. The resume reconcile reads the change set id back from the
405
- // DECIDED projection, not from turnSeq — so a "wasted" id on a pure-reconcile
406
- // resume (which never authors a baseline) is harmless.
407
- const changeSetId = `${executionId}:${turnSeq}`;
408
- heartbeat();
409
-
410
- // Serialize this turn against every other execution sharing this working
411
- // tree — sessions declaring the same localPath (or the shared runner root)
412
- // resolve to ONE directory, and an unserialized concurrent write lands
413
- // inside this turn's baseline→candidate window, misattributing another
414
- // session's file to this turn's review. Acquired before ANY tree mutation
415
- // below (decision reconcile, gate install, agent writes, capture). While
416
- // another turn holds the lock this surfaces a visible waiting state and
417
- // heartbeats; a cancel aborts the wait immediately.
418
- if (primaryWorkspaceDir) {
419
- try {
420
- releaseWorkspaceLock = await acquireWorkspaceLock(primaryWorkspaceDir, {
421
- onWaiting: () => reportSetupProgress(
422
- client, executionId, "Waiting for workspace — in use by another session",
423
- ),
424
- heartbeat,
425
- signal: Context.current().cancellationSignal,
426
- timeoutMs: config.workspaceLockTimeoutMs,
427
- });
428
- } catch (lockErr) {
429
- if (lockErr instanceof WorkspaceLockCancelledError) {
430
- throw new CancelledFailure("Activity cancelled while waiting for the workspace lock");
431
- }
432
- if (lockErr instanceof WorkspaceLockTimeoutError) {
433
- status.phase = ExecutionPhase.EXECUTION_FAILED;
434
- status.error = lockErr.message;
435
- status.completedAt = utcTimestamp();
436
- status.messages.push(create(AgentMessageSchema, {
437
- type: MessageType.MESSAGE_SYSTEM,
438
- content: `Execution failed: ${lockErr.message}`,
439
- timestamp: utcTimestamp(),
440
- }));
441
- await persist(status);
442
- console.warn(`ExecuteCursor workspace lock timeout: execution=${executionId}`);
443
- return slimStatus(status);
444
- }
445
- throw lockErr;
446
- }
447
- }
448
- heartbeat();
449
- setupTiming.mark("acquire_workspace_lock");
450
-
451
- // Set OTel baggage so downstream calls carry execution context.
452
- try {
453
- const { setBaggage, BAGGAGE_EXECUTION_ID, BAGGAGE_SESSION_ID, BAGGAGE_ORG_ID } = await import("../../otel.js");
454
- await setBaggage({
455
- [BAGGAGE_EXECUTION_ID]: executionId,
456
- [BAGGAGE_SESSION_ID]: sessionId ?? "",
457
- [BAGGAGE_ORG_ID]: session?.metadata?.org ?? "",
458
- });
459
- } catch {
460
- // Tracing not initialized — silently skip.
461
- }
462
-
463
- // Cloud Cursor agents are disabled platform-wide (see determineCursorMode),
464
- // so every session runs LOCAL. We intentionally ignore any persisted
465
- // cursor_mode here so a session can never route to the cloud path while
466
- // it is disabled — even one that was created when cloud was enabled.
467
- const cursorMode = determineCursorMode(
468
- blueprint.sessionSpec.workspaceEntries,
469
- config.cloudModeEnabled,
470
- );
471
- const agentMode = isCloudMode(cursorMode) ? "cloud" as const : "local" as const;
472
-
473
- heartbeat();
474
-
475
- // Phase 3: Check if this is a reinvocation after approval
476
- const isReinvocation = !!threadId;
477
- let approvalDecisions: Map<string, ApprovalAction> | undefined;
478
- // Adjudicated approvals reconstructed from the tool calls (the source of
479
- // truth for a decision). The backend projects pending_approvals from
480
- // tool-call status and clears decided entries, so pending_approvals is empty
481
- // by reinvocation time — the decision survives only on the tool call. This
482
- // feeds both the grant builder and the reinvocation prompt below.
483
- let adjudicatedApprovals: PendingApproval[] = [];
484
- // tool-call id -> content digest of the approved edit, threaded into the
485
- // grant builder so an approved edit is authorized by its exact content (a
486
- // sibling edit to the same file re-gates). Sourced from the persisted
487
- // approval_content_digest field (see reconstructAdjudicatedApprovals).
488
- let adjudicatedContentDigests: Map<string, string> = new Map();
489
- // Sub-agent executions carried over from the persisted transcript on a
490
- // resume, handed to the MessageAccumulator so a gated tool inside a
491
- // delegated sub-agent survives the round-trip (see seeding below).
492
- let seededSubAgents: SubAgentExecution[] = [];
493
-
494
- if (isReinvocation) {
495
- const existingStatus = execution.status;
496
- // Seed the in-progress status from the persisted execution BEFORE the
497
- // MessageAccumulator wraps status.messages, so this resumed turn APPENDS
498
- // onto prior history rather than rebuilding from empty. A Cursor resume
499
- // re-issues approved tool calls with fresh ids; a from-empty rebuild would
500
- // drop the previously-committed ids and the backend's append-only-at-
501
- // identity guard would reject the whole update, stalling the run (the
502
- // "approval propagation is broken" watchdog failure). The resumed re-runs
503
- // are reconciled onto these seeded calls by canonical identity inside the
504
- // accumulator. Mirrors the deep-agent seedStatusFromExecution.
505
- seededSubAgents = seedCursorTranscriptFromExecution(status, execution);
506
-
507
- // File-review reconcile (the dual-source half): reconcile every change set
508
- // the server projected as DECIDED, sourced from the ledger decisions and
509
- // the pinned git refs (approved kept at their "after" bytes, rejected
510
- // snapped back to baseline — all uncommitted, hash-verified). This is
511
- // independent of tool approvals: a single turn can carry BOTH a DECIDED
512
- // file change set AND an approved shell/MCP action.
513
- let reconciledFileReview = false;
514
- let fileReviewFailed = false;
515
- let fileReviewFailureDetail = "";
516
- const discardedPaths: string[] = [];
517
- if (captureMode && primaryWorkspaceDir) {
518
- const decidedSets = (existingStatus?.fileChangeSets ?? []).filter(
519
- (cs) => cs.status === FileChangeSetStatus.DECIDED,
520
- );
521
- for (const changeSet of decidedSets) {
522
- const capResult = await applyCaptureDecisions({
523
- status,
524
- gitRoot: primaryWorkspaceDir,
525
- executionId,
526
- changeSet,
527
- // Thread the CAS store so CAS-captured files in the change set
528
- // reconcile from the durable manifest (approved after-blobs written,
529
- // rejected snapped back). In a non-git workspace this is the ONLY
530
- // reconcile; in a git tree it composes with the git-ref reconcile.
531
- storage: artifactStorage,
532
- gitWorkspace,
533
- });
534
- if (!capResult.isCaptureTurn) continue;
535
- reconciledFileReview = true;
536
- if (capResult.failed) {
537
- fileReviewFailed = true;
538
- fileReviewFailureDetail = capResult.failureDetail ?? "file review reconcile failed";
539
- }
540
- if (capResult.hadReject) discardedPaths.push(...capResult.rejectedPaths);
541
- }
542
- }
543
-
544
- // Tool approvals (shell / MCP / gitignored writes) still resolve from the
545
- // message transcript — the deny-gate path, unchanged by the file-review
546
- // cutover.
547
- const adjudicated = reconstructAdjudicatedApprovals(existingStatus?.messages ?? []);
548
- if (adjudicated.decisions.size > 0) {
549
- approvalDecisions = adjudicated.decisions;
550
- adjudicatedApprovals = adjudicated.pendingApprovals;
551
- adjudicatedContentDigests = adjudicated.contentDigests;
552
-
553
- // A reject of an irreversible action (shell/MCP) fails the execution.
554
- const hasReject = [...approvalDecisions.values()].some(
555
- (a) => a === ApprovalAction.REJECT,
556
- );
557
- if (hasReject) {
558
- status.phase = ExecutionPhase.EXECUTION_FAILED;
559
- status.error = "Execution rejected by user";
560
- status.completedAt = utcTimestamp();
561
- status.messages.push(create(AgentMessageSchema, {
562
- type: MessageType.MESSAGE_SYSTEM,
563
- content: "Execution was rejected by the user during tool approval.",
564
- timestamp: utcTimestamp(),
565
- }));
566
- await persist(status);
567
- return slimStatus(status);
568
- }
569
- // else: fall through to run the approved shell/MCP. The agent may produce
570
- // further edits, captured as a new change set in the next cycle.
571
- } else if (reconciledFileReview) {
572
- // Pure file review: the agent already finished its full turn during
573
- // capture, so keeping/discarding a change does NOT re-prompt it
574
- // (Cursor-like). The reconcile is done; the execution is complete.
575
- status.phase = ExecutionPhase.EXECUTION_COMPLETED;
576
- status.completedAt = utcTimestamp();
577
- // Push the APPROVED tree — reconcile snapped rejected files back to
578
- // baseline, so what finalize commits is exactly what the user kept.
579
- // Mirrors the deep-agent's processCaptureWriteback: after reconcile,
580
- // before persist, never on a failed reconcile (diverged bytes must
581
- // not reach the remote).
582
- if (!fileReviewFailed && writebackCoordinator) {
583
- await writebackCoordinator.finalize();
584
- }
585
- if (fileReviewFailed) {
586
- // What-you-approve-is-what-applies could not be honored (on-disk bytes
587
- // diverged from the approved digest). Surface it to the human; the
588
- // FileReviewFailure(HASH_MISMATCH) event is the audit record.
589
- status.messages.push(create(AgentMessageSchema, {
590
- type: MessageType.MESSAGE_SYSTEM,
591
- content:
592
- "Some approved file changes could not be applied because the file " +
593
- "changed after review: " + fileReviewFailureDetail + ".",
594
- timestamp: utcTimestamp(),
595
- }));
596
- } else if (discardedPaths.length > 0) {
597
- // A reject is a DISCARD that COMPLETES (not FAILED) — surface a SYSTEM
598
- // note listing the reverted files. This note is for the human; it does
599
- // NOT re-sync the Cursor SDK agent (its native context still believes
600
- // those edits stuck). The agent self-corrects by re-reading, and any
601
- // edit it makes from that stale belief is itself re-surfaced as a new
602
- // change set next turn (the structural safety net). See
603
- // design-decisions/capture-reject-next-turn-resync-not-built.md.
604
- status.messages.push(create(AgentMessageSchema, {
605
- type: MessageType.MESSAGE_SYSTEM,
606
- content:
607
- "Some proposed file changes were discarded by the user and were not applied: " +
608
- discardedPaths.join(", ") + ".",
609
- timestamp: utcTimestamp(),
610
- }));
611
- }
612
- await persist(status);
613
- console.log(
614
- `ExecuteCursor file-review resume short-circuit: execution=${executionId}, ` +
615
- `failed=${fileReviewFailed}, discarded=${discardedPaths.length}`,
616
- );
617
- return slimStatus(status);
618
- }
619
- }
620
-
621
- // Phase 4: Resolve MCP servers with approval policies.
622
- // The MCP-bound env map (and ONLY it — never the agent process env)
623
- // carries the reserved caller-identity keys, so a server that declares
624
- // them in spec.env can template the platform-verified caller into its
625
- // headers. filterEnvToDeclaredKeys keeps every other server blind.
626
- await reportSetupProgress(client, executionId, "Resolving MCP servers");
627
- const transportPosture = resolveMcpTransportPosture(config.mode);
628
- const mcpEnvVars = injectCallerIdentityEnv(
629
- envVars,
630
- resolveCallerIdentity(
631
- blueprint.sessionSpec.metadata,
632
- session.status?.audit?.specAudit?.createdBy,
633
- ),
634
- sessionId,
635
- );
636
- // The resolved-server list mutates through backfill and attachment
637
- // injection below; the Cursor SDK config is projected from it exactly
638
- // once, after the last mutation (see toCursorMcpConfig).
639
- let resolvedMcpServers = (await resolveMcpServers(
640
- client, blueprint.mergedMcpServerUsages, mcpEnvVars, transportPosture,
641
- )).resolvedServers;
642
- setupTiming.mark("resolve_mcp_servers");
643
-
644
- // Phase 4a: Connect backfill for undiscovered MCP servers
645
- heartbeatPhase = "resolving_mcp_servers";
646
- const sessionOrg = session.metadata?.org ?? "";
647
- resolvedMcpServers = await backfillMcpServersIfNeeded(
648
- client, resolvedMcpServers, blueprint.mergedMcpServerUsages, mcpEnvVars, sessionOrg,
649
- transportPosture, heartbeat, secretKeys,
650
- );
651
- setupTiming.mark("backfill_mcp");
652
-
653
- // The synthesized attachments' credential story (DD-006 D4): the
654
- // exchanged token authenticates the discovery reads per-call (a
655
- // desktop runner's ambient embedded_runner credential is refused by
656
- // the messaging reach; undefined lets a cloud sandbox runner's
657
- // ambient session-scoped token or OSS's no-auth apply). The
658
- // attachment header falls back to the ambient credential where no
659
- // exchange happens. Unlike the env read (which hard-fails on a broken
660
- // exchange — secrets are load-bearing there), this exchange is
661
- // opportunistic: every consumer below degrades to an empty answer by
662
- // contract, and the server refuses the ambient fallback safely, so a
663
- // failed exchange must not kill the run.
664
- let exchangedRunnerToken: string | undefined;
665
- try {
666
- exchangedRunnerToken =
667
- await client.acquireScopedRunnerToken({ agentExecutionId: executionId });
668
- } catch (err) {
669
- console.warn(
670
- "[execute-cursor] Scoped-token exchange failed for attachment/discovery " +
671
- `reads; degrading to the ambient credential: ${err instanceof Error ? err.message : err}`,
672
- );
673
- }
674
- const attachmentCredential = exchangedRunnerToken
675
- ?? config.stigmerTokenRef?.current
676
- ?? config.stigmerToken;
677
-
678
- // Phase 4a2: Synthesize the channel messaging attachment (DD-006
679
- // D7/D8). Deliberately AFTER resolve + backfill: the attachment has
680
- // no McpServerUsage and reports discovered capabilities, so the
681
- // backfill's destructiveHint tightener can never force-gate its
682
- // tools; empty approval maps keep it approval-free by construction.
683
- // The discovery read is the attachment decision — the control plane
684
- // runs the SAME candidate computation the send authorization uses —
685
- // and every failure mode (no channel, OSS, registry down, pre-3a
686
- // control plane) degrades to honest absence: no tool, no section,
687
- // execution unharmed.
688
- const channelMessaging = await discoverChannelMessaging(client, exchangedRunnerToken);
689
- if (channelMessaging.length > 0) {
690
- const attachment = synthesizeChannelAttachment(channelMessaging, {
691
- bridgeEndpoint: config.mcpBridgeEndpoint,
692
- credential: attachmentCredential,
693
- backendEndpoint: config.stigmerBackendEndpoint,
694
- });
695
- if (attachment) {
696
- resolvedMcpServers = injectSynthesizedAttachment(
697
- resolvedMcpServers, attachment, "channel messaging",
698
- );
699
- }
700
- }
701
-
702
- // Phase 4a4: Synthesize the conversation participation attachment
703
- // (channel-conversations DD-008 D-c) — the third sibling. The
704
- // channel-id session label IS the attachment decision (stamped
705
- // server-side on every channel session; a free local read, unlike
706
- // the channels discovery RPC above). HTTP-only: synthesize answers
707
- // undefined with no bridge endpoint by design (see
708
- // shared/conversation-attachment.ts).
709
- const conversationAttachment = synthesizeConversationAttachment(
710
- readChannelConversationId(session.metadata?.labels),
711
- {
712
- bridgeEndpoint: config.mcpBridgeEndpoint,
713
- credential: attachmentCredential,
714
- backendEndpoint: config.stigmerBackendEndpoint,
715
- },
716
- );
717
- if (conversationAttachment) {
718
- resolvedMcpServers = injectSynthesizedAttachment(
719
- resolvedMcpServers, conversationAttachment, "conversation participation",
720
- );
721
- }
722
-
723
- // Phase 4a5: Synthesize the memory capture attachment (DD-005 D1) —
724
- // the fourth sibling. The recall snapshot's enabled bit IS the
725
- // attachment decision (server-stamped at execution create; a free
726
- // local read, like the conversation attachment's session label). The
727
- // capture context is attribution the server verifies or trusts per
728
- // edition (Stage 3 provenance decision); the subject is never
729
- // threaded — it derives from the credential.
730
- const memoryAttachment = synthesizeMemoryAttachment(
731
- execution.spec?.recalledMemories,
732
- {
733
- org: session.metadata?.org ?? "",
734
- agentId: blueprint.agent.metadata?.id ?? "",
735
- sessionId,
736
- agentExecutionId: executionId,
737
- },
738
- {
739
- bridgeEndpoint: config.mcpBridgeEndpoint,
740
- credential: attachmentCredential,
741
- backendEndpoint: config.stigmerBackendEndpoint,
742
- },
743
- );
744
- if (memoryAttachment) {
745
- resolvedMcpServers = injectSynthesizedAttachment(
746
- resolvedMcpServers, memoryAttachment, "memory capture",
747
- );
748
- }
749
- // The one projection point: every mutation above is now visible in the
750
- // Cursor SDK config by construction (no per-mutation rebuild to forget).
751
- const mcpConfig = toCursorMcpConfig(resolvedMcpServers);
752
-
753
- // Phase 4b: Merge approval policies from all layers.
754
- //
755
- // Two bypasses (see ActiveLeases, shared with the native harness): the
756
- // pre-armed spec.auto_approve_all is the one whole-run global bypass; an
757
- // interactive APPROVE_ALL grants a run-lifetime lease scoped to that action's
758
- // class. deriveActiveLeases keeps this contract defined once. Server-scoped
759
- // leases drop that server's tools from the merged map (so the hook treats
760
- // them as auto-approved); the global bypass empties the map entirely.
761
- const leases = deriveActiveLeases(execution);
762
- const globalBypass = leases.global;
763
- // Layer-3 overrides ride each resolved server from its merged usage —
764
- // see ResolvedMcpServer.toolApprovalOverrides (issue #349) — so there
765
- // is no separate override input to pass here.
766
- const mergedPolicies = mergeApprovalPolicies(
767
- resolvedMcpServers,
768
- leases,
769
- );
770
- heartbeat();
771
-
772
- // Phase 4c: Validate MCP server env health (diagnostic, non-blocking)
773
- const mcpWarnings = validateMcpServerEnv(
774
- resolvedMcpServers,
775
- blueprint.mergedMcpServerUsages,
776
- );
777
- if (mcpWarnings.length > 0) {
778
- console.warn(
779
- `ExecuteCursor MCP pre-flight warnings: execution=${executionId}\n` +
780
- mcpWarnings.map((w) => ` - ${w}`).join("\n"),
781
- );
782
- }
783
-
784
- // Phase 5: Resolve skills (merged from agent + session)
785
- await reportSetupProgress(client, executionId, "Resolving skills");
786
- // (primaryWorkspaceDir / captureMode were resolved right after provisioning.)
787
- const skillMetadata = await resolveSkills(client, blueprint.mergedSkillRefs, {
788
- sessionId,
789
- primaryWorkspaceDir,
790
- });
791
- heartbeat();
792
- setupTiming.mark("resolve_skills");
793
-
794
- // Phase 5b: Resolve attachments (fail-hard — explicit user inputs; see
795
- // attachment-resolver.ts). Downloads by storage key through the same
796
- // artifactStorage resolved for status offload above. The vision budget
797
- // rides along so image attachments are selected for inline delivery while
798
- // their bytes are already in hand (attachment-vision.ts owns all policy).
799
- // The budget also carries the requested model's registry vision
800
- // capability, looked up from the raw executionConfig name — full model
801
- // validation (Phase 6) isn't needed for this, and ""/"default" (the Auto
802
- // pool) resolves to unknown, which the policy treats as sighted.
803
- const visionBudget = new VisionBudget(CURSOR_VISION_PROFILE, {
804
- modelVision: await getModelVisionCapability(spec.executionConfig?.modelName ?? ""),
805
- });
806
- const attachmentResults = await resolveAttachments(spec.attachments, {
807
- sessionId,
808
- primaryWorkspaceDir,
809
- mode: config.mode,
810
- storage: artifactStorage,
811
- visionBudget,
812
- });
813
- const attachmentEntries = attachmentResults.map((a) => ({
814
- path: a.relativePath,
815
- ...(a.renamedFrom !== undefined ? { renamedFrom: a.renamedFrom } : {}),
816
- ...(a.downloadUrl !== undefined ? { downloadUrl: a.downloadUrl } : {}),
817
- }));
818
- // Vision facts, derived once from the single resolution result: the
819
- // images the model will see inline (in attachment order) and the ones
820
- // that degraded to path-only, disclosed in the prompt.
821
- const visionImages = attachmentResults.flatMap((a) => (a.vision ? [a.vision] : []));
822
- const visionNotViewable: NotViewableEntry[] = attachmentResults.flatMap((a) =>
823
- a.visionDegraded ? [{ path: a.relativePath, reason: a.visionDegraded }] : [],
824
- );
825
- const visionPromptInfo = visionImages.length > 0 || visionNotViewable.length > 0
826
- ? {
827
- inlineFilenames: visionImages.map((v) => v.filename),
828
- notViewable: visionNotViewable,
829
- }
830
- : undefined;
831
- if (visionPromptInfo) {
832
- console.log(
833
- `[attachment-vision] execution=${executionId} inline=${visionImages.length} ` +
834
- `(${visionImages.reduce((n, v) => n + v.byteSize, 0)} bytes) ` +
835
- `degraded=${JSON.stringify(visionNotViewable.map((d) => `${d.path}:${d.reason}`))}`,
836
- );
837
- }
838
- setupTiming.mark("resolve_attachments");
839
-
840
- // Phase 5b3: Exact-apply approved whole-file writes (HITL "what you approve
841
- // is what gets applied"). The Cursor deny-only harness reinvokes the model,
842
- // which regenerates content, so a resource grant alone cannot guarantee the
843
- // bytes that land match the bytes the user approved. The runner therefore
844
- // writes the EXACT approved whole-file content itself, marks those tool calls
845
- // COMPLETED, and (below) issues NO grant for them — so any FURTHER change the
846
- // model makes to those files is re-gated. Hunk edits / shell / MCP stay on
847
- // the grant + reinvocation path. Every uncertain case degrades to that path,
848
- // so this can never corrupt a file (see exact-apply.ts).
849
- let appliedToolCallIds: ReadonlySet<string> = new Set();
850
- // Exact-apply is the deny-gate path's "what you approve is what gets applied"
851
- // mechanism (the model regenerates content on reinvocation). Capture mode
852
- // does not reinvoke the model for file edits — it applies the exact captured
853
- // bytes itself in applyCaptureDecisions — so exact-apply is scoped OUT of it.
854
- if (!captureMode && isReinvocation && approvalDecisions) {
855
- appliedToolCallIds = await applyApprovedWholeFileWrites({
856
- messages: status.messages,
857
- workspaceBackend: new LocalWorkspaceBackend(primaryWorkspaceDir),
858
- workspaceDirs: blueprint.workspaceDirs,
859
- executionId,
860
- });
861
- if (appliedToolCallIds.size > 0) {
862
- // Persist the applied writes (tool calls now COMPLETED with the approved
863
- // diff) before reinvocation, so the applied state is durable even if the
864
- // continuation fails, and the UI reflects it immediately.
865
- await persist(status);
866
- }
867
- }
868
-
869
- // Phase 5c: Install the HITL approval gate BEFORE resolving the agent.
870
- //
871
- // The gate's runtime artifacts (hook script, approval-state file, denial
872
- // ledger) live in the session HITL directory OUTSIDE the workspace; only a
873
- // minimal, merged, transient .cursor/hooks.json is written into the repo,
874
- // pointing at the hook script by absolute path. The hook is scoped to this
875
- // runner's own process so the user's interactive IDE — sharing the same repo
876
- // hooks.json — is never gated (issue #173). Installing here (rather than
877
- // after agent create/resume) guarantees the hook is present no matter when
878
- // the SDK reads hook config, and the finally restores the repo afterward.
879
- //
880
- // On reinvocation, turn the user's approvals into tool-identity grants so
881
- // the resumed agent's re-attempt (which carries a fresh tool-call id) is
882
- // allowed through. Exact-applied writes are EXCLUDED from the grants: with no
883
- // grant, a further write to that file is re-gated (the user sees every change).
884
- // Capture mode: pin the pre-turn baseline tree before the agent runs (and
885
- // before the gate is installed, though the gate files are excluded from the
886
- // capture anyway). The turn-end capture diffs the post-turn tree against this
887
- // to build the per-file cards; the baseline ref is also what a reject reverts
888
- // to on resume. Covers a fresh turn and the approved-irreversible resume
889
- // fall-through (the agent will run and may make further edits).
890
- if (captureMode && primaryWorkspaceDir) {
891
- // Pin the pre-turn tree AND author BASELINE_CAPTURED so the projection can
892
- // materialize the change set (status CAPTURING) before any candidate exists.
893
- // The event rides the next persist; CAPTURING does not arm the unified gate.
894
- baselineTree = await captureBaselineToLedger({
895
- status,
896
- gitRoot: primaryWorkspaceDir,
897
- executionId,
898
- changeSetId,
899
- gitWorkspace,
900
- });
901
- }
902
-
903
- hitlDir = await ensureHitlDir(sessionId);
904
- const grantApprovals = excludeAppliedFromGrants(adjudicatedApprovals, appliedToolCallIds);
905
- const approvalGrants = approvalDecisions
906
- ? buildApprovalGrants(grantApprovals, approvalDecisions, adjudicatedContentDigests)
907
- : undefined;
908
- if (approvalGrants && approvalGrants.length > 0 && !globalBypass) {
909
- emitCursorGrantReceipts(
910
- approvalGrants,
911
- deriveExecutionFingerprintKey(getRunnerHitlMasterSecret(), executionId),
912
- executionId,
913
- );
914
- }
915
- // CAS capture requires artifact storage to persist blobs
916
- // (captureCandidateToLedger throws without it). In a git tree, captureMode
917
- // alone governs tracked-file capture (no storage needed) and captureIgnored is
918
- // the narrower switch (git tree + storage) that also captures gitignored
919
- // writes. In a non-git workspace ALL capture is CAS, so captureMode already
920
- // required storage — captureIgnored then equals captureMode. When storage is
921
- // absent a git tree keeps gating gitignored writes and a non-git workspace
922
- // falls back to the deny-gate entirely (no regression).
923
- const captureIgnored = captureMode && !!artifactStorage;
924
- // Unattended approval mode (DD-014): approver-less surfaces (channels,
925
- // guest shares) stamp APPROVAL_MODE_UNATTENDED; the hook then records
926
- // approval denials with the non-pausing "unattended" kind, so the
927
- // first-denial stop never fires and the turn boundary settles the denied
928
- // calls as SKIPPED instead of pausing a turn nobody can approve.
929
- const approvalState = buildApprovalState(
930
- mergedPolicies,
931
- globalBypass,
932
- leases.categories,
933
- approvalGrants,
934
- captureMode,
935
- captureIgnored,
936
- gitWorkspace,
937
- isUnattendedApprovalMode(execution),
938
- // The enabled_tools capability manifest (issue #350): restricted
939
- // servers' allow-lists, enforced by the hook's "disabled" arm ahead of
940
- // every approval bypass. The Cursor SDK config cannot hide a server's
941
- // tools, so this deny-at-call is the harness's enforcement.
942
- enabledToolsBySlug(resolvedMcpServers),
943
- );
944
- const hitlGate = await installHitlGate({
945
- workspaceRoot: primaryWorkspaceDir,
946
- hitlDir,
947
- approvalState,
948
- runnerPid: process.pid,
949
- });
950
- hitlCleanup = async () => {
951
- await removeHitlGate(hitlGate);
952
- await removeStigmerSymlink(primaryWorkspaceDir);
953
- };
954
- // Issue #205 diagnosability: the merge preserved the user's own hooks on
955
- // the gating events, and Cursor runs every configured hook — so any of
956
- // these can deny this turn's tools without writing our denial ledger. Log
957
- // the exposure up front; the turn boundary uses the same list to name the
958
- // likely culprit if it detects an unattributed hook block.
959
- if (hitlGate.foreignGatingHooks.length > 0) {
960
- console.warn(
961
- `ExecuteCursor: workspace hooks.json carries ${hitlGate.foreignGatingHooks.length} ` +
962
- `foreign gating hook(s) [${hitlGate.foreignGatingHooks.join(", ")}] — a deny from ` +
963
- `any of them blocks the runner's tools outside Stigmer's approval flow ` +
964
- `(execution=${executionId})`,
965
- );
966
- }
967
- // Arm the denial watcher as soon as the gate exists. The per-turn ledger
968
- // reset may flip the flag once before the run starts; the loop's read then
969
- // sees an empty ledger and clears it — harmless by construction.
970
- stopDenialWatcher = watchDenialLedger(hitlDir, () => {
971
- turnState.denialLedgerDirty = true;
972
- });
973
- setupTiming.mark("install_hitl_gate");
974
-
975
- // Mid-run live capture (DD-32 / DD-33): choose the progress substrate for this
976
- // turn's workspace shape ONCE (git / non-git CAS / hybrid). It owns its own
977
- // short-circuit cache across the loop's persists; the floor lives in
978
- // progressState. Undefined outside capture mode — writes are deny-gated and
979
- // nothing is captured.
980
- const progressSubstrate: ProgressSubstrate | undefined = buildCursorProgressSubstrate({
981
- captureMode,
982
- gitWorkspace,
983
- workspaceRoot: primaryWorkspaceDir,
984
- baselineTree,
985
- executionId,
986
- hitlDir,
987
- storage: artifactStorage,
988
- });
989
-
990
- // Phase 5d: Ensure model pricing registry is populated before validation
991
- await ensurePricingLoaded();
992
- setupTiming.mark("load_pricing");
993
-
994
- // Phase 6: Validate model selection and resolve the variant attributes.
995
- // UNSPECIFIED → STANDARD (#357) and UNSPECIFIED → DISABLED (#772)
996
- // resolve here and nowhere else: every upstream layer preserves the
997
- // caller's raw enum values.
998
- const requestedModel = spec.executionConfig?.modelName || "default";
999
- const validatedModel = resolveModelId(requestedModel);
1000
- if (validatedModel !== requestedModel) {
1001
- console.log(
1002
- `ExecuteCursor model resolved: execution=${executionId}, requested="${requestedModel}", using="${validatedModel}"`,
1003
- );
1004
- }
1005
- const requestedServiceTier = resolveEffectiveServiceTier(spec.executionConfig?.serviceTier);
1006
- const requestedThinkingMode = resolveEffectiveThinkingMode(spec.executionConfig?.thinkingMode);
1007
-
1008
- heartbeat();
1009
-
1010
- // Phase 7: Resolve Cursor Agent (create, resume, or graceful fallback)
1011
- await reportSetupProgress(client, executionId, "Initializing Cursor agent");
1012
-
1013
- // In proxy mode, use the stigmer token as the API key — the proxy
1014
- // validates it and injects the real Cursor API key server-side.
1015
- // In direct mode, use the user's own CURSOR_API_KEY.
1016
- const effectiveApiKey = config.proxyEndpoint
1017
- ? (config.stigmerTokenRef?.current ?? config.stigmerToken ?? config.cursorApiKey)
1018
- : config.cursorApiKey;
1019
-
1020
- if (!effectiveApiKey || effectiveApiKey === "proxy-managed") {
1021
- const source = config.proxyEndpoint ? "proxy (STIGMER_TOKEN)" : "direct (CURSOR_API_KEY)";
1022
- throw new Error(
1023
- `No Cursor API credential available. Mode=${source}, ` +
1024
- `proxyEndpoint=${config.proxyEndpoint ?? "unset"}, ` +
1025
- `hasStigmerToken=${!!config.stigmerToken}, ` +
1026
- `hasTokenRef=${!!config.stigmerTokenRef?.current}`,
1027
- );
1028
- }
1029
-
1030
- // Register blueprint sub-agents with the Cursor SDK so the parent can
1031
- // delegate to them by name via the Task tool. Re-supplied on every
1032
- // create/resume (the SDK does not persist agent config across resume).
1033
- const cursorSubAgents = buildCursorSubAgentDefinitions(blueprint.subAgents);
1034
- if (cursorSubAgents) {
1035
- console.log(
1036
- `ExecuteCursor registering ${Object.keys(cursorSubAgents).length} custom sub-agent(s): ` +
1037
- `execution=${executionId}, names=${Object.keys(cursorSubAgents).join(", ")}`,
1038
- );
1039
- }
1040
-
1041
- // Translate the tier + thinking mode into the explicit variant params
1042
- // sent with every create/resume. Never a bare { id }: the catalog's
1043
- // default variant is account-influenced and picks the served variant
1044
- // (#357 fast pricing, #772 thinking).
1045
- const modelParams = await resolveServiceTierParams({
1046
- apiKey: effectiveApiKey,
1047
- modelId: validatedModel,
1048
- tier: requestedServiceTier,
1049
- thinking: requestedThinkingMode,
1050
- executionId,
1051
- });
1052
-
1053
- const createOptions: CreateAgentOptions | CreateCloudAgentOptions = agentMode === "cloud"
1054
- ? {
1055
- apiKey: effectiveApiKey,
1056
- model: validatedModel || undefined,
1057
- modelParams,
1058
- repos: blueprint.cloudRepos,
1059
- sessionId,
1060
- mcpServers: mcpConfig,
1061
- agents: cursorSubAgents,
1062
- }
1063
- : {
1064
- apiKey: effectiveApiKey,
1065
- model: validatedModel,
1066
- modelParams,
1067
- workspaceDirs: blueprint.workspaceDirs,
1068
- sessionId,
1069
- workspaceRootDir: config.workspaceRootDir,
1070
- mcpServers: mcpConfig,
1071
- agents: cursorSubAgents,
1072
- };
1073
-
1074
- // Agent.create/Agent.resume have no timeout of their own — a degraded
1075
- // transport (dead proxy connection, stale HTTP/2 session) hangs them
1076
- // forever, which the periodic heartbeat would happily keep alive. Each
1077
- // attempt is bounded; on expiry the wrapper resets the proxy transport
1078
- // and retries once, so a stale-session hang recovers without failing the
1079
- // execution. A second expiry propagates a plain Error to the generic
1080
- // catch below, which persists EXECUTION_FAILED (no Temporal retry —
1081
- // the activity returns rather than throws, and maximumAttempts is 1).
1082
- heartbeatPhase = "resolving_agent";
1083
- const resolveTimeoutSeconds = Math.round(config.agentResolveTimeoutMs / 1000);
1084
- // Close the span since load_pricing here so the resolve_agent segment
1085
- // below measures the SDK Agent.create/resume call alone, not the
1086
- // progress-report gRPC + options assembly above (issue #209: resolve_agent
1087
- // is the largest user-visible setup segment; this split keeps its
1088
- // historical meaning — the SDK call was already 98%+ of it).
1089
- setupTiming.mark("prepare_agent");
1090
-
1091
- // Phase 8a: Reuse the previous turn's agent when this session parked one
1092
- // (#215). A checkout hit skips Agent.resume() AND — the real win — keeps
1093
- // the SDK executor lease alive, so agent.send() below re-acquires the
1094
- // warm executor instead of re-spawning every stdio MCP server (the
1095
- // measured 2.2–3.2s `send_returned` tax). The fingerprint covers the
1096
- // full acquisition config, so any drift (rotated credential, edited MCP
1097
- // servers, model change) falls through to a fresh resolve.
1098
- const agentFingerprint = computeAgentFingerprint(
1099
- createOptions as unknown as Record<string, unknown>,
1100
- );
1101
- const parkedAgent = takeCachedAgent(sessionId, agentFingerprint, threadId ?? "");
1102
- let resolution: AgentResolution;
1103
- if (parkedAgent) {
1104
- console.log(
1105
- `ExecuteCursor reusing parked session agent: execution=${executionId}, ` +
1106
- `session=${sessionId}, agentId=${parkedAgent.agentId}`,
1107
- );
1108
- resolution = {
1109
- agent: parkedAgent as AgentResolution["agent"],
1110
- agentId: parkedAgent.agentId,
1111
- isNew: false,
1112
- resumed: true,
1113
- mode: agentMode,
1114
- // The parked handle IS the live conversation — every consumer of
1115
- // "resumed_successfully" (prompt selection, poisoned-handle
1116
- // recovery eligibility) wants exactly those semantics.
1117
- reason: "resumed_successfully",
1118
- };
1119
- } else {
1120
- resolution = await resolveAgentWithTransportRecovery({
1121
- harnessStateId: threadId,
1122
- createOptions,
1123
- mode: agentMode,
1124
- timeoutMs: config.agentResolveTimeoutMs,
1125
- buildTimeoutMessage: (finalAttempt) =>
1126
- `Cursor agent ${threadId ? "resume" : "create"} timed out after ${resolveTimeoutSeconds}s ` +
1127
- `(${config.proxyEndpoint ? `via proxy ${config.proxyEndpoint}` : "direct Cursor API connection"}). ` +
1128
- `The transport connection is likely dead. ` +
1129
- (finalAttempt
1130
- ? `An automatic retry on a fresh transport connection also timed out. ` +
1131
- `Retry the message later; if this persists, check proxy and network health.`
1132
- : `Resetting the transport and retrying automatically.`),
1133
- resetTransport: closeProxySessions,
1134
- });
1135
- }
1136
-
1137
- console.log(
1138
- `ExecuteCursor agent resolved: execution=${executionId}, ` +
1139
- `reason=${resolution.reason}, mode=${resolution.mode}, ` +
1140
- `agentId=${resolution.agentId}, resumed=${resolution.resumed}` +
1141
- (resolution.resumeFailureDetail ? `, failureDetail=${resolution.resumeFailureDetail}` : ""),
1142
- );
1143
- setupTiming.mark("resolve_agent");
1144
- emitTimingLog("execution_setup", {
1145
- execution_id: executionId,
1146
- session_id: sessionId,
1147
- harness: "cursor",
1148
- agent_resumed: resolution.resumed,
1149
- cursor_mode: agentMode,
1150
- mcp_server_count: blueprint.mergedMcpServerUsages.length,
1151
- skill_count: blueprint.mergedSkillRefs.length,
1152
- workspace_entry_count: session.spec?.workspaceEntries?.length ?? 0,
1153
- }, setupTiming);
1154
-
1155
- errorContext = { model: validatedModel, mode: agentMode, agentId: resolution.agentId };
1156
-
1157
- // (HITL approval gate already installed in Phase 5c, before agent resolution.)
1158
-
1159
- // Phase 9: Store new agentId as harness_state_id and persist cursor_mode
1160
- if (resolution.isNew && resolution.agentId) {
1161
- try {
1162
- blueprint.sessionSpec.harnessStateId = resolution.agentId;
1163
- if (blueprint.sessionSpec.cursorMode === CursorMode.UNSPECIFIED) {
1164
- blueprint.sessionSpec.cursorMode = cursorMode;
1165
- }
1166
- // Clear slug to avoid re-validation of potentially invalid
1167
- // server-generated slugs. BuildUpdateStateStep preserves the
1168
- // existing slug from the database record.
1169
- if (blueprint.session.metadata) {
1170
- blueprint.session.metadata.slug = "";
1171
- }
1172
- await client.updateSession(blueprint.session);
1173
- console.log(
1174
- `Stored Cursor agentId=${resolution.agentId} as harness_state_id, ` +
1175
- `cursorMode=${CursorMode[cursorMode]} on session ${sessionId}`,
1176
- );
1177
- } catch (err) {
1178
- console.warn("Failed to persist harness_state_id/cursorMode on session (non-fatal):", err);
1179
- }
1180
- }
1181
-
1182
- // Phase 9b: Detect structured output schema from execution config
1183
- const structuredOutputSchema = spec.executionConfig?.structuredOutputSchema as
1184
- Record<string, unknown> | undefined;
1185
-
1186
- // Phase 9c: Semantic memory selection (DD-008), memoized to at most one
1187
- // run per invocation. Deliberately NOT decided by the Phase-10
1188
- // resolution alone: a resumed-agent primary send carries no memories,
1189
- // but a mid-send poisoned-handle failure rebuilds on a FRESH agent
1190
- // whose recovery prompt does — the buildFromPlan drop-hazard class —
1191
- // so every memory-carrying build site awaits this lazily instead.
1192
- // Selection runs against the frozen first message's semantics: above
1193
- // the activation threshold it picks top-k for spec.message, otherwise
1194
- // (and on any failure) it injects the full candidate set — Phase 2
1195
- // behavior. The outcome report is stamped on the turn's status ONCE,
1196
- // picked up by the next persist; a re-invocation replays the report
1197
- // already persisted on the execution rather than re-selecting (the
1198
- // written-once rule).
1199
- let memorySelection: Promise<RecalledMemoriesContent | undefined> | undefined;
1200
- const selectMemoriesOnce = (): Promise<RecalledMemoriesContent | undefined> => {
1201
- memorySelection ??= selectRecalledFacts(spec.recalledMemories, spec.message, {
1202
- proxyEndpoint: config.proxyEndpoint,
1203
- stigmerToken: config.stigmerToken,
1204
- executionId,
1205
- priorReport: execution.status?.recalledMemoriesReport,
1206
- }).then((selection) => {
1207
- if (selection.report !== undefined) {
1208
- status.recalledMemoriesReport = selection.report;
1209
- }
1210
- return selection.content;
1211
- });
1212
- return memorySelection;
1213
- };
1214
- const recalledMemories = promptCarriesStandingContext(resolution.reason)
1215
- ? await selectMemoriesOnce()
1216
- : undefined;
1217
-
1218
- // Phase 10: Build the prompt
1219
- const interactionMode = spec.executionConfig?.interactionMode
1220
- ?? InteractionMode.UNSPECIFIED;
1221
- const buildFromPlan = spec.executionConfig?.buildFromPlan ?? false;
1222
-
1223
- const prompt = buildPrompt({
1224
- resolution,
1225
- approvalDecisions,
1226
- instructions: blueprint.instructions,
1227
- userMessage: spec.message,
1228
- skills: skillMetadata,
1229
- channelMessaging,
1230
- subAgents: blueprint.subAgents,
1231
- workspaceDirs: blueprint.workspaceDirs,
1232
- workspaceFileRefs: spec.workspaceFileRefs ?? [],
1233
- attachments: attachmentEntries,
1234
- vision: visionPromptInfo,
1235
- downloadUrlKind: artifactStorage?.downloadUrlKind,
1236
- pendingApprovals: adjudicatedApprovals,
1237
- appliedToolCallIds,
1238
- interactionMode,
1239
- buildFromPlan,
1240
- contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1241
- senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1242
- sessionContext: readSessionContext(blueprint.sessionSpec.metadata),
1243
- declaredPreferences: readDeclaredPreferences(spec.declaredPreferences),
1244
- recalledMemories,
1245
- conversationCatchup: readConversationCatchup(spec.conversationCatchup),
1246
- // The turn's recorded transcript, seeded from the persisted execution
1247
- // on a reinvocation (Phase 3). Consumed only by the HITL-recovery
1248
- // shape — reached from HERE when the stored handle failed to resume
1249
- // at resolution time (issue #366 crossing 2).
1250
- turnRecoveryDigest: isReinvocation
1251
- ? composeTurnRecoveryDigest(status.messages)
1252
- : undefined,
1253
- });
1254
-
1255
- // Phase 10a: Inject the structured output instruction for the Cursor
1256
- // harness. A per-turn directive, so like buildFromPlan it must ride every
1257
- // prompt this turn sends — the primary AND the poisoned-handle recovery
1258
- // rebuild (the transport retry re-sends effectivePrompt and inherits it).
1259
- const withStructuredOutputDirective = (basePrompt: string): string =>
1260
- appendStructuredOutputDirective(basePrompt, structuredOutputSchema);
1261
- const effectivePrompt = withStructuredOutputDirective(prompt);
1262
-
1263
- // Phase 10a1: The turn's vision payload. The invariant is "images
1264
- // accompany the user's turn message, wherever the conversation does not
1265
- // already hold them" (primarySendCarriesImages): the ONLY send that
1266
- // skips them is a HITL re-invocation of a successfully RESUMED agent,
1267
- // whose native conversation carries the images from the original send.
1268
- // Every send that starts an empty conversation re-delivers them — the
1269
- // ordinary first/fresh-agent primary send, the HITL primary send after a
1270
- // resolution-time resume failure, and both mid-send recovery retries
1271
- // (which always run on a fresh agent, so their sites pass turnImages
1272
- // unconditionally). Attachments re-resolve on every invocation
1273
- // (Phase 5b), so the bytes are in hand even on a re-invocation.
1274
- const turnImages = toCursorImages(visionImages);
1275
- const primarySendImages = primarySendCarriesImages(approvalDecisions, resolution.reason)
1276
- ? turnImages
1277
- : [];
1278
- const toSendMessage = (
1279
- sendPrompt: string,
1280
- images: { data: string; mimeType: string }[],
1281
- ): string | SDKUserMessage =>
1282
- images.length > 0 ? { text: sendPrompt, images } : sendPrompt;
1283
-
1284
- // Phase 10a2: Log Stigmer preamble size for context trimming diagnostics
1285
- const promptChars = effectivePrompt.length;
1286
- const promptEstimatedTokens = Math.ceil(promptChars / 4);
1287
- console.log(
1288
- `ExecuteCursor prompt built: execution=${executionId}, ` +
1289
- `chars=${promptChars}, estimatedTokens=${promptEstimatedTokens}, ` +
1290
- `resolution=${resolution.reason}, mode=${resolution.mode}`,
1291
- );
1292
-
1293
- // Phase 10b: Initialize usage accumulator for runner-side token tracking
1294
- await ensurePricingLoaded();
1295
- const usageAccumulator = new UsageAccumulator(
1296
- validatedModel,
1297
- requestedServiceTier,
1298
- modelParams,
1299
- requestedThinkingMode,
1300
- );
1301
-
1302
- // Phase 10c: Start OTel turn span. Coarse-grained — spans the whole turn
1303
- // (agent.send + stream + any recovery retry + the turn boundary), ended once
1304
- // from the finally via finishTurnTelemetry with the final token snapshot.
1305
- const { startCursorTurnSpan } = await import("../../otel.js");
1306
- const turnSpan = await startCursorTurnSpan({
1307
- model: validatedModel,
1308
- mode: agentMode,
1309
- sessionId: sessionId ?? "",
1310
- });
1311
- // Bind the telemetry-finish closure now that the span + usage accumulator
1312
- // exist. Reads usageAccumulator at CALL time (in the finally), so it captures
1313
- // tokens from any recovery retry that ran after the primary stream. Guarded
1314
- // so a second call (finally after an inline path already finished it) is a
1315
- // no-op. Metrics failures are swallowed — OTel is optional.
1316
- let turnTelemetryFinished = false;
1317
- finishTurnTelemetry = async () => {
1318
- if (turnTelemetryFinished) return;
1319
- turnTelemetryFinished = true;
1320
- const usage = usageAccumulator.snapshot();
1321
- turnSpan.setTokens(Number(usage.inputTokens), Number(usage.outputTokens));
1322
- turnSpan.end();
1323
- try {
1324
- const { recordTurnMetrics } = await import("../../otel.js");
1325
- const durationMs =
1326
- Date.now() - (status.startedAt ? new Date(status.startedAt).getTime() : Date.now());
1327
- await recordTurnMetrics({
1328
- durationMs,
1329
- inputTokens: Number(usage.inputTokens),
1330
- outputTokens: Number(usage.outputTokens),
1331
- model: validatedModel,
1332
- mode: agentMode,
1333
- });
1334
- } catch {
1335
- // Metrics not initialized — silently skip.
1336
- }
1337
- };
1338
-
1339
- // Phase 11: Send message and stream events
1340
- status.phase = ExecutionPhase.EXECUTION_IN_PROGRESS;
1341
-
1342
- const deltaEnricher = new DeltaEnricher();
1343
- const todoTracker = new TodoTracker(status.todos);
1344
- const eventRecorder = createCursorEventRecorder(executionId);
1345
-
1346
- // The two recovery retries (poisoned-handle / transport-timeout) below run at
1347
- // most once per turn; this guard is the latch.
1348
- let alreadyRetriedWithFreshAgent = false;
1349
-
1350
- // The shared onDelta only needs the usage/enricher/heartbeat/state subset,
1351
- // and it is wired at SEND time — before the accumulator exists — so it takes
1352
- // the narrow deps. The primary send and both retry sends reuse this object.
1353
- const maxCostUsd = spec.executionConfig?.maxCostUsd ?? 0;
1354
- const onDeltaDeps: TurnOnDeltaDeps = {
1355
- usageAccumulator,
1356
- deltaEnricher,
1357
- heartbeat,
1358
- promptEstimatedTokens,
1359
- executionId,
1360
- state: turnState,
1361
- maxCostUsd,
1362
- };
1363
-
1364
- // The activity-wide periodic heartbeat (started at entry) keeps Temporal
1365
- // informed during silent SDK operations (long tool calls, MCP requests,
1366
- // model thinking); relabel it for the streaming phase.
1367
- heartbeatPhase = "cursor_streaming";
1368
-
1369
- // The Cursor SDK registers abort listeners on the cancellation signal for
1370
- // each concurrent tool call (fetch, MCP, shell). With 10+ parallel tools,
1371
- // Node's default limit of 10 triggers MaxListenersExceededWarning. This is
1372
- // a diagnostic warning, not a functional error — reproduction tests confirm
1373
- // zero tool call loss — but it pollutes logs and creates false alarm fatigue.
1374
- // Raise the limit on the Temporal cancellation signal used throughout this
1375
- // activity. 25 covers observed peaks (~12 concurrent tools + heartbeat +
1376
- // shutdown signal + SDK internals) with headroom.
1377
- try {
1378
- setMaxListeners(25, Context.current().cancellationSignal);
1379
- } catch {
1380
- // Fallback: if the Temporal signal doesn't support setMaxListeners
1381
- // (e.g. older SDK), the warning is harmless — ignore.
1382
- }
1383
-
1384
- // Issue #209 forensics: the SDK acquires the local executor — the piece
1385
- // that actually spawns stdio MCP servers — inside send(), AFTER the
1386
- // execution_setup timeline above has already been emitted. This one-shot
1387
- // timeline makes that previously invisible window measurable:
1388
- // `send_returned` covers the send() call itself, `first_delta` the wait
1389
- // until the SDK's first delta. Primary send only — the recovery retries
1390
- // below rebuild the agent and would skew the user-perceived turn start
1391
- // this measures. No delta (immediate pause/failure) → no line.
1392
- const turnStartTiming = new TimingRecorder();
1393
- let turnFirstEventEmitted = false;
1394
- const primaryOnDelta = makeCursorTurnOnDelta(onDeltaDeps);
1395
-
1396
- // The stall watchdog is armed inside consumeCursorTurnStream (it needs the
1397
- // run to cancel), stored on turnState.stallWatchdog so this shared onDelta can
1398
- // reset it and the activity's finally can stop it as a backstop.
1399
- const run = await resolution.agent.send(toSendMessage(effectivePrompt, primarySendImages), {
1400
- onDelta: (event) => {
1401
- if (!turnFirstEventEmitted) {
1402
- turnFirstEventEmitted = true;
1403
- turnStartTiming.mark("first_delta");
1404
- emitTimingLog("turn_first_event", {
1405
- execution_id: executionId,
1406
- session_id: sessionId,
1407
- harness: "cursor",
1408
- agent_resumed: resolution.resumed,
1409
- mcp_server_count: blueprint.mergedMcpServerUsages.length,
1410
- }, turnStartTiming);
1411
- }
1412
- primaryOnDelta(event);
1413
- },
1414
- });
1415
- // Normally send() resolves before any delta arrives, making send_returned
1416
- // the first segment; if a delta beat it, the line is already emitted and
1417
- // adding a mark now would be meaningless.
1418
- if (!turnFirstEventEmitted) {
1419
- turnStartTiming.mark("send_returned");
1420
- }
1421
-
1422
- // Everything at an index >= this was produced by THIS turn's stream — the
1423
- // positional turn boundary the approved-command provenance (DD-28) scopes
1424
- // its qualification to. Snapshotted before the accumulator can append.
1425
- const turnStartMessageIndex = status.messages.length;
1426
-
1427
- const accumulator = new MessageAccumulator(status.messages, {
1428
- mergedPolicies,
1429
- provenance: { globalBypass, leasedCategories: leases.categories },
1430
- workspaceRoot: primaryWorkspaceDir,
1431
- seededSubAgents,
1432
- });
1433
- // Shared cadence with the native harness: discrete state changes force a
1434
- // flush; high-frequency token deltas ride this scheduler's time cadence
1435
- // (env-tunable via STREAMING_* — see loadStreamingConfig).
1436
- const scheduler = new StreamingUpdateScheduler(loadStreamingConfig());
1437
-
1438
- // Full deps for the shared stream loop — the collaborators + the injected
1439
- // heartbeat/cancellation (so the loop is testable, mirroring the deep-agent
1440
- // streamExecution seam), all keyed off the single turnState. Consumed by the
1441
- // primary stream here and by both recovery retries below.
1442
- const streamDeps: CursorTurnStreamDeps = {
1443
- ...onDeltaDeps,
1444
- status,
1445
- accumulator,
1446
- todoTracker,
1447
- eventRecorder,
1448
- scheduler,
1449
- progressSubstrate,
1450
- progressState,
1451
- changeSetId,
1452
- hitlDir,
1453
- stallTimeoutMs: config.cursorStreamStallTimeoutMs,
1454
- persist,
1455
- isCancelled: () => Context.current().cancellationSignal.aborted,
1456
- };
1457
-
1458
- // Primary stream. consumeCursorTurnStream owns the per-event loop (transcript,
1459
- // todos, sub-agent tracking, live persist, DD-32/DD-33 mid-run progress, the
1460
- // first-denial early stop, and the stall watchdog) and reports why it ended;
1461
- // resolvePreBoundaryTerminal below maps that to a terminal outcome. The two
1462
- // recovery retries drive the identical loop, so they inherit every one of
1463
- // these behaviors instead of the old bare loop that dropped them.
1464
- await consumeCursorTurnStream(run, streamDeps);
1465
-
1466
- periodicHeartbeat.stop();
1467
- // Worker-shutdown vs. user-pause disambiguation. Primary-only: the periodic
1468
- // heartbeat is stopped here, before any recovery retry runs, so a retry
1469
- // classifies a shutdown from the shutdown signal directly (in
1470
- // resolvePreBoundaryTerminal). The heartbeat timer may set `cancelled` before
1471
- // the AbortSignal microtask propagates; the direct signal check catches that.
1472
- // The decision table (including #776's grace-window guard: an aborted
1473
- // shutdown signal with NO interruption evidence stays "none") lives in
1474
- // classifyTurnInterruption — shared/worker-shutdown.ts.
1475
- const interruption = classifyTurnInterruption({
1476
- heartbeatCancelled: periodicHeartbeat.cancelled,
1477
- heartbeatWorkerShutdown: periodicHeartbeat.workerShutdown,
1478
- cancellationSignalAborted: Context.current().cancellationSignal.aborted,
1479
- shutdownSignalAborted: shutdownSignal?.aborted ?? false,
1480
- });
1481
- if (interruption === "worker-shutdown") {
1482
- turnState.pauseDetected = false;
1483
- } else if (interruption === "pause") {
1484
- turnState.pauseDetected = true;
1485
- }
1486
- workerShutdownDetected = interruption === "worker-shutdown";
1487
-
1488
- // Post-stream finalize, shared by the primary turn and both recovery retries:
1489
- // finalize the transcript + streaming flags, mark any in-flight sub-agent
1490
- // CANCELLED on an aborted turn, snapshot usage, flush the recorder, and
1491
- // persist so the UI sees the settled rows. The unified loop applies delta
1492
- // enrichments per-iteration, so — unlike the old bare retry path — no
1493
- // compensating applyEnrichments() is needed here.
1494
- const finalizeStreamPhase = async () => {
1495
- accumulator.finalize();
1496
- deltaEnricher.finalize(status.messages);
1497
- // A pause / cancel / worker shutdown aborts the Cursor SDK run, so any
1498
- // sub-agent the parent had delegated is no longer executing. Mark it
1499
- // CANCELLED rather than leaving a permanent IN_PROGRESS "zombie" in the
1500
- // final snapshot (parity with the native harness's cancelSubAgents()).
1501
- if (
1502
- turnState.pauseDetected ||
1503
- workerShutdownDetected ||
1504
- turnState.stallDetected ||
1505
- turnState.costCapExceeded ||
1506
- Context.current().cancellationSignal.aborted
1507
- ) {
1508
- accumulator.cancelInProgressSubAgents();
1509
- }
1510
- status.subAgentExecutions = accumulator.subAgentExecutions;
1511
- await eventRecorder?.flush();
1512
- if (usageAccumulator.hasTurns) {
1513
- status.streamingUsage = create(StreamingUsageSummarySchema, usageAccumulator.snapshot());
1514
- }
1515
- console.log(
1516
- `ExecuteCursor stream ended: execution=${executionId}, events=${turnState.eventCount}, messages=${status.messages.length}, subAgents=${status.subAgentExecutions.length}`,
1517
- );
1518
- // Persist immediately after finalize so the UI sees correct tool-call
1519
- // statuses before the boundary / run.wait() / structured-output extraction.
1520
- await persist(status);
1521
- heartbeat();
1522
- };
1523
-
1524
- // Pre-boundary terminal handling, shared by the primary turn and both retries
1525
- // so a retry that stalls, pauses, is cancelled, or is platform-stopped is
1526
- // mapped IDENTICALLY to the primary — the fix for the mid-retry pause that
1527
- // used to surface as EXECUTION_FAILED. "proceed" (a normal completion or a
1528
- // first denial) goes on to the turn boundary; a stall / platform-stop asks
1529
- // the caller to RETURN a terminal status; a worker-shutdown / pause /
1530
- // infra-cancel asks the caller to THROW CancelledFailure. The OTel turn span
1531
- // + metrics are ended once from the finally (finishTurnTelemetry), so they
1532
- // include any recovery retry and never leak on these exits.
1533
- type PreBoundaryTerminal =
1534
- | { kind: "proceed" }
1535
- | { kind: "return" }
1536
- | { kind: "throw"; message: string };
1537
- const resolvePreBoundaryTerminal = async (): Promise<PreBoundaryTerminal> => {
1538
- // Stall: the watchdog cancelled a turn that made no progress. RETURN (not
1539
- // throw): re-running the identical prompt via Temporal retry would very
1540
- // likely wedge again.
1541
- if (turnState.stallDetected) {
1542
- const err = turnState.stallError ?? new StallTimeoutError(config.cursorStreamStallTimeoutMs);
1543
- status.phase = ExecutionPhase.EXECUTION_FAILED;
1544
- status.error = formatStallFailure(err);
1545
- status.completedAt = utcTimestamp();
1546
- status.messages.push(create(AgentMessageSchema, {
1547
- type: MessageType.MESSAGE_SYSTEM,
1548
- content: `Execution failed: the agent made no progress for too long and was stopped (${err.message}). You can retry or resume.`,
1549
- timestamp: utcTimestamp(),
1550
- }));
1551
- await persist(status);
1552
- console.warn(`ExecuteCursor stalled: execution=${executionId}, events=${turnState.eventCount}, error=${status.error}`);
1553
- return { kind: "return" };
1554
- }
1555
-
1556
- // Cost cap (cost-guard.ts): onDelta flagged the overrun and the loop
1557
- // cancelled the run. EXECUTION_TERMINATED, not FAILED — the platform
1558
- // deliberately stopped the run, work is checkpointed, and the
1559
- // conversation continues on the next message (the recursion-limit
1560
- // precedent in execute-deep-agent/streaming-terminal.ts). RETURN (not
1561
- // throw): a Temporal retry would re-run the identical prompt and burn
1562
- // the same budget again.
1563
- if (turnState.costCapExceeded) {
1564
- const estimated = usageAccumulator.snapshot().estimatedCostUsd;
1565
- status.phase = ExecutionPhase.EXECUTION_TERMINATED;
1566
- status.error = formatCostLimitError(maxCostUsd, estimated);
1567
- status.completedAt = utcTimestamp();
1568
- status.messages.push(create(AgentMessageSchema, {
1569
- type: MessageType.MESSAGE_SYSTEM,
1570
- content: COST_LIMIT_USER_COPY,
1571
- timestamp: utcTimestamp(),
1572
- }));
1573
- await persist(status);
1574
- // Clean terminal: the conversation continues on the next message,
1575
- // so park the healthy agent for that turn (#215).
1576
- cacheSessionAgent(sessionId ?? "", resolution.agent, agentFingerprint);
1577
- console.warn(
1578
- `ExecuteCursor terminated (cost cap): execution=${executionId}, ` +
1579
- `estimatedCostUsd=${estimated.toFixed(4)}, maxCostUsd=${maxCostUsd.toFixed(2)}`,
1580
- );
1581
- return { kind: "return" };
1582
- }
1583
-
1584
- // Worker shutdown: the runner/manager aborted the shutdown signal. NOT a
1585
- // user pause. Checked via the shutdown signal directly so a retry (whose
1586
- // periodic heartbeat is already stopped) still classifies it correctly —
1587
- // but only alongside a delivered cancellation: a retry that completed
1588
- // normally inside the drain grace window must stay a completion (#776's
1589
- // grace-window guard, mirroring the primary's `interrupted` gate).
1590
- if (
1591
- workerShutdownDetected ||
1592
- ((shutdownSignal?.aborted ?? false) && Context.current().cancellationSignal.aborted)
1593
- ) {
1594
- status.phase = ExecutionPhase.EXECUTION_FAILED;
1595
- status.error = "Execution interrupted: runner worker was shut down. Retry or resume.";
1596
- status.completedAt = utcTimestamp();
1597
- status.messages.push(create(AgentMessageSchema, {
1598
- type: MessageType.MESSAGE_SYSTEM,
1599
- content: "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
1600
- timestamp: utcTimestamp(),
1601
- }));
1602
- await persist(status);
1603
- console.log(`ExecuteCursor interrupted (worker shutdown): execution=${executionId}, events=${turnState.eventCount}`);
1604
- return { kind: "throw", message: "Activity cancelled (worker shutdown, not user pause)" };
1605
- }
1606
-
1607
- // pauseDetected is only true if a heartbeat() call threw CancelledFailure,
1608
- // confirming the orchestrator explicitly requested a pause.
1609
- if (turnState.pauseDetected) {
1610
- status.phase = ExecutionPhase.EXECUTION_PAUSED;
1611
- status.messages.push(create(AgentMessageSchema, {
1612
- type: MessageType.MESSAGE_SYSTEM,
1613
- content: "Execution paused by user. Use resume to continue.",
1614
- timestamp: utcTimestamp(),
1615
- }));
1616
- await persist(status);
1617
- console.log(`ExecuteCursor paused: execution=${executionId}, events=${turnState.eventCount}`);
1618
- return { kind: "throw", message: "Activity paused by orchestrator" };
1619
- }
1620
-
1621
- // Cancellation without pauseDetected (e.g. heartbeat timeout): report as
1622
- // failed rather than misleadingly labeling it a user pause.
1623
- if (Context.current().cancellationSignal.aborted) {
1624
- status.phase = ExecutionPhase.EXECUTION_FAILED;
1625
- status.error = "Execution interrupted: agent was unresponsive (heartbeat timeout). Retry or resume.";
1626
- status.completedAt = utcTimestamp();
1627
- status.messages.push(create(AgentMessageSchema, {
1628
- type: MessageType.MESSAGE_SYSTEM,
1629
- content: "Execution interrupted: the agent was unresponsive for too long. You can retry or resume.",
1630
- timestamp: utcTimestamp(),
1631
- }));
1632
- await persist(status);
1633
- console.log(`ExecuteCursor interrupted (infrastructure cancel): execution=${executionId}, events=${turnState.eventCount}`);
1634
- return { kind: "throw", message: "Activity cancelled (heartbeat timeout, not user pause)" };
1635
- }
1636
-
1637
- // Platform stop signal: a clean COMPLETED early exit.
1638
- if (turnState.platformStopSignaled) {
1639
- status.phase = ExecutionPhase.EXECUTION_COMPLETED;
1640
- status.completedAt = utcTimestamp();
1641
- status.messages.push(create(AgentMessageSchema, {
1642
- type: MessageType.MESSAGE_SYSTEM,
1643
- content: "Execution stopped by the platform.",
1644
- timestamp: utcTimestamp(),
1645
- }));
1646
- await persist(status);
1647
- // Clean terminal — park for the session's next turn (#215).
1648
- cacheSessionAgent(sessionId ?? "", resolution.agent, agentFingerprint);
1649
- console.log(`ExecuteCursor completed (platform stop): execution=${executionId}`);
1650
- return { kind: "return" };
1651
- }
1652
-
1653
- return { kind: "proceed" };
1654
- };
1655
-
1656
- await finalizeStreamPhase();
1657
- const primaryTerminal = await resolvePreBoundaryTerminal();
1658
- if (primaryTerminal.kind === "return") return slimStatus(status);
1659
- if (primaryTerminal.kind === "throw") throw new CancelledFailure(primaryTerminal.message);
1660
-
1661
- // Phase 12: The turn boundary — author this turn's change set to the
1662
- // file_review ledger (CANDIDATE_CAPTURED) and overlay the hook's denials as
1663
- // WAITING_APPROVAL gate rows. The full pipeline and its ordering rationale
1664
- // live in turn-boundary.ts; this closure binds the turn's state so the
1665
- // recovery retries below (which re-run the agent AFTER this primary call)
1666
- // can re-enter the IDENTICAL pipeline — a retry's edits must reach the
1667
- // ledger or they silently escape review. `baselineTree` is read at call
1668
- // time, so both entries see the baseline authored at turn start.
1669
- const runBoundary = (denialSettled?: Promise<void>) =>
1670
- runTurnBoundary({
1671
- status,
1672
- executionId,
1673
- changeSetId,
1674
- hitlDir,
1675
- captureMode,
1676
- baselineTree,
1677
- primaryWorkspaceDir,
1678
- gitWorkspace,
1679
- turnStartMessageIndex,
1680
- approvalGrants,
1681
- globalBypass,
1682
- seededSubAgents,
1683
- artifactStorage,
1684
- mergedPolicies,
1685
- denialCancelSettled: denialSettled,
1686
- foreignGatingHooks: hitlGate.foreignGatingHooks,
1687
- });
1688
- // Pauses for review exactly like the native harness: the boundary mutated
1689
- // the transcript in place; we flip the phase, persist, and RETURN to the
1690
- // workflow, which waits for the approval/file-review signal and reinvokes.
1691
- const enterApprovalPause = async (boundary: TurnBoundaryResult) => {
1692
- status.phase = ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL;
1693
- await persist(status);
1694
- // The approval-resume reinvocation is the cache's best case: park the
1695
- // agent so the resumed turn skips the full executor rebuild (#215).
1696
- // (This path previously dropped the handle without close() — the
1697
- // lease leaked; parking makes the lifetime explicit.) An absent
1698
- // sessionId falls back to "" — the cache closes the lease immediately.
1699
- cacheSessionAgent(sessionId ?? "", resolution.agent, agentFingerprint);
1700
- console.log(
1701
- `ExecuteCursor returning WAITING_FOR_APPROVAL: ${boundary.deniedToolCallCount} gated tool(s), ` +
1702
- `${boundary.capturedChangeCount} file card(s) pending`,
1703
- );
1704
- return slimStatus(status);
1705
- };
1706
-
1707
- // Issue #205: a tool was blocked by a hook Stigmer does not own (the merge
1708
- // preserves the user's own gating hooks, and Cursor runs every one), so no
1709
- // approval can unblock it — an approval grants a token only OUR hook reads,
1710
- // and the foreign hook would deny the re-attempt forever. Completing would
1711
- // be the silent-failure shape the issue describes; instead fail with a
1712
- // diagnosable reason naming the blocked tools and the likely culprit.
1713
- // Shared by the primary turn and both recovery retries.
1714
- const enterUnattributedHookBlockFailure = async (boundary: TurnBoundaryResult) => {
1715
- const blockedTools = [...new Set(boundary.unattributedHookBlocks.map((b) => b.toolName))]
1716
- .join(", ");
1717
- const culprit = hitlGate.foreignGatingHooks.length > 0
1718
- ? ` The workspace's .cursor/hooks.json registers hook(s) outside Stigmer's control ` +
1719
- `[${hitlGate.foreignGatingHooks.join(", ")}], which most likely denied it.`
1720
- : "";
1721
- status.phase = ExecutionPhase.EXECUTION_FAILED;
1722
- status.error =
1723
- `A Cursor hook outside Stigmer's approval gate blocked tool(s): ${blockedTools}.` +
1724
- culprit +
1725
- ` Stigmer cannot request approval on a foreign hook's behalf — remove or adjust ` +
1726
- `the hook in .cursor/hooks.json and retry.`;
1727
- status.completedAt = utcTimestamp();
1728
- status.messages.push(create(AgentMessageSchema, {
1729
- type: MessageType.MESSAGE_SYSTEM,
1730
- content: `Execution failed: ${status.error}`,
1731
- timestamp: utcTimestamp(),
1732
- }));
1733
- await persist(status);
1734
- try { resolution.agent.close(); } catch { /* best effort */ }
1735
- console.error(
1736
- `ExecuteCursor failed (unattributed hook block): execution=${executionId}, ` +
1737
- `tools=[${blockedTools}], foreignHooks=[${hitlGate.foreignGatingHooks.join(", ")}]`,
1738
- );
1739
- return slimStatus(status);
1740
- };
1741
-
1742
- // Re-enter the turn boundary for a recovery retry: author the retry's net
1743
- // change set to the file_review ledger and overlay any denials as gates —
1744
- // without this a retry's file edits silently escape review (production case
1745
- // aex_01kws27q1e2esvkqjpvectttxf). The stream finalize now runs through the
1746
- // shared finalizeStreamPhase (in runRecoveryStream), so this is only the
1747
- // boundary + completedAt. Returns undefined for a cancelled retry — there is
1748
- // no review to open. Passes denialCancelSettled so a first denial that stopped
1749
- // the RETRY waits for run.cancel() before the ledger read, exactly like the
1750
- // primary path.
1751
- const settleRetryTurn = async (
1752
- retryResultStatus: string,
1753
- ): Promise<TurnBoundaryResult | undefined> => {
1754
- const retryBoundary =
1755
- retryResultStatus === "cancelled"
1756
- ? undefined
1757
- : await runBoundary(
1758
- turnState.firstDenialDetected ? turnState.denialCancelSettled : undefined,
1759
- );
1760
- // Phase 13 stamped completedAt BEFORE the retry ran. A terminal outcome
1761
- // re-stamps it to the true end; a review pause CLEARS it — the primary
1762
- // pause path never stamps it (a waiting turn is not complete).
1763
- status.completedAt = retryBoundary?.waiting ? "" : utcTimestamp();
1764
- return retryBoundary;
1765
- };
1766
-
1767
- // The shared recovery spine. A fresh agent runs the IDENTICAL stream loop,
1768
- // finalize, and pre-boundary terminal handling as the primary turn, then — on
1769
- // a normal completion or a first denial — waits and re-enters the boundary.
1770
- // The two recovery call sites below differ only in how they build the fresh
1771
- // agent/prompt and how they classify a retry ERROR; everything the primary
1772
- // does mid-stream (live persist, DD-32/DD-33 mid-run progress, sub-agent
1773
- // tracking, the first-denial stop, and correct pause/stall/platform-stop
1774
- // mapping) they now inherit for free instead of the old bare loop.
1775
- type RecoveryOutcome =
1776
- | { proceeded: false; terminal: Exclude<PreBoundaryTerminal, { kind: "proceed" }> }
1777
- | {
1778
- proceeded: true;
1779
- retryRun: Run;
1780
- retryResult: Awaited<ReturnType<Run["wait"]>>;
1781
- retryBoundary: TurnBoundaryResult | undefined;
1782
- };
1783
- const runRecoveryStream = async (
1784
- freshAgent: AgentResolution["agent"],
1785
- retryPrompt: string,
1786
- ): Promise<RecoveryOutcome> => {
1787
- // The fresh agent is now the live handle: point resolution at it so the
1788
- // terminal close() (platform stop, or Phase 14 success) frees THIS agent's
1789
- // executor lease rather than the disposed one it replaced. (Without this the
1790
- // poisoned-handle path leaked the fresh agent — it closed the stale one.)
1791
- resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
1792
- turnState.streamErrorMessage = undefined;
1793
- // The retry always carries the turn's full image payload — never the
1794
- // primary send's HITL-trimmed set: the fresh agent's conversation is
1795
- // empty, so skipping them here would silently lose the user's photo on
1796
- // a recovered turn (issue #366's vision corollary).
1797
- const retryRun = await freshAgent.send(toSendMessage(retryPrompt, turnImages), {
1798
- onDelta: makeCursorTurnOnDelta(onDeltaDeps),
1799
- });
1800
- await consumeCursorTurnStream(retryRun, streamDeps);
1801
- await finalizeStreamPhase();
1802
- const terminal = await resolvePreBoundaryTerminal();
1803
- if (terminal.kind !== "proceed") return { proceeded: false, terminal };
1804
- const retryResult = await retryRun.wait();
1805
- console.log(
1806
- `ExecuteCursor retry run.wait(): execution=${executionId}, ` +
1807
- `retryResult=${JSON.stringify(retryResult)}`,
1808
- );
1809
- const retryBoundary = await settleRetryTurn(retryResult.status);
1810
- return { proceeded: true, retryRun, retryResult, retryBoundary };
1811
- };
1812
-
1813
- // The denial-settle wait applies only when a first denial stopped THIS run;
1814
- // a normal completion passes no promise.
1815
- const boundary = await runBoundary(
1816
- turnState.firstDenialDetected ? turnState.denialCancelSettled : undefined,
1817
- );
1818
- if (boundary.waiting) {
1819
- // A pausing turn is never silent, so an unattributed block alongside our
1820
- // own gate only warns (logged by the boundary) — the pause wins.
1821
- return enterApprovalPause(boundary);
1822
- }
1823
- if (boundary.unattributedHookBlocks.length > 0) {
1824
- return enterUnattributedHookBlockFailure(boundary);
1825
- }
1826
-
1827
- // Phase 13: Map final result
1828
- const result = await run.wait();
1829
- console.log(
1830
- `ExecuteCursor run.wait() result: execution=${executionId}, result=${JSON.stringify(result)}`,
1831
- );
1832
- // Echo sanity check only: result.model ECHOES the requested selection —
1833
- // the SDK never reports the variant that actually served the call
1834
- // (verified against the billing ledger, #357). A mismatch here means the
1835
- // SDK rewrote our selection (contract change), not variant drift; the
1836
- // authoritative requested-vs-billed reconciliation is the cloud billing
1837
- // handler's pricing_variant mismatch metric.
1838
- const echoedSelection = result.model;
1839
- if (echoedSelection) {
1840
- const idMatches = echoedSelection.id === validatedModel;
1841
- // Compare id/value pairs explicitly, never serialized objects: the SDK
1842
- // may add fields to ModelParameterValue or reorder keys, and neither
1843
- // is contract drift.
1844
- const echoedParams = [...(echoedSelection.params ?? [])]
1845
- .sort((a, b) => a.id.localeCompare(b.id));
1846
- const paramsMatch =
1847
- echoedParams.length === modelParams.length &&
1848
- echoedParams.every(
1849
- (p, i) => p.id === modelParams[i].id && p.value === modelParams[i].value,
1850
- );
1851
- if (!idMatches || !paramsMatch) {
1852
- console.warn(
1853
- `ExecuteCursor model selection echo mismatch (SDK contract drift?): ` +
1854
- `execution=${executionId}, ` +
1855
- `requested=${JSON.stringify({ id: validatedModel, params: modelParams })}, ` +
1856
- `echoed=${JSON.stringify(echoedSelection)}`,
1857
- );
1858
- }
1859
- }
1860
- status.completedAt = utcTimestamp();
1861
-
1862
- switch (result.status) {
1863
- case "finished":
1864
- status.phase = ExecutionPhase.EXECUTION_COMPLETED;
1865
- break;
1866
- case "error": {
1867
- // Shape-aware extraction, NOT String(): the result's error fields are
1868
- // structured at runtime often enough that a bare coercion showed users
1869
- // "[object Object]" and shadowed every fallback source below (oss#299).
1870
- const runErrorSources = extractRunErrorSources(result);
1871
-
1872
- // The SDK frequently resolves run.wait() to a bare { status: "error" }
1873
- // while the real reason (e.g. the original grpc-status 12 routing
1874
- // failure) lives on the failing conversation turn. Capture it here so
1875
- // the classified error is actionable instead of "no detail from SDK".
1876
- const conversationErrorText = await introspectConversation(run, executionId);
1877
-
1878
- const capturedRejection = getCapturedRejection(executionId);
1879
- if (capturedRejection) clearCapturedRejection(executionId);
1880
-
1881
- const classified = synthesizeError({
1882
- sdkError: runErrorSources.sdkError,
1883
- sdkResultFields: runErrorSources.sdkResultFields,
1884
- streamErrorMessage: turnState.streamErrorMessage,
1885
- capturedRejection,
1886
- conversationErrorText,
1887
- isResumedHandle: resolution.reason === "resumed_successfully",
1888
- fallbackContext: { model: validatedModel, mode: agentMode, agentId: resolution.agentId },
1889
- durationMs: (result as unknown as Record<string, unknown>).durationMs as number | undefined,
1890
- messageCount: status.messages.length,
1891
- proxyMode: !!config.proxyEndpoint,
1892
- });
1893
-
1894
- console.error(
1895
- `ExecuteCursor agent error: execution=${executionId}, ` +
1896
- `classified=${JSON.stringify(classified)}, rawResult=${JSON.stringify(result)}`,
1897
- );
1898
-
1899
- if (
1900
- shouldRetryWithFreshAgent(classified)
1901
- && resolution.reason === "resumed_successfully"
1902
- && !alreadyRetriedWithFreshAgent
1903
- ) {
1904
- alreadyRetriedWithFreshAgent = true;
1905
- console.warn(
1906
- `ExecuteCursor poisoned-handle recovery: execution=${executionId}, ` +
1907
- `disposing agent ${resolution.agentId} and creating fresh agent`,
1908
- );
1909
-
1910
- try { resolution.agent.close(); } catch { /* best effort */ }
1911
-
1912
- const freshAgent = agentMode === "cloud"
1913
- ? await createCloudAgent(createOptions as CreateCloudAgentOptions)
1914
- : await createAgent(createOptions as CreateAgentOptions);
1915
-
1916
- const freshPrompt = buildPrompt({
1917
- resolution: {
1918
- ...resolution,
1919
- agent: freshAgent,
1920
- agentId: freshAgent.agentId,
1921
- isNew: true,
1922
- resumed: false,
1923
- reason: "created_after_resume_failure",
1924
- resumeFailureDetail: `poisoned-handle recovery: ${classified.message}`,
1925
- },
1926
- approvalDecisions,
1927
- instructions: blueprint.instructions,
1928
- userMessage: spec.message,
1929
- skills: skillMetadata,
1930
- channelMessaging,
1931
- subAgents: blueprint.subAgents,
1932
- workspaceDirs: blueprint.workspaceDirs,
1933
- workspaceFileRefs: spec.workspaceFileRefs ?? [],
1934
- attachments: attachmentEntries,
1935
- vision: visionPromptInfo,
1936
- downloadUrlKind: artifactStorage?.downloadUrlKind,
1937
- pendingApprovals: adjudicatedApprovals,
1938
- // Without the applied set, the HITL-recovery prompt would tell
1939
- // the fresh agent to carry out writes the runner already
1940
- // exact-applied (the primary call at Phase 10 passes it too).
1941
- appliedToolCallIds,
1942
- interactionMode,
1943
- // buildFromPlan was silently dropped here until T03 Sitting 3 —
1944
- // a build turn that hit handle recovery lost its directive. The
1945
- // fresh prompt must carry every per-turn directive the original
1946
- // did.
1947
- buildFromPlan,
1948
- contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1949
- senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1950
- sessionContext: readSessionContext(blueprint.sessionSpec.metadata),
1951
- declaredPreferences: readDeclaredPreferences(spec.declaredPreferences),
1952
- // Lazily selected: the primary send may have been a resumed-agent
1953
- // shape that carried no memories, but this fresh agent's prompt
1954
- // must (see the Phase 9c memoized selection).
1955
- recalledMemories: await selectMemoriesOnce(),
1956
- conversationCatchup: readConversationCatchup(spec.conversationCatchup),
1957
- // Composed fresh (not reused from Phase 10): the failed primary
1958
- // stream may have appended partial work onto status.messages,
1959
- // and the replacement agent should know about that too.
1960
- turnRecoveryDigest: composeTurnRecoveryDigest(status.messages),
1961
- });
1962
-
1963
- console.log(
1964
- `ExecuteCursor retry with fresh agent: execution=${executionId}, ` +
1965
- `newAgentId=${freshAgent.agentId}`,
1966
- );
1967
-
1968
- try {
1969
- blueprint.sessionSpec.harnessStateId = freshAgent.agentId;
1970
- if (blueprint.session.metadata) blueprint.session.metadata.slug = "";
1971
- await client.updateSession(blueprint.session);
1972
- } catch (updateErr) {
1973
- console.warn("Failed to update session with fresh agentId (non-fatal):", updateErr);
1974
- }
1975
-
1976
- // Same per-turn directive rule as buildFromPlan above: a
1977
- // structured-output turn keeps its output contract on the rebuilt
1978
- // prompt (the transport retry re-sends effectivePrompt and
1979
- // inherits it without help).
1980
- const outcome = await runRecoveryStream(
1981
- freshAgent,
1982
- withStructuredOutputDirective(freshPrompt),
1983
- );
1984
- if (!outcome.proceeded) {
1985
- if (outcome.terminal.kind === "return") return slimStatus(status);
1986
- throw new CancelledFailure(outcome.terminal.message);
1987
- }
1988
-
1989
- const { retryRun, retryResult, retryBoundary } = outcome;
1990
- if (retryBoundary?.waiting) {
1991
- // The retry's edits/denials armed the gate — pause for review. On a
1992
- // retry error this supersedes the failure, exactly as on the primary
1993
- // path (a captured change pauses the turn before run.wait() is
1994
- // consulted).
1995
- console.log(
1996
- `ExecuteCursor poisoned-handle recovery paused for review: execution=${executionId}`,
1997
- );
1998
- return enterApprovalPause(retryBoundary);
1999
- }
2000
- if (retryBoundary && retryBoundary.unattributedHookBlocks.length > 0) {
2001
- return enterUnattributedHookBlockFailure(retryBoundary);
2002
- }
2003
-
2004
- if (retryResult.status === "finished") {
2005
- status.phase = ExecutionPhase.EXECUTION_COMPLETED;
2006
- console.log(
2007
- `ExecuteCursor poisoned-handle recovery SUCCEEDED: execution=${executionId}`,
2008
- );
2009
- break;
2010
- }
2011
-
2012
- if (retryResult.status === "cancelled") {
2013
- status.phase = ExecutionPhase.EXECUTION_CANCELLED;
2014
- break;
2015
- }
2016
-
2017
- const retryRejection = getCapturedRejection(executionId);
2018
- if (retryRejection) clearCapturedRejection(executionId);
2019
-
2020
- const retryConversationErrorText = await introspectConversation(retryRun, executionId);
2021
-
2022
- // Same shape-aware extraction as the primary error arm — the retry
2023
- // previously String()-coerced result.result alone, so a structured
2024
- // retry failure both read "[object Object]" and ignored the
2025
- // error/message/reason fields the primary arm consults.
2026
- const retryErrorSources = extractRunErrorSources(retryResult);
2027
- const retryClassified = synthesizeError({
2028
- sdkError: retryErrorSources.sdkError,
2029
- sdkResultFields: retryErrorSources.sdkResultFields,
2030
- streamErrorMessage: turnState.streamErrorMessage,
2031
- capturedRejection: retryRejection,
2032
- conversationErrorText: retryConversationErrorText,
2033
- isResumedHandle: false,
2034
- fallbackContext: { model: validatedModel, mode: agentMode, agentId: freshAgent.agentId },
2035
- proxyMode: !!config.proxyEndpoint,
2036
- });
2037
-
2038
- status.phase = ExecutionPhase.EXECUTION_FAILED;
2039
- status.error = formatClassifiedError(retryClassified);
2040
- console.error(
2041
- `ExecuteCursor poisoned-handle recovery FAILED: execution=${executionId}, ` +
2042
- `retryError=${status.error}`,
2043
- );
2044
- break;
2045
- }
2046
-
2047
- // Transport-timeout retry: fresh agent got 0 messages (degraded h2 session).
2048
- // Reset proxy sessions and try once with a new connection.
2049
- if (
2050
- classified.category === "network"
2051
- && classified.retryable
2052
- && resolution.reason !== "resumed_successfully"
2053
- && !alreadyRetriedWithFreshAgent
2054
- ) {
2055
- alreadyRetriedWithFreshAgent = true;
2056
- console.warn(
2057
- `ExecuteCursor transport-timeout recovery: execution=${executionId}, ` +
2058
- `resetting proxy sessions and retrying with fresh agent`,
2059
- );
2060
-
2061
- try { resolution.agent.close(); } catch { /* best effort */ }
2062
- closeProxySessions();
2063
-
2064
- const freshAgent = agentMode === "cloud"
2065
- ? await createCloudAgent(createOptions as CreateCloudAgentOptions)
2066
- : await createAgent(createOptions as CreateAgentOptions);
2067
-
2068
- try {
2069
- blueprint.sessionSpec.harnessStateId = freshAgent.agentId;
2070
- if (blueprint.session.metadata) blueprint.session.metadata.slug = "";
2071
- await client.updateSession(blueprint.session);
2072
- } catch (updateErr) {
2073
- console.warn("Failed to update session with fresh agentId (non-fatal):", updateErr);
2074
- }
2075
-
2076
- const outcome = await runRecoveryStream(freshAgent, effectivePrompt);
2077
- if (!outcome.proceeded) {
2078
- if (outcome.terminal.kind === "return") return slimStatus(status);
2079
- throw new CancelledFailure(outcome.terminal.message);
2080
- }
2081
-
2082
- const { retryResult, retryBoundary } = outcome;
2083
- if (retryBoundary?.waiting) {
2084
- // The retry's edits/denials armed the gate — pause for review (see
2085
- // the poisoned-handle branch above for the precedence rationale).
2086
- console.log(
2087
- `ExecuteCursor transport-timeout recovery paused for review: execution=${executionId}`,
2088
- );
2089
- return enterApprovalPause(retryBoundary);
2090
- }
2091
- if (retryBoundary && retryBoundary.unattributedHookBlocks.length > 0) {
2092
- return enterUnattributedHookBlockFailure(retryBoundary);
2093
- }
2094
-
2095
- if (retryResult.status === "finished") {
2096
- status.phase = ExecutionPhase.EXECUTION_COMPLETED;
2097
- break;
2098
- }
2099
-
2100
- status.phase = ExecutionPhase.EXECUTION_FAILED;
2101
- status.error = `Transport recovery failed: ${formatClassifiedError(classified)}`;
2102
- break;
2103
- }
2104
-
2105
- status.phase = ExecutionPhase.EXECUTION_FAILED;
2106
- status.error = formatClassifiedError(classified);
2107
- break;
2108
- }
2109
- case "cancelled":
2110
- status.phase = ExecutionPhase.EXECUTION_CANCELLED;
2111
- break;
2112
- default:
2113
- status.phase = ExecutionPhase.EXECUTION_COMPLETED;
2114
- }
2115
-
2116
- // Extract structured output BEFORE persisting, so the subscriber sees
2117
- // COMPLETED + structured_output atomically.
2118
- let structuredOutput: unknown = undefined;
2119
- let finalText: string | undefined;
2120
-
2121
- if (status.phase === ExecutionPhase.EXECUTION_COMPLETED) {
2122
- const lastAiMsg = [...status.messages]
2123
- .reverse()
2124
- .find(m => m.type === MessageType.MESSAGE_AI);
2125
- finalText = lastAiMsg?.content;
2126
-
2127
- if (structuredOutputSchema && finalText) {
2128
- const { extractJsonFromText } = await import("../../shared/extract-json.js");
2129
-
2130
- // Tier 1 + 1.5: JSON.parse, code-fence extraction, heuristic brace match
2131
- structuredOutput = extractJsonFromText(finalText);
2132
- if (structuredOutput !== undefined) {
2133
- console.log(
2134
- `ExecuteCursor structured output extracted (text): execution=${executionId}, ` +
2135
- `finalTextLength=${finalText.length}`,
2136
- );
2137
- }
2138
-
2139
- if (structuredOutput === undefined) {
2140
- // Tier 2: LLM extraction with withStructuredOutput — deterministic,
2141
- // uses function-calling to guarantee schema-conformant output
2142
- console.log(
2143
- `ExecuteCursor text extraction failed, trying LLM extraction: execution=${executionId}, ` +
2144
- `finalTextLength=${finalText.length}`,
2145
- );
2146
- try {
2147
- const { extractStructuredOutput } = await import("./extract-structured-output.js");
2148
- structuredOutput = await extractStructuredOutput(
2149
- finalText, structuredOutputSchema, config, requestedModel,
2150
- );
2151
- if (structuredOutput !== undefined) {
2152
- console.log(
2153
- `ExecuteCursor structured output extracted (LLM): execution=${executionId}`,
2154
- );
2155
- }
2156
- } catch (extractErr) {
2157
- const errMsg = extractErr instanceof Error ? extractErr.message : String(extractErr);
2158
- console.error(
2159
- `ExecuteCursor structured output extraction FAILED: execution=${executionId}, ` +
2160
- `requestedModel=${requestedModel}, ` +
2161
- `finalTextLength=${finalText.length}, ` +
2162
- `error=${errMsg}`,
2163
- );
2164
- }
2165
- }
2166
- }
2167
-
2168
- if (structuredOutput !== undefined) {
2169
- status.structuredOutput = structuredOutput as JsonObject;
2170
- }
2171
-
2172
- // Plan mode: publish the final plan message as a plan artifact (named
2173
- // from the plan's title). The Cursor harness has no auto-publish
2174
- // pipeline, so this is the only artifact path; build storage from the
2175
- // same config-driven factory the native harness uses.
2176
- if (interactionMode === InteractionMode.PLAN && finalText && artifactStorage) {
2177
- try {
2178
- await publishPlanArtifact({ status, executionId, planText: finalText, artifactStorage });
2179
- } catch (err) {
2180
- console.warn(
2181
- `ExecuteCursor plan artifact publish skipped (non-fatal): ` +
2182
- `execution=${executionId}, error=${err}`,
2183
- );
2184
- }
2185
- }
2186
- }
2187
-
2188
- // Collapse any redundant same-identity tool-call twin born this turn before
2189
- // the terminal persist. On a resume turn the gated tool is already granted, so
2190
- // there is no denial ledger and reconcileDeniedToolCalls never runs — the
2191
- // extra attempt the model emits beside the approved action (a stuck RUNNING
2192
- // zombie, a denied-reported-as-success COMPLETED, or an all-no-change double)
2193
- // would otherwise persist as a second "No preview available" card. The shared
2194
- // routine keeps the diff/output carrier and blanks the rest to hidden SKIPPED
2195
- // rows in place, preserving each committed id so the finalize stays append-only.
2196
- const collapsedTwins = collapseRedundantToolCallTwins(status.messages);
2197
- if (collapsedTwins > 0) {
2198
- console.log(
2199
- `ExecuteCursor collapsed ${collapsedTwins} redundant tool-call twin(s) at ` +
2200
- `terminal finalize (kept in place as hidden SKIPPED rows): execution=${executionId}`,
2201
- );
2202
- }
2203
-
2204
- // Write-back safety net on terminal completion. A capture-mode turn with
2205
- // captured changes always paused above (boundary.waiting), so reaching
2206
- // here means no reviewable delta this turn and this is normally a no-op —
2207
- // it exists for the same reason the deep-agent finalizes on completion:
2208
- // stragglers outside the capture (and it never runs mid-turn).
2209
- if (status.phase === ExecutionPhase.EXECUTION_COMPLETED && writebackCoordinator) {
2210
- await writebackCoordinator.finalize();
2211
- }
2212
-
2213
- // NOW persist — subscriber sees COMPLETED + structured_output atomically
2214
- await persist(status);
2215
-
2216
- console.log(
2217
- `ExecuteCursor completed: execution=${executionId}, phase=${ExecutionPhase[status.phase]}, ` +
2218
- `hasStructuredOutput=${structuredOutput !== undefined}` +
2219
- (status.error ? `, error=${status.error}` : ""),
2220
- );
2221
-
2222
- // Park the agent (with its executor lease) for the session's next turn
2223
- // instead of closing it — the idle TTL / shutdown hooks in
2224
- // agent-session-cache own the eventual release, so cache buildup across
2225
- // sessions stays bounded while turns of ONE session stop paying the
2226
- // executor + MCP re-spawn tax (#215).
2227
- cacheSessionAgent(sessionId ?? "", resolution.agent, agentFingerprint);
2228
-
2229
- const slim = slimStatus(status) as Record<string, unknown>;
2230
- if (finalText !== undefined) {
2231
- slim.final_text = finalText;
2232
- }
2233
- if (structuredOutput !== undefined) {
2234
- slim.structured = structuredOutput;
2235
- }
2236
- return slim;
2237
-
2238
- } catch (err) {
2239
- periodicHeartbeat?.stop();
2240
-
2241
- if (err instanceof CancelledFailure) {
2242
- // Worker shutdown is infrastructure failure, not pause. The direct
2243
- // signal check covers a CancelledFailure thrown BEFORE the post-stream
2244
- // classification ran (workerShutdownDetected still false); no extra
2245
- // interruption-evidence gate is needed here — the caught
2246
- // CancelledFailure IS the evidence (#776).
2247
- if (workerShutdownDetected || (shutdownSignal?.aborted ?? false)) {
2248
- console.log(`ExecuteCursor cancelled (worker shutdown) for execution ${executionId}`);
2249
- status.phase = ExecutionPhase.EXECUTION_FAILED;
2250
- status.error = "Execution interrupted: runner worker was shut down. Retry or resume.";
2251
- status.completedAt = utcTimestamp();
2252
- status.messages.push(create(AgentMessageSchema, {
2253
- type: MessageType.MESSAGE_SYSTEM,
2254
- content: "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
2255
- timestamp: utcTimestamp(),
2256
- }));
2257
- } else if (turnState.pauseDetected) {
2258
- console.log(`ExecuteCursor cancelled (pause) for execution ${executionId}`);
2259
- status.phase = ExecutionPhase.EXECUTION_PAUSED;
2260
- status.messages.push(create(AgentMessageSchema, {
2261
- type: MessageType.MESSAGE_SYSTEM,
2262
- content: "Execution paused by user. Use resume to continue.",
2263
- timestamp: utcTimestamp(),
2264
- }));
2265
- } else {
2266
- console.log(`ExecuteCursor cancelled (infrastructure) for execution ${executionId}`);
2267
- status.phase = ExecutionPhase.EXECUTION_FAILED;
2268
- status.error = "Execution interrupted: agent was unresponsive (heartbeat timeout). Retry or resume.";
2269
- status.completedAt = utcTimestamp();
2270
- status.messages.push(create(AgentMessageSchema, {
2271
- type: MessageType.MESSAGE_SYSTEM,
2272
- content: "Execution interrupted: the agent was unresponsive for too long. You can retry or resume.",
2273
- timestamp: utcTimestamp(),
2274
- }));
2275
- }
2276
- // The aborted Cursor run leaves no live sub-agent — mark any in-flight
2277
- // delegation CANCELLED so the final snapshot has no zombie sub-agent.
2278
- cancelInProgressSubAgentProtos(status.subAgentExecutions);
2279
- await persist(status).catch(() => {}); throw err;
2280
- }
2281
-
2282
- // If a non-CancelledFailure error occurs while a pause is in progress,
2283
- // treat the execution as paused rather than failed. The error was likely
2284
- // caused by the cancellation (e.g. SDK stream teardown) and should not
2285
- // overwrite the PAUSED state that the Pause RPC already set in the DB.
2286
- if (turnState.pauseDetected) {
2287
- const errDetail = err instanceof Error ? err.message : String(err);
2288
- console.log(
2289
- `ExecuteCursor error during pause (treating as pause): execution=${executionId}, error=${errDetail}`,
2290
- );
2291
- status.phase = ExecutionPhase.EXECUTION_PAUSED;
2292
- status.messages.push(create(AgentMessageSchema, {
2293
- type: MessageType.MESSAGE_SYSTEM,
2294
- content: "Execution paused by user. Use resume to continue.",
2295
- timestamp: utcTimestamp(),
2296
- }));
2297
- cancelInProgressSubAgentProtos(status.subAgentExecutions);
2298
- await persist(status).catch(() => {}); throw new CancelledFailure("Activity paused by orchestrator (error during pause)");
2299
- }
2300
-
2301
- // Infrastructure cancellation (e.g. heartbeat timeout) with a
2302
- // non-CancelledFailure error — report as failed, not paused.
2303
- if (Context.current().cancellationSignal.aborted) {
2304
- const errDetail = err instanceof Error ? err.message : String(err);
2305
- console.log(
2306
- `ExecuteCursor error during infrastructure cancel: execution=${executionId}, error=${errDetail}`,
2307
- );
2308
- status.phase = ExecutionPhase.EXECUTION_FAILED;
2309
- status.error = `Execution interrupted: ${errDetail}`;
2310
- status.completedAt = utcTimestamp();
2311
- status.messages.push(create(AgentMessageSchema, {
2312
- type: MessageType.MESSAGE_SYSTEM,
2313
- content: "Execution interrupted: the agent was unresponsive for too long. You can retry or resume.",
2314
- timestamp: utcTimestamp(),
2315
- }));
2316
- cancelInProgressSubAgentProtos(status.subAgentExecutions);
2317
- await persist(status).catch(() => {}); throw new CancelledFailure("Activity cancelled (infrastructure, not user pause)");
2318
- }
2319
-
2320
- // A thrown CursorSdkError carries structured fields (code/status/endpoint/
2321
- // requestId) that the generic format below would flatten to a bare message.
2322
- // Route it through the same classifier as the run.wait() error path so the
2323
- // failure category and full diagnostics are preserved.
2324
- const { CursorSdkError } = await import("@cursor/sdk");
2325
- if (err instanceof CursorSdkError) {
2326
- const sdkErrorJson = err.toJSON();
2327
- console.error(
2328
- `ExecuteCursor SDK error: execution=${executionId}, sdkError=${JSON.stringify(sdkErrorJson)}`,
2329
- );
2330
- const classified = synthesizeError({
2331
- sdkError: { code: err.code, status: err.status, message: err.message },
2332
- sdkResultFields: undefined,
2333
- streamErrorMessage: undefined,
2334
- capturedRejection: getCapturedRejection(executionId),
2335
- isResumedHandle: false,
2336
- fallbackContext: errorContext,
2337
- proxyMode: !!config.proxyEndpoint,
2338
- });
2339
- clearCapturedRejection(executionId);
2340
- status.phase = ExecutionPhase.EXECUTION_FAILED;
2341
- status.error = formatClassifiedError(classified);
2342
- status.completedAt = utcTimestamp();
2343
- status.messages.push(
2344
- create(AgentMessageSchema, {
2345
- type: MessageType.MESSAGE_SYSTEM,
2346
- content: "Internal system error occurred. Please contact support if this issue persists.",
2347
- timestamp: utcTimestamp(),
2348
- }),
2349
- create(AgentMessageSchema, {
2350
- type: MessageType.MESSAGE_SYSTEM,
2351
- content: `Error details: ${status.error}`,
2352
- timestamp: utcTimestamp(),
2353
- }),
2354
- );
2355
- try {
2356
- await persist(status);
2357
- } catch (persistErr) {
2358
- console.error("Failed to persist error status (best-effort):", persistErr);
2359
- } return slimStatus(status);
2360
- }
2361
-
2362
- // Unwrap + classify before formatting: the structured-output extraction
2363
- // path uses a LangChain model whose errors arrive MiddlewareError-wrapped
2364
- // with raw provider prose — the same leak the deep-agent harness fixes.
2365
- // Non-model errors keep the root error's own identity.
2366
- const { errorType: errType, errorMessage: errMsg } = describeExecutionError(err, {
2367
- proxyMode: !!config.proxyEndpoint,
2368
- });
2369
- console.error(`ExecuteCursor failed: execution=${executionId}, [${errType}] ${errMsg}`);
2370
-
2371
- status.phase = ExecutionPhase.EXECUTION_FAILED;
2372
- status.error = `Execution failed: [${errType}] ${errMsg}`;
2373
- status.completedAt = utcTimestamp();
2374
- status.messages.push(
2375
- create(AgentMessageSchema, {
2376
- type: MessageType.MESSAGE_SYSTEM,
2377
- content: "Internal system error occurred. Please contact support if this issue persists.",
2378
- timestamp: utcTimestamp(),
2379
- }),
2380
- create(AgentMessageSchema, {
2381
- type: MessageType.MESSAGE_SYSTEM,
2382
- content: `Error details: [${errType}] ${errMsg}`,
2383
- timestamp: utcTimestamp(),
2384
- }),
2385
- );
2386
-
2387
- try {
2388
- await persist(status);
2389
- } catch (persistErr) {
2390
- console.error("Failed to persist error status (best-effort):", persistErr);
2391
- }
2392
-
2393
- return slimStatus(status);
2394
- } finally {
2395
- // Stop the activity-wide periodic heartbeat on EVERY exit path
2396
- // (idempotent). The epilogue and catch stop it at the pause/shutdown
2397
- // disambiguation points; this covers early returns (e.g. the
2398
- // pure-reconcile resume) so no orphaned timer survives the activity.
2399
- periodicHeartbeat?.stop();
2400
-
2401
- // End the OTel turn span + record metrics with the final token snapshot on
2402
- // EVERY exit path (idempotent). Placed here so the span covers any recovery
2403
- // retry (whose tokens accrue after the primary stream) and never leaks on an
2404
- // early return or throw. A no-op when OTel is off or the span never opened.
2405
- await finishTurnTelemetry?.();
2406
-
2407
- // Disarm the stall watchdog on EVERY exit path (idempotent). consumeCursorTurnStream
2408
- // stops the one it armed; this covers throws before that point so no orphaned
2409
- // timer survives the activity.
2410
- turnState.stallWatchdog?.stop();
2411
-
2412
- // Close the denial-ledger watcher on EVERY exit path (idempotent) so no
2413
- // orphaned fs.watch handle survives the activity.
2414
- stopDenialWatcher?.();
2415
-
2416
- // Tear down the HITL gate on EVERY exit path (success, error, approval
2417
- // pause, cancellation) so attaching a real repo leaves the user's
2418
- // .cursor/hooks.json and workspace untouched between turns (issue #173).
2419
- // Best-effort: a leftover hooks.json is inert because the scope guard
2420
- // allows all invocations once this runner PID is gone.
2421
- if (hitlCleanup) {
2422
- try {
2423
- await hitlCleanup();
2424
- } catch (cleanupErr) {
2425
- console.warn(
2426
- `ExecuteCursor HITL gate teardown failed (non-fatal): ` +
2427
- `execution=${executionId}, error=${cleanupErr instanceof Error ? cleanupErr.message : cleanupErr}`,
2428
- );
2429
- }
2430
- }
2431
-
2432
- // Release the workspace turn lock LAST — hitlCleanup above still mutates
2433
- // the tree (restores .cursor/hooks.json), and the next queued turn must
2434
- // not baseline until every mutation of this one has landed. Idempotent
2435
- // and non-throwing (see workspace-lock.ts), so it can never mask the
2436
- // turn's real outcome.
2437
- await releaseWorkspaceLock?.();
2438
- }
2439
- }
2440
-
2441
- /**
2442
- * Seed an in-progress status from the persisted execution on a durable resume
2443
- * (HITL approval, pause/resume, or transient recovery) so the upcoming turn
2444
- * APPENDS onto prior history instead of replacing it. This is the Cursor analog
2445
- * of the deep-agent's seedStatusFromExecution (execute-deep-agent/index.ts).
2446
- *
2447
- * Why it is required: a resumed Cursor agent re-issues the previously gated tool
2448
- * calls with brand-new call ids. Without seeding, the MessageAccumulator would
2449
- * rebuild the transcript from empty and emit a status that drops the already-
2450
- * committed tool-call ids. The backend's append-only-at-identity guard
2451
- * (AgentExecutionUpdateStatusHandler / update_status.go) rejects any non-
2452
- * terminal update that drops a committed tool-call id, so the resumed progress
2453
- * would never persist — the run stalls in WAITING_FOR_APPROVAL with no pending
2454
- * approvals and the workflow watchdog fails it. Seeding makes the resume status
2455
- * a strict superset; the re-runs are then reconciled in place onto these seeded
2456
- * calls by canonical identity inside the accumulator.
2457
- *
2458
- * The persisted protos are cloned so the input execution stays immutable, and
2459
- * the seeded messages are pushed into status.messages (which the accumulator
2460
- * wraps by reference) BEFORE the accumulator is constructed. Sub-agent
2461
- * executions are returned rather than written to status.subAgentExecutions
2462
- * directly, because the accumulator owns that array (it overwrites
2463
- * status.subAgentExecutions with its own on every flush) — handing them to the
2464
- * accumulator keeps the seeded sub-agent rows from being clobbered.
2465
- *
2466
- * @returns the cloned sub-agent executions to seed into the MessageAccumulator.
2467
- */
2468
- function seedCursorTranscriptFromExecution(
2469
- status: AgentExecutionStatus,
2470
- execution: AgentExecution,
2471
- ): SubAgentExecution[] {
2472
- const persisted = execution.status;
2473
- if (!persisted || persisted.messages.length === 0) return [];
2474
- for (const message of persisted.messages) {
2475
- status.messages.push(clone(AgentMessageSchema, message));
2476
- }
2477
- return persisted.subAgentExecutions.map((sub) => clone(SubAgentExecutionSchema, sub));
2478
- }
2479
-
2480
- // ---------------------------------------------------------------------------
2481
- // Prompt selection
2482
- // ---------------------------------------------------------------------------
2483
-
2484
- export interface BuildPromptInput {
2485
- resolution: AgentResolution;
2486
- approvalDecisions: Map<string, ApprovalAction> | undefined;
2487
- instructions: string;
2488
- userMessage: string;
2489
- skills: import("./prompt-builder.js").SkillMetadata[];
2490
- /**
2491
- * Serving proactive channels + their templates (the DD-006 D2
2492
- * discovery read) — the `<available_channel_templates>` section.
2493
- */
2494
- channelMessaging?: import("../../shared/channel-attachment.js").ChannelMessagingInfo[];
2495
- subAgents: import("@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb").SubAgent[];
2496
- workspaceDirs: string[];
2497
- workspaceFileRefs: string[];
2498
- /**
2499
- * This turn's resolved attachments for the `<input_files>` section —
2500
- * final paths plus duplicate-rename disclosure (attachment-resolver.ts).
2501
- */
2502
- attachments: import("./prompt-builder.js").AttachmentPromptEntry[];
2503
- /**
2504
- * Vision facts for the input-files section (T04): which attachments the
2505
- * model sees inline and which degraded to path-only. PER-TURN like the
2506
- * catchup — it rides both the enhanced prompt and a resumed turn's prefix.
2507
- */
2508
- vision?: import("./prompt-builder.js").VisionPromptInfo;
2509
- /**
2510
- * What kind of URL the turn's storage backend mints (issue #532) — keys
2511
- * the input-files hand-off wording. Sourced from the resolved
2512
- * artifactStorage's self-description; absent when no storage resolved.
2513
- */
2514
- downloadUrlKind?: import("../../shared/attachment-download-urls.js").DownloadUrlKind;
2515
- pendingApprovals: import("@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb").PendingApproval[];
2516
- /**
2517
- * Approved whole-file writes the runner already applied itself (exact-apply).
2518
- * The reinvocation prompt marks these as done so the model does not redo them;
2519
- * the remaining approved actions are the ones it must still carry out.
2520
- */
2521
- appliedToolCallIds?: ReadonlySet<string>;
2522
- interactionMode?: InteractionMode;
2523
- /**
2524
- * The execution is a Build-from-plan turn (spec.execution_config
2525
- * .build_from_plan): both prompt paths carry the implement-plan directive.
2526
- */
2527
- buildFromPlan?: boolean;
2528
- /**
2529
- * Rollover context bridge from `SessionSpec.metadata` (cloud DD-013).
2530
- * Only the enhanced-prompt path consumes it — a resumed agent's native
2531
- * context IS the previous conversation, so it needs no bridge.
2532
- */
2533
- contextBridge?: string;
2534
- /**
2535
- * Channel sender identity from `SessionSpec.metadata`. Like the bridge,
2536
- * only the enhanced-prompt path consumes it — a resumed agent's native
2537
- * context already carries it from the session's first turn.
2538
- */
2539
- senderIdentity?: import("../../shared/sender-identity.js").SenderIdentity;
2540
- /**
2541
- * Embedder-supplied session context from `SessionSpec.metadata`. Like
2542
- * the bridge, only the enhanced-prompt path consumes it — a resumed
2543
- * agent's native context already carries it from the session's first
2544
- * turn.
2545
- */
2546
- sessionContext?: string;
2547
- /**
2548
- * Platform-declared standing preferences from the execution spec's
2549
- * `declared_preferences` (stigmer/stigmer#293). Like the bridge, only
2550
- * the enhanced-prompt path consumes it — deliberately frozen per Cursor
2551
- * session (DD-002 D3): the first turn delivers it into the agent's own
2552
- * conversation store, and repeating it on resumed turns would bloat the
2553
- * store with identical content.
2554
- */
2555
- declaredPreferences?: import("../../shared/declared-preferences.js").DeclaredPreferencesContent;
2556
- /**
2557
- * The subject's confirmed memories from the execution spec's
2558
- * `recalled_memories` (stigmer/stigmer#293 Phase 2, DD-006). Like the
2559
- * preferences, only the enhanced-prompt path consumes it — deliberately
2560
- * frozen per Cursor session (DD-002 D3, inherited by DD-006 D4): the
2561
- * first turn delivers it into the agent's own conversation store, and
2562
- * repeating it on resumed turns would bloat the store with identical
2563
- * content.
2564
- */
2565
- recalledMemories?: import("../../shared/recalled-memories.js").RecalledMemoriesContent;
2566
- /**
2567
- * Conversation catchup from the execution spec's `conversation_catchup`
2568
- * (cloud DD-006): what happened on the channel conversation that the
2569
- * agent has not seen. PER-TURN, so unlike the standing values
2570
- * above it rides BOTH prompt paths — the enhanced prompt and a resumed
2571
- * turn's prefix (the `interaction_mode` shape). Handback lands
2572
- * mid-session on a resumed agent: the resumed path is the one that
2573
- * matters. Once delivered, the digest persists in the agent's own
2574
- * conversation store; the next turn's field is composed fresh and is
2575
- * usually blank.
2576
- */
2577
- conversationCatchup?: string;
2578
- /**
2579
- * The turn's recorded transcript rendered as digest lines
2580
- * (turn-recovery.ts), composed from `status.messages` at the call site.
2581
- * Consumed ONLY by the HITL-recovery shape — a fresh agent that replaced
2582
- * a lost one mid-HITL needs the story of the work it no longer remembers
2583
- * (issue #366); every other shape either has native context or no prior
2584
- * work to tell.
2585
- */
2586
- turnRecoveryDigest?: string;
2587
- }
2588
-
2589
- /**
2590
- * Select and build the appropriate prompt based on resolution reason and
2591
- * HITL state.
2592
- *
2593
- * Conversation continuation is carried entirely by the Cursor SDK's native
2594
- * agent state (the local SQLite store persisted on the durable workspace
2595
- * volume, or cloud server-side state) — there is no separate continuation
2596
- * store. The prompt therefore depends only on how the agent was resolved:
2597
- *
2598
- * 1. HITL reinvocation, -> buildReinvocationPrompt (approval decisions
2599
- * resumed agent only; the resumed agent's native context
2600
- * carries the prior conversation)
2601
- * 2. HITL reinvocation, -> buildHitlRecoveryPrompt (full context +
2602
- * fresh agent after the turn's recorded transcript + decisions;
2603
- * resume failure the replacement agent's conversation is
2604
- * empty, and both fresh-agent crossings —
2605
- * resolution-time resume failure and mid-send
2606
- * poisoned-handle recovery — land here by
2607
- * keying on the reason, issue #366)
2608
- * 3. resumed_successfully -> raw userMessage (native context carries it)
2609
- * 4. first execution / fresh -> buildEnhancedPrompt (full instructions +
2610
- * agent after resume failure skills; no prior conversation to inherit)
2611
- */
2612
- /**
2613
- * Whether this activity invocation is a HITL re-invocation — the turn resumes
2614
- * an agent purely to convey approval decisions, carrying NO user message.
2615
- * Discriminates the two surfaces that depend on the agent already holding
2616
- * this turn's content natively — the prompt shape (below) and the primary
2617
- * send's vision payload — but never alone: both pair it with
2618
- * `resolution.reason`, because a FRESH agent mid-HITL holds nothing and
2619
- * needs the full re-delivery (issue #366).
2620
- */
2621
- export function isHitlReinvocation(
2622
- approvalDecisions: Map<string, ApprovalAction> | undefined,
2623
- ): approvalDecisions is Map<string, ApprovalAction> {
2624
- return approvalDecisions !== undefined && approvalDecisions.size > 0;
2625
- }
2626
-
2627
- /**
2628
- * Whether the PRIMARY send delivers the turn's vision payload. The invariant
2629
- * is "images accompany the user's turn message, wherever the conversation
2630
- * does not already hold them" — so the only send that skips them is a HITL
2631
- * re-invocation of a successfully RESUMED agent, whose native conversation
2632
- * carries the images from the original send. A fresh agent mid-HITL
2633
- * (resolution-time resume failure — issue #366's vision corollary) holds
2634
- * nothing and needs the re-delivery. The mid-send recovery retries always
2635
- * run on a fresh agent, so their send sites carry the payload
2636
- * unconditionally rather than consulting this.
2637
- */
2638
- export function primarySendCarriesImages(
2639
- approvalDecisions: Map<string, ApprovalAction> | undefined,
2640
- reason: AgentResolutionReason,
2641
- ): boolean {
2642
- return !(isHitlReinvocation(approvalDecisions) && reason === "resumed_successfully");
2643
- }
2644
-
2645
- /**
2646
- * Whether a prompt built for this resolution carries the STANDING context —
2647
- * instructions, skills, declared preferences, recalled memories, session
2648
- * context. Exactly one resolution shape does not: a successfully RESUMED
2649
- * agent, whose native conversation already holds the first message's
2650
- * context (both its prompt shapes — the raw follow-up and the
2651
- * decisions-only HITL reinvocation — send no standing sections).
2652
- *
2653
- * The named authority for that routing property (the
2654
- * primarySendCarriesImages idiom): buildPrompt's internal routing and the
2655
- * activity's standing-context preparation (e.g. the memory selection gate)
2656
- * both consult THIS predicate, so the two can never drift.
2657
- */
2658
- export function promptCarriesStandingContext(reason: AgentResolutionReason): boolean {
2659
- return reason !== "resumed_successfully";
2660
- }
2661
-
2662
- /**
2663
- * Append the structured-output contract to a prompt when the execution
2664
- * requests one. A per-turn directive (the buildFromPlan rule): it must ride
2665
- * every prompt this turn sends — the primary send AND the poisoned-handle
2666
- * recovery rebuild, which previously lost it (issue #366 ride-along).
2667
- */
2668
- export function appendStructuredOutputDirective(
2669
- basePrompt: string,
2670
- schema: Record<string, unknown> | undefined,
2671
- ): string {
2672
- if (!schema) return basePrompt;
2673
- const schemaStr = JSON.stringify(schema, null, 2);
2674
- return basePrompt + `\n\n---\nCRITICAL OUTPUT REQUIREMENT:\nYour final response MUST be a single valid JSON object (no markdown, no commentary, no code fences) that matches this schema:\n${schemaStr}\n\nRespond with ONLY the JSON object. Nothing else.`;
2675
- }
2676
-
2677
- export function buildPrompt(input: BuildPromptInput): string {
2678
- const {
2679
- resolution,
2680
- approvalDecisions,
2681
- instructions,
2682
- userMessage,
2683
- skills,
2684
- subAgents,
2685
- workspaceDirs,
2686
- workspaceFileRefs,
2687
- attachments,
2688
- interactionMode,
2689
- buildFromPlan,
2690
- conversationCatchup,
2691
- } = input;
2692
-
2693
- // HITL reinvocation: the decisions-only prompt is correct ONLY while the
2694
- // agent's native context still carries the prior conversation — which only
2695
- // resumed_successfully guarantees. Any other reason means a fresh agent
2696
- // mid-HITL (in practice created_after_resume_failure: the stored handle
2697
- // failed to resume, or a poisoned handle was replaced mid-send), which
2698
- // gets the full recovery shape instead — enhanced context + the turn's
2699
- // recorded transcript + the same decisions — because the bare decisions on
2700
- // an empty conversation strand the agent with instructions and no story,
2701
- // and the session inherits that amnesia permanently (issue #366).
2702
- if (isHitlReinvocation(approvalDecisions)) {
2703
- if (promptCarriesStandingContext(resolution.reason)) {
2704
- return buildHitlRecoveryPrompt(
2705
- {
2706
- instructions,
2707
- userMessage,
2708
- skills,
2709
- channelMessaging: input.channelMessaging ?? [],
2710
- subAgents,
2711
- workspaceDirs,
2712
- workspaceFileRefs,
2713
- attachments,
2714
- vision: input.vision,
2715
- downloadUrlKind: input.downloadUrlKind,
2716
- interactionMode,
2717
- buildFromPlan,
2718
- contextBridge: input.contextBridge,
2719
- senderIdentity: input.senderIdentity,
2720
- sessionContext: input.sessionContext,
2721
- declaredPreferences: input.declaredPreferences,
2722
- recalledMemories: input.recalledMemories,
2723
- conversationCatchup,
2724
- },
2725
- {
2726
- turnDigest: input.turnRecoveryDigest,
2727
- pendingApprovals: input.pendingApprovals,
2728
- approvalDecisions,
2729
- appliedToolCallIds: input.appliedToolCallIds,
2730
- },
2731
- );
2732
- }
2733
- return buildReinvocationPrompt(
2734
- input.pendingApprovals,
2735
- approvalDecisions,
2736
- input.appliedToolCallIds,
2737
- );
2738
- }
2739
-
2740
- // A successfully resumed agent carries its own conversation context via the
2741
- // SDK's native store — send the raw user message with no preamble. The
2742
- // exceptions are the per-EXECUTION values, which never inherit from the
2743
- // session's first turn: the interaction-mode prefix (a follow-up can switch
2744
- // Agent→Plan mid-session, and for Cursor the prompt is the only plan-mode
2745
- // enforcement), the implement-plan directive (the build turn is usually a
2746
- // follow-up on a resumed agent), THIS turn's attachments (spec.attachments
2747
- // is per-execution — a file sent on a follow-up turn materializes for this
2748
- // turn and would otherwise never be announced at all), and the conversation
2749
- // catchup (handback ALWAYS lands mid-session on a resumed agent — this
2750
- // prefix is the property the metadata lane structurally cannot deliver,
2751
- // cloud DD-006). Catchup last: it is context, and context sits closest to
2752
- // the task (the enhanced prompt's own ordering doctrine); the input files
2753
- // precede it because they are this turn's payload, not background.
2754
- if (!promptCarriesStandingContext(resolution.reason)) {
2755
- const prefixes = [
2756
- formatInteractionModePrefix(interactionMode),
2757
- formatImplementPlanSection(buildFromPlan, attachments),
2758
- attachments.length > 0
2759
- ? formatInputFiles(attachments, input.vision, input.downloadUrlKind)
2760
- : undefined,
2761
- conversationCatchup !== undefined
2762
- ? formatConversationCatchupSection(conversationCatchup)
2763
- : undefined,
2764
- ].filter((p): p is string => p !== undefined);
2765
- return prefixes.length > 0
2766
- ? [...prefixes, userMessage].join("\n\n")
2767
- : userMessage;
2768
- }
2769
-
2770
- // First execution, or a fresh agent created after a resume failure: there is
2771
- // no prior conversation to inherit, so start a new turn with full context —
2772
- // including the rollover bridge, when the session carries one.
2773
- return buildEnhancedPrompt({
2774
- instructions,
2775
- userMessage,
2776
- skills,
2777
- channelMessaging: input.channelMessaging ?? [],
2778
- subAgents,
2779
- workspaceDirs,
2780
- workspaceFileRefs,
2781
- attachments,
2782
- vision: input.vision,
2783
- downloadUrlKind: input.downloadUrlKind,
2784
- interactionMode,
2785
- buildFromPlan,
2786
- contextBridge: input.contextBridge,
2787
- senderIdentity: input.senderIdentity,
2788
- sessionContext: input.sessionContext,
2789
- declaredPreferences: input.declaredPreferences,
2790
- recalledMemories: input.recalledMemories,
2791
- conversationCatchup,
2792
- });
2793
- }
2794
-
2795
- // ---------------------------------------------------------------------------
2796
- // Helpers
2797
- // ---------------------------------------------------------------------------
2798
-
2799
- /**
2800
- * Best-effort: read the failing run's conversation to recover the real error
2801
- * reason the SDK swallowed in run.wait(). Logs the (bounded) raw turns for deep
2802
- * diagnostics and returns a concise error string for the classifier.
2803
- *
2804
- * Strictly non-fatal — any failure (unsupported operation, transport error)
2805
- * returns undefined and never propagates into the execution's error path.
2806
- */
2807
- async function introspectConversation(
2808
- run: Run,
2809
- executionId: string,
2810
- ): Promise<string | undefined> {
2811
- try {
2812
- if (!run.supports("conversation")) {
2813
- console.log(
2814
- `ExecuteCursor conversation introspection unsupported: execution=${executionId}, ` +
2815
- `reason=${run.unsupportedReason("conversation") ?? "n/a"}`,
2816
- );
2817
- return undefined;
2818
- }
2819
- const turns = await run.conversation();
2820
- const raw = JSON.stringify(turns);
2821
- const bounded = raw.length > 8000 ? `${raw.slice(0, 8000)}…(truncated ${raw.length} chars)` : raw;
2822
- console.error(
2823
- `ExecuteCursor conversation introspection: execution=${executionId}, ` +
2824
- `turns=${turns.length}, raw=${bounded}`,
2825
- );
2826
- return extractConversationErrorText(turns);
2827
- } catch (introspectErr) {
2828
- console.warn(
2829
- `ExecuteCursor conversation introspection failed (non-fatal): execution=${executionId}, ` +
2830
- `error=${introspectErr instanceof Error ? introspectErr.message : String(introspectErr)}`,
2831
- );
2832
- return undefined;
2833
- }
2834
- }
2835
-
2836
- /**
2837
- * Walk the last conversation turn and collect human-meaningful error text
2838
- * (error-status payloads and `text`/`message`/`reason` strings). Schema-agnostic
2839
- * by design so it tolerates SDK conversation-shape changes. Returns undefined
2840
- * when nothing useful is found.
2841
- */
2842
- function extractConversationErrorText(turns: ConversationTurn[]): string | undefined {
2843
- if (!turns || turns.length === 0) return undefined;
2844
- const collected: string[] = [];
2845
-
2846
- const visit = (node: unknown, depth: number): void => {
2847
- if (node == null || depth > 6 || typeof node !== "object") return;
2848
- if (Array.isArray(node)) {
2849
- for (const item of node) visit(item, depth + 1);
2850
- return;
2851
- }
2852
- const obj = node as Record<string, unknown>;
2853
- if (obj.status === "error" && obj.error != null) {
2854
- collected.push(
2855
- typeof obj.error === "string" ? obj.error : JSON.stringify(obj.error),
2856
- );
2857
- }
2858
- for (const [key, value] of Object.entries(obj)) {
2859
- if (
2860
- (key === "text" || key === "message" || key === "reason")
2861
- && typeof value === "string"
2862
- && value.trim().length > 0
2863
- ) {
2864
- collected.push(value.trim());
2865
- } else if (typeof value === "object" && value != null) {
2866
- visit(value, depth + 1);
2867
- }
2868
- }
2869
- };
2870
-
2871
- visit(turns[turns.length - 1], 0);
2872
- if (collected.length === 0) return undefined;
2873
-
2874
- const joined = [...new Set(collected)].join(" | ");
2875
- return joined.length > 600 ? `${joined.slice(0, 600)}…` : joined;
2876
- }