Skip to content

feat: retain lossless failure keyframes(暂缓) - #1593

Draft
tianguzhang wants to merge 18 commits into
MaaXYZ:mainfrom
tianguzhang:feat/on-error-video
Draft

tianguzhang wants to merge 18 commits into
MaaXYZ:mainfrom
tianguzhang:feat/on-error-video

Conversation

@tianguzhang

@tianguzhang tianguzhang commented Oct 29, 2026 •

Copy link
Copy Markdown

A single error screenshot can miss the transition that caused a Pipeline failure. This adds an opt-in, lossless history of the actual recognition inputs, following #1594.

  • SaveOnErrorFrames is disabled by default and independent of SaveOnError, SaveDraw, and debug mode. Enabling it saves unredacted recognition images.
  • A shared 32 MiB raw-image budget covers active history, pinned action boundaries, and queued exports. Pixel-identical frames are deduplicated. A bounded background writer saves PNGs and frames.json.
  • Screenshot cache updates and cached reads share a mutex, so readers receive a complete image while capture is running.
  • Exports use retained directory handles below the configured log root, private staging, exclusive file creation, and publication without replacement. Unsafe POSIX staging parents and descendant symlinks/reparse points are rejected. Unsupported filesystems fail the export rather than weaken these guarantees.

Validation at 1b328722: local Windows Framework/Toolkit builds and SDK regressions passed, including failure-frame history, concurrent cached-image reads, live-job cache clearing, and neural-network frame lifetime. Native Windows and WSL Linux tests cover occupied files, late publication collisions, directory replacement, and link handling. Removing the POSIX parent guard from a temporary build makes the new regression fail as expected. Temporary probes remain local; permanent coverage is included in PipelineTesting and the Python binding suite.

Cross-platform builds and Windows, Ubuntu, and macOS tests passed.

Open design items remain in #1594. Task completion waits at most 15 seconds for export, so a timeout does not guarantee files are ready for client packaging. Final archive-size enforcement belongs to the client. Earlier intermittent pixel differences remain unresolved, with the original failed artifacts retained; later passing runs do not establish a fix.

@Windsland52

Copy link
Copy Markdown
Member

不太可能录视频,留关键帧还差不多

@MistEO

MistEO commented Oct 29, 2026

Copy link
Copy Markdown
Member

大的 feat 可以先开个 issue 大家一起讨论下再做

@Windsland52

Copy link
Copy Markdown
Member

关于关键帧我有一点想法,目前还在开发工具中尝试,完善了之后能以少量图片和数据尽可能无损还原一段时间内的画面变化

@tianguzhang

Copy link
Copy Markdown
Author

好

@tianguzhang tianguzhang changed the title feat: save bounded video before pipeline errors feat: save bounded video before pipeline errors(暂缓) Oct 29, 2026
@tianguzhang tianguzhang changed the title feat: save bounded video before pipeline errors(暂缓) feat: retain lossless failure keyframes(暂缓) Oct 30, 2026
@tianguzhang
tianguzhang marked this pull request as ready for review November 6, 2026 13:03
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-06T13:14:27.305373Z 85b4794 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

