agent-dealer 1.2.5 → 1.2.7

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 (75) 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-DyAJNyfV.css +1 -0
  41. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +64 -0
  42. package/bundle/server/static-ui/index.html +2 -2
  43. package/bundle/shared/dist/action-presentation.d.ts +51 -0
  44. package/bundle/shared/dist/action-presentation.js +280 -0
  45. package/bundle/shared/dist/action-presentation.test.d.ts +1 -0
  46. package/bundle/shared/dist/action-presentation.test.js +180 -0
  47. package/bundle/shared/dist/attempt-waste.d.ts +2 -2
  48. package/bundle/shared/dist/execution-report.d.ts +2 -2
  49. package/bundle/shared/dist/human-actions.d.ts +12 -12
  50. package/bundle/shared/dist/index.d.ts +41 -40
  51. package/bundle/shared/dist/index.js +1 -0
  52. package/bundle/shared/dist/issues.d.ts +12 -12
  53. package/bundle/shared/dist/outbound-draft.d.ts +6 -6
  54. package/bundle/shared/dist/profile-snapshot.d.ts +2 -2
  55. package/bundle/shared/dist/usage-events.d.ts +2 -2
  56. package/bundle/shared/dist/worker-sessions.d.ts +6 -6
  57. package/bundle/shared/dist/workflow.d.ts +2 -2
  58. package/bundle/shared/package.json +1 -1
  59. package/dist/doctor.js +4 -1
  60. package/dist/managed/index.d.ts +1 -1
  61. package/dist/managed/index.js +1 -1
  62. package/dist/managed/updater.d.ts +16 -6
  63. package/dist/managed/updater.js +28 -11
  64. package/dist/managed-update-restart.test.d.ts +1 -0
  65. package/dist/managed-update-restart.test.js +227 -0
  66. package/dist/ports.d.ts +6 -0
  67. package/dist/ports.js +10 -0
  68. package/dist/runtime-state.d.ts +10 -0
  69. package/dist/runtime-state.js +26 -0
  70. package/dist/start.js +67 -2
  71. package/dist/status.js +30 -2
  72. package/dist/update-check.js +3 -6
  73. package/package.json +1 -1
  74. package/bundle/server/static-ui/assets/index-BII-LgB8.css +0 -1
  75. package/bundle/server/static-ui/assets/index-Bq8wWpZm.js +0 -60
@@ -25,10 +25,12 @@
25
25
  // connection (`hasInflightExecution`): a capacity read must not SIGTERM
26
26
  // an admitted turn.
27
27
  // - The host launches under a server-owned XDG home
28
- // (`prepareMuseServeHome`): the same worker posture as the exec lane's
29
- // per-attempt settings (no MCP servers, no subagents, no workflows, no
30
- // reminders) plus a symlink to the ambient login, so serve-lane turns
31
- // never inherit the operator's ambient config.
28
+ // (`prepareMuseServeHome`): the deckless worker base posture
29
+ // (`buildMuseBaseSettings` — no MCP servers, no subagents, no workflows,
30
+ // no reminders) plus a symlink to the ambient login, so the host never
31
+ // inherits the operator's ambient config. It runs no sessions; deck-enabled
32
+ // developer turns each carry their own required `agent-deck` server on the
33
+ // isolated exec lane.
32
34
  // - `usage/changed` is ingested as soon as received; the throttled
33
35
  // on-demand `usage/read` on the same host is the final read.
34
36
  // - Restart/crash: usage state is process-local to the host, so a dead host
@@ -51,17 +53,16 @@
51
53
  // for an hour.
52
54
  //
53
55
  // Execution precondition (NOT-269): only a host that observes the account's
54
- // provider traffic can answer `usage/read`. Real Dealer Muse turns run
55
- // through THIS host (`session/start` + `turn/start` via the execution lane
56
- // below, driven by runners/muse-serve-session.ts) — that traffic is the
57
- // observation, so the session-boundary refresh hook
58
- // (`refreshMuseCapacityAfterSession` in coordinator/muse-spawn.ts) is the
59
- // final `usage/read` that populates 5H/1W. If no genuine turn has populated
60
- // a complete current pair for an hour, `muse-probe.ts` may use its own host
61
- // for the bounded paid fallback. When the normal serve execution lane cannot
62
- // admit a developer turn, that session still falls back to the legacy
63
- // `muse exec` subprocess before any model work starts; last-good capacity
64
- // rows remain preserved.
56
+ // provider traffic can answer `usage/read`. NOT-278 removed developer turns
57
+ // from this host: every deck-enabled Muse developer turn runs the isolated
58
+ // `muse exec` lane (coordinator/muse-spawn.ts), which cannot observe the
59
+ // host, so the session-boundary refresh hook
60
+ // (`refreshMuseCapacityAfterSession` in coordinator/muse-spawn.ts) stays
61
+ // honest N/A with last-good rows preserved until a genuine turn observes
62
+ // the host again. If no genuine turn has populated a complete current pair
63
+ // for an hour, `muse-probe.ts` may use its own host for the bounded paid
64
+ // fallback. Per-session Agent Deck support for the serve protocol is a
65
+ // non-goal until the protocol can represent isolated session MCP identity.
65
66
  //
