# Files changed · PR \#23904 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23904/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(server\): wake PR watches when remaining CI checks finish](https://git.cafe/pingdotgg/t3code/pull/23904.md)

Version: 21

Head commit: 93fe829bdb5a0947f87ce38bb8b31c381748956f

Base commit: 51273020db3245f4ec29514a419d29b5dcb012dc

Comparison base: 51273020db3245f4ec29514a419d29b5dcb012dc

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/orchestration-v2/Orchestrator.ts

Status: modified; +3; −0

```diff
@@ -3400,6 +3400,9 @@
                       passedChecks: existing.endedWatch.passedChecks,
                       conflicting: existing.endedWatch.conflicting,
                     }),
+                    ...(existing.endedWatch?.owedChecks !== undefined && {
+                      owedChecks: existing.endedWatch.owedChecks,
+                    }),
                     ...(existing.endedWatch?.checksAwaitedSince !== undefined && {
                       checksAwaitedSince: existing.endedWatch.checksAwaitedSince,
                     }),
```

## apps/server/src/orchestration-v2/PullRequestWatchReactor.ts

Status: modified; +1; −0

```diff
@@ -171,6 +171,7 @@
     left.failedChecks.join("\n") === right.failedChecks.join("\n") &&
     left.passed === right.passed &&
     left.passedChecks.join("\n") === right.passedChecks.join("\n") &&
+    (left.owedChecks ?? []).join("\n") === (right.owedChecks ?? []).join("\n") &&
     left.remarksThrough === right.remarksThrough &&
     left.remarkIds.join("\n") === right.remarkIds.join("\n") &&
     left.conflicting === right.conflicting &&
```

## apps/server/src/orchestration-v2/pullRequestWatch.test.ts

Status: modified; +79; −0

