Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
import * as VcsProcess from "../src/vcs/VcsProcess.ts";
import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts";
import * as PullRequestService from "../src/pullRequest/PullRequestService.ts";

const decodeCodexSettings = Schema.decodeEffect(CodexSettings);

Expand Down Expand Up @@ -350,6 +351,11 @@ export const makeOrchestrationIntegrationHarness = (
);
const checkpointReactorLayer = CheckpointReactorLive.pipe(
Layer.provideMerge(runtimeServicesLayer),
Layer.provideMerge(
Layer.mock(PullRequestService.PullRequestService)({
refreshAfterTurn: Effect.void,
}),
),
Layer.provideMerge(
Layer.succeed(VcsStatusBroadcaster, {
getStatus: () => Effect.die("getStatus should not be called in this test"),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const RPC_REQUIRED_SCOPES = {
// Read scope like the reads it un-caches: refreshing is part of reading, and a read-only
// client pressing refresh must not be told it may not look again.
[WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope,
[WS_METHODS.pullRequestsSubscribeRefreshes]: AuthOrchestrationReadScope,
// The candidate list is a read like the detail beside it; asking somebody for a review is a
// write like every other one.
[WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope,
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { ProviderValidationError } from "../../provider/Errors.ts";
import { ServerConfig } from "../../config.ts";
import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";
import { PullRequestService } from "../../pullRequest/PullRequestService.ts";

const asProjectId = (value: string): ProjectId => ProjectId.make(value);
const asTurnId = (value: string): TurnId => TurnId.make(value);
Expand Down Expand Up @@ -325,6 +326,8 @@ describe("CheckpointReactor", () => {
const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-checkpoint-reactor-test-",
});
const pullRequestRefreshes: number[] = [];
const refreshAfterTurn = Effect.sync(() => void pullRequestRefreshes.push(1));
const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, {
getStatus: () => Effect.die("getStatus should not be called in this test"),
refreshLocalStatus: (cwd: string) =>
Expand Down Expand Up @@ -355,6 +358,7 @@ describe("CheckpointReactor", () => {
Layer.provideMerge(projectionSnapshotLayer),
Layer.provideMerge(RuntimeReceiptBusLive),
Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
Layer.provideMerge(Layer.mock(PullRequestService)({ refreshAfterTurn })),
Layer.provideMerge(vcsStatusBroadcasterLayer),
Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),
Layer.provideMerge(
Expand Down Expand Up @@ -466,6 +470,7 @@ describe("CheckpointReactor", () => {
provider,
cwd,
drain,
pullRequestRefreshes,
};
}

Expand Down Expand Up @@ -766,6 +771,14 @@ describe("CheckpointReactor", () => {

NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "v2\n", "utf8");

harness.provider.emit({
type: "turn.started",
eventId: EventId.make("evt-turn-started-aux"),
provider: ProviderDriverKind.make("codex"),
createdAt,
threadId: ThreadId.make("thread-1"),
turnId: asTurnId("turn-aux"),
});
harness.provider.emit({
type: "turn.completed",
eventId: EventId.make("evt-turn-completed-aux"),
Expand All @@ -782,6 +795,7 @@ describe("CheckpointReactor", () => {
const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
expect(midThread?.checkpoints).toHaveLength(0);
expect(pullRequestRefreshCalls).toEqual([]);
expect(harness.pullRequestRefreshes).toEqual([]);

harness.provider.emit({
type: "turn.completed",
Expand All @@ -801,6 +815,7 @@ describe("CheckpointReactor", () => {
expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
await harness.drain();
expect(pullRequestRefreshCalls).toEqual([harness.cwd]);
expect(harness.pullRequestRefreshes).toEqual([1]);
});

it("captures pre-turn and completion checkpoints for claude runtime events", async () => {
Expand Down
48 changes: 45 additions & 3 deletions apps/server/src/orchestration/Layers/CheckpointReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type { OrchestrationDispatchError } from "../Errors.ts";
import { isGitRepository } from "../../git/Utils.ts";
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
import * as PullRequestService from "../../pullRequest/PullRequestService.ts";

const nowIso = Effect.map(DateTime.now, DateTime.formatIso);

Expand Down Expand Up @@ -88,6 +89,9 @@ const make = Effect.gen(function* () {
const receiptBus = yield* RuntimeReceiptBus;
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
const pullRequests = yield* PullRequestService.PullRequestService;
const startedTurns = new Map<ThreadId, TurnId>();
const pending = new Set<ThreadId>();

const appendRevertFailureActivity = (input: {
readonly threadId: ThreadId;
Expand Down Expand Up @@ -854,6 +858,7 @@ const make = Effect.gen(function* () {

const processDomainEvent = Effect.fn("processDomainEvent")(function* (event: OrchestrationEvent) {
if (event.type === "thread.turn-start-requested" || event.type === "thread.message-sent") {
if (event.type === "thread.turn-start-requested") pending.add(event.payload.threadId);
yield* ensurePreTurnBaselineFromDomainTurnStart(event);
return;
}
Expand Down Expand Up @@ -897,14 +902,46 @@ const make = Effect.gen(function* () {
const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* (
event: ProviderRuntimeEvent,
) {
if (event.type === "session.exited") {
startedTurns.delete(event.threadId);
pending.delete(event.threadId);
return;
}

if (event.type === "turn.started") {
const turnId = toTurnId(event.turnId);
const activeTurnId = (yield* providerService.listSessions()).find((session) =>
sameId(session.threadId, event.threadId),
)?.activeTurnId;
const mayReplace = pending.has(event.threadId) && sameId(activeTurnId, turnId);
if (turnId !== null && (!startedTurns.has(event.threadId) || mayReplace)) {
startedTurns.set(event.threadId, turnId);
Comment thread
maria-rcks marked this conversation as resolved.
pending.delete(event.threadId);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sticky pending allows unrelated turn replace

Medium Severity

pending is set on every thread.turn-start-requested and is cleared only when a start is accepted, a qualifying terminal event fires, or the session exits. A rejected or ignored turn.started leaves pending set, so the next active start on that thread can replace the established primary turn. If that replacement never terminates, the original turn's completion may not refresh pull request data.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2f435f3. Configure here.

yield* ensurePreTurnBaselineFromTurnStart(event);
return;
}

if (event.type === "turn.completed") {
if (event.type === "turn.completed" || event.type === "turn.aborted") {
const turnId = toTurnId(event.turnId);
yield* refreshLocalGitStatusFromTurnCompletion(event);
const thread = yield* resolveThreadDetail(event.threadId);
const startedTurnId = startedTurns.get(event.threadId);
const isTrackedTurn = sameId(startedTurnId, turnId);
if (isTrackedTurn) startedTurns.delete(event.threadId);
if (event.type === "turn.completed") {
yield* refreshLocalGitStatusFromTurnCompletion(event);
}
if (
turnId !== null &&
thread !== undefined &&
(isTrackedTurn ||
sameId(thread.session?.activeTurnId, turnId) ||
(startedTurnId === undefined && !thread.session?.activeTurnId))
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
maria-rcks marked this conversation as resolved.
) {
pending.delete(event.threadId);
yield* pullRequests.refreshAfterTurn;
}
Comment thread
maria-rcks marked this conversation as resolved.
if (event.type === "turn.aborted") return;
yield* captureCheckpointFromTurnCompletion(event).pipe(
Effect.catch((error) =>
Effect.flatMap(nowIso, (createdAt) =>
Expand Down Expand Up @@ -963,7 +1000,12 @@ const make = Effect.gen(function* () {

yield* forkParked(
Stream.runForEach(providerService.streamEvents, (event) => {
if (event.type !== "turn.started" && event.type !== "turn.completed") {
if (
event.type !== "turn.started" &&
event.type !== "turn.completed" &&
event.type !== "turn.aborted" &&
event.type !== "session.exited"
) {
return Effect.void;
}
return worker.enqueue({ source: "runtime", event });
Expand Down
23 changes: 17 additions & 6 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2646,10 +2646,11 @@ it.effect("a listing narrowed to some projects is its own cache entry", () =>
}),
);

it.effect("an explicit invalidation makes the next listing ask the host again", () =>
it.effect("explicit and turn invalidations make the next listing ask the host again", () =>
Effect.gen(function* () {
let hostCalls = 0;
let viewerCalls = 0;
const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
const service = yield* makeService({
projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
providers: [
Expand All @@ -2673,11 +2674,18 @@ it.effect("an explicit invalidation makes the next listing ask the host again",
assert.strictEqual(viewerCalls, 2);

// Forgetting one change request leaves the listings shared.
yield* service.invalidate({
reference: { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 },
});
yield* service.invalidate({ reference });
yield* service.list({ state: "open" });
assert.strictEqual(hostCalls, 2);
const observedRefresh = yield* Stream.runHead(service.subscribeRefreshes).pipe(
Effect.forkChild({ startImmediately: true }),
);

yield* service.refreshAfterTurn;
const refresh = Option.getOrThrow(yield* Fiber.join(observedRefresh));
yield* service.list({ state: "open" });
assert.isAbove(refresh, 0);
assert.strictEqual(hostCalls, 3);
}),
);

Expand Down Expand Up @@ -3900,7 +3908,7 @@ it.effect("refuses a remark rewritten into nothing but whitespace", () =>
}),
);

it.effect("forgets the cached detail after a rewrite, like the other mutations", () =>
it.effect("forgets the cached detail after a rewrite or terminal turn", () =>
Effect.gen(function* () {
let coreCalls = 0;
const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
Expand Down Expand Up @@ -3935,8 +3943,11 @@ it.effect("forgets the cached detail after a rewrite, like the other mutations",
yield* service.detail(reference);
yield* service.update({ ...reference, title: "Renamed" });
yield* service.detail(reference);

assert.strictEqual(coreCalls, 2);

yield* service.refreshAfterTurn;
yield* service.detail(reference);
assert.strictEqual(coreCalls, 3);
}),
);

Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export class PullRequestService extends Context.Service<
never,
Scope.Scope
>;
readonly subscribeRefreshes: Stream.Stream<number>;
readonly refreshAfterTurn: Effect.Effect<void>;
readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>;
readonly activity: (
input: PullRequestRef,
Expand Down Expand Up @@ -531,6 +533,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string

export const make = Effect.gen(function* () {
const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64);
const pullRequestRefreshes = yield* PubSub.sliding<number>(1);
const registry = yield* PullRequestProviderRegistry;
const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
Expand Down Expand Up @@ -2123,10 +2126,12 @@ export const make = Effect.gen(function* () {
// scope re-entering `refEpochs` after eviction can never mint a key an old entry still has.
let epochCounter = 0;
let listingsEpoch = 0;
let turnRefreshEpoch = 0;
const refEpochs = new Map<string, number>();
const REF_EPOCH_CAPACITY = 2_048;
const refScope = (ref: PullRequestRef) => `${ref.projectId} ${ref.repository} ${ref.number}`;
const refEpoch = (ref: PullRequestRef) => refEpochs.get(refScope(ref)) ?? 0;
const refEpoch = (ref: PullRequestRef) =>
Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0);
Comment thread
maria-rcks marked this conversation as resolved.
const refCacheKey = (ref: PullRequestRef) =>
JSON.stringify([refEpoch(ref), ref.projectId, ref.repository, ref.number]);
const bumpRefEpoch = (ref: PullRequestRef) => {
Expand Down Expand Up @@ -2396,6 +2401,11 @@ export const make = Effect.gen(function* () {
}).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights)));
};

const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = Effect.suspend(() => {
turnRefreshEpoch = listingsEpoch = ++epochCounter;
return PubSub.publish(pullRequestRefreshes, turnRefreshEpoch).pipe(Effect.asVoid);
});

// A mutation's own client re-reads right after it, and every other client's next read must
// see the action too — so a write forgets the change request it touched and the listings its
// state change reorders, for everyone, without any client asking.
Expand Down Expand Up @@ -2435,6 +2445,8 @@ export const make = Effect.gen(function* () {
subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(
Effect.map((subscription) => Stream.fromSubscription(subscription)),
),
subscribeRefreshes: Stream.fromPubSub(pullRequestRefreshes),
refreshAfterTurn,
detail,
activity,
threadComments,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2096,6 +2096,12 @@ const makeWsRpcLayer = (
observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), {
"rpc.aggregate": "pull-requests",
}),
[WS_METHODS.pullRequestsSubscribeRefreshes]: () =>
observeRpcStream(
WS_METHODS.pullRequestsSubscribeRefreshes,
pullRequests.subscribeRefreshes,
{ "rpc.aggregate": "pull-requests" },
),
[WS_METHODS.pullRequestsReviewerCandidates]: (input) =>
observeRpcEffect(
WS_METHODS.pullRequestsReviewerCandidates,
Expand Down
24 changes: 17 additions & 7 deletions apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ import { useProjects } from "~/state/entities";
import { useEnvironments } from "~/state/environments";
import { useEnvironmentQuery } from "~/state/query";
import { useLiveRefresh } from "~/hooks/useLiveRefresh";
import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests";
import {
pullRequestEnvironment,
usePullRequestTurnRefresh,
useSharedPullRequestSummary,
} from "~/state/pullRequests";
import { useAtomCommand } from "~/state/use-atom-command";
import { vcsEnvironment } from "~/state/vcs";
import { formatRelativeTimeLabel } from "~/timestampFormat";
Expand Down Expand Up @@ -577,6 +581,7 @@ export function PullRequestDetailPanel({
const activityQuery = useEnvironmentQuery(
pullRequestEnvironment.activity({ environmentId, input: reference }),
);
const turnRefresh = usePullRequestTurnRefresh(environmentId);
const [cachedDetail, setCachedDetail] = useState(() =>
readPullRequestDetailSnapshot(
typeof window === "undefined" ? undefined : window.localStorage,
Expand Down Expand Up @@ -675,6 +680,8 @@ export function PullRequestDetailPanel({
detailQuery.refresh();
activityQuery.refresh();
}, [activityQuery.refresh, detailQuery.refresh]);
const [refreshToken, setRefreshToken] = useState(0);
const codeRefreshToken = refreshToken + (turnRefresh ?? 0);
const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>(
null,
);
Expand All @@ -694,19 +701,22 @@ export function PullRequestDetailPanel({
state: resolvedCoreDetail.state,
});
}, [onStateChange, resolvedCoreDetail]);
// Core detail is cheap enough to re-read while this stays open. Activity is heavier, so the
// Core detail and a mounted diff re-read while this stays open. Activity is heavier, so the
// revision effect above reads it only after this same pull request reports a change. Keyed by
// the pull request rather than by the panel, because this one panel shows a different pull
// request every time it is opened.
useLiveRefresh(detailQuery.refresh, {
key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}`,
});
useLiveRefresh(
() => {
detailQuery.refresh();
setRefreshToken((token) => token + 1);
},
{ key: `pull-request:${reference.projectId}:${reference.repository}#${reference.number}` },
);
// The button, on the other hand, goes around the server's cache rather than through it: it is
// the answer for a reader who can see that what they are looking at is behind. The
// invalidation goes first so the re-reads miss that cache; if it fails, the reads still run
// and at worst answer from it.
const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false });
const [refreshToken, setRefreshToken] = useState(0);
const refreshFromHost = useCallback(async () => {
await invalidate({ environmentId, input: { reference } });
refreshDetail();
Expand Down Expand Up @@ -2352,7 +2362,7 @@ export function PullRequestDetailPanel({
fixFindingLabel={handoffLabels.fixFinding}
onFixFinding={startFixFinding}
onRefresh={refreshDetail}
refreshToken={refreshToken}
refreshToken={codeRefreshToken}
/>
</Suspense>
</div>
Expand Down
Loading
Loading