From 243d1e7c4499d7ba4dc5b22794fcd2c880936c2d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 16:24:18 -0700 Subject: [PATCH] fix(server): merged threads settle even after the agent wakes on its own (#15388) Co-authored-by: Claude Opus 5.5 (1M context) --- .../ProjectionSettlement.test.ts | 56 ++++++++++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 37 ++++++++++-- .../ThreadSettlementService.test.ts | 48 +++++++++++++--- .../ThreadSettlementService.ts | 14 +++-- docs/user/thread-sidebar.md | 3 +- 5 files changed, 138 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 03b67cc4e4ceead8b80e113d408941552d653f27..1e8297c362c2812a7a9ae6fef5f997a45059d15a 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -278,7 +278,13 @@ it.effect.each([ }; assert.deepEqual( resolveAutoSettlementAt({ ...settings, thread: candidate }), - resolveAutoSettlementAt({ ...settings, thread: expected }), + resolveAutoSettlementAt({ + ...settings, + thread: { + ...expected, + latestUserAuthoredMessageAt: candidate.latestUserAuthoredMessageAt, + }, + }), ); } assert.equal( @@ -292,6 +298,54 @@ it.effect.each([ }).pipe(Effect.provide(testLayer)), ); +it.effect.each([ + ["sql", SqlLayer], + ["memory", ProjectionStore.layerMemory], +] as const)("%s: the user-authored message time ignores agent notifications", ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* createThread("agent-woken"); + yield* createRun(threadId); + const message = ( + id: string, + createdBy: "user" | "agent", + creationSource: "web" | "provider", + at: DateTime.Utc, + ) => + store.apply({ + id: EventId.make(`event:settlement:${id}`), + type: "message.updated", + threadId, + occurredAt: at, + payload: { + createdBy, + creationSource, + id: MessageId.make(`message:settlement:${id}`), + threadId, + runId: null, + nodeId: null, + role: "user", + text: id, + attachments: [], + streaming: false, + createdAt: at, + updatedAt: at, + }, + }); + const written = DateTime.subtract(now, { hours: 2 }); + yield* message("written", "user", "web", written); + yield* message("notification", "agent", "provider", now); + + const [candidate] = yield* store.getSettlementCandidates(threadId); + assert.isDefined(candidate); + assert.equal(DateTime.formatIso(candidate.latestUserMessageAt!), DateTime.formatIso(now)); + assert.equal( + DateTime.formatIso(candidate.latestUserAuthoredMessageAt!), + DateTime.formatIso(written), + ); + }).pipe(Effect.provide(testLayer)), +); + const pullRequestLink = (number: number) => ({ host: "github.com", repository: "owner/repository", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ac0624b058068ae4cb530681b4d3496bd0666db6..f35bbfc348c8eb227e5197e12d4d2b16cc11e3c3 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -159,7 +159,13 @@ export type ProjectionThreadPullRequests = Pick< "id" | "projectId" | "settledOverride" | "settledAt" | "pullRequests" >; -/** Thread activity needed by settlement, without transcript or fork history. */ +/** + * Thread activity needed by settlement, without transcript or fork history. + * `latestUserAuthoredMessageAt` is the last message the user wrote. Agent, + * provider, and server notifications also use the user role, so + * `latestUserMessageAt` moves when background work or a PR watch wakes the + * agent. + */ export type ProjectionSettlementCandidate = Pick< OrchestrationV2ThreadShell, | "id" @@ -187,7 +193,7 @@ export type ProjectionSettlementCandidate = Pick< | "activityRunStatus" | "pendingRuntimeRequest" | "pendingBackgroundTasks" ->; +> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null }; const ProjectionCheckpointContext = Schema.Struct({ runs: Schema.Array( @@ -930,7 +936,7 @@ type SettlementThreadRow = Pick< | "latest_run_started_at" | "latest_run_completed_at" | "latest_user_message_at" ->; +> & { readonly latest_user_authored_message_at: string | null }; type ShellRunItemCountRow = { readonly thread_id: string; @@ -5051,7 +5057,15 @@ export const layer: Layer.Layer = WHERE message.thread_id = t.thread_id AND message.role = 'user' ORDER BY message.updated_at DESC, message.message_id DESC LIMIT 1 - ) AS latest_user_message_at + ) AS latest_user_message_at, + ( + SELECT message.updated_at + FROM orchestration_v2_projection_messages message + WHERE message.thread_id = t.thread_id AND message.role = 'user' + AND json_extract(message.payload_json, '$.createdBy') = 'user' + ORDER BY message.updated_at DESC, message.message_id DESC + LIMIT 1 + ) AS latest_user_authored_message_at FROM orchestration_v2_projection_threads t LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = ( SELECT latest.run_id FROM orchestration_v2_projection_runs latest @@ -5113,6 +5127,10 @@ export const layer: Layer.Layer = row.latest_user_message_at === null ? null : DateTime.makeUnsafe(row.latest_user_message_at), + latestUserAuthoredMessageAt: + row.latest_user_authored_message_at === null + ? null + : DateTime.makeUnsafe(row.latest_user_authored_message_at), activityRunStatus: null, activityRunStartedAt: null, pendingRuntimeRequest: null, @@ -5578,7 +5596,16 @@ export const layerMemory: Layer.Layer = Layer.effect( !runs.some(isActivityRunForShell) && !runtimeRequests.some((request) => request.status === "pending"), ) - .map(threadShellFromProjection) + .map((projection) => ({ + ...threadShellFromProjection(projection), + latestUserAuthoredMessageAt: + projection.messages + .filter((message) => message.role === "user" && message.createdBy === "user") + .map((message) => message.updatedAt) + .toSorted( + (left, right) => DateTime.toEpochMillis(right) - DateTime.toEpochMillis(left), + )[0] ?? null, + })) .toSorted( (left, right) => DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) || diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 1c0671ddff45de0aa1b339c96696087d7f9f31cb..ef12204541b6232c5d19d735a8868e7ceca545b4 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -46,7 +46,12 @@ function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs); } -function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { +type SettlementShell = OrchestrationV2ThreadShell & + Pick; + +// A fixture's user message is one the user wrote unless the test sets +// latestUserAuthoredMessageAt on its own. +function shell(overrides: Partial = {}): SettlementShell { return { id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), @@ -83,6 +88,7 @@ function shell(overrides: Partial = {}): Orchestrati latestRunStartedAt: null, latestRunCompletedAt: null, latestUserMessageAt: null, + latestUserAuthoredMessageAt: overrides.latestUserMessageAt ?? null, createdAt: at(-30 * DAY_MS), updatedAt: at(-10 * DAY_MS), archivedAt: null, @@ -305,6 +311,31 @@ describe("resolveAutoSettlementAt", () => { ).toEqual(shell().createdAt); }); + it("settles on merge after agent-started runs, but not after the user writes again", () => { + // A background command stopped after the merge and its notification + // started a run. Only the user's own messages hold a merged thread open. + const woken = shell({ + latestUserMessageAt: at(-30 * 60 * 1_000), + latestUserAuthoredMessageAt: at(-2 * 60 * 60 * 1_000), + latestRunRequestedAt: at(-30 * 60 * 1_000), + latestRunCompletedAt: at(-29 * 60 * 1_000), + }); + const input = { + thread: woken, + pullRequest: { state: "merged" as const, mergedAt: DateTime.formatIso(at(-60 * 60 * 1_000)) }, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-29 * 60 * 1_000)); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + thread: { ...woken, latestUserAuthoredMessageAt: at(-30 * 60 * 1_000) }, + }), + ).toBeNull(); + }); + it("settles inactive threads even when their pull request remains open", () => { const input = { thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }), @@ -383,10 +414,7 @@ function makeProject( }; } -function makeThread( - id: string, - overrides: Partial = {}, -): OrchestrationV2ThreadShell { +function makeThread(id: string, overrides: Partial = {}): SettlementShell { return shell({ id: ThreadId.make(id), projectId: PROJECT_ID, @@ -398,10 +426,14 @@ function makeThread( }); } +type SettlementSnapshot = Omit & { + readonly threads: ReadonlyArray; +}; + function makeSnapshot( - threads: ReadonlyArray, + threads: ReadonlyArray, projects: ReadonlyArray = [makeProject()], -): OrchestrationV2ShellSnapshot { +): SettlementSnapshot { return { schemaVersion: 1, snapshotSequence: 1, @@ -448,7 +480,7 @@ function makeBranchPullRequest(state: "open" | "closed" | "merged") { } interface HarnessOptions { - readonly snapshot: OrchestrationV2ShellSnapshot; + readonly snapshot: SettlementSnapshot; readonly settings?: ContractServerSettings; readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index bcd4f7b1ebc943be30cb156e431b3ae523308d14..7c64ce02804ae263aa688d8f3d01f5cad6de7d2b 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -106,10 +106,15 @@ export function threadHasQueuedTurnStart( ].every((value) => value === null || value < messageAtMs); } +/** + * A merged or closed pull request settles the thread unless the user wrote to + * it afterwards. Runs that background work, a PR watch, or another agent + * started do not count, so they cannot hold a merged thread open. + */ function pullRequestSettles( thread: Pick< - OrchestrationV2ThreadShell, - "createdAt" | "latestUserMessageAt" | "latestRunRequestedAt" + ProjectionStore.ProjectionSettlementCandidate, + "createdAt" | "latestUserAuthoredMessageAt" >, pullRequest: SettlementPullRequest, autoSettleOnMerge: boolean, @@ -121,8 +126,7 @@ function pullRequestSettles( if (terminalAt == null) return false; const userAnchorMs = latestMillis([ toMillis(thread.createdAt), - toMillis(thread.latestUserMessageAt), - toMillis(thread.latestRunRequestedAt), + toMillis(thread.latestUserAuthoredMessageAt), ]); if (userAnchorMs === null) return false; const pullRequestAtMs = Date.parse(terminalAt); @@ -132,7 +136,7 @@ function pullRequestSettles( /** Cheap checks that run before any source control lookup. */ export function isAutoSettlementCandidate( - thread: ProjectionStore.ProjectionSettlementCandidate, + thread: Omit, nowMs: number, ): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b591d518805da2ffad10693d6bf0e5cd40b17a0b..a43896189d71835cc271781917a5f405d427cb9e 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -135,7 +135,8 @@ threads whose pull request merged. A closed pull request can also settle an idle thread. Work in progress, pending questions or approvals, and live background work prevent automatic settlement. An open pull request does not prevent inactivity settlement, but an old closed or merged pull request does not settle work you -resumed after it closed. +resumed after it closed. Only your own messages count as resuming. A turn that +finished background work or a pull request watch starts on its own does not. To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick -- 2.54.0