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.
- package/bundle/server/dist/adapters/base-conflict-probe.test.js +102 -0
- package/bundle/server/dist/adapters/git-worktree.js +60 -5
- package/bundle/server/dist/adapters/git-worktree.test.js +13 -1
- package/bundle/server/dist/adapters/github.js +13 -0
- package/bundle/server/dist/adapters/github.test.js +13 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +815 -0
- package/bundle/server/dist/coordinator/auto-merge.js +16 -3
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +17 -0
- package/bundle/server/dist/coordinator/base-advanced-scan.js +308 -0
- package/bundle/server/dist/coordinator/commands.js +232 -1
- package/bundle/server/dist/coordinator/developer-effect.js +206 -2
- package/bundle/server/dist/coordinator/developer-effect.test.js +490 -1
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +278 -16
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +281 -0
- package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
- package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
- package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +5 -1
- package/bundle/server/dist/coordinator/routing.js +17 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +3 -0
- package/bundle/server/dist/coordinator/worker-loop.js +3 -2
- package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
- package/bundle/server/dist/runners/muse-code.test.js +13 -1
- package/bundle/server/dist/runners/spawn-cli.js +93 -20
- package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/{index-CU6fNntg.js → index-Cf4dipuK.js} +10 -10
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +9 -0
- package/bundle/shared/dist/workflow.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- 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
|
-
|
|
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
|