Stop idle workspace Git refreshes from starving daemon requests (#3323)
* fix(server): trust healthy workspace git watchers Quiet observed workspaces were periodically scheduling full Git refreshes, saturating the daemon-wide process pool as workspace count grew. Keep only cheap observation re-ensure work, refresh ignore paths from watcher events, and detect silent metadata watchers with a one-shot canary before accepting the subscription. * fix(server): refresh watcher ignores from git metadata Repository exclude and Git configuration changes also affect the standard ignore set. Route those metadata events back to linked working-tree observations so their excluded directory lists stay current without periodic Git polling. * test(server): drive ignore updates through watcher events The integration contract now exercises the production event-driven ignore refresh instead of waiting for the removed periodic audit. * fix(server): normalize git metadata paths on windows File observation returns platform-native relative separators. Normalize those paths before matching Git metadata names so repository exclusion changes refresh working-tree ignores on Windows too. * fix(server): trail concurrent ignore refreshes Remember ignore-source events that arrive during an in-flight Git query and recompute once more before considering the working-tree observer current.
This commit is contained in:
@@ -16,4 +16,6 @@ Read aggregate health from the owning observer service. Runtime metrics include
|
|||||||
|
|
||||||
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
|
||||||
|
|
||||||
|
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
|
||||||
|
|
||||||
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
||||||
|
|
||||||
|
const cleanupPaths: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
cleanupPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requires the canary event to round-trip through the watcher callback", async () => {
|
||||||
|
const watchRoot = await mkdtemp(join(tmpdir(), "paseo-watcher-canary-"));
|
||||||
|
cleanupPaths.push(watchRoot);
|
||||||
|
const canary = createWatcherLivenessCanary(watchRoot, { timeoutMs: 1_000 });
|
||||||
|
|
||||||
|
const verification = canary.verify();
|
||||||
|
const canaryPath = canary.path;
|
||||||
|
expect(canary.filterEvents([{ path: canaryPath, type: "create" }])).toEqual([]);
|
||||||
|
|
||||||
|
await expect(verification).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects when the watcher never reports the canary", async () => {
|
||||||
|
const watchRoot = await mkdtemp(join(tmpdir(), "paseo-watcher-canary-"));
|
||||||
|
cleanupPaths.push(watchRoot);
|
||||||
|
const canary = createWatcherLivenessCanary(watchRoot, { timeoutMs: 10 });
|
||||||
|
|
||||||
|
await expect(canary.verify()).rejects.toThrow("did not report its liveness canary");
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { rm, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { FileChange } from "./file-observer/index.js";
|
||||||
|
|
||||||
|
export const WATCHER_LIVENESS_CANARY_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export interface WatcherLivenessCanary {
|
||||||
|
readonly path: string;
|
||||||
|
filterEvents(events: FileChange[]): FileChange[];
|
||||||
|
verify(signal?: AbortSignal): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWatcherLivenessCanary(
|
||||||
|
watchRoot: string,
|
||||||
|
options: { timeoutMs?: number } = {},
|
||||||
|
): WatcherLivenessCanary {
|
||||||
|
const canaryPath = join(watchRoot, `.paseo-watcher-canary-${randomUUID()}`);
|
||||||
|
const timeoutMs = options.timeoutMs ?? WATCHER_LIVENESS_CANARY_TIMEOUT_MS;
|
||||||
|
let reportCanary!: () => void;
|
||||||
|
const reported = new Promise<void>((resolve) => {
|
||||||
|
reportCanary = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: canaryPath,
|
||||||
|
filterEvents(events) {
|
||||||
|
const filtered = events.filter((event) => event.path !== canaryPath);
|
||||||
|
if (filtered.length !== events.length) {
|
||||||
|
reportCanary();
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
async verify(signal) {
|
||||||
|
await writeFile(canaryPath, "paseo watcher liveness canary\n", { flag: "wx" });
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
let removeAbortListener = () => {};
|
||||||
|
try {
|
||||||
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Watcher for ${watchRoot} did not report its liveness canary within ${timeoutMs}ms`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
const abortPromise = new Promise<never>((_resolve, reject) => {
|
||||||
|
if (!signal) return;
|
||||||
|
const rejectForAbort = () => reject(signal.reason);
|
||||||
|
if (signal.aborted) {
|
||||||
|
rejectForAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", rejectForAbort, { once: true });
|
||||||
|
removeAbortListener = () => signal.removeEventListener("abort", rejectForAbort);
|
||||||
|
});
|
||||||
|
await Promise.race([reported, timeoutPromise, abortPromise]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
removeAbortListener();
|
||||||
|
await rm(canaryPath, { force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -153,6 +153,18 @@ async function startObservedFixture(siblingCount: number): Promise<ReturnType<ty
|
|||||||
return fixture;
|
return fixture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("quiet observed workspaces spawn no git commands beyond the old self-heal interval", async () => {
|
||||||
|
await startObservedFixture(2);
|
||||||
|
await settleGitCommands();
|
||||||
|
|
||||||
|
startGitCommandMetrics();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 61_000));
|
||||||
|
await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 5_000 });
|
||||||
|
const idle = stopGitCommandMetrics();
|
||||||
|
|
||||||
|
expect(idle.submissions).toEqual([]);
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
interface GitRuntimeExpectation {
|
interface GitRuntimeExpectation {
|
||||||
currentBranch?: string;
|
currentBranch?: string;
|
||||||
remoteUrl?: string;
|
remoteUrl?: string;
|
||||||
@@ -377,21 +389,21 @@ test.each([1, 2, 10])(
|
|||||||
const creation = stopGitCommandMetrics();
|
const creation = stopGitCommandMetrics();
|
||||||
|
|
||||||
expect(response?.workspace?.name).toBe("created-during-measurement");
|
expect(response?.workspace?.name).toBe("created-during-measurement");
|
||||||
expect(creation.submitted).toBe(90);
|
|
||||||
expect(countGitOperations(creation.submissions)).toEqual({
|
expect(countGitOperations(creation.submissions)).toEqual({
|
||||||
branch: 4,
|
branch: 4,
|
||||||
config: 19,
|
config: 22,
|
||||||
diff: 2,
|
diff: 3,
|
||||||
"for-each-ref": 6,
|
"for-each-ref": 7,
|
||||||
"ls-files": 3,
|
"ls-files": 4,
|
||||||
"merge-base": 2,
|
"merge-base": 3,
|
||||||
"rev-list": 2,
|
"rev-list": 3,
|
||||||
"rev-parse": 34,
|
"rev-parse": 38,
|
||||||
"show-ref": 6,
|
"show-ref": 7,
|
||||||
status: 6,
|
status: 7,
|
||||||
"symbolic-ref": 4,
|
"symbolic-ref": 4,
|
||||||
worktree: 2,
|
worktree: 1,
|
||||||
});
|
});
|
||||||
|
expect(creation.submitted).toBe(103);
|
||||||
const existingWorktrees = new Set(fixture.siblingWorktrees.map((cwd) => realpathSync(cwd)));
|
const existingWorktrees = new Set(fixture.siblingWorktrees.map((cwd) => realpathSync(cwd)));
|
||||||
expect(
|
expect(
|
||||||
creation.submissions.filter((command) => existingWorktrees.has(realpathSync(command.cwd))),
|
creation.submissions.filter((command) => existingWorktrees.has(realpathSync(command.cwd))),
|
||||||
|
|||||||
@@ -180,7 +180,6 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
getCheckoutWorktreeState,
|
getCheckoutWorktreeState,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
runGitCommand,
|
runGitCommand,
|
||||||
getWorkspaceGitSelfHealPhaseMs: () => 7_000,
|
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
const diffManager = new CheckoutDiffManager({
|
const diffManager = new CheckoutDiffManager({
|
||||||
@@ -320,7 +319,7 @@ test("recursive observation updates tracked state and prunes ignored storms", as
|
|||||||
editedDuringIgnoreUpdate = true;
|
editedDuringIgnoreUpdate = true;
|
||||||
writeFileSync(newlyTrackedPath, "first\nsecond\n");
|
writeFileSync(newlyTrackedPath, "first\nsecond\n");
|
||||||
};
|
};
|
||||||
writeFileSync(path.join(repoDir, ".git", "index"), "force-added\n");
|
writeFileSync(path.join(repoDir, ".gitignore"), "cache/\n");
|
||||||
await vi.waitFor(
|
await vi.waitFor(
|
||||||
() => {
|
() => {
|
||||||
expect(editedDuringIgnoreUpdate).toBe(true);
|
expect(editedDuringIgnoreUpdate).toBe(true);
|
||||||
|
|||||||
@@ -191,6 +191,11 @@ function createService(
|
|||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
signal: null,
|
signal: null,
|
||||||
})),
|
})),
|
||||||
|
createWatcherLivenessCanary: vi.fn(() => ({
|
||||||
|
path: "",
|
||||||
|
filterEvents: (events) => events,
|
||||||
|
verify: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
@@ -1583,6 +1588,47 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("repository watcher canary timeout closes the subscription and starts fallback polling", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const logger = createLogger();
|
||||||
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
|
const verifyCanary = vi.fn(async () => {
|
||||||
|
throw new Error("watcher did not report its liveness canary");
|
||||||
|
});
|
||||||
|
const service = createService(
|
||||||
|
watcher,
|
||||||
|
{
|
||||||
|
getCheckoutStatus,
|
||||||
|
createWatcherLivenessCanary: vi.fn(() => ({
|
||||||
|
path: path.join(GIT_DIR, "paseo", ".watcher-canary-timeout"),
|
||||||
|
filterEvents: (events: WatchEvent[]) => events,
|
||||||
|
verify: verifyCanary,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
const repositoryWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0];
|
||||||
|
expect(repositoryWatcher?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ repoGitRoot: GIT_DIR }),
|
||||||
|
"Failed to start repository metadata watcher; using degraded polling",
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusCallsBeforePoll = getCheckoutStatus.mock.calls.length;
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsBeforePoll);
|
||||||
|
});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("setup-time watcher error defers recovery until subscribe settles", async () => {
|
test("setup-time watcher error defers recovery until subscribe settles", async () => {
|
||||||
const watcher = createWatcherHarness();
|
const watcher = createWatcherHarness();
|
||||||
const openedSubscription = createDeferred<{
|
const openedSubscription = createDeferred<{
|
||||||
@@ -1862,7 +1908,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "node_modules"));
|
expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "node_modules"));
|
||||||
|
|
||||||
ignoredDirectories = "node_modules/\nbuild/\n";
|
ignoredDirectories = "node_modules/\nbuild/\n";
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(runGitCommand).toHaveBeenCalledTimes(3);
|
expect(runGitCommand).toHaveBeenCalledTimes(3);
|
||||||
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledTimes(1);
|
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledTimes(1);
|
||||||
@@ -1918,7 +1964,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
const originalWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
const originalWatcher = watcher.records.find((record) => record.directory === REPO_CWD);
|
||||||
|
|
||||||
ignoredDirectories = "node_modules/\n";
|
ignoredDirectories = "node_modules/\n";
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
originalWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(originalWatcher?.updateIgnore).toHaveBeenCalledTimes(1);
|
expect(originalWatcher?.updateIgnore).toHaveBeenCalledTimes(1);
|
||||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||||
@@ -1940,6 +1986,93 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("repository exclude changes update working-tree filtering", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
let ignoredDirectories = "node_modules/\n";
|
||||||
|
const runGitCommand = vi.fn(async (args: string[]) => ({
|
||||||
|
stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories,
|
||||||
|
stderr: "",
|
||||||
|
truncated: false,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
}));
|
||||||
|
const service = createService(watcher, { runGitCommand });
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
|
||||||
|
const repositoryWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0];
|
||||||
|
ignoredDirectories = "node_modules/\nbuild/\n";
|
||||||
|
|
||||||
|
repositoryWatcher?.callback(null, [
|
||||||
|
{ path: path.join(GIT_DIR, "info", "exclude"), type: "update" },
|
||||||
|
]);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "build"));
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignore events during a refresh trigger a trailing recomputation", async () => {
|
||||||
|
const watcher = createWatcherHarness();
|
||||||
|
const blockedRefresh = createDeferred<{
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
truncated: boolean;
|
||||||
|
exitCode: number;
|
||||||
|
signal: null;
|
||||||
|
}>();
|
||||||
|
let lsFilesCallCount = 0;
|
||||||
|
const runGitCommand = vi.fn(async (args: string[]) => {
|
||||||
|
if (args[0] === "rev-parse") {
|
||||||
|
return {
|
||||||
|
stdout: `${REPO_CWD}\n`,
|
||||||
|
stderr: "",
|
||||||
|
truncated: false,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
lsFilesCallCount += 1;
|
||||||
|
if (lsFilesCallCount === 2) return blockedRefresh.promise;
|
||||||
|
return {
|
||||||
|
stdout: lsFilesCallCount === 1 ? "node_modules/\n" : "node_modules/\nbuild/\n",
|
||||||
|
stderr: "",
|
||||||
|
truncated: false,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const service = createService(watcher, { runGitCommand });
|
||||||
|
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||||
|
});
|
||||||
|
const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0];
|
||||||
|
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
|
||||||
|
await vi.waitFor(() => expect(lsFilesCallCount).toBe(2));
|
||||||
|
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
|
||||||
|
blockedRefresh.resolve({
|
||||||
|
stdout: "node_modules/\n",
|
||||||
|
stderr: "",
|
||||||
|
truncated: false,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(lsFilesCallCount).toBe(3));
|
||||||
|
expect(checkoutWatcher?.ignore).toContain(path.join(REPO_CWD, "build"));
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
service.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test("ignore watcher update failure enters polling with a distinct reason", async () => {
|
test("ignore watcher update failure enters polling with a distinct reason", async () => {
|
||||||
const watcher = createWatcherHarness();
|
const watcher = createWatcherHarness();
|
||||||
const logger = createLogger();
|
const logger = createLogger();
|
||||||
@@ -1971,7 +2104,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
checkoutWatcher?.updateIgnore.mockRejectedValueOnce(new Error("update failed"));
|
checkoutWatcher?.updateIgnore.mockRejectedValueOnce(new Error("update failed"));
|
||||||
ignoredDirectories = "node_modules/\n";
|
ignoredDirectories = "node_modules/\n";
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]);
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(logger.warn).toHaveBeenCalledWith(
|
expect(logger.warn).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "watcher_update_failed" }),
|
expect.objectContaining({ reason: "watcher_update_failed" }),
|
||||||
@@ -1993,7 +2126,7 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a missed watcher event converges through the deterministic audit", async () => {
|
test("a working-tree watcher event refreshes summary and diff subscribers", async () => {
|
||||||
const watcher = createWatcherHarness();
|
const watcher = createWatcherHarness();
|
||||||
let isDirty = false;
|
let isDirty = false;
|
||||||
let diffStat = { additions: 0, deletions: 0 };
|
let diffStat = { additions: 0, deletions: 0 };
|
||||||
@@ -2010,7 +2143,6 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
getCheckoutShortstat,
|
getCheckoutShortstat,
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getWorkspaceGitSelfHealPhaseMs: () => 10_000,
|
|
||||||
});
|
});
|
||||||
const diffManager = new CheckoutDiffManager({
|
const diffManager = new CheckoutDiffManager({
|
||||||
logger: createLogger(),
|
logger: createLogger(),
|
||||||
@@ -2033,7 +2165,10 @@ describe("WorkspaceGitService checkout observation", () => {
|
|||||||
diffStat = { additions: 7, deletions: 3 };
|
diffStat = { additions: 7, deletions: 3 };
|
||||||
diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }];
|
diffFiles = [{ path: "tracked.txt", additions: 7, deletions: 3, status: "modified" }];
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(70_000);
|
getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]?.callback(null, [
|
||||||
|
{ path: path.join(REPO_CWD, "tracked.txt"), type: "update" },
|
||||||
|
]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(listener).toHaveBeenLastCalledWith(
|
expect(listener).toHaveBeenLastCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -370,6 +370,11 @@ function buildDefaultServiceDeps() {
|
|||||||
signal: null,
|
signal: null,
|
||||||
})),
|
})),
|
||||||
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
||||||
|
createWatcherLivenessCanary: vi.fn(() => ({
|
||||||
|
path: "",
|
||||||
|
filterEvents: (events) => events,
|
||||||
|
verify: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
now: () => new Date("2026-04-12T00:00:00.000Z"),
|
now: () => new Date("2026-04-12T00:00:00.000Z"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -867,7 +872,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("self-heal timer refreshes git without refreshing GitHub", async () => {
|
test("quiet observed workspaces do not refresh git on the observation re-ensure timer", async () => {
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
@@ -886,7 +891,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await vi.advanceTimersByTimeAsync(120_000);
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledWith(
|
expect(getPullRequestStatus).toHaveBeenCalledWith(
|
||||||
REPO_CWD,
|
REPO_CWD,
|
||||||
@@ -899,50 +904,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("self-heal phase is stable across process restarts", () => {
|
test("observation re-ensure phase is stable across process restarts", () => {
|
||||||
expect(getWorkspaceGitSelfHealPhaseMs("/tmp/repo")).toBe(54_185);
|
expect(getWorkspaceGitSelfHealPhaseMs("/tmp/repo")).toBe(54_185);
|
||||||
expect(getWorkspaceGitSelfHealPhaseMs("/tmp/staggered-repo")).toBe(9_817);
|
expect(getWorkspaceGitSelfHealPhaseMs("/tmp/staggered-repo")).toBe(9_817);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("self-heal staggers workspaces and settles into a 120 second cadence", async () => {
|
test("observation re-ensure retries setup even when the git snapshot is still recent", async () => {
|
||||||
vi.setSystemTime(0);
|
|
||||||
const otherRepoCwd = resolvePath("/tmp/staggered-repo");
|
|
||||||
const refreshes = new Map<string, number[]>();
|
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => {
|
|
||||||
const timestamps = refreshes.get(cwd) ?? [];
|
|
||||||
timestamps.push(Date.now());
|
|
||||||
refreshes.set(cwd, timestamps);
|
|
||||||
return createCheckoutStatus(cwd, { hasRemote: false, remoteUrl: null });
|
|
||||||
});
|
|
||||||
const service = createService({
|
|
||||||
getCheckoutSnapshotFacts: vi.fn(async (cwd: string) =>
|
|
||||||
createCheckoutFacts(cwd, { remoteUrl: null }),
|
|
||||||
),
|
|
||||||
getCheckoutStatus,
|
|
||||||
getWorkspaceGitSelfHealPhaseMs: (cwd) => (cwd === REPO_CWD ? 10_000 : 40_000),
|
|
||||||
now: () => new Date(Date.now()),
|
|
||||||
});
|
|
||||||
const first = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
|
||||||
const second = service.registerWorkspace({ cwd: otherRepoCwd }, vi.fn());
|
|
||||||
await Promise.all([service.getSnapshot(REPO_CWD), service.getSnapshot(otherRepoCwd)]);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(300_000);
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
const firstSelfHeals = refreshes.get(REPO_CWD)?.slice(1) ?? [];
|
|
||||||
const secondSelfHeals = refreshes.get(otherRepoCwd)?.slice(1) ?? [];
|
|
||||||
expect(firstSelfHeals).toHaveLength(2);
|
|
||||||
expect(secondSelfHeals).toHaveLength(2);
|
|
||||||
expect(firstSelfHeals[0]).not.toBe(secondSelfHeals[0]);
|
|
||||||
expect(firstSelfHeals[1] - firstSelfHeals[0]).toBe(120_000);
|
|
||||||
expect(secondSelfHeals[1] - secondSelfHeals[0]).toBe(120_000);
|
|
||||||
|
|
||||||
first.unsubscribe();
|
|
||||||
second.unsubscribe();
|
|
||||||
service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("self-heal retries observation setup even when the git snapshot is still recent", async () => {
|
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
const getCheckoutSnapshotFacts = vi
|
const getCheckoutSnapshotFacts = vi
|
||||||
.fn<(cwd: string) => Promise<CheckoutSnapshotFacts>>()
|
.fn<(cwd: string) => Promise<CheckoutSnapshotFacts>>()
|
||||||
@@ -1483,7 +1450,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multiple subscribers on the same target share one self-heal timer", async () => {
|
test("multiple subscribers on the same target do not duplicate periodic git work", async () => {
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
|
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
|
||||||
createCheckoutFacts(cwd, { remoteUrl: null }),
|
createCheckoutFacts(cwd, { remoteUrl: null }),
|
||||||
@@ -1504,14 +1471,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await vi.advanceTimersByTimeAsync(120_000);
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
first.unsubscribe();
|
first.unsubscribe();
|
||||||
second.unsubscribe();
|
second.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unsubscribe with no remaining subscribers clears the self-heal timer", async () => {
|
test("unsubscribe with no remaining subscribers clears the observation re-ensure timer", async () => {
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -1531,7 +1498,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("service disposal clears all self-heal timers", async () => {
|
test("service disposal clears all observation re-ensure timers", async () => {
|
||||||
let nowMs = 0;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -1547,42 +1514,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("direct getSnapshot returns current snapshot during a self-heal refresh", async () => {
|
|
||||||
let nowMs = 0;
|
|
||||||
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
|
||||||
const getCheckoutStatus = vi
|
|
||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
|
||||||
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
|
|
||||||
.mockImplementationOnce(async () => selfHealRefresh.promise);
|
|
||||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) =>
|
|
||||||
createCheckoutFacts(cwd, { remoteUrl: null }),
|
|
||||||
);
|
|
||||||
const service = createService({
|
|
||||||
getCheckoutSnapshotFacts,
|
|
||||||
getCheckoutStatus,
|
|
||||||
now: () => new Date(nowMs),
|
|
||||||
});
|
|
||||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
|
||||||
await service.getSnapshot(REPO_CWD);
|
|
||||||
|
|
||||||
nowMs = 120_000;
|
|
||||||
await vi.advanceTimersByTimeAsync(120_000);
|
|
||||||
await flushPromises();
|
|
||||||
const directRead = service.getSnapshot(REPO_CWD);
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
|
||||||
await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD));
|
|
||||||
|
|
||||||
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
service.dispose();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
describe("WorkspaceGitServiceImpl D2 read methods", () => {
|
||||||
|
|||||||
@@ -293,6 +293,11 @@ function buildDefaultTestServiceDeps() {
|
|||||||
signal: null,
|
signal: null,
|
||||||
})),
|
})),
|
||||||
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
||||||
|
createWatcherLivenessCanary: vi.fn(() => ({
|
||||||
|
path: "",
|
||||||
|
filterEvents: (events) => events,
|
||||||
|
verify: vi.fn(async () => {}),
|
||||||
|
})),
|
||||||
now: () => new Date("2026-04-12T00:00:00.000Z"),
|
now: () => new Date("2026-04-12T00:00:00.000Z"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join, resolve } from "node:path";
|
import { basename, join, resolve } from "node:path";
|
||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import pLimit from "p-limit";
|
import pLimit from "p-limit";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -68,11 +68,12 @@ import {
|
|||||||
createFileObserver,
|
createFileObserver,
|
||||||
} from "./file-observer/index.js";
|
} from "./file-observer/index.js";
|
||||||
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
||||||
|
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
||||||
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
|
||||||
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
||||||
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
|
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
|
||||||
const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
|
const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||||
@@ -108,12 +109,16 @@ function mergeSets<T>(
|
|||||||
return { merged, added };
|
return { merged, added };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkspaceGitSelfHealPhaseMs(cwd: string): number {
|
export function getWorkspaceGitObservationReensurePhaseMs(cwd: string): number {
|
||||||
return (
|
return (
|
||||||
createHash("sha256").update(cwd).digest().readUInt32BE(0) % WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS
|
createHash("sha256").update(cwd).digest().readUInt32BE(0) %
|
||||||
|
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kept for local diagnostic fixtures that only use the stable phase calculation.
|
||||||
|
export const getWorkspaceGitSelfHealPhaseMs = getWorkspaceGitObservationReensurePhaseMs;
|
||||||
|
|
||||||
export interface WorkspaceGitRuntimeSnapshot {
|
export interface WorkspaceGitRuntimeSnapshot {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
git: {
|
git: {
|
||||||
@@ -353,7 +358,8 @@ interface WorkspaceGitServiceDependencies {
|
|||||||
observer: WorkspaceGitFetchObserver,
|
observer: WorkspaceGitFetchObserver,
|
||||||
) => Promise<WorkspaceGitFetchResult>;
|
) => Promise<WorkspaceGitFetchResult>;
|
||||||
runGitCommand: typeof runGitCommand;
|
runGitCommand: typeof runGitCommand;
|
||||||
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitSelfHealPhaseMs;
|
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
||||||
|
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
|
||||||
now: () => Date;
|
now: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,15 +393,12 @@ interface WorkspaceGitTarget {
|
|||||||
workingTreeWatchTarget: WorkingTreeWatchTarget | null;
|
workingTreeWatchTarget: WorkingTreeWatchTarget | null;
|
||||||
debounceTimer: NodeJS.Timeout | null;
|
debounceTimer: NodeJS.Timeout | null;
|
||||||
pendingDebounceRequest: WorkspaceGitRefreshRequest | null;
|
pendingDebounceRequest: WorkspaceGitRefreshRequest | null;
|
||||||
selfHealTimer: NodeJS.Timeout | null;
|
observationReensureTimer: NodeJS.Timeout | null;
|
||||||
forgePrStatusPollSubscription: { unsubscribe: () => void } | null;
|
forgePrStatusPollSubscription: { unsubscribe: () => void } | null;
|
||||||
forgePrStatusPollKey: string | null;
|
forgePrStatusPollKey: string | null;
|
||||||
refreshState: WorkspaceGitRefreshState;
|
refreshState: WorkspaceGitRefreshState;
|
||||||
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
|
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
|
||||||
latestGitLoadedAtMs: number | null;
|
latestGitLoadedAtMs: number | null;
|
||||||
latestStructuralRefreshAtMs: number | null;
|
|
||||||
latestWorktreeRefreshAtMs: number | null;
|
|
||||||
auditWindowStartedAtMs: number | null;
|
|
||||||
latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
|
latestForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
|
||||||
latestForgeLoadedAtMs: number | null;
|
latestForgeLoadedAtMs: number | null;
|
||||||
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||||
@@ -443,6 +446,7 @@ interface WorkingTreeWatchTarget {
|
|||||||
subscription: FileObserverSubscription | null;
|
subscription: FileObserverSubscription | null;
|
||||||
ignoredDirectories: Set<string>;
|
ignoredDirectories: Set<string>;
|
||||||
ignoredDirectoriesRefreshPromise: Promise<void> | null;
|
ignoredDirectoriesRefreshPromise: Promise<void> | null;
|
||||||
|
ignoredDirectoriesRefreshRequested: boolean;
|
||||||
aliases: Set<string>;
|
aliases: Set<string>;
|
||||||
workspaceKeys: Set<string>;
|
workspaceKeys: Set<string>;
|
||||||
fallbackPolling: boolean;
|
fallbackPolling: boolean;
|
||||||
@@ -500,7 +504,8 @@ function buildDefaultWorkspaceGitServiceDeps(
|
|||||||
hasOriginRemote,
|
hasOriginRemote,
|
||||||
runGitFetch: fetchWorkspaceGitRemote,
|
runGitFetch: fetchWorkspaceGitRemote,
|
||||||
runGitCommand,
|
runGitCommand,
|
||||||
getWorkspaceGitSelfHealPhaseMs,
|
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
|
||||||
|
createWatcherLivenessCanary,
|
||||||
now: () => new Date(),
|
now: () => new Date(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1130,15 +1135,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
workingTreeWatchTarget: null,
|
workingTreeWatchTarget: null,
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
pendingDebounceRequest: null,
|
pendingDebounceRequest: null,
|
||||||
selfHealTimer: null,
|
observationReensureTimer: null,
|
||||||
forgePrStatusPollSubscription: null,
|
forgePrStatusPollSubscription: null,
|
||||||
forgePrStatusPollKey: null,
|
forgePrStatusPollKey: null,
|
||||||
refreshState: { status: "idle" },
|
refreshState: { status: "idle" },
|
||||||
latestGit: null,
|
latestGit: null,
|
||||||
latestGitLoadedAtMs: null,
|
latestGitLoadedAtMs: null,
|
||||||
latestStructuralRefreshAtMs: null,
|
|
||||||
latestWorktreeRefreshAtMs: null,
|
|
||||||
auditWindowStartedAtMs: null,
|
|
||||||
latestForge: null,
|
latestForge: null,
|
||||||
latestForgeLoadedAtMs: null,
|
latestForgeLoadedAtMs: null,
|
||||||
latestSnapshot: null,
|
latestSnapshot: null,
|
||||||
@@ -1295,6 +1297,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
subscription: null,
|
subscription: null,
|
||||||
ignoredDirectories,
|
ignoredDirectories,
|
||||||
ignoredDirectoriesRefreshPromise: null,
|
ignoredDirectoriesRefreshPromise: null,
|
||||||
|
ignoredDirectoriesRefreshRequested: false,
|
||||||
aliases: new Set([cwd]),
|
aliases: new Set([cwd]),
|
||||||
workspaceKeys: new Set(),
|
workspaceKeys: new Set(),
|
||||||
fallbackPolling: false,
|
fallbackPolling: false,
|
||||||
@@ -1438,6 +1441,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (watcherErrored) {
|
if (watcherErrored) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (events.some((event) => basename(event.path) === ".gitignore")) {
|
||||||
|
void this.refreshWorkingTreeIgnoredDirectories(target);
|
||||||
|
}
|
||||||
if (!this.hasRelevantWorkingTreeEvent(target, events)) {
|
if (!this.hasRelevantWorkingTreeEvent(target, events)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1629,22 +1635,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (target.closed || target.repoRoot === null) {
|
if (target.closed || target.repoRoot === null) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
target.ignoredDirectoriesRefreshRequested = true;
|
||||||
if (target.ignoredDirectoriesRefreshPromise) {
|
if (target.ignoredDirectoriesRefreshPromise) {
|
||||||
return target.ignoredDirectoriesRefreshPromise;
|
return target.ignoredDirectoriesRefreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshPromise = this.replaceWorkingTreeIgnoredDirectories(target)
|
const refreshPromise = (async () => {
|
||||||
.catch((error) => {
|
while (!target.closed && target.ignoredDirectoriesRefreshRequested) {
|
||||||
this.logger.warn(
|
target.ignoredDirectoriesRefreshRequested = false;
|
||||||
{ err: error, cwd: target.cwd },
|
try {
|
||||||
"Failed to refresh working tree watcher ignore paths",
|
await this.replaceWorkingTreeIgnoredDirectories(target);
|
||||||
);
|
} catch (error) {
|
||||||
})
|
this.logger.warn(
|
||||||
.finally(() => {
|
{ err: error, cwd: target.cwd },
|
||||||
if (target.ignoredDirectoriesRefreshPromise === refreshPromise) {
|
"Failed to refresh working tree watcher ignore paths",
|
||||||
target.ignoredDirectoriesRefreshPromise = null;
|
);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
if (target.ignoredDirectoriesRefreshPromise === refreshPromise) {
|
||||||
|
target.ignoredDirectoriesRefreshPromise = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
target.ignoredDirectoriesRefreshPromise = refreshPromise;
|
target.ignoredDirectoriesRefreshPromise = refreshPromise;
|
||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
}
|
}
|
||||||
@@ -1822,6 +1834,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
join(target.repoGitRoot, path),
|
join(target.repoGitRoot, path),
|
||||||
);
|
);
|
||||||
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
|
const matchesRepoGitRoot = createRealpathAwarePathMatcher(target.repoGitRoot);
|
||||||
|
const canary = this.deps.createWatcherLivenessCanary(target.repoGitRoot);
|
||||||
|
let openedSubscription: FileObserverSubscription | null = null;
|
||||||
let watcherErrored = false;
|
let watcherErrored = false;
|
||||||
let subscribeSettled = false;
|
let subscribeSettled = false;
|
||||||
const markSubscribeSettled = () => {
|
const markSubscribeSettled = () => {
|
||||||
@@ -1834,6 +1848,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const subscription = await this.subscribeWithDeadline(
|
const subscription = await this.subscribeWithDeadline(
|
||||||
target.repoGitRoot,
|
target.repoGitRoot,
|
||||||
(error, events) => {
|
(error, events) => {
|
||||||
|
const liveEvents = canary.filterEvents(events);
|
||||||
if (error) {
|
if (error) {
|
||||||
if (watcherErrored) {
|
if (watcherErrored) {
|
||||||
return;
|
return;
|
||||||
@@ -1853,7 +1868,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (watcherErrored) {
|
if (watcherErrored) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const relevantEvents = events.filter(
|
if (liveEvents.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const relevantEvents = liveEvents.filter(
|
||||||
(event) =>
|
(event) =>
|
||||||
!matchesRepoGitRoot(event.path) &&
|
!matchesRepoGitRoot(event.path) &&
|
||||||
ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)),
|
ignore.every((ignoredPath) => !isRealpathInsideRoot(ignoredPath, event.path)),
|
||||||
@@ -1869,6 +1887,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
})
|
})
|
||||||
: relevantEvents;
|
: relevantEvents;
|
||||||
if (immediateEvents.length > 0) {
|
if (immediateEvents.length > 0) {
|
||||||
|
this.refreshWorkingTreeIgnoresFromRepoMetadataEvents(target, immediateEvents);
|
||||||
const routedRefreshes = this.routeRepoMetadataEvents(target, immediateEvents);
|
const routedRefreshes = this.routeRepoMetadataEvents(target, immediateEvents);
|
||||||
this.scheduleRepoMetadataRefresh(
|
this.scheduleRepoMetadataRefresh(
|
||||||
target,
|
target,
|
||||||
@@ -1882,6 +1901,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
{ ignore },
|
{ ignore },
|
||||||
markSubscribeSettled,
|
markSubscribeSettled,
|
||||||
);
|
);
|
||||||
|
openedSubscription = subscription;
|
||||||
|
await canary.verify(this.disposeController.signal);
|
||||||
if (watcherErrored) {
|
if (watcherErrored) {
|
||||||
await this.unsubscribeWatcherSubscription(subscription, target.repoGitRoot);
|
await this.unsubscribeWatcherSubscription(subscription, target.repoGitRoot);
|
||||||
return false;
|
return false;
|
||||||
@@ -1904,6 +1925,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (openedSubscription) {
|
||||||
|
await this.unsubscribeWatcherSubscription(openedSubscription, target.repoGitRoot);
|
||||||
|
}
|
||||||
if (watcherErrored) {
|
if (watcherErrored) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1974,6 +1998,41 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return refreshes;
|
return refreshes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private refreshWorkingTreeIgnoresFromRepoMetadataEvents(
|
||||||
|
target: RepoGitTarget,
|
||||||
|
events: FileChange[],
|
||||||
|
): void {
|
||||||
|
const workspaceKeys = new Set<string>();
|
||||||
|
for (const event of events) {
|
||||||
|
const commonRelativePath = getRealpathAwareRelativePath(
|
||||||
|
target.repoGitRoot,
|
||||||
|
event.path,
|
||||||
|
)?.replaceAll("\\", "/");
|
||||||
|
if (commonRelativePath === "config" || commonRelativePath === "info/exclude") {
|
||||||
|
for (const workspaceKey of target.workspaceKeys) workspaceKeys.add(workspaceKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const workspaceKey of target.workspaceKeys) {
|
||||||
|
const facts = this.workspaceTargets.get(workspaceKey)?.latestFacts;
|
||||||
|
if (
|
||||||
|
facts?.isGit &&
|
||||||
|
facts.absoluteGitDir &&
|
||||||
|
getRealpathAwareRelativePath(facts.absoluteGitDir, event.path)?.replaceAll("\\", "/") ===
|
||||||
|
"config.worktree"
|
||||||
|
) {
|
||||||
|
workspaceKeys.add(workspaceKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const workspaceKey of workspaceKeys) {
|
||||||
|
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
|
||||||
|
const workingTreeTarget = workspaceTarget
|
||||||
|
? this.getWorkingTreeWatchTargetForWorkspace(workspaceTarget)
|
||||||
|
: null;
|
||||||
|
if (workingTreeTarget) void this.refreshWorkingTreeIgnoredDirectories(workingTreeTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean {
|
private isFetchRemoteMetadataEvent(target: RepoGitTarget, event: FileChange): boolean {
|
||||||
const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
|
const relativePath = getRealpathAwareRelativePath(target.repoGitRoot, event.path);
|
||||||
const effect = classifyGitMetadataPath("common", relativePath ?? "");
|
const effect = classifyGitMetadataPath("common", relativePath ?? "");
|
||||||
@@ -2309,57 +2368,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void {
|
private startWorkspaceSubscriptionTimers(target: WorkspaceGitTarget): void {
|
||||||
if (!target.selfHealTimer) {
|
if (!target.observationReensureTimer) {
|
||||||
target.auditWindowStartedAtMs = this.deps.now().getTime();
|
const reensureObservation = () => {
|
||||||
const runSelfHealTick = () => {
|
|
||||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
target.selfHealTimer = null;
|
target.observationReensureTimer = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.selfHealTimer = setTimeout(runSelfHealTick, WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS);
|
target.observationReensureTimer = setTimeout(
|
||||||
|
reensureObservation,
|
||||||
|
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
|
||||||
|
);
|
||||||
this.scheduleWorkspaceObservationSetup(target);
|
this.scheduleWorkspaceObservationSetup(target);
|
||||||
const auditWindowStartedAtMs = target.auditWindowStartedAtMs;
|
|
||||||
target.auditWindowStartedAtMs = this.deps.now().getTime();
|
|
||||||
if (auditWindowStartedAtMs === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const workingTreeTarget = this.getWorkingTreeWatchTargetForWorkspace(target);
|
|
||||||
if (workingTreeTarget) {
|
|
||||||
void this.refreshWorkingTreeIgnoredDirectories(workingTreeTarget);
|
|
||||||
}
|
|
||||||
const refreshStructure =
|
|
||||||
target.latestStructuralRefreshAtMs === null ||
|
|
||||||
target.latestStructuralRefreshAtMs < auditWindowStartedAtMs;
|
|
||||||
const refreshWorktree =
|
|
||||||
refreshStructure ||
|
|
||||||
target.latestWorktreeRefreshAtMs === null ||
|
|
||||||
target.latestWorktreeRefreshAtMs < auditWindowStartedAtMs;
|
|
||||||
if (!refreshStructure && !refreshWorktree) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (refreshWorktree) {
|
|
||||||
if (workingTreeTarget) {
|
|
||||||
this.notifyWorkingTreeConsumers(workingTreeTarget);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.refreshWorkspaceTarget(target, {
|
|
||||||
force: false,
|
|
||||||
refreshStructure,
|
|
||||||
refreshWorktree,
|
|
||||||
includeForge: false,
|
|
||||||
reason: "self-heal-git",
|
|
||||||
notify: true,
|
|
||||||
queueIfBusy: true,
|
|
||||||
movedRemoteRefs: new Set(),
|
|
||||||
}).catch((error) => {
|
|
||||||
this.logger.warn(
|
|
||||||
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
|
||||||
"Failed to run workspace git self-heal refresh",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
target.selfHealTimer = setTimeout(
|
target.observationReensureTimer = setTimeout(
|
||||||
runSelfHealTick,
|
reensureObservation,
|
||||||
this.deps.getWorkspaceGitSelfHealPhaseMs(target.cwd),
|
this.deps.getWorkspaceGitSelfHealPhaseMs(target.cwd),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2891,7 +2913,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
};
|
};
|
||||||
const loadedAtMs = this.deps.now().getTime();
|
const loadedAtMs = this.deps.now().getTime();
|
||||||
target.latestGitLoadedAtMs = loadedAtMs;
|
target.latestGitLoadedAtMs = loadedAtMs;
|
||||||
target.latestWorktreeRefreshAtMs = loadedAtMs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshGitSnapshot(
|
private async refreshGitSnapshot(
|
||||||
@@ -2915,8 +2936,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.latestGit = buildNotGitSnapshot(cwd).git;
|
target.latestGit = buildNotGitSnapshot(cwd).git;
|
||||||
const loadedAtMs = this.deps.now().getTime();
|
const loadedAtMs = this.deps.now().getTime();
|
||||||
target.latestGitLoadedAtMs = loadedAtMs;
|
target.latestGitLoadedAtMs = loadedAtMs;
|
||||||
target.latestStructuralRefreshAtMs = loadedAtMs;
|
|
||||||
target.latestWorktreeRefreshAtMs = loadedAtMs;
|
|
||||||
target.latestForge = buildForgeUnavailableSnapshot();
|
target.latestForge = buildForgeUnavailableSnapshot();
|
||||||
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
|
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
|
||||||
return facts;
|
return facts;
|
||||||
@@ -2951,10 +2970,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
};
|
};
|
||||||
const loadedAtMs = this.deps.now().getTime();
|
const loadedAtMs = this.deps.now().getTime();
|
||||||
target.latestGitLoadedAtMs = loadedAtMs;
|
target.latestGitLoadedAtMs = loadedAtMs;
|
||||||
target.latestStructuralRefreshAtMs = loadedAtMs;
|
|
||||||
if (refreshWorktree) {
|
|
||||||
target.latestWorktreeRefreshAtMs = loadedAtMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) {
|
if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) {
|
||||||
target.latestForge = buildForgeUnavailableSnapshot();
|
target.latestForge = buildForgeUnavailableSnapshot();
|
||||||
@@ -3266,9 +3281,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
}
|
}
|
||||||
if (target.selfHealTimer) {
|
if (target.observationReensureTimer) {
|
||||||
clearTimeout(target.selfHealTimer);
|
clearTimeout(target.observationReensureTimer);
|
||||||
target.selfHealTimer = null;
|
target.observationReensureTimer = null;
|
||||||
}
|
}
|
||||||
this.stopForgePrStatusPollForTarget(target);
|
this.stopForgePrStatusPollForTarget(target);
|
||||||
target.listeners.clear();
|
target.listeners.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user