mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 13:40:30 +00:00
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:
@@ -223,6 +223,46 @@ async function invokeAgentIdentityGet(
|
||||
}
|
||||
|
||||
describe("gateway agent handler", () => {
|
||||
it("preserves ACP metadata from the current stored session entry", async () => {
|
||||
const existingAcpMeta = {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime-1",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
};
|
||||
|
||||
mockMainSessionEntry({
|
||||
acp: existingAcpMeta,
|
||||
});
|
||||
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const store: Record<string, unknown> = {
|
||||
"agent:main:main": {
|
||||
sessionId: "existing-session-id",
|
||||
updatedAt: Date.now(),
|
||||
acp: existingAcpMeta,
|
||||
},
|
||||
};
|
||||
const result = await updater(store);
|
||||
capturedEntry = store["agent:main:main"] as Record<string, unknown>;
|
||||
return result;
|
||||
});
|
||||
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
|
||||
await runMainAgent("test", "test-idem-acp-meta");
|
||||
|
||||
expect(mocks.updateSessionStore).toHaveBeenCalled();
|
||||
expect(capturedEntry).toBeDefined();
|
||||
expect(capturedEntry?.acp).toEqual(existingAcpMeta);
|
||||
});
|
||||
|
||||
it("preserves cliSessionIds from existing session entry", async () => {
|
||||
const existingCliSessionIds = { "claude-cli": "abc-123-def" };
|
||||
const existingClaudeCliSessionId = "abc-123-def";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BARE_SESSION_RESET_PROMPT } from "../../auto-reply/reply/session-reset-
|
||||
import { agentCommand } from "../../commands/agent.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
mergeSessionEntry,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveExplicitAgentSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
@@ -385,7 +386,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
resolvedGroupChannel = resolvedGroupChannel || inheritedGroup?.groupChannel;
|
||||
resolvedGroupSpace = resolvedGroupSpace || inheritedGroup?.groupSpace;
|
||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||
const nextEntry: SessionEntry = {
|
||||
const nextEntryPatch: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: now,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
@@ -410,7 +411,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
cliSessionIds: entry?.cliSessionIds,
|
||||
claudeCliSessionId: entry?.claudeCliSessionId,
|
||||
};
|
||||
sessionEntry = nextEntry;
|
||||
sessionEntry = mergeSessionEntry(entry, nextEntryPatch);
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry,
|
||||
@@ -432,7 +433,7 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey);
|
||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const persisted = await updateSessionStore(storePath, (store) => {
|
||||
const target = resolveGatewaySessionStoreTarget({
|
||||
cfg,
|
||||
key: requestedSessionKey,
|
||||
@@ -443,8 +444,11 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
canonicalKey: target.canonicalKey,
|
||||
candidates: target.storeKeys,
|
||||
});
|
||||
store[canonicalSessionKey] = nextEntry;
|
||||
const merged = mergeSessionEntry(store[canonicalSessionKey], nextEntryPatch);
|
||||
store[canonicalSessionKey] = merged;
|
||||
return merged;
|
||||
});
|
||||
sessionEntry = persisted;
|
||||
}
|
||||
if (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") {
|
||||
context.addChatRun(idem, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { getAcpSessionManager } from "../../acp/control-plane/manager.js";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js";
|
||||
import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../../agents/pi-embedded.js";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { unbindThreadBindingsBySessionKey } from "../../discord/monitor/thread-bindings.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import {
|
||||
@@ -202,6 +204,82 @@ async function ensureSessionRuntimeCleanup(params: {
|
||||
);
|
||||
}
|
||||
|
||||
const ACP_RUNTIME_CLEANUP_TIMEOUT_MS = 15_000;
|
||||
|
||||
async function runAcpCleanupStep(params: {
|
||||
op: () => Promise<void>;
|
||||
}): Promise<{ status: "ok" } | { status: "timeout" } | { status: "error"; error: unknown }> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<{ status: "timeout" }>((resolve) => {
|
||||
timer = setTimeout(() => resolve({ status: "timeout" }), ACP_RUNTIME_CLEANUP_TIMEOUT_MS);
|
||||
});
|
||||
const opPromise = params
|
||||
.op()
|
||||
.then(() => ({ status: "ok" as const }))
|
||||
.catch((error: unknown) => ({ status: "error" as const, error }));
|
||||
const outcome = await Promise.race([opPromise, timeoutPromise]);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async function closeAcpRuntimeForSession(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
sessionKey: string;
|
||||
entry?: SessionEntry;
|
||||
reason: "session-reset" | "session-delete";
|
||||
}) {
|
||||
if (!params.entry?.acp) {
|
||||
return undefined;
|
||||
}
|
||||
const acpManager = getAcpSessionManager();
|
||||
const cancelOutcome = await runAcpCleanupStep({
|
||||
op: async () => {
|
||||
await acpManager.cancelSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
reason: params.reason,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (cancelOutcome.status === "timeout") {
|
||||
return errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`Session ${params.sessionKey} is still active; try again in a moment.`,
|
||||
);
|
||||
}
|
||||
if (cancelOutcome.status === "error") {
|
||||
logVerbose(
|
||||
`sessions.${params.reason}: ACP cancel failed for ${params.sessionKey}: ${String(cancelOutcome.error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const closeOutcome = await runAcpCleanupStep({
|
||||
op: async () => {
|
||||
await acpManager.closeSession({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
reason: params.reason,
|
||||
requireAcpSession: false,
|
||||
allowBackendUnavailable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (closeOutcome.status === "timeout") {
|
||||
return errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
`Session ${params.sessionKey} is still active; try again in a moment.`,
|
||||
);
|
||||
}
|
||||
if (closeOutcome.status === "error") {
|
||||
logVerbose(
|
||||
`sessions.${params.reason}: ACP runtime close failed for ${params.sessionKey}: ${String(closeOutcome.error)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
"sessions.list": ({ params, respond }) => {
|
||||
if (!assertValidParams(params, validateSessionsListParams, "sessions.list", respond)) {
|
||||
@@ -348,7 +426,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key);
|
||||
const { entry } = loadSessionEntry(key);
|
||||
const { entry, legacyKey, canonicalKey } = loadSessionEntry(key);
|
||||
const hadExistingEntry = Boolean(entry);
|
||||
const commandReason = p.reason === "new" ? "new" : "reset";
|
||||
const hookEvent = createInternalHookEvent(
|
||||
@@ -369,6 +447,16 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, cleanupError);
|
||||
return;
|
||||
}
|
||||
const acpCleanupError = await closeAcpRuntimeForSession({
|
||||
cfg,
|
||||
sessionKey: legacyKey ?? canonicalKey ?? target.canonicalKey ?? key,
|
||||
entry,
|
||||
reason: "session-reset",
|
||||
});
|
||||
if (acpCleanupError) {
|
||||
respond(false, undefined, acpCleanupError);
|
||||
return;
|
||||
}
|
||||
let oldSessionId: string | undefined;
|
||||
let oldSessionFile: string | undefined;
|
||||
const next = await updateSessionStore(storePath, (store) => {
|
||||
@@ -446,13 +534,23 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
|
||||
|
||||
const { entry } = loadSessionEntry(key);
|
||||
const { entry, legacyKey, canonicalKey } = loadSessionEntry(key);
|
||||
const sessionId = entry?.sessionId;
|
||||
const cleanupError = await ensureSessionRuntimeCleanup({ cfg, key, target, sessionId });
|
||||
if (cleanupError) {
|
||||
respond(false, undefined, cleanupError);
|
||||
return;
|
||||
}
|
||||
const acpCleanupError = await closeAcpRuntimeForSession({
|
||||
cfg,
|
||||
sessionKey: legacyKey ?? canonicalKey ?? target.canonicalKey ?? key,
|
||||
entry,
|
||||
reason: "session-delete",
|
||||
});
|
||||
if (acpCleanupError) {
|
||||
respond(false, undefined, acpCleanupError);
|
||||
return;
|
||||
}
|
||||
const deleted = await updateSessionStore(storePath, (store) => {
|
||||
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
|
||||
const hadEntry = Boolean(store[primaryKey]);
|
||||
|
||||
Reference in New Issue
Block a user