Skip to content

Commit 5103bb0

Browse files
committed
🤖 fix: prevent race condition between rename and streaming
Added renamingWorkspaces set to track workspaces being renamed. - sendMessage and resumeStream now check this flag and return error - Both AI-generated rename and user-initiated rename set the flag - Flag is cleared in finally blocks to ensure cleanup This prevents a race where: 1. Stream ends, rename starts 2. Auto-resume or user triggers new stream 3. Stream uses old paths while rename is changing them _Generated with mux_
1 parent f91fa30 commit 5103bb0

File tree

1 file changed

+59
-26
lines changed

1 file changed

+59
-26
lines changed

src/node/services/workspaceService.ts

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export class WorkspaceService extends EventEmitter {
8181
string,
8282
{ chat: () => void; metadata: () => void }
8383
>();
84+
// Tracks workspaces currently being renamed to prevent streaming during rename
85+
private readonly renamingWorkspaces = new Set<string>();
8486

8587
constructor(
8688
private readonly config: Config,
@@ -688,37 +690,44 @@ export class WorkspaceService extends EventEmitter {
688690
// Wait for the stream to complete before renaming (rename is blocked during streaming)
689691
await this.waitForStreamComplete(workspaceId);
690692

691-
// Attempt to rename with collision retry (same logic as workspace creation)
692-
let finalName = aiGeneratedName;
693-
for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) {
694-
const renameResult = await this.rename(workspaceId, finalName);
693+
// Mark workspace as renaming to block new streams during the rename operation
694+
this.renamingWorkspaces.add(workspaceId);
695+
try {
696+
// Attempt to rename with collision retry (same logic as workspace creation)
697+
let finalName = aiGeneratedName;
698+
for (let attempt = 0; attempt <= MAX_WORKSPACE_NAME_COLLISION_RETRIES; attempt++) {
699+
const renameResult = await this.rename(workspaceId, finalName);
700+
701+
if (renameResult.success) {
702+
log.info("Successfully renamed workspace to AI-generated name", {
703+
workspaceId,
704+
oldName: currentName,
705+
newName: finalName,
706+
});
707+
return;
708+
}
695709

696-
if (renameResult.success) {
697-
log.info("Successfully renamed workspace to AI-generated name", {
710+
// If collision and not last attempt, retry with suffix
711+
if (
712+
renameResult.error?.includes("already exists") &&
713+
attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES
714+
) {
715+
log.debug(`Workspace name collision for "${finalName}", retrying with suffix`);
716+
finalName = appendCollisionSuffix(aiGeneratedName);
717+
continue;
718+
}
719+
720+
// Non-collision error or out of retries - keep placeholder name
721+
log.info("Failed to rename workspace to AI-generated name", {
698722
workspaceId,
699-
oldName: currentName,
700-
newName: finalName,
723+
aiGeneratedName: finalName,
724+
error: renameResult.error,
701725
});
702726
return;
703727
}
704-
705-
// If collision and not last attempt, retry with suffix
706-
if (
707-
renameResult.error?.includes("already exists") &&
708-
attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES
709-
) {
710-
log.debug(`Workspace name collision for "${finalName}", retrying with suffix`);
711-
finalName = appendCollisionSuffix(aiGeneratedName);
712-
continue;
713-
}
714-
715-
// Non-collision error or out of retries - keep placeholder name
716-
log.info("Failed to rename workspace to AI-generated name", {
717-
workspaceId,
718-
aiGeneratedName: finalName,
719-
error: renameResult.error,
720-
});
721-
return;
728+
} finally {
729+
// Always clear renaming flag, even on error
730+
this.renamingWorkspaces.delete(workspaceId);
722731
}
723732
} catch (error) {
724733
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -891,6 +900,9 @@ export class WorkspaceService extends EventEmitter {
891900
return Err(validation.error ?? "Invalid workspace name");
892901
}
893902

903+
// Mark workspace as renaming to block new streams during the rename operation
904+
this.renamingWorkspaces.add(workspaceId);
905+
894906
const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId);
895907
if (!metadataResult.success) {
896908
return Err(`Failed to get workspace metadata: ${metadataResult.error}`);
@@ -958,6 +970,9 @@ export class WorkspaceService extends EventEmitter {
958970
} catch (error) {
959971
const message = error instanceof Error ? error.message : String(error);
960972
return Err(`Failed to rename workspace: ${message}`);
973+
} finally {
974+
// Always clear renaming flag, even on error
975+
this.renamingWorkspaces.delete(workspaceId);
961976
}
962977
}
963978

@@ -1102,6 +1117,15 @@ export class WorkspaceService extends EventEmitter {
11021117
});
11031118

11041119
try {
1120+
// Block streaming while workspace is being renamed to prevent path conflicts
1121+
if (this.renamingWorkspaces.has(workspaceId)) {
1122+
log.debug("sendMessage blocked: workspace is being renamed", { workspaceId });
1123+
return Err({
1124+
type: "unknown",
1125+
raw: "Workspace is being renamed. Please wait and try again.",
1126+
});
1127+
}
1128+
11051129
const session = this.getOrCreateSession(workspaceId);
11061130
void this.updateRecencyTimestamp(workspaceId);
11071131

@@ -1144,6 +1168,15 @@ export class WorkspaceService extends EventEmitter {
11441168
options: SendMessageOptions | undefined = { model: "claude-3-5-sonnet-latest" }
11451169
): Promise<Result<void, SendMessageError>> {
11461170
try {
1171+
// Block streaming while workspace is being renamed to prevent path conflicts
1172+
if (this.renamingWorkspaces.has(workspaceId)) {
1173+
log.debug("resumeStream blocked: workspace is being renamed", { workspaceId });
1174+
return Err({
1175+
type: "unknown",
1176+
raw: "Workspace is being renamed. Please wait and try again.",
1177+
});
1178+
}
1179+
11471180
const session = this.getOrCreateSession(workspaceId);
11481181
const result = await session.resumeStream(options);
11491182
if (!result.success) {

0 commit comments

Comments
 (0)