| 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,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 | ); |
| 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 | const pullRequestLink = (number: number) => ({ | 349 | const 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 | >; |
| 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 | export type ProjectionSettlementCandidate = Pick< | 169 | export 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 }; |
| 191 | 197 | ||
| 192 | const ProjectionCheckpointContext = Schema.Struct({ | 198 | const 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 }; |
| 934 | 940 | ||
| 935 | type ShellRunItemCountRow = { | 941 | type 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 DESC | 5058 | ORDER BY message.updated_at DESC, message.message_id DESC |
| 5053 | LIMIT 1 | 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 | FROM orchestration_v2_projection_threads t | 5069 | 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 latest | 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,6 +5127,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5113 | row.latest_user_message_at === null | 5127 | row.latest_user_message_at === null |
| 5114 | ? null | 5128 | ? 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 | } |
| 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 | 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 | }); |
| 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 | 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 | } |
| 385 | 416 | ||
| 386 | function makeThread( | 417 | function 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 | } |
| 400 | 428 | ||
| 429 | type SettlementSnapshot = Omit<OrchestrationV2ShellSnapshot, "threads"> & { | ||
| 430 | readonly threads: ReadonlyArray<SettlementShell>; | ||
| 431 | }; | ||
| 432 | |||
| 401 | function makeSnapshot( | 433 | function 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 | } |
| 449 | 481 | ||
| 450 | interface HarnessOptions { | 482 | interface 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 | } |
| 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 | function pullRequestSettles( | 114 | function 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( |
| 132 | 136 | ||
| 133 | /** Cheap checks that run before any source control lookup. */ | 137 | /** Cheap checks that run before any source control lookup. */ |
| 134 | export function isAutoSettlementCandidate( | 138 | export 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 |
| 135 | thread. Work in progress, pending questions or approvals, and live background work | 135 | thread. Work in progress, pending questions or approvals, and live background work |
| 136 | prevent automatic settlement. An open pull request does not prevent inactivity | 136 | prevent automatic settlement. An open pull request does not prevent inactivity |
| 137 | settlement, but an old closed or merged pull request does not settle work you | 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 | To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, | 141 | To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, |
| 141 | choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick | 142 | choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick |