Skip to content

Agent loop: subagents for heavy work + safe-point message queue - #2335

Closed
jaylfc wants to merge 1 commit into
devfrom
exec/tsk-rl2lfb
Closed

Agent loop: subagents for heavy work + safe-point message queue#2335
jaylfc wants to merge 1 commit into
devfrom
exec/tsk-rl2lfb

Conversation

@jaylfc

@jaylfc jaylfc commented Aug 9, 2026

Copy link
Copy Markdown
Owner

CARD TITLE (intent, not commit subject): Agent loop: subagents for heavy work + safe-point message queue

Autonomous build of board card tsk-rl2lfb.

Files:
changelog.d/tsk-rl2lfb-agent-loop.md | 10 +
docs/design/agent-loop-subagents.md | 95 ++++++++
tests/test_agent_loop.py | 456 +++++++++++++++++++++++++++++++++++
tinyagentos/agent_loop.py | 449 ++++++++++++++++++++++++++++++++++
4 files changed, 1010 insertions(+)

Summary by CodeRabbit

New Features

  • Added responsive background task handling through supervised subagents.
  • Messages received during active work are safely queued and processed in order.
  • Redirects can cancel ongoing subagent work.
  • Added progress updates and status visibility for active and queued tasks.
  • Preserved the main conversation flow for presenting results and handling interruptions.

Documentation

  • Added design documentation covering task states, message handling, cancellation, status reporting, and integration behavior.

Tests

  • Added comprehensive coverage for queuing, cancellation, progress updates, lifecycle states, failures, and responsiveness.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds AgentLoop with asynchronous subagent execution, queued message handling, safe-point delivery, redirect cancellation, progress forwarding, status reporting, lifecycle APIs, tests, and design documentation.

Changes

AgentLoop orchestration

Layer / File(s) Summary
Loop contracts and message intake
tinyagentos/agent_loop.py, tests/test_agent_loop.py
Defines loop states, message and subagent records, callback types, and immediate or FIFO-queued message handling.
Subagent execution and cancellation
tinyagentos/agent_loop.py, tests/test_agent_loop.py
Adds supervised worker execution, progress forwarding, outcome tracking, manual cancellation, and redirect cancellation coverage.
Safe-point delivery and redirect handling
tinyagentos/agent_loop.py, tests/test_agent_loop.py
Drains queued messages only at safe points, records delivery, preserves ordering, and validates redirect behavior during active work.
Status and subagent inspection
tinyagentos/agent_loop.py, tests/test_agent_loop.py, docs/design/agent-loop-subagents.md, changelog.d/tsk-rl2lfb-agent-loop.md
Adds status snapshots, subagent lookup and await APIs, lifecycle checks, and documentation for the new controller. setup.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MainChatLoop
  participant AgentLoop
  participant SubagentWorker
  participant ProgressSink

  MainChatLoop->>AgentLoop: handle_message(content)
  AgentLoop->>SubagentWorker: spawn worker
  SubagentWorker->>ProgressSink: forward progress
  MainChatLoop->>AgentLoop: reach_safe_point()
  AgentLoop->>SubagentWorker: cancel if redirect is queued
  AgentLoop-->>MainChatLoop: return delivered messages and status
Loading
🚥 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 title clearly and concisely describes the main changes: subagent delegation and safe-point message queuing in the agent loop.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch exec/tsk-rl2lfb

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.

@gitar-bot

gitar-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

Important

You are using the Gitar free plan. Upgrade to unlock code review, CI analysis, auto-apply, custom automations, and more.

Gitar

Comment thread tinyagentos/agent_loop.py
if any(m.is_redirect for m in queued):
await self.cancel_subagents(reason="redirect at safe point")

