From 6fc50c3e418eabbebc9e9abeb07d0f0ddb4b1f56 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 13 Aug 2026 19:27:41 +0000 Subject: [PATCH] fix(coding-agent): prevented post-yield TUI stalls Kept the Bun event loop live across subagent yield drains and delayed parent result flushes. Added a timer-lifecycle regression for the idle flush. Fixes #8462 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/session/agent-session.ts | 32 ++++++++---- packages/coding-agent/src/task/executor.ts | 3 +- .../test/agent-session-async-delivery.test.ts | 52 ++++++++++++++++++- 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d0b7ec695..bbfe7adcb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the parent TUI stalling after a subagent submits its result until terminal focus or resize wakes the event loop ([#8462](https://github.com/can1357/oh-my-pi/issues/8462)). + ## [17.3.1] - 2026-08-13 ### Fixed diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f83ca3373..831aa5aa7 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -37,6 +37,7 @@ import { type AsideMessage, type BeforeToolCallContext, type BeforeToolCallResult, + EventLoopKeepalive, resolveTelemetry, type StreamFn, TERMINAL_TOOL_RESULT_ABORT_REASON, @@ -1213,15 +1214,28 @@ export class AgentSession { } }, scheduleIdleFlush: run => { - this.#schedulePostPromptTask( - async () => { - await run(); - }, - { - delayMs: 1, - onSkip: () => this.yieldQueue.cancelIdleFlushScheduling(), - }, - ); + const keepalive = new EventLoopKeepalive(); + try { + this.#schedulePostPromptTask( + async () => { + try { + await run(); + } finally { + keepalive[Symbol.dispose](); + } + }, + { + delayMs: 1, + onSkip: () => { + keepalive[Symbol.dispose](); + this.yieldQueue.cancelIdleFlushScheduling(); + }, + }, + ); + } catch (error) { + keepalive[Symbol.dispose](); + throw error; + } }, }); this.yieldQueue.register(LAUNCH_COMPLETION_MESSAGE_TYPE, { diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 1aa7ab80d..0667cffe5 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -6,7 +6,7 @@ import path from "node:path"; import type { AgentEvent, AgentIdentity, AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core"; -import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; +import { EventLoopKeepalive, recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core"; import type { Api, Model, ServiceTierByFamily, Usage } from "@oh-my-pi/pi-ai"; import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils"; import { ASYNC_JOB_MANAGER_SHUTDOWN_REASON, AsyncJobManager } from "../async"; @@ -1868,6 +1868,7 @@ async function driveSessionToYield( monitor: SubagentRunMonitor, task: string, ): Promise { + using _keepalive = new EventLoopKeepalive(); const abortSignal = monitor.abortSignal; let exitCode = 0; let error: string | undefined; diff --git a/packages/coding-agent/test/agent-session-async-delivery.test.ts b/packages/coding-agent/test/agent-session-async-delivery.test.ts index c0c2d9bd7..292a25fa1 100644 --- a/packages/coding-agent/test/agent-session-async-delivery.test.ts +++ b/packages/coding-agent/test/agent-session-async-delivery.test.ts @@ -5,7 +5,7 @@ * THAT session, and `hasPendingAsyncWork()` / `settleAsyncWork()` define the * run quiescence the task executor's barrier is built on. */ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -24,6 +24,7 @@ describe("AgentSession owner-routed async delivery", () => { const authStorages: AuthStorage[] = []; afterEach(async () => { + vi.useRealTimers(); if (session) { await session.dispose(); } @@ -343,4 +344,53 @@ describe("AgentSession owner-routed async delivery", () => { await session.settleAsyncWork(); expect(session.hasPendingAsyncWork()).toBe(false); }); + + it("keeps the event loop live until a delayed idle flush runs", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; + const mock = createMockModel({ handler: () => ({ content: ["Done"] }) }); + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { model, systemPrompt: ["Test"], tools: [] }, + convertToLlm, + streamFn: mock.stream, + }); + const authStorage = await AuthStorage.create(":memory:"); + authStorages.push(authStorage); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + const manager = new AsyncJobManager({}); + AsyncJobManager.setInstance(manager); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated(), + modelRegistry: new ModelRegistry(authStorage), + agentId: "SubAgent", + asyncJobManager: manager, + }); + + let flushed = false; + session.yieldQueue.register("keepalive-probe", { + isStale: () => { + flushed = true; + return true; + }, + build: () => null, + }); + vi.useFakeTimers(); + const baselineTimers = vi.getTimerCount(); + session.yieldQueue.enqueue("keepalive-probe", {}); + + // The 1ms flush timer and a keepalive must both remain armed until the + // flush runs. Without the keepalive, Bun can park here until unrelated + // TTY I/O wakes the loop. + expect(vi.getTimerCount()).toBeGreaterThanOrEqual(baselineTimers + 2); + + vi.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(flushed).toBe(true); + expect(vi.getTimerCount()).toBe(baselineTimers + 1); + }); });