@stigmer/runner 3.1.0 → 3.1.2

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 (111) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  6. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +19 -1
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.d.ts +5 -0
  11. package/dist/activities/execute-cursor/index.js +102 -22
  12. package/dist/activities/execute-cursor/index.js.map +1 -1
  13. package/dist/activities/execute-cursor/message-translator.js +10 -1
  14. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  15. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  16. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  17. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  18. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  19. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  20. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  21. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  22. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  23. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  24. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  25. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  26. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/index.js +59 -2
  28. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  30. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  31. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/setup.js +22 -16
  33. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  34. package/dist/config.d.ts +7 -0
  35. package/dist/config.js +9 -0
  36. package/dist/config.js.map +1 -1
  37. package/dist/middleware/approval-gate.js +36 -6
  38. package/dist/middleware/approval-gate.js.map +1 -1
  39. package/dist/runner-manager.d.ts +2 -0
  40. package/dist/runner-manager.js +2 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.d.ts +2 -0
  43. package/dist/runner.js +2 -1
  44. package/dist/runner.js.map +1 -1
  45. package/dist/shared/artifact-storage.d.ts +32 -0
  46. package/dist/shared/artifact-storage.js +74 -1
  47. package/dist/shared/artifact-storage.js.map +1 -1
  48. package/dist/shared/implement-plan-prompt.d.ts +42 -0
  49. package/dist/shared/implement-plan-prompt.js +66 -0
  50. package/dist/shared/implement-plan-prompt.js.map +1 -0
  51. package/dist/shared/plan-mode-prompt.d.ts +30 -0
  52. package/dist/shared/plan-mode-prompt.js +51 -0
  53. package/dist/shared/plan-mode-prompt.js.map +1 -0
  54. package/dist/shared/status.d.ts +11 -0
  55. package/dist/shared/status.js +12 -2
  56. package/dist/shared/status.js.map +1 -1
  57. package/dist/shared/tool-row.d.ts +30 -1
  58. package/dist/shared/tool-row.js +53 -4
  59. package/dist/shared/tool-row.js.map +1 -1
  60. package/dist/shared/workspace/session-root.d.ts +35 -0
  61. package/dist/shared/workspace/session-root.js +53 -0
  62. package/dist/shared/workspace/session-root.js.map +1 -0
  63. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  64. package/dist/shared/workspace/workspace-lock.js +187 -0
  65. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  66. package/package.json +7 -5
  67. package/src/__tests__/config.test.ts +8 -0
  68. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  69. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  70. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  71. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +125 -2
  72. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  73. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  74. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  75. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  76. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  77. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  78. package/src/activities/execute-cursor/hook-script.ts +19 -1
  79. package/src/activities/execute-cursor/index.ts +114 -26
  80. package/src/activities/execute-cursor/message-translator.ts +9 -1
  81. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  82. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  83. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  84. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  85. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  86. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  87. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  88. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +88 -0
  89. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  90. package/src/activities/execute-deep-agent/index.ts +70 -2
  91. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  92. package/src/activities/execute-deep-agent/setup.ts +25 -17
  93. package/src/config.ts +17 -0
  94. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  95. package/src/middleware/approval-gate.ts +39 -7
  96. package/src/runner-manager.ts +6 -1
  97. package/src/runner.ts +6 -1
  98. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  99. package/src/shared/__tests__/implement-plan-prompt.test.ts +48 -0
  100. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  101. package/src/shared/__tests__/status.test.ts +7 -2
  102. package/src/shared/__tests__/tool-row.test.ts +136 -1
  103. package/src/shared/artifact-storage.ts +83 -1
  104. package/src/shared/implement-plan-prompt.ts +73 -0
  105. package/src/shared/plan-mode-prompt.ts +51 -0
  106. package/src/shared/status.ts +11 -1
  107. package/src/shared/tool-row.ts +58 -5
  108. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  109. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  110. package/src/shared/workspace/session-root.ts +60 -0
  111. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
2
3
  import { buildEnhancedSystemPrompt } from "../prompt-builder.js";
4
+ import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
3
5
  import { SourceType } from "../../../shared/workspace/types.js";
4
6
  import type { ProvisionResult } from "../../../shared/workspace/types.js";
5
7
 
@@ -205,4 +207,90 @@ describe("buildEnhancedSystemPrompt", () => {
205
207
 
206
208
  expect(prompt).not.toContain("## Workspace");
207
209
  });
