diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f739c98da29..6e946d0f895 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -70,6 +70,54 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; + +const COMMIT_SIGNING_FAILURE_PATTERNS = [ + /gpg(?:2)?(?:\.exe)?: .*failed to sign/i, + /gpg failed to sign the data/i, + /signing failed:/i, + /failed to sign the data/i, + /pinentry.*(?:failed|error|not found|no such file|cancell?ed)/i, + /(?:failed|error|no such file|cancell?ed).*pinentry/i, + /inappropriate ioctl for device/i, + /cannot open \/dev\/tty/i, + /no secret key/i, + /secret key not available/i, + /ssh-keygen(?:\.exe)?:?.*(?:failed|error|couldn['’]t).*sign/i, + /couldn['’]t sign (?:message|data)/i, + /couldn['’]t load public key/i, + /no private key found for public key/i, + /load key .*: (?:invalid format|no such file or directory|permission denied)/i, + /agent refused operation/i, +] as const; + +export function isCommitSigningFailureStderr(stderr: string): boolean { + return COMMIT_SIGNING_FAILURE_PATTERNS.some((pattern) => pattern.test(stderr)); +} + +/** Longer than any real git error line, short enough to keep logs readable. */ +const GIT_STDERR_LOG_LIMIT = 2000; + +/** + * Strip credentials from git output so it can be logged. + * + * git echoes the remote URL it used, and those URLs routinely carry secrets + * (`https://x-access-token:TOKEN@github.com/...`), so raw stderr must never + * reach a log. Redacts the userinfo component of any URL plus bare tokens that + * commonly appear on their own. + */ +export function redactGitOutput(stderr: string): string { + return ( + stderr + .slice(0, GIT_STDERR_LOG_LIMIT) + .replace(/([a-zA-Z][\w+.-]*:\/\/)[^/@\s]*@/g, "$1@") + .replace(/\b(gh[pousr]_|github_pat_|glpat-)[A-Za-z0-9_-]+/g, "$1") + // Take the whole value, not just the scheme word: `Authorization: Bearer X` + // must not redact `Bearer` and leave `X` behind. + .replace(/\b(Authorization)\s*[:=]\s*.*/gi, "$1: ") + .replace(/\b(Bearer|token)\s*[:=]?\s+\S+/gi, "$1 ") + ); +} + const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, @@ -379,6 +427,7 @@ function gitCommandContext( command: "git", cwd: input.cwd, argumentCount: input.args.length, + failureKind: "unknown" as const, } as const; } @@ -1737,25 +1786,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - const args = ["commit", "-m", subject]; + const args = ["commit"]; + if (options?.disableSigning) { + args.push("--no-gpg-sign"); + } + args.push("-m", subject); const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } - const progress = - options?.progress?.onOutputLine === undefined - ? options?.progress - : { - ...options.progress, + let hookFailed = false; + const progress: GitVcsDriver.ExecuteGitProgress = { + ...(options?.progress?.onOutputLine + ? { onStdoutLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, - }; - yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + } + : {}), + ...(options?.progress?.onHookStarted + ? { onHookStarted: options.progress.onHookStarted } + : {}), + onHookFinished: (input) => { + if (input.exitCode !== null && input.exitCode !== 0) { + hookFailed = true; + } + return options?.progress?.onHookFinished?.(input) ?? Effect.void; + }, + }; + const result = yield* executeGitWithStableDiagnostics("GitVcsDriver.commit.commit", cwd, args, { + allowNonZeroExit: true, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(progress ? { progress } : {}), - }).pipe(Effect.asVoid); + progress, + }); + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.commit.commit", cwd, args }), + detail: "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + ...(!options?.disableSigning && !hookFailed && isCommitSigningFailureStderr(result.stderr) + ? { failureKind: "commit_signing_failed" as const } + : {}), + }); + } const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD", @@ -2107,6 +2183,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* operation: "GitVcsDriver.getReviewDiffPreview.hash", command: "crypto.digest SHA-256", cwd: input.cwd, + failureKind: "unknown", detail: "Failed to hash review diff.", cause, }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7d3d70d4275..72e48e6e0c1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -48,7 +48,6 @@ import { ProjectWriteFileError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, - OrchestrationReplayEventsError, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -62,7 +61,6 @@ import { WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; -import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -105,7 +103,6 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; -import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; @@ -356,7 +353,6 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getThreadActivities, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], @@ -507,8 +503,6 @@ const makeWsRpcLayer = ( const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const repositoryIdentityResolver = - yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery; @@ -672,53 +666,6 @@ const makeWsRpcLayer = ( }); }; - const enrichProjectEvent = ( - event: OrchestrationEvent, - ): Effect.Effect => { - switch (event.type) { - case "project.created": - return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe( - Effect.map((repositoryIdentity) => ({ - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - })), - ); - case "project.meta-updated": - return Effect.gen(function* () { - const workspaceRoot = - event.payload.workspaceRoot ?? - Option.match( - yield* projectionSnapshotQuery.getProjectShellById(event.payload.projectId), - { - onNone: () => null, - onSome: (project) => project.workspaceRoot, - }, - ) ?? - null; - if (workspaceRoot === null) { - return event; - } - - const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot); - return { - ...event, - payload: { - ...event.payload, - repositoryIdentity, - }, - } satisfies OrchestrationEvent; - }).pipe(Effect.orElseSucceed(() => event)); - default: - return Effect.succeed(event); - } - }; - - const enrichOrchestrationEvents = (events: ReadonlyArray) => - Effect.forEach(events, enrichProjectEvent, { concurrency: 4 }); - const toShellStreamEvent = ( event: OrchestrationEvent, ): Effect.Effect, never, never> => { @@ -1418,30 +1365,6 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "orchestration" }, ), - [ORCHESTRATION_WS_METHODS.replayEvents]: (input) => - observeRpcEffect( - ORCHESTRATION_WS_METHODS.replayEvents, - Stream.runCollect( - orchestrationEngine.readEvents( - clamp(input.fromSequenceExclusive, { - maximum: Number.MAX_SAFE_INTEGER, - minimum: 0, - }), - ), - ).pipe( - Effect.map((events) => Array.from(events)), - Effect.flatMap(enrichOrchestrationEvents), - Effect.map((events) => events.map(projectActivityEvent)), - Effect.mapError( - (cause) => - new OrchestrationReplayEventsError({ - message: "Failed to replay orchestration events", - cause, - }), - ), - ), - { "rpc.aggregate": "orchestration" }, - ), [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) => observeRpcStreamEffect( ORCHESTRATION_WS_METHODS.subscribeShell, diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 76336f1ef1f..ab198db86d2 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -212,6 +212,19 @@ describe("resolveBranchTriggerLabel", () => { ).toBe("From main"); }); + it("shows the bare branch name when reusing the selected worktree base branch", () => { + expect( + resolveBranchTriggerLabel({ + activeWorktreePath: null, + effectiveEnvMode: "worktree", + resolvedActiveBranch: "main", + resolvedActiveBranchIsRemote: false, + startFromOrigin: true, + reuseBaseBranch: true, + }), + ).toBe("main"); + }); + it("does not duplicate the origin prefix for an explicit remote ref", () => { expect( resolveBranchTriggerLabel({ diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index cebb685d0a9..443e56674a7 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -173,6 +173,7 @@ export function resolveBranchTriggerLabel(input: { resolvedActiveBranch: string | null; resolvedActiveBranchIsRemote: boolean | null; startFromOrigin: boolean; + reuseBaseBranch?: boolean; }): string { const { activeWorktreePath, @@ -180,11 +181,17 @@ export function resolveBranchTriggerLabel(input: { resolvedActiveBranch, resolvedActiveBranchIsRemote, startFromOrigin, + reuseBaseBranch = false, } = input; if (!resolvedActiveBranch) { return "Select ref"; } + // Reused base branch is checked out as-is (Tim #15); otherwise "From X" for + // new worktree branches, with optional origin/ prefix (upstream #4680). if (effectiveEnvMode === "worktree" && !activeWorktreePath) { + if (reuseBaseBranch) { + return resolvedActiveBranch; + } const baseRef = startFromOrigin && resolvedActiveBranchIsRemote === false ? `origin/${resolvedActiveBranch}` diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9b4cbf2b4a4..4bb674212db 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -75,6 +75,8 @@ interface BranchToolbarBranchSelectorProps { onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + reuseBaseBranch: boolean; + onReuseBaseBranchChange: (reuseBaseBranch: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -94,10 +96,13 @@ export function BranchToolbarBranchSelector({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, + reuseBaseBranch, + onReuseBaseBranchChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const startFromOriginSwitchId = useId(); + const reuseBaseBranchSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -599,6 +604,7 @@ export function BranchToolbarBranchSelector({ resolvedActiveBranch, resolvedActiveBranchIsRemote, startFromOrigin, + reuseBaseBranch, }); // PR pill shown next to the branch selector when the active branch has one. @@ -810,32 +816,64 @@ export function BranchToolbarBranchSelector({ {isSelectingWorktreeBase ? ( - - - - - onStartFromOriginChange(Boolean(checked))} - /> - - } - /> - - Creates the worktree from the latest matching branch on origin instead of your local - branch. - - +
+ + + + + onReuseBaseBranchChange(Boolean(checked))} + /> + + } + /> + + Checks out the selected branch in the worktree instead of creating a new branch + from it. + + + + + + + onStartFromOriginChange(Boolean(checked))} + /> + + } + /> + + Creates the worktree from the latest matching branch on origin instead of your + local branch. + + +
) : null} {branchStatusText ? {branchStatusText} : null} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 281a71f9a12..9ab53c5e129 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -70,6 +70,38 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; +export function filterBrowseEntries(input: { + browseEntries: ReadonlyArray; + browseFilterQuery: string; + highlightedItemValue: string | null; +}): { + filteredEntries: FilesystemBrowseEntry[]; + highlightedEntry: FilesystemBrowseEntry | null; + exactEntry: FilesystemBrowseEntry | null; +} { + const lowerFilter = input.browseFilterQuery.toLowerCase(); + const showHidden = input.browseFilterQuery.startsWith("."); + + const filteredEntries = input.browseEntries.filter( + (entry) => + entry.name.toLowerCase().startsWith(lowerFilter) && + (showHidden || !entry.name.startsWith(".")), + ); + + let highlightedEntry: FilesystemBrowseEntry | null = null; + if (input.highlightedItemValue?.startsWith("browse:")) { + const highlightedPath = input.highlightedItemValue.slice("browse:".length); + highlightedEntry = filteredEntries.find((entry) => entry.fullPath === highlightedPath) ?? null; + } + + const exactEntry = + input.browseFilterQuery.length > 0 + ? (filteredEntries.find((entry) => entry.name === input.browseFilterQuery) ?? null) + : null; + + return { filteredEntries, highlightedEntry, exactEntry }; +} + export function normalizeSearchText(value: string): string { return value.trim().toLowerCase().replace(/\s+/g, " "); } diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index a0d4510be7f..ac85762a543 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -22,7 +22,11 @@ import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; -import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +import { + vcsCommandConcurrency, + vcsCommandScheduler, + vcsThreadCommandConcurrency, +} from "./vcsCommandScheduler.ts"; import { invalidateCachedVcsRefs, vcsRefsCacheStateAtom, @@ -311,6 +315,18 @@ export function createVcsEnvironmentAtoms( concurrency: vcsCommandConcurrency, onSettled: invalidateRefs, }), + previewWorktreeCleanup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:preview-worktree-cleanup", + tag: WS_METHODS.vcsPreviewWorktreeCleanup, + scheduler: vcsCommandScheduler, + concurrency: vcsThreadCommandConcurrency, + }), + cleanupThreadWorktree: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:cleanup-thread-worktree", + tag: WS_METHODS.vcsCleanupThreadWorktree, + scheduler: vcsCommandScheduler, + concurrency: vcsThreadCommandConcurrency, + }), createRef: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-ref", tag: WS_METHODS.vcsCreateRef, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index b936246dc82..8007ecddf2c 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -34,6 +34,7 @@ import { createVcsActionTransportId, EMPTY_VCS_ACTION_STATE, getVcsActionTargetKey, + isCommitSigningFailure, normalizeVcsActionProgressEvent, parseVcsActionTargetKey, VcsActionMissingTerminalEventError, @@ -258,6 +259,7 @@ describe("vcsActionState", () => { kind: "action_failed", phase: null, message: "Push failed.", + failureKind: "unknown", }), ); @@ -398,6 +400,7 @@ describe("vcsActionState", () => { kind: "action_failed", phase: "push", message: remoteMessage, + failureKind: "unknown", }, ]), { @@ -417,6 +420,7 @@ describe("vcsActionState", () => { environmentId, cwd, phase: "push", + failureKind: "unknown", remoteMessageLength: remoteMessage.length, }); expect(error).not.toHaveProperty("detail"); @@ -425,6 +429,37 @@ describe("vcsActionState", () => { }), ); + it.effect("prefers a classified terminal failure over a following stream error", () => + Effect.gen(function* () { + const target = { environmentId, cwd }; + const transportActionId = createVcsActionTransportId(target, actionId); + const transportError = new Error("rpc stream closed"); + const stream = Stream.fromIterable([ + { + actionId: transportActionId, + action, + cwd, + kind: "action_failed", + phase: "commit", + message: "Remote diagnostic that must stay sanitized.", + failureKind: "commit_signing_failed", + }, + ]).pipe(Stream.concat(Stream.fail(transportError))); + + const error = yield* consumeVcsActionProgress(stream, { + target, + transportActionId, + actionId, + action, + onProgress: () => Effect.void, + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsActionRemoteFailureError); + expect(error).toMatchObject({ failureKind: "commit_signing_failed" }); + expect(isCommitSigningFailure(error)).toBe(true); + }), + ); + it.effect("reports a missing terminal event as a protocol failure", () => Effect.gen(function* () { const target = { environmentId, cwd }; @@ -608,6 +643,7 @@ describe("vcsActionState", () => { action, phase: "push", message: "push failed after creating the branch", + failureKind: "unknown", }), ), } as unknown as WsRpcProtocolClient; diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index f0c3791e35b..e0cbf7338f4 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -2,6 +2,7 @@ import { EnvironmentId, type EnvironmentId as EnvironmentIdType, GitActionProgressPhase, + GitActionFailureKind, type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, @@ -75,6 +76,7 @@ export interface RunVcsStackedActionInput { readonly action: GitStackedAction; readonly commitMessage?: string; readonly featureBranch?: boolean; + readonly disableCommitSigning?: boolean; readonly filePaths?: ReadonlyArray; readonly onProgress?: (event: GitActionProgressEvent) => void; } @@ -101,6 +103,7 @@ export class VcsActionRemoteFailureError extends Schema.TaggedErrorClass({ isRunning: false, operation: null, @@ -265,6 +276,19 @@ export function consumeVcsActionProgress( ): Effect.Effect { return Effect.suspend(() => { let terminalEvent: GitActionProgressEvent | null = null; + const remoteFailure = ( + event: Extract, + ): VcsActionRemoteFailureError => + new VcsActionRemoteFailureError({ + actionId: input.actionId, + transportActionId: input.transportActionId, + action: event.action, + environmentId: input.target.environmentId, + cwd: input.target.cwd, + phase: event.phase, + failureKind: event.failureKind, + remoteMessageLength: event.message.length, + }); return stream.pipe( Stream.runForEach((event) => { const normalized = normalizeVcsActionProgressEvent( @@ -281,22 +305,18 @@ export function consumeVcsActionProgress( } return input.onProgress(normalized); }), + Effect.catch((error) => { + const terminal = terminalEvent; + const failure: E | VcsActionRemoteFailureError = + terminal?.kind === "action_failed" ? remoteFailure(terminal) : error; + return Effect.fail(failure); + }), Effect.flatMap(() => { if (terminalEvent?.kind === "action_finished") { return Effect.succeed(terminalEvent.result); } if (terminalEvent?.kind === "action_failed") { - return Effect.fail( - new VcsActionRemoteFailureError({ - actionId: input.actionId, - transportActionId: input.transportActionId, - action: terminalEvent.action, - environmentId: input.target.environmentId, - cwd: input.target.cwd, - phase: terminalEvent.phase, - remoteMessageLength: terminalEvent.message.length, - }), - ); + return Effect.fail(remoteFailure(terminalEvent)); } return Effect.fail( new VcsActionMissingTerminalEventError({ @@ -462,6 +482,7 @@ export function createVcsActionManager( action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), + ...(input.disableCommitSigning ? { disableCommitSigning: true } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), }; return consumeVcsActionProgress( diff --git a/packages/client-runtime/src/state/vcsCommandScheduler.ts b/packages/client-runtime/src/state/vcsCommandScheduler.ts index a11b157bb2d..d7b508709b3 100644 --- a/packages/client-runtime/src/state/vcsCommandScheduler.ts +++ b/packages/client-runtime/src/state/vcsCommandScheduler.ts @@ -11,3 +11,11 @@ export const vcsCommandConcurrency: AtomCommandConcurrency<{ mode: "serial", key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), }; + +export const vcsThreadCommandConcurrency: AtomCommandConcurrency<{ + readonly environmentId: EnvironmentId; + readonly input: { readonly threadId: string }; +}> = { + mode: "serial", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId]), +};