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
9 changes: 9 additions & 0 deletions src/browser/components/AppLoader/AppLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ function AppLoaderInner() {

// Sync stores when metadata finishes loading
useEffect(() => {
// #4662: git/PR probes for a workspace wait for its chat replay to settle. Wire the gate
// before setClient/syncWorkspaces below, which can refresh synchronously.
const chatReplayGate = {
isReplayPending: workspaceStore.isWorkspaceChatReplayPending,
subscribeKey: workspaceStore.subscribeKey,
};
gitStatusStore.setChatReplayGate(chatReplayGate);
getPRStatusStoreInstance().setChatReplayGate(chatReplayGate);

// Keep store clients in sync even during backend restarts (api can be null while reconnecting).
workspaceStoreInstance.setClient(api ?? null);
gitStatusStore.setClient(api ?? null);
Expand Down
41 changes: 41 additions & 0 deletions src/browser/stores/GitStatusStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,47 @@ describe("GitStatusStore", () => {
unsub();
});

// #4662: opening a workspace must not spawn git status/fetch while its chat replay runs.
it("defers a workspace's status and fetch until its chat replay settles", async () => {
const pendingId = "replay-pending";
const readyId = "replay-settled";
let pending = true;
const gateListeners = new Set<() => void>();
store.setChatReplayGate({
isReplayPending: (workspaceId) => pending && workspaceId === pendingId,
subscribeKey: (workspaceId, listener) => {
if (workspaceId !== pendingId) return () => undefined;
gateListeners.add(listener);
return () => gateListeners.delete(listener);
},
});
const scriptsFor = (workspaceId: string) =>
mockExecuteBash.mock.calls
.map((call) => (call as unknown[])[0] as { workspaceId: string; script: string })
.filter((args) => args.workspaceId === workspaceId)
.map((args) => (args.script === GIT_FETCH_SCRIPT ? "fetch" : "status"));
store.syncWorkspaces(
new Map([
// Separate projects: local fetches are deduplicated per project.
[pendingId, { ...createWorkspaceMetadata(pendingId), projectName: "pending-project" }],
[readyId, createWorkspaceMetadata(readyId)],
])
);
const unsubscribers = [pendingId, readyId].map((id) => store.subscribeKey(id, jest.fn()));

await waitUntil(() => scriptsFor(readyId).length === 2);
expect(scriptsFor(pendingId)).toEqual([]);
expect(gateListeners.size).toBe(1);

pending = false;
for (const listener of Array.from(gateListeners)) listener();

await waitUntil(() => scriptsFor(pendingId).length === 2);
expect(scriptsFor(pendingId).sort()).toEqual(["fetch", "status"]);
expect(gateListeners.size).toBe(0);
for (const unsubscribe of unsubscribers) unsubscribe();
});

describe("passive fetch runtime gating", () => {
it("skips passive fetch and status checks for devcontainer with unresolved runtime status", async () => {
store.dispose();
Expand Down
27 changes: 24 additions & 3 deletions src/browser/stores/GitStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { FrontendWorkspaceMetadata, GitStatus } from "@/common/types/worksp
import { readPersistedState } from "@/browser/hooks/usePersistedState";
import { RefreshController } from "@/browser/utils/RefreshController";
import { repoRootBashOptions } from "@/browser/utils/executeBash";
import { deferWhileChatReplayPending, type ChatReplayGate } from "@/browser/utils/chatReplayGate";
import {
canRunPassiveRuntimeCommand,
onPassiveRuntimeEligible,
Expand Down Expand Up @@ -97,6 +98,8 @@ export class GitStatusStore {
private fetchCache = new Map<string, FetchState>();
private runtimeStatusRetryUnsubscribers = new Map<string, () => void>();
private runtimeFetchRetryUnsubscribers = new Map<string, () => void>();
private chatReplayGate: ChatReplayGate | null = null;
private chatReplayRetryUnsubscribers = new Map<string, () => void>();
private client: RouterClient<AppRouter> | null = null;
private immediateUpdateQueued = false;
private workspaceMetadata = new Map<string, FrontendWorkspaceMetadata>();
Expand Down Expand Up @@ -141,6 +144,11 @@ export class GitStatusStore {
}
}

/** Defer refreshes of a workspace while its chat replay is pending; null disables gating. */
setChatReplayGate(gate: ChatReplayGate | null): void {
this.chatReplayGate = gate;
}

/**
* Subscribe to git status changes (any workspace).
* Delegates to MapStore's subscribeAny.
Expand Down Expand Up @@ -338,6 +346,7 @@ export class GitStatusStore {

this.cleanupRuntimeRetryMap(this.runtimeStatusRetryUnsubscribers, metadata);
this.cleanupRuntimeRetryMap(this.runtimeFetchRetryUnsubscribers, metadata);
this.cleanupRuntimeRetryMap(this.chatReplayRetryUnsubscribers, metadata);

// Remove statuses for deleted workspaces
// Iterate plain map (statusCache) for membership, not reactive store
Expand Down Expand Up @@ -371,9 +380,17 @@ export class GitStatusStore {
return;
}

// Only poll workspaces that have active subscribers.
const workspaces = Array.from(this.workspaceMetadata.values()).filter((ws) =>
this.hasWorkspaceSubscribers(ws.id)
// Only poll workspaces that have active subscribers and whose chat replay has settled
// (#4662: a deferred workspace skips both the status script and git fetch).
const workspaces = Array.from(this.workspaceMetadata.values()).filter(
(ws) =>
this.hasWorkspaceSubscribers(ws.id) &&
!deferWhileChatReplayPending(
this.chatReplayGate,
this.chatReplayRetryUnsubscribers,
ws.id,
() => this.refreshController.requestImmediate()
)
);

if (workspaces.length === 0) {
Expand Down Expand Up @@ -1085,6 +1102,10 @@ export class GitStatusStore {
unsub();
}
this.runtimeFetchRetryUnsubscribers.clear();
for (const unsub of this.chatReplayRetryUnsubscribers.values()) {
unsub();
}
this.chatReplayRetryUnsubscribers.clear();
this.refreshController.dispose();
}

Expand Down
54 changes: 54 additions & 0 deletions src/browser/stores/PRStatusStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,60 @@ describe("passive refresh runtime gating", () => {
});
});

// #4662: opening a workspace must not spawn gh pr/stack probes while its chat replay runs.
describe("chat replay gating", () => {
it.each(["the chat replay settles", "the last subscriber leaves"] as const)(
"defers PR and stack probes until %s",
async (release) => {
const metadata = createWorkspaceMetadata("replay-pending", DEFAULT_RUNTIME_CONFIG);
const executeBash = mock(() =>
Promise.resolve({ success: false as const, error: "gh unavailable" })
);
let pending = true;
const gateListeners = new Set<() => void>();
const store = new PRStatusStore({ getStatus: () => null });

try {
store.setChatReplayGate({
isReplayPending: () => pending,
subscribeKey: (_workspaceId, listener) => {
gateListeners.add(listener);
return () => gateListeners.delete(listener);
},
});
store.setClient({
workspace: { executeBash },
} as unknown as Parameters<PRStatusStore["setClient"]>[0]);
store.syncWorkspaces(new Map([[metadata.id, metadata]]));
const unsubscribe = store.subscribeWorkspace(metadata.id, () => undefined);

await waitUntil(() => gateListeners.size === 1);
expect(executeBash.mock.calls.length).toBe(0);

if (release === "the last subscriber leaves") {
// The last subscriber leaving must release the watcher on WorkspaceStore.
unsubscribe();
expect(gateListeners.size).toBe(0);
return;
}
pending = false;
for (const listener of Array.from(gateListeners)) listener();

await waitUntil(() => executeBash.mock.calls.length === 2);
const scripts = executeBash.mock.calls.map(
(call) => ((call as unknown[])[0] as { script: string }).script
);
expect(scripts.some((script) => script.includes("gh pr view"))).toBe(true);
expect(scripts.some((script) => script.includes("gh stack view"))).toBe(true);
expect(gateListeners.size).toBe(0);
unsubscribe();
} finally {
store.dispose();
}
}
);
});

describe("parseMergeQueueEntry", () => {
it("returns null for null and undefined", () => {
expect(parseMergeQueueEntry(null)).toBeNull();
Expand Down
32 changes: 32 additions & 0 deletions src/browser/stores/PRStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
onPassiveRuntimeEligible,
type PassiveRuntimeDeps,
} from "@/browser/utils/runtimeExecutionPolicy";
import { deferWhileChatReplayPending, type ChatReplayGate } from "@/browser/utils/chatReplayGate";
/**
* Parse a GitHub PR URL to extract owner, repo, and number.
* Returns null if the URL is not a valid GitHub PR URL.
Expand Down Expand Up @@ -331,6 +332,8 @@ export class PRStatusStore {
private workspacePRCache = new Map<string, WorkspacePRCacheEntry>();
private workspaceStackCache = new Map<string, WorkspaceStackCacheEntry>();
private runtimeRetryUnsubscribers = new Map<string, () => void>();
private chatReplayGate: ChatReplayGate | null = null;
private chatReplayRetryUnsubscribers = new Map<string, () => void>();

// Track active subscriptions per workspace so we only refresh workspaces that are actually visible.
private workspaceSubscriptionCounts = new Map<string, number>();
Expand Down Expand Up @@ -384,6 +387,11 @@ export class PRStatusStore {
}
}

/** Defer refreshes of a workspace while its chat replay is pending; null disables gating. */
setChatReplayGate(gate: ChatReplayGate | null): void {
this.chatReplayGate = gate;
}

syncWorkspaces(metadata: Map<string, FrontendWorkspaceMetadata>): void {
if (!this.isActive && metadata.size > 0) {
this.isActive = true;
Expand All @@ -396,6 +404,12 @@ export class PRStatusStore {
this.runtimeRetryUnsubscribers.delete(id);
}
}
for (const [id, unsubscribe] of this.chatReplayRetryUnsubscribers) {
if (!metadata.has(id)) {
unsubscribe();
this.chatReplayRetryUnsubscribers.delete(id);
}
}
this.refreshController.bindListeners();
this.refreshController.requestImmediate();
}
Expand Down Expand Up @@ -428,6 +442,8 @@ export class PRStatusStore {
this.workspaceSubscriptionCounts.delete(workspaceId);
this.runtimeRetryUnsubscribers.get(workspaceId)?.();
this.runtimeRetryUnsubscribers.delete(workspaceId);
this.chatReplayRetryUnsubscribers.get(workspaceId)?.();
this.chatReplayRetryUnsubscribers.delete(workspaceId);
} else {
this.workspaceSubscriptionCounts.set(workspaceId, next);
}
Expand Down Expand Up @@ -909,6 +925,18 @@ export class PRStatusStore {
const refreshes: Array<Promise<void>> = [];

for (const workspaceId of workspaceIds) {
// #4662: defer gh pr/stack probes while the workspace's chat replay is pending.
if (
deferWhileChatReplayPending(
this.chatReplayGate,
this.chatReplayRetryUnsubscribers,
workspaceId,
() => this.refreshController.requestImmediate()
)
) {
continue;
}

const shouldFetchPR = this.shouldFetchWorkspace(this.workspacePRCache.get(workspaceId), now);
const shouldFetchStack = this.shouldFetchStack(
this.workspaceStackCache.get(workspaceId),
Expand Down Expand Up @@ -976,6 +1004,10 @@ export class PRStatusStore {
unsubscribe();
}
this.runtimeRetryUnsubscribers.clear();
for (const unsubscribe of this.chatReplayRetryUnsubscribers.values()) {
unsubscribe();
}
this.chatReplayRetryUnsubscribers.clear();
this.refreshController.dispose();
}
}
Expand Down
35 changes: 35 additions & 0 deletions src/browser/stores/WorkspaceStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,41 @@ describe("WorkspaceStore", () => {
expect.anything()
);
});
// #4662: workspace-open git/PR probes wait on this gate, so it must open on every way
// the first replay can settle, or the probes would never run for the workspace.
it.each(["caught-up", "attempt end", "switch away"] as const)(
"reports the chat replay pending until %s",
async (settle) => {
const workspaceId = `workspace-replay-gate-${settle.replace(" ", "-")}`;
const firstAttempt = createControllableAsyncIterable<WorkspaceChatMessage>();
let subscriptions = 0;
mockOnChat.mockImplementation(async function* (input, options) {
if (input?.workspaceId !== workspaceId || subscriptions++ > 0) {
await waitForAbortSignal(options?.signal);
return;
}
options?.signal?.addEventListener("abort", () => firstAttempt.close(), { once: true });
yield* firstAttempt.iterable;
});
createAndAddWorkspace(store, workspaceId);
expect(store.isWorkspaceChatReplayPending(workspaceId)).toBe(true);
expect(await waitUntil(() => subscriptions === 1)).toBe(true);

let notified = false;
const unsubscribe = store.subscribeKey(workspaceId, () => {
notified ||= !store.isWorkspaceChatReplayPending(workspaceId);
});
if (settle === "caught-up") firstAttempt.push(caughtUpEvent());
else if (settle === "attempt end") firstAttempt.close();
else createAndAddWorkspace(store, `${workspaceId}-other`);

expect(await waitUntil(() => notified)).toBe(true);
expect(store.isWorkspaceChatReplayPending(workspaceId)).toBe(false);
unsubscribe();
mockChatScript([], { keepOpen: true });
}
);

it("keeps transcript hydration active across full replay resets", async () => {
const workspaceId = "workspace-full-replay-hydration";

Expand Down
Loading
Loading