Skip to content

Commit 34ec8a8

Browse files
juliusmarmingeJulius Marmingecursoragentcursor[bot]
authored
[codex] add configurable automatic git fetch interval (#2605)
Co-authored-by: Julius Marminge <julius@macmini.local> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
1 parent 932df4e commit 34ec8a8

22 files changed

Lines changed: 517 additions & 106 deletions

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1601,7 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16011601
hasActionableProposedPlan: row.hasActionableProposedPlan > 0,
16021602
}),
16031603
),
1604-
updatedAt: updatedAt ?? new Date(0).toISOString(),
1604+
updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z",
16051605
};
16061606

16071607
return yield* decodeShellSnapshot(snapshot).pipe(

apps/server/src/provider/Layers/ProviderRegistry.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
2727
import { ChildProcessSpawner } from "effect/unstable/process";
2828
import { deepMerge } from "@t3tools/shared/Struct";
2929
import { createModelCapabilities } from "@t3tools/shared/model";
30+
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
3031

3132
import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
3233
import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
@@ -48,6 +49,8 @@ import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.t
4849
import { ProviderRegistry } from "../Services/ProviderRegistry.ts";
4950
import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
5051
const decodeServerSettings = Schema.decodeSync(ServerSettings);
52+
const encodeServerSettings = Schema.encodeSync(ServerSettings);
53+
const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS);
5154

