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
@@ -823,6 +823,299 @@ test("NOT-127: resolveDeveloperWorktree still escalates a dirty leftover when th
823
823
  fs.rmSync(leftover.path, { recursive: true, force: true });
824
824
  await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
825
825
  });
826
+ // ---------------------------------------------------------------- NOT-280: dead-predecessor salvage
827
+ async function dropWorktree(p) {
828
+ fs.rmSync(p, { recursive: true, force: true });
829
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
830
+ }
831
+ /** A dirty managed leftover on its own branch: one tracked edit + one untracked file. */
832
+ async function dirtyLeftover(branch, sessionId) {
833
+ git(repo, "branch", branch, "main");
834
+ const leftover = await createRoleWorktree({ repo, role: "developer", sessionId, ref: branch });
835
+ fs.writeFileSync(path.join(leftover.path, "README.md"), "hello\nedited by dead worker\n");
836
+ fs.writeFileSync(path.join(leftover.path, "new-file.txt"), "untracked ticket work\n");
837
+ return leftover;
838
+ }
839
+ function resolveFor(branch, sessionId, liveness) {
840
+ return resolveDeveloperWorktree({
841
+ repo,
842
+ sessionId,
843
+ branchName: branch,
844
+ baseBranch: "main",
845
+ reuseBranch: true,
846
+ ownerLiveness: () => liveness,
847
+ });
848
+ }
849
+ test("NOT-280: a proven-dead predecessor's dirty checkout is salvaged once and reused from the saved tip", async () => {
850
+ const { SALVAGE_RESUME_MESSAGE } = await import("./git-worktree.js");
851
+ const leftover = await dirtyLeftover("issue-280-salvage", "s-280-dead");
852
+ const before = git(leftover.path, "rev-parse", "HEAD");
853
+ const resolved = await resolveFor("issue-280-salvage", "s-280-next", { state: "dead", sessionId: "s-280-dead" });
854
+ assert.equal(resolved.kind, "reused");
855
+ if (resolved.kind !== "reused")
856
+ return;
857
+ assert.equal(resolved.path, leftover.path);
858
+ assert.ok(resolved.salvage, "salvage evidence is reported");
859
+ assert.equal(resolved.salvage.predecessorSessionId, "s-280-dead");
860
+ assert.equal(resolved.salvage.recovered, false);
861
+ assert.equal(resolved.salvage.message, SALVAGE_RESUME_MESSAGE);
862
+ const head = git(leftover.path, "rev-parse", "HEAD");
863
+ assert.equal(head, resolved.salvage.commitSha);
864
+ assert.equal(git(leftover.path, "rev-parse", "refs/heads/issue-280-salvage"), head, "the tip is on the issue branch");
865
+ assert.equal(git(repo, "rev-list", "--count", `${before}..${head}`), "1", "exactly one salvage commit");
866
+ const files = git(leftover.path, "show", "--name-only", "--format=", head).split("\n").sort();
867
+ assert.deepEqual(files, ["README.md", "new-file.txt"], "tracked and untracked work both land");
868
+ assert.equal(await isWorktreeClean(leftover.path), true);
869
+ // Restart after the commit landed, before the attempt continued: the saved tip is
870
+ // detected and never re-committed.
871
+ const again = await resolveFor("issue-280-salvage", "s-280-next-2", { state: "dead", sessionId: "s-280-dead" });
872
+ assert.equal(again.kind, "reused");
873
+ if (again.kind === "reused") {
874
+ assert.equal(again.salvage?.commitSha, head);
875
+ assert.equal(again.salvage?.recovered, true);
876
+ }
877
+ assert.equal(git(leftover.path, "rev-parse", "HEAD"), head, "no duplicate commit");
878
+ await dropWorktree(leftover.path);
879
+ });
880
+ /**
881
+ * Publish `branch` to origin with one extra commit the managed clone has never seen, so any
882
+ * `git fetch` during a refused resolution would leave a remote-tracking ref, a new object, and
883
+ * a rewritten FETCH_HEAD behind in the shared repository.
884
+ */
885
+ function publishRemoteOnlyCommit(branch) {
886
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-280-remote-"));
887
+ try {
888
+ execFileSync("git", ["clone", "-q", remote, other]);
889
+ git(other, "config", "user.email", "t@example.com");
890
+ git(other, "config", "user.name", "t");
891
+ git(other, "checkout", "-q", "-b", branch, git(repo, "rev-parse", "main"));
892
+ fs.writeFileSync(path.join(other, "remote-only.txt"), branch);
893
+ git(other, "add", "remote-only.txt");
894
+ git(other, "commit", "-q", "-m", "remote-only");
895
+ git(other, "push", "-q", "origin", `${branch}:refs/heads/${branch}`);
896
+ return git(other, "rev-parse", "HEAD");
897
+ }
898
+ finally {
899
+ fs.rmSync(other, { recursive: true, force: true });
900
+ }
901
+ }
902
+ /**
903
+ * Shared-repository state a refused resolution must not change: tracking ref, object store,
904
+ * FETCH_HEAD (a fetch), and the `.git/worktrees` administrative entries (a prune).
905
+ */
906
+ function sharedRepoSnapshot(branch, remoteSha) {
907
+ const gitDir = git(repo, "rev-parse", "--absolute-git-dir");
908
+ const fetchHead = path.join(gitDir, "FETCH_HEAD");
909
+ const adminDir = path.join(gitDir, "worktrees");
910
+ let trackingRef = null;
911
+ try {
912
+ trackingRef = git(repo, "rev-parse", "--verify", "-q", `refs/remotes/origin/${branch}`);
913
+ }
914
+ catch {
915
+ trackingRef = null;
916
+ }
917
+ let hasRemoteObject = true;
918
+ try {
919
+ git(repo, "cat-file", "-e", remoteSha);
920
+ }
921
+ catch {
922
+ hasRemoteObject = false;
923
+ }
924
+ return {
925
+ trackingRef,
926
+ hasRemoteObject,
927
+ fetchHead: fs.existsSync(fetchHead) ? fs.readFileSync(fetchHead, "utf8") : null,
928
+ localRef: git(repo, "rev-parse", `refs/heads/${branch}`),
929
+ worktreeAdmin: fs.existsSync(adminDir) ? fs.readdirSync(adminDir).sort() : [],
930
+ };
931
+ }
932
+ /** A registered worktree whose directory is gone — exactly what `git worktree prune` deletes. */
933
+ function staleAdminEntry(branch) {
934
+ const stale = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-280-stale-")), "gone");
935
+ execFileSync("git", ["worktree", "add", "-q", "-b", branch, stale, "main"], { cwd: repo });
936
+ fs.rmSync(path.dirname(stale), { recursive: true, force: true });
937
+ return path.basename(stale);
938
+ }
939
+ test("NOT-280: live owner, unknown owner, and outside-root checkouts are never mutated", async () => {
940
+ const leftover = await dirtyLeftover("issue-280-live", "s-280-live");
941
+ const staleEntry = staleAdminEntry("issue-280-stale-admin");
942
+ const remoteSha = publishRemoteOnlyCommit("issue-280-live");
943
+ const head = git(leftover.path, "rev-parse", "HEAD");
944
+ const status = git(leftover.path, "status", "--porcelain");
945
+ const shared = sharedRepoSnapshot("issue-280-live", remoteSha);
946
+ assert.equal(shared.trackingRef, null, "precondition: the remote tip has never been fetched");
947
+ assert.equal(shared.hasRemoteObject, false, "precondition: the remote-only object is absent");
948
+ assert.ok(shared.worktreeAdmin.includes(staleEntry), "precondition: a prunable admin entry exists");
949
+ const live = await resolveFor("issue-280-live", "s-280-live-next", { state: "alive", sessionId: "s-280-live" });
950
+ assert.equal(live.kind, "live_owner");
951
+ const unknown = await resolveFor("issue-280-live", "s-280-live-next", { state: "dead" });
952
+ assert.equal(unknown.kind, "conflict");
953
+ assert.equal(git(leftover.path, "rev-parse", "HEAD"), head);
954
+ assert.equal(git(leftover.path, "status", "--porcelain"), status);
955
+ assert.deepEqual(sharedRepoSnapshot("issue-280-live", remoteSha), shared, "no fetch or prune: refs, objects, FETCH_HEAD, .git/worktrees untouched");
956
+ fs.rmSync(leftover.path, { recursive: true, force: true });
957
+ const external = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-280-external-"));
958
+ git(repo, "branch", "issue-280-external", "main");
959
+ const extRemoteSha = publishRemoteOnlyCommit("issue-280-external");
960
+ execFileSync("git", ["worktree", "add", external, "issue-280-external"], { cwd: repo });
961
+ try {
962
+ fs.writeFileSync(path.join(external, "outside.txt"), "not ours\n");
963
+ const extHead = git(external, "rev-parse", "HEAD");
964
+ const extShared = sharedRepoSnapshot("issue-280-external", extRemoteSha);
965
+ const resolved = await resolveFor("issue-280-external", "s-280-ext", { state: "dead", sessionId: "s-280-ext-owner" });
966
+ assert.equal(resolved.kind, "conflict");
967
+ assert.equal(git(external, "rev-parse", "HEAD"), extHead);
968
+ assert.equal(git(external, "status", "--porcelain"), "?? outside.txt");
969
+ assert.deepEqual(sharedRepoSnapshot("issue-280-external", extRemoteSha), extShared);
970
+ assert.ok(extShared.worktreeAdmin.includes(staleEntry), "stale admin entry survives the outside-root refusal");
971
+ }
972
+ finally {
973
+ execFileSync("git", ["worktree", "remove", "--force", external], { cwd: repo });
974
+ }
975
+ await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
976
+ });
977
+ test("NOT-280: salvagePredecessorWorktree refuses a checkout whose branch identity does not match, without mutating it", async () => {
978
+ const { salvagePredecessorWorktree } = await import("./git-worktree.js");
979
+ const leftover = await dirtyLeftover("issue-280-identity", "s-280-identity");
980
+ const head = git(leftover.path, "rev-parse", "HEAD");
981
+ const status = git(leftover.path, "status", "--porcelain");
982
+ const result = await salvagePredecessorWorktree(leftover.path, "issue-280-some-other-issue");
983
+ assert.equal(result.ok, false);
984
+ if (!result.ok)
985
+ assert.match(result.reason, /not refs\/heads\/issue-280-some-other-issue/);
986
+ assert.equal(git(leftover.path, "rev-parse", "HEAD"), head);
987
+ assert.equal(git(leftover.path, "status", "--porcelain"), status);
988
+ await dropWorktree(leftover.path);
989
+ });
990
+ /**
991
+ * Commit a tracked `stat.txt` in `worktree`, refresh the index, then bump the file's mtime
992
+ * without changing its content: the index entry is now stat-stale, so a plain `git status`
993
+ * would rewrite the index. Returns a reader of the linked worktree's raw index bytes.
994
+ */
995
+ function staleStatIndex(worktree) {
996
+ const file = path.join(worktree, "stat.txt");
997
+ fs.writeFileSync(file, "unchanged content\n");
998
+ git(worktree, "add", "stat.txt");
999
+ git(worktree, "commit", "-q", "-m", "stat fixture", "--", "stat.txt");
1000
+ fs.utimesSync(file, new Date("2021-01-01T00:00:00Z"), new Date("2021-01-01T00:00:00Z"));
1001
+ git(worktree, "update-index", "-q", "--refresh");
1002
+ fs.utimesSync(file, new Date("2022-01-01T00:00:00Z"), new Date("2022-01-01T00:00:00Z"));
1003
+ const indexPath = path.resolve(worktree, git(worktree, "rev-parse", "--git-path", "index"));
1004
+ return () => fs.readFileSync(indexPath);
1005
+ }
1006
+ /** Proves the fixture is meaningful: an ordinary `git status` does rewrite this index. */
1007
+ function assertPlainStatusRewrites(worktree, readIndex, untouched) {
1008
+ git(worktree, "status", "--porcelain");
1009
+ assert.notDeepEqual(readIndex(), untouched, "fixture: plain git status refreshes the stale stat entry");
1010
+ }
1011
+ test("NOT-280: refusals never refresh a stale-stat index (outside root, wrong branch, unknown owner)", async () => {
1012
+ const { salvagePredecessorWorktree } = await import("./git-worktree.js");
1013
+ // Wrong branch identity: refused before any status read.
1014
+ const wrong = await dirtyLeftover("issue-280-stat-identity", "s-280-stat-identity");
1015
+ const wrongIndex = staleStatIndex(wrong.path);
1016
+ const wrongBefore = wrongIndex();
1017
+ const refused = await salvagePredecessorWorktree(wrong.path, "issue-280-stat-other");
1018
+ assert.equal(refused.ok, false);
1019
+ assert.deepEqual(wrongIndex(), wrongBefore, "wrong-branch refusal left the index byte-identical");
1020
+ assertPlainStatusRewrites(wrong.path, wrongIndex, wrongBefore);
1021
+ await dropWorktree(wrong.path);
1022
+ // Dirty leftover with no known owner: conflict + fingerprint, no index write.
1023
+ const unknown = await dirtyLeftover("issue-280-stat-unknown", "s-280-stat-unknown");
1024
+ const unknownIndex = staleStatIndex(unknown.path);
1025
+ const unknownBefore = unknownIndex();
1026
+ const unknownResolved = await resolveFor("issue-280-stat-unknown", "s-280-stat-unknown-next", { state: "dead" });
1027
+ assert.equal(unknownResolved.kind, "conflict");
1028
+ assert.deepEqual(unknownIndex(), unknownBefore, "unknown-owner refusal left the index byte-identical");
1029
+ assertPlainStatusRewrites(unknown.path, unknownIndex, unknownBefore);
1030
+ await dropWorktree(unknown.path);
1031
+ // Outside the managed root: conflict + fingerprint, no index write.
1032
+ const external = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-280-stat-external-"));
1033
+ git(repo, "branch", "issue-280-stat-external", "main");
1034
+ execFileSync("git", ["worktree", "add", "-q", external, "issue-280-stat-external"], { cwd: repo });
1035
+ try {
1036
+ git(external, "config", "user.email", "t@example.com");
1037
+ git(external, "config", "user.name", "t");
1038
+ const extIndex = staleStatIndex(external);
1039
+ const extBefore = extIndex();
1040
+ const resolved = await resolveFor("issue-280-stat-external", "s-280-stat-ext", { state: "dead", sessionId: "s-280-stat-ext-owner" });
1041
+ assert.equal(resolved.kind, "conflict");
1042
+ if (resolved.kind === "conflict")
1043
+ assert.match(resolved.reason, /outside the coordinator/);
1044
+ assert.deepEqual(extIndex(), extBefore, "outside-root refusal left the index byte-identical");
1045
+ assertPlainStatusRewrites(external, extIndex, extBefore);
1046
+ }
1047
+ finally {
1048
+ execFileSync("git", ["worktree", "remove", "--force", external], { cwd: repo });
1049
+ }
1050
+ });
1051
+ test("NOT-280: a rejecting commit hook leaves the checkout intact and names the git failure; the blocker fingerprint is stable", async () => {
1052
+ const leftover = await dirtyLeftover("issue-280-hook", "s-280-hook");
1053
+ // Partially staged state must survive the failed attempt exactly.
1054
+ git(leftover.path, "add", "README.md");
1055
+ const head = git(leftover.path, "rev-parse", "HEAD");
1056
+ const status = git(leftover.path, "status", "--porcelain");
1057
+ const hooksDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-280-hooks-"));
1058
+ fs.writeFileSync(path.join(hooksDir, "pre-commit"), "#!/bin/sh\necho 'policy hook rejects this commit' >&2\nexit 1\n", { mode: 0o755 });
1059
+ git(repo, "config", "core.hooksPath", hooksDir);
1060
+ try {
1061
+ const first = await resolveFor("issue-280-hook", "s-280-hook-next", { state: "dead", sessionId: "s-280-hook" });
1062
+ assert.equal(first.kind, "conflict");
1063
+ if (first.kind !== "conflict")
1064
+ return;
1065
+ assert.match(first.reason, /policy hook rejects this commit/);
1066
+ assert.match(first.reason, /git .*commit/);
1067
+ assert.ok(first.reason.includes(leftover.path));
1068
+ assert.ok(first.fingerprint);
1069
+ assert.equal(git(leftover.path, "rev-parse", "HEAD"), head);
1070
+ assert.equal(git(leftover.path, "status", "--porcelain"), status, "files and index left exactly as found");
1071
+ const second = await resolveFor("issue-280-hook", "s-280-hook-next-2", { state: "dead", sessionId: "s-280-hook" });
1072
+ assert.equal(second.kind, "conflict");
1073
+ if (second.kind === "conflict")
1074
+ assert.equal(second.fingerprint, first.fingerprint, "unchanged blocker → same fingerprint");
1075
+ fs.writeFileSync(path.join(leftover.path, "operator-touch.txt"), "changed\n");
1076
+ const third = await resolveFor("issue-280-hook", "s-280-hook-next-3", { state: "dead", sessionId: "s-280-hook" });
1077
+ if (third.kind === "conflict")
1078
+ assert.notEqual(third.fingerprint, first.fingerprint, "changed state → new fingerprint");
1079
+ }
1080
+ finally {
1081
+ git(repo, "config", "--unset", "core.hooksPath");
1082
+ fs.rmSync(hooksDir, { recursive: true, force: true });
1083
+ }
1084
+ await dropWorktree(leftover.path);
1085
+ });
1086
+ test("NOT-280: a failed index rollback after a rejected salvage commit is reported, and the checkout is preserved", async () => {
1087
+ const leftover = await dirtyLeftover("issue-280-rollback", "s-280-rollback");
1088
+ const head = git(leftover.path, "rev-parse", "HEAD");
1089
+ const worktreeGitDir = git(leftover.path, "rev-parse", "--absolute-git-dir");
1090
+ const hooksDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-280-rollback-hooks-"));
1091
+ // The hook rejects the commit AND leaves the worktree's git dir unwritable, so the
1092
+ // salvage's `git read-tree` cannot take index.lock to restore the pre-salvage index.
1093
+ fs.writeFileSync(path.join(hooksDir, "pre-commit"), `#!/bin/sh\nchmod a-w '${worktreeGitDir}'\necho 'hook rejects and locks the index' >&2\nexit 1\n`, { mode: 0o755 });
1094
+ git(repo, "config", "core.hooksPath", hooksDir);
1095
+ try {
1096
+ const resolved = await resolveFor("issue-280-rollback", "s-280-rollback-next", { state: "dead", sessionId: "s-280-rollback" });
1097
+ assert.equal(resolved.kind, "conflict");
1098
+ if (resolved.kind !== "conflict")
1099
+ return;
1100
+ assert.match(resolved.reason, /hook rejects and locks the index/, "names the commit failure");
1101
+ assert.match(resolved.reason, /restoring the index afterwards also failed \(git read-tree [0-9a-f]{40}\)/, "names the failing cleanup step");
1102
+ assert.match(resolved.reason, /index\.lock/, "carries the read-tree stderr");
1103
+ assert.match(resolved.reason, /staging remains/);
1104
+ assert.ok(resolved.reason.includes(leftover.path));
1105
+ assert.doesNotMatch(resolved.reason, /left intact/);
1106
+ assert.ok(fs.existsSync(leftover.path), "checkout preserved, not removed");
1107
+ assert.equal(git(leftover.path, "rev-parse", "HEAD"), head, "no commit landed");
1108
+ assert.equal(fs.readFileSync(path.join(leftover.path, "README.md"), "utf8"), "hello\nedited by dead worker\n");
1109
+ assert.equal(fs.readFileSync(path.join(leftover.path, "new-file.txt"), "utf8"), "untracked ticket work\n");
1110
+ }
1111
+ finally {
1112
+ fs.chmodSync(worktreeGitDir, 0o755);
1113
+ fs.rmSync(path.join(worktreeGitDir, "index.lock"), { force: true });
1114
+ git(repo, "config", "--unset", "core.hooksPath");
1115
+ fs.rmSync(hooksDir, { recursive: true, force: true });
1116
+ }
1117
+ await dropWorktree(leftover.path);
1118
+ });
826
1119
  // ---------------------------------------------------------------- NOT-197: fresh base
