From 3651af2d44295dea144d7bc6823e8ab87560e7d3 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 30 Dec 2025 18:01:00 -0300 Subject: [PATCH 1/4] fix: resolve tool scheduling, cancellation, and search issues - #945: Skip git grep for unsupported brace expansion patterns like *.{ts,tsx} Git grep pathspecs don't support shell-style brace expansion, so we now detect these patterns and fall back to system grep which handles them. - #948: Kill child process on abort in DiscoveredTool.execute The abort signal was being ignored (prefixed with underscore). Now we properly listen for abort and kill the spawned child process. - #951: Use global counter for history message IDs to prevent collisions Changed from per-instance useRef counter to module-level counter so different hook instances using the same timestamp won't collide. - #952/#957: Fix tool confirmation abort signal handling - Store original abort signal per callId in callIdToSignal map - Use stored signal in handleMessageBusResponse instead of creating new one - Add reentrancy guard to publishBufferedResults to prevent race conditions Fixes #945, #948, #951, #952, #957 --- .../cli/src/ui/hooks/useHistoryManager.ts | 16 +- packages/core/src/core/coreToolScheduler.ts | 77 ++++--- packages/core/src/tools/grep.ts | 13 +- packages/core/src/tools/tool-registry.ts | 92 ++++---- .../20251230fixes/issues/issue-945.json | 1 + .../20251230fixes/issues/issue-948.json | 1 + .../20251230fixes/issues/issue-951.json | 1 + .../20251230fixes/issues/issue-952.json | 1 + .../20251230fixes/issues/issue-957.json | 1 + project-plans/20251230fixes/plan.md | 200 ++++++++++++++++++ 10 files changed, 329 insertions(+), 74 deletions(-) create mode 100644 project-plans/20251230fixes/issues/issue-945.json create mode 100644 project-plans/20251230fixes/issues/issue-948.json create mode 100644 project-plans/20251230fixes/issues/issue-951.json create mode 100644 project-plans/20251230fixes/issues/issue-952.json create mode 100644 project-plans/20251230fixes/issues/issue-957.json create mode 100644 project-plans/20251230fixes/plan.md diff --git a/packages/cli/src/ui/hooks/useHistoryManager.ts b/packages/cli/src/ui/hooks/useHistoryManager.ts index ff82eeafa..0e0b56a1b 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.ts @@ -13,6 +13,10 @@ import { DEFAULT_HISTORY_MAX_ITEMS, } from '../../constants/historyLimits.js'; +// Global counter for generating unique message IDs across all hook instances. +// This prevents ID collisions when multiple useHistory hooks use the same baseTimestamp. +let globalMessageIdCounter = 0; + // Type for the updater function passed to updateHistoryItem type HistoryItemUpdater = ( prevItem: HistoryItem, @@ -49,7 +53,6 @@ export function useHistory( options?: UseHistoryOptions, ): UseHistoryManagerReturn { const [history, setHistory] = useState([]); - const messageIdCounterRef = useRef(0); const maxItems = options?.maxItems; const maxBytes = options?.maxBytes; const limits = useMemo( @@ -63,10 +66,11 @@ export function useHistory( setHistory((prev) => trimHistory(prev, limits)); }, [limits]); - // Generates a unique message ID based on a timestamp and a counter. + // Generates a unique message ID based on a timestamp and a global counter. + // Using a global counter ensures uniqueness across all hook instances. const getNextMessageId = useCallback((baseTimestamp: number): number => { - messageIdCounterRef.current += 1; - return baseTimestamp + messageIdCounterRef.current; + globalMessageIdCounter += 1; + return baseTimestamp * 1000 + globalMessageIdCounter; }, []); const loadHistory = useCallback((newHistory: HistoryItem[]) => { @@ -128,10 +132,10 @@ export function useHistory( [], ); - // Clears the entire history state and resets the ID counter. + // Clears the entire history state. Note: we do NOT reset the global counter + // to ensure IDs remain unique across conversation clears within the same session. const clearItems = useCallback(() => { setHistory([]); - messageIdCounterRef.current = 0; ConversationContext.startNewConversation(); }, []); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 5887c2c44..40a4283da 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -412,6 +412,9 @@ export class CoreToolScheduler { > = new Map(); private nextPublishIndex = 0; private readonly toolContextInteractiveMode: boolean; + // Track the abort signal for each tool call so we can use it when handling + // confirmation responses from the message bus + private callIdToSignal: Map = new Map(); constructor(options: CoreToolSchedulerOptions) { this.config = options.config; @@ -491,12 +494,14 @@ export class CoreToolScheduler { : ToolConfirmationOutcome.Cancel : ToolConfirmationOutcome.Cancel); - const abortController = new AbortController(); + // Use the original signal stored for this call, or create a new one as fallback + const originalSignal = this.callIdToSignal.get(callId); + const signal = originalSignal ?? new AbortController().signal; void this.handleConfirmationResponse( callId, waitingToolCall.confirmationDetails.onConfirm, derivedOutcome, - abortController.signal, + signal, response.payload, true, ); @@ -940,6 +945,8 @@ export class CoreToolScheduler { } const { request: reqInfo, invocation } = toolCall; + // Store the signal for this call so we can use it later in message bus responses + this.callIdToSignal.set(reqInfo.callId, signal); try { if (signal.aborted) { @@ -1352,37 +1359,51 @@ export class CoreToolScheduler { }); } + // Reentrancy guard for publishBufferedResults to prevent race conditions + // when multiple async tool completions trigger publishing simultaneously + private isPublishingBufferedResults = false; + private async publishBufferedResults(signal: AbortSignal): Promise { - const callsInOrder = this.toolCalls.filter( - (call) => call.status === 'scheduled' || call.status === 'executing', - ); + // Prevent reentrant calls which can cause race conditions + if (this.isPublishingBufferedResults) { + return; + } + this.isPublishingBufferedResults = true; - // Publish results in original request order - while (this.nextPublishIndex < callsInOrder.length) { - const expectedCall = callsInOrder[this.nextPublishIndex]; - const buffered = this.pendingResults.get(expectedCall.request.callId); + try { + const callsInOrder = this.toolCalls.filter( + (call) => call.status === 'scheduled' || call.status === 'executing', + ); - if (!buffered) { - // Next result not ready yet, stop publishing - break; - } + // Publish results in original request order + while (this.nextPublishIndex < callsInOrder.length) { + const expectedCall = callsInOrder[this.nextPublishIndex]; + const buffered = this.pendingResults.get(expectedCall.request.callId); - // Publish this result - await this.publishResult(buffered, signal); + if (!buffered) { + // Next result not ready yet, stop publishing + break; + } - // Remove from buffer - this.pendingResults.delete(buffered.callId); - this.nextPublishIndex++; - } + // Publish this result + await this.publishResult(buffered, signal); - // Check if all tools completed - if ( - this.nextPublishIndex === callsInOrder.length && - callsInOrder.length > 0 - ) { - // Reset for next batch - this.nextPublishIndex = 0; - this.pendingResults.clear(); + // Remove from buffer + this.pendingResults.delete(buffered.callId); + this.nextPublishIndex++; + } + + // Check if all tools completed + if ( + this.nextPublishIndex === callsInOrder.length && + callsInOrder.length > 0 + ) { + // Reset for next batch + this.nextPublishIndex = 0; + this.pendingResults.clear(); + } + } finally { + this.isPublishingBufferedResults = false; } } @@ -1548,7 +1569,9 @@ export class CoreToolScheduler { const completedCalls = [...this.toolCalls] as CompletedToolCall[]; this.toolCalls = []; + // Clean up signal mappings for completed calls for (const call of completedCalls) { + this.callIdToSignal.delete(call.request.callId); logToolCall(this.config, new ToolCallEvent(call)); } diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index ba9c0c59d..98c96af30 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -29,6 +29,14 @@ import { } from '../utils/toolOutputLimiter.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; +/** + * Checks if a glob pattern contains brace expansion syntax that git grep doesn't support. + * Git grep pathspecs don't support shell-style brace expansion like {ts,tsx,js}. + */ +function hasBraceExpansion(pattern: string): boolean { + return /\{[^}]*,[^}]*\}/.test(pattern); +} + // --- Interfaces --- /** @@ -500,7 +508,10 @@ class GrepToolInvocation extends BaseToolInvocation< try { // --- Strategy 1: git grep --- - const isGit = isGitRepository(absolutePath); + // Skip git grep if include pattern has brace expansion (e.g., *.{ts,tsx}) + // because git grep pathspecs don't support shell-style brace expansion. + const hasBracePattern = include && hasBraceExpansion(include); + const isGit = !hasBracePattern && isGitRepository(absolutePath); const gitAvailable = isGit && (await this.isCommandAvailable('git')); if (gitAvailable) { diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index af9f0817c..713467e8e 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -70,7 +70,7 @@ Signal: Signal number or \`(none)\` if no signal was received. async execute( params: ToolParams, - _signal: AbortSignal, + signal: AbortSignal, _updateOutput?: (output: string) => void, ): Promise { const callCommand = this.config.getToolCallCommand()!; @@ -82,55 +82,67 @@ Signal: Signal number or \`(none)\` if no signal was received. let stderr = ''; let error: Error | null = null; let code: number | null = null; - let signal: NodeJS.Signals | null = null; - - await new Promise((resolve) => { - const onStdout = (data: Buffer) => { - stdout += data?.toString(); - }; - - const onStderr = (data: Buffer) => { - stderr += data?.toString(); - }; + let exitSignal: NodeJS.Signals | null = null; - const onError = (err: Error) => { - error = err; - }; - - const onClose = ( - _code: number | null, - _signal: NodeJS.Signals | null, - ) => { - code = _code; - signal = _signal; - cleanup(); - resolve(); - }; + // Handle abort signal to kill the child process + const abortHandler = () => { + if (!child.killed) { + child.kill('SIGTERM'); + } + }; + signal.addEventListener('abort', abortHandler); - const cleanup = () => { - child.stdout.removeListener('data', onStdout); - child.stderr.removeListener('data', onStderr); - child.removeListener('error', onError); - child.removeListener('close', onClose); - if (child.connected) { - child.disconnect(); - } - }; + try { + await new Promise((resolve) => { + const onStdout = (data: Buffer) => { + stdout += data?.toString(); + }; + + const onStderr = (data: Buffer) => { + stderr += data?.toString(); + }; + + const onError = (err: Error) => { + error = err; + }; + + const onClose = ( + _code: number | null, + _signal: NodeJS.Signals | null, + ) => { + code = _code; + exitSignal = _signal; + cleanup(); + resolve(); + }; + + const cleanup = () => { + child.stdout.removeListener('data', onStdout); + child.stderr.removeListener('data', onStderr); + child.removeListener('error', onError); + child.removeListener('close', onClose); + if (child.connected) { + child.disconnect(); + } + }; - child.stdout.on('data', onStdout); - child.stderr.on('data', onStderr); - child.on('error', onError); - child.on('close', onClose); - }); + child.stdout.on('data', onStdout); + child.stderr.on('data', onStderr); + child.on('error', onError); + child.on('close', onClose); + }); + } finally { + signal.removeEventListener('abort', abortHandler); + } // if there is any error, non-zero exit code, signal, or stderr, return error details instead of stdout - if (error || code !== 0 || signal || stderr) { + if (error || code !== 0 || exitSignal || stderr) { const llmContent = [ `Stdout: ${stdout || '(empty)'}`, `Stderr: ${stderr || '(empty)'}`, `Error: ${error ?? '(none)'}`, `Exit Code: ${code ?? '(none)'}`, - `Signal: ${signal ?? '(none)'}`, + `Signal: ${exitSignal ?? '(none)'}`, ].join('\n'); return { llmContent, diff --git a/project-plans/20251230fixes/issues/issue-945.json b/project-plans/20251230fixes/issues/issue-945.json new file mode 100644 index 000000000..bc778f899 --- /dev/null +++ b/project-plans/20251230fixes/issues/issue-945.json @@ -0,0 +1 @@ +{"body":"## Summary\nThe search_file_content tool can return false negatives when include is a glob (especially brace expansion like **/*.{ts,tsx,js} or when include is prefixed with the path). This often triggers the JS fallback scan, which can appear to hang on large trees.\n\n## Repro\n1. Run search with: pattern=sandbox, path=packages/cli/src, include=**/*.{ts,tsx,js}\n2. Expected to match packages/cli/src/config/sandboxConfig.ts\n3. Actual: No matches found OR very slow fallback traversal\n\nIf include is path-prefixed (e.g. packages/cli/src/**/*.{ts,tsx,js}) it never matches when cwd is already packages/cli/src.\n\n## Root Cause (likely)\n- include is passed directly to git grep as a pathspec, but git pathspec does not support brace expansion and relative-path assumptions differ.\n- When git grep returns empty, tool falls back to JS glob and scans huge trees, which feels like a hang.\n\n## Suggested Fix\n- Normalize include to be relative to path (strip leading search dir).\n- Detect {} / ** / other glob patterns and either convert to git pathspec (:(glob)) or skip git grep and go straight to JS glob.\n- Add a timeout / progress / early exit for JS fallback to avoid long hangs.\n","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/274\n- https://github.com/vybestack/llxprt-code/issues/391\n- https://github.com/vybestack/llxprt-code/issues/63\n- https://github.com/vybestack/llxprt-code/issues/473\n- https://github.com/vybestack/llxprt-code/issues/751\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n- [pidgeon777](https://github.com/pidgeon777)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit) or [schedule a call](https://calendly.com/parth-coderabbit/issues-feedback)!","createdAt":"2025-12-22T22:31:19Z","url":"https://github.com/vybestack/llxprt-code/issues/945#issuecomment-3684417170"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find the search_file_content tool implementation\nfd -e ts -e js -e tsx \"search.*file.*content\" --type f\n```\n\nLength of output: 0\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for files that might contain the implementation using ripgrep\nrg -n \"search_file_content\" -A 3 -B 3 --type ts --type js\n```\n\nLength of output: 7474\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for git grep usage in the codebase\nrg -n \"git grep\" -A 5 -B 5 --type ts --type js\n```\n\nLength of output: 5344\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for fallback glob logic\nrg -n \"fallback\" -A 3 -B 3 --type ts --type js | head -100\n```\n\nLength of output: 6350\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the grep.ts file to understand the implementation\ncat packages/core/src/tools/grep.ts\n```\n\nLength of output: 29158\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver I've investigated issue #945 and can confirm the problem. Here's my analysis:\n\n## Root Cause\n\nThe `search_file_content` tool (implemented in `packages/core/src/tools/grep.ts`) has a **critical flaw in how it handles the `include` parameter with git grep**.\n\n**At lines 514-516:**\n```typescript\nif (include) {\n gitArgs.push('--', include);\n}\n```\n\nThe `include` parameter is passed directly to git grep as a pathspec. However:\n\n1. **Git pathspec does NOT support brace expansion** - Patterns like `**/*.{ts,tsx,js}` are not valid git pathspec syntax\n2. **Path-prefixed globs fail** - If `include` is `packages/cli/src/**/*.ts` and the cwd is already `packages/cli/src`, it won't match (double-prefixing issue)\n3. **Git pathspec uses different syntax** - Git supports glob patterns via the `:(glob)` magic signature, but the code doesn't use it\n\nWhen git grep returns no matches (exit code 1), the code correctly treats it as \"no matches found\" and falls back to the JavaScript implementation (line 665+), which uses `globStream` that DOES support proper glob syntax. However, this fallback scans the entire directory tree, which can be extremely slow on large codebases.\n\n## Suggested Fixes\n\n### Option 1: Detect Glob Patterns and Skip Git Grep\n```typescript\n// Check if include contains glob patterns that git pathspec can't handle\nconst hasUnsupportedGlob = include \u0026\u0026 (\n include.includes('{') || \n include.includes('}') ||\n include.startsWith(absolutePath) // Path-prefixed\n);\n\nif (gitAvailable \u0026\u0026 !hasUnsupportedGlob) {\n // Use git grep\n}\n```\n\n### Option 2: Convert to Git Pathspec Format\n```typescript\nif (include) {\n // Normalize path-prefixed includes\n let normalizedInclude = include;\n if (include.startsWith(absolutePath)) {\n normalizedInclude = path.relative(absolutePath, include);\n }\n \n // Use git's glob magic signature for patterns with braces\n if (normalizedInclude.includes('{') || normalizedInclude.includes('}')) {\n gitArgs.push('--', `:(glob)${normalizedInclude}`);\n } else {\n gitArgs.push('--', normalizedInclude);\n }\n}\n```\n\n### Option 3: Add Progress/Timeout for JS Fallback\nThe JavaScript fallback should have better user experience:\n```typescript\n// Add progress logging\nconsole.debug('Falling back to JavaScript grep - this may take a moment for large directories...');\n\n// Consider adding a file count limit or early exit\nlet filesScanned = 0;\nconst MAX_FILES_TO_SCAN = 10000;\n```\n\nWould you like me to create a PR with one of these fixes, or would you prefer to handle this internally?","createdAt":"2025-12-23T13:12:18Z","url":"https://github.com/vybestack/llxprt-code/issues/945#issuecomment-3686595519"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Fetch details of issues #952 and #957\ngh issue view 952 --json number,title,body,labels,state,author\necho \"---SEPARATOR---\"\ngh issue view 957 --json number,title,body,labels,state,author\n```\n\nLength of output: 4063\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver Yes, you're absolutely right! These issues are definitely related. Here's the connection:\n\n## Root Cause โ†’ Symptoms Chain\n\n**#945** (this issue) is the **root cause**:\n- `search_file_content` tool's glob pattern handling is broken\n- Brace expansion and `**/*.ts` patterns fail with git grep\n- Falls back to slow JavaScript glob traversal that can take minutes on large trees\n\n**#952** is experiencing the **hanging symptom**:\n- The SearchText example you posted uses `integration-tests/**/*.ts` \n- This is exactly the type of glob pattern that triggers #945's bug\n- Git grep fails โ†’ JS fallback kicks in โ†’ appears to hang\n- Returns \"No matches found\" which could be either a false negative OR the JS scan eventually completing with no results\n\n**#957** is the **downstream effect**:\n- When tools take unexpectedly long (due to #945's slow fallback), they:\n - Get stuck in the tool scheduler queue\n - Eventually get cancelled with \"Tool call cancelled while in queue\"\n- Your note \"this was on something that was taking awhile for no reason\" aligns perfectly with #945's slow fallback behavior\n\n## Smoking Gun Evidence\n\nIn #952, you have:\n```\nโœ“ SearchText 'output-format|outputFormat|...' in integration-tests/**/*.ts within ./\n No matches found\n```\n\nThat `integration-tests/**/*.ts` glob is being passed directly to git grep (which doesn't support `**` properly), failing, then triggering the slow JS fallback scan.\n\n## Fix Priority\n\nFixing #945 should resolve or significantly improve both #952 and #957. The fixes I suggested earlier (detecting unsupported globs, converting to git pathspec format, or adding timeouts) would prevent the slow fallback from causing these queue issues.","createdAt":"2025-12-29T19:20:18Z","url":"https://github.com/vybestack/llxprt-code/issues/945#issuecomment-3697310851"}],"number":945,"title":"search_file_content: include glob can false-negative and trigger slow fallback"} diff --git a/project-plans/20251230fixes/issues/issue-948.json b/project-plans/20251230fixes/issues/issue-948.json new file mode 100644 index 000000000..aaf403d2c --- /dev/null +++ b/project-plans/20251230fixes/issues/issue-948.json @@ -0,0 +1 @@ +{"body":"### What happened?\n\nif the llm kicks off a long shell call esc shows cancelled but the shell contiues\n\n### What did you expect to happen?\n\nto cancel /kill the shell job too\n\n### Client information\n\nv0.7.0-nightly.251222.e40c645db\n\n### Login information\n\n_No response_\n\n### Anything else we need to know?\n\n_No response_","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Possible Duplicates**\n- https://github.com/vybestack/llxprt-code/issues/521\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/307\n- https://github.com/vybestack/llxprt-code/issues/61\n- https://github.com/vybestack/llxprt-code/issues/619\n- https://github.com/vybestack/llxprt-code/issues/525\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n- [e2720pjk](https://github.com/e2720pjk)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit) or [schedule a call](https://calendly.com/parth-coderabbit/issues-feedback)!","createdAt":"2025-12-23T00:24:01Z","url":"https://github.com/vybestack/llxprt-code/issues/948#issuecomment-3684628374"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find files related to shell tool execution\necho \"=== Finding shell tool related files ===\"\nfd -e py -e ts -e js -e go shell | head -20\n\necho -e \"\\n=== Searching for shell tool implementation ===\"\nrg -l \"shell.*tool\" --type=py --type=ts --type=js --type=go | head -20\n\necho -e \"\\n=== Searching for process execution and cancellation ===\"\nrg -n \"cancel.*process|kill.*process|abort.*process\" --type=py --type=ts --type=js --type=go -C2 | head -50\n```\n\nLength of output: 5840\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the shell tool implementation\necho \"=== Shell Tool Implementation ===\"\ncat packages/core/src/tools/shell.ts\n```\n\nLength of output: 24970\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the shell execution service implementation\necho \"=== Shell Execution Service Implementation ===\"\ncat packages/core/src/services/shellExecutionService.ts\n```\n\nLength of output: 18747\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for ESC key handling and abort controller usage\necho \"=== Searching for ESC key handling ===\"\nrg -n \"escape|ESC|Escape\" --type=ts --type=tsx -C3 | grep -i \"key\\|cancel\\|abort\" | head -40\n\necho -e \"\\n=== Searching for AbortController creation and usage ===\"\nrg -n \"AbortController|abortController|abort\\(\\)\" --type=ts --type=tsx -C2 | head -60\n\necho -e \"\\n=== Searching for tool execution cancellation ===\"\nrg -n \"cancel.*tool|abort.*tool|tool.*cancel|tool.*abort\" --type=ts --type=tsx -C2 | head -40\n```\n\nLength of output: 231\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for ESC key handling and abort controller usage in TS/JS files\necho \"=== Searching for ESC key handling ===\"\nrg -n \"Escape|escape|ESC\" --type=ts --type=js -C3 | head -60\n\necho -e \"\\n=== Searching for AbortController creation and usage ===\"\nrg -n \"new AbortController|abortController\" --type=ts --type=js -C3 | head -60\n```\n\nLength of output: 8893\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the keyboard handlers to understand ESC handling\necho \"=== Reading useKeyboardHandlers ===\"\ncat packages/ui/src/hooks/useKeyboardHandlers.ts\n\necho -e \"\\n=== Searching for cancel/abort in UI hooks ===\"\nrg -n \"onCancel|handleCancel|cancelStreaming|abortTool\" packages/ui/src/hooks/ -A3 -B3\n```\n\nLength of output: 8240\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for cancelAll implementation and tool execution\necho \"=== Searching for cancelAll implementation ===\"\nrg -n \"cancelAll\\s*=\" --type=ts --type=js -A5 -B2 | head -80\n\necho -e \"\\n=== Searching for tool execution with abort signal ===\"\nrg -n \"executeToolCall|execute.*tool\" --type=ts --type=js -C3 | head -60\n```\n\nLength of output: 4690\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for tool execution in the UI and abort handling\necho \"=== Searching for UI component that uses cancelAll ===\"\nfd -e tsx -e ts . packages/ui/src | xargs rg -l \"cancelAll\" | head -10\n\necho -e \"\\n=== Reading files that use cancelAll ===\"\nrg -n \"cancelAll\" packages/ui/src -A10 -B5 | head -100\n```\n\nLength of output: 7126\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find the CoreToolScheduler implementation\necho \"=== Finding CoreToolScheduler ===\"\nfd -e ts -e js . packages/core/src | xargs rg -l \"class.*ToolScheduler|ToolScheduler.*cancelAll\" | head -10\n\necho -e \"\\n=== Reading CoreToolScheduler cancelAll implementation ===\"\nrg -n \"cancelAll\" packages/core/src -A10 -B5 | head -120\n```\n\nLength of output: 4146\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the app.tsx to understand how cancelAll is wired up\necho \"=== Reading app.tsx around cancelAll usage ===\"\ncat packages/ui/src/app.tsx | grep -A30 -B30 \"cancelAll\"\n\necho -e \"\\n=== Check how abort signal is managed in streaming lifecycle ===\"\ncat packages/ui/src/hooks/useStreamingLifecycle.ts\n```\n\nLength of output: 6247\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check how tools are scheduled and what AbortSignal is passed\necho \"=== Reading useToolScheduler schedule implementation ===\"\ncat packages/ui/src/hooks/useToolScheduler.ts | grep -A50 \"const schedule\"\n\necho -e \"\\n=== Check CoreToolScheduler schedule method ===\"\nrg -n \"schedule.*async|scheduleToolCall\" packages/core/src/core/coreToolScheduler.ts -A20 -B5 | head -100\n```\n\nLength of output: 4204\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the CoreToolScheduler schedule method\necho \"=== Reading CoreToolScheduler schedule method ===\"\ncat packages/core/src/core/coreToolScheduler.ts | grep -A80 \"async schedule\"\n\necho -e \"\\n=== Check how tool execution receives abort signal ===\"\nrg -n \"executeToolWithAbortSignal|execute.*AbortSignal\" packages/core/src/core/coreToolScheduler.ts -A10 -B5\n```\n\nLength of output: 105\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the entire CoreToolScheduler file to understand the flow\necho \"=== Reading CoreToolScheduler structure ===\"\nrg -n \"schedule\\(|processNext|executeWith\" packages/core/src/core/coreToolScheduler.ts -C5 | head -150\n\necho -e \"\\n=== Search for tool.execute calls in CoreToolScheduler ===\"\nrg -n \"tool\\.execute|invocation\\.execute\" packages/core/src/core/coreToolScheduler.ts -C8 | head -100\n```\n\nLength of output: 1223\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Let's read key sections of CoreToolScheduler\necho \"=== Reading CoreToolScheduler to find where tools are executed ===\"\ncat packages/core/src/core/coreToolScheduler.ts | head -100\n\necho -e \"\\n=== Search for where tool invocations execute ===\"\nrg -n \"executeInvocation|ToolInvocation|confirmationHandle\" packages/core/src/core/coreToolScheduler.ts -C5 | head -150\n```\n\nLength of output: 6833\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find where the tool invocation's execute method is called\necho \"=== Searching for invocation.execute calls ===\"\nrg -n \"invocation\\.execute\\(\" packages/core/src/core/coreToolScheduler.ts -C10\n\necho -e \"\\n=== Search for executeScheduledToolCall or similar ===\"\nrg -n \"executeScheduled|executeTool|runTool\" packages/core/src/core/coreToolScheduler.ts -C8 | head -100\n```\n\nLength of output: 102\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Let's look at the full _schedule method and processNext\necho \"=== Reading the _schedule method in full ===\"\nrg -n \"private async _schedule\" packages/core/src/core/coreToolScheduler.ts -A100 | head -120\n\necho -e \"\\n=== Look for messageBus and tool execution ===\"\nrg -n \"messageBus|MessageBus\" packages/core/src/core/coreToolScheduler.ts -C5 | head -80\n```\n\nLength of output: 6734\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for where the abort signal gets passed to tool invocations\necho \"=== Looking for where tools get executed with signals ===\"\nrg -n \"\\.execute\\(.*signal\" packages/core/src/core/coreToolScheduler.ts -B5 -A15\n\necho -e \"\\n=== Check if there's an AbortController created per tool ===\"\nrg -n \"AbortController|abortController\" packages/core/src/core/coreToolScheduler.ts -C5\n```\n\nLength of output: 1317\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for the complete flow of how signals are passed\necho \"=== Check where the signal parameter comes from in _schedule ===\"\nrg -n \"private async _schedule\" packages/core/src/core/coreToolScheduler.ts -A200 | grep -A5 -B5 \"signal\"\n\necho -e \"\\n=== Look for handleConfirmationResponse to see if it uses the new AbortController ===\"\nrg -n \"handleConfirmationResponse\" packages/core/src/core/coreToolScheduler.ts -A30 | head -100\n```\n\nLength of output: 5958\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver I've investigated the issue and found the root cause of why shell commands continue running even after ESC shows \"cancelled\".\n\n## Root Cause\n\nThe problem is in the **abort signal propagation** in `CoreToolScheduler`. Here's the flow:\n\n1. **User presses ESC** โ†’ `handleCancelAll()` is called\n2. This calls `cancelStreaming()` which aborts the main `AbortController`\n3. This also calls `cancelAll()` which marks tools as cancelled in the scheduler\n\nHowever, there's a critical bug at **line 494 in `packages/core/src/core/coreToolScheduler.ts`**:\n\n```typescript\nprivate handleMessageBusResponse(response: ToolConfirmationResponse): void {\n // ... code ...\n \n const abortController = new AbortController(); // โŒ NEW controller created!\n void this.handleConfirmationResponse(\n callId,\n waitingToolCall.confirmationDetails.onConfirm,\n derivedOutcome,\n abortController.signal, // โŒ New signal, not the original one!\n response.payload,\n true,\n );\n}\n```\n\nWhen tool confirmations are handled via the message bus (e.g., from policy engine auto-approvals), a **brand new `AbortController`** is created. This new signal is completely disconnected from the original signal that was passed to `schedule()`.\n\n## The Problem\n\n- The original `AbortSignal` passed to `schedule(request, signal)` flows through validation and confirmation\n- But when `handleMessageBusResponse` processes an approval, it creates a fresh `AbortController`\n- This new signal is what gets passed to `invocation.execute(signal, ...)` \n- When the user presses ESC, the **original** signal is aborted, but the shell tool is running with the **new** signal\n- Result: UI shows \"cancelled\" but the shell process never receives the abort signal\n\n## Solution\n\nThe `CoreToolScheduler` needs to track the original `AbortSignal` for each tool call and reuse it throughout the entire lifecycle, including when processing message bus responses. The signal should be stored alongside each tool call in the scheduler's state.\n\nWould you like me to create an issue to track this bug for fixing?","createdAt":"2025-12-23T00:27:27Z","url":"https://github.com/vybestack/llxprt-code/issues/948#issuecomment-3684633449"}],"number":948,"title":"shell tool won't cancel"} diff --git a/project-plans/20251230fixes/issues/issue-951.json b/project-plans/20251230fixes/issues/issue-951.json new file mode 100644 index 000000000..df1a28f89 --- /dev/null +++ b/project-plans/20251230fixes/issues/issue-951.json @@ -0,0 +1 @@ +{"body":"### What happened?\n\nIf you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n\nthe shell tool is running and I tried scrolling up it is drawing weird and I can't scroll all the way back down.\n\n### What did you expect to happen?\n\nto not get the werid repeats and to scroll down to the bottom of the shell tool that is running below this text.\n\n### Client information\n\n v0.7.0-nightly.251222.e40c645db\n\n### Login information\n\n_No response_\n\n### Anything else we need to know?\n\n_No response_","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Possible Duplicates**\n- https://github.com/vybestack/llxprt-code/issues/142\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/100\n- https://github.com/vybestack/llxprt-code/issues/141\n- https://github.com/vybestack/llxprt-code/issues/518\n- https://github.com/vybestack/llxprt-code/issues/607\n- https://github.com/vybestack/llxprt-code/issues/152\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Related PRs\u003c/b\u003e\u003c/summary\u003e\n\nvybestack/llxprt-code#590 - Kitty protocol cherry-pick fixes and compatibility updates [merged]\nvybestack/llxprt-code#661 - feat: Hybrid UI Architecture \u0026 ToolCall UI Alignment [merged]\nvybestack/llxprt-code#733 - fix(security): Resolve Dependabot alerts #19 #20 for jws vulnerability [merged]\nvybestack/llxprt-code#896 - fix: enforce tool output ephemerals across providers (and remove dead polaris textual replay) [merged]\nvybestack/llxprt-code#917 - Fix tool-call history for provider switching [merged]\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n- [e2720pjk](https://github.com/e2720pjk)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit) or [schedule a call](https://calendly.com/parth-coderabbit/issues-feedback)!","createdAt":"2025-12-23T15:02:57Z","url":"https://github.com/vybestack/llxprt-code/issues/951#issuecomment-3686948329"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find files related to shell tool UI and display\necho \"=== Finding shell tool related files ===\"\nfd -e tsx -e ts -e jsx -e js | rg -i \"shell|terminal|tool.*output|execution\" | head -20\n\necho -e \"\\n=== Finding scroll-related components ===\"\nfd -e tsx -e ts -e jsx -e js | rg -i \"scroll\" | head -20\n\necho -e \"\\n=== Searching for shell tool component implementations ===\"\nrg -l \"shell.*tool|tool.*shell|ShellTool|ToolExecution\" --type=tsx --type=ts --type=jsx --type=js | head -20\n```\n\nLength of output: 1586\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for scroll area implementations and auto-scroll logic\necho \"=== Searching for scroll area implementations ===\"\nrg -n -C3 \"scrollTo|scrollTop|autoScroll|scroll.*bottom\" --type=tsx --type=ts --type=jsx --type=js | head -100\n\necho -e \"\\n=== Searching for scroll container classes/components ===\"\nast-grep --pattern 'class $_ {\n $$$\n scroll$_($$$) {\n $$$\n }\n $$$\n}'\n```\n\nLength of output: 136\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for TODOs or comments related to scroll issues\necho \"=== Searching for scroll-related TODOs and comments ===\"\nrg -n \"TODO|FIXME|BUG|HACK\" --type=tsx --type=ts --type=jsx --type=js | rg -i \"scroll\" | head -20\n\necho -e \"\\n=== Searching for issue references in code ===\"\nrg -n \"#951|issue.*951\" --type=tsx --type=ts --type=jsx --type=js\n```\n\nLength of output: 159\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search with proper file extensions instead of --type\necho \"=== Searching for scroll area implementations ===\"\nrg -n -C3 \"scrollTo|scrollTop|autoScroll|scroll.*bottom\" -g \"*.ts\" -g \"*.tsx\" -g \"*.js\" -g \"*.jsx\" | head -150\n\necho -e \"\\n=== Searching for scroll-related TODOs ===\"\nrg -n \"TODO|FIXME|BUG|HACK\" -g \"*.ts\" -g \"*.tsx\" -g \"*.js\" -g \"*.jsx\" | rg -i \"scroll\"\n```\n\nLength of output: 11755\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Examine the ScrollProvider and related scroll management files\necho \"=== ScrollProvider.tsx ===\"\ncat packages/cli/src/ui/contexts/ScrollProvider.tsx\n\necho -e \"\\n=== useScrollManagement.ts ===\"\ncat packages/ui/src/hooks/useScrollManagement.ts\n\necho -e \"\\n=== useBatchedScroll.ts ===\"\ncat packages/cli/src/ui/hooks/useBatchedScroll.ts\n```\n\nLength of output: 13721\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for shell tool output rendering components\necho \"=== Finding shell execution/output components ===\"\nrg -n -C5 \"shell.*output|tool.*output|execution.*output\" -g \"*.tsx\" -g \"*.ts\" | head -100\n\necho -e \"\\n=== Examining UIStateShell.tsx ===\"\ncat packages/cli/src/ui/containers/UIStateShell.tsx | head -200\n```\n\nLength of output: 7871\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Examine VirtualizedList implementation\necho \"=== VirtualizedList.tsx ===\"\ncat packages/cli/src/ui/components/shared/VirtualizedList.tsx\n\necho -e \"\\n=== Finding streaming/dynamic content handling ===\"\nrg -n -C5 \"streaming|chunk|append.*output|tool.*running\" -g \"*.tsx\" -g \"*.ts\" | head -100\n```\n\nLength of output: 20411\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for any existing scroll-related bugs or regression tests\necho \"=== Scroll regression tests ===\"\ncat scripts/tests/oldui-scrollback-regression.test.js\n\necho -e \"\\n=== Scroll load test ===\"\ncat scripts/oldui-scrollback-load.js | head -150\n```\n\nLength of output: 4524\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check for any open issues mentioning scroll problems\necho \"=== Searching for scroll-related comments and issues in code ===\"\nrg -n \"scroll.*bug|scroll.*issue|scroll.*problem|scroll.*broken|scroll.*borked\" -g \"*.tsx\" -g \"*.ts\" -g \"*.js\" -g \"*.md\" -i\n\necho -e \"\\n=== Check for references to rendering issues ===\"\nrg -n \"render.*bug|render.*issue|duplicate.*render|repeat.*render\" -g \"*.tsx\" -g \"*.ts\" -i | head -30\n```\n\nLength of output: 1385\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the project documentation about UI findings and scroll issues\necho \"=== UI Findings Documentation ===\"\ncat project-plans/20251215oldui/uifindings.md\n\necho -e \"\\n=== Testing Strategy Documentation ===\"\ncat project-plans/20251215oldui/testing-strategy.md\n\necho -e \"\\n=== Tmux Harness Documentation ===\"\ncat dev-docs/oldui-tmux-harness.md\n```\n\nLength of output: 23304\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for issue 951 and related scroll issues in GitHub\necho \"=== Searching for related GitHub issues ===\"\ngh issue view 951 --json title,body,labels,state,comments --jq '{title, body, state, labels: [.labels[].name], comments: [.comments[].body]}'\n\necho -e \"\\n=== Searching for other scroll-related issues ===\"\ngh issue list --search \"scroll\" --json number,title,state --limit 10\n```\n\nLength of output: 14388\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for any existing fixes or workarounds for scroll issues\necho \"=== Recent commits mentioning scroll ===\"\ngit log --oneline --all --grep=\"scroll\" -20\n\necho -e \"\\n=== Recent commits mentioning render or redraw ===\"\ngit log --oneline --all --grep=\"render\\|redraw\" -15\n```\n\nLength of output: 2328\n\n---\n\n@acoliver, I've investigated issue #951 and this is a known class of scrollback rendering issues with the old Ink UI that has been actively worked on.\n\n## What I Found\n\n**The Problem**: This is related to the documented \"Old Ink UI scrollback redraw\" issue where the UI continuously re-renders while you're scrolled up, causing:\n- Repeated/duplicated text patterns in the scrollback buffer\n- Scroll position becoming stuck or unable to return to bottom\n- Particularly problematic during streaming output from shell tools\n\n**Recent Work**: There have been significant fixes recently:\n- Commits addressing scroll jitter, scroll gaps, and mouse scroll issues\n- Addition of VirtualizedList, ScrollProvider, and scrollable list primitives\n- Automated tmux-based harness specifically built to detect and reproduce these issues\n\n## Documentation References\n\nThe issue is extensively documented in:\n- `project-plans/20251215oldui/uifindings.md` - Details the scrollback redraw problem\n- `project-plans/20251215oldui/testing-strategy.md` - Testing approach for scroll issues \n- `dev-docs/oldui-tmux-harness.md` - Automated reproduction harness\n\n## Next Steps\n\nTo help debug this specific case:\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check what version/commit the reported version corresponds to\necho \"=== Checking commit e40c645db ===\"\ngit log --oneline e40c645db -1 2\u003e/dev/null || echo \"Commit not found in current branch\"\n\necho -e \"\\n=== Checking recent tags/releases ===\"\ngit tag --contains e40c645db 2\u003e/dev/null | head -10 || echo \"No tags found for this commit\"\n\necho -e \"\\n=== Recent commits on main/master ===\"\ngit log --oneline --first-parent origin/main -10 2\u003e/dev/null || git log --oneline --first-parent main -10 2\u003e/dev/null || echo \"Could not fetch recent commits\"\n```\n\nLength of output: 666\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are newer fixes after the reported version\necho \"=== Commits after e40c645db related to scroll ===\"\ngit log --oneline --grep=\"scroll\" e40c645db..HEAD 2\u003e/dev/null | head -20 || echo \"Could not compare commits\"\n\necho -e \"\\n=== Check current branch and latest commit ===\"\necho \"Current branch: $(git branch --show-current)\"\necho \"Latest commit: $(git log --oneline -1)\"\n```\n\nLength of output: 192\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there's a fix-oldui or similar branch with recent scroll fixes\necho \"=== Checking for branches with scroll fixes ===\"\ngit branch -a | grep -i \"scroll\\|oldui\\|fix.*ui\"\n\necho -e \"\\n=== Check if fix-oldui branch exists and its status ===\"\ngit log --oneline fix-oldui -10 2\u003e/dev/null || git log --oneline origin/fix-oldui -10 2\u003e/dev/null || echo \"fix-oldui branch not found\"\n\necho -e \"\\n=== Compare main with fix-oldui if it exists ===\"\ngit log --oneline main..origin/fix-oldui --grep=\"scroll\" 2\u003e/dev/null | head -10 || echo \"No comparison available\"\n```\n\nLength of output: 181\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for open PRs related to scroll issues\necho \"=== Searching for open PRs related to scroll ===\"\ngh pr list --search \"scroll\" --json number,title,state --limit 10\n\necho -e \"\\n=== Searching for recently merged PRs with scroll fixes ===\"\ngh pr list --search \"scroll\" --state merged --json number,title,mergedAt --limit 10\n```\n\nLength of output: 1248\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if the scroll fixes were on a branch that was already merged\necho \"=== Commit 5dde73e4 (Merge pull request #845 from vybestack/fix-oldui) details ===\"\ngit log --oneline 5dde73e4 -1 2\u003e/dev/null || echo \"Not found\"\n\necho -e \"\\n=== Checking if commit e40c645db includes the fix-oldui merge ===\"\ngit log --oneline e40c645db --grep=\"fix-oldui\" -10 2\u003e/dev/null || git log --oneline --all --grep=\"fix-oldui\" -10 2\u003e/dev/null || echo \"Not found\"\n\necho -e \"\\n=== Check if 5dde73e4 is an ancestor of e40c645db ===\"\ngit merge-base --is-ancestor 5dde73e4 e40c645db 2\u003e/dev/null \u0026\u0026 echo \"YES - fix-oldui merge IS in the reported version\" || echo \"NO - fix-oldui merge is NOT in the reported version\"\n```\n\nLength of output: 436\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get details of PR #845 to understand what was fixed\necho \"=== PR #845 Details ===\"\ngh pr view 845 --json title,body,mergedAt,commits | jq -r '.title, .body' | head -100\n\necho -e \"\\n=== Commits in PR #845 ===\"\ngh pr view 845 --json commits | jq -r '.commits[] | \"\\(.oid[0:8]) \\(.messageHeadline)\"'\n```\n\nLength of output: 6845\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are any commits between the fix-oldui merge and the reported version\necho \"=== Commits between fix-oldui merge (5dde73e4) and reported version (e40c645db) ===\"\ngit log --oneline 5dde73e4..e40c645db\n\necho -e \"\\n=== Count of commits ===\"\ngit log --oneline 5dde73e4..e40c645db | wc -l\n```\n\nLength of output: 14703\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver, I've completed my investigation. Here's what I found:\n\n## Current Status\n\n**Good news**: The major scrollback fix PR (#845) from Dec 17, 2025 **IS included** in the reported version (v0.7.0-nightly.251222.e40c645db). That PR addressed the core \"scrollback redraw spam\" issue documented in #456.\n\n**The problem**: Despite those fixes being present, you're experiencing a **new or different scroll rendering issue** with symptoms that suggest this may be a distinct bug:\n\n1. **\"Weird repeats\"** - Text appearing duplicated/repeated (not just scrollback spam)\n2. **Can't scroll back down** - Scroll position stuck or unable to return to bottom\n3. **Occurs during shell tool execution** - Specifically while streaming output\n\n## Analysis\n\nPR #845 fixed the primary issue where the UI would continuously append to terminal scrollback while the user was scrolled up. However, your report suggests there may be:\n\n- A **rendering duplication bug** (the \"weird repeats\")\n- A **scroll position tracking issue** (inability to scroll back to bottom)\n- Possibly related to the VirtualizedList scroll anchoring during rapid streaming updates\n\n## Recommended Next Steps\n\n### 1. **Reproduce with the tmux harness**\n\nThe codebase has an automated reproduction tool specifically for scroll issues:\n\n```bash\n# Basic shell command scrollback test\nnode scripts/oldui-tmux-harness.js --scenario scrollback --rows 20 --cols 100\n\n# Or create a custom script for your exact scenario\nnode scripts/oldui-tmux-harness.js --script \u003cyour-script.json\u003e\n```\n\n### 2. **Check UI settings**\n\nWhat's your current setting for:\n```bash\n/set ui.enableMouseEvents\n/set ui.useAlternateBuffer\n```\n\nThe scroll fixes work best with `ui.useAlternateBuffer=true` (default).\n\n### 3. **Gather reproduction details**\n\nTo help debug, we'd need:\n- Exact steps to reproduce (which shell command, how long it runs, etc.)\n- Terminal emulator and dimensions\n- Whether this happens consistently or intermittently\n- Screen recording or tmux capture if possible\n\n### 4. **Check for edge cases**\n\nThe VirtualizedList has logic to handle \"sticking to bottom\" during streaming. Your symptoms suggest there might be an edge case where:\n- Content height changes rapidly during streaming\n- Scroll anchor calculation gets confused\n- Rendering happens multiple times for the same content\n\nWould you like me to:\n1. Create a custom tmux script to try reproducing your exact scenario?\n2. Examine the VirtualizedList scroll anchoring logic more closely for potential bugs?\n3. Check if there are any other recent scroll-related issues or PRs?\n\nLet me know what additional details you can provide and I'll dig deeper into this specific manifestation of the scroll issue.","createdAt":"2025-12-23T15:19:58Z","url":"https://github.com/vybestack/llxprt-code/issues/951#issuecomment-3687004434"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for where \"history-\" keys are being generated\necho \"=== Searching for 'history-' key generation ===\"\nrg -n \"history-\" -g \"*.tsx\" -g \"*.ts\" -g \"*.jsx\" -g \"*.js\" -C5\n\necho -e \"\\n=== Searching for key prop assignments ===\"\nrg -n \"key=.*history\" -g \"*.tsx\" -g \"*.jsx\" -C3\n```\n\nLength of output: 5827\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the DefaultAppLayout.tsx file to understand the context\necho \"=== DefaultAppLayout.tsx - Full content ===\"\ncat packages/cli/src/ui/layouts/DefaultAppLayout.tsx\n\necho -e \"\\n=== Search for where history comes from ===\"\nrg -n \"const.*history\\s*=\" -g \"*.tsx\" -g \"*.ts\" packages/cli/src/ui/layouts/ -C5\n```\n\nLength of output: 21918\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for where history items get their IDs and how they're created\necho \"=== Searching for history item ID assignment ===\"\nrg -n \"\\.id\\s*=\" -g \"*.tsx\" -g \"*.ts\" packages/cli/src/ui/ -C3\n\necho -e \"\\n=== Searching for addItem or history management ===\"\nrg -n \"addItem|addToHistory|pushHistory\" -g \"*.tsx\" -g \"*.ts\" packages/cli/src/ui/ -C3\n\necho -e \"\\n=== Looking at UIStateContext ===\"\ncat packages/cli/src/ui/contexts/UIStateContext.tsx | head -200\n```\n\nLength of output: 253085\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver Thanks โ€” I tracked the duplicate key down to how history item IDs are generated and used.\n\nWhat we know so far\n- The duplicate comes from DefaultAppLayout using keys like history-${h.id}. Line 233 in packages/cli/src/ui/layouts/DefaultAppLayout.tsx.\n- History itemsโ€™ id values are created in packages/cli/src/ui/hooks/useHistoryManager.ts via getNextMessageId(baseTimestamp) when addItem(...) is called.\n- Many places call addItem multiple times with the same base timestamp (e.g., userMessageTimestamp = Date.now() used for several items in one flow). If getNextMessageId doesnโ€™t guarantee uniqueness per call, React will see repeated keys and youโ€™ll get both the x7042 warning and the visual โ€œrepeats/stuck scrollโ€ you observed.\n\nWhat I want to confirm\n- The exact implementation of getNextMessageId in packages/cli/src/ui/hooks/useHistoryManager.ts (or wherever itโ€™s defined). If itโ€™s just returning baseTimestamp (or otherwise not disambiguating same-ms calls), that explains the collisions like history-1766506849037.\n\nProposed fix direction\n- Make getNextMessageId strictly monotonic and unique:\n - Maintain module-level lastTimestamp and perTimestampCounter.\n - If baseTimestamp === lastTimestamp, increment a suffix counter; else reset counter.\n - Compute id = baseTimestamp * 1000 + counter (or use a 32-bit counter shifted into lower bits).\n - Alternatively, keep an ever-increasing monotonic integer independent of wall time, but still seed with baseTimestamp to preserve ordering across restored history.\n\n- Also harden load/restore paths:\n - In slashCommandProcessor.ts Lines 500โ€“505, we currently add restored items with addItem(item, index). Using index as a base timestamp risks collisions across sessions. For restored history, prefer the original item.id if present, or pass the original message.timestamp.getTime() as baseTimestamp. If neither exists, use a unique generator that doesnโ€™t reuse small indices.\n\nIf you want, I can:\n- Open a focused PR to patch getNextMessageId to be collision-safe and update the restore path to avoid index-based bases.\n- Add a regression test where we call addItem twice with the same baseTimestamp and assert unique ids, and a streaming test that adds N items within a single millisecond.\n\nWould you like me to proceed with a small PR for those two fixes and a test?","createdAt":"2025-12-23T16:24:46Z","url":"https://github.com/vybestack/llxprt-code/issues/951#issuecomment-3687224660"}],"number":951,"title":"scroll area can get borked"} diff --git a/project-plans/20251230fixes/issues/issue-952.json b/project-plans/20251230fixes/issues/issue-952.json new file mode 100644 index 000000000..678cdaa38 --- /dev/null +++ b/project-plans/20251230fixes/issues/issue-952.json @@ -0,0 +1 @@ +{"body":"### What happened?\n\nโœ“ ReadFile integration-tests/test-helper.ts\n\n โŠท ReadFile ...integration-tests//json-output.test.ts\n\n Running: ...integration-tests//json-output.test.ts\n\n โœ“ SearchText 'output-format|outputFormat|--output-format|--outputFormat|json-output' in integration-tests/**/*.ts within ./\n\n No matches found\n\nThe read file has been going on for a while and normally is instant. So far I've only seen this with gpt-5.2. so either it is some tool thing with Responses or is a timing issue that gpt seems to hit more often\n\n### What did you expect to happen?\n\nnot hang\n\n### Client information\n\nv0.7.0-nightly.251223.e40c645db\n\n### Login information\n\n_No response_\n\n### Anything else we need to know?\n\n_No response_","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Possible Duplicates**\n- https://github.com/vybestack/llxprt-code/issues/152\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/104\n- https://github.com/vybestack/llxprt-code/issues/951\n- https://github.com/vybestack/llxprt-code/issues/84\n- https://github.com/vybestack/llxprt-code/issues/231\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n- [dipping5115](https://github.com/dipping5115)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit) or [schedule a call](https://calendly.com/parth-coderabbit/issues-feedback)!","createdAt":"2025-12-23T21:12:38Z","url":"https://github.com/vybestack/llxprt-code/issues/952#issuecomment-3687979076"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\n# First, let's find files related to tool execution and ReadFile\necho \"=== Finding tool execution files ===\"\nfd -e ts -e js | rg -i \"(tool|execute)\" | head -30\n\necho -e \"\\n=== Finding ReadFile implementation ===\"\nrg -n \"class ReadFile|ReadFile.*Tool|readfile\" --type=ts -i | head -20\n\necho -e \"\\n=== Finding parallel tool execution logic ===\"\nrg -n \"parallel.*tool|executeTools|executeTool\" --type=ts -C3 | head -50\n```\n\nLength of output: 7388\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Finding ReadFile tool class ===\"\nrg -n \"class.*ReadFile|export.*ReadFile\" --type=ts -A5 | head -40\n\necho -e \"\\n=== Finding tool execution in scheduler ===\"\ncat packages/ui/src/hooks/useToolScheduler.ts\n\necho -e \"\\n=== Looking for tool execution completion logic ===\"\nrg -n \"toolComplete|tool.*complete|onComplete\" --type=ts -C3 | head -60\n```\n\nLength of output: 19478\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Finding CoreToolScheduler implementation ===\"\nfd \"CoreToolScheduler.ts\" --type f\n\necho -e \"\\n=== Reading CoreToolScheduler ===\"\nrg -n \"class CoreToolScheduler\" --type=ts -A10\n\necho -e \"\\n=== Looking for schedule method and execution logic ===\"\nast-grep --pattern 'class CoreToolScheduler {\n $$$\n schedule($$$) {\n $$$\n }\n $$$\n}'\n```\n\nLength of output: 1149\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Reading full CoreToolScheduler file ===\"\ncat packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 51027\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Checking recent git history for parallel tool changes ===\"\ngit log --oneline --all -20 --grep=\"parallel\\|tool\\|scheduler\" -- packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Checking recent commits to coreToolScheduler.ts ===\"\ngit log --oneline -10 -- packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Looking for recent changes to publishBufferedResults ===\"\ngit log -p -5 --all -- packages/core/src/core/coreToolScheduler.ts | grep -A30 -B5 \"publishBufferedResults\"\n```\n\nLength of output: 2438\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Getting details of the buffered parallel tool execution commit ===\"\ngit show 9039042c --stat\n\necho -e \"\\n=== Getting the actual changes to publishBufferedResults ===\"\ngit show 9039042c -- packages/core/src/core/coreToolScheduler.ts | grep -A50 \"publishBufferedResults\"\n\necho -e \"\\n=== Checking if there's a lock or guard mechanism ===\"\nrg -n \"isPublishing|publishLock|publishing\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 5855\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Looking at the test file for parallel execution tests ===\"\nrg -n \"parallel.*execution|buffered.*result\" packages/core/src/core/coreToolScheduler.test.ts -A10 | head -80\n\necho -e \"\\n=== Checking if tests cover concurrent completion ===\"\nrg -n \"concurrent|race|timing\" packages/core/src/core/coreToolScheduler.test.ts -C3\n```\n\nLength of output: 1951\n\n---\n\n๐Ÿ Script executed:\n````\n#!/bin/bash\n\necho \"=== Creating a minimal reproduction test case ===\"\ncat \u003e /tmp/race_condition_demo.md \u003c\u003c 'EOF'\n# Race Condition in publishBufferedResults\n\n## The Problem\n\nWhen multiple tools complete in parallel, they each call `publishBufferedResults()` concurrently.\nThe function filters `this.toolCalls` each time it runs:\n\n```typescript\nconst callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n);\n```\n\nAs tools get published, their status changes from 'executing' to 'success', so they get\nfiltered OUT on subsequent calls. But `nextPublishIndex` is shared and assumes a stable array.\n\n## Example Scenario (3 parallel tools: A, B, C)\n\n### Initial state:\n- All 3 tools scheduled and start executing\n- nextPublishIndex = 0\n- toolCalls = [A(executing), B(executing), C(executing)]\n\n### Tool B completes first (fastest):\n- Buffers result, calls publishBufferedResults()\n- callsInOrder = [A, B, C] (all still 'executing')\n- nextPublishIndex = 0\n- Checks A at index 0: not ready, breaks\n- nextPublishIndex stays 0\n\n### Tool A completes second:\n- Buffers result, calls publishBufferedResults()\n- callsInOrder = [A, B, C] (might be [A, B, C] or some may be 'success')\n- nextPublishIndex = 0\n- Publishes A at index 0 โ†’ status becomes 'success', nextPublishIndex = 1\n- Publishes B at index 1 โ†’ status becomes 'success', nextPublishIndex = 2\n- Checks C at index 2: not ready, breaks\n\n### Tool C completes last:\n- Buffers result, calls publishBufferedResults()\n- callsInOrder = filter(status === 'scheduled' || 'executing')\n - A is 'success' โ†’ FILTERED OUT\n - B is 'success' โ†’ FILTERED OUT \n - C is 'executing' โ†’ INCLUDED\n- callsInOrder = [C] (length = 1)\n- nextPublishIndex = 2 (from previous calls)\n- while (2 \u003c 1) โ†’ FALSE, loop never runs\n- **C's result is never published!**\n\nThe tool appears to hang because the result sits in pendingResults forever.\n\n## Root Cause\n\nThe array `callsInOrder` is recalculated on each call based on current status,\nbut `nextPublishIndex` assumes a stable array. When already-published tools\nget filtered out, the array shrinks, making nextPublishIndex point beyond\nthe array bounds.\nEOF\n\ncat /tmp/race_condition_demo.md\n\necho -e \"\\n=== Verifying the filtering logic in the actual code ===\"\nrg -n \"callsInOrder.*filter\" packages/core/src/core/coreToolScheduler.ts -A2 -B2\n````\n\nLength of output: 2404\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver I found the root cause of the hanging parallel tool calls! It's a **race condition** in the `publishBufferedResults()` method introduced in commit `9039042c`.\n\n## The Problem\n\nWhen multiple tools execute in parallel and complete around the same time, they each call `publishBufferedResults()` concurrently. The method recalculates its working array on every invocation:\n\n```typescript\n// Line 1356-1358 in coreToolScheduler.ts\nconst callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n);\n```\n\nAs tools get published, their status changes from `'executing'` โ†’ `'success'`, so they're **filtered out** on subsequent calls. But `nextPublishIndex` is shared state that assumes a stable array.\n\n## Race Condition Scenario\n\nLet's say 3 tools (A, B, C) execute in parallel:\n\n1. **All tools start executing**: `nextPublishIndex = 0`, all have status `'executing'`\n\n2. **Tool B completes first** (fastest):\n - Calls `publishBufferedResults()`\n - `callsInOrder = [A, B, C]` (all still executing)\n - Checks index 0 (Tool A): not ready yet, breaks\n - `nextPublishIndex` stays 0\n\n3. **Tool A completes second**:\n - Calls `publishBufferedResults()`\n - `callsInOrder = [A, B, C]`\n - Publishes A at index 0 โ†’ status becomes `'success'`, `nextPublishIndex = 1`\n - Publishes B at index 1 โ†’ status becomes `'success'`, `nextPublishIndex = 2`\n - Checks C at index 2: not ready, breaks\n\n4. **Tool C completes last**:\n - Calls `publishBufferedResults()`\n - `callsInOrder = filter(...)` โ†’ **A and B filtered out (status='success')**\n - `callsInOrder = [C]` (length = 1)\n - `while (2 \u003c 1)` โ†’ **FALSE, loop never runs!**\n - **Tool C's result never gets published** โŒ\n\nThe tool appears to hang because its result sits in `pendingResults` forever.\n\n## The Fix\n\nAdd a concurrency guard to ensure only one `publishBufferedResults()` executes at a time:\n\n```typescript\nprivate isPublishingResults = false;\n\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n // Guard against concurrent execution\n if (this.isPublishingResults) {\n return;\n }\n \n this.isPublishingResults = true;\n try {\n const callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n );\n\n // Publish results in original request order\n while (this.nextPublishIndex \u003c callsInOrder.length) {\n const expectedCall = callsInOrder[this.nextPublishIndex];\n const buffered = this.pendingResults.get(expectedCall.request.callId);\n\n if (!buffered) {\n // Next result not ready yet, stop publishing\n break;\n }\n\n // Publish this result\n await this.publishResult(buffered, signal);\n\n // Remove from buffer\n this.pendingResults.delete(buffered.callId);\n this.nextPublishIndex++;\n }\n\n // Check if all tools completed\n if (\n this.nextPublishIndex === callsInOrder.length \u0026\u0026\n callsInOrder.length \u003e 0\n ) {\n // Reset for next batch\n this.nextPublishIndex = 0;\n this.pendingResults.clear();\n }\n } finally {\n this.isPublishingResults = false;\n }\n}\n```\n\nThis ensures the filtered array doesn't shrink underneath the publishing loop, preventing the index from going out of bounds.\n\n## Why This Hits GPT-5.2 More Often\n\nThe issue is timing-dependent. GPT-5.2 likely has different response patterns or uses more parallel tool calls, hitting the race condition window more frequently than other models.\n\nWould you like me to open an issue for this, or would you prefer to create a fix PR directly?\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-23T21:15:15Z","url":"https://github.com/vybestack/llxprt-code/issues/952#issuecomment-3687985254"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\n# Fetch details of issue #957\necho \"=== Fetching issue #957 details ===\"\ngh issue view 957 --repo vybestack/llxprt-code --json number,title,body,state,createdAt,author,comments --jq '{number: .number, title: .title, body: .body, state: .state, createdAt: .createdAt, author: .author.login, comments: [.comments[] | {author: .author.login, body: .body, createdAt: .createdAt}]}'\n```\n\nLength of output: 64889\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver Yes, absolutely! **These are the same bug** โ€” just different symptoms of the race condition in `publishBufferedResults()`.\n\n## The Connection\n\n**Root Cause (identified in #957):**\nThe `publishBufferedResults()` method is called concurrently by multiple tool completion handlers when parallel tools finish simultaneously, causing a race condition where `nextPublishIndex` can be corrupted and tools hang in 'executing' state forever.\n\n**How they manifest differently:**\n\n**Issue #957** - User sees the **queue error**:\n- Tools hang in 'executing' state due to race\n- Subsequent tool calls queue up waiting\n- User hits Escape to cancel\n- Queued items abort with \"Tool call cancelled while in queue\"\n\n**Issue #952** - User sees the **hang itself**:\n- ReadFile (or any tool) appears to hang\n- Actually completed successfully, but status never updated from 'executing' to 'success'\n- No queue error visible yet because user hasn't tried to cancel\n- Just looks like the tool is \"taking forever for no reason\"\n\n## Why #952 Happens More with GPT-5.2\n\nGPT-5.2 likely:\n- Makes more parallel tool calls\n- Has faster response times, causing tools to complete closer together\n- Hits the race condition window more frequently\n\nThe race happens when tools finish within microseconds of each other (very common with fast operations like ReadFile on SSD), and both simultaneously call `publishBufferedResults()` from their completion handlers (line 1510 in coreToolScheduler.ts).\n\n## The Fix\n\nAs identified in the #957 discussion, `publishBufferedResults()` needs to be made reentrant-safe with a mutex/lock to serialize concurrent calls. This will fix both:\n- #952: Tools won't hang anymore โœ“\n- #957: No more queue buildup โ†’ no more \"cancelled while in queue\" errors โœ“\n\nWant me to open a single PR that fixes both issues by adding the mutex to `publishBufferedResults()`?\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n---\n\n```\nLearnt from: acoliver\nRepo: vybestack/llxprt-code PR: 0\nFile: :0-0\nTimestamp: 2025-12-26T14:17:41.974Z\nLearning: In packages/core/src/core/coreToolScheduler.ts, the `publishBufferedResults()` method (line 1355) is called concurrently by multiple tool completion handlers (line 1510 in each tool's `.then()` callback) when parallel tools finish simultaneously. This causes a race condition where `nextPublishIndex` can be corrupted and tools can hang in 'executing' state forever, blocking the scheduler and causing subsequent tool calls to queue indefinitely. The method must be made reentrant-safe using a mutex/lock pattern to serialize concurrent calls.\n```\n\n---\n\n```\nLearnt from: acoliver\nRepo: vybestack/llxprt-code PR: 0\nFile: :0-0\nTimestamp: 2025-12-18T14:06:22.557Z\nLearning: OpenAIResponsesProvider (packages/core/src/providers/openai-responses/OpenAIResponsesProvider.ts) currently bypasses the ephemeral truncation system by using direct `JSON.stringify(toolResponseBlock.result)` and needs to be updated to support ephemeral settings like the other providers.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-29T19:19:26Z","url":"https://github.com/vybestack/llxprt-code/issues/952#issuecomment-3697309301"}],"number":952,"title":"sometimes parallel tool calls are hanging"} diff --git a/project-plans/20251230fixes/issues/issue-957.json b/project-plans/20251230fixes/issues/issue-957.json new file mode 100644 index 000000000..cd41d9b45 --- /dev/null +++ b/project-plans/20251230fixes/issues/issue-957.json @@ -0,0 +1 @@ +{"body":"### What happened?\n\n Reason: Error: Tool call cancelled while in queue.\n Stack trace:\n Error: Tool call cancelled while in queue.\n at AbortSignal.abortHandler (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/node_modules/@vybestack/llxprt-code-core/dist/src/core/coreToolScheduler.js:485:32)\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:845:20)\n at AbortSignal.dispatchEvent (node:internal/event_target:778:26)\n at runAbort (node:internal/abort_controller:488:10)\n at abortSignal (node:internal/abort_controller:459:3)\n at AbortController.abort (node:internal/abort_controller:507:5)\n at Object.current (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/hooks/useGeminiStream.js:233:37)\n at file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/hooks/useGeminiStream.js:268:33\n at broadcast (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/contexts/KeypressContext.js:456:17)\n at processKittyBuffer (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/contexts/KeypressContext.js:659:21)\n\nthis was on something that was taking awhile for now reason\n\n### What did you expect to happen?\n\nnot get stuck\n\n### Client information\n\n Reason: Error: Tool call cancelled while in queue.\n Stack trace:\n Error: Tool call cancelled while in queue.\n at AbortSignal.abortHandler (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/node_modules/@vybestack/llxprt-code-core/dist/src/core/coreToolScheduler.js:485:32)\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:845:20)\n at AbortSignal.dispatchEvent (node:internal/event_target:778:26)\n at runAbort (node:internal/abort_controller:488:10)\n at abortSignal (node:internal/abort_controller:459:3)\n at AbortController.abort (node:internal/abort_controller:507:5)\n at Object.current (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/hooks/useGeminiStream.js:233:37)\n at file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/hooks/useGeminiStream.js:268:33\n at broadcast (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/contexts/KeypressContext.js:456:17)\n at processKittyBuffer (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/contexts/KeypressContext.js:659:21)\n\n### Login information\n\n_No response_\n\n### Anything else we need to know?\n\n_No response_","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Possible Duplicates**\n- https://github.com/vybestack/llxprt-code/issues/61\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/101\n- https://github.com/vybestack/llxprt-code/issues/619\n- https://github.com/vybestack/llxprt-code/issues/84\n- https://github.com/vybestack/llxprt-code/issues/152\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit)!","createdAt":"2025-12-26T13:44:12Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692889563"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find and examine the coreToolScheduler file\nfd -t f \"coreToolScheduler\" --exec cat {}\n```\n\nLength of output: 137622\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find and examine the useGeminiStream file\nfd -t f \"useGeminiStream\" --exec cat {}\n```\n\nLength of output: 249583\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for any concurrency or queue limits in the scheduler\nrg -n \"maxConcurrent|queue.*limit|MAX_CONCURRENT\" --type ts --type js\n```\n\nLength of output: 562\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for where tool calls are enqueued and dequeued\nrg -nC5 \"cancelled while in queue\" --type ts --type js\n```\n\nLength of output: 884\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver thanks โ€” I dug in and have a concrete read on whatโ€™s happening and how to fix it.\n\nWhatโ€™s happening\n- When a tool is still executing or awaiting approval, CoreToolScheduler queues any new tool requests. Confirmed in packages/core/src/core/coreToolScheduler.ts schedule(): if isRunning() or isScheduling, the request is pushed to requestQueue with an AbortSignal listener; on abort it rejects with Error('Tool call cancelled while in queue.'). Lines ~812โ€“821 from the repo scan.\n- The queue only drains after all current tools reach a terminal state and after onAllToolCallsComplete finishes (see checkAndNotifyCompletion()).\n- In the UI, pressing Escape triggers cancelOngoingRequest in useGeminiStream, which currently does abortControllerRef.current.abort() first and then cancelAllToolCalls(). That ordering fires the queueโ€™s abort-handler, so queued items reject with โ€œTool call cancelled while in queue,โ€ which is the error you saw. This matches your stack (useGeminiStream.js ~233/268).\n\nWhy it looked โ€œstuckโ€\n- The scheduler intentionally runs one batch at a time; anything scheduled while another batch is executing/awaiting approval is queued. If the active tool takes a while (or if finalization work in onAllToolCallsComplete is slow), queued items sit and appear โ€œstuck.โ€ Hitting Escape then cancels them via the queueโ€™s abort path, producing the confusing message rather than a clean โ€œuser cancelled.โ€\n\nProposed fixes (low-risk)\n1) UI: swap cancel order to flush the queue before aborting the stream\n- This makes queued items get cancelled by scheduler.cancelAll() (โ€œby userโ€) instead of the queue abort-handler (โ€œwhile in queueโ€), and avoids the confusing error.\n\nFile: packages/ui/src/ui/hooks/useGeminiStream.ts (or .js, same spot as your stack)\n```ts\n// Before\nconst cancelOngoingRequest = useCallback(() =\u003e {\n if (streamingState !== StreamingState.Responding) return;\n if (turnCancelledRef.current) return;\n turnCancelledRef.current = true;\n abortControllerRef.current?.abort();\n if (abortControllerRef.current) {\n // Synchronously clear the tool queue and mark active tools as cancelled in the UI.\n cancelAllToolCalls();\n }\n // ...\n}, [/* ... */]);\n\n// After (swap order)\nconst cancelOngoingRequest = useCallback(() =\u003e {\n if (streamingState !== StreamingState.Responding) return;\n if (turnCancelledRef.current) return;\n turnCancelledRef.current = true;\n if (abortControllerRef.current) {\n // 1) Flush scheduler: cancels queued + active tool calls deterministically\n cancelAllToolCalls();\n // 2) Then abort model stream to stop further events\n abortControllerRef.current.abort();\n } else {\n // Fallback: at least abort stream\n // (kept for safety; not strictly necessary if ref is always set while responding)\n }\n // ...\n}, [/* ... */]);\n```\n\n2) Core: clarify the queue abort message (optional but helpful)\n- If the abort path still triggers (e.g., external abort), make it clearer itโ€™s a user-cancel during waiting.\n\nFile: packages/core/src/core/coreToolScheduler.ts (schedule โ†’ abortHandler)\n```ts\n// Before\nreject(new Error('Tool call cancelled while in queue.'));\n\n// After\nreject(new Error('Tool call cancelled by user while waiting to run.'));\n```\n\n3) Instrumentation: make queue/finalization causes visible (optional)\n- Log why we queued and how long finalization takes, to spot โ€œtaking awhile for no reasonโ€ cases.\n\nFile: packages/core/src/core/coreToolScheduler.ts (inside schedule() where we push to queue)\n```ts\nif (toolSchedulerLogger.enabled) {\n toolSchedulerLogger.debug(() =\u003e\n `Queued tool call(s): reason=${this.isRunning() ? 'active_calls' : 'scheduling'}; ` +\n `active_statuses=${this.toolCalls.map(c =\u003e c.status).join(',')}; queue_len=${this.requestQueue.length + 1}`\n );\n}\n```\n\nFile: packages/core/src/core/coreToolScheduler.ts (in checkAndNotifyCompletion())\n```ts\nif (this.onAllToolCallsComplete) {\n this.isFinalizingToolCalls = true;\n const t0 = Date.now();\n await this.onAllToolCallsComplete(completedCalls);\n const dt = Date.now() - t0;\n if (toolSchedulerLogger.enabled \u0026\u0026 dt \u003e 500) {\n toolSchedulerLogger.debug(() =\u003e `onAllToolCallsComplete took ${dt}ms`);\n }\n this.isFinalizingToolCalls = false;\n}\n```\n\nWhy this fixes your symptom\n- With the UI ordering change, Escape cleanly cancels both active and queued tool calls via cancelAll(), so youโ€™ll no longer see โ€œcancelled while in queue.โ€ Youโ€™ll get the existing โ€œRequest cancelled.โ€ info item in the UI, and the queue is flushed deterministically.\n- The optional message tweak and logs make future diagnosis much clearer if something truly sits in the queue longer than expected.\n\nNotes\n- Iโ€™m keeping our previous learning in mind that each tool execution must emit exactly one final ToolResponseBlock; none of the above changes alter that behavior.\n- Concurrency: by design, we batch and run tools in parallel within a batch, but we donโ€™t accept new batches until the current one finalizes. If you want us to explore allowing โ€œlate joinโ€ into the current batch for client-initiated tools, I can spec that separately.\n\nWant me to open a small follow-up PR with (1) the UI cancel ordering change, and (2) the improved core message + debug logs? If you prefer, I can also open a tracking issue for the optional instrumentation.\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T13:46:40Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692892141"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n@acoliver you're absolutely right โ€” if the queue is filling up, it means something ahead of it never finished. Let me dig into what could cause a tool call to hang and not reach a terminal state.\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 1. Find where tool call states are set/transitioned in the scheduler\necho \"=== Tool call state transitions in scheduler ===\"\nrg -nC5 \"\\.status\\s*=\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Looking for completion detection logic ===\"\nrg -nC8 \"isRunning|isComplete|terminal.*state\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 9081\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 2. Check how tools actually execute and signal completion\necho \"=== Tool execution entry points ===\"\nrg -nC5 \"executeTool|runTool|toolExecution\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Error handling in tool execution ===\"\nrg -nC5 \"catch.*tool|tool.*error\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 414\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 3. Look for timeout mechanisms or watchdogs\necho \"=== Timeout or watchdog mechanisms ===\"\nrg -nC3 \"timeout|watchdog|MAX_EXECUTION_TIME\" packages/core/src/core/\n\necho -e \"\\n=== Abort signal handling in tool execution ===\"\nrg -nC5 \"abortSignal.*execute|signal.*abort.*tool\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2435\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 4. Check the actual tool implementations for potential hang points\necho \"=== Shell command tool implementation ===\"\nfd -t f \"shellCommandTool\" --exec head -100 {}\n\necho -e \"\\n=== Looking for tools that might not resolve ===\"\nrg -nC3 \"async.*execute.*\\(\" packages/core/src/tools/ -A 20 | head -200\n```\n\nLength of output: 14286\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 5. Look for scenarios where state updates might be missed\necho \"=== Tool call status update after execution ===\"\nrg -nC8 \"await.*execute|\\.then.*status\" packages/core/src/core/coreToolScheduler.ts | head -150\n```\n\nLength of output: 47\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for where tool status transitions from scheduled -\u003e executing -\u003e terminal\necho \"=== Where tools get executed (invocation.execute calls) ===\"\nrg -nC10 \"invocation\\.execute\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 59\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find the full execution flow including status transitions\necho \"=== attemptExecutionOfScheduledCalls full implementation ===\"\nrg -nA80 \"private attemptExecutionOfScheduledCalls\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 3262\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for confirmation/approval flows that might block\necho \"=== Confirmation handling that could block ===\"\nrg -nC10 \"requiresConfirmation|ideConfirmation\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1118\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are any tools that spawn processes or have async operations without proper error handling\necho \"=== Shell command execution that might hang ===\"\nrg -nC10 \"spawn|exec|child_process\" packages/core/src/tools/ | head -300\n```\n\nLength of output: 21741\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for MCP tool execution which might hang\necho \"=== MCP tool invocation ===\"\nrg -nC10 \"class.*McpTool|execute.*mcp\" packages/core/src/tools/\n```\n\nLength of output: 27\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for MCP tool implementation - might be in a different file\necho \"=== Finding MCP tool files ===\"\nfd -t f \"mcp.*tool\" packages/core/src/tools/\nfd -t f \"mcp-tool\" packages/core/src/tools/\n```\n\nLength of output: 184\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get the full DiscoveredTool execute implementation to see if close event is handled\necho \"=== Full DiscoveredTool execute method ===\"\nrg -nA50 \"class DiscoveredTool\" packages/core/src/tools/tool-registry.ts | head -100\n```\n\nLength of output: 3572\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if the spawned process properly handles all exit scenarios\necho \"=== Checking spawn promise resolution in DiscoveredTool ===\"\nsed -n '71,130p' packages/core/src/tools/tool-registry.ts\n```\n\nLength of output: 1782\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for the catch block completion in attemptExecutionOfScheduledCalls\necho \"=== Full catch block in attemptExecutionOfScheduledCalls ===\"\nrg -nA20 \"\\.catch\\(async \\(executionError\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 704\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there's any scenario where execute() doesn't complete\necho \"=== Looking for unhandled promise scenarios ===\"\nrg -nC5 \"new Promise.*resolve.*reject\" packages/core/src/tools/tool-registry.ts\n```\n\nLength of output: 405\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check the MCP tool execute method for similar issues\necho \"=== MCP Tool execute implementation ===\"\nrg -nA60 \"async execute\" packages/core/src/tools/mcp-tool.ts | head -150\n```\n\nLength of output: 2112\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for abort signal handling in the spawned child process\necho \"=== Check if abort signal is used anywhere in DiscoveredTool ===\"\nrg -nC10 \"signal.*abort|abort.*signal\" packages/core/src/tools/tool-registry.ts | head -100\n```\n\nLength of output: 64\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if other tools properly handle abort signals\necho \"=== How other tools handle abort signal ===\"\nrg -nB5 -A10 \"if.*signal.*aborted|signal\\.aborted\" packages/core/src/tools/ | head -150\n```\n\nLength of output: 9726\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver ah, you're absolutely right! I found it. The **actual** hang is in the `DiscoveredTool` implementation (custom project tools discovered via your tool discovery command). Let me show you:\n\n## Root Cause: DiscoveredTool doesn't respect abort signals\n\n**File: `packages/core/src/tools/tool-registry.ts` (lines 71-87)**\n\nThe `DiscoveredTool.execute()` method spawns a child process but **never wires up the abort signal**:\n\n```typescript\nasync execute(\n params: ToolParams,\n _signal: AbortSignal, // โ† Named with underscore = not used!\n _updateOutput?: (output: string) =\u003e void,\n): Promise\u003cToolResult\u003e {\n const callCommand = this.config.getToolCallCommand()!;\n const child = spawn(callCommand, [this.name]);\n child.stdin.write(JSON.stringify(params));\n child.stdin.end();\n\n // ... collect stdout/stderr ...\n\n await new Promise\u003cvoid\u003e((resolve) =\u003e { // โ† Only resolve, no abort path!\n // ... setup listeners for stdout, stderr, error, close ...\n child.on('close', onClose); // โ† Only resolves when child exits\n });\n```\n\n**What happens when a discovered tool hangs or runs for a long time:**\n1. The spawned child process never exits (or takes forever)\n2. The `child.on('close')` event never fires\n3. The Promise never resolves\n4. Tool status stays `'executing'` forever\n5. `scheduler.isRunning()` returns `true` indefinitely\n6. All subsequent tool calls queue up waiting\n7. User hits Escape โ†’ queue items abort with \"cancelled while in queue\"\n\n**Contrast with other tools that work correctly:**\n- **MCP tool** (lines 142-168 in mcp-tool.ts): checks `signal.aborted` upfront, races promise with abort listener, cleans up on abort\n- **Shell tool** (line 237 in shell.ts): checks `signal.aborted` before spawning, adds abort listener to kill process\n- **Task tool** (lines 196, 247, 259, 287): checks `signal.aborted` at multiple points, wires abort to kill child agents\n\n## The Fix\n\nWire the abort signal to kill the child process if it's cancelled:\n\n**File: `packages/core/src/tools/tool-registry.ts` (in `DiscoveredTool.execute`)**\n```typescript\nasync execute(\n params: ToolParams,\n signal: AbortSignal, // Remove underscore\n _updateOutput?: (output: string) =\u003e void,\n): Promise\u003cToolResult\u003e {\n // Check abort before spawning\n if (signal.aborted) {\n return {\n llmContent: 'Tool call was cancelled before execution.',\n returnDisplay: 'Tool cancelled.',\n error: {\n message: 'Tool call cancelled',\n type: ToolErrorType.TOOL_CALL_CANCELLED,\n },\n };\n }\n\n const callCommand = this.config.getToolCallCommand()!;\n const child = spawn(callCommand, [this.name]);\n child.stdin.write(JSON.stringify(params));\n child.stdin.end();\n\n let stdout = '';\n let stderr = '';\n let error: Error | null = null;\n let code: number | null = null;\n let childSignal: NodeJS.Signals | null = null;\n let aborted = false;\n\n await new Promise\u003cvoid\u003e((resolve, reject) =\u003e {\n // Abort handler: kill child and reject\n const onAbort = () =\u003e {\n aborted = true;\n cleanup();\n child.kill('SIGTERM');\n // Give it a moment, then force kill\n setTimeout(() =\u003e {\n if (!child.killed) {\n child.kill('SIGKILL');\n }\n }, 1000);\n const err = new Error('Tool call cancelled by user');\n err.name = 'AbortError';\n reject(err);\n };\n\n const onStdout = (data: Buffer) =\u003e {\n stdout += data?.toString();\n };\n\n const onStderr = (data: Buffer) =\u003e {\n stderr += data?.toString();\n };\n\n const onError = (err: Error) =\u003e {\n error = err;\n };\n\n const onClose = (\n _code: number | null,\n _signal: NodeJS.Signals | null,\n ) =\u003e {\n code = _code;\n childSignal = _signal;\n cleanup();\n if (aborted) {\n // Already rejected via onAbort\n return;\n }\n resolve();\n };\n\n const cleanup = () =\u003e {\n child.stdout.removeListener('data', onStdout);\n child.stderr.removeListener('data', onStderr);\n child.removeListener('error', onError);\n child.removeListener('close', onClose);\n signal.removeEventListener('abort', onAbort);\n if (child.connected) {\n child.disconnect();\n }\n };\n\n signal.addEventListener('abort', onAbort, { once: true });\n child.stdout.on('data', onStdout);\n child.stderr.on('data', onStderr);\n child.on('error', onError);\n child.on('close', onClose);\n });\n\n // If aborted, return cancelled result\n if (aborted || signal.aborted) {\n return {\n llmContent: 'Tool call was cancelled during execution.',\n returnDisplay: 'Tool cancelled.',\n error: {\n message: 'Tool call cancelled',\n type: ToolErrorType.TOOL_CALL_CANCELLED,\n },\n };\n }\n\n // ... rest of existing error/success handling ...\n```\n\n**Why this fixes the hang:**\n- When the tool is taking too long and user hits Escape, the abort signal fires\n- The `onAbort` handler immediately kills the child process (SIGTERM, then SIGKILL after 1s)\n- The Promise rejects with AbortError\n- The scheduler's catch block in `attemptExecutionOfScheduledCalls` (line 1518) handles it, transitions tool to 'cancelled'\n- Tool reaches terminal state, scheduler stops blocking, queue drains\n\n**Impact:**\n- Only affects \"discovered tools\" (custom project tools registered via `tool_discovery_command` config)\n- Built-in tools (shell, edit, grep, etc.) already handle abort properly\n- MCP tools already handle abort properly\n\nWant me to open a PR with this fix? This should resolve the actual root cause where tools hang indefinitely and block the scheduler.\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T13:51:01Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692896737"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find built-in tools that perform read operations\necho \"=== Finding built-in tools that perform reads ===\"\nfd -t f \"Tool.ts\" packages/core/src/tools/ --exec basename {}\n\necho -e \"\\n=== Looking for read_file tool implementation ===\"\nrg -nA60 \"class.*ReadFile|export.*readFile.*Tool\" packages/core/src/tools/\n\necho -e \"\\n=== Check grep tool implementation ===\"\nrg -nA60 \"class.*Grep|export.*grep.*Tool\" packages/core/src/tools/\n\necho -e \"\\n=== Check search tool implementation ===\"\nrg -nA60 \"class.*Search|export.*search.*Tool\" packages/core/src/tools/\n```\n\nLength of output: 54379\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check abort signal handling in file reading tools\necho \"=== Abort signal handling in read_file ===\"\nrg -nC10 \"signal.*abort|abort.*signal\" packages/core/src/tools/readFileTool.ts\n\necho -e \"\\n=== Check if fs operations can hang ===\"\nrg -nC5 \"fs\\.readFile|fs\\.read|createReadStream\" packages/core/src/tools/readFileTool.ts packages/core/src/tools/grepTool.ts packages/core/src/tools/searchTool.ts\n```\n\nLength of output: 397\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for timeout mechanisms in the core scheduler or tools\necho \"=== Timeout mechanisms ===\"\nrg -nC5 \"setTimeout|TIMEOUT|timeout\" packages/core/src/core/coreToolScheduler.ts | head -100\n\necho -e \"\\n=== Check if there's a tool execution timeout ===\"\nrg -nC3 \"MAX_EXECUTION|TOOL_TIMEOUT|execution.*timeout\" packages/core/src/\n```\n\nLength of output: 1119\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get full readFileTool implementation\necho \"=== Full readFileTool implementation ===\"\ncat packages/core/src/tools/readFileTool.ts\n```\n\nLength of output: 112\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check for any streaming or async operations that might not complete\necho \"=== Looking for potential hang points in built-in tools ===\"\nrg -nC8 \"new Promise|async.*function.*execute\" packages/core/src/tools/readFileTool.ts packages/core/src/tools/grepTool.ts packages/core/src/tools/searchTool.ts | head -200\n```\n\nLength of output: 293\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get the full read-file.ts implementation\necho \"=== Full read-file.ts execute method ===\"\nsed -n '156,290p' packages/core/src/tools/read-file.ts\n```\n\nLength of output: 4216\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find processSingleFileContent implementation - it's the actual file reading logic\necho \"=== Finding processSingleFileContent ===\"\nrg -nA80 \"export.*function processSingleFileContent|async function processSingleFileContent\" packages/core/src/tools/\n```\n\nLength of output: 40\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if any built-in tool execute methods accept but don't use abort signal\necho \"=== Tools that accept signal but may not use it ===\"\nrg -nB5 -A20 \"async execute\\(.*signal.*\\)\" packages/core/src/tools/read-file.ts packages/core/src/tools/ripGrep.ts packages/core/src/tools/grep.ts | head -150\n```\n\nLength of output: 3906\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for file system operations that could block\necho \"=== File system operations in read-file.ts ===\"\nrg -nC8 \"fs\\.|FileSystemService|readFile|statSync\" packages/core/src/tools/read-file.ts\n```\n\nLength of output: 633\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are any file operations that might hang on network mounts\necho \"=== Looking for synchronous fs operations ===\"\nrg -n \"\\.statSync|\\.readFileSync|\\.readdirSync\" packages/core/src/\n```\n\nLength of output: 10668\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find processSingleFileContent to see if it supports abort signals\necho \"=== Finding processSingleFileContent implementation ===\"\nrg -nB5 -A50 \"function processSingleFileContent\" packages/core/src/\n```\n\nLength of output: 4371\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check how the scheduler calls tool execute methods\necho \"=== How scheduler calls tool execute ===\"\nrg -nC10 \"invocation\\.execute\\(\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 40\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check ls tool execute signature\necho \"=== ls tool execute signature ===\"\nrg -nB5 -A10 \"async execute\" packages/core/src/tools/ls.ts\n```\n\nLength of output: 702\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are other tools missing abort signal\necho \"=== Finding all tool execute methods ===\"\nrg -n \"async execute\\(\" packages/core/src/tools/*.ts | grep -v \".test.ts\"\n```\n\nLength of output: 2278\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check how execute is called in the scheduler to understand the signature\necho \"=== Finding where tools are executed ===\"\nrg -nB3 -A15 \"\\.execute\\(.*signal\" packages/core/src/core/coreToolScheduler.ts | head -80\n```\n\nLength of output: 678\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check the base tool interface to understand the expected signature\necho \"=== Base tool invocation interface ===\"\nrg -nB5 -A10 \"abstract.*execute|execute.*AbortSignal\" packages/core/src/tools/tools.ts | head -100\n```\n\nLength of output: 1024\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get more context on processSingleFileContent to see if it does any long-running I/O\necho \"=== processSingleFileContent continued ===\"\nsed -n '313,450p' packages/core/src/utils/fileUtils.ts\n```\n\nLength of output: 5226\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver ah, you're absolutely right! I found it. The **`read_file` tool cannot be cancelled** because it doesn't accept or use the abort signal at all.\n\n## Root Cause: read_file tool ignores abort signals\n\n**File: `packages/core/src/tools/read-file.ts` (line 156)**\n\n```typescript\nasync execute(): Promise\u003cToolResult\u003e { // โ† Missing signal parameter!\n const result = await processSingleFileContent(\n this.getFilePath(),\n this.config.getTargetDir(),\n this.config.getFileSystemService(),\n this.params.offset,\n this.params.limit,\n );\n // ... rest of method\n}\n```\n\n**Compare with working tools:**\n- `grep.ts` line 133: `async execute(signal: AbortSignal): Promise\u003cToolResult\u003e`\n- `ripGrep.ts` line 109: `async execute(signal: AbortSignal): Promise\u003cToolResult\u003e`\n- `shell.ts` line 213: `async execute(signal: AbortSignal): Promise\u003cToolResult\u003e`\n\n**What happens when a read hangs:**\n1. File I/O blocks on: network mount, file lock, slow disk, large file on HDD, etc.\n2. `processSingleFileContent` calls `fs.promises.stat()` and `fs.promises.readFile()` which can hang indefinitely\n3. Tool status stays `'executing'` forever because the Promise never resolves\n4. Scheduler's `isRunning()` returns `true` indefinitely\n5. All new tool calls queue up waiting\n6. User hits Escape โ†’ queued items abort with \"cancelled while in queue\"\n\n**Why this wasn't caught:**\n- The base class defines: `abstract execute(signal: AbortSignal, updateOutput?: (output: string) =\u003e void): Promise\u003cTResult\u003e;`\n- But TypeScript allows subclasses to ignore unused parameters (it's \"contravariant\" on parameters)\n- So `read-file` compiles fine with `execute(): Promise\u003cToolResult\u003e`, but the signal is never received\n\n## The Fix\n\n**1) Update `ReadFileToolInvocation.execute()` to accept and check the signal:**\n\n```typescript\n// File: packages/core/src/tools/read-file.ts\nasync execute(signal: AbortSignal): Promise\u003cToolResult\u003e { // Add signal parameter\n // Check abort before starting\n if (signal.aborted) {\n return {\n llmContent: 'File read was cancelled before execution.',\n returnDisplay: 'Read cancelled.',\n error: {\n message: 'File read cancelled',\n type: ToolErrorType.TOOL_CALL_CANCELLED,\n },\n };\n }\n\n const result = await processSingleFileContent(\n this.getFilePath(),\n this.config.getTargetDir(),\n this.config.getFileSystemService(),\n this.params.offset,\n this.params.limit,\n signal, // Pass signal down\n );\n\n // Check abort after I/O\n if (signal.aborted) {\n return {\n llmContent: 'File read was cancelled during execution.',\n returnDisplay: 'Read cancelled.',\n error: {\n message: 'File read cancelled',\n type: ToolErrorType.TOOL_CALL_CANCELLED,\n },\n };\n }\n\n // ... rest of existing logic\n}\n```\n\n**2) Update `processSingleFileContent` to accept and respect the signal:**\n\n```typescript\n// File: packages/core/src/utils/fileUtils.ts\nexport async function processSingleFileContent(\n filePath: string,\n rootDirectory: string,\n fileSystemService: FileSystemService,\n offset?: number,\n limit?: number,\n signal?: AbortSignal, // Add signal parameter\n): Promise\u003cProcessedFileReadResult\u003e {\n try {\n // Check abort before I/O\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n\n if (!fs.existsSync(filePath)) {\n return {\n llmContent: 'Could not read file because no file was found at the specified path.',\n returnDisplay: 'File not found.',\n error: `File not found: ${filePath}`,\n errorType: ToolErrorType.FILE_NOT_FOUND,\n };\n }\n\n const stats = await fs.promises.stat(filePath);\n \n // Check abort after stat (which can hang on network mounts)\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n\n if (stats.isDirectory()) {\n // ... existing logic\n }\n\n const fileSizeInMB = stats.size / (1024 * 1024);\n if (fileSizeInMB \u003e 20) {\n // ... existing logic\n }\n\n const fileType = await detectFileType(filePath);\n const relativePathForDisplay = path\n .relative(rootDirectory, filePath)\n .replace(/\\\\/g, '/');\n\n switch (fileType) {\n case 'text': {\n // Check abort before expensive read\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n \n const content = await readFileWithEncoding(filePath);\n \n // Check abort after read (which can take time for large files)\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n \n // ... rest of text handling\n }\n case 'image':\n case 'pdf':\n case 'audio':\n case 'video': {\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n \n const contentBuffer = await fs.promises.readFile(filePath);\n \n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n \n // ... rest of binary handling\n }\n }\n } catch (error) {\n // ... existing error handling\n }\n}\n```\n\n**Other tools with the same issue:**\n- `read_line_range.ts` (line 144): also missing signal parameter\n- `insert_at_line.ts` (line 70): also missing signal\n- `delete_line_range.ts` (line 69): also missing signal\n- `list-subagents.ts` (line 63): also missing signal\n\n**Why this fixes your symptom:**\n- When `read_file` hangs on slow I/O, Escape now immediately cancels it via the abort signal\n- Tool reaches terminal state ('cancelled') instead of hanging in 'executing'\n- Scheduler stops blocking and processes/drains the queue\n- No more \"cancelled while in queue\" errors because the queue doesn't build up\n\nWant me to open a PR with:\n1. The `read_file` tool abort signal handling (highest priority since that's what you hit)\n2. The other file operation tools (`read_line_range`, `insert_at_line`, `delete_line_range`)\n3. Optional: Add a 30-second timeout as a safety net for tools that don't properly handle abort?\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T14:06:18Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692919676"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 1. Look at how isRunning() determines if tools are still executing\necho \"=== isRunning() implementation ===\"\nrg -nA15 \"private isRunning\\(\\)|public isRunning\\(\\)\" packages/core/src/core/coreToolScheduler.ts\n\n# 2. Look at how parallel tools are executed and how completion is tracked\necho -e \"\\n=== Parallel tool execution with Promise.allSettled ===\"\nrg -nB5 -A30 \"Promise\\.allSettled\" packages/core/src/core/coreToolScheduler.ts\n\n# 3. Look at checkAndNotifyCompletion for how it determines all tools are done\necho -e \"\\n=== checkAndNotifyCompletion implementation ===\"\nrg -nB5 -A40 \"private.*checkAndNotifyCompletion|async checkAndNotifyCompletion\" packages/core/src/core/coreToolScheduler.ts\n\n# 4. Look for where tool status is updated after execution completes\necho -e \"\\n=== Status updates after tool execution ===\"\nrg -nC8 \"toolCall\\.status = .*completed|toolCall\\.status = .*failed|toolCall\\.status = .*cancelled\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2248\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find where parallel execution happens and where status transitions occur\necho \"=== Looking for parallel execution logic ===\"\nrg -nB10 -A50 \"attemptExecutionOfScheduledCalls\" packages/core/src/core/coreToolScheduler.ts | head -200\n\necho -e \"\\n=== Find where status is set to completed/success/failed ===\"\nrg -n \"\\.status = 'success'|\\.status = 'completed'|\\.status = 'error'|\\.status = 'failed'\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Look for forEach or map operations that update tool call status ===\"\nrg -nC10 \"forEach.*toolCall|map.*toolCall.*status\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 7816\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get the full .then() and .catch() blocks for parallel tool execution\necho \"=== Full parallel execution promise handling ===\"\nsed -n '1494,1540p' packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== setStatusInternal implementation ===\"\nrg -nB3 -A20 \"private setStatusInternal\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Look for checkAndNotifyCompletion calls ===\"\nrg -n \"checkAndNotifyCompletion\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 3271\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look at the buffering and publishing logic that handles parallel completion\necho \"=== bufferResult implementation ===\"\nrg -nB5 -A30 \"private bufferResult\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== publishBufferedResults implementation ===\"\nrg -nB5 -A50 \"private.*publishBufferedResults|async publishBufferedResults\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== bufferError implementation ===\"\nrg -nB5 -A20 \"private bufferError\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 3589\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look at how the execution order is tracked - there's an executionIndices map\necho \"=== How execution indices are assigned ===\"\nsed -n '1460,1480p' packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== publishResult implementation that sets status ===\"\nrg -nA50 \"private async publishResult\" packages/core/src/core/coreToolScheduler.ts | head -100\n\necho -e \"\\n=== Check if nextPublishIndex is ever reset ===\"\nrg -n \"nextPublishIndex\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2911\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver you're absolutely right! I found the **actual bug** โ€” it's a race condition in the parallel tool completion logic that causes tools to get stuck in 'executing' status even after they finish.\n\n## Root Cause: Array index mismatch during parallel execution\n\n**The Bug (lines 1355-1386 in coreToolScheduler.ts):**\n\nWhen tools execute in parallel, each completion tries to publish results in order using `nextPublishIndex`. But there's a critical flaw:\n\n1. `publishBufferedResults()` filters `toolCalls` for status `'scheduled'` OR `'executing'` (line 1357)\n2. As results publish, tool status changes from `'executing'` โ†’ `'success'` (line 1435)\n3. The **next** call to `publishBufferedResults()` rebuilds `callsInOrder` with the filter\n4. Already-published tools (`'success'`) are **excluded** from the new array\n5. But `nextPublishIndex` is a **class field** that keeps incrementing\n6. Array shrinks, but the index keeps growing โ†’ **index out of bounds**\n\n**Concrete example with 3 parallel tools [A, B, C]:**\n\n```\nTool A completes first:\n callsInOrder = [A, B, C] // all 'executing'\n nextPublishIndex = 0\n โ†’ Publish A at callsInOrder[0] โœ“\n โ†’ A status = 'success'\n โ†’ nextPublishIndex = 1\n\nTool B completes:\n callsInOrder = [B, C] // A excluded (status='success')\n nextPublishIndex = 1 // still pointing at original index\n โ†’ expectedCall = callsInOrder[1] = C\n โ†’ C not ready yet, break\n โ†’ B is ready but at callsInOrder[0], never checked!\n\nTool C completes:\n callsInOrder = [C] // A and B excluded\n nextPublishIndex = 1 // still!\n โ†’ while (1 \u003c 1) is FALSE, loop never executes!\n โ†’ B and C never published\n\nResult:\n โœ— B and C remain in status='executing' forever\n โœ— isRunning() returns true (line 759: checks for 'executing')\n โœ— All new tool calls queue indefinitely\n โœ— User hits Escape โ†’ \"cancelled while in queue\"\n```\n\n**Why the reset never triggers:**\n\nThe reset condition checks `nextPublishIndex === callsInOrder.length`, but:\n- After A publishes: nextPublishIndex=1, callsInOrder.length=2 (B, C remain) โ†’ no reset\n- After B should publish but doesn't: nextPublishIndex=1, callsInOrder.length=1 (C remains) โ†’ no reset\n- After C should publish but doesn't: nextPublishIndex=1, callsInOrder.length=1 โ†’ no reset\n\n## The Fix\n\n**Option 1: Use the execution index from buffered results (cleanest)**\n\n```typescript\n// File: packages/core/src/core/coreToolScheduler.ts\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n // Get all calls that were executing in this batch (use original execution order)\n const allExecutingCalls = Array.from(this.pendingResults.values())\n .sort((a, b) =\u003e a.executionIndex - b.executionIndex);\n\n const totalInBatch = allExecutingCalls.length;\n if (totalInBatch === 0) return;\n\n // Publish results in execution order\n for (const buffered of allExecutingCalls) {\n const callId = buffered.callId;\n \n // Skip if already published\n const currentCall = this.toolCalls.find(c =\u003e c.request.callId === callId);\n if (!currentCall || currentCall.status !== 'executing') {\n continue;\n }\n\n // Publish this result\n await this.publishResult(buffered, signal);\n \n // Remove from buffer\n this.pendingResults.delete(callId);\n }\n\n // Reset for next batch if all published\n const anyStillExecuting = this.toolCalls.some(c =\u003e c.status === 'executing');\n if (!anyStillExecuting \u0026\u0026 this.pendingResults.size === 0) {\n this.nextPublishIndex = 0;\n }\n}\n```\n\n**Option 2: Track the original batch size (minimal change)**\n\n```typescript\n// File: packages/core/src/core/coreToolScheduler.ts\n\n// Add class field (around line 413):\nprivate originalBatchSize = 0;\n\n// In attemptExecutionOfScheduledCalls (around line 1464):\nconst callsToExecute = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled',\n);\n\n// Store original batch size before execution starts\nthis.originalBatchSize = callsToExecute.length;\n\n// In publishBufferedResults (line 1355):\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n // Don't filter - keep ALL calls to maintain stable indices\n const callsInOrder = this.toolCalls.slice(0, this.originalBatchSize);\n\n // Publish results in original request order\n while (this.nextPublishIndex \u003c callsInOrder.length) {\n const expectedCall = callsInOrder[this.nextPublishIndex];\n const buffered = this.pendingResults.get(expectedCall.request.callId);\n\n if (!buffered) {\n // Next result not ready yet, stop publishing\n break;\n }\n\n // Publish this result\n await this.publishResult(buffered, signal);\n\n // Remove from buffer\n this.pendingResults.delete(buffered.callId);\n this.nextPublishIndex++;\n }\n\n // Check if all tools completed\n if (\n this.nextPublishIndex === this.originalBatchSize \u0026\u0026\n this.originalBatchSize \u003e 0\n ) {\n // Reset for next batch\n this.nextPublishIndex = 0;\n this.originalBatchSize = 0;\n this.pendingResults.clear();\n }\n}\n```\n\n**Why this explains your symptom:**\n- Read tool completed successfully (I/O was fine, as you said)\n- But if 2+ tools were executing in parallel, the second one got stuck in 'executing' status despite finishing\n- Scheduler's `isRunning()` kept returning true\n- Queue built up waiting for the \"stuck\" tool\n- Escape triggered queue abort โ†’ \"cancelled while in queue\" errors\n\nWant me to open a PR with Option 1 (the cleanest fix)? This should completely resolve the parallel tool hang issue.\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n---\n\n```\nLearnt from: acoliver\nRepo: vybestack/llxprt-code PR: 0\nFile: :0-0\nTimestamp: 2025-12-18T14:06:22.557Z\nLearning: In the llxprt-code codebase, the `tool-output-max-items` ephemeral setting only applies to tools that return lists/collections (glob, grep, read-many-files), not to single-output tools like shell. Not all ephemerals are relevant to every tool.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T14:16:34Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692931897"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 1. Look at how parallel tools are actually executed and coordinated\necho \"=== How parallel tool execution works ===\"\nrg -nA30 \"Promise\\.all.*executionPromises|await Promise\\.all\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Check the state management during parallel execution ===\"\nrg -nB5 -A10 \"isRunning\\(\\)|isScheduling|isFinalizingToolCalls\" packages/core/src/core/coreToolScheduler.ts | head -100\n```\n\nLength of output: 3438\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 2. Look for race conditions in checkAndNotifyCompletion\necho \"=== Full checkAndNotifyCompletion implementation ===\"\nrg -nA50 \"private.*checkAndNotifyCompletion\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1844\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 3. Check how requestQueue is drained after completion\necho \"=== How requestQueue is processed ===\"\nrg -nB5 -A25 \"this\\.requestQueue\" packages/core/src/core/coreToolScheduler.ts | head -150\n```\n\nLength of output: 3799\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 4. Look for scenarios where a tool could get stuck in 'executing' state\necho \"=== All places where tool status is set to 'executing' ===\"\nrg -n \"status.*=.*'executing'|status.*=.*Executing\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== All places where tool status transitions FROM 'executing' ===\"\nrg -nB3 -A3 \"status.*=.*'(completed|error|cancelled)'\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1803\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 5. Check if there's any async/await issue in the execution flow\necho \"=== Full attemptExecutionOfScheduledCalls to check for Promise handling issues ===\"\nsed -n '1470,1600p' packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 4187\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 6. Look for any scenario where the scheduler could think it's running when it's not\necho \"=== How isRunning is implemented ===\"\nrg -nA15 \"private isRunning\\(\\)|public isRunning\\(\\)\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== How isScheduling is tracked ===\"\nrg -nC5 \"isScheduling\\s*=\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1744\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find publishBufferedResults to see if it triggers checkAndNotifyCompletion\necho \"=== publishBufferedResults implementation ===\"\nrg -nA40 \"private.*publishBufferedResults|async publishBufferedResults\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1419\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check bufferResult implementation to understand the flow\necho \"=== bufferResult and bufferError implementations ===\"\nrg -nA25 \"private bufferResult|private bufferError\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1306\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for all places where checkAndNotifyCompletion is called\necho \"=== All calls to checkAndNotifyCompletion ===\"\nrg -n \"checkAndNotifyCompletion\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 242\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there's a race condition in the status update + completion check flow\necho \"=== setStatusInternal implementation ===\"\nrg -nA20 \"private setStatusInternal\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1551\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look at the entire flow of how parallel execution is coordinated\necho \"=== Looking for Promise tracking or coordination ===\"\nrg -nC5 \"executionPromises|allSettled|Promise\\.all\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 52\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check the context around the other checkAndNotifyCompletion calls\necho \"=== Context around line 709 checkAndNotifyCompletion call ===\"\nsed -n '700,720p' packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Context around line 1692 checkAndNotifyCompletion call ===\"\nsed -n '1680,1700p' packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1170\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check the publishResult implementation to see what happens after status is set\necho \"=== Full publishResult implementation ===\"\nrg -nA60 \"private async publishResult\\(\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2048\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Let's trace the exact flow - find what calls publishResult and what happens after\necho \"=== Looking for what happens AFTER setStatusInternal in publishResult ===\"\nsed -n '1389,1470p' packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2489\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver **Found it!** The root cause is a **race condition in parallel tool execution** โ€” exactly what you suspected.\n\n## The Bug: publishBufferedResults is not reentrant-safe\n\n**File: `packages/core/src/core/coreToolScheduler.ts`**\n\nWhen multiple tools complete at nearly the same time (very common with fast operations like reading small files on SSD), they **both concurrently call** `publishBufferedResults()`:\n\n**Line 1497-1510** (in each tool's `.then()` handler):\n```typescript\ninvocation\n .execute(signal, liveOutputCallback)\n .then(async (toolResult: ToolResult) =\u003e {\n // Buffer the result\n this.bufferResult(callId, toolName, toolResult, scheduledCall, executionIndex);\n // โš ๏ธ Multiple tools call this concurrently!\n await this.publishBufferedResults(signal);\n })\n```\n\n**Line 1355-1387** (publishBufferedResults implementation):\n```typescript\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n const callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n );\n\n // โš ๏ธ NOT SYNCHRONIZED - multiple concurrent calls interleave here!\n while (this.nextPublishIndex \u003c callsInOrder.length) {\n const expectedCall = callsInOrder[this.nextPublishIndex];\n const buffered = this.pendingResults.get(expectedCall.request.callId);\n\n if (!buffered) {\n break; // โš ๏ธ Can break prematurely if racing with another call\n }\n\n await this.publishResult(buffered, signal); // โš ๏ธ Awaits, allowing interleaving\n this.pendingResults.delete(buffered.callId);\n this.nextPublishIndex++; // โš ๏ธ Not atomic!\n }\n \n // Reset logic (lines 1379-1386)\n}\n```\n\n## How the Race Causes Your Hang\n\n**Timeline with 2 parallel ReadFile tools:**\n\n1. **Both tools finish around the same time** (microseconds apart on fast SSD)\n2. **Tool 1's `.then()` handler starts:**\n - Buffers result 1 in `pendingResults`\n - Calls `await publishBufferedResults()`\n - Enters the while loop, sees `nextPublishIndex = 0`\n - Gets result 1 from `pendingResults`\n - Starts `await this.publishResult(...)` โ€” **suspends here**\n3. **Tool 2's `.then()` handler starts** (while #1 is awaiting):\n - Buffers result 2 in `pendingResults`\n - **Also** calls `await publishBufferedResults()`\n - Enters the **same** while loop, also sees `nextPublishIndex = 0`\n - Gets result 1 from `pendingResults` (still there!)\n - Tries to publish result #1 again, or...\n4. **Interleaving chaos:**\n - If 1 finishes first and increments `nextPublishIndex` to 1\n - Then 2 might see the change mid-loop\n - Or 2 might skip an index\n - Or 2 might see `buffered = undefined` when checking index 1 (if #1 hasn't buffered yet due to execution order)\n - Result: `break` exits early (line 1367)\n5. **The reset logic (lines 1379-1386) never triggers** because:\n - `nextPublishIndex` doesn't reach `callsInOrder.length` due to early break\n - `pendingResults` never clears\n6. **Tool 2 stays in `status = 'executing'` forever:**\n - `publishResult()` never gets called for tool 2\n - `setStatusInternal(callId, 'success', ...)` never happens\n - Tool never transitions to terminal state\n7. **Scheduler blocks indefinitely:**\n - `isRunning()` sees tool 2 with `status = 'executing'`, returns `true`\n - All new tool calls queue up\n - User hits Escape โ†’ queue items abort with \"cancelled while in queue\"\n\n## The Fix\n\nAdd a lock to ensure only **one** `publishBufferedResults` runs at a time:\n\n```typescript\n// Add at class level (around line 400)\nprivate isPublishing = false;\nprivate publishQueue: Array\u003c{\n signal: AbortSignal;\n resolve: () =\u003e void;\n reject: (err: Error) =\u003e void;\n}\u003e = [];\n\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n // If already publishing, queue this request\n if (this.isPublishing) {\n return new Promise\u003cvoid\u003e((resolve, reject) =\u003e {\n this.publishQueue.push({ signal, resolve, reject });\n });\n }\n\n this.isPublishing = true;\n try {\n await this._publishBufferedResultsImpl(signal);\n } finally {\n this.isPublishing = false;\n // Process next queued publish request\n const next = this.publishQueue.shift();\n if (next) {\n this._publishBufferedResultsImpl(next.signal)\n .then(next.resolve)\n .catch(next.reject);\n }\n }\n}\n\nprivate async _publishBufferedResultsImpl(signal: AbortSignal): Promise\u003cvoid\u003e {\n // Original implementation (lines 1356-1387) goes here\n const callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n );\n\n while (this.nextPublishIndex \u003c callsInOrder.length) {\n const expectedCall = callsInOrder[this.nextPublishIndex];\n const buffered = this.pendingResults.get(expectedCall.request.callId);\n\n if (!buffered) {\n break;\n }\n\n await this.publishResult(buffered, signal);\n this.pendingResults.delete(buffered.callId);\n this.nextPublishIndex++;\n }\n\n if (\n this.nextPublishIndex === callsInOrder.length \u0026\u0026\n callsInOrder.length \u003e 0\n ) {\n this.nextPublishIndex = 0;\n this.pendingResults.clear();\n }\n}\n```\n\n## Why This Fixes Your Issue\n\n- **Serializes all calls to `publishBufferedResults`** โ€” only one runs at a time\n- **Preserves order** โ€” results still publish in execution order\n- **No race on `nextPublishIndex`** โ€” it's only modified by one caller at a time\n- **Tools transition to terminal state reliably** โ€” `setStatusInternal(callId, 'success')` always gets called\n- **Queue drains properly** โ€” `isRunning()` returns `false` after all tools complete\n- **No more \"cancelled while in queue\"** โ€” subsequent tool calls don't queue up\n\nThis explains why it happens more with parallel tools โ€” the more tools running simultaneously, the higher the chance they finish at nearly the same time and trigger the race.\n\nWant me to open a PR with this fix?\n\n\n\n๐Ÿฐ๐Ÿ’ก\n*Parallel debugging is always a wild ride!*\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003eโœ๏ธ Learnings added\u003c/summary\u003e\n\n```\nLearnt from: acoliver\nURL: https://github.com/vybestack/llxprt-code/issues/957\n\nTimestamp: 2025-12-26T14:17:41.974Z\nLearning: In packages/core/src/core/coreToolScheduler.ts, the `publishBufferedResults()` method (line 1355) is called concurrently by multiple tool completion handlers (line 1510 in each tool's `.then()` callback) when parallel tools finish simultaneously. This causes a race condition where `nextPublishIndex` can be corrupted and tools can hang in 'executing' state forever, blocking the scheduler and causing subsequent tool calls to queue indefinitely. The method must be made reentrant-safe using a mutex/lock pattern to serialize concurrent calls.\n```\n\n\u003e Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).\n\n\u003c/details\u003e\n\n\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T14:17:42Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692933303"}],"number":957,"title":"some tool calls seem to be getting stuck in queue"} diff --git a/project-plans/20251230fixes/plan.md b/project-plans/20251230fixes/plan.md new file mode 100644 index 000000000..a427ffade --- /dev/null +++ b/project-plans/20251230fixes/plan.md @@ -0,0 +1,200 @@ +# Fix Plan for Issues #945, #948, #951, #952, #957 + +**Date**: 2024-12-30 +**Branch**: Current working branch + +## Overview + +This plan addresses five related issues involving tool execution, cancellation, and UI stability. + +--- + +## Issue #945: search_file_content glob include issues + +**Problem**: Brace expansion patterns like `**/*.{ts,tsx,js}` don't work with git grep, causing fallback to slow JS glob scan. + +**Root Cause**: `grep.ts` lines 516-517 pass the `include` pattern directly to git grep via `--` pathspec, but git grep doesn't support shell-style brace expansion `{...}`. + +**Fix Location**: `packages/core/src/tools/grep.ts` + +**Solution**: +1. Add a helper function to detect unsupported glob patterns (brace expansion `{...}`) +2. When detected, skip git grep entirely and use the JS glob fallback directly +3. This is simpler and more reliable than trying to convert patterns + +**Code Changes**: +```typescript +// Add near top of file, after imports +function hasUnsupportedGitGrepPattern(pattern: string): boolean { + // Git grep doesn't support brace expansion {a,b,c} + return /\{[^}]*,[^}]*\}/.test(pattern); +} + +// In searchWithGitGrep method, add early bail-out check: +// Before attempting git grep, check if include pattern is unsupported +if (include && hasUnsupportedGitGrepPattern(include)) { + return null; // Signal to use JS fallback +} +``` + +--- + +## Issue #948: Shell cancellation doesn't kill the process + +**Problem**: ESC shows "cancelled" but the shell process continues running. + +**Root Cause**: `tool-registry.ts` `DiscoveredTool.execute()` (lines 71-149) accepts `_signal: AbortSignal` but ignores it - the underscore prefix indicates it's unused. The spawned child process is never killed on abort. + +**Fix Location**: `packages/core/src/tools/tool-registry.ts` + +**Solution**: +1. Remove the underscore prefix from `_signal` parameter +2. Add abort signal listener that kills the child process +3. Use the same pattern as `shellExecutionService.ts` (lines 312-330, 535-572) + +**Code Changes**: +```typescript +// In DiscoveredTool.execute method: +async execute(signal: AbortSignal): Promise { + // ... existing code to spawn child ... + + // Add abort handling + const abortHandler = () => { + if (child && !child.killed) { + child.kill('SIGTERM'); + } + }; + signal.addEventListener('abort', abortHandler); + + try { + // ... existing execution logic ... + } finally { + signal.removeEventListener('abort', abortHandler); + } +} +``` + +--- + +## Issue #951: History ID uniqueness/key collisions + +**Problem**: React keys can collide when multiple hook instances generate IDs. + +**Root Cause**: `useHistoryManager.ts` lines 67-69 generates IDs as `baseTimestamp + messageIdCounterRef.current`. The counter is per-hook instance (useRef), so different hook instances using the same baseTimestamp can generate identical IDs. + +**Fix Location**: `packages/cli/src/ui/hooks/useHistoryManager.ts` + +**Solution**: +1. Use a module-level counter instead of per-instance ref for the incrementing part +2. Combine with timestamp for monotonically increasing, globally unique IDs + +**Code Changes**: +```typescript +// Add at module level (outside component) +let globalMessageIdCounter = 0; + +// Modify getNextMessageId function: +const getNextMessageId = useCallback((baseTimestamp: number): number => { + globalMessageIdCounter += 1; + return baseTimestamp * 1000 + globalMessageIdCounter; +}, []); + +// Remove messageIdCounterRef since we use global counter now +// Update clearItems to NOT reset the global counter (IDs should remain unique) +``` + +--- + +## Issues #952/#957: Tool read hangs / "Tool call cancelled while in queue" error + +**Problem**: +- #952: ReadFile shows running but nothing happens +- #957: "Tool call cancelled while in queue" error + +**Root Cause**: `coreToolScheduler.ts` has two issues: +1. Line 494 in `handleMessageBusResponse` creates a NEW `AbortController` instead of using the original signal from the queued request. This means confirmation responses lose the original abort context. +2. `publishBufferedResults` (lines 1355-1386) can be called from multiple async completions simultaneously, causing race conditions. + +**Fix Location**: `packages/core/src/core/coreToolScheduler.ts` + +**Solution**: +1. Store the original signal with the queued request +2. Pass original signal through `handleMessageBusResponse` instead of creating new one +3. Add a reentrancy guard to `publishBufferedResults` + +**Code Changes**: + +```typescript +// 1. In the queue item type, ensure signal is stored +interface QueuedToolRequest { + request: ToolCallRequestInfo; + signal: AbortSignal; // Store original signal +} + +// 2. In handleMessageBusResponse, use stored signal instead of creating new: +// REMOVE: const abortController = new AbortController(); +// USE: the signal from the queued request + +// 3. Add reentrancy guard to publishBufferedResults: +private isPublishingBufferedResults = false; + +private async publishBufferedResults(): Promise { + if (this.isPublishingBufferedResults) { + return; // Prevent reentrant calls + } + this.isPublishingBufferedResults = true; + try { + // ... existing logic ... + } finally { + this.isPublishingBufferedResults = false; + } +} +``` + +--- + +## Implementation Order + +1. **#951** (useHistoryManager) - Simplest, isolated change +2. **#945** (grep.ts) - Self-contained, low risk +3. **#948** (tool-registry.ts) - Moderate complexity +4. **#952/#957** (coreToolScheduler.ts) - Most complex, affects core scheduling + +--- + +## Verification Steps (per AGENTS.md) + +After all fixes: +```bash +npm run format +npm run lint +npm run typecheck +npm run test +npm run build +node scripts/start.js --profile-load synthetic --prompt "write me a haiku" +``` + +--- + +## Test Considerations + +- **#945**: Existing `grep.test.ts` has good coverage; add test for brace expansion pattern detection +- **#948**: Add test verifying abort signal kills child process +- **#951**: `useHistoryManager.test.ts` exists; add test for ID uniqueness across instances +- **#952/#957**: `coreToolScheduler.test.ts` has cancellation tests; verify existing tests still pass + +--- + +## Commit Message Template + +``` +fix: resolve tool scheduling and cancellation issues + +- #945: Skip git grep for unsupported brace expansion patterns +- #948: Kill child process on abort in DiscoveredTool.execute +- #951: Use global counter for history message IDs to prevent collisions +- #952/#957: Pass original abort signal through confirmation flow and + add reentrancy guard to publishBufferedResults + +Fixes #945, #948, #951, #952, #957 +``` From 4ec9278ca2bb36af237c8bf5157a1bc29f69dbf4 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 30 Dec 2025 18:21:49 -0300 Subject: [PATCH 2/4] fix: address review feedback and add abort signal handling to grep - Fix ReDoS vulnerability in hasBraceExpansion by using indexOf instead of regex (CodeQL) - Add abort signal handling to git grep and system grep child processes (#962) - Add debug logging when fallback AbortSignal is used (CodeRabbit) - Remove issue JSON files from project-plans (not needed in repo) --- packages/core/src/core/coreToolScheduler.ts | 6 ++++ packages/core/src/tools/grep.ts | 36 ++++++++++++++++--- .../20251230fixes/issues/issue-945.json | 1 - .../20251230fixes/issues/issue-948.json | 1 - .../20251230fixes/issues/issue-951.json | 1 - .../20251230fixes/issues/issue-952.json | 1 - .../20251230fixes/issues/issue-957.json | 1 - 7 files changed, 38 insertions(+), 9 deletions(-) delete mode 100644 project-plans/20251230fixes/issues/issue-945.json delete mode 100644 project-plans/20251230fixes/issues/issue-948.json delete mode 100644 project-plans/20251230fixes/issues/issue-951.json delete mode 100644 project-plans/20251230fixes/issues/issue-952.json delete mode 100644 project-plans/20251230fixes/issues/issue-957.json diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 40a4283da..94f2bdcd5 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -496,6 +496,12 @@ export class CoreToolScheduler { // Use the original signal stored for this call, or create a new one as fallback const originalSignal = this.callIdToSignal.get(callId); + if (!originalSignal && toolSchedulerLogger.enabled) { + toolSchedulerLogger.debug( + () => + `Using fallback AbortSignal for callId=${callId} (original signal not found in map)`, + ); + } const signal = originalSignal ?? new AbortController().signal; void this.handleConfirmationResponse( callId, diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 98c96af30..2019a0921 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -32,9 +32,15 @@ import { MessageBus } from '../confirmation-bus/message-bus.js'; /** * Checks if a glob pattern contains brace expansion syntax that git grep doesn't support. * Git grep pathspecs don't support shell-style brace expansion like {ts,tsx,js}. + * Uses indexOf for O(n) complexity instead of regex to avoid ReDoS vulnerability. */ function hasBraceExpansion(pattern: string): boolean { - return /\{[^}]*,[^}]*\}/.test(pattern); + const braceStart = pattern.indexOf('{'); + if (braceStart === -1) return false; + const braceEnd = pattern.indexOf('}', braceStart); + if (braceEnd === -1) return false; + const commaPos = pattern.indexOf(',', braceStart); + return commaPos !== -1 && commaPos < braceEnd; } // --- Interfaces --- @@ -537,12 +543,23 @@ class GrepToolInvocation extends BaseToolInvocation< const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; + // Handle abort signal to kill child process + const abortHandler = () => { + if (!child.killed) { + child.kill('SIGTERM'); + } + reject(new Error('git grep aborted')); + }; + options.signal.addEventListener('abort', abortHandler); + child.stdout.on('data', (chunk) => stdoutChunks.push(chunk)); child.stderr.on('data', (chunk) => stderrChunks.push(chunk)); - child.on('error', (err) => - reject(new Error(`Failed to start git grep: ${err.message}`)), - ); + child.on('error', (err) => { + options.signal.removeEventListener('abort', abortHandler); + reject(new Error(`Failed to start git grep: ${err.message}`)); + }); child.on('close', (code) => { + options.signal.removeEventListener('abort', abortHandler); const stdoutData = Buffer.concat(stdoutChunks).toString('utf8'); const stderrData = Buffer.concat(stderrChunks).toString('utf8'); if (code === 0) resolve(stdoutData); @@ -607,6 +624,16 @@ class GrepToolInvocation extends BaseToolInvocation< const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; + // Handle abort signal to kill child process + const abortHandler = () => { + if (!child.killed) { + child.kill('SIGTERM'); + } + cleanup(); + reject(new Error('system grep aborted')); + }; + options.signal.addEventListener('abort', abortHandler); + const onData = (chunk: Buffer) => stdoutChunks.push(chunk); const onStderr = (chunk: Buffer) => { const stderrStr = chunk.toString(); @@ -643,6 +670,7 @@ class GrepToolInvocation extends BaseToolInvocation< }; const cleanup = () => { + options.signal.removeEventListener('abort', abortHandler); child.stdout.removeListener('data', onData); child.stderr.removeListener('data', onStderr); child.removeListener('error', onError); diff --git a/project-plans/20251230fixes/issues/issue-945.json b/project-plans/20251230fixes/issues/issue-945.json deleted file mode 100644 index bc778f899..000000000 --- a/project-plans/20251230fixes/issues/issue-945.json +++ /dev/null @@ -1 +0,0 @@ -{"body":"## Summary\nThe search_file_content tool can return false negatives when include is a glob (especially brace expansion like **/*.{ts,tsx,js} or when include is prefixed with the path). This often triggers the JS fallback scan, which can appear to hang on large trees.\n\n## Repro\n1. Run search with: pattern=sandbox, path=packages/cli/src, include=**/*.{ts,tsx,js}\n2. Expected to match packages/cli/src/config/sandboxConfig.ts\n3. Actual: No matches found OR very slow fallback traversal\n\nIf include is path-prefixed (e.g. packages/cli/src/**/*.{ts,tsx,js}) it never matches when cwd is already packages/cli/src.\n\n## Root Cause (likely)\n- include is passed directly to git grep as a pathspec, but git pathspec does not support brace expansion and relative-path assumptions differ.\n- When git grep returns empty, tool falls back to JS glob and scans huge trees, which feels like a hang.\n\n## Suggested Fix\n- Normalize include to be relative to path (strip leading search dir).\n- Detect {} / ** / other glob patterns and either convert to git pathspec (:(glob)) or skip git grep and go straight to JS glob.\n- Add a timeout / progress / early exit for JS fallback to avoid long hangs.\n","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/274\n- https://github.com/vybestack/llxprt-code/issues/391\n- https://github.com/vybestack/llxprt-code/issues/63\n- https://github.com/vybestack/llxprt-code/issues/473\n- https://github.com/vybestack/llxprt-code/issues/751\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n- [pidgeon777](https://github.com/pidgeon777)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit) or [schedule a call](https://calendly.com/parth-coderabbit/issues-feedback)!","createdAt":"2025-12-22T22:31:19Z","url":"https://github.com/vybestack/llxprt-code/issues/945#issuecomment-3684417170"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find the search_file_content tool implementation\nfd -e ts -e js -e tsx \"search.*file.*content\" --type f\n```\n\nLength of output: 0\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for files that might contain the implementation using ripgrep\nrg -n \"search_file_content\" -A 3 -B 3 --type ts --type js\n```\n\nLength of output: 7474\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for git grep usage in the codebase\nrg -n \"git grep\" -A 5 -B 5 --type ts --type js\n```\n\nLength of output: 5344\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for fallback glob logic\nrg -n \"fallback\" -A 3 -B 3 --type ts --type js | head -100\n```\n\nLength of output: 6350\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the grep.ts file to understand the implementation\ncat packages/core/src/tools/grep.ts\n```\n\nLength of output: 29158\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver I've investigated issue #945 and can confirm the problem. Here's my analysis:\n\n## Root Cause\n\nThe `search_file_content` tool (implemented in `packages/core/src/tools/grep.ts`) has a **critical flaw in how it handles the `include` parameter with git grep**.\n\n**At lines 514-516:**\n```typescript\nif (include) {\n gitArgs.push('--', include);\n}\n```\n\nThe `include` parameter is passed directly to git grep as a pathspec. However:\n\n1. **Git pathspec does NOT support brace expansion** - Patterns like `**/*.{ts,tsx,js}` are not valid git pathspec syntax\n2. **Path-prefixed globs fail** - If `include` is `packages/cli/src/**/*.ts` and the cwd is already `packages/cli/src`, it won't match (double-prefixing issue)\n3. **Git pathspec uses different syntax** - Git supports glob patterns via the `:(glob)` magic signature, but the code doesn't use it\n\nWhen git grep returns no matches (exit code 1), the code correctly treats it as \"no matches found\" and falls back to the JavaScript implementation (line 665+), which uses `globStream` that DOES support proper glob syntax. However, this fallback scans the entire directory tree, which can be extremely slow on large codebases.\n\n## Suggested Fixes\n\n### Option 1: Detect Glob Patterns and Skip Git Grep\n```typescript\n// Check if include contains glob patterns that git pathspec can't handle\nconst hasUnsupportedGlob = include \u0026\u0026 (\n include.includes('{') || \n include.includes('}') ||\n include.startsWith(absolutePath) // Path-prefixed\n);\n\nif (gitAvailable \u0026\u0026 !hasUnsupportedGlob) {\n // Use git grep\n}\n```\n\n### Option 2: Convert to Git Pathspec Format\n```typescript\nif (include) {\n // Normalize path-prefixed includes\n let normalizedInclude = include;\n if (include.startsWith(absolutePath)) {\n normalizedInclude = path.relative(absolutePath, include);\n }\n \n // Use git's glob magic signature for patterns with braces\n if (normalizedInclude.includes('{') || normalizedInclude.includes('}')) {\n gitArgs.push('--', `:(glob)${normalizedInclude}`);\n } else {\n gitArgs.push('--', normalizedInclude);\n }\n}\n```\n\n### Option 3: Add Progress/Timeout for JS Fallback\nThe JavaScript fallback should have better user experience:\n```typescript\n// Add progress logging\nconsole.debug('Falling back to JavaScript grep - this may take a moment for large directories...');\n\n// Consider adding a file count limit or early exit\nlet filesScanned = 0;\nconst MAX_FILES_TO_SCAN = 10000;\n```\n\nWould you like me to create a PR with one of these fixes, or would you prefer to handle this internally?","createdAt":"2025-12-23T13:12:18Z","url":"https://github.com/vybestack/llxprt-code/issues/945#issuecomment-3686595519"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Fetch details of issues #952 and #957\ngh issue view 952 --json number,title,body,labels,state,author\necho \"---SEPARATOR---\"\ngh issue view 957 --json number,title,body,labels,state,author\n```\n\nLength of output: 4063\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver Yes, you're absolutely right! These issues are definitely related. Here's the connection:\n\n## Root Cause โ†’ Symptoms Chain\n\n**#945** (this issue) is the **root cause**:\n- `search_file_content` tool's glob pattern handling is broken\n- Brace expansion and `**/*.ts` patterns fail with git grep\n- Falls back to slow JavaScript glob traversal that can take minutes on large trees\n\n**#952** is experiencing the **hanging symptom**:\n- The SearchText example you posted uses `integration-tests/**/*.ts` \n- This is exactly the type of glob pattern that triggers #945's bug\n- Git grep fails โ†’ JS fallback kicks in โ†’ appears to hang\n- Returns \"No matches found\" which could be either a false negative OR the JS scan eventually completing with no results\n\n**#957** is the **downstream effect**:\n- When tools take unexpectedly long (due to #945's slow fallback), they:\n - Get stuck in the tool scheduler queue\n - Eventually get cancelled with \"Tool call cancelled while in queue\"\n- Your note \"this was on something that was taking awhile for no reason\" aligns perfectly with #945's slow fallback behavior\n\n## Smoking Gun Evidence\n\nIn #952, you have:\n```\nโœ“ SearchText 'output-format|outputFormat|...' in integration-tests/**/*.ts within ./\n No matches found\n```\n\nThat `integration-tests/**/*.ts` glob is being passed directly to git grep (which doesn't support `**` properly), failing, then triggering the slow JS fallback scan.\n\n## Fix Priority\n\nFixing #945 should resolve or significantly improve both #952 and #957. The fixes I suggested earlier (detecting unsupported globs, converting to git pathspec format, or adding timeouts) would prevent the slow fallback from causing these queue issues.","createdAt":"2025-12-29T19:20:18Z","url":"https://github.com/vybestack/llxprt-code/issues/945#issuecomment-3697310851"}],"number":945,"title":"search_file_content: include glob can false-negative and trigger slow fallback"} diff --git a/project-plans/20251230fixes/issues/issue-948.json b/project-plans/20251230fixes/issues/issue-948.json deleted file mode 100644 index aaf403d2c..000000000 --- a/project-plans/20251230fixes/issues/issue-948.json +++ /dev/null @@ -1 +0,0 @@ -{"body":"### What happened?\n\nif the llm kicks off a long shell call esc shows cancelled but the shell contiues\n\n### What did you expect to happen?\n\nto cancel /kill the shell job too\n\n### Client information\n\nv0.7.0-nightly.251222.e40c645db\n\n### Login information\n\n_No response_\n\n### Anything else we need to know?\n\n_No response_","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Possible Duplicates**\n- https://github.com/vybestack/llxprt-code/issues/521\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/307\n- https://github.com/vybestack/llxprt-code/issues/61\n- https://github.com/vybestack/llxprt-code/issues/619\n- https://github.com/vybestack/llxprt-code/issues/525\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n- [e2720pjk](https://github.com/e2720pjk)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit) or [schedule a call](https://calendly.com/parth-coderabbit/issues-feedback)!","createdAt":"2025-12-23T00:24:01Z","url":"https://github.com/vybestack/llxprt-code/issues/948#issuecomment-3684628374"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find files related to shell tool execution\necho \"=== Finding shell tool related files ===\"\nfd -e py -e ts -e js -e go shell | head -20\n\necho -e \"\\n=== Searching for shell tool implementation ===\"\nrg -l \"shell.*tool\" --type=py --type=ts --type=js --type=go | head -20\n\necho -e \"\\n=== Searching for process execution and cancellation ===\"\nrg -n \"cancel.*process|kill.*process|abort.*process\" --type=py --type=ts --type=js --type=go -C2 | head -50\n```\n\nLength of output: 5840\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the shell tool implementation\necho \"=== Shell Tool Implementation ===\"\ncat packages/core/src/tools/shell.ts\n```\n\nLength of output: 24970\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the shell execution service implementation\necho \"=== Shell Execution Service Implementation ===\"\ncat packages/core/src/services/shellExecutionService.ts\n```\n\nLength of output: 18747\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for ESC key handling and abort controller usage\necho \"=== Searching for ESC key handling ===\"\nrg -n \"escape|ESC|Escape\" --type=ts --type=tsx -C3 | grep -i \"key\\|cancel\\|abort\" | head -40\n\necho -e \"\\n=== Searching for AbortController creation and usage ===\"\nrg -n \"AbortController|abortController|abort\\(\\)\" --type=ts --type=tsx -C2 | head -60\n\necho -e \"\\n=== Searching for tool execution cancellation ===\"\nrg -n \"cancel.*tool|abort.*tool|tool.*cancel|tool.*abort\" --type=ts --type=tsx -C2 | head -40\n```\n\nLength of output: 231\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for ESC key handling and abort controller usage in TS/JS files\necho \"=== Searching for ESC key handling ===\"\nrg -n \"Escape|escape|ESC\" --type=ts --type=js -C3 | head -60\n\necho -e \"\\n=== Searching for AbortController creation and usage ===\"\nrg -n \"new AbortController|abortController\" --type=ts --type=js -C3 | head -60\n```\n\nLength of output: 8893\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the keyboard handlers to understand ESC handling\necho \"=== Reading useKeyboardHandlers ===\"\ncat packages/ui/src/hooks/useKeyboardHandlers.ts\n\necho -e \"\\n=== Searching for cancel/abort in UI hooks ===\"\nrg -n \"onCancel|handleCancel|cancelStreaming|abortTool\" packages/ui/src/hooks/ -A3 -B3\n```\n\nLength of output: 8240\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for cancelAll implementation and tool execution\necho \"=== Searching for cancelAll implementation ===\"\nrg -n \"cancelAll\\s*=\" --type=ts --type=js -A5 -B2 | head -80\n\necho -e \"\\n=== Searching for tool execution with abort signal ===\"\nrg -n \"executeToolCall|execute.*tool\" --type=ts --type=js -C3 | head -60\n```\n\nLength of output: 4690\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for tool execution in the UI and abort handling\necho \"=== Searching for UI component that uses cancelAll ===\"\nfd -e tsx -e ts . packages/ui/src | xargs rg -l \"cancelAll\" | head -10\n\necho -e \"\\n=== Reading files that use cancelAll ===\"\nrg -n \"cancelAll\" packages/ui/src -A10 -B5 | head -100\n```\n\nLength of output: 7126\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find the CoreToolScheduler implementation\necho \"=== Finding CoreToolScheduler ===\"\nfd -e ts -e js . packages/core/src | xargs rg -l \"class.*ToolScheduler|ToolScheduler.*cancelAll\" | head -10\n\necho -e \"\\n=== Reading CoreToolScheduler cancelAll implementation ===\"\nrg -n \"cancelAll\" packages/core/src -A10 -B5 | head -120\n```\n\nLength of output: 4146\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the app.tsx to understand how cancelAll is wired up\necho \"=== Reading app.tsx around cancelAll usage ===\"\ncat packages/ui/src/app.tsx | grep -A30 -B30 \"cancelAll\"\n\necho -e \"\\n=== Check how abort signal is managed in streaming lifecycle ===\"\ncat packages/ui/src/hooks/useStreamingLifecycle.ts\n```\n\nLength of output: 6247\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check how tools are scheduled and what AbortSignal is passed\necho \"=== Reading useToolScheduler schedule implementation ===\"\ncat packages/ui/src/hooks/useToolScheduler.ts | grep -A50 \"const schedule\"\n\necho -e \"\\n=== Check CoreToolScheduler schedule method ===\"\nrg -n \"schedule.*async|scheduleToolCall\" packages/core/src/core/coreToolScheduler.ts -A20 -B5 | head -100\n```\n\nLength of output: 4204\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the CoreToolScheduler schedule method\necho \"=== Reading CoreToolScheduler schedule method ===\"\ncat packages/core/src/core/coreToolScheduler.ts | grep -A80 \"async schedule\"\n\necho -e \"\\n=== Check how tool execution receives abort signal ===\"\nrg -n \"executeToolWithAbortSignal|execute.*AbortSignal\" packages/core/src/core/coreToolScheduler.ts -A10 -B5\n```\n\nLength of output: 105\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the entire CoreToolScheduler file to understand the flow\necho \"=== Reading CoreToolScheduler structure ===\"\nrg -n \"schedule\\(|processNext|executeWith\" packages/core/src/core/coreToolScheduler.ts -C5 | head -150\n\necho -e \"\\n=== Search for tool.execute calls in CoreToolScheduler ===\"\nrg -n \"tool\\.execute|invocation\\.execute\" packages/core/src/core/coreToolScheduler.ts -C8 | head -100\n```\n\nLength of output: 1223\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Let's read key sections of CoreToolScheduler\necho \"=== Reading CoreToolScheduler to find where tools are executed ===\"\ncat packages/core/src/core/coreToolScheduler.ts | head -100\n\necho -e \"\\n=== Search for where tool invocations execute ===\"\nrg -n \"executeInvocation|ToolInvocation|confirmationHandle\" packages/core/src/core/coreToolScheduler.ts -C5 | head -150\n```\n\nLength of output: 6833\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find where the tool invocation's execute method is called\necho \"=== Searching for invocation.execute calls ===\"\nrg -n \"invocation\\.execute\\(\" packages/core/src/core/coreToolScheduler.ts -C10\n\necho -e \"\\n=== Search for executeScheduledToolCall or similar ===\"\nrg -n \"executeScheduled|executeTool|runTool\" packages/core/src/core/coreToolScheduler.ts -C8 | head -100\n```\n\nLength of output: 102\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Let's look at the full _schedule method and processNext\necho \"=== Reading the _schedule method in full ===\"\nrg -n \"private async _schedule\" packages/core/src/core/coreToolScheduler.ts -A100 | head -120\n\necho -e \"\\n=== Look for messageBus and tool execution ===\"\nrg -n \"messageBus|MessageBus\" packages/core/src/core/coreToolScheduler.ts -C5 | head -80\n```\n\nLength of output: 6734\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for where the abort signal gets passed to tool invocations\necho \"=== Looking for where tools get executed with signals ===\"\nrg -n \"\\.execute\\(.*signal\" packages/core/src/core/coreToolScheduler.ts -B5 -A15\n\necho -e \"\\n=== Check if there's an AbortController created per tool ===\"\nrg -n \"AbortController|abortController\" packages/core/src/core/coreToolScheduler.ts -C5\n```\n\nLength of output: 1317\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for the complete flow of how signals are passed\necho \"=== Check where the signal parameter comes from in _schedule ===\"\nrg -n \"private async _schedule\" packages/core/src/core/coreToolScheduler.ts -A200 | grep -A5 -B5 \"signal\"\n\necho -e \"\\n=== Look for handleConfirmationResponse to see if it uses the new AbortController ===\"\nrg -n \"handleConfirmationResponse\" packages/core/src/core/coreToolScheduler.ts -A30 | head -100\n```\n\nLength of output: 5958\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver I've investigated the issue and found the root cause of why shell commands continue running even after ESC shows \"cancelled\".\n\n## Root Cause\n\nThe problem is in the **abort signal propagation** in `CoreToolScheduler`. Here's the flow:\n\n1. **User presses ESC** โ†’ `handleCancelAll()` is called\n2. This calls `cancelStreaming()` which aborts the main `AbortController`\n3. This also calls `cancelAll()` which marks tools as cancelled in the scheduler\n\nHowever, there's a critical bug at **line 494 in `packages/core/src/core/coreToolScheduler.ts`**:\n\n```typescript\nprivate handleMessageBusResponse(response: ToolConfirmationResponse): void {\n // ... code ...\n \n const abortController = new AbortController(); // โŒ NEW controller created!\n void this.handleConfirmationResponse(\n callId,\n waitingToolCall.confirmationDetails.onConfirm,\n derivedOutcome,\n abortController.signal, // โŒ New signal, not the original one!\n response.payload,\n true,\n );\n}\n```\n\nWhen tool confirmations are handled via the message bus (e.g., from policy engine auto-approvals), a **brand new `AbortController`** is created. This new signal is completely disconnected from the original signal that was passed to `schedule()`.\n\n## The Problem\n\n- The original `AbortSignal` passed to `schedule(request, signal)` flows through validation and confirmation\n- But when `handleMessageBusResponse` processes an approval, it creates a fresh `AbortController`\n- This new signal is what gets passed to `invocation.execute(signal, ...)` \n- When the user presses ESC, the **original** signal is aborted, but the shell tool is running with the **new** signal\n- Result: UI shows \"cancelled\" but the shell process never receives the abort signal\n\n## Solution\n\nThe `CoreToolScheduler` needs to track the original `AbortSignal` for each tool call and reuse it throughout the entire lifecycle, including when processing message bus responses. The signal should be stored alongside each tool call in the scheduler's state.\n\nWould you like me to create an issue to track this bug for fixing?","createdAt":"2025-12-23T00:27:27Z","url":"https://github.com/vybestack/llxprt-code/issues/948#issuecomment-3684633449"}],"number":948,"title":"shell tool won't cancel"} diff --git a/project-plans/20251230fixes/issues/issue-951.json b/project-plans/20251230fixes/issues/issue-951.json deleted file mode 100644 index df1a28f89..000000000 --- a/project-plans/20251230fixes/issues/issue-951.json +++ /dev/null @@ -1 +0,0 @@ -{"body":"### What happened?\n\nIf you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you specifically mean โ€œdid npm run test:integration:sandbox:none pass after the changes?โ€ โ€” yes, I ran it with CI-like env (including OPENAI_API_KEY loaded from ~/.llxprt/keys/.synthetic_key) and it completed with Exit Code: 0.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n - npm run test:integration:sandbox:none with OPENAI_API_KEY=\"$(cat ~/.llxprt/keys/.synthetic_key)\" and the same env as .github/workflows/e2e.yml.\n If you want a clean confirmation, tell me and Iโ€™ll rerun exactly:\n\nthe shell tool is running and I tried scrolling up it is drawing weird and I can't scroll all the way back down.\n\n### What did you expect to happen?\n\nto not get the werid repeats and to scroll down to the bottom of the shell tool that is running below this text.\n\n### Client information\n\n v0.7.0-nightly.251222.e40c645db\n\n### Login information\n\n_No response_\n\n### Anything else we need to know?\n\n_No response_","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Possible Duplicates**\n- https://github.com/vybestack/llxprt-code/issues/142\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/100\n- https://github.com/vybestack/llxprt-code/issues/141\n- https://github.com/vybestack/llxprt-code/issues/518\n- https://github.com/vybestack/llxprt-code/issues/607\n- https://github.com/vybestack/llxprt-code/issues/152\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Related PRs\u003c/b\u003e\u003c/summary\u003e\n\nvybestack/llxprt-code#590 - Kitty protocol cherry-pick fixes and compatibility updates [merged]\nvybestack/llxprt-code#661 - feat: Hybrid UI Architecture \u0026 ToolCall UI Alignment [merged]\nvybestack/llxprt-code#733 - fix(security): Resolve Dependabot alerts #19 #20 for jws vulnerability [merged]\nvybestack/llxprt-code#896 - fix: enforce tool output ephemerals across providers (and remove dead polaris textual replay) [merged]\nvybestack/llxprt-code#917 - Fix tool-call history for provider switching [merged]\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n- [e2720pjk](https://github.com/e2720pjk)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit) or [schedule a call](https://calendly.com/parth-coderabbit/issues-feedback)!","createdAt":"2025-12-23T15:02:57Z","url":"https://github.com/vybestack/llxprt-code/issues/951#issuecomment-3686948329"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find files related to shell tool UI and display\necho \"=== Finding shell tool related files ===\"\nfd -e tsx -e ts -e jsx -e js | rg -i \"shell|terminal|tool.*output|execution\" | head -20\n\necho -e \"\\n=== Finding scroll-related components ===\"\nfd -e tsx -e ts -e jsx -e js | rg -i \"scroll\" | head -20\n\necho -e \"\\n=== Searching for shell tool component implementations ===\"\nrg -l \"shell.*tool|tool.*shell|ShellTool|ToolExecution\" --type=tsx --type=ts --type=jsx --type=js | head -20\n```\n\nLength of output: 1586\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for scroll area implementations and auto-scroll logic\necho \"=== Searching for scroll area implementations ===\"\nrg -n -C3 \"scrollTo|scrollTop|autoScroll|scroll.*bottom\" --type=tsx --type=ts --type=jsx --type=js | head -100\n\necho -e \"\\n=== Searching for scroll container classes/components ===\"\nast-grep --pattern 'class $_ {\n $$$\n scroll$_($$$) {\n $$$\n }\n $$$\n}'\n```\n\nLength of output: 136\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for TODOs or comments related to scroll issues\necho \"=== Searching for scroll-related TODOs and comments ===\"\nrg -n \"TODO|FIXME|BUG|HACK\" --type=tsx --type=ts --type=jsx --type=js | rg -i \"scroll\" | head -20\n\necho -e \"\\n=== Searching for issue references in code ===\"\nrg -n \"#951|issue.*951\" --type=tsx --type=ts --type=jsx --type=js\n```\n\nLength of output: 159\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search with proper file extensions instead of --type\necho \"=== Searching for scroll area implementations ===\"\nrg -n -C3 \"scrollTo|scrollTop|autoScroll|scroll.*bottom\" -g \"*.ts\" -g \"*.tsx\" -g \"*.js\" -g \"*.jsx\" | head -150\n\necho -e \"\\n=== Searching for scroll-related TODOs ===\"\nrg -n \"TODO|FIXME|BUG|HACK\" -g \"*.ts\" -g \"*.tsx\" -g \"*.js\" -g \"*.jsx\" | rg -i \"scroll\"\n```\n\nLength of output: 11755\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Examine the ScrollProvider and related scroll management files\necho \"=== ScrollProvider.tsx ===\"\ncat packages/cli/src/ui/contexts/ScrollProvider.tsx\n\necho -e \"\\n=== useScrollManagement.ts ===\"\ncat packages/ui/src/hooks/useScrollManagement.ts\n\necho -e \"\\n=== useBatchedScroll.ts ===\"\ncat packages/cli/src/ui/hooks/useBatchedScroll.ts\n```\n\nLength of output: 13721\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for shell tool output rendering components\necho \"=== Finding shell execution/output components ===\"\nrg -n -C5 \"shell.*output|tool.*output|execution.*output\" -g \"*.tsx\" -g \"*.ts\" | head -100\n\necho -e \"\\n=== Examining UIStateShell.tsx ===\"\ncat packages/cli/src/ui/containers/UIStateShell.tsx | head -200\n```\n\nLength of output: 7871\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Examine VirtualizedList implementation\necho \"=== VirtualizedList.tsx ===\"\ncat packages/cli/src/ui/components/shared/VirtualizedList.tsx\n\necho -e \"\\n=== Finding streaming/dynamic content handling ===\"\nrg -n -C5 \"streaming|chunk|append.*output|tool.*running\" -g \"*.tsx\" -g \"*.ts\" | head -100\n```\n\nLength of output: 20411\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for any existing scroll-related bugs or regression tests\necho \"=== Scroll regression tests ===\"\ncat scripts/tests/oldui-scrollback-regression.test.js\n\necho -e \"\\n=== Scroll load test ===\"\ncat scripts/oldui-scrollback-load.js | head -150\n```\n\nLength of output: 4524\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check for any open issues mentioning scroll problems\necho \"=== Searching for scroll-related comments and issues in code ===\"\nrg -n \"scroll.*bug|scroll.*issue|scroll.*problem|scroll.*broken|scroll.*borked\" -g \"*.tsx\" -g \"*.ts\" -g \"*.js\" -g \"*.md\" -i\n\necho -e \"\\n=== Check for references to rendering issues ===\"\nrg -n \"render.*bug|render.*issue|duplicate.*render|repeat.*render\" -g \"*.tsx\" -g \"*.ts\" -i | head -30\n```\n\nLength of output: 1385\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the project documentation about UI findings and scroll issues\necho \"=== UI Findings Documentation ===\"\ncat project-plans/20251215oldui/uifindings.md\n\necho -e \"\\n=== Testing Strategy Documentation ===\"\ncat project-plans/20251215oldui/testing-strategy.md\n\necho -e \"\\n=== Tmux Harness Documentation ===\"\ncat dev-docs/oldui-tmux-harness.md\n```\n\nLength of output: 23304\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for issue 951 and related scroll issues in GitHub\necho \"=== Searching for related GitHub issues ===\"\ngh issue view 951 --json title,body,labels,state,comments --jq '{title, body, state, labels: [.labels[].name], comments: [.comments[].body]}'\n\necho -e \"\\n=== Searching for other scroll-related issues ===\"\ngh issue list --search \"scroll\" --json number,title,state --limit 10\n```\n\nLength of output: 14388\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for any existing fixes or workarounds for scroll issues\necho \"=== Recent commits mentioning scroll ===\"\ngit log --oneline --all --grep=\"scroll\" -20\n\necho -e \"\\n=== Recent commits mentioning render or redraw ===\"\ngit log --oneline --all --grep=\"render\\|redraw\" -15\n```\n\nLength of output: 2328\n\n---\n\n@acoliver, I've investigated issue #951 and this is a known class of scrollback rendering issues with the old Ink UI that has been actively worked on.\n\n## What I Found\n\n**The Problem**: This is related to the documented \"Old Ink UI scrollback redraw\" issue where the UI continuously re-renders while you're scrolled up, causing:\n- Repeated/duplicated text patterns in the scrollback buffer\n- Scroll position becoming stuck or unable to return to bottom\n- Particularly problematic during streaming output from shell tools\n\n**Recent Work**: There have been significant fixes recently:\n- Commits addressing scroll jitter, scroll gaps, and mouse scroll issues\n- Addition of VirtualizedList, ScrollProvider, and scrollable list primitives\n- Automated tmux-based harness specifically built to detect and reproduce these issues\n\n## Documentation References\n\nThe issue is extensively documented in:\n- `project-plans/20251215oldui/uifindings.md` - Details the scrollback redraw problem\n- `project-plans/20251215oldui/testing-strategy.md` - Testing approach for scroll issues \n- `dev-docs/oldui-tmux-harness.md` - Automated reproduction harness\n\n## Next Steps\n\nTo help debug this specific case:\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check what version/commit the reported version corresponds to\necho \"=== Checking commit e40c645db ===\"\ngit log --oneline e40c645db -1 2\u003e/dev/null || echo \"Commit not found in current branch\"\n\necho -e \"\\n=== Checking recent tags/releases ===\"\ngit tag --contains e40c645db 2\u003e/dev/null | head -10 || echo \"No tags found for this commit\"\n\necho -e \"\\n=== Recent commits on main/master ===\"\ngit log --oneline --first-parent origin/main -10 2\u003e/dev/null || git log --oneline --first-parent main -10 2\u003e/dev/null || echo \"Could not fetch recent commits\"\n```\n\nLength of output: 666\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are newer fixes after the reported version\necho \"=== Commits after e40c645db related to scroll ===\"\ngit log --oneline --grep=\"scroll\" e40c645db..HEAD 2\u003e/dev/null | head -20 || echo \"Could not compare commits\"\n\necho -e \"\\n=== Check current branch and latest commit ===\"\necho \"Current branch: $(git branch --show-current)\"\necho \"Latest commit: $(git log --oneline -1)\"\n```\n\nLength of output: 192\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there's a fix-oldui or similar branch with recent scroll fixes\necho \"=== Checking for branches with scroll fixes ===\"\ngit branch -a | grep -i \"scroll\\|oldui\\|fix.*ui\"\n\necho -e \"\\n=== Check if fix-oldui branch exists and its status ===\"\ngit log --oneline fix-oldui -10 2\u003e/dev/null || git log --oneline origin/fix-oldui -10 2\u003e/dev/null || echo \"fix-oldui branch not found\"\n\necho -e \"\\n=== Compare main with fix-oldui if it exists ===\"\ngit log --oneline main..origin/fix-oldui --grep=\"scroll\" 2\u003e/dev/null | head -10 || echo \"No comparison available\"\n```\n\nLength of output: 181\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for open PRs related to scroll issues\necho \"=== Searching for open PRs related to scroll ===\"\ngh pr list --search \"scroll\" --json number,title,state --limit 10\n\necho -e \"\\n=== Searching for recently merged PRs with scroll fixes ===\"\ngh pr list --search \"scroll\" --state merged --json number,title,mergedAt --limit 10\n```\n\nLength of output: 1248\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if the scroll fixes were on a branch that was already merged\necho \"=== Commit 5dde73e4 (Merge pull request #845 from vybestack/fix-oldui) details ===\"\ngit log --oneline 5dde73e4 -1 2\u003e/dev/null || echo \"Not found\"\n\necho -e \"\\n=== Checking if commit e40c645db includes the fix-oldui merge ===\"\ngit log --oneline e40c645db --grep=\"fix-oldui\" -10 2\u003e/dev/null || git log --oneline --all --grep=\"fix-oldui\" -10 2\u003e/dev/null || echo \"Not found\"\n\necho -e \"\\n=== Check if 5dde73e4 is an ancestor of e40c645db ===\"\ngit merge-base --is-ancestor 5dde73e4 e40c645db 2\u003e/dev/null \u0026\u0026 echo \"YES - fix-oldui merge IS in the reported version\" || echo \"NO - fix-oldui merge is NOT in the reported version\"\n```\n\nLength of output: 436\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get details of PR #845 to understand what was fixed\necho \"=== PR #845 Details ===\"\ngh pr view 845 --json title,body,mergedAt,commits | jq -r '.title, .body' | head -100\n\necho -e \"\\n=== Commits in PR #845 ===\"\ngh pr view 845 --json commits | jq -r '.commits[] | \"\\(.oid[0:8]) \\(.messageHeadline)\"'\n```\n\nLength of output: 6845\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are any commits between the fix-oldui merge and the reported version\necho \"=== Commits between fix-oldui merge (5dde73e4) and reported version (e40c645db) ===\"\ngit log --oneline 5dde73e4..e40c645db\n\necho -e \"\\n=== Count of commits ===\"\ngit log --oneline 5dde73e4..e40c645db | wc -l\n```\n\nLength of output: 14703\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver, I've completed my investigation. Here's what I found:\n\n## Current Status\n\n**Good news**: The major scrollback fix PR (#845) from Dec 17, 2025 **IS included** in the reported version (v0.7.0-nightly.251222.e40c645db). That PR addressed the core \"scrollback redraw spam\" issue documented in #456.\n\n**The problem**: Despite those fixes being present, you're experiencing a **new or different scroll rendering issue** with symptoms that suggest this may be a distinct bug:\n\n1. **\"Weird repeats\"** - Text appearing duplicated/repeated (not just scrollback spam)\n2. **Can't scroll back down** - Scroll position stuck or unable to return to bottom\n3. **Occurs during shell tool execution** - Specifically while streaming output\n\n## Analysis\n\nPR #845 fixed the primary issue where the UI would continuously append to terminal scrollback while the user was scrolled up. However, your report suggests there may be:\n\n- A **rendering duplication bug** (the \"weird repeats\")\n- A **scroll position tracking issue** (inability to scroll back to bottom)\n- Possibly related to the VirtualizedList scroll anchoring during rapid streaming updates\n\n## Recommended Next Steps\n\n### 1. **Reproduce with the tmux harness**\n\nThe codebase has an automated reproduction tool specifically for scroll issues:\n\n```bash\n# Basic shell command scrollback test\nnode scripts/oldui-tmux-harness.js --scenario scrollback --rows 20 --cols 100\n\n# Or create a custom script for your exact scenario\nnode scripts/oldui-tmux-harness.js --script \u003cyour-script.json\u003e\n```\n\n### 2. **Check UI settings**\n\nWhat's your current setting for:\n```bash\n/set ui.enableMouseEvents\n/set ui.useAlternateBuffer\n```\n\nThe scroll fixes work best with `ui.useAlternateBuffer=true` (default).\n\n### 3. **Gather reproduction details**\n\nTo help debug, we'd need:\n- Exact steps to reproduce (which shell command, how long it runs, etc.)\n- Terminal emulator and dimensions\n- Whether this happens consistently or intermittently\n- Screen recording or tmux capture if possible\n\n### 4. **Check for edge cases**\n\nThe VirtualizedList has logic to handle \"sticking to bottom\" during streaming. Your symptoms suggest there might be an edge case where:\n- Content height changes rapidly during streaming\n- Scroll anchor calculation gets confused\n- Rendering happens multiple times for the same content\n\nWould you like me to:\n1. Create a custom tmux script to try reproducing your exact scenario?\n2. Examine the VirtualizedList scroll anchoring logic more closely for potential bugs?\n3. Check if there are any other recent scroll-related issues or PRs?\n\nLet me know what additional details you can provide and I'll dig deeper into this specific manifestation of the scroll issue.","createdAt":"2025-12-23T15:19:58Z","url":"https://github.com/vybestack/llxprt-code/issues/951#issuecomment-3687004434"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for where \"history-\" keys are being generated\necho \"=== Searching for 'history-' key generation ===\"\nrg -n \"history-\" -g \"*.tsx\" -g \"*.ts\" -g \"*.jsx\" -g \"*.js\" -C5\n\necho -e \"\\n=== Searching for key prop assignments ===\"\nrg -n \"key=.*history\" -g \"*.tsx\" -g \"*.jsx\" -C3\n```\n\nLength of output: 5827\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Read the DefaultAppLayout.tsx file to understand the context\necho \"=== DefaultAppLayout.tsx - Full content ===\"\ncat packages/cli/src/ui/layouts/DefaultAppLayout.tsx\n\necho -e \"\\n=== Search for where history comes from ===\"\nrg -n \"const.*history\\s*=\" -g \"*.tsx\" -g \"*.ts\" packages/cli/src/ui/layouts/ -C5\n```\n\nLength of output: 21918\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for where history items get their IDs and how they're created\necho \"=== Searching for history item ID assignment ===\"\nrg -n \"\\.id\\s*=\" -g \"*.tsx\" -g \"*.ts\" packages/cli/src/ui/ -C3\n\necho -e \"\\n=== Searching for addItem or history management ===\"\nrg -n \"addItem|addToHistory|pushHistory\" -g \"*.tsx\" -g \"*.ts\" packages/cli/src/ui/ -C3\n\necho -e \"\\n=== Looking at UIStateContext ===\"\ncat packages/cli/src/ui/contexts/UIStateContext.tsx | head -200\n```\n\nLength of output: 253085\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver Thanks โ€” I tracked the duplicate key down to how history item IDs are generated and used.\n\nWhat we know so far\n- The duplicate comes from DefaultAppLayout using keys like history-${h.id}. Line 233 in packages/cli/src/ui/layouts/DefaultAppLayout.tsx.\n- History itemsโ€™ id values are created in packages/cli/src/ui/hooks/useHistoryManager.ts via getNextMessageId(baseTimestamp) when addItem(...) is called.\n- Many places call addItem multiple times with the same base timestamp (e.g., userMessageTimestamp = Date.now() used for several items in one flow). If getNextMessageId doesnโ€™t guarantee uniqueness per call, React will see repeated keys and youโ€™ll get both the x7042 warning and the visual โ€œrepeats/stuck scrollโ€ you observed.\n\nWhat I want to confirm\n- The exact implementation of getNextMessageId in packages/cli/src/ui/hooks/useHistoryManager.ts (or wherever itโ€™s defined). If itโ€™s just returning baseTimestamp (or otherwise not disambiguating same-ms calls), that explains the collisions like history-1766506849037.\n\nProposed fix direction\n- Make getNextMessageId strictly monotonic and unique:\n - Maintain module-level lastTimestamp and perTimestampCounter.\n - If baseTimestamp === lastTimestamp, increment a suffix counter; else reset counter.\n - Compute id = baseTimestamp * 1000 + counter (or use a 32-bit counter shifted into lower bits).\n - Alternatively, keep an ever-increasing monotonic integer independent of wall time, but still seed with baseTimestamp to preserve ordering across restored history.\n\n- Also harden load/restore paths:\n - In slashCommandProcessor.ts Lines 500โ€“505, we currently add restored items with addItem(item, index). Using index as a base timestamp risks collisions across sessions. For restored history, prefer the original item.id if present, or pass the original message.timestamp.getTime() as baseTimestamp. If neither exists, use a unique generator that doesnโ€™t reuse small indices.\n\nIf you want, I can:\n- Open a focused PR to patch getNextMessageId to be collision-safe and update the restore path to avoid index-based bases.\n- Add a regression test where we call addItem twice with the same baseTimestamp and assert unique ids, and a streaming test that adds N items within a single millisecond.\n\nWould you like me to proceed with a small PR for those two fixes and a test?","createdAt":"2025-12-23T16:24:46Z","url":"https://github.com/vybestack/llxprt-code/issues/951#issuecomment-3687224660"}],"number":951,"title":"scroll area can get borked"} diff --git a/project-plans/20251230fixes/issues/issue-952.json b/project-plans/20251230fixes/issues/issue-952.json deleted file mode 100644 index 678cdaa38..000000000 --- a/project-plans/20251230fixes/issues/issue-952.json +++ /dev/null @@ -1 +0,0 @@ -{"body":"### What happened?\n\nโœ“ ReadFile integration-tests/test-helper.ts\n\n โŠท ReadFile ...integration-tests//json-output.test.ts\n\n Running: ...integration-tests//json-output.test.ts\n\n โœ“ SearchText 'output-format|outputFormat|--output-format|--outputFormat|json-output' in integration-tests/**/*.ts within ./\n\n No matches found\n\nThe read file has been going on for a while and normally is instant. So far I've only seen this with gpt-5.2. so either it is some tool thing with Responses or is a timing issue that gpt seems to hit more often\n\n### What did you expect to happen?\n\nnot hang\n\n### Client information\n\nv0.7.0-nightly.251223.e40c645db\n\n### Login information\n\n_No response_\n\n### Anything else we need to know?\n\n_No response_","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Possible Duplicates**\n- https://github.com/vybestack/llxprt-code/issues/152\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/104\n- https://github.com/vybestack/llxprt-code/issues/951\n- https://github.com/vybestack/llxprt-code/issues/84\n- https://github.com/vybestack/llxprt-code/issues/231\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n- [dipping5115](https://github.com/dipping5115)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit) or [schedule a call](https://calendly.com/parth-coderabbit/issues-feedback)!","createdAt":"2025-12-23T21:12:38Z","url":"https://github.com/vybestack/llxprt-code/issues/952#issuecomment-3687979076"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\n# First, let's find files related to tool execution and ReadFile\necho \"=== Finding tool execution files ===\"\nfd -e ts -e js | rg -i \"(tool|execute)\" | head -30\n\necho -e \"\\n=== Finding ReadFile implementation ===\"\nrg -n \"class ReadFile|ReadFile.*Tool|readfile\" --type=ts -i | head -20\n\necho -e \"\\n=== Finding parallel tool execution logic ===\"\nrg -n \"parallel.*tool|executeTools|executeTool\" --type=ts -C3 | head -50\n```\n\nLength of output: 7388\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Finding ReadFile tool class ===\"\nrg -n \"class.*ReadFile|export.*ReadFile\" --type=ts -A5 | head -40\n\necho -e \"\\n=== Finding tool execution in scheduler ===\"\ncat packages/ui/src/hooks/useToolScheduler.ts\n\necho -e \"\\n=== Looking for tool execution completion logic ===\"\nrg -n \"toolComplete|tool.*complete|onComplete\" --type=ts -C3 | head -60\n```\n\nLength of output: 19478\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Finding CoreToolScheduler implementation ===\"\nfd \"CoreToolScheduler.ts\" --type f\n\necho -e \"\\n=== Reading CoreToolScheduler ===\"\nrg -n \"class CoreToolScheduler\" --type=ts -A10\n\necho -e \"\\n=== Looking for schedule method and execution logic ===\"\nast-grep --pattern 'class CoreToolScheduler {\n $$$\n schedule($$$) {\n $$$\n }\n $$$\n}'\n```\n\nLength of output: 1149\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Reading full CoreToolScheduler file ===\"\ncat packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 51027\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Checking recent git history for parallel tool changes ===\"\ngit log --oneline --all -20 --grep=\"parallel\\|tool\\|scheduler\" -- packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Checking recent commits to coreToolScheduler.ts ===\"\ngit log --oneline -10 -- packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Looking for recent changes to publishBufferedResults ===\"\ngit log -p -5 --all -- packages/core/src/core/coreToolScheduler.ts | grep -A30 -B5 \"publishBufferedResults\"\n```\n\nLength of output: 2438\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Getting details of the buffered parallel tool execution commit ===\"\ngit show 9039042c --stat\n\necho -e \"\\n=== Getting the actual changes to publishBufferedResults ===\"\ngit show 9039042c -- packages/core/src/core/coreToolScheduler.ts | grep -A50 \"publishBufferedResults\"\n\necho -e \"\\n=== Checking if there's a lock or guard mechanism ===\"\nrg -n \"isPublishing|publishLock|publishing\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 5855\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\necho \"=== Looking at the test file for parallel execution tests ===\"\nrg -n \"parallel.*execution|buffered.*result\" packages/core/src/core/coreToolScheduler.test.ts -A10 | head -80\n\necho -e \"\\n=== Checking if tests cover concurrent completion ===\"\nrg -n \"concurrent|race|timing\" packages/core/src/core/coreToolScheduler.test.ts -C3\n```\n\nLength of output: 1951\n\n---\n\n๐Ÿ Script executed:\n````\n#!/bin/bash\n\necho \"=== Creating a minimal reproduction test case ===\"\ncat \u003e /tmp/race_condition_demo.md \u003c\u003c 'EOF'\n# Race Condition in publishBufferedResults\n\n## The Problem\n\nWhen multiple tools complete in parallel, they each call `publishBufferedResults()` concurrently.\nThe function filters `this.toolCalls` each time it runs:\n\n```typescript\nconst callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n);\n```\n\nAs tools get published, their status changes from 'executing' to 'success', so they get\nfiltered OUT on subsequent calls. But `nextPublishIndex` is shared and assumes a stable array.\n\n## Example Scenario (3 parallel tools: A, B, C)\n\n### Initial state:\n- All 3 tools scheduled and start executing\n- nextPublishIndex = 0\n- toolCalls = [A(executing), B(executing), C(executing)]\n\n### Tool B completes first (fastest):\n- Buffers result, calls publishBufferedResults()\n- callsInOrder = [A, B, C] (all still 'executing')\n- nextPublishIndex = 0\n- Checks A at index 0: not ready, breaks\n- nextPublishIndex stays 0\n\n### Tool A completes second:\n- Buffers result, calls publishBufferedResults()\n- callsInOrder = [A, B, C] (might be [A, B, C] or some may be 'success')\n- nextPublishIndex = 0\n- Publishes A at index 0 โ†’ status becomes 'success', nextPublishIndex = 1\n- Publishes B at index 1 โ†’ status becomes 'success', nextPublishIndex = 2\n- Checks C at index 2: not ready, breaks\n\n### Tool C completes last:\n- Buffers result, calls publishBufferedResults()\n- callsInOrder = filter(status === 'scheduled' || 'executing')\n - A is 'success' โ†’ FILTERED OUT\n - B is 'success' โ†’ FILTERED OUT \n - C is 'executing' โ†’ INCLUDED\n- callsInOrder = [C] (length = 1)\n- nextPublishIndex = 2 (from previous calls)\n- while (2 \u003c 1) โ†’ FALSE, loop never runs\n- **C's result is never published!**\n\nThe tool appears to hang because the result sits in pendingResults forever.\n\n## Root Cause\n\nThe array `callsInOrder` is recalculated on each call based on current status,\nbut `nextPublishIndex` assumes a stable array. When already-published tools\nget filtered out, the array shrinks, making nextPublishIndex point beyond\nthe array bounds.\nEOF\n\ncat /tmp/race_condition_demo.md\n\necho -e \"\\n=== Verifying the filtering logic in the actual code ===\"\nrg -n \"callsInOrder.*filter\" packages/core/src/core/coreToolScheduler.ts -A2 -B2\n````\n\nLength of output: 2404\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver I found the root cause of the hanging parallel tool calls! It's a **race condition** in the `publishBufferedResults()` method introduced in commit `9039042c`.\n\n## The Problem\n\nWhen multiple tools execute in parallel and complete around the same time, they each call `publishBufferedResults()` concurrently. The method recalculates its working array on every invocation:\n\n```typescript\n// Line 1356-1358 in coreToolScheduler.ts\nconst callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n);\n```\n\nAs tools get published, their status changes from `'executing'` โ†’ `'success'`, so they're **filtered out** on subsequent calls. But `nextPublishIndex` is shared state that assumes a stable array.\n\n## Race Condition Scenario\n\nLet's say 3 tools (A, B, C) execute in parallel:\n\n1. **All tools start executing**: `nextPublishIndex = 0`, all have status `'executing'`\n\n2. **Tool B completes first** (fastest):\n - Calls `publishBufferedResults()`\n - `callsInOrder = [A, B, C]` (all still executing)\n - Checks index 0 (Tool A): not ready yet, breaks\n - `nextPublishIndex` stays 0\n\n3. **Tool A completes second**:\n - Calls `publishBufferedResults()`\n - `callsInOrder = [A, B, C]`\n - Publishes A at index 0 โ†’ status becomes `'success'`, `nextPublishIndex = 1`\n - Publishes B at index 1 โ†’ status becomes `'success'`, `nextPublishIndex = 2`\n - Checks C at index 2: not ready, breaks\n\n4. **Tool C completes last**:\n - Calls `publishBufferedResults()`\n - `callsInOrder = filter(...)` โ†’ **A and B filtered out (status='success')**\n - `callsInOrder = [C]` (length = 1)\n - `while (2 \u003c 1)` โ†’ **FALSE, loop never runs!**\n - **Tool C's result never gets published** โŒ\n\nThe tool appears to hang because its result sits in `pendingResults` forever.\n\n## The Fix\n\nAdd a concurrency guard to ensure only one `publishBufferedResults()` executes at a time:\n\n```typescript\nprivate isPublishingResults = false;\n\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n // Guard against concurrent execution\n if (this.isPublishingResults) {\n return;\n }\n \n this.isPublishingResults = true;\n try {\n const callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n );\n\n // Publish results in original request order\n while (this.nextPublishIndex \u003c callsInOrder.length) {\n const expectedCall = callsInOrder[this.nextPublishIndex];\n const buffered = this.pendingResults.get(expectedCall.request.callId);\n\n if (!buffered) {\n // Next result not ready yet, stop publishing\n break;\n }\n\n // Publish this result\n await this.publishResult(buffered, signal);\n\n // Remove from buffer\n this.pendingResults.delete(buffered.callId);\n this.nextPublishIndex++;\n }\n\n // Check if all tools completed\n if (\n this.nextPublishIndex === callsInOrder.length \u0026\u0026\n callsInOrder.length \u003e 0\n ) {\n // Reset for next batch\n this.nextPublishIndex = 0;\n this.pendingResults.clear();\n }\n } finally {\n this.isPublishingResults = false;\n }\n}\n```\n\nThis ensures the filtered array doesn't shrink underneath the publishing loop, preventing the index from going out of bounds.\n\n## Why This Hits GPT-5.2 More Often\n\nThe issue is timing-dependent. GPT-5.2 likely has different response patterns or uses more parallel tool calls, hitting the race condition window more frequently than other models.\n\nWould you like me to open an issue for this, or would you prefer to create a fix PR directly?\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-23T21:15:15Z","url":"https://github.com/vybestack/llxprt-code/issues/952#issuecomment-3687985254"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n\n# Fetch details of issue #957\necho \"=== Fetching issue #957 details ===\"\ngh issue view 957 --repo vybestack/llxprt-code --json number,title,body,state,createdAt,author,comments --jq '{number: .number, title: .title, body: .body, state: .state, createdAt: .createdAt, author: .author.login, comments: [.comments[] | {author: .author.login, body: .body, createdAt: .createdAt}]}'\n```\n\nLength of output: 64889\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver Yes, absolutely! **These are the same bug** โ€” just different symptoms of the race condition in `publishBufferedResults()`.\n\n## The Connection\n\n**Root Cause (identified in #957):**\nThe `publishBufferedResults()` method is called concurrently by multiple tool completion handlers when parallel tools finish simultaneously, causing a race condition where `nextPublishIndex` can be corrupted and tools hang in 'executing' state forever.\n\n**How they manifest differently:**\n\n**Issue #957** - User sees the **queue error**:\n- Tools hang in 'executing' state due to race\n- Subsequent tool calls queue up waiting\n- User hits Escape to cancel\n- Queued items abort with \"Tool call cancelled while in queue\"\n\n**Issue #952** - User sees the **hang itself**:\n- ReadFile (or any tool) appears to hang\n- Actually completed successfully, but status never updated from 'executing' to 'success'\n- No queue error visible yet because user hasn't tried to cancel\n- Just looks like the tool is \"taking forever for no reason\"\n\n## Why #952 Happens More with GPT-5.2\n\nGPT-5.2 likely:\n- Makes more parallel tool calls\n- Has faster response times, causing tools to complete closer together\n- Hits the race condition window more frequently\n\nThe race happens when tools finish within microseconds of each other (very common with fast operations like ReadFile on SSD), and both simultaneously call `publishBufferedResults()` from their completion handlers (line 1510 in coreToolScheduler.ts).\n\n## The Fix\n\nAs identified in the #957 discussion, `publishBufferedResults()` needs to be made reentrant-safe with a mutex/lock to serialize concurrent calls. This will fix both:\n- #952: Tools won't hang anymore โœ“\n- #957: No more queue buildup โ†’ no more \"cancelled while in queue\" errors โœ“\n\nWant me to open a single PR that fixes both issues by adding the mutex to `publishBufferedResults()`?\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n---\n\n```\nLearnt from: acoliver\nRepo: vybestack/llxprt-code PR: 0\nFile: :0-0\nTimestamp: 2025-12-26T14:17:41.974Z\nLearning: In packages/core/src/core/coreToolScheduler.ts, the `publishBufferedResults()` method (line 1355) is called concurrently by multiple tool completion handlers (line 1510 in each tool's `.then()` callback) when parallel tools finish simultaneously. This causes a race condition where `nextPublishIndex` can be corrupted and tools can hang in 'executing' state forever, blocking the scheduler and causing subsequent tool calls to queue indefinitely. The method must be made reentrant-safe using a mutex/lock pattern to serialize concurrent calls.\n```\n\n---\n\n```\nLearnt from: acoliver\nRepo: vybestack/llxprt-code PR: 0\nFile: :0-0\nTimestamp: 2025-12-18T14:06:22.557Z\nLearning: OpenAIResponsesProvider (packages/core/src/providers/openai-responses/OpenAIResponsesProvider.ts) currently bypasses the ephemeral truncation system by using direct `JSON.stringify(toolResponseBlock.result)` and needs to be updated to support ephemeral settings like the other providers.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-29T19:19:26Z","url":"https://github.com/vybestack/llxprt-code/issues/952#issuecomment-3697309301"}],"number":952,"title":"sometimes parallel tool calls are hanging"} diff --git a/project-plans/20251230fixes/issues/issue-957.json b/project-plans/20251230fixes/issues/issue-957.json deleted file mode 100644 index cd41d9b45..000000000 --- a/project-plans/20251230fixes/issues/issue-957.json +++ /dev/null @@ -1 +0,0 @@ -{"body":"### What happened?\n\n Reason: Error: Tool call cancelled while in queue.\n Stack trace:\n Error: Tool call cancelled while in queue.\n at AbortSignal.abortHandler (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/node_modules/@vybestack/llxprt-code-core/dist/src/core/coreToolScheduler.js:485:32)\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:845:20)\n at AbortSignal.dispatchEvent (node:internal/event_target:778:26)\n at runAbort (node:internal/abort_controller:488:10)\n at abortSignal (node:internal/abort_controller:459:3)\n at AbortController.abort (node:internal/abort_controller:507:5)\n at Object.current (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/hooks/useGeminiStream.js:233:37)\n at file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/hooks/useGeminiStream.js:268:33\n at broadcast (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/contexts/KeypressContext.js:456:17)\n at processKittyBuffer (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/contexts/KeypressContext.js:659:21)\n\nthis was on something that was taking awhile for now reason\n\n### What did you expect to happen?\n\nnot get stuck\n\n### Client information\n\n Reason: Error: Tool call cancelled while in queue.\n Stack trace:\n Error: Tool call cancelled while in queue.\n at AbortSignal.abortHandler (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/node_modules/@vybestack/llxprt-code-core/dist/src/core/coreToolScheduler.js:485:32)\n at [nodejs.internal.kHybridDispatch] (node:internal/event_target:845:20)\n at AbortSignal.dispatchEvent (node:internal/event_target:778:26)\n at runAbort (node:internal/abort_controller:488:10)\n at abortSignal (node:internal/abort_controller:459:3)\n at AbortController.abort (node:internal/abort_controller:507:5)\n at Object.current (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/hooks/useGeminiStream.js:233:37)\n at file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/hooks/useGeminiStream.js:268:33\n at broadcast (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/contexts/KeypressContext.js:456:17)\n at processKittyBuffer (file:///opt/homebrew/lib/node_modules/@vybestack/llxprt-code/dist/src/ui/contexts/KeypressContext.js:659:21)\n\n### Login information\n\n_No response_\n\n### Anything else we need to know?\n\n_No response_","coderabbit_comments":[{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated issue plan by CodeRabbit --\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ”— Similar Issues\u003c/b\u003e\u003c/summary\u003e\n\n**Possible Duplicates**\n- https://github.com/vybestack/llxprt-code/issues/61\n\n**Related Issues**\n- https://github.com/vybestack/llxprt-code/issues/101\n- https://github.com/vybestack/llxprt-code/issues/619\n- https://github.com/vybestack/llxprt-code/issues/84\n- https://github.com/vybestack/llxprt-code/issues/152\n\u003c/details\u003e\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003e๐Ÿ‘ค Suggested Assignees\u003c/b\u003e\u003c/summary\u003e\n\n- [acoliver](https://github.com/acoliver)\n\u003c/details\u003e\n\n\n---\n\u003cdetails\u003e\n\u003csummary\u003eEnable issue planning\u003c/summary\u003e\n\n\u003csub\u003eTo enable issue planning, add the following to your `.coderabbit.yaml`:\u003c/sub\u003e\n```yaml\nissue_enrichment:\n planning:\n enabled: true\n```\n\n\u003csub\u003eYou can then request a plan by commenting `@coderabbitai plan` on any issue.\u003c/sub\u003e\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e ๐Ÿงช Issue enrichment is currently in open beta.\u003c/summary\u003e\n\n\nYou can configure auto-planning by selecting labels in the issue_enrichment configuration.\n\nTo disable automatic issue enrichment, add the following to your `.coderabbit.yaml`:\n```yaml\nissue_enrichment:\n auto_enrich:\n enabled: false\n```\n\u003c/details\u003e\n\n๐Ÿ’ฌ Have feedback or questions? Drop into our [discord](https://discord.gg/coderabbit)!","createdAt":"2025-12-26T13:44:12Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692889563"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find and examine the coreToolScheduler file\nfd -t f \"coreToolScheduler\" --exec cat {}\n```\n\nLength of output: 137622\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find and examine the useGeminiStream file\nfd -t f \"useGeminiStream\" --exec cat {}\n```\n\nLength of output: 249583\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Search for any concurrency or queue limits in the scheduler\nrg -n \"maxConcurrent|queue.*limit|MAX_CONCURRENT\" --type ts --type js\n```\n\nLength of output: 562\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for where tool calls are enqueued and dequeued\nrg -nC5 \"cancelled while in queue\" --type ts --type js\n```\n\nLength of output: 884\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver thanks โ€” I dug in and have a concrete read on whatโ€™s happening and how to fix it.\n\nWhatโ€™s happening\n- When a tool is still executing or awaiting approval, CoreToolScheduler queues any new tool requests. Confirmed in packages/core/src/core/coreToolScheduler.ts schedule(): if isRunning() or isScheduling, the request is pushed to requestQueue with an AbortSignal listener; on abort it rejects with Error('Tool call cancelled while in queue.'). Lines ~812โ€“821 from the repo scan.\n- The queue only drains after all current tools reach a terminal state and after onAllToolCallsComplete finishes (see checkAndNotifyCompletion()).\n- In the UI, pressing Escape triggers cancelOngoingRequest in useGeminiStream, which currently does abortControllerRef.current.abort() first and then cancelAllToolCalls(). That ordering fires the queueโ€™s abort-handler, so queued items reject with โ€œTool call cancelled while in queue,โ€ which is the error you saw. This matches your stack (useGeminiStream.js ~233/268).\n\nWhy it looked โ€œstuckโ€\n- The scheduler intentionally runs one batch at a time; anything scheduled while another batch is executing/awaiting approval is queued. If the active tool takes a while (or if finalization work in onAllToolCallsComplete is slow), queued items sit and appear โ€œstuck.โ€ Hitting Escape then cancels them via the queueโ€™s abort path, producing the confusing message rather than a clean โ€œuser cancelled.โ€\n\nProposed fixes (low-risk)\n1) UI: swap cancel order to flush the queue before aborting the stream\n- This makes queued items get cancelled by scheduler.cancelAll() (โ€œby userโ€) instead of the queue abort-handler (โ€œwhile in queueโ€), and avoids the confusing error.\n\nFile: packages/ui/src/ui/hooks/useGeminiStream.ts (or .js, same spot as your stack)\n```ts\n// Before\nconst cancelOngoingRequest = useCallback(() =\u003e {\n if (streamingState !== StreamingState.Responding) return;\n if (turnCancelledRef.current) return;\n turnCancelledRef.current = true;\n abortControllerRef.current?.abort();\n if (abortControllerRef.current) {\n // Synchronously clear the tool queue and mark active tools as cancelled in the UI.\n cancelAllToolCalls();\n }\n // ...\n}, [/* ... */]);\n\n// After (swap order)\nconst cancelOngoingRequest = useCallback(() =\u003e {\n if (streamingState !== StreamingState.Responding) return;\n if (turnCancelledRef.current) return;\n turnCancelledRef.current = true;\n if (abortControllerRef.current) {\n // 1) Flush scheduler: cancels queued + active tool calls deterministically\n cancelAllToolCalls();\n // 2) Then abort model stream to stop further events\n abortControllerRef.current.abort();\n } else {\n // Fallback: at least abort stream\n // (kept for safety; not strictly necessary if ref is always set while responding)\n }\n // ...\n}, [/* ... */]);\n```\n\n2) Core: clarify the queue abort message (optional but helpful)\n- If the abort path still triggers (e.g., external abort), make it clearer itโ€™s a user-cancel during waiting.\n\nFile: packages/core/src/core/coreToolScheduler.ts (schedule โ†’ abortHandler)\n```ts\n// Before\nreject(new Error('Tool call cancelled while in queue.'));\n\n// After\nreject(new Error('Tool call cancelled by user while waiting to run.'));\n```\n\n3) Instrumentation: make queue/finalization causes visible (optional)\n- Log why we queued and how long finalization takes, to spot โ€œtaking awhile for no reasonโ€ cases.\n\nFile: packages/core/src/core/coreToolScheduler.ts (inside schedule() where we push to queue)\n```ts\nif (toolSchedulerLogger.enabled) {\n toolSchedulerLogger.debug(() =\u003e\n `Queued tool call(s): reason=${this.isRunning() ? 'active_calls' : 'scheduling'}; ` +\n `active_statuses=${this.toolCalls.map(c =\u003e c.status).join(',')}; queue_len=${this.requestQueue.length + 1}`\n );\n}\n```\n\nFile: packages/core/src/core/coreToolScheduler.ts (in checkAndNotifyCompletion())\n```ts\nif (this.onAllToolCallsComplete) {\n this.isFinalizingToolCalls = true;\n const t0 = Date.now();\n await this.onAllToolCallsComplete(completedCalls);\n const dt = Date.now() - t0;\n if (toolSchedulerLogger.enabled \u0026\u0026 dt \u003e 500) {\n toolSchedulerLogger.debug(() =\u003e `onAllToolCallsComplete took ${dt}ms`);\n }\n this.isFinalizingToolCalls = false;\n}\n```\n\nWhy this fixes your symptom\n- With the UI ordering change, Escape cleanly cancels both active and queued tool calls via cancelAll(), so youโ€™ll no longer see โ€œcancelled while in queue.โ€ Youโ€™ll get the existing โ€œRequest cancelled.โ€ info item in the UI, and the queue is flushed deterministically.\n- The optional message tweak and logs make future diagnosis much clearer if something truly sits in the queue longer than expected.\n\nNotes\n- Iโ€™m keeping our previous learning in mind that each tool execution must emit exactly one final ToolResponseBlock; none of the above changes alter that behavior.\n- Concurrency: by design, we batch and run tools in parallel within a batch, but we donโ€™t accept new batches until the current one finalizes. If you want us to explore allowing โ€œlate joinโ€ into the current batch for client-initiated tools, I can spec that separately.\n\nWant me to open a small follow-up PR with (1) the UI cancel ordering change, and (2) the improved core message + debug logs? If you prefer, I can also open a tracking issue for the optional instrumentation.\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T13:46:40Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692892141"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n@acoliver you're absolutely right โ€” if the queue is filling up, it means something ahead of it never finished. Let me dig into what could cause a tool call to hang and not reach a terminal state.\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 1. Find where tool call states are set/transitioned in the scheduler\necho \"=== Tool call state transitions in scheduler ===\"\nrg -nC5 \"\\.status\\s*=\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Looking for completion detection logic ===\"\nrg -nC8 \"isRunning|isComplete|terminal.*state\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 9081\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 2. Check how tools actually execute and signal completion\necho \"=== Tool execution entry points ===\"\nrg -nC5 \"executeTool|runTool|toolExecution\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Error handling in tool execution ===\"\nrg -nC5 \"catch.*tool|tool.*error\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 414\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 3. Look for timeout mechanisms or watchdogs\necho \"=== Timeout or watchdog mechanisms ===\"\nrg -nC3 \"timeout|watchdog|MAX_EXECUTION_TIME\" packages/core/src/core/\n\necho -e \"\\n=== Abort signal handling in tool execution ===\"\nrg -nC5 \"abortSignal.*execute|signal.*abort.*tool\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2435\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 4. Check the actual tool implementations for potential hang points\necho \"=== Shell command tool implementation ===\"\nfd -t f \"shellCommandTool\" --exec head -100 {}\n\necho -e \"\\n=== Looking for tools that might not resolve ===\"\nrg -nC3 \"async.*execute.*\\(\" packages/core/src/tools/ -A 20 | head -200\n```\n\nLength of output: 14286\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 5. Look for scenarios where state updates might be missed\necho \"=== Tool call status update after execution ===\"\nrg -nC8 \"await.*execute|\\.then.*status\" packages/core/src/core/coreToolScheduler.ts | head -150\n```\n\nLength of output: 47\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for where tool status transitions from scheduled -\u003e executing -\u003e terminal\necho \"=== Where tools get executed (invocation.execute calls) ===\"\nrg -nC10 \"invocation\\.execute\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 59\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find the full execution flow including status transitions\necho \"=== attemptExecutionOfScheduledCalls full implementation ===\"\nrg -nA80 \"private attemptExecutionOfScheduledCalls\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 3262\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for confirmation/approval flows that might block\necho \"=== Confirmation handling that could block ===\"\nrg -nC10 \"requiresConfirmation|ideConfirmation\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1118\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are any tools that spawn processes or have async operations without proper error handling\necho \"=== Shell command execution that might hang ===\"\nrg -nC10 \"spawn|exec|child_process\" packages/core/src/tools/ | head -300\n```\n\nLength of output: 21741\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for MCP tool execution which might hang\necho \"=== MCP tool invocation ===\"\nrg -nC10 \"class.*McpTool|execute.*mcp\" packages/core/src/tools/\n```\n\nLength of output: 27\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for MCP tool implementation - might be in a different file\necho \"=== Finding MCP tool files ===\"\nfd -t f \"mcp.*tool\" packages/core/src/tools/\nfd -t f \"mcp-tool\" packages/core/src/tools/\n```\n\nLength of output: 184\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get the full DiscoveredTool execute implementation to see if close event is handled\necho \"=== Full DiscoveredTool execute method ===\"\nrg -nA50 \"class DiscoveredTool\" packages/core/src/tools/tool-registry.ts | head -100\n```\n\nLength of output: 3572\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if the spawned process properly handles all exit scenarios\necho \"=== Checking spawn promise resolution in DiscoveredTool ===\"\nsed -n '71,130p' packages/core/src/tools/tool-registry.ts\n```\n\nLength of output: 1782\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for the catch block completion in attemptExecutionOfScheduledCalls\necho \"=== Full catch block in attemptExecutionOfScheduledCalls ===\"\nrg -nA20 \"\\.catch\\(async \\(executionError\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 704\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there's any scenario where execute() doesn't complete\necho \"=== Looking for unhandled promise scenarios ===\"\nrg -nC5 \"new Promise.*resolve.*reject\" packages/core/src/tools/tool-registry.ts\n```\n\nLength of output: 405\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check the MCP tool execute method for similar issues\necho \"=== MCP Tool execute implementation ===\"\nrg -nA60 \"async execute\" packages/core/src/tools/mcp-tool.ts | head -150\n```\n\nLength of output: 2112\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for abort signal handling in the spawned child process\necho \"=== Check if abort signal is used anywhere in DiscoveredTool ===\"\nrg -nC10 \"signal.*abort|abort.*signal\" packages/core/src/tools/tool-registry.ts | head -100\n```\n\nLength of output: 64\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if other tools properly handle abort signals\necho \"=== How other tools handle abort signal ===\"\nrg -nB5 -A10 \"if.*signal.*aborted|signal\\.aborted\" packages/core/src/tools/ | head -150\n```\n\nLength of output: 9726\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver ah, you're absolutely right! I found it. The **actual** hang is in the `DiscoveredTool` implementation (custom project tools discovered via your tool discovery command). Let me show you:\n\n## Root Cause: DiscoveredTool doesn't respect abort signals\n\n**File: `packages/core/src/tools/tool-registry.ts` (lines 71-87)**\n\nThe `DiscoveredTool.execute()` method spawns a child process but **never wires up the abort signal**:\n\n```typescript\nasync execute(\n params: ToolParams,\n _signal: AbortSignal, // โ† Named with underscore = not used!\n _updateOutput?: (output: string) =\u003e void,\n): Promise\u003cToolResult\u003e {\n const callCommand = this.config.getToolCallCommand()!;\n const child = spawn(callCommand, [this.name]);\n child.stdin.write(JSON.stringify(params));\n child.stdin.end();\n\n // ... collect stdout/stderr ...\n\n await new Promise\u003cvoid\u003e((resolve) =\u003e { // โ† Only resolve, no abort path!\n // ... setup listeners for stdout, stderr, error, close ...\n child.on('close', onClose); // โ† Only resolves when child exits\n });\n```\n\n**What happens when a discovered tool hangs or runs for a long time:**\n1. The spawned child process never exits (or takes forever)\n2. The `child.on('close')` event never fires\n3. The Promise never resolves\n4. Tool status stays `'executing'` forever\n5. `scheduler.isRunning()` returns `true` indefinitely\n6. All subsequent tool calls queue up waiting\n7. User hits Escape โ†’ queue items abort with \"cancelled while in queue\"\n\n**Contrast with other tools that work correctly:**\n- **MCP tool** (lines 142-168 in mcp-tool.ts): checks `signal.aborted` upfront, races promise with abort listener, cleans up on abort\n- **Shell tool** (line 237 in shell.ts): checks `signal.aborted` before spawning, adds abort listener to kill process\n- **Task tool** (lines 196, 247, 259, 287): checks `signal.aborted` at multiple points, wires abort to kill child agents\n\n## The Fix\n\nWire the abort signal to kill the child process if it's cancelled:\n\n**File: `packages/core/src/tools/tool-registry.ts` (in `DiscoveredTool.execute`)**\n```typescript\nasync execute(\n params: ToolParams,\n signal: AbortSignal, // Remove underscore\n _updateOutput?: (output: string) =\u003e void,\n): Promise\u003cToolResult\u003e {\n // Check abort before spawning\n if (signal.aborted) {\n return {\n llmContent: 'Tool call was cancelled before execution.',\n returnDisplay: 'Tool cancelled.',\n error: {\n message: 'Tool call cancelled',\n type: ToolErrorType.TOOL_CALL_CANCELLED,\n },\n };\n }\n\n const callCommand = this.config.getToolCallCommand()!;\n const child = spawn(callCommand, [this.name]);\n child.stdin.write(JSON.stringify(params));\n child.stdin.end();\n\n let stdout = '';\n let stderr = '';\n let error: Error | null = null;\n let code: number | null = null;\n let childSignal: NodeJS.Signals | null = null;\n let aborted = false;\n\n await new Promise\u003cvoid\u003e((resolve, reject) =\u003e {\n // Abort handler: kill child and reject\n const onAbort = () =\u003e {\n aborted = true;\n cleanup();\n child.kill('SIGTERM');\n // Give it a moment, then force kill\n setTimeout(() =\u003e {\n if (!child.killed) {\n child.kill('SIGKILL');\n }\n }, 1000);\n const err = new Error('Tool call cancelled by user');\n err.name = 'AbortError';\n reject(err);\n };\n\n const onStdout = (data: Buffer) =\u003e {\n stdout += data?.toString();\n };\n\n const onStderr = (data: Buffer) =\u003e {\n stderr += data?.toString();\n };\n\n const onError = (err: Error) =\u003e {\n error = err;\n };\n\n const onClose = (\n _code: number | null,\n _signal: NodeJS.Signals | null,\n ) =\u003e {\n code = _code;\n childSignal = _signal;\n cleanup();\n if (aborted) {\n // Already rejected via onAbort\n return;\n }\n resolve();\n };\n\n const cleanup = () =\u003e {\n child.stdout.removeListener('data', onStdout);\n child.stderr.removeListener('data', onStderr);\n child.removeListener('error', onError);\n child.removeListener('close', onClose);\n signal.removeEventListener('abort', onAbort);\n if (child.connected) {\n child.disconnect();\n }\n };\n\n signal.addEventListener('abort', onAbort, { once: true });\n child.stdout.on('data', onStdout);\n child.stderr.on('data', onStderr);\n child.on('error', onError);\n child.on('close', onClose);\n });\n\n // If aborted, return cancelled result\n if (aborted || signal.aborted) {\n return {\n llmContent: 'Tool call was cancelled during execution.',\n returnDisplay: 'Tool cancelled.',\n error: {\n message: 'Tool call cancelled',\n type: ToolErrorType.TOOL_CALL_CANCELLED,\n },\n };\n }\n\n // ... rest of existing error/success handling ...\n```\n\n**Why this fixes the hang:**\n- When the tool is taking too long and user hits Escape, the abort signal fires\n- The `onAbort` handler immediately kills the child process (SIGTERM, then SIGKILL after 1s)\n- The Promise rejects with AbortError\n- The scheduler's catch block in `attemptExecutionOfScheduledCalls` (line 1518) handles it, transitions tool to 'cancelled'\n- Tool reaches terminal state, scheduler stops blocking, queue drains\n\n**Impact:**\n- Only affects \"discovered tools\" (custom project tools registered via `tool_discovery_command` config)\n- Built-in tools (shell, edit, grep, etc.) already handle abort properly\n- MCP tools already handle abort properly\n\nWant me to open a PR with this fix? This should resolve the actual root cause where tools hang indefinitely and block the scheduler.\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T13:51:01Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692896737"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find built-in tools that perform read operations\necho \"=== Finding built-in tools that perform reads ===\"\nfd -t f \"Tool.ts\" packages/core/src/tools/ --exec basename {}\n\necho -e \"\\n=== Looking for read_file tool implementation ===\"\nrg -nA60 \"class.*ReadFile|export.*readFile.*Tool\" packages/core/src/tools/\n\necho -e \"\\n=== Check grep tool implementation ===\"\nrg -nA60 \"class.*Grep|export.*grep.*Tool\" packages/core/src/tools/\n\necho -e \"\\n=== Check search tool implementation ===\"\nrg -nA60 \"class.*Search|export.*search.*Tool\" packages/core/src/tools/\n```\n\nLength of output: 54379\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check abort signal handling in file reading tools\necho \"=== Abort signal handling in read_file ===\"\nrg -nC10 \"signal.*abort|abort.*signal\" packages/core/src/tools/readFileTool.ts\n\necho -e \"\\n=== Check if fs operations can hang ===\"\nrg -nC5 \"fs\\.readFile|fs\\.read|createReadStream\" packages/core/src/tools/readFileTool.ts packages/core/src/tools/grepTool.ts packages/core/src/tools/searchTool.ts\n```\n\nLength of output: 397\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for timeout mechanisms in the core scheduler or tools\necho \"=== Timeout mechanisms ===\"\nrg -nC5 \"setTimeout|TIMEOUT|timeout\" packages/core/src/core/coreToolScheduler.ts | head -100\n\necho -e \"\\n=== Check if there's a tool execution timeout ===\"\nrg -nC3 \"MAX_EXECUTION|TOOL_TIMEOUT|execution.*timeout\" packages/core/src/\n```\n\nLength of output: 1119\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get full readFileTool implementation\necho \"=== Full readFileTool implementation ===\"\ncat packages/core/src/tools/readFileTool.ts\n```\n\nLength of output: 112\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check for any streaming or async operations that might not complete\necho \"=== Looking for potential hang points in built-in tools ===\"\nrg -nC8 \"new Promise|async.*function.*execute\" packages/core/src/tools/readFileTool.ts packages/core/src/tools/grepTool.ts packages/core/src/tools/searchTool.ts | head -200\n```\n\nLength of output: 293\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get the full read-file.ts implementation\necho \"=== Full read-file.ts execute method ===\"\nsed -n '156,290p' packages/core/src/tools/read-file.ts\n```\n\nLength of output: 4216\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find processSingleFileContent implementation - it's the actual file reading logic\necho \"=== Finding processSingleFileContent ===\"\nrg -nA80 \"export.*function processSingleFileContent|async function processSingleFileContent\" packages/core/src/tools/\n```\n\nLength of output: 40\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if any built-in tool execute methods accept but don't use abort signal\necho \"=== Tools that accept signal but may not use it ===\"\nrg -nB5 -A20 \"async execute\\(.*signal.*\\)\" packages/core/src/tools/read-file.ts packages/core/src/tools/ripGrep.ts packages/core/src/tools/grep.ts | head -150\n```\n\nLength of output: 3906\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for file system operations that could block\necho \"=== File system operations in read-file.ts ===\"\nrg -nC8 \"fs\\.|FileSystemService|readFile|statSync\" packages/core/src/tools/read-file.ts\n```\n\nLength of output: 633\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are any file operations that might hang on network mounts\necho \"=== Looking for synchronous fs operations ===\"\nrg -n \"\\.statSync|\\.readFileSync|\\.readdirSync\" packages/core/src/\n```\n\nLength of output: 10668\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find processSingleFileContent to see if it supports abort signals\necho \"=== Finding processSingleFileContent implementation ===\"\nrg -nB5 -A50 \"function processSingleFileContent\" packages/core/src/\n```\n\nLength of output: 4371\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check how the scheduler calls tool execute methods\necho \"=== How scheduler calls tool execute ===\"\nrg -nC10 \"invocation\\.execute\\(\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 40\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check ls tool execute signature\necho \"=== ls tool execute signature ===\"\nrg -nB5 -A10 \"async execute\" packages/core/src/tools/ls.ts\n```\n\nLength of output: 702\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there are other tools missing abort signal\necho \"=== Finding all tool execute methods ===\"\nrg -n \"async execute\\(\" packages/core/src/tools/*.ts | grep -v \".test.ts\"\n```\n\nLength of output: 2278\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check how execute is called in the scheduler to understand the signature\necho \"=== Finding where tools are executed ===\"\nrg -nB3 -A15 \"\\.execute\\(.*signal\" packages/core/src/core/coreToolScheduler.ts | head -80\n```\n\nLength of output: 678\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check the base tool interface to understand the expected signature\necho \"=== Base tool invocation interface ===\"\nrg -nB5 -A10 \"abstract.*execute|execute.*AbortSignal\" packages/core/src/tools/tools.ts | head -100\n```\n\nLength of output: 1024\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get more context on processSingleFileContent to see if it does any long-running I/O\necho \"=== processSingleFileContent continued ===\"\nsed -n '313,450p' packages/core/src/utils/fileUtils.ts\n```\n\nLength of output: 5226\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver ah, you're absolutely right! I found it. The **`read_file` tool cannot be cancelled** because it doesn't accept or use the abort signal at all.\n\n## Root Cause: read_file tool ignores abort signals\n\n**File: `packages/core/src/tools/read-file.ts` (line 156)**\n\n```typescript\nasync execute(): Promise\u003cToolResult\u003e { // โ† Missing signal parameter!\n const result = await processSingleFileContent(\n this.getFilePath(),\n this.config.getTargetDir(),\n this.config.getFileSystemService(),\n this.params.offset,\n this.params.limit,\n );\n // ... rest of method\n}\n```\n\n**Compare with working tools:**\n- `grep.ts` line 133: `async execute(signal: AbortSignal): Promise\u003cToolResult\u003e`\n- `ripGrep.ts` line 109: `async execute(signal: AbortSignal): Promise\u003cToolResult\u003e`\n- `shell.ts` line 213: `async execute(signal: AbortSignal): Promise\u003cToolResult\u003e`\n\n**What happens when a read hangs:**\n1. File I/O blocks on: network mount, file lock, slow disk, large file on HDD, etc.\n2. `processSingleFileContent` calls `fs.promises.stat()` and `fs.promises.readFile()` which can hang indefinitely\n3. Tool status stays `'executing'` forever because the Promise never resolves\n4. Scheduler's `isRunning()` returns `true` indefinitely\n5. All new tool calls queue up waiting\n6. User hits Escape โ†’ queued items abort with \"cancelled while in queue\"\n\n**Why this wasn't caught:**\n- The base class defines: `abstract execute(signal: AbortSignal, updateOutput?: (output: string) =\u003e void): Promise\u003cTResult\u003e;`\n- But TypeScript allows subclasses to ignore unused parameters (it's \"contravariant\" on parameters)\n- So `read-file` compiles fine with `execute(): Promise\u003cToolResult\u003e`, but the signal is never received\n\n## The Fix\n\n**1) Update `ReadFileToolInvocation.execute()` to accept and check the signal:**\n\n```typescript\n// File: packages/core/src/tools/read-file.ts\nasync execute(signal: AbortSignal): Promise\u003cToolResult\u003e { // Add signal parameter\n // Check abort before starting\n if (signal.aborted) {\n return {\n llmContent: 'File read was cancelled before execution.',\n returnDisplay: 'Read cancelled.',\n error: {\n message: 'File read cancelled',\n type: ToolErrorType.TOOL_CALL_CANCELLED,\n },\n };\n }\n\n const result = await processSingleFileContent(\n this.getFilePath(),\n this.config.getTargetDir(),\n this.config.getFileSystemService(),\n this.params.offset,\n this.params.limit,\n signal, // Pass signal down\n );\n\n // Check abort after I/O\n if (signal.aborted) {\n return {\n llmContent: 'File read was cancelled during execution.',\n returnDisplay: 'Read cancelled.',\n error: {\n message: 'File read cancelled',\n type: ToolErrorType.TOOL_CALL_CANCELLED,\n },\n };\n }\n\n // ... rest of existing logic\n}\n```\n\n**2) Update `processSingleFileContent` to accept and respect the signal:**\n\n```typescript\n// File: packages/core/src/utils/fileUtils.ts\nexport async function processSingleFileContent(\n filePath: string,\n rootDirectory: string,\n fileSystemService: FileSystemService,\n offset?: number,\n limit?: number,\n signal?: AbortSignal, // Add signal parameter\n): Promise\u003cProcessedFileReadResult\u003e {\n try {\n // Check abort before I/O\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n\n if (!fs.existsSync(filePath)) {\n return {\n llmContent: 'Could not read file because no file was found at the specified path.',\n returnDisplay: 'File not found.',\n error: `File not found: ${filePath}`,\n errorType: ToolErrorType.FILE_NOT_FOUND,\n };\n }\n\n const stats = await fs.promises.stat(filePath);\n \n // Check abort after stat (which can hang on network mounts)\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n\n if (stats.isDirectory()) {\n // ... existing logic\n }\n\n const fileSizeInMB = stats.size / (1024 * 1024);\n if (fileSizeInMB \u003e 20) {\n // ... existing logic\n }\n\n const fileType = await detectFileType(filePath);\n const relativePathForDisplay = path\n .relative(rootDirectory, filePath)\n .replace(/\\\\/g, '/');\n\n switch (fileType) {\n case 'text': {\n // Check abort before expensive read\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n \n const content = await readFileWithEncoding(filePath);\n \n // Check abort after read (which can take time for large files)\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n \n // ... rest of text handling\n }\n case 'image':\n case 'pdf':\n case 'audio':\n case 'video': {\n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n \n const contentBuffer = await fs.promises.readFile(filePath);\n \n if (signal?.aborted) {\n return {\n llmContent: 'File read was cancelled.',\n returnDisplay: 'Read cancelled.',\n error: 'File read cancelled',\n errorType: ToolErrorType.TOOL_CALL_CANCELLED,\n };\n }\n \n // ... rest of binary handling\n }\n }\n } catch (error) {\n // ... existing error handling\n }\n}\n```\n\n**Other tools with the same issue:**\n- `read_line_range.ts` (line 144): also missing signal parameter\n- `insert_at_line.ts` (line 70): also missing signal\n- `delete_line_range.ts` (line 69): also missing signal\n- `list-subagents.ts` (line 63): also missing signal\n\n**Why this fixes your symptom:**\n- When `read_file` hangs on slow I/O, Escape now immediately cancels it via the abort signal\n- Tool reaches terminal state ('cancelled') instead of hanging in 'executing'\n- Scheduler stops blocking and processes/drains the queue\n- No more \"cancelled while in queue\" errors because the queue doesn't build up\n\nWant me to open a PR with:\n1. The `read_file` tool abort signal handling (highest priority since that's what you hit)\n2. The other file operation tools (`read_line_range`, `insert_at_line`, `delete_line_range`)\n3. Optional: Add a 30-second timeout as a safety net for tools that don't properly handle abort?\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T14:06:18Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692919676"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 1. Look at how isRunning() determines if tools are still executing\necho \"=== isRunning() implementation ===\"\nrg -nA15 \"private isRunning\\(\\)|public isRunning\\(\\)\" packages/core/src/core/coreToolScheduler.ts\n\n# 2. Look at how parallel tools are executed and how completion is tracked\necho -e \"\\n=== Parallel tool execution with Promise.allSettled ===\"\nrg -nB5 -A30 \"Promise\\.allSettled\" packages/core/src/core/coreToolScheduler.ts\n\n# 3. Look at checkAndNotifyCompletion for how it determines all tools are done\necho -e \"\\n=== checkAndNotifyCompletion implementation ===\"\nrg -nB5 -A40 \"private.*checkAndNotifyCompletion|async checkAndNotifyCompletion\" packages/core/src/core/coreToolScheduler.ts\n\n# 4. Look for where tool status is updated after execution completes\necho -e \"\\n=== Status updates after tool execution ===\"\nrg -nC8 \"toolCall\\.status = .*completed|toolCall\\.status = .*failed|toolCall\\.status = .*cancelled\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2248\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find where parallel execution happens and where status transitions occur\necho \"=== Looking for parallel execution logic ===\"\nrg -nB10 -A50 \"attemptExecutionOfScheduledCalls\" packages/core/src/core/coreToolScheduler.ts | head -200\n\necho -e \"\\n=== Find where status is set to completed/success/failed ===\"\nrg -n \"\\.status = 'success'|\\.status = 'completed'|\\.status = 'error'|\\.status = 'failed'\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Look for forEach or map operations that update tool call status ===\"\nrg -nC10 \"forEach.*toolCall|map.*toolCall.*status\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 7816\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Get the full .then() and .catch() blocks for parallel tool execution\necho \"=== Full parallel execution promise handling ===\"\nsed -n '1494,1540p' packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== setStatusInternal implementation ===\"\nrg -nB3 -A20 \"private setStatusInternal\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Look for checkAndNotifyCompletion calls ===\"\nrg -n \"checkAndNotifyCompletion\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 3271\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look at the buffering and publishing logic that handles parallel completion\necho \"=== bufferResult implementation ===\"\nrg -nB5 -A30 \"private bufferResult\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== publishBufferedResults implementation ===\"\nrg -nB5 -A50 \"private.*publishBufferedResults|async publishBufferedResults\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== bufferError implementation ===\"\nrg -nB5 -A20 \"private bufferError\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 3589\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look at how the execution order is tracked - there's an executionIndices map\necho \"=== How execution indices are assigned ===\"\nsed -n '1460,1480p' packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== publishResult implementation that sets status ===\"\nrg -nA50 \"private async publishResult\" packages/core/src/core/coreToolScheduler.ts | head -100\n\necho -e \"\\n=== Check if nextPublishIndex is ever reset ===\"\nrg -n \"nextPublishIndex\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2911\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver you're absolutely right! I found the **actual bug** โ€” it's a race condition in the parallel tool completion logic that causes tools to get stuck in 'executing' status even after they finish.\n\n## Root Cause: Array index mismatch during parallel execution\n\n**The Bug (lines 1355-1386 in coreToolScheduler.ts):**\n\nWhen tools execute in parallel, each completion tries to publish results in order using `nextPublishIndex`. But there's a critical flaw:\n\n1. `publishBufferedResults()` filters `toolCalls` for status `'scheduled'` OR `'executing'` (line 1357)\n2. As results publish, tool status changes from `'executing'` โ†’ `'success'` (line 1435)\n3. The **next** call to `publishBufferedResults()` rebuilds `callsInOrder` with the filter\n4. Already-published tools (`'success'`) are **excluded** from the new array\n5. But `nextPublishIndex` is a **class field** that keeps incrementing\n6. Array shrinks, but the index keeps growing โ†’ **index out of bounds**\n\n**Concrete example with 3 parallel tools [A, B, C]:**\n\n```\nTool A completes first:\n callsInOrder = [A, B, C] // all 'executing'\n nextPublishIndex = 0\n โ†’ Publish A at callsInOrder[0] โœ“\n โ†’ A status = 'success'\n โ†’ nextPublishIndex = 1\n\nTool B completes:\n callsInOrder = [B, C] // A excluded (status='success')\n nextPublishIndex = 1 // still pointing at original index\n โ†’ expectedCall = callsInOrder[1] = C\n โ†’ C not ready yet, break\n โ†’ B is ready but at callsInOrder[0], never checked!\n\nTool C completes:\n callsInOrder = [C] // A and B excluded\n nextPublishIndex = 1 // still!\n โ†’ while (1 \u003c 1) is FALSE, loop never executes!\n โ†’ B and C never published\n\nResult:\n โœ— B and C remain in status='executing' forever\n โœ— isRunning() returns true (line 759: checks for 'executing')\n โœ— All new tool calls queue indefinitely\n โœ— User hits Escape โ†’ \"cancelled while in queue\"\n```\n\n**Why the reset never triggers:**\n\nThe reset condition checks `nextPublishIndex === callsInOrder.length`, but:\n- After A publishes: nextPublishIndex=1, callsInOrder.length=2 (B, C remain) โ†’ no reset\n- After B should publish but doesn't: nextPublishIndex=1, callsInOrder.length=1 (C remains) โ†’ no reset\n- After C should publish but doesn't: nextPublishIndex=1, callsInOrder.length=1 โ†’ no reset\n\n## The Fix\n\n**Option 1: Use the execution index from buffered results (cleanest)**\n\n```typescript\n// File: packages/core/src/core/coreToolScheduler.ts\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n // Get all calls that were executing in this batch (use original execution order)\n const allExecutingCalls = Array.from(this.pendingResults.values())\n .sort((a, b) =\u003e a.executionIndex - b.executionIndex);\n\n const totalInBatch = allExecutingCalls.length;\n if (totalInBatch === 0) return;\n\n // Publish results in execution order\n for (const buffered of allExecutingCalls) {\n const callId = buffered.callId;\n \n // Skip if already published\n const currentCall = this.toolCalls.find(c =\u003e c.request.callId === callId);\n if (!currentCall || currentCall.status !== 'executing') {\n continue;\n }\n\n // Publish this result\n await this.publishResult(buffered, signal);\n \n // Remove from buffer\n this.pendingResults.delete(callId);\n }\n\n // Reset for next batch if all published\n const anyStillExecuting = this.toolCalls.some(c =\u003e c.status === 'executing');\n if (!anyStillExecuting \u0026\u0026 this.pendingResults.size === 0) {\n this.nextPublishIndex = 0;\n }\n}\n```\n\n**Option 2: Track the original batch size (minimal change)**\n\n```typescript\n// File: packages/core/src/core/coreToolScheduler.ts\n\n// Add class field (around line 413):\nprivate originalBatchSize = 0;\n\n// In attemptExecutionOfScheduledCalls (around line 1464):\nconst callsToExecute = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled',\n);\n\n// Store original batch size before execution starts\nthis.originalBatchSize = callsToExecute.length;\n\n// In publishBufferedResults (line 1355):\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n // Don't filter - keep ALL calls to maintain stable indices\n const callsInOrder = this.toolCalls.slice(0, this.originalBatchSize);\n\n // Publish results in original request order\n while (this.nextPublishIndex \u003c callsInOrder.length) {\n const expectedCall = callsInOrder[this.nextPublishIndex];\n const buffered = this.pendingResults.get(expectedCall.request.callId);\n\n if (!buffered) {\n // Next result not ready yet, stop publishing\n break;\n }\n\n // Publish this result\n await this.publishResult(buffered, signal);\n\n // Remove from buffer\n this.pendingResults.delete(buffered.callId);\n this.nextPublishIndex++;\n }\n\n // Check if all tools completed\n if (\n this.nextPublishIndex === this.originalBatchSize \u0026\u0026\n this.originalBatchSize \u003e 0\n ) {\n // Reset for next batch\n this.nextPublishIndex = 0;\n this.originalBatchSize = 0;\n this.pendingResults.clear();\n }\n}\n```\n\n**Why this explains your symptom:**\n- Read tool completed successfully (I/O was fine, as you said)\n- But if 2+ tools were executing in parallel, the second one got stuck in 'executing' status despite finishing\n- Scheduler's `isRunning()` kept returning true\n- Queue built up waiting for the \"stuck\" tool\n- Escape triggered queue abort โ†’ \"cancelled while in queue\" errors\n\nWant me to open a PR with Option 1 (the cleanest fix)? This should completely resolve the parallel tool hang issue.\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n---\n\n```\nLearnt from: acoliver\nRepo: vybestack/llxprt-code PR: 0\nFile: :0-0\nTimestamp: 2025-12-18T14:06:22.557Z\nLearning: In the llxprt-code codebase, the `tool-output-max-items` ephemeral setting only applies to tools that return lists/collections (glob, grep, read-many-files), not to single-output tools like shell. Not all ephemerals are relevant to every tool.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T14:16:34Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692931897"},{"author":"coderabbitai","body":"\u003c!-- This is an auto-generated reply by CodeRabbit --\u003e\n\u003e [!TIP]\n\u003e For best results, initiate chat on the files or code changes.\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿงฉ Analysis chain\u003c/summary\u003e\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 1. Look at how parallel tools are actually executed and coordinated\necho \"=== How parallel tool execution works ===\"\nrg -nA30 \"Promise\\.all.*executionPromises|await Promise\\.all\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Check the state management during parallel execution ===\"\nrg -nB5 -A10 \"isRunning\\(\\)|isScheduling|isFinalizingToolCalls\" packages/core/src/core/coreToolScheduler.ts | head -100\n```\n\nLength of output: 3438\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 2. Look for race conditions in checkAndNotifyCompletion\necho \"=== Full checkAndNotifyCompletion implementation ===\"\nrg -nA50 \"private.*checkAndNotifyCompletion\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1844\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 3. Check how requestQueue is drained after completion\necho \"=== How requestQueue is processed ===\"\nrg -nB5 -A25 \"this\\.requestQueue\" packages/core/src/core/coreToolScheduler.ts | head -150\n```\n\nLength of output: 3799\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 4. Look for scenarios where a tool could get stuck in 'executing' state\necho \"=== All places where tool status is set to 'executing' ===\"\nrg -n \"status.*=.*'executing'|status.*=.*Executing\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== All places where tool status transitions FROM 'executing' ===\"\nrg -nB3 -A3 \"status.*=.*'(completed|error|cancelled)'\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1803\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 5. Check if there's any async/await issue in the execution flow\necho \"=== Full attemptExecutionOfScheduledCalls to check for Promise handling issues ===\"\nsed -n '1470,1600p' packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 4187\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# 6. Look for any scenario where the scheduler could think it's running when it's not\necho \"=== How isRunning is implemented ===\"\nrg -nA15 \"private isRunning\\(\\)|public isRunning\\(\\)\" packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== How isScheduling is tracked ===\"\nrg -nC5 \"isScheduling\\s*=\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1744\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Find publishBufferedResults to see if it triggers checkAndNotifyCompletion\necho \"=== publishBufferedResults implementation ===\"\nrg -nA40 \"private.*publishBufferedResults|async publishBufferedResults\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1419\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check bufferResult implementation to understand the flow\necho \"=== bufferResult and bufferError implementations ===\"\nrg -nA25 \"private bufferResult|private bufferError\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1306\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look for all places where checkAndNotifyCompletion is called\necho \"=== All calls to checkAndNotifyCompletion ===\"\nrg -n \"checkAndNotifyCompletion\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 242\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check if there's a race condition in the status update + completion check flow\necho \"=== setStatusInternal implementation ===\"\nrg -nA20 \"private setStatusInternal\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1551\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Look at the entire flow of how parallel execution is coordinated\necho \"=== Looking for Promise tracking or coordination ===\"\nrg -nC5 \"executionPromises|allSettled|Promise\\.all\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 52\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check the context around the other checkAndNotifyCompletion calls\necho \"=== Context around line 709 checkAndNotifyCompletion call ===\"\nsed -n '700,720p' packages/core/src/core/coreToolScheduler.ts\n\necho -e \"\\n=== Context around line 1692 checkAndNotifyCompletion call ===\"\nsed -n '1680,1700p' packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 1170\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Check the publishResult implementation to see what happens after status is set\necho \"=== Full publishResult implementation ===\"\nrg -nA60 \"private async publishResult\\(\" packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2048\n\n---\n\n๐Ÿ Script executed:\n```\n#!/bin/bash\n# Let's trace the exact flow - find what calls publishResult and what happens after\necho \"=== Looking for what happens AFTER setStatusInternal in publishResult ===\"\nsed -n '1389,1470p' packages/core/src/core/coreToolScheduler.ts\n```\n\nLength of output: 2489\n\n---\n\n\n\n\u003c/details\u003e\n\n@acoliver **Found it!** The root cause is a **race condition in parallel tool execution** โ€” exactly what you suspected.\n\n## The Bug: publishBufferedResults is not reentrant-safe\n\n**File: `packages/core/src/core/coreToolScheduler.ts`**\n\nWhen multiple tools complete at nearly the same time (very common with fast operations like reading small files on SSD), they **both concurrently call** `publishBufferedResults()`:\n\n**Line 1497-1510** (in each tool's `.then()` handler):\n```typescript\ninvocation\n .execute(signal, liveOutputCallback)\n .then(async (toolResult: ToolResult) =\u003e {\n // Buffer the result\n this.bufferResult(callId, toolName, toolResult, scheduledCall, executionIndex);\n // โš ๏ธ Multiple tools call this concurrently!\n await this.publishBufferedResults(signal);\n })\n```\n\n**Line 1355-1387** (publishBufferedResults implementation):\n```typescript\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n const callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n );\n\n // โš ๏ธ NOT SYNCHRONIZED - multiple concurrent calls interleave here!\n while (this.nextPublishIndex \u003c callsInOrder.length) {\n const expectedCall = callsInOrder[this.nextPublishIndex];\n const buffered = this.pendingResults.get(expectedCall.request.callId);\n\n if (!buffered) {\n break; // โš ๏ธ Can break prematurely if racing with another call\n }\n\n await this.publishResult(buffered, signal); // โš ๏ธ Awaits, allowing interleaving\n this.pendingResults.delete(buffered.callId);\n this.nextPublishIndex++; // โš ๏ธ Not atomic!\n }\n \n // Reset logic (lines 1379-1386)\n}\n```\n\n## How the Race Causes Your Hang\n\n**Timeline with 2 parallel ReadFile tools:**\n\n1. **Both tools finish around the same time** (microseconds apart on fast SSD)\n2. **Tool 1's `.then()` handler starts:**\n - Buffers result 1 in `pendingResults`\n - Calls `await publishBufferedResults()`\n - Enters the while loop, sees `nextPublishIndex = 0`\n - Gets result 1 from `pendingResults`\n - Starts `await this.publishResult(...)` โ€” **suspends here**\n3. **Tool 2's `.then()` handler starts** (while #1 is awaiting):\n - Buffers result 2 in `pendingResults`\n - **Also** calls `await publishBufferedResults()`\n - Enters the **same** while loop, also sees `nextPublishIndex = 0`\n - Gets result 1 from `pendingResults` (still there!)\n - Tries to publish result #1 again, or...\n4. **Interleaving chaos:**\n - If 1 finishes first and increments `nextPublishIndex` to 1\n - Then 2 might see the change mid-loop\n - Or 2 might skip an index\n - Or 2 might see `buffered = undefined` when checking index 1 (if #1 hasn't buffered yet due to execution order)\n - Result: `break` exits early (line 1367)\n5. **The reset logic (lines 1379-1386) never triggers** because:\n - `nextPublishIndex` doesn't reach `callsInOrder.length` due to early break\n - `pendingResults` never clears\n6. **Tool 2 stays in `status = 'executing'` forever:**\n - `publishResult()` never gets called for tool 2\n - `setStatusInternal(callId, 'success', ...)` never happens\n - Tool never transitions to terminal state\n7. **Scheduler blocks indefinitely:**\n - `isRunning()` sees tool 2 with `status = 'executing'`, returns `true`\n - All new tool calls queue up\n - User hits Escape โ†’ queue items abort with \"cancelled while in queue\"\n\n## The Fix\n\nAdd a lock to ensure only **one** `publishBufferedResults` runs at a time:\n\n```typescript\n// Add at class level (around line 400)\nprivate isPublishing = false;\nprivate publishQueue: Array\u003c{\n signal: AbortSignal;\n resolve: () =\u003e void;\n reject: (err: Error) =\u003e void;\n}\u003e = [];\n\nprivate async publishBufferedResults(signal: AbortSignal): Promise\u003cvoid\u003e {\n // If already publishing, queue this request\n if (this.isPublishing) {\n return new Promise\u003cvoid\u003e((resolve, reject) =\u003e {\n this.publishQueue.push({ signal, resolve, reject });\n });\n }\n\n this.isPublishing = true;\n try {\n await this._publishBufferedResultsImpl(signal);\n } finally {\n this.isPublishing = false;\n // Process next queued publish request\n const next = this.publishQueue.shift();\n if (next) {\n this._publishBufferedResultsImpl(next.signal)\n .then(next.resolve)\n .catch(next.reject);\n }\n }\n}\n\nprivate async _publishBufferedResultsImpl(signal: AbortSignal): Promise\u003cvoid\u003e {\n // Original implementation (lines 1356-1387) goes here\n const callsInOrder = this.toolCalls.filter(\n (call) =\u003e call.status === 'scheduled' || call.status === 'executing',\n );\n\n while (this.nextPublishIndex \u003c callsInOrder.length) {\n const expectedCall = callsInOrder[this.nextPublishIndex];\n const buffered = this.pendingResults.get(expectedCall.request.callId);\n\n if (!buffered) {\n break;\n }\n\n await this.publishResult(buffered, signal);\n this.pendingResults.delete(buffered.callId);\n this.nextPublishIndex++;\n }\n\n if (\n this.nextPublishIndex === callsInOrder.length \u0026\u0026\n callsInOrder.length \u003e 0\n ) {\n this.nextPublishIndex = 0;\n this.pendingResults.clear();\n }\n}\n```\n\n## Why This Fixes Your Issue\n\n- **Serializes all calls to `publishBufferedResults`** โ€” only one runs at a time\n- **Preserves order** โ€” results still publish in execution order\n- **No race on `nextPublishIndex`** โ€” it's only modified by one caller at a time\n- **Tools transition to terminal state reliably** โ€” `setStatusInternal(callId, 'success')` always gets called\n- **Queue drains properly** โ€” `isRunning()` returns `false` after all tools complete\n- **No more \"cancelled while in queue\"** โ€” subsequent tool calls don't queue up\n\nThis explains why it happens more with parallel tools โ€” the more tools running simultaneously, the higher the chance they finish at nearly the same time and trigger the race.\n\nWant me to open a PR with this fix?\n\n\n\n๐Ÿฐ๐Ÿ’ก\n*Parallel debugging is always a wild ride!*\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003eโœ๏ธ Learnings added\u003c/summary\u003e\n\n```\nLearnt from: acoliver\nURL: https://github.com/vybestack/llxprt-code/issues/957\n\nTimestamp: 2025-12-26T14:17:41.974Z\nLearning: In packages/core/src/core/coreToolScheduler.ts, the `publishBufferedResults()` method (line 1355) is called concurrently by multiple tool completion handlers (line 1510 in each tool's `.then()` callback) when parallel tools finish simultaneously. This causes a race condition where `nextPublishIndex` can be corrupted and tools can hang in 'executing' state forever, blocking the scheduler and causing subsequent tool calls to queue indefinitely. The method must be made reentrant-safe using a mutex/lock pattern to serialize concurrent calls.\n```\n\n\u003e Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure [review instructions](https://docs.coderabbit.ai/guides/review-instructions). You can manage existing learnings in the [app](https://app.coderabbit.ai/learnings).\n\n\u003c/details\u003e\n\n\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e๐Ÿง  Learnings used\u003c/summary\u003e\n\n```\nLearnt from: e2720pjk\nRepo: vybestack/llxprt-code PR: 583\nFile: packages/core/src/providers/openai/OpenAIProvider.ts:935-959\nTimestamp: 2025-11-16T22:51:26.374Z\nLearning: In the llxprt-code codebase (packages/core/src/providers/openai/OpenAIProvider.ts), tools like `run_shell_command` use internal streaming only for real-time UI updates during execution, but each tool execution produces exactly ONE final `ToolResponseBlock` containing the full result. The streaming chunks are never sent to the LLM and are not converted into multiple tool messages. The OpenAI Chat Completions API requires that each tool call (tool_call_id) corresponds to exactly one message with role 'tool', so duplicate tool response detection that removes subsequent tool messages with the same tool_call_id is correct and necessary for API compliance.\n```\n\n\u003c/details\u003e\n\n","createdAt":"2025-12-26T14:17:42Z","url":"https://github.com/vybestack/llxprt-code/issues/957#issuecomment-3692933303"}],"number":957,"title":"some tool calls seem to be getting stuck in queue"} From c1cc7b623cd99c258b6fc5ff85a39940047a9559 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 30 Dec 2025 18:50:41 -0300 Subject: [PATCH 3/4] fix: resolve cancel hang and unhandled rejection issues - Handle schedule() promise rejection when cancelling queued tool calls - Use pre-aborted fallback signal when original signal is missing - Fix publishBufferedResults reentrancy to prevent parallel tool hangs Addresses CodeRabbit feedback on PR #961 --- .../cli/src/ui/hooks/useReactToolScheduler.ts | 11 ++- packages/core/src/core/coreToolScheduler.ts | 87 +++++++++++-------- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index f091daaed..a4c86bb3b 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -310,7 +310,16 @@ export function useReactToolScheduler( ? request.map(ensureAgentId) : ensureAgentId(request); - scheduler.schedule(normalizedRequest, signal); + // The scheduler.schedule() returns a Promise that rejects when the abort + // signal fires while tool calls are queued. We intentionally catch and + // ignore these rejections because: + // 1. Cancellation is an expected user action, not an error + // 2. The UI state is updated via cancelAllToolCalls() synchronously + // 3. Tool results are not needed after cancellation + scheduler.schedule(normalizedRequest, signal).catch(() => { + // Silently ignore cancellation rejections - this is expected behavior + // when the user presses ESC to cancel queued tool calls + }); }, [scheduler], ); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 94f2bdcd5..de6f11d57 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -494,15 +494,25 @@ export class CoreToolScheduler { : ToolConfirmationOutcome.Cancel : ToolConfirmationOutcome.Cancel); - // Use the original signal stored for this call, or create a new one as fallback + // Use the original signal stored for this call, or a pre-aborted signal as fallback. + // If the original signal is missing, it means the tool call was already completed or + // cancelled (cleaned up in publishBufferedResults), so we use an aborted signal to + // ensure the confirmation handler doesn't proceed with execution. const originalSignal = this.callIdToSignal.get(callId); - if (!originalSignal && toolSchedulerLogger.enabled) { - toolSchedulerLogger.debug( - () => - `Using fallback AbortSignal for callId=${callId} (original signal not found in map)`, - ); + let signal: AbortSignal; + if (originalSignal) { + signal = originalSignal; + } else { + if (toolSchedulerLogger.enabled) { + toolSchedulerLogger.debug( + () => + `Using pre-aborted fallback AbortSignal for callId=${callId} (original signal not found in map)`, + ); + } + const abortedController = new AbortController(); + abortedController.abort(); + signal = abortedController.signal; } - const signal = originalSignal ?? new AbortController().signal; void this.handleConfirmationResponse( callId, waitingToolCall.confirmationDetails.onConfirm, @@ -1368,46 +1378,55 @@ export class CoreToolScheduler { // Reentrancy guard for publishBufferedResults to prevent race conditions // when multiple async tool completions trigger publishing simultaneously private isPublishingBufferedResults = false; + // Flag to track if another publish was requested while we were publishing + private pendingPublishRequest = false; private async publishBufferedResults(signal: AbortSignal): Promise { - // Prevent reentrant calls which can cause race conditions + // If already publishing, mark that we need another pass after current one completes if (this.isPublishingBufferedResults) { + this.pendingPublishRequest = true; return; } this.isPublishingBufferedResults = true; + this.pendingPublishRequest = false; try { - const callsInOrder = this.toolCalls.filter( - (call) => call.status === 'scheduled' || call.status === 'executing', - ); + // Loop to handle cases where new results arrive while we're publishing + do { + this.pendingPublishRequest = false; - // Publish results in original request order - while (this.nextPublishIndex < callsInOrder.length) { - const expectedCall = callsInOrder[this.nextPublishIndex]; - const buffered = this.pendingResults.get(expectedCall.request.callId); + const callsInOrder = this.toolCalls.filter( + (call) => call.status === 'scheduled' || call.status === 'executing', + ); - if (!buffered) { - // Next result not ready yet, stop publishing - break; - } + // Publish results in original request order + while (this.nextPublishIndex < callsInOrder.length) { + const expectedCall = callsInOrder[this.nextPublishIndex]; + const buffered = this.pendingResults.get(expectedCall.request.callId); - // Publish this result - await this.publishResult(buffered, signal); + if (!buffered) { + // Next result not ready yet, stop publishing + break; + } - // Remove from buffer - this.pendingResults.delete(buffered.callId); - this.nextPublishIndex++; - } + // Publish this result + await this.publishResult(buffered, signal); - // Check if all tools completed - if ( - this.nextPublishIndex === callsInOrder.length && - callsInOrder.length > 0 - ) { - // Reset for next batch - this.nextPublishIndex = 0; - this.pendingResults.clear(); - } + // Remove from buffer + this.pendingResults.delete(buffered.callId); + this.nextPublishIndex++; + } + + // Check if all tools completed + if ( + this.nextPublishIndex === callsInOrder.length && + callsInOrder.length > 0 + ) { + // Reset for next batch + this.nextPublishIndex = 0; + this.pendingResults.clear(); + } + } while (this.pendingPublishRequest); } finally { this.isPublishingBufferedResults = false; } From bb0519d0bacfe53a23552bf2d4f6c452ddf4f711 Mon Sep 17 00:00:00 2001 From: acoliver Date: Tue, 30 Dec 2025 23:05:42 -0300 Subject: [PATCH 4/4] fix: resolve parallel tool execution hang with index-based publishing The publishBufferedResults method was hanging when parallel tool calls completed out of order. The root cause was filtering toolCalls by status ('scheduled' or 'executing'), but publishResult() changes status to 'success', causing tools to disappear from the filtered list mid-iteration. Fix: - Store currentBatchSize when execution starts (immutable during batch) - Use executionIndex stored in buffered results for ordering instead of relying on filtered array positions - Add safety net in finally block to reschedule if pending results remain This approach is more robust because the batch size and execution indices are fixed at start and don't change as tools complete. Added tests for race condition scenarios with out-of-order completions. --- .../core/src/core/coreToolScheduler.test.ts | 240 ++++++++++++++++++ packages/core/src/core/coreToolScheduler.ts | 67 ++++- 2 files changed, 293 insertions(+), 14 deletions(-) diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 48c093d79..95c90cbe7 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -2254,6 +2254,246 @@ describe('CoreToolScheduler Buffered Parallel Execution', () => { // Verify ordered publishing despite error in tool 2 expect(publishOrder).toEqual([1, 2, 3]); // Request order maintained }); + + it('should handle race condition when later tools complete while publishBufferedResults is exiting', async () => { + // This test exercises the race condition where: + // 1. Tool #3 finishes first, calls publishBufferedResults + // 2. publishBufferedResults waits for tool #1, breaks out of inner while loop + // 3. Just as it checks pendingPublishRequest (false) and is about to exit do-while + // 4. Tool #1 finishes, sets pendingPublishRequest=true, returns immediately + // 5. Without the fix: first publishBufferedResults exits without processing buffered results + // 6. With the fix: the finally block detects pendingResults.size > 0 and reschedules + // + // The fix adds a check in the finally block to reschedule if pendingResults.size > 0 + + const completionOrder: number[] = []; + const publishOrder: number[] = []; + + // Use a deferred promise pattern to precisely control timing + const resolvers: Map void> = new Map(); + + const executeFn = vi + .fn() + .mockImplementation(async (args: { call: number }) => { + const callNum = args.call; + // Create a promise that we can resolve externally + await new Promise((resolve) => { + resolvers.set(callNum, resolve); + }); + completionOrder.push(callNum); + return { llmContent: `Call ${callNum} done` }; + }); + + const mockTool = new MockTool({ name: 'mockTool', execute: executeFn }); + const mockToolRegistry = { + getTool: () => mockTool, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByName: () => mockTool, + getToolByDisplayName: () => mockTool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const onToolCallsUpdate = vi.fn(); + const mockPolicyEngine = createMockPolicyEngine(); + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.YOLO, + getEphemeralSettings: () => ({}), + getAllowedTools: () => [], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'oauth-personal', + }), + getToolRegistry: () => mockToolRegistry, + getMessageBus: vi.fn().mockReturnValue(createMockMessageBus()), + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete: vi.fn(), + onToolCallsUpdate: (calls) => { + onToolCallsUpdate(calls); + calls.forEach((call) => { + if (call.status === 'success') { + const callNum = (call.request.args as { call: number }).call; + if (!publishOrder.includes(callNum)) { + publishOrder.push(callNum); + } + } + }); + }, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + const signal = new AbortController().signal; + + // Schedule 5 tool calls (simulating the scenario from the bug report) + await scheduler.schedule( + [1, 2, 3, 4, 5].map((n) => ({ + callId: `call${n}`, + name: 'mockTool', + args: { call: n }, + isClientInitiated: false, + prompt_id: 'test', + })), + signal, + ); + + // Wait for all tools to start executing and set up their resolvers + await vi.waitFor( + () => { + expect(resolvers.size).toBe(5); + }, + { timeout: 1000 }, + ); + + // Now complete tools in a specific order that triggers the race condition: + // Complete tool 3 first (middle of the batch) + resolvers.get(3)?.(); + + // Small delay to let publishBufferedResults start and break out waiting for tool 1 + await new Promise((resolve) => setTimeout(resolve, 5)); + + // Complete tools 4 and 5 + resolvers.get(4)?.(); + resolvers.get(5)?.(); + + // Small delay + await new Promise((resolve) => setTimeout(resolve, 5)); + + // Complete tool 2 + resolvers.get(2)?.(); + + // Small delay + await new Promise((resolve) => setTimeout(resolve, 5)); + + // Finally complete tool 1 (the blocker) + resolvers.get(1)?.(); + + // Wait for all calls to complete + await vi.waitFor( + () => { + expect(completionOrder.length).toBe(5); + expect(publishOrder.length).toBe(5); + }, + { timeout: 2000 }, + ); + + // Verify that despite the out-of-order completion, all results were published + // and in the correct request order + expect(publishOrder).toEqual([1, 2, 3, 4, 5]); + }); + + it('should recover when all later tools complete before first tool', async () => { + // Edge case: All tools except the first one complete, then the first one completes. + // Without the fix, the buffered results might get stuck. + const completionOrder: number[] = []; + const publishOrder: number[] = []; + + const executeFn = vi + .fn() + .mockImplementation(async (args: { call: number }) => { + // First tool takes longest, all others complete quickly + if (args.call === 1) { + await new Promise((resolve) => setTimeout(resolve, 80)); + } else { + // All other tools complete almost immediately but staggered + await new Promise((resolve) => setTimeout(resolve, args.call * 5)); + } + completionOrder.push(args.call); + return { llmContent: `Call ${args.call} done` }; + }); + + const mockTool = new MockTool({ name: 'mockTool', execute: executeFn }); + const mockToolRegistry = { + getTool: () => mockTool, + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByName: () => mockTool, + getToolByDisplayName: () => mockTool, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const mockPolicyEngine = createMockPolicyEngine(); + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.YOLO, + getEphemeralSettings: () => ({}), + getAllowedTools: () => [], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'oauth-personal', + }), + getToolRegistry: () => mockToolRegistry, + getMessageBus: vi.fn().mockReturnValue(createMockMessageBus()), + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete: vi.fn(), + onToolCallsUpdate: (calls) => { + calls.forEach((call) => { + if (call.status === 'success') { + const callNum = (call.request.args as { call: number }).call; + if (!publishOrder.includes(callNum)) { + publishOrder.push(callNum); + } + } + }); + }, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + const signal = new AbortController().signal; + + // Schedule 5 tool calls + await scheduler.schedule( + [1, 2, 3, 4, 5].map((n) => ({ + callId: `call${n}`, + name: 'mockTool', + args: { call: n }, + isClientInitiated: false, + prompt_id: 'test', + })), + signal, + ); + + // Wait for all calls to complete + await vi.waitFor( + () => { + expect(completionOrder.length).toBe(5); + expect(publishOrder.length).toBe(5); + }, + { timeout: 2000 }, + ); + + // Completion order: 2, 3, 4, 5, 1 (first is slowest) + expect(completionOrder).toEqual([2, 3, 4, 5, 1]); + + // But publish order should still be in request order + expect(publishOrder).toEqual([1, 2, 3, 4, 5]); + }); }); it('injects agentId into ContextAwareTool context', async () => { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index de6f11d57..0aafd707b 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -1380,6 +1380,8 @@ export class CoreToolScheduler { private isPublishingBufferedResults = false; // Flag to track if another publish was requested while we were publishing private pendingPublishRequest = false; + // Total number of tools in the current batch (set when execution starts) + private currentBatchSize = 0; private async publishBufferedResults(signal: AbortSignal): Promise { // If already publishing, mark that we need another pass after current one completes @@ -1395,40 +1397,73 @@ export class CoreToolScheduler { do { this.pendingPublishRequest = false; - const callsInOrder = this.toolCalls.filter( - (call) => call.status === 'scheduled' || call.status === 'executing', - ); + // Publish results in execution order using the stored executionIndex. + // We iterate while there are buffered results that match the next expected index. + // This approach doesn't rely on filtering toolCalls by status, which changes + // as we publish results (status goes from 'executing' to 'success'). + while (this.nextPublishIndex < this.currentBatchSize) { + // Find the buffered result with the next expected executionIndex + let nextBuffered: + | { + result: ToolResult; + callId: string; + toolName: string; + scheduledCall: ScheduledToolCall; + executionIndex: number; + } + | undefined; - // Publish results in original request order - while (this.nextPublishIndex < callsInOrder.length) { - const expectedCall = callsInOrder[this.nextPublishIndex]; - const buffered = this.pendingResults.get(expectedCall.request.callId); + for (const buffered of this.pendingResults.values()) { + if (buffered.executionIndex === this.nextPublishIndex) { + nextBuffered = buffered; + break; + } + } - if (!buffered) { - // Next result not ready yet, stop publishing + if (!nextBuffered) { + // The result for the next index isn't ready yet, stop publishing break; } // Publish this result - await this.publishResult(buffered, signal); + await this.publishResult(nextBuffered, signal); // Remove from buffer - this.pendingResults.delete(buffered.callId); + this.pendingResults.delete(nextBuffered.callId); this.nextPublishIndex++; } - // Check if all tools completed + // Check if all tools in this batch completed if ( - this.nextPublishIndex === callsInOrder.length && - callsInOrder.length > 0 + this.nextPublishIndex === this.currentBatchSize && + this.currentBatchSize > 0 ) { // Reset for next batch this.nextPublishIndex = 0; + this.currentBatchSize = 0; this.pendingResults.clear(); } } while (this.pendingPublishRequest); } finally { this.isPublishingBufferedResults = false; + + // After releasing the lock, check if there are still pending results + // that need publishing. This handles the race condition where: + // 1. We break out of the while loop waiting for result N + // 2. Result N arrives and calls publishBufferedResults + // 3. That call sees isPublishingBufferedResults=true, sets pendingPublishRequest=true, and returns + // 4. We then check pendingPublishRequest in the do-while, but it was set AFTER we checked + // 5. We exit without publishing the remaining buffered results + // + // By checking pendingResults.size here after releasing the lock, we ensure + // any buffered results get published. + if (this.pendingResults.size > 0) { + // Use setImmediate to avoid deep recursion and allow the event loop to process + // other pending tool completions first + setImmediate(() => { + void this.publishBufferedResults(signal); + }); + } } } @@ -1505,6 +1540,10 @@ export class CoreToolScheduler { (call) => call.status === 'scheduled', ); + // Store the batch size for ordered publishing - this is set once at the start + // and doesn't change as tools complete, ensuring we know when all are done + this.currentBatchSize = callsToExecute.length; + // Assign execution indices for ordered publishing const executionIndices = new Map(); callsToExecute.forEach((call, index) => {