Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .pylon/features.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,18 @@ decisions:
upstream_support: Pass the owning session to provider completion options for maintenance routing while retaining Pylon scoped payload hooks and independent summary request identities.
revisit_when:
- Upstream provides both owning-session routing and scoped auxiliary payload identity without replacing the fork hooks.

background-command-consumption:
area: runtime-reliability
state: candidate
owner: shared
decision: hybridize
pylon_refs:
- https://github.com/pylon-code/prime-agent/issues/64
- https://github.com/pylon-code/pylon/issues/552
upstream_refs:
- https://github.com/PrimeIntellect-ai/prime-agent/commit/ecd60e3cd4313643dede3cb2fcf13b4e2e9a2a75
fork_change: acknowledged-per-handle-background-notice-withdrawal
upstream_support: Withdraw a pending completion after a result reaches a live cell; add per-handle identity and host acknowledgment to preserve unread notices under PID reuse and mixed runtime versions.
revisit_when:
- Upstream provides exact handle identity and reader ownership for background completion withdrawal.
8 changes: 8 additions & 0 deletions .pylon/upstream-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,11 @@ Follow-up: Task10 builds/packs the exact merged tree into a private prefix and r
- **Hybridize** owning-session completion options with Pylon's existing scoped payload hooks. OpenCode builds its routing headers before invoking the payload hook, so both are necessary. Preserve each existing hook argument and append the optional routing identity through compaction, split-turn summaries, refinement planning/review and branch summaries.
- No daemon wire, protocol, capability, persistence or transcript change. The public SDK helper parameters remain additive; existing explicit routing headers retain precedence. Retain independent summary idempotency keys, stable retries and cancellation/ownership behavior.
- Validation: 54 focused local HTTP, child/auxiliary identity and semantic-edge regressions pass. Both OpenCode providers cover ordinary requests, manual/automatic compaction, refinement, branch summaries, resume, separate sessions, retry identity/body stability and explicit-header precedence. `npm run check` passes Biome, TypeScript, installer and browser-bundle checks. Live provider credentials are not used; installed-artifact acceptance remains part of Pylon #557.

## 2026-09-14 — background command result consumption

- **Hybridize** upstream #2159 (`ecd60e3cd4313643dede3cb2fcf13b4e2e9a2a75`) for #64 without advancing the complete upstream checkpoint. Preserve the fork's admission pause, cancellation, ownership and residency handling.
- Suppress unsent notices after a live cell receives a finished result. Withdraw accepted but still queued notices only by a random per-handle identity plus matching PID and command. PID/command alone cannot distinguish reused handles; duplicate or legacy identity-free withdrawals never remove another handle's notice. Delivered notices stay immutable.
- The kernel arms withdrawal only after the host echoes the exact completion identity. This is an additive, acknowledged kernel-bridge feature: old hosts retain notices, old runtimes remain accepted, and no startup dependency is introduced. Daemon commands, typed responses, protocol/schema revisions and negotiated SDK capabilities are unchanged; the existing generic custom-message details carry the optional identity.
- Detached readers must not consume the notice merely because another cell is active. Require the cell's task to directly or transitively await the reader. Reads from unproved thread/task ownership conservatively retain the notice.
- Validation: 109 Python REPL tests and 14 focused TypeScript/kernel tests pass, including newer-handle-first PID reuse, duplicate/foreign/legacy withdrawals, old-host fallback, detached readers, live result consumption, admission/Stop and residency. A timing-based upstream fixture now waits for its host request before asserting delivery. Repository checks and hosted results are recorded in the PR. Native transcript reconciliation remains Pylon #552 and installed delivery remains #557.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fixed redundant background-command completion turns after a live cell reads the result, preserving unread notices and matching withdrawals to the exact command handle.
4 changes: 2 additions & 2 deletions packages/coding-agent/docs/rlm.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ checks = bash("npm test")
checks.pid
```

When an unawaited handle's process group finishes, Prime Agent sends a Shell message with its PID and foreground exit code. A busy agent receives it as steering at the next safe turn boundary, without interrupting a running tool. An idle agent resumes to handle it. `await handle` and `handle.poll()` still return the foreground result before shell background jobs finish. The kernel stays resident until the process group is reaped, including for handles awaited in their creating cell. The message asks the agent to inspect the saved handle with `poll()`, `output()`, or `tail()` and continue the task. `await bash(...)` stays synchronous from the agent's perspective and does not send a second Shell message.
When an unawaited handle's process group finishes, Prime Agent sends a "Background command finished" notice with its PID and foreground exit code. A busy agent receives it as steering at the next safe turn boundary, without interrupting a running tool. An idle agent resumes to handle it. `await handle` and `handle.poll()` still return the foreground result before shell background jobs finish. The kernel stays resident until the process group is reaped, including for handles awaited in their creating cell. The message asks the agent to inspect the saved handle with `poll()`, `output()`, or `tail()` and continue the task. `await bash(...)` stays synchronous from the agent's perspective and does not send a second notice.

Awaiting the original handle in its creating cell suppresses the Shell message, even after the command finishes. If an `asyncio.as_completed` wrapper task finishes before the cell starts consuming its result, that cached-result read does not mark the handle as awaited. A Shell message can still arrive. Await the original handle in the creating cell to suppress it.
Reading the finished result withdraws the notice. Any read from a live cell counts -- `await handle`, `handle.poll()`, `handle.output()`, `handle.tail()` -- so a notice that is still queued is dropped and a notice that was not sent yet is never sent. Reads that no cell receives do not count: a detached watcher polling the handle between turns leaves the notice in place, because that notice is the only wake-up an idle session gets, and an `asyncio.as_completed` or `create_task` wrapper that resolves with no live cell waiting for it does not count either. A notice the agent already received is never retracted.

Each `bash()` call is its own process, while Python state, `os.chdir(...)`, and `os.environ[...]` changes persist in the kernel and apply to later `bash()` calls. Prime Agent extensions may intentionally add custom tools, but the built-in RLM design does not require a separate model tool for every capability.

Expand Down
38 changes: 38 additions & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.j
import {
type CreateRlmSubagentRuntimeOptions,
createAsyncBashCompletionHostHandler,
createAsyncBashConsumedHostHandler,
createDefaultRlmSubagentSessionName,
createRlmCreateSessionHostHandler,
createRlmDeleteSubagentHostHandler,
Expand Down Expand Up @@ -5451,6 +5452,40 @@ export class AgentSession {
for (const id of ids) this._durableRlmTerminalNoticeActionIds.delete(id);
}

/**
* The kernel read the command's result before the notice reached the model, so
* the notice has nothing left to report: drop it while it is still queued.
* Delivered notices are no longer clearable, which makes this a no-op.
*/
private _withdrawAsyncBashCompletionNotice(details: { pid: number; command: string; completionId: string }): void {
// A read withdraws only its own handle, even after PID/command reuse.
const notice = this._actionStore
.clearableActions()
.find((action) => this._isAsyncBashCompletionActionFor(action, details));
if (!notice) return;
this._cancelSessionActions(
(action) => action === notice,
new Error("Background command completion notice withdrawn: the kernel read the result first."),
);
this._emitQueueUpdate();
}

private _isAsyncBashCompletionActionFor(
action: QueuedSessionAction,
details: { pid: number; command: string; completionId: string },
): boolean {
if (action.payload.kind !== "turn") return false;
const message = primaryDeliveryRecord(action).message;
if (message.role !== "custom" || message.customType !== ASYNC_BASH_COMPLETION_CUSTOM_TYPE) return false;
// Retain PID/command checks alongside the per-handle identity.
const completion = message.details as AsyncBashCompletionDetails | undefined;
return (
completion?.completionId === details.completionId &&
completion.pid === details.pid &&
completion.command === details.command
);
}

private async _promptInjectedMessage(
text: string,
message: CustomMessage,
Expand Down Expand Up @@ -10359,6 +10394,9 @@ export class AgentSession {
}
}
}),
"bash.consumed": createAsyncBashConsumedHostHandler((details) => {
this._withdrawAsyncBashCompletionNotice(details);
}),
"rlm.find_models": createRlmFindModelsHostHandler((query, limit) => this.findRlmModels(query, limit)),
"rlm.list_subagents": createRlmListSubagentsHostHandler(() => this.listRlmSubagents()),
"rlm.delete_subagent": createRlmDeleteSubagentHostHandler((target) => this.deleteRlmSubagent(target)),
Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const HARNESS_DIGEST_CUSTOM_TYPE = "harness_digest";
export const RLM_CHILD_FAILURE_CUSTOM_TYPE = "rlm_child_failure";
export const RLM_CHILD_TERMINAL_NOTICE_CUSTOM_TYPE = "rlm_child_terminal_notice";
export const ASYNC_BASH_COMPLETION_CUSTOM_TYPE = "async_bash_completion";
export const ASYNC_BASH_COMPLETION_PREVIEW_LABEL = "Shell message received";
export const ASYNC_BASH_COMPLETION_PREVIEW_LABEL = "Background command finished";

/**
* Names and other metadata interpolated into a `[<kind> ...]` header line must not
Expand Down Expand Up @@ -173,6 +173,7 @@ export type RlmChildTerminalNoticeDetails =
};

export interface AsyncBashCompletionDetails {
completionId?: string;
pid: number;
command: string;
exitCode: number;
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/prompts/rlm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface RlmPromptOptions {
}

const LONG_RUNNING_WORK_PROMPT = [
"For slow or independently completing work, use a nonblocking control loop: start the work, record its handle or output location, then end your turn. A `bash()` handle left running beyond its creating cell sends a completion follow-up; when it arrives, inspect the saved handle and continue.",
"For slow or independently completing work, use a nonblocking control loop: start the work, record its handle or output location, then end your turn. A `bash()` handle left running beyond its creating cell sends a completion follow-up; when it arrives, inspect the saved handle and continue. Reading a finished handle's result first cancels that follow-up.",
"When delegation is available and useful, assign independent substantive tasks to separate workers. Start independent workers without waiting for each one sequentially, and let them run in parallel.",
"Do not keep the turn open by polling with `time.sleep()` or shell `sleep`, and do not replace polling with a long blocking `await`. Await only the short operation needed to start work or inspect a result that is already available; otherwise end the turn.",
].join("\n");
Expand Down
38 changes: 37 additions & 1 deletion packages/coding-agent/src/core/rlm-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,21 @@ export type RlmRunHandler = (request: RlmRunRequest) => Promise<Record<string, u
type RlmCreateSessionHandler = (request: RlmCreateSessionRequest) => Promise<RlmCreateSessionResult>;

interface AsyncBashCompletionRequest {
completionId?: string;
pid: number;
command: string;
exitCode: number;
}

type AsyncBashCompletionHandler = (request: AsyncBashCompletionRequest) => void | Promise<void>;

interface AsyncBashConsumedRequest {
completionId: string;
pid: number;
command: string;
}

type AsyncBashConsumedHandler = (request: AsyncBashConsumedRequest) => void | Promise<void>;
export type RlmListSubagentsHandler = () => RlmListSubagentsResult | Promise<RlmListSubagentsResult>;
export type RlmDeleteSubagentHandler = (target: string) => Promise<RlmDeleteSubagentResult>;
export type RlmFindModelsHandler = (query: string, limit: number) => RlmFindModelsResult | Promise<RlmFindModelsResult>;
Expand Down Expand Up @@ -214,10 +223,19 @@ export function createRlmRunHostHandler(handler: RlmRunHandler): HostRequestHand
};
}

function parseBashCompletionId(value: unknown): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || !/^[a-f0-9]{32}$/.test(value)) {
throw new Error("bash completionId must be a 32-character lowercase hex identity");
}
return value;
}

/** Adapt detached kernel bash completions into a validated host notification. */
export function createAsyncBashCompletionHostHandler(handler: AsyncBashCompletionHandler): HostRequestHandler {
return async (payload) => {
const { pid, command, exitCode } = payload;
const completionId = parseBashCompletionId(payload.completionId);
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
throw new Error("bash.completed pid must be a positive integer");
}
Expand All @@ -227,7 +245,25 @@ export function createAsyncBashCompletionHostHandler(handler: AsyncBashCompletio
if (typeof exitCode !== "number" || !Number.isInteger(exitCode)) {
throw new Error("bash.completed exitCode must be an integer");
}
await handler({ pid, command, exitCode });
await handler({ pid, command, exitCode, ...(completionId ? { completionId } : {}) });
return completionId ? { completionId } : {};
};
}

/** The kernel read a finished command's result, so its completion notice is stale. */
export function createAsyncBashConsumedHostHandler(handler: AsyncBashConsumedHandler): HostRequestHandler {
return async (payload) => {
const { pid, command } = payload;
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
throw new Error("bash.consumed pid must be a positive integer");
}
if (typeof command !== "string" || !command) {
throw new Error("bash.consumed command must be a non-empty string");
}
const completionId = parseBashCompletionId(payload.completionId);
// Legacy runtimes cannot identify which retained handle was read.
if (!completionId) return {};
await handler({ pid, command, completionId });
return {};
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,40 @@ describeRuntime("#2053 background kernel bash residency", () => {
}
});

it("withdraws the queued completion notice when a later cell reads the result", async () => {
const notice = createDeferred<void>();
const release = createDeferred<void>();
try {
const { session, kernel } = await start(async () => {
notice.resolve();
await release.promise;
});
harness!.setResponses([
async () => {
// The notice queues while the model is busy in the cell that reads the result.
release.resolve();
await vi.waitFor(() => expect(session.getSteeringMessages()).toHaveLength(1));
const read = await kernel.execute("handle.output()");
expect(read.result).toContain("read-later");
await vi.waitFor(() => expect(session.getSteeringMessages()).toEqual([]));
return fauxAssistantMessage("Read the background result directly.");
},
]);
await kernel.execute("from rlm import bash\nhandle = bash('printf read-later')");
await notice.promise;
await session.prompt("Keep working.");
await session.waitForIdle();
expect(
session.messages.filter(
(message) => message.role === "custom" && message.customType === ASYNC_BASH_COMPLETION_CUSTOM_TYPE,
),
).toEqual([]);
expect(harness!.eventsOfType("agent_start")).toHaveLength(1);
} finally {
release.resolve();
}
});

it("reports a terminal readiness failure without retrying or retaining completed work", async () => {
const beforeCompletion = vi.fn(async () => {});
const { session, kernel } = await start(beforeCompletion, false);
Expand Down
Loading
Loading