Files
workbuddy_skills/session-mechanism/assets/board-launch.py.tpl
T
admin 60a6e30ba2 session-mechanism:建任务的 RunLevel 改成按账户权限自适应(非管理员机不再被拒)
现象:常驻每次尝试给自己装保活任务,日志都留一行「常驻自我供给(计划任务):⛔ 登记失败:
Register-ScheduledTask : 拒绝访问」⇒ 非管理员账户的机器上常驻没有自愈载体(死了没人拉)。
真因(2026-10-12 实测坐实):建任务时把运行权限写死成 `-RunLevel Highest`,而该机账户不是
管理员,Windows 直接拒绝注册;同一个任务去掉这个参数就建得成。

改法(一次性判定,⛔ 不用「先按 Highest 建、失败再重试」—— 那会让每次多一次失败注册):
新增 `_admin_principal_flag()`:管理员(进程已提权)⇒ ` -RunLevel Highest`(原行为不变);
非管理员 ⇒ 空串(整段去掉 `-RunLevel`);判不出来 ⇒ 按普通权限走(宁可少一次提权,也要建得成)。
落点三处:`collabctl.py::ensure_keepalive_tasks()` 两处(保活任务 + 看板任务)、
`collabd.py::_escalate_to_keeper()` 一处(自我供给旁路)。

同批退役(定案 A):`scripts/keepalive.py` → `keepalive.py.retired-20261012`。
它零 importer、不在 `deploy_code.py` 分发清单,`references/deploy.md §4` 早已写明它是
「旧副本、职责已被本 skill 吸收」;留着就是第二份建任务实现,与「建任务只许一处」相抵。
⚠️ 它的 `-ExecutionTimeLimit (New-TimeSpan -Minutes 2)` 确实与另三处(无时限)不一致,
退役后该不一致一并消失;工作区侧无同名副本,无需清理。

文档订正(旧结论是误归因):
  · `assets/board-launch.py.tpl` 那条「`Highest` 不是可选项:`Limited` 实测 `LastTaskResult=1`」
    ⇒ 订正为「`RunLevel` 按账户权限定」。看板 `LastResult=1` 的真因是单例守卫 + `pythonw`
    无 stdout 把退出码顶成 1(`references/pitfalls.md` 已写明),与 `RunLevel` 无关;
    非管理员机上 `Limited` 实测建得成、也跑得起来。
  · `SKILL.md`、`references/supervise-persistence.md` 同步改口径。
  · `references/deploy.md` 包内脚本清单去掉 `keepalive.py`。

验证(本机实跑,⛔ 不真建任务:把脚本调用拦下来只看文本)
  · 两分支对照:管理员分支 6/6 段带 `-RunLevel Highest`;强制非管理员分支 6/6 段无 `RunLevel`。
  · 与改动前逐字对照:管理员分支生成的脚本归一化后完全一致(6/6 段)⇒ 原行为不变。
  · `selftest.py`:改前 110/0、改后 110/0(无回归)。
  · `install.py --manifest`:77 份文件、语法失败 0。
  · 副本已分发到本机 4 个工作区(md5 与源一致)。
⚠️ 非管理员分支本机验不到(本机是 Administrator)⇒ 该档验收需在非管理员账户的机器上跑。
2026-10-12 11:07:02 +08:00