66
67
  // PRODUCT DECISION (2026-09-26, resolved via human_action on this ticket):
67
68
  // the runner migration is in scope for NOT-270, in this same PR, not a
@@ -94,7 +95,7 @@ import path from "node:path";
94
95
  import { MUSE_CLI_ENV, resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js";
95
96
  import { getDataDir } from "../db/index.js";
96
97
  import { parseMuseVersion } from "../adapters/muse-capability.js";
97
- import { buildMuseDeveloperSettings } from "../runners/muse-code-settings.js";
98
+ import { buildMuseBaseSettings } from "../runners/muse-config.js";
98
99
  import { MSP_USAGE_CHANGED, MUSE_CLIENT_INFO, MUSE_SERVE_ARGV, assertMuseExecMethod, assertMuseReadOnlyMethod, hasMuseCredential, ingestMuseUsagePayload, museCapacityTimeoutMs, museClassifyRpcError, museEvidenceRef, museRefreshThrottleMs, museUuidv7, noteMuseCapacityFailure, parseMuseRpcLine, } from "./muse.js";
99
100
  function readResolvedMuseVersion() {
100
101
  return new Promise((resolve) => {
@@ -114,16 +115,16 @@ export function resolveMuseHostCwd() {
114
115
  return dir;
115
116
  }
116
117
  /**
117
- * NOT-270 serve lane: the owned host must run workers under the same
118
- * posture as the exec lane's per-attempt settings — no MCP servers, no
119
- * subagent delegation, no workflows, no reminder child runs — so serve-lane
120
- * turns never inherit the operator's ambient MCP/subagent config. Builds a
121
- * server-owned config home carrying `buildMuseDeveloperSettings()` plus a
122
- * symlink to the ambient login (linked, never read or copied; absent when
123
- * the operator authenticates with META_API_KEY), and an isolated data home
124
- * for the host's own session/state. Mirrors the exec lane's per-attempt
125
- * dirs (`coordinator/muse-spawn.ts`), but process-scoped: one home per host
126
- * instance, reused across restarts, removed on shutdown.
118
+ * NOT-278: the owned host never runs a session — it only serves the read-only
119
+ * handshake/`usage/read` allowlist for capacity, and it is shared across decks, so it
120
+ * carries the deckless worker base posture (`buildMuseBaseSettings()`: no MCP servers, no
121
+ * subagent delegation, no workflows, no reminder child runs) and never inherits the
122
+ * operator's ambient MCP/subagent config. Builds a server-owned config home carrying those
123
+ * base settings plus a symlink to the ambient login (linked, never read or copied; absent
124
+ * when the operator authenticates with META_API_KEY), and an isolated data home for the
125
+ * host's own session/state. Deck-enabled developer turns run on the isolated exec lane
126
+ * (`coordinator/muse-spawn.ts`), each with its own required `agent-deck` server. Process-
127
+ * scoped: one home per host instance, reused across restarts, removed on shutdown.
127
128
  */
128
129
  export function prepareMuseServeHome(ambientAuthFile = resolveMuseAuthFile()) {
129
130
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-serve-"));
@@ -135,7 +136,7 @@ export function prepareMuseServeHome(ambientAuthFile = resolveMuseAuthFile()) {
135
136
  fs.chmodSync(root, 0o700);
136
137
  fs.chmodSync(configHome, 0o700);
137
138
  fs.chmodSync(dataHome, 0o700);
138
- fs.writeFileSync(path.join(configHome, "muse", "settings.json"), `${JSON.stringify(buildMuseDeveloperSettings(), null, 2)}\n`, { mode: 0o600 });
139
+ fs.writeFileSync(path.join(configHome, "muse", "settings.json"), `${JSON.stringify(buildMuseBaseSettings(), null, 2)}\n`, { mode: 0o600 });
139
140
  let authLinked = false;
140
141
  try {
141
142
  if (fs.existsSync(ambientAuthFile)) {
@@ -226,9 +227,9 @@ export class MuseCapacityHost {
226
227
  /**
227
228
  * Env for the host subprocess: the server-owned XDG home always wins over
228
229
  * inherited ambient config (the server process itself may run under an
229
- * operator XDG home), so serve-lane workers never inherit ambient MCP
230
- * servers, subagents, or workflows. Only an explicit XDG override in
231
- * `opts.env` (tests) still wins.
230
+ * operator XDG home), so the host never inherits ambient MCP servers,
231
+ * subagents, or workflows. Only an explicit XDG override in `opts.env`
232
+ * (tests) still wins.
232
233
  */
233
234
  mergedEnv() {
234
235
  const home = this.ensureServeHome();
@@ -488,8 +489,9 @@ export class MuseCapacityHost {
488
489
  /**
489
490
  * NOT-277: start (or reuse) the host, and report whether it runs `version`. A live host on any
490
491
  * other (or an unknown) version is restarted onto the current on-disk binary when it is idle;
491
- * while real turns are using it, it is left alone and this returns false (the caller runs its
492
- * work on the exec lane instead). Never true for a host not known to run `version`.
492
+ * while the connection is in use it is left alone and this returns false. Never true for a
493
+ * host not known to run `version`. (NOT-278: no developer turn runs on the host any more —
494
+ * this only gates capacity reads, which are version-independent.)
493
495
  */
494
496
  async ensureStartedOnVersion(version) {
495
497
  if (!(await this.ensureStarted()))
@@ -473,9 +473,11 @@ test("serve host launches under a server-owned config home with worker settings"
473
473
  assert.ok(!String(rec.dataHome).includes(".config"), "data home is not the operator's");
474
474
  assert.equal(rec.settingsError, null, "settings.json parses on the launched host");
475
475
  const settings = rec.settings;
476
- // Same worker posture as the exec lane's per-attempt settings: no MCP
477
- // servers, no subagent delegation, no workflows, no reminder child runs.
478
- assert.ok(!("mcpServers" in settings), "workers get no MCP servers");
476
+ // NOT-278: the host runs no sessions, so it carries the deckless worker base posture
477
+ // (`buildMuseBaseSettings`): no MCP servers, no subagent delegation, no workflows, no
478
+ // reminder child runs. Deck-enabled developer turns each carry their own required
479
+ // `agent-deck` server on the isolated exec lane.
480
+ assert.ok(!("mcpServers" in settings), "the host carries no MCP servers");
479
481
  const run = settings.run;
480
482
  assert.equal(run.workflow_trigger_mode, "off");
481
483
  assert.equal(run.subagent_delegation_mode, "off");
@@ -33,9 +33,10 @@ function claudeDisallowedTools(policy) {
33
33
  }
34
34
  function buildArgs(runtime, policy, prompt, model, mcpConfigPath, effort) {
35
35
  if (runtime === "muse_code") {
36
- // Muse's developer argv is built by runners/muse-code-args.ts inside coordinator/muse-spawn.ts
37
- // (NOT-181); it needs a per-attempt session id and XDG dirs this pure builder has no place
38
- // for. Falling through to the Claude arm below would spawn `muse` with Claude flags.
36
+ // Muse's developer argv is built by runners/muse-config.ts (`prepareMuseAttempt`,
37
+ // NOT-278) inside coordinator/muse-spawn.ts; it needs a per-attempt deck, session id, and
38
+ // XDG dirs this pure builder has no place for. Falling through to the Claude arm below
39
+ // would spawn `muse` with Claude flags.
39
40
  throw new Error("muse_code is not wired through the shared arg builder — its developer argv is built in coordinator/muse-spawn.ts");
40
41
  }
41
42
  if (runtime === "codex_local") {
@@ -26,9 +26,11 @@ export function emitCheckpointObserved(ev) {
26
26
  samplingPrecisionMs: ev.samplingPrecisionMs ?? null,
27
27
  branch: ev.branch ?? null,
28
28
  },
29
- idempotencyKey: ev.kind === "commit" && ev.origin === "salvage"
30
- ? `checkpoint:${ev.workerSessionId}:commit:salvage`
31
- : `checkpoint:${ev.workerSessionId}:${ev.kind}`,
29
+ idempotencyKey: ev.resumeSalvage && ev.observedSha
30
+ ? `checkpoint:resume-salvage:${ev.issueId}:${ev.observedSha}`
31
+ : ev.kind === "commit" && ev.origin === "salvage"
32
+ ? `checkpoint:${ev.workerSessionId}:commit:salvage`
33
+ : `checkpoint:${ev.workerSessionId}:${ev.kind}`,
32
34
  });
33
35
  }
34
36
  /**
@@ -167,3 +167,26 @@ test("checkpoints attribute per session across attempts of one issue", async ()
167
167
  assert.deepEqual(records.map((r) => r.sessionId).sort(), [sessionId, second.id].sort());
168
168
  assert.deepEqual(records.map((r) => r.observedSha).sort(), [COMMIT_SHA, "c".repeat(40)].sort());
169
169
  });
170
+ test("NOT-280: resume-salvage checkpoints dedupe per issue on restart but never collide across issues with an identical commit SHA", async () => {
171
+ const a = await setup();
172
+ const b = await setup();
173
+ const emit = (ctx) => emitCheckpointObserved({
174
+ issueId: ctx.issueId,
175
+ workflowInstanceId: ctx.instanceId,
176
+ workerSessionId: ctx.sessionId,
177
+ role: "developer",
178
+ stage: "developing",
179
+ round: 1,
180
+ kind: "commit",
181
+ observedSha: COMMIT_SHA,
182
+ origin: "salvage",
183
+ resumeSalvage: true,
184
+ });
185
+ emit(a);
186
+ emit(a); // restart re-detecting the same saved tip
187
+ emit(b); // another issue whose salvage produced the identical commit object
188
+ assert.equal(checkpointsOf(a.issueId, "commit").length, 1, "restart never duplicates the row");
189
+ assert.equal(checkpointsOf(b.issueId, "commit").length, 1, "the second issue still gets its own checkpoint");
190
+ const [row] = listWorkflowEventsForIssue(b.issueId).filter((e) => e.type === "checkpoint.observed");
191
+ assert.equal(row.workerSessionId, b.sessionId, "tied to its own predecessor session");
192
+ });
@@ -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, normalizeResolutionNote, 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";
@@ -786,6 +786,19 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
786
786
  const pushDivergence = actionType === "policy_escalation" && !resumeAsReviewer
787
787
  ? (effect.pushDivergence ?? null)
788
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
+ }
789
802
  const action = createHumanAction({
790
803
  issueId: issue.id,
791
804
  workflowInstanceId: instance.id,
@@ -796,7 +809,9 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
796
809
  ? { review: reviewerOutcome.result, ...(nonConvergence ? { nonConvergence } : {}) }
797
810
  : pushDivergence
798
811
  ? { [PUSH_DIVERGENCE_EVIDENCE_KEY]: pushDivergence }
799
- : undefined,
812
+ : blockerFingerprint
813
+ ? { [WORKTREE_BLOCKER_EVIDENCE_KEY]: { fingerprint: blockerFingerprint } }
814
+ : undefined,
800
815
  // issueNow.headSha, not issue.headSha: a stale outcome that itself exhausted the
801
816
  // infra budget already patched the newly observed head onto the issue above — the
802
817
  // pre-transition issue param would still carry the stale SHA a "resume" must not reuse.
@@ -819,6 +834,51 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
819
834
  }
820
835
  return base;
821
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
+ }
822
882
  /**
823
883
  * NOT-194: true when the action is a merge-failure escalation (evidence carries
824
884
  * `mergeFailure: true`). Pre-NOT-194 open merge-failure actions have no such evidence and
@@ -1292,6 +1352,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1292
1352
  // Keyed on the resolved human action, not the round/attempt counters: a
1293
1353
  // "infra" resume resets infra_attempts to 0 every time, so a counter-based key would
1294
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}`);
1295
1356
  const next = resumeAsReviewer
1296
1357
  ? enqueueWorkItem({
1297
1358
  issueId: issue.id,
@@ -1299,7 +1360,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1299
1360
  kind: "reviewer",
1300
1361
  round: issueNow.currentRound,
1301
1362
  payload: { inputSha: effectiveResumeHeadSha, profileSnapshot: queuedProfileSnapshot(issue, "reviewer") },
1302
- idempotencyKey: `${instance.id}:reviewer:resume:${action.id}`,
1363
+ idempotencyKey: resumeKey,
1303
1364
  })
1304
1365
  : enqueueWorkItem({
1305
1366
  issueId: issue.id,
@@ -1312,7 +1373,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1312
1373
  // payload — later rounds never see it.
1313
1374
  ...(scopeDecisionNote ? { scopeDecisionNote } : {}),
1314
1375
  },
1315
- idempotencyKey: `${instance.id}:developer:resume:${action.id}`,
1376
+ idempotencyKey: resumeKey,
1316
1377
  });
1317
1378
  return {
1318
1379
  ok: true,
@@ -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
  });
@@ -818,8 +893,8 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
818
893
  priorConclusion,
819
894
  priorVerificationReceipt,
820
895
  worktreePath,
821
- deckId: isMuse ? null : (snapshot?.deckId ?? null),
822
- noAgentDeck: isMuse,
896
+ deckId: snapshot?.deckId ?? null,
897
+ museDeveloper: isMuse,
823
898
  guidance: guidance.length ? guidance : undefined,
824
899
  scopeDecisionNote,
825
900
  });
@@ -920,6 +995,9 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
920
995
  effort: snapshot?.effort ?? null,
921
996
  // Frozen profile budget → Muse's `--max-model-steps`; the other runtimes ignore it.
922
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,
923
1001
  prompt,
924
1002
  cwd: worktreePath,
925
1003
  timeoutMs: developerEffectConfig.sessionTimeoutMs,