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:
+33
-18
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user