fix(training-studio): close the review's labeling-race and balance gaps

Five of Copilot's six findings were real:

- A missing/non-numeric balance from getBuzzAccount now returns null from
  the embed's getBuzzBalances instead of coercing to 0 — blue:0 asserted
  the whole price was non-Blue with certainty, defeating the fail-safe
  "up to" confirmation the unknown-balance path exists for.
- sourceLabel keeps the ARRIVAL text untrimmed (zip .txt and reused
  captions), as the switch dialog promises.
- Un-captioned reuse items become labelable only once hydration fills
  blobUrl — the drain runs again after hydration, restoring the pre-lazy
  behavior (failed hydration still degrades to the manual editor).
- A drain result resolving between abort and delivery is dropped, so a
  mode switch can't receive an old-mode label into a freshly reset tile.
- An aborted drain's finally no longer clears the replacement drain's
  progress counter — slot and labelRun release only under the identity
  guard.

The sixth (getBuzzAccount "returns an array") is wrong — the router's
getUserBuzzAccounts reduces rows into a {clientType: balance} record,
which is exactly how useQueryBuzz consumes it (initialData[type]).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ
This commit is contained in:
Luis Rojas
2026-09-18 11:19:24 -04:00
parent 23c4e072d8
commit 059a0f0ee3
2 changed files with 32 additions and 14 deletions
@@ -289,7 +289,8 @@
blobId: item.blobId,
blobUrl: item.url,
...parseLabel(label, labelMode),
sourceLabel: label || undefined,
// Verbatim — the switch logic promises to restore the ARRIVAL text exactly.
sourceLabel: item.caption || undefined,
labelTried: label ? true : undefined,
};
});
@@ -321,6 +322,10 @@
}
patch(t.id, { previewUrl: url, blobUrl: url });
});
// Un-captioned reuse items only become labelable once `blobUrl` exists — the add-time drain
// saw nothing to do, so run it again now (a failed hydration leaves the manual-label editor
// as the fallback, same as before).
void ensureLabeling();
}
// Video models train on stills too (the on-site trainer has always accepted images for video
@@ -387,7 +392,8 @@
status: 'uploading' as const,
progress: 0,
...parseLabel(label, labelMode),
sourceLabel: label || undefined,
// Verbatim (untrimmed) — the switch logic promises to restore the file's text exactly.
sourceLabel: e.caption || undefined,
labelTried: label ? true : undefined,
};
});
@@ -428,16 +434,28 @@
const items = targets.map((t) => ({ key: String(t.id), mediaUrl: t.blobUrl! }));
labelRun = { total: targets.length, done: 0, keys: new Set(items.map((i) => i.key)) };
for (const t of targets) patch(t.id, { labeling: true });
await runAutoLabel(labelMode, media, items, applyLabel, controller.signal);
// A result that resolves between abort and delivery must be dropped — post-switch it
// would write an OLD-mode label into a freshly reset tile and mark it done.
await runAutoLabel(
labelMode,
media,
items,
(r) => {
if (!controller.signal.aborted) applyLabel(r);
},
controller.signal
);
}
} catch (err) {
if (!isAbort(err)) for (const i of images) if (i.labeling) patch(i.id, { labeling: false });
} finally {
// Only release the slot if it's still OURS — an aborted drain's finally runs after the
// aborter has already started a replacement, and nulling that one would let a third drain
// run concurrently with it.
if (labelController === controller) labelController = null;
labelRun = null;
// Only release the slot/progress if they're still OURS — an aborted drain's finally runs
// after the aborter has already started a replacement, and clearing that one's state would
// let a third drain start concurrently (or blank the live progress counter).
if (labelController === controller) {
labelController = null;
labelRun = null;
}
}
}
+6 -6
View File
@@ -145,12 +145,12 @@ function TrainingStudioEmbed({ orchestratorMode }: { orchestratorMode: 'dev' | '
string,
number
>;
const num = (v: unknown) => (typeof v === 'number' ? v : 0);
return {
yellow: num(accounts.yellow),
green: num(accounts.green),
blue: num(accounts.blue),
};
const { yellow, green, blue } = accounts;
// A missing/non-numeric balance must stay UNKNOWN (null → the element's fail-safe
// "up to" confirmation), not read as a known zero — blue:0 would assert the whole
// price is non-Blue with certainty.
if ([yellow, green, blue].some((v) => typeof v !== 'number')) return null;
return { yellow, green, blue };
} catch {
return null;
}