agent-dealer 1.2.4 → 1.2.6
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/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
- package/bundle/server/dist/adapters/agent-health.js +28 -6
- package/bundle/server/dist/adapters/agent-health.test.js +66 -11
- package/bundle/server/dist/adapters/git-worktree.js +187 -18
- package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
- package/bundle/server/dist/adapters/github.js +110 -12
- package/bundle/server/dist/adapters/github.test.js +274 -3
- package/bundle/server/dist/adapters/muse-capability.js +356 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
- package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
- package/bundle/server/dist/capacity/muse-host.js +92 -32
- package/bundle/server/dist/capacity/muse-host.test.js +130 -4
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- package/bundle/server/dist/coordinator/args.js +4 -3
- package/bundle/server/dist/coordinator/checkpoint.js +5 -3
- package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
- package/bundle/server/dist/coordinator/commands.js +100 -12
- package/bundle/server/dist/coordinator/developer-effect.js +128 -16
- package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
- package/bundle/server/dist/coordinator/human-resolution.js +33 -1
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +25 -6
- package/bundle/server/dist/coordinator/prompts.test.js +36 -8
- package/bundle/server/dist/coordinator/routing.js +1 -0
- package/bundle/server/dist/coordinator/routing.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +9 -1
- package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
- package/bundle/server/dist/repository/human-actions.js +13 -0
- package/bundle/server/dist/routes/human-actions.js +10 -1
- package/bundle/server/dist/routes/human-actions.test.js +117 -1
- package/bundle/server/dist/routes/index.js +12 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- package/bundle/server/dist/routes/version.js +30 -0
- package/bundle/server/dist/routes/version.test.js +21 -0
- package/bundle/server/dist/runners/muse-config-core.js +63 -16
- package/bundle/server/dist/runners/muse-config.js +1 -1
- package/bundle/server/dist/runners/muse-config.test.js +135 -29
- package/bundle/server/dist/runners/spawn-cli.js +17 -3
- package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
- package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +15 -15
- package/bundle/shared/dist/agents.js +6 -0
- package/bundle/shared/dist/index.d.ts +7 -7
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +7 -3
- package/dist/doctor.js +21 -13
- package/dist/doctor.test.js +9 -8
- package/dist/managed/index.d.ts +1 -1
- package/dist/managed/index.js +1 -1
- package/dist/managed/updater.d.ts +16 -6
- package/dist/managed/updater.js +28 -11
- package/dist/managed-update-restart.test.d.ts +1 -0
- package/dist/managed-update-restart.test.js +227 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +10 -0
- package/dist/runtime-state.d.ts +10 -0
- package/dist/runtime-state.js +26 -0
- package/dist/start.js +67 -2
- package/dist/status.js +30 -2
- package/dist/update-check.js +3 -6
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
|
@@ -1,45 +1,29 @@
|
|
|
1
1
|
// packages/server/src/coordinator/muse-spawn.ts
|
|
2
2
|
//
|
|
3
|
-
// NOT-
|
|
4
|
-
//
|
|
5
|
-
// (runners/muse-
|
|
6
|
-
//
|
|
3
|
+
// NOT-278: the native Muse Code developer spawn. Every deck-enabled Muse developer turn runs the
|
|
4
|
+
// isolated `muse exec` lane: one exact, fail-closed per-attempt home built by
|
|
5
|
+
// `prepareMuseAttempt` (runners/muse-config.ts) — settings.json with exactly one required
|
|
6
|
+
// `agent-deck` server (deck/workspace headers), filtered env, stdin-only API key — verified
|
|
7
|
+
// immediately before spawn and removed afterwards on every outcome.
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// 4. the raw stdout archived next to the log, and the log itself rewritten as Claude/Cursor-shaped
|
|
14
|
-
// events, so every existing log reader (usage, result text, auth/usage-cap classification,
|
|
15
|
-
// activity strip) works unchanged.
|
|
9
|
+
// The shared Muse serve host cannot carry per-session MCP configuration or deck/workspace
|
|
10
|
+
// headers (`session/start` accepts only command, workspace, model, and approval mode), so the
|
|
11
|
+
// serve lane is never used here. Fresh 5H/1W observation from the serve host is deliberately
|
|
12
|
+
// suspended while deck-enabled work is forced to exec; capacity stays honest N/A/stale with
|
|
13
|
+
// last-good rows preserved. Per-session Agent Deck support in the serve protocol is a non-goal.
|
|
16
14
|
//
|
|
17
15
|
// Muse cannot disable `cron_*` (NOT-177). The tool activity is checked here after the fact and
|
|
18
16
|
// surfaced as `cronCalls`; the effect turns that into a `muse_cron_used` escalation. It detects,
|
|
19
17
|
// it does not prevent.
|
|
20
|
-
import { randomUUID } from "node:crypto";
|
|
21
18
|
import fs from "node:fs";
|
|
22
19
|
import path from "node:path";
|
|
23
20
|
import { MUSE_CODE_CONTRIBUTOR_MODEL } from "@agent-dealer/shared";
|
|
24
21
|
import { resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { runMuseServeTurn } from "../runners/muse-serve-session.js";
|
|
22
|
+
import { getAgentDeckMcpUrl } from "../adapters/agent-deck.js";
|
|
23
|
+
import { getWorkerMcpConfigDir } from "../paths.js";
|
|
24
|
+
import { prepareMuseAttempt, } from "../runners/muse-config.js";
|
|
25
|
+
import { parseMuseRun, } from "../runners/muse-code-jsonl.js";
|
|
30
26
|
import { spawnCli } from "../runners/spawn-cli.js";
|
|
31
|
-
/**
|
|
32
|
-
* NOT-270: execution lane selection. Default is the server-owned serve host
|
|
33
|
-
* (real turns through it are the NOT-269 precondition for 5H/1W capacity).
|
|
34
|
-
* `AGENT_DEALER_MUSE_RUNNER=exec` forces the legacy `muse exec` subprocess
|
|
35
|
-
* lane — the operator escape hatch if the serve lane ever misbehaves in
|
|
36
|
-
* production (capacity then reads honest N/A until serve runs again).
|
|
37
|
-
*/
|
|
38
|
-
function museServeLaneEnabled() {
|
|
39
|
-
return process.env.AGENT_DEALER_MUSE_RUNNER !== "exec";
|
|
40
|
-
}
|
|
41
|
-
/** Worktree-relative home of every per-attempt Muse config/data dir. */
|
|
42
|
-
export const MUSE_ATTEMPT_ROOT = ".dealer-muse";
|
|
43
27
|
/**
|
|
44
28
|
* Step cap when the profile sets no `maxTurns`. High on purpose (the PoC's value): the developer
|
|
45
29
|
* wall-clock timeout is the real limit, the cap only stops a runaway loop.
|
|
@@ -81,32 +65,24 @@ function readSessionLog(dataDir, museSessionId) {
|
|
|
81
65
|
};
|
|
82
66
|
return visit(root, 0);
|
|
83
67
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// mkdir's mode is masked by the umask; the dirs hold a login symlink and the session log.
|
|
92
|
-
fs.chmodSync(root, 0o700);
|
|
93
|
-
fs.chmodSync(configHome, 0o700);
|
|
94
|
-
fs.chmodSync(dataHome, 0o700);
|
|
95
|
-
fs.writeFileSync(path.join(configHome, "muse", "settings.json"), JSON.stringify(buildMuseDeveloperSettings(), null, 2) + "\n", { mode: 0o600 });
|
|
96
|
-
// Linked, never read or copied. Absent when the operator authenticates with META_API_KEY.
|
|
97
|
-
const auth = resolveMuseAuthFile();
|
|
98
|
-
if (fs.existsSync(auth))
|
|
99
|
-
fs.symlinkSync(auth, path.join(configHome, "muse", "auth.json"));
|
|
100
|
-
return { root, configHome, dataHome };
|
|
101
|
-
}
|
|
102
|
-
function removeAttemptDirs(worktreePath, root) {
|
|
103
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
68
|
+
/**
|
|
69
|
+
* NOT-278: the attempt's credential. A saved login is linked, never read or copied; otherwise
|
|
70
|
+
* the API key travels on stdin only (`--api-key-stdin`, written once by the launcher and never
|
|
71
|
+
* logged). Missing credentials fail before spawn — nothing is written and no process starts.
|
|
72
|
+
*/
|
|
73
|
+
function resolveMuseCredential() {
|
|
74
|
+
const authFile = resolveMuseAuthFile();
|
|
104
75
|
try {
|
|
105
|
-
fs.
|
|
76
|
+
if (fs.existsSync(authFile))
|
|
77
|
+
return { kind: "auth-file", path: authFile };
|
|
106
78
|
}
|
|
107
79
|
catch {
|
|
108
|
-
//
|
|
80
|
+
// An unreadable auth path is the same as a missing one: fall through to the API key.
|
|
109
81
|
}
|
|
82
|
+
const apiKey = process.env.META_API_KEY;
|
|
83
|
+
if (apiKey !== undefined && apiKey.trim() !== "")
|
|
84
|
+
return { kind: "api-key", apiKey };
|
|
85
|
+
throw new Error("Muse Code auth required: run `muse login` or set META_API_KEY");
|
|
110
86
|
}
|
|
111
87
|
function splitSpawnLog(raw) {
|
|
112
88
|
const idx = raw.indexOf(STDERR_MARKER);
|
|
@@ -123,11 +99,9 @@ function splitSpawnLog(raw) {
|
|
|
123
99
|
* safe point is contractual, not opportunistic — a recent Agents-page poll
|
|
124
100
|
* must never skip it. This creates no model turn of its own (the capacity
|
|
125
101
|
* client only ever sends the read-only handshake/`usage/read` allowlist)
|
|
126
|
-
* and never affects the session result.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* structural precondition) and the read stays honest N/A with last-good
|
|
130
|
-
* rows preserved.
|
|
102
|
+
* and never affects the session result. Exec-lane turns leave the host
|
|
103
|
+
* unobserved by construction (the NOT-269 structural precondition), so the
|
|
104
|
+
* read stays honest N/A with last-good rows preserved.
|
|
131
105
|
*/
|
|
132
106
|
function refreshMuseCapacityAfterSession() {
|
|
133
107
|
void (async () => {
|
|
@@ -143,129 +117,63 @@ function refreshMuseCapacityAfterSession() {
|
|
|
143
117
|
})();
|
|
144
118
|
}
|
|
145
119
|
/**
|
|
146
|
-
* NOT-
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
120
|
+
* NOT-278: run the deck-enabled developer turn as one isolated `muse exec` attempt. Fails before
|
|
121
|
+
* spawn without a frozen profile deck (the effect already maps that to `deck_failure`; this is
|
|
122
|
+
* the defense in depth that keeps a deckless launch from ever reaching a child process).
|
|
123
|
+
*
|
|
124
|
+
* The attempt is built with `prepareMuseAttempt({ role: "developer", ... })` on a Dealer-owned
|
|
125
|
+
* base directory outside the worktree and outside OS temp (the worker MCP config root), with
|
|
126
|
+
* `agentDeck.url` set to the configured Agent Deck MCP endpoint ending in `/mcp` (overridable
|
|
127
|
+
* for deckless infra callers), `deckId` to the frozen profile value, and `workspace` to the
|
|
128
|
+
* real worktree path. Immediately before spawn the exact cwd/argv/env/stdin about to be
|
|
129
|
+
* launched is verified; the child receives exactly
|
|
130
|
+
* `attempt.env` (no `process.env` merge) and `attempt.stdin`, so ambient `META_API_KEY`,
|
|
131
|
+
* `MUSE_*`, `CODEX_HOME`, and unrelated variables can never reach it.
|
|
151
132
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* capacity/muse-host.ts — same worker settings as the exec lane's
|
|
155
|
-
* per-attempt settings.json plus a symlink to the ambient login, so runner
|
|
156
|
-
* and capacity can never diverge on identity or posture). `onSpawn` is
|
|
157
|
-
* deliberately not called: there is no per-attempt pid to persist, and
|
|
158
|
-
* recovery must never treat the shared host as an attempt process.
|
|
133
|
+
* Settings, data, and the auth link are removed in `finally` on success, Muse failure,
|
|
134
|
+
* timeout, abort, and spawn error — operator credentials are never touched.
|
|
159
135
|
*/
|
|
160
|
-
async function runMuseServeLane(input, model, onAdmitted) {
|
|
161
|
-
const { getMuseCapacityHost } = await import("../capacity/muse-host.js");
|
|
162
|
-
// Only this call may fall back: it resolves `admitted:false` (or throws)
|
|
163
|
-
// strictly before any model work starts. Everything below runs after
|
|
164
|
-
// admission — the caller's no-double-execution rule (via onAdmitted)
|
|
165
|
-
// keeps those errors loud instead of falling back to exec.
|
|
166
|
-
const turn = await runMuseServeTurn({
|
|
167
|
-
host: getMuseCapacityHost(),
|
|
168
|
-
prompt: input.prompt,
|
|
169
|
-
model,
|
|
170
|
-
cwd: input.cwd,
|
|
171
|
-
timeoutMs: input.timeoutMs,
|
|
172
|
-
signal: input.signal,
|
|
173
|
-
onAdmitted: () => onAdmitted(),
|
|
174
|
-
});
|
|
175
|
-
if (!turn.admitted)
|
|
176
|
-
return null;
|
|
177
|
-
const cronCalls = turn.tools
|
|
178
|
-
.map((t) => t.name)
|
|
179
|
-
.filter((n) => n !== null && CRON_TOOL_RE.test(n));
|
|
180
|
-
const summary = {
|
|
181
|
-
museSessionId: turn.sessionId ?? randomUUID(),
|
|
182
|
-
confirmedModel: turn.confirmedModel,
|
|
183
|
-
usage: turn.usage,
|
|
184
|
-
failure: turn.failure,
|
|
185
|
-
cronCalls,
|
|
186
|
-
runCount: 1,
|
|
187
|
-
rawLogPath: null,
|
|
188
|
-
};
|
|
189
|
-
// Same normalized evidence both lanes write: the raw folded history, then
|
|
190
|
-
// the Claude/Cursor-shaped stream every existing log reader consumes.
|
|
191
|
-
const rawLogPath = `${input.logPath.replace(/\.ndjson$/, "")}.muse-raw.jsonl`;
|
|
192
|
-
try {
|
|
193
|
-
fs.writeFileSync(rawLogPath, `${turn.viewItems.map((i) => JSON.stringify(i)).join("\n")}\n`);
|
|
194
|
-
summary.rawLogPath = rawLogPath;
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
// evidence copy is best-effort; the normalized log below still carries the outcome
|
|
198
|
-
}
|
|
199
|
-
const events = normalizeMuseRun({
|
|
200
|
-
sessionId: summary.museSessionId,
|
|
201
|
-
confirmedModel: turn.confirmedModel,
|
|
202
|
-
tools: turn.tools,
|
|
203
|
-
finalText: turn.finalText,
|
|
204
|
-
usage: turn.usage,
|
|
205
|
-
failure: turn.failure,
|
|
206
|
-
}).map((e) => e.type === "result" ? { ...e, muse: { ...summary, usage: turn.usage } } : e);
|
|
207
|
-
fs.writeFileSync(input.logPath, `${events.map((e) => JSON.stringify(e)).join("\n")}\n`);
|
|
208
|
-
// The real session above is the observation opportunity: kick the
|
|
209
|
-
// best-effort capacity refresh (no synthetic turn, never throws, never
|
|
210
|
-
// blocks this result).
|
|
211
|
-
refreshMuseCapacityAfterSession();
|
|
212
|
-
// A failed session must not read as success. 124 mirrors spawn-cli's
|
|
213
|
-
// timeout exit so timeout handling downstream sees the same signal.
|
|
214
|
-
const exitCode = turn.timedOut ? 124 : turn.failure ? 1 : turn.terminal === "completed" ? 0 : 1;
|
|
215
|
-
return {
|
|
216
|
-
exitCode,
|
|
217
|
-
transcript: turn.finalText ?? "",
|
|
218
|
-
logPath: input.logPath,
|
|
219
|
-
timedOut: turn.timedOut,
|
|
220
|
-
muse: summary,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
136
|
export async function runMuseDeveloperSession(input) {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
// precondition for 5H/1W). Pre-admission failure falls back to exec; an
|
|
228
|
-
// admitted turn never falls back — its errors propagate loudly instead of
|
|
229
|
-
// executing the work a second time on the exec lane.
|
|
230
|
-
// `AGENT_DEALER_MUSE_RUNNER=exec` skips the serve lane entirely
|
|
231
|
-
// (operator escape hatch).
|
|
232
|
-
if (museServeLaneEnabled()) {
|
|
233
|
-
let admitted = false;
|
|
234
|
-
try {
|
|
235
|
-
const served = await runMuseServeLane(input, model, () => {
|
|
236
|
-
admitted = true;
|
|
237
|
-
});
|
|
238
|
-
if (served)
|
|
239
|
-
return served;
|
|
240
|
-
}
|
|
241
|
-
catch (err) {
|
|
242
|
-
if (admitted)
|
|
243
|
-
throw err;
|
|
244
|
-
// Pre-admission serve-lane error: fall through to the exec lane below.
|
|
245
|
-
}
|
|
137
|
+
const deckId = typeof input.deckId === "string" && input.deckId.trim() !== "" ? input.deckId : null;
|
|
138
|
+
if (!deckId) {
|
|
139
|
+
throw new Error("Muse developer session requires a frozen profile deckId — workers never start without one");
|
|
246
140
|
}
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
141
|
+
const model = input.model ?? MUSE_CODE_CONTRIBUTOR_MODEL;
|
|
142
|
+
const mcpUrl = typeof input.agentDeckUrl === "string" && input.agentDeckUrl.trim() !== ""
|
|
143
|
+
? input.agentDeckUrl
|
|
144
|
+
: `${getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "")}/mcp`;
|
|
145
|
+
// prepareMuseAttempt validates everything (role controls, deck identity, credential shape,
|
|
146
|
+
// unsafe paths) and writes the per-attempt dir; it removes its own dir when setup fails.
|
|
147
|
+
const attempt = prepareMuseAttempt({
|
|
148
|
+
role: "developer",
|
|
149
|
+
worktreePath: input.cwd,
|
|
150
|
+
baseDir: getWorkerMcpConfigDir(),
|
|
151
|
+
agentDeck: { url: mcpUrl, deckId, workspace: input.cwd },
|
|
152
|
+
credential: resolveMuseCredential(),
|
|
153
|
+
sessionId: input.sessionId,
|
|
251
154
|
prompt: input.prompt,
|
|
155
|
+
maxModelSteps: input.maxModelSteps ?? DEFAULT_MUSE_MAX_MODEL_STEPS,
|
|
252
156
|
});
|
|
253
157
|
const { logPath } = input;
|
|
254
|
-
const dirs = prepareAttemptDirs(input.cwd);
|
|
255
158
|
let spawned;
|
|
256
159
|
let sessionLog;
|
|
257
160
|
try {
|
|
258
|
-
|
|
161
|
+
// The exact launch about to happen — a tampered settings file or a differing
|
|
162
|
+
// cwd/argv/env/stdin fails here, before any process exists.
|
|
163
|
+
attempt.verify({ cwd: attempt.cwd, argv: [...attempt.argv], env: { ...attempt.env }, stdin: attempt.stdin });
|
|
164
|
+
spawned = await spawnCli(input.sessionId, resolveMuseBin(), [...attempt.argv], attempt.cwd, {
|
|
259
165
|
logPath,
|
|
260
166
|
timeoutMs: input.timeoutMs,
|
|
261
|
-
env: { ...
|
|
167
|
+
env: { ...attempt.env },
|
|
168
|
+
exactEnv: true,
|
|
169
|
+
stdin: attempt.stdin,
|
|
262
170
|
signal: input.signal,
|
|
263
171
|
onSpawn: input.onSpawn,
|
|
264
172
|
});
|
|
265
|
-
sessionLog = readSessionLog(
|
|
173
|
+
sessionLog = readSessionLog(attempt.env.XDG_DATA_HOME, input.sessionId);
|
|
266
174
|
}
|
|
267
175
|
finally {
|
|
268
|
-
|
|
176
|
+
attempt.cleanup();
|
|
269
177
|
}
|
|
270
178
|
const rawLog = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : spawned.transcript;
|
|
271
179
|
const { stdout, stderr } = splitSpawnLog(rawLog);
|
|
@@ -278,7 +186,7 @@ export async function runMuseDeveloperSession(input) {
|
|
|
278
186
|
});
|
|
279
187
|
const cronCalls = run.tools.map((t) => t.name).filter((n) => n !== null && CRON_TOOL_RE.test(n));
|
|
280
188
|
const summary = {
|
|
281
|
-
museSessionId,
|
|
189
|
+
museSessionId: input.sessionId,
|
|
282
190
|
confirmedModel: run.confirmedModel,
|
|
283
191
|
usage: run.usage,
|
|
284
192
|
failure: run.failure,
|
|
@@ -60,6 +60,7 @@ export function projectDeveloperRoute(route, currentStatus, round) {
|
|
|
60
60
|
...("pushDivergence" in route && route.pushDivergence
|
|
61
61
|
? { pushDivergence: route.pushDivergence }
|
|
62
62
|
: {}),
|
|
63
|
+
...(route.blockerFingerprint ? { blockerFingerprint: route.blockerFingerprint } : {}),
|
|
63
64
|
},
|
|
64
65
|
advance: "none",
|
|
65
66
|
};
|
|
@@ -9,6 +9,19 @@ function guidanceSection(guidance) {
|
|
|
9
9
|
...guidance.flatMap((g) => [g.trim(), ``]),
|
|
10
10
|
];
|
|
11
11
|
}
|
|
12
|
+
/** NOT-272: a resolved product_scope_decision's human note, verbatim. Empty/absent
|
|
13
|
+
* renders nothing so noteless resolves produce byte-for-byte the prompt they always did. */
|
|
14
|
+
function scopeDecisionSection(note) {
|
|
15
|
+
if (!note?.trim())
|
|
16
|
+
return [];
|
|
17
|
+
return [
|
|
18
|
+
`## Human decision`,
|
|
19
|
+
`A human resolved the product-scope question that was holding this work. This is the decision — follow it. Do not re-derive it, do not re-escalate the same question, and do not write this decision into source comments; it is recorded on the human action.`,
|
|
20
|
+
``,
|
|
21
|
+
note.trim(),
|
|
22
|
+
``,
|
|
23
|
+
];
|
|
24
|
+
}
|
|
12
25
|
/**
|
|
13
26
|
* An agent with a deckId is launched with that deck already equipped in its MCP session
|
|
14
27
|
* (NOT-106). `bind_workspace` confirms the equipped deck for this session cwd — it does
|
|
@@ -23,16 +36,18 @@ function guidanceSection(guidance) {
|
|
|
23
36
|
*
|
|
24
37
|
* Workers never start without a deckId (fail-closed at admission/effect).
|
|
25
38
|
*/
|
|
26
|
-
|
|
39
|
+
/**
|
|
40
|
+
* NOT-278: Muse cannot mechanically disable `cron_*`, so its developer prompt carries this
|
|
41
|
+
* prohibition on top of the standard deck bootstrap. A session that calls one is failed as
|
|
42
|
+
* `muse_cron_used` and escalated to the operator.
|
|
43
|
+
*/
|
|
44
|
+
function museCronProhibitionSection() {
|
|
27
45
|
return [
|
|
28
|
-
`This session has no Agent Deck and no MCP servers. The Task and Acceptance criteria above are the complete brief — if they only reference a ticket id you cannot look it up, so say so in your conclusion instead of guessing.`,
|
|
29
46
|
`Never call \`cron_create\`, \`cron_list\` or \`cron_delete\` and never schedule anything: a session that does is failed and escalated to the operator.`,
|
|
30
47
|
``,
|
|
31
48
|
];
|
|
32
49
|
}
|
|
33
|
-
function agentDeckSection(worktreePath, deckId
|
|
34
|
-
if (noAgentDeck)
|
|
35
|
-
return noAgentDeckSection();
|
|
50
|
+
function agentDeckSection(worktreePath, deckId) {
|
|
36
51
|
if (!deckId || !worktreePath) {
|
|
37
52
|
return [
|
|
38
53
|
`This session is misconfigured: Agent Deck is required but missing. Stop and report the bootstrap failure — do not improvise without the deck.`,
|
|
@@ -63,6 +78,8 @@ export function buildDeveloperPrompt(input) {
|
|
|
63
78
|
parts.push(...formatVerificationReceiptSection(input.priorVerificationReceipt));
|
|
64
79
|
}
|
|
65
80
|
}
|
|
81
|
+
// NOT-272: the scope decision leads — the developer reads it before the task itself.
|
|
82
|
+
parts.push(...scopeDecisionSection(input.scopeDecisionNote));
|
|
66
83
|
parts.push(`## Task`, input.taskSnapshot.title, input.taskSnapshot.description, ``, `## Acceptance criteria`, input.taskSnapshot.acceptanceCriteria, ``);
|
|
67
84
|
if (input.findings?.length) {
|
|
68
85
|
parts.push(`## Findings to address`);
|
|
@@ -73,7 +90,9 @@ export function buildDeveloperPrompt(input) {
|
|
|
73
90
|
parts.push(``);
|
|
74
91
|
}
|
|
75
92
|
parts.push(...guidanceSection(input.guidance));
|
|
76
|
-
parts.push(...agentDeckSection(input.worktreePath, input.deckId
|
|
93
|
+
parts.push(...agentDeckSection(input.worktreePath, input.deckId));
|
|
94
|
+
if (input.museDeveloper)
|
|
95
|
+
parts.push(...museCronProhibitionSection());
|
|
77
96
|
parts.push(`## Required`,
|
|
78
97
|
// NOT-115: encourage incremental commits so a mid-session death leaves less
|
|
79
98
|
// uncommitted work for dirty_worktree escalation — soft mitigation only.
|
|
@@ -133,6 +133,26 @@ test("no guidance section when there is none", () => {
|
|
|
133
133
|
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1 });
|
|
134
134
|
assert.doesNotMatch(prompt, /## Guidance from the team/);
|
|
135
135
|
});
|
|
136
|
+
// NOT-272: a product_scope_decision note reaches the very next developer round verbatim
|
|
137
|
+
// under its own heading, positioned before the task so it is read before acting.
|
|
138
|
+
test("NOT-272: scope decision note renders verbatim under its own heading before the task", () => {
|
|
139
|
+
const note = "The muse_code runner migration IS in scope for this ticket — proceed with it.";
|
|
140
|
+
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 2, scopeDecisionNote: note });
|
|
141
|
+
assert.match(prompt, /## Human decision/);
|
|
142
|
+
assert.ok(prompt.includes(note), "note text must appear verbatim");
|
|
143
|
+
assert.ok(prompt.indexOf("## Human decision") < prompt.indexOf("## Task"), "Human decision section must precede the task");
|
|
144
|
+
assert.match(prompt, /do not re-escalate the same question/i);
|
|
145
|
+
});
|
|
146
|
+
// NOT-272: resolving without a note changes nothing — absent, empty, and blank notes all
|
|
147
|
+
// produce byte-for-byte the prompt built before this change.
|
|
148
|
+
test("NOT-272: no scope decision note produces byte-for-byte the same prompt as before", () => {
|
|
149
|
+
const base = { taskSnapshot, round: 2, findings: [{ fingerprint: "f1", severity: "blocking", title: "Bug", rationale: "It breaks", file: "a.ts", line: 10, status: "open", firstRound: 1, lastRound: 1, issueId: "i" }], guidance: ["Use the new logging util instead."] };
|
|
150
|
+
const without = buildDeveloperPrompt(base);
|
|
151
|
+
assert.doesNotMatch(without, /## Human decision/);
|
|
152
|
+
assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: undefined }), without);
|
|
153
|
+
assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: "" }), without);
|
|
154
|
+
assert.equal(buildDeveloperPrompt({ ...base, scopeDecisionNote: " \n " }), without);
|
|
155
|
+
});
|
|
136
156
|
test("guidance since the last session is surfaced in the reviewer prompt", () => {
|
|
137
157
|
const prompt = buildReviewerPrompt({
|
|
138
158
|
taskSnapshot,
|
|
@@ -210,17 +230,25 @@ test("reviewer prompt deck section requires bind_workspace first, matching the d
|
|
|
210
230
|
assert.match(prompt, /do not web-fetch Linear/);
|
|
211
231
|
assert.doesNotMatch(prompt, /get_playbook\(/);
|
|
212
232
|
});
|
|
213
|
-
// NOT-
|
|
214
|
-
//
|
|
215
|
-
test("NOT-
|
|
216
|
-
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1,
|
|
217
|
-
assert.match(prompt, /
|
|
233
|
+
// NOT-278: a Muse Code developer gets the standard Agent Deck bootstrap gate plus the
|
|
234
|
+
// Muse-specific cron_* prohibition (detected post-run as muse_cron_used).
|
|
235
|
+
test("NOT-278: museDeveloper prompt carries the standard deck bootstrap and forbids cron_*", () => {
|
|
236
|
+
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt", museDeveloper: true });
|
|
237
|
+
assert.match(prompt, /bind_workspace\(\{ deckId: "deck-1", workspaceRoot: "\/wt" \}\)/);
|
|
238
|
+
assert.match(prompt, /bootstrap is a hard gate/i);
|
|
239
|
+
assert.match(prompt, /do not improvise without the deck/i);
|
|
240
|
+
assert.match(prompt, /call_service_tool/);
|
|
218
241
|
assert.match(prompt, /cron_create/);
|
|
219
|
-
assert.
|
|
220
|
-
assert.
|
|
242
|
+
assert.match(prompt, /cron_list/);
|
|
243
|
+
assert.match(prompt, /cron_delete/);
|
|
221
244
|
assert.match(prompt, /do NOT push and do NOT open a pull request/);
|
|
222
245
|
});
|
|
223
|
-
test("NOT-
|
|
246
|
+
test("NOT-278: a non-Muse developer prompt has the deck bootstrap but no cron prohibition", () => {
|
|
247
|
+
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt" });
|
|
248
|
+
assert.match(prompt, /bind_workspace/);
|
|
249
|
+
assert.doesNotMatch(prompt, /cron_create/);
|
|
250
|
+
});
|
|
251
|
+
test("a deckless developer prompt still fails closed", () => {
|
|
224
252
|
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: null });
|
|
225
253
|
assert.match(prompt, /misconfigured: Agent Deck is required but missing/);
|
|
226
254
|
});
|
|
@@ -72,6 +72,7 @@ export function routeDeveloperOutcome(outcome, limits) {
|
|
|
72
72
|
next: "human_action",
|
|
73
73
|
actionType: "policy_escalation",
|
|
74
74
|
reason: `${outcome.reason} Recovery:\n${outcome.recoveryCommands.join("\n")}`,
|
|
75
|
+
...(outcome.fingerprint ? { blockerFingerprint: outcome.fingerprint } : {}),
|
|
75
76
|
};
|
|
76
77
|
case "muse_cron_used":
|
|
77
78
|
// Spends no budget and never retries: a policy breach the operator must look at, like a
|
|
@@ -99,6 +99,26 @@ test("worktree conflict always escalates immediately — never retried, regardle
|
|
|
99
99
|
assert.match(reason, /old-session-developer/);
|
|
100
100
|
assert.match(reason, /git status/);
|
|
101
101
|
});
|
|
102
|
+
test("NOT-280: worktree_conflict carries its blocker fingerprint through routing and projection to the action effect", async () => {
|
|
103
|
+
const { projectDeveloperRoute } = await import("./projection.js");
|
|
104
|
+
const outcome = {
|
|
105
|
+
kind: "worktree_conflict",
|
|
106
|
+
path: "/data/worktrees/old-session-developer",
|
|
107
|
+
reason: "Automatic salvage failed: git commit failed: hook rejected",
|
|
108
|
+
recoveryCommands: ["git status"],
|
|
109
|
+
fingerprint: "fp-1",
|
|
110
|
+
};
|
|
111
|
+
const result = routeDeveloperOutcome(outcome, REVIEW_ROUNDS_LEFT);
|
|
112
|
+
assert.equal(result.next, "human_action");
|
|
113
|
+
assert.equal(result.blockerFingerprint, "fp-1");
|
|
114
|
+
const { effect, advance } = projectDeveloperRoute(result, "developing", 1);
|
|
115
|
+
assert.equal(advance, "none", "a worktree blocker never spends any budget");
|
|
116
|
+
assert.equal(effect.kind, "human_action");
|
|
117
|
+
assert.equal(effect.blockerFingerprint, "fp-1");
|
|
118
|
+
const { fingerprint: _omit, ...legacy } = outcome;
|
|
119
|
+
const plain = routeDeveloperOutcome(legacy, REVIEW_ROUNDS_LEFT);
|
|
120
|
+
assert.equal("blockerFingerprint" in plain, false);
|
|
121
|
+
});
|
|
102
122
|
test("NOT-127: live_owner retries on the infra budget — never escalates as worktree_conflict", () => {
|
|
103
123
|
const outcome = {
|
|
104
124
|
kind: "live_owner",
|
|
@@ -17,6 +17,7 @@ import { resolveClaudeBin, resolveCodexBin, resolveCursorBin, resolveMuseBin } f
|
|
|
17
17
|
import { spawnCli } from "../runners/spawn-cli.js";
|
|
18
18
|
import { buildDeveloperArgs, buildReviewerArgs } from "./args.js";
|
|
19
19
|
import { assertReviewerReadOnly } from "./permissions.js";
|
|
20
|
+
import { assertWorktreeCwd } from "./worktree-cwd-guard.js";
|
|
20
21
|
import { extractResultTranscript } from "./usage.js";
|
|
21
22
|
import { runMuseDeveloperSession } from "./muse-spawn.js";
|
|
22
23
|
export function developerSessionLogPath(sessionId) {
|
|
@@ -32,8 +33,12 @@ const BIN_FOR = {
|
|
|
32
33
|
muse_code: resolveMuseBin,
|
|
33
34
|
};
|
|
34
35
|
export const realDeveloperSpawn = async (input) => {
|
|
36
|
+
// NOT-273: never spawn a worker outside a managed worktree — throws before
|
|
37
|
+
// any child process exists (the effect reports it as "could not start").
|
|
38
|
+
assertWorktreeCwd(input.cwd);
|
|
35
39
|
if (input.runtime === "muse_code") {
|
|
36
|
-
//
|
|
40
|
+
// NOT-278: isolated deck-configured exec attempt (prepareMuseAttempt); the shared serve
|
|
41
|
+
// lane cannot carry per-session deck/workspace identity, so it is never used here.
|
|
37
42
|
return runMuseDeveloperSession({
|
|
38
43
|
...input,
|
|
39
44
|
maxModelSteps: input.maxModelSteps ?? undefined,
|
|
@@ -51,6 +56,9 @@ export const realDeveloperSpawn = async (input) => {
|
|
|
51
56
|
* loosens a reviewer's tools can never silently reach a live spawn.
|
|
52
57
|
*/
|
|
53
58
|
export const realReviewerSpawn = async (input) => {
|
|
59
|
+
// NOT-273: same worktree-cwd guard as the developer path — throws before
|
|
60
|
+
// any child process exists.
|
|
61
|
+
assertWorktreeCwd(input.cwd);
|
|
54
62
|
const args = buildReviewerArgs(input.runtime, input.prompt, input.model ?? undefined, input.policy, input.mcpConfigPath, input.effort);
|
|
55
63
|
assertReviewerReadOnly(args, { mcpConfigPath: input.mcpConfigPath, mcpEnv: input.mcpEnv });
|
|
56
64
|
const logPath = input.logPath ?? reviewerSessionLogPath(input.sessionId);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// packages/server/src/coordinator/worktree-cwd-guard.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-273: defensive guard on the developer/reviewer spawn path. The worktree
|
|
4
|
+
// resolution (`resolveDeveloperWorktree` / `roleWorktreePathForResolution`) is
|
|
5
|
+
// correct today, but nothing asserts the resolved cwd before a child process
|
|
6
|
+
// is spawned — a regression there would run agent CLIs against the wrong
|
|
7
|
+
// directory (in the worst case the primary repo checkout itself, where a
|
|
8
|
+
// worker's `git add . && git commit` lands real commits on the checkout).
|
|
9
|
+
// Every real developer/reviewer spawn calls `assertWorktreeCwd` first and
|
|
10
|
+
// throws instead of spawning when the cwd is not a recognized
|
|
11
|
+
// coordinator-managed worktree path.
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
/** Basename shape the worktree manager itself uses: `<sessionId>-<role>`. */
|
|
14
|
+
const ROLE_SUFFIX_RE = /-(developer|reviewer)$/;
|
|
15
|
+
/**
|
|
16
|
+
* Parent directory names under which coordinator-managed role worktrees live:
|
|
17
|
+
* - legacy layout: `<repo>/.agent-dealer-worktrees/<sessionId>-<role>`
|
|
18
|
+
* - managed layout (NOT-149): `<executionRoot>/worktrees/<identity>/<sessionId>-<role>`
|
|
19
|
+
* - dev-mode equivalent: `<dataDir>/worktrees/...`
|
|
20
|
+
*/
|
|
21
|
+
const WORKTREE_PARENT_NAMES = new Set([".agent-dealer-worktrees", "worktrees"]);
|
|
22
|
+
/**
|
|
23
|
+
* True when `cwd` looks like a coordinator-managed role worktree path: its
|
|
24
|
+
* basename carries the `-developer`/`-reviewer` role suffix AND one of its
|
|
25
|
+
* parent directories is a recognized worktrees root. In particular the
|
|
26
|
+
* primary repo root itself never matches (no role suffix, no worktrees
|
|
27
|
+
* parent), nor does the ambient `process.cwd()` of a dev checkout.
|
|
28
|
+
*/
|
|
29
|
+
export function isManagedWorktreeCwd(cwd) {
|
|
30
|
+
if (typeof cwd !== "string" || cwd.length === 0)
|
|
31
|
+
return false;
|
|
32
|
+
const normalized = path.normalize(cwd);
|
|
33
|
+
if (!ROLE_SUFFIX_RE.test(path.basename(normalized)))
|
|
34
|
+
return false;
|
|
35
|
+
const segments = normalized.split(path.sep).filter(Boolean);
|
|
36
|
+
return segments.slice(0, -1).some((segment) => WORKTREE_PARENT_NAMES.has(segment));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Throw instead of spawning when `cwd` is not a recognized managed-worktree
|
|
40
|
+
* path. Called at the top of `realDeveloperSpawn`/`realReviewerSpawn`, before
|
|
41
|
+
* any child process exists — the effects turn this pre-spawn throw into a
|
|
42
|
+
* `session_failed` "could not start" outcome, never a run against the repo.
|
|
43
|
+
*/
|
|
44
|
+
export function assertWorktreeCwd(cwd) {
|
|
45
|
+
if (!isManagedWorktreeCwd(cwd)) {
|
|
46
|
+
throw new Error(`Refusing to spawn a worker outside a managed worktree (NOT-273): ${cwd}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// packages/server/src/coordinator/worktree-cwd-guard.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-273: the developer/reviewer spawn path refuses to spawn outside a
|
|
4
|
+
// recognized coordinator-managed worktree — in particular the primary repo
|
|
5
|
+
// root itself — before any child process exists.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
const { assertWorktreeCwd, isManagedWorktreeCwd } = await import("./worktree-cwd-guard.js");
|
|
12
|
+
const { realDeveloperSpawn, realReviewerSpawn } = await import("./spawn.js");
|
|
13
|
+
const SESSION_ID = "00000000-0000-4000-8000-000000000001";
|
|
14
|
+
test("real worktree-shaped paths pass the guard", () => {
|
|
15
|
+
// Legacy layout: <repo>/.agent-dealer-worktrees/<sessionId>-<role>.
|
|
16
|
+
assert.equal(isManagedWorktreeCwd(`/repo/.agent-dealer-worktrees/${SESSION_ID}-developer`), true);
|
|
17
|
+
assert.equal(isManagedWorktreeCwd(`/repo/.agent-dealer-worktrees/${SESSION_ID}-reviewer`), true);
|
|
18
|
+
// Managed layout (NOT-149): <executionRoot>/worktrees/<identity>/<sessionId>-<role>.
|
|
19
|
+
assert.equal(isManagedWorktreeCwd(`/data/execution/worktrees/github.com/acme/app/${SESSION_ID}-developer`), true);
|
|
20
|
+
assert.equal(isManagedWorktreeCwd(`/data/execution/worktrees/github.com/acme/app/${SESSION_ID}-reviewer`), true);
|
|
21
|
+
// Dev-mode equivalent under a dev data dir.
|
|
22
|
+
assert.equal(isManagedWorktreeCwd(path.join(os.homedir(), ".agent-dealer-dev", "worktrees", "github.com", "acme", "app", `${SESSION_ID}-developer`)), true);
|
|
23
|
+
assert.doesNotThrow(() => assertWorktreeCwd(`/repo/.agent-dealer-worktrees/${SESSION_ID}-developer`));
|
|
24
|
+
});
|
|
25
|
+
test("the primary repo root and other non-worktree paths are rejected", () => {
|
|
26
|
+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-guard-repo-"));
|
|
27
|
+
// A plain checkout dir (what the ambient process cwd is in a normal dev
|
|
28
|
+
// checkout). Deliberately synthetic, never `process.cwd()` itself: the
|
|
29
|
+
// runner's own cwd is environment-dependent and may itself be
|
|
30
|
+
// worktree-shaped (e.g. a `<uuid>-developer` dir under a `worktrees`
|
|
31
|
+
// parent), which the guard correctly accepts per the naming convention.
|
|
32
|
+
const checkoutDir = path.join(repoRoot, "checkout");
|
|
33
|
+
fs.mkdirSync(checkoutDir);
|
|
34
|
+
try {
|
|
35
|
+
assert.equal(isManagedWorktreeCwd(repoRoot), false, "primary repo root itself");
|
|
36
|
+
assert.equal(isManagedWorktreeCwd(checkoutDir), false, "ambient-checkout-shaped cwd");
|
|
37
|
+
assert.equal(isManagedWorktreeCwd(os.tmpdir()), false, "bare temp dir");
|
|
38
|
+
assert.equal(isManagedWorktreeCwd(path.join(os.tmpdir(), `${SESSION_ID}-developer`)), false, "role suffix without a worktrees parent");
|
|
39
|
+
assert.equal(isManagedWorktreeCwd("/repo/.agent-dealer-worktrees"), false, "worktrees root without a session dir");
|
|
40
|
+
assert.equal(isManagedWorktreeCwd(""), false, "empty cwd");
|
|
41
|
+
assert.throws(() => assertWorktreeCwd(repoRoot), /managed worktree/);
|
|
42
|
+
assert.throws(() => assertWorktreeCwd(checkoutDir), /managed worktree/);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
fs.rmSync(repoRoot, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
test("realDeveloperSpawn refuses the primary repo root before any process is spawned", async () => {
|
|
49
|
+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-guard-dev-"));
|
|
50
|
+
try {
|
|
51
|
+
await assert.rejects(realDeveloperSpawn({
|
|
52
|
+
sessionId: SESSION_ID,
|
|
53
|
+
runtime: "claude_code",
|
|
54
|
+
policy: { worktreeWrite: true },
|
|
55
|
+
model: null,
|
|
56
|
+
prompt: "Implement it",
|
|
57
|
+
cwd: repoRoot,
|
|
58
|
+
timeoutMs: 1000,
|
|
59
|
+
}), /managed worktree/, "repo-root cwd throws instead of spawning");
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
fs.rmSync(repoRoot, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
test("realReviewerSpawn refuses the primary repo root before any process is spawned", async () => {
|
|
66
|
+
// Synthetic repo-root stand-in, never `process.cwd()`: the runner's own
|
|
67
|
+
// cwd is environment-dependent and may itself be worktree-shaped.
|
|
68
|
+
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-guard-rev-"));
|
|
69
|
+
try {
|
|
70
|
+
await assert.rejects(realReviewerSpawn({
|
|
71
|
+
sessionId: SESSION_ID,
|
|
72
|
+
runtime: "claude_code",
|
|
73
|
+
policy: { worktreeWrite: false },
|
|
74
|
+
model: null,
|
|
75
|
+
prompt: "Review it",
|
|
76
|
+
cwd: repoRoot,
|
|
77
|
+
timeoutMs: 1000,
|
|
78
|
+
}), /managed worktree/, "repo-root cwd throws instead of spawning");
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
fs.rmSync(repoRoot, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
});
|