210
+
211
+ describe("plan mode", () => {
212
+ const base = {
213
+ instructions: "Test",
214
+ provisionResults: [],
215
+ containerRoot: "",
216
+ skillsPromptSection: "",
217
+ workspaceFileRefs: [],
218
+ workspaceRoot: "/workspace",
219
+ injectedFiles: [],
220
+ };
221
+
222
+ it("appends the shared plan-mode directive as the final section", () => {
223
+ const prompt = buildEnhancedSystemPrompt({
224
+ ...base,
225
+ interactionMode: InteractionMode.PLAN,
226
+ });
227
+
228
+ expect(prompt).toContain("## Plan mode");
229
+ expect(prompt.endsWith(PLAN_MODE_DIRECTIVE)).toBe(true);
230
+ });
231
+
232
+ it("omits the directive for Agent mode", () => {
233
+ const prompt = buildEnhancedSystemPrompt({
234
+ ...base,
235
+ interactionMode: InteractionMode.AGENT,
236
+ });
237
+
238
+ expect(prompt).not.toContain("## Plan mode");
239
+ });
240
+
241
+ it("omits the directive when the mode is unset", () => {
242
+ const prompt = buildEnhancedSystemPrompt(base);
243
+
244
+ expect(prompt).not.toContain("## Plan mode");
245
+ });
246
+ });
247
+
248
+ describe("build from plan", () => {
249
+ const base = {
250
+ instructions: "Test",
251
+ provisionResults: [],
252
+ containerRoot: "",
253
+ skillsPromptSection: "",
254
+ workspaceFileRefs: [],
255
+ workspaceRoot: "/workspace",
256
+ injectedFiles: [],
257
+ };
258
+ const planFile = {
259
+ filename: "plan.md",
260
+ path: ".stigmer/inputs/plan.md",
261
+ size: 1024,
262
+ };
263
+
264
+ it("appends the implement-plan directive pointing at the injected plan", () => {
265
+ const prompt = buildEnhancedSystemPrompt({
266
+ ...base,
267
+ buildFromPlan: true,
268
+ injectedFiles: [planFile],
269
+ });
270
+
271
+ expect(prompt).toContain("## Implement the approved plan");
272
+ expect(prompt).toContain("`.stigmer/inputs/plan.md`");
273
+ expect(prompt).toContain("APPROVED");
274
+ });
275
+
276
+ it("falls back to the conversation-plan variant when the plan did not inject", () => {
277
+ const prompt = buildEnhancedSystemPrompt({
278
+ ...base,
279
+ buildFromPlan: true,
280
+ });
281
+
282
+ expect(prompt).toContain("## Implement the approved plan");
283
+ expect(prompt).toContain("conversation above");
284
+ expect(prompt).not.toContain("plan.md");
285
+ });
286
+
287
+ it("omits the directive for an ordinary execution", () => {
288
+ const prompt = buildEnhancedSystemPrompt({
289
+ ...base,
290
+ injectedFiles: [planFile],
291
+ });
292
+
293
+ expect(prompt).not.toContain("## Implement the approved plan");
294
+ });
295
+ });
208
296
  });
