feat: ACP thread-bound agents (#23580)

* docs: add ACP thread-bound agents plan doc

* docs: expand ACP implementation specification

* feat(acp): route ACP sessions through core dispatch and lifecycle cleanup

* feat(acp): add /acp commands and Discord spawn gate

* ACP: add acpx runtime plugin backend

* fix(subagents): defer transient lifecycle errors before announce

* Agents: harden ACP sessions_spawn and tighten spawn guidance

* Agents: require explicit ACP target for runtime spawns

* docs: expand ACP control-plane implementation plan

* ACP: harden metadata seeding and spawn guidance

* ACP: centralize runtime control-plane manager and fail-closed dispatch

* ACP: harden runtime manager and unify spawn helpers

* Commands: route ACP sessions through ACP runtime in agent command

* ACP: require persisted metadata for runtime spawns

* Sessions: preserve ACP metadata when updating entries

* Plugins: harden ACP backend registry across loaders

* ACPX: make availability probe compatible with adapters

* E2E: add manual Discord ACP plain-language smoke script

* ACPX: preserve streamed spacing across Discord delivery

* Docs: add ACP Discord streaming strategy

* ACP: harden Discord stream buffering for thread replies

* ACP: reuse shared block reply pipeline for projector

* ACP: unify streaming config and adopt coalesceIdleMs

* Docs: add temporary ACP production hardening plan

* Docs: trim temporary ACP hardening plan goals

* Docs: gate ACP thread controls by backend capabilities

* ACP: add capability-gated runtime controls and /acp operator commands

* Docs: remove temporary ACP hardening plan

* ACP: fix spawn target validation and close cache cleanup

* ACP: harden runtime dispatch and recovery paths

* ACP: split ACP command/runtime internals and centralize policy

* ACP: harden runtime lifecycle, validation, and observability

* ACP: surface runtime and backend session IDs in thread bindings

* docs: add temp plan for binding-service migration

* ACP: migrate thread binding flows to SessionBindingService

* ACP: address review feedback and preserve prompt wording

* ACPX plugin: pin runtime dependency and prefer bundled CLI

* Discord: complete binding-service migration cleanup and restore ACP plan

* Docs: add standalone ACP agents guide

* ACP: route harness intents to thread-bound ACP sessions

* ACP: fix spawn thread routing and queue-owner stall

* ACP: harden startup reconciliation and command bypass handling

* ACP: fix dispatch bypass type narrowing

* ACP: align runtime metadata to agentSessionId

* ACP: normalize session identifier handling and labels

* ACP: mark thread banner session ids provisional until first reply

* ACP: stabilize session identity mapping and startup reconciliation

* ACP: add resolved session-id notices and cwd in thread intros

* Discord: prefix thread meta notices consistently

* Discord: unify ACP/thread meta notices with gear prefix

* Discord: split thread persona naming from meta formatting

* Extensions: bump acpx plugin dependency to 0.1.9

* Agents: gate ACP prompt guidance behind acp.enabled

* Docs: remove temp experiment plan docs

* Docs: scope streaming plan to holy grail refactor

* Docs: refactor ACP agents guide for human-first flow

* Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow

* Docs/Skill: add OpenCode and Pi to ACP harness lists

* Docs/Skill: align ACP harness list with current acpx registry

* Dev/Test: move ACP plain-language smoke script and mark as keep

* Docs/Skill: reorder ACP harness lists with Pi first

* ACP: split control-plane manager into core/types/utils modules

* Docs: refresh ACP thread-bound agents plan

* ACP: extract dispatch lane and split manager domains

* ACP: centralize binding context and remove reverse deps

* Infra: unify system message formatting

* ACP: centralize error boundaries and session id rendering

* ACP: enforce init concurrency cap and strict meta clear

* Tests: fix ACP dispatch binding mock typing

* Tests: fix Discord thread-binding mock drift and ACP request id

* ACP: gate slash bypass and persist cleared overrides

* ACPX: await pre-abort cancel before runTurn return

* Extension: pin acpx runtime dependency to 0.1.11

* Docs: add pinned acpx install strategy for ACP extension

* Extensions/acpx: enforce strict local pinned startup

* Extensions/acpx: tighten acp-router install guidance

* ACPX: retry runtime test temp-dir cleanup

* Extensions/acpx: require proactive ACPX repair for thread spawns

* Extensions/acpx: require restart offer after acpx reinstall

* extensions/acpx: remove workspace protocol devDependency

* extensions/acpx: bump pinned acpx to 0.1.13

* extensions/acpx: sync lockfile after dependency bump

* ACPX: make runtime spawn Windows-safe

* fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
This commit is contained in:
Onur Solmaz
2026-02-26 11:00:09 +01:00
committed by GitHub
parent a9d9a968ed
commit a7d56e3554
151 changed files with 19005 additions and 324 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,159 @@
import type { OpenClawConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js";
import { withAcpRuntimeErrorBoundary } from "../runtime/errors.js";
import {
createIdentityFromStatus,
identityEquals,
mergeSessionIdentity,
resolveRuntimeHandleIdentifiersFromIdentity,
resolveSessionIdentityFromMeta,
} from "../runtime/session-identity.js";
import type { AcpRuntime, AcpRuntimeHandle, AcpRuntimeStatus } from "../runtime/types.js";
import type { SessionAcpMeta, SessionEntry } from "./manager.types.js";
import { hasLegacyAcpIdentityProjection } from "./manager.utils.js";
export async function reconcileManagerRuntimeSessionIdentifiers(params: {
cfg: OpenClawConfig;
sessionKey: string;
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
meta: SessionAcpMeta;
runtimeStatus?: AcpRuntimeStatus;
failOnStatusError: boolean;
setCachedHandle: (sessionKey: string, handle: AcpRuntimeHandle) => void;
writeSessionMeta: (params: {
cfg: OpenClawConfig;
sessionKey: string;
mutate: (
current: SessionAcpMeta | undefined,
entry: SessionEntry | undefined,
) => SessionAcpMeta | null | undefined;
failOnError?: boolean;
}) => Promise<SessionEntry | null>;
}): Promise<{
handle: AcpRuntimeHandle;
meta: SessionAcpMeta;
runtimeStatus?: AcpRuntimeStatus;
}> {
let runtimeStatus = params.runtimeStatus;
if (!runtimeStatus && params.runtime.getStatus) {
try {
runtimeStatus = await withAcpRuntimeErrorBoundary({
run: async () =>
await params.runtime.getStatus!({
handle: params.handle,
}),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not read ACP runtime status.",
});
} catch (error) {
if (params.failOnStatusError) {
throw error;
}
logVerbose(
`acp-manager: failed to refresh ACP runtime status for ${params.sessionKey}: ${String(error)}`,
);
return {
handle: params.handle,
meta: params.meta,
runtimeStatus,
};
}
}
const now = Date.now();
const currentIdentity = resolveSessionIdentityFromMeta(params.meta);
const nextIdentity =
mergeSessionIdentity({
current: currentIdentity,
incoming: createIdentityFromStatus({
status: runtimeStatus,
now,
}),
now,
}) ?? currentIdentity;
const handleIdentifiers = resolveRuntimeHandleIdentifiersFromIdentity(nextIdentity);
const handleChanged =
handleIdentifiers.backendSessionId !== params.handle.backendSessionId ||
handleIdentifiers.agentSessionId !== params.handle.agentSessionId;
const nextHandle: AcpRuntimeHandle = handleChanged
? {
...params.handle,
...(handleIdentifiers.backendSessionId
? { backendSessionId: handleIdentifiers.backendSessionId }
: {}),
...(handleIdentifiers.agentSessionId
? { agentSessionId: handleIdentifiers.agentSessionId }
: {}),
}
: params.handle;
if (handleChanged) {
params.setCachedHandle(params.sessionKey, nextHandle);
}
const metaChanged =
!identityEquals(currentIdentity, nextIdentity) || hasLegacyAcpIdentityProjection(params.meta);
if (!metaChanged) {
return {
handle: nextHandle,
meta: params.meta,
runtimeStatus,
};
}
const nextMeta: SessionAcpMeta = {
backend: params.meta.backend,
agent: params.meta.agent,
runtimeSessionName: params.meta.runtimeSessionName,
...(nextIdentity ? { identity: nextIdentity } : {}),
mode: params.meta.mode,
...(params.meta.runtimeOptions ? { runtimeOptions: params.meta.runtimeOptions } : {}),
...(params.meta.cwd ? { cwd: params.meta.cwd } : {}),
lastActivityAt: now,
state: params.meta.state,
...(params.meta.lastError ? { lastError: params.meta.lastError } : {}),
};
if (!identityEquals(currentIdentity, nextIdentity)) {
const currentAgentSessionId = currentIdentity?.agentSessionId ?? "<none>";
const nextAgentSessionId = nextIdentity?.agentSessionId ?? "<none>";
const currentAcpxSessionId = currentIdentity?.acpxSessionId ?? "<none>";
const nextAcpxSessionId = nextIdentity?.acpxSessionId ?? "<none>";
const currentAcpxRecordId = currentIdentity?.acpxRecordId ?? "<none>";
const nextAcpxRecordId = nextIdentity?.acpxRecordId ?? "<none>";
logVerbose(
`acp-manager: session identity updated for ${params.sessionKey} ` +
`(agentSessionId ${currentAgentSessionId} -> ${nextAgentSessionId}, ` +
`acpxSessionId ${currentAcpxSessionId} -> ${nextAcpxSessionId}, ` +
`acpxRecordId ${currentAcpxRecordId} -> ${nextAcpxRecordId})`,
);
}
await params.writeSessionMeta({
cfg: params.cfg,
sessionKey: params.sessionKey,
mutate: (current, entry) => {
if (!entry) {
return null;
}
const base = current ?? entry.acp;
if (!base) {
return null;
}
return {
backend: base.backend,
agent: base.agent,
runtimeSessionName: base.runtimeSessionName,
...(nextIdentity ? { identity: nextIdentity } : {}),
mode: base.mode,
...(base.runtimeOptions ? { runtimeOptions: base.runtimeOptions } : {}),
...(base.cwd ? { cwd: base.cwd } : {}),
state: base.state,
lastActivityAt: now,
...(base.lastError ? { lastError: base.lastError } : {}),
};
},
});
return {
handle: nextHandle,
meta: nextMeta,
runtimeStatus,
};
}

View File

@@ -0,0 +1,118 @@
import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "../runtime/errors.js";
import type { AcpRuntime, AcpRuntimeCapabilities, AcpRuntimeHandle } from "../runtime/types.js";
import type { SessionAcpMeta } from "./manager.types.js";
import { createUnsupportedControlError } from "./manager.utils.js";
import type { CachedRuntimeState } from "./runtime-cache.js";
import {
buildRuntimeConfigOptionPairs,
buildRuntimeControlSignature,
normalizeText,
resolveRuntimeOptionsFromMeta,
} from "./runtime-options.js";
export async function resolveManagerRuntimeCapabilities(params: {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
}): Promise<AcpRuntimeCapabilities> {
let reported: AcpRuntimeCapabilities | undefined;
if (params.runtime.getCapabilities) {
reported = await withAcpRuntimeErrorBoundary({
run: async () => await params.runtime.getCapabilities!({ handle: params.handle }),
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not read ACP runtime capabilities.",
});
}
const controls = new Set<AcpRuntimeCapabilities["controls"][number]>(reported?.controls ?? []);
if (params.runtime.setMode) {
controls.add("session/set_mode");
}
if (params.runtime.setConfigOption) {
controls.add("session/set_config_option");
}
if (params.runtime.getStatus) {
controls.add("session/status");
}
const normalizedKeys = (reported?.configOptionKeys ?? [])
.map((entry) => normalizeText(entry))
.filter(Boolean) as string[];
return {
controls: [...controls].toSorted(),
...(normalizedKeys.length > 0 ? { configOptionKeys: normalizedKeys } : {}),
};
}
export async function applyManagerRuntimeControls(params: {
sessionKey: string;
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
meta: SessionAcpMeta;
getCachedRuntimeState: (sessionKey: string) => CachedRuntimeState | null;
}): Promise<void> {
const options = resolveRuntimeOptionsFromMeta(params.meta);
const signature = buildRuntimeControlSignature(options);
const cached = params.getCachedRuntimeState(params.sessionKey);
if (cached?.appliedControlSignature === signature) {
return;
}
const capabilities = await resolveManagerRuntimeCapabilities({
runtime: params.runtime,
handle: params.handle,
});
const backend = params.handle.backend || params.meta.backend;
const runtimeMode = normalizeText(options.runtimeMode);
const configOptions = buildRuntimeConfigOptionPairs(options);
const advertisedKeys = new Set(
(capabilities.configOptionKeys ?? [])
.map((entry) => normalizeText(entry))
.filter(Boolean) as string[],
);
await withAcpRuntimeErrorBoundary({
run: async () => {
if (runtimeMode) {
if (!capabilities.controls.includes("session/set_mode") || !params.runtime.setMode) {
throw createUnsupportedControlError({
backend,
control: "session/set_mode",
});
}
await params.runtime.setMode({
handle: params.handle,
mode: runtimeMode,
});
}
if (configOptions.length > 0) {
if (
!capabilities.controls.includes("session/set_config_option") ||
!params.runtime.setConfigOption
) {
throw createUnsupportedControlError({
backend,
control: "session/set_config_option",
});
}
for (const [key, value] of configOptions) {
if (advertisedKeys.size > 0 && !advertisedKeys.has(key)) {
throw new AcpRuntimeError(
"ACP_BACKEND_UNSUPPORTED_CONTROL",
`ACP backend "${backend}" does not accept config key "${key}".`,
);
}
await params.runtime.setConfigOption({
handle: params.handle,
key,
value,
});
}
}
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "Could not apply ACP runtime options before turn execution.",
});
if (cached) {
cached.appliedControlSignature = signature;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
import { AcpSessionManager } from "./manager.core.js";
export { AcpSessionManager } from "./manager.core.js";
export type {
AcpCloseSessionInput,
AcpCloseSessionResult,
AcpInitializeSessionInput,
AcpManagerObservabilitySnapshot,
AcpRunTurnInput,
AcpSessionResolution,
AcpSessionRuntimeOptions,
AcpSessionStatus,
AcpStartupIdentityReconcileResult,
} from "./manager.types.js";
let ACP_SESSION_MANAGER_SINGLETON: AcpSessionManager | null = null;
export function getAcpSessionManager(): AcpSessionManager {
if (!ACP_SESSION_MANAGER_SINGLETON) {
ACP_SESSION_MANAGER_SINGLETON = new AcpSessionManager();
}
return ACP_SESSION_MANAGER_SINGLETON;
}
export const __testing = {
resetAcpSessionManagerForTests() {
ACP_SESSION_MANAGER_SINGLETON = null;
},
};

View File

@@ -0,0 +1,141 @@
import type { OpenClawConfig } from "../../config/config.js";
import type {
SessionAcpIdentity,
AcpSessionRuntimeOptions,
SessionAcpMeta,
SessionEntry,
} from "../../config/sessions/types.js";
import type { AcpRuntimeError } from "../runtime/errors.js";
import { requireAcpRuntimeBackend } from "../runtime/registry.js";
import {
listAcpSessionEntries,
readAcpSessionEntry,
upsertAcpSessionMeta,
} from "../runtime/session-meta.js";
import type {
AcpRuntime,
AcpRuntimeCapabilities,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimePromptMode,
AcpRuntimeSessionMode,
AcpRuntimeStatus,
} from "../runtime/types.js";
export type AcpSessionResolution =
| {
kind: "none";
sessionKey: string;
}
| {
kind: "stale";
sessionKey: string;
error: AcpRuntimeError;
}
| {
kind: "ready";
sessionKey: string;
meta: SessionAcpMeta;
};
export type AcpInitializeSessionInput = {
cfg: OpenClawConfig;
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
cwd?: string;
backendId?: string;
};
export type AcpRunTurnInput = {
cfg: OpenClawConfig;
sessionKey: string;
text: string;
mode: AcpRuntimePromptMode;
requestId: string;
signal?: AbortSignal;
onEvent?: (event: AcpRuntimeEvent) => Promise<void> | void;
};
export type AcpCloseSessionInput = {
cfg: OpenClawConfig;
sessionKey: string;
reason: string;
clearMeta?: boolean;
allowBackendUnavailable?: boolean;
requireAcpSession?: boolean;
};
export type AcpCloseSessionResult = {
runtimeClosed: boolean;
runtimeNotice?: string;
metaCleared: boolean;
};
export type AcpSessionStatus = {
sessionKey: string;
backend: string;
agent: string;
identity?: SessionAcpIdentity;
state: SessionAcpMeta["state"];
mode: AcpRuntimeSessionMode;
runtimeOptions: AcpSessionRuntimeOptions;
capabilities: AcpRuntimeCapabilities;
runtimeStatus?: AcpRuntimeStatus;
lastActivityAt: number;
lastError?: string;
};
export type AcpManagerObservabilitySnapshot = {
runtimeCache: {
activeSessions: number;
idleTtlMs: number;
evictedTotal: number;
lastEvictedAt?: number;
};
turns: {
active: number;
queueDepth: number;
completed: number;
failed: number;
averageLatencyMs: number;
maxLatencyMs: number;
};
errorsByCode: Record<string, number>;
};
export type AcpStartupIdentityReconcileResult = {
checked: number;
resolved: number;
failed: number;
};
export type ActiveTurnState = {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
abortController: AbortController;
cancelPromise?: Promise<void>;
};
export type TurnLatencyStats = {
completed: number;
failed: number;
totalMs: number;
maxMs: number;
};
export type AcpSessionManagerDeps = {
listAcpSessions: typeof listAcpSessionEntries;
readSessionEntry: typeof readAcpSessionEntry;
upsertSessionMeta: typeof upsertAcpSessionMeta;
requireRuntimeBackend: typeof requireAcpRuntimeBackend;
};
export const DEFAULT_DEPS: AcpSessionManagerDeps = {
listAcpSessions: listAcpSessionEntries,
readSessionEntry: readAcpSessionEntry,
upsertSessionMeta: upsertAcpSessionMeta,
requireRuntimeBackend: requireAcpRuntimeBackend,
};
export type { AcpSessionRuntimeOptions, SessionAcpMeta, SessionEntry };

View File

@@ -0,0 +1,64 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionAcpMeta } from "../../config/sessions/types.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? fallback);
}
export function resolveMissingMetaError(sessionKey: string): AcpRuntimeError {
return new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`ACP metadata is missing for ${sessionKey}. Recreate this ACP session with /acp spawn and rebind the thread.`,
);
}
export function normalizeSessionKey(sessionKey: string): string {
return sessionKey.trim();
}
export function normalizeActorKey(sessionKey: string): string {
return sessionKey.trim().toLowerCase();
}
export function normalizeAcpErrorCode(code: string | undefined): AcpRuntimeError["code"] {
if (!code) {
return "ACP_TURN_FAILED";
}
const normalized = code.trim().toUpperCase();
for (const allowed of ACP_ERROR_CODES) {
if (allowed === normalized) {
return allowed;
}
}
return "ACP_TURN_FAILED";
}
export function createUnsupportedControlError(params: {
backend: string;
control: string;
}): AcpRuntimeError {
return new AcpRuntimeError(
"ACP_BACKEND_UNSUPPORTED_CONTROL",
`ACP backend "${params.backend}" does not support ${params.control}.`,
);
}
export function resolveRuntimeIdleTtlMs(cfg: OpenClawConfig): number {
const ttlMinutes = cfg.acp?.runtime?.ttlMinutes;
if (typeof ttlMinutes !== "number" || !Number.isFinite(ttlMinutes) || ttlMinutes <= 0) {
return 0;
}
return Math.round(ttlMinutes * 60 * 1000);
}
export function hasLegacyAcpIdentityProjection(meta: SessionAcpMeta): boolean {
const raw = meta as Record<string, unknown>;
return (
Object.hasOwn(raw, "backendSessionId") ||
Object.hasOwn(raw, "agentSessionId") ||
Object.hasOwn(raw, "sessionIdsProvisional")
);
}

View File

@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import type { AcpRuntime } from "../runtime/types.js";
import type { AcpRuntimeHandle } from "../runtime/types.js";
import type { CachedRuntimeState } from "./runtime-cache.js";
import { RuntimeCache } from "./runtime-cache.js";
function mockState(sessionKey: string): CachedRuntimeState {
const runtime = {
ensureSession: vi.fn(async () => ({
sessionKey,
backend: "acpx",
runtimeSessionName: `runtime:${sessionKey}`,
})),
runTurn: vi.fn(async function* () {
yield { type: "done" as const };
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
} as unknown as AcpRuntime;
return {
runtime,
handle: {
sessionKey,
backend: "acpx",
runtimeSessionName: `runtime:${sessionKey}`,
} as AcpRuntimeHandle,
backend: "acpx",
agent: "codex",
mode: "persistent",
};
}
describe("RuntimeCache", () => {
it("tracks idle candidates with touch-aware lookups", () => {
vi.useFakeTimers();
try {
const cache = new RuntimeCache();
const actor = "agent:codex:acp:s1";
cache.set(actor, mockState(actor), { now: 1_000 });
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 1_999 })).toHaveLength(0);
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 2_000 })).toHaveLength(1);
cache.get(actor, { now: 2_500 });
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 3_200 })).toHaveLength(0);
expect(cache.collectIdleCandidates({ maxIdleMs: 1_000, now: 3_500 })).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("returns snapshot entries with idle durations", () => {
const cache = new RuntimeCache();
cache.set("a", mockState("a"), { now: 10 });
cache.set("b", mockState("b"), { now: 100 });
const snapshot = cache.snapshot({ now: 1_100 });
const byActor = new Map(snapshot.map((entry) => [entry.actorKey, entry]));
expect(byActor.get("a")?.idleMs).toBe(1_090);
expect(byActor.get("b")?.idleMs).toBe(1_000);
});
});