```diff
@@ -134,6 +134,85 @@
     ]);
   });
 
+  // regression: #23712 required checks woke the agent early and the rest finishing never did
+  it("wakes again when the other checks finish after the required ones passed", () => {
+    const required = (name: string, status: PullRequestCheck["status"]) => ({
+      ...check(name, status),
+      required: true,
+    });
+    const early = detail({
+      checks: [required("test", "success"), check("e2e", "pending")],
+    });
+    const first = evaluatePullRequestWatch(watch(), early, noRemarks);
+    assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: true }]);
+
+    const done = detail({
+      checks: [required("test", "success"), check("e2e", "success")],
+    });
+    const final = evaluatePullRequestWatch(first.next, done, noRemarks);
+    assert.deepEqual(
+      final.changes.map((change) => change.kind),
+      ["checks-passed"],
+    );
+    assert.deepEqual(evaluatePullRequestWatch(final.next, done, noRemarks).changes, []);
+    // A later advisory job running and passing is not more news.
+    const botPending = detail({ checks: [...done.checks, check("bot", "pending")] });
+    const churn = evaluatePullRequestWatch(final.next, botPending, noRemarks);
+    assert.deepEqual(churn.changes, []);
+    const botDone = detail({ checks: [...done.checks, check("bot", "success")] });
+    assert.deepEqual(evaluatePullRequestWatch(churn.next, botDone, noRemarks).changes, []);
+
+    // A pending check that turns required on the next read still finishes with one wake.
+    const promoted = detail({
+      checks: [required("test", "success"), required("e2e", "success")],
+    });
+    const gated = evaluatePullRequestWatch(first.next, promoted, noRemarks);
+    assert.deepEqual(gated.changes, [{ kind: "checks-passed", count: 2, required: true }]);
+    assert.deepEqual(evaluatePullRequestWatch(gated.next, promoted, noRemarks).changes, []);
+
+    // Nothing else was pending when the required ones passed, so nothing more is owed.
+    const green = evaluatePullRequestWatch(watch(), done, noRemarks);
+    assert.deepEqual(green.changes, [{ kind: "checks-passed", count: 1, required: true }]);
+    assert.deepEqual(evaluatePullRequestWatch(green.next, botDone, noRemarks).changes, []);
+  });
+
+  it("reports an owed non-required failure once after the required notice", () => {
+    const required = { ...check("test", "success"), required: true };
+    const early = detail({ checks: [required, check("e2e", "pending")] });
+    const first = evaluatePullRequestWatch(watch(), early, noRemarks);
+    assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: true }]);
+    const failed = detail({ checks: [required, check("e2e", "failure")] });
+    const failure = evaluatePullRequestWatch(first.next, failed, noRemarks);
+    assert.deepEqual(failure.changes, [
+      { kind: "checks-failed", failed: [check("e2e", "failure")] },
+    ]);
+    const repeated = evaluatePullRequestWatch(failure.next, failed, noRemarks);
+    assert.deepEqual(repeated.changes, []);
+    const recovered = detail({ checks: [required, check("e2e", "success")] });
+    const final = evaluatePullRequestWatch(repeated.next, recovered, noRemarks);
+    assert.deepEqual(final.changes, [{ kind: "checks-passed", count: 2, required: false }]);
+    assert.deepEqual(evaluatePullRequestWatch(final.next, recovered, noRemarks).changes, []);
+  });
+
+  it("clears old-head owed checks when a new head is pushed", () => {
+    const required = { ...check("test", "success"), required: true };
+    const early = detail({ checks: [required, check("e2e", "pending")] });
+    const first = evaluatePullRequestWatch(watch(), early, noRemarks);
+    assert.deepEqual(first.next.owedChecks, ["e2e"]);
+    const unreadHead = detail({ headSha: "bbbbbbbbbb", checks: [] });
+    const unread = evaluatePullRequestWatch(first.next, unreadHead, noRemarks);
+    assert.deepEqual(unread.changes, []);
+    assert.deepEqual(unread.next.owedChecks, []);
+    const pushed = detail({ ...unreadHead, checks: [required] });
+    const newHead = evaluatePullRequestWatch(unread.next, pushed, noRemarks);
+    assert.deepEqual(newHead.changes, [{ kind: "checks-passed", count: 1, required: true }]);
+    assert.deepEqual(newHead.next.owedChecks, []);
+    const lateResult = detail({ ...pushed, checks: [required, check("e2e", "success")] });
+    const final = evaluatePullRequestWatch(newHead.next, lateResult, noRemarks);
+    assert.deepEqual(final.changes, []);
+    assert.deepEqual(evaluatePullRequestWatch(final.next, lateResult, noRemarks).changes, []);
+  });
+
   // regression: #16208 PR watches burned GitHub's rate limit and gave up
   it("does not report passed again for a new passed check where none is required", () => {
     const first = evaluatePullRequestWatch(
```

## apps/server/src/orchestration-v2/pullRequestWatch.ts

Status: modified; +22; −0

```diff
@@ -51,6 +51,7 @@
   let failedChecks = headMoved ? [] : watch.failedChecks;
   let passed = headMoved ? false : watch.passed;
   let passedChecks = headMoved ? [] : watch.passedChecks;
+  let owedChecks = headMoved ? [] : (watch.owedChecks ?? []);
   if (detail.checks.length > 0) {
     const failed = detail.checks.filter(isFailedCheck);
     const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name));
@@ -65,8 +66,28 @@
     // A required job created and finished between two passes is never seen pending. Without
     // required checks, any check counts, and advisory bots keep adding passed ones: no wake.
     const gateGrew = required.length > 0 && gateNames.some((name) => !told.includes(name));
+    // Other checks still pending when required ones passed are owed one wake when all settle.
+    // Only those are owed, so later advisory churn never wakes. One turned required is gate news.
+    const owed =
+      passedNow && passed
+        ? detail.checks
+            .filter((check) => check.required !== true && owedChecks.includes(check.name))
+            .map((check) => check.name)
+        : [];
+    const settled =
+      failed.length === 0 && detail.checks.every((check) => check.status !== "pending");
+    owedChecks = owed;
     if (passedNow && (!passed || gateGrew)) {
       changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 });
+      owedChecks =
+        required.length > 0
+          ? detail.checks
+              .filter((check) => check.required !== true && check.status === "pending")
+              .map((check) => check.name)
+          : [];
+    } else if (settled && owed.length > 0) {
+      changes.push({ kind: "checks-passed", count: detail.checks.length, required: false });
+      owedChecks = [];
     }
     passed = passedNow;
     passedChecks = passedNow ? gateNames : [];
@@ -127,6 +148,7 @@
       failedChecks,
       passed,
       passedChecks,
+      owedChecks,
       remarksThrough,
       remarkIds,
       conflicting,
```

