web: scope subagent storage to workspace profile

Subagent event JSONL files and rehydration metadata were stored in the
shared ~/.openclaw/web-chat/ directory regardless of the active workspace
profile, while parent chat sessions were correctly profile-scoped.

Move subagent event persistence into the profile-scoped web-chat dir
(web-chat-<profile>/subagent-events/) and add a profile-local
subagent-index.json for fast rehydration after page refresh. The shared
gateway registry (~/.openclaw/subagents/runs.json) remains the fallback
source. Existing events in the legacy shared path are still readable as
a migration fallback.
This commit is contained in:
kumarabhirup 2026-02-20 13:37:46 -08:00
parent 161c329214
commit 76f06e0eaf
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167

View File

@ -6,7 +6,7 @@
* *
* Events are fed from the gateway WebSocket connection (gateway-events.ts). * Events are fed from the gateway WebSocket connection (gateway-events.ts).
*/ */
import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs"; import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { import {
extractToolResult, extractToolResult,
@ -15,7 +15,7 @@ import {
parseErrorBody, parseErrorBody,
} from "./agent-runner"; } from "./agent-runner";
import { subscribeToSessionKey, type GatewayEvent } from "./gateway-events"; import { subscribeToSessionKey, type GatewayEvent } from "./gateway-events";
import { resolveOpenClawStateDir } from "./workspace"; import { resolveOpenClawStateDir, resolveWebChatDir } from "./workspace";
// ── Types ── // ── Types ──
@ -84,7 +84,13 @@ function getRegistry(): SubagentRegistry {
// ── Event persistence ── // ── Event persistence ──
/** Profile-scoped directory for subagent event JSONL files. */
function subagentEventsDir(): string { function subagentEventsDir(): string {
return join(resolveWebChatDir(), "subagent-events");
}
/** Pre-profile-scoping legacy path — used as a read fallback for migration. */
function legacySubagentEventsDir(): string {
return join(resolveOpenClawStateDir(), "web-chat", "subagent-events"); return join(resolveOpenClawStateDir(), "web-chat", "subagent-events");
} }
@ -102,8 +108,18 @@ function persistEvent(sessionKey: string, event: SseEvent): void {
} }
function loadPersistedEvents(sessionKey: string): SseEvent[] { function loadPersistedEvents(sessionKey: string): SseEvent[] {
const filePath = join(subagentEventsDir(), safeFilename(sessionKey)); const fname = safeFilename(sessionKey);
if (!existsSync(filePath)) {return [];}
// Try profile-scoped dir first, fall back to legacy shared dir.
let filePath = join(subagentEventsDir(), fname);
if (!existsSync(filePath)) {
const legacyPath = join(legacySubagentEventsDir(), fname);
if (existsSync(legacyPath)) {
filePath = legacyPath;
} else {
return [];
}
}
try { try {
const lines = readFileSync(filePath, "utf-8").split("\n"); const lines = readFileSync(filePath, "utf-8").split("\n");
@ -116,8 +132,50 @@ function loadPersistedEvents(sessionKey: string): SseEvent[] {
} catch { return []; } } catch { return []; }
} }
// ── Profile-scoped subagent index ──
type SubagentIndexEntry = {
runId: string;
parentWebSessionId: string;
task: string;
label?: string;
status: "running" | "completed" | "error";
startedAt: number;
endedAt?: number;
};
function subagentIndexPath(): string {
return join(resolveWebChatDir(), "subagent-index.json");
}
function loadSubagentIndex(): Record<string, SubagentIndexEntry> {
const p = subagentIndexPath();
if (!existsSync(p)) {return {};}
try {
return JSON.parse(readFileSync(p, "utf-8")) as Record<string, SubagentIndexEntry>;
} catch { return {}; }
}
function upsertSubagentIndex(sessionKey: string, entry: SubagentIndexEntry): void {
try {
const dir = resolveWebChatDir();
mkdirSync(dir, { recursive: true });
const index = loadSubagentIndex();
index[sessionKey] = entry;
writeFileSync(subagentIndexPath(), JSON.stringify(index, null, 2));
} catch { /* best-effort */ }
}
/** Read the on-disk registry entry and derive the proper status. */ /** Read the on-disk registry entry and derive the proper status. */
function readDiskStatus(sessionKey: string): "running" | "completed" | "error" { function readDiskStatus(sessionKey: string): "running" | "completed" | "error" {
// Check profile-scoped index first.
const profileIndex = loadSubagentIndex();
const profileEntry = profileIndex[sessionKey];
if (profileEntry) {
return profileEntry.status;
}
// Fall back to the shared gateway registry.
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json"); const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
if (!existsSync(registryPath)) {return "running";} if (!existsSync(registryPath)) {return "running";}
try { try {
@ -188,6 +246,17 @@ export function registerSubagent(
} }
keys.add(info.sessionKey); keys.add(info.sessionKey);
// Persist to the profile-scoped subagent index.
upsertSubagentIndex(info.sessionKey, {
runId: info.runId,
parentWebSessionId,
task: info.task,
label: info.label,
status: run.status,
startedAt: run.startedAt,
endedAt: run.endedAt,
});
// NOTE: We do NOT subscribe to gateway WebSocket here. During live // NOTE: We do NOT subscribe to gateway WebSocket here. During live
// streaming, events arrive via routeRawEvent() from the parent's NDJSON // streaming, events arrive via routeRawEvent() from the parent's NDJSON
// stream. After the parent exits, activateGatewayFallback() subscribes. // stream. After the parent exits, activateGatewayFallback() subscribes.
@ -352,9 +421,10 @@ export function routeRawEvent(
} }
/** /**
* Lazily register a subagent by reading the on-disk registry * Lazily register a subagent by reading the on-disk registries.
* (~/.openclaw/subagents/runs.json). Returns true if the subagent was * Checks the profile-scoped subagent-index.json first, then falls back
* found and registered (or was already registered). * to the shared gateway registry (~/.openclaw/subagents/runs.json).
* Returns true if the subagent was found and registered (or already registered).
*/ */
export function ensureRegisteredFromDisk( export function ensureRegisteredFromDisk(
sessionKey: string, sessionKey: string,
@ -362,6 +432,20 @@ export function ensureRegisteredFromDisk(
): boolean { ): boolean {
if (getRegistry().runs.has(sessionKey)) {return true;} if (getRegistry().runs.has(sessionKey)) {return true;}
// 1. Check profile-scoped index.
const profileIndex = loadSubagentIndex();
const profileEntry = profileIndex[sessionKey];
if (profileEntry) {
registerSubagent(profileEntry.parentWebSessionId || parentWebSessionId, {
sessionKey,
runId: profileEntry.runId,
task: profileEntry.task,
label: profileEntry.label,
}, { fromDisk: true });
return true;
}
// 2. Fall back to the shared gateway registry.
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json"); const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
if (!existsSync(registryPath)) {return false;} if (!existsSync(registryPath)) {return false;}
@ -572,6 +656,17 @@ function finalizeRun(run: SubagentRun, status: "completed" | "error"): void {
run.status = status; run.status = status;
run.endedAt = Date.now(); run.endedAt = Date.now();
// Update the profile-scoped subagent index with final status.
upsertSubagentIndex(run.sessionKey, {
runId: run.runId,
parentWebSessionId: run.parentWebSessionId,
task: run.task,
label: run.label,
status: run.status,
startedAt: run.startedAt,
endedAt: run.endedAt,
});
// Signal completion to all subscribers // Signal completion to all subscribers
for (const sub of run.subscribers) { for (const sub of run.subscribers) {
try { sub(null); } catch { /* ignore */ } try { sub(null); } catch { /* ignore */ }