agent-dealer 1.2.10 → 1.2.12
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/base-conflict-probe.test.js +102 -0
- package/bundle/server/dist/adapters/git-worktree.js +60 -5
- package/bundle/server/dist/adapters/git-worktree.test.js +13 -1
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +815 -0
- package/bundle/server/dist/coordinator/auto-merge.js +16 -3
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +17 -0
- package/bundle/server/dist/coordinator/base-advanced-scan.js +308 -0
- package/bundle/server/dist/coordinator/commands.js +294 -11
- package/bundle/server/dist/coordinator/commands.test.js +288 -3
- package/bundle/server/dist/coordinator/developer-effect.js +114 -2
- package/bundle/server/dist/coordinator/developer-effect.test.js +318 -1
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +195 -21
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +91 -0
- package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +5 -1
- package/bundle/server/dist/coordinator/routing.js +7 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +3 -0
- package/bundle/server/dist/coordinator/worker-loop.js +3 -2
- package/bundle/server/dist/routes/issues.js +54 -7
- package/bundle/server/dist/routes/issues.test.js +326 -7
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CBdkhTI_.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +9 -0
- package/bundle/shared/dist/workflow.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +2 -0
- package/dist/issue.d.ts +8 -0
- package/dist/issue.js +35 -0
- package/dist/issue.test.js +92 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-CU6fNntg.js +0 -64
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// packages/server/src/adapters/base-conflict-probe.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-355: the pre-publish base-conflict probe against real git — two branches
|
|
4
|
+
// editing the same line of one file report `conflict` with that path; non-
|
|
5
|
+
// overlapping edits report `clean`; neither touches the working tree or any ref.
|
|
6
|
+
import { test, before, after } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-probe-home-"));
|
|
13
|
+
const { probeBaseConflict } = await import("./git-worktree.js");
|
|
14
|
+
let repo;
|
|
15
|
+
function git(cwd, ...args) {
|
|
16
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
17
|
+
}
|
|
18
|
+
function commitFile(file, content, message) {
|
|
19
|
+
fs.writeFileSync(path.join(repo, file), content);
|
|
20
|
+
git(repo, "add", file);
|
|
21
|
+
git(repo, "commit", "-q", "-m", message);
|
|
22
|
+
return git(repo, "rev-parse", "HEAD");
|
|
23
|
+
}
|
|
24
|
+
before(() => {
|
|
25
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-probe-repo-"));
|
|
26
|
+
git(repo, "init", "-q", "-b", "main");
|
|
27
|
+
git(repo, "config", "user.email", "test@example.com");
|
|
28
|
+
git(repo, "config", "user.name", "Test");
|
|
29
|
+
commitFile("shared.txt", "line one\nline two\nline three\n", "init");
|
|
30
|
+
commitFile("other.txt", "other\n", "other");
|
|
31
|
+
});
|
|
32
|
+
after(() => fs.rmSync(repo, { recursive: true, force: true }));
|
|
33
|
+
/** Branch `name` off the init commits and give both it and a fresh `base-<name>` one edit each. */
|
|
34
|
+
function divergedPair(name, feature, base) {
|
|
35
|
+
git(repo, "checkout", "-q", "-b", `base-${name}`, "main");
|
|
36
|
+
commitFile(base[0], base[1], `base ${name}`);
|
|
37
|
+
git(repo, "checkout", "-q", "-b", name, "main");
|
|
38
|
+
commitFile(feature[0], feature[1], `feature ${name}`);
|
|
39
|
+
return `base-${name}`;
|
|
40
|
+
}
|
|
41
|
+
test("NOT-355: the same line edited on both sides reports conflict with that path, and changes nothing", async () => {
|
|
42
|
+
const baseRef = divergedPair("same-line", ["shared.txt", "line one\nfeature edit\nline three\n"], ["shared.txt", "line one\nbase edit\nline three\n"]);
|
|
43
|
+
const headBefore = git(repo, "rev-parse", "HEAD");
|
|
44
|
+
const refsBefore = git(repo, "for-each-ref");
|
|
45
|
+
const probe = await probeBaseConflict({ worktreePath: repo, baseRef });
|
|
46
|
+
assert.deepEqual(probe, { state: "conflict", files: ["shared.txt"] });
|
|
47
|
+
assert.equal(git(repo, "rev-parse", "HEAD"), headBefore, "HEAD unchanged");
|
|
48
|
+
assert.equal(git(repo, "for-each-ref"), refsBefore, "no ref moved");
|
|
49
|
+
assert.equal(git(repo, "status", "--porcelain"), "", "working tree untouched");
|
|
50
|
+
});
|
|
51
|
+
test("NOT-355: non-overlapping edits report clean", async () => {
|
|
52
|
+
const baseRef = divergedPair("disjoint", ["shared.txt", "line one\nline two\nfeature edit\n"], ["other.txt", "other, edited on base\n"]);
|
|
53
|
+
const probe = await probeBaseConflict({ worktreePath: repo, baseRef });
|
|
54
|
+
assert.deepEqual(probe, { state: "clean" });
|
|
55
|
+
assert.equal(git(repo, "status", "--porcelain"), "");
|
|
56
|
+
});
|
|
57
|
+
test("NOT-355: a git without merge-tree --write-tree is skipped (fail open)", async () => {
|
|
58
|
+
const calls = [];
|
|
59
|
+
const probe = await probeBaseConflict({
|
|
60
|
+
worktreePath: repo,
|
|
61
|
+
baseRef: "main",
|
|
62
|
+
exec: async (args) => {
|
|
63
|
+
calls.push(args);
|
|
64
|
+
throw Object.assign(new Error("git merge-tree failed"), {
|
|
65
|
+
code: 129,
|
|
66
|
+
stdout: "",
|
|
67
|
+
stderr: "error: unknown option `write-tree'\nusage: git merge-tree <base-tree> <branch1> <branch2>",
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
assert.equal(probe.state, "skipped");
|
|
72
|
+
assert.match(probe.reason, /not supported/);
|
|
73
|
+
assert.deepEqual(calls, [["merge-tree", "--write-tree", "--name-only", "--no-messages", "main", "HEAD"]]);
|
|
74
|
+
});
|
|
75
|
+
test("NOT-355: any other merge-tree failure is an error, never skipped (fail open) or conflict", async () => {
|
|
76
|
+
const badRef = await probeBaseConflict({ worktreePath: repo, baseRef: "no-such-ref" });
|
|
77
|
+
assert.equal(badRef.state, "error");
|
|
78
|
+
assert.match(badRef.reason, /no-such-ref/);
|
|
79
|
+
// A usage-looking stderr without exit 129, or a 129 without the usage dump, is not "unsupported".
|
|
80
|
+
for (const failure of [
|
|
81
|
+
{ code: 128, stderr: "usage: git merge-tree [--write-tree] [<options>] <branch1> <branch2>" },
|
|
82
|
+
{ code: 129, stderr: "fatal: something else entirely" },
|
|
83
|
+
{ code: 1, stderr: "" }, // conflict exit with no conflicted paths
|
|
84
|
+
{ code: "ENOENT", stderr: "" }, // git missing / spawn failure
|
|
85
|
+
]) {
|
|
86
|
+
const probe = await probeBaseConflict({
|
|
87
|
+
worktreePath: repo,
|
|
88
|
+
baseRef: "main",
|
|
89
|
+
exec: async () => {
|
|
90
|
+
throw Object.assign(new Error("git merge-tree failed"), { stdout: "", ...failure });
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
assert.equal(probe.state, "error", `exit ${failure.code} must not fail open`);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
test("NOT-355: headRef probes a branch from the repo itself (no worktree checkout)", async () => {
|
|
97
|
+
const baseRef = divergedPair("by-ref", ["shared.txt", "line one\nby-ref feature\nline three\n"], ["shared.txt", "line one\nby-ref base\nline three\n"]);
|
|
98
|
+
git(repo, "checkout", "-q", "main");
|
|
99
|
+
const probe = await probeBaseConflict({ worktreePath: repo, baseRef, headRef: "by-ref" });
|
|
100
|
+
assert.deepEqual(probe, { state: "conflict", files: ["shared.txt"] });
|
|
101
|
+
assert.deepEqual(await probeBaseConflict({ worktreePath: repo, baseRef: "main", headRef: "by-ref" }), { state: "clean" });
|
|
102
|
+
});
|
|
@@ -18,10 +18,23 @@ import { withRepoLock } from "../runners/process-registry.js";
|
|
|
18
18
|
* Kept inside the repo (not `$AGENT_DEALER_HOME`) so `agent-deck use` grants cover the
|
|
19
19
|
* worker cwd and `bind_workspace` to that path can succeed. */
|
|
20
20
|
export const WORKTREES_DIR_NAME = ".agent-dealer-worktrees";
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
const gitCommandObservers = new Set();
|
|
23
|
+
/** Registers `observer` for every adapter `git` invocation; returns the unsubscribe. */
|
|
24
|
+
export function observeGitCommands(observer) {
|
|
25
|
+
gitCommandObservers.add(observer);
|
|
26
|
+
return () => {
|
|
27
|
+
gitCommandObservers.delete(observer);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function run(file, args, opts) {
|
|
31
|
+
for (const observer of gitCommandObservers)
|
|
32
|
+
observer(args, opts.cwd);
|
|
33
|
+
return execFileAsync(file, args, { ...opts, encoding: "utf8" });
|
|
34
|
+
}
|
|
35
|
+
async function git(cwd, args, timeout) {
|
|
23
36
|
try {
|
|
24
|
-
return await run("git", args, { cwd });
|
|
37
|
+
return await run("git", args, timeout === undefined ? { cwd } : { cwd, timeout });
|
|
25
38
|
}
|
|
26
39
|
catch (err) {
|
|
27
40
|
const e = err;
|
|
@@ -581,11 +594,12 @@ export async function pushLeaseToSha(opts) {
|
|
|
581
594
|
/**
|
|
582
595
|
* NOT-221: read origin's live tip for a branch without touching local refs — shown on
|
|
583
596
|
* the still-open action after a lease push fails, so the operator sees what moved.
|
|
584
|
-
* Null when the branch cannot be resolved remotely (deleted, renamed, or
|
|
597
|
+
* Null when the branch cannot be resolved remotely (deleted, renamed, unreachable, or
|
|
598
|
+
* — when `timeoutMs` is given — the read stalled past that bound).
|
|
585
599
|
*/
|
|
586
600
|
export async function readRemoteTip(opts) {
|
|
587
601
|
try {
|
|
588
|
-
const { stdout } = await git(opts.cwd, ["ls-remote", "origin", `refs/heads/${opts.branch}`]);
|
|
602
|
+
const { stdout } = await git(opts.cwd, ["ls-remote", "origin", `refs/heads/${opts.branch}`], opts.timeoutMs);
|
|
589
603
|
const sha = stdout.trim().split(/\s+/)[0];
|
|
590
604
|
return sha && /^[0-9a-f]{40}$/i.test(sha) ? sha : null;
|
|
591
605
|
}
|
|
@@ -598,6 +612,47 @@ export async function commitsAhead(opts) {
|
|
|
598
612
|
const { stdout } = await git(opts.worktreePath, ["rev-list", "--count", `${opts.baseRef}..HEAD`]);
|
|
599
613
|
return Number(stdout.trim());
|
|
600
614
|
}
|
|
615
|
+
const defaultProbeGitExec = (args, opts) => run("git", args, { cwd: opts.cwd });
|
|
616
|
+
/** git < 2.38 rejects `--write-tree` with exit 129 and the old usage dump. */
|
|
617
|
+
const MERGE_TREE_UNSUPPORTED = /unknown option|usage: git merge-tree/i;
|
|
618
|
+
/**
|
|
619
|
+
* NOT-355: `git merge-tree --write-tree --name-only --no-messages <baseRef> <headRef>`
|
|
620
|
+
* — a pure in-object-store merge. Exit 0 is clean; exit 1 is a conflict whose stdout
|
|
621
|
+
* is the (partial) tree OID followed by one conflicted path per line. Never a merge
|
|
622
|
+
* commit, never a rebase, never a working-tree change.
|
|
623
|
+
*/
|
|
624
|
+
export async function probeBaseConflict(opts) {
|
|
625
|
+
const exec = opts.exec ?? defaultProbeGitExec;
|
|
626
|
+
const args = ["merge-tree", "--write-tree", "--name-only", "--no-messages", opts.baseRef, opts.headRef ?? "HEAD"];
|
|
627
|
+
try {
|
|
628
|
+
await exec(args, { cwd: opts.worktreePath });
|
|
629
|
+
return { state: "clean" };
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
const e = err;
|
|
633
|
+
const stdout = typeof e.stdout === "string" ? e.stdout : "";
|
|
634
|
+
const stderr = typeof e.stderr === "string" ? e.stderr : "";
|
|
635
|
+
if (e.code === 1) {
|
|
636
|
+
const files = [
|
|
637
|
+
...new Set(stdout
|
|
638
|
+
.split("\n")
|
|
639
|
+
.slice(1)
|
|
640
|
+
.map((line) => line.trim())
|
|
641
|
+
.filter((line) => line.length > 0)),
|
|
642
|
+
];
|
|
643
|
+
if (files.length > 0)
|
|
644
|
+
return { state: "conflict", files };
|
|
645
|
+
return {
|
|
646
|
+
state: "error",
|
|
647
|
+
reason: `git merge-tree exited 1 without naming a conflicted path${stderr.trim() ? `: ${stderr.trim()}` : ""}`,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (e.code === 129 && MERGE_TREE_UNSUPPORTED.test(stderr)) {
|
|
651
|
+
return { state: "skipped", reason: "git merge-tree --write-tree is not supported by the installed git" };
|
|
652
|
+
}
|
|
653
|
+
return { state: "error", reason: `git merge-tree failed: ${stderr.trim() || e.message || String(err)}` };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
601
656
|
/** `commitsAhead` between two arbitrary refs — no worktree, no HEAD (see `pushBranchRef`). */
|
|
602
657
|
export async function countCommitsBetween(opts) {
|
|
603
658
|
const { stdout } = await git(opts.repo, ["rev-list", "--count", `${opts.base}..${opts.head}`]);
|
|
@@ -11,7 +11,7 @@ import path from "node:path";
|
|
|
11
11
|
// against findWorktreeForBranch/resolveDeveloperWorktree would fail.
|
|
12
12
|
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-home-")));
|
|
13
13
|
process.env.AGENT_DEALER_HOME = home;
|
|
14
|
-
const { branchExists, createRoleWorktree, safeRemoveWorktree, fetchFreshBase, inspectLeftoverWorktree, isWorktreeClean, withRepoLock, pushBranch, pushWithLease, commitsAhead, findWorktreeForBranch, resolveDeveloperWorktree, worktreesRoot, WORKTREES_DIR_NAME, } = await import("./git-worktree.js");
|
|
14
|
+
const { branchExists, createRoleWorktree, safeRemoveWorktree, fetchFreshBase, readRemoteTip, inspectLeftoverWorktree, isWorktreeClean, withRepoLock, pushBranch, pushWithLease, commitsAhead, findWorktreeForBranch, resolveDeveloperWorktree, worktreesRoot, WORKTREES_DIR_NAME, } = await import("./git-worktree.js");
|
|
15
15
|
let repo;
|
|
16
16
|
let remote;
|
|
17
17
|
function git(cwd, ...args) {
|
|
@@ -1508,3 +1508,15 @@ test("NOT-197: fetchFreshBase uses the local base as-is when the repo has no ori
|
|
|
1508
1508
|
fs.rmSync(bare, { recursive: true, force: true });
|
|
1509
1509
|
}
|
|
1510
1510
|
});
|
|
1511
|
+
test("NOT-356: readRemoteTip honours timeoutMs and returns null for a stalled remote", async () => {
|
|
1512
|
+
const repo = fs.mkdtempSync(path.join(home, "stalled-remote-"));
|
|
1513
|
+
const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "ignore" });
|
|
1514
|
+
git("init", "-q");
|
|
1515
|
+
// An `ext::` remote whose helper never answers stands in for a hung network read.
|
|
1516
|
+
git("config", "protocol.ext.allow", "always");
|
|
1517
|
+
git("remote", "add", "origin", "ext::sh -c 'sleep 30'");
|
|
1518
|
+
const started = Date.now();
|
|
1519
|
+
const tip = await readRemoteTip({ cwd: repo, branch: "any", timeoutMs: 300 });
|
|
1520
|
+
assert.equal(tip, null);
|
|
1521
|
+
assert.ok(Date.now() - started < 10_000, "the stalled ls-remote was not cut off by the timeout");
|
|
1522
|
+
});
|