Skip to content

Commit 435151f

Browse files
authored
fix(worker): continue research when expansion names only attached repositories (AIW-284) (#315)
1 parent 92a8610 commit 435151f

4 files changed

Lines changed: 171 additions & 10 deletions

File tree

apps/worker/src/repository-discovery/runner.test.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,20 @@ describe("repository expansion validation", () => {
179179
completedRounds: 0,
180180
},
181181
{
182-
name: "already attached repository",
182+
name: "repository requested twice in one round",
183183
requests: [
184184
{
185185
provider: "gitlab" as const,
186186
repoPath: "acme/shared/contracts",
187187
rationale: "imports",
188188
},
189+
{
190+
provider: "gitlab" as const,
191+
repoPath: "acme/shared/contracts",
192+
rationale: "imports again",
193+
},
189194
],
190-
attached: [
191-
{ provider: "gitlab" as const, repoPath: "acme/shared/contracts" },
192-
],
195+
attached: [],
193196
completedRounds: 0,
194197
},
195198
])("returns targeted clarification for $name", ({ requests, attached, completedRounds }) => {
@@ -203,6 +206,49 @@ describe("repository expansion validation", () => {
203206
).toBe("clarification_needed");
204207
});
205208

209+
it("reports already_attached instead of asking a human when every request is attached", () => {
210+
// A clarification here would park the run on an unanswerable question: the
211+
// workspace already holds everything research named, so there is nothing a
212+
// human could add (AIW-284).
213+
expect(
214+
validateRepositoryExpansionRequests({
215+
requests: [
216+
{
217+
provider: "gitlab",
218+
repoPath: "acme/shared/contracts",
219+
rationale: "imports",
220+
},
221+
],
222+
catalog,
223+
attached: [
224+
{ provider: "gitlab", repoPath: "acme/shared/contracts" },
225+
],
226+
completedRounds: 0,
227+
}),
228+
).toEqual({ kind: "already_attached" });
229+
});
230+
231+
it("keeps the round limit ahead of the already-attached no-op", () => {
232+
// The two-round limit is checked first, so an all-attached request on the
233+
// third round is still the human question it was, not a silent continue.
234+
expect(
235+
validateRepositoryExpansionRequests({
236+
requests: [
237+
{
238+
provider: "gitlab",
239+
repoPath: "acme/shared/contracts",
240+
rationale: "imports",
241+
},
242+
],
243+
catalog,
244+
attached: [
245+
{ provider: "gitlab", repoPath: "acme/shared/contracts" },
246+
],
247+
completedRounds: 2,
248+
}).kind,
249+
).toBe("clarification_needed");
250+
});
251+
206252
it("returns clarification for more than three fresh repositories in one round, distinct from the total cap", () => {
207253
// The per-round cap (>3 in a single round) is enforced before the catalog is
208254
// even consulted, and is separate from both the two-round limit and the

apps/worker/src/repository-discovery/runner.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ export function assembleRepositoryDiscoveryPrompt(input: {
8888

8989
export type RepositoryExpansionDecision =
9090
| { kind: "attach"; repositories: SelectedRepository[] }
91+
// Every requested repository is already in the workspace. Not an error and not
92+
// a question: nothing is left to clone, so the caller continues research with
93+
// what is attached.
94+
| { kind: "already_attached" }
9195
| { kind: "clarification_needed"; questions: string[] };
9296

9397
// Total repositories one research workspace may ever hold. This is a hard cap:
@@ -157,7 +161,8 @@ export function validateRepositoryExpansionRequests(input: {
157161
}
158162
requested.add(key);
159163
// Already-attached repositories are filtered out and the fresh ones proceed;
160-
// only an all-attached request (nothing fresh remains) becomes a clarification.
164+
// an all-attached request (nothing fresh remains) is reported as
165+
// already_attached below, never as a clarification.
161166
if (attached.has(key)) {
162167
continue;
163168
}
@@ -175,9 +180,10 @@ export function validateRepositoryExpansionRequests(input: {
175180
});
176181
}
177182
if (repositories.length === 0) {
178-
return clarification(
179-
"Research requested only repositories that are already attached. Which additional repository is required?",
180-
);
183+
// Asking a human "which additional repository is required?" here has no
184+
// useful answer: research already holds everything it named. Report the
185+
// no-op so the caller keeps going instead of parking the run (AIW-284).
186+
return { kind: "already_attached" };
181187
}
182188
if (input.attached.length + repositories.length > MAX_WORKSPACE_REPOSITORIES) {
183189
return clarification(

apps/worker/src/workflows/agent.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,9 +1025,11 @@ export async function applyHumanRepositoryExpansion(
10251025
if (decision.kind === "clarification_needed") {
10261026
return { kind: "clarification", questions: decision.questions };
10271027
}
1028-
if (decision.repositories.length === 0) {
1028+
if (decision.kind === "already_attached" || decision.repositories.length === 0) {
10291029
// Every named repository is already attached: nothing new to clone, so let
1030-
// the caller run research instead of re-raising the clarification.
1030+
// the caller run research instead of re-raising the clarification. The human
1031+
// validator reports this as an empty attach rather than already_attached, so
1032+
// both shapes land here.
10311033
return { kind: "noop" };
10321034
}
10331035
const attached = await deps.attach(decision.repositories);
@@ -4078,6 +4080,33 @@ async function agentWorkflowBody(
40784080
if (decision.kind === "clarification_needed") {
40794081
return planningClarificationResult(decision.questions);
40804082
}
4083+
if (decision.kind === "already_attached") {
4084+
// Research asked only for repositories the workspace already holds:
4085+
// nothing to clone, and no question a human could usefully answer, so
4086+
// continue with what is attached instead of parking the run (AIW-284).
4087+
// The round still counts and the requests are still recorded. That is
4088+
// deliberate: it bounds a model that keeps re-requesting the same
4089+
// repositories (the third round trips the expansion limit, which IS a
4090+
// legitimate human question), and it puts the requests into the
4091+
// "Repository expansion history" note the next research prompt carries,
4092+
// which tells the model those repositories are attached and that it
4093+
// should continue the same research.
4094+
ctx.repositoryExpansion = {
4095+
rounds: ctx.repositoryExpansion.rounds + 1,
4096+
priorRequests: [
4097+
...ctx.repositoryExpansion.priorRequests,
4098+
...requests,
4099+
],
4100+
};
4101+
await emitRepositoryWorkflowObservation(execution?.observations, {
4102+
event: "expansion",
4103+
round: ctx.repositoryExpansion.rounds,
4104+
attachedCount: 0,
4105+
totalCount: ctx.selectedRepositories.length,
4106+
cloneDurationMs: 0,
4107+
});
4108+
return null;
4109+
}
40814110
const attached = await attachResearchRepositoriesStep(
40824111
ctx.sandboxId,
40834112
ctx.workspaceManifest,

apps/worker/src/workflows/multi-repo-research.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,4 +705,84 @@ describe("expansion round counter survives a clarification round-trip", () => {
705705
});
706706
expect(second.kind).toBe("attach");
707707
});
708+
709+
// AIW-284: research asking only for repositories the workspace already holds
710+
// used to park the whole run on "Which additional repository is required?",
711+
// which no human can answer, because everything named was already there. Same
712+
// limitation as the test above: expandResearchWorkspace is an inline closure in
713+
// agent.ts, so this drives the exported validator and mirrors the call site's
714+
// state transitions rather than executing the loop itself.
715+
it("continues without a clarification and still burns a round when every requested repository is attached", () => {
716+
const ctx = makeCtx({
717+
sandboxId: "sbx-research",
718+
workspaceManifest: { version: 2, repositories: [] },
719+
selectedRepositories: [
720+
{
721+
provider: "github",
722+
repoPath: "acme/service",
723+
defaultBranch: "main",
724+
selectedRationale: "symptom",
725+
},
726+
{
727+
provider: "gitlab",
728+
repoPath: "acme/shared/contracts",
729+
defaultBranch: "main",
730+
selectedRationale: "imports",
731+
},
732+
],
733+
});
734+
735+
const requests = [
736+
{
737+
provider: "gitlab" as const,
738+
repoPath: "acme/shared/contracts",
739+
rationale: "need the schema",
740+
},
741+
{
742+
provider: "github" as const,
743+
repoPath: "acme/service",
744+
rationale: "need the caller",
745+
},
746+
];
747+
const decision = validateRepositoryExpansionRequests({
748+
requests,
749+
catalog,
750+
attached: ctx.selectedRepositories,
751+
completedRounds: ctx.repositoryExpansion.rounds,
752+
});
753+
754+
// The run keeps going: no clarification, and nothing to clone.
755+
expect(decision).toEqual({ kind: "already_attached" });
756+
757+
// The call site advances the durable counter exactly as the attach path
758+
// does, so a model that keeps re-asking is bounded instead of looping.
759+
ctx.repositoryExpansion = {
760+
rounds: ctx.repositoryExpansion.rounds + 1,
761+
priorRequests: [...ctx.repositoryExpansion.priorRequests, ...requests],
762+
};
763+
expect(ctx.repositoryExpansion.rounds).toBe(1);
764+
expect(ctx.repositoryExpansion.priorRequests).toEqual(requests);
765+
766+
// Round two repeats the no-op, and round three hits the expansion limit,
767+
// which IS a question a human can act on.
768+
expect(
769+
validateRepositoryExpansionRequests({
770+
requests,
771+
catalog,
772+
attached: ctx.selectedRepositories,
773+
completedRounds: 1,
774+
}),
775+
).toEqual({ kind: "already_attached" });
776+
expect(
777+
validateRepositoryExpansionRequests({
778+
requests,
779+
catalog,
780+
attached: ctx.selectedRepositories,
781+
completedRounds: 2,
782+
}),
783+
).toMatchObject({
784+
kind: "clarification_needed",
785+
questions: [expect.stringContaining("maximum of 2")],
786+
});
787+
});
708788
});

0 commit comments

Comments
 (0)