fix: recover terminal session status on visible inbound turns (#89045)

* fix: recover terminal session status on visible inbound turns (#86827)

When a group chat session enters a terminal status (failed/timeout/killed),
subsequent visible inbound messages now automatically recover the session by
clearing stale lifecycle fields while preserving the session ID and transcript
continuity.

Changes:
- session.ts: detect terminal status on visible turns and clear status/
  startedAt/endedAt/runtimeMs/abortedLastRun without rotating sessionId
- dispatch-from-config.ts: force-clear stale active reply operations for
  terminal sessions and retry admission once
- agent.ts: mirror terminal recovery in the agent API dispatch path
- kernel.ts: add zero-count visible dispatch warning diagnostic
- types.ts: add 'warning' to ChannelTurnLogEvent event union

* fix: guard terminal recovery from concurrent force-clear race

When two visible messages arrive against the same terminal session
snapshot, the second turn could force-fail the first turn's freshly
admitted recovery operation, dropping the very message the recovery
path exists to save.

Add a terminalRecovery flag on ReplyOperation that is set after a
recovery turn clears the proven stale leftover and admits its own
operation. The force-clear branch now skips operations marked as
in-flight terminal recoveries, letting concurrent turns fall through
to normal busy/wait handling instead.

Add a two-turn regression test that gates the first recovery turn
open, races a second visible turn against the same terminal snapshot,
and asserts neither turn's operation is incorrectly killed.

Also fix missing FinalizedMsgContext import in kernel.ts.

* fix: avoid return value in Promise executor to satisfy lint

* fix: gate terminal force-clear to visible reply turns

A heartbeat/control turn can pass the early active-run short-circuit, reach
the terminal force-clear branch, and abort an in-flight visible recovery
operation that a concurrent visible turn just admitted (before that op is
marked terminalRecovery). Gate the force-clear to visible reply turns so
non-visible turns fall through to normal busy/skip handling instead of
killing the recovery they are meant to protect.

Adds a focused regression test exercising a heartbeat turn that reaches the
force-clear branch against a terminal session snapshot with an active
recovery operation present; it must leave that operation intact and skip.

* fix: verify session identity before terminal force-clear

* fix: mark clean no-stale terminal recovery to survive concurrent visible turn

The same-session terminal-recovery race could still drop a reply when two
visible turns raced the same failed snapshot. The terminalRecovery marker was
set only on the post-force-clear re-admission path, never on the clean no-stale
admission path. So when the first turn admitted cleanly (no stale op to clear),
its recovery op stayed terminalRecovery=false and a second concurrent visible
turn force-cleared it, dropping the first reply (#86827).

Consolidate marking to the single owned-admission choke point that both the
clean no-stale admission and the re-admission-after-force-clear flow through.
Genuine stale leftovers from the original failed run never pass through this
admission, so they stay unmarked and remain force-clearable.

Add a no-stale regression twin to the existing stale-race test.

* fix: suppress zero-count visible-dispatch warning for observed-delivery turns

maybeWarnZeroCountVisibleDispatch re-implemented a partial visibility
check that omitted observedReplyDelivery, so visible turns delivered via
the observed-delivery path (queuedFinal=false, zero counts,
observedReplyDelivery=true) falsely tripped the silent-drop sentinel and
emitted a bogus zero-count-visible-dispatch warning/event.

Use the canonical hasVisibleChannelTurnDispatch helper for the warning
suppression so all non-count delivery paths (observedReplyDelivery,
fallback, summary, queuedFinal) are honored. Add regression tests
covering the observed-delivery case (no warning) and a genuinely empty
visible dispatch (still warns).

* test: read persisted session store in failed-group recovery test

---------

Co-authored-by: 忻役 <xinyi@mininglamp.com>
This commit is contained in:
Jerry-Xin
2026-06-29 06:45:37 +08:00
committed by GitHub
parent 30b39c6eab
commit 6f90ee4cb8
12 changed files with 793 additions and 29 deletions
@@ -448,6 +448,7 @@ function createMockReplyOperation(): {
sessionId: "session",
abortSignal: new AbortController().signal,
resetTriggered: false,
terminalRecovery: false,
phase: "running",
result: null,
setPhase: vi.fn(),
@@ -461,6 +462,7 @@ function createMockReplyOperation(): {
fail: failMock,
abortByUser: vi.fn(),
abortForRestart: vi.fn(),
markTerminalRecovery: vi.fn(),
},
};
}
@@ -46,6 +46,7 @@ function createReplyOperation(): TestReplyOperation {
sessionId: "session",
abortSignal: new AbortController().signal,
resetTriggered: false,
terminalRecovery: false,
phase: "queued",
result: null,
setPhase: vi.fn<ReplyOperation["setPhase"]>(),
@@ -61,6 +62,7 @@ function createReplyOperation(): TestReplyOperation {
fail: vi.fn(),
abortByUser: vi.fn(),
abortForRestart: vi.fn(),
markTerminalRecovery: vi.fn(),
};
}
@@ -1713,6 +1713,377 @@ describe("dispatchReplyFromConfig", () => {
}
});
it("clears stale active reply operations for terminal sessions and retries admission", async () => {
setNoAbort();
const sessionKey = "agent:main:telegram:group:-1003774691294";
const sessionId = "failed-session";
const activeOperation = createReplyOperation({
sessionKey,
sessionId,
resetTriggered: false,
});
activeOperation.setPhase("running");
sessionStoreMocks.currentEntry = {
sessionId,
updatedAt: Date.now(),
status: "failed",
};
const dispatcher = createDispatcher();
const replyResolver = vi.fn(async () => ({ text: "fresh reply" }) satisfies ReplyPayload);
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
ChatType: "group",
SessionKey: sessionKey,
MessageSid: "visible-after-failure",
To: "telegram:-1003774691294",
BodyForAgent: "@openclaw recover",
}),
cfg: automaticGroupReplyConfig,
dispatcher,
replyResolver,
});
expect(activeOperation.result).toMatchObject({ kind: "failed", code: "run_failed" });
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
queuedFinal: true,
counts: { tool: 0, block: 0, final: 0 },
});
expect(replyRunRegistry.isActive(sessionKey)).toBe(false);
});
it("does not kill a sibling recovery turn when a second visible turn races the same terminal snapshot", async () => {
setNoAbort();
const sessionKey = "agent:main:telegram:group:-1003774691295";
const sessionId = "failed-session-race";
// Leftover stuck run from the failed lifecycle; both racing turns read the
// same terminal store snapshot below.
const staleOperation = createReplyOperation({
sessionKey,
sessionId,
resetTriggered: false,
});
staleOperation.setPhase("running");
sessionStoreMocks.currentEntry = {
sessionId,
updatedAt: Date.now(),
status: "failed",
};
let releaseFirstTurn: () => void = () => {};
const firstResolverGate = new Promise<void>((release) => {
releaseFirstTurn = release;
});
let signalFirstResolverEntered: () => void = () => {};
const firstTurnEntered = new Promise<void>((resolve) => {
signalFirstResolverEntered = resolve;
});
const firstReplyResolver = vi.fn(async () => {
signalFirstResolverEntered();
await firstResolverGate;
return { text: "first recovery reply" } satisfies ReplyPayload;
});
const secondReplyResolver = vi.fn(
async () => ({ text: "second reply" }) satisfies ReplyPayload,
);
const firstDispatcher = createDispatcher();
const secondDispatcher = createDispatcher();
const buildRaceCtx = (messageSid: string) =>
buildTestCtx({
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
ChatType: "group",
SessionKey: sessionKey,
MessageSid: messageSid,
To: "telegram:-1003774691295",
BodyForAgent: "@openclaw recover",
});
const firstTurn = dispatchReplyFromConfig({
ctx: buildRaceCtx("visible-race-first"),
cfg: automaticGroupReplyConfig,
dispatcher: firstDispatcher,
replyResolver: firstReplyResolver,
});
// First turn cleared the leftover run and now owns the in-flight recovery
// operation; capture it before the second turn races in.
await firstTurnEntered;
expect(staleOperation.result).toMatchObject({ kind: "failed", code: "run_failed" });
const recoveryOperation = replyRunRegistry.get(sessionKey);
expect(recoveryOperation).toBeDefined();
expect(recoveryOperation).not.toBe(staleOperation);
const secondTurn = dispatchReplyFromConfig({
ctx: buildRaceCtx("visible-race-second"),
cfg: automaticGroupReplyConfig,
dispatcher: secondDispatcher,
replyResolver: secondReplyResolver,
});
// Give the second turn time to run its admission/recovery path. With the
// bug it would force-fail the first turn's fresh recovery operation here.
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(recoveryOperation?.result).toBeNull();
expect(secondReplyResolver).not.toHaveBeenCalled();
releaseFirstTurn();
const firstResult = await firstTurn;
const secondResult = await secondTurn;
// The first recovery completed normally; the second turn was never allowed
// to kill it and got its own admission once the first finished.
expect(recoveryOperation?.result).toMatchObject({ kind: "completed" });
expect(firstReplyResolver).toHaveBeenCalledTimes(1);
expect(secondReplyResolver).toHaveBeenCalledTimes(1);
expect(firstResult).toMatchObject({ queuedFinal: true });
expect(secondResult).toMatchObject({ queuedFinal: true });
expect(firstDispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
expect(secondDispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
expect(replyRunRegistry.isActive(sessionKey)).toBe(false);
});
it("marks a clean no-stale terminal recovery so a racing visible turn cannot force-clear it", async () => {
setNoAbort();
const sessionKey = "agent:main:telegram:group:-1003774691297";
const sessionId = "failed-session-no-stale-race";
// No leftover op is pre-registered: the first visible turn reaches the clean
// admission path (nothing to force-clear). Both racing turns read the same
// terminal store snapshot below.
sessionStoreMocks.currentEntry = {
sessionId,
updatedAt: Date.now(),
status: "failed",
};
let releaseFirstTurn: () => void = () => {};
const firstResolverGate = new Promise<void>((release) => {
releaseFirstTurn = release;
});
let signalFirstResolverEntered: () => void = () => {};
const firstTurnEntered = new Promise<void>((resolve) => {
signalFirstResolverEntered = resolve;
});
const firstReplyResolver = vi.fn(async () => {
signalFirstResolverEntered();
await firstResolverGate;
return { text: "first recovery reply" } satisfies ReplyPayload;
});
const secondReplyResolver = vi.fn(
async () => ({ text: "second reply" }) satisfies ReplyPayload,
);
const firstDispatcher = createDispatcher();
const secondDispatcher = createDispatcher();
const buildRaceCtx = (messageSid: string) =>
buildTestCtx({
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
ChatType: "group",
SessionKey: sessionKey,
MessageSid: messageSid,
To: "telegram:-1003774691295",
BodyForAgent: "@openclaw recover",
});
const firstTurn = dispatchReplyFromConfig({
ctx: buildRaceCtx("visible-no-stale-first"),
cfg: automaticGroupReplyConfig,
dispatcher: firstDispatcher,
replyResolver: firstReplyResolver,
});
// First turn admitted cleanly and now owns the in-flight recovery operation;
// capture it before the second turn races in.
await firstTurnEntered;
const recoveryOperation = replyRunRegistry.get(sessionKey);
expect(recoveryOperation).toBeDefined();
// The marker must be set on the clean no-stale admission path too; without it
// the racing second visible turn would force-clear this op (#86827).
expect(recoveryOperation?.terminalRecovery).toBe(true);
const secondTurn = dispatchReplyFromConfig({
ctx: buildRaceCtx("visible-no-stale-second"),
cfg: automaticGroupReplyConfig,
dispatcher: secondDispatcher,
replyResolver: secondReplyResolver,
});
// Give the second turn time to run its admission/recovery path. With the
// bug it would force-fail the first turn's fresh recovery operation here.
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
expect(recoveryOperation?.result).toBeNull();
expect(secondReplyResolver).not.toHaveBeenCalled();
releaseFirstTurn();
const firstResult = await firstTurn;
const secondResult = await secondTurn;
// The first recovery completed normally; the second turn was never allowed
// to kill it and got its own admission once the first finished.
expect(recoveryOperation?.result).toMatchObject({ kind: "completed" });
expect(firstReplyResolver).toHaveBeenCalledTimes(1);
expect(secondReplyResolver).toHaveBeenCalledTimes(1);
expect(firstResult).toMatchObject({ queuedFinal: true });
expect(secondResult).toMatchObject({ queuedFinal: true });
expect(firstDispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
expect(secondDispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
expect(replyRunRegistry.isActive(sessionKey)).toBe(false);
});
it("does not force-clear an active recovery operation for a heartbeat turn on a terminal session", async () => {
setNoAbort();
const sessionKey = "agent:main:telegram:group:-1003774691296";
const sessionId = "failed-session-heartbeat";
sessionStoreMocks.currentEntry = {
sessionId,
updatedAt: Date.now(),
status: "failed",
};
const dispatcher = createDispatcher();
const replyResolver = vi.fn(
async () => ({ text: "heartbeat should not run" }) satisfies ReplyPayload,
);
// A concurrent visible turn already cleared the failed leftover and admitted
// a fresh recovery operation. Register it inside the fast-abort seam, which
// runs after the early heartbeat short-circuit but before admission, so the
// heartbeat reaches the terminal force-clear branch with this op active. The
// op is intentionally NOT marked `terminalRecovery`, so only the visible-turn
// guard can stop the heartbeat from force-failing it.
let recoveryOperation: ReturnType<typeof createReplyOperation> | undefined;
const result = await dispatchReplyFromConfig({
ctx: buildTestCtx({
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
ChatType: "group",
SessionKey: sessionKey,
MessageSid: "heartbeat-after-failure",
To: "telegram:-1003774691296",
BodyForAgent: "[OpenClaw heartbeat poll]",
}),
cfg: automaticGroupReplyConfig,
dispatcher,
replyOptions: { isHeartbeat: true },
fastAbortResolver: async () => {
recoveryOperation = createReplyOperation({
sessionKey,
sessionId,
resetTriggered: false,
});
recoveryOperation.setPhase("running");
return { handled: false, aborted: false };
},
formatAbortReplyTextResolver: () => "aborted",
replyResolver,
});
// The heartbeat left the active visible recovery operation untouched and
// skipped itself instead of force-clearing the in-flight visible turn.
expect(recoveryOperation).toBeDefined();
expect(recoveryOperation?.result).toBeNull();
expect(replyRunRegistry.get(sessionKey)).toBe(recoveryOperation);
expect(replyResolver).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
expect(result).toMatchObject({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
});
recoveryOperation?.complete();
});
it("does not force-clear an active operation whose session id differs from the terminal snapshot", async () => {
setNoAbort();
const sessionKey = "agent:main:telegram:group:-1003774691297";
// Terminal store snapshot still reports the failed lifecycle's session id.
sessionStoreMocks.currentEntry = {
sessionId: "failed-session-rotated",
updatedAt: Date.now(),
status: "failed",
};
const dispatcher = createDispatcher();
const replyResolver = vi.fn(
async () => ({ text: "visible recovery reply" }) satisfies ReplyPayload,
);
// A concurrent reset/rotation admitted a fresh op under the same session key
// but with a NEW session id, after this turn already captured the stale
// terminal snapshot. Register it inside the fast-abort seam, which runs after
// the early short-circuit but before admission, so the visible turn reaches
// the terminal force-clear branch with this op active. The op is NOT marked
// `terminalRecovery`, so only the session-id guard can stop the force-clear.
let freshOperation: ReturnType<typeof createReplyOperation> | undefined;
let signalFreshRegistered: () => void = () => {};
const freshRegistered = new Promise<void>((resolve) => {
signalFreshRegistered = resolve;
});
const turn = dispatchReplyFromConfig({
ctx: buildTestCtx({
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
ChatType: "group",
SessionKey: sessionKey,
MessageSid: "visible-after-rotation",
To: "telegram:-1003774691297",
BodyForAgent: "@openclaw recover",
}),
cfg: automaticGroupReplyConfig,
dispatcher,
fastAbortResolver: async () => {
freshOperation = createReplyOperation({
sessionKey,
sessionId: "fresh-rotated-session",
resetTriggered: false,
});
freshOperation.setPhase("running");
signalFreshRegistered();
return { handled: false, aborted: false };
},
formatAbortReplyTextResolver: () => "aborted",
replyResolver,
});
// Let the visible turn run its admission/force-clear path. With the bug it
// would force-fail the rotated op here, mistaking a valid in-flight reply for
// the stale terminal leftover and recreating the message loss (#86827).
await freshRegistered;
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
// The session-id guard kept the rotated op untouched: it still owns the
// session key and was never force-failed, so the visible turn simply parks
// behind it instead of dropping it.
expect(freshOperation).toBeDefined();
expect(freshOperation?.result).toBeNull();
expect(replyRunRegistry.get(sessionKey)).toBe(freshOperation);
expect(replyResolver).not.toHaveBeenCalled();
// Releasing the rotated op lets the parked visible turn admit and deliver,
// proving the message survived the rotation rather than being silently lost.
freshOperation?.complete();
const result = await turn;
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({ queuedFinal: true });
expect(replyRunRegistry.isActive(sessionKey)).toBe(false);
});
it("routes when OriginatingChannel differs from Provider", async () => {
setNoAbort();
mocks.routeReply.mockClear();
@@ -206,6 +206,10 @@ function isDispatchReplyOperationAbortedError(
return error instanceof DispatchReplyOperationAbortedError;
}
function isRecoverableTerminalSessionStatus(status: SessionEntry["status"] | undefined): boolean {
return status === "failed" || status === "timeout" || status === "killed";
}
function composeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
const activeSignals: AbortSignal[] = [];
for (const signal of signals) {
@@ -1421,6 +1425,48 @@ export async function dispatchReplyFromConfig(
});
}
}
if (
admission.status === "skipped" &&
admission.reason === "active-run" &&
// Only visible reply turns may force-clear a stale terminal operation.
// A heartbeat/control turn can also see the terminal snapshot, but it must
// not abort an in-flight visible recovery a concurrent visible turn just
// admitted (before that op is marked `terminalRecovery`); let it fall
// through to normal busy/skip handling instead.
replyTurnKind === "visible" &&
isRecoverableTerminalSessionStatus(sessionStoreEntry.entry?.status) &&
// Only clear the leftover op that belongs to the SAME terminal session.
// A concurrent reset/rotation can admit a fresh op (new sessionId) under
// this session key while we still hold the stale terminal snapshot;
// force-clearing by the active op's id would drop that valid in-flight
// reply and recreate the message loss this fix exists to prevent (#86827).
admission.activeOperation?.sessionId === sessionStoreEntry.entry?.sessionId &&
// Only clear the proven stale leftover from the failed lifecycle. A
// freshly-admitted visible recovery op is marked `terminalRecovery` at the
// admission choke point below; force-failing that op would drop the very
// recovery turn this path exists to protect (concurrent visible turns can
// read the same terminal snapshot before it clears).
!admission.activeOperation?.terminalRecovery
) {
const cleared = forceClearReplyRunBySessionId(
admission.activeOperation?.sessionId ?? operationSessionId,
new Error("clearing stale terminal reply operation"),
);
if (cleared) {
logVerbose(
`dispatch-from-config: cleared stale active reply operation for terminal session ${dispatchOperationSessionKey}`,
);
admission = await admitReplyTurn({
sessionKey: dispatchOperationSessionKey,
sessionId: operationSessionId,
kind: replyTurnKind,
resetTriggered: false,
routeThreadId,
upstreamAbortSignal: params.replyOptions?.abortSignal,
waitForActive: !allowActivePreDispatch && !allowSlackRoutedThreadBypass,
});
}
}
if (admission.status === "skipped") {
if (allowActivePreDispatch && admission.reason === "active-run") {
preDispatchAbortOperation = admission.activeOperation;
@@ -1445,6 +1491,20 @@ export async function dispatchReplyFromConfig(
);
return { status: "busy" };
}
// Mark every freshly-admitted visible recovery of a terminal session at this
// single choke point (both the clean no-stale admission and the
// re-admission after a sibling force-clear flow through here). The marker
// protects this op from being force-cleared by a concurrent sibling visible
// turn that reads the same terminal snapshot (#86827). Genuine stale
// leftovers from the original failed run never pass through this admission,
// so they stay unmarked and remain force-clearable.
if (
replyTurnKind === "visible" &&
isRecoverableTerminalSessionStatus(sessionStoreEntry.entry?.status) &&
operationSessionId === sessionStoreEntry.entry?.sessionId
) {
admission.operation.markTerminalRecovery();
}
dispatchReplyOperation = admission.operation;
dispatchReplyOperation.retainFailureUntilComplete();
dispatchAbortOperation = admission.operation;
@@ -56,9 +56,18 @@ export type ReplyOperation = {
readonly routeThreadId?: string | number;
readonly abortSignal: AbortSignal;
readonly resetTriggered: boolean;
/**
* True when this operation was admitted to recover a terminal session (a
* leftover failed/timeout/killed run). Concurrent visible turns reading the
* same terminal store snapshot must NOT force-clear such an operation: it is a
* sibling recovery already in flight, not the proven stale leftover.
*/
readonly terminalRecovery: boolean;
readonly phase: ReplyOperationPhase;
readonly result: ReplyOperationResult | null;
setPhase(next: "queued" | "preflight_compacting" | "memory_flushing" | "running"): void;
/** Mark this operation as an in-flight terminal-session recovery. */
markTerminalRecovery(): void;
updateSessionId(nextSessionId: string): void;
attachBackend(handle: ReplyBackendHandle): void;
detachBackend(handle: ReplyBackendHandle): void;
@@ -391,6 +400,7 @@ export function createReplyOperation(params: {
let result: ReplyOperationResult | null = null;
let stateCleared = false;
let retainFailureUntilComplete = false;
let terminalRecovery = false;
const clearState = (
afterClearBarrier?: PromiseLike<unknown>,
@@ -472,6 +482,9 @@ export function createReplyOperation(params: {
get resetTriggered() {
return params.resetTriggered;
},
get terminalRecovery() {
return terminalRecovery;
},
get phase() {
return phase;
},
@@ -484,6 +497,9 @@ export function createReplyOperation(params: {
}
phase = next;
},
markTerminalRecovery() {
terminalRecovery = true;
},
updateSessionId(nextSessionId) {
if (result) {
return;
+59 -1
View File
@@ -11,6 +11,7 @@ import * as bootstrapCache from "../../agents/bootstrap-cache.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js";
import { readSessionStoreForTest } from "../../config/sessions/test-helpers.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
import {
testing as sessionBindingTesting,
@@ -2157,13 +2158,14 @@ describe("initSessionState reset policy", () => {
expectNewSession: true,
},
{
name: "failed main terminal rows reuse when the transcript exists",
name: "failed main terminal rows recover on visible turns when the transcript exists",
sessionKey: "agent:main:main",
status: "failed" as const,
updatedAtOffsetMs: -10_000,
endedAtOffsetMs: -11_000,
transcriptMtimeOffsetMs: 0,
expectNewSession: false,
expectRecovered: true,
},
{
name: "main terminal rows reuse when updatedAt already reflects the transcript",
@@ -2228,6 +2230,14 @@ describe("initSessionState reset policy", () => {
expect(entry?.startedAt).toBeUndefined();
expect(entry?.endedAt).toBeUndefined();
expect(entry?.runtimeMs).toBeUndefined();
} else if (scenario.expectRecovered) {
// Visible turns recover recoverable terminal rows in place: the session id
// is reused, but the terminal lifecycle fields are cleared (#86827).
expect(result.sessionId).toBe(existingSessionId);
expect(entry?.status).toBeUndefined();
expect(entry?.startedAt).toBeUndefined();
expect(entry?.endedAt).toBeUndefined();
expect(entry?.runtimeMs).toBeUndefined();
} else {
expect(result.sessionId).toBe(existingSessionId);
expect(entry?.status).toBe(scenario.status ?? "done");
@@ -2235,6 +2245,54 @@ describe("initSessionState reset policy", () => {
}
});
it("recovers failed group sessions without rotating the transcript", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
const root = await makeCaseDir("openclaw-reset-failed-entry-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:telegram:group:-1001";
const existingSessionId = "failed-entry-old";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: Date.now(),
startedAt: Date.now() - 10_000,
endedAt: Date.now() - 1_000,
runtimeMs: 9_000,
status: "failed",
abortedLastRun: true,
chatType: "group",
},
});
const cfg = { session: { store: storePath } } as OpenClawConfig;
const result = await initSessionState({
ctx: {
Body: "@openclaw hello",
RawBody: "@openclaw hello",
CommandBody: "@openclaw hello",
SessionKey: sessionKey,
ChatType: "group",
Provider: "telegram",
BotUsername: "openclaw",
},
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionId).toBe(existingSessionId);
expect(result.abortedLastRun).toBe(false);
expect(result.sessionEntry.abortedLastRun).toBeUndefined();
const persisted = readSessionStoreForTest(storePath);
expect(persisted[sessionKey]?.sessionId).toBe(existingSessionId);
expect(persisted[sessionKey]?.status).toBeUndefined();
expect(persisted[sessionKey]?.startedAt).toBeUndefined();
expect(persisted[sessionKey]?.endedAt).toBeUndefined();
expect(persisted[sessionKey]?.runtimeMs).toBeUndefined();
expect(persisted[sessionKey]?.abortedLastRun).toBeUndefined();
});
it("keeps the existing stale session for /reset soft", async () => {
vi.setSystemTime(new Date(2026, 0, 18, 5, 30, 0));
const root = await makeCaseDir("openclaw-reset-soft-stale-");
+47 -19
View File
@@ -160,6 +160,21 @@ function hasProviderOwnedSession(entry: SessionEntry | undefined): boolean {
return Boolean(provider && getCliSessionBinding(entry, provider));
}
function isRecoverableTerminalSessionStatus(status: SessionEntry["status"] | undefined): boolean {
return status === "failed" || status === "timeout" || status === "killed";
}
function recoverTerminalSessionEntryForVisibleTurn(entry: SessionEntry): SessionEntry {
return {
...entry,
status: undefined,
startedAt: undefined,
endedAt: undefined,
runtimeMs: undefined,
abortedLastRun: undefined,
};
}
export type SessionInitResult = {
sessionCtx: TemplateContext;
sessionEntry: SessionEntry;
@@ -538,9 +553,16 @@ async function initSessionStateAttemptLocked(
mainKey,
storePath,
}));
const recoverTerminalVisibleEntry =
canReuseExistingEntry &&
!isSystemEvent &&
!resetTriggered &&
(entryFreshness?.fresh ?? false) &&
isRecoverableTerminalSessionStatus(entry?.status);
const freshEntry =
(isSystemEvent && canReuseExistingEntry) ||
(((reconnectResumeRequested && canReuseExistingEntry) ||
recoverTerminalVisibleEntry ||
(entryFreshness?.fresh ?? false) ||
(softResetAllowed && canReuseExistingEntry)) &&
!terminalMainTranscriptNewerThanRegistry);
@@ -577,23 +599,29 @@ async function initSessionStateAttemptLocked(
clearSessionResetRuntimeState([sessionKey, previousSessionEntry.sessionId]);
}
if (!isNewSession && effectiveFreshEntry && canReuseExistingEntry) {
sessionId = entry.sessionId;
systemSent = entry.systemSent ?? false;
abortedLastRun = entry.abortedLastRun ?? false;
persistedThinking = entry.thinkingLevel;
persistedVerbose = entry.verboseLevel;
persistedTrace = entry.traceLevel;
persistedReasoning = entry.reasoningLevel;
persistedTtsAuto = entry.ttsAuto;
persistedResponseUsage = entry.responseUsage;
persistedModelOverride = entry.modelOverride;
persistedProviderOverride = entry.providerOverride;
persistedModelOverrideSource = entry.modelOverrideSource;
persistedAuthProfileOverride = entry.authProfileOverride;
persistedAuthProfileOverrideSource = entry.authProfileOverrideSource;
persistedAuthProfileOverrideCompactionCount = entry.authProfileOverrideCompactionCount;
persistedLabel = entry.label;
const recoveredTerminalEntry =
entry && recoverTerminalVisibleEntry
? recoverTerminalSessionEntryForVisibleTurn(entry)
: undefined;
const reusableEntry = recoveredTerminalEntry ?? entry;
if (!isNewSession && effectiveFreshEntry && canReuseExistingEntry && reusableEntry) {
sessionId = reusableEntry.sessionId;
systemSent = reusableEntry.systemSent ?? false;
abortedLastRun = reusableEntry.abortedLastRun ?? false;
persistedThinking = reusableEntry.thinkingLevel;
persistedVerbose = reusableEntry.verboseLevel;
persistedTrace = reusableEntry.traceLevel;
persistedReasoning = reusableEntry.reasoningLevel;
persistedTtsAuto = reusableEntry.ttsAuto;
persistedResponseUsage = reusableEntry.responseUsage;
persistedModelOverride = reusableEntry.modelOverride;
persistedProviderOverride = reusableEntry.providerOverride;
persistedModelOverrideSource = reusableEntry.modelOverrideSource;
persistedAuthProfileOverride = reusableEntry.authProfileOverride;
persistedAuthProfileOverrideSource = reusableEntry.authProfileOverrideSource;
persistedAuthProfileOverrideCompactionCount = reusableEntry.authProfileOverrideCompactionCount;
persistedLabel = reusableEntry.label;
} else {
sessionId = crypto.randomUUID();
isNewSession = true;
@@ -648,7 +676,7 @@ async function initSessionStateAttemptLocked(
}
}
const baseEntry = !isNewSession && effectiveFreshEntry ? entry : undefined;
const baseEntry = !isNewSession && effectiveFreshEntry ? reusableEntry : undefined;
const usageFamilyKey = previousSessionEntry
? (previousSessionEntry.usageFamilyKey ?? sessionKey)
: baseEntry?.usageFamilyKey;
@@ -738,7 +766,7 @@ async function initSessionStateAttemptLocked(
: (baseEntry?.sessionStartedAt ?? lifecycleTimestamps.sessionStartedAt),
lastInteractionAt: isSystemEvent ? baseEntry?.lastInteractionAt : now,
systemSent,
abortedLastRun,
abortedLastRun: recoveredTerminalEntry ? undefined : abortedLastRun,
// Persist previously stored thinking/verbose levels when present.
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
+102
View File
@@ -804,6 +804,108 @@ describe("channel turn kernel", () => {
expect(logRecord?.trace?.traceId).toBe(traceId);
});
it("logs a warning when a visible prepared dispatch queues no payloads", async () => {
const events: string[] = [];
const log = vi.fn();
const recordInboundSession = createRecordInboundSession(events);
const runDispatch = vi.fn(async () => ({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
}));
const result = await runPreparedChannelTurn({
channel: "test",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession,
runDispatch,
log,
messageId: "msg-zero",
record: {
onRecordError: vi.fn(),
},
});
expect(result.dispatchResult?.queuedFinal).toBe(false);
expect(log.mock.calls).toContainEqual([
expect.objectContaining({
stage: "dispatch",
event: "warning",
messageId: "msg-zero",
reason: "zero-count-visible-dispatch",
}),
]);
});
it("does not warn for observed-path deliveries with zero queued counts", async () => {
const events: string[] = [];
const log = vi.fn();
const recordInboundSession = createRecordInboundSession(events);
// Observed-delivery path: queuedFinal false and all counts zero, but the reply was
// delivered via observedReplyDelivery and must not trip the silent-drop sentinel.
const runDispatch = vi.fn(async () => ({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
observedReplyDelivery: true,
}));
const result = await runPreparedChannelTurn({
channel: "test",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession,
runDispatch,
log,
messageId: "msg-observed",
record: {
onRecordError: vi.fn(),
},
});
expect(result.dispatchResult?.observedReplyDelivery).toBe(true);
expect(log.mock.calls).not.toContainEqual([
expect.objectContaining({ reason: "zero-count-visible-dispatch" }),
]);
});
it("still warns when a visible turn has zero counts and no observed delivery", async () => {
const events: string[] = [];
const log = vi.fn();
const recordInboundSession = createRecordInboundSession(events);
// Guard against over-suppression: a genuinely empty visible dispatch must still warn.
const runDispatch = vi.fn(async () => ({
queuedFinal: false,
counts: { tool: 0, block: 0, final: 0 },
observedReplyDelivery: false,
}));
const result = await runPreparedChannelTurn({
channel: "test",
routeSessionKey: "agent:main:test:peer",
storePath: "/tmp/sessions.json",
ctxPayload: createCtx(),
recordInboundSession,
runDispatch,
log,
messageId: "msg-empty",
record: {
onRecordError: vi.fn(),
},
});
expect(result.dispatchResult?.observedReplyDelivery).toBe(false);
expect(log.mock.calls).toContainEqual([
expect.objectContaining({
stage: "dispatch",
event: "warning",
messageId: "msg-empty",
reason: "zero-count-visible-dispatch",
}),
]);
});
it("drops direct prepared turns with bot-loop protection before record and dispatch", async () => {
const events: string[] = [];
const log = vi.fn();
+57 -1
View File
@@ -4,15 +4,21 @@ import {
clearHistoryEntriesIfEnabled,
recordPendingHistoryEntryWithMedia,
} from "../../auto-reply/reply/history.js";
import type { FinalizedMsgContext } from "../../auto-reply/templating.js";
import {
createDiagnosticTraceContextFromActiveScope,
runWithDiagnosticTraceContext,
} from "../../infra/diagnostic-trace-context.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { toHistoryMediaEntries } from "../inbound-event/media.js";
import { createChannelReplyPipeline } from "../message/reply-pipeline.js";
import type { CreateChannelReplyPipelineParams } from "../message/reply-pipeline.js";
import { recordChannelBotPairLoopAndCheckSuppression } from "./bot-loop-protection.js";
import { EMPTY_CHANNEL_TURN_DISPATCH_COUNTS } from "./dispatch-result.js";
import {
EMPTY_CHANNEL_TURN_DISPATCH_COUNTS,
hasVisibleChannelTurnDispatch,
type ChannelTurnDispatchResultLike,
} from "./dispatch-result.js";
import {
deliverInboundReplyWithMessageSendContext,
isDurableInboundReplyDeliveryHandled,
@@ -100,6 +106,7 @@ const DEFAULT_EVENT_CLASS: ChannelEventClass = {
kind: "message",
canStartAgentTurn: true,
};
const log = createSubsystemLogger("channels/turn/kernel");
/**
* @deprecated Compatibility assembly for legacy buffered reply dispatchers.
@@ -268,6 +275,50 @@ function resolveObserveOnlyDispatchResult<TDispatchResult>(
}) as TDispatchResult;
}
function isSystemChannelTurn(ctx: FinalizedMsgContext): boolean {
return (
ctx.Provider === "heartbeat" || ctx.Provider === "cron-event" || ctx.Provider === "exec-event"
);
}
function maybeWarnZeroCountVisibleDispatch<TDispatchResult>(
params: Pick<
PreparedChannelTurn<TDispatchResult>,
"admission" | "channel" | "ctxPayload" | "messageId" | "routeSessionKey"
> & {
dispatchResult: TDispatchResult;
log?: (event: ChannelTurnLogEvent) => void;
},
): void {
if (params.admission?.kind === "observeOnly" || isSystemChannelTurn(params.ctxPayload)) {
return;
}
const dispatchResult = params.dispatchResult as ChannelTurnDispatchResultLike;
// Suppress the silent-drop warning using the canonical visible-delivery signal, which
// includes observedReplyDelivery and other non-count delivery paths. A partial count-only
// check would falsely flag observed-path deliveries (queuedFinal=false, zero counts) as drops.
if (hasVisibleChannelTurnDispatch(dispatchResult)) {
return;
}
log.warn(
`visible channel turn dispatched with no queued reply payloads: channel=${params.channel} ` +
`messageId=${params.messageId ?? "unknown"} sessionKey=${
params.ctxPayload.SessionKey ?? params.routeSessionKey
}`,
);
emit({
...params,
event: {
stage: "dispatch",
event: "warning",
messageId: params.messageId,
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
admission: params.admission?.kind ?? "dispatch",
reason: "zero-count-visible-dispatch",
},
});
}
function isExplicitlyNonVisibleChannelDelivery(result: unknown): boolean {
return (
typeof result === "object" &&
@@ -549,6 +600,11 @@ async function runPreparedChannelTurnCoreInTrace<
options.suppressObserveOnlyDispatch && admission.kind === "observeOnly"
? resolveObserveOnlyDispatchResult(params)
: await params.runDispatch();
maybeWarnZeroCountVisibleDispatch({
...params,
admission,
dispatchResult,
});
} catch (err) {
emit({
...params,
+1 -1
View File
@@ -416,7 +416,7 @@ export type ChannelTurnStage =
/** Structured channel turn log event. */
export type ChannelTurnLogEvent = {
stage: ChannelTurnStage;
event: "start" | "done" | "drop" | "handled" | "error";
event: "start" | "done" | "drop" | "handled" | "error" | "warning";
channel: string;
accountId?: string;
messageId?: string;
+57 -4
View File
@@ -1085,7 +1085,7 @@ describe("gateway agent handler", () => {
expect(capturedEntry?.sessionFile).toBeUndefined();
});
it("keeps a failed session reusable when its default transcript exists", async () => {
it("recovers a failed session when its default transcript exists", async () => {
const now = Date.parse("2026-05-18T09:49:00.000Z");
vi.useFakeTimers({ toFake: ["Date"] });
dateOnlyFakeClockActive = true;
@@ -1098,6 +1098,10 @@ describe("gateway agent handler", () => {
const failedEntryWithDefaultTranscript = {
sessionId: "failed-present-default-session-id",
status: "failed",
startedAt: now - 1_000,
endedAt: now,
runtimeMs: 1_000,
abortedLastRun: true,
updatedAt: now,
sessionStartedAt: now,
lastInteractionAt: now,
@@ -1116,12 +1120,16 @@ describe("gateway agent handler", () => {
const call = await waitForAgentCommandCall<{ sessionId?: string }>();
expect(call.sessionId).toBe("failed-present-default-session-id");
expect(capturedEntry?.sessionId).toBe("failed-present-default-session-id");
expect(capturedEntry?.status).toBe("failed");
expect(capturedEntry?.status).toBeUndefined();
expect(capturedEntry?.startedAt).toBeUndefined();
expect(capturedEntry?.endedAt).toBeUndefined();
expect(capturedEntry?.runtimeMs).toBeUndefined();
expect(capturedEntry?.abortedLastRun).toBeUndefined();
expect(capturedEntry?.sessionFile).toBeUndefined();
});
});
it("keeps a failed session reusable when its relative transcript resolves and exists", async () => {
it("recovers a failed session when its relative transcript resolves and exists", async () => {
const now = Date.parse("2026-05-18T09:50:00.000Z");
vi.useFakeTimers({ toFake: ["Date"] });
dateOnlyFakeClockActive = true;
@@ -1135,6 +1143,10 @@ describe("gateway agent handler", () => {
sessionId: "failed-present-session-id",
sessionFile: "relative-present.jsonl",
status: "failed",
startedAt: now - 1_000,
endedAt: now,
runtimeMs: 1_000,
abortedLastRun: true,
updatedAt: now,
sessionStartedAt: now,
lastInteractionAt: now,
@@ -1153,7 +1165,11 @@ describe("gateway agent handler", () => {
const call = await waitForAgentCommandCall<{ sessionId?: string }>();
expect(call.sessionId).toBe("failed-present-session-id");
expect(capturedEntry?.sessionId).toBe("failed-present-session-id");
expect(capturedEntry?.status).toBe("failed");
expect(capturedEntry?.status).toBeUndefined();
expect(capturedEntry?.startedAt).toBeUndefined();
expect(capturedEntry?.endedAt).toBeUndefined();
expect(capturedEntry?.runtimeMs).toBeUndefined();
expect(capturedEntry?.abortedLastRun).toBeUndefined();
expect(capturedEntry?.sessionFile).toBe("relative-present.jsonl");
});
});
@@ -1727,6 +1743,43 @@ describe("gateway agent handler", () => {
});
expect(mocks.agentCommand).not.toHaveBeenCalled();
});
it("recovers terminal failed agent API sessions without rotating the session id", async () => {
const sessionId = "failed-agent-session";
await withTempDir({ prefix: "openclaw-gateway-terminal-recovery-" }, async (root) => {
const sessionsDir = `${root}/sessions`;
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(`${sessionsDir}/${sessionId}.jsonl`, "", "utf8");
mocks.loadSessionEntry.mockReturnValue({
cfg: {},
storePath: `${sessionsDir}/sessions.json`,
entry: {
sessionId,
status: "failed",
startedAt: 100,
endedAt: 200,
runtimeMs: 100,
abortedLastRun: true,
updatedAt: Date.now(),
},
canonicalKey: "agent:main:main",
});
const capturedEntry = await runMainAgentAndCaptureEntry("recover-terminal-agent-session");
const call = await waitForAgentCommandCall();
expect(call.sessionId).toBe(sessionId);
expectRecordFields(capturedEntry, {
sessionId,
status: undefined,
startedAt: undefined,
endedAt: undefined,
runtimeMs: undefined,
abortedLastRun: undefined,
});
});
});
it("does not restore a stale session id over a fresh store rotation (#5369)", async () => {
mocks.resolveSessionLifecycleTimestamps.mockImplementation(
({ entry }: { entry?: { sessionId?: string; sessionStartedAt?: number } }) => ({
+19 -3
View File
@@ -174,6 +174,10 @@ import type {
const RESET_COMMAND_RE = /^\/(new|reset)(?:\s+([\s\S]*))?$/i;
function isRecoverableTerminalSessionStatus(status: SessionEntry["status"] | undefined): boolean {
return status === "failed" || status === "timeout" || status === "killed";
}
type AgentSendSessionLifecycleTransition = {
cfg: OpenClawConfig;
sessionKey: string;
@@ -1767,6 +1771,10 @@ export const agentHandlers: GatewayRequestHandlers = {
policy: resetPolicy,
})
: undefined;
const visibleRequest =
request.bootstrapContextRunKind !== "cron" &&
request.bootstrapContextRunKind !== "heartbeat" &&
!request.internalEvents?.length;
const resolveFailedSessionTranscriptMissingForEntry = (
candidateEntry: SessionEntry | undefined,
) => {
@@ -1834,7 +1842,7 @@ export const agentHandlers: GatewayRequestHandlers = {
(!canReuseSession && !usableRequestedSessionId) ||
Boolean(usableRequestedSessionId && entry?.sessionId !== usableRequestedSessionId);
let rotatedSessionId = Boolean(entry?.sessionId && entry.sessionId !== sessionId);
const touchInteraction = !isSystemGatewayRun && !request.internalEvents?.length;
const touchInteraction = visibleRequest;
const sessionAgent = canonicalSessionAgentId;
type AgentSessionPatchBuild = {
patch: Partial<SessionEntry>;
@@ -1996,6 +2004,14 @@ export const agentHandlers: GatewayRequestHandlers = {
? freshEntry?.sessionId
: freshSessionId;
const shouldClearRotatedState = freshRotatedSessionId && !freshSessionRotatedSinceLoad;
const freshRecoverTerminalSession =
freshCanReuseSession &&
visibleRequest &&
isRecoverableTerminalSessionStatus(freshEntry?.status);
const shouldClearTerminalState =
freshRecoverTerminalSession &&
!freshSessionRotatedSinceLoad &&
patchSessionId === freshEntry?.sessionId;
const patch: Partial<SessionEntry> = {
sessionId: patchSessionId,
updatedAt: now,
@@ -2024,14 +2040,14 @@ export const agentHandlers: GatewayRequestHandlers = {
groupChannel: nextGroup.groupChannel,
space: nextGroup.groupSpace,
...(pluginOwnerId ? { pluginOwnerId } : {}),
...(shouldClearRotatedState
...(shouldClearRotatedState || shouldClearTerminalState
? {
status: undefined,
startedAt: undefined,
endedAt: undefined,
runtimeMs: undefined,
abortedLastRun: undefined,
sessionFile: undefined,
...(shouldClearRotatedState ? { sessionFile: undefined } : {}),
}
: {}),
};