authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-28 02:15:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 11:15:14+02:00
log9cf9fc9c5c0f48d42f407f86cf83d0f04fbdf5a8
treea0257fedc6f1f26ff17385f95c492ed051774d2b
parente00781a6619125e30f3e30ec6e17bc4c61d30502
signature Signed by PGP key B5690EEEBB952194

Settle merged PR threads immediately (#4704)


2 files changed, 17 insertions(+), 57 deletions(-)

packages/client-runtime/src/state/threadSettled.test.ts+15-32
......@@ -163,32 +163,11 @@ describe("effectiveSettled", () => {
163163 ).toBe(true);
164164 });
165165
166 it("does not re-settle a warm thread on the merge signal: a message sent in a settled thread keeps it active until idle", () => {
167 // The merge signal never clears, so without the idle guard a follow-up
168 // message would un-settle the row only until its turn completed, then
169 // snap straight back into the settled tail.
170 const justActive = makeShell({ activityAt: "2026-04-09T23:30:00.000Z" });
171 // The idle gate is strict: activity exactly one hour old is still warm.
172 const boundary = makeShell({ activityAt: "2026-04-09T23:00:00.000Z" });
173 const idle = makeShell({ activityAt: "2026-04-09T22:59:59.999Z" });
174
166 it("settles immediately when a change request merges or closes", () => {
167 const recentlyActive = makeShell({ activityAt: "2026-04-09T23:59:59.999Z" });
175168 for (const changeRequestState of ["merged", "closed"] as const) {
176169 expect(
177 effectiveSettled(justActive, {
178 now: NOW,
179 autoSettleAfterDays: null,
180 changeRequestState,
181 }),
182 ).toBe(false);
183 expect(
184 effectiveSettled(boundary, {
185 now: NOW,
186 autoSettleAfterDays: null,
187 changeRequestState,
188 }),
189 ).toBe(false);
190 expect(
191 effectiveSettled(idle, {
170 effectiveSettled(recentlyActive, {
192171 now: NOW,
193172 autoSettleAfterDays: null,
194173 changeRequestState,
......@@ -197,14 +176,18 @@ describe("effectiveSettled", () => {
197176 }
198177 });
199178
200 it("re-settles a merged-PR thread once the follow-up burst goes idle", () => {
201 // Same shell, advancing clock: active while warm, settled again after
202 // the idle window passes — the burst cools and the merge signal wins.
203 const shell = makeShell({ activityAt: "2026-04-09T23:30:00.000Z" });
204 const options = { autoSettleAfterDays: null, changeRequestState: "merged" as const };
205
206 expect(effectiveSettled(shell, { ...options, now: NOW })).toBe(false);
207 expect(effectiveSettled(shell, { ...options, now: "2026-04-10T00:30:00.001Z" })).toBe(true);
179 it("keeps an explicitly un-settled merged-PR thread active", () => {
180 const shell = makeShell({
181 settledOverride: "active",
182 activityAt: "2026-04-09T23:59:59.999Z",
183 });
184 expect(
185 effectiveSettled(shell, {
186 now: NOW,
187 autoSettleAfterDays: null,
188 changeRequestState: "merged",
189 }),
190 ).toBe(false);
208191 });
209192
210193 it("never settles a starting session, even with a settled override", () => {
packages/client-runtime/src/state/threadSettled.ts+2-25
......@@ -214,20 +214,6 @@ export function threadWokeAt(
214214 return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null;
215215}
216216
217/**
218 * A merged/closed change request settles its thread only once the thread has
219 * been idle this long. Without the idle guard the merge signal is permanent:
220 * sending a message to a merged-PR thread would un-settle the row only until
221 * its turn completed, then the still-merged PR would snap it straight back
222 * into the settled tail. An hour keeps the follow-up conversation visible
223 * while it is warm; once the burst goes stale the merge signal settles it
224 * again. Activity timestamps can originate on another device while `now` is
225 * this caller's clock: skew shortens or stretches the window by its size,
226 * the same exposure the inactivity auto-settle already accepts — worst case
227 * is a row changing lists early or late, never lost work.
228 */
229export const CHANGE_REQUEST_SETTLE_IDLE_MS = 60 * 60 * 1_000;
230
231217/**
232218 * Settled resolution over the server-backed settled lifecycle. Activity
233219 * blockers (pending approval/user-input, a live session, an unadjudicated
......@@ -235,7 +221,7 @@ export const CHANGE_REQUEST_SETTLE_IDLE_MS = 60 * 60 * 1_000;
235221 * override. Past the blockers, the explicit user override (thread.settle /
236222 * thread.unsettle commands, projected into settledOverride + settledAt)
237223 * wins in both directions; without one, a thread auto-settles on a
238 * merged/closed PR (once idle) or inactivity past the window. The server
224 * merged/closed PR immediately or on inactivity past the window. The server
239225 * un-settles on real activity (user message, session start, approval/
240226 * user-input request), so an override never goes stale silently.
241227 */
......@@ -271,16 +257,7 @@ export function effectiveSettled(
271257 // until real activity clears it server-side.
272258 if (shell.settledOverride === "active") return false;
273259 if (options.changeRequestState === "merged" || options.changeRequestState === "closed") {
274 // Only an idle thread settles on the merge signal: the signal itself
275 // never clears, so without this guard fresh activity (a message sent in
276 // a settled thread) would re-settle the moment its turn completed.
277 const lastActivityAt = threadLastActivityAt(shell);
278 if (
279 lastActivityAt === null ||
280 Date.parse(lastActivityAt) < Date.parse(options.now) - CHANGE_REQUEST_SETTLE_IDLE_MS
281 ) {
282 return true;
283 }
260 return true;
284261 }
285262 if (options.autoSettleAfterDays === null) return false;
286263