Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4287,6 +4287,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
...(existing.endedWatch?.checksAwaitedSince !== undefined && {
checksAwaitedSince: existing.endedWatch.checksAwaitedSince,
}),
...(existing.endedWatch?.pendingEdits !== undefined && {
pendingEdits: existing.endedWatch.pendingEdits,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}),
remarksThrough: startedAt,
remarkIds: [],
wakes: 0,
Expand Down
15 changes: 12 additions & 3 deletions apps/server/src/orchestration-v2/PullRequestWatchReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ const snapshotFingerprint = ({ link }: WatchTarget) => {
].join(" ");
};

const holdsEdits = (group: WatchGroup) =>
group.targets.some(({ watch }) => (watch.pendingEdits?.length ?? 0) > 0);

function planRead(
group: WatchGroup,
last: LastRead | undefined,
Expand All @@ -152,15 +155,19 @@ function planRead(
watch.checksAwaitedSince != null &&
now - Date.parse(watch.checksAwaitedSince) >= PULL_REQUEST_WATCH_NO_CHECKS_MS,
);
// A held edit is delivered by the next read that finds it unchanged, which a quiet fingerprint skips.
const editsHeld = holdsEdits(group);
if (fingerprint === null) {
const read =
editsHeld ||
last.inFlight ||
!last.remarksComplete ||
last.fingerprint !== group.fingerprint ||
now - last.at >= QUIET_REREAD_MS;
return { detail: read || checksDue, activity: read };
}
const activity =
editsHeld ||
last.remarks !== fingerprint.remarks ||
!last.remarksComplete ||
now - last.at >= FINGERPRINT_REREAD_MS;
Expand All @@ -182,6 +189,7 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc
(left.approvalAnnounced ?? false) === (right.approvalAnnounced ?? false) &&
left.remarksThrough === right.remarksThrough &&
left.remarkIds.join("\n") === right.remarkIds.join("\n") &&
JSON.stringify(left.pendingEdits ?? []) === JSON.stringify(right.pendingEdits ?? []) &&
left.conflicting === right.conflicting &&
left.wakes === right.wakes &&
left.checksAwaitedSince === right.checksAwaitedSince
Expand Down Expand Up @@ -471,14 +479,15 @@ export const make = Effect.gen(function* () {
if (
last === undefined ||
group.targets.some((target) => !last.watches.has(watchKey(target))) ||
(fingerprint !== null && last.remarks !== fingerprint.remarks)
(fingerprint !== null && last.remarks !== fingerprint.remarks) ||
holdsEdits(group)
) {
// A new watch or changed host remarks needs a fresh view of every tail.
// A new watch, changed host remarks, or a held edit needs a fresh view of every tail.
threadTails.delete(group.key);
}
const reference = referenceOf(group);
// Invalidate cached reads before using the newer fingerprint; clients receive no notification.
if (fingerprint !== null) yield* pullRequests.invalidate({ reference });
if (fingerprint !== null || holdsEdits(group)) yield* pullRequests.invalidate({ reference });
Comment thread
Umais-Adeed marked this conversation as resolved.
Resolved
const [detailRead, activityRead] = yield* Effect.all(
[
Effect.exit(pullRequests.detail({ ...reference, allowStale: false })),
Expand Down
67 changes: 46 additions & 21 deletions apps/server/src/orchestration-v2/pullRequestWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ describe("evaluatePullRequestWatch", () => {
assert.deepEqual(again.next.remarkIds, [first.id, "late"]);
});

it("reports edits after the watermark once and counts them toward the wake limit", () => {
it("reports a settled edit once and counts it toward the wake limit", () => {
const old = remark("greptile[bot]", "2026-10-02T11:00:00Z");
const watching = watch({
headSha: "aaaaaaaaaa",
Expand All @@ -411,19 +411,21 @@ describe("evaluatePullRequestWatch", () => {
});
assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [old]).changes, []);
const edited = { ...old, editedAt: "2026-10-02T12:06:00Z" };
const report = evaluatePullRequestWatch(watching, detail(), [edited]);
assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [edited] }]);
assert.equal(report.next.remarksThrough, edited.editedAt);
assert.deepEqual(report.next.remarkIds, [old.id]);
assert.isTrue(report.exhausted);
assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited]).changes, []);
const late = { ...edited, id: "late" };
assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited, late]).changes, [
const held = evaluatePullRequestWatch(watching, detail(), [edited]);
assert.deepEqual(held.changes, []);
assert.equal(held.next.remarksThrough, edited.editedAt);
assert.deepEqual(held.next.remarkIds, [old.id]);
const late = { ...remark("reviewer", edited.editedAt), id: "late" };
const report = evaluatePullRequestWatch(held.next, detail(), [edited, late]);
assert.deepEqual(report.changes, [
{ kind: "remarks", remarks: [late] },
{ kind: "remarks", remarks: [edited], edited: true },
]);
assert.isTrue(report.exhausted);
assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited, late]).changes, []);
});

