agent-dealer 1.2.3 → 1.2.5

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 (43) hide show
  1. package/bundle/server/dist/adapters/agent-health.js +25 -2
  2. package/bundle/server/dist/adapters/agent-health.test.js +55 -2
  3. package/bundle/server/dist/adapters/github.js +110 -12
  4. package/bundle/server/dist/adapters/github.test.js +274 -3
  5. package/bundle/server/dist/adapters/muse-capability.js +330 -0
  6. package/bundle/server/dist/adapters/muse-capability.test.js +378 -0
  7. package/bundle/server/dist/capacity/claude-local-cache.js +222 -63
  8. package/bundle/server/dist/capacity/claude-local-cache.test.js +179 -30
  9. package/bundle/server/dist/capacity/muse-host.js +111 -23
  10. package/bundle/server/dist/capacity/muse-host.test.js +125 -1
  11. package/bundle/server/dist/capacity/muse-lifecycle.test.js +2 -3
  12. package/bundle/server/dist/capacity/muse-probe.js +250 -0
  13. package/bundle/server/dist/capacity/muse-probe.test.js +183 -0
  14. package/bundle/server/dist/capacity/muse.js +16 -6
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/commands.js +35 -8
  17. package/bundle/server/dist/coordinator/developer-effect.js +40 -6
  18. package/bundle/server/dist/coordinator/developer-effect.test.js +121 -12
  19. package/bundle/server/dist/coordinator/human-resolution.js +31 -1
  20. package/bundle/server/dist/coordinator/muse-spawn.js +12 -2
  21. package/bundle/server/dist/coordinator/prompts.js +15 -0
  22. package/bundle/server/dist/coordinator/prompts.test.js +20 -0
  23. package/bundle/server/dist/coordinator/spawn.js +7 -0
  24. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  25. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  26. package/bundle/server/dist/routes/human-actions.js +10 -1
  27. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  28. package/bundle/server/dist/routes/index.js +17 -14
  29. package/bundle/server/dist/routes/runtime-capacity.test.js +11 -3
  30. package/bundle/server/dist/runners/muse-serve-session.js +7 -6
  31. package/bundle/server/package.json +2 -2
  32. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  33. package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +60 -0
  34. package/bundle/server/static-ui/index.html +2 -2
  35. package/bundle/shared/dist/agents.d.ts +15 -15
  36. package/bundle/shared/dist/agents.js +6 -0
  37. package/bundle/shared/dist/index.d.ts +7 -7
  38. package/bundle/shared/package.json +1 -1
  39. package/dist/doctor.d.ts +22 -3
  40. package/dist/doctor.js +46 -12
  41. package/dist/doctor.test.js +41 -10
  42. package/package.json +1 -1
  43. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
@@ -401,6 +401,91 @@ test("NOT-178: a Muse Code developer without credentials is refused at admission
401
401
  setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
402
402
  }
403
403
  });
