fix: dashboard misses sessions written by ao spawn due to path mismatch

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 <noreply@anthropic.com>
This commit is contained in:
Sujay Choubey 2026-02-17 18:57:12 +05:30
parent 1cf11b6d9f
commit b247ff0fee
1 changed files with 82 additions and 16 deletions

View File

@ -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<string, string> | 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<Record<string, string>>,
): 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<string, string> = {};
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<string>();
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[];
}
/**