agent-dealer 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) 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 +28 -6
  4. package/bundle/server/dist/adapters/agent-health.test.js +66 -11
  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/github.js +110 -12
  8. package/bundle/server/dist/adapters/github.test.js +274 -3
  9. package/bundle/server/dist/adapters/muse-capability.js +356 -0
  10. package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
  11. package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
  12. package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
  13. package/bundle/server/dist/capacity/muse-host.js +92 -32
  14. package/bundle/server/dist/capacity/muse-host.test.js +130 -4
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/args.js +4 -3
  17. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  18. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  19. package/bundle/server/dist/coordinator/commands.js +100 -12
  20. package/bundle/server/dist/coordinator/developer-effect.js +128 -16
  21. package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
  22. package/bundle/server/dist/coordinator/human-resolution.js +33 -1
  23. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  24. package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
  25. package/bundle/server/dist/coordinator/projection.js +1 -0
  26. package/bundle/server/dist/coordinator/prompts.js +25 -6
  27. package/bundle/server/dist/coordinator/prompts.test.js +36 -8
  28. package/bundle/server/dist/coordinator/routing.js +1 -0
  29. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  30. package/bundle/server/dist/coordinator/spawn.js +9 -1
  31. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  32. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  33. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  34. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  35. package/bundle/server/dist/repository/human-actions.js +13 -0
  36. package/bundle/server/dist/routes/human-actions.js +10 -1
  37. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  38. package/bundle/server/dist/routes/index.js +12 -5
  39. package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
  40. package/bundle/server/dist/routes/version.js +30 -0
  41. package/bundle/server/dist/routes/version.test.js +21 -0
  42. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  43. package/bundle/server/dist/runners/muse-config.js +1 -1
  44. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  45. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  46. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  47. package/bundle/server/package.json +2 -2
  48. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  49. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
  50. package/bundle/server/static-ui/index.html +2 -2
  51. package/bundle/shared/dist/agents.d.ts +15 -15
  52. package/bundle/shared/dist/agents.js +6 -0
  53. package/bundle/shared/dist/index.d.ts +7 -7
  54. package/bundle/shared/package.json +1 -1
  55. package/dist/doctor.d.ts +7 -3
  56. package/dist/doctor.js +21 -13
  57. package/dist/doctor.test.js +9 -8
  58. package/dist/managed/index.d.ts +1 -1
  59. package/dist/managed/index.js +1 -1
  60. package/dist/managed/updater.d.ts +16 -6
  61. package/dist/managed/updater.js +28 -11
  62. package/dist/managed-update-restart.test.d.ts +1 -0
  63. package/dist/managed-update-restart.test.js +227 -0
  64. package/dist/ports.d.ts +6 -0
  65. package/dist/ports.js +10 -0
  66. package/dist/runtime-state.d.ts +10 -0
  67. package/dist/runtime-state.js +26 -0
  68. package/dist/start.js +67 -2
  69. package/dist/status.js +30 -2
  70. package/dist/update-check.js +3 -6
  71. package/package.json +1 -1
  72. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
@@ -11,7 +11,7 @@ import { canTransitionIssue } from "@agent-dealer/shared";
11
11
  import { getDb } from "../db/index.js";
12
12
  import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, resetIssueInfraAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
13
13
  import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, listWorkflowEventsForIssue, startWorkflowInstance, WorkflowAlreadyActiveError, } from "../repository/workflow-events.js";
14
- import { createHumanAction, findOpenHumanAction, getHumanAction, listHumanActionsForIssue, resolveHumanAction, updateOpenHumanAction, } from "../repository/human-actions.js";
14
+ import { createHumanAction, findOpenHumanAction, getHumanAction, listHumanActionsForIssue, reopenHumanAction, resolveHumanAction, updateOpenHumanAction, } from "../repository/human-actions.js";
15
15
  import { pushLeaseToSha, readRemoteTip } from "../adapters/git-worktree.js";
16
16
  import { ensureIssueRepoCheckout } from "../adapters/managed-repo.js";
17
17
  import { reconcileFinding, resolveFindingsAbsentFromRound } from "../repository/findings.js";
@@ -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, WORKTREE_BLOCKER_EVIDENCE_KEY, 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
@@ -781,6 +786,19 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
781
786
  const pushDivergence = actionType === "policy_escalation" && !resumeAsReviewer
782
787
  ? (effect.pushDivergence ?? null)
783
788
  : null;
