Skip to content

Commit dbba695

Browse files
committed
fix(memory-host-sdk): include parent session cron lineage
1 parent 1e2cebb commit dbba695

4 files changed

Lines changed: 133 additions & 24 deletions

File tree

packages/memory-host-sdk/src/host/session-files.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,56 @@ describe("listSessionTranscriptCorpusEntriesForAgent", () => {
162162
});
163163
});
164164

165+
it("classifies active entries through parentSessionKey cron ancestry", async () => {
166+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
167+
fsSync.mkdirSync(sessionsDir, { recursive: true });
168+
const cronPath = path.join(sessionsDir, "cron-run.jsonl");
169+
const childPath = path.join(sessionsDir, "cron-child.jsonl");
170+
const grandchildPath = path.join(sessionsDir, "cron-grandchild.jsonl");
171+
for (const filePath of [cronPath, childPath, grandchildPath]) {
172+
fsSync.writeFileSync(filePath, "");
173+
}
174+
fsSync.writeFileSync(
175+
path.join(sessionsDir, "sessions.json"),
176+
JSON.stringify({
177+
"agent:main:cron:job-1:run:run-1": {
178+
sessionFile: "cron-run.jsonl",
179+
sessionId: "cron-run",
180+
},
181+
"agent:main:child:one": {
182+
parentSessionKey: "agent:main:cron:job-1:run:run-1",
183+
sessionFile: "cron-child.jsonl",
184+
sessionId: "cron-child",
185+
},
186+
"agent:main:child:two": {
187+
parentSessionKey: "agent:main:child:one",
188+
sessionFile: "cron-grandchild.jsonl",
189+
sessionId: "cron-grandchild",
190+
},
191+
}),
192+
);
193+
194+
const classification = loadSessionTranscriptClassificationForAgent("main");
195+
196+
expect(classification.cronRunTranscriptPaths).toEqual(
197+
new Set([cronPath, childPath, grandchildPath].map((filePath) => path.resolve(filePath))),
198+
);
199+
await expect(listSessionTranscriptCorpusEntriesForAgent("main")).resolves.toEqual(
200+
expect.arrayContaining([
201+
expect.objectContaining({
202+
generatedByCronRun: true,
203+
sessionFile: childPath,
204+
sessionKey: "agent:main:child:one",
205+
}),
206+
expect.objectContaining({
207+
generatedByCronRun: true,
208+
sessionFile: grandchildPath,
209+
sessionKey: "agent:main:child:two",
210+
}),
211+
]),
212+
);
213+
});
214+
165215
it("keeps archive classification when the active transcript is missing", async () => {
166216
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
167217
fsSync.mkdirSync(sessionsDir, { recursive: true });

packages/memory-host-sdk/src/host/session-files.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ export type ResolvedSessionTranscriptIdentity = {
9191
type SessionTranscriptStoreEntry = {
9292
sessionFile?: unknown;
9393
sessionId?: unknown;
94+
/** Explicit parent session key for current child-session linkage. */
95+
parentSessionKey?: unknown;
9496
/** Parent session key that spawned this session, for cron-parentage chain walks. */
9597
spawnedBy?: unknown;
9698
};
@@ -248,6 +250,20 @@ function resolveSessionStoreTranscriptResolvedPath(
248250
return null;
249251
}
250252

253+
function readParentSessionKeys(entry: SessionTranscriptStoreEntry | undefined): string[] {
254+
const keys = new Set<string>();
255+
for (const value of [entry?.parentSessionKey, entry?.spawnedBy]) {
256+
if (typeof value !== "string") {
257+
continue;
258+
}
259+
const trimmed = value.trim();
260+
if (trimmed) {
261+
keys.add(trimmed);
262+
}
263+
}
264+
return [...keys];
265+
}
266+
251267
function extractAgentIdFromSessionsDir(sessionsDir: string): string | null {
252268
const parts = path.normalize(path.resolve(sessionsDir)).split(path.sep).filter(Boolean);
253269
const sessionsIndex = parts.length - 1;
@@ -282,10 +298,11 @@ export function loadSessionTranscriptClassificationForSessionsDir(
282298
const dreamingTranscriptPaths = new Set<string>();
283299
const cronRunTranscriptPaths = new Set<string>();
284300

285-
// Walk the spawnedBy chain to determine if a session is cron-descended.
301+
// Walk persisted parent links to determine if a session is cron-descended.
286302
// Handles transitive parentage: a subagent spawned by another subagent
287303
// that was spawned by a cron run inherits the cron classification.
288304
const cronCache = new Map<string, boolean>();
305+
const resolvingCronKeys = new Set<string>();
289306
function isCronSession(sessionKey: string, entry: SessionTranscriptStoreEntry): boolean {
290307
if (isCronRunSessionKey(sessionKey)) {
291308
cronCache.set(sessionKey, true);
@@ -295,24 +312,21 @@ export function loadSessionTranscriptClassificationForSessionsDir(
295312
if (cached !== undefined) {
296313
return cached;
297314
}
298-
// Mark as not-yet-resolved to guard against cycles.
299-
cronCache.set(sessionKey, false);
300-
if (typeof entry.spawnedBy === "string" && entry.spawnedBy.trim().length > 0) {
301-
// Check the spawnedBy key directly before requiring a parent store entry:
302-
// a subagent whose spawnedBy is already a cron-run key is cron-descended
303-
// even when the parent row has been pruned or was never in this store.
304-
if (isCronRunSessionKey(entry.spawnedBy)) {
305-
cronCache.set(sessionKey, true);
315+
if (resolvingCronKeys.has(sessionKey)) {
316+
return false;
317+
}
318+
resolvingCronKeys.add(sessionKey);
319+
const result = readParentSessionKeys(entry).some((parentKey) => {
320+
// Direct cron parent keys are enough even when the parent row was pruned.
321+
if (isCronRunSessionKey(parentKey)) {
306322
return true;
307323
}
308-
const parentEntry = store[entry.spawnedBy];
309-
if (parentEntry) {
310-
const result = isCronSession(entry.spawnedBy, parentEntry);
311-
cronCache.set(sessionKey, result);
312-
return result;
313-
}
314-
}
315-
return false;
324+
const parentEntry = store[parentKey];
325+
return parentEntry ? isCronSession(parentKey, parentEntry) : false;
326+
});
327+
resolvingCronKeys.delete(sessionKey);
328+
cronCache.set(sessionKey, result);
329+
return result;
316330
}
317331

318332
for (const [sessionKey, entry] of Object.entries(store)) {

packages/memory-host-sdk/src/host/session-transcript-classification.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ describe("loadSessionTranscriptClassificationForSessionsDir", () => {
9191
expect(result.cronRunTranscriptPaths.size).toBe(3);
9292
});
9393

94+
it("classifies parentSessionKey descendants of a cron parent", () => {
95+
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
96+
fsSync.mkdirSync(sessionsDir, { recursive: true });
97+
const cronSession = path.join(sessionsDir, "cron-run.jsonl");
98+
const childSession = path.join(sessionsDir, "child.jsonl");
99+
const grandchildSession = path.join(sessionsDir, "grandchild.jsonl");
100+
for (const filePath of [cronSession, childSession, grandchildSession]) {
101+
fsSync.writeFileSync(filePath, "");
102+
}
103+
writeSessionStore(sessionsDir, {
104+
"agent:main:cron:job-1:run:run-1": { sessionFile: cronSession },
105+
"agent:main:child:one": {
106+
parentSessionKey: "agent:main:cron:job-1:run:run-1",
107+
sessionFile: childSession,
108+
},
109+
"agent:main:child:two": {
110+
parentSessionKey: "agent:main:child:one",
111+
sessionFile: grandchildSession,
112+
},
113+
});
114+
115+
const result = loadSessionTranscriptClassificationForSessionsDir(sessionsDir);
116+
117+
expect(result.cronRunTranscriptPaths).toEqual(
118+
new Set([cronSession, childSession, grandchildSession]),
119+
);
120+
});
121+
94122
it("does not classify a normal subagent with no cron ancestry", () => {
95123
const sessionsDir = path.join(tmpDir, "agents", "main", "sessions");
96124
fsSync.mkdirSync(sessionsDir, { recursive: true });

packages/memory-host-sdk/src/host/session-transcript-corpus.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,27 @@ function classifySessionEntry(
172172
};
173173
}
174174

175+
function readParentSessionKeys(entry: SessionEntry | undefined): string[] {
176+
const keys = new Set<string>();
177+
for (const value of [entry?.parentSessionKey, entry?.spawnedBy]) {
178+
if (typeof value !== "string") {
179+
continue;
180+
}
181+
const trimmed = value.trim();
182+
if (trimmed) {
183+
keys.add(trimmed);
184+
}
185+
}
186+
return [...keys];
187+
}
188+
175189
function collectCronGeneratedSessionKeys(
176190
summaries: readonly SessionEntrySummary[],
177191
): ReadonlySet<string> {
178192
const entriesByKey = new Map(summaries.map((summary) => [summary.sessionKey, summary.entry]));
179193
const cronGeneratedKeys = new Set<string>();
180194
const cache = new Map<string, boolean>();
195+
const resolving = new Set<string>();
181196

182197
const isCronGenerated = (sessionKey: string, entry: SessionEntry | undefined): boolean => {
183198
if (isCronRunSessionKey(sessionKey)) {
@@ -189,14 +204,16 @@ function collectCronGeneratedSessionKeys(
189204
if (cached !== undefined) {
190205
return cached;
191206
}
207+
if (resolving.has(sessionKey)) {
208+
return false;
209+
}
192210

193-
// Store a temporary false before following parent links so cyclic
194-
// spawnedBy chains terminate without classifying the cycle as cron.
195-
cache.set(sessionKey, false);
196-
const parentKey = typeof entry?.spawnedBy === "string" ? entry.spawnedBy.trim() : "";
197-
const generated =
198-
parentKey.length > 0 &&
199-
(isCronRunSessionKey(parentKey) || isCronGenerated(parentKey, entriesByKey.get(parentKey)));
211+
resolving.add(sessionKey);
212+
const generated = readParentSessionKeys(entry).some(
213+
(parentKey) =>
214+
isCronRunSessionKey(parentKey) || isCronGenerated(parentKey, entriesByKey.get(parentKey)),
215+
);
216+
resolving.delete(sessionKey);
200217
cache.set(sessionKey, generated);
201218
if (generated) {
202219
cronGeneratedKeys.add(sessionKey);

0 commit comments

Comments
 (0)