Our assessment centre exercises have written answers in them, and those get an AI review. The review runs inline in the request that saves the attempt, not in a worker, which means one route handler holds the whole thing:
export const maxDuration = 60;
Sixty seconds, on a serverless function. That is a deliberate number: the review usually finishes in a few seconds, and the request returning with the feedback already attached is much simpler than a queue, a job id and a client that polls. The attempt row carries an ai_status so the UI knows which of the four states it is in: not applicable, processing, complete, failed.
Then we gave saved attempts their own page, and the simple design grew a hole.
The happy path is fine. The route sets processing, does the work, writes complete or failed, responds. The browser is still sitting there and sees the answer.
Now take the request that hits the ceiling. At sixty seconds the platform stops the function. There is no finally, no catch, no last write. The row stays exactly as the handler left it, which is processing, forever. A page that renders a spinner whenever the status says processing will render a spinner for that row in six months.
This class of bug is easy to miss because the state is correct in the ordinary sense. The column is not corrupt. It faithfully records what we intended to do. It just has no way to record that the thing which intended it no longer exists.
The saved report page does not trust the stored status on its own. It reads the status together with the row's updated_at and derives what the UI is allowed to show:
/**
* The attempts route scores written answers inside its own 60 second budget. A
* review still marked in progress well after that was cut off and will never
* finish, so it reads as failed rather than as a spinner that never stops.
*/
const REVIEW_STALE_MS = 2 * 60 * 1000;
function toPanelStatus(status: string, updatedAt: Date): AiPanelStatus {
if (status === 'complete' || status === 'failed') return status;
if (status === 'pending' || status === 'processing') {
return Date.now() - updatedAt.getTime() < REVIEW_STALE_MS
? 'processing'
: 'failed';
}
return 'not_applicable';
}
Two minutes, against a sixty second budget. The gap matters and it is worth being explicit about why it is not sixty seconds and one millisecond:
updated_at is set when the row is written, which is before the review starts, not when it ends.
Note also that the derived status is a function of two columns, not a new column. Nothing writes "stale". A background job that swept the table and rewrote abandoned rows would be a second source of truth and another thing to alert on. Deriving it means the rule lives in one function with the comment that explains it, and every surface that imports that function agrees.
The other half is how feedback that does arrive gets onto a server-rendered page. The page mounts a component with no markup in it:
export function ExerciseReviewRefresher() {
const router = useRouter();
useEffect(() => {
const id = setInterval(() => router.refresh(), 5000);
return () => clearInterval(id);
}, [router]);
return null;
}
Three things make that safe rather than a permanent five second heartbeat against our own database:
processing. A complete or failed report does not mount it at all. router.refresh() re-runs the server component, and the page is force-dynamic with revalidate = 0, so the refresh actually re-reads the row instead of being served the render from five seconds ago.failed, the component unmounts, and the interval is cleared by its own cleanup. There is no counter to get wrong, because the thing that ends the poll is the same rule that ends the spinner.
A poller whose exit condition is a separate variable from the one the UI renders is a poller that will one day disagree with the UI. Here there is one condition.
The written exercise format these reviews belong to is described on the written exercise guide, which is free to read, and the hub at cogniprep.app/assessment-centre-practice names the one e-tray scenario that is free to play in full. Play that and you can watch the inline review state in its ordinary form: the request returns with the feedback attached, so the processing state is brief and then the panel fills in.
The saved report page described here, with the refresher and the derived status, is in the repository and goes out with the next release rather than being clickable today. The part you can check now is the timing claim: submit a written answer and watch how long the request that saves the attempt takes in the Network tab. That single request is doing the review.
Any status column written by a process that can be killed needs a second opinion, and the second opinion has to be derived from data that is already there. Ask of every non-terminal state in a table: which process writes the terminal one, and what happens to the row if that process is gone? If the answer is "nothing", the row is a permanent spinner.