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