agent-dealer 1.2.7 → 1.2.8

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 (87) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +4 -1
  5. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  6. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  7. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  8. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  9. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  10. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  11. package/bundle/server/dist/coordinator/admission.js +14 -1
  12. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  13. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  14. package/bundle/server/dist/coordinator/auto-merge.js +39 -3
  15. package/bundle/server/dist/coordinator/commands.js +90 -5
  16. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  17. package/bundle/server/dist/coordinator/developer-effect.js +79 -1
  18. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  19. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  20. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  21. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  22. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  23. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  24. package/bundle/server/dist/coordinator/human-resolution.js +17 -1
  25. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  26. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  27. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  28. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  29. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  30. package/bundle/server/dist/coordinator/playbook-feedback.js +690 -0
  31. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  32. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  33. package/bundle/server/dist/coordinator/prompts.js +78 -2
  34. package/bundle/server/dist/coordinator/prompts.test.js +83 -0
  35. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  36. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  37. package/bundle/server/dist/coordinator/reviewer-effect.js +10 -1
  38. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  39. package/bundle/server/dist/coordinator/routing.test.js +14 -0
  40. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  41. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  42. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  43. package/bundle/server/dist/db/index.js +5 -0
  44. package/bundle/server/dist/db/schema.sql +3 -0
  45. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  46. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  47. package/bundle/server/dist/repository/human-actions.js +14 -0
  48. package/bundle/server/dist/repository/issues.js +29 -4
  49. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  50. package/bundle/server/dist/routes/human-actions.js +10 -8
  51. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  52. package/bundle/server/dist/routes/issues.js +19 -2
  53. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  54. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  55. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  56. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  57. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  58. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  59. package/bundle/server/package.json +2 -2
  60. package/bundle/server/static-ui/assets/{index-yLyxRd-7.js → index-B6SVCzMR.js} +14 -14
  61. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  62. package/bundle/server/static-ui/index.html +2 -2
  63. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  64. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  65. package/bundle/shared/dist/execution-contract.js +307 -0
  66. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  67. package/bundle/shared/dist/execution-contract.test.js +499 -0
  68. package/bundle/shared/dist/execution-report.d.ts +9 -9
  69. package/bundle/shared/dist/execution-report.js +2 -0
  70. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  71. package/bundle/shared/dist/failure-cause.js +2 -0
  72. package/bundle/shared/dist/human-actions.d.ts +4 -4
  73. package/bundle/shared/dist/human-actions.js +9 -0
  74. package/bundle/shared/dist/index.d.ts +85 -84
  75. package/bundle/shared/dist/index.js +3 -0
  76. package/bundle/shared/dist/issues.d.ts +146 -16
  77. package/bundle/shared/dist/issues.js +8 -0
  78. package/bundle/shared/dist/issues.test.js +1 -0
  79. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  80. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  81. package/bundle/shared/dist/worker-sessions.js +8 -0
  82. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  83. package/bundle/shared/dist/workflow.d.ts +4 -4
  84. package/bundle/shared/dist/workflow.js +10 -0
  85. package/bundle/shared/dist/workflow.test.js +2 -0
  86. package/bundle/shared/package.json +1 -1
  87. package/package.json +1 -1
@@ -10,6 +10,13 @@ export const MERGE_FAILURE_RESPONSE_OPTIONS = [
10
10
  ];
11
11
  /** Evidence key marking a policy_escalation as a NOT-194 merge failure. */
12
12
  export const MERGE_FAILURE_EVIDENCE_KEY = "mergeFailure";
13
+ /**
14
+ * NOT-310: conflicting file paths (string[]) carried on a merge-failure
15
+ * policy_escalation's evidence once the automatic sync + conflict-repair round are
16
+ * spent. Read by operators, not by routing — choice narrowing still keys off
17
+ * `MERGE_FAILURE_EVIDENCE_KEY` alone.
18
+ */
19
+ export const MERGE_CONFLICT_FILES_EVIDENCE_KEY = "conflictingFiles";
13
20
  /** Evidence key marking a policy_escalation as a NOT-221 diverged-push escalation. */
14
21
  export const PUSH_DIVERGENCE_EVIDENCE_KEY = "pushDivergence";
15
22
  /** NOT-280: evidence key holding a worktree blocker's `{ fingerprint }` for action dedupe. */
@@ -71,6 +78,10 @@ const VALID_CHOICES = {
71
78
  deck_interaction_required: ["resume", "close"],
72
79
  reflection_interaction_required: ["retry", "dismiss"],
73
80
  outbound_delivery_interaction_required: ["retry_send", "reject"],
81
+ // NOT-308: listed only so the map stays total over HumanActionType — parse rejects it
82
+ // below and the only legal resolver is commands.ts's dedicated muse_capability branch
83
+ // (acknowledge records a per-version override; there is no workflow outcome to map).
84
+ muse_capability: ["acknowledge"],
74
85
  };
