From 55af555846b573c20782c94b2e012fb4f67d40c7 Mon Sep 17 00:00:00 2001 From: happy5318 Date: Sat, 1 Aug 2026 11:56:26 +0800 Subject: [PATCH 1/3] fix: do not duplicate the active turn's user row on mid-run reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a turn is live the server can reconcile that turn's user row into the transcript returned by /api/session (the sidecar/state.db merge picks it up from state.db, where the agent writes it immediately) while pending_user_message is still set. The row is then followed by the same turn's assistant/tool rows. _pendingCurrentTailUserMessage() scans the tail backwards and returns null as soon as it meets a completed assistant, so in that state it reports "the current turn has no user row". getPendingSessionMessage() then materializes pending_user_message a second time and the pane renders two identical user bubbles until the settle render replaces the list wholesale (which is why the duplicate disappears when the answer lands, and why nothing wrong is ever persisted). Scanning past assistant/tool rows on text alone would regress #3300: a user who submits the same text twice in a row legitimately gets two identical user turns. Use pending_started_at as the discriminator instead — the server stamps the active turn's user row with it, and no earlier turn can share it. Text equality is still required, so a false match needs identical text AND a near-identical timestamp. Absent/invalid pending_started_at disables the fallback and keeps the previous strict-tail behaviour. Verified: the new midRunReloadDedupe assertion fails on the unpatched ui.js and passes with the fix; the existing repeat-prompt, compaction-boundary and strict-tail assertions still pass. --- static/ui.js | 83 ++++++++++++++++++++--- tests/test_run_journal_frontend_static.py | 70 +++++++++++++++++++ 2 files changed, 145 insertions(+), 8 deletions(-) diff --git a/static/ui.js b/static/ui.js index 3b51b5a4..5e45f95f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10365,22 +10365,89 @@ function _pendingCurrentTailUserMessage(messages){ return null; } +// Tolerance when matching a transcript row against session.pending_started_at. +// Some persistence paths stamp the active turn's user row with the exact float +// started_at, others truncate to whole seconds (observed drift up to ~0.4s), and +// state.db round-trips lose sub-microsecond precision. 1.5s absorbs all of those +// while staying far below the gap between two genuinely separate turns. +const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5; + +function _messageTimestampSeconds(msg){ + if(!msg) return null; + const raw=msg._ts!=null?msg._ts:msg.timestamp; + const value=Number(raw); + return Number.isFinite(value)&&value>0?value:null; +} + +/** + * Find the active turn's user row even when the current turn's output already + * follows it in the transcript. + * + * While a turn is live the server can reconcile the current user row into the + * returned transcript (the sidecar/state.db merge picks it up from state.db, + * where the agent writes it immediately) while `pending_user_message` is still + * set. The row is then followed by that same turn's assistant/tool rows, so the + * strict tail scan in `_pendingCurrentTailUserMessage` stops at the completed + * assistant and reports "no current user row" — and the caller materializes the + * pending prompt a SECOND time, rendering a duplicate user bubble until the + * settle render replaces the list. + * + * Scanning past assistant/tool rows alone would be wrong: a user who submits the + * same text twice in a row (a plain "继续" follow-up) legitimately gets two + * identical user turns, and matching on text would swallow the new one. So the + * discriminator here is the TIMESTAMP: the server stamps the active turn's user + * row with `pending_started_at`, which no earlier turn can share. Text equality + * is still required, so a false match needs identical text AND a near-identical + * timestamp. + */ +function _pendingActiveTurnUserMessage(messages, session){ + const startedAt=Number(session?.pending_started_at); + if(!Number.isFinite(startedAt)||startedAt<=0) return null; + const list=Array.isArray(messages)?messages:[]; + for(let i=list.length-1;i>=0;i--){ + const msg=list[i]; + if(!msg||String(msg.role||'')!=='user') continue; + if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(msg)) continue; + const ts=_messageTimestampSeconds(msg); + if(ts===null) continue; + if(Math.abs(ts-startedAt)<=_PENDING_ACTIVE_TURN_TS_TOLERANCE) return msg; + } + return null; +} + function getPendingSessionMessage(session, messagesOverride=null){ const text=String(session?.pending_user_message||'').trim(); if(!text) return null; const attachments=Array.isArray(session?.pending_attachments)?session.pending_attachments.filter(Boolean):[]; const sourceMessages=Array.isArray(messagesOverride)?messagesOverride:session?.messages; const messages=Array.isArray(sourceMessages)?sourceMessages:[]; + const pendingCandidate={role:'user',content:text}; + const _matchesPending=(row)=>{ + if(!row) return false; + return typeof _sameTranscriptMessage==='function' + ? _sameTranscriptMessage(row,pendingCandidate) + : String(msgContent(row)||'').trim()===text; + }; + const _adoptExistingRow=(row)=>{ + if(attachments.length&&!row.attachments?.length) row.attachments=attachments; + return null; + }; const currentTailUser=_pendingCurrentTailUserMessage(messages); if(currentTailUser){ - const pendingCandidate={role:'user',content:text}; - const sameCurrentTurn=typeof _sameTranscriptMessage==='function' - ? _sameTranscriptMessage(currentTailUser,pendingCandidate) - : String(msgContent(currentTailUser)||'').trim()===text; - if(sameCurrentTurn){ - if(attachments.length&&!currentTailUser.attachments?.length) currentTailUser.attachments=attachments; - return null; - } + const sameCurrentTurn=_matchesPending(currentTailUser); + if(sameCurrentTurn) return _adoptExistingRow(currentTailUser); + } + // Fallback: the current turn's user row is already in the transcript but the + // strict tail scan above could not see it because this turn's assistant/tool + // output follows it. Matched by pending_started_at, so previous turns that + // repeat the same text are unaffected. Guarded with typeof so a partial load + // (or a static probe that extracts only some helpers) degrades to the + // original strict-tail behaviour instead of throwing. + const activeTurnUser=typeof _pendingActiveTurnUserMessage==='function' + ? _pendingActiveTurnUserMessage(messages,session) + : null; + if(activeTurnUser&&activeTurnUser!==currentTailUser&&_matchesPending(activeTurnUser)){ + return _adoptExistingRow(activeTurnUser); } return { role:'user', diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index 52a60202..81793bc8 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -24,6 +24,20 @@ def _function_body(src: str, signature: str) -> str: raise AssertionError(f"could not extract function body for {signature!r}") +def _optional_function_body(src: str, signature: str) -> str: + """Extract a helper if present, else return "". + + Used for helpers whose ABSENCE is itself the regression under test: the + probe must still run so the behavioral assertion reports the duplicate, + rather than dying with a substring-not-found extraction error that says + nothing about observable behavior. + """ + try: + return _function_body(src, signature) + except (ValueError, AssertionError): + return "" + + def _run_session_identity_probe() -> dict: prompt = "same submitted prompt\nwith a second line" workspace_prompt = f"[Workspace::v1: /tmp/hermes-webui]\n{prompt}" @@ -197,12 +211,15 @@ def _run_pending_session_message_probe() -> dict: _function_body(SESSIONS_SRC, "function _normalizeUserTranscriptText"), _function_body(SESSIONS_SRC, "function _sameTranscriptMessage"), _function_body(UI_SRC, "function _pendingCurrentTailUserMessage"), + _optional_function_body(UI_SRC, "function _messageTimestampSeconds"), + _optional_function_body(UI_SRC, "function _pendingActiveTurnUserMessage"), _function_body(UI_SRC, "function _isContextCompactionText"), _function_body(UI_SRC, "function _isContextCompactionMessage"), _function_body(UI_SRC, "function getPendingSessionMessage"), ] ) script = f""" +const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5; {helpers} const prompt = {json.dumps(prompt)}; const historical = {{role:'user', content:prompt, _ts:1}}; @@ -270,6 +287,50 @@ const repeatedCompletedPromptCount = repeatedCompletedMessages.filter( ).length; const compactionCurrentTail = _pendingCurrentTailUserMessage([historical, historicalAnswer, currentTailForCompaction, compactionMarker]); +// Mid-run reload: the server already reconciled the active turn's user row into +// the transcript AND this turn's assistant/tool output follows it, while +// pending_user_message is still set. The strict tail scan cannot see the user +// row (it stops at the completed assistant), so without the pending_started_at +// fallback the prompt is materialized a second time -> duplicate user bubble. +const midRunStartedAt = 500; +const midRunTranscript = [ + {{role:'user', content:'earlier question', timestamp:100}}, + {{role:'assistant', content:'earlier answer', timestamp:200}}, + {{role:'user', content:prompt, timestamp:midRunStartedAt}}, + {{role:'assistant', content:'', timestamp:midRunStartedAt+15}}, + {{role:'tool', content:'{{"ok":true}}', timestamp:midRunStartedAt+16}}, + {{role:'assistant', content:'partial answer', timestamp:midRunStartedAt+60}}, +]; +const midRunResult = getPendingSessionMessage( + {{pending_user_message:prompt, pending_started_at:midRunStartedAt}}, + midRunTranscript +); +const midRunWorkspaceTranscript = [ + {{role:'user', content:'earlier question', timestamp:100}}, + {{role:'assistant', content:'earlier answer', timestamp:200}}, + {{role:'user', content:{json.dumps(current_workspace_prompt)}, timestamp:midRunStartedAt}}, + {{role:'assistant', content:'partial answer', timestamp:midRunStartedAt+30}}, +]; +const midRunWorkspaceResult = getPendingSessionMessage( + {{pending_user_message:prompt, pending_started_at:midRunStartedAt}}, + midRunWorkspaceTranscript +); +// A same-text row from an OLDER turn must never be adopted: only the row whose +// timestamp matches pending_started_at identifies the active turn. +const staleSameTextTranscript = [ + {{role:'user', content:prompt, timestamp:midRunStartedAt-600}}, + {{role:'assistant', content:'old answer', timestamp:midRunStartedAt-500}}, +]; +const staleSameTextResult = getPendingSessionMessage( + {{pending_user_message:prompt, pending_started_at:midRunStartedAt}}, + staleSameTextTranscript +); +// Legacy/absent pending_started_at disables the fallback rather than guessing. +const noStartedAtResult = getPendingSessionMessage( + {{pending_user_message:prompt}}, + midRunTranscript +); + process.stdout.write(JSON.stringify({{ historicalSameTextSurvives: !!fromHistoricalSameText && fromHistoricalSameText.content===prompt && fromHistoricalSameText._pending===true, historicalWorkspaceSurvives: !!fromHistoricalWorkspace && fromHistoricalWorkspace.content===prompt && fromHistoricalWorkspace._pending===true, @@ -282,6 +343,10 @@ process.stdout.write(JSON.stringify({{ compactionBoundaryCurrentTail: compactionCurrentTail&&compactionCurrentTail.role==='user'&&compactionCurrentTail.content===prompt, compactionCurrentTailAttachmentsCopied: Array.isArray(currentTailForCompaction.attachments) && currentTailForCompaction.attachments[0].name==='note.txt', repeatedCompletedPromptsRemainValid: repeatedCompletedPromptCount===3, + midRunReloadDedupe: midRunResult===null, + midRunWorkspaceReloadDedupe: midRunWorkspaceResult===null, + staleSameTextStillMaterializes: !!staleSameTextResult && staleSameTextResult._pending===true, + legacyNoStartedAtStillMaterializes: !!noStartedAtResult && noStartedAtResult._pending===true, isContextCompactionText: _isContextCompactionText(compactionMarker.content), isContextCompactionMessage: _isContextCompactionMessage(compactionMarker), }})); @@ -496,6 +561,11 @@ def test_get_pending_session_message_keeps_deferred_repeat_prompt_by_behavior(): assert result["compactionBoundaryCurrentTail"] is True assert result["compactionCurrentTailAttachmentsCopied"] is True assert result["repeatedCompletedPromptsRemainValid"] is True + # #6649 follow-up: mid-run reload must not duplicate the active turn's user row + assert result["midRunReloadDedupe"] is True + assert result["midRunWorkspaceReloadDedupe"] is True + assert result["staleSameTextStillMaterializes"] is True + assert result["legacyNoStartedAtStillMaterializes"] is True assert result["isContextCompactionText"] is True assert result["isContextCompactionMessage"] is True From 5cda58fc9bc586cb817affe0220dc2ebc1a6805b Mon Sep 17 00:00:00 2001 From: happy5318 Date: Mon, 3 Aug 2026 13:46:56 +0800 Subject: [PATCH 2/3] fix: match active turn by exact identity (token/epsilon), not 1.5s proximity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2: the 1.5s timestamp tolerance over-deduped a legitimate rapid repeat — two identical-text turns <1.5s apart collapsed into one, hiding the second turn and copying its attachments onto the earlier row (the mirror risk of #6649). _pendingActiveTurnUserMessage now matches only on unambiguous identity: - the row carries the server-stamped _active_turn_token (stream_id + started_at, per build_active_turn_token), or - its timestamp equals pending_started_at within a precision-only epsilon (1e-6, absorbs float/state.db drift, never a full second). Anything wider (whole-second truncation, ~1s rapid repeat) returns null so getPendingSessionMessage() materializes the pending turn — fail toward the harmless transient duplicate the settle render clears. Regressions added: two completed identical-text turns ~1s apart → second pending row returned + first row's attachments untouched; token-identity row adopted even when its timestamp is outside the epsilon. --- static/ui.js | 61 ++++++++++++++++++----- tests/test_run_journal_frontend_static.py | 37 +++++++++++++- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/static/ui.js b/static/ui.js index 5e45f95f..2c18028c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -10365,12 +10365,16 @@ function _pendingCurrentTailUserMessage(messages){ return null; } -// Tolerance when matching a transcript row against session.pending_started_at. -// Some persistence paths stamp the active turn's user row with the exact float -// started_at, others truncate to whole seconds (observed drift up to ~0.4s), and -// state.db round-trips lose sub-microsecond precision. 1.5s absorbs all of those -// while staying far below the gap between two genuinely separate turns. -const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5; +// Precision-only epsilon when matching a transcript row against +// session.pending_started_at. The server stamps the active turn's user row with +// the exact pending_started_at float; state.db round-trips can lose +// sub-microsecond precision, so 1e-6 absorbs float drift without ever treating a +// whole-second (or sub-second) difference as identity. Anything wider is +// ambiguous — a fast "继续"/"continue" double-send lands ~1s after the previous +// turn — and must NOT match: fail toward materializing the pending turn (a +// harmless transient duplicate the settle render clears) rather than hiding a +// turn and moving its attachments onto an earlier row. +const _PENDING_ACTIVE_TURN_TS_EPSILON=1e-6; function _messageTimestampSeconds(msg){ if(!msg) return null; @@ -10379,6 +10383,26 @@ function _messageTimestampSeconds(msg){ return Number.isFinite(value)&&value>0?value:null; } +/** + * Exact-identity match for the active turn's user row via its + * `_active_turn_token`, mirroring the server's `build_active_turn_token` + * ("{stream_id}:{started_at}") stamped by the eager-checkpoint path. The token + * embeds the stream_id, which no other turn can share, so a row carrying the + * current session's token IS the active turn — no timestamp tolerance needed. + */ +function _activeTurnTokenMatches(msg, session){ + if(!msg||typeof msg._active_turn_token!=='string') return false; + const streamId=session&&session.active_stream_id; + const startedAt=Number(session&&session.pending_started_at); + if(!streamId||!Number.isFinite(startedAt)||startedAt<=0) return false; + const sep=msg._active_turn_token.lastIndexOf(':'); + if(sep<=0) return false; + if(msg._active_turn_token.slice(0,sep).trim()!==String(streamId).trim()) return false; + const tokenStarted=Number(msg._active_turn_token.slice(sep+1)); + return Number.isFinite(tokenStarted)&&tokenStarted>0 + && Math.abs(tokenStarted-startedAt)<=_PENDING_ACTIVE_TURN_TS_EPSILON; +} + /** * Find the active turn's user row even when the current turn's output already * follows it in the transcript. @@ -10394,11 +10418,16 @@ function _messageTimestampSeconds(msg){ * * Scanning past assistant/tool rows alone would be wrong: a user who submits the * same text twice in a row (a plain "继续" follow-up) legitimately gets two - * identical user turns, and matching on text would swallow the new one. So the - * discriminator here is the TIMESTAMP: the server stamps the active turn's user - * row with `pending_started_at`, which no earlier turn can share. Text equality - * is still required, so a false match needs identical text AND a near-identical - * timestamp. + * identical user turns, and matching on text would swallow the new one. The + * discriminator is therefore exact identity, never proximity: the active turn's + * row either carries the server-stamped `_active_turn_token` (stream_id + + * started_at — unique to this turn), or its timestamp equals `pending_started_at` + * within a precision-only epsilon that absorbs float/state.db drift but never a + * full second. A whole-second (or sub-second) mismatch is ambiguous and returns + * null so the caller materializes the pending turn — the transient duplicate is + * harmless, hiding a turn + moving its attachments is not. Text equality is + * still required downstream, so a false match needs identical text AND an exact + * identity signal. */ function _pendingActiveTurnUserMessage(messages, session){ const startedAt=Number(session?.pending_started_at); @@ -10408,10 +10437,18 @@ function _pendingActiveTurnUserMessage(messages, session){ const msg=list[i]; if(!msg||String(msg.role||'')!=='user') continue; if(typeof _isContextCompactionMessage==='function'&&_isContextCompactionMessage(msg)) continue; + // Unambiguous: the row carries the active turn's exact token + // (stream_id + started_at) stamped by the server's eager-checkpoint path. + if(typeof _activeTurnTokenMatches==='function'&&_activeTurnTokenMatches(msg,session)) return msg; + // Unambiguous: the row's timestamp IS pending_started_at within + // precision-only float drift (never a whole second). const ts=_messageTimestampSeconds(msg); if(ts===null) continue; - if(Math.abs(ts-startedAt)<=_PENDING_ACTIVE_TURN_TS_TOLERANCE) return msg; + if(Math.abs(ts-startedAt)<=_PENDING_ACTIVE_TURN_TS_EPSILON) return msg; } + // Any wider drift (whole-second truncation, a rapid repeat ~1s later) is + // ambiguous: return null so getPendingSessionMessage() materializes the + // pending turn rather than guessing. return null; } diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index 81793bc8..80588f3c 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -212,6 +212,7 @@ def _run_pending_session_message_probe() -> dict: _function_body(SESSIONS_SRC, "function _sameTranscriptMessage"), _function_body(UI_SRC, "function _pendingCurrentTailUserMessage"), _optional_function_body(UI_SRC, "function _messageTimestampSeconds"), + _optional_function_body(UI_SRC, "function _activeTurnTokenMatches"), _optional_function_body(UI_SRC, "function _pendingActiveTurnUserMessage"), _function_body(UI_SRC, "function _isContextCompactionText"), _function_body(UI_SRC, "function _isContextCompactionMessage"), @@ -219,7 +220,7 @@ def _run_pending_session_message_probe() -> dict: ] ) script = f""" -const _PENDING_ACTIVE_TURN_TS_TOLERANCE=1.5; +const _PENDING_ACTIVE_TURN_TS_EPSILON=1e-6; {helpers} const prompt = {json.dumps(prompt)}; const historical = {{role:'user', content:prompt, _ts:1}}; @@ -331,6 +332,32 @@ const noStartedAtResult = getPendingSessionMessage( midRunTranscript ); +// Regression (review round 2): two completed identical-text turns whose +// started_at differ by ~1s. The old 1.5s tolerance adopted the EARLIER row as +// the "active turn", hiding the second (still-pending) turn and copying its +// attachments onto the old row. With the precision-only epsilon the second turn +// is materialized and the first row stays untouched. +const rapidRepeatTurnOne = {{role:'user', content:prompt, timestamp:100}}; +const rapidRepeatAnswerOne = {{role:'assistant', content:'done', timestamp:101}}; +const rapidRepeatSecondAttachments = [{{name:'second.txt', path:'second.txt', mime:'text/plain'}}]; +const rapidRepeatResult = getPendingSessionMessage( + {{pending_user_message:prompt, pending_started_at:101, pending_attachments:rapidRepeatSecondAttachments}}, + [rapidRepeatTurnOne, rapidRepeatAnswerOne] +); +// Exact token identity: the eager-checkpoint row carries _active_turn_token +// built from the same stream_id + started_at as the session — adopted even +// though its timestamp (499.9) is NOT within the epsilon of pending_started_at. +const tokenIdentityTranscript = [ + {{role:'user', content:'earlier question', timestamp:100}}, + {{role:'assistant', content:'earlier answer', timestamp:200}}, + {{role:'user', content:prompt, timestamp:499.9, _active_turn_token:'stream-abc:500'}}, + {{role:'assistant', content:'partial', timestamp:560}}, +]; +const tokenIdentityResult = getPendingSessionMessage( + {{pending_user_message:prompt, pending_started_at:midRunStartedAt, active_stream_id:'stream-abc'}}, + tokenIdentityTranscript +); + process.stdout.write(JSON.stringify({{ historicalSameTextSurvives: !!fromHistoricalSameText && fromHistoricalSameText.content===prompt && fromHistoricalSameText._pending===true, historicalWorkspaceSurvives: !!fromHistoricalWorkspace && fromHistoricalWorkspace.content===prompt && fromHistoricalWorkspace._pending===true, @@ -347,6 +374,9 @@ process.stdout.write(JSON.stringify({{ midRunWorkspaceReloadDedupe: midRunWorkspaceResult===null, staleSameTextStillMaterializes: !!staleSameTextResult && staleSameTextResult._pending===true, legacyNoStartedAtStillMaterializes: !!noStartedAtResult && noStartedAtResult._pending===true, + rapidRepeatSecondTurnReturned: !!rapidRepeatResult && rapidRepeatResult._pending===true && rapidRepeatResult.content===prompt && Array.isArray(rapidRepeatResult.attachments) && rapidRepeatResult.attachments[0].name==='second.txt', + rapidRepeatFirstRowKeptClean: !Array.isArray(rapidRepeatTurnOne.attachments), + tokenIdentityDedupe: tokenIdentityResult===null, isContextCompactionText: _isContextCompactionText(compactionMarker.content), isContextCompactionMessage: _isContextCompactionMessage(compactionMarker), }})); @@ -566,6 +596,11 @@ def test_get_pending_session_message_keeps_deferred_repeat_prompt_by_behavior(): assert result["midRunWorkspaceReloadDedupe"] is True assert result["staleSameTextStillMaterializes"] is True assert result["legacyNoStartedAtStillMaterializes"] is True + # #6670 review round 2: ~1s-apart identical turns must not be collapsed by + # the active-turn fallback (no over-dedup, no attachment mis-attachment). + assert result["rapidRepeatSecondTurnReturned"] is True + assert result["rapidRepeatFirstRowKeptClean"] is True + assert result["tokenIdentityDedupe"] is True assert result["isContextCompactionText"] is True assert result["isContextCompactionMessage"] is True From 84ace4120614653668eac486c6a366c71e8c8b0d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 6 Aug 2026 00:27:33 +0000 Subject: [PATCH 3/3] changelog: #6670 don't duplicate active turn user row on mid-run reload --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a20c830..70e04dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ ### Fixed +- **A mid-run reload no longer briefly shows the active turn's prompt twice.** While a turn is live the server can reconcile the current user row into the returned transcript (via the state.db merge) while `pending_user_message` is still set, and that row is then followed by the same turn's assistant/tool output — so the strict tail scan reported "no current user row" and the client materialized the pending prompt a second time, showing a duplicate user bubble until the settle render. The client now also matches the active turn's row by exact identity — the server-stamped `_active_turn_token` (`stream_id:started_at`) or a timestamp equal to `pending_started_at` within a precision-only epsilon — and adopts it instead of re-rendering. Matching is identity-only, never proximity: a rapid same-text repeat (e.g. sending "continue" twice ~1s apart) is treated as ambiguous and still materializes as its own turn, so a legitimate repeated prompt is never swallowed. Thanks @happy5318. (#6670) + - **"Force recovery" now repairs a dirty Stable checkout that has no promoted release ref.** On the Stable channel a WebUI checkout with local modifications but no promoted ref to move to previously reported "already up to date" and did nothing, leaving the working tree stuck. Force recovery now detects a *confirmed* dirty tree (strict `git diff-index` exit-1 only — a timeout or probe failure is treated as unknown and never triggers a destructive reset) and discards the local changes by resetting to the current `HEAD`, so the commit itself never moves and the channel is unchanged. Clean or unknown checkouts stay a no-op, the ancestor-rewind guard is preserved, and ignored build/cache artifacts survive (no `git clean -x`); a `git clean -fd` failure (e.g. a Windows reserved-name file, #4914) stays non-fatal. Thanks @rodboev. (#6688) - **OIDC-only deployments get a clear startup warning for ambiguous whitespace `allow_values`.** When `webui_oidc.allow_values` (or `HERMES_WEBUI_OIDC_ALLOW_VALUES`) was given as a scalar with internal whitespace like `"alice@example.com bob@example.com"`, whitespace is not a separator so the whole string is treated as one allow-list entry — silently locking out the intended users. Startup (and OIDC resolution) now emit a warning naming the ambiguity and pointing to the comma-delimited scalar or YAML-array forms, while a genuinely intentional multi-word group is called out as already correct. Parsing and enforcement are byte-for-byte unchanged — this is a diagnostic only, it never broadens authorization. Thanks @rodboev. (#6663, #6603)