404
+ // NOT-277: a Muse Code auto-update is re-validated automatically. A version whose developer
405
+ // shell/write check passes is admitted with no manual step; one that lost it is refused with the
406
+ // versions and capability named; one whose check could not complete is refused, never assumed ok.
407
+ test("NOT-277: a Muse Code update is admitted when capable, refused by name when not", async () => {
408
+ const { clearAgentHealthCaches, runtimeIssuesUncached } = await import("../adapters/agent-health.js");
409
+ const { setMuseCapabilityProbeForTests, settleMuseCapabilityCheckForTests } = await import("../adapters/muse-capability.js");
410
+ const OLD = "1.3.0-R3401.1";
411
+ const NEW = "1.4.0-R4161.1";
412
+ const BROKEN = "1.4.0-R4302.1";
413
+ const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-stub-"));
414
+ const versionFile = path.join(stubDir, "version");
415
+ const stub = path.join(stubDir, "muse");
416
+ fs.writeFileSync(stub, `#!/bin/sh\ncat ${JSON.stringify(versionFile)}\n`);
417
+ fs.chmodSync(stub, 0o755);
418
+ // The real checker's async path runs `gh auth status` too; CI runners have no gh login,
419
+ // so a logged-in stub keeps the `ok: true` assertions about Muse alone.
420
+ const ghStub = path.join(stubDir, "gh");
421
+ fs.writeFileSync(ghStub, "#!/bin/sh\necho 'Logged in to github.com account dealer-test'\nexit 0\n");
422
+ fs.chmodSync(ghStub, 0o755);
423
+ const probed = [];
424
+ setMuseCapabilityProbeForTests(async (version) => {
425
+ probed.push(version);
426
+ if (version === BROKEN)
427
+ return { status: "missing", detail: "probe session completed without running its shell command" };
428
+ if (version === "9.9.9-R1")
429
+ throw new Error("probe session timed out");
430
+ return { status: "capable" };
431
+ });
432
+ const saved = {
433
+ MUSE_CLI: process.env.MUSE_CLI,
434
+ META_API_KEY: process.env.META_API_KEY,
435
+ SKIP: process.env.AGENT_DEALER_SKIP_AGENT_HEALTH,
436
+ PATH: process.env.PATH,
437
+ };
438
+ process.env.MUSE_CLI = stub;
439
+ process.env.PATH = `${stubDir}${path.delimiter}${process.env.PATH ?? ""}`;
440
+ process.env.META_API_KEY = "k";
441
+ delete process.env.AGENT_DEALER_SKIP_AGENT_HEALTH;
442
+ setAdmissionHealthCheckerForTests(null);
443
+ clearAgentHealthCaches();
444
+ /** Muse reports `version`; the next health read sees it and its one-time check settles. */
445
+ const museReports = async (version) => {
446
+ fs.writeFileSync(versionFile, `Muse Code ${version.split("-")[0]} (${version})\n`);
447
+ await runtimeIssuesUncached("muse_code");
448
+ await settleMuseCapabilityCheckForTests();
449
+ };
450
+ try {
451
+ const issue = readyIssue("muse-updated", { runtimes: { dev: "muse_code", rev: "codex_local" } });
452
+ const developerHealth = () => checkRoleAgentHealthy(issue, "developer", { deckOnline: true });
453
+ await museReports(OLD);
454
+ assert.deepEqual(await developerHealth(), { ok: true });
455
+ await museReports(NEW);
456
+ assert.deepEqual(await developerHealth(), { ok: true });
457
+ assert.deepEqual(probed, [OLD, NEW]);
458
+ await museReports(BROKEN);
459
+ enqueueIssue(issue.id);
460
+ assert.equal(await admitNext(), null);
461
+ assert.equal(getIssue(issue.id).status, "ready");
462
+ assert.equal(getActiveWorkflowInstance(issue.id), null);
463
+ const reason = getQueuedEntryForIssue(issue.id).waitReason;
464
+ assert.match(reason, /developer unhealthy/);
465
+ assert.match(reason, new RegExp(`Muse Code updated ${NEW} → ${BROKEN}: developer sessions no longer get shell/write access`));
466
+ await museReports("9.9.9-R1");
467
+ const unverified = await developerHealth();
468
+ assert.equal(unverified.ok, false);
469
+ assert.match(unverified.reason, new RegExp(`Could not verify Muse Code developer shell/write access after version change \\(${BROKEN} → 9\\.9\\.9-R1\\)`));
470
+ assert.deepEqual(probed, [OLD, NEW, BROKEN, "9.9.9-R1"]);
471
+ }
472
+ finally {
473
+ for (const [key, value] of Object.entries({
474
+ MUSE_CLI: saved.MUSE_CLI,
475
+ META_API_KEY: saved.META_API_KEY,
476
+ AGENT_DEALER_SKIP_AGENT_HEALTH: saved.SKIP,
477
+ PATH: saved.PATH,
478
+ })) {
479
+ if (value === undefined)
480
+ delete process.env[key];
481
+ else
482
+ process.env[key] = value;
483
+ }
484
+ setMuseCapabilityProbeForTests(null);
485
+ clearAgentHealthCaches();
486
+ setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
487
+ }
488
+ });
404
489
  // NOT-178: Muse Code is developer-only, so it is never a healthy reviewer — even with the
405
490
  // unit-test health skip on, and without probing the CLI.
