@@ -187,6 +187,206 @@ describe("post-compaction loop guard wired into runEmbeddedAgent", () => {
187187 expect ( attemptSignalReason ) . toBeInstanceOf ( PostCompactionLoopPersistedError ) ;
188188 } ) ;
189189
190+ it ( "releases the lane after a post-compaction abort when the backend ignores cancellation" , async ( ) => {
191+ vi . useFakeTimers ( ) ;
192+ let settleIgnoredAttempt : ( ( value : ReturnType < typeof makeAttemptResult > ) => void ) | undefined ;
193+ let resolveAttemptAborted : ( ( ) => void ) | undefined ;
194+ const attemptAbortedPromise = new Promise < void > ( ( resolve ) => {
195+ resolveAttemptAborted = resolve ;
196+ } ) ;
197+ try {
198+ const overflowError = makeOverflowError ( ) ;
199+ let attemptAborted = false ;
200+ mockedRunEmbeddedAttempt . mockResolvedValueOnce (
201+ makeAttemptResult ( { promptError : overflowError } ) ,
202+ ) ;
203+ mockedRunEmbeddedAttempt . mockImplementationOnce ( async ( attemptParams : unknown ) => {
204+ const { abortSignal, onToolOutcome } = attemptParams as {
205+ abortSignal ?: AbortSignal ;
206+ onToolOutcome ?: ToolOutcomeObserver ;
207+ } ;
208+ for ( let i = 0 ; i < 3 ; i += 1 ) {
209+ await executeWrappedToolOutcome (
210+ "gateway" ,
211+ { action : "lookup" , path : "x" } ,
212+ "identical-result" ,
213+ onToolOutcome ,
214+ ) ;
215+ }
216+ attemptAborted = abortSignal ?. aborted ?? false ;
217+ resolveAttemptAborted ?.( ) ;
218+ return await new Promise ( ( resolve ) => {
219+ settleIgnoredAttempt = resolve ;
220+ } ) ;
221+ } ) ;
222+ mockedCompactDirect . mockResolvedValueOnce (
223+ makeCompactionSuccess ( {
224+ summary : "Compacted session" ,
225+ firstKeptEntryId : "entry-5" ,
226+ tokensBefore : 150000 ,
227+ } ) ,
228+ ) ;
229+
230+ const run = runEmbeddedAgent ( {
231+ ...baseParams ,
232+ runId : "run-post-compaction-abort-lane-release" ,
233+ timeoutMs : 48 * 60 * 60 * 1000 ,
234+ } ) ;
235+ let settled = false ;
236+ void run
237+ . finally ( ( ) => {
238+ settled = true ;
239+ } )
240+ . catch ( ( ) => { } ) ;
241+
242+ await attemptAbortedPromise ;
243+ expect ( attemptAborted ) . toBe ( true ) ;
244+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
245+
246+ expect ( settled ) . toBe ( true ) ;
247+ await expect ( run ) . rejects . toMatchObject ( { name : "CommandLaneTaskTimeoutError" } ) ;
248+ } finally {
249+ settleIgnoredAttempt ?.( makeAttemptResult ( ) ) ;
250+ vi . useRealTimers ( ) ;
251+ }
252+ } ) ;
253+
254+ it ( "keeps a native lane alive while a tool is still running" , async ( ) => {
255+ vi . useFakeTimers ( ) ;
256+ let settleAttempt : ( ( value : ReturnType < typeof makeAttemptResult > ) => void ) | undefined ;
257+ let resolveAttemptStarted : ( ( ) => void ) | undefined ;
258+ const attemptStarted = new Promise < void > ( ( resolve ) => {
259+ resolveAttemptStarted = resolve ;
260+ } ) ;
261+ try {
262+ mockedRunEmbeddedAttempt . mockImplementationOnce ( async ( attemptParams : unknown ) => {
263+ const { onAttemptTimeoutArmed } = attemptParams as {
264+ onAttemptTimeoutArmed ?: ( ) => void ;
265+ } ;
266+ resolveAttemptStarted ?.( ) ;
267+ onAttemptTimeoutArmed ?.( ) ;
268+ return await new Promise ( ( resolve ) => {
269+ settleAttempt = resolve ;
270+ } ) ;
271+ } ) ;
272+
273+ const run = runEmbeddedAgent ( {
274+ ...baseParams ,
275+ runId : "run-native-tool-heartbeat" ,
276+ timeoutMs : 1 ,
277+ agentHarnessRuntimeOverride : "openclaw" ,
278+ } ) ;
279+ let settled = false ;
280+ void run
281+ . finally ( ( ) => {
282+ settled = true ;
283+ } )
284+ . catch ( ( ) => { } ) ;
285+
286+ await vi . advanceTimersByTimeAsync ( 0 ) ;
287+ await attemptStarted ;
288+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
289+
290+ expect ( settled ) . toBe ( false ) ;
291+ settleAttempt ?.( makeAttemptResult ( ) ) ;
292+ await expect ( run ) . resolves . toMatchObject ( { meta : { aborted : false } } ) ;
293+ } finally {
294+ vi . useRealTimers ( ) ;
295+ }
296+ } ) ;
297+
298+ it ( "releases a native lane when a timed-out attempt ignores cancellation" , async ( ) => {
299+ vi . useFakeTimers ( ) ;
300+ let settleAttempt : ( ( value : ReturnType < typeof makeAttemptResult > ) => void ) | undefined ;
301+ let resolveAttemptStarted : ( ( ) => void ) | undefined ;
302+ const attemptStarted = new Promise < void > ( ( resolve ) => {
303+ resolveAttemptStarted = resolve ;
304+ } ) ;
305+ try {
306+ mockedRunEmbeddedAttempt . mockImplementationOnce ( async ( attemptParams : unknown ) => {
307+ const { onAttemptTimeoutArmed, onAttemptTimeout } = attemptParams as {
308+ onAttemptTimeoutArmed ?: ( ) => void ;
309+ onAttemptTimeout ?: ( reason : Error ) => void ;
310+ } ;
311+ resolveAttemptStarted ?.( ) ;
312+ onAttemptTimeoutArmed ?.( ) ;
313+ onAttemptTimeout ?.( new Error ( "attempt timed out" ) ) ;
314+ return await new Promise ( ( resolve ) => {
315+ settleAttempt = resolve ;
316+ } ) ;
317+ } ) ;
318+
319+ const run = runEmbeddedAgent ( {
320+ ...baseParams ,
321+ runId : "run-native-timeout-lane-release" ,
322+ timeoutMs : 48 * 60 * 60 * 1000 ,
323+ agentHarnessRuntimeOverride : "openclaw" ,
324+ } ) ;
325+ let settled = false ;
326+ void run
327+ . finally ( ( ) => {
328+ settled = true ;
329+ } )
330+ . catch ( ( ) => { } ) ;
331+
332+ await vi . advanceTimersByTimeAsync ( 0 ) ;
333+ await attemptStarted ;
334+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
335+
336+ expect ( settled ) . toBe ( true ) ;
337+ await expect ( run ) . rejects . toMatchObject ( { name : "CommandLaneTaskTimeoutError" } ) ;
338+ } finally {
339+ settleAttempt ?.( makeAttemptResult ( ) ) ;
340+ vi . useRealTimers ( ) ;
341+ }
342+ } ) ;
343+
344+ it ( "releases a native lane when an explicit abort ignores cancellation" , async ( ) => {
345+ vi . useFakeTimers ( ) ;
346+ let settleAttempt : ( ( value : ReturnType < typeof makeAttemptResult > ) => void ) | undefined ;
347+ let resolveAttemptStarted : ( ( ) => void ) | undefined ;
348+ const attemptStarted = new Promise < void > ( ( resolve ) => {
349+ resolveAttemptStarted = resolve ;
350+ } ) ;
351+ try {
352+ mockedRunEmbeddedAttempt . mockImplementationOnce ( async ( attemptParams : unknown ) => {
353+ const { onAttemptTimeoutArmed, onAttemptAbort } = attemptParams as {
354+ onAttemptTimeoutArmed ?: ( ) => void ;
355+ onAttemptAbort ?: ( ) => void ;
356+ } ;
357+ resolveAttemptStarted ?.( ) ;
358+ onAttemptTimeoutArmed ?.( ) ;
359+ onAttemptAbort ?.( ) ;
360+ return await new Promise ( ( resolve ) => {
361+ settleAttempt = resolve ;
362+ } ) ;
363+ } ) ;
364+
365+ const run = runEmbeddedAgent ( {
366+ ...baseParams ,
367+ runId : "run-native-abort-lane-release" ,
368+ timeoutMs : 48 * 60 * 60 * 1000 ,
369+ agentHarnessRuntimeOverride : "openclaw" ,
370+ } ) ;
371+ let settled = false ;
372+ void run
373+ . finally ( ( ) => {
374+ settled = true ;
375+ } )
376+ . catch ( ( ) => { } ) ;
377+
378+ await vi . advanceTimersByTimeAsync ( 0 ) ;
379+ await attemptStarted ;
380+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
381+
382+ expect ( settled ) . toBe ( true ) ;
383+ await expect ( run ) . rejects . toMatchObject ( { name : "CommandLaneTaskTimeoutError" } ) ;
384+ } finally {
385+ settleAttempt ?.( makeAttemptResult ( ) ) ;
386+ vi . useRealTimers ( ) ;
387+ }
388+ } ) ;
389+
190390 it ( "does not abort when the result hash changes across post-compaction attempts (progress was made)" , async ( ) => {
191391 const overflowError = makeOverflowError ( ) ;
192392 // Attempt 1: overflow → triggers compaction.
0 commit comments