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.
Files changed (57) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +3 -4
  4. package/bundle/server/dist/adapters/agent-health.test.js +11 -9
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/muse-capability.js +51 -25
  8. package/bundle/server/dist/adapters/muse-capability.test.js +138 -52
  9. package/bundle/server/dist/capacity/claude-local-cache.js +59 -27
  10. package/bundle/server/dist/capacity/claude-local-cache.test.js +163 -9
  11. package/bundle/server/dist/capacity/muse-host.js +34 -32
  12. package/bundle/server/dist/capacity/muse-host.test.js +5 -3
  13. package/bundle/server/dist/coordinator/args.js +4 -3
  14. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  15. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  16. package/bundle/server/dist/coordinator/commands.js +66 -5
  17. package/bundle/server/dist/coordinator/developer-effect.js +88 -10
  18. package/bundle/server/dist/coordinator/developer-effect.test.js +170 -0
  19. package/bundle/server/dist/coordinator/human-resolution.js +2 -0
  20. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  21. package/bundle/server/dist/coordinator/muse-spawn.js +72 -174
  22. package/bundle/server/dist/coordinator/projection.js +1 -0
  23. package/bundle/server/dist/coordinator/prompts.js +10 -6
  24. package/bundle/server/dist/coordinator/prompts.test.js +16 -8
  25. package/bundle/server/dist/coordinator/routing.js +1 -0
  26. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  27. package/bundle/server/dist/coordinator/spawn.js +2 -1
  28. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  29. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  30. package/bundle/server/dist/repository/human-actions.js +13 -0
  31. package/bundle/server/dist/routes/index.js +11 -8
  32. package/bundle/server/dist/routes/version.js +30 -0
  33. package/bundle/server/dist/routes/version.test.js +21 -0
  34. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  35. package/bundle/server/dist/runners/muse-config.js +1 -1
  36. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  37. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  38. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  39. package/bundle/server/package.json +2 -2
  40. package/bundle/server/static-ui/assets/{index-Bq8wWpZm.js → index-CoDzZcsz.js} +1 -1
  41. package/bundle/server/static-ui/index.html +1 -1
  42. package/bundle/shared/package.json +1 -1
  43. package/dist/doctor.js +4 -1
  44. package/dist/managed/index.d.ts +1 -1
  45. package/dist/managed/index.js +1 -1
  46. package/dist/managed/updater.d.ts +16 -6
  47. package/dist/managed/updater.js +28 -11
  48. package/dist/managed-update-restart.test.d.ts +1 -0
  49. package/dist/managed-update-restart.test.js +227 -0
  50. package/dist/ports.d.ts +6 -0
  51. package/dist/ports.js +10 -0
  52. package/dist/runtime-state.d.ts +10 -0
  53. package/dist/runtime-state.js +26 -0
  54. package/dist/start.js +67 -2
  55. package/dist/status.js +30 -2
  56. package/dist/update-check.js +3 -6
  57. package/package.json +1 -1
@@ -1,46 +1,29 @@
1
1
  // packages/server/src/coordinator/muse-spawn.ts
2
2
  //
3
- // NOT-181: the native Muse Code developer spawn. Wires the already-tested pieces — binary
4
- // resolution (cli-env.ts), argv (runners/muse-code-args.ts), JSONL/session-log parsing
5
- // (runners/muse-code-jsonl.ts) — into the existing developer-effect contract without changing
6
- // either side:
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
- // 1. a per-attempt config + data dir under the worktree (`.dealer-muse/<attempt>/`, kept out of
9
- // git via info/exclude): `settings.json` with no MCP servers, a symlinked `auth.json`;
10
- // 2. `spawnCli` with `XDG_CONFIG_HOME`/`XDG_DATA_HOME` pointed at it;
11
- // 3. the on-disk session log (usage + confirmed model exist only there) read while the dir still
12
- // exists, then the dir is removed on success *and* failure;
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 { museVersionRequiredForWork } from "../adapters/muse-capability.js";
26
- import { ensureWorktreeExcluded } from "../adapters/worktree-exclude.js";
27
- import { buildMuseDeveloperInvocation } from "../runners/muse-code-args.js";
28
- import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
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
- function prepareAttemptDirs(worktreePath) {
86
- ensureWorktreeExcluded(worktreePath, `/${MUSE_ATTEMPT_ROOT}/`);
87
- const root = path.join(worktreePath, MUSE_ATTEMPT_ROOT, randomUUID());
88
- const configHome = path.join(root, "config");
89
- const dataHome = path.join(root, "data");
90
- fs.mkdirSync(path.join(configHome, "muse"), { recursive: true, mode: 0o700 });
91
- fs.mkdirSync(dataHome, { recursive: true, mode: 0o700 });
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.rmdirSync(path.join(worktreePath, MUSE_ATTEMPT_ROOT)); // only when no sibling attempt remains
76
+ if (fs.existsSync(authFile))
77
+ return { kind: "auth-file", path: authFile };
107
78
  }
