mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 07:51:12 +00:00
* feat(codex): continue paired-node Codex catalog sessions from the Control UI Codex catalog rows on paired nodes were view-only. Node rows are now continuable when the node advertises and permits the codex threads.list, thread.turns.list, and cli.session.resume commands. Continue adopts the session into a locked codex OpenClaw session (deterministic harness:codex:node-session key, bounded native-history import, two-phase initializing marker) and installs a webchat conversation binding (codex-cli-node-session) through the new generic bindPluginSessionConversation seam with attempt-ID guarded rollback, so each web turn runs codex exec resume on the owning node via the existing inbound_claim path. Local continue/archive semantics are unchanged; archived rows are no longer offered as continuable. * fix(plugins): serialize plugin session binds per session Review P2: the bind rollback checked the attempt id and then awaited the unbind, so a concurrent Continue could install a newer binding between the two and lose it to the older attempt's rollback. All session binds for one session key now queue through an in-process tail, so a failing attempt can only ever roll back its own binding; the attempt-id guard stays as defense against out-of-band rebinds. * refactor(codex): extract node-continue and session-bind modules for the LOC ratchet Pure movement to satisfy the changed-file TypeScript size ratchet: the paired-node continue flow moves to session-catalog-node-continue.ts and session-catalog-node-adoption.ts, catalog paging/parsing helpers to session-catalog-parsing.ts, the Control UI session-bind seam to session-conversation-binding.ts (+ session-key helpers), and the internal conversation-id/channel-prefix helpers to utils. transcript-mirror.ts is restored to main byte-for-byte; the node history builder constructs a plain CodexThread (only id is required) instead of widening signatures. * fix(plugins): satisfy preserve-caught-error in the session-bind rollback * fix(codex): gate node continue on operator.admin and resume by CLI session id Second review round: (1) bound node turns pass canMutateCodexHost only for owners/admins, while sessions.catalog.continue is operator.write — a write-scoped Control UI client could adopt a node session whose every turn would then be rejected. The gateway now forwards the caller's scopes to the provider (SessionCatalogContinueProviderParams.clientScopes) and codex fails node continues closed without operator.admin, before the dedupe join so a non-admin cannot ride an admin's in-flight operation. (2) The node binding stored the app-server threadId, but codex exec resume takes the CLI session id and forked threads share a session tree where the two differ; the binding now prefers record.sessionId. * fix(codex): defer node-session restore until the binding is durable Third review round: continuing an archived adopted node session unarchived it before the gateway installed the conversation binding, so a failed bind left a visible session with no node routing. The unarchive now rides afterConversationBound; finalizeNodeAdoptedSession already covers both the initializing and finalized marker states, so the eager restore helper is deleted.
115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
import crypto from "node:crypto";
|
|
import {
|
|
createConversationBindingRecord,
|
|
resolveConversationBindingRecord,
|
|
unbindConversationBindingRecord,
|
|
} from "../bindings/records.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js";
|
|
import { bindConversationNow, buildPluginBindingIdentity } from "./conversation-binding.js";
|
|
import type {
|
|
PluginConversationBinding,
|
|
PluginConversationBindingRequestParams,
|
|
} from "./conversation-binding.types.js";
|
|
|
|
const log = createSubsystemLogger("plugins/binding");
|
|
|
|
// Serializes bind+finalize+rollback per session so a failing older attempt
|
|
// can never unbind or restore over a newer successful one (all session binds
|
|
// go through this in-process seam).
|
|
const pluginSessionBindTails = new Map<string, Promise<void>>();
|
|
|
|
/** Binds a plugin-owned runtime to one authenticated Control UI session. */
|
|
export async function bindPluginSessionConversation(params: {
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
pluginRoot: string;
|
|
sessionKey: string;
|
|
binding: PluginConversationBindingRequestParams;
|
|
afterBind?: () => Promise<void>;
|
|
}): Promise<PluginConversationBinding> {
|
|
const sessionKey = params.sessionKey.trim();
|
|
if (!sessionKey) {
|
|
throw new Error("session key is required for a plugin session binding");
|
|
}
|
|
const previousTail = pluginSessionBindTails.get(sessionKey) ?? Promise.resolve();
|
|
const operation = previousTail.then(() =>
|
|
bindPluginSessionConversationExclusive({ ...params, sessionKey }),
|
|
);
|
|
const tail = operation.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
pluginSessionBindTails.set(sessionKey, tail);
|
|
try {
|
|
return await operation;
|
|
} finally {
|
|
if (pluginSessionBindTails.get(sessionKey) === tail) {
|
|
pluginSessionBindTails.delete(sessionKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function bindPluginSessionConversationExclusive(params: {
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
pluginRoot: string;
|
|
sessionKey: string;
|
|
binding: PluginConversationBindingRequestParams;
|
|
afterBind?: () => Promise<void>;
|
|
}): Promise<PluginConversationBinding> {
|
|
const sessionKey = params.sessionKey;
|
|
const conversation = {
|
|
channel: INTERNAL_MESSAGE_CHANNEL,
|
|
accountId: "default",
|
|
conversationId: sessionKey,
|
|
};
|
|
const previous = resolveConversationBindingRecord(conversation);
|
|
const bindingAttemptId = crypto.randomUUID();
|
|
const binding = await bindConversationNow({
|
|
identity: buildPluginBindingIdentity(params),
|
|
conversation,
|
|
targetSessionKey: sessionKey,
|
|
summary: params.binding.summary,
|
|
detachHint: params.binding.detachHint,
|
|
data: params.binding.data,
|
|
bindingAttemptId,
|
|
});
|
|
try {
|
|
await params.afterBind?.();
|
|
return binding;
|
|
} catch (error) {
|
|
const current = resolveConversationBindingRecord(conversation);
|
|
if (current?.metadata?.bindingAttemptId !== bindingAttemptId) {
|
|
throw error;
|
|
}
|
|
try {
|
|
await unbindConversationBindingRecord({
|
|
bindingId: current.bindingId,
|
|
reason: "plugin-session-bind-rollback",
|
|
});
|
|
if (previous && (previous.expiresAt === undefined || previous.expiresAt > Date.now())) {
|
|
await createConversationBindingRecord({
|
|
targetSessionKey: previous.targetSessionKey,
|
|
targetKind: previous.targetKind,
|
|
conversation: previous.conversation,
|
|
placement: "current",
|
|
metadata: previous.metadata,
|
|
...(previous.expiresAt === undefined
|
|
? {}
|
|
: { ttlMs: Math.max(1, previous.expiresAt - Date.now()) }),
|
|
});
|
|
}
|
|
} catch (rollbackError) {
|
|
// The finalize failure is superseded by the rollback failure on the
|
|
// throw path; keep it observable for diagnosis.
|
|
log.warn("plugin session binding finalization failed before rollback", { error });
|
|
throw new Error(
|
|
"plugin session binding finalization failed and its previous binding could not be restored",
|
|
{ cause: rollbackError },
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|