fix: canonicalize diagnostic session aliases

This commit is contained in:
Peter Steinberger
2026-05-03 16:57:31 +01:00
parent 02b1075a57
commit 1e8de7661e
4 changed files with 190 additions and 21 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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", () => {