| author | |
| committer | |
| log | 453e596da7e98d973e3853a5c2728681dedf363b |
| tree | a202fdc0ed4065fc4c294afe20dc232ed77c4174 |
| parent | 655dfbe8ba87f0f9ef1d6fad83c20634634653aa |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>3 files changed, 38 insertions(+), 20 deletions(-)
apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts+27-13| ... | ... | @@ -802,7 +802,7 @@ describe("PullRequestSyncReactor", () => { |
| 802 | 802 | ), |
| 803 | 803 | ); |
| 804 | 804 | |
| 805 | it.effect("polls open pull requests on settled threads every fifteen minutes", () => | |
| 805 | it.effect("leaves a settled thread's links unread until the thread is unsettled", () => | |
| 806 | 806 | Effect.scoped( |
| 807 | 807 | Effect.gen(function* () { |
| 808 | 808 | yield* TestClock.setTime(Date.parse(NOW)); |
| ... | ... | @@ -811,29 +811,35 @@ describe("PullRequestSyncReactor", () => { |
| 811 | 811 | makeThread("settled", { |
| 812 | 812 | settledOverride: "settled", |
| 813 | 813 | settledAt: "2026-08-21T00:00:00.000Z", |
| 814 | pullRequests: [makeLink(5, { state: "open" })], | |
| 814 | pullRequests: [makeLink(5, { state: "open" }), makeLink(6, { state: "closed" })], | |
| 815 | 815 | }), |
| 816 | 816 | ]), |
| 817 | 817 | }); |
| 818 | 818 | |
| 819 | 819 | yield* Effect.gen(function* () { |
| 820 | 820 | const reactor = yield* startAndSweep(fixture); |
| 821 | assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); | |
| 822 | ||
| 823 | yield* sweepAgain(fixture, reactor); | |
| 824 | assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); | |
| 825 | ||
| 826 | for (let index = 0; index < 13; index += 1) yield* sweepAgain(fixture, reactor); | |
| 827 | assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); | |
| 821 | for (let index = 0; index < 15; index += 1) yield* sweepAgain(fixture, reactor); | |
| 822 | assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); | |
| 828 | 823 | |
| 824 | yield* Ref.update(fixture.snapshots, (snapshot) => ({ | |
| 825 | ...snapshot, | |
| 826 | threads: snapshot.threads.map((thread) => ({ | |
| 827 | ...thread, | |
| 828 | settledOverride: null, | |
| 829 | settledAt: null, | |
| 830 | })), | |
| 831 | })); | |
| 829 | 832 | yield* sweepAgain(fixture, reactor); |
| 830 | assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 2); | |
| 833 | assert.deepStrictEqual( | |
| 834 | (yield* Ref.get(fixture.summaryCalls)).map((call) => call.number).toSorted(), | |
| 835 | [5, 6], | |
| 836 | ); | |
| 831 | 837 | }).pipe(Effect.provide(fixture.layer)); |
| 832 | 838 | }), |
| 833 | 839 | ), |
| 834 | 840 | ); |
| 835 | 841 | |
| 836 | it.effect("auto-links missing native stack layers and leaves dismissed ones alone", () => | |
| 842 | it.effect("auto-links missing native stack layers on active threads only", () => | |
| 837 | 843 | Effect.scoped( |
| 838 | 844 | Effect.gen(function* () { |
| 839 | 845 | yield* TestClock.setTime(Date.parse(NOW)); |
| ... | ... | @@ -856,6 +862,11 @@ describe("PullRequestSyncReactor", () => { |
| 856 | 862 | makeLink(41, { state: "merged" }, { source: "stack-dismissed" }), |
| 857 | 863 | ], |
| 858 | 864 | }), |
| 865 | makeThread("settled", { | |
| 866 | settledOverride: "settled", | |
| 867 | settledAt: "2026-08-21T00:00:00.000Z", | |
| 868 | pullRequests: [makeLink(42)], | |
| 869 | }), | |
| 859 | 870 | ]), |
| 860 | 871 | stack: () => Effect.succeed(stack), |
| 861 | 872 | }); |
| ... | ... | @@ -865,8 +876,11 @@ describe("PullRequestSyncReactor", () => { |
| 865 | 876 | |
| 866 | 877 | const syncCommands = yield* Ref.get(fixture.syncCommands); |
| 867 | 878 | assert.deepStrictEqual( |
| 868 | syncCommands.map((command) => [command.number, command.stack] as const), | |
| 869 | [[42, { kind: "native", ...stack }]], | |
| 879 | syncCommands.map((command) => [command.threadId, command.stack] as const), | |
| 880 | [ | |
| 881 | [ThreadId.make("one"), { kind: "native", ...stack }], | |
| 882 | [ThreadId.make("settled"), { kind: "native", ...stack }], | |
| 883 | ], | |
| 870 | 884 | ); |
| 871 | 885 | assert.deepStrictEqual( |
| 872 | 886 | (yield* Ref.get(fixture.linkCommands)).map(({ commandId: _, ...rest }) => rest), |
apps/server/src/orchestration-v2/PullRequestSyncReactor.ts+10-6| ... | ... | @@ -172,10 +172,12 @@ export const make = Effect.gen(function* () { |
| 172 | 172 | const isDue = (key: string, entries: ReadonlyArray<LinkEntry>, nowMs: number): boolean => { |
| 173 | 173 | if (requested.has(key) || retryStacks.has(key)) return true; |
| 174 | 174 | if (entries.some((entry) => entry.link.snapshot === null)) return true; |
| 175 | if (entries.every((entry) => entry.link.snapshot?.state === "merged")) return false; | |
| 176 | if (entries.some((entry) => entry.link.snapshot?.state === "open" && isUnsettled(entry.thread))) | |
| 177 | return true; | |
| 178 | // Closed requests can reopen on the host, including after the thread settles. | |
| 175 | // Settled threads stop watching their pull requests. Unsettling one makes its links due on | |
| 176 | // the next sweep, since the cadence clock below kept running while it was settled. | |
| 177 | const active = entries.filter((entry) => isUnsettled(entry.thread)); | |
| 178 | if (active.every((entry) => entry.link.snapshot?.state === "merged")) return false; | |
| 179 | if (active.some((entry) => entry.link.snapshot?.state === "open")) return true; | |
| 180 | // Closed requests can reopen on the host. | |
| 179 | 181 | const last = lastSyncedAt.get(key); |
| 180 | 182 | return last === undefined || nowMs - last >= SLOW_SYNC_INTERVAL_MS; |
| 181 | 183 | }; |
| ... | ... | @@ -222,8 +224,10 @@ export const make = Effect.gen(function* () { |
| 222 | 224 | link.snapshot === null || |
| 223 | 225 | !snapshotFieldsEqual(link.snapshot, fields) || |
| 224 | 226 | !stacksEqual(link.stack, nextStack); |
| 225 | // Persist discovered siblings before a terminal snapshot can trigger settlement. | |
| 226 | for (const layer of fetchedStack?.stack?.layers ?? []) { | |
| 227 | // Persist discovered siblings before a terminal snapshot can trigger settlement. A settled | |
| 228 | // thread that shares this pull request with an active one takes the fresh snapshot, but | |
| 229 | // gains no links. | |
| 230 | for (const layer of isUnsettled(thread) ? (fetchedStack?.stack?.layers ?? []) : []) { | |
| 227 | 231 | const layerKey = { |
| 228 | 232 | host: normalizeThreadPullRequestKey(link).host, |
| 229 | 233 | repository: link.repository, |
docs/user/source-control.md+1-1| ... | ... | @@ -195,7 +195,7 @@ Linking and unlinking are available in the web and desktop clients. |
| 195 | 195 | The **Linked pull requests** panel lists every review and groups stacks. Unlink a review from its |
| 196 | 196 | row menu. An unlinked stack layer stays out of later syncs. Open linked reviews refresh on the server; |
| 197 | 197 | closed reviews refresh periodically so reopening one on the host is detected. Merged reviews refresh |
| 198 | when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked | |
| 198 | when requested. A settled thread's reviews stop refreshing until you unsettle it. With **Auto-settle merged threads** enabled, a thread can settle after every linked | |
| 199 | 199 | review is terminal. An open or unsynced link keeps it active. |
| 200 | 200 | |
| 201 | 201 | Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While |