827
1120
  /** An isolated repo + file remote pair, so tests can move or break origin without
828
1121
  * touching the module-level fixture every other test shares. */
@@ -22,7 +22,6 @@
22
22
  import { execFileSync } from "node:child_process";
23
23
  import { createHash, randomUUID } from "node:crypto";
24
24
  import fs from "node:fs";
25
- import os from "node:os";
26
25
  import path from "node:path";
27
26
  import { DEVELOPER_ROLE_CEILING } from "@agent-dealer/shared";
28
27
  import { MUSE_CLI_ENV, resolveMuseBin } from "../cli-env.js";
@@ -195,19 +194,6 @@ export function museCapabilityIssues(version, onSettled = () => { }) {
195
194
  return issueFor(s.lastChecked, from); // incl. error retry: keep the block up
196
195
  return verifyingIssue(version, from);
197
196
  }
198
- /**
199
- * NOT-277: the Muse version developer work may run on outside the on-disk binary (i.e. on the
200
- * long-lived serve host). `undefined` when no version has been observed yet — nothing to bind to;
201
- * `null` when the reported version is not confirmed capable (only the on-disk binary, which
202
- * admission gates on, may run work then); otherwise the reported, confirmed-capable version.
203
- */
204
- export function museVersionRequiredForWork() {
205
- const s = loadState();
206
- if (!s.current)
207
- return undefined;
208
- const checked = s.lastChecked;
209
- return checked?.status === "capable" && checked.version === s.current.version ? checked.version : null;
210
- }
211
197
  /** True while a capability check is running (callers use a short health-cache TTL meanwhile). */
