| author | |
| committer | |
| log | ca4f84e702428656665d53635e16251112091657 |
| tree | 761e93a9c9c18607b79cea00bf1b2d99147ee2f4 |
| parent | 736130c2fd39b85cd515fa2a41cd10c8a8a52630 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 202 insertions(+), 19 deletions(-)
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+4| ... | ... | @@ -335,6 +335,10 @@ it.effect.each([ |
| 335 | 335 | [open, null, 2], |
| 336 | 336 | ], |
| 337 | 337 | ); |
| 338 | assert.deepEqual( | |
| 339 | (yield* store.getThreadsWithPullRequests(open)).map((thread) => thread.id), | |
| 340 | [open], | |
| 341 | ); | |
| 338 | 342 | }).pipe(Effect.provide(testLayer)), |
| 339 | 343 | ); |
| 340 | 344 |
apps/server/src/orchestration-v2/ProjectionStore.ts+11-8| ... | ... | @@ -349,12 +349,12 @@ export interface ProjectionStoreV2Shape { |
| 349 | 349 | ) => Effect.Effect<ReadonlyArray<ProjectionSettlementCandidate>, ProjectionStoreV2Error>; |
| 350 | 350 | /** |
| 351 | 351 | * Active (not deleted, not archived) threads with at least one pull request |
| 352 | * link, in shell snapshot order. Skips run, message and item reads. | |
| 352 | * link, in shell snapshot order, or only `threadId` when given. Skips run, | |
| 353 | * message and item reads. | |
| 353 | 354 | */ |
| 354 | readonly getThreadsWithPullRequests: () => Effect.Effect< | |
| 355 | ReadonlyArray<ProjectionThreadPullRequests>, | |
| 356 | ProjectionStoreV2Error | |
| 357 | >; | |
| 355 | readonly getThreadsWithPullRequests: ( | |
| 356 | threadId?: ThreadId, | |
| 357 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadPullRequests>, ProjectionStoreV2Error>; | |
| 358 | 358 | readonly getTurnStartContext: ( |
| 359 | 359 | threadId: ThreadId, |
| 360 | 360 | runId: RunId, |
| ... | ... | @@ -5133,12 +5133,14 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5133 | 5133 | ) |
| 5134 | 5134 | .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); |
| 5135 | 5135 | |
| 5136 | const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = () => | |
| 5136 | const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = ( | |
| 5137 | threadId, | |
| 5138 | ) => | |
| 5137 | 5139 | Effect.gen(function* () { |
| 5138 | 5140 | const rows = yield* sql<PayloadRow>` |
| 5139 | 5141 | SELECT payload_json |
| 5140 | 5142 | FROM orchestration_v2_projection_threads |
| 5141 | WHERE deleted_at IS NULL | |
| 5143 | WHERE deleted_at IS NULL${threadId === undefined ? sql`` : sql` AND thread_id = ${threadId}`} | |
| 5142 | 5144 | AND json_extract(payload_json, '$.archivedAt') IS NULL |
| 5143 | 5145 | AND json_array_length(payload_json, '$.pullRequests') > 0 |
| 5144 | 5146 | ORDER BY updated_at ASC, thread_id ASC |
| ... | ... | @@ -5583,13 +5585,14 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( |
| 5583 | 5585 | left.id.localeCompare(right.id), |
| 5584 | 5586 | ); |
| 5585 | 5587 | }), |
| 5586 | getThreadsWithPullRequests: () => | |
| 5588 | getThreadsWithPullRequests: (threadId) => | |
| 5587 | 5589 | Ref.get(replayState).pipe( |
| 5588 | 5590 | Effect.map((state) => |
| 5589 | 5591 | [...state.projections.values()] |
| 5590 | 5592 | .map(({ thread }) => thread) |
| 5591 | 5593 | .filter( |
| 5592 | 5594 | (thread) => |
| 5595 | (threadId === undefined || thread.id === threadId) && | |
| 5593 | 5596 | thread.deletedAt === null && |
| 5594 | 5597 | thread.archivedAt === null && |
| 5595 | 5598 | (thread.pullRequests ?? []).length > 0, |
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+134-3| ... | ... | @@ -1,10 +1,16 @@ |
| 1 | 1 | import * as Stream from "effect/Stream"; |
| 2 | 2 | import { |
| 3 | EventId, | |
| 4 | MessageId, | |
| 3 | 5 | ProjectId, |
| 4 | 6 | ProviderInstanceId, |
| 5 | 7 | PullRequestOperationError, |
| 8 | RunId, | |
| 6 | 9 | ThreadId, |
| 10 | TurnItemId, | |
| 7 | 11 | type OrchestrationV2Command as OrchestrationCommand, |
| 12 | type OrchestrationV2DomainEvent, | |
| 13 | type OrchestrationV2Run, | |
| 8 | 14 | type OrchestrationProjectShell, |
| 9 | 15 | type PullRequestRef, |
| 10 | 16 | type PullRequestStack, |
| ... | ... | @@ -171,6 +177,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 171 | 177 | const linkCommands = yield* Ref.make<ReadonlyArray<LinkCommand>>([]); |
| 172 | 178 | const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]); |
| 173 | 179 | const stackCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]); |
| 180 | const domainEvents = yield* Queue.unbounded<OrchestrationV2DomainEvent>(); | |
| 174 | 181 | |
| 175 | 182 | const summary: PullRequestService.PullRequestService["Service"]["summary"] = ( |
| 176 | 183 | input, |
| ... | ... | @@ -211,12 +218,17 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 211 | 218 | }), |
| 212 | 219 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ |
| 213 | 220 | // Mirrors the store's filter: active threads that have at least one link. |
| 214 | getThreadsWithPullRequests: () => | |
| 221 | getThreadsWithPullRequests: (threadId) => | |
| 215 | 222 | Queue.offer(snapshotReads, undefined).pipe( |
| 216 | 223 | Effect.andThen(Ref.get(snapshots)), |
| 217 | 224 | Effect.map((snapshot) => |
| 218 | 225 | snapshot.threads |
| 219 | .filter((thread) => thread.archivedAt === null && thread.pullRequests.length > 0) | |
| 226 | .filter( | |
| 227 | (thread) => | |
| 228 | (threadId === undefined || thread.id === threadId) && | |
| 229 | thread.archivedAt === null && | |
| 230 | thread.pullRequests.length > 0, | |
| 231 | ) | |
| 220 | 232 | .map((thread) => ({ |
| 221 | 233 | id: thread.id, |
| 222 | 234 | projectId: thread.projectId, |
| ... | ... | @@ -233,7 +245,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 233 | 245 | Effect.andThen(Effect.die(new Error("pull request sync must not read the shell"))), |
| 234 | 246 | ), |
| 235 | 247 | dispatch, |
| 236 | streamDomainEvents: Stream.empty, | |
| 248 | streamDomainEvents: Stream.fromQueue(domainEvents), | |
| 237 | 249 | }), |
| 238 | 250 | Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), |
| 239 | 251 | Layer.succeed(Crypto.Crypto, testCrypto), |
| ... | ... | @@ -248,6 +260,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 248 | 260 | linkCommands, |
| 249 | 261 | summaryCalls, |
| 250 | 262 | stackCalls, |
| 263 | domainEvents, | |
| 251 | 264 | layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)), |
| 252 | 265 | }; |
| 253 | 266 | }); |
| ... | ... | @@ -272,6 +285,68 @@ const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* ( |
| 272 | 285 | yield* reactor.drain; |
| 273 | 286 | }); |
| 274 | 287 | |
| 288 | function runUpdated( | |
| 289 | threadId: ThreadId, | |
| 290 | status: OrchestrationV2Run["status"], | |
| 291 | ): OrchestrationV2DomainEvent { | |
| 292 | const runId = RunId.make(`run:${threadId}:1`); | |
| 293 | const at = DateTime.makeUnsafe(NOW); | |
| 294 | return { | |
| 295 | type: "run.updated", | |
| 296 | id: EventId.make(`event:${threadId}:${status}`), | |
| 297 | threadId, | |
| 298 | runId, | |
| 299 | occurredAt: at, | |
| 300 | payload: { | |
| 301 | id: runId, | |
| 302 | threadId, | |
| 303 | ordinal: 1, | |
| 304 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 305 | modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, | |
| 306 | providerThreadId: null, | |
| 307 | userMessageId: MessageId.make(`message:${threadId}:1`), | |
| 308 | rootNodeId: null, | |
| 309 | activeAttemptId: null, | |
| 310 | status, | |
| 311 | requestedAt: at, | |
| 312 | startedAt: at, | |
| 313 | completedAt: at, | |
| 314 | checkpointId: null, | |
| 315 | contextHandoffId: null, | |
| 316 | }, | |
| 317 | }; | |
| 318 | } | |
| 319 | ||
| 320 | function commandRan(threadId: ThreadId, input: string): OrchestrationV2DomainEvent { | |
| 321 | const runId = RunId.make(`run:${threadId}:1`); | |
| 322 | const at = DateTime.makeUnsafe(NOW); | |
| 323 | return { | |
| 324 | type: "turn-item.updated", | |
| 325 | id: EventId.make(`event:${threadId}:command`), | |
| 326 | threadId, | |
| 327 | runId, | |
| 328 | occurredAt: at, | |
| 329 | payload: { | |
| 330 | id: TurnItemId.make(`item:${threadId}:command`), | |
| 331 | threadId, | |
| 332 | runId, | |
| 333 | nodeId: null, | |
| 334 | providerThreadId: null, | |
| 335 | providerTurnId: null, | |
| 336 | nativeItemRef: null, | |
| 337 | parentItemId: null, | |
| 338 | ordinal: 1, | |
| 339 | status: "completed", | |
| 340 | title: null, | |
| 341 | startedAt: at, | |
| 342 | completedAt: at, | |
| 343 | updatedAt: at, | |
| 344 | type: "command_execution", | |
| 345 | input, | |
| 346 | }, | |
| 347 | }; | |
| 348 | } | |
| 349 | ||
| 275 | 350 | /** What the reactor would have persisted, so the next sweep sees its own writes. */ |
| 276 | 351 | function applySync( |
| 277 | 352 | snapshot: TestShellSnapshot, |
| ... | ... | @@ -832,4 +907,60 @@ describe("PullRequestSyncReactor", () => { |
| 832 | 907 | }), |
| 833 | 908 | ), |
| 834 | 909 | ); |
| 910 | ||
| 911 | it.effect("reads open links fresh only when a run that ran a merge command ends", () => | |
| 912 | Effect.scoped( | |
| 913 | Effect.gen(function* () { | |
| 914 | yield* TestClock.setTime(Date.parse(NOW)); | |
| 915 | const state = yield* Ref.make<PullRequestSummary["state"]>("open"); | |
| 916 | const invalidated = yield* Ref.make<ReadonlyArray<number>>([]); | |
| 917 | const fixture = yield* makeHarness({ | |
| 918 | snapshot: makeSnapshot([ | |
| 919 | makeThread("agent", { pullRequests: [makeLink(7, { state: "open" })] }), | |
| 920 | makeThread("other", { pullRequests: [makeLink(9, { state: "open" })] }), | |
| 921 | ]), | |
| 922 | summary: (input) => | |
| 923 | Ref.get(state).pipe( | |
| 924 | Effect.map((current) => | |
| 925 | makeSummary(input, current === "merged" ? { state: current, mergedAt: NOW } : {}), | |
| 926 | ), | |
| 927 | ), | |
| 928 | invalidate: ({ reference }) => | |
| 929 | Ref.update(invalidated, (numbers) => [...numbers, reference?.number ?? -1]), | |
| 930 | }); | |
| 931 | ||
| 932 | yield* Effect.gen(function* () { | |
| 933 | const reactor = yield* startAndSweep(fixture); | |
| 934 | // The agent merges from a shell during its turn, inside the summary cache window. | |
| 935 | yield* Ref.set(state, "merged"); | |
| 936 | yield* Ref.set(fixture.summaryCalls, []); | |
| 937 | ||
| 938 | yield* Queue.offerAll(fixture.domainEvents, [ | |
| 939 | // A run that only reads its pull request costs no host read when it ends. | |
| 940 | commandRan(ThreadId.make("other"), "gh pr view 9"), | |
| 941 | runUpdated(ThreadId.make("other"), "completed"), | |
| 942 | commandRan(ThreadId.make("agent"), "gh pr merge 7 --squash 2>&1 | tail -3"), | |
| 943 | runUpdated(ThreadId.make("agent"), "completed"), | |
| 944 | ]); | |
| 945 | // The agent thread's lookup, then the requested sweep. | |
| 946 | yield* Queue.take(fixture.snapshotReads); | |
| 947 | yield* Queue.take(fixture.snapshotReads); | |
| 948 | yield* reactor.drain; | |
| 949 | ||
| 950 | assert.deepStrictEqual(yield* Ref.get(invalidated), [7]); | |
| 951 | assert.deepStrictEqual( | |
| 952 | (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number), | |
| 953 | [7], | |
| 954 | ); | |
| 955 | assert.deepStrictEqual( | |
| 956 | (yield* Ref.get(fixture.syncCommands)).map((command) => [ | |
| 957 | command.number, | |
| 958 | command.snapshot.state, | |
| 959 | ]), | |
| 960 | [[7, "merged"]], | |
| 961 | ); | |
| 962 | }).pipe(Effect.provide(fixture.layer)); | |
| 963 | }), | |
| 964 | ), | |
| 965 | ); | |
| 835 | 966 | }); |
apps/server/src/orchestration-v2/PullRequestSyncReactor.ts+48-6| ... | ... | @@ -2,6 +2,7 @@ import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 2 | 2 | import { |
| 3 | 3 | CommandId, |
| 4 | 4 | type PullRequestSummary, |
| 5 | type ThreadId, | |
| 5 | 6 | type ThreadPullRequestKey, |
| 6 | 7 | type ThreadPullRequestLink, |
| 7 | 8 | type ThreadPullRequestSnapshot, |
| ... | ... | @@ -29,8 +30,11 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 29 | 30 | import { forkParked } from "../serverActivation.ts"; |
| 30 | 31 | import * as Orchestrator from "./Orchestrator.ts"; |
| 31 | 32 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 33 | import { isTerminalRunStatus } from "./ThreadManagementService.ts"; | |
| 32 | 34 | |
| 33 | 35 | const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; |
| 36 | /** Shell commands that can merge or close a pull request without a merge notification. */ | |
| 37 | const PULL_REQUEST_CLOSE_COMMAND = /\b(?:gh\s+pr|glab\s+mr)\s+(?:merge|close)\b/u; | |
| 34 | 38 | |
| 35 | 39 | type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">; |
| 36 | 40 | |
| ... | ... | @@ -330,22 +334,60 @@ export const make = Effect.gen(function* () { |
| 330 | 334 | }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), |
| 331 | 335 | ); |
| 332 | 336 | |
| 337 | // Threads whose current run ran a merge or close command, until that run ends. | |
| 338 | const closeCommandThreads = new Set<ThreadId>(); | |
| 339 | const refreshOpenLinks = (threadId: ThreadId) => | |
| 340 | projections.getThreadsWithPullRequests(threadId).pipe( | |
| 341 | Effect.flatMap((threads) => | |
| 342 | Effect.forEach( | |
| 343 | threads.flatMap((thread) => | |
| 344 | visibleThreadPullRequests(thread.pullRequests ?? []).filter( | |
| 345 | (link) => link.snapshot?.state === "open", | |
| 346 | ), | |
| 347 | ), | |
| 348 | requestSync, | |
| 349 | { discard: true }, | |
| 350 | ), | |
| 351 | ), | |
| 352 | Effect.catchCause(logSkipped("pull request refresh after run skipped", { threadId })), | |
| 353 | ); | |
| 354 | ||
| 333 | 355 | const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( |
| 334 | 356 | "PullRequestSyncReactor.start", |
| 335 | 357 | )(function* () { |
| 336 | 358 | const events = engine.streamDomainEvents; |
| 337 | 359 | yield* forkParked( |
| 338 | Stream.runForEach(events, (event) => | |
| 339 | event.type === "thread.pull-request-synced" | |
| 340 | ? Effect.forEach( | |
| 360 | Stream.runForEach(events, (event) => { | |
| 361 | switch (event.type) { | |
| 362 | case "thread.pull-request-synced": | |
| 363 | return Effect.forEach( | |
| 341 | 364 | visibleThreadPullRequests(event.payload.pullRequests ?? []).filter( |
| 342 | 365 | (link) => link.snapshot === null, |
| 343 | 366 | ), |
| 344 | 367 | requestSync, |
| 345 | 368 | { discard: true }, |
| 346 | ) | |
| 347 | : Effect.void, | |
| 348 | ).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), | |
| 369 | ); | |
| 370 | // An agent can merge or close its pull request from a shell (`gh pr merge`), which | |
| 371 | // sends no merge notification. When a run that ran such a command ends, read the | |
| 372 | // thread's open links fresh, so settlement does not wait for the next sweep and the | |
| 373 | // cached summary. Other runs add no host reads. | |
| 374 | case "turn-item.updated": | |
| 375 | if ( | |
| 376 | event.payload.type === "command_execution" && | |
| 377 | PULL_REQUEST_CLOSE_COMMAND.test(event.payload.input) | |
| 378 | ) { | |
| 379 | closeCommandThreads.add(event.threadId); | |
| 380 | } | |
| 381 | return Effect.void; | |
| 382 | case "run.updated": | |
| 383 | return isTerminalRunStatus(event.payload.status) && | |
| 384 | closeCommandThreads.delete(event.threadId) | |
| 385 | ? refreshOpenLinks(event.threadId) | |
| 386 | : Effect.void; | |
| 387 | default: | |
| 388 | return Effect.void; | |
| 389 | } | |
| 390 | }).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), | |
| 349 | 391 | ); |
| 350 | 392 | yield* forkParked( |
| 351 | 393 | Effect.gen(function* () { |
docs/internals/overview.md+5-2| ... | ... | @@ -87,8 +87,11 @@ must reject that operation before changing the filesystem. |
| 87 | 87 | Thread settlement is server-owned. The |
| 88 | 88 | [settlement service](../../apps/server/src/orchestration-v2/ThreadSettlementService.ts) evaluates PR |
| 89 | 89 | and inactivity settings without a connected client. Merge notifications invalidate cached PR state |
| 90 | and trigger a check. The guarded `thread.auto-settle` command rejects newer activity, explicit | |
| 91 | settlement overrides, and live or blocked work. It records the activity timestamp for stable | |
| 90 | and trigger a check. A merge outside T3, such as an agent running `gh pr merge`, sends no | |
| 91 | notification, so the [PR sync reactor](../../apps/server/src/orchestration-v2/PullRequestSyncReactor.ts) | |
| 92 | re-reads a thread's open links when a run that ran a merge or close command ends. The guarded | |
| 93 | `thread.auto-settle` command rejects newer activity, explicit settlement overrides, and live or | |
| 94 | blocked work. It records the activity timestamp for stable | |
| 92 | 95 | sorting and detaches idle provider sessions. Clients render the persisted result; they do not |
| 93 | 96 | derive settlement from their own clocks or PR caches. |
| 94 | 97 |