From ada4c39bcac27aa7773ddbf8c412a71a8f58c58c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 21:07:13 +0000 Subject: [PATCH 1/5] test(server): reproduces issue 23701, a watch on a head with no checks never wakes A docs-only push that path filters keep every workflow off reports no checks, so the watch reads it every sweep and stays silent forever. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/runtimeLayer.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9db0564cced0..daed6ec3de56 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2746,6 +2746,109 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + // regression: #23701 a head no check runs on never woke the watch + it.effect("wakes once when no checks report on the head, without saying they passed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-no-checks"); + const projectId = ProjectId.make("pr-watch-no-checks-project"); + yield* seedProject({ + projectId, + title: "Watch no checks", + workspaceRoot: "/workspace/watch-no-checks", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-no-checks-create"), + threadId, + projectId, + title: "Watch no checks", + 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-no-checks-start"), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/14", source: "agent" }, + }); + + // A docs-only push that path filters keep every workflow off. + let headSha = "6074dd48ff"; + let checks: PullRequestDetail["checks"] = []; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: 14, + at: "2026-10-02T12:00:00.000Z", + }), + headSha, + checks, + })), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const wakes = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => + messages.filter( + (message) => message.notification !== undefined && message.notification !== null, + ), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + const anHourOfSweeps = Effect.gen(function* () { + for (let pass = 0; pass < 6; pass++) { + yield* reactor.sweep; + yield* TestClock.adjust("10 minutes"); + } + yield* reactor.sweep; + }); + + yield* anHourOfSweeps; + const told = yield* wakes; + assert.equal(told.length, 1, "one wake for a head with no checks"); + assert.notMatch(told[0]!.text, /\bpass/i); + assert.notEqual(told[0]!.notification?.outcome, "completed"); + + // An empty read on a head whose checks were seen is a failed read, not "no checks". + headSha = "7777777777"; + checks = [{ name: "lint", status: "pending", description: null, url: null, required: true }]; + yield* reactor.sweep; + checks = []; + yield* anHourOfSweeps; + assert.equal((yield* wakes).length, 1); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-no-checks-end"), + threadId, + ...key, + watching: false, + }); + }), + ); + // 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* () { From 3ab3b83f30f1b77f2e0d7a1396fc7a024142bc54 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 21:13:18 +0000 Subject: [PATCH 2/5] fix(server): a watched pull request with no checks wakes the thread once A head no check runs on, such as a docs-only push that path filters keep every workflow off, never woke the watch: an empty check list was treated only as a failed host read. The watch now records when the head was first read without any check. If none shows up within 15 minutes, it wakes the thread once saying no checks reported, as an update rather than a pass. A check seen on the head clears it, so later empty reads are still treated as failed reads, and a new head starts it again. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 3 ++ .../PullRequestWatchReactor.ts | 5 ++-- .../pullRequestWatch.forgejoComments.test.ts | 7 ++++- .../orchestration-v2/pullRequestWatch.test.ts | 10 ++++++- .../src/orchestration-v2/pullRequestWatch.ts | 28 +++++++++++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 2 ++ packages/contracts/src/threadPullRequest.ts | 5 ++++ 7 files changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 8c8620088fa9..35e706b9775c 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3400,6 +3400,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio passedChecks: existing.endedWatch.passedChecks, conflicting: existing.endedWatch.conflicting, }), + ...(existing.endedWatch?.checksAwaitedSince !== undefined && { + checksAwaitedSince: existing.endedWatch.checksAwaitedSince, + }), remarksThrough: startedAt, remarkIds: [], wakes: 0, diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 3e4896d8a5cb..4e629de79fbc 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -174,7 +174,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc left.remarksThrough === right.remarksThrough && left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && - left.wakes === right.wakes + left.wakes === right.wakes && + left.checksAwaitedSince === right.checksAwaitedSince ); } @@ -539,7 +540,7 @@ export const make = Effect.gen(function* () { remarks: fingerprint?.remarks ?? null, }); yield* eachTarget(group, (target) => { - const report = evaluatePullRequestWatch(target.watch, detail, remarks); + const report = evaluatePullRequestWatch(target.watch, detail, remarks, now); if (report.changes.length > 0) { const wake = pullRequestWatchMessage({ number: target.link.number, diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts index 53a77e178c56..361399da64be 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts @@ -6,7 +6,12 @@ import { forgejoComment, } from "@t3tools/source-control-forgejo/server/forgejoPullRequestJson"; -import { evaluatePullRequestWatch } from "./pullRequestWatch.ts"; +import { evaluatePullRequestWatch as evaluate } from "./pullRequestWatch.ts"; + +// These cases read the head once or at one moment, so the no-checks notice never comes due. +const evaluatePullRequestWatch = ( + ...args: Parameters extends [...infer Head, number] ? Head : never +) => evaluate(...args, 0); const decodeForgejoComment = Schema.decodeSync(ForgejoComment); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 4ab9e8b6d67b..bddcb8a1d134 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -5,7 +5,15 @@ import type { } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import { PULL_REQUEST_WATCH_WAKE_LIMIT, evaluatePullRequestWatch } from "./pullRequestWatch.ts"; +import { + PULL_REQUEST_WATCH_WAKE_LIMIT, + evaluatePullRequestWatch as evaluate, +} from "./pullRequestWatch.ts"; + +// These cases read the head once or at one moment, so the no-checks notice never comes due. +const evaluatePullRequestWatch = ( + ...args: Parameters extends [...infer Head, number] ? Head : never +) => evaluate(...args, 0); const STARTED = "2026-10-02T12:00:00.000Z"; diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index c2d7d3f6e47e..8462b68dae4f 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -5,12 +5,15 @@ import type { PullRequestDetail, ThreadPullRequestWatch, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; /** * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so * this only stops a chatty bot looping an agent that is replying to it. */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; +/** Checks usually register within a minute of a push, so a head still without any is news. */ +export const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; @@ -18,6 +21,7 @@ export type PullRequestWatchChange = | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray } | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } + | { readonly kind: "no-checks"; readonly minutes: number } | { readonly kind: "conflicting" }; export interface PullRequestWatchReport { @@ -34,6 +38,7 @@ export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick, remarks: ReadonlyArray | null, + now: number, ): PullRequestWatchReport { const changes: Array = []; const headSha = detail.headSha ?? null; @@ -64,6 +69,23 @@ export function evaluatePullRequestWatch( passedChecks = passedNow ? gateNames : []; } + // Only a head never read with checks waits for them: an empty read after checks were seen is + // the failed read above. Watches saved before this field fall back to what they recorded. + const nowIso = DateTime.formatIso(DateTime.makeUnsafe(now)); + const awaitedBefore = headMoved + ? nowIso + : watch.checksAwaitedSince !== undefined + ? watch.checksAwaitedSince + : watch.passed || watch.failedChecks.length > 0 + ? null + : nowIso; + let checksAwaitedSince = detail.checks.length > 0 ? null : awaitedBefore; + const awaitedMs = checksAwaitedSince === null ? 0 : now - Date.parse(checksAwaitedSince); + if (awaitedMs >= PULL_REQUEST_WATCH_NO_CHECKS_MS) { + changes.push({ kind: "no-checks", minutes: Math.round(awaitedMs / 60_000) }); + checksAwaitedSince = null; + } + const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); const through = Date.parse(watch.remarksThrough); const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt; @@ -106,6 +128,7 @@ export function evaluatePullRequestWatch( remarkIds, conflicting, wakes, + checksAwaitedSince, }, exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT, }; @@ -153,6 +176,10 @@ function changeLines( return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`; }), ]; + case "no-checks": + return [ + `- No checks have reported${context.commit} after ${change.minutes} minutes. None may be configured to run on this change.`, + ]; case "conflicting": return [`- The branch now conflicts with ${context.baseBranch}.`]; } @@ -162,6 +189,7 @@ const SUMMARY: Record = { "checks-failed": "checks failed", "checks-passed": "checks passed", remarks: "new comments", + "no-checks": "no checks reported", conflicting: "merge conflict", }; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index daed6ec3de56..b33acec163ff 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2834,6 +2834,8 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { // An empty read on a head whose checks were seen is a failed read, not "no checks". headSha = "7777777777"; checks = [{ name: "lint", status: "pending", description: null, url: null, required: true }]; + // Without a fingerprint, a quiet pull request is read again only once the reread is due. + yield* TestClock.adjust("10 minutes"); yield* reactor.sweep; checks = []; yield* anHourOfSweeps; diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 6d3bf6df0874..b8fe073558b5 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -95,6 +95,11 @@ export const ThreadPullRequestWatch = Schema.Struct({ conflicting: Schema.Boolean, /** Comment-only wakes in a row. Watching stops at a limit, so bots cannot loop it. */ wakes: NonNegativeInt, + /** + * When the head was first read without any check, until a check shows up or the agent is told + * none did. Null once either happened; absent on watches saved before it existed. + */ + checksAwaitedSince: Schema.optional(Schema.NullOr(IsoDateTime)), }); export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type; From 171fec973b8029810b4a4a56491314f82a6cddbc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 21:19:45 +0000 Subject: [PATCH 3/5] fix(server): only GitHub watches report a head with no checks A watch saved before the no-checks clock may have seen its head's checks pending, so its clock now starts at the next push instead of inferring none were ever seen. Azure DevOps reports no checks because it cannot read pipelines, so the notice is limited to GitHub; other hosts keep their existing check, conflict, and comment semantics. Co-Authored-By: Claude Opus 5.5 --- .../pullRequestWatch.forgejoComments.test.ts | 3 +- .../orchestration-v2/pullRequestWatch.test.ts | 36 +++++++++++++++++-- .../src/orchestration-v2/pullRequestWatch.ts | 35 +++++++++--------- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts index 361399da64be..4301e38fe24c 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts @@ -1,4 +1,4 @@ -import type { ThreadPullRequestWatch } from "@t3tools/contracts"; +import { SourceControlProviderKind, type ThreadPullRequestWatch } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; import { @@ -30,6 +30,7 @@ describe("Forgejo comment watch boundary", () => { wakes: 2, } satisfies ThreadPullRequestWatch; const finalDetail = { + provider: SourceControlProviderKind.make("forgejo"), headSha: seen.headSha, checks: [], mergeability: "mergeable", diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index bddcb8a1d134..a8f1a81860a1 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -1,5 +1,6 @@ -import type { - PullRequestCheck, +import { + SourceControlProviderKind, + type PullRequestCheck, PullRequestComment, ThreadPullRequestWatch, } from "@t3tools/contracts"; @@ -40,6 +41,7 @@ const check = (name: string, status: PullRequestCheck["status"]): PullRequestChe type Detail = Parameters[1]; const detail = (overrides: Partial = {}): Detail => ({ + provider: SourceControlProviderKind.make("github"), headSha: "aaaaaaaaaa", checks: [check("lint", "success"), check("test", "pending")], mergeability: "mergeable", @@ -260,4 +262,34 @@ describe("evaluatePullRequestWatch", () => { assert.isFalse(pushed.exhausted); assert.equal(pushed.next.wakes, 1); }); + + const anHourLater = ( + first: ThreadPullRequestWatch, + read: Detail, + ): ReadonlyArray> => { + const kinds: Array> = []; + let current = first; + for (let minute = 0; minute <= 60; minute += 10) { + const { changes, next } = evaluate(current, read, noRemarks, minute * 60_000); + kinds.push(changes.map((change) => change.kind)); + current = next; + } + return kinds; + }; + + it("does not tell a watch saved before the no-checks clock that its seen head has none", () => { + // Such a watch may have seen this head's checks pending, which records nothing. + const legacy = watch({ headSha: "aaaaaaaaaa" }); + assert.isTrue(anHourLater(legacy, detail({ checks: [] })).every((kinds) => kinds.length === 0)); + const pushed = anHourLater(legacy, detail({ headSha: "bbbbbbbbbb", checks: [] })); + assert.deepEqual(pushed.flat(), ["no-checks"]); + }); + + it("never reports missing checks on a host whose checks it cannot read", () => { + const azure = detail({ + provider: SourceControlProviderKind.make("azure-devops"), + checks: [], + }); + assert.isTrue(anHourLater(watch(), azure).every((kinds) => kinds.length === 0)); + }); }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 8462b68dae4f..72225169b540 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -12,7 +12,7 @@ import * as DateTime from "effect/DateTime"; * this only stops a chatty bot looping an agent that is replying to it. */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; -/** Checks usually register within a minute of a push, so a head still without any is news. */ +/** How long a GitHub head may go without any check before the agent is told none reported. */ export const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; @@ -36,7 +36,10 @@ const isFailedCheck = (check: PullRequestCheck) => export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, - detail: Pick, + detail: Pick< + PullRequestDetail, + "provider" | "headSha" | "checks" | "mergeability" | "viewer" | "author" + >, remarks: ReadonlyArray | null, now: number, ): PullRequestWatchReport { @@ -70,20 +73,20 @@ export function evaluatePullRequestWatch( } // Only a head never read with checks waits for them: an empty read after checks were seen is - // the failed read above. Watches saved before this field fall back to what they recorded. - const nowIso = DateTime.formatIso(DateTime.makeUnsafe(now)); - const awaitedBefore = headMoved - ? nowIso - : watch.checksAwaitedSince !== undefined - ? watch.checksAwaitedSince - : watch.passed || watch.failedChecks.length > 0 - ? null - : nowIso; - let checksAwaitedSince = detail.checks.length > 0 ? null : awaitedBefore; - const awaitedMs = checksAwaitedSince === null ? 0 : now - Date.parse(checksAwaitedSince); - if (awaitedMs >= PULL_REQUEST_WATCH_NO_CHECKS_MS) { - changes.push({ kind: "no-checks", minutes: Math.round(awaitedMs / 60_000) }); - checksAwaitedSince = null; + // the failed read above. A watch saved before this field may have seen pending checks on its + // head, so its clock starts only at the next push. Other hosts can lack a checks API (Azure + // DevOps pipelines), where an empty list says nothing, so only GitHub waits. + let checksAwaitedSince = watch.checksAwaitedSince; + if (detail.provider === "github") { + const awaitedBefore = headMoved + ? DateTime.formatIso(DateTime.makeUnsafe(now)) + : (watch.checksAwaitedSince ?? null); + checksAwaitedSince = detail.checks.length > 0 ? null : awaitedBefore; + const awaitedMs = checksAwaitedSince === null ? 0 : now - Date.parse(checksAwaitedSince); + if (awaitedMs >= PULL_REQUEST_WATCH_NO_CHECKS_MS) { + changes.push({ kind: "no-checks", minutes: Math.round(awaitedMs / 60_000) }); + checksAwaitedSince = null; + } } const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); From 1fbb1426134e533d482bd930e865ff3058528f66 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 21:42:32 +0000 Subject: [PATCH 4/5] fix(server): keep the no-checks wait constant module-private Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/pullRequestWatch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 72225169b540..057744af05ab 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -13,7 +13,7 @@ import * as DateTime from "effect/DateTime"; */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; /** How long a GitHub head may go without any check before the agent is told none reported. */ -export const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; +const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; From 048e855e9e76bd8b4562cf2be92842187a022995 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 22:09:19 +0000 Subject: [PATCH 5/5] test(server): cover checks arriving after no-checks notice --- .../src/orchestration-v2/runtimeLayer.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b33acec163ff..650fbfc6b0b5 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2851,6 +2851,119 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("wakes for checks that complete after the no-checks notice on the same head", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-late-checks"); + const projectId = ProjectId.make("pr-watch-late-checks-project"); + yield* seedProject({ + projectId, + title: "Watch late checks", + workspaceRoot: "/workspace/watch-late-checks", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-late-checks-create"), + threadId, + projectId, + title: "Watch late checks", + 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-late-checks-start"), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/14", source: "agent" }, + }); + + const headSha = "6074dd48ff"; + let checks: PullRequestDetail["checks"] = []; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => ({ + ...watchedPullRequestDetail({ + projectId, + number: 14, + at: "2026-10-02T12:00:00.000Z", + }), + headSha, + checks, + })), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + const wakes = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => + messages.filter( + (message) => message.notification !== undefined && message.notification !== null, + ), + ); + 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; + assert.lengthOf(yield* wakes, 0); + yield* TestClock.adjust("20 minutes"); + yield* sweep; + const noChecks = yield* wakes; + assert.lengthOf(noChecks, 1); + assert.include(noChecks[0]!.text, "No checks have reported"); + assert.equal(noChecks[0]!.notification?.outcome, "updated"); + + checks = [{ name: "lint", status: "pending", description: null, url: null, required: true }]; + yield* TestClock.adjust("10 minutes"); + yield* sweep; + assert.lengthOf(yield* wakes, 1); + + checks = [{ name: "lint", status: "success", description: null, url: null, required: true }]; + yield* TestClock.adjust("10 minutes"); + yield* sweep; + const completed = yield* wakes; + assert.lengthOf(completed, 2); + assert.equal(completed[1]!.notification?.summary, "#14: checks passed"); + assert.include(completed[1]!.text, "All 1 required check passed on 6074dd4."); + assert.equal(completed[1]!.notification?.outcome, "completed"); + + yield* TestClock.adjust("1 hour"); + yield* sweep; + const settled = yield* wakes; + assert.lengthOf(settled, 2); + assert.lengthOf( + settled.filter((message) => message.text.includes("No checks have reported")), + 1, + ); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-late-checks-end"), + 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* () {