Files
alknet/docs/architecture/decisions/054-local-tty-backend-sibling-crate.md
T
glm-5.2 e4e6ccfb14 docs(arch): alknet-tty Phase 1 specs — wire format, backend trait, local backend, exit-chunk ordering
Grounded in the alknet-docker POC (seed codec) and the alknet-tty POC
(2026-07-05, validated the control channel, the local-PTY blocking→async
bridge, and the signal-delivery contract).

Four ADRs:
- ADR-052: wire format — alknet/tty ALPN, two-carriage (JSON negotiation
  then raw chunks), fixed channel set 0-3, control as JSON, negotiation-
  error framing disambiguation
- ADR-053: TtyBackend trait + TtyHandle — the backend inversion point;
  exit_code as a Future; REQ-TTY-01 (backends need not be natively async)
- ADR-054: local backend as alknet-tty-local sibling crate behind a
  feature re-export; PTY vs pipe per-session; runner pattern preserved
- ADR-055: exit code on a stream_type 3 control chunk; "exit chunk is
  last" invariant; adapter owns the ordering

Five component specs (crates/tty/): README, overview, tty-wire, tty-
backend, tty-adapter, tty-local. The docker/SSH backends are future
crates (out of scope); the trait shape is committed so they can be
built against it.

Five OQs (OQ-43…47): two resolved (TtyControl Clone, stdin closure),
two deferred(scope) (terminal modes, runner API surface), one open
low-risk (flow control).

Zero critical issues from a general-subagent architecture review;
warnings addressed (inline rationale trimmed to ADR refs, TtyError and
StdinCmd defined, framing disambiguation documented, missing ADRs added
to index tables).
2026-07-06 15:05:36 +00:00

206 lines
9.6 KiB
Markdown

