@@ -91,6 +91,8 @@ export type ResolvedSessionTranscriptIdentity = {
9191type 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+
251267function 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 ) ) {
0 commit comments