Skip to content

Commit 6f76d9f

Browse files
openperfsteipete
andauthored
fix(diagnostics): reclaim wedged session lanes with a stale leaked active run (#86056)
* fix(diagnostics): reclaim wedged session lanes with a stale leaked active run A group session lane could wedge permanently (#85639): an embedded run that dies abnormally leaves a stale ACTIVE_EMBEDDED_RUNS handle, so the diagnostic heartbeat classifies the lane stale_session_state (recoveryEligible without allowActiveAbort) while stuck-session recovery reads the leaked isEmbeddedPiRunActive flag and skips with active_reply_work — a tautology that keeps the lane forever. The age-based escape never fires because ageMs (last-activity) resets on every incoming queued message. Make the active-run skip a liveness check: before keeping the lane, consult the run's real forward-progress age (lastProgressAgeMs, not refreshed by incoming messages). If a run flagged active has made no forward progress past the resolved diagnostics.stuckSessionAbortMs threshold (threaded through the recovery request; falls back to a 5-minute floor) with queued work waiting, treat it as a leaked/dead handle and reclaim it (abort + drain + force-clear) instead of skipping. A genuinely progressing run, or one within an operator-raised threshold, is kept. Fixes #85639 * test(diagnostics): cover stale active run recovery --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent e761eb8 commit 6f76d9f

5 files changed

Lines changed: 207 additions & 2 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
4141
- Tests: give the memory fallback QA scenario enough turn budget to exercise native Windows gateway runs instead of failing on the client timeout while the mock agent is still dispatching.
4242
- Tests: collect QA gateway CPU/RSS metrics on native Windows and give the channel baseline enough turn budget to report slow gateway runs instead of timing out before proof.
4343
- Install/update: bypass npm `min-release-age` policies with `--min-release-age=0` instead of `--before` so hosted installers keep working on npm versions that reject the combined config. (#84749) Thanks @TeodoroRodrigo.
44+
- Diagnostics: reclaim wedged session lanes when stale active-run bookkeeping blocks queued work despite no forward progress. Fixes #85639. Thanks @openperf.
4445
- WebChat: keep message-tool replies visible in the chat while still summarizing internal tool results for the model. Fixes #86347. Thanks @shakkernerd.
4546
- Gateway/perf: fail startup benchmark samples when the Gateway process exits before benchmark teardown, including signal deaths after readiness probes.
4647
- Gateway/perf: fail restart benchmark samples when the Gateway exits before benchmark teardown, including clean exits and signal deaths after successful restart probes.

src/logging/diagnostic-session-recovery.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export type StuckSessionRecoveryRequest = {
2828
allowActiveAbort?: boolean;
2929
expectedState?: DiagnosticSessionState;
3030
stateGeneration?: number;
31+
/**
32+
* Resolved no-forward-progress age (from `diagnostics.stuckSessionAbortMs`) after
33+
* which an "active" run with queued work is treated as a leaked/dead handle and
34+
* reclaimed. Honors an operator-raised threshold; falls back to a safe floor.
35+
*/
36+
staleActiveProgressAbortMs?: number;
3137
};
3238

3339
type DiagnosticSessionRecoveryBaseOutcome = {

src/logging/diagnostic-stuck-session-recovery.runtime.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
1414
resolveActiveEmbeddedRunHandleSessionId: vi.fn(),
1515
resolveEmbeddedSessionLane: vi.fn((key: string) => `session:${key}`),
1616
waitForEmbeddedPiRunEnd: vi.fn(),
17+
getDiagnosticSessionActivitySnapshot: vi.fn(),
1718
diag: {
1819
debug: vi.fn(),
1920
warn: vi.fn(),
@@ -60,6 +61,10 @@ vi.mock("./diagnostic-runtime.js", () => ({
6061
diagnosticLogger: mocks.diag,
6162
}));
6263

64+
vi.mock("./diagnostic-run-activity.js", () => ({
65+
getDiagnosticSessionActivitySnapshot: mocks.getDiagnosticSessionActivitySnapshot,
66+
}));
67+
6368
import {
6469
testing,
6570
recoverStuckDiagnosticSession,
@@ -85,6 +90,10 @@ function resetMocks() {
8590
mocks.resolveActiveEmbeddedRunHandleSessionId.mockReset();
8691
mocks.resolveEmbeddedSessionLane.mockClear();
8792
mocks.waitForEmbeddedPiRunEnd.mockReset();
93+
mocks.getDiagnosticSessionActivitySnapshot.mockReset();
94+
// Default: no progress signal, so the staleness gate stays off unless a test
95+
// opts in by returning a stale lastProgressAgeMs.
96+
mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({});
8897
mocks.diag.debug.mockReset();
8998
mocks.diag.warn.mockReset();
9099
}
@@ -121,6 +130,26 @@ describe("stuck session recovery", () => {
121130
]);
122131
});
123132

133+
it("reclaims a stale active embedded run with queued work and no forward progress (#85639)", async () => {
134+
mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue("session-1");
135+
mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({
136+
lastProgressAgeMs: 10 * 60_000,
137+
});
138+
mocks.abortEmbeddedPiRun.mockReturnValue(true);
139+
mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true);
140+
141+
const outcome = await recoverStuckDiagnosticSession({
142+
sessionId: "session-1",
143+
sessionKey: "agent:main:main",
144+
ageMs: 180_000,
145+
queueDepth: 1,
146+
});
147+
148+
expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("session-1");
149+
expect(outcome.status).toBe("aborted");
150+
expect(warnLogMessages().some((m) => m.includes("reclaiming stale active run"))).toBe(true);
151+
});
152+
124153
it("aborts an active embedded run when active abort recovery is enabled", async () => {
125154
mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue("session-1");
126155
mocks.abortEmbeddedPiRun.mockReturnValue(true);
@@ -292,6 +321,107 @@ describe("stuck session recovery", () => {
292321
]);
293322
});
294323

324+
it("reclaims stale leaked reply work with queued work and no forward progress (#85639)", async () => {
325+
mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session");
326+
mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined);
327+
mocks.isEmbeddedPiRunActive.mockReturnValue(true);
328+
mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false);
329+
// The "active" run has made no forward progress for well past the staleness
330+
// window — a leaked/dead handle, not genuine work.
331+
mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 10 * 60_000 });
332+
mocks.abortEmbeddedPiRun.mockReturnValue(true);
333+
mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true);
334+
335+
const outcome = await recoverStuckDiagnosticSession({
336+
sessionId: "queued-reply-session",
337+
sessionKey: "agent:main:main",
338+
ageMs: 180_000,
339+
queueDepth: 1,
340+
});
341+
342+
// Reclaimed (aborted) instead of skipping with active_reply_work.
343+
expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("queued-reply-session");
344+
expect(outcome.status).not.toBe("skipped");
345+
expect(warnLogMessages().some((m) => m.includes("reclaiming stale active reply work"))).toBe(
346+
true,
347+
);
348+
});
349+
350+
it("honors an operator-raised stuck-session abort threshold for stale reclaim (#85639)", async () => {
351+
mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session");
352+
mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined);
353+
mocks.isEmbeddedPiRunActive.mockReturnValue(true);
354+
mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false);
355+
mocks.abortEmbeddedPiRun.mockReturnValue(true);
356+
mocks.waitForEmbeddedPiRunEnd.mockResolvedValue(true);
357+
358+
// Operator raised the abort threshold to 20 min to protect slow active work.
359+
const raisedAbortMs = 20 * 60_000;
360+
361+
// Below the raised threshold (10 min): keep the lane, do not reclaim.
362+
mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 10 * 60_000 });
363+
const kept = await recoverStuckDiagnosticSession({
364+
sessionId: "queued-reply-session",
365+
sessionKey: "agent:main:main",
366+
ageMs: 180_000,
367+
queueDepth: 1,
368+
staleActiveProgressAbortMs: raisedAbortMs,
369+
});
370+
expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled();
371+
expect(kept.status).toBe("skipped");
372+
373+
// Past the raised threshold (25 min): reclaim.
374+
mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 25 * 60_000 });
375+
const reclaimed = await recoverStuckDiagnosticSession({
376+
sessionId: "queued-reply-session",
377+
sessionKey: "agent:main:main",
378+
ageMs: 180_000,
379+
queueDepth: 1,
380+
staleActiveProgressAbortMs: raisedAbortMs,
381+
});
382+
expect(mocks.abortEmbeddedPiRun).toHaveBeenCalledWith("queued-reply-session");
383+
expect(reclaimed.status).not.toBe("skipped");
384+
});
385+
386+
it("keeps the lane when active reply work is still progressing", async () => {
387+
mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session");
388+
mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined);
389+
mocks.isEmbeddedPiRunActive.mockReturnValue(true);
390+
mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false);
391+
// Recent forward progress: a genuinely active run must not be reclaimed.
392+
mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 5_000 });
393+
394+
const outcome = await recoverStuckDiagnosticSession({
395+
sessionId: "queued-reply-session",
396+
sessionKey: "agent:main:main",
397+
ageMs: 180_000,
398+
queueDepth: 1,
399+
});
400+
401+
expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled();
402+
expect(outcome.status).toBe("skipped");
403+
expect(warnLogMessages().some((m) => m.includes("reason=active_reply_work"))).toBe(true);
404+
});
405+
406+
it("does not reclaim stale reply work when no work is queued", async () => {
407+
mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session");
408+
mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined);
409+
mocks.isEmbeddedPiRunActive.mockReturnValue(true);
410+
mocks.isEmbeddedPiRunHandleActive.mockReturnValue(false);
411+
mocks.getDiagnosticSessionActivitySnapshot.mockReturnValue({ lastProgressAgeMs: 10 * 60_000 });
412+
413+
const outcome = await recoverStuckDiagnosticSession({
414+
sessionId: "queued-reply-session",
415+
sessionKey: "agent:main:main",
416+
ageMs: 180_000,
417+
queueDepth: 0,
418+
});
419+
420+
expect(mocks.abortEmbeddedPiRun).not.toHaveBeenCalled();
421+
expect(outcome.status).toBe("skipped");
422+
expect(warnLogMessages().some((m) => m.includes("reason=active_reply_work"))).toBe(true);
423+
});
424+
295425
it("aborts stale reply work without an embedded handle when active abort recovery is enabled", async () => {
296426
mocks.resolveActiveEmbeddedRunSessionId.mockReturnValue("queued-reply-session");
297427
mocks.resolveActiveEmbeddedRunHandleSessionId.mockReturnValue(undefined);

src/logging/diagnostic-stuck-session-recovery.runtime.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
resolveActiveEmbeddedRunHandleSessionId,
88
} from "../agents/pi-embedded-runner/runs.js";
99
import { getCommandLaneSnapshot, resetCommandLane } from "../process/command-queue.js";
10+
import { getDiagnosticSessionActivitySnapshot } from "./diagnostic-run-activity.js";
1011
import { diagnosticLogger as diag } from "./diagnostic-runtime.js";
1112
import {
1213
formatStoppedCronSessionDiagnosticFields,
@@ -20,6 +21,42 @@ import {
2021
import { isDiagnosticSessionStateCurrent } from "./diagnostic-session-state.js";
2122

2223
const STUCK_SESSION_ABORT_SETTLE_MS = 15_000;
24+
// Default no-forward-progress age used only when the caller does not carry a
25+
// resolved `diagnostics.stuckSessionAbortMs`. A run flagged "active" that has made
26+
// no forward progress (tool/model/chunk events) for at least the resolved window,
27+
// while queued work waits, is treated as a leaked/dead handle and reclaimed even
28+
// without an explicit active-abort grant. `lastProgressAgeMs` tracks real progress
29+
// (not incoming queued messages), so it keeps growing while a lane is wedged.
30+
const STUCK_SESSION_PROGRESS_STALE_MS = 5 * 60_000;
31+
32+
function resolveStaleActiveProgressAbortMs(params: StuckSessionRecoveryParams): number {
33+
const configured = params.staleActiveProgressAbortMs;
34+
// Honor the resolved `diagnostics.stuckSessionAbortMs` as-is — an operator can
35+
// raise it to protect slow active work (it is the same threshold the existing
36+
// `session.stalled` abort uses). It is floored at the warn threshold upstream,
37+
// not necessarily 5 min, so we only apply the 5-min default when no value is
38+
// carried (e.g. direct callers).
39+
return typeof configured === "number" && configured > 0
40+
? configured
41+
: STUCK_SESSION_PROGRESS_STALE_MS;
42+
}
43+
44+
function isActiveRunProgressStale(params: {
45+
sessionId?: string;
46+
sessionKey?: string;
47+
queueDepth?: number;
48+
staleAbortMs: number;
49+
}): boolean {
50+
if ((params.queueDepth ?? 0) <= 0) {
51+
return false;
52+
}
53+
const activity = getDiagnosticSessionActivitySnapshot({
54+
sessionId: params.sessionId,
55+
sessionKey: params.sessionKey,
56+
});
57+
const lastProgressAgeMs = activity.lastProgressAgeMs;
58+
return typeof lastProgressAgeMs === "number" && lastProgressAgeMs >= params.staleAbortMs;
59+
}
2360
const recoveriesInFlight = new Set<string>();
2461

2562
export type StuckSessionRecoveryParams = StuckSessionRecoveryRequest;
@@ -100,9 +137,18 @@ export async function recoverStuckDiagnosticSession(
100137
let aborted = false;
101138
let drained = true;
102139
let forceCleared = false;
140+
const staleActiveProgressAbortMs = resolveStaleActiveProgressAbortMs(params);
103141

104142
if (activeSessionId) {
105-
if (params.allowActiveAbort !== true) {
143+
const reclaimStaleActiveRun =
144+
params.allowActiveAbort !== true &&
145+
isActiveRunProgressStale({
146+
sessionId: activeSessionId,
147+
sessionKey: params.sessionKey,
148+
queueDepth: params.queueDepth,
149+
staleAbortMs: staleActiveProgressAbortMs,
150+
});
151+
if (params.allowActiveAbort !== true && !reclaimStaleActiveRun) {
106152
const outcome: StuckSessionRecoveryOutcome = {
107153
status: "skipped",
108154
action: "observe_only",
@@ -118,6 +164,11 @@ export async function recoverStuckDiagnosticSession(
118164
diag.warn(`stuck session recovery outcome: ${formatRecoveryOutcome(outcome)}`);
119165
return outcome;
120166
}
167+
if (reclaimStaleActiveRun) {
168+
diag.warn(
169+
`stuck session recovery reclaiming stale active run: ${formatRecoveryContext(params, { activeSessionId })}`,
170+
);
171+
}
121172
const result = await abortAndDrainEmbeddedPiRun({
122173
sessionId: activeSessionId,
123174
sessionKey: params.sessionKey,
@@ -131,7 +182,23 @@ export async function recoverStuckDiagnosticSession(
131182
}
132183

133184
if (!activeSessionId && activeWorkSessionId && isEmbeddedPiRunActive(activeWorkSessionId)) {
134-
if (params.allowActiveAbort === true) {
185+
const reclaimStaleReplyWork =
186+
params.allowActiveAbort !== true &&
187+
isActiveRunProgressStale({
188+
sessionId: activeWorkSessionId,
189+
sessionKey: params.sessionKey,
190+
queueDepth: params.queueDepth,
191+
staleAbortMs: staleActiveProgressAbortMs,
192+
});
193+
if (params.allowActiveAbort === true || reclaimStaleReplyWork) {
194+
if (reclaimStaleReplyWork) {
195+
diag.warn(
196+
`stuck session recovery reclaiming stale active reply work: ${formatRecoveryContext(
197+
params,
198+
{ activeSessionId: activeWorkSessionId },
199+
)}`,
200+
);
201+
}
135202
const result = await abortAndDrainEmbeddedPiRun({
136203
sessionId: activeWorkSessionId,
137204
sessionKey: params.sessionKey,

src/logging/diagnostic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,7 @@ export function startDiagnosticHeartbeat(
12881288
queueDepth: state.queueDepth,
12891289
expectedState: state.state,
12901290
stateGeneration: state.generation,
1291+
staleActiveProgressAbortMs: stuckSessionAbortMs,
12911292
},
12921293
});
12931294
} else if (

0 commit comments

Comments
 (0)