Conversation
…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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_qwenalready appendsPEERS_SYSTEM_PROMPT); this adds the MESSAGING, hub-routed both ways so the Claude-inboxprotocol and
crossSessionInboundpolicy 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:
agent/qwen/peer_mcp.pyregisterssend_message({to, message})viaMCP (
turma-peer, the same mechanismturma-askuses for AskUserQuestion, since qwen hasneither 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, noINBOX_PREFIX— indistinguishablefrom a native peer), dsh via its control socket, qwen via
_type_into_pane.agent/qwen/peer_inbox.pyforges a~/.claude/sessions/<pid>.jsonrecord + bindscc-socks/<pid>.sockunder its OWN live pid(required for Claude Code's
SO_PEERCREDliveness check), run as a per-session backgroundsubprocess. Inbound is verified against the wire
session_id, policy-checked(
crossSessionInbound), then typed into the pane.(
_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 whetheror not delivery succeeded (best-effort, matching Claude's own SendMessage).
_dsh_peer_frameis widened to a shared_peer_frame(now used by both runtimes).QWEN_PEERS_ADDENDUM(twin ofDSH_PEERS_ADDENDUM) corrects the peer directive for a runtimewith neither
SendMessagenorListAgents.Docs:
.claude/rules/qwen-peer.md(new file —qwen.mdhad only ~200 chars of headroom underits 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
qapass returned FAIL with three findings (one MEDIUM, two LOW), all fixed in afollow-up commit and re-verified by a
qa-deltapass driving the real fixed code (not justreading the diff) — PASS, all three confirmed closed:
Popen, nottmux-hosted.
turma-agent.servicerunsKillMode=processso tmux/ttyd/dsh survive amanager restart for
resume_on_boot's adopt path to reattach to — but that left the forgeralive too with no adopt path, leaking one more live process + bound socket + registry record
per restart, forever. Fixed by mirroring the existing
ttydPid/_kill_ttydpattern: the pidis 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.jsonthe same wayttydPiddoes.peer_mcp.py's tmp filename wasn't dot-prefixed, so the hub'spoller (which skips dotfiles specifically to avoid reading a request mid-write) could
occasionally read-and-delete a file before
peer_mcp.py's ownos.replaceran, reporting aspurious "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.
now swept once older than
QWEN_PEER_POLL_SEC * 5, confirmed a fresh one survives one passand 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~/.awsis a symlink to a WSL mount and the guard's credential globs aren't realpath'd;confirmed failing on clean
HEADbefore 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
mcpServerskey actually surfacing the tool to a real qwen model.QWEN_ENABLED=Falsefleet-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.
~/.awson WSL) escapes the deny rule and a write is allowed. Confirmed on clean
HEAD. I could notfile 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 failureqafull pass → FAIL → fixed →qa-delta→ PASS (see above)