diff --git a/experiments/shell/README.md b/experiments/shell/README.md index 047fe115..248b059a 100644 --- a/experiments/shell/README.md +++ b/experiments/shell/README.md @@ -61,7 +61,7 @@ R1 的 [#12–#17 限定合同](../../docs/milestones/R1-local-text.md)和 [#18 ## 选区丢弃诊断 -几何/DPI/缩放变化后若选区被 `TextSelection::reconcile` 丢弃,可用环境变量 `TERMFORGE_SELECTION_DIAG=` 打开一次性诊断:每次丢弃写入一行 `{"schema":"termforge-selection-discard-v1","reason":"...","row":N}`(`row` 是发生不一致的选区行序号,`0` 表示与具体行无关的分支),`reason` 取 `epoch-mismatch`、`anchor-missing-or-shrunk`、`row-identity-replaced`、`row-text-changed`、`endpoint-unavailable` 之一。未设置该变量时不写任何文件、行为不变;`experiments/shell/run.py` 的 `execute()` 会自动把该变量指向本次场景产物目录下的 `selection-discard.jsonl`,因此失败时该文件随 `build/test-artifacts/` 一起保留。`selection_core` 里新增的分组只验证观察器能收到分支名。 +几何/DPI/缩放变化后若选区被 `TextSelection::reconcile` 丢弃,可用环境变量 `TERMFORGE_SELECTION_DIAG=` 打开一次性诊断:每次丢弃写入一行 `{"schema":"termforge-selection-discard-v1","reason":"...","row":N}`(`row` 是发生不一致的选区行序号,`0` 表示与具体行无关的分支),`reason` 取 `epoch-mismatch`、`anchor-missing-or-shrunk`、`row-identity-replaced`、`row-text-changed`、`endpoint-unavailable` 之一。未设置该变量时不写任何文件、行为不变;另有 `TERMFORGE_CAPTURE_OUTPUT=<路径>` 会把该窗口消费的**全部输出字节**追加写入文件(同样 opt-in、默认不写),供失败时逐字节回放;`experiments/shell/run.py` 的 `execute()` 会自动把该变量指向本次场景产物目录下的 `selection-discard.jsonl`,因此失败时该文件随 `build/test-artifacts/` 一起保留。`selection_core` 里新增的分组只验证观察器能收到分支名。 ## 所有权与调度 diff --git a/experiments/shell/run.py b/experiments/shell/run.py index 5d3d1335..e1c2efb7 100644 --- a/experiments/shell/run.py +++ b/experiments/shell/run.py @@ -432,6 +432,7 @@ def execute(exe, mode, out, timeout=40, settings_source=None, *, resources=False # the variable is set, and the path stays inside the preserved artifacts. environment = dict(os.environ) environment.setdefault("TERMFORGE_SELECTION_DIAG", str((out / "selection-discard.jsonl").resolve())) + environment.setdefault("TERMFORGE_CAPTURE_OUTPUT", str((out / "output.bin").resolve())) process = subprocess.Popen([str(exe.with_name("shell_test_launcher.exe").resolve()), desktop.full_name, mode, str((out / "frame.bmp").resolve())], cwd=out, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/experiments/shell/shell.cpp b/experiments/shell/shell.cpp index 3b51701e..e455bbee 100644 --- a/experiments/shell/shell.cpp +++ b/experiments/shell/shell.cpp @@ -30,16 +30,17 @@ namespace { // Opt-in diagnostic for the selection-discard path, used when a hosted geometry // change drops a live selection. Gated by TERMFORGE_SELECTION_DIAG=; // without it nothing is written and no behaviour changes. -std::string selection_diag_path() { +std::string env_path(const char* name) { #ifdef _MSC_VER char* buffer = nullptr; size_t size = 0; - if (_dupenv_s(&buffer, &size, "TERMFORGE_SELECTION_DIAG") != 0 || !buffer) return {}; + if (_dupenv_s(&buffer, &size, name) != 0 || !buffer) return {}; std::string value(buffer); std::free(buffer); return value; #else - const char* value = std::getenv("TERMFORGE_SELECTION_DIAG"); + const char* value = std::getenv(name); return value ? std::string(value) : std::string{}; #endif } +std::string selection_diag_path() { return env_path("TERMFORGE_SELECTION_DIAG"); } void selection_discard_diagnostic(const termforge::TextSelection::DiscardReport& report) { static const std::string path = selection_diag_path(); if (path.empty()) return; @@ -104,6 +105,7 @@ class ShellWindow { tf_shell_test::HostedImeObservation ime_observation_; inline static uint64_t next_identity_ = 0; const uint64_t identity_ = ++next_identity_; + std::ofstream capture_output_; std::unique_ptr clipboard_read_; std::optional pending_paste_; std::unique_ptr paste_prompt_; @@ -809,6 +811,13 @@ class ShellWindow { observed_output_.append(bytes); if(observed_output_.size()>8192) observed_output_.erase(0,observed_output_.size()-8192); } + // Opt-in full capture: TERMFORGE_CAPTURE_OUTPUT= appends every + // consumed chunk so a hosted failure can be replayed byte for byte. + // Nothing is written unless the variable is set. + if (capture_output_) { + capture_output_.write(bytes.data(), static_cast(bytes.size())); + capture_output_.flush(); + } parser_.feed(bytes); batch += bytes.size(); #ifdef TERMFORGE_CANDIDATE_CORE route_terminal_events(); @@ -992,6 +1001,9 @@ class ShellWindow { DispatchMessageW(&message); } ShellWindow(std::string mode, std::string image) : mode_(std::move(mode)), image_path_(std::move(image)) { + // Opt-in full output capture for replayable failure evidence. + const std::string capture = env_path("TERMFORGE_CAPTURE_OUTPUT"); + if (!capture.empty()) capture_output_.open(capture, std::ios::binary | std::ios::app); snapshot_ = std::make_shared(viewport_.snapshot(parser_.screen), 0); } ~ShellWindow() {