authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 20:53:27-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:53:27-03:00
log5055de3ae483d5ece45695ad682b9766ff0cf423
tree51fdfc811c0dd7aa3760e1373fcc2a09e611881a
parent876f8656e310f365eab98678e68bcc45615fec7f
signature Signed by PGP key B5690EEEBB952194

fix(vcs): passive sidebar rows stop retaining remote pollers (#15666)


11 files changed, 213 insertions(+), 29 deletions(-)

apps/mobile/src/connection/background-activity-scopes.ts+2-1
......@@ -45,7 +45,8 @@ function scopeForSubscription(
4545 if (observation.method !== WS_METHODS.subscribeVcsStatus) {
4646 return null;
4747 }
48 const input = observation.input as { readonly cwd?: unknown };
48 const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown };
49 if (input.includeRemote === false) return null;
4950 return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null;
5051}
5152
apps/mobile/src/connection/background-activity.test.ts+11
......@@ -12,6 +12,12 @@ describe("mobile background activity", () => {
1212 it.effect("retains VCS demand only while the mobile subscription is active", () =>
1313 Effect.gen(function* () {
1414 const environmentId = EnvironmentId.make("mobile-environment");
15 const releasePassive = yield* observeMobileBackgroundActivitySubscription({
16 environmentId,
17 method: WS_METHODS.subscribeVcsStatus,
18 input: { cwd: "/workspace", includeRemote: false },
19 });
20 expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]);
1521 const release = yield* observeMobileBackgroundActivitySubscription({
1622 environmentId,
1723 method: WS_METHODS.subscribeVcsStatus,
......@@ -22,6 +28,11 @@ describe("mobile background activity", () => {
2228 { type: "vcs-status", cwd: "/workspace" },
2329 ]);
2430
31 yield* releasePassive;
32 expect(retainedMobileBackgroundScopes(environmentId)).toEqual([
33 { type: "vcs-status", cwd: "/workspace" },
34 ]);
35
2536 yield* release;
2637 expect(retainedMobileBackgroundScopes(environmentId)).toEqual([]);
2738 }),
apps/server/src/git/GitManager.test.ts+124
......@@ -6,7 +6,10 @@ import * as NodeChildProcess from "node:child_process";
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import { it } from "@effect/vitest";
88import * as Duration from "effect/Duration";
9import * as Context from "effect/Context";
910import * as Effect from "effect/Effect";
11import * as Deferred from "effect/Deferred";
12import * as Exit from "effect/Exit";
1013import * as FileSystem from "effect/FileSystem";
1114import * as Layer from "effect/Layer";
1215import * as Logger from "effect/Logger";
......@@ -16,11 +19,13 @@ import * as References from "effect/References";
1619import * as Result from "effect/Result";
1720import * as Schema from "effect/Schema";
1821import * as Scope from "effect/Scope";
22import * as Stream from "effect/Stream";
1923import { TestClock } from "effect/testing";
2024import { ChildProcessSpawner } from "effect/process";
2125import { expect } from "vite-plus/test";
2226import type {
2327 GitActionProgressEvent,
28 GitManagerServiceError,
2429 GitPreparePullRequestThreadInput,
2530 ModelSelection,
2631} from "@t3tools/contracts";
......@@ -43,6 +48,11 @@ import * as GitLabCli from "../sourceControl/GitLabCli.ts";
4348import * as TextGeneration from "../textGeneration/TextGeneration.ts";
4449import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
4550import * as VcsProcess from "../vcs/VcsProcess.ts";
51import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
52import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
53import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
54import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
55import * as GitWorkflowService from "./GitWorkflowService.ts";
4656import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
4757import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
4858import {
......@@ -778,6 +788,120 @@ const layerGitManagerTest = GitVcsDriver.layer.pipe(
778788);
779789
780790it.layer(layerGitManagerTest)("GitManager", (it) => {
791 it.effect("passive worktree status streams do not start remote refreshes", () =>
792 Effect.gen(function* () {
793 const repoDir = yield* makeTempDir("t3code-passive-vcs-");
794 yield* initRepo(repoDir);
795 const remoteDir = yield* createBareRemote();
796 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
797 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
798 const worktreeDir = NodePath.join(repoDir, "worktree");
799 yield* runGit(repoDir, ["worktree", "add", "-b", "feature/passive", worktreeDir]);
800 yield* runGit(worktreeDir, ["push", "-u", "origin", "feature/passive"]);
801
802 const { manager } = yield* makeManager();
803 let remoteReads = 0;
804 const workflowContext = yield* Layer.build(
805 GitWorkflowService.layer.pipe(
806 Layer.provide(
807 Layer.succeed(GitManager.GitManager, {
808 ...manager,
809 remoteStatus: (input, options) =>
810 manager.remoteStatus(input, options).pipe(
811 Effect.tap(() =>
812 Effect.sync(() => {
813 remoteReads += 1;
814 }),
815 ),
816 ),
817 }),
818 ),
819 Layer.provide(
820 VcsDriverRegistry.layer.pipe(
821 Layer.provide(VcsProjectConfig.layer),
822 Layer.provide(VcsProcess.layer),
823 ),
824 ),
825 ),
826 );
827 const broadcasterContext = yield* Layer.build(
828 VcsStatusBroadcaster.layer.pipe(
829 Layer.provide(
830 Layer.succeed(
831 GitWorkflowService.GitWorkflowService,
832 Context.get(workflowContext, GitWorkflowService.GitWorkflowService),
833 ),
834 ),
835 Layer.provide(
836 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
837 hasDemand: () => Effect.succeed(true),
838 shouldRunScopeWork: () => Effect.succeed(true),
839 }),
840 ),
841 ),
842 );
843 const broadcaster = Context.get(
844 broadcasterContext,
845 VcsStatusBroadcaster.VcsStatusBroadcaster,
846 );
847 const passiveScope = yield* Scope.make();
848 const snapshots = yield* Deferred.make<void, GitManagerServiceError>();
849 const localUpdated = yield* Deferred.make<void>();
850 const remoteUpdated = yield* Deferred.make<void>();
851 let snapshotCount = 0;
852 for (const cwd of [repoDir, worktreeDir]) {
853 yield* Stream.runForEach(
854 broadcaster.streamStatus(
855 { cwd, includeRemote: false },
856 { automaticRemoteRefreshInterval: Effect.succeed(Duration.seconds(1)) },
857 ),
858 (event) => {
859 if (
860 cwd === worktreeDir &&
861 event._tag === "localUpdated" &&
862 event.local.hasWorkingTreeChanges
863 ) {
864 return Deferred.succeed(localUpdated, undefined);
865 }
866 if (cwd === worktreeDir && event._tag === "remoteUpdated") {
867 expect(event.remote?.hasUpstream).toBe(true);
868 return Deferred.succeed(remoteUpdated, undefined);
869 }
870 if (event._tag !== "snapshot") return Effect.void;
871 expect(event.local.isRepo).toBe(true);
872 expect(event.local.refName).toBe(cwd === repoDir ? "main" : "feature/passive");
873 snapshotCount += 1;
874 return snapshotCount === 2 ? Deferred.succeed(snapshots, undefined) : Effect.void;
875 },
876 ).pipe(
877 Effect.catchCause((cause) => Deferred.failCause(snapshots, cause)),
878 Effect.forkIn(passiveScope),
879 );
880 }
881 yield* Deferred.await(snapshots);
882 expect(remoteReads).toBe(0);
883 yield* TestClock.adjust("1 minute");
884 expect(remoteReads).toBe(0);
885
886 const fs = yield* FileSystem.FileSystem;
887 yield* fs.writeFileString(NodePath.join(worktreeDir, "README.md"), "changed\n");
888 yield* broadcaster.refreshLocalStatus(worktreeDir);
889 yield* Deferred.await(localUpdated);
890 expect(remoteReads).toBe(0);
891
892 const activeScope = yield* Scope.make();
893 yield* Stream.runDrain(broadcaster.streamStatus({ cwd: worktreeDir })).pipe(
894 Effect.forkIn(activeScope),
895 );
896 yield* Deferred.await(remoteUpdated);
897 expect(remoteReads).toBe(1);
898 yield* Scope.close(activeScope, Exit.void);
899 yield* TestClock.adjust("1 minute");
900 expect(remoteReads).toBe(1);
901 yield* Scope.close(passiveScope, Exit.void);
902 }),
903 );
904
781905 it.effect("status includes draft PR metadata when branch already has a draft PR", () =>
782906 Effect.gen(function* () {
783907 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+38-13
......@@ -594,7 +594,7 @@ describe("VcsStatusBroadcaster", () => {
594594 },
595595 );
596596
597 it.effect("streams a local snapshot first and remote updates later", () => {
597 it.effect("passive streams retain cached remote status", () => {
598598 const state = {
599599 currentLocalStatus: baseLocalStatus,
600600 currentRemoteStatus: baseRemoteStatus,
......@@ -608,18 +608,25 @@ describe("VcsStatusBroadcaster", () => {
608608 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
609609 const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
610610 const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
611 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => {
612 if (event._tag === "snapshot") {
613 return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
614 }
615 if (event._tag === "remoteUpdated") {
616 return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore);
617 }
618 return Effect.void;
619 }).pipe(Effect.forkScoped);
611 yield* Stream.runForEach(
612 broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }),
613 (event) => {
614 if (event._tag === "snapshot") {
615 return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
616 }
617 if (event._tag === "remoteUpdated") {
618 return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore);
619 }
620 return Effect.void;
621 },
622 ).pipe(Effect.forkScoped);
620623
621624 const snapshot = yield* Deferred.await(snapshotDeferred);
622 yield* broadcaster.refreshStatus("/repo");
625 yield* TestClock.adjust("1 minute");
626 assert.equal(state.remoteStatusCalls, 0);
627 yield* broadcaster.getStatus({ cwd: "/repo" });
628 state.currentRemoteStatus = remoteStatusWithPr;
629 yield* broadcaster.refreshPullRequestStatus("/repo");
623630 const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);
624631
625632 assert.deepStrictEqual(snapshot, {
......@@ -629,8 +636,16 @@ describe("VcsStatusBroadcaster", () => {
629636 } satisfies VcsStatusStreamEvent);
630637 assert.deepStrictEqual(remoteUpdated, {
631638 _tag: "remoteUpdated",
632 remote: baseRemoteStatus,
639 remote: remoteStatusWithPr,
633640 } satisfies VcsStatusStreamEvent);
641 const cachedSnapshot = yield* Stream.runHead(
642 broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }),
643 );
644 assert.deepStrictEqual(Option.getOrThrow(cachedSnapshot), {
645 _tag: "snapshot",
646 local: baseLocalStatus,
647 remote: remoteStatusWithPr,
648 });
634649 }).pipe(Effect.provide(layerTestFor(state)));
635650 });
636651
......@@ -1032,7 +1047,7 @@ describe("VcsStatusBroadcaster", () => {
10321047 }).pipe(Effect.provide(layerTest));
10331048 });
10341049
1035 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
1050 it.effect("releases remote demand while passive observers remain", () => {
10361051 const state = {
10371052 currentLocalStatus: baseLocalStatus,
10381053 currentRemoteStatus: baseRemoteStatus,
......@@ -1092,6 +1107,16 @@ describe("VcsStatusBroadcaster", () => {
10921107 const secondSnapshot = yield* Deferred.make<VcsStatusStreamEvent>();
10931108 const firstScope = yield* Scope.make();
10941109 const secondScope = yield* Scope.make();
1110 const passiveSnapshot = yield* Deferred.make<VcsStatusStreamEvent>();
1111 yield* Stream.runForEach(
1112 broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }),
1113 (event) =>
1114 event._tag === "snapshot"
1115 ? Deferred.succeed(passiveSnapshot, event).pipe(Effect.ignore)
1116 : Effect.void,
1117 ).pipe(Effect.forkScoped);
1118 yield* Deferred.await(passiveSnapshot);
1119 assert.equal(state.remoteStatusCalls, 0);
10951120 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
10961121 event._tag === "snapshot"
10971122 ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore)
apps/server/src/vcs/VcsStatusBroadcaster.ts+15-10
......@@ -19,6 +19,7 @@ import type {
1919 VcsStatusRemoteResult,
2020 VcsStatusResult,
2121 VcsStatusStreamEvent,
22 VcsStatusSubscriptionInput,
2223} from "@t3tools/contracts";
2324import { mergeGitStatusParts } from "@t3tools/shared/git";
2425import * as KeyedLock from "@t3tools/shared/KeyedLock";
......@@ -201,7 +202,7 @@ export class VcsStatusBroadcaster extends Context.Service<
201202 cwd: string,
202203 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
203204 readonly streamStatus: (
204 input: VcsStatusInput,
205 input: VcsStatusSubscriptionInput,
205206 options?: StreamStatusOptions,
206207 ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>;
207208 }
......@@ -708,15 +709,19 @@ export const make = Effect.gen(function* () {
708709 const initialLocal = yield* getOrLoadLocalStatus(cwd);
709710 const cachedStatus = yield* getCachedStatus(cwd);
710711 const initialRemote = cachedStatus?.remote?.value ?? null;
711 yield* retainRemotePoller(
712 cwd,
713 input.cwd,
714 options?.automaticRemoteRefreshInterval ??
715 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
716 cachedStatus?.remote === null || cachedStatus?.remote === undefined,
717 );
718
719 const release = releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid);
712 if (input.includeRemote !== false) {
713 yield* retainRemotePoller(
714 cwd,
715 input.cwd,
716 options?.automaticRemoteRefreshInterval ??
717 Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL),
718 cachedStatus?.remote === null || cachedStatus?.remote === undefined,
719 );
720 }
721 const release =
722 input.includeRemote === false
723 ? Effect.void
724 : releaseRemotePoller(cwd, input.cwd).pipe(Effect.ignore, Effect.asVoid);
720725
721726 return Stream.concat(
722727 Stream.make({
apps/web/src/components/Sidebar.tsx+2-2
......@@ -1209,7 +1209,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
12091209 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
12101210 ? vcsEnvironment.status({
12111211 environmentId: thread.environmentId,
1212 input: { cwd: gitCwd },
1212 input: { cwd: gitCwd, includeRemote: false },
12131213 })
12141214 : null,
12151215 );
......@@ -2204,7 +2204,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
22042204 leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null
22052205 ? vcsEnvironment.status({
22062206 environmentId: thread.environmentId,
2207 input: { cwd: gitCwd },
2207 input: { cwd: gitCwd, includeRemote: false },
22082208 })
22092209 : null,
22102210 );
apps/web/src/components/ThreadStatusIndicators.tsx+1-1
......@@ -910,7 +910,7 @@ export function ThreadRowLeadingStatus({
910910 gitCwd !== null
911911 ? vcsEnvironment.status({
912912 environmentId: thread.environmentId,
913 input: { cwd: gitCwd },
913 input: { cwd: gitCwd, includeRemote: false },
914914 })
915915 : null,
916916 );
apps/web/src/lib/backgroundActivityReporter.test.ts+9
......@@ -22,6 +22,12 @@ describe("wasRecentlyInteracted", () => {
2222 Effect.gen(function* () {
2323 const environmentId = EnvironmentId.make("environment-observation-test");
2424 const scope = { type: "vcs-status" as const, cwd: "/repo" };
25 const releasePassive = yield* observeBackgroundActivitySubscription({
26 environmentId,
27 method: WS_METHODS.subscribeVcsStatus,
28 input: { cwd: scope.cwd, includeRemote: false },
29 });
30 expect(retainedBackgroundScopes(environmentId)).toEqual([]);
2531 const release = yield* observeBackgroundActivitySubscription({
2632 environmentId,
2733 method: WS_METHODS.subscribeVcsStatus,
......@@ -30,6 +36,9 @@ describe("wasRecentlyInteracted", () => {
3036
3137 expect(retainedBackgroundScopes(environmentId)).toEqual([scope]);
3238
39 yield* releasePassive;
40 expect(retainedBackgroundScopes(environmentId)).toEqual([scope]);
41
3342 yield* release;
3443 expect(retainedBackgroundScopes(environmentId)).toEqual([]);
3544 }),
apps/web/src/lib/backgroundActivityReporter.ts+2-1
......@@ -118,7 +118,8 @@ function scopeForSubscription(
118118 if (observation.method !== WS_METHODS.subscribeVcsStatus) {
119119 return null;
120120 }
121 const input = observation.input as { readonly cwd?: unknown };
121 const input = observation.input as { readonly cwd?: unknown; readonly includeRemote?: unknown };
122 if (input.includeRemote === false) return null;
122123 return typeof input.cwd === "string" ? { type: "vcs-status", cwd: input.cwd } : null;
123124}
124125
packages/contracts/src/git.ts+7
......@@ -111,6 +111,13 @@ export const VcsStatusInput = Schema.Struct({
111111});
112112export type VcsStatusInput = typeof VcsStatusInput.Type;
113113
114export const VcsStatusSubscriptionInput = Schema.Struct({
115 ...VcsStatusInput.fields,
116 /** Passive observers receive cached remote status without retaining its refresh loop. */
117 includeRemote: Schema.optional(Schema.Boolean),
118});
119export type VcsStatusSubscriptionInput = typeof VcsStatusSubscriptionInput.Type;
120
114121export const VcsPullInput = Schema.Struct({
115122 cwd: TrimmedNonEmptyStringSchema,
116123});
packages/contracts/src/rpc.ts+2-1
......@@ -121,6 +121,7 @@ import {
121121 GitResolvePullRequestResult,
122122 GitRunStackedActionInput,
123123 VcsStatusInput,
124 VcsStatusSubscriptionInput,
124125 VcsStatusResult,
125126 VcsStatusStreamEvent,
126127} from "./git.ts";
......@@ -1236,7 +1237,7 @@ const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback,
12361237});
12371238
12381239const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
1239 payload: VcsStatusInput,
1240 payload: VcsStatusSubscriptionInput,
12401241 success: VcsStatusStreamEvent,
12411242 error: Schema.Union([GitManagerServiceError, EnvironmentAuthorizationError]),
12421243 stream: true,