Skip to content

feat: implement unified subtask/subagent abstraction with enhanced sidebar display#103

Merged
rexlunae merged 12 commits intomainfrom
devin/1773200781-subtask-subagent-system
Mar 19, 2026
Merged

feat: implement unified subtask/subagent abstraction with enhanced sidebar display#103
rexlunae merged 12 commits intomainfrom
devin/1773200781-subtask-subagent-system

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot commented Mar 11, 2026

feat: unified subtask/subagent abstraction with enhanced sidebar

Summary

Adds a unified subtask abstraction (SubtaskHandle<T>) for spawning concurrent work (subagents, background tasks, one-shot tasks) as async tokio tasks integrated with ThreadManager. Enhances the protocol and sidebar to display richer thread metadata (status icons, descriptions).

Core abstraction (threads/subtask.rs — new file, ~626 lines):

  • SubtaskHandle<T> — generic joinable handle with cancellation via CancellationToken, status/description updates
  • spawn_subagent, spawn_task, spawn_background — factory functions that create a ThreadManager entry, spawn a tokio task, and wire up lifecycle updates
  • SubtaskRegistry — tracks multiple concurrent subtasks for bulk cancel/list
  • Cancellation is cooperative: dropping a handle or calling cancel() signals via CancellationToken; the spawned task is expected to check the token and clean up its own ThreadManager status

Protocol changes (ThreadInfoDto):

  • Added kind_icon: Option<String> and status_icon: Option<String> fields
  • Gateway populates these for chat threads, task-manager tasks (📋), and subagent sessions (🤖)

Sidebar enhancements:

  • Uses structured status_icon from gateway when available, falls back to string matching
  • Displays truncated description line below thread label when present
  • All string truncation is UTF-8 safe (uses chars().take(N))

Commands:

  • /thread bg — background the current thread (sends ThreadSwitch { thread_id: 0 } sentinel to gateway)
  • /thread fg <id> — foreground a thread by ID (sends ThreadSwitch); rejects id=0 since it collides with the background sentinel
  • Added to /help output and command autocomplete

Thread management:

  • Added ThreadManager::clear_foreground() method to support backgrounding all threads
  • Gateway ThreadSwitch handler recognizes thread_id == 0 as sentinel for "clear foreground"
  • Gateway uses switch_foreground() (returns bool) instead of switch_to() (returns Option<ThreadId>) for all switch operations — both explicit thread switches and auto-switch

Bugfixes:

  • Moved test_subagent_appears_in_active_list inside mod tests block in sessions.rs
  • Fixed test_should_block assertion in skill_audit.rs (pre-existing on main — assertion was negated, contradicting its own comment about error_count > 0 always blocking)

Updates since last revision

  • Fixed UTF-8 panic: Label and description truncation in sidebar.rs now uses chars().take(N).collect() instead of byte-index slicing, preventing panics on multi-byte characters.
  • Fixed /thread bg end-to-end: ThreadBackground handler now sends ThreadSwitch { thread_id: 0 } to gateway, and gateway recognizes this sentinel via new clear_foreground() method on ThreadManager, properly clearing the foreground state instead of returning "Thread 0 not found".
  • Fixed /thread fg 0 sentinel collision: /thread fg 0 now returns an error message ("Thread ID 0 is reserved") instead of silently backgrounding the current thread.
  • Fixed placeholder JoinHandle in SubtaskRegistry: Removed the no-op tokio::spawn(async {}) placeholder from RegistryEntry. cancel() and cancel_all() now rely solely on cooperative CancellationToken signaling.
  • Fixed orphaned Running threads: Removed handle.abort() call from SubtaskHandle::Drop. Now relies purely on cooperative cancellation so the spawned task has a chance to update ThreadManager status before exiting.
  • Fixed /thread fg after /thread bg: Gateway's ThreadSwitch handler now uses switch_foreground(target_id) instead of switch_to(target_id).is_some(). The switch_to method returns the old foreground ID (which is None after backgrounding), causing the check to incorrectly fail even when the switch succeeds. switch_foreground returns a bool indicating success, fixing /thread fg functionality after backgrounding.
  • Fixed auto-switch desync bug: Auto-switch code path (line 1206) was using switch_to().is_some() which failed after /thread bg (when foreground is None), causing client/server desync. Now uses switch_foreground() which correctly returns bool, matching the fix already applied to the explicit ThreadSwitch handler.
  • Fixed pre-existing test failure: Corrected test_should_block assertion in skill_audit.rs — the test was checking !should_block(50) but the comment noted "Still blocked due to error_count > 0", which is correct behavior. Flipped the assertion to match the intended behavior.
  • Merged main branch: Incorporated upstream changes including the oxidized-skillsoxidized-agentic-audit crate rename.

