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