agent-dealer 1.2.4 → 1.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +28 -6
  4. package/bundle/server/dist/adapters/agent-health.test.js +66 -11
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/github.js +110 -12
  8. package/bundle/server/dist/adapters/github.test.js +274 -3
  9. package/bundle/server/dist/adapters/muse-capability.js +356 -0
  10. package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
  11. package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
  12. package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
  13. package/bundle/server/dist/capacity/muse-host.js +92 -32
  14. package/bundle/server/dist/capacity/muse-host.test.js +130 -4
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/args.js +4 -3
  17. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  18. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  19. package/bundle/server/dist/coordinator/commands.js +100 -12
  20. package/bundle/server/dist/coordinator/developer-effect.js +128 -16
  21. package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
  22. package/bundle/server/dist/coordinator/human-resolution.js +33 -1
  23. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  24. package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
  25. package/bundle/server/dist/coordinator/projection.js +1 -0
  26. package/bundle/server/dist/coordinator/prompts.js +25 -6
  27. package/bundle/server/dist/coordinator/prompts.test.js +36 -8
  28. package/bundle/server/dist/coordinator/routing.js +1 -0
  29. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  30. package/bundle/server/dist/coordinator/spawn.js +9 -1
  31. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  32. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  33. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  34. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  35. package/bundle/server/dist/repository/human-actions.js +13 -0
  36. package/bundle/server/dist/routes/human-actions.js +10 -1
  37. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  38. package/bundle/server/dist/routes/index.js +12 -5
  39. package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
  40. package/bundle/server/dist/routes/version.js +30 -0
  41. package/bundle/server/dist/routes/version.test.js +21 -0
  42. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  43. package/bundle/server/dist/runners/muse-config.js +1 -1
  44. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  45. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  46. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  47. package/bundle/server/package.json +2 -2
  48. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  49. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
  50. package/bundle/server/static-ui/index.html +2 -2
  51. package/bundle/shared/dist/agents.d.ts +15 -15
  52. package/bundle/shared/dist/agents.js +6 -0
  53. package/bundle/shared/dist/index.d.ts +7 -7
  54. package/bundle/shared/package.json +1 -1
  55. package/dist/doctor.d.ts +7 -3
  56. package/dist/doctor.js +21 -13
  57. package/dist/doctor.test.js +9 -8
  58. package/dist/managed/index.d.ts +1 -1
  59. package/dist/managed/index.js +1 -1
  60. package/dist/managed/updater.d.ts +16 -6
  61. package/dist/managed/updater.js +28 -11
  62. package/dist/managed-update-restart.test.d.ts +1 -0
  63. package/dist/managed-update-restart.test.js +227 -0
  64. package/dist/ports.d.ts +6 -0
  65. package/dist/ports.js +10 -0
  66. package/dist/runtime-state.d.ts +10 -0
  67. package/dist/runtime-state.js +26 -0
  68. package/dist/start.js +67 -2
  69. package/dist/status.js +30 -2
  70. package/dist/update-check.js +3 -6
  71. package/package.json +1 -1
  72. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
@@ -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. */
@@ -70,8 +70,23 @@ export const CHECKS_FAILURE_GENERIC_REASON = "Developer's PR checks failed.";
70
70
  export const CHECKS_EVIDENCE_MAX_FAILED_CHECKS = 10;
71
71
  /** Max distinct Actions runs whose logs are fetched (one `gh run view` per run). */
72
72
  export const CHECKS_EVIDENCE_MAX_RUNS = 5;
73
- /** Per-run cap on fetched log text before excerpt focusing (tail kept — failures surface last). */
74
- export const CHECKS_EVIDENCE_MAX_LOG_CHARS_PER_RUN = 20_000;
73
+ /**
74
+ * NOT-276: raw per-run ceiling (2MB) on fetched log text actually held in
75
+ * memory — purely a guard against pathologically huge logs, NOT a "search only
76
+ * the tail" window. `fetchChecksFailureEvidence` passes everything under this
77
+ * ceiling untruncated into `buildFailureExcerpt`, whose failure-pattern search
78
+ * plus the per-excerpt line/char caps already bound the output.
79
+ */
80
+ export const CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN = 2_000_000;
81
+ /**
82
+ * NOT-276 round 3: explicit `maxBuffer` (bytes) for the `gh run view --log-failed`
83
+ * fetch. Node's `execFile` defaults to ~1 MiB, which would reject any log over
84
+ * that size with ERR_CHILD_PROCESS_STDIO_MAXBUFFER before the 2MB raw-log
85
+ * ceiling above ever applies. This buffer sits above the ceiling (plus headroom
86
+ * for stderr bytes and multi-byte chars) so the ceiling — not the process
87
+ * buffer — is what bounds memory.
88
+ */
89
+ export const CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER = 4_000_000;
75
90
  /** Single global cap on the focused excerpt threaded into the retry prompt. */