View File

@@ -0,0 +1,99 @@
import type { AcpRuntime, AcpRuntimeHandle, AcpRuntimeSessionMode } from "../runtime/types.js";
export type CachedRuntimeState = {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
backend: string;
agent: string;
mode: AcpRuntimeSessionMode;
cwd?: string;
appliedControlSignature?: string;
};
type RuntimeCacheEntry = {
state: CachedRuntimeState;
lastTouchedAt: number;
};
export type CachedRuntimeSnapshot = {
actorKey: string;
state: CachedRuntimeState;
lastTouchedAt: number;
idleMs: number;
};
export class RuntimeCache {
private readonly cache = new Map<string, RuntimeCacheEntry>();
size(): number {
return this.cache.size;
}
has(actorKey: string): boolean {
return this.cache.has(actorKey);
}
get(
actorKey: string,
params: {
touch?: boolean;
now?: number;
} = {},
): CachedRuntimeState | null {
const entry = this.cache.get(actorKey);
if (!entry) {
return null;
}
if (params.touch !== false) {
entry.lastTouchedAt = params.now ?? Date.now();
}
return entry.state;
}
peek(actorKey: string): CachedRuntimeState | null {
return this.get(actorKey, { touch: false });
}
getLastTouchedAt(actorKey: string): number | null {
return this.cache.get(actorKey)?.lastTouchedAt ?? null;
}
set(
actorKey: string,
state: CachedRuntimeState,
params: {
now?: number;
} = {},
): void {
this.cache.set(actorKey, {
state,
lastTouchedAt: params.now ?? Date.now(),
});
}
clear(actorKey: string): void {
this.cache.delete(actorKey);
}
snapshot(params: { now?: number } = {}): CachedRuntimeSnapshot[] {
const now = params.now ?? Date.now();
const entries: CachedRuntimeSnapshot[] = [];
for (const [actorKey, entry] of this.cache.entries()) {
entries.push({
actorKey,
state: entry.state,
lastTouchedAt: entry.lastTouchedAt,
idleMs: Math.max(0, now - entry.lastTouchedAt),
});
}
return entries;
}
collectIdleCandidates(params: { maxIdleMs: number; now?: number }): CachedRuntimeSnapshot[] {
if (!Number.isFinite(params.maxIdleMs) || params.maxIdleMs <= 0) {
return [];
}
const now = params.now ?? Date.now();
return this.snapshot({ now }).filter((entry) => entry.idleMs >= params.maxIdleMs);
}
}

View File

@@ -0,0 +1,349 @@
import { isAbsolute } from "node:path";
import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js";
import { AcpRuntimeError } from "../runtime/errors.js";
const MAX_RUNTIME_MODE_LENGTH = 64;
const MAX_MODEL_LENGTH = 200;
const MAX_PERMISSION_PROFILE_LENGTH = 80;
const MAX_CWD_LENGTH = 4096;
const MIN_TIMEOUT_SECONDS = 1;
const MAX_TIMEOUT_SECONDS = 24 * 60 * 60;
const MAX_BACKEND_OPTION_KEY_LENGTH = 64;
const MAX_BACKEND_OPTION_VALUE_LENGTH = 512;
const MAX_BACKEND_EXTRAS = 32;
const SAFE_OPTION_KEY_RE = /^[a-z0-9][a-z0-9._:-]*$/i;
function failInvalidOption(message: string): never {
throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message);
}
function validateNoControlChars(value: string, field: string): string {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code < 32 || code === 127) {
failInvalidOption(`${field} must not include control characters.`);
}
}
return value;
}
function validateBoundedText(params: { value: unknown; field: string; maxLength: number }): string {
const normalized = normalizeText(params.value);
if (!normalized) {
failInvalidOption(`${params.field} must not be empty.`);
}
if (normalized.length > params.maxLength) {
failInvalidOption(`${params.field} must be at most ${params.maxLength} characters.`);
}
return validateNoControlChars(normalized, params.field);
}
function validateBackendOptionKey(rawKey: unknown): string {
const key = validateBoundedText({
value: rawKey,
field: "ACP config key",
maxLength: MAX_BACKEND_OPTION_KEY_LENGTH,
});
if (!SAFE_OPTION_KEY_RE.test(key)) {
failInvalidOption(
"ACP config key must use letters, numbers, dots, colons, underscores, or dashes.",
);
}
return key;
}
function validateBackendOptionValue(rawValue: unknown): string {
return validateBoundedText({
value: rawValue,
field: "ACP config value",
maxLength: MAX_BACKEND_OPTION_VALUE_LENGTH,
});
}
export function validateRuntimeModeInput(rawMode: unknown): string {
return validateBoundedText({
value: rawMode,
field: "Runtime mode",
maxLength: MAX_RUNTIME_MODE_LENGTH,
});
}
export function validateRuntimeModelInput(rawModel: unknown): string {
return validateBoundedText({
value: rawModel,
field: "Model id",
maxLength: MAX_MODEL_LENGTH,
});
}
export function validateRuntimePermissionProfileInput(rawProfile: unknown): string {
return validateBoundedText({
value: rawProfile,
field: "Permission profile",
maxLength: MAX_PERMISSION_PROFILE_LENGTH,
});
}
export function validateRuntimeCwdInput(rawCwd: unknown): string {
const cwd = validateBoundedText({
value: rawCwd,
field: "Working directory",
maxLength: MAX_CWD_LENGTH,
});
if (!isAbsolute(cwd)) {
failInvalidOption(`Working directory must be an absolute path. Received "${cwd}".`);
}
return cwd;
}
export function validateRuntimeTimeoutSecondsInput(rawTimeout: unknown): number {
if (typeof rawTimeout !== "number" || !Number.isFinite(rawTimeout)) {
failInvalidOption("Timeout must be a positive integer in seconds.");
}
const timeout = Math.round(rawTimeout);
if (timeout < MIN_TIMEOUT_SECONDS || timeout > MAX_TIMEOUT_SECONDS) {
failInvalidOption(
`Timeout must be between ${MIN_TIMEOUT_SECONDS} and ${MAX_TIMEOUT_SECONDS} seconds.`,
);
}
return timeout;
}
export function parseRuntimeTimeoutSecondsInput(rawTimeout: unknown): number {
const normalized = normalizeText(rawTimeout);
if (!normalized || !/^\d+$/.test(normalized)) {
failInvalidOption("Timeout must be a positive integer in seconds.");
}
return validateRuntimeTimeoutSecondsInput(Number.parseInt(normalized, 10));
}
export function validateRuntimeConfigOptionInput(
rawKey: unknown,
rawValue: unknown,
): {
key: string;
value: string;
} {
return {
key: validateBackendOptionKey(rawKey),
value: validateBackendOptionValue(rawValue),
};
}
export function validateRuntimeOptionPatch(
patch: Partial<AcpSessionRuntimeOptions> | undefined,
): Partial<AcpSessionRuntimeOptions> {
if (!patch) {
return {};
}
const rawPatch = patch as Record<string, unknown>;
const allowedKeys = new Set([
"runtimeMode",
"model",
"cwd",
"permissionProfile",
"timeoutSeconds",
"backendExtras",
]);
for (const key of Object.keys(rawPatch)) {
if (!allowedKeys.has(key)) {
failInvalidOption(`Unknown runtime option "${key}".`);
}
}
const next: Partial<AcpSessionRuntimeOptions> = {};
if (Object.hasOwn(rawPatch, "runtimeMode")) {
if (rawPatch.runtimeMode === undefined) {
next.runtimeMode = undefined;
} else {
next.runtimeMode = validateRuntimeModeInput(rawPatch.runtimeMode);
}
}
if (Object.hasOwn(rawPatch, "model")) {
if (rawPatch.model === undefined) {
next.model = undefined;
} else {
next.model = validateRuntimeModelInput(rawPatch.model);
}
}
if (Object.hasOwn(rawPatch, "cwd")) {
if (rawPatch.cwd === undefined) {
next.cwd = undefined;
} else {
next.cwd = validateRuntimeCwdInput(rawPatch.cwd);
}
}
if (Object.hasOwn(rawPatch, "permissionProfile")) {
if (rawPatch.permissionProfile === undefined) {
next.permissionProfile = undefined;
} else {
next.permissionProfile = validateRuntimePermissionProfileInput(rawPatch.permissionProfile);
}
}
if (Object.hasOwn(rawPatch, "timeoutSeconds")) {
if (rawPatch.timeoutSeconds === undefined) {
next.timeoutSeconds = undefined;
} else {
next.timeoutSeconds = validateRuntimeTimeoutSecondsInput(rawPatch.timeoutSeconds);
}
}
if (Object.hasOwn(rawPatch, "backendExtras")) {
const rawExtras = rawPatch.backendExtras;
if (rawExtras === undefined) {
next.backendExtras = undefined;
} else if (!rawExtras || typeof rawExtras !== "object" || Array.isArray(rawExtras)) {
failInvalidOption("Backend extras must be a key/value object.");
} else {
const entries = Object.entries(rawExtras);
if (entries.length > MAX_BACKEND_EXTRAS) {
failInvalidOption(`Backend extras must include at most ${MAX_BACKEND_EXTRAS} entries.`);
}
const extras: Record<string, string> = {};
for (const [entryKey, entryValue] of entries) {
const { key, value } = validateRuntimeConfigOptionInput(entryKey, entryValue);
extras[key] = value;
}
next.backendExtras = Object.keys(extras).length > 0 ? extras : undefined;
}
}
return next;
}
export function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export function normalizeRuntimeOptions(
options: AcpSessionRuntimeOptions | undefined,
): AcpSessionRuntimeOptions {
const runtimeMode = normalizeText(options?.runtimeMode);
const model = normalizeText(options?.model);
const cwd = normalizeText(options?.cwd);
const permissionProfile = normalizeText(options?.permissionProfile);
let timeoutSeconds: number | undefined;
if (typeof options?.timeoutSeconds === "number" && Number.isFinite(options.timeoutSeconds)) {
const rounded = Math.round(options.timeoutSeconds);
if (rounded > 0) {
timeoutSeconds = rounded;
}
}
const backendExtrasEntries = Object.entries(options?.backendExtras ?? {})
.map(([key, value]) => [normalizeText(key), normalizeText(value)] as const)
.filter(([key, value]) => Boolean(key && value)) as Array<[string, string]>;
const backendExtras =
backendExtrasEntries.length > 0 ? Object.fromEntries(backendExtrasEntries) : undefined;
return {
...(runtimeMode ? { runtimeMode } : {}),
...(model ? { model } : {}),
...(cwd ? { cwd } : {}),
...(permissionProfile ? { permissionProfile } : {}),
...(typeof timeoutSeconds === "number" ? { timeoutSeconds } : {}),
...(backendExtras ? { backendExtras } : {}),
};
}
export function mergeRuntimeOptions(params: {
current?: AcpSessionRuntimeOptions;
patch?: Partial<AcpSessionRuntimeOptions>;
}): AcpSessionRuntimeOptions {
const current = normalizeRuntimeOptions(params.current);
const patch = normalizeRuntimeOptions(validateRuntimeOptionPatch(params.patch));
const mergedExtras = {
...current.backendExtras,
...patch.backendExtras,
};
return normalizeRuntimeOptions({
...current,
...patch,
...(Object.keys(mergedExtras).length > 0 ? { backendExtras: mergedExtras } : {}),
});
}
export function resolveRuntimeOptionsFromMeta(meta: SessionAcpMeta): AcpSessionRuntimeOptions {
const normalized = normalizeRuntimeOptions(meta.runtimeOptions);
if (normalized.cwd || !meta.cwd) {
return normalized;
}
return normalizeRuntimeOptions({
...normalized,
cwd: meta.cwd,
});
}
export function runtimeOptionsEqual(
a: AcpSessionRuntimeOptions | undefined,
b: AcpSessionRuntimeOptions | undefined,
): boolean {
return JSON.stringify(normalizeRuntimeOptions(a)) === JSON.stringify(normalizeRuntimeOptions(b));
}
export function buildRuntimeControlSignature(options: AcpSessionRuntimeOptions): string {
const normalized = normalizeRuntimeOptions(options);
const extras = Object.entries(normalized.backendExtras ?? {}).toSorted(([a], [b]) =>
a.localeCompare(b),
);
return JSON.stringify({
runtimeMode: normalized.runtimeMode ?? null,
model: normalized.model ?? null,
permissionProfile: normalized.permissionProfile ?? null,
timeoutSeconds: normalized.timeoutSeconds ?? null,
backendExtras: extras,
});
}
export function buildRuntimeConfigOptionPairs(
options: AcpSessionRuntimeOptions,
): Array<[string, string]> {
const normalized = normalizeRuntimeOptions(options);
const pairs = new Map<string, string>();
if (normalized.model) {
pairs.set("model", normalized.model);
}
if (normalized.permissionProfile) {
pairs.set("approval_policy", normalized.permissionProfile);
}
if (typeof normalized.timeoutSeconds === "number") {
pairs.set("timeout", String(normalized.timeoutSeconds));
}
for (const [key, value] of Object.entries(normalized.backendExtras ?? {})) {
if (!pairs.has(key)) {
pairs.set(key, value);
}
}
return [...pairs.entries()];
}
export function inferRuntimeOptionPatchFromConfigOption(
key: string,
value: string,
): Partial<AcpSessionRuntimeOptions> {
const validated = validateRuntimeConfigOptionInput(key, value);
const normalizedKey = validated.key.toLowerCase();
if (normalizedKey === "model") {
return { model: validateRuntimeModelInput(validated.value) };
}
if (
normalizedKey === "approval_policy" ||
normalizedKey === "permission_profile" ||
normalizedKey === "permissions"
) {
return { permissionProfile: validateRuntimePermissionProfileInput(validated.value) };
}
if (normalizedKey === "timeout" || normalizedKey === "timeout_seconds") {
return { timeoutSeconds: parseRuntimeTimeoutSecondsInput(validated.value) };
}
if (normalizedKey === "cwd") {
return { cwd: validateRuntimeCwdInput(validated.value) };
}
return {
backendExtras: {
[validated.key]: validated.value,
},
};
}

View File

@@ -0,0 +1,53 @@
export class SessionActorQueue {
private readonly tailBySession = new Map<string, Promise<void>>();
private readonly pendingBySession = new Map<string, number>();
getTailMapForTesting(): Map<string, Promise<void>> {
return this.tailBySession;
}
getTotalPendingCount(): number {
let total = 0;
for (const count of this.pendingBySession.values()) {
total += count;
}
return total;
}
getPendingCountForSession(actorKey: string): number {
return this.pendingBySession.get(actorKey) ?? 0;
}
async run<T>(actorKey: string, op: () => Promise<T>): Promise<T> {
const previous = this.tailBySession.get(actorKey) ?? Promise.resolve();
this.pendingBySession.set(actorKey, (this.pendingBySession.get(actorKey) ?? 0) + 1);
let release: () => void = () => {};
const marker = new Promise<void>((resolve) => {
release = resolve;
});
const queuedTail = previous
.catch(() => {
// Keep actor queue alive after an operation failure.
})
.then(() => marker);
this.tailBySession.set(actorKey, queuedTail);
await previous.catch(() => {
// Previous failures should not block newer commands.
});
try {
return await op();
} finally {
const pending = (this.pendingBySession.get(actorKey) ?? 1) - 1;
if (pending <= 0) {
this.pendingBySession.delete(actorKey);
} else {
this.pendingBySession.set(actorKey, pending);
}
release();
if (this.tailBySession.get(actorKey) === queuedTail) {
this.tailBySession.delete(actorKey);
}
}
}
}