5255
const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({});
5356
const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({});
@@ -256,7 +259,8 @@ function makeMutableServerSettingsService(
256259
updateSettings: (patch) =>
257260
Effect.gen(function* () {
258261
const current = yield* Ref.get(settingsRef);
259-
const next = decodeServerSettings(deepMerge(current, patch));
262+
const next = applyServerSettingsPatch(current, patch);
263+
encodeServerSettings(next);
260264
yield* Ref.set(settingsRef, next);
261265
yield* PubSub.publish(changes, next);
262266
return next;
@@ -930,7 +934,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
930934
const missingBinary = `t3code_codex_missing_`;
931935
const serverSettings = yield* makeMutableServerSettingsService(
932936
decodeServerSettings(
933-
deepMerge(DEFAULT_SERVER_SETTINGS, {
937+
deepMerge(encodedDefaultServerSettings, {
934938
providers: {
935939
// Disable every built-in probe that would otherwise spawn
936940
// on the CI host. `enabled: false` short-circuits each
@@ -1029,7 +1033,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
10291033
const secondMissing = `t3code_codex_second_`;
10301034
const serverSettings = yield* makeMutableServerSettingsService(
10311035
decodeServerSettings(
1032-
deepMerge(DEFAULT_SERVER_SETTINGS, {
1036+
deepMerge(encodedDefaultServerSettings, {
10331037
providers: {
10341038
codex: { enabled: true, binaryPath: firstMissing },
10351039
claudeAgent: { enabled: false },
@@ -1124,7 +1128,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
11241128
Effect.gen(function* () {
11251129
const serverSettings = yield* makeMutableServerSettingsService(
11261130
decodeServerSettings(
1127-
deepMerge(DEFAULT_SERVER_SETTINGS, {
1131+
deepMerge(encodedDefaultServerSettings, {
11281132
providers: {
11291133
codex: { enabled: false },
11301134
claudeAgent: { enabled: false },
@@ -1180,7 +1184,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T
11801184
Effect.gen(function* () {
11811185
const serverSettings = yield* makeMutableServerSettingsService(
11821186
decodeServerSettings(
1183-
deepMerge(DEFAULT_SERVER_SETTINGS, {
1187+
deepMerge(encodedDefaultServerSettings, {
11841188
providers: {
11851189
codex: {
11861190
enabled: false,

apps/server/src/server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,7 @@ const buildAppUnderTest = (options?: {
635635
snapshotSequence: 0,
636636
projects: [],
637637
threads: [],
638-
updatedAt: new Date(0).toISOString(),
638+
updatedAt: "1970-01-01T00:00:00.000Z",
639639
}),
640640
getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
641641
getProjectShellById: () => Effect.succeed(Option.none()),

apps/server/src/serverSettings.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { createModelSelection } from "@t3tools/shared/model";
1010
import { assert, it } from "@effect/vitest";
1111
import * as Effect from "effect/Effect";
12+
import * as Duration from "effect/Duration";
1213
import * as FileSystem from "effect/FileSystem";
1314
import * as Layer from "effect/Layer";
1415
import * as Schema from "effect/Schema";
@@ -437,6 +438,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
437438
serverPassword: "secret-password",
438439
},
439440
},
441+
automaticGitFetchInterval: Duration.seconds(10),
440442
});
441443

442444
assert.equal(next.providers.codex.binaryPath, "/opt/homebrew/bin/codex");
@@ -458,6 +460,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
458460
serverPassword: "secret-password",
459461
},
460462
},
463+
automaticGitFetchInterval: 10_000,
461464
});
462465
}).pipe(Effect.provide(makeServerSettingsLayer())),
463466
);

apps/server/src/serverSettings.ts

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,33 @@ import * as Semaphore from "effect/Semaphore";
4545
import { writeFileStringAtomically } from "./atomicWrite.ts";
4646
import { ServerConfig } from "./config.ts";
4747
import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
48-
import { fromLenientJson } from "@t3tools/shared/schemaJson";
48+
import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
4949
import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
5050
import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
5151
import { ServerSecretStore } from "./auth/Services/ServerSecretStore.ts";
52-
const decodeServerSettings = Schema.decodeEffect(ServerSettings);
52+
53+
const encodeServerSettings = Schema.encodeEffect(ServerSettings);
54+
const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings));
55+
const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);
5356

5457
const textEncoder = new TextEncoder();
5558
const textDecoder = new TextDecoder();
5659

60+
const normalizeServerSettings = (
61+
settings: ServerSettings,
62+
): Effect.Effect<ServerSettings, ServerSettingsError> =>
63+
encodeServerSettings(settings).pipe(
64+
Effect.flatMap(decodeServerSettings),
65+
Effect.mapError(
66+
(cause) =>
67+
new ServerSettingsError({
68+
settingsPath: "<memory>",
69+
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
70+
cause,
71+
}),
72+
),
73+
);
74+
5775
function providerEnvironmentSecretName(input: {
5876
readonly instanceId: string;
5977
readonly name: string;
@@ -117,28 +135,24 @@ export class ServerSettingsService extends Context.Service<
117135
Layer.effect(
118136
ServerSettingsService,
119137
Effect.gen(function* () {
120-
const currentSettingsRef = yield* Ref.make<ServerSettings>(
121-
deepMerge(DEFAULT_SERVER_SETTINGS, overrides),
122-
);
138+
const { automaticGitFetchInterval, ...overridesForMerge } = overrides;
139+
const merged = deepMerge(DEFAULT_SERVER_SETTINGS, overridesForMerge);
140+
const initialSettings = yield* normalizeServerSettings({
141+
...merged,
142+
...(automaticGitFetchInterval !== undefined
143+
? { automaticGitFetchInterval: automaticGitFetchInterval as Duration.Duration }
144+
: {}),
145+
});
146+
const currentSettingsRef = yield* Ref.make<ServerSettings>(initialSettings);
123147

124148
return {
125149
start: Effect.void,
126150
ready: Effect.void,
127151
getSettings: Ref.get(currentSettingsRef),
128152
updateSettings: (patch) =>
129153
Ref.get(currentSettingsRef).pipe(
130-
Effect.flatMap((currentSettings) =>
131-
decodeServerSettings(applyServerSettingsPatch(currentSettings, patch)).pipe(
132-
Effect.mapError(
133-
(cause) =>
134-
new ServerSettingsError({
135-
settingsPath: "<memory>",
136-
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
137-
cause,
138-
}),
139-
),
140-
),
141-
),
154+
Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)),
155+
Effect.flatMap(normalizeServerSettings),
142156
Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)),
143157
),
144158
streamChanges: Stream.empty,
@@ -200,7 +214,10 @@ function fallbackTextGenerationProvider(settings: ServerSettings): ServerSetting
200214
}
201215

202216
// Values under these keys are compared as a whole — never stripped field-by-field.
203-
const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelSelection"]);
217+
const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
218+
"automaticGitFetchInterval",
219+
"textGenerationModelSelection",
220+
]);
204221

205222
function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
206223
if (Array.isArray(current) || Array.isArray(defaults)) {
@@ -430,25 +447,29 @@ const makeServerSettings = Effect.gen(function* () {
430447
};
431448
});
432449

433-
const writeSettingsAtomically = (settings: ServerSettings) => {
434-
const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {};
450+
const writeSettingsAtomically = Effect.fnUntraced(
451+
function* (settings: ServerSettings) {
452+
const sparseSettingsJson = yield* encodeServerSettingsJson(
453+
stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {},
454+
);
435455

436-
return writeFileStringAtomically({
437-
filePath: settingsPath,
438-
contents: `${JSON.stringify(sparseSettings, null, 2)}\n`,
439-
}).pipe(
440-
Effect.provideService(FileSystem.FileSystem, fs),
441-
Effect.provideService(Path.Path, pathService),
442-
Effect.mapError(
443-
(cause) =>
444-
new ServerSettingsError({
445-
settingsPath,
446-
detail: "failed to write settings file",
447-
cause,
448-
}),
449-
),
450-
);
451-
};
456+
return yield* writeFileStringAtomically({
457+
filePath: settingsPath,
458+
contents: `${sparseSettingsJson}\n`,
459+
}).pipe(
460+
Effect.provideService(FileSystem.FileSystem, fs),
461+
Effect.provideService(Path.Path, pathService),
462+
);
463+
},
464+
Effect.mapError(
465+
(cause) =>
466+
new ServerSettingsError({
467+
settingsPath,
468+
detail: "failed to write settings file",
469+
cause,
470+
}),
471+
),
472+
);
452473

453474
const revalidateAndEmit = writeSemaphore.withPermits(1)(
454475
Effect.gen(function* () {
@@ -533,16 +554,7 @@ const makeServerSettings = Effect.gen(function* () {
533554
current,
534555
applyServerSettingsPatch(current, patch),
535556
);
536-
const next = yield* decodeServerSettings(nextPersisted).pipe(
537-
Effect.mapError(
538-
(cause) =>
539-
new ServerSettingsError({
540-
settingsPath: "<memory>",
541-
detail: `failed to normalize server settings: ${SchemaIssue.makeFormatterDefault()(cause.issue)}`,
542-
cause,
543-
}),
544-
),
545-
);
557+
const next = yield* normalizeServerSettings(nextPersisted);
546558
yield* writeSettingsAtomically(next);
547559
yield* Cache.set(settingsCache, cacheKey, next);
548560
yield* emitChange(next);

apps/server/src/vcs/GitVcsDriverCore.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,65 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
130130
}),
131131
);
132132

133+
it.effect("disables SSH askpass for background upstream status fetches", () =>
134+
Effect.gen(function* () {
135+
const cwd = yield* makeTmpDir();
136+
const tempDir = yield* makeTmpDir("git-vcs-driver-ssh-env-");
137+
const { initialBranch } = yield* initRepoWithCommit(cwd);
138+
const fileSystem = yield* FileSystem.FileSystem;
139+
const pathService = yield* Path.Path;
140+
const sshLogPath = pathService.join(tempDir, "ssh-env.txt");
141+
const sshWrapperPath = pathService.join(tempDir, "ssh-wrapper.sh");
142+
const previousGitSsh = process.env.GIT_SSH;
143+
const previousAskpassRequire = process.env.SSH_ASKPASS_REQUIRE;
144+
const previousAskpassLog = process.env.T3_TEST_SSH_ASKPASS_LOG;
145+
146+
yield* fileSystem.writeFileString(
147+
sshWrapperPath,
148+
[
149+
"#!/bin/sh",
150+
'printf "%s\\n" "${SSH_ASKPASS_REQUIRE:-}" > "$T3_TEST_SSH_ASKPASS_LOG"',
151+
"exit 1",
152+
"",
153+
].join("\n"),
154+
);
155+
yield* fileSystem.chmod(sshWrapperPath, 0o755);
156+
yield* git(cwd, ["remote", "add", "origin", "ssh://example.invalid/repo.git"]);
157+
yield* git(cwd, ["update-ref", `refs/remotes/origin/${initialBranch}`, "HEAD"]);
158+
yield* git(cwd, ["branch", "--set-upstream-to", `origin/${initialBranch}`]);
159+
160+
yield* Effect.gen(function* () {
161+
process.env.GIT_SSH = sshWrapperPath;
162+
process.env.SSH_ASKPASS_REQUIRE = "force";
163+
process.env.T3_TEST_SSH_ASKPASS_LOG = sshLogPath;
164+
165+
yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
166+
167+
assert.equal((yield* fileSystem.readFileString(sshLogPath)).trim(), "never");
168+
}).pipe(
169+
Effect.ensuring(
170+
Effect.sync(() => {
171+
if (previousGitSsh === undefined) {
172+
delete process.env.GIT_SSH;
173+
} else {
174+
process.env.GIT_SSH = previousGitSsh;
175+
}
176+
if (previousAskpassRequire === undefined) {
177+
delete process.env.SSH_ASKPASS_REQUIRE;
178+
} else {
179+
process.env.SSH_ASKPASS_REQUIRE = previousAskpassRequire;
180+
}
181+
if (previousAskpassLog === undefined) {
182+
delete process.env.T3_TEST_SSH_ASKPASS_LOG;
183+
} else {
184+
process.env.T3_TEST_SSH_ASKPASS_LOG = previousAskpassLog;
185+
}
186+
}),
187+
),
188+
);
189+
}),
190+
);
191+
133192
it.effect("reuses the no-upstream fallback ahead count for default-branch delta", () =>
134193
Effect.gen(function* () {
135194
const cwd = yield* makeTmpDir();

apps/server/src/vcs/GitVcsDriverCore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
4141
const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
4242
const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
4343
const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
44+
const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
45+
SSH_ASKPASS_REQUIRE: "never",
46+
} satisfies NodeJS.ProcessEnv);
4447
const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
4548
const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
4649
const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({
@@ -72,6 +75,7 @@ interface ExecuteGitOptions {
7275
timeoutMs?: number | undefined;
7376
allowNonZeroExit?: boolean | undefined;
7477
fallbackErrorMessage?: string | undefined;
78+
env?: NodeJS.ProcessEnv | undefined;
7579
maxOutputBytes?: number | undefined;
7680
truncateOutputAtMaxBytes?: boolean | undefined;
7781
progress?: GitVcsDriver.ExecuteGitProgress | undefined;
@@ -738,6 +742,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
738742
cwd,
739743
args,
740744
...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
745+
...(options.env !== undefined ? { env: options.env } : {}),
741746
allowNonZeroExit: true,
742747
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
743748
...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
@@ -870,6 +875,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
870875
["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
871876
{
872877
allowNonZeroExit: true,
878+
env: STATUS_UPSTREAM_REFRESH_ENV,
873879
timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
874880
},
875881
).pipe(Effect.asVoid);

apps/server/src/vcs/VcsStatusBroadcaster.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { assert, it, describe } from "@effect/vitest";
22
import * as NodeServices from "@effect/platform-node/NodeServices";
33
import * as Deferred from "effect/Deferred";
4+
import * as Duration from "effect/Duration";
45
import * as Effect from "effect/Effect";
56
import * as Exit from "effect/Exit";
67
import * as FileSystem from "effect/FileSystem";
@@ -284,6 +285,31 @@ describe("VcsStatusBroadcaster", () => {
284285
}).pipe(Effect.provide(makeTestLayer(state)));
285286
});
286287

288+
it.effect("does not start automatic remote refreshes when disabled", () => {
289+
const state = {
290+
currentLocalStatus: baseLocalStatus,
291+
currentRemoteStatus: baseRemoteStatus,
292+
localStatusCalls: 0,
293+
remoteStatusCalls: 0,
294+
localInvalidationCalls: 0,
295+
remoteInvalidationCalls: 0,
296+
};
297+
298+
return Effect.gen(function* () {
299+
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
300+
const snapshot = yield* Stream.runHead(
301+
broadcaster.streamStatus(
302+
{ cwd: "/repo" },
303+
{ automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) },
304+
),
305+
);
306+
307+
assert.isTrue(Option.isSome(snapshot));
308+
assert.equal(state.remoteStatusCalls, 0);
309+
assert.equal(state.remoteInvalidationCalls, 0);
310+
}).pipe(Effect.provide(makeTestLayer(state)));
311+
});
312+
287313
it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
288314
const state = {
289315
currentLocalStatus: baseLocalStatus,

0 commit comments

Comments
 (0)