agent-dealer 1.1.2 → 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.
@@ -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: opts.reuseBranch ? opts.branchName : opts.baseBranch,
592
+ ref,
509
593
  detach: false,
510
- newBranch: opts.reuseBranch ? undefined : opts.branchName,
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} Resume development, or close the issue?`,
300
- responseOptions: [
301
- { choice: "resume", label: "Resume development" },
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,