@@ -51,7 +51,9 @@ import type { SetupResult } from "../setup.js";
51
51
  vi.mock("@temporalio/activity", () => ({
52
52
  Context: {
53
53
  current: () => ({
54
- cancellationSignal: { aborted: false },
54
+ // A real AbortSignal: the workspace-lock wait registers abort listeners
55
+ // on it, which a bare `{ aborted: false }` stub cannot satisfy.
56
+ cancellationSignal: new AbortController().signal,
55
57
  heartbeat: vi.fn(),
56
58
  }),
57
59
  },
@@ -187,7 +189,9 @@ function fakeMemoryGateSetup(opts: {
187
189
  agent: {},
188
190
  session: { spec: { workspaceEntries: [] } },
189
191
  workspaceBackend: {
190
- rootDir: "/tmp/ws",
192
+ // Unique per test file: the workspace turn lock keys on this path, and a
193
+ // path shared across files would serialize parallel test workers for real.
194
+ rootDir: "/tmp/stigmer-test-ws-seq-gate",
191
195
  exists: vi.fn(async () => false),
192
196
  readFile: vi.fn(async () => ""),
193
197
  },
@@ -252,6 +256,7 @@ const memoryConfig: Config = {
252
256
  checkpointerProxyEndpoint: null,
253
257
  primaryModel: "claude-sonnet",
254
258
  cursorStreamStallTimeoutMs: 180000,
259
+ workspaceLockTimeoutMs: 900000,
255
260
  };
256
261
 
257
262
  function toolCallIds(status: AgentExecutionStatus): string[] {
@@ -19,7 +19,13 @@ import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/a
19
19
  import { ExecutionPhase, FileCaptureClass, FileChangeSetStatus, InteractionMode, MessageType, ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
20
20
  import { activityStarted, activityFinished } from "../../idle-watchdog.js";
21
21
  import { normalizeActivityInput, type ExecuteActivityInput } from "../../shared/activity-input.js";
22
- import { persistStatus, slimStatus, utcTimestamp } from "../../shared/status.js";
22
+ import { persistStatus, reportSetupProgress, slimStatus, utcTimestamp } from "../../shared/status.js";
23
+ import {
24
+ acquireWorkspaceLock,
25
+ WorkspaceLockCancelledError,
26
+ WorkspaceLockTimeoutError,
27
+ type ReleaseWorkspaceLock,
28
+ } from "../../shared/workspace/workspace-lock.js";
23
29
  import type { ToolOutputOffloadContext } from "../../shared/status-offload.js";
24
30
  import { publishPlanArtifact } from "../../shared/plan-artifact.js";
25
31
  import { classifyTool } from "../../shared/tool-kind.js";
@@ -48,7 +54,7 @@ import { hasCandidateCaptured } from "../../shared/filereview/events.js";
48
54
  import { casBlobReader, type CasPathCapture } from "../../shared/filereview/cas-substrate.js";
49
55
  import { partitionIgnoredPathsBySecret } from "../../shared/filereview/secret-paths.js";
50
56
  import type { CasCaptureObserver } from "./cas-capture-observer.js";
51
- import { collectSubAgentToolCallIds } from "../../shared/tool-row.js";
57
+ import { collectSubAgentToolCallIds, withholdSecretContentFromMessages } from "../../shared/tool-row.js";
52
58
  import { stampFlowedFileEditRows, stampFlowedSubAgentFileEditRows } from "./stamp-flowed-rows.js";
53
59
 
54
60
  /** The harness id stamped on the deep-agent's file-review ledger events. */
@@ -75,6 +81,11 @@ export function createDeepAgentActivities(config: Config) {
75
81
  const { executionId, threadId, turnSeq } = normalizeActivityInput(arg0, arg1);
76
82
  activityStarted();
77
83
  let setup: SetupResult | null = null;
84
+ // Exclusive turn lock on the workspace working tree — held across the
85
+ // entire tree-mutating window (decision reconcile, agent writes,
86
+ // candidate capture, write-back) and released in the finally. Mirrors
87
+ // the Cursor harness; see shared/workspace/workspace-lock.ts.
88
+ let releaseWorkspaceLock: ReleaseWorkspaceLock | undefined;
78
89
 
79
90
  try {
80
91
  console.log(`[ExecuteDeepAgent] Started for execution ${executionId}`);
@@ -163,6 +174,45 @@ export function createDeepAgentActivities(config: Config) {
163
174
  const gitRoot = setup.workspaceBackend.rootDir;
164
175
  const changeSetId = `${executionId}:${turnSeq}`;
165
176
 
177
+ // Serialize this turn against every other execution sharing this
178
+ // working tree — an unserialized concurrent write lands inside this
179
+ // turn's baseline→candidate window and gets misattributed to this
180
+ // turn's review. Acquired before ANY tree mutation below (decision
181
+ // reconcile, agent writes, candidate capture, write-back); waiting
182
+ // surfaces a visible state and heartbeats, and a cancel aborts the
183
+ // wait immediately. Mirrors the Cursor harness wiring exactly.
184
+ try {
185
+ releaseWorkspaceLock = await acquireWorkspaceLock(gitRoot, {
186
+ onWaiting: () => reportSetupProgress(
187
+ client, executionId, "Waiting for workspace — in use by another session",
188
+ ),
189
+ heartbeat: () => Context.current().heartbeat(),
190
+ signal: Context.current().cancellationSignal,
191
+ timeoutMs: config.workspaceLockTimeoutMs,
192
+ });
193
+ } catch (lockErr) {
194
+ if (lockErr instanceof WorkspaceLockCancelledError) {
195
+ throw new CancelledFailure("Activity cancelled while waiting for the workspace lock");
196
+ }
197
+ if (lockErr instanceof WorkspaceLockTimeoutError) {
198
+ const failedStatus = create(AgentExecutionStatusSchema, {
199
+ phase: ExecutionPhase.EXECUTION_FAILED,
200
+ error: lockErr.message,
201
+ completedAt: utcTimestamp(),
202
+ messages: [
203
+ create(AgentMessageSchema, {
204
+ type: MessageType.MESSAGE_SYSTEM,
205
+ content: `Execution failed: ${lockErr.message}`,
206
+ timestamp: utcTimestamp(),
207
+ }),
208
+ ],
209
+ });
210
+ await persistStatus(client, executionId, failedStatus, { offload: statusOffload });
211
+ return slimStatus(failedStatus);
212
+ }
213
+ throw lockErr;
214
+ }
215
+
166
216
  // (1) Capture-mode resume — reconcile any DECIDED change set FIRST (this
167
217
  // drops the per-execution refs), before the next baseline re-pins them
168
218
  // (refs are executionId-keyed). Then (2) short-circuit a pure file-review
@@ -308,6 +358,20 @@ export function createDeepAgentActivities(config: Config) {
308
358
  executionId,
309
359
  });
310
360
 
361
+ // Never-persist-secret backstop (DD-26 #2): withhold content from any
362
+ // built-in write row targeting a secret-like path, across the top-level
363
+ // and sub-agent transcripts, before ANY persist below. On the deny-gate a
364
+ // secret write is hard-blocked (a COMPLETED row) or — under
365
+ // auto_approve_all, where no gate is installed — flowed; either way the
366
+ // streamed row still carries `args` from handleToolStarted, and the
367
+ // capture-mode stamping pass (which performs the same scrub) does not run
368
+ // here. Idempotent / a no-op in capture mode. This single post-stream
369
+ // sweep is the one choke point that precedes every downstream persist.
370
+ withholdSecretContentFromMessages(
371
+ initialStatus.messages,
372
+ initialStatus.subAgentExecutions,
373
+ );
374
+
311
375
  // Turn boundary (capture mode): capture the candidate change set from the
312
376
  // git diff and author CANDIDATE_CAPTURED, then stamp the flowed file-edit
313
377
  // rows with the change set id — they stay visible in place as
@@ -590,6 +654,10 @@ export function createDeepAgentActivities(config: Config) {
590
654
 
591
655
  } finally {
592
656
  await cleanup(setup);
657
+ // Release the workspace turn lock LAST — the next queued turn must
658
+ // not baseline until every mutation of this one has landed.
659
+ // Idempotent and non-throwing (see workspace-lock.ts).
660
+ await releaseWorkspaceLock?.();
593
661
  activityFinished();
594
662
  }
595
663
  },
@@ -7,8 +7,14 @@
7
7
  */
8
8
 
9
9
  import { relative } from "node:path";
10
+ import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
10
11
  import type { ProvisionResult, GitMetadata } from "../../shared/workspace/types.js";
11
12
  import { SourceType } from "../../shared/workspace/types.js";
13
+ import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
14
+ import {
15
+ buildImplementPlanDirective,
16
+ findApprovedPlanPath,
17
+ } from "../../shared/implement-plan-prompt.js";
12
18
 
13
19
  const RESPONSE_RULES = `
14
20
 
@@ -84,6 +90,22 @@ export interface PromptBuilderInput {
84
90
  workspaceFileRefs: string[];
85
91
  workspaceRoot: string;
86
92
  injectedFiles: InjectedFile[];
93
+ /**
94
+ * The execution's interaction mode. PLAN appends the shared plan-mode
95
+ * directive so the model knows the turn's deliverable is a plan document.
96
+ * Tool-level write enforcement is separate (see setup.ts permissions) —
97
+ * without this directive the model is silently read-only but never told
98
+ * to produce a plan.
99
+ */
100
+ interactionMode?: InteractionMode;
101
+ /**
102
+ * The execution is a Build-from-plan turn (spec.execution_config
103
+ * .build_from_plan): appends the shared implement-plan directive, pointing
104
+ * the model at the injected approved plan document (or, when the plan
105
+ * attachment did not materialize, at the conversation's plan). The user
106
+ * message itself is just a short label ("Build from plan").
107
+ */
108
+ buildFromPlan?: boolean;
87
109
  }
88
110
 
89
111
  export interface InjectedFile {
@@ -128,6 +150,23 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
128
150
  prompt += RESPONSE_RULES;
129
151
  prompt += SUB_AGENT_RULES;
130
152
 
153
+ // Last sections on purpose: these per-execution directives redefine the
154
+ // turn's deliverable, so they must be the freshest instruction the model
155
+ // reads. (PLAN and build_from_plan are mutually exclusive in practice —
156
+ // the build turn is always an Agent-mode execution.)
157
+ if (input.interactionMode === InteractionMode.PLAN) {
158
+ prompt += "\n\n## Plan mode\n\n" + PLAN_MODE_DIRECTIVE;
159
+ }
160
+
161
+ if (input.buildFromPlan) {
162
+ const planPath = findApprovedPlanPath(
163
+ input.injectedFiles.map((f) => f.path),
164
+ );
165
+ prompt +=
166
+ "\n\n## Implement the approved plan\n\n" +
167
+ buildImplementPlanDirective(planPath);
168
+ }
169
+
131
170
  return prompt;
132
171
  }
133
172
 
@@ -35,6 +35,7 @@ import { isGitWorkTree, isPathCapturable } from "../../shared/filereview/git-sub
35
35
  import { deriveCaptureMode } from "../../shared/filereview/capture.js";
36
36
  import { resolveWorkspacePath } from "../../shared/file-change.js";
37
37
  import { ensurePlatformDir } from "../../shared/workspace/platform-dir.js";
38
+ import { resolveSessionWorkspaceRoot } from "../../shared/workspace/session-root.js";
38
39
  import { buildWorkspaceFileTree } from "../../shared/workspace/file-tree.js";
39
40
  import { reportSetupProgress } from "../../shared/status.js";
40
41
  import { resolveEnvironment, type EnvironmentResult } from "./environment.js";
@@ -49,7 +50,7 @@ import { getDefaultModel } from "../../shared/model-registry.js";
49
50
  import { buildChatModel } from "../../shared/model-client.js";
50
51
  import {
51
52
  loadArtifactStorageConfig,
52
- createArtifactStorage,
53
+ resolveUsableArtifactStorage,
53
54
  type ArtifactStorage,
54
55
  } from "../../shared/artifact-storage.js";
55
56
  import {
@@ -201,20 +202,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
201
202
  await reportSetupProgress(client, executionId, "Resolving environment…");
202
203
  const envResult: EnvironmentResult = await resolveEnvironment(client, executionId);
203
204
 
204
- // Step 6: Create artifact storage. Best-effort, mirroring the Cursor harness:
205
- // `createArtifactStorage` throws only for proxy transport with a missing
206
- // token/endpoint (a misconfiguration; local storage never throws). We degrade
207
- // rather than crash an absent store flips capture mode off (deny-gate
208
- // fallback below) and disables offload, instead of failing the whole execution.
209
- let artifactStorage: ArtifactStorage | undefined;
210
- try {
211
- artifactStorage = createArtifactStorage(loadArtifactStorageConfig(config));
212
- } catch (storageErr) {
213
- console.warn(
214
- `[setup] artifact storage unavailable — capture degrades to the deny-gate ` +
215
- `and tool-output offload is disabled: execution=${executionId}, error=${storageErr}`,
216
- );
217
- }
205
+ // Step 6: Resolve a usable artifact store (shared with the Cursor harness so
206
+ // both degrade identically). Returns `undefined` never throws when there
207
+ // is no working substrate: a proxy misconfig OR an unwritable local path
208
+ // (which `createArtifactStorage` cannot detect, since it just holds a path).
209
+ // An absent store flips capture mode off (deny-gate fallback below) and
210
+ // disables offload, instead of flowing writes then crashing at the boundary.
211
+ const artifactStorage: ArtifactStorage | undefined =
212
+ await resolveUsableArtifactStorage(loadArtifactStorageConfig(config), { executionId });
218
213
 
219
214
  // Step 7: Provision workspace
220
215
  await reportSetupProgress(client, executionId, "Initializing workspace…");
@@ -372,6 +367,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
372
367
  workspaceFileRefs: execution.spec!.workspaceFileRefs || [],
373
368
  workspaceRoot: workspaceBackend.rootDir,
374
369
  injectedFiles,
370
+ interactionMode: execution.spec!.executionConfig?.interactionMode,
371
+ buildFromPlan: execution.spec!.executionConfig?.buildFromPlan,
375
372
  });
376
373
 
377
374
  // Step 9: Construct the LLM model. Resolution to the provider API id
@@ -631,6 +628,11 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
631
628
 
632
629
  /**
633
630
  * Provision workspace from session workspace entries.
631
+ *
632
+ * A session with no entries gets its own empty per-session directory (see
633
+ * shared/workspace/session-root.ts) — never the shared root, which would
634
+ * leak other sessions' files into it. Mirrors the Cursor harness's
635
+ * provisionCursorWorkspace exactly.
634
636
  */
635
637
  async function provisionWorkspace(
636
638
  config: Config,
@@ -639,13 +641,19 @@ async function provisionWorkspace(
639
641
  sessionId: string,
640
642
  ): Promise<{ workspaceBackend: WorkspaceBackend; provisionResults: ProvisionResult[] }> {
641
643
  const platformDir = await ensurePlatformDir(sessionId);
642
- const workspaceBackend = new LocalWorkspaceBackend(config.workspaceRootDir, platformDir);
643
644
 
644
645
  const workspaceEntries = session.spec!.workspaceEntries || [];
645
646
  if (workspaceEntries.length === 0) {
646
- return { workspaceBackend, provisionResults: [] };
647
+ const sessionRoot = await resolveSessionWorkspaceRoot(
648
+ config.workspaceRootDir, workspaceEntries, sessionId,
649
+ );
650
+ return {
651
+ workspaceBackend: new LocalWorkspaceBackend(sessionRoot, platformDir),
652
+ provisionResults: [],
653
+ };
647
654
  }
648
655
 
656
+ const workspaceBackend = new LocalWorkspaceBackend(config.workspaceRootDir, platformDir);
649
657
  const provisioner = new WorkspaceProvisioner();
650
658
  const provisionResults = await provisioner.provisionAll(
651
659
  workspaceEntries.map(entry => ({
package/src/config.ts CHANGED
@@ -37,6 +37,12 @@ import { homedir, tmpdir } from "node:os";
37
37
  */
38
38
  export const DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS = 180_000;
39
39
 
40
+ // Re-exported so the runner/manager options mappers default the lock-wait
41
+ // bound from the same constant the lock module owns (no drift across the
42
+ // three construction sites — mirrors DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS).
43
+ export { DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./shared/workspace/workspace-lock.js";
44
+ import { DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./shared/workspace/workspace-lock.js";
45
+
40
46
  export interface Config {
41
47
  readonly taskQueue: string;
42
48
  readonly temporalAddress: string;
@@ -83,6 +89,12 @@ export interface Config {
83
89
  * heartbeatTimeout (process liveness) and the 30s keep-alive heartbeat.
84
90
  */
85
91
  readonly cursorStreamStallTimeoutMs: number;
92
+ /**
93
+ * Max wait (milliseconds) for the per-workspace turn lock before failing
94
+ * the execution with a "workspace is in use by another session" error.
95
+ * See shared/workspace/workspace-lock.ts for the serialization model.
96
+ */
97
+ readonly workspaceLockTimeoutMs: number;
86
98
  /** Shared mutable token reference for dynamic token updates (manager mode). */
87
99
  readonly stigmerTokenRef?: { current: string | null };
88
100
  }
@@ -145,6 +157,10 @@ export function loadConfig(): Config {
145
157
  ? parseInt(process.env.CURSOR_STREAM_STALL_TIMEOUT_MS, 10)
146
158
  : DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS;
147
159
 
160
+ const workspaceLockTimeoutMs = process.env.WORKSPACE_LOCK_TIMEOUT_MS
161
+ ? parseInt(process.env.WORKSPACE_LOCK_TIMEOUT_MS, 10)
162
+ : DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS;
163
+
148
164
  return {
149
165
  taskQueue,
150
166
  temporalAddress,
@@ -162,6 +178,7 @@ export function loadConfig(): Config {
162
178
  checkpointerProxyEndpoint,
163
179
  primaryModel,
164
180
  cursorStreamStallTimeoutMs,
181
+ workspaceLockTimeoutMs,
165
182
  };
166
183
  }
167
184
 
@@ -398,7 +398,10 @@ describe("ApprovalGateMiddleware", () => {
398
398
  expect(mockedInterrupt).not.toHaveBeenCalled();
399
399
  });
400
400
 
401
- it("KEEPS GATING a gitignored write (it cannot be captured or reverted)", async () => {
401
+ it("KEEPS GATING a non-secret gitignored write (it cannot be captured or reverted)", async () => {
402
+ // A NON-secret gitignored path: no CAS routing (captureIgnored unset) and not
403
+ // secret-like, so it stays on the interrupt gate. (A secret-like gitignored
404
+ // write is hard-blocked instead — see the DD-26 #2 deny-gate cases below.)
402
405
  const mw = createApprovalGateMiddleware(makeConfig({
403
406
  fileCaptureMode: true,
404
407
  isCapturablePath: async () => false, // gitignored
@@ -406,7 +409,7 @@ describe("ApprovalGateMiddleware", () => {
406
409
  mockedInterrupt.mockReturnValue({ action: "approve" });
407
410
 
408
411
  await mw.wrapToolCall!(
409
- makeRequest({ name: "write", args: { path: ".env" } }),
412
+ makeRequest({ name: "write", args: { path: "dist/bundle.js" } }),
410
413
  passthrough,
411
414
  );
412
415
 
@@ -650,12 +653,12 @@ describe("ApprovalGateMiddleware", () => {
650
653
  expect(mockedInterrupt).toHaveBeenCalledTimes(1);
651
654
  });
652
655
 
653
- it("GATES a secret-like write when capture mode is off Cursor parity: gated, NOT hard-blocked", async () => {
654
- // The secret hard-block lives only inside the fileCaptureMode+captureIgnored
655
- // arm; with no substrate that arm is off, so a secret-like write is GATED
656
- // (shown for approval) rather than hard-blocked exactly what Cursor's
657
- // no-storage hook does. It exposes no more than Cursor (secret parity, see
658
- // the parity investigation in the project docs).
656
+ it("HARD-BLOCKS a secret-like write when capture mode is off (DD-26 #2): never gated, never applied", async () => {
657
+ // DD-26 follow-up #2 supersedes the earlier "gated, not hard-blocked" parity:
658
+ // a secret-like write must NEVER surface its content for approval, in ANY
659
+ // mode. On the deny-gate it is hard-blocked exactly like the capture-mode
660
+ // secret block never interrupted, never applied. The content never reaches
661
+ // a pending approval or the persisted transcript.
659
662
  const handler = vi.fn(passthrough);
660
663
  const recordBlockedSecret = vi.fn();
661
664
  const mw = createApprovalGateMiddleware(makeConfig({
@@ -665,14 +668,76 @@ describe("ApprovalGateMiddleware", () => {
665
668
  }));
666
669
  mockedInterrupt.mockReturnValue({ action: "approve" });
667
670
 
671
+ const result = await mw.wrapToolCall!(
672
+ makeRequest({ name: "write", args: { path: ".env", content: "API_KEY=xyz" } }),
673
+ handler,
674
+ );
675
+
676
+ expect(mockedInterrupt).not.toHaveBeenCalled(); // hard-block, not a pause
677
+ expect(handler).not.toHaveBeenCalled(); // never applied
678
+ expect(recordBlockedSecret).toHaveBeenCalledWith(".env");
679
+ expect((result as ToolMessage).content).toContain("blocked for security");
680
+ });
681
+
682
+ it("hard-blocks a secret-like edit too (id_rsa via path fragment)", async () => {
683
+ const handler = vi.fn(passthrough);
684
+ const mw = createApprovalGateMiddleware(makeConfig({
685
+ fileCaptureMode: false,
686
+ captureIgnored: false,
687
+ }));
688
+ mockedInterrupt.mockReturnValue({ action: "approve" });
689
+
690
+ const result = await mw.wrapToolCall!(
691
+ makeRequest({ name: "edit", args: { path: ".ssh/id_rsa", old_string: "a", new_string: "b" } }),
692
+ handler,
693
+ );
694
+
695
+ expect(mockedInterrupt).not.toHaveBeenCalled();
696
+ expect(handler).not.toHaveBeenCalled();
697
+ expect((result as ToolMessage).content).toContain("blocked for security");
698
+ });
699
+
700
+ it("hard-blocks a secret write in a git workspace with no storage (captureMode on, captureIgnored off)", async () => {
701
+ // captureMode true + captureIgnored false = a git workspace with no artifact
702
+ // storage. A gitignored secret write skips the captureIgnored arm and would
703
+ // otherwise have reached the deny-gate (a leak); it is now hard-blocked too,
704
+ // and the recorded path lets the turn boundary author a content-less entry.
705
+ const handler = vi.fn(passthrough);
706
+ const recordBlockedSecret = vi.fn();
707
+ const mw = createApprovalGateMiddleware(makeConfig({
708
+ fileCaptureMode: true,
709
+ isCapturablePath: async () => false, // gitignored
710
+ captureIgnored: false, // no artifact storage
711
+ recordBlockedSecret,
712
+ }));
713
+ mockedInterrupt.mockReturnValue({ action: "approve" });
714
+
715
+ const result = await mw.wrapToolCall!(
716
+ makeRequest({ name: "write", args: { path: "secrets.yaml", content: "token: t" } }),
717
+ handler,
718
+ );
719
+
720
+ expect(mockedInterrupt).not.toHaveBeenCalled();
721
+ expect(handler).not.toHaveBeenCalled();
722
+ expect(recordBlockedSecret).toHaveBeenCalledWith("secrets.yaml");
723
+ expect((result as ToolMessage).content).toContain("blocked for security");
724
+ });
725
+
726
+ it("still GATES a NON-secret write when capture mode is off (deny-gate unchanged)", async () => {
727
+ const handler = vi.fn(passthrough);
728
+ const mw = createApprovalGateMiddleware(makeConfig({
729
+ fileCaptureMode: false,
730
+ captureIgnored: false,
731
+ }));
732
+ mockedInterrupt.mockReturnValue({ action: "approve" });
733
+
668
734
  await mw.wrapToolCall!(
669
- makeRequest({ name: "write", args: { path: ".env" } }),
735
+ makeRequest({ name: "write", args: { path: "notes.md", content: "hi" } }),
670
736
  handler,
671
737
  );
672
738
 
673
- expect(mockedInterrupt).toHaveBeenCalledTimes(1); // gated, not hard-blocked
674
- expect(recordBlockedSecret).not.toHaveBeenCalled(); // no capture arm to record it
675
- expect(handler).toHaveBeenCalledTimes(1); // flows only after the user approves
739
+ expect(mockedInterrupt).toHaveBeenCalledTimes(1); // a non-secret write still gates
740
+ expect(handler).toHaveBeenCalledTimes(1); // flows after approve
676
741
  });
677
742
 
678
743
  it("still auto-approves read-only built-ins when capture mode is off", async () => {
@@ -138,6 +138,24 @@ const CATEGORY_APPROVAL_MESSAGE: Record<ToolApprovalCategory, string> = {
138
138
  shell: "Execute command: {{args.command}}",
139
139
  };
140
140
 
141
+ /**
142
+ * The ToolMessage returned when a secret-like write is hard-blocked (DD-E /
143
+ * DD-26 #2): the write is NEVER applied and the graph continues (this replaces
144
+ * the tool call's side effect, so the model moves on rather than waiting). The
145
+ * path is named (a filename is not itself the secret); the CONTENT is not echoed.
146
+ * Shared by the capture-mode secret block and the deny-gate secret block so both
147
+ * speak with one voice.
148
+ */
149
+ function secretBlockToolMessage(toolName: string, path: string, toolCallId: string): ToolMessage {
150
+ return new ToolMessage({
151
+ content:
152
+ `Tool '${toolName}' was blocked for security: '${path}' matches a ` +
153
+ `secret-like path Stigmer will not capture for review. Nothing was written.`,
154
+ tool_call_id: toolCallId,
155
+ name: toolName,
156
+ });
157
+ }
158
+
141
159
  export function createApprovalGateMiddleware(
142
160
  config: ApprovalGateConfig,
143
161
  ): StigmerMiddleware {
@@ -183,13 +201,7 @@ export function createApprovalGateMiddleware(
183
201
  // and NEVER captured. Record it so the turn boundary authors a
184
202
  // DIFF_UNREVIEWABLE entry (blocking approval); nothing is written.
185
203
  config.recordBlockedSecret?.(path);
186
- return new ToolMessage({
187
- content:
188
- `Tool '${toolName}' was blocked for security: '${path}' matches a ` +
189
- `secret-like path Stigmer will not capture for review. Nothing was written.`,
190
- tool_call_id: toolCall.id,
191
- name: toolName,
192
- });
204
+ return secretBlockToolMessage(toolName, path, toolCall.id);
193
205
  }
194
206
  if (category === "write") {
195
207
  // Non-secret gitignored write/edit: flows (apply-then-review). The
@@ -203,6 +215,26 @@ export function createApprovalGateMiddleware(
203
215
  }
204
216
  }
205
217
 
218
+ // Deny-gate secret hard-block (DD-26 #2): a built-in file WRITE to a
219
+ // secret-like path that reaches here has no capture substrate for it — the
220
+ // classic no-storage deny-gate, or a git workspace with no artifact storage
221
+ // whose gitignored write skipped the captureIgnored arm above. It must NOT
222
+ // surface its content for approval, so hard-block it (never applied, graph
223
+ // continues) exactly like the capture-mode secret block — a secret write is
224
+ // never applied or persisted in ANY mode. Placed AFTER the capture block so
225
+ // capture-mode paths stay byte-identical (a capturable write already flowed;
226
+ // a captureIgnored gitignored secret is already blocked). Deletes carry no
227
+ // content and stay on the deny-gate. recordBlockedSecret is a no-op unless a
228
+ // turn boundary reads it (the git-no-storage case, where it authors a
229
+ // content-less DIFF_UNREVIEWABLE).
230
+ if (!serverSlug && category === "write") {
231
+ const path = extractFilePath(toolCall.args);
232
+ if (path !== null && isSecretLikePath(path)) {
233
+ config.recordBlockedSecret?.(path);
234
+ return secretBlockToolMessage(toolName, path, toolCall.id);
235
+ }
236
+ }
237
+
206
238
  const requirement = resolveToolApproval(
207
239
  toolName,
208
240
  serverSlug,
@@ -24,7 +24,7 @@ import {
24
24
  } from "@temporalio/worker";
25
25
  import type { PayloadCodec } from "@temporalio/common";
26
26
  import type { Config } from "./config.js";
27
- import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS } from "./config.js";
27
+ import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
28
28
  import type { WorkerActivities } from "./worker.js";
29
29
  import { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
30
30
  import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
@@ -91,6 +91,9 @@ export interface RunnerManagerOptions {
91
91
  /** No-progress bound for the Cursor harness stream (ms). @default 180000 */
92
92
  readonly cursorStreamStallTimeoutMs?: number;
93
93
 
94
+ /** Max wait for the per-workspace turn lock (ms). @default 900000 */
95
+ readonly workspaceLockTimeoutMs?: number;
96
+
94
97
  /** Checkpointer type for LangGraph agent state. @default "memory" (or "http" if proxyEndpoint is set) */
95
98
  readonly checkpointerType?: "memory" | "http";
96
99
 
@@ -576,6 +579,8 @@ export function mapManagerOptionsToConfig(
576
579
  primaryModel: options.primaryModel ?? "gpt-4.1",
577
580
  cursorStreamStallTimeoutMs:
578
581
  options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
582
+ workspaceLockTimeoutMs:
583
+ options.workspaceLockTimeoutMs ?? DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
579
584
  };
580
585
  }
581
586