Skip to content

Commit 0b86dec

Browse files
authored
fix: keep live OpenClaw session locks during cleanup (#88129)
Keep session lock cleanup from removing live OpenClaw-owned locks solely because they are old. Cleanup now reports age-only stale locks without deleting them, while still removing dead, orphaned, recycled, malformed-old, and non-OpenClaw-owned locks. Update doctor docs and regression coverage for the cleanup/repair contract. Refs #87779
1 parent 61e7b04 commit 0b86dec

4 files changed

Lines changed: 96 additions & 12 deletions

File tree

docs/gateway/doctor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ That stages grounded durable candidates into the short-term dreaming store while
382382

383383
</Accordion>
384384
<Accordion title="3c. Session lock cleanup">
385-
Doctor scans every agent session directory for stale write-lock files — files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, older than 30 minutes, or a live PID that can be proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes stale lock files automatically; otherwise it prints a note and instructs you to rerun with `--fix`.
385+
Doctor scans every agent session directory for stale write-lock files — files left behind when a session exited abnormally. For each lock file found it reports: the path, PID, whether the PID is still alive, lock age, and whether it is considered stale (dead PID, malformed owner metadata, older than 30 minutes, or a live PID that can be proven to belong to a non-OpenClaw process). In `--fix` / `--repair` mode it removes locks with dead, orphaned, recycled, malformed-old, or non-OpenClaw owners automatically. Old locks that are still owned by a live OpenClaw process are reported but left in place so doctor does not cut off an active transcript writer.
386386
</Accordion>
387387
<Accordion title="3d. Session transcript branch repair">
388388
Doctor scans agent session JSONL files for the duplicated branch shape created by the 2026.4.24 prompt transcript rewrite bug: an abandoned user turn with OpenClaw internal runtime context plus an active sibling containing the same visible user prompt. In `--fix` / `--repair` mode, doctor backs up each affected file next to the original and rewrites the transcript to the active branch so gateway history and memory readers no longer see duplicate turns.

src/agents/session-write-lock.test.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ describe("acquireSessionWriteLock", () => {
668668
},
669669
{
670670
name: "old-live.jsonl.lock",
671-
removed: true,
671+
removed: false,
672672
stale: true,
673673
staleReasons: ["too-old"],
674674
},
@@ -680,17 +680,82 @@ describe("acquireSessionWriteLock", () => {
680680
stale: true,
681681
staleReasons: ["dead-pid", "too-old"],
682682
},
683+
]);
684+
685+
await expectPathMissing(staleDeadLock);
686+
await expect(fs.access(staleAliveLock)).resolves.toBeUndefined();
687+
await expect(fs.access(freshAliveLock)).resolves.toBeUndefined();
688+
} finally {
689+
await fs.rm(root, { recursive: true, force: true });
690+
}
691+
});
692+
693+
it("cleans old live .jsonl lock files owned by non-OpenClaw processes", async () => {
694+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
695+
const sessionsDir = path.join(root, "sessions");
696+
await fs.mkdir(sessionsDir, { recursive: true });
697+
const nowMs = Date.now();
698+
const lockPath = path.join(sessionsDir, "old-non-openclaw.jsonl.lock");
699+
700+
try {
701+
await fs.writeFile(
702+
lockPath,
703+
JSON.stringify({
704+
pid: process.pid,
705+
createdAt: new Date(nowMs - 120_000).toISOString(),
706+
}),
707+
"utf8",
708+
);
709+
710+
const result = await cleanStaleLockFiles({
711+
sessionsDir,
712+
staleMs: 30_000,
713+
nowMs,
714+
removeStale: true,
715+
readOwnerProcessArgs: () => ["python", "worker.py"],
716+
});
717+
718+
expect(lockCleanupRecords(result.cleaned)).toEqual([
683719
{
684-
name: "old-live.jsonl.lock",
720+
name: "old-non-openclaw.jsonl.lock",
685721
removed: true,
686722
stale: true,
687-
staleReasons: ["too-old"],
723+
staleReasons: ["too-old", "non-openclaw-owner"],
688724
},
689725
]);
726+
await expectPathMissing(lockPath);
727+
} finally {
728+
await fs.rm(root, { recursive: true, force: true });
729+
}
730+
});
690731