it("wakes once per edit of a remark seen at the watermark, never for the agent's own", () => {
it("wakes once when a remark's edits settle, never for the agent's own", () => {
const seen = remark("ci-bot", "2026-10-10T19:52:40.000Z");
const watching = watch({
headSha: "aaaaaaaaaa",
Expand All @@ -432,17 +434,40 @@ describe("evaluatePullRequestWatch", () => {
wakes: 2,
});
assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [seen]).changes, []);
const edited = { ...seen, editedAt: "2026-10-10T20:04:50.000Z" };
const first = evaluatePullRequestWatch(watching, detail(), [edited]);
assert.deepEqual(first.changes, [{ kind: "remarks", remarks: [edited] }]);
assert.strictEqual(first.next.remarksThrough, edited.editedAt);
assert.deepEqual(evaluatePullRequestWatch(first.next, detail(), [edited]).changes, []);
const again = { ...seen, editedAt: "2026-10-10T20:05:50.000Z" };
const second = evaluatePullRequestWatch(first.next, detail(), [again]);
assert.deepEqual(second.changes, [{ kind: "remarks", remarks: [again] }]);
assert.deepEqual(evaluatePullRequestWatch(second.next, detail(), [again]).changes, []);
const own = { ...edited, author: { login: "agent-user", name: null, avatarUrl: null } };
assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [own]).changes, []);
let next = watching;
for (const editedAt of ["2026-10-10T20:04:50.000Z", "2026-10-10T20:06:50.000Z"]) {
const sweep = evaluatePullRequestWatch(next, detail(), [{ ...seen, editedAt }]);
assert.deepEqual(sweep.changes, []);
next = sweep.next;
}
const final = { ...seen, editedAt: "2026-10-10T20:06:50.000Z" };
// A failed remark read keeps the held edit for the next sweep.
const unread = evaluatePullRequestWatch(next, detail(), null);
assert.deepEqual(unread.changes, []);
const settled = evaluatePullRequestWatch(unread.next, detail(), [final]);
assert.deepEqual(settled.changes, [{ kind: "remarks", remarks: [final], edited: true }]);
assert.deepEqual(evaluatePullRequestWatch(settled.next, detail(), [final]).changes, []);
// A watch started again keeps the held edit, even one the bot changed while it was stopped.
const rewatched = { ...next, remarksThrough: "2026-10-10T20:10:00.000Z", remarkIds: [] };
const whileStopped = { ...seen, editedAt: "2026-10-10T20:08:50.000Z" };
const reheld = evaluatePullRequestWatch(rewatched, detail(), [whileStopped]);
assert.deepEqual(reheld.changes, []);
assert.deepEqual(evaluatePullRequestWatch(reheld.next, detail(), [whileStopped]).changes, [
{ kind: "remarks", remarks: [whileStopped], edited: true },
]);
const own = { ...final, author: { login: "agent-user", name: null, avatarUrl: null } };
const ownHeld = evaluatePullRequestWatch(watching, detail(), [own]);
assert.deepEqual(evaluatePullRequestWatch(ownHeld.next, detail(), [own]).changes, []);
});

it("reports a remark new to the watch at once even when it was already edited", () => {
const posted = {
...remark("coderabbit[bot]", "2026-10-02T12:06:00Z"),
editedAt: "2026-10-02T12:07:00Z",
};
assert.deepEqual(evaluatePullRequestWatch(watch(), detail(), [posted]).changes, [
{ kind: "remarks", remarks: [posted] },
]);
});

