mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
1180 lines
40 KiB
JavaScript
1180 lines
40 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { execFileSync, spawn, type ChildProcessByStdio } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import type { Readable } from "node:stream";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import type { ChatEvent, EventFrame } from "../packages/gateway-protocol/src/index.js";
|
|
import { buildAcpDatabaseSessionKey } from "../src/acp/runtime/session-meta-keys.js";
|
|
import type { GatewayClient } from "../src/gateway/client.js";
|
|
import { connectTestGatewayClient } from "../src/gateway/gateway-cli-backend.live-helpers.js";
|
|
import { createOpenClawTestState } from "../src/test-utils/openclaw-test-state.js";
|
|
import { getFreePortBlockWithPermissionFallback } from "../src/test-utils/ports.js";
|
|
import { sleep } from "../src/utils/sleep.js";
|
|
|
|
const RESET_CLEANUP_DEADLINE_MS = 15_000;
|
|
const RESET_REQUEST_TIMEOUT_MS = 75_000;
|
|
const EVENT_WAIT_TIMEOUT_MS = 30_000;
|
|
const CONTROL_SESSION_KEY = "main";
|
|
const PROOF_CHANNEL = "webchat";
|
|
const PROOF_ACCOUNT_ID = "default";
|
|
const PROOF_CONVERSATION_ID = CONTROL_SESSION_KEY;
|
|
const PROOF_ACP_AGENT = "main";
|
|
|
|
type AdapterEvent = {
|
|
at: string;
|
|
event: string;
|
|
instanceId: string;
|
|
pid: number;
|
|
sessionId?: string;
|
|
text?: string;
|
|
reply?: string;
|
|
stopReason?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type RuntimeIdentity = {
|
|
instanceId: string;
|
|
sessionId: string;
|
|
};
|
|
|
|
type ScenarioName = "close-timeout" | "cancel-timeout" | "late-turn" | "runtime-option-timeout";
|
|
type ResetCommand = "/new" | "/reset";
|
|
type GatewayProcess = ChildProcessByStdio<null, Readable, Readable>;
|
|
type ChatFinalEvent = Extract<ChatEvent, { state: "final" }>;
|
|
|
|
type ScenarioResult = {
|
|
scenario: ScenarioName;
|
|
resetCommand: ResetCommand;
|
|
gatewayPid: number;
|
|
gatewayPidAfterReset: number;
|
|
gatewayPidAfterLateCompletion: number;
|
|
oldIdentity: RuntimeIdentity;
|
|
freshIdentity: RuntimeIdentity;
|
|
followupIdentity: RuntimeIdentity;
|
|
cleanupStartedAt: string;
|
|
cleanupDeadlineAt: string;
|
|
resetCompletedAt: string;
|
|
resetElapsedMs: number;
|
|
resetResponse: string;
|
|
lateCompletionAt: string;
|
|
lateRuntimeOptionCompletedAt?: string;
|
|
gatewayStayedAlive: boolean;
|
|
assertions: string[];
|
|
keyEvents: AdapterEvent[];
|
|
};
|
|
|
|
export function parseAdapterEvents(raw: string): AdapterEvent[] {
|
|
return raw
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.flatMap((line) => {
|
|
try {
|
|
const parsed = JSON.parse(line) as AdapterEvent;
|
|
return parsed && typeof parsed === "object" ? [parsed] : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
export function identityFromTurn(event: AdapterEvent): RuntimeIdentity {
|
|
if (!event.sessionId) {
|
|
throw new Error(`turn event is missing sessionId: ${JSON.stringify(event)}`);
|
|
}
|
|
return { instanceId: event.instanceId, sessionId: event.sessionId };
|
|
}
|
|
|
|
export function sameAcpSession(left: RuntimeIdentity, right: RuntimeIdentity): boolean {
|
|
return left.sessionId === right.sessionId;
|
|
}
|
|
|
|
export function extractSpawnedAcpSessionKey(messages: unknown[]): string {
|
|
for (const message of messages) {
|
|
if (
|
|
!message ||
|
|
typeof message !== "object" ||
|
|
(message as { role?: unknown }).role !== "assistant"
|
|
) {
|
|
continue;
|
|
}
|
|
const match = messageText(message).match(/Spawned ACP session (\S+) \(/);
|
|
if (match?.[1]) {
|
|
return match[1];
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function assert(condition: unknown, message: string): asserts condition {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
async function readEvents(controlDir: string): Promise<AdapterEvent[]> {
|
|
const raw = await fs.readFile(path.join(controlDir, "adapter.jsonl"), "utf8").catch(() => "");
|
|
return parseAdapterEvents(raw);
|
|
}
|
|
|
|
async function waitForEvent(params: {
|
|
controlDir: string;
|
|
afterIndex?: number;
|
|
description: string;
|
|
match: Partial<AdapterEvent>;
|
|
timeoutMs?: number;
|
|
}): Promise<{ event: AdapterEvent; index: number; events: AdapterEvent[] }> {
|
|
const startedAt = Date.now();
|
|
const afterIndex = params.afterIndex ?? -1;
|
|
while (Date.now() - startedAt < (params.timeoutMs ?? EVENT_WAIT_TIMEOUT_MS)) {
|
|
const events = await readEvents(params.controlDir);
|
|
const relativeIndex = events
|
|
.slice(afterIndex + 1)
|
|
.findIndex((event) =>
|
|
Object.entries(params.match).every(([key, value]) => event[key] === value),
|
|
);
|
|
if (relativeIndex >= 0) {
|
|
const index = afterIndex + 1 + relativeIndex;
|
|
return { event: events[index]!, index, events };
|
|
}
|
|
await sleep(50);
|
|
}
|
|
const events = await readEvents(params.controlDir);
|
|
throw new Error(
|
|
`timed out waiting for ${params.description}; recent events=${JSON.stringify(events.slice(-12))}`,
|
|
);
|
|
}
|
|
|
|
async function writeMarker(controlDir: string, name: string): Promise<void> {
|
|
await fs.writeFile(path.join(controlDir, name), `${new Date().toISOString()}\n`, "utf8");
|
|
}
|
|
|
|
async function releaseAllMarkers(controlDir: string): Promise<void> {
|
|
await Promise.all(
|
|
["release-cancel", "release-close", "release-set-mode", "release-turn"].map((name) =>
|
|
writeMarker(controlDir, name),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function logDriverEvent(
|
|
scenarioDir: string,
|
|
event: string,
|
|
fields: Record<string, unknown> = {},
|
|
): Promise<void> {
|
|
await fs.appendFile(
|
|
path.join(scenarioDir, "driver.jsonl"),
|
|
`${JSON.stringify({ at: new Date().toISOString(), event, ...fields })}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function readAcpMetadataRow(
|
|
stateDir: string,
|
|
sessionKey: string,
|
|
): Record<string, unknown> | undefined {
|
|
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
|
|
try {
|
|
const database = new DatabaseSync(databasePath, { readOnly: true });
|
|
try {
|
|
return database
|
|
.prepare("SELECT * FROM acp_sessions WHERE session_key = ?")
|
|
.get(buildAcpDatabaseSessionKey(sessionKey, PROOF_ACP_AGENT)) as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
} finally {
|
|
database.close();
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function readAcpRuntimeOptionsFromRow(
|
|
row: Record<string, unknown> | undefined,
|
|
): Record<string, unknown> {
|
|
assert(row !== undefined, "ACP metadata row is missing while checking runtime options");
|
|
const raw = row.runtime_options_json;
|
|
if (raw === null || raw === undefined || raw === "") {
|
|
return {};
|
|
}
|
|
assert(typeof raw === "string", "ACP runtime options must be serialized JSON");
|
|
const options: unknown = JSON.parse(raw);
|
|
assert(
|
|
typeof options === "object" && options !== null && !Array.isArray(options),
|
|
"ACP runtime options must be an object",
|
|
);
|
|
return options as Record<string, unknown>;
|
|
}
|
|
|
|
function readAgentSessionRows(stateDir: string, sessionKey: string): Record<string, unknown> {
|
|
const databasePath = path.join(
|
|
stateDir,
|
|
"agents",
|
|
PROOF_ACP_AGENT,
|
|
"agent",
|
|
"openclaw-agent.sqlite",
|
|
);
|
|
try {
|
|
const database = new DatabaseSync(databasePath, { readOnly: true });
|
|
try {
|
|
return {
|
|
node: database.prepare("SELECT * FROM session_nodes WHERE session_key = ?").get(sessionKey),
|
|
windows: database
|
|
.prepare("SELECT * FROM session_windows WHERE session_key = ? ORDER BY updated_at DESC")
|
|
.all(sessionKey),
|
|
};
|
|
} finally {
|
|
database.close();
|
|
}
|
|
} catch (error) {
|
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}
|
|
|
|
export function readAcpSessionIdFromRow(row: Record<string, unknown> | undefined): string {
|
|
const raw = row?.identity_json;
|
|
if (typeof raw !== "string") {
|
|
return "";
|
|
}
|
|
try {
|
|
const identity = JSON.parse(raw) as { acpxSessionId?: unknown };
|
|
return typeof identity.acpxSessionId === "string" ? identity.acpxSessionId : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
async function sendChat(
|
|
client: GatewayClient,
|
|
text: string,
|
|
options: { sessionKey?: string; withOrigin?: boolean } = {},
|
|
): Promise<string> {
|
|
const startedAt = Date.now();
|
|
while (true) {
|
|
try {
|
|
const withOrigin = options.withOrigin !== false;
|
|
const started = await client.request<{ runId?: string; status?: string }>("chat.send", {
|
|
sessionKey: options.sessionKey ?? CONTROL_SESSION_KEY,
|
|
message: text,
|
|
idempotencyKey: `proof-${text}-${randomUUID()}`,
|
|
...(withOrigin
|
|
? {
|
|
originatingChannel: PROOF_CHANNEL,
|
|
originatingTo: PROOF_CONVERSATION_ID,
|
|
originatingAccountId: PROOF_ACCOUNT_ID,
|
|
}
|
|
: {}),
|
|
});
|
|
assert(started.status === "started" && typeof started.runId === "string", "chat.send failed");
|
|
return started.runId;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (
|
|
!message.includes("chat.send unavailable during gateway startup") ||
|
|
Date.now() - startedAt >= 30_000
|
|
) {
|
|
throw error;
|
|
}
|
|
await sleep(250);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForRun(client: GatewayClient, runId: string, timeoutMs = 45_000): Promise<void> {
|
|
const result = await client.request<{ error?: unknown; status?: string }>(
|
|
"agent.wait",
|
|
{ runId, timeoutMs },
|
|
{ timeoutMs: timeoutMs + 5_000 },
|
|
);
|
|
assert(result.status === "ok", `agent.wait failed for ${runId}: ${JSON.stringify(result)}`);
|
|
}
|
|
|
|
function messageText(message: unknown): string {
|
|
if (!message || typeof message !== "object") {
|
|
return "";
|
|
}
|
|
const content = (message as { content?: unknown }).content;
|
|
if (typeof content === "string") {
|
|
return content;
|
|
}
|
|
if (!Array.isArray(content)) {
|
|
return "";
|
|
}
|
|
return content
|
|
.flatMap((block) => {
|
|
if (!block || typeof block !== "object") {
|
|
return [];
|
|
}
|
|
const text = (block as { text?: unknown }).text;
|
|
return typeof text === "string" ? [text] : [];
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function isChatFinalEvent(payload: unknown, runId: string): payload is ChatFinalEvent {
|
|
if (!payload || typeof payload !== "object") {
|
|
return false;
|
|
}
|
|
const candidate = payload as { state?: unknown; runId?: unknown };
|
|
return candidate.state === "final" && candidate.runId === runId;
|
|
}
|
|
|
|
async function sendResetCommand(params: {
|
|
client: GatewayClient;
|
|
gatewayEvents: EventFrame[];
|
|
scenarioDir: string;
|
|
acpSessionKey: string;
|
|
command: ResetCommand;
|
|
}): Promise<string> {
|
|
const runId = await sendChat(params.client, params.command);
|
|
await logDriverEvent(params.scenarioDir, "reset_command_requested", {
|
|
command: params.command,
|
|
sourceSessionKey: CONTROL_SESSION_KEY,
|
|
acpSessionKey: params.acpSessionKey,
|
|
runId,
|
|
});
|
|
await waitForRun(params.client, runId, RESET_REQUEST_TIMEOUT_MS - 5_000);
|
|
const finalEvent = params.gatewayEvents
|
|
.toReversed()
|
|
.find(
|
|
(event): event is EventFrame & { payload: ChatFinalEvent } =>
|
|
event.event === "chat" && isChatFinalEvent(event.payload, runId),
|
|
);
|
|
const histories = await Promise.all(
|
|
[CONTROL_SESSION_KEY, params.acpSessionKey].map(async (sessionKey) => {
|
|
const history = await params.client.request<{ messages?: unknown[] }>("chat.history", {
|
|
sessionKey,
|
|
limit: 20,
|
|
});
|
|
return history.messages ?? [];
|
|
}),
|
|
);
|
|
const historyResponse = histories
|
|
.flat()
|
|
.toReversed()
|
|
.filter(
|
|
(message) =>
|
|
message &&
|
|
typeof message === "object" &&
|
|
(message as { role?: unknown }).role === "assistant",
|
|
)
|
|
.map(messageText)
|
|
.find((text) => text.includes("ACP session reset in place") || text.includes("Session reset"));
|
|
const eventResponse = messageText(finalEvent?.payload?.message);
|
|
const resetResponse =
|
|
[eventResponse, historyResponse].find(
|
|
(text) => text?.includes("ACP session reset in place") || text?.includes("Session reset"),
|
|
) ?? "";
|
|
await logDriverEvent(params.scenarioDir, "reset_command_observed", {
|
|
command: params.command,
|
|
runId,
|
|
eventResponse,
|
|
historyResponse,
|
|
});
|
|
assert(resetResponse, `${params.command} did not return its reset acknowledgement`);
|
|
await logDriverEvent(params.scenarioDir, "reset_command_completed", {
|
|
command: params.command,
|
|
sourceSessionKey: CONTROL_SESSION_KEY,
|
|
acpSessionKey: params.acpSessionKey,
|
|
runId,
|
|
response: resetResponse,
|
|
});
|
|
return resetResponse;
|
|
}
|
|
|
|
function requireGatewayPid(pid: number | undefined): number {
|
|
assert(typeof pid === "number", "Gateway child process has no PID");
|
|
process.kill(pid, 0);
|
|
return pid;
|
|
}
|
|
|
|
async function waitForGatewayPort(params: {
|
|
child: GatewayProcess;
|
|
port: number;
|
|
timeoutMs: number;
|
|
}): Promise<void> {
|
|
const startedAt = Date.now();
|
|
while (Date.now() - startedAt < params.timeoutMs) {
|
|
if (params.child.exitCode !== null || params.child.signalCode !== null) {
|
|
throw new Error(
|
|
`Gateway exited before listening: code=${String(params.child.exitCode)} signal=${String(params.child.signalCode)}`,
|
|
);
|
|
}
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const socket = net.connect({ host: "127.0.0.1", port: params.port });
|
|
socket.once("connect", () => {
|
|
socket.destroy();
|
|
resolve();
|
|
});
|
|
socket.once("error", (error) => {
|
|
socket.destroy();
|
|
reject(error);
|
|
});
|
|
});
|
|
return;
|
|
} catch {
|
|
await sleep(50);
|
|
}
|
|
}
|
|
throw new Error(`timed out waiting for Gateway port ${params.port}`);
|
|
}
|
|
|
|
function signalGatewayProcess(child: GatewayProcess, signal: NodeJS.Signals): void {
|
|
if (process.platform !== "win32" && typeof child.pid === "number") {
|
|
try {
|
|
process.kill(-child.pid, signal);
|
|
return;
|
|
} catch {
|
|
// Fall back to the direct child when the process group already exited.
|
|
}
|
|
}
|
|
child.kill(signal);
|
|
}
|
|
|
|
async function stopGatewayProcess(child: GatewayProcess | undefined): Promise<void> {
|
|
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
return;
|
|
}
|
|
signalGatewayProcess(child, "SIGTERM");
|
|
const exited = await Promise.race([
|
|
new Promise<boolean>((resolve) => {
|
|
child.once("exit", () => {
|
|
resolve(true);
|
|
});
|
|
}),
|
|
sleep(3_000).then(() => false),
|
|
]);
|
|
if (!exited && child.exitCode === null && child.signalCode === null) {
|
|
signalGatewayProcess(child, "SIGKILL");
|
|
await Promise.race([
|
|
new Promise<void>((resolve) => {
|
|
child.once("exit", () => {
|
|
resolve();
|
|
});
|
|
}),
|
|
sleep(3_000),
|
|
]);
|
|
}
|
|
}
|
|
|
|
async function sendAndObserveTurn(params: {
|
|
client: GatewayClient;
|
|
controlDir: string;
|
|
scenarioDir: string;
|
|
stateDir: string;
|
|
acpSessionKey: string;
|
|
text: string;
|
|
afterIndex?: number;
|
|
directToAcpSession?: boolean;
|
|
}): Promise<{ event: AdapterEvent; index: number }> {
|
|
const runId = await sendChat(
|
|
params.client,
|
|
params.text,
|
|
params.directToAcpSession ? { sessionKey: params.acpSessionKey } : {},
|
|
);
|
|
await logDriverEvent(params.scenarioDir, "turn_requested", {
|
|
sourceSessionKey: CONTROL_SESSION_KEY,
|
|
acpSessionKey: params.acpSessionKey,
|
|
text: params.text,
|
|
runId,
|
|
});
|
|
await waitForRun(params.client, runId);
|
|
await logDriverEvent(params.scenarioDir, "turn_run_completed", {
|
|
acpSessionKey: params.acpSessionKey,
|
|
text: params.text,
|
|
runId,
|
|
acpRow: readAcpMetadataRow(params.stateDir, params.acpSessionKey),
|
|
agentRows: readAgentSessionRows(params.stateDir, params.acpSessionKey),
|
|
});
|
|
return await waitForEvent({
|
|
controlDir: params.controlDir,
|
|
afterIndex: params.afterIndex,
|
|
description: `completed turn ${params.text}`,
|
|
match: { event: "turn_end", text: params.text, stopReason: "end_turn" },
|
|
});
|
|
}
|
|
|
|
async function spawnAndBindAcpSession(params: {
|
|
client: GatewayClient;
|
|
controlDir: string;
|
|
repoRoot: string;
|
|
scenarioDir: string;
|
|
}): Promise<string> {
|
|
const command = `/acp spawn ${PROOF_ACP_AGENT} --bind here --cwd ${params.repoRoot}`;
|
|
const runId = await sendChat(params.client, command);
|
|
await logDriverEvent(params.scenarioDir, "acp_spawn_requested", {
|
|
sourceSessionKey: CONTROL_SESSION_KEY,
|
|
runId,
|
|
});
|
|
await waitForRun(params.client, runId, 60_000);
|
|
const history = await params.client.request<{ messages?: unknown[] }>("chat.history", {
|
|
sessionKey: CONTROL_SESSION_KEY,
|
|
limit: 20,
|
|
});
|
|
const messages = history.messages ?? [];
|
|
const acpSessionKey = extractSpawnedAcpSessionKey(messages);
|
|
assert(
|
|
acpSessionKey,
|
|
`the ACP spawn command did not return a session key: ${messages.map(messageText).join(" | ")}`,
|
|
);
|
|
await waitForEvent({
|
|
controlDir: params.controlDir,
|
|
description: "spawned ACP adapter session creation",
|
|
match: { event: "session_create" },
|
|
});
|
|
await logDriverEvent(params.scenarioDir, "acp_spawn_bound", {
|
|
sourceSessionKey: CONTROL_SESSION_KEY,
|
|
acpSessionKey,
|
|
runId,
|
|
});
|
|
return acpSessionKey;
|
|
}
|
|
|
|
async function snapshotDirectory(root: string): Promise<Record<string, string>> {
|
|
const snapshot: Record<string, string> = {};
|
|
const visit = async (dir: string): Promise<void> => {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
for (const entry of entries) {
|
|
const absolute = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await visit(absolute);
|
|
} else if (entry.isFile()) {
|
|
const relative = path.relative(root, absolute);
|
|
const stat = await fs.stat(absolute);
|
|
snapshot[relative] =
|
|
stat.size <= 256 * 1024
|
|
? await fs.readFile(absolute, "utf8").catch(() => `<binary:${stat.size}>`)
|
|
: `<omitted:${stat.size}>`;
|
|
}
|
|
}
|
|
};
|
|
await visit(root);
|
|
return snapshot;
|
|
}
|
|
|
|
function redactGatewayLogs(raw: string, replacements: Array<[string, string]>): string {
|
|
let result = raw;
|
|
for (const [value, replacement] of replacements) {
|
|
if (value) {
|
|
result = result.split(value).join(replacement);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runScenario(params: {
|
|
repoRoot: string;
|
|
runtimeRoot: string;
|
|
outputRoot: string;
|
|
scenario: ScenarioName;
|
|
}): Promise<ScenarioResult> {
|
|
const scenarioDir = path.join(params.outputRoot, params.scenario);
|
|
const controlDir = path.join(scenarioDir, "control");
|
|
const acpxStateDir = path.join(scenarioDir, "acpx-state");
|
|
const rawGatewayLog = path.join(scenarioDir, "gateway.raw.log");
|
|
await fs.mkdir(controlDir, { recursive: true });
|
|
await fs.mkdir(acpxStateDir, { recursive: true });
|
|
|
|
const adapterPath = path.join(params.repoRoot, "test/fixtures/acp-reset-timeout-adapter.ts");
|
|
const tsxImport = path.join(params.repoRoot, "node_modules/tsx/dist/loader.mjs");
|
|
const port = await getFreePortBlockWithPermissionFallback({
|
|
offsets: [0, 1, 2, 4],
|
|
fallbackBase: 43_000,
|
|
});
|
|
const gatewayToken = `acp-reset-proof-${randomUUID()}`;
|
|
const state = await createOpenClawTestState({
|
|
label: `acp-reset-${params.scenario}`,
|
|
layout: "home",
|
|
applyEnv: false,
|
|
});
|
|
await state.writeConfig({
|
|
gateway: {
|
|
port,
|
|
bind: "loopback",
|
|
auth: { mode: "token", token: gatewayToken },
|
|
controlUi: { enabled: false },
|
|
},
|
|
logging: {
|
|
level: "debug",
|
|
consoleLevel: "warn",
|
|
consoleStyle: "json",
|
|
file: rawGatewayLog,
|
|
},
|
|
acp: {
|
|
enabled: true,
|
|
backend: "acpx",
|
|
defaultAgent: PROOF_ACP_AGENT,
|
|
allowedAgents: [PROOF_ACP_AGENT],
|
|
dispatch: { enabled: true },
|
|
},
|
|
plugins: {
|
|
enabled: true,
|
|
allow: ["acpx"],
|
|
entries: {
|
|
acpx: {
|
|
enabled: true,
|
|
config: {
|
|
cwd: params.repoRoot,
|
|
stateDir: acpxStateDir,
|
|
permissionMode: "deny-all",
|
|
nonInteractivePermissions: "deny",
|
|
agents: {
|
|
[PROOF_ACP_AGENT]: {
|
|
command: process.execPath,
|
|
args: ["--import", tsxImport, adapterPath, "--control-dir", controlDir],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const gatewayEnv: NodeJS.ProcessEnv = {
|
|
...state.env,
|
|
OPENCLAW_GATEWAY_TOKEN: "",
|
|
OPENCLAW_GATEWAY_PASSWORD: "",
|
|
OPENCLAW_SKIP_CHANNELS: "1",
|
|
OPENCLAW_SKIP_PROVIDERS: "1",
|
|
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
|
|
OPENCLAW_SKIP_CRON: "1",
|
|
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
|
|
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
|
OPENCLAW_TEST_MINIMAL_GATEWAY: "0",
|
|
OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE: "0",
|
|
OPENCLAW_SKIP_ACPX_RUNTIME_PROBE: "1",
|
|
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(params.runtimeRoot, "dist", "extensions"),
|
|
};
|
|
const gatewayStdout: string[] = [];
|
|
const gatewayStderr: string[] = [];
|
|
let gatewayProcess: GatewayProcess | undefined;
|
|
let client: GatewayClient | undefined;
|
|
const gatewayEvents: EventFrame[] = [];
|
|
try {
|
|
gatewayProcess = spawn(
|
|
process.execPath,
|
|
[
|
|
path.join(params.runtimeRoot, "dist", "index.js"),
|
|
"gateway",
|
|
"--port",
|
|
String(port),
|
|
"--bind",
|
|
"loopback",
|
|
"--allow-unconfigured",
|
|
],
|
|
{
|
|
cwd: params.runtimeRoot,
|
|
env: gatewayEnv,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
detached: process.platform !== "win32",
|
|
},
|
|
);
|
|
gatewayProcess.stdout.setEncoding("utf8");
|
|
gatewayProcess.stderr.setEncoding("utf8");
|
|
gatewayProcess.stdout.on("data", (chunk) => gatewayStdout.push(String(chunk)));
|
|
gatewayProcess.stderr.on("data", (chunk) => gatewayStderr.push(String(chunk)));
|
|
await waitForGatewayPort({ child: gatewayProcess, port, timeoutMs: 60_000 });
|
|
const gatewayPid = requireGatewayPid(gatewayProcess.pid);
|
|
await logDriverEvent(scenarioDir, "gateway_started", {
|
|
gatewayPid,
|
|
port,
|
|
entrypoint: [path.join(params.runtimeRoot, "dist", "index.js")],
|
|
});
|
|
client = await connectTestGatewayClient({
|
|
url: `ws://127.0.0.1:${port}`,
|
|
token: gatewayToken,
|
|
clientDisplayName: null,
|
|
timeoutMs: 60_000,
|
|
requestTimeoutMs: RESET_REQUEST_TIMEOUT_MS,
|
|
onEvent: (event) => gatewayEvents.push(event),
|
|
});
|
|
|
|
const acpSessionKey = await spawnAndBindAcpSession({
|
|
client,
|
|
controlDir,
|
|
repoRoot: params.repoRoot,
|
|
scenarioDir,
|
|
});
|
|
await logDriverEvent(scenarioDir, "runtime_state_after_spawn", {
|
|
sessionKey: acpSessionKey,
|
|
acpRow: readAcpMetadataRow(state.stateDir, acpSessionKey),
|
|
agentRows: readAgentSessionRows(state.stateDir, acpSessionKey),
|
|
});
|
|
|
|
const turn = {
|
|
client,
|
|
controlDir,
|
|
scenarioDir,
|
|
stateDir: state.stateDir,
|
|
acpSessionKey,
|
|
};
|
|
const baseline = await sendAndObserveTurn({
|
|
...turn,
|
|
text: `baseline-${params.scenario}`,
|
|
});
|
|
const oldIdentity = identityFromTurn(baseline.event);
|
|
const rowAfterBaseline = readAcpMetadataRow(state.stateDir, acpSessionKey);
|
|
assert(
|
|
readAcpSessionIdFromRow(rowAfterBaseline) === oldIdentity.sessionId,
|
|
`baseline metadata does not own the observed ACP session: ${JSON.stringify(rowAfterBaseline)}`,
|
|
);
|
|
await logDriverEvent(scenarioDir, "acp_session_after_baseline", {
|
|
sessionKey: acpSessionKey,
|
|
row: rowAfterBaseline,
|
|
identity: oldIdentity,
|
|
});
|
|
|
|
await writeMarker(
|
|
controlDir,
|
|
params.scenario === "cancel-timeout" ? "hang-cancel" : "hang-close",
|
|
);
|
|
if (params.scenario === "runtime-option-timeout") {
|
|
await writeMarker(controlDir, "hang-set-mode");
|
|
}
|
|
if (params.scenario === "late-turn") {
|
|
await writeMarker(controlDir, "cancel-no-abort");
|
|
}
|
|
|
|
let heldTurnRunId: string | undefined;
|
|
let runtimeOptionRunId: string | undefined;
|
|
let eventCursor = baseline.index;
|
|
const waitForOldSessionEvent = (
|
|
description: string,
|
|
match: Partial<AdapterEvent>,
|
|
afterIndex = eventCursor,
|
|
) =>
|
|
waitForEvent({
|
|
controlDir,
|
|
afterIndex,
|
|
description,
|
|
match: { ...match, sessionId: oldIdentity.sessionId },
|
|
});
|
|
if (params.scenario === "runtime-option-timeout") {
|
|
runtimeOptionRunId = await sendChat(client, "/acp set-mode plan");
|
|
const setModeStarted = await waitForOldSessionEvent("old runtime-option operation start", {
|
|
event: "set_mode_start",
|
|
});
|
|
eventCursor = setModeStarted.index;
|
|
await logDriverEvent(scenarioDir, "held_runtime_option_started", {
|
|
runId: runtimeOptionRunId,
|
|
identity: oldIdentity,
|
|
});
|
|
} else if (params.scenario !== "close-timeout") {
|
|
heldTurnRunId = await sendChat(client, "hold-turn");
|
|
const heldTurn = await waitForOldSessionEvent("held old turn start", {
|
|
event: "turn_start",
|
|
text: "hold-turn",
|
|
});
|
|
eventCursor = heldTurn.index;
|
|
await logDriverEvent(scenarioDir, "held_turn_started", {
|
|
runId: heldTurnRunId,
|
|
identity: identityFromTurn(heldTurn.event),
|
|
});
|
|
}
|
|
|
|
const cleanupStartedMs = Date.now();
|
|
const cleanupStartedAt = new Date(cleanupStartedMs).toISOString();
|
|
const cleanupDeadlineAt = new Date(cleanupStartedMs + RESET_CLEANUP_DEADLINE_MS).toISOString();
|
|
const resetCommand = resolveResetCommand(params.scenario);
|
|
const resetResponse = await sendResetCommand({
|
|
client,
|
|
gatewayEvents,
|
|
scenarioDir,
|
|
acpSessionKey,
|
|
command: resetCommand,
|
|
});
|
|
const resetCompletedMs = Date.now();
|
|
const resetElapsedMs = resetCompletedMs - cleanupStartedMs;
|
|
assert(
|
|
resetElapsedMs >= RESET_CLEANUP_DEADLINE_MS - 100,
|
|
`reset completed before the production cleanup deadline: ${resetElapsedMs}ms`,
|
|
);
|
|
const gatewayPidAfterReset = requireGatewayPid(gatewayProcess.pid);
|
|
assert(gatewayPidAfterReset === gatewayPid, "Gateway PID changed during reset");
|
|
await logDriverEvent(scenarioDir, "runtime_state_after_reset", {
|
|
sessionKey: acpSessionKey,
|
|
row: readAcpMetadataRow(state.stateDir, acpSessionKey),
|
|
gatewayPid: gatewayPidAfterReset,
|
|
});
|
|
|
|
const fresh = await sendAndObserveTurn({
|
|
...turn,
|
|
text: `fresh-${params.scenario}`,
|
|
afterIndex: eventCursor,
|
|
directToAcpSession: true,
|
|
});
|
|
eventCursor = fresh.index;
|
|
const freshIdentity = identityFromTurn(fresh.event);
|
|
assert(
|
|
freshIdentity.sessionId !== oldIdentity.sessionId,
|
|
"reset reused the old ACP session ID",
|
|
);
|
|
const rowAfterFresh = readAcpMetadataRow(state.stateDir, acpSessionKey);
|
|
const freshRuntimeOptions = readAcpRuntimeOptionsFromRow(rowAfterFresh);
|
|
if (params.scenario === "runtime-option-timeout") {
|
|
assert(fresh.event.mode === "default", "fresh backend inherited the pending old mode");
|
|
}
|
|
assert(
|
|
readAcpSessionIdFromRow(rowAfterFresh) === freshIdentity.sessionId,
|
|
`fresh metadata does not own the new ACP session: ${JSON.stringify(rowAfterFresh)}`,
|
|
);
|
|
await logDriverEvent(scenarioDir, "runtime_state_after_fresh_turn", {
|
|
sessionKey: acpSessionKey,
|
|
row: rowAfterFresh,
|
|
identity: freshIdentity,
|
|
});
|
|
|
|
let lateCompletion: { event: AdapterEvent; index: number };
|
|
let lateRuntimeOptionCompletedAt: string | undefined;
|
|
if (params.scenario === "close-timeout") {
|
|
await writeMarker(controlDir, "release-close");
|
|
lateCompletion = await waitForOldSessionEvent("late close completion", {
|
|
event: "close_end",
|
|
});
|
|
} else if (params.scenario === "cancel-timeout") {
|
|
await writeMarker(controlDir, "release-cancel");
|
|
lateCompletion = await waitForOldSessionEvent("late cancel completion", {
|
|
event: "cancel_end",
|
|
});
|
|
await waitForOldSessionEvent("cancelled old turn completion", {
|
|
event: "turn_end",
|
|
stopReason: "cancelled",
|
|
});
|
|
} else if (params.scenario === "runtime-option-timeout") {
|
|
await writeMarker(controlDir, "release-set-mode");
|
|
await writeMarker(controlDir, "release-close");
|
|
const lateRuntimeOption = await waitForOldSessionEvent("late runtime-option completion", {
|
|
event: "set_mode_end",
|
|
});
|
|
assert(
|
|
lateRuntimeOption.index > fresh.index,
|
|
"runtime-option completion was not observed after the fresh turn",
|
|
);
|
|
lateRuntimeOptionCompletedAt = lateRuntimeOption.event.at;
|
|
lateCompletion = await waitForOldSessionEvent(
|
|
"late close completion after runtime-option operation",
|
|
{ event: "close_end" },
|
|
);
|
|
if (runtimeOptionRunId) {
|
|
await client
|
|
.request(
|
|
"agent.wait",
|
|
{ runId: runtimeOptionRunId, timeoutMs: 5_000 },
|
|
{ timeoutMs: 10_000 },
|
|
)
|
|
.catch(() => undefined);
|
|
}
|
|
await logDriverEvent(scenarioDir, "late_runtime_option_completed", {
|
|
runId: runtimeOptionRunId,
|
|
identity: oldIdentity,
|
|
completionAt: lateRuntimeOption.event.at,
|
|
});
|
|
} else {
|
|
await writeMarker(controlDir, "release-turn");
|
|
lateCompletion = await waitForOldSessionEvent("late old turn completion", {
|
|
event: "turn_end",
|
|
text: "hold-turn",
|
|
stopReason: "end_turn",
|
|
});
|
|
await writeMarker(controlDir, "release-close");
|
|
await waitForOldSessionEvent(
|
|
"old close completion after late turn",
|
|
{ event: "close_end" },
|
|
lateCompletion.index,
|
|
);
|
|
}
|
|
eventCursor = lateCompletion.index;
|
|
|
|
if (heldTurnRunId) {
|
|
await client
|
|
.request("agent.wait", { runId: heldTurnRunId, timeoutMs: 5_000 }, { timeoutMs: 10_000 })
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
await sleep(500);
|
|
const gatewayPidAfterLateCompletion = requireGatewayPid(gatewayProcess.pid);
|
|
assert(
|
|
gatewayPidAfterLateCompletion === gatewayPid,
|
|
"Gateway PID changed after late operation completion",
|
|
);
|
|
const rowAfterLateCompletion = readAcpMetadataRow(state.stateDir, acpSessionKey);
|
|
assert(
|
|
readAcpSessionIdFromRow(rowAfterLateCompletion) === freshIdentity.sessionId,
|
|
`late completion replaced fresh ACP metadata: ${JSON.stringify(rowAfterLateCompletion)}`,
|
|
);
|
|
|
|
if (params.scenario === "runtime-option-timeout") {
|
|
assert(
|
|
isDeepStrictEqual(
|
|
readAcpRuntimeOptionsFromRow(rowAfterLateCompletion),
|
|
freshRuntimeOptions,
|
|
),
|
|
"late runtime-option completion changed the fresh runtime options",
|
|
);
|
|
}
|
|
|
|
const followup = await sendAndObserveTurn({
|
|
...turn,
|
|
text: `followup-${params.scenario}`,
|
|
afterIndex: eventCursor,
|
|
directToAcpSession: true,
|
|
});
|
|
const followupIdentity = identityFromTurn(followup.event);
|
|
assert(
|
|
sameAcpSession(freshIdentity, followupIdentity),
|
|
`late completion replaced the fresh ACP session: fresh=${JSON.stringify(freshIdentity)} followup=${JSON.stringify(followupIdentity)}`,
|
|
);
|
|
const rowAfterFollowup = readAcpMetadataRow(state.stateDir, acpSessionKey);
|
|
assert(
|
|
readAcpSessionIdFromRow(rowAfterFollowup) === freshIdentity.sessionId,
|
|
`follow-up metadata no longer owns the fresh ACP session: ${JSON.stringify(rowAfterFollowup)}`,
|
|
);
|
|
if (params.scenario === "runtime-option-timeout") {
|
|
assert(
|
|
isDeepStrictEqual(readAcpRuntimeOptionsFromRow(rowAfterFollowup), freshRuntimeOptions),
|
|
"follow-up changed the fresh runtime options after the stale operation",
|
|
);
|
|
assert(
|
|
followup.event.mode === "default",
|
|
"follow-up replayed the stale mode into the fresh backend",
|
|
);
|
|
}
|
|
await logDriverEvent(scenarioDir, "runtime_state_after_late_completion", {
|
|
sessionKey: acpSessionKey,
|
|
rowAfterLateCompletion,
|
|
rowAfterFollowup,
|
|
freshRuntimeOptions,
|
|
freshBackendMode: fresh.event.mode,
|
|
followupBackendMode: followup.event.mode,
|
|
followupIdentity,
|
|
gatewayPid: gatewayPidAfterLateCompletion,
|
|
});
|
|
|
|
const events = await readEvents(controlDir);
|
|
const keyEvents = events.filter((event) =>
|
|
[
|
|
"process_start",
|
|
"session_create",
|
|
"session_load",
|
|
"turn_start",
|
|
"turn_end",
|
|
"set_mode_start",
|
|
"set_mode_end",
|
|
"cancel_start",
|
|
"cancel_end",
|
|
"close_start",
|
|
"close_end",
|
|
"process_exit",
|
|
].includes(event.event),
|
|
);
|
|
const result: ScenarioResult = {
|
|
scenario: params.scenario,
|
|
resetCommand,
|
|
gatewayPid,
|
|
gatewayPidAfterReset,
|
|
gatewayPidAfterLateCompletion,
|
|
oldIdentity,
|
|
freshIdentity,
|
|
followupIdentity,
|
|
cleanupStartedAt,
|
|
cleanupDeadlineAt,
|
|
resetCompletedAt: new Date(resetCompletedMs).toISOString(),
|
|
resetElapsedMs,
|
|
resetResponse,
|
|
lateCompletionAt: lateCompletion.event.at,
|
|
...(lateRuntimeOptionCompletedAt ? { lateRuntimeOptionCompletedAt } : {}),
|
|
gatewayStayedAlive: true,
|
|
assertions: [
|
|
`reset exceeded the ${RESET_CLEANUP_DEADLINE_MS}ms production cleanup deadline`,
|
|
`bound ${resetCommand} completed the ACP reset path`,
|
|
"Gateway child PID remained unchanged",
|
|
"the first post-reset turn used a fresh ACP session ID",
|
|
"the late old operation completed after the fresh turn",
|
|
"persistent ACP metadata remained owned by the fresh session",
|
|
"the follow-up turn remained on the fresh ACP session",
|
|
...(params.scenario === "runtime-option-timeout"
|
|
? [
|
|
"the old runtime-option operation started before reset and completed after the fresh turn",
|
|
"the superseded runtime-option operation did not replace fresh metadata or ownership",
|
|
"fresh runtime options remained unchanged after late completion and follow-up",
|
|
"the fresh backend and follow-up remained in default mode instead of the stale plan mode",
|
|
]
|
|
: []),
|
|
],
|
|
keyEvents,
|
|
};
|
|
|
|
await fs.writeFile(
|
|
path.join(scenarioDir, "result.json"),
|
|
`${JSON.stringify(result, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(scenarioDir, "acpx-state-snapshot.json"),
|
|
`${JSON.stringify(await snapshotDirectory(acpxStateDir), null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
return result;
|
|
} finally {
|
|
await releaseAllMarkers(controlDir).catch(() => {});
|
|
await client?.stopAndWait({ timeoutMs: 2_000 }).catch(() => {});
|
|
const replacements: Array<[string, string]> = [
|
|
[gatewayToken, "<gateway-token>"],
|
|
[params.repoRoot, "<repo-root>"],
|
|
[params.runtimeRoot, "<built-runtime>"],
|
|
[state.home, "<isolated-home>"],
|
|
[state.stateDir, "<isolated-state>"],
|
|
[params.outputRoot, "<proof-output>"],
|
|
];
|
|
await stopGatewayProcess(gatewayProcess).catch(() => {});
|
|
const rawLogs = await fs.readFile(rawGatewayLog, "utf8").catch(() => "");
|
|
await fs.writeFile(
|
|
path.join(scenarioDir, "gateway.log"),
|
|
redactGatewayLogs(rawLogs, replacements),
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(scenarioDir, "gateway-process.log"),
|
|
redactGatewayLogs(
|
|
`--- stdout ---\n${gatewayStdout.join("")}\n--- stderr ---\n${gatewayStderr.join("")}\n`,
|
|
replacements,
|
|
),
|
|
"utf8",
|
|
);
|
|
await fs.rm(rawGatewayLog, { force: true });
|
|
await state.cleanup();
|
|
}
|
|
}
|
|
|
|
async function createBuiltGatewayRuntime(repoRoot: string): Promise<string> {
|
|
const runtimeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acp-reset-proof-runtime-"));
|
|
try {
|
|
await fs.copyFile(path.join(repoRoot, "package.json"), path.join(runtimeRoot, "package.json"));
|
|
await fs.cp(path.join(repoRoot, "dist"), path.join(runtimeRoot, "dist"), { recursive: true });
|
|
await fs.mkdir(path.join(runtimeRoot, "docs", "reference"), { recursive: true });
|
|
await fs.cp(
|
|
path.join(repoRoot, "docs", "reference", "templates"),
|
|
path.join(runtimeRoot, "docs", "reference", "templates"),
|
|
{ recursive: true },
|
|
);
|
|
await fs.symlink(
|
|
path.join(repoRoot, "node_modules"),
|
|
path.join(runtimeRoot, "node_modules"),
|
|
process.platform === "win32" ? "junction" : "dir",
|
|
);
|
|
return runtimeRoot;
|
|
} catch (error) {
|
|
await fs.rm(runtimeRoot, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function resolveOutputRoot(repoRoot: string): string {
|
|
const outputIndex = process.argv.indexOf("--output-dir");
|
|
const configured = outputIndex >= 0 ? process.argv[outputIndex + 1]?.trim() : "";
|
|
if (configured) {
|
|
return path.resolve(configured);
|
|
}
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
return path.join(repoRoot, "tmp", `acp-reset-timeout-proof-${stamp}`);
|
|
}
|
|
|
|
export function resolveResetCommand(scenario: ScenarioName): ResetCommand {
|
|
return scenario === "late-turn" ? "/new" : "/reset";
|
|
}
|
|
|
|
function resolveScenarios(): ScenarioName[] {
|
|
const scenarioIndex = process.argv.indexOf("--scenario");
|
|
const configured = scenarioIndex >= 0 ? process.argv[scenarioIndex + 1]?.trim() : "";
|
|
if (!configured) {
|
|
return ["close-timeout", "cancel-timeout", "late-turn", "runtime-option-timeout"];
|
|
}
|
|
if (
|
|
configured === "close-timeout" ||
|
|
configured === "cancel-timeout" ||
|
|
configured === "late-turn" ||
|
|
configured === "runtime-option-timeout"
|
|
) {
|
|
return [configured];
|
|
}
|
|
throw new Error(`unknown proof scenario: ${configured}`);
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const outputRoot = resolveOutputRoot(repoRoot);
|
|
await fs.mkdir(outputRoot, { recursive: true });
|
|
const revision = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
}).trim();
|
|
const startedAt = new Date().toISOString();
|
|
const results: ScenarioResult[] = [];
|
|
const runtimeRoot = await createBuiltGatewayRuntime(repoRoot);
|
|
try {
|
|
for (const scenario of resolveScenarios()) {
|
|
process.stdout.write(`[proof] starting ${scenario}\n`);
|
|
const result = await runScenario({ repoRoot, runtimeRoot, outputRoot, scenario });
|
|
results.push(result);
|
|
process.stdout.write(
|
|
`[proof] ${scenario} passed reset=${result.resetElapsedMs}ms old=${result.oldIdentity.sessionId} fresh=${result.freshIdentity.sessionId}\n`,
|
|
);
|
|
}
|
|
} finally {
|
|
await fs.rm(runtimeRoot, { recursive: true, force: true });
|
|
}
|
|
const summary = {
|
|
claim:
|
|
"After ACP reset cleanup exceeds its production deadline, OpenClaw discards old runtime ownership, creates a fresh ACP session without restarting Gateway, and rejects late old cancel/close/turn/runtime-option completion from replacing the fresh runtime.",
|
|
revision,
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
environment: {
|
|
platform: `${os.platform()} ${os.release()} ${os.arch()}`,
|
|
node: process.version,
|
|
sqlite: process.versions.sqlite,
|
|
acpAdapter: "test/fixtures/acp-reset-timeout-adapter.ts",
|
|
gateway: "isolated child process spawned from a temporary copy of the built dist runtime",
|
|
acpRegistration: "bundled acpx plugin service with a configured proof agent command",
|
|
binding:
|
|
"dynamic /acp spawn main --bind here binding for the built-in WebChat conversation with channel network startup disabled",
|
|
isolatedState: true,
|
|
productionCleanupDeadlineMs: RESET_CLEANUP_DEADLINE_MS,
|
|
},
|
|
commands: [
|
|
"node --import tsx scripts/acp-reset-timeout-proof.ts --output-dir <proof-output>",
|
|
"Gateway: node <built-runtime>/dist/index.js gateway --port <ephemeral> --bind loopback --allow-unconfigured",
|
|
"Adapter: node --import tsx test/fixtures/acp-reset-timeout-adapter.ts --control-dir <proof-output>/<scenario>/control",
|
|
],
|
|
results,
|
|
limitations: [
|
|
"The backend is a deterministic ACP protocol adapter, not a commercial third-party model backend.",
|
|
"Reset removes the dynamic conversation binding, so post-reset proof turns target the same ACP session key directly through Gateway chat.send.",
|
|
"The bound /new command path is exercised by the late-turn scenario; the bound /reset path is exercised by the close, cancel, and runtime-option timeout scenarios.",
|
|
"The proof covers one old runtime and one fresh runtime per timeout mode; it does not exhaustively fuzz arbitrary reset concurrency.",
|
|
"Process and ACP session identity are observed at the adapter boundary; actor epochs remain an internal implementation detail covered by focused tests and the reset-overlap runtime-option scenario.",
|
|
],
|
|
};
|
|
await fs.writeFile(
|
|
path.join(outputRoot, "summary.json"),
|
|
`${JSON.stringify(summary, null, 2)}\n`,
|
|
);
|
|
process.stdout.write(`[proof] all scenarios passed; artifacts=${outputRoot}\n`);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
main().catch((error: unknown) => {
|
|
process.stderr.write(
|
|
`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|