A rename retry after any refusal may rename only the record it named

improvise-9ww8: a crossing rename by another client bumps layout_version,
so the refusal is LayoutMoved, not RecordGone, and the retry recaptured
whatever record was under the cursor. refused() now pins Opened::Retry(r)
for any refused rename of r; the recapture becomes the target only if it
is r, else Gone(r). RecordMoved on the same record stays a force. The
explicit RecordGone pin is subsumed and removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-23 17:13:58 -07:00
parent 4ff79ff21e
commit ff8ea23909
5 changed files with 175 additions and 57 deletions
+33 -18
View File
@@ -2853,8 +2853,8 @@ they answer different questions:
state. It is composed optimistically at the keypress and folded the same way
on the host, where `CommitAndAdvance` reads it.
- `ClientState::opened` (`Opened::Target(OpenedCoordinate { record, category,
version, seen, document_generation })`, or `Opened::Gone` after a
`RecordGone`, below) answers **which record the rename names, and which
version, seen, document_generation })`, or `Opened::Retry`/`Opened::Gone`
after a refused rename, below) answers **which record the rename names, and which
record versions and document its basis claims**. It is session-local and
never serialized.
@@ -2873,23 +2873,38 @@ the host's value would have no record in it. Gating the capture on
`enter-mode` records no opening, yet the host still commits a rename from it,
so the gate would strand that path on "still waiting".
**A retry after `RecordMoved` is a force.** A refusal replays the view slice,
so the editor reopens with the typed text. `settle_opened` then captures the
record at its **current** version, so a second `Enter` succeeds. That is the
one-keystroke force `RecordMoved` promises (gotcha 43). The user has just been
told that someone else changed the row.
**A retry after a refusal may rename only the record it named (improvise-20ek,
improvise-9ww8).** A refusal replays the view slice, so the editor reopens with
the typed text over whatever row the cursor now shows. The rule is about that
row:
**A retry after `RecordGone` sends nothing (improvise-20ek).** `RecordGone` on
the record the refused rename named means another client renamed or deleted that
row. The replayed editor then sits over a **different** record, and recapturing
it would rename that one with text typed for another row. So `refused()` pins
`ClientState::opened` to `Opened::Gone(record)`, and `rename_coordinate` answers
`Enter` with a status and no write. Esc leaves the editor, and opening it again
captures afresh. This keeps "force never creates structure" (gotcha 43) from
turning into "force picks another target". The pin applies only to that
rejection on that record. The reviewer's rival on PR #221 kept the opened target
through every refusal instead, and that breaks the reorder case above: after a
successful rename the next editor must capture the row it now shows.
- `refused()` pins `ClientState::opened` to `Opened::Retry(record)`, where
`record` is the one the refused rename named. The refusal's kind plays no part.
- Once the host has answered, `settle_opened` captures what is under the cursor.
If that is the same record, it becomes the target at its **current** version,
so a second `Enter` goes through. That is the one-keystroke force
`RecordMoved` promises (gotcha 43), and the user has just been told someone
else changed the row.
- If it is any other record, or none, `opened` becomes `Opened::Gone(record)`,
and `rename_coordinate` answers `Enter` with a status and no write. Esc
leaves the editor, and opening it again captures afresh.
**Why the rule names the record rather than a rejection.** The first version
(20ek) pinned only on `RecordGone` of the renamed record. But `stale_basis`
checks the layout tier before the record tier, and another client's rename
bumps `layout_version`. So when A's `Enter` crosses B's rename in flight, which
takes nothing more than ordinary latency, the refusal is `LayoutMoved`, not
`RecordGone`. The retry then renamed whatever record had moved under the
cursor. That happened both when B had renamed A's own record, and when B's
rename merely re-sorted the rows so that a different, still-living record sat
under the cursor. So "is the source gone" is not the discriminator either. The
only question that separates a force from a retarget is whether the capture is
the record the user typed for. With that rule the explicit `RecordGone` pin is
subsumed: after `RecordGone(r)` the capture can never be `r`. It was removed
once a rival that dropped it failed no test. The reviewer's rival on PR #221,
which kept the opened target through every refusal, still breaks the reorder
case: after a *successful* rename, the next editor must capture the row it now
shows.
**The destination is a record too (improvise-milr).** A rename onto an existing
coordinate overwrites that record (improvise-kndq), so it is a write to the
+60
View File
@@ -5332,3 +5332,63 @@ the capture through every refusal and every return to `Normal`, fails this test
as well as the reorder test: its capture survives the swap. So the test does
see one regression class, a capture that outlives its editor, even though no
rival of the captured *generation* is observable.
### a_retry_after_a_crossing_rename_of_its_record_renames_no_other_record
improvise-9ww8, found by the adversarial review of PR #251 at `29d2adef`. The
earlier fix pinned the editor only on `RecordGone` of the renamed record. But
`stale_basis` checks the layout tier before the record tier, and another
client's rename bumps `layout_version`. So a crossing is refused `LayoutMoved`,
and the retry recaptured whatever record was under the cursor.
The test makes the crossing explicit. A opens North's coordinate and types `Zed`.
B renames North to `Yak`, and B's relays to A are **held**. A then composes
`Enter` without having heard B. Only after that does A receive B's relays and
then its own refusal, in the order a single socket delivers them. The setup
asserts that the refusal is `LayoutMoved`, which is the tier the old pin could
not see. The retry `Enter` must rename nothing: afterwards Yak holds 1, South 2,
West 3, and there is no Zed.
- **Red at `4ff79ff2`:** the retry renamed South to Zed, the reviewer's probe 1.
- **Rival `no_pin_at_all`** (no pin after a refusal): fails this test, the
reorder-crossing test below and `a_retry_after_record_gone_renames_no_other_record`.
- **Rival `retry_any_capture`** (after a refusal, accept whatever capture is
under the cursor): fails the same three.
- The existing 20ek test delivers B's rename to A **before** `Enter`, so A's
layout claim is current there and only the record tier can fire. That is why
it could not see this.
### a_retry_after_a_crossing_reorder_renames_no_other_record
improvise-9ww8, the reviewer's probe 2, which shows that "is the source gone" is
not the discriminator. The crossing is the same, but A edits **South** (row 1)
and B renames North. South survives, but the re-sort moves it to row 0, so the
replayed editor sits over West. The retry must not rename West: West keeps its
3, Zed is never West's 3, and Yak holds 1.
- **Red at `4ff79ff2`:** West was renamed to Zed while South still existed.
- **Rivals `no_pin_at_all` and `retry_any_capture`:** both fail this test.
### a_retry_after_a_crossing_write_to_its_own_record_is_a_force
The control for the two tests above. A rule of "decline after any refusal" would
pass both of them, so this test separates the fix from that rule. The crossing
is a `SetCell` by B on North's Revenue. It is refused `RecordMoved(north)` (a
write reshapes nothing, so only the record tier fires). The replayed editor is
still over North, so the retry is the one-keystroke force: Zed holds B's 9, and
North is gone.
- **Rival `always_decline`** (every retry after a refusal declines): fails only
this test.
- **Rival `never_clear_opened`** (the #221 reviewer's rival: keep the opened
target through everything): fails this test as well as the reorder and swap
tests. Its stale target claims North at the version it opened with, so the
retry is refused again rather than forced.
**Amending the entry for `a_retry_after_record_gone_renames_no_other_record`
above.** Its rival `never_set_gone` no longer exists as written. The explicit
`RecordGone` pin it removed has itself been removed, because the general rule
subsumes it: after `RecordGone(r)` the capture can never be `r`, so it becomes
`Gone(r)` anyway. A rival that dropped the explicit pin (`gone_as_retry`) failed
no test, which is how the redundancy was found. The test's live rivals are now
`no_pin_at_all` and `retry_any_capture`.
+31 -36
View File
@@ -50,11 +50,12 @@ pub struct ClientState {
pub opened: Option<Opened>,
}
/// What an open coordinate editor renames. `Gone` is a refusal's answer, kept
/// so a retry cannot silently retarget (improvise-20ek, `clients.md`).
/// What an open coordinate editor renames. After a refusal the retry may name
/// only the record it named (`Retry`) or none (`Gone`): improvise-20ek, -9ww8.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Opened {
Target(OpenedCoordinate),
Retry(RecordHash),
Gone(RecordHash),
}
@@ -151,9 +152,7 @@ impl ClientState {
if rejection.refused_an_edit() {
let dropped = self.in_flight.pop_front();
self.replay();
if let Some(gone) = renamed_away(dropped.as_ref(), rejection) {
self.opened = Some(Opened::Gone(gone));
}
self.opened = renamed(dropped.as_ref()).map(Opened::Retry);
}
self.last_rejection = Some(rejection);
self.settle_mode();
@@ -199,23 +198,29 @@ impl ClientState {
let answered = !self.in_flight.iter().any(|sent| sent.awaited);
self.opened = match (self.view.mode.is_editing(), self.opened.take()) {
(false, _) => None,
(true, Some(Opened::Retry(record))) if answered => Some(match self.captured() {
Some(target) if target.record == record => Opened::Target(target),
_ => Opened::Gone(record),
}),
(true, Some(opened)) => Some(opened),
(true, None) if answered => {
crate::resolve::coordinate_at_cursor(self).and_then(|(record, category)| {
let basis = self.basis.current()?;
Some(Opened::Target(OpenedCoordinate {
record,
category,
version: basis.record(record)?,
seen: basis.records.clone(),
document_generation: basis.document_generation,
}))
})
}
(true, None) if answered => self.captured().map(Opened::Target),
(true, None) => None,
};
}
/// The coordinate under the cursor, with every counter this client holds.
fn captured(&self) -> Option<OpenedCoordinate> {
let (record, category) = crate::resolve::coordinate_at_cursor(self)?;
let basis = self.basis.current()?;
Some(OpenedCoordinate {
record,
category,
version: basis.record(record)?,
seen: basis.records.clone(),
document_generation: basis.document_generation,
})
}
/// An editor that arrived without its buffer is seeded here, so what is
/// drawn and what Enter commits read one buffer (improvise-ktlk).
fn settle_editor(&mut self) {
@@ -247,25 +252,15 @@ impl ClientState {
}
}
/// The record a refused frame was renaming, when the refusal says that very
/// record is gone. Only this refusal pins the editor: `RecordMoved` is a force.
fn renamed_away(sent: Option<&Sent>, rejection: Rejection) -> Option<RecordHash> {
let Rejection::RecordGone(gone) = rejection else {
return None;
};
sent?
.cmds
.iter()
.any(|cmd| {
matches!(
cmd,
Command::Model {
cmd: ModelCommand::RenameRecordCoordinate { record, .. },
..
} if *record == gone
)
})
.then_some(gone)
/// The record a refused frame renamed, if it renamed one (improvise-9ww8).
fn renamed(sent: Option<&Sent>) -> Option<RecordHash> {
sent?.cmds.iter().find_map(|cmd| match cmd {
Command::Model {
cmd: ModelCommand::RenameRecordCoordinate { record, .. },
..
} => Some(*record),
_ => None,
})
}
#[cfg(test)]
+4 -3
View File
@@ -934,8 +934,9 @@ fn commit(state: &ClientState, edit_mode: AppMode, advance: Advance) -> Option<V
Some(commands)
}
/// Why a retry after `RecordGone` sends nothing: the row it opened on is gone.
const RECORD_RENAMED_AWAY: &str = "that row no longer exists; Esc to leave the editor";
/// Why a retry after a refusal sends nothing: the record it opened on is no longer
/// the one under the cursor, or is gone (improvise-20ek, improvise-9ww8).
const RECORD_RENAMED_AWAY: &str = "the row you were editing has changed; Esc to leave the editor";
/// `CommitAndAdvance` over a coordinate: rename the record the editor OPENED on,
/// at the version it was shown — `context/code/clients.md` (improvise-fdk1).
@@ -957,7 +958,7 @@ fn rename_coordinate(
(false, true, _) => vec![Command::SetStatus(
RECORD_COORDS_CANNOT_BE_EMPTY.to_string(),
)],
(false, false, None) => {
(false, false, None | Some(Opened::Retry(_))) => {
return Some(vec![Command::SetStatus(
"still waiting for the host to answer your last edit".to_string(),
)]);
@@ -1573,3 +1573,50 @@ fn a_retry_after_a_crossing_reorder_renames_no_other_record() {
assert_ne!(region(&host, "Zed"), Some(CellValue::Number(3.0)));
assert_eq!(region(&host, "Yak"), Some(CellValue::Number(1.0)));
}
/// The control for the two crossings above: `RecordMoved` on the SAME record is
/// still a one-keystroke force. Evidence: `context/test-evidence/clients.md`.
#[test]
fn a_retry_after_a_crossing_write_to_its_own_record_is_a_force() {
use improvise_protocol::{ModelCommand, Rejection, ServerFrame};
let mut host = three_records_host();
let (a_id, mut a, coordinate) = records_client_at_coordinate(&mut host);
assert_eq!(a.state().grid.cell(0, coordinate).unwrap().text, "North");
let revenue = (0..a.state().grid.extent.cols)
.find(|&c| {
a.record_cell_at(0, c)
.is_some_and(|x| x.measure.as_deref() == Some("Revenue"))
})
.expect("North's revenue");
let north = a.record_cell_at(0, revenue).unwrap();
press(&mut host, a_id, &mut a, "i");
retype(&mut host, a_id, &mut a, "Zed");
let held = held_from_another_client(
&mut host,
a_id,
ModelCommand::SetCell {
cell: north.clone(),
value: CellValue::Number(9.0),
},
);
let frame = a.key(&BrowserKey::new("Enter")).expect("Enter composes");
let answer = send(&mut host, a_id, &serde_json::to_string(&frame).unwrap());
deliver(&mut a, &held);
deliver(&mut a, &answer);
assert!(
answer.iter().any(|f| matches!(
f.msg,
ServerFrame::Rejected(Rejection::RecordMoved(h)) if h == north.record
)),
"setup: the crossing write moves the record A is renaming: {answer:?}"
);
press(&mut host, a_id, &mut a, "Enter");
assert_eq!(
region(&host, "Zed"),
Some(CellValue::Number(9.0)),
"the retry must rename North, carrying B's write"
);
assert_eq!(region(&host, "North"), None);
}