@stigmer/runner 3.1.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/attachment-resolver.d.ts +49 -8
  3. package/dist/activities/execute-cursor/attachment-resolver.js +83 -26
  4. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +13 -0
  6. package/dist/activities/execute-cursor/cas-observations.js +39 -5
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +19 -1
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/index.d.ts +5 -0
  11. package/dist/activities/execute-cursor/index.js +102 -22
  12. package/dist/activities/execute-cursor/index.js.map +1 -1
  13. package/dist/activities/execute-cursor/message-translator.js +10 -1
  14. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  15. package/dist/activities/execute-cursor/prompt-builder.d.ts +32 -7
  16. package/dist/activities/execute-cursor/prompt-builder.js +38 -20
  17. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  18. package/dist/activities/execute-cursor/skill-resolver.d.ts +1 -10
  19. package/dist/activities/execute-cursor/skill-resolver.js +3 -55
  20. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  21. package/dist/activities/execute-cursor/stigmer-link.d.ts +35 -0
  22. package/dist/activities/execute-cursor/stigmer-link.js +73 -0
  23. package/dist/activities/execute-cursor/stigmer-link.js.map +1 -0
  24. package/dist/activities/execute-cursor/workspace-provision.d.ts +3 -2
  25. package/dist/activities/execute-cursor/workspace-provision.js +5 -3
  26. package/dist/activities/execute-cursor/workspace-provision.js.map +1 -1
  27. package/dist/activities/execute-deep-agent/index.js +59 -2
  28. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  29. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +17 -0
  30. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  31. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  32. package/dist/activities/execute-deep-agent/setup.js +22 -16
  33. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  34. package/dist/config.d.ts +7 -0
  35. package/dist/config.js +9 -0
  36. package/dist/config.js.map +1 -1
  37. package/dist/middleware/approval-gate.js +36 -6
  38. package/dist/middleware/approval-gate.js.map +1 -1
  39. package/dist/runner-manager.d.ts +2 -0
  40. package/dist/runner-manager.js +2 -1
  41. package/dist/runner-manager.js.map +1 -1
  42. package/dist/runner.d.ts +2 -0
  43. package/dist/runner.js +2 -1
  44. package/dist/runner.js.map +1 -1
  45. package/dist/shared/artifact-storage.d.ts +32 -0
  46. package/dist/shared/artifact-storage.js +74 -1
  47. package/dist/shared/artifact-storage.js.map +1 -1
  48. package/dist/shared/implement-plan-prompt.d.ts +42 -0
  49. package/dist/shared/implement-plan-prompt.js +66 -0
  50. package/dist/shared/implement-plan-prompt.js.map +1 -0
  51. package/dist/shared/plan-mode-prompt.d.ts +30 -0
  52. package/dist/shared/plan-mode-prompt.js +51 -0
  53. package/dist/shared/plan-mode-prompt.js.map +1 -0
  54. package/dist/shared/status.d.ts +11 -0
  55. package/dist/shared/status.js +12 -2
  56. package/dist/shared/status.js.map +1 -1
  57. package/dist/shared/tool-row.d.ts +30 -1
  58. package/dist/shared/tool-row.js +53 -4
  59. package/dist/shared/tool-row.js.map +1 -1
  60. package/dist/shared/workspace/session-root.d.ts +35 -0
  61. package/dist/shared/workspace/session-root.js +53 -0
  62. package/dist/shared/workspace/session-root.js.map +1 -0
  63. package/dist/shared/workspace/workspace-lock.d.ts +86 -0
  64. package/dist/shared/workspace/workspace-lock.js +187 -0
  65. package/dist/shared/workspace/workspace-lock.js.map +1 -0
  66. package/package.json +7 -5
  67. package/src/__tests__/config.test.ts +8 -0
  68. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  69. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  70. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +161 -0
  71. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +125 -2
  72. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -0
  73. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +28 -0
  74. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +63 -4
  75. package/src/activities/execute-cursor/__tests__/workspace-provision.test.ts +36 -2
  76. package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
  77. package/src/activities/execute-cursor/cas-observations.ts +41 -5
  78. package/src/activities/execute-cursor/hook-script.ts +19 -1
  79. package/src/activities/execute-cursor/index.ts +114 -26
  80. package/src/activities/execute-cursor/message-translator.ts +9 -1
  81. package/src/activities/execute-cursor/prompt-builder.ts +60 -22
  82. package/src/activities/execute-cursor/skill-resolver.ts +3 -59
  83. package/src/activities/execute-cursor/stigmer-link.ts +78 -0
  84. package/src/activities/execute-cursor/workspace-provision.ts +5 -3
  85. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +7 -2
  86. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +7 -2
  87. package/src/activities/execute-deep-agent/__tests__/index.test.ts +4 -1
  88. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +88 -0
  89. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
  90. package/src/activities/execute-deep-agent/index.ts +70 -2
  91. package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
  92. package/src/activities/execute-deep-agent/setup.ts +25 -17
  93. package/src/config.ts +17 -0
  94. package/src/middleware/__tests__/approval-gate.test.ts +77 -12
  95. package/src/middleware/approval-gate.ts +39 -7
  96. package/src/runner-manager.ts +6 -1
  97. package/src/runner.ts +6 -1
  98. package/src/shared/__tests__/artifact-storage.test.ts +111 -1
  99. package/src/shared/__tests__/implement-plan-prompt.test.ts +48 -0
  100. package/src/shared/__tests__/secret-leak-scan.test.ts +105 -0
  101. package/src/shared/__tests__/status.test.ts +7 -2
  102. package/src/shared/__tests__/tool-row.test.ts +136 -1
  103. package/src/shared/artifact-storage.ts +83 -1
  104. package/src/shared/implement-plan-prompt.ts +73 -0
  105. package/src/shared/plan-mode-prompt.ts +51 -0
  106. package/src/shared/status.ts +11 -1
  107. package/src/shared/tool-row.ts +58 -5
  108. package/src/shared/workspace/__tests__/session-root.test.ts +42 -0
  109. package/src/shared/workspace/__tests__/workspace-lock.test.ts +200 -0
  110. package/src/shared/workspace/session-root.ts +60 -0
  111. package/src/shared/workspace/workspace-lock.ts +241 -0
@@ -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
+ }