212
198
  export function museCapabilityCheckInFlight() {
213
199
  return inFlight !== null;
@@ -261,26 +247,67 @@ function reportedMuseVersion() {
261
247
  return null;
262
248
  }
263
249
  }
264
- /**
265
- * One real developer session (same posture and model a developer round uses) in a throwaway
266
- * git repo. Always a fresh `muse exec` of the on-disk binary — never the shared serve host, which
267
- * may still be the pre-update build — and the binary must still report `version` afterwards, so
268
- * the verdict is about the build that was probed. Its only path to success is running `sh probe.sh`, which writes a git blob hash of a
269
- * fresh nonce to result.txt — the model cannot produce that value without a shell call, and the
270
- * file only exists if the shell could write the workspace.
271
- */
272
250
  export async function defaultMuseCapabilityProbe(version, opts = {}) {
273
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-capability-"));
251
+ const [{ runMuseDeveloperSession }, { getAgentDeckMcpUrl, checkAgentDeckHealth, fetchDecks }] = await Promise.all([
252
+ import("../coordinator/muse-spawn.js"),
253
+ import("./agent-deck.js"),
254
+ ]);
255
+ const { verifyWorkerDeckConnection } = await import("./agent-deck-bind.js");
256
+ const listDecks = opts.listDecks ?? fetchDecks;
257
+ const verifyDeck = opts.verifyDeck ??
258
+ ((args) => verifyWorkerDeckConnection({ deckId: args.deckId, worktreePath: args.worktreePath, playbookIds: [] }));
259
+ // Decouple the paid capability check from deck reachability: when the deck is down, fail
260
+ // closed here — before any child exists — instead of burning a model session that could
261
+ // only fail on its required deck server. The deck gate (`deck_offline`) already blocks
262
+ // admission meanwhile, and the error-retry path re-runs this cheap check, not a turn.
263
+ if (!(await checkAgentDeckHealth())) {
264
+ return {
265
+ status: "error",
266
+ detail: `Agent Deck is unreachable — capability check for ${version} not run (no model session spent)`,
267
+ };
268
+ }
269
+ const scratchParent = path.join(getDataDir(), ".temporal");
270
+ fs.mkdirSync(scratchParent, { recursive: true });
271
+ const dir = fs.mkdtempSync(path.join(scratchParent, "muse-capability-"));
274
272
  try {
275
273
  execFileSync("git", ["init", "-q"], { cwd: dir, stdio: "ignore" });
276
274
  const nonce = randomUUID();
277
275
  fs.writeFileSync(path.join(dir, "probe.sh"), `#!/bin/sh\nprintf '%s' '${nonce}' | git hash-object --stdin > result.txt\n`);
278
- const { runMuseDeveloperSession } = await import("../coordinator/muse-spawn.js");
276
+ // The probe session must bind a real deck: a synthetic id could never pass the
277
+ // production `get_bound_deck` identity check, so it would either burn a paid turn
278
+ // that only fails on its required deck server, or run on an unselected deck. List
279
+ // the live decks and preflight the bound one before spawning — a deck that answers
280
+ // but rejects this id fails closed here, with no model session spent.
281
+ const listed = await listDecks();
282
+ if (!listed.ok) {
283
+ return {
284
+ status: "error",
285
+ detail: `Agent Deck deck list unavailable (${listed.message}) — capability check for ${version} not run (no model session spent)`,
286
+ };
287
+ }
288
+ const probeDeckId = listed.decks[0]?.id;
289
+ if (!probeDeckId) {
290
+ return {
291
+ status: "error",
292
+ detail: `Agent Deck listed no decks — capability check for ${version} not run (no model session spent)`,
293
+ };
294
+ }
295
+ const verified = await verifyDeck({ deckId: probeDeckId, worktreePath: dir });
296
+ if (!verified.ok) {
297
+ return {
298
+ status: "error",
299
+ detail: verified.kind === "deck_unavailable"
300
+ ? `Agent Deck is unreachable for deck ${probeDeckId} (${verified.reason}) — capability check for ${version} not run (no model session spent)`
301
+ : `Agent Deck rejected probe deck ${probeDeckId} (${verified.reason}) — capability check for ${version} not run (no model session spent)`,
302
+ };
303
+ }
279
304
  const run = await runMuseDeveloperSession({
280
305
  sessionId: randomUUID(),
281
306
  runtime: "muse_code",
282
307
  policy: DEVELOPER_ROLE_CEILING,
283
308
  model: null,
309
+ deckId: probeDeckId,
310
+ agentDeckUrl: `${getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "")}/mcp`,
284
311
  maxModelSteps: PROBE_MAX_MODEL_STEPS,
285
312
  prompt: "Dealer capability check. Using your shell tool, run exactly this command in the current " +
286
313
  "directory: sh probe.sh\nDo not create or edit result.txt any other way. When the command " +
@@ -288,7 +315,6 @@ export async function defaultMuseCapabilityProbe(version, opts = {}) {
288
315
  cwd: dir,
289
316
  timeoutMs: opts.timeoutMs ?? PROBE_TIMEOUT_MS,
290
317
  logPath: path.join(dir, "probe.ndjson"),
291
- execLaneOnly: true,
292
318
  });
293
319
  const after = reportedMuseVersion();
294
320
  if (after !== version) {