agent-dealer 1.2.9 → 1.2.11

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 (33) hide show
  1. package/bundle/server/dist/adapters/base-conflict-probe.test.js +102 -0
  2. package/bundle/server/dist/adapters/git-worktree.js +60 -5
  3. package/bundle/server/dist/adapters/git-worktree.test.js +13 -1
  4. package/bundle/server/dist/adapters/github.js +13 -0
  5. package/bundle/server/dist/adapters/github.test.js +13 -0
  6. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +815 -0
  7. package/bundle/server/dist/coordinator/auto-merge.js +16 -3
  8. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +17 -0
  9. package/bundle/server/dist/coordinator/base-advanced-scan.js +308 -0
  10. package/bundle/server/dist/coordinator/commands.js +232 -1
  11. package/bundle/server/dist/coordinator/developer-effect.js +206 -2
  12. package/bundle/server/dist/coordinator/developer-effect.test.js +490 -1
  13. package/bundle/server/dist/coordinator/merge-conflict-sync.js +278 -16
  14. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +281 -0
  15. package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
  16. package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
  17. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +5 -1
  18. package/bundle/server/dist/coordinator/routing.js +17 -0
  19. package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
  20. package/bundle/server/dist/coordinator/usage-cap-defer.js +3 -0
  21. package/bundle/server/dist/coordinator/worker-loop.js +3 -2
  22. package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
  23. package/bundle/server/dist/runners/muse-code.test.js +13 -1
  24. package/bundle/server/dist/runners/spawn-cli.js +93 -20
  25. package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
  26. package/bundle/server/package.json +2 -2
  27. package/bundle/server/static-ui/assets/{index-CU6fNntg.js → index-Cf4dipuK.js} +10 -10
  28. package/bundle/server/static-ui/index.html +1 -1
  29. package/bundle/shared/dist/workflow.d.ts +4 -4
  30. package/bundle/shared/dist/workflow.js +9 -0
  31. package/bundle/shared/dist/workflow.test.js +1 -0
  32. package/bundle/shared/package.json +1 -1
  33. package/package.json +1 -1
