agent-dealer 1.2.5 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +3 -4
  4. package/bundle/server/dist/adapters/agent-health.test.js +11 -9
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/muse-capability.js +51 -25
  8. package/bundle/server/dist/adapters/muse-capability.test.js +138 -52
  9. package/bundle/server/dist/capacity/claude-local-cache.js +59 -27
  10. package/bundle/server/dist/capacity/claude-local-cache.test.js +163 -9
  11. package/bundle/server/dist/capacity/muse-host.js +34 -32
  12. package/bundle/server/dist/capacity/muse-host.test.js +5 -3
  13. package/bundle/server/dist/coordinator/args.js +4 -3
  14. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  15. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  16. package/bundle/server/dist/coordinator/commands.js +66 -5
  17. package/bundle/server/dist/coordinator/developer-effect.js +88 -10
  18. package/bundle/server/dist/coordinator/developer-effect.test.js +170 -0
  19. package/bundle/server/dist/coordinator/human-resolution.js +2 -0
  20. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  21. package/bundle/server/dist/coordinator/muse-spawn.js +72 -174
  22. package/bundle/server/dist/coordinator/projection.js +1 -0
  23. package/bundle/server/dist/coordinator/prompts.js +10 -6
  24. package/bundle/server/dist/coordinator/prompts.test.js +16 -8
  25. package/bundle/server/dist/coordinator/routing.js +1 -0
  26. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  27. package/bundle/server/dist/coordinator/spawn.js +2 -1
  28. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  29. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  30. package/bundle/server/dist/repository/human-actions.js +13 -0
  31. package/bundle/server/dist/routes/index.js +11 -8
  32. package/bundle/server/dist/routes/version.js +30 -0
  33. package/bundle/server/dist/routes/version.test.js +21 -0
  34. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  35. package/bundle/server/dist/runners/muse-config.js +1 -1
  36. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  37. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  38. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  39. package/bundle/server/package.json +2 -2
  40. package/bundle/server/static-ui/assets/{index-Bq8wWpZm.js → index-CoDzZcsz.js} +1 -1
  41. package/bundle/server/static-ui/index.html +1 -1
  42. package/bundle/shared/package.json +1 -1
  43. package/dist/doctor.js +4 -1
  44. package/dist/managed/index.d.ts +1 -1
  45. package/dist/managed/index.js +1 -1
  46. package/dist/managed/updater.d.ts +16 -6
  47. package/dist/managed/updater.js +28 -11
  48. package/dist/managed-update-restart.test.d.ts +1 -0
  49. package/dist/managed-update-restart.test.js +227 -0
  50. package/dist/ports.d.ts +6 -0
  51. package/dist/ports.js +10 -0
  52. package/dist/runtime-state.d.ts +10 -0
  53. package/dist/runtime-state.js +26 -0
  54. package/dist/start.js +67 -2
  55. package/dist/status.js +30 -2
  56. package/dist/update-check.js +3 -6
  57. package/package.json +1 -1
@@ -203,10 +203,11 @@ async function materializeWorkerMcpConfig(opts) {
203
203
  const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
204
204
  const mcpUrl = `${mcpBase}/mcp`;
205
205
  const headers = deckLaunchHeaders(opts.deckId, opts.worktreePath);
206
- // Muse Code has no Agent Deck / MCP wiring (NOT-178 non-goal; NOT-181 owns it). Refuse before
207
- // anything is written rather than fall through to the Claude config below.
206
+ // NOT-278: Muse Code settings come only from `prepareMuseAttempt`
207
+ // (runners/muse-config.ts) — never emit a Claude/Codex/Cursor config for Muse. Refuse
208
+ // before anything is written rather than fall through to the Claude config below.
208
209
  if (opts.runtime === "muse_code") {
209
- throw new Error("Muse Code does not support Agent Deck MCP configuration");
210
+ throw new Error("Muse Code does not support Agent Deck MCP configuration via the shared materializer — its settings come only from prepareMuseAttempt");
210
211
  }
211
212
  if (opts.runtime === "codex_local") {
212
213
  const codexHome = path.join(getWorkerMcpConfigDir(), `codex-home-${opts.deckId.slice(0, 8)}-${randomUUID()}`);
@@ -370,6 +371,32 @@ async function verifyDeckConnection(opts) {
370
371
  }
371
372
  }
372
373
  }
