feat: report a switch as queued and attribute its landings #82

Merged
natsukium merged 1 commit from feat/operation-ids-24 into main 2026-09-04 19:56:25 +09:00
Owner

Fixes #24.

A switch reply used to say accepted, which promised more than the
daemon had done: it counted the outboxes that took the reattach push,
not the windows that landed on the new session. The field is renamed
queued (and the error.kind token not_accepted to not_queued)
across sessions switch, switch-all, retarget-all, window retarget / felis ssh, and felis bridge, so the name states exactly
what happened.

The reply also carries an operation_id now — an opaque
incarnation/sequence token naming the fan-out the reply queued,
null when nothing was pushed. The daemon keeps a ring of the last 256
operations and records one verdict per pushed window under it: landed
when that window re-attaches (the client echoes the id back on
SessionAttach), superseded when a later switch moves it first. A
window that re-dials onto another daemon never reports back, and an id
older than the ring, or from before a restart, resolves to nothing.

Wire: protocol minor 8 — OperationId, OpsSwitched.operation_id, the
OperationEcho on PushReattach / PushRetargetHost, and
SessionAttach.operation. A peer below it omits the fields, so the
switch is still reported as queued with nothing to attribute. The
acceptedqueued rename is a tag-3 rename (invisible on the wire,
a compile break for a generated client), acknowledged in
crates/felis-protocol/proto/BREAKING.md as a deliberate pre-release
break.

Doc cascade: docs/reference/ipc.md (the minor ledger row, the
operation-id wire shapes), docs/reference/cli.md and
docs/reference/control-surfaces.md (the renamed fields and the new
result field), docs/explanation/architecture/ipc.md (why a queued
reply plus an attributed ledger, and the rejected alternatives), and
CHANGELOG.md.

Verified: just check green (fmt, clippy, nextest, deny); reviewed by
plan/correctness/docs lenses and pi sol.

Deferred

  • --wait <timeout> on sessions switch / switch-all /
    retarget-all
    — the plan moves it post-v0.1; it needs the query arm
    below, and the id it waits on is now frozen on the wire.
  • Ops::OperationStatus { id } / OperationReport query arm
    reporting pending/landed/superseded and Unknown{Evicted,OtherIncarnation}
    — the plan moves it post-v0.1; the ledger already computes every
    verdict it would return, so it is purely additive.
  • Reporting the daemon incarnation in Welcome / felis daemon status — only useful once a client can query an operation id; it
    ships with the query arm.
  • failed as an inferred timeout verdict — rejected for v0.1 in the
    plan and recorded as rejected in the explanation doc: it could only
    ever be inference dressed as observation.
  • An end-to-end test for the not_queued error kind — no existing
    harness reaches it (a scope that resolves to a window whose outbox is
    dead); the kind's token is pinned by the cli_output golden test.
  • Cross-host completion reporting for carrier retargets — rejected
    in round 4 of the plan and recorded with a Revisit-if trigger; the
    origin daemon holds no record the target daemon can reach.
  • The ring-bound test does not pin retention — an over-eviction
    regression would pass it. Test-hardening beyond the current acceptance
    criteria: the finding does not show current retention behavior is
    wrong, only that the test could pass under a hypothetical future
    regression, so strengthening the assertion is an extra test, not a fix
    to shipped behavior.
  • The daemon incarnation is never published in ConnWelcome or daemon
    status, contrary to the round-2 amendment
    — this is the same item as
    "reporting the daemon incarnation" above, and it ships with the query
    arm, which the accepted plan defers post-v0.1. The round-2 amendment
    freezes only the OperationId field shape now; it does not mandate
    wiring the Welcome/status surfaces before the query arm lands.