async with self._lock:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING]: reach_safe_point releases the lock between SAFE_POINT and IDLE transitions. During this window, handle_message can acquire the lock and start a new turn (treating SAFE_POINT as idle), potentially corrupting state or losing queued messages.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/agent_loop.py
entry.task_obj.cancel()
# Wait outside the lock so the loop isn't held while workers unwind.
if to_cancel:
_done, pending = await asyncio.wait(to_cancel, timeout=10.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING]: cancel_subagents leaves tasks pending after the 10s timeout without forcefully terminating them. A worker that swallows CancelledError will run forever, leaking resources.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/agent_loop.py
if e.task_obj is not None and not e.task_obj.done()
]
if tasks:
await asyncio.wait_for(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[WARNING]: await_all_subagents does not cancel individual tasks when asyncio.wait_for times out. The gather coroutine is cancelled, but the underlying tasks continue running indefinitely.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/agent_loop.py

Safe to call from any context (no I/O, no await).
"""
subagents: list[dict[str, Any]] = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[SUGGESTION]: status() reads shared state (_state, _subagents, _message_queue, _current_turn_id) without acquiring self._lock, which could return an inconsistent snapshot during concurrent mutations.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Aug 9, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
WARNING 3
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/agent_loop.py 339 reach_safe_point releases the lock between SAFE_POINT and IDLE transitions, allowing handle_message to start a new turn during the safe point
tinyagentos/agent_loop.py 299 cancel_subagents leaves tasks pending after the 10s timeout without forcefully terminating them
tinyagentos/agent_loop.py 446 await_all_subagents does not cancel individual tasks when asyncio.wait_for times out

SUGGESTION

File Line Issue
tinyagentos/agent_loop.py 359 status() reads shared state without acquiring the lock
Files Reviewed (1 file)
  • tinyagentos/agent_loop.py - 4 issues

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 56.5K · Output: 20.2K · Cached: 301.2K

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
docs/design/agent-loop-subagents.md (1)

64-73: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a language to the fenced code block.

markdownlint reports MD040 for this block. Use text for the state diagram.

-```
+```text
 IDLE
🤖 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 `@docs/design/agent-loop-subagents.md` around lines 64 - 73, Update the fenced
code block containing the IDLE/WORKING/SAFE_POINT state diagram to specify the
text language, changing the opening fence to ```text while preserving the
diagram content.

Source: Linters/SAST tools

tests/test_agent_loop.py (2)

312-360: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the async-sink and timeout paths.

Two behaviours have no coverage:

  • _progress schedules a task when the sink returns a coroutine (tinyagentos/agent_loop.py lines 247-252). _RecordingSink.__call__ is sync, so this branch never runs.
  • await_subagent(sub_id, timeout=...) and await_all_subagents(timeout=...) are never called with a timeout. Both currently cancel the subagents on timeout; see the comments on those methods.

Add a coroutine sink and a short-timeout test to lock in the intended behaviour.

🤖 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 `@tests/test_agent_loop.py` around lines 312 - 360, Extend the tests around
AgentLoop with an async sink whose __call__ returns a coroutine, then verify
_progress schedules and delivers progress through that sink. Add short-timeout
coverage for await_subagent and await_all_subagents using workers that do not
finish promptly, asserting the timeout path cancels the relevant subagents and
preserves the documented state.

108-211: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression test for a message that arrives during SAFE_POINT.

The suite covers IDLE and WORKING intake, but not the window in which reach_safe_point has set SAFE_POINT and is awaiting cancel_subagents. That window is where the queue clear and the turn-id clobber occur; see the comment on tinyagentos/agent_loop.py lines 202-210. A test that sends a message while a slow-unwinding subagent is being cancelled would pin the fixed behaviour.

🤖 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 `@tests/test_agent_loop.py` around lines 108 - 211, Add an async regression
test covering message intake while AgentLoop.reach_safe_point() is in SAFE_POINT
and awaiting cancel_subagents(). Use a slow-unwinding subagent whose
cancellation can be observed, send a message during that window, then await the
safe point and assert the message is delivered with its original turn
association while the queue is not cleared or the turn ID clobbered.
🤖 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 `@tinyagentos/agent_loop.py`:
- Around line 404-416: The await API at tinyagentos/agent_loop.py lines 404-416
must stop cancelling live subagent tasks on timeout: resolve entry.task_obj to a
local asyncio.Task, use asyncio.wait({task_obj}, timeout=timeout), and raise
asyncio.TimeoutError when it is not done, preserving cancellation handling and
result retrieval. Apply the same non-cancelling asyncio.wait approach to lines
433-449, replacing the wait_for/gather pair and raising asyncio.TimeoutError
when pending is non-empty.
- Around line 202-210: Update the message-handling branch around the state
transition to treat SAFE_POINT like WORKING, appending the message and returning
QUEUED instead of starting a turn. Remove the unconditional
_message_queue.clear() from this path, and make reach_safe_point’s final
IDLE/None transition conditional so it does not overwrite a turn started after
queue draining.
- Around line 245-254: Update the progress sink handling around self._sink(msg)
to retain each scheduled coroutine task in a persistent set and remove it via a
completion callback after it finishes. Ensure the set is owned by the agent loop
instance and initialized appropriately, while preserving the existing
best-effort scheduling and exception logging behavior.

---

Nitpick comments:
In `@docs/design/agent-loop-subagents.md`:
- Around line 64-73: Update the fenced code block containing the
IDLE/WORKING/SAFE_POINT state diagram to specify the text language, changing the
opening fence to ```text while preserving the diagram content.

In `@tests/test_agent_loop.py`:
- Around line 312-360: Extend the tests around AgentLoop with an async sink
whose __call__ returns a coroutine, then verify _progress schedules and delivers
progress through that sink. Add short-timeout coverage for await_subagent and
await_all_subagents using workers that do not finish promptly, asserting the
timeout path cancels the relevant subagents and preserves the documented state.
- Around line 108-211: Add an async regression test covering message intake
while AgentLoop.reach_safe_point() is in SAFE_POINT and awaiting
cancel_subagents(). Use a slow-unwinding subagent whose cancellation can be
observed, send a message during that window, then await the safe point and
assert the message is delivered with its original turn association while the
queue is not cleared or the turn ID clobbered.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b2465c2-5068-449a-86e8-a5b204e26616

📥 Commits

Reviewing files that changed from the base of the PR and between 9d80b17 and a4ec9c2.

📒 Files selected for processing (4)
  • changelog.d/tsk-rl2lfb-agent-loop.md
  • docs/design/agent-loop-subagents.md
  • tests/test_agent_loop.py
  • tinyagentos/agent_loop.py

Comment thread tinyagentos/agent_loop.py
Comment on lines +202 to +210
async with self._lock:
if self._state == LoopState.WORKING:
self._message_queue.append(msg)
return LoopAction.QUEUED
# IDLE (or briefly SAFE_POINT) -> start a new turn.
self._state = LoopState.WORKING
self._current_turn_id = msg_id
self._message_queue.clear()
return LoopAction.IMMEDIATE

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Fix the state-clobber race and the destructive queue clear.

reach_safe_point releases self._lock between line 332 and line 339 while cancel_subagents runs, which can take up to 10 seconds. A message that arrives in that window sees SAFE_POINT, falls into this branch, returns IMMEDIATE, and sets _state = WORKING plus _current_turn_id = msg_id. reach_safe_point then reacquires the lock and overwrites both with IDLE/None. The caller drives a turn that the loop no longer tracks.

Line 209 also clears the queue unconditionally. Any message appended in that same window is discarded, which breaks the "never dropped" guarantee stated in the module docstring and in changelog.d/tsk-rl2lfb-agent-loop.md.

Two changes are needed: treat SAFE_POINT as busy (queue the message) and remove the blanket clear(), since reach_safe_point already drains the queue.

🐛 Proposed fix
         async with self._lock:
-            if self._state == LoopState.WORKING:
+            if self._state in (LoopState.WORKING, LoopState.SAFE_POINT):
                 self._message_queue.append(msg)
                 return LoopAction.QUEUED
-            # IDLE (or briefly SAFE_POINT) -> start a new turn.
+            # IDLE -> start a new turn. The queue is already drained by
+            # reach_safe_point, so nothing is discarded here.
             self._state = LoopState.WORKING
             self._current_turn_id = msg_id
-            self._message_queue.clear()
         return LoopAction.IMMEDIATE

reach_safe_point must then avoid overwriting a turn that a queued message starts; keep the final transition conditional:

         async with self._lock:
-            self._state = LoopState.IDLE
-            self._current_turn_id = None
+            if self._state == LoopState.SAFE_POINT:
+                self._state = LoopState.IDLE
+                self._current_turn_id = None
             self._delivered.extend(queued)
🤖 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 `@tinyagentos/agent_loop.py` around lines 202 - 210, Update the
message-handling branch around the state transition to treat SAFE_POINT like
WORKING, appending the message and returning QUEUED instead of starting a turn.
Remove the unconditional _message_queue.clear() from this path, and make
reach_safe_point’s final IDLE/None transition conditional so it does not
overwrite a turn started after queue draining.

Comment thread tinyagentos/agent_loop.py
Comment on lines +245 to +254
try:
res = self._sink(msg)
if asyncio.iscoroutine(res):
# Best-effort schedule; the sink owns its lifecycle.
try:
asyncio.get_running_loop().create_task(res)
except RuntimeError:
pass
except Exception:
logger.exception("subagent %s: progress sink raised", sub_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

asyncio create_task strong reference required task garbage collected

💡 Result:

Yes, it is necessary to maintain a strong reference to a Task created by asyncio.create_task to prevent it from being garbage collected prematurely [1][2][3]. The asyncio event loop maintains only weak references to scheduled tasks [1][4]. If a task is not referenced elsewhere in your code—meaning it has no strong references—the Python garbage collector may destroy the task object at any time, even while it is still pending or suspended [1][5]. If this happens, you may encounter an error message such as "Task was destroyed but it is pending!" [2][6][7]. To reliably manage background tasks (often called "fire-and-forget" tasks), you should store them in a collection, such as a set, to keep a strong reference [1][4]. It is also standard practice to remove the task from that collection once it has finished to avoid memory leaks [1][4]. Example of recommended practice: background_tasks = set task = asyncio.create_task(some_coroutine) # Keep a strong reference background_tasks.add(task) # Remove the reference automatically when the task finishes task.add_done_callback(background_tasks.discard) For more structured concurrency in modern Python (3.11+), it is highly recommended to use asyncio.TaskGroup instead, which automatically manages these references and handles task lifetimes safely [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the relevant source and class structure without running repository code.
if [ -f tinyagentos/agent_loop.py ]; then
  echo "---- agent_loop.py outline ----"
  ast-grep outline tinyagentos/agent_loop.py --view compact || true
  echo
  echo "---- relevant source lines ----"
  sed -n '1,120p;210,285p' tinyagentos/agent_loop.py | nl -ba
else
  echo "tinyagentos/agent_loop.py not found"
  fd -a agent_loop.py .
fi

echo
echo "---- sink references in agent_loop.py ----"
rg -n "_sink|_progress|sink_tasks|TaskGroup|run_in_executor|create_task|_runner|cancel_subagents" tinyagentos/agent_loop.py || true

echo
echo "---- Python asyncio task GC behavior probe ----"
python3 - <<'PY'
import asyncio, gc, weakref, sys

def warn_handler(*args, **kw):
    print("WARNING:", args[0], file=sys.stderr)

def run_sink_with_progress_loop(sink_name):
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        sink_called = False

        async def sink():
            nonlocal sink_called
            sink_called = True

        async def runner():
            loop.create_task(sink())
            asyncio.get_running_loop().stop()

        loop.run_coroutine_threadsafe(runner(), loop).result(timeout=5)
    finally:
        loop.close()
    return sink_called

def run_sink_without_progress_loop(sink_name):
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        sink_called = False

        async def sink():
            nonlocal sink_called
            sink_called = True

        async def runner():
            # Drop reference to task immediately; force GC while message is in sink queue.
            t = asyncio.get_running_loop().create_task(sink())
            del t
            gc.collect()
            await asyncio.sleep(0.01)
            asyncio.get_running_loop().stop()

        loop.run_coroutine_threadsafe(runner(), loop).result(timeout=5)
    finally:
        loop.close()
    return sink_called

import warnings
warnings.filterwarnings("always", category=PendingDeprecationWarning)
warnings.filterwarnings("always", category=RuntimeWarning)

print("task with progress loop referenced:", run_sink_with_progress_loop("referenced"))
print("task without progress loop unreferenced:", run_sink_without_progress_loop("unreferenced"))
PY

Repository: jaylfc/taOS

Length of output: 406


🏁 Script executed:

#!/bin/bash
set -euo pipefail

if [ -f tinyagentos/agent_loop.py ]; then
  echo "---- agent_loop.py class/method identifiers ----"
  ast-grep outline tinyagentos/agent_loop.py || true
  echo
  echo "---- relevant source lines ----"
  sed -n '1,140p;210,285p' tinyagentos/agent_loop.py
else
  echo "tinyagentos/agent_loop.py not found"
  fd -a agent_loop.py .
fi

echo
echo "---- sink references in agent_loop.py ----"
rg -n "_sink|_progress|sink_tasks|TaskGroup|run_in_executor|create_task|_runner|cancel_subagents" tinyagentos/agent_loop.py || true

echo
echo "---- Python asyncio task GC behavior probe ----"
python3 - <<'PY'
import asyncio, gc, sys

def run_sink_with_progress_loop():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    called = False

    async def sink():
        nonlocal called
        called = True

    async def runner():
        loop.create_task(sink())
        asyncio.get_running_loop().stop()

    try:
        asyncio.run_coroutine_threadsafe(runner(), loop).result(timeout=5)
    finally:
        loop.close()
    return called

def run_sink_without_progress_loop():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    called = False

    async def sink():
        nonlocal called
        called = True

    async def runner():
        t = asyncio.get_running_loop().create_task(sink())
        ref_count_before = sys.getrefcount(t)
        del t
        gc.collect()
        ref_count_after = sys.getrefcount(t)
        await asyncio.sleep(0.01)
        asyncio.get_running_loop().stop()

    try:
        asyncio.run_coroutine_threadsafe(runner(), loop).result(timeout=5)
    finally:
        loop.close()
    return called

print("task with progress loop referenced:", run_sink_with_progress_loop())
print("task without progress loop unreferenced:", run_sink_without_progress_loop())
PY

Repository: jaylfc/taOS

Length of output: 10403


🌐 Web query:

Python 3.9+ Task __del__ warning Pending tasks no garbage collected asyncio garbage collection

💡 Result:

The warning "Task was destroyed but it is pending!" occurs because asyncio maintains only weak references to scheduled tasks [1][2]. If you create a task (e.g., using asyncio.create_task) but do not keep a strong reference to it, the Python garbage collector may destroy the task object while it is still pending or suspended [2][3][4]. This typically happens when the task is waiting on a future or coroutine that is not referenced elsewhere, causing the task to become unreachable to the garbage collector [5][6][3]. To prevent this issue and the associated warning, you must maintain a strong reference to the task until it completes [2][4]. Recommended approaches include: 1. Use asyncio.TaskGroup (Recommended for Python 3.11+): TaskGroup automatically manages the lifecycle of tasks, ensuring they are properly awaited and exceptions are propagated [2][3]. async with asyncio.TaskGroup as tg: tg.create_task(some_coro) 2. Maintain a collection: If you need to manage fire-and-forget tasks, store them in a collection (like a set) and remove them using a done callback to avoid memory leaks [2][7]. background_tasks = set task = asyncio.create_task(some_coro) background_tasks.add(task) task.add_done_callback(background_tasks.discard) This behavior is fundamental to how asyncio's task scheduling interacts with Python's memory management [3][4]. While internal changes have been proposed to make this more robust, keeping a strong reference remains the standard, supported practice [8][9].

Citations:


Keep the sink task alive until it finishes.

asyncio.create_task stores the task with only a weak reference. The task created at line 250 is not referenced elsewhere, so the garbage collector can drop it while it is pending; the sink coroutine then may not run and the progress write is lost. Keep the task in a set and discard it when it completes.

🤖 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 `@tinyagentos/agent_loop.py` around lines 245 - 254, Update the progress sink
handling around self._sink(msg) to retain each scheduled coroutine task in a
persistent set and remove it via a completion callback after it finishes. Ensure
the set is owned by the agent loop instance and initialized appropriately, while
preserving the existing best-effort scheduling and exception logging behavior.

Comment thread tinyagentos/agent_loop.py
Comment on lines +404 to +416
async with self._lock:
entry = self._subagents.get(sub_id)
if entry is None:
raise KeyError(sub_id)
try:
await asyncio.wait_for(entry.task_obj, timeout=timeout)
except asyncio.CancelledError:
# Distinguish "the subagent itself was cancelled" from "this
# caller was cancelled". Only swallow the former.
if entry.handle.state == "cancelled":
return entry.handle.result
raise
return entry.handle.result

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Timeouts cancel the subagents in both await APIs. asyncio.wait_for cancels the future it wraps when the timeout expires. Both methods pass live subagent tasks to it, so a timed-out observation destroys the work instead of only ending the wait. This contradicts the docstring of each method.

  • tinyagentos/agent_loop.py#L404-L416: replace asyncio.wait_for(entry.task_obj, timeout=timeout) with asyncio.wait({task_obj}, timeout=timeout) and raise asyncio.TimeoutError when the task is not in the done set. Also resolve entry.task_obj into a local first, because its type is asyncio.Task | None.
  • tinyagentos/agent_loop.py#L433-L449: replace the wait_for/gather pair with asyncio.wait(tasks, timeout=timeout) and raise asyncio.TimeoutError when pending is non-empty.
📍 Affects 1 file
  • tinyagentos/agent_loop.py#L404-L416 (this comment)
  • tinyagentos/agent_loop.py#L433-L449
🤖 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 `@tinyagentos/agent_loop.py` around lines 404 - 416, The await API at
tinyagentos/agent_loop.py lines 404-416 must stop cancelling live subagent tasks
on timeout: resolve entry.task_obj to a local asyncio.Task, use
asyncio.wait({task_obj}, timeout=timeout), and raise asyncio.TimeoutError when
it is not done, preserving cancellation handling and result retrieval. Apply the
same non-cancelling asyncio.wait approach to lines 433-449, replacing the
wait_for/gather pair and raising asyncio.TimeoutError when pending is non-empty.

@jaylfc

jaylfc commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

BLOCKING, two independent blockers. The library core is genuinely good — real un-mocked tests, no hallucinated APIs, clean cancel-propagation — which is why this is worth fixing forward rather than closing.

  1. Not wired in — the exact shape Make 3-strike QUARANTINE loud: store + lifespan wiring + surfacing + tests (FULL slice, replaces tsk-glxi4e) #2333 was blocked for this morning. git grep agent_loop on dev: zero hits; the diff adds only the four new files. The design doc's "Integration points" section is written in the future tense ("routes/taos_agent.py CAN use", "AgentChatRouter CAN delegate") and neither does. After merge no real taOS agent can spawn a subagent, queue a message, or surface status — card items (1), (3) and (4) are unmet, and the changelog fragment announces behavior nothing enables. Also undesigned: AgentChatRouter already serializes turns per agent with its own asyncio.Lock; which mechanism wins needs deciding as part of the wiring.

  2. The safe-point transition violates the card's own core invariant ("queued, never dropped"). Verified against the source. reach_safe_point drains the queue under the lock, releases it, awaits cancel_subagents() (up to 10s), then re-acquires to set IDLE. In that window handle_message sees state SAFE_POINT, takes the not-WORKING branch, starts turn A and returns IMMEDIATE; the resumed reach_safe_point then stomps state to IDLE with turn A in flight; the next message clears the queue — silently dropping anything queued for turn A — and two "atomic" turns run concurrently. Fix: hold the lock across the whole transition or re-check state before the IDLE reset, and remove the destructive clear() in the IMMEDIATE branch.

  3. Also required: await_subagent/await_all_subagents use asyncio.wait_for(task, timeout), which CANCELS the task on timeout — a caller polling with a timeout kills the heavy work as a side effect, and the "does not cancel them, just waits" docstring is false under timeout. Use asyncio.wait/shield semantics. And reuse task_utils (_create_supervised_task, cancel_and_wait) instead of re-implementing them — self._subagents/self._delivered are never pruned (unbounded growth on a long-lived loop), and the fire-and-forget create_task in _progress is the weak-ref GC pitfall dev's own agent_chat_router.py documents and guards against.

Test gaps sit exactly on the bugs: no test for a message arriving during the safe-point/cancel window, none for await_subagent(timeout=...). Add both, red-first.

Bot adjudication: CodeRabbit's Critical (the race) and Majors (wait_for, GC'd task) are all correct, verified independently. Kilo's force-terminate and status()-lock findings are noise. Fix-forward card follows; do not open a new PR.

@jaylfc jaylfc added the lead-blocked Lead has blocked this PR; gate_merge.sh refuses at exit 10. label Aug 9, 2026
jaylfc added a commit that referenced this pull request Aug 9, 2026
Fix-forward #2335: safe-point race + wait_for cancellation + task_utils reuse + wiring design note
@jaylfc

jaylfc commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #2337 (merged): same four files rebuilt with the safe-point race fixed (red-proven against this PR's code), non-destructive await semantics, task_utils reuse, and an honest changelog. Wiring is deliberately deferred to the AgentLoop/AgentChatRouter design decision.

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

Labels

lead-blocked Lead has blocked this PR; gate_merge.sh refuses at exit 10.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant