Skip to content

fix(omp): keep parent non-idle while internal task subagents run - #2245

Closed
jasonhnd wants to merge 1 commit into
getpaseo:mainfrom
jasonhnd:fix/2232-omp-idle-while-task-subagents
Closed

fix(omp): keep parent non-idle while internal task subagents run#2245
jasonhnd wants to merge 1 commit into
getpaseo:mainfrom
jasonhnd:fix/2232-omp-idle-while-task-subagents

Conversation

@jasonhnd

Copy link
Copy Markdown
Contributor

Summary

Fixes #2232: an OMP parent agent can flip to idle / UI “stopped” while OMP-internal task subagents are still actively writing child session logs.

Background / root cause

Paseo marks an OMP turn complete after:

  1. OMP emits agent_end (with an assistant message), and
  2. get_state() reports !isStreaming && !isCompacting

That only means the parent model loop is idle.

OMP’s isStreaming is approximately:

agent.state.isStreaming || promptInFlight

It does not include background task / hub-wait children. So a long fan-out audit can:

  • end the parent turn
  • keep the OMP process alive
  • keep writing child *.jsonl under the session artifacts directory
  • while Paseo already shows the parent as idle/stopped

This matches the reporter’s observation:

Observed Meaning
paseo inspectStatus: idle Paseo turn completed
OMP pid still alive with open FDs on child jsonls Work still in progress
Stored nativeHandle → missing ....jsonl Parent file path may not be on disk
Real artifact → ..../ directory with live child jsonls OMP stem/artifacts layout

OMP’s on-disk layout (confirmed against OMP v17.0.5) is:

parent:   ~/.omp/agent/sessions/<cwd>/<timestamp>_<id>.jsonl
children: ~/.omp/agent/sessions/<cwd>/<timestamp>_<id>/<AgentName>.jsonl

Paseo already assumed that layout for history replay. The bug was treating parent turn idle as whole job finished.

What this PR changes

  1. Turn-complete gate (main fix)
    In completeTurnAfterProviderIdle, also require no running OMP-internal subagents before emitting turn_completed / flipping the agent idle.

  2. get_subagents reconciliation
    Each idle poll calls OMP’s get_subagents RPC and merges into the local subagent index.

    • Covers missed lifecycle/progress frames
    • Older OMP binaries without the RPC fall back to the event index (debug-logged)
  3. Orphan child history recovery
    If the parent .jsonl is missing (ENOENT) but the stem artifacts directory exists, history still surfaces child transcripts instead of returning empty.

Non-goals (intentionally not in this PR)

  • New lifecycle enum values (waiting / delegated)
  • Promoting every OMP task child into a full managed Paseo agent
  • Rewriting nativeHandle from file path to directory (OMP --session still expects the logical .jsonl path)

Evidence

