@stigmer/runner 3.1.2 → 3.1.3
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 +18 -0
- package/dist/activities/execute-cursor/capture-flow.js +21 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +121 -139
- package/dist/activities/execute-cursor/index.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-deep-agent/index.js +30 -3
- package/dist/activities/execute-deep-agent/index.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/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-substrate.d.ts +9 -0
- package/dist/shared/filereview/cas-substrate.js +9 -1
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- 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 +42 -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 +3 -1
- package/dist/shared/filereview/index.js +3 -1
- 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 +96 -0
- package/dist/shared/filereview/progress.js +134 -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 +6 -4
- package/dist/shared/plan-mode-prompt.js +10 -5
- package/dist/shared/plan-mode-prompt.js.map +1 -1
- package/package.json +3 -2
- 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__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/attachment-resolver.ts +1 -1
- package/src/activities/execute-cursor/capture-flow.ts +32 -0
- package/src/activities/execute-cursor/index.ts +130 -153
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +11 -0
- package/src/activities/execute-deep-agent/index.ts +34 -3
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/shared/__tests__/implement-plan-prompt.test.ts +40 -2
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -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 +236 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-substrate.ts +17 -1
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +108 -0
- package/src/shared/filereview/index.ts +22 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +186 -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 +10 -5
|
@@ -43,9 +43,9 @@ 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 {
|
|
48
|
+
import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
|
|
49
49
|
import { startStallWatchdog, StallTimeoutError, formatStallFailure, type StallWatchdog } 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";
|
|
@@ -74,16 +74,20 @@ import {
|
|
|
74
74
|
type ReleaseWorkspaceLock,
|
|
75
75
|
} from "../../shared/workspace/workspace-lock.js";
|
|
76
76
|
import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
|
|
77
|
-
import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts,
|
|
78
|
-
import { deriveTurnCommandProvenance } from "./command-provenance.js";
|
|
77
|
+
import { buildApprovalState, buildApprovalGrants, emitCursorGrantReceipts, readDenialLedger, reconstructAdjudicatedApprovals, watchDenialLedger } from "./approval-state.js";
|
|
79
78
|
import { applyApprovedWholeFileWrites, excludeAppliedFromGrants } from "./exact-apply.js";
|
|
80
79
|
import { isGitWorkTree } from "../../shared/filereview/git-substrate.js";
|
|
81
80
|
import {
|
|
82
81
|
captureBaselineToLedger,
|
|
83
|
-
|
|
82
|
+
captureProgressToStatus,
|
|
84
83
|
applyCaptureDecisions,
|
|
85
84
|
deriveCaptureMode,
|
|
86
85
|
} from "./capture-flow.js";
|
|
86
|
+
import { runTurnBoundary, type TurnBoundaryResult } from "./turn-boundary.js";
|
|
87
|
+
import {
|
|
88
|
+
newProgressCaptureState,
|
|
89
|
+
type ProgressCaptureState,
|
|
90
|
+
} from "../../shared/filereview/progress.js";
|
|
87
91
|
import { deriveExecutionFingerprintKey } from "../../shared/approval-fingerprint.js";
|
|
88
92
|
import { getRunnerHitlMasterSecret } from "../../shared/fingerprint-secret.js";
|
|
89
93
|
import { provisionCursorWorkspace } from "./workspace-provision.js";
|
|
@@ -102,12 +106,6 @@ import { setMaxListeners } from "node:events";
|
|
|
102
106
|
import { startHeartbeat } from "../../shared/heartbeat.js";
|
|
103
107
|
import { getShutdownSignalForQueue } from "../../runner-manager.js";
|
|
104
108
|
|
|
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
109
|
/**
|
|
112
110
|
* Creates the activity functions bound to the runner config.
|
|
113
111
|
* Returned object is passed to Temporal Worker.create({ activities }).
|
|
@@ -304,6 +302,9 @@ async function executeCursorInner(
|
|
|
304
302
|
// Pre-turn baseline tree, pinned before the agent runs (capture mode only)
|
|
305
303
|
// so the turn-end capture diffs against it and the tree restores exactly.
|
|
306
304
|
let baselineTree: string | undefined;
|
|
305
|
+
// Per-turn state for mid-run live capture (DD-32): the last progress tree sha
|
|
306
|
+
// (short-circuit) + last capture time (floor), threaded across persists.
|
|
307
|
+
const progressState: ProgressCaptureState = newProgressCaptureState();
|
|
307
308
|
// Deterministic id of the change set this turn may produce:
|
|
308
309
|
// `${executionId}:${turnSeq}`. Minted from the workflow-threaded turn index
|
|
309
310
|
// so it is stable across a Temporal retry (idempotent ledger authoring) and
|
|
@@ -1068,6 +1069,22 @@ async function executeCursorInner(
|
|
|
1068
1069
|
// accumulator tracked sub-agents in memory but they only reached the
|
|
1069
1070
|
// status (and the subscriber stream) after the loop ended.
|
|
1070
1071
|
status.subAgentExecutions = accumulator.subAgentExecutions;
|
|
1072
|
+
// Mid-run live capture (DD-32): attach the "N files changed so far"
|
|
1073
|
+
// snapshot onto status.file_change_progress, throttled internally by the
|
|
1074
|
+
// floor + tree-sha short-circuit. Git capture mode only (a pinned baseline
|
|
1075
|
+
// exists); shell + sub-agent + tool edits are all captured for free by the
|
|
1076
|
+
// workspace-wide diff. Never authoritative — the turn-boundary candidate
|
|
1077
|
+
// remains the reviewed diff.
|
|
1078
|
+
if (captureMode && gitWorkspace && baselineTree && primaryWorkspaceDir) {
|
|
1079
|
+
await captureProgressToStatus({
|
|
1080
|
+
status,
|
|
1081
|
+
gitRoot: primaryWorkspaceDir,
|
|
1082
|
+
executionId,
|
|
1083
|
+
changeSetId,
|
|
1084
|
+
baselineTree,
|
|
1085
|
+
state: progressState,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1071
1088
|
const signal = await persist(status);
|
|
1072
1089
|
deltaEnricher.markPersisted();
|
|
1073
1090
|
todoTracker.markPersisted();
|
|
@@ -1245,155 +1262,88 @@ async function executeCursorInner(
|
|
|
1245
1262
|
return slimStatus(status);
|
|
1246
1263
|
}
|
|
1247
1264
|
|
|
1248
|
-
// Phase 12:
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
// run.cancel() to settle. run.cancel() races the SDK's auto-execution: until
|
|
1259
|
-
// it lands, the agent process may still attempt a post-denial workaround
|
|
1260
|
-
// whose hook denial would land AFTER a premature ledger read — the row then
|
|
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
|
-
);
|
|
1314
|
-
}
|
|
1315
|
-
const captured = await captureTurnToLedger({
|
|
1265
|
+
// Phase 12: The turn boundary — author this turn's change set to the
|
|
1266
|
+
// file_review ledger (CANDIDATE_CAPTURED) and overlay the hook's denials as
|
|
1267
|
+
// WAITING_APPROVAL gate rows. The full pipeline and its ordering rationale
|
|
1268
|
+
// live in turn-boundary.ts; this closure binds the turn's state so the
|
|
1269
|
+
// recovery retries below (which re-run the agent AFTER this primary call)
|
|
1270
|
+
// can re-enter the IDENTICAL pipeline — a retry's edits must reach the
|
|
1271
|
+
// ledger or they silently escape review. `baselineTree` is read at call
|
|
1272
|
+
// time, so both entries see the baseline authored at turn start.
|
|
1273
|
+
const runBoundary = (denialSettled?: Promise<void>) =>
|
|
1274
|
+
runTurnBoundary({
|
|
1316
1275
|
status,
|
|
1317
|
-
gitRoot: primaryWorkspaceDir,
|
|
1318
1276
|
executionId,
|
|
1319
1277
|
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
1278
|
hitlDir,
|
|
1333
|
-
|
|
1279
|
+
captureMode,
|
|
1280
|
+
baselineTree,
|
|
1281
|
+
primaryWorkspaceDir,
|
|
1334
1282
|
gitWorkspace,
|
|
1283
|
+
turnStartMessageIndex,
|
|
1284
|
+
approvalGrants,
|
|
1285
|
+
globalBypass,
|
|
1286
|
+
seededSubAgents,
|
|
1287
|
+
artifactStorage,
|
|
1288
|
+
mergedPolicies,
|
|
1289
|
+
denialCancelSettled: denialSettled,
|
|
1335
1290
|
});
|
|
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
|
-
}
|
|
1291
|
+
// Pauses for review exactly like the native harness: the boundary mutated
|
|
1292
|
+
// the transcript in place; we flip the phase, persist, and RETURN to the
|
|
1293
|
+
// workflow, which waits for the approval/file-review signal and reinvokes.
|
|
1294
|
+
const enterApprovalPause = async (boundary: TurnBoundaryResult) => {
|
|
1390
1295
|
status.phase = ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL;
|
|
1391
1296
|
await persist(status);
|
|
1392
1297
|
console.log(
|
|
1393
|
-
`ExecuteCursor returning WAITING_FOR_APPROVAL: ${
|
|
1394
|
-
`${capturedChangeCount} file card(s) pending`,
|
|
1298
|
+
`ExecuteCursor returning WAITING_FOR_APPROVAL: ${boundary.deniedToolCallCount} gated tool(s), ` +
|
|
1299
|
+
`${boundary.capturedChangeCount} file card(s) pending`,
|
|
1395
1300
|
);
|
|
1396
1301
|
return slimStatus(status);
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
// Stream epilogue + boundary re-entry for a recovery retry (poisoned-handle /
|
|
1305
|
+
// transport-timeout). The retry re-runs the agent AFTER the primary epilogue
|
|
1306
|
+
// and boundary already ran, so its stream state must be settled the same way:
|
|
1307
|
+
// - flush the enricher's buffered deltas onto the rows (the primary loop
|
|
1308
|
+
// applies them every iteration; the bare retry loop does not — without the
|
|
1309
|
+
// flush, a completed tool call never receives its completedAt evidence and
|
|
1310
|
+
// finalize's reconciliation sweep leaves it RUNNING forever);
|
|
1311
|
+
// - finalize streaming state and sync sub-agents;
|
|
1312
|
+
// - refresh streaming usage (the retry's turns accumulated in memory only)
|
|
1313
|
+
// and re-stamp completedAt (stamped below BEFORE the retry ran);
|
|
1314
|
+
// - re-enter the turn boundary so edits made by the retry reach the
|
|
1315
|
+
// file_review ledger / approval gates — without this, a retry's file
|
|
1316
|
+
// edits silently escape review (production case
|
|
1317
|
+
// aex_01kws27q1e2esvkqjpvectttxf).
|
|
1318
|
+
// Returns undefined for a cancelled retry: parity with the primary path,
|
|
1319
|
+
// where cancellation exits before the boundary — there is no review to open.
|
|
1320
|
+
const settleRetryTurn = async (
|
|
1321
|
+
retryResultStatus: string,
|
|
1322
|
+
): Promise<TurnBoundaryResult | undefined> => {
|
|
1323
|
+
accumulator.finalize();
|
|
1324
|
+
deltaEnricher.applyEnrichments(status.messages);
|
|
1325
|
+
deltaEnricher.finalize(status.messages);
|
|
1326
|
+
status.subAgentExecutions = accumulator.subAgentExecutions;
|
|
1327
|
+
if (usageAccumulator.hasTurns) {
|
|
1328
|
+
status.streamingUsage = create(StreamingUsageSummarySchema, usageAccumulator.snapshot());
|
|
1329
|
+
}
|
|
1330
|
+
// Re-flush the (dev-only) event recorder: flush rewrites the full JSONL,
|
|
1331
|
+
// so the recorded trace now includes the retry's events too.
|
|
1332
|
+
await eventRecorder?.flush();
|
|
1333
|
+
const retryBoundary =
|
|
1334
|
+
retryResultStatus === "cancelled" ? undefined : await runBoundary();
|
|
1335
|
+
// Phase 13 stamped completedAt BEFORE the retry ran. A terminal outcome
|
|
1336
|
+
// re-stamps it to the true end; a review pause CLEARS it — the primary
|
|
1337
|
+
// pause path never stamps it (a waiting turn is not complete).
|
|
1338
|
+
status.completedAt = retryBoundary?.waiting ? "" : utcTimestamp();
|
|
1339
|
+
return retryBoundary;
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
// The denial-settle wait applies only when a first denial stopped THIS run;
|
|
1343
|
+
// the recovery retries have no early stop and pass no promise.
|
|
1344
|
+
const boundary = await runBoundary(firstDenialDetected ? denialCancelSettled : undefined);
|
|
1345
|
+
if (boundary.waiting) {
|
|
1346
|
+
return enterApprovalPause(boundary);
|
|
1397
1347
|
}
|
|
1398
1348
|
|
|
1399
1349
|
// Phase 13: Map final result
|
|
@@ -1523,7 +1473,9 @@ async function executeCursorInner(
|
|
|
1523
1473
|
for await (const retryEvent of retryRun.stream()) {
|
|
1524
1474
|
if (Context.current().cancellationSignal.aborted) break;
|
|
1525
1475
|
retryWatchdog.recordActivity();
|
|
1476
|
+
eventRecorder?.record(retryEvent, eventCount);
|
|
1526
1477
|
accumulator.processEvent(retryEvent);
|
|
1478
|
+
eventCount++;
|
|
1527
1479
|
if (retryEvent.type === "status") {
|
|
1528
1480
|
const retryStatusEvent = retryEvent as { status?: string; message?: string };
|
|
1529
1481
|
if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
|
|
@@ -1542,6 +1494,18 @@ async function executeCursorInner(
|
|
|
1542
1494
|
`retryResult=${JSON.stringify(retryResult)}`,
|
|
1543
1495
|
);
|
|
1544
1496
|
|
|
1497
|
+
const retryBoundary = await settleRetryTurn(retryResult.status);
|
|
1498
|
+
if (retryBoundary?.waiting) {
|
|
1499
|
+
// The retry's edits/denials armed the gate — pause for review. On a
|
|
1500
|
+
// retry error this supersedes the failure, exactly as on the primary
|
|
1501
|
+
// path (a captured change pauses the turn before run.wait() is
|
|
1502
|
+
// consulted).
|
|
1503
|
+
console.log(
|
|
1504
|
+
`ExecuteCursor poisoned-handle recovery paused for review: execution=${executionId}`,
|
|
1505
|
+
);
|
|
1506
|
+
return enterApprovalPause(retryBoundary);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1545
1509
|
if (retryResult.status === "finished") {
|
|
1546
1510
|
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|
|
1547
1511
|
console.log(
|
|
@@ -1632,7 +1596,9 @@ async function executeCursorInner(
|
|
|
1632
1596
|
for await (const retryEvent of retryRun.stream()) {
|
|
1633
1597
|
if (Context.current().cancellationSignal.aborted) break;
|
|
1634
1598
|
retryWatchdog.recordActivity();
|
|
1599
|
+
eventRecorder?.record(retryEvent, eventCount);
|
|
1635
1600
|
accumulator.processEvent(retryEvent);
|
|
1601
|
+
eventCount++;
|
|
1636
1602
|
if (retryEvent.type === "status") {
|
|
1637
1603
|
const retryStatusEvent = retryEvent as { status?: string; message?: string };
|
|
1638
1604
|
if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
|
|
@@ -1646,6 +1612,17 @@ async function executeCursorInner(
|
|
|
1646
1612
|
}
|
|
1647
1613
|
|
|
1648
1614
|
const retryResult = await retryRun.wait();
|
|
1615
|
+
const retryBoundary = await settleRetryTurn(retryResult.status);
|
|
1616
|
+
if (retryBoundary?.waiting) {
|
|
1617
|
+
// The retry's edits/denials armed the gate — pause for review (see
|
|
1618
|
+
// the poisoned-handle branch above for the precedence rationale).
|
|
1619
|
+
console.log(
|
|
1620
|
+
`ExecuteCursor transport-timeout recovery paused for review: execution=${executionId}`,
|
|
1621
|
+
);
|
|
1622
|
+
resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
|
|
1623
|
+
return enterApprovalPause(retryBoundary);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1649
1626
|
if (retryResult.status === "finished") {
|
|
1650
1627
|
status.phase = ExecutionPhase.EXECUTION_COMPLETED;
|
|
1651
1628
|
resolution = { ...resolution, agent: freshAgent, agentId: freshAgent.agentId, isNew: true };
|
|
@@ -1723,10 +1700,10 @@ async function executeCursorInner(
|
|
|
1723
1700
|
status.structuredOutput = structuredOutput as JsonObject;
|
|
1724
1701
|
}
|
|
1725
1702
|
|
|
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.
|
|
1703
|
+
// Plan mode: publish the final plan message as a plan artifact (named
|
|
1704
|
+
// from the plan's title). The Cursor harness has no auto-publish
|
|
1705
|
+
// pipeline, so this is the only artifact path; build storage from the
|
|
1706
|
+
// same config-driven factory the native harness uses.
|
|
1730
1707
|
if (interactionMode === InteractionMode.PLAN && finalText && artifactStorage) {
|
|
1731
1708
|
try {
|
|
1732
1709
|
await publishPlanArtifact({ status, executionId, planText: finalText, artifactStorage });
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Cursor harness's turn boundary — the single post-run pipeline that turns
|
|
3
|
+
* a finished agent run into the durable review surfaces:
|
|
4
|
+
*
|
|
5
|
+
* 1. read the denial ledger the preToolUse hook appended this turn;
|
|
6
|
+
* 2. derive the approved-command provenance (DD-28 auto-keep facts);
|
|
7
|
+
* 3. capture the turn's net file change set to the file_review ledger
|
|
8
|
+
* (CANDIDATE_CAPTURED) and stamp the flowed edit rows;
|
|
9
|
+
* 4. reconcile denied tool calls to WAITING_APPROVAL gate rows and redact the
|
|
10
|
+
* model's provisional post-denial narration.
|
|
11
|
+
*
|
|
12
|
+
* Extracted from the activity entry point (index.ts Phase 12) so it is directly
|
|
13
|
+
* unit-testable AND re-enterable: the poisoned-handle / transport-timeout
|
|
14
|
+
* recoveries re-run the agent with a fresh handle AFTER the primary boundary
|
|
15
|
+
* already ran, so their edits must flow through this exact pipeline again or
|
|
16
|
+
* they silently escape review (production case aex_01kws27q1e2esvkqjpvectttxf,
|
|
17
|
+
* where a Build-from-plan retry created a file with no review gate).
|
|
18
|
+
*
|
|
19
|
+
* Re-entry is safe by construction: a retry is only reachable when the primary
|
|
20
|
+
* boundary captured nothing (a captured change pauses the turn before
|
|
21
|
+
* run.wait() is ever consulted), `stampFlowedFileEditRows` skips already-stamped
|
|
22
|
+
* rows, and the denial ledger is per-turn append-only.
|
|
23
|
+
*
|
|
24
|
+
* The caller owns everything around the boundary: the stream epilogue
|
|
25
|
+
* (accumulator/enricher finalize), the WAITING_FOR_APPROVAL phase flip +
|
|
26
|
+
* persist, and the terminal result mapping.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
30
|
+
import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
31
|
+
import { collectSubAgentToolCallIds } from "../../shared/tool-row.js";
|
|
32
|
+
import { LocalWorkspaceBackend } from "../../shared/workspace/local-backend.js";
|
|
33
|
+
import type { ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
34
|
+
import type { MergedToolPolicy } from "../../shared/approval-policy.js";
|
|
35
|
+
import { primaryToken, readDenialLedger, type ApprovalGrant } from "./approval-state.js";
|
|
36
|
+
import { deriveTurnCommandProvenance } from "./command-provenance.js";
|
|
37
|
+
import { captureTurnToLedger } from "./capture-flow.js";
|
|
38
|
+
import {
|
|
39
|
+
clearProvisionalPostDenialNarration,
|
|
40
|
+
reconcileDeniedToolCalls,
|
|
41
|
+
} from "./message-translator.js";
|
|
42
|
+
|
|
43
|
+
// How long the boundary waits for the first-denial-stop's run.cancel() to
|
|
44
|
+
// settle before reading the final denial ledger and capturing the turn's tree.
|
|
45
|
+
// Long enough for the SDK's normal teardown, short enough that a wedged cancel
|
|
46
|
+
// cannot noticeably delay the approval pause the user is already waiting on.
|
|
47
|
+
const FIRST_DENIAL_CANCEL_TIMEOUT_MS = 5_000;
|
|
48
|
+
|
|
49
|
+
export interface TurnBoundaryOptions {
|
|
50
|
+
/** Mutated in place: gate rows overlaid, edit rows stamped, narration redacted. */
|
|
51
|
+
readonly status: AgentExecutionStatus;
|
|
52
|
+
readonly executionId: string;
|
|
53
|
+
/** This turn's change set id (`{executionId}:{turnSeq}`). */
|
|
54
|
+
readonly changeSetId: string;
|
|
55
|
+
/** Session HITL dir holding the denial ledger + CAS sidecar; undefined → no gate installed. */
|
|
56
|
+
readonly hitlDir: string | undefined;
|
|
57
|
+
/** Whether this turn runs apply-then-review capture (vs. the deny-gate fallback). */
|
|
58
|
+
readonly captureMode: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The baseline authored at turn start — the git tree sha for a git workspace,
|
|
61
|
+
* or "" (empty, but authored) for a non-git one. `undefined` means no baseline
|
|
62
|
+
* was authored this turn, which skips capture entirely; a plain truthiness
|
|
63
|
+
* check would wrongly skip the non-git capture.
|
|
64
|
+
*/
|
|
65
|
+
readonly baselineTree: string | undefined;
|
|
66
|
+
readonly primaryWorkspaceDir: string;
|
|
67
|
+
/** True for a git work tree; false for a CAS-only non-git workspace. */
|
|
68
|
+
readonly gitWorkspace: boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Index of the first message produced by THIS turn's stream — the positional
|
|
71
|
+
* turn boundary the approved-command provenance (DD-28) scopes to.
|
|
72
|
+
*/
|
|
73
|
+
readonly turnStartMessageIndex: number;
|
|
74
|
+
/** Reinvocation grants; their tokens map back to the consent rows for DD-28. */
|
|
75
|
+
readonly approvalGrants: ApprovalGrant[] | undefined;
|
|
76
|
+
/** spec.auto_approve_all — qualifies every command as consented for DD-28. */
|
|
77
|
+
readonly globalBypass: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Sub-agent executions that existed BEFORE this turn's stream (cloned in on
|
|
80
|
+
* resume); their rows are skipped when stamping so a resume never re-stamps a
|
|
81
|
+
* prior turn's sub-agent rows.
|
|
82
|
+
*/
|
|
83
|
+
readonly seededSubAgents: readonly SubAgentExecution[];
|
|
84
|
+
/** CAS blob store for gitignored/non-git captures; undefined → git-only capture. */
|
|
85
|
+
readonly artifactStorage: ArtifactStorage | undefined;
|
|
86
|
+
/** Merged approval policies, threaded to the denied-call reconcile for gate provenance. */
|
|
87
|
+
readonly mergedPolicies: Map<string, MergedToolPolicy>;
|
|
88
|
+
/**
|
|
89
|
+
* The first-denial-stop's run.cancel() promise, when a denial stopped this
|
|
90
|
+
* run. Awaited (timeboxed) before the ledger read: run.cancel() races the
|
|
91
|
+
* SDK's auto-execution — until it lands, the agent process may still attempt
|
|
92
|
+
* a post-denial workaround whose hook denial would land AFTER a premature
|
|
93
|
+
* ledger read (the row then never collapses and renders as RUNNING forever —
|
|
94
|
+
* production case aex_01kwj07f7g23c3wp9sn8496z5g), or a late tool could
|
|
95
|
+
* mutate the tree mid-capture. Omit when no denial stopped the run (the
|
|
96
|
+
* normal completion path and the recovery retries, which have no early stop).
|
|
97
|
+
*/
|
|
98
|
+
readonly denialCancelSettled?: Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface TurnBoundaryResult {
|
|
102
|
+
/**
|
|
103
|
+
* True when the turn must pause for human review — at least one gated tool
|
|
104
|
+
* call or one captured file change. The caller flips the phase to
|
|
105
|
+
* WAITING_FOR_APPROVAL, persists, and returns without consulting run.wait().
|
|
106
|
+
*/
|
|
107
|
+
readonly waiting: boolean;
|
|
108
|
+
/** File changes authored to the file_review ledger this call (0 = no candidate). */
|
|
109
|
+
readonly capturedChangeCount: number;
|
|
110
|
+
/** Denied tool calls reconciled to WAITING_APPROVAL gate rows this call. */
|
|
111
|
+
readonly deniedToolCallCount: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run the turn boundary: author this turn's change set to the file_review
|
|
116
|
+
* ledger and overlay the hook's denials as approval gates. Mutates
|
|
117
|
+
* `opts.status` in place and reports whether the turn must pause.
|
|
118
|
+
*
|
|
119
|
+
* The hook records each denial to the ledger; we mark the corresponding tool
|
|
120
|
+
* calls WAITING_APPROVAL. The backend projects pending_approvals from that
|
|
121
|
+
* tool-call status (PendingApprovalComputer), so — exactly like the native
|
|
122
|
+
* harness — the approval surface is driven entirely by tool-call status. We
|
|
123
|
+
* deliberately do NOT set status.pendingApprovals here: any value would be
|
|
124
|
+
* discarded by the backend's recompute on the next updateStatus.
|
|
125
|
+
*/
|
|
126
|
+
export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBoundaryResult> {
|
|
127
|
+
const {
|
|
128
|
+
status,
|
|
129
|
+
executionId,
|
|
130
|
+
changeSetId,
|
|
131
|
+
hitlDir,
|
|
132
|
+
captureMode,
|
|
133
|
+
baselineTree,
|
|
134
|
+
primaryWorkspaceDir,
|
|
135
|
+
gitWorkspace,
|
|
136
|
+
turnStartMessageIndex,
|
|
137
|
+
approvalGrants,
|
|
138
|
+
globalBypass,
|
|
139
|
+
seededSubAgents,
|
|
140
|
+
artifactStorage,
|
|
141
|
+
mergedPolicies,
|
|
142
|
+
denialCancelSettled,
|
|
143
|
+
} = opts;
|
|
144
|
+
|
|
145
|
+
// The timebox keeps a wedged cancel from hanging the pause; the reconcile
|
|
146
|
+
// trims below remain the backstop for that degraded case.
|
|
147
|
+
if (denialCancelSettled) {
|
|
148
|
+
await Promise.race([
|
|
149
|
+
denialCancelSettled,
|
|
150
|
+
new Promise<void>((resolve) => {
|
|
151
|
+
const timer = setTimeout(resolve, FIRST_DENIAL_CANCEL_TIMEOUT_MS);
|
|
152
|
+
timer.unref();
|
|
153
|
+
}),
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
const deniedLedger = await readDenialLedger(hitlDir ?? "");
|
|
157
|
+
|
|
158
|
+
// Capture mode: author the net change set to the file_review ledger as the
|
|
159
|
+
// CANDIDATE_CAPTURED event (projected server-side to a file_change_set
|
|
160
|
+
// AWAITING_REVIEW — the single review surface). The runner-owned gate files
|
|
161
|
+
// are excluded from the capture. The agent's edits are LEFT applied on the
|
|
162
|
+
// working tree (Cursor parity — the user reviews the real change; nothing is
|
|
163
|
+
// committed and the next turn is blocked until approval, and a reject snaps
|
|
164
|
+
// each file back on resume). Runs BEFORE the denial reconcile so a denied
|
|
165
|
+
// (gitignored) write stays on the deny-gate path while every flowed edit is
|
|
166
|
+
// captured to the ledger.
|
|
167
|
+
let capturedChangeCount = 0;
|
|
168
|
+
if (captureMode && baselineTree !== undefined && primaryWorkspaceDir) {
|
|
169
|
+
const deniedTokens = new Set(deniedLedger.map((e) => e.token));
|
|
170
|
+
// Approved-command turn facts (DD-28): when every mutation-capable call
|
|
171
|
+
// this turn was a consented shell command, attach the provenance so the
|
|
172
|
+
// backend can verify the cited consent rows and auto-keep the set instead
|
|
173
|
+
// of arming a second gate. Fail-closed: any non-qualifying turn attaches
|
|
174
|
+
// nothing and reviews manually exactly as before.
|
|
175
|
+
const commandProvenance = deriveTurnCommandProvenance({
|
|
176
|
+
messages: status.messages,
|
|
177
|
+
turnStartIndex: turnStartMessageIndex,
|
|
178
|
+
deniedTokens,
|
|
179
|
+
grantTokenToConsentId: new Map(
|
|
180
|
+
(approvalGrants ?? []).map((g) => [
|
|
181
|
+
primaryToken(g.key, g.salient, g.contentDigest),
|
|
182
|
+
g.sourceToolCallId,
|
|
183
|
+
]),
|
|
184
|
+
),
|
|
185
|
+
globalBypass,
|
|
186
|
+
});
|
|
187
|
+
if (commandProvenance) {
|
|
188
|
+
console.log(
|
|
189
|
+
`ExecuteCursor capture: turn qualifies for approved-command auto-keep ` +
|
|
190
|
+
`(consent rows: ${commandProvenance.consentToolCallIds.join(",") || "(auto_approve_all)"}); ` +
|
|
191
|
+
`attaching provenance to candidate (execution=${executionId})`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const captured = await captureTurnToLedger({
|
|
195
|
+
status,
|
|
196
|
+
gitRoot: primaryWorkspaceDir,
|
|
197
|
+
executionId,
|
|
198
|
+
changeSetId,
|
|
199
|
+
baselineTree,
|
|
200
|
+
messages: status.messages,
|
|
201
|
+
deniedTokens,
|
|
202
|
+
commandProvenance,
|
|
203
|
+
// Scope sub-agent row stamping to this turn: the seeded prior sub-agents
|
|
204
|
+
// (cloned in on resume) are the "before this turn" rows to skip.
|
|
205
|
+
priorSubAgentToolCallIds: collectSubAgentToolCallIds(seededSubAgents),
|
|
206
|
+
// The CAS half: read the sidecar the hook staged this turn and compose it
|
|
207
|
+
// into the change set. hitlDir + storage are present when captureIgnored
|
|
208
|
+
// was on (a git tree's gitignored writes, or ALL writes in a non-git
|
|
209
|
+
// workspace). In a git tree this composes with the git diff (HYBRID); in a
|
|
210
|
+
// non-git workspace it IS the whole change set (CAS-only).
|
|
211
|
+
hitlDir,
|
|
212
|
+
storage: artifactStorage,
|
|
213
|
+
gitWorkspace,
|
|
214
|
+
});
|
|
215
|
+
capturedChangeCount = captured.length;
|
|
216
|
+
if (capturedChangeCount > 0) {
|
|
217
|
+
console.log(
|
|
218
|
+
`ExecuteCursor capture: ${capturedChangeCount} file change(s) authored to the ` +
|
|
219
|
+
`file_review ledger (change_set=${changeSetId}), working tree left applied ` +
|
|
220
|
+
`for review (execution=${executionId})`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The gate reads each denied file's pre-edit `before` from the workspace the
|
|
226
|
+
// runner is co-located with (local FS for OSS; the sandbox in cloud), so a
|
|
227
|
+
// whole-file rewrite gate renders a true before/after diff. The tool was
|
|
228
|
+
// DENIED, so disk still holds the old content. User files are never platform
|
|
229
|
+
// paths, so no platformDir routing is needed here.
|
|
230
|
+
const gateWorkspaceBackend = new LocalWorkspaceBackend(primaryWorkspaceDir);
|
|
231
|
+
const deniedToolCalls = await reconcileDeniedToolCalls(
|
|
232
|
+
status.messages,
|
|
233
|
+
deniedLedger,
|
|
234
|
+
mergedPolicies,
|
|
235
|
+
gateWorkspaceBackend,
|
|
236
|
+
);
|
|
237
|
+
// Observability: a synthesized placeholder (id `approval:*`) means a denial
|
|
238
|
+
// correlated to NO streamed tool call in either the exact or the normalized
|
|
239
|
+
// pass. After the normalized-path fallback this should be ~0; a non-zero rate
|
|
240
|
+
// is the early-warning signal of a NEW identity drift (the gate would then
|
|
241
|
+
// show "No preview available" with no diff). Logged, not thrown — the
|
|
242
|
+
// synthesized gate still safely surfaces the approval.
|
|
243
|
+
const synthesizedGateCount = deniedToolCalls.filter((tc) =>
|
|
244
|
+
tc.id.startsWith("approval:"),
|
|
245
|
+
).length;
|
|
246
|
+
if (synthesizedGateCount > 0) {
|
|
247
|
+
console.warn(
|
|
248
|
+
`ExecuteCursor reconcile synthesized ${synthesizedGateCount} placeholder gate(s) ` +
|
|
249
|
+
`with no correlated stream call (execution=${executionId}); ` +
|
|
250
|
+
`possible hook/stream identity drift — gate(s) will lack a diff`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
if (deniedToolCalls.length > 0) {
|
|
254
|
+
// Deterministic clean-pause: a turn that pauses for approval must read as
|
|
255
|
+
// the same shape the native harness produces — pre-tool text + the gated
|
|
256
|
+
// tool calls — never the model's provisional reaction to Cursor's deny
|
|
257
|
+
// (e.g. "blocked by a hook; enable it in your Cursor settings"). We blank
|
|
258
|
+
// that reaction in place (keeping the message count, so the finalize stays
|
|
259
|
+
// append-only) rather than removing it. See
|
|
260
|
+
// clearProvisionalPostDenialNarration for the full rationale.
|
|
261
|
+
const redactedNarration = clearProvisionalPostDenialNarration(status.messages, deniedToolCalls);
|
|
262
|
+
if (redactedNarration.length > 0) {
|
|
263
|
+
console.log(
|
|
264
|
+
`ExecuteCursor redacted ${redactedNarration.length} provisional post-denial narration message(s) before pausing for approval`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
waiting: deniedToolCalls.length > 0 || capturedChangeCount > 0,
|
|
271
|
+
capturedChangeCount,
|
|
272
|
+
deniedToolCallCount: deniedToolCalls.length,
|
|
273
|
+
};
|
|
274
|
+
}
|