agent-dealer 1.3.3 → 1.3.4
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/coordinator/commands.js +79 -1
- package/bundle/server/dist/coordinator/commands.test.js +2 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +2 -0
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +2 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +2 -0
- package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
- package/bundle/server/dist/db/auto-merge-default-migration.test.js +52 -0
- package/bundle/server/dist/db/index.js +4 -2
- package/bundle/server/dist/db/schema.sql +1 -1
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +2 -0
- package/bundle/server/dist/routes/auto-merge-policy.test.js +378 -0
- package/bundle/server/dist/routes/human-actions.test.js +4 -4
- package/bundle/server/dist/routes/issues-execution-contract.test.js +4 -3
- package/bundle/server/dist/routes/issues.js +32 -2
- package/bundle/server/dist/routes/issues.test.js +25 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CHCiztRI.css +1 -0
- package/bundle/server/static-ui/assets/index-CrLQFFpw.js +64 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/issues.d.ts +8 -5
- package/bundle/shared/dist/issues.js +7 -4
- package/bundle/shared/dist/issues.test.js +11 -1
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +1 -1
- package/dist/issue.d.ts +1 -0
- package/dist/issue.js +3 -2
- package/dist/issue.test.js +37 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-B_pNUDRY.js +0 -64
- package/bundle/server/static-ui/assets/index-DL8mEXzN.css +0 -1
|
@@ -32,7 +32,7 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
|
|
|
32
32
|
import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
|
|
33
33
|
import { MERGE_CONFLICT_FILES_EVIDENCE_KEY, MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
|
|
34
34
|
import { AUTH_TRANSIENT_RETRY_PAYLOAD_KEY, RUNTIME_AUTH_PARK_EVIDENCE_KEY, authProbeConfirmsFailure, defaultRemediationForRuntime, gateRuntimeAuthParkResume, parseRuntimeAuthParkEvidence, remediationFromProbe, } from "./runtime-auth-park.js";
|
|
35
|
-
import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
35
|
+
import { AUTO_MERGE_INTENT, OPERATOR_VERIFICATION_INTENT, finalizeAutoMerge } from "./auto-merge.js";
|
|
36
36
|
import { triggerLinearPostMerge } from "./linear-merge-verify.js";
|
|
37
37
|
import { conflictRepairSpent, queueConflictRepairRound } from "./merge-conflict-sync.js";
|
|
38
38
|
import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
|
|
@@ -261,6 +261,84 @@ export function applyParkedEdit(issueId, patch) {
|
|
|
261
261
|
throw err;
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
|
+
export function autoMergePolicyEditability(issue) {
|
|
265
|
+
switch (issue.status) {
|
|
266
|
+
case "ready":
|
|
267
|
+
return { editable: true, reason: null };
|
|
268
|
+
case "developing":
|
|
269
|
+
case "reviewing":
|
|
270
|
+
case "repairing":
|
|
271
|
+
return getActiveWorkflowInstance(issue.id)
|
|
272
|
+
? { editable: true, reason: null }
|
|
273
|
+
: { editable: false, reason: "No active workflow is running for this issue." };
|
|
274
|
+
case "needs_human": {
|
|
275
|
+
const open = listHumanActionsForIssue(issue.id).filter((a) => a.status === "open");
|
|
276
|
+
// The operator-verification gate runs after approval routing: resolving it continues
|
|
277
|
+
// the already-committed merge path, so the policy is locked while it is open.
|
|
278
|
+
if (issue.currentIntent === OPERATOR_VERIFICATION_INTENT ||
|
|
279
|
+
open.some((a) => a.actionType === "operator_verification")) {
|
|
280
|
+
return {
|
|
281
|
+
editable: false,
|
|
282
|
+
reason: "The reviewer approved and the merge decision is committed — it waits only on operator verification.",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// Merge-failure parks, including the no-head operator-verification escalation.
|
|
286
|
+
if (open.some((a) => a.actionType === "policy_escalation" && isMergeFailureAction(a))) {
|
|
287
|
+
return {
|
|
288
|
+
editable: false,
|
|
289
|
+
reason: "The reviewer approved and the merge was already attempted — use the merge-failure action instead.",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { editable: true, reason: null };
|
|
293
|
+
}
|
|
294
|
+
case "final_review":
|
|
295
|
+
return {
|
|
296
|
+
editable: false,
|
|
297
|
+
reason: issue.currentOwner === "system" && issue.currentIntent === AUTO_MERGE_INTENT
|
|
298
|
+
? "The reviewer approved and auto-merge is already in flight."
|
|
299
|
+
: "The reviewer approved and the merge decision is committed — use Merge on the final review action.",
|
|
300
|
+
};
|
|
301
|
+
case "done":
|
|
302
|
+
case "closed":
|
|
303
|
+
return { editable: false, reason: `The issue is ${issue.status}.` };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* NOT-490: an `autoMerge`-only update — the one input that may change during a live
|
|
308
|
+
* round, because nothing reads it until approved-review routing. Guard re-check, row
|
|
309
|
+
* write and audit run in one SQLite transaction, which serializes against the
|
|
310
|
+
* reviewer-completion transaction: an update committed first is the value
|
|
311
|
+
* `applyReviewer` routes on; a reviewer decision committed first moves the issue to
|
|
312
|
+
* `final_review` and this answers 409, so one approval can never yield both an
|
|
313
|
+
* auto-merge and a `final_review` action. Queue entry, frozen snapshot, agents,
|
|
314
|
+
* budgets, branch and PR are untouched (`updateIssue` writes only the policy column
|
|
315
|
+
* here). A real change appends one `issue.reassigned` event with before/after values
|
|
316
|
+
* and the actor; a no-op appends nothing.
|
|
317
|
+
*/
|
|
318
|
+
export function applyAutoMergePolicyUpdate(issueId, autoMerge, actor) {
|
|
319
|
+
return getDb().transaction(() => {
|
|
320
|
+
const fresh = getIssue(issueId);
|
|
321
|
+
if (!fresh)
|
|
322
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
323
|
+
const editability = autoMergePolicyEditability(fresh);
|
|
324
|
+
if (!editability.editable) {
|
|
325
|
+
return { ok: false, code: 409, error: `Auto-merge can no longer be changed: ${editability.reason}` };
|
|
326
|
+
}
|
|
327
|
+
if (fresh.autoMerge === autoMerge)
|
|
328
|
+
return { ok: true, issue: fresh, changed: false };
|
|
329
|
+
const next = updateIssue(issueId, { autoMerge });
|
|
330
|
+
appendWorkflowEvent({
|
|
331
|
+
issueId,
|
|
332
|
+
workflowInstanceId: getActiveWorkflowInstance(issueId)?.id ?? null,
|
|
333
|
+
type: "issue.reassigned",
|
|
334
|
+
actorType: "human",
|
|
335
|
+
stage: next.status,
|
|
336
|
+
round: next.currentRound,
|
|
337
|
+
payload: { fromAutoMerge: fresh.autoMerge, toAutoMerge: next.autoMerge, actor },
|
|
338
|
+
});
|
|
339
|
+
return { ok: true, issue: next, changed: true };
|
|
340
|
+
})();
|
|
341
|
+
}
|
|
264
342
|
/**
|
|
265
343
|
* Stable identity-plus-bytes key for one attachment manifest entry. blobPath is
|
|
266
344
|
* excluded: it is content-addressed from the compared fields under an
|
|
@@ -42,6 +42,8 @@ function newIssue(opts = {}) {
|
|
|
42
42
|
baseBranch: "main",
|
|
43
43
|
maxReviewRounds: opts.maxReviewRounds ?? 3,
|
|
44
44
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
45
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
46
|
+
autoMerge: false,
|
|
45
47
|
source: "manual"
|
|
46
48
|
}).id;
|
|
47
49
|
}
|
|
@@ -89,6 +89,8 @@ async function makeIssue(opts = {}) {
|
|
|
89
89
|
reviewerAgentId: rev.id,
|
|
90
90
|
maxReviewRounds: 3,
|
|
91
91
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
92
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
93
|
+
autoMerge: false,
|
|
92
94
|
source: "manual"
|
|
93
95
|
}).id;
|
|
94
96
|
}
|
|
@@ -104,6 +104,8 @@ async function makeIssue(opts = {}) {
|
|
|
104
104
|
reviewerAgentId: rev.id,
|
|
105
105
|
maxReviewRounds: 3,
|
|
106
106
|
maxInfraAttempts: opts.maxInfraAttempts ?? 3,
|
|
107
|
+
// NOT-489: omitted now means auto-merge; this suite exercises final human review.
|
|
108
|
+
autoMerge: false,
|
|
107
109
|
source: "manual"
|
|
108
110
|
}).id;
|
|
109
111
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// NOT-489: a clean install's `issues.auto_merge` column defaults on, while an upgrade
|
|
2
|
+
// never rewrites an existing issue's stored choice — and new creates on the upgraded
|
|
3
|
+
// database still take the shared `CreateIssueInput` default (true), not the column's.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-auto-merge-migrate-"));
|
|
12
|
+
const { migrate, getDb } = await import("./index.js");
|
|
13
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
14
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const autoMergeDefault = (db) => db.prepare("PRAGMA table_info(issues)").all().find((c) => c.name === "auto_merge").dflt_value;
|
|
17
|
+
const storedAutoMerge = (id) => getDb().prepare("SELECT auto_merge FROM issues WHERE id = ?").get(id).auto_merge;
|
|
18
|
+
function newIssueId(autoMerge) {
|
|
19
|
+
return createIssue({
|
|
20
|
+
title: "T",
|
|
21
|
+
repo: "github.com/acme/app",
|
|
22
|
+
baseBranch: "main",
|
|
23
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
24
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
25
|
+
maxReviewRounds: 3,
|
|
26
|
+
maxInfraAttempts: 3,
|
|
27
|
+
source: "manual",
|
|
28
|
+
...(autoMerge === undefined ? {} : { autoMerge }),
|
|
29
|
+
}).id;
|
|
30
|
+
}
|
|
31
|
+
test("a clean schema defaults auto_merge on", () => {
|
|
32
|
+
const db = new Database(":memory:");
|
|
33
|
+
db.exec(fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8"));
|
|
34
|
+
assert.equal(autoMergeDefault(db), "1");
|
|
35
|
+
});
|
|
36
|
+
test("upgrading preserves every stored auto_merge value; post-upgrade omitted-field creates still get true", () => {
|
|
37
|
+
migrate();
|
|
38
|
+
const optedOut = newIssueId(false);
|
|
39
|
+
const optedIn = newIssueId(true);
|
|
40
|
+
// An upgraded database whose column predates NOT-489 (still DEFAULT 0).
|
|
41
|
+
getDb().exec("ALTER TABLE issues DROP COLUMN auto_merge");
|
|
42
|
+
getDb().exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
|
|
43
|
+
getDb().prepare("UPDATE issues SET auto_merge = 1 WHERE id = ?").run(optedIn);
|
|
44
|
+
assert.equal(autoMergeDefault(getDb()), "0");
|
|
45
|
+
migrate();
|
|
46
|
+
assert.equal(storedAutoMerge(optedOut), 0, "a pre-existing false row is not rewritten");
|
|
47
|
+
assert.equal(getIssue(optedOut).autoMerge, false);
|
|
48
|
+
assert.equal(storedAutoMerge(optedIn), 1);
|
|
49
|
+
const fresh = newIssueId();
|
|
50
|
+
assert.equal(storedAutoMerge(fresh), 1, "an omitted-field create on the upgraded DB stores true");
|
|
51
|
+
assert.equal(getIssue(fresh).autoMerge, true);
|
|
52
|
+
});
|
|
@@ -264,8 +264,10 @@ export function migrate() {
|
|
|
264
264
|
if (!issueColsForCi.some((c) => c.name === "ci_attempts")) {
|
|
265
265
|
db.exec("ALTER TABLE issues ADD COLUMN ci_attempts INTEGER NOT NULL DEFAULT 0");
|
|
266
266
|
}
|
|
267
|
-
// NOT-102: per-issue auto-merge after reviewer approve
|
|
268
|
-
//
|
|
267
|
+
// NOT-102: per-issue auto-merge after reviewer approve. Rows that predate the column
|
|
268
|
+
// backfill off so in-flight workflows keep the human final_review path. NOT-489: new
|
|
269
|
+
// creates write the shared `CreateIssueInput` default (true) explicitly, so this
|
|
270
|
+
// backfill DEFAULT never decides a new issue's policy and stays 0 to preserve old rows.
|
|
269
271
|
const issueColsForAutoMerge = db.prepare("PRAGMA table_info(issues)").all();
|
|
270
272
|
if (!issueColsForAutoMerge.some((c) => c.name === "auto_merge")) {
|
|
271
273
|
db.exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
|
|
@@ -280,6 +280,8 @@ test("agent-operated CLI: discover profiles, create + start a Dev-review issue,
|
|
|
280
280
|
"Widget renders.",
|
|
281
281
|
"--base-branch",
|
|
282
282
|
"main",
|
|
283
|
+
// NOT-489: auto-merge is the default; this walk drives the final-review action.
|
|
284
|
+
"--no-auto-merge",
|
|
283
285
|
]));
|
|
284
286
|
const issueId = created.id;
|
|
285
287
|
assert.equal(created.status, "ready");
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// NOT-490: auto-merge policy changes on queued and in-flight issues — the policy-only
|
|
2
|
+
// PATCH path, its audit event, the frozen-input guard for mixed patches, and the
|
|
3
|
+
// linearization against reviewer-approval routing.
|
|
4
|
+
import { test, before, beforeEach, afterEach } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import Fastify from "fastify";
|
|
11
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not490-"));
|
|
12
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
13
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CODEX_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
14
|
+
const { registerIssueRoutes } = await import("./issues.js");
|
|
15
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
16
|
+
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
17
|
+
const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
18
|
+
const { claimWorkItem } = await import("../repository/work-items.js");
|
|
19
|
+
const { enqueueIssue, getQueuedEntryForIssue, listQueuedEntries } = await import("../repository/queue-entries.js");
|
|
20
|
+
const { queueStatusForIssue, setAdmissionHealthCheckerForTests } = await import("../coordinator/admission.js");
|
|
21
|
+
const { startWorkflow, applyCompletion, getTaskSnapshot } = await import("../coordinator/commands.js");
|
|
22
|
+
const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
|
|
23
|
+
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("../coordinator/auto-merge.js");
|
|
24
|
+
let fixtureRepo = "";
|
|
25
|
+
before(() => {
|
|
26
|
+
migrate();
|
|
27
|
+
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
28
|
+
// A real local checkout so the auto-merge finalizer resolves its cwd.
|
|
29
|
+
fixtureRepo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not490-repo-"));
|
|
30
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: fixtureRepo });
|
|
31
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: fixtureRepo });
|
|
32
|
+
execFileSync("git", ["config", "user.name", "Test"], { cwd: fixtureRepo });
|
|
33
|
+
fs.writeFileSync(path.join(fixtureRepo, "README.md"), "hi\n");
|
|
34
|
+
execFileSync("git", ["add", "."], { cwd: fixtureRepo });
|
|
35
|
+
execFileSync("git", ["commit", "-m", "init"], { cwd: fixtureRepo });
|
|
36
|
+
});
|
|
37
|
+
let mergeCalls = 0;
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
getDb().exec(`
|
|
40
|
+
DELETE FROM work_items;
|
|
41
|
+
DELETE FROM human_actions;
|
|
42
|
+
DELETE FROM workflow_events;
|
|
43
|
+
DELETE FROM findings;
|
|
44
|
+
DELETE FROM review_publications;
|
|
45
|
+
DELETE FROM worker_sessions;
|
|
46
|
+
DELETE FROM artifacts;
|
|
47
|
+
DELETE FROM usage_events;
|
|
48
|
+
DELETE FROM workflow_instances;
|
|
49
|
+
DELETE FROM queue_entries;
|
|
50
|
+
DELETE FROM issues;
|
|
51
|
+
`);
|
|
52
|
+
mergeCalls = 0;
|
|
53
|
+
clearFinalizeInflightForTests();
|
|
54
|
+
setMergePrForTests(async () => {
|
|
55
|
+
mergeCalls++;
|
|
56
|
+
return { ok: true };
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
setMergePrForTests(null);
|
|
61
|
+
clearFinalizeInflightForTests();
|
|
62
|
+
});
|
|
63
|
+
async function buildApp() {
|
|
64
|
+
const app = Fastify();
|
|
65
|
+
await registerIssueRoutes(app);
|
|
66
|
+
return app;
|
|
67
|
+
}
|
|
68
|
+
function newIssue(opts) {
|
|
69
|
+
return createIssue({
|
|
70
|
+
title: opts.title ?? "Policy target",
|
|
71
|
+
description: "d",
|
|
72
|
+
acceptanceCriteria: opts.acceptanceCriteria ?? "It works",
|
|
73
|
+
repo: fixtureRepo,
|
|
74
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
75
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
76
|
+
baseBranch: "main",
|
|
77
|
+
maxReviewRounds: opts.maxReviewRounds ?? 3,
|
|
78
|
+
maxInfraAttempts: 3,
|
|
79
|
+
source: "manual",
|
|
80
|
+
autoMerge: opts.autoMerge,
|
|
81
|
+
}).id;
|
|
82
|
+
}
|
|
83
|
+
async function complete(issueId, outcome) {
|
|
84
|
+
const item = claimWorkItem(`test-${issueId}`, { leaseMs: 60_000 });
|
|
85
|
+
assert.ok(item && item.issueId === issueId, "expected to lease this issue's work item");
|
|
86
|
+
return applyCompletion(item.id, item.leaseToken, outcome);
|
|
87
|
+
}
|
|
88
|
+
const review = (verdict) => ReviewerResult.parse({
|
|
89
|
+
verdict,
|
|
90
|
+
baseSha: "b",
|
|
91
|
+
headSha: "h",
|
|
92
|
+
acceptanceCriteriaAssessment: "ok",
|
|
93
|
+
evidenceAssessment: "ok",
|
|
94
|
+
findings: [],
|
|
95
|
+
risks: [],
|
|
96
|
+
});
|
|
97
|
+
const cleanHandoff = {
|
|
98
|
+
kind: "clean_handoff",
|
|
99
|
+
branch: "issue-490",
|
|
100
|
+
headSha: "abc123",
|
|
101
|
+
baseSha: "base1",
|
|
102
|
+
prNumber: 42,
|
|
103
|
+
prUrl: "https://gh/pr/42",
|
|
104
|
+
};
|
|
105
|
+
function policyEvents(issueId) {
|
|
106
|
+
return listWorkflowEventsForIssue(issueId).filter((e) => e.type === "issue.reassigned");
|
|
107
|
+
}
|
|
108
|
+
/** Every issue column except the policy itself and the write timestamp. */
|
|
109
|
+
function frozenView(issueId) {
|
|
110
|
+
const { autoMerge: _a, updatedAt: _u, ...rest } = getIssue(issueId);
|
|
111
|
+
return rest;
|
|
112
|
+
}
|
|
113
|
+
/** Drives a fresh autoMerge issue into each eligible pre-decision state. */
|
|
114
|
+
const ELIGIBLE = [
|
|
115
|
+
{
|
|
116
|
+
status: "developing",
|
|
117
|
+
setup: async (autoMerge) => {
|
|
118
|
+
const id = newIssue({ autoMerge });
|
|
119
|
+
assert.equal(startWorkflow(id).ok, true);
|
|
120
|
+
return id;
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
status: "reviewing",
|
|
125
|
+
setup: async (autoMerge) => {
|
|
126
|
+
const id = newIssue({ autoMerge });
|
|
127
|
+
startWorkflow(id);
|
|
128
|
+
await complete(id, cleanHandoff);
|
|
129
|
+
return id;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
status: "repairing",
|
|
134
|
+
setup: async (autoMerge) => {
|
|
135
|
+
const id = newIssue({ autoMerge });
|
|
136
|
+
startWorkflow(id);
|
|
137
|
+
await complete(id, cleanHandoff);
|
|
138
|
+
await complete(id, { kind: "verdict", result: review("changes_requested") });
|
|
139
|
+
return id;
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
status: "needs_human",
|
|
144
|
+
setup: async (autoMerge) => {
|
|
145
|
+
// Review-round budget of 1: changes_requested parks at attempts_exhausted, which a
|
|
146
|
+
// human resume continues in the same workflow.
|
|
147
|
+
const id = newIssue({ autoMerge, maxReviewRounds: 1 });
|
|
148
|
+
startWorkflow(id);
|
|
149
|
+
await complete(id, cleanHandoff);
|
|
150
|
+
await complete(id, { kind: "verdict", result: review("changes_requested") });
|
|
151
|
+
return id;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
test("NOT-490: a queued ready issue accepts an autoMerge-only update with queue entry, wait reason and other fields untouched", async () => {
|
|
156
|
+
const app = await buildApp();
|
|
157
|
+
const first = newIssue({ autoMerge: true, title: "Ahead" });
|
|
158
|
+
const id = newIssue({ autoMerge: false });
|
|
159
|
+
const behind = newIssue({ autoMerge: true, title: "Behind" });
|
|
160
|
+
for (const issueId of [first, id, behind])
|
|
161
|
+
enqueueIssue(issueId);
|
|
162
|
+
const queueBefore = listQueuedEntries();
|
|
163
|
+
const statusBefore = queueStatusForIssue(id);
|
|
164
|
+
const entryBefore = getQueuedEntryForIssue(id);
|
|
165
|
+
const issueBefore = frozenView(id);
|
|
166
|
+
assert.ok(entryBefore, "precondition: queued");
|
|
167
|
+
const detailBefore = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
168
|
+
assert.deepEqual(detailBefore.autoMergePolicy, { editable: true, reason: null });
|
|
169
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true, updatedBy: "operator-1" } });
|
|
170
|
+
assert.equal(res.statusCode, 200);
|
|
171
|
+
assert.equal(res.json().autoMerge, true);
|
|
172
|
+
assert.equal(getIssue(id).autoMerge, true);
|
|
173
|
+
assert.equal(getIssue(id).status, "ready");
|
|
174
|
+
assert.deepEqual(frozenView(id), issueBefore, "every other issue field is unchanged");
|
|
175
|
+
assert.deepEqual(getQueuedEntryForIssue(id), entryBefore, "same queue entry");
|
|
176
|
+
assert.deepEqual(listQueuedEntries(), queueBefore, "queue order and entries are untouched");
|
|
177
|
+
assert.deepEqual(queueStatusForIssue(id), statusBefore, "same position and wait reason");
|
|
178
|
+
const events = policyEvents(id);
|
|
179
|
+
assert.equal(events.length, 1);
|
|
180
|
+
assert.equal(events[0].actorType, "human");
|
|
181
|
+
assert.deepEqual(JSON.parse(events[0].payloadJson), { fromAutoMerge: false, toAutoMerge: true, actor: "operator-1" });
|
|
182
|
+
// A queued issue also refuses coordinator-owned fields smuggled beside the policy.
|
|
183
|
+
for (const mixed of [{ autoMerge: false, branch: "x" }, { autoMerge: false, status: "done" }, { autoMerge: false, headSha: "s" }]) {
|
|
184
|
+
const refused = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: mixed });
|
|
185
|
+
assert.equal(refused.statusCode, 409, `mixed ${JSON.stringify(mixed)} must be refused`);
|
|
186
|
+
assert.equal(getIssue(id).autoMerge, true, "no partial write");
|
|
187
|
+
assert.equal(policyEvents(id).length, 1);
|
|
188
|
+
assert.deepEqual(listQueuedEntries(), queueBefore);
|
|
189
|
+
}
|
|
190
|
+
// Idempotent no-op: 200, no event.
|
|
191
|
+
const again = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } });
|
|
192
|
+
assert.equal(again.statusCode, 200);
|
|
193
|
+
assert.equal(policyEvents(id).length, 1, "a no-op update records nothing");
|
|
194
|
+
// And back off, attributed to the default actor.
|
|
195
|
+
await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } });
|
|
196
|
+
const after = policyEvents(id);
|
|
197
|
+
assert.equal(after.length, 2);
|
|
198
|
+
assert.deepEqual(JSON.parse(after[1].payloadJson), { fromAutoMerge: true, toAutoMerge: false, actor: "human" });
|
|
199
|
+
assert.deepEqual(listQueuedEntries(), queueBefore);
|
|
200
|
+
await app.close();
|
|
201
|
+
});
|
|
202
|
+
for (const { status, setup } of ELIGIBLE) {
|
|
203
|
+
test(`NOT-490: an active ${status} issue accepts an autoMerge-only update; a mixed patch is 409 with no partial write`, async () => {
|
|
204
|
+
const app = await buildApp();
|
|
205
|
+
const id = await setup(false);
|
|
206
|
+
assert.equal(getIssue(id).status, status, "precondition");
|
|
207
|
+
const snapshotBefore = JSON.stringify(getTaskSnapshot(getIssue(id)));
|
|
208
|
+
const issueBefore = frozenView(id);
|
|
209
|
+
const eventsBefore = listWorkflowEventsForIssue(id).length;
|
|
210
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
211
|
+
assert.equal(detail.autoMergePolicy.editable, true);
|
|
212
|
+
// Mixed patches that carry a frozen field fail closed and write nothing.
|
|
213
|
+
for (const mixed of [
|
|
214
|
+
{ autoMerge: true, title: "New title" },
|
|
215
|
+
{ autoMerge: true, repo: "acme/other" },
|
|
216
|
+
{ autoMerge: true, developerAgentId: BUILTIN_AGENT_CODEX_ID },
|
|
217
|
+
{ autoMerge: true, maxReviewRounds: 9 },
|
|
218
|
+
// Coordinator-owned fields the schema does not declare: a non-strict parse would
|
|
219
|
+
// strip them, so the route must refuse on the raw body keys.
|
|
220
|
+
{ autoMerge: true, branch: "other" },
|
|
221
|
+
{ autoMerge: true, status: "done" },
|
|
222
|
+
{ autoMerge: true, headSha: "deadbeef" },
|
|
223
|
+
{ autoMerge: true, prNumber: 7 },
|
|
224
|
+
{ autoMerge: true, prUrl: "https://gh/pr/7" },
|
|
225
|
+
{ autoMerge: true, queuePosition: 0 },
|
|
226
|
+
]) {
|
|
227
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: mixed });
|
|
228
|
+
assert.equal(res.statusCode, 409, `mixed ${JSON.stringify(mixed)} must be refused`);
|
|
229
|
+
assert.equal(getIssue(id).autoMerge, false, "no partial write of the policy");
|
|
230
|
+
assert.deepEqual(frozenView(id), issueBefore);
|
|
231
|
+
assert.equal(listWorkflowEventsForIssue(id).length, eventsBefore);
|
|
232
|
+
}
|
|
233
|
+
const ok = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } });
|
|
234
|
+
assert.equal(ok.statusCode, 200);
|
|
235
|
+
assert.equal(getIssue(id).autoMerge, true);
|
|
236
|
+
assert.equal(getIssue(id).status, status, "status is untouched");
|
|
237
|
+
assert.deepEqual(frozenView(id), issueBefore, "frozen inputs, branch, PR, rounds untouched");
|
|
238
|
+
assert.equal(JSON.stringify(getTaskSnapshot(getIssue(id))), snapshotBefore, "frozen task snapshot untouched");
|
|
239
|
+
const events = policyEvents(id);
|
|
240
|
+
assert.equal(events.length, 1);
|
|
241
|
+
assert.deepEqual(JSON.parse(events[0].payloadJson), { fromAutoMerge: false, toAutoMerge: true, actor: "human" });
|
|
242
|
+
assert.ok(events[0].workflowInstanceId, "the event is scoped to the live workflow");
|
|
243
|
+
const noop = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } });
|
|
244
|
+
assert.equal(noop.statusCode, 200);
|
|
245
|
+
assert.equal(policyEvents(id).length, 1);
|
|
246
|
+
await app.close();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
test("NOT-490: a policy update committed before reviewer routing turns approval into the guarded auto-merge", async () => {
|
|
250
|
+
const app = await buildApp();
|
|
251
|
+
const id = newIssue({ autoMerge: false });
|
|
252
|
+
startWorkflow(id);
|
|
253
|
+
await complete(id, cleanHandoff);
|
|
254
|
+
assert.equal((await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } })).statusCode, 200);
|
|
255
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
256
|
+
assert.equal(getIssue(id).status, "done");
|
|
257
|
+
assert.equal(mergeCalls, 1, "exactly one merge");
|
|
258
|
+
assert.equal(listHumanActionsForIssue(id).filter((a) => a.actionType === "final_review").length, 0, "no final_review action");
|
|
259
|
+
assert.equal(listWorkflowEventsForIssue(id).filter((e) => e.type === "final_review.requested").length, 0);
|
|
260
|
+
await app.close();
|
|
261
|
+
});
|
|
262
|
+
test("NOT-490: a committed false policy routes approval to final_review and never merges", async () => {
|
|
263
|
+
const app = await buildApp();
|
|
264
|
+
const id = newIssue({ autoMerge: true });
|
|
265
|
+
startWorkflow(id);
|
|
266
|
+
await complete(id, cleanHandoff);
|
|
267
|
+
assert.equal((await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } })).statusCode, 200);
|
|
268
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
269
|
+
assert.equal(getIssue(id).status, "final_review");
|
|
270
|
+
assert.equal(getIssue(id).currentOwner, "human");
|
|
271
|
+
assert.equal(mergeCalls, 0);
|
|
272
|
+
assert.equal(listHumanActionsForIssue(id).filter((a) => a.actionType === "final_review").length, 1);
|
|
273
|
+
await app.close();
|
|
274
|
+
});
|
|
275
|
+
test("NOT-490: a reviewer decision committed first wins — the later update is 409 and creates no second effect", async () => {
|
|
276
|
+
const app = await buildApp();
|
|
277
|
+
const id = newIssue({ autoMerge: false });
|
|
278
|
+
startWorkflow(id);
|
|
279
|
+
await complete(id, cleanHandoff);
|
|
280
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
281
|
+
assert.equal(getIssue(id).status, "final_review");
|
|
282
|
+
const eventsBefore = listWorkflowEventsForIssue(id).length;
|
|
283
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
284
|
+
assert.equal(detail.autoMergePolicy.editable, false);
|
|
285
|
+
assert.match(detail.autoMergePolicy.reason ?? "", /Merge/);
|
|
286
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } });
|
|
287
|
+
assert.equal(res.statusCode, 409);
|
|
288
|
+
assert.match(res.json().error, /no longer be changed/);
|
|
289
|
+
assert.equal(getIssue(id).autoMerge, false);
|
|
290
|
+
assert.equal(getIssue(id).status, "final_review");
|
|
291
|
+
assert.equal(mergeCalls, 0, "no auto-merge effect");
|
|
292
|
+
assert.equal(listHumanActionsForIssue(id).filter((a) => a.actionType === "final_review").length, 1, "one final_review action");
|
|
293
|
+
assert.equal(listWorkflowEventsForIssue(id).length, eventsBefore, "no audit event for the losing update");
|
|
294
|
+
await app.close();
|
|
295
|
+
});
|
|
296
|
+
test("NOT-490: while auto-merge finalization is in flight the policy is locked; the merge completes once", async () => {
|
|
297
|
+
const app = await buildApp();
|
|
298
|
+
let releaseMerge;
|
|
299
|
+
const mergeGate = new Promise((resolve) => (releaseMerge = resolve));
|
|
300
|
+
setMergePrForTests(async () => {
|
|
301
|
+
mergeCalls++;
|
|
302
|
+
await mergeGate;
|
|
303
|
+
return { ok: true };
|
|
304
|
+
});
|
|
305
|
+
const id = newIssue({ autoMerge: true });
|
|
306
|
+
startWorkflow(id);
|
|
307
|
+
await complete(id, cleanHandoff);
|
|
308
|
+
const approval = complete(id, { kind: "verdict", result: review("approved") });
|
|
309
|
+
// The routing transaction commits before the async merge — wait for the park.
|
|
310
|
+
for (let i = 0; i < 200 && getIssue(id).status !== "final_review"; i++) {
|
|
311
|
+
await new Promise((r) => setImmediate(r));
|
|
312
|
+
}
|
|
313
|
+
assert.equal(getIssue(id).status, "final_review");
|
|
314
|
+
assert.equal(getIssue(id).currentOwner, "system");
|
|
315
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
316
|
+
assert.equal(detail.autoMergePolicy.editable, false);
|
|
317
|
+
assert.match(detail.autoMergePolicy.reason ?? "", /in flight/);
|
|
318
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } });
|
|
319
|
+
assert.equal(res.statusCode, 409);
|
|
320
|
+
assert.equal(getIssue(id).autoMerge, true);
|
|
321
|
+
releaseMerge();
|
|
322
|
+
await approval;
|
|
323
|
+
assert.equal(getIssue(id).status, "done");
|
|
324
|
+
assert.equal(mergeCalls, 1);
|
|
325
|
+
assert.equal(listHumanActionsForIssue(id).length, 0, "no human action created");
|
|
326
|
+
assert.equal(policyEvents(id).length, 0);
|
|
327
|
+
await app.close();
|
|
328
|
+
});
|
|
329
|
+
test("NOT-490: an operator-verification park after approval keeps the committed policy locked", async () => {
|
|
330
|
+
const app = await buildApp();
|
|
331
|
+
const id = newIssue({
|
|
332
|
+
autoMerge: true,
|
|
333
|
+
acceptanceCriteria: "- [ ] Operator signs in with SSO [operator]",
|
|
334
|
+
});
|
|
335
|
+
startWorkflow(id);
|
|
336
|
+
await complete(id, cleanHandoff);
|
|
337
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
338
|
+
assert.equal(getIssue(id).status, "needs_human");
|
|
339
|
+
const gate = listHumanActionsForIssue(id).filter((a) => a.status === "open");
|
|
340
|
+
assert.deepEqual(gate.map((a) => a.actionType), ["operator_verification"], "precondition: parked at the gate");
|
|
341
|
+
assert.equal(mergeCalls, 0);
|
|
342
|
+
const eventsBefore = listWorkflowEventsForIssue(id).length;
|
|
343
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
344
|
+
assert.equal(detail.autoMergePolicy.editable, false);
|
|
345
|
+
assert.match(detail.autoMergePolicy.reason ?? "", /operator verification/);
|
|
346
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } });
|
|
347
|
+
assert.equal(res.statusCode, 409);
|
|
348
|
+
assert.match(res.json().error, /no longer be changed/);
|
|
349
|
+
assert.equal(getIssue(id).autoMerge, true, "committed policy is unchanged");
|
|
350
|
+
assert.equal(listWorkflowEventsForIssue(id).length, eventsBefore, "no audit event for the refused update");
|
|
351
|
+
assert.equal(policyEvents(id).length, 0);
|
|
352
|
+
await app.close();
|
|
353
|
+
});
|
|
354
|
+
test("NOT-490: a merge-failure park and terminal issues are read-only with an explanation", async () => {
|
|
355
|
+
const app = await buildApp();
|
|
356
|
+
setMergePrForTests(async () => ({ ok: false, reason: "protected branch" }));
|
|
357
|
+
const id = newIssue({ autoMerge: true });
|
|
358
|
+
startWorkflow(id);
|
|
359
|
+
await complete(id, cleanHandoff);
|
|
360
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
361
|
+
assert.equal(getIssue(id).status, "needs_human");
|
|
362
|
+
const parked = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
363
|
+
assert.equal(parked.autoMergePolicy.editable, false);
|
|
364
|
+
assert.match(parked.autoMergePolicy.reason ?? "", /merge/);
|
|
365
|
+
assert.equal((await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } })).statusCode, 409);
|
|
366
|
+
assert.equal(getIssue(id).autoMerge, true);
|
|
367
|
+
setMergePrForTests(async () => ({ ok: true }));
|
|
368
|
+
const done = newIssue({ autoMerge: true, title: "Done" });
|
|
369
|
+
startWorkflow(done);
|
|
370
|
+
await complete(done, cleanHandoff);
|
|
371
|
+
await complete(done, { kind: "verdict", result: review("approved") });
|
|
372
|
+
assert.equal(getIssue(done).status, "done");
|
|
373
|
+
const doneDetail = (await app.inject({ method: "GET", url: `/api/issues/${done}` })).json();
|
|
374
|
+
assert.deepEqual(doneDetail.autoMergePolicy, { editable: false, reason: "The issue is done." });
|
|
375
|
+
assert.equal((await app.inject({ method: "PATCH", url: `/api/issues/${done}`, payload: { autoMerge: false } })).statusCode, 409);
|
|
376
|
+
assert.equal(policyEvents(done).length, 0);
|
|
377
|
+
await app.close();
|
|
378
|
+
});
|
|
@@ -36,7 +36,7 @@ async function buildApp() {
|
|
|
36
36
|
return app;
|
|
37
37
|
}
|
|
38
38
|
function seedIssueAwaitingFinalReview() {
|
|
39
|
-
const issue = createIssue({ title: "Awaiting review", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
39
|
+
const issue = createIssue({ title: "Awaiting review", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, maxReviewRounds: 3, maxInfraAttempts: 3, autoMerge: false, source: "manual" });
|
|
40
40
|
transitionIssue(issue.id, "developing");
|
|
41
41
|
transitionIssue(issue.id, "reviewing");
|
|
42
42
|
transitionIssue(issue.id, "final_review", { currentOwner: "human" });
|
|
@@ -48,7 +48,7 @@ function seedIssueAwaitingFinalReview() {
|
|
|
48
48
|
* (unlike seedIssueAwaitingFinalReview's hand-crafted transitions) — needed to exercise
|
|
49
49
|
* resolveHumanActionAndAdvance's actual transactional path through the route. */
|
|
50
50
|
async function seedRealIssueAwaitingFinalReview() {
|
|
51
|
-
const issue = createIssue({ title: "Real workflow", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
51
|
+
const issue = createIssue({ title: "Real workflow", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, autoMerge: false, source: "manual" });
|
|
52
52
|
const start = startWorkflow(issue.id);
|
|
53
53
|
assert.equal(start.ok, true);
|
|
54
54
|
const devItem = claimWorkItem(`route-test-${issue.id}-dev`, { leaseMs: 60_000 });
|
|
@@ -286,7 +286,7 @@ test("POST resolve 400s on a choice not valid for reflection_interaction_require
|
|
|
286
286
|
});
|
|
287
287
|
// NOT-272: seeds.
|
|
288
288
|
function seedPreStartScopeDecision() {
|
|
289
|
-
const issue = createIssue({ title: "Scope gated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
289
|
+
const issue = createIssue({ title: "Scope gated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, autoMerge: false, source: "manual" });
|
|
290
290
|
const action = createHumanAction({
|
|
291
291
|
issueId: issue.id,
|
|
292
292
|
actionType: "product_scope_decision",
|
|
@@ -299,7 +299,7 @@ function seedPreStartScopeDecision() {
|
|
|
299
299
|
/** Drives a real issue to a mid-workflow product_scope_decision via a reviewer
|
|
300
300
|
* escalate verdict carrying a productScopeQuestion (NOT-150 routing). */
|
|
301
301
|
async function seedMidWorkflowScopeDecision() {
|
|
302
|
-
const issue = createIssue({ title: "Scope escalated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
302
|
+
const issue = createIssue({ title: "Scope escalated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, autoMerge: false, source: "manual" });
|
|
303
303
|
const start = startWorkflow(issue.id);
|
|
304
304
|
assert.equal(start.ok, true);
|
|
305
305
|
const devItem = claimWorkItem(`scope-note-test-${issue.id}-dev`, { leaseMs: 60_000 });
|