diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md
index ef3799e..e581ffb 100644
--- a/DEVELOPMENT_LOG.md
+++ b/DEVELOPMENT_LOG.md
@@ -1368,3 +1368,15 @@
- 强制用原始 `start_time` 后前 3 秒评价钩子,要求 15 秒内出现第一次有效刺激,并把评论动机、单一话题和完整反应闭环设为硬门槛。
- 片段以 60–90 秒为主,普通可看内容不得虚高到 78 分;本次不修改三阶段分析代码、A/B/C 门槛、候选池或历史分析结果。
- 相关选片与 Prompt 测试 `43 passed`,Ruff 和 `git diff --check` 通过;活动 SQLite 更新前已生成一致性备份,更新后 `integrity_check=ok`、外键异常为 0,其他 Prompt 与任务记录未变化。
+
+## 2026-08-29 RunDock Worker 回环直连修复
+
+- 定位到 RunDock 同时正常托管 `Niuma-Studio` 与 `Niuma-Publish-Worker`,但 Web 继承系统代理且缺少回环绕过,导致 `127.0.0.1:8765` 请求可能被代理返回 502;问题不是 Worker 未启动。
+- `PublishWorkerClient` 改用显式空代理 opener,所有健康检查、账号检查、登录、创作者中心、投稿与 execution 查询均不再继承系统代理;既有超时与结果不确定边界保持不变。
+- `start_native.ps1` 与 `run_native.ps1` 共用 `native_environment.ps1`,保留已有 `NO_PROXY` 条目、去重并追加 `127.0.0.1,localhost,::1`。
+- 发送中心与后端的旧 Docker Worker 提示改为当前 RunDock 原生双进程托管说明;Web 不会自行启动或合并独立 Worker 注册项。
+- 活动 SQLite 和 RunDock 状态在运行态调整前已备份;本次不修改数据库结构、公开 API、全局代理、AI Provider、账号配置或 E 盘媒体。
+- 仅为 Web 注册项补充 `NO_PROXY=127.0.0.1,localhost,::1` 并受控重启 Web;Worker 注册项和监听进程保持不变。现场复核 8001/8765 各只有一个监听者,调度器 `worker_available=true` 且 `last_scan_at` 连续推进。
+- 依次补发 `ea237f98f568` 与 `2059b5822678`,分别取得 execution `feee7d7789fb44fc8bce2b6a21ec2d11`、`bf7ae642f7d840588761c49879f9149b`;Worker 均为 `confirmed_success`,数据库均为 `PUBLISHED`,无 `NEED_REVIEW`、结果不确定或重复任务。
+- 抖音创作者中心独立核对:作品“小钟一上场就质疑代班主持:这集可以看吗”显示 2026-08-29 23:37 已发布,作品“小钟把英文歌唱成‘虾酥’,竟然真的接到代言”显示 2026-08-29 23:38 已发布;补发后仅剩原有 8 条未来排期。
+- 验证结果:定向测试 `23 passed`,完整测试 `803 passed`;Ruff、Compileall、PowerShell 5/7 静态解析和 `git diff --check` 全部通过。测试期间未调用真实平台,真实投稿严格限定为上述两条漏发任务。
diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md
index 1fc8210..8ede106 100644
--- a/NEXT_STEPS.md
+++ b/NEXT_STEPS.md
@@ -1125,3 +1125,11 @@
2. 人工检查候选片段原始开头前 3 秒是否独立成立、15 秒内是否出现有效刺激,并优先保留 60–90 秒的完整互动回合。
3. 不要为了凑数开启普通片段;记录“不好笑、节奏拖沓、铺垫不足、内容重复”等反馈,供后续全局复审参考。
4. 连续发布 10 条后,在抖音后台与此前 10 条比较前段留存、平均观看、完播和评论表现;若无改善,再排查评分门槛、标题封面、账号分发或素材重复。
+
+## 2026-08-29 RunDock Worker 回环与漏发验收(已完成)
+
+1. RunDock 中 `Niuma-Studio` 与 `Niuma-Publish-Worker` 均保持独立运行,8001/8765 各只有一个监听进程;Web 已补充回环 `NO_PROXY`,Worker 未重启、未合并注册项。
+2. 调度器已恢复为 `worker_available=true`,`last_scan_at` 连续推进;两条漏发任务分别取得独立 execution,Worker 均为 `confirmed_success`,数据库均为 `PUBLISHED`。
+3. 抖音创作者中心已逐条核对 23:37 与 23:38 两条作品,状态均为“已发布”;发送中心只剩 8 条未来排期,没有新建重复任务。
+4. 下一步仅需观察下一条未来排期是否按原计划执行;若以后出现登录、验证码、风控、超时或结果不确定,继续按 `NEED_REVIEW` 规则停止自动重试并人工核对。
+5. 本修复通过 PR 评审和 CI 后再由用户决定是否 Squash 合并;不得自动合并。
diff --git a/app/services/publishers/worker_client.py b/app/services/publishers/worker_client.py
index 56db7d2..80e6685 100644
--- a/app/services/publishers/worker_client.py
+++ b/app/services/publishers/worker_client.py
@@ -7,7 +7,7 @@
import socket
from typing import Any
from urllib.error import HTTPError, URLError
-from urllib.request import Request, urlopen
+from urllib.request import ProxyHandler, Request, build_opener
from app.core.config import settings
from app.services.publishers.base import (
@@ -26,6 +26,12 @@
}
+def urlopen(request: Request, timeout: int):
+ """直连本机 Worker,不继承宿主进程的系统代理。"""
+ opener = build_opener(ProxyHandler({}))
+ return opener.open(request, timeout=timeout) # noqa: S310 - Worker URL is local configuration
+
+
def validate_worker_identifier(value: str, field_name: str, *, max_length: int) -> str:
"""验证会进入 URL、journal 或 Windows 目录名的稳定标识。"""
text = str(value or "")
@@ -92,8 +98,8 @@ def _request(self, method: str, path: str, payload: dict[str, Any] | None = None
and not definitely_not_connected
)
raise PublishWorkerUnavailable(
- "发送服务正在随 Docker 中的牛马片场项目自动启动。"
- "如果刚刚运行项目,请稍候并重新检测;持续未连接时,请在 Docker Desktop 中停止后重新运行本项目。",
+ "Windows 发布 Worker 未连接。请确认 RunDock 中独立托管的 "
+ "Niuma-Publish-Worker 正在运行,并检查本机 127.0.0.1:8765 监听状态。",
request_may_have_been_received=timed_out or publish_result_uncertain,
) from exc
if not raw:
diff --git a/app/static/js/publish-center.js b/app/static/js/publish-center.js
index 1c656f6..7325707 100644
--- a/app/static/js/publish-center.js
+++ b/app/static/js/publish-center.js
@@ -1442,7 +1442,7 @@ if (publishCenterRoot) {
const ready = schedulerHealthy && data.worker_available;
const resultMessage = schedulerFailures
? (data.last_error_message || "调度扫描异常,正在自动重试")
- : (data.worker_available ? "调度器与 Windows Worker 均已连接。" : "发送服务仍在随 Docker 项目自动启动;请稍候,或在 Docker Desktop 中停止后重新运行本项目。");
+ : (data.worker_available ? "调度器与 Windows Worker 均已连接。" : "发送服务由 RunDock 的 Niuma-Publish-Worker 独立托管;请检查该注册项和本机 127.0.0.1:8765 监听状态。");
showMessage(resultMessage, ready ? "success" : "error");
}
} catch (error) {
diff --git a/app/templates/publish.html b/app/templates/publish.html
index ba870cf..2f57bbd 100644
--- a/app/templates/publish.html
+++ b/app/templates/publish.html
@@ -164,7 +164,7 @@
{% if scheduler_health.consecutive_failures %}{{ scheduler_health.last_error_message }} · {% endif %}{{ scheduler_health.worker_message }} · 页面及排期均使用北京时间
- 自动连接:发送服务会在 Docker 中的牛马片场项目运行后自动启动。若刚刚运行项目,请稍候并点击“重新检测”;持续未连接时,请在 Docker Desktop 中停止后重新运行本项目。
+ 自动连接:发送服务由 RunDock 的 Niuma-Publish-Worker 独立托管。若刚刚启动项目,请稍候并点击“重新检测”;持续未连接时,请检查该注册项和本机 127.0.0.1:8765 监听状态。
diff --git a/scripts/native_environment.ps1 b/scripts/native_environment.ps1
new file mode 100644
index 0000000..e576cb6
--- /dev/null
+++ b/scripts/native_environment.ps1
@@ -0,0 +1,22 @@
+function Merge-NativeNoProxy {
+ [CmdletBinding()]
+ param(
+ [AllowNull()]
+ [AllowEmptyString()]
+ [string[]]$ExistingValues = @()
+ )
+
+ $merged = @()
+ $seen = @{}
+ foreach ($value in @($ExistingValues) + @('127.0.0.1', 'localhost', '::1')) {
+ foreach ($entry in ([string]$value -split ',')) {
+ $candidate = $entry.Trim()
+ if ([string]::IsNullOrWhiteSpace($candidate) -or $seen.ContainsKey($candidate)) {
+ continue
+ }
+ $seen[$candidate] = $true
+ $merged += $candidate
+ }
+ }
+ return ($merged -join ',')
+}
diff --git a/scripts/run_native.ps1 b/scripts/run_native.ps1
index c0cc18d..98e0a93 100644
--- a/scripts/run_native.ps1
+++ b/scripts/run_native.ps1
@@ -12,6 +12,7 @@ param(
$ErrorActionPreference = 'Stop'
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path.TrimEnd('\')
Set-Location -LiteralPath $ProjectRoot
+. (Join-Path $PSScriptRoot 'native_environment.ps1')
function Resolve-NativePath {
param(
@@ -51,6 +52,10 @@ if ($databaseParent) {
New-Item -ItemType Directory -Path $databaseParent -Force | Out-Null
}
+$NativeNoProxy = Merge-NativeNoProxy -ExistingValues @(
+ [Environment]::GetEnvironmentVariable('NO_PROXY', 'Process'),
+ [Environment]::GetEnvironmentVariable('no_proxy', 'Process')
+)
$nativeEnvironment = [ordered]@{
DATA_DIR = $DataDirPath
DATABASE_PATH = $DatabasePathPath
@@ -62,6 +67,7 @@ $nativeEnvironment = [ordered]@{
PUBLISH_WORKER_URL = "http://127.0.0.1:$WorkerPort"
OPENCLI_HOST_BRIDGE_URL = "http://127.0.0.1:$WorkerPort"
OPENCLI_LOCAL_BASE_URL = "http://127.0.0.1:$Port"
+ NO_PROXY = $NativeNoProxy
}
$previousEnvironment = @{}
diff --git a/scripts/start_native.ps1 b/scripts/start_native.ps1
index 7f0d3e6..05b2039 100644
--- a/scripts/start_native.ps1
+++ b/scripts/start_native.ps1
@@ -14,6 +14,7 @@ param(
$ErrorActionPreference = 'Stop'
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path.TrimEnd('\')
Set-Location -LiteralPath $ProjectRoot
+. (Join-Path $PSScriptRoot 'native_environment.ps1')
function Resolve-NativePath {
param(
@@ -117,6 +118,10 @@ if (-not $SkipWorker) {
}
}
+$NativeNoProxy = Merge-NativeNoProxy -ExistingValues @(
+ [Environment]::GetEnvironmentVariable('NO_PROXY', 'Process'),
+ [Environment]::GetEnvironmentVariable('no_proxy', 'Process')
+)
$nativeEnvironment = [ordered]@{
DATA_DIR = $DataDirPath
DATABASE_PATH = $DatabasePathPath
@@ -128,6 +133,7 @@ $nativeEnvironment = [ordered]@{
PUBLISH_WORKER_URL = "http://127.0.0.1:$WorkerPort"
OPENCLI_HOST_BRIDGE_URL = "http://127.0.0.1:$WorkerPort"
OPENCLI_LOCAL_BASE_URL = "http://127.0.0.1:$Port"
+ NO_PROXY = $NativeNoProxy
}
$shellCommand = Get-Command powershell.exe -ErrorAction SilentlyContinue
diff --git a/tests/test_native_scripts.py b/tests/test_native_scripts.py
index b3695cb..804e0e1 100644
--- a/tests/test_native_scripts.py
+++ b/tests/test_native_scripts.py
@@ -1,5 +1,9 @@
+import shutil
+import subprocess
from pathlib import Path
+import pytest
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -61,6 +65,31 @@ def test_native_scripts_set_host_paths_and_loopback_urls_without_env_file_access
assert ".env" not in runner
assert "compose" not in start.lower()
assert "compose" not in runner.lower()
+ for script in (start, runner):
+ assert "native_environment.ps1" in script
+ assert "Merge-NativeNoProxy" in script
+ assert "NO_PROXY = $NativeNoProxy" in script
+
+
+def test_native_no_proxy_merge_preserves_existing_entries_and_adds_loopback():
+ shell = shutil.which("powershell.exe") or shutil.which("pwsh")
+ if not shell:
+ pytest.skip("当前环境没有 PowerShell")
+ helper = (PROJECT_ROOT / "scripts" / "native_environment.ps1").resolve()
+ escaped_helper = str(helper).replace("'", "''")
+ command = (
+ f". '{escaped_helper}'; "
+ "Merge-NativeNoProxy -ExistingValues "
+ "@('corp.internal,localhost', 'api.local,127.0.0.1')"
+ )
+ result = subprocess.run(
+ [shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.stdout.strip() == "corp.internal,localhost,api.local,127.0.0.1,::1"
def test_native_stop_requires_state_identity_and_only_stops_owned_worker():
diff --git a/tests/test_publish_worker_autostart.py b/tests/test_publish_worker_autostart.py
index f56fa93..f68ef7a 100644
--- a/tests/test_publish_worker_autostart.py
+++ b/tests/test_publish_worker_autostart.py
@@ -56,8 +56,11 @@ def test_publish_center_no_longer_requests_manual_start_command():
worker_client = _read("app/services/publishers/worker_client.py")
assert r".\scripts\start_niuma_studio.ps1" not in template
- assert "发送服务会在 Docker 中的牛马片场项目运行后自动启动" in template
- assert "随 Docker 项目自动启动" in javascript
+ assert "RunDock 的 Niuma-Publish-Worker 独立托管" in template
+ assert "RunDock 的 Niuma-Publish-Worker 独立托管" in javascript
+ assert "Docker Desktop" not in template
+ assert "随 Docker 项目自动启动" not in javascript
+ assert "RunDock 中独立托管" in worker_client
assert r".\scripts\start_niuma_studio.ps1" not in worker_client
diff --git a/tests/test_publish_worker_client.py b/tests/test_publish_worker_client.py
index 2f5cdd0..2c5a85d 100644
--- a/tests/test_publish_worker_client.py
+++ b/tests/test_publish_worker_client.py
@@ -2,6 +2,8 @@
import json
import socket
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from threading import Thread
import pytest
from fastapi.testclient import TestClient
@@ -79,6 +81,37 @@ def fake_urlopen(request, timeout):
assert captured == {"authorization": "Bearer secret-token", "timeout": 7}
+def test_worker_client_bypasses_environment_proxy_without_no_proxy(monkeypatch):
+ class HealthHandler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ body = json.dumps({"status": "ok", "worker": "windows_chrome"}).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *_):
+ return
+
+ for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"):
+ monkeypatch.setenv(name, "http://127.0.0.1:1")
+ for name in ("NO_PROXY", "no_proxy"):
+ monkeypatch.delenv(name, raising=False)
+
+ server = ThreadingHTTPServer(("127.0.0.1", 0), HealthHandler)
+ thread = Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ result = PublishWorkerClient(f"http://127.0.0.1:{server.server_port}", "token", 2).health()
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=2)
+
+ assert result == {"status": "ok", "worker": "windows_chrome"}
+
+
def test_worker_timeout_is_marked_as_possibly_received(monkeypatch):
def timeout(*_, **__):
raise socket.timeout("timed out")
@@ -112,7 +145,8 @@ def offline(*_, **__):
with pytest.raises(PublishWorkerUnavailable) as caught:
PublishWorkerClient("http://127.0.0.1:8765", "token", 2).health()
assert caught.value.request_may_have_been_received is False
- assert "随 Docker 中的牛马片场项目自动启动" in caught.value.message
+ assert "RunDock 中独立托管" in caught.value.message
+ assert "127.0.0.1:8765" in caught.value.message
assert r".\scripts" not in caught.value.message