789
+ // NOT-280: an unchanged worktree blocker (same fingerprint) lands on the action it
790
+ // already raised — still open, or reopened when a Resume changed nothing — instead of
791
+ // opening an identical one on every Resume.
792
+ const blockerFingerprint = effect.kind === "human_action" ? (effect.blockerFingerprint ?? null) : null;
793
+ if (blockerFingerprint) {
794
+ const same = sameBlockerAction(issue.id, blockerFingerprint);
795
+ if (same) {
796
+ ev.emit("human_action.requested", {
797
+ payload: { actionType: same.actionType, actionId: same.id, reopened: true, blockerFingerprint },
798
+ });
799
+ return { ...base, humanActionId: same.id };
800
+ }
801
+ }
784
802
  const action = createHumanAction({
785
803
  issueId: issue.id,
786
804
  workflowInstanceId: instance.id,
@@ -791,7 +809,9 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
791
809
  ? { review: reviewerOutcome.result, ...(nonConvergence ? { nonConvergence } : {}) }
792
810
  : pushDivergence
793
811
  ? { [PUSH_DIVERGENCE_EVIDENCE_KEY]: pushDivergence }
794
- : undefined,
812
+ : blockerFingerprint
813
+ ? { [WORKTREE_BLOCKER_EVIDENCE_KEY]: { fingerprint: blockerFingerprint } }
814
+ : undefined,
795
815
  // issueNow.headSha, not issue.headSha: a stale outcome that itself exhausted the
796
816
  // infra budget already patched the newly observed head onto the issue above — the
797
817
  // pre-transition issue param would still carry the stale SHA a "resume" must not reuse.
@@ -814,6 +834,51 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
814
834
  }
815
835
  return base;
816
836
  }
