agent-dealer 1.1.1 → 1.1.3
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/git-worktree.js +87 -3
- package/bundle/server/dist/adapters/git-worktree.test.js +160 -1
- package/bundle/server/dist/coordinator/auto-merge.js +10 -5
- package/bundle/server/dist/coordinator/commands.js +207 -28
- package/bundle/server/dist/coordinator/developer-effect.js +20 -1
- package/bundle/server/dist/coordinator/developer-effect.test.js +148 -1
- package/bundle/server/dist/coordinator/external-merge-close.test.js +290 -0
- package/bundle/server/dist/coordinator/external-merge.js +67 -0
- package/bundle/server/dist/coordinator/failure-reason.js +2 -0
- package/bundle/server/dist/coordinator/human-resolution.js +22 -1
- package/bundle/server/dist/coordinator/merge-failure-retry.test.js +277 -0
- package/bundle/server/dist/coordinator/routing.js +4 -0
- package/bundle/server/dist/coordinator/routing.test.js +14 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +39 -0
- package/bundle/server/dist/coordinator/worker-loop.js +2 -1
- package/bundle/server/dist/repository/issues.js +19 -0
- package/bundle/server/dist/routes/issues.js +2 -2
- package/bundle/server/dist/runners/muse-config-core.js +488 -0
- package/bundle/server/dist/runners/muse-config.js +5 -0
- package/bundle/server/dist/runners/muse-config.test.js +521 -0
- package/bundle/server/package.json +2 -2
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
|
@@ -27,6 +27,67 @@ async function git(cwd, args) {
|
|
|
27
27
|
throw new Error(`git ${args.join(" ")} failed: ${e.stderr?.trim() || e.message}`);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/** Default bound for the pre-branch fetch of `origin/<baseBranch>` (NOT-197). */
|
|
31
|
+
export const DEFAULT_BASE_FETCH_TIMEOUT_MS = 60_000;
|
|
32
|
+
function clampFetchTimeoutMs(timeoutMs) {
|
|
33
|
+
return Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
34
|
+
? timeoutMs
|
|
35
|
+
: DEFAULT_BASE_FETCH_TIMEOUT_MS;
|
|
36
|
+
}
|
|
37
|
+
/** Whether `repo` has an `origin` remote at all. */
|
|
38
|
+
async function hasOriginRemote(repo) {
|
|
39
|
+
try {
|
|
40
|
+
await git(repo, ["remote", "get-url", "origin"]);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* NOT-197: resolve the commit a fresh developer branch must start from. Fetches
|
|
49
|
+
* `origin/<baseBranch>` in the cached clone with a bounded timeout and returns the
|
|
50
|
+
* fetched `origin/<baseBranch>` tip — never the possibly-stale local base branch the
|
|
51
|
+
* cached clone was left on.
|
|
52
|
+
*
|
|
53
|
+
* A repo with no `origin` remote has no fresher truth than its local branches, so the
|
|
54
|
+
* local base is used as-is (legacy local checkouts keep working offline). Any other
|
|
55
|
+
* fetch failure — network down, VPN, timeout — is returned, never silently replaced
|
|
56
|
+
* with the stale local branch: the caller defers the start instead.
|
|
57
|
+
*/
|
|
58
|
+
export async function fetchFreshBase(repo, baseBranch, timeoutMs) {
|
|
59
|
+
const boundMs = clampFetchTimeoutMs(timeoutMs);
|
|
60
|
+
if (!(await hasOriginRemote(repo))) {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await git(repo, ["rev-parse", baseBranch]);
|
|
63
|
+
return { ok: true, sha: stdout.trim(), ref: baseBranch };
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return { ok: false, reason: `no origin remote and local base ${baseBranch} does not resolve: ${err.message}` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await run("git", ["fetch", "origin", baseBranch], { cwd: repo, timeout: boundMs });
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const e = err;
|
|
74
|
+
const timedOut = e?.killed === true;
|
|
75
|
+
const detail = typeof e?.stderr === "string" && e.stderr.trim() ? e.stderr.trim() : e?.message;
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: timedOut
|
|
79
|
+
? `git fetch origin ${baseBranch} timed out after ${boundMs}ms — network or VPN may be down`
|
|
80
|
+
: `git fetch origin ${baseBranch} failed: ${detail}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await git(repo, ["rev-parse", `origin/${baseBranch}`]);
|
|
85
|
+
return { ok: true, sha: stdout.trim(), ref: `origin/${baseBranch}` };
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
return { ok: false, reason: `fetched origin ${baseBranch} but origin/${baseBranch} does not resolve: ${err.message}` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
30
91
|
export async function addWorktree(opts) {
|
|
31
92
|
const args = ["worktree", "add"];
|
|
32
93
|
if (opts.detach)
|
|
@@ -500,15 +561,38 @@ export async function resolveDeveloperWorktree(opts) {
|
|
|
500
561
|
}
|
|
501
562
|
await pruneWorktrees(opts.repo);
|
|
502
563
|
}
|
|
564
|
+
// NOT-197: a fresh issue branch starts from the freshly fetched
|
|
565
|
+
// `origin/<baseBranch>` tip — never the cached clone's possibly-stale local base.
|
|
566
|
+
// A failed fetch returns `base_unavailable` (no branch is created) instead of
|
|
567
|
+
// silently falling back to the stale local branch. The reuse path below needs no
|
|
568
|
+
// fetch — it checks out a branch that already exists — and is unchanged.
|
|
569
|
+
let ref;
|
|
570
|
+
let newBranch;
|
|
571
|
+
let baseSha = null;
|
|
572
|
+
let baseRef = null;
|
|
573
|
+
if (!opts.reuseBranch) {
|
|
574
|
+
const fresh = await fetchFreshBase(opts.repo, opts.baseBranch, opts.fetchTimeoutMs);
|
|
575
|
+
if (!fresh.ok) {
|
|
576
|
+
return { kind: "base_unavailable", reason: fresh.reason };
|
|
577
|
+
}
|
|
578
|
+
ref = fresh.ref;
|
|
579
|
+
newBranch = opts.branchName;
|
|
580
|
+
baseSha = fresh.sha;
|
|
581
|
+
baseRef = fresh.ref;
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
ref = opts.branchName;
|
|
585
|
+
newBranch = undefined;
|
|
586
|
+
}
|
|
503
587
|
const worktreePath = opts.worktreePath ?? roleWorktreePath(opts.repo, opts.sessionId, "developer");
|
|
504
588
|
ensureParentDir(worktreePath);
|
|
505
589
|
await addWorktree({
|
|
506
590
|
repo: opts.repo,
|
|
507
591
|
path: worktreePath,
|
|
508
|
-
ref
|
|
592
|
+
ref,
|
|
509
593
|
detach: false,
|
|
510
|
-
newBranch
|
|
594
|
+
newBranch,
|
|
511
595
|
});
|
|
512
|
-
return { kind: "created", path: tryRealpath(worktreePath) };
|
|
596
|
+
return { kind: "created", path: tryRealpath(worktreePath), baseSha, baseRef };
|
|
513
597
|
});
|
|
514
598
|
}
|
|
@@ -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 { createRoleWorktree, safeRemoveWorktree, inspectLeftoverWorktree, isWorktreeClean, withRepoLock, pushBranch, commitsAhead, findWorktreeForBranch, resolveDeveloperWorktree, worktreesRoot, WORKTREES_DIR_NAME, } = await import("./git-worktree.js");
|
|
14
|
+
const { branchExists, createRoleWorktree, safeRemoveWorktree, fetchFreshBase, inspectLeftoverWorktree, isWorktreeClean, withRepoLock, pushBranch, 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) {
|
|
@@ -500,3 +500,162 @@ test("NOT-127: resolveDeveloperWorktree still escalates a dirty leftover when th
|
|
|
500
500
|
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
501
501
|
await withRepoLock(repo, async () => execFileSync("git", ["worktree", "prune"], { cwd: repo }));
|
|
502
502
|
});
|
|
503
|
+
// ---------------------------------------------------------------- NOT-197: fresh base
|
|
504
|
+
/** An isolated repo + file remote pair, so tests can move or break origin without
|
|
505
|
+
* touching the module-level fixture every other test shares. */
|
|
506
|
+
function makeIsoRepoPair(tag) {
|
|
507
|
+
const isoRepo = fs.mkdtempSync(path.join(os.tmpdir(), `dealer-wt197-repo-${tag}-`));
|
|
508
|
+
git(isoRepo, "init", "-b", "main");
|
|
509
|
+
git(isoRepo, "config", "user.email", "test@example.com");
|
|
510
|
+
git(isoRepo, "config", "user.name", "Test");
|
|
511
|
+
fs.writeFileSync(path.join(isoRepo, "README.md"), "hello\n");
|
|
512
|
+
git(isoRepo, "add", ".");
|
|
513
|
+
git(isoRepo, "commit", "-m", "init");
|
|
514
|
+
const isoRemote = fs.mkdtempSync(path.join(os.tmpdir(), `dealer-wt197-remote-${tag}-`));
|
|
515
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", isoRemote]);
|
|
516
|
+
git(isoRepo, "remote", "add", "origin", isoRemote);
|
|
517
|
+
git(isoRepo, "push", "-q", "origin", "main");
|
|
518
|
+
return { isoRepo, isoRemote };
|
|
519
|
+
}
|
|
520
|
+
/** Advance origin/main from a separate clone so the repo's own local main stays stale. */
|
|
521
|
+
function advanceOriginMain(isoRemote, file) {
|
|
522
|
+
const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt197-other-"));
|
|
523
|
+
try {
|
|
524
|
+
execFileSync("git", ["clone", "-q", isoRemote, other]);
|
|
525
|
+
git(other, "config", "user.email", "test@example.com");
|
|
526
|
+
git(other, "config", "user.name", "Test");
|
|
527
|
+
fs.writeFileSync(path.join(other, file), "upstream\n");
|
|
528
|
+
git(other, "add", ".");
|
|
529
|
+
git(other, "commit", "-q", "-m", "upstream change");
|
|
530
|
+
git(other, "push", "-q", "origin", "main");
|
|
531
|
+
return git(other, "rev-parse", "HEAD");
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
fs.rmSync(other, { recursive: true, force: true });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async function removeIsoWorktree(isoRepo, worktreePath, branch) {
|
|
538
|
+
fs.rmSync(worktreePath, { recursive: true, force: true });
|
|
539
|
+
await withRepoLock(isoRepo, async () => execFileSync("git", ["worktree", "prune"], { cwd: isoRepo }));
|
|
540
|
+
execFileSync("git", ["branch", "-D", branch], { cwd: isoRepo });
|
|
541
|
+
}
|
|
542
|
+
test("NOT-197: a fresh developer branch starts from the fetched origin/<base>, not the stale local base", async () => {
|
|
543
|
+
const { isoRepo, isoRemote } = makeIsoRepoPair("fresh");
|
|
544
|
+
try {
|
|
545
|
+
const staleLocalMain = git(isoRepo, "rev-parse", "main");
|
|
546
|
+
const originTip = advanceOriginMain(isoRemote, "upstream-change.txt");
|
|
547
|
+
assert.notEqual(staleLocalMain, originTip, "the local main must lag origin/main for this test to mean anything");
|
|
548
|
+
assert.equal(git(isoRepo, "rev-parse", "main"), staleLocalMain, "advancing origin must not move the local main");
|
|
549
|
+
const resolved = await resolveDeveloperWorktree({
|
|
550
|
+
repo: isoRepo,
|
|
551
|
+
sessionId: "s-fresh-base",
|
|
552
|
+
branchName: "issue-fresh-base",
|
|
553
|
+
baseBranch: "main",
|
|
554
|
+
reuseBranch: false,
|
|
555
|
+
});
|
|
556
|
+
assert.equal(resolved.kind, "created");
|
|
557
|
+
if (resolved.kind !== "created")
|
|
558
|
+
return;
|
|
559
|
+
assert.equal(resolved.baseSha, originTip, "the recorded base must be the fetched origin/main tip");
|
|
560
|
+
assert.equal(resolved.baseRef, "origin/main");
|
|
561
|
+
assert.equal(git(isoRepo, "rev-parse", "issue-fresh-base"), originTip, "the new branch must start exactly at the origin/main tip, not the stale local main");
|
|
562
|
+
assert.equal(git(isoRepo, "rev-parse", "main"), staleLocalMain, "the stale local base itself is left untouched");
|
|
563
|
+
await removeIsoWorktree(isoRepo, resolved.path, "issue-fresh-base");
|
|
564
|
+
}
|
|
565
|
+
finally {
|
|
566
|
+
fs.rmSync(isoRepo, { recursive: true, force: true });
|
|
567
|
+
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
test("NOT-197: a failed pre-branch fetch returns base_unavailable and creates no branch", async () => {
|
|
571
|
+
const { isoRepo, isoRemote } = makeIsoRepoPair("fail");
|
|
572
|
+
try {
|
|
573
|
+
git(isoRepo, "remote", "set-url", "origin", path.join(isoRemote, "does-not-exist.git"));
|
|
574
|
+
const resolved = await resolveDeveloperWorktree({
|
|
575
|
+
repo: isoRepo,
|
|
576
|
+
sessionId: "s-fetch-fail",
|
|
577
|
+
branchName: "issue-no-base",
|
|
578
|
+
baseBranch: "main",
|
|
579
|
+
reuseBranch: false,
|
|
580
|
+
});
|
|
581
|
+
assert.equal(resolved.kind, "base_unavailable");
|
|
582
|
+
if (resolved.kind === "base_unavailable")
|
|
583
|
+
assert.match(resolved.reason, /fetch/i);
|
|
584
|
+
assert.equal(await branchExists(isoRepo, "issue-no-base"), false, "a failed fetch must never fall back to cutting the branch from the stale local base");
|
|
585
|
+
}
|
|
586
|
+
finally {
|
|
587
|
+
fs.rmSync(isoRepo, { recursive: true, force: true });
|
|
588
|
+
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
test("NOT-197: the reuse path checks out the existing branch without fetching", async () => {
|
|
592
|
+
const { isoRepo, isoRemote } = makeIsoRepoPair("reuse");
|
|
593
|
+
try {
|
|
594
|
+
git(isoRepo, "branch", "issue-reuse-197");
|
|
595
|
+
// Break origin so thoroughly that any fetch attempt would fail — reuse must not care.
|
|
596
|
+
git(isoRepo, "remote", "set-url", "origin", path.join(isoRemote, "does-not-exist.git"));
|
|
597
|
+
const broken = await fetchFreshBase(isoRepo, "main");
|
|
598
|
+
assert.equal(broken.ok, false, "sanity: a fetch against this origin really does fail");
|
|
599
|
+
const leftover = await createRoleWorktree({ repo: isoRepo, role: "developer", sessionId: "s-reuse-left", ref: "issue-reuse-197" });
|
|
600
|
+
const resolved = await resolveDeveloperWorktree({
|
|
601
|
+
repo: isoRepo,
|
|
602
|
+
sessionId: "s-reuse-new",
|
|
603
|
+
branchName: "issue-reuse-197",
|
|
604
|
+
baseBranch: "main",
|
|
605
|
+
reuseBranch: true,
|
|
606
|
+
});
|
|
607
|
+
assert.equal(resolved.kind, "reused");
|
|
608
|
+
if (resolved.kind === "reused")
|
|
609
|
+
assert.equal(resolved.path, leftover.path);
|
|
610
|
+
fs.rmSync(leftover.path, { recursive: true, force: true });
|
|
611
|
+
await withRepoLock(isoRepo, async () => execFileSync("git", ["worktree", "prune"], { cwd: isoRepo }));
|
|
612
|
+
execFileSync("git", ["branch", "-D", "issue-reuse-197"], { cwd: isoRepo });
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
fs.rmSync(isoRepo, { recursive: true, force: true });
|
|
616
|
+
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
test("NOT-197: fetchFreshBase bounds a hanging fetch with a timeout instead of hanging", async () => {
|
|
620
|
+
const { isoRepo, isoRemote } = makeIsoRepoPair("timeout");
|
|
621
|
+
try {
|
|
622
|
+
// An ssh remote whose "ssh" is a script that sleeps: the fetch can only end when our
|
|
623
|
+
// timeout kills it, so this deterministically exercises the timeout classification.
|
|
624
|
+
const sleeper = path.join(isoRepo, "fake-ssh.sh");
|
|
625
|
+
fs.writeFileSync(sleeper, "#!/bin/sh\nsleep 30\n");
|
|
626
|
+
fs.chmodSync(sleeper, 0o755);
|
|
627
|
+
git(isoRepo, "remote", "set-url", "origin", "ssh://git@example.invalid/repo.git");
|
|
628
|
+
git(isoRepo, "config", "core.sshCommand", sleeper);
|
|
629
|
+
const startedAt = Date.now();
|
|
630
|
+
const result = await fetchFreshBase(isoRepo, "main", 1000);
|
|
631
|
+
const elapsedMs = Date.now() - startedAt;
|
|
632
|
+
assert.equal(result.ok, false);
|
|
633
|
+
if (!result.ok)
|
|
634
|
+
assert.match(result.reason, /timed out/);
|
|
635
|
+
assert.ok(elapsedMs < 20_000, `the fetch must be bounded (took ${elapsedMs}ms)`);
|
|
636
|
+
}
|
|
637
|
+
finally {
|
|
638
|
+
fs.rmSync(isoRepo, { recursive: true, force: true });
|
|
639
|
+
fs.rmSync(isoRemote, { recursive: true, force: true });
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
test("NOT-197: fetchFreshBase uses the local base as-is when the repo has no origin remote", async () => {
|
|
643
|
+
const bare = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt197-noorigin-"));
|
|
644
|
+
try {
|
|
645
|
+
git(bare, "init", "-b", "main");
|
|
646
|
+
git(bare, "config", "user.email", "test@example.com");
|
|
647
|
+
git(bare, "config", "user.name", "Test");
|
|
648
|
+
fs.writeFileSync(path.join(bare, "README.md"), "hello\n");
|
|
649
|
+
git(bare, "add", ".");
|
|
650
|
+
git(bare, "commit", "-m", "init");
|
|
651
|
+
const result = await fetchFreshBase(bare, "main");
|
|
652
|
+
assert.equal(result.ok, true);
|
|
653
|
+
if (result.ok) {
|
|
654
|
+
assert.equal(result.ref, "main");
|
|
655
|
+
assert.equal(result.sha, git(bare, "rev-parse", "main"));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
fs.rmSync(bare, { recursive: true, force: true });
|
|
660
|
+
}
|
|
661
|
+
});
|
|
@@ -28,6 +28,7 @@ import { getDb } from "../db/index.js";
|
|
|
28
28
|
import { getIssue, listIssues, transitionIssue } from "../repository/issues.js";
|
|
29
29
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
|
|
30
30
|
import { createHumanAction, findOpenHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
|
|
31
|
+
import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS } from "./human-resolution.js";
|
|
31
32
|
const run = promisify(execFile);
|
|
32
33
|
/** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
|
|
33
34
|
export const GH_MERGE_TIMEOUT_MS = 20_000;
|
|
@@ -291,16 +292,20 @@ function escalateMergeFailure(issue, workflowInstanceId, reason) {
|
|
|
291
292
|
currentOwner: "human",
|
|
292
293
|
currentIntent: reason,
|
|
293
294
|
});
|
|
295
|
+
// NOT-194: the work is approved, so "Resume development" is wrong. Offer a merge
|
|
296
|
+
// retry on the current PR head, another repair round (same as final_review:repair),
|
|
297
|
+
// or close. The reason keeps the underlying failure text verbatim so the operator can
|
|
298
|
+
// pick retry vs repair. Evidence marks this as a merge failure for per-action choice
|
|
299
|
+
// narrowing in resolveHumanActionAndAdvance (pre-NOT-194 open actions have no such
|
|
300
|
+
// evidence and still resolve through resume).
|
|
294
301
|
const action = createHumanAction({
|
|
295
302
|
issueId: issue.id,
|
|
296
303
|
workflowInstanceId,
|
|
297
304
|
actionType: "policy_escalation",
|
|
298
305
|
reason,
|
|
299
|
-
question: `${reason}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
{ choice: "close", label: "Close" },
|
|
303
|
-
],
|
|
306
|
+
question: `${reason} Retry the merge, queue another repair round, or close the issue?`,
|
|
307
|
+
evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true },
|
|
308
|
+
responseOptions: [...MERGE_FAILURE_RESPONSE_OPTIONS],
|
|
304
309
|
});
|
|
305
310
|
appendWorkflowEvent({
|
|
306
311
|
issueId: issue.id,
|