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
@@ -30,6 +30,7 @@ import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstanc
30
30
  import { createHumanAction, findOpenHumanAction, findOpenHumanActionByRequestId, resolveHumanAction, } from "../repository/human-actions.js";
31
31
  import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS } from "./human-resolution.js";
32
32
  import { isMergeConflictFailure, runMergeConflictSync } from "./merge-conflict-sync.js";
33
+ import { startBaseAdvancedScan } from "./base-advanced-scan.js";
33
34
  import { OPERATOR_VERIFICATION_RESPONSE_OPTIONS, formatOperatorCriteria, getOperatorCriteriaForIssue, hasOperatorVerificationForHead, operatorVerificationRequestId, } from "./operator-criteria.js";
34
35
  const run = promisify(execFile);
35
36
  /** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
@@ -147,7 +148,7 @@ export const realMergePr = async ({ cwd, number }) => {
147
148
  const reason = ghErrorReason(err, "gh pr merge failed", cwd);
148
149
  // Crash between a successful merge and the done-transition: retry must not escalate.
149
150
  if (ALREADY_MERGED.test(reason))
150
- return { ok: true };
151
+ return { ok: true, alreadyMerged: true };
151
152
  return { ok: false, reason };
152
153
  }
153
154
  };
@@ -163,6 +164,10 @@ export function setMergePrForTests(fn) {
163
164
  * for the same issue share one Promise (set synchronously before any await).
164
165
  */
165
166
  const finalizeInflight = new Map();
167
+ /** NOT-356: whether this process is merging the issue's PR right now. */
168
+ export function isAutoMergeInFlight(issueId) {
169
+ return finalizeInflight.has(issueId);
170
+ }
166
171
  /** Test hook — clear single-flight state between cases. */
167
172
  export function clearFinalizeInflightForTests() {
168
173
  finalizeInflight.clear();
@@ -227,7 +232,7 @@ async function finalizeAutoMergeOnce(issueId) {
227
232
  mergePr: mergePrImpl,
228
233
  });
229
234
  if (sync.outcome === "merged") {
230
- merge = { ok: true };
235
+ merge = sync.alreadyMerged ? { ok: true, alreadyMerged: true } : { ok: true };
231
236
  }
