Keep PR status current after agent merges (#1455)
* feat(server): refresh PR state promptly after agent merges via gh CLI When an agent merges a PR with gh pr merge, the app could take up to 2 minutes to show the merged state because: - gh pr merge is a remote GitHub operation that does not touch local git refs, so file watchers never fire - the GitHub self-heal poll runs on a 2-minute slow interval for settled PRs - no refresh is triggered when agent tool calls complete Add a detection path that intercepts completed shell tool calls in the agent manager and signals the workspace git service when a command may have changed external state (gh *, git push, npm publish). The git service then invalidates the GitHub cache and schedules a forced GitHub-inclusive refresh, so the updated PR state arrives within ~500ms. The agent manager does not leak cache semantics; it calls a semantic onWorkspaceStateMayHaveChanged callback and lets the git service decide what that means internally. * refactor(server): share workspace refresh merge logic
This commit is contained in:
@@ -6,7 +6,12 @@ import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent-manager.js";
|
||||
import {
|
||||
AgentManager,
|
||||
commandMayHaveChangedExternalState,
|
||||
type AgentManagerEvent,
|
||||
type ManagedAgent,
|
||||
} from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt } from "./agent-prompt.js";
|
||||
@@ -5899,3 +5904,138 @@ test("user_message events wrapping a paseo-system envelope are not restored duri
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(userMessages[0].text).toBe("real user message");
|
||||
});
|
||||
|
||||
test("commandMayHaveChangedExternalState matches remote-state commands", () => {
|
||||
// GitHub PR operations (remote, no local file changes)
|
||||
expect(commandMayHaveChangedExternalState("gh pr merge 123")).toBe(true);
|
||||
expect(commandMayHaveChangedExternalState("gh pr close 123")).toBe(true);
|
||||
expect(commandMayHaveChangedExternalState("gh pr create")).toBe(true);
|
||||
expect(commandMayHaveChangedExternalState("gh pr edit 123")).toBe(true);
|
||||
expect(commandMayHaveChangedExternalState('gh pr comment 123 -b "lgtm"')).toBe(true);
|
||||
expect(commandMayHaveChangedExternalState("gh pr review 123 -a")).toBe(true);
|
||||
// Git remote operations (local refs unchanged)
|
||||
expect(commandMayHaveChangedExternalState("git push origin main")).toBe(true);
|
||||
expect(commandMayHaveChangedExternalState("git fetch origin")).toBe(true);
|
||||
});
|
||||
|
||||
test("commandMayHaveChangedExternalState ignores local or read-only commands", () => {
|
||||
// Local git mutations — already caught by file watchers on .git/HEAD
|
||||
expect(commandMayHaveChangedExternalState("git commit -m 'hello'")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("git checkout main")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("git merge feature")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("git rebase main")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("git reset --hard HEAD~1")).toBe(false);
|
||||
// git pull includes a merge/rebase that changes local refs → watchers catch it
|
||||
expect(commandMayHaveChangedExternalState("git pull origin main")).toBe(false);
|
||||
// Read-only gh commands
|
||||
expect(commandMayHaveChangedExternalState("gh pr view 123")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("gh pr list")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("gh auth status")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("gh repo view")).toBe(false);
|
||||
// Miscellaneous local commands
|
||||
expect(commandMayHaveChangedExternalState("git status")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("ls -la")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("cat file.txt")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("npm install")).toBe(false);
|
||||
expect(commandMayHaveChangedExternalState("npm publish")).toBe(false);
|
||||
});
|
||||
|
||||
test("onWorkspaceStateMayHaveChanged is called when a completed shell tool call may have changed external state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const onWorkspaceStateMayHaveChanged = vi.fn();
|
||||
|
||||
const codex = fakeCodexEmitting({
|
||||
turnItems: [
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "bash",
|
||||
status: "completed",
|
||||
detail: { type: "shell", command: "gh pr merge 123 --squash" },
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex },
|
||||
registry: storage,
|
||||
logger,
|
||||
onWorkspaceStateMayHaveChanged,
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
await manager.runAgent(snapshot.id, { text: "merge it" });
|
||||
|
||||
expect(onWorkspaceStateMayHaveChanged).toHaveBeenCalledTimes(1);
|
||||
expect(onWorkspaceStateMayHaveChanged).toHaveBeenCalledWith({ cwd: workdir });
|
||||
});
|
||||
|
||||
test("onWorkspaceStateMayHaveChanged is not called for non-shell tool calls", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const onWorkspaceStateMayHaveChanged = vi.fn();
|
||||
|
||||
const codex = fakeCodexEmitting({
|
||||
turnItems: [
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "read",
|
||||
status: "completed",
|
||||
detail: { type: "read", filePath: "/tmp/foo.txt" },
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex },
|
||||
registry: storage,
|
||||
logger,
|
||||
onWorkspaceStateMayHaveChanged,
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
await manager.runAgent(snapshot.id, { text: "read it" });
|
||||
|
||||
expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-external-state-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const onWorkspaceStateMayHaveChanged = vi.fn();
|
||||
|
||||
const codex = fakeCodexEmitting({
|
||||
turnItems: [
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "bash",
|
||||
status: "running",
|
||||
detail: { type: "shell", command: "gh pr merge 123 --squash" },
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex },
|
||||
registry: storage,
|
||||
logger,
|
||||
onWorkspaceStateMayHaveChanged,
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
await manager.runAgent(snapshot.id, { text: "merge it" });
|
||||
|
||||
expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -192,6 +192,7 @@ export interface AgentManagerOptions {
|
||||
idFactory?: () => string;
|
||||
registry?: AgentStorage;
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||
durableTimelineStore?: AgentTimelineStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
mcpBaseUrl?: string;
|
||||
@@ -484,6 +485,7 @@ export class AgentManager {
|
||||
private appendSystemPrompt: string;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private onAgentArchived?: AgentArchivedCallback;
|
||||
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||
private logger: Logger;
|
||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||
|
||||
@@ -492,6 +494,7 @@ export class AgentManager {
|
||||
this.registry = options?.registry;
|
||||
this.durableTimelineStore = options?.durableTimelineStore;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
|
||||
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
||||
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||
@@ -3215,6 +3218,19 @@ export class AgentManager {
|
||||
epoch: this.timelineStore.getEpoch(agentId),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
|
||||
if (
|
||||
item.type === "tool_call" &&
|
||||
item.status === "completed" &&
|
||||
item.detail?.type === "shell" &&
|
||||
commandMayHaveChangedExternalState(item.detail.command)
|
||||
) {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (agent) {
|
||||
this.onWorkspaceStateMayHaveChanged?.({ cwd: agent.cwd });
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
@@ -3662,3 +3678,20 @@ export class AgentManager {
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
|
||||
export function commandMayHaveChangedExternalState(command: string): boolean {
|
||||
const normalized = command.toLowerCase();
|
||||
// Commands that operate on remote state and do NOT trigger local file
|
||||
// watchers. Local git mutations (commit, checkout, merge, rebase, reset,
|
||||
// pull) are already caught by watchers on .git/HEAD and refs/heads/.
|
||||
return (
|
||||
// GitHub PR operations (merge, close, create, edit, comment, review)
|
||||
/\bgh\s+pr\s+(merge|close|create|edit|comment|review)\b/.test(normalized) ||
|
||||
// Pushes to remote — local refs unchanged, but remote state (PR checks,
|
||||
// mergeable status) may shift immediately after.
|
||||
/\bgit\s+push\b/.test(normalized) ||
|
||||
// Fetches update refs/remotes/ which our watchers do not watch, so
|
||||
// ahead/behind counts can drift stale until the next refresh.
|
||||
/\bgit\s+fetch\b/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -547,6 +547,9 @@ export async function createPaseoDaemon(
|
||||
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
||||
registry: agentStorage,
|
||||
appendSystemPrompt: config.appendSystemPrompt,
|
||||
onWorkspaceStateMayHaveChanged: ({ cwd }) => {
|
||||
workspaceGitService.onWorkspaceStateMayHaveChanged(cwd);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -597,6 +597,7 @@ function createWorkspaceGitServiceStub(): WorkspaceGitService {
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
|
||||
@@ -147,6 +147,7 @@ function createHarness(input: {
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
|
||||
@@ -521,6 +521,7 @@ function createSessionForWorkspaceTests(
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
@@ -631,6 +632,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
@@ -1013,6 +1015,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
@@ -1373,6 +1376,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
@@ -1562,6 +1566,7 @@ test("close_items_request archives stored agents that are not currently loaded",
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
@@ -1712,6 +1717,7 @@ test("close_items_request continues after an archive failure", async () => {
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
@@ -2578,6 +2584,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
|
||||
@@ -77,6 +77,7 @@ export function createNoopWorkspaceGitService(
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
dispose: () => {},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -147,6 +147,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
dispose: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,6 +252,7 @@ function createSessionForWireCompatTest(options?: {
|
||||
loopService: {} as SessionOptions["loopService"],
|
||||
checkoutDiffManager: {
|
||||
scheduleRefreshForCwd() {},
|
||||
onWorkspaceStateMayHaveChanged() {},
|
||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||
github: {
|
||||
invalidate() {},
|
||||
|
||||
@@ -509,7 +509,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
service.scheduleRefreshForCwd(REPO_CWD);
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
@@ -644,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
const forcePromise = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
|
||||
await flushPromises();
|
||||
service.scheduleRefreshForCwd(REPO_CWD);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushPromises();
|
||||
|
||||
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||
@@ -776,7 +776,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
|
||||
subscription.unsubscribe();
|
||||
watchCallbacks[0]?.();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(callsBeforeStaleCallback);
|
||||
@@ -1728,4 +1728,67 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("onWorkspaceStateMayHaveChanged invalidates github cache and schedules a forced github-inclusive refresh", async () => {
|
||||
const github = createGitHubServiceStub();
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
getPullRequestStatus,
|
||||
github,
|
||||
});
|
||||
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.onWorkspaceStateMayHaveChanged(REPO_CWD);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(github.invalidate).toHaveBeenCalledWith({ cwd: REPO_CWD });
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("onWorkspaceStateMayHaveChanged preserves includeGitHub when a file watcher fires within the debounce window", async () => {
|
||||
const github = createGitHubServiceStub();
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
getPullRequestStatus,
|
||||
github,
|
||||
});
|
||||
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.onWorkspaceStateMayHaveChanged(REPO_CWD);
|
||||
// File watcher fires 200ms later (before debounce expires)
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
service.scheduleRefreshForCwd(REPO_CWD);
|
||||
|
||||
// Advance past the debounce
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushPromises();
|
||||
|
||||
// Should still refresh GitHub because the first signal asked for it
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("onWorkspaceStateMayHaveChanged is a no-op for unknown cwds", () => {
|
||||
const github = createGitHubServiceStub();
|
||||
const service = createService({ github });
|
||||
|
||||
service.onWorkspaceStateMayHaveChanged("/unknown/cwd");
|
||||
|
||||
expect(github.invalidate).not.toHaveBeenCalled();
|
||||
service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -878,7 +878,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
expect(repoRootWatch).toBeDefined();
|
||||
|
||||
repoRootWatch?.callback();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutShortstat).toHaveBeenLastCalledWith(
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "./workspace-git-metadata.js";
|
||||
import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
||||
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
||||
@@ -158,6 +158,7 @@ export interface WorkspaceGitService {
|
||||
onChange: () => void,
|
||||
): Promise<{ repoRoot: string | null; unsubscribe: () => void }>;
|
||||
scheduleRefreshForCwd(cwd: string): void;
|
||||
onWorkspaceStateMayHaveChanged(cwd: string): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -217,11 +218,10 @@ interface WorkspaceGitRefreshRequest {
|
||||
notify: boolean;
|
||||
}
|
||||
|
||||
interface QueuedWorkspaceGitRefresh {
|
||||
force: boolean;
|
||||
includeGitHub: boolean;
|
||||
reason: string;
|
||||
notify: boolean;
|
||||
interface ScheduledWorkspaceGitRefreshOptions {
|
||||
force?: boolean;
|
||||
includeGitHub?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
type WorkspaceGitRefreshState =
|
||||
@@ -233,7 +233,7 @@ type WorkspaceGitRefreshState =
|
||||
promise: Promise<WorkspaceGitRuntimeSnapshot>;
|
||||
force: boolean;
|
||||
includeGitHub: boolean;
|
||||
queued: QueuedWorkspaceGitRefresh | null;
|
||||
queued: WorkspaceGitRefreshRequest | null;
|
||||
};
|
||||
|
||||
interface WorkspaceGitServiceDependencies {
|
||||
@@ -268,6 +268,7 @@ interface WorkspaceGitTarget {
|
||||
listeners: Set<WorkspaceGitListener>;
|
||||
watchers: FSWatcher[];
|
||||
debounceTimer: NodeJS.Timeout | null;
|
||||
pendingDebounceRequest: WorkspaceGitRefreshRequest | null;
|
||||
selfHealTimer: NodeJS.Timeout | null;
|
||||
githubPollSubscription: { unsubscribe: () => void } | null;
|
||||
githubPollKey: string | null;
|
||||
@@ -686,6 +687,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
}
|
||||
|
||||
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
||||
const normalizedCwd = normalizeWorkspaceId(cwd);
|
||||
const target = this.workspaceTargets.get(normalizedCwd);
|
||||
if (!target || target.closed) {
|
||||
return;
|
||||
}
|
||||
this.deps.github.invalidate({ cwd: normalizedCwd });
|
||||
this.scheduleWorkspaceRefresh(target, {
|
||||
force: true,
|
||||
includeGitHub: true,
|
||||
reason: "external-state-change",
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const target of this.workspaceTargets.values()) {
|
||||
this.closeWorkspaceTarget(target);
|
||||
@@ -799,6 +814,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
listeners: new Set(),
|
||||
watchers: [],
|
||||
debounceTimer: null,
|
||||
pendingDebounceRequest: null,
|
||||
selfHealTimer: null,
|
||||
githubPollSubscription: null,
|
||||
githubPollKey: null,
|
||||
@@ -1100,7 +1116,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
|
||||
private scheduleWorkspaceRefresh(
|
||||
targetOrCwd: WorkspaceGitTarget | string,
|
||||
options?: { force?: boolean; reason?: string },
|
||||
options?: ScheduledWorkspaceGitRefreshOptions,
|
||||
): void {
|
||||
const target =
|
||||
typeof targetOrCwd === "string"
|
||||
@@ -1110,6 +1126,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = this.buildScheduledRefreshRequest(options);
|
||||
target.pendingDebounceRequest = this.mergeRefreshRequests(
|
||||
target.pendingDebounceRequest,
|
||||
request,
|
||||
);
|
||||
|
||||
if (target.debounceTimer) {
|
||||
clearTimeout(target.debounceTimer);
|
||||
}
|
||||
@@ -1119,12 +1141,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
target.debounceTimer = null;
|
||||
void this.refreshWorkspaceTarget(target, {
|
||||
force: options?.force === true,
|
||||
includeGitHub: false,
|
||||
reason: options?.reason ?? "watch",
|
||||
notify: true,
|
||||
});
|
||||
const merged = target.pendingDebounceRequest;
|
||||
target.pendingDebounceRequest = null;
|
||||
if (merged) {
|
||||
void this.refreshWorkspaceTarget(target, merged);
|
||||
}
|
||||
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
@@ -1460,7 +1481,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const needsGitHubRefresh =
|
||||
request.force && request.includeGitHub && !target.refreshState.includeGitHub;
|
||||
if (needsForcedRefresh || needsGitHubRefresh) {
|
||||
target.refreshState.queued = this.mergeQueuedRefresh(target.refreshState.queued, request);
|
||||
target.refreshState.queued = this.mergeRefreshRequests(target.refreshState.queued, request);
|
||||
}
|
||||
return target.refreshState.promise;
|
||||
}
|
||||
@@ -1532,27 +1553,33 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return this.deps.now().getTime() - target.lastShellOutAtMs < WORKSPACE_GIT_INTERNAL_MIN_GAP_MS;
|
||||
}
|
||||
|
||||
private mergeQueuedRefresh(
|
||||
queued: QueuedWorkspaceGitRefresh | null,
|
||||
private buildScheduledRefreshRequest(
|
||||
options: ScheduledWorkspaceGitRefreshOptions | undefined,
|
||||
): WorkspaceGitRefreshRequest {
|
||||
return {
|
||||
force: options?.force === true,
|
||||
includeGitHub: options?.includeGitHub ?? false,
|
||||
reason: options?.reason ?? "watch",
|
||||
notify: true,
|
||||
};
|
||||
}
|
||||
|
||||
private mergeRefreshRequests(
|
||||
pending: WorkspaceGitRefreshRequest | null,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
): QueuedWorkspaceGitRefresh {
|
||||
if (!queued) {
|
||||
return {
|
||||
force: request.force,
|
||||
includeGitHub: request.includeGitHub,
|
||||
reason: request.reason,
|
||||
notify: request.notify,
|
||||
};
|
||||
): WorkspaceGitRefreshRequest {
|
||||
if (!pending) {
|
||||
return request;
|
||||
}
|
||||
|
||||
const force = queued.force || request.force;
|
||||
const upgradesForce = request.force && !queued.force;
|
||||
const upgradesGitHub = request.includeGitHub && !queued.includeGitHub;
|
||||
const force = pending.force || request.force;
|
||||
const upgradesForce = request.force && !pending.force;
|
||||
const upgradesGitHub = request.includeGitHub && !pending.includeGitHub;
|
||||
return {
|
||||
force,
|
||||
includeGitHub: queued.includeGitHub || request.includeGitHub,
|
||||
reason: upgradesForce || upgradesGitHub ? request.reason : queued.reason,
|
||||
notify: queued.notify || request.notify,
|
||||
includeGitHub: pending.includeGitHub || request.includeGitHub,
|
||||
reason: upgradesForce || upgradesGitHub ? request.reason : pending.reason,
|
||||
notify: pending.notify || request.notify,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user