Skip to content

Deep hardening: crash-safe sessions, safe imports, atomic updates, and visual regression#1

Draft
wyt1234 wants to merge 6 commits into
mainfrom
agent/deep-rail-hardening
Draft

Deep hardening: crash-safe sessions, safe imports, atomic updates, and visual regression#1
wyt1234 wants to merge 6 commits into
mainfrom
agent/deep-rail-hardening

Conversation

@wyt1234

@wyt1234 wyt1234 commented Jul 13, 2026

Copy link
Copy Markdown

What changed

  • Added guardian-owned process generations, Linux subreaper cleanup, exclusive worker/session leases, and fail-closed process-tree census before resume or removal.
  • Hardened Codex rollout identity/claiming, prompt readiness and bracketed-paste submission, terminal/control sanitization, and autopilot delivery/recovery.
  • Limited automatic history import to genuine current-directory chats active in the last 7 days, with /import Nd and exact-session expansion.
  • Made distillation runs isolated, version reservations atomic, coverage machine-checkable, and trust/config writes private and race-safe.
  • Made self-update replacement atomic and build-identity checked; strengthened the release quality gate and removed unsupported legacy macOS assets.
  • Restored visual-only mouse hover without stealing keyboard selection or jumping a scrolled list.
  • Replaced the stale README screenshot with a reproducible five-frame GIF generated through the real PTY renderer.

Why

Rail had several cross-process failure modes that only appear under crashes, concurrent managers, stale workers, or large histories: orphaned Codex trees could retain SQLite locks; sessions could cross-wire rollout identity; prompt injection could race Codex readiness; distillation/update writes were not sufficiently isolated; and pointer movement previously had to be disabled because it mutated durable selection.

This change makes those boundaries explicit and testable instead of relying on timing or process heuristics.

User impact

  • Rail is now explicitly Linux-only because its cleanup proof depends on subreapers and /proc process identity.
  • Printable keys always compose text; blank sessions use Ctrl+N, and autopilot uses Ctrl+A.
  • Startup imports only 7 days of genuine same-directory conversations. Older sessions remain available through /import 15d or /import <session_id>.
  • Hover is a quiet visual highlight; keyboard selection, attach ownership, and scroll position stay independent.
  • The rolling latest release publishes only the two supported Linux assets and cleans a stale macOS asset if present.

Validation

  • cargo fmt --all -- --check
  • RUSTFLAGS=-Dwarnings cargo check --locked --all-targets
  • cargo test --locked --all-targets -- --test-threads=1: 66/66
  • cargo test --release --locked --all-targets -- --test-threads=1: 66/66
  • Focused real-PTY Cockpit gates:
    • mouse hover: 1/1
    • visual/Unicode/terminal safety: 4/4
    • import/slash commands: 12/12
  • README demo regenerated twice with identical PNG and GIF hashes.
  • Python byte-compile, workflow YAML parse, staged diff check, and secret-pattern audit passed.

The complete socket/guardian Cockpit suite cannot run inside the current local sandbox because AF_UNIX is denied; this PR's quality workflow runs that full audit on GitHub Actions before any release job can start.

wuyitao and others added 6 commits July 11, 2026 15:53
Production (src/worker.rs):
- mark_failed_if_owner: reject only an EXPLICIT different owner. A None
  worker_pid means the failure happened before the final bind persisted our
  pid (e.g. a non-socket sits at the canonical socket path and run_worker
  bails pre-bind); at that point we still hold both worker.lock and
  socket.lock, so the error is ours to record and must not be swallowed into
  a stuck `starting` row. This is the one real production defect behind the
  red gate.

Cockpit (tests/cockpit.py) — no production behavior changed:
- keys #9: compare a stable row identity (the age cell ticks 0s->1s between
  reads) and wait on the composer repaint instead of fixed sleeps (fixes the
  nav flake and the uppercase-W race).
- redesign #13: clear the transient "auto-imported N chat(s)..." footer with a
  harmless Left-arrow before asserting the Windows-style key hints.
- distill #15: production runs the distill session FROM (and pre-trusts) the
  stable distill root; the corpus is a private per-run child. Assert
  cwd == -C == distill_dir trust and corpus-under-runs, not corpus trust.
- never-ready: accept portable-pty 0.8.1's newline+VEOF ("0a04") writer-Drop
  cleanup sentinel; still forbid framed/private prompt bytes.
- INJECT: drop the redundant post-DELIVERED detach frame. INJECT is a
  one-frame protocol and the worker closes the headless slot the instant it
  emits DELIVERED, so the extra frame raced that close and aborted the whole
  run with BrokenPipe (which had been hiding the two checks below).
- STOP #58: poll for the guardian's async certification instead of reading the
  state file the instant the tree dies (the certified write lags process death).
- distill-prepare #60: the run lock is non-blocking fail-closed (LOCK_EX|
  LOCK_NB), so a second overlapping prepare is refused, not queued. Assert the
  winners never share a version claim or corpus dir and each loser is exactly
  that fail-closed refusal, rather than expecting two concurrent successes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The updater asked GitHub for main's HEAD sha and (a) offered an update on ANY
difference and (b) demanded the downloaded rolling-release binary's embedded
marker equal main HEAD. But `releases/latest` is a rolling tag that LAGS main
(CI builds after the push), so in practice:
- a dev build ahead of the last publish nagged "update available" and, on
  click, tried to DOWNGRADE;
- every install failed "release asset is stale or mismatched" because the
  release's marker never equalled the current main HEAD.

Now resolve the rolling `latest` tag's own commit, ask GitHub's compare API for
ahead/behind, and act only when the release is strictly `ahead`: no phantom
note, no downgrade, and the marker is validated against the RELEASE's sha so a
lagging-but-valid release installs cleanly. The decision is a pure function
(classify_release) unit-tested across ahead/behind/diverged/identical/same-sha/
unknown/garbage-sha — including the exact "we are ahead of the last publish"
case that used to fail.

Verified against the live API: compare df71b6d...latest = behind -> NotNewer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build.rs only re-ran on changes to .git/refs/heads/main, so a commit on any
feature branch (e.g. agent/deep-rail-hardening) never refreshed the embedded
sha and `rail --version` — and the updater's own build_sha — reported an old
commit. Watch .git/HEAD, .git/packed-refs, and the exact ref HEAD points at,
so the sha stays correct on whatever branch is checked out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… output

A too-old codex 400s the configured model and exits at startup, but the session
only showed "codex exited with status N" — the real reason ("requires a newer
version of Codex") was invisible, so a wrong-version codex read as an
unexplained, repeatedly-triggered bug. finish_run now appends codex's last log
line (ANSI/control stripped) to last_error on a quick failure.

To make that reliable, drain the child's final PTY output into the log on
ChildExit before finishing: rx (control) is polled before pty_rx, so ChildExit
could arrive with the last chunk still queued — which both truncated the
transcript tail and hid the reason. Bounded drain (50ms-quiet, 300ms cap).

Tests: hint_from_log_bytes unit test (ANSI/OSC stripping, last readable line,
no-junk); cockpit integration test with a fake codex that prints a version error
and exits 1 -> asserts status=failed and the reason is surfaced ANSI-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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