fix(agents): preserve resumed turns after stale terminal events (#116777)

This commit is contained in:
Peter Steinberger
2026-07-31 03:57:16 -07:00
committed by GitHub
parent 66d5410171
commit 9f2d2781da
4 changed files with 80 additions and 5 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
# Distinct OPENCLAW_* names in production source under src, packages, and extensions.
# Ratchet: lower this number when cleanup removes names; never raise it.
518
517
@@ -113,6 +113,18 @@ export function projectMainSessionRecoveryLifecycle(params: {
)
: runs;
if (settlesRecovery) {
if (
matchesFence &&
lifecycleGeneration !== params.currentLifecycleGeneration &&
remaining?.some(
(run) =>
run.runId === runId && run.lifecycleGeneration === params.currentLifecycleGeneration,
)
) {
// Older generations share the live owner's run id. Consume only their
// fence; recording that id as terminal would also tombstone its replacement.
return { action: "apply", patch: { restartRecoveryRuns: remaining } };
}
const foregroundClaims = params.entry?.mainRestartRecovery?.foregroundClaims;
const foregroundOwnerClaimId =
runId &&
@@ -2,12 +2,16 @@ import { describe, expect, it } from "vitest";
import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js";
import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lifecycle.js";
function recoveryEntry(params?: { hasCurrentOwner?: boolean }): SessionEntry {
function recoveryEntry(params?: {
hasCurrentOwner?: boolean;
ownsDelivery?: boolean;
}): SessionEntry {
return {
sessionId: "session-1",
updatedAt: 100,
status: "running",
abortedLastRun: false,
...(params?.ownsDelivery ? { restartRecoveryDeliveryRunId: "recovery" } : {}),
restartRecoveryRuns: [
{ runId: "recovery", lifecycleGeneration: "generation-old" },
{ runId: "recovery", lifecycleGeneration: "generation-current" },
@@ -52,11 +56,14 @@ describe("main-session recovery run ownership", () => {
});
});
it("does not let an older same-id terminal settle its replacement generation", () => {
it.each([
{ name: "admitted delivery owner", params: { ownsDelivery: true } },
{ name: "foreground owner", params: { hasCurrentOwner: true, ownsDelivery: true } },
])("does not let an older same-id terminal settle or tombstone its $name", ({ params }) => {
expect(
projectMainSessionRecoveryLifecycle({
currentLifecycleGeneration: "generation-current",
entry: recoveryEntry({ hasCurrentOwner: true }),
entry: recoveryEntry(params),
event: {
runId: "recovery",
lifecycleGeneration: "generation-old",
@@ -68,8 +75,25 @@ describe("main-session recovery run ownership", () => {
action: "apply",
patch: {
restartRecoveryRuns: [{ runId: "recovery", lifecycleGeneration: "generation-current" }],
restartRecoveryTerminalRunIds: ["recovery"],
},
});
});
it("suppresses a duplicate older terminal after its fence has been consumed", () => {
const entry = recoveryEntry({ ownsDelivery: true });
entry.restartRecoveryRuns = [{ runId: "recovery", lifecycleGeneration: "generation-current" }];
expect(
projectMainSessionRecoveryLifecycle({
currentLifecycleGeneration: "generation-current",
entry,
event: {
runId: "recovery",
lifecycleGeneration: "generation-old",
data: { phase: "end" },
},
snapshotPatch: { status: "done", abortedLastRun: false },
}),
).toEqual({ action: "suppress" });
});
});
@@ -434,6 +434,45 @@ describe("session lifecycle state", () => {
expect(persisted.mainRestartRecovery).toBeUndefined();
});
it("keeps an active recovery when an older same-run terminal arrives", async () => {
const lifecycleGeneration = getAgentEventLifecycleGeneration();
const persisted = await persistLifecycle(
{
sessionId: "session-id",
updatedAt: 1_000,
startedAt: 1_050,
status: "running",
abortedLastRun: false,
restartRecoveryDeliveryRunId: "recovery-run",
restartRecoveryRuns: [
{ runId: "recovery-run", lifecycleGeneration: "pre-restart" },
{ runId: "recovery-run", lifecycleGeneration },
],
mainRestartRecovery: {
cycleId: "cycle-1",
revision: 5,
chargedAttempts: 2,
},
},
{
ts: 2_000,
sessionId: "session-id",
runId: "recovery-run",
lifecycleGeneration: "pre-restart",
data: { phase: "end", endedAt: 1_800 },
},
);
expect(persisted).toMatchObject({
status: "running",
abortedLastRun: false,
restartRecoveryDeliveryRunId: "recovery-run",
restartRecoveryRuns: [{ runId: "recovery-run", lifecycleGeneration }],
mainRestartRecovery: { cycleId: "cycle-1" },
});
expect(persisted.restartRecoveryTerminalRunIds).toBeUndefined();
});
it("does not settle a foreground owner from a stale lifecycle generation", async () => {
const persisted = await persistLifecycle(
{