History

Refresh local git status on turn completion (#1821)

Julius Marminge committed 7 months ago

Commit 2aa73981 parent 5b3b31b6 files · +131 −4Browse files

Unified · Split

6 files
  1. apps/server/integration/OrchestrationEngineHarness.integration.ts +17 −0
  2. apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +36 −0
  3. apps/server/src/git/Layers/GitStatusBroadcaster.ts +8 −4
  4. apps/server/src/git/Services/GitStatusBroadcaster.ts +4 −0
  5. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +43 −0
  6. apps/server/src/orchestration/Layers/CheckpointReactor.ts +23 −0
apps/server/integration/OrchestrationEngineHarness.integration.ts+17−0
@@ -26,6 +26,7 @@ import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore · 25 unmodified lines
2626 import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
2727 import { GitCoreLive } from "../src/git/Layers/GitCore.ts";
2828 import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
29+import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts";
2930 import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
3031 import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
3132 import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
@@ -321,6 +322,22 @@ export const makeOrchestrationIntegrationHarness = ( · 289 unmodified lines
321322 );
322323 const checkpointReactorLayer = CheckpointReactorLive.pipe(
323324 Layer.provideMerge(runtimeServicesLayer),
325+ Layer.provideMerge(
326+ Layer.succeed(GitStatusBroadcaster, {
327+ getStatus: () => Effect.die("getStatus should not be called in this test"),
328+ refreshLocalStatus: () =>
329+ Effect.succeed({
330+ isRepo: true,
331+ hasOriginRemote: false,
332+ isDefaultBranch: true,
333+ branch: "main",
334+ hasWorkingTreeChanges: false,
335+ workingTree: { files: [], insertions: 0, deletions: 0 },
336+ }),
337+ refreshStatus: () => Effect.die("refreshStatus should not be called in this test"),
338+ streamStatus: () => Stream.empty,
339+ }),
340+ ),
324341 Layer.provideMerge(
325342 WorkspaceEntriesLive.pipe(
326343 Layer.provide(WorkspacePathsLive),
apps/server/src/git/Layers/GitStatusBroadcaster.test.ts+36−0
@@ -143,6 +143,42 @@ describe("GitStatusBroadcasterLive", () => { · 142 unmodified lines
143143 }).pipe(Effect.provide(makeTestLayer(state)));
144144 });
145145
146+ it.effect("refreshes only the cached local snapshot when requested", () => {
147+ const state = {
148+ currentLocalStatus: baseLocalStatus,
149+ currentRemoteStatus: baseRemoteStatus,
150+ localStatusCalls: 0,
151+ remoteStatusCalls: 0,
152+ localInvalidationCalls: 0,
153+ remoteInvalidationCalls: 0,
154+ };
155+
156+ return Effect.gen(function* () {
157+ const broadcaster = yield* GitStatusBroadcaster;
158+ const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
159+
160+ state.currentLocalStatus = {
161+ ...baseLocalStatus,
162+ branch: "feature/local-only-refresh",
163+ hasWorkingTreeChanges: true,
164+ };
165+
166+ const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo");
167+ const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
168+
169+ assert.deepStrictEqual(initial, baseStatus);
170+ assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus);
171+ assert.deepStrictEqual(cached, {
172+ ...state.currentLocalStatus,
173+ ...baseRemoteStatus,
174+ });
175+ assert.equal(state.localStatusCalls, 2);
176+ assert.equal(state.remoteStatusCalls, 1);
177+ assert.equal(state.localInvalidationCalls, 1);
178+ assert.equal(state.remoteInvalidationCalls, 0);
179+ }).pipe(Effect.provide(makeTestLayer(state)));
180+ });
181+
146182 it.effect("streams a local snapshot first and remote updates later", () => {
147183 const state = {
148184 currentLocalStatus: baseLocalStatus,
apps/server/src/git/Layers/GitStatusBroadcaster.ts+8−4
@@ -179,10 +179,13 @@ export const GitStatusBroadcasterLive = Layer.effect( · 178 unmodified lines
179179 return mergeGitStatusParts(local, remote);
180180 });
181181
182− const refreshLocalStatus = Effect.fn("refreshLocalStatus")(function* (cwd: string) {
183− yield* gitManager.invalidateLocalStatus(cwd);
184− const local = yield* gitManager.localStatus({ cwd });
185− return yield* updateCachedLocalStatus(cwd, local, { publish: true });
182+ const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn(
183+ "refreshLocalStatus",
184+ )(function* (cwd) {
185+ const normalizedCwd = normalizeCwd(cwd);
186+ yield* gitManager.invalidateLocalStatus(normalizedCwd);
187+ const local = yield* gitManager.localStatus({ cwd: normalizedCwd });
188+ return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true });
186189 });
187190
188191 const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) {
@@ -300,6 +303,7 @@ export const GitStatusBroadcasterLive = Layer.effect( · 111 unmodified lines
300303
301304 return {
302305 getStatus,
306+ refreshLocalStatus,
303307 refreshStatus,
304308 streamStatus,
305309 } satisfies GitStatusBroadcasterShape;
apps/server/src/git/Services/GitStatusBroadcaster.ts+4−0
@@ -3,6 +3,7 @@ import type { Effect, Stream } from "effect"; · 2 unmodified lines
33 import type {
44 GitManagerServiceError,
55 GitStatusInput,
6+ GitStatusLocalResult,
67 GitStatusResult,
78 GitStatusStreamEvent,
89 } from "@t3tools/contracts";
@@ -11,6 +12,9 @@ export interface GitStatusBroadcasterShape { · 2 unmodified lines
1112 readonly getStatus: (
1213 input: GitStatusInput,
1314 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
15+ readonly refreshLocalStatus: (
16+ cwd: string,
17+ ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>;
1418 readonly refreshStatus: (cwd: string) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
1519 readonly streamStatus: (
1620 input: GitStatusInput,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+43−0
@@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines
2020 import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
2121 import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
2222 import { GitCoreLive } from "../../git/Layers/GitCore.ts";
23+import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
2324 import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2425 import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2526 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
@@ -243,6 +244,7 @@ describe("CheckpointReactor", () => { · 217 unmodified lines
243244 readonly threadWorktreePath?: string | null;
244245 readonly providerSessionCwd?: string;
245246 readonly providerName?: ProviderKind;
247+ readonly gitStatusRefreshCalls?: Array<string>;
246248 }) {
247249 const cwd = createGitRepository();
248250 tempDirs.push(cwd);
@@ -264,11 +266,30 @@ describe("CheckpointReactor", () => { · 15 unmodified lines
264266 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
265267 prefix: "t3-checkpoint-reactor-test-",
266268 });
269+ const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, {
270+ getStatus: () => Effect.die("getStatus should not be called in this test"),
271+ refreshLocalStatus: (cwd: string) =>
272+ Effect.sync(() => {
273+ options?.gitStatusRefreshCalls?.push(cwd);
274+ }).pipe(
275+ Effect.as({
276+ isRepo: true,
277+ hasOriginRemote: false,
278+ isDefaultBranch: true,
279+ branch: "main",
280+ hasWorkingTreeChanges: false,
281+ workingTree: { files: [], insertions: 0, deletions: 0 },
282+ }),
283+ ),
284+ refreshStatus: () => Effect.die("refreshStatus should not be called in this test"),
285+ streamStatus: () => Stream.empty,
286+ });
267287
268288 const layer = CheckpointReactorLive.pipe(
269289 Layer.provideMerge(orchestrationLayer),
270290 Layer.provideMerge(RuntimeReceiptBusLive),
271291 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
292+ Layer.provideMerge(gitStatusBroadcasterLayer),
272293 Layer.provideMerge(CheckpointStoreLive),
273294 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
274295 Layer.provideMerge(WorkspacePathsLive),
@@ -426,6 +447,28 @@ describe("CheckpointReactor", () => { · 151 unmodified lines
426447 ).toBe("v2\n");
427448 });
428449
450+ it("refreshes local git status state on turn completion using the session cwd", async () => {
451+ const gitStatusRefreshCalls: string[] = [];
452+ const harness = await createHarness({
453+ seedFilesystemCheckpoints: false,
454+ gitStatusRefreshCalls,
455+ });
456+
457+ harness.provider.emit({
458+ type: "turn.completed",
459+ eventId: EventId.makeUnsafe("evt-turn-completed-refresh-local-status"),
460+ provider: "codex",
461+ createdAt: new Date().toISOString(),
462+ threadId: ThreadId.makeUnsafe("thread-1"),
463+ turnId: asTurnId("turn-refresh-local-status"),
464+ payload: { state: "completed" },
465+ });
466+
467+ await harness.drain();
468+
469+ expect(gitStatusRefreshCalls).toEqual([harness.cwd]);
470+ });
471+
429472 it("ignores auxiliary thread turn completion while primary turn is active", async () => {
430473 const harness = await createHarness({ seedFilesystemCheckpoints: false });
431474 const createdAt = new Date().toISOString();
apps/server/src/orchestration/Layers/CheckpointReactor.ts+23−0
@@ -24,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; · 23 unmodified lines
2424 import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2525 import { OrchestrationDispatchError } from "../Errors.ts";
2626 import { isGitRepository } from "../../git/Utils.ts";
27+import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
2728 import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts";
2829
2930 type ReactorInput =
@@ -69,6 +70,7 @@ const make = Effect.gen(function* () { · 39 unmodified lines
6970 const checkpointStore = yield* CheckpointStore;
7071 const receiptBus = yield* RuntimeReceiptBus;
7172 const workspaceEntries = yield* WorkspaceEntries;
73+ const gitStatusBroadcaster = yield* GitStatusBroadcaster;
7274
7375 const appendRevertFailureActivity = (input: {
7476 readonly threadId: ThreadId;
@@ -498,6 +500,26 @@ const make = Effect.gen(function* () { · 423 unmodified lines
498500 },
499501 );
500502
503+ const refreshLocalGitStatusFromTurnCompletion = Effect.fn(
504+ "refreshLocalGitStatusFromTurnCompletion",
505+ )(function* (event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>) {
506+ const sessionRuntime = yield* resolveSessionRuntimeForThread(event.threadId);
507+ if (Option.isNone(sessionRuntime)) {
508+ return;
509+ }
510+
511+ yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe(
512+ Effect.catch((error) =>
513+ Effect.logWarning("failed to refresh local git status after turn completion", {
514+ threadId: event.threadId,
515+ turnId: event.turnId ?? null,
516+ cwd: sessionRuntime.value.cwd,
517+ detail: error.message,
518+ }),
519+ ),
520+ );
521+ });
522+
501523 const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn(
502524 "ensurePreTurnBaselineFromDomainTurnStart",
503525 )(function* (
@@ -736,6 +758,7 @@ const make = Effect.gen(function* () { · 232 unmodified lines
736758
737759 if (event.type === "turn.completed") {
738760 const turnId = toTurnId(event.turnId);
761+ yield* refreshLocalGitStatusFromTurnCompletion(event);
739762 yield* captureCheckpointFromTurnCompletion(event).pipe(
740763 Effect.catch((error) =>
741764 appendCaptureFailureActivity({