Skip to content

fix(beads-rust): recursively fetch all descendant tasks for epic hierarchy - #394

Open
oliveagle wants to merge 2 commits into
subsy:mainfrom
oliveagle:ole
Open

fix(beads-rust): recursively fetch all descendant tasks for epic hierarchy#394
oliveagle wants to merge 2 commits into
subsy:mainfrom
oliveagle:ole

Conversation

@oliveagle

@oliveagle oliveagle commented May 17, 2026

Copy link
Copy Markdown

Summary

  • getChildIds previously only returned direct children of an epic, missing grandchild tasks (e.g., epic -> user-story -> tasks)
  • Now recursively traverses parent-child relationships to include all descendants
  • Also includes rebase onto main which pulled in timeout/retry changes

Test plan

  • Run bun test src/plugins/trackers/builtin/beads-rust/index.test.ts — all 50 tests pass
  • Run ralph-tui run in a project with multi-level hierarchy (epic -> user-story -> tasks) — all tasks should appear

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat requests now support configurable timeout and retry behavior with a dedicated UI dialog.
    • New timeout dialog UI allowing users to retry with extended timeout, continue indefinitely, or cancel requests.
    • Improved recursive task and issue hierarchy fetching for better data retrieval.
    • Added overlay background color support to the theme system.
  • Configuration Changes

    • Default request timeout updated to 60 seconds (previously unlimited).

Review Change Stack

rhtang and others added 2 commits May 17, 2026 14:35
Implement a comprehensive timeout and retry system for agent execution
that provides a Claude Code-like experience when requests take too long.

Features:
- Auto-interrupt on timeout (like Ctrl+C)
- User prompt to retry, continue indefinitely, or cancel
- Exponential backoff for retries (1.5x multiplier, max 5min)
- Maximum 3 retry attempts
- Visual timeout dialog with clear options

Changes:
- src/chat/types.ts: Add TimeoutConfig, TimeoutState, and event types
- src/chat/engine.ts: Implement timeout handling with auto-interrupt
- src/tui/components/PrdChatApp.tsx: Add TimeoutDialog UI component
- src/tui/theme.ts: Add bg.overlay color for dialog backdrop

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…archy

getChildIds previously only returned direct children of an epic, missing
grandchild tasks (e.g., epic -> user-story -> tasks). Now recursively
traverses parent-child relationships to include all descendants.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@vercel

vercel Bot commented May 17, 2026

Copy link
Copy Markdown

@oliveagle is attempting to deploy a commit to the plgeek Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR adds timeout and retry capabilities to the chat engine, integrates a timeout dialog UI with keyboard-driven recovery options, refactors the task tracker to recursively collect all descendant tasks, and extends theme colors to support overlay styling.

Changes

Chat Engine Timeout & Retry Feature