# ADR-054: Local TTY Backend as a Sibling Crate (`alknet-tty-local`)
## Status
Accepted
## Context
The alknet-tty research (`docs/research/alknet-tty/phase-0-findings.md`
DP-1) posed the placement question for the local-process backend
(`std::process::Command` with piped stdio, or `portable_pty` for a real
PTY):
- **(a) In alknet-tty**: the crate ships with the local backend built-in.
Pro: zero-config runner, one crate gets a terminal/process-streaming
endpoint. Con: alknet-tty pulls in `portable_pty` even for deployments
that only use docker/ssh backends.
- **(b) In a sibling crate (`alknet-tty-local`)**: alknet-tty defines the
trait (ADR-053); the local backend is a separate crate. Pro:
alknet-tty stays dependency-light; consumers opt into the local
backend explicitly. Con: one extra crate for the common case.
The local backend is the simplest backend and the one that enables the
runner pattern (a process whose stdin/stdout/stderr/exit-code stream over
a framed bidi connection — the same shape as GitHub/Gitea Actions runners,
just over alknet's transport instead of HTTP polling). It has no heavy
dependencies in the pipe case — just `std` — but the PTY case pulls in
`portable_pty` (a non-trivial native dependency that builds on Unix
`openpty`/`ioctl` and Windows ConPTY).
The relevant constraint from ADR-053: alknet-tty itself depends only on
alknet-core and the wire-format codec (ADR-052). The `portable_pty`
dependency does not belong in the core tty crate — a docker-only
deployment or an SSH-PTY-only deployment should not pull in PTY allocation
code. This is the same inversion as `OperationAdapter` (ADR-017): the
trait lives where the types live; the implementations live where their
transport dependencies live.
### The feature-re-export compromise
The research recommended (b) sibling crate **behind a feature flag on
alknet-tty** for the common case (`features = ["local"]` → re-export from
`alknet-tty-local`). This keeps alknet-tty's default dependency surface
minimal while making the local backend a one-feature opt-in. The
`portable_pty` dependency lives in `alknet-tty-local`; alknet-tty itself
never depends on `portable_pty`.
## Decision
### 1. The local backend is a sibling crate, `alknet-tty-local`
`alknet-tty-local` implements `TtyBackend` for `LocalTtyBackend` (ADR-053),
backed by `portable_pty` for the PTY case and `std::process::Command` with
`Stdio::piped()` for the pipe/runner case. It depends on alknet-tty (for
the `TtyBackend`/`TtyHandle`/`TtyControl` traits) and on `portable_pty`
for the PTY case.
```
alknet-tty-local
├── alknet-tty (TtyBackend trait, TtyHandle, TtyControl, wire types)
├── alknet-core (Connection — via alknet-tty's re-export; not direct)
├── portable_pty (PTY allocation — the heavy dep, here not in alknet-tty)
└── libc (signal forwarding — REQ-TTY-02, Unix only)
```
### 2. `alknet-tty` re-exports the local backend behind a `local` feature
```toml
# alknet-tty Cargo.toml
[features]
default = []
local = ["dep:alknet-tty-local"] # re-export LocalTtyBackend
[dependencies]
alknet-tty-local = { path = "../alknet-tty-local", optional = true }
```
A consumer that wants the local backend enables `features = ["local"]`
on alknet-tty and gets `alknet_tty::local::LocalTtyBackend` re-exported
from `alknet-tty-local`. A consumer that only wants docker/ssh backends
uses the default features and depends on `alknet-tty-docker` /
`alknet-tty-ssh` (or their own backend crate) directly — no
`portable_pty` in the dependency tree.
This is the same feature-re-export pattern the Rust ecosystem uses for
optional heavy dependencies (e.g., `tokio`'s `full` feature pulling in
`tokio-util`, `h2`, etc.). The seam is the `TtyBackend` trait; extraction
is cheap because the trait is the contract.
### 3. PTY vs pipe is a per-session choice, not a per-deployment choice
`TtyParams.terminal: Option<TerminalParams>` (ADR-053) selects the mode:
- **`terminal: Some(TerminalParams { ... })`** — allocate a real PTY
via `portable_pty`. Terminal semantics: resize (via
`ioctl(TIOCSWINSZ)`), signal delivery to the foreground process group
(via `libc::kill(-pgid, sig)`, REQ-TTY-02), escape-sequence handling
(the kernel PTY's line discipline). stdout and stderr are merged
(kernel PTY property — one output stream from the slave), so
`TtyHandle.stderr` is `None`.
- **`terminal: None`** — pipe mode, no PTY. `std::process::Command` with
`Stdio::piped()` for stdin/stdout/stderr. No resize, no
escape-sequence handling, but `kill(pid, sig)` still works for signal
forwarding. stdout and stderr are separate streams, so
`TtyHandle.stderr` is `Some`. This is the **runner** case — a
command-streaming endpoint with no terminal semantics.
The same `LocalTtyBackend` serves both; the `allocate()` call branches on
`params.terminal`. A deployment that only does terminals always sends
`Some`; a deployment that only does runners always sends `None`; a
deployment that does both (a hub that runs agents in PTYs and runs
`cargo test` as a runner) sends the appropriate one per session.
### 4. The runner pattern is preserved, not specialized
The pipe mode (`terminal: None`) is the "runner" generalization the
research identified: a process whose stdin/stdout/stderr/exit-code stream
over a framed bidi connection. This is functionally identical to
GitHub/Gitea Actions runners, just over alknet's transport instead of
HTTP polling:
- A coordinator sends a negotiation frame with
`{ "backend": "local", "tty": null, "cmd": ["cargo", "test"] }`.
- The endpoint runs `cargo test` with piped stdio, streams stdout/stderr
chunks back, sends `{"type":"exit","code":N}` when it finishes (ADR-055).
- The coordinator gets reliable completion notification (the exit
control chunk + stream close) — no polling.
The runner-specific API surface (job management, log persistence, task
graph integration) is **out of scope for alknet-tty**. alknet-tty provides
the *mechanism* (a framed byte stream for a process); the runner *policy*
is a downstream crate's job. This ADR commits to preserving the option
(`terminal: None` → pipe mode) and not building runner policy into
alknet-tty. See OQ-46.
## Consequences
**Positive:**
- alknet-tty's default dependency surface is minimal (alknet-core + the
wire-format codec). A docker-only or ssh-only deployment never pulls
in `portable_pty`.
- The local backend is a one-feature opt-in (`features = ["local"]`) for
the common case — a consumer that wants a terminal/runner endpoint with
no docker or SSH gets it with one feature flag, not a separate
dependency.
- PTY vs pipe is per-session, so one `LocalTtyBackend` serves terminals
and runners. A hub that does both doesn't need two backends.
- The runner pattern is preserved without baking runner policy into
alknet-tty. The mechanism is the framed byte stream; the policy is
downstream.
- The sibling-crate placement composes with ADR-003's no-handler-depends-
on-another-handler rule: alknet-tty-local depends on alknet-tty (for
the trait), alknet-tty does not depend on alknet-tty-local (the
feature re-export is optional).
**Negative:**
- A consumer that wants the local backend must enable a feature flag
(`features = ["local"]`). Forgetting the flag results in the
`alknet_tty::local` module not existing — a compile error, not a silent
miss. This is the standard Rust feature-flag trade and is
self-documenting.
- One extra crate in the workspace (`alknet-tty-local` alongside
`alknet-tty`). The workspace `Cargo.toml` gains a member; version
coordination is per-crate. This is the established pattern (alknet-http
is one crate with colocated server + adapters; alknet-call is separate
from alknet-core).
- The runner-specific API surface (job management, log persistence) is
not in alknet-tty. A downstream crate that wants a full runner builds
on the pipe mode + the wire format. This is the right layering
(mechanism vs policy) but means a "runner crate" is a separate future
deliverable, not part of alknet-tty. See OQ-46.
## Door type
**Two-way.** The sibling-crate placement is reversible: if the local
backend turned out to be the only backend anyone used, merging
`alknet-tty-local` back into `alknet-tty` (behind the same `local`
feature, just in the same crate) is mechanical — the trait is the seam,
and the feature gate already exists. The cost of reversal is low
(re-exports become local modules), and no downstream consumer breaks (the
`alknet_tty::local::LocalTtyBackend` path stays valid).
This is a two-way door that is **decided** (sibling crate + feature
re-export), not deferred. The decision is made now; the reversal is cheap
if a future consolidation warrants it. See ADR-009 §"What this framework
is NOT" — door type classifies reversal cost, not urgency.
## References
- `docs/research/alknet-tty/phase-0-findings.md` DP-1 — the placement
question this ADR resolves
- [ADR-003](003-crate-decomposition.md) + Amendment 1 — crate
decomposition rule (the sibling-crate placement preserves it)
- [ADR-009](009-one-way-door-decision-framework.md) — door-type-as-deferral
anti-pattern (this ADR's two-way-door classification is reversal cost,
not a deferral)
- [ADR-017](017-call-protocol-client-and-adapter-contract.md) — the
adapter-location-map pattern (trait where types live, implementation
where deps live) this ADR follows
- [ADR-053](053-ttybackend-trait-and-ttyhandle.md) — the `TtyBackend`
trait this crate implements
- OQ-46 — runner API surface (deferred(scope): mechanism in alknet-tty,
policy is a downstream crate)
- Spec: [crates/tty/tty-local.md](../crates/tty/tty-local.md)