fix: address Copilot review round 2 on #2251

- SkillsScreen: key verdicts and React elements by manifest ROW INDEX,
  not URI. The checker deliberately tolerates a duplicated URI so it can
  report `duplicate-resource`; a URI key collided those rows into one
  verdict, so verifying either updated both and "Verify all" raced two
  different digest/size declarations into the same slot.
- SkillsScreen: hold the invalidation generation in React state keyed by
  the manifest, not a ref bumped during render. `useValueChange` runs its
  callback during render and requires setState-only purity — an abandoned
  render left the ref incremented and silently discarded a live
  verification.
- SkillsScreen: title a size disagreement "Size mismatch" and render its
  reason. `verifySkillResource` catches it before hashing, so the alert
  was showing "actual undefined" under "Digest mismatch".
- SkillsScreen: pass `contents` to ContentViewer so a base64 SKILL.md
  renders. The text-block form substituted "" and painted a blank preview
  for a file verification had just read correctly.
- skills.ts: add `malformed-size` for a size that is not a non-negative
  safe integer, and exclude such values from the 16 MiB total. A negative
  one could pull the sum back under the limit and hide a violation.
- managedSkillsState: make the overlap guard per-session instead of a
  boolean. A disconnect during an in-flight walk left it set, so the
  reconnect's own load no-oped and was never retried — permanently, if
  the stale request never settled. A stale `finally` can no longer clear
  the live session's guard either.
- skillsSchemas: require the `{ skill }` envelope for `skills/get`. The
  accepted SEP settles it, and normalizing an inline entry would let a
  non-conforming response past the one place that could report it.
- #2248 and the PR description: corrected — they said
  `ReadResourceDirectoryResultSchema` was already declared, which round 1
  removed. #2248 now owns defining it, and records the frontmatter
  cross-check gap too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-04 21:45:49 -04:00
commit bfeebd440d

View file

@ -63,9 +63,9 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
private error: Error | null = null;
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
// Overlap guard: a walk in flight makes a second one a no-op so a slow older
// walk can't clobber a newer list via last-write-wins.
private running = false;
// Overlap guard, held as the generation whose walk is in flight (or `null`).
// See `refresh` for why this is not a boolean.
private runningGeneration: number | null = null;
// Session counter, advanced by `reset` (disconnect) and by `destroy`. A walk
// captures it and abandons its writes when it no longer matches.
private generation = 0;
@ -151,8 +151,6 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
this.applyPages([], 0);
return this.getSkills();
}
if (this.running) return this.getSkills();
this.running = true;
// The session this walk belongs to. A disconnect, a `destroy()`, or a
// reconnect on the same client advances it, and every write below is
// gated on it still being current — otherwise a slow walk's continuation
@ -160,6 +158,14 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
// previous session's skills into the next one.
const generation = this.generation;
const isCurrent = () => this.generation === generation;
// The overlap guard is per SESSION, not a bare boolean. A boolean would
// stay set while a walk from a dead session was still awaiting the server,
// so the reconnect's own load would return here as a no-op and never be
// retried — permanently, if that stale request never settles. Keying it on
// the generation lets a new session start immediately, and the `finally`
// below only clears the guard it actually set.
if (this.runningGeneration === generation) return this.getSkills();
this.runningGeneration = generation;
try {
const collected: SkillEntry[] = [];
const seen = new Set<string>();
@ -199,7 +205,11 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
}
throw err;
} finally {
this.running = false;
// Only the walk that set the guard may clear it: a stale session's
// `finally` must not release the guard a live session is holding.
if (this.runningGeneration === generation) {
this.runningGeneration = null;
}
}
}