fix(mcp): codekeeper_note_str_replace מודד את התוצאה לפני שהוא בונה אותה — max_size חובה ב-_apply_edit, ו-call-arg חוסם ב-mypy של CI (#3495)
תבנית Pull Request
✨ תיאור קצר
- מה השתנה למשתמש ולשירות:
codekeeper_note_str_replaceבודק עכשיו את אורך התוצאה לפני שהוא בונה אותה. עד היום הוא בנה את גוף הפתק החדש ורק אז מדד אותו, ולכןreplace_allעל מחרוזת קצרה עםnew_stringארוך בנה בזיכרון מחרוזת באורך "מספר המופעים כפול אורך ההחלפה" לפני הסירוב. על שרת חי, בקשה של כ-10KB הקפיצה את שיא הזיכרון של התהליך בכ-19MiB, ועל פתק מלא בכ-172MiB. בתקרות של הייצור זה כ-40GB — בקשה אחת שמפילה את שירות ה-MCP לכל המשתמשים. התשובה לסוכן על תוצאה ארוכה מדי שאינה רווחים בלבד לא השתנתה: אותוcontent_too_longעםmax, רק לפני ההקצאה. הקצה היחיד שהשתנה: תוצאה של רווחים בלבד מעל התקרה, שנענתה עד היוםempty_content. - ומה מונע את הפעם הבאה:
max_sizeשל_apply_editהפך לחובה, בלי ברירת מחדל, ו-call-argנוסף לשער החוסם של mypy ב-CI. כך כלי עתידי שיחליף טקסט דרך_apply_editוישכח את התקרה ייעצר ב-CI, ולא בייצור.
📦 שינויים עיקריים
- קוד (Backend)
- בוט טלגרם — רק שינוי שם של מחלקה מקומית ב-
main.py, בלי שינוי התנהגות (פירוט למטה) - מסד נתונים/מיגרציות
- תיעוד (docs/)
- DevOps/CI/CD
פירוט נקודות:
mcp_server/handlers.py—_apply_edit:max_size: intחובה (במקוםint | None = None), והבדיקה המוקדמת רצה תמיד. ה-docstring מסביר למה, ומוסיף מקור לחשבון: זה בדיוק החשבון שלפיוstr.replaceעצמו מקצה את התוצאה (new_size = slen + n * (len2 - len1)ב-replace,Objects/unicodeobject.c, נקרא ב-CPython 3.11.15; הייצור רץ על 3.11 —Dockerfile,.python-version).mcp_server/handlers.py—note_str_replace: מעבירmax_size=MAX_NOTE_CONTENT, ו-code_too_largeשחוזר משם נענה{"ok": false, "error": "content_too_long", "max": MAX_NOTE_CONTENT}— בדיוק התשובה של היום על תוצאה ארוכה מדי שאינה רווחים בלבד (הקצה של רווחים בלבד — ב"השפעות/סיכונים"). הבדיקה שאחרי ההחלפה נשארה כרשת על מה שנכתב (ProductionBackend.update_noteאינו בודק אורך, נבדק), והיא אינה ההגנה: היא לא נדלקת כל עוד החשבון ב-_apply_editמדויק..github/workflows/ci.yml: השער של המעבר הרחב של mypy חוסם עכשיו גםcall-arg(attr-defined|return-value|call-arg). עד היוםcall-argהודפס ביומן ולא עצר דבר.main.py: המחלקה המקומית השנייה בשם_Ctxב-setup_bot_dataנקראת עכשיו_CtxPredictive(כמו_CtxMaintו-_CtxBkpשלידה). זה היה המופע היחיד שלcall-argבריפו — false positive של mypy, כי שתי מחלקות באותו שם באותה פונקציה — ובלי התיקון השער החדש היה אדום מהיום הראשון. שינוי שם בלבד.- תיעוד:
docs/mcp-server.rst— בשורתcodekeeper_note_str_replaceבטבלת הכלים (הגודל נבדק לפני הבנייה, כמו ב-codekeeper_edit_file), ובפסקה "גודל, לפני שהתוצאה נבנית".docs/whats-new.rst— שתי שורות: התיקון (כולל שינוי הקצה שלempty_content), ושער ה-CI.
אתרי הקריאה של _apply_edit (grep -rn "_apply_edit(")
| איפה | התקרה |
|---|---|
mcp_server/handlers.py — edit_file | max_size=max_code_size() (כבר מ-#3494) |
mcp_server/handlers.py — multi_edit_file | max_size=max_code_size() (כבר מ-#3494) |
mcp_server/handlers.py — note_str_replace | max_size=MAX_NOTE_CONTENT — חדש |
tests/test_mcp_edit_append.py | שמונה קריאות עם max_size, ואחת בכוונה בלעדיו — מוודאת TypeError |
tests/test_mcp_server_build.py | אחת, עם max_size=MAX_NOTE_CONTENT |
האם mypy באמת תופס קורא ששכח? נמדד, לא הונח: אחרי שהפכתי את max_size לחובה ולפני שתיקנתי את note_str_replace, הפקודה של CI (mypy . --ignore-missing-imports --show-error-codes) החזירה mcp_server/handlers.py:2095: error: Missing named argument "max_size" for "_apply_edit" [call-arg] — גם ב-mypy 2.4.0 (מה שה-CI מתקין, בלי נעיצה) וגם ב-1.18.2 (הנעוץ ב-requirements/development.txt). אבל השער של CI לא חסם call-arg, כלומר החובה הייתה נבדקת רק בזמן ריצה. לכן השער הורחב. הטסטים מוחרגים מ-mypy (mypy.ini) — שם הקריאות נבדקות בהרצה.
תקרה אחת
MAX_NOTE_CONTENT ב-mcp_server/handlers.py הוא from sticky_notes_target import MAX_NOTE_CHARS as MAX_NOTE_CONTENT — אותו אובייקט, לא עותק — ו-tests/test_note_boards_api.py::test_the_limit_has_a_single_source כבר מצמיד אותו לוובאפ. לא נוסף עותק שלישי: הטסטים והתיעוד נוקבים בשם, לא במספר.
החשבון באישו, מתוקן
באישו כתוב "גוף בקשה של 1MiB — כ-20GB". תקרת הגוף נגזרת מ-MAX_CODE_SIZE (request_bytes_for ב-mcp_server/limits.py): על ברירת המחדל שבקוד (100,000) היא 1MiB, ועל 300,000 — הערך בייצור כפי שמסרת (את הסביבה ב-Render לא קראתי) — היא 2MiB (נמדד: request_bytes_for(300_000) == 2_097_152). כלומר פתק של MAX_NOTE_CHARS תווים כפול new_string של כ-2MiB — כ-40GB. המסקנה זהה. תיקנתי את אותו מספר גם במסמך ההצעה ב-Ck (הצעת-דפוס-תקרה-אחרי-הגברה.md); את גוף האישו לא ערכתי.
סריקה לאותו דפוס (mcp_server/handlers.py, mcp_server/backend.py)
סריקת AST של כל *, .replace, .join, .sub ודומיהם בשני הקבצים:
- המקום היחיד שבונה תוכן של משתמש כפול מונה הוא
_apply_edit(code.replace(old, new)), ועכשיו כל שלושת הקוראים שלו מוגנים. _sanitize_note_text—.replaceו-re.subרק מקצרים.apply_line_range—"\n".joinעל פרוסה של הקובץ, לא יותר ממנו.append_file—code + sep + contentנבנה לפני ש-_resave_editedמודד אותו, אבל זה סכום ולא מכפלה: חסום בתקרת הגוף. לא הדפוס.backend.py— אין בוjoin,replaceאוsubבכלל; ה-+=שם מספריים.- סרקתי גם את שאר
mcp_server/*.py: אין מופע נוסף (redaction.pyמחליף התאמה במסמן קבוע, ובמקרה הגרוע גדל פי שניים בערך — לא בשליטת הבקשה). - אין מה לתקן מעבר ל-PR הזה, ולא נפתח אישו.
🧪 בדיקות
- Unit
- Integration
- Manual
טסטים חדשים:
tests/test_mcp_multi_edit.py::test_a_note_replace_all_is_checked_before_its_result_is_built— דרךcall_toolעלbuild_mcpאמיתי, מעלProductionBackendאמיתי (הפתק באוסףsticky_notesשל הדמה המשותפת), בדגם שלtest_a_single_replace_all_is_checked_before_its_result_is_built. בודק את התשובה, שהגבול על שיא ההקצאה אינו תלוי במספר המופעים —peak < 20 × request_bytes— ושבמסד שום דבר לא נכתב (הפתק נקרא מהאוסף, ואין צילום ב-sticky_note_versions). פרמטרי: 2,000 מופעים (האישו) ופתק מלא עד התקרה.tests/test_mcp_new_note_tools.py— על תוצאה שאינה רווחים בלבד צורת הסירוב זהה להיום (שוויון מלא, בלי שדות נוספים; עובר גם על הקוד הישן, וזו הכוונה); הגבול בתו (MAX_NOTE_CONTENTנכתב, תו אחד יותר נדחה); ושינוי הקצה של רווחים בלבד.tests/test_mcp_edit_append.py— החשבון מדויק בתו (6 עובר, 5 נדחה), ו-_apply_editבליmax_sizeהואTypeError.
לפני ואחרי — tracemalloc דרך call_tool (בקשה של 10,097 בתים, גבול 201,940):
| מקרה | הקוד שלפני (d2f9e3b) | אחרי |
|---|---|---|
| 2,000 מופעים × 10,000 תווים | 20,019,845 בתים — נופל | 23,303 בתים |
פתק מלא (MAX_NOTE_CHARS מופעים) | 200,015,625 בתים — נופל | 18,287–23,367 בתים |
וטסט הקצה של רווחים בלבד נופל על הקוד הישן (empty_content), כמצופה.
שרת חי — uvicorn mcp_server.app:app במצב PAT, מעל mongod 8.0.15 אמיתי, ולקוח MCP אמיתי (streamable_http_client של mcp 1.28.1) על HTTP. אותו תסריט על שני עצים; הזיכרון הוא VmHWM של תהליך השרת (/proc/<pid>/status) לפני ואחרי כל קריאה, ומה שבמסד נקרא אחרי כל קריאה ב-pymongo:
| מקרה | לפני | אחרי |
|---|---|---|
| 2,000 מופעים | content_too_long, שיא ה-RSS +19,184KB | content_too_long, +92KB |
| פתק מלא | content_too_long, +175,900KB | content_too_long, +56KB |
| רווחים בלבד מעל התקרה | empty_content | content_too_long |
| תוצאה בדיוק בתקרה | ok, במסד 20,000 תווים, גרסה אחת | ok, במסד 20,000 תווים, גרסה אחת |
בכל הסירובים הפתק במסד נשאר כמו שהיה ולא נכתבה גרסה. שיא ה-RSS של השרת אחרי כל הקריאות: 285,276KB לפני, 90,748KB אחרי.
מוטציות — כולן ב-git worktree נפרד, וכולן נתפסו:
| מוטציה | מה נפל |
|---|---|
M0: note_str_replace בלי max_size | שער ה-mypy החדש (exit 1). ועם השער הקודם אותה מוטציה עברה — זו הראיה ששינוי השער הוא מה שתופס |
M1: בלי הבדיקה החזויה ב-_apply_edit | שלושת טסטי הזיכרון, החשבון בתו, רווחים בלבד |
M2: note_str_replace עם תקרה ענקית | טסט הזיכרון של הפתק, רווחים בלבד |
M3: >= במקום > | הגבול בתו (פתק ויחידה) |
M4: MAX_NOTE_CONTENT - 1 | הגבול בתו |
M5: ברירת מחדל ל-max_size חזרה | test_apply_edit_has_no_default_ceiling |
M6: בלי התרגום ל-content_too_long | צורת הסירוב, טסט הזיכרון, הגבול, רווחים בלבד |
M7: התרגום בלי max | צורת הסירוב, טסט הזיכרון, הגבול, רווחים בלבד |
(M1 ו-M2 אינם מפילים את טסט הצורה ואת טסט הגבול — הרשת שאחרי ההחלפה עונה שם נכון. הם נתפסים בשיא ההקצאה, וזה התפקיד של טסט הזיכרון.)
שאר הבדיקות:
- כל החבילה (
-n auto --dist=loadscope, כמו ב-CI), על הקוד הסופי: 7,203 עברו, 313 דולגו, 0 נכשלו (Python 3.11.15; הדילוגים הם מה שמדלג גם ב-CI — מונגו אינו נגיש ובדיקות הדפדפן). ובקבצים שנגעתי בהם גם ב-Python 3.12.3, השנייה במטריצה: 379 עברו, והשיא בטסט הפתק 17,916 ו-16,652 בתים. - השוואת תשובות בין הבסיס ל-PR, בהרצה (נוסף אחרי הריוויו): אותה מטריצה של 14 סוגי קלט ל-
note_str_replaceרצה על d2f9e3b ועל 6ba68c0, כל צד ב-git worktreeנפרד. השתנו רק תוצאות של רווחים בלבד מעל התקרה (רווחים, ירידות שורה, ותו אחד מעל התקרה): עד היוםempty_content, עכשיוcontent_too_long, ובשני המקרים לא נכתב דבר. כל השאר זהה במילון המלא, כוללambiguous_matchעל תוצאה שהייתה חורגת, ורווחים בלבד בדיוק בתקרה (empty_contentבשניהם). - שער ה-mypy של CI משוחזר מקומית על העץ הסופי: עובר (mypy 2.4.0), ואין שגיאת
call-arg/attr-defined/return-valueגם ב-1.18.2. - flake8 בקונפיג של CI על הקבצים שהשתנו — נקי.
scripts/generate_ai_map.py --check— עדכני. - לא בניתי את Sphinx: רק פרוזה בעמודים קיימים, בלי עמוד חדש ובלי
toctree— ה-check של RTD יתפוס אזהרות.
🧪 בדיקות נדרשות ב‑PR
- 🔍 Code Quality & Security
- Unit Tests (3.11)
- Unit Tests (3.12)
📝 סוג שינוי
- feat: פיצ'ר חדש
- fix: תיקון באג (התקרה של הפתק נבדקת לפני שהתוצאה נבנית)
- docs: שינוי תיעוד בלבד
- refactor: שינוי קוד ללא שינוי התנהגות
- perf: שיפור ביצועים
- chore/ci: תשתית/CI (
call-argבשער של mypy) - breaking change: שינוי שובר תאימות — רק מקרה קצה אחד משנה קוד שגיאה (למטה), ושני הקודים אינם כותבים דבר
✅ צ'קליסט
- הקוד עוקב אחרי הסגנון — flake8 בקונפיג של CI נקי; mypy — השער החוסם עובר
- בדיקות רצות ועוברות
- תיעוד עודכן (README/Docs)
- ג'ובים חדשים — לא רלוונטי
- משתני סביבה — לא נוספו
- טוקנים של עיצוב — לא רלוונטי
- אין סודות/מפתחות בקוד
- אין מחיקות מסוכנות/פעולות על root
- הודעת הקומיט תואמת Conventional Commits
- CHANGELOG עודכן (
docs/whats-new.rst) - כל ה‑Required Checks ירוקים — ממתין ל-CI
- צילום/וידאו UI — לא רלוונטי
- עיינתי במסמכי אתר התיעוד — נתיב:
docs/mcp-server.rst| המשפט: "אותה בדיקה חלה גם עלcodekeeper_edit_file:replace_allעםnew_stringארוך נדחה לפני שהתוצאה נבנית."
עיון ב-CodeBot – Project Docs: כן. AI-MAP.md; docs/mcp-server.rst (טבלת הכלים, "כמה עריכות בגרסה אחת" ו"גודל, לפני שהתוצאה נבנית", "קריאת פתק בודד", "היסטוריית פתקים", פתרון תקלות); docs/whats-new.rst; docs/doc-authoring.rst; docs/versioning-stable-anchors.rst; docs/style-glossary.rst; docs/testing.rst; docs/dev/sticky_notes_extending.rst ("התיעוד למשתמש הוא חלק מהפיצ'ר"); docs/user/sticky_notes.rst — נבדק ש"מעבר לתקרה השמירה נדחית ומוחזר content_too_long" נשאר נכון (ועכשיו גם בקצה של רווחים בלבד), ולכן העמוד לא שונה.
דפוסי amir-bug-patterns שנדלקו ונקראו: U3 ו-external-input-isinstance (תקרה היא סירוב עם קוד משלו, ואינה תחליף לתקרת הגוף ולהגבלת הקצב); H6 (התקרה בתווים, החשבון בתווים, max בתווים — אין ערבוב יחידות); silent-truncation-at-sink; prose-restates-code-fact (grep על max_size, _apply_edit, note_str_replace, empty_content, content_too_long בכל הפרוזה; שמות ולא ערכים); claude-md-snippets/testing.md ו-TESTING-PATTERNS.md (דרך הממשק של הצרכן, בתצורת הייצור, ונופל על הקוד הישן); widened-exception-scope; line-number-coupling; host-metric-in-container (העלות נמדדת בשיא — tracemalloc ו-VmHWM — ולא אחרי); K15 ו-lazy-init-guard-publish-order ("לפני שמסירים התנהגות מנוונת — מודדים מי נשען עליה": None כ"בלי תקרה" — קורא ייצור אחד ושבע קריאות בטסטים, כולם עודכנו); R6; write-from-cached-read (הקריאה של הפתק היא find_one ישיר, לא קאש); K11. ובנוסף ההצעה ב-Ck: הצעת-דפוס-תקרה-אחרי-הגברה.md — אין לה פתקים.
🧩 השפעות/סיכונים
- שינוי בקצה (הכרעה מהאישו): תוצאה של רווחים בלבד שארוכה מהתקרה מחזירה עכשיו
content_too_longולאempty_content. שני הסירובים אינם כותבים דבר. - ה-CI: PR עתידי עם שגיאת
call-argייכשל ב-"🔍 Code Quality & Security". זו הכוונה. היום אין אף אחת בריפו (mypy 2.4.0). ה-CI מתקין mypy בלי נעיצה, ולכן גרסה חדשה שתזהה מקרים חדשים יכולה להאדים את השער — אותו סיכון שכבר קיים ל-attr-definedו-return-value. - ביצועים: הבדיקה המוקדמת היא
str.countאחד שכבר רץ קודם — אין עבודה נוספת.
🔗 קישורים
- Issues קשורים: Closes #3495. התקדים: #3494.
- Docs Preview: ה-check של Read the Docs על ה-PR
- מסמכים/מפרטים רלוונטיים:
docs/mcp-server.rst— "גודל, לפני שהתוצאה נבנית" - Branch Protection & PR Rules
🧯 סיכון / החזרה לאחור (Rollback)
- Revert של ה-PR מחזיר את הבנייה לפני המדידה בפתקים ואת השער הקודם. אין מיגרציה, אין אינדקס ואין שינוי בנתונים.
סטיות מהתוכנית
- נוסף, ולא היה בתוכנית: הרחבת שער ה-mypy ל-
call-arg, ושינוי השם ב-main.py. ביקשת לוודא ש-mypy ב-CI תופסmax_sizeחסר — הוא סימן אותו, אבל השער לא חסם עליו. בלי זה "חובה" הייתה נבדקת רק בזמן ריצה. - הבדיקה שאחרי ההחלפה נשארה כרשת על מה שנכתב, עם הערה שאומרת שהיא אינה ההגנה. התוכנית לא אמרה להסיר אותה;
backend.update_noteאינו בודק אורך, וכל שאר כותבי הפתקים בודקים בגבול הכתיבה. - טסטים מעבר לתוכנית: הגבול בתו, הקצה של רווחים בלבד, החשבון ביחידה, וברירת המחדל שאינה קיימת. וטסט הזיכרון רץ גם על פתק מלא, כדי להראות שהגבול אינו תלוי במספר המופעים.
🤖 Generated with Claude Code
https://claude.ai/code/session_01DhRqpoqm8WFN2KP7cJSKXJ
Generated by Claude Code
Summary by Sourcery
Reject oversized note replacements before constructing their results and enforce explicit edit size limits through mypy and CI.
Bug Fixes:
- Prevent oversized note replacements from building the full replacement string before rejecting it, avoiding excessive MCP server memory usage while preserving the existing error response.
- Define the note replacement boundary consistently, including the exact-limit and whitespace-only edge cases.
Enhancements:
- Make the edit size limit mandatory for all _apply_edit callers so every edit path is protected by an explicit ceiling.
CI:
- Make mypy call-arg errors fail the CI type-check gate and resolve the existing duplicate context-class naming conflict.
Documentation:
- Document that note replacement size is checked before constructing the result and record the updated behavior in the changelog.
Tests:
- Add coverage for exact size limits, missing required ceilings, unchanged rejection responses, whitespace-only content, and memory usage across large replacement workloads.