feat(learning): abandoned attempt handling

scenario_attempts.status = 'abandoned' (a valid value since Phase 3B,
never previously set by anything) now gets set on: environment
destroy/reset, logout, session expiry (piggybacked on the existing hourly
session sweeper - no new background job), and switching to a different
scenario while one is still open. Never an academic failure marker -
result stays NULL, history is preserved. Call-site wiring in
server.js/admin-routes.js lands in the next commit alongside other
platform hardening in those same files.
This commit is contained in:
2026-08-24 20:14:52 -05:00
parent eae8e48d43
commit 6dda13b837
3 changed files with 101 additions and 6 deletions
+64 -6
View File
@@ -2,11 +2,55 @@
// session on a scenario (not per validation click, see migrations/012). Every mutation here
// updates that one row in place; nothing is ever inserted twice for the same open attempt.
//
// Deliberately does NOT try to detect "abandoned" attempts automatically (e.g. on Lab destroy)
// — that would need a reaper-style background job wired to Provisioner environment_events,
// which is more machinery than the instructional-visibility goal here needs. An attempt just
// stays 'in_progress' if the student never returns to finish it; instructors can already see
// "in_progress, N failed validations" as a real signal. Documented as a known limitation.
// Phase 4.1 (docs/PHASE4_1_PREPRODUCTION_HARDENING.md §13) added real abandonment handling —
// see abandonAttempt/abandonOpenAttemptsFor* below and their call sites (environment
// destroy/reset in server.js and admin-routes.js, logout in server.js, the session sweeper in
// sessions.js, and the "different scenario" transition inline in getOrCreateOpenAttempt just
// below). Still deliberately NOT a new standalone background job — every trigger either rides
// an existing request handler or the session sweeper that already runs periodically.
// An attempt is never silently deleted or marked as a failure by abandonment — result stays
// NULL, the row stays queryable, instructors see "abandoned (reason)" as a real, honest status
// rather than a punitive one. No-op (and does not overwrite an existing abandoned_reason) if
// the attempt is already terminal.
async function abandonAttempt(pool, attemptId, reason) {
const { rows } = await pool.query(
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = $2
WHERE attempt_id = $1 AND status = 'in_progress' RETURNING *`,
[attemptId, reason]
);
return rows[0] || null;
}
// Trigger: environment destroyed (idle reaper, student reset, admin reset/destroy — see
// server.js's student reset route and admin-routes.js's reset/destroy routes, all of which
// call this right after telling the Provisioner to tear the environment down). A reset creates
// a fresh environment_id in some flows and reuses the same one in others (see
// provisioner lifecycle.js's reset — same environment_id, fresh container) — either way, any
// attempt still open against the OLD container state is honestly "not going to be resumed as
// the same working session," so it's abandoned rather than left open indefinitely.
async function abandonOpenAttemptsForEnvironment(pool, environmentId) {
const { rows } = await pool.query(
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'environment_destroyed'
WHERE environment_id = $1 AND status = 'in_progress' RETURNING attempt_id`,
[environmentId]
);
return rows.length;
}
// Trigger: logout (server.js POST /api/logout) and the session sweeper's periodic pass for
// students whose sessions have all expired (sessions.js). Deliberately student-scoped, not
// environment-scoped — a student can log out while their Lab keeps running (it isn't
// destroyed until reaped or explicitly ended), but the WORKING SESSION on whatever scenario
// they had open is over the moment they're no longer authenticated to record more progress on it.
async function abandonOpenAttemptsForStudent(pool, studentId) {
const { rows } = await pool.query(
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'session_ended'
WHERE student_id = $1 AND status = 'in_progress' RETURNING attempt_id`,
[studentId]
);
return rows.length;
}
async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environmentId, cohortId, curriculumStage, assessmentSessionId }) {
const { rows: existing } = await pool.query(
@@ -17,6 +61,17 @@ async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environment
);
if (existing[0]) return existing[0];
// Trigger: student starts a different scenario while a previous one is still open (started
// via /start or implicitly via /validate, never finished). Only one scenario is "the one
// currently being worked on" per student+environment at a time from the app's own UX, so an
// open attempt on ANY OTHER scenario in this same environment is abandoned before the new
// one is created — never silently left as a phantom "still in progress" row.
await pool.query(
`UPDATE scenario_attempts SET status = 'abandoned', abandoned_at = now(), abandoned_reason = 'scenario_switched'
WHERE student_id = $1 AND environment_id = $2 AND scenario_id != $3 AND status = 'in_progress'`,
[studentId, environmentId, scenarioId]
);
const { rows } = await pool.query(
`INSERT INTO scenario_attempts
(student_id, scenario_id, environment_id, cohort_id, curriculum_stage, started_at, status, assessment_session_id)
@@ -80,4 +135,7 @@ async function getAttemptSummary(pool, { studentId, scenarioId, environmentId })
return rows[0] || null;
}
module.exports = { getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary };
module.exports = {
getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary,
abandonAttempt, abandonOpenAttemptsForEnvironment, abandonOpenAttemptsForStudent,
};