Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions apps/server/src/orchestration-v2/PullRequestWatchReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration-v2/pullRequestWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
107 changes: 107 additions & 0 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand Down
Loading