init: workbuddy_skills 重建,仅收录 session-mechanism
- 按用户指示清空原有 25 技能内容,只提交 session-mechanism(57 文件) - 附 .gitignore(产物 + 本机凭据) - 令牌明文已脱敏(历史 .neodata_token 与 pitfalls 引用均不入库) - 本提交为孤儿提交(父提交为空),历史自此重新开始
This commit is contained in:
commit
19101acd65
58 files changed
+36134
No files matched your search
@@ -0,0 +1,83 @@
|
||||
# -*- 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=Highest` 不是可选项**:`Limited` 实测 `LastTaskResult=1`(写日志/读库被拒)。
|
||||
|
||||
## ⛔ 两条封死的写法(都亲手栽过)
|
||||
|
||||
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__` 往上找 `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` 在**模块顶层**读这些名字。
|
||||
# ⚠️ `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__")
|
||||
@@ -0,0 +1,235 @@
|
||||
// 真跑 render():把 board.html 的页面脚本放进 vm 的**全局作用域**执行,
|
||||
// 这样顶层 `function render(){}` 会挂到 sandbox 对象上,能真调到。
|
||||
// ⛔ 上次用 `new Function()` 是假绿 —— 函数关在局部,取到 undefined,等于没跑。
|
||||
const fs = require('fs'), http = require('http'), vm = require('vm');
|
||||
|
||||
const path = require('path');
|
||||
const P = process.env.BOARD_HTML || path.join(__dirname, 'board.html');
|
||||
const SNAP_URL = process.env.BOARD_SNAP || 'http://127.0.0.1:20099/board.json';
|
||||
// 🔴 2026-10-05:支持**盘上快照**(自测/离线用)—— ⛔ 不必起服务,⛔ 不依赖网络。
|
||||
const SNAP_FILE = process.env.BOARD_SNAP_FILE || '';
|
||||
// 分母口径断言可被外部指定(自测会传期望值;不传则只报数不判)
|
||||
const WANT_PASS = process.env.BOARD_WANT_PASS || '';
|
||||
const html = fs.readFileSync(P, 'utf8');
|
||||
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map(m => m[1]);
|
||||
const code = blocks.join('\n;\n');
|
||||
|
||||
const els = {};
|
||||
function mkEl(id) {
|
||||
const e = {
|
||||
id, hidden: false, textContent: '', innerHTML: '', style: {}, children: [],
|
||||
dataset: {}, // ← 页面用 dataset.base 存静态文案
|
||||
classList: { toggle(){}, add(){}, remove(){}, contains(){ return false; } },
|
||||
appendChild(c){ this.children.push(c); return c; },
|
||||
insertBefore(c){ this.children.push(c); return c; },
|
||||
setAttribute(){}, getAttribute(){ return null; }, addEventListener(){},
|
||||
removeAttribute(){}, focus(){}, blur(){}, click(){},
|
||||
querySelector(){ return null; }, querySelectorAll(){ return []; },
|
||||
getBoundingClientRect(){ return {width:900,height:500,top:0,left:0}; },
|
||||
getContext(){ const noop=()=>{};
|
||||
return {clearRect:noop,fillRect:noop,beginPath:noop,moveTo:noop,lineTo:noop,
|
||||
stroke:noop,fill:noop,arc:noop,fillText:noop,save:noop,restore:noop,
|
||||
translate:noop,scale:noop,closePath:noop,setLineDash:noop,bezierCurveTo:noop,
|
||||
measureText:()=>({width:10})}; },
|
||||
parentNode: null, offsetWidth: 900, offsetHeight: 500, offsetTop: 0,
|
||||
};
|
||||
return e;
|
||||
}
|
||||
const document = {
|
||||
getElementById(id){ return els[id] || (els[id] = mkEl(id)); },
|
||||
createElement(t){ return mkEl('__'+t); },
|
||||
createElementNS(){ return mkEl('__ns'); },
|
||||
querySelector(){ return null; }, querySelectorAll(){ return []; },
|
||||
addEventListener(){}, removeEventListener(){},
|
||||
body: mkEl('body'), documentElement: mkEl('html'), head: mkEl('head'),
|
||||
createTextNode(t){ return {textContent:t}; },
|
||||
};
|
||||
let rafQ = [];
|
||||
const sandbox = {
|
||||
document, console,
|
||||
window: null,
|
||||
navigator: { userAgent: 'node' },
|
||||
location: { protocol: 'http:', hash: '', search: '', href: 'http://127.0.0.1:20099/' },
|
||||
requestAnimationFrame: fn => { rafQ.push(fn); return rafQ.length; },
|
||||
cancelAnimationFrame: () => {},
|
||||
setTimeout: () => 0, // ⛔ 不开轮询
|
||||
clearTimeout: () => {},
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
fetch: () => Promise.reject(new Error('probe: fetch disabled')),
|
||||
localStorage: { getItem(){return null;}, setItem(){}, removeItem(){} },
|
||||
matchMedia: () => ({ matches:false, addEventListener(){} }),
|
||||
addEventListener(){}, removeEventListener(){},
|
||||
alert(){}, getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
|
||||
let bootErr = null;
|
||||
try {
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(code, sandbox, { filename: 'board.html', timeout: 15000 });
|
||||
} catch (e) { bootErr = e; }
|
||||
|
||||
console.log('脚本装载 :', bootErr ? ('❌ ' + bootErr.message) : '✅ OK');
|
||||
console.log('render 类型 :', typeof sandbox.render);
|
||||
console.log('renderProject :', typeof sandbox.renderProject);
|
||||
console.log('paintScope :', typeof sandbox.paintScope);
|
||||
console.log('');
|
||||
|
||||
function run(snap) {
|
||||
console.log('快照顶层键数 :', Object.keys(snap).length);
|
||||
console.log('顶层有 acc? :', Object.prototype.hasOwnProperty.call(snap,'acc'));
|
||||
console.log('goal.acceptance 条数:', Object.keys((snap.goal||{}).acceptance||{}).length);
|
||||
console.log('');
|
||||
let err=null, out='';
|
||||
// 清掉桩里 fetch reject 留下的残留(那是**测试桩**造成的,⛔ 不是页面 bug)
|
||||
if (els['err']) els['err'].textContent = '';
|
||||
try {
|
||||
const rp = sandbox.renderProject || sandbox.render;
|
||||
out = rp(snap) || ''; // ← 原来炸的就是这条
|
||||
} catch(e){ err = e; }
|
||||
const errEl = els['err'];
|
||||
// 🔴 renderProject 直接写 `#proj`(⛔ 不是返回值、⛔ 也不是 #project)
|
||||
// ⚠️ 2026-10-05:**目标 id / 归属判据 / 工作区**那几项落在**右上角 `?` 的 `#projTip`** 里,
|
||||
// ⛔ 不在 `#proj` ⇒ 只读 `#proj` 会漏掉它们(实测"目标 id 没渲染"就是这么假红的)。
|
||||
const _tip = (els['projTip'] && els['projTip'].innerHTML) || '';
|
||||
const outHtml = (out || (els['proj'] ? (els['proj'].innerHTML||'') : '')) + '\n' + _tip;
|
||||
console.log('render() 抛异常:', err ? ('❌ ' + err.message) : '✅ 无');
|
||||
if (err) console.log(' 位置:', (err.stack||'').split('\n')[1]);
|
||||
console.log('#err 文案 :', errEl && errEl.textContent ? ('❌ ' + errEl.textContent.slice(0,160)) : '✅ 空(未报错)');
|
||||
console.log('#proj 产物长度 :', outHtml.length);
|
||||
console.log('产物含 accRow :', outHtml.indexOf('accRow') >= 0 ? '✅' : '❌');
|
||||
console.log('产物含 完成情况:', outHtml.indexOf('完成情况') >= 0 ? '✅' : '❌');
|
||||
const m = outHtml.match(/(\d+)\s*(?:\/|/)\s*(\d+)\s*通过/);
|
||||
console.log('通过率数值 :', m ? m[0] : '(未渲染出)');
|
||||
|
||||
/* 🔴🔴 2026-10-05 加:**静默错文案**这一族(⛔ 不抛异常,只画错值 ⇒ 上面那些断言全绿漏过)。
|
||||
用户报障逐字:「看板中目标还是空的 本项目 目标(未声明主题)(未声明目标)」。
|
||||
真因=目标数据在 `d.project` / `d.goal` 下,而代码裸写 `d.short` / `d.criteria` / `d.id` …
|
||||
⇒ 取到 `undefined` ⇒ **悄悄**显示兜底文案与 '—',⛔ 一句异常都不抛。
|
||||
🔴 判据=**拿快照里的真值去比对渲染产物**("没炸" ≠ "画对了")。 */
|
||||
let silent = 0;
|
||||
['(未声明主题)', '(未声明目标)'].forEach((t) => {
|
||||
if (outHtml.indexOf(t) >= 0) { console.log('兜底文案 : ❌ 出现了 ' + t + ' ⇒ 取值层级错'); silent++; }
|
||||
});
|
||||
if (!silent) console.log('兜底文案 : ✅ 未出现');
|
||||
const _pj = snap.project || {}, _gj = snap.goal || {};
|
||||
const _title = _gj.title || _pj.title;
|
||||
/* 🔴🔴 2026-10-05 **「主题真值」口径已改**(用户逐字:「目标 vibe-product 提取 …
|
||||
改为 vibe-product 目标:提取 …」)⇒ 目标行**不再**渲染 `goal.short`(主题简称),
|
||||
改为渲染【所属工作区真实目录名 `ws_name`】+【目标全称 `goal.title`】。
|
||||
⚠️ 旧断言查的是 `goal.short` ⇒ 本区格(`goal.short='本机协作'` ≠ `ws_name='ai1net-dsh-server'`)
|
||||
会**假红**;而 peer 格恰好两者同字面 ⇒ **假绿**。⇒ 旧断言已失效,⛔ 不许留。
|
||||
✅ 新判据=**双查**:pill 必须画出 `ws_name`,标题必须画出 `title`(各查一次,互不遮掩)。 */
|
||||
const _wsn = String(snap.ws_name || '').trim();
|
||||
if (_wsn) {
|
||||
if (outHtml.indexOf(_wsn) >= 0) console.log('工作区真值 : ✅ 渲染出「' + _wsn + '」');
|
||||
else { console.log('工作区真值 : ❌ 快照有 ws_name「' + _wsn + '」却没渲染出来'); silent++; }
|
||||
} else {
|
||||
console.log('工作区真值 : ⚠️ 快照没有 ws_name(后端没给?目标行 pill 会退化成「—」)');
|
||||
}
|
||||
if (_title) {
|
||||
if (outHtml.indexOf(_title) >= 0) console.log('目标真值 : ✅ 渲染出(' + _title.slice(0, 24) + '…)');
|
||||
else { console.log('目标真值 : ❌ 快照有 title 却没渲染出来'); silent++; }
|
||||
}
|
||||
/* 🔴🔴 2026-10-05 加:**目标行必须是「目标 [工作区] 标题」这个形态**(用户逐字:
|
||||
「vibe-product 目标:提取 …」)。
|
||||
⛔ 判据是**产物形态**,不是"两个值分别出现过" —— 两个值都渲染了,但顺序/标签错了
|
||||
(比如 pill 跑到标题后面、或「目标」标签丢了)同样不合用户口径。
|
||||
✅ 真跑:拿**真产物**,去标签后**按令牌顺序**咬住 `目标` → `ws_name` → 标题。
|
||||
🔴🔴 坑(本轮实测):⛔ **不能**用 `/<\/?[^>]*>/g` 去标签 —— 标题里带 `https://…`
|
||||
时,正则会把 `<` 到下一个 `>` 之间整段吃掉(`//www.tiaoyue.com/ 的设计风格…` 全没了)
|
||||
⇒ `indexOf(标题)` 恒 -1 ⇒ **假红**("标题没渲染"其实渲染了)。
|
||||
⇒ ✅ 只剥**本文件已知的真实标签**(`<b>`/`<span …>`/`<div …>` 之类),
|
||||
⛔ 不用宽泛的 `<[^>]*>`。 */
|
||||
if (_wsn && _title) {
|
||||
const _flat = outHtml
|
||||
.replace(/<\/?(?:b|i|u|em|strong|span|div|code|ul|li|a|br|p|small|sup|sub|svg|path|canvas)(?:\s[^<>]*)?\/?>/gi, ' ')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/\s+/g, '');
|
||||
const _needle = _title.replace(/\s+/g, '');
|
||||
const _iLead = _flat.indexOf('目标'), _iWs = _flat.indexOf(_wsn), _iTi = _flat.indexOf(_needle);
|
||||
if (_iLead >= 0 && _iWs > _iLead && _iTi > _iWs) {
|
||||
console.log('目标行形态 : ✅ 目标 → ' + _wsn + ' → 标题(顺序正确)');
|
||||
} else {
|
||||
console.log('目标行形态 : ❌ 期望「目标 → 工作区 → 标题」,实得 idx(目标)=' + _iLead
|
||||
+ ' idx(工作区)=' + _iWs + ' idx(标题)=' + _iTi);
|
||||
silent++;
|
||||
}
|
||||
}
|
||||
if (_pj.id) {
|
||||
if (outHtml.indexOf(String(_pj.id)) >= 0) console.log('目标 id : ✅ ' + _pj.id);
|
||||
else { console.log('目标 id : ❌ 没渲染(criteria/id 取错层级)'); silent++; }
|
||||
}
|
||||
/* 🔴🔴 2026-10-05 加:**「任务类别」整行必须不在版面上**(用户逐字:
|
||||
「任务类别(声明任务类别)这个没用就删除 看着烦」)。
|
||||
⚠️ 判据是「**版面上没有**」而不是「函数里没有」—— 所以拿 `renderProject()` 的**真产物**判,
|
||||
⛔ 不 grep 源码(源码里 `'任务类别'` 字样还在:`?` 提示里那句「任务类别的来源」**必须保留**)。
|
||||
✅ 同时钉住「`?` 提示里那条来源仍在」⇒ 防**删过头**(把该留的信息一起删了)。 */
|
||||
let dashOk = true;
|
||||
if (outHtml.indexOf('>任务类别<') >= 0 || outHtml.indexOf('(未声明任务类别)') >= 0
|
||||
|| outHtml.indexOf('class="topics"') >= 0) {
|
||||
console.log('任务类别行 : ❌ 版面上还有(用户已要求删除)');
|
||||
dashOk = false;
|
||||
} else {
|
||||
console.log('任务类别行 : ✅ 版面已无该行(按用户口径删除)');
|
||||
}
|
||||
if (_tip.indexOf('任务类别的来源') >= 0) {
|
||||
console.log('类别信息留存 : ✅ `?` 提示里仍有「任务类别的来源」(⛔ 没删过头)');
|
||||
} else {
|
||||
console.log('类别信息留存 : ❌ `?` 提示里也没有了 ⇒ 删过头(类别来源无处可查)');
|
||||
dashOk = false;
|
||||
}
|
||||
/* 🔴🔴🔴 2026-10-05 加:**主会话 chip 不许画成「⚠ 无主会话」**(用户报障逐字:
|
||||
「peer 工作区的主会话还是找不到对应的吗」)。
|
||||
⛔ 判据不是"源码里有没有这个词" —— 源码**必然**有那句兜底文案(`board.html:1353`),
|
||||
所以只能查**渲染产物**:快照的 `project.main_by_topic` 有值 ⇒ 产物里就该有 id8;
|
||||
只有**取不到**时才允许出现「无主会话」。
|
||||
✅ 双向断言:**快照有值 ⇒ 产物不许说"无主会话"**;**快照确实空 ⇒ 必须说"无主会话"**
|
||||
(⛔ 否则把兜底整个删掉也能"绿",那是假绿)。 */
|
||||
const _mbt = ((snap.project || {}).main_by_topic) || {};
|
||||
const _hasMain = Object.keys(_mbt).some(k => {
|
||||
const v = _mbt[k];
|
||||
return (typeof v === 'object' && v && v.sid) || (typeof v === 'string' && v);
|
||||
});
|
||||
const _said = outHtml.indexOf('无主会话') >= 0;
|
||||
if (_hasMain && _said) {
|
||||
console.log('主会话渲染 : ❌ 快照有 `main_by_topic` 却仍画「无主会话」');
|
||||
dashOk = false;
|
||||
} else if (!_hasMain && !_said) {
|
||||
console.log('主会话渲染 : ❌ 快照无主会话,产物也没提「无主会话」(兜底被删?)');
|
||||
dashOk = false;
|
||||
} else {
|
||||
console.log('主会话渲染 : ✅ ' + (_hasMain ? '快照有主会话 ⇒ 产物未画兜底文案'
|
||||
: '快照确无主会话 ⇒ 如实画了兜底文案'));
|
||||
}
|
||||
console.log('静默错文案 :', silent ? ('❌ ' + silent + ' 项') : '✅ 无');
|
||||
|
||||
let ok = !err && !(errEl && errEl.textContent)
|
||||
&& outHtml.indexOf('accRow') >= 0 && outHtml.indexOf('完成情况') >= 0
|
||||
&& (typeof sandbox.renderProject==='function')
|
||||
&& silent === 0 && dashOk;
|
||||
if (WANT_PASS && (!m || m[0].replace(/\s/g,'') !== WANT_PASS.replace(/\s/g,''))) {
|
||||
console.log('期望通过率 : ❌ 应得 ' + WANT_PASS + ',实得 ' + (m?m[0]:'—'));
|
||||
ok = false;
|
||||
} else if (WANT_PASS) {
|
||||
console.log('期望通过率 : ✅ ' + WANT_PASS);
|
||||
}
|
||||
console.log('\n' + (ok ? '✅ 真跑通了(render 真被调用、零异常、#proj 真渲染出完成情况、无静默错文案)' : '❌ 仍有问题'));
|
||||
process.exit(ok ? 0 : 1);
|
||||
}
|
||||
|
||||
// 快照来源两选一:盘上文件(自测/离线)优先,否则打活服务。
|
||||
if (SNAP_FILE) {
|
||||
let raw;
|
||||
try { raw = fs.readFileSync(SNAP_FILE, 'utf8'); }
|
||||
catch (e) { console.log('读盘上快照失败:', e.message); process.exit(1); }
|
||||
run(JSON.parse(raw));
|
||||
} else {
|
||||
http.get(SNAP_URL, res => {
|
||||
let buf = ''; res.on('data', c => buf += c);
|
||||
res.on('end', () => { run(JSON.parse(buf)); });
|
||||
}).on('error', e => { console.log('取快照失败:', e.message); process.exit(1); });
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,307 @@
|
||||
:root {
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
--space-6: 32px;
|
||||
--space-7: 48px;
|
||||
--space-8: 64px;
|
||||
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-base: 14px;
|
||||
--text-md: 16px;
|
||||
--text-lg: 20px;
|
||||
--text-xl: 24px;
|
||||
--text-2xl: 32px;
|
||||
|
||||
--weight-normal: 400;
|
||||
--weight-medium: 500;
|
||||
|
||||
--leading-body: 1.6;
|
||||
--leading-heading: 1.3;
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--duration-fast: 120ms;
|
||||
--duration-base: 200ms;
|
||||
--ease: cubic-bezier(0.2, 0, 0.2, 1);
|
||||
|
||||
--z-base: 0;
|
||||
--z-sticky: 10;
|
||||
--z-dropdown: 20;
|
||||
--z-overlay: 30;
|
||||
--z-modal: 40;
|
||||
--z-toast: 50;
|
||||
|
||||
--font-sans: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--color-bg-page: #f7f7f5;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-subtle: #f1efe8;
|
||||
--color-bg-inverse: #2c2c2a;
|
||||
|
||||
--color-text-primary: #2c2c2a;
|
||||
--color-text-secondary: #5f5e5a;
|
||||
--color-text-tertiary: #6f6e6a;
|
||||
--color-text-inverse: #ffffff;
|
||||
|
||||
--color-border-subtle: rgba(0, 0, 0, 0.08);
|
||||
--color-border-default: rgba(0, 0, 0, 0.15);
|
||||
--color-border-strong: rgba(0, 0, 0, 0.3);
|
||||
|
||||
--color-accent: #185fa5;
|
||||
--color-accent-hover: #0c447c;
|
||||
--color-accent-subtle: #e6f1fb;
|
||||
--color-on-accent: #ffffff;
|
||||
|
||||
--color-success: #0f6e56;
|
||||
--color-success-subtle: #e1f5ee;
|
||||
--color-warning: #854f0b;
|
||||
--color-warning-subtle: #faeeda;
|
||||
--color-danger: #a32d2d;
|
||||
--color-danger-subtle: #fcebeb;
|
||||
|
||||
--color-focus-ring: #185fa5;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-bg-page: #1b1b19;
|
||||
--color-bg-surface: #262624;
|
||||
--color-bg-subtle: #2f2f2c;
|
||||
--color-bg-inverse: #f1efe8;
|
||||
|
||||
--color-text-primary: #f1efe8;
|
||||
--color-text-secondary: #b4b2a9;
|
||||
--color-text-tertiary: #9a9890;
|
||||
--color-text-inverse: #2c2c2a;
|
||||
|
||||
--color-border-subtle: rgba(255, 255, 255, 0.1);
|
||||
--color-border-default: rgba(255, 255, 255, 0.18);
|
||||
--color-border-strong: rgba(255, 255, 255, 0.32);
|
||||
|
||||
--color-accent: #85b7eb;
|
||||
--color-accent-hover: #b5d4f4;
|
||||
--color-accent-subtle: #0c447c;
|
||||
--color-on-accent: #042c53;
|
||||
|
||||
--color-success: #5dcaa5;
|
||||
--color-success-subtle: #085041;
|
||||
--color-warning: #ef9f27;
|
||||
--color-warning-subtle: #633806;
|
||||
--color-danger: #f09595;
|
||||
--color-danger-subtle: #791f1f;
|
||||
|
||||
--color-focus-ring: #85b7eb;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg-page);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-normal);
|
||||
line-height: var(--leading-body);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
margin: 0;
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: var(--leading-heading);
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-2xl); }
|
||||
h2 { font-size: var(--text-xl); }
|
||||
h3 { font-size: var(--text-lg); }
|
||||
h4 { font-size: var(--text-md); }
|
||||
|
||||
p { margin: 0; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) var(--ease);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-surface);
|
||||
border-color: var(--color-border-default);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--space-5);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-3);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.input:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.input[aria-invalid="true"] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stack-2 { gap: var(--space-2); }
|
||||
.stack-3 { gap: var(--space-3); }
|
||||
.stack-4 { gap: var(--space-4); }
|
||||
.stack-5 { gap: var(--space-5); }
|
||||
.stack-6 { gap: var(--space-6); }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-2 { gap: var(--space-2); }
|
||||
.row-3 { gap: var(--space-3); }
|
||||
.row-4 { gap: var(--space-4); }
|
||||
|
||||
.muted { color: var(--color-text-secondary); }
|
||||
.hint { color: var(--color-text-tertiary); font-size: var(--text-xs); }
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: var(--color-bg-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: pulse 1.2s var(--ease) infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.55; }
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# collabd supervisor keeper -- NEVER returns on purpose.
|
||||
#
|
||||
# 🔴🔴 2026-10-04 修:首行原本多打了三个双引号(即「三连双引号 + #」开头,
|
||||
# 把整份 `.ps1` 变成了「Python 看着合法、PowerShell 看着语法错」的畸形文件
|
||||
# ⇒ 生成物被 PowerShell 判定 9 处语法错、**一秒内秒退** ⇒ 常驻彻底没人续命。
|
||||
# ⛔ 这个文件是 **PowerShell**(不是 Python)⇒ 注释一律用 `#`,⛔ 不要三引号。
|
||||
#
|
||||
# ⚙️ 本文件由 `init_workspace.py` **按工作区变量自动生成**(⛔ 请勿手改:要改改模板 + 重跑脚本)。
|
||||
# 生成时间:__STAMP__
|
||||
# 适用工作区:__WS__
|
||||
#
|
||||
# Why this file exists(⛔ 下面几条是踩出来的,⛔ 别"简化"掉):
|
||||
# ① 计划任务**只在任务失败(非零退出)时重启**。早前版本末尾直接前台调 pythonw,
|
||||
# pythonw 死掉后脚本返回 0 ⇒ 任务被判"成功"⇒ RestartCount=999 永不触发
|
||||
# ⇒ **常驻死了就永远死着**。⇒ **重启逻辑必须住在脚本里**。
|
||||
# ② ⛔ **绝不能用 `& $pyw ...`** 起常驻:pythonw 是 GUI 子系统程序,PowerShell 的
|
||||
# 调用运算符**不阻塞**它 ⇒ 立刻返回 ⇒ 脚本以为"collabd 退出了"⇒ 每几秒就重拉
|
||||
# ⇒ **叠出一堆重复常驻**。⇒ 用 `Start-Process -Wait`(真阻塞)。
|
||||
# ③ **存活判据=心跳,不是退出码**:collabd 发现有活的常驻时会**故意 exit 0**(幂等),
|
||||
# 那个 0 退出**绝不能**触发重拉风暴。
|
||||
# ④ UTF-8 **带 BOM**(⛔ 不带 ⇒ PowerShell 5.1 按 ANSI/GBK 解码 ⇒ CJK 路径被毁
|
||||
# ⇒ 任务一秒内 Result=1 退出)。
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
$env:CODEBUDDY_CONFIG_DIR = "__CFGDIR__"
|
||||
$env:COLLABD_CONFIG = "__COLLABDCFG__"
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
$env:PYTHONUNBUFFERED = "1" # stdout 重定向到文件,Python 会块缓冲 ⇒ 不设它诊断全压在 4KB 之后
|
||||
|
||||
$root = "__WS__"
|
||||
$pyw = "__PYW__"
|
||||
$script = "__SCRIPT__"
|
||||
$hbPath = "__HBPATH__"
|
||||
$logPath = "__LOG__"
|
||||
$stampPath = "__STAMP__"
|
||||
# 🔴 停止标志:机制侧 `--set-life 已完成` ⇒ 写它 ⇒ 常驻优雅退出。
|
||||
# ⚠️ 改前缺陷:keeper 不知道它 ⇒ 目标收口后**每 60 秒重拉一次、每次秒退**
|
||||
# (实测**空转 370 次/6 小时**)⇒ "完成即收工"只做到"停掉常驻",
|
||||
# ⛔ **没做到"别再拉它起来"** —— 那是同一件事的两半。
|
||||
$stopFlag = "__STOPFLAG__"
|
||||
|
||||
$staleSec = 90 # 与 collabd.supervise_alive() 同一陈旧阈值
|
||||
Set-Location $root
|
||||
|
||||
function Say([string]$m) {
|
||||
$ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
|
||||
Add-Content -LiteralPath $logPath -Value ("[" + $ts + "] " + $m) -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-HbAge {
|
||||
try {
|
||||
if (-not (Test-Path -LiteralPath $hbPath)) { return -1 }
|
||||
$raw = Get-Content -LiteralPath $hbPath -Raw -Encoding UTF8
|
||||
if (-not $raw) { return -1 }
|
||||
$ts = [double](($raw | ConvertFrom-Json).ts)
|
||||
if ($ts -le 0) { return -1 }
|
||||
return [int]((Get-Date).ToString("U") - $ts)
|
||||
} catch { return -1 }
|
||||
}
|
||||
|
||||
function Wait-HeartbeatFresh([int]$maxWait) {
|
||||
$waited = 0
|
||||
while ($waited -lt $maxWait) {
|
||||
$age = Get-HbAge
|
||||
if ($age -ge 0 -and $age -lt $staleSec) {
|
||||
Say ("another supervisor is alive (heartbeat " + $age + "s old) => standing down")
|
||||
return $true
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
$waited += 5
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
Say ("keeper up. keeper_pid=" + $PID + " script=" + $script)
|
||||
|
||||
$backoff = 5
|
||||
while ($true) {
|
||||
# ① 循环开头:目标已完成 ⇒ 收工,不再重拉
|
||||
if (Test-Path -LiteralPath $stopFlag) {
|
||||
Say "guard.stop present => target finished; keeper stands down (no more respawn)"
|
||||
exit 0
|
||||
}
|
||||
$ageBefore = Get-HbAge
|
||||
if ($ageBefore -ge 0 -and $ageBefore -lt $staleSec) {
|
||||
if (Wait-HeartbeatFresh 300) { continue }
|
||||
}
|
||||
|
||||
Say ("spawn collabd --supervise (backoff=" + $backoff + "s)")
|
||||
$outLog = "__OUTLOG__"
|
||||
$errLog = "__ERRLOG__"
|
||||
try {
|
||||
# -Wait 给的是**真阻塞**(`&` 给不了 GUI 子系统程序);-Redirect* 让证据落盘
|
||||
$proc = Start-Process -FilePath $pyw `
|
||||
-ArgumentList @("-u", $script, "--supervise") `
|
||||
-WorkingDirectory $root `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $outLog `
|
||||
-RedirectStandardError $errLog `
|
||||
-PassThru -Wait
|
||||
$code = $proc.ExitCode
|
||||
} catch {
|
||||
Say ("spawn threw: " + $_.Exception.Message + " => retry in " + $backoff + "s")
|
||||
Start-Sleep -Seconds $backoff
|
||||
if ($backoff -lt 60) { $backoff = [Math]::Min(60, $backoff * 2) }
|
||||
continue
|
||||
}
|
||||
|
||||
$ageAfter = Get-HbAge
|
||||
if ($ageAfter -ge 0 -and $ageAfter -lt $staleSec) {
|
||||
$verdict = "heartbeat still fresh (" + $ageAfter + "s) => idle exit, no respawn yet"
|
||||
} elseif ($ageAfter -ge 0) {
|
||||
$verdict = "heartbeat stale (" + $ageAfter + "s) => supervisor really died"
|
||||
} else {
|
||||
$verdict = "heartbeat unreadable"
|
||||
}
|
||||
Say ("collabd exited (exit=" + $code + ", " + $verdict + ")")
|
||||
Set-Content -LiteralPath $stampPath -Value ((Get-Date).ToString("yyyy-MM-dd HH:mm:ss")) -Encoding UTF8
|
||||
|
||||
# ② 退避**期间**标志可能出现 ⇒ 别再睡满一轮(⛔ 漏这里 ⇒ 收工后还会多拉一次)
|
||||
$waitLeft = $backoff
|
||||
while ($waitLeft -gt 0) {
|
||||
if (Test-Path -LiteralPath $stopFlag) {
|
||||
Say "guard.stop appeared during backoff => keeper stands down early"
|
||||
exit 0
|
||||
}
|
||||
Start-Sleep -Seconds 5
|
||||
$waitLeft -= 5
|
||||
}
|
||||
if ($backoff -lt 60) { $backoff = [Math]::Min(60, $backoff * 2) }
|
||||
}
|
||||
Reference in new issue
Block a user