Issue reproduction shape (from #2232)

Reporter case:

  • Paseo 0.1.110, OMP 17.0.5
  • Parent status idle while child files under session stem continued updating
  • Healthy running agents still had existing parent .jsonl handles; the broken case had missing parent file + live stem directory

Code-path evidence (this repo)

Before this PR, turn completion was solely:

if (!state.isStreaming && !state.isCompacting) {
  this.completeTurn(...)
}

OMP subagent events were already mapped (subagent_lifecycle / subagent_progressprovider_subagent), but never consulted when deciding whether the parent turn was done.

After this PR:

if (!state.isStreaming && !state.isCompacting && !(await this.hasRunningOmpSubagents())) {
  this.completeTurn(...)
}

hasRunningOmpSubagents() reconciles via get_subagents, then checks the index.

Upstream OMP evidence

OMP v17.0.5:

  • get_state exposes isStreaming / sessionFile but not running children
  • Separate RPC: get_subagents{ subagents: [...] } with per-child status
  • Artifacts dir = parent session file with .jsonl stripped

So Paseo was missing a signal that OMP already exposes.

Test plan

Automated (ran locally)

npx vitest run \
  packages/server/src/server/agent/providers/omp/agent.test.ts \
  packages/server/src/server/agent/providers/omp/subagent-index.test.ts \
  packages/server/src/server/agent/providers/omp/history-mapper.test.ts \
  packages/server/src/server/agent/providers/omp/cli-runtime.test.ts \
  packages/server/src/server/agent/providers/omp/runtime.test.ts \
  --bail=1

38 tests passed, including new coverage:

Test What it proves
stays active while OMP internal task subagents are still running Parent agent_end + isStreaming=false + running lifecycle child ⇒ no turn_completed until child completes
stays active when get_subagents reports running children without prior events Even without prior lifecycle events, get_subagents alone can block idle and later release it
hasRunning / reconcileSnapshots unit tests Index tracks running/terminal correctly
discovers child transcripts when parent session file is missing Orphan stem-dir children still appear in history

Also:

  • npm run typecheck (pre-commit / workspace)
  • npm run lint on touched files

Manual verification checklist

  • Start an OMP agent that fans out multiple task subagents (audit/research style)
  • While children are still writing, confirm parent stays running in list/inspect
  • After all children complete, parent becomes idle
  • Interrupt while children run: parent cancels cleanly, does not stick forever
  • Optional: inspect history when parent .jsonl is absent but stem dir has child jsonls

Problem solved

Before After
Parent looks idle/stopped while OMP task children still run Parent stays non-idle until children are not running
Easy to interrupt/restart work that is still live Status reflects real in-flight fan-out work
Missing parent .jsonl ⇒ empty history Child transcripts under stem dir still surface

Closes #2232

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 638e4a9ff2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

async getSubagents(): Promise<OmpSubagentSnapshot[]> {
const data = OmpSubagentsResultSchema.parse(await this.request({ type: "get_subagents" }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound get_subagents fallback latency

When running against OMP builds that predate get_subagents, this call does not fall back promptly: the OMP RPC docs note that unknown-command responses omit the request id, while JsonlRpcProcess only resolves pending requests by id and otherwise waits for its default 30s timeout. In that environment, every otherwise-idle turn now waits about 30 seconds before turn_completed is emitted, making normal prompts appear stuck; use a short timeout/probe and cache unsupported runtimes before gating completion on this RPC. Source checked: https://github.com/can1357/oh-my-pi/blob/main/docs/rpc.md#requestresponse-correlation

Useful? React with 👍 / 👎.

state.description = snapshot.description ?? state.description;
state.toolCallId = snapshot.parentToolCallId ?? state.toolCallId;
state.status = mapSnapshotStatus(snapshot.status);
events.push(this.upsert(snapshot.id, state.status, state));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Deduplicate polled subagent upserts

When an OMP task child keeps running after agent_end, the idle gate polls roughly every 10ms and reconcileSnapshots returns an upsert for every unchanged snapshot on every poll. The provider-subagent store applies and forwards each upsert with a fresh updatedAt, so a minutes-long fan-out can produce thousands of duplicate WebSocket/store updates per child even though nothing changed; only emit when the descriptor actually changes or debounce this polling path.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real-world bug (#2232) where a Paseo parent OMP agent would flip to idle/stopped while OMP-internal task subagents were still actively writing child session logs. The fix adds a hasRunningOmpSubagents() gate to completeTurnAfterProviderIdle, backed by a new get_subagents RPC poll and a reconcileSnapshots merge into the existing subagent index. A secondary fix surfaces orphan child transcripts in history when the parent .jsonl is missing.

  • Turn-completion gate: completeTurnAfterProviderIdle now also awaits hasRunningOmpSubagents() — which polls OMP's get_subagents RPC and merges results into the subagent index — before emitting turn_completed.
  • Orphan history recovery: streamOmpHistory now falls back to discoverOrphanSubagentTranscripts when the parent .jsonl is absent but the stem artifacts directory still holds child .jsonl files.
  • OmpSubagentSnapshot schema: the hand-written interface is replaced by a Zod-inferred type (OmpSubagentsResultSchema), making index optional and adding detached to match the actual OMP wire format.

Confidence Score: 4/5

Safe to merge for the targeted bug; the reconcile path has an unresolved state-regression edge case under concurrent cancellation noted in a prior review thread.

The idle-gate fix is well-targeted and the new tests prove the key scenarios. The main residual concern — noted in an earlier review thread — is that reconcileSnapshots unconditionally overwrites state.status on every poll, which means a child that was already marked canceled by terminalizeRunning can regress back to running if OMP's get_subagents response hasn't caught up yet. That code is unchanged in this PR. For normal (non-canceled) fan-out flows the new gate works correctly.

packages/server/src/server/agent/providers/omp/subagent-index.ts — reconcileSnapshots unconditionally overwrites status; and packages/server/src/server/agent/providers/omp/history.ts — orphan discovery hardcodes all children as completed.

Important Files Changed

Filename Overview
packages/server/src/server/agent/providers/omp/agent.ts Core fix: adds hasRunningOmpSubagents() guard to completeTurnAfterProviderIdle; logic is correct and well-scoped.
packages/server/src/server/agent/providers/omp/subagent-index.ts Adds hasRunning and reconcileSnapshots; reconcileSnapshots unconditionally overwrites status on every poll (previously flagged in thread).
packages/server/src/server/agent/providers/omp/history.ts Adds orphan child transcript discovery; all discovered orphans are hardcoded as "completed" regardless of actual terminal state.
packages/server/src/server/agent/providers/omp/rpc-types.ts Adds OmpSubagentSnapshotSchema/OmpSubagentsResultSchema and get_subagents command; replaces hand-written interface with schema-inferred type, making index optional and adding detached.
packages/server/src/server/agent/providers/omp/agent.test.ts Two new regression tests for the idle-gate fix; behavior coverage is strong but both tests use a bare setImmediate hack in the test body.
packages/server/src/server/agent/providers/omp/subagent-index.test.ts New unit tests for hasRunning and reconcileSnapshots; clear setup/act/assert and cross the module interface.
packages/server/src/server/agent/providers/omp/history-mapper.test.ts New test for orphan transcript discovery; only covers the success case — a failed/aborted child transcript would show as "completed" due to the hardcoded status.
packages/server/src/server/agent/providers/omp/cli-runtime.ts Adds getSubagents() RPC call with schema validation; straightforward and correct.
packages/server/src/server/agent/providers/omp/runtime.ts Adds getSubagents() to the OmpRuntimeSession interface; clean addition.
packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts Simplifies FakeOmpSubagentSnapshot to a type alias for OmpSubagentSnapshot; aligns fake with real type.

Sequence Diagram

sequenceDiagram
    participant Paseo
    participant OmpAgentSession
    participant SubagentIndex
    participant OmpRuntimeSession

    Note over Paseo,OmpRuntimeSession: Parent model turn ends (agent_end + isStreaming=false)

    loop completeTurnAfterProviderIdle
        OmpAgentSession->>OmpRuntimeSession: getState()
        OmpRuntimeSession-->>OmpAgentSession: "{isStreaming: false, isCompacting: false}"
        OmpAgentSession->>OmpRuntimeSession: getSubagents()
        OmpRuntimeSession-->>OmpAgentSession: "[{id: child-1, status: running}]"
        OmpAgentSession->>SubagentIndex: reconcileSnapshots(runtimeSession, snapshots)
        SubagentIndex-->>OmpAgentSession: [upsert events]
        OmpAgentSession->>SubagentIndex: hasRunning(runtimeSession)
        SubagentIndex-->>OmpAgentSession: true - keep waiting
    end

    Note over OmpRuntimeSession: Child task completes
    OmpRuntimeSession-->>OmpAgentSession: "subagent_lifecycle {status: completed}"
    OmpAgentSession->>SubagentIndex: handleLifecycle(...)

    loop Next idle poll
        OmpAgentSession->>OmpRuntimeSession: getSubagents()
        OmpRuntimeSession-->>OmpAgentSession: "[{id: child-1, status: completed}]"
        OmpAgentSession->>SubagentIndex: reconcileSnapshots(...)
        OmpAgentSession->>SubagentIndex: hasRunning(runtimeSession)
        SubagentIndex-->>OmpAgentSession: false - complete turn
        OmpAgentSession->>Paseo: turn_completed
    end
Loading

Reviews (6): Last reviewed commit: "fix(omp): keep parent non-idle while int..." | Re-trigger Greptile

Comment on lines +104 to +124
reconcileSnapshots(
parent: object,
snapshots: ReadonlyArray<{
id: string;
agent: string;
description?: string;
status: "pending" | "running" | "completed" | "failed" | "aborted";
parentToolCallId?: string;
}>,
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
for (const snapshot of snapshots) {
const state = this.stateFor(parent, snapshot.id, snapshot.agent);
state.title = snapshot.agent || state.title;
state.description = snapshot.description ?? state.description;
state.toolCallId = snapshot.parentToolCallId ?? state.toolCallId;
state.status = mapSnapshotStatus(snapshot.status);
events.push(this.upsert(snapshot.id, state.status, state));
}
return events;
}

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.

P1 Terminal state can regress during cancellation

terminalizeRunning sets in-flight children to "canceled" and emits those events. If completeTurnAfterProviderIdle is still polling on the same tick, the subsequent reconcileSnapshots call can receive "running" from OMP (OMP hasn't processed the abort yet) and unconditionally overwrite the now-"canceled" index entry back to "running". The loop exits correctly via the activeTurnId guard, but the contradiction produces a spurious "running" upsert event after the "canceled" upsert — visible to any downstream consumer ordering those events. A forward-progress guard (only allow status changes in the terminal direction) would prevent the reversal.

Comment on lines +113 to +123
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
for (const snapshot of snapshots) {
const state = this.stateFor(parent, snapshot.id, snapshot.agent);
state.title = snapshot.agent || state.title;
state.description = snapshot.description ?? state.description;
state.toolCallId = snapshot.parentToolCallId ?? state.toolCallId;
state.status = mapSnapshotStatus(snapshot.status);
events.push(this.upsert(snapshot.id, state.status, state));
}
return events;

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.

P2 Redundant upsert events emitted on every idle poll

reconcileSnapshots unconditionally pushes an upsert event for every snapshot on every call, regardless of whether status or metadata changed. Because hasRunningOmpSubagents calls this on every completeTurnAfterProviderIdle iteration, a 60-second fan-out with 5 children at a 1-second poll rate generates ~300 extra upsert events, all carrying identical "running" status. Adding a guard that only emits when the recorded state actually changes would limit events to genuine transitions.

@jasonhnd
jasonhnd force-pushed the fix/2232-omp-idle-while-task-subagents branch 3 times, most recently from 06eae4e to f39f809 Compare July 24, 2026 01:04
OMP can end the parent model loop (agent_end + isStreaming=false) while
task children still write under the session stem directory. Gate turn
completion on running subagents, reconcile via get_subagents, and recover
orphan child history when the parent .jsonl is missing.

Fixes getpaseo#2232
@ABorakati

Copy link
Copy Markdown
Contributor

Reviewed this against a live OMP v17 session while chasing the same issue, and found two gaps. The idle-gate half of #2232 works — parent stays non-idle. The subagent-card half does not, and I think the test in this PR hides it.

1. The card still terminalizes on dispatch

handleToolExecutionEnd deletes the call from activeToolCalls, then decides whether to hold the card open by asking subagentCardTracker.hasRunningItems. That tracker is empty at that moment, and necessarily so.

OMP's task defaults to async.enabled=true — RPC mode resets async.* to built-in defaults, so a Paseo-hosted session always gets it — which makes tool_execution_end a dispatch acknowledgement, not a result. Measured on a real RPC session at subscription level events (the level the adapter subscribes at), batch of 2 detached spawns:

frame t
tool_execution_start +13109ms
tool_execution_end +13591ms
first subagent_lifecycle: started +15927ms (2336ms after the result)
children finish ~28.4s later

At subscription level progress the first frame was +940ms after the tool end. No child frame ever precedes tool_execution_end for a detached spawn.

So the deferral only engages in the ordering that never occurs. The non-deferred branch runs, the card flips to completed with log text literally "Spawned N background agents", and every later child frame then fails the payload.parentToolCallId && this.activeToolCalls.has(...) guard and is dropped — so the card never gains child rows either. settleDeferredTaskCall is only called from inside that same guard, so once the non-deferred branch runs the card is unrecoverable.

Parent idle is unaffected, since subagentIndex.handleLifecycle sits outside that guard. This is the card channel only.

The regression test encodes the wrong order. agent.test.ts"holds a task tool call open until its OMP children finish" emits subagent_lifecycle: started before tool_execution_end, which is the reverse of the wire order above. It passes against the broken path. Worth flipping regardless of how the fix lands — a fixture in wire order fails today.

2. reconcileSnapshots can confirm "running" but never clear

get_subagents returns only still-running children. A finished child disappears from the reply rather than reporting a terminal status — verified directly: once the child completed, the reply was {"subagents":[]}, never a terminal entry.

reconcileSnapshots iterates only the ids present in the reply, so an id that disappears is never visited and never cleared. Combined with the narrow-only if (state.status === "running") guard, the merge is one-directional by construction. The header comment says it covers "missed event frames", but only in one direction: if the terminal subagent_lifecycle/subagent_progress frame is dropped, hasRunning stays true forever and completeTurnAfterProviderIdle loops forever — reintroducing the #2260/#2281 stuck-running class this repo just got out of.

Fix shape: treat absence from a successful reply as terminal for ids the index holds as running, gated on the child having been listed by some earlier reply — otherwise a child whose lifecycle frame beat its first snapshot gets terminalized the moment it starts.

Related, smaller: statesByParent is keyed on the runtime session with no turn dimension and is cleared only by clear()/terminalizeRunning, so a child outliving its turn holds every later turn non-idle.

Patch

I have both fixed, with tests that are red before / green after:

  • Defer every non-error task call unconditionally and settle off OmpSubagentIndex instead of the card tracker — the index is fed outside the activeToolCalls guard, so it's the only channel that sees late frames. Settling requires both a linked child and none running (an index that knows nothing yet must not read as done), with completeTurn force-settling as a backstop for a task that never produced a child. Keeping the id in activeToolCalls is what re-opens the card channel.
  • Sweep absent-but-previously-listed ids in reconcileSnapshots, gated on a seenInSnapshot flag.
  • Reorder the fixture to wire order; add coverage for late progress, the absence sweep, and the never-listed child.

Verification: stashing only the source files reproduces the bug exactly —

FAIL > holds a task tool call open when its OMP children appear after the result
FAIL > routes late child progress into the deferred task card
  AssertionError: expected 'completed' to be 'running'

npx vitest run packages/server/src/server/agent/providers/omp/ → 19 files, 122 tests pass. Typecheck and lint clean. Also dogfooded in the desktop dev app against a locally built daemon: card now stays running with live child rows for the whole fan-out, and card + spinner resolve together when the last child finishes.

One caveat on applying it: my commit sits on top of a local follow-up commit of ours (deferral scaffolding, ensureLiveTurnStarted, harness helpers) rather than directly on this PR's head, so it won't cherry-pick cleanly onto fix/2232-omp-idle-while-task-subagents as-is. Happy to rebase it onto your branch and open it as a stacked PR, or hand you the hunks to fold in — whichever you prefer, @jasonhnd. The two behavioural changes are small; most of the diff is tests.

One open question worth a maintainer call

detached is parsed in three schemas (OmpSubagentLifecyclePayloadSchema, OmpSubagentProgressPayloadSchema, OmpSubagentSnapshotSchema) and read nowhere. It's the only signal separating "child belongs to this turn" from "child intentionally outlives it", and the direction isn't obvious:

I deliberately left that alone rather than pick semantics unilaterally. Worth deciding before more logic accretes on the gate.

@ABorakati

ABorakati commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts
index df838cfdb..a396df940 100644
--- a/packages/server/src/server/agent/providers/omp/agent.ts
+++ b/packages/server/src/server/agent/providers/omp/agent.ts
@@ -1664,11 +1664,11 @@ export class OmpAgentSession implements AgentSession {
         this.subagentCardTracker.handleLifecycle(payload, (toolCallId) =>
           this.emitActiveToolCall(toolCallId),
         );
-        this.settleDeferredTaskCall(payload.parentToolCallId);
       }
       for (const mapped of this.subagentIndex.handleLifecycle(this.runtimeSession, payload)) {
         this.emit(mapped);
       }
+      this.settleDeferredTaskCalls();
       return true;
     }
     if (event.type === "subagent_progress") {
@@ -1677,11 +1677,11 @@ export class OmpAgentSession implements AgentSession {
         this.subagentCardTracker.handleProgress(payload, (toolCallId) =>
           this.emitActiveToolCall(toolCallId),
         );
-        this.settleDeferredTaskCall(payload.parentToolCallId);
       }
       for (const mapped of this.subagentIndex.handleProgress(this.runtimeSession, payload)) {
         this.emit(mapped);
       }
+      this.settleDeferredTaskCalls();
       return true;
     }
     if (event.type === "subagent_event") {
@@ -1940,21 +1940,25 @@ export class OmpAgentSession implements AgentSession {
     const result = parseToolResult(event.result);
     const error = event.isError ? event.result : null;
     const status = event.isError ? "failed" : "completed";
+    if (event.toolName === "task" && !event.isError) {
+      // OMP's `task` defaults to `async.enabled=true` (RPC mode resets it to the
+      // built-in default), so this result means "the children were dispatched",
+      // not "the children finished" — and the first child frame lands *after* it
+      // (measured ~2.3s at subscription level "events"). Gating the deferral on
+      // already-observed children therefore never fires for the case it exists
+      // for. Defer unconditionally and let the subagent index — which receives
+      // late frames outside the `activeToolCalls` guard — decide when the call
+      // is really done. Re-adding the id to `activeToolCalls` is what re-opens
+      // the card channel so those frames still reach the tracker.
+      this.deferredTaskCompletions.set(event.toolCallId, { toolCall, result, error, status });
+      this.activeToolCalls.set(event.toolCallId, toolCall);
+      this.emitToolCallEvent(event.toolCallId, toolCall, "running", result, null);
+      this.settleDeferredTaskCall(event.toolCallId);
+      return;
+    }
+    this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error);
     if (event.toolName === "task") {
-      // OMP's batched `task` resolves once the children are dispatched, so this
-      // result is not their completion. Hold the Paseo tool call open — and keep
-      // it in `activeToolCalls` so later child frames still route to its card —
-      // until every child reports a terminal status.
-      if (this.subagentCardTracker.hasRunningItems(event.toolCallId)) {
-        this.deferredTaskCompletions.set(event.toolCallId, { toolCall, result, error, status });
-        this.activeToolCalls.set(event.toolCallId, toolCall);
-        this.emitToolCallEvent(event.toolCallId, toolCall, "running", result, null);
-        return;
-      }
-      this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error);
       this.subagentCardTracker.delete(event.toolCallId);
-    } else {
-      this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error);
     }
     if (event.toolName === "todo") {
       const item = mapOmpTodoToolResult(result);
@@ -1971,9 +1975,20 @@ export class OmpAgentSession implements AgentSession {
    * finished. Emits before dropping the card so the final tool call still
    * carries the accumulated child log.
    */
-  private settleDeferredTaskCall(toolCallId: string): void {
+  private settleDeferredTaskCall(toolCallId: string, force = false): void {
     const deferred = this.deferredTaskCompletions.get(toolCallId);
-    if (!deferred || this.subagentCardTracker.hasRunningItems(toolCallId)) {
+    if (!deferred) {
+      return;
+    }
+    // Before the first child frame arrives the index knows nothing about this
+    // call, which must not read as "done" — require a linked child *and* no
+    // running ones. `force` is the turn-end backstop for a `task` that never
+    // produced a child at all.
+    if (
+      !force &&
+      (!this.subagentIndex.knowsToolCall(this.runtimeSession, toolCallId) ||
+        this.subagentIndex.hasRunningForToolCall(this.runtimeSession, toolCallId))
+    ) {
       return;
     }
     this.deferredTaskCompletions.delete(toolCallId);
@@ -1988,6 +2003,12 @@ export class OmpAgentSession implements AgentSession {
     this.subagentCardTracker.delete(toolCallId);
   }
 
+  private settleDeferredTaskCalls(force = false): void {
+    for (const toolCallId of this.deferredTaskCompletions.keys()) {
+      this.settleDeferredTaskCall(toolCallId, force);
+    }
+  }
+
   /**
    * OMP can resume model work after Paseo already completed a turn — a parked
    * hub wait releasing, or autonomous follow-up work. Without a `turn_start` for
@@ -2214,6 +2235,10 @@ export class OmpAgentSession implements AgentSession {
   }
 
   private completeTurn(turnId: string | undefined, messages: OmpAgentMessage[]): void {
+    // The idle gate already waited for every OMP child to leave `running`, so a
+    // still-deferred `task` here produced no child at all (an instant no-op or a
+    // spawn OMP never reported). Settle it rather than leaking an open card.
+    this.settleDeferredTaskCalls(true);
     this.activeTurnId = null;
     this.activeClientMessageId = null;
     this.activeAssistantMessageId = null;
diff --git a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts
index d742c1067..7034c4c50 100644
--- a/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts
+++ b/packages/server/src/server/agent/providers/omp/subagent-card-tracker.ts
@@ -159,25 +159,6 @@ export class OmpSubagentCardTracker {
     return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
   }
 
-  /**
-   * True while any tracked child of this `task` call is still in-flight. OMP's
-   * batched `task` returns once children are dispatched, so the tool result is
-   * not a completion signal — the card has to stay open until this goes false.
-   */
-  hasRunningItems(toolCallId: string): boolean {
-    const state = this.states.get(toolCallId);
-    if (!state) {
-      return false;
-    }
-    for (const item of state.items.values()) {
-      const status = item.status ?? "running";
-      if (status === "pending" || status === "running") {
-        return true;
-      }
-    }
-    return false;
-  }
-
   flush(toolCallId: string): void {
     const state = this.states.get(toolCallId);
     if (!state || !state.dirty) {
diff --git a/packages/server/src/server/agent/providers/omp/subagent-index.ts b/packages/server/src/server/agent/providers/omp/subagent-index.ts
index 5642e5451..7f9dbd386 100644
--- a/packages/server/src/server/agent/providers/omp/subagent-index.ts
+++ b/packages/server/src/server/agent/providers/omp/subagent-index.ts
@@ -17,6 +17,8 @@ interface OmpSubagentState {
   status: "running" | "completed" | "failed" | "canceled";
   /** Serialized last upsert forwarded from a `get_subagents` poll, for dedupe. */
   lastReconciled: string | null;
+  /** Whether a successful `get_subagents` reply has ever listed this child. */
+  seenInSnapshot: boolean;
   mapper: OmpHistoryMapper;
 }
 
@@ -99,6 +101,43 @@ export class OmpSubagentIndex {
     return false;
   }
 
+  /**
+   * True while a child of this specific `task` tool call is still in-flight.
+   * A child whose parent link has not arrived yet (`toolCallId` still null —
+   * `handleEvent` can learn a child before any frame names its parent) counts
+   * as in-flight for every open call, so a partially populated index can never
+   * settle a card early.
+   */
+  hasRunningForToolCall(parent: object, toolCallId: string): boolean {
+    const states = this.statesByParent.get(parent);
+    if (!states) {
+      return false;
+    }
+    for (const state of states.values()) {
+      if (state.status !== "running") {
+        continue;
+      }
+      if (state.toolCallId === null || state.toolCallId === toolCallId) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** True once any child has been linked to this `task` tool call. */
+  knowsToolCall(parent: object, toolCallId: string): boolean {
+    const states = this.statesByParent.get(parent);
+    if (!states) {
+      return false;
+    }
+    for (const state of states.values()) {
+      if (state.toolCallId === toolCallId) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   /**
    * Merge a `get_subagents` snapshot into local state so missed event frames
    * do not leave idle detection blind.
@@ -116,6 +155,7 @@ export class OmpSubagentIndex {
     const events: AgentStreamEvent[] = [];
     for (const snapshot of snapshots) {
       const state = this.stateFor(parent, snapshot.id, snapshot.agent);
+      state.seenInSnapshot = true;
       state.title = snapshot.agent || state.title;
       state.description = snapshot.description ?? state.description;
       state.toolCallId = snapshot.parentToolCallId ?? state.toolCallId;
@@ -136,6 +176,28 @@ export class OmpSubagentIndex {
       state.lastReconciled = descriptor;
       events.push(event);
     }
+    // OMP's `get_subagents` reply lists only still-running children: a finished
+    // child disappears rather than reporting a terminal status, so absence is
+    // the only terminal signal this channel gives. Without this sweep the merge
+    // is one-directional — it can confirm "still running" but never clear it,
+    // and a dropped terminal frame wedges the idle gate open forever (the
+    // #2260/#2281 stuck-running class). Only sweep ids a snapshot previously
+    // listed, so a child whose lifecycle frame beat its first snapshot is not
+    // terminalized on arrival.
+    const states = this.statesByParent.get(parent);
+    if (!states) {
+      return events;
+    }
+    const present = new Set(snapshots.map((snapshot) => snapshot.id));
+    for (const [id, state] of states) {
+      if (state.status !== "running" || !state.seenInSnapshot || present.has(id)) {
+        continue;
+      }
+      state.status = "completed";
+      const event = this.upsert(id, state.status, state);
+      state.lastReconciled = JSON.stringify(event.event);
+      events.push(event);
+    }
     return events;
   }
 
@@ -154,6 +216,7 @@ export class OmpSubagentIndex {
       toolCallId: null,
       status: "running",
       lastReconciled: null,
+      seenInSnapshot: false,
       mapper: new OmpHistoryMapper("omp", [], OMP_HISTORY_MAPPER_HOOKS),
     };
     states.set(id, state);

The two behavioural changes are the task branch in handleToolExecutionEnd and the sweep at the end of reconcileSnapshots; everything else is the plumbing those two need (knowsToolCall/hasRunningForToolCall, the seenInSnapshot flag, moving settlement outside the activeToolCalls guard, and the completeTurn backstop). hasRunningItems is deleted because those two call sites were its only callers.

@jasonhnd

Copy link
Copy Markdown
Contributor Author

Replaced by #3371.

This PR had the right idle-gate idea, but review found two defects that made it unsafe to land: the card tests used the reverse of the OMP v17 wire order, and snapshot reconcile never treated a disappeared get_subagents id as terminal (risk of a stuck-running parent).

#3371 rewrites both halves on current main: idle gate + two-way snapshot merge, and deferred task cards with true wire-order tests. Please use that PR instead of this one.

@jasonhnd jasonhnd closed this Aug 14, 2026
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.

OMP parent shows idle/stopped while internal task subagents are still active; nativeHandle points at missing .jsonl

2 participants