Skip to content

fix(core): report every subagent result to its parent when it goes idle - #60360

Open
kitlangton wants to merge 1 commit into
devfrom
subagent-idle-parent
Open

kitlangton wants to merge 1 commit into
devfrom
subagent-idle-parent

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Subagents now report every real result to their parent whenever they go idle, including turns that finish after the initial run or across a server restart.
A subagent is a child session launched to do a task; if a background command or plugin woke it for another turn after its first reply, the parent never received the follow-up result. Turns started by a user prompting an idle child directly, empty retries, and deliberate cancellations behave as before, while steering a running child still delivers its answer.

🔍 Origin: reported in #48826; replaces #58825 with one saved-marker path on the SQLite job table (#60491).

❌ Before

sequenceDiagram
    participant P as Parent session
    participant J as Background job
    participant C as Child session
    J->>C: run first turn
    C-->>J: first turn settles (or stops empty)
    J->>P: deliver once, then stop watching
    Note over C: Background shell or plugin wakes child
    C-->>C: ❌ finishes real result, parent never hears
Loading

✅ After

sequenceDiagram
    participant P as Parent session
    participant J as SQLite job row
    participant C as Child session
    Note over J,C: One job row per child tracks reported idle + dirty flag
    C->>J: turn 1 idle (text or error)
    J->>P: deliver <subagent ...> and advance reported
    Note over C: Later turn (or after server restart)
    C->>J: turn 2 idle (text or error)
    J->>P: ✅ deliver <subagent ...> in order and advance reported
Loading
- background job waits once -> reads last message -> delivers to parent
- later child turns after job exit are unwatched and lost
+ store one `job` row per child session (`pending`, `reported`, `dirty`)
+ session projector marks `dirty` on idle, cleans up on delete, and re-anchors `reported` on revert
+ whenever a child goes idle (and on startup recovery for dirty rows), scan new turns in order:
+   deliver text results and failures to parent; skip empty turns, interrupts, and user-started turns
+ subagent job only blocks foreground calls and reports explicit cancellations

📊 Proof

Case Before After
Background or foreground child woken by a background shell or plugin after its first report ❌ follow-up result dropped ✅ parent receives <subagent> for each real turn in order
Empty initial step or repeated empty plugin continuations ❌ parent woken with "completed without a text response" ✅ silent until a turn produces text or fails
Server restarts between a child's first turn and a later continuation (or before delivery) ❌ lost ✅ saved job row survives restart; catches up in order and reconciles legacy notifications without duplicates
User steers a running child, reverts a child turn, prompts an idle child directly, interrupts, or deletes — ✅ steered and post-revert turns deliver; idle direct prompt skipped; row removed on delete

The continuation, empty-step, ordering, revert, and restart tests in tool-subagent.test.ts and session-execution.test.ts fail on dev without the source changes. 114 targeted tests pass across 6 test files.

↩️ Undo: revert isn't enough if a database has already run migration 20261030204714_job_subagent, which adds pending, reported, and dirty columns and upgrades job_child_session_idx to a unique index on job(child_session_id). Earlier code ignores the nullable/defaulted columns, while multiple retained background job rows for the same child session would require dropping the unique index.

👀 Read: packages/core/src/session/subagent-job.ts (syncUnlocked, legacy notification_id reconciliation in deliverToParent, and the foreground hold in block) alongside the JobTable projections in packages/core/src/session/projector.ts. The rest is routine.

Supersedes #57132 for delivering follow-up turns after background work · builds on #60491

Checks run
  • packages/core: bun script/migration.ts --check (0 schema drift)
  • packages/core: bun test test/job.test.ts test/session-execution.test.ts test/tool-subagent.test.ts test/config/command-subagent.test.ts test/session-projector.test.ts test/session-revert.test.ts (114 pass, 0 fail)
  • packages/core: bun run typecheck and bun run typecheck:tsc (0 errors)
  • packages/server: bun run typecheck and bun run typecheck:tsc (0 errors)
  • bun run check (lint + workspace typecheck, 35/35 packages, 0 errors)

Added by the opencode bot (AI-generated).

Fixes #48826

Related: #50751, #36349, #45480

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The PR fixes what it targets: on the base branch the PR's continuation, empty-step, ordering and restart tests fail, and with the PR they all pass. Core and server pass tsc. Public API: unchanged. The change only adds internal KV keys (subagent.parent/<id>) and metadata.source: "subagent" on subagent prompts.

