agent-dealer 1.2.9 → 1.2.11

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 (33) hide show
  1. package/bundle/server/dist/adapters/base-conflict-probe.test.js +102 -0
  2. package/bundle/server/dist/adapters/git-worktree.js +60 -5
  3. package/bundle/server/dist/adapters/git-worktree.test.js +13 -1
  4. package/bundle/server/dist/adapters/github.js +13 -0
  5. package/bundle/server/dist/adapters/github.test.js +13 -0
  6. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +815 -0
  7. package/bundle/server/dist/coordinator/auto-merge.js +16 -3
  8. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +17 -0
  9. package/bundle/server/dist/coordinator/base-advanced-scan.js +308 -0
  10. package/bundle/server/dist/coordinator/commands.js +232 -1
  11. package/bundle/server/dist/coordinator/developer-effect.js +206 -2
  12. package/bundle/server/dist/coordinator/developer-effect.test.js +490 -1
  13. package/bundle/server/dist/coordinator/merge-conflict-sync.js +278 -16
  14. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +281 -0
  15. package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
  16. package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
  17. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +5 -1
  18. package/bundle/server/dist/coordinator/routing.js +17 -0
  19. package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
  20. package/bundle/server/dist/coordinator/usage-cap-defer.js +3 -0
  21. package/bundle/server/dist/coordinator/worker-loop.js +3 -2
  22. package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
  23. package/bundle/server/dist/runners/muse-code.test.js +13 -1
  24. package/bundle/server/dist/runners/spawn-cli.js +93 -20
  25. package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
  26. package/bundle/server/package.json +2 -2
  27. package/bundle/server/static-ui/assets/{index-CU6fNntg.js → index-Cf4dipuK.js} +10 -10
  28. package/bundle/server/static-ui/index.html +1 -1
  29. package/bundle/shared/dist/workflow.d.ts +4 -4
  30. package/bundle/shared/dist/workflow.js +9 -0
  31. package/bundle/shared/dist/workflow.test.js +1 -0
  32. package/bundle/shared/package.json +1 -1
  33. package/package.json +1 -1
@@ -688,3 +688,818 @@ test("escalate txn no-ops when a racing success already marked the issue done",
688
688
  assert.equal(getIssue(issueId).status, "done");
689
689
  assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0, "must not open policy_escalation on an already-done issue");
690
690
  });