374
+ function toVerificationOutcome(verified) {
375
+ if (verified.ok)
376
+ return { ok: true };
377
+ return verified.kind === "deck_unavailable"
378
+ ? { ok: false, kind: "deck_unavailable", reason: `Agent Deck is unreachable — ${verified.reason}` }
379
+ : { ok: false, kind: "infra_failure", reason: `preflight failed: ${verified.reason}` };
380
+ }
381
+ /**
382
+ * NOT-278: live `get_bound_deck` (+ configured-playbook) preflight without materializing any
383
+ * runtime MCP config. Muse Code sessions use this: their settings.json comes only from
384
+ * `prepareMuseAttempt` (runners/muse-config.ts), so the shared materializer below must
385
+ * never run for them. Same result mapping as the materializing path: no response is
386
+ * `deck_unavailable`; a response that fails validation is `infra_failure` (surfaced by the
387
+ * effect as `deck_failure`).
388
+ */
389
+ export async function verifyWorkerDeckConnection(opts) {
390
+ const timeoutMs = opts.timeoutMs ?? Number(process.env.DECK_BIND_TIMEOUT_MS ?? 30_000);
391
+ const verified = await verifyDeckConnection({
392
+ deckId: opts.deckId,
393
+ worktreePath: opts.worktreePath,
394
+ playbookIds: opts.playbookIds ?? [],
395
+ callTool: opts.callTool,
396
+ timeoutMs,
397
+ });
398
+ return toVerificationOutcome(verified);
399
+ }
373
400
  /**
374
401
  * Materialize a per-attempt MCP config for the profile's deck and verify with
375
402
  * `get_bound_deck` and every configured playbook before spawn. No mint, no ledger, no
@@ -408,6 +435,9 @@ export async function prepareWorkerDeckConnection(opts) {
408
435
  catch {
409
436
  // best-effort
410
437
  }
438
+ // Same mapping as toVerificationOutcome, inlined so the result carries the
439
+ // materializing-path type (the shared helper's `ok: true` variant has no
440
+ // mcpConfigPath and is unreachable in this branch).
411
441
  return verified.kind === "deck_unavailable"
412
442
  ? { ok: false, kind: "deck_unavailable", reason: `Agent Deck is unreachable — ${verified.reason}` }
413
443
  : { ok: false, kind: "infra_failure", reason: `preflight failed: ${verified.reason}` };
@@ -12,7 +12,7 @@ import { parse as parseToml } from "smol-toml";
12
12
  import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
13
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deckbind-"));
14
14
  const { migrate } = await import("../db/index.js");
15
- const { prepareWorkerDeckConnection, releaseWorkerDeckConnection, parseDeckToolResult, isDeckUnreachableError } = await import("./agent-deck-bind.js");
15
+ const { prepareWorkerDeckConnection, releaseWorkerDeckConnection, parseDeckToolResult, isDeckUnreachableError, verifyWorkerDeckConnection } = await import("./agent-deck-bind.js");
16
16
  const { codexScopedConfigDeniesSendGate } = await import("./codex-scoped-config.js");
17
17
  const { roleCeiling } = await import("@agent-dealer/shared");
18
18
  /** Default worker policy: outboundMutation is false for BOTH roles (NOT-134). */
@@ -59,7 +59,65 @@ test("prepareWorkerDeckConnection verifies and writes a claude MCP config with d
59
59
  assert.equal(fs.existsSync(result.mcpConfigPath), false);
60
60
  }
61
61
  });
