From d7b38031af2fb6e0f11df73c821803faea6765a0 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 27 Oct 2026 01:54:31 -0700 Subject: [PATCH] fix: hold every turn during a move, and state the repoint's contract - The chat manager refuses sends, retries and message edits while a move runs, from its first step, handing the text back. The composer was locked but a retry or an edit could still start a turn in the parent. - A move into an existing fork checks again for a running turn after saving that fork's pending edits. - repoint_unresolvable_draft_identities documents that it authorizes nothing and when its unscoped form may be used. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../windmill-api-workspaces/src/workspaces.rs | 5 ++++ .../copilot/chat/AIChatManager.svelte.ts | 23 +++++++++++++++---- .../components/sessions/SessionWrapper.svelte | 6 ++++- .../components/sessions/moveSessionToFork.ts | 1 + 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index aee50e14c9..5dafe574ea 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -8583,6 +8583,11 @@ fn principal_resolves_sql(principal: &str) -> String { /// The draft half of `repoint_unresolvable_cloned_identities`: an app policy or trigger in a draft /// copied into `target_workspace_id` that names nobody there is re-pointed at `authed`. `items` /// narrows it to `authed`'s own rows at those keys, for drafts copied into an existing workspace. +/// +/// **Authorizes nothing itself.** The caller must have checked that `authed` (loaded in +/// `target_workspace_id`) may write those drafts there. `items = None` rewrites every draft in the +/// workspace, other users' included, so it is only for a fork being created inside the same +/// transaction, before anyone else has drafts in it. pub async fn repoint_unresolvable_draft_identities( tx: &mut Transaction<'_, Postgres>, target_workspace_id: &str, diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 71ecd0e0ea..1a6434cd89 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -788,6 +788,9 @@ export class AIChatManager implements ChatViewHost { /** The session is moving into a fork. Its editors stay closed meanwhile: an edit would save a * draft in the workspace being left, after the move listed what it brings along. */ moving = $state(false) + /** A move into a fork has started: no turn starts until it ends, retries and edits included. + * Set before `moving`, which waits for the editors to hand over their pending edits. */ + moveStarted = $state(false) onWorkModeChanged: ((mode: AIWorkMode) => void) | undefined = undefined workModesAvailable = $derived(this.isSessionChat && supportsPlanMode(this.mode)) /** Use is the only mode: the workspace is protected, or the user's role builds nothing in it. */ @@ -3646,7 +3649,9 @@ export class AIChatManager implements ChatViewHost { // false`), and a copy here would double it. Paste tokens are expanded // into the text, as the queue does, because the restore lanes carry no // pastes. - if (this.runHeldElsewhere) { + // A move into a fork holds turns the same way: one started now would act on the + // workspace being left. The composer is locked then, but a retry or an edit is not. + if (this.runHeldElsewhere || this.moveStarted) { if (options.synthetic) { // Client-authored prompt (a job auto-resume), not user input: nothing // to hand back and no toast. Releasing the staged text un-blocks the @@ -3664,7 +3669,12 @@ export class AIChatManager implements ChatViewHost { options.files ) } - sendUserToast('This session is running in another tab. Your message was kept.', true) + sendUserToast( + this.moveStarted + ? 'This session is moving to a fork. Your message was kept.' + : 'This session is running in another tab. Your message was kept.', + true + ) } return false } @@ -4833,7 +4843,7 @@ export class AIChatManager implements ChatViewHost { // nothing yet, so there is nothing to restore. Un-submitted context-chip // edits are the one loss — the chips re-seed from the untouched message // on the next edit. - if (this.runHeldElsewhere) { + if (this.runHeldElsewhere || this.moveStarted) { if (newContent !== undefined) { this.restoreToInput( expanded(chatDraft(newContent, pastes ?? [])), @@ -4842,7 +4852,12 @@ export class AIChatManager implements ChatViewHost { ) } // "Text", not "message": chip edits are the part that does not survive. - sendUserToast('This session is running in another tab. Your text was kept.', true) + sendUserToast( + this.moveStarted + ? 'This session is moving to a fork. Your text was kept.' + : 'This session is running in another tab. Your text was kept.', + true + ) return } diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index a30223d8bc..cb8db6721b 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -540,6 +540,7 @@ return } moving = true + if (runtime) runtime.manager.moveStarted = true const releaseAutoResume = runtime?.manager.holdAutoResume() // Bracketed like a turn, so another tab on this session cannot send while its drafts // move, then re-reads the session (its new workspace, the model note) once it ends. @@ -621,7 +622,10 @@ sendUserToast(`Session moved to ${req.targetName}`) } finally { moving = false - if (runtime) runtime.manager.moving = false + if (runtime) { + runtime.manager.moving = false + runtime.manager.moveStarted = false + } releaseAutoResume?.() if (chatId) localRunEnded(session.id, runtime?.manager.historyManager.getCurrentChatId() ?? chatId) diff --git a/frontend/src/lib/components/sessions/moveSessionToFork.ts b/frontend/src/lib/components/sessions/moveSessionToFork.ts index 64b23218d3..714cf5540c 100644 --- a/frontend/src/lib/components/sessions/moveSessionToFork.ts +++ b/frontend/src/lib/components/sessions/moveSessionToFork.ts @@ -128,6 +128,7 @@ export async function moveSessionToFork( // conflict they are; still parked, they would land on top of the moved draft later. const unsavedThere = await flushPendingSaves(fork.id) if (unsavedThere) return { ok: false, error: unsavedThere, retry: fork } + if (opts.isBusy?.()) return { ok: false, error: busy, retry: fork } } let copied = 0