agent-dealer 1.1.3 → 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 (60) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +259 -10
  2. package/bundle/server/dist/adapters/git-worktree.test.js +566 -10
  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/commands.js +292 -10
  7. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +5 -0
  8. package/bundle/server/dist/coordinator/developer-effect.js +130 -7
  9. package/bundle/server/dist/coordinator/developer-effect.test.js +249 -1
  10. package/bundle/server/dist/coordinator/execution-boundaries.test.js +493 -0
  11. package/bundle/server/dist/coordinator/execution-intervals.js +120 -0
  12. package/bundle/server/dist/coordinator/execution-intervals.test.js +109 -0
  13. package/bundle/server/dist/coordinator/failure-cause.js +478 -0
  14. package/bundle/server/dist/coordinator/failure-cause.test.js +287 -0
  15. package/bundle/server/dist/coordinator/failure-reason.test.js +21 -0
  16. package/bundle/server/dist/coordinator/human-resolution.js +17 -1
  17. package/bundle/server/dist/coordinator/latest-failure.js +51 -1
  18. package/bundle/server/dist/coordinator/latest-failure.test.js +55 -0
  19. package/bundle/server/dist/coordinator/projection.js +9 -1
  20. package/bundle/server/dist/coordinator/push-with-lease.test.js +271 -0
  21. package/bundle/server/dist/coordinator/queue-history.js +324 -0
  22. package/bundle/server/dist/coordinator/queue-history.test.js +287 -0
  23. package/bundle/server/dist/coordinator/recovery.js +38 -1
  24. package/bundle/server/dist/coordinator/recovery.test.js +63 -0
  25. package/bundle/server/dist/coordinator/reviewer-effect.js +77 -2
  26. package/bundle/server/dist/coordinator/routing.js +19 -1
  27. package/bundle/server/dist/db/index.js +30 -0
  28. package/bundle/server/dist/db/schema.sql +28 -0
  29. package/bundle/server/dist/repository/admission-settings.js +86 -0
  30. package/bundle/server/dist/repository/admission-settings.test.js +78 -0
  31. package/bundle/server/dist/repository/failure-causes.js +104 -0
  32. package/bundle/server/dist/repository/human-actions.js +19 -0
  33. package/bundle/server/dist/repository/queue-entries.js +86 -42
  34. package/bundle/server/dist/repository/workflow-events.js +8 -0
  35. package/bundle/server/dist/routes/execute-now.integration.test.js +305 -0
  36. package/bundle/server/dist/routes/issues.js +100 -17
  37. package/bundle/server/dist/routes/issues.test.js +159 -1
  38. package/bundle/server/dist/routes/queue.js +29 -2
  39. package/bundle/server/dist/routes/queue.test.js +65 -0
  40. package/bundle/server/package.json +2 -2
  41. package/bundle/server/static-ui/assets/index-Bva8TLlT.js +60 -0
  42. package/bundle/server/static-ui/assets/index-DDI4iqx0.css +1 -0
  43. package/bundle/server/static-ui/index.html +2 -2
  44. package/bundle/shared/dist/failure-cause.d.ts +67 -0
  45. package/bundle/shared/dist/failure-cause.js +67 -0
  46. package/bundle/shared/dist/index.d.ts +3 -2
  47. package/bundle/shared/dist/index.js +1 -0
  48. package/bundle/shared/dist/queue-entries.d.ts +67 -0
  49. package/bundle/shared/dist/queue-entries.js +25 -0
  50. package/bundle/shared/dist/workflow.d.ts +4 -4
  51. package/bundle/shared/dist/workflow.js +16 -0
  52. package/bundle/shared/dist/workflow.test.js +4 -0
  53. package/bundle/shared/package.json +1 -1
  54. package/dist/index.js +1 -0
  55. package/dist/issue.d.ts +3 -0
  56. package/dist/issue.js +15 -0
  57. package/dist/issue.test.js +44 -0
  58. package/package.json +1 -1
  59. package/bundle/server/static-ui/assets/index-CYZRXBZj.css +0 -1
  60. package/bundle/server/static-ui/assets/index-PU5ONp31.js +0 -60
@@ -88,6 +88,94 @@ export async function fetchFreshBase(repo, baseBranch, timeoutMs) {
88
88
  return { ok: false, reason: `fetched origin ${baseBranch} but origin/${baseBranch} does not resolve: ${err.message}` };
89
89
  }
90
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
+ }
91
179
  export async function addWorktree(opts) {
92
180
  const args = ["worktree", "add"];
93
181
  if (opts.detach)
@@ -326,17 +414,98 @@ async function gatherPushRejectionFacts(opts) {
326
414
  return null;
327
415
  }
328
416
  }
329
- 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) {
330
479
  const rejected = PUSH_REJECTION_PATTERNS.test(message);
331
480
  if (!rejected)
332
481
  return { ok: false, reason: message, rejected: false };
333
482
  const facts = await gatherPushRejectionFacts({ cwd, branch, localRef });
334
- if (facts) {
335
- // Prefer the classified summary over raw git stderr so the escalation never carries
336
- // git's misleading `use 'git pull'` hint for a diverged branch (NOT-137).
337
- 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
+ }
338
505
  }
339
- 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 };
340
509
  }
341
510
  /**
342
511
  * The coordinator — never the developer worker — pushes the branch, after the worker's
@@ -352,7 +521,7 @@ export async function pushBranch(opts) {
352
521
  return { ok: true };
353
522
  }
354
523
  catch (err) {
355
- return pushFailureResult(opts.worktreePath, opts.branch, "HEAD", err.message);
524
+ return pushFailureResult(opts.worktreePath, opts.branch, "HEAD", err.message, opts.lastKnownHeadSha);
356
525
  }
357
526
  }
358
527
  /**
@@ -367,7 +536,46 @@ export async function pushBranchRef(opts) {
367
536
  return { ok: true };
368
537
  }
369
538
  catch (err) {
370
- 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;
371
579
  }
372
580
  }
373
581
  /** Commits on HEAD not on `baseRef` — zero means the developer produced nothing to push/PR. */
@@ -500,6 +708,18 @@ export function sessionIdFromRoleWorktreePath(worktreePath) {
500
708
  export async function resolveDeveloperWorktree(opts) {
501
709
  return withRepoLock(opts.repo, async () => {
502
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
+ }
503
723
  const existing = await findWorktreeForBranch(opts.repo, opts.branchName);
504
724
  if (existing) {
505
725
  const managedRoot = opts.worktreePath
@@ -526,6 +746,18 @@ export async function resolveDeveloperWorktree(opts) {
526
746
  }
527
747
  const state = await inspectLeftoverWorktree(existing);
528
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
+ }
529
761
  return { kind: "reused", path: tryRealpath(existing) };
530
762
  }
531
763
  if (state === "dirty_or_unpushed") {
@@ -564,8 +796,7 @@ export async function resolveDeveloperWorktree(opts) {
564
796
  // NOT-197: a fresh issue branch starts from the freshly fetched
565
797
  // `origin/<baseBranch>` tip — never the cached clone's possibly-stale local base.
566
798
  // 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.
799
+ // silently falling back to the stale local branch.
569
800
  let ref;
570
801
  let newBranch;
571
802
  let baseSha = null;
@@ -580,7 +811,25 @@ export async function resolveDeveloperWorktree(opts) {
580
811
  baseSha = fresh.sha;
581
812
  baseRef = fresh.ref;
582
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
+ }
583
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
+ }
584
833
  ref = opts.branchName;
585
834
  newBranch = undefined;
586
835
  }