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:
parent
1cf11b6d9f
commit
b247ff0fee
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue