Skip to content

[Bug] Sync subagent delegation returns to parent before its background descendants (explore/librarian) quiesce #4615

@ririnto

Description

@ririnto

Prerequisites

  • I am writing this issue in English.
  • I searched existing issues and found related but not exact matches.
  • I am using the latest version of oh-my-openagent (4.5.1).
  • I read the documentation (docs/guide/orchestration.md) and couldn't find the answer.

Bug Description

A sync subagent delegation returns to its parent before the background descendants it launched have quiesced.

Case: main delegates task(subagent_type="plan", run_in_background=false). plan runs its mandatory protocol and launches explore / librarian via call_omo_agent(..., run_in_background=true). If plan reaches a terminal turn before those finish, the parent's sync poller declares plan complete and returns immediately — so the parent gets a plan that never synthesized the explore/librarian findings.

The notification path is not the problem: parent-wake (parent-wake-notifier.ts, recently refined) does deliver completion wakes to the plan session. It is an ordering bug — the main-side sync poller returns the premature result before parent-wake can resume plan.

This is the runtime layer of #2124 (addressed only at the prompt layer by #2328, which covered Sisyphus but not plan), and the same quiescence gap as #4163.

Steps to Reproduce

  1. Delegate task(subagent_type="plan", run_in_background=false) from a main session.
  2. Let plan launch explore / librarian via call_omo_agent(..., run_in_background=true).
  3. Let plan reach a terminal turn before they finish.
  4. Parent receives the plan result while explore/librarian are still running; their findings are not synthesized.

Expected Behavior

While a sync subagent owns direct child background tasks in pending/running, the parent delegation should stay non-quiescent until they finish and the subagent emits a terminal turn after collecting them. This should apply to all sync subagents (not just plan), and stay bounded since descendants carry their own timeouts.

Actual Behavior

The sync poller decides completion from the subagent's terminal assistant finish alone, never consulting the BackgroundManager for in-flight descendants, so it returns early.

Two Code Paths (why notifications work but the parent returns early)

Background completion is handled by two independent paths that never meet:

Path Trigger Knows about background descendants? Effect on the parent delegation
Parent-wake (notification) A background task reaches a terminal state, gated on validateSessionHasOutput (real response arrived) + no incomplete todos Yes — driven by task completion Async-injects a system-reminder into the parent session so it could resume. Recently refined (defer during active turns, unblock stale wakes, align activity windows).
Sync completion (poller) The polled subagent emits a terminal assistant turn No — purely session-local, no output/descendant cross-check Breaks the poll loop and returns the subagent result to its caller

The asymmetry is the core issue: the background path waits for real output before notifying, while the sync path returns on terminal finish alone. Because the two are decoupled, the sync poller fires first on plan's initial terminal turn and returns the premature result; the parent-wake that would have resumed plan arrives afterward, against a delegation that has already concluded. Improving parent-wake therefore cannot fix this — the completion decision has to consult descendant state.

Root Cause Analysis

The sync completion check is session-local and is never given a BackgroundManager reference; the parent-wake path is async and not consulted by the completion decision.

Evidence:

  • sync-session-poller.tsisSessionComplete() returns true from the session's own terminal finish / pending tool parts only; the poll loop breaks immediately, with no descendant check.
  • sync-task.tsexecuteSyncTask() holds executorCtx.manager but never passes it to pollSyncSession(...); after the poll it fetches the result and returns with no continuation.
  • completion-poller.tswaitForCompletion() has the same gap on the call_omo_agent sync path (polls only target session status/messages).
  • parent-wake-notifier.ts — delivers wakes asynchronously and never checks subagentSessions / syncSubagentSessions, so it cannot hold the sync poller open.
  • manager.ts — the needed predicate already exists: getTasksByParentSession() / getAllDescendantTasks(), and updateBackgroundTaskMarker() already treats running || pending as active; the background path's own completion is gated by validateSessionHasOutput().
  • constants.tsPLAN_AGENT_SYSTEM_PREPEND_STATIC_BEFORE_SKILLS tells plan to launch the agents but not to wait for and collect their results (unlike Sisyphus after fix(agents): prevent orchestrator from rushing ahead of background agents #2328).

Suggested Fix Surface

Two layers, mirroring the runtime/prompt split:

  1. Sync completion check (deterministic): make it descendant-aware. sync-session-poller.ts should not treat the session as complete while its direct child background tasks are pending/running (resetting the inactivity timer during that wait); sync-task.ts should pass executorCtx.manager to the poller; apply the same to completion-poller.ts for the call_omo_agent sync path. Bound the wait and limit it to direct children (not a recursive descendant block, per [Bug] todo-continuation can resume parent session before descendant background tasks finish #4163).
  2. plan prompt (reinforcement): instruct plan to wait for and collect explore/librarian results via background_output before producing the plan, as fix(agents): prevent orchestrator from rushing ahead of background agents #2328 did for Sisyphus.

The runtime gate keeps the parent from returning early (giving parent-wake time to resume plan); the prompt ensures the resumed plan actually collects and synthesizes. Parent-wake itself needs no change.

Related Issues

Additional Context

Based on behavior directly observed while using omo, combined with source analysis of the dev branch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions