@stigmer/runner 3.1.1 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) 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/capture-flow.d.ts +18 -0
  6. package/dist/activities/execute-cursor/capture-flow.js +21 -0
  7. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  8. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  9. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  10. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  11. package/dist/activities/execute-cursor/hook-script.js +19 -1
  12. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  13. package/dist/activities/execute-cursor/index.d.ts +5 -0
  14. package/dist/activities/execute-cursor/index.js +222 -160
  15. package/dist/activities/execute-cursor/index.js.map +1 -1
  16. package/dist/activities/execute-cursor/message-translator.js +10 -1
  17. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  18. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  19. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  20. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  21. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  22. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  23. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  24. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  25. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  26. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  27. package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
  28. package/dist/activities/execute-cursor/turn-boundary.js +163 -0
  29. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
  30. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  31. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  32. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  33. package/dist/activities/execute-deep-agent/index.js +89 -5
  34. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  35. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  36. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  37. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  38. package/dist/activities/execute-deep-agent/setup.js +22 -16
  39. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  40. package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
  41. package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
  42. package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
  43. package/dist/activities/execute-deep-agent/streaming.js +5 -1
  44. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  45. package/dist/config.d.ts +7 -0
  46. package/dist/config.js +9 -0
  47. package/dist/config.js.map +1 -1
  48. package/dist/middleware/approval-gate.js +36 -6
  49. package/dist/middleware/approval-gate.js.map +1 -1
  50. package/dist/runner-manager.d.ts +2 -0
  51. package/dist/runner-manager.js +2 -1
  52. package/dist/runner-manager.js.map +1 -1
  53. package/dist/runner.d.ts +2 -0
  54. package/dist/runner.js +2 -1
  55. package/dist/runner.js.map +1 -1
  56. package/dist/shared/artifact-storage.d.ts +32 -0
  57. package/dist/shared/artifact-storage.js +74 -1
  58. package/dist/shared/artifact-storage.js.map +1 -1
  59. package/dist/shared/filereview/capture.d.ts +18 -1
  60. package/dist/shared/filereview/capture.js +105 -28
  61. package/dist/shared/filereview/capture.js.map +1 -1
  62. package/dist/shared/filereview/cas-substrate.d.ts +9 -0
  63. package/dist/shared/filereview/cas-substrate.js +9 -1
  64. package/dist/shared/filereview/cas-substrate.js.map +1 -1
  65. package/dist/shared/filereview/events.d.ts +16 -0
  66. package/dist/shared/filereview/events.js +37 -0
  67. package/dist/shared/filereview/events.js.map +1 -1
  68. package/dist/shared/filereview/git-substrate.d.ts +42 -0
  69. package/dist/shared/filereview/git-substrate.js +78 -0
  70. package/dist/shared/filereview/git-substrate.js.map +1 -1
  71. package/dist/shared/filereview/index.d.ts +3 -1
  72. package/dist/shared/filereview/index.js +3 -1
  73. package/dist/shared/filereview/index.js.map +1 -1
  74. package/dist/shared/filereview/line-counts.d.ts +48 -0
  75. package/dist/shared/filereview/line-counts.js +72 -0
  76. package/dist/shared/filereview/line-counts.js.map +1 -0
  77. package/dist/shared/filereview/progress.d.ts +96 -0
  78. package/dist/shared/filereview/progress.js +134 -0
  79. package/dist/shared/filereview/progress.js.map +1 -0
  80. package/dist/shared/implement-plan-prompt.d.ts +44 -0
  81. package/dist/shared/implement-plan-prompt.js +98 -0
  82. package/dist/shared/implement-plan-prompt.js.map +1 -0
  83. package/dist/shared/plan-artifact.d.ts +59 -11
  84. package/dist/shared/plan-artifact.js +144 -16
  85. package/dist/shared/plan-artifact.js.map +1 -1
  86. package/dist/shared/plan-mode-prompt.d.ts +32 -0
  87. package/dist/shared/plan-mode-prompt.js +56 -0
  88. package/dist/shared/plan-mode-prompt.js.map +1 -0
  89. package/dist/shared/status.d.ts +11 -0
  90. package/dist/shared/status.js +12 -2
  91. package/dist/shared/status.js.map +1 -1
  92. package/dist/shared/tool-row.d.ts +30 -1
  93. package/dist/shared/tool-row.js +53 -4
  94. package/dist/shared/tool-row.js.map +1 -1
  95. package/dist/shared/workspace/session-root.d.ts +35 -0
  96. package/dist/shared/workspace/session-root.js +53 -0
  97. package/dist/shared/workspace/session-root.js.map +1 -0
  98. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  99. package/dist/shared/workspace/workspace-lock.js +187 -0
  100. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  101. package/package.json +8 -5
  102. package/src/__tests__/config.test.ts +8 -0
  103. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  104. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  105. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  106. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
  107. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  108. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
  109. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  110. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  111. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
  112. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  113. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  114. package/src/activities/execute-cursor/capture-flow.ts +32 -0
  115. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  116. package/src/activities/execute-cursor/hook-script.ts +19 -1
  117. package/src/activities/execute-cursor/index.ts +243 -178
  118. package/src/activities/execute-cursor/message-translator.ts +9 -1
  119. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  120. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  121. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  122. package/src/activities/execute-cursor/turn-boundary.ts +274 -0
  123. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  124. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  125. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  126. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  127. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
  128. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  129. package/src/activities/execute-deep-agent/index.ts +104 -5
  130. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  131. package/src/activities/execute-deep-agent/setup.ts +25 -17
  132. package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
  133. package/src/activities/execute-deep-agent/streaming.ts +12 -1
  134. package/src/config.ts +17 -0
  135. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  136. package/src/middleware/approval-gate.ts +39 -7
  137. package/src/runner-manager.ts +6 -1
  138. package/src/runner.ts +6 -1
  139. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  140. package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
  141. package/src/shared/__tests__/plan-artifact.test.ts +142 -9
  142. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  143. package/src/shared/__tests__/status.test.ts +7 -2
  144. package/src/shared/__tests__/tool-row.test.ts +136 -1
  145. package/src/shared/artifact-storage.ts +83 -1
  146. package/src/shared/filereview/__tests__/capture.test.ts +182 -1
  147. package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
  148. package/src/shared/filereview/__tests__/events.test.ts +105 -0
  149. package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
  150. package/src/shared/filereview/__tests__/progress.test.ts +236 -0
  151. package/src/shared/filereview/capture.ts +134 -30
  152. package/src/shared/filereview/cas-substrate.ts +17 -1
  153. package/src/shared/filereview/events.ts +50 -0
  154. package/src/shared/filereview/git-substrate.ts +108 -0
  155. package/src/shared/filereview/index.ts +22 -1
  156. package/src/shared/filereview/line-counts.ts +83 -0
  157. package/src/shared/filereview/progress.ts +186 -0
  158. package/src/shared/implement-plan-prompt.ts +104 -0
  159. package/src/shared/plan-artifact.ts +152 -16
  160. package/src/shared/plan-mode-prompt.ts +56 -0
  161. package/src/shared/status.ts +11 -1
  162. package/src/shared/tool-row.ts +58 -5
  163. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  164. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  165. package/src/shared/workspace/session-root.ts +60 -0
  166. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -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
 
