mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 14:30:24 +00:00
ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist * Gateway: harden ACP startup and service PATH * ACP: reinitialize error-state configured bindings * ACP: classify pre-turn runtime failures as session init failures * Plugins: move configured ACP routing behind channel seams * Telegram tests: align startup probe assertions after rebase * Discord: harden ACP configured binding recovery * ACP: recover Discord bindings after stale runtime exits * ACPX: replace dead sessions during ensure * Discord: harden ACP binding recovery * Discord: fix review follow-ups * ACP bindings: load channel snapshots across workspaces * ACP bindings: cache snapshot channel plugin resolution * Experiments: add ACP pluginification holy grail plan * Experiments: rename ACP pluginification plan doc * Experiments: drop old ACP pluginification doc path * ACP: move configured bindings behind plugin services * Experiments: update bindings capability architecture plan * Bindings: isolate configured binding routing and targets * Discord tests: fix runtime env helper path * Tests: fix channel binding CI regressions * Tests: normalize ACP workspace assertion on Windows * Bindings: isolate configured binding registry * Bindings: finish configured binding cleanup * Bindings: finish generic cleanup * Bindings: align runtime approval callbacks * ACP: delete residual bindings barrel * Bindings: restore legacy compatibility * Revert "Bindings: restore legacy compatibility" This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe. * Tests: drop ACP route legacy helper names * Discord/ACP: fix binding regressions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
@@ -39,6 +39,25 @@ describe("acpx plugin config parsing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers the workspace plugin root for dist/extensions/acpx bundles", () => {
|
||||
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-workspace-"));
|
||||
const workspacePluginRoot = path.join(repoRoot, "extensions", "acpx");
|
||||
const bundledPluginRoot = path.join(repoRoot, "dist", "extensions", "acpx");
|
||||
try {
|
||||
fs.mkdirSync(workspacePluginRoot, { recursive: true });
|
||||
fs.mkdirSync(bundledPluginRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(workspacePluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(workspacePluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(bundledPluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(bundledPluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(bundledPluginRoot, "index.js")).href;
|
||||
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(workspacePluginRoot);
|
||||
} finally {
|
||||
fs.rmSync(repoRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves bundled acpx with pinned version by default", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
|
||||
@@ -13,14 +13,18 @@ export const ACPX_PINNED_VERSION = "0.1.16";
|
||||
export const ACPX_VERSION_ANY = "any";
|
||||
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
||||
|
||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||
function isAcpxPluginRoot(dir: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(dir, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(dir, "package.json"))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNearestAcpxPluginRoot(moduleUrl: string): string {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
// Bundled entries live at the plugin root while source files still live under src/.
|
||||
if (
|
||||
fs.existsSync(path.join(cursor, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(cursor, "package.json"))
|
||||
) {
|
||||
if (isAcpxPluginRoot(cursor)) {
|
||||
return cursor;
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
@@ -32,10 +36,29 @@ export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): stri
|
||||
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
||||
}
|
||||
|
||||
function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null {
|
||||
if (
|
||||
path.basename(currentRoot) !== "acpx" ||
|
||||
path.basename(path.dirname(currentRoot)) !== "extensions" ||
|
||||
path.basename(path.dirname(path.dirname(currentRoot))) !== "dist"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx");
|
||||
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
||||
}
|
||||
|
||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||
const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl);
|
||||
// In a live repo checkout, dist/ can be rebuilt out from under the running gateway.
|
||||
// Prefer the stable source plugin root when a built extension is running beside it.
|
||||
return resolveWorkspaceAcpxPluginRoot(resolvedRoot) ?? resolvedRoot;
|
||||
}
|
||||
|
||||
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
|
||||
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
|
||||
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
|
||||
return `npm install --omit=dev --no-save acpx@${version}`;
|
||||
return `npm install --omit=dev --no-save --package-lock=false acpx@${version}`;
|
||||
}
|
||||
export const ACPX_LOCAL_INSTALL_COMMAND = buildAcpxLocalInstallCommand();
|
||||
|
||||
|
||||
@@ -85,7 +85,13 @@ describe("acpx ensure", () => {
|
||||
});
|
||||
expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`],
|
||||
args: [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--no-save",
|
||||
"--package-lock=false",
|
||||
`acpx@${ACPX_PINNED_VERSION}`,
|
||||
],
|
||||
cwd: "/plugin",
|
||||
stripProviderAuthEnvVars,
|
||||
});
|
||||
|
||||
@@ -233,7 +233,13 @@ export async function ensureAcpx(params: {
|
||||
|
||||
const install = await spawnAndCollect({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-save", `acpx@${installVersion}`],
|
||||
args: [
|
||||
"install",
|
||||
"--omit=dev",
|
||||
"--no-save",
|
||||
"--package-lock=false",
|
||||
`acpx@${installVersion}`,
|
||||
],
|
||||
cwd: pluginRoot,
|
||||
stripProviderAuthEnvVars: params.stripProviderAuthEnvVars,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -64,6 +64,58 @@ describe("resolveSpawnCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("routes node shebang wrappers through the current node runtime on posix", async () => {
|
||||
const dir = await createTempDir();
|
||||
const scriptPath = path.join(dir, "acpx");
|
||||
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: scriptPath,
|
||||
args: ["--help"],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
platform: "linux",
|
||||
env: {},
|
||||
execPath: "/custom/node",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
command: "/custom/node",
|
||||
args: [scriptPath, "--help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes PATH-resolved node shebang wrappers through the current node runtime on posix", async () => {
|
||||
const dir = await createTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
const scriptPath = path.join(binDir, "acpx");
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await writeFile(scriptPath, "#!/usr/bin/env node\nconsole.log('ok')\n", "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
command: "acpx",
|
||||
args: ["--help"],
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
platform: "linux",
|
||||
env: { PATH: binDir },
|
||||
execPath: "/custom/node",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolved).toEqual({
|
||||
command: "/custom/node",
|
||||
args: [scriptPath, "--help"],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes .js command execution through node on windows", () => {
|
||||
const resolved = resolveSpawnCommand(
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
WindowsSpawnProgram,
|
||||
WindowsSpawnProgramCandidate,
|
||||
@@ -57,11 +58,76 @@ const DEFAULT_RUNTIME: SpawnRuntime = {
|
||||
execPath: process.execPath,
|
||||
};
|
||||
|
||||
function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean {
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return false;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
return true;
|
||||
}
|
||||
accessSync(filePath, fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExecutableFromPath(command: string, runtime: SpawnRuntime): string | undefined {
|
||||
const pathEnv = runtime.env.PATH ?? runtime.env.Path;
|
||||
if (!pathEnv) {
|
||||
return undefined;
|
||||
}
|
||||
for (const entry of pathEnv.split(path.delimiter).filter(Boolean)) {
|
||||
const candidate = path.join(entry, command);
|
||||
if (isExecutableFile(candidate, runtime.platform)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveNodeShebangScriptPath(command: string, runtime: SpawnRuntime): string | undefined {
|
||||
const commandPath =
|
||||
path.isAbsolute(command) || command.includes(path.sep)
|
||||
? command
|
||||
: resolveExecutableFromPath(command, runtime);
|
||||
if (!commandPath || !isExecutableFile(commandPath, runtime.platform)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const firstLine = readFileSync(commandPath, "utf8").split(/\r?\n/, 1)[0] ?? "";
|
||||
if (/^#!.*(?:\/usr\/bin\/env\s+node\b|\/node(?:js)?\b)/.test(firstLine)) {
|
||||
return commandPath;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSpawnCommand(
|
||||
params: { command: string; args: string[] },
|
||||
options?: SpawnCommandOptions,
|
||||
runtime: SpawnRuntime = DEFAULT_RUNTIME,
|
||||
): ResolvedSpawnCommand {
|
||||
if (runtime.platform !== "win32") {
|
||||
const nodeShebangScript = resolveNodeShebangScriptPath(params.command, runtime);
|
||||
if (nodeShebangScript) {
|
||||
options?.onResolved?.({
|
||||
command: params.command,
|
||||
cacheHit: false,
|
||||
strictWindowsCmdWrapper: options?.strictWindowsCmdWrapper === true,
|
||||
resolution: "direct",
|
||||
});
|
||||
return {
|
||||
command: runtime.execPath,
|
||||
args: [nodeShebangScript, ...params.args],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const strictWindowsCmdWrapper = options?.strictWindowsCmdWrapper === true;
|
||||
const cacheKey = params.command;
|
||||
const cachedProgram = options?.cache;
|
||||
|
||||
@@ -154,6 +154,90 @@ describe("AcpxRuntime", () => {
|
||||
expect(resumeArgs[resumeFlagIndex + 1]).toBe(resumeSessionId);
|
||||
});
|
||||
|
||||
it("replaces dead named sessions returned by sessions ensure", async () => {
|
||||
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
|
||||
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const sessionKey = "agent:codex:acp:dead-session";
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||
expect(newIndex).toBeGreaterThan(statusIndex);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a live named session when sessions ensure exits before returning identifiers", async () => {
|
||||
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
|
||||
process.env.MOCK_ACPX_STATUS_STATUS = "alive";
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const sessionKey = "agent:codex:acp:ensure-fallback-alive";
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
expect(handle.acpxRecordId).toBe("rec-" + sessionKey);
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||
expect(newIndex).toBe(-1);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
|
||||
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a fresh named session when sessions ensure exits and status is dead", async () => {
|
||||
process.env.MOCK_ACPX_ENSURE_EXIT_1 = "1";
|
||||
process.env.MOCK_ACPX_STATUS_STATUS = "dead";
|
||||
process.env.MOCK_ACPX_STATUS_SUMMARY = "queue owner unavailable";
|
||||
try {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
const sessionKey = "agent:codex:acp:ensure-fallback-dead";
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
});
|
||||
|
||||
expect(handle.backend).toBe("acpx");
|
||||
const logs = await readMockRuntimeLogEntries(logPath);
|
||||
const ensureIndex = logs.findIndex((entry) => entry.kind === "ensure");
|
||||
const statusIndex = logs.findIndex((entry) => entry.kind === "status");
|
||||
const newIndex = logs.findIndex((entry) => entry.kind === "new");
|
||||
expect(ensureIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(statusIndex).toBeGreaterThan(ensureIndex);
|
||||
expect(newIndex).toBeGreaterThan(statusIndex);
|
||||
} finally {
|
||||
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
|
||||
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes text plus image attachments into ACP prompt blocks", async () => {
|
||||
const { runtime, logPath } = await createMockRuntimeFixture();
|
||||
|
||||
|
||||
@@ -92,6 +92,26 @@ function formatAcpxExitMessage(params: {
|
||||
return stderr || `acpx exited with code ${params.exitCode ?? "unknown"}`;
|
||||
}
|
||||
|
||||
function summarizeLogText(text: string, maxChars = 240): string {
|
||||
const normalized = text.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length <= maxChars) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, maxChars)}...`;
|
||||
}
|
||||
|
||||
function findSessionIdentifierEvent(events: AcpxJsonObject[]): AcpxJsonObject | undefined {
|
||||
return events.find(
|
||||
(event) =>
|
||||
asOptionalString(event.agentSessionId) ||
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
);
|
||||
}
|
||||
|
||||
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
|
||||
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
||||
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
|
||||
@@ -252,6 +272,146 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.healthy = result.ok;
|
||||
}
|
||||
|
||||
private async createNamedSession(params: {
|
||||
agent: string;
|
||||
cwd: string;
|
||||
sessionName: string;
|
||||
resumeSessionId?: string;
|
||||
}): Promise<AcpxJsonObject[]> {
|
||||
const command = params.resumeSessionId
|
||||
? [
|
||||
"sessions",
|
||||
"new",
|
||||
"--name",
|
||||
params.sessionName,
|
||||
"--resume-session",
|
||||
params.resumeSessionId,
|
||||
]
|
||||
: ["sessions", "new", "--name", params.sessionName];
|
||||
return await this.runControlCommand({
|
||||
args: await this.buildVerbArgs({
|
||||
agent: params.agent,
|
||||
cwd: params.cwd,
|
||||
command,
|
||||
}),
|
||||
cwd: params.cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
}
|
||||
|
||||
private async shouldReplaceEnsuredSession(params: {
|
||||
sessionName: string;
|
||||
agent: string;
|
||||
cwd: string;
|
||||
}): Promise<boolean> {
|
||||
const args = await this.buildVerbArgs({
|
||||
agent: params.agent,
|
||||
cwd: params.cwd,
|
||||
command: ["status", "--session", params.sessionName],
|
||||
});
|
||||
let events: AcpxJsonObject[];
|
||||
try {
|
||||
events = await this.runControlCommand({
|
||||
args,
|
||||
cwd: params.cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
ignoreNoSession: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession status probe failed: session=${params.sessionName} cwd=${params.cwd} error=${summarizeLogText(error instanceof Error ? error.message : String(error)) || "<empty>"}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const noSession = events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION");
|
||||
if (noSession) {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession replacing missing named session: session=${params.sessionName} cwd=${params.cwd}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const detail = events.find((event) => !toAcpxErrorEvent(event));
|
||||
const status = asTrimmedString(detail?.status)?.toLowerCase();
|
||||
if (status === "dead") {
|
||||
const summary = summarizeLogText(asOptionalString(detail?.summary) ?? "");
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession replacing dead named session: session=${params.sessionName} cwd=${params.cwd} status=${status} summary=${summary || "<empty>"}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async recoverEnsureFailure(params: {
|
||||
sessionName: string;
|
||||
agent: string;
|
||||
cwd: string;
|
||||
error: unknown;
|
||||
}): Promise<AcpxJsonObject[] | null> {
|
||||
const errorMessage = summarizeLogText(
|
||||
params.error instanceof Error ? params.error.message : String(params.error),
|
||||
);
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession probing named session after ensure failure: session=${params.sessionName} cwd=${params.cwd} error=${errorMessage || "<empty>"}`,
|
||||
);
|
||||
const args = await this.buildVerbArgs({
|
||||
agent: params.agent,
|
||||
cwd: params.cwd,
|
||||
command: ["status", "--session", params.sessionName],
|
||||
});
|
||||
let events: AcpxJsonObject[];
|
||||
try {
|
||||
events = await this.runControlCommand({
|
||||
args,
|
||||
cwd: params.cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
ignoreNoSession: true,
|
||||
});
|
||||
} catch (statusError) {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession status fallback failed: session=${params.sessionName} cwd=${params.cwd} error=${summarizeLogText(statusError instanceof Error ? statusError.message : String(statusError)) || "<empty>"}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const noSession = events.some((event) => toAcpxErrorEvent(event)?.code === "NO_SESSION");
|
||||
if (noSession) {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession creating named session after ensure failure and missing status: session=${params.sessionName} cwd=${params.cwd}`,
|
||||
);
|
||||
return await this.createNamedSession({
|
||||
agent: params.agent,
|
||||
cwd: params.cwd,
|
||||
sessionName: params.sessionName,
|
||||
});
|
||||
}
|
||||
|
||||
const detail = events.find((event) => !toAcpxErrorEvent(event));
|
||||
const status = asTrimmedString(detail?.status)?.toLowerCase();
|
||||
if (status === "dead") {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession replacing dead named session after ensure failure: session=${params.sessionName} cwd=${params.cwd}`,
|
||||
);
|
||||
return await this.createNamedSession({
|
||||
agent: params.agent,
|
||||
cwd: params.cwd,
|
||||
sessionName: params.sessionName,
|
||||
});
|
||||
}
|
||||
|
||||
if (status === "alive" || findSessionIdentifierEvent(events)) {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession reusing live named session after ensure failure: session=${params.sessionName} cwd=${params.cwd} status=${status || "unknown"}`,
|
||||
);
|
||||
return events;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
|
||||
const sessionName = asTrimmedString(input.sessionKey);
|
||||
if (!sessionName) {
|
||||
@@ -264,45 +424,80 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
const cwd = asTrimmedString(input.cwd) || this.config.cwd;
|
||||
const mode = input.mode;
|
||||
const resumeSessionId = asTrimmedString(input.resumeSessionId);
|
||||
const ensureSubcommand = resumeSessionId
|
||||
? ["sessions", "new", "--name", sessionName, "--resume-session", resumeSessionId]
|
||||
: ["sessions", "ensure", "--name", sessionName];
|
||||
const ensureCommand = await this.buildVerbArgs({
|
||||
agent,
|
||||
cwd,
|
||||
command: ensureSubcommand,
|
||||
});
|
||||
|
||||
let events = await this.runControlCommand({
|
||||
args: ensureCommand,
|
||||
cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
let ensuredEvent = events.find(
|
||||
(event) =>
|
||||
asOptionalString(event.agentSessionId) ||
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
);
|
||||
|
||||
if (!ensuredEvent && !resumeSessionId) {
|
||||
const newCommand = await this.buildVerbArgs({
|
||||
let events: AcpxJsonObject[];
|
||||
if (resumeSessionId) {
|
||||
events = await this.createNamedSession({
|
||||
agent,
|
||||
cwd,
|
||||
command: ["sessions", "new", "--name", sessionName],
|
||||
sessionName,
|
||||
resumeSessionId,
|
||||
});
|
||||
events = await this.runControlCommand({
|
||||
args: newCommand,
|
||||
cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
ensuredEvent = events.find(
|
||||
(event) =>
|
||||
asOptionalString(event.agentSessionId) ||
|
||||
asOptionalString(event.acpxSessionId) ||
|
||||
asOptionalString(event.acpxRecordId),
|
||||
} else {
|
||||
try {
|
||||
events = await this.runControlCommand({
|
||||
args: await this.buildVerbArgs({
|
||||
agent,
|
||||
cwd,
|
||||
command: ["sessions", "ensure", "--name", sessionName],
|
||||
}),
|
||||
cwd,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
});
|
||||
} catch (error) {
|
||||
const recovered = await this.recoverEnsureFailure({
|
||||
sessionName,
|
||||
agent,
|
||||
cwd,
|
||||
error,
|
||||
});
|
||||
if (!recovered) {
|
||||
throw error;
|
||||
}
|
||||
events = recovered;
|
||||
}
|
||||
}
|
||||
if (events.length === 0) {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession returned no events after sessions ensure: session=${sessionName} agent=${agent} cwd=${cwd}`,
|
||||
);
|
||||
}
|
||||
let ensuredEvent = findSessionIdentifierEvent(events);
|
||||
|
||||
if (
|
||||
ensuredEvent &&
|
||||
!resumeSessionId &&
|
||||
(await this.shouldReplaceEnsuredSession({
|
||||
sessionName,
|
||||
agent,
|
||||
cwd,
|
||||
}))
|
||||
) {
|
||||
events = await this.createNamedSession({
|
||||
agent,
|
||||
cwd,
|
||||
sessionName,
|
||||
});
|
||||
if (events.length === 0) {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession returned no events after replacing dead session: session=${sessionName} agent=${agent} cwd=${cwd}`,
|
||||
);
|
||||
}
|
||||
ensuredEvent = findSessionIdentifierEvent(events);
|
||||
}
|
||||
|
||||
if (!ensuredEvent && !resumeSessionId) {
|
||||
events = await this.createNamedSession({
|
||||
agent,
|
||||
cwd,
|
||||
sessionName,
|
||||
});
|
||||
if (events.length === 0) {
|
||||
this.logger?.warn?.(
|
||||
`acpx ensureSession returned no events after sessions new: session=${sessionName} agent=${agent} cwd=${cwd}`,
|
||||
);
|
||||
}
|
||||
ensuredEvent = findSessionIdentifierEvent(events);
|
||||
}
|
||||
if (!ensuredEvent) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
|
||||
@@ -76,6 +76,17 @@ const setValue = command === "set" ? String(args[commandIndex + 2] || "") : "";
|
||||
|
||||
if (command === "sessions" && args[commandIndex + 1] === "ensure") {
|
||||
writeLog({ kind: "ensure", agent, args, sessionName: ensureName });
|
||||
if (process.env.MOCK_ACPX_ENSURE_EXIT_1 === "1") {
|
||||
emitJson({
|
||||
jsonrpc: "2.0",
|
||||
id: null,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: "mock ensure failure",
|
||||
},
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.env.MOCK_ACPX_ENSURE_EMPTY === "1") {
|
||||
emitJson({ action: "session_ensured", name: ensureName });
|
||||
} else {
|
||||
@@ -173,11 +184,14 @@ if (command === "set") {
|
||||
|
||||
if (command === "status") {
|
||||
writeLog({ kind: "status", agent, args, sessionName: sessionFromOption });
|
||||
const status = process.env.MOCK_ACPX_STATUS_STATUS || (sessionFromOption ? "alive" : "no-session");
|
||||
const summary = process.env.MOCK_ACPX_STATUS_SUMMARY || "";
|
||||
emitJson({
|
||||
acpxRecordId: sessionFromOption ? "rec-" + sessionFromOption : null,
|
||||
acpxSessionId: sessionFromOption ? "sid-" + sessionFromOption : null,
|
||||
agentSessionId: sessionFromOption ? "inner-" + sessionFromOption : null,
|
||||
status: sessionFromOption ? "alive" : "no-session",
|
||||
status,
|
||||
...(summary ? { summary } : {}),
|
||||
pid: 4242,
|
||||
uptime: 120,
|
||||
});
|
||||
@@ -382,6 +396,9 @@ export async function readMockRuntimeLogEntries(
|
||||
export async function cleanupMockRuntimeFixtures(): Promise<void> {
|
||||
delete process.env.MOCK_ACPX_LOG;
|
||||
delete process.env.MOCK_ACPX_CONFIG_SHOW_AGENTS;
|
||||
delete process.env.MOCK_ACPX_ENSURE_EXIT_1;
|
||||
delete process.env.MOCK_ACPX_STATUS_STATUS;
|
||||
delete process.env.MOCK_ACPX_STATUS_SUMMARY;
|
||||
sharedMockCliScriptPath = null;
|
||||
logFileSequence = 0;
|
||||
while (tempDirs.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user