Concerns:

  • Steering a background child now drops its result. Background completions are no longer delivered by the job. Delivery now depends on the idle scan, and the scan skips any turn that contains a user message without source: "subagent". A user typing into a running background child from the TUI therefore suppresses the parent's notification for that turn. On dev that turn's result was still delivered. See the inline comment.
  • #50751 is not fixed by this. The PR cites it as the origin, but its reproduction continues the child through a plugin's session.prompt (prompt-session). That is a user prompt without the subagent source, which this PR deliberately skips. The case actually fixed is a child woken by a synthetic message (background shell or plugin).
  • Unbounded markers on every boot. Markers are only removed when a session is deleted, so they build up for every subagent child ever created. Each startup syncs all of them, and each deletion scans all of them (inline).

Fixes #48826
Related: #50751, #36349, #45480

}
}
for (const message of page) {
if (message.type === "user" && message.metadata?.source !== "subagent") prompted = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any user message without source: "subagent" in a busy period suppresses that turn's delivery. That includes a message a user types into a child that is still running its parent-requested work. For background children the job no longer delivers completions (notify only calls completeBackground), so the parent never gets that result. On dev it did, as noted on #50751: messages sent mid-run land in the answer the parent receives. Could the skip only apply when the turn was started by a direct prompt, e.g. the first user/synthetic message after the previous idle, rather than any user message inside it?

}),
)

const recover: Interface["recover"] = (suspended, prepare) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Markers are only cleared when a session is deleted or the parent/child goes missing, so every subagent child ever spawned keeps one. recover then runs a full sync per marker on every startup: a KV read, two session lookups and a message page. forget also scans every marker on each session deletion. That grows without bound for heavy subagent users. Could markers be dropped, or skipped on recovery, once a child is idle and fully reported, or be indexed by parent?

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both earlier points are fixed:

  • A turn is now classified by the message that started it, so steering a running child still delivers its result.
  • Startup only syncs markers that are dirty or orphaned, and session deletion uses a per-parent child index instead of scanning every marker.

I re-ran the #48826 reproduction in the TUI (simulated model). On dev the parent only ever gets the early reply. With this PR it receives a second <subagent> notification carrying the post-shell final answer. The three nearest test files pass (62/62) and core passes tsc.

Public API: unchanged. The only additions are internal KV keys (subagent.parent/… and subagent.child/…) and metadata.source: "subagent" on subagent prompts.

One optional note inline on the raw SQL in dirtyMarkers.

Before

After

)

// Startup only visits markers that are orphaned or behind the child's newest projected idle row.
const dirtyMarkers = Effect.gen(function* () {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not blocking: this goes around KV.Service and queries KVTable, SessionTable and SessionMessageTable directly, using json_extract on the KV value. That couples SubagentJob to KV's JSON-text storage and to the session schema, so a change to either would silently break startup recovery rather than fail to compile. A small query on SessionStore, such as the latest idle IDs for a set of sessions, together with kv.scan would keep it within the existing services.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The remaining point is addressed. SubagentJob no longer queries the KV and session tables directly. Startup now scans a subagent.dirty/<child> flag, which is written in the same transaction that projects the idle or revert event. The earlier steering and startup-cost fixes are unchanged.

I re-ran the #48826 reproduction in the TUI: the parent still receives the follow-up <subagent> result. The three nearest test files pass (62/62) and core passes tsc.

Public API: unchanged. The only additions are internal KV keys (subagent.parent/…, subagent.child/…, subagent.dirty/…).

No new concerns.

Before

After

@kitlangton
kitlangton force-pushed the subagent-idle-parent branch from efefd6a to f2c1a6e Compare October 30, 2026 21:33

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All three earlier points are addressed:

  • Turns are still classified by their starter message, so steering a running child keeps reporting.
  • Markers are now one job row per child, with reported/dirty columns. Startup only syncs dirty rows, and session deletion removes rows in the projector.
  • The KV keys and the raw json_extract queries are gone.

I re-ran the #48826 reproduction in the TUI with the simulated model. On dev the parent never gets the post-shell result; with this PR it does. The six target test files pass (114/114), migration.ts --check shows no drift, and core passes tsc.

Public API: unchanged. The new job_subagent migration adds job.pending, job.reported and job.dirty, and makes job_child_session_idx unique after deleting duplicate per-child rows. As the description notes, rolling back requires dropping that index.

One new risk, inline: the catch-up loop in syncUnlocked can spin forever if a revert removes reported while it runs.

  • packages/core/src/session/subagent-job.ts:171: This re-scan has no exit if reported no longer exists. store.messages returns [] when the cursor ID is missing (if (input.cursor && !anchor) return []). A revert committed while this sync is running deletes that idle message and moves the row's reported to an older idle in the projector. The local reported here is still the deleted ID, so latestIdle !== reported stays true. Every pass then gets an empty page and loops again, querying the database without end while holding the child's lock, and updateMarker(..., false) never runs. Could this re-read the row's reported (or bail out on an empty page) instead of looping on local state?

Before

After

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] V2: subagent with pending background work (shell commands or subagents, background: true) marked completed early — results never collected

1 participant