authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 15:20:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 15:20:44-07:00
log193e3c62e6d52fcc200d823ca6d9061d13f81f23
tree3f89b680ee1e2b8e78e83bcda47fb9c559c684f5
parent91dfe60a99b7bf6cc44138a237e663ba158062b7
signature Signed by PGP key B5690EEEBB952194

fix(client-runtime): keep a warm thread un-settled despite a merged/closed PR (#4309)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 77 insertions(+), 7 deletions(-)

packages/client-runtime/src/state/threadSettled.test.ts+44
......@@ -163,6 +163,50 @@ 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
175 for (const changeRequestState of ["merged", "closed"] as const) {
176 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, {
192 now: NOW,
193 autoSettleAfterDays: null,
194 changeRequestState,
195 }),
196 ).toBe(true);
197 }
198 });
199
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);
208 });
209
166210 it("never settles a starting session, even with a settled override", () => {
167211 const shell = makeShell({
168212 settledOverride: "settled",
packages/client-runtime/src/state/threadSettled.ts+33-7
......@@ -91,12 +91,29 @@ export function canSettle(
9191}
9292
9393/**
94 * Settled resolution over the server-backed settled lifecycle. The explicit
95 * user override (thread.settle / thread.unsettle commands, projected into
96 * settledOverride + settledAt) wins in both directions; without one, a
97 * thread auto-settles on a merged/closed PR or inactivity past the window.
98 * The server un-settles on real activity (user message, session start,
99 * approval/user-input request), so an override never goes stale silently.
94 * A merged/closed change request settles its thread only once the thread has
95 * been idle this long. Without the idle guard the merge signal is permanent:
96 * sending a message to a merged-PR thread would un-settle the row only until
97 * its turn completed, then the still-merged PR would snap it straight back
98 * into the settled tail. An hour keeps the follow-up conversation visible
99 * while it is warm; once the burst goes stale the merge signal settles it
100 * again. Activity timestamps can originate on another device while `now` is
101 * this caller's clock: skew shortens or stretches the window by its size,
102 * the same exposure the inactivity auto-settle already accepts — worst case
103 * is a row changing lists early or late, never lost work.
104 */
105export const CHANGE_REQUEST_SETTLE_IDLE_MS = 60 * 60 * 1_000;
106
107/**
108 * Settled resolution over the server-backed settled lifecycle. Activity
109 * blockers (pending approval/user-input, a live session, an unadjudicated
110 * queued turn) are checked first and hold a thread active regardless of any
111 * override. Past the blockers, the explicit user override (thread.settle /
112 * thread.unsettle commands, projected into settledOverride + settledAt)
113 * wins in both directions; without one, a thread auto-settles on a
114 * merged/closed PR (once idle) or inactivity past the window. The server
115 * un-settles on real activity (user message, session start, approval/
116 * user-input request), so an override never goes stale silently.
100117 */
101118export function effectiveSettled(
102119 shell: OrchestrationThreadShell,
......@@ -130,7 +147,16 @@ export function effectiveSettled(
130147 // until real activity clears it server-side.
131148 if (shell.settledOverride === "active") return false;
132149 if (options.changeRequestState === "merged" || options.changeRequestState === "closed") {
133 return true;
150 // Only an idle thread settles on the merge signal: the signal itself
151 // never clears, so without this guard fresh activity (a message sent in
152 // a settled thread) would re-settle the moment its turn completed.
153 const lastActivityAt = threadLastActivityAt(shell);
154 if (
155 lastActivityAt === null ||
156 Date.parse(lastActivityAt) < Date.parse(options.now) - CHANGE_REQUEST_SETTLE_IDLE_MS
157 ) {
158 return true;
159 }
134160 }
135161 if (options.autoSettleAfterDays === null) return false;
136162