Skip to content

channel actions omit join's name when deriving agent_id — named-but-unregistered sessions get 'No channel memberships' in the same session that joined #990

Description

@laynepenney

Symptom, witnessed twice in one day (2026-08-20)

Two agents in different workspaces, independently, same sequence over the MCP recall_channel tool:

  1. join(channel="dev", name="Atlas")Joined #dev as Atlas (a_ee2af0db) — acknowledged, membership written.
  2. unread(channel="dev", name="Atlas") 10 milliseconds later, same sessionNo channel memberships -- join a channel first.

The exact-transcript witness (timestamps from the MCP end events): unread at 09:43:14.686Z failed, join at 09:43:20.455Z succeeded naming the id, unread at 09:43:20.465Z failed again. A subsequent read succeeded (channel reads are log-path-based and don't consult memberships) and later post calls succeeded and attributed correctly. The second witness reported the identical join-ack-then-no-memberships shape in a different workspace with a different display name.

Mechanism — every link read from source, and the id reproduced from the formula

Function names are the anchors here; line numbers are deliberately omitted because they drift (this issue's first draft cited them and they were already stale against dev by filing time).

_agent_id (channel.py) resolves in three branches: SYNAPT_AGENT_ID env → name-derived "a_" + sha256(f"named:{griptree}:{name}")[:8] → session-scoped "s_" + sha256(f"{griptree}:{data_dir}:{ppid}")[:8].

  • channel_join (channel.py) derives with the name: _agent_id(project_dir, name=display_name). The membership row is written under the name-derived id.
  • channel_unread and channel_unread_read (channel.py) derive without it: _agent_id(project_dir).
  • The MCP layer accepts name on every action and the unread handler drops it: _handle_unread (actions.py) calls channel_unread_read(limit=…, show_pins=…, detail=…) — no identity argument at all.

So for a session with no SYNAPT_AGENT_ID, join writes the membership under the a_* id and unread queries memberships WHERE agent_id = <s_* session hash>: zero rows, deterministically, forever. Not a race, not staleness — two different pure functions of the same inputs.

Derivation proof: the id the witnessed join returned is exactly what _agent_id's named branch produces from that session's workspace directory name and display name — computed independently from the formula and compared against the transcript above, byte-identical. The formula is reader-checkable on neutral inputs: "a_" + sha256("named:my-workspace:Alice").hexdigest()[:8] = a_300f4ee0. Formula, salt, and truncation are exactly _agent_id's named branch.

Why most sessions never see it: any session with SYNAPT_AGENT_ID set (e.g. spawned via gr) short-circuits both derivations to the same registered id at resolution step 1, and a session that never passes name= gets the same s_* hash on both sides. The defect bites exactly the named-but-unregistered sessions — which includes every external user who passes name= to feel like a person instead of a hash.

This is a class, not an unread bug — sweep derived by grep over the handlers

Of the 22 action handlers in actions.py, exactly 4 thread identity through (join, post, directive, broadcast). The other 18 derive the nameless id — which diverges from join's id precisely in the named-but-unregistered case above. The identity-DEPENDENT members of those 18, each a live defect for such a session:

Action Consequence under the wrong id
unread the witnessed failure: membership invisible in the same session that joined
heartbeat refreshes a phantom s_* presence row while the real a_* row goes stale toward reaping — an agent heartbeating correctly still gets marked offline
leave removes membership for the wrong id — you cannot leave a channel you joined with a name
claim / unclaim / intent claims attributed to an id that matches neither your join nor your posts; unclaim breaks across sessions because the s_* hash includes the parent pid
rename renames the wrong row
read succeeds (no membership gate) but advances cursors under the s_* id, so read-state diverges from the identity your posts carry

The remaining droppers (who, list, search, pin/unpin, board, mute/unmute/kick, read_message) are read-only or target-other and are listed for completeness of the sweep, not as defects.

Relation to prior issues (all checked before filing)

Fix directions (implementer's ruling, both stated)

  1. Mechanical: thread name through all identity-dependent handlers so every action derives the id join derived. Small, but it preserves per-call derivation — the class regenerates the next time a handler is added without the thread.
  2. Structural: resolve identity once per session (at first action or server init) and stop deriving per-call from optional parameters. Kills the class: a handler cannot drop what it never had to carry. Consistent with the direction of moving resolution server-side.

Either way, the fix carries discriminating witnesses per the witness's own ask: a join → unread pair asserting the SAME resolved id on both sides (with the resolved id recorded in the assertion, not just the outcome), a heartbeat witness asserting the refreshed presence row is the joined row, and a control where SYNAPT_AGENT_ID is set proving the registered path never diverged.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions