agent-dealer 1.2.8 → 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 +93 -4
- package/bundle/server/dist/adapters/github.test.js +96 -1
- package/bundle/server/dist/adapters/worktree-deps.js +96 -0
- package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
- package/bundle/server/dist/coordinator/auto-merge.js +205 -1
- package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
- package/bundle/server/dist/coordinator/commands.js +341 -18
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/developer-effect.js +237 -3
- package/bundle/server/dist/coordinator/developer-effect.test.js +460 -3
- package/bundle/server/dist/coordinator/human-resolution.js +19 -0
- package/bundle/server/dist/coordinator/human-resolution.test.js +33 -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/operator-criteria.js +168 -0
- package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
- package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
- package/bundle/server/dist/coordinator/projection.js +10 -4
- package/bundle/server/dist/coordinator/projection.test.js +13 -0
- package/bundle/server/dist/coordinator/prompts.js +134 -1
- package/bundle/server/dist/coordinator/prompts.test.js +148 -0
- package/bundle/server/dist/coordinator/recovery.js +57 -3
- package/bundle/server/dist/coordinator/recovery.test.js +67 -8
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
- package/bundle/server/dist/coordinator/routing.js +55 -6
- package/bundle/server/dist/coordinator/routing.test.js +62 -15
- package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
- package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
- package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
- package/bundle/server/dist/db/index.js +13 -0
- package/bundle/server/dist/db/schema.sql +2 -0
- package/bundle/server/dist/repository/issues.js +39 -3
- package/bundle/server/dist/repository/work-items.js +7 -0
- package/bundle/server/dist/runners/muse-code-args.js +25 -0
- package/bundle/server/dist/runners/muse-code-args.test.js +30 -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/server/static-ui/assets/index-CU6fNntg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/human-actions.d.ts +4 -4
- package/bundle/shared/dist/human-actions.js +8 -0
- package/bundle/shared/dist/issues.d.ts +30 -0
- package/bundle/shared/dist/issues.js +14 -0
- package/bundle/shared/dist/issues.test.js +2 -0
- package/bundle/shared/dist/workflow.d.ts +4 -4
- package/bundle/shared/dist/workflow.js +6 -0
- package/bundle/shared/dist/workflow.test.js +1 -0
- package/bundle/shared/package.json +1 -1
- package/dist/action.js +4 -1
- package/dist/action.test.js +42 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import { canTransitionIssue, tryCompileContract } from "@agent-dealer/shared";
|
|
11
11
|
import { getDb } from "../db/index.js";
|
|
12
|
-
import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, resetIssueInfraAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
|
|
12
|
+
import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, incrementIssueCiAttempts, resetIssueInfraAttempts, resetIssueCiAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
|
|
13
13
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, listWorkflowEventsForIssue, startWorkflowInstance, WorkflowAlreadyActiveError, } from "../repository/workflow-events.js";
|
|
14
14
|
import { createHumanAction, findOpenHumanAction, getHumanAction, listHumanActionsForIssue, reopenHumanAction, resolveHumanAction, updateOpenHumanAction, } from "../repository/human-actions.js";
|
|
15
15
|
import { pushLeaseToSha, readRemoteTip } from "../adapters/git-worktree.js";
|
|
@@ -20,7 +20,7 @@ import { getAgent } from "../repository/agents.js";
|
|
|
20
20
|
import { githubIssuesSync, invalidateMuseHealthCache } from "../adapters/agent-health.js";
|
|
21
21
|
import { recordMuseCapabilityOverride } from "../adapters/muse-capability.js";
|
|
22
22
|
import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
|
|
23
|
-
import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, listWorkItemsForIssue, } from "../repository/work-items.js";
|
|
23
|
+
import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, getWorkItemByIdempotencyKey, listWorkItemsForIssue, } from "../repository/work-items.js";
|
|
24
24
|
import { completeSession, getWorkerSession, listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
|
|
25
25
|
import { killRunProcess } from "../runners/spawn-cli.js";
|
|
26
26
|
import { buildProfileSnapshot, serializeProfileSnapshot } from "./profile-snapshot.js";
|
|
@@ -31,9 +31,11 @@ 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";
|
|
35
|
+
import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
|
|
34
36
|
import { externalMergeStateForIssue } from "./external-merge.js";
|
|
35
37
|
import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
|
|
36
|
-
import { capEscalationEvents, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
|
|
38
|
+
import { capEscalationEvents, checksWaitDeferralStartedAt, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForChecksPending, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, formatChecksWaitEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
|
|
37
39
|
import { dequeueIssue, getQueuedEntryForIssue, markQueueEntryAdmitted, } from "../repository/queue-entries.js";
|
|
38
40
|
import { fetchResumeLiveHeadSha } from "./resume-live-head.js";
|
|
39
41
|
import { getWorkflow } from "./workflows/registry.js";
|
|
@@ -136,7 +138,11 @@ function freezeTaskSnapshot(issue) {
|
|
|
136
138
|
* never changes the eventual session. The worker loop consumes this off the payload and
|
|
137
139
|
* only falls back to a live resolve for a legacy item queued before snapshots were carried.
|
|
138
140
|
*/
|
|
139
|
-
|
|
141
|
+
/**
|
|
142
|
+
* NOT-333: shared with recovery's stranded-reviewer re-enqueue so a recovered round
|
|
143
|
+
* carries the same frozen profile as a normally routed one.
|
|
144
|
+
*/
|
|
145
|
+
export function queuedProfileSnapshot(issue, kind) {
|
|
140
146
|
const agentId = kind === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
|
|
141
147
|
const agent = agentId ? getAgent(agentId) : null;
|
|
142
148
|
return agent ? serializeProfileSnapshot(buildProfileSnapshot(agent, kind)) : null;
|
|
@@ -327,6 +333,18 @@ export async function applyCompletion(workItemId, leaseToken, outcome) {
|
|
|
327
333
|
if (outcome.kind === "base_fetch_failed") {
|
|
328
334
|
return applyBaseFetchDeferralCompletion(workItemId, leaseToken, outcome);
|
|
329
335
|
}
|
|
336
|
+
// NOT-311: CI still queued/running (or not yet registered) at the poll timeout —
|
|
337
|
+
// the session ended cleanly, so the item waits on backoff and re-polls instead of
|
|
338
|
+
// spending an infra attempt. Past the wait ceiling this escalates to a human.
|
|
339
|
+
if (outcome.kind === "checks_pending") {
|
|
340
|
+
return applyChecksPendingCompletion(workItemId, leaseToken, outcome);
|
|
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
|
+
}
|
|
330
348
|
const routed = getDb().transaction(() => {
|
|
331
349
|
const before = getWorkItem(workItemId);
|
|
332
350
|
if (!before)
|
|
@@ -415,6 +433,208 @@ function applyDeckOutageCompletion(workItemId, leaseToken, outage) {
|
|
|
415
433
|
function applyBaseFetchDeferralCompletion(workItemId, leaseToken, failure) {
|
|
416
434
|
return applyDeferralCompletion(workItemId, leaseToken, failure, (item, issue, instance) => deferLeasedWorkItemForBaseFetch(item, leaseToken, failure, issue, instance));
|
|
417
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* NOT-311: CI still pending at the poll timeout — defer with backoff, no attempt
|
|
438
|
+
* spent. Unlike a deck outage the wait has a ceiling, so the escalation arm raises a
|
|
439
|
+
* human action naming the stuck PR/SHA once CI has waited too long.
|
|
440
|
+
*/
|
|
441
|
+
function applyChecksPendingCompletion(workItemId, leaseToken, pending) {
|
|
442
|
+
return applyDeferralCompletion(workItemId, leaseToken, pending, (item, issue, instance) => deferLeasedWorkItemForChecksPending(item, leaseToken, pending, issue, instance), (issue, instance, item) => routeChecksWaitEscalation(issue, instance, item, pending));
|
|
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
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* NOT-311: the CI wait passed its ceiling — hand to a human with the PR and head SHA
|
|
586
|
+
* CI never proved, without spending infra attempts. Mirrors the usage-cap ceiling
|
|
587
|
+
* escalation (worker.failed + needs_human + policy_escalation).
|
|
588
|
+
*/
|
|
589
|
+
export function routeChecksWaitEscalation(issue, instance, item, pending) {
|
|
590
|
+
const payload = parseWorkItemPayload(item.payloadJson);
|
|
591
|
+
const firstDeferredAt = checksWaitDeferralStartedAt(payload) ?? pending.waitStartedAt;
|
|
592
|
+
const reason = formatChecksWaitEscalationReason(pending, firstDeferredAt);
|
|
593
|
+
const role = item.kind === "developer" ? "developer" : "reviewer";
|
|
594
|
+
const ev = eventEmitter(issue, instance, item.workerSessionId, "needs_human", issue.currentRound);
|
|
595
|
+
for (const type of capEscalationEvents()) {
|
|
596
|
+
const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
|
|
597
|
+
ev.emit(type, {
|
|
598
|
+
actorType: role,
|
|
599
|
+
payload: {
|
|
600
|
+
...workerSessionPayload({
|
|
601
|
+
runtime: session?.runtime ?? null,
|
|
602
|
+
model: session?.model ?? null,
|
|
603
|
+
sessionId: item.workerSessionId ?? "",
|
|
604
|
+
worktreePath: session?.worktreePath,
|
|
605
|
+
}),
|
|
606
|
+
outcome: "checks_pending",
|
|
607
|
+
reason: pending.reason,
|
|
608
|
+
branch: pending.branch,
|
|
609
|
+
prNumber: pending.prNumber,
|
|
610
|
+
headSha: pending.headSha,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
applyProjectionTransition(issue, {
|
|
615
|
+
issueStatus: "needs_human",
|
|
616
|
+
currentOwner: "human",
|
|
617
|
+
currentIntent: reason,
|
|
618
|
+
events: capEscalationEvents(),
|
|
619
|
+
}, {});
|
|
620
|
+
const action = createHumanAction({
|
|
621
|
+
issueId: issue.id,
|
|
622
|
+
workflowInstanceId: instance.id,
|
|
623
|
+
actionType: "policy_escalation",
|
|
624
|
+
reason,
|
|
625
|
+
question: questionFor("policy_escalation", reason),
|
|
626
|
+
evidence: { checksPending: { ...pending, firstDeferredAt } },
|
|
627
|
+
responseOptions: responseOptionsFor("policy_escalation"),
|
|
628
|
+
});
|
|
629
|
+
ev.emit("human_action.requested", { payload: { actionType: "policy_escalation", actionId: action.id } });
|
|
630
|
+
return {
|
|
631
|
+
applied: true,
|
|
632
|
+
issueStatus: "needs_human",
|
|
633
|
+
nextWorkItemId: null,
|
|
634
|
+
humanActionId: action.id,
|
|
635
|
+
instanceCompleted: false,
|
|
636
|
+
};
|
|
637
|
+
}
|
|
418
638
|
/**
|
|
419
639
|
* Deferral ceiling exceeded — escalate with cap evidence without spending infra attempts.
|
|
420
640
|
* Used when applyCompletion or the worker loop cannot defer any longer.
|
|
@@ -525,6 +745,8 @@ function applyDeveloper(issue, instance, item, outcome) {
|
|
|
525
745
|
maxReviewRounds: issue.maxReviewRounds,
|
|
526
746
|
infraAttempts: issue.infraAttempts,
|
|
527
747
|
maxInfraAttempts: issue.maxInfraAttempts,
|
|
748
|
+
ciAttempts: issue.ciAttempts,
|
|
749
|
+
maxCiAttempts: issue.maxCiAttempts,
|
|
528
750
|
});
|
|
529
751
|
const { projection, effect, advance } = projectDeveloperRoute(route, issue.status, issue.currentRound);
|
|
530
752
|
const ev = eventEmitter(issue, instance, item.workerSessionId, projection.issueStatus, issue.currentRound);
|
|
@@ -607,6 +829,9 @@ function applyDeveloper(issue, instance, item, outcome) {
|
|
|
607
829
|
incrementIssueRound(issue.id);
|
|
608
830
|
else if (advance === "infra")
|
|
609
831
|
incrementIssueInfraAttempts(issue.id);
|
|
832
|
+
// NOT-313: a CI-repair retry spends ci_attempts, never infra_attempts.
|
|
833
|
+
else if (advance === "ci")
|
|
834
|
+
incrementIssueCiAttempts(issue.id);
|
|
610
835
|
const issueNow = getIssue(issue.id);
|
|
611
836
|
return applyEffect(issue, instance, effect, route, issueNow, ev, item.id);
|
|
612
837
|
}
|
|
@@ -740,9 +965,10 @@ function findNonConvergence(issue, instance, result) {
|
|
|
740
965
|
});
|
|
741
966
|
return detectNonConvergence(history, issue.currentRound, floorRound);
|
|
742
967
|
}
|
|
743
|
-
/** Every route that spends an infra attempt (projection.ts's advance === "infra"
|
|
744
|
-
* the same round/head, so its enqueue needs a suffix the plain
|
|
745
|
-
* provide to avoid colliding with the previous (terminal)
|
|
968
|
+
/** Every route that spends an infra or CI attempt (projection.ts's advance === "infra"
|
|
969
|
+
* or "ci") repeats the same round/head, so its enqueue needs a suffix the plain
|
|
970
|
+
* round/head key doesn't provide to avoid colliding with the previous (terminal)
|
|
971
|
+
* attempt's key.
|
|
746
972
|
* retry_reviewer_at_new_head belongs here too — a head that cycles A→B→A would otherwise
|
|
747
973
|
* re-derive attempt A's ORIGINAL key (same round, same head) and collide with that
|
|
748
974
|
* now-terminal work item, leaving the issue "reviewing" with no pending item. */
|
|
@@ -773,6 +999,21 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
|
|
|
773
999
|
const attemptSuffix = isInfraRetry(route) ? `:retry-of:${causativeItemId}` : "";
|
|
774
1000
|
if (route.next === "retry_developer_with_findings")
|
|
775
1001
|
ev.emit("repair.started");
|
|
1002
|
+
let idempotencyKey = `${instance.id}:${kind}:${issueNow.currentRound}${headSuffix}${attemptSuffix}`;
|
|
1003
|
+
// NOT-333: a developer round that pushes nothing (e.g. a resume round that only
|
|
1004
|
+
// updates the PR description) re-derives the exact key of the already-terminal
|
|
1005
|
+
// reviewer item it follows — same instance, same round, same head — so the enqueue
|
|
1006
|
+
// dedupes against that done row and strands the issue in `reviewing` with no pending
|
|
1007
|
+
// work. A terminal duplicate gets the same `:retry-of:<causative work item id>`
|
|
1008
|
+
// shape the infra-retry path uses, so a new row is created; a still-active
|
|
1009
|
+
// (pending/leased) duplicate keeps deduping.
|
|
1010
|
+
if (kind === "reviewer" && attemptSuffix === "") {
|
|
1011
|
+
const existing = getWorkItemByIdempotencyKey(idempotencyKey);
|
|
1012
|
+
if (existing &&
|
|
1013
|
+
(existing.status === "done" || existing.status === "dead" || existing.status === "cancelled")) {
|
|
1014
|
+
idempotencyKey += `:retry-of:${causativeItemId}`;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
776
1017
|
const next = enqueueWorkItem({
|
|
777
1018
|
issueId: issue.id,
|
|
778
1019
|
workflowInstanceId: instance.id,
|
|
@@ -785,7 +1026,7 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
|
|
|
785
1026
|
...(effect.branch ? { branch: effect.branch } : {}),
|
|
786
1027
|
profileSnapshot: queuedProfileSnapshot(issue, kind),
|
|
787
1028
|
},
|
|
788
|
-
idempotencyKey
|
|
1029
|
+
idempotencyKey,
|
|
789
1030
|
});
|
|
790
1031
|
return { ...base, nextWorkItemId: next.id };
|
|
791
1032
|
}
|
|
@@ -996,6 +1237,12 @@ function questionFor(actionType, reason, resumeAsReviewer = false, mergeFailure
|
|
|
996
1237
|
// directly with its own question text (NOT-308). Case exists only so this
|
|
997
1238
|
// function stays total over HumanActionType.
|
|
998
1239
|
throw new Error("muse_capability is not raised through applyEffect");
|
|
1240
|
+
case "operator_verification":
|
|
1241
|
+
// Never raised through applyEffect — auto-merge.ts's operator gate creates
|
|
1242
|
+
// this action directly with a question listing each criterion and its
|
|
1243
|
+
// command (NOT-314). Case exists only so this function stays total over
|
|
1244
|
+
// HumanActionType.
|
|
1245
|
+
throw new Error("operator_verification is not raised through applyEffect");
|
|
999
1246
|
}
|
|
1000
1247
|
}
|
|
1001
1248
|
/** Exported for db/migrate-to-issues.ts, which must populate the same response options on
|
|
@@ -1042,6 +1289,11 @@ export function responseOptionsFor(actionType, resumeAsReviewer = false, opts =
|
|
|
1042
1289
|
// admits on that Muse version (missing) or dismisses (unverified). Never raised
|
|
1043
1290
|
// through applyEffect; case exists only so this function stays total.
|
|
1044
1291
|
return [{ choice: "acknowledge", label: "Acknowledge" }];
|
|
1292
|
+
case "operator_verification":
|
|
1293
|
+
// Raised by auto-merge.ts's operator gate with these exact options (the
|
|
1294
|
+
// shared constant keeps the two from drifting); listed here so the table
|
|
1295
|
+
// stays total over HumanActionType.
|
|
1296
|
+
return [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS];
|
|
1045
1297
|
}
|
|
1046
1298
|
}
|
|
1047
1299
|
function parseContinuationPreview(json) {
|
|
@@ -1080,6 +1332,17 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1080
1332
|
const normalizedNote = normalizeResolutionNote(opts?.note);
|
|
1081
1333
|
if (!normalizedNote.ok)
|
|
1082
1334
|
return { ok: false, code: 400, error: normalizedNote.error };
|
|
1335
|
+
// NOT-314: every operator_verification choice requires a non-empty note — the
|
|
1336
|
+
// recorded result (verified), the waiver reason (waive), or what to fix
|
|
1337
|
+
// (repair). parseHumanResolution below also rejects a missing note; this
|
|
1338
|
+
// names the requirement instead of misreporting a valid choice as invalid.
|
|
1339
|
+
if (action.actionType === "operator_verification" && !normalizedNote.note) {
|
|
1340
|
+
return {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
code: 400,
|
|
1343
|
+
error: 'A non-empty note is required to resolve operator_verification (verified: the result, e.g. pasted output; waive: why it is waived; repair: what the next round must fix)',
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1083
1346
|
// NOT-308: a Muse capability escalation resolves without touching the workflow — it
|
|
1084
1347
|
// has no round to spend and no merge to park, and parseHumanResolution (below)
|
|
1085
1348
|
// deliberately rejects it since there is no workflow outcome to map. Acknowledging a
|
|
@@ -1140,13 +1403,17 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1140
1403
|
return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
|
|
1141
1404
|
}
|
|
1142
1405
|
// NOT-272: the typed resolution carries the note only for product_scope_decision
|
|
1143
|
-
// (parseHumanResolution drops it for every other action type
|
|
1406
|
+
// (parseHumanResolution drops it for every other action type — except
|
|
1407
|
+
// operator_verification, whose note is required and always carried).
|
|
1144
1408
|
const scopeDecisionNote = resolution.actionType === "product_scope_decision" && "note" in resolution
|
|
1145
1409
|
? resolution.note
|
|
1146
1410
|
: undefined;
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1411
|
+
// NOT-314: the repair instruction rides the next developer round's payload;
|
|
1412
|
+
// the verified/waived note is stored on the artifact / waiver event below.
|
|
1413
|
+
const operatorNote = resolution.actionType === "operator_verification" && "note" in resolution
|
|
1414
|
+
? resolution.note
|
|
1415
|
+
: undefined;
|
|
1416
|
+
const storedResolution = scopeDecisionNote ?? operatorNote ? { choice, note: scopeDecisionNote ?? operatorNote } : { choice };
|
|
1150
1417
|
// NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
|
|
1151
1418
|
// retry_merge/repair/close only (resume would re-run development on approved work);
|
|
1152
1419
|
// every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
|
|
@@ -1191,12 +1458,56 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1191
1458
|
// Park like auto-merge, then the async wrapper runs finalizeAutoMerge outside this txn.
|
|
1192
1459
|
// NOT-194: a merge-failure retry_merge parks the same way — the old action is resolved
|
|
1193
1460
|
// first, so a second failure escalates exactly one fresh action with the new reason.
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1461
|
+
// NOT-314: an operator_verification verified/waive parks the same way — the recorded
|
|
1462
|
+
// result satisfies the gate, so finalizeAutoMerge merges on the very next pass.
|
|
1463
|
+
// (repair takes the generic repair-round path below, never this park.)
|
|
1464
|
+
const parksForMerge = (resolution.actionType === "final_review" &&
|
|
1465
|
+
(resolution.choice === "merge" || resolution.choice === "complete")) ||
|
|
1466
|
+
(resolution.actionType === "policy_escalation" && resolution.choice === "retry_merge") ||
|
|
1467
|
+
(resolution.actionType === "operator_verification" &&
|
|
1468
|
+
(resolution.choice === "verified" || resolution.choice === "waive"));
|
|
1469
|
+
if (instance && parksForMerge) {
|
|
1198
1470
|
return getDb().transaction(() => {
|
|
1199
|
-
resolveHumanAction(actionId, resolvedBy,
|
|
1471
|
+
resolveHumanAction(actionId, resolvedBy, storedResolution);
|
|
1472
|
+
// NOT-314: durably record what the merge is authorized by, in the same txn as
|
|
1473
|
+
// the park — `verified` stores the result as an issue artifact (kind
|
|
1474
|
+
// operator_verification, author human) pinned to this head; `waive`
|
|
1475
|
+
// appends an operator_verification.waived workflow event. A later finalize
|
|
1476
|
+
// re-checks the record against the then-current head, so new commits
|
|
1477
|
+
// re-block the gate.
|
|
1478
|
+
if (resolution.actionType === "operator_verification" && operatorNote) {
|
|
1479
|
+
if (resolution.choice === "verified") {
|
|
1480
|
+
createIssueArtifact({
|
|
1481
|
+
issueId: issue.id,
|
|
1482
|
+
kind: OPERATOR_VERIFICATION_ARTIFACT_KIND,
|
|
1483
|
+
author: "human",
|
|
1484
|
+
content: {
|
|
1485
|
+
headSha: issue.headSha,
|
|
1486
|
+
note: operatorNote,
|
|
1487
|
+
verifiedBy: resolvedBy,
|
|
1488
|
+
verifiedAt: new Date().toISOString(),
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
else {
|
|
1493
|
+
appendWorkflowEvent({
|
|
1494
|
+
issueId: issue.id,
|
|
1495
|
+
workflowInstanceId: instance.id,
|
|
1496
|
+
type: "operator_verification.waived",
|
|
1497
|
+
actorType: "human",
|
|
1498
|
+
actorRef: resolvedBy,
|
|
1499
|
+
stage: issue.status,
|
|
1500
|
+
round: issue.currentRound,
|
|
1501
|
+
payload: {
|
|
1502
|
+
actionType: "operator_verification",
|
|
1503
|
+
choice: "waive",
|
|
1504
|
+
headSha: issue.headSha,
|
|
1505
|
+
waivedBy: resolvedBy,
|
|
1506
|
+
note: operatorNote,
|
|
1507
|
+
},
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1200
1511
|
appendWorkflowEvent({
|
|
1201
1512
|
issueId: issue.id,
|
|
1202
1513
|
workflowInstanceId: instance.id,
|
|
@@ -1205,7 +1516,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1205
1516
|
actorRef: resolvedBy,
|
|
1206
1517
|
stage: resolution.actionType === "final_review" ? "final_review" : issue.status,
|
|
1207
1518
|
round: issue.currentRound,
|
|
1208
|
-
payload: {
|
|
1519
|
+
payload: {
|
|
1520
|
+
actionType: action.actionType,
|
|
1521
|
+
choice: resolution.choice,
|
|
1522
|
+
pendingMerge: true,
|
|
1523
|
+
...(operatorNote ? { note: operatorNote } : {}),
|
|
1524
|
+
},
|
|
1209
1525
|
});
|
|
1210
1526
|
transitionIssue(issue.id, "final_review", {
|
|
1211
1527
|
currentOwner: "system",
|
|
@@ -1411,7 +1727,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1411
1727
|
incrementIssueRound(issue.id);
|
|
1412
1728
|
break;
|
|
1413
1729
|
case "infra":
|
|
1730
|
+
// NOT-313: a resume past an infra OR CI-checks escalation grants a fresh
|
|
1731
|
+
// budget of both kinds — the CI budget is not a review-round spend either.
|
|
1414
1732
|
resetIssueInfraAttempts(issue.id);
|
|
1733
|
+
resetIssueCiAttempts(issue.id);
|
|
1415
1734
|
if (advancesRound)
|
|
1416
1735
|
incrementIssueRound(issue.id);
|
|
1417
1736
|
break;
|
|
@@ -1455,6 +1774,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
|
|
|
1455
1774
|
// NOT-272: a product_scope_decision note rides only this next round's
|
|
1456
1775
|
// payload — later rounds never see it.
|
|
1457
1776
|
...(scopeDecisionNote ? { scopeDecisionNote } : {}),
|
|
1777
|
+
// NOT-314: same one-shot ride for an operator_verification repair
|
|
1778
|
+
// note — only the repair choice reaches this path (verified/waive
|
|
1779
|
+
// park for merge above), and only this round reads it.
|
|
1780
|
+
...(operatorNote ? { operatorRepairNote: operatorNote } : {}),
|
|
1458
1781
|
},
|
|
1459
1782
|
idempotencyKey: resumeKey,
|
|
1460
1783
|
});
|
|
@@ -635,6 +635,53 @@ test("fail → retry → exhaust → resume → fail again does not collide with
|
|
|
635
635
|
assert.equal(pending.length, 1, "a fresh item must be enqueued — the old (round, infraAttempts)-keyed row must not be silently reused");
|
|
636
636
|
assert.notEqual(pending[0].id, firstRetryItem.id, "must be a NEW work item, not the pre-escalation retry's now-terminal row");
|
|
637
637
|
});
|
|
638
|
+
test("NOT-333: a resume round that pushes nothing still enqueues a fresh reviewer instead of deduping against the terminal one", async () => {
|
|
639
|
+
// The NOT-200 shape: reviewer round 1 at head H escalates with a product scope
|
|
640
|
+
// question, the human resumes, and the developer round changes nothing — so its
|
|
641
|
+
// handoff re-derives the exact idempotency key of the already-done reviewer item.
|
|
642
|
+
const issueId = newIssue();
|
|
643
|
+
startWorkflow(issueId);
|
|
644
|
+
await complete(issueId, cleanHandoff);
|
|
645
|
+
const firstReviewer = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer");
|
|
646
|
+
await complete(issueId, {
|
|
647
|
+
kind: "verdict",
|
|
648
|
+
result: { ...okReview("escalated"), productScopeQuestion: "Should deleted users retain sessions?" },
|
|
649
|
+
});
|
|
650
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
|
|
651
|
+
const resolved = resolveHumanActionAndAdvance(action.id, "yusuke", "resume");
|
|
652
|
+
assert.equal(resolved.ok, true);
|
|
653
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
654
|
+
// The resume round pushes nothing: the handoff carries the very same head SHA.
|
|
655
|
+
const { result } = await complete(issueId, cleanHandoff);
|
|
656
|
+
assert.equal(result.applied, true);
|
|
657
|
+
assert.equal(getIssue(issueId).status, "reviewing");
|
|
658
|
+
assert.equal(getWorkItem(firstReviewer.id).status, "done");
|
|
659
|
+
const reviewers = listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer");
|
|
660
|
+
const pending = reviewers.filter((i) => i.status === "pending");
|
|
661
|
+
assert.equal(pending.length, 1, "a fresh reviewer round must be queued, not dropped via a key collision");
|
|
662
|
+
assert.notEqual(pending[0].id, firstReviewer.id, "must be a NEW work item, not the escalated round's now-terminal row");
|
|
663
|
+
assert.match(pending[0].idempotencyKey, /:retry-of:/, "the terminal duplicate is disambiguated like an infra retry");
|
|
664
|
+
assert.equal(JSON.parse(pending[0].payloadJson).inputSha, cleanHandoff.headSha);
|
|
665
|
+
});
|
|
666
|
+
test("NOT-333: a duplicate developer completion while the reviewer is still pending or leased enqueues no second reviewer", async () => {
|
|
667
|
+
const issueId = newIssue();
|
|
668
|
+
startWorkflow(issueId);
|
|
669
|
+
const item = claim(issueId);
|
|
670
|
+
const first = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
|
|
671
|
+
assert.equal(first.applied, true);
|
|
672
|
+
// A retried callback redelivers the same completion while the reviewer is pending.
|
|
673
|
+
const second = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
|
|
674
|
+
assert.equal(second.applied, false);
|
|
675
|
+
// Same while the reviewer is leased — still exactly one active reviewer row.
|
|
676
|
+
const reviewer = claim(issueId);
|
|
677
|
+
assert.equal(reviewer.kind, "reviewer");
|
|
678
|
+
const third = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
|
|
679
|
+
assert.equal(third.applied, false);
|
|
680
|
+
const active = listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && (i.status === "pending" || i.status === "leased"));
|
|
681
|
+
assert.equal(active.length, 1);
|
|
682
|
+
assert.equal(active[0].id, reviewer.id);
|
|
683
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 1);
|
|
684
|
+
});
|
|
638
685
|
test("resolving product_scope_decision:resume after a reviewer's escalated+question resumes as the developer", async () => {
|
|
639
686
|
const issueId = newIssue();
|
|
640
687
|
startWorkflow(issueId);
|