Skip to content

Commit 7f57ccf

Browse files
committed
fix(coding-agent): leave shared passive hydration resident on guard cancellation
The ownership-blind close closed shared hydrations out from under concurrent wakes (both interleavings); the idle sweep is the ownership-free arbiter. origin: round-4 fix b956400 / PR #583
1 parent 2ec6e39 commit 7f57ccf

2 files changed

Lines changed: 135 additions & 26 deletions

File tree

‎packages/coding-agent/src/modes/daemon/daemon-mode.ts‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,7 +1323,6 @@ export class AgentDaemon {
13231323
if (passiveSubagent.rootParentState) this.adoptClientEnv(passiveSubagent.rootParentState, clientEnv);
13241324
const state = await this.hydratePassiveRlmSubagent(passiveSubagent);
13251325
if (runtimeOpenGuard && !(await runtimeOpenGuard())) {
1326-
await this.closeCancelledPassiveHydration(state);
13271326
throw new RuntimeOpenCancelledError();
13281327
}
13291328
if (command.name) {
@@ -1997,7 +1996,6 @@ export class AgentDaemon {
19971996
? await this.waitForBoundSession(resident)
19981997
: undefined;
19991998
if (passiveSubagent && childState && this.getRunnableCronJob(job.id) === undefined) {
2000-
await this.closeCancelledPassiveHydration(childState);
20011999
throw new RuntimeOpenCancelledError();
20022000
}
20032001
if (!childState || childState.runtime.metadata.kind !== "subagent") {
@@ -2549,16 +2547,6 @@ export class AgentDaemon {
25492547
}
25502548
}
25512549

2552-
private async closeCancelledPassiveHydration(state: ActiveSessionState): Promise<void> {
2553-
const metadata = state.runtime.metadata;
2554-
if (metadata.kind === "subagent" && metadata.parentActiveSessionId && metadata.rlmChildId) {
2555-
this.sessions
2556-
.get(metadata.parentActiveSessionId)
2557-
?.runtime.session.releaseFinishedRlmChildSession(metadata.rlmChildId, state.runtime.session);
2558-
}
2559-
await this.closeSession(state, "shutdown", true, false);
2560-
}
2561-
25622550
private async waitForBoundSession(state: ActiveSessionState): Promise<ActiveSessionState> {
25632551
const completion = this.bindingCompletions.get(state.activeSessionId);
25642552
if (completion) {

‎packages/coding-agent/test/daemon-mode.test.ts‎

Lines changed: 135 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4845,7 +4845,7 @@ describe("daemon mode helpers", () => {
48454845
}
48464846
});
48474847

4848-
it("closes passive hydration when a runtime-open guard is cancelled during hydration", async () => {
4848+
it("leaves passive hydration resident when its runtime-open guard is cancelled", async () => {
48494849
const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-guarded-hydration-"));
48504850
let releaseHydration!: () => void;
48514851
const hydrationGate = new Promise<void>((resolve) => {
@@ -4861,27 +4861,148 @@ describe("daemon mode helpers", () => {
48614861
childRuntimeGate: hydrationGate,
48624862
});
48634863
const internals = fixture.daemon as unknown as {
4864+
cronStore: AgentCronJobStore;
48644865
sessions: Map<string, ActiveSessionState>;
4865-
createRuntime(
4866-
command: Extract<DaemonCommand, { type: "create" }>,
4867-
guard?: () => boolean,
4868-
): Promise<ActiveSessionState>;
4866+
createRuntime(command: Extract<DaemonCommand, { type: "create" }>): Promise<ActiveSessionState>;
4867+
getRunnableCronJob(id: string): AgentCronJob | undefined;
4868+
runCronJob(job: AgentCronJob): Promise<"skipped" | undefined>;
4869+
passivateIdleChildren(threshold: number, now: number, limit: number): Promise<number>;
48694870
};
4870-
await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile });
4871+
const parentState = await internals.createRuntime({
4872+
type: "create",
4873+
sessionPath: fixture.parentSessionFile,
4874+
});
4875+
const parentSession = parentState.runtime.session as unknown as {
4876+
releaseFinishedRlmChildSession: ReturnType<typeof vi.fn>;
4877+
};
4878+
parentSession.releaseFinishedRlmChildSession = vi.fn(() => true);
4879+
const childInfo = await readSessionInfo(fixture.childSessionFile);
4880+
if (!childInfo) throw new Error("Missing child session info");
4881+
const heartbeat = internals.cronStore.createRlmHeartbeat({
4882+
activeSessionId: "stale-child-active-id",
4883+
sessionId: childInfo.id,
4884+
sessionFile: fixture.childSessionFile,
4885+
cwd: tempDir,
4886+
runtimeKind: "subagent",
4887+
scheduleText: "every 30s",
4888+
prompt: "must not run",
4889+
now: new Date("2026-01-01T00:00:00.000Z"),
4890+
});
48714891
let runnable = true;
4872-
const opening = internals.createRuntime(
4873-
{ type: "create", sessionPath: fixture.childSessionFile },
4874-
() => runnable,
4875-
);
4892+
internals.getRunnableCronJob = vi.fn(() => (runnable ? heartbeat : undefined));
4893+
4894+
const run = internals.runCronJob(heartbeat);
48764895
await hydrationStarted;
48774896
runnable = false;
48784897
releaseHydration();
48794898

4880-
await expect(opening).rejects.toThrow();
4881-
expect(
4882-
[...internals.sessions.values()].some((state) => state.runtime.metadata.rlmChildId === fixture.childId),
4883-
).toBe(false);
4899+
await expect(run).resolves.toBe("skipped");
4900+
internals.cronStore.cancel(heartbeat.id);
4901+
const childState = [...internals.sessions.values()].find(
4902+
(state) => state.runtime.metadata.rlmChildId === fixture.childId,
4903+
);
4904+
expect(childState).toBeDefined();
4905+
expect(fixture.runtimeSessions[1]?.disposeAsync).not.toHaveBeenCalled();
4906+
expect(parentSession.releaseFinishedRlmChildSession).not.toHaveBeenCalled();
4907+
4908+
await expect(internals.passivateIdleChildren(90, Date.parse("2036-08-01T12:00:00Z"), 1)).resolves.toBe(1);
4909+
expect(internals.sessions.has(childState!.activeSessionId)).toBe(false);
48844910
expect(fixture.runtimeSessions[1]?.disposeAsync).toHaveBeenCalledOnce();
4911+
expect(parentSession.releaseFinishedRlmChildSession).toHaveBeenCalledWith(
4912+
fixture.childId,
4913+
childState!.runtime.session,
4914+
);
4915+
} finally {
4916+
releaseHydration();
4917+
rmSync(tempDir, { recursive: true, force: true });
4918+
}
4919+
});
4920+
4921+
it("keeps an attach-owned passive hydration usable when a joining heartbeat is cancelled", async () => {
4922+
const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-shared-guarded-hydration-"));
4923+
let releaseHydration!: () => void;
4924+
const hydrationGate = new Promise<void>((resolve) => {
4925+
releaseHydration = resolve;
4926+
});
4927+
let markHydrationStarted!: () => void;
4928+
const hydrationStarted = new Promise<void>((resolve) => {
4929+
markHydrationStarted = resolve;
4930+
});
4931+
let markHeartbeatJoinStarted!: () => void;
4932+
const heartbeatJoinStarted = new Promise<void>((resolve) => {
4933+
markHeartbeatJoinStarted = resolve;
4934+
});
4935+
try {
4936+
const fixture = makePersistedRlmDaemonFixture(tempDir, {
4937+
childRuntimeStarted: markHydrationStarted,
4938+
childRuntimeGate: hydrationGate,
4939+
});
4940+
const internals = fixture.daemon as unknown as {
4941+
cronStore: AgentCronJobStore;
4942+
sessions: Map<string, ActiveSessionState>;
4943+
createRuntime(command: Extract<DaemonCommand, { type: "create" }>): Promise<ActiveSessionState>;
4944+
createAgentMessageController(
4945+
getCurrentState: () => ActiveSessionState | undefined,
4946+
): AgentSessionMessageController;
4947+
getRunnableCronJob(id: string): AgentCronJob | undefined;
4948+
runCronJob(job: AgentCronJob): Promise<"skipped" | undefined>;
4949+
hydratePassiveRlmSubagent(passive: unknown): Promise<ActiveSessionState>;
4950+
};
4951+
const parentState = await internals.createRuntime({
4952+
type: "create",
4953+
sessionPath: fixture.parentSessionFile,
4954+
});
4955+
const parentSession = parentState.runtime.session as unknown as {
4956+
releaseFinishedRlmChildSession: ReturnType<typeof vi.fn>;
4957+
retainFinishedRlmChildSession: ReturnType<typeof vi.fn>;
4958+
};
4959+
parentSession.releaseFinishedRlmChildSession = vi.fn(() => true);
4960+
const childInfo = await readSessionInfo(fixture.childSessionFile);
4961+
if (!childInfo) throw new Error("Missing child session info");
4962+
const heartbeat = internals.cronStore.createRlmHeartbeat({
4963+
activeSessionId: "stale-child-active-id",
4964+
sessionId: childInfo.id,
4965+
sessionFile: fixture.childSessionFile,
4966+
cwd: tempDir,
4967+
runtimeKind: "subagent",
4968+
scheduleText: "every 30s",
4969+
prompt: "must not run",
4970+
now: new Date("2026-01-01T00:00:00.000Z"),
4971+
});
4972+
let runnable = true;
4973+
internals.getRunnableCronJob = vi.fn(() => (runnable ? heartbeat : undefined));
4974+
4975+
const attach = internals.createRuntime({ type: "create", sessionPath: fixture.childSessionFile });
4976+
await hydrationStarted;
4977+
const hydratePassive = internals.hydratePassiveRlmSubagent.bind(fixture.daemon);
4978+
internals.hydratePassiveRlmSubagent = vi.fn(async (passive) => {
4979+
markHeartbeatJoinStarted();
4980+
return hydratePassive(passive);
4981+
});
4982+
const run = internals.runCronJob(heartbeat);
4983+
await heartbeatJoinStarted;
4984+
runnable = false;
4985+
releaseHydration();
4986+
4987+
const attachedState = await attach;
4988+
await expect(run).resolves.toBe("skipped");
4989+
expect(internals.sessions.get(attachedState.activeSessionId)).toBe(attachedState);
4990+
expect(fixture.runtimeSessions[1]?.disposeAsync).not.toHaveBeenCalled();
4991+
expect(parentSession.releaseFinishedRlmChildSession).not.toHaveBeenCalled();
4992+
expect(parentSession.retainFinishedRlmChildSession).toHaveBeenCalledWith(
4993+
fixture.childId,
4994+
attachedState.runtime.session,
4995+
);
4996+
4997+
await expect(
4998+
internals
4999+
.createAgentMessageController(() => parentState)
5000+
.sendAgentMessage({ target: fixture.childId, message: "still usable" }),
5001+
).resolves.toMatchObject({ deliveryStatus: "delivered", target: { runtimeKind: "subagent" } });
5002+
expect(fixture.acceptAgentMessagePrompt).toHaveBeenCalledWith(
5003+
expect.stringContaining("still usable"),
5004+
expect.any(Object),
5005+
);
48855006
} finally {
48865007
releaseHydration();
48875008
rmSync(tempDir, { recursive: true, force: true });

0 commit comments

Comments
 (0)