Skip to content

Commit 089650f

Browse files
authored
fix(worker): refuse the no_change_needed exit when PR review feedback is pending (#325)
1 parent 90845cc commit 089650f

4 files changed

Lines changed: 220 additions & 28 deletions

File tree

apps/worker/src/sandbox/context.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,68 @@ describe("assembleResearchPlanContext", () => {
7070
expect(result).toContain("resolutionEvidence");
7171
});
7272

73+
it("omits the Resolution Check when repository contexts carry PR review feedback", () => {
74+
const result = assembleResearchPlanContext({
75+
ticket: {
76+
identifier: "TEST-10",
77+
title: "Add login page",
78+
description: "Build a login page",
79+
acceptanceCriteria: "User can log in",
80+
comments: [],
81+
},
82+
prompt: "",
83+
branchName: "blazebot/test-10",
84+
repositoryContexts: [
85+
{
86+
repository: {
87+
provider: "github",
88+
repoPath: "acme/api",
89+
defaultBranch: "main",
90+
selectedRationale: "workflow-owned branch for this ticket",
91+
},
92+
prComments: [
93+
{ author: "Bob", body: "please add the missing null check", liked: false },
94+
],
95+
checkResults: [],
96+
hasConflicts: false,
97+
},
98+
],
99+
});
100+
101+
expect(result).not.toContain("## Resolution Check");
102+
expect(result).toContain("## Existing pull request — address this review feedback");
103+
expect(result).toContain("Research is read-only");
104+
});
105+
106+
it("keeps the Resolution Check when repository contexts have no PR comments", () => {
107+
const result = assembleResearchPlanContext({
108+
ticket: {
109+
identifier: "TEST-11",
110+
title: "Add login page",
111+
description: "Build a login page",
112+
acceptanceCriteria: "User can log in",
113+
comments: [],
114+
},
115+
prompt: "",
116+
branchName: "blazebot/test-11",
117+
repositoryContexts: [
118+
{
119+
repository: {
120+
provider: "github",
121+
repoPath: "acme/api",
122+
defaultBranch: "main",
123+
selectedRationale: "workflow-owned branch for this ticket",
124+
},
125+
prComments: [],
126+
checkResults: [],
127+
hasConflicts: false,
128+
},
129+
],
130+
});
131+
132+
expect(result).toContain("## Resolution Check");
133+
});
134+
73135
it("assembles context for new ticket (no PR feedback)", () => {
74136
const result = assembleResearchPlanContext({
75137
ticket: {

apps/worker/src/sandbox/context.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ export function assembleResearchPlanContext(input: ResearchPlanContextInput): st
8383
const selectedRepositoriesSection = renderSelectedRepositories(selectedRepositories, input.workspaceManifest);
8484
const repositoryContextSection = renderRepositoryContexts(repositoryContexts);
8585
const clarificationsSection = renderClarificationsSection(ticket.clarifications);
86+
// Same condition as renderRepositoryContexts' remediation section: when the
87+
// ticket's PR carries review feedback, that feedback is the task, so the
88+
// Resolution Check must not offer the already-resolved exit.
89+
const hasPrFeedback = (repositoryContexts ?? []).some(
90+
(context) => context.prComments.length > 0,
91+
);
8692

8793
let md = `# Requirements
8894
@@ -140,7 +146,9 @@ This protocol extends and overrides any older Output Format instructions above.
140146
repository.
141147
- Set fields that do not apply to \`null\`, as required by the structured schema.
142148
- Research is read-only: do not modify files, create commits, or change branches.
143-
149+
`;
150+
if (!hasPrFeedback) {
151+
md += `
144152
## Resolution Check
145153
146154
- Before planning any implementation, check whether the ticket is already resolved:
@@ -157,6 +165,7 @@ This protocol extends and overrides any older Output Format instructions above.
157165
is resolved, do not set \`noChangeNeeded\`; follow the Repository Access
158166
Protocol instead.
159167
`;
168+
}
160169
return md;
161170
}
162171

apps/worker/src/workflows/agent-no-change.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22
import type { ResearchResult } from "../sandbox/agents/types.js";
3-
import { buildResolutionEvidenceComment } from "./agent.js";
3+
import { buildResolutionEvidenceComment, resolveNoChangeAction } from "./agent.js";
44

55
const research = (overrides: Partial<ResearchResult> = {}): ResearchResult => ({
66
status: "completed",
@@ -58,3 +58,62 @@ describe("buildResolutionEvidenceComment", () => {
5858
expect(withMissing).toBe(withEmpty);
5959
});
6060
});
61+
62+
describe("resolveNoChangeAction", () => {
63+
const contextWith = (prComments: Array<{ author: string; body: string; liked: boolean }>) => [
64+
{ prComments },
65+
];
66+
const humanComment = [
67+
{ author: "Bob", body: "please add the missing null check", liked: false },
68+
];
69+
70+
it("returns no_change for a complete signal with no repository contexts", () => {
71+
expect(resolveNoChangeAction(research(), [], false)).toBe("no_change");
72+
});
73+
74+
it("returns no_change when the ticket's PR has no comments", () => {
75+
expect(resolveNoChangeAction(research(), contextWith([]), false)).toBe(
76+
"no_change",
77+
);
78+
});
79+
80+
it("returns retry on the first declaration against pending PR feedback", () => {
81+
expect(
82+
resolveNoChangeAction(research(), contextWith(humanComment), false),
83+
).toBe("retry");
84+
});
85+
86+
it("returns fail when the retry was already spent", () => {
87+
expect(
88+
resolveNoChangeAction(research(), contextWith(humanComment), true),
89+
).toBe("fail");
90+
});
91+
92+
it("returns proceed for a half-filled signal even with pending PR feedback", () => {
93+
expect(
94+
resolveNoChangeAction(
95+
research({ resolutionEvidence: [] }),
96+
contextWith(humanComment),
97+
false,
98+
),
99+
).toBe("proceed");
100+
expect(
101+
resolveNoChangeAction(
102+
research({
103+
writeRepositories: [
104+
{ provider: "github", repoPath: "acme/api", rationale: "fix lives here" },
105+
],
106+
}),
107+
contextWith(humanComment),
108+
false,
109+
),
110+
).toBe("proceed");
111+
expect(
112+
resolveNoChangeAction(
113+
research({ noChangeNeeded: undefined }),
114+
contextWith(humanComment),
115+
false,
116+
),
117+
).toBe("proceed");
118+
});
119+
});

apps/worker/src/workflows/agent.ts

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { TicketEvent } from "../adapters/messaging/types.js";
2121
import type { ActiveRunOwner } from "../lib/active-run-owner.js";
2222
import type { DownloadedAttachment } from "../sandbox/attachments.js";
2323
import type { SelectedRepository } from "../adapters/vcs/repository-directory.js";
24+
import type { SelectedRepositoryPromptContext } from "../sandbox/context.js";
2425
import {
2526
buildRuntimeGraph,
2627
createWorkflowExecutionErrorState,
@@ -2088,6 +2089,34 @@ export function buildResolutionEvidenceComment(research: ResearchResult): string
20882089
return sections.join("\n\n");
20892090
}
20902091

2092+
/**
2093+
* Decide what to do with research's already-resolved declaration. A review
2094+
* comment on the ticket's own PR means a person explicitly asked for changes,
2095+
* so the no_change_needed exit must not be taken: the first declaration earns
2096+
* one corrective research retry, a repeat fails the block. Uses the same
2097+
* prComments condition as renderRepositoryContexts' remediation section, so
2098+
* the prompt and the engine agree on what counts as pending feedback. Pure so
2099+
* the decision table stays unit-testable.
2100+
*/
2101+
export function resolveNoChangeAction(
2102+
research: ResearchResult,
2103+
repositoryContexts: ReadonlyArray<
2104+
Pick<SelectedRepositoryPromptContext, "prComments">
2105+
>,
2106+
retryUsed: boolean,
2107+
): "proceed" | "no_change" | "retry" | "fail" {
2108+
const noChangeSignal =
2109+
research.noChangeNeeded === true &&
2110+
(research.resolutionEvidence ?? []).length > 0 &&
2111+
(research.writeRepositories ?? []).length === 0;
2112+
if (!noChangeSignal) return "proceed";
2113+
const hasPrFeedback = repositoryContexts.some(
2114+
(context) => context.prComments.length > 0,
2115+
);
2116+
if (!hasPrFeedback) return "no_change";
2117+
return retryUsed ? "fail" : "retry";
2118+
}
2119+
20912120
export function v2TerminalBlockResult(input: {
20922121
terminalStatus: TerminalStatus;
20932122
postComment?: string;
@@ -4487,6 +4516,7 @@ async function agentWorkflowBody(
44874516
}
44884517

44894518
case "planning_agent": {
4519+
let noChangeRetryUsed = false;
44904520
for (;;) {
44914521
// AIW-147 IM-11: a human answer to the expansion-limit clarification
44924522
// attaches the repositories it named beyond the model round limit
@@ -4540,15 +4570,22 @@ async function agentWorkflowBody(
45404570
continue;
45414571
}
45424572
const expansionRound = ctx.repositoryExpansion.rounds;
4573+
// The retry re-runs the research phase, so both the label and the
4574+
// artifact phase must stay distinct from the first pass (same
4575+
// freshness trick as the -expansion-N suffix).
4576+
const noChangeRetrySuffix = noChangeRetryUsed ? " no-change retry" : "";
45434577
const researchLabel =
45444578
ctx.schemaVersion === 2
4545-
? `Research ${node.id}${expansionRound > 0 ? ` expansion ${expansionRound}` : ""}`
4546-
: `Research${expansionRound > 0 ? ` expansion ${expansionRound}` : ""}`;
4579+
? `Research ${node.id}${expansionRound > 0 ? ` expansion ${expansionRound}` : ""}${noChangeRetrySuffix}`
4580+
: `Research${expansionRound > 0 ? ` expansion ${expansionRound}` : ""}${noChangeRetrySuffix}`;
45474581
const baseResearchArtifactPhase = agentArtifactPhase("research", execution);
4548-
const researchArtifactPhase =
4582+
const expandedResearchArtifactPhase =
45494583
expansionRound > 0
45504584
? `${baseResearchArtifactPhase}-expansion-${expansionRound}`
45514585
: baseResearchArtifactPhase;
4586+
const researchArtifactPhase = noChangeRetryUsed
4587+
? `${expandedResearchArtifactPhase}-no-change-retry`
4588+
: expandedResearchArtifactPhase;
45524589
const researchPhase = phaseKey(researchLabel, invocationAttempt);
45534590
const { kind, model, runtime } = resolveAgentForNode(node);
45544591
const workspace = await ensureCodeWorkspace(execution);
@@ -4600,25 +4637,33 @@ async function agentWorkflowBody(
46004637
RESEARCH_SCHEMA,
46014638
runtime,
46024639
);
4640+
const researchAdditions = [...ctx.preSandboxAdditions.research];
4641+
if (ctx.repositoryExpansion.priorRequests.length > 0) {
4642+
researchAdditions.push({
4643+
target: ["research" as const],
4644+
title: "Repository expansion history",
4645+
content: [
4646+
"The following repositories were requested and are now attached.",
4647+
"Continue the same research; do not restart from assumptions.",
4648+
JSON.stringify(ctx.repositoryExpansion.priorRequests),
4649+
].join("\n"),
4650+
});
4651+
}
4652+
if (noChangeRetryUsed) {
4653+
researchAdditions.push({
4654+
target: ["research" as const],
4655+
title: "Do not declare this ticket already resolved",
4656+
content: [
4657+
"A human requested changes in the PR review feedback above, and the previous research pass wrongly concluded no change was needed.",
4658+
"Treat addressing every point of that review feedback as the task: produce an implementation plan for it, declare the writeRepositories it touches, and do not set noChangeNeeded.",
4659+
].join("\n"),
4660+
});
4661+
}
46034662
const researchContext = {
46044663
ticket: resolveAgentTicketInput(resolvedInputs, ticketData, ctx.clarifications),
46054664
branchName,
46064665
attachments: downloadedAttachments,
4607-
preSandboxAdditions:
4608-
ctx.repositoryExpansion.priorRequests.length > 0
4609-
? [
4610-
...ctx.preSandboxAdditions.research,
4611-
{
4612-
target: ["research" as const],
4613-
title: "Repository expansion history",
4614-
content: [
4615-
"The following repositories were requested and are now attached.",
4616-
"Continue the same research; do not restart from assumptions.",
4617-
JSON.stringify(ctx.repositoryExpansion.priorRequests),
4618-
].join("\n"),
4619-
},
4620-
]
4621-
: ctx.preSandboxAdditions.research,
4666+
preSandboxAdditions: researchAdditions,
46224667
repositoryContexts: ctx.repositoryContexts,
46234668
workspaceManifest: ctx.workspaceManifest ?? undefined,
46244669
};
@@ -4748,14 +4793,31 @@ async function agentWorkflowBody(
47484793

47494794
// An already resolved ticket (fix landed in an earlier commit, PR,
47504795
// or ticket comment) ends the run here as a successful no-op: there
4751-
// is nothing for any downstream block to write. All three signals
4752-
// must agree, so a half-filled signal keeps the normal plan path and
4753-
// its researchDeclaredNoWritesGuard verdict untouched.
4754-
const noChangeSignal =
4755-
research.noChangeNeeded === true &&
4756-
(research.resolutionEvidence ?? []).length > 0 &&
4757-
(research.writeRepositories ?? []).length === 0;
4758-
if (noChangeSignal) {
4796+
// is nothing for any downstream block to write. A half-filled
4797+
// signal keeps the normal plan path and its
4798+
// researchDeclaredNoWritesGuard verdict untouched. When the
4799+
// ticket's own PR carries human review feedback, that request is
4800+
// the task, so the exit is refused: one corrective research retry,
4801+
// then a hard fail instead of a false success.
4802+
const noChangeAction = resolveNoChangeAction(
4803+
research,
4804+
ctx.repositoryContexts,
4805+
noChangeRetryUsed,
4806+
);
4807+
if (noChangeAction === "retry") {
4808+
console.warn(
4809+
"[agent] research declared no_change_needed despite pending PR review feedback; retrying research once with a corrective note",
4810+
);
4811+
noChangeRetryUsed = true;
4812+
continue;
4813+
}
4814+
if (noChangeAction === "fail") {
4815+
return executionError(
4816+
"research declared no change needed but the ticket's PR has unresolved human review feedback; refusing the no_change_needed exit",
4817+
{ category: "engine", phase: "research" },
4818+
);
4819+
}
4820+
if (noChangeAction === "no_change") {
47594821
// Ticket-bound side effects only, exactly like the terminate
47604822
// dispatch: an uncorrelated entry has no ticket to comment on,
47614823
// move, or notify about.

0 commit comments

Comments
 (0)