Skip to content
Merged
2 changes: 2 additions & 0 deletions src/Tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import type { FileStateCache } from './utils/fileStateCache.js'
import type { DenialTrackingState } from './utils/permissions/denialTracking.js'
import type { SystemPrompt } from './utils/systemPromptType.js'
import type { ContentReplacementState } from './utils/toolResultStorage.js'
import type { QueryLifecycleOperationTracker } from './utils/queryLifecycle.js'

// Re-export progress types for backwards compatibility
export type {
Expand Down Expand Up @@ -291,6 +292,7 @@ export type ToolUseContext = {
toolInputSummary?: string | null,
) => (request: PromptRequest) => Promise<PromptResponse>
toolUseId?: string
queryLifecycle?: QueryLifecycleOperationTracker
criticalSystemReminder_EXPERIMENTAL?: string
/** When true, preserve toolUseResult on messages even for subagents.
* Used by in-process teammates whose transcripts are viewable by the user. */
Expand Down
1 change: 1 addition & 0 deletions src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,7 @@ async function* queryLoop(
c => c.type === 'pending',
),
queryTracking,
queryLifecycle: toolUseContext.queryLifecycle,
effortValue: appState.effortValue,
advisorModel: appState.advisorModel,
skipCacheWrite,
Expand Down
114 changes: 104 additions & 10 deletions src/screens/REPL.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getP
import { asSessionId, asAgentId } from '../types/ids.js';
import { logForDebugging } from '../utils/debug.js';
import { QueryGuard } from '../utils/QueryGuard.js';
import { QueryLifecycleOperationTracker, formatQueryLifecycleAbortSignalReason, formatQueryLifecycleLogMessage, type QueryActiveOperationSnapshot, type QueryGuardTimeoutInfo, type QueryLifecycleContext, type QueryTerminalReason } from '../utils/queryLifecycle.js';
import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js';
import { isEnvTruthy } from '../utils/envUtils.js';
import { formatTokens, truncateToWidth } from '../utils/format.js';
Expand Down Expand Up @@ -551,6 +552,34 @@ function _temp2(setFrame_0) {
function _temp(f) {
return (f + 1) % TITLE_ANIMATION_FRAMES.length;
}
function getAbortReasonLabel(reason: unknown): string | undefined {
if (reason === undefined) return undefined;
if (typeof reason === 'string') return reason;
if (reason instanceof Error) return reason.name;
return String(reason);
}
function getQueryTerminalReason(signal: AbortSignal, didThrow: boolean): QueryTerminalReason {
if (!signal.aborted) return didThrow ? 'unknown' : 'ok';
switch (getAbortReasonLabel(signal.reason)) {
case 'query-timeout':
return 'query-timeout';
case 'user-cancel':
case 'interrupt':
return 'user-abort';
case 'background':
return 'parent-ended';
default:
return 'unknown';
}
}
function summarizeActiveOperations(snapshot: QueryActiveOperationSnapshot): string {
const apiIds = snapshot.apiCalls.map(call => call.requestId ?? call.clientRequestId ?? 'unknown').join(',');
const toolIds = snapshot.toolUses.map(tool => `${tool.toolName}:${tool.toolUseId}`).join(',');
return `activeApiCalls=${snapshot.apiCalls.length} activeToolUses=${snapshot.toolUses.length}` + (apiIds ? ` apiIds=${apiIds}` : '') + (toolIds ? ` toolIds=${toolIds}` : '');
}
function logQueryLifecycle(event: string, context: QueryLifecycleContext, extras = ''): void {
logForDebugging(formatQueryLifecycleLogMessage(event, context, extras));
}
export type Props = {
commands: Command[];
debug: boolean;
Expand Down Expand Up @@ -1449,6 +1478,7 @@ export function REPL({
}, [setLocalCommands]);
const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState<Set<string>>(new Set());
const hasInterruptibleToolInProgressRef = useRef(false);
const queryLifecycleTrackerRef = useRef(new QueryLifecycleOperationTracker());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

// Remote session hook - manages WebSocket connection and message handling for --remote mode
const remoteSession = useRemoteSession({
Expand Down Expand Up @@ -1726,18 +1756,26 @@ export function REPL({
const mrOnBeforeQuery = useCallback(async (_input: string, _allMessages: MessageType[], _newMessageCount: number) => true, []);
const mrOnTurnComplete = useCallback(async (_allMessages: MessageType[], _aborted: boolean) => { }, []);
const mrRender = useCallback(() => null, []);
const abortTimedOutQuery = useCallback(() => {
const abortTimedOutQuery = useCallback((timeout: QueryGuardTimeoutInfo) => {
const timeoutOperations = summarizeActiveOperations(timeout.activeOperations);
logQueryLifecycle('timeout', timeout.context, timeoutOperations);
const activeAbortController = abortControllerRef.current;
if (activeAbortController && !activeAbortController.signal.aborted) {
logQueryLifecycle('abort_requested', timeout.context, formatQueryLifecycleAbortSignalReason('query-timeout'));
activeAbortController.abort('query-timeout');
}
if (timeout.activeOperations.apiCalls.length > 0) {
logForDebugging(`api.call.active_on_abort queryId=${timeout.context.queryId} generation=${timeout.generation} ${timeoutOperations}`);
}
logQueryLifecycle('end', timeout.context, timeoutOperations);
if (feature('TOKEN_BUDGET')) {
snapshotOutputTokensForTurn(null);
}

// QueryGuard calls this before forceEnd(); defer UI cleanup until after
// the guard has released so the normal stale-generation finally path skips.
queueMicrotask(() => {
logQueryLifecycle('abort_acknowledged', timeout.context, formatQueryLifecycleAbortSignalReason('query-timeout'));
resetLoadingState();
setAbortController(null);
void mrOnTurnComplete(messagesRef.current, true);
Expand Down Expand Up @@ -2229,7 +2267,17 @@ export function REPL({
if (feature('PROACTIVE') || feature('KAIROS')) {
proactiveModule?.pauseProactive();
}
queryGuard.forceEnd();
const cancelContext = queryGuard.activeContext;
const cancelOperations = queryLifecycleTrackerRef.current.snapshot();
const completedCancelContext = cancelContext ? {
...cancelContext,
terminalReason: 'user-abort' as const,
abortReason: 'user-cancel'
} : null;
if (cancelContext) {
logQueryLifecycle('abort_requested', cancelContext, formatQueryLifecycleAbortSignalReason('user-cancel'));
}
queryGuard.forceEnd('user-abort', 'user-cancel');
skipIdleCheckRef.current = false;

// Preserve partially-streamed text so the user can read what was
Expand Down Expand Up @@ -2265,6 +2313,16 @@ export function REPL({
} else {
abortController?.abort('user-cancel');
}
if (cancelContext) {
logQueryLifecycle('abort_acknowledged', cancelContext, formatQueryLifecycleAbortSignalReason('user-cancel'));
}
if (completedCancelContext) {
const cancelOperationSummary = summarizeActiveOperations(cancelOperations);
if (cancelOperations.apiCalls.length > 0) {
logForDebugging(`api.call.active_on_abort queryId=${completedCancelContext.queryId} generation=${completedCancelContext.queryGeneration} ${cancelOperationSummary}`);
}
logQueryLifecycle('end', completedCancelContext, cancelOperationSummary);
}

// Clear the controller so subsequent Escape presses don't see a stale
// aborted signal. Without this, canCancelRunningTask is false (signal
Expand Down Expand Up @@ -2503,7 +2561,7 @@ export function REPL({
reject
}]);
}), []);
const getToolUseContext = useCallback((messages: MessageType[], newMessages: MessageType[], abortController: AbortController, mainLoopModel: string, queryGeneration?: number): ProcessUserInputContext => {
const getToolUseContext = useCallback((messages: MessageType[], newMessages: MessageType[], abortController: AbortController, mainLoopModel: string, queryGeneration?: number, queryLifecycle?: QueryLifecycleOperationTracker): ProcessUserInputContext => {
// Read mutable values fresh from the store rather than closure-capturing
// useAppState() snapshots. Same values today (closure is refreshed by the
// render between turns); decouples freshness from React's render cycle for
Expand All @@ -2530,6 +2588,7 @@ export function REPL({
} satisfies ProcessUserInputContext['queryActivity'];
return {
abortController,
...(queryLifecycle ? { queryLifecycle } : {}),
options: {
commands,
tools: computeTools(),
Expand Down Expand Up @@ -2793,7 +2852,7 @@ export function REPL({
void removeTranscriptMessage(tombstonedMessage.uuid);
}, setStreamingThinking, undefined, onStreamingText);
}, [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText]);
const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, queryGeneration: number, effort?: EffortValue) => {
const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, queryGeneration: number, effort?: EffortValue, queryLifecycle?: QueryLifecycleOperationTracker) => {
// Prepare IDE integration for new prompt. Read mcpClients fresh from
// store — useManageMCPConnections may have populated it since the
// render that captured this closure (same pattern as computeTools).
Expand Down Expand Up @@ -2880,7 +2939,7 @@ export function REPL({
setAbortController(null);
return;
}
const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam, queryGeneration);
const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam, queryGeneration, queryLifecycle);
const querySessionId = getSessionId();
const queryAutoCompactTracking = getAutoCompactTrackingForSession(querySessionId);
// getToolUseContext reads tools/mcpClients fresh from store.getState()
Expand Down Expand Up @@ -2977,10 +3036,17 @@ export function REPL({
}

// Concurrent guard via state machine. tryStart() atomically checks
// and transitions idle→running, returning the generation number.
// and transitions idle→running, returning lifecycle context.
// Returns null if already running — no separate check-then-set.
const thisGeneration = queryGuard.tryStart();
if (thisGeneration === null) {
const lifecycleTracker = queryLifecycleTrackerRef.current;
const querySource = getQuerySourceForREPL();
const startResult = queryGuard.tryStart({
queryId: randomUUID(),
querySource,
startedAt: Date.now(),
getActiveOperations: () => lifecycleTracker.snapshot()
});
if (startResult === null) {
logEvent('tengu_concurrent_onquery_detected', {});

// Extract and enqueue user message text, skipping meta messages
Expand All @@ -2997,6 +3063,12 @@ export function REPL({
});
return;
}
lifecycleTracker.clear();
const thisGeneration = startResult.generation;
const queryContext = startResult.context;
logQueryLifecycle('start', queryContext);
logQueryLifecycle('guard_start', queryContext);
let didThrow = false;
try {
// isLoading is derived from queryGuard — tryStart() above already
// transitioned dispatching→running, so no setter call needed here.
Expand Down Expand Up @@ -3034,12 +3106,32 @@ export function REPL({
return;
}
}
await onQueryImpl(latestMessages, newMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, thisGeneration, effort);
await onQueryImpl(latestMessages, newMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, thisGeneration, effort, lifecycleTracker);
} catch (error) {
didThrow = true;
throw error;
} finally {
const terminalReason = getQueryTerminalReason(abortController.signal, didThrow);
const abortReason = getAbortReasonLabel(abortController.signal.reason);
const activeOperations = lifecycleTracker.snapshot();
const completedContext = {
...queryContext,
terminalReason,
...(abortReason !== undefined && {
abortReason
})
};
// queryGuard.end() atomically checks generation and transitions
// running→idle. Returns false if a newer query owns the guard
// (cancel+resubmit race where the stale finally fires as a microtask).
if (queryGuard.end(thisGeneration)) {
if (queryGuard.end(thisGeneration, terminalReason, abortReason)) {
logQueryLifecycle('end', completedContext, summarizeActiveOperations(activeOperations));
if (activeOperations.apiCalls.length > 0) {
logForDebugging(`api.call.orphaned_on_query_end queryId=${queryContext.queryId} generation=${thisGeneration} ${summarizeActiveOperations(activeOperations)}`, {
level: 'warn'
});
}
lifecycleTracker.clear();
setLastQueryCompletionTime(Date.now());
skipIdleCheckRef.current = false;
// Always reset loading state in finally - this ensures cleanup even
Expand Down Expand Up @@ -3125,6 +3217,8 @@ export function REPL({
// controller makes ctrl+c fire onCancel() (aborting nothing) instead of
// propagating to the double-press exit flow.
setAbortController(null);
} else if (!queryGuard.isActive) {
lifecycleTracker.clear();
}

// Auto-restore: if the user interrupted before any meaningful response
Expand Down
Loading