From 81613f643292daf41b23549c77ba037ae54c8e4b Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:25:15 +0100 Subject: [PATCH 1/3] fix(server): settled threads' pull request status keeps updating A settled thread whose pull requests no active thread shared never read them again, so a PR that merged or closed after the thread settled kept its old badge on every client. Settled-only links that are not final now refresh hourly, and unsettling a thread refreshes its open and closed links at once. Co-Authored-By: Claude Opus 5.5 --- .../PullRequestSyncReactor.test.ts | 62 ++++++++++++++++--- .../PullRequestSyncReactor.ts | 10 ++- docs/user/source-control.md | 3 +- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 0161054781567..874912e26bd22 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1253,7 +1253,7 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("leaves a settled thread's links unread until the thread is unsettled", () => + it.effect("reads a settled thread's unfinished links hourly, and at once when unsettled", () => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -1262,15 +1262,25 @@ describe("PullRequestSyncReactor", () => { makeThread("settled", { settledOverride: "settled", settledAt: "2026-08-21T00:00:00.000Z", - pullRequests: [makeLink(5, { state: "open" }), makeLink(6, { state: "closed" })], + pullRequests: [ + makeLink(5, { state: "open", updatedAt: NOW }), + makeLink(6, { state: "closed" }), + makeLink(7, { state: "merged", mergedAt: "2026-08-20T00:00:00.000Z" }), + ], }), ]), }); + const readNumbers = Effect.map(Ref.getAndSet(fixture.summaryCalls, []), (calls) => + calls.map((call) => call.number).toSorted(), + ); yield* Effect.gen(function* () { const reactor = yield* startAndSweep(fixture); - for (let index = 0; index < 15; index += 1) yield* sweepAgain(fixture, reactor); - assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + assert.deepStrictEqual(yield* readNumbers, [5, 6]); + for (let index = 0; index < 59; index += 1) yield* sweepAgain(fixture, reactor); + assert.deepStrictEqual(yield* readNumbers, []); + yield* sweepAgain(fixture, reactor); + assert.deepStrictEqual(yield* readNumbers, [5, 6]); yield* Ref.update(fixture.snapshots, (snapshot) => ({ ...snapshot, @@ -1280,11 +1290,45 @@ describe("PullRequestSyncReactor", () => { settledAt: null, })), })); - yield* sweepAgain(fixture, reactor); - assert.deepStrictEqual( - (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number).toSorted(), - [5, 6], - ); + const threadId = ThreadId.make("settled"); + const at = DateTime.makeUnsafe(NOW); + yield* Queue.offer(fixture.domainEvents, { + type: "thread.unsettled", + id: EventId.make("event:unsettled"), + threadId, + occurredAt: at, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: PROJECT_ID, + title: "settled", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + createdAt: at, + updatedAt: at, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }); + yield* Queue.take(fixture.snapshotReads); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual(yield* readNumbers, [5, 6]); }).pipe(Effect.provide(fixture.layer)); }), ), diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index 9dffe4babb424..f3148b93c8439 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -47,6 +47,9 @@ const QUIET_SYNC_INTERVAL_MS = 5 * 60 * 1_000; // A pull request closed this long ago is rarely reopened, so the sweep stops reading it, as it // does merged ones. A reader that sees it open, or a requested refresh, still reads it at once. const CLOSED_FINAL_AFTER_MS = 24 * 60 * 60 * 1_000; +// Settled threads can hold hundreds of links nobody watches, so their unfinished ones are read +// hourly; unsettling the thread reads them at once. +const SETTLED_SYNC_INTERVAL_MS = 60 * 60 * 1_000; const isChangeRequestCloseCommand = (command: string) => sourceControlDefinitions.definitions.some( @@ -359,9 +362,12 @@ export const make = Effect.gen(function* () { ? snapshot.mergedAt != null : snapshot?.state === "closed" && nowMs - Date.parse(snapshot.closedAt ?? "") >= CLOSED_FINAL_AFTER_MS; - if (active.every(isFinal)) return false; const last = lastSyncedAt.get(key); const sinceLastMs = last === undefined ? Number.POSITIVE_INFINITY : nowMs - last; + if (active.length === 0) { + return !entries.every(isFinal) && sinceLastMs >= SETTLED_SYNC_INTERVAL_MS; + } + if (active.every(isFinal)) return false; const open = active.filter((entry) => entry.link.snapshot?.state === "open"); if (open.length > 0) { const quiet = open.every((entry) => { @@ -714,6 +720,8 @@ export const make = Effect.gen(function* () { ended.add(item.id); return Effect.andThen(linked, refreshUnmergedLinks(event.threadId)); } + case "thread.unsettled": + return refreshUnmergedLinks(event.threadId); case "run.updated": return isTerminalRunStatus(event.payload.status) && closeCommands.delete(event.threadId) ? refreshUnmergedLinks(event.threadId) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 949bddd8afbf0..ebedc28b478f4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -298,7 +298,8 @@ Linking and unlinking are available in the web and desktop clients. The **Linked pull requests** panel lists every review and groups stacks. Unlink a review from its row menu. An unlinked stack layer stays out of later syncs. Open linked reviews refresh on the server; closed reviews refresh periodically so reopening one on the host is detected. Merged reviews refresh -when 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 +when requested. A settled thread's open and recently closed reviews refresh about once an hour, and +they refresh at once when you unsettle it. With **Auto-settle merged threads** enabled, a thread can settle after every linked review is terminal. An open or unsynced link keeps it active. Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While From 5324e7dd5d3f556a6014577dd4bfd2f56db5e41d Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:42:23 +0100 Subject: [PATCH 2/3] fix(server): settled links refresh when the shared PR is final elsewhere --- .../src/orchestration-v2/PullRequestSyncReactor.test.ts | 5 +++++ apps/server/src/orchestration-v2/PullRequestSyncReactor.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 874912e26bd22..896085257e271 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1268,6 +1268,11 @@ describe("PullRequestSyncReactor", () => { makeLink(7, { state: "merged", mergedAt: "2026-08-20T00:00:00.000Z" }), ], }), + makeThread("active", { + pullRequests: [ + makeLink(5, { state: "merged", mergedAt: "2026-08-20T00:00:00.000Z" }), + ], + }), ]), }); const readNumbers = Effect.map(Ref.getAndSet(fixture.summaryCalls, []), (calls) => diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index f3148b93c8439..f75b801090fa1 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -364,10 +364,9 @@ export const make = Effect.gen(function* () { nowMs - Date.parse(snapshot.closedAt ?? "") >= CLOSED_FINAL_AFTER_MS; const last = lastSyncedAt.get(key); const sinceLastMs = last === undefined ? Number.POSITIVE_INFINITY : nowMs - last; - if (active.length === 0) { + if (active.every(isFinal)) { return !entries.every(isFinal) && sinceLastMs >= SETTLED_SYNC_INTERVAL_MS; } - if (active.every(isFinal)) return false; const open = active.filter((entry) => entry.link.snapshot?.state === "open"); if (open.length > 0) { const quiet = open.every((entry) => { From 23fe779d32899646a135cb25fdb823b7b95cc689 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:29:25 +0100 Subject: [PATCH 3/3] fix(server): settled threads read pull request status once on settle, then stop --- .../PullRequestSyncReactor.test.ts | 65 ++++++++----------- .../PullRequestSyncReactor.ts | 10 +-- docs/user/source-control.md | 3 +- 3 files changed, 32 insertions(+), 46 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts index 896085257e271..6910b3c41198d 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -1253,7 +1253,7 @@ describe("PullRequestSyncReactor", () => { ), ); - it.effect("reads a settled thread's unfinished links hourly, and at once when unsettled", () => + it.effect("reads a settled thread's links once when it settles, then until unsettled", () => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); @@ -1262,44 +1262,20 @@ describe("PullRequestSyncReactor", () => { makeThread("settled", { settledOverride: "settled", settledAt: "2026-08-21T00:00:00.000Z", - pullRequests: [ - makeLink(5, { state: "open", updatedAt: NOW }), - makeLink(6, { state: "closed" }), - makeLink(7, { state: "merged", mergedAt: "2026-08-20T00:00:00.000Z" }), - ], - }), - makeThread("active", { - pullRequests: [ - makeLink(5, { state: "merged", mergedAt: "2026-08-20T00:00:00.000Z" }), - ], + pullRequests: [makeLink(5, { state: "open" }), makeLink(6, { state: "closed" })], }), ]), }); - const readNumbers = Effect.map(Ref.getAndSet(fixture.summaryCalls, []), (calls) => - calls.map((call) => call.number).toSorted(), - ); yield* Effect.gen(function* () { const reactor = yield* startAndSweep(fixture); - assert.deepStrictEqual(yield* readNumbers, [5, 6]); - for (let index = 0; index < 59; index += 1) yield* sweepAgain(fixture, reactor); - assert.deepStrictEqual(yield* readNumbers, []); - yield* sweepAgain(fixture, reactor); - assert.deepStrictEqual(yield* readNumbers, [5, 6]); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); - yield* Ref.update(fixture.snapshots, (snapshot) => ({ - ...snapshot, - threads: snapshot.threads.map((thread) => ({ - ...thread, - settledOverride: null, - settledAt: null, - })), - })); const threadId = ThreadId.make("settled"); const at = DateTime.makeUnsafe(NOW); yield* Queue.offer(fixture.domainEvents, { - type: "thread.unsettled", - id: EventId.make("event:unsettled"), + type: "thread.settled", + id: EventId.make("event:settled"), threadId, occurredAt: at, payload: { @@ -1315,17 +1291,13 @@ describe("PullRequestSyncReactor", () => { branch: null, worktreePath: null, activeProviderThreadId: null, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: threadId, - }, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, forkedFrom: null, createdAt: at, updatedAt: at, archivedAt: null, - settledOverride: null, - settledAt: null, + settledOverride: "settled", + settledAt: at, lastVisitedAt: null, deletedAt: null, }, @@ -1333,7 +1305,26 @@ describe("PullRequestSyncReactor", () => { yield* Queue.take(fixture.snapshotReads); yield* Queue.take(fixture.snapshotReads); yield* reactor.drain; - assert.deepStrictEqual(yield* readNumbers, [5, 6]); + assert.deepStrictEqual( + (yield* Ref.getAndSet(fixture.summaryCalls, [])).map((call) => call.number).toSorted(), + [5, 6], + ); + for (let index = 0; index < 15; index += 1) yield* sweepAgain(fixture, reactor); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + + yield* Ref.update(fixture.snapshots, (snapshot) => ({ + ...snapshot, + threads: snapshot.threads.map((thread) => ({ + ...thread, + settledOverride: null, + settledAt: null, + })), + })); + yield* sweepAgain(fixture, reactor); + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number).toSorted(), + [5, 6], + ); }).pipe(Effect.provide(fixture.layer)); }), ), diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts index f75b801090fa1..3d28743c325af 100644 --- a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -47,9 +47,6 @@ const QUIET_SYNC_INTERVAL_MS = 5 * 60 * 1_000; // A pull request closed this long ago is rarely reopened, so the sweep stops reading it, as it // does merged ones. A reader that sees it open, or a requested refresh, still reads it at once. const CLOSED_FINAL_AFTER_MS = 24 * 60 * 60 * 1_000; -// Settled threads can hold hundreds of links nobody watches, so their unfinished ones are read -// hourly; unsettling the thread reads them at once. -const SETTLED_SYNC_INTERVAL_MS = 60 * 60 * 1_000; const isChangeRequestCloseCommand = (command: string) => sourceControlDefinitions.definitions.some( @@ -362,11 +359,9 @@ export const make = Effect.gen(function* () { ? snapshot.mergedAt != null : snapshot?.state === "closed" && nowMs - Date.parse(snapshot.closedAt ?? "") >= CLOSED_FINAL_AFTER_MS; + if (active.every(isFinal)) return false; const last = lastSyncedAt.get(key); const sinceLastMs = last === undefined ? Number.POSITIVE_INFINITY : nowMs - last; - if (active.every(isFinal)) { - return !entries.every(isFinal) && sinceLastMs >= SETTLED_SYNC_INTERVAL_MS; - } const open = active.filter((entry) => entry.link.snapshot?.state === "open"); if (open.length > 0) { const quiet = open.every((entry) => { @@ -719,7 +714,8 @@ export const make = Effect.gen(function* () { ended.add(item.id); return Effect.andThen(linked, refreshUnmergedLinks(event.threadId)); } - case "thread.unsettled": + // Settled threads are not swept, so read their links once to settle with fresh status. + case "thread.settled": return refreshUnmergedLinks(event.threadId); case "run.updated": return isTerminalRunStatus(event.payload.status) && closeCommands.delete(event.threadId) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index ebedc28b478f4..9421cc698c6d7 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -298,8 +298,7 @@ Linking and unlinking are available in the web and desktop clients. The **Linked pull requests** panel lists every review and groups stacks. Unlink a review from its row menu. An unlinked stack layer stays out of later syncs. Open linked reviews refresh on the server; closed reviews refresh periodically so reopening one on the host is detected. Merged reviews refresh -when requested. A settled thread's open and recently closed reviews refresh about once an hour, and -they refresh at once when you unsettle it. With **Auto-settle merged threads** enabled, a thread can settle after every linked +when requested. Settling a thread refreshes its open and closed reviews once; after that they stop refreshing until you unsettle it. With **Auto-settle merged threads** enabled, a thread can settle after every linked review is terminal. An open or unsynced link keeps it active. Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While