agent-dealer 1.2.9 → 1.2.10
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/github.js +13 -0
- package/bundle/server/dist/adapters/github.test.js +13 -0
- package/bundle/server/dist/coordinator/commands.js +147 -0
- package/bundle/server/dist/coordinator/developer-effect.js +93 -1
- package/bundle/server/dist/coordinator/developer-effect.test.js +172 -0
- package/bundle/server/dist/coordinator/merge-conflict-sync.js +100 -12
- package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +190 -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/routing.js +10 -0
- package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
- 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/shared/package.json +1 -1
- package/package.json +1 -1
|
@@ -25,6 +25,10 @@ export function parsePrView(json) {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
export const PR_VIEW_FIELDS = "number,url,baseRefName,headRefName,headRefOid,isDraft";
|
|
28
|
+
/** Normalizes `gh pr view --json mergeable` output; anything unrecognized is UNKNOWN. */
|
|
29
|
+
export function parsePrMergeableState(raw) {
|
|
30
|
+
return raw === "CONFLICTING" || raw === "MERGEABLE" ? raw : "UNKNOWN";
|
|
31
|
+
}
|
|
28
32
|
const FAILURE_STATES = new Set([
|
|
29
33
|
"failure",
|
|
30
34
|
"error",
|
|
@@ -394,6 +398,15 @@ export function createGithubAdapter(exec = defaultExec) {
|
|
|
394
398
|
return null;
|
|
395
399
|
}
|
|
396
400
|
},
|
|
401
|
+
async prMergeableState({ cwd, number }) {
|
|
402
|
+
try {
|
|
403
|
+
const raw = await ghPrView(exec, cwd, "mergeable", String(number));
|
|
404
|
+
return parsePrMergeableState(raw?.mergeable);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return "UNKNOWN";
|
|
408
|
+
}
|
|
409
|
+
},
|
|
397
410
|
async publishReview({ cwd, number, event, bodyFilePath }) {
|
|
398
411
|
const result = await runReview(exec, cwd, number, event, bodyFilePath);
|
|
399
412
|
if (result.ok || event === "COMMENT" || !OWN_PR_REVIEW_PATTERN.test(result.reason)) {
|
|
@@ -114,6 +114,19 @@ test("viewPr: a retry can find the already-created PR by explicit branch even wi
|
|
|
114
114
|
const view = await createGithubAdapter(exec).viewPr({ cwd: "/repo", branch: "issue-4e5eb611" });
|
|
115
115
|
assert.equal(view?.number, 42);
|
|
116
116
|
});
|
|
117
|
+
test("NOT-354: prMergeableState reads `mergeable` by PR number; unknown values and failures are UNKNOWN", async () => {
|
|
118
|
+
const conflicting = queuedExec([{ stdout: JSON.stringify({ mergeable: "CONFLICTING" }) }]);
|
|
119
|
+
assert.equal(await createGithubAdapter(conflicting.exec).prMergeableState({ cwd: "/repo", number: 42 }), "CONFLICTING");
|
|
120
|
+
assert.deepEqual(conflicting.calls[0], ["pr", "view", "42", "--json", "mergeable"]);
|
|
121
|
+
const mergeable = queuedExec([{ stdout: JSON.stringify({ mergeable: "MERGEABLE" }) }]);
|
|
122
|
+
assert.equal(await createGithubAdapter(mergeable.exec).prMergeableState({ cwd: "/repo", number: 42 }), "MERGEABLE");
|
|
123
|
+
const unknown = queuedExec([{ stdout: JSON.stringify({ mergeable: "UNKNOWN" }) }]);
|
|
124
|
+
assert.equal(await createGithubAdapter(unknown.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
|
|
125
|
+
const odd = queuedExec([{ stdout: JSON.stringify({ mergeable: "something-new" }) }]);
|
|
126
|
+
assert.equal(await createGithubAdapter(odd.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
|
|
127
|
+
const failing = queuedExec([{ error: "HTTP 502" }]);
|
|
128
|
+
assert.equal(await createGithubAdapter(failing.exec).prMergeableState({ cwd: "/repo", number: 42 }), "UNKNOWN");
|
|
129
|
+
});
|
|
117
130
|
test("checksSnapshot looks up the PR's check rollup by explicit selector (number preferred), never a bare `gh pr view`", async () => {
|
|
118
131
|
const success = { stdout: JSON.stringify({ statusCheckRollup: [{ conclusion: "success" }] }) };
|
|
119
132
|
const byNumber = queuedExec([success]);
|
|
@@ -31,6 +31,7 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
|
|
|
31
31
|
import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
|
|
32
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";
|
|
33
33
|
import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
34
|
+
import { 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,12 @@ 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
|
+
}
|
|
341
348
|
const routed = getDb().transaction(() => {
|
|
342
349
|
const before = getWorkItem(workItemId);
|
|
343
350
|
if (!before)
|
|
@@ -434,6 +441,146 @@ function applyBaseFetchDeferralCompletion(workItemId, leaseToken, failure) {
|
|
|
434
441
|
function applyChecksPendingCompletion(workItemId, leaseToken, pending) {
|
|
435
442
|
return applyDeferralCompletion(workItemId, leaseToken, pending, (item, issue, instance) => deferLeasedWorkItemForChecksPending(item, leaseToken, pending, issue, instance), (issue, instance, item) => routeChecksWaitEscalation(issue, instance, item, pending));
|
|
436
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* NOT-354: act on the base sync a conflicting PR's CI wait ran. A clean sync is a
|
|
446
|
+
* normal CI wait on the pushed head, anchored at the original wait start (the
|
|
447
|
+
* deferral keeps any persisted `checksWaitStartedAt`), so the ceiling is never
|
|
448
|
+
* reset. A textual conflict queues the one NOT-310 conflict-repair round while
|
|
449
|
+
* finishing this item; anything else escalates once.
|
|
450
|
+
*/
|
|
451
|
+
function applyChecksConflictCompletion(workItemId, leaseToken, outcome) {
|
|
452
|
+
const { sync } = outcome;
|
|
453
|
+
if (sync.result === "synced") {
|
|
454
|
+
const headSha = sync.headSha ?? outcome.headSha;
|
|
455
|
+
return applyChecksPendingCompletion(workItemId, leaseToken, {
|
|
456
|
+
kind: "checks_pending",
|
|
457
|
+
reason: `Synced ${outcome.baseBranch} into PR #${outcome.prNumber}; waiting for CI on ${headSha.slice(0, 12)} - no attempt spent`,
|
|
458
|
+
branch: outcome.branch,
|
|
459
|
+
prNumber: outcome.prNumber,
|
|
460
|
+
headSha,
|
|
461
|
+
waitStartedAt: outcome.waitStartedAt,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return getDb().transaction(() => {
|
|
465
|
+
const before = getWorkItem(workItemId);
|
|
466
|
+
if (!before)
|
|
467
|
+
return { applied: false, reason: "not_found" };
|
|
468
|
+
if (before.status === "done" || before.status === "dead" || before.status === "cancelled") {
|
|
469
|
+
return { applied: false, reason: "already_terminal" };
|
|
470
|
+
}
|
|
471
|
+
const issue = getIssue(before.issueId);
|
|
472
|
+
if (!issue)
|
|
473
|
+
return { applied: false, reason: "not_found" };
|
|
474
|
+
const instance = getActiveWorkflowInstance(before.issueId);
|
|
475
|
+
if (!instance || instance.id !== before.workflowInstanceId) {
|
|
476
|
+
return { applied: false, reason: "no_active_instance" };
|
|
477
|
+
}
|
|
478
|
+
if (sync.result === "repair") {
|
|
479
|
+
const queued = queueConflictRepairRound({
|
|
480
|
+
issueId: issue.id,
|
|
481
|
+
instanceId: instance.id,
|
|
482
|
+
baseBranch: outcome.baseBranch,
|
|
483
|
+
branch: outcome.branch,
|
|
484
|
+
files: sync.files,
|
|
485
|
+
entry: "checks_wait",
|
|
486
|
+
finish: { workItemId, leaseToken, result: outcome },
|
|
487
|
+
});
|
|
488
|
+
if (queued) {
|
|
489
|
+
const session = before.workerSessionId ? getWorkerSession(before.workerSessionId) : null;
|
|
490
|
+
appendWorkflowEvent({
|
|
491
|
+
issueId: issue.id,
|
|
492
|
+
workflowInstanceId: instance.id,
|
|
493
|
+
workerSessionId: before.workerSessionId,
|
|
494
|
+
type: "worker.completed",
|
|
495
|
+
actorType: "developer",
|
|
496
|
+
stage: getIssue(issue.id).status,
|
|
497
|
+
round: issue.currentRound,
|
|
498
|
+
payload: {
|
|
499
|
+
...workerSessionPayload({
|
|
500
|
+
runtime: session?.runtime,
|
|
501
|
+
model: session?.model,
|
|
502
|
+
sessionId: before.workerSessionId ?? "",
|
|
503
|
+
worktreePath: session?.worktreePath,
|
|
504
|
+
}),
|
|
505
|
+
outcome: outcome.kind,
|
|
506
|
+
...(isPublishOnlyItem(before) ? { publishOnly: true } : {}),
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
return {
|
|
510
|
+
applied: true,
|
|
511
|
+
issueStatus: getIssue(issue.id).status,
|
|
512
|
+
nextWorkItemId: queued.id,
|
|
513
|
+
humanActionId: null,
|
|
514
|
+
instanceCompleted: false,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
// Lease lost, or the issue left its developer stage — a lost lease applies
|
|
518
|
+
// nothing; otherwise fall through to the single escalation.
|
|
519
|
+
if (!getWorkItem(workItemId) || getWorkItem(workItemId).leaseToken !== leaseToken) {
|
|
520
|
+
return { applied: false, reason: "lease_lost" };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const item = finishWorkItem(workItemId, leaseToken, { status: "done", result: outcome });
|
|
524
|
+
if (!item)
|
|
525
|
+
return { applied: false, reason: "lease_lost" };
|
|
526
|
+
const reason = sync.result === "escalate"
|
|
527
|
+
? sync.reason
|
|
528
|
+
: `PR #${outcome.prNumber} conflicts with ${outcome.baseBranch}; the conflict-repair round could not be queued.`;
|
|
529
|
+
const evidence = sync.result === "escalate" ? sync.evidence : { checksConflict: true };
|
|
530
|
+
return routeChecksConflictEscalation(issue, instance, item, outcome, reason, evidence);
|
|
531
|
+
})();
|
|
532
|
+
}
|
|
533
|
+
/** NOT-354: the one escalation for a conflicting PR — same shape as the CI-wait
|
|
534
|
+
* ceiling escalation, naming the PR, base, and (when known) the conflicting files. */
|
|
535
|
+
function routeChecksConflictEscalation(issue, instance, item, outcome, reason, evidence) {
|
|
536
|
+
const ev = eventEmitter(issue, instance, item.workerSessionId, "needs_human", issue.currentRound);
|
|
537
|
+
const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
|
|
538
|
+
for (const type of capEscalationEvents()) {
|
|
539
|
+
ev.emit(type, {
|
|
540
|
+
actorType: "developer",
|
|
541
|
+
payload: {
|
|
542
|
+
...workerSessionPayload({
|
|
543
|
+
runtime: session?.runtime ?? null,
|
|
544
|
+
model: session?.model ?? null,
|
|
545
|
+
sessionId: item.workerSessionId ?? "",
|
|
546
|
+
worktreePath: session?.worktreePath,
|
|
547
|
+
}),
|
|
548
|
+
outcome: outcome.kind,
|
|
549
|
+
reason,
|
|
550
|
+
branch: outcome.branch,
|
|
551
|
+
baseBranch: outcome.baseBranch,
|
|
552
|
+
prNumber: outcome.prNumber,
|
|
553
|
+
headSha: outcome.headSha,
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
applyProjectionTransition(issue, { issueStatus: "needs_human", currentOwner: "human", currentIntent: reason, events: capEscalationEvents() }, {});
|
|
558
|
+
const action = createHumanAction({
|
|
559
|
+
issueId: issue.id,
|
|
560
|
+
workflowInstanceId: instance.id,
|
|
561
|
+
actionType: "policy_escalation",
|
|
562
|
+
reason,
|
|
563
|
+
question: questionFor("policy_escalation", reason),
|
|
564
|
+
evidence: {
|
|
565
|
+
...evidence,
|
|
566
|
+
checksConflict: {
|
|
567
|
+
branch: outcome.branch,
|
|
568
|
+
baseBranch: outcome.baseBranch,
|
|
569
|
+
prNumber: outcome.prNumber,
|
|
570
|
+
headSha: outcome.headSha,
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
responseOptions: responseOptionsFor("policy_escalation"),
|
|
574
|
+
});
|
|
575
|
+
ev.emit("human_action.requested", { payload: { actionType: "policy_escalation", actionId: action.id } });
|
|
576
|
+
return {
|
|
577
|
+
applied: true,
|
|
578
|
+
issueStatus: "needs_human",
|
|
579
|
+
nextWorkItemId: null,
|
|
580
|
+
humanActionId: action.id,
|
|
581
|
+
instanceCompleted: false,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
437
584
|
/**
|
|
438
585
|
* NOT-311: the CI wait passed its ceiling — hand to a human with the PR and head SHA
|
|
439
586
|
* CI never proved, without spending infra attempts. Mirrors the usage-cap ceiling
|
|
@@ -17,6 +17,7 @@ import fs from "node:fs";
|
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { parsePhaseBudget, parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
|
|
20
|
+
import { runMergeConflictSync } from "./merge-conflict-sync.js";
|
|
20
21
|
import { getTaskSnapshot } from "./commands.js";
|
|
21
22
|
import { extractOperatorCriteria } from "./operator-criteria.js";
|
|
22
23
|
import { buildDeveloperPrompt } from "./prompts.js";
|
|
@@ -28,7 +29,7 @@ import { recordIssueBaseSha } from "../repository/issues.js";
|
|
|
28
29
|
import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
|
|
29
30
|
import { baseRefCandidates, inspectBranchProgress } from "./branch-progress.js";
|
|
30
31
|
import { prepareWorkerDeckConnection, releaseWorkerDeckConnection, verifyWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
|
|
31
|
-
import { realGithubAdapter, pollPrChecksDetailed } from "../adapters/github.js";
|
|
32
|
+
import { realGithubAdapter, pollPrChecksDetailed, } from "../adapters/github.js";
|
|
32
33
|
import { checksWaitCeilingExceeded, checksWaitCeilingMs } from "./checks-wait-config.js";
|
|
33
34
|
import { museCapabilitySafetyNetAfterSession } from "../adapters/muse-capability.js";
|
|
34
35
|
import { getOrAssignSessionCorrelationId, getWorkerSession, mergeSessionMetadata, patchRunningSession, recordSessionProcess, setSessionInputSha } from "../repository/worker-sessions.js";
|
|
@@ -328,6 +329,11 @@ function checksWaitStartedAtFromPayload(payloadJson) {
|
|
|
328
329
|
* A missing/unknown workflow answer (no adapter method, unreadable base ref) waits
|
|
329
330
|
* fail-closed: sending an unverified PR to review is the outcome this exists to
|
|
330
331
|
* prevent, and the wait ceiling still bounds it.
|
|
332
|
+
*
|
|
333
|
+
* NOT-354: "no checks" on a CONFLICTING PR is not a queued wait — GitHub creates no
|
|
334
|
+
* merge ref, so the workflow never starts. That case returns `conflicting` (and the
|
|
335
|
+
* `conflicting` milestone reason) so the caller runs the base sync instead of
|
|
336
|
+
* deferring. UNKNOWN mergeability is "not computed yet" and keeps today's wait.
|
|
331
337
|
*/
|
|
332
338
|
async function resolveChecksWait(opts) {
|
|
333
339
|
const waitStartedAt = checksWaitStartedAtFromPayload(opts.payloadJson) ?? opts.pollStartedAt;
|
|
@@ -350,6 +356,16 @@ async function resolveChecksWait(opts) {
|
|
|
350
356
|
if (hasWorkflow === false) {
|
|
351
357
|
return { pending: null, milestoneReason: "none_no_workflow" };
|
|
352
358
|
}
|
|
359
|
+
let mergeable = "UNKNOWN";
|
|
360
|
+
try {
|
|
361
|
+
mergeable = (await opts.github.prMergeableState?.({ cwd: opts.cwd, number: opts.prNumber })) ?? "UNKNOWN";
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
mergeable = "UNKNOWN";
|
|
365
|
+
}
|
|
366
|
+
if (mergeable === "CONFLICTING") {
|
|
367
|
+
return { pending: null, milestoneReason: "conflicting", conflicting: { waitStartedAt } };
|
|
368
|
+
}
|
|
353
369
|
return {
|
|
354
370
|
pending: { ...base, reason: "Waiting for CI (queued) - no attempt spent" },
|
|
355
371
|
milestoneReason: "none_with_workflow",
|
|
@@ -364,6 +380,60 @@ async function resolveChecksWait(opts) {
|
|
|
364
380
|
}
|
|
365
381
|
return { pending: null };
|
|
366
382
|
}
|
|
383
|
+
/**
|
|
384
|
+
* NOT-354: a conflicting PR's CI wait runs the NOT-310 base sync (`checks_wait`
|
|
385
|
+
* entry: plain push, never force/rebase, one sync + one repair round per episode)
|
|
386
|
+
* from the effect, which still holds the lease. applyCompletion acts on the
|
|
387
|
+
* result. A refusal (`skipped`) becomes the single escalation, never a wait.
|
|
388
|
+
*/
|
|
389
|
+
async function checksConflictOutcome(ctx, deps, opts) {
|
|
390
|
+
const sync = deps.conflictSync ?? runMergeConflictSync;
|
|
391
|
+
const conflictReason = `PR #${opts.prNumber} conflicts with ${opts.baseBranch} (mergeable: CONFLICTING), ` +
|
|
392
|
+
`so its CI cannot start.`;
|
|
393
|
+
const base = {
|
|
394
|
+
kind: "checks_conflicting",
|
|
395
|
+
branch: opts.branch,
|
|
396
|
+
baseBranch: opts.baseBranch,
|
|
397
|
+
prNumber: opts.prNumber,
|
|
398
|
+
headSha: opts.headSha,
|
|
399
|
+
waitStartedAt: opts.waitStartedAt,
|
|
400
|
+
};
|
|
401
|
+
let result;
|
|
402
|
+
try {
|
|
403
|
+
result = await sync({
|
|
404
|
+
issueId: ctx.issue.id,
|
|
405
|
+
instanceId: ctx.instance.id,
|
|
406
|
+
repo: ctx.issue.repo,
|
|
407
|
+
branch: opts.branch,
|
|
408
|
+
baseBranch: opts.baseBranch,
|
|
409
|
+
prNumber: opts.prNumber,
|
|
410
|
+
mergeReason: conflictReason,
|
|
411
|
+
entry: "checks_wait",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
416
|
+
result = { outcome: "skipped", reason: `base sync errored: ${detail}` };
|
|
417
|
+
}
|
|
418
|
+
switch (result.outcome) {
|
|
419
|
+
case "synced":
|
|
420
|
+
return { ...base, sync: { result: "synced", headSha: result.headSha } };
|
|
421
|
+
case "repair_needed":
|
|
422
|
+
return { ...base, sync: { result: "repair", files: result.files } };
|
|
423
|
+
case "escalate":
|
|
424
|
+
return { ...base, sync: { result: "escalate", reason: result.reason, evidence: result.evidence } };
|
|
425
|
+
default:
|
|
426
|
+
// skipped (or a merge-only outcome that cannot occur on this entry).
|
|
427
|
+
return {
|
|
428
|
+
...base,
|
|
429
|
+
sync: {
|
|
430
|
+
result: "escalate",
|
|
431
|
+
reason: `${conflictReason} Dealer could not sync ${opts.baseBranch}: ${result.outcome === "skipped" ? result.reason : result.outcome}`,
|
|
432
|
+
evidence: { checksConflict: true },
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
367
437
|
/**
|
|
368
438
|
* Infra retry that skips the agent: publish whatever the branch already carries. Uses the
|
|
369
439
|
* issue repo as `gh` cwd and `origin/<branch>` as the verified head (any worktree was
|
|
@@ -667,6 +737,16 @@ async function runPublishOnlyHandoff(ctx, deps, branchName) {
|
|
|
667
737
|
author: "system",
|
|
668
738
|
content: { snapshot: checks, prNumber: prView.number, headSha: prView.headRefOid },
|
|
669
739
|
});
|
|
740
|
+
// NOT-354: a conflicting PR never gets checks — sync the base instead of waiting.
|
|
741
|
+
if (wait.conflicting) {
|
|
742
|
+
return checksConflictOutcome(ctx, deps, {
|
|
743
|
+
branch: branchName,
|
|
744
|
+
baseBranch: prView.baseRefName,
|
|
745
|
+
prNumber: prView.number,
|
|
746
|
+
headSha: prView.headRefOid,
|
|
747
|
+
waitStartedAt: wait.conflicting.waitStartedAt,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
670
750
|
// NOT-311: still-waiting CI defers with backoff (no attempt spent) instead of
|
|
671
751
|
// timing out the session. An aborted wait keeps today's `timed_out`.
|
|
672
752
|
if (wait.pending)
|
|
@@ -1811,6 +1891,18 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1811
1891
|
author: "system",
|
|
1812
1892
|
content: { snapshot: checks, prNumber: prView.number, headSha: prView.headRefOid },
|
|
1813
1893
|
});
|
|
1894
|
+
// NOT-354: a conflicting PR never gets checks — sync the base instead of waiting.
|
|
1895
|
+
// The worktree goes first: the sync refuses a branch still checked out elsewhere.
|
|
1896
|
+
if (wait.conflicting) {
|
|
1897
|
+
await bestEffortRemove(repoPath, worktreePath);
|
|
1898
|
+
return checksConflictOutcome(ctx, deps, {
|
|
1899
|
+
branch: branchName,
|
|
1900
|
+
baseBranch: prView.baseRefName,
|
|
1901
|
+
prNumber: prView.number,
|
|
1902
|
+
headSha: prView.headRefOid,
|
|
1903
|
+
waitStartedAt: wait.conflicting.waitStartedAt,
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1814
1906
|
// NOT-311: still-waiting CI defers with backoff (no attempt spent) instead of
|
|
1815
1907
|
// timing out the session. An aborted wait keeps today's `timed_out`.
|
|
1816
1908
|
if (wait.pending) {
|
|
@@ -162,6 +162,14 @@ function fakeGithub(opts = {}) {
|
|
|
162
162
|
throw new Error("publishReview is unused by the developer effect");
|
|
163
163
|
}
|
|
164
164
|
};
|
|
165
|
+
if (opts.mergeable !== undefined) {
|
|
166
|
+
const mergeable = opts.mergeable;
|
|
167
|
+
adapter.prMergeableState = async ({ number }) => {
|
|
168
|
+
if (number == null)
|
|
169
|
+
throw new Error("fakeGithub.prMergeableState requires an explicit PR number");
|
|
170
|
+
return typeof mergeable === "function" ? mergeable() : mergeable;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
165
173
|
return adapter;
|
|
166
174
|
}
|
|
167
175
|
test("clean handoff: real worktree, real push, fake GitHub — issue moves to reviewing with a reviewer work item queued", async () => {
|
|
@@ -667,6 +675,170 @@ test("NOT-311: none with an unknown workflow answer waits fail-closed", async ()
|
|
|
667
675
|
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
668
676
|
assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "none_with_workflow");
|
|
669
677
|
});
|
|
678
|
+
/** NOT-354: records every base-sync invocation and answers from `respond`. */
|
|
679
|
+
function recordingConflictSync(respond) {
|
|
680
|
+
const calls = [];
|
|
681
|
+
return {
|
|
682
|
+
calls,
|
|
683
|
+
sync: async (call) => {
|
|
684
|
+
calls.push(call);
|
|
685
|
+
return respond(call, calls.length);
|
|
686
|
+
},
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
async function withShortDeferralBackoff(fn) {
|
|
690
|
+
const prevBase = process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
|
|
691
|
+
const prevMax = process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
|
|
692
|
+
process.env.DECK_OUTAGE_BACKOFF_BASE_MS = "10";
|
|
693
|
+
process.env.DECK_OUTAGE_BACKOFF_MAX_MS = "10";
|
|
694
|
+
try {
|
|
695
|
+
await fn();
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
if (prevBase === undefined)
|
|
699
|
+
delete process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
|
|
700
|
+
else
|
|
701
|
+
process.env.DECK_OUTAGE_BACKOFF_BASE_MS = prevBase;
|
|
702
|
+
if (prevMax === undefined)
|
|
703
|
+
delete process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
|
|
704
|
+
else
|
|
705
|
+
process.env.DECK_OUTAGE_BACKOFF_MAX_MS = prevMax;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for (const state of ["UNKNOWN", "MERGEABLE"]) {
|
|
709
|
+
test(`NOT-354: none + pull_request workflow + ${state} PR keeps the queued CI wait, no base sync`, async () => {
|
|
710
|
+
const issueId = await makeIssue();
|
|
711
|
+
const recorder = recordingConflictSync(async () => {
|
|
712
|
+
throw new Error("a non-conflicting PR must never run the base sync");
|
|
713
|
+
});
|
|
714
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
|
|
715
|
+
deckCallTool: okDeckCallTool,
|
|
716
|
+
spawn: commitingSpawn,
|
|
717
|
+
github: fakeGithub({ checks: "none", pullRequestWorkflow: true, mergeable: state }),
|
|
718
|
+
conflictSync: recorder.sync,
|
|
719
|
+
}));
|
|
720
|
+
startWorkflow(issueId);
|
|
721
|
+
await pump(1);
|
|
722
|
+
assert.equal(recorder.calls.length, 0);
|
|
723
|
+
const issue = getIssue(issueId);
|
|
724
|
+
assert.equal(issue.status, "developing");
|
|
725
|
+
assert.match(issue.currentIntent ?? "", /Waiting for CI \(queued\) - no attempt spent/);
|
|
726
|
+
const deferred = listWorkflowEventsForIssue(issueId).filter((e) => e.type === "worker.deferred");
|
|
727
|
+
assert.equal(deferred.length, 1);
|
|
728
|
+
assert.equal(JSON.parse(deferred[0].payloadJson ?? "{}").outcome, "checks_pending");
|
|
729
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
730
|
+
assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "none_with_workflow");
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
test("NOT-354: none + pull_request workflow + CONFLICTING runs the base sync instead of a queued wait; a clean sync re-polls and hands off", async () => {
|
|
734
|
+
await withShortDeferralBackoff(async () => {
|
|
735
|
+
let mergeable = "CONFLICTING";
|
|
736
|
+
let ci = "none";
|
|
737
|
+
let spawns = 0;
|
|
738
|
+
const countingSpawn = async (input) => {
|
|
739
|
+
spawns++;
|
|
740
|
+
return commitingSpawn(input);
|
|
741
|
+
};
|
|
742
|
+
const issueId = await makeIssue();
|
|
743
|
+
const branch = issueBranchName(issueId);
|
|
744
|
+
const recorder = recordingConflictSync(async () => ({ outcome: "synced", headSha: git(remote, "rev-parse", branch) }));
|
|
745
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
|
|
746
|
+
deckCallTool: okDeckCallTool,
|
|
747
|
+
spawn: countingSpawn,
|
|
748
|
+
github: fakeGithub({ checks: () => ci, pullRequestWorkflow: true, mergeable: () => mergeable }),
|
|
749
|
+
conflictSync: recorder.sync,
|
|
750
|
+
}));
|
|
751
|
+
startWorkflow(issueId);
|
|
752
|
+
await pump(1);
|
|
753
|
+
// The coordinator invoked the NOT-310 sync through its checks-wait entry.
|
|
754
|
+
assert.equal(recorder.calls.length, 1);
|
|
755
|
+
const call = recorder.calls[0];
|
|
756
|
+
assert.equal(call.entry, "checks_wait");
|
|
757
|
+
assert.equal(call.branch, branch);
|
|
758
|
+
assert.equal(call.baseBranch, "main");
|
|
759
|
+
assert.equal(call.repo, repo);
|
|
760
|
+
assert.equal(call.mergePr, undefined, "the checks-wait entry never merges");
|
|
761
|
+
assert.match(call.mergeReason, /conflicts with main/);
|
|
762
|
+
const events = listWorkflowEventsForIssue(issueId);
|
|
763
|
+
const completed = events.find((e) => e.type === "checks.completed");
|
|
764
|
+
assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "conflicting");
|
|
765
|
+
const deferred = events.filter((e) => e.type === "worker.deferred");
|
|
766
|
+
assert.equal(deferred.length, 1, "the synced head waits on a normal CI re-poll");
|
|
767
|
+
const deferredReason = JSON.parse(deferred[0].payloadJson ?? "{}").reason;
|
|
768
|
+
assert.doesNotMatch(deferredReason, /Waiting for CI \(queued\)/);
|
|
769
|
+
assert.match(deferredReason, /Synced main into PR #\d+/);
|
|
770
|
+
const item = listWorkItemsForIssue(issueId)[0];
|
|
771
|
+
const payload = JSON.parse(item.payloadJson ?? "{}");
|
|
772
|
+
assert.equal(payload.publishOnly, true, "the re-poll takes the no-agent publish path");
|
|
773
|
+
assert.ok(payload.checksWaitStartedAt, "the wait keeps its original ceiling anchor");
|
|
774
|
+
// GitHub recomputes mergeability on the pushed head and CI runs.
|
|
775
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
776
|
+
mergeable = "MERGEABLE";
|
|
777
|
+
ci = "success";
|
|
778
|
+
await pump(1);
|
|
779
|
+
const issue = getIssue(issueId);
|
|
780
|
+
assert.equal(issue.status, "reviewing", "the synced head continues to review with no human action");
|
|
781
|
+
assert.equal(spawns, 1, "the re-poll spawns no agent");
|
|
782
|
+
assert.equal(recorder.calls.length, 1, "no second sync");
|
|
783
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
|
|
784
|
+
assert.equal(issue.infraAttempts, 0);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
test("NOT-354: CONFLICTING with an unresolvable conflict queues exactly one conflict-repair round; still conflicting after it escalates once with the files", async () => {
|
|
788
|
+
const issueId = await makeIssue();
|
|
789
|
+
const prompts = [];
|
|
790
|
+
let n = 0;
|
|
791
|
+
const appendingSpawn = async (input) => {
|
|
792
|
+
prompts.push(input.prompt);
|
|
793
|
+
n++;
|
|
794
|
+
fs.appendFileSync(path.join(input.cwd, "feature.txt"), `round ${n}\n`);
|
|
795
|
+
git(input.cwd, "add", ".");
|
|
796
|
+
git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", `round ${n}`);
|
|
797
|
+
return { exitCode: 0, transcript: "Implementation conclusion: done.", logPath: "/dev/null", timedOut: false };
|
|
798
|
+
};
|
|
799
|
+
const { runMergeConflictSync } = await import("./merge-conflict-sync.js");
|
|
800
|
+
// First episode call: the sync hit a textual conflict. Second call: the real
|
|
801
|
+
// sync, whose episode bound must escalate before touching git.
|
|
802
|
+
const recorder = recordingConflictSync(async (call, i) => i === 1 ? { outcome: "repair_needed", files: ["shared.txt"] } : runMergeConflictSync(call));
|
|
803
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
|
|
804
|
+
deckCallTool: okDeckCallTool,
|
|
805
|
+
spawn: appendingSpawn,
|
|
806
|
+
github: fakeGithub({ checks: "none", pullRequestWorkflow: true, mergeable: "CONFLICTING" }),
|
|
807
|
+
conflictSync: recorder.sync,
|
|
808
|
+
}));
|
|
809
|
+
startWorkflow(issueId);
|
|
810
|
+
await pump(1);
|
|
811
|
+
let issue = getIssue(issueId);
|
|
812
|
+
assert.equal(issue.status, "developing");
|
|
813
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
|
|
814
|
+
const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
|
|
815
|
+
assert.equal(pending.length, 1, "exactly one conflict-repair round");
|
|
816
|
+
assert.equal(pending[0].kind, "developer");
|
|
817
|
+
assert.equal(pending[0].round, 2);
|
|
818
|
+
const payload = JSON.parse(pending[0].payloadJson ?? "{}");
|
|
819
|
+
assert.deepEqual(payload.conflictRepair, { baseBranch: "main", branch: issueBranchName(issueId), files: ["shared.txt"] });
|
|
820
|
+
assert.equal(payload.publishOnly, undefined, "the repair round is an agent session, not a re-poll");
|
|
821
|
+
assert.equal(listWorkflowEventsForIssue(issueId).filter((e) => e.type === "worker.deferred").length, 0, "a conflicting PR never defers as a CI wait");
|
|
822
|
+
await pump(1);
|
|
823
|
+
assert.equal(prompts.length, 2);
|
|
824
|
+
assert.ok(prompts[1].includes("main"), "repair prompt names the base");
|
|
825
|
+
assert.ok(prompts[1].includes("shared.txt"), "repair prompt names the conflicting files");
|
|
826
|
+
issue = getIssue(issueId);
|
|
827
|
+
assert.equal(issue.status, "needs_human");
|
|
828
|
+
assert.equal(recorder.calls.length, 2);
|
|
829
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.status === "pending").length, 0);
|
|
830
|
+
const open = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
831
|
+
assert.equal(open.length, 1, "escalates exactly once");
|
|
832
|
+
assert.equal(open[0].actionType, "policy_escalation");
|
|
833
|
+
assert.match(open[0].reason, /shared\.txt/);
|
|
834
|
+
assert.match(open[0].reason, /conflicts with main/);
|
|
835
|
+
assert.doesNotMatch(open[0].reason, /Auto-merge failed/);
|
|
836
|
+
const evidence = JSON.parse(open[0].evidenceJson);
|
|
837
|
+
assert.deepEqual(evidence.conflictingFiles, ["shared.txt"]);
|
|
838
|
+
assert.equal(evidence.mergeFailure, undefined, "a pre-review conflict offers no retry_merge");
|
|
839
|
+
await pump(1);
|
|
840
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 1, "no repeat escalation");
|
|
841
|
+
});
|
|
670
842
|
test("NOT-311: past CHECKS_WAIT_CEILING_MS the wait escalates naming the PR and head SHA", async () => {
|
|
671
843
|
// A zero ceiling makes the very first wait already past it — no clock to inject,
|
|
672
844
|
// no sleeping: the deferral reports escalated and a policy_escalation lands.
|