Skip to content

Commit a670ad6

Browse files
authored
Merge pull request #35 from pylon-code/fix/request-abort-rlm-cascade
fix(coding-agent): cancelling a turn now stops its subagents
2 parents 74672ed + 8fb195b commit a670ad6

7 files changed

Lines changed: 225 additions & 12 deletions

File tree

.pylon/features.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,23 @@ decisions:
296296
revisit_when:
297297
- Prime upstream makes the session retry loop failure-kind aware and honors Retry-After with a delay ceiling.
298298
- Prime upstream makes exactly one retry layer own policy so provider SDK retries cannot multiply session retries.
299+
300+
turn-scoped-subagent-cancellation:
301+
area: runtime-reliability
302+
state: candidate
303+
owner: pylon-prime-integration
304+
decision: redesign
305+
pylon_refs:
306+
- https://github.com/pylon-code/prime-agent/issues/22
307+
- https://github.com/pylon-code/prime-agent/issues/25
308+
- https://github.com/pylon-code/prime-agent/pull/35
309+
upstream_refs:
310+
- https://github.com/PrimeIntellect-ai/prime-agent/pull/346
311+
- https://github.com/PrimeIntellect-ai/prime-agent/pull/464
312+
- https://github.com/PrimeIntellect-ai/prime-agent/pull/1253
313+
- https://github.com/PrimeIntellect-ai/prime-agent/tree/c382f09856d4a8c8d2b765179657047d58691f25
314+
fork_change: candidate
315+
upstream_support: Prime through c382f09856d4 keeps two cancel semantics. `AgentSession.requestAbort()` deliberately leaves active RLM child runs alive (PR #346 pinned that with a characterization test) while only `abort()` and `abortForUpdateRestart()` cascade, and every user-facing cancel path routes through `requestAbort()`. PR #464 made per-child cancellation explicit but never bound it to a turn abort, and open PR #1253 scopes child cancellation to kernel host teardown only. No upstream contract cancels the children a cancelled turn owns.
316+
revisit_when:
317+
- Prime makes a user-facing turn abort cancel the child runs it owns, or exposes an equivalent turn-scoped cancellation contract.
318+
- Prime introduces a real detached or background spawn mode, which would need a per-child survival decision instead of one retained-versus-active rule.

.pylon/upstream-review.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,14 @@ This ledger records Prime upstream evidence and the decision taken for each over
137137
- `child-scoped-provider-identity`: **retain**. Inline RLM children now run their payload, response, and context hooks through their own extension runner, converging with the daemon path that already gets a per-child runner from `createAgentSession`. Side questions and the compaction, branch-summary, refine, and auto-refine-review passes run through a scoped view of the owning session whose `getSessionId()` returns `<sessionId>/<scope>`; every other accessor still reports the owning session. The extension contract stays additive: existing `before_provider_request` handlers keep working and simply observe one identity per agent instead of the parent's for all of them.
138138
- Out of scope by design: `SessionManager.sessionId` still changes on fork and branch. A genuinely divergent history deserves a new provider key, so persisting identity across forks is a separate decision.
139139
- Validation: `npm run check` clean. `test/suite/regressions/23-child-provider-identity.test.ts` passes 3/3 and fails on the pre-fix inline-child wiring. Adjacent suites pass: side questions, fast-mode children, compaction (suite, extensions, summary reasoning), refinement, subagent runtime host, subagent model selection, subagent terminal messages, agent-session runtime, recursion, context tree, concurrent sessions, daemon agent connection, and the `packages/ai` faux provider — 620 passes with 8 skips across 18 files.
140+
141+
## 2026-08-31 — turn-scoped subagent cancellation
142+
143+
- Upstream evidence: `PrimeIntellect-ai/prime-agent@c382f09856d4a8c8d2b765179657047d58691f25` (current upstream `main`); latest audited release remains `v0.8.1`. Reviewed the upstream `AgentSession` abort surface (`requestAbort`, `abort`, `abortForUpdateRestart`, `_cancelActiveRlmChildRuns`, `_cancelRlmChildRun`, `_abandonRlmRunForQuiescence`), the RLM child run lifecycle, and upstream PRs #346, #464, and #1253 plus issue searches for cancel/abort cascade work.
144+
- `turn-scoped-subagent-cancellation`: **redesign**. Upstream keeps two cancel semantics. `requestAbort()` suspends the scheduler and aborts retry, compaction, branch summary, bash, refine, and the provider stream, but deliberately leaves active RLM child runs running; only `abort()` and `abortForUpdateRestart()` call `_cancelActiveRlmChildRuns`. PR #346 pinned the split with the characterization test "does not cancel active rlm children when only the parent turn is interrupted". Because every user-facing cancel path (interactive ctrl-c through `AgentConnection.abort`, ACP/in-process `abort` and `abortAndClearQueue`, daemon `abort` and `abort_and_clear_queue`) routes through `requestAbort()`, a cancelled parent turn left its children streaming until they finished or idled out. Pylon replaces the two semantics with one: `requestAbort()` now performs the same cascade `abort()` always did, and `abort()` inherits it instead of repeating it.
145+
- Not superseded upstream. PR #464 (closed unmerged) made per-child cancellation explicit and idempotent, which is the `cancelRlmChildRun` machinery the fork already has, but never bound it to a turn abort. Open PR #1253 cancels admitted child runs when the owning kernel host is disposed, killed, or stopped, which is teardown-scoped rather than turn-scoped and does not change `requestAbort()`.
146+
- Retained children survive by design. `_activeRlmChildRuns` holds every spawn run that has not finished, including one admitted by an earlier turn under the documented fire-and-forget delegation pattern; `RLMSpawnHandle` confirms admission only, so no Python cell ever awaits a child answer and there is no host-visible marker separating awaited from detached spawns. `_rlmChildSessions` holds children that already finished their run and stay addressable for `agent_message`, `rlm.list_subagents`, and inspectors. The cascade covers the first set and recurses into grandchildren through the child's own `abort()`. It does not touch the second set, because `abort()` never did, because a retained child's activity belongs to a later explicit request rather than the cancelled turn, and because the user already has `cancelRlmChildRun` / `rlm.delete_subagent` for a specific one. Prime exposes no detached or background spawn mode: `rlm.run` accepts only `name`, `model`, and `thinking`, so "retained versus active" is the only real distinction available.
147+
- Ordering matters for correctness and is preserved: the cascade runs after the scheduler suspension, so `_cancelRlmChildRun` routes each cancelled run through `_abandonRlmRunForQuiescence`. Cancelled children therefore leave no unsettled quiescence work, cannot report success, and cannot inject a late terminal notice into the next turn. Session-local only; no daemon command, event, or schema change.
148+
- Validation: new faux-provider regression `packages/coding-agent/test/suite/regressions/25-request-abort-rlm-cascade.test.ts` (2 tests) proves the child's provider stream is cut, that no further child request reaches the provider, that the parent settles and its next turn runs, and pins retained-child survival; it fails on the pre-change implementation. The upstream characterization test was inverted to "cancels active rlm children when the parent turn is interrupted", and the ACP-close terminal-notice retention test now settles the child before the scheduler cut, since a live child no longer survives it. `test/agent-session-recursion.test.ts` passes 112/112 and a 17-file affected batch across abort, RLM, subagent, queue, prompt, compaction, ACP, and correlated-lifecycle suites passes 396/396. `npm run check` is clean.
149+
- Fork change: [pylon-code/prime-agent#35](https://github.com/pylon-code/prime-agent/pull/35).
150+
- Revisit when Prime makes a user-facing turn abort cancel the children it owns, or introduces a real detached/background spawn mode that needs a per-child survival decision.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fixed cancelling a turn so in-flight subagent runs stop with it instead of continuing to consume provider capacity; retained subagents keep running and are still cancelled individually ([#25](https://github.com/pylon-code/prime-agent/issues/25)).

packages/coding-agent/docs/rlm-runtime.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ audit = await rlm("slow independent audit", name="audit-reviewer")
168168

169169
End the turn instead of waiting for completion. Children send requested answers with `await agent_message.send(message, receiver_role="parent")`, and replies arrive as ordinary agent messages over later turns. A child may instead write results to files for the parent to read. The host runs each admitted child as an independent `AgentSession`; daemon-backed children can be retained as independently addressable session workers.
170170

171+
A run admitted this way still belongs to the parent session's lifecycle: cancelling the parent turn cancels every child run that has not finished, so a cancel does not leave a fleet streaming. Children that already finished stay retained and addressable, and keep running any later work of their own.
172+
171173
## Parent-Scoped Sub-Agent Registry
172174

173175
The TypeScript parent maintains the authoritative direct-child registry. `await rlm.list_subagents()` returns stable child IDs, active-session IDs when daemon-backed, session IDs, names, directories, and running/completed status.
@@ -248,6 +250,7 @@ Provider credentials are resolved by the TypeScript host. The bounded model cata
248250
| Requested model unavailable | Spawn fails instead of substituting another model. |
249251
| Host connection closed | Pending `host_request` calls fail with `RuntimeError` so awaiting cells unblock. |
250252
| Child cancellation | Host aborts the child and removes failed/cancelled registry entries. |
253+
| Parent turn cancelled | Every in-flight child run is cancelled, including one admitted by an earlier turn. Retained child sessions keep running; stop one with `rlm.delete_subagent()` or the interactive per-child stop. |
251254
| Parent teardown | Active descendants are cancelled and their runtimes are closed. |
252255

253256
## Focused Validation

packages/coding-agent/src/core/agent-session.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7462,6 +7462,15 @@ export class AgentSession {
74627462
return this._resourceLoader;
74637463
}
74647464

7465+
/**
7466+
* Cancel the active turn and everything it owns, including every in-flight RLM
7467+
* child run. Every user-facing cancel (interactive ctrl-c, ACP/daemon `abort`,
7468+
* `abort_and_clear_queue`) routes here, so an unfinished spawn run must die with
7469+
* the cancel rather than keep holding provider capacity, even when an earlier
7470+
* turn admitted it. Retained child sessions have already finished their run and
7471+
* stay addressable, so they keep running; cancel one by id with
7472+
* {@link cancelRlmChildRun}.
7473+
*/
74657474
requestAbort(): void {
74667475
this._failDeferredPromptLifecycles();
74677476
for (const run of [...this._unsettledRlmChildRuns]) {
@@ -7490,13 +7499,15 @@ export class AgentSession {
74907499
this._autoRefineReviewAbort?.abort();
74917500
this._refineAbortController?.abort();
74927501
this.agent.abort();
7502+
// After the scheduler suspension above, so cancelled runs are abandoned for
7503+
// quiescence and cannot inject a late terminal notice into the next turn.
7504+
this._cancelActiveRlmChildRuns("Parent session aborted");
74937505
}
74947506

74957507
async abort(): Promise<void> {
74967508
const compactionOperation = this._compactionOperation;
74977509
const branchSummaryOperation = this._branchSummaryOperation;
74987510
this.requestAbort();
7499-
this._cancelActiveRlmChildRuns("Parent session aborted");
75007511
this._goalAbortInProgress = this._goalState.status === "active";
75017512
try {
75027513
await Promise.allSettled([

packages/coding-agent/test/agent-session-recursion.test.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1722,18 +1722,21 @@ describe("AgentSession rlm recursion", () => {
17221722
await root.runRlmChild("finish during ACP close", { name: "paused-terminal-worker" });
17231723
await childStarted.promise;
17241724
const inputPause = root.acquireSessionInputPause();
1725-
root.requestAbort();
1726-
await expect(root.prompt("external prompt", { resumeIfIdle: false })).rejects.toThrow(
1727-
"session input admission is paused",
1728-
);
1729-
1730-
childCompletion.resolve();
17311725
const internals = root as unknown as InspectableRlmSession;
17321726
const deferredNotices = () =>
17331727
root
17341728
.getPendingNextTurnMessageSnapshots()
17351729
.filter((message) => message.customType === "rlm_child_terminal_notice");
1730+
// The child settles under the input pause. requestAbort cancels live child runs
1731+
// and suppresses their notices, so retention across the scheduler cut only
1732+
// applies to a notice that already exists.
1733+
childCompletion.resolve();
17361734
await vi.waitFor(() => expect(deferredNotices()).toHaveLength(1));
1735+
root.requestAbort();
1736+
await expect(root.prompt("external prompt", { resumeIfIdle: false })).rejects.toThrow(
1737+
"session input admission is paused",
1738+
);
1739+
expect(deferredNotices()).toHaveLength(1);
17371740
expect(synthesizedAgentMessageSend).not.toHaveBeenCalled();
17381741
const restartSnapshot = root.getPendingNextTurnMessageSnapshots();
17391742
await vi.waitFor(() => expect(internals._unsettledRlmChildRuns.size).toBe(0));
@@ -3001,7 +3004,7 @@ describe("AgentSession rlm recursion", () => {
30013004
expect(promptAndWait).not.toHaveBeenCalled();
30023005
});
30033006

3004-
it("does not cancel active rlm children when only the parent turn is interrupted", async () => {
3007+
it("cancels active rlm children when the parent turn is interrupted", async () => {
30053008
let releaseChild: () => void = () => {};
30063009
const release = new Promise<void>((resolve) => {
30073010
releaseChild = resolve;
@@ -3025,14 +3028,22 @@ describe("AgentSession rlm recursion", () => {
30253028
await waitFor(() => childStarted);
30263029
const runs = (root as unknown as InspectableRlmSession)._activeRlmChildRuns;
30273030
expect(runs.size).toBe(1);
3028-
const run = [...runs.values()][0];
3031+
const childId = [...runs.keys()][0];
3032+
if (!childId) {
3033+
throw new Error("Missing child run id");
3034+
}
3035+
const run = runs.get(childId);
30293036

30303037
root.requestAbort();
30313038

3032-
expect(run.status).toBe("running");
3033-
expect(run.error).toBeUndefined();
3039+
expect(run?.status).toBe("cancelled");
3040+
expect(run?.error).toBe("Parent session aborted");
3041+
// The cut is authoritative: a cancelled child neither blocks the next strong
3042+
// barrier nor injects a terminal notice into a later turn.
3043+
expect(run?.abandonedForQuiescence).toBe(true);
30343044
releaseChild();
3035-
await waitFor(() => run.status === "done");
3045+
await waitFor(() => !runs.has(childId));
3046+
expect(root.hasRunningRlmChildren()).toBe(false);
30363047
});
30373048

30383049
it("cancels a single rlm child run by id and reports unknown ids", async () => {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { type FauxResponseStep, fauxAssistantMessage } from "@earendil-works/pi-ai";
2+
import { afterEach, describe, expect, it } from "vitest";
3+
import type { CustomMessage } from "../../../src/core/messages.js";
4+
import { createHarness, getAssistantTexts, type Harness } from "../harness.js";
5+
6+
interface Gate {
7+
promise: Promise<void>;
8+
open: () => void;
9+
}
10+
11+
function gate(): Gate {
12+
let open = () => {};
13+
const promise = new Promise<void>((resolve) => {
14+
open = resolve;
15+
});
16+
return { promise, open };
17+
}
18+
19+
/** Faux step that holds the provider stream open until the gate opens or the request aborts. */
20+
function heldResponse(options: { record: () => void; release: Promise<void>; text: string }): FauxResponseStep {
21+
return async (_context, streamOptions) => {
22+
options.record();
23+
const signal = streamOptions?.signal;
24+
await new Promise<void>((resolve) => {
25+
if (signal?.aborted) {
26+
resolve();
27+
return;
28+
}
29+
signal?.addEventListener("abort", () => resolve(), { once: true });
30+
void options.release.then(() => resolve());
31+
});
32+
return fauxAssistantMessage(options.text);
33+
};
34+
}
35+
36+
function terminalNotices(messages: readonly unknown[]): CustomMessage[] {
37+
return messages.filter(
38+
(message): message is CustomMessage =>
39+
typeof message === "object" &&
40+
message !== null &&
41+
(message as { role?: unknown }).role === "custom" &&
42+
((message as { customType?: unknown }).customType === "rlm_child_terminal_notice" ||
43+
(message as { customType?: unknown }).customType === "rlm_child_failure"),
44+
);
45+
}
46+
47+
describe("issue #25 requestAbort cascades into active RLM child runs", () => {
48+
const harnesses: Harness[] = [];
49+
const gates: Gate[] = [];
50+
51+
afterEach(() => {
52+
while (gates.length > 0) {
53+
gates.pop()?.open();
54+
}
55+
while (harnesses.length > 0) {
56+
harnesses.pop()?.cleanup();
57+
}
58+
});
59+
60+
async function createParent(child: Harness): Promise<Harness> {
61+
const parent = await createHarness({
62+
rlmDepth: 0,
63+
rlmMaxDepth: 1,
64+
subagentRuntimeHost: {
65+
createRlmSubagentRuntime: async () => ({ session: child.session }),
66+
deleteRlmSubagentRuntime: async () => {},
67+
},
68+
});
69+
harnesses.push(parent);
70+
return parent;
71+
}
72+
73+
it("terminates an in-flight child provider stream and settles the parent", async () => {
74+
const child = await createHarness();
75+
harnesses.push(child);
76+
const release = gate();
77+
gates.push(release);
78+
let childRequests = 0;
79+
child.setResponses([
80+
heldResponse({
81+
record: () => {
82+
childRequests++;
83+
},
84+
release: release.promise,
85+
text: "child finished after the abort",
86+
}),
87+
fauxAssistantMessage("child must not start a second turn"),
88+
]);
89+
const parent = await createParent(child);
90+
parent.setResponses([fauxAssistantMessage("parent recovered")]);
91+
92+
const spawned = await parent.session.runRlmChild("long shard", { name: "cascade-worker" });
93+
await expect.poll(() => childRequests).toBe(1);
94+
expect(parent.session.getRlmChildRunStatus(spawned.rlm_child_id)).toBe("running");
95+
96+
parent.session.requestAbort();
97+
98+
expect(parent.session.getRlmChildRunStatus(spawned.rlm_child_id)).toBe("cancelled");
99+
// The run unwinds on its own once the child's provider stream is cut.
100+
await expect.poll(() => parent.session.getRlmChildRunStatus(spawned.rlm_child_id)).toBeUndefined();
101+
expect(parent.session.hasRunningRlmChildren()).toBe(false);
102+
expect(child.session.isStreaming).toBe(false);
103+
// No further child request reached the provider.
104+
expect(child.faux.state.callCount).toBe(1);
105+
expect(child.getPendingResponseCount()).toBe(1);
106+
// A cancelled child does not report an outcome to the parent.
107+
expect(terminalNotices(parent.session.messages)).toEqual([]);
108+
expect(parent.session.getPendingNextTurnMessageSnapshots()).toEqual([]);
109+
110+
// The cut leaves no quiescence waiter behind, and the next turn runs.
111+
await expect(parent.session.waitForRlmQuiescence()).resolves.toBeUndefined();
112+
parent.session.resumeQueuedWork();
113+
await parent.session.prompt("what happened?");
114+
await expect(parent.session.waitForRlmQuiescence()).resolves.toBeUndefined();
115+
expect(getAssistantTexts(parent)).toEqual(["parent recovered"]);
116+
});
117+
118+
it("leaves a retained background subagent running", async () => {
119+
const child = await createHarness();
120+
harnesses.push(child);
121+
const release = gate();
122+
gates.push(release);
123+
let childRequests = 0;
124+
child.setResponses([
125+
fauxAssistantMessage("first shard done"),
126+
heldResponse({
127+
record: () => {
128+
childRequests++;
129+
},
130+
release: release.promise,
131+
text: "background work finished",
132+
}),
133+
]);
134+
const parent = await createParent(child);
135+
parent.setResponses([fauxAssistantMessage("parent consumed the child result")]);
136+
137+
const spawned = await parent.session.runRlmChild("first shard", { name: "retained-worker" });
138+
await expect.poll(() => terminalNotices(parent.session.messages)).toHaveLength(1);
139+
const retained = parent.session.getRlmChildSession(spawned.rlm_child_id);
140+
expect(retained).toBe(child.session);
141+
142+
// Retained children stay addressable, so their own work is background work the
143+
// parent turn does not own.
144+
const background = child.session.prompt("keep working in the background");
145+
await expect.poll(() => childRequests).toBe(1);
146+
147+
parent.session.requestAbort();
148+
149+
expect(child.session.isStreaming).toBe(true);
150+
release.open();
151+
await background;
152+
expect(child.session.getLastAssistantText()).toBe("background work finished");
153+
expect(child.faux.state.callCount).toBe(2);
154+
expect(parent.session.getRlmChildSession(spawned.rlm_child_id)).toBe(child.session);
155+
});
156+
});

0 commit comments

Comments
 (0)