View File

@@ -0,0 +1,77 @@
import type { OpenClawConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
import { getAcpSessionManager } from "./manager.js";
export type AcpSpawnRuntimeCloseHandle = {
runtime: {
close: (params: {
handle: { sessionKey: string; backend: string; runtimeSessionName: string };
reason: string;
}) => Promise<void>;
};
handle: { sessionKey: string; backend: string; runtimeSessionName: string };
};
export async function cleanupFailedAcpSpawn(params: {
cfg: OpenClawConfig;
sessionKey: string;
shouldDeleteSession: boolean;
deleteTranscript: boolean;
runtimeCloseHandle?: AcpSpawnRuntimeCloseHandle;
}): Promise<void> {
if (params.runtimeCloseHandle) {
await params.runtimeCloseHandle.runtime
.close({
handle: params.runtimeCloseHandle.handle,
reason: "spawn-failed",
})
.catch((err) => {
logVerbose(
`acp-spawn: runtime cleanup close failed for ${params.sessionKey}: ${String(err)}`,
);
});
}
const acpManager = getAcpSessionManager();
await acpManager
.closeSession({
cfg: params.cfg,
sessionKey: params.sessionKey,
reason: "spawn-failed",
allowBackendUnavailable: true,
requireAcpSession: false,
})
.catch((err) => {
logVerbose(
`acp-spawn: manager cleanup close failed for ${params.sessionKey}: ${String(err)}`,
);
});
await getSessionBindingService()
.unbind({
targetSessionKey: params.sessionKey,
reason: "spawn-failed",
})
.catch((err) => {
logVerbose(
`acp-spawn: binding cleanup unbind failed for ${params.sessionKey}: ${String(err)}`,
);
});
if (!params.shouldDeleteSession) {
return;
}
await callGateway({
method: "sessions.delete",
params: {
key: params.sessionKey,
deleteTranscript: params.deleteTranscript,
emitLifecycleHooks: false,
},
timeoutMs: 10_000,
}).catch(() => {
// Best-effort cleanup only.
});
}