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