Skip to content

Commit 6d5eedf

Browse files
fix: honour track readiness in the picker, scope cache invalidation
- page.tsx: /api/tracks returns per-track `ready` and a top-level `default`, but mapApiTracks dropped both — so nothing was preselected and a track whose Moss index is not loaded stayed selectable, failing later at /health. Both fields are carried through now: selection keeps a still-valid choice, else adopts the backend default, else the first ready track; unready tracks are disabled and labelled. A track without `ready` is treated as usable so an older backend does not grey out everything. - moss-vscode: the finally added in e733c8f cleared the persisted cache on any non-ready outcome, including a failure *before* rebuild() deleted anything (session setup, workspace scan) — where the previous documents are still intact and dropping the cache just forces a needless full re-index. The indexer now exposes hasDiscardedPreviousIndex(), set when deletion begins and reset on a ready finish, and the caller gates on it. - tracks.py: "optimising" -> "optimizing", matching US spelling elsewhere. Verified: track selection over 7 shapes (default unready, none ready, current gone unready, legacy backend without the field); and cache invalidation across fail-before-deletion, fail-after-deletion, cancelled and successful runs — only the first is now preserved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e733c8f commit 6d5eedf

4 files changed

Lines changed: 73 additions & 20 deletions

File tree

apps/moss-interview-coach/backend/tracks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"label": "Machine Learning Concepts",
6262
"blurb": "ML fundamentals, evaluation, training, and model systems.",
6363
"fallback_tips": [
64-
"Name the metric you are optimising and why it fits the problem.",
64+
"Name the metric you are optimizing and why it fits the problem.",
6565
"Separate training-time choices from inference-time constraints.",
6666
"Say how you would detect the model degrading in production.",
6767
],

apps/moss-interview-coach/frontend/app/page.tsx

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ type InterviewTrack = {
1010
id: string;
1111
label: string;
1212
blurb: string;
13+
/** False when the track's Moss index is not loaded on the backend. */
14+
ready: boolean;
1315
};
1416

