From db0b00082da47d512ec02c67b350ef5a5526e13b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 18 Aug 2026 19:09:45 +0800 Subject: [PATCH] emrg: TUI user messages render as markdown + stop_all self-held lock attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rant 2026-08-18T18:52:45 (Plan B, superseding 18:50:14): user messages in the TUI now render through the same Rich markdown pipeline as assistant messages (width-based wrapping, CJK handling) via a new UserMarkdown widget, while keeping the "> " prefix + bold cyan role visual. ChatHistory.add('user', ...) routes to UserMarkdown; system/tool/assistant stay on ChatRow. update_last() handles UserMarkdown rows for the assistant plain-text fallback path. +6 tests. Rant 2026-08-18T18:57:09: stop_all no longer aborts installs whose only locked files are python-dist DLLs held by stop_all's own runtime (the interpreter must load them; they are released when stop_all exits, before the installer overwrites via ewWaitUntilTerminated). createfile-probe verify now attributes locked files to module-holder processes — self/excluded-ancestor-held locks are self-held (advisory WARNING), only external-holder or unattributable locks are residuals. Final self-lock guard message changed from "installer will fail" to advisory. +4 tests. Agent.md test count 952 -> 962. --- Agent.md | 2 +- emrg/_stop_all.py | 102 ++++++++++++++++++--- emrg/client/python_tui/widgets/markdown.py | 37 ++++++++ emrg/client/widgets.py | 12 ++- tests/test_installer_stop.py | 88 ++++++++++++++++-- tests/test_user_markdown.py | 102 +++++++++++++++++++++ 6 files changed, 319 insertions(+), 24 deletions(-) create mode 100644 tests/test_user_markdown.py diff --git a/Agent.md b/Agent.md index cbe0c63d..1a457a02 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (952) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (962) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py index f992cc89..7dcecfd3 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -921,6 +921,11 @@ def _check_locked_files(root: str, try_open=None) -> list[str]: _lock_probe_error: str | None = None +def _install_root() -> str: + """Windows install dir (``~/.emrg/install``) — single source of truth.""" + return os.path.join(os.path.expanduser("~"), ".emrg", "install") + + def check_install_writable() -> list[str]: """Windows: probe ``install\\`` for files locked against overwrite. @@ -937,7 +942,7 @@ def check_install_writable() -> list[str]: _lock_probe_error = None if not is_win(): return [] - root = os.path.join(os.path.expanduser("~"), ".emrg", "install") + root = _install_root() if not os.path.isdir(root): return [] files = _iter_install_files(root) @@ -1027,6 +1032,53 @@ def stop_lock_owners() -> None: _windows_cats_cache: list[tuple[str, list[str]]] | None = None +def _classify_locked_files( + locked: list[str], + mh_holders: list[tuple[int, str, str, int, list[str], str]], + root: str, +) -> tuple[list[str], list[str]]: + """Split createfile-probe locked files into self-held vs residual. + + Rant 2026-08-18T18:57:09: when stop_all itself runs from + install\\python-dist\\python.exe, the probe reports the interpreter's own + DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong + to the stop_all process (module-holder tag ``excluded``) and are released + the moment stop_all exits, BEFORE the installer overwrites (installer runs + stop_all synchronously via ewWaitUntilTerminated). Counting them as + residuals aborts a perfectly fine install. + + Returns ``(self_held, residual)`` install-relative paths: + - self_held: locked file attributed ONLY to excluded (self/ancestor) holders + - residual: locked file with an external (``target``) holder, or one that + cannot be attributed to any known holder (conservative — could be a + plain non-DLL lock held by an external process that loaded no module). + """ + if not locked or not root: + return [], list(locked) + # Separator-agnostic: module-holder files arrive with backslashes (PS + # Substring), locked paths are native. Normalize both to forward slashes + # so the attribution works identically on Windows and in POSIX unit tests. + def _norm(p: str) -> str: + return p.replace("\\", "/") + + root_n = _norm(root) + tag_by_rel: dict[str, set[str]] = {} + for _pid, _name, _exe, _parent, files, tag in mh_holders: + for f in files: + tag_by_rel.setdefault(_norm(f), set()).add(tag) + self_held: list[str] = [] + residual: list[str] = [] + for p in locked: + rel = _norm(os.path.relpath(_norm(p), root_n)) + tags = tag_by_rel.get(rel, set()) + if tags == {"excluded"}: + self_held.append(rel) + else: + # No tags (unattributable) OR has an external target holder. + residual.append(rel) + return self_held, residual + + def _verify_windows_categories() -> list[tuple[str, list[str]]]: """Windows residual scan, one ``(category, residual_strings)`` entry per check — so the operator can see each check's result instead of guessing @@ -1066,9 +1118,10 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]: # section locks; RM missed the browser-harness child, CreateFileW probes # cannot see module locks at all). Any external holder = residual. mh_out = _module_holder_ps() + mh_holders = _parse_module_holders(mh_out) mh = [ f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})" - for pid, name, _exe, _parent, files, tag in _parse_module_holders(mh_out) + for pid, name, _exe, _parent, files, tag in mh_holders if tag == "target" ] cats.append(("module-holder", mh)) @@ -1095,9 +1148,29 @@ def _verify_windows_categories() -> list[tuple[str, list[str]]]: global _lock_probe_error _lock_probe_error = None locked = check_install_writable() - probe_items = [f"locked file (createfile-probe): {p}" for p in locked] + probe_items = [] if _lock_probe_error: probe_items.append(f"lock-probe failed (error: {_lock_probe_error})") + # Self-held attribution (rant 2026-08-18T18:57:09): when stop_all runs + # from install\python-dist\python.exe, that interpreter MUST load its own + # python-dist DLLs (python313.dll, select.pyd, ...) — those image-section + # locks are held by the stop_all process itself (module-holder tag + # ``excluded`` = self + ancestor chain) and are RELEASED when stop_all + # exits, before the installer starts overwriting (make-installer.sh uses + # ewWaitUntilTerminated). They are NOT residuals — counting them aborts + # the install while nothing is actually wrong. A locked file is residual + # only when an EXTERNAL holder (module-holder ``target`` / RM owner) + # exists or the lock cannot be attributed to any known holder (conservative). + self_held, residual_locked = _classify_locked_files( + locked, mh_holders, _install_root() if is_win() else "" + ) + probe_items.extend(f"locked file (createfile-probe): {p}" for p in residual_locked) + if self_held: + print( + f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all " + f"runtime itself (python-dist DLL) — self-held, released when " + f"stop_all exits; installer continues" + ) cats.append(("createfile-probe", probe_items)) # bundled-git residual @@ -1347,19 +1420,20 @@ def stop_all() -> int: print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})") time.sleep(0.3) print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)") - # Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01): after - # both kill retries the probe still reports locked files but neither - # the module-holder enumeration nor RM found an EXTERNAL owner — the - # lock holder is stop_all's own runtime (python-dist loaded - # install\lib modules) or an undetectable handle. The installer - # cannot win; a freshly launched installer process holds no locks. - # Exit 1 so the install aborts with this explanation instead of a - # code-5 dialog. + # Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined + # 18:57:09): after both kill retries the probe still reports locked + # files but neither the module-holder enumeration nor RM found an + # EXTERNAL owner — the lock holder is stop_all's own runtime + # (python-dist loaded install\lib modules). The installer runs stop_all + # synchronously (ewWaitUntilTerminated), so these locks are released + # when stop_all exits and the overwrite proceeds — advisory only, NOT + # a hard abort (the pre-18:57:09 guard wrongly blocked installs whose + # only locks were python-dist DLLs held by stop_all itself). if locked and not _module_holder_external_found and _rm_no_external_owner: print( - "emrg stop: WARNING lock holder is the stop_all runtime itself " - "(no external module-holder / RM owner) - installer will fail; " - "re-run installer (fresh process won't hold the lock)" + f"emrg stop: WARNING {len(locked)} file(s) locked by the " + f"stop_all runtime itself (python-dist DLL) — released when " + f"stop_all exits; installer continues" ) residuals = verify() if residuals: diff --git a/emrg/client/python_tui/widgets/markdown.py b/emrg/client/python_tui/widgets/markdown.py index e258daee..e90d58dc 100644 --- a/emrg/client/python_tui/widgets/markdown.py +++ b/emrg/client/python_tui/widgets/markdown.py @@ -47,6 +47,43 @@ def render(self, ctx: RenderContext) -> list[Line]: return lines +class UserMarkdown(Markdown): + """User message rendered as markdown with the role prefix preserved. + + Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages + go through the same Rich markdown pipeline as assistant messages — free + width-based wrapping, CJK wide-char handling — while keeping the + ``> `` prefix + bold cyan role visual. The markdown is rendered at + ``ctx.width - len(prefix)`` so the prefix on the first line never + overflows the buffer width (continuation lines get a same-width indent). + """ + + _ROLE_PREFIX = "> " + _ROLE_STYLE = "bold cyan" + + def render(self, ctx: RenderContext) -> list[Line]: + from rich.style import Style + + from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines + from emrg.client.python_tui.widgets.base import Span + + prefix = self._ROLE_PREFIX + indent = " " * len(prefix) + role_style = Style.parse(self._ROLE_STYLE) + avail = max(1, ctx.width - len(prefix)) + + md = RichMarkdown(self.text, code_theme="monokai") + md_lines = rich_renderable_to_lines(md, avail) + lines: list[Line] = [] + for i, line in enumerate(md_lines): + lead = prefix if i == 0 else indent + line.spans.insert(0, Span(text=lead, style=role_style)) + line.style = ctx.style + lines.append(line) + self._dirty = False + return lines + + @dataclass class StreamingMarkdown(Widget): """Incremental markdown renderer for token-by-token streaming. diff --git a/emrg/client/widgets.py b/emrg/client/widgets.py index 84e97bc3..f81a163c 100644 --- a/emrg/client/widgets.py +++ b/emrg/client/widgets.py @@ -10,7 +10,7 @@ from rich.style import Style from emrg.client.python_tui import ChatRow, ToolCard from emrg.client.python_tui.widgets.base import Line, Span, Widget -from emrg.client.python_tui.widgets.markdown import StreamingMarkdown +from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown class InputWidget(Widget): @@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v def add(self, role_or_widget, content=None): if isinstance(role_or_widget, Widget): self.rows.append(role_or_widget) + elif role_or_widget == "user": + # Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user + # messages render as markdown (free width wrap, CJK handling) + # while keeping the "> " prefix + cyan role visual. + self.rows.append(UserMarkdown(content or "")) else: self.rows.append(ChatRow(role=role_or_widget, content=content or "")) self._line_cache.append(None) # 新 row 无缓存 @@ -693,6 +698,11 @@ def update_last(self, content): row.dirty = True self._dirty = True return + if isinstance(row, UserMarkdown): + row.text = content + row.dirty = True + self._dirty = True + return def last_tool_card(self): for row in reversed(self.rows): diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index 36c995b0..10b7ffc0 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -158,23 +158,24 @@ def test_stop_all_py_deletefile_semantic_lock_probe(): assert "GENERIC_READ = 0x80000000" not in content # 旧常量赋值已移除 assert "SetFileInformationByHandle" in content assert "FILE_DISPOSITION_INFO = 2" in content - # 自锁防护(rant 2026-08-18T16:09:45):开头打印 python-dist 运行时 + 兜底 - # WARNING(lock holder 是 stop_all 自身 → 提示重跑安装器) + # 自锁防护(rant 2026-08-18T16:09:45,18:57:09 改为提示性):开头打印 + # python-dist 运行时 + verify 对 self-held 锁不中止安装(stop_all 退出即释放) assert "python-dist runtime:" in content - assert "lock holder is the stop_all runtime itself" in content - assert "re-run installer (fresh process won't hold the lock)" in content + assert "self-held" in content + assert "installer continues" in content + assert "re-run installer (fresh process won't hold the lock)" not in content # 旧文案已移除 def test_stop_all_py_rm_no_external_owner_flag(): """rant 2026-08-18T16:09:45 — _print_rm_diag 记录"无外部 owner"证据。 RM owners==0 或全部 owner 被祖先链排除 → _rm_no_external_owner=True, - stop_all 重试循环后据此输出自锁 WARNING 并 exit 1。 + stop_all 重试循环后据此输出自锁提示(18:57:09 改为 advisory,不再 exit 1)。 """ content = _read("emrg/_stop_all.py") assert "_rm_no_external_owner" in content assert 'd["owners"] == 0 or "owner(s) excluded" in stdout' in content - assert "re-run installer (fresh process won't hold the lock)" in content + assert "installer continues" in content def test_stop_all_py_module_holder_enumeration(): @@ -212,9 +213,10 @@ def test_stop_all_py_module_holder_enumeration(): assert '"RM re-scan", rm' in verify_src assert verify_src.index('"module-holder"') < verify_src.index('"RM re-scan"') assert "install-module holder" in verify_src - # 自锁兜底:外部 module-holder 与 RM owner 都无 → WARNING + # 自锁兜底:外部 module-holder 与 RM owner 都无 → 提示性 WARNING(18:57:09 + # 改为 advisory —— stop_all 退出即释放,安装继续) assert "_module_holder_external_found" in content - assert "no external module-holder / RM owner" in content + assert "installer continues" in content # createfile-probe 降级为补充(CreateFileW 探测对 DLL 模块锁假阴性) assert "createfile-probe" in content assert "module locks need the module-holder scan" in content @@ -302,3 +304,73 @@ def test_agent_md_no_stop_emrg_cmd_refs(): """No stale stop-emrg.cmd references in docs.""" for rel in ("README.md", "README.cn.md", "Agent.md"): assert "stop-emrg.cmd" not in _read(rel), rel + + +def test_classify_locked_files_self_held_only(): + """rant 2026-08-18T18:57:09 — python-dist DLLs locked by stop_all's own + runtime (module-holder tag=excluded) are self-held → NOT residuals.""" + from emrg._stop_all import _classify_locked_files + + root = "C:\\Users\\x\\.emrg\\install" + locked = [ + root + "\\bin\\python-dist\\python313.dll", + root + "\\bin\\python-dist\\select.pyd", + ] + holders = [ + (11572, "python.exe", "python-dist", 1, + ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"], "excluded"), + ] + self_held, residual = _classify_locked_files(locked, holders, root) + assert sorted(self_held) == ["bin/python-dist/python313.dll", "bin/python-dist/select.pyd"] + assert residual == [] + + +def test_classify_locked_files_external_holder_residual(): + """A locked file held by an EXTERNAL (target) module-holder stays residual.""" + from emrg._stop_all import _classify_locked_files + + root = "C:\\Users\\x\\.emrg\\install" + locked = [root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd"] + holders = [ + (9280, "python.exe", "browser_harness", 9556, + ["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"), + ] + self_held, residual = _classify_locked_files(locked, holders, root) + assert self_held == [] + assert residual == ["lib/websockets/speedups.cp313-win_amd64.pyd"] + + +def test_classify_locked_files_unattributable_residual_conservative(): + """A locked file with NO known module-holder stays residual (conservative — + could be a plain non-DLL lock held by an external process).""" + from emrg._stop_all import _classify_locked_files + + root = "C:\\Users\\x\\.emrg\\install" + locked = [root + "\\bin\\some-data-file.dat"] + self_held, residual = _classify_locked_files(locked, [], root) + assert self_held == [] + assert residual == ["bin/some-data-file.dat"] + + +def test_classify_locked_files_mixed(): + """Mixed: self-held python-dist + external pyd + unattributable data.""" + from emrg._stop_all import _classify_locked_files + + root = "C:\\Users\\x\\.emrg\\install" + locked = [ + root + "\\bin\\python-dist\\python313.dll", + root + "\\lib\\websockets\\speedups.cp313-win_amd64.pyd", + root + "\\bin\\data.dat", + ] + holders = [ + (11572, "python.exe", "python-dist", 1, + ["bin/python-dist/python313.dll"], "excluded"), + (9280, "python.exe", "browser_harness", 9556, + ["lib/websockets/speedups.cp313-win_amd64.pyd"], "target"), + ] + self_held, residual = _classify_locked_files(locked, holders, root) + assert self_held == ["bin/python-dist/python313.dll"] + assert sorted(residual) == [ + "bin/data.dat", + "lib/websockets/speedups.cp313-win_amd64.pyd", + ] diff --git a/tests/test_user_markdown.py b/tests/test_user_markdown.py new file mode 100644 index 00000000..8e8090e1 --- /dev/null +++ b/tests/test_user_markdown.py @@ -0,0 +1,102 @@ +"""Tests for UserMarkdown rendering (Plan B, rant 2026-08-18T18:52:45). + +User messages render through the same Rich markdown pipeline as assistant +messages — width-based wrapping + CJK handling — while keeping the "> " +prefix + bold cyan role visual. Verifies: +- long user messages wrap, no content loss / buffer truncation +- markdown styling (**bold**, `code`) is preserved +- CJK wide chars wrap at display width (no misalignment) +- ChatHistory routes "user" → UserMarkdown; system/tool/assistant → ChatRow +- update_last() updates a UserMarkdown row +""" + +from __future__ import annotations + +from rich.cells import cell_len + +from emrg.client.python_tui.buffer import Buffer, write_lines_to_buffer +from emrg.client.python_tui.widgets.base import RenderContext +from emrg.client.python_tui.widgets.chat_row import ChatRow +from emrg.client.python_tui.widgets.markdown import UserMarkdown +from emrg.client.widgets import ChatHistory + + +def _render(user_text: str, width: int) -> list[str]: + ctx = RenderContext(width=width) + lines = UserMarkdown(user_text).render(ctx) + return ["".join(s.text for s in line.spans) for line in lines] + + +def test_user_markdown_wraps_long_message(): + """Long user message wraps; first line has '> ', continuations indented.""" + visible = _render( + "This is a very long user message that should wrap into multiple lines", 20 + ) + assert len(visible) > 1 + assert visible[0].startswith("> ") + assert all(line.startswith(" ") for line in visible[1:]) + for line in visible: + assert cell_len(line) <= 20 + + +def test_user_markdown_no_content_loss_in_buffer(): + """Rendered lines written to the cell buffer retain the full text.""" + text = "a reasonably long user message that should be fully visible after wrap" + ctx = RenderContext(width=30) + lines = UserMarkdown(text).render(ctx) + buf = Buffer(width=30, height=10) + write_lines_to_buffer(buf, lines) + rows = [] + for y in range(10): + cells = [buf.get_cell(x, y).char or " " for x in range(30)] + rows.append("".join(cells).rstrip()) + joined = " ".join(" ".join(rows).split()) + assert "a reasonably long user message that should be fully visible after wrap" in joined + + +def test_user_markdown_styling_preserved(): + """Markdown syntax produces styled spans (not literal asterisks).""" + ctx = RenderContext(width=40) + lines = UserMarkdown("**bold text** and `code`").render(ctx) + spans = [s for line in lines for s in line.spans] + texts = [s.text for s in spans] + assert "**" not in "".join(texts) # markdown markers consumed + # The rendered bold segment carries a bold style + bold_span = next(s for s in spans if s.text == "bold text") + assert bold_span.style is not None and bool(bold_span.style.bold) + # The role prefix carries bold cyan + prefix_span = spans[0] + assert prefix_span.text == "> " + assert prefix_span.style.bold and prefix_span.style.color is not None + + +def test_user_markdown_cjk_wrap_boundary(): + """CJK wide chars wrap at display-width boundaries — no overflow.""" + text = "你好世界这是一个很长的中文消息需要自动换行处理" + visible = _render(text, 20) + for line in visible: + assert cell_len(line) <= 20 + joined = visible[0][2:] + "".join(line[2:] for line in visible[1:]) + assert joined.replace(" ", "") == text.replace(" ", "") + + +def test_chat_history_user_routes_to_user_markdown(): + """ChatHistory.add('user', ...) creates a UserMarkdown; others ChatRow.""" + chat = ChatHistory() + chat.add("user", "hello world") + chat.add("assistant", "hi") + chat.add("system", "sys") + chat.add("tool", "tool") + assert isinstance(chat.rows[0], UserMarkdown) + assert isinstance(chat.rows[1], ChatRow) + assert isinstance(chat.rows[2], ChatRow) + assert isinstance(chat.rows[3], ChatRow) + + +def test_chat_history_update_last_updates_user_markdown(): + """update_last() updates the last UserMarkdown row (assistant fallback path).""" + chat = ChatHistory() + chat.add("user", "original text") + chat.update_last("updated text") + assert isinstance(chat.rows[0], UserMarkdown) + assert chat.rows[0].text == "updated text"