From 75846cf04534164d32e88841f83ed5805a010989 Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 23 Aug 2026 15:43:30 +0000 Subject: [PATCH] fix: retry unchanged reschedule intent after stale revision (Closes #1311) --- frontend/today-week-reschedule.js | 44 ++++++++++++++++--- tests/test_today_week_reschedule.py | 67 +++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/frontend/today-week-reschedule.js b/frontend/today-week-reschedule.js index dba72e5..38cfa4d 100644 --- a/frontend/today-week-reschedule.js +++ b/frontend/today-week-reschedule.js @@ -70,11 +70,12 @@ function createTodayWeekReschedule({ throw conflict; } - function flush() { + function flush(retryConflict = false) { if (flushing) return flushing; if (!pending()) return Promise.resolve(false); flushing = (async () => { let result = null; + let conflictRetried = false; while (true) { const records = readQueue(); const record = records[0]; @@ -83,7 +84,25 @@ function createTodayWeekReschedule({ result = await api('api/v1/week/reschedule', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(record.body), }); - } catch (error) { await restoreConflict(error); } + } catch (error) { + if (error?.status === 409 && retryConflict && !conflictRetried) { + const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]); + adoptToday(today); week.adopt(weekState); + const latest = readQueue(); + if (today?.ids?.includes(record.body.identity) && + latest[0]?.body?.operation_id === record.body.operation_id) { + latest[0] = {...latest[0], body:{ + ...latest[0].body, + today_revision:today.revision, + week_revision:weekState.revision, + }}; + if (!saveQueue(latest)) throw new Error('Could not update this saved move after plans changed.'); + conflictRetried = true; + continue; + } + } + await restoreConflict(error); + } adoptToday(result.today); week.adopt(result.week); const latest = readQueue(); @@ -207,9 +226,24 @@ function createTodayWeekReschedule({ } let result; try { - result = admitted ? await flush() : await api('api/v1/week/reschedule', { - method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body), - }); + if (admitted) { + result = await flush(true); + } else { + try { + result = await api('api/v1/week/reschedule', { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body), + }); + } catch (error) { + if (error?.status !== 409) throw error; + const [today, weekState] = await Promise.all([api('api/v1/today'), api('api/v1/week')]); + adoptToday(today); week.adopt(weekState); + if (!today?.ids?.includes(body.identity)) throw error; + body.today_revision = today.revision; body.week_revision = weekState.revision; + result = await api('api/v1/week/reschedule', { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body), + }); + } + } } catch(error) { if (admitted && error?.status === 409) await restoreConflict(error); if (admitted && error?.status !== 401 && error?.status !== 403) { diff --git a/tests/test_today_week_reschedule.py b/tests/test_today_week_reschedule.py index a8858e5..00babb4 100644 --- a/tests/test_today_week_reschedule.py +++ b/tests/test_today_week_reschedule.py @@ -64,6 +64,73 @@ console.log(JSON.stringify({opened,confirmed,requests,adoptedToday,weekState})); assert result["confirmed"]["today"]["revision"] == 5 +def test_reschedule_controller_retries_one_stale_revision_when_active_intent_is_unchanged(): + result = run_controller(""" +const requests=[]; +let attempts=0; +const weekState={revision:7,offline_snapshot:false,timezone:'UTC',days:[ + {plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}} +]}; +const week={ + load:async()=>weekState, + review:()=>({days:[{...weekState.days[0],label:'Tue',planned_minutes:0}]}), + adopt:value=>Object.assign(weekState,value), +}; +const api=async(url,options)=>{ + if(url==='api/v1/today')return {revision:5,ids:['active'],estimates:{active:45}}; + if(url==='api/v1/week')return {revision:8,offline_snapshot:false,timezone:'UTC',days:[ + {plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}} + ]}; + const body=JSON.parse(options.body);requests.push(body);attempts++; + if(attempts===1){const error=new Error('stale revision');error.status=409;throw error;} + return {today:{revision:6,ids:[],estimates:{}},week:{revision:9,timezone:'UTC',days:[ + {plan_date:'2026-08-25',ids:['active'],capacity_minutes:90,estimates:{active:45}} + ]}}; +}; +const controller=createReschedule({week,api,getToday:()=>null,adoptToday:()=>{},operationId:()=> 'move-active'}); +await controller.open('active'); +let message='',confirmed=null; +try{confirmed=await controller.confirm('2026-08-25',45);}catch(error){message=error.message;} +console.log(JSON.stringify({message,confirmed,requests})); +""") + + assert result["message"] == "" + assert result["confirmed"]["today"]["revision"] == 6 + assert [request["today_revision"] for request in result["requests"]] == [5, 5] + assert [request["week_revision"] for request in result["requests"]] == [7, 8] + assert result["requests"][0]["operation_id"] == result["requests"][1]["operation_id"] + + +def test_reschedule_controller_rebases_admitted_move_once_after_stale_revision(): + result = run_controller(""" +const values=new Map(); +const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}; +const requests=[];let attempts=0; +const initialWeek={revision:7,offline_snapshot:false,timezone:'UTC',days:[ + {plan_date:'2026-08-25',ids:[],capacity_minutes:90,estimates:{}} +]}; +const week={load:async()=>initialWeek,review:()=>({days:[{...initialWeek.days[0],label:'Tue',planned_minutes:0}]}),adopt:()=>{}}; +const api=async(url,options)=>{ + if(url==='api/v1/today')return {revision:5,ids:['active'],estimates:{active:45}}; + if(url==='api/v1/week')return {revision:8,offline_snapshot:false,timezone:'UTC',days:initialWeek.days}; + const body=JSON.parse(options.body);requests.push(body);attempts++; + if(attempts===1){const error=new Error('stale revision');error.status=409;throw error;} + return {today:{revision:6,ids:[],estimates:{}},week:{revision:9,timezone:'UTC',days:[]}}; +}; +const controller=createReschedule({week,api,adoptToday:()=>{},storage,getLogin:()=> 'timmy',operationId:()=> 'move-active'}); +await controller.open('active'); +let message='',confirmed=null; +try{confirmed=await controller.confirm('2026-08-25',45);}catch(error){message=error.message;} +console.log(JSON.stringify({message,confirmed,requests,remaining:values.size})); +""") + + assert result["message"] == "" + assert result["confirmed"]["today"]["revision"] == 6 + assert [request["today_revision"] for request in result["requests"]] == [5, 5] + assert [request["week_revision"] for request in result["requests"]] == [7, 8] + assert result["remaining"] == 0 + + def test_reschedule_controller_blocks_offline_full_and_overloaded_days_without_mutation(): result = run_controller(""" let calls=0;