openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
247 lines (246 loc) • 9.45 kB
JavaScript
import { i as extractErrorCode, r as collectNestedErrorCandidates } from "./error-coercion-D_-xJ90S.js";
import { n as computeBackoff } from "./src-BQ327IOM.js";
import "./backoff-BkMI1WEL.js";
import { t as SESSION_WORK_START_CHANGED_ERROR_CODE } from "./work-start-error-Dt2kfIXb.js";
import fs from "node:fs";
import childProcess from "node:child_process";
import { randomUUID } from "node:crypto";
//#region src/channels/message/ingress-claim-owner.ts
/**
* Process-liveness identity for durable channel-ingress claims.
*
* ownerId = pid:startToken:uuid. Starttime binds the PID to one process instance so
* Linux TIDs and recycled PIDs cannot impersonate a dead claim owner.
*/
const INGRESS_CLAIM_LEASE_MS = 18e5;
function readProcessStartTime(pid) {
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
if (process.platform === "darwin") try {
const startedAt = childProcess.execFileSync("/bin/ps", [
"-o",
"lstart=",
"-p",
String(pid)
], {
encoding: "utf8",
env: {
...process.env,
LC_ALL: "C",
TZ: "UTC"
},
stdio: [
"ignore",
"pipe",
"ignore"
],
timeout: 2e3,
killSignal: "SIGKILL"
}).trim();
const startedAtMs = Date.parse(`${startedAt} UTC`);
return Number.isFinite(startedAtMs) ? Math.floor(startedAtMs / 1e3) : null;
} catch {
return null;
}
if (process.platform !== "linux") return null;
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
const commEndIndex = stat.lastIndexOf(")");
if (commEndIndex < 0) return null;
const fields = stat.slice(commEndIndex + 1).trimStart().split(/\s+/);
const starttime = Number(fields[19]);
return Number.isInteger(starttime) && starttime >= 0 ? starttime : null;
} catch {
return null;
}
}
const INGRESS_CLAIM_PROCESS_START_TIME = readProcessStartTime(process.pid);
const INGRESS_CLAIM_PROCESS_ID = [
process.pid,
INGRESS_CLAIM_PROCESS_START_TIME ?? "x",
randomUUID()
].join(":");
/** Process-local live drain instance UUIDs (ownerId third field). */
const liveIngressDrainInstanceIds = /* @__PURE__ */ new Set();
function processPidFromOwnerId(ownerId) {
const pid = Number.parseInt(ownerId.split(":", 1)[0] ?? "", 10);
return Number.isSafeInteger(pid) && pid > 0 ? pid : -1;
}
/** Instance UUID from ownerId `pid:startToken:uuid`. */
function processInstanceIdFromOwnerId(ownerId) {
const parts = ownerId.split(":");
if (parts.length < 3) return null;
const instanceId = parts[2];
return instanceId && instanceId.length > 0 ? instanceId : null;
}
/** Mint a unique per-drain ownerId (`pid:startToken:uuid`). Caller registers via drain. */
function createIngressDrainOwnerId() {
return [
process.pid,
INGRESS_CLAIM_PROCESS_START_TIME ?? "x",
randomUUID()
].join(":");
}
function registerLiveIngressDrainInstance(ownerId) {
const instanceId = processInstanceIdFromOwnerId(ownerId);
if (instanceId) liveIngressDrainInstanceIds.add(instanceId);
}
function deregisterLiveIngressDrainInstance(ownerId) {
const instanceId = processInstanceIdFromOwnerId(ownerId);
if (instanceId) liveIngressDrainInstanceIds.delete(instanceId);
}
/**
* True when a same-process drain instance still holds this ownerId.
* Recovery must not steal claims from a live peer drain on the same queue.
*/
function isLiveLocalIngressDrainOwner(ownerId) {
const instanceId = processInstanceIdFromOwnerId(ownerId);
return instanceId != null && liveIngressDrainInstanceIds.has(instanceId);
}
function parseOwnerStartToken(ownerId) {
const parts = ownerId.split(":");
if (parts.length === 2) return { kind: "existence-only" };
if (parts.length < 2) return { kind: "missing" };
const startField = parts[1] ?? "";
if (startField === "x") return { kind: "existence-only" };
const starttime = Number(startField);
if (Number.isSafeInteger(starttime) && starttime >= 0) return {
kind: "numeric",
value: starttime
};
return { kind: "missing" };
}
function processExists(pid) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (err) {
const code = err.code;
return code !== "ESRCH" && code !== "EINVAL";
}
}
function isFreshClaimOwner(claim, options) {
const now = options?.now ?? Date.now();
const maxAgeMs = options?.maxAgeMs ?? 18e5;
return now - claim.claimedAt < maxAgeMs;
}
function isClaimOwnerProcessInstanceLive(claim, options) {
const exists = options?.processExists ?? processExists;
const readStart = options?.readProcessStartTime ?? readProcessStartTime;
if (!exists(claim.processPid)) return false;
const startToken = parseOwnerStartToken(claim.processId);
if (startToken.kind === "missing") return false;
if (startToken.kind === "existence-only") return true;
const actualStart = readStart(claim.processPid);
if (actualStart === null) return true;
return actualStart === startToken.value;
}
function toOwnerIdentity(claim) {
return {
processId: claim.ownerId,
processPid: processPidFromOwnerId(claim.ownerId),
claimedAt: claim.claimedAt
};
}
function resolveOwnerIdentity(claim) {
const raw = claim.claim;
if (!raw) return null;
if ("ownerId" in raw) return toOwnerIdentity(raw);
return {
processId: raw.processId,
processPid: raw.processPid,
claimedAt: raw.claimedAt
};
}
/** True when another live process still holds a fresh claim on this event. */
function isIngressClaimOwnedByOtherLiveProcess(claim, options) {
const owner = resolveOwnerIdentity(claim);
if (!owner) return false;
return owner.processId !== INGRESS_CLAIM_PROCESS_ID && owner.processPid !== process.pid && isFreshClaimOwner(owner, options) && isClaimOwnerProcessInstanceLive(owner, options);
}
/** True when a corrupt claimed row is still live-owned by this or another process. */
function isIngressCorruptClaimOwnedByOtherLiveProcess(claim, options) {
const owner = toOwnerIdentity(claim.claim);
if (owner.processId === INGRESS_CLAIM_PROCESS_ID) return isFreshClaimOwner(owner, options);
return owner.processPid !== process.pid && isFreshClaimOwner(owner, options) && isClaimOwnerProcessInstanceLive(owner, options);
}
//#endregion
//#region src/channels/message/ingress-retry-policy.ts
/**
* Generic ingress retry backoff and dead-letter decisions.
*
* Channel-specific non-retryable classification stays out of core; pass it in.
*/
const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS = 8;
const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS = 864e5;
const DEFAULT_INGRESS_RETRY_BASE_MS = 1e3;
const DEFAULT_INGRESS_RETRY_MAX_MS = 18e4;
function isSessionStartConflictFailure(error) {
return collectNestedErrorCandidates(error).some((candidate) => extractErrorCode(candidate) === SESSION_WORK_START_CHANGED_ERROR_CODE);
}
function resolveConfig(config) {
return {
maxAttempts: config?.maxAttempts ?? 8,
deadLetterMinAgeMs: config?.deadLetterMinAgeMs ?? 864e5,
baseMs: config?.baseMs ?? 1e3,
maxMs: config?.maxMs ?? 18e4
};
}
/** Next attempt number after a failed dispatch (1-based for the attempt just finished). */
function resolveIngressAttemptNumber(event) {
return (event.attempts ?? 0) + 1;
}
/** Remaining backoff delay before a released event may be claimed again. */
function resolveIngressRetryDelayMs(event, config, now = Date.now()) {
const { baseMs, maxMs } = resolveConfig(config);
const attempts = event.attempts ?? 0;
if (!event.lastError || event.lastAttemptAt === void 0 || attempts <= 0) return 0;
const delayMs = computeBackoff({
initialMs: baseMs,
maxMs,
factor: 2,
jitter: 0
}, Math.min(attempts, 9));
return Math.max(0, event.lastAttemptAt + delayMs - now);
}
/**
* Dead-letter requires BOTH attempt floor and minimum age.
* Over-limit events keep retrying at the capped delay until age is met.
*/
function shouldDeadLetterRetryableIngressEvent(event, attempt, config, now = Date.now()) {
const { maxAttempts, deadLetterMinAgeMs } = resolveConfig(config);
return attempt >= maxAttempts && now - event.receivedAt >= deadLetterMinAgeMs;
}
/** Resolve release vs fail for a dispatch error using optional non-retryable hook. */
function resolveIngressFailureDisposition(params) {
const now = params.now ?? Date.now();
const { maxAttempts } = resolveConfig(params.config);
const attempt = resolveIngressAttemptNumber(params.event);
const message = params.formatError(params.err);
const nonRetryable = params.resolveNonRetryableFailure?.(params.err) ?? null;
if (nonRetryable) return {
kind: "fail",
reason: nonRetryable.reason,
message: nonRetryable.message,
attempt
};
if (attempt >= maxAttempts && isSessionStartConflictFailure(params.err)) return {
kind: "fail",
reason: "session-start-conflict-retry-limit",
message,
attempt
};
if (shouldDeadLetterRetryableIngressEvent(params.event, attempt, params.config, now)) return {
kind: "fail",
reason: "retry-limit-exceeded",
message,
attempt
};
return {
kind: "release",
attempt,
message
};
}
//#endregion
export { resolveIngressFailureDisposition as a, INGRESS_CLAIM_PROCESS_ID as c, isIngressClaimOwnedByOtherLiveProcess as d, isIngressCorruptClaimOwnedByOtherLiveProcess as f, registerLiveIngressDrainInstance as h, DEFAULT_INGRESS_RETRY_MAX_MS as i, createIngressDrainOwnerId as l, processPidFromOwnerId as m, DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS as n, resolveIngressRetryDelayMs as o, isLiveLocalIngressDrainOwner as p, DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS as r, INGRESS_CLAIM_LEASE_MS as s, DEFAULT_INGRESS_RETRY_BASE_MS as t, deregisterLiveIngressDrainInstance as u };