Skip to content

Commit 933e745

Browse files
Keep child research reconciliation passive
1 parent 81f89d3 commit 933e745

3 files changed

Lines changed: 207 additions & 3 deletions

File tree

‎apps/server/src/research/routes.test.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,99 @@ async function failInitial(context: Awaited<ReturnType<typeof setup>>) {
215215
});
216216
}
217217

218+
async function completedChildEvidence(context: Awaited<ReturnType<typeof setup>>) {
219+
let child = await context.storage.channels.create({
220+
id: crypto.randomUUID(),
221+
repositoryId: context.channel.repositoryId,
222+
repositoryOwner: context.channel.repositoryOwner,
223+
repositoryName: context.channel.repositoryName,
224+
parentChannelId: context.channel.id,
225+
title: "Published research child",
226+
createdBy: USER_ID,
227+
now: context.now,
228+
});
229+
let legacy = await context.storage.research.start({
230+
id: crypto.randomUUID(),
231+
channelId: child.id,
232+
title: "Legacy child research",
233+
question: "Create a grandchild",
234+
origin: "inline",
235+
createdBy: USER_ID,
236+
turnId: crypto.randomUUID(),
237+
messageId: crypto.randomUUID(),
238+
requestId: requestId(1),
239+
idempotencyKey: "legacy-child-request",
240+
fingerprint: "legacy-child-request",
241+
now: context.now,
242+
lease: context.lease,
243+
});
244+
let evidence = await context.jobs.enqueueUser({
245+
channelId: child.id,
246+
type: "research-evidence",
247+
targetKey: `workspace:${legacy.workspace.id}:turn:${legacy.turn.id}:evidence`,
248+
idempotencyKey: "legacy-child-evidence",
249+
input: {
250+
workspaceId: legacy.workspace.id,
251+
turnId: legacy.turn.id,
252+
query: legacy.turn.question,
253+
},
254+
});
255+
await context.storage.research.linkJob({
256+
channelId: child.id,
257+
workspaceId: legacy.workspace.id,
258+
turnId: legacy.turn.id,
259+
role: "evidence",
260+
jobId: evidence.job.id,
261+
now: context.now,
262+
lease: context.lease,
263+
});
264+
let [claimed] = await context.storage.jobs.claim({
265+
channelId: child.id,
266+
claimOwner: "completed-child-worker",
267+
count: 1,
268+
ttlMs: 30_000,
269+
now: new Date(context.now.getTime() + 1),
270+
lease: context.lease,
271+
});
272+
if (!claimed) throw new Error("child evidence job was not claimable");
273+
let completed = await context.jobs.settle({
274+
channelId: child.id,
275+
jobId: claimed.id,
276+
claimOwner: "completed-child-worker",
277+
claimGeneration: claimed.claimGeneration,
278+
artifact: {
279+
workspaceId: legacy.workspace.id,
280+
turnId: legacy.turn.id,
281+
query: legacy.turn.question,
282+
findings: ["The public release notes retain compatibility."],
283+
sources: [{ title: "Release notes", url: "https://example.com/releases/v3" }],
284+
model: "research-model",
285+
},
286+
});
287+
return { child, legacy, completed };
288+
}
289+
218290
function retryMutation(origin = "https://chopin.test"): RequestInit {
219291
return { method: "POST", headers: { origin } };
220292
}
221293

222294
describe("research workspace routes", () => {
295+
it("reads completed legacy child evidence without reconciling new answer work", async () => {
296+
let context = await setup();
297+
let { child, legacy } = await completedChildEvidence(context);
298+
let response = await context.router.handle(request(
299+
`/api/channels/${child.id}/research-workspaces/${legacy.workspace.id}`,
300+
context.cookie,
301+
));
302+
if (!response) throw new Error("research workspace route was not registered");
303+
304+
expect(response.status).toBe(200);
305+
expect(await response.json()).toMatchObject({ workspace: { id: legacy.workspace.id } });
306+
expect(await context.storage.research.get(child.id, legacy.workspace.id))
307+
.toMatchObject({ turns: [{ evidenceJobId: expect.any(String), answerJobId: undefined }] });
308+
expect((await context.jobs.list(child.id, 100))!.jobs).toHaveLength(1);
309+
});
310+
223311
it("enforces authentication, exact origin, write access, and owner timing", async () => {
224312
let context = await setup();
225313
let path = `/api/channels/${context.channel.id}/research-workspaces`;

‎apps/server/src/research/service.test.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,114 @@ async function failInitialEvidence(context: Awaited<ReturnType<typeof setup>>) {
4646
return claimed.id;
4747
}
4848

49+
async function childEvidence(
50+
context: Awaited<ReturnType<typeof setup>>,
51+
complete = true,
52+
) {
53+
let child = await context.storage.channels.create({
54+
id: crypto.randomUUID(),
55+
repositoryId: REPOSITORY_ID,
56+
repositoryOwner: "octo-org",
57+
repositoryName: "score",
58+
parentChannelId: context.channelId,
59+
title: "Published research child",
60+
createdBy: context.userId,
61+
now: context.advance(),
62+
});
63+
let legacy = await context.storage.research.start({
64+
id: crypto.randomUUID(),
65+
channelId: child.id,
66+
title: "Legacy child research",
67+
question: "Create a grandchild",
68+
origin: "inline",
69+
createdBy: context.userId,
70+
turnId: crypto.randomUUID(),
71+
messageId: crypto.randomUUID(),
72+
requestId: requestId(1),
73+
idempotencyKey: "legacy-child-request",
74+
fingerprint: "legacy-child-request",
75+
now: context.advance(),
76+
lease: context.lease,
77+
});
78+
let evidence = await context.jobs.enqueueUser({
79+
channelId: child.id,
80+
type: "research-evidence",
81+
targetKey: `workspace:${legacy.workspace.id}:turn:${legacy.turn.id}:evidence`,
82+
idempotencyKey: "legacy-child-evidence",
83+
input: {
84+
workspaceId: legacy.workspace.id,
85+
turnId: legacy.turn.id,
86+
query: legacy.turn.question,
87+
},
88+
});
89+
await context.storage.research.linkJob({
90+
channelId: child.id,
91+
workspaceId: legacy.workspace.id,
92+
turnId: legacy.turn.id,
93+
role: "evidence",
94+
jobId: evidence.job.id,
95+
now: context.advance(),
96+
lease: context.lease,
97+
});
98+
if (!complete) return { child, legacy, evidence: evidence.job };
99+
let [claimed] = await context.storage.jobs.claim({
100+
channelId: child.id,
101+
claimOwner: "completed-child-worker",
102+
count: 1,
103+
ttlMs: 30_000,
104+
now: context.advance(),
105+
lease: context.lease,
106+
});
107+
if (!claimed) throw new Error("child evidence job was not claimable");
108+
let completed = await context.jobs.settle({
109+
channelId: child.id,
110+
jobId: claimed.id,
111+
claimOwner: "completed-child-worker",
112+
claimGeneration: claimed.claimGeneration,
113+
artifact: evidenceArtifact(claimed),
114+
});
115+
return { child, legacy, evidence: completed.job };
116+
}
117+
49118
describe("research workspace service", () => {
119+
it("observes completed legacy child evidence without reconciling new answer work", async () => {
120+
let context = await setup();
121+
let { child, legacy } = await childEvidence(context);
122+
123+
expect(await context.service.reconcile(child.id, legacy.workspace.id)).toBe(true);
124+
expect(await context.service.get(child.id, legacy.workspace.id))
125+
.toMatchObject({ workspace: { id: legacy.workspace.id } });
126+
expect(await context.storage.research.get(child.id, legacy.workspace.id))
127+
.toMatchObject({ turns: [{ evidenceJobId: expect.any(String), answerJobId: undefined }] });
128+
expect((await context.jobs.list(child.id, 100))!.jobs).toHaveLength(1);
129+
});
130+
131+
it("does not advance completed legacy child evidence from job changes", async () => {
132+
let context = await setup();
133+
let { child, legacy, evidence } = await childEvidence(context);
134+
135+
await context.service.jobChanged(evidence);
136+
expect(await context.storage.research.get(child.id, legacy.workspace.id))
137+
.toMatchObject({ turns: [{ evidenceJobId: expect.any(String), answerJobId: undefined }] });
138+
expect((await context.jobs.list(child.id, 100))!.jobs).toHaveLength(1);
139+
});
140+
141+
it("keeps active legacy child research cancellable and readable", async () => {
142+
let context = await setup();
143+
let { child, legacy, evidence } = await childEvidence(context, false);
144+
145+
expect(await context.service.get(child.id, legacy.workspace.id))
146+
.toMatchObject({ workspace: { id: legacy.workspace.id } });
147+
expect(
148+
await context.service.cancelRequest({
149+
channelId: child.id,
150+
workspaceId: legacy.workspace.id,
151+
}),
152+
).toMatchObject({ id: legacy.workspace.id, stage: "cancelled" });
153+
expect(await context.jobs.get(child.id, evidence.id))
154+
.toMatchObject({ job: { state: "cancelled" } });
155+
});
156+
50157
it("starts one inline request and schedules its initial turn immediately", async () => {
51158
let context = await setup();
52159
let brief = " Compare API v2 with v3.\nExplain migration risks. ";

‎apps/server/src/research/service.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -664,7 +664,12 @@ export class ResearchWorkspaceService {
664664
return this.#exclusive(
665665
channelId,
666666
workspaceId,
667-
async () => await this.#reconciled(channelId, workspaceId) !== undefined,
667+
async () => {
668+
if (!await this.#isTopLevelChannel(channelId)) {
669+
return await this.#storage.research.get(channelId, workspaceId) !== undefined;
670+
}
671+
return await this.#reconciled(channelId, workspaceId) !== undefined;
672+
},
668673
);
669674
}
670675

@@ -832,6 +837,7 @@ export class ResearchWorkspaceService {
832837
|| (job.type !== "research-evidence" && job.type !== "research-answer")
833838
|| !this.#validId(job.channelId)
834839
) return;
840+
if (!await this.#isTopLevelChannel(job.channelId)) return;
835841
let linked = await this.#storage.research.findTurnByJob(job.channelId, job.id);
836842
if (!linked) return;
837843
await this.#exclusive(job.channelId, linked.workspaceId, async () => {
@@ -1347,15 +1353,18 @@ export class ResearchWorkspaceService {
13471353
}
13481354

13491355
async #requireTopLevelChannel(channelId: string): Promise<void> {
1350-
let channel = await this.#storage.channels.get(channelId);
1351-
if (channel?.parentChannelId) {
1356+
if (!await this.#isTopLevelChannel(channelId)) {
13521357
throw new ResearchWorkspaceError(
13531358
"invalid-request",
13541359
"Child documents cannot start research.",
13551360
);
13561361
}
13571362
}
13581363

1364+
async #isTopLevelChannel(channelId: string): Promise<boolean> {
1365+
return !(await this.#storage.channels.get(channelId))?.parentChannelId;
1366+
}
1367+
13591368
async #published(workspace: ResearchWorkspace): Promise<void> {
13601369
try {
13611370
await this.#publish(workspace.channelId, workspace.id, workspace.revision);

0 commit comments

Comments
 (0)