@stigmer/runner 3.2.2 → 3.2.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/__test-utils__/cursor-hook-harness.d.ts +5 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
- package/dist/activities/execute-cursor/approval-state.js +13 -1
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +38 -0
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +28 -12
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
- package/dist/activities/execute-cursor/message-translator.js +78 -2
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
- package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/turn-boundary.js +15 -2
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
- package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
- package/dist/activities/execute-deep-agent/hitl.js +48 -1
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +10 -1
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
- package/dist/activities/execute-deep-agent/setup.js +12 -1
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +9 -0
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +20 -0
- package/dist/middleware/approval-gate.js +16 -1
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/shared/approval-policy.d.ts +24 -1
- package/dist/shared/approval-policy.js +35 -1
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/with-timeout.d.ts +10 -0
- package/dist/shared/with-timeout.js +14 -1
- package/dist/shared/with-timeout.js.map +1 -1
- package/dist/workflow-engine/tasks/human-input.js +1 -0
- package/dist/workflow-engine/tasks/human-input.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +22 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/human-input-orchestrator.d.ts +2 -1
- package/dist/workflows/human-input-orchestrator.js +2 -1
- package/dist/workflows/human-input-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/workflow-event-activities.test.ts +29 -0
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
- package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
- package/src/activities/execute-cursor/approval-state.ts +32 -1
- package/src/activities/execute-cursor/hook-script.ts +40 -0
- package/src/activities/execute-cursor/index.ts +27 -14
- package/src/activities/execute-cursor/message-translator.ts +82 -1
- package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
- package/src/activities/execute-cursor/turn-boundary.ts +23 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
- package/src/activities/execute-deep-agent/hitl.ts +51 -0
- package/src/activities/execute-deep-agent/index.ts +11 -1
- package/src/activities/execute-deep-agent/setup.ts +27 -0
- package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
- package/src/activities/execute-deep-agent/status-builder.ts +7 -0
- package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
- package/src/activities/workflow-event-activities.ts +9 -0
- package/src/middleware/__tests__/approval-gate.test.ts +95 -0
- package/src/middleware/approval-gate.ts +37 -0
- package/src/shared/__tests__/with-timeout.test.ts +16 -1
- package/src/shared/approval-policy.ts +41 -2
- package/src/shared/with-timeout.ts +15 -1
- package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
- package/src/workflow-engine/tasks/human-input.ts +1 -0
- package/src/workflow-engine/types.ts +23 -0
- package/src/workflows/human-input-orchestrator.ts +2 -1
|
@@ -39,7 +39,7 @@ 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";
|
|
42
|
-
import {
|
|
42
|
+
import { resolveAgentWithTransportRecovery } 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";
|
|
@@ -57,7 +57,7 @@ import { StreamingUpdateScheduler, loadStreamingConfig } from "../../shared/stre
|
|
|
57
57
|
import { createCursorEventRecorder } from "./cursor-event-recorder.js";
|
|
58
58
|
import { resolveMcpServers, validateMcpServerEnv } from "./mcp-resolver.js";
|
|
59
59
|
import { mergeApprovalPolicies } from "./approval-policy.js";
|
|
60
|
-
import { deriveActiveLeases } from "../../shared/approval-policy.js";
|
|
60
|
+
import { deriveActiveLeases, isUnattendedApprovalMode } from "../../shared/approval-policy.js";
|
|
61
61
|
import { backfillMcpServersIfNeeded } from "./connect-backfill.js";
|
|
62
62
|
import { resolveExecutionEnv } from "./env-resolver.js";
|
|
63
63
|
import { resolveBlueprint } from "./blueprint-resolver.js";
|
|
@@ -117,7 +117,6 @@ import type { ClassifiedError } from "./error-classifier.js";
|
|
|
117
117
|
import { createAgent, createCloudAgent } from "./session-lifecycle.js";
|
|
118
118
|
import { setMaxListeners } from "node:events";
|
|
119
119
|
import { startHeartbeat } from "../../shared/heartbeat.js";
|
|
120
|
-
import { withTimeout } from "../../shared/with-timeout.js";
|
|
121
120
|
import { getShutdownSignalForQueue } from "../../runner-manager.js";
|
|
122
121
|
|
|
123
122
|
/**
|
|
@@ -722,6 +721,11 @@ async function executeCursorInner(
|
|
|
722
721
|
// absent a git tree keeps gating gitignored writes and a non-git workspace
|
|
723
722
|
// falls back to the deny-gate entirely (no regression).
|
|
724
723
|
const captureIgnored = captureMode && !!artifactStorage;
|
|
724
|
+
// Unattended approval mode (DD-014): approver-less surfaces (channels,
|
|
725
|
+
// guest shares) stamp APPROVAL_MODE_UNATTENDED; the hook then records
|
|
726
|
+
// approval denials with the non-pausing "unattended" kind, so the
|
|
727
|
+
// first-denial stop never fires and the turn boundary settles the denied
|
|
728
|
+
// calls as SKIPPED instead of pausing a turn nobody can approve.
|
|
725
729
|
const approvalState = buildApprovalState(
|
|
726
730
|
mergedPolicies,
|
|
727
731
|
globalBypass,
|
|
@@ -730,6 +734,7 @@ async function executeCursorInner(
|
|
|
730
734
|
captureMode,
|
|
731
735
|
captureIgnored,
|
|
732
736
|
gitWorkspace,
|
|
737
|
+
isUnattendedApprovalMode(execution),
|
|
733
738
|
);
|
|
734
739
|
const hitlGate = await installHitlGate({
|
|
735
740
|
workspaceRoot: primaryWorkspaceDir,
|
|
@@ -842,21 +847,29 @@ async function executeCursorInner(
|
|
|
842
847
|
|
|
843
848
|
// Agent.create/Agent.resume have no timeout of their own — a degraded
|
|
844
849
|
// transport (dead proxy connection, stale HTTP/2 session) hangs them
|
|
845
|
-
// forever, which the periodic heartbeat would happily keep alive.
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
//
|
|
850
|
+
// forever, which the periodic heartbeat would happily keep alive. Each
|
|
851
|
+
// attempt is bounded; on expiry the wrapper resets the proxy transport
|
|
852
|
+
// and retries once, so a stale-session hang recovers without failing the
|
|
853
|
+
// execution. A second expiry propagates a plain Error to the generic
|
|
854
|
+
// catch below, which persists EXECUTION_FAILED (no Temporal retry —
|
|
855
|
+
// the activity returns rather than throws, and maximumAttempts is 1).
|
|
849
856
|
heartbeatPhase = "resolving_agent";
|
|
850
857
|
const resolveTimeoutSeconds = Math.round(config.agentResolveTimeoutMs / 1000);
|
|
851
|
-
let resolution: AgentResolution = await
|
|
852
|
-
|
|
853
|
-
|
|
858
|
+
let resolution: AgentResolution = await resolveAgentWithTransportRecovery({
|
|
859
|
+
harnessStateId: threadId,
|
|
860
|
+
createOptions,
|
|
861
|
+
mode: agentMode,
|
|
862
|
+
timeoutMs: config.agentResolveTimeoutMs,
|
|
863
|
+
buildTimeoutMessage: (finalAttempt) =>
|
|
854
864
|
`Cursor agent ${threadId ? "resume" : "create"} timed out after ${resolveTimeoutSeconds}s ` +
|
|
855
865
|
`(${config.proxyEndpoint ? `via proxy ${config.proxyEndpoint}` : "direct Cursor API connection"}). ` +
|
|
856
|
-
`The transport connection is likely dead.
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
866
|
+
`The transport connection is likely dead. ` +
|
|
867
|
+
(finalAttempt
|
|
868
|
+
? `An automatic retry on a fresh transport connection also timed out. ` +
|
|
869
|
+
`Retry the message later; if this persists, check proxy and network health.`
|
|
870
|
+
: `Resetting the transport and retrying automatically.`),
|
|
871
|
+
resetTransport: closeProxySessions,
|
|
872
|
+
});
|
|
860
873
|
|
|
861
874
|
console.log(
|
|
862
875
|
`ExecuteCursor agent resolved: execution=${executionId}, ` +
|
|
@@ -36,7 +36,7 @@ import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/a
|
|
|
36
36
|
import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
37
37
|
import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
38
38
|
import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
39
|
-
import { MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
39
|
+
import { ApprovalPolicySource, MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
40
40
|
import type { SDKMessage } from "@cursor/sdk";
|
|
41
41
|
import type { MergedToolPolicy } from "./approval-policy.js";
|
|
42
42
|
import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage, SALIENT_ARG_FIELDS } from "./approval-policy.js";
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
POLICY_ENGINE_VERSION,
|
|
45
45
|
resolveApprovalProvenance,
|
|
46
46
|
toProtoPolicySource,
|
|
47
|
+
unattendedSkipMessage,
|
|
47
48
|
} from "../../shared/approval-policy.js";
|
|
48
49
|
import {
|
|
49
50
|
approvalDenials,
|
|
@@ -1916,6 +1917,86 @@ export function detectUnattributedHookBlocks(
|
|
|
1916
1917
|
return blocks;
|
|
1917
1918
|
}
|
|
1918
1919
|
|
|
1920
|
+
/**
|
|
1921
|
+
* Stamp the tool calls the hook denied under UNATTENDED approval mode
|
|
1922
|
+
* (DD-014) as terminal TOOL_CALL_SKIPPED rows with UNATTENDED_SKIP
|
|
1923
|
+
* provenance — the Cursor twin of the native harness's
|
|
1924
|
+
* `reconcileUnattendedSkips`, so both harnesses persist the same honest
|
|
1925
|
+
* shape for a platform-resolved skip.
|
|
1926
|
+
*
|
|
1927
|
+
* An unattended denial never pauses the run (its ledger kind is excluded
|
|
1928
|
+
* from {@link approvalDenials}), so the stream leaves the denied call as a
|
|
1929
|
+
* FAILED row carrying Cursor's generic hook-block error — dishonest ("the
|
|
1930
|
+
* tool broke") and alarming in a transcript an org admin reviews. This pass
|
|
1931
|
+
* correlates the unattended ledger entries to their streamed calls with the
|
|
1932
|
+
* SAME two identities the rest of the file uses — exact token, then
|
|
1933
|
+
* (category, workspace-normalized path) for the abs-vs-rel drift — and
|
|
1934
|
+
* settles each match to SKIPPED with a result explaining the skip.
|
|
1935
|
+
*
|
|
1936
|
+
* Scope: hook-blocked FAILED rows and still-non-terminal (PENDING/RUNNING)
|
|
1937
|
+
* rows only — a COMPLETED row is a real execution and is never rewritten
|
|
1938
|
+
* (an unattended deny cannot produce one). `approval_action`/`approved_by`
|
|
1939
|
+
* stay untouched: server-owned, human-decision-only fields (DD-014 D-e).
|
|
1940
|
+
* Unmatched ledger entries need no synthesis — there is no pause to
|
|
1941
|
+
* surface; the model already saw the deny and adapted in-turn.
|
|
1942
|
+
*
|
|
1943
|
+
* Returns how many tool calls were stamped.
|
|
1944
|
+
*/
|
|
1945
|
+
export function stampUnattendedSkippedToolCalls(
|
|
1946
|
+
messages: readonly AgentMessage[],
|
|
1947
|
+
subAgentExecutions: readonly SubAgentExecution[],
|
|
1948
|
+
unattendedLedger: readonly DeniedLedgerEntry[],
|
|
1949
|
+
workspaceRoot?: string,
|
|
1950
|
+
): number {
|
|
1951
|
+
if (unattendedLedger.length === 0) return 0;
|
|
1952
|
+
|
|
1953
|
+
const ledgerTokens = new Set(unattendedLedger.map((e) => e.token));
|
|
1954
|
+
const ledgerNormalizedSalients = new Set<string>();
|
|
1955
|
+
if (workspaceRoot) {
|
|
1956
|
+
for (const entry of unattendedLedger) {
|
|
1957
|
+
const decoded = decodeIdentityToken(entry.token);
|
|
1958
|
+
if (!decoded) continue;
|
|
1959
|
+
const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
|
|
1960
|
+
if (normalized) ledgerNormalizedSalients.add(normalized);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
const matchesLedger = (tc: ToolCall): boolean => {
|
|
1965
|
+
if (ledgerTokens.has(toolCallIdentityToken(tc))) return true;
|
|
1966
|
+
if (!workspaceRoot) return false;
|
|
1967
|
+
const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
|
|
1968
|
+
const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
|
|
1969
|
+
return !!normalized && ledgerNormalizedSalients.has(normalized);
|
|
1970
|
+
};
|
|
1971
|
+
|
|
1972
|
+
let stamped = 0;
|
|
1973
|
+
const apply = (msgs: readonly AgentMessage[]): void => {
|
|
1974
|
+
for (const msg of msgs) {
|
|
1975
|
+
for (const tc of msg.toolCalls) {
|
|
1976
|
+
const deniedShape =
|
|
1977
|
+
(tc.status === ToolCallStatus.TOOL_CALL_FAILED && isHookBlockError(tc.error)) ||
|
|
1978
|
+
tc.status === ToolCallStatus.TOOL_CALL_PENDING ||
|
|
1979
|
+
tc.status === ToolCallStatus.TOOL_CALL_RUNNING;
|
|
1980
|
+
if (!deniedShape || !matchesLedger(tc)) continue;
|
|
1981
|
+
tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
|
|
1982
|
+
tc.approvalPolicySource = ApprovalPolicySource.UNATTENDED_SKIP;
|
|
1983
|
+
tc.policyEngineVersion = POLICY_ENGINE_VERSION;
|
|
1984
|
+
tc.error = "";
|
|
1985
|
+
tc.result = unattendedSkipMessage(tc.name);
|
|
1986
|
+
tc.isStreaming = false;
|
|
1987
|
+
if (!tc.completedAt) tc.completedAt = utcTimestamp();
|
|
1988
|
+
stamped++;
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
apply(messages);
|
|
1994
|
+
for (const subAgent of subAgentExecutions) {
|
|
1995
|
+
apply(subAgent.messages);
|
|
1996
|
+
}
|
|
1997
|
+
return stamped;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1919
2000
|
/**
|
|
1920
2001
|
* Compute a streamed tool call's identity token in the same canonical space the
|
|
1921
2002
|
* preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
|
|
@@ -41,6 +41,7 @@ import { join } from "node:path";
|
|
|
41
41
|
|
|
42
42
|
import { Agent } from "@cursor/sdk";
|
|
43
43
|
import type { SDKAgent, CursorAgentPlatformOptions, AgentDefinition } from "@cursor/sdk";
|
|
44
|
+
import { withTimeout, TimeoutError } from "../../shared/with-timeout.js";
|
|
44
45
|
import type { CursorMcpServerConfig } from "./mcp-resolver.js";
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
@@ -437,6 +438,81 @@ export async function resolveAgent(
|
|
|
437
438
|
};
|
|
438
439
|
}
|
|
439
440
|
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Transport-recovery wrapper
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
export interface ResolveWithTransportRecoveryOptions {
|
|
446
|
+
harnessStateId: string;
|
|
447
|
+
createOptions: CreateAgentOptions | CreateCloudAgentOptions;
|
|
448
|
+
mode: "local" | "cloud";
|
|
449
|
+
/** Bound applied to each attempt independently. */
|
|
450
|
+
timeoutMs: number;
|
|
451
|
+
/**
|
|
452
|
+
* Builds the timeout rejection message. `finalAttempt` is true when no
|
|
453
|
+
* further automatic retry will follow, so the message can be honest about
|
|
454
|
+
* whether "retry" means the system or the user.
|
|
455
|
+
*/
|
|
456
|
+
buildTimeoutMessage: (finalAttempt: boolean) => string;
|
|
457
|
+
/**
|
|
458
|
+
* Closes the transport the hung attempt is riding on so the next attempt
|
|
459
|
+
* dials fresh. Injected (rather than imported from the http2 interceptor)
|
|
460
|
+
* to keep this module transport-agnostic and the recovery unit-testable.
|
|
461
|
+
*/
|
|
462
|
+
resetTransport: () => void;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Resolve a Cursor Agent with one automatic recovery from a transport hang.
|
|
467
|
+
*
|
|
468
|
+
* Agent.create/Agent.resume have no timeout of their own — a degraded
|
|
469
|
+
* transport (dead proxy connection, stale HTTP/2 session) hangs them forever.
|
|
470
|
+
* Each attempt is bounded by `timeoutMs`; when the first attempt expires,
|
|
471
|
+
* the transport is reset and the full resolveAgent() is retried once. Only
|
|
472
|
+
* a TimeoutError triggers recovery: deterministic failures (auth, validation)
|
|
473
|
+
* propagate immediately — resetting the transport cannot fix them.
|
|
474
|
+
*
|
|
475
|
+
* The retry re-invokes resolveAgent(), not createAgent() directly: at
|
|
476
|
+
* resolve time only the transport is suspect, not the agent handle, so a
|
|
477
|
+
* timed-out resume is retried resume-first and conversation context is
|
|
478
|
+
* preserved. (This deliberately diverges from the stream-phase recovery in
|
|
479
|
+
* index.ts, which jumps to a fresh create because there the handle itself
|
|
480
|
+
* has already failed a run.)
|
|
481
|
+
*
|
|
482
|
+
* Orphan semantics: withTimeout bounds the wait, not the work, so the first
|
|
483
|
+
* attempt's promise survives its expiry. resetTransport() closes the HTTP/2
|
|
484
|
+
* session that attempt is riding on, so the orphan rejects promptly and
|
|
485
|
+
* withTimeout's attached catch absorbs the late rejection (settled promise —
|
|
486
|
+
* no unhandledRejection). In the residual case where the orphan completes
|
|
487
|
+
* before the reset, it leaves an agent that never receives a prompt: no LLM
|
|
488
|
+
* cost, no state impact, cleaned up with the sandbox.
|
|
489
|
+
*/
|
|
490
|
+
export async function resolveAgentWithTransportRecovery(
|
|
491
|
+
opts: ResolveWithTransportRecoveryOptions,
|
|
492
|
+
): Promise<AgentResolution> {
|
|
493
|
+
const attempt = (finalAttempt: boolean) =>
|
|
494
|
+
withTimeout(
|
|
495
|
+
opts.timeoutMs,
|
|
496
|
+
() => opts.buildTimeoutMessage(finalAttempt),
|
|
497
|
+
() => resolveAgent(opts.harnessStateId, opts.createOptions, opts.mode),
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
return await attempt(false);
|
|
502
|
+
} catch (err) {
|
|
503
|
+
if (!(err instanceof TimeoutError)) throw err;
|
|
504
|
+
|
|
505
|
+
console.warn(
|
|
506
|
+
`resolveAgentWithTransportRecovery: agent resolution timed out after ` +
|
|
507
|
+
`${opts.timeoutMs}ms (sessionId=${opts.createOptions.sessionId}, mode=${opts.mode}, ` +
|
|
508
|
+
`resume=${!!opts.harnessStateId}) — resetting transport and retrying once`,
|
|
509
|
+
);
|
|
510
|
+
opts.resetTransport();
|
|
511
|
+
|
|
512
|
+
return attempt(true);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
440
516
|
/**
|
|
441
517
|
* Dispose a Cursor Agent when a session is deleted.
|
|
442
518
|
* Best-effort: logs and swallows errors.
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
denialKindOf,
|
|
43
43
|
primaryToken,
|
|
44
44
|
readDenialLedger,
|
|
45
|
+
unattendedDenials,
|
|
45
46
|
type ApprovalGrant,
|
|
46
47
|
} from "./approval-state.js";
|
|
47
48
|
import { deriveTurnCommandProvenance } from "./command-provenance.js";
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
clearProvisionalPostDenialNarration,
|
|
51
52
|
detectUnattributedHookBlocks,
|
|
52
53
|
reconcileDeniedToolCalls,
|
|
54
|
+
stampUnattendedSkippedToolCalls,
|
|
53
55
|
type UnattributedHookBlock,
|
|
54
56
|
} from "./message-translator.js";
|
|
55
57
|
|
|
@@ -302,6 +304,27 @@ export async function runTurnBoundary(opts: TurnBoundaryOptions): Promise<TurnBo
|
|
|
302
304
|
}
|
|
303
305
|
}
|
|
304
306
|
|
|
307
|
+
// Unattended approval mode (DD-014): denials the hook resolved with the
|
|
308
|
+
// non-pausing "unattended" kind never became gates above; settle their
|
|
309
|
+
// streamed rows (FAILED-with-hook-error or interrupted non-terminal) to
|
|
310
|
+
// honest TOOL_CALL_SKIPPED + UNATTENDED_SKIP provenance — the same shape
|
|
311
|
+
// the native harness's reconcileUnattendedSkips persists. Runs before the
|
|
312
|
+
// #205 attribution pass, which then never sees them as hook-block FAILED
|
|
313
|
+
// rows (they are in the full ledger regardless, so attribution was safe
|
|
314
|
+
// either way).
|
|
315
|
+
const stampedUnattended = stampUnattendedSkippedToolCalls(
|
|
316
|
+
status.messages,
|
|
317
|
+
status.subAgentExecutions,
|
|
318
|
+
unattendedDenials(deniedLedger),
|
|
319
|
+
primaryWorkspaceDir,
|
|
320
|
+
);
|
|
321
|
+
if (stampedUnattended > 0) {
|
|
322
|
+
console.log(
|
|
323
|
+
`ExecuteCursor turn boundary: ${stampedUnattended} unattended-mode tool call(s) ` +
|
|
324
|
+
`settled as SKIPPED (no approver on this surface; execution=${executionId})`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
305
328
|
// Issue #205 invariant: a blocked tool must never silently complete. Match
|
|
306
329
|
// this turn's hook-blocked FAILED rows against the FULL ledger (all kinds) —
|
|
307
330
|
// anything left over was denied by a hook that is not ours (or by our hook
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { Command } from "@langchain/langgraph";
|
|
3
|
-
import { resolveResumeInput, reconcileNonExecutingDecisions } from "../hitl.js";
|
|
4
|
-
import { ApprovalAction, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
3
|
+
import { resolveResumeInput, reconcileNonExecutingDecisions, reconcileUnattendedSkips } from "../hitl.js";
|
|
4
|
+
import { ApprovalAction, ApprovalPolicySource, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
5
|
import { create } from "@bufbuild/protobuf";
|
|
6
6
|
import {
|
|
7
7
|
AgentMessageSchema,
|
|
@@ -238,3 +238,104 @@ describe("reconcileNonExecutingDecisions", () => {
|
|
|
238
238
|
expect(status.messages[0].toolCalls[0].status).toBe(ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
|
|
239
239
|
});
|
|
240
240
|
});
|
|
241
|
+
|
|
242
|
+
describe("reconcileUnattendedSkips (DD-014)", () => {
|
|
243
|
+
function statusWithCalls(
|
|
244
|
+
toolCalls: Array<{ id: string; status: ToolCallStatus; result?: string }>,
|
|
245
|
+
) {
|
|
246
|
+
return create(AgentExecutionStatusSchema, {
|
|
247
|
+
messages: [
|
|
248
|
+
create(AgentMessageSchema, {
|
|
249
|
+
toolCalls: toolCalls.map(tc =>
|
|
250
|
+
create(ToolCallSchema, {
|
|
251
|
+
id: tc.id,
|
|
252
|
+
name: "gated_tool",
|
|
253
|
+
status: tc.status,
|
|
254
|
+
result: tc.result ?? "",
|
|
255
|
+
}),
|
|
256
|
+
),
|
|
257
|
+
}),
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
it("terminalizes a registry-recorded call to SKIPPED with UNATTENDED_SKIP provenance", () => {
|
|
263
|
+
// The stream saw the gate's skip ToolMessage as a normal tool result and
|
|
264
|
+
// marked the call COMPLETED — the reconciler owns the honest terminal shape.
|
|
265
|
+
const status = statusWithCalls([
|
|
266
|
+
{ id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "was skipped" },
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
reconcileUnattendedSkips(status, new Set(["call-1"]));
|
|
270
|
+
|
|
271
|
+
const tc = status.messages[0].toolCalls[0];
|
|
272
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
273
|
+
expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNATTENDED_SKIP);
|
|
274
|
+
expect(tc.policyEngineVersion).not.toBe("");
|
|
275
|
+
// Server-owned human-decision fields stay untouched (DD-014 D-e).
|
|
276
|
+
expect(tc.approvalAction).toBe(ApprovalAction.UNSPECIFIED);
|
|
277
|
+
expect(tc.approvedBy).toBe("");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("backfills the skip result on a row whose tool events never fired", () => {
|
|
281
|
+
const status = statusWithCalls([
|
|
282
|
+
{ id: "call-1", status: ToolCallStatus.TOOL_CALL_RUNNING },
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
reconcileUnattendedSkips(status, new Set(["call-1"]));
|
|
286
|
+
|
|
287
|
+
const tc = status.messages[0].toolCalls[0];
|
|
288
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
289
|
+
expect(tc.result).toContain("skipped automatically");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("touches only registry entries and no-ops on an empty/absent registry", () => {
|
|
293
|
+
const status = statusWithCalls([
|
|
294
|
+
{ id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "real result" },
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
reconcileUnattendedSkips(status, new Set(["other-call"]));
|
|
298
|
+
reconcileUnattendedSkips(status, new Set());
|
|
299
|
+
reconcileUnattendedSkips(status, undefined);
|
|
300
|
+
|
|
301
|
+
const tc = status.messages[0].toolCalls[0];
|
|
302
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_COMPLETED);
|
|
303
|
+
expect(tc.result).toBe("real result");
|
|
304
|
+
expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNSPECIFIED);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("covers sub-agent transcripts (sub-agent gates share the parent registry)", () => {
|
|
308
|
+
const status = create(AgentExecutionStatusSchema, {
|
|
309
|
+
messages: [],
|
|
310
|
+
subAgentExecutions: [{
|
|
311
|
+
messages: [
|
|
312
|
+
create(AgentMessageSchema, {
|
|
313
|
+
toolCalls: [create(ToolCallSchema, {
|
|
314
|
+
id: "sub-call-1",
|
|
315
|
+
name: "gated_tool",
|
|
316
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
317
|
+
})],
|
|
318
|
+
}),
|
|
319
|
+
],
|
|
320
|
+
}],
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
reconcileUnattendedSkips(status, new Set(["sub-call-1"]));
|
|
324
|
+
|
|
325
|
+
const tc = status.subAgentExecutions[0].messages[0].toolCalls[0];
|
|
326
|
+
expect(tc.status).toBe(ToolCallStatus.TOOL_CALL_SKIPPED);
|
|
327
|
+
expect(tc.approvalPolicySource).toBe(ApprovalPolicySource.UNATTENDED_SKIP);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("is idempotent", () => {
|
|
331
|
+
const status = statusWithCalls([
|
|
332
|
+
{ id: "call-1", status: ToolCallStatus.TOOL_CALL_COMPLETED },
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
reconcileUnattendedSkips(status, new Set(["call-1"]));
|
|
336
|
+
const after = JSON.stringify(status.messages[0].toolCalls[0]);
|
|
337
|
+
reconcileUnattendedSkips(status, new Set(["call-1"]));
|
|
338
|
+
|
|
339
|
+
expect(JSON.stringify(status.messages[0].toolCalls[0])).toBe(after);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -17,9 +17,11 @@ import { Command } from "@langchain/langgraph";
|
|
|
17
17
|
import type { AgentExecution, AgentExecutionStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
18
18
|
import {
|
|
19
19
|
ApprovalAction,
|
|
20
|
+
ApprovalPolicySource,
|
|
20
21
|
ToolCallStatus,
|
|
21
22
|
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
22
23
|
import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
24
|
+
import { POLICY_ENGINE_VERSION, unattendedSkipMessage } from "../../shared/approval-policy.js";
|
|
23
25
|
|
|
24
26
|
// APPROVE_ALL resumes the interrupted tool exactly like APPROVE. Its
|
|
25
27
|
// "auto-approve the rest of the run" effect is realized in setup.ts (the
|
|
@@ -233,3 +235,52 @@ export function reconcileNonExecutingDecisions(status: AgentExecutionStatus): vo
|
|
|
233
235
|
apply(subAgent.messages);
|
|
234
236
|
}
|
|
235
237
|
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Terminalize every tool call the approval gate auto-skipped under UNATTENDED
|
|
241
|
+
* approval mode — the sibling of {@link reconcileNonExecutingDecisions} for
|
|
242
|
+
* skips that have no human decision behind them.
|
|
243
|
+
*
|
|
244
|
+
* The gate (the single writer of the registry) records each auto-skipped
|
|
245
|
+
* tool-call id at the moment it returns the skip ToolMessage; this reconciler
|
|
246
|
+
* (the single writer of the terminal row) folds each id into:
|
|
247
|
+
* - `status = TOOL_CALL_SKIPPED` — the tool did not run, whatever transient
|
|
248
|
+
* status the stream left behind (COMPLETED from a tool_finished that carried
|
|
249
|
+
* the skip message, or RUNNING when no tool events fired);
|
|
250
|
+
* - `approval_policy_source = UNATTENDED_SKIP` — the resolution layer,
|
|
251
|
+
* overriding the gating-layer source stamped at tool-start (the
|
|
252
|
+
* AUTO_APPROVE_ALL precedent: layer-4 resolutions own the resolved call);
|
|
253
|
+
* - a result backfilled from {@link unattendedSkipMessage} when the stream
|
|
254
|
+
* delivered none, so the transcript row is never blank.
|
|
255
|
+
*
|
|
256
|
+
* `approval_action` / `approved_by` are deliberately NOT touched — those are
|
|
257
|
+
* server-owned fields recording HUMAN decisions only (DD-014 D-e). No
|
|
258
|
+
* approval-request event exists for these calls, so the pending-approvals
|
|
259
|
+
* projection stays empty by construction. Idempotent: re-running re-resolves
|
|
260
|
+
* identically. Covers sub-agent transcripts because sub-agent gates inherit
|
|
261
|
+
* the parent's registry instance.
|
|
262
|
+
*/
|
|
263
|
+
export function reconcileUnattendedSkips(
|
|
264
|
+
status: AgentExecutionStatus,
|
|
265
|
+
unattendedSkips: ReadonlySet<string> | undefined,
|
|
266
|
+
): void {
|
|
267
|
+
if (!unattendedSkips || unattendedSkips.size === 0) return;
|
|
268
|
+
|
|
269
|
+
const apply = (messages: readonly AgentMessage[]): void => {
|
|
270
|
+
for (const msg of messages) {
|
|
271
|
+
for (const tc of msg.toolCalls) {
|
|
272
|
+
if (!unattendedSkips.has(tc.id)) continue;
|
|
273
|
+
tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
|
|
274
|
+
tc.approvalPolicySource = ApprovalPolicySource.UNATTENDED_SKIP;
|
|
275
|
+
tc.policyEngineVersion = POLICY_ENGINE_VERSION;
|
|
276
|
+
tc.isStreaming = false;
|
|
277
|
+
if (!tc.result) tc.result = unattendedSkipMessage(tc.name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
apply(status.messages);
|
|
283
|
+
for (const subAgent of status.subAgentExecutions) {
|
|
284
|
+
apply(subAgent.messages);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -47,7 +47,7 @@ import { StatusBuilder } from "./status-builder.js";
|
|
|
47
47
|
import { InlinePublisher } from "./inline-publisher.js";
|
|
48
48
|
import { WriteBackCoordinator } from "../../shared/workspace/writeback-coordinator.js";
|
|
49
49
|
import { processPostStream } from "./post-stream.js";
|
|
50
|
-
import { resolveResumeInput, reconcileNonExecutingDecisions, type GraphStateSnapshot } from "./hitl.js";
|
|
50
|
+
import { resolveResumeInput, reconcileNonExecutingDecisions, reconcileUnattendedSkips, type GraphStateSnapshot } from "./hitl.js";
|
|
51
51
|
import { captureApprovalArtifacts } from "./approval-file-change.js";
|
|
52
52
|
import {
|
|
53
53
|
applyCaptureDecisions,
|
|
@@ -144,6 +144,7 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
144
144
|
toolServerMap: setup.toolServerMap,
|
|
145
145
|
leasedCategories: setup.leasedCategories,
|
|
146
146
|
globalBypass: setup.globalBypass,
|
|
147
|
+
unattended: setup.unattended,
|
|
147
148
|
});
|
|
148
149
|
|
|
149
150
|
const resume = resolveResumeInput(
|
|
@@ -408,6 +409,7 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
408
409
|
toolServerMap: setup.toolServerMap,
|
|
409
410
|
leasedCategories: setup.leasedCategories,
|
|
410
411
|
globalBypass: setup.globalBypass,
|
|
412
|
+
unattended: setup.unattended,
|
|
411
413
|
},
|
|
412
414
|
streamVersion: setup.streamVersion,
|
|
413
415
|
// Mid-run live capture (DD-32 / DD-33): attach file_change_progress
|
|
@@ -465,6 +467,14 @@ export function createDeepAgentActivities(config: Config) {
|
|
|
465
467
|
// completion persists below so the terminal status is what is persisted.
|
|
466
468
|
reconcileNonExecutingDecisions(initialStatus);
|
|
467
469
|
|
|
470
|
+
// Terminalize every tool call the gate auto-skipped under UNATTENDED
|
|
471
|
+
// approval mode (DD-014): the skip has no human decision behind it, so
|
|
472
|
+
// reconcileNonExecutingDecisions cannot see it — this sibling folds the
|
|
473
|
+
// gate's registry into terminal SKIPPED rows with UNATTENDED_SKIP
|
|
474
|
+
// provenance, whatever transient status the stream left behind. No-op
|
|
475
|
+
// for interactive executions (empty registry).
|
|
476
|
+
reconcileUnattendedSkips(initialStatus, setup.unattendedSkips);
|
|
477
|
+
|
|
468
478
|
// Turn boundary (capture mode): capture the candidate change set from the
|
|
469
479
|
// git diff and author CANDIDATE_CAPTURED, then stamp the flowed file-edit
|
|
470
480
|
// rows with the change set id — they stay visible in place as
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
import {
|
|
60
60
|
mergeApprovalPolicies,
|
|
61
61
|
deriveActiveLeases,
|
|
62
|
+
isUnattendedApprovalMode,
|
|
62
63
|
type MergedToolPolicy,
|
|
63
64
|
} from "../../shared/approval-policy.js";
|
|
64
65
|
import type { ToolApprovalCategory } from "../../shared/tool-kind.js";
|
|
@@ -125,6 +126,20 @@ export interface SetupResult {
|
|
|
125
126
|
* the gate (see ActiveLeases / deriveActiveLeases).
|
|
126
127
|
*/
|
|
127
128
|
readonly globalBypass: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED,
|
|
131
|
+
* stamped by approver-less surfaces — channels, guest shares). The gate
|
|
132
|
+
* resolves gated tools as automatic skips instead of interrupting; the
|
|
133
|
+
* builders never seed WAITING_APPROVAL. See isUnattendedApprovalMode.
|
|
134
|
+
*/
|
|
135
|
+
readonly unattended: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Tool-call ids the gate auto-skipped under {@link unattended} this turn.
|
|
138
|
+
* Written ONLY by the approval gate (parent + inherited sub-agent gates
|
|
139
|
+
* share this instance); read by reconcileUnattendedSkips after the stream
|
|
140
|
+
* to stamp terminal SKIPPED rows. Empty set when not unattended.
|
|
141
|
+
*/
|
|
142
|
+
readonly unattendedSkips: Set<string>;
|
|
128
143
|
readonly hasStructuredOutput: boolean;
|
|
129
144
|
readonly streamVersion: "v2" | "v3";
|
|
130
145
|
/**
|
|
@@ -440,6 +455,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
440
455
|
leases,
|
|
441
456
|
);
|
|
442
457
|
|
|
458
|
+
// Unattended approval mode (DD-014): approver-less surfaces (channels,
|
|
459
|
+
// guest shares) stamp APPROVAL_MODE_UNATTENDED, and the gate resolves
|
|
460
|
+
// gated tools as automatic skips instead of interrupting. The registry is
|
|
461
|
+
// the gate→reconciler channel for the skipped tool-call ids; one instance
|
|
462
|
+
// is shared with sub-agent gates via the inherited config.
|
|
463
|
+
const unattended = isUnattendedApprovalMode(execution);
|
|
464
|
+
const unattendedSkips = new Set<string>();
|
|
465
|
+
|
|
443
466
|
// The approval gate config is the single source of truth for HITL gating,
|
|
444
467
|
// built once and inherited verbatim by sub-agents (so a mutating tool inside
|
|
445
468
|
// a sub-agent is gated identically to one in the parent). Null under the
|
|
@@ -476,6 +499,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
476
499
|
isCapturablePath,
|
|
477
500
|
captureIgnored: captureMode && !!artifactStorage,
|
|
478
501
|
recordBlockedSecret: (rawPath: string) => casObserver.recordBlockedSecret(rawPath),
|
|
502
|
+
unattended,
|
|
503
|
+
unattendedSkips,
|
|
479
504
|
}
|
|
480
505
|
: null;
|
|
481
506
|
|
|
@@ -648,6 +673,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
648
673
|
toolServerMap,
|
|
649
674
|
leasedCategories: leases.categories,
|
|
650
675
|
globalBypass,
|
|
676
|
+
unattended,
|
|
677
|
+
unattendedSkips,
|
|
651
678
|
hasStructuredOutput: !!outputSchema,
|
|
652
679
|
streamVersion,
|
|
653
680
|
casObserver,
|
|
@@ -157,6 +157,14 @@ export interface ApprovalProvenanceInputs {
|
|
|
157
157
|
readonly leasedCategories?: ReadonlySet<ToolApprovalCategory>;
|
|
158
158
|
/** Pre-armed spec.auto_approve_all — the one whole-run global bypass. */
|
|
159
159
|
readonly globalBypass: boolean;
|
|
160
|
+
/**
|
|
161
|
+
* Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
|
|
162
|
+
* the gate auto-skips gated calls instead of interrupting, so the builders
|
|
163
|
+
* must NOT seed WAITING_APPROVAL rows or flip the phase to
|
|
164
|
+
* WAITING_FOR_APPROVAL — the row streams as RUNNING and the post-stream
|
|
165
|
+
* `reconcileUnattendedSkips` stamps the terminal SKIPPED + provenance.
|
|
166
|
+
*/
|
|
167
|
+
readonly unattended?: boolean;
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
/** Shared empty set so a provider without leases allocates nothing per call. */
|
|
@@ -319,6 +319,13 @@ export class StatusBuilder {
|
|
|
319
319
|
return { requiresApproval: false, message: "", serverSlug };
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
+
// Unattended mode: the gate auto-skips instead of interrupting, so no row
|
|
323
|
+
// is ever WAITING_APPROVAL and the phase never flips — the streamed row
|
|
324
|
+
// runs to its skip result and reconcileUnattendedSkips terminalizes it.
|
|
325
|
+
if (this.approvalProvider.unattended) {
|
|
326
|
+
return { requiresApproval: false, message: "", serverSlug };
|
|
327
|
+
}
|
|
328
|
+
|
|
322
329
|
if (serverSlug) {
|
|
323
330
|
const key = `${serverSlug}/${toolName}`;
|
|
324
331
|
const policy = this.approvalProvider.policies.get(key);
|
|
@@ -419,6 +419,13 @@ export class V3StatusBuilder implements ExecutionStatusWriter {
|
|
|
419
419
|
return { requiresApproval: false, message: "", serverSlug };
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
// Unattended mode: the gate auto-skips instead of interrupting, so no row
|
|
423
|
+
// is ever WAITING_APPROVAL and the phase never flips — the streamed row
|
|
424
|
+
// runs to its skip result and reconcileUnattendedSkips terminalizes it.
|
|
425
|
+
if (this.approvalProvider.unattended) {
|
|
426
|
+
return { requiresApproval: false, message: "", serverSlug };
|
|
427
|
+
}
|
|
428
|
+
|
|
422
429
|
if (serverSlug) {
|
|
423
430
|
const key = `${serverSlug}/${toolName}`;
|
|
424
431
|
const policy = this.approvalProvider.policies.get(key);
|