fix(rpc): require attachment for session mutations; add attach_session, release_attachment, reclaim_session
Part of #3795
Release gate (BLOCKING, lead ruling 2026-10-23)
Before any senpi release that ships this PR, and before omo adopts that senpi, a real-host acceptance run must PASS: the omo client (oh-my-openagent#10712) against this PR's host, in BOTH grace mode and SENPI_RPC_REQUIRE_ATTACH=1, covering the thread tools and the gateway mutations. Tracked in code-yeongyu/oh-my-openagent#10995.
Summary
- Add live-only, identity-fenced
attach_session, one-holdrelease_attachmentwith disconnect semantics, and atomicreclaim_session, advertised as theattach_session,release_attachment,release_caller_awareandreclaim_sessioncapabilities. - Make
release_sessionexclude every hold owned by the caller while preservingforcefor attachments held by other connections. - Gate socket-host session mutations before in-process and worker routing. This release serves legacy violations with exactly one stderr line;
SENPI_RPC_REQUIRE_ATTACH=1enforces immediately. - Keep existing
close_sessionandset_session_contextrefusals, stdio behavior, and host-internal calls unchanged. - Update the RPC reference, the fork tracker, and the Unreleased changelog.
Rules as they now stand
Wire contract v1.1 (lead ruling 2026-10-22, senpi#3795). The attachment commands follow wire contract v1 with one addition: the error code session_not_attached. On the attachment commands it carries errorData { reason } only, never an empty object; only the gate refusal adds action.
| Command | Refusals |
|---|---|
attach_session | unknown_session, session_identity_mismatch, session_busy_lifecycle with { reason: "host_draining" }, session_not_attached with { reason: "no_connection" } |
release_attachment | session_not_attached with { reason: "not_attached" } (the caller holds no attachment on that session) |
reclaim_session | unknown_session, session_identity_mismatch, session_busy_lifecycle (with { reason: "host_draining" } while draining), attached_elsewhere (errorData.attachments), session_not_attached with { reason: "no_connection" } |
| any gated command | session_not_attached with errorData { reason, action } (enforcing hosts only) |
Host draining is not a new code. While the host drains or shuts down, attach_session and reclaim_session both refuse before admission with session_busy_lifecycle and errorData: { reason: "host_draining" }. Nothing is attached, the entry is unchanged, and the refusal is transient: retry against the successor.
Unattended opens count as attachments (design v7.1). An open_session { attended: false } hold is an attachment like any other open, so its connection passes the gate until its own close_session or disconnect gives the hold back. The unattended-hold semantics themselves (holds, pausing, the parked reopen) are unchanged. senpi host session therefore sends no extra attach_session after its parked reopen; that CLI code is back to main.
resume_session requires attachment. It releases the session's engine holds and starts the held turns, so it is gated like every other mutation.
Every caller hold is the caller's own. unload_session and release_session share one count of other attached clients that excludes all of the caller's holds, so a connection that holds a session twice never refuses its own unload.
The contract test (test/suite/rpc-attachment-wire-schema.ts) is an exact, independent transcription of wire contract v1.1. It rejects any code or reason outside it, including host_draining as a code.
Latest local checks at 1209c4fe05 (clean env -i, short TMPDIR): bun run check exit 0; changelog gate PASS; git diff --check empty; router/gate and attachment suites 19 files, 69/69; senpi#3678 17/17; senpi#3811 15/15; senpi#3859 7/7. RED for the gating at f21614f4ab: 4 tests failed on the ungated source (the unattached record_user_activity was served, and the exempt set still listed it). RED for LOW 2 and 3 at a5c2d2fb68: the forged id wrote 2 lines, and a stopping host answered deferred: true.
Design delta v7.2: record_user_activity requires an attachment
Outcome: gated, by lead ruling 2026-10-22 (senpi#3814), superseding an earlier ruling that kept it ungated. record_user_activity (senpi#3859) is not on the attach gate's exempt list; an unattached connection is refused with session_not_attached under enforcement and logged once in grace, like every other session command.
Why: the earlier ruling assumed the desktop sends it unattached. It does not. Its only known caller, the desktop prewarm path (omo-desktop-app apps/server/src/provider/Layers/OmoAdapter.ts:7121 at origin/main d86e4f76e8), sends it through the handle built from the session's own open_session reply on the same shared connection (OmoSessionHandle.ts:397-403), so the call arrives attached and gating it costs that caller nothing. Gating keeps the design's rule intact: session commands require attachment, with no exceptions the design doesn't list.
The outcome is recorded in docs/rpc.md, the rpc changes.md, and a v7.2 note in section 3b of the binding design copy (the design document the lane keeps with its evidence). ATTACH_GATE_EXEMPT is exported and pinned by an exact-set test without record_user_activity. The tests name the caller: "serves desktop prewarm draft activity on its attached opener connection" and "refuses an unattached record_user_activity under enforcement, like every other session mutation".
Review notes (d92c33d899)
- LOW 2, log forging (fixed): the grace line escapes the client-chosen sessionId as one JSON string token, so an id holding a newline writes exactly one line.
- LOW 3, one draining test (fixed):
attach_session,reclaim_sessionandrelease_attachmentsharehostStopping(draining, or a drain exit already requested). A stopping host no longer defers arelease_attachmentthe drain already owns. - LOW 4, unload counts all caller holds: intended and documented.
unload_sessionandrelease_sessionshare one count of other attached clients that excludes every caller hold;host-session-park.test.tsandrpc-attachment-caller-holds.test.tscover it. - LOW 5, split size: the router and RPC types are split by responsibility. senpi#3811's per-connection role binding was re-applied at its three moved sites and is proven by
issue-3811-split-router-roles.test.ts(each site, when removed, fails it); the full RPC/host cluster passes.
Intended existing-test contract change
The assertion formerly at packages/coding-agent/test/rpc-socket-host.test.ts:468 pinned an unattached foreign prompt succeeding silently. It now pins the release-N grace contract instead: the prompt is still served, and the host emits exactly:
senpi rpc host: prompt on <session> from a connection not attached to it (refused from the next release)
The focused gate suite separately proves that SENPI_RPC_REQUIRE_ATTACH=1 refuses the same operation with errorCode: "session_not_attached".
Notes
release_attachmentuses the same deferred final-release path as a dropped connection, so releasing a temporary hold cannot abort another client's active turn.reclaim_sessionperforms path, durable identity, host identity, lifecycle, and foreign-attachment checks; records its attachment; claims all caller holds; and marks closing before its first await.- Touched oversized protocol/router/test modules were split by responsibility.
session-event-writer.tsandrpc-client.tsremain pre-existing oversized modules; this PR changes them by a few lines each.
Evidence
Final head evidence
Head 1209c4fe05c2fbcb74f97145b14419f0b3729888 (gating RED f21614f4ab, fix 1209c4fe05; merges main at 2d07c36f2e). Static checks and focused suites below were run at this head; earlier evidence lines name the head they ran at.
RED at the pre-fix source (tests run in this checkout before any source edit):
host-session-park > unloads a parked session through the CLI
{"outcome":"refused","reason":"attached","detail":"1 other client(s) attached"} exitCode 3, expected 0
rpc-attachment-caller-holds > two caller holds: outcome refused, "1 other client(s) attached"
rpc-attachment-caller-holds > one other connection: detail "2 other client(s) attached", expected 1
rpc-attachment-caller-holds > names why ...: errorData {} where { reason: "no_connection" } was expected
rpc-attachment-wire-contract > attach-unowned rejected by the v1.1 schema (errorData {})
Focused tests at the fix (local, CI=1, clean env -i): host-session-park, rpc-attachment-caller-holds, rpc-attachment-wire-contract, rpc-release-attachment, rpc-caller-release and host-session-command pass. The spawn-heavy suites failed locally only on host spawn/readiness deadlines under heavy machine load, before any assertion; the full receipt at this head covers them.
Statics (at 7e1ba2960b): tsc --noEmit exit 0, tsc --noEmit -p scripts/tsconfig.json exit 0, bun run check exit 0 (Biome checked 6409 files, no fixes); changelog-gate: PASS - changes.md coverage complete (1 production path(s) covered); git diff --check origin/main...HEAD empty.
Real-CLI QA (.agents/skills/senpi-qa/scripts/attachment-two-client-qa.mjs at 3275a18319, isolated sandbox under env -i with a deep TMPDIR run root, two forked CLI client processes on one CLI host, then senpi host session unload --socket on a parked session). The host socket now lives in a short /tmp/aq-* dir: under a deep run root it reached 122 bytes, past the 103-byte macOS limit, which failed an earlier receipt run in the harness, not the product.
PASS two real CLI client processes: attach=2 release=3 reclaim=2; B remains open/attached; stray close/end=0; CLI parked unload exit=0 outcome=unloaded
summary.json: {"pass":true,"clients":2,"hosts":1,"attach":2,"release":3,"reclaim":2,"parkedCliUnload":"unloaded","cliUnloadExit":0,"authUnchanged":true,...}
5f9aed4c0c5e1d27601c953e1a582b46f05e179e4d6ca36af82a5c17878536d4 transcript.jsonl
9b1491ad7956e46fb7f4757c4d3e72a43c9fb0db181c0ae31c87f9e28cdfac8c host.stderr.log
a759894a7437f3c704400f28c121afc9957f59487f6358002d5bb46e03a7d7cd cli-unload.json
925d800eada5e44912fa52247059779f668da6eeb6d53efcde50bbaad8352e52 summary.json
L2 rerun: rpc-socket-host-release > does not close or stop events for the surviving attachment passed 5 of 5 sequential runs at this head (5.8 s to 15.9 s each, CI=1, clean env -i); no race reproduced.
Worker isolation (rpc-worker-isolation > closes a spinning JavaScript worker without terminating its sibling). It failed once in an earlier full regression run at 4bdd91a058 on an RPC response deadline. The test itself uses fixed 3 s deadlines for get_state and close_session while a sibling worker spins. Run alone 3 times each, it passed 3/3 at this head (load 37-53) and 3/3 at origin/main f304e3ff22 (fresh install and build in a throwaway worktree, load 22-26). This PR does not touch the test, and neither side reproduces the failure, so it reads as load-sensitive rather than a regression.
Idle-exit tests (senpi#3392 flake list). Two idle-exit tests failed once each in a Linux-container senpi-full run at 3275a18319 (18395 passed, 2 failed). On macOS, run alone and alternating base and head under the same load (17-32), both passed 3/3 at the merge base fde8e55c6f (fresh install and build in a throwaway worktree) and 3/3 at the head:
| Test | Base (fde8e55c6f) | Head |
|---|---|---|
rpc-host-idle-exit-closed-turn "exits within the idle window once a interactive session closed mid-turn and every client left" | 3/3 pass | 3/3 pass |
rpc-host-status-observe "lets a host idle out on schedule while status --all polls it every second" | 3/3 pass | 3/3 pass |
No code was changed for them. This PR leaves idle occupancy alone: unattended holds count as on main, status --all stays an observe-only read, and the hold-count change touches only unload_session and release_session refusals. The Linux senpi-full rerun at the new head will show whether they recur.