the trap keeps its evidence, and says where the procedure is

This bug will not be reproduced on demand. It will arrive as somebody's report,
hours later, from a browser whose console is long gone — so a `console.warn` is
seen by nobody, and "what did you see?" gets an answer built from memory and a
screenshot.

So the trap now persists. Up to five occurrences in
`localStorage['officer:editor-buffer-trap']`, each carrying the stack at the moment
of the edit, the first 200 characters that arrived, how many, which file, and when.
The question becomes "paste this key", and the answer is complete.

Recording a bug must never become one: private mode, a full quota and a disabled
storage API all throw at that write, and none of them is worth breaking an edit
over. The console line survives either way.

`docs/editor-buffer-corruption.md` is the procedure — what it looks like, the four
candidates already eliminated and why, the one command to ask a reporter for, how
to read each field, what an EMPTY key would mean (either the editor was focused,
making it a paste, or the insertion came in chunks under the threshold), and the
instruction to delete every piece of this once the cause is known. Linked from
TODO.md and from CLAUDE.md's reading list, so somebody handed a report finds it
without grepping for it.

Stated there rather than left implicit: a fix invented without knowing the cause
would hide the symptom on the one screen where it is visible while the mechanism
carried on doing whatever else it does. The corruption is never saved and reopening
the tab clears it, so waiting is cheap and a wrong fix is not.

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:18:56 +00:00
co-authored by Claude Opus 5
parent fc8d02e8e1
commit c0e61a4cba
4 changed files with 133 additions and 18 deletions
@@ -29,22 +29,36 @@ type CodeEditorViewProps = {
};
/**
* Shout when the buffer changes in a way a person could not have typed.
* Catch the buffer changing in a way a person could not have typed, and LEAVE THE EVIDENCE ON DISK.
*
* Background-job output has twice appeared spliced into an open file — the model mutated, the tab marked
* dirty, the file on disk untouched. Two explanations are already ruled out: the end-of-turn refresh
* cannot do it (`reloadFile` clears `isDirty`), and nothing in the app types into the page. That leaves
* Monaco's own change event, which means it looks like a paste or a programmatic edit — and neither can
* be identified from a screenshot after the fact.
* Background-job output has appeared spliced into an open file — the model mutated, the tab marked dirty,
* the file itself untouched. Two explanations are ruled out by reading: the end-of-turn refresh cannot do
* it (`reloadFile` clears `isDirty`), and nothing in the app types into the page. That leaves Monaco's own
* change event, so it looks like a paste or a programmatic edit, and neither is identifiable from a
* screenshot taken afterwards.
*
* So this does not prevent anything. It records the one thing missing: what arrived, how big it was, and
* whether the editor even had focus at the time. A large insertion into an UNFOCUSED editor is not a
* user, and the stack trace attached to it names whatever called it.
* ── Why it writes to localStorage and not just the console ──
*
* Delete this once the cause is known. It is a trap, not a feature.
* This is rare, nobody can reproduce it on demand, and it will arrive as somebody's report hours later.
* A `console.warn` is only seen by a person who happened to have devtools open at that second — which is
* nobody. Persisting the last few reports means the question "what did you see?" becomes "paste this
* key", and the answer is complete: the stack, the size, the path, the first 200 characters and whether
* the editor was even focused.
*
* The stack is the point. A person cannot type 200 characters into an editor they are not focused on, so
* whatever did will be named in it.
*
* Full procedure, and what is already ruled out: `docs/editor-buffer-corruption.md`. Delete all of this
* once the cause is known — it is a trap, not a feature.
*/
const SUSPICIOUS_INSERT = 200;
/** Where the evidence waits to be asked for. Documented in `docs/editor-buffer-corruption.md`. */
export const EDITOR_TRAP_KEY = 'officer:editor-buffer-trap';
/** Enough to see a pattern across occurrences, few enough to never be the reason storage is full. */
const TRAP_KEPT = 5;
function reportSuspiciousEdit(
editor: MonacoEditor.IStandaloneCodeEditor | null,
file: { path: string; content: string },
@@ -54,13 +68,31 @@ function reportSuspiciousEdit(
if (grew < SUSPICIOUS_INSERT) return;
if (editor?.hasTextFocus()) return;
const report = {
at: new Date().toISOString(),
path: file.path,
charactersAdded: grew,
editorWasFocused: false,
inserted: next.slice(0, 200),
stack: new Error('editor buffer changed while unfocused').stack ?? 'no stack',
};
// eslint-disable-next-line no-console
console.warn(
`[editor] ${grew} characters appeared in ${file.path} while the editor was NOT focused. ` +
`This is the background-job-output bug (TODO.md → Known bugs). First 120 chars:\n` +
next.slice(0, 120),
new Error('stack at the moment of the edit'),
`[editor] ${grew} characters appeared in ${file.path} while the editor was NOT focused — ` +
`see docs/editor-buffer-corruption.md. Saved to localStorage["${EDITOR_TRAP_KEY}"].`,
report,
);
// Never let recording a bug become one. Private mode, a full quota and a disabled storage API all throw
// here, and none of them are worth breaking an edit over.
try {
const previous: unknown = JSON.parse(window.localStorage.getItem(EDITOR_TRAP_KEY) ?? '[]');
const list = Array.isArray(previous) ? previous : [];
window.localStorage.setItem(EDITOR_TRAP_KEY, JSON.stringify([...list, report].slice(-TRAP_KEPT)));
} catch {
/* the console line above is still there */
}
}
export const CodeEditorView = ({