File size: 6,547 Bytes
f778c12 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | /** Process-local ACP runtime handle cache with lifecycle cleanup and reuse checks. */
import {
resolveRuntimeHandleIdentifiersFromIdentity,
resolveSessionIdentityFromMeta,
} from "@openclaw/acp-core/runtime/session-identity";
import type {
AcpRuntime,
AcpRuntimeHandle,
AcpRuntimeSessionMode,
AcpRuntimeStatus,
} from "@openclaw/acp-core/runtime/types";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { logVerbose } from "../../globals.js";
import { isAcpOwnerRepairRequired } from "./manager.runtime-owner.js";
import type { AcpSessionTarget, SessionAcpMeta } from "./manager.types.js";
import { acpSessionActorKey } from "./manager.utils.js";
import { normalizeText } from "./runtime-options.js";
import type { SessionActorQueue } from "./session-actor-queue.js";
/** Cached runtime handle plus the configuration signature that made it reusable. */
export type CachedRuntimeState = {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
backend: string;
agent: string;
mode: AcpRuntimeSessionMode;
cwd?: string;
configSignature: string;
appliedControlSignature?: string;
};
/** Process-local cache of live ACP runtime handles keyed by canonical session actor. */
export class ManagerRuntimeHandleCache {
private readonly runtimeCache = new Map<string, CachedRuntimeState>();
get(target: AcpSessionTarget): CachedRuntimeState | null {
return this.runtimeCache.get(acpSessionActorKey(target)) ?? null;
}
set(target: AcpSessionTarget, state: CachedRuntimeState): void {
this.runtimeCache.set(acpSessionActorKey(target), state);
}
clear(target: AcpSessionTarget): void {
this.runtimeCache.delete(acpSessionActorKey(target));
}
/** Returns cache counters used by ACP manager observability snapshots. */
getObservabilitySnapshot() {
return {
activeSessions: this.runtimeCache.size,
idleTtlMs: 0,
evictedTotal: 0,
};
}
/** Closes and removes one cached runtime handle when present. */
async close(
params: AcpSessionTarget & { reason: string; expectedHandle?: AcpRuntimeHandle },
): Promise<void> {
const cached = this.get(params);
if (!cached || (params.expectedHandle && cached.handle !== params.expectedHandle)) {
return;
}
try {
await cached.runtime.close({
handle: cached.handle,
reason: params.reason,
});
} catch (error) {
if (params.expectedHandle && isAcpOwnerRepairRequired(error)) {
throw error;
}
logVerbose(
`acp-manager: cached runtime close failed for ${params.sessionKey}: ${String(error)}`,
);
} finally {
this.clear(params);
}
}
/** Drains every cached handle behind its session actor before process shutdown. */
async closeAll(params: { actorQueue: SessionActorQueue; reason: string }): Promise<void> {
await Promise.all(
[...this.runtimeCache.keys()].map((actorKey) =>
params.actorQueue.run(actorKey, async () => {
const cached = this.runtimeCache.get(actorKey);
if (!cached) {
return;
}
try {
await cached.runtime.close({ handle: cached.handle, reason: params.reason });
} catch (error) {
logVerbose(
`acp-manager: cached runtime close failed for ${cached.handle.sessionKey}: ${String(error)}`,
);
} finally {
this.runtimeCache.delete(actorKey);
}
}),
),
);
}
/** Clears a cached handle only when the caller still owns the same runtime identifiers. */
clearIfHandleMatches(params: AcpSessionTarget & { handle: AcpRuntimeHandle }): void {
const cached = this.get(params);
if (!cached || !this.runtimeHandlesMatch(cached.handle, params.handle)) {
return;
}
this.clear(params);
}
/** Checks whether a cached runtime handle is still healthy enough to reuse. */
async isReusable(params: {
sessionKey: string;
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
}): Promise<boolean> {
if (!params.runtime.getStatus) {
return true;
}
try {
const status = await params.runtime.getStatus({
handle: params.handle,
});
if (isRuntimeStatusUnavailable(status)) {
logVerbose(
`acp-manager: evicting cached runtime handle for ${params.sessionKey} after unhealthy status probe: ${status.summary ?? "status unavailable"}`,
);
return false;
}
return true;
} catch (error) {
if (isAcpOwnerRepairRequired(error)) {
throw error;
}
logVerbose(
`acp-manager: evicting cached runtime handle for ${params.sessionKey} after status probe failed: ${String(error)}`,
);
return false;
}
}
handleMatchesMeta(params: { handle: AcpRuntimeHandle; meta: SessionAcpMeta }): boolean {
const identity = resolveSessionIdentityFromMeta(params.meta);
const expectedHandleIds = resolveRuntimeHandleIdentifiersFromIdentity(identity);
if ((params.handle.backendSessionId ?? "") !== (expectedHandleIds.backendSessionId ?? "")) {
return false;
}
if ((params.handle.agentSessionId ?? "") !== (expectedHandleIds.agentSessionId ?? "")) {
return false;
}
const expectedAcpxRecordId = identity?.acpxRecordId ?? "";
const actualAcpxRecordId =
normalizeText((params.handle as { acpxRecordId?: unknown }).acpxRecordId) ?? "";
return actualAcpxRecordId === expectedAcpxRecordId;
}
private runtimeHandlesMatch(a: AcpRuntimeHandle, b: AcpRuntimeHandle): boolean {
return (
a.sessionKey === b.sessionKey &&
a.agentId === b.agentId &&
a.backend === b.backend &&
a.runtimeSessionName === b.runtimeSessionName &&
(a.cwd ?? "") === (b.cwd ?? "") &&
(a.acpxRecordId ?? "") === (b.acpxRecordId ?? "") &&
(a.backendSessionId ?? "") === (b.backendSessionId ?? "") &&
(a.agentSessionId ?? "") === (b.agentSessionId ?? "")
);
}
}
function isRuntimeStatusUnavailable(status: AcpRuntimeStatus | undefined): boolean {
if (!status) {
return false;
}
const detailsStatus = normalizeLowercaseStringOrEmpty(status.details?.status);
if (detailsStatus === "dead" || detailsStatus === "no-session") {
return true;
}
const summaryMatch = status.summary?.match(/\bstatus=([^\s]+)/i);
const summaryStatus = normalizeLowercaseStringOrEmpty(summaryMatch?.[1]);
return summaryStatus === "dead" || summaryStatus === "no-session";
}
|