Skip to content

Commit 1403d96

Browse files
committed
fix(agent-core-v2): skip stray files during session index scans
1 parent bf38798 commit 1403d96

5 files changed

Lines changed: 55 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix recent sessions missing from the session list when the sessions folder contains stray files.

packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,14 +198,14 @@ export class SessionIndexProjector {
198198
}
199199

200200
private async scanAuthoritative(): Promise<AuthoritativeScan> {
201-
const { storage, docs, sessionsScope } = this.deps;
201+
const { storage, docs, sessionsScope, log } = this.deps;
202202
const summaries: SessionSummary[] = [];
203203
const counts = new Map<string, { active: number; archived: number }>();
204204
let sourceMaxMtimeMs = (await storage.mtime(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) ?? 0;
205205
for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) {
206206
const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId);
207207
const found = await mapBounded(sessionIds, SCAN_CONCURRENCY, async (sessionId) => {
208-
const mtime = await sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId);
208+
const mtime = await sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId, log);
209209
if (mtime > sourceMaxMtimeMs) sourceMaxMtimeMs = mtime;
210210
return readSessionSummary(docs, sessionsScope, workspaceId, sessionId);
211211
});

packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
163163
const published = manifest.sourceMaxMtimeMs;
164164
if (published === undefined) return false;
165165
try {
166-
return (await scanSessionsMaxMtime(this.storage, this.sessionsScope)) <= published;
166+
return (await scanSessionsMaxMtime(this.storage, this.sessionsScope, this.log)) <= published;
167167
} catch (error) {
168168
this.log.warn('session index freshness check failed; re-projecting', {
169169
error: String(error),

packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import { ILogService } from '#/_base/log/log';
12
import { SESSION_INDEX_KEY, SESSION_INDEX_SCOPE } from '#/app/workspace/workspaceAlias';
23
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
3-
import { IFileSystemStorageService } from '#/persistence/interface/storage';
4+
import {
5+
IFileSystemStorageService,
6+
StorageError,
7+
StorageErrors,
8+
} from '#/persistence/interface/storage';
49

510
import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, type SessionSummary } from './sessionIndex';
611

@@ -171,27 +176,49 @@ export async function mapBounded<T, R>(
171176
return out;
172177
}
173178

179+
async function stateFileMtime(
180+
storage: IFileSystemStorageService,
181+
scope: string,
182+
log: ILogService | undefined,
183+
): Promise<number | undefined> {
184+
try {
185+
return await storage.mtime(scope, META_KEY);
186+
} catch (error) {
187+
if (
188+
error instanceof StorageError &&
189+
error.code === StorageErrors.codes.STORAGE_IO_FAILED &&
190+
error.details?.['errno'] === 'ENOTDIR'
191+
) {
192+
log?.warn('session index skips a non-directory entry', { path: error.details['path'] });
193+
return undefined;
194+
}
195+
throw error;
196+
}
197+
}
198+
174199
export async function sessionStateMaxMtime(
175200
storage: IFileSystemStorageService,
176201
sessionsScope: string,
177202
workspaceId: string,
178203
sessionId: string,
204+
log?: ILogService,
179205
): Promise<number> {
180206
const base = `${sessionsScope}/${workspaceId}/${sessionId}`;
181-
const direct = await storage.mtime(base, META_KEY);
182-
const nested = await storage.mtime(`${base}/${META_SCOPE}`, META_KEY);
207+
const direct = await stateFileMtime(storage, base, log);
208+
const nested = await stateFileMtime(storage, `${base}/${META_SCOPE}`, log);
183209
return Math.max(direct ?? 0, nested ?? 0);
184210
}
185211

186212
export async function scanSessionsMaxMtime(
187213
storage: IFileSystemStorageService,
188214
sessionsScope: string,
215+
log?: ILogService,
189216
): Promise<number> {
190217
let max = (await storage.mtime(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY)) ?? 0;
191218
for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) {
192219
const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId);
193220
const mtimes = await mapBounded(sessionIds, MTIME_SCAN_CONCURRENCY, (sessionId) =>
194-
sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId),
221+
sessionStateMaxMtime(storage, sessionsScope, workspaceId, sessionId, log),
195222
);
196223
for (const mtime of mtimes) {
197224
if (mtime > max) max = mtime;

packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,22 @@ describe('FileSessionIndex (read model)', () => {
524524
expect(await store.count({ workspaceIds: [workspaceId], includeArchived: true })).toBe(2);
525525
});
526526

527+
it('prepare skips stray files and state-less directories instead of failing the projection', async () => {
528+
await seedSession('active', { title: 'hello', createdAt: 1, updatedAt: 2 });
529+
await fsp.writeFile(join(sessionsDir, 'workspace.json'), '{}');
530+
await fsp.writeFile(join(sessionsDir, workspaceId, 'workspace.json'), '{}');
531+
await fsp.writeFile(join(sessionsDir, workspaceId, '.DS_Store'), 'junk');
532+
await fsp.mkdir(join(sessionsDir, workspaceId, 'no-state'), { recursive: true });
533+
534+
const store = build();
535+
const status = await store.prepare();
536+
expect(status).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
537+
538+
const page = await store.listRecent({ workspaceIds: [workspaceId] });
539+
expect(page.items.map((s) => s.id)).toEqual(['active']);
540+
expect(await store.count({ workspaceIds: [workspaceId] })).toBe(1);
541+
});
542+
527543
it('serves warm reads without touching the session directories', async () => {
528544
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 });
529545
await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 3 });

0 commit comments

Comments
 (0)