你好——我发现了 11 个问题

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="214" />
<code_context>
+    temporary += ".partial";
+    try {
+        std::filesystem::create_directories(temporary / "frames");
+        json::array manifest;
+        cv::Mat previous;
+        for (size_t i = 0; i < job.frames.size(); ++i) {
</code_context>
<issue_to_address>
**Export follows planted symlinks**

When a local attacker can modify the configured `on_error` directory while a task failure export is being written, `create_directories` and the `ofstream` calls follow symlinks under the export path, so an attacker who can modify the shared log directory can race the writer with a symlink and make the application overwrite a file outside that directory.

Create the temporary export directory with restrictive permissions and write files using symlink-resistant, exclusive file creation, rejecting any pre-existing symlink or unexpected path component.

Also at `source/MaaFramework/Task/FailureFrames.cpp:224-226`, `source/MaaFramework/Task/FailureFrames.cpp:262-264`.
</issue_to_address>

### Comment 2
<location path="source/MaaFramework/Global/OptionMgr.cpp" line_range="124" />
<code_context>
+        return false;
+    }
+
+    save_on_error_frames_ = *reinterpret_cast<const bool*>(value);
+
+    LogInfo << "Set save on error frames" << VAR(save_on_error_frames_);
</code_context>
<issue_to_address>
**Changing the setting races tasks**

When a binding or native caller changes the option while a task is running, `set_save_on_error_frames` writes a plain `bool` while the task runner reads it in recognition and action paths, causing a C++ data race and undefined behavior; callers changing the setting during an active task can corrupt that task's execution.

Make the setting atomic or synchronize reads and writes.

Also at `source/MaaFramework/Global/OptionMgr.h:31`.
</issue_to_address>

### Comment 3
<location path="source/MaaFramework/Global/OptionMgr.h" line_range="55" />
<code_context>
     bool save_draw_ = false;
     bool debug_mode_ = false;
     bool save_on_error_ = false;
+    bool save_on_error_frames_ = true;
     int draw_quality_ = 85;
     size_t reco_image_cache_limit_ = 4096;
</code_context>
<issue_to_address>
**Sensitive screens are retained by default**

When an application processes a screen containing credentials or personal data, its log directory is nonempty, and a Pipeline error occurs, `Recognizer::recognize` captures the unredacted recognition input, and `FailureFrames::write` encodes it as PNG in the configured log directory; credentials or personal data therefore remain in error artifacts accessible to anyone who can access or receives those logs.

Default failure-frame capture to off and require explicit opt-in, with clear notice that unredacted screen images will be written to the log directory.

Also at `source/MaaFramework/Task/FailureFrames.cpp:220-226`.
</issue_to_address>

### Comment 4
<location path="source/MaaFramework/Task/PipelineTask.cpp" line_range="498" />
<code_context>
+    if (option.save_on_error_frames() && tasker()) {
+        auto& frames = tasker()->failure_frames();
+        frames.observe(image, MaaInvalidId, node_name, true);
+        frames.save(directory / path(std::format("{}_{}", stamp, task_id_)), node_name);
     }
-
</code_context>
<issue_to_address>
**Recovered tasks still leave failure exports**

When a node failure is handled by an error path that later recovers, or the task is stopped, `PipelineTask::save_on_error` queues the export before the final task outcome is known, and nothing cancels it after recovery or stop. The writer therefore publishes failure-frame files for tasks that succeed or stop.

Queue the export only after the task outcome is final, or cancel it when the task recovers or stops.

Also at `source/MaaFramework/Task/FailureFrames.cpp:163-164`.
</issue_to_address>

### Comment 5
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="163" />
<code_context>
+{
+    try {
+        std::scoped_lock lock(mutex_);
+        if (!active_ || pending_ || (frames_.empty() && !before_action_ && !after_action_)) {
+            LogWarn << "failure frame export unavailable or busy" << directory;
+            return;
</code_context>
<issue_to_address>
**One task produces duplicate exports**

When a later Pipeline error occurs after the first export has been accepted, `save()` checks `pending_`, but `work()` clears it when writing starts, so the later request is accepted instead of being dropped. Multiple exports for one task can create duplicate bundles or target the same timestamp-and-task-ID paths, causing a rename failure and a missing export.

Track that an export has already been accepted for the task and reject all subsequent save requests.

Also at `source/MaaFramework/Task/PipelineTask.cpp:488-498`, `source/MaaFramework/Task/FailureFrames.cpp:210-211`, `source/MaaFramework/Task/FailureFrames.cpp:266`.
</issue_to_address>

### Comment 6
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="103" />
<code_context>
+                return;
+            }
+            while (frames_.size() > 1 && (bytes_->load() + length > kMaxBytes || frames_.size() >= kMaxFrames)) {
+                frames_.pop_front();
+            }
+            // Pins must not freeze capture when the remaining pool cannot fit
</code_context>
<issue_to_address>
**Memory-drop count is inaccurate**

When memory pressure evicts retained frames or a pin is reset while its frame remains in history, eviction removes frames without incrementing `dropped_`, while resetting a pin increments it even when the frame remains retained. The exported `dropped_for_memory` therefore does not report the number of frames actually lost.

Update `dropped_` only when a frame is actually removed from history.

Also at `source/MaaFramework/Task/FailureFrames.cpp:112-113`, `source/MaaFramework/Task/FailureFrames.cpp:260`.
</issue_to_address>

### Comment 7
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="102-103" />
<code_context>
+                ++dropped_;
+                return;
+            }
+            while (frames_.size() > 1 && (bytes_->load() + length > kMaxBytes || frames_.size() >= kMaxFrames)) {
+                frames_.pop_front();
+            }
+            // Pins must not freeze capture when the remaining pool cannot fit
</code_context>
<issue_to_address>
**History collapses after 64 frames**

When the history reaches 64 distinct frames and another distinct image is observed, `observe` keeps evicting while the frame-count limit is reached, not just until one slot is free. The next distinct frame therefore leaves only two frames in the history, so the export loses most of the retained context.

Handle the frame-count limit separately from byte pressure and evict only the oldest frame needed to make room.
</issue_to_address>

### Comment 8
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="173" />
<code_context>
+                job.frames.push_back(**pinned);
+            }
+        }
+        std::ranges::sort(job.frames, {}, &Frame::first_ms);
+        pending_ = std::move(job);
+        if (!writer_.joinable()) {
</code_context>
<issue_to_address>
**Exported frames can be out of order**

When distinct frames are captured within the same millisecond, `save()` sorts only by millisecond-resolution `first_ms`, so frames with equal timestamps can be reordered. The exported sequence and `change_bbox` values then no longer reliably describe capture order.

Use a tie-breaker that preserves capture order when sorting frames with equal `first_ms`.

Also at `source/MaaFramework/Task/FailureFrames.cpp:92`.
</issue_to_address>

### Comment 9
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="95" />
<code_context>
+        const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
+        const bool equal = !frames_.empty() && image.size() == frames_.back().pixels->image.size()
+                           && image.type() == frames_.back().pixels->image.type()
+                           && cv::norm(image, frames_.back().pixels->image, cv::NORM_INF) == 0;
+        if (!equal) {
+            const size_t length = image.total() * image.elemSize();
</code_context>
<issue_to_address>
**Repeated full-frame comparisons slow recognition**

When a captured image is tested against multiple candidates before one matches, `Recognizer::recognize` observes the image for every candidate node, even though `PipelineTask::recognize_list` passes the same captured image through its candidate loop. With frame history enabled by default, each such observation runs a full-image `cv::norm` against the retained frame, so fallback-heavy pipelines repeatedly scan the same high-resolution image and take longer to recognize.

Deduplicate observations by captured-image identity or generation so each recognition input is compared and recorded at most once.

Also at `source/MaaFramework/Task/Component/Recognizer.cpp:119`.
</issue_to_address>

### Comment 10
<location path="include/MaaFramework/MaaDef.h" line_range="134" />
<code_context>
+    ///
+    /// value: bool, eg: true; val_size: sizeof(bool)
+    /// default value is true, independent of SaveOnError and DebugMode
+    MaaGlobalOption_SaveOnErrorFrames = 10,
 };

</code_context>
<issue_to_address>
**C++ module omits new option**

`MaaFramework.cppm` does not export `MaaGlobalOption_SaveOnErrorFrames`, so C++20 module consumers cannot use the new option through the module interface.

Export the new option from `source/modules/MaaFramework.cppm`.
</issue_to_address>

### Comment 11
<location path="source/binding/Python/maa/tasker.py" line_range="736" />
<code_context>
             )
         )

