@stigmer/runner 3.0.8-dev.20260613085218 → 3.0.8
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/hook-script.d.ts +23 -12
- package/dist/activities/execute-cursor/hook-script.js +85 -51
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +210 -79
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +35 -0
- package/dist/activities/execute-cursor/message-translator.js +114 -6
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +25 -0
- package/dist/activities/execute-cursor/prompt-builder.js +54 -0
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +8 -2
- package/dist/activities/execute-cursor/workspace-setup.js +62 -30
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +14 -4
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.js +32 -8
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +4 -5
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +3 -4
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +8 -0
- package/dist/activities/execute-deep-agent/streaming.js +3 -4
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-tracker.js +4 -5
- package/dist/activities/execute-deep-agent/subagent-tracker.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -5
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/config.d.ts +21 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/in-flight.d.ts +35 -0
- package/dist/in-flight.js +61 -0
- package/dist/in-flight.js.map +1 -0
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +90 -29
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -0
- package/dist/runner.js.map +1 -1
- package/dist/shared/grpc-retry.d.ts +9 -20
- package/dist/shared/grpc-retry.js +9 -52
- package/dist/shared/grpc-retry.js.map +1 -1
- package/dist/shared/stall-watchdog.d.ts +68 -0
- package/dist/shared/stall-watchdog.js +102 -0
- package/dist/shared/stall-watchdog.js.map +1 -0
- package/dist/shared/status-offload.d.ts +84 -0
- package/dist/shared/status-offload.js +292 -0
- package/dist/shared/status-offload.js.map +1 -0
- package/dist/shared/status.d.ts +34 -3
- package/dist/shared/status.js +102 -9
- package/dist/shared/status.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/config.test.ts +8 -0
- package/src/__tests__/in-flight.test.ts +84 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +74 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +90 -15
- package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +244 -0
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +53 -4
- package/src/activities/execute-cursor/hook-script.ts +85 -51
- package/src/activities/execute-cursor/index.ts +170 -35
- package/src/activities/execute-cursor/message-translator.ts +113 -6
- package/src/activities/execute-cursor/prompt-builder.ts +59 -0
- package/src/activities/execute-cursor/workspace-setup.ts +76 -44
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +66 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +6 -3
- package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +70 -0
- package/src/activities/execute-deep-agent/index.ts +16 -4
- package/src/activities/execute-deep-agent/status-builder-shared.ts +27 -5
- package/src/activities/execute-deep-agent/status-builder.ts +3 -5
- package/src/activities/execute-deep-agent/streaming-v3.ts +4 -4
- package/src/activities/execute-deep-agent/streaming.ts +13 -4
- package/src/activities/execute-deep-agent/subagent-tracker.ts +4 -5
- package/src/activities/execute-deep-agent/v3-status-builder.ts +5 -5
- package/src/config.ts +27 -0
- package/src/in-flight.ts +71 -0
- package/src/runner-manager.ts +127 -33
- package/src/runner.ts +6 -0
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/grpc-retry-extended.test.ts +6 -144
- package/src/shared/__tests__/grpc-retry.test.ts +5 -134
- package/src/shared/__tests__/stall-watchdog.test.ts +193 -0
- package/src/shared/__tests__/status-offload.test.ts +256 -0
- package/src/shared/__tests__/status.test.ts +199 -0
- package/src/shared/grpc-retry.ts +9 -72
- package/src/shared/stall-watchdog.ts +122 -0
- package/src/shared/status-offload.ts +342 -0
- package/src/shared/status.ts +142 -8
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Template for the
|
|
2
|
+
* Template for the approval hook script that Cursor spawns.
|
|
3
3
|
*
|
|
4
|
-
* This module doesn't execute as a hook itself — it generates the shell
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* This module doesn't execute as a hook itself — it generates the shell script
|
|
5
|
+
* written to the HITL dir as stigmer-approval.sh. Cursor invokes that ONE script
|
|
6
|
+
* for TWO events (registered in .cursor/hooks.json by workspace-setup.ts):
|
|
7
|
+
* - `preToolUse` — fires for built-in tools (Write/Shell/Delete/…).
|
|
8
|
+
* - `beforeMCPExecution` — the only event Cursor enforces for MCP tool calls;
|
|
9
|
+
* `preToolUse` does NOT gate MCP (confirmed by a live
|
|
10
|
+
* payload capture). MCP is therefore gated in exactly
|
|
11
|
+
* ONE place, so a denial is never double-recorded.
|
|
12
|
+
* The script branches on the payload's `hook_event_name`: MCP tools are gated on
|
|
13
|
+
* the beforeMCPExecution invocation, built-ins on the preToolUse invocation.
|
|
7
14
|
*
|
|
8
15
|
* The hook script:
|
|
9
16
|
* 1. Reads the tool call JSON from stdin
|
|
10
17
|
* 2. Reads the approval state JSON file written by the cursor-runner
|
|
11
|
-
* 3. Evaluates the policy: auto-approve, approved grants (reinvocation),
|
|
12
|
-
* gated built-in tools
|
|
18
|
+
* 3. Evaluates the policy: auto-approve, approved grants (reinvocation), then —
|
|
19
|
+
* by event — gated built-in tools (preToolUse) or MCP require-approval
|
|
20
|
+
* policies (beforeMCPExecution)
|
|
13
21
|
* 4. On a deny, appends the call's identity token to the denial ledger
|
|
14
22
|
* (stigmer-denials.jsonl) so the runner can mark the gated tool call as
|
|
15
23
|
* WAITING_APPROVAL — the hook is the only place the deny decision is made,
|
|
@@ -49,23 +57,35 @@
|
|
|
49
57
|
* Policy evaluation order (first match wins). The model is "gate the dangerous
|
|
50
58
|
* set, allow the rest" — matching the native harness and avoiding denial of
|
|
51
59
|
* auto-approved MCP tools (which are absent from mcpToolPolicies):
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
60
|
+
* 0. Scope guard: not the runner's own agent → allow (never touch the ledger)
|
|
61
|
+
* 1. Missing state file → deny (fail-closed); autoApproveAll → allow
|
|
62
|
+
* 2. beforeMCPExecution event → MCP tool present in mcpToolPolicies
|
|
63
|
+
* (require-approval):
|
|
64
|
+
* a. name token in approvedGrantTokens → allow (reinvocation grant)
|
|
55
65
|
* b. otherwise → record denial, deny
|
|
56
|
-
*
|
|
57
|
-
*
|
|
66
|
+
* (auto-approved / unlisted MCP tools fall through → allow)
|
|
67
|
+
* 3. preToolUse event → gated built-in (category non-empty):
|
|
68
|
+
* a. identity token in approvedGrantTokens → allow (reinvocation grant)
|
|
58
69
|
* b. otherwise → record denial, deny
|
|
59
|
-
*
|
|
70
|
+
* (read-only / ungated built-ins fall through → allow)
|
|
60
71
|
*/
|
|
61
72
|
|
|
62
73
|
import { SALIENT_ARG_FIELDS, getBuiltInGatedCategories } from "./approval-policy.js";
|
|
63
74
|
|
|
75
|
+
// Shown to the model when the gate denies a tool call. It must NOT teach the
|
|
76
|
+
// model to ask for permission in prose or to "stop and wait" — that framing
|
|
77
|
+
// makes the model narrate approval requests instead of invoking tools (the
|
|
78
|
+
// platform's approval surface is driven by tool invocation; see
|
|
79
|
+
// formatToolApprovalProtocol in prompt-builder.ts). Instead it tells the model
|
|
80
|
+
// the approval is automatic and that it should not retry or work around THIS
|
|
81
|
+
// action. Embedded verbatim into the generated hook script inside a
|
|
82
|
+
// single-quoted bash echo of a JSON object, so the text must contain no double
|
|
83
|
+
// quotes, apostrophes, or backslashes.
|
|
64
84
|
const APPROVAL_REQUIRED_AGENT_MESSAGE =
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
85
|
+
"This action has been submitted to the user for approval automatically; you " +
|
|
86
|
+
"do not need to ask for permission. Do not retry it or attempt a workaround " +
|
|
87
|
+
"for this action. The platform will resume you automatically after the user " +
|
|
88
|
+
"responds — continue with the rest of the task.";
|
|
69
89
|
|
|
70
90
|
/**
|
|
71
91
|
* Build the bash `case` arms that map an incoming hook `tool_name` to its
|
|
@@ -91,9 +111,11 @@ function buildCategoryCaseArms(): string {
|
|
|
91
111
|
* Build the inline Node.js identity extractor embedded in the hook script.
|
|
92
112
|
*
|
|
93
113
|
* Parses the hook's stdin JSON properly (the bash fallback's grep truncates
|
|
94
|
-
* string values at the first escaped quote) and emits
|
|
95
|
-
*
|
|
96
|
-
*
|
|
114
|
+
* string values at the first escaped quote) and emits five lines: tool_name,
|
|
115
|
+
* canonical category, identity token, MCP name-token, and hook_event_name (the
|
|
116
|
+
* event discriminator: `preToolUse` for built-ins, `beforeMCPExecution` for MCP).
|
|
117
|
+
* The token encodings must stay byte-identical to grantToken() in
|
|
118
|
+
* approval-state.ts.
|
|
97
119
|
*
|
|
98
120
|
* Authored as a single-quoted bash string, so the JS must not contain single
|
|
99
121
|
* quotes. The category map and salient field list are baked from
|
|
@@ -115,7 +137,8 @@ function buildNodeIdentityScript(): string {
|
|
|
115
137
|
`let s="";`,
|
|
116
138
|
`for(const f of ${fields}){const v=a[f];if(typeof v==="string"&&v){s=v;break;}}`,
|
|
117
139
|
`const b=(x)=>Buffer.from(x,"utf8").toString("base64");`,
|
|
118
|
-
`
|
|
140
|
+
`const ev=typeof t.hook_event_name==="string"?t.hook_event_name:"";`,
|
|
141
|
+
`process.stdout.write(name+"\\n"+cat+"\\n"+b(cat+"\\n"+s)+"\\n"+b(name+"\\n")+"\\n"+ev);`,
|
|
119
142
|
].join("");
|
|
120
143
|
}
|
|
121
144
|
|
|
@@ -156,13 +179,15 @@ export function generateHookScript(
|
|
|
156
179
|
const categoryCaseArms = buildCategoryCaseArms();
|
|
157
180
|
const nodeIdentityScript = buildNodeIdentityScript();
|
|
158
181
|
return `#!/bin/bash
|
|
159
|
-
# Stigmer HITL approval hook for Cursor preToolUse
|
|
182
|
+
# Stigmer HITL approval hook for Cursor (preToolUse + beforeMCPExecution).
|
|
160
183
|
# Generated by cursor-runner — do not edit manually.
|
|
161
184
|
#
|
|
162
|
-
# Reads tool call from stdin (JSON), checks approval state file, returns a
|
|
163
|
-
# permission decision on stdout (JSON).
|
|
164
|
-
#
|
|
165
|
-
#
|
|
185
|
+
# Reads a tool call from stdin (JSON), checks the approval state file, returns a
|
|
186
|
+
# permission decision on stdout (JSON). Branches on hook_event_name: MCP tools
|
|
187
|
+
# are gated on beforeMCPExecution, built-ins on preToolUse (preToolUse does not
|
|
188
|
+
# enforce MCP). On a deny, appends the call's canonical identity token to the
|
|
189
|
+
# denial ledger so the runner can mark the gated tool call as WAITING_APPROVAL.
|
|
190
|
+
# See hook-script.ts for the cross-taxonomy identity design.
|
|
166
191
|
|
|
167
192
|
set -euo pipefail
|
|
168
193
|
|
|
@@ -219,6 +244,7 @@ if [ -n "$IDENTITY" ]; then
|
|
|
219
244
|
CATEGORY=$(printf '%s\\n' "$IDENTITY" | sed -n 2p)
|
|
220
245
|
TOKEN=$(printf '%s\\n' "$IDENTITY" | sed -n 3p)
|
|
221
246
|
MCP_TOKEN=$(printf '%s\\n' "$IDENTITY" | sed -n 4p)
|
|
247
|
+
HOOK_EVENT=$(printf '%s\\n' "$IDENTITY" | sed -n 5p)
|
|
222
248
|
else
|
|
223
249
|
# Fallback when the Node binary cannot run: grep/cut extraction. Best-effort
|
|
224
250
|
# only — '"field":"[^"]*"' truncates at the first JSON-escaped quote, so the
|
|
@@ -227,6 +253,7 @@ else
|
|
|
227
253
|
# Every extraction ends with '|| true': under 'set -e' a non-matching grep
|
|
228
254
|
# would otherwise abort the script and emit no decision.
|
|
229
255
|
TOOL_NAME=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
256
|
+
HOOK_EVENT=$(echo "$INPUT" | grep -o '"hook_event_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
230
257
|
SALIENT=""
|
|
231
258
|
for field in ${salientFields}; do
|
|
232
259
|
v=$(echo "$INPUT" | grep -o "\\"$field\\":\\"[^\\"]*\\"" | head -1 | cut -d'"' -f4 || true)
|
|
@@ -262,7 +289,37 @@ record_denial() {
|
|
|
262
289
|
echo '{"toolName":"'"$TOOL_NAME"'","token":"'"$1"'"}' >> "$LEDGER_FILE" 2>/dev/null || true
|
|
263
290
|
}
|
|
264
291
|
|
|
265
|
-
# --- 2.
|
|
292
|
+
# --- 2. MCP tools (beforeMCPExecution event) ---
|
|
293
|
+
# preToolUse does NOT enforce gating for MCP calls — beforeMCPExecution does — so
|
|
294
|
+
# MCP is gated here and ONLY here (never double-recorded). mcpToolPolicies holds
|
|
295
|
+
# only require-approval tools (auto-approved MCP tools are absent), so presence
|
|
296
|
+
# means "deny" unless an entry is explicitly false. MCP tool names are consistent
|
|
297
|
+
# across the hook and the stream, so the identity token is name-only:
|
|
298
|
+
# base64("$TOOL_NAME\\n").
|
|
299
|
+
if [ "$HOOK_EVENT" = "beforeMCPExecution" ]; then
|
|
300
|
+
if echo "$STATE" | grep -q "\\"mcpToolPolicies\\"" && [ -n "$TOOL_NAME" ]; then
|
|
301
|
+
TOOL_POLICY=$(echo "$STATE" | grep -o "\\"$TOOL_NAME\\":{[^}]*}" | head -1 || true)
|
|
302
|
+
if [ -n "$TOOL_POLICY" ] && ! echo "$TOOL_POLICY" | grep -q '"requiresApproval":false'; then
|
|
303
|
+
# Reinvocation grant: this tool was approved earlier → allow.
|
|
304
|
+
if echo "$STATE" | grep -qF "\\"$MCP_TOKEN\\""; then
|
|
305
|
+
echo '{"permission":"allow"}'
|
|
306
|
+
exit 0
|
|
307
|
+
fi
|
|
308
|
+
MSG=$(echo "$TOOL_POLICY" | grep -o '"message":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
309
|
+
if [ -z "$MSG" ]; then
|
|
310
|
+
MSG="Tool requires approval: $TOOL_NAME"
|
|
311
|
+
fi
|
|
312
|
+
record_denial "$MCP_TOKEN"
|
|
313
|
+
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"'"$MSG"'"}'
|
|
314
|
+
exit 0
|
|
315
|
+
fi
|
|
316
|
+
fi
|
|
317
|
+
# Auto-approved or unlisted MCP tool → allow.
|
|
318
|
+
echo '{"permission":"allow"}'
|
|
319
|
+
exit 0
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
# --- 3. Gated built-in tools (preToolUse event, category non-empty) ---
|
|
266
323
|
if [ -n "$CATEGORY" ]; then
|
|
267
324
|
# Reinvocation grant: this exact resource was approved earlier → allow.
|
|
268
325
|
if echo "$STATE" | grep -qF "\\"$TOKEN\\""; then
|
|
@@ -274,32 +331,9 @@ if [ -n "$CATEGORY" ]; then
|
|
|
274
331
|
exit 0
|
|
275
332
|
fi
|
|
276
333
|
|
|
277
|
-
# --- 3. MCP tools that require approval → deny ---
|
|
278
|
-
# mcpToolPolicies holds only require-approval tools (auto-approved MCP tools are
|
|
279
|
-
# absent), so presence means "deny" unless an entry is explicitly false. MCP tool
|
|
280
|
-
# names are consistent across the hook and the stream, so the identity token is
|
|
281
|
-
# name-only: base64("$TOOL_NAME\\n").
|
|
282
|
-
if echo "$STATE" | grep -q "\\"mcpToolPolicies\\"" && [ -n "$TOOL_NAME" ]; then
|
|
283
|
-
TOOL_POLICY=$(echo "$STATE" | grep -o "\\"$TOOL_NAME\\":{[^}]*}" | head -1 || true)
|
|
284
|
-
if [ -n "$TOOL_POLICY" ] && ! echo "$TOOL_POLICY" | grep -q '"requiresApproval":false'; then
|
|
285
|
-
if echo "$STATE" | grep -qF "\\"$MCP_TOKEN\\""; then
|
|
286
|
-
echo '{"permission":"allow"}'
|
|
287
|
-
exit 0
|
|
288
|
-
fi
|
|
289
|
-
MSG=$(echo "$TOOL_POLICY" | grep -o '"message":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
290
|
-
if [ -z "$MSG" ]; then
|
|
291
|
-
MSG="Tool requires approval: $TOOL_NAME"
|
|
292
|
-
fi
|
|
293
|
-
record_denial "$MCP_TOKEN"
|
|
294
|
-
echo '{"permission":"deny","agent_message":"${APPROVAL_REQUIRED_AGENT_MESSAGE}","user_message":"'"$MSG"'"}'
|
|
295
|
-
exit 0
|
|
296
|
-
fi
|
|
297
|
-
fi
|
|
298
|
-
|
|
299
334
|
# --- 4. Everything else → allow ---
|
|
300
|
-
# Read-only built-ins
|
|
301
|
-
#
|
|
302
|
-
# rest) and prevents denying auto-approved MCP tools the state cannot enumerate.
|
|
335
|
+
# Read-only built-ins and anything not explicitly gated. Fail-open mirrors the
|
|
336
|
+
# native harness (gate the dangerous set, allow the rest).
|
|
303
337
|
echo '{"permission":"allow"}'
|
|
304
338
|
exit 0
|
|
305
339
|
`;
|
|
@@ -43,7 +43,8 @@ import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_p
|
|
|
43
43
|
import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
|
|
44
44
|
import { MessageAccumulator, reconcileDeniedToolCalls, cancelInProgressSubAgentProtos } from "./message-translator.js";
|
|
45
45
|
import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
|
|
46
|
-
import {
|
|
46
|
+
import { startStallWatchdog, StallTimeoutError, formatStallFailure, type StallWatchdog } from "../../shared/stall-watchdog.js";
|
|
47
|
+
import { createArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
|
|
47
48
|
import { publishPlanArtifact } from "../../shared/plan-artifact.js";
|
|
48
49
|
import { DeltaEnricher } from "./delta-enricher.js";
|
|
49
50
|
import { TodoTracker } from "./todo-tracker.js";
|
|
@@ -127,11 +128,43 @@ async function executeCursorInner(
|
|
|
127
128
|
startedAt: utcTimestamp(),
|
|
128
129
|
});
|
|
129
130
|
|
|
131
|
+
// Artifact storage for offloading oversized tool outputs (screenshots, giant
|
|
132
|
+
// dumps) out of the persisted status, and for publishing the plan artifact.
|
|
133
|
+
// Created once here so it is available to EVERY persist below. Best-effort:
|
|
134
|
+
// if it can't be built (e.g. proxy mode without a token), offload is disabled
|
|
135
|
+
// but persistStatus still enforces the aggregate size cap, so persistence can
|
|
136
|
+
// never silently blow past the gRPC limit.
|
|
137
|
+
let artifactStorage: ArtifactStorage | undefined;
|
|
138
|
+
try {
|
|
139
|
+
artifactStorage = createArtifactStorage(loadArtifactStorageConfig(config));
|
|
140
|
+
} catch (storageErr) {
|
|
141
|
+
console.warn(
|
|
142
|
+
`ExecuteCursor artifact storage unavailable — tool-output offload disabled ` +
|
|
143
|
+
`(aggregate size guard still active): execution=${executionId}, error=${storageErr}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const statusOffload = artifactStorage
|
|
147
|
+
? { artifactStorage, executionId }
|
|
148
|
+
: undefined;
|
|
149
|
+
// ALL status persistence in this activity flows through `persist`, so the
|
|
150
|
+
// single size-bounding guard (offload + aggregate elision) is unforgeable and
|
|
151
|
+
// a future call site cannot accidentally skip it.
|
|
152
|
+
const persist = (s: AgentExecutionStatus = status) =>
|
|
153
|
+
persistStatus(client, executionId, s, { offload: statusOffload });
|
|
154
|
+
|
|
130
155
|
let sessionId: string | undefined;
|
|
131
156
|
let session: import("@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb").Session | undefined;
|
|
132
157
|
let pauseDetected = false;
|
|
133
158
|
let workerShutdownDetected = false;
|
|
159
|
+
// Set by the stall watchdog when the SDK stream makes no progress for
|
|
160
|
+
// config.cursorStreamStallTimeoutMs. stallError carries the recognizable
|
|
161
|
+
// message surfaced to the user; both feed the Phase 11a stall branch.
|
|
162
|
+
let stallDetected = false;
|
|
163
|
+
let stallError: StallTimeoutError | undefined;
|
|
134
164
|
let periodicHeartbeat: ReturnType<typeof startHeartbeat> | undefined;
|
|
165
|
+
// Progress-based stall watchdog (see ../../shared/stall-watchdog.ts). Stopped
|
|
166
|
+
// in the finally on every exit path; complements the liveness heartbeat.
|
|
167
|
+
let stallWatchdog: StallWatchdog | undefined;
|
|
135
168
|
// Session HITL directory (runner-owned, outside the workspace) where the hook
|
|
136
169
|
// script, approval-state file, and denial ledger live. Set once the gate is
|
|
137
170
|
// installed; the WAITING_FOR_APPROVAL path reads the denial ledger from here.
|
|
@@ -227,7 +260,7 @@ async function executeCursorInner(
|
|
|
227
260
|
content: "Execution was rejected by the user during tool approval.",
|
|
228
261
|
timestamp: utcTimestamp(),
|
|
229
262
|
}));
|
|
230
|
-
await
|
|
263
|
+
await persist(status);
|
|
231
264
|
return slimStatus(status);
|
|
232
265
|
}
|
|
233
266
|
}
|
|
@@ -496,6 +529,9 @@ async function executeCursorInner(
|
|
|
496
529
|
let firstTurnAttributionLogged = false;
|
|
497
530
|
let streamErrorMessage: string | undefined;
|
|
498
531
|
let alreadyRetriedWithFreshAgent = false;
|
|
532
|
+
// Most recent tool name observed on the stream, used only to enrich the
|
|
533
|
+
// stall message ("last tool: …") so a wedged turn names the likely culprit.
|
|
534
|
+
let lastToolName: string | undefined;
|
|
499
535
|
|
|
500
536
|
// Periodic heartbeat keeps Temporal informed during silent SDK operations
|
|
501
537
|
// (e.g. long tool calls, MCP requests, model thinking). Without this,
|
|
@@ -525,6 +561,10 @@ async function executeCursorInner(
|
|
|
525
561
|
|
|
526
562
|
const run = await resolution.agent.send(effectivePrompt, {
|
|
527
563
|
onDelta: ({ update }) => {
|
|
564
|
+
// Reset the stall timer on the delta channel too: a long model
|
|
565
|
+
// generation emits token deltas but few discrete stream events, so
|
|
566
|
+
// resetting only in the stream loop would false-positive a stall.
|
|
567
|
+
stallWatchdog?.recordActivity();
|
|
528
568
|
if (update.type === "turn-ended" && update.usage) {
|
|
529
569
|
usageAccumulator.addTurn(update.usage);
|
|
530
570
|
|
|
@@ -552,14 +592,46 @@ async function executeCursorInner(
|
|
|
552
592
|
},
|
|
553
593
|
});
|
|
554
594
|
|
|
595
|
+
// Arm the stall watchdog now that the run exists. The periodic heartbeat
|
|
596
|
+
// above proves the process is alive, not that the agent is progressing: if
|
|
597
|
+
// the stream wedges (a tool call or model connection that never returns),
|
|
598
|
+
// no event/delta arrives, Phase 12 below is never reached, and the
|
|
599
|
+
// execution hangs at EXECUTION_IN_PROGRESS forever. On stall we end the run
|
|
600
|
+
// cleanly via the SDK's run.cancel() (guarded by supports("cancel")), which
|
|
601
|
+
// unblocks the for-await; the stallDetected branch in Phase 11a then
|
|
602
|
+
// reports EXECUTION_FAILED with a recognizable, actionable message.
|
|
603
|
+
stallWatchdog = startStallWatchdog(config.cursorStreamStallTimeoutMs, (idleMs) => {
|
|
604
|
+
stallDetected = true;
|
|
605
|
+
stallError = new StallTimeoutError(idleMs, lastToolName ? `last tool: ${lastToolName}` : undefined);
|
|
606
|
+
console.warn(
|
|
607
|
+
`ExecuteCursor stall detected: execution=${executionId}, idleMs=${idleMs}, lastTool=${lastToolName ?? "none"}`,
|
|
608
|
+
);
|
|
609
|
+
if (run.supports?.("cancel")) {
|
|
610
|
+
void run.cancel().catch((cancelErr) => {
|
|
611
|
+
console.warn(
|
|
612
|
+
`ExecuteCursor run.cancel() after stall failed (non-fatal): execution=${executionId}, ` +
|
|
613
|
+
`error=${cancelErr instanceof Error ? cancelErr.message : cancelErr}`,
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
555
619
|
const accumulator = new MessageAccumulator(status.messages, { mergedPolicies });
|
|
556
620
|
let eventCount = 0;
|
|
557
621
|
|
|
622
|
+
try {
|
|
558
623
|
for await (const event of run.stream()) {
|
|
559
624
|
if (pauseDetected || Context.current().cancellationSignal.aborted) {
|
|
560
625
|
pauseDetected = true;
|
|
561
626
|
break;
|
|
562
627
|
}
|
|
628
|
+
if (stallDetected) break;
|
|
629
|
+
|
|
630
|
+
// Progress: reset the stall timer on every stream event.
|
|
631
|
+
stallWatchdog.recordActivity();
|
|
632
|
+
if (event.type === "tool_call" && typeof event.name === "string") {
|
|
633
|
+
lastToolName = event.name;
|
|
634
|
+
}
|
|
563
635
|
|
|
564
636
|
eventRecorder?.record(event, eventCount);
|
|
565
637
|
|
|
@@ -598,7 +670,7 @@ async function executeCursorInner(
|
|
|
598
670
|
// accumulator tracked sub-agents in memory but they only reached the
|
|
599
671
|
// status (and the subscriber stream) after the loop ended.
|
|
600
672
|
status.subAgentExecutions = accumulator.subAgentExecutions;
|
|
601
|
-
const signal = await
|
|
673
|
+
const signal = await persist(status);
|
|
602
674
|
deltaEnricher.markPersisted();
|
|
603
675
|
todoTracker.markPersisted();
|
|
604
676
|
accumulator.markSubAgentPersisted();
|
|
@@ -616,8 +688,17 @@ async function executeCursorInner(
|
|
|
616
688
|
break;
|
|
617
689
|
}
|
|
618
690
|
}
|
|
691
|
+
} catch (streamErr) {
|
|
692
|
+
// run.cancel() from the stall watchdog can make the stream iterator
|
|
693
|
+
// reject; that is the expected teardown, so swallow it and fall through
|
|
694
|
+
// to the stallDetected branch in Phase 11a. Anything else is a genuine
|
|
695
|
+
// stream failure — rethrow it to the outer error handler.
|
|
696
|
+
if (!stallDetected) throw streamErr;
|
|
697
|
+
console.warn(`ExecuteCursor stream ended via stall cancel: execution=${executionId}`);
|
|
698
|
+
}
|
|
619
699
|
|
|
620
700
|
periodicHeartbeat.stop();
|
|
701
|
+
stallWatchdog.stop();
|
|
621
702
|
// Check both the heartbeat flag AND the shutdown signal directly.
|
|
622
703
|
// Race condition: the heartbeat timer may detect Temporal's CancelledFailure
|
|
623
704
|
// (from worker.shutdown()) before the AbortSignal microtask propagates,
|
|
@@ -638,7 +719,7 @@ async function executeCursorInner(
|
|
|
638
719
|
// sub-agent the parent had delegated is no longer executing. Mark it
|
|
639
720
|
// CANCELLED rather than leaving a permanent IN_PROGRESS "zombie" in the
|
|
640
721
|
// final snapshot (parity with the native harness's cancelSubAgents()).
|
|
641
|
-
if (pauseDetected || workerShutdownDetected || Context.current().cancellationSignal.aborted) {
|
|
722
|
+
if (pauseDetected || workerShutdownDetected || stallDetected || Context.current().cancellationSignal.aborted) {
|
|
642
723
|
accumulator.cancelInProgressSubAgents();
|
|
643
724
|
}
|
|
644
725
|
status.subAgentExecutions = accumulator.subAgentExecutions;
|
|
@@ -654,7 +735,7 @@ async function executeCursorInner(
|
|
|
654
735
|
// call statuses before run.wait() / structured output extraction.
|
|
655
736
|
// This is unconditional (not throttled) because finalize is a
|
|
656
737
|
// once-per-execution correctness boundary.
|
|
657
|
-
await
|
|
738
|
+
await persist(status);
|
|
658
739
|
heartbeat();
|
|
659
740
|
|
|
660
741
|
// End OTel turn span with accumulated token usage
|
|
@@ -678,7 +759,29 @@ async function executeCursorInner(
|
|
|
678
759
|
}
|
|
679
760
|
|
|
680
761
|
|
|
681
|
-
// Phase 11a: Handle worker shutdown, pause, or infrastructure cancellation.
|
|
762
|
+
// Phase 11a: Handle stall, worker shutdown, pause, or infrastructure cancellation.
|
|
763
|
+
|
|
764
|
+
// Stall: the watchdog cancelled a turn that made no progress for longer
|
|
765
|
+
// than config.cursorStreamStallTimeoutMs (a wedged tool call or a dead
|
|
766
|
+
// model connection). The keep-alive heartbeat proves liveness, so Temporal
|
|
767
|
+
// never reaps this on its own — this branch is the only clean exit. We
|
|
768
|
+
// RETURN (not throw): re-running the identical prompt via Temporal retry
|
|
769
|
+
// would very likely wedge again. accumulator.finalize() above already
|
|
770
|
+
// cleared isStreaming, so the UI spinners stop.
|
|
771
|
+
if (stallDetected) {
|
|
772
|
+
const err = stallError ?? new StallTimeoutError(config.cursorStreamStallTimeoutMs);
|
|
773
|
+
status.phase = ExecutionPhase.EXECUTION_FAILED;
|
|
774
|
+
status.error = formatStallFailure(err);
|
|
775
|
+
status.completedAt = utcTimestamp();
|
|
776
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
777
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
778
|
+
content: `Execution failed: the agent made no progress for too long and was stopped (${err.message}). You can retry or resume.`,
|
|
779
|
+
timestamp: utcTimestamp(),
|
|
780
|
+
}));
|
|
781
|
+
await persist(status);
|
|
782
|
+
console.warn(`ExecuteCursor stalled: execution=${executionId}, events=${eventCount}, error=${status.error}`);
|
|
783
|
+
return slimStatus(status);
|
|
784
|
+
}
|
|
682
785
|
|
|
683
786
|
// Worker shutdown: the runner-manager aborted the shutdown signal before
|
|
684
787
|
// calling worker.shutdown(). This is NOT a user-initiated pause — it's
|
|
@@ -692,7 +795,7 @@ async function executeCursorInner(
|
|
|
692
795
|
content: "Execution interrupted: the runner worker was shut down while the agent was still running. You can retry or resume.",
|
|
693
796
|
timestamp: utcTimestamp(),
|
|
694
797
|
}));
|
|
695
|
-
await
|
|
798
|
+
await persist(status); console.log(`ExecuteCursor interrupted (worker shutdown): execution=${executionId}, events=${eventCount}`);
|
|
696
799
|
throw new CancelledFailure("Activity cancelled (worker shutdown, not user pause)");
|
|
697
800
|
}
|
|
698
801
|
|
|
@@ -705,7 +808,7 @@ async function executeCursorInner(
|
|
|
705
808
|
content: "Execution paused by user. Use resume to continue.",
|
|
706
809
|
timestamp: utcTimestamp(),
|
|
707
810
|
}));
|
|
708
|
-
await
|
|
811
|
+
await persist(status); console.log(`ExecuteCursor paused: execution=${executionId}, events=${eventCount}`);
|
|
709
812
|
throw new CancelledFailure("Activity paused by orchestrator");
|
|
710
813
|
}
|
|
711
814
|
|
|
@@ -721,7 +824,7 @@ async function executeCursorInner(
|
|
|
721
824
|
content: "Execution interrupted: the agent was unresponsive for too long. You can retry or resume.",
|
|
722
825
|
timestamp: utcTimestamp(),
|
|
723
826
|
}));
|
|
724
|
-
await
|
|
827
|
+
await persist(status); console.log(`ExecuteCursor interrupted (infrastructure cancel): execution=${executionId}, events=${eventCount}`);
|
|
725
828
|
throw new CancelledFailure("Activity cancelled (heartbeat timeout, not user pause)");
|
|
726
829
|
}
|
|
727
830
|
|
|
@@ -734,7 +837,7 @@ async function executeCursorInner(
|
|
|
734
837
|
content: "Execution stopped by the platform.",
|
|
735
838
|
timestamp: utcTimestamp(),
|
|
736
839
|
}));
|
|
737
|
-
await
|
|
840
|
+
await persist(status); try { resolution.agent.close(); } catch { /* best effort */ }
|
|
738
841
|
console.log(`ExecuteCursor completed (platform stop): execution=${executionId}`);
|
|
739
842
|
return slimStatus(status);
|
|
740
843
|
}
|
|
@@ -751,7 +854,7 @@ async function executeCursorInner(
|
|
|
751
854
|
const deniedToolCalls = reconcileDeniedToolCalls(status.messages, deniedLedger, mergedPolicies);
|
|
752
855
|
if (deniedToolCalls.length > 0) {
|
|
753
856
|
status.phase = ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL;
|
|
754
|
-
await
|
|
857
|
+
await persist(status);
|
|
755
858
|
console.log(`ExecuteCursor returning WAITING_FOR_APPROVAL: ${deniedToolCalls.length} tools pending`);
|
|
756
859
|
return slimStatus(status);
|
|
757
860
|
}
|
|
@@ -858,8 +961,10 @@ async function executeCursorInner(
|
|
|
858
961
|
console.warn("Failed to update session with fresh agentId (non-fatal):", updateErr);
|
|
859
962
|
}
|
|
860
963
|
|
|
964
|
+
let retryWatchdog: StallWatchdog | undefined;
|
|
861
965
|
const retryRun = await freshAgent.send(freshPrompt, {
|
|
862
966
|
onDelta: ({ update }) => {
|
|
967
|
+
retryWatchdog?.recordActivity();
|
|
863
968
|
if (update.type === "turn-ended" && update.usage) {
|
|
864
969
|
usageAccumulator.addTurn(update.usage);
|
|
865
970
|
}
|
|
@@ -867,19 +972,31 @@ async function executeCursorInner(
|
|
|
867
972
|
try { heartbeat(); } catch { /* swallow during retry */ }
|
|
868
973
|
},
|
|
869
974
|
});
|
|
975
|
+
// Mirror the primary stream's stall protection: a wedged retry must
|
|
976
|
+
// not hang the activity. On stall, cancel the run so the loop ends and
|
|
977
|
+
// retryRun.wait() resolves down the existing non-finished failure path.
|
|
978
|
+
retryWatchdog = startStallWatchdog(config.cursorStreamStallTimeoutMs, (idleMs) => {
|
|
979
|
+
console.warn(`ExecuteCursor retry stall detected: execution=${executionId}, idleMs=${idleMs}`);
|
|
980
|
+
if (retryRun.supports?.("cancel")) void retryRun.cancel().catch(() => { /* best effort */ });
|
|
981
|
+
});
|
|
870
982
|
|
|
871
983
|
streamErrorMessage = undefined;
|
|
872
984
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
if (
|
|
879
|
-
|
|
985
|
+
try {
|
|
986
|
+
for await (const retryEvent of retryRun.stream()) {
|
|
987
|
+
if (Context.current().cancellationSignal.aborted) break;
|
|
988
|
+
retryWatchdog.recordActivity();
|
|
989
|
+
accumulator.processEvent(retryEvent);
|
|
990
|
+
if (retryEvent.type === "status") {
|
|
991
|
+
const retryStatusEvent = retryEvent as { status?: string; message?: string };
|
|
992
|
+
if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
|
|
993
|
+
streamErrorMessage = retryStatusEvent.message;
|
|
994
|
+
}
|
|
880
995
|
}
|
|
996
|
+
heartbeat();
|
|
881
997
|
}
|
|
882
|
-
|
|
998
|
+
} finally {
|
|
999
|
+
retryWatchdog.stop();
|
|
883
1000
|
}
|
|
884
1001
|
|
|
885
1002
|
const retryResult = await retryRun.wait();
|
|
@@ -953,8 +1070,10 @@ async function executeCursorInner(
|
|
|
953
1070
|
console.warn("Failed to update session with fresh agentId (non-fatal):", updateErr);
|
|
954
1071
|
}
|
|
955
1072
|
|
|
1073
|
+
let retryWatchdog: StallWatchdog | undefined;
|
|
956
1074
|
const retryRun = await freshAgent.send(effectivePrompt, {
|
|
957
1075
|
onDelta: ({ update }) => {
|
|
1076
|
+
retryWatchdog?.recordActivity();
|
|
958
1077
|
if (update.type === "turn-ended" && update.usage) {
|
|
959
1078
|
usageAccumulator.addTurn(update.usage);
|
|
960
1079
|
}
|
|
@@ -962,19 +1081,31 @@ async function executeCursorInner(
|
|
|
962
1081
|
try { heartbeat(); } catch { /* swallow during retry */ }
|
|
963
1082
|
},
|
|
964
1083
|
});
|
|
1084
|
+
// Mirror the primary stream's stall protection: a wedged retry must
|
|
1085
|
+
// not hang the activity. On stall, cancel the run so the loop ends and
|
|
1086
|
+
// retryRun.wait() resolves down the existing non-finished failure path.
|
|
1087
|
+
retryWatchdog = startStallWatchdog(config.cursorStreamStallTimeoutMs, (idleMs) => {
|
|
1088
|
+
console.warn(`ExecuteCursor retry stall detected: execution=${executionId}, idleMs=${idleMs}`);
|
|
1089
|
+
if (retryRun.supports?.("cancel")) void retryRun.cancel().catch(() => { /* best effort */ });
|
|
1090
|
+
});
|
|
965
1091
|
|
|
966
1092
|
streamErrorMessage = undefined;
|
|
967
1093
|
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
if (
|
|
974
|
-
|
|
1094
|
+
try {
|
|
1095
|
+
for await (const retryEvent of retryRun.stream()) {
|
|
1096
|
+
if (Context.current().cancellationSignal.aborted) break;
|
|
1097
|
+
retryWatchdog.recordActivity();
|
|
1098
|
+
accumulator.processEvent(retryEvent);
|
|
1099
|
+
if (retryEvent.type === "status") {
|
|
1100
|
+
const retryStatusEvent = retryEvent as { status?: string; message?: string };
|
|
1101
|
+
if (retryStatusEvent.status === "ERROR" && retryStatusEvent.message) {
|
|
1102
|
+
streamErrorMessage = retryStatusEvent.message;
|
|
1103
|
+
}
|
|
975
1104
|
}
|
|
1105
|
+
heartbeat();
|
|
976
1106
|
}
|
|
977
|
-
|
|
1107
|
+
} finally {
|
|
1108
|
+
retryWatchdog.stop();
|
|
978
1109
|
}
|
|
979
1110
|
|
|
980
1111
|
const retryResult = await retryRun.wait();
|
|
@@ -1059,9 +1190,8 @@ async function executeCursorInner(
|
|
|
1059
1190
|
// Cursor harness has no auto-publish pipeline, so this is the only
|
|
1060
1191
|
// artifact path; build storage from the same config-driven factory the
|
|
1061
1192
|
// native harness uses.
|
|
1062
|
-
if (interactionMode === InteractionMode.PLAN && finalText) {
|
|
1193
|
+
if (interactionMode === InteractionMode.PLAN && finalText && artifactStorage) {
|
|
1063
1194
|
try {
|
|
1064
|
-
const artifactStorage = createArtifactStorage(loadArtifactStorageConfig(config));
|
|
1065
1195
|
await publishPlanArtifact({ status, executionId, planText: finalText, artifactStorage });
|
|
1066
1196
|
} catch (err) {
|
|
1067
1197
|
console.warn(
|
|
@@ -1073,7 +1203,7 @@ async function executeCursorInner(
|
|
|
1073
1203
|
}
|
|
1074
1204
|
|
|
1075
1205
|
// NOW persist — subscriber sees COMPLETED + structured_output atomically
|
|
1076
|
-
await
|
|
1206
|
+
await persist(status);
|
|
1077
1207
|
|
|
1078
1208
|
console.log(
|
|
1079
1209
|
`ExecuteCursor completed: execution=${executionId}, phase=${ExecutionPhase[status.phase]}, ` +
|
|
@@ -1131,7 +1261,7 @@ async function executeCursorInner(
|
|
|
1131
1261
|
// The aborted Cursor run leaves no live sub-agent — mark any in-flight
|
|
1132
1262
|
// delegation CANCELLED so the final snapshot has no zombie sub-agent.
|
|
1133
1263
|
cancelInProgressSubAgentProtos(status.subAgentExecutions);
|
|
1134
|
-
await
|
|
1264
|
+
await persist(status).catch(() => {}); throw err;
|
|
1135
1265
|
}
|
|
1136
1266
|
|
|
1137
1267
|
// If a non-CancelledFailure error occurs while a pause is in progress,
|
|
@@ -1150,7 +1280,7 @@ async function executeCursorInner(
|
|
|
1150
1280
|
timestamp: utcTimestamp(),
|
|
1151
1281
|
}));
|
|
1152
1282
|
cancelInProgressSubAgentProtos(status.subAgentExecutions);
|
|
1153
|
-
await
|
|
1283
|
+
await persist(status).catch(() => {}); throw new CancelledFailure("Activity paused by orchestrator (error during pause)");
|
|
1154
1284
|
}
|
|
1155
1285
|
|
|
1156
1286
|
// Infrastructure cancellation (e.g. heartbeat timeout) with a
|
|
@@ -1169,7 +1299,7 @@ async function executeCursorInner(
|
|
|
1169
1299
|
timestamp: utcTimestamp(),
|
|
1170
1300
|
}));
|
|
1171
1301
|
cancelInProgressSubAgentProtos(status.subAgentExecutions);
|
|
1172
|
-
await
|
|
1302
|
+
await persist(status).catch(() => {}); throw new CancelledFailure("Activity cancelled (infrastructure, not user pause)");
|
|
1173
1303
|
}
|
|
1174
1304
|
|
|
1175
1305
|
// A thrown CursorSdkError carries structured fields (code/status/endpoint/
|
|
@@ -1207,7 +1337,7 @@ async function executeCursorInner(
|
|
|
1207
1337
|
}),
|
|
1208
1338
|
);
|
|
1209
1339
|
try {
|
|
1210
|
-
await
|
|
1340
|
+
await persist(status);
|
|
1211
1341
|
} catch (persistErr) {
|
|
1212
1342
|
console.error("Failed to persist error status (best-effort):", persistErr);
|
|
1213
1343
|
} return slimStatus(status);
|
|
@@ -1234,13 +1364,18 @@ async function executeCursorInner(
|
|
|
1234
1364
|
);
|
|
1235
1365
|
|
|
1236
1366
|
try {
|
|
1237
|
-
await
|
|
1367
|
+
await persist(status);
|
|
1238
1368
|
} catch (persistErr) {
|
|
1239
1369
|
console.error("Failed to persist error status (best-effort):", persistErr);
|
|
1240
1370
|
}
|
|
1241
1371
|
|
|
1242
1372
|
return slimStatus(status);
|
|
1243
1373
|
} finally {
|
|
1374
|
+
// Disarm the stall watchdog on EVERY exit path (idempotent). The happy
|
|
1375
|
+
// path stops it after the stream loop; this covers throws before that
|
|
1376
|
+
// point so no orphaned timer survives the activity.
|
|
1377
|
+
stallWatchdog?.stop();
|
|
1378
|
+
|
|
1244
1379
|
// Tear down the HITL gate on EVERY exit path (success, error, approval
|
|
1245
1380
|
// pause, cancellation) so attaching a real repo leaves the user's
|
|
1246
1381
|
// .cursor/hooks.json and workspace untouched between turns (issue #173).
|