agent-dealer 1.2.8 → 1.2.9
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 +80 -4
- package/bundle/server/dist/adapters/github.test.js +83 -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 +194 -18
- package/bundle/server/dist/coordinator/commands.test.js +47 -0
- package/bundle/server/dist/coordinator/developer-effect.js +145 -3
- package/bundle/server/dist/coordinator/developer-effect.test.js +288 -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/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 +45 -6
- package/bundle/server/dist/coordinator/routing.test.js +62 -15
- 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/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
|
@@ -18,15 +18,18 @@ import os from "node:os";
|
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { parsePhaseBudget, parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
|
|
20
20
|
import { getTaskSnapshot } from "./commands.js";
|
|
21
|
+
import { extractOperatorCriteria } from "./operator-criteria.js";
|
|
21
22
|
import { buildDeveloperPrompt } from "./prompts.js";
|
|
22
23
|
import { guidanceForNextSession } from "./guidance.js";
|
|
23
24
|
import { realDeveloperSpawn, developerSessionLogPath } from "./spawn.js";
|
|
25
|
+
import { ensureWorktreeDeps, worktreeDepsTimeoutMs, } from "../adapters/worktree-deps.js";
|
|
24
26
|
import { DEFAULT_BASE_FETCH_TIMEOUT_MS, fastForwardLocalBranchToSha, resolveDeveloperWorktree, safeRemoveWorktree, isWorktreeClean, commitsAhead, pushBranch, mergeBase, branchExists, pushBranchRef, revParseHead, revParseRef, fetchRef, salvageDirtyWorktree, dirtyWorktreeRecoveryCommands, withRepoLock, leftoverFingerprint, } from "../adapters/git-worktree.js";
|
|
25
27
|
import { recordIssueBaseSha } from "../repository/issues.js";
|
|
26
28
|
import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
|
|
27
29
|
import { baseRefCandidates, inspectBranchProgress } from "./branch-progress.js";
|
|
28
30
|
import { prepareWorkerDeckConnection, releaseWorkerDeckConnection, verifyWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
|
|
29
|
-
import { realGithubAdapter,
|
|
31
|
+
import { realGithubAdapter, pollPrChecksDetailed } from "../adapters/github.js";
|
|
32
|
+
import { checksWaitCeilingExceeded, checksWaitCeilingMs } from "./checks-wait-config.js";
|
|
30
33
|
import { museCapabilitySafetyNetAfterSession } from "../adapters/muse-capability.js";
|
|
31
34
|
import { getOrAssignSessionCorrelationId, getWorkerSession, mergeSessionMetadata, patchRunningSession, recordSessionProcess, setSessionInputSha } from "../repository/worker-sessions.js";
|
|
32
35
|
import { museIdleMinutes, museStallMetadata } from "./muse-spawn.js";
|
|
@@ -117,10 +120,18 @@ export const developerEffectConfig = {
|
|
|
117
120
|
get checksPollIntervalMs() {
|
|
118
121
|
return num("CHECKS_POLL_INTERVAL_MS", 15_000);
|
|
119
122
|
},
|
|
123
|
+
/** NOT-311: overall CI-wait ceiling measured from the first poll, across deferrals. */
|
|
124
|
+
get checksWaitCeilingMs() {
|
|
125
|
+
return checksWaitCeilingMs();
|
|
126
|
+
},
|
|
120
127
|
/** NOT-197: bound for the pre-branch `git fetch origin <base>` on a fresh issue branch. */
|
|
121
128
|
get baseFetchTimeoutMs() {
|
|
122
129
|
return num("BASE_FETCH_TIMEOUT_MS", DEFAULT_BASE_FETCH_TIMEOUT_MS);
|
|
123
130
|
},
|
|
131
|
+
/** NOT-315: bound for `npm ci` in the fresh worktree (coordinator side, with network). */
|
|
132
|
+
get worktreeDepsTimeoutMs() {
|
|
133
|
+
return worktreeDepsTimeoutMs();
|
|
134
|
+
},
|
|
124
135
|
/** NOT-110: bounded window to let a lagging `gh pr view` catch up to a just-pushed HEAD. */
|
|
125
136
|
get headReconcileTimeoutMs() {
|
|
126
137
|
return num("HEAD_RECONCILE_TIMEOUT_MS", 30_000);
|
|
@@ -290,6 +301,69 @@ async function checksFailedOutcome(opts) {
|
|
|
290
301
|
});
|
|
291
302
|
return details ? { kind: "checks_failed", details } : { kind: "checks_failed" };
|
|
292
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* NOT-311: the `checksWaitStartedAt` persisted by a prior `checks_pending` deferral,
|
|
306
|
+
* anchoring the overall wait ceiling across re-polls. Null on a first wait (or an
|
|
307
|
+
* unparseable payload) — the caller then anchors at its own poll start.
|
|
308
|
+
*/
|
|
309
|
+
function checksWaitStartedAtFromPayload(payloadJson) {
|
|
310
|
+
if (!payloadJson)
|
|
311
|
+
return null;
|
|
312
|
+
try {
|
|
313
|
+
const v = JSON.parse(payloadJson).checksWaitStartedAt;
|
|
314
|
+
return typeof v === "string" && v ? v : null;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* NOT-311: decide what a finished checks poll means. Returns a `checks_pending`
|
|
322
|
+
* outcome when CI is still queued/running at the timeout (or reports no checks while
|
|
323
|
+
* the base branch has a `pull_request` workflow — "not yet registered", never "no
|
|
324
|
+
* CI"), and null when the caller stays on today's path (success, failure, an aborted
|
|
325
|
+
* wait, or `none` with no workflow configured). Also yields the `checks.completed`
|
|
326
|
+
* milestone reason for the wait cases.
|
|
327
|
+
*
|
|
328
|
+
* A missing/unknown workflow answer (no adapter method, unreadable base ref) waits
|
|
329
|
+
* fail-closed: sending an unverified PR to review is the outcome this exists to
|
|
330
|
+
* prevent, and the wait ceiling still bounds it.
|
|
331
|
+
*/
|
|
332
|
+
async function resolveChecksWait(opts) {
|
|
333
|
+
const waitStartedAt = checksWaitStartedAtFromPayload(opts.payloadJson) ?? opts.pollStartedAt;
|
|
334
|
+
const base = {
|
|
335
|
+
kind: "checks_pending",
|
|
336
|
+
branch: opts.branch,
|
|
337
|
+
prNumber: opts.prNumber,
|
|
338
|
+
headSha: opts.headSha,
|
|
339
|
+
waitStartedAt,
|
|
340
|
+
};
|
|
341
|
+
const noChecks = opts.poll.result === "none" || (opts.poll.result === "timeout" && opts.poll.lastSnapshot === "none");
|
|
342
|
+
if (noChecks) {
|
|
343
|
+
let hasWorkflow = null;
|
|
344
|
+
try {
|
|
345
|
+
hasWorkflow = (await opts.github.hasPullRequestWorkflow?.({ cwd: opts.cwd, baseRef: opts.baseRef })) ?? null;
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
hasWorkflow = null;
|
|
349
|
+
}
|
|
350
|
+
if (hasWorkflow === false) {
|
|
351
|
+
return { pending: null, milestoneReason: "none_no_workflow" };
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
pending: { ...base, reason: "Waiting for CI (queued) - no attempt spent" },
|
|
355
|
+
milestoneReason: "none_with_workflow",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (opts.poll.result === "timeout" && opts.poll.lastSnapshot === "pending" && !opts.poll.aborted && !opts.signal?.aborted) {
|
|
359
|
+
const milestoneReason = checksWaitCeilingExceeded(waitStartedAt) ? "pending_ceiling" : undefined;
|
|
360
|
+
return {
|
|
361
|
+
pending: { ...base, reason: "Waiting for CI (running) - no attempt spent" },
|
|
362
|
+
...(milestoneReason ? { milestoneReason } : {}),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return { pending: null };
|
|
366
|
+
}
|
|
293
367
|
/**
|
|
294
368
|
* Infra retry that skips the agent: publish whatever the branch already carries. Uses the
|
|
295
369
|
* issue repo as `gh` cwd and `origin/<branch>` as the verified head (any worktree was
|
|
@@ -528,17 +602,35 @@ async function runPublishOnlyHandoff(ctx, deps, branchName) {
|
|
|
528
602
|
prNumber: prView.number,
|
|
529
603
|
headSha: prView.headRefOid,
|
|
530
604
|
});
|
|
531
|
-
const
|
|
605
|
+
const pollStartedAt = new Date().toISOString();
|
|
606
|
+
const poll = await pollPrChecksDetailed(deps.github, {
|
|
532
607
|
cwd,
|
|
533
608
|
timeoutMs: developerEffectConfig.checksPollTimeoutMs,
|
|
534
609
|
intervalMs: developerEffectConfig.checksPollIntervalMs,
|
|
535
610
|
signal: ctx.signal,
|
|
536
611
|
number: prView.number,
|
|
537
612
|
});
|
|
613
|
+
// NOT-311: pending/queued CI at the timeout (or no checks yet with a
|
|
614
|
+
// `pull_request` workflow on the base) waits with backoff instead of spending
|
|
615
|
+
// an infra attempt — resolved here so the milestone carries the wait reason.
|
|
616
|
+
const wait = await resolveChecksWait({
|
|
617
|
+
github: deps.github,
|
|
618
|
+
cwd,
|
|
619
|
+
prNumber: prView.number,
|
|
620
|
+
baseRef: prView.baseRefName,
|
|
621
|
+
headSha: prView.headRefOid,
|
|
622
|
+
branch: branchName,
|
|
623
|
+
poll,
|
|
624
|
+
signal: ctx.signal,
|
|
625
|
+
payloadJson: workItem.payloadJson,
|
|
626
|
+
pollStartedAt,
|
|
627
|
+
});
|
|
628
|
+
const checks = poll.result;
|
|
538
629
|
milestone("checks.completed", `Developer · checks ${checks}`, {
|
|
539
630
|
snapshot: checks,
|
|
540
631
|
prNumber: prView.number,
|
|
541
632
|
headSha: prView.headRefOid,
|
|
633
|
+
...(wait.milestoneReason ? { reason: wait.milestoneReason } : {}),
|
|
542
634
|
});
|
|
543
635
|
const postPollView = await deps.github.viewPr({ cwd, branch: branchName });
|
|
544
636
|
if (!postPollView) {
|
|
@@ -575,6 +667,10 @@ async function runPublishOnlyHandoff(ctx, deps, branchName) {
|
|
|
575
667
|
author: "system",
|
|
576
668
|
content: { snapshot: checks, prNumber: prView.number, headSha: prView.headRefOid },
|
|
577
669
|
});
|
|
670
|
+
// NOT-311: still-waiting CI defers with backoff (no attempt spent) instead of
|
|
671
|
+
// timing out the session. An aborted wait keeps today's `timed_out`.
|
|
672
|
+
if (wait.pending)
|
|
673
|
+
return wait.pending;
|
|
578
674
|
if (checks === "timeout")
|
|
579
675
|
return { kind: "timed_out" };
|
|
580
676
|
await fetchRef(cwd, prView.baseRefName);
|
|
@@ -773,6 +869,18 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
773
869
|
recordIssueBaseSha(issue.id, resolved.baseSha);
|
|
774
870
|
issue.baseSha = resolved.baseSha;
|
|
775
871
|
}
|
|
872
|
+
// NOT-315: provision dependencies here, outside the sandbox, with network — the
|
|
873
|
+
// builder sandbox has none, so an in-session install can never work. A throw
|
|
874
|
+
// lands in the worktree-setup adapter_failure below: visible and
|
|
875
|
+
// infra-retryable, never a silent hour-long stall. The session is not spawned.
|
|
876
|
+
const depsResult = await ensureWorktreeDeps(worktreePath, {
|
|
877
|
+
runner: deps.depsRunner,
|
|
878
|
+
timeoutMs: developerEffectConfig.worktreeDepsTimeoutMs,
|
|
879
|
+
});
|
|
880
|
+
milestone("worktree.deps_ready", `Developer · dependencies ready (round ${round})`, {
|
|
881
|
+
...(depsResult.ran ? { ran: true } : { ran: false, reason: depsResult.reason }),
|
|
882
|
+
durationMs: depsResult.durationMs,
|
|
883
|
+
});
|
|
776
884
|
}
|
|
777
885
|
catch (err) {
|
|
778
886
|
return { kind: "adapter_failure", reason: `worktree setup failed: ${String(err)}` };
|
|
@@ -886,6 +994,14 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
886
994
|
const scopeDecisionNote = typeof payload.scopeDecisionNote === "string" && payload.scopeDecisionNote.trim()
|
|
887
995
|
? payload.scopeDecisionNote.trim()
|
|
888
996
|
: undefined;
|
|
997
|
+
// NOT-314: same one-shot ride for an operator_verification repair note.
|
|
998
|
+
const operatorRepairNote = typeof payload.operatorRepairNote === "string" && payload.operatorRepairNote.trim()
|
|
999
|
+
? payload.operatorRepairNote.trim()
|
|
1000
|
+
: undefined;
|
|
1001
|
+
// NOT-314: the frozen snapshot's operator criteria, parsed here so the
|
|
1002
|
+
// worker never re-derives them — the section renders only when non-empty.
|
|
1003
|
+
const operatorCriteria = extractOperatorCriteria(taskSnapshot.acceptanceCriteria);
|
|
1004
|
+
const hasOperatorCriteria = operatorCriteria.length > 0 ? operatorCriteria : undefined;
|
|
889
1005
|
// NOT-310: the conflict directive rides only the work item queued by the merge
|
|
890
1006
|
// path — exactly this round reads it; nothing is re-read from the DB. Shaped
|
|
891
1007
|
// defensively: a malformed directive renders nothing rather than breaking the
|
|
@@ -938,6 +1054,8 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
938
1054
|
guidance: guidance.length ? guidance : undefined,
|
|
939
1055
|
scopeDecisionNote,
|
|
940
1056
|
conflictRepair,
|
|
1057
|
+
operatorCriteria: hasOperatorCriteria,
|
|
1058
|
+
operatorRepairNote,
|
|
941
1059
|
});
|
|
942
1060
|
// NOT-83 review finding: the session is marked `running` (worker-loop.ts) before this
|
|
943
1061
|
// handler ever runs, so an abort landing during worktree setup/deck-bind above already
|
|
@@ -1614,7 +1732,8 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1614
1732
|
prNumber: prView.number,
|
|
1615
1733
|
headSha: prView.headRefOid,
|
|
1616
1734
|
});
|
|
1617
|
-
const
|
|
1735
|
+
const pollStartedAt = new Date().toISOString();
|
|
1736
|
+
const poll = await pollPrChecksDetailed(deps.github, {
|
|
1618
1737
|
cwd: worktreePath,
|
|
1619
1738
|
timeoutMs: developerEffectConfig.checksPollTimeoutMs,
|
|
1620
1739
|
intervalMs: developerEffectConfig.checksPollIntervalMs,
|
|
@@ -1624,10 +1743,27 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1624
1743
|
// back to a bare `gh pr view` either).
|
|
1625
1744
|
number: prView.number,
|
|
1626
1745
|
});
|
|
1746
|
+
// NOT-311: pending/queued CI at the timeout (or no checks yet with a
|
|
1747
|
+
// `pull_request` workflow on the base) waits with backoff instead of spending
|
|
1748
|
+
// an infra attempt — resolved here so the milestone carries the wait reason.
|
|
1749
|
+
const wait = await resolveChecksWait({
|
|
1750
|
+
github: deps.github,
|
|
1751
|
+
cwd: worktreePath,
|
|
1752
|
+
prNumber: prView.number,
|
|
1753
|
+
baseRef: prView.baseRefName,
|
|
1754
|
+
headSha: prView.headRefOid,
|
|
1755
|
+
branch: branchName,
|
|
1756
|
+
poll,
|
|
1757
|
+
signal: ctx.signal,
|
|
1758
|
+
payloadJson: workItem.payloadJson,
|
|
1759
|
+
pollStartedAt,
|
|
1760
|
+
});
|
|
1761
|
+
const checks = poll.result;
|
|
1627
1762
|
milestone("checks.completed", `Developer · checks ${checks}`, {
|
|
1628
1763
|
snapshot: checks,
|
|
1629
1764
|
prNumber: prView.number,
|
|
1630
1765
|
headSha: prView.headRefOid,
|
|
1766
|
+
...(wait.milestoneReason ? { reason: wait.milestoneReason } : {}),
|
|
1631
1767
|
});
|
|
1632
1768
|
// The poll can run for up to checksPollTimeoutMs (default 10 minutes) — re-fetch and
|
|
1633
1769
|
// re-validate identity against the SAME localHead before trusting either the checks
|
|
@@ -1675,6 +1811,12 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
|
|
|
1675
1811
|
author: "system",
|
|
1676
1812
|
content: { snapshot: checks, prNumber: prView.number, headSha: prView.headRefOid },
|
|
1677
1813
|
});
|
|
1814
|
+
// NOT-311: still-waiting CI defers with backoff (no attempt spent) instead of
|
|
1815
|
+
// timing out the session. An aborted wait keeps today's `timed_out`.
|
|
1816
|
+
if (wait.pending) {
|
|
1817
|
+
await bestEffortRemove(repoPath, worktreePath);
|
|
1818
|
+
return wait.pending;
|
|
1819
|
+
}
|
|
1678
1820
|
if (checks === "timeout") {
|
|
1679
1821
|
await bestEffortRemove(repoPath, worktreePath);
|
|
1680
1822
|
return { kind: "timed_out" };
|
|
@@ -151,7 +151,12 @@ 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");
|
|
@@ -530,6 +535,8 @@ test("checks_failed: CI failure after a clean push/PR retries without a human ac
|
|
|
530
535
|
await pump(1);
|
|
531
536
|
const issue = getIssue(issueId);
|
|
532
537
|
assert.equal(issue.status, "developing");
|
|
538
|
+
assert.equal(issue.ciAttempts, 1, "a checks_failed retry spends the CI budget");
|
|
539
|
+
assert.equal(issue.infraAttempts, 0, "a checks_failed retry never touches the infra budget");
|
|
533
540
|
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0);
|
|
534
541
|
const kinds = listArtifactsForIssue(issueId).map((a) => a.kind);
|
|
535
542
|
const evidence = listArtifactsForIssue(issueId).find((a) => a.kind === "checks_evidence");
|
|
@@ -540,12 +547,159 @@ test("checks_failed: CI failure after a clean push/PR retries without a human ac
|
|
|
540
547
|
assert.ok(kinds.includes("implementation_conclusion"), "conclusion must survive a post-session verification failure");
|
|
541
548
|
assert.ok(kinds.includes("developer_transcript"), "raw trace must survive a post-session verification failure");
|
|
542
549
|
});
|
|
543
|
-
test("
|
|
550
|
+
test("NOT-311: checks_pending — checks still pending at the poll deadline defers, spending no attempt", async () => {
|
|
551
|
+
// Previously this returned `timed_out`, spending an infra attempt on a session that
|
|
552
|
+
// had already ended cleanly. Now the item waits on backoff and re-polls.
|
|
544
553
|
const issueId = await makeIssue();
|
|
545
554
|
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "pending" }) }));
|
|
546
555
|
startWorkflow(issueId);
|
|
547
556
|
await pump(1);
|
|
557
|
+
const issue = getIssue(issueId);
|
|
558
|
+
assert.equal(issue.status, "developing");
|
|
559
|
+
assert.equal(issue.infraAttempts, 0, "a CI wait spends no infra attempt");
|
|
560
|
+
assert.equal(issue.currentRound, 1, "a CI wait must not spend a review round");
|
|
561
|
+
assert.match(issue.currentIntent ?? "", /Waiting for CI \(running\) - no attempt spent/);
|
|
562
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0, "no review handoff on unverified CI");
|
|
563
|
+
const items = listWorkItemsForIssue(issueId);
|
|
564
|
+
assert.equal(items.length, 1);
|
|
565
|
+
assert.equal(items[0].status, "pending", "the item waits, it is not finished or dead-lettered");
|
|
566
|
+
assert.equal(items[0].attemptCount, 0, "the claim-time attempt bump is reverted");
|
|
567
|
+
assert.ok(Date.parse(items[0].availableAt) > Date.parse(items[0].updatedAt), "the re-poll is gated behind a backoff");
|
|
568
|
+
const deferredPayload = JSON.parse(items[0].payloadJson ?? "{}");
|
|
569
|
+
assert.equal(deferredPayload.publishOnly, true, "the re-poll takes the no-agent publish path, not a fresh spawn");
|
|
570
|
+
assert.equal(deferredPayload.branch, issueBranchName(issueId));
|
|
571
|
+
assert.ok(deferredPayload.checksWaitStartedAt, "the wait anchors its ceiling at the first poll");
|
|
572
|
+
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
|
|
573
|
+
const events = listWorkflowEventsForIssue(issueId);
|
|
574
|
+
const deferrals = events.filter((e) => e.type === "worker.deferred");
|
|
575
|
+
assert.ok(deferrals.length > 0);
|
|
576
|
+
assert.equal(JSON.parse(deferrals[0].payloadJson ?? "{}").outcome, "checks_pending");
|
|
577
|
+
assert.equal(events.some((e) => e.type === "worker.failed"), false, "a CI wait is not a failure");
|
|
578
|
+
const completed = events.find((e) => e.type === "checks.completed");
|
|
579
|
+
assert.ok(completed, "the wait still records a checks.completed milestone");
|
|
580
|
+
assert.equal(JSON.parse(completed.payloadJson ?? "{}").snapshot, "timeout");
|
|
581
|
+
});
|
|
582
|
+
test("NOT-311: the deferred CI wait re-polls through publish-only and hands off once CI passes", async () => {
|
|
583
|
+
// First run waits (pending), the deferred re-poll sees success and reaches review —
|
|
584
|
+
// all without spawning a second agent session and without spending infra attempts.
|
|
585
|
+
let ci = "pending";
|
|
586
|
+
let spawns = 0;
|
|
587
|
+
const countingSpawn = async (input) => {
|
|
588
|
+
spawns++;
|
|
589
|
+
return commitingSpawn(input);
|
|
590
|
+
};
|
|
591
|
+
// A short backoff (set before the first deferral computes its `until`) lets the
|
|
592
|
+
// second pump actually pick the waiting item back up.
|
|
593
|
+
const prevBase = process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
|
|
594
|
+
const prevMax = process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
|
|
595
|
+
process.env.DECK_OUTAGE_BACKOFF_BASE_MS = "10";
|
|
596
|
+
process.env.DECK_OUTAGE_BACKOFF_MAX_MS = "10";
|
|
597
|
+
try {
|
|
598
|
+
const issueId = await makeIssue();
|
|
599
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: countingSpawn, github: fakeGithub({ checks: () => ci }) }));
|
|
600
|
+
startWorkflow(issueId);
|
|
601
|
+
await pump(1);
|
|
602
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
603
|
+
assert.equal(getIssue(issueId).infraAttempts, 0);
|
|
604
|
+
const deferred = listWorkItemsForIssue(issueId)[0];
|
|
605
|
+
assert.equal(deferred.status, "pending");
|
|
606
|
+
// Fast-forward past the deferral backoff, then let CI pass.
|
|
607
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
608
|
+
ci = "success";
|
|
609
|
+
await pump(1);
|
|
610
|
+
const issue = getIssue(issueId);
|
|
611
|
+
assert.equal(issue.status, "reviewing", "the re-poll hands off once CI passes");
|
|
612
|
+
assert.equal(issue.infraAttempts, 0, "waiting plus a passing re-poll still spends nothing");
|
|
613
|
+
assert.equal(spawns, 1, "the re-poll must not spawn a second agent session");
|
|
614
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
if (prevBase === undefined)
|
|
618
|
+
delete process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
|
|
619
|
+
else
|
|
620
|
+
process.env.DECK_OUTAGE_BACKOFF_BASE_MS = prevBase;
|
|
621
|
+
if (prevMax === undefined)
|
|
622
|
+
delete process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
|
|
623
|
+
else
|
|
624
|
+
process.env.DECK_OUTAGE_BACKOFF_MAX_MS = prevMax;
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
test("NOT-311: none with a pull_request workflow on the base waits (no review handoff)", async () => {
|
|
628
|
+
const issueId = await makeIssue();
|
|
629
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "none", pullRequestWorkflow: true }) }));
|
|
630
|
+
startWorkflow(issueId);
|
|
631
|
+
await pump(1);
|
|
632
|
+
const issue = getIssue(issueId);
|
|
633
|
+
assert.equal(issue.status, "developing");
|
|
634
|
+
assert.equal(issue.infraAttempts, 0, "an unregistered-CI wait spends no infra attempt");
|
|
635
|
+
assert.match(issue.currentIntent ?? "", /Waiting for CI \(queued\) - no attempt spent/);
|
|
636
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0, "no review handoff on 'no checks yet'");
|
|
637
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
638
|
+
assert.ok(completed);
|
|
639
|
+
const payload = JSON.parse(completed.payloadJson ?? "{}");
|
|
640
|
+
assert.equal(payload.snapshot, "none");
|
|
641
|
+
assert.equal(payload.reason, "none_with_workflow");
|
|
642
|
+
});
|
|
643
|
+
test("NOT-311: none with no pull_request workflow on the base proceeds as today", async () => {
|
|
644
|
+
const issueId = await makeIssue();
|
|
645
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "none", pullRequestWorkflow: false }) }));
|
|
646
|
+
startWorkflow(issueId);
|
|
647
|
+
await pump(1);
|
|
648
|
+
const issue = getIssue(issueId);
|
|
649
|
+
assert.equal(issue.status, "reviewing", "no configured CI still hands off");
|
|
650
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
|
|
651
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
652
|
+
assert.ok(completed);
|
|
653
|
+
const payload = JSON.parse(completed.payloadJson ?? "{}");
|
|
654
|
+
assert.equal(payload.snapshot, "none");
|
|
655
|
+
assert.equal(payload.reason, "none_no_workflow");
|
|
656
|
+
});
|
|
657
|
+
test("NOT-311: none with an unknown workflow answer waits fail-closed", async () => {
|
|
658
|
+
// The adapter cannot read the base (or has no such method): 'no checks yet' must
|
|
659
|
+
// never read as a pass.
|
|
660
|
+
const issueId = await makeIssue();
|
|
661
|
+
const github = fakeGithub({ checks: "none", pullRequestWorkflow: null });
|
|
662
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github }));
|
|
663
|
+
startWorkflow(issueId);
|
|
664
|
+
await pump(1);
|
|
548
665
|
assert.equal(getIssue(issueId).status, "developing");
|
|
666
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0);
|
|
667
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
668
|
+
assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "none_with_workflow");
|
|
669
|
+
});
|
|
670
|
+
test("NOT-311: past CHECKS_WAIT_CEILING_MS the wait escalates naming the PR and head SHA", async () => {
|
|
671
|
+
// A zero ceiling makes the very first wait already past it — no clock to inject,
|
|
672
|
+
// no sleeping: the deferral reports escalated and a policy_escalation lands.
|
|
673
|
+
const prev = process.env.CHECKS_WAIT_CEILING_MS;
|
|
674
|
+
process.env.CHECKS_WAIT_CEILING_MS = "0";
|
|
675
|
+
try {
|
|
676
|
+
const issueId = await makeIssue();
|
|
677
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "pending" }) }));
|
|
678
|
+
startWorkflow(issueId);
|
|
679
|
+
await pump(1);
|
|
680
|
+
const issue = getIssue(issueId);
|
|
681
|
+
assert.equal(issue.status, "needs_human");
|
|
682
|
+
assert.equal(issue.infraAttempts, 0, "a ceiling escalation spends no infra attempt");
|
|
683
|
+
const actions = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
|
|
684
|
+
assert.equal(actions.length, 1);
|
|
685
|
+
assert.equal(actions[0].actionType, "policy_escalation");
|
|
686
|
+
// The wait never hands off, so the issue row carries no PR/SHA — the milestone
|
|
687
|
+
// and the human action do.
|
|
688
|
+
const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
|
|
689
|
+
assert.ok(completed);
|
|
690
|
+
const msPayload = JSON.parse(completed.payloadJson ?? "{}");
|
|
691
|
+
assert.equal(msPayload.reason, "pending_ceiling");
|
|
692
|
+
assert.ok(msPayload.prNumber, "the milestone names the PR");
|
|
693
|
+
assert.ok(msPayload.headSha, "the milestone names the head SHA");
|
|
694
|
+
assert.match(actions[0].reason, new RegExp(`CI did not complete on ${msPayload.headSha}`));
|
|
695
|
+
assert.match(actions[0].reason, new RegExp(`PR #${msPayload.prNumber}`));
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
if (prev === undefined)
|
|
699
|
+
delete process.env.CHECKS_WAIT_CEILING_MS;
|
|
700
|
+
else
|
|
701
|
+
process.env.CHECKS_WAIT_CEILING_MS = prev;
|
|
702
|
+
}
|
|
549
703
|
});
|
|
550
704
|
test("NOT-255: a non-draft PR with otherwise-verified identity hands off cleanly (just-failed retry_merge)", async () => {
|
|
551
705
|
// A merge-failure escalation is reachable from `retry_merge`, which undrafts the PR
|
|
@@ -2079,7 +2233,9 @@ test("NOT-252: checks_failed enriches checks_evidence and the retry prompt with
|
|
|
2079
2233
|
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: evolvingSpawn, github }));
|
|
2080
2234
|
startWorkflow(issueId);
|
|
2081
2235
|
await pump(1);
|
|
2082
|
-
assert.equal(getIssue(issueId).status, "developing", "enriched checks_failed still retries on the
|
|
2236
|
+
assert.equal(getIssue(issueId).status, "developing", "enriched checks_failed still retries on the CI budget");
|
|
2237
|
+
assert.equal(getIssue(issueId).ciAttempts, 1, "the enriched retry spends ci_attempts");
|
|
2238
|
+
assert.equal(getIssue(issueId).infraAttempts, 0, "the enriched retry leaves infra_attempts alone");
|
|
2083
2239
|
const evidence = listArtifactsForIssue(issueId).find((a) => a.kind === "checks_evidence");
|
|
2084
2240
|
assert.ok(evidence, "enriched checks_failed persists checks_evidence");
|
|
2085
2241
|
const content = JSON.parse(evidence.contentJson);
|
|
@@ -2138,3 +2294,132 @@ test("NOT-252: publish-only retry on checks_failed enriches evidence and the nex
|
|
|
2138
2294
|
assert.match(prompts[1], /publish-checks/);
|
|
2139
2295
|
assert.match(prompts[1], /is not in this registry/);
|
|
2140
2296
|
});
|
|
2297
|
+
test("NOT-313: three CI repair attempts then a CI-specific escalation — infra_attempts stays 0", async () => {
|
|
2298
|
+
// Exit-predicate loop: the CI budget (default 3) buys three repair attempts;
|
|
2299
|
+
// the fourth consecutive checks_failed escalates with a CI-specific reason.
|
|
2300
|
+
const issueId = await makeIssue();
|
|
2301
|
+
const prompts = [];
|
|
2302
|
+
let call = 0;
|
|
2303
|
+
const evolvingSpawn = async (input) => {
|
|
2304
|
+
call++;
|
|
2305
|
+
prompts.push(input.prompt);
|
|
2306
|
+
fs.writeFileSync(path.join(input.cwd, "feature.txt"), `implemented attempt ${call}\n`);
|
|
2307
|
+
git(input.cwd, "add", ".");
|
|
2308
|
+
git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", `implement ${call}`);
|
|
2309
|
+
return { exitCode: 0, transcript: "Implementation conclusion: added the widget.", logPath: "/dev/null", timedOut: false };
|
|
2310
|
+
};
|
|
2311
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: evolvingSpawn, github: fakeGithub({ checks: "failure" }) }));
|
|
2312
|
+
startWorkflow(issueId);
|
|
2313
|
+
await pump(1); // failure #1 → CI repair attempt 1
|
|
2314
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
2315
|
+
assert.equal(getIssue(issueId).ciAttempts, 1);
|
|
2316
|
+
assert.equal(getIssue(issueId).infraAttempts, 0);
|
|
2317
|
+
await pump(1); // failure #2 → CI repair attempt 2
|
|
2318
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
2319
|
+
assert.equal(getIssue(issueId).ciAttempts, 2);
|
|
2320
|
+
assert.equal(getIssue(issueId).infraAttempts, 0);
|
|
2321
|
+
assert.match(prompts[1], /CI repair attempt 1 of 3/);
|
|
2322
|
+
await pump(1); // failure #3 → CI repair attempt 3
|
|
2323
|
+
assert.equal(getIssue(issueId).status, "developing");
|
|
2324
|
+
assert.equal(getIssue(issueId).ciAttempts, 3);
|
|
2325
|
+
assert.equal(getIssue(issueId).infraAttempts, 0);
|
|
2326
|
+
assert.match(prompts[2], /CI repair attempt 2 of 3/);
|
|
2327
|
+
await pump(1); // failure #4 → budget spent → escalate
|
|
2328
|
+
const issue = getIssue(issueId);
|
|
2329
|
+
assert.equal(issue.status, "needs_human");
|
|
2330
|
+
assert.equal(issue.ciAttempts, 3, "the escalation itself spends no further CI attempt");
|
|
2331
|
+
assert.equal(issue.infraAttempts, 0, "four CI failures never touched the infra budget");
|
|
2332
|
+
assert.match(prompts[3], /CI repair attempt 3 of 3/);
|
|
2333
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
|
|
2334
|
+
assert.ok(action, "the exhausted CI budget raises a human action");
|
|
2335
|
+
assert.equal(action.actionType, "policy_escalation");
|
|
2336
|
+
assert.match(action.reason, /CI checks still failing after 3 repair attempts \(limit 3\)/);
|
|
2337
|
+
assert.doesNotMatch(action.reason, /infra-attempt limit reached/);
|
|
2338
|
+
});
|
|
2339
|
+
test("NOT-315: a failing dependency install is a worktree-setup failure with the stderr tail — the session is not spawned", async () => {
|
|
2340
|
+
// Hermetic repo: the shared fixture carries no package.json (so the step would skip
|
|
2341
|
+
// before the runner is ever invoked), so this test cuts its own repo + remote whose
|
|
2342
|
+
// main tip declares npm dependencies — the fresh worktree then really triggers the install.
|
|
2343
|
+
const npmRepo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-npm-repo-"));
|
|
2344
|
+
const npmRemote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-npm-remote-"));
|
|
2345
|
+
try {
|
|
2346
|
+
git(npmRepo, "init", "-q", "-b", "main");
|
|
2347
|
+
git(npmRepo, "config", "user.email", "test@example.com");
|
|
2348
|
+
git(npmRepo, "config", "user.name", "Test");
|
|
2349
|
+
fs.writeFileSync(path.join(npmRepo, "README.md"), "hello\n");
|
|
2350
|
+
fs.writeFileSync(path.join(npmRepo, "package.json"), JSON.stringify({ name: "wt", version: "1.0.0" }));
|
|
2351
|
+
fs.writeFileSync(path.join(npmRepo, "package-lock.json"), JSON.stringify({ name: "wt", lockfileVersion: 3, packages: {} }));
|
|
2352
|
+
git(npmRepo, "add", ".");
|
|
2353
|
+
git(npmRepo, "commit", "-q", "-m", "init");
|
|
2354
|
+
execFileSync("git", ["init", "-q", "--bare", "-b", "main", npmRemote]);
|
|
2355
|
+
git(npmRepo, "remote", "add", "origin", npmRemote);
|
|
2356
|
+
git(npmRepo, "push", "-q", "origin", "main");
|
|
2357
|
+
const devAgent = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
2358
|
+
const revAgent = createAgent({ name: `rev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
|
|
2359
|
+
const issueId = createIssue({
|
|
2360
|
+
title: "Add widget",
|
|
2361
|
+
description: "Build the widget.",
|
|
2362
|
+
acceptanceCriteria: "Widget renders.",
|
|
2363
|
+
repo: npmRepo,
|
|
2364
|
+
baseBranch: "main",
|
|
2365
|
+
developerAgentId: devAgent.id,
|
|
2366
|
+
reviewerAgentId: revAgent.id,
|
|
2367
|
+
maxReviewRounds: 3,
|
|
2368
|
+
maxInfraAttempts: 3,
|
|
2369
|
+
source: "manual"
|
|
2370
|
+
}).id;
|
|
2371
|
+
let spawnCalls = 0;
|
|
2372
|
+
const neverSpawn = async () => {
|
|
2373
|
+
spawnCalls++;
|
|
2374
|
+
return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
|
|
2375
|
+
};
|
|
2376
|
+
let depsCalls = 0;
|
|
2377
|
+
const failingDeps = async () => {
|
|
2378
|
+
depsCalls++;
|
|
2379
|
+
const err = new Error("npm ci exited");
|
|
2380
|
+
err.stderr = "npm error code EAI_AGAIN\nnpm error registry unreachable (sandbox has no network)\n";
|
|
2381
|
+
throw err;
|
|
2382
|
+
};
|
|
2383
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: neverSpawn, github: fakeGithub(), depsRunner: failingDeps }));
|
|
2384
|
+
startWorkflow(issueId);
|
|
2385
|
+
await pump(1);
|
|
2386
|
+
assert.equal(depsCalls, 1, "the worktree declares npm deps, so the install must run exactly once");
|
|
2387
|
+
assert.equal(spawnCalls, 0, "the session must not spawn when dependency provisioning fails");
|
|
2388
|
+
const issue = getIssue(issueId);
|
|
2389
|
+
assert.equal(issue.status, "developing", "adapter_failure retries on the infra budget");
|
|
2390
|
+
const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
|
|
2391
|
+
// adapter_failure never spawned, so the session row goes terminal (done) with the
|
|
2392
|
+
// setup failure recorded as its reason — like every other adapter_failure.
|
|
2393
|
+
assert.ok(dev.status === "done" || dev.status === "failed", `terminal session, got ${dev.status}`);
|
|
2394
|
+
const reason = JSON.parse(dev.errorJson ?? "{}").reason ?? dev.errorJson ?? "";
|
|
2395
|
+
assert.match(reason, /worktree setup failed/);
|
|
2396
|
+
assert.match(reason, /dependency install failed: /);
|
|
2397
|
+
assert.match(reason, /registry unreachable/);
|
|
2398
|
+
}
|
|
2399
|
+
finally {
|
|
2400
|
+
fs.rmSync(npmRepo, { recursive: true, force: true });
|
|
2401
|
+
fs.rmSync(npmRemote, { recursive: true, force: true });
|
|
2402
|
+
}
|
|
2403
|
+
});
|
|
2404
|
+
test("NOT-315: a skipped dependency install still emits worktree.deps_ready with ran/skipped and durationMs", async () => {
|
|
2405
|
+
// The fixture repo carries no package.json, so the step skips (no package.json) and
|
|
2406
|
+
// the fake runner is never called — the milestone must still record the skip.
|
|
2407
|
+
const issueId = await makeIssue();
|
|
2408
|
+
let depsCalls = 0;
|
|
2409
|
+
const countingDeps = async () => {
|
|
2410
|
+
depsCalls++;
|
|
2411
|
+
return { stdout: "", stderr: "" };
|
|
2412
|
+
};
|
|
2413
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub(), depsRunner: countingDeps }));
|
|
2414
|
+
startWorkflow(issueId);
|
|
2415
|
+
await pump(1);
|
|
2416
|
+
assert.equal(getIssue(issueId).status, "reviewing", "a skipped install must not block handoff");
|
|
2417
|
+
assert.equal(depsCalls, 0, "no package.json means the runner is never invoked");
|
|
2418
|
+
const { listWorkflowEventsForIssue: listEvents } = await import("../repository/workflow-events.js");
|
|
2419
|
+
const depsEvent = listEvents(issueId).find((e) => e.type === "worktree.deps_ready");
|
|
2420
|
+
assert.ok(depsEvent, "expected a worktree.deps_ready milestone");
|
|
2421
|
+
const payload = JSON.parse(depsEvent.payloadJson);
|
|
2422
|
+
assert.equal(payload.ran, false);
|
|
2423
|
+
assert.match(String(payload.reason ?? ""), /no package\.json/);
|
|
2424
|
+
assert.ok(typeof payload.durationMs === "number" && payload.durationMs >= 0);
|
|
2425
|
+
});
|
|
@@ -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
|
+
});
|