Renames the written-usage rule into practice across the contract surface: docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files, taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the .opencode/agents/ prompts. Every replacement is a sense-preserving rewrite (colons, semicolons, commas, parentheses, sentence splits, varied), never a mechanical swap; no spaced hyphens were substituted. Enum fields, frontmatter, tables, wire data, and cited identifiers survive verbatim; en dashes inside numeric ranges keep their meaning. Verification: taskgraph validate (43 tasks), repo grep clean (single exempt hit: the verification command's own search pattern in AGENTS.md), cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
9.3 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | |
|---|---|---|---|---|---|---|---|---|
| w4-git-exit-close | Wave 4: git row hardening (status-before-close by construction, wave-3 gate booked item 1) | completed |
|
narrow | medium | component | implementation |
Description
Close the wave-3 gate's only structural booked finding
(review-wave-3 §Booked findings item 1): the git row's
status-before-close is honored by scheduling, not by construction.
Today serve_duplex_resolved splits the channel stream internally and
its session halves drop when the serve returns, so russh's
ChannelCloseOnDrop rx-half emits a server→client CHANNEL_CLOSE
inside the seam call, before serve_git_row's explicit
exit_status_request. The ordering currently holds (the task's
continuation queues the report in the same poll, ahead of the drop's
send) and every wire row passes, but a full Sender<Msg> buffer at
the serve tail could order the implicit close first and silently drop
the report (russh no-ops exit_status_request for an already-closed
channel), a silent exit-report loss on a published row.
The fix wants a seam/shape change (decide and record which):
- split the channel halves in
serve_git_rowand pass them into the seam (the report then rides a half the seam never closes), or - queue the exit report while the stream is alive (the report's send ordered before any close-contracting drop by construction), or
- another shape that makes the ordering structural rather than scheduler-luck; the gate's reasoning and the two named options are the starting points, not the bounds.
Constraints: every existing git wire row stays green (no behavior
change on the happy path, since this is ordering hardening, not
semantics
change); the spawn-and-return constraint (ADR-006 decision 4) holds,
because the reporting still rides the spawned task; the tty rows are NOT in
this class (their report precedes any close-contracting drop in one
task, which the gate verified; don't touch them). New wire row(s) pinning
the construction: a saturated Sender<Msg> buffer at the serve tail
(the failure scenario made real) still delivers the exit report,
the by-construction property proven on the wire, not by reasoning.
Acceptance Criteria
- The shape decided + recorded (module docs carry the reasoning); the git row's status-before-close holds by construction, not scheduling
- Every existing git wire row green (no happy-path behavior change); the spawn-and-return constraint intact
- New wire row(s): the saturated-buffer scenario delivers the exit report (the by-construction property pinned)
cargo test(+ per-feature +--all-features), clippy ×2 (-D warnings), fmt,cargo doc --no-depspass
References
- tasks/implementation/review-wave-3.md §Booked findings item 1 (the full finding + the two named fix shapes)
- docs/architecture/mapping.md (the git exec row, exit-status both directions), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (decision 4, spawn-and-return)
- src/git_map.rs, src/serve.rs (
report_exit_status_then_close, the wave-2 reporting mechanics this hardening composes with), alkgit 0.1.0serve_duplex_resolved(the seam whose half-ownership is the question)
Notes
The shape decided (2026-10-29)
Went with the first named option, splitting the channel halves in
serve_git_row and passing them into the seam, refined by one russh
0.65 reality: russh::channels::io::{ChannelRx, ChannelTx} are
private at this pin, so the split-halves duplex is rebuilt from the
public API in src/git_map.rs:
- The reader is
git_map's own [ChannelHalveReader]: the ownedChannelReadHalf(channel.split()), apoll_readstate machine whose observation semantics repin russh's privateChannelRxwithout theChannelCloseOnDropwrapper, soDatafills (partial reads buffered),ExtendedData/other non-data messages swallowed and re-read (the_-arm parity),Eofends the stream, the peer'sCloseleaves the reader to the channel's sender-death EOF (wait()→None). Thewait()access is future-owned: each read round-trips the half through a boxed future that parks it insideChannelReadHalf::waitand hands it back with the observed message, fully owned ('static-usable, the seam'sTbound) across every park/buffer/drop state, no extra task, and noDroppath ever messages the wire. - The writer is the write half's
ChannelTxviamake_writer_ext(None). Its value is'static-free of the half (clones only) but the Rust 2024 capture rules give the opaque a phantom of the half's borrow; a trait-object erasure (Box<dyn AsyncWrite + Unpin + Send>) drops the phantom, leaving the seam's'staticbound satisfiable without parking the half.poll_shutdownstill sends EOF (exactlyinto_stream's).
The result: nothing the seam drops ever messages the wire, with the
report's exit_status_request send is the sole close-contracting
sender, and its queue position ahead of the explicit close holds by
construction (sequential awaited sends on the one Handle),
saturated buffer or not. The hazard's premise (russh's
ChannelCloseOnDrop Drop spawning a CHANNEL_CLOSE send that can
win a queue position on the bounded Sender<Msg>
(event_buffer_size, default 10) before the report's, is removed,
not raced.
The wire row (the by-construction property pinned)
tests/git_mapping.rs::backlogged_seam_tail_still_reports_exit_status_before_close,
the finding's failure scenario made real on the wire: the serve's
answer volume (300 one-pkt-line ref answers) exceeds the entire
outbound pipeline via the new client-direction data gate
(tests/common/harness.rs::DataGate + the harness handler's data
hook, which russh's client loop awaits inline): the armed gate freezes
the client's loop → the transport duplex fills (DUPLEX_CAPACITY) →
the server's flush pends → russh's bounded message queue fills → the
serve's remaining answer writes pend (the serve tail blocked). The
gate's release then drives the serve tail through the just-unwinding
contention, and the row pins: the whole backlogged answer drains
through the same queue the report rides, then exit-status(0), then
close; the order is asserted, the exit code asserted.
The row falsifies the old shape. With a temporary one-line revert
of serve_git_row to channel.into_stream() the row FAILED
15/15 runs, as the implicit close won the frozen-drain queue race
("expected exit-status(0), got Some(Close)"), the silent report loss
the gate booked. With the split-halves shape: 10/10 green. So the
row is a two-sided pin: deterministic red under the hazard, green by
construction without it.
Test-vehicle additions (test surface, freely versioned): the doubles'
per-repo serve gate (tests/common/git_doubles.rs: install_gate, a
race-safe double-check MemGate, gating GitRefs::list_refs, so the
row schedules the serve's stages against prepared wire state) and the
harness's DataGate + drive_with_data_gate (the drive* bodies
factored into the shared mount_and_connect core; existing wrappers'
signatures unchanged).
Constraints check
- Every existing git wire row green, with the full git suite
(
tests/git_mapping.rs, 12 rows) and every other loopback suite: no behavior change on the happy path (the wire order the rows pin, i.e. data, exit-status, close, is identical under both shapes when the race doesn't fire). - Spawn-and-return intact (ADR-006 decision 4): the reporting
still rides the spawned serving task through the cloned
Session::handle();serve.rs's mechanics untouched. - The tty rows untouched (not in this class; the gate verified that).
- No API/limits change:
event_buffer_sizestays russh's default posture (the adapter docs' recorded default posture preserved); the row pins the default's value with a note that a russh default change re-reads the row's freeze-settle yields, not its assertions.
Summary
Completed. The git row's status-before-close now holds by
construction: serve_git_row no longer hands russh's
close-on-drop ChannelStream (into_stream()) to the seam; the
seam rides a split-halves duplex (git_channel_duplex →
ChannelHalveReader over the owned read half + the write half's
ChannelTx via the capture-erased trait object) where no drop
path ever messages the wire, making the report's send the sole
close-contracting sender and the report-before-close order
unconditional (russh 0.65's private ChannelRx/ChannelTx noted;
the semantics repinned from the public API). New wire row pins the
by-construction property on a genuinely saturated tail (volume flood
→ frozen client loop → duplex → bounded message queue; gate release
→ the tail through the unwinding contention), and it also falsifies
the old shape deterministically (15/15 red under a temporary
into_stream() revert, 10/10 green with the fix). Doubles gained a
race-safe per-repo serve gate; the harness gained the client-side
data gate + drive_with_data_gate (the drive* cores factored,
signatures unchanged). Verification: cargo test (default),
--all-features, and 6 mixed per-feature builds all green; clippy ×2
-D warnings clean; fmt clean; cargo doc --no-deps warning-free;
taskgraph validate clean (35 tasks).