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([...@@ -278,7 +278,13 @@ it.effect.each([
278 };278 };
279 assert.deepEqual(279 assert.deepEqual(
280 resolveAutoSettlementAt({ ...settings, thread: candidate }),280 resolveAutoSettlementAt({ ...settings, thread: candidate }),
281 resolveAutoSettlementAt({ ...settings, thread: expected }),281 resolveAutoSettlementAt({
282 ...settings,
283 thread: {
284 ...expected,
285 latestUserAuthoredMessageAt: candidate.latestUserAuthoredMessageAt,
286 },
287 }),
282 );288 );
283 }289 }
284 assert.equal(290 assert.equal(
...@@ -292,6 +298,54 @@ it.effect.each([...@@ -292,6 +298,54 @@ it.effect.each([
292 }).pipe(Effect.provide(testLayer)),298 }).pipe(Effect.provide(testLayer)),
293);299);
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
295const pullRequestLink = (number: number) => ({349const pullRequestLink = (number: number) => ({
296 host: "github.com",350 host: "github.com",
297 repository: "owner/repository",351 repository: "owner/repository",
apps/server/src/orchestration-v2/ProjectionStore.ts+32-5
...@@ -159,7 +159,13 @@ export type ProjectionThreadPullRequests = Pick<...@@ -159,7 +159,13 @@ export type ProjectionThreadPullRequests = Pick<
159 "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests"159 "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests"
160>;160>;
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 */
163export type ProjectionSettlementCandidate = Pick<169export type ProjectionSettlementCandidate = Pick<
164 OrchestrationV2ThreadShell,170 OrchestrationV2ThreadShell,
165 | "id"171 | "id"
...@@ -187,7 +193,7 @@ export type ProjectionSettlementCandidate = Pick<...@@ -187,7 +193,7 @@ export type ProjectionSettlementCandidate = Pick<
187 | "activityRunStatus"193 | "activityRunStatus"
188 | "pendingRuntimeRequest"194 | "pendingRuntimeRequest"
189 | "pendingBackgroundTasks"195 | "pendingBackgroundTasks"
190>;196> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null };
191197
192const ProjectionCheckpointContext = Schema.Struct({198const ProjectionCheckpointContext = Schema.Struct({
193 runs: Schema.Array(199 runs: Schema.Array(
...@@ -930,7 +936,7 @@ type SettlementThreadRow = Pick<...@@ -930,7 +936,7 @@ type SettlementThreadRow = Pick<
930 | "latest_run_started_at"936 | "latest_run_started_at"
931 | "latest_run_completed_at"937 | "latest_run_completed_at"
932 | "latest_user_message_at"938 | "latest_user_message_at"
933>;939> & { readonly latest_user_authored_message_at: string | null };
934940
935type ShellRunItemCountRow = {941type ShellRunItemCountRow = {
936 readonly thread_id: string;942 readonly thread_id: string;
...@@ -5051,7 +5057,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5051,7 +5057,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
5051 WHERE message.thread_id = t.thread_id AND message.role = 'user'5057 WHERE message.thread_id = t.thread_id AND message.role = 'user'
5052 ORDER BY message.updated_at DESC, message.message_id DESC5058 ORDER BY message.updated_at DESC, message.message_id DESC
5053 LIMIT 15059 LIMIT 1
5054 ) AS latest_user_message_at5060 ) 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
5055 FROM orchestration_v2_projection_threads t5069 FROM orchestration_v2_projection_threads t
5056 LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = (5070 LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = (
5057 SELECT latest.run_id FROM orchestration_v2_projection_runs latest5071 SELECT latest.run_id FROM orchestration_v2_projection_runs latest
...@@ -5113,6 +5127,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5113,6 +5127,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
5113 row.latest_user_message_at === null5127 row.latest_user_message_at === null
5114 ? null5128 ? null
5115 : DateTime.makeUnsafe(row.latest_user_message_at),5129 : 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),
5116 activityRunStatus: null,5134 activityRunStatus: null,
5117 activityRunStartedAt: null,5135 activityRunStartedAt: null,
5118 pendingRuntimeRequest: null,5136 pendingRuntimeRequest: null,
...@@ -5578,7 +5596,16 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(...@@ -5578,7 +5596,16 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
5578 !runs.some(isActivityRunForShell) &&5596 !runs.some(isActivityRunForShell) &&
5579 !runtimeRequests.some((request) => request.status === "pending"),5597 !runtimeRequests.some((request) => request.status === "pending"),
5580 )5598 )
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 }))
5582 .toSorted(5609 .toSorted(
5583 (left, right) =>5610 (left, right) =>
5584 DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) ||5611 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 {...@@ -46,7 +46,12 @@ function at(offsetMs: number): DateTime.Utc {
46 return DateTime.makeUnsafe(NOW_MS + offsetMs);46 return DateTime.makeUnsafe(NOW_MS + offsetMs);
47}47}
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 {
50 return {55 return {
51 id: ThreadId.make("thread-1"),56 id: ThreadId.make("thread-1"),
52 projectId: ProjectId.make("project-1"),57 projectId: ProjectId.make("project-1"),
...@@ -83,6 +88,7 @@ function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): Orchestrati...@@ -83,6 +88,7 @@ function shell(overrides: Partial<OrchestrationV2ThreadShell> = {}): Orchestrati
83 latestRunStartedAt: null,88 latestRunStartedAt: null,
84 latestRunCompletedAt: null,89 latestRunCompletedAt: null,
85 latestUserMessageAt: null,90 latestUserMessageAt: null,
91 latestUserAuthoredMessageAt: overrides.latestUserMessageAt ?? null,
86 createdAt: at(-30 * DAY_MS),92 createdAt: at(-30 * DAY_MS),
87 updatedAt: at(-10 * DAY_MS),93 updatedAt: at(-10 * DAY_MS),
88 archivedAt: null,94 archivedAt: null,
...@@ -305,6 +311,31 @@ describe("resolveAutoSettlementAt", () => {...@@ -305,6 +311,31 @@ describe("resolveAutoSettlementAt", () => {
305 ).toEqual(shell().createdAt);311 ).toEqual(shell().createdAt);
306 });312 });
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
308 it("settles inactive threads even when their pull request remains open", () => {339 it("settles inactive threads even when their pull request remains open", () => {
309 const input = {340 const input = {
310 thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }),341 thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }),
...@@ -383,10 +414,7 @@ function makeProject(...@@ -383,10 +414,7 @@ function makeProject(
383 };414 };
384}415}
385416
386function makeThread(417function makeThread(id: string, overrides: Partial<SettlementShell> = {}): SettlementShell {
387 id: string,
388 overrides: Partial<OrchestrationV2ThreadShell> = {},
389): OrchestrationV2ThreadShell {
390 return shell({418 return shell({
391 id: ThreadId.make(id),419 id: ThreadId.make(id),
392 projectId: PROJECT_ID,420 projectId: PROJECT_ID,
...@@ -398,10 +426,14 @@ function makeThread(...@@ -398,10 +426,14 @@ function makeThread(
398 });426 });
399}427}
400428
429type SettlementSnapshot = Omit<OrchestrationV2ShellSnapshot, "threads"> & {
430 readonly threads: ReadonlyArray<SettlementShell>;
431};
432
401function makeSnapshot(433function makeSnapshot(
402 threads: ReadonlyArray<OrchestrationV2ThreadShell>,434 threads: ReadonlyArray<SettlementShell>,
403 projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],435 projects: ReadonlyArray<OrchestrationProjectShell> = [makeProject()],
404): OrchestrationV2ShellSnapshot {436): SettlementSnapshot {
405 return {437 return {
406 schemaVersion: 1,438 schemaVersion: 1,
407 snapshotSequence: 1,439 snapshotSequence: 1,
...@@ -448,7 +480,7 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") {...@@ -448,7 +480,7 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") {
448}480}
449481
450interface HarnessOptions {482interface HarnessOptions {
451 readonly snapshot: OrchestrationV2ShellSnapshot;483 readonly snapshot: SettlementSnapshot;
452 readonly settings?: ContractServerSettings;484 readonly settings?: ContractServerSettings;
453 readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"];485 readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"];
454 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];486 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];
apps/server/src/orchestration-v2/ThreadSettlementService.ts+9-5
...@@ -106,10 +106,15 @@ export function threadHasQueuedTurnStart(...@@ -106,10 +106,15 @@ export function threadHasQueuedTurnStart(
106 ].every((value) => value === null || value < messageAtMs);106 ].every((value) => value === null || value < messageAtMs);
107}107}
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 */
109function pullRequestSettles(114function pullRequestSettles(
110 thread: Pick<115 thread: Pick<
111 OrchestrationV2ThreadShell,116 ProjectionStore.ProjectionSettlementCandidate,
112 "createdAt" | "latestUserMessageAt" | "latestRunRequestedAt"117 "createdAt" | "latestUserAuthoredMessageAt"
113 >,118 >,
114 pullRequest: SettlementPullRequest,119 pullRequest: SettlementPullRequest,
115 autoSettleOnMerge: boolean,120 autoSettleOnMerge: boolean,
...@@ -121,8 +126,7 @@ function pullRequestSettles(...@@ -121,8 +126,7 @@ function pullRequestSettles(
121 if (terminalAt == null) return false;126 if (terminalAt == null) return false;
122 const userAnchorMs = latestMillis([127 const userAnchorMs = latestMillis([
123 toMillis(thread.createdAt),128 toMillis(thread.createdAt),
124 toMillis(thread.latestUserMessageAt),129 toMillis(thread.latestUserAuthoredMessageAt),
125 toMillis(thread.latestRunRequestedAt),
126 ]);130 ]);
127 if (userAnchorMs === null) return false;131 if (userAnchorMs === null) return false;
128 const pullRequestAtMs = Date.parse(terminalAt);132 const pullRequestAtMs = Date.parse(terminalAt);
...@@ -132,7 +136,7 @@ function pullRequestSettles(...@@ -132,7 +136,7 @@ function pullRequestSettles(
132136
133/** Cheap checks that run before any source control lookup. */137/** Cheap checks that run before any source control lookup. */
134export function isAutoSettlementCandidate(138export function isAutoSettlementCandidate(
135 thread: ProjectionStore.ProjectionSettlementCandidate,139 thread: Omit<ProjectionStore.ProjectionSettlementCandidate, "latestUserAuthoredMessageAt">,
136 nowMs: number,140 nowMs: number,
137): boolean {141): boolean {
138 if (thread.archivedAt !== null || thread.settledOverride !== null) return false;142 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...@@ -135,7 +135,8 @@ threads whose pull request merged. A closed pull request can also settle an idle
135thread. Work in progress, pending questions or approvals, and live background work135thread. Work in progress, pending questions or approvals, and live background work
136prevent automatic settlement. An open pull request does not prevent inactivity136prevent automatic settlement. An open pull request does not prevent inactivity
137settlement, but an old closed or merged pull request does not settle work you137settlement, 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
140To keep one thread out of the settled shelf no matter how long it sits idle, open its menu,141To keep one thread out of the settled shelf no matter how long it sits idle, open its menu,
141choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick142choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick