@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.
Files changed (80) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +5 -0
  3. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  4. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  5. package/dist/activities/execute-cursor/approval-state.d.ts +27 -2
  6. package/dist/activities/execute-cursor/approval-state.js +13 -1
  7. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +38 -0
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.js +28 -12
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/message-translator.d.ts +26 -0
  13. package/dist/activities/execute-cursor/message-translator.js +78 -2
  14. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  15. package/dist/activities/execute-cursor/session-lifecycle.d.ts +45 -0
  16. package/dist/activities/execute-cursor/session-lifecycle.js +41 -0
  17. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  18. package/dist/activities/execute-cursor/turn-boundary.js +15 -2
  19. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  20. package/dist/activities/execute-deep-agent/hitl.d.ts +25 -0
  21. package/dist/activities/execute-deep-agent/hitl.js +48 -1
  22. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/index.js +10 -1
  24. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  25. package/dist/activities/execute-deep-agent/setup.d.ts +14 -0
  26. package/dist/activities/execute-deep-agent/setup.js +12 -1
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +8 -0
  29. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/status-builder.js +6 -0
  31. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/v3-status-builder.js +6 -0
  33. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  34. package/dist/activities/workflow-event-activities.js +9 -0
  35. package/dist/activities/workflow-event-activities.js.map +1 -1
  36. package/dist/middleware/approval-gate.d.ts +20 -0
  37. package/dist/middleware/approval-gate.js +16 -1
  38. package/dist/middleware/approval-gate.js.map +1 -1
  39. package/dist/shared/approval-policy.d.ts +24 -1
  40. package/dist/shared/approval-policy.js +35 -1
  41. package/dist/shared/approval-policy.js.map +1 -1
  42. package/dist/shared/with-timeout.d.ts +10 -0
  43. package/dist/shared/with-timeout.js +14 -1
  44. package/dist/shared/with-timeout.js.map +1 -1
  45. package/dist/workflow-engine/tasks/human-input.js +1 -0
  46. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  47. package/dist/workflow-engine/types.d.ts +22 -0
  48. package/dist/workflow-engine/types.js.map +1 -1
  49. package/dist/workflows/human-input-orchestrator.d.ts +2 -1
  50. package/dist/workflows/human-input-orchestrator.js +2 -1
  51. package/dist/workflows/human-input-orchestrator.js.map +1 -1
  52. package/package.json +2 -2
  53. package/src/activities/__tests__/workflow-event-activities.test.ts +29 -0
  54. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +6 -0
  55. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +105 -0
  56. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +54 -0
  57. package/src/activities/execute-cursor/__tests__/session-lifecycle.test.ts +122 -1
  58. package/src/activities/execute-cursor/approval-state.ts +32 -1
  59. package/src/activities/execute-cursor/hook-script.ts +40 -0
  60. package/src/activities/execute-cursor/index.ts +27 -14
  61. package/src/activities/execute-cursor/message-translator.ts +82 -1
  62. package/src/activities/execute-cursor/session-lifecycle.ts +76 -0
  63. package/src/activities/execute-cursor/turn-boundary.ts +23 -0
  64. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +103 -2
  65. package/src/activities/execute-deep-agent/hitl.ts +51 -0
  66. package/src/activities/execute-deep-agent/index.ts +11 -1
  67. package/src/activities/execute-deep-agent/setup.ts +27 -0
  68. package/src/activities/execute-deep-agent/status-builder-shared.ts +8 -0
  69. package/src/activities/execute-deep-agent/status-builder.ts +7 -0
  70. package/src/activities/execute-deep-agent/v3-status-builder.ts +7 -0
  71. package/src/activities/workflow-event-activities.ts +9 -0
  72. package/src/middleware/__tests__/approval-gate.test.ts +95 -0
  73. package/src/middleware/approval-gate.ts +37 -0
  74. package/src/shared/__tests__/with-timeout.test.ts +16 -1
  75. package/src/shared/approval-policy.ts +41 -2
  76. package/src/shared/with-timeout.ts +15 -1
  77. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +40 -0
  78. package/src/workflow-engine/tasks/human-input.ts +1 -0
  79. package/src/workflow-engine/types.ts +23 -0
  80. 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 { resolveAgent } from "./session-lifecycle.js";
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. The
846
- // bound converts that hang into an immediate, named transport failure.
847
- // The message carries "timed out" so the error classifier's network
848
- // patterns mark it retryable.
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 withTimeout(
852
- config.agentResolveTimeoutMs,
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. Retry the message; if this persists, ` +
857
- `check proxy and network health.`,
858
- () => resolveAgent(threadId, createOptions, agentMode),
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);