406
491
  test("NOT-178: a Muse Code reviewer is refused regardless of CLI health", async () => {
@@ -28,7 +28,7 @@ import { reasonForWorkerFailedEvent } from "./failure-reason.js";
28
28
  import { recordCausesForWorkerFailedEvent } from "./failure-cause.js";
29
29
  import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
30
30
  import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
31
- import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
31
+ import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
32
32
  import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
33
33
  import { externalMergeStateForIssue } from "./external-merge.js";
34
34
  import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
@@ -197,7 +197,7 @@ export function activeWorkflowConflictMessage(issueId) {
197
197
  * the gate stays on startWorkflow; clearing a stale gate after AC lands lives here so
198
198
  * admitNext cannot leave it dangling.
199
199
  */
200
- export function startWorkflowCore(issueId) {
200
+ export function startWorkflowCore(issueId, opts) {
201
201
  const issue = getIssue(issueId);
202
202
  if (!issue)
203
203
  throw new StartPreconditionError(404, "Issue not found");
@@ -233,7 +233,12 @@ export function startWorkflowCore(issueId) {
233
233
  workflowInstanceId: instance.id,
234
234
  kind: "developer",
235
235
  round: 1,
236
- payload: { profileSnapshot: queuedProfileSnapshot(issue, "developer") },
236
+ payload: {
237
+ profileSnapshot: queuedProfileSnapshot(issue, "developer"),
238
+ // NOT-272: carry the scope decision only on this first round's payload — later
239
+ // rounds never see it (the prompt reads it from here, not from the DB).
240
+ ...(opts?.scopeDecisionNote ? { scopeDecisionNote: opts.scopeDecisionNote } : {}),
241
+ },
237
242
  idempotencyKey: `${instance.id}:developer:1`,
238
243
  });
239
244
  // NOT-103: every successful start is a force-admit — keep queue state in sync whether
@@ -982,10 +987,23 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
982
987
  return { ok: false, code: 404, error: "Human action not found" };
983
988
  if (action.status !== "open")
984
989
  return { ok: false, code: 409, error: "Human action already resolved" };
985
- const resolution = parseHumanResolution(action.actionType, choice);
990
+ // NOT-272: same shape validation as the HTTP route (which already applied it) —
991
+ // defense in depth for direct/CLI callers of this sync entry point.
992
+ const normalizedNote = normalizeResolutionNote(opts?.note);
993
+ if (!normalizedNote.ok)
994
+ return { ok: false, code: 400, error: normalizedNote.error };
995
+ const resolution = parseHumanResolution(action.actionType, choice, normalizedNote.note);
986
996
  if (!resolution) {
987
997
  return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
988
998
  }
999
+ // NOT-272: the typed resolution carries the note only for product_scope_decision
1000
+ // (parseHumanResolution drops it for every other action type).
1001
+ const scopeDecisionNote = resolution.actionType === "product_scope_decision" && "note" in resolution
1002
+ ? resolution.note
1003
+ : undefined;
1004
+ const storedResolution = scopeDecisionNote
1005
+ ? { choice, note: scopeDecisionNote }
1006
+ : { choice };
989
1007
  // NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
990
1008
  // retry_merge/repair/close only (resume would re-run development on approved work);
991
1009
  // every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
@@ -1077,7 +1095,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1077
1095
  }
1078
1096
  try {
1079
1097
  return getDb().transaction(() => {
1080
- resolveHumanAction(actionId, resolvedBy, { choice });
1098
+ resolveHumanAction(actionId, resolvedBy, storedResolution);
1081
1099
  appendWorkflowEvent({
1082
1100
  issueId: issue.id,
1083
1101
  type: "human_action.resolved",
@@ -1086,7 +1104,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1086
1104
  stage: issue.status,
1087
1105
  payload: { actionType: action.actionType, choice },
1088
1106
  });
1089
- const { workItem } = startWorkflowCore(issue.id);
1107
+ // NOT-272: this branch is always product_scope_decision (legacy terminal
1108
+ // actions returned above) — the note rides the round-1 payload so the very
1109
+ // next developer round reads it in its prompt.
1110
+ const { workItem } = startWorkflowCore(issue.id, { scopeDecisionNote: scopeDecisionNote });
1090
1111
  return {
1091
1112
  ok: true,
1092
1113
  issueStatus: getIssue(issue.id).status,
@@ -1165,7 +1186,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1165
1186
  : {};
1166
1187
  return getDb().transaction(() => {
1167
1188
  resolveHumanAction(actionId, resolvedBy, {
1168
- choice,
1189
+ ...storedResolution,
1169
1190
  // NOT-226: record the re-pin so the resolved action itself shows which head the
1170
1191
  // reviewer was re-queued at when the branch had moved while parked.
1171
1192
  ...(rePinnedResumeHeadSha
@@ -1285,7 +1306,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1285
1306
  workflowInstanceId: instance.id,
1286
1307
  kind: "developer",
1287
1308
  round: issueNow.currentRound,
1288
- payload: { profileSnapshot: queuedProfileSnapshot(issue, "developer") },
1309
+ payload: {
1310
+ profileSnapshot: queuedProfileSnapshot(issue, "developer"),
1311
+ // NOT-272: a product_scope_decision note rides only this next round's
1312
+ // payload — later rounds never see it.
1313
+ ...(scopeDecisionNote ? { scopeDecisionNote } : {}),
1314
+ },
1289
1315
  idempotencyKey: `${instance.id}:developer:resume:${action.id}`,
1290
1316
  });
1291
1317
  return {
@@ -1347,6 +1373,7 @@ export async function resolveHumanActionAndAdvanceAsync(actionId, resolvedBy, ch
1347
1373
  const result = resolveHumanActionAndAdvance(actionId, resolvedBy, choice, {
1348
1374
  resumeLiveHeadSha,
1349
1375
  externalMergeState,
1376
+ note: opts?.note,
1350
1377
  });
1351
1378
  if (!result.ok || !result.pendingMerge)
1352
1379
  return result;
@@ -782,6 +782,11 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
782
782
  const openFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
783
783
  const guidance = guidanceForNextSession(issue.id, sessionId);
784
784
  const retryReason = payload.retryReason ?? undefined;
785
+ // NOT-272: the scope note rides only the work item queued by the resolution —
786
+ // exactly this round reads it; nothing is re-read from the DB.
787
+ const scopeDecisionNote = typeof payload.scopeDecisionNote === "string" && payload.scopeDecisionNote.trim()
788
+ ? payload.scopeDecisionNote.trim()
789
+ : undefined;
785
790
  let priorConclusion;
786
791
  let priorVerificationReceipt;
787
792
  if (retryReason) {
@@ -816,6 +821,7 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
816
821
  deckId: isMuse ? null : (snapshot?.deckId ?? null),
817
822
  noAgentDeck: isMuse,
818
823
  guidance: guidance.length ? guidance : undefined,
824
+ scopeDecisionNote,
819
825
  });
820
826
  // NOT-83 review finding: the session is marked `running` (worker-loop.ts) before this
821
827
  // handler ever runs, so an abort landing during worktree setup/deck-bind above already
@@ -1286,13 +1292,41 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1286
1292
  author: "system",
1287
1293
  blobPath: spawned.logPath,
1288
1294
  });
1295
+ // NOT-275: a clean exit (exitCode 0, not timed out, no usage cap) that leaves
1296
+ // the worktree dirty hit the same escalation as a crash before this — the
1297
+ // session did real ticket-scoped work but never ran `git commit`. Salvage
1298
+ // first (same mechanism as the crash/timeout path above): on success fall
1299
+ // through to push/PR as if the agent had committed itself; only when the
1300
+ // salvage commit itself fails do we escalate dirty_worktree with recovery.
1289
1301
  if (!(await isWorktreeClean(worktreePath))) {
1290
- return {
1291
- kind: "dirty_worktree",
1292
- reason: reasonForDirtyWorktree(spawned.logPath, runtime),
1293
- path: worktreePath,
1294
- recoveryCommands: dirtyWorktreeRecoveryCommands(repoPath, worktreePath),
1295
- };
1302
+ const salvaged = await salvageDirtyWorktree(worktreePath, "crash");
1303
+ if (!salvaged.ok) {
1304
+ return {
1305
+ kind: "dirty_worktree",
1306
+ reason: `${reasonForDirtyWorktree(spawned.logPath, runtime)} Auto-commit salvage failed: ${salvaged.reason}`,
1307
+ path: worktreePath,
1308
+ recoveryCommands: dirtyWorktreeRecoveryCommands(repoPath, worktreePath),
1309
+ };
1310
+ }
1311
+ // NOT-172: durable salvage-commit checkpoint at the existing success point.
1312
+ try {
1313
+ emitCheckpointObserved({
1314
+ issueId: issue.id,
1315
+ workflowInstanceId: instance.id,
1316
+ workerSessionId: sessionId,
1317
+ role: "developer",
1318
+ stage,
1319
+ round,
1320
+ kind: "commit",
1321
+ observedSha: salvaged.commitSha,
1322
+ origin: "salvage",
1323
+ inputSha: samplerInputSha,
1324
+ branch: branchName,
1325
+ });
1326
+ }
1327
+ catch {
1328
+ // checkpoint evidence must never fail the attempt itself
1329
+ }
1296
1330
  }
1297
1331
  const ahead = await commitsAhead({ worktreePath, baseRef: `origin/${baseBranch}` });
1298
1332
  if (ahead === 0) {
@@ -109,11 +109,6 @@ const commitingSpawn = async (input) => {
109
109
  };
110
110
  /** Never touches the worktree — nothing to push, the no_pr outcome. */
111
111
  const noopSpawn = async () => ({ exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false });
112
- /** Leaves an untracked file uncommitted — the dirty_worktree outcome. */
113
- const dirtySpawn = async (input) => {
114
- fs.writeFileSync(path.join(input.cwd, "scratch.txt"), "oops\n");
115
- return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
116
- };
117
112
  const crashingSpawn = async () => ({ exitCode: 1, transcript: "boom", logPath: "/dev/null", timedOut: false });
118
113
  const timedOutSpawn = async () => ({ exitCode: 1, transcript: "", logPath: "/dev/null", timedOut: true });
119
114
  /**
@@ -253,9 +248,95 @@ test("the branch created on a retried round is reused, not re-created — no 'br
253
248
  assert.match(prompts[1], /\*\*Last failure:\*\* Developer session produced no PR\./);
254
249
  assert.doesNotMatch(prompts[1], /fresh branch/);
255
250
  });
256
- test("dirty_worktree: an uncommitted file escalates without consuming a round", async () => {
251
+ test("dirty_worktree: an uncommitted file whose salvage commit fails escalates without consuming a round", async () => {
252
+ // NOT-275: a clean exit (exitCode 0) that leaves the worktree dirty is salvaged
253
+ // first — escalation only happens when the salvage commit itself fails. Force
254
+ // that failure with a rejecting pre-commit hook (same technique as the NOT-145
255
+ // salvage-failure test).
256
+ const issueId = await makeIssue();
257
+ const salvageBlockedSpawn = async (input) => {
258
+ fs.writeFileSync(path.join(input.cwd, "scratch.txt"), "oops\n");
259
+ const common = git(input.cwd, "rev-parse", "--git-common-dir");
260
+ const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(input.cwd, common, "hooks");
261
+ fs.mkdirSync(hooks, { recursive: true });
262
+ const hookPath = path.join(hooks, "pre-commit");
263
+ fs.writeFileSync(hookPath, "#!/bin/sh\necho salvage-blocked >&2\nexit 1\n");
264
+ fs.chmodSync(hookPath, 0o755);
265
+ return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
266
+ };
267
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: salvageBlockedSpawn, github: fakeGithub() }));
268
+ startWorkflow(issueId);
269
+ await pump(1);
270
+ const issue = getIssue(issueId);
271
+ assert.equal(issue.status, "needs_human");
272
+ assert.equal(issue.currentRound, 1, "dirty worktree never consumes a round");
273
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation");
274
+ assert.ok(action, "expected a policy_escalation human action");
275
+ assert.match(action.reason, /Auto-commit salvage failed|salvage-blocked/i);
276
+ // NOT-145 / NOT-137: dirty escalations carry path + recovery like worktree_conflict.
277
+ assert.match(action.reason, /Recovery:/);
278
+ assert.match(action.reason, /git status/);
279
+ // Cleanup hook so later tests in this file aren't poisoned (worktrees share
280
+ // .git/hooks via the common dir).
281
+ const round1 = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
282
+ const leftover = roleWorktreePath(repo, round1.id, "developer");
283
+ assert.ok(fs.existsSync(path.join(leftover, "scratch.txt")), "failed salvage must not wipe WIP");
284
+ const common = git(repo, "rev-parse", "--git-common-dir");
285
+ const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(repo, common, "hooks");
286
+ fs.rmSync(path.join(hooks, "pre-commit"), { force: true });
287
+ fs.rmSync(leftover, { recursive: true, force: true });
288
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
289
+ });
290
+ test("NOT-275: a clean exit that leaves tracked + untracked changes salvages a checkpoint and proceeds to push/PR", async () => {
291
+ // The session exits 0 (not timed out, no usage cap) but never ran `git commit`.
292
+ // The effect must auto-commit the WIP as a salvage checkpoint on the issue
293
+ // branch and continue exactly as if the agent had committed the work itself —
294
+ // it must NOT return kind: "dirty_worktree".
295
+ const issueId = await makeIssue();
296
+ const cleanExitDirtySpawn = async (input) => {
297
+ fs.appendFileSync(path.join(input.cwd, "README.md"), "more docs\n");
298
+ fs.writeFileSync(path.join(input.cwd, "feature.txt"), "implemented\n");
299
+ return { exitCode: 0, transcript: "Implementation conclusion: added the widget.", logPath: "/dev/null", timedOut: false };
300
+ };
301
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: cleanExitDirtySpawn, github: fakeGithub() }));
302
+ startWorkflow(issueId);
303
+ await pump(1);
304
+ const issue = getIssue(issueId);
305
+ assert.equal(issue.status, "reviewing", "salvaged clean-exit work proceeds to push/PR like a self-committed session");
306
+ assert.equal(issue.currentRound, 1);
307
+ assert.ok(issue.headSha, "push happened — head SHA recorded");
308
+ assert.ok(issue.prNumber, "draft PR opened");
309
+ assert.equal(git(repo, "ls-remote", "origin", `refs/heads/${issue.branch}`).length > 0, true);
310
+ const branch = issueBranchName(issueId);
311
+ assert.equal(git(repo, "log", "-1", "--pretty=%s", branch), "wip: crash salvage");
312
+ assert.ok(git(repo, "show", `${branch}:feature.txt`).includes("implemented"), "untracked WIP is in the salvage tip");
313
+ assert.ok(git(repo, "show", `${branch}:README.md`).includes("more docs"), "tracked WIP is in the salvage tip");
314
+ const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
315
+ assert.equal(dev.status, "done");
316
+ assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"), "no dirty_worktree escalation");
317
+ // Deliverable 1: the salvage path emits the commit checkpoint evidence event.
318
+ const salvageCommits = listWorkflowEventsForIssue(issueId)
319
+ .filter((e) => e.type === "checkpoint.observed")
320
+ .map((e) => JSON.parse(e.payloadJson))
321
+ .filter((p) => p.kind === "commit" && p.origin === "salvage");
322
+ assert.equal(salvageCommits.length, 1, "salvage emits exactly one salvage-origin commit checkpoint");
323
+ assert.equal(salvageCommits[0].observedSha, issue.headSha, "the checkpoint vouches for the salvaged tip");
324
+ });
325
+ test("NOT-275: a clean exit with dirty worktree still returns dirty_worktree when the salvage commit itself fails", async () => {
257
326
  const issueId = await makeIssue();
258
- registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: dirtySpawn, github: fakeGithub() }));
327
+ const failingSalvageSpawn = async (input) => {
328
+ fs.appendFileSync(path.join(input.cwd, "README.md"), "more docs\n");
329
+ fs.writeFileSync(path.join(input.cwd, "feature.txt"), "implemented\n");
330
+ // Reject the coordinator's salvage commit so we exercise the escalate-and-preserve path.
331
+ const common = git(input.cwd, "rev-parse", "--git-common-dir");
332
+ const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(input.cwd, common, "hooks");
333
+ fs.mkdirSync(hooks, { recursive: true });
334
+ const hookPath = path.join(hooks, "pre-commit");
335
+ fs.writeFileSync(hookPath, "#!/bin/sh\necho salvage-blocked >&2\nexit 1\n");
336
+ fs.chmodSync(hookPath, 0o755);
337
+ return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
338
+ };
339
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: failingSalvageSpawn, github: fakeGithub() }));
259
340
  startWorkflow(issueId);
260
341
  await pump(1);
261
342
  const issue = getIssue(issueId);
@@ -263,9 +344,18 @@ test("dirty_worktree: an uncommitted file escalates without consuming a round",
263
344
  assert.equal(issue.currentRound, 1, "dirty worktree never consumes a round");
264
345
  const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation");
265
346
  assert.ok(action, "expected a policy_escalation human action");
266
- // NOT-145 / NOT-137: exit-0 dirty escalations carry path + recovery like worktree_conflict.
347
+ assert.match(action.reason, /Auto-commit salvage failed|salvage-blocked/i);
267
348
  assert.match(action.reason, /Recovery:/);
268
349
  assert.match(action.reason, /git status/);
350
+ const round1 = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
351
+ const leftover = roleWorktreePath(repo, round1.id, "developer");
352
+ assert.ok(fs.existsSync(path.join(leftover, "feature.txt")), "failed salvage must not wipe WIP");
353
+ // Cleanup hook so later tests in this file aren't poisoned.
354
+ const common = git(repo, "rev-parse", "--git-common-dir");
355
+ const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(repo, common, "hooks");
356
+ fs.rmSync(path.join(hooks, "pre-commit"), { force: true });
357
+ fs.rmSync(leftover, { recursive: true, force: true });
358
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
269
359
  });
270
360
  test("session_failed: the agent process exits non-zero — retried like no_pr", async () => {
271
361
  const issueId = await makeIssue();
@@ -924,11 +1014,22 @@ test("NOT-88: a leftover clean worktree from a resolved unpushed_commit escalati
924
1014
  });
925
1015
  test("NOT-88: a leftover dirty worktree escalates as an actionable worktree_conflict on every Resume — no infra budget burned, no opaque crash loop", async () => {
926
1016
  const issueId = await makeIssue();
927
- // Round 1 exits 0 but leaves the worktree dirty — dirty_worktree escalation preserves the
928
- // checkout (NOT-145 salvage applies only to timeout/crash infra deaths). That leftover is
1017
+ // Round 1 exits 0 but leaves the worktree dirty with salvage blocked (NOT-275
1018
+ // salvages clean-exit dirt first, so a rejecting pre-commit hook forces the
1019
+ // dirty_worktree escalation that preserves the checkout). That leftover is
929
1020
  // the setup NOT-88's collision needs: round 2 must find that SAME dirty leftover still
930
1021
  // holding the branch.
931
- registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: dirtySpawn, github: fakeGithub() }));
1022
+ const salvageBlockedDirtySpawn = async (input) => {
1023
+ fs.writeFileSync(path.join(input.cwd, "scratch.txt"), "oops\n");
1024
+ const common = git(input.cwd, "rev-parse", "--git-common-dir");
1025
+ const hooks = path.isAbsolute(common) ? path.join(common, "hooks") : path.join(input.cwd, common, "hooks");
1026
+ fs.mkdirSync(hooks, { recursive: true });
1027
+ const hookPath = path.join(hooks, "pre-commit");
1028
+ fs.writeFileSync(hookPath, "#!/bin/sh\necho salvage-blocked >&2\nexit 1\n");
1029
+ fs.chmodSync(hookPath, 0o755);
1030
+ return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
1031
+ };
1032
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: salvageBlockedDirtySpawn, github: fakeGithub() }));
932
1033
  startWorkflow(issueId);
933
1034
  await pump(1);
934
1035
  const round1Session = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
@@ -950,6 +1051,11 @@ test("NOT-88: a leftover dirty worktree escalates as an actionable worktree_conf
950
1051
  assert.ok(secondAction, "a fresh, actionable escalation — not silence and not a crash");
951
1052
  assert.match(secondAction.reason, /uncommitted changes/);
952
1053
  assert.match(secondAction.reason, new RegExp(leftoverPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
1054
+ // Cleanup the salvage-blocking hook too (worktrees share .git/hooks via the
1055
+ // common dir — leaving it would reject commits in every later test).
1056
+ const commonDir = git(repo, "rev-parse", "--git-common-dir");
1057
+ const hooksDir = path.isAbsolute(commonDir) ? path.join(commonDir, "hooks") : path.join(repo, commonDir, "hooks");
1058
+ fs.rmSync(path.join(hooksDir, "pre-commit"), { force: true });
953
1059
  fs.rmSync(leftoverPath, { recursive: true, force: true });
954
1060
  await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
955
1061
  });
@@ -1650,7 +1756,10 @@ test("NOT-130: a green suite on a dirty worktree is not persisted as tip evidenc
1650
1756
  startWorkflow(issueId);
1651
1757
  await pump(1);
1652
1758
  assert.equal(listArtifactsForIssue(issueId).some((a) => a.kind === "verification_receipt"), false, "dirty tree must not mint a tip-scoped verification receipt");
1653
- assert.equal(getIssue(issueId).status, "needs_human");
1759
+ // NOT-275: the post-suite dirt is salvaged and the handoff proceeds (the receipt
1760
+ // check above stays the point of this test — the suite ran on a dirty tree, so
1761
+ // no tip-scoped receipt may exist even after the salvaged push/PR).
1762
+ assert.equal(getIssue(issueId).status, "reviewing");
1654
1763
  });
1655
1764
  test("NOT-130: checks_failed retry does not carry a prior green receipt into the prompt", async () => {
1656
1765
  const issueId = await makeIssue();
@@ -22,6 +22,31 @@ export const PUSH_DIVERGENCE_RESPONSE_OPTIONS = [
22
22
  { choice: "resume", label: "Resume development" },
23
23
  { choice: "close", label: "Close" },
24
24
  ];
25
+ /**
26
+ * NOT-272: cap for the optional human note on a `product_scope_decision` resolution.
27
+ * Introduced here (no route-level text cap convention exists to mirror) — long enough
28
+ * for a real scope decision, short enough to fit verbatim in the next round's prompt.
29
+ */
30
+ export const PRODUCT_SCOPE_NOTE_MAX_CHARS = 4000;
31
+ /**
32
+ * NOT-272: normalizes the raw `note` body field of POST /api/human-actions/:id/resolve.
33
+ * Absent/blank is "no note" (today's behavior); a present non-string or an over-long
34
+ * string is a caller error. Scoping to `product_scope_decision` happens in
35
+ * `parseHumanResolution` / the resolve path — this only validates the shape.
36
+ */
37
+ export function normalizeResolutionNote(note) {
38
+ if (note === undefined || note === null)
39
+ return { ok: true };
40
+ if (typeof note !== "string")
41
+ return { ok: false, error: "note must be a string" };
42
+ const trimmed = note.trim();
43
+ if (!trimmed)
44
+ return { ok: true };
45
+ if (trimmed.length > PRODUCT_SCOPE_NOTE_MAX_CHARS) {
46
+ return { ok: false, error: `note must be at most ${PRODUCT_SCOPE_NOTE_MAX_CHARS} characters` };
47
+ }
48
+ return { ok: true, note: trimmed };
49
+ }
25
50
  /**
26
51
  * The only choices resolveHumanActionOutcome accepts per action type — the stored "response
27
52
  * options." `reflection_interaction_required` and `outbound_delivery_interaction_required`
@@ -58,7 +83,7 @@ const VALID_CHOICES = {
58
83
  * Run-scoped, has no Issue/workflow_instance to advance, and its only legal resolver is
59
84
  * `resolveOutboundDeliveryAction` (queue/approve-deliver.ts).
60
85
  */
61
- export function parseHumanResolution(actionType, choice) {
86
+ export function parseHumanResolution(actionType, choice, note) {
62
87
  if (actionType === "reflection_interaction_required" || actionType === "outbound_delivery_interaction_required") {
63
88
  return null;
64
89
  }
@@ -66,6 +91,11 @@ export function parseHumanResolution(actionType, choice) {
66
91
  return null;
67
92
  if (!VALID_CHOICES[actionType].includes(choice))
68
93
  return null;
94
+ // NOT-272: `note` is declared only on the product_scope_decision variant — a note
95
+ // arriving with any other action type is dropped here, never stored or threaded.
96
+ if (actionType === "product_scope_decision" && note) {
97
+ return { actionType, choice, note };
98
+ }
69
99
  return { actionType, choice };
70
100
  }
71
101
  /**
@@ -22,6 +22,7 @@ import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import { MUSE_CODE_CONTRIBUTOR_MODEL } from "@agent-dealer/shared";
24
24
  import { resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
25
+ import { museVersionRequiredForWork } from "../adapters/muse-capability.js";
25
26
  import { ensureWorktreeExcluded } from "../adapters/worktree-exclude.js";
26
27
  import { buildMuseDeveloperInvocation } from "../runners/muse-code-args.js";
27
28
  import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
@@ -159,12 +160,21 @@ function refreshMuseCapacityAfterSession() {
159
160
  */
160
161
  async function runMuseServeLane(input, model, onAdmitted) {
161
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;
162
172
  // Only this call may fall back: it resolves `admitted:false` (or throws)
163
173
  // strictly before any model work starts. Everything below runs after
164
174
  // admission — the caller's no-double-execution rule (via onAdmitted)
165
175
  // keeps those errors loud instead of falling back to exec.
166
176
  const turn = await runMuseServeTurn({
167
- host: getMuseCapacityHost(),
177
+ host,
168
178
  prompt: input.prompt,
169
179
  model,
170
180
  cwd: input.cwd,
@@ -229,7 +239,7 @@ export async function runMuseDeveloperSession(input) {
229
239
  // executing the work a second time on the exec lane.
230
240
  // `AGENT_DEALER_MUSE_RUNNER=exec` skips the serve lane entirely
231
241
  // (operator escape hatch).
232
- if (museServeLaneEnabled()) {
242
+ if (museServeLaneEnabled() && !input.execLaneOnly) {
233
243
  let admitted = false;
234
244
  try {
235
245
  const served = await runMuseServeLane(input, model, () => {
@@ -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
@@ -63,6 +76,8 @@ export function buildDeveloperPrompt(input) {
63
76
  parts.push(...formatVerificationReceiptSection(input.priorVerificationReceipt));
64
77
  }
65
78
  }
79
+ // NOT-272: the scope decision leads — the developer reads it before the task itself.
80
+ parts.push(...scopeDecisionSection(input.scopeDecisionNote));
66
81
  parts.push(`## Task`, input.taskSnapshot.title, input.taskSnapshot.description, ``, `## Acceptance criteria`, input.taskSnapshot.acceptanceCriteria, ``);
67
82
  if (input.findings?.length) {
68
83
  parts.push(`## Findings to address`);
@@ -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,
@@ -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,6 +33,9 @@ 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
  // Own argv, per-attempt XDG dirs and result parsing; deck/MCP are never wired for Muse.
37
41
  return runMuseDeveloperSession({
@@ -51,6 +55,9 @@ export const realDeveloperSpawn = async (input) => {
51
55
  * loosens a reviewer's tools can never silently reach a live spawn.
52
56
  */
53
57
  export const realReviewerSpawn = async (input) => {
58
+ // NOT-273: same worktree-cwd guard as the developer path — throws before
59
+ // any child process exists.
60
+ assertWorktreeCwd(input.cwd);
54
61
  const args = buildReviewerArgs(input.runtime, input.prompt, input.model ?? undefined, input.policy, input.mcpConfigPath, input.effort);
55
62
  assertReviewerReadOnly(args, { mcpConfigPath: input.mcpConfigPath, mcpEnv: input.mcpEnv });
56
63
  const logPath = input.logPath ?? reviewerSessionLogPath(input.sessionId);