From b23acb85265d0b5ea8b4b0bbdffa5363f981647f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 26 Oct 2026 04:48:15 -0500 Subject: [PATCH] fix(server): wake no-checks PR watches at the configured deadline A watch waiting on a head with no checks is told after 15 minutes, but the reactor reads that pull request again only when its fingerprint changes or a quiet reread comes due, up to 30 minutes later. Read the detail once any watch's no-checks deadline has passed. Co-Authored-By: Claude Opus 5.5 --- .../PullRequestWatchReactor.ts | 19 +++- .../src/orchestration-v2/pullRequestWatch.ts | 2 +- .../src/orchestration-v2/runtimeLayer.test.ts | 107 ++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 03a16e770efac..62a2cb09220c6 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -34,7 +34,11 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts"; +import { + evaluatePullRequestWatch, + PULL_REQUEST_WATCH_NO_CHECKS_MS, + pullRequestWatchMessage, +} from "./pullRequestWatch.ts"; /** Checks take minutes; faster sweeps waste the account's shared host quota. */ const SWEEP_MINUTES = 2; @@ -139,19 +143,28 @@ function planRead( if (last === undefined || group.targets.some((target) => !last.watches.has(watchKey(target)))) { return { detail: true, activity: true }; } + // Nothing on the host changes when the no-checks deadline passes, so it alone prompts a read. + const checksDue = group.targets.some( + ({ watch }) => + watch.checksAwaitedSince != null && + now - Date.parse(watch.checksAwaitedSince) >= PULL_REQUEST_WATCH_NO_CHECKS_MS, + ); if (fingerprint === null) { const read = last.inFlight || !last.remarksComplete || last.fingerprint !== group.fingerprint || now - last.at >= QUIET_REREAD_MS; - return { detail: read, activity: read }; + return { detail: read || checksDue, activity: read }; } const activity = last.remarks !== fingerprint.remarks || !last.remarksComplete || now - last.at >= FINGERPRINT_REREAD_MS; - return { detail: activity || last.status !== fingerprint.status || last.inFlight, activity }; + return { + detail: activity || checksDue || last.status !== fingerprint.status || last.inFlight, + activity, + }; } function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 1213948cc4d6c..033125a6d6c9d 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -10,7 +10,7 @@ import * as DateTime from "effect/DateTime"; /** Bounds consecutive comment-only wakes to stop bot reply loops; check, conflict, or push news resets it. */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; /** How long a head may go without any check before the agent is told none reported. */ -const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; +export const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 025191f098823..1f7b4e5c6b4e2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3228,6 +3228,113 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([true, false])( + "reads a quiet head when its no-checks notice comes due (fingerprint: %s)", + (fingerprinted) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make(`runtime-pull-request-watch-due-${fingerprinted}`); + const projectId = ProjectId.make(`pr-watch-due-project-${fingerprinted}`); + yield* seedProject({ + projectId, + title: "Watch no-checks deadline", + workspaceRoot: "/workspace/watch-due", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-due-create-${fingerprinted}`), + threadId, + projectId, + title: "Watch no-checks deadline", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 14 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-due-start-${fingerprinted}`), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/14", source: "agent" }, + }); + + let reads = 0; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + // The host reports the same fingerprint throughout, so only the deadline can prompt a read. + watchFingerprint: () => + Effect.succeed(fingerprinted ? { status: "quiet", remarks: "none" } : null), + invalidate: () => Effect.void, + detail: () => + Effect.sync(() => { + reads++; + return { + ...watchedPullRequestDetail({ + projectId, + number: 14, + at: "2026-10-02T12:00:00.000Z", + }), + headSha: "6074dd48ff", + checks: [], + }; + }), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const wakes = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => + messages.filter((message) => message.text.includes("No checks have reported")), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain())); + + yield* sweep; + yield* TestClock.adjust("10 minutes"); + yield* sweep; + assert.lengthOf(yield* wakes, 0); + + yield* TestClock.adjust("6 minutes"); + yield* sweep; + const told = yield* wakes; + assert.lengthOf(told, 1); + assert.include(told[0]!.text, "after 16 minutes"); + + // The notice clears the deadline, so the watch returns to its quiet cadence. + const readsAfterNotice = reads; + yield* TestClock.adjust("2 minutes"); + yield* sweep; + assert.equal(reads, readsAfterNotice); + assert.lengthOf(yield* wakes, 1); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-due-end-${fingerprinted}`), + threadId, + ...key, + watching: false, + }); + yield* worker.drain(); + }), + ); + // regression: #21155 watching a pull request again repeated news the thread already got it.effect("watching a dismissed stack layer again keeps what its last watch told", () => Effect.gen(function* () {