From 3bc5415450bed7b4d75f2b30d32d05ef502e3f3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:13:44 +0000 Subject: [PATCH 1/7] fix: use config.script_path instead of truncated UI label text Agent-Logs-Url: https://github.com/123sleaf-123/OneDragon-ScriptChainer/sessions/5cac24b1-51ab-4dcd-8883-ac1058343e20 Co-authored-by: 123sleaf-123 <80097831+123sleaf-123@users.noreply.github.com> --- src/script_chainer/gui/page/script_setting_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/script_chainer/gui/page/script_setting_interface.py b/src/script_chainer/gui/page/script_setting_interface.py index a4bafb8..163fea4 100644 --- a/src/script_chainer/gui/page/script_setting_interface.py +++ b/src/script_chainer/gui/page/script_setting_interface.py @@ -221,7 +221,7 @@ def _get_editable_combo_value(self, card: EditableComboBoxSettingCard) -> str: def get_config_value(self) -> ScriptConfig: config = self.config.copy() - config.script_path = self.script_path_opt.contentLabel.text() + config.script_path = self.config.script_path config.script_process_name = self._get_editable_combo_value(self.script_process_name_opt) config.game_process_name = self._get_editable_combo_value(self.game_process_name_opt) config.run_timeout_seconds = int(self.run_timeout_seconds_opt.getValue()) From 85a79190712c40c3e8b50119e03035e9c45da28d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 04:33:54 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=97=A0=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E9=87=8D=E5=90=AF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScriptConfig 新增 no_log_timeout_seconds / no_log_max_retries 字段 - script_runner: stdout 回调追踪最后输出时间,监控循环检测静默超时并抛出异常,run_script 实现完整重试循环 - GUI: ScriptEditDialog 新增开关+超时秒数+最大重试次数配置项 Agent-Logs-Url: https://github.com/123sleaf-123/OneDragon-ScriptChainer/sessions/d68e27fd-bfe2-4a58-a5f9-74d96e436f06 Co-authored-by: 123sleaf-123 <80097831+123sleaf-123@users.noreply.github.com> --- src/script_chainer/config/script_config.py | 2 + .../gui/page/script_setting_interface.py | 57 +++++++++ src/script_chainer/win_exe/script_runner.py | 115 ++++++++++++++---- 3 files changed, 149 insertions(+), 25 deletions(-) diff --git a/src/script_chainer/config/script_config.py b/src/script_chainer/config/script_config.py index 4727d29..7da3176 100644 --- a/src/script_chainer/config/script_config.py +++ b/src/script_chainer/config/script_config.py @@ -66,6 +66,8 @@ class ScriptConfig: notify_log_interval: int = 0 enabled: bool = True attach_direction: str = AttachDirection.NONE + no_log_timeout_seconds: int = 0 + no_log_max_retries: int = 3 # 不参与序列化的元数据 idx: int = field(default=0, repr=False, compare=False) diff --git a/src/script_chainer/gui/page/script_setting_interface.py b/src/script_chainer/gui/page/script_setting_interface.py index 9fb652e..a299d63 100644 --- a/src/script_chainer/gui/page/script_setting_interface.py +++ b/src/script_chainer/gui/page/script_setting_interface.py @@ -19,6 +19,7 @@ MessageBoxBase, PrimaryDropDownPushButton, RoundMenu, + SpinBox, SubtitleLabel, SwitchButton, TransparentToolButton, @@ -187,6 +188,38 @@ def __init__(self, config: ScriptConfig, parent=None): ) self.viewLayout.addWidget(self.notify_log_opt) + # ── 静默超时重启 ── + self.no_log_timeout_input = SpinBox() + self.no_log_timeout_input.setRange(30, 86400) + self.no_log_timeout_input.setSingleStep(30) + self.no_log_timeout_input.setFixedWidth(140) + + self.no_log_timeout_switch = SwitchButton() + self.no_log_timeout_switch.setOnText('') + self.no_log_timeout_switch.setOffText('') + self.no_log_timeout_switch.checkedChanged.connect(self._on_no_log_timeout_toggled) + + self.no_log_timeout_opt = MultiPushSettingCard( + icon=FluentIcon.SYNC, + title='无日志超时重启(秒)', + content='超过设定秒数无日志输出时,判定为未响应并重新执行', + btn_list=[self.no_log_timeout_input, self.no_log_timeout_switch], + ) + self.viewLayout.addWidget(self.no_log_timeout_opt) + + self.no_log_max_retries_input = SpinBox() + self.no_log_max_retries_input.setRange(1, 99) + self.no_log_max_retries_input.setSingleStep(1) + self.no_log_max_retries_input.setFixedWidth(140) + + self.no_log_max_retries_opt = MultiPushSettingCard( + icon=FluentIcon.SYNC, + title='最大重启次数', + content='无日志超时时最多重启的次数', + btn_list=[self.no_log_max_retries_input], + ) + self.viewLayout.addWidget(self.no_log_max_retries_opt) + self.init_by_config(config) def init_by_config(self, config: ScriptConfig): @@ -215,10 +248,28 @@ def init_by_config(self, config: ScriptConfig): self.notify_log_interval_input.blockSignals(False) self.notify_log_interval_input.setEnabled(notify_log_enabled) + no_log_enabled = config.no_log_timeout_seconds > 0 + self.no_log_timeout_switch.blockSignals(True) + self.no_log_timeout_switch.setChecked(no_log_enabled) + self.no_log_timeout_switch.blockSignals(False) + self.no_log_timeout_input.blockSignals(True) + self.no_log_timeout_input.setValue(config.no_log_timeout_seconds if no_log_enabled else 300) + self.no_log_timeout_input.blockSignals(False) + self.no_log_timeout_input.setEnabled(no_log_enabled) + self.no_log_max_retries_input.blockSignals(True) + self.no_log_max_retries_input.setValue(max(1, config.no_log_max_retries)) + self.no_log_max_retries_input.blockSignals(False) + self.no_log_max_retries_input.setEnabled(no_log_enabled) + def _on_notify_log_toggled(self, checked: bool) -> None: """日志推送开关切换时启用/禁用间隔输入框""" self.notify_log_interval_input.setEnabled(checked) + def _on_no_log_timeout_toggled(self, checked: bool) -> None: + """静默超时重启开关切换时启用/禁用相关输入框""" + self.no_log_timeout_input.setEnabled(checked) + self.no_log_max_retries_input.setEnabled(checked) + @staticmethod def _set_editable_combo_value(card: EditableComboBoxSettingCard, value: str) -> None: """设置可编辑下拉框的值,若预设列表中无匹配则直接设置文本""" @@ -280,6 +331,12 @@ def get_config_value(self) -> ScriptConfig: else: config.notify_log_interval = 0 + if self.no_log_timeout_switch.isChecked(): + config.no_log_timeout_seconds = max(30, self.no_log_timeout_input.value()) + else: + config.no_log_timeout_seconds = 0 + config.no_log_max_retries = self.no_log_max_retries_input.value() + return config def validate(self) -> bool: diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 7e4e175..6ebfe27 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -36,6 +36,10 @@ _active_pm: ProcessManager | None = None +class _NoLogTimeoutError(Exception): + """脚本长时间无日志输出时抛出,触发外层重试。""" + + class _TeeWriter: """包装 stdout,将每行输出同时写入 LogNotifier。""" @@ -126,12 +130,14 @@ def _run_group_script( def _make_stdout_callback( display_name: str, log_notifier: LogNotifier | None = None, + last_log_time: list[float] | None = None, ) -> Callable[[str], None]: """创建 stdout 回调,为每行输出添加前缀。 Args: display_name: 显示名称。 log_notifier: 可选的日志通知器,用于定时推送日志。 + last_log_time: 可选的单元素列表,用于记录最后一次收到日志的时间戳(线程安全写入)。 """ prefix = f'{Style.DIM}[{display_name}]{Style.RESET_ALL}' @@ -140,6 +146,8 @@ def _on_stdout(line: str) -> None: log.info('[脚本] %s', line) if log_notifier is not None: log_notifier.add(line) + if last_log_time is not None: + last_log_time[0] = time.time() return _on_stdout @@ -147,6 +155,7 @@ def _on_stdout(line: str) -> None: def _launch_script( script_config: ScriptConfig, log_notifier: LogNotifier | None = None, + last_log_time: list[float] | None = None, ) -> ProcessManager: """启动脚本子进程并返回 ProcessManager。 @@ -158,6 +167,7 @@ def _launch_script( Args: script_config: 脚本配置。 log_notifier: 可选的日志通知器,用于定时推送日志。 + last_log_time: 可选的单元素列表,用于记录最后一次收到日志的时间戳。 Returns: 已初始化的 ProcessManager。 @@ -185,6 +195,7 @@ def _launch_script( stdout_callback=_make_stdout_callback( display_name, log_notifier=log_notifier, + last_log_time=last_log_time, ), ) except LauncherExitError as e: @@ -258,18 +269,25 @@ def _wait_for_subprocess_ready( return False - -def _monitor_script_done(script_config: ScriptConfig) -> None: +def _monitor_script_done( + script_config: ScriptConfig, + last_log_time: list[float] | None = None, +) -> None: """监控脚本运行状态,等待完成条件满足。 Args: script_config: 脚本配置。 + last_log_time: 可选的单元素列表,记录最后一次收到日志的时间戳。 + 当 script_config.no_log_timeout_seconds > 0 时启用静默超时检测, + 超时后抛出 _NoLogTimeoutError。 """ start_time = time.time() script_ever_existed: bool = False game_ever_existed: bool = False last_status: str = '' + no_log_timeout = script_config.no_log_timeout_seconds + while True: is_done: bool = False status: str = '' @@ -316,8 +334,9 @@ def _monitor_script_done(script_config: ScriptConfig) -> None: print_message(f'未知的检查结束方式 {script_config.check_done}', level='ERROR') is_done = True - # 超时检查 now = time.time() + + # 总运行超时检查 if now - start_time > script_config.run_timeout_seconds: is_done = True print_message(f'脚本运行超时 {script_config.script_display_name}', level='ERROR') @@ -325,6 +344,18 @@ def _monitor_script_done(script_config: ScriptConfig) -> None: if is_done: break + # 静默超时检查(无日志输出超时,触发重启) + if ( + no_log_timeout > 0 + and last_log_time is not None + and now - last_log_time[0] > no_log_timeout + ): + print_message( + f'脚本超过 {no_log_timeout} 秒无日志输出,判定为未响应 {script_config.script_display_name}', + level='ERROR', + ) + raise _NoLogTimeoutError() + time.sleep(1) @@ -371,9 +402,14 @@ def run_script( 1. 校验配置。 2. 启动子进程(使用 ProcessManager)。 3. 等待子进程就绪。 - 4. 监控运行状态。 + 4. 监控运行状态(若配置了静默超时,无日志输出超时后终止并重启)。 5. 清理进程。 + 静默超时重启逻辑: + 当 script_config.no_log_timeout_seconds > 0 时,若脚本在指定时间内没有任何 + 日志输出,则认为游戏/脚本未响应,会终止当前进程并重新启动,最多重试 + script_config.no_log_max_retries 次。 + Args: script_config: 脚本配置。 log_notifier: 可选的日志通知器,用于定时推送日志。 @@ -386,31 +422,60 @@ def run_script( return script_path = script_config.script_path + no_log_timeout = script_config.no_log_timeout_seconds + max_retries = script_config.no_log_max_retries if no_log_timeout > 0 else 0 - # 1. 启动脚本子进程 - pm = _launch_script(script_config, log_notifier=log_notifier) - _active_pm = pm + # last_log_time[0] 记录最近一次收到子进程输出的时间(仅外部 EXE 脚本有效) + last_log_time: list[float] | None = [time.time()] if no_log_timeout > 0 else None - # 2. 等待子进程就绪 - # 仅当脚本进程名与启动文件名不同时才期望追踪目标进程(launcher 场景) - expect_target = ( - bool(script_config.script_process_name) - and script_config.script_process_name.lower() != PurePath(script_path).name.lower() - ) - if not _wait_for_subprocess_ready(pm, script_path, expect_target=expect_target): - print_message(f'子进程创建失败 {script_path}', level='ERROR') - pm.kill() - _active_pm = None - return - - print_message(f'脚本子进程创建成功 {script_path}', level='PASS') + for attempt in range(max_retries + 1): + if attempt > 0: + print_message( + f'第 {attempt}/{max_retries} 次重启脚本 {script_config.script_display_name}', + level='INFO', + ) + # 重置日志时间戳,避免残留旧时间立即再次触发 + if last_log_time is not None: + last_log_time[0] = time.time() + + # 1. 启动脚本子进程 + pm = _launch_script(script_config, log_notifier=log_notifier, last_log_time=last_log_time) + _active_pm = pm + + # 2. 等待子进程就绪 + # 仅当脚本进程名与启动文件名不同时才期望追踪目标进程(launcher 场景) + expect_target = ( + bool(script_config.script_process_name) + and script_config.script_process_name.lower() != PurePath(script_path).name.lower() + ) + if not _wait_for_subprocess_ready(pm, script_path, expect_target=expect_target): + print_message(f'子进程创建失败 {script_path}', level='ERROR') + pm.kill() + _active_pm = None + return - # 3. 监控脚本运行状态 - _monitor_script_done(script_config) + print_message(f'脚本子进程创建成功 {script_path}', level='PASS') - # 4. 清理进程 - _cleanup_processes(script_config, pm) - _active_pm = None + # 3. 监控脚本运行状态 + try: + _monitor_script_done(script_config, last_log_time=last_log_time) + except _NoLogTimeoutError: + # 静默超时:终止当前进程,进入下一轮重试 + _cleanup_processes(script_config, pm) + _active_pm = None + if attempt < max_retries: + continue + else: + print_message( + f'已达最大重试次数 ({max_retries}),放弃重启 {script_config.script_display_name}', + level='ERROR', + ) + return + + # 4. 清理进程(正常退出路径) + _cleanup_processes(script_config, pm) + _active_pm = None + return def _run_python_script( From f3b178bb3251d9d2084f0ea57669ffc8d73b2716 Mon Sep 17 00:00:00 2001 From: sleaf Date: Thu, 30 Apr 2026 20:31:46 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E9=9D=99=E9=BB=98=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=94=AF=E6=8C=81=E6=9C=80=E5=B0=8F1?= =?UTF-8?q?=E7=A7=92=EF=BC=8C=E4=BF=AE=E5=A4=8D=E9=87=8D=E8=AF=95=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将静默超时时间范围从30~86400调整为1~86400,步进从30改为1 - 修复静默超时重试时break误用为continue,确保正确进入下一轮重试 --- src/script_chainer/gui/page/script_setting_interface.py | 6 +++--- src/script_chainer/win_exe/script_runner.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/script_chainer/gui/page/script_setting_interface.py b/src/script_chainer/gui/page/script_setting_interface.py index c395e47..f51a9e2 100644 --- a/src/script_chainer/gui/page/script_setting_interface.py +++ b/src/script_chainer/gui/page/script_setting_interface.py @@ -191,8 +191,8 @@ def __init__(self, config: ScriptConfig, parent=None): # ── 静默超时重启 ── self.no_log_timeout_input = SpinBox() - self.no_log_timeout_input.setRange(30, 86400) - self.no_log_timeout_input.setSingleStep(30) + self.no_log_timeout_input.setRange(1, 86400) + self.no_log_timeout_input.setSingleStep(1) self.no_log_timeout_input.setFixedWidth(140) self.no_log_timeout_switch = SwitchButton() @@ -333,7 +333,7 @@ def get_config_value(self) -> ScriptConfig: config.notify_log_interval = 0 if self.no_log_timeout_switch.isChecked(): - config.no_log_timeout_seconds = max(30, self.no_log_timeout_input.value()) + config.no_log_timeout_seconds = max(1, self.no_log_timeout_input.value()) else: config.no_log_timeout_seconds = 0 config.no_log_max_retries = self.no_log_max_retries_input.value() diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 8d2f3dc..a2487a9 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -501,7 +501,7 @@ def run_script( # 静默超时:终止当前进程,进入下一轮重试 _cleanup_processes(script_config, pm) if attempt < max_retries: - break # 跳出 try 块,由外层 for 循环进入下一轮 + continue # 跳出 try 块,由外层 for 循环进入下一轮 else: print_message( f'已达最大重试次数 ({max_retries}),放弃重启 {script_config.script_display_name}', From 2860475c01268bbf271de40a1c7fb97554c9efb0 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 2 May 2026 17:13:18 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=E5=8D=A1=E4=BD=8F=E5=90=8E=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E6=9D=80=E6=8E=89=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/win_exe/script_runner.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index a2487a9..5415aa4 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -397,7 +397,7 @@ def _monitor_script_done( break -def _cleanup_processes(script_config: ScriptConfig, pm: ProcessManager) -> None: +def _cleanup_processes(script_config: ScriptConfig, pm: ProcessManager, force_script: bool = False) -> None: """清理脚本和游戏进程。 通过 ProcessManager.kill() 精确终止已追踪的进程及其子进程树(基于 PID)。 @@ -405,8 +405,9 @@ def _cleanup_processes(script_config: ScriptConfig, pm: ProcessManager) -> None: Args: script_config: 脚本配置。 pm: ProcessManager 实例。 + force_script: 是否忽略用户配置,强制终止当前被管理的脚本进程。 """ - if script_config.kill_script_after_done: + if force_script or script_config.kill_script_after_done: print_message(f'尝试关闭脚本进程 {pm.main_name} (pid={pm.main_pid})') try: pm.kill() @@ -499,7 +500,7 @@ def run_script( _monitor_script_done(script_config, state, last_log_time=last_log_time) except _NoLogTimeoutError: # 静默超时:终止当前进程,进入下一轮重试 - _cleanup_processes(script_config, pm) + _cleanup_processes(script_config, pm, force_script=True) if attempt < max_retries: continue # 跳出 try 块,由外层 for 循环进入下一轮 else: From e0973ef1e775c162f08e1bf2dd42213505efa2ab Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 2 May 2026 17:18:16 +0800 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=E6=8A=8Alast=5Flog=5Ftime?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=88=B0state=E9=87=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/win_exe/script_runner.py | 37 +++++++++------------ 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 5415aa4..13235f6 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -55,6 +55,7 @@ class _RunMonitorState: script_ever_existed: bool = False game_ever_existed: bool = False + last_log_time: float | None = None class _TeeWriter: @@ -164,14 +165,14 @@ def _run_group_script( def _make_stdout_callback( display_name: str, log_notifier: LogNotifier | None = None, - last_log_time: list[float] | None = None, + state: _RunMonitorState | None = None, ) -> Callable[[str], None]: """创建 stdout 回调,为每行输出添加前缀。 Args: display_name: 显示名称。 log_notifier: 可选的日志通知器,用于定时推送日志。 - last_log_time: 可选的单元素列表,用于记录最后一次收到日志的时间戳(线程安全写入)。 + state: 可选的运行监控状态,用于记录最后一次收到日志的时间戳。 """ prefix = f'{Style.DIM}[{display_name}]{Style.RESET_ALL}' @@ -180,8 +181,8 @@ def _on_stdout(line: str) -> None: log.info('[脚本] %s', line) if log_notifier is not None: log_notifier.add(line) - if last_log_time is not None: - last_log_time[0] = time.time() + if state is not None: + state.last_log_time = time.time() return _on_stdout @@ -189,7 +190,7 @@ def _on_stdout(line: str) -> None: def _launch_script( script_config: ScriptConfig, log_notifier: LogNotifier | None = None, - last_log_time: list[float] | None = None, + state: _RunMonitorState | None = None, ) -> ProcessManager: """启动脚本子进程并返回 ProcessManager。 @@ -201,7 +202,7 @@ def _launch_script( Args: script_config: 脚本配置。 log_notifier: 可选的日志通知器,用于定时推送日志。 - last_log_time: 可选的单元素列表,用于记录最后一次收到日志的时间戳。 + state: 可选的运行监控状态,用于记录最后一次收到日志的时间戳。 Returns: 已初始化的 ProcessManager。 @@ -229,7 +230,7 @@ def _launch_script( stdout_callback=_make_stdout_callback( display_name, log_notifier=log_notifier, - last_log_time=last_log_time, + state=state, ), ) except LauncherExitError as e: @@ -309,16 +310,12 @@ def _wait_for_subprocess_ready( def _monitor_script_done( script_config: ScriptConfig, state: _RunMonitorState, - last_log_time: list[float] | None = None, ) -> None: """监控脚本运行状态,等待完成条件满足。 Args: script_config: 脚本配置。 state: 运行监控状态(跨 _wait_for_subprocess_ready 持久化的进程存在标志)。 - last_log_time: 可选的单元素列表,记录最后一次收到日志的时间戳。 - 当 script_config.no_log_timeout_seconds > 0 时启用静默超时检测, - 超时后抛出 _NoLogTimeoutError。 """ start_time = time.time() last_status: str = '' @@ -384,8 +381,8 @@ def _monitor_script_done( # 静默超时检查(无日志输出超时,触发重启) if ( no_log_timeout > 0 - and last_log_time is not None - and now - last_log_time[0] > no_log_timeout + and state.last_log_time is not None + and now - state.last_log_time > no_log_timeout ): print_message( f'脚本超过 {no_log_timeout} 秒无日志输出,判定为未响应 {script_config.script_display_name}', @@ -464,23 +461,17 @@ def run_script( no_log_timeout = script_config.no_log_timeout_seconds max_retries = script_config.no_log_max_retries if no_log_timeout > 0 else 0 - # last_log_time[0] 记录最近一次收到子进程输出的时间(仅外部 EXE 脚本有效) - last_log_time: list[float] | None = [time.time()] if no_log_timeout > 0 else None - for attempt in range(max_retries + 1): if attempt > 0: print_message( f'第 {attempt}/{max_retries} 次重启脚本 {script_config.script_display_name}', level='INFO', ) - # 重置日志时间戳,避免残留旧时间立即再次触发 - if last_log_time is not None: - last_log_time[0] = time.time() # 1. 启动脚本子进程 - pm = _launch_script(script_config, log_notifier=log_notifier, last_log_time=last_log_time) - _active_pm = pm state = _RunMonitorState() + pm = _launch_script(script_config, log_notifier=log_notifier, state=state) + _active_pm = pm try: # 2. 等待子进程就绪 # 仅当脚本进程名与启动文件名不同时才期望追踪目标进程(launcher 场景) @@ -494,10 +485,12 @@ def run_script( return print_message(f'脚本子进程创建成功 {script_path}', level='PASS') + if no_log_timeout > 0: + state.last_log_time = time.time() # 3. 监控脚本运行状态 try: - _monitor_script_done(script_config, state, last_log_time=last_log_time) + _monitor_script_done(script_config, state) except _NoLogTimeoutError: # 静默超时:终止当前进程,进入下一轮重试 _cleanup_processes(script_config, pm, force_script=True) From 4a301039e4fe9a176e8b764411ab5748c9fbdba0 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 2 May 2026 20:18:13 +0800 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20=E6=8F=90=E5=8F=96=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E5=87=BD=E6=95=B0=E5=B9=B6=E6=8F=90=E4=BE=9B=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/services/log_notifier.py | 4 + src/script_chainer/win_exe/script_runner.py | 151 ++++++++++++-------- 2 files changed, 94 insertions(+), 61 deletions(-) diff --git a/src/script_chainer/services/log_notifier.py b/src/script_chainer/services/log_notifier.py index 855ba07..3788ea6 100644 --- a/src/script_chainer/services/log_notifier.py +++ b/src/script_chainer/services/log_notifier.py @@ -59,6 +59,10 @@ def stop(self) -> None: self._timer = None self._flush() + def flush(self) -> None: + """立即推送当前通知池中的日志。""" + self._flush() + def _schedule_next(self) -> None: if self._stopped: return diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 13235f6..1121c72 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -148,20 +148,6 @@ def _push_chain_notification( ) -def _run_group_script( - script_config: ScriptConfig, - log_notifier: LogNotifier | None = None, -) -> None: - """运行运行组中的单个脚本。""" - try: - if script_config.script_type == ScriptType.PYTHON: - _run_python_script(script_config, log_notifier=log_notifier) - else: - run_script(script_config, log_notifier=log_notifier) - except Exception: - log.error('脚本执行异常', exc_info=True) - - def _make_stdout_callback( display_name: str, log_notifier: LogNotifier | None = None, @@ -307,6 +293,7 @@ def _wait_for_subprocess_ready( return False + def _monitor_script_done( script_config: ScriptConfig, state: _RunMonitorState, @@ -428,23 +415,23 @@ def _cleanup_processes(script_config: ScriptConfig, pm: ProcessManager, force_sc log.error('关闭游戏进程失败', exc_info=True) -def run_script( +def _run_script_once( script_config: ScriptConfig, log_notifier: LogNotifier | None = None, ) -> None: - """运行单个脚本的完整生命周期。 + """运行单个脚本的一次完整生命周期。 流程: 1. 校验配置。 2. 启动子进程(使用 ProcessManager)。 3. 等待子进程就绪。 - 4. 监控运行状态(若配置了静默超时,无日志输出超时后终止并重启)。 + 4. 监控运行状态。 5. 清理进程。 - 静默超时重启逻辑: + 静默超时逻辑: 当 script_config.no_log_timeout_seconds > 0 时,若脚本在指定时间内没有任何 - 日志输出,则认为游戏/脚本未响应,会终止当前进程并重新启动,最多重试 - script_config.no_log_max_retries 次。 + 日志输出,则认为游戏/脚本未响应,会终止当前进程并向调用方抛出 + _NoLogTimeoutError,由链编排层决定是否重试和发送通知。 Args: script_config: 脚本配置。 @@ -459,54 +446,96 @@ def run_script( script_path = script_config.script_path no_log_timeout = script_config.no_log_timeout_seconds - max_retries = script_config.no_log_max_retries if no_log_timeout > 0 else 0 - for attempt in range(max_retries + 1): - if attempt > 0: + # 1. 启动脚本子进程 + state = _RunMonitorState() + pm = _launch_script(script_config, log_notifier=log_notifier, state=state) + _active_pm = pm + try: + # 2. 等待子进程就绪 + # 仅当脚本进程名与启动文件名不同时才期望追踪目标进程(launcher 场景) + expect_target = ( + bool(script_config.script_process_name) + and script_config.script_process_name.lower() != PurePath(script_path).name.lower() + ) + if not _wait_for_subprocess_ready(pm, script_path, state, expect_target=expect_target): + print_message(f'子进程创建失败 {script_path}', level='ERROR') + pm.kill() + return + + print_message(f'脚本子进程创建成功 {script_path}', level='PASS') + if no_log_timeout > 0: + state.last_log_time = time.time() + + # 3. 监控脚本运行状态 + try: + _monitor_script_done(script_config, state) + except _NoLogTimeoutError: + _cleanup_processes(script_config, pm, force_script=True) + raise + + # 4. 清理进程(正常退出路径) + _cleanup_processes(script_config, pm) + finally: + _active_pm = None + + +def _run_external_script_with_retries( + script_config: ScriptConfig, + log_notifier: LogNotifier | None = None, + ctx: ScriptChainerContext | None = None, + chain_name: str = '', +) -> None: + """运行外部脚本,并在静默超时时按配置重试。""" + max_retries = ( + script_config.no_log_max_retries + if script_config.no_log_timeout_seconds > 0 + else 0 + ) + for retry_count in range(max_retries + 1): + if retry_count > 0: + if log_notifier is not None: + log_notifier.flush() print_message( - f'第 {attempt}/{max_retries} 次重启脚本 {script_config.script_display_name}', + f'重试运行脚本 ({retry_count}/{max_retries}) ' + f'{script_config.script_display_name}', level='INFO', ) - - # 1. 启动脚本子进程 - state = _RunMonitorState() - pm = _launch_script(script_config, log_notifier=log_notifier, state=state) - _active_pm = pm + if script_config.notify_start: + _push_chain_notification( + ctx, + chain_name, + f'无日志超时重试 ({retry_count}/{max_retries})', + script_config, + ) try: - # 2. 等待子进程就绪 - # 仅当脚本进程名与启动文件名不同时才期望追踪目标进程(launcher 场景) - expect_target = ( - bool(script_config.script_process_name) - and script_config.script_process_name.lower() != PurePath(script_path).name.lower() + _run_script_once(script_config, log_notifier=log_notifier) + return + except _NoLogTimeoutError: + if retry_count < max_retries: + continue + print_message( + f'已达最大重试次数 ({max_retries}),放弃重启 ' + f'{script_config.script_display_name}', + level='ERROR', ) - if not _wait_for_subprocess_ready(pm, script_path, state, expect_target=expect_target): - print_message(f'子进程创建失败 {script_path}', level='ERROR') - pm.kill() - return + return - print_message(f'脚本子进程创建成功 {script_path}', level='PASS') - if no_log_timeout > 0: - state.last_log_time = time.time() - # 3. 监控脚本运行状态 - try: - _monitor_script_done(script_config, state) - except _NoLogTimeoutError: - # 静默超时:终止当前进程,进入下一轮重试 - _cleanup_processes(script_config, pm, force_script=True) - if attempt < max_retries: - continue # 跳出 try 块,由外层 for 循环进入下一轮 - else: - print_message( - f'已达最大重试次数 ({max_retries}),放弃重启 {script_config.script_display_name}', - level='ERROR', - ) - return - - # 4. 清理进程(正常退出路径) - _cleanup_processes(script_config, pm) - finally: - _active_pm = None +def _run_script_in_group( + script_config: ScriptConfig, + log_notifier: LogNotifier | None = None, + ctx: ScriptChainerContext | None = None, + chain_name: str = '', +) -> None: + """运行运行组中的单个脚本。""" + try: + if script_config.script_type == ScriptType.PYTHON: + _run_python_script(script_config, log_notifier) + else: + _run_external_script_with_retries(script_config, log_notifier, ctx, chain_name) + except Exception: + log.error('脚本执行异常', exc_info=True) def _run_python_script( @@ -670,7 +699,7 @@ def run_chain(chain_name: str = '01', shutdown_delay: int = 0, debug_index: int ) for script_config in group.scripts: - _run_group_script(script_config, log_notifier=log_notifier) + _run_script_in_group(script_config, log_notifier, ctx, chain_name) if group.host.notify_done: _push_chain_notification( From c996d8d40ee8c2d0edc9d66540bef78fb511345b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 2 May 2026 20:27:26 +0800 Subject: [PATCH 7/7] =?UTF-8?q?style:=20=E7=BB=9F=E4=B8=80=E8=B0=83?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/win_exe/script_runner.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 1121c72..49a1990 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -213,11 +213,7 @@ def _launch_script( args=args_list, target_process=target, search_timeout=30, - stdout_callback=_make_stdout_callback( - display_name, - log_notifier=log_notifier, - state=state, - ), + stdout_callback=_make_stdout_callback(display_name, log_notifier, state), ) except LauncherExitError as e: log.error('启动器异常退出: %s', e, exc_info=True) @@ -449,7 +445,7 @@ def _run_script_once( # 1. 启动脚本子进程 state = _RunMonitorState() - pm = _launch_script(script_config, log_notifier=log_notifier, state=state) + pm = _launch_script(script_config, log_notifier, state) _active_pm = pm try: # 2. 等待子进程就绪 @@ -509,7 +505,7 @@ def _run_external_script_with_retries( script_config, ) try: - _run_script_once(script_config, log_notifier=log_notifier) + _run_script_once(script_config, log_notifier) return except _NoLogTimeoutError: if retry_count < max_retries: