Skip to content

Commit 6c162e7

Browse files
committed
fix(coding-agent): correct rlmDepth semantics on reference edges, env parsing, and legacy fallbacks
- Finding 8: new-session parent links were deriving spawn depth; copy the source header depth across the reference edge and add a runtime regression test (origin: PR #584). - Finding 9: first-entry forks of legacy sessions discarded effective runtime depth; fall back to session.rlmDepth in persisted and in-memory branches and test both (origin: PR #584). - Finding 10: root RLM_DEPTH parsing accepted malformed and unsafe values; require decimal digits and a valid safe non-negative integer, with probe cases as tests (origin: PR #584). - Finding 11: child-depth derivation could overflow MAX_SAFE_INTEGER; guard the increment and test the boundary (origin: PR #584). - Finding 12: daemon children of in-memory parents skipped explicit depth persistence; always create the child header with explicit depth and test --no-session ancestry (origin: PR #584). - Finding 13: legacy completed subagents rehydrated at root depth; restore the depth-one fallback and test a registry/header pair without depth (origin: PR #584).
1 parent b31f4b9 commit 6c162e7

6 files changed

Lines changed: 177 additions & 16 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,10 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
512512
const sessionDir = this.session.sessionManager.getSessionDir();
513513
const sessionManager = SessionManager.create(this.cwd, sessionDir);
514514
if (options?.parentSession) {
515-
sessionManager.newSession({ parentSession: options.parentSession });
515+
sessionManager.newSession({
516+
parentSession: options.parentSession,
517+
rlmDepth: this.session.sessionManager.getHeader()?.rlmDepth,
518+
});
516519
}
517520
const lease = this.acquireReplacementLease(sessionManager.getSessionFile());
518521

@@ -577,7 +580,7 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
577580
const sessionManager = SessionManager.create(this.cwd, sessionDir);
578581
sessionManager.newSession({
579582
parentSession: currentSessionFile,
580-
rlmDepth: sourceHeader?.rlmDepth,
583+
rlmDepth: sourceHeader?.rlmDepth ?? this.session.rlmDepth,
581584
});
582585
const lease = this.acquireReplacementLease(sessionManager.getSessionFile());
583586
await this.teardownForReplacement("fork", sessionManager.getSessionFile(), lease);
@@ -628,7 +631,7 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
628631
const sourceHeader = sessionManager.getHeader();
629632
sessionManager.newSession({
630633
parentSession: this.session.sessionFile,
631-
rlmDepth: sourceHeader?.rlmDepth,
634+
rlmDepth: sourceHeader?.rlmDepth ?? this.session.rlmDepth,
632635
});
633636
} else {
634637
sessionManager.createBranchedSession(targetLeafId);

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -703,16 +703,17 @@ function isValidRlmDepth(value: unknown): value is number {
703703
}
704704

705705
function deriveChildRlmDepth(parentHeader: Partial<SessionHeader> | undefined): number | undefined {
706-
return isValidRlmDepth(parentHeader?.rlmDepth) ? parentHeader.rlmDepth + 1 : undefined;
706+
const depth = parentHeader?.rlmDepth;
707+
return isValidRlmDepth(depth) && depth < Number.MAX_SAFE_INTEGER ? depth + 1 : undefined;
707708
}
708709

709710
function rootRlmDepthFromEnv(): number {
710711
const value = process.env.RLM_DEPTH;
711712
if (value === undefined || value === "") {
712713
return 0;
713714
}
714-
const parsed = Number.parseInt(value, 10);
715-
if (!Number.isFinite(parsed) || parsed < 0) {
715+
const parsed = Number(value);
716+
if (!/^\d+$/.test(value) || !isValidRlmDepth(parsed)) {
716717
throw new Error("RLM_DEPTH must be a non-negative integer");
717718
}
718719
return parsed;

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

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2179,12 +2179,10 @@ export class AgentDaemon {
21792179
options: CreateRlmSubagentRuntimeOptions,
21802180
): Promise<AgentSessionRuntime> {
21812181
const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir);
2182-
if (options.parentSession.sessionFile) {
2183-
sessionManager.newSession({
2184-
parentSession: options.parentSession.sessionFile,
2185-
rlmDepth: options.rlmDepth,
2186-
});
2187-
}
2182+
sessionManager.newSession({
2183+
parentSession: options.parentSession.sessionFile,
2184+
rlmDepth: options.rlmDepth,
2185+
});
21882186
let stateRef: ActiveSessionState | undefined;
21892187
// Subagents inherit the parent's client env (e.g. herdr pane identity).
21902188
const runtime = await withClientEnv(parentState.clientEnv, () =>
@@ -2530,7 +2528,7 @@ export class AgentDaemon {
25302528
},
25312529
},
25322530
rlmSessionDir: entry.sessionDir,
2533-
rlmDepth: entry.rlmDepth,
2531+
rlmDepth: entry.rlmDepth ?? 1,
25342532
rlmMaxDepth: entry.rlmMaxDepth,
25352533
rlmParentNodeId: entry.rlmParentNodeId ?? entry.childId,
25362534
},

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,58 @@ describe("daemon mode helpers", () => {
483483
}
484484
});
485485

486+
it("persists explicit child depth for an in-memory daemon parent", async () => {
487+
const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-in-memory-parent-depth-"));
488+
try {
489+
const createRuntime = vi.fn(async (options: Parameters<CreateAgentSessionRuntimeFactory>[0]) => ({
490+
session: makeRuntimeSession(options.sessionManager),
491+
extensionsResult: { extensions: [], errors: [], runtime: {} } as unknown as Awaited<
492+
ReturnType<CreateAgentSessionRuntimeFactory>
493+
>["extensionsResult"],
494+
services: { cwd: options.cwd, agentDir: options.agentDir } as Awaited<
495+
ReturnType<CreateAgentSessionRuntimeFactory>
496+
>["services"],
497+
diagnostics: [],
498+
}));
499+
const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), {
500+
defaultSessionConfig: { agentDir: tempDir, cwd: tempDir, sessionDir: join(tempDir, "sessions") },
501+
createRuntime,
502+
});
503+
const internals = daemon as unknown as {
504+
createRuntime(command: Extract<DaemonCommand, { type: "create" }>): Promise<ActiveSessionState>;
505+
createRlmSubagentRuntime(
506+
parentState: ActiveSessionState,
507+
options: CreateRlmSubagentRuntimeOptions,
508+
): Promise<ActiveSessionState["runtime"]>;
509+
};
510+
const parentState = await internals.createRuntime({ type: "create", noSession: true });
511+
const child = await internals.createRlmSubagentRuntime(parentState, {
512+
parentSession: parentState.runtime.session,
513+
id: "child-1",
514+
prompt: "persist depth",
515+
sessionName: "depth-child",
516+
sessionDir: join(tempDir, "child"),
517+
model: {} as Model<Api>,
518+
thinkingLevel: "off",
519+
serviceTier: null,
520+
scopedModels: [],
521+
activeToolNames: [],
522+
customTools: [],
523+
includeGoals: false,
524+
includeCompactSkill: false,
525+
rlmDepth: 1,
526+
rlmMaxDepth: 2,
527+
rlmParentNodeId: "child-1",
528+
});
529+
530+
expect(parentState.runtime.session.sessionFile).toBeUndefined();
531+
expect(child.session.sessionManager.getHeader()).toMatchObject({ rlmDepth: 1 });
532+
expect(child.session.sessionManager.getHeader()?.parentSession).toBeUndefined();
533+
} finally {
534+
rmSync(tempDir, { recursive: true, force: true });
535+
}
536+
});
537+
486538
it("defers RLM heartbeats while a subagent is binding", async () => {
487539
const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-binding-heartbeat-"));
488540
let releaseChildBinding: (() => void) | undefined;
@@ -3943,6 +3995,42 @@ describe("daemon mode helpers", () => {
39433995
}
39443996
});
39453997

3998+
it("rehydrates a legacy passive subagent at depth one", async () => {
3999+
const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-legacy-rlm-depth-"));
4000+
try {
4001+
const fixture = makePersistedRlmDaemonFixture(tempDir);
4002+
const childLines = readFileSync(fixture.childSessionFile, "utf8").split("\n");
4003+
const childHeader = JSON.parse(childLines[0] ?? "{}") as Record<string, unknown>;
4004+
delete childHeader.rlmDepth;
4005+
childLines[0] = JSON.stringify(childHeader);
4006+
writeFileSync(fixture.childSessionFile, childLines.join("\n"));
4007+
4008+
const registryPath = join(fixture.parentArtifactDir, "rlm-subagents.jsonl");
4009+
const registryEntry = JSON.parse(readFileSync(registryPath, "utf8").trim()) as Record<string, unknown>;
4010+
delete registryEntry.rlmDepth;
4011+
writeFileSync(registryPath, `${JSON.stringify(registryEntry)}\n`);
4012+
4013+
const internals = fixture.daemon as unknown as {
4014+
createRuntime(command: Extract<DaemonCommand, { type: "create" }>): Promise<ActiveSessionState>;
4015+
createAgentMessageController(
4016+
getCurrentState: () => ActiveSessionState | undefined,
4017+
): AgentSessionMessageController;
4018+
};
4019+
const parentState = await internals.createRuntime({
4020+
type: "create",
4021+
sessionPath: fixture.parentSessionFile,
4022+
});
4023+
4024+
await internals
4025+
.createAgentMessageController(() => parentState)
4026+
.sendAgentMessage({ target: "renamed-worker", message: "report progress" });
4027+
4028+
expect(fixture.createRuntime.mock.calls[1]?.[0].sessionOptions?.rlmDepth).toBe(1);
4029+
} finally {
4030+
rmSync(tempDir, { recursive: true, force: true });
4031+
}
4032+
});
4033+
39464034
it("hydrates only the ancestor chain when a nested passive child is messaged", async () => {
39474035
const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-lazy-nested-message-"));
39484036
try {

‎packages/coding-agent/test/session-manager/file-operations.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,37 @@ describe("loadEntriesFromFile", () => {
157157
});
158158

159159
describe("session tree metadata", () => {
160+
it.each(["2.5", "2oops", "9007199254740993"])("rejects invalid RLM_DEPTH value %s", (value) => {
161+
const tempDir = join(tmpdir(), `invalid-root-depth-test-${Date.now()}-${Math.random()}`);
162+
mkdirSync(tempDir, { recursive: true });
163+
vi.stubEnv("RLM_DEPTH", value);
164+
try {
165+
expect(() => SessionManager.create(tempDir, tempDir)).toThrow("RLM_DEPTH must be a non-negative integer");
166+
} finally {
167+
vi.unstubAllEnvs();
168+
rmSync(tempDir, { recursive: true, force: true });
169+
}
170+
});
171+
172+
it("does not persist an unsafe derived depth", () => {
173+
const tempDir = join(tmpdir(), `max-parent-depth-test-${Date.now()}-${Math.random()}`);
174+
mkdirSync(tempDir, { recursive: true });
175+
try {
176+
const parent = SessionManager.create(tempDir, tempDir);
177+
parent.newSession({ rlmDepth: Number.MAX_SAFE_INTEGER });
178+
parent.flushNow();
179+
const parentFile = parent.getSessionFile();
180+
if (!parentFile) throw new Error("Missing parent session file");
181+
182+
const child = SessionManager.create(tempDir, tempDir);
183+
child.newSession({ parentSession: parentFile });
184+
185+
expect(child.getHeader()?.rlmDepth).toBeUndefined();
186+
} finally {
187+
rmSync(tempDir, { recursive: true, force: true });
188+
}
189+
});
190+
160191
it("persists a derived depth and exposes parent linkage from the header", async () => {
161192
const tempDir = join(tmpdir(), `session-tree-test-${Date.now()}-${Math.random()}`);
162193
mkdirSync(tempDir, { recursive: true });

‎packages/coding-agent/test/suite/agent-session-runtime.test.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ describe("AgentSessionRuntime characterization", () => {
5252
bootstrapThinkingLevel?: boolean;
5353
inMemory?: boolean;
5454
sessionConfig?: AgentSessionRuntimeConfig;
55+
sessionManager?: SessionManager;
56+
sessionOptions?: Parameters<CreateAgentSessionRuntimeFactory>[0]["sessionOptions"];
5557
onCreateRuntime?: (options: Parameters<CreateAgentSessionRuntimeFactory>[0]) => void;
5658
},
5759
) {
@@ -115,6 +117,7 @@ describe("AgentSessionRuntime characterization", () => {
115117
sessionStartEvent,
116118
model: serviceOptions.model,
117119
thinkingLevel: serviceOptions.thinkingLevel,
120+
...runtimeOptions.sessionOptions,
118121
})),
119122
services,
120123
diagnostics: services.diagnostics,
@@ -123,10 +126,13 @@ describe("AgentSessionRuntime characterization", () => {
123126
const runtime = await createAgentSessionRuntime(createRuntime, {
124127
cwd: tempDir,
125128
agentDir: tempDir,
126-
sessionManager: options?.inMemory
127-
? SessionManager.inMemory(tempDir)
128-
: SessionManager.create(tempDir, join(tempDir, "sessions")),
129+
sessionManager:
130+
options?.sessionManager ??
131+
(options?.inMemory
132+
? SessionManager.inMemory(tempDir)
133+
: SessionManager.create(tempDir, join(tempDir, "sessions"))),
129134
sessionConfig: options?.sessionConfig,
135+
sessionOptions: options?.sessionOptions,
130136
});
131137
await runtime.session.bindExtensions({});
132138

@@ -183,6 +189,38 @@ describe("AgentSessionRuntime characterization", () => {
183189
expect(calls[1]?.sessionConfig).toBe(sessionConfig);
184190
});
185191

192+
it("copies depth across new-session parent reference edges", async () => {
193+
const { runtime } = await createRuntimeForTest(() => {});
194+
const parentSession = runtime.session.sessionFile;
195+
if (!parentSession) throw new Error("Missing parent session file");
196+
197+
await runtime.newSession({ parentSession });
198+
199+
expect(runtime.session.sessionManager.getHeader()).toMatchObject({ parentSession, rlmDepth: 0 });
200+
});
201+
202+
it.each([false, true])(
203+
"uses the effective runtime depth when forking a legacy session before its first entry (inMemory=%s)",
204+
async (inMemory) => {
205+
const tempDir = join(tmpdir(), `pi-runtime-legacy-fork-${Date.now()}-${Math.random().toString(36).slice(2)}`);
206+
const sessionManager = inMemory
207+
? SessionManager.inMemory(tempDir)
208+
: SessionManager.create(tempDir, join(tempDir, "sessions"));
209+
sessionManager.newSession({ rlmDepth: undefined });
210+
const firstEntry = sessionManager.appendMessage({ role: "user", content: "fork here", timestamp: 1 });
211+
const { runtime } = await createRuntimeForTest(() => {}, {
212+
cwd: tempDir,
213+
sessionManager,
214+
sessionOptions: { rlmDepth: 2 },
215+
});
216+
217+
await runtime.fork(firstEntry);
218+
219+
expect(runtime.session.sessionManager.getHeader()?.rlmDepth).toBe(2);
220+
expect(runtime.session.rlmDepth).toBe(2);
221+
},
222+
);
223+
186224
it("disposes a runtime only once across repeated teardown calls", async () => {
187225
const shutdownEvents: SessionShutdownEvent[] = [];
188226
const beforeInvalidate = vi.fn();
@@ -694,6 +732,7 @@ describe("AgentSessionRuntime characterization", () => {
694732
});
695733
await otherRuntime.session.prompt("other");
696734
const otherSessionFile = otherRuntime.session.sessionFile!;
735+
await otherRuntime.dispose();
697736

698737
await runtime.switchSession(otherSessionFile);
699738

@@ -769,6 +808,7 @@ describe("AgentSessionRuntime characterization", () => {
769808
otherRuntime.session.setThinkingLevel("off");
770809
await otherRuntime.session.prompt("hello");
771810
const targetSessionFile = otherRuntime.session.sessionFile!;
811+
await otherRuntime.dispose();
772812

773813
await runtime.switchSession(targetSessionFile);
774814

0 commit comments

Comments
 (0)