@@ -58,10 +58,6 @@ function isDreamingNarrativeSessionKeyLike(value: unknown): boolean {
5858 return typeof value === "string" && isDreamingNarrativeSessionStoreKey ( value ) ;
5959}
6060
61- function hasCronRunSessionKey ( value : unknown ) : boolean {
62- return typeof value === "string" && isCronRunSessionKey ( value ) ;
63- }
64-
6561function normalizeComparablePath ( pathname : string ) : string {
6662 const resolved = path . resolve ( pathname ) ;
6763 return process . platform === "win32" ? resolved . toLowerCase ( ) : resolved ;
@@ -163,6 +159,7 @@ function resolveSessionStoreTranscriptCorpusPath(
163159function classifySessionEntry (
164160 sessionKey : string ,
165161 entry : SessionEntry ,
162+ cronGeneratedSessionKeys : ReadonlySet < string > ,
166163) : {
167164 generatedByDreamingNarrative : boolean ;
168165 generatedByCronRun : boolean ;
@@ -171,10 +168,69 @@ function classifySessionEntry(
171168 generatedByDreamingNarrative :
172169 isDreamingNarrativeSessionStoreKey ( sessionKey ) ||
173170 isDreamingNarrativeSessionKeyLike ( entry . spawnedBy ) ,
174- generatedByCronRun : isCronRunSessionKey ( sessionKey ) || hasCronRunSessionKey ( entry . spawnedBy ) ,
171+ generatedByCronRun : cronGeneratedSessionKeys . has ( sessionKey ) ,
175172 } ;
176173}
177174
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+
189+ function collectCronGeneratedSessionKeys (
190+ summaries : readonly SessionEntrySummary [ ] ,
191+ ) : ReadonlySet < string > {
192+ // Build the cron-generated closure once so active entries and archive
193+ // artifacts share the same lineage classification.
194+ const entriesByKey = new Map ( summaries . map ( ( summary ) => [ summary . sessionKey , summary . entry ] ) ) ;
195+ const cronGeneratedKeys = new Set < string > ( ) ;
196+ const cache = new Map < string , boolean > ( ) ;
197+ const resolving = new Set < string > ( ) ;
198+
199+ const isCronGenerated = ( sessionKey : string , entry : SessionEntry | undefined ) : boolean => {
200+ if ( isCronRunSessionKey ( sessionKey ) ) {
201+ cache . set ( sessionKey , true ) ;
202+ cronGeneratedKeys . add ( sessionKey ) ;
203+ return true ;
204+ }
205+ const cached = cache . get ( sessionKey ) ;
206+ if ( cached !== undefined ) {
207+ return cached ;
208+ }
209+ if ( resolving . has ( sessionKey ) ) {
210+ return false ;
211+ }
212+
213+ resolving . add ( sessionKey ) ;
214+ const generated = readParentSessionKeys ( entry ) . some (
215+ ( parentKey ) =>
216+ // Parent rows can be pruned before child rows; a cron-shaped parent key
217+ // still carries cron lineage without requiring a store entry.
218+ isCronRunSessionKey ( parentKey ) || isCronGenerated ( parentKey , entriesByKey . get ( parentKey ) ) ,
219+ ) ;
220+ resolving . delete ( sessionKey ) ;
221+ cache . set ( sessionKey , generated ) ;
222+ if ( generated ) {
223+ cronGeneratedKeys . add ( sessionKey ) ;
224+ }
225+ return generated ;
226+ } ;
227+
228+ for ( const summary of summaries ) {
229+ isCronGenerated ( summary . sessionKey , summary . entry ) ;
230+ }
231+ return cronGeneratedKeys ;
232+ }
233+
178234function isRegularSessionTranscriptFile ( absPath : string ) : boolean {
179235 try {
180236 return fsSync . lstatSync ( absPath ) . isFile ( ) ;
@@ -187,6 +243,7 @@ function toSessionStoreCorpusEntry(
187243 agentId : string ,
188244 sessionsDir : string ,
189245 summary : SessionEntrySummary ,
246+ cronGeneratedSessionKeys : ReadonlySet < string > ,
190247) : SessionTranscriptCorpusEntry | null {
191248 const sessionFile = resolveSessionStoreTranscriptCorpusPath ( agentId , sessionsDir , summary . entry ) ;
192249 if ( ! sessionFile || ! isUsageCountedSessionTranscriptFileName ( path . basename ( sessionFile ) ) ) {
@@ -200,7 +257,11 @@ function toSessionStoreCorpusEntry(
200257 return null ;
201258 }
202259 const sessionKey = summary . sessionKey . trim ( ) ;
203- const classification = classifySessionEntry ( summary . sessionKey , summary . entry ) ;
260+ const classification = classifySessionEntry (
261+ summary . sessionKey ,
262+ summary . entry ,
263+ cronGeneratedSessionKeys ,
264+ ) ;
204265 return {
205266 agentId,
206267 artifactKind : "active-session" ,
@@ -299,11 +360,13 @@ export function listSessionTranscriptCorpusEntriesForAgentSync(
299360 const activeEntryOwnersByPath = new Map < string , string > ( ) ;
300361 const artifactDirsByPath = new Map < string , string > ( ) ;
301362 rememberArtifactDir ( artifactDirsByPath , sessionsDir ) ;
302- for ( const summary of listSessionEntries ( {
363+ const sessionEntries = listSessionEntries ( {
303364 agentId : normalizedAgentId ,
304365 hydrateSkillPromptRefs : false ,
305366 storePath,
306- } ) ) {
367+ } ) ;
368+ const cronGeneratedSessionKeys = collectCronGeneratedSessionKeys ( sessionEntries ) ;
369+ for ( const summary of sessionEntries ) {
307370 const sessionKey = isSharedFixedStore
308371 ? summary . sessionKey
309372 : canonicalizeMainSessionAlias ( {
@@ -316,7 +379,12 @@ export function listSessionTranscriptCorpusEntriesForAgentSync(
316379 sessionKey,
317380 ...( isSharedFixedStore ? { } : { fallbackAgentId : normalizedAgentId } ) ,
318381 } ) ;
319- const entry = toSessionStoreCorpusEntry ( ownerAgentId , sessionsDir , summary ) ;
382+ const entry = toSessionStoreCorpusEntry (
383+ ownerAgentId ,
384+ sessionsDir ,
385+ summary ,
386+ cronGeneratedSessionKeys ,
387+ ) ;
320388 if ( ! entry ) {
321389 continue ;
322390 }
0 commit comments