agent-dealer 1.1.2 → 1.1.4

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 (69) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +344 -11
  2. package/bundle/server/dist/adapters/git-worktree.test.js +716 -1
  3. package/bundle/server/dist/coordinator/admission.js +232 -9
  4. package/bundle/server/dist/coordinator/admission.test.js +227 -2
  5. package/bundle/server/dist/coordinator/agent-boundaries.js +88 -0
  6. package/bundle/server/dist/coordinator/auto-merge.js +10 -5
  7. package/bundle/server/dist/coordinator/commands.js +495 -34
  8. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +5 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +149 -7
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +397 -2
  11. package/bundle/server/dist/coordinator/execution-boundaries.test.js +493 -0
  12. package/bundle/server/dist/coordinator/execution-intervals.js +120 -0
  13. package/bundle/server/dist/coordinator/execution-intervals.test.js +109 -0
  14. package/bundle/server/dist/coordinator/external-merge-close.test.js +290 -0
  15. package/bundle/server/dist/coordinator/external-merge.js +67 -0
  16. package/bundle/server/dist/coordinator/failure-cause.js +478 -0
  17. package/bundle/server/dist/coordinator/failure-cause.test.js +287 -0
  18. package/bundle/server/dist/coordinator/failure-reason.js +2 -0
  19. package/bundle/server/dist/coordinator/failure-reason.test.js +21 -0
  20. package/bundle/server/dist/coordinator/human-resolution.js +38 -1
  21. package/bundle/server/dist/coordinator/latest-failure.js +51 -1
  22. package/bundle/server/dist/coordinator/latest-failure.test.js +55 -0
  23. package/bundle/server/dist/coordinator/merge-failure-retry.test.js +277 -0
  24. package/bundle/server/dist/coordinator/projection.js +9 -1
  25. package/bundle/server/dist/coordinator/push-with-lease.test.js +271 -0
  26. package/bundle/server/dist/coordinator/queue-history.js +324 -0
  27. package/bundle/server/dist/coordinator/queue-history.test.js +287 -0
  28. package/bundle/server/dist/coordinator/recovery.js +38 -1
  29. package/bundle/server/dist/coordinator/recovery.test.js +63 -0
  30. package/bundle/server/dist/coordinator/reviewer-effect.js +77 -2
  31. package/bundle/server/dist/coordinator/routing.js +23 -1
  32. package/bundle/server/dist/coordinator/routing.test.js +14 -0
  33. package/bundle/server/dist/coordinator/usage-cap-defer.js +39 -0
  34. package/bundle/server/dist/coordinator/worker-loop.js +2 -1
  35. package/bundle/server/dist/db/index.js +30 -0
  36. package/bundle/server/dist/db/schema.sql +28 -0
  37. package/bundle/server/dist/repository/admission-settings.js +86 -0
  38. package/bundle/server/dist/repository/admission-settings.test.js +78 -0
  39. package/bundle/server/dist/repository/failure-causes.js +104 -0
  40. package/bundle/server/dist/repository/human-actions.js +19 -0
  41. package/bundle/server/dist/repository/issues.js +19 -0
  42. package/bundle/server/dist/repository/queue-entries.js +86 -42
  43. package/bundle/server/dist/repository/workflow-events.js +8 -0
  44. package/bundle/server/dist/routes/execute-now.integration.test.js +305 -0
  45. package/bundle/server/dist/routes/issues.js +102 -19
  46. package/bundle/server/dist/routes/issues.test.js +159 -1
  47. package/bundle/server/dist/routes/queue.js +29 -2
  48. package/bundle/server/dist/routes/queue.test.js +65 -0
  49. package/bundle/server/package.json +2 -2
  50. package/bundle/server/static-ui/assets/index-Bva8TLlT.js +60 -0
  51. package/bundle/server/static-ui/assets/index-DDI4iqx0.css +1 -0
  52. package/bundle/server/static-ui/index.html +2 -2
  53. package/bundle/shared/dist/failure-cause.d.ts +67 -0
  54. package/bundle/shared/dist/failure-cause.js +67 -0
  55. package/bundle/shared/dist/index.d.ts +3 -2
  56. package/bundle/shared/dist/index.js +1 -0
  57. package/bundle/shared/dist/queue-entries.d.ts +67 -0
  58. package/bundle/shared/dist/queue-entries.js +25 -0
  59. package/bundle/shared/dist/workflow.d.ts +4 -4
  60. package/bundle/shared/dist/workflow.js +16 -0
  61. package/bundle/shared/dist/workflow.test.js +4 -0
  62. package/bundle/shared/package.json +1 -1
  63. package/dist/index.js +1 -0
  64. package/dist/issue.d.ts +3 -0
  65. package/dist/issue.js +15 -0
  66. package/dist/issue.test.js +44 -0
  67. package/package.json +1 -1
  68. package/bundle/server/static-ui/assets/index-CYZRXBZj.css +0 -1
  69. package/bundle/server/static-ui/assets/index-PU5ONp31.js +0 -60