package/src/runner.ts CHANGED
@@ -17,7 +17,7 @@ import { join } from "node:path";
17
17
  import { homedir, tmpdir } from "node:os";
18
18
  import type { PayloadCodec } from "@temporalio/common";
19
19
  import type { Config } from "./config.js";
20
- import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS } from "./config.js";
20
+ import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
21
21
  import type { WorkerActivities } from "./worker.js";
22
22
  import { resolveRunnerBootstrap } from "./bootstrap.js";
23
23
 
@@ -69,6 +69,9 @@ export interface StigmerRunnerOptions {
69
69
  /** No-progress bound for the Cursor harness stream (ms). @default 180000 */
70
70
  readonly cursorStreamStallTimeoutMs?: number;
71
71
 
72
+ /** Max wait for the per-workspace turn lock (ms). @default 900000 */
73
+ readonly workspaceLockTimeoutMs?: number;
74
+
72
75
  /** Checkpointer type for LangGraph agent state. @default "memory" (or "http" if proxyEndpoint is set) */
73
76
  readonly checkpointerType?: "memory" | "http";
74
77
 
@@ -274,6 +277,8 @@ export function mapOptionsToConfig(options: StigmerRunnerOptions): Config {
274
277
  primaryModel: options.primaryModel ?? "gpt-4.1",
275
278
  cursorStreamStallTimeoutMs:
276
279
  options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
280
+ workspaceLockTimeoutMs:
281
+ options.workspaceLockTimeoutMs ?? DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
277
282
  };
278
283
  }
279
284
 
