Skip to content

aggregator(registry): non-array taskRefs passes isSessionRecord and crashes mergeRecords' spread — a string one silently splits into single-character refs #2408

Closed

Description

@dezren39

aggregator(registry): a non-array taskRefs passes isSessionRecord and crashes mergeRecords' spread — a string one silently splits into single-character refs

Bug

The session registry's hygiene check validates only three fields, but the merge spreads a fourth, unchecked field as an array:

  • pm-stack/aggregator/src/registry.ts:138-142 — isSessionRecord checks only that id, source, and label are strings. taskRefs may be any JSON value: a number, a boolean, null, a plain object, or a string.
  • pm-stack/aggregator/src/registry.ts:130 — when an incoming record shares a (source, id) key with a stored one, mergeRecords runs taskRefs: [...new Set([...prev.taskRefs, ...r.taskRefs])]. Spreading a non-iterable throws TypeError: Spread syntax requires ...iterable[Symbol.iterator] to be a function (or ...iterable not be null or undefined for null). Spreading a string does not throw — it silently turns "abc" into the refs ["a","b","c"], corrupting the session's task linkage.
  • The unchecked value reaches the merge from three directions, all filtered only by isSessionRecord / an id-only check: the durable store file (src/sources/live.ts:611, loadRegistryStore), the fleet feed's registry section (src/sources/live.ts:980), and Claude launch records (registry.ts:100 — parseLaunchRecords checks only that id is a string; registry.ts:88 — recordFromLaunch copies l.taskRefs ?? [] verbatim, so 42 passes through and only null/undefined are replaced).

registry() (src/sources/live.ts:976-1000) calls mergeIntoRegistry → mergeRecords (src/sources/live.ts:619) with no guard whenever any incoming record exists. /sessions and /api/registry.json await source.registry() uncaught (src/server.ts:543, src/server.ts:552), so the request fails; the facet-priming callers catch and silently substitute [] (src/server.ts:147, :160, :744), so the registry reads as empty there. Because the poisoned record sits in the durable store, every later live read that merges anything re-throws — the wedge is permanent until the store file is hand-repaired.

Evidence (verified this run, main 5a34fa7)

Sandbox Bun probe (/tmp/probe-laneA-taskrefs/probe.ts) importing the real registry.ts:

isSessionRecord(taskRefs=42) = true
isSessionRecord(taskRefs=null) = true
prev taskRefs=42 THROWS: TypeError: Spread syntax requires ...iterable[Symbol.iterator] to be a function
incoming taskRefs=null THROWS: TypeError: Spread syntax requires ...iterable not be null or undefined
prev taskRefs='abc' merged taskRefs = ["a","b","c"]
well-formed merge: OK

Why it matters

One malformed record — a hand-edited or torn store file, a collector/feed entry whose taskRefs is a number or a bare string instead of an array, a launch-record line with "taskRefs": 42 — permanently breaks the registry surface once any record with the same (source, id) is merged, and there is no signal about which record or field is responsible. The string form is worse in a way: it never crashes, it just invents single-character task refs that link the session to nothing real. This is the same validator/consumer mismatch family as #2376 (lastSeenAt crashing the merge sort, same file) and #2347 (comments/actions createdAt), but a different field, a different crash expression (spread at registry.ts:130, not localeCompare in the sort), and a silent-corruption mode neither of those has; #2376's acceptance criteria pin only lastSeenAt and would not fix this.

Acceptance criteria

  • A record with a missing or non-array taskRefs can no longer crash mergeRecords, and a string taskRefs can no longer be split into character refs: either isSessionRecord rejects/coerces it at both load sites (store + feed), or the merge treats a non-array value as [] — the triage pin records which layer owns the rule, applied consistently to recordFromLaunch/parseLaunchRecords 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) cover: mergeRecords merging a duplicate-key record whose stored or incoming taskRefs is a number, null, and a string; a store/feed load containing such a record; existing merge tests keep passing.
  • No behaviour change for well-formed records: the deduped union of refs 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)
    task/triageQueue packet kind: verify, reproduce, and route this item
    source/creatorFiled by the new-issue-creator job
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    effort/xsTrivial: expected under 30 minutes of work
    app/aggregatorPackage/app: aggregator (pm-stack in this repo)
    on Oct 29, 2026
  3. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator job (lane A, slug new-issue-creator-code-a)
    • Session / ID: cron new-issue-creator-code-a run 2026-10-29 14:05 CDT (19:05Z)
    • Found via: filed this run
    • Task: Filed this issue after verifying the crash/corruption against current main (5a34fa7) with a Bun probe importing the real registry.ts; labelled and enqueued a triage packet.
    • Expectation: effort/xs triage — pin which layer owns the taskRefs array rule (validator vs merge), then route a small_fix.
    • Links: triage packet enqueued to repo-health-code-pilot this run; evidence in the issue body.
  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. 24 remaining items

  6. added
    status/parkedDeliberately paused with a recorded resume condition
    on Nov 1, 2026
  7. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  8. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 1, 2026
  9. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up (resumed)

  10. added a commit that references this issue on Nov 1, 2026
  11. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done

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