diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e507fa8fed..fb715bc5ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/logging/diagnostic-run-activity.ts b/src/logging/diagnostic-run-activity.ts index 5f1c1fe7671..f8155e94fcb 100644 --- a/src/logging/diagnostic-run-activity.ts +++ b/src/logging/diagnostic-run-activity.ts @@ -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 { diff --git a/src/logging/diagnostic-session-state.ts b/src/logging/diagnostic-session-state.ts index 69a70681213..edc2e98d14c 100644 --- a/src/logging/diagnostic-session-state.ts +++ b/src/logging/diagnostic-session-state.ts @@ -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; + 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; } diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 2893cccadf6..831b8cc6c50 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -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", () => {