Skip to content

Commit b4881a8

Browse files
committed
feat(coding-agent): persist parent edges and derived depth for all sessions
Groundwork for the recursive agent tree (Track A PR 2): every session - live, passive, or saved-only - carries its tree position without a runtime and without inference. - additive rlmDepth field in the session JSONL header (no format bump; old readers ignore it); surfaced through SessionInfo, the saved-session catalog, SessionSummary, and agent-connection types - spawn edges derive child depth = parent depth + 1 at creation; fork and branch COPY the source depth (reference edges, not structural parents): a forked root stays a root - RLM_DEPTH env seeds fresh parentless sessions; persisted header wins for resumed sessions; legacy children resolve depth via the subagent registry fallback, never chain walks at scan time - one shared saved-session-info serializer (deduped from daemon-mode/ daemon-supervisor); DAEMON_SCHEMA_REVISION 9 with regenerated id Track A PR 2 of the recursive-agent-harness plan.
1 parent 79a0c8d commit b4881a8

16 files changed

Lines changed: 371 additions & 66 deletions

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,10 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
344344
async createRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): Promise<RlmSubagentRuntime> {
345345
const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir);
346346
if (options.parentSession.sessionFile) {
347-
sessionManager.newSession({ parentSession: options.parentSession.sessionFile });
347+
sessionManager.newSession({
348+
parentSession: options.parentSession.sessionFile,
349+
rlmDepth: options.rlmDepth,
350+
});
348351
}
349352
const runtime = await this.scopedBuild(() =>
350353
createAgentSessionRuntime(this.createRuntime, {
@@ -570,8 +573,12 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
570573
}
571574
const sessionDir = this.session.sessionManager.getSessionDir();
572575
if (!targetLeafId) {
576+
const sourceHeader = this.session.sessionManager.getHeader();
573577
const sessionManager = SessionManager.create(this.cwd, sessionDir);
574-
sessionManager.newSession({ parentSession: currentSessionFile });
578+
sessionManager.newSession({
579+
parentSession: currentSessionFile,
580+
rlmDepth: sourceHeader?.rlmDepth,
581+
});
575582
const lease = this.acquireReplacementLease(sessionManager.getSessionFile());
576583
await this.teardownForReplacement("fork", sessionManager.getSessionFile(), lease);
577584
await this.buildAndApplyReplacement(
@@ -618,7 +625,11 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
618625

619626
const sessionManager = this.session.sessionManager;
620627
if (!targetLeafId) {
621-
sessionManager.newSession({ parentSession: this.session.sessionFile });
628+
const sourceHeader = sessionManager.getHeader();
629+
sessionManager.newSession({
630+
parentSession: this.session.sessionFile,
631+
rlmDepth: sourceHeader?.rlmDepth,
632+
});
622633
} else {
623634
sessionManager.createBranchedSession(targetLeafId);
624635
}

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,10 @@ export class AgentSession {
12311231
this._mcpManager = config.mcpManager;
12321232
this._baseToolsOverride = config.baseToolsOverride;
12331233
this._sessionStartEvent = config.sessionStartEvent ?? { type: "session_start", reason: "startup" };
1234-
this._rlmDepth = config.rlmDepth ?? parseDepth(process.env.RLM_DEPTH, 0, "RLM_DEPTH");
1234+
this._rlmDepth =
1235+
config.rlmDepth ??
1236+
this.sessionManager.getHeader()?.rlmDepth ??
1237+
parseDepth(process.env.RLM_DEPTH, 0, "RLM_DEPTH");
12351238
this._rlmMaxDepth = config.rlmMaxDepth ?? parseDepth(process.env.RLM_MAX_DEPTH, 1, "RLM_MAX_DEPTH");
12361239
this._prewarmIpythonKernel = (config.prewarmIpythonKernel ?? false) && this._rlmDepth === 0;
12371240
this._autoRefineReviewer = config.autoRefineReviewer;
@@ -3990,6 +3993,11 @@ export class AgentSession {
39903993
return this.sessionManager.getSessionId();
39913994
}
39923995

3996+
/** Current RLM spawn depth for this session. */
3997+
get rlmDepth(): number {
3998+
return this._rlmDepth;
3999+
}
4000+
39934001
/** Current session display name, if set */
39944002
get sessionName(): string | undefined {
39954003
return this.sessionManager.getSessionName();
@@ -8625,7 +8633,10 @@ export class AgentSession {
86258633
private _createInlineRlmSubagentRuntime(options: CreateRlmSubagentRuntimeOptions): RlmSubagentRuntime {
86268634
const childSessionManager = SessionManager.create(this._cwd, options.sessionDir);
86278635
if (options.parentSession.sessionFile) {
8628-
childSessionManager.newSession({ parentSession: options.parentSession.sessionFile });
8636+
childSessionManager.newSession({
8637+
parentSession: options.parentSession.sessionFile,
8638+
rlmDepth: options.rlmDepth,
8639+
});
86298640
}
86308641
childSessionManager.appendModelChange(options.model.provider, options.model.id);
86318642
childSessionManager.appendThinkingLevelChange(options.thinkingLevel);

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,16 @@ export interface SessionHeader {
7979
timestamp: string;
8080
cwd: string;
8181
parentSession?: string;
82+
/** RLM spawn depth. Optional for backward compatibility. Forks preserve the source depth. */
83+
rlmDepth?: number;
8284
git?: GitContext;
8385
}
8486

8587
export interface NewSessionOptions {
8688
id?: string;
8789
parentSession?: string;
90+
/** Explicit RLM spawn depth. An explicitly undefined value suppresses parent derivation. */
91+
rlmDepth?: number;
8892
}
8993

9094
export type SessionPersistListener = (sessionFile: string) => void;
@@ -291,6 +295,8 @@ export interface SessionInfo {
291295
state?: SessionState;
292296
/** Path to the parent session (if this session was forked). */
293297
parentSessionPath?: string;
298+
/** RLM spawn depth; absent when legacy metadata cannot establish it. */
299+
rlmDepth?: number;
294300
created: Date;
295301
modified: Date;
296302
messageCount: number;
@@ -692,6 +698,26 @@ function readSessionHeader(filePath: string): Partial<SessionHeader> | undefined
692698
return JSON.parse(firstLine) as Partial<SessionHeader>;
693699
}
694700

701+
function isValidRlmDepth(value: unknown): value is number {
702+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
703+
}
704+
705+
function deriveChildRlmDepth(parentHeader: Partial<SessionHeader> | undefined): number | undefined {
706+
return isValidRlmDepth(parentHeader?.rlmDepth) ? parentHeader.rlmDepth + 1 : undefined;
707+
}
708+
709+
function rootRlmDepthFromEnv(): number {
710+
const value = process.env.RLM_DEPTH;
711+
if (value === undefined || value === "") {
712+
return 0;
713+
}
714+
const parsed = Number.parseInt(value, 10);
715+
if (!Number.isFinite(parsed) || parsed < 0) {
716+
throw new Error("RLM_DEPTH must be a non-negative integer");
717+
}
718+
return parsed;
719+
}
720+
695721
function isValidSessionFile(filePath: string): boolean {
696722
try {
697723
const header = readSessionHeader(filePath);
@@ -1021,6 +1047,7 @@ async function scanSessionInfo(filePath: string, stats: Awaited<ReturnType<typeo
10211047
if (!header) return null;
10221048
const cwd = typeof header.cwd === "string" ? header.cwd : "";
10231049
const parentSessionPath = header.parentSession;
1050+
const rlmDepth = isValidRlmDepth(header.rlmDepth) ? header.rlmDepth : parentSessionPath ? undefined : 0;
10241051
const modified = getSessionModifiedDateFromLastActivity(lastActivityTime, header, stats.mtime);
10251052

10261053
return {
@@ -1030,6 +1057,7 @@ async function scanSessionInfo(filePath: string, stats: Awaited<ReturnType<typeo
10301057
name,
10311058
state,
10321059
parentSessionPath,
1060+
rlmDepth,
10331061
created: new Date(header.timestamp),
10341062
modified,
10351063
messageCount,
@@ -1177,6 +1205,15 @@ export class SessionManager {
11771205
newSession(options?: NewSessionOptions): string | undefined {
11781206
let sessionId = options?.id ?? createSessionId();
11791207
let sessionFile: string | undefined;
1208+
const hasExplicitRlmDepth = options !== undefined && Object.hasOwn(options, "rlmDepth");
1209+
let parentHeader: Partial<SessionHeader> | undefined;
1210+
if (options?.parentSession && !hasExplicitRlmDepth) {
1211+
try {
1212+
parentHeader = readSessionHeader(options.parentSession);
1213+
} catch {
1214+
// Legacy-invalid or unavailable parents leave the child depth unknown.
1215+
}
1216+
}
11801217
if (this.persist) {
11811218
if (options?.id) {
11821219
sessionFile = getSessionFilePath(this.getSessionDir(), sessionId);
@@ -1193,13 +1230,19 @@ export class SessionManager {
11931230
this.sessionId = sessionId;
11941231
const timestamp = new Date().toISOString();
11951232
const git = this.persist ? (captureGitContext(this.cwd) ?? undefined) : undefined;
1233+
const rlmDepth = hasExplicitRlmDepth
1234+
? options?.rlmDepth
1235+
: options?.parentSession
1236+
? deriveChildRlmDepth(parentHeader)
1237+
: rootRlmDepthFromEnv();
11961238
const header: SessionHeader = {
11971239
type: "session",
11981240
version: CURRENT_SESSION_VERSION,
11991241
id: this.sessionId,
12001242
timestamp,
12011243
cwd: this.cwd,
12021244
parentSession: options?.parentSession,
1245+
rlmDepth,
12031246
git,
12041247
};
12051248
this.fileEntries = [header];
@@ -1305,6 +1348,7 @@ export class SessionManager {
13051348
if (!existsSync(dir)) {
13061349
mkdirSync(dir, { recursive: true });
13071350
}
1351+
const previousHeader = this.getHeader();
13081352
const target = createUniqueSessionFileTarget(dir);
13091353
this.sessionDir = dir;
13101354
this.sessionId = target.sessionId;
@@ -1318,6 +1362,8 @@ export class SessionManager {
13181362
id: this.sessionId,
13191363
timestamp,
13201364
cwd: this.cwd,
1365+
parentSession: previousHeader?.parentSession,
1366+
rlmDepth: previousHeader?.rlmDepth ?? (previousHeader?.parentSession ? undefined : 0),
13211367
git,
13221368
};
13231369
this.fileEntries = [header, ...this.getEntries()];
@@ -1941,6 +1987,7 @@ export class SessionManager {
19411987
timestamp,
19421988
cwd: this.cwd,
19431989
parentSession: this.persist ? previousSessionFile : undefined,
1990+
rlmDepth: this.getHeader()?.rlmDepth,
19441991
git: this.persist ? (captureGitContext(this.cwd) ?? undefined) : undefined,
19451992
};
19461993

@@ -2134,6 +2181,7 @@ export class SessionManager {
21342181
timestamp,
21352182
cwd: targetCwd,
21362183
parentSession: sourcePath,
2184+
rlmDepth: sourceHeader.rlmDepth,
21372185
git: captureGitContext(targetCwd) ?? undefined,
21382186
};
21392187
appendFileSync(newSessionFile, `${JSON.stringify(newHeader)}\n`);

packages/coding-agent/src/modes/agent-connection/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface AgentConnectionSessionHeader {
6060
timestamp: string;
6161
cwd: string;
6262
parentSession?: string;
63+
rlmDepth?: number;
6364
git?: {
6465
repoUrl?: string;
6566
commit?: string;
@@ -122,6 +123,7 @@ export interface AgentConnectionSavedSessionInfo {
122123
name?: string;
123124
state?: AgentConnectionSavedSessionState;
124125
parentSessionPath?: string;
126+
rlmDepth?: number;
125127
created: Date;
126128
modified: Date;
127129
messageCount: number;

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

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ import {
141141
type DaemonCommand,
142142
type DaemonOutbound,
143143
type DaemonResponse,
144-
type DaemonSavedSessionInfo,
145144
type DaemonSessionClosedReason,
146145
type DaemonSessionSnapshot,
147146
type DaemonUpdateRestartManifest,
@@ -186,6 +185,7 @@ import {
186185
SESSION_LEASES_ENABLED_ENV,
187186
} from "./daemon-worker-protocol.js";
188187
import { MutationDrainLatch } from "./mutation-drain-latch.js";
188+
import { serializeSavedSessionInfo } from "./saved-session-info.js";
189189
import {
190190
createSnapshotTranscriptChunks,
191191
SNAPSHOT_TARGET_CHUNK_BYTES,
@@ -1070,9 +1070,9 @@ export class AgentDaemon {
10701070
parentEntry?.sessionFile ??
10711071
passive.rootParentState?.runtime.session.sessionFile ??
10721072
passive.rootInfo?.path,
1073+
rlmDepth: passive.info.rlmDepth ?? passive.entry.rlmDepth,
10731074
rlmChildId: passive.entry.childId,
10741075
rlmParentNodeId: passive.entry.rlmParentNodeId ?? passive.entry.childId,
1075-
...(!passive.rootParentState ? { rlmDepth: passive.entry.rlmDepth } : {}),
10761076
spawnCode: passive.entry.spawnCode,
10771077
};
10781078
});
@@ -2146,7 +2146,10 @@ export class AgentDaemon {
21462146
): Promise<AgentSessionRuntime> {
21472147
const sessionManager = SessionManager.create(options.parentSession.sessionManager.getCwd(), options.sessionDir);
21482148
if (options.parentSession.sessionFile) {
2149-
sessionManager.newSession({ parentSession: options.parentSession.sessionFile });
2149+
sessionManager.newSession({
2150+
parentSession: options.parentSession.sessionFile,
2151+
rlmDepth: options.rlmDepth,
2152+
});
21502153
}
21512154
let stateRef: ActiveSessionState | undefined;
21522155
// Subagents inherit the parent's client env (e.g. herdr pane identity).
@@ -2364,7 +2367,7 @@ export class AgentDaemon {
23642367
},
23652368
},
23662369
rlmSessionDir: entry.sessionDir,
2367-
rlmDepth: entry.rlmDepth ?? 1,
2370+
rlmDepth: entry.rlmDepth,
23682371
rlmMaxDepth: entry.rlmMaxDepth ?? 1,
23692372
rlmParentNodeId: entry.rlmParentNodeId ?? entry.childId,
23702373
},
@@ -5580,23 +5583,6 @@ function hasDaemonOutboundActiveSessionId(
55805583
return "activeSessionId" in message && typeof message.activeSessionId === "string";
55815584
}
55825585

5583-
function serializeSavedSessionInfo(session: SessionInfo): DaemonSavedSessionInfo {
5584-
return {
5585-
path: session.path,
5586-
id: session.id,
5587-
cwd: session.cwd,
5588-
name: session.name,
5589-
state: session.state,
5590-
parentSessionPath: session.parentSessionPath,
5591-
created: session.created.toISOString(),
5592-
modified: session.modified.toISOString(),
5593-
messageCount: session.messageCount,
5594-
firstMessage: session.firstMessage,
5595-
allMessagesText: session.allMessagesText,
5596-
agentStatus: session.agentStatus,
5597-
};
5598-
}
5599-
56005586
export function getChildActiveSessionStates(
56015587
sessions: ReadonlyMap<string, ActiveSessionState>,
56025588
parentState: ActiveSessionState,

packages/coding-agent/src/modes/daemon/daemon-protocol.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ export const DAEMON_PROTOCOL_NAME = "prime-agent.daemon";
5252
export const DAEMON_PROTOCOL_VERSION = 7;
5353
export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7;
5454
// Revision 9 publishes persisted RLM spawn depth on passive session rows.
55-
export const DAEMON_SCHEMA_REVISION = 9;
56-
export const DAEMON_SCHEMA_ID = "protocol-7-schema-9-b56e29842cfa";
55+
// Revision 10 publishes persisted RLM spawn depth on all session catalog rows.
56+
export const DAEMON_SCHEMA_REVISION = 10;
57+
export const DAEMON_SCHEMA_ID = "protocol-7-schema-10-37a654228732";
5758

5859
export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME;
5960
export type DaemonProtocolVersion = number;
@@ -779,6 +780,7 @@ export interface DaemonSavedSessionInfo {
779780
name?: string;
780781
state?: AgentConnectionSavedSessionState;
781782
parentSessionPath?: string;
783+
rlmDepth?: number;
782784
created: string;
783785
modified: string;
784786
messageCount: number;

packages/coding-agent/src/modes/daemon/daemon-session-list.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export interface SessionSummary {
3232
isSessionActive: boolean;
3333
hasActiveHeartbeat?: boolean;
3434
runtimeKind?: "top-level" | "subagent";
35-
/** RLM spawn depth for persisted passive subagent rows. */
35+
/** RLM spawn depth (0 for roots); fork edges preserve the source depth. */
3636
rlmDepth?: number;
3737
activeSessionId?: string;
3838
sessionId: string;
@@ -167,6 +167,7 @@ export function summaryForActiveSession(
167167
isSessionActive: session.isSessionActive,
168168
hasActiveHeartbeat: hasActiveHeartbeat || undefined,
169169
runtimeKind: metadata.kind,
170+
rlmDepth: session.rlmDepth,
170171
activeSessionId: activeSession.activeSessionId,
171172
sessionId: session.sessionId,
172173
sessionFile: session.sessionFile,
@@ -238,6 +239,7 @@ export function summaryForInactiveSession(session: SessionInfo): SessionSummary
238239
modified: session.modified.toISOString(),
239240
firstMessage: session.firstMessage,
240241
parentSessionPath: session.parentSessionPath,
242+
rlmDepth: session.rlmDepth,
241243
// Carry the persisted recap/verdict so an off-daemon session keeps its
242244
// agents-view bucket (e.g. Completed) instead of defaulting to Needs Input.
243245
// Gate on message-count currency like isSummaryCurrent does for resident

packages/coding-agent/src/modes/daemon/daemon-supervisor.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ import {
5656
type DaemonCommand,
5757
type DaemonOutbound,
5858
type DaemonResponse,
59-
type DaemonSavedSessionInfo,
6059
type DaemonUpdateRestartManifest,
6160
failure,
6261
isDaemonCommandEnvelope,
@@ -100,6 +99,7 @@ import {
10099
SESSION_LEASES_ENABLED_ENV,
101100
} from "./daemon-worker-protocol.js";
102101
import { MutationDrainLatch } from "./mutation-drain-latch.js";
102+
import { serializeSavedSessionInfo } from "./saved-session-info.js";
103103
import { SNAPSHOT_TARGET_CHUNK_BYTES, SnapshotTranscriptCache } from "./snapshot-transcript-cache.js";
104104
import { WorkerRecoveryJournal } from "./worker-recovery-journal.js";
105105

@@ -456,23 +456,6 @@ function sortCronJobs(jobs: AgentCronJob[]): AgentCronJob[] {
456456
});
457457
}
458458

459-
function serializeSavedSessionInfo(session: SessionInfo): DaemonSavedSessionInfo {
460-
return {
461-
path: session.path,
462-
id: session.id,
463-
cwd: session.cwd,
464-
name: session.name,
465-
state: session.state,
466-
parentSessionPath: session.parentSessionPath,
467-
created: session.created.toISOString(),
468-
modified: session.modified.toISOString(),
469-
messageCount: session.messageCount,
470-
firstMessage: session.firstMessage,
471-
allMessagesText: session.allMessagesText,
472-
agentStatus: session.agentStatus,
473-
};
474-
}
475-
476459
function descriptorKey(socketPath: string): string {
477460
return createHash("sha256").update(socketPath).digest("hex").slice(0, 12);
478461
}

0 commit comments

Comments
 (0)