75
86
  /**
76
87
  * Validates a raw (actionType, choice) pair against that action type's allowed response
@@ -84,9 +95,14 @@ const VALID_CHOICES = {
84
95
  * `outbound_delivery_interaction_required` is rejected for the same reason (NOT-95): it is
85
96
  * Run-scoped, has no Issue/workflow_instance to advance, and its only legal resolver is
86
97
  * `resolveOutboundDeliveryAction` (queue/approve-deliver.ts).
98
+ * `muse_capability` is rejected the same way (NOT-308): acknowledging it records a
99
+ * per-version capability override, not a workflow outcome, and its only legal resolver
100
+ * is commands.ts's dedicated branch.
87
101
  */
88
102
  export function parseHumanResolution(actionType, choice, note) {
89
- if (actionType === "reflection_interaction_required" || actionType === "outbound_delivery_interaction_required") {
103
+ if (actionType === "reflection_interaction_required" ||
104
+ actionType === "outbound_delivery_interaction_required" ||
105
+ actionType === "muse_capability") {
90
106
  return null;
91
107
  }
92
108
  if (!(actionType in VALID_CHOICES))
@@ -0,0 +1,647 @@
1
+ // packages/server/src/coordinator/merge-conflict-sync.ts
2
+ //
3
+ // NOT-310: when an approved PR cannot merge because the base moved ("not
4
+ // mergeable"), resolve it in the merge path instead of escalating with no way
5
+ // forward: bring the PR branch up to date with the base in a coordinator-owned
6
+ // worktree, wait for checks, and retry the merge once. A textual conflict aborts
7
+ // the sync and queues one developer repair round whose prompt names the base and
8
+ // the conflicting files; a still-conflicting merge after that round escalates
9
+ // once with the file list.
10
+ //
11
+ // "Not mergeable" is broader than staleness (branch policy, dismissed
12
+ // approvals), so a retry that still fails only earns the repair round when the
13
+ // base actually advanced past the synced tip — otherwise the branch is already
14
+ // up to date and Dealer escalates directly with the retry's reason instead of
15
+ // spending a round on a false "base moved". Note the sync push itself can
16
+ // dismiss the approval the merge depended on in repos with dismiss-stale-
17
+ // approvals; that retry failure then takes this same direct-escalation path.
18
+ //
19
+ // Bound: at most one automatic sync + one conflict-repair round per
20
+ // merge-failure episode. The episode resets on any resolved human action (a
21
+ // retry_merge / repair click starts a fresh episode); the repair-spent marker is
22
+ // the append-only `auto_merge.conflict_repair_queued` event, which no
23
+ // requeue/defer can rewrite the way a work-item payload could be.
24
+ //
25
+ // Safety: the sync checkout starts at exactly what Dealer pushed (NOT-219 reuse
26
+ // semantics) and is removed afterwards; the push is always a plain
27
+ // `git push -u origin HEAD:refs/heads/<branch>` — never force, never a lease
28
+ // retry, never a rebase. An existing checkout holding the branch belongs to
29
+ // someone else and fails closed to today's escalation, except our own
30
+ // merge-sync leftover from a crashed run (dead owner + clean tree), which is
31
+ // adopted. Every refusal or infra failure degrades to today's escalation — the
32
+ // sync only ever adds a self-resolution attempt, never removes an outcome.
33
+ import { execFile } from "node:child_process";
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+ import { promisify } from "node:util";
37
+ import { MERGE_CONFLICT_FILES_EVIDENCE_KEY, MERGE_FAILURE_EVIDENCE_KEY, } from "./human-resolution.js";
38
+ import { getAgent } from "../repository/agents.js";
39
+ import { getDb } from "../db/index.js";
40
+ import { getIssue, incrementIssueRound, transitionIssue } from "../repository/issues.js";
41
+ import { enqueueWorkItem, listWorkItemsForIssue, } from "../repository/work-items.js";
42
+ import { appendWorkflowEvent, getActiveWorkflowInstance, listWorkflowEventsForIssue, } from "../repository/workflow-events.js";
43
+ import { addWorktree, DEFAULT_BASE_FETCH_TIMEOUT_MS, fastForwardLocalBranchToSha, fetchFreshBase, fetchReusedBranch, findWorktreeForBranch, isAncestor, isWorktreeClean, pruneWorktrees, removeWorktree, revParseHead, safeRemoveWorktree, } from "../adapters/git-worktree.js";
44
+ import { classifyIssueRepo, worktreesRootForResolution, } from "../adapters/managed-repo.js";
45
+ import { pollPrChecks, realGithubAdapter, } from "../adapters/github.js";
46
+ import { withRepoLock } from "../runners/process-registry.js";
47
+ import { checkDeveloperWorktreeOwnerLiveness } from "./worktree-owner-liveness.js";
48
+ import { buildProfileSnapshot, serializeProfileSnapshot } from "./profile-snapshot.js";
49
+ const run = promisify(execFile);
50
+ /** `gh pr merge` wording for a branch that no longer merges cleanly. */
51
+ const NOT_MERGEABLE_PATTERN = /not mergeable/i;
52
+ /** `gh`'s CONFLICTING mergeable state and conflict failure text. */
53
+ const CONFLICT_PATTERN = /conflict/i;
54
+ /**
55
+ * Whether a merge-failure reason means "the branch conflicts with the base"
56
+ * (worth a base sync) rather than an infra failure (timeout, missing cwd, auth)
57
+ * or a non-staleness rejection (protected branch, failed checks). Either signal
58
+ * from the ticket — `mergeable = CONFLICTING` or the `gh` message — classifies;
59
+ * the message alone suffices because a failed `gh pr merge` already reports it.
60
+ */
61
+ export function isMergeConflictFailure(reason) {
62
+ return NOT_MERGEABLE_PATTERN.test(reason) || CONFLICT_PATTERN.test(reason);
63
+ }
64
+ /** Failure from {@link defaultSyncGitExec} — `killed` marks a timeout kill, as
65
+ * opposed to git itself exiting nonzero (a merge reporting conflicts). */
66
+ export class SyncGitError extends Error {
67
+ stdout;
68
+ stderr;
69
+ killed;
70
+ constructor(message, opts) {
71
+ super(message);
72
+ this.name = "SyncGitError";
73
+ this.stdout = opts.stdout;
74
+ this.stderr = opts.stderr;
75
+ this.killed = opts.killed;
76
+ }
77
+ }
78
+ export const defaultSyncGitExec = async (args, opts) => {
79
+ try {
80
+ const { stdout, stderr } = await run("git", args, {
81
+ cwd: opts.cwd,
82
+ encoding: "utf8",
83
+ timeout: opts.timeoutMs,
84
+ });
85
+ return { stdout, stderr };
86
+ }
87
+ catch (err) {
88
+ const e = err;
89
+ const stdout = typeof e.stdout === "string" ? e.stdout : "";
90
+ const stderr = typeof e.stderr === "string" ? e.stderr : "";
91
+ const detail = stderr.trim() || e.message || `git ${args.join(" ")} failed`;
92
+ throw new SyncGitError(detail, {
93
+ stdout,
94
+ stderr,
95
+ killed: e.killed === true,
96
+ });
97
+ }
98
+ };
99
+ let gitExecImpl = defaultSyncGitExec;
100
+ /** Test hook — record or fake the sync's merge/push/abort shell-outs. */
101
+ export function setConflictSyncGitExecForTests(exec) {
102
+ gitExecImpl = exec ?? defaultSyncGitExec;
103
+ }
104
+ let githubImpl = realGithubAdapter;
105
+ /** Test hook — fake the post-push checks poll (never hits real `gh`). */
106
+ export function setConflictSyncGithubForTests(adapter) {
107
+ githubImpl = adapter ?? realGithubAdapter;
108
+ }
109
+ function numEnv(name, fallback) {
110
+ const raw = process.env[name];
111
+ const n = raw == null || raw === "" ? NaN : Number(raw);
112
+ return Number.isFinite(n) && n > 0 ? n : fallback;
113
+ }
114
+ /**
115
+ * Sync bounds. The checks poll reads the same env as `developerEffectConfig`
116
+ * (one operator-facing knob for "how long CI may take"); the import is not
117
+ * shared because merge-conflict-sync → developer-effect would cycle through
118
+ * commands/auto-merge.
119
+ */
120
+ export const mergeSyncConfig = {
121
+ get syncGitTimeoutMs() {
122
+ return numEnv("MERGE_SYNC_GIT_TIMEOUT_MS", DEFAULT_BASE_FETCH_TIMEOUT_MS);
123
+ },
124
+ get checksPollTimeoutMs() {
125
+ return numEnv("CHECKS_POLL_TIMEOUT_MS", 10 * 60_000);
126
+ },
127
+ get checksPollIntervalMs() {
128
+ return numEnv("CHECKS_POLL_INTERVAL_MS", 15_000);
129
+ },
130
+ };
131
+ /** Cap for the conflicting-file list in escalation text/evidence. */
132
+ export const CONFLICTING_FILES_MAX = 20;
133
+ /** Coordinator-owned sync checkout infix — adoption + stale-dir removal only
134
+ * ever touch paths under this name, never a worker session's checkout. */
135
+ const SYNC_PATH_INFIX = "merge-sync-";
136
+ /** Explicit identity for the sync's merge commit — coordinator checkouts must
137
+ * not depend on whatever a worker happened to configure (mirrors the salvage
138
+ * commit in git-worktree.ts). The squash-merge erases it anyway. */
139
+ const SYNC_GIT_IDENTITY_ARGS = [
140
+ "-c",
141
+ "user.email=agent-dealer@localhost",
142
+ "-c",
143
+ "user.name=Agent Dealer",
144
+ ];
145
+ function parseRepairFiles(payloadJson) {
146
+ if (!payloadJson)
147
+ return [];
148
+ try {
149
+ const parsed = JSON.parse(payloadJson);
150
+ return Array.isArray(parsed.files)
151
+ ? parsed.files.filter((f) => typeof f === "string")
152
+ : [];
153
+ }
154
+ catch {
155
+ return [];
156
+ }
157
+ }
158
+ /**
159
+ * Whether a conflict-repair round already ran for this merge-failure episode:
160
+ * the latest `auto_merge.conflict_repair_queued` event for this instance with
161
+ * no `human_action.resolved` after it. A human resolution (retry_merge, repair,
162
+ * or anything else) starts a fresh episode. Returns the spent round's file list
163
+ * for the escalation.
164
+ */
165
+ export function conflictRepairSpent(issueId, instanceId) {
166
+ const events = listWorkflowEventsForIssue(issueId).filter((e) => e.workflowInstanceId === instanceId);
167
+ let lastRepair = -1;
168
+ let lastResolved = -1;
169
+ let files = [];
170
+ events.forEach((e, index) => {
171
+ if (e.type === "auto_merge.conflict_repair_queued") {
172
+ lastRepair = index;
173
+ files = parseRepairFiles(e.payloadJson);
174
+ }
175
+ else if (e.type === "human_action.resolved") {
176
+ lastResolved = index;
177
+ }
178
+ });
179
+ return lastRepair > lastResolved
180
+ ? { spent: true, files }
181
+ : { spent: false, files: [] };
182
+ }
183
+ /**
184
+ * Mirrors commands.ts's `queuedProfileSnapshot` (kept local: auto-merge →
185
+ * commands would close an import cycle — commands already imports auto-merge).
186
+ */
187
+ function queuedDeveloperProfileSnapshot(issue) {
188
+ const agent = issue.developerAgentId ? getAgent(issue.developerAgentId) : null;
189
+ return agent ? serializeProfileSnapshot(buildProfileSnapshot(agent, "developer")) : null;
190
+ }
191
+ /** Mirrors commands.ts's `resumeWorkItemKey` collision scan for the same reason. */
192
+ function conflictRepairWorkItemKey(issueId, instanceId, round) {
193
+ const taken = new Set(listWorkItemsForIssue(issueId).map((w) => w.idempotencyKey));
194
+ const base = `${instanceId}:developer:conflict-repair:${round}`;
195
+ if (!taken.has(base))
196
+ return base;
197
+ let n = 2;
198
+ while (taken.has(`${base}:${n}`))
199
+ n++;
200
+ return `${base}:${n}`;
201
+ }
202
+ function auditSync(input, outcome, detail) {
203
+ try {
204
+ appendWorkflowEvent({
205
+ issueId: input.issueId,
206
+ workflowInstanceId: input.instanceId,
207
+ workerSessionId: null,
208
+ type: "auto_merge.conflict_sync",
209
+ actorType: "system",
210
+ stage: "final_review",
211
+ round: getIssue(input.issueId)?.currentRound ?? null,
212
+ payload: {
213
+ outcome,
214
+ branch: input.branch,
215
+ baseBranch: input.baseBranch,
216
+ ...(detail ?? {}),
217
+ },
218
+ });
219
+ }
220
+ catch {
221
+ // Audit only — observability must never fail the merge path.
222
+ }
223
+ }
224
+ function formatFileList(files) {
225
+ const shown = files.slice(0, CONFLICTING_FILES_MAX);
226
+ const extra = files.length - shown.length;
227
+ return shown.join(", ") + (extra > 0 ? ` (and ${extra} more)` : "");
228
+ }
229
+ /**
230
+ * Queues the conflict-repair developer round: final_review → repairing (a
231
+ * genuine repair cycle spending a review round, like merge-failure "repair"),
232
+ * with the conflict directive on the work-item payload for exactly this round.
233
+ * Null when the issue left the merge park first (a racer won) — the caller
234
+ * then falls back to today's escalation, which no-ops off-park itself.
235
+ */
236
+ function queueConflictRepairRound(input) {
237
+ return getDb().transaction(() => {
238
+ const current = getIssue(input.issueId);
239
+ if (!current || current.status !== "final_review")
240
+ return null;
241
+ const active = getActiveWorkflowInstance(input.issueId);
242
+ if (!active || active.id !== input.instanceId)
243
+ return null;
244
+ const round = current.currentRound + 1;
245
+ incrementIssueRound(input.issueId);
246
+ transitionIssue(input.issueId, "repairing", {
247
+ currentOwner: "developer",
248
+ currentIntent: `Resolving merge conflict with ${input.baseBranch} (round ${round})`,
249
+ });
250
+ appendWorkflowEvent({
251
+ issueId: input.issueId,
252
+ workflowInstanceId: active.id,
253
+ workerSessionId: null,
254
+ type: "repair.started",
255
+ actorType: "system",
256
+ stage: "repairing",
257
+ round,
258
+ });
259
+ const directive = {
260
+ baseBranch: input.baseBranch,
261
+ branch: input.branch,
262
+ files: input.files,
263
+ };
264
+ const item = enqueueWorkItem({
265
+ issueId: input.issueId,
266
+ workflowInstanceId: active.id,
267
+ kind: "developer",
268
+ round,
269
+ payload: {
270
+ profileSnapshot: queuedDeveloperProfileSnapshot(current),
271
+ conflictRepair: directive,
272
+ },
273
+ idempotencyKey: conflictRepairWorkItemKey(input.issueId, active.id, round),
274
+ });
275
+ appendWorkflowEvent({
276
+ issueId: input.issueId,
277
+ workflowInstanceId: active.id,
278
+ workerSessionId: null,
279
+ type: "auto_merge.conflict_repair_queued",
280
+ actorType: "system",
281
+ stage: "repairing",
282
+ round,
283
+ payload: {
284
+ baseBranch: input.baseBranch,
285
+ branch: input.branch,
286
+ files: input.files,
287
+ round,
288
+ workItemId: item.id,
289
+ },
290
+ });
291
+ return { id: item.id, round };
292
+ })();
293
+ }
294
+ function tryRealpath(p) {
295
+ try {
296
+ return fs.realpathSync(p);
297
+ }
298
+ catch {
299
+ return p;
300
+ }
301
+ }
302
+ async function listUnmergedFiles(syncPath, timeoutMs) {
303
+ const { stdout } = await gitExecImpl(["diff", "--name-only", "--diff-filter=U"], {
304
+ cwd: syncPath,
305
+ timeoutMs,
306
+ });
307
+ return stdout
308
+ .split("\n")
309
+ .map((line) => line.trim())
310
+ .filter((line) => line.length > 0);
311
+ }
312
+ /**
313
+ * Undo a failed merge. The sync checkout was verified clean with HEAD at the
314
+ * fetched origin tip before the merge, so it carries no unique commits and
315
+ * resetting its uncommitted merge state cannot lose work.
316
+ */
317
+ async function abortMergeState(syncPath, timeoutMs) {
318
+ try {
319
+ await gitExecImpl(["merge", "--abort"], { cwd: syncPath, timeoutMs });
320
+ return;
321
+ }
322
+ catch {
323
+ // No merge in progress (tool failure before git started merging), or an
324
+ // abort that itself failed — fall through to the equivalent reset.
325
+ }
326
+ await gitExecImpl(["reset", "--hard", "HEAD"], { cwd: syncPath, timeoutMs });
327
+ }
328
+ /**
329
+ * Whether `origin/<base>` advanced past the synced tip since the sync fetched
330
+ * it. Only a descendant counts — a rewritten base (force-push) is a human
331
+ * call, not new staleness a repair round should merge. A failed re-fetch
332
+ * fails closed to "not advanced": the repair round is the expensive action,
333
+ * so an unreadable base escalates instead of spending it.
334
+ */
335
+ async function checkBaseAdvanced(repoPath, baseBranch, syncedSha, timeoutMs) {
336
+ const fresh = await fetchFreshBase(repoPath, baseBranch, timeoutMs);
337
+ if (!fresh.ok)
338
+ return { advanced: false, freshSha: null, fetchFailed: true };
339
+ if (fresh.sha === syncedSha)
340
+ return { advanced: false, freshSha: fresh.sha, fetchFailed: false };
341
+ const advanced = await isAncestor(repoPath, syncedSha, fresh.sha).catch(() => false);
342
+ return { advanced, freshSha: fresh.sha, fetchFailed: false };
343
+ }
344
+ /** Best-effort removal of our own sync checkout. `branchPushed: true` is
345
+ * truthful on the abort path (the branch never moved off the fetched origin
346
+ * tip) and safe on the failed-push path (the only unpushed state possible is
347
+ * our own reproducible base merge, which the repair round re-does). */
348
+ async function cleanupSyncCheckout(repoPath, syncPath) {
349
+ try {
350
+ await safeRemoveWorktree({ repo: repoPath, path: syncPath, role: "developer", branchPushed: true });
351
+ }
352
+ catch {
353
+ // Leave it: the next repair round's worktree resolution reuses or reports it.
354
+ }
355
+ }
356
+ /**
357
+ * Runs the NOT-310 self-resolution for one not-mergeable failure. See the
358
+ * module doc for the bound, the safety invariant, and the fail-closed shape:
359
+ * every refusal returns `skipped` so the caller escalates exactly as today.
360
+ */
361
+ export async function runMergeConflictSync(opts) {
362
+ const auditInput = {
363
+ issueId: opts.issueId,
364
+ instanceId: opts.instanceId,
365
+ branch: opts.branch,
366
+ baseBranch: opts.baseBranch,
367
+ };
368
+ const skip = (reason) => {
369
+ auditSync(auditInput, "skipped", { reason });
370
+ return { outcome: "skipped", reason };
371
+ };
372
+ const failed = (detail) => {
373
+ auditSync(auditInput, "failed", { detail });
374
+ return {
375
+ outcome: "escalate",
376
+ reason: `Auto-merge failed: ${opts.mergeReason} (base sync ${opts.baseBranch}: ${detail})`,
377
+ evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true },
378
+ };
379
+ };
380
+ // The conflict-repair round already ran for this episode and the merge still
381
+ // conflicts — escalate once with the file list, never sync again.
382
+ const spent = conflictRepairSpent(opts.issueId, opts.instanceId);
383
+ if (spent.spent) {
384
+ auditSync(auditInput, "conflict_spent", { files: spent.files });
385
+ const filesText = spent.files.length > 0
386
+ ? `Conflicting files: ${formatFileList(spent.files)}.`
387
+ : "The conflicting files are unknown — the retry failed without a local merge.";
388
+ return {
389
+ outcome: "escalate",
390
+ reason: `Auto-merge failed: ${opts.mergeReason} ` +
391
+ `Dealer already synced ${opts.baseBranch} and ran one conflict-repair round; ` +
392
+ `the PR still conflicts. ${filesText}`,
393
+ evidence: {
394
+ [MERGE_FAILURE_EVIDENCE_KEY]: true,
395
+ [MERGE_CONFLICT_FILES_EVIDENCE_KEY]: spent.files,
396
+ },
397
+ };
398
+ }
399
+ if (!opts.branch.trim())
400
+ return skip("issue has no branch to sync");
401
+ let repoPath;
402
+ let syncPath;
403
+ try {
404
+ const resolution = classifyIssueRepo(opts.repo);
405
+ repoPath = resolution.repoPath;
406
+ syncPath = path.join(worktreesRootForResolution(resolution), `${SYNC_PATH_INFIX}${opts.issueId.slice(0, 8)}`);
407
+ }
408
+ catch (err) {
409
+ return skip(`repo did not classify: ${err instanceof Error ? err.message : String(err)}`);
410
+ }
411
+ const timeoutMs = mergeSyncConfig.syncGitTimeoutMs;
412
+ // An existing checkout holding the branch belongs to someone else — except
413
+ // our own merge-sync leftover from a crashed run, which a dead owner + clean
414
+ // tree lets us adopt instead of failing closed.
415
+ let existing;
416
+ try {
417
+ existing = await findWorktreeForBranch(repoPath, opts.branch);
418
+ }
419
+ catch (err) {
420
+ return skip(`could not list worktrees: ${err instanceof Error ? err.message : String(err)}`);
421
+ }
422
+ let adopted = false;
423
+ if (existing) {
424
+ if (tryRealpath(existing) !== tryRealpath(syncPath)) {
425
+ return skip(`branch already checked out at ${existing}`);
426
+ }
427
+ const liveness = checkDeveloperWorktreeOwnerLiveness(existing);
428
+ if (liveness.state === "alive") {
429
+ return skip(`sync checkout owned by a live session${liveness.sessionId ? ` (${liveness.sessionId})` : ""}`);
430
+ }
431
+ const clean = await isWorktreeClean(existing).catch(() => false);
432
+ if (!clean)
433
+ return skip("sync checkout is dirty");
434
+ adopted = true;
435
+ }
436
+ // Start at exactly what Dealer pushed (NOT-219 reuse semantics): fetch
437
+ // origin/<branch> and fast-forward the local ref — never merge onto a stale
438
+ // or diverged local branch.
439
+ const reused = await fetchReusedBranch(repoPath, opts.branch, timeoutMs);
440
+ if (!reused.ok)
441
+ return skip(`fetch origin/${opts.branch} failed: ${reused.reason}`);
442
+ if (reused.remoteSha == null)
443
+ return skip(`origin/${opts.branch} does not exist`);
444
+ if (!adopted) {
445
+ const ff = await fastForwardLocalBranchToSha({
446
+ repo: repoPath,
447
+ branch: opts.branch,
448
+ sha: reused.remoteSha,
449
+ });
450
+ if (!ff)
451
+ return skip(`local ${opts.branch} is ahead of or diverged from origin/${opts.branch}`);
452
+ }
453
+ else {
454
+ // Never move a checked-out branch's ref — require the adopted checkout's
455
+ // HEAD to already equal the fetched tip (a crashed run's partial merge
456
+ // fails closed instead of being reinterpreted).
457
+ const head = await revParseHead(existing).catch(() => null);
458
+ if (head !== reused.remoteSha) {
459
+ return skip("adopted sync checkout is not at the fetched tip");
460
+ }
461
+ }
462
+ const base = await fetchFreshBase(repoPath, opts.baseBranch, timeoutMs);
463
+ if (!base.ok)
464
+ return skip(`fetch origin/${opts.baseBranch} failed: ${base.reason}`);
465
+ if (!adopted) {
466
+ try {
467
+ await withRepoLock(repoPath, async () => {
468
+ // Our deterministic path with no registered checkout (crashed cleanup):
469
+ // it can only hold a previous sync's reproducible state — clear it so
470
+ // the add below cannot collide on the directory.
471
+ if (fs.existsSync(syncPath))
472
+ fs.rmSync(syncPath, { recursive: true, force: true });
473
+ fs.mkdirSync(path.dirname(syncPath), { recursive: true });
474
+ await pruneWorktrees(repoPath);
475
+ await addWorktree({ repo: repoPath, path: syncPath, ref: opts.branch });
476
+ });
477
+ }
478
+ catch (err) {
479
+ return skip(`could not create sync checkout: ${err instanceof Error ? err.message : String(err)}`);
480
+ }
481
+ }
482
+ // Merge the freshly fetched base. A nonzero exit with unmerged entries is a
483
+ // textual conflict (repair round); a nonzero exit without them — or a
484
+ // timeout kill — is a tool failure (escalate with the detail). HEAD around
485
+ // the merge tells an "Already up to date" no-op from a real sync apart for
486
+ // the retry-failure decision below.
487
+ const headBeforeMerge = await revParseHead(syncPath).catch(() => null);
488
+ let mergeError = null;
489
+ try {
490
+ await gitExecImpl([...SYNC_GIT_IDENTITY_ARGS, "merge", "--no-edit", base.ref], {
491
+ cwd: syncPath,
492
+ timeoutMs,
493
+ });
494
+ }
495
+ catch (err) {
496
+ mergeError =
497
+ err instanceof SyncGitError
498
+ ? err
499
+ : new SyncGitError(err instanceof Error ? err.message : String(err), {
500
+ stdout: "",
501
+ stderr: "",
502
+ killed: false,
503
+ });
504
+ }
505
+ if (mergeError) {
506
+ const unmerged = await listUnmergedFiles(syncPath, timeoutMs).catch(() => null);
507
+ await abortMergeState(syncPath, timeoutMs).catch(() => { });
508
+ const clean = await isWorktreeClean(syncPath).catch(() => false);
509
+ if (!clean) {
510
+ // Unreachable in practice (the checkout was clean with no unique commits
511
+ // before the merge), but a conflict-marked leftover must never block the
512
+ // repair round's own worktree resolution — drop our checkout entirely.
513
+ try {
514
+ await withRepoLock(repoPath, async () => {
515
+ await removeWorktree({ repo: repoPath, path: syncPath, force: true });
516
+ await pruneWorktrees(repoPath);
517
+ });
518
+ }
519
+ catch {
520
+ // Leave it; the repair round reports it through the normal path.
521
+ }
522
+ }
523
+ else {
524
+ await cleanupSyncCheckout(repoPath, syncPath);
525
+ }
526
+ if (mergeError.killed) {
527
+ return failed(`merge of origin/${opts.baseBranch} timed out after ${timeoutMs}ms`);
528
+ }
529
+ if (unmerged === null) {
530
+ return failed(`merge failed and the conflict list was unreadable: ${mergeError.message}`);
531
+ }
532
+ if (unmerged.length === 0) {
533
+ return failed(`merge failed: ${mergeError.message}`);
534
+ }
535
+ const queued = queueConflictRepairRound({
536
+ issueId: opts.issueId,
537
+ instanceId: opts.instanceId,
538
+ baseBranch: opts.baseBranch,
539
+ branch: opts.branch,
540
+ files: unmerged.slice(0, CONFLICTING_FILES_MAX),
541
+ });
542
+ if (!queued)
543
+ return skip("issue left the merge park before the repair round queued");
544
+ auditSync(auditInput, "repair_queued", { files: unmerged });
545
+ return { outcome: "repair_queued", workItemId: queued.id, round: queued.round };
546
+ }
547
+ const headAfterMerge = await revParseHead(syncPath).catch(() => null);
548
+ // Null when a rev-parse failed — unknown, never "unchanged". (A successful
549
+ // merge + push means the branch contains the base either way; only the
550
+ // already-up-to-date claim needs a verified-unchanged HEAD.)
551
+ const mergeChangedHead = headBeforeMerge === null || headAfterMerge === null
552
+ ? null
553
+ : headBeforeMerge !== headAfterMerge;
554
+ // Plain push, never force — the refspec mirrors pushBranch exactly.
555
+ try {
556
+ await gitExecImpl(["push", "-u", "origin", `HEAD:refs/heads/${opts.branch}`], {
557
+ cwd: syncPath,
558
+ timeoutMs,
559
+ });
560
+ }
561
+ catch (err) {
562
+ await cleanupSyncCheckout(repoPath, syncPath);
563
+ const detail = err instanceof Error ? err.message : String(err);
564
+ return failed(`could not push the synced branch: ${detail}`);
565
+ }
566
+ // Wait for checks on the new head exactly as the developer effect does, then
567
+ // retry the merge once. A failed/timed-out poll escalates directly — retrying
568
+ // the merge against red or unknown checks cannot succeed.
569
+ let checks;
570
+ try {
571
+ checks = await pollPrChecks(githubImpl, {
572
+ cwd: syncPath,
573
+ number: opts.prNumber,
574
+ timeoutMs: mergeSyncConfig.checksPollTimeoutMs,
575
+ intervalMs: mergeSyncConfig.checksPollIntervalMs,
576
+ });
577
+ }
578
+ catch (err) {
579
+ await cleanupSyncCheckout(repoPath, syncPath);
580
+ const detail = err instanceof Error ? err.message : String(err);
581
+ return failed(`checks poll errored: ${detail}`);
582
+ }
583
+ if (checks === "failure") {
584
+ await cleanupSyncCheckout(repoPath, syncPath);
585
+ return failed(`synced ${opts.baseBranch} ${base.sha.slice(0, 12)} but PR checks failed on the merged head`);
586
+ }
587
+ if (checks === "timeout") {
588
+ await cleanupSyncCheckout(repoPath, syncPath);
589
+ return failed(`synced ${opts.baseBranch} ${base.sha.slice(0, 12)} but timed out waiting for PR checks on the merged head`);
590
+ }
591
+ await cleanupSyncCheckout(repoPath, syncPath);
592
+ const retry = await opts.mergePr({ cwd: repoPath, number: opts.prNumber });
593
+ if (retry.ok) {
594
+ auditSync(auditInput, "merged", { baseSha: base.sha });
595
+ return { outcome: "merged" };
596
+ }
597
+ if (isMergeConflictFailure(retry.reason)) {
598
+ // The retry failed the same way. Only a base that actually advanced past
599
+ // the synced tip is new staleness worth a repair round — anything else (a
600
+ // policy block, a dismissed approval, GitHub's stale mergeability cache)
601
+ // is not something a merge-resolve round can fix, so escalate directly
602
+ // with the retry's reason instead of spending a round on a false
603
+ // "base moved". This also covers the "Already up to date" no-op merge:
604
+ // the branch already contains the base, so there is nothing to resolve.
605
+ const advance = await checkBaseAdvanced(repoPath, opts.baseBranch, base.sha, timeoutMs);
606
+ if (!advance.advanced) {
607
+ const short = (sha) => sha.slice(0, 12);
608
+ const note = advance.fetchFailed
609
+ ? `could not re-check ${opts.baseBranch} after the retry, so no repair round was queued`
610
+ : advance.freshSha === base.sha
611
+ ? mergeChangedHead === false
612
+ ? `branch already contains ${opts.baseBranch} ${short(base.sha)} — not a staleness conflict`
613
+ : `branch now contains ${opts.baseBranch} ${short(base.sha)} — not a staleness conflict`
614
+ : `${opts.baseBranch} was rewritten since the sync (${short(base.sha)} → ${short(advance.freshSha)}); needs a human look`;
615
+ auditSync(auditInput, "failed", {
616
+ detail: retry.reason,
617
+ mergeChangedHead,
618
+ baseSha: base.sha,
619
+ freshBaseSha: advance.freshSha,
620
+ });
621
+ return {
622
+ outcome: "escalate",
623
+ reason: `Auto-merge failed: ${retry.reason} (${note})`,
624
+ evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true },
625
+ };
626
+ }
627
+ // The base moved again under us — the one repair round re-syncs + resolves.
628
+ const queued = queueConflictRepairRound({
629
+ issueId: opts.issueId,
630
+ instanceId: opts.instanceId,
631
+ baseBranch: opts.baseBranch,
632
+ branch: opts.branch,
633
+ files: [],
634
+ });
635
+ if (!queued)
636
+ return skip("issue left the merge park before the repair round queued");
637
+ auditSync(auditInput, "repair_queued", { files: [], baseMovedAgain: true });
638
+ return { outcome: "repair_queued", workItemId: queued.id, round: queued.round };
639
+ }
640
+ auditSync(auditInput, "failed", { detail: retry.reason });
641
+ return {
642
+ outcome: "escalate",
643
+ reason: `Auto-merge failed: ${retry.reason} ` +
644
+ `(after syncing ${opts.baseBranch} ${base.sha.slice(0, 12)})`,
645
+ evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true },
646
+ };
647
+ }