691
+ // ---------------------------------------------------------------------------
692
+ // NOT-356: after Dealer merges a PR, the other open Dealer PRs on the same repo +
693
+ // base are probed against the new base tip and the idle conflicting ones synced.
694
+ const { settleBaseAdvancedScansForTests, setBaseAdvancedProbeForTests } = await import("./base-advanced-scan.js");
695
+ const { probeBaseConflict, observeGitCommands } = await import("../adapters/git-worktree.js");
696
+ /** origin + clone with one pushed branch per sibling. A `conflict` sibling edits the
697
+ * line of shared.txt the merged PR also edits; a clean one adds its own file.
698
+ * `landMergedPr` lands that merged PR's change on origin/main. */
699
+ function initSiblingRepos(siblings) {
700
+ const g = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
701
+ const origin = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not356-origin-"));
702
+ g(origin, "init", "-q", "-b", "main");
703
+ g(origin, "config", "user.email", "test@example.com");
704
+ g(origin, "config", "user.name", "Test");
705
+ fs.writeFileSync(path.join(origin, "shared.txt"), "line1\nline2\n");
706
+ g(origin, "add", ".");
707
+ g(origin, "commit", "-q", "-m", "A base");
708
+ const local = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not356-local-"));
709
+ fs.rmSync(local, { recursive: true, force: true });
710
+ execFileSync("git", ["clone", "-q", origin, local]);
711
+ g(local, "config", "user.email", "test@example.com");
712
+ g(local, "config", "user.name", "Test");
713
+ const heads = {};
714
+ for (const s of siblings) {
715
+ g(local, "checkout", "-q", "-b", s.branch, "main");
716
+ if (s.conflict)
717
+ fs.writeFileSync(path.join(local, "shared.txt"), `line1\n${s.branch} change\n`);
718
+ else
719
+ fs.writeFileSync(path.join(local, `${s.branch}.txt`), "own file\n");
720
+ g(local, "add", ".");
721
+ g(local, "commit", "-q", "-m", `${s.branch} feature`);
722
+ g(local, "push", "-q", "-u", "origin", s.branch);
723
+ heads[s.branch] = g(local, "rev-parse", "HEAD");
724
+ g(local, "checkout", "-q", "main");
725
+ }
726
+ let landed = 0;
727
+ const landMergedPr = () => {
728
+ landed += 1;
729
+ fs.writeFileSync(path.join(origin, "shared.txt"), `line1\nmerged change ${landed}\n`);
730
+ g(origin, "add", ".");
731
+ g(origin, "commit", "-q", "-m", `merged sibling PR ${landed}`);
732
+ return g(origin, "rev-parse", "HEAD");
733
+ };
734
+ return { origin, local, heads, landMergedPr };
735
+ }
736
+ function originTip(origin, branch) {
737
+ return execFileSync("git", ["rev-parse", branch], { cwd: origin, encoding: "utf8" }).trim();
738
+ }
739
+ /** The merged issue: handed off and parked for auto-merge. Set up before siblings
740
+ * (parkForAutoMerge clears every work item). */
741
+ async function parkedMergeIn(repo) {
742
+ const issueId = newIssue({ autoMerge: true, repo });
743
+ startWorkflow(issueId);
744
+ await complete(issueId, { ...cleanHandoff, branch: "merged-pr" });
745
+ await parkForAutoMerge(issueId);
746
+ return issueId;
747
+ }
748
+ /** The developer effect's durable proof that Dealer pushed `sha` to the branch. */
749
+ async function recordDealerPush(issueId, branch, sha) {
750
+ const { appendWorkflowEvent } = await import("../repository/workflow-events.js");
751
+ appendWorkflowEvent({
752
+ issueId,
753
+ workflowInstanceId: getActiveWorkflowInstance(issueId).id,
754
+ workerSessionId: null,
755
+ type: "checkpoint.observed",
756
+ actorType: "developer",
757
+ stage: getIssue(issueId).status,
758
+ payload: { kind: "branch_pushed", observedSha: sha, branch },
759
+ });
760
+ }
761
+ /** An idle sibling: Dealer pushed `pushedSha` (default: the handed-off head), then
762
+ * handed off at `headSha`; its reviewer round still queued. */
763
+ async function idleSiblingIn(repo, branch, headSha, prNumber, pushedSha = headSha) {
764
+ const issueId = newIssue({ autoMerge: false, repo });
765
+ startWorkflow(issueId);
766
+ await recordDealerPush(issueId, branch, pushedSha);
767
+ // Hold back earlier siblings' queued reviewer rounds so the claim gets this issue's item.
768
+ const held = getDb()
769
+ .prepare("SELECT id, available_at FROM work_items WHERE issue_id != ? AND status = 'pending'")
770
+ .all(issueId);
771
+ const setAvailable = getDb().prepare("UPDATE work_items SET available_at = ? WHERE id = ?");
772
+ for (const h of held)
773
+ setAvailable.run("9999-01-01T00:00:00.000Z", h.id);
774
+ try {
775
+ await complete(issueId, { ...cleanHandoff, branch, headSha, prNumber, prUrl: `https://gh/pr/${prNumber}` });
776
+ }
777
+ finally {
778
+ for (const h of held)
779
+ setAvailable.run(h.available_at, h.id);
780
+ }
781
+ assert.equal(getIssue(issueId).status, "reviewing");
782
+ return issueId;
783
+ }
784
+ function baseAdvancedEvents(issueId) {
785
+ return listWorkflowEventsForIssue(issueId)
786
+ .filter((e) => e.type === "base.advanced")
787
+ .map((e) => JSON.parse(e.payloadJson));
788
+ }
789
+ function syncEvents(issueId) {
790
+ return listWorkflowEventsForIssue(issueId)
791
+ .filter((e) => e.type === "auto_merge.conflict_sync")
792
+ .map((e) => JSON.parse(e.payloadJson));
793
+ }
794
+ /** Every git command: the sync's seam plus git-worktree's runner (fetch/probe/worktree). */
795
+ function recordAllGit() {
796
+ const calls = [];
797
+ setConflictSyncGitExecForTests(async (args, opts) => {
798
+ calls.push(args);
799
+ return defaultSyncGitExec(args, opts);
800
+ });
801
+ const stop = observeGitCommands((args) => {
802
+ calls.push([...args]);
803
+ });
804
+ return { calls, stop };
805
+ }
806
+ function assertNoForceNoRebase(calls) {
807
+ for (const args of calls) {
808
+ assert.ok(!args.includes("rebase"), `never rebase: git ${args.join(" ")}`);
809
+ if (args[0] === "push") {
810
+ assert.ok(args.every((a) => !/^(-f|--force)/.test(a) && !a.startsWith("+")), `push must never force: git ${args.join(" ")}`);
811
+ }
812
+ }
813
+ }
814
+ const syncMerges = (calls) => calls.filter((a) => a.includes("merge") && a.includes("--no-edit"));
815
+ afterEach(() => {
816
+ setBaseAdvancedProbeForTests(null);
817
+ });
818
+ test("NOT-356: an idle sibling that now conflicts is probed, synced, and sent one repair round naming the files", async () => {
819
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-a", conflict: true }]);
820
+ const mergedId = await parkedMergeIn(local);
821
+ const siblingId = await idleSiblingIn(local, "sib-a", heads["sib-a"], 43);
822
+ const reviewerItem = listWorkItemsForIssue(siblingId).find((i) => i.status === "pending");
823
+ setMergePrForTests(async () => {
824
+ landMergedPr();
825
+ return { ok: true };
826
+ });
827
+ const { calls, stop } = recordAllGit();
828
+ try {
829
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
830
+ const result = await finalizeAutoMerge(mergedId);
831
+ assert.equal(result.issueStatus, "done");
832
+ await settleBaseAdvancedScansForTests();
833
+ }
834
+ finally {
835
+ stop();
836
+ }
837
+ const events = baseAdvancedEvents(siblingId);
838
+ assert.equal(events.length, 1);
839
+ assert.equal(events[0].action, "repair_queued");
840
+ assert.equal(events[0].probe, "conflict");
841
+ assert.deepEqual(events[0].files, ["shared.txt"]);
842
+ assert.equal(events[0].mergedIssueId, mergedId);
843
+ // The local merge-tree probe ran against the fetched base, not GitHub's mergeable state.
844
+ assert.ok(calls.some((a) => a[0] === "merge-tree" && a.includes("--write-tree") && a.includes(heads["sib-a"])));
845
+ // runMergeConflictSync ran through its base_advanced entry.
846
+ assert.deepEqual(syncEvents(siblingId).map((e) => [e.entry, e.outcome]), [["base_advanced", "repair_needed"]]);
847
+ assert.equal(syncMerges(calls).length, 1);
848
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "a conflicted sync pushes nothing");
849
+ assertNoForceNoRebase(calls);
850
+ assert.equal(originTip(origin, "sib-a"), heads["sib-a"]);
851
+ const sibling = getIssue(siblingId);
852
+ assert.equal(sibling.status, "repairing");
853
+ assert.equal(listWorkItemsForIssue(siblingId).find((i) => i.id === reviewerItem.id).status, "cancelled", "the stale reviewer round is superseded");
854
+ const pending = listWorkItemsForIssue(siblingId).filter((i) => i.status === "pending");
855
+ assert.equal(pending.length, 1);
856
+ assert.equal(pending[0].kind, "developer");
857
+ assert.deepEqual(JSON.parse(pending[0].payloadJson).conflictRepair, {
858
+ baseBranch: "main",
859
+ branch: "sib-a",
860
+ files: ["shared.txt"],
861
+ });
862
+ assert.equal(listHumanActionsForIssue(siblingId).filter((a) => a.status === "open").length, 0);
863
+ });
864
+ test("NOT-356: a sibling that does not conflict gets only a base.advanced event", async () => {
865
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-clean", conflict: false }]);
866
+ const mergedId = await parkedMergeIn(local);
867
+ const siblingId = await idleSiblingIn(local, "sib-clean", heads["sib-clean"], 44);
868
+ const before = listWorkflowEventsForIssue(siblingId).length;
869
+ setMergePrForTests(async () => {
870
+ landMergedPr();
871
+ return { ok: true };
872
+ });
873
+ const { calls, stop } = recordAllGit();
874
+ try {
875
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
876
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
877
+ await settleBaseAdvancedScansForTests();
878
+ }
879
+ finally {
880
+ stop();
881
+ }
882
+ const after = listWorkflowEventsForIssue(siblingId).slice(before);
883
+ assert.deepEqual(after.map((e) => e.type), ["base.advanced"], "nothing but the event");
884
+ assert.equal(JSON.parse(after[0].payloadJson).action, "clean");
885
+ assert.equal(syncMerges(calls).length, 0, "no sync");
886
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0);
887
+ assert.equal(getIssue(siblingId).status, "reviewing");
888
+ assert.equal(listWorkItemsForIssue(siblingId).filter((i) => i.status === "pending").length, 1);
889
+ assert.equal(originTip(origin, "sib-clean"), heads["sib-clean"]);
890
+ });
891
+ test("NOT-356: a sibling with an active worker session is left alone with a base.advanced event", async () => {
892
+ const { createWorkerSession, startSession } = await import("../repository/worker-sessions.js");
893
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-busy", conflict: true }]);
894
+ const mergedId = await parkedMergeIn(local);
895
+ const siblingId = await idleSiblingIn(local, "sib-busy", heads["sib-busy"], 45);
896
+ const session = createWorkerSession({
897
+ issueId: siblingId,
898
+ role: "reviewer",
899
+ round: 1,
900
+ agentId: null,
901
+ runtime: null,
902
+ });
903
+ assert.ok(startSession(session.id));
904
+ setMergePrForTests(async () => {
905
+ landMergedPr();
906
+ return { ok: true };
907
+ });
908
+ const { calls, stop } = recordAllGit();
909
+ try {
910
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
911
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
912
+ await settleBaseAdvancedScansForTests();
913
+ }
914
+ finally {
915
+ stop();
916
+ }
917
+ assert.deepEqual(baseAdvancedEvents(siblingId).map((e) => e.action), ["active_worker"]);
918
+ assert.deepEqual(syncEvents(siblingId), []);
919
+ assert.equal(syncMerges(calls).length, 0);
920
+ assert.equal(getIssue(siblingId).status, "reviewing");
921
+ assert.equal(originTip(origin, "sib-busy"), heads["sib-busy"]);
922
+ });
923
+ test("NOT-356: a sibling whose remote tip is not Dealer's last push is skipped and nothing is pushed", async () => {
924
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-hand", conflict: true }]);
925
+ const mergedId = await parkedMergeIn(local);
926
+ const siblingId = await idleSiblingIn(local, "sib-hand", heads["sib-hand"], 46);
927
+ // A human pushes onto the PR branch by hand.
928
+ const g = (...args) => execFileSync("git", args, { cwd: local, encoding: "utf8" }).trim();
929
+ g("checkout", "-q", "sib-hand");
930
+ fs.writeFileSync(path.join(local, "hand.txt"), "by hand\n");
931
+ g("add", ".");
932
+ g("commit", "-q", "-m", "hand edit");
933
+ g("push", "-q", "origin", "sib-hand");
934
+ const handTip = g("rev-parse", "HEAD");
935
+ g("checkout", "-q", "main");
936
+ setMergePrForTests(async () => {
937
+ landMergedPr();
938
+ return { ok: true };
939
+ });
940
+ const { calls, stop } = recordAllGit();
941
+ try {
942
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
943
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
944
+ await settleBaseAdvancedScansForTests();
945
+ }
946
+ finally {
947
+ stop();
948
+ }
949
+ const events = baseAdvancedEvents(siblingId);
950
+ assert.deepEqual(events.map((e) => e.action), ["hand_edited"]);
951
+ assert.equal(events[0].remoteSha, handTip);
952
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "nothing pushed");
953
+ assert.equal(calls.filter((a) => a[0] === "merge-tree").length, 0, "not even probed");
954
+ assert.deepEqual(syncEvents(siblingId), []);
955
+ assert.equal(originTip(origin, "sib-hand"), handTip);
956
+ assert.equal(getIssue(siblingId).status, "reviewing");
957
+ });
958
+ test("NOT-356: a sibling whose probe throws never changes the merge result, and the rest are still processed", async () => {
959
+ const { local, heads, landMergedPr } = initSiblingRepos([
960
+ { branch: "sib-throw", conflict: true },
961
+ { branch: "sib-next", conflict: true },
962
+ ]);
963
+ const mergedId = await parkedMergeIn(local);
964
+ const throwingId = await idleSiblingIn(local, "sib-throw", heads["sib-throw"], 47);
965
+ const nextId = await idleSiblingIn(local, "sib-next", heads["sib-next"], 48);
966
+ setBaseAdvancedProbeForTests(async (opts) => {
967
+ if (opts.headRef === heads["sib-throw"])
968
+ throw new Error("probe exploded");
969
+ return probeBaseConflict(opts);
970
+ });
971
+ setMergePrForTests(async () => {
972
+ landMergedPr();
973
+ return { ok: true };
974
+ });
975
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
976
+ const result = await finalizeAutoMerge(mergedId);
977
+ assert.deepEqual(result, {
978
+ applied: true,
979
+ issueStatus: "done",
980
+ nextWorkItemId: null,
981
+ humanActionId: null,
982
+ instanceCompleted: true,
983
+ triggerReflect: true,
984
+ });
985
+ await settleBaseAdvancedScansForTests();
986
+ assert.equal(getIssue(mergedId).status, "done");
987
+ const thrown = baseAdvancedEvents(throwingId);
988
+ assert.deepEqual(thrown.map((e) => e.action), ["failed"]);
989
+ assert.match(String(thrown[0].reason), /probe exploded/);
990
+ assert.equal(getIssue(throwingId).status, "reviewing", "the failing sibling is left as it was");
991
+ assert.deepEqual(baseAdvancedEvents(nextId).map((e) => e.action), ["repair_queued"]);
992
+ assert.equal(getIssue(nextId).status, "repairing");
993
+ });
994
+ test("NOT-356: a second Dealer merge in the same episode never syncs or repairs the sibling again", async () => {
995
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-twice", conflict: true }]);
996
+ const firstMerge = await parkedMergeIn(local);
997
+ const secondMerge = await parkedMergeIn(local);
998
+ const siblingId = await idleSiblingIn(local, "sib-twice", heads["sib-twice"], 49);
999
+ setMergePrForTests(async () => {
1000
+ landMergedPr();
1001
+ return { ok: true };
1002
+ });
1003
+ const { calls, stop } = recordAllGit();
1004
+ try {
1005
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
1006
+ assert.equal((await finalizeAutoMerge(firstMerge)).issueStatus, "done");
1007
+ await settleBaseAdvancedScansForTests();
1008
+ assert.equal(getIssue(siblingId).status, "repairing");
1009
+ assert.equal(getIssue(siblingId).currentRound, 2);
1010
+ assert.equal((await finalizeAutoMerge(secondMerge)).issueStatus, "done");
1011
+ await settleBaseAdvancedScansForTests();
1012
+ }
1013
+ finally {
1014
+ stop();
1015
+ }
1016
+ assert.deepEqual(baseAdvancedEvents(siblingId).map((e) => e.action), ["repair_queued", "repair_spent"]);
1017
+ assert.equal(syncMerges(calls).length, 1, "one sync per issue per episode");
1018
+ assert.equal(syncEvents(siblingId).length, 1);
1019
+ assert.equal(listWorkflowEventsForIssue(siblingId).filter((e) => e.type === "auto_merge.conflict_repair_queued").length, 1, "one repair round per episode");
1020
+ assert.equal(getIssue(siblingId).currentRound, 2);
1021
+ assertNoForceNoRebase(calls);
1022
+ assert.equal(originTip(origin, "sib-twice"), heads["sib-twice"]);
1023
+ });
1024
+ /** Run one Dealer merge of `mergedId` (landing its change on origin/main) and wait
1025
+ * for the sibling scan it starts, recording every git command. */
1026
+ async function mergeAndScan(mergedId, landMergedPr) {
1027
+ setMergePrForTests(async () => {
1028
+ landMergedPr();
1029
+ return { ok: true };
1030
+ });
1031
+ const { calls, stop } = recordAllGit();
1032
+ try {
1033
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
1034
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
1035
+ await settleBaseAdvancedScansForTests();
1036
+ }
1037
+ finally {
1038
+ stop();
1039
+ }
1040
+ return calls;
1041
+ }
1042
+ test("NOT-356: a clean sync re-enters the checks wait on the pushed head, superseding the stale reviewer", async () => {
1043
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-sync", conflict: false }]);
1044
+ const mergedId = await parkedMergeIn(local);
1045
+ const siblingId = await idleSiblingIn(local, "sib-sync", heads["sib-sync"], 50);
1046
+ const reviewerItem = listWorkItemsForIssue(siblingId).find((i) => i.status === "pending");
1047
+ const roundBefore = getIssue(siblingId).currentRound;
1048
+ // The probe reports a conflict GitHub could not see yet; the sync then merges cleanly.
1049
+ setBaseAdvancedProbeForTests(async () => ({ state: "conflict", files: ["shared.txt"] }));
1050
+ const calls = await mergeAndScan(mergedId, landMergedPr);
1051
+ const events = baseAdvancedEvents(siblingId);
1052
+ assert.deepEqual(events.map((e) => e.action), ["synced"]);
1053
+ const synced = originTip(origin, "sib-sync");
1054
+ assert.notEqual(synced, heads["sib-sync"]);
1055
+ assert.equal(events[0].syncedHeadSha, synced);
1056
+ const pushes = calls.filter((a) => a[0] === "push");
1057
+ assert.deepEqual(pushes, [["push", "-u", "origin", "HEAD:refs/heads/sib-sync"]]);
1058
+ assertNoForceNoRebase(calls);
1059
+ const sibling = getIssue(siblingId);
1060
+ assert.equal(sibling.status, "repairing");
1061
+ assert.equal(sibling.currentOwner, "developer");
1062
+ assert.equal(sibling.currentRound, roundBefore, "re-entering the checks wait spends no round");
1063
+ assert.match(sibling.currentIntent ?? "", /waiting for CI on/);
1064
+ const items = listWorkItemsForIssue(siblingId);
1065
+ assert.equal(items.find((i) => i.id === reviewerItem.id).status, "cancelled", "no review of the stale head");
1066
+ const pending = items.filter((i) => i.status === "pending");
1067
+ assert.equal(pending.length, 1);
1068
+ assert.equal(pending[0].kind, "developer");
1069
+ assert.equal(pending[0].id, events[0].workItemId);
1070
+ const payload = JSON.parse(pending[0].payloadJson);
1071
+ assert.equal(payload.publishOnly, true, "a no-agent CI wait on the pushed head");
1072
+ assert.equal(payload.branch, "sib-sync");
1073
+ assert.equal(payload.conflictRepair, undefined);
1074
+ });
1075
+ test("NOT-356: an origin reset to an older Dealer head is a hand edit, not Dealer's last push", async () => {
1076
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-old", conflict: true }]);
1077
+ const mergedId = await parkedMergeIn(local);
1078
+ const siblingId = await idleSiblingIn(local, "sib-old", heads["sib-old"], 51);
1079
+ // Dealer later pushed a newer head (a base sync) onto the branch ...
1080
+ const g = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
1081
+ g(local, "checkout", "-q", "sib-old");
1082
+ fs.writeFileSync(path.join(local, "later.txt"), "dealer sync\n");
1083
+ g(local, "add", ".");
1084
+ g(local, "commit", "-q", "-m", "dealer sync");
1085
+ g(local, "push", "-q", "origin", "sib-old");
1086
+ const newer = g(local, "rev-parse", "HEAD");
1087
+ g(local, "checkout", "-q", "main");
1088
+ const { appendWorkflowEvent } = await import("../repository/workflow-events.js");
1089
+ appendWorkflowEvent({
1090
+ issueId: siblingId,
1091
+ workflowInstanceId: getActiveWorkflowInstance(siblingId).id,
1092
+ workerSessionId: null,
1093
+ type: "auto_merge.conflict_sync",
1094
+ actorType: "system",
1095
+ stage: "reviewing",
1096
+ payload: { outcome: "synced", entry: "base_advanced", branch: "sib-old", baseBranch: "main", headSha: newer },
1097
+ });
1098
+ // ... and a human reset origin back to the older Dealer handoff head.
1099
+ g(origin, "update-ref", "refs/heads/sib-old", heads["sib-old"]);
1100
+ const calls = await mergeAndScan(mergedId, landMergedPr);
1101
+ const events = baseAdvancedEvents(siblingId);
1102
+ assert.deepEqual(events.map((e) => e.action), ["hand_edited"]);
1103
+ assert.equal(events[0].remoteSha, heads["sib-old"]);
1104
+ assert.equal(events[0].lastPushedSha, newer);
1105
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0);
1106
+ assert.equal(calls.filter((a) => a[0] === "merge-tree").length, 0);
1107
+ assert.equal(originTip(origin, "sib-old"), heads["sib-old"]);
1108
+ assert.equal(getIssue(siblingId).status, "reviewing");
1109
+ });
1110
+ test("NOT-356: an external push accepted by a publish-only clean handoff is still a hand edit", async () => {
1111
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-ext", conflict: true }]);
1112
+ const mergedId = await parkedMergeIn(local);
1113
+ // Dealer pushed its head; then someone pushed outside the coordinator, and the
1114
+ // publish-only retry accepted origin and handed off at that observed head.
1115
+ const g = (...args) => execFileSync("git", args, { cwd: local, encoding: "utf8" }).trim();
1116
+ g("checkout", "-q", "sib-ext");
1117
+ fs.writeFileSync(path.join(local, "hand.txt"), "by hand\n");
1118
+ g("add", ".");
1119
+ g("commit", "-q", "-m", "hand edit");
1120
+ g("push", "-q", "origin", "sib-ext");
1121
+ const handTip = g("rev-parse", "HEAD");
1122
+ g("checkout", "-q", "main");
1123
+ const siblingId = await idleSiblingIn(local, "sib-ext", handTip, 54, heads["sib-ext"]);
1124
+ assert.equal(getIssue(siblingId).headSha, handTip, "the handoff recorded the observed head");
1125
+ assert.equal(JSON.parse(listWorkflowEventsForIssue(siblingId).find((e) => e.type === "pull_request.opened").payloadJson).headSha, handTip);
1126
+ const calls = await mergeAndScan(mergedId, landMergedPr);
1127
+ const events = baseAdvancedEvents(siblingId);
1128
+ assert.deepEqual(events.map((e) => e.action), ["hand_edited"]);
1129
+ assert.equal(events[0].remoteSha, handTip);
1130
+ assert.equal(events[0].lastPushedSha, heads["sib-ext"]);
1131
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "nothing pushed");
1132
+ assert.equal(calls.filter((a) => a[0] === "merge-tree").length, 0, "not even probed");
1133
+ assert.deepEqual(syncEvents(siblingId), []);
1134
+ assert.equal(originTip(origin, "sib-ext"), handTip);
1135
+ assert.equal(getIssue(siblingId).status, "reviewing");
1136
+ });
1137
+ test("NOT-356: a hand push after the ownership check is never synced or pushed over", async () => {
1138
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-race", conflict: true }]);
1139
+ const mergedId = await parkedMergeIn(local);
1140
+ const siblingId = await idleSiblingIn(local, "sib-race", heads["sib-race"], 52);
1141
+ const g = (...args) => execFileSync("git", args, { cwd: local, encoding: "utf8" }).trim();
1142
+ let handTip = "";
1143
+ // The human push lands between the validated fetch and the sync's own fetch.
1144
+ setBaseAdvancedProbeForTests(async (opts) => {
1145
+ const result = await probeBaseConflict(opts);
1146
+ g("checkout", "-q", "-B", "hand", heads["sib-race"]);
1147
+ fs.writeFileSync(path.join(local, "hand.txt"), "by hand\n");
1148
+ g("add", ".");
1149
+ g("commit", "-q", "-m", "hand edit");
1150
+ g("push", "-q", "origin", "HEAD:refs/heads/sib-race");
1151
+ handTip = g("rev-parse", "HEAD");
1152
+ g("checkout", "-q", "main");
1153
+ return result;
1154
+ });
1155
+ const calls = await mergeAndScan(mergedId, landMergedPr);
1156
+ const events = baseAdvancedEvents(siblingId);
1157
+ assert.deepEqual(events.map((e) => e.action), ["hand_edited"]);
1158
+ assert.deepEqual(syncEvents(siblingId).map((e) => [e.outcome, e.code]), [["skipped", "tip_moved"]]);
1159
+ assert.equal(syncMerges(calls).length, 0, "the moved tip is never merged");
1160
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0);
1161
+ assert.equal(originTip(origin, "sib-race"), handTip);
1162
+ assert.equal(getIssue(siblingId).status, "reviewing");
1163
+ });
1164
+ test("NOT-356: a hand push during a conflicted sync merge never queues the repair round", async () => {
1165
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-textual", conflict: true }]);
1166
+ const mergedId = await parkedMergeIn(local);
1167
+ const siblingId = await idleSiblingIn(local, "sib-textual", heads["sib-textual"], 59);
1168
+ const reviewerItem = listWorkItemsForIssue(siblingId).find((i) => i.status === "pending");
1169
+ const g = (...args) => execFileSync("git", args, { cwd: local, encoding: "utf8" }).trim();
1170
+ let handTip = "";
1171
+ setMergePrForTests(async () => {
1172
+ landMergedPr();
1173
+ return { ok: true };
1174
+ });
1175
+ const calls = [];
1176
+ // The human push lands while the sync's merge reports the textual conflict.
1177
+ setConflictSyncGitExecForTests(async (args, opts) => {
1178
+ calls.push(args);
1179
+ try {
1180
+ return await defaultSyncGitExec(args, opts);
1181
+ }
1182
+ finally {
1183
+ if (args.includes("merge") && args.includes("--no-edit")) {
1184
+ g("checkout", "-q", "-B", "hand", heads["sib-textual"]);
1185
+ fs.writeFileSync(path.join(local, "hand.txt"), "by hand\n");
1186
+ g("add", ".");
1187
+ g("commit", "-q", "-m", "hand edit");
1188
+ g("push", "-q", "origin", "HEAD:refs/heads/sib-textual");
1189
+ handTip = g("rev-parse", "HEAD");
1190
+ g("checkout", "-q", "main");
1191
+ }
1192
+ }
1193
+ });
1194
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
1195
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
1196
+ await settleBaseAdvancedScansForTests();
1197
+ assert.deepEqual(syncEvents(siblingId).map((e) => [e.entry, e.outcome]), [["base_advanced", "repair_needed"]]);
1198
+ const events = baseAdvancedEvents(siblingId);
1199
+ assert.deepEqual(events.map((e) => e.action), ["hand_edited"]);
1200
+ assert.equal(events[0].remoteSha, handTip);
1201
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0);
1202
+ assert.equal(originTip(origin, "sib-textual"), handTip);
1203
+ assert.equal(listWorkflowEventsForIssue(siblingId).filter((e) => e.type === "auto_merge.conflict_repair_queued").length, 0, "no repair round was queued");
1204
+ assert.equal(listWorkItemsForIssue(siblingId).find((i) => i.id === reviewerItem.id).status, "pending");
1205
+ assert.equal(getIssue(siblingId).status, "reviewing");
1206
+ });
1207
+ test("NOT-356: a hand reset to an older tip after the sync fetch is never fast-forwarded over", async () => {
1208
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-reset", conflict: false }]);
1209
+ const g = (...args) => execFileSync("git", args, { cwd: local, encoding: "utf8" }).trim();
1210
+ // A second Dealer commit, so the human has an ancestor to reset back to.
1211
+ g("checkout", "-q", "sib-reset");
1212
+ fs.writeFileSync(path.join(local, "second.txt"), "second\n");
1213
+ g("add", ".");
1214
+ g("commit", "-q", "-m", "second commit");
1215
+ g("push", "-q", "origin", "sib-reset");
1216
+ const dealerTip = g("rev-parse", "HEAD");
1217
+ g("checkout", "-q", "main");
1218
+ const olderTip = heads["sib-reset"];
1219
+ const mergedId = await parkedMergeIn(local);
1220
+ const siblingId = await idleSiblingIn(local, "sib-reset", dealerTip, 55);
1221
+ setBaseAdvancedProbeForTests(async () => ({ state: "conflict", files: ["shared.txt"] }));
1222
+ setMergePrForTests(async () => {
1223
+ landMergedPr();
1224
+ return { ok: true };
1225
+ });
1226
+ const calls = [];
1227
+ // The human resets origin back to the ancestor after the sync fetched and
1228
+ // merged: Dealer's merge commit would now be a valid fast-forward.
1229
+ setConflictSyncGitExecForTests(async (args, opts) => {
1230
+ calls.push(args);
1231
+ const out = await defaultSyncGitExec(args, opts);
1232
+ if (args.includes("merge") && args.includes("--no-edit")) {
1233
+ execFileSync("git", ["update-ref", "refs/heads/sib-reset", olderTip], { cwd: origin });
1234
+ }
1235
+ return out;
1236
+ });
1237
+ const stop = observeGitCommands((args) => {
1238
+ calls.push([...args]);
1239
+ });
1240
+ try {
1241
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
1242
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
1243
+ await settleBaseAdvancedScansForTests();
1244
+ }
1245
+ finally {
1246
+ stop();
1247
+ }
1248
+ assert.equal(syncMerges(calls).length, 1, "the sync merged onto the validated tip");
1249
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "no branch-changing push");
1250
+ assert.equal(originTip(origin, "sib-reset"), olderTip, "the hand reset stands");
1251
+ assert.deepEqual(baseAdvancedEvents(siblingId).map((e) => e.action), ["hand_edited"]);
1252
+ assert.deepEqual(syncEvents(siblingId).map((e) => [e.outcome, e.code]), [["skipped", "tip_moved"]]);
1253
+ assertNoForceNoRebase(calls);
1254
+ assert.equal(getIssue(siblingId).status, "reviewing");
1255
+ });
1256
+ /** Installs `body` as the repository's pre-push hook, via `core.hooksPath`
1257
+ * when given. */
1258
+ function installRepoPrePushHook(local, body, hooksPath) {
1259
+ let dir;
1260
+ if (hooksPath) {
1261
+ execFileSync("git", ["config", "core.hooksPath", hooksPath], { cwd: local });
1262
+ dir = hooksPath;
1263
+ }
1264
+ else {
1265
+ dir = path.resolve(local, execFileSync("git", ["rev-parse", "--git-path", "hooks"], { cwd: local, encoding: "utf8" }).trim());
1266
+ }
1267
+ fs.mkdirSync(dir, { recursive: true });
1268
+ fs.writeFileSync(path.join(dir, "pre-push"), `#!/bin/sh\n${body}\n`, { mode: 0o755 });
1269
+ }
1270
+ test("NOT-356: the sync push still runs the repository's pre-push hook", async () => {
1271
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-hook", conflict: false }]);
1272
+ const hookLog = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-hook-log-")), "log");
1273
+ installRepoPrePushHook(local, `echo "args $1" >> "${hookLog}"\ncat >> "${hookLog}"\nexit 0`, fs.mkdtempSync(path.join(os.tmpdir(), "dealer-repo-hooks-")));
1274
+ const mergedId = await parkedMergeIn(local);
1275
+ const siblingId = await idleSiblingIn(local, "sib-hook", heads["sib-hook"], 57);
1276
+ setBaseAdvancedProbeForTests(async () => ({ state: "conflict", files: ["shared.txt"] }));
1277
+ const calls = await mergeAndScan(mergedId, landMergedPr);
1278
+ assert.deepEqual(baseAdvancedEvents(siblingId).map((e) => e.action), ["synced"]);
1279
+ const synced = originTip(origin, "sib-hook");
1280
+ assert.notEqual(synced, heads["sib-hook"]);
1281
+ const log = fs.readFileSync(hookLog, "utf8");
1282
+ assert.match(log, /^args origin$/m, "the repository hook got git's arguments");
1283
+ assert.match(log, new RegExp(`^HEAD ${synced} refs/heads/sib-hook ${heads["sib-hook"]}$`, "m"), "the repository hook got git's ref lines on stdin");
1284
+ assertNoForceNoRebase(calls);
1285
+ });
1286
+ test("NOT-356: a repository pre-push hook that rejects still stops the sync push", async () => {
1287
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-veto", conflict: false }]);
1288
+ installRepoPrePushHook(local, `echo "repo policy: no pushes" >&2\nexit 1`);
1289
+ const mergedId = await parkedMergeIn(local);
1290
+ const siblingId = await idleSiblingIn(local, "sib-veto", heads["sib-veto"], 58);
1291
+ setBaseAdvancedProbeForTests(async () => ({ state: "conflict", files: ["shared.txt"] }));
1292
+ const calls = await mergeAndScan(mergedId, landMergedPr);
1293
+ assert.equal(syncMerges(calls).length, 1);
1294
+ assert.deepEqual(calls.filter((a) => a[0] === "push"), [["push", "-u", "origin", "HEAD:refs/heads/sib-veto"]], "one plain push attempt, vetoed by the repository hook");
1295
+ assert.equal(originTip(origin, "sib-veto"), heads["sib-veto"], "nothing was published");
1296
+ const events = baseAdvancedEvents(siblingId);
1297
+ assert.deepEqual(events.map((e) => e.action), ["failed"]);
1298
+ assert.match(String(events[0].reason), /repo policy: no pushes/);
1299
+ assertNoForceNoRebase(calls);
1300
+ });
1301
+ test("NOT-356: a worker that starts during the sync blocks the push and leaves the branch as it was", async () => {
1302
+ const { createWorkerSession, startSession } = await import("../repository/worker-sessions.js");
1303
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-late", conflict: false }]);
1304
+ const mergedId = await parkedMergeIn(local);
1305
+ const siblingId = await idleSiblingIn(local, "sib-late", heads["sib-late"], 53);
1306
+ setBaseAdvancedProbeForTests(async () => {
1307
+ const session = createWorkerSession({ issueId: siblingId, role: "reviewer", round: 1, agentId: null, runtime: null });
1308
+ assert.ok(startSession(session.id));
1309
+ return { state: "conflict", files: ["shared.txt"] };
1310
+ });
1311
+ const calls = await mergeAndScan(mergedId, landMergedPr);
1312
+ const events = baseAdvancedEvents(siblingId);
1313
+ assert.deepEqual(events.map((e) => e.action), ["active_worker"]);
1314
+ assert.deepEqual(syncEvents(siblingId).map((e) => [e.outcome, e.code]), [["skipped", "refused"]]);
1315
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "nothing pushed under a live worker");
1316
+ assert.equal(originTip(origin, "sib-late"), heads["sib-late"]);
1317
+ assert.equal(execFileSync("git", ["rev-parse", "refs/heads/sib-late"], { cwd: local, encoding: "utf8" }).trim(), heads["sib-late"], "the unpushed base merge is dropped from the local branch");
1318
+ assert.equal(getIssue(siblingId).status, "reviewing");
1319
+ assert.equal(listWorkItemsForIssue(siblingId).filter((i) => i.status === "pending").length, 1);
1320
+ });
1321
+ test("NOT-356: a sibling aborted after the sync merge but before the push is never pushed", async () => {
1322
+ const { abortIssue } = await import("./commands.js");
1323
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-abort", conflict: false }]);
1324
+ const mergedId = await parkedMergeIn(local);
1325
+ const siblingId = await idleSiblingIn(local, "sib-abort", heads["sib-abort"], 56);
1326
+ setBaseAdvancedProbeForTests(async () => ({ state: "conflict", files: ["shared.txt"] }));
1327
+ setMergePrForTests(async () => {
1328
+ landMergedPr();
1329
+ return { ok: true };
1330
+ });
1331
+ const calls = [];
1332
+ // The operator aborts the sibling while the sync's base merge runs: work and
1333
+ // sessions are cancelled and the workflow completes, so no worker is active.
1334
+ setConflictSyncGitExecForTests(async (args, opts) => {
1335
+ calls.push(args);
1336
+ const out = await defaultSyncGitExec(args, opts);
1337
+ if (args.includes("merge") && args.includes("--no-edit")) {
1338
+ const aborted = abortIssue(siblingId, "operator", { killProcess: () => true });
1339
+ assert.ok(aborted.ok);
1340
+ }
1341
+ return out;
1342
+ });
1343
+ const stop = observeGitCommands((args) => {
1344
+ calls.push([...args]);
1345
+ });
1346
+ try {
1347
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
1348
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
1349
+ await settleBaseAdvancedScansForTests();
1350
+ }
1351
+ finally {
1352
+ stop();
1353
+ }
1354
+ assert.equal(syncMerges(calls).length, 1, "the abort landed after the sync merge");
1355
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "nothing pushed to the closed issue");
1356
+ assert.equal(originTip(origin, "sib-abort"), heads["sib-abort"]);
1357
+ assert.equal(getIssue(siblingId).status, "closed");
1358
+ const events = baseAdvancedEvents(siblingId);
1359
+ assert.deepEqual(events.map((e) => e.action), ["skipped"]);
1360
+ assert.match(String(events[0].reason), /left its open stage|workflow instance/);
1361
+ assert.deepEqual(syncEvents(siblingId).map((e) => [e.outcome, e.code]), [["skipped", "refused"]]);
1362
+ assertNoForceNoRebase(calls);
1363
+ });
1364
+ test("NOT-356: a sibling aborted while the pre-push tip re-read runs is never pushed", async () => {
1365
+ const { abortIssue } = await import("./commands.js");
1366
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-late-abort", conflict: false }]);
1367
+ const mergedId = await parkedMergeIn(local);
1368
+ const siblingId = await idleSiblingIn(local, "sib-late-abort", heads["sib-late-abort"], 57);
1369
+ setBaseAdvancedProbeForTests(async () => ({ state: "conflict", files: ["shared.txt"] }));
1370
+ setMergePrForTests(async () => {
1371
+ landMergedPr();
1372
+ return { ok: true };
1373
+ });
1374
+ const calls = [];
1375
+ // The abort lands during the last await before the push: the ls-remote that
1376
+ // re-pins origin's tip after the base merge, which itself still succeeds.
1377
+ let merged = false;
1378
+ setConflictSyncGitExecForTests(async (args, opts) => {
1379
+ calls.push(args);
1380
+ const out = await defaultSyncGitExec(args, opts);
1381
+ if (args.includes("merge") && args.includes("--no-edit"))
1382
+ merged = true;
1383
+ if (merged && args[0] === "ls-remote") {
1384
+ const aborted = abortIssue(siblingId, "operator", { killProcess: () => true });
1385
+ assert.ok(aborted.ok);
1386
+ }
1387
+ return out;
1388
+ });
1389
+ const stop = observeGitCommands((args) => {
1390
+ calls.push([...args]);
1391
+ });
1392
+ try {
1393
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
1394
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
1395
+ await settleBaseAdvancedScansForTests();
1396
+ }
1397
+ finally {
1398
+ stop();
1399
+ }
1400
+ assert.equal(syncMerges(calls).length, 1);
1401
+ assert.ok(calls.some((a) => a[0] === "ls-remote"), "the abort landed in the pre-push tip re-read");
1402
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "nothing pushed to the closed issue");
1403
+ assert.equal(originTip(origin, "sib-late-abort"), heads["sib-late-abort"]);
1404
+ assert.equal(getIssue(siblingId).status, "closed");
1405
+ assert.deepEqual(syncEvents(siblingId).map((e) => [e.outcome, e.code]), [["skipped", "refused"]]);
1406
+ assertNoForceNoRebase(calls);
1407
+ });
1408
+ test("NOT-356: a worker that starts during the pre-push tip re-read blocks the push", async () => {
1409
+ const { createWorkerSession, startSession } = await import("../repository/worker-sessions.js");
1410
+ const { origin, local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-late-worker", conflict: false }]);
1411
+ const mergedId = await parkedMergeIn(local);
1412
+ const siblingId = await idleSiblingIn(local, "sib-late-worker", heads["sib-late-worker"], 58);
1413
+ setBaseAdvancedProbeForTests(async () => ({ state: "conflict", files: ["shared.txt"] }));
1414
+ setMergePrForTests(async () => {
1415
+ landMergedPr();
1416
+ return { ok: true };
1417
+ });
1418
+ const calls = [];
1419
+ let merged = false;
1420
+ setConflictSyncGitExecForTests(async (args, opts) => {
1421
+ calls.push(args);
1422
+ const out = await defaultSyncGitExec(args, opts);
1423
+ if (args.includes("merge") && args.includes("--no-edit"))
1424
+ merged = true;
1425
+ if (merged && args[0] === "ls-remote") {
1426
+ const session = createWorkerSession({ issueId: siblingId, role: "reviewer", round: 1, agentId: null, runtime: null });
1427
+ assert.ok(startSession(session.id));
1428
+ }
1429
+ return out;
1430
+ });
1431
+ const stop = observeGitCommands((args) => {
1432
+ calls.push([...args]);
1433
+ });
1434
+ try {
1435
+ const { finalizeAutoMerge } = await import("./auto-merge.js");
1436
+ assert.equal((await finalizeAutoMerge(mergedId)).issueStatus, "done");
1437
+ await settleBaseAdvancedScansForTests();
1438
+ }
1439
+ finally {
1440
+ stop();
1441
+ }
1442
+ assert.equal(syncMerges(calls).length, 1);
1443
+ assert.equal(calls.filter((a) => a[0] === "push").length, 0, "nothing pushed under a live worker");
1444
+ assert.equal(originTip(origin, "sib-late-worker"), heads["sib-late-worker"]);
1445
+ assert.deepEqual(syncEvents(siblingId).map((e) => [e.outcome, e.code]), [["skipped", "refused"]]);
1446
+ assertNoForceNoRebase(calls);
1447
+ });
1448
+ test("NOT-356: a PR GitHub already reports merged (a hand merge found by recovery) starts no sibling scan", async () => {
1449
+ const { local, heads, landMergedPr } = initSiblingRepos([{ branch: "sib-ext", conflict: true }]);
1450
+ const mergedId = await parkedMergeIn(local);
1451
+ const siblingId = await idleSiblingIn(local, "sib-ext", heads["sib-ext"], 61);
1452
+ const before = listWorkflowEventsForIssue(siblingId).length;
1453
+ // A human merged the parked PR by hand; the recovery finalize finds it merged.
1454
+ landMergedPr();
1455
+ setMergePrForTests(async () => ({ ok: true, alreadyMerged: true }));
1456
+ const { calls, stop } = recordAllGit();
1457
+ try {
1458
+ const { recoverStrandedAutoMerges } = await import("./auto-merge.js");
1459
+ await recoverStrandedAutoMerges();
1460
+ await settleBaseAdvancedScansForTests();
1461
+ }
1462
+ finally {
1463
+ stop();
1464
+ }
1465
+ assert.equal(getIssue(mergedId).status, "done", "recovery still completes the issue");
1466
+ assert.deepEqual(listWorkflowEventsForIssue(siblingId).slice(before), [], "the sibling is untouched");
1467
+ assert.deepEqual(calls, [], "no probe, sync or push");
1468
+ assert.equal(getIssue(siblingId).status, "reviewing");
1469
+ });
1470
+ test("NOT-356: idle siblings parked on a needs_human gate or a stranded auto-merge are probed and repaired", async () => {
1471
+ const { transitionIssue } = await import("../repository/issues.js");
1472
+ const { createHumanAction } = await import("../repository/human-actions.js");
1473
+ const { cancelWorkItem } = await import("../repository/work-items.js");
1474
+ const { AUTO_MERGE_INTENT } = await import("./auto-merge.js");
1475
+ const { local, heads, landMergedPr } = initSiblingRepos([
1476
+ { branch: "sib-human", conflict: true },
1477
+ { branch: "sib-park", conflict: true },
1478
+ ]);
1479
+ const mergedId = await parkedMergeIn(local);
1480
+ const humanId = await idleSiblingIn(local, "sib-human", heads["sib-human"], 54);
1481
+ const parkId = await idleSiblingIn(local, "sib-park", heads["sib-park"], 55);
1482
+ for (const id of [humanId, parkId]) {
1483
+ for (const w of listWorkItemsForIssue(id))
1484
+ if (w.status === "pending")
1485
+ cancelWorkItem(w.id);
1486
+ }
1487
+ transitionIssue(humanId, "needs_human", { currentOwner: "human", currentIntent: "waiting on a human" });
1488
+ createHumanAction({
1489
+ issueId: humanId,
1490
+ workflowInstanceId: getActiveWorkflowInstance(humanId).id,
1491
+ actionType: "policy_escalation",
1492
+ reason: "waiting on a human",
1493
+ question: "?",
1494
+ });
1495
+ transitionIssue(parkId, "final_review", { currentOwner: "system", currentIntent: AUTO_MERGE_INTENT });
1496
+ await mergeAndScan(mergedId, landMergedPr);
1497
+ for (const id of [humanId, parkId]) {
1498
+ assert.deepEqual(baseAdvancedEvents(id).map((e) => e.action), ["repair_queued"]);
1499
+ assert.equal(getIssue(id).status, "repairing");
1500
+ assert.equal(listHumanActionsForIssue(id).filter((a) => a.status === "open").length, 0);
1501
+ const pending = listWorkItemsForIssue(id).filter((i) => i.status === "pending");
1502
+ assert.equal(pending.length, 1);
1503
+ assert.ok(JSON.parse(pending[0].payloadJson).conflictRepair);
1504
+ }
1505
+ });