Fixes #24. A switch reply used to say `accepted`, which promised more than the daemon had done: it counted the outboxes that took the reattach push, not the windows that landed on the new session. The field is renamed `queued` (and the `error.kind` token `not_accepted` to `not_queued`) across `sessions switch`, `switch-all`, `retarget-all`, `window retarget` / `felis ssh`, and `felis bridge`, so the name states exactly what happened. The reply also carries an `operation_id` now — an opaque `incarnation`/`sequence` token naming the fan-out the reply queued, `null` when nothing was pushed. The daemon keeps a ring of the last 256 operations and records one verdict per pushed window under it: `landed` when that window re-attaches (the client echoes the id back on `SessionAttach`), `superseded` when a later switch moves it first. A window that re-dials onto another daemon never reports back, and an id older than the ring, or from before a restart, resolves to nothing. Wire: protocol minor 8 — `OperationId`, `OpsSwitched.operation_id`, the `OperationEcho` on `PushReattach` / `PushRetargetHost`, and `SessionAttach.operation`. A peer below it omits the fields, so the switch is still reported as queued with nothing to attribute. The `accepted` → `queued` rename is a tag-3 rename (invisible on the wire, a compile break for a generated client), acknowledged in `crates/felis-protocol/proto/BREAKING.md` as a deliberate pre-release break. Doc cascade: `docs/reference/ipc.md` (the minor ledger row, the operation-id wire shapes), `docs/reference/cli.md` and `docs/reference/control-surfaces.md` (the renamed fields and the new result field), `docs/explanation/architecture/ipc.md` (why a queued reply plus an attributed ledger, and the rejected alternatives), and `CHANGELOG.md`. Verified: just check green (fmt, clippy, nextest, deny); reviewed by plan/correctness/docs lenses and pi sol. ## Deferred - **`--wait <timeout>` on `sessions switch` / `switch-all` / `retarget-all`** — the plan moves it post-v0.1; it needs the query arm below, and the id it waits on is now frozen on the wire. - **`Ops::OperationStatus { id }` / `OperationReport` query arm** reporting pending/landed/superseded and `Unknown{Evicted,OtherIncarnation}` — the plan moves it post-v0.1; the ledger already computes every verdict it would return, so it is purely additive. - **Reporting the daemon incarnation in `Welcome` / `felis daemon status`** — only useful once a client can query an operation id; it ships with the query arm. - **`failed` as an inferred timeout verdict** — rejected for v0.1 in the plan and recorded as rejected in the explanation doc: it could only ever be inference dressed as observation. - **An end-to-end test for the `not_queued` error kind** — no existing harness reaches it (a scope that resolves to a window whose outbox is dead); the kind's token is pinned by the `cli_output` golden test. - **Cross-host completion reporting for carrier retargets** — rejected in round 4 of the plan and recorded with a Revisit-if trigger; the origin daemon holds no record the target daemon can reach. - **The ring-bound test does not pin retention** — an over-eviction regression would pass it. Test-hardening beyond the current acceptance criteria: the finding does not show current retention behavior is wrong, only that the test could pass under a hypothetical future regression, so strengthening the assertion is an extra test, not a fix to shipped behavior. - **The daemon incarnation is never published in `ConnWelcome` or daemon status, contrary to the round-2 amendment** — this is the same item as "reporting the daemon incarnation" above, and it ships with the query arm, which the accepted plan defers post-v0.1. The round-2 amendment freezes only the `OperationId` field shape now; it does not mandate wiring the `Welcome`/status surfaces before the query arm lands.
feat: report a switch as queued and attribute its landings
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (push) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 47s
darwin / build felis (aarch64-darwin) (push) Successful in 13s
windows / cargo nextest (Windows) (pull_request) Failing after 2m31s
windows / frontend smoke (Windows) (pull_request) Has been skipped
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 2m35s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m54s
windows / cargo nextest (Windows) (push) Failing after 2m27s
windows / frontend smoke (Windows) (push) Has been skipped
windows / package felis (x86_64-pc-windows-msvc) (push) Has been skipped
pr / nix flake check (pull_request) Successful in 38s
pr / cargo build / clippy / test / deny (push) Successful in 2m33s
pr / cargo build / clippy / test / deny (pull_request) Successful in 2m24s
pr / wire schema is compatible with the base (pull_request) Successful in 10s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 55s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Failing after 14s
fuzz / cargo fuzz smoke (per target) (push) Successful in 1m18s
pr / nix flake check (push) Successful in 13s
pr / publish felis (x86_64-linux) (push) Successful in 11s
pr / wire schema is compatible with the base (push) Successful in 14s
pr / frontend smoke (x86_64-linux) (push) Successful in 6s
windows / cargo clippy (Windows cross) (push) Failing after 11s
5fe3af7bef
`OpsSwitched.accepted` promised more than the daemon could observe: the
reply is written the moment the pushes are enqueued, while the dial, the
attach, and the supersession by a later switch all happen afterwards. A
script reading it as "the window moved" was reading a field that never
meant that, which the shipped agent skill already worked around by
polling `sessions info`.

The count is renamed to `queued`, saying only what it can (and the CLI
error kind `not_accepted` to `not_queued`), and each request now mints
one `OperationId` — incarnation plus sequence, so an id from before a
restart resolves to nothing rather than to a later operation's verdict.
Every push carries it beside the attachment it was delivered to, since a
re-attach mints a fresh attachment and the operation id alone could not
say which target of an all-mirror switch landed; the attach echoes both
back, and the daemon marks that one target landed. A later push to the
same attachment supersedes the earlier target first, so the machine is
monotone and a superseded target can never be reported as landed.

The ledger is a handle shared with the session tasks rather than a field
of the pool, so the record is opened by the actor that enqueues the
pushes, under the same lock a landing is attributed through. Recording
afterwards, from the connection task, left two gaps with nothing to
close them: a window that re-attached before the record existed lost its
verdict for good, and two concurrent switches naming one window could
insert their records in the opposite order from their pushes, marking
the operation that actually lands superseded by the one that lost.

One id per request rather than per push: the all-mirror scope fans out
to several windows and a scalar on the reply could not name them apart.
The record ring is bounded at 256 because nothing collects these
entries — a window that never lands leaves its target pending forever.
`failed` is deliberately absent: a window that fails to land holds no
connection left to say so, so it could only ever be an inferred timeout,
and a carrier retarget lands on another daemon that holds no record at
all. The wire shape is decided now, before the tag, because a missing
echo field means a verdict can never be attributed afterwards.

Protocol minor 8; the rename is acknowledged in the wire-break ledger.

Refs #24
natsukium deleted branch feat/operation-ids-24 2026-09-04 19:56:25 +09:00
Sign in to join this conversation.
No description provided.