89 lines
4.9 KiB
Smarty
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""看板启动器(**无控制台**)· 模板。
> 🔴 用户 2026-10-04 21:0x 定案:「**看板 包括常驻 按现在的方式启动,全局只允许一个,把规则记录下来**」
> 本文件=本文档所述「现在的方式」的可复制载体。装法见 `references/supervise-persistence.md`。
## 为什么必须有它(⛔ 别再退回 `.cmd`)
计划任务的动作若写 `.cmd`/`.bat`/`powershell.exe`,那些都是**控制台程序**
⇒ 每次触发都分配一个 `conhost.exe` ⇒ **屏幕上弹黑窗**(用户已投诉两次)。
更糟:`.cmd` 里若**前台**跑 `pythonw`(没有 `start`)⇒ `cmd.exe` **不返回**
⇒ 黑窗**常驻**挂着(实测 `cmd.exe 62932` + `conhost.exe 54928` 挂在看板进程树上)。
## 解法
计划任务动作 **直接指向 `pythonw.exe`**(GUI 子系统 ⇒ 操作系统层面就不分配控制台 ⇒ 零黑窗);
本来靠 `.cmd` 的 `set` 传的环境变量,改在**本进程内**设好,再 `runpy` 起 `board.py`。
## 装法(⚠️ 三处必改:{{WS}} / {{BOARD}} / {{PORT}})
```powershell
$pyw = '<managed-python>\pythonw.exe'
$a = New-ScheduledTaskAction -Execute $pyw -Argument '"<本文件>"' `
-WorkingDirectory '<工作区根>'
$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) `
-MultipleInstances IgnoreNew -RestartCount 999 `
-RestartInterval (New-TimeSpan -Minutes 1)
$p = New-ScheduledTaskPrincipal -UserId '<当前用户>' -LogonType Interactive -RunLevel Highest
Register-ScheduledTask -TaskName 'dsh-board-<端口>' -Action $a -Settings $s -Principal $p -Force
```
🔴 **`RunLevel` 按账户权限定**:管理员(进程已提权)⇒ 带 `-RunLevel Highest`;非管理员 ⇒ 去掉 `-RunLevel`(走普通权限)。
依据(2026-10-12 实测):非管理员账户上写死 `-RunLevel Highest` ⇒ `Register-ScheduledTask : 拒绝访问` —— 任务**根本建不出来**。
⚠️ 旧版这里写的是「`Highest` 不是可选项:`Limited` 实测 `LastTaskResult=1`」,**那是误归因**:
看板 `LastResult=1` 的真因是单例守卫 + `pythonw` 无 stdout 把退出码顶成 1
(见 `references/pitfalls.md`「看板的 `LastResult=1` 是虚警」),与 `RunLevel` 无关;
非管理员机上 `Limited` 实测建得成、也跑得起来。
## ⛔ 两条封死的写法(都亲手栽过)
1. **`exec(compile(...))`** —— 从 `C:\Windows\System32`(任务默认 cwd)起时 **rc=1 且零输出**。
✅ 用 `runpy.run_path(BOARD, run_name="__main__")`:它会正确设好
`__file__`/`__name__`/`sys.path[0]`,`board.py` 顶部的 `_sm_load_roots()`
(靠 `__file__` 往上找 `env/roots.env`)才找得到。
2. **`pythonw.exe` 下 `sys.stdout/stderr` 可能是 `None`** ⇒ 任何 `print` 抛 `AttributeError` 秒退。
✅ 起 `board.py` 前先给两个流兜底(本文件已做)。
## 验收(三条全绿才算成,⛔ "打印了启动消息"不算)
- `netstat` 见 `127.0.0.1:<port> LISTENING`
- `curl /` = 200
- 🔴 **父进程是 `svchost.exe -s Schedule`**(⇒ 已脱离会话)**且进程树下零 `cmd.exe`/零 `conhost.exe`**
"""
import os
import sys
# ── 三处按实际改 ────────────────────────────────────────────────
WS = r"<工作区根,例:E:\ProgramData\AIProject\ai1net-dsh-server>"
BOARD = r"<技能脚本,例:E:\ProgramData\.workbuddy\skills\session-mechanism\scripts\board.py>"
PORT = "8788"
# ───────────────────────────────────────────────────────────────
LOG = os.path.join(WS, "tmp", "board-serve.err.log")
# 🔴 必须**先设好 env 再起 board** —— `board.py` 在**模块顶层**读这些名字。
# ⚠️ `env/roots.env` 只兜底 `COLLABD_PROD_CONFIG`,而 `board.py` 读的是 `COLLABD_CONFIG`
# ⇒ **名字对不上,不设就落到 `${脚本目录}/collabd.config.json`(不存在)⇒ 起不来**。
os.environ["COLLABD_CONFIG"] = os.path.join(WS, ".workbuddy", "collab", "collabd.config.json")
os.environ.setdefault("CODEBUDDY_CONFIG_DIR", os.path.expanduser("~/.workbuddy"))
os.environ["PYTHONIOENCODING"] = "utf-8"
os.environ["PYTHONUNBUFFERED"] = "1"
# 🔴 `pythonw.exe` 下两个流可能是 None ⇒ 兜底,否则 print 抛异常秒退
try:
_f = open(LOG, "a", encoding="utf-8", errors="replace")
except Exception:
_f = None
if _f is not None:
if sys.stdout is None:
sys.stdout = _f
if sys.stderr is None:
sys.stderr = _f
# 🔴 ⛔ 别用 `exec(compile(...))`(见文件头「两条封死的写法」)⇒ `runpy` 才会正确设好 `__file__` 等
import runpy
sys.argv = [BOARD, "--serve", PORT]
runpy.run_path(BOARD, run_name="__main__")