Skip to content

XERK-518 [Qwen L]: peer roster + cross-session messaging (SEND + RECEIVE) - #552

Merged
xerhab merged 2 commits into
mainfrom
XERK-518
Aug 29, 2026
Merged

XERK-518 [Qwen L]: peer roster + cross-session messaging (SEND + RECEIVE)#552
xerhab merged 2 commits into
mainfrom
XERK-518

Conversation

@xerhab

@xerhab xerhab commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Summary

XERK-348 matched for the Qwen Code runtime — the dsh [L] (XERK-476) analogue. The peer ROSTER
was already runtime-independent (any running session lists, _launch_qwen already appends
PEERS_SYSTEM_PROMPT); this adds the MESSAGING, hub-routed both ways so the Claude-inbox
protocol and crossSessionInbound policy stay in one Python home.

The load-bearing difference from dsh: dsh is headless with a control socket, so its driver
pushes peer events over that socket. qwen is an interactive TUI with no control socket,
so both directions are file-rendezvous based and delivery to a qwen target is pane-based:

  • SEND (qwen → peer): agent/qwen/peer_mcp.py registers send_message({to, message}) via
    MCP (turma-peer, the same mechanism turma-ask uses for AskUserQuestion, since qwen has
    neither native tool). The call writes a request file and returns immediately
    (fire-and-forget, mirroring Claude Code's own SendMessage). The hub resolves the roster name
    against this host's running sessions and dispatches per target runtime: claude via
    _post_to_inbox (sender = the qwen session's own name, no INBOX_PREFIX — indistinguishable
    from a native peer), dsh via its control socket, qwen via _type_into_pane.
  • RECEIVE (native Claude peer → qwen): agent/qwen/peer_inbox.py forges a
    ~/.claude/sessions/<pid>.json record + binds cc-socks/<pid>.sock under its OWN live pid
    (required for Claude Code's SO_PEERCRED liveness check), run as a per-session background
    subprocess. Inbound is verified against the wire session_id, policy-checked
    (crossSessionInbound), then typed into the pane.
  • Both directions are drained off the beat by a polling worker
    (_qwen_peer_worker_loop, QWEN_PEER_POLL_SEC=2s), per the XERK-395 beat-loop budget contract
    — a Claude inbox post or a dsh socket write both block on a 5s-class ack. The two directions
    are told apart purely by a recv- filename prefix; every file is consumed once read whether
    or not delivery succeeded (best-effort, matching Claude's own SendMessage).
  • _dsh_peer_frame is widened to a shared _peer_frame (now used by both runtimes).
    QWEN_PEERS_ADDENDUM (twin of DSH_PEERS_ADDENDUM) corrects the peer directive for a runtime
    with neither SendMessage nor ListAgents.

Docs: .claude/rules/qwen-peer.md (new file — qwen.md had only ~200 chars of headroom under
its 40k ceiling, so this follows the repo's own split-by-path remedy rather than editing it,
same as qwen-migration.md/qwen-delegation.md).

QA

Full qa pass returned FAIL with three findings (one MEDIUM, two LOW), all fixed in a
follow-up commit and re-verified by a qa-delta pass driving the real fixed code (not just
reading the diff) — PASS, all three confirmed closed:

  1. MEDIUM — restart-orphaned subprocess. The peer-inbox forger is a bare Popen, not
    tmux-hosted. turma-agent.service runs KillMode=process so tmux/ttyd/dsh survive a
    manager restart for resume_on_boot's adopt path to reattach to — but that left the forger
    alive too with no adopt path, leaking one more live process + bound socket + registry record
    per restart, forever. Fixed by mirroring the existing ttydPid/_kill_ttyd pattern: the pid
    is now persisted on the session record and reaped on both start and teardown when it's an
    orphan. QA-delta confirmed the orphan is actually killed (os.kill(pid, 0)
    ProcessLookupError) and that the pid reaches ~/.turma/sessions.json the same way
    ttydPid does.
  2. LOW — atomic-write race. peer_mcp.py's tmp filename wasn't dot-prefixed, so the hub's
    poller (which skips dotfiles specifically to avoid reading a request mid-write) could
    occasionally read-and-delete a file before peer_mcp.py's own os.replace ran, reporting a
    spurious "write failed" for an already-delivered message. QA-delta reproduced 7/500 failures
    pre-fix under an aggressive real-filesystem race and 0/500 post-fix.
  3. LOW/cosmetic — stale dotfiles. A writer crashing mid-write left a dotfile forever;
    now swept once older than QWEN_PEER_POLL_SEC * 5, confirmed a fresh one survives one pass
    and a real write completes orders of magnitude under that threshold.

Full test suite: 2222 passed, one pre-existing failure unrelated to this branch
(test_qwen_guard.py::TestQwenGuardShimEndToEnd::test_replace_in_aws_is_denied — this host's
~/.aws is a symlink to a WSL mount and the guard's credential globs aren't realpath'd;
confirmed failing on clean HEAD before this branch existed).

Not independently verified end-to-end (host-proof only, the same footing every prior [Qwen *]
child shipped on): the RECEIVE leg's real delivery over Claude Code's private inbox-record
format, and the mcpServers key actually surfacing the tool to a real qwen model.
QWEN_ENABLED=False fleet-wide, so none of this is reachable in production yet.

Separately flagged (not in this PR)

While running the suite I hit a pre-existing, security-shaped bug unrelated to this change:
the qwen/dsh safety-guard shim realpaths a tool's target path but matches it against
non-realpath'd credential globs, so a symlinked credential-store directory (e.g. ~/.aws
on WSL) escapes the deny rule and a write is allowed. Confirmed on clean HEAD. I could not
file a Jira ticket for it this session (the Jira connector was down); flagging it here so it
isn't lost — happy to file it or open a fix PR on request.

Test plan

  • python3 -m pytest tests/ — 2222 passed, 1 pre-existing unrelated failure
  • qa full pass → FAIL → fixed → qa-delta → PASS (see above)
  • Syntax-checked all touched files

xerhab added 2 commits August 29, 2026 10:31
…IVE)

XERK-348 matched for qwen, the dsh [L] (XERK-476) analogue. The ROSTER half was
already runtime-independent and needed no code; this adds the MESSAGING,
hub-routed both ways so the Claude-inbox protocol and the crossSessionInbound
policy stay in one Python home.

The load-bearing difference from dsh: dsh is headless with a control socket, so
its driver PUSHES peer events and the hub delivers over that same socket. qwen
has neither, so both directions are FILE-RENDEZVOUS based (QWEN_PEER_DIR) and a
qwen target is delivered via its PANE:

- SEND: agent/qwen/peer_mcp.py registers send_message({to, message}) via MCP
  (mcpServers."turma-peer", the same mechanism [Qwen C] used for turma-ask,
  since qwen has no native SendMessage). A call writes a request file and
  returns immediately — fire-and-forget, mirroring Claude Code's own
  SendMessage. _deliver_qwen_peer_send resolves the roster name against this
  host's running sessions and dispatches on the target's runtime: claude via
  _post_to_inbox (sender = the qwen session's rcName, no INBOX_PREFIX), dsh via
  its control socket, qwen via _type_into_pane.

- RECEIVE: agent/qwen/peer_inbox.py forges a ~/.claude/sessions/<pid>.json
  record and binds cc-socks/<pid>.sock under its OWN live pid, so a native
  Claude peer's SendMessage lands. Run as a per-session subprocess because the
  pid must be a live process the registry's liveness/SO_PEERCRED checks accept
  — the single-pid manager cannot masquerade as N sessions. Inbound is verified
  against the wire session_id, then policy-checked (crossSessionInbound) before
  it is typed into the pane.

Both directions are drained off the beat by a polling worker
(_qwen_peer_worker_loop, XERK-395): a Claude inbox post and a dsh socket write
both block on a 5s-class ack, and a batch of those on the heartbeat could
approach OFFLINE_AFTER_MS. The two directions are distinguished by the "recv-"
filename prefix alone, and every file is consumed once read whether or not
delivery succeeded (a peer message is best-effort, like Claude's own).

Also: _dsh_peer_frame is widened to _peer_frame and shared by both runtimes, and
QWEN_PEERS_ADDENDUM corrects the directive for a runtime with neither
SendMessage nor ListAgents (the DSH_PEERS_ADDENDUM twin).

Docs land in .claude/rules/qwen-peer.md rather than qwen.md, which is 212 chars
under its 40k ceiling — the same split-by-path remedy [Qwen J]/[Qwen K] used.

Tests: TestQwenPeerMessaging + the peer cases in TestLaunchQwen
(test_hub_agent.py), and test_qwen_peer.py (the MCP JSON-RPC contract, the
request-file shape and caps, the forged record's pid/socket/filename agreement,
and inbound wire handling driven over a real UNIX socket). The RECEIVE leg's
real Claude delivery and the mcpServers key stay host-proof only, the footing
dsh [L] and [Qwen C] shipped on.
…race, stale dotfiles

qa reported FAIL on the peer-messaging change. Three fixes, all in the qwen
peer-messaging code this branch already touches:

- MEDIUM: the peer-inbox forger is a bare subprocess, not tmux-hosted.
  turma-agent.service runs KillMode=process so tmux/ttyd/dsh survive a manager
  restart for resume_on_boot's adopt path to reattach to — but that leaves the
  forger alive too, and unlike tmux/ttyd it had no adopt path: the old
  manager's in-memory qwen_peer_inboxes entry died with it, so every restart
  while a qwen session ran leaked one more live forger (process + bound
  cc-socks entry + live ~/.claude/sessions record), forever. Fixed by mirroring
  _launch_ttyd/_kill_ttyd's ttydPid pattern: the pid is now persisted on the
  session record, and both _start_qwen_peer_inbox (on every start, including
  the adopt path) and _stop_qwen_peer_inbox reap a persisted pid that isn't
  the one currently tracked.

- LOW: peer_mcp.py's atomic-write tmp name wasn't dot-prefixed, so a
  microscopic window let the hub's poller (which skips dotfiles to avoid
  reading a request mid-write) read-and-delete the file before peer_mcp.py's
  own os.replace ran — which then raised FileNotFoundError and reported
  "write failed" for a message that had, in fact, already been delivered.
  Dot-prefixed now, matching peer_inbox.py's own atomic write.

- LOW/cosmetic: a dotfile left by a writer that crashed between open() and
  os.replace() was never cleaned until the whole session tore down. Now swept
  once older than QWEN_PEER_POLL_SEC * 5 (a fresh one is left alone — it may
  still be in flight).

.claude/rules/qwen-peer.md updated to match. Tests added for all three:
TestLaunchQwen (pid persistence, restart-orphan reap on both start and stop),
TestQwenPeerMessaging (fresh-vs-stale dotfile sweep), test_qwen_peer.py (the
dot-prefixed tmp name). Full suite: 2222 passed, 1 pre-existing failure
unrelated to this branch (test_qwen_guard's ~/.aws symlink case, confirmed
failing on clean HEAD).
@xerhab
xerhab merged commit 4d1e125 into main Aug 29, 2026
4 checks passed
@xerhab
xerhab deleted the XERK-518 branch August 29, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant