Merge pull request 'feat: CI browser journey intermittently leaves week reschedule dialog open' (#1312) from timmy/1311-ci-browser-journey-intermittently-leaves-week-re into main
All checks were successful
CI / lint (push) Successful in 3m46s
CI / build-release (push) Successful in 8s
CI / browser-journey (push) Successful in 6m15s
CI / release-candidate (push) Successful in 9s

This commit is contained in:
timmy 2026-08-23 16:07:23 +00:00
commit 187b0decae
2 changed files with 106 additions and 5 deletions

View File

@ -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) {

View File

@ -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;