mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
fix: canonicalize diagnostic session aliases
This commit is contained in:
@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/native commands: compare Discord-normalized slash-command descriptions and localized descriptions during reconcile so CJK or multiline command text no longer triggers redundant startup PATCH bursts and rate-limit 429s. Fixes #76587. Thanks @zhengsx.
|
||||
- Agents/OpenAI: omit Chat Completions `reasoning_effort` for `gpt-5.4-mini` only when function tools are present while preserving tool-free Chat and Responses reasoning support, preventing Telegram-routed fallback runs from hanging after OpenAI rejects tool payloads. Fixes #76176. Thanks @ThisIsAdilah and @chinar-amrutkar.
|
||||
- Telegram: reuse the successful startup `getMe` probe for grammY polling startup and continue into `getUpdates` after recoverable `deleteWebhook` cleanup failures, reducing high-latency Bot API control-plane calls before long polling starts. Refs #76388. Thanks @jackiedepp.
|
||||
- Gateway/diagnostics: merge session id/key aliases in diagnostic session state and activity tracking so completed runs no longer leave stale queued work behind that keeps liveness samples at warning level.
|
||||
- Agents/models: forward model `maxTokens` as the default output-token limit for OpenAI-compatible Responses and Completions transports when no runtime override is provided, preventing provider defaults from silently truncating larger outputs. (#76645) Thanks @joeyfrasier.
|
||||
- macOS CLI/onboarding: honor sensitive wizard text steps in `openclaw-mac wizard` with termios no-echo input, suppressing saved credential previews while preserving long API keys and gateway tokens. Fixes #76698. Thanks @anurag-bg-neu and @sallyom.
|
||||
- Control UI/Skills: fix skill detail modal silently failing to open in all browsers by deferring `showModal()` until the dialog element is connected to the DOM; the Lit `ref` callback fired before connection causing a `DOMException: HTMLDialogElement.showModal: Dialog element is not connected` on every skill click. Thanks @nickmopen.
|
||||
|
||||
@@ -46,34 +46,86 @@ function sessionRefs(params: { sessionId?: string; sessionKey?: string }): strin
|
||||
return refs;
|
||||
}
|
||||
|
||||
function registerSessionActivityRefs(
|
||||
activity: SessionActivity,
|
||||
params: { sessionId?: string; sessionKey?: string; runId?: string },
|
||||
): void {
|
||||
activity.sessionId ??= params.sessionId;
|
||||
activity.sessionKey ??= params.sessionKey;
|
||||
for (const ref of sessionRefs(params)) {
|
||||
activityByRef.set(ref, activity);
|
||||
}
|
||||
if (params.runId) {
|
||||
activityByRunId.set(params.runId, activity);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceSessionActivityReferences(source: SessionActivity, target: SessionActivity): void {
|
||||
for (const [ref, activity] of activityByRef) {
|
||||
if (activity === source) {
|
||||
activityByRef.set(ref, target);
|
||||
}
|
||||
}
|
||||
for (const [runId, activity] of activityByRunId) {
|
||||
if (activity === source) {
|
||||
activityByRunId.set(runId, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSessionActivity(target: SessionActivity, source: SessionActivity): void {
|
||||
target.sessionId ??= source.sessionId;
|
||||
target.sessionKey ??= source.sessionKey;
|
||||
target.activeEmbeddedRun ||= source.activeEmbeddedRun;
|
||||
for (const [key, tool] of source.activeTools) {
|
||||
target.activeTools.set(key, tool);
|
||||
}
|
||||
for (const call of source.activeModelCalls) {
|
||||
target.activeModelCalls.add(call);
|
||||
}
|
||||
if (source.lastProgressAt > target.lastProgressAt) {
|
||||
target.lastProgressAt = source.lastProgressAt;
|
||||
target.lastProgressReason = source.lastProgressReason;
|
||||
}
|
||||
replaceSessionActivityReferences(source, target);
|
||||
}
|
||||
|
||||
function resolveSessionActivity(params: {
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runId?: string;
|
||||
create?: boolean;
|
||||
}): SessionActivity | undefined {
|
||||
let activity: SessionActivity | undefined;
|
||||
if (params.runId) {
|
||||
const byRun = activityByRunId.get(params.runId);
|
||||
if (byRun) {
|
||||
return byRun;
|
||||
activity = byRun;
|
||||
}
|
||||
}
|
||||
|
||||
for (const ref of sessionRefs(params)) {
|
||||
const activity = activityByRef.get(ref);
|
||||
if (activity) {
|
||||
if (params.runId) {
|
||||
activityByRunId.set(params.runId, activity);
|
||||
}
|
||||
return activity;
|
||||
const byRef = activityByRef.get(ref);
|
||||
if (!byRef) {
|
||||
continue;
|
||||
}
|
||||
if (!activity) {
|
||||
activity = byRef;
|
||||
} else if (activity !== byRef) {
|
||||
mergeSessionActivity(activity, byRef);
|
||||
}
|
||||
}
|
||||
|
||||
if (activity) {
|
||||
registerSessionActivityRefs(activity, params);
|
||||
return activity;
|
||||
}
|
||||
|
||||
if (!params.create) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const activity: SessionActivity = {
|
||||
const created: SessionActivity = {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
activeEmbeddedRun: false,
|
||||
@@ -81,13 +133,8 @@ function resolveSessionActivity(params: {
|
||||
activeModelCalls: new Set(),
|
||||
lastProgressAt: Date.now(),
|
||||
};
|
||||
for (const ref of sessionRefs(params)) {
|
||||
activityByRef.set(ref, activity);
|
||||
}
|
||||
if (params.runId) {
|
||||
activityByRunId.set(params.runId, activity);
|
||||
}
|
||||
return activity;
|
||||
registerSessionActivityRefs(created, params);
|
||||
return created;
|
||||
}
|
||||
|
||||
function touchSessionActivity(activity: SessionActivity, reason: string, now = Date.now()): void {
|
||||
|
||||
@@ -70,21 +70,75 @@ function resolveSessionKey({ sessionKey, sessionId }: SessionRef) {
|
||||
return sessionKey ?? sessionId ?? "unknown";
|
||||
}
|
||||
|
||||
function findStateBySessionId(sessionId: string): SessionState | undefined {
|
||||
for (const state of diagnosticSessionStates.values()) {
|
||||
function findStateEntryBySessionId(sessionId: string): [string, SessionState] | undefined {
|
||||
for (const entry of diagnosticSessionStates.entries()) {
|
||||
const [, state] = entry;
|
||||
if (state.sessionId === sessionId) {
|
||||
return state;
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sessionStatePriority(state: SessionStateValue): number {
|
||||
const priorities = {
|
||||
idle: 0,
|
||||
waiting: 1,
|
||||
processing: 2,
|
||||
} satisfies Record<SessionStateValue, number>;
|
||||
return priorities[state];
|
||||
}
|
||||
|
||||
function mergeSessionState(target: SessionState, source: SessionState): void {
|
||||
const sourceIsNewer = source.lastActivity > target.lastActivity;
|
||||
const sourceIsSameAgeAndMoreActive =
|
||||
source.lastActivity === target.lastActivity &&
|
||||
sessionStatePriority(source.state) > sessionStatePriority(target.state);
|
||||
target.sessionId ??= source.sessionId;
|
||||
target.sessionKey ??= source.sessionKey;
|
||||
if (sourceIsNewer || sourceIsSameAgeAndMoreActive) {
|
||||
target.state = source.state;
|
||||
}
|
||||
target.lastActivity = Math.max(target.lastActivity, source.lastActivity);
|
||||
target.queueDepth += source.queueDepth;
|
||||
target.lastStuckWarnAgeMs =
|
||||
target.lastStuckWarnAgeMs === undefined || source.lastStuckWarnAgeMs === undefined
|
||||
? undefined
|
||||
: Math.max(target.lastStuckWarnAgeMs, source.lastStuckWarnAgeMs);
|
||||
if (source.toolCallHistory?.length) {
|
||||
target.toolCallHistory = [...(target.toolCallHistory ?? []), ...source.toolCallHistory];
|
||||
}
|
||||
if (source.toolLoopWarningBuckets?.size) {
|
||||
const buckets = (target.toolLoopWarningBuckets ??= new Map());
|
||||
for (const [bucket, count] of source.toolLoopWarningBuckets) {
|
||||
buckets.set(bucket, Math.max(buckets.get(bucket) ?? 0, count));
|
||||
}
|
||||
}
|
||||
if (source.commandPollCounts?.size) {
|
||||
const counts = (target.commandPollCounts ??= new Map());
|
||||
for (const [command, value] of source.commandPollCounts) {
|
||||
const existing = counts.get(command);
|
||||
if (!existing || value.lastPollAt > existing.lastPollAt) {
|
||||
counts.set(command, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getDiagnosticSessionState(ref: SessionRef): SessionState {
|
||||
pruneDiagnosticSessionStates();
|
||||
const key = resolveSessionKey(ref);
|
||||
const existing =
|
||||
diagnosticSessionStates.get(key) ?? (ref.sessionId && findStateBySessionId(ref.sessionId));
|
||||
const direct = diagnosticSessionStates.get(key);
|
||||
const sessionIdEntry = ref.sessionId ? findStateEntryBySessionId(ref.sessionId) : undefined;
|
||||
const existing = direct ?? sessionIdEntry?.[1];
|
||||
if (existing) {
|
||||
if (direct && sessionIdEntry && sessionIdEntry[1] !== direct) {
|
||||
mergeSessionState(direct, sessionIdEntry[1]);
|
||||
diagnosticSessionStates.delete(sessionIdEntry[0]);
|
||||
} else if (!direct && ref.sessionKey && sessionIdEntry) {
|
||||
diagnosticSessionStates.delete(sessionIdEntry[0]);
|
||||
diagnosticSessionStates.set(key, existing);
|
||||
}
|
||||
if (ref.sessionId) {
|
||||
existing.sessionId = ref.sessionId;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
setDiagnosticsEnabledForProcess,
|
||||
type DiagnosticEventPayload,
|
||||
} from "../infra/diagnostic-events.js";
|
||||
import { markDiagnosticEmbeddedRunStarted } from "./diagnostic-run-activity.js";
|
||||
import {
|
||||
getDiagnosticSessionActivitySnapshot,
|
||||
markDiagnosticEmbeddedRunStarted,
|
||||
} from "./diagnostic-run-activity.js";
|
||||
import {
|
||||
diagnosticSessionStates,
|
||||
getDiagnosticSessionStateCountForTest,
|
||||
@@ -93,6 +96,70 @@ describe("diagnostic session state pruning", () => {
|
||||
expect(bySessionId.sessionKey).toBe("agent:main:demo-channel:channel:c1");
|
||||
expect(getDiagnosticSessionStateCountForTest()).toBe(1);
|
||||
});
|
||||
|
||||
it("canonicalizes sessionId-only state when the sessionKey becomes known", () => {
|
||||
const sessionKey = "agent:main:demo-channel:channel:c1";
|
||||
const pending = getDiagnosticSessionState({ sessionId: "s1" });
|
||||
pending.queueDepth = 1;
|
||||
|
||||
const keyed = getDiagnosticSessionState({ sessionId: "s1", sessionKey });
|
||||
|
||||
expect(keyed).toBe(pending);
|
||||
expect(keyed.queueDepth).toBe(1);
|
||||
expect(diagnosticSessionStates.has("s1")).toBe(false);
|
||||
expect(diagnosticSessionStates.get(sessionKey)).toBe(keyed);
|
||||
expect(getDiagnosticSessionState({ sessionKey })).toBe(keyed);
|
||||
expect(getDiagnosticSessionStateCountForTest()).toBe(1);
|
||||
});
|
||||
|
||||
it("merges split sessionId and sessionKey state without leaving stale queued work", () => {
|
||||
const sessionKey = "agent:main:demo-channel:channel:c1";
|
||||
const keyed = getDiagnosticSessionState({ sessionKey });
|
||||
keyed.queueDepth = 1;
|
||||
keyed.lastActivity = 1;
|
||||
const bySessionId = getDiagnosticSessionState({ sessionId: "s1" });
|
||||
bySessionId.queueDepth = 1;
|
||||
bySessionId.state = "processing";
|
||||
bySessionId.lastActivity = 2;
|
||||
|
||||
const merged = getDiagnosticSessionState({ sessionId: "s1", sessionKey });
|
||||
|
||||
expect(merged).toBe(keyed);
|
||||
expect(merged.queueDepth).toBe(2);
|
||||
expect(merged.state).toBe("processing");
|
||||
expect(diagnosticSessionStates.has("s1")).toBe(false);
|
||||
expect(getDiagnosticSessionStateCountForTest()).toBe(1);
|
||||
|
||||
logSessionStateChange({ sessionId: "s1", sessionKey, state: "idle", reason: "run_completed" });
|
||||
logSessionStateChange({ sessionKey, state: "idle", reason: "message_completed" });
|
||||
|
||||
expect(getDiagnosticSessionState({ sessionKey }).queueDepth).toBe(0);
|
||||
expect(getDiagnosticSessionStateCountForTest()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diagnostic session activity aliases", () => {
|
||||
beforeEach(() => {
|
||||
resetDiagnosticStateForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetDiagnosticStateForTest();
|
||||
});
|
||||
|
||||
it("registers the sessionKey alias when activity first arrives with only a sessionId", () => {
|
||||
const sessionKey = "agent:main:demo-channel:channel:c1";
|
||||
|
||||
markDiagnosticEmbeddedRunStarted({ sessionId: "s1" });
|
||||
markDiagnosticEmbeddedRunStarted({ sessionId: "s1", sessionKey });
|
||||
|
||||
expect(getDiagnosticSessionActivitySnapshot({ sessionKey }).activeWorkKind).toBe(
|
||||
"embedded_run",
|
||||
);
|
||||
expect(getDiagnosticSessionActivitySnapshot({ sessionId: "s1" }).activeWorkKind).toBe(
|
||||
"embedded_run",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logger import side effects", () => {
|
||||
|
||||
Reference in New Issue
Block a user