@@ -13,7 +13,12 @@ vi.mock("../../packages/terminal-core/src/note.js", () => ({
1313 note,
1414} ) ) ;
1515
16- import { noteSessionLockHealth } from "./doctor-session-locks.js" ;
16+ import {
17+ detectStaleSessionLocks ,
18+ noteSessionLockHealth ,
19+ sessionLockToHealthFinding ,
20+ sessionLockToRepairEffect ,
21+ } from "./doctor-session-locks.js" ;
1722
1823async function expectPathMissing ( targetPath : string ) : Promise < void > {
1924 try {
@@ -105,6 +110,154 @@ describe("noteSessionLockHealth", () => {
105110 await expect ( fs . access ( freshLock ) ) . resolves . toBeUndefined ( ) ;
106111 } ) ;
107112
113+ it ( "detects stale locks without removing them for structured lint" , async ( ) => {
114+ const sessionsDir = state . sessionsDir ( ) ;
115+ await fs . mkdir ( sessionsDir , { recursive : true } ) ;
116+
117+ const staleLock = path . join ( sessionsDir , "stale.jsonl.lock" ) ;
118+ const freshLock = path . join ( sessionsDir , "fresh.jsonl.lock" ) ;
119+
120+ await fs . writeFile (
121+ staleLock ,
122+ JSON . stringify ( { pid : - 1 , createdAt : new Date ( Date . now ( ) - 120_000 ) . toISOString ( ) } ) ,
123+ "utf8" ,
124+ ) ;
125+ await fs . writeFile (
126+ freshLock ,
127+ JSON . stringify ( { pid : process . pid , createdAt : new Date ( ) . toISOString ( ) } ) ,
128+ "utf8" ,
129+ ) ;
130+
131+ const locks = await detectStaleSessionLocks ( {
132+ staleMs : 30_000 ,
133+ readOwnerProcessArgs : ( ) => [ "node" , "/opt/openclaw/openclaw.mjs" , "doctor" ] ,
134+ } ) ;
135+
136+ expect ( locks ) . toHaveLength ( 1 ) ;
137+ expect ( locks [ 0 ] ?. lockPath ) . toBe ( staleLock ) ;
138+ await expect ( fs . access ( staleLock ) ) . resolves . toBeUndefined ( ) ;
139+ await expect ( fs . access ( freshLock ) ) . resolves . toBeUndefined ( ) ;
140+ } ) ;
141+
142+ it ( "maps stale locks to structured findings and dry-run effects" , async ( ) => {
143+ const sessionsDir = state . sessionsDir ( ) ;
144+ await fs . mkdir ( sessionsDir , { recursive : true } ) ;
145+ const lockPath = path . join ( sessionsDir , "stale.jsonl.lock" ) ;
146+ await fs . writeFile (
147+ lockPath ,
148+ JSON . stringify ( { pid : - 1 , createdAt : new Date ( Date . now ( ) - 120_000 ) . toISOString ( ) } ) ,
149+ "utf8" ,
150+ ) ;
151+
152+ const [ lock ] = await detectStaleSessionLocks ( {
153+ staleMs : 30_000 ,
154+ readOwnerProcessArgs : ( ) => [ "node" , "/opt/openclaw/openclaw.mjs" , "doctor" ] ,
155+ } ) ;
156+ if ( ! lock ) {
157+ throw new Error ( "expected stale session lock" ) ;
158+ }
159+
160+ expect ( sessionLockToHealthFinding ( lock ) ) . toEqual (
161+ expect . objectContaining ( {
162+ checkId : "core/doctor/session-locks" ,
163+ severity : "warning" ,
164+ path : lockPath ,
165+ } ) ,
166+ ) ;
167+ expect ( sessionLockToRepairEffect ( lock ) ) . toEqual ( {
168+ kind : "state" ,
169+ action : "would-remove-stale-session-lock" ,
170+ target : lockPath ,
171+ dryRunSafe : false ,
172+ } ) ;
173+ } ) ;
174+
175+ it ( "preserves fresh malformed stale locks in dry-run repair effects" , async ( ) => {
176+ const sessionsDir = state . sessionsDir ( ) ;
177+ await fs . mkdir ( sessionsDir , { recursive : true } ) ;
178+
179+ const malformedLock = path . join ( sessionsDir , "malformed.jsonl.lock" ) ;
180+ await fs . writeFile ( malformedLock , "{}" , "utf8" ) ;
181+
182+ const [ lock ] = await detectStaleSessionLocks ( {
183+ staleMs : 30_000 ,
184+ readOwnerProcessArgs : ( ) => [ "node" , "/opt/openclaw/openclaw.mjs" , "doctor" ] ,
185+ } ) ;
186+ if ( ! lock ) {
187+ throw new Error ( "expected stale session lock" ) ;
188+ }
189+
190+ expect ( lock . staleReasons ) . toEqual ( [ "missing-pid" , "invalid-createdAt" ] ) ;
191+ expect ( lock . removable ) . toBe ( false ) ;
192+ expect ( sessionLockToHealthFinding ( lock ) . fixHint ) . toContain ( "after the cleanup grace period" ) ;
193+ expect ( sessionLockToRepairEffect ( lock ) ) . toEqual ( {
194+ kind : "state" ,
195+ action : "would-preserve-mtime-gated-stale-session-lock" ,
196+ target : malformedLock ,
197+ dryRunSafe : false ,
198+ } ) ;
199+ await expect ( fs . access ( malformedLock ) ) . resolves . toBeUndefined ( ) ;
200+ } ) ;
201+
202+ it ( "uses the supplied env to choose the structured lint state dir" , async ( ) => {
203+ const other = await createOpenClawTestState ( {
204+ layout : "state-only" ,
205+ prefix : "openclaw-doctor-locks-other-" ,
206+ applyEnv : false ,
207+ } ) ;
208+ try {
209+ await fs . mkdir ( other . sessionsDir ( ) , { recursive : true } ) ;
210+ const lockPath = path . join ( other . sessionsDir ( ) , "other-stale.jsonl.lock" ) ;
211+ await fs . writeFile (
212+ lockPath ,
213+ JSON . stringify ( { pid : - 1 , createdAt : new Date ( Date . now ( ) - 120_000 ) . toISOString ( ) } ) ,
214+ "utf8" ,
215+ ) ;
216+
217+ const locks = await detectStaleSessionLocks ( {
218+ env : other . env ,
219+ staleMs : 30_000 ,
220+ readOwnerProcessArgs : ( ) => [ "node" , "/opt/openclaw/openclaw.mjs" , "doctor" ] ,
221+ } ) ;
222+
223+ expect ( locks . map ( ( lock ) => lock . lockPath ) ) . toEqual ( [ lockPath ] ) ;
224+ } finally {
225+ await other . cleanup ( ) ;
226+ }
227+ } ) ;
228+
229+ it ( "preserves report-only live OpenClaw locks in dry-run repair effects" , async ( ) => {
230+ const sessionsDir = state . sessionsDir ( ) ;
231+ await fs . mkdir ( sessionsDir , { recursive : true } ) ;
232+
233+ const reportOnlyLock = path . join ( sessionsDir , "report-only.jsonl.lock" ) ;
234+ await fs . writeFile (
235+ reportOnlyLock ,
236+ JSON . stringify ( { pid : process . pid , createdAt : new Date ( Date . now ( ) - 45_000 ) . toISOString ( ) } ) ,
237+ "utf8" ,
238+ ) ;
239+
240+ const [ lock ] = await detectStaleSessionLocks ( {
241+ staleMs : 30_000 ,
242+ readOwnerProcessArgs : ( ) => [ "node" , "/opt/openclaw/openclaw.mjs" , "doctor" ] ,
243+ } ) ;
244+ if ( ! lock ) {
245+ throw new Error ( "expected stale session lock" ) ;
246+ }
247+
248+ expect ( lock . staleReasons ) . toEqual ( [ "too-old" ] ) ;
249+ expect ( sessionLockToHealthFinding ( lock ) . fixHint ) . toBe (
250+ "OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck." ,
251+ ) ;
252+ expect ( sessionLockToRepairEffect ( lock ) ) . toEqual ( {
253+ kind : "state" ,
254+ action : "would-preserve-report-only-stale-session-lock" ,
255+ target : reportOnlyLock ,
256+ dryRunSafe : false ,
257+ } ) ;
258+ await expect ( fs . access ( reportOnlyLock ) ) . resolves . toBeUndefined ( ) ;
259+ } ) ;
260+
108261 it ( "uses configured stale threshold without removing live OpenClaw lock files" , async ( ) => {
109262 const sessionsDir = state . sessionsDir ( ) ;
110263 await fs . mkdir ( sessionsDir , { recursive : true } ) ;
0 commit comments