@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.
- 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/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 +102 -22
- 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/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 +59 -2
- 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/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/implement-plan-prompt.d.ts +42 -0
- package/dist/shared/implement-plan-prompt.js +66 -0
- package/dist/shared/implement-plan-prompt.js.map +1 -0
- package/dist/shared/plan-mode-prompt.d.ts +30 -0
- package/dist/shared/plan-mode-prompt.js +51 -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 +7 -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 +125 -2
- package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +55 -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__/workspace-provision.test.ts +36 -2
- package/src/activities/execute-cursor/attachment-resolver.ts +116 -34
- 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 +114 -26
- 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/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 +88 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +7 -2
- package/src/activities/execute-deep-agent/index.ts +70 -2
- package/src/activities/execute-deep-agent/prompt-builder.ts +39 -0
- package/src/activities/execute-deep-agent/setup.ts +25 -17
- 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 +48 -0
- 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/implement-plan-prompt.ts +73 -0
- package/src/shared/plan-mode-prompt.ts +51 -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
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
import { join, resolve } from "node:path";
|
|
41
|
+
import { homedir } from "node:os";
|
|
42
|
+
import { createHash } from "node:crypto";
|
|
43
|
+
import { mkdir, realpath } from "node:fs/promises";
|
|
44
|
+
import lockfile from "proper-lockfile";
|
|
45
|
+
/** How long a waiter polls between acquisition attempts. */
|
|
46
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
47
|
+
/**
|
|
48
|
+
* Default bound on how long an execution waits for a contended workspace
|
|
49
|
+
* before failing with {@link WorkspaceLockTimeoutError}. Generous — an agent
|
|
50
|
+
* turn legitimately runs for minutes — but finite, so a pathological holder
|
|
51
|
+
* produces an explicit, actionable failure instead of an invisible hang.
|
|
52
|
+
*/
|
|
53
|
+
export const DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS = 15 * 60 * 1_000;
|
|
54
|
+
/**
|
|
55
|
+
* How long a lock artifact may go un-refreshed before waiters treat the
|
|
56
|
+
* holder as dead and take over. proper-lockfile refreshes at stale/2, so a
|
|
57
|
+
* live holder can never be usurped; only a crashed process trips this.
|
|
58
|
+
*/
|
|
59
|
+
const DEFAULT_STALE_MS = 10_000;
|
|
60
|
+
/** The acquisition wait exceeded its bound — the workspace stayed busy. */
|
|
61
|
+
export class WorkspaceLockTimeoutError extends Error {
|
|
62
|
+
constructor(workspaceRoot, waitedMs) {
|
|
63
|
+
super(`Workspace is in use by another session: ${workspaceRoot} ` +
|
|
64
|
+
`(waited ${Math.round(waitedMs / 1000)}s). Another agent execution is ` +
|
|
65
|
+
`operating on this workspace directory; retry after it finishes.`);
|
|
66
|
+
this.name = "WorkspaceLockTimeoutError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The acquisition wait was cancelled (e.g. the user cancelled the execution). */
|
|
70
|
+
export class WorkspaceLockCancelledError extends Error {
|
|
71
|
+
constructor(workspaceRoot) {
|
|
72
|
+
super(`Workspace lock wait cancelled for ${workspaceRoot}`);
|
|
73
|
+
this.name = "WorkspaceLockCancelledError";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Runner-owned home for lock artifacts (HOME override honored, as in platform-dir.ts). */
|
|
77
|
+
function defaultLockDir() {
|
|
78
|
+
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
|
79
|
+
return join(home, ".stigmer", "workspace-locks");
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Acquire the exclusive turn lock for a workspace working tree, waiting (with
|
|
83
|
+
* heartbeats and cancellation) while another turn holds it.
|
|
84
|
+
*
|
|
85
|
+
* The caller must hold the lock across the turn's ENTIRE tree-mutating window
|
|
86
|
+
* — decision reconcile, HITL gate install, the agent's own writes, and the
|
|
87
|
+
* candidate capture — and release it in a `finally` as the last
|
|
88
|
+
* workspace-touching step.
|
|
89
|
+
*/
|
|
90
|
+
export async function acquireWorkspaceLock(workspaceRoot, options = {}) {
|
|
91
|
+
const { onWaiting, heartbeat, signal, timeoutMs = DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, staleMs = DEFAULT_STALE_MS, lockDir = defaultLockDir(), } = options;
|
|
92
|
+
// Resolve symlink aliases so every path spelling of one directory contends
|
|
93
|
+
// on one lock. The resolved path is also what we hand proper-lockfile as
|
|
94
|
+
// the target (with realpath:false — already resolved here). A root that
|
|
95
|
+
// does not exist yet cannot be realpath'd, so it locks on its canonical
|
|
96
|
+
// absolute spelling instead — proper-lockfile never touches the target
|
|
97
|
+
// itself (the artifact lives at lockfilePath), so the lock still works.
|
|
98
|
+
const resolvedRoot = await realpath(workspaceRoot).catch(() => resolve(workspaceRoot));
|
|
99
|
+
const key = createHash("sha256").update(resolvedRoot).digest("hex").slice(0, 16);
|
|
100
|
+
await mkdir(lockDir, { recursive: true });
|
|
101
|
+
const lockfilePath = join(lockDir, `${key}.lock`);
|
|
102
|
+
const startedAt = Date.now();
|
|
103
|
+
let waitingReported = false;
|
|
104
|
+
for (;;) {
|
|
105
|
+
if (signal?.aborted) {
|
|
106
|
+
throw new WorkspaceLockCancelledError(resolvedRoot);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const release = await lockfile.lock(resolvedRoot, {
|
|
110
|
+
lockfilePath,
|
|
111
|
+
realpath: false,
|
|
112
|
+
stale: staleMs,
|
|
113
|
+
// No library-side retries: the wait loop below owns retry policy so it
|
|
114
|
+
// can heartbeat, report waiting, honor cancellation, and bound the wait.
|
|
115
|
+
retries: 0,
|
|
116
|
+
// A compromised lock (artifact vanished / refresh missed its staleness
|
|
117
|
+
// window under extreme event-loop stall) must not crash the worker
|
|
118
|
+
// process — the default behavior throws uncaught. Log loudly and let
|
|
119
|
+
// the turn finish: the exposure is bounded to pre-lock semantics.
|
|
120
|
+
onCompromised: (err) => {
|
|
121
|
+
console.error(`[workspace-lock] lock on ${resolvedRoot} compromised mid-turn ` +
|
|
122
|
+
`(continuing unlocked): ${err}`);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
if (waitingReported) {
|
|
126
|
+
console.log(`[workspace-lock] acquired ${resolvedRoot} after waiting ` +
|
|
127
|
+
`${Date.now() - startedAt}ms`);
|
|
128
|
+
}
|
|
129
|
+
// Idempotent wrapper: the executors release in a `finally` that also
|
|
130
|
+
// runs on paths where an earlier error may already have torn the lock
|
|
131
|
+
// down; a double release (or a release racing staleness takeover) must
|
|
132
|
+
// never mask the turn's real outcome.
|
|
133
|
+
let released = false;
|
|
134
|
+
return async () => {
|
|
135
|
+
if (released)
|
|
136
|
+
return;
|
|
137
|
+
released = true;
|
|
138
|
+
try {
|
|
139
|
+
await release();
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.warn(`[workspace-lock] release of ${resolvedRoot} failed (ignored): ${err}`);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
if (!isLockHeldError(err))
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
if (!waitingReported) {
|
|
151
|
+
waitingReported = true;
|
|
152
|
+
console.log(`[workspace-lock] ${resolvedRoot} is held by another turn; waiting`);
|
|
153
|
+
try {
|
|
154
|
+
await onWaiting?.();
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.warn(`[workspace-lock] onWaiting callback failed (non-fatal): ${err}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const waitedMs = Date.now() - startedAt;
|
|
161
|
+
if (waitedMs + pollIntervalMs > timeoutMs) {
|
|
162
|
+
throw new WorkspaceLockTimeoutError(resolvedRoot, waitedMs);
|
|
163
|
+
}
|
|
164
|
+
heartbeat?.();
|
|
165
|
+
await sleepAbortable(pollIntervalMs, signal);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** proper-lockfile signals "already held" with code ELOCKED. */
|
|
169
|
+
function isLockHeldError(err) {
|
|
170
|
+
return typeof err === "object" && err !== null &&
|
|
171
|
+
err.code === "ELOCKED";
|
|
172
|
+
}
|
|
173
|
+
/** Sleep that wakes immediately when the signal aborts (wait stays responsive). */
|
|
174
|
+
function sleepAbortable(ms, signal) {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
signal?.removeEventListener("abort", onAbort);
|
|
178
|
+
resolve();
|
|
179
|
+
}, ms);
|
|
180
|
+
const onAbort = () => {
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
resolve();
|
|
183
|
+
};
|
|
184
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=workspace-lock.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-lock.js","sourceRoot":"","sources":["../../../src/shared/workspace/workspace-lock.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,QAAQ,MAAM,iBAAiB,CAAC;AAEvC,4DAA4D;AAC5D,MAAM,wBAAwB,GAAG,KAAK,CAAC;AAEvC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,iCAAiC,GAAG,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC;AAEjE;;;;GAIG;AACH,MAAM,gBAAgB,GAAG,MAAM,CAAC;AAEhC,2EAA2E;AAC3E,MAAM,OAAO,yBAA0B,SAAQ,KAAK;IAClD,YAAY,aAAqB,EAAE,QAAgB;QACjD,KAAK,CACH,2CAA2C,aAAa,GAAG;YAC3D,WAAW,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,IAAI,CAAC,iCAAiC;YACvE,iEAAiE,CAClE,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;IAC1C,CAAC;CACF;AAED,kFAAkF;AAClF,MAAM,OAAO,2BAA4B,SAAQ,KAAK;IACpD,YAAY,aAAqB;QAC/B,KAAK,CAAC,qCAAqC,aAAa,EAAE,CAAC,CAAC;QAC5D,IAAI,CAAC,IAAI,GAAG,6BAA6B,CAAC;IAC5C,CAAC;CACF;AA0BD,2FAA2F;AAC3F,SAAS,cAAc;IACrB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,EAAE,CAAC;IACtE,OAAO,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,iBAAiB,CAAC,CAAC;AACnD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,aAAqB,EACrB,UAAuC,EAAE;IAEzC,MAAM,EACJ,SAAS,EACT,SAAS,EACT,MAAM,EACN,SAAS,GAAG,iCAAiC,EAC7C,cAAc,GAAG,wBAAwB,EACzC,OAAO,GAAG,gBAAgB,EAC1B,OAAO,GAAG,cAAc,EAAE,GAC3B,GAAG,OAAO,CAAC;IAEZ,2EAA2E;IAC3E,yEAAyE;IACzE,wEAAwE;IACxE,wEAAwE;IACxE,uEAAuE;IACvE,wEAAwE;IACxE,MAAM,YAAY,GAAG,MAAM,QAAQ,CAAC,aAAa,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC;IACvF,MAAM,GAAG,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACjF,MAAM,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1C,MAAM,YAAY,GAAG,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,CAAC;IAElD,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC7B,IAAI,eAAe,GAAG,KAAK,CAAC;IAE5B,SAAS,CAAC;QACR,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;YACpB,MAAM,IAAI,2BAA2B,CAAC,YAAY,CAAC,CAAC;QACtD,CAAC;QAED,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,YAAY,EAAE;gBAChD,YAAY;gBACZ,QAAQ,EAAE,KAAK;gBACf,KAAK,EAAE,OAAO;gBACd,uEAAuE;gBACvE,yEAAyE;gBACzE,OAAO,EAAE,CAAC;gBACV,uEAAuE;gBACvE,mEAAmE;gBACnE,qEAAqE;gBACrE,kEAAkE;gBAClE,aAAa,EAAE,CAAC,GAAG,EAAE,EAAE;oBACrB,OAAO,CAAC,KAAK,CACX,4BAA4B,YAAY,wBAAwB;wBAChE,0BAA0B,GAAG,EAAE,CAChC,CAAC;gBACJ,CAAC;aACF,CAAC,CAAC;YAEH,IAAI,eAAe,EAAE,CAAC;gBACpB,OAAO,CAAC,GAAG,CACT,6BAA6B,YAAY,iBAAiB;oBAC1D,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,IAAI,CAC9B,CAAC;YACJ,CAAC;YAED,qEAAqE;YACrE,sEAAsE;YACtE,uEAAuE;YACvE,sCAAsC;YACtC,IAAI,QAAQ,GAAG,KAAK,CAAC;YACrB,OAAO,KAAK,IAAI,EAAE;gBAChB,IAAI,QAAQ;oBAAE,OAAO;gBACrB,QAAQ,GAAG,IAAI,CAAC;gBAChB,IAAI,CAAC;oBACH,MAAM,OAAO,EAAE,CAAC;gBAClB,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,OAAO,CAAC,IAAI,CAAC,+BAA+B,YAAY,sBAAsB,GAAG,EAAE,CAAC,CAAC;gBACvF,CAAC;YACH,CAAC,CAAC;QACJ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC;gBAAE,MAAM,GAAG,CAAC;QACvC,CAAC;QAED,IAAI,CAAC,eAAe,EAAE,CAAC;YACrB,eAAe,GAAG,IAAI,CAAC;YACvB,OAAO,CAAC,GAAG,CAAC,oBAAoB,YAAY,mCAAmC,CAAC,CAAC;YACjF,IAAI,CAAC;gBACH,MAAM,SAAS,EAAE,EAAE,CAAC;YACtB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,OAAO,CAAC,IAAI,CAAC,2DAA2D,GAAG,EAAE,CAAC,CAAC;YACjF,CAAC;QACH,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,SAAS,CAAC;QACxC,IAAI,QAAQ,GAAG,cAAc,GAAG,SAAS,EAAE,CAAC;YAC1C,MAAM,IAAI,yBAAyB,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAC;QAC9D,CAAC;QAED,SAAS,EAAE,EAAE,CAAC;QACd,MAAM,cAAc,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;IAC/C,CAAC;AACH,CAAC;AAED,gEAAgE;AAChE,SAAS,eAAe,CAAC,GAAY;IACnC,OAAO,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI;QAC3C,GAA0B,CAAC,IAAI,KAAK,SAAS,CAAC;AACnD,CAAC;AAED,mFAAmF;AACnF,SAAS,cAAc,CAAC,EAAU,EAAE,MAAoB;IACtD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC9C,OAAO,EAAE,CAAC;QACZ,CAAC,EAAE,EAAE,CAAC,CAAC;QACP,MAAM,OAAO,GAAG,GAAG,EAAE;YACnB,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,EAAE,CAAC;QACZ,CAAC,CAAC;QACF,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IAC7D,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/runner",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -77,16 +77,15 @@
|
|
|
77
77
|
"@langchain/langgraph": "^1.3.0",
|
|
78
78
|
"@langchain/mcp-adapters": "^1.1.3",
|
|
79
79
|
"@langchain/openai": "^1.4.0",
|
|
80
|
-
"zod-to-json-schema": "^3.24.0",
|
|
81
80
|
"@opentelemetry/api": "^1.9.0",
|
|
82
81
|
"@opentelemetry/core": "^2.0.0",
|
|
83
82
|
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.218.0",
|
|
84
|
-
"@opentelemetry/sdk-metrics": "^2.0.0",
|
|
85
83
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.218.0",
|
|
86
84
|
"@opentelemetry/resources": "^2.0.0",
|
|
85
|
+
"@opentelemetry/sdk-metrics": "^2.0.0",
|
|
87
86
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
|
88
87
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
|
89
|
-
"@stigmer/protos": "3.1.
|
|
88
|
+
"@stigmer/protos": "3.1.2",
|
|
90
89
|
"@temporalio/activity": "^1.11.0",
|
|
91
90
|
"@temporalio/client": "^1.11.0",
|
|
92
91
|
"@temporalio/common": "^1.11.0",
|
|
@@ -96,12 +95,15 @@
|
|
|
96
95
|
"deepagents": "^1.10.0",
|
|
97
96
|
"jq-wasm": "^1.1.0-jq-1.8.1",
|
|
98
97
|
"js-yaml": "^4.1.1",
|
|
98
|
+
"proper-lockfile": "^4.1.2",
|
|
99
99
|
"semver": "^7.8.0",
|
|
100
|
-
"zod": "^3.25.0"
|
|
100
|
+
"zod": "^3.25.0",
|
|
101
|
+
"zod-to-json-schema": "^3.24.0"
|
|
101
102
|
},
|
|
102
103
|
"devDependencies": {
|
|
103
104
|
"@temporalio/testing": "^1.16.2",
|
|
104
105
|
"@types/js-yaml": "^4.0.9",
|
|
106
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
105
107
|
"@types/semver": "^7.7.1",
|
|
106
108
|
"esbuild": "^0.28.0",
|
|
107
109
|
"tsx": "^4.21.0",
|
|
@@ -33,6 +33,7 @@ describe("loadConfig", () => {
|
|
|
33
33
|
expect(config.proxyEndpoint).toBeNull();
|
|
34
34
|
expect(config.maxConcurrentActivities).toBe(5);
|
|
35
35
|
expect(config.cursorStreamStallTimeoutMs).toBe(180000);
|
|
36
|
+
expect(config.workspaceLockTimeoutMs).toBe(900000);
|
|
36
37
|
});
|
|
37
38
|
|
|
38
39
|
it("respects CURSOR_STREAM_STALL_TIMEOUT_MS", () => {
|
|
@@ -42,6 +43,13 @@ describe("loadConfig", () => {
|
|
|
42
43
|
expect(config.cursorStreamStallTimeoutMs).toBe(90000);
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
it("respects WORKSPACE_LOCK_TIMEOUT_MS", () => {
|
|
47
|
+
process.env.CURSOR_API_KEY = "test-key";
|
|
48
|
+
process.env.WORKSPACE_LOCK_TIMEOUT_MS = "60000";
|
|
49
|
+
const config = loadConfig();
|
|
50
|
+
expect(config.workspaceLockTimeoutMs).toBe(60000);
|
|
51
|
+
});
|
|
52
|
+
|
|
45
53
|
it("respects STIGMER_TASK_QUEUE", () => {
|
|
46
54
|
process.env.STIGMER_TASK_QUEUE = "runner:abc123";
|
|
47
55
|
process.env.CURSOR_API_KEY = "test-key";
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Cursor harness's attachment resolver.
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing behaviors: storage-key attachments materialize under the
|
|
5
|
+
* platform inputs dir (the universal path — every server-created attachment
|
|
6
|
+
* carries a storage key), the workspace `.stigmer` symlink exists even when
|
|
7
|
+
* the agent has no skills, and any attachment that cannot be materialized
|
|
8
|
+
* fails the resolution loudly (the silent-skip regression behind "plan file
|
|
9
|
+
* wasn't found").
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, lstatSync, readlinkSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { resolveAttachments, AttachmentResolutionError } from "../attachment-resolver.js";
|
|
17
|
+
import { getPlatformDir } from "../../../shared/workspace/platform-dir.js";
|
|
18
|
+
import { makeInMemoryArtifactStorage } from "../../../__test-utils__/fake-artifact-storage.js";
|
|
19
|
+
|
|
20
|
+
function makeAttachment(overrides: Partial<{
|
|
21
|
+
filename: string;
|
|
22
|
+
storageKey: string;
|
|
23
|
+
mountPath: string;
|
|
24
|
+
contentType: string;
|
|
25
|
+
extract: boolean;
|
|
26
|
+
localPath: string;
|
|
27
|
+
}> = {}) {
|
|
28
|
+
return {
|
|
29
|
+
filename: overrides.filename ?? "plan.md",
|
|
30
|
+
storageKey: overrides.storageKey ?? "attachments/01ABC/plan.md",
|
|
31
|
+
mountPath: overrides.mountPath ?? "",
|
|
32
|
+
contentType: overrides.contentType ?? "text/markdown",
|
|
33
|
+
extract: overrides.extract ?? false,
|
|
34
|
+
localPath: overrides.localPath ?? "",
|
|
35
|
+
$typeName: "ai.stigmer.agentic.agentexecution.v1.Attachment" as const,
|
|
36
|
+
$unknown: undefined,
|
|
37
|
+
} as any;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("resolveAttachments", () => {
|
|
41
|
+
let workspaceDir: string;
|
|
42
|
+
let sessionId: string;
|
|
43
|
+
let platformDir: string;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
workspaceDir = mkdtempSync(join(tmpdir(), "attach-ws-"));
|
|
47
|
+
sessionId = `test-session-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
48
|
+
platformDir = getPlatformDir(sessionId);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
rmSync(platformDir, { recursive: true, force: true });
|
|
53
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function options(overrides: Partial<Parameters<typeof resolveAttachments>[1]> = {}) {
|
|
57
|
+
return {
|
|
58
|
+
sessionId,
|
|
59
|
+
primaryWorkspaceDir: workspaceDir,
|
|
60
|
+
mode: "local" as const,
|
|
61
|
+
storage: undefined,
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it("returns [] and touches nothing for an execution without attachments", async () => {
|
|
67
|
+
const result = await resolveAttachments([], options());
|
|
68
|
+
|
|
69
|
+
expect(result).toEqual([]);
|
|
70
|
+
expect(() => lstatSync(join(workspaceDir, ".stigmer"))).toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("downloads a storage-key attachment into .stigmer/inputs (the uploadAttachment path)", async () => {
|
|
74
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
75
|
+
await storage.upload("attachments/01ABC/plan.md", Buffer.from("# The Plan"), "text/markdown");
|
|
76
|
+
|
|
77
|
+
const result = await resolveAttachments([makeAttachment()], options({ storage }));
|
|
78
|
+
|
|
79
|
+
expect(result).toEqual([
|
|
80
|
+
{ filename: "plan.md", relativePath: ".stigmer/inputs/plan.md" },
|
|
81
|
+
]);
|
|
82
|
+
expect(readFileSync(join(platformDir, "inputs", "plan.md"), "utf-8")).toBe("# The Plan");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("ensures the workspace .stigmer symlink even when the agent has no skills", async () => {
|
|
86
|
+
// The regression this guards: only the skill resolver created the link,
|
|
87
|
+
// so a skill-less agent's attachments were written but unreachable.
|
|
88
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
89
|
+
await storage.upload("attachments/01ABC/plan.md", Buffer.from("# The Plan"), "text/markdown");
|
|
90
|
+
|
|
91
|
+
await resolveAttachments([makeAttachment()], options({ storage }));
|
|
92
|
+
|
|
93
|
+
const linkPath = join(workspaceDir, ".stigmer");
|
|
94
|
+
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
95
|
+
expect(readlinkSync(linkPath)).toBe(platformDir);
|
|
96
|
+
// The resolved relative path actually dangles nowhere: it reads through
|
|
97
|
+
// the link to the platform dir.
|
|
98
|
+
expect(readFileSync(join(workspaceDir, ".stigmer", "inputs", "plan.md"), "utf-8")).toBe("# The Plan");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("copies a localPath attachment directly in local mode (no storage round-trip)", async () => {
|
|
102
|
+
const srcPath = join(workspaceDir, "src.csv");
|
|
103
|
+
writeFileSync(srcPath, "a,b,c");
|
|
104
|
+
|
|
105
|
+
const result = await resolveAttachments(
|
|
106
|
+
[makeAttachment({ filename: "data.csv", storageKey: "", localPath: srcPath })],
|
|
107
|
+
options(),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual([
|
|
111
|
+
{ filename: "data.csv", relativePath: ".stigmer/inputs/data.csv" },
|
|
112
|
+
]);
|
|
113
|
+
expect(readFileSync(join(platformDir, "inputs", "data.csv"), "utf-8")).toBe("a,b,c");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("ignores localPath in cloud mode and downloads by storage key", async () => {
|
|
117
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
118
|
+
await storage.upload("attachments/01ABC/plan.md", Buffer.from("from storage"), "text/markdown");
|
|
119
|
+
|
|
120
|
+
const result = await resolveAttachments(
|
|
121
|
+
[makeAttachment({ localPath: "/nonexistent/host/path.md" })],
|
|
122
|
+
options({ mode: "cloud", storage }),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect(result).toHaveLength(1);
|
|
126
|
+
expect(readFileSync(join(platformDir, "inputs", "plan.md"), "utf-8")).toBe("from storage");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("fails loudly when the storage download fails (no silent skip)", async () => {
|
|
130
|
+
const { storage } = makeInMemoryArtifactStorage();
|
|
131
|
+
// Nothing uploaded — the download will throw.
|
|
132
|
+
|
|
133
|
+
await expect(
|
|
134
|
+
resolveAttachments([makeAttachment()], options({ storage })),
|
|
135
|
+
).rejects.toThrow(AttachmentResolutionError);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("fails loudly when a storage-backed attachment arrives with no usable storage", async () => {
|
|
139
|
+
await expect(
|
|
140
|
+
resolveAttachments([makeAttachment()], options({ storage: undefined })),
|
|
141
|
+
).rejects.toThrow(/artifact storage is unavailable/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("fails loudly on an attachment with neither localPath nor storageKey", async () => {
|
|
145
|
+
await expect(
|
|
146
|
+
resolveAttachments(
|
|
147
|
+
[makeAttachment({ storageKey: "", localPath: "" })],
|
|
148
|
+
options(),
|
|
149
|
+
),
|
|
150
|
+
).rejects.toThrow(/missing storageKey/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("fails loudly when the local file cannot be read", async () => {
|
|
154
|
+
await expect(
|
|
155
|
+
resolveAttachments(
|
|
156
|
+
[makeAttachment({ storageKey: "", localPath: join(workspaceDir, "missing.md") })],
|
|
157
|
+
options(),
|
|
158
|
+
),
|
|
159
|
+
).rejects.toThrow(AttachmentResolutionError);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect } from "vitest";
|
|
11
11
|
import { create } from "@bufbuild/protobuf";
|
|
12
|
-
import { ApprovalAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
12
|
+
import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
13
13
|
import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
|
|
14
14
|
|
|
15
15
|
import { buildPrompt } from "../index.js";
|
|
16
16
|
import type { BuildPromptInput } from "../index.js";
|
|
17
|
-
import { buildReinvocationPrompt, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
|
|
17
|
+
import { buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection, formatToolApprovalProtocol, buildToolApprovalRuleFile } from "../prompt-builder.js";
|
|
18
|
+
import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
|
|
18
19
|
import type { AgentResolution, AgentResolutionReason } from "../session-lifecycle.js";
|
|
19
20
|
|
|
20
21
|
const USER_MESSAGE = "What was the secret token I told you?";
|
|
@@ -250,3 +251,125 @@ describe("buildReinvocationPrompt", () => {
|
|
|
250
251
|
expect(prompt).not.toContain("Carry them out now");
|
|
251
252
|
});
|
|
252
253
|
});
|
|
254
|
+
|
|
255
|
+
describe("formatInteractionModePrefix", () => {
|
|
256
|
+
it("wraps the shared plan-mode directive in the interaction_mode section", () => {
|
|
257
|
+
const prefix = formatInteractionModePrefix(InteractionMode.PLAN);
|
|
258
|
+
|
|
259
|
+
expect(prefix).toBeDefined();
|
|
260
|
+
expect(prefix!.startsWith("<interaction_mode>")).toBe(true);
|
|
261
|
+
expect(prefix!.endsWith("</interaction_mode>")).toBe(true);
|
|
262
|
+
expect(prefix).toContain(PLAN_MODE_DIRECTIVE);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it.each([
|
|
266
|
+
["AGENT", InteractionMode.AGENT],
|
|
267
|
+
["UNSPECIFIED", InteractionMode.UNSPECIFIED],
|
|
268
|
+
["undefined", undefined],
|
|
269
|
+
])("returns undefined for %s", (_label, mode) => {
|
|
270
|
+
expect(formatInteractionModePrefix(mode)).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("injects the directive into a Plan-mode first prompt", () => {
|
|
274
|
+
const prompt = buildPrompt(
|
|
275
|
+
input({
|
|
276
|
+
resolution: resolution("local", "created_first_execution"),
|
|
277
|
+
interactionMode: InteractionMode.PLAN,
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(prompt).toContain("<interaction_mode>");
|
|
282
|
+
expect(prompt).toContain("your FINAL message IS the plan");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("prefixes the directive on a resumed Plan-mode follow-up (mode is per-execution)", () => {
|
|
286
|
+
const prompt = buildPrompt(
|
|
287
|
+
input({
|
|
288
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
289
|
+
interactionMode: InteractionMode.PLAN,
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(prompt.startsWith("<interaction_mode>")).toBe(true);
|
|
294
|
+
expect(prompt.endsWith(USER_MESSAGE)).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("keeps a resumed Agent-mode follow-up as the raw user message", () => {
|
|
298
|
+
const prompt = buildPrompt(
|
|
299
|
+
input({
|
|
300
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
301
|
+
interactionMode: InteractionMode.AGENT,
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
expect(prompt).toBe(USER_MESSAGE);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("formatImplementPlanSection", () => {
|
|
310
|
+
const PLAN_PATH = ".stigmer/inputs/plan.md";
|
|
311
|
+
|
|
312
|
+
it("wraps the attached-plan directive when the plan is among the attachments", () => {
|
|
313
|
+
const section = formatImplementPlanSection(true, [PLAN_PATH, ".stigmer/inputs/data.csv"]);
|
|
314
|
+
|
|
315
|
+
expect(section).toBeDefined();
|
|
316
|
+
expect(section!.startsWith("<implement_plan>")).toBe(true);
|
|
317
|
+
expect(section!.endsWith("</implement_plan>")).toBe(true);
|
|
318
|
+
expect(section).toContain(`\`${PLAN_PATH}\``);
|
|
319
|
+
expect(section).toContain("APPROVED");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("falls back to the conversation-plan directive when no plan attachment resolved", () => {
|
|
323
|
+
const section = formatImplementPlanSection(true, [".stigmer/inputs/data.csv"]);
|
|
324
|
+
|
|
325
|
+
expect(section).toBeDefined();
|
|
326
|
+
expect(section).not.toContain("plan.md");
|
|
327
|
+
expect(section).toContain("conversation above");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("returns undefined for an ordinary (non-build) execution", () => {
|
|
331
|
+
expect(formatImplementPlanSection(false, [PLAN_PATH])).toBeUndefined();
|
|
332
|
+
expect(formatImplementPlanSection(undefined, [PLAN_PATH])).toBeUndefined();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("injects the directive into a build-from-plan first prompt", () => {
|
|
336
|
+
const prompt = buildPrompt(
|
|
337
|
+
input({
|
|
338
|
+
resolution: resolution("local", "created_first_execution"),
|
|
339
|
+
buildFromPlan: true,
|
|
340
|
+
attachmentPaths: [PLAN_PATH],
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
expect(prompt).toContain("<implement_plan>");
|
|
345
|
+
expect(prompt).toContain(`\`${PLAN_PATH}\``);
|
|
346
|
+
expect(prompt).toContain(USER_MESSAGE);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("prefixes the directive on a resumed build turn (build_from_plan is per-execution)", () => {
|
|
350
|
+
// The common shape: the plan turn ran earlier in the session, so the
|
|
351
|
+
// build turn resumes the agent — the directive must still arrive.
|
|
352
|
+
const prompt = buildPrompt(
|
|
353
|
+
input({
|
|
354
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
355
|
+
buildFromPlan: true,
|
|
356
|
+
attachmentPaths: [PLAN_PATH],
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
expect(prompt.startsWith("<implement_plan>")).toBe(true);
|
|
361
|
+
expect(prompt.endsWith(USER_MESSAGE)).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("keeps a resumed non-build follow-up as the raw user message", () => {
|
|
365
|
+
const prompt = buildPrompt(
|
|
366
|
+
input({
|
|
367
|
+
resolution: resolution("local", "resumed_successfully"),
|
|
368
|
+
buildFromPlan: false,
|
|
369
|
+
attachmentPaths: [PLAN_PATH],
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
expect(prompt).toBe(USER_MESSAGE);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resetCasObservations,
|
|
27
27
|
casObservationsDir,
|
|
28
28
|
buildObservationStagingScript,
|
|
29
|
+
buildSecretClassifyScript,
|
|
29
30
|
} from "../cas-observations.js";
|
|
30
31
|
import { isSecretLikePath } from "../../../shared/filereview/secret-paths.js";
|
|
31
32
|
|
|
@@ -184,4 +185,58 @@ describe("cas-observations sidecar", () => {
|
|
|
184
185
|
}
|
|
185
186
|
});
|
|
186
187
|
});
|
|
188
|
+
|
|
189
|
+
// The deny-gate (no-capture-substrate) classify-only script. It shares the
|
|
190
|
+
// classifier fragment with the staging script, so its verdict must equal
|
|
191
|
+
// isSecretLikePath byte-for-byte — a secret must never fall through the hook's
|
|
192
|
+
// deny-gate secret hard-block (DD-26 #2).
|
|
193
|
+
describe("buildSecretClassifyScript (deny-gate classify-only)", () => {
|
|
194
|
+
function runClassify(salient: string): string {
|
|
195
|
+
return execFileSync(process.execPath, ["-e", buildSecretClassifyScript()], {
|
|
196
|
+
input: salient,
|
|
197
|
+
})
|
|
198
|
+
.toString()
|
|
199
|
+
.trim();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it("classifies secrets byte-for-byte identically to isSecretLikePath (no staging)", () => {
|
|
203
|
+
const paths = [
|
|
204
|
+
".env",
|
|
205
|
+
".env.local",
|
|
206
|
+
"config/.env.production",
|
|
207
|
+
"id_rsa",
|
|
208
|
+
"server.pem",
|
|
209
|
+
"certs/key.pfx",
|
|
210
|
+
"terraform.tfstate",
|
|
211
|
+
".aws/credentials",
|
|
212
|
+
"home/.ssh/known_hosts",
|
|
213
|
+
".npmrc",
|
|
214
|
+
"secrets.yaml",
|
|
215
|
+
"/abs/path/.env",
|
|
216
|
+
"id_rsa.pub",
|
|
217
|
+
"app.log",
|
|
218
|
+
"src/index.ts",
|
|
219
|
+
"docs/notes.md",
|
|
220
|
+
"build/out.js",
|
|
221
|
+
"environment.ts",
|
|
222
|
+
];
|
|
223
|
+
for (const p of paths) {
|
|
224
|
+
const expected = isSecretLikePath(p) ? "secret" : "ok";
|
|
225
|
+
expect(runClassify(p), `classification for ${p}`).toBe(expected);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("fail-closes on empty input (classifies as secret), matching isSecretLikePath('')", () => {
|
|
230
|
+
expect(runClassify("")).toBe("secret");
|
|
231
|
+
expect(isSecretLikePath("")).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("does not stage anything (pure classifier)", async () => {
|
|
235
|
+
const ws = tmp("obs-classify-nostage-");
|
|
236
|
+
writeFileSync(join(ws, ".env"), "API_KEY=xyz", "utf-8");
|
|
237
|
+
expect(runClassify(".env")).toBe("secret");
|
|
238
|
+
// No sidecar dir is created or written by the classify-only script.
|
|
239
|
+
expect(await readCasObservations(ws)).toEqual({ captured: [], secretPaths: [] });
|
|
240
|
+
});
|
|
241
|
+
});
|
|
187
242
|
});
|