agent-dealer 1.0.0 → 1.0.1
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/managed-repo.js +11 -0
- package/bundle/server/dist/coordinator/auto-merge.integration.test.js +55 -4
- package/bundle/server/dist/coordinator/auto-merge.js +72 -4
- package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +45 -3
- package/bundle/server/dist/coordinator/commands.js +17 -11
- package/bundle/server/dist/coordinator/commands.test.js +21 -4
- package/bundle/server/dist/coordinator/human-resolution.js +5 -2
- package/bundle/server/dist/coordinator/human-resolution.test.js +13 -1
- package/bundle/server/dist/coordinator/prompts.js +21 -13
- package/bundle/server/dist/coordinator/prompts.test.js +21 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +56 -11
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +67 -9
- package/bundle/server/dist/coordinator/reviewer-result.js +49 -1
- package/bundle/server/dist/coordinator/reviewer-result.test.js +38 -0
- package/bundle/server/dist/coordinator/routing.js +15 -4
- package/bundle/server/dist/coordinator/routing.test.js +17 -2
- package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
- package/bundle/server/dist/db/migrate-to-issues.test.js +1 -0
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +3 -2
- package/bundle/server/dist/routes/human-actions.test.js +2 -0
- package/bundle/server/package.json +2 -2
- package/bundle/shared/package.json +1 -1
- package/dist/action.test.js +2 -2
- package/dist/install.js +1 -1
- package/dist/lifecycle.contract.test.js +2 -2
- package/dist/setup.js +5 -1
- package/package.json +1 -1
|
@@ -26,6 +26,17 @@ async function git(cwd, args) {
|
|
|
26
26
|
export function managedRepoPath(identity) {
|
|
27
27
|
return path.join(getExecutionRoot(), "repos", identity);
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Test helper: mark a managed identity as having a local clone so merge cwd resolution
|
|
31
|
+
* (and similar) does not fail closed. Does not create a real git repo.
|
|
32
|
+
*/
|
|
33
|
+
export function stubManagedCloneForTests(repoInput) {
|
|
34
|
+
const classified = classifyIssueRepo(repoInput);
|
|
35
|
+
if (classified.kind === "managed") {
|
|
36
|
+
fs.mkdirSync(path.join(classified.repoPath, ".git"), { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
return classified.repoPath;
|
|
39
|
+
}
|
|
29
40
|
export function managedWorktreePath(identity, sessionId, role) {
|
|
30
41
|
return path.join(getExecutionRoot(), "worktrees", identity, `${sessionId}-${role}`);
|
|
31
42
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// NOT-102 acceptance: auto-merge on/off, merge failure escalation, recent repos.
|
|
2
|
+
// NOT-151: portable github.com/… issue.repo must resolve to managed clone cwd, not the identity.
|
|
2
3
|
import { test, before, beforeEach, afterEach } from "node:test";
|
|
3
4
|
import assert from "node:assert/strict";
|
|
4
5
|
import fs from "node:fs";
|
|
5
6
|
import os from "node:os";
|
|
6
7
|
import path from "node:path";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
7
9
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not102-"));
|
|
8
10
|
const { migrate, getDb } = await import("../db/index.js");
|
|
9
11
|
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
@@ -14,7 +16,23 @@ const { claimWorkItem, listWorkItemsForIssue } = await import("../repository/wor
|
|
|
14
16
|
const { startWorkflow, applyCompletion } = await import("./commands.js");
|
|
15
17
|
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
16
18
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
17
|
-
|
|
19
|
+
const { managedRepoPath } = await import("../adapters/managed-repo.js");
|
|
20
|
+
/** Real local checkout so resolveAutoMergeCwd accepts the default legacy repo. */
|
|
21
|
+
let fixtureRepo = "";
|
|
22
|
+
function initFixtureRepo() {
|
|
23
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not102-repo-"));
|
|
24
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: dir });
|
|
25
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
|
|
26
|
+
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
|
|
27
|
+
fs.writeFileSync(path.join(dir, "README.md"), "hi\n");
|
|
28
|
+
execFileSync("git", ["add", "."], { cwd: dir });
|
|
29
|
+
execFileSync("git", ["commit", "-m", "init"], { cwd: dir });
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
before(() => {
|
|
33
|
+
migrate();
|
|
34
|
+
fixtureRepo = initFixtureRepo();
|
|
35
|
+
});
|
|
18
36
|
beforeEach(() => {
|
|
19
37
|
getDb().exec(`
|
|
20
38
|
DELETE FROM work_items;
|
|
@@ -40,7 +58,7 @@ function newIssue(opts = {}) {
|
|
|
40
58
|
title: "Coordinate me",
|
|
41
59
|
description: "d",
|
|
42
60
|
acceptanceCriteria: "It works",
|
|
43
|
-
repo: opts.repo ??
|
|
61
|
+
repo: opts.repo ?? fixtureRepo,
|
|
44
62
|
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
45
63
|
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
46
64
|
baseBranch: "main",
|
|
@@ -110,7 +128,7 @@ test("autoMerge off: final_review complete undrafts+merges then marks done", asy
|
|
|
110
128
|
assert.equal(resolved.instanceCompleted, true);
|
|
111
129
|
assert.equal(resolved.triggerReflect, true);
|
|
112
130
|
}
|
|
113
|
-
assert.deepEqual(calls, [{ cwd:
|
|
131
|
+
assert.deepEqual(calls, [{ cwd: fixtureRepo, number: 42 }]);
|
|
114
132
|
assert.equal(getIssue(issueId).status, "done");
|
|
115
133
|
assert.equal(getActiveWorkflowInstance(issueId), null);
|
|
116
134
|
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
|
|
@@ -153,9 +171,42 @@ test("autoMerge on: reviewer approve merges PR, marks done, skips final_review h
|
|
|
153
171
|
assert.equal(issue.status, "done");
|
|
154
172
|
assert.equal(getActiveWorkflowInstance(issueId), null);
|
|
155
173
|
assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.actionType === "final_review").length, 0);
|
|
156
|
-
assert.deepEqual(calls, [{ cwd:
|
|
174
|
+
assert.deepEqual(calls, [{ cwd: fixtureRepo, number: 42 }]);
|
|
157
175
|
assert.ok(listWorkflowEventsForIssue(issueId).some((e) => e.type === "issue.completed"));
|
|
158
176
|
});
|
|
177
|
+
test("NOT-151: portable github.com repo merges via managed clone path, not identity string", async () => {
|
|
178
|
+
const identity = "github.com/not-so-fat/agent-dealer";
|
|
179
|
+
const managed = managedRepoPath(identity);
|
|
180
|
+
fs.mkdirSync(path.join(managed, ".git"), { recursive: true });
|
|
181
|
+
const calls = [];
|
|
182
|
+
setMergePrForTests(async (opts) => {
|
|
183
|
+
calls.push(opts);
|
|
184
|
+
return { ok: true };
|
|
185
|
+
});
|
|
186
|
+
const issueId = newIssue({ autoMerge: true, repo: identity });
|
|
187
|
+
startWorkflow(issueId);
|
|
188
|
+
await complete(issueId, cleanHandoff);
|
|
189
|
+
await complete(issueId, { kind: "verdict", result: okReview("approved") });
|
|
190
|
+
assert.equal(getIssue(issueId).status, "done");
|
|
191
|
+
assert.deepEqual(calls, [{ cwd: managed, number: 42 }]);
|
|
192
|
+
assert.notEqual(calls[0]?.cwd, identity);
|
|
193
|
+
});
|
|
194
|
+
test("NOT-151: missing managed clone escalates clearly without spawn gh ENOENT", async () => {
|
|
195
|
+
let mergeCalled = false;
|
|
196
|
+
setMergePrForTests(async () => {
|
|
197
|
+
mergeCalled = true;
|
|
198
|
+
return { ok: true };
|
|
199
|
+
});
|
|
200
|
+
const issueId = newIssue({ autoMerge: true, repo: "github.com/missing/no-clone" });
|
|
201
|
+
startWorkflow(issueId);
|
|
202
|
+
await complete(issueId, cleanHandoff);
|
|
203
|
+
await complete(issueId, { kind: "verdict", result: okReview("approved") });
|
|
204
|
+
assert.equal(mergeCalled, false);
|
|
205
|
+
const issue = getIssue(issueId);
|
|
206
|
+
assert.equal(issue.status, "needs_human");
|
|
207
|
+
assert.match(issue.currentIntent ?? "", /Managed clone missing/);
|
|
208
|
+
assert.doesNotMatch(issue.currentIntent ?? "", /ENOENT/);
|
|
209
|
+
});
|
|
159
210
|
test("autoMerge on: merge failure escalates to policy_escalation; issue not left half-done as final_review", async () => {
|
|
160
211
|
setMergePrForTests(async () => ({ ok: false, reason: "required status checks failed" }));
|
|
161
212
|
const issueId = newIssue({ autoMerge: true });
|
|
@@ -15,8 +15,15 @@
|
|
|
15
15
|
//
|
|
16
16
|
// Concurrency: finalizeAutoMerge is single-flight per issueId (in-process). Success and
|
|
17
17
|
// escalate txns are also defensive if a racer already wrote done / needs_human.
|
|
18
|
+
//
|
|
19
|
+
// NOT-151: issue.repo is a portable GitHub identity after NOT-149 — never pass it to
|
|
20
|
+
// execFile as cwd (Node reports that as misleading `spawn gh ENOENT`). Resolve via
|
|
21
|
+
// classifyIssueRepo → managed/legacy local path first.
|
|
18
22
|
import { execFile } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
19
25
|
import { promisify } from "node:util";
|
|
26
|
+
import { classifyIssueRepo } from "../adapters/managed-repo.js";
|
|
20
27
|
import { getDb } from "../db/index.js";
|
|
21
28
|
import { getIssue, listIssues, transitionIssue } from "../repository/issues.js";
|
|
22
29
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
|
|
@@ -27,17 +34,69 @@ export const GH_MERGE_TIMEOUT_MS = 20_000;
|
|
|
27
34
|
/** Must match projection.ts's auto_merge currentIntent — recovery keys off this string. */
|
|
28
35
|
export const AUTO_MERGE_INTENT = "Auto-merging approved PR";
|
|
29
36
|
const ALREADY_MERGED = /already (been )?merged|pull request is not mergeable:.*merged/i;
|
|
37
|
+
/**
|
|
38
|
+
* Map issue.repo (portable identity or legacy local path) to a real filesystem cwd for
|
|
39
|
+
* `gh pr merge`. Missing managed clones fail closed with a clear reason — never hand the
|
|
40
|
+
* identity string to execFile.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveAutoMergeCwd(repoField) {
|
|
43
|
+
try {
|
|
44
|
+
const classified = classifyIssueRepo(repoField);
|
|
45
|
+
const cwd = classified.repoPath;
|
|
46
|
+
if (classified.kind === "managed") {
|
|
47
|
+
const hasGit = fs.existsSync(path.join(cwd, ".git")) || fs.existsSync(path.join(cwd, "HEAD"));
|
|
48
|
+
if (!hasGit) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: `Managed clone missing for ${classified.identity} (${cwd}). Re-run the developer step so Dealer can clone it, or restore the checkout under execution/repos.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, cwd };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
30
64
|
/** True when Node killed the child for exceeding `timeout` (promisify(execFile)). */
|
|
31
65
|
export function isGhTimeoutError(err) {
|
|
32
66
|
const e = err;
|
|
33
67
|
return Boolean(e.killed || e.signal === "SIGTERM");
|
|
34
68
|
}
|
|
69
|
+
/** True when Node failed to spawn (missing binary *or* missing cwd — both surface ENOENT). */
|
|
70
|
+
export function isGhSpawnEnoent(err) {
|
|
71
|
+
const e = err;
|
|
72
|
+
if (e.code === "ENOENT")
|
|
73
|
+
return true;
|
|
74
|
+
const msg = (e.message ?? "").toLowerCase();
|
|
75
|
+
return msg.includes("spawn") && msg.includes("enoent");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Distinguish "cwd is not a real directory" from "gh missing on PATH" — both look like
|
|
79
|
+
* `spawn gh ENOENT` from Node. Prefer checking the cwd on disk over trusting the message.
|
|
80
|
+
*/
|
|
81
|
+
export function ghSpawnEnoentReason(err, cwd) {
|
|
82
|
+
if (!isGhSpawnEnoent(err))
|
|
83
|
+
return null;
|
|
84
|
+
if (!fs.existsSync(cwd)) {
|
|
85
|
+
return `invalid merge cwd (${cwd}) — path does not exist (portable issue.repo must not be used as cwd)`;
|
|
86
|
+
}
|
|
87
|
+
return "gh not on PATH — install GitHub CLI (`gh`) and ensure the daemon can see it";
|
|
88
|
+
}
|
|
35
89
|
/** Map an execFile failure to a stable reason string (timeout vs stderr/stdout). */
|
|
36
|
-
export function ghErrorReason(err, fallback) {
|
|
90
|
+
export function ghErrorReason(err, fallback, cwd) {
|
|
37
91
|
const e = err;
|
|
38
92
|
if (isGhTimeoutError(err)) {
|
|
39
93
|
return `gh timed out after ${GH_MERGE_TIMEOUT_MS}ms`;
|
|
40
94
|
}
|
|
95
|
+
if (cwd) {
|
|
96
|
+
const spawnReason = ghSpawnEnoentReason(err, cwd);
|
|
97
|
+
if (spawnReason)
|
|
98
|
+
return spawnReason;
|
|
99
|
+
}
|
|
41
100
|
return (e.stderr || e.stdout || e.message || fallback).trim() || fallback;
|
|
42
101
|
}
|
|
43
102
|
/** Production: mark draft ready (ignore if already), then squash-merge — async + timed. */
|
|
@@ -52,7 +111,12 @@ export const realMergePr = async ({ cwd, number }) => {
|
|
|
52
111
|
catch (err) {
|
|
53
112
|
// Timeout is a hang, not "already ready" — fail closed so we do not burn another 20s on merge.
|
|
54
113
|
if (isGhTimeoutError(err)) {
|
|
55
|
-
return { ok: false, reason: ghErrorReason(err, "gh pr ready failed") };
|
|
114
|
+
return { ok: false, reason: ghErrorReason(err, "gh pr ready failed", cwd) };
|
|
115
|
+
}
|
|
116
|
+
// Bad cwd / missing gh on the ready step would also fail merge — surface now.
|
|
117
|
+
const spawnReason = ghSpawnEnoentReason(err, cwd);
|
|
118
|
+
if (spawnReason) {
|
|
119
|
+
return { ok: false, reason: spawnReason };
|
|
56
120
|
}
|
|
57
121
|
// Already ready / not a draft — ignore; merge is the authority.
|
|
58
122
|
}
|
|
@@ -65,7 +129,7 @@ export const realMergePr = async ({ cwd, number }) => {
|
|
|
65
129
|
return { ok: true };
|
|
66
130
|
}
|
|
67
131
|
catch (err) {
|
|
68
|
-
const reason = ghErrorReason(err, "gh pr merge failed");
|
|
132
|
+
const reason = ghErrorReason(err, "gh pr merge failed", cwd);
|
|
69
133
|
// Crash between a successful merge and the done-transition: retry must not escalate.
|
|
70
134
|
if (ALREADY_MERGED.test(reason))
|
|
71
135
|
return { ok: true };
|
|
@@ -118,7 +182,11 @@ async function finalizeAutoMergeOnce(issueId) {
|
|
|
118
182
|
if (issue.prNumber == null) {
|
|
119
183
|
return escalateMergeFailure(issue, instance.id, "Reviewer approved but the issue has no PR number to merge.");
|
|
120
184
|
}
|
|
121
|
-
const
|
|
185
|
+
const resolvedCwd = resolveAutoMergeCwd(issue.repo);
|
|
186
|
+
if (!resolvedCwd.ok) {
|
|
187
|
+
return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${resolvedCwd.reason}`);
|
|
188
|
+
}
|
|
189
|
+
const merge = await mergePrImpl({ cwd: resolvedCwd.cwd, number: issue.prNumber });
|
|
122
190
|
if (!merge.ok) {
|
|
123
191
|
return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${merge.reason}`);
|
|
124
192
|
}
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
// Unit coverage for bounded gh timeout classification (NOT-102
|
|
2
|
-
import { test } from "node:test";
|
|
1
|
+
// Unit coverage for bounded gh timeout classification (NOT-102) and NOT-151 spawn ENOENT mapping.
|
|
2
|
+
import { test, before } from "node:test";
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
|
-
import
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not151-unit-"));
|
|
9
|
+
const { GH_MERGE_TIMEOUT_MS, ghErrorReason, ghSpawnEnoentReason, isGhTimeoutError, resolveAutoMergeCwd, } = await import("./auto-merge.js");
|
|
10
|
+
const { managedRepoPath } = await import("../adapters/managed-repo.js");
|
|
11
|
+
before(async () => {
|
|
12
|
+
const { migrate } = await import("../db/index.js");
|
|
13
|
+
migrate();
|
|
14
|
+
});
|
|
5
15
|
test("isGhTimeoutError detects killed / SIGTERM from execFile timeout", () => {
|
|
6
16
|
assert.equal(isGhTimeoutError({ killed: true }), true);
|
|
7
17
|
assert.equal(isGhTimeoutError({ signal: "SIGTERM" }), true);
|
|
@@ -12,3 +22,35 @@ test("ghErrorReason maps timeout before stderr", () => {
|
|
|
12
22
|
assert.equal(ghErrorReason({ stderr: " checks failed \n" }, "fallback"), "checks failed");
|
|
13
23
|
assert.equal(ghErrorReason({}, "gh pr merge failed"), "gh pr merge failed");
|
|
14
24
|
});
|
|
25
|
+
test("NOT-151: ghSpawnEnoentReason distinguishes bad cwd from missing gh", () => {
|
|
26
|
+
const missing = path.join(os.tmpdir(), "dealer-missing-merge-cwd-xyz");
|
|
27
|
+
assert.match(ghSpawnEnoentReason({ code: "ENOENT", message: "spawn gh ENOENT" }, missing) ?? "", /invalid merge cwd/);
|
|
28
|
+
const existing = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-merge-cwd-"));
|
|
29
|
+
assert.match(ghSpawnEnoentReason({ code: "ENOENT", message: "spawn gh ENOENT" }, existing) ?? "", /gh not on PATH/);
|
|
30
|
+
assert.equal(ghSpawnEnoentReason({ stderr: "checks failed" }, existing), null);
|
|
31
|
+
});
|
|
32
|
+
test("NOT-151: resolveAutoMergeCwd maps portable identity to managed path when clone exists", () => {
|
|
33
|
+
const identity = "github.com/not-so-fat/agent-dealer";
|
|
34
|
+
const managed = managedRepoPath(identity);
|
|
35
|
+
fs.mkdirSync(path.join(managed, ".git"), { recursive: true });
|
|
36
|
+
const ok = resolveAutoMergeCwd(identity);
|
|
37
|
+
assert.equal(ok.ok, true);
|
|
38
|
+
if (ok.ok)
|
|
39
|
+
assert.equal(ok.cwd, managed);
|
|
40
|
+
});
|
|
41
|
+
test("NOT-151: resolveAutoMergeCwd fails closed when managed clone is missing", () => {
|
|
42
|
+
const missing = resolveAutoMergeCwd("github.com/missing/not-cloned");
|
|
43
|
+
assert.equal(missing.ok, false);
|
|
44
|
+
if (!missing.ok) {
|
|
45
|
+
assert.match(missing.reason, /Managed clone missing/);
|
|
46
|
+
assert.doesNotMatch(missing.reason, /ENOENT/);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
test("NOT-151: resolveAutoMergeCwd accepts a real legacy local checkout", () => {
|
|
50
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "legacy-merge-cwd-"));
|
|
51
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: dir });
|
|
52
|
+
const ok = resolveAutoMergeCwd(dir);
|
|
53
|
+
assert.equal(ok.ok, true);
|
|
54
|
+
if (ok.ok)
|
|
55
|
+
assert.equal(ok.cwd, dir);
|
|
56
|
+
});
|
|
@@ -3,6 +3,7 @@ import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, resetIssueI
|
|
|
3
3
|
import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, startWorkflowInstance, WorkflowAlreadyActiveError, } from "../repository/workflow-events.js";
|
|
4
4
|
import { createHumanAction, findOpenHumanAction, getHumanAction, listHumanActionsForIssue, resolveHumanAction, } from "../repository/human-actions.js";
|
|
5
5
|
import { reconcileFinding } from "../repository/findings.js";
|
|
6
|
+
import { normalizeReviewerResult } from "./reviewer-result.js";
|
|
6
7
|
import { getAgent } from "../repository/agents.js";
|
|
7
8
|
import { githubIssuesSync } from "../adapters/agent-health.js";
|
|
8
9
|
import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
|
|
@@ -499,12 +500,14 @@ function applyReviewer(issue, instance, item, outcome) {
|
|
|
499
500
|
autoMerge: issue.autoMerge,
|
|
500
501
|
}, issue.headSha);
|
|
501
502
|
const hasVerdict = outcome.kind === "verdict";
|
|
503
|
+
// Normalize before emit/finding reconcile so remapped blocking findings (NOT-150) persist.
|
|
504
|
+
const verdictResult = outcome.kind === "verdict" ? normalizeReviewerResult(outcome.result) : null;
|
|
502
505
|
const { projection, effect, advance } = projectReviewerRoute(route, issue.currentRound, hasVerdict);
|
|
503
506
|
const ev = eventEmitter(issue, instance, item.workerSessionId, projection.issueStatus, issue.currentRound);
|
|
504
507
|
const patch = {};
|
|
505
508
|
for (const type of projection.events) {
|
|
506
|
-
if (type === "review.submitted" &&
|
|
507
|
-
ev.emit("review.submitted", { actorType: "reviewer", payload:
|
|
509
|
+
if (type === "review.submitted" && verdictResult) {
|
|
510
|
+
ev.emit("review.submitted", { actorType: "reviewer", payload: verdictResult });
|
|
508
511
|
}
|
|
509
512
|
else if (type === "worker.completed" || type === "worker.failed") {
|
|
510
513
|
const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
|
|
@@ -544,8 +547,8 @@ function applyReviewer(issue, instance, item, outcome) {
|
|
|
544
547
|
patch.headSha = outcome.currentHeadSha;
|
|
545
548
|
}
|
|
546
549
|
// Thread reviewer findings across rounds (PRD §6.4) — every blocking/non-blocking finding.
|
|
547
|
-
if (
|
|
548
|
-
for (const f of
|
|
550
|
+
if (verdictResult) {
|
|
551
|
+
for (const f of verdictResult.findings) {
|
|
549
552
|
reconcileFinding({
|
|
550
553
|
issueId: issue.id,
|
|
551
554
|
fingerprint: f.fingerprint,
|
|
@@ -651,7 +654,7 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
|
|
|
651
654
|
function questionFor(actionType, reason, resumeAsReviewer = false) {
|
|
652
655
|
switch (actionType) {
|
|
653
656
|
case "final_review":
|
|
654
|
-
return "
|
|
657
|
+
return "Merge this work, send it back for another repair round, or close it?";
|
|
655
658
|
case "attempts_exhausted":
|
|
656
659
|
return "The review-round limit is reached. Retry with a fresh round, or close the issue?";
|
|
657
660
|
case "policy_escalation":
|
|
@@ -684,9 +687,9 @@ export function responseOptionsFor(actionType, resumeAsReviewer = false) {
|
|
|
684
687
|
switch (actionType) {
|
|
685
688
|
case "final_review":
|
|
686
689
|
return [
|
|
687
|
-
{ choice: "
|
|
690
|
+
{ choice: "merge", label: "Merge" },
|
|
688
691
|
{ choice: "repair", label: "Another repair round" },
|
|
689
|
-
{ choice: "close", label: "Close
|
|
692
|
+
{ choice: "close", label: "Close" },
|
|
690
693
|
];
|
|
691
694
|
case "attempts_exhausted":
|
|
692
695
|
return [
|
|
@@ -748,9 +751,11 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
|
|
|
748
751
|
if (!issue)
|
|
749
752
|
return { ok: false, code: 404, error: "Issue not found" };
|
|
750
753
|
const instance = getActiveWorkflowInstance(action.issueId);
|
|
751
|
-
// NOT-102: human
|
|
754
|
+
// NOT-102 / NOT-150: human Merge (or legacy "complete") must undraft+merge.
|
|
752
755
|
// Park like auto-merge, then the async wrapper runs finalizeAutoMerge outside this txn.
|
|
753
|
-
if (instance &&
|
|
756
|
+
if (instance &&
|
|
757
|
+
resolution.actionType === "final_review" &&
|
|
758
|
+
(resolution.choice === "merge" || resolution.choice === "complete")) {
|
|
754
759
|
return getDb().transaction(() => {
|
|
755
760
|
resolveHumanAction(actionId, resolvedBy, { choice });
|
|
756
761
|
appendWorkflowEvent({
|
|
@@ -761,7 +766,7 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice) {
|
|
|
761
766
|
actorRef: resolvedBy,
|
|
762
767
|
stage: "final_review",
|
|
763
768
|
round: issue.currentRound,
|
|
764
|
-
payload: { actionType: "final_review", choice:
|
|
769
|
+
payload: { actionType: "final_review", choice: resolution.choice, pendingMerge: true },
|
|
765
770
|
});
|
|
766
771
|
transitionIssue(issue.id, "final_review", {
|
|
767
772
|
currentOwner: "system",
|
|
@@ -966,7 +971,8 @@ function resolveLegacyTerminalAction(action, issue, resolvedBy, resolution) {
|
|
|
966
971
|
if (resolution.choice === "close") {
|
|
967
972
|
nextStatus = "closed";
|
|
968
973
|
}
|
|
969
|
-
else if (resolution.actionType === "final_review" &&
|
|
974
|
+
else if (resolution.actionType === "final_review" &&
|
|
975
|
+
(resolution.choice === "merge" || resolution.choice === "complete")) {
|
|
970
976
|
nextStatus = "done";
|
|
971
977
|
}
|
|
972
978
|
else if (resolution.actionType === "final_review" && resolution.choice === "repair") {
|
|
@@ -17,11 +17,13 @@ const { startWorkflow, applyCompletion, resolveHumanActionAndAdvance, resolveHum
|
|
|
17
17
|
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
18
18
|
const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
|
|
19
19
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
20
|
+
const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
|
|
20
21
|
before(() => migrate());
|
|
21
22
|
beforeEach(() => {
|
|
22
23
|
getDb().exec("DELETE FROM work_items");
|
|
23
24
|
clearFinalizeInflightForTests();
|
|
24
25
|
setMergePrForTests(async () => ({ ok: true }));
|
|
26
|
+
stubManagedCloneForTests("acme/app");
|
|
25
27
|
});
|
|
26
28
|
function newIssue(opts = {}) {
|
|
27
29
|
return createIssue({
|
|
@@ -400,13 +402,16 @@ test("fail → retry → exhaust → resume → fail again does not collide with
|
|
|
400
402
|
assert.equal(pending.length, 1, "a fresh item must be enqueued — the old (round, infraAttempts)-keyed row must not be silently reused");
|
|
401
403
|
assert.notEqual(pending[0].id, firstRetryItem.id, "must be a NEW work item, not the pre-escalation retry's now-terminal row");
|
|
402
404
|
});
|
|
403
|
-
test("resolving
|
|
405
|
+
test("resolving product_scope_decision:resume after a reviewer's escalated+question resumes as the developer", async () => {
|
|
404
406
|
const issueId = newIssue();
|
|
405
407
|
startWorkflow(issueId);
|
|
406
408
|
await complete(issueId, cleanHandoff);
|
|
407
|
-
await complete(issueId, {
|
|
408
|
-
|
|
409
|
-
|
|
409
|
+
await complete(issueId, {
|
|
410
|
+
kind: "verdict",
|
|
411
|
+
result: { ...okReview("escalated"), productScopeQuestion: "Should deleted users retain sessions?" },
|
|
412
|
+
});
|
|
413
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
|
|
414
|
+
assert.ok(action, "true product escalate opens product_scope_decision, not policy_escalation");
|
|
410
415
|
const resolved = resolveHumanActionAndAdvance(action.id, "yusuke", "resume");
|
|
411
416
|
assert.equal(resolved.ok, true);
|
|
412
417
|
const issue = getIssue(issueId);
|
|
@@ -414,6 +419,18 @@ test("resolving policy_escalation:resume after a reviewer's escalated verdict (a
|
|
|
414
419
|
const pending = listWorkItemsForIssue(issueId).filter((i) => i.status === "pending");
|
|
415
420
|
assert.deepEqual(pending.map((i) => i.kind), ["developer"]);
|
|
416
421
|
});
|
|
422
|
+
test("NOT-150: bare escalated verdict remaps to automatic repair, not policy_escalation", async () => {
|
|
423
|
+
const issueId = newIssue();
|
|
424
|
+
startWorkflow(issueId);
|
|
425
|
+
await complete(issueId, cleanHandoff);
|
|
426
|
+
await complete(issueId, { kind: "verdict", result: okReview("escalated") });
|
|
427
|
+
const issue = getIssue(issueId);
|
|
428
|
+
assert.equal(issue.status, "repairing");
|
|
429
|
+
assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
|
|
430
|
+
assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "developer" && i.status === "pending").length, 1);
|
|
431
|
+
const findings = listFindingsForIssue(issueId);
|
|
432
|
+
assert.ok(findings.some((f) => f.severity === "blocking"), "bare escalate remap must thread a blocking finding into repair");
|
|
433
|
+
});
|
|
417
434
|
test("a stale review re-queues a reviewer at the new head without consuming a round", async () => {
|
|
418
435
|
const issueId = newIssue();
|
|
419
436
|
startWorkflow(issueId);
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* reopen an issue or enqueue developer/reviewer work).
|
|
9
9
|
*/
|
|
10
10
|
const VALID_CHOICES = {
|
|
11
|
-
|
|
11
|
+
// "complete" kept as a synonym for "merge" so older open actions / CLI callers still resolve.
|
|
12
|
+
final_review: ["merge", "complete", "repair", "close"],
|
|
12
13
|
attempts_exhausted: ["retry", "close"],
|
|
13
14
|
policy_escalation: ["resume", "close"],
|
|
14
15
|
product_scope_decision: ["resume"],
|
|
@@ -52,8 +53,10 @@ export function parseHumanResolution(actionType, choice) {
|
|
|
52
53
|
export function resolveHumanActionOutcome(resolution) {
|
|
53
54
|
switch (resolution.actionType) {
|
|
54
55
|
case "final_review":
|
|
55
|
-
|
|
56
|
+
// Merge (and legacy "complete") undraft+merge via commands.ts, then mark done.
|
|
57
|
+
if (resolution.choice === "merge" || resolution.choice === "complete") {
|
|
56
58
|
return { issueStatus: "done", workflowOutcome: "done", triggerReflect: true };
|
|
59
|
+
}
|
|
57
60
|
if (resolution.choice === "repair")
|
|
58
61
|
return { issueStatus: "repairing", startNewRound: true, roundKind: "review" };
|
|
59
62
|
if (resolution.choice === "close")
|
|
@@ -23,12 +23,24 @@ test("parseHumanResolution rejects reflection_interaction_required even though V
|
|
|
23
23
|
test("resolveHumanActionOutcome throws rather than silently closing on an invalid choice reaching it directly", () => {
|
|
24
24
|
assert.throws(() => resolveHumanActionOutcome({ actionType: "final_review", choice: "bogus" }), /Unrecognized final_review choice/);
|
|
25
25
|
});
|
|
26
|
-
test("final_review
|
|
26
|
+
test("final_review merge marks the issue done and triggers reflect", () => {
|
|
27
|
+
const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "merge" });
|
|
28
|
+
assert.equal(result.issueStatus, "done");
|
|
29
|
+
assert.equal(result.workflowOutcome, "done");
|
|
30
|
+
assert.equal(result.triggerReflect, true);
|
|
31
|
+
});
|
|
32
|
+
test("final_review complete (legacy synonym) still marks done", () => {
|
|
27
33
|
const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "complete" });
|
|
28
34
|
assert.equal(result.issueStatus, "done");
|
|
29
35
|
assert.equal(result.workflowOutcome, "done");
|
|
30
36
|
assert.equal(result.triggerReflect, true);
|
|
31
37
|
});
|
|
38
|
+
test("parseHumanResolution accepts merge for final_review", () => {
|
|
39
|
+
assert.deepStrictEqual(parseHumanResolution("final_review", "merge"), {
|
|
40
|
+
actionType: "final_review",
|
|
41
|
+
choice: "merge",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
32
44
|
test("final_review repair sends the issue back for another round without reflect", () => {
|
|
33
45
|
const result = resolveHumanActionOutcome({ actionType: "final_review", choice: "repair" });
|
|
34
46
|
assert.equal(result.issueStatus, "repairing");
|
|
@@ -73,16 +73,18 @@ export function buildDeveloperPrompt(input) {
|
|
|
73
73
|
}
|
|
74
74
|
const REVIEWER_RESULT_SHAPE = '{"verdict":"approved"|"changes_requested"|"escalated","baseSha":"...","headSha":"...","acceptanceCriteriaAssessment":"...","evidenceAssessment":"...","findings":[{"fingerprint":"stable-slug","severity":"blocking"|"non_blocking","title":"...","rationale":"...","file":"...","line":0}],"risks":["..."],"productScopeQuestion":"..."}';
|
|
75
75
|
function reviewerContractSection(baseSha, headSha) {
|
|
76
|
+
// Verdict table matches docs/PRD_ISSUE_COORDINATION.md §6.4 / design NOT-150 — do not drift.
|
|
76
77
|
return [
|
|
77
78
|
`## Required final JSON block`,
|
|
78
79
|
`End your reply with exactly one fenced \`\`\`json block shaped like:`,
|
|
79
80
|
REVIEWER_RESULT_SHAPE,
|
|
80
|
-
`Rules:`,
|
|
81
|
+
`Rules (verdict contract):`,
|
|
81
82
|
`- Set "baseSha" to exactly "${baseSha}" and "headSha" to exactly "${headSha}" — these are the coordinator-verified SHAs you were checked out at, not values you compute.`,
|
|
82
83
|
`- "fingerprint" must be a short, stable slug for the finding (e.g. "missing-null-check-args-ts") so the same issue re-found next round is recognized as recurring, not duplicated.`,
|
|
83
84
|
`- "findings" holds every blocking AND non-blocking observation; "risks" is uncertainties that are not findings tied to a location.`,
|
|
84
|
-
`-
|
|
85
|
-
`-
|
|
85
|
+
`- "approved": AC met for this tip and no finding is "blocking" (non_blocking nits allowed).`,
|
|
86
|
+
`- "changes_requested": any "blocking" finding a coding pass can address — including incomplete review because AC-critical files were omitted/truncated from the diff. List omitted paths in a blocking finding.`,
|
|
87
|
+
`- "escalated": only when acceptance criteria / product scope are ambiguous, contradictory, or need a human product call — not ordinary code defects, not "diff too large". You MUST set non-empty "productScopeQuestion"; omit the field otherwise.`,
|
|
86
88
|
`- You cannot edit files, push, or publish anything — you only return this JSON. The coordinator publishes it to GitHub on your behalf.`,
|
|
87
89
|
];
|
|
88
90
|
}
|
|
@@ -91,37 +93,43 @@ function reviewerContractSection(baseSha, headSha) {
|
|
|
91
93
|
* earlier, much smaller per-file cap still truncated mid-file on a genuinely large PR,
|
|
92
94
|
* cutting off before the code under review). Whole files only — never a mid-hunk cut,
|
|
93
95
|
* which would be actively misleading — so a file either fits completely or is entirely
|
|
94
|
-
* omitted and
|
|
95
|
-
*
|
|
96
|
-
* full revision, so no verdict against it can be trusted, and this must be enforced in
|
|
97
|
-
* code — a prompt instruction alone is not a structural guarantee (the same reasoning
|
|
98
|
-
* `args.ts`/`permissions.ts` already apply to enforcement in general).
|
|
96
|
+
* omitted and listed in `omittedPaths`. Truncation policy (PRD §6.4 / NOT-150): coordinator
|
|
97
|
+
* remaps illegal escalate / approved+blocking; never blind escalate → Resume|Close.
|
|
99
98
|
*/
|
|
100
99
|
export const TOTAL_DIFF_LIMIT = 300_000;
|
|
100
|
+
function pathFromDiffGitHeader(headerLine) {
|
|
101
|
+
// `diff --git a/path b/path` — prefer the b/ side; fall back to a/.
|
|
102
|
+
const m = headerLine.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
103
|
+
if (!m)
|
|
104
|
+
return null;
|
|
105
|
+
return m[2] || m[1] || null;
|
|
106
|
+
}
|
|
101
107
|
export function formatDiffForPrompt(diff) {
|
|
102
108
|
const trimmed = diff.trim();
|
|
103
109
|
if (!trimmed)
|
|
104
|
-
return { text: "(empty diff)", truncated: false };
|
|
110
|
+
return { text: "(empty diff)", truncated: false, omittedPaths: [] };
|
|
105
111
|
const blocks = trimmed.split(/(?=^diff --git )/m).filter(Boolean);
|
|
106
112
|
const manifest = blocks.map((b) => b.slice(0, b.indexOf("\n"))).join("\n");
|
|
107
113
|
const pieces = [];
|
|
114
|
+
const omittedPaths = [];
|
|
108
115
|
let total = 0;
|
|
109
|
-
let omittedFiles = 0;
|
|
110
116
|
for (const block of blocks) {
|
|
111
117
|
if (total + block.length > TOTAL_DIFF_LIMIT) {
|
|
112
|
-
|
|
118
|
+
const header = block.slice(0, block.indexOf("\n"));
|
|
119
|
+
omittedPaths.push(pathFromDiffGitHeader(header) ?? (header || "unknown"));
|
|
113
120
|
continue;
|
|
114
121
|
}
|
|
115
122
|
pieces.push(block);
|
|
116
123
|
total += block.length;
|
|
117
124
|
}
|
|
118
|
-
const truncated =
|
|
125
|
+
const truncated = omittedPaths.length > 0;
|
|
119
126
|
const footer = truncated
|
|
120
|
-
? `\n... [${
|
|
127
|
+
? `\n... [${omittedPaths.length} changed file(s) omitted — diff exceeds ${TOTAL_DIFF_LIMIT} characters. Omitted: ${omittedPaths.join(", ")}. If any omitted path is AC-critical, verdict MUST be "changes_requested" with a blocking finding listing those paths. If AC is still certifiable from the visible tip with only non_blocking findings, "approved" is allowed. Do NOT use "escalated" for truncation — escalate only with productScopeQuestion for a true product gap.]`
|
|
121
128
|
: "";
|
|
122
129
|
return {
|
|
123
130
|
text: `Changed files (${blocks.length}):\n${manifest}\n\n${pieces.join("\n")}${footer}`,
|
|
124
131
|
truncated,
|
|
132
|
+
omittedPaths,
|
|
125
133
|
};
|
|
126
134
|
}
|
|
127
135
|
export function buildReviewerPrompt(input) {
|
|
@@ -145,6 +145,27 @@ test("reviewer prompt embeds the diff, echoes the exact SHAs to report, and forb
|
|
|
145
145
|
assert.match(prompt, /"headSha" to exactly "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"/);
|
|
146
146
|
assert.match(prompt, /You cannot edit files, push, or publish anything/);
|
|
147
147
|
});
|
|
148
|
+
test("NOT-150: reviewer verdict rules match the design table (blocking ⇒ changes_requested; escalate needs productScopeQuestion)", () => {
|
|
149
|
+
const prompt = buildReviewerPrompt(reviewerBase);
|
|
150
|
+
assert.match(prompt, /"approved": AC met/);
|
|
151
|
+
assert.match(prompt, /no finding is "blocking"/);
|
|
152
|
+
assert.match(prompt, /"changes_requested": any "blocking" finding/);
|
|
153
|
+
assert.match(prompt, /"escalated": only when acceptance criteria/);
|
|
154
|
+
assert.match(prompt, /MUST set non-empty "productScopeQuestion"/);
|
|
155
|
+
assert.match(prompt, /not ordinary code defects/);
|
|
156
|
+
assert.match(prompt, /not "diff too large"/);
|
|
157
|
+
});
|
|
158
|
+
test("NOT-150: truncated-diff footer does not reject approved/changes_requested; forbids escalate-for-truncation", async () => {
|
|
159
|
+
const { formatDiffForPrompt, TOTAL_DIFF_LIMIT } = await import("./prompts.js");
|
|
160
|
+
const big = "x".repeat(TOTAL_DIFF_LIMIT + 1);
|
|
161
|
+
const diff = `diff --git a/small.ts b/small.ts\n+ok\n\ndiff --git a/huge.ts b/huge.ts\n+${big}\n`;
|
|
162
|
+
const formatted = formatDiffForPrompt(diff);
|
|
163
|
+
assert.equal(formatted.truncated, true);
|
|
164
|
+
assert.ok(formatted.omittedPaths.some((p) => p.includes("huge.ts")));
|
|
165
|
+
assert.match(formatted.text, /changes_requested/);
|
|
166
|
+
assert.match(formatted.text, /Do NOT use "escalated" for truncation/);
|
|
167
|
+
assert.doesNotMatch(formatted.text, /will not accept "approved" or "changes_requested"/);
|
|
168
|
+
});
|
|
148
169
|
test("reviewer prompt includes the developer's conclusion, checks summary, and prior findings when given", () => {
|
|
149
170
|
const prompt = buildReviewerPrompt({
|
|
150
171
|
...reviewerBase,
|
|
@@ -29,7 +29,7 @@ import { getTaskSnapshot } from "./commands.js";
|
|
|
29
29
|
import { buildReviewerPrompt, formatDiffForPrompt, TOTAL_DIFF_LIMIT } from "./prompts.js";
|
|
30
30
|
import { guidanceForNextSession } from "./guidance.js";
|
|
31
31
|
import { realReviewerSpawn, reviewerSessionLogPath } from "./spawn.js";
|
|
32
|
-
import { parseReviewerResult, ReviewerResult as ReviewerResultSchema } from "./reviewer-result.js";
|
|
32
|
+
import { parseReviewerResult, normalizeReviewerResult, INCOMPLETE_REVIEW_FINGERPRINT, ReviewerResult as ReviewerResultSchema, } from "./reviewer-result.js";
|
|
33
33
|
import { createRoleWorktree, safeRemoveWorktree, isWorktreeClean, mergeBase, fetchRef, diffShas, } from "../adapters/git-worktree.js";
|
|
34
34
|
import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
|
|
35
35
|
import { prepareWorkerDeckConnection, releaseWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
|
|
@@ -108,6 +108,47 @@ async function bestEffortRemove(repo, worktreePath) {
|
|
|
108
108
|
// leave it for crash-recovery inspection — cleanup is a courtesy, not part of the contract
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
+
/** Inject a stable blocking incomplete-review finding listing omitted paths (NOT-150). */
|
|
112
|
+
function withIncompleteReviewFinding(result, omittedPaths) {
|
|
113
|
+
if (result.findings.some((f) => f.fingerprint === INCOMPLETE_REVIEW_FINGERPRINT)) {
|
|
114
|
+
const { productScopeQuestion: _drop, ...rest } = result;
|
|
115
|
+
return { ...rest, verdict: "changes_requested" };
|
|
116
|
+
}
|
|
117
|
+
const paths = omittedPaths.length > 0 ? omittedPaths.join(", ") : "(unlisted omitted files)";
|
|
118
|
+
const { productScopeQuestion: _drop, ...rest } = result;
|
|
119
|
+
return {
|
|
120
|
+
...rest,
|
|
121
|
+
verdict: "changes_requested",
|
|
122
|
+
findings: [
|
|
123
|
+
...result.findings,
|
|
124
|
+
{
|
|
125
|
+
fingerprint: INCOMPLETE_REVIEW_FINGERPRINT,
|
|
126
|
+
severity: "blocking",
|
|
127
|
+
title: "Diff truncated — incomplete review",
|
|
128
|
+
rationale: `Reviewer prompt omitted path(s): ${paths}. Cannot fully certify acceptance criteria without them; shrink the change set or split the PR so the next review sees the full AC-critical surface.`,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Truncation remap (PRD §6.4 / design NOT-150): never blind escalate → Resume|Close.
|
|
135
|
+
* Shippable approved (no blocking) stays approved; otherwise ensure changes_requested with
|
|
136
|
+
* the incomplete-review finding listing omitted paths (in addition to any other blocking).
|
|
137
|
+
*/
|
|
138
|
+
function applyTruncationVerdictPolicy(result, omittedPaths) {
|
|
139
|
+
const normalized = normalizeReviewerResult(result);
|
|
140
|
+
const hasProductQ = !!normalized.productScopeQuestion?.trim();
|
|
141
|
+
const hasBlocking = normalized.findings.some((f) => f.severity === "blocking");
|
|
142
|
+
if (normalized.verdict === "escalated" && hasProductQ) {
|
|
143
|
+
return normalized;
|
|
144
|
+
}
|
|
145
|
+
if (normalized.verdict === "approved" && !hasBlocking) {
|
|
146
|
+
return normalized;
|
|
147
|
+
}
|
|
148
|
+
// Truncated + not shippable-approved → changes_requested with incomplete-review (omitted paths).
|
|
149
|
+
const { productScopeQuestion: _drop, ...rest } = normalized;
|
|
150
|
+
return withIncompleteReviewFinding({ ...rest, verdict: "changes_requested" }, omittedPaths);
|
|
151
|
+
}
|
|
111
152
|
function readImplementationConclusion(issueId) {
|
|
112
153
|
const artifact = latestIssueArtifact(issueId, "implementation_conclusion");
|
|
113
154
|
if (!artifact?.contentJson)
|
|
@@ -281,7 +322,7 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
|
|
|
281
322
|
await fetchRef(worktreePath, baseBranch);
|
|
282
323
|
const baseSha = await mergeBase({ repo: worktreePath, base: `origin/${baseBranch}`, head: headSha });
|
|
283
324
|
const diff = await diffShas({ worktreePath, baseSha, headSha });
|
|
284
|
-
const { truncated: diffTruncated } = formatDiffForPrompt(diff);
|
|
325
|
+
const { truncated: diffTruncated, omittedPaths } = formatDiffForPrompt(diff);
|
|
285
326
|
const openFindings = listFindingsForIssue(issue.id).filter((f) => f.status === "open" || f.status === "recurring");
|
|
286
327
|
const guidance = guidanceForNextSession(issue.id, sessionId);
|
|
287
328
|
const prompt = buildReviewerPrompt({
|
|
@@ -402,20 +443,23 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
|
|
|
402
443
|
return { kind: "session_failed" };
|
|
403
444
|
}
|
|
404
445
|
result = parsed;
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
if (diffTruncated && result.verdict !== "escalated") {
|
|
446
|
+
// Truncation policy (PRD §6.4 / design NOT-150): never blind-escalate to Resume|Close.
|
|
447
|
+
// Persist evidence; remap bare escalate / approved+blocking; keep shippable approved.
|
|
448
|
+
if (diffTruncated) {
|
|
449
|
+
const reportedVerdict = result.verdict;
|
|
450
|
+
result = applyTruncationVerdictPolicy(result, omittedPaths);
|
|
411
451
|
createIssueArtifact({
|
|
412
452
|
issueId: issue.id,
|
|
413
453
|
workerSessionId: sessionId,
|
|
414
454
|
kind: "diff_truncated_evidence",
|
|
415
455
|
author: "system",
|
|
416
|
-
content: {
|
|
456
|
+
content: {
|
|
457
|
+
reportedVerdict,
|
|
458
|
+
overriddenTo: result.verdict,
|
|
459
|
+
diffCharLimit: TOTAL_DIFF_LIMIT,
|
|
460
|
+
omittedPaths,
|
|
461
|
+
},
|
|
417
462
|
});
|
|
418
|
-
result = { ...result, verdict: "escalated" };
|
|
419
463
|
}
|
|
420
464
|
}
|
|
421
465
|
catch {
|
|
@@ -456,7 +500,8 @@ export async function runReviewerEffect(ctx, deps = defaultDeps) {
|
|
|
456
500
|
// diff). A `row` with no recorded result (still `claimed` after every wait
|
|
457
501
|
// attempt, or the winner itself failed) has nothing safe to report — escalate.
|
|
458
502
|
if (claim.row?.state === "published" && claim.row.resultJson) {
|
|
459
|
-
const
|
|
503
|
+
const winnerParsed = ReviewerResultSchema.parse(JSON.parse(claim.row.resultJson));
|
|
504
|
+
const winnerResult = normalizeReviewerResult(winnerParsed);
|
|
460
505
|
return { kind: "verdict", result: winnerResult };
|
|
461
506
|
}
|
|
462
507
|
return { kind: "publish_failed" };
|
|
@@ -447,7 +447,7 @@ test("publish claim: a prior claimant that recorded failure is safely reclaimed
|
|
|
447
447
|
assert.equal(outcome.kind, "verdict", "reclaiming a failed prior claim must let publication proceed normally");
|
|
448
448
|
assert.equal(github.publishCallCount(), 1);
|
|
449
449
|
});
|
|
450
|
-
test("diff truncated:
|
|
450
|
+
test("diff truncated: AC-met approved (no blocking) stays on the approve path — never bare escalate", async () => {
|
|
451
451
|
const issueId = await makeIssue();
|
|
452
452
|
const github = fakeGithub();
|
|
453
453
|
// A single file whose diff alone exceeds prompts.ts's TOTAL_DIFF_LIMIT (300,000 chars).
|
|
@@ -465,12 +465,70 @@ test("diff truncated: an oversized diff forces the verdict to escalate in code,
|
|
|
465
465
|
registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github }));
|
|
466
466
|
await pump(1);
|
|
467
467
|
const issue = getIssue(issueId);
|
|
468
|
-
assert.equal(issue.status, "
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
468
|
+
assert.equal(issue.status, "final_review", "Fixture A: truncated + approved + no blocking → final_review, not opaque escalate");
|
|
469
|
+
const truncateEvidence = listArtifactsForIssue(issueId).find((a) => a.kind === "diff_truncated_evidence");
|
|
470
|
+
assert.ok(truncateEvidence);
|
|
471
|
+
const truncateBody = JSON.parse(truncateEvidence.contentJson);
|
|
472
|
+
assert.equal(truncateBody.overriddenTo, "approved");
|
|
472
473
|
const published = listArtifactsForIssue(issueId).find((a) => a.kind === "review_published");
|
|
473
|
-
assert.equal(JSON.parse(published.contentJson).event, "
|
|
474
|
+
assert.equal(JSON.parse(published.contentJson).event, "APPROVE");
|
|
475
|
+
});
|
|
476
|
+
test("diff truncated: blocking incomplete-review finding remaps to changes_requested repair, not escalate", async () => {
|
|
477
|
+
const issueId = await makeIssue();
|
|
478
|
+
const github = fakeGithub();
|
|
479
|
+
const bigFileSpawn = async (input) => {
|
|
480
|
+
const lines = Array.from({ length: 20_000 }, (_, i) => `line ${i} of a very large generated file`);
|
|
481
|
+
fs.writeFileSync(path.join(input.cwd, "big.txt"), `${lines.join("\n")}\n`);
|
|
482
|
+
git(input.cwd, "add", ".");
|
|
483
|
+
git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "add a big file");
|
|
484
|
+
return { exitCode: 0, transcript: "Implementation conclusion: added a big file.", logPath: "/dev/null", timedOut: false };
|
|
485
|
+
};
|
|
486
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: bigFileSpawn, github }));
|
|
487
|
+
startWorkflow(issueId);
|
|
488
|
+
await pump(1);
|
|
489
|
+
const finding = {
|
|
490
|
+
fingerprint: "diff-omits-shared-schema-files",
|
|
491
|
+
severity: "blocking",
|
|
492
|
+
title: "Shared schema omitted",
|
|
493
|
+
rationale: "AC-critical shared schema files cannot be certified without seeing them.",
|
|
494
|
+
};
|
|
495
|
+
// Fixture B: model reported approved despite blocking (footer used to discourage changes_requested).
|
|
496
|
+
registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, {
|
|
497
|
+
deckCallTool: okDeckCallTool,
|
|
498
|
+
spawn: verdictSpawn({ verdict: "approved", findings: [finding] }),
|
|
499
|
+
github,
|
|
500
|
+
}));
|
|
501
|
+
await pump(1);
|
|
502
|
+
const issue = getIssue(issueId);
|
|
503
|
+
assert.equal(issue.status, "repairing", "Fixture B: blocking incomplete review → automatic repair");
|
|
504
|
+
assert.equal(issue.currentRound, 2);
|
|
505
|
+
const truncateEvidence = listArtifactsForIssue(issueId).find((a) => a.kind === "diff_truncated_evidence");
|
|
506
|
+
assert.ok(truncateEvidence);
|
|
507
|
+
assert.equal(JSON.parse(truncateEvidence.contentJson).overriddenTo, "changes_requested");
|
|
508
|
+
assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
|
|
509
|
+
});
|
|
510
|
+
test("diff truncated: bare escalated remaps to changes_requested with incomplete-review finding", async () => {
|
|
511
|
+
const issueId = await makeIssue();
|
|
512
|
+
const github = fakeGithub();
|
|
513
|
+
const bigFileSpawn = async (input) => {
|
|
514
|
+
const lines = Array.from({ length: 20_000 }, (_, i) => `line ${i} of a very large generated file`);
|
|
515
|
+
fs.writeFileSync(path.join(input.cwd, "big.txt"), `${lines.join("\n")}\n`);
|
|
516
|
+
git(input.cwd, "add", ".");
|
|
517
|
+
git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "add a big file");
|
|
518
|
+
return { exitCode: 0, transcript: "Implementation conclusion: added a big file.", logPath: "/dev/null", timedOut: false };
|
|
519
|
+
};
|
|
520
|
+
registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: bigFileSpawn, github }));
|
|
521
|
+
startWorkflow(issueId);
|
|
522
|
+
await pump(1);
|
|
523
|
+
registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "escalated" }), github }));
|
|
524
|
+
await pump(1);
|
|
525
|
+
const issue = getIssue(issueId);
|
|
526
|
+
assert.equal(issue.status, "repairing");
|
|
527
|
+
const truncateEvidence = listArtifactsForIssue(issueId).find((a) => a.kind === "diff_truncated_evidence");
|
|
528
|
+
assert.equal(JSON.parse(truncateEvidence.contentJson).overriddenTo, "changes_requested");
|
|
529
|
+
assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
|
|
530
|
+
const findings = listFindingsForIssue(issueId);
|
|
531
|
+
assert.ok(findings.some((f) => f.fingerprint === "diff-truncated-incomplete-review"), "truncated bare escalate must keep incomplete-review with omitted paths");
|
|
474
532
|
});
|
|
475
533
|
test("changes_requested: findings thread onto the issue and a fresh developer repair round is queued", async () => {
|
|
476
534
|
const issueId = await makeIssue();
|
|
@@ -500,15 +558,15 @@ test("escalated with a product scope question opens product_scope_decision", asy
|
|
|
500
558
|
assert.ok(action);
|
|
501
559
|
assert.equal(action.reason, "Should this support X?");
|
|
502
560
|
});
|
|
503
|
-
test("escalated with no product scope question
|
|
561
|
+
test("escalated with no product scope question remaps to automatic repair (NOT-150)", async () => {
|
|
504
562
|
const issueId = await makeIssue();
|
|
505
563
|
const github = fakeGithub();
|
|
506
564
|
await advanceToReviewing(issueId, github);
|
|
507
565
|
registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "escalated" }), github }));
|
|
508
566
|
await pump(1);
|
|
509
567
|
const issue = getIssue(issueId);
|
|
510
|
-
assert.equal(issue.status, "
|
|
511
|
-
assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
|
|
568
|
+
assert.equal(issue.status, "repairing");
|
|
569
|
+
assert.ok(!listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
|
|
512
570
|
});
|
|
513
571
|
test("stale: a head that moved since the reviewer was queued is re-reviewed at the new head, not silently published against the old one", async () => {
|
|
514
572
|
const issueId = await makeIssue();
|
|
@@ -19,6 +19,54 @@ export const ReviewerResult = z.object({
|
|
|
19
19
|
/** Present only when verdict is "escalated" and the reviewer identifies a missing product call. */
|
|
20
20
|
productScopeQuestion: z.string().optional(),
|
|
21
21
|
});
|
|
22
|
+
/** Stable fingerprint for coordinator-injected incomplete-review findings (truncated diff). */
|
|
23
|
+
export const INCOMPLETE_REVIEW_FINGERPRINT = "diff-truncated-incomplete-review";
|
|
24
|
+
/** Stable fingerprint when bare escalate is remapped without a product question (NOT-150). */
|
|
25
|
+
export const BARE_ESCALATE_REMAP_FINGERPRINT = "escalated-without-product-scope-question";
|
|
26
|
+
/**
|
|
27
|
+
* Enforce PRD §6.4 / design NOT-150 invariants:
|
|
28
|
+
* - `escalated` requires a non-empty `productScopeQuestion` (else remap to `changes_requested`
|
|
29
|
+
* with a blocking finding — never empty-findings repair)
|
|
30
|
+
* - any `blocking` finding forbids `approved` (remap to `changes_requested`)
|
|
31
|
+
* - bare escalate is never a valid routing input (avoids Resume|Close `policy_escalation`)
|
|
32
|
+
*/
|
|
33
|
+
export function normalizeReviewerResult(raw) {
|
|
34
|
+
const hasBlocking = raw.findings.some((f) => f.severity === "blocking");
|
|
35
|
+
const question = raw.productScopeQuestion?.trim() || undefined;
|
|
36
|
+
if (raw.verdict === "escalated") {
|
|
37
|
+
if (question) {
|
|
38
|
+
return { ...raw, productScopeQuestion: question };
|
|
39
|
+
}
|
|
40
|
+
const { productScopeQuestion: _drop, ...rest } = raw;
|
|
41
|
+
if (hasBlocking) {
|
|
42
|
+
return { ...rest, verdict: "changes_requested" };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
...rest,
|
|
46
|
+
verdict: "changes_requested",
|
|
47
|
+
findings: [
|
|
48
|
+
...raw.findings,
|
|
49
|
+
{
|
|
50
|
+
fingerprint: BARE_ESCALATE_REMAP_FINGERPRINT,
|
|
51
|
+
severity: "blocking",
|
|
52
|
+
title: "Escalated without a product scope question",
|
|
53
|
+
rationale: "Reviewer returned escalated without productScopeQuestion. Remapped to changes_requested so a coding repair can proceed; escalate only for true product ambiguity with a concrete question.",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Drop a stray productScopeQuestion on non-escalate verdicts.
|
|
59
|
+
const withoutQuestion = question
|
|
60
|
+
? (() => {
|
|
61
|
+
const { productScopeQuestion: _drop, ...rest } = raw;
|
|
62
|
+
return rest;
|
|
63
|
+
})()
|
|
64
|
+
: raw;
|
|
65
|
+
if (withoutQuestion.verdict === "approved" && hasBlocking) {
|
|
66
|
+
return { ...withoutQuestion, verdict: "changes_requested" };
|
|
67
|
+
}
|
|
68
|
+
return withoutQuestion;
|
|
69
|
+
}
|
|
22
70
|
/** Mirrors the plan-triage/reflect JSON-fence parsing pattern already used elsewhere. */
|
|
23
71
|
export function parseReviewerResult(text) {
|
|
24
72
|
const trimmed = text.trim();
|
|
@@ -28,7 +76,7 @@ export function parseReviewerResult(text) {
|
|
|
28
76
|
try {
|
|
29
77
|
const parsed = ReviewerResult.safeParse(JSON.parse(candidate));
|
|
30
78
|
if (parsed.success)
|
|
31
|
-
return parsed.data;
|
|
79
|
+
return normalizeReviewerResult(parsed.data);
|
|
32
80
|
}
|
|
33
81
|
catch {
|
|
34
82
|
// try next candidate
|
|
@@ -35,11 +35,49 @@ test("accepts an escalated verdict carrying a productScopeQuestion", () => {
|
|
|
35
35
|
const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(escalated)}\n\`\`\``);
|
|
36
36
|
assert.deepEqual(result, escalated);
|
|
37
37
|
});
|
|
38
|
+
test("NOT-150: escalated without productScopeQuestion remaps to changes_requested", () => {
|
|
39
|
+
const bare = { ...valid, verdict: "escalated" };
|
|
40
|
+
const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(bare)}\n\`\`\``);
|
|
41
|
+
assert.equal(result?.verdict, "changes_requested");
|
|
42
|
+
assert.equal(result?.productScopeQuestion, undefined);
|
|
43
|
+
assert.ok(result?.findings.some((f) => f.severity === "blocking"));
|
|
44
|
+
assert.ok(result?.findings.some((f) => f.fingerprint === "escalated-without-product-scope-question"));
|
|
45
|
+
});
|
|
46
|
+
test("NOT-150: bare escalate with existing blocking findings keeps those findings", () => {
|
|
47
|
+
const bare = {
|
|
48
|
+
...valid,
|
|
49
|
+
verdict: "escalated",
|
|
50
|
+
findings: [{ fingerprint: "real-bug", severity: "blocking", title: "Bug", rationale: "breaks" }],
|
|
51
|
+
};
|
|
52
|
+
const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(bare)}\n\`\`\``);
|
|
53
|
+
assert.equal(result?.verdict, "changes_requested");
|
|
54
|
+
assert.equal(result?.findings.length, 1);
|
|
55
|
+
assert.equal(result?.findings[0]?.fingerprint, "real-bug");
|
|
56
|
+
});
|
|
57
|
+
test("NOT-150: approved with a blocking finding remaps to changes_requested", () => {
|
|
58
|
+
const bad = {
|
|
59
|
+
...valid,
|
|
60
|
+
verdict: "approved",
|
|
61
|
+
findings: [{ fingerprint: "diff-omits-shared-schema-files", severity: "blocking", title: "Omitted", rationale: "AC-critical" }],
|
|
62
|
+
};
|
|
63
|
+
const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(bad)}\n\`\`\``);
|
|
64
|
+
assert.equal(result?.verdict, "changes_requested");
|
|
65
|
+
assert.equal(result?.findings[0]?.fingerprint, "diff-omits-shared-schema-files");
|
|
66
|
+
});
|
|
38
67
|
test("findings carry file/line and severity through unchanged", () => {
|
|
39
68
|
const withFindings = {
|
|
40
69
|
...valid,
|
|
70
|
+
verdict: "changes_requested",
|
|
41
71
|
findings: [{ fingerprint: "f1", severity: "blocking", title: "Bug", rationale: "It breaks", file: "a.ts", line: 10 }],
|
|
42
72
|
};
|
|
43
73
|
const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(withFindings)}\n\`\`\``);
|
|
44
74
|
assert.deepEqual(result, withFindings);
|
|
45
75
|
});
|
|
76
|
+
test("non_blocking findings on approved pass through without remapping", () => {
|
|
77
|
+
const withNits = {
|
|
78
|
+
...valid,
|
|
79
|
+
findings: [{ fingerprint: "nit", severity: "non_blocking", title: "Nit", rationale: "Style" }],
|
|
80
|
+
};
|
|
81
|
+
const result = parseReviewerResult(`\`\`\`json\n${JSON.stringify(withNits)}\n\`\`\``);
|
|
82
|
+
assert.deepEqual(result, withNits);
|
|
83
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// packages/server/src/coordinator/routing.ts
|
|
2
|
+
import { normalizeReviewerResult } from "./reviewer-result.js";
|
|
1
3
|
function roundsRemain(limits) {
|
|
2
4
|
return limits.currentRound < limits.maxReviewRounds;
|
|
3
5
|
}
|
|
@@ -145,7 +147,9 @@ export function routeReviewerOutcome(outcome, limits, pinnedHeadSha) {
|
|
|
145
147
|
}
|
|
146
148
|
}
|
|
147
149
|
function routeVerdict(result, limits) {
|
|
148
|
-
|
|
150
|
+
// Defense in depth: same invariants as parseReviewerResult / PRD §6.4 (NOT-150).
|
|
151
|
+
const normalized = normalizeReviewerResult(result);
|
|
152
|
+
switch (normalized.verdict) {
|
|
149
153
|
case "approved":
|
|
150
154
|
return limits.autoMerge ? { next: "auto_merge" } : { next: "final_review" };
|
|
151
155
|
case "changes_requested":
|
|
@@ -153,8 +157,15 @@ function routeVerdict(result, limits) {
|
|
|
153
157
|
? { next: "retry_developer_with_findings" }
|
|
154
158
|
: { next: "human_action", actionType: "attempts_exhausted", reason: "Reviewer requested changes and the review-round limit is reached." };
|
|
155
159
|
case "escalated":
|
|
156
|
-
return
|
|
157
|
-
? { next: "human_action", actionType: "product_scope_decision", reason:
|
|
158
|
-
:
|
|
160
|
+
return normalized.productScopeQuestion
|
|
161
|
+
? { next: "human_action", actionType: "product_scope_decision", reason: normalized.productScopeQuestion }
|
|
162
|
+
: // Illegal bare escalate — treat as changes_requested so repair can run (NOT-150).
|
|
163
|
+
roundsRemain(limits)
|
|
164
|
+
? { next: "retry_developer_with_findings" }
|
|
165
|
+
: {
|
|
166
|
+
next: "human_action",
|
|
167
|
+
actionType: "attempts_exhausted",
|
|
168
|
+
reason: "Reviewer escalated without a product scope question and the review-round limit is reached.",
|
|
169
|
+
};
|
|
159
170
|
}
|
|
160
171
|
}
|
|
@@ -253,13 +253,28 @@ test("escalated with a product scope question routes to product_scope_decision",
|
|
|
253
253
|
const result = routeReviewerOutcome(outcome, REVIEW_ROUNDS_LEFT, PINNED_HEAD);
|
|
254
254
|
assert.equal(result.actionType, "product_scope_decision");
|
|
255
255
|
});
|
|
256
|
-
test("escalated without a product scope question
|
|
256
|
+
test("escalated without a product scope question remaps to automatic repair (NOT-150)", () => {
|
|
257
257
|
const outcome = {
|
|
258
258
|
kind: "verdict",
|
|
259
259
|
result: { verdict: "escalated", baseSha: "b", headSha: "h", acceptanceCriteriaAssessment: "unclear", evidenceAssessment: "ok", findings: [], risks: [] },
|
|
260
260
|
};
|
|
261
261
|
const result = routeReviewerOutcome(outcome, REVIEW_ROUNDS_LEFT, PINNED_HEAD);
|
|
262
|
-
assert.
|
|
262
|
+
assert.deepStrictEqual(result, { next: "retry_developer_with_findings" });
|
|
263
|
+
});
|
|
264
|
+
test("approved with blocking finding remaps to changes_requested repair (NOT-150)", () => {
|
|
265
|
+
const outcome = {
|
|
266
|
+
kind: "verdict",
|
|
267
|
+
result: {
|
|
268
|
+
verdict: "approved",
|
|
269
|
+
baseSha: "b",
|
|
270
|
+
headSha: "h",
|
|
271
|
+
acceptanceCriteriaAssessment: "ok",
|
|
272
|
+
evidenceAssessment: "ok",
|
|
273
|
+
findings: [{ fingerprint: "f1", severity: "blocking", title: "Bug", rationale: "breaks" }],
|
|
274
|
+
risks: [],
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
assert.deepStrictEqual(routeReviewerOutcome(outcome, REVIEW_ROUNDS_LEFT, PINNED_HEAD), { next: "retry_developer_with_findings" });
|
|
263
278
|
});
|
|
264
279
|
test("stale review retries the reviewer at the freshly verified head while infra attempts remain", () => {
|
|
265
280
|
const outcome = { kind: "stale", currentHeadSha: "new-head" };
|
|
@@ -25,6 +25,7 @@ const { runCoordinatorTick, drainCoordinator, activeAttemptCount, startCoordinat
|
|
|
25
25
|
const { recoverCoordinator } = await import("./recovery.js");
|
|
26
26
|
const { ReviewerResult } = await import("./reviewer-result.js");
|
|
27
27
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
|
|
28
|
+
const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
|
|
28
29
|
before(() => migrate());
|
|
29
30
|
// claimWorkItem / recovery scan the whole table (one loop in production); start each
|
|
30
31
|
// case from an empty queue so a prior test's un-processed item is never claimed here.
|
|
@@ -32,6 +33,7 @@ beforeEach(() => {
|
|
|
32
33
|
getDb().exec("DELETE FROM work_items");
|
|
33
34
|
clearFinalizeInflightForTests();
|
|
34
35
|
setMergePrForTests(async () => ({ ok: true }));
|
|
36
|
+
stubManagedCloneForTests("acme/app");
|
|
35
37
|
});
|
|
36
38
|
afterEach(() => resetEffectHandlers());
|
|
37
39
|
function newIssue(maxReviewRounds = 3) {
|
|
@@ -135,6 +135,7 @@ test("a legacy run left in 'review' seeds an open final_review human action", ()
|
|
|
135
135
|
});
|
|
136
136
|
test("a migrated final_review action is resolvable end to end through the real coordinator function, for every choice", () => {
|
|
137
137
|
for (const [choice, expectedStatus] of [
|
|
138
|
+
["merge", "done"],
|
|
138
139
|
["complete", "done"],
|
|
139
140
|
["repair", "needs_human"],
|
|
140
141
|
["close", "closed"],
|
|
@@ -304,8 +304,9 @@ test("agent-operated CLI: discover profiles, create + start a Dev-review issue,
|
|
|
304
304
|
const actions = cliJson(await runCli(["action", "list"]));
|
|
305
305
|
const finalReviewAction = actions.find((a) => a.issueId === issueId && a.actionType === "final_review" && a.status === "open");
|
|
306
306
|
assert.ok(finalReviewAction, "the open final_review human action must be listable via `action list`");
|
|
307
|
-
assert.ok(finalReviewAction.choices.some((c) => c.choice === "complete"), "the CLI must expose the action's valid choices, including
|
|
308
|
-
const
|
|
307
|
+
assert.ok(finalReviewAction.choices.some((c) => c.choice === "merge" || c.choice === "complete"), "the CLI must expose the action's valid choices, including Merge");
|
|
308
|
+
const mergeChoice = finalReviewAction.choices.find((c) => c.choice === "merge")?.choice ?? "complete";
|
|
309
|
+
const resolved = cliJson(await runCli(["action", "resolve", finalReviewAction.id, "--choice", mergeChoice, "--by", "cli-agent"]));
|
|
309
310
|
assert.equal(resolved.issueStatus, "done");
|
|
310
311
|
// 8. Final state, including sessions/artifacts/usage/evidence, all via the CLI.
|
|
311
312
|
const final = cliJson(await runCli(["issue", "show", issueId, "--include", "evidence"]));
|
|
@@ -17,6 +17,7 @@ const { listArtifactsForIssue } = await import("../repository/artifacts-for-issu
|
|
|
17
17
|
const { createRun, getRun, transitionRun, addArtifact, updateRunFields } = await import("../repository/runs.js");
|
|
18
18
|
const { pendingSendCount, getPendingOutboundDraft } = await import("../repository/outbound-drafts.js");
|
|
19
19
|
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("../coordinator/auto-merge.js");
|
|
20
|
+
const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
|
|
20
21
|
before(() => {
|
|
21
22
|
migrate();
|
|
22
23
|
});
|
|
@@ -27,6 +28,7 @@ beforeEach(() => {
|
|
|
27
28
|
clearFinalizeInflightForTests();
|
|
28
29
|
// final_review:complete now undrafts+merges — never hit real `gh` from route tests.
|
|
29
30
|
setMergePrForTests(async () => ({ ok: true }));
|
|
31
|
+
stubManagedCloneForTests("acme/app");
|
|
30
32
|
});
|
|
31
33
|
async function buildApp() {
|
|
32
34
|
const app = Fastify();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-dealer/server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@agent-dealer/shared": "1.0.
|
|
25
|
+
"@agent-dealer/shared": "1.0.1",
|
|
26
26
|
"@fastify/cors": "^11.0.1",
|
|
27
27
|
"@fastify/static": "^8.2.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/dist/action.test.js
CHANGED
|
@@ -12,7 +12,7 @@ test("action list calls GET /api/human-actions and surfaces parsed choices", asy
|
|
|
12
12
|
reason: "done",
|
|
13
13
|
question: "Accept?",
|
|
14
14
|
evidenceJson: null,
|
|
15
|
-
responseOptionsJson: JSON.stringify([{ choice: "
|
|
15
|
+
responseOptionsJson: JSON.stringify([{ choice: "merge", label: "Merge" }]),
|
|
16
16
|
continuationPreviewJson: null,
|
|
17
17
|
status: "open",
|
|
18
18
|
resolutionJson: null,
|
|
@@ -31,7 +31,7 @@ test("action list calls GET /api/human-actions and surfaces parsed choices", asy
|
|
|
31
31
|
assert.equal(code, 0);
|
|
32
32
|
stub.assertCalled();
|
|
33
33
|
const [action] = JSON.parse(printed);
|
|
34
|
-
assert.deepEqual(action.choices, [{ choice: "
|
|
34
|
+
assert.deepEqual(action.choices, [{ choice: "merge", label: "Merge" }]);
|
|
35
35
|
}
|
|
36
36
|
finally {
|
|
37
37
|
console.log = originalLog;
|
package/dist/install.js
CHANGED
|
@@ -53,7 +53,7 @@ export async function runInstall(args, deps = {}) {
|
|
|
53
53
|
if (migrateCli) {
|
|
54
54
|
console.log("Prefer ~/.local/bin ahead of any npm global agent-dealer on PATH.");
|
|
55
55
|
}
|
|
56
|
-
console.log("Next: agent-dealer doctor && agent-dealer start --daemon --open");
|
|
56
|
+
console.log("Next: gh auth login -h github.com (if needed) && agent-dealer doctor && agent-dealer start --daemon --open");
|
|
57
57
|
if (purgeGlobal) {
|
|
58
58
|
const code = deps.purgeGlobal
|
|
59
59
|
? await deps.purgeGlobal()
|
|
@@ -38,12 +38,12 @@ test("agent-operated CLI contract: discover profiles/issues, start, inspect choi
|
|
|
38
38
|
{ choice: "close", label: "Close" },
|
|
39
39
|
]),
|
|
40
40
|
openAction("00000000-0000-4000-a000-000000000024", "final_review", [
|
|
41
|
-
{ choice: "
|
|
41
|
+
{ choice: "merge", label: "Merge" },
|
|
42
42
|
{ choice: "repair", label: "Request repair" },
|
|
43
43
|
{ choice: "close", label: "Close" },
|
|
44
44
|
]),
|
|
45
45
|
];
|
|
46
|
-
const resolutions = ["resume", "resume", "retry", "
|
|
46
|
+
const resolutions = ["resume", "resume", "retry", "merge"];
|
|
47
47
|
const expected = [
|
|
48
48
|
{
|
|
49
49
|
path: "/api/agents",
|
package/dist/setup.js
CHANGED
|
@@ -18,7 +18,11 @@ export async function runSetup(options = {}) {
|
|
|
18
18
|
fs.copyFileSync(template, envFile);
|
|
19
19
|
console.log(`Created ${envFile}`);
|
|
20
20
|
console.log(`Data directory: ${home}`);
|
|
21
|
-
console.log("Next:
|
|
21
|
+
console.log("Next:");
|
|
22
|
+
console.log(" 1. brew install gh && gh auth login -h github.com");
|
|
23
|
+
console.log(" 2. Prefer ~/.local/bin on PATH (managed install); drop shadowed npm -g binaries");
|
|
24
|
+
console.log(" 3. agent-dealer doctor # GitHub must be green before kicking issues");
|
|
25
|
+
console.log(" 4. agent-dealer start --daemon");
|
|
22
26
|
return 0;
|
|
23
27
|
}
|
|
24
28
|
export function printSetupHelp() {
|