Saving, when the answers come back out of order
Evidence for issue #123 — settings save immediately; no older answer may speak for a newer edit; no writer may replace a version it never saw. Updated after review round 2.
The bug, before anything was changed
The reproduction is a browser suite that parks every write and lets the test choose the order the
answers come back in. Run against unmodified main (ef08e84) it is red in sixteen
places — the two halves of the issue, both observed rather than assumed:
a file save that lands after newer typing
ok the newer text is still in the editor
FAIL and still in the remembered draft — an older success cannot clear it
Expected values to be strictly equal:
FAIL the file is not reported saved while newer text is unwritten
Expected "actual" to be strictly unequal to:
ok the next save writes the newer text
FAIL …against the base the older save returned, not the one it was typed on
Expected values to be strictly equal:
ok once the current text is committed it is saved, and the draft is released
saving again while a write is still in flight
FAIL an explicit save during a write is not dropped — the newer revision follows it
Expected values to be strictly equal:
FAIL and it is the latest requested text
Expected values to be strictly equal:
FAIL save and close does not report success while its buffer is uncommitted
Expected values to be strictly equal:
ok …and does report success once it is
settings save immediately
ok mounting settings saves nothing
FAIL a change dispatches at once, with no debounce to wait out
nothing was sent within 448ms of the click
FAIL one write at a time
Expected values to be strictly equal:
FAIL the pending change follows the moment the write settles, with no second delay
no second write after the first settled
FAIL and it carries both edits
Expected values to be strictly equal:
a secret description saves immediately too
FAIL a description dispatches without waiting
nothing was sent within 410ms
ok and it does not read as saved while the newer text is still unwritten
FAIL the newer description follows the one that was in flight
the newer description was never sent
FAIL and it is the newer text
Expected values to be strictly equal:
closing settings while a write is out
FAIL the third change is sent too
no write for the third change
FAIL a write already sent finishes after the panel closes
the outstanding write never settled
FAIL and it carried the change that was made
Expected values to be strictly equal:
16 failed
What a save says now
The suites
129 checks in the browser, 38 on the server. The last seven sections are round two's.
a file save that lands after newer typing ok the newer text is still in the editor ok and still in the remembered draft — an older success cannot clear it ok the file is not reported saved while newer text is unwritten ok the next save writes the newer text ok …against the base the older save returned, not the one it was typed on ok once the current text is committed it is saved, and the draft is released saving again while a write is still in flight ok an explicit save during a write is not dropped — the newer revision follows it ok and it is the latest requested text ok save and close does not report success while its buffer is uncommitted ok …and does report success once it is ok the viewer settles instead of reporting itself forever settings save immediately ok mounting settings saves nothing ok a change dispatches at once, with no debounce to wait out ok one write at a time ok the pending change follows the moment the write settles, with no second delay ok and it carries both edits a secret description saves immediately too ok a description dispatches without waiting ok and it does not read as saved while the newer text is still unwritten ok the newer description follows the one that was in flight ok and it is the newer text closing settings while a write is out ok the third change is sent too ok a write already sent finishes after the panel closes ok and it carried the change that was made reopening the panel ok reopening shows what was saved, not the value it was opened on first time ok and the saved description with it a settings write that fails ok the change is sent ok the failure is on screen ok and that setting does not claim to be saved ok while the descriptions beside it are unaffected — two resources, saved apart ok a failure does not fade the way the tick does ok Retry sends it again ok carrying the change that failed ok and the failure clears once it lands a file write that is refused ok the save goes out ok the file is not reported saved ok the conflict is named ok and the text is still there — in the editor and in the kept draft ok overwrite sends it again ok without the precondition it was refused on ok and then it really is saved ok with nothing left unwritten two save handlers on one keystroke ok the save goes out once ok and the second handler does not make a second round trip say the same thing ok the file is saved either way a file save that lands after the view is gone ok the save goes out ok the newer buffer survives a save that lands after the view closed ok and it is rebased onto the version that save committed ok reopening the file shows the unsaved text, not what was saved under it ok …and still says it is unsaved ok and saving it writes that text ok against the base the older save returned a file save that lands while another file is open ok a save finishing for one file does not clear another file's draft ok nor mark the open file saved ok nor touch its text ok the open file saves ok and it carries its own file and its own base, not the other file's a file write that fails has a way to send it again ok the pane saves the buffer ok the failure is named in the strip ok and a failure that is not a conflict still offers Retry ok which sends it again ok with the same text ok and still with its precondition — Retry is not an overwrite ok and then it reads as saved a number that is not a number yet ok a complete number is saved as it is typed ok with the value in it ok an emptied field sends nothing ok so the setting keeps the last number that was one ok the box keeps what was typed rather than snapping to 0 ok and it is marked as not a number yet ok finishing the edit saves the finished number ok and it is the number that was typed settings changed somewhere else ok a conflict says which kind of failure it is ok and offers both ways out rather than choosing one ok my value is still on screen while the choice is open ok keeping mine sends it again ok against the revision the server reported, not a blind overwrite ok and it is still my value ok taking theirs shows what is stored ok and does not turn round and save it back ok with the conflict cleared an answer that never comes ok a settings save has a finite window for an answer ok a request that never answers gives the slot back instead of saying "saving…" forever ok and it is not reported as saved ok a retry asks what the server holds before sending anything again ok and a write that had landed after all is recognised, not repeated ok a lost answer with nothing committed is sent again ok with the value that was owed ok and a precondition, not a forced write a settings change whose panel is closed before it fails ok a failure that arrives after the panel is closed is reported in the app ok with a way back to the setting and a way to send it again ok and the change is still there to send ok carrying the value that was made before the panel closed ok reopening shows the value still owed to the server, not the older one it would fetch ok and the app-level report stands down while the panel is open ok retrying from the panel sends it once more ok and it lands a lost answer while somebody else was writing ok a value we never saw is not overwritten to recover our own ok so their change is still on the server ok and it is reported as a conflict, not as saved ok keeping mine is what sends it — a click, not a recovery step ok against the revision the check found a check that is itself unanswered ok a read-back that never answers gives the slot back too ok and stays retryable ok a later edit is not trapped behind it ok and it carries the newer value reopening settings while a write is still out ok the value being written is what the panel comes back to ok the edit that followed it goes out ok carrying both fields — the one still in flight is not reverted ok and what ends up stored is the value that was made, not an older one a Save saves what was asked for, not what is typed after it ok the queued save goes out ok and it carries the version that was asked for ok text typed after that Save stays dirty rather than being committed by it ok with its draft intact ok and still on screen what save and close is told when the buffer moves on ok a write that succeeded for older text is not a yes to "is the buffer saved?" ok the newer text is still unsaved, and says so ok with its draft kept save and close, and then changing your mind ok save and close sends the buffer ok an older success does not reverse Keep editing ok the newer text is still the buffer ok and the file does not read as saved ok Keep editing keeps the file open even when the save it interrupted succeeds ok save and close sends the newer buffer ok which is the text on screen ok and then it does close ok with nothing left unsaved a derived update that fails after the save has landed ok a derived failure that arrives after the save reaches the open panel ok without sending the settings again to find out ok and the save itself still reads as saved — they are different outcomes save-races: ok
Server
PASS a first save needs no revision — there is nothing to replace status 200
PASS and answers with the revision it committed
PASS reading it back agrees, revision included {"rev":"294a59d9af46c43e"}
PASS the file it was written to is whole JSON, not a fragment
PASS and nothing is left behind beside it am-config.json,crons.json,home,operations.jsonl,state,workspaces
PASS replacing settings that exist without saying which version is refused status 409 "base-required"
PASS the first of two tabs commits
PASS the second, still holding the old revision, is refused status 409 "stale"
PASS and is told what is actually stored, so it can reconcile {"enabled":true,"space":"someone/newer","visibility":"public"}
PASS the first tab's change is still there — nothing was silently erased {"space":"someone/newer","usd":12}
PASS and the refused writer succeeds once it replaces the version it was told about
PASS a failed write is answered as a failure, not as ok status 500 {"error":"could not save settings — EACCES: permission denied, open '/tmp/am-settings-write-YmAciG/.
PASS with something the operator can read could not save settings — EACCES: permission denied, open '/tmp/am-settings-write-YmAciG/.am-config.json.am-tmp'
PASS the settings that were there are whole and untouched
PASS and no half-written file is left beside them am-config.json,crons.json,home,operations.jsonl,state,workspaces
PASS a save that said ok is still there when the settings are read again answered undefined, reads back 99
PASS a damaged settings file is reported on the read, not silently defaulted over "am-config.json is not valid JSON — Expected double-quoted property name in JSON at position 52 (line 1 column 53)"
PASS and an ordinary edit does not get to overwrite it status 409 "unreadable"
PASS the bytes that were there are still there
PASS descriptions save the same way
PASS and land as whole JSON
PASS a description written against a revision that has moved on is refused
PASS the probe variable is detected, so the generated skill has something to describe 24 detected
PASS a burst of saves leaves the generated skill on the last one
PASS and not on one it overtook
PASS with nothing reported as pending or failed once it has converged {"pending":false,"error":null,"at":1788980263377}
PASS an array is not an object of descriptions, and is refused status 400 "notes must be an object of name → description"
PASS and neither is a description that is not text
PASS the descriptions that were there are untouched
PASS so the file still reads, and ordinary saves still work null
PASS proved by making one
PASS a settings body that is not an object is refused rather than normalized to defaults status 400
PASS the derived outcome can be read without sending the settings again {"pending":false,"error":null,"at":1788980263392}
PASS a settings save still succeeds when the derived update cannot be written status 200
PASS and the file it was asked to save really is saved
PASS but the derived failure is reported, not swallowed into a fully applied state {"pending":false,"error":"EACCES: permission denied, open '/tmp/am-settings-write-YmAciG/workspaces/skills/environment.md'","at":1788980263398}
PASS and it clears once a later save gets the derived update through {"pending":false,"error":null,"at":1788980263405}
PASS with the skill on the newest description
38 passed, 0 failed
Mutation checks
Every guard was reverted on its own; each line is what the suites then said.
Round two
- Adopt the read-back revision before comparing → the other client's value is overwritten to recover ours, and it reports saved.
- Leave the in-flight value out of
pending()→ reopening mid-write fetches over it, and the next field edit stores the old threshold. - Read the live buffer when the queued write goes out → a Save commits text typed after it, then clears that text's draft.
- Drop the withdrawn-close check → Keep editing is reversed by the answer that arrives after it.
- Let
saveNowanswer for the older text → “is the buffer saved?” is yes while it is dirty. - Leave the read-back unbounded → the resource wedges: later edits never dispatch.
- Stop following up a pending derived pass → a derived failure never reaches the panel.
- Restore the permissive notes writer →
{"notes":[]}is accepted, and every later read and save of that file is refused as damaged.
Round one
- Release the draft without checking whose it is → 8 checks fail, including “the newer buffer survives a save that lands after the view closed”.
- Let a response speak for whatever the viewer shows now → the open file's next save carries the other file's base.
- Force
superseded = false→ 12 checks fail. - Drop the “has the buffer moved on?” test → an older success clears a newer draft and reports it saved.
- Return a fresh object from
useSavereach render → the viewer never stops re-reporting itself. - Send settings without a base revision → the second tab's write is accepted over the first.
- Remove the finite answer window → the finite-window check fails.
- Restore the old numeric onChange → an emptied field is sent as 0.
- Restore the swallowed write on the server → answered true, reads back 12.
Generated for the PR against huggingface/agent-manager. Screenshots are rendered from the components themselves with the network stubbed; no live settings, secrets or workspaces were read or written. The server suite now supplies its own probe variable, so it says the same thing on a machine with no credentials in the environment.