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.
- package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
- package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
- package/bundle/server/dist/adapters/agent-health.js +3 -4
- package/bundle/server/dist/adapters/agent-health.test.js +11 -9
- package/bundle/server/dist/adapters/git-worktree.js +187 -18
- package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
- package/bundle/server/dist/adapters/muse-capability.js +51 -25
- package/bundle/server/dist/adapters/muse-capability.test.js +138 -52
- package/bundle/server/dist/capacity/claude-local-cache.js +59 -27
- package/bundle/server/dist/capacity/claude-local-cache.test.js +163 -9
- package/bundle/server/dist/capacity/muse-host.js +34 -32
- package/bundle/server/dist/capacity/muse-host.test.js +5 -3
- package/bundle/server/dist/coordinator/args.js +4 -3
- package/bundle/server/dist/coordinator/checkpoint.js +5 -3
- package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
- package/bundle/server/dist/coordinator/commands.js +66 -5
- package/bundle/server/dist/coordinator/developer-effect.js +88 -10
- package/bundle/server/dist/coordinator/developer-effect.test.js +170 -0
- package/bundle/server/dist/coordinator/human-resolution.js +2 -0
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -174
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +10 -6
- package/bundle/server/dist/coordinator/prompts.test.js +16 -8
- package/bundle/server/dist/coordinator/routing.js +1 -0
- package/bundle/server/dist/coordinator/routing.test.js +20 -0
- package/bundle/server/dist/coordinator/spawn.js +2 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
- package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
- package/bundle/server/dist/repository/human-actions.js +13 -0
- package/bundle/server/dist/routes/index.js +11 -8
- package/bundle/server/dist/routes/version.js +30 -0
- package/bundle/server/dist/routes/version.test.js +21 -0
- package/bundle/server/dist/runners/muse-config-core.js +63 -16
- package/bundle/server/dist/runners/muse-config.js +1 -1
- package/bundle/server/dist/runners/muse-config.test.js +135 -29
- package/bundle/server/dist/runners/spawn-cli.js +17 -3
- package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-Bq8wWpZm.js → index-CoDzZcsz.js} +1 -1
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.js +4 -1
- package/dist/managed/index.d.ts +1 -1
- package/dist/managed/index.js +1 -1
- package/dist/managed/updater.d.ts +16 -6
- package/dist/managed/updater.js +28 -11
- package/dist/managed-update-restart.test.d.ts +1 -0
- package/dist/managed-update-restart.test.js +227 -0
- package/dist/ports.d.ts +6 -0
- package/dist/ports.js +10 -0
- package/dist/runtime-state.d.ts +10 -0
- package/dist/runtime-state.js +26 -0
- package/dist/start.js +67 -2
- package/dist/status.js +30 -2
- package/dist/update-check.js +3 -6
- 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
|
|
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
|
-
|
|
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) {
|