@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
- package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
- package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +18 -0
- package/dist/activities/execute-cursor/capture-flow.js +21 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
- package/dist/activities/execute-cursor/cas-observations.js +39 -5
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +19 -1
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +5 -0
- package/dist/activities/execute-cursor/index.js +222 -160
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.js +10 -1
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
- package/dist/activities/execute-cursor/prompt-builder.js +38 -20
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
- package/dist/activities/execute-cursor/skill-resolver.js +3 -55
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
- package/dist/activities/execute-cursor/stigmer-link.js +73 -0
- package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
- package/dist/activities/execute-cursor/turn-boundary.d.ts +108 -0
- package/dist/activities/execute-cursor/turn-boundary.js +163 -0
- package/dist/activities/execute-cursor/turn-boundary.js.map +1 -0
- package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
- package/dist/activities/execute-cursor/workspace-provision.js +5 -3
- package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.js +89 -5
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +22 -16
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js +6 -1
- package/dist/activities/execute-deep-agent/streaming-v3.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.d.ts +7 -0
- package/dist/activities/execute-deep-agent/streaming.js +5 -1
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/config.d.ts +7 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/middleware/approval-gate.js +36 -6
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +2 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/artifact-storage.d.ts +32 -0
- package/dist/shared/artifact-storage.js +74 -1
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/filereview/capture.d.ts +18 -1
- package/dist/shared/filereview/capture.js +105 -28
- package/dist/shared/filereview/capture.js.map +1 -1
- package/dist/shared/filereview/cas-substrate.d.ts +9 -0
- package/dist/shared/filereview/cas-substrate.js +9 -1
- package/dist/shared/filereview/cas-substrate.js.map +1 -1
- package/dist/shared/filereview/events.d.ts +16 -0
- package/dist/shared/filereview/events.js +37 -0
- package/dist/shared/filereview/events.js.map +1 -1
- package/dist/shared/filereview/git-substrate.d.ts +42 -0
- package/dist/shared/filereview/git-substrate.js +78 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -1
- package/dist/shared/filereview/index.d.ts +3 -1
- package/dist/shared/filereview/index.js +3 -1
- package/dist/shared/filereview/index.js.map +1 -1
- package/dist/shared/filereview/line-counts.d.ts +48 -0
- package/dist/shared/filereview/line-counts.js +72 -0
- package/dist/shared/filereview/line-counts.js.map +1 -0
- package/dist/shared/filereview/progress.d.ts +96 -0
- package/dist/shared/filereview/progress.js +134 -0
- package/dist/shared/filereview/progress.js.map +1 -0
- package/dist/shared/implement-plan-prompt.d.ts +44 -0
- package/dist/shared/implement-plan-prompt.js +98 -0
- package/dist/shared/implement-plan-prompt.js.map +1 -0
- package/dist/shared/plan-artifact.d.ts +59 -11
- package/dist/shared/plan-artifact.js +144 -16
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/plan-mode-prompt.d.ts +32 -0
- package/dist/shared/plan-mode-prompt.js +56 -0
- package/dist/shared/plan-mode-prompt.js.map +1 -0
- package/dist/shared/status.d.ts +11 -0
- package/dist/shared/status.js +12 -2
- package/dist/shared/status.js.map +1 -1
- package/dist/shared/tool-row.d.ts +30 -1
- package/dist/shared/tool-row.js +53 -4
- package/dist/shared/tool-row.js.map +1 -1
- package/dist/shared/workspace/session-root.d.ts +35 -0
- package/dist/shared/workspace/session-root.js +53 -0
- package/dist/shared/workspace/session-root.js.map +1 -0
- package/dist/shared/workspace/workspace-lock.d.ts +86 -0
- package/dist/shared/workspace/workspace-lock.js +187 -0
- package/dist/shared/workspace/workspace-lock.js.map +1 -0
- package/package.json +8 -5
- package/src/__tests__/config.test.ts +8 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +132 -2
- package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
- package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +68 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
- package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +280 -0
- package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
- package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
- package/src/activities/execute-cursor/capture-flow.ts +32 -0
- package/src/activities/execute-cursor/cas-observations.ts +41 -5
- package/src/activities/execute-cursor/hook-script.ts +19 -1
- package/src/activities/execute-cursor/index.ts +243 -178
- package/src/activities/execute-cursor/message-translator.ts +9 -1
- package/src/activities/execute-cursor/prompt-builder.ts +60 -22
- package/src/activities/execute-cursor/skill-resolver.ts +3 -59
- package/src/activities/execute-cursor/stigmer-link.ts +78 -0
- package/src/activities/execute-cursor/turn-boundary.ts +274 -0
- package/src/activities/execute-cursor/workspace-provision.ts +5 -3
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +99 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
- package/src/activities/execute-deep-agent/index.ts +104 -5
- package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
- package/src/activities/execute-deep-agent/setup.ts +25 -17
- package/src/activities/execute-deep-agent/streaming-v3.ts +6 -1
- package/src/activities/execute-deep-agent/streaming.ts +12 -1
- package/src/config.ts +17 -0
- package/src/middleware/__tests__/approval-gate.test.ts +77 -12
- package/src/middleware/approval-gate.ts +39 -7
- package/src/runner-manager.ts +6 -1
- package/src/runner.ts +6 -1
- package/src/shared/__tests__/artifact-storage.test.ts +111 -1
- package/src/shared/__tests__/implement-plan-prompt.test.ts +86 -0
- package/src/shared/__tests__/plan-artifact.test.ts +142 -9
- package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
- package/src/shared/__tests__/status.test.ts +7 -2
- package/src/shared/__tests__/tool-row.test.ts +136 -1
- package/src/shared/artifact-storage.ts +83 -1
- package/src/shared/filereview/__tests__/capture.test.ts +182 -1
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +34 -0
- package/src/shared/filereview/__tests__/events.test.ts +105 -0
- package/src/shared/filereview/__tests__/line-counts.test.ts +73 -0
- package/src/shared/filereview/__tests__/progress.test.ts +236 -0
- package/src/shared/filereview/capture.ts +134 -30
- package/src/shared/filereview/cas-substrate.ts +17 -1
- package/src/shared/filereview/events.ts +50 -0
- package/src/shared/filereview/git-substrate.ts +108 -0
- package/src/shared/filereview/index.ts +22 -1
- package/src/shared/filereview/line-counts.ts +83 -0
- package/src/shared/filereview/progress.ts +186 -0
- package/src/shared/implement-plan-prompt.ts +104 -0
- package/src/shared/plan-artifact.ts +152 -16
- package/src/shared/plan-mode-prompt.ts +56 -0
- package/src/shared/status.ts +11 -1
- package/src/shared/tool-row.ts +58 -5
- package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
- package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
- package/src/shared/workspace/session-root.ts +60 -0
- package/src/shared/workspace/workspace-lock.ts +241 -0
package/src/shared/tool-row.ts
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-workspace turn lock — serializes agent-execution turns that share a
|
|
3
|
+
* working tree.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the file-review substrate (shared/filereview) computes a
|
|
6
|
+
* turn's change set as `git diff baselineTree candidateTree`, where both trees
|
|
7
|
+
* are live snapshots of the working directory at turn start/end. That model is
|
|
8
|
+
* correct for a single writer, but nothing scopes a working tree to one
|
|
9
|
+
* execution: sessions declaring the same `localPath` (or sharing the runner's
|
|
10
|
+
* `workspaceRootDir`) resolve to the SAME directory, and the runner happily
|
|
11
|
+
* runs activities concurrently. A concurrent turn's write landing between
|
|
12
|
+
* another turn's baseline and candidate gets misattributed to that other turn
|
|
13
|
+
* (observed in prod: aex_01kwpzhmvbvwqdez9cb331nekr reviewed a notes.md it
|
|
14
|
+
* never wrote). The same race lets a reject-reconcile in one session silently
|
|
15
|
+
* revert a file another session's user just approved.
|
|
16
|
+
*
|
|
17
|
+
* Why a FILE lock and not in-process/server-side coordination: the shared
|
|
18
|
+
* resource is a directory on one host. A `localPath` entry resolves to the
|
|
19
|
+
* user's literal directory in ANY runner process on the machine — the CLI
|
|
20
|
+
* daemon and the desktop app's embedded runner can contend on one tree while
|
|
21
|
+
* answering to DIFFERENT control planes, so no single server can see all
|
|
22
|
+
* contenders and no in-process mutex can exclude a sibling process. Mutual
|
|
23
|
+
* exclusion must live where the collision happens: the filesystem.
|
|
24
|
+
*
|
|
25
|
+
* Mechanics (proper-lockfile):
|
|
26
|
+
* - The lock artifact lives under `~/.stigmer/workspace-locks/{key}.lock`,
|
|
27
|
+
* NEVER inside the user's workspace (issue #173: attaching a real repo must
|
|
28
|
+
* leave no Stigmer droppings). The key is sha256(realpath(workspaceRoot)),
|
|
29
|
+
* so symlink aliases of one directory converge on one lock — the same
|
|
30
|
+
* keying pattern as getHitlGateDir in platform-dir.ts.
|
|
31
|
+
* - While held, proper-lockfile refreshes the artifact's mtime on an interval;
|
|
32
|
+
* if the holder process dies, refreshing stops and the artifact goes stale,
|
|
33
|
+
* so the next waiter simply takes over. A crashed runner can never deadlock
|
|
34
|
+
* a workspace, and no human ever has to delete a lock file.
|
|
35
|
+
* - Deadlock-free by construction: an activity holds at most ONE lock (its
|
|
36
|
+
* primary workspace root), and a turn that pauses for human approval ENDS
|
|
37
|
+
* its activity — releasing the lock — so a change set sitting unreviewed
|
|
38
|
+
* never blocks the workspace.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { join, resolve } from "node:path";
|
|
42
|
+
import { homedir } from "node:os";
|
|
43
|
+
import { createHash } from "node:crypto";
|
|
44
|
+
import { mkdir, realpath } from "node:fs/promises";
|
|
45
|
+
import lockfile from "proper-lockfile";
|
|
46
|
+
|
|
47
|
+
/** How long a waiter polls between acquisition attempts. */
|
|
48
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default bound on how long an execution waits for a contended workspace
|
|
52
|
+
* before failing with {@link WorkspaceLockTimeoutError}. Generous — an agent
|
|
53
|
+
* turn legitimately runs for minutes — but finite, so a pathological holder
|
|
54
|
+
* produces an explicit, actionable failure instead of an invisible hang.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS = 15 * 60 * 1_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* How long a lock artifact may go un-refreshed before waiters treat the
|
|
60
|
+
* holder as dead and take over. proper-lockfile refreshes at stale/2, so a
|
|
61
|
+
* live holder can never be usurped; only a crashed process trips this.
|
|
62
|
+
*/
|
|
63
|
+
const DEFAULT_STALE_MS = 10_000;
|
|
64
|
+
|
|
65
|
+
/** The acquisition wait exceeded its bound — the workspace stayed busy. */
|
|
66
|
+
export class WorkspaceLockTimeoutError extends Error {
|
|
67
|
+
constructor(workspaceRoot: string, waitedMs: number) {
|
|
68
|
+
super(
|
|
69
|
+
`Workspace is in use by another session: ${workspaceRoot} ` +
|
|
70
|
+
`(waited ${Math.round(waitedMs / 1000)}s). Another agent execution is ` +
|
|
71
|
+
`operating on this workspace directory; retry after it finishes.`,
|
|
72
|
+
);
|
|
73
|
+
this.name = "WorkspaceLockTimeoutError";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The acquisition wait was cancelled (e.g. the user cancelled the execution). */
|
|
78
|
+
export class WorkspaceLockCancelledError extends Error {
|
|
79
|
+
constructor(workspaceRoot: string) {
|
|
80
|
+
super(`Workspace lock wait cancelled for ${workspaceRoot}`);
|
|
81
|
+
this.name = "WorkspaceLockCancelledError";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface AcquireWorkspaceLockOptions {
|
|
86
|
+
/**
|
|
87
|
+
* Invoked once, when the first acquisition attempt finds the workspace
|
|
88
|
+
* held by another turn — the hook for surfacing a visible "waiting for
|
|
89
|
+
* workspace" state to the user. Never invoked on an uncontended acquire.
|
|
90
|
+
*/
|
|
91
|
+
readonly onWaiting?: () => void | Promise<void>;
|
|
92
|
+
/** Invoked on every poll while waiting (Temporal activity liveness). */
|
|
93
|
+
readonly heartbeat?: () => void;
|
|
94
|
+
/** Aborts the wait immediately (Temporal activity cancellation). */
|
|
95
|
+
readonly signal?: AbortSignal;
|
|
96
|
+
/** Max wait before {@link WorkspaceLockTimeoutError}. */
|
|
97
|
+
readonly timeoutMs?: number;
|
|
98
|
+
/** Poll interval override (tests). */
|
|
99
|
+
readonly pollIntervalMs?: number;
|
|
100
|
+
/** Staleness bound override (tests). */
|
|
101
|
+
readonly staleMs?: number;
|
|
102
|
+
/** Lock-artifact directory override (tests). */
|
|
103
|
+
readonly lockDir?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Idempotent releaser returned by {@link acquireWorkspaceLock}. */
|
|
107
|
+
export type ReleaseWorkspaceLock = () => Promise<void>;
|
|
108
|
+
|
|
109
|
+
/** Runner-owned home for lock artifacts (HOME override honored, as in platform-dir.ts). */
|
|
110
|
+
function defaultLockDir(): string {
|
|
111
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
112
|
+
return join(home, ".stigmer", "workspace-locks");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Acquire the exclusive turn lock for a workspace working tree, waiting (with
|
|
117
|
+
* heartbeats and cancellation) while another turn holds it.
|
|
118
|
+
*
|
|
119
|
+
* The caller must hold the lock across the turn's ENTIRE tree-mutating window
|
|
120
|
+
* — decision reconcile, HITL gate install, the agent's own writes, and the
|
|
121
|
+
* candidate capture — and release it in a `finally` as the last
|
|
122
|
+
* workspace-touching step.
|
|
123
|
+
*/
|
|
124
|
+
export async function acquireWorkspaceLock(
|
|
125
|
+
workspaceRoot: string,
|
|
126
|
+
options: AcquireWorkspaceLockOptions = {},
|
|
127
|
+
): Promise<ReleaseWorkspaceLock> {
|
|
128
|
+
const {
|
|
129
|
+
onWaiting,
|
|
130
|
+
heartbeat,
|
|
131
|
+
signal,
|
|
132
|
+
timeoutMs = DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
|
|
133
|
+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
134
|
+
staleMs = DEFAULT_STALE_MS,
|
|
135
|
+
lockDir = defaultLockDir(),
|
|
136
|
+
} = options;
|
|
137
|
+
|
|
138
|
+
// Resolve symlink aliases so every path spelling of one directory contends
|
|
139
|
+
// on one lock. The resolved path is also what we hand proper-lockfile as
|
|
140
|
+
// the target (with realpath:false — already resolved here). A root that
|
|
141
|
+
// does not exist yet cannot be realpath'd, so it locks on its canonical
|
|
142
|
+
// absolute spelling instead — proper-lockfile never touches the target
|
|
143
|
+
// itself (the artifact lives at lockfilePath), so the lock still works.
|
|
144
|
+
const resolvedRoot = await realpath(workspaceRoot).catch(() => resolve(workspaceRoot));
|
|
145
|
+
const key = createHash("sha256").update(resolvedRoot).digest("hex").slice(0, 16);
|
|
146
|
+
await mkdir(lockDir, { recursive: true });
|
|
147
|
+
const lockfilePath = join(lockDir, `${key}.lock`);
|
|
148
|
+
|
|
149
|
+
const startedAt = Date.now();
|
|
150
|
+
let waitingReported = false;
|
|
151
|
+
|
|
152
|
+
for (;;) {
|
|
153
|
+
if (signal?.aborted) {
|
|
154
|
+
throw new WorkspaceLockCancelledError(resolvedRoot);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const release = await lockfile.lock(resolvedRoot, {
|
|
159
|
+
lockfilePath,
|
|
160
|
+
realpath: false,
|
|
161
|
+
stale: staleMs,
|
|
162
|
+
// No library-side retries: the wait loop below owns retry policy so it
|
|
163
|
+
// can heartbeat, report waiting, honor cancellation, and bound the wait.
|
|
164
|
+
retries: 0,
|
|
165
|
+
// A compromised lock (artifact vanished / refresh missed its staleness
|
|
166
|
+
// window under extreme event-loop stall) must not crash the worker
|
|
167
|
+
// process — the default behavior throws uncaught. Log loudly and let
|
|
168
|
+
// the turn finish: the exposure is bounded to pre-lock semantics.
|
|
169
|
+
onCompromised: (err) => {
|
|
170
|
+
console.error(
|
|
171
|
+
`[workspace-lock] lock on ${resolvedRoot} compromised mid-turn ` +
|
|
172
|
+
`(continuing unlocked): ${err}`,
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (waitingReported) {
|
|
178
|
+
console.log(
|
|
179
|
+
`[workspace-lock] acquired ${resolvedRoot} after waiting ` +
|
|
180
|
+
`${Date.now() - startedAt}ms`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Idempotent wrapper: the executors release in a `finally` that also
|
|
185
|
+
// runs on paths where an earlier error may already have torn the lock
|
|
186
|
+
// down; a double release (or a release racing staleness takeover) must
|
|
187
|
+
// never mask the turn's real outcome.
|
|
188
|
+
let released = false;
|
|
189
|
+
return async () => {
|
|
190
|
+
if (released) return;
|
|
191
|
+
released = true;
|
|
192
|
+
try {
|
|
193
|
+
await release();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
console.warn(`[workspace-lock] release of ${resolvedRoot} failed (ignored): ${err}`);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if (!isLockHeldError(err)) throw err;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!waitingReported) {
|
|
203
|
+
waitingReported = true;
|
|
204
|
+
console.log(`[workspace-lock] ${resolvedRoot} is held by another turn; waiting`);
|
|
205
|
+
try {
|
|
206
|
+
await onWaiting?.();
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.warn(`[workspace-lock] onWaiting callback failed (non-fatal): ${err}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const waitedMs = Date.now() - startedAt;
|
|
213
|
+
if (waitedMs + pollIntervalMs > timeoutMs) {
|
|
214
|
+
throw new WorkspaceLockTimeoutError(resolvedRoot, waitedMs);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
heartbeat?.();
|
|
218
|
+
await sleepAbortable(pollIntervalMs, signal);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** proper-lockfile signals "already held" with code ELOCKED. */
|
|
223
|
+
function isLockHeldError(err: unknown): boolean {
|
|
224
|
+
return typeof err === "object" && err !== null &&
|
|
225
|
+
(err as { code?: unknown }).code === "ELOCKED";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Sleep that wakes immediately when the signal aborts (wait stays responsive). */
|
|
229
|
+
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
230
|
+
return new Promise((resolve) => {
|
|
231
|
+
const timer = setTimeout(() => {
|
|
232
|
+
signal?.removeEventListener("abort", onAbort);
|
|
233
|
+
resolve();
|
|
234
|
+
}, ms);
|
|
235
|
+
const onAbort = () => {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
resolve();
|
|
238
|
+
};
|
|
239
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
240
|
+
});
|
|
241
|
+
}
|