@@ -1,14 +1,16 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import { mkdtemp, rm, readFile } from "node:fs/promises";
2
+ import { mkdtemp, rm, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import {
6
6
  LocalArtifactStorage,
7
7
  ProxyArtifactStorage,
8
8
  createArtifactStorage,
9
+ resolveUsableArtifactStorage,
9
10
  loadArtifactStorageConfig,
10
11
  type ArtifactStorageConfig,
11
12
  } from "../artifact-storage.js";
13
+ import { deriveCaptureMode } from "../filereview/capture.js";
12
14
 
13
15
  // ── LocalArtifactStorage ─────────────────────────────────────────────
14
16
 
@@ -428,6 +430,7 @@ describe("loadArtifactStorageConfig", () => {
428
430
  checkpointerProxyEndpoint: null,
429
431
  primaryModel: "gpt-4.1",
430
432
  cursorStreamStallTimeoutMs: 180000,
433
+ workspaceLockTimeoutMs: 900000,
431
434
  };
432
435
 
433
436
  afterEach(() => {
@@ -476,3 +479,110 @@ describe("loadArtifactStorageConfig", () => {
476
479
  expect(cfg.type).toBe("proxy");
477
480
  });
478
481
  });
482
+
483
+ // ── resolveUsableArtifactStorage (DD-26 follow-up #1) ─────────────────
484
+ //
485
+ // The shared construct-or-degrade seam: it must return `undefined` (never throw)
486
+ // for every "no working substrate" condition so both harnesses fall to the
487
+ // deny-gate up front, instead of flowing file writes then crashing at the
488
+ // turn-boundary upload.
489
+
490
+ describe("resolveUsableArtifactStorage", () => {
491
+ const ctx = { executionId: "exec-test" };
492
+ let tempDir: string;
493
+
494
+ beforeEach(async () => {
495
+ tempDir = await mkdtemp(join(tmpdir(), "resolve-artifact-test-"));
496
+ });
497
+
498
+ afterEach(async () => {
499
+ await rm(tempDir, { recursive: true, force: true });
500
+ vi.restoreAllMocks();
501
+ });
502
+
503
+ const localCfg = (localPath: string): ArtifactStorageConfig => ({
504
+ type: "local",
505
+ localPath,
506
+ localServeUrl: "http://localhost:7235",
507
+ proxyEndpoint: null,
508
+ proxyAuthToken: null,
509
+ });
510
+
511
+ it("returns a LocalArtifactStorage for a writable path and leaves no residue", async () => {
512
+ const base = join(tempDir, "artifacts");
513
+ const storage = await resolveUsableArtifactStorage(localCfg(base), ctx);
514
+
515
+ expect(storage).toBeInstanceOf(LocalArtifactStorage);
516
+ // The write-probe file must be cleaned up; the base dir may be created.
517
+ const entries = await readdir(base);
518
+ expect(entries).toEqual([]);
519
+ });
520
+
521
+ it("returns undefined for an unwritable local path (mkdir ENOTDIR)", async () => {
522
+ // Deterministic, cross-platform: point localPath at a child of a regular
523
+ // FILE, so the resolver's recursive mkdir fails with ENOTDIR.
524
+ const filePath = join(tempDir, "not-a-dir");
525
+ await writeFile(filePath, "");
526
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
527
+
528
+ const storage = await resolveUsableArtifactStorage(
529
+ localCfg(join(filePath, "sub")),
530
+ ctx,
531
+ );
532
+
533
+ expect(storage).toBeUndefined();
534
+ expect(warn).toHaveBeenCalledWith(
535
+ expect.stringContaining("local path not writable"),
536
+ );
537
+ });
538
+
539
+ it("returns undefined when proxy config is a misconfig (construct throws)", async () => {
540
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
541
+ const cfg: ArtifactStorageConfig = {
542
+ type: "proxy",
543
+ localPath: "/tmp/artifacts",
544
+ localServeUrl: "http://localhost:7235",
545
+ proxyEndpoint: "https://proxy.example.com",
546
+ proxyAuthToken: null, // missing token
547
+ };
548
+
549
+ const storage = await resolveUsableArtifactStorage(cfg, ctx);
550
+
551
+ expect(storage).toBeUndefined();
552
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("unavailable"));
553
+ });
554
+
555
+ it("returns a ProxyArtifactStorage for a valid proxy config without any network call", async () => {
556
+ // Proxy is never probed at the network layer — assert no fetch happens.
557
+ const fetchSpy = vi.spyOn(globalThis, "fetch");
558
+ const cfg: ArtifactStorageConfig = {
559
+ type: "proxy",
560
+ localPath: "/tmp/artifacts",
561
+ localServeUrl: "http://localhost:7235",
562
+ proxyEndpoint: "https://proxy.example.com",
563
+ proxyAuthToken: "token-123",
564
+ };
565
+
566
+ const storage = await resolveUsableArtifactStorage(cfg, ctx);
567
+
568
+ expect(storage).toBeInstanceOf(ProxyArtifactStorage);
569
+ expect(fetchSpy).not.toHaveBeenCalled();
570
+ });
571
+
572
+ it("wires through to deriveCaptureMode: an unwritable local store degrades a non-git turn to the deny-gate", async () => {
573
+ const filePath = join(tempDir, "not-a-dir");
574
+ await writeFile(filePath, "");
575
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
576
+
577
+ const storage = await resolveUsableArtifactStorage(
578
+ localCfg(join(filePath, "sub")),
579
+ ctx,
580
+ );
581
+
582
+ // Non-git workspace + no usable storage => no capture substrate => deny-gate.
583
+ expect(deriveCaptureMode("/some/workspace", false, !!storage)).toBe(false);
584
+ // A git workspace still captures (git substrate needs no storage), but its
585
+ // gitignored->CAS edits and offload are off because the store is absent.
586
+ expect(deriveCaptureMode("/some/workspace", true, !!storage)).toBe(true);
587
+ });
588
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildImplementPlanDirective,
4
+ findApprovedPlanPath,
5
+ } from "../implement-plan-prompt.js";
6
+
7
+ describe("findApprovedPlanPath", () => {
8
+ it("finds the plan by the legacy filename among attachment paths", () => {
9
+ expect(
10
+ findApprovedPlanPath([
11
+ ".stigmer/inputs/data.csv",
12
+ ".stigmer/inputs/plan.md",
13
+ ]),
14
+ ).toBe(".stigmer/inputs/plan.md");
15
+ });
16
+
17
+ it("finds a title-named <slug>_<id>.plan.md plan", () => {
18
+ expect(
19
+ findApprovedPlanPath([
20
+ ".stigmer/inputs/data.csv",
21
+ ".stigmer/inputs/plan-card-ux-cleanup_a1b2c3d4.plan.md",
22
+ ]),
23
+ ).toBe(".stigmer/inputs/plan-card-ux-cleanup_a1b2c3d4.plan.md");
24
+ });
25
+
26
+ it("returns undefined when no plan attachment resolved", () => {
27
+ expect(findApprovedPlanPath([".stigmer/inputs/data.csv"])).toBeUndefined();
28
+ expect(findApprovedPlanPath([])).toBeUndefined();
29
+ });
30
+
31
+ it("matches the plan convention only — a plan-adjacent filename is not the plan", () => {
32
+ expect(
33
+ findApprovedPlanPath([".stigmer/inputs/my-plan.md.bak"]),
34
+ ).toBeUndefined();
35
+ // A file merely containing "plan" is not a plan artifact.
36
+ expect(
37
+ findApprovedPlanPath([".stigmer/inputs/myplan.md"]),
38
+ ).toBeUndefined();
39
+ });
40
+ });
41
+
42
+ describe("buildImplementPlanDirective", () => {
43
+ it("points the model at the attached plan and names it authoritative", () => {
44
+ const directive = buildImplementPlanDirective(".stigmer/inputs/plan.md");
45
+
46
+ expect(directive).toContain("APPROVED");
47
+ expect(directive).toContain("`.stigmer/inputs/plan.md`");
48
+ expect(directive).toContain("Read it FIRST");
49
+ // The user may have edited the document after the plan turn — the file,
50
+ // not the conversation, is the approved text.
51
+ expect(directive).toContain("authoritative");
52
+ });
53
+
54
+ it("falls back to the conversation's plan when nothing is attached", () => {
55
+ const directive = buildImplementPlanDirective(undefined);
56
+
57
+ expect(directive).toContain("APPROVED");
58
+ expect(directive).toContain("conversation above");
59
+ expect(directive).not.toContain("plan.md");
60
+ });
61
+
62
+ // Tier 3 (plan-driven build progress): both variants instruct the agent to
63
+ // derive its to-do list from the plan and keep it current — the agent's todo
64
+ // tool is the single writer of status.todos, so this instruction is the
65
+ // entire plan→progress linkage.
66
+ it("instructs plan-derived progress tracking in the attached variant", () => {
67
+ const directive = buildImplementPlanDirective(".stigmer/inputs/plan.md");
68
+
69
+ expect(directive).toContain("to-do list");
70
+ expect(directive).toContain("break the plan into");
71
+ expect(directive).toContain("in progress");
72
+ expect(directive).toContain("completed");
73
+ });
74
+
75
+ it("instructs plan-derived progress tracking in the conversation-only variant", () => {
76
+ const directive = buildImplementPlanDirective(undefined);
77
+
78
+ expect(directive).toContain("to-do list");
79
+ expect(directive).toContain("break the plan into");
80
+ expect(directive).toContain("in progress");
81
+ expect(directive).toContain("completed");
82
+ // The progress block must never name the file — this variant exists
83
+ // because no plan file materialized.
84
+ expect(directive).not.toContain("plan.md");
85
+ });
86
+ });