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
This commit is contained in:
roboomp
2026-08-13 19:27:41 +00:00
committed by can1357
parent e1d02c3b58
commit 6fc50c3e41
4 changed files with 80 additions and 11 deletions
+4
View File
@@ -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
@@ -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<LaunchCompletionEntry>(LAUNCH_COMPLETION_MESSAGE_TYPE, {
+2 -1
View File
@@ -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<DriveOutcome> {
using _keepalive = new EventLoopKeepalive();
const abortSignal = monitor.abortSignal;
let exitCode = 0;
let error: string | undefined;
@@ -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);
});
});