1517
type GradeFeedback = {
@@ -109,13 +111,16 @@ function extractQuestionFromBotText(text: string): string {
109111
return (questions.at(-1) ?? parts.at(-1) ?? cleaned).trim();
110112
}
111113

112-
function mapApiTracks(
113-
apiTracks: Array<{ id: string; label: string; blurb?: string }>,
114-
): InterviewTrack[] {
114+
type ApiTrack = { id: string; label: string; blurb?: string; ready?: boolean };
115+
116+
function mapApiTracks(apiTracks: ApiTrack[]): InterviewTrack[] {
115117
return apiTracks.map((track) => ({
116118
id: track.id,
117119
label: track.label,
118120
blurb: track.blurb ?? "",
121+
// Absent means the backend did not report readiness; treat as usable so an
122+
// older backend does not render every track unselectable.
123+
ready: track.ready !== false,
119124
}));
120125
}
121126

@@ -165,19 +170,26 @@ export default function HomePage() {
165170
if (cancelled) return;
166171
if (!res.ok) throw new Error(`HTTP ${res.status}`);
167172
const data = (await res.json()) as {
168-
tracks?: Array<{ id: string; label: string; blurb?: string }>;
173+
tracks?: ApiTrack[];
174+
default?: string;
169175
};
170176
if (cancelled) return;
171177
if (!Array.isArray(data.tracks) || data.tracks.length === 0) {
172178
throw new Error("no tracks returned");
173179
}
174180
const mapped = mapApiTracks(data.tracks);
175181
setTracks(mapped);
176-
// Drop a selection whose track vanished from the refreshed list, so
177-
// Start cannot fire an id the backend would normalise to the default.
178-
setSelectedTrack((current) =>
179-
current && mapped.some((t) => t.id === current) ? current : null,
180-
);
182+
// Keep a still-valid selection; otherwise adopt the backend's default,
183+
// falling back to the first usable track. Anything unready is skipped,
184+
// and a selection whose track vanished is dropped so Start cannot fire
185+
// an id the backend would normalise to something else.
186+
setSelectedTrack((current) => {
187+
if (current && mapped.some((t) => t.id === current && t.ready)) {
188+
return current;
189+
}
190+
const preferred = mapped.find((t) => t.id === data.default && t.ready);
191+
return (preferred ?? mapped.find((t) => t.ready))?.id ?? null;
192+
});
181193
setTracksStatus("ready");
182194
} catch {
183195
if (cancelled) return;
@@ -713,23 +725,42 @@ function IdleView({
713725
<ul className="space-y-1 border-y border-[var(--cream)]/10 py-2">
714726
{tracks.map((track) => {
715727
const selected = selectedTrack === track.id;
728+
// The backend reports whether this track's Moss index is loaded.
729+
// Starting an unready one only fails later at /health, so show it
730+
// as unavailable rather than letting it be picked.
731+
const unavailable = !track.ready;
716732
return (
717733
<li key={track.id}>
718734
<button
719735
type="button"
720736
aria-pressed={selected}
737+
disabled={unavailable}
738+
title={unavailable ? "Moss index not loaded for this track" : undefined}
721739
onClick={() => onSelectTrack(track.id)}
722740
className={`flex w-full items-baseline justify-between gap-6 px-1 py-3 text-left transition ${
723-
selected
724-
? "text-[var(--accent)]"
725-
: "text-[var(--cream)] hover:text-[var(--accent)]"
741+
unavailable
742+
? "cursor-not-allowed text-[var(--cream)]/35"
743+
: selected
744+
? "text-[var(--accent)]"
745+
: "text-[var(--cream)] hover:text-[var(--accent)]"
726746
}`}
727747
>
728-
<span className="font-display text-2xl md:text-[1.75rem]">{track.label}</span>
748+
<span className="font-display text-2xl md:text-[1.75rem]">
749+
{track.label}
750+
{unavailable ? (
751+
<span className="ml-3 align-middle text-xs tracking-wide text-[var(--warn)]">
752+
index not loaded
753+
</span>
754+
) : null}
755+
</span>
729756
{track.blurb ? (
730757
<span
731758
className={`max-w-[14rem] text-right text-xs leading-snug md:max-w-xs md:text-sm ${
732-
selected ? "text-[var(--accent)]/80" : "text-[var(--fog)]"
759+
unavailable
760+
? "text-[var(--fog)]/50"
761+
: selected
762+
? "text-[var(--accent)]/80"
763+
: "text-[var(--fog)]"
733764
}`}
734765
>
735766
{track.blurb}

apps/moss-vscode/src/extension.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -512,14 +512,17 @@ async function runCreateIndex(
512512
statusBarItem.tooltip = message;
513513
}
514514
} finally {
515-
// rebuild() deletes the previous documents before scanning, so *any* run
516-
// that does not end in a ready index leaves the on-disk cache describing
515+
// rebuild() deletes the previous documents before scanning, so any run that
516+
// got that far and did not end ready leaves the on-disk cache describing
517517
// documents that no longer exist — cancelled, or thrown from
518518
// readFileForIndex()/addDocs() midway. persistIndex() refuses to write
519519
// while unindexed, so drop the cache here instead, in a finally so the
520-
// throw path cannot skip it; otherwise the next launch restores stale
521-
// metadata for an index that is gone.
522-
if (!indexer.isIndexed()) {
520+
// throw path cannot skip it.
521+
//
522+
// Gated on hasDiscardedPreviousIndex() so a failure *before* deletion began
523+
// (session setup, workspace scan) keeps the cache — those documents are
524+
// still intact and re-indexing from scratch would be wasted work.
525+
if (!indexer.isIndexed() && indexer.hasDiscardedPreviousIndex()) {
523526
await clearIndexCache(context).catch(() => undefined);
524527
log("Index not ready; cleared stale index cache.");
525528
}

apps/moss-vscode/src/indexer/indexer.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export class CodebaseIndexer {
2424
private watchers: vscode.Disposable[] = [];
2525
private indexing = false;
2626
private watchingEnabled = false;
27+
/** True once rebuild() has begun destroying the previous index. */
28+
private discardedPreviousIndex = false;
2729
private onPersist: (() => void) | undefined;
2830

2931
setPersistHandler(handler: (() => void) | undefined): void {
@@ -44,6 +46,16 @@ export class CodebaseIndexer {
4446
return Object.fromEntries(this.pathChunkCounts.entries());
4547
}
4648

49+
/**
50+
* Whether the last rebuild got far enough to destroy the previous index.
51+
*
52+
* Lets the caller decide if a non-ready outcome must also invalidate the
53+
* persisted cache, or whether the old documents are still intact.
54+
*/
55+
hasDiscardedPreviousIndex(): boolean {
56+
return this.discardedPreviousIndex;
57+
}
58+
4759
isIndexed(): boolean {
4860
return this.status.state === "ready" && this.pathChunkCounts.size > 0;
4961
}
@@ -97,6 +109,7 @@ export class CodebaseIndexer {
97109
return;
98110
}
99111
this.indexing = true;
112+
this.discardedPreviousIndex = false;
100113

101114
try {
102115
const files = await scanWorkspaceFiles(token);
@@ -109,6 +122,11 @@ export class CodebaseIndexer {
109122
staleIds.push(`${rel}#chunk-${i}`);
110123
}
111124
}
125+
// Past this point the previous index is being destroyed, so any exit that
126+
// is not "ready" leaves the persisted cache describing documents that no
127+
// longer exist. A failure *before* here (scan, session setup) leaves the
128+
// old documents intact, and the cache with them.
129+
this.discardedPreviousIndex = true;
112130
if (staleIds.length) {
113131
await this.deleteInBatches(staleIds);
114132
}
@@ -215,6 +233,7 @@ export class CodebaseIndexer {
215233
return;
216234
}
217235
this.watchingEnabled = true;
236+
this.discardedPreviousIndex = false;
218237
this.setStatus({
219238
state: "ready",
220239
files: this.pathChunkCounts.size,

0 commit comments

Comments
 (0)