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
|
@@ -151,12 +151,25 @@ function fakeGithub(opts = {}) {
|
|
|
151
151
|
if (number == null && !branch) {
|
|
152
152
|
throw new Error("fakeGithub.checksSnapshot requires an explicit number or branch — bare checks lookup is the NOT-82 bug");
|
|
153
153
|
}
|
|
154
|
-
|
|
154
|
+
// NOT-311: a function lets one test flip CI mid-wait (pending → success) so the
|
|
155
|
+
// deferred re-poll can prove the wait closes without a fresh agent session.
|
|
156
|
+
return typeof opts.checks === "function" ? opts.checks() : (opts.checks ?? "success");
|
|
157
|
+
},
|
|
158
|
+
async hasPullRequestWorkflow() {
|
|
159
|
+
return opts.pullRequestWorkflow ?? null;
|
|
155
160
|
},
|
|
156
161
|
async publishReview() {
|
|
157
162
|
throw new Error("publishReview is unused by the developer effect");
|
|
158
163
|
}
|
|
159
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
|
+
}
|
|
160
173
|
return adapter;
|
|
161
174
|
}
|
|
162
175
|
test("clean handoff: real worktree, real push, fake GitHub — issue moves to reviewing with a reviewer work item queued", async () => {
|
|
@@ -530,6 +543,8 @@ test("checks_failed: CI failure after a clean push/PR retries without a human ac
|
|
|
530
543
|
await pump(1);
|
|
531
544
|
const issue = getIssue(issueId);
|
|
532
545
|
assert.equal(issue.status, "developing");
|
|
546
|
+
assert.equal(issue.ciAttempts, 1, "a checks_failed retry spends the CI budget");
|
|
547
|
+
assert.equal(issue.infraAttempts, 0, "a checks_failed retry never touches the infra budget");
|
|
533
548
|
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0);
|
|
534
549
|
const kinds = listArtifactsForIssue(issueId).map((a) => a.kind);
|
|
535
550
|
const evidence = listArtifactsForIssue(issueId).find((a) => a.kind === "checks_evidence");
|
|
@@ -540,12 +555,323 @@ test("checks_failed: CI failure after a clean push/PR retries without a human ac
|
|
|
540
555
|
assert.ok(kinds.includes("implementation_conclusion"), "conclusion must survive a post-session verification failure");
|
|
541
556
|
assert.ok(kinds.includes("developer_transcript"), "raw trace must survive a post-session verification failure");
|
|
542
557
|
});
|
|
543
|
-
test("
|
|
558
|
+
test("NOT-311: checks_pending — checks still pending at the poll deadline defers, spending no attempt", async () => {
|
|
559
|
+
// Previously this returned `timed_out`, spending an infra attempt on a session that
|
|
560
|
+
// had already ended cleanly. Now the item waits on backoff and re-polls.
|
|
544
561
|
const issueId = await makeIssue();
|
|
545
562
|
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "pending" }) }));
|
|
546
563
|
startWorkflow(issueId);
|
|
547
564
|
await pump(1);
|
|
565
|
+
const issue = getIssue(issueId);
|
|
566
|
+
assert.equal(issue.status, "developing");
|
|
567
|
+
assert.equal(issue.infraAttempts, 0, "a CI wait spends no infra attempt");
|
|
568
|
+
assert.equal(issue.currentRound, 1, "a CI wait must not spend a review round");
|
|
569
|
+
assert.match(issue.currentIntent ?? "", /Waiting for CI \(running\) - no attempt spent/);
|
|
570
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0, "no review handoff on unverified CI");
|
|
571
|
+
const items = listWorkItemsForIssue(issueId);
|
|
572
|
+
assert.equal(items.length, 1);
|
|
573
|
+
assert.equal(items[0].status, "pending", "the item waits, it is not finished or dead-lettered");
|
|
574
|
+
assert.equal(items[0].attemptCount, 0, "the claim-time attempt bump is reverted");
|
|
575
|
+
assert.ok(Date.parse(items[0].availableAt) > Date.parse(items[0].updatedAt), "the re-poll is gated behind a backoff");
|
|
576
|
+
const deferredPayload = JSON.parse(items[0].payloadJson ?? "{}");
|
|
577
|
+
assert.equal(deferredPayload.publishOnly, true, "the re-poll takes the no-agent publish path, not a fresh spawn");
|
|
578
|
+
assert.equal(deferredPayload.branch, issueBranchName(issueId));
|
|
579
|
+
assert.ok(deferredPayload.checksWaitStartedAt, "the wait anchors its ceiling at the first poll");
|
|
580
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
|
|
581
|
+
const events = listWorkflowEventsForIssue(issueId);
|
|
582
|
+
const deferrals = events.filter((e) => e.type === "worker.deferred");
|
|
583
|
+
assert.ok(deferrals.length > 0);
|
|
584
|
+
assert.equal(JSON.parse(deferrals[0].payloadJson ?? "{}").outcome, "checks_pending");
|
|
585
|
+
assert.equal(events.some((e) => e.type === "worker.failed"), false, "a CI wait is not a failure");
|
|
586
|
+
const completed = events.find((e) => e.type === "checks.completed");
|
|
587
|
+
assert.ok(completed, "the wait still records a checks.completed milestone");
|
|
588
|
+
assert.equal(JSON.parse(completed.payloadJson ?? "{}").snapshot, "timeout");
|
|
589
|
+
});
|
|
590
|
+
test("NOT-311: the deferred CI wait re-polls through publish-only and hands off once CI passes", async () => {
|
|
591
|
+
// First run waits (pending), the deferred re-poll sees success and reaches review —
|
|
592
|
+
// all without spawning a second agent session and without spending infra attempts.
|
|
593
|
+
let ci = "pending";
|
|
594
|
+
let spawns = 0;
|
|
595
|
+
const countingSpawn = async (input) => {
|
|
596
|
+
spawns++;
|
|
597
|
+
return commitingSpawn(input);
|
|
598
|
+
};
|
|
599
|
+
// A short backoff (set before the first deferral computes its `until`) lets the
|
|
600
|
+
// second pump actually pick the waiting item back up.
|
|
601
|
+
const prevBase = process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
|
|
602
|
+
const prevMax = process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
|
|
603
|
+
process.env.DECK_OUTAGE_BACKOFF_BASE_MS = "10";
|
|
604
|
+
process.env.DECK_OUTAGE_BACKOFF_MAX_MS = "10";
|
|
605
|
+
try {
|
|
606
|
+
const issueId = await makeIssue();
|
|
607
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: countingSpawn, github: fakeGithub({ checks: () => ci }) }));
|
|
608
|
+
startWorkflow(issueId);
|
|
609
|
+
await pump(1);
|
|
610
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
611
|
+
assert.equal(getIssue(issueId).infraAttempts, 0);
|
|
612
|
+
const deferred = listWorkItemsForIssue(issueId)[0];
|
|
613
|
+
assert.equal(deferred.status, "pending");
|
|
614
|
+
// Fast-forward past the deferral backoff, then let CI pass.
|
|
615
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
616
|
+
ci = "success";
|
|
617
|
+
await pump(1);
|
|
618
|
+
const issue = getIssue(issueId);
|
|
619
|
+
assert.equal(issue.status, "reviewing", "the re-poll hands off once CI passes");
|
|
620
|
+
assert.equal(issue.infraAttempts, 0, "waiting plus a passing re-poll still spends nothing");
|
|
621
|
+
assert.equal(spawns, 1, "the re-poll must not spawn a second agent session");
|
|
622
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
if (prevBase === undefined)
|
|
626
|
+
delete process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
|
|
627
|
+
else
|
|
628
|
+
process.env.DECK_OUTAGE_BACKOFF_BASE_MS = prevBase;
|
|
629
|
+
if (prevMax === undefined)
|
|
630
|
+
delete process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
|
|
631
|
+
else
|
|
632
|
+
process.env.DECK_OUTAGE_BACKOFF_MAX_MS = prevMax;
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
test("NOT-311: none with a pull_request workflow on the base waits (no review handoff)", async () => {
|
|
636
|
+
const issueId = await makeIssue();
|
|
637
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "none", pullRequestWorkflow: true }) }));
|
|
638
|
+
startWorkflow(issueId);
|
|
639
|
+
await pump(1);
|
|
640
|
+
const issue = getIssue(issueId);
|
|
641
|
+
assert.equal(issue.status, "developing");
|
|
642
|
+
assert.equal(issue.infraAttempts, 0, "an unregistered-CI wait spends no infra attempt");
|
|
643
|
+
assert.match(issue.currentIntent ?? "", /Waiting for CI \(queued\) - no attempt spent/);
|
|
644
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0, "no review handoff on 'no checks yet'");
|
|
645
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
646
|
+
assert.ok(completed);
|
|
647
|
+
const payload = JSON.parse(completed.payloadJson ?? "{}");
|
|
648
|
+
assert.equal(payload.snapshot, "none");
|
|
649
|
+
assert.equal(payload.reason, "none_with_workflow");
|
|
650
|
+
});
|
|
651
|
+
test("NOT-311: none with no pull_request workflow on the base proceeds as today", async () => {
|
|
652
|
+
const issueId = await makeIssue();
|
|
653
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "none", pullRequestWorkflow: false }) }));
|
|
654
|
+
startWorkflow(issueId);
|
|
655
|
+
await pump(1);
|
|
656
|
+
const issue = getIssue(issueId);
|
|
657
|
+
assert.equal(issue.status, "reviewing", "no configured CI still hands off");
|
|
658
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
|
|
659
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
660
|
+
assert.ok(completed);
|
|
661
|
+
const payload = JSON.parse(completed.payloadJson ?? "{}");
|
|
662
|
+
assert.equal(payload.snapshot, "none");
|
|
663
|
+
assert.equal(payload.reason, "none_no_workflow");
|
|
664
|
+
});
|
|
665
|
+
test("NOT-311: none with an unknown workflow answer waits fail-closed", async () => {
|
|
666
|
+
// The adapter cannot read the base (or has no such method): 'no checks yet' must
|
|
667
|
+
// never read as a pass.
|
|
668
|
+
const issueId = await makeIssue();
|
|
669
|
+
const github = fakeGithub({ checks: "none", pullRequestWorkflow: null });
|
|
670
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github }));
|
|
671
|
+
startWorkflow(issueId);
|
|
672
|
+
await pump(1);
|
|
548
673
|
assert.equal(getIssue(issueId).status, "developing");
|
|
674
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0);
|
|
675
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
676
|
+
assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "none_with_workflow");
|
|
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
|
+
});
|
|
842
|
+
test("NOT-311: past CHECKS_WAIT_CEILING_MS the wait escalates naming the PR and head SHA", async () => {
|
|
843
|
+
// A zero ceiling makes the very first wait already past it — no clock to inject,
|
|
844
|
+
// no sleeping: the deferral reports escalated and a policy_escalation lands.
|
|
845
|
+
const prev = process.env.CHECKS_WAIT_CEILING_MS;
|
|
846
|
+
process.env.CHECKS_WAIT_CEILING_MS = "0";
|
|
847
|
+
try {
|
|
848
|
+
const issueId = await makeIssue();
|
|
849
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "pending" }) }));
|
|
850
|
+
startWorkflow(issueId);
|
|
851
|
+
await pump(1);
|
|
852
|
+
const issue = getIssue(issueId);
|
|
853
|
+
assert.equal(issue.status, "needs_human");
|
|
854
|
+
assert.equal(issue.infraAttempts, 0, "a ceiling escalation spends no infra attempt");
|
|
855
|
+
const actions = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
856
|
+
assert.equal(actions.length, 1);
|
|
857
|
+
assert.equal(actions[0].actionType, "policy_escalation");
|
|
858
|
+
// The wait never hands off, so the issue row carries no PR/SHA — the milestone
|
|
859
|
+
// and the human action do.
|
|
860
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
861
|
+
assert.ok(completed);
|
|
862
|
+
const msPayload = JSON.parse(completed.payloadJson ?? "{}");
|
|
863
|
+
assert.equal(msPayload.reason, "pending_ceiling");
|
|
864
|
+
assert.ok(msPayload.prNumber, "the milestone names the PR");
|
|
865
|
+
assert.ok(msPayload.headSha, "the milestone names the head SHA");
|
|
866
|
+
assert.match(actions[0].reason, new RegExp(`CI did not complete on ${msPayload.headSha}`));
|
|
867
|
+
assert.match(actions[0].reason, new RegExp(`PR #${msPayload.prNumber}`));
|
|
868
|
+
}
|
|
869
|
+
finally {
|
|
870
|
+
if (prev === undefined)
|
|
871
|
+
delete process.env.CHECKS_WAIT_CEILING_MS;
|
|
872
|
+
else
|
|
873
|
+
process.env.CHECKS_WAIT_CEILING_MS = prev;
|
|
874
|
+
}
|
|
549
875
|
});
|
|
550
876
|
test("NOT-255: a non-draft PR with otherwise-verified identity hands off cleanly (just-failed retry_merge)", async () => {
|
|
551
877
|
// A merge-failure escalation is reachable from `retry_merge`, which undrafts the PR
|
|
@@ -2079,7 +2405,9 @@ test("NOT-252: checks_failed enriches checks_evidence and the retry prompt with
|
|
|
2079
2405
|
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: evolvingSpawn, github }));
|
|
2080
2406
|
startWorkflow(issueId);
|
|
2081
2407
|
await pump(1);
|
|
2082
|
-
assert.equal(getIssue(issueId).status, "developing", "enriched checks_failed still retries on the
|
|
2408
|
+
assert.equal(getIssue(issueId).status, "developing", "enriched checks_failed still retries on the CI budget");
|
|
2409
|
+
assert.equal(getIssue(issueId).ciAttempts, 1, "the enriched retry spends ci_attempts");
|
|
2410
|
+
assert.equal(getIssue(issueId).infraAttempts, 0, "the enriched retry leaves infra_attempts alone");
|
|
2083
2411
|
const evidence = listArtifactsForIssue(issueId).find((a) => a.kind === "checks_evidence");
|
|
2084
2412
|
assert.ok(evidence, "enriched checks_failed persists checks_evidence");
|
|
2085
2413
|
const content = JSON.parse(evidence.contentJson);
|
|
@@ -2138,3 +2466,132 @@ test("NOT-252: publish-only retry on checks_failed enriches evidence and the nex
|
|
|
2138
2466
|
assert.match(prompts[1], /publish-checks/);
|
|
2139
2467
|
assert.match(prompts[1], /is not in this registry/);
|
|
2140
2468
|
});
|
|
2469
|
+
test("NOT-313: three CI repair attempts then a CI-specific escalation — infra_attempts stays 0", async () => {
|
|
2470
|
+
// Exit-predicate loop: the CI budget (default 3) buys three repair attempts;
|
|
2471
|
+
// the fourth consecutive checks_failed escalates with a CI-specific reason.
|
|
2472
|
+
const issueId = await makeIssue();
|
|
2473
|
+
const prompts = [];
|
|
2474
|
+
let call = 0;
|
|
2475
|
+
const evolvingSpawn = async (input) => {
|
|
2476
|
+
call++;
|
|
2477
|
+
prompts.push(input.prompt);
|
|
2478
|
+
fs.writeFileSync(path.join(input.cwd, "feature.txt"), `implemented attempt ${call}\n`);
|
|
2479
|
+
git(input.cwd, "add", ".");
|
|
2480
|
+
git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", `implement ${call}`);
|
|
2481
|
+
return { exitCode: 0, transcript: "Implementation conclusion: added the widget.", logPath: "/dev/null", timedOut: false };
|
|
2482
|
+
};
|
|
2483
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: evolvingSpawn, github: fakeGithub({ checks: "failure" }) }));
|
|
2484
|
+
startWorkflow(issueId);
|
|
2485
|
+
await pump(1); // failure #1 → CI repair attempt 1
|
|
2486
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
2487
|
+
assert.equal(getIssue(issueId).ciAttempts, 1);
|
|
2488
|
+
assert.equal(getIssue(issueId).infraAttempts, 0);
|
|
2489
|
+
await pump(1); // failure #2 → CI repair attempt 2
|
|
2490
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
2491
|
+
assert.equal(getIssue(issueId).ciAttempts, 2);
|
|
2492
|
+
assert.equal(getIssue(issueId).infraAttempts, 0);
|
|
2493
|
+
assert.match(prompts[1], /CI repair attempt 1 of 3/);
|
|
2494
|
+
await pump(1); // failure #3 → CI repair attempt 3
|
|
2495
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
2496
|
+
assert.equal(getIssue(issueId).ciAttempts, 3);
|
|
2497
|
+
assert.equal(getIssue(issueId).infraAttempts, 0);
|
|
2498
|
+
assert.match(prompts[2], /CI repair attempt 2 of 3/);
|
|
2499
|
+
await pump(1); // failure #4 → budget spent → escalate
|
|
2500
|
+
const issue = getIssue(issueId);
|
|
2501
|
+
assert.equal(issue.status, "needs_human");
|
|
2502
|
+
assert.equal(issue.ciAttempts, 3, "the escalation itself spends no further CI attempt");
|
|
2503
|
+
assert.equal(issue.infraAttempts, 0, "four CI failures never touched the infra budget");
|
|
2504
|
+
assert.match(prompts[3], /CI repair attempt 3 of 3/);
|
|
2505
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
|
|
2506
|
+
assert.ok(action, "the exhausted CI budget raises a human action");
|
|
2507
|
+
assert.equal(action.actionType, "policy_escalation");
|
|
2508
|
+
assert.match(action.reason, /CI checks still failing after 3 repair attempts \(limit 3\)/);
|
|
2509
|
+
assert.doesNotMatch(action.reason, /infra-attempt limit reached/);
|
|
2510
|
+
});
|
|
2511
|
+
test("NOT-315: a failing dependency install is a worktree-setup failure with the stderr tail — the session is not spawned", async () => {
|
|
2512
|
+
// Hermetic repo: the shared fixture carries no package.json (so the step would skip
|
|
2513
|
+
// before the runner is ever invoked), so this test cuts its own repo + remote whose
|
|
2514
|
+
// main tip declares npm dependencies — the fresh worktree then really triggers the install.
|
|
2515
|
+
const npmRepo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-npm-repo-"));
|
|
2516
|
+
const npmRemote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-npm-remote-"));
|
|
2517
|
+
try {
|
|
2518
|
+
git(npmRepo, "init", "-q", "-b", "main");
|
|
2519
|
+
git(npmRepo, "config", "user.email", "test@example.com");
|
|
2520
|
+
git(npmRepo, "config", "user.name", "Test");
|
|
2521
|
+
fs.writeFileSync(path.join(npmRepo, "README.md"), "hello\n");
|
|
2522
|
+
fs.writeFileSync(path.join(npmRepo, "package.json"), JSON.stringify({ name: "wt", version: "1.0.0" }));
|
|
2523
|
+
fs.writeFileSync(path.join(npmRepo, "package-lock.json"), JSON.stringify({ name: "wt", lockfileVersion: 3, packages: {} }));
|
|
2524
|
+
git(npmRepo, "add", ".");
|
|
2525
|
+
git(npmRepo, "commit", "-q", "-m", "init");
|
|
2526
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", npmRemote]);
|
|
2527
|
+
git(npmRepo, "remote", "add", "origin", npmRemote);
|
|
2528
|
+
git(npmRepo, "push", "-q", "origin", "main");
|
|
2529
|
+
const devAgent = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
2530
|
+
const revAgent = createAgent({ name: `rev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
2531
|
+
const issueId = createIssue({
|
|
2532
|
+
title: "Add widget",
|
|
2533
|
+
description: "Build the widget.",
|
|
2534
|
+
acceptanceCriteria: "Widget renders.",
|
|
2535
|
+
repo: npmRepo,
|
|
2536
|
+
baseBranch: "main",
|
|
2537
|
+
developerAgentId: devAgent.id,
|
|
2538
|
+
reviewerAgentId: revAgent.id,
|
|
2539
|
+
maxReviewRounds: 3,
|
|
2540
|
+
maxInfraAttempts: 3,
|
|
2541
|
+
source: "manual"
|
|
2542
|
+
}).id;
|
|
2543
|
+
let spawnCalls = 0;
|
|
2544
|
+
const neverSpawn = async () => {
|
|
2545
|
+
spawnCalls++;
|
|
2546
|
+
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
2547
|
+
};
|
|
2548
|
+
let depsCalls = 0;
|
|
2549
|
+
const failingDeps = async () => {
|
|
2550
|
+
depsCalls++;
|
|
2551
|
+
const err = new Error("npm ci exited");
|
|
2552
|
+
err.stderr = "npm error code EAI_AGAIN\nnpm error registry unreachable (sandbox has no network)\n";
|
|
2553
|
+
throw err;
|
|
2554
|
+
};
|
|
2555
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: neverSpawn, github: fakeGithub(), depsRunner: failingDeps }));
|
|
2556
|
+
startWorkflow(issueId);
|
|
2557
|
+
await pump(1);
|
|
2558
|
+
assert.equal(depsCalls, 1, "the worktree declares npm deps, so the install must run exactly once");
|
|
2559
|
+
assert.equal(spawnCalls, 0, "the session must not spawn when dependency provisioning fails");
|
|
2560
|
+
const issue = getIssue(issueId);
|
|
2561
|
+
assert.equal(issue.status, "developing", "adapter_failure retries on the infra budget");
|
|
2562
|
+
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
2563
|
+
// adapter_failure never spawned, so the session row goes terminal (done) with the
|
|
2564
|
+
// setup failure recorded as its reason — like every other adapter_failure.
|
|
2565
|
+
assert.ok(dev.status === "done" || dev.status === "failed", `terminal session, got ${dev.status}`);
|
|
2566
|
+
const reason = JSON.parse(dev.errorJson ?? "{}").reason ?? dev.errorJson ?? "";
|
|
2567
|
+
assert.match(reason, /worktree setup failed/);
|
|
2568
|
+
assert.match(reason, /dependency install failed: /);
|
|
2569
|
+
assert.match(reason, /registry unreachable/);
|
|
2570
|
+
}
|
|
2571
|
+
finally {
|
|
2572
|
+
fs.rmSync(npmRepo, { recursive: true, force: true });
|
|
2573
|
+
fs.rmSync(npmRemote, { recursive: true, force: true });
|
|
2574
|
+
}
|
|
2575
|
+
});
|
|
2576
|
+
test("NOT-315: a skipped dependency install still emits worktree.deps_ready with ran/skipped and durationMs", async () => {
|
|
2577
|
+
// The fixture repo carries no package.json, so the step skips (no package.json) and
|
|
2578
|
+
// the fake runner is never called — the milestone must still record the skip.
|
|
2579
|
+
const issueId = await makeIssue();
|
|
2580
|
+
let depsCalls = 0;
|
|
2581
|
+
const countingDeps = async () => {
|
|
2582
|
+
depsCalls++;
|
|
2583
|
+
return { stdout: "", stderr: "" };
|
|
2584
|
+
};
|
|
2585
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub(), depsRunner: countingDeps }));
|
|
2586
|
+
startWorkflow(issueId);
|
|
2587
|
+
await pump(1);
|
|
2588
|
+
assert.equal(getIssue(issueId).status, "reviewing", "a skipped install must not block handoff");
|
|
2589
|
+
assert.equal(depsCalls, 0, "no package.json means the runner is never invoked");
|
|
2590
|
+
const { listWorkflowEventsForIssue: listEvents } = await import("../repository/workflow-events.js");
|
|
2591
|
+
const depsEvent = listEvents(issueId).find((e) => e.type === "worktree.deps_ready");
|
|
2592
|
+
assert.ok(depsEvent, "expected a worktree.deps_ready milestone");
|
|
2593
|
+
const payload = JSON.parse(depsEvent.payloadJson);
|
|
2594
|
+
assert.equal(payload.ran, false);
|
|
2595
|
+
assert.match(String(payload.reason ?? ""), /no package\.json/);
|
|
2596
|
+
assert.ok(typeof payload.durationMs === "number" && payload.durationMs >= 0);
|
|
2597
|
+
});
|
|
@@ -82,6 +82,7 @@ const VALID_CHOICES = {
|
|
|
82
82
|
// below and the only legal resolver is commands.ts's dedicated muse_capability branch
|
|
83
83
|
// (acknowledge records a per-version override; there is no workflow outcome to map).
|
|
84
84
|
muse_capability: ["acknowledge"],
|
|
85
|
+
operator_verification: ["verified", "waive", "repair"],
|
|
85
86
|
};
|
|
86
87
|
/**
|
|
87
88
|
* Validates a raw (actionType, choice) pair against that action type's allowed response
|
|
@@ -114,6 +115,16 @@ export function parseHumanResolution(actionType, choice, note) {
|
|
|
114
115
|
if (actionType === "product_scope_decision" && note) {
|
|
115
116
|
return { actionType, choice, note };
|
|
116
117
|
}
|
|
118
|
+
// NOT-314: operator_verification always carries a required non-empty note (the
|
|
119
|
+
// recorded result, the waiver reason, or the repair instruction). A missing or
|
|
120
|
+
// blank note is a caller error — resolveHumanActionAndAdvance 400s, never stores.
|
|
121
|
+
if (actionType === "operator_verification") {
|
|
122
|
+
if (!VALID_CHOICES.operator_verification.includes(choice))
|
|
123
|
+
return null;
|
|
124
|
+
if (!note || !note.trim())
|
|
125
|
+
return null;
|
|
126
|
+
return { actionType, choice, note: note.trim() };
|
|
127
|
+
}
|
|
117
128
|
return { actionType, choice };
|
|
118
129
|
}
|
|
119
130
|
/**
|
|
@@ -164,6 +175,14 @@ export function resolveHumanActionOutcome(resolution) {
|
|
|
164
175
|
throw new Error(`Unrecognized policy_escalation choice: ${resolution.choice}`);
|
|
165
176
|
case "product_scope_decision":
|
|
166
177
|
return { issueStatus: "developing", startNewRound: true, roundKind: "none" };
|
|
178
|
+
case "operator_verification":
|
|
179
|
+
// NOT-314: `repair` is a genuine repair cycle (same shape as
|
|
180
|
+
// final_review:repair). `verified` / `waive` never reach here — the sync
|
|
181
|
+
// core parks them for undraft+merge before consulting this outcome map
|
|
182
|
+
// (same as policy_escalation:retry_merge).
|
|
183
|
+
if (resolution.choice === "repair")
|
|
184
|
+
return { issueStatus: "repairing", startNewRound: true, roundKind: "review" };
|
|
185
|
+
throw new Error(`Unrecognized operator_verification choice: ${resolution.choice}`);
|
|
167
186
|
case "deck_interaction_required":
|
|
168
187
|
// Same semantics as policy_escalation:resume — an authority/control-plane hiccup is
|
|
169
188
|
// not a review-round spend; a fresh attempt mints its own new authority when it runs.
|
|
@@ -82,3 +82,36 @@ test("deck_interaction_required close ends the issue", () => {
|
|
|
82
82
|
assert.equal(result.issueStatus, "closed");
|
|
83
83
|
assert.equal(result.workflowOutcome, "closed");
|
|
84
84
|
});
|
|
85
|
+
// NOT-314: operator_verification parses only with a non-empty note.
|
|
86
|
+
test("parseHumanResolution accepts operator_verification choices only with a note", () => {
|
|
87
|
+
assert.deepStrictEqual(parseHumanResolution("operator_verification", "verified", "SSO login OK"), {
|
|
88
|
+
actionType: "operator_verification",
|
|
89
|
+
choice: "verified",
|
|
90
|
+
note: "SSO login OK",
|
|
91
|
+
});
|
|
92
|
+
assert.deepStrictEqual(parseHumanResolution("operator_verification", "waive", " waiver reason "), {
|
|
93
|
+
actionType: "operator_verification",
|
|
94
|
+
choice: "waive",
|
|
95
|
+
note: "waiver reason",
|
|
96
|
+
});
|
|
97
|
+
assert.deepStrictEqual(parseHumanResolution("operator_verification", "repair", "fix the probe"), {
|
|
98
|
+
actionType: "operator_verification",
|
|
99
|
+
choice: "repair",
|
|
100
|
+
note: "fix the probe",
|
|
101
|
+
});
|
|
102
|
+
assert.equal(parseHumanResolution("operator_verification", "verified"), null);
|
|
103
|
+
assert.equal(parseHumanResolution("operator_verification", "verified", ""), null);
|
|
104
|
+
assert.equal(parseHumanResolution("operator_verification", "verified", " "), null);
|
|
105
|
+
assert.equal(parseHumanResolution("operator_verification", "merge", "a note"), null);
|
|
106
|
+
});
|
|
107
|
+
test("operator_verification repair queues another repair round; verified/waive never map here", () => {
|
|
108
|
+
const result = resolveHumanActionOutcome({
|
|
109
|
+
actionType: "operator_verification",
|
|
110
|
+
choice: "repair",
|
|
111
|
+
note: "fix the probe",
|
|
112
|
+
});
|
|
113
|
+
assert.equal(result.issueStatus, "repairing");
|
|
114
|
+
assert.equal(result.startNewRound, true);
|
|
115
|
+
assert.equal(result.roundKind, "review");
|
|
116
|
+
assert.throws(() => resolveHumanActionOutcome({ actionType: "operator_verification", choice: "verified", note: "ok" }), /Unrecognized operator_verification choice/);
|
|
117
|
+
});
|