76
91
  export const CHECKS_EVIDENCE_MAX_EXCERPT_CHARS = 4_000;
77
92
  /** Max lines in the focused excerpt; context lines kept around each failure line. */
@@ -148,11 +163,35 @@ export function sanitizeCiText(text) {
148
163
  return out;
149
164
  }
150
165
  const FAILURE_LINE_PATTERN = /error|err!|e404|fail|fatal|exception|traceback|assert|not found|cannot |can't |unable |conflict|reject|denied|panic|timed?\s*out|npm ERR!/i;
166
+ /**
167
+ * Explicit failure markers, not just failure-adjacent vocabulary: TAP's own `not ok`
168
+ * result line (the TAP spec's standard fail marker, used by many test harnesses beyond
169
+ * `node:test` — not a runner-specific parser) and GitHub Actions' own `##[error]`
170
+ * workflow-command annotation (emitted by the platform itself for a genuinely failed
171
+ * step, regardless of what tool ran in it). A coordinator/CI-tooling repo's own test
172
+ * suite is full of passing tests *about* failure handling — "conflict", "timeout",
173
+ * "denied", "escalates" — so `FAILURE_LINE_PATTERN` density alone is not reliable
174
+ * (NOT-276 round 3: verified against the real run-36330128633 log, a cluster of
175
+ * passing tests named around escalation/conflict/timeout out-ranked the actual
176
+ * `not ok 355`/`356` failure under pure density ranking). These two markers get a
177
+ * large ranking bonus below so a window that contains one always wins.
178
+ */
179
+ // Not anchored to line start: `gh run view --log-failed` prefixes every line with
180
+ // `<job>\t<step>\t<timestamp> ` before the actual tool output, so the TAP/annotation
181
+ // text never starts at column 0.
182
+ const STRONG_FAILURE_LINE_PATTERN = /\bnot ok\b|##\[error\]/i;
183
+ const STRONG_HIT_WEIGHT = 1000;
151
184
  /**
152
185
  * Focus a (sanitized) log around its useful failure/error lines: keep a small context
153
- * window around each matching line, merge overlapping windows, collapse long runs of
154
- * identical lines (CI setup spam), then enforce the global line/char caps. With no
155
- * matching line, the tail is the most likely failure site. Never returns unsanitized text.
186
+ * window around each matching line, merge overlapping windows, rank merged regions by
187
+ * weighted hit density (explicit failure markers far outweigh failure-adjacent
188
+ * vocabulary; ties break on density, then log order) so an early real failure cluster
189
+ * is not crowded out of the line budget by sparse isolated mentions in passing-test
190
+ * names, or by a *dense* but merely topical cluster of passing tests about failure
191
+ * handling itself (NOT-276 round 2 found the former, round 3 the latter — see
192
+ * `STRONG_FAILURE_LINE_PATTERN`'s comment), then collapse long runs of identical lines
193
+ * (CI setup spam) and enforce the global line/char caps. With no matching line, the
194
+ * tail is the most likely failure site. Never returns unsanitized text.
156
195
  */