@@ -27,6 +27,155 @@ 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
+ }
91
+ /** `git fetch origin <branch>` output when the remote simply has no such branch. */
92
+ const NO_REMOTE_REF_PATTERNS = /couldn't find remote ref|remote ref .* does not exist/i;
93
+ function fetchErrorDetail(err) {
94
+ const e = err;
95
+ return typeof e?.stderr === "string" && e.stderr.trim() ? e.stderr.trim() : (e?.message ?? String(err));
96
+ }
97
+ /**
98
+ * NOT-219: fetch `origin/<branch>` before reusing an existing issue branch for a
99
+ * repair round, so the worktree starts at exactly what Dealer pushed (and the
100
+ * reviewer reviewed) — never the managed clone's possibly-stale local ref. The
101
+ * stale-local case is real: a round-1 worker that commits on a side branch leaves
102
+ * the local issue branch at its creation point while Dealer pushes HEAD to
103
+ * `origin/<branch>`, and reusing the local ref then cherry-picks/push-rejects.
104
+ *
105
+ * A fetch that fails (network down, timeout) is returned — never silently replaced
106
+ * with the stale local branch: the caller defers the start like NOT-197's
107
+ * `base_unavailable`. The one fetch failure that is NOT a deferral is a remote with
108
+ * no such branch yet (a same-round retry whose branch was never pushed): there is
109
+ * nothing to catch up to, so the local branch is used as-is.
110
+ */
111
+ export async function fetchReusedBranch(repo, branch, timeoutMs) {
112
+ const boundMs = clampFetchTimeoutMs(timeoutMs);
113
+ if (!(await hasOriginRemote(repo))) {
114
+ return { ok: true, remoteSha: null };
115
+ }
116
+ try {
117
+ await run("git", ["fetch", "origin", branch], { cwd: repo, timeout: boundMs });
118
+ }
119
+ catch (err) {
120
+ const e = err;
121
+ const detail = fetchErrorDetail(err);
122
+ if (NO_REMOTE_REF_PATTERNS.test(detail)) {
123
+ return { ok: true, remoteSha: null };
124
+ }
125
+ const timedOut = e?.killed === true;
126
+ return {
127
+ ok: false,
128
+ reason: timedOut
129
+ ? `git fetch origin ${branch} timed out after ${boundMs}ms — network or VPN may be down`
130
+ : `git fetch origin ${branch} failed: ${detail}`,
131
+ };
132
+ }
133
+ try {
134
+ const { stdout } = await git(repo, ["rev-parse", `origin/${branch}`]);
135
+ return { ok: true, remoteSha: stdout.trim() };
136
+ }
137
+ catch (err) {
138
+ return { ok: false, reason: `fetched origin ${branch} but origin/${branch} does not resolve: ${err.message}` };
139
+ }
140
+ }
141
+ /** Whether `ancestor` is an ancestor of (or equal to) `descendant`. */
142
+ export async function isAncestor(repo, ancestor, descendant) {
143
+ try {
144
+ await git(repo, ["merge-base", "--is-ancestor", ancestor, descendant]);
145
+ return true;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ /**
152
+ * NOT-219: fast-forward the managed clone's local `branch` ref to `sha` (the SHA a
153
+ * developer push just published). Strictly a fast-forward — when the local ref has
154
+ * commits the pushed SHA lacks (diverged or ahead), it is preserved untouched and
155
+ * this returns false. Best-effort: returns false instead of throwing, so a caller
156
+ * can safely ignore bookkeeping it cannot complete — the next repair round's fetch
157
+ * heals a ref this missed anyway.
158
+ */
159
+ export async function fastForwardLocalBranchToSha(opts) {
160
+ return withRepoLock(opts.repo, async () => {
161
+ try {
162
+ if (!(await branchExists(opts.repo, opts.branch))) {
163
+ await git(opts.repo, ["branch", opts.branch, opts.sha]);
164
+ return true;
165
+ }
166
+ const localSha = await revParseRef(opts.repo, `refs/heads/${opts.branch}`);
167
+ if (localSha === opts.sha)
168
+ return true;
169
+ if (!(await isAncestor(opts.repo, localSha, opts.sha)))
170
+ return false;
171
+ await git(opts.repo, ["update-ref", `refs/heads/${opts.branch}`, opts.sha, localSha]);
172
+ return true;
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ });
178
+ }
30
179
  export async function addWorktree(opts) {
31
180
  const args = ["worktree", "add"];
32
181
  if (opts.detach)
@@ -265,17 +414,98 @@ async function gatherPushRejectionFacts(opts) {
265
414
  return null;
266
415
  }
267
416
  }
268
- async function pushFailureResult(cwd, branch, localRef, message) {
417
+ /**
418
+ * NOT-220: whether a rejected diverged push is provably safe to recover with a
419
+ * lease-pinned force push. Returns the remote SHA to pin the lease to, or null
420
+ * when today's `unpushed_commit` escalation must stand. ALL must hold:
421
+ *
422
+ * 1. The fetched remote tip is exactly the last head Dealer verified/pushed
423
+ * (`lastKnownHeadSha`) — anyone else having pushed since rules recovery out.
424
+ * 2. Every remote-only commit has a patch-equivalent local counterpart:
425
+ * `git cherry <local> <remote>` lists the remote-only non-merge commits, one
426
+ * line each, `-` when an equivalent patch exists locally. (Note the argument
427
+ * order: head must be the remote so the lines enumerate remote-only commits.)
428
+ * 3. The output is non-empty AND the remote-only range holds no merge commits —
429
+ * `git cherry` never lists merges, so a pure-merge range would otherwise be
430
+ * vacuously "all equivalent" with nothing actually proven.
431
+ */
432
+ async function leasePushExpectedRemoteSha(opts) {
433
+ try {
434
+ if (!opts.lastKnownHeadSha || opts.remoteSha !== opts.lastKnownHeadSha)
435
+ return null;
436
+ const { stdout } = await git(opts.cwd, ["cherry", opts.localSha, opts.remoteSha]);
437
+ const lines = stdout
438
+ .split("\n")
439
+ .map((l) => l.trim())
440
+ .filter((l) => l.length > 0);
441
+ if (lines.length === 0)
442
+ return null;
443
+ if (!lines.every((l) => l.startsWith("-")))
444
+ return null;
445
+ const { stdout: merges } = await git(opts.cwd, [
446
+ "rev-list",
447
+ "--merges",
448
+ `${opts.localSha}..${opts.remoteSha}`,
449
+ ]);
450
+ if (merges.trim().length > 0)
451
+ return null;
452
+ return opts.remoteSha;
453
+ }
454
+ catch {
455
+ return null;
456
+ }
457
+ }
458
+ /**
459
+ * NOT-220: the single lease-pinned retry. Pins the lease to the fetched remote
460
+ * SHA — a race where origin moves between fetch and push makes this fail instead
461
+ * of overwriting anyone's work. Same `-u` upstream tracking as the plain push.
462
+ */
463
+ export async function pushWithLease(opts) {
464
+ try {
465
+ await git(opts.cwd, [
466
+ "push",
467
+ "-u",
468
+ "origin",
469
+ `--force-with-lease=refs/heads/${opts.branch}:${opts.expectedRemoteSha}`,
470
+ `${opts.localRef}:refs/heads/${opts.branch}`,
471
+ ]);
472
+ return { ok: true, newSha: await revParseRef(opts.cwd, opts.localRef) };
473
+ }
474
+ catch (err) {
475
+ return { ok: false, reason: err.message };
476
+ }
477
+ }
478
+ async function pushFailureResult(cwd, branch, localRef, message, lastKnownHeadSha) {
269
479
  const rejected = PUSH_REJECTION_PATTERNS.test(message);
270
480
  if (!rejected)
271
481
  return { ok: false, reason: message, rejected: false };
272
482
  const facts = await gatherPushRejectionFacts({ cwd, branch, localRef });
273
- if (facts) {
274
- // Prefer the classified summary over raw git stderr so the escalation never carries
275
- // git's misleading `use 'git pull'` hint for a diverged branch (NOT-137).
276
- return { ok: false, reason: facts.summary, rejected: true, facts };
483
+ if (!facts)
484
+ return { ok: false, reason: message, rejected: true };
485
+ // NOT-220: a divergence Dealer can prove safe to resolve (remote tip is the
486
+ // last verified head, every remote-only commit patch-equivalent locally) is
487
+ // retried once with the lease pin — no human action needed.
488
+ if (facts.relationship === "diverged") {
489
+ const pin = await leasePushExpectedRemoteSha({
490
+ cwd,
491
+ localSha: facts.localSha,
492
+ remoteSha: facts.remoteSha,
493
+ lastKnownHeadSha,
494
+ });
495
+ if (pin !== null) {
496
+ const retried = await pushWithLease({ cwd, branch, localRef, expectedRemoteSha: pin });
497
+ if (retried.ok)
498
+ return { ok: true, leasePush: { oldSha: pin, newSha: retried.newSha } };
499
+ // The lease lost its race (or the retry failed another way): re-gather so
500
+ // the escalation names the tip that actually beat us, not the stale one.
501
+ const fresh = await gatherPushRejectionFacts({ cwd, branch, localRef });
502
+ if (fresh)
503
+ return { ok: false, reason: fresh.summary, rejected: true, facts: fresh };
504
+ }
277
505
  }
278
- return { ok: false, reason: message, rejected: true };
506
+ // Prefer the classified summary over raw git stderr so the escalation never carries
507
+ // git's misleading `use 'git pull'` hint for a diverged branch (NOT-137).
508
+ return { ok: false, reason: facts.summary, rejected: true, facts };
279
509
  }
280
510
  /**
281
511
  * The coordinator — never the developer worker — pushes the branch, after the worker's
@@ -291,7 +521,7 @@ export async function pushBranch(opts) {
291
521
  return { ok: true };
292
522
  }
293
523
  catch (err) {
294
- return pushFailureResult(opts.worktreePath, opts.branch, "HEAD", err.message);
524
+ return pushFailureResult(opts.worktreePath, opts.branch, "HEAD", err.message, opts.lastKnownHeadSha);
295
525
  }
296
526
  }
297
527
  /**
@@ -306,7 +536,46 @@ export async function pushBranchRef(opts) {
306
536
  return { ok: true };
307
537
  }
308
538
  catch (err) {
309
- return pushFailureResult(opts.repo, opts.branch, `refs/heads/${opts.branch}`, err.message);
539
+ return pushFailureResult(opts.repo, opts.branch, `refs/heads/${opts.branch}`, err.message, opts.lastKnownHeadSha);
540
+ }
541
+ }
542
+ /**
543
+ * NOT-221: publish already-finished commits over a diverged remote with a lease pinned
544
+ * to the exact remote tip the operator reviewed (`remoteSha`, stored in the
545
+ * push_with_lease action's evidence). The explicit `<localSha>:refs/heads/<branch>`
546
+ * refspec pushes the recorded local tip even when the checkout's HEAD has since moved
547
+ * (or the push runs from the repo rather than the preserved worktree).
548
+ *
549
+ * When origin no longer matches the pin the push fails and nothing is published — the
550
+ * caller leaves the action open and shows the freshly observed tip instead.
551
+ */
552
+ export async function pushLeaseToSha(opts) {
553
+ try {
554
+ await git(opts.cwd, [
555
+ "push",
556
+ `--force-with-lease=refs/heads/${opts.branch}:${opts.remoteSha}`,
557
+ "origin",
558
+ `${opts.localSha}:refs/heads/${opts.branch}`,
559
+ ]);
560
+ return { ok: true };
561
+ }
562
+ catch (err) {
563
+ return { ok: false, reason: err.message };
564
+ }
565
+ }
566
+ /**
567
+ * NOT-221: read origin's live tip for a branch without touching local refs — shown on
568
+ * the still-open action after a lease push fails, so the operator sees what moved.
569
+ * Null when the branch cannot be resolved remotely (deleted, renamed, or unreachable).
570
+ */
571
+ export async function readRemoteTip(opts) {
572
+ try {
573
+ const { stdout } = await git(opts.cwd, ["ls-remote", "origin", `refs/heads/${opts.branch}`]);
574
+ const sha = stdout.trim().split(/\s+/)[0];
575
+ return sha && /^[0-9a-f]{40}$/i.test(sha) ? sha : null;
576
+ }
577
+ catch {
578
+ return null;
310
579
  }
311
580
  }
312
581
  /** Commits on HEAD not on `baseRef` — zero means the developer produced nothing to push/PR. */
@@ -439,6 +708,18 @@ export function sessionIdFromRoleWorktreePath(worktreePath) {
439
708
  export async function resolveDeveloperWorktree(opts) {
440
709
  return withRepoLock(opts.repo, async () => {
441
710
  await pruneWorktrees(opts.repo);
711
+ // NOT-219: a repair round reuses an existing issue branch, so first fetch
712
+ // `origin/<branch>` — the worktree must start at exactly what Dealer pushed,
713
+ // never the managed clone's possibly-stale local ref. A failed fetch defers the
714
+ // start (`base_unavailable`) instead of falling back to the stale local branch.
715
+ let reuseRemoteSha = null;
716
+ if (opts.reuseBranch) {
717
+ const reused = await fetchReusedBranch(opts.repo, opts.branchName, opts.fetchTimeoutMs);
718
+ if (!reused.ok) {
719
+ return { kind: "base_unavailable", reason: reused.reason };
720
+ }
721
+ reuseRemoteSha = reused.remoteSha;
722
+ }
442
723
  const existing = await findWorktreeForBranch(opts.repo, opts.branchName);
443
724
  if (existing) {
444
725
  const managedRoot = opts.worktreePath
@@ -465,6 +746,18 @@ export async function resolveDeveloperWorktree(opts) {
465
746
  }
466
747
  const state = await inspectLeftoverWorktree(existing);
467
748
  if (state === "clean") {
749
+ // NOT-219: a clean leftover holding a stale local branch advances to the
750
+ // fetched remote tip when that is a strict fast-forward (nothing unique to
751
+ // lose — the local tip is already an ancestor of the remote one). Any other
752
+ // relationship (ahead, diverged) keeps the leftover exactly as-is.
753
+ if (reuseRemoteSha !== null) {
754
+ const localSha = await revParseRef(opts.repo, `refs/heads/${opts.branchName}`).catch(() => null);
755
+ if (localSha !== null &&
756
+ localSha !== reuseRemoteSha &&
757
+ (await isAncestor(opts.repo, localSha, reuseRemoteSha))) {
758
+ await git(existing, ["merge", "--ff-only", "-q", `origin/${opts.branchName}`]).catch(() => null);
759
+ }
760
+ }
468
761
  return { kind: "reused", path: tryRealpath(existing) };
469
762
  }
470
763
  if (state === "dirty_or_unpushed") {
@@ -500,15 +793,55 @@ export async function resolveDeveloperWorktree(opts) {
500
793
  }
501
794
  await pruneWorktrees(opts.repo);
502
795
  }
796
+ // NOT-197: a fresh issue branch starts from the freshly fetched
797
+ // `origin/<baseBranch>` tip — never the cached clone's possibly-stale local base.
798
+ // A failed fetch returns `base_unavailable` (no branch is created) instead of
799
+ // silently falling back to the stale local branch.
800
+ let ref;
801
+ let newBranch;
802
+ let baseSha = null;
803
+ let baseRef = null;
804
+ if (!opts.reuseBranch) {
805
+ const fresh = await fetchFreshBase(opts.repo, opts.baseBranch, opts.fetchTimeoutMs);
806
+ if (!fresh.ok) {
807
+ return { kind: "base_unavailable", reason: fresh.reason };
808
+ }
809
+ ref = fresh.ref;
810
+ newBranch = opts.branchName;
811
+ baseSha = fresh.sha;
812
+ baseRef = fresh.ref;
813
+ }
814
+ else if (reuseRemoteSha !== null && !(await branchExists(opts.repo, opts.branchName))) {
815
+ // NOT-219: the remote has the issue branch but the clone has no local ref for
816
+ // it (e.g. round 1 committed on a side branch and never created the local
817
+ // issue branch) — cut it at the fetched `origin/<branch>` tip.
818
+ ref = `origin/${opts.branchName}`;
819
+ newBranch = opts.branchName;
820
+ }
821
+ else {
822
+ // NOT-219: when the remote tip is known and the local ref is strictly behind
823
+ // it (an ancestor of it), advance the local ref to the pushed tip before
824
+ // checking it out — the repair worktree then starts at HEAD == origin/<branch>.
825
+ // A local ref with commits the remote lacks (ahead or diverged) is preserved
826
+ // untouched: it is checked out as-is, exactly like the pre-NOT-219 behavior.
827
+ if (reuseRemoteSha !== null && (await branchExists(opts.repo, opts.branchName))) {
828
+ const localSha = await revParseRef(opts.repo, `refs/heads/${opts.branchName}`);
829
+ if (localSha !== reuseRemoteSha && (await isAncestor(opts.repo, localSha, reuseRemoteSha))) {
830
+ await git(opts.repo, ["update-ref", `refs/heads/${opts.branchName}`, reuseRemoteSha, localSha]);
831
+ }
832
+ }
833
+ ref = opts.branchName;
834
+ newBranch = undefined;
835
+ }
503
836
  const worktreePath = opts.worktreePath ?? roleWorktreePath(opts.repo, opts.sessionId, "developer");
504
837
  ensureParentDir(worktreePath);
505
838
  await addWorktree({
506
839
  repo: opts.repo,
507
840
  path: worktreePath,
508
- ref: opts.reuseBranch ? opts.branchName : opts.baseBranch,
841
+ ref,
509
842
  detach: false,
510
- newBranch: opts.reuseBranch ? undefined : opts.branchName,
843
+ newBranch,
511
844
  });
512
- return { kind: "created", path: tryRealpath(worktreePath) };
845
+ return { kind: "created", path: tryRealpath(worktreePath), baseSha, baseRef };
513
846
  });
514
847
  }