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.
- package/bundle/server/dist/adapters/git-worktree.js +344 -11
- package/bundle/server/dist/adapters/git-worktree.test.js +716 -1
- package/bundle/server/dist/coordinator/admission.js +232 -9
- package/bundle/server/dist/coordinator/admission.test.js +227 -2
- package/bundle/server/dist/coordinator/agent-boundaries.js +88 -0
- package/bundle/server/dist/coordinator/auto-merge.js +10 -5
- package/bundle/server/dist/coordinator/commands.js +495 -34
- package/bundle/server/dist/coordinator/deck-outage.integration.test.js +5 -0
- package/bundle/server/dist/coordinator/developer-effect.js +149 -7
- package/bundle/server/dist/coordinator/developer-effect.test.js +397 -2
- package/bundle/server/dist/coordinator/execution-boundaries.test.js +493 -0
- package/bundle/server/dist/coordinator/execution-intervals.js +120 -0
- package/bundle/server/dist/coordinator/execution-intervals.test.js +109 -0
- 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-cause.js +478 -0
- package/bundle/server/dist/coordinator/failure-cause.test.js +287 -0
- package/bundle/server/dist/coordinator/failure-reason.js +2 -0
- package/bundle/server/dist/coordinator/failure-reason.test.js +21 -0
- package/bundle/server/dist/coordinator/human-resolution.js +38 -1
- package/bundle/server/dist/coordinator/latest-failure.js +51 -1
- package/bundle/server/dist/coordinator/latest-failure.test.js +55 -0
- package/bundle/server/dist/coordinator/merge-failure-retry.test.js +277 -0
- package/bundle/server/dist/coordinator/projection.js +9 -1
- package/bundle/server/dist/coordinator/push-with-lease.test.js +271 -0
- package/bundle/server/dist/coordinator/queue-history.js +324 -0
- package/bundle/server/dist/coordinator/queue-history.test.js +287 -0
- package/bundle/server/dist/coordinator/recovery.js +38 -1
- package/bundle/server/dist/coordinator/recovery.test.js +63 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +77 -2
- package/bundle/server/dist/coordinator/routing.js +23 -1
- 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/db/index.js +30 -0
- package/bundle/server/dist/db/schema.sql +28 -0
- package/bundle/server/dist/repository/admission-settings.js +86 -0
- package/bundle/server/dist/repository/admission-settings.test.js +78 -0
- package/bundle/server/dist/repository/failure-causes.js +104 -0
- package/bundle/server/dist/repository/human-actions.js +19 -0
- package/bundle/server/dist/repository/issues.js +19 -0
- package/bundle/server/dist/repository/queue-entries.js +86 -42
- package/bundle/server/dist/repository/workflow-events.js +8 -0
- package/bundle/server/dist/routes/execute-now.integration.test.js +305 -0
- package/bundle/server/dist/routes/issues.js +102 -19
- package/bundle/server/dist/routes/issues.test.js +159 -1
- package/bundle/server/dist/routes/queue.js +29 -2
- package/bundle/server/dist/routes/queue.test.js +65 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-Bva8TLlT.js +60 -0
- package/bundle/server/static-ui/assets/index-DDI4iqx0.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/failure-cause.d.ts +67 -0
- package/bundle/shared/dist/failure-cause.js +67 -0
- package/bundle/shared/dist/index.d.ts +3 -2
- package/bundle/shared/dist/index.js +1 -0
- package/bundle/shared/dist/queue-entries.d.ts +67 -0
- package/bundle/shared/dist/queue-entries.js +25 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +16 -0
- package/bundle/shared/dist/workflow.test.js +4 -0
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +1 -0
- package/dist/issue.d.ts +3 -0
- package/dist/issue.js +15 -0
- package/dist/issue.test.js +44 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-CYZRXBZj.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
841
|
+
ref,
|
|
509
842
|
detach: false,
|
|
510
|
-
newBranch
|
|
843
|
+
newBranch,
|
|
511
844
|
});
|
|
512
|
-
return { kind: "created", path: tryRealpath(worktreePath) };
|
|
845
|
+
return { kind: "created", path: tryRealpath(worktreePath), baseSha, baseRef };
|
|
513
846
|
});
|
|
514
847
|
}
|