232
237
  else if (sync.outcome === "repair_queued") {
233
238
  return {
@@ -249,7 +254,7 @@ async function finalizeAutoMergeOnce(issueId) {
249
254
  if (!merge.ok) {
250
255
  return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${merge.reason}`);
251
256
  }
252
- return getDb().transaction(() => {
257
+ const merged = getDb().transaction(() => {
253
258
  const current = getIssue(issueId);
254
259
  const active = getActiveWorkflowInstance(issueId);
255
260
  if (!active) {
@@ -308,6 +313,14 @@ async function finalizeAutoMergeOnce(issueId) {
308
313
  triggerReflect: true,
309
314
  };
310
315
  })();
316
+ // NOT-356: the base just moved under every other open Dealer PR on this repo +
317
+ // base — probe them and resolve the idle conflicting ones. Background and
318
+ // self-contained: nothing it does can change this merge's result. Only for a
319
+ // merge this call made: a PR GitHub already reports merged (a human merged it,
320
+ // or a crash recovery re-runs the finalize) is not Dealer's merge.
321
+ if (merged.triggerReflect && !merge.alreadyMerged)
322
+ startBaseAdvancedScan(getIssue(issueId) ?? issue);
323
+ return merged;
311
324
  }
312
325
  /**
313
326
  * NOT-314: the `[operator]` merge gate. After reviewer approve and before the
@@ -54,3 +54,20 @@ test("NOT-151: resolveAutoMergeCwd accepts a real legacy local checkout", () =>
54
54
  if (ok.ok)
55
55
  assert.equal(ok.cwd, dir);
56
56
  });
57
+ test("NOT-356: realMergePr marks a PR GitHub already merged, and only that, as alreadyMerged", async () => {
58
+ const { realMergePr } = await import("./auto-merge.js");
59
+ const bin = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not356-gh-"));
60
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not356-cwd-"));
61
+ const savedPath = process.env.PATH;
62
+ process.env.PATH = `${bin}${path.delimiter}${savedPath ?? ""}`;
63
+ const fakeGh = (mergeBody) => fs.writeFileSync(path.join(bin, "gh"), `#!/bin/sh\nif [ "$2" = "merge" ]; then\n${mergeBody}\nfi\nexit 0\n`, { mode: 0o755 });
64
+ try {
65
+ fakeGh(`echo "GraphQL: Pull request #7 was already merged" >&2; exit 1`);
66
+ assert.deepEqual(await realMergePr({ cwd, number: 7 }), { ok: true, alreadyMerged: true });
67
+ fakeGh("exit 0");
68
+ assert.deepEqual(await realMergePr({ cwd, number: 7 }), { ok: true });
69
+ }
70
+ finally {
71
+ process.env.PATH = savedPath;
72
+ }
73
+ });
@@ -0,0 +1,308 @@
1
+ import { DEFAULT_BASE_FETCH_TIMEOUT_MS, fetchFreshBase, fetchReusedBranch, probeBaseConflict, readRemoteTip, } from "../adapters/git-worktree.js";
2
+ import { classifyIssueRepo } from "../adapters/managed-repo.js";
3
+ import { getDb } from "../db/index.js";
4
+ import { getIssue, listIssues } from "../repository/issues.js";
5
+ import { getActiveWorkerSessionForIssue } from "../repository/worker-sessions.js";
6
+ import { listWorkItemsForIssue } from "../repository/work-items.js";
7
+ import { appendWorkflowEvent, getActiveWorkflowInstance } from "../repository/workflow-events.js";
8
+ import { isAutoMergeInFlight } from "./auto-merge.js";
9
+ import { BASE_ADVANCED_STATUSES, checksWaitSyncSpent, conflictRepairSpent, queueChecksWaitAfterBaseSync, queueConflictRepairRound, runMergeConflictSync, } from "./merge-conflict-sync.js";
10
+ /** Open stages in which an issue can hold a PR Dealer opened. */
11
+ const SIBLING_STATUSES = BASE_ADVANCED_STATUSES;
12
+ let probeImpl = probeBaseConflict;
13
+ /** Test hook — fake or wrap the per-sibling conflict probe. */
14
+ export function setBaseAdvancedProbeForTests(probe) {
15
+ probeImpl = probe ?? probeBaseConflict;
16
+ }
17
+ /** Tail of the serialized scan chain per repo + base. */
18
+ const scanChains = new Map();
19
+ /**
20
+ * Start the sibling scan for a PR Dealer just merged. Never throws and never
21
+ * delays the caller: the work is chained behind any earlier scan for the same
22
+ * repo + base and runs in the background.
23
+ */
24
+ export function startBaseAdvancedScan(merged) {
25
+ const key = `${merged.repo}\u0000${merged.baseBranch}`;
26
+ const prev = scanChains.get(key) ?? Promise.resolve();
27
+ const next = prev
28
+ .then(() => scanSiblingsAfterMerge(merged))
29
+ .then(() => undefined, (err) => {
30
+ console.error("[coordinator] base-advanced scan", merged.id, err);
31
+ });
32
+ scanChains.set(key, next);
33
+ void next.finally(() => {
34
+ if (scanChains.get(key) === next)
35
+ scanChains.delete(key);
36
+ });
37
+ }
38
+ /** Test hook — resolve once every started scan has finished. */
39
+ export async function settleBaseAdvancedScansForTests() {
40
+ while (scanChains.size > 0) {
41
+ await Promise.all([...scanChains.values()]);
42
+ }
43
+ }
44
+ /**
45
+ * Probe and (when idle and conflicting) resolve every other open Dealer PR on the
46
+ * merged issue's repo + base. Exported for direct tests; production goes through
47
+ * {@link startBaseAdvancedScan}.
48
+ */
49
+ export async function scanSiblingsAfterMerge(merged) {
50
+ const siblings = listIssues(SIBLING_STATUSES).filter((i) => i.id !== merged.id &&
51
+ i.repo === merged.repo &&
52
+ i.baseBranch === merged.baseBranch &&
53
+ i.prNumber != null &&
54
+ Boolean(i.branch));
55
+ if (siblings.length === 0)
56
+ return [];
57
+ const context = { mergedIssueId: merged.id, mergedPrNumber: merged.prNumber, baseBranch: merged.baseBranch };
58
+ let repoPath = null;
59
+ let base = null;
60
+ let setupFailure = null;
61
+ try {
62
+ repoPath = classifyIssueRepo(merged.repo).repoPath;
63
+ const fresh = await fetchFreshBase(repoPath, merged.baseBranch, DEFAULT_BASE_FETCH_TIMEOUT_MS);
64
+ if (fresh.ok)
65
+ base = { sha: fresh.sha, ref: fresh.ref };
66
+ else
67
+ setupFailure = `fetch origin/${merged.baseBranch} failed: ${fresh.reason}`;
68
+ }
69
+ catch (err) {
70
+ setupFailure = err instanceof Error ? err.message : String(err);
71
+ }
72
+ const results = [];
73
+ for (const sibling of siblings) {
74
+ const record = (action, detail) => {
75
+ recordBaseAdvanced(sibling.id, { ...context, baseSha: base?.sha ?? null, action, ...(detail ?? {}) });
76
+ results.push({ issueId: sibling.id, action });
77
+ };
78
+ try {
79
+ if (setupFailure || !repoPath || !base) {
80
+ record("skipped", { reason: setupFailure ?? "base did not resolve" });
81
+ continue;
82
+ }
83
+ const { action, detail } = await scanSibling(sibling, repoPath, base);
84
+ record(action, detail);
85
+ }
86
+ catch (err) {
87
+ record("failed", { reason: err instanceof Error ? err.message : String(err) });
88
+ }
89
+ }
90
+ return results;
91
+ }
92
+ async function scanSibling(sibling, repoPath, base) {
93
+ const issue = getIssue(sibling.id);
94
+ const instance = getActiveWorkflowInstance(sibling.id);
95
+ if (!issue || !instance || !SIBLING_STATUSES.includes(issue.status)) {
96
+ return { action: "skipped", detail: { reason: "issue is no longer open" } };
97
+ }
98
+ const branch = issue.branch;
99
+ const prNumber = issue.prNumber;
100
+ const busy = activeReason(issue.id);
101
+ if (busy)
102
+ return { action: "active_worker", detail: { reason: busy } };
103
+ // Start from exactly what Dealer last pushed; anything else is a hand edit.
104
+ const reused = await fetchReusedBranch(repoPath, branch, DEFAULT_BASE_FETCH_TIMEOUT_MS);
105
+ if (!reused.ok)
106
+ return { action: "skipped", detail: { reason: `fetch origin/${branch} failed: ${reused.reason}` } };
107
+ if (reused.remoteSha == null)
108
+ return { action: "skipped", detail: { reason: `origin/${branch} does not exist` } };
109
+ const lastPushed = lastDealerPushedSha(issue);
110
+ if (reused.remoteSha !== lastPushed) {
111
+ return { action: "hand_edited", detail: { remoteSha: reused.remoteSha, lastPushedSha: lastPushed } };
112
+ }
113
+ const expectedHeadSha = reused.remoteSha;
114
+ const probe = await probeImpl({
115
+ worktreePath: repoPath,
116
+ baseRef: base.ref,
117
+ headRef: expectedHeadSha,
118
+ });
119
+ if (probe.state === "clean")
120
+ return { action: "clean", detail: { probe: "clean", headSha: expectedHeadSha } };
121
+ if (probe.state !== "conflict") {
122
+ return { action: "skipped", detail: { probe: probe.state, reason: probe.reason } };
123
+ }
124
+ const probeDetail = { probe: "conflict", files: probe.files, headSha: expectedHeadSha };
125
+ if (conflictRepairSpent(issue.id, instance.id).spent) {
126
+ return { action: "repair_spent", detail: probeDetail };
127
+ }
128
+ // Every mutation re-checks the sibling: it may have been closed, re-targeted or
129
+ // picked up by a worker since the checks above.
130
+ let drifted = false;
131
+ const guard = () => {
132
+ const drift = siblingDrift(issue, instance.id);
133
+ if (drift) {
134
+ drifted = true;
135
+ return drift;
136
+ }
137
+ return activeReason(issue.id);
138
+ };
139
+ // One push-only sync per episode: a spent sync goes straight to the repair round.
140
+ let files = probe.files;
141
+ if (!checksWaitSyncSpent(issue.id, instance.id)) {
142
+ const sync = await runMergeConflictSync({
143
+ issueId: issue.id,
144
+ instanceId: instance.id,
145
+ repo: issue.repo,
146
+ branch,
147
+ baseBranch: issue.baseBranch,
148
+ prNumber,
149
+ mergeReason: `${issue.baseBranch} advanced and PR #${prNumber} now conflicts with it.`,
150
+ entry: "base_advanced",
151
+ expectedHeadSha,
152
+ beforePush: guard,
153
+ });
154
+ if (sync.outcome === "synced") {
155
+ const wait = queueChecksWaitAfterBaseSync({
156
+ issueId: issue.id,
157
+ instanceId: instance.id,
158
+ baseBranch: issue.baseBranch,
159
+ branch,
160
+ prNumber,
161
+ headSha: sync.headSha,
162
+ guard,
163
+ });
164
+ return {
165
+ action: "synced",
166
+ detail: {
167
+ ...probeDetail,
168
+ syncedHeadSha: sync.headSha,
169
+ ...(wait
170
+ ? { workItemId: wait.id, ...(wait.kept ? { checksWait: "queued developer round" } : {}) }
171
+ : { reason: "issue became busy or left its stage before the checks wait queued" }),
172
+ },
173
+ };
174
+ }
175
+ if (sync.outcome === "skipped" && sync.code === "tip_moved") {
176
+ return { action: "hand_edited", detail: { ...probeDetail, reason: sync.reason } };
177
+ }
178
+ if (sync.outcome === "skipped" && sync.code === "refused") {
179
+ return { action: drifted ? "skipped" : "active_worker", detail: { ...probeDetail, reason: sync.reason } };
180
+ }
181
+ if (sync.outcome === "escalate" || sync.outcome === "skipped") {
182
+ return { action: sync.outcome === "escalate" ? "failed" : "skipped", detail: { ...probeDetail, reason: sync.reason } };
183
+ }
184
+ if (sync.outcome !== "repair_needed") {
185
+ return { action: "skipped", detail: { ...probeDetail, reason: `unexpected sync outcome ${sync.outcome}` } };
186
+ }
187
+ if (sync.files.length > 0)
188
+ files = sync.files;
189
+ }
190
+ // The textual-conflict path returns before the sync's own pre-push tip read, so
191
+ // re-read origin right before the repair mutation: a hand edit is never touched.
192
+ // Bounded like the sync's own git calls: a stalled remote must not hold up the
193
+ // remaining siblings, and a timed-out read (null) fails closed as hand_edited.
194
+ const tipNow = await readRemoteTip({ cwd: repoPath, branch, timeoutMs: DEFAULT_BASE_FETCH_TIMEOUT_MS });
195
+ const lastPushedNow = lastDealerPushedSha(issue);
196
+ if (tipNow !== lastPushedNow) {
197
+ return { action: "hand_edited", detail: { ...probeDetail, remoteSha: tipNow, lastPushedSha: lastPushedNow } };
198
+ }
199
+ const queued = queueConflictRepairRound({
200
+ issueId: issue.id,
201
+ instanceId: instance.id,
202
+ baseBranch: issue.baseBranch,
203
+ branch,
204
+ files,
205
+ entry: "base_advanced",
206
+ guard,
207
+ });
208
+ if (!queued) {
209
+ return { action: "skipped", detail: { ...probeDetail, reason: "issue became busy or left its stage before the repair round queued" } };
210
+ }
211
+ return { action: "repair_queued", detail: { ...probeDetail, files, workItemId: queued.id, round: queued.round } };
212
+ }
213
+ /** Why the sibling is no longer the open PR the scan validated: closed or moved to
214
+ * another stage outside the open set, a different (or no) active workflow instance,
215
+ * or a changed branch, PR, repo or base. */
216
+ function siblingDrift(expected, instanceId) {
217
+ const now = getIssue(expected.id);
218
+ if (!now)
219
+ return "issue no longer exists";
220
+ if (!SIBLING_STATUSES.includes(now.status))
221
+ return `issue left its open stage (now ${now.status})`;
222
+ if (getActiveWorkflowInstance(expected.id)?.id !== instanceId)
223
+ return "its workflow instance is no longer active";
224
+ if (now.branch !== expected.branch ||
225
+ now.prNumber !== expected.prNumber ||
226
+ now.repo !== expected.repo ||
227
+ now.baseBranch !== expected.baseBranch) {
228
+ return "its branch, PR, repo or base changed";
229
+ }
230
+ return null;
231
+ }
232
+ /** Why the sibling is not idle: a running worker session, a leased work item (an
233
+ * effect mid-flight), or its own auto-merge running in this process. */
234
+ function activeReason(issueId) {
235
+ if (getActiveWorkerSessionForIssue(issueId))
236
+ return "a worker session is running";
237
+ if (listWorkItemsForIssue(issueId).some((w) => w.status === "leased"))
238
+ return "a work item is leased";
239
+ if (isAutoMergeInFlight(issueId))
240
+ return "its auto-merge is in flight";
241
+ return null;
242
+ }
243
+ /**
244
+ * Events that prove Dealer itself put a head on origin. `pull_request.opened` and
245
+ * the issue's `headSha` are deliberately absent: a publish-only handoff accepts an
246
+ * origin someone pushed outside the coordinator and records that observed head
247
+ * there, so neither says who pushed it.
248
+ */
249
+ const PUSH_EVENT_TYPES = ["checkpoint.observed", "branch.pushed", "auto_merge.conflict_sync"];
250
+ /**
251
+ * The single SHA Dealer last pushed to the issue branch: the latest of a developer
252
+ * push checkpoint, a lease push, or a push-only base sync that moved the head (in
253
+ * event order). Null when no event proves a push — the tip is then never Dealer's.
254
+ * An older Dealer head is not accepted — a reset back to it is a hand edit too.
255
+ */
256
+ function lastDealerPushedSha(issue) {
257
+ const rows = getDb()
258
+ .prepare(`SELECT type, payload_json AS payloadJson FROM workflow_events
259
+ WHERE issue_id = ? AND type IN (${PUSH_EVENT_TYPES.map(() => "?").join(", ")})
260
+ ORDER BY ts ASC, rowid ASC`)
261
+ .all(issue.id, ...PUSH_EVENT_TYPES);
262
+ let last = null;
263
+ for (const row of rows) {
264
+ let p;
265
+ try {
266
+ p = JSON.parse(row.payloadJson ?? "{}");
267
+ }
268
+ catch {
269
+ continue; // unreadable audit payload names no head
270
+ }
271
+ let sha = null;
272
+ if (row.type === "checkpoint.observed")
273
+ sha = p.kind === "branch_pushed" ? p.observedSha : null;
274
+ else if (row.type === "branch.pushed")
275
+ sha = p.newSha ?? p.localSha;
276
+ // A sync whose merge left the head unchanged pushed nothing new: its head is
277
+ // whatever origin already held, not proof of a Dealer push.
278
+ else if (p.outcome === "synced" && p.mergeChangedHead !== false)
279
+ sha = p.headSha;
280
+ if (typeof sha === "string" && sha)
281
+ last = sha;
282
+ }
283
+ return last;
284
+ }
285
+ function recordBaseAdvanced(issueId, payload) {
286
+ try {
287
+ getDb().transaction(() => {
288
+ const issue = getIssue(issueId);
289
+ if (!issue)
290
+ return;
291
+ const instance = getActiveWorkflowInstance(issueId);
292
+ appendWorkflowEvent({
293
+ issueId,
294
+ workflowInstanceId: instance?.id ?? null,
295
+ workerSessionId: null,
296
+ type: "base.advanced",
297
+ actorType: "system",
298
+ stage: issue.status,
299
+ round: issue.currentRound,
300
+ payload,
301
+ });
302
+ })();
303
+ }
304
+ catch (err) {
305
+ // Audit only — never fail the scan for a sibling.
306
+ console.error("[coordinator] base.advanced event", issueId, err);
307
+ }
308
+ }
@@ -29,8 +29,9 @@ import { reasonForWorkerFailedEvent } from "./failure-reason.js";
29
29
  import { recordCausesForWorkerFailedEvent } from "./failure-cause.js";
30
30
  import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
31
31
  import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
32
- import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
32
+ import { MERGE_CONFLICT_FILES_EVIDENCE_KEY, MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
33
33
  import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
34
+ import { conflictRepairSpent, queueConflictRepairRound } from "./merge-conflict-sync.js";
34
35
  import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
35
36
  import { externalMergeStateForIssue } from "./external-merge.js";
36
37
  import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
@@ -338,6 +339,17 @@ export async function applyCompletion(workItemId, leaseToken, outcome) {
338
339
  if (outcome.kind === "checks_pending") {
339
340
  return applyChecksPendingCompletion(workItemId, leaseToken, outcome);
340
341
  }
342
+ // NOT-354: the PR conflicts with its base, so CI will never start. The effect
343
+ // already ran the base sync; this re-polls the synced head, queues the one
344
+ // conflict-repair round, or escalates once — never a "Waiting for CI" deferral.
345
+ if (outcome.kind === "checks_conflicting") {
346
+ return applyChecksConflictCompletion(workItemId, leaseToken, outcome);
347
+ }
348
+ // NOT-355: the pre-publish probe found a base conflict before anything was pushed.
349
+ // Queue the one conflict-repair round (needs the lease), or escalate once.
350
+ if (outcome.kind === "base_conflict") {
351
+ return applyBaseConflictCompletion(workItemId, leaseToken, outcome);
352
+ }
341
353
  const routed = getDb().transaction(() => {
342
354
  const before = getWorkItem(workItemId);
343
355
  if (!before)
@@ -434,6 +446,225 @@ function applyBaseFetchDeferralCompletion(workItemId, leaseToken, failure) {
434
446
  function applyChecksPendingCompletion(workItemId, leaseToken, pending) {
435
447
  return applyDeferralCompletion(workItemId, leaseToken, pending, (item, issue, instance) => deferLeasedWorkItemForChecksPending(item, leaseToken, pending, issue, instance), (issue, instance, item) => routeChecksWaitEscalation(issue, instance, item, pending));
436
448
  }
449
+ /**
450
+ * NOT-354: act on the base sync a conflicting PR's CI wait ran. A clean sync is a
451
+ * normal CI wait on the pushed head, anchored at the original wait start (the
452
+ * deferral keeps any persisted `checksWaitStartedAt`), so the ceiling is never
453
+ * reset. A textual conflict queues the one NOT-310 conflict-repair round while
454
+ * finishing this item; anything else escalates once.
455
+ */
456
+ function applyChecksConflictCompletion(workItemId, leaseToken, outcome) {
457
+ const { sync } = outcome;
458
+ if (sync.result === "synced") {
459
+ const headSha = sync.headSha ?? outcome.headSha;
460
+ return applyChecksPendingCompletion(workItemId, leaseToken, {
461
+ kind: "checks_pending",
462
+ reason: `Synced ${outcome.baseBranch} into PR #${outcome.prNumber}; waiting for CI on ${headSha.slice(0, 12)} - no attempt spent`,
463
+ branch: outcome.branch,
464
+ prNumber: outcome.prNumber,
465
+ headSha,
466
+ waitStartedAt: outcome.waitStartedAt,
467
+ });
468
+ }
469
+ return getDb().transaction(() => {
470
+ const before = getWorkItem(workItemId);
471
+ if (!before)
472
+ return { applied: false, reason: "not_found" };
473
+ if (before.status === "done" || before.status === "dead" || before.status === "cancelled") {
474
+ return { applied: false, reason: "already_terminal" };
475
+ }
476
+ const issue = getIssue(before.issueId);
477
+ if (!issue)
478
+ return { applied: false, reason: "not_found" };
479
+ const instance = getActiveWorkflowInstance(before.issueId);
480
+ if (!instance || instance.id !== before.workflowInstanceId) {
481
+ return { applied: false, reason: "no_active_instance" };
482
+ }
483
+ if (sync.result === "repair") {
484
+ const queued = queueConflictRepairRound({
485
+ issueId: issue.id,
486
+ instanceId: instance.id,
487
+ baseBranch: outcome.baseBranch,
488
+ branch: outcome.branch,
489
+ files: sync.files,
490
+ entry: "checks_wait",
491
+ finish: { workItemId, leaseToken, result: outcome },
492
+ });
493
+ if (queued) {
494
+ const session = before.workerSessionId ? getWorkerSession(before.workerSessionId) : null;
495
+ appendWorkflowEvent({
496
+ issueId: issue.id,
497
+ workflowInstanceId: instance.id,
498
+ workerSessionId: before.workerSessionId,
499
+ type: "worker.completed",
500
+ actorType: "developer",
501
+ stage: getIssue(issue.id).status,
502
+ round: issue.currentRound,
503
+ payload: {
504
+ ...workerSessionPayload({
505
+ runtime: session?.runtime,
506
+ model: session?.model,
507
+ sessionId: before.workerSessionId ?? "",
508
+ worktreePath: session?.worktreePath,
509
+ }),
510
+ outcome: outcome.kind,
511
+ ...(isPublishOnlyItem(before) ? { publishOnly: true } : {}),
512
+ },
513
+ });
514
+ return {
515
+ applied: true,
516
+ issueStatus: getIssue(issue.id).status,
517
+ nextWorkItemId: queued.id,
518
+ humanActionId: null,
519
+ instanceCompleted: false,
520
+ };
521
+ }
522
+ // Lease lost, or the issue left its developer stage — a lost lease applies
523
+ // nothing; otherwise fall through to the single escalation.
524
+ if (!getWorkItem(workItemId) || getWorkItem(workItemId).leaseToken !== leaseToken) {
525
+ return { applied: false, reason: "lease_lost" };
526
+ }
527
+ }
528
+ const item = finishWorkItem(workItemId, leaseToken, { status: "done", result: outcome });
529
+ if (!item)
530
+ return { applied: false, reason: "lease_lost" };
531
+ const reason = sync.result === "escalate"
532
+ ? sync.reason
533
+ : `PR #${outcome.prNumber} conflicts with ${outcome.baseBranch}; the conflict-repair round could not be queued.`;
534
+ const evidence = sync.result === "escalate" ? sync.evidence : { checksConflict: true };
535
+ return routeChecksConflictEscalation(issue, instance, item, outcome, reason, evidence, "checksConflict");
536
+ })();
537
+ }
538
+ /**
539
+ * NOT-355: the pre-publish probe found the unpushed branch conflicts with its base.
540
+ * Queue one conflict-repair round naming the files while finishing this item — the
541
+ * developer merges the base and the next round's normal push proceeds. When this
542
+ * episode already spent its conflict-repair round (the probe still conflicts after
543
+ * it), escalate once with the file list instead; never a second round.
544
+ */
545
+ function applyBaseConflictCompletion(workItemId, leaseToken, outcome) {
546
+ return getDb().transaction(() => {
547
+ const before = getWorkItem(workItemId);
548
+ if (!before)
549
+ return { applied: false, reason: "not_found" };
550
+ if (before.status === "done" || before.status === "dead" || before.status === "cancelled") {
551
+ return { applied: false, reason: "already_terminal" };
552
+ }
553
+ const issue = getIssue(before.issueId);
554
+ if (!issue)
555
+ return { applied: false, reason: "not_found" };
556
+ const instance = getActiveWorkflowInstance(before.issueId);
557
+ if (!instance || instance.id !== before.workflowInstanceId) {
558
+ return { applied: false, reason: "no_active_instance" };
559
+ }
560
+ const facts = { kind: outcome.kind, branch: outcome.branch, baseBranch: outcome.baseBranch, prNumber: null, headSha: outcome.headSha };
561
+ const filesText = outcome.files.length > 0 ? ` Conflicting files: ${outcome.files.join(", ")}.` : "";
562
+ const spent = conflictRepairSpent(issue.id, instance.id).spent;
563
+ if (!spent) {
564
+ const queued = queueConflictRepairRound({
565
+ issueId: issue.id,
566
+ instanceId: instance.id,
567
+ baseBranch: outcome.baseBranch,
568
+ branch: outcome.branch,
569
+ files: outcome.files,
570
+ entry: "pre_publish",
571
+ finish: { workItemId, leaseToken, result: outcome },
572
+ });
573
+ if (queued) {
574
+ const session = before.workerSessionId ? getWorkerSession(before.workerSessionId) : null;
575
+ appendWorkflowEvent({
576
+ issueId: issue.id,
577
+ workflowInstanceId: instance.id,
578
+ workerSessionId: before.workerSessionId,
579
+ type: "worker.completed",
580
+ actorType: "developer",
581
+ stage: getIssue(issue.id).status,
582
+ round: issue.currentRound,
583
+ payload: {
584
+ ...workerSessionPayload({
585
+ runtime: session?.runtime,
586
+ model: session?.model,
587
+ sessionId: before.workerSessionId ?? "",
588
+ worktreePath: session?.worktreePath,
589
+ }),
590
+ outcome: outcome.kind,
591
+ },
592
+ });
593
+ return {
594
+ applied: true,
595
+ issueStatus: getIssue(issue.id).status,
596
+ nextWorkItemId: queued.id,
597
+ humanActionId: null,
598
+ instanceCompleted: false,
599
+ };
600
+ }
601
+ if (!getWorkItem(workItemId) || getWorkItem(workItemId).leaseToken !== leaseToken) {
602
+ return { applied: false, reason: "lease_lost" };
603
+ }
604
+ }
605
+ const item = finishWorkItem(workItemId, leaseToken, { status: "done", result: outcome });
606
+ if (!item)
607
+ return { applied: false, reason: "lease_lost" };
608
+ const reason = spent
609
+ ? `Branch ${outcome.branch} conflicts with ${outcome.baseBranch} and was not published. ` +
610
+ `Dealer already ran one conflict-repair round; the branch still conflicts.${filesText}`
611
+ : `Branch ${outcome.branch} conflicts with ${outcome.baseBranch} and was not published; ` +
612
+ `the conflict-repair round could not be queued.${filesText}`;
613
+ return routeChecksConflictEscalation(issue, instance, item, facts, reason, { [MERGE_CONFLICT_FILES_EVIDENCE_KEY]: outcome.files }, "baseProbeConflict");
614
+ })();
615
+ }
616
+ /** NOT-354: the one escalation for a conflicting PR — same shape as the CI-wait
617
+ * ceiling escalation, naming the PR, base, and (when known) the conflicting files.
618
+ * NOT-355 reuses it for an unpublished branch (`prNumber` null) under its own key. */
619
+ function routeChecksConflictEscalation(issue, instance, item, outcome, reason, evidence, factsKey) {
620
+ const ev = eventEmitter(issue, instance, item.workerSessionId, "needs_human", issue.currentRound);
621
+ const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
622
+ for (const type of capEscalationEvents()) {
623
+ ev.emit(type, {
624
+ actorType: "developer",
625
+ payload: {
626
+ ...workerSessionPayload({
627
+ runtime: session?.runtime ?? null,
628
+ model: session?.model ?? null,
629
+ sessionId: item.workerSessionId ?? "",
630
+ worktreePath: session?.worktreePath,
631
+ }),
632
+ outcome: outcome.kind,
633
+ reason,
634
+ branch: outcome.branch,
635
+ baseBranch: outcome.baseBranch,
636
+ prNumber: outcome.prNumber,
637
+ headSha: outcome.headSha,
638
+ },
639
+ });
640
+ }
641
+ applyProjectionTransition(issue, { issueStatus: "needs_human", currentOwner: "human", currentIntent: reason, events: capEscalationEvents() }, {});
642
+ const action = createHumanAction({
643
+ issueId: issue.id,
644
+ workflowInstanceId: instance.id,
645
+ actionType: "policy_escalation",
646
+ reason,
647
+ question: questionFor("policy_escalation", reason),
648
+ evidence: {
649
+ ...evidence,
650
+ [factsKey]: {
651
+ branch: outcome.branch,
652
+ baseBranch: outcome.baseBranch,
653
+ prNumber: outcome.prNumber,
654
+ headSha: outcome.headSha,
655
+ },
656
+ },
657
+ responseOptions: responseOptionsFor("policy_escalation"),
658
+ });
659
+ ev.emit("human_action.requested", { payload: { actionType: "policy_escalation", actionId: action.id } });
660
+ return {
661
+ applied: true,
662
+ issueStatus: "needs_human",
663
+ nextWorkItemId: null,
664
+ humanActionId: action.id,
665
+ instanceCompleted: false,
666
+ };
667
+ }
437
668
  /**
438
669
  * NOT-311: the CI wait passed its ceiling — hand to a human with the PR and head SHA
439
670
  * CI never proved, without spending infra attempts. Mirrors the usage-cap ceiling