+    @staticmethod
+    def set_save_on_error_frames(save_on_error_frames: bool) -> bool:
+        """设置是否保存任务失败关键帧,默认启用 / Set whether to save task failure keyframes, enabled by default
</code_context>
<issue_to_address>
**Python option lacks test coverage**

The new Python setter has no corresponding test in `test/python/`, so regressions in configuring this option are not caught by the required Python unit tests.

Add a Python unit test that calls `set_save_on_error_frames` and verifies the option-setting result.
</issue_to_address>

Sourcery 对开源项目免费——如果你喜欢我们的审查,请考虑分享 ✨
Original comment in English

Hey - I've found 11 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="214" />
<code_context>
+    temporary += ".partial";
+    try {
+        std::filesystem::create_directories(temporary / "frames");
+        json::array manifest;
+        cv::Mat previous;
+        for (size_t i = 0; i < job.frames.size(); ++i) {
</code_context>
<issue_to_address>
**Export follows planted symlinks**

When a local attacker can modify the configured `on_error` directory while a task failure export is being written, `create_directories` and the `ofstream` calls follow symlinks under the export path, so an attacker who can modify the shared log directory can race the writer with a symlink and make the application overwrite a file outside that directory.

Create the temporary export directory with restrictive permissions and write files using symlink-resistant, exclusive file creation, rejecting any pre-existing symlink or unexpected path component.

Also at `source/MaaFramework/Task/FailureFrames.cpp:224-226`, `source/MaaFramework/Task/FailureFrames.cpp:262-264`.
</issue_to_address>

### Comment 2
<location path="source/MaaFramework/Global/OptionMgr.cpp" line_range="124" />
<code_context>
+        return false;
+    }
+
+    save_on_error_frames_ = *reinterpret_cast<const bool*>(value);
+
+    LogInfo << "Set save on error frames" << VAR(save_on_error_frames_);
</code_context>
<issue_to_address>
**Changing the setting races tasks**

When a binding or native caller changes the option while a task is running, `set_save_on_error_frames` writes a plain `bool` while the task runner reads it in recognition and action paths, causing a C++ data race and undefined behavior; callers changing the setting during an active task can corrupt that task's execution.

Make the setting atomic or synchronize reads and writes.

Also at `source/MaaFramework/Global/OptionMgr.h:31`.
</issue_to_address>

### Comment 3
<location path="source/MaaFramework/Global/OptionMgr.h" line_range="55" />
<code_context>
     bool save_draw_ = false;
     bool debug_mode_ = false;
     bool save_on_error_ = false;
+    bool save_on_error_frames_ = true;
     int draw_quality_ = 85;
     size_t reco_image_cache_limit_ = 4096;
</code_context>
<issue_to_address>
**Sensitive screens are retained by default**

When an application processes a screen containing credentials or personal data, its log directory is nonempty, and a Pipeline error occurs, `Recognizer::recognize` captures the unredacted recognition input, and `FailureFrames::write` encodes it as PNG in the configured log directory; credentials or personal data therefore remain in error artifacts accessible to anyone who can access or receives those logs.

Default failure-frame capture to off and require explicit opt-in, with clear notice that unredacted screen images will be written to the log directory.

Also at `source/MaaFramework/Task/FailureFrames.cpp:220-226`.
</issue_to_address>

### Comment 4
<location path="source/MaaFramework/Task/PipelineTask.cpp" line_range="498" />
<code_context>
+    if (option.save_on_error_frames() && tasker()) {
+        auto& frames = tasker()->failure_frames();
+        frames.observe(image, MaaInvalidId, node_name, true);
+        frames.save(directory / path(std::format("{}_{}", stamp, task_id_)), node_name);
     }
-
</code_context>
<issue_to_address>
**Recovered tasks still leave failure exports**

When a node failure is handled by an error path that later recovers, or the task is stopped, `PipelineTask::save_on_error` queues the export before the final task outcome is known, and nothing cancels it after recovery or stop. The writer therefore publishes failure-frame files for tasks that succeed or stop.

Queue the export only after the task outcome is final, or cancel it when the task recovers or stops.

Also at `source/MaaFramework/Task/FailureFrames.cpp:163-164`.
</issue_to_address>

### Comment 5
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="163" />
<code_context>
+{
+    try {
+        std::scoped_lock lock(mutex_);
+        if (!active_ || pending_ || (frames_.empty() && !before_action_ && !after_action_)) {
+            LogWarn << "failure frame export unavailable or busy" << directory;
+            return;
</code_context>
<issue_to_address>
**One task produces duplicate exports**

When a later Pipeline error occurs after the first export has been accepted, `save()` checks `pending_`, but `work()` clears it when writing starts, so the later request is accepted instead of being dropped. Multiple exports for one task can create duplicate bundles or target the same timestamp-and-task-ID paths, causing a rename failure and a missing export.

Track that an export has already been accepted for the task and reject all subsequent save requests.

Also at `source/MaaFramework/Task/PipelineTask.cpp:488-498`, `source/MaaFramework/Task/FailureFrames.cpp:210-211`, `source/MaaFramework/Task/FailureFrames.cpp:266`.
</issue_to_address>

### Comment 6
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="103" />
<code_context>
+                return;
+            }
+            while (frames_.size() > 1 && (bytes_->load() + length > kMaxBytes || frames_.size() >= kMaxFrames)) {
+                frames_.pop_front();
+            }
+            // Pins must not freeze capture when the remaining pool cannot fit
</code_context>
<issue_to_address>
**Memory-drop count is inaccurate**

When memory pressure evicts retained frames or a pin is reset while its frame remains in history, eviction removes frames without incrementing `dropped_`, while resetting a pin increments it even when the frame remains retained. The exported `dropped_for_memory` therefore does not report the number of frames actually lost.

Update `dropped_` only when a frame is actually removed from history.

Also at `source/MaaFramework/Task/FailureFrames.cpp:112-113`, `source/MaaFramework/Task/FailureFrames.cpp:260`.
</issue_to_address>

### Comment 7
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="102-103" />
<code_context>
+                ++dropped_;
+                return;
+            }
+            while (frames_.size() > 1 && (bytes_->load() + length > kMaxBytes || frames_.size() >= kMaxFrames)) {
+                frames_.pop_front();
+            }
+            // Pins must not freeze capture when the remaining pool cannot fit
</code_context>
<issue_to_address>
**History collapses after 64 frames**

When the history reaches 64 distinct frames and another distinct image is observed, `observe` keeps evicting while the frame-count limit is reached, not just until one slot is free. The next distinct frame therefore leaves only two frames in the history, so the export loses most of the retained context.

Handle the frame-count limit separately from byte pressure and evict only the oldest frame needed to make room.
</issue_to_address>

### Comment 8
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="173" />
<code_context>
+                job.frames.push_back(**pinned);
+            }
+        }
+        std::ranges::sort(job.frames, {}, &Frame::first_ms);
+        pending_ = std::move(job);
+        if (!writer_.joinable()) {
</code_context>
<issue_to_address>
**Exported frames can be out of order**

When distinct frames are captured within the same millisecond, `save()` sorts only by millisecond-resolution `first_ms`, so frames with equal timestamps can be reordered. The exported sequence and `change_bbox` values then no longer reliably describe capture order.

Use a tie-breaker that preserves capture order when sorting frames with equal `first_ms`.

Also at `source/MaaFramework/Task/FailureFrames.cpp:92`.
</issue_to_address>

### Comment 9
<location path="source/MaaFramework/Task/FailureFrames.cpp" line_range="95" />
<code_context>
+        const auto now = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
+        const bool equal = !frames_.empty() && image.size() == frames_.back().pixels->image.size()
+                           && image.type() == frames_.back().pixels->image.type()
+                           && cv::norm(image, frames_.back().pixels->image, cv::NORM_INF) == 0;
+        if (!equal) {
+            const size_t length = image.total() * image.elemSize();
</code_context>
<issue_to_address>
**Repeated full-frame comparisons slow recognition**

When a captured image is tested against multiple candidates before one matches, `Recognizer::recognize` observes the image for every candidate node, even though `PipelineTask::recognize_list` passes the same captured image through its candidate loop. With frame history enabled by default, each such observation runs a full-image `cv::norm` against the retained frame, so fallback-heavy pipelines repeatedly scan the same high-resolution image and take longer to recognize.

Deduplicate observations by captured-image identity or generation so each recognition input is compared and recorded at most once.

Also at `source/MaaFramework/Task/Component/Recognizer.cpp:119`.
</issue_to_address>

### Comment 10
<location path="include/MaaFramework/MaaDef.h" line_range="134" />
<code_context>
+    ///
+    /// value: bool, eg: true; val_size: sizeof(bool)
+    /// default value is true, independent of SaveOnError and DebugMode
+    MaaGlobalOption_SaveOnErrorFrames = 10,
 };

</code_context>
<issue_to_address>
**C++ module omits new option**

`MaaFramework.cppm` does not export `MaaGlobalOption_SaveOnErrorFrames`, so C++20 module consumers cannot use the new option through the module interface.

Export the new option from `source/modules/MaaFramework.cppm`.
</issue_to_address>

### Comment 11
<location path="source/binding/Python/maa/tasker.py" line_range="736" />
<code_context>
             )
         )

+    @staticmethod
+    def set_save_on_error_frames(save_on_error_frames: bool) -> bool:
+        """设置是否保存任务失败关键帧,默认启用 / Set whether to save task failure keyframes, enabled by default
</code_context>
<issue_to_address>
**Python option lacks test coverage**

The new Python setter has no corresponding test in `test/python/`, so regressions in configuring this option are not caught by the required Python unit tests.

Add a Python unit test that calls `set_save_on_error_frames` and verifies the option-setting result.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread source/MaaFramework/Global/OptionMgr.h Resolved
Comment thread include/MaaFramework/MaaDef.h Resolved
Comment thread source/binding/Python/maa/tasker.py Resolved

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 85b479412e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread source/MaaFramework/Tasker/Tasker.cpp Resolved
Retain parent inputs before custom callbacks and arm post-action capture without a preceding image. Order frames by capture sequence, count actual history losses, and synchronize option access.

Refs MaaXYZ#1594
Keep the original export name until the destination or staging path is occupied. Create a fresh staging directory before writing so existing evidence stays intact.

Refs MaaXYZ#1594
Compare continuous 8-bit images bytewise and retain the existing norm check for other layouts and depths. Keep observing each recognition input because shared buffers can change between callbacks.
Verify the history option independently of legacy screenshots, pixel-exact PNG exports, history eviction and cleanup after stopping an active task.
Disable native and Toolkit history capture by default while honoring existing config values. Document that opted-in exports contain unredacted recognition inputs and cover default and persisted configurations in the binding suite.
Create private staging and exclusive files relative to retained directory handles. Publish without replacing existing archives, and reject unsafe POSIX staging parents.
Prevent cached_image from cloning pixels while the screenshot worker updates the same buffer.
@tianguzhang
tianguzhang marked this pull request as draft November 13, 2026 13:58

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants