Skip to content

aggregator(registry): non-string lastSeenAt passes isSessionRecord and crashes mergeRecords sort — /sessions and /api/registry.json fail once the registry grows #2376

Closed

Description

@dezren39

aggregator(registry): a non-string lastSeenAt passes isSessionRecord and crashes mergeRecords' sort — /sessions and /api/registry.json go down once the registry is big enough

Bug

The session registry's hygiene check validates only three fields, but the merge that every live registry read runs sorts on a fourth, unchecked field with a string-only method:

  • pm-stack/aggregator/src/registry.ts:138-142 — isSessionRecord checks only id, source, and label are strings. lastSeenAt (and startedAt) may be any JSON value: a number, a boolean, an object.
  • pm-stack/aggregator/src/registry.ts:134 — mergeRecords returns [...byKey.values()].sort((a, b) => (b.lastSeenAt ?? "").localeCompare(a.lastSeenAt ?? "")). localeCompare exists only on strings, so a record whose lastSeenAt is e.g. 12345 throws TypeError: (b.lastSeenAt ?? "").localeCompare is not a function whenever the sort compares it in that position.
  • The unchecked value reaches the merge from three directions, all filtered only by isSessionRecord: the durable store file (sources/live.ts:611, loadRegistryStore), the fleet feed's registry section (sources/live.ts:980), and Claude launch records (registry.ts:82, recordFromLaunch copies l.lastSeenAt verbatim; parseLaunchRecords checks only that id is a string).

registry() (sources/live.ts:976-1000) calls mergeIntoRegistry → mergeRecords (sources/live.ts:619) with no guard around it whenever any incoming record exists. The callers split: /sessions and /api/registry.json await source.registry() uncaught (server.ts:543, server.ts:552), so the request fails; the facet-priming callers catch and silently substitute [] (server.ts:147, :160, :744), so the registry reads as empty there.

Evidence (verified this run, main 5a34fa7)

Sandbox Bun probe (/tmp/probe-laneB-regsort/probe.ts, probe2.ts) importing the real registry.ts:

isSessionRecord(good)= true
isSessionRecord(badNum)= true      (lastSeenAt: 12345)
isSessionRecord(badBool)= true     (lastSeenAt: true)
good+missing OK                    (lastSeenAt absent is fine: ?? "" )
big THROWS TypeError (b.lastSeenAt ?? "").localeCompare is not a function
rev THROWS TypeError (same, reversed input order)

The latency is part of the bug: with only two records Bun's sort happened to compare in the direction that coerces the number as the localeCompare argument, so a small registry can accept — and mergeIntoRegistry can persist (sources/live.ts:627-631) — a poisoned record without error. The same record throws reliably once the registry holds several records (8-record probe, both input orders). The durable store therefore accumulates the trigger silently and the failure switches on later, on an unrelated read, once enough sessions exist.

Why it matters

One malformed record — a hand-edited or torn store file, a collector/feed entry with a numeric epoch instead of an ISO string, a launch-record line with a numeric lastSeenAt — permanently breaks the registry surface: every live /sessions and /api/registry.json request fails while that record is present, and there is no signal about which record or field is responsible. The neighbouring stores learned this exact lesson in #2347 (comments/actions: load filter checked only id, all() sorted via createdAt.localeCompare); the registry's filter is the same shape with a different field. This is distinct from #1560 (same file: sparse incoming records wipe fields via undefined spread — a data-loss semantics bug, no type check or crash involved) and its acceptance criteria do not cover this.

Acceptance criteria

  • A record with a missing or non-string lastSeenAt can no longer crash mergeRecords: either isSessionRecord rejects/coerces it at both load sites (store + feed), or the sort (and the >= newest-comparison at registry.ts:131) treats a non-string value as absent — the triage pin records which layer owns the rule, applied consistently to recordFromLaunch's unchecked copy as well.
  • A poisoned record already in the durable store does not wedge the registry: /sessions and /api/registry.json keep serving the remaining records.
  • Regression tests (aggregator suite, e.g. test/slice3.test.ts) cover: mergeRecords over several records including one with numeric and one with boolean lastSeenAt, and a store/feed load containing such a record; existing merge tests keep passing.
  • No behaviour change for well-formed records: newest-lastSeenAt ordering and the merge special cases from aggregator: registry merge wipes a session's model/title/directory when a later sparse record omits them — the durable store forgets what it knew #1560's contract are preserved.

Related

Activity

  1. developing-today commented on Oct 29, 2026

    @developing-today
    Contributor
  2. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    effort/xsTrivial: expected under 30 minutes of work
    on Oct 29, 2026
  3. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  4. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  5. 9 remaining items

  6. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  7. added
    queue/needs-reviewQueue task finished and is awaiting review
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 1, 2026
  8. added a commit that references this issue on Nov 1, 2026
  9. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

    Done. isSessionRecord now requires lastSeenAt/startedAt to be string-or-absent (rejection, not coercion), and parseLaunchRecords routes its output through the same filter. New test/registry-hygiene-2376.test.ts: poisoned records (numeric/boolean/object) dropped on the store/feed filter path and the launch path, survivors merge/sort unchanged. Verified on the laptop: 46/46 tests pass (6 new + slice3 + registry-view), tsc clean for touched files, Codex review completed with no findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/aggregatorPackage/app: aggregator (pm-stack in this repo)effort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobstatus:donetask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions