fix(mock): stop scoring timing out on longer sessions, and let a failed report be retried - #134
Conversation
MOCK_REPORT_TIMEOUT_MS was a flat 60s passed to AbortSignal.timeout, which is a total wall clock rather than a time to first byte - the reply is one non-streaming JSON body, so nothing arrives until the whole report is generated. The report is the one call whose duration scales with the session: MockReport carries a score, a justification and a full rewritten answer per question, follow-ups included, over a body that also carries the profile and context. A twelve-turn session asks for several times the generation a three-turn one does, so one number is right for neither - and the long ones hit the deadline routinely. The abort is client-side, so the backend finishes the report and charges for it anyway. Derived from the turns actually being sent rather than the session's configured length, so a session ended early is not held to a deadline for questions it never asked and one that ran long on follow-ups gets the time they cost. Bounded at both ends; End stays live for the whole of Scoring as the way out of a request that really has hung. Refs #133 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL
The report is the only charged call in a session whose failure was terminal. finishToScoring lands on Finished with reportError set, and from there the answers stay on screen and stay exportable but the score they were billed for could not be obtained by any route short of a second interview. Deliberately not the automatic retry generateNextQuestion has. A timeout on this call says nothing about whether the backend finished and billed the attempt that timed out, so a silent second attempt can spend twice on the candidate's behalf - and at this call's deadline it would also double the wait before they are told anything at all. Stays on Finished rather than returning to Scoring: Scoring is an active session, so it would re-arm the navigation lock and swap the report screen the candidate is looking at for the session screen, which has no question left to show. rescoring carries the in-flight state instead. Two things fall out of splitting requestReport off. A missing setup is now a failure rather than an early return - by that point the state is already Scoring, which has no control that ends it but End, so returning stranded the session on a spinner instead of holding the terminal-state invariant. And the failure is logged, the way the question path logs its own: this is the end of a session the candidate paid for, and the reason it produced nothing was the one thing neither the screen nor the log recorded. Refs #133 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL
The report screen stated the failure and stopped there. The control goes in the alert that names it rather than beside Export and Done, because it is the answer to what that alert says and nowhere else on the screen is about the score being missing. rescoring rather than the session state drives the button, so the answers stay on screen and the navigation lock stays off while the retry is in flight. Refs #133 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL
Covers the deadline scaling with the turns being scored and staying bounded at both ends, and the retry: the in-flight shape staying on Finished rather than re-entering Scoring, a success clearing the error, a failure landing back where the first one did, a retry with nothing to recover not reaching the backend at all, and a retry abandoned by clear() writing nothing onto the session that replaced it. Refs #133 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL
A score that arrives after an export is content that file does not contain, which is the rule appendAnswer already follows for a new answer. Unreachable before retryScoring existed - a report only ever arrived before there was anything to export it from - but the report screen keeps Export beside the failure, so saving the answers and then retrying is an ordinary thing to do, and Done and Practise again would have waved the candidate past a score that was never written anywhere. Also gives the retry control the gap it needs: AlertDescription is a grid whose own gap-1 is sized for two lines of copy, not for copy followed by a button. Prettier reformatted three unrelated spots in report.tsx - the file is one this change touches, which is the rule the repo sets for formatting. Refs #133 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL
Review passWalked the diff for errors and side effects. One real defect found and fixed in Fixed: a retried report left
|
A successful retry unmounts the failure alert, and with it the Score again button the candidate has just pressed - which drops focus to the body and loses their place in a screen that has at that moment filled up with the score they were waiting for. Keyed on the report arriving rather than on mount alone. It is the same replacement the existing mount effect exists for, one step further in: this screen is never reached through a real navigation, so nothing else moves focus here on its own. The flag only ever flips once per session - a retry needs reportError set, which is only ever true while report is null - so nothing steals focus while the candidate is reading. Refs #133 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL
Second review passRe-walked the diff looking specifically for errors and side effects. One defect found and fixed in Fixed: a successful retry dropped keyboard focusThe retry unmounts the failure alert, and with it the Score again button that was just pressed. Focus falls to This is the same replacement the existing mount effect exists for, one step further in - the comment there already notes that this screen is never reached through a real navigation, so nothing else moves focus on its own. The effect is now keyed on the report arriving. It can only fire twice at most: a retry requires Traced and clear
Constant placement
Verification
|
Closes #133
Mock interview scoring fails on longer sessions with "The request timed out", and the failure is terminal: the candidate has already been billed for the report and has no way left to obtain one.
The timeout
MOCK_REPORT_TIMEOUT_MSwas a flat60_000passed toAbortSignal.timeout, which is a total wall-clock deadline rather than a time-to-first-byte one. The reply is a single non-streaming JSON body, so nothing arrives until the whole report is generated.The report is the one call whose duration scales with the session.
MockReportcarries ascore, ajustificationand a fullstronger_answerrewrite for every question asked - follow-ups included - over a body that also carries the profile and context. A twelve-turn session asks for several times the generation a three-turn one does, so one number is right for neither: sized for the short one it truncates the long one. That is the "sometimes" - it tracks interview length rather than anything intermittent. The abort is client-side only, so the backend goes on to finish the report and charge for it.mockReportTimeoutMs()derives it fromdata.questions.length- the turns actually being sent, rather than the session's configured length, so a session ended early is not held to a deadline for questions it never asked and one that ran long on follow-ups gets the time they cost. Bounded at both ends: a floor so a short session still gets a real deadline, and a ceiling so a genuinely hung request cannot hold theScoringspinner indefinitely just because the session was long. Wall clock rather than a stall timer, unlike the streaming paths - there is one body and no progress to detect - and End stays live throughoutScoringas the manual way out.The recovery
generateNextQuestionretries once; the report did not, and it is the one charged call whose failure is terminal.retryScoring()re-runs it, and the report screen offers Score again inside the alert that reports the failure. Deliberately user-driven rather than automatic: a client-side timeout says nothing about whether the backend finished and billed the attempt that timed out, so a silent second attempt can spend twice on the candidate's behalf - and at this call's deadline it would also double the wait before they are told anything. The answers are already on screen; the spend is theirs to make. It is the same reasoning the 402 path already applies to question generation.It stays on
Finishedrather than returning toScoring.Scoringis an active session, so it would re-arm the navigation lock and swap the report screen the candidate is looking at for the session screen, which has no question left to show. Arescoringflag on the session carries the in-flight state instead, mirrored in both type files.Two things that fall out of the split
setupinrequestReportis now a failure rather than an early return. By that point the state is alreadyScoring, which has no control that ends it but End, so returning stranded the session on a spinner instead of holding the terminal-state invariantmock-interview-state.test.mjspins.Testing
test/mock-report-retry.test.mjspins the deadline scaling and staying bounded, and the retry: the in-flight shape staying onFinished, a success clearing the error, a failure landing back where the first one did, a retry with nothing to recover not reaching the backend at all, and a retry abandoned byclear()writing nothing onto the session that replaced it.Run locally, all green:
pnpm lint, bothtscconfigs,pnpm build,pnpm test:main(20 new checks, no regressions).🤖 Generated with Claude Code
https://claude.ai/code/session_01Kt5ucR5XPzzzxqPYzSpXaL