From bb568df6805459c22950b7e16747c8dc9f2af936 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Wed, 23 Sep 2026 13:38:54 +0200 Subject: [PATCH 1/4] fix(server): a zero Git fetch interval also stops the fetch on window focus Setting the interval to 0 only gated the periodic poller. An explicit status refresh, which runs on every window focus, on visibility change, and when a mobile client selects a thread, still ran `git fetch` and woke the SSH agent the setting promises to keep quiet. `refreshStatus` now takes the same interval option as `streamStatus` and, at zero, reads the upstream the cache already holds instead of fetching. Both server callers pass the configured interval. Explicit pull, push and fetch are unchanged. --- .../src/vcs/VcsStatusBroadcaster.test.ts | 33 +++++++++++++++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 16 +++++++-- apps/server/src/ws.ts | 6 ++-- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 24a7657d0e27..612946f896d7 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -594,6 +594,39 @@ describe("VcsStatusBroadcaster", () => { }, ); + it.effect( + "an explicit refresh reads the cached upstream when periodic refreshes are disabled", + () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + remoteStatusRefreshUpstreamValues: [] as Array, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + // A window focus or a mobile thread selection with the interval at 0. + yield* broadcaster.refreshStatus("/repo", { + automaticRemoteRefreshInterval: Effect.succeed(Duration.zero), + }); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false]); + assert.equal(state.remoteInvalidationCalls, 1); + + yield* broadcaster.refreshStatus("/repo", { + automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)), + }); + yield* broadcaster.refreshStatus("/repo"); + assert.deepStrictEqual(state.remoteStatusRefreshUpstreamValues, [false, true, true]); + assert.equal(state.remoteStatusCalls, 3); + }).pipe(Effect.provide(makeTestLayer(state))); + }, + ); + it.effect("streams a local snapshot first and remote updates later", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 118e2bd29807..a4d7e0249f2f 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -191,7 +191,10 @@ export class VcsStatusBroadcaster extends Context.Service< readonly refreshLocalStatus: ( cwd: string, ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly refreshStatus: ( + cwd: string, + options?: StreamStatusOptions, + ) => Effect.Effect; /** * Refresh a loaded cwd after a turn if background policy allows it. * GitManager retries missing PRs for the current branch and keeps known @@ -485,8 +488,15 @@ export const make = Effect.gen(function* () { const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { + )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + // A zero fetch interval is the user's promise that nothing fetches on its + // own, and a refresh runs on every window focus, so it reads the upstream + // the cache already has instead of fetching it. + const configuredInterval = yield* ( + options?.automaticRemoteRefreshInterval ?? Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL) + ); + const refreshUpstream = !Duration.isZero(configuredInterval); // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. return yield* withRemoteWriteLock( @@ -494,7 +504,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { yield* workflow.invalidateStatus(cwd); // Local after remote: the fetch can move the base that the Changes totals compare with. - const remote = yield* workflow.remoteStatus({ cwd }); + const remote = yield* workflow.remoteStatus({ cwd }, { refreshUpstream }); const local = yield* workflow.localStatus({ cwd }); const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a0006ddb24b..bf21ed89d3cd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1677,7 +1677,7 @@ const makeWsRpcLayer = ( const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster - .refreshStatus(cwd) + .refreshStatus(cwd, { automaticRemoteRefreshInterval: automaticGitFetchInterval }) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); const getOrchestrationV2ArchivedShellSnapshot = sql @@ -3222,7 +3222,9 @@ const makeWsRpcLayer = ( [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( WS_METHODS.vcsRefreshStatus, - vcsStatusBroadcaster.refreshStatus(input.cwd), + vcsStatusBroadcaster.refreshStatus(input.cwd, { + automaticRemoteRefreshInterval: automaticGitFetchInterval, + }), { "rpc.aggregate": "vcs", }, From 30492c1e0f5489da88d29cdd54add000c357f555 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Wed, 23 Sep 2026 13:44:00 +0200 Subject: [PATCH 2/4] fix(server): skip the automatic pull too when the fetch interval is zero A pull contacts the remote as much as a fetch does, and without a fetch the "behind" count it would act on is stale. A refresh with the interval at 0 now leaves the automatic pull alone as well. --- .../src/vcs/VcsStatusBroadcaster.test.ts | 50 +++++++++++++++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 4 +- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 612946f896d7..535789ca97ee 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -212,6 +212,56 @@ describe("VcsStatusBroadcaster", () => { }, ); + it.effect("does not pull automatically when periodic refreshes are disabled", () => { + let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 }; + let pullCalls = 0; + const localStatus: VcsStatusLocalResult = { + ...baseLocalStatus, + isDefaultRef: true, + refName: "main", + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: () => Effect.succeed(true), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(localStatus), + remoteStatus: () => Effect.succeed(remoteStatus), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + pullCurrentBranch: () => + Effect.sync(() => { + pullCalls += 1; + remoteStatus = { ...remoteStatus, behindCount: 0 }; + return { status: "pulled" as const, refName: "main", upstreamRef: "origin/main" }; + }), + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + // The cached upstream still says "behind", but nothing fetched, so a + // focus refresh with the interval at 0 must not reach for the remote. + const quiet = yield* broadcaster.refreshStatus("/repo", { + automaticRemoteRefreshInterval: Effect.succeed(Duration.zero), + }); + assert.equal(pullCalls, 0); + assert.equal(quiet.behindCount, 2); + + const pulled = yield* broadcaster.refreshStatus("/repo"); + assert.equal(pullCalls, 1); + assert.equal(pulled.behindCount, 0); + }).pipe(Effect.provide(testLayer)); + }); + it.effect("reuses the cached VCS status across repeated reads", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index a4d7e0249f2f..a36421922635 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -506,7 +506,9 @@ export const make = Effect.gen(function* () { // Local after remote: the fetch can move the base that the Changes totals compare with. const remote = yield* workflow.remoteStatus({ cwd }, { refreshUpstream }); const local = yield* workflow.localStatus({ cwd }); - const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); + // An automatic pull contacts the remote too, and without a fetch the + // "behind" count it would act on is stale anyway. + const pulled = refreshUpstream ? yield* maybeAutoPull(cwd, remote, [rawCwd]) : null; if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }), From 252ba70caaff5d7b3239837e6dcb9caba3e19f60 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Sat, 3 Oct 2026 08:10:50 +0200 Subject: [PATCH 3/4] fix(server): worktree handoff refresh respects a zero fetch interval --- .../server/src/mcp/WorktreeMcpService.test.ts | 31 ++++++++++++++++++- apps/server/src/mcp/WorktreeMcpService.ts | 15 ++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 06e997076647..2c11b9c3b796 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -88,6 +89,7 @@ interface HarnessOptions { readonly currentBranch?: string | null; readonly notARepo?: boolean; readonly newWorktreesStartFromOrigin?: boolean; + readonly automaticGitFetchInterval?: Duration.Duration; readonly setupScript?: "started" | "no-script" | "fails" | "dies"; readonly dispatchFails?: boolean; readonly dispatchDies?: boolean; @@ -256,7 +258,9 @@ const makeHarness = (options: HarnessOptions = {}) => { workingTree: { files: [], insertions: 0, deletions: 0 }, }), ); - const refreshStatus = vi.fn((_: string) => Effect.die("refreshStatus stub")); + const refreshStatus = vi.fn< + VcsStatusBroadcaster.VcsStatusBroadcaster["Service"]["refreshStatus"] + >(() => Effect.die("refreshStatus stub")); const runForThread = vi.fn((input: { readonly worktreePath: string }) => { switch (options.setupScript ?? "started") { case "no-script": @@ -313,6 +317,9 @@ const makeHarness = (options: HarnessOptions = {}) => { } satisfies Partial), ServerSettings.layerTest({ newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false, + ...(options.automaticGitFetchInterval === undefined + ? {} + : { automaticGitFetchInterval: options.automaticGitFetchInterval }), }), Layer.mock(GitWorkflowService.GitWorkflowService)({ listRefs, @@ -347,6 +354,7 @@ const makeHarness = (options: HarnessOptions = {}) => { deleteLocalBranch, localStatus, runForThread, + refreshStatus, }; }; @@ -426,6 +434,27 @@ describe("t3_worktree_handoff", () => { }); }); + it.effect("refreshes the new worktree's status with the configured Git fetch interval", () => + Effect.gen(function* () { + for (const [configured, expected] of [ + [Duration.zero, Duration.zero], + [undefined, Duration.seconds(30)], + ] as const) { + const harness = makeHarness( + configured === undefined ? {} : { automaticGitFetchInterval: configured }, + ); + yield* runHandoff(harness, { branch: "feature/refresh" }); + + expect(harness.refreshStatus).toHaveBeenCalledTimes(1); + const [cwd, refreshOptions] = harness.refreshStatus.mock.calls[0]!; + expect(cwd).toBe("/worktrees/project/feature/refresh"); + const interval = refreshOptions?.automaticRemoteRefreshInterval; + if (interval === undefined) return expect.fail("refreshStatus got no fetch interval"); + expect(Duration.toMillis(yield* interval)).toBe(Duration.toMillis(expected)); + } + }), + ); + it.effect("skips the continuation when no continuationPrompt is given", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index fb0bd85d70d3..dcbafe25801a 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -1,5 +1,6 @@ import { CommandId, + DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, MessageId, type ProjectId, WorktreeMcpFailure, @@ -9,6 +10,7 @@ import { type WorktreeMcpSetupScriptStatus, type WorktreeMcpStatusResult, } from "@t3tools/contracts"; +import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -115,6 +117,15 @@ const make = Effect.gen(function* () { asOperationFailed("Unable to read server settings"), ); + // The post-handoff status refresh honors the Git fetch interval, so a zero + // interval keeps it from fetching, like the client's refresh RPC. + const automaticGitFetchInterval = serverSettings.getSettings.pipe( + Effect.map( + (settings) => resolveServerBackgroundActivitySettings(settings).automaticGitFetchInterval, + ), + Effect.orElseSucceed(() => DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL), + ); + const handoffIds = (scope: McpInvocationScope) => crypto.randomUUIDv4.pipe( Effect.map((uuid) => { @@ -379,7 +390,9 @@ const make = Effect.gen(function* () { const continuation = yield* recheckAndBind.pipe(Effect.andThen(queueContinuation)); yield* vcsStatusBroadcaster - .refreshStatus(worktreePath) + .refreshStatus(worktreePath, { + automaticRemoteRefreshInterval: automaticGitFetchInterval, + }) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach); let setupScript: WorktreeMcpSetupScriptStatus = { status: "skipped" }; From ca0188a13c6ddfb88237a72d613250e8d51e6362 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Sat, 3 Oct 2026 08:32:00 +0200 Subject: [PATCH 4/4] fix(server): the initial poll does not auto-pull when the fetch interval is zero --- .../src/vcs/VcsStatusBroadcaster.test.ts | 57 +++++++++++++++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 6 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 535789ca97ee..03a2c5285212 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -262,6 +262,63 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(testLayer)); }); + it.effect("does not pull automatically on the initial poll when the interval is zero", () => { + let pullCalls = 0; + // Settles on the first remote update or pull, whichever the poll reaches. + const settled = Deferred.makeUnsafe(); + const behindRemote: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: () => Effect.succeed(true), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.succeed({ ...baseLocalStatus, isDefaultRef: true, refName: "main" }), + remoteStatus: () => Effect.succeed(behindRemote), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + pullCurrentBranch: () => + Effect.sync(() => { + pullCalls += 1; + return { status: "pulled" as const, refName: "main", upstreamRef: "origin/main" }; + }).pipe(Effect.tap(() => Deferred.succeed(settled, undefined))), + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const scope = yield* Scope.make(); + let remoteUpdated: VcsStatusStreamEvent | undefined; + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + (event) => { + if (event._tag !== "remoteUpdated") return Effect.void; + remoteUpdated = event; + return Deferred.succeed(settled, undefined); + }, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(settled); + assert.equal(pullCalls, 0); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: behindRemote, + } satisfies VcsStatusStreamEvent); + + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(testLayer)); + }); + it.effect("reuses the cached VCS status across repeated reads", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index a36421922635..b66e74b189ac 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -467,7 +467,11 @@ export const make = Effect.gen(function* () { } const previousRemote = (yield* getCachedStatus(cwd))?.remote?.value; const remote = yield* workflow.remoteStatus({ cwd }, options); - const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + // Like refreshStatus: no automatic pull when nothing was fetched. + const pulled = + options?.refreshUpstream === false + ? null + : yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; // Local status holds the Changes totals, which compare against remote refs. A fetch can // move them with no local trigger (a push from a terminal, a PR merged on the host), so