| 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,6 +335,10 @@ it.effect.each([ |
| 335 | [open, null, 2], | 335 | [open, null, 2], |
| 336 | ], | 336 | ], |
| 337 | ); | 337 | ); |
| 338 | assert.deepEqual( | ||
| 339 | (yield* store.getThreadsWithPullRequests(open)).map((thread) => thread.id), | ||
| 340 | [open], | ||
| 341 | ); | ||
| 338 | }).pipe(Effect.provide(testLayer)), | 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,12 +349,12 @@ export interface ProjectionStoreV2Shape { |
| 349 | ) => Effect.Effect<ReadonlyArray<ProjectionSettlementCandidate>, ProjectionStoreV2Error>; | 349 | ) => Effect.Effect<ReadonlyArray<ProjectionSettlementCandidate>, ProjectionStoreV2Error>; |
| 350 | /** | 350 | /** |
| 351 | * Active (not deleted, not archived) threads with at least one pull request | 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 | readonly getThreadsWithPullRequests: ( |
| 355 | ReadonlyArray<ProjectionThreadPullRequests>, | 356 | threadId?: ThreadId, |
| 356 | ProjectionStoreV2Error | 357 | ) => Effect.Effect<ReadonlyArray<ProjectionThreadPullRequests>, ProjectionStoreV2Error>; |
| 357 | >; | ||
| 358 | readonly getTurnStartContext: ( | 358 | readonly getTurnStartContext: ( |
| 359 | threadId: ThreadId, | 359 | threadId: ThreadId, |
| 360 | runId: RunId, | 360 | runId: RunId, |
| ... | @@ -5133,12 +5133,14 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -5133,12 +5133,14 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5133 | ) | 5133 | ) |
| 5134 | .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); | 5134 | .pipe(Effect.mapError((cause) => new ProjectionStoreSetupError({ cause }))); |
| 5135 | 5135 | ||
| 5136 | const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = () => | 5136 | const getThreadsWithPullRequests: ProjectionStoreV2Shape["getThreadsWithPullRequests"] = ( |
| 5137 | threadId, | ||
| 5138 | ) => | ||
| 5137 | Effect.gen(function* () { | 5139 | Effect.gen(function* () { |
| 5138 | const rows = yield* sql<PayloadRow>` | 5140 | const rows = yield* sql<PayloadRow>` |
| 5139 | SELECT payload_json | 5141 | SELECT payload_json |
| 5140 | FROM orchestration_v2_projection_threads | 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 | AND json_extract(payload_json, '$.archivedAt') IS NULL | 5144 | AND json_extract(payload_json, '$.archivedAt') IS NULL |
| 5143 | AND json_array_length(payload_json, '$.pullRequests') > 0 | 5145 | AND json_array_length(payload_json, '$.pullRequests') > 0 |
| 5144 | ORDER BY updated_at ASC, thread_id ASC | 5146 | ORDER BY updated_at ASC, thread_id ASC |
| ... | @@ -5583,13 +5585,14 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( | ... | @@ -5583,13 +5585,14 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( |
| 5583 | left.id.localeCompare(right.id), | 5585 | left.id.localeCompare(right.id), |
| 5584 | ); | 5586 | ); |
| 5585 | }), | 5587 | }), |
| 5586 | getThreadsWithPullRequests: () => | 5588 | getThreadsWithPullRequests: (threadId) => |
| 5587 | Ref.get(replayState).pipe( | 5589 | Ref.get(replayState).pipe( |
| 5588 | Effect.map((state) => | 5590 | Effect.map((state) => |
| 5589 | [...state.projections.values()] | 5591 | [...state.projections.values()] |
| 5590 | .map(({ thread }) => thread) | 5592 | .map(({ thread }) => thread) |
| 5591 | .filter( | 5593 | .filter( |
| 5592 | (thread) => | 5594 | (thread) => |
| 5595 | (threadId === undefined || thread.id === threadId) && | ||
| 5593 | thread.deletedAt === null && | 5596 | thread.deletedAt === null && |
| 5594 | thread.archivedAt === null && | 5597 | thread.archivedAt === null && |
| 5595 | (thread.pullRequests ?? []).length > 0, | 5598 | (thread.pullRequests ?? []).length > 0, |
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+134-3| ... | @@ -1,10 +1,16 @@ | ... | @@ -1,10 +1,16 @@ |
| 1 | import * as Stream from "effect/Stream"; | 1 | import * as Stream from "effect/Stream"; |
| 2 | import { | 2 | import { |
| 3 | EventId, | ||
| 4 | MessageId, | ||
| 3 | ProjectId, | 5 | ProjectId, |
| 4 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| 5 | PullRequestOperationError, | 7 | PullRequestOperationError, |
| 8 | RunId, | ||
| 6 | ThreadId, | 9 | ThreadId, |
| 10 | TurnItemId, | ||
| 7 | type OrchestrationV2Command as OrchestrationCommand, | 11 | type OrchestrationV2Command as OrchestrationCommand, |
| 12 | type OrchestrationV2DomainEvent, | ||
| 13 | type OrchestrationV2Run, | ||
| 8 | type OrchestrationProjectShell, | 14 | type OrchestrationProjectShell, |
| 9 | type PullRequestRef, | 15 | type PullRequestRef, |
| 10 | type PullRequestStack, | 16 | type PullRequestStack, |
| ... | @@ -171,6 +177,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: | ... | @@ -171,6 +177,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 171 | const linkCommands = yield* Ref.make<ReadonlyArray<LinkCommand>>([]); | 177 | const linkCommands = yield* Ref.make<ReadonlyArray<LinkCommand>>([]); |
| 172 | const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]); | 178 | const summaryCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]); |
| 173 | const stackCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]); | 179 | const stackCalls = yield* Ref.make<ReadonlyArray<PullRequestRef>>([]); |
| 180 | const domainEvents = yield* Queue.unbounded<OrchestrationV2DomainEvent>(); | ||
| 174 | 181 | ||
| 175 | const summary: PullRequestService.PullRequestService["Service"]["summary"] = ( | 182 | const summary: PullRequestService.PullRequestService["Service"]["summary"] = ( |
| 176 | input, | 183 | input, |
| ... | @@ -211,12 +218,17 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: | ... | @@ -211,12 +218,17 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 211 | }), | 218 | }), |
| 212 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ | 219 | Layer.mock(ProjectionStore.ProjectionStoreV2)({ |
| 213 | // Mirrors the store's filter: active threads that have at least one link. | 220 | // Mirrors the store's filter: active threads that have at least one link. |
| 214 | getThreadsWithPullRequests: () => | 221 | getThreadsWithPullRequests: (threadId) => |
| 215 | Queue.offer(snapshotReads, undefined).pipe( | 222 | Queue.offer(snapshotReads, undefined).pipe( |
| 216 | Effect.andThen(Ref.get(snapshots)), | 223 | Effect.andThen(Ref.get(snapshots)), |
| 217 | Effect.map((snapshot) => | 224 | Effect.map((snapshot) => |
| 218 | snapshot.threads | 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 | .map((thread) => ({ | 232 | .map((thread) => ({ |
| 221 | id: thread.id, | 233 | id: thread.id, |
| 222 | projectId: thread.projectId, | 234 | projectId: thread.projectId, |
| ... | @@ -233,7 +245,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: | ... | @@ -233,7 +245,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 233 | Effect.andThen(Effect.die(new Error("pull request sync must not read the shell"))), | 245 | Effect.andThen(Effect.die(new Error("pull request sync must not read the shell"))), |
| 234 | ), | 246 | ), |
| 235 | dispatch, | 247 | dispatch, |
| 236 | streamDomainEvents: Stream.empty, | 248 | streamDomainEvents: Stream.fromQueue(domainEvents), |
| 237 | }), | 249 | }), |
| 238 | Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), | 250 | Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), |
| 239 | Layer.succeed(Crypto.Crypto, testCrypto), | 251 | Layer.succeed(Crypto.Crypto, testCrypto), |
| ... | @@ -248,6 +260,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: | ... | @@ -248,6 +260,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 248 | linkCommands, | 260 | linkCommands, |
| 249 | summaryCalls, | 261 | summaryCalls, |
| 250 | stackCalls, | 262 | stackCalls, |
| 263 | domainEvents, | ||
| 251 | layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)), | 264 | layer: PullRequestSyncReactor.layer.pipe(Layer.provide(dependencies)), |
| 252 | }; | 265 | }; |
| 253 | }); | 266 | }); |
| ... | @@ -272,6 +285,68 @@ const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* ( | ... | @@ -272,6 +285,68 @@ const sweepAgain = Effect.fn("sweepPullRequestSyncHarness")(function* ( |
| 272 | yield* reactor.drain; | 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 | /** What the reactor would have persisted, so the next sweep sees its own writes. */ | 350 | /** What the reactor would have persisted, so the next sweep sees its own writes. */ |
| 276 | function applySync( | 351 | function applySync( |
| 277 | snapshot: TestShellSnapshot, | 352 | snapshot: TestShellSnapshot, |
| ... | @@ -832,4 +907,60 @@ describe("PullRequestSyncReactor", () => { | ... | @@ -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,6 +2,7 @@ import { siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 2 | import { | 2 | import { |
| 3 | CommandId, | 3 | CommandId, |
| 4 | type PullRequestSummary, | 4 | type PullRequestSummary, |
| 5 | type ThreadId, | ||
| 5 | type ThreadPullRequestKey, | 6 | type ThreadPullRequestKey, |
| 6 | type ThreadPullRequestLink, | 7 | type ThreadPullRequestLink, |
| 7 | type ThreadPullRequestSnapshot, | 8 | type ThreadPullRequestSnapshot, |
| ... | @@ -29,8 +30,11 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | ... | @@ -29,8 +30,11 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 29 | import { forkParked } from "../serverActivation.ts"; | 30 | import { forkParked } from "../serverActivation.ts"; |
| 30 | import * as Orchestrator from "./Orchestrator.ts"; | 31 | import * as Orchestrator from "./Orchestrator.ts"; |
| 31 | import * as ProjectionStore from "./ProjectionStore.ts"; | 32 | import * as ProjectionStore from "./ProjectionStore.ts"; |
| 33 | import { isTerminalRunStatus } from "./ThreadManagementService.ts"; | ||
| 32 | 34 | ||
| 33 | const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; | 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 | type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">; | 39 | type SnapshotFields = Omit<ThreadPullRequestSnapshot, "syncedAt">; |
| 36 | 40 | ||
| ... | @@ -330,22 +334,60 @@ export const make = Effect.gen(function* () { | ... | @@ -330,22 +334,60 @@ export const make = Effect.gen(function* () { |
| 330 | }).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), | 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 | const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( | 355 | const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( |
| 334 | "PullRequestSyncReactor.start", | 356 | "PullRequestSyncReactor.start", |
| 335 | )(function* () { | 357 | )(function* () { |
| 336 | const events = engine.streamDomainEvents; | 358 | const events = engine.streamDomainEvents; |
| 337 | yield* forkParked( | 359 | yield* forkParked( |
| 338 | Stream.runForEach(events, (event) => | 360 | Stream.runForEach(events, (event) => { |
| 339 | event.type === "thread.pull-request-synced" | 361 | switch (event.type) { |
| 340 | ? Effect.forEach( | 362 | case "thread.pull-request-synced": |
| 363 | return Effect.forEach( | ||
| 341 | visibleThreadPullRequests(event.payload.pullRequests ?? []).filter( | 364 | visibleThreadPullRequests(event.payload.pullRequests ?? []).filter( |
| 342 | (link) => link.snapshot === null, | 365 | (link) => link.snapshot === null, |
| 343 | ), | 366 | ), |
| 344 | requestSync, | 367 | requestSync, |
| 345 | { discard: true }, | 368 | { discard: true }, |
| 346 | ) | 369 | ); |
| 347 | : Effect.void, | 370 | // An agent can merge or close its pull request from a shell (`gh pr merge`), which |
| 348 | ).pipe(Effect.catchCause(logSkipped("pull request sync event stream failed", {}))), | 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 | yield* forkParked( | 392 | yield* forkParked( |
| 351 | Effect.gen(function* () { | 393 | Effect.gen(function* () { |
docs/internals/overview.md+5-2| ... | @@ -87,8 +87,11 @@ must reject that operation before changing the filesystem. | ... | @@ -87,8 +87,11 @@ must reject that operation before changing the filesystem. |
| 87 | Thread settlement is server-owned. The | 87 | Thread settlement is server-owned. The |
| 88 | [settlement service](../../apps/server/src/orchestration-v2/ThreadSettlementService.ts) evaluates PR | 88 | [settlement service](../../apps/server/src/orchestration-v2/ThreadSettlementService.ts) evaluates PR |
| 89 | and inactivity settings without a connected client. Merge notifications invalidate cached PR state | 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 | 90 | and trigger a check. A merge outside T3, such as an agent running `gh pr merge`, sends no |
| 91 | settlement overrides, and live or blocked work. It records the activity timestamp for stable | 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 | sorting and detaches idle provider sessions. Clients render the persisted result; they do not | 95 | sorting and detaches idle provider sessions. Clients render the persisted result; they do not |
| 93 | derive settlement from their own clocks or PR caches. | 96 | derive settlement from their own clocks or PR caches. |
| 94 | 97 |