agent-dealer 1.2.5 → 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 +3 -4
- package/bundle/server/dist/adapters/agent-health.test.js +11 -9
- 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/muse-capability.js +51 -25
- package/bundle/server/dist/adapters/muse-capability.test.js +138 -52
- package/bundle/server/dist/capacity/claude-local-cache.js +59 -27
- package/bundle/server/dist/capacity/claude-local-cache.test.js +163 -9
- package/bundle/server/dist/capacity/muse-host.js +34 -32
- package/bundle/server/dist/capacity/muse-host.test.js +5 -3
- 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 +66 -5
- package/bundle/server/dist/coordinator/developer-effect.js +88 -10
- package/bundle/server/dist/coordinator/developer-effect.test.js +170 -0
- package/bundle/server/dist/coordinator/human-resolution.js +2 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -174
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +10 -6
- package/bundle/server/dist/coordinator/prompts.test.js +16 -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 +2 -1
- 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/index.js +11 -8
- 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-Bq8wWpZm.js → index-CoDzZcsz.js} +1 -1
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.js +4 -1
- 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
|
@@ -1,46 +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 { normalizeMuseRun, parseMuseRun, } from "../runners/muse-code-jsonl.js";
|
|
30
|
-
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";
|
|
31
26
|
import { spawnCli } from "../runners/spawn-cli.js";
|
|
32
|
-
/**
|
|
33
|
-
* NOT-270: execution lane selection. Default is the server-owned serve host
|
|
34
|
-
* (real turns through it are the NOT-269 precondition for 5H/1W capacity).
|
|
35
|
-
* `AGENT_DEALER_MUSE_RUNNER=exec` forces the legacy `muse exec` subprocess
|
|
36
|
-
* lane — the operator escape hatch if the serve lane ever misbehaves in
|
|
37
|
-
* production (capacity then reads honest N/A until serve runs again).
|
|
38
|
-
*/
|
|
39
|
-
function museServeLaneEnabled() {
|
|
40
|
-
return process.env.AGENT_DEALER_MUSE_RUNNER !== "exec";
|
|
41
|
-
}
|
|
42
|
-
/** Worktree-relative home of every per-attempt Muse config/data dir. */
|
|
43
|
-
export const MUSE_ATTEMPT_ROOT = ".dealer-muse";
|
|
44
27
|
/**
|
|
45
28
|
* Step cap when the profile sets no `maxTurns`. High on purpose (the PoC's value): the developer
|
|
46
29
|
* wall-clock timeout is the real limit, the cap only stops a runaway loop.
|
|
@@ -82,32 +65,24 @@ function readSessionLog(dataDir, museSessionId) {
|
|
|
82
65
|
};
|
|
83
66
|
return visit(root, 0);
|
|
84
67
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// mkdir's mode is masked by the umask; the dirs hold a login symlink and the session log.
|
|
93
|
-
fs.chmodSync(root, 0o700);
|
|
94
|
-
fs.chmodSync(configHome, 0o700);
|
|
95
|
-
fs.chmodSync(dataHome, 0o700);
|
|
96
|
-
fs.writeFileSync(path.join(configHome, "muse", "settings.json"), JSON.stringify(buildMuseDeveloperSettings(), null, 2) + "\n", { mode: 0o600 });
|
|
97
|
-
// Linked, never read or copied. Absent when the operator authenticates with META_API_KEY.
|
|
98
|
-
const auth = resolveMuseAuthFile();
|
|
99
|
-
if (fs.existsSync(auth))
|
|
100
|
-
fs.symlinkSync(auth, path.join(configHome, "muse", "auth.json"));
|
|
101
|
-
return { root, configHome, dataHome };
|
|
102
|
-
}
|
|
103
|
-
function removeAttemptDirs(worktreePath, root) {
|
|
104
|
-
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();
|
|
105
75
|
try {
|
|
106
|
-
fs.
|
|
76
|
+
if (fs.existsSync(authFile))
|
|
77
|
+
return { kind: "auth-file", path: authFile };
|
|
107
78
|
}
|
|
108
79
|
catch {
|
|
109
|
-
//
|
|
80
|
+
// An unreadable auth path is the same as a missing one: fall through to the API key.
|
|
110
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");
|
|
111
86
|
}
|
|
112
87
|
function splitSpawnLog(raw) {
|
|
113
88
|
const idx = raw.indexOf(STDERR_MARKER);
|
|
@@ -124,11 +99,9 @@ function splitSpawnLog(raw) {
|
|
|
124
99
|
* safe point is contractual, not opportunistic — a recent Agents-page poll
|
|
125
100
|
* must never skip it. This creates no model turn of its own (the capacity
|
|
126
101
|
* client only ever sends the read-only handshake/`usage/read` allowlist)
|
|
127
|
-
* and never affects the session result.
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* structural precondition) and the read stays honest N/A with last-good
|
|
131
|
-
* 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.
|
|
132
105
|
*/
|
|
133
106
|
function refreshMuseCapacityAfterSession() {
|
|
134
107
|
void (async () => {
|
|
@@ -144,138 +117,63 @@ function refreshMuseCapacityAfterSession() {
|
|
|
144
117
|
})();
|
|
145
118
|
}
|
|
146
119
|
/**
|
|
147
|
-
* NOT-
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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.
|
|
152
132
|
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* capacity/muse-host.ts — same worker settings as the exec lane's
|
|
156
|
-
* per-attempt settings.json plus a symlink to the ambient login, so runner
|
|
157
|
-
* and capacity can never diverge on identity or posture). `onSpawn` is
|
|
158
|
-
* deliberately not called: there is no per-attempt pid to persist, and
|
|
159
|
-
* 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.
|
|
160
135
|
*/
|
|
161
|
-
async function runMuseServeLane(input, model, onAdmitted) {
|
|
162
|
-
const { getMuseCapacityHost } = await import("../capacity/muse-host.js");
|
|
163
|
-
const host = getMuseCapacityHost();
|
|
164
|
-
// NOT-277: admission gated on the capability check of the on-disk binary; a long-lived host can
|
|
165
|
-
// still run an older (possibly broken) build. Serve only on a host known to run the confirmed
|
|
166
|
-
// version (restarted onto it when idle); otherwise the work runs on the checked exec lane.
|
|
167
|
-
const required = museVersionRequiredForWork();
|
|
168
|
-
if (required === null)
|
|
169
|
-
return null;
|
|
170
|
-
if (required !== undefined && !(await host.ensureStartedOnVersion(required)))
|
|
171
|
-
return null;
|
|
172
|
-
// Only this call may fall back: it resolves `admitted:false` (or throws)
|
|
173
|
-
// strictly before any model work starts. Everything below runs after
|
|
174
|
-
// admission — the caller's no-double-execution rule (via onAdmitted)
|
|
175
|
-
// keeps those errors loud instead of falling back to exec.
|
|
176
|
-
const turn = await runMuseServeTurn({
|
|
177
|
-
host,
|
|
178
|
-
prompt: input.prompt,
|
|
179
|
-
model,
|
|
180
|
-
cwd: input.cwd,
|
|
181
|
-
timeoutMs: input.timeoutMs,
|
|
182
|
-
signal: input.signal,
|
|
183
|
-
onAdmitted: () => onAdmitted(),
|
|
184
|
-
});
|
|
185
|
-
if (!turn.admitted)
|
|
186
|
-
return null;
|
|
187
|
-
const cronCalls = turn.tools
|
|
188
|
-
.map((t) => t.name)
|
|
189
|
-
.filter((n) => n !== null && CRON_TOOL_RE.test(n));
|
|
190
|
-
const summary = {
|
|
191
|
-
museSessionId: turn.sessionId ?? randomUUID(),
|
|
192
|
-
confirmedModel: turn.confirmedModel,
|
|
193
|
-
usage: turn.usage,
|
|
194
|
-
failure: turn.failure,
|
|
195
|
-
cronCalls,
|
|
196
|
-
runCount: 1,
|
|
197
|
-
rawLogPath: null,
|
|
198
|
-
};
|
|
199
|
-
// Same normalized evidence both lanes write: the raw folded history, then
|
|
200
|
-
// the Claude/Cursor-shaped stream every existing log reader consumes.
|
|
201
|
-
const rawLogPath = `${input.logPath.replace(/\.ndjson$/, "")}.muse-raw.jsonl`;
|
|
202
|
-
try {
|
|
203
|
-
fs.writeFileSync(rawLogPath, `${turn.viewItems.map((i) => JSON.stringify(i)).join("\n")}\n`);
|
|
204
|
-
summary.rawLogPath = rawLogPath;
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
// evidence copy is best-effort; the normalized log below still carries the outcome
|
|
208
|
-
}
|
|
209
|
-
const events = normalizeMuseRun({
|
|
210
|
-
sessionId: summary.museSessionId,
|
|
211
|
-
confirmedModel: turn.confirmedModel,
|
|
212
|
-
tools: turn.tools,
|
|
213
|
-
finalText: turn.finalText,
|
|
214
|
-
usage: turn.usage,
|
|
215
|
-
failure: turn.failure,
|
|
216
|
-
}).map((e) => e.type === "result" ? { ...e, muse: { ...summary, usage: turn.usage } } : e);
|
|
217
|
-
fs.writeFileSync(input.logPath, `${events.map((e) => JSON.stringify(e)).join("\n")}\n`);
|
|
218
|
-
// The real session above is the observation opportunity: kick the
|
|
219
|
-
// best-effort capacity refresh (no synthetic turn, never throws, never
|
|
220
|
-
// blocks this result).
|
|
221
|
-
refreshMuseCapacityAfterSession();
|
|
222
|
-
// A failed session must not read as success. 124 mirrors spawn-cli's
|
|
223
|
-
// timeout exit so timeout handling downstream sees the same signal.
|
|
224
|
-
const exitCode = turn.timedOut ? 124 : turn.failure ? 1 : turn.terminal === "completed" ? 0 : 1;
|
|
225
|
-
return {
|
|
226
|
-
exitCode,
|
|
227
|
-
transcript: turn.finalText ?? "",
|
|
228
|
-
logPath: input.logPath,
|
|
229
|
-
timedOut: turn.timedOut,
|
|
230
|
-
muse: summary,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
136
|
export async function runMuseDeveloperSession(input) {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// precondition for 5H/1W). Pre-admission failure falls back to exec; an
|
|
238
|
-
// admitted turn never falls back — its errors propagate loudly instead of
|
|
239
|
-
// executing the work a second time on the exec lane.
|
|
240
|
-
// `AGENT_DEALER_MUSE_RUNNER=exec` skips the serve lane entirely
|
|
241
|
-
// (operator escape hatch).
|
|
242
|
-
if (museServeLaneEnabled() && !input.execLaneOnly) {
|
|
243
|
-
let admitted = false;
|
|
244
|
-
try {
|
|
245
|
-
const served = await runMuseServeLane(input, model, () => {
|
|
246
|
-
admitted = true;
|
|
247
|
-
});
|
|
248
|
-
if (served)
|
|
249
|
-
return served;
|
|
250
|
-
}
|
|
251
|
-
catch (err) {
|
|
252
|
-
if (admitted)
|
|
253
|
-
throw err;
|
|
254
|
-
// Pre-admission serve-lane error: fall through to the exec lane below.
|
|
255
|
-
}
|
|
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");
|
|
256
140
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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,
|
|
261
154
|
prompt: input.prompt,
|
|
155
|
+
maxModelSteps: input.maxModelSteps ?? DEFAULT_MUSE_MAX_MODEL_STEPS,
|
|
262
156
|
});
|
|
263
157
|
const { logPath } = input;
|
|
264
|
-
const dirs = prepareAttemptDirs(input.cwd);
|
|
265
158
|
let spawned;
|
|
266
159
|
let sessionLog;
|
|
267
160
|
try {
|
|
268
|
-
|
|
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, {
|
|
269
165
|
logPath,
|
|
270
166
|
timeoutMs: input.timeoutMs,
|
|
271
|
-
env: { ...
|
|
167
|
+
env: { ...attempt.env },
|
|
168
|
+
exactEnv: true,
|
|
169
|
+
stdin: attempt.stdin,
|
|
272
170
|
signal: input.signal,
|
|
273
171
|
onSpawn: input.onSpawn,
|
|
274
172
|
});
|
|
275
|
-
sessionLog = readSessionLog(
|
|
173
|
+
sessionLog = readSessionLog(attempt.env.XDG_DATA_HOME, input.sessionId);
|
|
276
174
|
}
|
|
277
175
|
finally {
|
|
278
|
-
|
|
176
|
+
attempt.cleanup();
|
|
279
177
|
}
|
|
280
178
|
const rawLog = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : spawned.transcript;
|
|
281
179
|
const { stdout, stderr } = splitSpawnLog(rawLog);
|
|
@@ -288,7 +186,7 @@ export async function runMuseDeveloperSession(input) {
|
|
|
288
186
|
});
|
|
289
187
|
const cronCalls = run.tools.map((t) => t.name).filter((n) => n !== null && CRON_TOOL_RE.test(n));
|
|
290
188
|
const summary = {
|
|
291
|
-
museSessionId,
|
|
189
|
+
museSessionId: input.sessionId,
|
|
292
190
|
confirmedModel: run.confirmedModel,
|
|
293
191
|
usage: run.usage,
|
|
294
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
|
};
|
|
@@ -36,16 +36,18 @@ function scopeDecisionSection(note) {
|
|
|
36
36
|
*
|
|
37
37
|
* Workers never start without a deckId (fail-closed at admission/effect).
|
|
38
38
|
*/
|
|
39
|
-
|
|
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() {
|
|
40
45
|
return [
|
|
41
|
-
`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.`,
|
|
42
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.`,
|
|
43
47
|
``,
|
|
44
48
|
];
|
|
45
49
|
}
|
|
46
|
-
function agentDeckSection(worktreePath, deckId
|
|
47
|
-
if (noAgentDeck)
|
|
48
|
-
return noAgentDeckSection();
|
|
50
|
+
function agentDeckSection(worktreePath, deckId) {
|
|
49
51
|
if (!deckId || !worktreePath) {
|
|
50
52
|
return [
|
|
51
53
|
`This session is misconfigured: Agent Deck is required but missing. Stop and report the bootstrap failure — do not improvise without the deck.`,
|
|
@@ -88,7 +90,9 @@ export function buildDeveloperPrompt(input) {
|
|
|
88
90
|
parts.push(``);
|
|
89
91
|
}
|
|
90
92
|
parts.push(...guidanceSection(input.guidance));
|
|
91
|
-
parts.push(...agentDeckSection(input.worktreePath, input.deckId
|
|
93
|
+
parts.push(...agentDeckSection(input.worktreePath, input.deckId));
|
|
94
|
+
if (input.museDeveloper)
|
|
95
|
+
parts.push(...museCronProhibitionSection());
|
|
92
96
|
parts.push(`## Required`,
|
|
93
97
|
// NOT-115: encourage incremental commits so a mid-session death leaves less
|
|
94
98
|
// uncommitted work for dirty_worktree escalation — soft mitigation only.
|
|
@@ -230,17 +230,25 @@ test("reviewer prompt deck section requires bind_workspace first, matching the d
|
|
|
230
230
|
assert.match(prompt, /do not web-fetch Linear/);
|
|
231
231
|
assert.doesNotMatch(prompt, /get_playbook\(/);
|
|
232
232
|
});
|
|
233
|
-
// NOT-
|
|
234
|
-
//
|
|
235
|
-
test("NOT-
|
|
236
|
-
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1,
|
|
237
|
-
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/);
|
|
238
241
|
assert.match(prompt, /cron_create/);
|
|
239
|
-
assert.
|
|
240
|
-
assert.
|
|
242
|
+
assert.match(prompt, /cron_list/);
|
|
243
|
+
assert.match(prompt, /cron_delete/);
|
|
241
244
|
assert.match(prompt, /do NOT push and do NOT open a pull request/);
|
|
242
245
|
});
|
|
243
|
-
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", () => {
|
|
244
252
|
const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: null });
|
|
245
253
|
assert.match(prompt, /misconfigured: Agent Deck is required but missing/);
|
|
246
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",
|
|
@@ -37,7 +37,8 @@ export const realDeveloperSpawn = async (input) => {
|
|
|
37
37
|
// any child process exists (the effect reports it as "could not start").
|
|
38
38
|
assertWorktreeCwd(input.cwd);
|
|
39
39
|
if (input.runtime === "muse_code") {
|
|
40
|
-
//
|
|
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.
|
|
41
42
|
return runMuseDeveloperSession({
|
|
42
43
|
...input,
|
|
43
44
|
maxModelSteps: input.maxModelSteps ?? undefined,
|
|
@@ -70,6 +70,10 @@ function sessionLooksLive(session) {
|
|
|
70
70
|
*
|
|
71
71
|
* Returns alive if **any** candidate session for this path is live — basename predecessor
|
|
72
72
|
* and every `worktree_path` match (see {@link listWorkerSessionsOwningWorktree}).
|
|
73
|
+
*
|
|
74
|
+
* NOT-280: a dead verdict names the most recently created candidate — the predecessor
|
|
75
|
+
* whose dirt a resume salvage is attributed to. No candidate at all stays a bare dead
|
|
76
|
+
* verdict (unknown owner), which keeps dirt on the fail-closed conflict path.
|
|
73
77
|
*/
|
|
74
78
|
export function checkDeveloperWorktreeOwnerLiveness(worktreePath) {
|
|
75
79
|
const candidates = listWorkerSessionsOwningWorktree(worktreePath);
|
|
@@ -78,5 +82,6 @@ export function checkDeveloperWorktreeOwnerLiveness(worktreePath) {
|
|
|
78
82
|
return { state: "alive", sessionId: session.id };
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
|
-
|
|
85
|
+
const latest = [...candidates].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
|
|
86
|
+
return latest ? { state: "dead", sessionId: latest.id } : { state: "dead" };
|
|
82
87
|
}
|
|
@@ -137,7 +137,8 @@ test("checkDeveloperWorktreeOwnerLiveness: terminal session with a dead pid is d
|
|
|
137
137
|
patchRunningSession(session.id, { worktreePath: wt });
|
|
138
138
|
recordSessionProcess(session.id, pid, COORDINATOR_PROCESS_OWNER, startTime);
|
|
139
139
|
completeSession(session.id, { status: "failed", errorJson: JSON.stringify({ reason: "gone" }) });
|
|
140
|
-
|
|
140
|
+
// NOT-280: a proven-dead owner is named so a resume salvage can be tied to it.
|
|
141
|
+
assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), { state: "dead", sessionId: session.id });
|
|
141
142
|
});
|
|
142
143
|
test("checkDeveloperWorktreeOwnerLiveness: unknown path with no session is dead", () => {
|
|
143
144
|
assert.deepEqual(checkDeveloperWorktreeOwnerLiveness("/repo/.agent-dealer-worktrees/no-such-session-developer"), { state: "dead" });
|
|
@@ -144,6 +144,19 @@ export function updateOpenHumanAction(id, patch) {
|
|
|
144
144
|
.run(patch.reason ?? null, patch.question ?? null, patch.evidence !== undefined ? JSON.stringify(patch.evidence) : null, id);
|
|
145
145
|
return getHumanAction(id);
|
|
146
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* NOT-280: put a resolved action back to open, unchanged — used when a Resume ran into the
|
|
149
|
+
* exact same blocker again, so the operator keeps one action instead of an identical copy.
|
|
150
|
+
* Only touches resolved rows; returns null otherwise.
|
|
151
|
+
*/
|
|
152
|
+
export function reopenHumanAction(id) {
|
|
153
|
+
const info = getDb()
|
|
154
|
+
.prepare(`UPDATE human_actions SET
|
|
155
|
+
status = 'open', resolution_json = NULL, resolved_by = NULL, resolved_at = NULL
|
|
156
|
+
WHERE id = ? AND status = 'resolved'`)
|
|
157
|
+
.run(id);
|
|
158
|
+
return info.changes === 0 ? null : getHumanAction(id);
|
|
159
|
+
}
|
|
147
160
|
/** Run-scoped equivalent of `findOpenHumanAction` (NOT-95 — outbound-draft delivery
|
|
148
161
|
* parking has no Issue). */
|
|
149
162
|
export function findOpenHumanActionForRun(runId, actionType) {
|
|
@@ -12,6 +12,7 @@ import { refreshMuseCapacityIfStale } from "../capacity/muse-probe.js";
|
|
|
12
12
|
import { refreshCodexCapacityIfStale } from "../capacity/codex-app-server.js";
|
|
13
13
|
import { getCursorTeamBillingSnapshot, refreshCursorTeamBillingIfStale, } from "../capacity/cursor-team.js";
|
|
14
14
|
import { getCursorIndividualBillingSnapshot, refreshCursorIndividualBillingIfStale, refreshCursorIndividualCapacityIfStale, } from "../capacity/cursor-individual.js";
|
|
15
|
+
import { registerVersionRoute } from "./version.js";
|
|
15
16
|
async function resolveDeckName(deckId) {
|
|
16
17
|
if (!deckId)
|
|
17
18
|
return null;
|
|
@@ -22,6 +23,7 @@ async function resolveDeckName(deckId) {
|
|
|
22
23
|
}
|
|
23
24
|
export async function registerRoutes(app) {
|
|
24
25
|
app.get("/health", async () => ({ ok: true }));
|
|
26
|
+
registerVersionRoute(app);
|
|
25
27
|
// NOT-159: process-lifetime Linear GraphQL counters (API-key bucket diagnostics).
|
|
26
28
|
app.get("/api/debug/linear-usage", async () => getLinearUsageSnapshot());
|
|
27
29
|
app.get("/api/runtimes/:runtime/models", async (req, reply) => {
|
|
@@ -77,14 +79,15 @@ export async function registerRoutes(app) {
|
|
|
77
79
|
// NOT-268: Claude local-first ladder — one background refresh ingests
|
|
78
80
|
// Claude Code's own free cache (plain file read, never a spawn) and then
|
|
79
81
|
// considers the free `/usage` refresh without blocking the read: a
|
|
80
|
-
// bounded `claude -p "/usage"` call fires at most once per
|
|
81
|
-
// when
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
82
|
+
// bounded `claude -p "/usage"` call fires at most once per 14 minutes,
|
|
83
|
+
// only when either valid 5H/1W observation is missing or at least 14
|
|
84
|
+
// minutes old (NOT-281 — checked per window so a fresh sibling never
|
|
85
|
+
// suppresses its stale twin), and only when enabled (default on;
|
|
86
|
+
// `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=off` disables it). `/usage` is a
|
|
87
|
+
// local slash-command — no model call, $0 cost, ~300ms, verified live
|
|
88
|
+
// 2026-09-27 (the original design spawned a real model turn and was
|
|
89
|
+
// fixed after a live proof showed it never worked; see
|
|
90
|
+
// docs/RUNTIME_CAPACITY.md). Failures never break the read below.
|
|
88
91
|
app.get("/api/runtime-capacity", async () => {
|
|
89
92
|
try {
|
|
90
93
|
void refreshClaudeCapacityIfStale().catch(() => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// packages/server/src/routes/version.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-279: the running backend reports its own version. The CLI's installed version (and
|
|
4
|
+
// the managed `current` symlink) can move ahead of a server that is still serving the old
|
|
5
|
+
// build, so `agent-dealer status` must ask the live process instead of inferring from disk.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
/** Read once at module load: the package this process was started from, not whatever is on
|
|
10
|
+
* disk later (a managed update may replace sibling install dirs while we keep running). */
|
|
11
|
+
function readServerPackageVersion() {
|
|
12
|
+
try {
|
|
13
|
+
// src/routes/version.ts and dist/routes/version.js both sit two levels below package.json.
|
|
14
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
15
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
16
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const RUNNING_VERSION = readServerPackageVersion();
|
|
23
|
+
const STARTED_AT = new Date().toISOString();
|
|
24
|
+
export function registerVersionRoute(app) {
|
|
25
|
+
app.get("/api/version", async () => ({
|
|
26
|
+
version: RUNNING_VERSION,
|
|
27
|
+
pid: process.pid,
|
|
28
|
+
startedAt: STARTED_AT,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// packages/server/src/routes/version.test.ts
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import Fastify from "fastify";
|
|
8
|
+
import { registerVersionRoute } from "./version.js";
|
|
9
|
+
test("NOT-279: /api/version reports the running server package version and pid", async () => {
|
|
10
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
11
|
+
const expected = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
|
|
12
|
+
const app = Fastify();
|
|
13
|
+
registerVersionRoute(app);
|
|
14
|
+
const res = await app.inject({ method: "GET", url: "/api/version" });
|
|
15
|
+
assert.equal(res.statusCode, 200);
|
|
16
|
+
const body = res.json();
|
|
17
|
+
assert.equal(body.version, expected);
|
|
18
|
+
assert.equal(body.pid, process.pid);
|
|
19
|
+
assert.ok(!Number.isNaN(Date.parse(body.startedAt)));
|
|
20
|
+
await app.close();
|
|
21
|
+
});
|