62
- test("prepareWorkerDeckConnection refuses Muse Code before writing any MCP config or verifying (NOT-178)", async () => {
62
+ // NOT-278: Muse Code settings come only from prepareMuseAttempt — the shared materializer
63
+ // must never run for Muse. Muse preflights through verifyWorkerDeckConnection instead.
64
+ test("verifyWorkerDeckConnection preflights Muse without materializing any MCP config", async () => {
65
+ const calls = [];
66
+ const result = await verifyWorkerDeckConnection({
67
+ deckId: DECK,
68
+ worktreePath: WT,
69
+ callTool: async (name, args) => {
70
+ calls.push(name);
71
+ if (name === "get_bound_deck")
72
+ return textResult({ id: DECK, name: "personal-dev" });
73
+ return textResult({ id: args.playbook_id });
74
+ },
75
+ });
76
+ assert.equal(result.ok, true);
77
+ assert.deepEqual(calls, ["get_bound_deck"]);
78
+ });
79
+ test("verifyWorkerDeckConnection maps a wrong deck to infra_failure and silence to deck_unavailable", async () => {
80
+ const wrong = await verifyWorkerDeckConnection({
81
+ deckId: DECK,
82
+ worktreePath: WT,
83
+ callTool: async () => textResult({ id: "some-other-deck-id" }),
84
+ });
85
+ assert.equal(wrong.ok, false);
86
+ if (!wrong.ok) {
87
+ assert.equal(wrong.kind, "infra_failure");
88
+ assert.match(wrong.reason, /returned deck some-other-deck-id, expected/);
89
+ }
90
+ const silent = await verifyWorkerDeckConnection({
91
+ deckId: DECK,
92
+ worktreePath: WT,
93
+ callTool: async () => {
94
+ throw Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1110"), { code: "ECONNREFUSED" });
95
+ },
96
+ });
97
+ assert.equal(silent.ok, false);
98
+ if (!silent.ok) {
99
+ assert.equal(silent.kind, "deck_unavailable");
100
+ assert.match(silent.reason, /unreachable/);
101
+ }
102
+ });
103
+ test("verifyWorkerDeckConnection maps a missing configured playbook to infra_failure (NOT-278)", async () => {
104
+ const missing = await verifyWorkerDeckConnection({
105
+ deckId: DECK,
106
+ worktreePath: WT,
107
+ playbookIds: ["pb-required"],
108
+ callTool: async (name, args) => {
109
+ if (name === "get_bound_deck")
110
+ return textResult({ id: DECK, name: "personal-dev" });
111
+ return errorResult(`playbook ${args.playbook_id} not found`);
112
+ },
113
+ });
114
+ assert.equal(missing.ok, false);
115
+ if (!missing.ok) {
116
+ assert.equal(missing.kind, "infra_failure");
117
+ assert.match(missing.reason, /preflight failed/);
118
+ }
119
+ });
120
+ test("prepareWorkerDeckConnection refuses Muse Code before writing any MCP config or verifying (NOT-278)", async () => {
63
121
  let verifyCalled = false;
64
122
  const result = await prepareWorkerDeckConnection({
65
123
  policy: DENIED,
@@ -469,10 +469,9 @@ function resolveMcpRegistration(mcpRegistered) {
469
469
  }
470
470
  function agentSpecificIssues(agent, agentDeckOnline, mcpRegistration, deckAccessResult) {
471
471
  const issues = [];
472
- // NOT-181: a Muse Code worker gets no MCP servers and no Agent Deck, so a missing, offline or
473
- // deleted deck cannot stop it. (The profile form still asks for one; Muse ignores it.)
474
- if (agent.runtime === "muse_code")
475
- return issues;
472
+ // NOT-278: a Muse Code developer session receives the selected deck exactly like the other
473
+ // developer runtimes, so `deck_missing`, `deck_offline`, and `deck_unauthorized` apply to
474
+ // Muse too. Only the Claude-only `mcp_not_registered` check below stays Claude-only.
476
475
  if (!agent.deckId) {
477
476
  issues.push({
478
477
  code: "deck_missing",
@@ -37,18 +37,20 @@ describe("agent-health", { concurrency: false }, () => {
37
37
  assert.equal(result.issues.some((i) => i.code === "deck_missing"), true);
38
38
  assert.equal(result.healthy, false);
39
39
  });
40
- // NOT-181: a Muse Code worker gets no MCP servers and no Agent Deck, so no deck problem may park it.
41
- test("muse_code ignores deck state: no deck, offline deck or unreadable deck raises a deck issue", async () => {
40
+ // NOT-278: a Muse Code developer session receives the selected deck exactly like the other
41
+ // developer runtimes, so deck_missing, deck_offline, and deck_unauthorized apply to Muse too.
42
+ test("muse_code reports deck state: missing deck, offline deck, and unreadable deck raise deck issues", async () => {
42
43
  const created = createAgent({ name: "muse-no-deck", runtime: "muse_code", deckId: randomUUID() });
43
44
  getDb().prepare("UPDATE agents SET deck_id = NULL WHERE id = ?").run(created.id);
44
- for (const [agent, online] of [
45
- [getAgent(created.id), true],
46
- [createAgent({ name: "muse-offline", runtime: "muse_code", deckId: randomUUID() }), false],
47
- [createAgent({ name: "muse-stale", runtime: "muse_code", deckId: randomUUID() }), true],
48
- ]) {
45
+ const cases = [
46
+ { agent: getAgent(created.id), online: true, code: "deck_missing" },
47
+ { agent: createAgent({ name: "muse-offline", runtime: "muse_code", deckId: randomUUID() }), online: false, code: "deck_offline" },
48
+ { agent: createAgent({ name: "muse-stale", runtime: "muse_code", deckId: randomUUID() }), online: true, code: "deck_unauthorized" },
49
+ ];
50
+ for (const { agent, online, code } of cases) {
49
51
  const result = await healthForAgent(agent, online, new Map(), true, FAILURE, NO_GITHUB);
50
- assert.deepEqual(result.issues, [], agent.name);
51
- assert.equal(result.healthy, true);
52
+ assert.equal(result.issues.some((i) => i.code === code), true, `${agent.name} reports ${code}`);
53
+ assert.equal(result.healthy, false);
52
54
  }
53
55
  });
54
56
  test("agent deck offline: reports deck_offline, not deck_unauthorized", async () => {
@@ -9,6 +9,7 @@
9
9
  // Lifted from archive/not-57-full-p0-slice and extended with role-aware creation,
10
10
  // safe (non-destructive) removal, and leftover inspection for crash recovery.
11
11
  import { execFile } from "node:child_process";
12
+ import { createHash } from "node:crypto";
12
13
  import fs from "node:fs";
13
14
  import path from "node:path";
14
15
  import { promisify } from "node:util";
@@ -192,14 +193,28 @@ export async function removeWorktree(opts) {
192
193
  args.push(opts.path);
193
194
  await git(opts.repo, args);
194
195
  }
196
+ /**
197
+ * NOT-280: `git status --porcelain` without the opportunistic index refresh — plain status
198
+ * rewrites a stale-stat index, which would mutate a checkout a refusal path must leave
199
+ * untouched (outside root, wrong branch, unknown owner).
200
+ */
201
+ async function readOnlyStatus(worktreePath) {
202
+ return (await git(worktreePath, ["--no-optional-locks", "status", "--porcelain"])).stdout;
203
+ }
195
204
  export async function isWorktreeClean(worktreePath) {
196
- const { stdout } = await git(worktreePath, ["status", "--porcelain"]);
197
- return stdout.trim().length === 0;
205
+ return (await readOnlyStatus(worktreePath)).trim().length === 0;
198
206
  }
199
207
  /** Commit message for a timeout salvage tip (NOT-145). */
200
208
  export const SALVAGE_TIMEOUT_MESSAGE = "wip: timeout salvage";
201
209
  /** Commit message for a crash/non-zero exit salvage tip (NOT-145). */
202
210
  export const SALVAGE_CRASH_MESSAGE = "wip: crash salvage";
211
+ /** Commit message for a dead predecessor's dirt salvaged before a new attempt (NOT-280). */
212
+ export const SALVAGE_RESUME_MESSAGE = "wip: resume salvage";
213
+ const SALVAGE_MESSAGES = {
214
+ timeout: SALVAGE_TIMEOUT_MESSAGE,
215
+ crash: SALVAGE_CRASH_MESSAGE,
216
+ resume: SALVAGE_RESUME_MESSAGE,
217
+ };
203
218
  /**
204
219
  * NOT-145: stage everything and commit a clearly marked salvage tip so a timeout/crash
205
220
  * can remove the worktree for retry without silently discarding uncommitted WIP.
@@ -207,7 +222,7 @@ export const SALVAGE_CRASH_MESSAGE = "wip: crash salvage";
207
222
  * (parent NOT-143), not a preserved dirty checkout the next attempt cannot adopt.
208
223
  */
209
224
  export async function salvageDirtyWorktree(worktreePath, kind) {
210
- const message = kind === "timeout" ? SALVAGE_TIMEOUT_MESSAGE : SALVAGE_CRASH_MESSAGE;
225
+ const message = SALVAGE_MESSAGES[kind];
211
226
  try {
212
227
  await git(worktreePath, ["add", "-A"]);
213
228
  // Explicit identity: coordinator-managed worktrees may lack user.name/email, and a
@@ -690,6 +705,96 @@ export function sessionIdFromRoleWorktreePath(worktreePath) {
690
705
  const m = /^(.*)-(developer|reviewer)$/.exec(base);
691
706
  return m?.[1] ?? null;
692
707
  }
708
+ function sha256Hex(parts) {
709
+ return createHash("sha256").update(parts.join("\0")).digest("hex");
710
+ }
711
+ /**
712
+ * NOT-280: stable identity of a leftover's blocking state. Best-effort reads of HEAD and
713
+ * status: an unreadable checkout still fingerprints (by path + reason), so repeated
714
+ * Resumes against the same corrupt tree dedupe too.
715
+ */
716
+ export async function leftoverFingerprint(worktreePath, reason) {
717
+ const head = await git(worktreePath, ["rev-parse", "HEAD"]).then((r) => r.stdout.trim(), () => "");
718
+ const status = await readOnlyStatus(worktreePath).catch(() => "");
719
+ return sha256Hex([tryRealpath(worktreePath), reason, head, status]);
720
+ }
721
+ /** Unmerged index entries (`git status --porcelain` XY codes) — a half-resolved merge. */
722
+ const UNMERGED_STATUS = /^(DD|AU|UD|UA|DU|AA|UU) /m;
723
+ /**
724
+ * NOT-280: commit a proven-dead predecessor's dirty managed checkout on the issue branch
725
+ * so the next attempt can continue from that tip. Inspects before touching anything:
726
+ * the checkout must be on exactly `refs/heads/<branch>` with no in-progress merge/
727
+ * cherry-pick/revert and no unmerged entries — anything else fails closed. A failed
728
+ * commit (e.g. a rejecting hook) restores the index it staged, so the checkout is left
729
+ * exactly as found.
730
+ */
731
+ export async function salvagePredecessorWorktree(worktreePath, branchName) {
732
+ let headRef;
733
+ let status;
734
+ let indexTree;
735
+ // Branch identity first, so a wrong-branch checkout is refused before any status read.
736
+ try {
737
+ headRef = (await git(worktreePath, ["symbolic-ref", "-q", "HEAD"])).stdout.trim();
738
+ }
739
+ catch (err) {
740
+ return { ok: false, reason: `inspection failed: ${err.message}` };
741
+ }
742
+ if (headRef !== `refs/heads/${branchName}`) {
743
+ return { ok: false, reason: `checkout is on ${headRef || "a detached HEAD"}, not refs/heads/${branchName}` };
744
+ }
745
+ try {
746
+ status = await readOnlyStatus(worktreePath);
747
+ }
748
+ catch (err) {
749
+ return { ok: false, reason: `inspection failed: ${err.message}` };
750
+ }
751
+ if (UNMERGED_STATUS.test(status)) {
752
+ return { ok: false, reason: "checkout has unmerged paths (an unfinished merge/rebase)" };
753
+ }
754
+ for (const marker of ["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"]) {
755
+ if (await refExists(worktreePath, marker)) {
756
+ return { ok: false, reason: `checkout has an in-progress operation (${marker} exists)` };
757
+ }
758
+ }
759
+ try {
760
+ indexTree = (await git(worktreePath, ["write-tree"])).stdout.trim();
761
+ }
762
+ catch (err) {
763
+ return { ok: false, reason: `inspection failed: ${err.message}` };
764
+ }
765
+ const headBefore = await revParseHead(worktreePath);
766
+ const salvaged = await salvageDirtyWorktree(worktreePath, "resume");
767
+ if (salvaged.ok)
768
+ return salvaged;
769
+ // Undo the `git add -A` staging when the commit itself did not land; a landed commit
770
+ // with residual dirt keeps its commit (the work is saved) and still fails closed.
771
+ // A failed rollback leaves the salvage's staging in place, so it is reported alongside
772
+ // the commit failure rather than claiming the checkout was left as found.
773
+ const headAfter = await revParseHead(worktreePath).catch(() => null);
774
+ if (headAfter === headBefore) {
775
+ try {
776
+ await git(worktreePath, ["read-tree", indexTree]);
777
+ }
778
+ catch (err) {
779
+ return {
780
+ ok: false,
781
+ reason: `${salvaged.reason}; restoring the index afterwards also failed (git read-tree ${indexTree}): ${err.message} — files in ${worktreePath} are untouched but \`git add -A\` staging remains`,
782
+ };
783
+ }
784
+ }
785
+ return { ok: false, reason: salvaged.reason };
786
+ }
787
+ /** Whether HEAD's subject is a resume-salvage tip (restart after the commit landed). */
788
+ async function headIsResumeSalvage(worktreePath) {
789
+ try {
790
+ const { stdout } = await git(worktreePath, ["log", "-1", "--format=%H%x00%s"]);
791
+ const [sha, subject] = stdout.trim().split("\0");
792
+ return subject === SALVAGE_RESUME_MESSAGE && sha ? sha : null;
793
+ }
794
+ catch {
795
+ return null;
796
+ }
797
+ }
693
798
  /**
694
799
  * The developer-setup half of design §"Worktree lifecycle and concurrency"'s crash-recovery
695
800
  * requirement: a leftover worktree from an earlier round/escalation on the SAME issue branch
@@ -707,31 +812,40 @@ export function sessionIdFromRoleWorktreePath(worktreePath) {
707
812
  */
708
813
  export async function resolveDeveloperWorktree(opts) {
709
814
  return withRepoLock(opts.repo, async () => {
710
- await pruneWorktrees(opts.repo);
711
- // NOT-219: a repair round reuses an existing issue branch, so first fetch
815
+ // NOT-280: no up-front `git worktree prune` — it deletes `.git/worktrees/*` admin
816
+ // entries, so it runs only once no refusal (outside root, live owner, dirt, unreadable
817
+ // checkout) applies: for a vanished leftover here, otherwise right before the add below.
818
+ // NOT-219: a repair round reuses an existing issue branch, so fetch
712
819
  // `origin/<branch>` — the worktree must start at exactly what Dealer pushed,
713
820
  // never the managed clone's possibly-stale local ref. A failed fetch defers the
714
821
  // start (`base_unavailable`) instead of falling back to the stale local branch.
822
+ // NOT-280: the fetch runs only once every refusal check (outside root, live owner,
823
+ // unknown-owner dirt, unreadable checkout) has passed, so a refused leftover leaves
824
+ // the shared repository's refs, FETCH_HEAD and objects untouched.
825
+ const fetchReuse = async () => opts.reuseBranch
826
+ ? fetchReusedBranch(opts.repo, opts.branchName, opts.fetchTimeoutMs)
827
+ : { ok: true, remoteSha: null };
715
828
  let reuseRemoteSha = null;
716
- if (opts.reuseBranch) {
717
- const reused = await fetchReusedBranch(opts.repo, opts.branchName, opts.fetchTimeoutMs);
718
- if (!reused.ok) {
719
- return { kind: "base_unavailable", reason: reused.reason };
720
- }
721
- reuseRemoteSha = reused.remoteSha;
829
+ let existing = await findWorktreeForBranch(opts.repo, opts.branchName);
830
+ if (existing && !fs.existsSync(existing)) {
831
+ // A registered checkout whose directory is gone is a stale administrative entry
832
+ // (pre-NOT-280 this was pruned before the lookup): prune it and look again.
833
+ await pruneWorktrees(opts.repo);
834
+ existing = await findWorktreeForBranch(opts.repo, opts.branchName);
722
835
  }
723
- const existing = await findWorktreeForBranch(opts.repo, opts.branchName);
724
836
  if (existing) {
725
837
  const managedRoot = opts.worktreePath
726
838
  ? tryRealpath(path.dirname(opts.worktreePath))
727
839
  : tryRealpath(worktreesRoot(opts.repo));
728
840
  const underRoot = tryRealpath(existing).startsWith(managedRoot + path.sep);
729
841
  if (!underRoot) {
842
+ const reason = `Branch ${opts.branchName} is already checked out at ${existing}, outside the coordinator's managed worktrees — it cannot be safely reused or removed automatically.`;
730
843
  return {
731
844
  kind: "conflict",
732
845
  path: existing,
733
- reason: `Branch ${opts.branchName} is already checked out at ${existing}, outside the coordinator's managed worktrees — it cannot be safely reused or removed automatically.`,
846
+ reason,
734
847
  recoveryCommands: [`git -C ${opts.repo} worktree list`, `# free the branch, then Resume: cd ${existing} && git status`],
848
+ fingerprint: await leftoverFingerprint(existing, reason),
735
849
  };
736
850
  }
737
851
  // Live owner first — clean/dirty are only meaningful once the predecessor is gone.
@@ -744,8 +858,45 @@ export async function resolveDeveloperWorktree(opts) {
744
858
  reason: `Developer worktree for branch ${opts.branchName} at ${existing} is still in use by session ${owner.sessionId} (live process) — refusing to adopt it or escalate as a worktree conflict.`,
745
859
  };
746
860
  }
747
- const state = await inspectLeftoverWorktree(existing);
861
+ // NOT-280: only a known Dealer session proven dead may have its dirt salvaged.
862
+ const deadPredecessor = owner.state === "dead" ? (owner.sessionId ?? null) : null;
863
+ let state = await inspectLeftoverWorktree(existing);
864
+ let salvage;
865
+ let salvageFailure = null;
866
+ if (state === "dirty_or_unpushed" && deadPredecessor) {
867
+ const salvaged = await salvagePredecessorWorktree(existing, opts.branchName);
868
+ if (salvaged.ok) {
869
+ salvage = {
870
+ commitSha: salvaged.commitSha,
871
+ message: salvaged.message,
872
+ predecessorSessionId: deadPredecessor,
873
+ recovered: false,
874
+ };
875
+ state = await inspectLeftoverWorktree(existing);
876
+ }
877
+ else {
878
+ salvageFailure = salvaged.reason;
879
+ }
880
+ }
881
+ else if (state === "clean" && deadPredecessor) {
882
+ // Restart after the salvage commit landed but before the attempt continued: the
883
+ // saved tip is detected, never re-committed.
884
+ const landed = await headIsResumeSalvage(existing);
885
+ if (landed) {
886
+ salvage = {
887
+ commitSha: landed,
888
+ message: SALVAGE_RESUME_MESSAGE,
889
+ predecessorSessionId: deadPredecessor,
890
+ recovered: true,
891
+ };
892
+ }
893
+ }
748
894
  if (state === "clean") {
895
+ const reused = await fetchReuse();
896
+ if (!reused.ok) {
897
+ return { kind: "base_unavailable", reason: reused.reason };
898
+ }
899
+ reuseRemoteSha = reused.remoteSha;
749
900
  // NOT-219: a clean leftover holding a stale local branch advances to the
750
901
  // fetched remote tip when that is a strict fast-forward (nothing unique to
751
902
  // lose — the local tip is already an ancestor of the remote one). Any other
@@ -758,19 +909,26 @@ export async function resolveDeveloperWorktree(opts) {
758
909
  await git(existing, ["merge", "--ff-only", "-q", `origin/${opts.branchName}`]).catch(() => null);
759
910
  }
760
911
  }
761
- return { kind: "reused", path: tryRealpath(existing) };
912
+ return { kind: "reused", path: tryRealpath(existing), ...(salvage ? { salvage } : {}) };
762
913
  }
763
914
  if (state === "dirty_or_unpushed") {
915
+ const base = `A previous developer worktree for branch ${opts.branchName} still holds it at ${existing} with uncommitted changes.`;
916
+ const reason = salvageFailure
917
+ ? `${base} Automatic salvage of dead session ${deadPredecessor}'s work failed and the checkout was preserved (not removed or reset): ${salvageFailure}`
918
+ : salvage
919
+ ? `${base} Salvage tip ${salvage.commitSha.slice(0, 7)} landed but the checkout is still dirty.`
920
+ : base;
764
921
  return {
765
922
  kind: "conflict",
766
923
  path: existing,
767
- reason: `A previous developer worktree for branch ${opts.branchName} still holds it at ${existing} with uncommitted changes.`,
924
+ reason,
768
925
  recoveryCommands: [
769
926
  `cd ${existing}`,
770
927
  "git status",
771
928
  "git log --oneline -5",
772
929
  `# once the work there is safe (pushed or intentionally discarded): git -C ${opts.repo} worktree remove ${existing} --force`,
773
930
  ],
931
+ fingerprint: await leftoverFingerprint(existing, reason),
774
932
  };
775
933
  }
776
934
  // inspectLeftoverWorktree reports "missing" for ANY `git status` failure, not just a
@@ -780,23 +938,34 @@ export async function resolveDeveloperWorktree(opts) {
780
938
  // below — a narrower repeat of the exact loop this function exists to close). Only a
781
939
  // truly gone directory is safe to treat as a stale administrative entry.
782
940
  if (fs.existsSync(existing)) {
941
+ const reason = `A previous developer worktree for branch ${opts.branchName} exists at ${existing} but its status could not be determined.`;
783
942
  return {
784
943
  kind: "conflict",
785
944
  path: existing,
786
- reason: `A previous developer worktree for branch ${opts.branchName} exists at ${existing} but its status could not be determined.`,
945
+ reason,
787
946
  recoveryCommands: [
788
947
  `cd ${existing}`,
789
948
  "git status",
790
949
  `# once resolved: git -C ${opts.repo} worktree remove ${existing} --force`,
791
950
  ],
951
+ fingerprint: await leftoverFingerprint(existing, reason),
792
952
  };
793
953
  }
794
- await pruneWorktrees(opts.repo);
795
954
  }
955
+ // Clear stale registrations (e.g. a vanished checkout at the target path) before the
956
+ // add — every refusal path above has already returned without mutating anything.
957
+ await pruneWorktrees(opts.repo);
796
958
  // NOT-197: a fresh issue branch starts from the freshly fetched
797
959
  // `origin/<baseBranch>` tip — never the cached clone's possibly-stale local base.
798
960
  // A failed fetch returns `base_unavailable` (no branch is created) instead of
799
961
  // silently falling back to the stale local branch.
962
+ if (opts.reuseBranch) {
963
+ const reused = await fetchReuse();
964
+ if (!reused.ok) {
965
+ return { kind: "base_unavailable", reason: reused.reason };
966
+ }
967
+ reuseRemoteSha = reused.remoteSha;
968
+ }
800
969
  let ref;
801
970
  let newBranch;
802
971
  let baseSha = null;