@@ -26,6 +26,7 @@ const mockHasDurableRunPublication = vi.fn();
2626const mockDb = { } as Db ;
2727const mockStopSandboxesByIds = vi . fn ( ) ;
2828const mockListWorkflowSteps = vi . fn ( ) ;
29+ const mockAssertActiveRunOwnerState = vi . hoisted ( ( ) => vi . fn ( ) ) ;
2930vi . mock ( "workflow/api" , ( ) => ( { getRun : ( ...args : any [ ] ) => mockGetRun ( ...args ) } ) ) ;
3031vi . mock ( "workflow/runtime" , ( ) => ( {
3132 getWorld : ( ) => ( {
@@ -51,6 +52,9 @@ vi.mock("./run-start-lifecycle.js", () => ({
5152vi . mock ( "../sandbox/stop-ticket-sandboxes.js" , ( ) => ( {
5253 stopSandboxesByIds : ( ...args : any [ ] ) => mockStopSandboxesByIds ( ...args ) ,
5354} ) ) ;
55+ vi . mock ( "./active-run-owner.js" , ( ) => ( {
56+ assertActiveRunOwnerState : ( ...args : any [ ] ) => mockAssertActiveRunOwnerState ( ...args ) ,
57+ } ) ) ;
5458
5559function entry ( overrides : Partial < ActiveRunEntry > = { } ) : ActiveRunEntry {
5660 return {
@@ -135,6 +139,7 @@ describe("reconcileRuns owner-CAS recovery", () => {
135139 cursor : null ,
136140 hasMore : false ,
137141 } ) ;
142+ mockAssertActiveRunOwnerState . mockResolvedValue ( undefined ) ;
138143 } ) ;
139144
140145 it ( "leaves a fresh unbound reservation for its candidate" , async ( ) => {
@@ -349,6 +354,72 @@ describe("reconcileRuns owner-CAS recovery", () => {
349354 expect ( onTicketCancelled ) . not . toHaveBeenCalled ( ) ;
350355 } ) ;
351356
357+ it . each ( [ "Review" , "Done" ] ) (
358+ "releases a terminal manual ticket without overwriting live Jira %s from a stale AI snapshot" ,
359+ async ( liveStatus ) => {
360+ const manual = entry ( { kind : "manual_ticket" } ) ;
361+ const runRegistry = registry ( [ manual ] ) ;
362+ const tracker = issueTracker ( liveStatus ) ;
363+ mockGetRun . mockReturnValue ( { status : Promise . resolve ( "completed" ) } ) ;
364+ const onReleased = vi . fn ( ) ;
365+ const { reconcileRuns } = await import ( "./reconcile.js" ) ;
366+
367+ await expect (
368+ reconcileRuns (
369+ new Set ( [ "PROJ-1" ] ) ,
370+ runRegistry ,
371+ tracker ,
372+ undefined ,
373+ onReleased ,
374+ undefined ,
375+ mockDb ,
376+ ) ,
377+ ) . resolves . toEqual ( { cancelled : 0 , cleaned : 1 } ) ;
378+ expect ( mockCancelRunDetailed ) . not . toHaveBeenCalled ( ) ;
379+ expect ( tracker . fetchTicket ) . toHaveBeenCalledOnce ( ) ;
380+ expect ( tracker . moveTicket ) . not . toHaveBeenCalled ( ) ;
381+ expect ( mockAssertActiveRunOwnerState ) . toHaveBeenCalledWith (
382+ mockDb ,
383+ manual ,
384+ "bound" ,
385+ ) ;
386+ expect ( runRegistry . release ) . toHaveBeenCalledWith (
387+ manual . subjectKey ,
388+ manual . ownerToken ,
389+ manual . runId ,
390+ ) ;
391+ expect ( onReleased ) . toHaveBeenCalledWith ( manual . subjectKey ) ;
392+ } ,
393+ ) ;
394+
395+ it ( "retains a manual claim when stale-snapshot withdrawal cannot be confirmed" , async ( ) => {
396+ const manual = entry ( { kind : "manual_ticket" } ) ;
397+ const runRegistry = registry ( [ manual ] ) ;
398+ const tracker = issueTracker ( "AI" ) ;
399+ const moveError = new Error ( "response lost" ) ;
400+ vi . mocked ( tracker . fetchTicket )
401+ . mockResolvedValueOnce ( { trackerStatus : "AI" } as never )
402+ . mockResolvedValueOnce ( { trackerStatus : "AI" } as never ) ;
403+ vi . mocked ( tracker . moveTicket ) . mockRejectedValue ( moveError ) ;
404+ mockGetRun . mockReturnValue ( { status : Promise . resolve ( "completed" ) } ) ;
405+ const { reconcileRuns } = await import ( "./reconcile.js" ) ;
406+
407+ await expect (
408+ reconcileRuns (
409+ new Set ( [ "PROJ-1" ] ) ,
410+ runRegistry ,
411+ tracker ,
412+ undefined ,
413+ undefined ,
414+ undefined ,
415+ mockDb ,
416+ ) ,
417+ ) . resolves . toEqual ( { cancelled : 0 , cleaned : 0 } ) ;
418+ expect ( tracker . moveTicket ) . toHaveBeenCalledWith ( "PROJ-1" , "Backlog" ) ;
419+ expect ( tracker . fetchTicket ) . toHaveBeenCalledTimes ( 2 ) ;
420+ expect ( runRegistry . release ) . not . toHaveBeenCalled ( ) ;
421+ } ) ;
422+
352423 it ( "does not evict a ticket-triggered run that is still executing" , async ( ) => {
353424 const bound = entry ( ) ;
354425 const runRegistry = registry ( [ bound ] ) ;
0 commit comments