Merge PR #6119: feat: lift subagent async/auto-background limits via owner-routed delivery and quiescence (@korri123)

# Conflicts:
#	packages/coding-agent/src/task/executor.ts
This commit is contained in:
can1357
2026-07-23 17:52:52 +02:00
18 changed files with 1124 additions and 228 deletions
+3
View File
@@ -7,6 +7,9 @@
### Fixed
- Improved resilience against transient stream JSON parse failures by recovering completed tool calls while safely preventing incomplete, unknown, refused, or sensitive calls from executing.
### Changed
- Queued steering no longer hard-aborts non-interruptible tools (e.g. `bash`): it aborts interruptible waits only and raises a cooperative steering signal (`ToolCallContext.steeringSignal`) that long-running tools may observe to finish early or background themselves. The mid-batch steering/IRC watch now runs for every tool batch instead of only batches containing an interruptible tool.
## [17.0.5] - 2026-07-18
+33 -20
View File
@@ -1817,13 +1817,18 @@ async function executeToolCalls(
const shouldInterruptImmediately = interruptMode !== "wait";
const steeringAbortController = new AbortController();
const ircAbortController = new AbortController();
// Interruptible tools observe steering + external + IRC aborts; every other
// tool only sees steering + external, so an IRC-only interrupt never kills a
// partially side-effecting foreground tool (e.g. `bash`) running alongside a
// pure wait (e.g. `job` poll).
const nonInterruptibleSignal: AbortSignal = signal
? AbortSignal.any([signal, steeringAbortController.signal])
: steeringAbortController.signal;
// Cooperative channel: aborted when queued steering (or an interrupting
// peer IRC) is detected mid-batch. Tools receive it via tool context
// (`ctx.steeringSignal`) and MAY react — e.g. an auto-backgroundable bash
// backgrounds itself so the message injects promptly — but it never kills
// anything; ignoring it is always safe.
const steeringSoftController = new AbortController();
// Interruptible tools (pure waits: hub wait, vibe) observe steering +
// external + IRC aborts. Every other tool sees ONLY the external signal:
// neither queued steering nor a peer IRC ever hard-kills a partially
// side-effecting foreground tool (e.g. `bash`) — those get the cooperative
// steeringSignal above, and the message injects at the next boundary.
const nonInterruptibleSignal: AbortSignal = signal ?? new AbortController().signal;
const interruptibleSignal: AbortSignal = signal
? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
: AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
@@ -1889,13 +1894,16 @@ async function executeToolCalls(
}
}
if (steeringQueued) {
// Queued steering upgrades an in-flight IRC interrupt: it aborts the
// shared signal so foreground tools stop as they do for a user Esc.
// Queued steering hard-aborts only interruptible waits and raises the
// cooperative soft signal for everything else: the boundary dequeue
// below injects the message as soon as running tools finish (or
// background themselves), and not-yet-started tools are skipped.
// Idempotent — a second steer poll after the abort is a no-op.
if (!steeringAbortController.signal.aborted) {
interruptState.triggered = true;
interruptState.source = steeringSource ?? "unknown";
steeringAbortController.abort();
steeringSoftController.abort();
}
return;
}
@@ -1903,11 +1911,13 @@ async function executeToolCalls(
// must not re-abort, and (unlike steering above) never re-consume a queue.
if (interruptState.triggered) return;
if (hasIrcInterrupts && (await hasIrcInterrupts())) {
// Peer IRC only aborts interruptible waits: a foreground bash / write
// mid-execution keeps running so we never leave partial side effects.
// Peer IRC hard-aborts interruptible waits only; foreground tools keep
// running (no partial side effects) but get the cooperative soft
// signal so backgroundable work can step aside for the peer message.
interruptState.triggered = true;
interruptState.source = "irc";
ircAbortController.abort();
steeringSoftController.abort();
}
};
@@ -2090,12 +2100,17 @@ async function executeToolCalls(
: effectiveArgs;
record.args = executionArgs;
// The cooperative steering signal rides the loop-owned
// ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
// AgentToolContext itself is app-built via declaration merging, so
// the loop cannot construct or extend one structurally.
const toolContext = getToolContext
? getToolContext({
batchId,
index,
total: toolCalls.length,
toolCalls: toolCallInfos,
steeringSignal: steeringSoftController.signal,
})
: undefined;
const rawResult = await tool.execute(
@@ -2237,16 +2252,14 @@ async function executeToolCalls(
}
}
// While an interruptible tool call is in flight (e.g. a `hub` wait blocking
// on external work), queued steering or interrupting IRC would otherwise
// wait out the tool's own window. Poll only non-consuming queues and abort
// the shared tool signal so the boundary dequeue below injects the message
// promptly. Gated on immediate-interrupt mode + an interruptible call;
// checkSteering is idempotent (no-op once triggered).
// While tool calls are in flight, queued steering or interrupting IRC would
// otherwise wait out the tools' own window. Poll only non-consuming queues:
// detection hard-aborts interruptible waits, soft-signals cooperative tools
// (auto-background bash), and skips not-yet-started tools, so the boundary
// dequeue below injects the message promptly. Gated on immediate-interrupt
// mode; checkSteering is idempotent (no-op once triggered).
const watchSteeringWhileRunning =
shouldInterruptImmediately &&
(hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined) &&
records.some(record => record.interruptible);
shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined);
const steeringWatchTimer = watchSteeringWhileRunning
? setInterval(() => void checkSteering(), STEERING_INTERRUPT_POLL_MS)
: undefined;
+9
View File
@@ -458,6 +458,15 @@ export interface ToolCallContext {
index: number;
total: number;
toolCalls: Array<{ id: string; name: string }>;
/**
* Cooperative steering signal: aborted when a queued user/steering message
* (or an interrupting peer IRC) is detected while this tool batch runs.
* Unlike the hard abort signal it NEVER kills the tool — long-running
* tools MAY observe it (via `ctx.toolCall.steeringSignal`) to finish early
* or background themselves so the message injects promptly; ignoring it is
* always safe (the message injects at the next batch boundary).
*/
steeringSignal?: AbortSignal;
}
/** A single tool-call content block emitted by an assistant message. */
+4 -1
View File
@@ -558,7 +558,10 @@ describe("skipped tools without spans", () => {
description: "slow",
parameters: z.object({ value: z.string().optional() }),
intent: "omit",
// concurrency: shared (default) — both run in parallel; we abort via steering.
// concurrency: shared (default) — both run in parallel. Interruptible:
// queued steering hard-aborts only interruptible waits; non-interruptible
// tools now run to completion and the steer injects at the boundary.
interruptible: true,
execute: async (_id, _args, signal) => {
await new Promise<void>((resolve, reject) => {
if (!signal) {
+13
View File
@@ -241,6 +241,19 @@
- Fixed custom `anthropic-messages` OAuth providers being unable to opt into configured Claude Code fingerprint header overrides. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888))
- Fixed authoritative providers (e.g. `openai-codex`) keeping unsupported bundled models selectable when a fresh model cache and an expired OAuth token coincided: built-in discovery now forces the OAuth refresh so the provider's model manager is constructed and prunes stale bundled entries (e.g. `gpt-5.4-nano`) instead of waiting out the cache TTL. ([#5364](https://github.com/can1357/oh-my-pi/issues/5364))
### Added
- Added owner-routed async job delivery: every session (including subagents) registers its own delivery sink, so background bash/task results are injected into the owning agent's run instead of the first top-level session; deliveries whose owner is gone are dead-lettered with the result retained on the job row.
- Added `AsyncJobManager.registerDeliverySink` and `AsyncJobManager.waitForOwnerJobs` (with an `excludeSuppressed` filter for quiescence checks).
- Added background-on-steer for auto-backgrounded bash: an incoming user/peer message backgrounds the running command (instead of waiting it out or killing it) so the message is handled promptly.
### Changed
- Subagents now inherit `async.enabled` and `bash.autoBackground.enabled` from the parent instead of having both force-disabled. Subagent runs complete only after their own background jobs settle and the agent submits a `yield` that postdates every delivered result: a terminal yield with jobs still pending parks the run (recoverable turn stop) instead of completing it, async results are folded in as follow-up turns (with a one-time notice offering `hub` wait/cancel), a result delivered after a yield supersedes that yield and re-runs the yield reminder ladder, and a run that never refreshes a superseded yield fails with the stale payload preserved as salvage. Teardown cancels and awaits surviving jobs before isolation worktree capture and cleanup.
### Fixed
- Fixed MCP tools repeatedly unmounting and remounting mid-session when server names have overlapping sanitized prefixes (e.g. `atlassian` alongside an imported `atlassian:atlassian`), and stale tools remaining registered after disconnecting a server with special characters in its name.
## [17.0.5] - 2026-07-18
### Added
+90 -2
View File
@@ -60,8 +60,19 @@ export interface AsyncJob {
queued?: boolean;
}
/** Delivery callback for a settled job's result text. */
export type AsyncJobDeliverySink = (jobId: string, text: string, job?: AsyncJob) => void | Promise<void>;
export interface AsyncJobManagerOptions {
onJobComplete: (jobId: string, text: string, job?: AsyncJob) => void | Promise<void>;
/**
* Delivery sink for UNOWNED completions (jobs registered without an
* `ownerId`). Owned deliveries route exclusively through
* {@link AsyncJobManager.registerDeliverySink}; when the owner has no live
* sink they are dead-lettered (dropped with a warning; the job row keeps
* the result text until retention eviction) — never routed here, which
* would leak one agent's result into another session.
*/
onJobComplete?: AsyncJobDeliverySink;
maxRunningJobs?: number;
retentionMs?: number;
}
@@ -128,6 +139,7 @@ export class AsyncJobManager {
readonly #watchedJobs = new Set<string>();
readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
readonly #pollEscalation = new Map<string | undefined, PollEscalationState>();
readonly #deliverySinks = new Map<string, AsyncJobDeliverySink>();
readonly #onJobComplete: AsyncJobManagerOptions["onJobComplete"];
readonly #maxRunningJobs: number;
readonly #retentionMs: number;
@@ -409,6 +421,55 @@ export class AsyncJobManager {
await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
}
/**
* Route completions for jobs owned by `ownerId` to `sink`. Sessions register
* their own sink at construction and unregister on dispose. Owned deliveries
* with no live sink are dead-lettered — `onJobComplete` serves only unowned
* deliveries.
*
* Last registration wins for an owner id; the returned unregister clears the
* mapping only while it still points at `sink`, so a revived session's fresh
* registration survives its parked predecessor's late cleanup.
*/
registerDeliverySink(ownerId: string, sink: AsyncJobDeliverySink): () => void {
this.#deliverySinks.set(ownerId, sink);
return () => {
if (this.#deliverySinks.get(ownerId) === sink) this.#deliverySinks.delete(ownerId);
};
}
/**
* Wait until every job owned by `ownerId` has settled — its run promise
* resolved, which for cancelled jobs means the underlying process actually
* exited. Jobs registered while waiting (e.g. by a follow-up turn) are
* awaited too. Returns false when `timeoutMs` elapses first.
*
* `excludeSuppressed` skips jobs whose delivery is suppressed (acknowledged
* or `hub`-watched): those can never re-wake a run, so quiescence barriers
* pass it to share one contract with the pending-async-wake predicate.
* Teardown reaps omit it — worktree safety concerns every owner process.
*/
async waitForOwnerJobs(
ownerId: string,
options?: { timeoutMs?: number; excludeSuppressed?: boolean },
): Promise<boolean> {
const deadline =
options?.timeoutMs === undefined ? Number.POSITIVE_INFINITY : Date.now() + Math.max(0, options.timeoutMs);
const awaited = new Set<string>();
for (;;) {
const pending = this.#filterJobs(this.#jobs.values(), { ownerId }).filter(
job => !awaited.has(job.id) && (options?.excludeSuppressed !== true || !this.isDeliverySuppressed(job.id)),
);
if (pending.length === 0) return true;
for (const job of pending) awaited.add(job.id);
const settled = await this.#waitForDeliveryPromise(
Promise.all(pending.map(job => job.promise)).then(() => {}),
deadline,
);
if (!settled) return false;
}
}
async #waitForAllUntil(deadline: number): Promise<boolean> {
const promises = Array.from(this.#jobs.values()).map(job => job.promise);
if (promises.length === 0) return true;
@@ -489,6 +550,7 @@ export class AsyncJobManager {
this.#suppressedDeliveries.clear();
this.#watchedJobs.clear();
this.#pollEscalation.clear();
this.#deliverySinks.clear();
return jobsSettled && drained;
}
@@ -655,11 +717,37 @@ export class AsyncJobManager {
}
}
/**
* Resolve the sink for one delivery attempt: owned deliveries route ONLY to
* their owner's registered sink (a missing sink dead-letters — never the
* default, which would misroute a dead owner's result into another
* session); unowned deliveries use the constructor default. Resolved per
* attempt so a sink registered between retries (e.g. a revived session)
* picks up the retry.
*/
#resolveDeliverySink(ownerId: string | undefined): AsyncJobDeliverySink | undefined {
if (ownerId !== undefined) return this.#deliverySinks.get(ownerId);
return this.#onJobComplete;
}
#deliverDelivery(delivery: AsyncJobDelivery): Promise<void> {
const sink = this.#resolveDeliverySink(delivery.ownerId);
if (!sink) {
// Dead-letter: owned delivery with no live sink (session disposed or
// parked), or unowned delivery with no default sink. Drop it — the
// job row keeps its result/error text until retention eviction, so
// the outcome stays inspectable via job queries and agent:// reads.
logger.warn("Async job delivery dead-lettered: no delivery sink", {
jobId: delivery.jobId,
ownerId: delivery.ownerId,
});
delivery.promise = Promise.resolve();
return delivery.promise;
}
const promise = (async () => {
this.#inFlightDeliveries.push(delivery);
try {
await this.#onJobComplete(delivery.jobId, delivery.text, this.#jobs.get(delivery.jobId));
await sink(delivery.jobId, delivery.text, this.#jobs.get(delivery.jobId));
} catch (error) {
delivery.attempt += 1;
delivery.lastError = error instanceof Error ? error.message : String(error);
@@ -0,0 +1,6 @@
Your yield was recorded, but {{count}} background job{{#if multiple}}s{{/if}} you own {{#if multiple}}are{{else}}is{{/if}} still running: {{jobs}}.
This run completes only after these jobs settle AND you submit a fresh `yield` that accounts for their results. Job results arrive as follow-up messages; a result that arrives after your yield supersedes it — your current yield will NOT be accepted as the final report. Decide now:
- Need the results? Wait for them (`hub` op:"wait"), then submit a fresh `yield` that incorporates them.
- Job no longer needed? Cancel it (`hub` op:"cancel", ids:[...]) and re-yield.
- Otherwise stand by; when each result arrives, submit a fresh `yield` (repeat your report unchanged if the result does not affect it).
+5 -95
View File
@@ -32,7 +32,7 @@ import {
formatActiveRepoWatchdogPrompt,
formatAdvisorContextPrompt,
} from "./advisor";
import { type AsyncJob, AsyncJobManager } from "./async";
import { AsyncJobManager } from "./async";
import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller";
import { createAutoresearchExtension } from "./autoresearch";
import { loadCapability } from "./capability";
@@ -106,7 +106,6 @@ import {
import { MCP_CONNECTION_STATUS_EVENT_CHANNEL, type McpConnectionStatusEvent } from "./mcp/startup-events";
import { createSessionMemoryRuntimeContext, resolveMemoryBackend } from "./memory-backend";
import type { MnemopiSessionState } from "./mnemopi/state";
import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with { type: "text" };
import { AgentLifecycleManager } from "./registry/agent-lifecycle";
import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
@@ -201,60 +200,11 @@ import { EventBus } from "./utils/event-bus";
import { buildNamedToolChoice } from "./utils/tool-choice";
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
type AsyncResultEntry = {
jobId: string;
result: string;
job: AsyncJob | undefined;
durationMs: number | undefined;
};
type AsyncResultJobDetails = {
jobId: string;
type?: "bash" | "task";
label?: string;
durationMs?: number;
};
type AsyncResultDetails = {
jobs: AsyncResultJobDetails[];
};
type McpNotificationEntry = {
serverName: string;
uri: string;
};
function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessage<AsyncResultDetails> | null {
if (entries.length === 0) return null;
const jobs = entries.map(entry => ({
jobId: entry.jobId,
result: entry.result,
type: entry.job?.type,
label: entry.job?.label,
durationMs: entry.durationMs,
}));
const details: AsyncResultDetails = {
jobs: jobs.map(job => ({
jobId: job.jobId,
type: job.type,
label: job.label,
durationMs: job.durationMs,
})),
};
return {
role: "custom",
customType: "async-result",
content: prompt.render(asyncResultTemplate, {
multiple: jobs.length > 1,
jobs,
}),
display: true,
attribution: "agent",
details,
timestamp: Date.now(),
};
}
type LateDiagnosticsDetails = {
files: Array<{ path: string; summary: string; errored: boolean; messages: string[] }>;
};
@@ -1577,28 +1527,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const restrictToolNames = options.restrictToolNames === true;
const enableLsp = !restrictToolNames && (options.enableLsp ?? true);
const asyncMaxJobs = Math.min(100, Math.max(1, settings.get("async.maxJobs") ?? 100));
const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000;
const ASYNC_PREVIEW_MAX_CHARS = 4_000;
const formatAsyncResultForFollowUp = async (result: string): Promise<string> => {
if (result.length <= ASYNC_INLINE_RESULT_MAX_CHARS) {
return result;
}
const preview = `${result.slice(0, ASYNC_PREVIEW_MAX_CHARS)}\n\n[Output truncated. Showing first ${ASYNC_PREVIEW_MAX_CHARS.toLocaleString()} characters.]`;
try {
const { path: artifactPath, id: artifactId } = await sessionManager.allocateArtifactPath("async");
if (artifactPath && artifactId) {
await Bun.write(artifactPath, result);
return `${preview}\nFull output: artifact://${artifactId}`;
}
} catch (error) {
logger.warn("Failed to persist async follow-up artifact", {
error: error instanceof Error ? error.message : String(error),
});
}
return preview;
};
// Only the first top-level session in a process owns an AsyncJobManager.
// Subagents inherit the parent's manager via `AsyncJobManager.instance()`
// (set below), and any additional top-level session spun up in-process
@@ -1607,24 +1535,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// owning session's manager and break the `task`/`bash` async paths
// (issue #1923). The `instance()` guard means later sessions also skip
// constructing an orphaned manager that nothing would ever route to.
// Delivery is owner-routed: every AgentSession registers its own sink
// (see session/async-job-delivery.ts), so the manager takes no default
// onJobComplete here.
const asyncJobManager =
!options.parentTaskPrefix && !AsyncJobManager.instance()
? new AsyncJobManager({
maxRunningJobs: asyncMaxJobs,
onJobComplete: async (jobId, result, job) => {
if (!session || asyncJobManager!.isDeliverySuppressed(jobId)) return;
const formattedResult = await formatAsyncResultForFollowUp(result);
if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
session.yieldQueue.enqueue<AsyncResultEntry>("async-result", {
jobId,
result: formattedResult,
job,
durationMs,
});
},
})
? new AsyncJobManager({ maxRunningJobs: asyncMaxJobs })
: undefined;
const scopedAsyncJobManager = asyncJobManager ?? (options.parentTaskPrefix ? AsyncJobManager.instance() : undefined);
@@ -3131,12 +3047,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
titleSystemPrompt: options.titleSystemPrompt,
});
hasSession = true;
if (asyncJobManager) {
session.yieldQueue.register<AsyncResultEntry>("async-result", {
isStale: entry => asyncJobManager.isDeliverySuppressed(entry.jobId),
build: buildAsyncResultBatchMessage,
});
}
session.yieldQueue.register<McpNotificationEntry>("mcp-notification", {
build: buildMcpNotificationBatchMessage,
});
@@ -355,6 +355,12 @@ import { formatLocalCalendarDate } from "../utils/local-date";
import { generateSessionTitle } from "../utils/title-generator";
import { buildNamedToolChoice, isToolChoiceActive } from "../utils/tool-choice";
import type { VibeModeState } from "../vibe/state";
import {
ASYNC_INLINE_RESULT_MAX_CHARS,
ASYNC_PREVIEW_MAX_CHARS,
type AsyncResultEntry,
buildAsyncResultBatchMessage,
} from "./async-job-delivery";
import type { AuthStorage } from "./auth-storage";
import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
import {
@@ -2068,6 +2074,8 @@ export class AgentSession {
* undefined to avoid reading the primary's jobs.
*/
readonly #asyncJobManager: AsyncJobManager | undefined;
/** Clears this session's owner delivery sink registration; set when a manager + agent id exist. */
#unregisterAsyncDeliverySink: (() => void) | undefined;
#pendingPythonMessages: PythonExecutionMessage[] = [];
#activeEvalExecutions = new Set<Promise<unknown>>();
#evalExecutionDisposing = false;
@@ -2921,6 +2929,21 @@ export class AgentSession {
this.#providerSessionId = config.providerSessionId;
this.#inheritedProviderPromptCacheKey =
config.providerPromptCacheKeySource === "fork" ? this.agent.promptCacheKey : undefined;
// Owner-routed async delivery: completions for jobs this agent owns are
// injected into THIS session's run as async-result follow-ups. Without a
// registered sink the manager dead-letters owned deliveries, so this
// registration is what makes background jobs usable — for the main
// session and for subagents inheriting the process manager alike.
if (this.#asyncJobManager && this.#agentId) {
const manager = this.#asyncJobManager;
this.#unregisterAsyncDeliverySink = manager.registerDeliverySink(this.#agentId, (jobId, text, job) =>
this.#deliverAsyncJobResult(manager, jobId, text, job),
);
this.yieldQueue.register<AsyncResultEntry>("async-result", {
isStale: entry => manager.isDeliverySuppressed(entry.jobId),
build: buildAsyncResultBatchMessage,
});
}
this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
const event: AgentEvent = {
type: "message_update",
@@ -4147,6 +4170,67 @@ export class AgentSession {
);
}
/**
* Public view of the pending-async-wake state for run drivers: true while
* owner-scoped async work can still re-wake this session's run (a running
* background job with an unsuppressed delivery, or a queued / in-flight
* delivery). The task executor's quiescence barrier polls this to
* distinguish a scheduling pause from terminal completion.
*/
hasPendingAsyncWork(): boolean {
return this.#hasPendingAsyncWake();
}
/**
* Settle one generation of owner-scoped async work: wait for running owner
* jobs to finish, deliver their queued results (which enqueue async-result
* follow-ups on this session's yield queue), and wait for the injected
* follow-up turn(s) to go idle. Callers loop while
* {@link hasPendingAsyncWork} still holds — a follow-up turn may start new
* jobs.
*/
async settleAsyncWork(): Promise<void> {
const manager = this.#asyncJobManager;
if (!manager || !this.#agentId) return;
await manager.waitForOwnerJobs(this.#agentId, { excludeSuppressed: true });
await manager.drainDeliveries({ filter: { ownerId: this.#agentId } });
await this.waitForIdle();
}
/**
* Delivery sink for async jobs owned by this agent: format the result
* (spilling oversized output to an artifact) and enqueue it as an
* async-result follow-up on the yield queue. The queue's idle flush starts
* the follow-up turn when the session is between turns.
*/
async #deliverAsyncJobResult(manager: AsyncJobManager, jobId: string, text: string, job?: AsyncJob): Promise<void> {
if (this.#isDisposed) return;
if (manager.isDeliverySuppressed(jobId)) return;
const formatted = await this.#formatAsyncResultForFollowUp(text);
if (manager.isDeliverySuppressed(jobId)) return;
const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
this.yieldQueue.enqueue<AsyncResultEntry>("async-result", { jobId, result: formatted, job, durationMs });
}
async #formatAsyncResultForFollowUp(result: string): Promise<string> {
if (result.length <= ASYNC_INLINE_RESULT_MAX_CHARS) {
return result;
}
const preview = `${result.slice(0, ASYNC_PREVIEW_MAX_CHARS)}\n\n[Output truncated. Showing first ${ASYNC_PREVIEW_MAX_CHARS.toLocaleString()} characters.]`;
try {
const { path: artifactPath, id: artifactId } = await this.sessionManager.allocateArtifactPath("async");
if (artifactPath && artifactId) {
await Bun.write(artifactPath, result);
return `${preview}\nFull output: artifact://${artifactId}`;
}
} catch (error) {
logger.warn("Failed to persist async follow-up artifact", {
error: error instanceof Error ? error.message : String(error),
});
}
return preview;
}
// =========================================================================
// Event Subscription
// =========================================================================
@@ -6904,6 +6988,10 @@ export class AgentSession {
}
async #disposeOwnedAsyncJobs(): Promise<void> {
// Unregister before cancelling: a job completing during teardown must
// dead-letter rather than enqueue a follow-up into a disposing session.
this.#unregisterAsyncDeliverySink?.();
this.#unregisterAsyncDeliverySink = undefined;
this.#cancelOwnAsyncJobs();
const manager = this.#ownedAsyncJobManager;
if (!manager) return;
@@ -0,0 +1,74 @@
/**
* Owner-routed async job delivery: formatting and batch-message assembly for
* `async-result` follow-ups.
*
* Each {@link AgentSession} registers a delivery sink for its own agent id
* (`AsyncJobManager.registerDeliverySink`) and enqueues formatted entries on
* its yield queue; the queue's idle flush injects them as a follow-up turn.
* This replaces the old single hardwired `onJobComplete` closure that routed
* every completion — regardless of owner — into the first top-level session.
*/
import { prompt } from "@oh-my-pi/pi-utils";
import type { AsyncJob } from "../async";
import asyncResultTemplate from "../prompts/tools/async-result.md" with { type: "text" };
import type { CustomMessage } from "./messages";
/**
* `customType` of the injected async-result follow-up message. The task
* executor's run monitor matches on it to invalidate a previously recorded
* yield: a result injected after the yield supersedes that yield's payload.
*/
export const ASYNC_RESULT_MESSAGE_TYPE = "async-result";
/** Result payloads longer than this spill to an artifact with an inline preview. */
export const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000;
export const ASYNC_PREVIEW_MAX_CHARS = 4_000;
export interface AsyncResultEntry {
jobId: string;
result: string;
job: AsyncJob | undefined;
durationMs: number | undefined;
}
type AsyncResultJobDetails = {
jobId: string;
type?: "bash" | "task";
label?: string;
durationMs?: number;
};
export type AsyncResultDetails = {
jobs: AsyncResultJobDetails[];
};
export function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessage<AsyncResultDetails> | null {
if (entries.length === 0) return null;
const jobs = entries.map(entry => ({
jobId: entry.jobId,
result: entry.result,
type: entry.job?.type,
label: entry.job?.label,
durationMs: entry.durationMs,
}));
const details: AsyncResultDetails = {
jobs: jobs.map(job => ({
jobId: job.jobId,
type: job.type,
label: job.label,
durationMs: job.durationMs,
})),
};
return {
role: "custom",
customType: ASYNC_RESULT_MESSAGE_TYPE,
content: prompt.render(asyncResultTemplate, {
multiple: jobs.length > 1,
jobs,
}),
display: true,
attribution: "agent",
details,
timestamp: Date.now(),
};
}
+244 -53
View File
@@ -5,10 +5,11 @@
*/
import path from "node:path";
import type { AgentEvent, AgentIdentity, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core";
import type { AgentEvent, AgentIdentity, AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core";
import { 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 { AsyncJobManager } from "../async";
import type { Rule } from "../capability/rule";
import { ModelRegistry } from "../config/model-registry";
import {
@@ -32,6 +33,7 @@ import type { HindsightSessionState } from "../hindsight/state";
import type { LocalProtocolOptions } from "../internal-urls";
import type { MCPManager } from "../mcp/manager";
import type { MnemopiSessionState } from "../mnemopi/state";
import subagentAsyncPendingTemplate from "../prompts/system/subagent-async-pending.md" with { type: "text" };
import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
import { AgentLifecycleManager } from "../registry/agent-lifecycle";
@@ -39,6 +41,7 @@ import { AgentRegistry } from "../registry/agent-registry";
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "../sdk";
import type { AgentSession, AgentSessionEvent, Prewalk } from "../session/agent-session";
import type { ArtifactManager } from "../session/artifacts";
import { ASYNC_RESULT_MESSAGE_TYPE } from "../session/async-job-delivery";
import type { AuthStorage } from "../session/auth-storage";
import { SKILL_PROMPT_MESSAGE_TYPE, USER_INTERRUPT_LABEL } from "../session/messages";
import { SessionManager } from "../session/session-manager";
@@ -825,8 +828,11 @@ export function createSubagentSettings(
snapshot["tier.google"] = subagentTiers.google ?? "none";
return Settings.isolated({
...snapshot,
"async.enabled": false,
"bash.autoBackground.enabled": false,
// Async jobs and bash auto-backgrounding are inherited from the parent:
// background jobs are owner-routed to the subagent's own session, and
// the run driver's quiescence barrier + teardown reap guarantee no
// owner job outlives the run, so worktree capture/cleanup stays
// race-free (previously both were force-disabled here).
// Subagents run headless — there is no UI to confirm prompts against, so
// the parent task approval is the authorization boundary. Use yolo mode
@@ -884,6 +890,20 @@ interface SubagentRunMonitor {
budgetStopRequested(): boolean;
/** Resolves when the budget-stop session abort has settled (immediately when no stop fired). */
waitForBudgetStop(): Promise<void>;
/**
* True when a recorded yield was invalidated by a later async-result
* injection and no fresh yield has landed since: the yield payload
* predates background job outcomes the model was shown.
*/
yieldInvalidatedByAsync(): boolean;
/**
* True once a terminal yield with pending owner async work stopped the
* free-running turn (recoverable, like a budget stop) instead of
* terminating the run. Cleared when {@link waitForYieldTurnStop} settles.
*/
yieldTurnStopRequested(): boolean;
/** Resolves when the yield turn-stop session abort has settled (immediately when none fired). */
waitForYieldTurnStop(): Promise<void>;
/** The abort kind for this run, when an abort was requested. */
abortKind(): AbortReason | undefined;
terminalError(): string | undefined;
@@ -912,6 +932,15 @@ interface SubagentRunMonitor {
finish(): void;
}
/**
* True when `message` is the session-injected async-result follow-up
* ({@link ASYNC_RESULT_MESSAGE_TYPE}): the transcript-ordered signal that a
* background job outcome landed after whatever the model said before it.
*/
function isAsyncResultInjection(message: AgentMessage | undefined): boolean {
return message?.role === "custom" && message.customType === ASYNC_RESULT_MESSAGE_TYPE;
}
function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
const {
index,
@@ -963,6 +992,9 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
let activeSession: AgentSession | null = null;
let yieldCalled = false;
let yieldCallPending = false;
let yieldInvalidatedByAsync = false;
let yieldTurnStopRequested = false;
let yieldTurnStopPromise: Promise<void> | null = null;
// Accumulate usage incrementally from message_end events (no memory for streaming events)
const accumulatedUsage: Usage = {
@@ -1041,6 +1073,29 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
terminalError ??= message;
requestAbort("terminate");
};
// Yield turn-stop: a terminal yield recorded while owner async work is
// still pending is a scheduling pause, not run completion. Stop the
// free-running turn exactly like a budget stop (session abort, monitor
// signal untouched) so driveSessionToYield's quiescence barrier can settle
// the jobs, fold their results in, and demand a fresh yield. Terminating
// here instead would abort the run signal and make the barrier
// unreachable, completing the run with a payload that predates the job
// outcomes.
const requestYieldTurnStop = () => {
if (yieldTurnStopRequested || abortSent || resolved) return;
yieldTurnStopRequested = true;
const session = activeSession;
yieldTurnStopPromise = session
? session.abort().catch(error => {
logger.debug("Subagent yield turn-stop abort failed", {
error: error instanceof Error ? error.message : String(error),
});
})
: Promise.resolve();
};
/** Owner async work that can still re-wake the run (quiescence barrier predicate). */
const sessionHasPendingAsyncWork = (): boolean => activeSession?.hasPendingAsyncWork?.() ?? false;
// Handle abort signal
if (signal) {
@@ -1245,6 +1300,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
if (toolName === "yield") {
yieldCalled = true;
yieldCallPending = false;
yieldInvalidatedByAsync = false;
}
};
@@ -1258,6 +1314,16 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
if (event.message?.role === "assistant") {
resetRecentOutput();
}
// An async-result follow-up injected after a recorded yield
// supersedes that yield: its payload predates the job outcome the
// model is now being shown. Un-latch so the quiescence barrier's
// reminder ladder demands a fresh yield. Guarded on the run signal:
// once the run is completing, late injections must not destabilize
// the settled classification.
if (yieldCalled && !abortSignal.aborted && isAsyncResultInjection(event.message)) {
yieldCalled = false;
yieldInvalidatedByAsync = true;
}
break;
case "tool_execution_start": {
@@ -1341,7 +1407,14 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
isError: event.isError,
})
) {
requestAbort("terminate");
if (event.toolName === "yield" && sessionHasPendingAsyncWork()) {
// Terminal yield with owner jobs still pending: park the
// run behind the quiescence barrier instead of completing
// it (see requestYieldTurnStop).
requestYieldTurnStop();
} else {
requestAbort("terminate");
}
}
}
if (event.toolName === "yield") {
@@ -1625,6 +1698,25 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || budgetStopRequested,
budgetStopRequested: () => budgetStopRequested,
waitForBudgetStop: () => budgetStopAbortPromise ?? Promise.resolve(),
yieldInvalidatedByAsync: () => yieldInvalidatedByAsync,
yieldTurnStopRequested: () => yieldTurnStopRequested,
waitForYieldTurnStop: async () => {
const pending = yieldTurnStopPromise;
if (!pending) {
yieldTurnStopRequested = false;
return;
}
try {
await pending;
} finally {
// Clear only after the abort settled so the idempotence gate in
// requestYieldTurnStop stays closed while it is in flight.
if (yieldTurnStopPromise === pending) {
yieldTurnStopPromise = null;
yieldTurnStopRequested = false;
}
}
},
// A soft stop that never escalated still identifies as a budget abort so
// the lifecycle can park the agent as resumable instead of killing it.
abortKind: () => abortReason ?? (budgetStopRequested ? "budget" : undefined),
@@ -1723,62 +1815,135 @@ async function driveSessionToYield(
await awaitAbortable(session.prompt(task, { attribution: "agent" }));
await awaitAbortable(session.waitForIdle());
} catch (err) {
// A budget stop cancels the free-running turn by aborting the
// session, which can surface here as a rejected prompt. Swallow it
// and drive the forced final yield below; real caller/timeout
// aborts (monitor signal) and genuine failures keep the old path.
if (!monitor.budgetStopRequested() || abortSignal.aborted) throw err;
// A budget stop or a yield turn-stop (terminal yield parked behind
// the async quiescence barrier) cancels the free-running turn by
// aborting the session, which can surface here as a rejected
// prompt. Swallow it and drive the barrier/forced final yield
// below; real caller/timeout aborts (monitor signal) and genuine
// failures keep the old path.
const recoverableStop = monitor.budgetStopRequested() || monitor.yieldTurnStopRequested();
if (!recoverableStop || abortSignal.aborted) throw err;
}
const reminderToolChoice = buildNamedToolChoice("yield", session.model);
let retryCount = 0;
while (!monitor.yieldCalled() && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
// A budget stop collapses the reminder ladder to a single forced
// final yield: wait for the stop's session abort to settle, then
// prompt once with the wrap-up reminder + named tool choice.
const budgetStop = monitor.budgetStopRequested();
if (budgetStop) {
retryCount = MAX_YIELD_RETRIES - 1;
await monitor.waitForBudgetStop();
if (monitor.yieldCalled() || abortSignal.aborted) break;
}
// Skip reminders when the model returned a terminal error (e.g.
// rate-limit cap hit, auth failure). Re-prompting would just
// hit the same wall, multiplying the failure noise without
// any chance of producing a yield.
const lastBeforeReminder = session.getLastAssistantMessage();
if (lastBeforeReminder?.stopReason === "error") break;
try {
retryCount++;
const reminder = prompt.render(submitReminderTemplate, {
retryCount,
maxRetries: MAX_YIELD_RETRIES,
budgetStop,
});
const isFinalRetry = retryCount >= MAX_YIELD_RETRIES;
await awaitAbortable(
session.prompt(reminder, {
attribution: "agent",
synthetic: true,
...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
}),
);
await awaitAbortable(session.waitForIdle());
} catch (err) {
if (abortSignal.aborted || err instanceof ToolAbortError) {
// Benign control-flow exit — user cancel (^C) or compaction aborting
// pending operations both surface here as ToolAbortError. The outer
// catch and finally already mark the run aborted; logging at ERROR
// would spam operator dashboards with non-failures.
logger.debug("Subagent prompt aborted");
} else {
logger.error("Subagent prompt failed", {
error: err instanceof Error ? err.message : String(err),
const runYieldLadder = async (): Promise<void> => {
let retryCount = 0;
while (!monitor.yieldCalled() && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
// A budget stop collapses the reminder ladder to a single forced
// final yield: wait for the stop's session abort to settle, then
// prompt once with the wrap-up reminder + named tool choice.
const budgetStop = monitor.budgetStopRequested();
if (budgetStop) {
retryCount = MAX_YIELD_RETRIES - 1;
await monitor.waitForBudgetStop();
if (monitor.yieldCalled() || abortSignal.aborted) break;
}
// Skip reminders when the model returned a terminal error (e.g.
// rate-limit cap hit, auth failure). Re-prompting would just
// hit the same wall, multiplying the failure noise without
// any chance of producing a yield.
const lastBeforeReminder = session.getLastAssistantMessage();
if (lastBeforeReminder?.stopReason === "error") break;
try {
retryCount++;
const reminder = prompt.render(submitReminderTemplate, {
retryCount,
maxRetries: MAX_YIELD_RETRIES,
budgetStop,
});
const isFinalRetry = retryCount >= MAX_YIELD_RETRIES;
await awaitAbortable(
session.prompt(reminder, {
attribution: "agent",
synthetic: true,
...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
}),
);
await awaitAbortable(session.waitForIdle());
} catch (err) {
if (abortSignal.aborted || err instanceof ToolAbortError) {
// Benign control-flow exit — user cancel (^C) or compaction aborting
// pending operations both surface here as ToolAbortError. The outer
// catch and finally already mark the run aborted; logging at ERROR
// would spam operator dashboards with non-failures.
logger.debug("Subagent prompt aborted");
} else {
logger.error("Subagent prompt failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
}
};
// Yield ladder + quiescence barrier (structured concurrency), one
// loop: each iteration first demands a yield — initially, and again
// whenever an async-result delivery un-latched the previous one
// (including during the notice turn) — then either completes on
// quiescence or settles one generation of owner async work.
//
// A final yield with owner background jobs still running or
// undelivered is a scheduling pause, not run completion — the monitor
// parks such a yield with a recoverable turn-stop instead of
// terminating the run. Jobs are settled and their results folded into
// the run as async-result follow-up turns; each delivered result
// supersedes the yield it postdates, so the reminder ladder re-runs
// to demand a fresh yield that accounts for it. Only a yield with no
// pending owner work left is terminal — the isolation runner captures
// and destroys the worktree right after this run resolves, so no
// owner job that could still re-wake the session may outlive it.
// Suppressed (acknowledged / hub-watched) jobs never re-wake the run
// and are reaped at teardown.
//
// Before blocking on running jobs, tell the model ONCE what it is
// waiting on so it can `hub` wait/cancel instead of sitting silent
// until the jobs (or the runtime limit) expire. Runs that never yield
// (ladder exhausted / terminal model error) skip the barrier — more
// injected turns just multiply the failure noise; the teardown reap
// still cancels and awaits their jobs before worktree capture.
let asyncPendingNoticeSent = false;
while (!abortSignal.aborted) {
if (!monitor.yieldCalled()) {
await runYieldLadder();
// Ladder exhausted / terminal model error: classified below
// (missing yield, or stale yield when one was invalidated).
if (!monitor.yieldCalled()) break;
}
// Let the parked yield's turn-stop session abort settle before
// prompting again (mirrors waitForBudgetStop).
await awaitAbortable(monitor.waitForYieldTurnStop());
if (!session.hasPendingAsyncWork()) break;
if (!asyncPendingNoticeSent) {
asyncPendingNoticeSent = true;
const running = session.getAsyncJobSnapshot()?.running ?? [];
if (running.length > 0) {
const jobs = running.map(job => `${job.id}${job.label ? ` (${job.label})` : ""}`).join(", ");
const notice = prompt.render(subagentAsyncPendingTemplate, {
count: running.length,
multiple: running.length > 1,
jobs,
});
try {
await awaitAbortable(session.prompt(notice, { attribution: "agent", synthetic: true }));
await awaitAbortable(session.waitForIdle());
} catch (err) {
if (abortSignal.aborted || err instanceof ToolAbortError) throw err;
// A failed notice turn must not kill the run — fall through
// to the passive settle below.
logger.warn("Subagent async-pending notice failed", {
error: err instanceof Error ? err.message : String(err),
});
}
// Re-evaluate: the notice turn may have cancelled, watched, or
// absorbed the jobs — or already re-yielded.
continue;
}
}
await awaitAbortable(session.settleAsyncWork());
// Results delivered during the settle invalidated the recorded
// yield: the next iteration's ladder demands a fresh one.
}
if (monitor.yieldCalled()) {
@@ -1817,6 +1982,18 @@ async function driveSessionToYield(
abortReasonText ??= monitor.resolveAbortReasonText();
exitCode = 1;
}
// A recorded yield that async-result deliveries superseded and the
// model never refreshed is stale: fail the run instead of letting the
// parent act on a payload that predates the background job outcomes
// the model was shown. The stale payload still ships through
// finalizeSubprocessOutput's failed-after-yield path (exit 1 + stderr,
// output preserved as salvage).
if (monitor.yieldInvalidatedByAsync() && !abortSignal.aborted) {
exitCode = 1;
error ??=
"Background job results arrived after the subagent's last yield; it did not submit a refreshed yield covering them.";
}
} catch (err) {
if (abortSignal.aborted && monitor.yieldCalled() && !monitor.runtimeLimitExceeded()) {
exitCode = 0;
@@ -2837,6 +3014,20 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
reviveSession,
});
}
// Structured-concurrency reap: cancel and await ALL surviving owner
// jobs (abort paths; suppressed/watched jobs the model left behind)
// so isolation capture/cleanup never races a live process writing
// into the worktree. This never proceeds while an owner process is
// live: cancellation SIGKILL-escalates, so settlement is expected
// within one interval — an unkillable process blocks here visibly
// (with periodic warnings) instead of silently racing teardown.
const jobManager = AsyncJobManager.instance();
if (jobManager) {
jobManager.cancelAll({ ownerId: id });
while (!(await jobManager.waitForOwnerJobs(id, { timeoutMs: 10_000 }))) {
logger.warn("Subagent async jobs still settling; delaying teardown until process exit", { id });
}
}
}
// Launch-latency breakdown (subagent invocation → first chat dispatch).
+34 -11
View File
@@ -786,28 +786,40 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
job: ManagedBashJobHandle,
thresholdMs: number,
signal?: AbortSignal,
): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
steeringSignal?: AbortSignal,
): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "steer" } | { kind: "aborted" }> {
if (signal?.aborted) {
return { kind: "aborted" };
}
if (steeringSignal?.aborted) {
return { kind: "steer" };
}
const waiters: Array<Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }>> = [
job.completion,
Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const })),
];
const waiters: Array<
Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "steer" } | { kind: "aborted" }>
> = [job.completion, Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const }))];
if (!signal) {
if (!signal && !steeringSignal) {
return await Promise.race(waiters);
}
const { promise: abortedPromise, resolve: resolveAborted } = Promise.withResolvers<{ kind: "aborted" }>();
const onAbort = () => resolveAborted({ kind: "aborted" });
signal.addEventListener("abort", onAbort, { once: true });
waiters.push(abortedPromise);
const { promise: steerPromise, resolve: resolveSteer } = Promise.withResolvers<{ kind: "steer" }>();
const onSteer = () => resolveSteer({ kind: "steer" });
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
waiters.push(abortedPromise);
}
if (steeringSignal) {
steeringSignal.addEventListener("abort", onSteer, { once: true });
waiters.push(steerPromise);
}
try {
return await Promise.race(waiters);
} finally {
signal.removeEventListener("abort", onAbort);
signal?.removeEventListener("abort", onAbort);
steeringSignal?.removeEventListener("abort", onSteer);
}
}
@@ -994,7 +1006,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
// foreground-wait cannot also be injected by the delivery loop. Lifted
// via resumeDeliveries() if we end up backgrounding after all.
autoBgManager.acknowledgeDeliveries([job.jobId]);
const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
const waitResult = await this.#waitForManagedBashJob(
job,
autoBackgroundWaitMs,
signal,
ctx?.toolCall?.steeringSignal,
);
if (waitResult.kind === "completed") {
return waitResult.result;
}
@@ -1007,9 +1024,15 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
}
job.stopUpdates();
autoBgManager.resumeDeliveries([job.jobId]);
// "steer": a queued user/peer message arrived mid-wait — background
// the command (it keeps running) so the message injects promptly.
const notices =
waitResult.kind === "steer"
? [...pendingNotices, "Backgrounded early to handle an incoming message; the command keeps running."]
: pendingNotices;
return this.#buildBackgroundStartResult(job.jobId, job.getLatestText(), timeoutSec, {
requestedTimeoutSec,
notices: pendingNotices,
notices,
});
}
@@ -0,0 +1,96 @@
/**
* Owner-routed async delivery + quiescence (structured concurrency for
* background jobs): each AgentSession registers a delivery sink for its own
* agent id, owned job completions inject async-result follow-up turns into
* THAT session, and `hasPendingAsyncWork()` / `settleAsyncWork()` define the
* run quiescence the task executor's barrier is built on.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
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";
import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
describe("AgentSession owner-routed async delivery", () => {
let session: AgentSession;
let tempDir: string;
const authStorages: AuthStorage[] = [];
beforeEach(() => {
tempDir = path.join(os.tmpdir(), `pi-async-delivery-test-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
});
afterEach(async () => {
if (session) {
await session.dispose();
}
for (const authStorage of authStorages.splice(0)) {
authStorage.close();
}
if (tempDir && fs.existsSync(tempDir)) {
removeSyncWithRetries(tempDir);
}
AsyncJobManager.resetForTests();
});
it("injects an owned completion as a follow-up turn and reaches quiescence", 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(path.join(tempDir, "auth.db"));
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,
});
const gate = Promise.withResolvers<string>();
manager.register("bash", "gated job", () => gate.promise, { id: "sub-job", ownerId: "SubAgent" });
// A running owned job holds the session out of quiescence.
expect(session.hasPendingAsyncWork()).toBe(true);
gate.resolve("job finished: ALL GREEN");
await session.settleAsyncWork();
// The completion routed to THIS session (not a global default sink) and
// ran as a follow-up turn whose context carries the job result.
expect(session.hasPendingAsyncWork()).toBe(false);
const sawResult = mock.calls.some(call =>
call.context.messages.some(message => {
if (typeof message.content === "string") {
return message.content.includes("ALL GREEN");
}
return (
Array.isArray(message.content) &&
message.content.some(content => content.type === "text" && content.text.includes("ALL GREEN"))
);
}),
);
expect(sawResult).toBe(true);
});
});
@@ -1008,19 +1008,6 @@ describe("AgentSession concurrent prompt guard", () => {
const asyncJobManager = new AsyncJobManager({
maxRunningJobs: 2,
retentionMs: 1_000,
onJobComplete: async () => {
deliveryStarted = true;
await deliveryGate.promise;
await session.sendCustomMessage(
{
customType: "async-result",
content: "Background result",
display: true,
attribution: "agent",
},
{ deliverAs: "followUp", triggerTurn: true },
);
},
});
AsyncJobManager.setInstance(asyncJobManager);
@@ -1036,6 +1023,21 @@ describe("AgentSession concurrent prompt guard", () => {
capabilities: {},
deferAgentInitiatedTurns: true,
});
// Override the session's self-registered sink: the test gates delivery
// and reproduces the ACP follow-up injection explicitly.
asyncJobManager.registerDeliverySink(ownerId, async () => {
deliveryStarted = true;
await deliveryGate.promise;
await session.sendCustomMessage(
{
customType: "async-result",
content: "Background result",
display: true,
attribution: "agent",
},
{ deliverAs: "followUp", triggerTurn: true },
);
});
await session.prompt("First message");
expect(session.isStreaming).toBe(false);
@@ -1085,13 +1087,6 @@ describe("AgentSession concurrent prompt guard", () => {
const asyncJobManager = new AsyncJobManager({
maxRunningJobs: 3,
retentionMs: 1_000,
onJobComplete: async jobId => {
started.add(jobId);
if (jobId === "job-a") {
await deliveryGate.promise;
}
delivered.push(jobId);
},
});
AsyncJobManager.setInstance(asyncJobManager);
@@ -1121,6 +1116,19 @@ describe("AgentSession concurrent prompt guard", () => {
agentId: "acp-session-a",
ownedAsyncJobManager: asyncJobManager,
});
// Override both sessions' self-registered sinks so the test controls
// delivery timing and records routing order.
asyncJobManager.registerDeliverySink("acp-session-a", async jobId => {
started.add(jobId);
if (jobId === "job-a") {
await deliveryGate.promise;
}
delivered.push(jobId);
});
asyncJobManager.registerDeliverySink("acp-session-b", async jobId => {
started.add(jobId);
delivered.push(jobId);
});
try {
asyncJobManager.register("bash", "A", async () => "A", { id: "job-a", ownerId: "acp-session-a" });
@@ -114,7 +114,7 @@ describe("AgentSession todo reminder async-job deferral", () => {
authStorage.setRuntimeApiKey("anthropic", "test-key");
modelRegistry = new ModelRegistry(authStorage);
sessionManager = SessionManager.create(tempDir.path(), tempDir.path());
manager = new AsyncJobManager({ onJobComplete: async () => {} });
manager = new AsyncJobManager({});
gates = [];
extensionRunner = {
emit: vi.fn().mockResolvedValue(undefined),
@@ -149,6 +149,9 @@ describe("AgentSession todo reminder async-job deferral", () => {
asyncJobManager: manager,
extensionRunner,
});
// Override the session's self-registered sink with a no-op: these tests
// exercise the async-wake deferral gates, not result injection.
manager.registerDeliverySink("Main", () => {});
reminderAttempts = [];
agentEndTerminalStates = [];
@@ -303,16 +303,13 @@ describe("AsyncJobManager", () => {
releaseMainDelivery = resolve;
});
const subagentCompletions: Array<{ jobId: string; text: string }> = [];
const manager = new AsyncJobManager({
retentionMs: 0,
onJobComplete: async (jobId, text) => {
if (jobId === mainJobId) {
notifyMainDeliveryStarted();
await mainDeliveryReleased;
return;
}
subagentCompletions.push({ jobId, text });
},
const manager = new AsyncJobManager({ retentionMs: 0 });
manager.registerDeliverySink("0-Main", async () => {
notifyMainDeliveryStarted();
await mainDeliveryReleased;
});
manager.registerDeliverySink("3-AuthLoader", (jobId, text) => {
subagentCompletions.push({ jobId, text });
});
mainJobId = manager.register("task", "main job", async () => "main result", { ownerId: "0-Main" });
@@ -336,7 +333,6 @@ describe("AsyncJobManager", () => {
});
test("scoped delivery drain times out while a matching delivery callback is in flight", async () => {
let mainJobId = "";
let targetJobId = "";
let releaseMainDelivery = (): void => {};
let notifyMainDeliveryStarted = (): void => {};
@@ -355,22 +351,18 @@ describe("AsyncJobManager", () => {
releaseTargetDelivery = resolve;
});
const completions: string[] = [];
const manager = new AsyncJobManager({
onJobComplete: async jobId => {
if (jobId === mainJobId) {
notifyMainDeliveryStarted();
await mainDeliveryReleased;
return;
}
if (jobId === targetJobId) {
notifyTargetDeliveryStarted();
await targetDeliveryReleased;
completions.push(jobId);
}
},
const manager = new AsyncJobManager({});
manager.registerDeliverySink("0-Main", async () => {
notifyMainDeliveryStarted();
await mainDeliveryReleased;
});
manager.registerDeliverySink("3-AuthLoader", async jobId => {
notifyTargetDeliveryStarted();
await targetDeliveryReleased;
completions.push(jobId);
});
mainJobId = manager.register("task", "main job", async () => "main result", { ownerId: "0-Main" });
manager.register("task", "main job", async () => "main result", { ownerId: "0-Main" });
targetJobId = manager.register("task", "subagent job", async () => "subagent result", {
ownerId: "3-AuthLoader",
});
@@ -437,6 +429,76 @@ describe("AsyncJobManager", () => {
await manager.waitForAll();
expect(manager.getJob(parentJobId)?.status).toBe("cancelled");
});
test("routes owned deliveries to the owner's registered sink only", async () => {
const mainDeliveries: string[] = [];
const defaultDeliveries: string[] = [];
const manager = new AsyncJobManager({
onJobComplete: async jobId => {
defaultDeliveries.push(jobId);
},
});
manager.registerDeliverySink("Main", jobId => {
mainDeliveries.push(jobId);
});
manager.register("bash", "owned", async () => "ok", { id: "owned-1", ownerId: "Main" });
manager.register("bash", "unowned", async () => "ok", { id: "unowned-1" });
await manager.waitForAll();
await manager.drainDeliveries({ timeoutMs: 500 });
expect(mainDeliveries).toEqual(["owned-1"]);
expect(defaultDeliveries).toEqual(["unowned-1"]);
});
test("dead-letters an owned delivery when its owner has no live sink", async () => {
const defaultDeliveries: string[] = [];
const manager = new AsyncJobManager({
onJobComplete: async jobId => {
defaultDeliveries.push(jobId);
},
});
const unregister = manager.registerDeliverySink("Sub", () => {});
unregister();
manager.register("bash", "orphan", async () => "orphan result", { id: "orphan-1", ownerId: "Sub" });
await manager.waitForAll();
const drained = await manager.drainDeliveries({ timeoutMs: 500 });
// Dead-letter drops the delivery (drain settles) without misrouting it
// into the default sink; the outcome stays readable on the job row.
expect(drained).toBe(true);
expect(defaultDeliveries).toEqual([]);
expect(manager.getJob("orphan-1")?.resultText).toBe("orphan result");
});
test("waitForOwnerJobs settles cancelled jobs and skips suppressed ones on request", async () => {
const manager = new AsyncJobManager({});
manager.register(
"bash",
"hung",
async ({ signal }) => {
await new Promise<void>(resolve => {
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
return "stopped";
},
{ id: "hung-1", ownerId: "Sub" },
);
// Quiescence-barrier contract: a watched (suppressed) job can never
// re-wake a run, so the filtered wait treats it as settled.
manager.watchJobs(["hung-1"]);
await expect(manager.waitForOwnerJobs("Sub", { excludeSuppressed: true })).resolves.toBe(true);
// Teardown-reap contract: the unfiltered wait blocks until the
// cancelled job's body actually finishes.
const reap = manager.waitForOwnerJobs("Sub", { timeoutMs: 1_000 });
manager.cancelAll({ ownerId: "Sub" });
await expect(reap).resolves.toBe(true);
expect(manager.getJob("hung-1")?.status).toBe("cancelled");
});
});
describe("AsyncJobManager smart poll-wait escalation", () => {
@@ -0,0 +1,253 @@
/**
* Quiescence barrier fresh-yield contract (PR #6119 review): a terminal
* `yield` recorded while owner background jobs are still pending parks the
* run instead of terminating it, and an async-result delivered after that
* yield supersedes it — the run only completes on a yield that postdates
* every delivered result. A model that never refreshes its yield must fail
* the run rather than surface the stale payload as a clean success.
*/
import { afterEach, describe, expect, it, vi } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
import type { CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk";
import * as sdkModule from "@oh-my-pi/pi-coding-agent/sdk";
import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { runSubprocess } from "@oh-my-pi/pi-coding-agent/task/executor";
import type { AgentDefinition } from "@oh-my-pi/pi-coding-agent/task/types";
import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus";
const baseAgent: AgentDefinition = { name: "task", description: "test", systemPrompt: "test", source: "bundled" };
function assistantStopMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "openai-responses",
provider: "openai",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
interface AsyncQuiescenceHarness {
session: AgentSession;
prompts: string[];
abortCalls: () => number;
settleCalls: () => number;
emitTerminalYield: (data: unknown) => void;
finishJob: () => void;
}
/**
* Mock session with the owner-async surface the barrier drives:
* `hasPendingAsyncWork` / `getAsyncJobSnapshot` / `settleAsyncWork`. The job
* "finishes" during the first settle, which injects the async-result
* follow-up (custom message_start) and a plain assistant reaction WITHOUT a
* fresh yield — exactly the review's stale-yield scenario.
*/
function createAsyncSession(
onPrompt: (params: { text: string; promptIndex: number; harness: AsyncQuiescenceHarness }) => void,
): AsyncQuiescenceHarness {
const listeners: Array<(event: AgentSessionEvent) => void> = [];
const state = { messages: [] as AssistantMessage[] };
const prompts: string[] = [];
let abortCount = 0;
let settleCount = 0;
let pendingAsync = true;
let runningJobs: Array<{ id: string; label?: string }> = [{ id: "job-1", label: "background build" }];
let toolCallSeq = 0;
const emit = (event: AgentSessionEvent) => {
for (const listener of [...listeners]) listener(event);
};
const emitTerminalYield = (data: unknown) => {
toolCallSeq += 1;
emit({
type: "tool_execution_end",
toolCallId: `yield-${toolCallSeq}`,
toolName: "yield",
result: {
content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data },
},
} as AgentSessionEvent);
};
const finishJob = () => {
pendingAsync = false;
runningJobs = [];
// Owner job completed: the session injects the async-result follow-up
// turn. The model reacts with text only — no fresh yield.
emit({
type: "message_start",
message: {
role: "custom",
customType: "async-result",
content: "<system-notice>Background job job-1 has completed.\nexit 1: build FAILED</system-notice>",
display: true,
attribution: "agent",
timestamp: Date.now(),
},
} as AgentSessionEvent);
const reaction = assistantStopMessage("The background build failed after I yielded.");
state.messages.push(reaction);
emit({ type: "message_end", message: reaction } as AgentSessionEvent);
};
const harness: AsyncQuiescenceHarness = {
session: undefined as unknown as AgentSession,
prompts,
abortCalls: () => abortCount,
settleCalls: () => settleCount,
emitTerminalYield,
finishJob,
};
const session = {
state,
agent: { state: { systemPrompt: ["test"] } },
model: undefined,
extensionRunner: undefined,
sessionManager: { appendSessionInit: () => {} },
getActiveToolNames: () => ["read", "yield"],
getEnabledToolNames: () => ["read", "yield"],
setActiveToolsByName: async (_toolNames: string[]) => {},
subscribe: (listener: (event: AgentSessionEvent) => void) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index >= 0) listeners.splice(index, 1);
};
},
prompt: async (text: string) => {
prompts.push(text);
onPrompt({ text, promptIndex: prompts.length, harness });
},
waitForIdle: async () => {},
getLastAssistantMessage: () => state.messages[state.messages.length - 1],
hasPendingAsyncWork: () => pendingAsync,
getAsyncJobSnapshot: () => ({ running: runningJobs, recent: [] }),
settleAsyncWork: async () => {
settleCount += 1;
harness.finishJob();
},
abort: async () => {
abortCount += 1;
},
dispose: async () => {},
};
harness.session = session as unknown as AgentSession;
return harness;
}
function mockCreateAgentSession(session: AgentSession) {
return vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue({
session,
extensionsResult: {} as unknown as LoadExtensionsResult,
setToolUIContext: () => {},
eventBus: new EventBus(),
} as CreateAgentSessionResult);
}
describe("runSubprocess async quiescence fresh-yield contract", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("parks a pending yield, injects the result, and completes on the fresh yield", async () => {
const harness = createAsyncSession(({ promptIndex, harness: h }) => {
if (promptIndex === 1) {
// Terminal yield while the background job is still running.
h.emitTerminalYield({ report: "STALE: build passing (job still running)" });
return;
}
if (promptIndex === 2) {
// Async-pending notice: the model stands by. The job then
// finishes during the barrier's settle.
return;
}
// Reminder ladder after the async-result invalidated the yield:
// submit the fresh yield that accounts for the job outcome.
h.emitTerminalYield({ report: "FRESH: build failed, see job-1" });
});
mockCreateAgentSession(harness.session);
const result = await runSubprocess({
cwd: "/tmp",
agent: baseAgent,
task: "do the work",
index: 0,
id: "quiescence-fresh-yield",
});
// Run did not terminate on the parked yield: the barrier noticed, the
// job settled, and the ladder demanded exactly one more prompt.
expect(harness.prompts).toHaveLength(3);
expect(harness.prompts[1]).toContain("yield was recorded");
expect(harness.settleCalls()).toBe(1);
// The parked yield stopped the turn without killing the run.
expect(harness.abortCalls()).toBeGreaterThanOrEqual(1);
// The fresh yield — not the stale one — is the result of record.
expect(result.exitCode).toBe(0);
expect(result.output).toContain("FRESH: build failed");
expect(result.output).not.toContain("STALE");
});
it("fails the run when the model never refreshes the superseded yield", async () => {
const harness = createAsyncSession(({ promptIndex, harness: h }) => {
if (promptIndex === 1) {
h.emitTerminalYield({ report: "STALE: build passing (job still running)" });
}
// Notice and every reminder: the model never yields again.
});
mockCreateAgentSession(harness.session);
const result = await runSubprocess({
cwd: "/tmp",
agent: baseAgent,
task: "do the work",
index: 0,
id: "quiescence-stale-refusal",
});
// task + notice + full reminder ladder (3).
expect(harness.prompts).toHaveLength(5);
// Stale payload must not read as success; it ships only as failed-run
// salvage with an explicit reason.
expect(result.exitCode).toBe(1);
expect(result.error).toContain("refreshed yield");
expect(result.output).toContain("STALE: build passing");
});
it("terminates immediately on yield when no owner async work is pending", async () => {
const harness = createAsyncSession(({ promptIndex, harness: h }) => {
if (promptIndex === 1) {
h.finishJob();
h.emitTerminalYield({ report: "done" });
}
});
mockCreateAgentSession(harness.session);
const result = await runSubprocess({
cwd: "/tmp",
agent: baseAgent,
task: "do the work",
index: 0,
id: "quiescence-no-async",
});
expect(harness.prompts).toHaveLength(1);
expect(result.exitCode).toBe(0);
expect(result.output).toContain("done");
});
});
+53
View File
@@ -1468,6 +1468,59 @@ function b() {
await asyncJobManager.dispose();
});
it("backgrounds a running command when the steering signal fires mid-wait", async () => {
const asyncJobManager = new AsyncJobManager({});
const autoBackgroundBashTool = wrapToolWithMetaNotice(
new BashTool(
createTestToolSession(
testDir,
Settings.isolated({
"bash.autoBackground.enabled": true,
// High threshold: only the steering signal can background this.
"bash.autoBackground.thresholdMs": 60_000,
}),
{
getSessionId: () => "test-session",
asyncJobManager,
},
),
),
);
const steering = new AbortController();
steering.abort();
const result = await autoBackgroundBashTool.execute(
"test-call-steer-background",
{ command: "printf 'start\\n'; sleep 0.05; printf 'done\\n'" },
undefined,
undefined,
{
...createTestToolContext([]),
toolCall: {
batchId: "batch-1",
index: 0,
total: 1,
toolCalls: [{ id: "test-call-steer-background", name: "bash" }],
steeringSignal: steering.signal,
},
},
);
// The steer backgrounds the command instead of killing it: the call
// returns a running job and the command finishes on its own.
expect(result.details?.async?.state).toBe("running");
expect(getTextOutput(result)).toContain("Backgrounded early to handle an incoming message");
const jobId = result.details?.async?.jobId;
if (!jobId) {
throw new Error("expected a steer-backgrounded job id");
}
const job = asyncJobManager.getJob(jobId);
expect(job?.status).toBe("running");
await job?.promise;
expect(asyncJobManager.getJob(jobId)?.status).toBe("completed");
await asyncJobManager.dispose();
});
it("should background instead of timing out when auto-background wait exceeds the effective timeout", async () => {
const deliveries: Array<{ jobId: string; text: string }> = [];
const asyncJobManager = new AsyncJobManager({