fix(web): stop sending prompts into a busy turn on the web UI#1522
Conversation
…ooking idle - Guard startSessionAndSendPrompt with a per-workspace reentry lock so a double-click / repeated Enter during draft-session creation cannot fire two concurrent first prompts into the same new session. - Track goal.active in the agent event projector so turn.ended between goal-driven continuation turns keeps the session 'running' instead of projecting a false 'idle' that drains the local queue into a still-busy core (turn.agent_busy). - Show a 'starting conversation…' loading state on the empty-session landing while the first prompt is being created and submitted. - Persist the resolved model in startSessionAndActivateSkill so the first skill turn on a fresh session does not fail with 'Model not set'.
🦋 Changeset detectedLatest commit: 37d33cf The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 80e11842c8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| :models="models" | ||
| :starred-ids="starredIds" | ||
| :skills="skills" | ||
| :starting="starting" |
There was a problem hiding this comment.
Pass the starting guard through the dock composer
This only disables the centered empty Composer. During startSessionAndSendPrompt, createDraftSession calls selectSession, which sets activeSessionId while it is still awaiting the snapshot, before the original prompt is submitted; at that point this pane switches to ChatDock, whose Composer is not given starting. On a slow snapshot, a user can type/submit from the dock, sendPrompt sees no inFlightPromptSessions yet, and then the original submitPromptInternal runs too, recreating the concurrent first-turn POST this change is meant to prevent.
Useful? React with 👍 / 👎.
| : s.goalActive | ||
| ? 'running' | ||
| : 'idle'; |
There was a problem hiding this comment.
Emit idle when a goal blocks after turn end
In goal mode, driveGoal can emit turn.ended while the goal is still active and only then call markBlocked (for example a UserPromptSubmit hook block at packages/agent-core/src/agent/turn/index.ts:449-451, or the post-turn budget check at :464-466). This branch projects that completed turn.ended as running; the later goal.updated only clears goalActive/goal UI and never emits an idle status, so onSessionIdle is not called and sendingBySession, inFlightPromptSessions, and queued prompts remain stuck for that session.
Useful? React with 👍 / 👎.
commit: |
- Pass the starting guard through the dock composer: draft-session creation selects the new session before submit, which swaps the empty composer for the dock; disabling both composers closes the last path to a concurrent first POST. Also take the workspace lock in startSessionAndActivateSkill / startSessionAndOpenSideChat. - Emit the owed idle when a goal settles (blocked/paused/completed) in the inter-turn gap after a turn.ended was projected as 'running', so sending state, in-flight flags and queued prompts flush instead of the session staying 'running' forever.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a22d1faad3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (wasActive && !s.goalActive && s.idleOwed) { | ||
| s.idleOwed = false; | ||
| out.push({ | ||
| type: 'sessionStatusChanged', | ||
| sessionId, | ||
| status: 'idle', | ||
| previousStatus: 'running', | ||
| }); |
There was a problem hiding this comment.
Clear idle debt when a new goal turn starts
When a goal has already crossed at least one active turn boundary, turn.ended leaves s.idleOwed = true. Because that flag is not cleared on the next turn.started, a later goal.updated emitted by UpdateGoal('complete'|'blocked') during that next turn satisfies this branch and projects idle before the following turn.ended; onSessionIdle can then drain queued prompts while driveGoal still has activeTurn set, reproducing turn.agent_busy for multi-turn goals. Clear the debt when a new turn starts or otherwise gate this branch on actually being between turns.
Useful? React with 👍 / 👎.
The idle debt from a 'running' projection survived turn.started, so an
UpdateGoal('complete'|'blocked') landing mid-turn in the NEXT goal turn
synthesized an early idle. onSessionIdle could then drain queued prompts
into a core that was mid-turn again, re-opening the turn.agent_busy race
for multi-turn goals. Clear the debt on turn.started: from that point the
turn's own turn.ended carries the idle with goalActive already false.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3979f42471
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // turn.ended knows whether the core is merely between continuation | ||
| // turns (goal still driving) or truly idle. | ||
| const wasActive = s.goalActive; | ||
| s.goalActive = goal?.status === 'active'; |
There was a problem hiding this comment.
Preserve goal-active state across snapshots
When a running goal session is rebuilt from a snapshot (reopen/select, resync, or stale-WS recovery), seedInFlight() resets the projector state and then subscribes at snapshot.asOfSeq, so the earlier goal.updated(active) frame is not replayed. Because this live goal.updated path is the only place that restores goalActive, the next turn.ended after a snapshot is projected as idle even though the core is still between goal continuation turns, allowing onSessionIdle to drain queued prompts back into turn.agent_busy. Seed this flag from the snapshot/current goal state, or otherwise replay/include the active goal state before live turn events resume.
Useful? React with 👍 / 👎.
| const isStartingFirstPrompt = computed<boolean>(() => | ||
| workspaceState.isStartingFirstPrompt(rawState.activeWorkspaceId), | ||
| ); |
There was a problem hiding this comment.
Keep the first-prompt lock after workspace id changes
When createDraftSession() starts from a derived workspace, it adds the lock under the original workspace id, then addWorkspace()/selectWorkspace(session.workspaceId ...) can replace rawState.activeWorkspaceId with the registered id before submitPromptInternal() starts. This computed then returns false while the first prompt is still in the create/select window, so the composer can re-enable and a second send uses the new workspace/session id, bypassing startingFirstPromptWorkspaces and reopening the duplicate first-submit race. Lock the registered id as well or compute this state from the in-flight lock independent of the current raw workspace id.
Useful? React with 👍 / 👎.
isStartingFirstPrompt now reads from the lock set directly (size > 0) instead of the current activeWorkspaceId. createDraftSession can swap activeWorkspaceId to a registered id mid-flight; a workspace-keyed read would then return false while the first prompt is still in the create/ select/submit window, re-enabling the composer and reopening the duplicate first-submit race.
|
@codex please re-review the latest state. The earlier review threads should be addressed:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a0fd3cd0b2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| : s.goalActive | ||
| ? 'running' | ||
| : 'idle'; |
There was a problem hiding this comment.
Seed goal state for snapshot-seeded turns
When the projector is reset from a session snapshot while an active goal turn is already in flight (seedSnapshot → seedInFlight calls reset, and the snapshot payload only carries in_flight_turn, not the current goal), s.goalActive remains false because the earlier goal.updated(active) is before the subscription cursor. The first live turn.ended for that goal then falls through to idle here, so onSessionIdle can drain queued prompts into the still-running goal driver and reintroduce turn.agent_busy after a reload/reopen mid-goal; seed this state from the snapshot or current goal before using it to mark the session idle.
Useful? React with 👍 / 👎.
The goalActive / idleOwed shadow state machine grew through multiple review rounds and still leaves edge cases (snapshot-seeded turns, mid- turn goal updates). Roll it back to the simple 'turn.ended projects idle' behavior. Goal-driven sessions can once again race a queued prompt into a busy core; this is accepted as a known limitation to be resolved properly in a follow-up that has the core emit an authoritative idle signal.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: acf74ccbca
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| web: Fix an occasional "another turn is active" error when sending the first message of a conversation or while a goal is driving follow-up turns. |
There was a problem hiding this comment.
Add the missing goal-aware busy-state guard
This release note still is not true for the goal-continuation case: I checked apps/kimi-web/src/api/daemon/agentEventProjector.ts and turn.ended still projects every nonfailed turn to idle, and apps/kimi-web/src/composables/useKimiWebClient.ts still drains the queued prompt immediately from onSessionIdle. When a goal keeps the core activeTurn alive across follow-up turns, a prompt queued or typed after that turn.ended can still be POSTed into the busy turn and hit turn.agent_busy; please include the goal-aware status/queue guard before shipping this fix.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f366a41ac5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| : rawState.defaultModel) ?? undefined; | ||
| await persistSessionProfile( | ||
| { | ||
| model, |
There was a problem hiding this comment.
Fix the failing profile-patch test expectation
When running the existing useWorkspaceState — startSessionAndActivateSkill test, the mocked upsertSessionFront does not add sess_new and defaultModel is null, so this newly added field is passed as { model: undefined, ... } to persistSessionProfile; the assertion at apps/kimi-web/test/workspace-state.test.ts:640 still expects an object without model, which will fail the web test suite. Update the test expectation to include the resolved/undefined model field or adjust the setup to reflect the new behavior.
Useful? React with 👍 / 👎.
|
Codex Review: Didn't find any major issues. Hooray! Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
Related Issue
No linked issue — the problem is explained below.
Problem
On the web UI, users occasionally hit
Cannot launch a new turn while another turn (ID 0) is active(turn.agent_busy). Two web-side paths could deliver a prompt to the core while itsactiveTurnwas still set:Double-submit of the first message. Sending the first message from the empty-session composer awaits draft-session creation (
addWorkspace→createSession→selectSession) before the session id exists, so the per-session in-flight guard could not cover that window. A double-click / repeated Enter fired two concurrent first POSTs into the same new session; the daemon accepted both and the core rejected the second.Goal drives look idle between continuation turns. The core intentionally keeps
activeTurnset across goal-driven continuation turns, but the web event projector mapped everyturn.endedtoidle.onSessionIdlethen cleared the spinner and drained the local queue into the still-busy core — matching the reported repro ("the reply finished, the moon spinner was about to clear, I sent a message, and got the error").Additionally: sending the first message appeared to "hang" with no feedback while the session was being created; and activating a skill as the first action of a new session failed with
Model not setbecause the resolved model was never persisted to the session profile before the skill turn started.What changed
All changes are in
apps/kimi-web:useWorkspaceState.ts):startSessionAndSendPromptnow takes a per-workspace lock covering draft-session creation through prompt submission, making the duplicate-submit window unhittable.agentEventProjector.ts): the projector tracksgoal.updatedand projectsturn.endedasrunning(instead ofidle) while a goal is still active, so the queue only drains when the core is truly idle. Added unit tests for the active/completed/cancelled cases.ConversationPane.vue,Composer.vue, i18n): the empty-session landing shows an immediate starting state (spinner title, disabled textarea, spinner send button) while the first prompt is in flight, so users no longer double-click out of "did it register?" uncertainty.useWorkspaceState.ts):startSessionAndActivateSkillnow includes the resolved model in the session profile patch, fixing the first skill turn failing withModel not set.The daemon-side
PromptServicecheck-then-set race remains as hardening for non-web clients and can be addressed separately; web no longer triggers it.Checklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update. (Bug fixes and small UX state changes only; no new docs needed.)