authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-09 11:02:43-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-09 11:02:43-07:00
log2aa73985ea8d13d401ef9063ead4fbb9eaeeab38
tree1db22e81387540d1176db1499b5d36d36fa3c651
parent5b3b31b6e6846eb0509fb2e7c31646ae843c5609
signature Signed by PGP key B5690EEEBB952194

Refresh local git status on turn completion (#1821)

Co-authored-by: codex <codex@users.noreply.github.com>

6 files changed, 131 insertions(+), 4 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+17
......@@ -26,6 +26,7 @@ import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore
2626import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
2727import { GitCoreLive } from "../src/git/Layers/GitCore.ts";
2828import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
29import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts";
2930import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
3031import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
3132import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
......@@ -321,6 +322,22 @@ export const makeOrchestrationIntegrationHarness = (
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
......@@ -143,6 +143,42 @@ describe("GitStatusBroadcasterLive", () => {
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(
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(
300303
301304 return {
302305 getStatus,
306 refreshLocalStatus,
303307 refreshStatus,
304308 streamStatus,
305309 } satisfies GitStatusBroadcasterShape;
apps/server/src/git/Services/GitStatusBroadcaster.ts+4
......@@ -3,6 +3,7 @@ import type { Effect, Stream } from "effect";
33import type {
44 GitManagerServiceError,
55 GitStatusInput,
6 GitStatusLocalResult,
67 GitStatusResult,
78 GitStatusStreamEvent,
89} from "@t3tools/contracts";
......@@ -11,6 +12,9 @@ export interface GitStatusBroadcasterShape {
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
......@@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2020import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
2121import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
2222import { GitCoreLive } from "../../git/Layers/GitCore.ts";
23import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
2324import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2425import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2526import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
......@@ -243,6 +244,7 @@ describe("CheckpointReactor", () => {
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", () => {
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", () => {
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
......@@ -24,6 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
2424import { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2525import { OrchestrationDispatchError } from "../Errors.ts";
2626import { isGitRepository } from "../../git/Utils.ts";
27import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
2728import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts";
2829
2930type ReactorInput =
......@@ -69,6 +70,7 @@ const make = Effect.gen(function* () {
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* () {
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* () {
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({