From 92ad79fd5e39716fd37d420a638d56208295999e Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:10:30 +1100 Subject: [PATCH 1/2] fix(server): a git action that fails partway refreshes the Git panel Co-Authored-By: Claude Opus 5.5 --- apps/server/src/ws.test.ts | 71 ++++++++++++++++++++++++++++++++++++++ apps/server/src/ws.ts | 4 ++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index caa06c2e97a80..d5c0765f5c985 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -3,12 +3,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { AuthSessionId, + AuthSourceControlWriteScope, EnvironmentId, + GitManagerError, ProjectId, ThreadId, ProviderDriverKind, ProviderInstanceId, WS_METHODS, + WsRpcGroup, WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, @@ -58,6 +61,7 @@ import * as ModelManifest from "./provider/ModelManifest.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as OtlpEndpointCheck from "./observability/OtlpEndpointCheck.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; +import * as RpcAuthorization from "./auth/RpcAuthorization.ts"; import * as PeerLinkRequests from "./peer/PeerLinkRequests.ts"; import * as PeerLinks from "./peer/PeerLinks.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -1035,6 +1039,73 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( ), ); +it.effect("a git action that fails partway still refreshes the repository status", () => + Effect.gen(function* () { + const failure = new GitManagerError({ operation: "test", cwd: "/repo", detail: "push failed" }); + const refreshed = yield* Deferred.make(); + const exits = yield* Queue.unbounded(); + const services = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(GitWorkflowService.GitWorkflowService)({ + runStackedAction: () => Effect.fail(failure), + }), + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshStatus: (cwd) => + Deferred.succeed(refreshed, cwd).pipe(Effect.andThen(Effect.fail(failure))), + }), + Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(UsageLimitSources.UsageLimitSources)({}), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/unused" }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + ); + const server = yield* RpcServer.makeNoSerialization(WsRpcGroup, { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Exit" ? Queue.offer(exits, response.exit._tag) : Effect.void, + }).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("git-test"), + subject: "git-test", + method: "browser-session-cookie", + scopes: [AuthSourceControlWriteScope], + }, + {}, + yield* ServerBrowser.ServerBrowser, + () => Effect.void, + ).pipe( + Layer.provide(services), + Layer.merge(RpcAuthorization.layer([AuthSourceControlWriteScope])), + ), + ), + ); + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("git"), + tag: WS_METHODS.gitRunStackedAction, + payload: { actionId: "action", cwd: "/repo", action: "commit_push" }, + headers: Headers.empty, + }); + assert.equal(yield* Queue.take(exits), "Failure"); + assert.equal(yield* Deferred.await(refreshed), "/repo"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + // Compaction keeps the oldest and newest event per message, so a cursor older // than the deleted rows still replays each message where the client would // first have seen it (reducers append on first sight) and reaches the same diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 00842afde41b7..d77622c3d5011 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3031,7 +3031,9 @@ export const layerWsRpc = ( }) .pipe( Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), + // Earlier steps (branch, commit) may have landed before the failing one. + onFailure: (cause) => + refreshGitStatus(input.cwd).pipe(Effect.andThen(Queue.failCause(queue, cause))), onSuccess: (result) => (input.threadId === undefined ? Effect.void From fd6fdc1fe7188fac62fd9c1677e229d6bc660911 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 05:10:55 +1100 Subject: [PATCH 2/2] test(server): assert failed git actions retain their original error --- apps/server/src/ws.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index a0e22725cf205..8b7b251851544 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1536,8 +1536,13 @@ it.effect("a missing thread worktree gets its project's skills in the cwd snapsh it.effect("a git action that fails partway still refreshes the repository status", () => Effect.gen(function* () { const failure = new GitManagerError({ operation: "test", cwd: "/repo", detail: "push failed" }); + const refreshFailure = new GitManagerError({ + operation: "refresh", + cwd: "/repo", + detail: "refresh failed", + }); const refreshed = yield* Deferred.make(); - const exits = yield* Queue.unbounded(); + const exits = yield* Queue.unbounded>(); const services = Layer.mergeAll( layerUnusedConfigRpcServices, Layer.mock(GitWorkflowService.GitWorkflowService)({ @@ -1545,7 +1550,7 @@ it.effect("a git action that fails partway still refreshes the repository status }), Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ refreshStatus: (cwd) => - Deferred.succeed(refreshed, cwd).pipe(Effect.andThen(Effect.fail(failure))), + Deferred.succeed(refreshed, cwd).pipe(Effect.andThen(Effect.fail(refreshFailure))), }), Layer.mock(ExternalLauncher.ExternalLauncher)({}), Layer.mock(ProviderRegistry.ProviderRegistry)({}), @@ -1560,7 +1565,7 @@ it.effect("a git action that fails partway still refreshes the repository status const server = yield* RpcServer.makeNoSerialization(WsRpcGroup, { ...WS_RPC_SERVER_OPTIONS, onFromServer: (response) => - response._tag === "Exit" ? Queue.offer(exits, response.exit._tag) : Effect.void, + response._tag === "Exit" ? Queue.offer(exits, response.exit) : Effect.void, }).pipe( Effect.provide( layerWsRpc( @@ -1587,7 +1592,8 @@ it.effect("a git action that fails partway still refreshes the repository status payload: { actionId: "action", cwd: "/repo", action: "commit_push" }, headers: Headers.empty, }); - assert.equal(yield* Queue.take(exits), "Failure"); + const exit = yield* Queue.take(exits); + assert.deepEqual(Exit.isFailure(exit) ? Cause.squash(exit.cause) : exit, failure); assert.equal(yield* Deferred.await(refreshed), "/repo"); }).pipe( Effect.provide(