fix: recover stale sessions without masking ARR submission errors

This commit is contained in:
Wyndham ARR committed 2026-10-08 21:04:34 +08:00
1 parent 5acedc02e0
commit 9487634f83
5 files changed
+140 -7

No files matched your search

@@ -199,3 +199,14 @@ Read: memory-index, project-positioning, current-state latest September sections
- Local activation: backed up only the editing9/17 review, then used the existing internal append-only reanalysis operation with expected revision2. Authenticated API now reports editing revision3/pending0/total0/can_finalize=true,18 cancellations excluded, no job and no finalize intent. Original data SHA remains `b9e115470f3ce4d1a31fde03308c18f0854c9daef5a8beb71d8ca5b997cdc831`; original manifest SHA remains `8efb54d2737d15d837295ffa7f5e5bd1ff0d467d89fb1e956de6b1055a8c076f`.10/7 remains finalized source revision4 and its existing price-review job, unchanged. No Oracle call, service restart, policy-pin change, Finance write, real daily/monthly generation, remote push or primary-checkout edit.
- UI verification: preserved the original10/7 price panel and opened a separate local result tab. The pending-date button was initially disabled after restoration; choosing9/17 refreshed its enabled state, then opening the existing pending task made no new download. Page visibly shows9/17,0/0 confirmed,18 excluded cancellations, “没有待完善字段”, and an enabled “确认并生成日报”. Did not click generation. Both result and existing price tabs are retained. Private screenshot: `/Users/chillishark/Library/Application Support/ARR2.0/production-validation-20261007/sept17-optional-review-cleared-20261008.png`.
- Private audit: `production-validation-20261007/sept17-optional-reanalysis-dccb6cbb03c4/{receipt,activation}.json`; replay manifest `7e4ec018d8898864c4fabacc4e2fba8be8e917704b95bc4e6fc7467d8c635783`; optional-only projected data `8afddb5d08bbc0c8814c1036db6b0be91aa45cb25676bc25731e642b8581ad9f`; pre-append backup `ohip-production-57106-20261008/sept17-source-reanalysis-backup-20261008-123341`. Raw guest data stays outside Git. Promotion candidate: explain old pending captures require explicit source-policy interpretation rather than blanket staff confirmation; retain current optional/required distinction. Broader report-scope alignment remains deferred.
## Same-task Follow-up: September16 Submission Recovery
- User reports selecting9/16 and seeing “尚未查到任务,可继续提交原请求”. Concurrent gate Passed for resumed task/feature/codex/owned checkout/base2417b1a with no peers. Project Context Loaded: retained required memory/positioning/decisions/architecture/domain/evidence/reflection/commitments/stale context plus current task record and planning-gate instructions. Relevant boundaries: two input paths, original pricing/Finance identity, independent report dates, no duplicate source acquisition after an uncertain response. Primary unknown dirty docs remain untouched; September restrictions remain superseded only by explicit October authorization. Planning gate Passed.
- Read-only evidence: current live queue has only9/15,10/7,9/17; user request88cf3094edc2e82dec679e27c4bd3e57 is absent. At20:53:21 local service log records POST /api/arr-downloads then GET for that ID. Existing logs intentionally retain path only and omit HTTP status/error bodies, so exact historical refusal cannot be reconstructed. Browser-control copies still show10/7 and9/17; the user-visible older tab connection times out. Do not assert a guessed historical failure, lack of9/16 Oracle data or an Oracle outage.
- Confirmed code issue: submission catches400/409/503 but not403 for toast, then GET404 replaces any known submit rejection with generic not_received. A stale CSRF token after session/cookie changes can cause this precise path even while GET remains authenticated. It is a reproducible candidate, not proven historical cause.
- Plan: retry a mutation once only after explicit403/SESSION_INVALID, refresh token from authenticated /api/session only for the same username, preserve exact body/path/intent, and retain a definite rejection reason when reconciliation finds no task. Never retry network/500/other403 automatically. Add focused synthetic regression checks and verify local UI without submitting a new Oracle read or changing saved price/source decisions.
- Outcome: shared API refreshes a stale token once only after an explicit403/SESSION_INVALID. Authenticated /api/session must identify the same original username and supply a valid token; the retry preserves method/path/body/headers/returnEnvelope. A second rejection,401, changed username, malformed token, network loss,500 or any other403 does not trigger another mutation. Submission keeps a known4xx error reason when GET reconciliation returns404; generic not_received now clearly directs explicit retry. Existing uncertain-intent identity and independent-date behavior stay intact.
- Verification:56 JavaScript submission/source-review cases passed (8 new focused recovery/boundary cases), including exact body/request-ID retention, one retry limit,401/user-change/invalid-token refusal, other403 refusal, price decision body/extra-header/envelope preservation,500 uncertainty and existing lost-response/404 replay. Both scripts passed node syntax checks and Git whitespace checks. Independent agent reproduced stale-token POST403→GET404 in the actual frontend sandbox and a pure-memory backend: no queue.create call; then reviewed the final retry boundaries with no blocking issues. This establishes the bug mechanism, not the unknown historical HTTP response for the user's submission.
- Local verification: static changes are served immediately without restart. Reloaded the agent result tab only, selected9/16, and confirmed enabled “下载并处理” and “可下载所选日期的报表”;9/17 remains in the pending-date list and10/7 stays at0/2 price decisions. Did not press download, retry, finalize, or save prices; no new Oracle read/Finance write/task creation. The older user-visible tab's failed browser connection was not bypassed; user should refresh that page to receive the fix. Private screenshot `production-validation-20261007/sept16-submission-ready-20261008.png`; existing price tab and result tab retained.
- Follow-up/promotion: exact9/16 historical rejection remains unknown because current path-only logs omit status. Explain this evidence limit rather than claim Oracle no-data or prove session rejection for that event. User can explicitly retry the original9/16 intent from the refreshed page; then assess authoritative queue progress. Canonical UI/API recovery documentation should record bounded same-user refresh and preserved definite-error details at integration. No remote push, production deployment, primary-checkout edit or report generation occurred.
+29 -4
View File
@@ -12,6 +12,7 @@
arrDownloadLoaded: false,
arrDownloadTask: null,
arrDownloadIntent: null,
arrDownloadSubmissionError: null,
arrDownloadBusy: false,
arrDownloadLoading: false,
arrDownloadConfigLoading: false,
@@ -415,11 +416,14 @@
let status = !state.arrDownloadLoaded ? "connecting" : !state.arrDownloadReady ? "unavailable" : task?.status || "ready";
if (state.arrDownloadConfigDisconnected) status = "service_reconnecting";
if (state.arrDownloadDisconnected || pending) status = "reconnecting";
const rejected = status === "not_received" && Boolean(task?.submission_error);
const feedback = $("#arr-download-feedback");
feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : ["needs_review", "needs_data_review"].includes(status) ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) ? " is-error" : ""}`;
feedback.className = `arr-download-feedback${arrDownloadActive() ? " is-running" : status === "succeeded" ? " is-success" : ["needs_review", "needs_data_review"].includes(status) ? " is-review" : ["failed", "interrupted", "reconnecting", "service_reconnecting", "date_unavailable"].includes(status) || rejected ? " is-error" : ""}`;
const taskError = !task?.job_id && status === "failed" && task.error_code === "ARR_SOURCE_FETCH_FAILED" ? "source_failed"
: !task?.job_id && status === "interrupted" && task.error_code === "ARR_DOWNLOAD_INTERRUPTED" ? "source_interrupted" : status;
const text = I18N.t(`arr_download.${taskError}`);
const text = rejected
? I18N.t("arr_download.submission_rejected", { reason: task.submission_error })
: I18N.t(`arr_download.${taskError}`);
const taskDate = task?.report_date || state.arrDownloadIntent?.report_date;
$("#arr-download-status").textContent = taskDate ? `${taskDate} · ${text}` : text;
$("#arr-download-review").hidden = task?.status !== "needs_review" || !task.job_id;
@@ -444,6 +448,7 @@
}
async function acceptARRDownloadTask(task, { sync = true } = {}) {
state.arrDownloadSubmissionError = null;
state.arrDownloadTask = task;
rememberARRPendingReview(task);
state.arrDownloadDisconnected = false;
@@ -481,6 +486,9 @@
if (state.arrDownloadIntent?.request_id !== intent.request_id) return;
if (error.status === 404) {
state.arrDownloadTask = { ...intent, status: "not_received", can_retry: false };
if (state.arrDownloadSubmissionError?.request_id === intent.request_id) {
state.arrDownloadTask.submission_error = state.arrDownloadSubmissionError.message;
}
state.arrDownloadDisconnected = false;
} else {
state.arrDownloadDisconnected = true;
@@ -514,6 +522,7 @@
state.arrDownloadContextId = config.context_id;
state.arrDownloadRestored = false;
state.arrDownloadIntent = null;
state.arrDownloadSubmissionError = null;
state.arrDownloadTask = null;
state.arrDownloadDisconnected = false;
state.arrDownloadSynced = "";
@@ -578,6 +587,7 @@
intent = { request_id: [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""), report_date: reportDate };
}
rememberARRIntent(intent); // Persist before the HTTP call, including a lost response.
state.arrDownloadSubmissionError = null;
state.arrDownloadTask = null;
resetARRDataReview();
state.arrDownloadBusy = true;
@@ -600,9 +610,12 @@
return;
}
// A dropped response is not evidence of a failed download/commit.
if (error.status >= 400 && error.status < 500) {
state.arrDownloadSubmissionError = { request_id: intent.request_id, message: error.message };
}
state.arrDownloadDisconnected = true;
if (error.status === 503) state.arrDownloadReady = false;
if ([400, 409, 503].includes(error.status)) showToast(error.message, true);
if ([400, 403, 409, 503].includes(error.status)) showToast(error.message, true);
await loadARRDownloadTask();
} finally {
state.arrDownloadBusy = false;
@@ -873,7 +886,7 @@
}
async function api(path, options = {}) {
const { returnEnvelope = false, ...requestOptions } = options;
const { returnEnvelope = false, csrfRetried = false, ...requestOptions } = options;
const headers = new Headers(requestOptions.headers || {});
if (requestOptions.method && requestOptions.method !== "GET") headers.set("X-ARR-CSRF", state.csrf);
const response = await fetch(path, { ...requestOptions, headers, credentials: "same-origin" });
@@ -892,6 +905,18 @@
const requestError = new Error(I18N?.errorMessage(error.code, error.message) || error.message || "请求未完成");
requestError.code = error.code || "";
requestError.status = response.status;
if (response.status === 403 && error.code === "SESSION_INVALID" && !csrfRetried
&& requestOptions.method && requestOptions.method !== "GET") {
// A fresh login in another tab changes the shared cookie, while this
// page may retain the old token. The rejected mutation did not run.
// Refresh only for the same user, then retry the exact request once.
let session;
try { session = await api("/api/session"); } catch (_) { throw requestError; }
if (session?.username !== state.arrDownloadUsername || !state.arrDownloadUsername
|| typeof session.csrf_token !== "string" || !session.csrf_token) throw requestError;
state.csrf = session.csrf_token;
return api(path, { ...options, csrfRetried: true });
}
throw requestError;
}
return returnEnvelope ? payload : payload.data;
+2 -1
View File
@@ -132,7 +132,8 @@
"arr_download.failed": ["任务未完成,请查看任务日志", "Task failed. Check the task log.", "งานไม่สำเร็จ โปรดดูบันทึกงาน"],
"arr_download.interrupted": ["任务中断,继续原任务以核对处理结果", "Task interrupted. Resume to reconcile the result.", "งานขัดจังหวะ ดำเนินงานเดิมต่อเพื่อตรวจสอบผล"],
"arr_download.reconnecting": ["正在重新查询原任务状态…", "Checking the original task status…", "กำลังตรวจสอบสถานะงานเดิม…"],
"arr_download.not_received": ["尚未查到任务,可继续提交原请求", "Task not found yet. Resubmit the original request.", "ยังไม่พบงาน สามารถส่งคำขอเดิมอีกครั้ง"],
"arr_download.not_received": ["下载任务尚未建立,请点击下方按钮重试", "The download task has not been created. Use the button below to retry.", "ยังไม่ได้สร้างงานดาวน์โหลด โปรดกดปุ่มด้านล่างเพื่อลองอีกครั้ง"],
"arr_download.submission_rejected": ["提交未成功:{reason}。下载任务尚未建立,请重试。", "Submission failed: {reason}. No download task has been created. Please retry.", "ส่งคำขอไม่สำเร็จ: {reason} ยังไม่ได้สร้างงานดาวน์โหลด โปรดลองอีกครั้ง"],
"arr_download.date_unavailable": ["本机模拟没有所选日期的数据,请重新选择日期。", "No local simulation data for this date. Choose another date.", "ไม่มีข้อมูลจำลองในเครื่องสำหรับวันที่นี้ โปรดเลือกวันที่อื่น"],
"arr_download.submitting": ["正在提交…", "Submitting…", "กำลังส่ง…"],
"arr_download.running": ["任务进行中", "In progress", "กำลังดำเนินการ"],
@@ -27,6 +27,102 @@ test('old not_received intent is released only after the explicit source-date re
assert.equal(h.element('#arr-download-date').disabled,false);
});
test('stale session token refreshes for the same user and retries the exact submission once',async()=>{
let posts=0;
const h=harness((url,options)=>{
if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'});
assert.equal(url,'/api/arr-downloads');
if(++posts===1) return response(403,null,'SESSION_INVALID');
return response(202,{...JSON.parse(options.body),status:'queued',job_id:null,can_retry:false});
});
await h.submit();
assert.equal(h.calls.length,3);
assert.equal(h.calls[1].url,'/api/session');
assert.equal(h.calls[1].method,'GET');
assert.equal(h.calls[0].body,h.calls[2].body);
assert.equal(h.calls[0].csrf,'fixture');
assert.equal(h.calls[2].csrf,'fresh-fixture');
assert.equal(h.state.arrDownloadTask.status,'queued');
assert.equal(h.state.arrDownloadSubmissionError,null);
assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id);
});
test('a still-invalid session stops after one retry and keeps the rejection reason and identity',async()=>{
const h=harness((url,options)=>{
if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'});
return options.method==='POST' ? response(403,null,'SESSION_INVALID') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND');
});
await h.submit();
const posts=h.calls.filter(c=>c.method==='POST');
assert.equal(posts.length,2);
assert.equal(posts[0].body,posts[1].body);
assert.equal(h.calls.filter(c=>c.url==='/api/session').length,1);
assert.equal(h.state.arrDownloadTask.status,'not_received');
assert.equal(h.state.arrDownloadTask.submission_error,'SESSION_INVALID');
assert.match(h.element('#arr-download-status').textContent,/arr_download.submission_rejected/);
assert.match(h.element('#arr-download-feedback').className,/is-error/);
assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(posts[0].body).request_id);
});
for(const [description,sessionResponse] of [
['requires login',()=>response(401,null,'SESSION_INVALID')],
['switches user',()=>response(200,{username:'different-user',csrf_token:'fresh-fixture'})],
['has no valid token',()=>response(200,{username:'operator',csrf_token:''})],
]) {
test(`session refresh that ${description} cannot resend the mutation`,async()=>{
const h=harness((url,options)=>{
if(url==='/api/session') return sessionResponse();
return options.method==='POST' ? response(403,null,'SESSION_INVALID') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND');
});
await h.submit();
assert.equal(h.calls.filter(c=>c.method==='POST').length,1);
assert.equal(h.state.csrf,'fixture');
assert.equal(h.state.arrDownloadTask.submission_error,'SESSION_INVALID');
assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id);
});
}
test('a different forbidden response is displayed without refreshing or resubmitting',async()=>{
const h=harness((url,options)=>options.method==='POST'
? response(403,null,'REPLAY_ORIGIN_REJECTED') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND'));
await h.submit();
assert.equal(h.calls.length,2);
assert.equal(h.calls.some(c=>c.url==='/api/session'),false);
assert.equal(h.state.arrDownloadTask.submission_error,'REPLAY_ORIGIN_REJECTED');
assert.equal(h.state.arrDownloadTask.status,'not_received');
});
test('session recovery preserves a price decision body, extra headers and full response envelope',async()=>{
let posts=0;
const payload={case_id:'fixture-case',revision:7,real_price:'1500'};
const envelope={ok:true,data:{revision:8},trace:'fixture-trace'};
const h=harness((url,options)=>{
if(url==='/api/session') return response(200,{username:'operator',csrf_token:'fresh-fixture'});
assert.equal(url,'/api/jobs/fixture-job/review/items/1');
assert.equal(options.headers.get('X-Fixture'),'retained');
if(++posts===1) return response(403,null,'SESSION_INVALID');
return {status:200,ok:true,json:async()=>envelope};
});
const result=await h.api('/api/jobs/fixture-job/review/items/1',{
method:'POST',headers:{'Content-Type':'application/json','X-Fixture':'retained'},
body:JSON.stringify(payload),returnEnvelope:true,
});
assert.deepEqual(result,envelope);
assert.equal(h.calls[0].body,h.calls[2].body);
assert.deepEqual(JSON.parse(h.calls[2].body),payload);
assert.equal(posts,2);
});
test('a server failure remains uncertain without automatic session or mutation retry',async()=>{
const h=harness((url,options)=>options.method==='POST'
? response(500,null,'INTERNAL_ERROR') : response(404,null,'ARR_DOWNLOAD_NOT_FOUND'));
await h.submit();
assert.equal(h.calls.length,2);
assert.equal(h.state.arrDownloadSubmissionError,null);
assert.equal(h.state.arrDownloadTask.submission_error,undefined);
assert.equal(h.state.arrDownloadIntent.request_id,JSON.parse(h.calls[0].body).request_id);
});
test('lost response and 404 keep original identity for explicit resubmission',async()=>{
let posts=0;
const h=harness((url,options)=>{
+2 -2
View File
@@ -20,9 +20,9 @@ function harness(respond) {
window:{ARRI18n:{t:key=>key,text:value=>value,errorMessage:code=>code},crypto:webcrypto,
setTimeout(){return 1;},clearTimeout(){},location:{replace(){}}},
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)},
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body});return respond(url,options,calls);},
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body,csrf:options.headers.get('X-ARR-CSRF')});return respond(url,options,calls);},
});
const exports='state, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview';
const exports='state, api, submitARRDownload, rememberARRIntent, acceptARRDownloadTask, handleARRDownloadDateChange, initARRDownload, loadARRDownloadTask, loadARRDataReview, renderARRDataReview, arrDataReviewCanFinalize, saveARRDataReviewItem, finalizeARRDataReview, trackARRDataReviewDraft, parseARRDataReviewValue, selectARRPendingReview';
vm.runInContext(source.replace(boot,` globalThis.subject = {${exports}};\n})();`),context);
const subject=context.subject;
Object.assign(subject.state,{arrDownloadReady:true,arrDownloadLoaded:true,arrDownloadStorageKey:'test',arrDownloadUsername:'operator',csrf:'fixture'});