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:
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user