fix(mcp): codekeeper_note_str_replace מודד את התוצאה לפני שהוא בונה אותה — max_size חובה ב-_apply_edit, ו-call-arg חוסם ב-mypy של CI (#3495)

Merged
AM
17d ago
+201-26

תבנית 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_filemax_size=max_code_size() (כבר מ-#3494)
mcp_server/handlers.py — multi_edit_filemax_size=max_code_size() (כבר מ-#3494)
mcp_server/handlers.py — note_str_replacemax_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,184KBcontent_too_long, +92KB
פתק מלאcontent_too_long, +175,900KBcontent_too_long, +56KB
רווחים בלבד מעל התקרהempty_contentcontent_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.