feat(web): use workspace-aware agent IDs for gateway sessions

Replace hardcoded agent:main: session keys with resolveActiveAgentId() so each workspace routes to its own gateway agent.
This commit is contained in:
kumarabhirup 2026-03-03 15:38:00 -08:00
parent 74b3b23e26
commit 38f3cb6efe
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
3 changed files with 73 additions and 8 deletions

View File

@ -1,6 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveOpenClawStateDir } from "@/lib/workspace";
import { resolveOpenClawStateDir, resolveActiveAgentId } from "@/lib/workspace";
export const runtime = "nodejs";
@ -38,7 +38,7 @@ export async function GET(req: Request) {
return Response.json({ error: "sessionId required" }, { status: 400 });
}
const webSessionKey = `agent:main:web:${sessionId}`;
const webSessionKey = `agent:${resolveActiveAgentId()}:web:${sessionId}`;
const entries = readSubagentRegistry();
const subagents = entries

View File

@ -16,7 +16,7 @@ import {
existsSync,
mkdirSync,
} from "node:fs";
import { resolveWebChatDir, resolveOpenClawStateDir } from "./workspace";
import { resolveWebChatDir, resolveOpenClawStateDir, resolveActiveAgentId } from "./workspace";
import {
type AgentProcessHandle,
type AgentEvent,
@ -131,6 +131,16 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function resolveModelLabel(provider: unknown, model: unknown): string | null {
if (typeof model !== "string" || !model.trim()) { return null; }
const m = model.trim();
if (typeof provider === "string" && provider.trim()) {
const p = provider.trim();
return m.toLowerCase().startsWith(`${p.toLowerCase()}/`) ? m : `${p}/${m}`;
}
return m;
}
function extractAssistantTextFromChatPayload(
data: Record<string, unknown> | undefined,
): string {
@ -411,7 +421,7 @@ export function abortRun(sessionId: string): boolean {
*/
function sendGatewayAbort(sessionId: string): void {
try {
const sessionKey = `agent:main:web:${sessionId}`;
const sessionKey = `agent:${resolveActiveAgentId()}:web:${sessionId}`;
void callGatewayRpc("chat.abort", { sessionKey }, { timeoutMs: 4_000 }).catch(
() => {
// Best effort; don't let abort failures break the stop flow.
@ -717,6 +727,12 @@ function wireSubscribeOnlyProcess(
emit({ type: "tool-input-available", toolCallId, toolName, input: args });
run.accumulated.parts.push({ type: "tool-invocation", toolCallId, toolName, args });
accToolMap.set(toolCallId, run.accumulated.parts.length - 1);
} else if (phase === "update") {
const partialResult = extractToolResult(ev.data?.partialResult);
if (partialResult) {
const output = buildToolOutput(partialResult);
emit({ type: "tool-output-partial", toolCallId, output });
}
} else if (phase === "result") {
const isError = ev.data?.isError === true;
const result = extractToolResult(ev.data?.result);
@ -742,6 +758,23 @@ function wireSubscribeOnlyProcess(
}
}
if (ev.event === "agent" && ev.stream === "lifecycle" && (ev.data?.phase === "fallback" || ev.data?.phase === "fallback_cleared")) {
const data = ev.data;
const selected = resolveModelLabel(data?.selectedProvider, data?.selectedModel)
?? resolveModelLabel(data?.fromProvider, data?.fromModel);
const active = resolveModelLabel(data?.activeProvider, data?.activeModel)
?? resolveModelLabel(data?.toProvider, data?.toModel);
if (selected && active) {
const isClear = data?.phase === "fallback_cleared";
const reason = typeof data?.reasonSummary === "string" ? data.reasonSummary
: typeof data?.reason === "string" ? data.reason : undefined;
const label = isClear
? `Restored to ${selected}`
: `Switched to ${active}${reason ? ` (${reason})` : ""}`;
openStatusReasoning(label);
}
}
if (ev.event === "agent" && ev.stream === "compaction") {
const phase = typeof ev.data?.phase === "string" ? ev.data.phase : undefined;
if (phase === "start") { openStatusReasoning("Optimizing session context..."); }
@ -1093,7 +1126,7 @@ function wireChildProcess(run: ActiveRun): void {
// ── Parse stdout JSON lines ──
const rl = createInterface({ input: child.stdout! });
const parentSessionKey = `agent:main:web:${run.sessionId}`;
const parentSessionKey = `agent:${resolveActiveAgentId()}:web:${run.sessionId}`;
// Prevent unhandled 'error' events on the readline interface.
// When the child process fails to start (e.g. ENOENT — missing script)
// the stdout pipe is destroyed and readline re-emits the error. Without
@ -1237,6 +1270,12 @@ function wireChildProcess(run: ActiveRun): void {
args,
});
accToolMap.set(toolCallId, run.accumulated.parts.length - 1);
} else if (phase === "update") {
const partialResult = extractToolResult(ev.data?.partialResult);
if (partialResult) {
const output = buildToolOutput(partialResult);
emit({ type: "tool-output-partial", toolCallId, output });
}
} else if (phase === "result") {
const isError = ev.data?.isError === true;
const result = extractToolResult(ev.data?.result);
@ -1291,6 +1330,28 @@ function wireChildProcess(run: ActiveRun): void {
}
}
// Model fallback events
if (
ev.event === "agent" &&
ev.stream === "lifecycle" &&
(ev.data?.phase === "fallback" || ev.data?.phase === "fallback_cleared")
) {
const data = ev.data;
const selected = resolveModelLabel(data?.selectedProvider, data?.selectedModel)
?? resolveModelLabel(data?.fromProvider, data?.fromModel);
const active = resolveModelLabel(data?.activeProvider, data?.activeModel)
?? resolveModelLabel(data?.toProvider, data?.toModel);
if (selected && active) {
const isClear = data?.phase === "fallback_cleared";
const reason = typeof data?.reasonSummary === "string" ? data.reasonSummary
: typeof data?.reason === "string" ? data.reason : undefined;
const label = isClear
? `Restored to ${selected}`
: `Switched to ${active}${reason ? ` (${reason})` : ""}`;
openStatusReasoning(label);
}
}
// Chat final events can include assistant turns from runs outside
// the original parent process (e.g. subagent announce follow-ups).
if (ev.event === "chat") {

View File

@ -8,6 +8,7 @@ import { PassThrough } from "node:stream";
import NodeWebSocket from "ws";
import {
getEffectiveProfile,
resolveActiveAgentId,
resolveOpenClawStateDir,
resolveWorkspaceRoot,
} from "./workspace";
@ -653,6 +654,7 @@ class GatewayProcessHandle
const patch = await this.client.request("sessions.patch", {
key: sessionKey,
verboseLevel: "full",
reasoningLevel: "on",
});
if (patch.ok) {
return;
@ -880,8 +882,9 @@ export function spawnAgentProcess(
if (shouldForceLegacyStream()) {
return spawnLegacyAgentProcess(message, agentSessionId);
}
const agentId = resolveActiveAgentId();
const sessionKey = agentSessionId
? `agent:main:web:${agentSessionId}`
? `agent:${agentId}:web:${agentSessionId}`
: undefined;
return new GatewayProcessHandle({
mode: "start",
@ -902,17 +905,18 @@ function spawnCliAgentProcess(
message: string,
agentSessionId?: string,
): ReturnType<typeof spawn> {
const cliAgentId = resolveActiveAgentId();
const args = [
"agent",
"--agent",
"main",
cliAgentId,
"--message",
message,
"--stream-json",
];
if (agentSessionId) {
const sessionKey = `agent:main:web:${agentSessionId}`;
const sessionKey = `agent:${cliAgentId}:web:${agentSessionId}`;
args.push("--session-key", sessionKey, "--lane", "web", "--channel", "webchat");
}