@stigmer/runner 3.1.1 → 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
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The Plan-mode prompt directive, shared by both harnesses.
3
+ *
4
+ * Plan mode's output contract spans three components that must agree:
5
+ * - the model produces the plan as its FINAL message (this directive),
6
+ * - the runner publishes that final message verbatim as a `plan.md` artifact
7
+ * (`plan-artifact.ts` — `extractFinalPlanText`),
8
+ * - the SDK promotes the same message to a first-class plan document in the
9
+ * thread and offers "Build from plan".
10
+ *
11
+ * This module is the single source of truth for how the model is told to
12
+ * behave, so the two harnesses can never drift apart on the contract. Each
13
+ * harness wraps the directive in its own prompt framing (the Cursor harness
14
+ * uses XML-tag sections, the native harness markdown headings) — the framing
15
+ * is house style; the words are shared.
16
+ *
17
+ * Enforcement is separate from instruction: the native harness ALSO denies
18
+ * filesystem writes at the tool layer (see execute-deep-agent/setup.ts), so
19
+ * for it this directive is guidance toward a well-formed plan, not the
20
+ * enforcement mechanism. The Cursor harness has no tool-level lever (the
21
+ * Cursor SDK exposes no mode parameter), so there this directive is the
22
+ * enforcement — a documented stopgap.
23
+ */
24
+
25
+ /**
26
+ * Directive body injected into the system prompt of every Plan-mode
27
+ * execution. Deliberately explicit about the deliverable's shape: the final
28
+ * message is published verbatim as `plan.md`, so a fenced or chat-suffixed
29
+ * plan degrades the reviewable document the user sees.
30
+ */
31
+ export const PLAN_MODE_DIRECTIVE = [
32
+ "IMPORTANT: You are in Plan mode — a read-only analysis turn whose " +
33
+ "deliverable is an implementation plan.",
34
+ "",
35
+ "Constraints:",
36
+ "- Do NOT create, edit, or delete any files.",
37
+ "- Do NOT run commands that modify the filesystem or any external state.",
38
+ "- Only read, search, and analyze.",
39
+ "",
40
+ "Deliverable — your FINAL message IS the plan. It is published verbatim " +
41
+ "as a `plan.md` document that the user reviews and builds from, so:",
42
+ "- Write it as a complete, well-structured markdown document: start with " +
43
+ "a single `#` title and organize the work under `##` section headings. " +
44
+ "Use lists and tables where they aid scanning.",
45
+ "- Reference concrete file paths and describe the specific changes " +
46
+ "planned for each.",
47
+ "- Do NOT wrap the document in a code fence.",
48
+ '- Do NOT end with conversational closers ("Let me know...", "Shall I ' +
49
+ 'proceed?") — the next step is the user\'s Build action, and trailing ' +
50
+ "chat would be published as part of the document.",
51
+ ].join("\n");
@@ -193,6 +193,17 @@ export async function persistStatus(
193
193
  /**
194
194
  * Report a setup progress phase (e.g. "Resolving MCP servers") so the
195
195
  * frontend can display a spinner with context.
196
+ *
197
+ * Deliberately carries NO execution phase (UNSPECIFIED = "leave unchanged"):
198
+ * the server keeps `setup_progress` only while the merged phase is still
199
+ * PENDING (update_status.go / AgentExecutionUpdateStatusHandler clear it the
200
+ * moment the phase leaves PENDING), and the UI renders the label only in
201
+ * that window (SetupProgress.tsx: "Server-driven — preferred during
202
+ * PENDING"). Sending IN_PROGRESS here flipped the phase on the very first
203
+ * setup report, so every label self-destructed on arrival and the user saw
204
+ * a generic spinner instead. The genuine PENDING→IN_PROGRESS transition
205
+ * belongs to the streaming path, which persists it when the agent actually
206
+ * starts producing output.
196
207
  */
197
208
  export async function reportSetupProgress(
198
209
  client: StigmerClient,
@@ -200,7 +211,6 @@ export async function reportSetupProgress(
200
211
  phase: string,
201
212
  ): Promise<void> {
202
213
  const status = create(AgentExecutionStatusSchema, {
203
- phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
204
214
  setupProgress: create(SetupProgressSchema, { currentPhase: phase }),
205
215
  });
206
216
  await persistStatus(client, executionId, status);
@@ -18,10 +18,11 @@
18
18
  */
19
19
 
20
20
  import { ToolCallStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
21
- import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
21
+ import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
22
22
  import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
23
23
  import { extractFilePath } from "./file-tools.js";
24
24
  import { isSecretLikePath } from "./filereview/secret-paths.js";
25
+ import { toolApprovalCategory } from "./tool-kind.js";
25
26
  import { utcTimestamp } from "./status.js";
26
27
 
27
28
  /**
@@ -52,11 +53,63 @@ export function stampFileEditRow(tc: ToolCall, changeSetId: string): void {
52
53
  if (tc.fileChangeSetId) return;
53
54
  tc.fileChangeSetId = changeSetId;
54
55
 
55
- const path = extractFilePath((tc.args ?? {}) as Record<string, unknown>) ?? "";
56
- if (isSecretLikePath(path)) {
57
- tc.args = path ? { path } : undefined;
56
+ if (withholdSecretFileContent(tc)) {
57
+ // A FLOWED row's `result` is the tool's own output — for an edit that can
58
+ // echo the changed lines so drop it too. withholdSecretFileContent keeps
59
+ // `result` intact because the deny-gate path (below) uses it for the safe
60
+ // "blocked for security" message; here the row actually ran, so clear it.
58
61
  tc.result = "";
59
- tc.argsPreview = "";
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Withhold a file-mutating row's CONTENT while keeping its path visible: reduce
67
+ * `args` to `{ path }` (or `undefined` when the path cannot be determined) and
68
+ * clear `args_preview`. `result` and `status` are left untouched — a caller that
69
+ * needs the diff dropped clears it explicitly (see {@link stampFileEditRow}).
70
+ * Returns whether the row was secret-like (and therefore withheld).
71
+ *
72
+ * The single primitive behind the never-persist-secret-contents contract (design
73
+ * doc 12, D4): a file-mutating tool's `args` holds the full write body, which for
74
+ * a secret-like path must never reach the transcript / Temporal history. A
75
+ * filename is not itself the secret, so the path is kept. Fail-closed: an
76
+ * undeterminable path is treated as secret-like ({@link isSecretLikePath} of "").
77
+ */
78
+ export function withholdSecretFileContent(tc: ToolCall): boolean {
79
+ const path = extractFilePath((tc.args ?? {}) as Record<string, unknown>) ?? "";
80
+ if (!isSecretLikePath(path)) return false;
81
+ tc.args = path ? { path } : undefined;
82
+ tc.argsPreview = "";
83
+ return true;
84
+ }
85
+
86
+ /**
87
+ * Universal backstop (DD-26 follow-up #2): withhold secret content from every
88
+ * built-in file-WRITE row in a transcript (top-level + each sub-agent's), across
89
+ * BOTH harnesses, right before the status is persisted.
90
+ *
91
+ * This is the ONLY guarantee that survives `spec.auto_approve_all`, where the
92
+ * approval gate / deny-gate is never installed, so no per-harness hard-block runs
93
+ * — yet a secret write still flows and its content lands on the streamed row. It
94
+ * is a no-op in capture mode (the flowed rows are already content-less after
95
+ * {@link stampFileEditRow}, which shares {@link withholdSecretFileContent}), and
96
+ * is the deny-gate analog of the capture-mode stamping pass, which does not run
97
+ * when there is no change set. Scoped to `write` (FILE_WRITE/FILE_EDIT) because a
98
+ * delete carries no content.
99
+ */
100
+ export function withholdSecretContentFromMessages(
101
+ messages: readonly AgentMessage[],
102
+ subAgents?: readonly SubAgentExecution[],
103
+ ): void {
104
+ for (const msg of messages) {
105
+ for (const tc of msg.toolCalls) {
106
+ if (toolApprovalCategory(tc.name) === "write") {
107
+ withholdSecretFileContent(tc);
108
+ }
109
+ }
110
+ }
111
+ for (const sa of subAgents ?? []) {
112
+ withholdSecretContentFromMessages(sa.messages);
60
113
  }
61
114
  }
62
115
 
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveSessionWorkspaceRoot } from "../session-root.js";
6
+
7
+ let rootDir: string;
8
+
9
+ beforeEach(async () => {
10
+ rootDir = await mkdtemp(join(tmpdir(), "stigmer-session-root-"));
11
+ });
12
+
13
+ afterEach(async () => {
14
+ await rm(rootDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("resolveSessionWorkspaceRoot", () => {
18
+ it("returns the shared root untouched when the session has workspace entries", async () => {
19
+ const resolved = await resolveSessionWorkspaceRoot(rootDir, [{}], "ses_1");
20
+ expect(resolved).toBe(rootDir);
21
+ });
22
+
23
+ it("creates and returns a per-session directory when the session has no entries", async () => {
24
+ const resolved = await resolveSessionWorkspaceRoot(rootDir, [], "ses_1");
25
+ expect(resolved).toBe(join(rootDir, "sessions", "ses_1"));
26
+ expect((await stat(resolved)).isDirectory()).toBe(true);
27
+ });
28
+
29
+ it("is deterministic across calls (stable across turns and retries)", async () => {
30
+ const first = await resolveSessionWorkspaceRoot(rootDir, [], "ses_1");
31
+ const second = await resolveSessionWorkspaceRoot(rootDir, [], "ses_1");
32
+ expect(second).toBe(first);
33
+ });
34
+
35
+ it("rejects an empty sessionId for a no-entry session", async () => {
36
+ // An empty id would collapse every no-entry session onto one directory —
37
+ // the exact cross-session leakage this helper exists to prevent.
38
+ await expect(resolveSessionWorkspaceRoot(rootDir, [], "")).rejects.toThrow(
39
+ /sessionId is required/,
40
+ );
41
+ });
42
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Tests for the per-workspace turn lock (workspace-lock.ts).
3
+ *
4
+ * These run against the real proper-lockfile substrate on tmp directories —
5
+ * the lock's correctness IS its filesystem behavior, so mocking it would
6
+ * test nothing.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import { mkdtemp, mkdir, readdir, rm, symlink, utimes } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ acquireWorkspaceLock,
15
+ WorkspaceLockTimeoutError,
16
+ WorkspaceLockCancelledError,
17
+ } from "../workspace-lock.js";
18
+
19
+ let baseDir: string;
20
+ let workspaceDir: string;
21
+ let lockDir: string;
22
+
23
+ beforeEach(async () => {
24
+ baseDir = await mkdtemp(join(tmpdir(), "stigmer-workspace-lock-"));
25
+ workspaceDir = join(baseDir, "workspace");
26
+ lockDir = join(baseDir, "locks");
27
+ await mkdir(workspaceDir, { recursive: true });
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await rm(baseDir, { recursive: true, force: true });
32
+ });
33
+
34
+ /** Resolves true once `promise` settles; used to assert "still pending". */
35
+ function settled(promise: Promise<unknown>): { get: () => boolean } {
36
+ let done = false;
37
+ promise.then(() => { done = true; }, () => { done = true; });
38
+ return { get: () => done };
39
+ }
40
+
41
+ function sleep(ms: number): Promise<void> {
42
+ return new Promise((resolve) => setTimeout(resolve, ms));
43
+ }
44
+
45
+ describe("acquireWorkspaceLock", () => {
46
+ it("serializes two concurrent acquires on the same workspace", async () => {
47
+ const release1 = await acquireWorkspaceLock(workspaceDir, { lockDir });
48
+
49
+ const second = acquireWorkspaceLock(workspaceDir, {
50
+ lockDir,
51
+ pollIntervalMs: 25,
52
+ });
53
+ const secondSettled = settled(second);
54
+
55
+ await sleep(120);
56
+ expect(secondSettled.get()).toBe(false);
57
+
58
+ await release1();
59
+ const release2 = await second;
60
+ expect(secondSettled.get()).toBe(true);
61
+ await release2();
62
+ });
63
+
64
+ it("converges symlink aliases of one directory onto one lock", async () => {
65
+ const alias = join(baseDir, "workspace-alias");
66
+ await symlink(workspaceDir, alias);
67
+
68
+ const release = await acquireWorkspaceLock(workspaceDir, { lockDir });
69
+ await expect(
70
+ acquireWorkspaceLock(alias, {
71
+ lockDir,
72
+ pollIntervalMs: 20,
73
+ timeoutMs: 100,
74
+ }),
75
+ ).rejects.toBeInstanceOf(WorkspaceLockTimeoutError);
76
+ await release();
77
+ });
78
+
79
+ it("does not contend across distinct workspaces", async () => {
80
+ const otherDir = join(baseDir, "other-workspace");
81
+ await mkdir(otherDir, { recursive: true });
82
+
83
+ const release1 = await acquireWorkspaceLock(workspaceDir, { lockDir });
84
+ const release2 = await acquireWorkspaceLock(otherDir, {
85
+ lockDir,
86
+ timeoutMs: 200,
87
+ pollIntervalMs: 20,
88
+ });
89
+ await release1();
90
+ await release2();
91
+ });
92
+
93
+ it("recovers a stale lock left by a crashed holder", async () => {
94
+ const release = await acquireWorkspaceLock(workspaceDir, {
95
+ lockDir,
96
+ staleMs: 5_000,
97
+ });
98
+ // Simulate a crashed holder: the artifact exists but its mtime refresh
99
+ // has stopped. Backdate it past the staleness bound.
100
+ const [artifact] = await readdir(lockDir);
101
+ const past = new Date(Date.now() - 60_000);
102
+ await utimes(join(lockDir, artifact), past, past);
103
+
104
+ const release2 = await acquireWorkspaceLock(workspaceDir, {
105
+ lockDir,
106
+ staleMs: 5_000,
107
+ timeoutMs: 2_000,
108
+ pollIntervalMs: 20,
109
+ });
110
+ await release2();
111
+ await release(); // original holder's release must not throw (idempotent wrapper)
112
+ });
113
+
114
+ it("times out with a typed, actionable error when the workspace stays busy", async () => {
115
+ const release = await acquireWorkspaceLock(workspaceDir, { lockDir });
116
+ const err = await acquireWorkspaceLock(workspaceDir, {
117
+ lockDir,
118
+ timeoutMs: 120,
119
+ pollIntervalMs: 25,
120
+ }).catch((e: unknown) => e);
121
+
122
+ expect(err).toBeInstanceOf(WorkspaceLockTimeoutError);
123
+ expect(String(err)).toContain("in use by another session");
124
+ await release();
125
+ });
126
+
127
+ it("aborts the wait immediately on cancellation", async () => {
128
+ const release = await acquireWorkspaceLock(workspaceDir, { lockDir });
129
+ const controller = new AbortController();
130
+
131
+ const waiting = acquireWorkspaceLock(workspaceDir, {
132
+ lockDir,
133
+ pollIntervalMs: 5_000, // long poll: proves abort wakes the sleep early
134
+ timeoutMs: 60_000,
135
+ signal: controller.signal,
136
+ });
137
+
138
+ await sleep(50);
139
+ const before = Date.now();
140
+ controller.abort();
141
+ await expect(waiting).rejects.toBeInstanceOf(WorkspaceLockCancelledError);
142
+ expect(Date.now() - before).toBeLessThan(1_000);
143
+ await release();
144
+ });
145
+
146
+ it("invokes onWaiting exactly once and heartbeats on every poll", async () => {
147
+ const release1 = await acquireWorkspaceLock(workspaceDir, { lockDir });
148
+
149
+ let waitingCalls = 0;
150
+ let heartbeats = 0;
151
+ const second = acquireWorkspaceLock(workspaceDir, {
152
+ lockDir,
153
+ pollIntervalMs: 25,
154
+ onWaiting: () => { waitingCalls += 1; },
155
+ heartbeat: () => { heartbeats += 1; },
156
+ });
157
+
158
+ // Deterministic under load: hold the lock until the waiter has provably
159
+ // polled at least twice, rather than sleeping a fixed wall-clock time.
160
+ while (heartbeats < 2) {
161
+ await sleep(20);
162
+ }
163
+ await release1();
164
+ const release2 = await second;
165
+
166
+ expect(waitingCalls).toBe(1);
167
+ expect(heartbeats).toBeGreaterThanOrEqual(2);
168
+ await release2();
169
+ });
170
+
171
+ it("never invokes onWaiting on an uncontended acquire", async () => {
172
+ let waitingCalls = 0;
173
+ const release = await acquireWorkspaceLock(workspaceDir, {
174
+ lockDir,
175
+ onWaiting: () => { waitingCalls += 1; },
176
+ });
177
+ expect(waitingCalls).toBe(0);
178
+ await release();
179
+ });
180
+
181
+ it("release is idempotent", async () => {
182
+ const release = await acquireWorkspaceLock(workspaceDir, { lockDir });
183
+ await release();
184
+ await expect(release()).resolves.toBeUndefined();
185
+ // Lock is actually free again.
186
+ const release2 = await acquireWorkspaceLock(workspaceDir, {
187
+ lockDir,
188
+ timeoutMs: 200,
189
+ pollIntervalMs: 20,
190
+ });
191
+ await release2();
192
+ });
193
+
194
+ it("keeps every lock artifact outside the workspace (issue #173)", async () => {
195
+ const release = await acquireWorkspaceLock(workspaceDir, { lockDir });
196
+ expect(await readdir(workspaceDir)).toEqual([]);
197
+ expect((await readdir(lockDir)).length).toBeGreaterThan(0);
198
+ await release();
199
+ });
200
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Session workspace-root resolution — shared by both executors (Cursor and
3
+ * deep-agent) so their placement rules can never drift.
4
+ *
5
+ * SessionSpec.workspace_entries promises: "When empty, the session uses an
6
+ * empty workspace directory." Handing every no-entry session the shared
7
+ * `config.workspaceRootDir` broke that contract twice over: a "new" session
8
+ * started amid every prior session's leftovers, and — under the per-workspace
9
+ * turn lock (workspace-lock.ts) — unrelated no-entry sessions would falsely
10
+ * serialize against each other. A per-session directory honors the contract,
11
+ * isolates the default quickstart case, and removes the false contention.
12
+ *
13
+ * Sessions WITH entries keep the shared root: their content identity is the
14
+ * entry source (a git URL clones idempotently, a localPath IS the user's
15
+ * directory), and cross-session sharing of that content is intentional —
16
+ * that sharing is exactly what the turn lock serializes.
17
+ */
18
+
19
+ import { join } from "node:path";
20
+ import { mkdir } from "node:fs/promises";
21
+
22
+ /**
23
+ * Resolve (and create) the working-tree root for a session.
24
+ *
25
+ * - No workspace entries → `{workspaceRootDir}/sessions/{sessionId}`, created
26
+ * on demand. Deterministic from sessionId alone, so it is stable across
27
+ * turns, HITL reinvocations, and Temporal retries with no persisted state.
28
+ * - One or more entries → the shared `workspaceRootDir`, where the
29
+ * provisioner materializes each entry (in place, or per-entry subdirs).
30
+ *
31
+ * The `sessions/` namespace lives under the configured workspace root (the
32
+ * volume operators size and mount for workspace content), not under $HOME.
33
+ * It can coexist with a single-entry clone at the root: cloneInPlace
34
+ * (sources/git.ts) tolerates non-empty targets, colliding only if the repo
35
+ * itself ships a root-level `sessions` path — a strictly smaller surface
36
+ * than the old behavior, where any file a no-entry session dropped at the
37
+ * root could break the checkout.
38
+ */
39
+ export async function resolveSessionWorkspaceRoot(
40
+ workspaceRootDir: string,
41
+ workspaceEntries: readonly unknown[],
42
+ sessionId: string,
43
+ ): Promise<string> {
44
+ if (workspaceEntries.length > 0) {
45
+ return workspaceRootDir;
46
+ }
47
+ if (!sessionId) {
48
+ // Same invariant as resolvePlatformOptions: an empty sessionId would
49
+ // collapse every no-entry session onto one directory — the exact leakage
50
+ // this function exists to prevent.
51
+ throw new Error(
52
+ "resolveSessionWorkspaceRoot: sessionId is required for a session with " +
53
+ "no workspace entries; an empty value would collapse every such session " +
54
+ "onto one shared directory.",
55
+ );
56
+ }
57
+ const sessionRoot = join(workspaceRootDir, "sessions", sessionId);
58
+ await mkdir(sessionRoot, { recursive: true });
59
+ return sessionRoot;
60
+ }