it("does not treat a failed check read as a rerun", () => {
Expand Down
48 changes: 37 additions & 11 deletions apps/server/src/orchestration-v2/pullRequestWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export type PullRequestWatchChange =
| { readonly kind: "checks-failed"; readonly failed: ReadonlyArray<PullRequestCheck> }
| { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean }
| { readonly kind: "checks-awaiting-approval"; readonly checks: ReadonlyArray<PullRequestCheck> }
| { readonly kind: "remarks"; readonly remarks: ReadonlyArray<PullRequestComment> }
| {
readonly kind: "remarks";
readonly remarks: ReadonlyArray<PullRequestComment>;
readonly edited?: true;
}
| { readonly kind: "no-checks"; readonly minutes: number }
| { readonly kind: "conflicting" };

Expand Down Expand Up @@ -161,14 +165,33 @@ export function evaluatePullRequestWatch(
const through = Date.parse(watch.remarksThrough);
const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt;
// Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID.
const fresh = (remarks ?? []).filter((remark) => {
const at = Date.parse(activeAt(remark));
return (
(at > through || (at === through && !watch.remarkIds.includes(remark.id))) &&
remark.author?.login.toLowerCase() !== own
);
});
if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh });
const isNew = (at: string, id: string) =>
Date.parse(at) > through || (Date.parse(at) === through && !watch.remarkIds.includes(id));
const byOthers = (remarks ?? []).filter((remark) => remark.author?.login.toLowerCase() !== own);
const fresh = byOthers.filter((remark) => isNew(activeAt(remark), remark.id));
// Bots rewrite one status comment many times, so an edit of a remark the agent already saw
// waits until a read finds it unchanged and then wakes once with its final text.
const held = new Map((watch.pendingEdits ?? []).map((edit) => [edit.id, edit.editedAt]));
const added: Array<PullRequestComment> = [];
const edits: Array<PullRequestComment> = [];
const heldNow: Array<{ id: string; editedAt: string }> = [];
for (const remark of byOthers) {
if (remark.editedAt != null && held.get(remark.id) === remark.editedAt) {
edits.push(remark);
} else if (!fresh.includes(remark)) {
// A rewatch moves the watermark past an edit made while the watch was stopped.
if (remark.editedAt != null && held.has(remark.id)) {
heldNow.push({ id: remark.id, editedAt: remark.editedAt });
}
} else if (remark.editedAt == null || isNew(remark.createdAt, remark.id)) {
added.push(remark);
} else {
heldNow.push({ id: remark.id, editedAt: remark.editedAt });
}
}
const pendingEdits = remarks === null ? (watch.pendingEdits ?? []) : heldNow;
if (added.length > 0) changes.push({ kind: "remarks", remarks: added });
if (edits.length > 0) changes.push({ kind: "remarks", remarks: edits, edited: true });
const latest = Math.max(through, ...fresh.map((remark) => Date.parse(activeAt(remark))));
const atLatest = fresh.filter((remark) => Date.parse(activeAt(remark)) === latest);
const remarksThrough = latest === through ? watch.remarksThrough : activeAt(atLatest[0]!);
Expand Down Expand Up @@ -202,6 +225,7 @@ export function evaluatePullRequestWatch(
approvalAnnounced,
remarksThrough,
remarkIds,
...(pendingEdits.length > 0 ? { pendingEdits } : {}),
conflicting,
wakes,
checksAwaitedSince,
Expand Down Expand Up @@ -253,7 +277,7 @@ function changeLines(
];
case "remarks":
return [
`- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`,
`- ${change.remarks.length} ${change.edited ? "edited" : "new"} ${change.remarks.length === 1 ? "comment" : "comments"}:`,
...listed(change.remarks, (remark) => {
const where = remark.path === null ? "" : ` on ${remark.path}`;
const body = snippet(remark.body);
Expand Down Expand Up @@ -302,7 +326,9 @@ export function pullRequestWatchMessage(input: {
const failed = changes.some(
(change) => change.kind === "checks-failed" || change.kind === "conflicting",
);
const summary = changes.map((change) => SUMMARY[change.kind]);
const summary = changes.map((change) =>
change.kind === "remarks" && change.edited ? "edited comments" : SUMMARY[change.kind],
);
if (exhausted) summary.push("stopped watching");
return {
text,
Expand Down
Loading
Loading