Layer / File(s) Summary
Timeout Type Definitions
src/chat/types.ts
Introduces TimeoutConfig, TimeoutState, and DEFAULT_TIMEOUT_CONFIG to model request timeouts and retry behavior. Updates ChatStatus with 'completed', 'timeout', and 'retrying' states. Extends ChatEngineConfig and SendMessageOptions with timeout/retry options. Adds ChatTimeoutEvent and ChatRetryStartedEvent to the event model.
ChatEngine Timeout Initialization & Storage
src/chat/engine.ts
Imports timeout types and merges timeoutConfig defaults in the ChatEngine constructor. Initializes timeoutState with retry counters and current timeout values, storing the merged config for runtime use.
ChatEngine Timeout Control API
src/chat/engine.ts
Exposes public methods retry(), cancelTimeout(), continueIndefinitely(), interrupt(), and getTimeoutState(). Implements internal async flows doRetry and doContinueIndefinitely that update timeout state, emit retry events, and re-send pending messages with appropriate timeout settings.
ChatEngine Message Sending with Timeout Handling
src/chat/engine.ts
Refactors sendMessage() to reject concurrent/retrying sends, reset retry state for new messages, and delegate to _sendMessageInternal with per-attempt timeout computation. Tracks active agent execution in currentExecution, routes status === 'timeout' results to handleTimeout(), resets retry state on success, and clears execution handles on completion/error.
ChatEngine Factory Helpers Configuration
src/chat/engine.ts
Updates createPrdChatEngine and createTaskChatEngine to accept optional timeoutConfig parameters, changes default timeout from 0 to 60000 ms, and passes configuration through to the engine.
TimeoutDialog Component
src/tui/components/PrdChatApp.tsx
Implements a new TimeoutDialog React component with modal overlay styling, formats timeout duration display, and renders key-driven action buttons for retry, continue indefinitely, and cancel.
PrdChatApp Timeout Event Handling & Keyboard Input
src/tui/components/PrdChatApp.tsx
Integrates timeout state in PrdChatApp component state. Adds handlers for timeout:occurred (stop loading, show dialog) and retry:started (hide dialog, resume loading). Implements keyboard shortcuts (r, c, Escape) for timeout actions when the dialog is visible and disables chat input while timeout dialog is open.
Theme Overlay Color Support
src/tui/theme.ts
Extends ThemeColors.bg interface with overlay field and provides default overlay color (#0f0f12) in the Tokyo Night theme palette.

Task Descendant Recursion

Layer / File(s) Summary
Recursive Descendant Collection
src/plugins/trackers/builtin/beads-rust/index.ts
Rewrites getChildIds to recursively traverse the task dependency graph using breadth-first search. Seeds queue with parentId, iteratively fetches parent-child dependencies via br dep list ... --direction up --json, enqueues newly discovered parents while tracking visited descendants, and gracefully skips nodes with CLI failures or JSON parse errors.
Task Descendant Recursion Tests
src/plugins/trackers/builtin/beads-rust/index.test.ts
Updates existing dependency enrichment test to mock additional recursive descendant calls for nested parents. Adds new test case validating multi-level recursion: mocks epic → epic.1 → {epic.1.1, epic.1.2} hierarchy and asserts getTasks({ parentId: 'epic' }) returns all three descendant IDs.

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main fix in the beads-rust plugin (recursive descendant fetching for epic hierarchy), though the changeset also includes a separate timeout/retry mechanism feature.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/plugins/trackers/builtin/beads-rust/index.test.ts (1)

641-647: 💤 Low value

Unused mock response for epic.1 enrichment.

The mock data for epic.1 (line 617) doesn't include dependency_count, so enrichDependencies won't call enrichTaskDependencies for it. This mock response will remain unconsumed. Consider removing it to keep the test's mock sequence aligned with actual behavior.

🧹 Suggested cleanup
         // dep list epic.1.2 -> no children
         { exitCode: 0, stdout: JSON.stringify([]) },
-        // dep list epic.1 for dependency enrichment
-        {
-          exitCode: 0,
-          stdout: JSON.stringify([
-            { issue_id: 'epic.1', depends_on_id: 'epic', type: 'parent-child' },
-          ]),
-        },
         // dep list epic.1.1 for dependency enrichment
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugins/trackers/builtin/beads-rust/index.test.ts` around lines 641 -
647, The mock response includes an unused dependency row for 'epic.1' that
doesn't have dependency_count so enrichDependencies will never call
enrichTaskDependencies for it; fix by either removing the { issue_id: 'epic.1',
depends_on_id: 'epic', type: 'parent-child' } entry from the mocked stdout array
in the test or add a dependency_count field to the corresponding mocked task
object so enrichDependencies treats it as having dependencies and invokes
enrichTaskDependencies (look for enrichDependencies and enrichTaskDependencies
in the test to locate the relevant mocks).
src/chat/engine.ts (1)

252-267: ⚡ Quick win

doRetry does not enforce maxRetries.

The method increments retryCount without checking if it exceeds config.timeoutConfig.maxRetries. While the UI may hide the retry option, the public retry() method would still allow exceeding the limit if called directly.

Proposed fix
   private async doRetry(): Promise<void> {
     if (!this.pendingUserMessage || !this.pendingOptions) {
       this.setStatus('idle');
       return;
     }

+    // Enforce max retries
+    if (this.timeoutState.retryCount >= this.config.timeoutConfig.maxRetries) {
+      this.setStatus('error');
+      this.emit({
+        type: 'error:occurred',
+        timestamp: new Date(),
+        error: `Maximum retries (${this.config.timeoutConfig.maxRetries}) exceeded`,
+      });
+      this.pendingUserMessage = null;
+      this.pendingOptions = null;
+      return;
+    }
+
     // Increment retry count and update timeout
     this.timeoutState.retryCount++;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/engine.ts` around lines 252 - 267, doRetry increments
timeoutState.retryCount without enforcing config.timeoutConfig.maxRetries;
update doRetry (the method in the class handling retries) to check
timeoutState.retryCount against config.timeoutConfig.maxRetries before
incrementing and performing retry logic, and if the limit is reached set
timeoutState.retryPending = false, call setStatus('idle') (or an appropriate
failure state) and return early so retryCount never exceeds maxRetries.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/chat/engine.ts`:
- Around line 409-419: The code nulls this.currentExecution immediately after
awaiting handle.promise, which prevents handleTimeout from being able to
interrupt the running handle; update the flow so the execution handle remains
available to handleTimeout by either moving the assignment this.currentExecution
= null to after the timeout handling block or changing handleTimeout to accept
the handle (e.g., pass the local handle returned from this.config.agent.execute
into handleTimeout) and use that handle for isRunning()/interrupt checks; ensure
references to currentExecution, handle, handleTimeout, execute, and promise are
adjusted consistently so interruption logic can access the live handle.

In `@src/tui/components/PrdChatApp.tsx`:
- Around line 539-555: The timeout handling and dialog are currently only wired
to engineRef events and the chat-phase UI, so timeouts originating from
taskEngineRef.current.sendMessage (review-phase/tracker generation) have no
retry/continue/cancel UI; to fix, subscribe the same timeout event handling for
taskEngineRef (or centralize the timeout handler used in the 'timeout:occurred'
and 'retry:started' cases so it responds to both engineRef and taskEngineRef
events) and render the TimeoutDialog in the review-phase return branch as well
(ensure showTimeoutDialog && timeoutState && <TimeoutDialog ... /> appears in
the review-phase JSX), keeping existing state variables setIsLoading,
setShowTimeoutDialog, setTimeoutState, and setLoadingStatus so the
retry/continue/cancel flow works for both engines.

---

Nitpick comments:
In `@src/chat/engine.ts`:
- Around line 252-267: doRetry increments timeoutState.retryCount without
enforcing config.timeoutConfig.maxRetries; update doRetry (the method in the
class handling retries) to check timeoutState.retryCount against
config.timeoutConfig.maxRetries before incrementing and performing retry logic,
and if the limit is reached set timeoutState.retryPending = false, call
setStatus('idle') (or an appropriate failure state) and return early so
retryCount never exceeds maxRetries.

In `@src/plugins/trackers/builtin/beads-rust/index.test.ts`:
- Around line 641-647: The mock response includes an unused dependency row for
'epic.1' that doesn't have dependency_count so enrichDependencies will never
call enrichTaskDependencies for it; fix by either removing the { issue_id:
'epic.1', depends_on_id: 'epic', type: 'parent-child' } entry from the mocked
stdout array in the test or add a dependency_count field to the corresponding
mocked task object so enrichDependencies treats it as having dependencies and
invokes enrichTaskDependencies (look for enrichDependencies and
enrichTaskDependencies in the test to locate the relevant mocks).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 5fc58109-6cca-4669-b847-2c7849c3e902

📥 Commits

Reviewing files that changed from the base of the PR and between 8191b80 and 62a3f5d.

📒 Files selected for processing (6)
  • src/chat/engine.ts
  • src/chat/types.ts
  • src/plugins/trackers/builtin/beads-rust/index.test.ts
  • src/plugins/trackers/builtin/beads-rust/index.ts
  • src/tui/components/PrdChatApp.tsx
  • src/tui/theme.ts

Comment thread src/chat/engine.ts
Comment on lines 409 to +419
const handle = this.config.agent.execute(prompt, [], agentOptions);
this.currentExecution = handle;

const result = await handle.promise;
this.currentExecution = null;

const durationMs = Date.now() - startTime;

if (result.status === 'timeout') {
// Handle timeout
return await this.handleTimeout(content, options, durationMs);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

currentExecution is nulled before handleTimeout can interrupt the process.

The assignment this.currentExecution = null at line 413 executes before the timeout check at line 417. When handleTimeout is called, it tries to interrupt via this.currentExecution?.isRunning() (lines 511-513), but the handle is already null, making that interrupt dead code.

Move the null assignment after the timeout branch, or pass the handle to handleTimeout.

Proposed fix
       const handle = this.config.agent.execute(prompt, [], agentOptions);
       this.currentExecution = handle;

       const result = await handle.promise;
-      this.currentExecution = null;

       const durationMs = Date.now() - startTime;

       if (result.status === 'timeout') {
         // Handle timeout
-        return await this.handleTimeout(content, options, durationMs);
+        const timeoutResult = await this.handleTimeout(content, options, durationMs);
+        this.currentExecution = null;
+        return timeoutResult;
       }

+      this.currentExecution = null;
+
       if (result.status !== 'completed') {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handle = this.config.agent.execute(prompt, [], agentOptions);
this.currentExecution = handle;
const result = await handle.promise;
this.currentExecution = null;
const durationMs = Date.now() - startTime;
if (result.status === 'timeout') {
// Handle timeout
return await this.handleTimeout(content, options, durationMs);
const handle = this.config.agent.execute(prompt, [], agentOptions);
this.currentExecution = handle;
const result = await handle.promise;
const durationMs = Date.now() - startTime;
if (result.status === 'timeout') {
// Handle timeout
const timeoutResult = await this.handleTimeout(content, options, durationMs);
this.currentExecution = null;
return timeoutResult;
}
this.currentExecution = null;
if (result.status !== 'completed') {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/chat/engine.ts` around lines 409 - 419, The code nulls
this.currentExecution immediately after awaiting handle.promise, which prevents
handleTimeout from being able to interrupt the running handle; update the flow
so the execution handle remains available to handleTimeout by either moving the
assignment this.currentExecution = null to after the timeout handling block or
changing handleTimeout to accept the handle (e.g., pass the local handle
returned from this.config.agent.execute into handleTimeout) and use that handle
for isRunning()/interrupt checks; ensure references to currentExecution, handle,
handleTimeout, execute, and promise are adjusted consistently so interruption
logic can access the live handle.

Comment on lines +539 to +555
case 'timeout:occurred':
if (isMountedRef.current) {
// Stop showing loading indicator
setIsLoading(false);
setTimeoutState(event.timeoutState);
setShowTimeoutDialog(true);
}
break;

case 'retry:started':
if (isMountedRef.current) {
setShowTimeoutDialog(false);
setTimeoutState(event.timeoutState);
setIsLoading(true);
setLoadingStatus(`Retrying request (attempt ${event.timeoutState.retryCount})...`);
}
break;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Timeout recovery is scoped to engineRef only, so review-phase task timeouts cannot use retry/continue/cancel.

Line 539 handles timeout events only from engine.on(...), but tracker generation uses taskEngineRef.current.sendMessage(...) (review phase). Also, Line 1294 renders TimeoutDialog only in the chat-phase layout. Result: task-generation timeouts have no dialog-driven recovery path.

Suggested fix
@@
   const engineRef = useRef<ChatEngine | null>(null);
   const taskEngineRef = useRef<ChatEngine | null>(null);
+  const timeoutEngineRef = useRef<ChatEngine | null>(null);
@@
-    const unsubscribe = engine.on((event: ChatEvent) => {
+    const handleEngineEvent = (source: ChatEngine) => (event: ChatEvent) => {
       switch (event.type) {
@@
         case 'timeout:occurred':
           if (isMountedRef.current) {
             // Stop showing loading indicator
             setIsLoading(false);
             setTimeoutState(event.timeoutState);
             setShowTimeoutDialog(true);
+            timeoutEngineRef.current = source;
           }
           break;
@@
         case 'retry:started':
           if (isMountedRef.current) {
             setShowTimeoutDialog(false);
             setTimeoutState(event.timeoutState);
             setIsLoading(true);
             setLoadingStatus(`Retrying request (attempt ${event.timeoutState.retryCount})...`);
           }
           break;
       }
-    });
+    };
+
+    const unsubscribe = engine.on(handleEngineEvent(engine));
+    const unsubscribeTask = taskEngine.on(handleEngineEvent(taskEngine));
@@
     return () => {
       isMountedRef.current = false;
       unsubscribe();
+      unsubscribeTask();
     };
@@
   const handleTimeoutRetry = useCallback(() => {
-    if (!engineRef.current) return;
+    const timeoutEngine = timeoutEngineRef.current;
+    if (!timeoutEngine) return;
@@
-    engineRef.current.retry();
+    timeoutEngine.retry();
   }, []);
@@
   const handleTimeoutCancel = useCallback(() => {
-    if (!engineRef.current) return;
+    const timeoutEngine = timeoutEngineRef.current;
+    if (!timeoutEngine) return;
@@
-    engineRef.current.cancelTimeout();
+    timeoutEngine.cancelTimeout();
   }, []);
@@
   const handleTimeoutContinue = useCallback(() => {
-    if (!engineRef.current) return;
+    const timeoutEngine = timeoutEngineRef.current;
+    if (!timeoutEngine) return;
@@
-    engineRef.current.continueIndefinitely();
+    timeoutEngine.continueIndefinitely();
   }, []);
// Also render TimeoutDialog in the review-phase return branch
{showTimeoutDialog && timeoutState && <TimeoutDialog timeoutState={timeoutState} />}

Also applies to: 1294-1299

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/components/PrdChatApp.tsx` around lines 539 - 555, The timeout
handling and dialog are currently only wired to engineRef events and the
chat-phase UI, so timeouts originating from taskEngineRef.current.sendMessage
(review-phase/tracker generation) have no retry/continue/cancel UI; to fix,
subscribe the same timeout event handling for taskEngineRef (or centralize the
timeout handler used in the 'timeout:occurred' and 'retry:started' cases so it
responds to both engineRef and taskEngineRef events) and render the
TimeoutDialog in the review-phase return branch as well (ensure
showTimeoutDialog && timeoutState && <TimeoutDialog ... /> appears in the
review-phase JSX), keeping existing state variables setIsLoading,
setShowTimeoutDialog, setTimeoutState, and setLoadingStatus so the
retry/continue/cancel flow works for both engines.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant