Nest OpenCode sub-agent turns under the dispatching task span - #1
Open
DZQOX wants to merge 3 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
OpenCode runs sub-agents (the
tasktool) in child sessions. Two symptoms followed:session.idletriggered a globalfinalizeSessionTracing(), ending all sessions' in-flight spans. A child idle would bare-end the parent's still-running task span (statusokfrom 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.
state.metadata.sessionId(published while the part is running, before the child starts — exact under parallel task calls). Fallbacks:task_idresume, 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.session.idle/session.statusfinalize 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.sp.child.session.idon task spans;sp.metadata.opencode.parentSessionID/parentTaskCallIDon child turns.sp.session.idsemantics 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.SessionGraphmodule holds all relationship data (pure, independently unit-tested); hooks layer subscribessession.created/updated/deletedand 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
taskSpanForviolated 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.idwhile the binding carried a different callID, butToolPart.callIDis required in@opencode-ai/sdk1.18.5 andtraceToolPart/captureTaskBindingderive the id from the same expression, so the two cannot diverge. Gating the fallback was not sufficient either —pruneTaskCallsevicts ended calls at 500 whilebindingsis never pruned, which re-opens the gate (reproduced against the pinned commit).Also in this pass:
assistantPartsdeliberately left unbounded. Every SDK part shape declaressessionID, so entries are always indexed and cleared with their session; a cap could only drop parts a pendingtraceGenerationstill needs, losing turn and generation output.session.next.step.started(activity arriving without achat.message).sp.child.session.iddocumented 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'sparents/roots/bindings/childByTaskCallremain unbounded, growing one entry per session observed.Test plan
pnpm build && pnpm lint && pnpm test— 90 tests green workspace-wide (58 inopencode-plugin)BoundedSet/BoundedMapeviction and refresh-semantics unit testssp.child.session.idabsent when the task span ends firstsession.status,session.created/updated/deleted) are not type-checked, since the hostEventunion resolves toanywithout@opencode-ai/sdkinstalled