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.
- 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 +28 -6
- package/bundle/server/dist/adapters/agent-health.test.js +66 -11
- 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/github.js +110 -12
- package/bundle/server/dist/adapters/github.test.js +274 -3
- package/bundle/server/dist/adapters/muse-capability.js +356 -0
- package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
- package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
- package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
- package/bundle/server/dist/capacity/muse-host.js +92 -32
- package/bundle/server/dist/capacity/muse-host.test.js +130 -4
- package/bundle/server/dist/coordinator/admission.test.js +85 -0
- 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 +100 -12
- package/bundle/server/dist/coordinator/developer-effect.js +128 -16
- package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
- package/bundle/server/dist/coordinator/human-resolution.js +33 -1
- package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
- package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
- package/bundle/server/dist/coordinator/projection.js +1 -0
- package/bundle/server/dist/coordinator/prompts.js +25 -6
- package/bundle/server/dist/coordinator/prompts.test.js +36 -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 +9 -1
- package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
- package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
- 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/human-actions.js +10 -1
- package/bundle/server/dist/routes/human-actions.test.js +117 -1
- package/bundle/server/dist/routes/index.js +12 -5
- package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
- 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-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
- package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/agents.d.ts +15 -15
- package/bundle/shared/dist/agents.js +6 -0
- package/bundle/shared/dist/index.d.ts +7 -7
- package/bundle/shared/package.json +1 -1
- package/dist/doctor.d.ts +7 -3
- package/dist/doctor.js +21 -13
- package/dist/doctor.test.js +9 -8
- 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
- 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
|
-
/**
|
|
74
|
-
|
|
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,
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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"], {
|
|
358
|
-
|
|
359
|
-
|
|
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,
|
|
459
|
+
logsByRun.set(runId, full);
|
|
362
460
|
}
|
|
363
461
|
catch {
|
|
364
462
|
logsUnavailable = true;
|