Skip to content

Commit 44e809a

Browse files
IM.codesclaude
andcommitted
fix: add running lock to prevent Gemini tool-gap idle flicker
idleConfirmCount (≥2 polls) wasn't enough — tool call gaps longer than 3s allowed confirm count to reach threshold, triggering idle→running oscillation at ~3s intervals. Added RUNNING_LOCK_MS (5s): after emitting running, block idle transitions for 5 seconds. This covers typical tool-call gaps where Gemini pauses between sequential tool invocations. Strong idle (JSON + terminal both confirm) bypasses the lock via force=true so true completion isn't delayed. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 1c1e3f3 commit 44e809a

1 file changed

Lines changed: 14 additions & 6 deletions

File tree

src/daemon/gemini-watcher.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { updateSessionState } from '../store/session-store.js';
1414
const GEMINI_TMP_DIR = join(homedir(), '.gemini', 'tmp');
1515
const POLL_INTERVAL_MS = 1500; // Balanced: responsive enough without causing state flicker
1616
const IDLE_LOCK_MS = 2000; // After emitting idle, ignore terminal noise for this long
17+
const RUNNING_LOCK_MS = 5000; // After emitting running, don't transition to idle (tool-call gaps)
1718
const RETRY_DELAY_MS = 100;
1819

1920
// ── Path helpers ───────────────────────────────────────────────────────────────
@@ -116,6 +117,8 @@ export interface WatcherState {
116117
idleDebounceTimer?: ReturnType<typeof setTimeout>;
117118
/** Timestamp of last idle emit — used for idle lock (ignore terminal noise shortly after idle). */
118119
lastIdleEmitTs?: number;
120+
/** Timestamp of last running emit — used for running lock (ignore tool-gap idles). */
121+
lastRunningEmitTs?: number;
119122
_lastRotationCheck?: number;
120123
_terminalThinkingEmitted?: boolean;
121124
lastConversationStatus?: 'running' | 'idle' | null;
@@ -147,12 +150,12 @@ async function terminalThinkingCheck(sessionName: string, state: WatcherState):
147150
const status = detectStatus(lines, 'gemini');
148151

149152
if (status === 'idle') {
150-
// Strong idle: both JSON and terminal agree on idle — skip confirm count, transition faster
153+
// Strong idle: both JSON and terminal agree on idle — skip confirm count + running lock
151154
if (state.lastConversationStatus === 'idle') {
152155
state.idleConfirmCount = 2; // force past threshold
153156
if (state.idleDebounceTimer) { clearTimeout(state.idleDebounceTimer); state.idleDebounceTimer = undefined; }
154157
state._terminalThinkingEmitted = false;
155-
transitionState(sessionName, state, 'idle');
158+
transitionState(sessionName, state, 'idle', true); // force bypasses running lock
156159
return;
157160
}
158161
// Terminal shows idle but JSON doesn't — debounce before transitioning
@@ -191,14 +194,19 @@ async function terminalThinkingCheck(sessionName: string, state: WatcherState):
191194

192195
/**
193196
* Unified state transition — all idle/running changes MUST go through here.
194-
* Prevents flicker by deduplicating and enforcing idle lock.
197+
* Prevents flicker by deduplicating and enforcing bidirectional locks.
195198
*/
196-
function transitionState(sessionName: string, state: WatcherState, next: 'running' | 'idle'): void {
199+
function transitionState(sessionName: string, state: WatcherState, next: 'running' | 'idle', force = false): void {
197200
if (state.currentState === next) return; // already in this state
198-
// Idle lock: don't transition to running if we just emitted idle (terminal noise)
199-
if (next === 'running' && state.lastIdleEmitTs && (Date.now() - state.lastIdleEmitTs) < IDLE_LOCK_MS) return;
201+
if (!force) {
202+
// Idle lock: don't transition to running if we just emitted idle (terminal noise)
203+
if (next === 'running' && state.lastIdleEmitTs && (Date.now() - state.lastIdleEmitTs) < IDLE_LOCK_MS) return;
204+
// Running lock: don't transition to idle if we just emitted running (tool-call gaps)
205+
if (next === 'idle' && state.lastRunningEmitTs && (Date.now() - state.lastRunningEmitTs) < RUNNING_LOCK_MS) return;
206+
}
200207
state.currentState = next;
201208
if (next === 'idle') state.lastIdleEmitTs = Date.now();
209+
if (next === 'running') state.lastRunningEmitTs = Date.now();
202210
timelineEmitter.emit(sessionName, 'session.state', { state: next });
203211
updateSessionState(sessionName, next);
204212
}

0 commit comments

Comments
 (0)