From a2b03aaf7238e1e1604965390edcc412e0b83818 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 18 Oct 2026 15:49:47 +0530 Subject: [PATCH 1/3] fix(server): a new pull request watch no longer repeats what the last one reported A watch's state lived only on the watch, so ending it (settle, unwatch, or the read limit) threw it away. Watching the same pull request again started blank and woke the thread with "checks passed" for a head it had already been told about. Keep the ended watch on the link and start the next watch from its check and conflict state. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 22 +++- .../src/orchestration-v2/runtimeLayer.test.ts | 104 +++++++++++++++++- docs/user/source-control.md | 3 +- packages/contracts/src/threadPullRequest.ts | 2 + 4 files changed, 125 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 1499ca975de7..c02d9abf3fd2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -534,8 +534,10 @@ function withPullRequestWatch( link: ThreadPullRequestLink, watch: ThreadPullRequestWatch | undefined, ): ThreadPullRequestLink { - const { watch: _previous, ...rest } = link; - return watch === undefined ? rest : { ...rest, watch }; + const { watch: previous, endedWatch, ...rest } = link; + if (watch !== undefined) return { ...rest, watch }; + const ended = previous ?? endedWatch; + return ended === undefined ? rest : { ...rest, endedWatch: ended }; } function legacyPullRequestLink( @@ -544,6 +546,9 @@ function legacyPullRequestLink( now: DateTime.Utc, ): ThreadPullRequestLink { const key = legacyThreadPullRequestKey(linked); + const existing = threadPullRequestsOf(thread).find((link) => + threadPullRequestKeysEqual(link, key), + ); return withPullRequestWatch( { ...key, @@ -552,8 +557,9 @@ function legacyPullRequestLink( linkedAt: DateTime.formatIso(now), snapshot: null, stack: null, + ...(existing?.endedWatch && { endedWatch: existing.endedWatch }), }, - threadPullRequestsOf(thread).find((link) => threadPullRequestKeysEqual(link, key))?.watch, + existing?.watch, ); } @@ -3136,9 +3142,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio failedChecks: [], passed: false, passedChecks: [], + conflicting: false, + // Watching again must not repeat check or conflict news the last watch told. + ...(existing.endedWatch && { + headSha: existing.endedWatch.headSha, + failedChecks: existing.endedWatch.failedChecks, + passed: existing.endedWatch.passed, + passedChecks: existing.endedWatch.passedChecks, + conflicting: existing.endedWatch.conflicting, + }), remarksThrough: startedAt, remarkIds: [], - conflicting: false, wakes: 0, }); if (watch === existing.watch && links === linked) return thread; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 086c52296e9d..8092462428d0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2330,7 +2330,10 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { commandId: CommandId.make(`pr-watch-settle-${mode}`), threadId, }); - const expected = before.pullRequests?.map(({ watch: _watch, ...link }) => link); + const expected = before.pullRequests?.map(({ watch, ...link }) => ({ + ...link, + endedWatch: watch, + })); assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.pullRequests, expected); assert.isTrue((yield* maintenance.rebuild).valid); assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.pullRequests, expected); @@ -2649,6 +2652,105 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("wakes once for a state it reported, across a restart and a new watch", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-told"); + const projectId = ProjectId.make("pr-watch-told-project"); + yield* seedProject({ + projectId, + title: "Watch told", + workspaceRoot: "/workspace/watch-told", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-told-create"), + threadId, + projectId, + title: "Watch told", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const watch = (on: boolean, id: string) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-told-${id}`), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 12, + watching: on, + link: { url: "https://github.com/pingdotgg/t3code/pull/12", source: "agent" }, + }); + yield* watch(true, "start"); + + let headSha = "ad70160aaa"; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: 12, + at: "2026-10-02T12:00:00.000Z", + }), + headSha, + checks: ["lint", "test"].map((name) => ({ + name, + status: "success" as const, + description: null, + url: null, + required: true, + })), + })), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); + + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* summaries, ["#12: checks passed"]); + + // A restart keeps nothing in memory; the watch state is all it has. + const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* restarted.sweep; + assert.deepEqual(yield* summaries, ["#12: checks passed"]); + + // A new watch on the same pull request knows what the last one told. + yield* watch(false, "stop"); + yield* watch(true, "again"); + yield* restarted.sweep; + assert.deepEqual(yield* summaries, ["#12: checks passed"]); + + headSha = "bbbbbbbbbb"; + yield* TestClock.adjust("10 minutes"); + yield* restarted.sweep; + assert.deepEqual(yield* summaries, ["#12: checks passed", "#12: checks passed"]); + yield* watch(false, "end"); + }), + ); + it.effect("reports how long an ended watch was quiet", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 4a222b9102f2..357e1f66885e 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -283,7 +283,8 @@ watching inside GitHub's rate limit. Comments from your own account do not wake when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 failed reads in a row, or when you press Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new -watch. Subagents cannot watch pull requests; the thread that delegated to them does. To start or stop +watch. A new watch on the same pull request does not repeat check or conflict news an +earlier watch already reported. Subagents cannot watch pull requests; the thread that delegated to them does. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. In the thread details card, a watched pull request shows an eye; click it to stop watching. diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 92e88386f601..cf0bdf624149 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -106,5 +106,7 @@ export const ThreadPullRequestLink = Schema.Struct({ snapshot: Schema.NullOr(ThreadPullRequestSnapshot), stack: Schema.NullOr(ThreadPullRequestStack), watch: Schema.optional(ThreadPullRequestWatch), + /** The last watch as it ended. Watching again starts from it, so the thread is not told twice. */ + endedWatch: Schema.optional(ThreadPullRequestWatch), }); export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type; From 1c6549f5b022e43a71823ec7e1285d3e00eefb76 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 18 Oct 2026 16:11:05 +0530 Subject: [PATCH 2/3] fix(server): keep a watch's last pass when it ends on the comment limit Ending on the comment limit dropped that pass's state, so the kept watch could still say checks passed after they went pending, and a later green result would not wake. Save the pass before ending. Watching a dismissed stack layer again now keeps its ended watch too. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 2 + .../PullRequestWatchReactor.ts | 22 +- .../src/orchestration-v2/runtimeLayer.test.ts | 191 ++++++++++++++++++ docs/user/source-control.md | 4 +- 4 files changed, 207 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c02d9abf3fd2..600cb2b7f455 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3110,6 +3110,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const linked = threadPullRequestsOf(thread); const visible = (link: ThreadPullRequestLink) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, key); + const dismissed = linked.find((link) => threadPullRequestKeysEqual(link, key)); const links = command.type === "thread.pull-request.watch" && command.watching && @@ -3124,6 +3125,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio linkedAt: startedAt, snapshot: null, stack: null, + ...(dismissed?.endedWatch && { endedWatch: dismissed.endedWatch }), }, ] : linked; diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 793abb1154f2..f589c2c293de 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -477,16 +477,18 @@ export const make = Effect.gen(function* () { yield* eachTarget(group, (target) => { const report = evaluatePullRequestWatch(target.watch, detail, remarks); if (report.changes.length > 0) { - return record( - target, - report.exhausted ? null : report.next, - pullRequestWatchMessage({ - number: target.link.number, - url: target.link.url, - baseBranch: detail.baseBranch, - headSha: report.next.headSha, - report, - }), + const wake = pullRequestWatchMessage({ + number: target.link.number, + url: target.link.url, + baseBranch: detail.baseBranch, + headSha: report.next.headSha, + report, + }); + // Save what this pass saw before ending, so watching again starts from it. + return ( + report.exhausted + ? record(target, report.next).pipe(Effect.andThen(record(target, null, wake))) + : record(target, report.next, wake) ).pipe( Effect.tap(() => woke(target)), Effect.tap(() => (report.exhausted ? ended(target, "comment-limit") : Effect.void)), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 8092462428d0..6f2cfd50b569 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -66,6 +66,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; +import { PULL_REQUEST_WATCH_WAKE_LIMIT } from "./pullRequestWatch.ts"; import { PullRequestProviderError } from "@t3tools/source-control-core/server/PullRequestProvider"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; @@ -2751,6 +2752,196 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("a watch that ends on the comment limit keeps what its last pass saw", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-limit"); + const projectId = ProjectId.make("pr-watch-limit-project"); + yield* seedProject({ + projectId, + title: "Watch limit", + workspaceRoot: "/workspace/watch-limit", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-limit-create"), + threadId, + projectId, + title: "Watch limit", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 13 }; + const watch = (id: string) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-limit-${id}`), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/13", source: "agent" }, + }); + const watchOf = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0], + ); + yield* watch("start"); + const started = (yield* watchOf)?.watch; + assert.isDefined(started); + if (started === undefined) return; + // Green checks were reported, and one more comment-only wake ends the watch. + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make("pr-watch-limit-told"), + threadId, + ...key, + startedAt: started.startedAt, + watch: { + ...started, + headSha: "abc1234def", + passed: true, + passedChecks: ["lint"], + wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1, + }, + }); + + let status: "pending" | "success" = "pending"; + const commentAt = DateTime.formatIso( + DateTime.add(DateTime.makeUnsafe(started.startedAt), { seconds: 1 }), + ); + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ projectId, number: 13, at: commentAt }), + checks: [{ name: "lint", status, description: null, url: null, required: true }], + })), + activity: () => + Effect.succeed({ + comments: [ + { + id: "reviewer-comment", + kind: "issue-comment" as const, + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "One more thing.", + createdAt: commentAt, + url: "https://github.com/pingdotgg/t3code/pull/13#comment", + path: null, + reviewState: null, + }, + ], + commentCount: 1, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + const ended = yield* watchOf; + assert.isUndefined(ended?.watch); + assert.isFalse(ended?.endedWatch?.passed); + assert.deepEqual(yield* summaries, ["#13: new comments, stopped watching"]); + + // The checks went pending after the last report, so passing again is news. + status = "success"; + yield* TestClock.adjust("10 minutes"); + yield* watch("again"); + yield* reactor.sweep; + assert.deepEqual(yield* summaries, [ + "#13: new comments, stopped watching", + "#13: checks passed", + ]); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-limit-end"), + threadId, + ...key, + watching: false, + }); + }), + ); + + it.effect("watching a dismissed stack layer again keeps what its last watch told", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-dismissed"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-dismissed-create"), + threadId, + projectId: ProjectId.make("pr-watch-dismissed-project"), + title: "Watch dismissed", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 14 }; + const url = "https://github.com/pingdotgg/t3code/pull/14"; + const watchOf = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch, + ); + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make("pr-watch-dismissed-link"), + threadId, + ...key, + url, + source: "stack", + }); + const watching = (on: boolean, id: string) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-dismissed-${id}`), + threadId, + ...key, + watching: on, + link: { url, source: "agent" }, + }); + yield* watching(true, "start"); + const started = yield* watchOf; + assert.isDefined(started); + if (started === undefined) return; + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make("pr-watch-dismissed-told"), + threadId, + ...key, + startedAt: started.startedAt, + watch: { ...started, headSha: "abc1234def", passed: true, passedChecks: ["lint"] }, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.unlink", + commandId: CommandId.make("pr-watch-dismissed-unlink"), + threadId, + ...key, + }); + yield* watching(true, "again"); + const again = yield* watchOf; + assert.equal(again?.headSha, "abc1234def"); + assert.isTrue(again?.passed); + yield* watching(false, "end"); + }), + ); + it.effect("reports how long an ended watch was quiet", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 357e1f66885e..5fc4bfd81de5 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -283,8 +283,8 @@ watching inside GitHub's rate limit. Comments from your own account do not wake when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 failed reads in a row, or when you press Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new -watch. A new watch on the same pull request does not repeat check or conflict news an -earlier watch already reported. Subagents cannot watch pull requests; the thread that delegated to them does. To start or stop +watch. Watching a pull request again does not repeat check or conflict news its last +watch already reported; unlinking the pull request clears that. Subagents cannot watch pull requests; the thread that delegated to them does. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. In the thread details card, a watched pull request shows an eye; click it to stop watching. From 2fe34b09edff8151557fb9bd4e6f2db5a2ace806 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sun, 18 Oct 2026 16:15:20 +0530 Subject: [PATCH 3/3] fix(server): end a watch on the comment limit in one command Saving the last pass and ending the watch were two dispatches, so a failure between them left the watch on with the wake lost. The sync command now carries the state the ended watch keeps. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/Orchestrator.ts | 8 +++++++- .../src/orchestration-v2/PullRequestWatchReactor.ts | 6 ++++-- packages/contracts/src/orchestrationV2.ts | 2 ++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 600cb2b7f455..ed804171c93c 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3158,10 +3158,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio wakes: 0, }); if (watch === existing.watch && links === linked) return thread; + const final = + command.type === "thread.pull-request-watch.sync" && watch === undefined + ? command.endedWatch + : undefined; return { ...thread, pullRequests: links.map((link) => - link === existing ? withPullRequestWatch(link, watch) : link, + link === existing + ? withPullRequestWatch(final ? { ...link, watch: final } : link, watch) + : link, ), updatedAt: command.type === "thread.pull-request.watch" ? now : thread.updatedAt, }; diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index f589c2c293de..165e08407e19 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -259,6 +259,7 @@ export const make = Effect.gen(function* () { target: WatchTarget, next: ThreadPullRequestWatch | null, wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }, + endedWatch?: ThreadPullRequestWatch, ) => Effect.gen(function* () { const uuid = yield* crypto.randomUUIDv4(); @@ -269,6 +270,7 @@ export const make = Effect.gen(function* () { ...identityOf(target.link), startedAt: target.watch.startedAt, watch: next, + ...(endedWatch === undefined ? {} : { endedWatch }), ...(wake === undefined ? {} : { wake: { ...wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }), @@ -484,10 +486,10 @@ export const make = Effect.gen(function* () { headSha: report.next.headSha, report, }); - // Save what this pass saw before ending, so watching again starts from it. + // An ending watch keeps what this pass saw, so watching again starts from it. return ( report.exhausted - ? record(target, report.next).pipe(Effect.andThen(record(target, null, wake))) + ? record(target, null, wake, report.next) : record(target, report.next, wake) ).pipe( Effect.tap(() => woke(target)), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 04bec81a0798..86d8c67e2847 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2987,6 +2987,8 @@ const OrchestrationV2InternalCommand = Schema.Union([ ...ThreadPullRequestKey.fields, startedAt: IsoDateTime, watch: Schema.NullOr(ThreadPullRequestWatch), + /** With `watch: null`, the state the ended watch keeps instead of its last saved one. */ + endedWatch: Schema.optional(ThreadPullRequestWatch), wake: Schema.optional( Schema.Struct({ messageId: MessageId,