691-
await expectPathMissing(staleDeadLock);
692-
await expectPathMissing(staleAliveLock);
693-
await expect(fs.access(freshAliveLock)).resolves.toBeUndefined();
732+
it("does not clean fresh malformed .jsonl lock files during cleanup sweeps", async () => {
733+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
734+
const sessionsDir = path.join(root, "sessions");
735+
await fs.mkdir(sessionsDir, { recursive: true });
736+
const nowMs = Date.now();
737+
const lockPath = path.join(sessionsDir, "fresh-malformed.jsonl.lock");
738+
739+
try {
740+
await fs.writeFile(lockPath, "{}", "utf8");
741+
742+
const result = await cleanStaleLockFiles({
743+
sessionsDir,
744+
staleMs: 30_000,
745+
nowMs,
746+
removeStale: true,
747+
});
748+
749+
expect(lockCleanupRecords(result.locks)).toEqual([
750+
{
751+
name: "fresh-malformed.jsonl.lock",
752+
removed: false,
753+
stale: true,
754+
staleReasons: ["missing-pid", "invalid-createdAt"],
755+
},
756+
]);
757+
expect(result.cleaned).toEqual([]);
758+
await expect(fs.access(lockPath)).resolves.toBeUndefined();
694759
} finally {
695760
await fs.rm(root, { recursive: true, force: true });
696761
}

src/agents/session-write-lock.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const DEFAULT_SESSION_WRITE_LOCK_MAX_HOLD_MS = 5 * 60 * 1000;
4343
export const DEFAULT_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS = 60_000;
4444
const DEFAULT_WATCHDOG_INTERVAL_MS = 60_000;
4545
const DEFAULT_TIMEOUT_GRACE_MS = 2 * 60 * 1000;
46+
const REPORT_ONLY_STALE_LOCK_REASONS = new Set(["too-old", "hold-exceeded"]);
4647

4748
/**
4849
* Yield control to the event loop so other sessions can make progress
@@ -530,7 +531,10 @@ function shouldTreatAsNonOpenClawOwner(params: {
530531
heldByThisProcess: boolean;
531532
readOwnerProcessArgs: SessionLockOwnerProcessArgsReader;
532533
}): boolean {
533-
if (params.inspected.stale || params.inspected.pid === null || !params.inspected.pidAlive) {
534+
if (params.inspected.pid === null || !params.inspected.pidAlive) {
535+
return false;
536+
}
537+
if (params.inspected.staleReasons.includes("recycled-pid")) {
534538
return false;
535539
}
536540
if (params.inspected.pid === process.pid && params.heldByThisProcess) {
@@ -578,6 +582,21 @@ async function shouldReclaimContendedLockFile(
578582
}
579583
}
580584

585+
async function shouldRemoveLockDuringCleanup(
586+
lockPath: string,
587+
details: LockInspectionDetails,
588+
staleMs: number,
589+
nowMs: number,
590+
): Promise<boolean> {
591+
if (!details.stale) {
592+
return false;
593+
}
594+
if (details.staleReasons.every((reason) => REPORT_ONLY_STALE_LOCK_REASONS.has(reason))) {
595+
return false;
596+
}
597+
return await shouldReclaimContendedLockFile(lockPath, details, staleMs, nowMs);
598+
}
599+
581600
function sessionLockHeldByThisProcess(normalizedSessionFile: string): boolean {
582601
return SESSION_LOCKS.heldEntries().some(
583602
(entry) => entry.normalizedTargetPath === normalizedSessionFile,
@@ -728,7 +747,7 @@ export async function cleanStaleLockFiles(params: {
728747
removed: false,
729748
};
730749

731-
if (lockInfo.stale && removeStale) {
750+
if (removeStale && (await shouldRemoveLockDuringCleanup(lockPath, lockInfo, staleMs, nowMs))) {
732751
await fs.rm(lockPath, { force: true });
733752
lockInfo.removed = true;
734753
cleaned.push(lockInfo);

src/commands/doctor-session-locks.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ describe("noteSessionLockHealth", () => {
104104
await expect(fs.access(freshLock)).resolves.toBeUndefined();
105105
});
106106

107-
it("uses configured stale threshold when repairing lock files", async () => {
107+
it("uses configured stale threshold without removing live OpenClaw lock files", async () => {
108108
const sessionsDir = state.sessionsDir();
109109
await fs.mkdir(sessionsDir, { recursive: true });
110110

@@ -124,8 +124,8 @@ describe("noteSessionLockHealth", () => {
124124
expect(note).toHaveBeenCalledTimes(1);
125125
const [message] = firstNoteCall();
126126
expect(message).toContain("stale=yes (too-old)");
127-
expect(message).toContain("[removed]");
128-
await expectPathMissing(configuredStaleLock);
127+
expect(message).not.toContain("[removed]");
128+
await expect(fs.access(configuredStaleLock)).resolves.toBeUndefined();
129129
});
130130

131131
it("removes fresh live locks when the owner is not an OpenClaw process", async () => {

0 commit comments

Comments
 (0)