## apps/server/src/orchestration-v2/runtimeLayer.test.ts

Status: modified; +121; −0

```diff
@@ -2622,6 +2622,127 @@
     }),
   );
 
+  it.effect("finishes owed checks once after pending sweeps, a restart, and a re-watch", () =>
+    Effect.gen(function* () {
+      const orchestrator = yield* Orchestrator.OrchestratorV2;
+      const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
+      const threadId = ThreadId.make("runtime-pull-request-watch-owed");
+      const projectId = ProjectId.make("pr-watch-owed-project");
+      yield* seedProject({
+        projectId,
+        title: "Watch owed",
+        workspaceRoot: "/workspace/watch-owed",
+        defaultModelSelection: null,
+        createdAt: "2026-10-01T00:00:00.000Z",
+      });
+      yield* orchestrator.dispatch({
+        type: "thread.create",
+        createdBy: "user",
+        creationSource: "web",
+        commandId: CommandId.make("pr-watch-owed-create"),
+        threadId,
+        projectId,
+        title: "Watch owed",
+        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-owed-${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 remainingStatus: "pending" | "success" = "pending";
+      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: "ad70160aaa",
+              checks: ["lint", "test"].map((name) => ({
+                name,
+                status: name === "test" ? remainingStatus : ("success" as const),
+                description: null,
+                url: null,
+                required: name === "lint",
+              })),
+            })),
+          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* worker.drain();
+      yield* TestClock.adjust("10 minutes");
+      yield* reactor.sweep;
+      yield* worker.drain();
+      assert.deepEqual(yield* summaries, ["#12: checks passed"]);
+
+      assert.deepEqual(
+        (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks,
+        ["test"],
+      );
+      // The fresh reactor reloads the nonempty obligation from the persisted watch.
+      const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host));
+      yield* restarted.sweep;
+      yield* worker.drain();
+      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;
+      yield* worker.drain();
+      assert.deepEqual(yield* summaries, ["#12: checks passed"]);
+
+      assert.deepEqual(
+        (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks,
+        ["test"],
+      );
+      remainingStatus = "success";
+      yield* TestClock.adjust("10 minutes");
+      yield* restarted.sweep;
+      yield* worker.drain();
+      assert.deepEqual(yield* summaries, ["#12: checks passed", "#12: checks passed"]);
+      yield* TestClock.adjust("10 minutes");
+      yield* restarted.sweep;
+      yield* worker.drain();
+      assert.deepEqual(yield* summaries, ["#12: checks passed", "#12: checks passed"]);
+      assert.deepEqual(
+        (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.owedChecks,
+        [],
+      );
+      yield* watch(false, "end");
+    }),
+  );
+
   // regression: #21155 watching a pull request again repeated news the thread already got
   it.effect("a watch that ends on the comment limit keeps what its last pass saw", () =>
     Effect.gen(function* () {
```

## packages/contracts/src/threadPullRequest.ts

Status: modified; +2; −0

```diff
@@ -89,6 +89,8 @@
   passedChecks: Schema.Array(TrimmedNonEmptyString).pipe(
     Schema.withDecodingDefault(Effect.succeed([])),
   ),
+  /** Checks still pending when required ones passed early, owed one wake once all settle. */
+  owedChecks: Schema.optional(Schema.Array(TrimmedNonEmptyString)),
   remarksThrough: IsoDateTime,
   /** Remarks created exactly at `remarksThrough` that were reported, so a late one still counts. */
   remarkIds: Schema.Array(TrimmedNonEmptyString),
```
