From b247ff0fee1ca4d20545e3507f20a733b2fbba29 Mon Sep 17 00:00:00 2001 From: Sujay Choubey Date: Tue, 17 Feb 2026 18:57:12 +0530 Subject: [PATCH] fix: dashboard misses sessions written by ao spawn due to path mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI ao spawn command writes session metadata into project-scoped subdirectories ({dataDir}/{projectId}-sessions/{sessionId}), while session-manager — used by ao start and the web API — expects flat files at {dataDir}/{sessionId}. Neither path knew about the other, so sessions created via ao spawn were completely invisible to the dashboard: the web API returned an empty/stale sessions array regardless of how many agents were actively working. Root cause: two independent write paths with no shared read path. - packages/cli/src/commands/spawn.ts writes to getSessionDir() subdir - packages/core/src/session-manager.ts calls listMetadata(dataDir) which only scanned flat files at the root of dataDir Fix: update packages/core/src/metadata.ts to handle both locations: 1. Add resolveMetadataPath(dataDir, sessionId) — checks flat path first, then scans any {x}-sessions/ subdirectory for the session ID. Used by all read functions so they find files regardless of which write path created them. 2. Update listMetadata() to scan both flat files AND project subdirectories matching the {projectId}-sessions/ naming pattern. Deduplicates by session ID so the same session ID never appears twice. 3. Update readMetadata, readMetadataRaw, updateMetadata, deleteMetadata to use resolveMetadataPath. updateMetadata falls back to the flat path for genuinely new sessions that have no file yet. Write paths are unchanged — session-manager.spawn() continues to write flat files; ao spawn continues to write to the project subdirectory; the agent bash script's in-flight status updates (also written to the subdir) are now picked up correctly by the dashboard on every poll. Co-Authored-By: Claude Sonnet 4.5 --- packages/core/src/metadata.ts | 98 +++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 314028357..65ff4d15b 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -65,18 +65,56 @@ function validateSessionId(sessionId: SessionId): void { } } -/** Get the metadata file path for a session. */ +/** Get the canonical (flat) metadata file path for a session. Used for writes. */ function metadataPath(dataDir: string, sessionId: SessionId): string { validateSessionId(sessionId); return join(dataDir, sessionId); } +/** + * Find the actual metadata file for a session. + * + * Background: `ao spawn` (CLI) writes session metadata into project-scoped + * subdirectories (`{dataDir}/{projectId}-sessions/{sessionId}`), while + * `session-manager` (used by `ao start` / web API) writes flat files at + * `{dataDir}/{sessionId}`. Both patterns are valid; this function checks both + * so that reads always succeed regardless of which write path created the file. + * + * Returns the full path if found, or null if no file exists in either location. + */ +function resolveMetadataPath(dataDir: string, sessionId: SessionId): string | null { + validateSessionId(sessionId); + + // Check flat path first (session-manager write path) + const flat = join(dataDir, sessionId); + try { + if (existsSync(flat) && statSync(flat).isFile()) return flat; + } catch { + // fall through + } + + // Check project subdirectories: {dataDir}/{anything}-sessions/{sessionId} + // This is the ao-spawn CLI write path. + if (!existsSync(dataDir)) return null; + for (const entry of readdirSync(dataDir)) { + if (!entry.endsWith("-sessions")) continue; + const candidate = join(dataDir, entry, sessionId); + try { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } catch { + continue; + } + } + + return null; +} + /** * Read metadata for a session. Returns null if the file doesn't exist. */ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMetadata | null { - const path = metadataPath(dataDir, sessionId); - if (!existsSync(path)) return null; + const path = resolveMetadataPath(dataDir, sessionId); + if (!path) return null; const content = readFileSync(path, "utf-8"); const raw = parseMetadataFile(content); @@ -101,8 +139,8 @@ export function readMetadataRaw( dataDir: string, sessionId: SessionId, ): Record | null { - const path = metadataPath(dataDir, sessionId); - if (!existsSync(path)) return null; + const path = resolveMetadataPath(dataDir, sessionId); + if (!path) return null; return parseMetadataFile(readFileSync(path, "utf-8")); } @@ -142,7 +180,8 @@ export function updateMetadata( sessionId: SessionId, updates: Partial>, ): void { - const path = metadataPath(dataDir, sessionId); + // Use the existing file location if found; fall back to flat path for new files. + const path = resolveMetadataPath(dataDir, sessionId) ?? metadataPath(dataDir, sessionId); let existing: Record = {}; if (existsSync(path)) { @@ -169,8 +208,8 @@ export function updateMetadata( * Optionally archive it to an `archive/` subdirectory. */ export function deleteMetadata(dataDir: string, sessionId: SessionId, archive = true): void { - const path = metadataPath(dataDir, sessionId); - if (!existsSync(path)) return; + const path = resolveMetadataPath(dataDir, sessionId); + if (!path) return; if (archive) { const archiveDir = join(dataDir, "archive"); @@ -185,20 +224,47 @@ export function deleteMetadata(dataDir: string, sessionId: SessionId, archive = /** * List all session IDs that have metadata files. + * + * Scans two locations: + * 1. Flat files at `{dataDir}/{sessionId}` — written by session-manager (ao start) + * 2. Project subdirectories `{dataDir}/{projectId}-sessions/{sessionId}` — written + * by the `ao spawn` CLI command + * + * Both patterns coexist; this function deduplicates by session ID. */ export function listMetadata(dataDir: string): SessionId[] { - const dir = dataDir; - if (!existsSync(dir)) return []; + if (!existsSync(dataDir)) return []; - return readdirSync(dir).filter((name) => { - if (name === "archive" || name.startsWith(".")) return false; - if (!VALID_SESSION_ID.test(name)) return false; + const seen = new Set(); + + for (const name of readdirSync(dataDir)) { + if (name === "archive" || name.startsWith(".")) continue; + + const fullPath = join(dataDir, name); try { - return statSync(join(dir, name)).isFile(); + const stat = statSync(fullPath); + + if (stat.isFile() && VALID_SESSION_ID.test(name)) { + // Flat session file (session-manager write path) + seen.add(name); + } else if (stat.isDirectory() && name.endsWith("-sessions")) { + // Project subdirectory (ao spawn CLI write path) + for (const entry of readdirSync(fullPath)) { + if (entry === "archive" || entry.startsWith(".")) continue; + if (!VALID_SESSION_ID.test(entry)) continue; + try { + if (statSync(join(fullPath, entry)).isFile()) seen.add(entry); + } catch { + // skip unreadable entries + } + } + } } catch { - return false; + // skip entries that error on stat } - }); + } + + return [...seen] as SessionId[]; } /**