authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 16:24:18-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 16:24:18-07:00
log243d1e7c4499d7ba4dc5b22794fcd2c880936c2d
tree52e3d5999eb326713565d4260135a3cd0eaacc68
parentd65cd33a03983751598f6bdce76b402ac7bf2cca
signature Signed by PGP key B5690EEEBB952194

fix(server): merged threads settle even after the agent wakes on its own (#15388)

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

5 files changed, 138 insertions(+), 20 deletions(-)

apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+55-1
......@@ -278,7 +278,13 @@ it.effect.each([
278278 };
279279 assert.deepEqual(
280280 resolveAutoSettlementAt({ ...settings, thread: candidate }),
281 resolveAutoSettlementAt({ ...settings, thread: expected }),
281 resolveAutoSettlementAt({
282 ...settings,
283 thread: {
284 ...expected,
285 latestUserAuthoredMessageAt: candidate.latestUserAuthoredMessageAt,
286 },
287 }),
282288 );
283289 }
284290 assert.equal(
......@@ -292,6 +298,54 @@ it.effect.each([
292298 }).pipe(Effect.provide(testLayer)),
293299);
294300
301it.effect.each([
302 ["sql", SqlLayer],
303 ["memory", ProjectionStore.layerMemory],
304] as const)("%s: the user-authored message time ignores agent notifications", ([, testLayer]) =>
305 Effect.gen(function* () {
306 const store = yield* ProjectionStore.ProjectionStoreV2;
307 const threadId = yield* createThread("agent-woken");
308 yield* createRun(threadId);
309 const message = (
310 id: string,
311 createdBy: "user" | "agent",
312 creationSource: "web" | "provider",
313 at: DateTime.Utc,
314 ) =>
315 store.apply({
316 id: EventId.make(`event:settlement:${id}`),
317 type: "message.updated",
318 threadId,
319 occurredAt: at,
320 payload: {
321 createdBy,
322 creationSource,
323 id: MessageId.make(`message:settlement:${id}`),
324 threadId,
325 runId: null,
326 nodeId: null,
327 role: "user",
328 text: id,
329 attachments: [],
330 streaming: false,
331 createdAt: at,
332 updatedAt: at,
333 },
334 });
335 const written = DateTime.subtract(now, { hours: 2 });
336 yield* message("written", "user", "web", written);
337 yield* message("notification", "agent", "provider", now);
338
339 const [candidate] = yield* store.getSettlementCandidates(threadId);
340 assert.isDefined(candidate);
341 assert.equal(DateTime.formatIso(candidate.latestUserMessageAt!), DateTime.formatIso(now));
342 assert.equal(
343 DateTime.formatIso(candidate.latestUserAuthoredMessageAt!),
344 DateTime.formatIso(written),
345 );
346 }).pipe(Effect.provide(testLayer)),
347);
348
295349const pullRequestLink = (number: number) => ({
296350 host: "github.com",
297351 repository: "owner/repository",
apps/server/src/orchestration-v2/ProjectionStore.ts+32-5
......@@ -159,7 +159,13 @@ export type ProjectionThreadPullRequests = Pick<
159159 "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests"
160160>;
161161
162/** Thread activity needed by settlement, without transcript or fork history. */
162/**
163 * Thread activity needed by settlement, without transcript or fork history.
164 * `latestUserAuthoredMessageAt` is the last message the user wrote. Agent,
165 * provider, and server notifications also use the user role, so
166 * `latestUserMessageAt` moves when background work or a PR watch wakes the
167 * agent.
168 */
163169export type ProjectionSettlementCandidate = Pick<
164170 OrchestrationV2ThreadShell,
165171 | "id"
......@@ -187,7 +193,7 @@ export type ProjectionSettlementCandidate = Pick<
187193 | "activityRunStatus"
188194 | "pendingRuntimeRequest"
189195 | "pendingBackgroundTasks"
190>;
196> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null };
191197
192198const ProjectionCheckpointContext = Schema.Struct({
193199 runs: Schema.Array(
......@@ -930,7 +936,7 @@ type SettlementThreadRow = Pick<
930936 | "latest_run_started_at"
931937 | "latest_run_completed_at"
932938 | "latest_user_message_at"
933>;
939> & { readonly latest_user_authored_message_at: string | null };
934940
935941type ShellRunItemCountRow = {
936942 readonly thread_id: string;
......@@ -5051,7 +5057,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
50515057 WHERE message.thread_id = t.thread_id AND message.role = 'user'
50525058 ORDER BY message.updated_at DESC, message.message_id DESC
50535059 LIMIT 1
5054 ) AS latest_user_message_at
5060 ) AS latest_user_message_at,
5061 (
5062 SELECT message.updated_at
5063 FROM orchestration_v2_projection_messages message
5064 WHERE message.thread_id = t.thread_id AND message.role = 'user'
5065 AND json_extract(message.payload_json, '$.createdBy') = 'user'
5066 ORDER BY message.updated_at DESC, message.message_id DESC
5067 LIMIT 1
5068 ) AS latest_user_authored_message_at
50555069 FROM orchestration_v2_projection_threads t
50565070 LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = (
50575071 SELECT latest.run_id FROM orchestration_v2_projection_runs latest
......@@ -5113,6 +5127,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
51135127 row.latest_user_message_at === null
51145128 ? null
51155129 : DateTime.makeUnsafe(row.latest_user_message_at),
5130 latestUserAuthoredMessageAt:
5131 row.latest_user_authored_message_at === null
5132 ? null
5133 : DateTime.makeUnsafe(row.latest_user_authored_message_at),
51165134 activityRunStatus: null,
51175135 activityRunStartedAt: null,
51185136 pendingRuntimeRequest: null,
......@@ -5578,7 +5596,16 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
55785596 !runs.some(isActivityRunForShell) &&
55795597 !runtimeRequests.some((request) => request.status === "pending"),
55805598 )
5581 .map(threadShellFromProjection)
5599 .map((projection) => ({
5600 ...threadShellFromProjection(projection),
5601 latestUserAuthoredMessageAt:
5602 projection.messages
5603 .filter((message) => message.role === "user" && message.createdBy === "user")
5604 .map((message) => message.updatedAt)
5605 .toSorted(
5606 (left, right) => DateTime.toEpochMillis(right) - DateTime.toEpochMillis(left),
5607 )[0] ?? null,
5608 }))
55825609 .toSorted(
55835610 (left, right) =>
55845611 DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) ||
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+40-8
......@@ -46,7 +46,12 @@ function at(offsetMs: number): DateTime.Utc {
4646 return DateTime.makeUnsafe(NOW_MS + offsetMs);
4747}
4848
49function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): OrchestrationV2ThreadShell {
49type SettlementShell = OrchestrationV2ThreadShell &
50 Pick<ProjectionStore.ProjectionSettlementCandidate, "latestUserAuthoredMessageAt">;
51
52// A fixture's user message is one the user wrote unless the test sets
53// latestUserAuthoredMessageAt on its own.
54function shell(overrides: Partial<SettlementShell> = {}): SettlementShell {
5055 return {
5156 id: ThreadId.make("thread-1"),
5257 projectId: ProjectId.make("project-1"),
......@@ -83,6 +88,7 @@ function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): Orchestrati
8388 latestRunStartedAt: null,
8489 latestRunCompletedAt: null,
8590 latestUserMessageAt: null,
91 latestUserAuthoredMessageAt: overrides.latestUserMessageAt ?? null,
8692 createdAt: at(-30 * DAY_MS),
8793 updatedAt: at(-10 * DAY_MS),
8894 archivedAt: null,
......@@ -305,6 +311,31 @@ describe("resolveAutoSettlementAt", () => {
305311 ).toEqual(shell().createdAt);
306312 });
307313
314 it("settles on merge after agent-started runs, but not after the user writes again", () => {
315 // A background command stopped after the merge and its notification
316 // started a run. Only the user's own messages hold a merged thread open.
317 const woken = shell({
318 latestUserMessageAt: at(-30 * 60 * 1_000),
319 latestUserAuthoredMessageAt: at(-2 * 60 * 60 * 1_000),
320 latestRunRequestedAt: at(-30 * 60 * 1_000),
321 latestRunCompletedAt: at(-29 * 60 * 1_000),
322 });
323 const input = {
324 thread: woken,
325 pullRequest: { state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)) },
326 nowMs: NOW_MS,
327 autoSettleAfterDays: null,
328 autoSettleOnMerge: true,
329 };
330 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-29 * 60 * 1_000));
331 expect(
332 ThreadSettlementService.resolveAutoSettlementAt({
333 ...input,
334 thread: { ...woken, latestUserAuthoredMessageAt: at(-30 * 60 * 1_000) },
335 }),
336 ).toBeNull();
337 });
338
308339 it("settles inactive threads even when their pull request remains open", () => {
309340 const input = {
310341 thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }),
......@@ -383,10 +414,7 @@ function makeProject(
383414 };
384415}
385416
386function makeThread(
387 id: string,
388 overrides: Partial<OrchestrationV2ThreadShell> = {},
389): OrchestrationV2ThreadShell {
417function makeThread(id: string, overrides: Partial<SettlementShell> = {}): SettlementShell {
390418 return shell({
391419 id: ThreadId.make(id),
392420 projectId: PROJECT_ID,
......@@ -398,10 +426,14 @@ function makeThread(
398426 });
399427}
400428
429type SettlementSnapshot = Omit<OrchestrationV2ShellSnapshot, "threads"> & {
430 readonly threads: ReadonlyArray<SettlementShell>;
431};
432
401433function makeSnapshot(
402 threads: ReadonlyArray<OrchestrationV2ThreadShell>,
434 threads: ReadonlyArray<SettlementShell>,
403435 projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],
404): OrchestrationV2ShellSnapshot {
436): SettlementSnapshot {
405437 return {
406438 schemaVersion: 1,
407439 snapshotSequence: 1,
......@@ -448,7 +480,7 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") {
448480}
449481
450482interface HarnessOptions {
451 readonly snapshot: OrchestrationV2ShellSnapshot;
483 readonly snapshot: SettlementSnapshot;
452484 readonly settings?: ContractServerSettings;
453485 readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"];
454486 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];
apps/server/src/orchestration-v2/ThreadSettlementService.ts+9-5
......@@ -106,10 +106,15 @@ export function threadHasQueuedTurnStart(
106106 ].every((value) => value === null || value < messageAtMs);
107107}
108108
109/**
110 * A merged or closed pull request settles the thread unless the user wrote to
111 * it afterwards. Runs that background work, a PR watch, or another agent
112 * started do not count, so they cannot hold a merged thread open.
113 */
109114function pullRequestSettles(
110115 thread: Pick<
111 OrchestrationV2ThreadShell,
112 "createdAt" | "latestUserMessageAt" | "latestRunRequestedAt"
116 ProjectionStore.ProjectionSettlementCandidate,
117 "createdAt" | "latestUserAuthoredMessageAt"
113118 >,
114119 pullRequest: SettlementPullRequest,
115120 autoSettleOnMerge: boolean,
......@@ -121,8 +126,7 @@ function pullRequestSettles(
121126 if (terminalAt == null) return false;
122127 const userAnchorMs = latestMillis([
123128 toMillis(thread.createdAt),
124 toMillis(thread.latestUserMessageAt),
125 toMillis(thread.latestRunRequestedAt),
129 toMillis(thread.latestUserAuthoredMessageAt),
126130 ]);
127131 if (userAnchorMs === null) return false;
128132 const pullRequestAtMs = Date.parse(terminalAt);
......@@ -132,7 +136,7 @@ function pullRequestSettles(
132136
133137/** Cheap checks that run before any source control lookup. */
134138export function isAutoSettlementCandidate(
135 thread: ProjectionStore.ProjectionSettlementCandidate,
139 thread: Omit<ProjectionStore.ProjectionSettlementCandidate, "latestUserAuthoredMessageAt">,
136140 nowMs: number,
137141): boolean {
138142 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
docs/user/thread-sidebar.md+2-1
......@@ -135,7 +135,8 @@ threads whose pull request merged. A closed pull request can also settle an idle
135135thread. Work in progress, pending questions or approvals, and live background work
136136prevent automatic settlement. An open pull request does not prevent inactivity
137137settlement, but an old closed or merged pull request does not settle work you
138resumed after it closed.
138resumed after it closed. Only your own messages count as resuming. A turn that
139finished background work or a pull request watch starts on its own does not.
139140
140141To keep one thread out of the settled shelf no matter how long it sits idle, open its menu,
141142choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick