@stigmer/runner 3.1.2 → 3.1.4
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/activities/execute-cursor/attachment-resolver.d.ts +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.js +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +29 -0
- package/dist/activities/execute-cursor/capture-flow.js +59 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/command-provenance.d.ts +11 -25
- package/dist/activities/execute-cursor/command-provenance.js +25 -115
- package/dist/activities/execute-cursor/command-provenance.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +358 -557
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/todo-tracker.d.ts +6 -1
- package/dist/activities/execute-cursor/todo-tracker.js +15 -43
- package/dist/activities/execute-cursor/todo-tracker.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
- package/dist/activities/execute-cursor/turn-boundary.js +163 -0
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
- package/dist/activities/execute-cursor/turn-stream.d.ts +141 -0
- package/dist/activities/execute-cursor/turn-stream.js +249 -0
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -0
- package/dist/activities/execute-deep-agent/command-provenance.d.ts +61 -0
- package/dist/activities/execute-deep-agent/command-provenance.js +72 -0
- package/dist/activities/execute-deep-agent/command-provenance.js.map +1 -0
- package/dist/activities/execute-deep-agent/index.js +73 -4
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +8 -1
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
- package/dist/activities/execute-deep-agent/streaming.js +5 -1
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +12 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/otel.js +10 -0
- package/dist/otel.js.map +1 -1
- package/dist/shared/filereview/capture.d.ts +18 -1
- package/dist/shared/filereview/capture.js +105 -28
- package/dist/shared/filereview/capture.js.map +1 -1
- package/dist/shared/filereview/cas-progress.d.ts +63 -0
- package/dist/shared/filereview/cas-progress.js +128 -0
- package/dist/shared/filereview/cas-progress.js.map +1 -0
- package/dist/shared/filereview/cas-substrate.d.ts +38 -0
- package/dist/shared/filereview/cas-substrate.js +48 -15
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/command-provenance.d.ts +93 -0
- package/dist/shared/filereview/command-provenance.js +132 -0
- package/dist/shared/filereview/command-provenance.js.map +1 -0
- package/dist/shared/filereview/events.d.ts +16 -0
- package/dist/shared/filereview/events.js +37 -0
- package/dist/shared/filereview/events.js.map +1 -1
- package/dist/shared/filereview/git-substrate.d.ts +48 -0
- package/dist/shared/filereview/git-substrate.js +78 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +5 -2
- package/dist/shared/filereview/index.js +5 -2
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/line-counts.d.ts +48 -0
- package/dist/shared/filereview/line-counts.js +72 -0
- package/dist/shared/filereview/line-counts.js.map +1 -0
- package/dist/shared/filereview/progress.d.ts +167 -0
- package/dist/shared/filereview/progress.js +196 -0
- package/dist/shared/filereview/progress.js.map +1 -0
- package/dist/shared/implement-plan-prompt.d.ts +6 -4
- package/dist/shared/implement-plan-prompt.js +38 -6
- package/dist/shared/implement-plan-prompt.js.map +1 -1
- package/dist/shared/plan-artifact.d.ts +59 -11
- package/dist/shared/plan-artifact.js +144 -16
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +15 -4
- package/dist/shared/plan-mode-prompt.js +28 -5
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/dist/shared/todos.d.ts +56 -0
- package/dist/shared/todos.js +98 -0
- package/dist/shared/todos.js.map +1 -0
- package/dist/shared/tool-row.d.ts +16 -0
- package/dist/shared/tool-row.js +31 -0
- package/dist/shared/tool-row.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/otel-turn-span.test.ts +61 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +7 -0
- package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
- package/src/activities/execute-cursor/__tests__/progress-substrate.test.ts +169 -0
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +349 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +78 -0
- package/src/activities/execute-cursor/command-provenance.ts +25 -120
- package/src/activities/execute-cursor/index.ts +419 -602
- package/src/activities/execute-cursor/todo-tracker.ts +17 -59
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-cursor/turn-stream.ts +418 -0
- package/src/activities/execute-deep-agent/__tests__/command-provenance.test.ts +252 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +78 -0
- package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +105 -1
- package/src/activities/execute-deep-agent/command-provenance.ts +102 -0
- package/src/activities/execute-deep-agent/index.ts +91 -4
- package/src/activities/execute-deep-agent/status-builder.ts +9 -0
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/activities/execute-deep-agent/v3-status-builder.ts +13 -0
- package/src/otel.ts +8 -0
- package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/__tests__/todos.test.ts +216 -0
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-progress.test.ts +228 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +100 -0
- package/src/shared/filereview/__tests__/command-provenance.test.ts +252 -0
- package/src/shared/filereview/__tests__/events.test.ts +105 -0
- package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
- package/src/shared/filereview/__tests__/progress.test.ts +338 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-progress.ts +170 -0
- package/src/shared/filereview/cas-substrate.ts +79 -18
- package/src/shared/filereview/command-provenance.ts +180 -0
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +114 -0
- package/src/shared/filereview/index.ts +36 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +310 -0
- package/src/shared/implement-plan-prompt.ts +39 -8
- package/src/shared/plan-artifact.ts +152 -16
- package/src/shared/plan-mode-prompt.ts +28 -5
- package/src/shared/todos.ts +126 -0
- package/src/shared/tool-row.ts +34 -0
|
@@ -35,7 +35,7 @@ import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agent
|
|
|
35
35
|
import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
36
36
|
import type { AgentExecution, AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
37
37
|
import { ExecutionControlSignal, ExecutionPhase, FileChangeSetStatus, InteractionMode, MessageType, ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
38
|
-
import type {
|
|
38
|
+
import type { Run, ConversationTurn } from "@cursor/sdk";
|
|
39
39
|
|
|
40
40
|
import type { Config } from "../../config.js";
|
|
41
41
|
import { StigmerClient } from "../../client/stigmer-client.js";
|
|
@@ -43,15 +43,14 @@ import { resolveAgent } from "./session-lifecycle.js";
|
|
|
43
43
|
import type { AgentResolution, CreateAgentOptions, CreateCloudAgentOptions } from "./session-lifecycle.js";
|
|
44
44
|
import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
45
45
|
import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
|
|
46
|
-
import { MessageAccumulator,
|
|
46
|
+
import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
|
|
47
47
|
import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
48
|
+
import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
|
|
49
|
+
import { StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
|
|
50
50
|
import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
51
51
|
import { publishPlanArtifact } from "../../shared/plan-artifact.js";
|
|
52
52
|
import { DeltaEnricher } from "./delta-enricher.js";
|
|
53
53
|
import { TodoTracker } from "./todo-tracker.js";
|
|
54
|
-
import { shouldPersistStreamingStatus } from "./persist-decision.js";
|
|
55
54
|
import { StreamingUpdateScheduler, loadStreamingConfig } from "../../shared/streaming-scheduler.js";
|
|
56
55
|
import { createCursorEventRecorder } from "./cursor-event-recorder.js";
|
|
57
56
|
import { resolveMcpServers, validateMcpServerEnv } from "./mcp-resolver.js";
|
|
@@ -74,16 +73,29 @@ import {
|
|
|
74
73
|
type ReleaseWorkspaceLock,
|
|
75
74
|
} from "../../shared/workspace/workspace-lock.js";
|
|
76
75
|
import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
|
|
77
|
-
import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts,
|
|
78
|
-
import { deriveTurnCommandProvenance } from "./command-provenance.js";
|
|
76
|
+
import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts, reconstructAdjudicatedApprovals, watchDenialLedger } from "./approval-state.js";
|
|
79
77
|
import { applyApprovedWholeFileWrites, excludeAppliedFromGrants } from "./exact-apply.js";
|
|
80
78
|
import { isGitWorkTree } from "../../shared/filereview/git-substrate.js";
|
|
81
79
|
import {
|
|
82
80
|
captureBaselineToLedger,
|
|
83
|
-
|
|
81
|
+
buildCursorProgressSubstrate,
|
|
84
82
|
applyCaptureDecisions,
|
|
85
83
|
deriveCaptureMode,
|
|
86
84
|
} from "./capture-flow.js";
|
|
85
|
+
import { runTurnBoundary, type TurnBoundaryResult } from "./turn-boundary.js";
|
|
86
|
+
import {
|
|
87
|
+
consumeCursorTurnStream,
|
|
88
|
+
makeCursorTurnOnDelta,
|
|
89
|
+
newTurnStreamState,
|
|
90
|
+
type CursorTurnStreamDeps,
|
|
91
|
+
type TurnOnDeltaDeps,
|
|
92
|
+
} from "./turn-stream.js";
|
|
93
|
+
import {
|
|
94
|
+
captureFileChangeProgress,
|
|
95
|
+
newProgressCaptureState,
|
|
96
|
+
type ProgressCaptureState,
|
|
97
|
+
type ProgressSubstrate,
|
|
98
|
+
} from "../../shared/filereview/progress.js";
|
|
87
99
|
import { deriveExecutionFingerprintKey } from "../../shared/approval-fingerprint.js";
|
|
88
100
|
import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
|
|
89
101
|
import { provisionCursorWorkspace } from "./workspace-provision.js";
|
|
@@ -102,12 +114,6 @@ import { setMaxListeners } from "node:events";
|
|
|
102
114
|
import { startHeartbeat } from "../../shared/heartbeat.js";
|
|
103
115
|
import { getShutdownSignalForQueue } from "../../runner-manager.js";
|
|
104
116
|
|
|
105
|
-
// How long Phase 12 waits for the first-denial-stop's run.cancel() to settle
|
|
106
|
-
// before reading the final denial ledger and capturing the turn's tree. Long
|
|
107
|
-
// enough for the SDK's normal teardown, short enough that a wedged cancel
|
|
108
|
-
// cannot noticeably delay the approval pause the user is already waiting on.
|
|
109
|
-
const FIRST_DENIAL_CANCEL_TIMEOUT_MS = 5_000;
|
|
110
|
-
|
|
111
117
|
/**
|
|
112
118
|
* Creates the activity functions bound to the runner config.
|
|
113
119
|
* Returned object is passed to Temporal Worker.create({ activities }).
|
|
@@ -202,40 +208,30 @@ async function executeCursorInner(
|
|
|
202
208
|
|
|
203
209
|
let sessionId: string | undefined;
|
|
204
210
|
let session: import("@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb").Session | undefined;
|
|
205
|
-
|
|
211
|
+
// Single owner for every flag the turn's stream produces (pause, stall,
|
|
212
|
+
// first-denial, platform-stop, event count, the stall watchdog, …). Created
|
|
213
|
+
// once here — before the fs denial-watcher, the SDK onDelta, the stall
|
|
214
|
+
// watchdog, and the stream loop are wired — so all four producers plus the
|
|
215
|
+
// epilogue and the outer catch/finally share ONE source of truth. The primary
|
|
216
|
+
// turn and both recovery retries drive the same stream code against this
|
|
217
|
+
// object (see turn-stream.ts for per-field ownership).
|
|
218
|
+
const turnState = newTurnStreamState();
|
|
219
|
+
// NOT part of turnState: derived post-loop from the periodic heartbeat +
|
|
220
|
+
// shutdown signal (a runner-manager shutdown, not a stream event), and read by
|
|
221
|
+
// the epilogue + outer catch. Kept as a plain let alongside the stream flags.
|
|
206
222
|
let workerShutdownDetected = false;
|
|
207
|
-
// Set by the stall watchdog when the SDK stream makes no progress for
|
|
208
|
-
// config.cursorStreamStallTimeoutMs. stallError carries the recognizable
|
|
209
|
-
// message surfaced to the user; both feed the Phase 11a stall branch.
|
|
210
|
-
let stallDetected = false;
|
|
211
|
-
let stallError: StallTimeoutError | undefined;
|
|
212
|
-
// Set the moment the preToolUse hook records its first denial in the ledger.
|
|
213
|
-
// We then stop consuming the stream and cancel the run so the model never
|
|
214
|
-
// reacts to Cursor's tool-failure surface (narrate defeat, attempt a second
|
|
215
|
-
// gated tool) — converging the Cursor harness toward the native harness, which
|
|
216
|
-
// pauses BEFORE the model sees a denial. Phase 12 reconciles the denied tool
|
|
217
|
-
// calls into WAITING_FOR_APPROVAL exactly as after a natural stream end.
|
|
218
|
-
let firstDenialDetected = false;
|
|
219
|
-
// Flipped by the denial-ledger fs watcher the instant the hook writes a
|
|
220
|
-
// denial, so the NEXT stream event of ANY type triggers the ledger read —
|
|
221
|
-
// instead of waiting for the next tool_call event, during which the model's
|
|
222
|
-
// full post-denial reaction (thinking, narration, a workaround tool) would
|
|
223
|
-
// stream and persist (observed in production: aex_01kwj07f7g23c3wp9sn8496z5g).
|
|
224
|
-
// The tool_call-event read below remains the backstop where fs.watch is
|
|
225
|
-
// unreliable.
|
|
226
|
-
let denialLedgerDirty = false;
|
|
227
223
|
let stopDenialWatcher: (() => void) | undefined;
|
|
228
|
-
// The in-flight run.cancel() started by the first-denial stop. Awaited
|
|
229
|
-
// (timeboxed) before Phase 12 so the agent process has actually stopped
|
|
230
|
-
// before the final ledger read and the turn-boundary tree capture — closing
|
|
231
|
-
// the race where a post-denial workaround's ledger entry lands after the
|
|
232
|
-
// read (it would then never be collapsed) or a late tool mutates the tree
|
|
233
|
-
// mid-capture.
|
|
234
|
-
let denialCancelSettled: Promise<void> | undefined;
|
|
235
224
|
let periodicHeartbeat: ReturnType<typeof startHeartbeat> | undefined;
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
225
|
+
// Ends the OTel turn span + records turn metrics with the FINAL token snapshot.
|
|
226
|
+
// Hoisted and invoked from the finally so the span is closed exactly once on
|
|
227
|
+
// EVERY exit path — a happy completion, an approval pause, an early return, a
|
|
228
|
+
// throw, or a recovery retry (whose tokens accrue AFTER the primary stream
|
|
229
|
+
// ends). Ending it inline in the epilogue leaked the span on every non-happy
|
|
230
|
+
// path and excluded retry tokens/duration. Assigned when the span is created
|
|
231
|
+
// (once usageAccumulator exists); undefined — a no-op — before then (e.g. a
|
|
232
|
+
// pure-reconcile resume that returns before the agent runs) or when OTel is
|
|
233
|
+
// off. Idempotent: safe to call more than once.
|
|
234
|
+
let finishTurnTelemetry: (() => Promise<void>) | undefined;
|
|
239
235
|
// Session HITL directory (runner-owned, outside the workspace) where the hook
|
|
240
236
|
// script, approval-state file, and denial ledger live. Set once the gate is
|
|
241
237
|
// installed; the WAITING_FOR_APPROVAL path reads the denial ledger from here.
|
|
@@ -304,6 +300,9 @@ async function executeCursorInner(
|
|
|
304
300
|
// Pre-turn baseline tree, pinned before the agent runs (capture mode only)
|
|
305
301
|
// so the turn-end capture diffs against it and the tree restores exactly.
|
|
306
302
|
let baselineTree: string | undefined;
|
|
303
|
+
// Per-turn state for mid-run live capture (DD-32): the last progress tree sha
|
|
304
|
+
// (short-circuit) + last capture time (floor), threaded across persists.
|
|
305
|
+
const progressState: ProgressCaptureState = newProgressCaptureState();
|
|
307
306
|
// Deterministic id of the change set this turn may produce:
|
|
308
307
|
// `${executionId}:${turnSeq}`. Minted from the workflow-threaded turn index
|
|
309
308
|
// so it is stable across a Temporal retry (idempotent ledger authoring) and
|
|
@@ -688,7 +687,22 @@ async function executeCursorInner(
|
|
|
688
687
|
// reset may flip the flag once before the run starts; the loop's read then
|
|
689
688
|
// sees an empty ledger and clears it — harmless by construction.
|
|
690
689
|
stopDenialWatcher = watchDenialLedger(hitlDir, () => {
|
|
691
|
-
denialLedgerDirty = true;
|
|
690
|
+
turnState.denialLedgerDirty = true;
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// Mid-run live capture (DD-32 / DD-33): choose the progress substrate for this
|
|
694
|
+
// turn's workspace shape ONCE (git / non-git CAS / hybrid). It owns its own
|
|
695
|
+
// short-circuit cache across the loop's persists; the floor lives in
|
|
696
|
+
// progressState. Undefined outside capture mode — writes are deny-gated and
|
|
697
|
+
// nothing is captured.
|
|
698
|
+
const progressSubstrate: ProgressSubstrate | undefined = buildCursorProgressSubstrate({
|
|
699
|
+
captureMode,
|
|
700
|
+
gitWorkspace,
|
|
701
|
+
workspaceRoot: primaryWorkspaceDir,
|
|
702
|
+
baselineTree,
|
|
703
|
+
executionId,
|
|
704
|
+
hitlDir,
|
|
705
|
+
storage: artifactStorage,
|
|
692
706
|
});
|
|
693
707
|
|
|
694
708
|
// Phase 5d: Ensure model pricing registry is populated before validation
|
|
@@ -840,13 +854,42 @@ async function executeCursorInner(
|
|
|
840
854
|
await ensurePricingLoaded();
|
|
841
855
|
const usageAccumulator = new UsageAccumulator(validatedModel);
|
|
842
856
|
|
|
843
|
-
// Phase 10c: Start OTel turn span
|
|
857
|
+
// Phase 10c: Start OTel turn span. Coarse-grained — spans the whole turn
|
|
858
|
+
// (agent.send + stream + any recovery retry + the turn boundary), ended once
|
|
859
|
+
// from the finally via finishTurnTelemetry with the final token snapshot.
|
|
844
860
|
const { startCursorTurnSpan } = await import("../../otel.js");
|
|
845
861
|
const turnSpan = await startCursorTurnSpan({
|
|
846
862
|
model: validatedModel,
|
|
847
863
|
mode: agentMode,
|
|
848
864
|
sessionId: sessionId ?? "",
|
|
849
865
|
});
|
|
866
|
+
// Bind the telemetry-finish closure now that the span + usage accumulator
|
|
867
|
+
// exist. Reads usageAccumulator at CALL time (in the finally), so it captures
|
|
868
|
+
// tokens from any recovery retry that ran after the primary stream. Guarded
|
|
869
|
+
// so a second call (finally after an inline path already finished it) is a
|
|
870
|
+
// no-op. Metrics failures are swallowed — OTel is optional.
|
|
871
|
+
let turnTelemetryFinished = false;
|
|
872
|
+
finishTurnTelemetry = async () => {
|
|
873
|
+
if (turnTelemetryFinished) return;
|
|
874
|
+
turnTelemetryFinished = true;
|
|
875
|
+
const usage = usageAccumulator.snapshot();
|
|
876
|
+
turnSpan.setTokens(Number(usage.inputTokens), Number(usage.outputTokens));
|
|
877
|
+
turnSpan.end();
|
|
878
|
+
try {
|
|
879
|
+
const { recordTurnMetrics } = await import("../../otel.js");
|
|
880
|
+
const durationMs =
|
|
881
|
+
Date.now() - (status.startedAt ? new Date(status.startedAt).getTime() : Date.now());
|
|
882
|
+
await recordTurnMetrics({
|
|
883
|
+
durationMs,
|
|
884
|
+
inputTokens: Number(usage.inputTokens),
|
|
885
|
+
outputTokens: Number(usage.outputTokens),
|
|
886
|
+
model: validatedModel,
|
|
887
|
+
mode: agentMode,
|
|
888
|
+
});
|
|
889
|
+
} catch {
|
|
890
|
+
// Metrics not initialized — silently skip.
|
|
891
|
+
}
|
|
892
|
+
};
|
|
850
893
|
|
|
851
894
|
// Phase 11: Send message and stream events
|
|
852
895
|
status.phase = ExecutionPhase.EXECUTION_IN_PROGRESS;
|
|
@@ -855,13 +898,21 @@ async function executeCursorInner(
|
|
|
855
898
|
const todoTracker = new TodoTracker(status.todos);
|
|
856
899
|
const eventRecorder = createCursorEventRecorder(executionId);
|
|
857
900
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
let streamErrorMessage: string | undefined;
|
|
901
|
+
// The two recovery retries (poisoned-handle / transport-timeout) below run at
|
|
902
|
+
// most once per turn; this guard is the latch.
|
|
861
903
|
let alreadyRetriedWithFreshAgent = false;
|
|
862
|
-
|
|
863
|
-
//
|
|
864
|
-
|
|
904
|
+
|
|
905
|
+
// The shared onDelta only needs the usage/enricher/heartbeat/state subset,
|
|
906
|
+
// and it is wired at SEND time — before the accumulator exists — so it takes
|
|
907
|
+
// the narrow deps. The primary send and both retry sends reuse this object.
|
|
908
|
+
const onDeltaDeps: TurnOnDeltaDeps = {
|
|
909
|
+
usageAccumulator,
|
|
910
|
+
deltaEnricher,
|
|
911
|
+
heartbeat,
|
|
912
|
+
promptEstimatedTokens,
|
|
913
|
+
executionId,
|
|
914
|
+
state: turnState,
|
|
915
|
+
};
|
|
865
916
|
|
|
866
917
|
// Periodic heartbeat keeps Temporal informed during silent SDK operations
|
|
867
918
|
// (e.g. long tool calls, MCP requests, model thinking). Without this,
|
|
@@ -889,61 +940,11 @@ async function executeCursorInner(
|
|
|
889
940
|
// (e.g. older SDK), the warning is harmless — ignore.
|
|
890
941
|
}
|
|
891
942
|
|
|
943
|
+
// The stall watchdog is armed inside consumeCursorTurnStream (it needs the
|
|
944
|
+
// run to cancel), stored on turnState.stallWatchdog so this shared onDelta can
|
|
945
|
+
// reset it and the activity's finally can stop it as a backstop.
|
|
892
946
|
const run = await resolution.agent.send(effectivePrompt, {
|
|
893
|
-
onDelta: (
|
|
894
|
-
// Reset the stall timer on the delta channel too: a long model
|
|
895
|
-
// generation emits token deltas but few discrete stream events, so
|
|
896
|
-
// resetting only in the stream loop would false-positive a stall.
|
|
897
|
-
stallWatchdog?.recordActivity();
|
|
898
|
-
if (update.type === "turn-ended" && update.usage) {
|
|
899
|
-
usageAccumulator.addTurn(update.usage);
|
|
900
|
-
|
|
901
|
-
if (!firstTurnAttributionLogged) {
|
|
902
|
-
firstTurnAttributionLogged = true;
|
|
903
|
-
const sdkInputTokens = update.usage.inputTokens ?? 0;
|
|
904
|
-
const cursorOverhead = Math.max(0, sdkInputTokens - promptEstimatedTokens);
|
|
905
|
-
console.log(
|
|
906
|
-
`ExecuteCursor context attribution (first turn): execution=${executionId}, ` +
|
|
907
|
-
`sdkInputTokens=${sdkInputTokens}, stigmerPreamble=${promptEstimatedTokens}, ` +
|
|
908
|
-
`cursorOverhead=${cursorOverhead} (estimated)`,
|
|
909
|
-
);
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
deltaEnricher.processDelta(update);
|
|
913
|
-
try {
|
|
914
|
-
heartbeat();
|
|
915
|
-
} catch (hbErr) {
|
|
916
|
-
if (hbErr instanceof CancelledFailure) {
|
|
917
|
-
pauseDetected = true;
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
throw hbErr;
|
|
921
|
-
}
|
|
922
|
-
},
|
|
923
|
-
});
|
|
924
|
-
|
|
925
|
-
// Arm the stall watchdog now that the run exists. The periodic heartbeat
|
|
926
|
-
// above proves the process is alive, not that the agent is progressing: if
|
|
927
|
-
// the stream wedges (a tool call or model connection that never returns),
|
|
928
|
-
// no event/delta arrives, Phase 12 below is never reached, and the
|
|
929
|
-
// execution hangs at EXECUTION_IN_PROGRESS forever. On stall we end the run
|
|
930
|
-
// cleanly via the SDK's run.cancel() (guarded by supports("cancel")), which
|
|
931
|
-
// unblocks the for-await; the stallDetected branch in Phase 11a then
|
|
932
|
-
// reports EXECUTION_FAILED with a recognizable, actionable message.
|
|
933
|
-
stallWatchdog = startStallWatchdog(config.cursorStreamStallTimeoutMs, (idleMs) => {
|
|
934
|
-
stallDetected = true;
|
|
935
|
-
stallError = new StallTimeoutError(idleMs, lastToolName ? `last tool: ${lastToolName}` : undefined);
|
|
936
|
-
console.warn(
|
|
937
|
-
`ExecuteCursor stall detected: execution=${executionId}, idleMs=${idleMs}, lastTool=${lastToolName ?? "none"}`,
|
|
938
|
-
);
|
|
939
|
-
if (run.supports?.("cancel")) {
|
|
940
|
-
void run.cancel().catch((cancelErr) => {
|
|
941
|
-
console.warn(
|
|
942
|
-
`ExecuteCursor run.cancel() after stall failed (non-fatal): execution=${executionId}, ` +
|
|
943
|
-
`error=${cancelErr instanceof Error ? cancelErr.message : cancelErr}`,
|
|
944
|
-
);
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
+
onDelta: makeCursorTurnOnDelta(onDeltaDeps),
|
|
947
948
|
});
|
|
948
949
|
|
|
949
950
|
// Everything at an index >= this was produced by THIS turn's stream — the
|
|
@@ -961,439 +962,299 @@ async function executeCursorInner(
|
|
|
961
962
|
// flush; high-frequency token deltas ride this scheduler's time cadence
|
|
962
963
|
// (env-tunable via STREAMING_* — see loadStreamingConfig).
|
|
963
964
|
const scheduler = new StreamingUpdateScheduler(loadStreamingConfig());
|
|
964
|
-
let eventCount = 0;
|
|
965
|
-
|
|
966
|
-
try {
|
|
967
|
-
for await (const event of run.stream()) {
|
|
968
|
-
if (pauseDetected || Context.current().cancellationSignal.aborted) {
|
|
969
|
-
pauseDetected = true;
|
|
970
|
-
break;
|
|
971
|
-
}
|
|
972
|
-
if (stallDetected) break;
|
|
973
|
-
|
|
974
|
-
// Progress: reset the stall timer on every stream event.
|
|
975
|
-
stallWatchdog.recordActivity();
|
|
976
|
-
if (event.type === "tool_call" && typeof event.name === "string") {
|
|
977
|
-
lastToolName = event.name;
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
eventRecorder?.record(event, eventCount);
|
|
981
965
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
// reaction can persist: waiting for the next tool_call event let the full
|
|
1002
|
-
// post-denial reaction (thinking, narration, a workaround shell) stream
|
|
1003
|
-
// and persist live (production case aex_01kwj07f7g23c3wp9sn8496z5g). The
|
|
1004
|
-
// tool_call-event read stays as the backstop for platforms where fs.watch
|
|
1005
|
-
// is unreliable; the current event was already accumulated above, so the
|
|
1006
|
-
// anchor's own row is always present for the Phase 12 gate overlay. This
|
|
1007
|
-
// mirrors the native harness's pause-before-react semantics; Phase 12
|
|
1008
|
-
// reconciles the denied calls and its trim remains the last-resort
|
|
1009
|
-
// backstop for anything that persisted before the stop.
|
|
1010
|
-
if (!firstDenialDetected && hitlDir && (denialLedgerDirty || event.type === "tool_call")) {
|
|
1011
|
-
denialLedgerDirty = false;
|
|
1012
|
-
const denials = await readDenialLedger(hitlDir);
|
|
1013
|
-
if (denials.length > 0) {
|
|
1014
|
-
firstDenialDetected = true;
|
|
1015
|
-
console.log(
|
|
1016
|
-
`ExecuteCursor first denial detected (${denials.length} ledger ` +
|
|
1017
|
-
`entr${denials.length === 1 ? "y" : "ies"}); stopping turn to pause ` +
|
|
1018
|
-
`cleanly for approval: execution=${executionId}`,
|
|
1019
|
-
);
|
|
1020
|
-
if (run.supports?.("cancel")) {
|
|
1021
|
-
// Kept (not fire-and-forget): awaited timeboxed before Phase 12 so
|
|
1022
|
-
// the ledger read and tree capture see a stopped agent.
|
|
1023
|
-
denialCancelSettled = run.cancel().then(
|
|
1024
|
-
() => {},
|
|
1025
|
-
(cancelErr: unknown) => {
|
|
1026
|
-
console.warn(
|
|
1027
|
-
`ExecuteCursor run.cancel() after first denial failed (non-fatal): ` +
|
|
1028
|
-
`execution=${executionId}, ` +
|
|
1029
|
-
`error=${cancelErr instanceof Error ? cancelErr.message : cancelErr}`,
|
|
1030
|
-
);
|
|
1031
|
-
},
|
|
1032
|
-
);
|
|
1033
|
-
}
|
|
1034
|
-
break;
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
deltaEnricher.applyEnrichments(status.messages);
|
|
1039
|
-
eventCount++;
|
|
1040
|
-
|
|
1041
|
-
if (event.type === "status") {
|
|
1042
|
-
console.log(
|
|
1043
|
-
`ExecuteCursor stream status: execution=${executionId}, status=${JSON.stringify(event)}`,
|
|
1044
|
-
);
|
|
1045
|
-
const statusEvent = event as { status?: string; message?: string };
|
|
1046
|
-
if (statusEvent.status === "ERROR" && statusEvent.message) {
|
|
1047
|
-
streamErrorMessage = statusEvent.message;
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
const shouldPersist = shouldPersistStreamingStatus(
|
|
1052
|
-
{
|
|
1053
|
-
deltaEnricherDirty: deltaEnricher.isDirty,
|
|
1054
|
-
todosDirty: todoTracker.isDirty,
|
|
1055
|
-
contentDirty: accumulator.isDirty,
|
|
1056
|
-
},
|
|
1057
|
-
scheduler,
|
|
1058
|
-
eventCount,
|
|
1059
|
-
);
|
|
1060
|
-
if (usageAccumulator.hasTurns) {
|
|
1061
|
-
status.streamingUsage = create(StreamingUsageSummarySchema, usageAccumulator.snapshot());
|
|
1062
|
-
}
|
|
1063
|
-
if (shouldPersist) {
|
|
1064
|
-
// Sync sub-agent executions into status before every persist so the
|
|
1065
|
-
// live UI reflects delegation (including the IN_PROGRESS state) while
|
|
1066
|
-
// the parent is still running — matching the native harness, which
|
|
1067
|
-
// calls syncSubAgentExecutions() on each persist. Without this, the
|
|
1068
|
-
// accumulator tracked sub-agents in memory but they only reached the
|
|
1069
|
-
// status (and the subscriber stream) after the loop ended.
|
|
1070
|
-
status.subAgentExecutions = accumulator.subAgentExecutions;
|
|
1071
|
-
const signal = await persist(status);
|
|
1072
|
-
deltaEnricher.markPersisted();
|
|
1073
|
-
todoTracker.markPersisted();
|
|
1074
|
-
accumulator.markPersisted();
|
|
1075
|
-
scheduler.markUpdateSent(eventCount);
|
|
1076
|
-
heartbeat();
|
|
1077
|
-
if (signal === ExecutionControlSignal.STOP) {
|
|
1078
|
-
platformStopSignaled = true;
|
|
1079
|
-
console.warn(
|
|
1080
|
-
`ExecuteCursor platform stop signal received: execution=${executionId}`,
|
|
1081
|
-
);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
966
|
+
// Full deps for the shared stream loop — the collaborators + the injected
|
|
967
|
+
// heartbeat/cancellation (so the loop is testable, mirroring the deep-agent
|
|
968
|
+
// streamExecution seam), all keyed off the single turnState. Consumed by the
|
|
969
|
+
// primary stream here and by both recovery retries below.
|
|
970
|
+
const streamDeps: CursorTurnStreamDeps = {
|
|
971
|
+
...onDeltaDeps,
|
|
972
|
+
status,
|
|
973
|
+
accumulator,
|
|
974
|
+
todoTracker,
|
|
975
|
+
eventRecorder,
|
|
976
|
+
scheduler,
|
|
977
|
+
progressSubstrate,
|
|
978
|
+
progressState,
|
|
979
|
+
changeSetId,
|
|
980
|
+
hitlDir,
|
|
981
|
+
stallTimeoutMs: config.cursorStreamStallTimeoutMs,
|
|
982
|
+
persist,
|
|
983
|
+
isCancelled: () => Context.current().cancellationSignal.aborted,
|
|
984
|
+
};
|
|
1084
985
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
// make the stream iterator reject as it tears down; that is the expected
|
|
1093
|
-
// teardown for both, so swallow it and fall through (stallDetected ->
|
|
1094
|
-
// Phase 11a; firstDenialDetected -> Phase 12). Anything else is a genuine
|
|
1095
|
-
// stream failure — rethrow it to the outer error handler.
|
|
1096
|
-
if (!stallDetected && !firstDenialDetected) throw streamErr;
|
|
1097
|
-
console.warn(
|
|
1098
|
-
`ExecuteCursor stream ended via cancel: execution=${executionId}, ` +
|
|
1099
|
-
`stall=${stallDetected}, firstDenial=${firstDenialDetected}`,
|
|
1100
|
-
);
|
|
1101
|
-
}
|
|
986
|
+
// Primary stream. consumeCursorTurnStream owns the per-event loop (transcript,
|
|
987
|
+
// todos, sub-agent tracking, live persist, DD-32/DD-33 mid-run progress, the
|
|
988
|
+
// first-denial early stop, and the stall watchdog) and reports why it ended;
|
|
989
|
+
// resolvePreBoundaryTerminal below maps that to a terminal outcome. The two
|
|
990
|
+
// recovery retries drive the identical loop, so they inherit every one of
|
|
991
|
+
// these behaviors instead of the old bare loop that dropped them.
|
|
992
|
+
await consumeCursorTurnStream(run, streamDeps);
|
|
1102
993
|
|
|
1103
994
|
periodicHeartbeat.stop();
|
|
1104
|
-
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
//
|
|
1109
|
-
// signal check catches this case.
|
|
995
|
+
// Worker-shutdown vs. user-pause disambiguation. Primary-only: the periodic
|
|
996
|
+
// heartbeat is stopped here, before any recovery retry runs, so a retry
|
|
997
|
+
// classifies a shutdown from the shutdown signal directly (in
|
|
998
|
+
// resolvePreBoundaryTerminal). The heartbeat timer may set `cancelled` before
|
|
999
|
+
// the AbortSignal microtask propagates; the direct signal check catches that.
|
|
1110
1000
|
const isShutdown = periodicHeartbeat.workerShutdown || (shutdownSignal?.aborted ?? false);
|
|
1111
1001
|
if (isShutdown) {
|
|
1112
|
-
pauseDetected = false;
|
|
1002
|
+
turnState.pauseDetected = false;
|
|
1113
1003
|
} else if (periodicHeartbeat.cancelled) {
|
|
1114
|
-
pauseDetected = true;
|
|
1004
|
+
turnState.pauseDetected = true;
|
|
1115
1005
|
}
|
|
1116
|
-
|
|
1117
1006
|
workerShutdownDetected = isShutdown;
|
|
1118
1007
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
//
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
|
|
1126
|
-
accumulator.
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
try {
|
|
1151
|
-
const { recordTurnMetrics } = await import("../../otel.js");
|
|
1152
|
-
const turnDurationMs = Date.now() - (status.startedAt ? new Date(status.startedAt).getTime() : Date.now());
|
|
1153
|
-
await recordTurnMetrics({
|
|
1154
|
-
durationMs: turnDurationMs,
|
|
1155
|
-
inputTokens: Number(usageSnapshot.inputTokens),
|
|
1156
|
-
outputTokens: Number(usageSnapshot.outputTokens),
|
|
1157
|
-
model: validatedModel,
|
|
1158
|
-
mode: agentMode,
|
|
1159
|
-
});
|
|
1160
|
-
} catch {
|
|
1161
|
-
// Metrics not initialized — silently skip.
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
// Phase 11a: Handle stall, worker shutdown, pause, or infrastructure cancellation.
|
|
1166
|
-
|
|
1167
|
-
// Stall: the watchdog cancelled a turn that made no progress for longer
|
|
1168
|
-
// than config.cursorStreamStallTimeoutMs (a wedged tool call or a dead
|
|
1169
|
-
// model connection). The keep-alive heartbeat proves liveness, so Temporal
|
|
1170
|
-
// never reaps this on its own — this branch is the only clean exit. We
|
|
1171
|
-
// RETURN (not throw): re-running the identical prompt via Temporal retry
|
|
1172
|
-
// would very likely wedge again. accumulator.finalize() above already
|
|
1173
|
-
// cleared isStreaming, so the UI spinners stop.
|
|
1174
|
-
if (stallDetected) {
|
|
1175
|
-
const err = stallError ?? new StallTimeoutError(config.cursorStreamStallTimeoutMs);
|
|
1176
|
-
status.phase = ExecutionPhase.EXECUTION_FAILED;
|
|
1177
|
-
status.error = formatStallFailure(err);
|
|
1178
|
-
status.completedAt = utcTimestamp();
|
|
1179
|
-
status.messages.push(create(AgentMessageSchema, {
|
|
1180
|
-
type: MessageType.MESSAGE_SYSTEM,
|
|
1181
|
-
content: `Execution failed: the agent made no progress for too long and was stopped (${err.message}). You can retry or resume.`,
|
|
1182
|
-
timestamp: utcTimestamp(),
|
|
1183
|
-
}));
|
|
1008
|
+
// Post-stream finalize, shared by the primary turn and both recovery retries:
|
|
1009
|
+
// finalize the transcript + streaming flags, mark any in-flight sub-agent
|
|
1010
|
+
// CANCELLED on an aborted turn, snapshot usage, flush the recorder, and
|
|
1011
|
+
// persist so the UI sees the settled rows. The unified loop applies delta
|
|
1012
|
+
// enrichments per-iteration, so — unlike the old bare retry path — no
|
|
1013
|
+
// compensating applyEnrichments() is needed here.
|
|
1014
|
+
const finalizeStreamPhase = async () => {
|
|
1015
|
+
accumulator.finalize();
|
|
1016
|
+
deltaEnricher.finalize(status.messages);
|
|
1017
|
+
// A pause / cancel / worker shutdown aborts the Cursor SDK run, so any
|
|
1018
|
+
// sub-agent the parent had delegated is no longer executing. Mark it
|
|
1019
|
+
// CANCELLED rather than leaving a permanent IN_PROGRESS "zombie" in the
|
|
1020
|
+
// final snapshot (parity with the native harness's cancelSubAgents()).
|
|
1021
|
+
if (
|
|
1022
|
+
turnState.pauseDetected ||
|
|
1023
|
+
workerShutdownDetected ||
|
|
1024
|
+
turnState.stallDetected ||
|
|
1025
|
+
Context.current().cancellationSignal.aborted
|
|
1026
|
+
) {
|
|
1027
|
+
accumulator.cancelInProgressSubAgents();
|
|
1028
|
+
}
|
|
1029
|
+
status.subAgentExecutions = accumulator.subAgentExecutions;
|
|
1030
|
+
await eventRecorder?.flush();
|
|
1031
|
+
if (usageAccumulator.hasTurns) {
|
|
1032
|
+
status.streamingUsage = create(StreamingUsageSummarySchema, usageAccumulator.snapshot());
|
|
1033
|
+
}
|
|
1034
|
+
console.log(
|
|
1035
|
+
`ExecuteCursor stream ended: execution=${executionId}, events=${turnState.eventCount}, messages=${status.messages.length}, subAgents=${status.subAgentExecutions.length}`,
|
|
1036
|
+
);
|
|
1037
|
+
// Persist immediately after finalize so the UI sees correct tool-call
|
|
1038
|
+
// statuses before the boundary / run.wait() / structured-output extraction.
|
|
1184
1039
|
await persist(status);
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
}
|
|
1040
|
+
heartbeat();
|
|
1041
|
+
};
|
|
1188
1042
|
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1043
|
+
// Pre-boundary terminal handling, shared by the primary turn and both retries
|
|
1044
|
+
// so a retry that stalls, pauses, is cancelled, or is platform-stopped is
|
|
1045
|
+
// mapped IDENTICALLY to the primary — the fix for the mid-retry pause that
|
|
1046
|
+
// used to surface as EXECUTION_FAILED. "proceed" (a normal completion or a
|
|
1047
|
+
// first denial) goes on to the turn boundary; a stall / platform-stop asks
|
|
1048
|
+
// the caller to RETURN a terminal status; a worker-shutdown / pause /
|
|
1049
|
+
// infra-cancel asks the caller to THROW CancelledFailure. The OTel turn span
|
|
1050
|
+
// + metrics are ended once from the finally (finishTurnTelemetry), so they
|
|
1051
|
+
// include any recovery retry and never leak on these exits.
|
|
1052
|
+
type PreBoundaryTerminal =
|
|
1053
|
+
| { kind: "proceed" }
|
|
1054
|
+
| { kind: "return" }
|
|
1055
|
+
| { kind: "throw"; message: string };
|
|
1056
|
+
const resolvePreBoundaryTerminal = async (): Promise<PreBoundaryTerminal> => {
|
|
1057
|
+
// Stall: the watchdog cancelled a turn that made no progress. RETURN (not
|
|
1058
|
+
// throw): re-running the identical prompt via Temporal retry would very
|
|
1059
|
+
// likely wedge again.
|
|
1060
|
+
if (turnState.stallDetected) {
|
|
1061
|
+
const err = turnState.stallError ?? new StallTimeoutError(config.cursorStreamStallTimeoutMs);
|
|
1062
|
+
status.phase = ExecutionPhase.EXECUTION_FAILED;
|
|
1063
|
+
status.error = formatStallFailure(err);
|
|
1064
|
+
status.completedAt = utcTimestamp();
|
|
1065
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
1066
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
1067
|
+
content: `Execution failed: the agent made no progress for too long and was stopped (${err.message}). You can retry or resume.`,
|
|
1068
|
+
timestamp: utcTimestamp(),
|
|
1069
|
+
}));
|
|
1070
|
+
await persist(status);
|
|
1071
|
+
console.warn(`ExecuteCursor stalled: execution=${executionId}, events=${turnState.eventCount}, error=${status.error}`);
|
|
1072
|
+
return { kind: "return" };
|
|
1073
|
+
}
|
|
1204
1074
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1075
|
+
// Worker shutdown: the runner-manager aborted the shutdown signal. NOT a
|
|
1076
|
+
// user pause. Checked via the shutdown signal directly so a retry (whose
|
|
1077
|
+
// periodic heartbeat is already stopped) still classifies it correctly.
|
|
1078
|
+
if (workerShutdownDetected || (shutdownSignal?.aborted ?? false)) {
|
|
1079
|
+
status.phase = ExecutionPhase.EXECUTION_FAILED;
|
|
1080
|
+
status.error = "Execution interrupted: runner worker was shut down. Retry or resume.";
|
|
1081
|
+
status.completedAt = utcTimestamp();
|
|
1082
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
1083
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
1084
|
+
content: "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
|
|
1085
|
+
timestamp: utcTimestamp(),
|
|
1086
|
+
}));
|
|
1087
|
+
await persist(status);
|
|
1088
|
+
console.log(`ExecuteCursor interrupted (worker shutdown): execution=${executionId}, events=${turnState.eventCount}`);
|
|
1089
|
+
return { kind: "throw", message: "Activity cancelled (worker shutdown, not user pause)" };
|
|
1090
|
+
}
|
|
1217
1091
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
throw new CancelledFailure("Activity cancelled (heartbeat timeout, not user pause)");
|
|
1232
|
-
}
|
|
1092
|
+
// pauseDetected is only true if a heartbeat() call threw CancelledFailure,
|
|
1093
|
+
// confirming the orchestrator explicitly requested a pause.
|
|
1094
|
+
if (turnState.pauseDetected) {
|
|
1095
|
+
status.phase = ExecutionPhase.EXECUTION_PAUSED;
|
|
1096
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
1097
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
1098
|
+
content: "Execution paused by user. Use resume to continue.",
|
|
1099
|
+
timestamp: utcTimestamp(),
|
|
1100
|
+
}));
|
|
1101
|
+
await persist(status);
|
|
1102
|
+
console.log(`ExecuteCursor paused: execution=${executionId}, events=${turnState.eventCount}`);
|
|
1103
|
+
return { kind: "throw", message: "Activity paused by orchestrator" };
|
|
1104
|
+
}
|
|
1233
1105
|
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1106
|
+
// Cancellation without pauseDetected (e.g. heartbeat timeout): report as
|
|
1107
|
+
// failed rather than misleadingly labeling it a user pause.
|
|
1108
|
+
if (Context.current().cancellationSignal.aborted) {
|
|
1109
|
+
status.phase = ExecutionPhase.EXECUTION_FAILED;
|
|
1110
|
+
status.error = "Execution interrupted: agent was unresponsive (heartbeat timeout). Retry or resume.";
|
|
1111
|
+
status.completedAt = utcTimestamp();
|
|
1112
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
1113
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
1114
|
+
content: "Execution interrupted: the agent was unresponsive for too long. You can retry or resume.",
|
|
1115
|
+
timestamp: utcTimestamp(),
|
|
1116
|
+
}));
|
|
1117
|
+
await persist(status);
|
|
1118
|
+
console.log(`ExecuteCursor interrupted (infrastructure cancel): execution=${executionId}, events=${turnState.eventCount}`);
|
|
1119
|
+
return { kind: "throw", message: "Activity cancelled (heartbeat timeout, not user pause)" };
|
|
1120
|
+
}
|
|
1247
1121
|
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
// never collapses and renders as RUNNING forever (production case
|
|
1262
|
-
// aex_01kwj07f7g23c3wp9sn8496z5g) — or a late tool could mutate the tree
|
|
1263
|
-
// mid-capture. The timebox keeps a wedged cancel from hanging the pause;
|
|
1264
|
-
// the Phase 12 trims below remain the backstop for that degraded case.
|
|
1265
|
-
if (firstDenialDetected && denialCancelSettled) {
|
|
1266
|
-
await Promise.race([
|
|
1267
|
-
denialCancelSettled,
|
|
1268
|
-
new Promise<void>((resolve) => {
|
|
1269
|
-
const timer = setTimeout(resolve, FIRST_DENIAL_CANCEL_TIMEOUT_MS);
|
|
1270
|
-
timer.unref();
|
|
1271
|
-
}),
|
|
1272
|
-
]);
|
|
1273
|
-
}
|
|
1274
|
-
const deniedLedger = await readDenialLedger(hitlDir ?? "");
|
|
1275
|
-
|
|
1276
|
-
// Capture mode: author the net change set to the file_review ledger as the
|
|
1277
|
-
// CANDIDATE_CAPTURED event (projected server-side to a file_change_set
|
|
1278
|
-
// AWAITING_REVIEW — the single review surface). The runner-owned gate files
|
|
1279
|
-
// are excluded from the capture. The agent's edits are LEFT applied on the
|
|
1280
|
-
// working tree (Cursor parity — the user reviews the real change; nothing is
|
|
1281
|
-
// committed and the next turn is blocked until approval, and a reject snaps
|
|
1282
|
-
// each file back on resume). Runs BEFORE the denial reconcile so a denied
|
|
1283
|
-
// (gitignored) write stays on the deny-gate path while every flowed edit is
|
|
1284
|
-
// captured to the ledger.
|
|
1285
|
-
let capturedChangeCount = 0;
|
|
1286
|
-
// `baselineTree !== undefined` means a baseline was authored this turn — the
|
|
1287
|
-
// git tree sha for a git workspace, or "" (empty, but authored) for a non-git
|
|
1288
|
-
// one. A plain truthiness check would wrongly skip the non-git capture.
|
|
1289
|
-
if (captureMode && baselineTree !== undefined && primaryWorkspaceDir) {
|
|
1290
|
-
const deniedTokens = new Set(deniedLedger.map((e) => e.token));
|
|
1291
|
-
// Approved-command turn facts (DD-28): when every mutation-capable call
|
|
1292
|
-
// this turn was a consented shell command, attach the provenance so the
|
|
1293
|
-
// backend can verify the cited consent rows and auto-keep the set instead
|
|
1294
|
-
// of arming a second gate. Fail-closed: any non-qualifying turn attaches
|
|
1295
|
-
// nothing and reviews manually exactly as before.
|
|
1296
|
-
const commandProvenance = deriveTurnCommandProvenance({
|
|
1297
|
-
messages: status.messages,
|
|
1298
|
-
turnStartIndex: turnStartMessageIndex,
|
|
1299
|
-
deniedTokens,
|
|
1300
|
-
grantTokenToConsentId: new Map(
|
|
1301
|
-
(approvalGrants ?? []).map((g) => [
|
|
1302
|
-
primaryToken(g.key, g.salient, g.contentDigest),
|
|
1303
|
-
g.sourceToolCallId,
|
|
1304
|
-
]),
|
|
1305
|
-
),
|
|
1306
|
-
globalBypass,
|
|
1307
|
-
});
|
|
1308
|
-
if (commandProvenance) {
|
|
1309
|
-
console.log(
|
|
1310
|
-
`ExecuteCursor capture: turn qualifies for approved-command auto-keep ` +
|
|
1311
|
-
`(consent rows: ${commandProvenance.consentToolCallIds.join(",") || "(auto_approve_all)"}); ` +
|
|
1312
|
-
`attaching provenance to candidate (execution=${executionId})`,
|
|
1313
|
-
);
|
|
1122
|
+
// Platform stop signal: a clean COMPLETED early exit.
|
|
1123
|
+
if (turnState.platformStopSignaled) {
|
|
1124
|
+
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|
|
1125
|
+
status.completedAt = utcTimestamp();
|
|
1126
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
1127
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
1128
|
+
content: "Execution stopped by the platform.",
|
|
1129
|
+
timestamp: utcTimestamp(),
|
|
1130
|
+
}));
|
|
1131
|
+
await persist(status);
|
|
1132
|
+
try { resolution.agent.close(); } catch { /* best effort */ }
|
|
1133
|
+
console.log(`ExecuteCursor completed (platform stop): execution=${executionId}`);
|
|
1134
|
+
return { kind: "return" };
|
|
1314
1135
|
}
|
|
1315
|
-
|
|
1136
|
+
|
|
1137
|
+
return { kind: "proceed" };
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
await finalizeStreamPhase();
|
|
1141
|
+
const primaryTerminal = await resolvePreBoundaryTerminal();
|
|
1142
|
+
if (primaryTerminal.kind === "return") return slimStatus(status);
|
|
1143
|
+
if (primaryTerminal.kind === "throw") throw new CancelledFailure(primaryTerminal.message);
|
|
1144
|
+
|
|
1145
|
+
// Phase 12: The turn boundary — author this turn's change set to the
|
|
1146
|
+
// file_review ledger (CANDIDATE_CAPTURED) and overlay the hook's denials as
|
|
1147
|
+
// WAITING_APPROVAL gate rows. The full pipeline and its ordering rationale
|
|
1148
|
+
// live in turn-boundary.ts; this closure binds the turn's state so the
|
|
1149
|
+
// recovery retries below (which re-run the agent AFTER this primary call)
|
|
1150
|
+
// can re-enter the IDENTICAL pipeline — a retry's edits must reach the
|
|
1151
|
+
// ledger or they silently escape review. `baselineTree` is read at call
|
|
1152
|
+
// time, so both entries see the baseline authored at turn start.
|
|
1153
|
+
const runBoundary = (denialSettled?: Promise<void>) =>
|
|
1154
|
+
runTurnBoundary({
|
|
1316
1155
|
status,
|
|
1317
|
-
gitRoot: primaryWorkspaceDir,
|
|
1318
1156
|
executionId,
|
|
1319
1157
|
changeSetId,
|
|
1320
|
-
baselineTree,
|
|
1321
|
-
messages: status.messages,
|
|
1322
|
-
deniedTokens,
|
|
1323
|
-
commandProvenance,
|
|
1324
|
-
// Scope sub-agent row stamping to this turn: the seeded prior sub-agents
|
|
1325
|
-
// (cloned in on resume) are the "before this turn" rows to skip.
|
|
1326
|
-
priorSubAgentToolCallIds: collectSubAgentToolCallIds(seededSubAgents),
|
|
1327
|
-
// The CAS half: read the sidecar the hook staged this turn and compose it
|
|
1328
|
-
// into the change set. hitlDir + storage are present when captureIgnored
|
|
1329
|
-
// was on (a git tree's gitignored writes, or ALL writes in a non-git
|
|
1330
|
-
// workspace). In a git tree this composes with the git diff (HYBRID); in a
|
|
1331
|
-
// non-git workspace it IS the whole change set (CAS-only).
|
|
1332
1158
|
hitlDir,
|
|
1333
|
-
|
|
1159
|
+
captureMode,
|
|
1160
|
+
baselineTree,
|
|
1161
|
+
primaryWorkspaceDir,
|
|
1334
1162
|
gitWorkspace,
|
|
1163
|
+
turnStartMessageIndex,
|
|
1164
|
+
approvalGrants,
|
|
1165
|
+
globalBypass,
|
|
1166
|
+
seededSubAgents,
|
|
1167
|
+
artifactStorage,
|
|
1168
|
+
mergedPolicies,
|
|
1169
|
+
denialCancelSettled: denialSettled,
|
|
1335
1170
|
});
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
`file_review ledger (change_set=${changeSetId}), working tree left applied ` +
|
|
1341
|
-
`for review (execution=${executionId})`,
|
|
1342
|
-
);
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
// The gate reads each denied file's pre-edit `before` from the workspace the
|
|
1347
|
-
// runner is co-located with (local FS for OSS; the sandbox in cloud), so a
|
|
1348
|
-
// whole-file rewrite gate renders a true before/after diff. The tool was
|
|
1349
|
-
// DENIED, so disk still holds the old content. User files are never platform
|
|
1350
|
-
// paths, so no platformDir routing is needed here.
|
|
1351
|
-
const gateWorkspaceBackend = new LocalWorkspaceBackend(primaryWorkspaceDir);
|
|
1352
|
-
const deniedToolCalls = await reconcileDeniedToolCalls(
|
|
1353
|
-
status.messages,
|
|
1354
|
-
deniedLedger,
|
|
1355
|
-
mergedPolicies,
|
|
1356
|
-
gateWorkspaceBackend,
|
|
1357
|
-
);
|
|
1358
|
-
// Observability: a synthesized placeholder (id `approval:*`) means a denial
|
|
1359
|
-
// correlated to NO streamed tool call in either the exact or the normalized
|
|
1360
|
-
// pass. After the normalized-path fallback this should be ~0; a non-zero rate
|
|
1361
|
-
// is the early-warning signal of a NEW identity drift (the gate would then
|
|
1362
|
-
// show "No preview available" with no diff). Logged, not thrown — the
|
|
1363
|
-
// synthesized gate still safely surfaces the approval.
|
|
1364
|
-
const synthesizedGateCount = deniedToolCalls.filter((tc) =>
|
|
1365
|
-
tc.id.startsWith("approval:"),
|
|
1366
|
-
).length;
|
|
1367
|
-
if (synthesizedGateCount > 0) {
|
|
1368
|
-
console.warn(
|
|
1369
|
-
`ExecuteCursor reconcile synthesized ${synthesizedGateCount} placeholder gate(s) ` +
|
|
1370
|
-
`with no correlated stream call (execution=${executionId}); ` +
|
|
1371
|
-
`possible hook/stream identity drift — gate(s) will lack a diff`,
|
|
1372
|
-
);
|
|
1373
|
-
}
|
|
1374
|
-
if (deniedToolCalls.length > 0 || capturedChangeCount > 0) {
|
|
1375
|
-
if (deniedToolCalls.length > 0) {
|
|
1376
|
-
// Deterministic clean-pause: a turn that pauses for approval must read as
|
|
1377
|
-
// the same shape the native harness produces — pre-tool text + the gated
|
|
1378
|
-
// tool calls — never the model's provisional reaction to Cursor's deny
|
|
1379
|
-
// (e.g. "blocked by a hook; enable it in your Cursor settings"). We blank
|
|
1380
|
-
// that reaction in place (keeping the message count, so the finalize stays
|
|
1381
|
-
// append-only) rather than removing it. See
|
|
1382
|
-
// clearProvisionalPostDenialNarration for the full rationale.
|
|
1383
|
-
const redactedNarration = clearProvisionalPostDenialNarration(status.messages, deniedToolCalls);
|
|
1384
|
-
if (redactedNarration.length > 0) {
|
|
1385
|
-
console.log(
|
|
1386
|
-
`ExecuteCursor redacted ${redactedNarration.length} provisional post-denial narration message(s) before pausing for approval`,
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1171
|
+
// Pauses for review exactly like the native harness: the boundary mutated
|
|
1172
|
+
// the transcript in place; we flip the phase, persist, and RETURN to the
|
|
1173
|
+
// workflow, which waits for the approval/file-review signal and reinvokes.
|
|
1174
|
+
const enterApprovalPause = async (boundary: TurnBoundaryResult) => {
|
|
1390
1175
|
status.phase = ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL;
|
|
1391
1176
|
await persist(status);
|
|
1392
1177
|
console.log(
|
|
1393
|
-
`ExecuteCursor returning WAITING_FOR_APPROVAL: ${
|
|
1394
|
-
`${capturedChangeCount} file card(s) pending`,
|
|
1178
|
+
`ExecuteCursor returning WAITING_FOR_APPROVAL: ${boundary.deniedToolCallCount} gated tool(s), ` +
|
|
1179
|
+
`${boundary.capturedChangeCount} file card(s) pending`,
|
|
1395
1180
|
);
|
|
1396
1181
|
return slimStatus(status);
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
// Re-enter the turn boundary for a recovery retry: author the retry's net
|
|
1185
|
+
// change set to the file_review ledger and overlay any denials as gates —
|
|
1186
|
+
// without this a retry's file edits silently escape review (production case
|
|
1187
|
+
// aex_01kws27q1e2esvkqjpvectttxf). The stream finalize now runs through the
|
|
1188
|
+
// shared finalizeStreamPhase (in runRecoveryStream), so this is only the
|
|
1189
|
+
// boundary + completedAt. Returns undefined for a cancelled retry — there is
|
|
1190
|
+
// no review to open. Passes denialCancelSettled so a first denial that stopped
|
|
1191
|
+
// the RETRY waits for run.cancel() before the ledger read, exactly like the
|
|
1192
|
+
// primary path.
|
|
1193
|
+
const settleRetryTurn = async (
|
|
1194
|
+
retryResultStatus: string,
|
|
1195
|
+
): Promise<TurnBoundaryResult | undefined> => {
|
|
1196
|
+
const retryBoundary =
|
|
1197
|
+
retryResultStatus === "cancelled"
|
|
1198
|
+
? undefined
|
|
1199
|
+
: await runBoundary(
|
|
1200
|
+
turnState.firstDenialDetected ? turnState.denialCancelSettled : undefined,
|
|
1201
|
+
);
|
|
1202
|
+
// Phase 13 stamped completedAt BEFORE the retry ran. A terminal outcome
|
|
1203
|
+
// re-stamps it to the true end; a review pause CLEARS it — the primary
|
|
1204
|
+
// pause path never stamps it (a waiting turn is not complete).
|
|
1205
|
+
status.completedAt = retryBoundary?.waiting ? "" : utcTimestamp();
|
|
1206
|
+
return retryBoundary;
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
// The shared recovery spine. A fresh agent runs the IDENTICAL stream loop,
|
|
1210
|
+
// finalize, and pre-boundary terminal handling as the primary turn, then — on
|
|
1211
|
+
// a normal completion or a first denial — waits and re-enters the boundary.
|
|
1212
|
+
// The two recovery call sites below differ only in how they build the fresh
|
|
1213
|
+
// agent/prompt and how they classify a retry ERROR; everything the primary
|
|
1214
|
+
// does mid-stream (live persist, DD-32/DD-33 mid-run progress, sub-agent
|
|
1215
|
+
// tracking, the first-denial stop, and correct pause/stall/platform-stop
|
|
1216
|
+
// mapping) they now inherit for free instead of the old bare loop.
|
|
1217
|
+
type RecoveryOutcome =
|
|
1218
|
+
| { proceeded: false; terminal: Exclude<PreBoundaryTerminal, { kind: "proceed" }> }
|
|
1219
|
+
| {
|
|
1220
|
+
proceeded: true;
|
|
1221
|
+
retryRun: Run;
|
|
1222
|
+
retryResult: Awaited<ReturnType<Run["wait"]>>;
|
|
1223
|
+
retryBoundary: TurnBoundaryResult | undefined;
|
|
1224
|
+
};
|
|
1225
|
+
const runRecoveryStream = async (
|
|
1226
|
+
freshAgent: AgentResolution["agent"],
|
|
1227
|
+
retryPrompt: string,
|
|
1228
|
+
): Promise<RecoveryOutcome> => {
|
|
1229
|
+
// The fresh agent is now the live handle: point resolution at it so the
|
|
1230
|
+
// terminal close() (platform stop, or Phase 14 success) frees THIS agent's
|
|
1231
|
+
// executor lease rather than the disposed one it replaced. (Without this the
|
|
1232
|
+
// poisoned-handle path leaked the fresh agent — it closed the stale one.)
|
|
1233
|
+
resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
|
|
1234
|
+
turnState.streamErrorMessage = undefined;
|
|
1235
|
+
const retryRun = await freshAgent.send(retryPrompt, {
|
|
1236
|
+
onDelta: makeCursorTurnOnDelta(onDeltaDeps),
|
|
1237
|
+
});
|
|
1238
|
+
await consumeCursorTurnStream(retryRun, streamDeps);
|
|
1239
|
+
await finalizeStreamPhase();
|
|
1240
|
+
const terminal = await resolvePreBoundaryTerminal();
|
|
1241
|
+
if (terminal.kind !== "proceed") return { proceeded: false, terminal };
|
|
1242
|
+
const retryResult = await retryRun.wait();
|
|
1243
|
+
console.log(
|
|
1244
|
+
`ExecuteCursor retry run.wait(): execution=${executionId}, ` +
|
|
1245
|
+
`retryResult=${JSON.stringify(retryResult)}`,
|
|
1246
|
+
);
|
|
1247
|
+
const retryBoundary = await settleRetryTurn(retryResult.status);
|
|
1248
|
+
return { proceeded: true, retryRun, retryResult, retryBoundary };
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// The denial-settle wait applies only when a first denial stopped THIS run;
|
|
1252
|
+
// a normal completion passes no promise.
|
|
1253
|
+
const boundary = await runBoundary(
|
|
1254
|
+
turnState.firstDenialDetected ? turnState.denialCancelSettled : undefined,
|
|
1255
|
+
);
|
|
1256
|
+
if (boundary.waiting) {
|
|
1257
|
+
return enterApprovalPause(boundary);
|
|
1397
1258
|
}
|
|
1398
1259
|
|
|
1399
1260
|
// Phase 13: Map final result
|
|
@@ -1432,7 +1293,7 @@ async function executeCursorInner(
|
|
|
1432
1293
|
|
|
1433
1294
|
const classified = synthesizeError({
|
|
1434
1295
|
sdkResultFields: sdkErrorStr,
|
|
1435
|
-
streamErrorMessage,
|
|
1296
|
+
streamErrorMessage: turnState.streamErrorMessage,
|
|
1436
1297
|
capturedRejection,
|
|
1437
1298
|
conversationErrorText,
|
|
1438
1299
|
isResumedHandle: resolution.reason === "resumed_successfully",
|
|
@@ -1498,49 +1359,23 @@ async function executeCursorInner(
|
|
|
1498
1359
|
console.warn("Failed to update session with fresh agentId (non-fatal):", updateErr);
|
|
1499
1360
|
}
|
|
1500
1361
|
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
if (update.type === "turn-ended" && update.usage) {
|
|
1506
|
-
usageAccumulator.addTurn(update.usage);
|
|
1507
|
-
}
|
|
1508
|
-
deltaEnricher.processDelta(update);
|
|
1509
|
-
try { heartbeat(); } catch { /* swallow during retry */ }
|
|
1510
|
-
},
|
|
1511
|
-
});
|
|
1512
|
-
// Mirror the primary stream's stall protection: a wedged retry must
|
|
1513
|
-
// not hang the activity. On stall, cancel the run so the loop ends and
|
|
1514
|
-
// retryRun.wait() resolves down the existing non-finished failure path.
|
|
1515
|
-
retryWatchdog = startStallWatchdog(config.cursorStreamStallTimeoutMs, (idleMs) => {
|
|
1516
|
-
console.warn(`ExecuteCursor retry stall detected: execution=${executionId}, idleMs=${idleMs}`);
|
|
1517
|
-
if (retryRun.supports?.("cancel")) void retryRun.cancel().catch(() => { /* best effort */ });
|
|
1518
|
-
});
|
|
1519
|
-
|
|
1520
|
-
streamErrorMessage = undefined;
|
|
1521
|
-
|
|
1522
|
-
try {
|
|
1523
|
-
for await (const retryEvent of retryRun.stream()) {
|
|
1524
|
-
if (Context.current().cancellationSignal.aborted) break;
|
|
1525
|
-
retryWatchdog.recordActivity();
|
|
1526
|
-
accumulator.processEvent(retryEvent);
|
|
1527
|
-
if (retryEvent.type === "status") {
|
|
1528
|
-
const retryStatusEvent = retryEvent as { status?: string; message?: string };
|
|
1529
|
-
if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
|
|
1530
|
-
streamErrorMessage = retryStatusEvent.message;
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
heartbeat();
|
|
1534
|
-
}
|
|
1535
|
-
} finally {
|
|
1536
|
-
retryWatchdog.stop();
|
|
1362
|
+
const outcome = await runRecoveryStream(freshAgent, freshPrompt);
|
|
1363
|
+
if (!outcome.proceeded) {
|
|
1364
|
+
if (outcome.terminal.kind === "return") return slimStatus(status);
|
|
1365
|
+
throw new CancelledFailure(outcome.terminal.message);
|
|
1537
1366
|
}
|
|
1538
1367
|
|
|
1539
|
-
const retryResult =
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1368
|
+
const { retryRun, retryResult, retryBoundary } = outcome;
|
|
1369
|
+
if (retryBoundary?.waiting) {
|
|
1370
|
+
// The retry's edits/denials armed the gate — pause for review. On a
|
|
1371
|
+
// retry error this supersedes the failure, exactly as on the primary
|
|
1372
|
+
// path (a captured change pauses the turn before run.wait() is
|
|
1373
|
+
// consulted).
|
|
1374
|
+
console.log(
|
|
1375
|
+
`ExecuteCursor poisoned-handle recovery paused for review: execution=${executionId}`,
|
|
1376
|
+
);
|
|
1377
|
+
return enterApprovalPause(retryBoundary);
|
|
1378
|
+
}
|
|
1544
1379
|
|
|
1545
1380
|
if (retryResult.status === "finished") {
|
|
1546
1381
|
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|
|
@@ -1562,7 +1397,7 @@ async function executeCursorInner(
|
|
|
1562
1397
|
|
|
1563
1398
|
const retryClassified = synthesizeError({
|
|
1564
1399
|
sdkResultFields: retryResult.result ? String(retryResult.result) : undefined,
|
|
1565
|
-
streamErrorMessage,
|
|
1400
|
+
streamErrorMessage: turnState.streamErrorMessage,
|
|
1566
1401
|
capturedRejection: retryRejection,
|
|
1567
1402
|
conversationErrorText: retryConversationErrorText,
|
|
1568
1403
|
isResumedHandle: false,
|
|
@@ -1607,48 +1442,24 @@ async function executeCursorInner(
|
|
|
1607
1442
|
console.warn("Failed to update session with fresh agentId (non-fatal):", updateErr);
|
|
1608
1443
|
}
|
|
1609
1444
|
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
usageAccumulator.addTurn(update.usage);
|
|
1616
|
-
}
|
|
1617
|
-
deltaEnricher.processDelta(update);
|
|
1618
|
-
try { heartbeat(); } catch { /* swallow during retry */ }
|
|
1619
|
-
},
|
|
1620
|
-
});
|
|
1621
|
-
// Mirror the primary stream's stall protection: a wedged retry must
|
|
1622
|
-
// not hang the activity. On stall, cancel the run so the loop ends and
|
|
1623
|
-
// retryRun.wait() resolves down the existing non-finished failure path.
|
|
1624
|
-
retryWatchdog = startStallWatchdog(config.cursorStreamStallTimeoutMs, (idleMs) => {
|
|
1625
|
-
console.warn(`ExecuteCursor retry stall detected: execution=${executionId}, idleMs=${idleMs}`);
|
|
1626
|
-
if (retryRun.supports?.("cancel")) void retryRun.cancel().catch(() => { /* best effort */ });
|
|
1627
|
-
});
|
|
1628
|
-
|
|
1629
|
-
streamErrorMessage = undefined;
|
|
1445
|
+
const outcome = await runRecoveryStream(freshAgent, effectivePrompt);
|
|
1446
|
+
if (!outcome.proceeded) {
|
|
1447
|
+
if (outcome.terminal.kind === "return") return slimStatus(status);
|
|
1448
|
+
throw new CancelledFailure(outcome.terminal.message);
|
|
1449
|
+
}
|
|
1630
1450
|
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
streamErrorMessage = retryStatusEvent.message;
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
heartbeat();
|
|
1643
|
-
}
|
|
1644
|
-
} finally {
|
|
1645
|
-
retryWatchdog.stop();
|
|
1451
|
+
const { retryResult, retryBoundary } = outcome;
|
|
1452
|
+
if (retryBoundary?.waiting) {
|
|
1453
|
+
// The retry's edits/denials armed the gate — pause for review (see
|
|
1454
|
+
// the poisoned-handle branch above for the precedence rationale).
|
|
1455
|
+
console.log(
|
|
1456
|
+
`ExecuteCursor transport-timeout recovery paused for review: execution=${executionId}`,
|
|
1457
|
+
);
|
|
1458
|
+
return enterApprovalPause(retryBoundary);
|
|
1646
1459
|
}
|
|
1647
1460
|
|
|
1648
|
-
const retryResult = await retryRun.wait();
|
|
1649
1461
|
if (retryResult.status === "finished") {
|
|
1650
1462
|
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|
|
1651
|
-
resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
|
|
1652
1463
|
break;
|
|
1653
1464
|
}
|
|
1654
1465
|
|
|
@@ -1723,10 +1534,10 @@ async function executeCursorInner(
|
|
|
1723
1534
|
status.structuredOutput = structuredOutput as JsonObject;
|
|
1724
1535
|
}
|
|
1725
1536
|
|
|
1726
|
-
// Plan mode: publish the final plan message as a plan
|
|
1727
|
-
// Cursor harness has no auto-publish
|
|
1728
|
-
// artifact path; build storage from the
|
|
1729
|
-
// native harness uses.
|
|
1537
|
+
// Plan mode: publish the final plan message as a plan artifact (named
|
|
1538
|
+
// from the plan's title). The Cursor harness has no auto-publish
|
|
1539
|
+
// pipeline, so this is the only artifact path; build storage from the
|
|
1540
|
+
// same config-driven factory the native harness uses.
|
|
1730
1541
|
if (interactionMode === InteractionMode.PLAN && finalText && artifactStorage) {
|
|
1731
1542
|
try {
|
|
1732
1543
|
await publishPlanArtifact({ status, executionId, planText: finalText, artifactStorage });
|
|
@@ -1792,7 +1603,7 @@ async function executeCursorInner(
|
|
|
1792
1603
|
content: "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
|
|
1793
1604
|
timestamp: utcTimestamp(),
|
|
1794
1605
|
}));
|
|
1795
|
-
} else if (pauseDetected) {
|
|
1606
|
+
} else if (turnState.pauseDetected) {
|
|
1796
1607
|
console.log(`ExecuteCursor cancelled (pause) for execution ${executionId}`);
|
|
1797
1608
|
status.phase = ExecutionPhase.EXECUTION_PAUSED;
|
|
1798
1609
|
status.messages.push(create(AgentMessageSchema, {
|
|
@@ -1821,7 +1632,7 @@ async function executeCursorInner(
|
|
|
1821
1632
|
// treat the execution as paused rather than failed. The error was likely
|
|
1822
1633
|
// caused by the cancellation (e.g. SDK stream teardown) and should not
|
|
1823
1634
|
// overwrite the PAUSED state that the Pause RPC already set in the DB.
|
|
1824
|
-
if (pauseDetected) {
|
|
1635
|
+
if (turnState.pauseDetected) {
|
|
1825
1636
|
const errDetail = err instanceof Error ? err.message : String(err);
|
|
1826
1637
|
console.log(
|
|
1827
1638
|
`ExecuteCursor error during pause (treating as pause): execution=${executionId}, error=${errDetail}`,
|
|
@@ -1924,10 +1735,16 @@ async function executeCursorInner(
|
|
|
1924
1735
|
|
|
1925
1736
|
return slimStatus(status);
|
|
1926
1737
|
} finally {
|
|
1927
|
-
//
|
|
1928
|
-
// path
|
|
1929
|
-
//
|
|
1930
|
-
|
|
1738
|
+
// End the OTel turn span + record metrics with the final token snapshot on
|
|
1739
|
+
// EVERY exit path (idempotent). Placed here so the span covers any recovery
|
|
1740
|
+
// retry (whose tokens accrue after the primary stream) and never leaks on an
|
|
1741
|
+
// early return or throw. A no-op when OTel is off or the span never opened.
|
|
1742
|
+
await finishTurnTelemetry?.();
|
|
1743
|
+
|
|
1744
|
+
// Disarm the stall watchdog on EVERY exit path (idempotent). consumeCursorTurnStream
|
|
1745
|
+
// stops the one it armed; this covers throws before that point so no orphaned
|
|
1746
|
+
// timer survives the activity.
|
|
1747
|
+
turnState.stallWatchdog?.stop();
|
|
1931
1748
|
|
|
1932
1749
|
// Close the denial-ledger watcher on EVERY exit path (idempotent) so no
|
|
1933
1750
|
// orphaned fs.watch handle survives the activity.
|