157
196
  export function buildFailureExcerpt(combinedLog) {
158
197
  const sanitized = sanitizeCiText(combinedLog);
@@ -160,9 +199,14 @@ export function buildFailureExcerpt(combinedLog) {
160
199
  if (lines.every((l) => !l.trim()))
161
200
  return { excerpt: "", truncated: false };
162
201
  const hits = [];
202
+ const strongHits = new Set();
163
203
  lines.forEach((line, i) => {
164
- if (FAILURE_LINE_PATTERN.test(line))
204
+ const strong = STRONG_FAILURE_LINE_PATTERN.test(line);
205
+ if (strong || FAILURE_LINE_PATTERN.test(line)) {
165
206
  hits.push(i);
207
+ if (strong)
208
+ strongHits.add(i);
209
+ }
166
210
  });
167
211
  let selected;
168
212
  let truncated = false;
@@ -180,8 +224,54 @@ export function buildFailureExcerpt(combinedLog) {
180
224
  else
181
225
  merged.push([w[0], w[1]]);
182
226
  }
227
+ // Rank merged regions by weighted hit density so the excerpt budget goes to the
228
+ // most failure-indicative clusters first: an explicit failure marker (`not ok`,
229
+ // `##[error]`) counts for STRONG_HIT_WEIGHT, everything else for 1 — a window
230
+ // with one real marker always outranks a window with many topical-only hits.
231
+ // Ties keep log order, and output is re-sorted to log order for readability.
232
+ // `hits` is ascending (built in line order) and `merged` is ascending by `from`,
233
+ // so a single linear pass sums weight per region.
234
+ let hi = 0;
235
+ const ranked = merged.map(([from, to]) => {
236
+ while (hi < hits.length && hits[hi] < from)
237
+ hi++;
238
+ let weight = 0;
239
+ let k = hi;
240
+ while (k < hits.length && hits[k] <= to) {
241
+ weight += strongHits.has(hits[k]) ? STRONG_HIT_WEIGHT : 1;
242
+ k++;
243
+ }
244
+ return { from, to, weight };
245
+ });
246
+ ranked.sort((a, b) => b.weight - a.weight || a.from - b.from);
247
+ const taken = [];
248
+ let used = 0;
249
+ let usedChars = 0;
250
+ for (const w of ranked) {
251
+ const size = w.to - w.from + 1 + (taken.length > 0 ? 1 : 0);
252
+ let chars = taken.length > 0 ? 3 : 0; // "...\n" separator
253
+ for (let i = w.from; i <= w.to; i++)
254
+ chars += lines[i].length + 1;
255
+ // Skip regions that no longer fit — by line count or by character count, since
256
+ // the final excerpt is char-capped too (NOT-276 round 3: a lower-ranked but
257
+ // verbose region taken first would otherwise still crowd a higher-ranked
258
+ // region's content out of the *character* budget after chronological
259
+ // reassembly below, even though ranking correctly gave the real failure
260
+ // priority for line-budget inclusion). A smaller later region can still fill
261
+ // whatever budget remains — but always take the top-ranked region even if it
262
+ // alone exceeds either budget (the caps below truncate it, as before).
263
+ if (taken.length > 0 &&
264
+ (used + size > CHECKS_EVIDENCE_MAX_EXCERPT_LINES || usedChars + chars > CHECKS_EVIDENCE_MAX_EXCERPT_CHARS)) {
265
+ truncated = true;
266
+ continue;
267
+ }
268
+ taken.push([w.from, w.to]);
269
+ used += size;
270
+ usedChars += chars;
271
+ }
272
+ taken.sort((a, b) => a[0] - b[0]);
183
273
  const picked = [];
184
- merged.forEach(([from, to], idx) => {
274
+ taken.forEach(([from, to], idx) => {
185
275
  if (idx > 0)
186
276
  picked.push("...");
187
277
  for (let i = from; i <= to; i++)
@@ -241,7 +331,7 @@ export function formatChecksFailureDetails(opts) {
241
331
  return parts.join("\n");
242
332
  }
243
333
  const NO_COMMITS_PATTERN = /no commits between/i;
244
- const defaultExec = (args, opts) => run("gh", args, opts);
334
+ const defaultExec = (args, opts) => run("gh", args, { cwd: opts.cwd, maxBuffer: opts.maxBuffer });
245
335
  async function ghPrView(exec, cwd, fields, selector) {
246
336
  const args = ["pr", "view", ...(selector != null ? [selector] : []), "--json", fields];
247
337
  try {
@@ -354,11 +444,19 @@ export async function fetchChecksFailureEvidence(exec, opts) {
354
444
  let logsUnavailable = false;
355
445
  for (const runId of runIds) {
356
446
  try {
357
- const { stdout } = await exec(["run", "view", runId, "--log-failed"], { cwd: opts.cwd });
358
- const tail = stdout.length > CHECKS_EVIDENCE_MAX_LOG_CHARS_PER_RUN
359
- ? stdout.slice(-CHECKS_EVIDENCE_MAX_LOG_CHARS_PER_RUN)
447
+ const { stdout } = await exec(["run", "view", runId, "--log-failed"], {
448
+ cwd: opts.cwd,
449
+ maxBuffer: CHECKS_EVIDENCE_GH_LOG_MAX_BUFFER,
450
+ });
451
+ // NOT-276: search before truncating — the full fetched log feeds
452
+ // `buildFailureExcerpt`'s failure-pattern search, so an early failure is
453
+ // never discarded by a small tail window. Only logs beyond the raw memory
454
+ // ceiling are cut at all (tail kept), and the excerpt caps still bound
455
+ // the final output.
456
+ const full = stdout.length > CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN
457
+ ? stdout.slice(-CHECKS_EVIDENCE_MAX_RAW_LOG_CHARS_PER_RUN)
360
458
  : stdout;
361
- logsByRun.set(runId, tail);
459
+ logsByRun.set(runId, full);
362
460
  }
363
461
  catch {
364
462
  logsUnavailable = true;