108
79
  catch {
109
- // not empty or already gone
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. Serve-lane turns observed the host,
128
- * so this hook is the final read that populates 5H/1W; after an exec-lane
129
- * fallback the host stays unobserved by construction (the NOT-269
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-270: run the real developer turn through the server-owned serve host.
148
- * Returns null when no turn was admitted (host unavailable, session/start
149
- * or turn/start rejected) — the caller then runs the legacy `muse exec`
150
- * lane. Never returns null after admission: an admitted turn is real work
151
- * and its verdict is reported honestly, never retried on the other lane.
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
- * The serve lane needs no per-attempt XDG config/data dirs: the host runs
154
- * under its own server-owned home (`prepareMuseServeHome` in
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 model = input.model ?? MUSE_CODE_CONTRIBUTOR_MODEL;
235
- const museSessionId = randomUUID();
236
- // Serve-first: real turns through the owned host observe it (NOT-269
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 invocation = buildMuseDeveloperInvocation({
258
- model,
259
- maxModelSteps: input.maxModelSteps ?? DEFAULT_MUSE_MAX_MODEL_STEPS,
260
- sessionId: museSessionId,
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
- spawned = await spawnCli(input.sessionId, resolveMuseBin(), invocation.args, input.cwd, {
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: { ...invocation.env, XDG_CONFIG_HOME: dirs.configHome, XDG_DATA_HOME: dirs.dataHome },
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(dirs.dataHome, museSessionId);
173
+ sessionLog = readSessionLog(attempt.env.XDG_DATA_HOME, input.sessionId);
276
174
  }
277
175
  finally {
278
- removeAttemptDirs(input.cwd, dirs.root);
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
- function noAgentDeckSection() {
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, noAgentDeck) {
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, input.noAgentDeck));
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-181: a Muse Code worker has no Agent Deck, so it must not be told the session is
234
- // misconfigured (the deckless default) nor to bind a deck; it is told never to touch cron_*.
235
- test("NOT-181: noAgentDeck prompt states there is no deck/MCP and forbids cron_* instead of the misconfigured stop", () => {
236
- const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, noAgentDeck: true, deckId: null, worktreePath: "/wt" });
237
- assert.match(prompt, /no Agent Deck and no MCP servers/);
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.doesNotMatch(prompt, /misconfigured/);
240
- assert.doesNotMatch(prompt, /bind_workspace/);
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-181: without noAgentDeck a deckless prompt still fails closed", () => {
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
- // Own argv, per-attempt XDG dirs and result parsing; deck/MCP are never wired for Muse.
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
- return { state: "dead" };
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
- assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), { state: "dead" });
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 hour, only
81
- // when every valid 5H/1W observation is older than 60 minutes, and only
82
- // when enabled (default on; `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=off`
83
- // disables it). `/usage` is a local slash-command — no model call, $0
84
- // cost, ~300ms, verified live 2026-09-27 (the original design spawned a
85
- // real model turn and was fixed after a live proof showed it never
86
- // worked; see docs/RUNTIME_CAPACITY.md). Failures never break the read
87
- // below.
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
+ });