@@ -17,18 +17,19 @@ import fs from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
19
  import { parsePhaseBudget, parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
20
+ import { CONFLICTING_FILES_MAX, runMergeConflictSync } from "./merge-conflict-sync.js";
20
21
  import { getTaskSnapshot } from "./commands.js";
21
22
  import { extractOperatorCriteria } from "./operator-criteria.js";
22
23
  import { buildDeveloperPrompt } from "./prompts.js";
23
24
  import { guidanceForNextSession } from "./guidance.js";
24
25
  import { realDeveloperSpawn, developerSessionLogPath } from "./spawn.js";
25
26
  import { ensureWorktreeDeps, worktreeDepsTimeoutMs, } from "../adapters/worktree-deps.js";
26
- import { DEFAULT_BASE_FETCH_TIMEOUT_MS, fastForwardLocalBranchToSha, resolveDeveloperWorktree, safeRemoveWorktree, isWorktreeClean, commitsAhead, pushBranch, mergeBase, branchExists, pushBranchRef, revParseHead, revParseRef, fetchRef, salvageDirtyWorktree, dirtyWorktreeRecoveryCommands, withRepoLock, leftoverFingerprint, } from "../adapters/git-worktree.js";
27
+ import { DEFAULT_BASE_FETCH_TIMEOUT_MS, fastForwardLocalBranchToSha, resolveDeveloperWorktree, safeRemoveWorktree, isWorktreeClean, commitsAhead, pushBranch, mergeBase, branchExists, pushBranchRef, revParseHead, revParseRef, fetchRef, salvageDirtyWorktree, dirtyWorktreeRecoveryCommands, withRepoLock, leftoverFingerprint, fetchFreshBase, probeBaseConflict, refExists, } from "../adapters/git-worktree.js";
27
28
  import { recordIssueBaseSha } from "../repository/issues.js";
28
29
  import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
29
30
  import { baseRefCandidates, inspectBranchProgress } from "./branch-progress.js";
30
31
  import { prepareWorkerDeckConnection, releaseWorkerDeckConnection, verifyWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
31
- import { realGithubAdapter, pollPrChecksDetailed } from "../adapters/github.js";
32
+ import { realGithubAdapter, pollPrChecksDetailed, } from "../adapters/github.js";
32
33
  import { checksWaitCeilingExceeded, checksWaitCeilingMs } from "./checks-wait-config.js";
33
34
  import { museCapabilitySafetyNetAfterSession } from "../adapters/muse-capability.js";
34
35
  import { getOrAssignSessionCorrelationId, getWorkerSession, mergeSessionMetadata, patchRunningSession, recordSessionProcess, setSessionInputSha } from "../repository/worker-sessions.js";
@@ -328,6 +329,11 @@ function checksWaitStartedAtFromPayload(payloadJson) {
328
329
  * A missing/unknown workflow answer (no adapter method, unreadable base ref) waits
329
330
  * fail-closed: sending an unverified PR to review is the outcome this exists to
330
331
  * prevent, and the wait ceiling still bounds it.
332
+ *
333
+ * NOT-354: "no checks" on a CONFLICTING PR is not a queued wait — GitHub creates no
334
+ * merge ref, so the workflow never starts. That case returns `conflicting` (and the
335
+ * `conflicting` milestone reason) so the caller runs the base sync instead of
336
+ * deferring. UNKNOWN mergeability is "not computed yet" and keeps today's wait.
331
337
  */
332
338
  async function resolveChecksWait(opts) {
333
339
  const waitStartedAt = checksWaitStartedAtFromPayload(opts.payloadJson) ?? opts.pollStartedAt;
@@ -350,6 +356,16 @@ async function resolveChecksWait(opts) {
350
356
  if (hasWorkflow === false) {
351
357
  return { pending: null, milestoneReason: "none_no_workflow" };
352
358
  }
359
+ let mergeable = "UNKNOWN";
360
+ try {
361
+ mergeable = (await opts.github.prMergeableState?.({ cwd: opts.cwd, number: opts.prNumber })) ?? "UNKNOWN";
362
+ }
363
+ catch {
364
+ mergeable = "UNKNOWN";
365
+ }
366
+ if (mergeable === "CONFLICTING") {
367
+ return { pending: null, milestoneReason: "conflicting", conflicting: { waitStartedAt } };
368
+ }
353
369
  return {
354
370
  pending: { ...base, reason: "Waiting for CI (queued) - no attempt spent" },
355
371
  milestoneReason: "none_with_workflow",
@@ -364,6 +380,143 @@ async function resolveChecksWait(opts) {
364
380
  }
365
381
  return { pending: null };
366
382
  }
383
+ /**
384
+ * NOT-354: a conflicting PR's CI wait runs the NOT-310 base sync (`checks_wait`
385
+ * entry: plain push, never force/rebase, one sync + one repair round per episode)
386
+ * from the effect, which still holds the lease. applyCompletion acts on the
387
+ * result. A refusal (`skipped`) becomes the single escalation, never a wait.
388
+ */
389
+ async function checksConflictOutcome(ctx, deps, opts) {
390
+ const sync = deps.conflictSync ?? runMergeConflictSync;
391
+ const conflictReason = `PR #${opts.prNumber} conflicts with ${opts.baseBranch} (mergeable: CONFLICTING), ` +
392
+ `so its CI cannot start.`;
393
+ const base = {
394
+ kind: "checks_conflicting",
395
+ branch: opts.branch,
396
+ baseBranch: opts.baseBranch,
397
+ prNumber: opts.prNumber,
398
+ headSha: opts.headSha,
399
+ waitStartedAt: opts.waitStartedAt,
400
+ };
401
+ let result;
402
+ try {
403
+ result = await sync({
404
+ issueId: ctx.issue.id,
405
+ instanceId: ctx.instance.id,
406
+ repo: ctx.issue.repo,
407
+ branch: opts.branch,
408
+ baseBranch: opts.baseBranch,
409
+ prNumber: opts.prNumber,
410
+ mergeReason: conflictReason,
411
+ entry: "checks_wait",
412
+ });
413
+ }
414
+ catch (err) {
415
+ const detail = err instanceof Error ? err.message : String(err);
416
+ result = { outcome: "skipped", reason: `base sync errored: ${detail}` };
417
+ }
418
+ switch (result.outcome) {
419
+ case "synced":
420
+ return { ...base, sync: { result: "synced", headSha: result.headSha } };
421
+ case "repair_needed":
422
+ return { ...base, sync: { result: "repair", files: result.files } };
423
+ case "escalate":
424
+ return { ...base, sync: { result: "escalate", reason: result.reason, evidence: result.evidence } };
425
+ default:
426
+ // skipped (or a merge-only outcome that cannot occur on this entry).
427
+ return {
428
+ ...base,
429
+ sync: {
430
+ result: "escalate",
431
+ reason: `${conflictReason} Dealer could not sync ${opts.baseBranch}: ${result.outcome === "skipped" ? result.reason : result.outcome}`,
432
+ evidence: { checksConflict: true },
433
+ },
434
+ };
435
+ }
436
+ }
437
+ /**
438
+ * NOT-355: before a developer branch's first push, fetch the base fresh and ask
439
+ * `git merge-tree --write-tree` whether merging it into the branch would conflict.
440
+ * Local git, so it never waits on GitHub's lazily computed mergeability. Returns null
441
+ * to publish exactly as before — on `clean`, or on a git without `--write-tree` (the
442
+ * only fail-open case) — and otherwise the outcome that blocks the push:
443
+ * - conflict → `base_conflict` (applyCompletion queues the one repair round);
444
+ * - base fetch failed → `base_fetch_failed` (NOT-197's network deferral), re-queued
445
+ * publish-only so the next run re-probes instead of re-running the agent;
446
+ * - any other probe failure → `unpushed_commit`, a human decision with the commits kept.
447
+ * `worktreePath` null probes the local branch ref from the repo (the publish-only
448
+ * path). Every probe records one `base.probe` event. Never merges, rebases, or
449
+ * force-pushes.
450
+ */
451
+ async function prePublishBaseProbe(opts) {
452
+ const { repoPath, worktreePath, branchName, baseBranch } = opts;
453
+ const headSha = await (worktreePath ? revParseHead(worktreePath) : revParseRef(repoPath, `refs/heads/${branchName}`)).catch(() => null);
454
+ const common = { baseBranch, branch: branchName, headSha };
455
+ const notPublished = (reason) => ({
456
+ kind: "unpushed_commit",
457
+ reason: `${reason}; branch ${branchName} was not published.`,
458
+ branch: branchName,
459
+ ...(worktreePath ? { worktreePath } : {}),
460
+ });
461
+ // A session's commits must survive on the local issue branch before its checkout
462
+ // goes (strictly a fast-forward — the worker may have committed on a side branch).
463
+ const keepCommits = async () => {
464
+ if (!worktreePath)
465
+ return true;
466
+ if (!headSha)
467
+ return false;
468
+ const kept = await fastForwardLocalBranchToSha({ repo: repoPath, branch: branchName, sha: headSha }).catch(() => false);
469
+ if (kept)
470
+ await bestEffortRemove(repoPath, worktreePath);
471
+ return kept;
472
+ };
473
+ const fresh = await fetchFreshBase(repoPath, baseBranch, developerEffectConfig.baseFetchTimeoutMs);
474
+ if (!fresh.ok) {
475
+ opts.emit(`Developer · base probe deferred: could not fetch ${baseBranch}`, {
476
+ result: "deferred",
477
+ reason: fresh.reason,
478
+ ...common,
479
+ });
480
+ if (!(await keepCommits())) {
481
+ return notPublished(`Could not fetch ${baseBranch} to probe for conflicts (${fresh.reason})`);
482
+ }
483
+ return { kind: "base_fetch_failed", reason: fresh.reason, publishBranch: branchName };
484
+ }
485
+ if (!headSha) {
486
+ opts.emit(`Developer · base probe failed`, { result: "failed", reason: "branch head does not resolve", ...common });
487
+ return notPublished(`Base conflict probe against ${baseBranch} failed (branch head does not resolve)`);
488
+ }
489
+ const probe = await probeBaseConflict({
490
+ worktreePath: worktreePath ?? repoPath,
491
+ baseRef: fresh.sha,
492
+ headRef: worktreePath ? "HEAD" : `refs/heads/${branchName}`,
493
+ exec: opts.exec,
494
+ });
495
+ if (probe.state === "skipped") {
496
+ console.warn(`[dealer] base probe skipped for ${branchName}: ${probe.reason}`);
497
+ opts.emit(`Developer · base probe skipped`, { result: "skipped", reason: probe.reason, baseSha: fresh.sha, ...common });
498
+ return null;
499
+ }
500
+ if (probe.state === "error") {
501
+ opts.emit(`Developer · base probe failed`, { result: "failed", reason: probe.reason, baseSha: fresh.sha, ...common });
502
+ return notPublished(`Base conflict probe against ${baseBranch} failed (${probe.reason})`);
503
+ }
504
+ if (probe.state === "clean") {
505
+ opts.emit(`Developer · base probe clean`, { result: "clean", baseSha: fresh.sha, ...common });
506
+ return null;
507
+ }
508
+ const files = probe.files.slice(0, CONFLICTING_FILES_MAX);
509
+ opts.emit(`Developer · branch conflicts with ${baseBranch}; not publishing`, {
510
+ result: "conflict",
511
+ files: probe.files,
512
+ baseSha: fresh.sha,
513
+ ...common,
514
+ });
515
+ // The repair round continues from the local issue branch; keepCommits leaves the
516
+ // checkout in place when it cannot fast-forward, for crash-recovery inspection.
517
+ await keepCommits();
518
+ return { kind: "base_conflict", branch: branchName, baseBranch, headSha, files };
519
+ }
367
520
  /**
368
521
  * Infra retry that skips the agent: publish whatever the branch already carries. Uses the
369
522
  * issue repo as `gh` cwd and `origin/<branch>` as the verified head (any worktree was
@@ -455,6 +608,20 @@ async function runPublishOnlyHandoff(ctx, deps, branchName) {
455
608
  fetch: true,
456
609
  });
457
610
  if (progress.state === "unpushed") {
611
+ // NOT-355: commits origin has never seen (a reclaim, or a probe deferred on a
612
+ // base fetch) get the same pre-publish probe as a live attempt's first push.
613
+ if (!(await refExists(cwd, `refs/remotes/origin/${branchName}`))) {
614
+ const blocked = await prePublishBaseProbe({
615
+ repoPath: cwd,
616
+ worktreePath: null,
617
+ branchName,
618
+ baseBranch,
619
+ exec: deps.probeGitExec,
620
+ emit: (intent, payload) => milestone("base.probe", intent, payload),
621
+ });
622
+ if (blocked)
623
+ return { kind: "early", outcome: blocked };
624
+ }
458
625
  setLiveIntent(issue.id, `Developer · pushing ${progress.unpushed} recovered commit${progress.unpushed === 1 ? "" : "s"} (round ${round})`);
459
626
  const recovered = await pushBranchRef({
460
627
  repo: cwd,
@@ -667,6 +834,16 @@ async function runPublishOnlyHandoff(ctx, deps, branchName) {
667
834
  author: "system",
668
835
  content: { snapshot: checks, prNumber: prView.number, headSha: prView.headRefOid },
669
836
  });
837
+ // NOT-354: a conflicting PR never gets checks — sync the base instead of waiting.
838
+ if (wait.conflicting) {
839
+ return checksConflictOutcome(ctx, deps, {
840
+ branch: branchName,
841
+ baseBranch: prView.baseRefName,
842
+ prNumber: prView.number,
843
+ headSha: prView.headRefOid,
844
+ waitStartedAt: wait.conflicting.waitStartedAt,
845
+ });
846
+ }
670
847
  // NOT-311: still-waiting CI defers with backoff (no attempt spent) instead of
671
848
  // timing out the session. An aborted wait keeps today's `timed_out`.
672
849
  if (wait.pending)
@@ -1607,6 +1784,21 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1607
1784
  await bestEffortRemove(repoPath, worktreePath);
1608
1785
  return { kind: "no_pr" };
1609
1786
  }
1787
+ // NOT-355: a branch never pushed before (no PR can exist yet) is probed against
1788
+ // the fresh base first — a conflicting branch gets a repair round, not a
1789
+ // conflicting draft PR CI never runs on. Published branches are NOT-354/310's.
1790
+ if (!(await refExists(worktreePath, `refs/remotes/origin/${branchName}`))) {
1791
+ const blocked = await prePublishBaseProbe({
1792
+ repoPath,
1793
+ worktreePath,
1794
+ branchName,
1795
+ baseBranch,
1796
+ exec: deps.probeGitExec,
1797
+ emit: (intent, payload) => milestone("base.probe", intent, payload),
1798
+ });
1799
+ if (blocked)
1800
+ return blocked;
1801
+ }
1610
1802
  setLiveIntent(issue.id, `Developer · pushing branch (round ${round})`);
1611
1803
  // NOT-220: pushBranch retries once with a lease pin when the divergence is
1612
1804
  // proven safe (remote tip is issue.headSha, remote-only commits
@@ -1811,6 +2003,18 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1811
2003
  author: "system",
1812
2004
  content: { snapshot: checks, prNumber: prView.number, headSha: prView.headRefOid },
1813
2005
  });
2006
+ // NOT-354: a conflicting PR never gets checks — sync the base instead of waiting.
2007
+ // The worktree goes first: the sync refuses a branch still checked out elsewhere.
2008
+ if (wait.conflicting) {
2009
+ await bestEffortRemove(repoPath, worktreePath);
2010
+ return checksConflictOutcome(ctx, deps, {
2011
+ branch: branchName,
2012
+ baseBranch: prView.baseRefName,
2013
+ prNumber: prView.number,
2014
+ headSha: prView.headRefOid,
2015
+ waitStartedAt: wait.conflicting.waitStartedAt,
2016
+ });
2017
+ }
1814
2018
  // NOT-311: still-waiting CI defers with backoff (no attempt spent) instead of
1815
2019
  // timing out the session. An aborted wait keeps today's `timed_out`.
1816
2020
  if (wait.pending) {