Skip to content

Nest OpenCode sub-agent turns under the dispatching task span - #1

Open
DZQOX wants to merge 3 commits into
mainfrom
feat/opencode-subagent-nesting
Open

Nest OpenCode sub-agent turns under the dispatching task span#1
DZQOX wants to merge 3 commits into
mainfrom
feat/opencode-subagent-nesting

Conversation

@DZQOX

@DZQOX DZQOX commented Aug 4, 2026

Copy link
Copy Markdown

Problem

OpenCode runs sub-agents (the task tool) in child sessions. Two symptoms followed:

  1. No correlation: the plugin stamped each span with its own OpenCode sessionID, so a sub-agent run landed in a second, disconnected Softprobe session — invisible from the parent session's view.
  2. Data loss: any session's session.idle triggered a global finalizeSessionTracing(), ending all sessions' in-flight spans. A child idle would bare-end the parent's still-running task span (status ok from creation, no output ever written) and truncate the parent's turn.

Approach

One agent run = one trace tree. Child session turns are parented under the dispatching task span at collection time.

  • Authoritative binding from the task part's state.metadata.sessionId (published while the part is running, before the child starts — exact under parallel task calls). Fallbacks: task_id resume, then parentID + conservative inference (unique candidate → agent name → prompt text). Never attaches on ambiguity — a wrong nesting is silent corruption, a missing one is only a missing edge; unnested children fall back to the parent turn, then to an explicit trace root.
  • Per-session lifecycle: session.idle / session.status finalize only that session (composite dedup keys, per-session state index). Force-ended tools are marked traced so late duplicate ends don't recreate spans. Duplicate idle events are coalesced; hosts without sessionID in idle fall back to global finalize.
  • Span linkage: sp.child.session.id on task spans; sp.metadata.opencode.parentSessionID / parentTaskCallID on child turns. sp.session.id semantics unchanged (root-session stamping deferred pending Explorer rendering verification).
  • StartObservationOptions.root (new in @softprobe/tracing): root turns detach from the host's ambient OTEL context instead of being adopted into the host's trace.
  • Layering: new SessionGraph module holds all relationship data (pure, independently unit-tested); hooks layer subscribes session.created/updated/deleted and classifies unknown sessions lazily via the OpenCode client API (1.5s timeout) before the first span — spans cannot be re-parented afterwards.

Resume (task_id) re-binds to the new task call; nesting works recursively for grandchildren; ended task spans are archived so late-starting children still attach.

Review follow-up (a3191d0)

Two independent review passes found that taskSpanFor violated the "never attaches on ambiguity" invariant the design rests on: when the bound call's span was missing it substituted the unique active task span in the parent session, which could parent a child under an unrelated sibling task. The fallback is now deleted — direct lookup only, with callers falling back to the parent session's turn.

Its justification did not hold: it assumed a path keying the task span by part.id while the binding carried a different callID, but ToolPart.callID is required in @opencode-ai/sdk 1.18.5 and traceToolPart / captureTaskBinding derive the id from the same expression, so the two cannot diverge. Gating the fallback was not sufficient either — pruneTaskCalls evicts ended calls at 500 while bindings is never pruned, which re-opens the gate (reproduced against the pinned commit).

Also in this pass:

  • Bounded dedup sets. They survived every scoped finalize but nothing cleared them, so they grew for the process lifetime. Capped, and no longer cleared on the global path either — which extends the "a late duplicate delivery cannot recreate a span" guarantee to the dispose/shutdown paths. The cap raises the threshold rather than removing the failure, and is documented as such.
  • assistantParts deliberately left unbounded. Every SDK part shape declares sessionID, so entries are always indexed and cleared with their session; a cap could only drop parts a pending traceGeneration still needs, losing turn and generation output.
  • Idle-coalescing mark also resets on session.next.step.started (activity arriving without a chat.message).
  • Session lifecycle event shapes declared locally instead of cast inline at each call site.
  • sp.child.session.id documented as best-effort: read once at tool end, so a task finishing before its child link is published ships without it.

Known and not addressed here: SessionGraph's parents / roots / bindings / childByTaskCall remain unbounded, growing one entry per session observed.

Test plan

  • pnpm build && pnpm lint && pnpm test — 90 tests green workspace-wide (58 in opencode-plugin)
  • SessionGraph unit tests (binding authority, inference disambiguation, ambiguity → undefined, eviction)
  • BoundedSet / BoundedMap eviction and refresh-semantics unit tests
  • Multi-session integration tests — same-trace assertions (traceId/parentSpanId), idle isolation + task-output regression, duplicate-end recreation guard, resume re-binding, legacy fallback, lazy lookup, parallel-ambiguity fallback, late attach under ended span, grandchild nesting, hooks end-to-end
  • Regression tests for the review findings: no sibling-task substitution, dedup keys survive a global finalize, step.started resets idle coalescing, sp.child.session.id absent when the task span ends first
  • Verify against a live OpenCode sub-agent run before release — the lifecycle event names (session.status, session.created/updated/deleted) are not type-checked, since the host Event union resolves to any without @opencode-ai/sdk installed

zp and others added 3 commits August 5, 2026 03:42
Sub-agents (task tool) run in child sessions that were traced as
disconnected Softprobe sessions, and any session's idle finalized every
session's in-flight spans — destroying the parent task span's output.

- Scope finalize to the idle session (composite dedup keys, per-session
  state index) so a child idle never disturbs the parent; task output now
  survives. Also mark force-ended tools as traced so late duplicate ends
  do not recreate spans.
- Bind child sessions to their dispatching task call: task part
  state.metadata.sessionId (authoritative), task_id resume, then
  conservative parentID + task-call inference; never attach on ambiguity.
- Parent child turns under the task span (single trace, recursive);
  record sp.child.session.id on task spans and
  sp.metadata.opencode.parentSessionID / parentTaskCallID on child turns.
- New SessionGraph module holds all session relationship data; hooks
  layer handles session.created/updated/deleted + session.status,
  coalesces duplicate idle events, and classifies unknown sessions
  lazily via the OpenCode client API before the first span is created.
- Add StartObservationOptions.root so root turns detach from the host's
  ambient OTEL context instead of being adopted into it.
…ure.

- Reset the idle coalescing mark on new activity (busy status or
  chat.message) so a genuinely fresh idle is never swallowed — a swallowed
  idle left the turn span open and polluted its duration.
- Bind task_id resumes at tool start (same authority as part metadata) so
  a child message arriving before the call's part metadata can no longer
  consume a stale binding and attach to the previous dispatch's ended span.
- session.deleted now finalizes the session's in-flight spans before
  evicting its relationship data.
- Keep all dedup sets process-lifetime: scoped idle clear only drops
  payload caches, so late completed parts after a new turn cannot recreate
  ghost spans.
- resolveTaskCall rejects a lone candidate that contradicts agent/prompt
  hints; SessionGraph.evictSession drops dangling child parent links;
  scoped clearTraceState no longer discards live generation entries.
- Classification failure never skips traceUserMessage; export
  SessionLookup and parseTaskCallArgs from the package root.
taskSpanFor fell back to "the unique active task span in the parent
session" whenever the bound call's span was missing, which could parent a
child turn under an unrelated sibling task — the exact silent corruption
the binding design argues against. Delete the fallback: direct lookup
only, and callers fall back to the parent session's turn.

The fallback's justification does not hold. It claimed a path keying the
task span by part.id while the binding carried a different callID, but
@opencode-ai/sdk 1.18.5 makes ToolPart.callID required, and traceToolPart
and captureTaskBinding derive the id from the same expression, so the two
cannot diverge. Gating the fallback rather than removing it was not enough
either: pruneTaskCalls evicts ended calls at 500 while bindings is never
pruned, which re-opens the gate and mis-parents the child under whichever
sibling task happens to be active.

Dedup sets survived every scoped finalize but nothing ever cleared them,
so they grew for the process lifetime; cap them (BoundedSet) and stop
clearing them on the global path too, which makes the "a late duplicate
delivery cannot recreate a span" guarantee hold on the dispose and
shutdown paths as well. The cap is a threshold, not a cure — an evicted
callID that later receives a duplicate still yields two spans — so it sits
far above what any live session produces.

assistantParts stays unbounded on purpose: every SDK part shape declares
sessionID, so entries are always indexed in messageSession and cleared
with their session. Capping it could only drop parts a pending
traceGeneration still needs, losing the turn and generation output.

Not covered here: SessionGraph's parents/roots/bindings/childByTaskCall
are still unbounded, growing one entry per session observed.

Also reset the idle-coalescing mark on session.next.step.started (activity
that arrives without a chat.message; costs one extra forceFlush if it ever
lands between the paired idles), and declare the session lifecycle event
shapes locally instead of casting inline at each call site.

sp.child.session.id stays best-effort: it is read once at tool end and the
ended span is never revisited, so a task finishing before its child link
is published ships without it. The child turn carries the reverse edge in
most cases, though an ambiguous dispatch leaves only parentSessionID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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