#!/usr/bin/env python3
"""
dsh-double — dsh 自我管理 CLI(按需唤醒的休眠双身)
目标:让 dsh 具备自我管理/自我进化能力,避免"改配置→重启即爆→找外部 agent 救"。
机制:
- L0 保命外壳(dshd-supervisor,bash)在进程外做探活/拉起/机械回滚重启;
- 本工具(L1 自我管理)做健康快照、配置预检、蓝绿进化提交、standby 唤醒。
- standby 平时是磁盘副本(零进程/零端口),只在进化切流与故障修复时被临时唤醒。
用法:
dsh-double.py snapshot [name] 打健康基线快照并更新 last-known-good
dsh-double.py check 对当前配置跑预检(副本 dump-config),输出健康判定
dsh-double.py preview <变更目录> 把变更仅应用到 standby 做预检+试启动(主零改动)
dsh-double.py evolve <变更目录> 严格蓝绿提交:预检→主应用→重启主→确认健康→才停 standby;失败回滚
dsh-double.py wake [--port N] 唤醒 standby 为临时 dsh 实例(默认 3082)
dsh-double.py stop 停止已唤醒的 standby
dsh-double.py rollback [snapshot] 从快照恢复主配置(默认 last-known-good)
dsh-double.py status 显示快照/standby/主健康/试验田/最近事件
dsh-double.py standby-sync 把主配置层同步到 standby
# 试验田双身(对等双脑):隔离的完整副 dsh 实例 + 主↔副 agent 通信台账
dsh-double.py testbed init [--force] 创建试验田(克隆配置层/只读共享/独立数据/基线/令牌)
dsh-double.py testbed sync 从主重新克隆配置层并刷新基线(会覆盖试验田实验配置)
dsh-double.py testbed reset 停止并重建试验田(只清 double/testbed/,不碰主数据)
dsh-double.py testbed up [--port N] 唤醒试验田为完整副 dsh(默认 3081;纯启动,不同步)
dsh-double.py testbed down 休眠试验田(回到磁盘态,零进程)
dsh-double.py testbed restart 重启试验田
dsh-double.py testbed status 试验田状态(磁盘态/运行中/基线/最近通信)
dsh-double.py testbed diff [--out DIR] 试验田配置 vs 基线 → 生成增量 change_dir(喂给 evolve)
dsh-double.py testbed baseline 用主当前配置刷新基线(进化成功后重置增量基准)
dsh-double.py comm [N] 查看主↔副通信台账最近 N 条(默认 20)
环境变量:DSH_HOME(主 DSH_HOME,默认 ~/.dsh)
选项:
--home
主 DSH_HOME(默认 $DSH_HOME 或 ~/.dsh);演练时指向假主;对试验田操作时指向试验田
--standby standby 目录(默认 <主>/double/standby)
--port standby 试启动/唤醒端口(默认 3082,与试验田 3081 分离防撞)
--no-restart 应用主后不重启主(仅演练/只读场景;默认 false)
--no-wake 跳过 standby 动态试启动(只做静态预检)
"""
import os
import re
import shutil
import socket
import subprocess
import sys
import time
import urllib.request
from datetime import datetime
from pathlib import Path
HOME = Path(os.path.expanduser("~"))
N22_BIN = HOME / ".nvm/versions/node/v22.23.1/bin"
DSH_WRAPPER = HOME / ".local/bin/dsh"
# 真实 dsh 的 profile node_modules(插件实际安装处)——standby/假主只读共享
REAL_PROFILE_NM = HOME / ".dsh/profiles/web/node_modules"
# 内置 bundle(dsh-base/dsh-web-app/headless 等)在 dsh 全局包自己的依赖里解析
GLOBAL_DSH_NM = (HOME / ".nvm/versions/node/v22.23.1/lib/node_modules"
/ "@deepseek-ai/dsh/node_modules/@deepseek-ai")
MAIN_PORT = 3080
TESTBED_PORT = 3081
STANDBY_PORT = 3082 # 蓝绿引擎试启动/临时唤醒(与试验田端口分离,防撞)
BOOT_READY_TIMEOUT = 30 # 试启动/重启后等待 boot 完成的最大秒数
HEALTH_TIMEOUT = 40 # 主重启后等待探活健康的最大秒数
LIVE_READY_MARK = "dsh web: http://"
# 探活正则:主 = `dsh web` 结尾(dshd-start 不带 --port 启动);带 --port 的都是
# 试验田/试启动实例,绝不能算进主健康判定,反之主判定也不能匹配到它们。
MAIN_PAT = r"dsh web$"
def port_pat(port):
"""按端口给探活正则:主端口用无参形态,其余用 --port N 精确匹配。"""
return MAIN_PAT if port == MAIN_PORT else rf"dsh.*--port {port}\b"
# boot 期致命错误关键字(--dump-config / 启动日志判据)
FATAL_PATTERNS = [
"plugin tree failed to load",
"duplicate loader entry id",
"must be a top-level YAML array",
"YAMLException",
"entry .* not found",
"NO_ADAPTER",
"failed to apply loader entry",
"Invalid or unexpected token",
]
# 健康基线文件集(相对 DSH_HOME)——boot 期会改坏的只有这些
BASELINE_FILES = [
"settings.yaml",
".credentials.yaml",
"profiles/web/package.json",
"profiles/web/cordis.patch.yml",
"profiles/web/cordis.yml",
"profiles/web/pnpm-lock.yaml",
"profiles/web/pnpm-workspace.yaml",
"profiles/headless/package.json",
"profiles/headless/cordis.patch.yml",
"profiles/headless/cordis.yml",
"autovision/config.json",
]
BASELINE_DIRS = [
".agent-presets",
]
# ---------------------------------------------------------------------------
# 基础设施
# ---------------------------------------------------------------------------
def log(msg, evlog=None):
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
print(line)
if evlog:
with open(evlog, "a") as f:
f.write(line + "\n")
def die(msg, code=1):
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(code)
def run(cmd, timeout=120, env=None, cwd=None, check=False):
"""运行命令,返回 (returncode, stdout, stderr)。"""
merged_env = dict(os.environ)
if env:
merged_env.update(env)
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout,
env=merged_env, cwd=str(cwd) if cwd else None)
return p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired:
return 124, "", f"timeout after {timeout}s"
def dsh_env(home: Path):
"""为指定 DSH_HOME 构造启动环境(强制 nvm node22 + DSH_HOME)。"""
env = dict(os.environ)
env["DSH_HOME"] = str(home)
env["PATH"] = f"{N22_BIN}:" + env.get("PATH", "")
return env
# ---------------------------------------------------------------------------
# 探活
# ---------------------------------------------------------------------------
def tcp_up(port, host="127.0.0.1", timeout=2):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def http_up(port, timeout=3):
# 关键:必须绕过系统代理直连本机。urllib.request 默认读 macOS 系统代理
# 设置,会把 127.0.0.1 的请求转发给代理 → 得到 502 Bad Gateway → 健康 dsh
# 被误判故障(curl 对 localhost 自动 bypass 所以没暴露)。
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(f"http://127.0.0.1:{port}/", timeout=timeout) as r:
return r.status == 200
except Exception:
return False
def pgrep_up(pat="dsh web"):
"""按命令行正则匹配进程,返回 (found, pids)。
关键:不用 `pgrep -f`。macOS 的 pgrep 会**静默排除调用者的父进程链**
(据实测:由 dsh 自身 spawn 的 CLI 里跑 pgrep -f "dsh web" 匹配不到
dsh 主进程,只匹配到无关进程),导致插件内探活把健康主误判为故障。
改为 `ps` 全量扫描 + 只排除自身(os.getpid()),父链中的 dsh 能被正确
命中;正则语义与 pgrep -f 一致(standby 用 `dsh web.*--port N` 区分端口)。
"""
rc, out, _ = run(["/bin/ps", "-axo", "pid=,command="])
if rc != 0:
return False, []
self_pid = str(os.getpid())
pids = []
for line in out.splitlines():
line = line.strip()
if not line:
continue
pid, _, cmd = line.partition(" ")
pid = pid.strip()
if not pid.isdigit() or pid == self_pid:
continue
if re.search(pat, cmd):
pids.append(pid)
return bool(pids), pids
def probe(port=MAIN_PORT, pat=None):
"""三合一探活:TCP + HTTP + pgrep。返回 (ok, evidence) 证据链。
pat 缺省按端口推断(主=无 --port 形态,其余=--port N)。"""
if pat is None:
pat = port_pat(port)
t = tcp_up(port)
h = http_up(port)
g, pids = pgrep_up(pat)
evidence = {
"tcp_%d" % port: t,
"http_%d" % port: h,
"pgrep_pat": pat,
"pids": pids,
}
return (t and h and g), evidence
def wait_probe(port=MAIN_PORT, pat=None, timeout=HEALTH_TIMEOUT, interval=2):
"""等待探活通过,返回 (ok, evidence)。"""
if pat is None:
pat = port_pat(port)
deadline = time.time() + timeout
last = None
while time.time() < deadline:
ok, ev = probe(port, pat)
last = ev
if ok:
return True, ev
time.sleep(interval)
return False, last
# ---------------------------------------------------------------------------
# 快照 / standby
# ---------------------------------------------------------------------------
def ensure_dirs(home: Path):
d = home / "double"
for sub in ("snapshots", "standby", "evolutions", "tmp"):
(d / sub).mkdir(parents=True, exist_ok=True)
return d
def last_known_good(home: Path):
lkg = home / "double" / "last-known-good"
return lkg.read_text().strip() if lkg.exists() else None
def set_lkg(home: Path, snapname: str):
(home / "double" / "last-known-good").write_text(snapname)
def snapshot(home: Path, name=None):
"""复制健康基线到 snapshots/-,更新 last-known-good。"""
d = ensure_dirs(home)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
snapname = f"{ts}-{name}" if name else f"{ts}-auto"
dest = d / "snapshots" / snapname
if dest.exists():
shutil.rmtree(dest)
dest.mkdir(parents=True, exist_ok=True)
copied = []
for rel in BASELINE_FILES:
src = home / rel
if src.exists():
tgt = dest / rel
tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, tgt)
copied.append(rel)
for rel in BASELINE_DIRS:
src = home / rel
if src.is_dir():
tgt = dest / rel
tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, tgt, dirs_exist_ok=True, symlinks=True)
copied.append(rel)
# 可选 home 级 patch
if (home / "cordis.patch.yml").exists():
shutil.copy2(home / "cordis.patch.yml", dest / "cordis.patch.yml")
copied.append("cordis.patch.yml")
set_lkg(home, snapname)
return snapname, copied
def list_snapshots(home: Path):
d = home / "double" / "snapshots"
if not d.exists():
return []
return sorted([p.name for p in d.iterdir() if p.is_dir()])
def standby_dir(home: Path, override=None):
if override:
return Path(override)
return home / "double" / "standby"
def sync_standby(home: Path, stb: Path):
"""把主配置层同步到 standby;standby 的 node_modules symlink 到真实 profile。"""
stb.mkdir(parents=True, exist_ok=True)
for rel in BASELINE_FILES:
src = home / rel
tgt = stb / rel
if src.is_file():
tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, tgt)
for rel in BASELINE_DIRS:
src = home / rel
if src.is_dir():
tgt = stb / rel
tgt.parent.mkdir(parents=True, exist_ok=True)
if tgt.exists():
shutil.rmtree(tgt)
shutil.copytree(src, tgt, symlinks=True)
if (home / "cordis.patch.yml").exists():
shutil.copy2(home / "cordis.patch.yml", stb / "cordis.patch.yml")
# node_modules symlink
nm_link = stb / "profiles/web/node_modules"
nm_link.parent.mkdir(parents=True, exist_ok=True)
if nm_link.is_symlink():
nm_link.unlink()
if not nm_link.exists() and REAL_PROFILE_NM.exists():
nm_link.symlink_to(REAL_PROFILE_NM)
return stb
def apply_changes(src_dir: Path, target: Path, prefix: Path):
"""把变更目录里的文件按相对路径覆盖到 target。prefix 用于剥掉绝对路径前缀。"""
applied = []
for f in src_dir.rglob("*"):
if f.is_file():
rel = f.relative_to(src_dir)
tgt = target / rel
tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(f, tgt)
applied.append(str(rel))
return applied
# ---------------------------------------------------------------------------
# 预检
# ---------------------------------------------------------------------------
def yaml_syntax_ok(path: Path):
"""用 macOS 自带 ruby(psych) 校验 YAML 语法(零第三方依赖)。"""
rc, _, err = run(["ruby", "-ryaml", "-e", "YAML.load_file(ARGV[0])", str(path)], timeout=30)
return rc == 0, err.strip()
def json_ok(path: Path):
try:
import json
with open(path) as f:
json.load(f)
return True, ""
except Exception as e:
return False, str(e)
def check_bundles_present(home: Path, stb: Path):
"""校验 web profile bundles 里的包能否解析到(真实 profile node_modules 或全局)。"""
pkg = stb / "profiles/web/package.json"
issues = []
if not pkg.exists():
return ["missing profiles/web/package.json"]
import json
try:
data = json.loads(pkg.read_text())
except Exception as e:
return [f"profiles/web/package.json invalid JSON: {e}"]
bundles = (data.get("dsh", {}).get("profile", {}) or {}).get("bundles", [])
if not isinstance(bundles, list):
return ["bundles is not a list"]
nm = stb / "profiles/web/node_modules"
for b in bundles:
cands = [nm / b, REAL_PROFILE_NM / b]
# GLOBAL_DSH_NM 本身已在 @deepseek-ai 目录内:scoped 包(@scope/name)取 name 部分
if b.startswith("@") and "/" in b:
global_cand = GLOBAL_DSH_NM / b.split("/", 1)[1]
else:
global_cand = GLOBAL_DSH_NM / b
cands.append(global_cand)
if not any((c.exists() or c.is_symlink()) for c in cands):
issues.append(f"bundle '{b}' cannot be resolved (not in node_modules / global dsh)")
return issues
def check_default_model(home: Path, stb: Path):
"""防 NO_ADAPTER:agent-default-model.provider 必须指向已声明 provider 或真实存在。"""
issues = []
settings = stb / "settings.yaml"
if not settings.exists():
return []
import json
rc, out, err = run(["ruby", "-ryaml", "-rjson", "-e",
"s=YAML.load_file(ARGV[0]); print({default_model: s['agent-default-model'], providers: (s.dig('llm-pi-ai','providers')||{}).keys}.to_json)",
str(settings)], timeout=30)
if rc != 0:
issues.append(f"settings.yaml YAML parse failed: {err.strip()}")
return issues
try:
info = json.loads(out)
except Exception:
return issues
dm = info.get("default_model") or {}
prov = dm.get("provider")
if not prov:
return issues
known = set(info.get("providers") or [])
# anliu/opencode-go 是已知 provider;运行期 twin(*-autovision/*-vision)应被质疑
if prov not in known and (prov.endswith("-autovision") or prov.endswith("-vision")):
issues.append(f"agent-default-model.provider='{prov}' 不是静态 provider(疑似运行期 twin),重启会 NO_ADAPTER")
return issues
def dump_config(home: Path, stb: Path, profile="web"):
"""在 standby 上跑 --dump-config,返回 (ok, problems, detail, blob)。
注意:dump-config 只打印合成 entry 树、不做 EntryGroup duplicate 校验——
重复 loader entry id 是真实 boot(mountRootInclude)才报,需另做输出解析。"""
rc, out, err = run([str(DSH_WRAPPER), "--profile", profile, "--dump-config"],
timeout=90, env=dsh_env(stb), cwd=str(stb))
blob = out + "\n" + err
problems = []
for pat in FATAL_PATTERNS:
if re.search(pat, blob, re.IGNORECASE):
problems.append(pat)
# 捕获具体错误行
detail = ""
for line in blob.splitlines():
if any(k in line for k in ("duplicate loader entry", "not found", "YAMLException", "must be a top-level", "failed to apply", "NO_ADAPTER")):
detail = line.strip()
break
return (rc == 0 and not problems), problems, detail, blob
def detect_duplicate_entries(blob):
"""从 dump-config 输出提取 loader entry id 统计重复(补 dump-config 不做的
duplicate 校验:那是真实 boot 才触发的 EntryGroup.update 错误)。"""
seen = {}
for m in re.finditer(r"^- id: (\S+)", blob, re.MULTILINE):
eid = m.group(1)
seen[eid] = seen.get(eid, 0) + 1
return [eid for eid, n in seen.items() if n > 1]
def precheck(home: Path, stb: Path, profile="web"):
"""完整静态预检,返回 (issues, detail)。issues 空 = 健康。"""
issues = []
detail = ""
# 1. 关键文件 YAML/JSON 语法
for rel in ("profiles/web/cordis.patch.yml", "profiles/headless/cordis.patch.yml",
"settings.yaml", "autovision/config.json", "profiles/web/pnpm-workspace.yaml"):
p = stb / rel
if p.exists():
if p.suffix in (".yml", ".yaml"):
ok, err = yaml_syntax_ok(p)
if not ok:
issues.append(f"{rel}: YAML 语法错误 {err}")
elif p.suffix == ".json":
ok, err = json_ok(p)
if not ok:
issues.append(f"{rel}: JSON 语法错误 {err}")
# 2. bundles 可解析
issues += check_bundles_present(home, stb)
# 3. 默认模型防 NO_ADAPTER
issues += check_default_model(home, stb)
# 4. dump-config(静态;不包含 duplicate 校验)+ 输出解析检测重复 entry id
ok, problems, det, blob = dump_config(home, stb, profile)
dupes = detect_duplicate_entries(blob)
if dupes:
issues.append("dump-config 输出发现重复 loader entry id: " + ", ".join(dupes)
+ "(真实 boot 会报 duplicate loader entry 崩溃)")
if problems:
issues.append("dump-config 发现 boot 错误: " + ", ".join(problems))
detail = det or detail
elif not ok:
issues.append("dump-config 非零退出")
return issues, (det or detail)
def standby_live_test(stb: Path, port: int, timeout=BOOT_READY_TIMEOUT):
"""在 standby 上动态试启动 web --port ,出现 'dsh web: http://' 即 boot 成功。"""
env = dsh_env(stb)
outlog = stb / "double-trial.log"
# 清空旧日志,注意不能覆盖主 web-daemon.log
outlog.write_text("")
fl = open(outlog, "a")
p = subprocess.Popen([str(DSH_WRAPPER), "--profile", "web", "--port", str(port)],
env=env, cwd=str(stb), stdout=fl, stderr=fl)
try:
deadline = time.time() + timeout
while time.time() < deadline:
if p.poll() is not None:
return False, f"standby 提前退出 rc={p.returncode}", outlog
if LIVE_READY_MARK in outlog.read_text():
# 再确认端口在听
time.sleep(1)
if tcp_up(port):
return True, f"standby boot 成功 @127.0.0.1:{port}", outlog
time.sleep(0.5)
return False, f"standby {timeout}s 内未 boot 完成", outlog
finally:
try:
p.terminate()
p.wait(timeout=10)
except Exception:
p.kill()
# ---------------------------------------------------------------------------
# standby 唤醒 / 停止
# ---------------------------------------------------------------------------
def wake_standby(stb: Path, port: int):
env = dsh_env(stb)
outlog = stb / "double-wake.log"
fl = open(outlog, "a")
p = subprocess.Popen([str(DSH_WRAPPER), "--profile", "web", "--port", str(port)],
env=env, cwd=str(stb), stdout=fl, stderr=fl)
time.sleep(1)
if p.poll() is not None:
return False, f"standby 启动即退出 rc={p.returncode}", outlog
return True, f"standby 已唤醒 @127.0.0.1:{port} pid={p.pid}", outlog
def stop_standby(port=STANDBY_PORT, pat="dsh web"):
rc, out, _ = run(["pkill", "-f", f"dsh.*--port {port}"])
return rc == 0, out
def standby_running(port=STANDBY_PORT):
# 注意:试验田/试启动实例以 `dsh --profile web --port N` 启动,命令行里没有
# 连续的 "dsh web" 字样,必须用 --port N 匹配(旧 pattern 曾因此恒 False)。
return probe(port, rf"dsh.*--port {port}\b")
# ---------------------------------------------------------------------------
# 试验田双身(对等双脑):隔离的完整副 dsh 实例 + 主↔副通信台账
# ---------------------------------------------------------------------------
def testbed_dir(home: Path):
return home / "double" / "testbed"
def comm_dir(home: Path):
return home / "double" / "comm"
def comm_file(home: Path):
return comm_dir(home) / "transcript.jsonl"
def comm_tail(home: Path, n=20):
f = comm_file(home)
if not f.exists():
return []
lines = f.read_text().splitlines()
out = []
for line in lines[-n:]:
line = line.strip()
if not line:
continue
try:
import json
out.append(json.loads(line))
except Exception:
out.append({"raw": line[:200]})
return out
def ensure_twin_token(home: Path):
"""确保 double/config.json 里有 twinToken(主副 /double/twin/* 路由的 Bearer 凭据)。"""
import json
cfg_path = home / "double" / "config.json"
cfg = {}
if cfg_path.exists():
try:
cfg = json.loads(cfg_path.read_text())
except Exception:
cfg = {}
if not cfg.get("twinToken"):
import secrets
cfg["twinToken"] = secrets.token_hex(16)
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(json.dumps(cfg, indent=2, ensure_ascii=False))
try:
cfg_path.chmod(0o600)
except OSError:
pass
return cfg["twinToken"]
def _copy_config_layer(src_home: Path, dst: Path):
"""把 src_home 的健康基线配置层复制到 dst(保留权限)。"""
for rel in BASELINE_FILES:
s = src_home / rel
if s.is_file():
t = dst / rel
t.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(s, t)
if rel == ".credentials.yaml":
try:
t.chmod(0o600)
except OSError:
pass
for rel in BASELINE_DIRS:
s = src_home / rel
if s.is_dir():
t = dst / rel
if t.exists():
shutil.rmtree(t)
t.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(s, t, symlinks=True)
if (src_home / "cordis.patch.yml").exists():
shutil.copy2(src_home / "cordis.patch.yml", dst / "cordis.patch.yml")
def _ensure_symlink(link: Path, target: Path):
link.parent.mkdir(parents=True, exist_ok=True)
if link.is_symlink():
link.unlink()
elif link.exists():
shutil.rmtree(link)
link.symlink_to(target)
def testbed_clone(home: Path, tb: Path):
"""克隆主配置层到试验田 + 只读共享 node_modules/attachments 对象库 + 独立数据目录。"""
_copy_config_layer(home, tb)
# 只读共享(同机同用户):profile node_modules(插件解析)、home 根 node_modules(安装缓存)、
# attachments 对象库(内容寻址不可变)。试验田写附件会经 tmp 走自己的目录,不碰主。
_ensure_symlink(tb / "profiles/web/node_modules", home / "profiles/web/node_modules")
if (home / "node_modules").is_dir():
_ensure_symlink(tb / "node_modules", home / "node_modules")
if (home / "attachments/v1/objects").is_dir():
_ensure_symlink(tb / "attachments/v1/objects", home / "attachments/v1/objects")
# 工作流引导(dsh-agent-instructions 读 $DSH_HOME/AGENTS.md,dsh-skill-filesystem
# 发现 user-dsh 根 /skills):把主的全局铁律与 skills 带给试验田 agent,
# 否则它看不到"改自己先去试验田"的规则(它的 DSH_HOME 是试验田目录)。
if (home / "AGENTS.md").is_file():
shutil.copy2(home / "AGENTS.md", tb / "AGENTS.md")
if (home / "skills").is_dir():
_ensure_symlink(tb / "skills", home / "skills")
# 全新独立数据(完全隔离):会话/存储/工作区由试验田自己长,绝不复制主的历史
for d in ("sessions", "storages", "attachments/v1/tmp", "work"):
(tb / d).mkdir(parents=True, exist_ok=True)
return tb
def testbed_write_baseline(home: Path, tb: Path):
"""用主当前配置层刷新试验田基线(diff 的增量参照;不触碰试验田现有配置)。"""
bl = tb / "baseline"
if bl.exists():
shutil.rmtree(bl)
bl.mkdir(parents=True)
_copy_config_layer(home, bl)
(tb / "baseline.stamp").write_text(datetime.now().isoformat(timespec="seconds"))
return bl
def testbed_running(port=TESTBED_PORT):
return probe(port, rf"dsh.*--port {port}\b")
def testbed_up_sync(home: Path, tb: Path, port: int, timeout=BOOT_READY_TIMEOUT + 20):
"""启动试验田为完整副 dsh(detached),等到 boot 就绪。返回 (ok, msg)。"""
running, ev = testbed_running(port)
if running:
return True, f"试验田已在运行 @127.0.0.1:{port} pids={ev.get('pids')}"
outlog = tb / "double-web.log"
outlog.parent.mkdir(parents=True, exist_ok=True)
fl = open(outlog, "ab")
# start_new_session=True:脱离 CLI 的进程组,CLI 退出后试验田继续常驻
p = subprocess.Popen([str(DSH_WRAPPER), "--profile", "web", "--port", str(port)],
env=dsh_env(tb), cwd=str(tb),
stdout=fl, stderr=fl, start_new_session=True)
deadline = time.time() + timeout
while time.time() < deadline:
if p.poll() is not None:
return False, f"试验田启动即退出 rc={p.returncode}(查 {outlog})"
if LIVE_READY_MARK in outlog.read_text(errors="ignore") and tcp_up(port):
time.sleep(1)
if tcp_up(port):
return True, f"试验田已唤醒 @127.0.0.1:{port} pid={p.pid} 日志={outlog}"
time.sleep(0.5)
return False, f"试验田 {timeout}s 内未 boot 完成(查 {outlog})"
def testbed_down_sync(port: int = TESTBED_PORT, timeout=15):
# 注意:pkill -f 是 POSIX ERE,不支持 \b(PCRE 扩展)——必须用 $ 锚定,
# 否则 pattern 静默失配、旧进程杀不掉,"重启"变 no-op。
def _pkill(sig):
run(["pkill", sig, "-f", rf"dsh.*--port {port}$"])
_pkill("-TERM")
deadline = time.time() + timeout
while time.time() < deadline:
if not tcp_up(port):
return True, "试验田已休眠(磁盘态,零进程)"
time.sleep(1)
_pkill("-9")
deadline = time.time() + 8
while time.time() < deadline:
if not tcp_up(port):
return True, "试验田已休眠(SIGKILL 兜底后停止)"
time.sleep(1)
return False, f"试验田未能停止(pkill -f 'dsh.*--port {port}$' 无效,请人工检查)"
def testbed_diff_files(home: Path, tb: Path):
"""试验田配置层 vs 基线:返回有差异/新增的相对路径列表。"""
import filecmp
bl = tb / "baseline"
if not bl.is_dir():
die("试验田没有基线(先 testbed init 或 testbed baseline)")
rels = set()
for rel in BASELINE_FILES:
t, b = tb / rel, bl / rel
if t.exists() and not b.exists():
rels.add(rel)
elif t.exists() and b.exists() and not filecmp.cmp(t, b, shallow=False):
rels.add(rel)
for d in BASELINE_DIRS:
td, bd = tb / d, bl / d
if not td.is_dir():
continue
for f in td.rglob("*"):
if not f.is_file() or f.is_symlink():
continue
rel = str(f.relative_to(tb))
bf = bl / rel
if not bf.exists() or not filecmp.cmp(f, bf, shallow=False):
rels.add(rel)
return sorted(rels)
# ---------------------------------------------------------------------------
# 命令实现
# ---------------------------------------------------------------------------
def cmd_snapshot(args):
home = args.home
snapname, copied = snapshot(home, args.name)
print(f"快照已建立: {snapname}")
print("内容: " + ", ".join(copied))
print(f"last-known-good -> {snapname}")
def cmd_check(args):
home = args.home
stb = standby_dir(home, args.standby)
sync_standby(home, stb)
print(f"预检目标: standby={stb} (主配置已同步)")
issues, detail = precheck(home, stb)
if issues:
print("健康判定: ❌ 不健康")
for i in issues:
print(" - " + i)
if detail:
print(" 详情: " + detail[:400])
sys.exit(1)
else:
print("健康判定: ✅ 可安全 boot(dump-config 通过,无 boot 致命错误)")
sys.exit(0)
def cmd_preview(args):
home = args.home
stb = standby_dir(home, args.standby)
src = Path(args.change_dir)
if not src.is_dir():
die(f"变更目录不存在: {src}")
sync_standby(home, stb)
applied = apply_changes(src, stb, src)
print(f"变更已应用到 standby(主零改动): {len(applied)} 个文件")
for a in applied:
print(" ~ " + a)
issues, detail = precheck(home, stb)
if issues:
print("preview 判定: ❌ 变更不安全(以下问题)")
for i in issues:
print(" - " + i)
if detail:
print(" 详情: " + detail[:400])
sys.exit(1)
if args.no_wake:
print("preview 判定: ✅ 静态预检通过(跳过动态试启动)")
sys.exit(0)
ok, msg, _ = standby_live_test(stb, args.port)
if not ok:
print("preview 判定: ❌ 静态通过但试启动失败")
print(" " + msg)
sys.exit(1)
print(f"preview 判定: ✅ 预检 + 试启动均通过({msg}),可安全应用到主")
sys.exit(0)
def cmd_evolve(args):
import shlex
home = args.home
stb = standby_dir(home, args.standby)
src = Path(args.change_dir)
if not src.is_dir():
die(f"变更目录不存在: {src}")
main_port = args.main_port
supervisor_cmd = shlex.split(args.supervisor_cmd) if args.supervisor_cmd else None
ev = home / "double" / "evolutions"
ev.mkdir(parents=True, exist_ok=True)
evid = datetime.now().strftime("%Y%m%d-%H%M%S")
evlog = ev / f"{evid}.log"
log(f"== evolve {evid} 开始(严格蓝绿提交) home={home} main_port={main_port} ==", evlog)
# 1. pre-evolution 快照
snapname, _ = snapshot(home, f"pre-evol-{evid}")
log(f"步骤1 pre-evolution 快照: {snapname}", evlog)
# 2-3. 应用到 standby + 静态预检
sync_standby(home, stb)
applied = apply_changes(src, stb, src)
log(f"步骤2-3 变更已应用到 standby(主未动): {len(applied)} 文件", evlog)
issues, detail = precheck(home, stb)
if issues:
for i in issues:
log(f" [standby 预检失败] {i}", evlog)
log("进化中止:主实例零改动,standby 保持待命(未停止,供排查)", evlog)
print("evolve: ❌ standby 预检失败,主未改动。standby 保持待命(dsh-double stop 可停)。")
for i in issues:
print(" - " + i)
sys.exit(1)
# 4. standby 动态试启动
if not args.no_wake:
ok, msg, _ = standby_live_test(stb, args.port)
if not ok:
log(f"步骤4 standby 试启动失败: {msg}", evlog)
log("进化中止:主实例零改动", evlog)
print(f"evolve: ❌ standby 试启动失败({msg}),主未改动。")
sys.exit(1)
log(f"步骤4 standby 试启动通过: {msg}", evlog)
# 5. 应用变更到主
applied_main = apply_changes(src, home, src)
log(f"步骤5 变更已应用到主: {len(applied_main)} 文件", evlog)
# 6-7. 重启主(演练/只读时 --no-restart 跳过)
if args.no_restart:
log("步骤6-7 --no-restart:跳过主重启(只读模式)", evlog)
ok, ev_ = probe(main_port)
if not ok:
log("注意:主当前探活不健康(未重启,仅记录)", evlog)
print("evolve: ⚠️ 变更已应用到主但未重启(--no-restart),主当前探活: " +
("健康" if ok else "不健康"))
sys.exit(0)
print("evolve: ✅ 变更已应用到主(--no-restart 未重启)。蓝绿顺序: standby 已停,主保持健康。")
stop_standby(args.port)
log("主保持健康,standby 已停止", evlog)
sys.exit(0)
restart_cmd = supervisor_cmd or [str(HOME / ".local/bin/dshd-supervisor"), "restart"]
# 关键:重启进程链(supervisor→dshd-start→dsh web)必须以目标 home 启动。
# 引擎可能由试验田插件 spawn(其 env 的 DSH_HOME=试验田),不覆盖的话
# 主会被错误地以试验田 home 拉起(实测踩坑:3080 上跑出 role=testbed)。
restart_env = {"DSH_HOME": str(home)}
log("步骤6 重启主…", evlog)
rc, so, se = run(restart_cmd, timeout=60, env=restart_env)
log(f"步骤7 重启主完成 rc={rc}", evlog)
ok, evidence = wait_probe(main_port, timeout=HEALTH_TIMEOUT)
if ok:
log("步骤7 主健康确认 ✅ → 现在才停 standby(你的硬约束:主健康前 standby 绝不提前停)", evlog)
stop_standby(args.port)
log("步骤8 进化完成,standby 已停止,记录事件", evlog)
print("evolve: ✅ 主重启并确认健康,standby 随后停止。进化完成。")
sys.exit(0)
else:
log(f"步骤7 主启动失败: {evidence}", evlog)
log("步骤8 回滚主到 pre-evolution 快照并重启…", evlog)
# 回滚到本次 pre-evolution 快照
rollback_to(home, snapname)
rc, so, se = run(restart_cmd, timeout=60, env=restart_env)
ok2, ev2 = wait_probe(main_port, timeout=HEALTH_TIMEOUT)
if ok2:
log("回滚后主恢复健康 ✅,standby 停止", evlog)
stop_standby(args.port)
print("evolve: 🔄 主启动失败,已自动回滚到 pre-evolution 快照并恢复健康。")
print(" 回滚快照: " + snapname)
sys.exit(0)
else:
log(f"!! 回滚后主仍未恢复: {ev2} —— 请人工介入(standby 保持待命 @{args.port})", evlog)
print("evolve: ❌ 回滚后主仍未恢复!standby 保持待命(dsh-double wake 可唤起人工窗口)。")
sys.exit(2)
def rollback_to(home: Path, snapname: str):
snap = home / "double" / "snapshots" / snapname
if not snap.is_dir():
die(f"快照不存在: {snapname}")
for rel in BASELINE_FILES:
src = snap / rel
if src.exists():
tgt = home / rel
tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, tgt)
for rel in BASELINE_DIRS:
src = snap / rel
if src.is_dir():
tgt = home / rel
shutil.rmtree(tgt, ignore_errors=True)
tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, tgt, symlinks=True)
if (snap / "cordis.patch.yml").exists():
shutil.copy2(snap / "cordis.patch.yml", home / "cordis.patch.yml")
set_lkg(home, snapname)
def cmd_rollback(args):
home = args.home
snapname = args.snapshot or last_known_good(home)
if not snapname:
die("没有可用快照(先运行 snapshot)")
rollback_to(home, snapname)
print(f"已回滚到快照: {snapname}")
print("提示: 配置已恢复,需重启 dsh 生效(dshd-supervisor restart)。")
def cmd_wake(args):
home = args.home
stb = standby_dir(home, args.standby)
sync_standby(home, stb)
ok, msg, _ = wake_standby(stb, args.port)
if not ok:
print("wake: ❌ " + msg)
sys.exit(1)
print(f"wake: ✅ {msg}")
print(f" standby 是独立 DSH_HOME({stb}),可作备用大脑/人工窗口;完事用 dsh-double stop 退回磁盘态。")
def cmd_stop(args):
ok, out = stop_standby(args.port)
print("stop: standby 已停止(回到磁盘休眠态)" if ok else "stop: 无 standby 进程或已停止")
def cmd_status(args):
home = args.home
ok, ev = probe(MAIN_PORT)
print(f"主 dsh @127.0.0.1:{MAIN_PORT}: {'✅ 健康' if ok else '❌ 不可用'} 证据: {ev}")
lkg = last_known_good(home)
snaps = list_snapshots(home)
print(f"last-known-good: {lkg or '(无)'}")
print(f"快照数: {len(snaps)}")
for s in snaps[-5:]:
print(" " + s)
stb = standby_dir(home, args.standby)
sok, sev = standby_running(args.port)
print(f"standby @{args.port}: {'🟢 唤醒中' if sok else '💤 休眠(磁盘副本)'} 证据: {sev}")
print(f"standby 目录: {stb}")
tb = testbed_dir(home)
trunning, tev = testbed_running(TESTBED_PORT)
print(f"试验田 @{TESTBED_PORT}: {'🟢 运行中' if trunning else ('💤 磁盘态' if tb.is_dir() else '未创建(testbed init)')} 证据: {tev}")
tail = comm_tail(home, 1)
print(f"最近双 dsh 通信: {('[%s] %s %s' % (tail[0].get('dir'), tail[0].get('kind'), tail[0].get('status'))) if tail else '(无)'}")
evd = home / "double" / "evolutions"
if evd.exists():
evs = sorted(evd.glob("*.log"))
if evs:
print(f"最近进化事件(共 {len(evs)}):")
for e in evs[-3:]:
first = e.read_text().splitlines()
print(f" {e.name}: {first[0] if first else ''}")
def cmd_standby_sync(args):
home = args.home
stb = standby_dir(home, args.standby)
sync_standby(home, stb)
print(f"standby 已同步: {stb}")
# ---------------------------------------------------------------------------
# 试验田 / 通信台账 命令
# ---------------------------------------------------------------------------
def cmd_testbed(args):
home = args.home
tb = testbed_dir(home)
act = args.taction
if act == "init":
if tb.exists() and not args.force:
die(f"试验田已存在: {tb}(--force 重建;sync 只刷新配置层)")
if args.force and tb.exists():
testbed_down_sync(args.port)
shutil.rmtree(tb)
tb.mkdir(parents=True)
testbed_clone(home, tb)
testbed_write_baseline(home, tb)
ensure_twin_token(home)
ensure_dirs(tb) # 试验田自己的 double/(引擎对其操作时的 standby/快照)
print(f"试验田已创建: {tb}")
print(" 配置层: 从主克隆;node_modules/attachments 对象库只读共享")
print(" 数据: sessions/storages/pet 全新独立(不碰主历史)")
print(f" 唤醒: dsh-double.py testbed up --port {args.port}")
elif act == "sync":
if not tb.is_dir():
die(f"试验田不存在: {tb}(先 testbed init)")
testbed_clone(home, tb)
testbed_write_baseline(home, tb)
print(f"试验田配置层已从主重新克隆,基线已刷新: {tb}")
print(" 注意:试验田未提交的实验配置被覆盖;数据目录保持独立不受影响")
elif act == "reset":
args.force = True
tb_existing = tb.exists()
if tb_existing:
testbed_down_sync(args.port)
shutil.rmtree(tb)
tb.mkdir(parents=True)
testbed_clone(home, tb)
testbed_write_baseline(home, tb)
ensure_twin_token(home)
ensure_dirs(tb)
print(f"试验田已重建: {tb}(只清 double/testbed/,主数据零影响)")
elif act == "up":
if not tb.is_dir():
die(f"试验田不存在: {tb}(先 testbed init)")
ok, msg = testbed_up_sync(home, tb, args.port)
print(("up: ✅ " if ok else "up: ❌ ") + msg)
sys.exit(0 if ok else 1)
elif act == "down":
ok, msg = testbed_down_sync(args.port)
print(("down: ✅ " if ok else "down: ❌ ") + msg)
sys.exit(0 if ok else 1)
elif act == "restart":
ok1, msg1 = testbed_down_sync(args.port)
if not ok1:
print(f"restart: ❌ {msg1}(未执行 up,保持现状)")
sys.exit(1)
ok2, msg2 = testbed_up_sync(home, tb, args.port)
print("restart: " + ("✅" if ok2 else "❌") + f" {msg1} → {msg2}")
sys.exit(0 if ok2 else 1)
elif act == "status":
exists = tb.is_dir()
running, ev = testbed_running(args.port)
print(f"试验田目录: {tb} {'存在' if exists else '❌ 不存在(先 init)'}")
print(f"试验田 @127.0.0.1:{args.port}: {'🟢 运行中' if running else '💤 磁盘态(零进程)'} 证据: {ev}")
stamp = tb / "baseline.stamp"
print(f"基线时间: {stamp.read_text().strip() if stamp.exists() else '(无)'}")
diffs = testbed_diff_files(home, tb) if exists and (tb / 'baseline').is_dir() else []
print(f"未提交增量(vs 基线): {len(diffs)} 个文件" + (f" → {', '.join(diffs[:6])}{'…' if len(diffs) > 6 else ''}" if diffs else ""))
tail = comm_tail(home, 1)
if tail:
r = tail[0]
print(f"最近通信: [{r.get('dir')}] {r.get('kind')} {r.get('status')} @ {r.get('ts')}")
else:
print("最近通信: (无台账)")
elif act == "diff":
if not tb.is_dir():
die(f"试验田不存在: {tb}")
rels = testbed_diff_files(home, tb)
out = Path(args.out) if args.out else (home / "double" / "tmp" /
f"twin-diff-{datetime.now().strftime('%Y%m%d-%H%M%S')}")
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
for rel in rels:
t = out / rel
t.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(tb / rel, t)
# 机器可解析行(插件 twinAutoDiff 正则匹配此行):路径后不要黏任何后缀
print(f"增量 change_dir: {out}")
print(f"增量文件数: {len(rels)}(可喂给 evolve 应用到主)")
for r in rels:
print(" ~ " + r)
if not rels:
print(" (试验田与基线一致,无增量)")
elif act == "baseline":
if not tb.is_dir():
die(f"试验田不存在: {tb}")
testbed_write_baseline(home, tb)
print("基线已用主当前配置刷新(增量基准归零;试验田配置未动)")
else:
die(f"未知 testbed 动作: {act}")
def cmd_comm(args):
home = args.home
recs = comm_tail(home, args.n)
if not recs:
print(f"(通信台账为空: {comm_file(home)})")
return
for r in recs:
head = (r.get("text") or r.get("cmd") or r.get("path") or "")
head = head.replace("\n", " ")[:80]
reply = (r.get("reply") or "").replace("\n", " ")
print(f"[{r.get('ts')}] {r.get('dir','?')} {r.get('kind','?')} {r.get('status','?')}")
print(f" → {head}")
if reply:
print(f" ← {reply[:120]}{'…' if len(reply) > 120 else ''}")
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main():
import argparse
ap = argparse.ArgumentParser(prog="dsh-double", description="dsh 自我管理 CLI(按需唤醒的休眠双身)")
sub = ap.add_subparsers(dest="cmd", required=True)
def add_common(p):
p.add_argument("--home", default=None, help="主 DSH_HOME(默认 $DSH_HOME 或 ~/.dsh)")
p.add_argument("--standby", default=None, help="standby 目录(默认 <主>/double/standby)")
p.add_argument("--port", type=int, default=STANDBY_PORT, help=f"standby 端口(默认 {STANDBY_PORT})")
p.add_argument("--main-port", type=int, default=MAIN_PORT, help=f"主探活端口(默认 {MAIN_PORT};演练假主可改)")
p.add_argument("--supervisor-cmd", default=None, help="重启主用的命令字符串(默认 'dshd-supervisor restart';演练假主可改)")
p = sub.add_parser("snapshot"); p.add_argument("name", nargs="?", default=None); add_common(p); p.set_defaults(fn=cmd_snapshot)
p = sub.add_parser("check"); add_common(p); p.set_defaults(fn=cmd_check)
p = sub.add_parser("preview"); p.add_argument("change_dir"); add_common(p); p.add_argument("--no-wake", action="store_true", help="跳过动态试启动"); p.set_defaults(fn=cmd_preview)
p = sub.add_parser("evolve"); p.add_argument("change_dir"); add_common(p); p.add_argument("--no-wake", action="store_true"); p.add_argument("--no-restart", action="store_true", help="应用主后不重启(只读/演练)"); p.set_defaults(fn=cmd_evolve)
p = sub.add_parser("wake"); add_common(p); p.set_defaults(fn=cmd_wake)
p = sub.add_parser("stop"); add_common(p); p.set_defaults(fn=cmd_stop)
p = sub.add_parser("rollback"); p.add_argument("snapshot", nargs="?", default=None); add_common(p); p.set_defaults(fn=cmd_rollback)
p = sub.add_parser("status"); add_common(p); p.set_defaults(fn=cmd_status)
p = sub.add_parser("standby-sync"); add_common(p); p.set_defaults(fn=cmd_standby_sync)
tbp = sub.add_parser("testbed", description="试验田双身(隔离的完整副 dsh)")
tsub = tbp.add_subparsers(dest="taction", required=True)
def add_tb(p):
p.add_argument("--home", default=None, help="主 DSH_HOME(默认 $DSH_HOME 或 ~/.dsh)")
p.add_argument("--port", type=int, default=TESTBED_PORT, help=f"试验田端口(默认 {TESTBED_PORT})")
p = tsub.add_parser("init"); p.add_argument("--force", action="store_true", help="已存在时重建"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = tsub.add_parser("sync"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = tsub.add_parser("reset"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = tsub.add_parser("up"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = tsub.add_parser("down"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = tsub.add_parser("restart"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = tsub.add_parser("status"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = tsub.add_parser("diff"); p.add_argument("--out", default=None, help="增量输出目录(默认 <主>/double/tmp/twin-diff-)"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = tsub.add_parser("baseline"); add_tb(p); p.set_defaults(fn=cmd_testbed)
p = sub.add_parser("comm", description="查看主↔副通信台账")
p.add_argument("n", nargs="?", type=int, default=20, help="最近 N 条(默认 20)")
p.add_argument("--home", default=None)
p.set_defaults(fn=cmd_comm)
args = ap.parse_args()
if args.home:
args.home = Path(args.home)
else:
args.home = Path(os.environ.get("DSH_HOME", HOME / ".dsh"))
args.fn(args)
if __name__ == "__main__":
main()