Review & Testing Checklist for Human

  • Protocol compatibility (HIGH RISK): ThreadInfoDto now has two new fields (kind_icon, status_icon). Since bincode is positional, this is a breaking wire format change — any mismatch between client/server versions will cause deserialization failures. Verify this is acceptable or if a version bump / migration strategy is needed.
  • Cooperative-only cancellation: Subtasks that don't check CancellationToken will run indefinitely if cancelled. Verify this design choice is acceptable. The spawned task is expected to cooperatively check token.is_cancelled() or token.cancelled().await in its main loop.
  • thread_id == 0 sentinel convention: The gateway treats thread_id == 0 as "clear foreground." ThreadId::new() starts from 1 (via AtomicU64), so 0 should never be a real thread ID — but verify no code path creates ThreadId(0) for a real thread.
  • kind_icon is unused in rendering: The kind_icon field flows through the protocol but the sidebar only renders status_icon. Verify this is intended as future-proofing or if it should be displayed (e.g. prepended to the label).
  • Test modification on main's code: We fixed test_should_block which exists on main — the assertion was negated (testing !should_block) when the comment and logic require it to return true. Verify the fix is correct (with error_count=2, should_block() always returns true).

Test Plan

  1. Start gateway and TUI on this branch
  2. Create a few threads with /thread new <label>
  3. Verify sidebar shows threads with status icons (▶ for active, etc.) and description lines for tasks/subagents
  4. Test /thread bg — verify it clears the foreground and messages area
  5. Test /thread fg <id> after backgrounding — verify it switches successfully (should say "Switched to thread (id: N)") instead of "Thread N not found"
  6. Test /thread fg 0 — verify it returns an error instead of backgrounding
  7. Test auto-switch after /thread bg — send a message that triggers auto-switch (if enabled) and verify the client receives the ThreadSwitched notification (no desync)
  8. Create a thread with multi-byte characters in the label (e.g. emoji) — verify no panic on truncation
  9. With an active model configured, create threads with >3 messages and switch between them to verify the compaction flow works
  10. Visually inspect sidebar for layout issues with description rendering

Notes

View original video (rec-7e9b6cc83ef8444eb965f43fb0854134-edited.mp4)

  • The SubtaskHandle API is fully unit tested (6/6 tests pass) but lacks integration tests for spawn → sidebar → join flow
  • CI checks: All 16 checks passing — 11 platform-specific builds, Lint, Security Audit, Unit Tests (440/440 passed), Integration Tests, E2E Tests, Devin Review

Open with Devin

…debar display

- Add SubtaskHandle<T> with spawn_subagent, spawn_task, spawn_background functions
- Integrate with ThreadManager for automatic lifecycle tracking
- Support CancellationToken-based graceful cancellation
- Add SubtaskRegistry for tracking multiple concurrent subtasks
- Enhance ThreadInfoDto/ThreadInfo with kind_icon and status_icon fields
- Update send_threads_update() to populate icons for all thread sources
- Update sidebar to display descriptions and use structured status icons
- Add /thread bg and /thread fg commands for backgrounding/foregrounding
- Add thread commands to /help output
- Fix sessions.rs test that was outside mod tests block
- Wire client-side mapping of new icon fields through gateway_client

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration bot and others added 2 commits March 11, 2026 04:01
…to gateway

- Use chars().take(N) instead of byte slicing for label/description truncation
  to prevent panics on multi-byte UTF-8 characters
- ThreadBackground now sends ThreadSwitch with thread_id=0 to gateway
  instead of being a no-op

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
- Add clear_foreground() method to ThreadManager
- Gateway ThreadSwitch handler now treats thread_id=0 as 'background all'
  instead of trying to look up a nonexistent thread

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…ive cancellation only

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration bot and others added 2 commits March 11, 2026 04:21
…ing threads

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
switch_to returns the old foreground ID (Option<ThreadId>), which is None
after /thread bg clears the foreground. The gateway checked .is_some() on
this return value, causing /thread fg to always report 'Thread not found'
after backgrounding. switch_foreground returns bool, correctly indicating
whether the switch succeeded.

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 11 additional findings in Devin Review.

Open in Devin Review

Comment on lines +265 to +272
pub fn clear_foreground(&mut self) {
if let Some(old_id) = self.foreground_id {
if let Some(t) = self.threads.get_mut(&old_id) {
t.is_foreground = false;
}
}
self.foreground_id = None;
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 clear_foreground() does not emit a thread event, breaking event-driven sidebar updates

Every other state-changing method in ThreadManager emits an event — switch_foreground emits Foregrounded (manager.rs:256), remove emits Removed (manager.rs:374), set_status emits StatusChanged (manager.rs:226), etc. The new clear_foreground() modifies foreground_id and the thread's is_foreground flag but does not emit any event. The gateway's event-driven sidebar update loop at crates/rustyclaw-core/src/gateway/mod.rs:1555-1561 listens for these events to push updates. While the specific call site at mod.rs:1346 manually sends updates after calling clear_foreground(), this breaks the event system contract — any future caller of clear_foreground() will silently fail to notify subscribers.

Suggested change
pub fn clear_foreground(&mut self) {
if let Some(old_id) = self.foreground_id {
if let Some(t) = self.threads.get_mut(&old_id) {
t.is_foreground = false;
}
}
self.foreground_id = None;
}
pub fn clear_foreground(&mut self) {
let old_id = self.foreground_id;
if let Some(old) = old_id {
if let Some(t) = self.threads.get_mut(&old) {
t.is_foreground = false;
}
}
self.foreground_id = None;
if old_id.is_some() {
self.emit(ThreadEvent::Foregrounded {
thread_id: ThreadId(0),
previous_foreground: old_id,
});
}
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged — this is a valid consistency concern. The current call site in the gateway (mod.rs:1346) manually sends send_threads_update after clear_foreground(), so the sidebar does update correctly today. Adding the event emission would be a defensive improvement for future callers. Leaving this as-is for now since the current behavior is correct; happy to add the event if the reviewer wants it.

Comment thread crates/rustyclaw-tui/src/app/app.rs Outdated
The info message was sent unconditionally, even when the gateway was
disconnected (ws_sink is None). Move it inside the if-let block so the
user only sees 'Current thread backgrounded...' when the frame was
actually sent. This matches the pattern used by other thread commands.

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
@rexlunae rexlunae requested a review from Copilot March 18, 2026 23:47
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements a unified subtask/subagent abstraction in rustyclaw-core and enhances the gateway → TUI thread metadata flow so the sidebar can render richer status/description information, plus adds /thread bg and /thread fg commands for foreground management.

Changes:

  • Added SubtaskHandle<T> + spawn helpers and SubtaskRegistry integrated with ThreadManager.
  • Extended thread protocol DTOs with kind_icon / status_icon and updated gateway + TUI mapping/rendering.
  • Added background/foreground thread commands and a ThreadManager::clear_foreground() API; fixed a misplaced unit test block.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
crates/rustyclaw-tui/src/gateway_client.rs Maps new kind_icon / status_icon fields from gateway thread DTOs into TUI ThreadInfo.
crates/rustyclaw-tui/src/components/sidebar.rs Renders structured status_icon and adds a truncated description line in the sidebar.
crates/rustyclaw-tui/src/app/app.rs Sends ThreadSwitch frames for /thread bg (sentinel 0) and /thread fg <id>.
crates/rustyclaw-tui/src/action.rs Extends TUI ThreadInfo model with icon fields.
crates/rustyclaw-core/src/threads/subtask.rs New subtask abstraction: spawn/join/cancel integrated with ThreadManager, plus unit tests.
crates/rustyclaw-core/src/threads/mod.rs Exposes the new subtask module/types from the threads module.
crates/rustyclaw-core/src/threads/manager.rs Adds clear_foreground() to support backgrounding all threads.
crates/rustyclaw-core/src/sessions.rs Fixes test placement by moving test_subagent_appears_in_active_list back inside mod tests.
crates/rustyclaw-core/src/gateway/protocol/frames.rs Extends ThreadInfoDto with icon fields and updates frame-size tests.
crates/rustyclaw-core/src/gateway/mod.rs Populates new icon fields and implements thread_id == 0 sentinel handling + switch_foreground() usage.
crates/rustyclaw-core/src/commands.rs Adds /thread bg and /thread fg parsing, help text, and autocomplete names.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +428 to 434
/// Icon for the thread kind (e.g. chat, sub-agent, background, task)
pub kind_icon: Option<String>,
/// Icon for the thread status (e.g. running, completed, failed)
pub status_icon: Option<String>,
pub is_foreground: bool,
pub message_count: usize,
pub has_summary: bool,
Comment on lines +49 to +51
/// The underlying tokio JoinHandle (for abort).
join_handle: Option<tokio::task::JoinHandle<()>>,
}
Comment on lines +54 to +71
/// Wait for the subtask to complete and return its result.
///
/// This consumes the handle. After joining, the thread status is updated
/// to Completed or Failed.
pub async fn join(mut self) -> Result<T, String> {
let rx = self
.result_rx
.take()
.ok_or_else(|| "SubtaskHandle already joined".to_string())?;

match rx.await {
Ok(Ok(value)) => Ok(value),
Ok(Err(e)) => Err(e),
Err(_) => {
// Sender dropped — task panicked or was aborted
Err("Subtask channel closed unexpectedly".to_string())
}
}
Comment on lines +541 to +543
// Give tokio a moment to process the status update
tokio::time::sleep(std::time::Duration::from_millis(50)).await;

The oxidized-skills crate was removed/yanked from crates.io, causing all
CI checks to fail with 'no matching package named oxidized-skills found'
when the cargo cache expires. Switch to the git repository source at
tag v0.3.1 which matches the previously-used version.

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration bot and others added 2 commits March 19, 2026 00:03
Co-Authored-By: Erica Stith <rexlunae@gmail.com>
…ad bg

switch_to() returns the old foreground ID, which is None after /thread bg.
This caused the .is_some() check to fail, skipping the ThreadSwitched
notification and permanently desyncing the client. switch_foreground()
returns bool indicating success, which is the correct check here.

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
Copy link
Copy Markdown
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 11 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟡 TUI ThreadSwitched handler does not distinguish background (thread_id=0) from a real thread switch, clearing messages and showing confusing text

When the user issues /thread bg, the gateway sends back ThreadSwitched { thread_id: 0, context_summary: None }. The TUI handler at crates/rustyclaw-tui/src/app/app.rs:1663-1683 unconditionally clears all display messages and replaces them with "Switched to thread (id: 0)", which is a confusing sentinel value the user shouldn't see. This also overwrites the earlier helpful info message sent at crates/rustyclaw-tui/src/app/app.rs:611 ("Current thread backgrounded. Use /thread fg or sidebar to switch."). The handler should check for thread_id == 0 and either skip message clearing or display an appropriate "No foreground thread" message.

(Refers to lines 1663-1683)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a theoretical concern but doesn't actually happen in the /thread bg flow. The gateway's clear_foreground() handler (lines 1415-1432 in gateway/mod.rs) only sends a ThreadsUpdate frame back — it never sends a ThreadSwitched frame with thread_id=0. The ThreadSwitched event is only emitted by explicit ThreadSwitch requests (line 1425) and auto-switch (line 1206), both of which use real thread IDs via switch_foreground().

The only way ThreadSwitched { thread_id: 0 } could reach the TUI is if someone explicitly sent ThreadSwitch { thread_id: 0 } back as a server frame, which the gateway doesn't do. That said, adding a guard for thread_id == 0 in the TUI handler would be a reasonable defensive improvement for the future — will note it as a follow-up.

The test comment correctly noted 'Still blocked due to error_count > 0'
but the assertion was negated (!should_block). With error_count=2,
should_block() always returns true regardless of score threshold.

Co-Authored-By: Erica Stith <rexlunae@gmail.com>
@rexlunae rexlunae merged commit 8329c6d into main Mar 19, 2026
17 checks passed
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.

2 participants