authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-07 00:46:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 00:46:53-07:00
log453e596da7e98d973e3853a5c2728681dedf363b
treea202fdc0ed4065fc4c294afe20dc232ed77c4174
parent655dfbe8ba87f0f9ef1d6fad83c20634634653aa
signature Signed by PGP key B5690EEEBB952194

fix(server): settled threads stop polling their pull requests (#16762)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 38 insertions(+), 20 deletions(-)

apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+27-13
......@@ -802,7 +802,7 @@ describe("PullRequestSyncReactor", () => {
802802 ),
803803 );
804804
805 it.effect("polls open pull requests on settled threads every fifteen minutes", () =>
805 it.effect("leaves a settled thread's links unread until the thread is unsettled", () =>
806806 Effect.scoped(
807807 Effect.gen(function* () {
808808 yield* TestClock.setTime(Date.parse(NOW));
......@@ -811,29 +811,35 @@ describe("PullRequestSyncReactor", () => {
811811 makeThread("settled", {
812812 settledOverride: "settled",
813813 settledAt: "2026-08-21T00:00:00.000Z",
814 pullRequests: [makeLink(5, { state: "open" })],
814 pullRequests: [makeLink(5, { state: "open" }), makeLink(6, { state: "closed" })],
815815 }),
816816 ]),
817817 });
818818
819819 yield* Effect.gen(function* () {
820820 const reactor = yield* startAndSweep(fixture);
821 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
822
823 yield* sweepAgain(fixture, reactor);
824 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
825
826 for (let index = 0; index < 13; index += 1) yield* sweepAgain(fixture, reactor);
827 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1);
821 for (let index = 0; index < 15; index += 1) yield* sweepAgain(fixture, reactor);
822 assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []);
828823
824 yield* Ref.update(fixture.snapshots, (snapshot) => ({
825 ...snapshot,
826 threads: snapshot.threads.map((thread) => ({
827 ...thread,
828 settledOverride: null,
829 settledAt: null,
830 })),
831 }));
829832 yield* sweepAgain(fixture, reactor);
830 assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2);
833 assert.deepStrictEqual(
834 (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number).toSorted(),
835 [5, 6],
836 );
831837 }).pipe(Effect.provide(fixture.layer));
832838 }),
833839 ),
834840 );
835841
836 it.effect("auto-links missing native stack layers and leaves dismissed ones alone", () =>
842 it.effect("auto-links missing native stack layers on active threads only", () =>
837843 Effect.scoped(
838844 Effect.gen(function* () {
839845 yield* TestClock.setTime(Date.parse(NOW));
......@@ -856,6 +862,11 @@ describe("PullRequestSyncReactor", () => {
856862 makeLink(41, { state: "merged" }, { source: "stack-dismissed" }),
857863 ],
858864 }),
865 makeThread("settled", {
866 settledOverride: "settled",
867 settledAt: "2026-08-21T00:00:00.000Z",
868 pullRequests: [makeLink(42)],
869 }),
859870 ]),
860871 stack: () => Effect.succeed(stack),
861872 });
......@@ -865,8 +876,11 @@ describe("PullRequestSyncReactor", () => {
865876
866877 const syncCommands = yield* Ref.get(fixture.syncCommands);
867878 assert.deepStrictEqual(
868 syncCommands.map((command) => [command.number, command.stack] as const),
869 [[42, { kind: "native", ...stack }]],
879 syncCommands.map((command) => [command.threadId, command.stack] as const),
880 [
881 [ThreadId.make("one"), { kind: "native", ...stack }],
882 [ThreadId.make("settled"), { kind: "native", ...stack }],
883 ],
870884 );
871885 assert.deepStrictEqual(
872886 (yield* Ref.get(fixture.linkCommands)).map(({ commandId: _, ...rest }) => rest),
apps/server/src/orchestration-v2/PullRequestSyncReactor.ts+10-6
......@@ -172,10 +172,12 @@ export const make = Effect.gen(function* () {
172172 const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => {
173173 if (requested.has(key) || retryStacks.has(key)) return true;
174174 if (entries.some((entry) => entry.link.snapshot === null)) return true;
175 if (entries.every((entry) => entry.link.snapshot?.state === "merged")) return false;
176 if (entries.some((entry) => entry.link.snapshot?.state === "open" && isUnsettled(entry.thread)))
177 return true;
178 // Closed requests can reopen on the host, including after the thread settles.
175 // Settled threads stop watching their pull requests. Unsettling one makes its links due on
176 // the next sweep, since the cadence clock below kept running while it was settled.
177 const active = entries.filter((entry) => isUnsettled(entry.thread));
178 if (active.every((entry) => entry.link.snapshot?.state === "merged")) return false;
179 if (active.some((entry) => entry.link.snapshot?.state === "open")) return true;
180 // Closed requests can reopen on the host.
179181 const last = lastSyncedAt.get(key);
180182 return last === undefined || nowMs - last >= SLOW_SYNC_INTERVAL_MS;
181183 };
......@@ -222,8 +224,10 @@ export const make = Effect.gen(function* () {
222224 link.snapshot === null ||
223225 !snapshotFieldsEqual(link.snapshot, fields) ||
224226 !stacksEqual(link.stack, nextStack);
225 // Persist discovered siblings before a terminal snapshot can trigger settlement.
226 for (const layer of fetchedStack?.stack?.layers ?? []) {
227 // Persist discovered siblings before a terminal snapshot can trigger settlement. A settled
228 // thread that shares this pull request with an active one takes the fresh snapshot, but
229 // gains no links.
230 for (const layer of isUnsettled(thread) ? (fetchedStack?.stack?.layers ?? []) : []) {
227231 const layerKey = {
228232 host: normalizeThreadPullRequestKey(link).host,
229233 repository: link.repository,
docs/user/source-control.md+1-1
......@@ -195,7 +195,7 @@ Linking and unlinking are available in the web and desktop clients.
195195The **Linked pull requests** panel lists every review and groups stacks. Unlink a review from its
196196row menu. An unlinked stack layer stays out of later syncs. Open linked reviews refresh on the server;
197197closed reviews refresh periodically so reopening one on the host is detected. Merged reviews refresh
198when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked
198when requested. A settled thread's reviews stop refreshing until you unsettle it. With **Auto-settle merged threads** enabled, a thread can settle after every linked
199199review is terminal. An open or unsynced link keeps it active.
200200
201201Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While