837
+ /**
838
+ * NOT-280: an action reopened for an unchanged blocker (`sameBlockerAction`) can be
839
+ * resumed again — each Resume of it needs its own work item, so later resolutions of the
840
+ * same action get an ordinal suffix. The first Resume keeps the plain action-keyed name.
841
+ */
842
+ function resumeWorkItemKey(issueId, baseKey) {
843
+ const taken = new Set(listWorkItemsForIssue(issueId).map((w) => w.idempotencyKey));
844
+ if (!taken.has(baseKey))
845
+ return baseKey;
846
+ let n = 2;
847
+ while (taken.has(`${baseKey}:${n}`))
848
+ n++;
849
+ return `${baseKey}:${n}`;
850
+ }
851
+ /**
852
+ * NOT-280: the issue's most recent action, when it was raised for exactly this worktree
853
+ * blocker fingerprint and is either still open or was resolved by a Resume — reopened in
854
+ * that case. Anything else in between (another action, a different choice, a changed
855
+ * blocker) means state moved, so the caller raises a fresh action.
856
+ */
857
+ function sameBlockerAction(issueId, fingerprint) {
858
+ const actions = listHumanActionsForIssue(issueId);
859
+ const latest = actions[actions.length - 1];
860
+ if (!latest?.evidenceJson)
861
+ return null;
862
+ try {
863
+ const evidence = JSON.parse(latest.evidenceJson);
864
+ if (evidence[WORKTREE_BLOCKER_EVIDENCE_KEY]?.fingerprint !== fingerprint)
865
+ return null;
866
+ }
867
+ catch {
868
+ return null;
869
+ }
870
+ if (latest.status === "open")
871
+ return latest;
872
+ try {
873
+ const resolution = latest.resolutionJson ? JSON.parse(latest.resolutionJson) : null;
874
+ if (resolution?.choice !== "resume")
875
+ return null;
876
+ }
877
+ catch {
878
+ return null;
879
+ }
880
+ return reopenHumanAction(latest.id);
881
+ }
817
882
  /**
818
883
  * NOT-194: true when the action is a merge-failure escalation (evidence carries
819
884
  * `mergeFailure: true`). Pre-NOT-194 open merge-failure actions have no such evidence and
@@ -982,10 +1047,23 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
982
1047
  return { ok: false, code: 404, error: "Human action not found" };
983
1048
  if (action.status !== "open")
984
1049
  return { ok: false, code: 409, error: "Human action already resolved" };
985
- const resolution = parseHumanResolution(action.actionType, choice);
1050
+ // NOT-272: same shape validation as the HTTP route (which already applied it) —
1051
+ // defense in depth for direct/CLI callers of this sync entry point.
1052
+ const normalizedNote = normalizeResolutionNote(opts?.note);
1053
+ if (!normalizedNote.ok)
1054
+ return { ok: false, code: 400, error: normalizedNote.error };
1055
+ const resolution = parseHumanResolution(action.actionType, choice, normalizedNote.note);
986
1056
  if (!resolution) {
987
1057
  return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
988
1058
  }
1059
+ // NOT-272: the typed resolution carries the note only for product_scope_decision
1060
+ // (parseHumanResolution drops it for every other action type).
1061
+ const scopeDecisionNote = resolution.actionType === "product_scope_decision" && "note" in resolution
1062
+ ? resolution.note
1063
+ : undefined;
1064
+ const storedResolution = scopeDecisionNote
1065
+ ? { choice, note: scopeDecisionNote }
1066
+ : { choice };
989
1067
  // NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
990
1068
  // retry_merge/repair/close only (resume would re-run development on approved work);
991
1069
  // every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
@@ -1077,7 +1155,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1077
1155
  }
1078
1156
  try {
1079
1157
  return getDb().transaction(() => {
1080
- resolveHumanAction(actionId, resolvedBy, { choice });
1158
+ resolveHumanAction(actionId, resolvedBy, storedResolution);
1081
1159
  appendWorkflowEvent({
1082
1160
  issueId: issue.id,
1083
1161
  type: "human_action.resolved",
@@ -1086,7 +1164,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1086
1164
  stage: issue.status,
1087
1165
  payload: { actionType: action.actionType, choice },
1088
1166
  });
1089
- const { workItem } = startWorkflowCore(issue.id);
1167
+ // NOT-272: this branch is always product_scope_decision (legacy terminal
1168
+ // actions returned above) — the note rides the round-1 payload so the very
1169
+ // next developer round reads it in its prompt.
1170
+ const { workItem } = startWorkflowCore(issue.id, { scopeDecisionNote: scopeDecisionNote });
1090
1171
  return {
1091
1172
  ok: true,
1092
1173
  issueStatus: getIssue(issue.id).status,
@@ -1165,7 +1246,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1165
1246
  : {};
1166
1247
  return getDb().transaction(() => {
1167
1248
  resolveHumanAction(actionId, resolvedBy, {
1168
- choice,
1249
+ ...storedResolution,
1169
1250
  // NOT-226: record the re-pin so the resolved action itself shows which head the
1170
1251
  // reviewer was re-queued at when the branch had moved while parked.
1171
1252
  ...(rePinnedResumeHeadSha
@@ -1271,6 +1352,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1271
1352
  // Keyed on the resolved human action, not the round/attempt counters: a
1272
1353
  // "infra" resume resets infra_attempts to 0 every time, so a counter-based key would
1273
1354
  // collide across repeated escalate→resume cycles within the same round.
1355
+ const resumeKey = resumeWorkItemKey(issue.id, `${instance.id}:${resumeAsReviewer ? "reviewer" : "developer"}:resume:${action.id}`);
1274
1356
  const next = resumeAsReviewer
1275
1357
  ? enqueueWorkItem({
1276
1358
  issueId: issue.id,
@@ -1278,15 +1360,20 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1278
1360
  kind: "reviewer",
1279
1361
  round: issueNow.currentRound,
1280
1362
  payload: { inputSha: effectiveResumeHeadSha, profileSnapshot: queuedProfileSnapshot(issue, "reviewer") },
1281
- idempotencyKey: `${instance.id}:reviewer:resume:${action.id}`,
1363
+ idempotencyKey: resumeKey,
1282
1364
  })
1283
1365
  : enqueueWorkItem({
1284
1366
  issueId: issue.id,
1285
1367
  workflowInstanceId: instance.id,
1286
1368
  kind: "developer",
1287
1369
  round: issueNow.currentRound,
1288
- payload: { profileSnapshot: queuedProfileSnapshot(issue, "developer") },
1289
- idempotencyKey: `${instance.id}:developer:resume:${action.id}`,
1370
+ payload: {
1371
+ profileSnapshot: queuedProfileSnapshot(issue, "developer"),
1372
+ // NOT-272: a product_scope_decision note rides only this next round's
1373
+ // payload — later rounds never see it.
1374
+ ...(scopeDecisionNote ? { scopeDecisionNote } : {}),
1375
+ },
1376
+ idempotencyKey: resumeKey,
1290
1377
  });
1291
1378
  return {
1292
1379
  ok: true,
@@ -1347,6 +1434,7 @@ export async function resolveHumanActionAndAdvanceAsync(actionId, resolvedBy, ch
1347
1434
  const result = resolveHumanActionAndAdvance(actionId, resolvedBy, choice, {
1348
1435
  resumeLiveHeadSha,
1349
1436
  externalMergeState,
1437
+ note: opts?.note,
1350
1438
  });
1351
1439
  if (!result.ok || !result.pendingMerge)
1352
1440
  return result;
@@ -21,11 +21,11 @@ import { getTaskSnapshot } from "./commands.js";
21
21
  import { buildDeveloperPrompt } from "./prompts.js";
22
22
  import { guidanceForNextSession } from "./guidance.js";
23
23
  import { realDeveloperSpawn, developerSessionLogPath } from "./spawn.js";
24
- import { DEFAULT_BASE_FETCH_TIMEOUT_MS, fastForwardLocalBranchToSha, resolveDeveloperWorktree, safeRemoveWorktree, isWorktreeClean, commitsAhead, pushBranch, mergeBase, branchExists, pushBranchRef, revParseHead, revParseRef, fetchRef, salvageDirtyWorktree, dirtyWorktreeRecoveryCommands, withRepoLock, } from "../adapters/git-worktree.js";
24
+ import { DEFAULT_BASE_FETCH_TIMEOUT_MS, fastForwardLocalBranchToSha, resolveDeveloperWorktree, safeRemoveWorktree, isWorktreeClean, commitsAhead, pushBranch, mergeBase, branchExists, pushBranchRef, revParseHead, revParseRef, fetchRef, salvageDirtyWorktree, dirtyWorktreeRecoveryCommands, withRepoLock, leftoverFingerprint, } from "../adapters/git-worktree.js";
25
25
  import { recordIssueBaseSha } from "../repository/issues.js";
26
26
  import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
27
27
  import { baseRefCandidates, inspectBranchProgress } from "./branch-progress.js";
28
- import { prepareWorkerDeckConnection, releaseWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
28
+ import { prepareWorkerDeckConnection, releaseWorkerDeckConnection, verifyWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
29
29
  import { realGithubAdapter, pollPrChecks } from "../adapters/github.js";
30
30
  import { getWorkerSession, patchRunningSession, recordSessionProcess, setSessionInputSha } from "../repository/worker-sessions.js";
31
31
  import { COORDINATOR_PROCESS_OWNER, readProcessStartTime } from "./process-liveness.js";
@@ -675,7 +675,13 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
675
675
  return { kind: "base_fetch_failed", reason: resolved.reason };
676
676
  }
677
677
  if (resolved.kind === "conflict") {
678
- return { kind: "worktree_conflict", path: resolved.path, reason: resolved.reason, recoveryCommands: resolved.recoveryCommands };
678
+ return {
679
+ kind: "worktree_conflict",
680
+ path: resolved.path,
681
+ reason: resolved.reason,
682
+ recoveryCommands: resolved.recoveryCommands,
683
+ ...(resolved.fingerprint ? { fingerprint: resolved.fingerprint } : {}),
684
+ };
679
685
  }
680
686
  if (resolved.kind === "live_owner") {
681
687
  return {
@@ -687,6 +693,56 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
687
693
  }
688
694
  worktreePath = resolved.path;
689
695
  worktreeReused = resolved.kind === "reused";
696
+ if (resolved.kind === "reused" && resolved.salvage) {
697
+ // NOT-280: a dead predecessor's dirt was committed on the issue branch (or found
698
+ // already committed after a restart) — one durable checkpoint per salvage commit,
699
+ // tied to the predecessor session when Dealer still has its row.
700
+ // Unlike ordinary checkpoint evidence, this row is a deliverable: if it cannot be
701
+ // persisted (after one retry) the attempt stops before spawning, leaving the clean
702
+ // checkout and its salvage tip in place. The next Resume re-detects that tip
703
+ // (`recovered`) and retries the idempotent insert; an unchanged failure keeps the
704
+ // same fingerprint, so it reuses its human action instead of opening another.
705
+ const salvage = resolved.salvage;
706
+ const recordSalvage = () => emitCheckpointObserved({
707
+ issueId: issue.id,
708
+ workflowInstanceId: instance.id,
709
+ workerSessionId: getWorkerSession(salvage.predecessorSessionId)
710
+ ? salvage.predecessorSessionId
711
+ : sessionId,
712
+ role: "developer",
713
+ stage,
714
+ round,
715
+ kind: "commit",
716
+ observedSha: salvage.commitSha,
717
+ origin: "salvage",
718
+ branch: branchName,
719
+ resumeSalvage: true,
720
+ });
721
+ let checkpointError = null;
722
+ for (let attempt = 0; attempt < 2; attempt++) {
723
+ try {
724
+ recordSalvage();
725
+ checkpointError = null;
726
+ break;
727
+ }
728
+ catch (err) {
729
+ checkpointError = err;
730
+ }
731
+ }
732
+ if (checkpointError !== null) {
733
+ const reason = `Salvage tip ${salvage.commitSha.slice(0, 7)} of dead session ${salvage.predecessorSessionId}'s work ` +
734
+ `was committed on ${branchName} at ${worktreePath}, but its durable checkpoint could not be recorded: ` +
735
+ `${checkpointError instanceof Error ? checkpointError.message : String(checkpointError)}. ` +
736
+ "The checkout was left intact; Resume retries the checkpoint without re-committing.";
737
+ return {
738
+ kind: "worktree_conflict",
739
+ path: worktreePath,
740
+ reason,
741
+ recoveryCommands: [`cd ${worktreePath}`, "git log --oneline -3", "# fix the Dealer database, then Resume"],
742
+ fingerprint: await leftoverFingerprint(worktreePath, reason),
743
+ };
744
+ }
745
+ }
690
746
  if (resolved.kind === "created" && resolved.baseSha) {
691
747
  // NOT-197: record the true branch point while it is known — the verified handoff
692
748
  // re-checks it via merge-base, but crash/timeout progress inspection below already
@@ -729,8 +785,7 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
729
785
  // the spawn args' tool surface further down. They were resolved separately and had to
730
786
  // agree by inspection — the exact shape this stack exists to remove (NOT-134 review).
731
787
  const policy = snapshot?.permissionPolicy ?? roleCeiling("developer");
732
- // NOT-181: Muse Code workers get no MCP servers and no Agent Deck — nothing is materialized,
733
- // verified or released for them, whatever deck the profile happens to carry.
788
+ // NOT-278: every runtime, Muse included, is fail-closed without a deck — nothing spawns.
734
789
  const isMuse = runtime === "muse_code";
735
790
  let workerAuthority = null;
736
791
  // NOT-225: marks the setup/spawn boundary for the outer catch below. Only a throw
@@ -739,14 +794,14 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
739
794
  // adapter_failure path, since the session did start, run, and possibly commit.
740
795
  let sessionStarted = false;
741
796
  try {
742
- if (!isMuse && !snapshot?.deckId) {
797
+ if (!snapshot?.deckId) {
743
798
  await bestEffortRemove(repoPath, worktreePath);
744
799
  return {
745
800
  kind: "deck_failure",
746
801
  reason: "Agent profile has no Agent Deck — workers never start without one",
747
802
  };
748
803
  }
749
- if (!isMuse && snapshot?.deckId) {
804
+ if (!isMuse) {
750
805
  const prepared = await prepareWorkerDeckConnection({
751
806
  deckId: snapshot.deckId,
752
807
  worktreePath,
@@ -769,7 +824,27 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
769
824
  deckId: snapshot.deckId,
770
825
  });
771
826
  }
772
- if (taskBriefIsComplete(taskSnapshot) || isMuse) {
827
+ else {
828
+ // NOT-278: Muse runs the same live deck preflight without materializing a runtime MCP
829
+ // config — its settings.json comes only from `prepareMuseAttempt` in the spawn path.
830
+ // Same mapping: silence is `deck_unavailable`, a bad answer is `deck_failure`.
831
+ const verified = await verifyWorkerDeckConnection({
832
+ deckId: snapshot.deckId,
833
+ worktreePath,
834
+ callTool: deps.deckCallTool,
835
+ });
836
+ if (!verified.ok) {
837
+ await bestEffortRemove(repoPath, worktreePath);
838
+ return verified.kind === "deck_unavailable"
839
+ ? { kind: "deck_unavailable", reason: verified.reason }
840
+ : { kind: "deck_failure", reason: verified.reason };
841
+ }
842
+ milestone("deck.connected", `Developer · deck connected (round ${round})`, {
843
+ deckId: snapshot.deckId,
844
+ });
845
+ }
846
+ // NOT-278: Muse is deck-bound like every other runtime, so the same brief rule applies.
847
+ if (taskBriefIsComplete(taskSnapshot)) {
773
848
  milestone("brief.resolved", `Developer · brief ready (Task/AC complete)`, {
774
849
  resolution: "task_complete",
775
850
  });
@@ -782,6 +857,11 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
782
857
  const openFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
783
858
  const guidance = guidanceForNextSession(issue.id, sessionId);
784
859
  const retryReason = payload.retryReason ?? undefined;
860
+ // NOT-272: the scope note rides only the work item queued by the resolution —
861
+ // exactly this round reads it; nothing is re-read from the DB.
862
+ const scopeDecisionNote = typeof payload.scopeDecisionNote === "string" && payload.scopeDecisionNote.trim()
863
+ ? payload.scopeDecisionNote.trim()
864
+ : undefined;
785
865
  let priorConclusion;
786
866
  let priorVerificationReceipt;
787
867
  if (retryReason) {
@@ -813,9 +893,10 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
813
893
  priorConclusion,
814
894
  priorVerificationReceipt,
815
895
  worktreePath,
816
- deckId: isMuse ? null : (snapshot?.deckId ?? null),
817
- noAgentDeck: isMuse,
896
+ deckId: snapshot?.deckId ?? null,
897
+ museDeveloper: isMuse,
818
898
  guidance: guidance.length ? guidance : undefined,
899
+ scopeDecisionNote,
819
900
  });
820
901
  // NOT-83 review finding: the session is marked `running` (worker-loop.ts) before this
821
902
  // handler ever runs, so an abort landing during worktree setup/deck-bind above already
@@ -914,6 +995,9 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
914
995
  effort: snapshot?.effort ?? null,
915
996
  // Frozen profile budget → Muse's `--max-model-steps`; the other runtimes ignore it.
916
997
  maxModelSteps: parsePhaseBudget(snapshot?.budgetJson)?.maxTurns ?? null,
998
+ // NOT-278: frozen profile deck → the Muse exec attempt (deck headers + pre-spawn
999
+ // verify). Other runtimes carry their deck via mcpConfigPath/mcpEnv and ignore this.
1000
+ deckId: snapshot?.deckId ?? null,
917
1001
  prompt,
918
1002
  cwd: worktreePath,
919
1003
  timeoutMs: developerEffectConfig.sessionTimeoutMs,
@@ -1286,13 +1370,41 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1286
1370
  author: "system",
1287
1371
  blobPath: spawned.logPath,
1288
1372
  });
1373
+ // NOT-275: a clean exit (exitCode 0, not timed out, no usage cap) that leaves
1374
+ // the worktree dirty hit the same escalation as a crash before this — the
1375
+ // session did real ticket-scoped work but never ran `git commit`. Salvage
1376
+ // first (same mechanism as the crash/timeout path above): on success fall
1377
+ // through to push/PR as if the agent had committed itself; only when the
1378
+ // salvage commit itself fails do we escalate dirty_worktree with recovery.
1289
1379
  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
- };
1380
+ const salvaged = await salvageDirtyWorktree(worktreePath, "crash");
1381
+ if (!salvaged.ok) {
1382
+ return {
1383
+ kind: "dirty_worktree",
1384
+ reason: `${reasonForDirtyWorktree(spawned.logPath, runtime)} Auto-commit salvage failed: ${salvaged.reason}`,
1385
+ path: worktreePath,
1386
+ recoveryCommands: dirtyWorktreeRecoveryCommands(repoPath, worktreePath),
1387
+ };
1388
+ }
1389
+ // NOT-172: durable salvage-commit checkpoint at the existing success point.
1390
+ try {
1391
+ emitCheckpointObserved({
1392
+ issueId: issue.id,
1393
+ workflowInstanceId: instance.id,
1394
+ workerSessionId: sessionId,
1395
+ role: "developer",
1396
+ stage,
1397
+ round,
1398
+ kind: "commit",
1399
+ observedSha: salvaged.commitSha,
1400
+ origin: "salvage",
1401
+ inputSha: samplerInputSha,
1402
+ branch: branchName,
1403
+ });
1404
+ }
1405
+ catch {
1406
+ // checkpoint evidence must never fail the attempt itself
1407
+ }
1296
1408
  }
1297
1409
  const ahead = await commitsAhead({ worktreePath, baseRef: `origin/${baseBranch}` });
1298
1410
  if (ahead === 0) {