@gavinc-cn/touchstone-dsh 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +22 -1
- package/README.zh.md +12 -1
- package/agents.py +0 -0
- package/auth.py +0 -0
- package/board.py +1119 -93
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/chat.py +590 -17
- package/db.py +63 -12
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +8 -5
- package/dsh-plugin/lib/agent-driver.js +895 -126
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +399 -20
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +0 -0
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +100 -6
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu.py +1226 -42
- package/feishu_conv.py +920 -0
- package/lib.py +0 -0
- package/lifecycle.py +0 -0
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +8 -3
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +2 -1
- package/runner.py +11 -0
- package/server.py +264 -30
- package/sessparse.py +244 -44
- package/touchstone.py +8 -4
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +484 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +1 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +100 -0
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
package/dsh-plugin/src/client.js
CHANGED
|
@@ -105,20 +105,103 @@ function toggleByKey(e) {
|
|
|
105
105
|
/** 宿主页 keydown 监听体(capture 阶段; 函数引用稳定, 便于 dispose 时精确解绑)。 */
|
|
106
106
|
function onHostKey(e) { toggleByKey(e); }
|
|
107
107
|
|
|
108
|
+
// ── 侧栏入口按钮的配色与几何(2026-10-09 修) ──────────────────────────────────
|
|
109
|
+
// 血案: 原先写 color:'var(--dsw-alias-text-l1, #e8e8e8)' —— dsh **没有** text-l1 这族
|
|
110
|
+
// token(真实族是 --dsw-alias-label-primary/secondary/tertiary/caption/dimmed), var() 取不到
|
|
111
|
+
// 就一直吃 fallback: 浅色档在 #f9fafb 的侧栏底上画 #e8e8e8 的字(实测对比度 1.17, 同排的
|
|
112
|
+
// 「插件」「设置」是 18.08), 只有深色档碰巧能看。教训: 引用宿主 token 一律先核实名字。
|
|
113
|
+
// 现行口径(明暗两档都跟宿主走):
|
|
114
|
+
// · 颜色**只取 dsh 语义 token 且不写死 fallback** —— token 缺席时该声明失效并回落到继承
|
|
115
|
+
// (侧栏根 .hHd-Xa_root 已设 label-primary), 而不是钉死一个只对某一档成立的灰;
|
|
116
|
+
// · 尺寸/圆角/gap/行高逐条对齐 dsh 自己的 .SidebarRoot_panelRow(min-height 36 / 内边距
|
|
117
|
+
// 7px 8px / margin 0 2px / gap 8 / line-height 22 / --dsw-radius-md), 收起态对齐
|
|
118
|
+
// .hHd-Xa_collapsed .panelRow(36x36 居中);
|
|
119
|
+
// · 图标盒 16x16 —— 与 dsh 侧栏 SVG 图标同尺寸; 文字继承侧栏根的 14px。
|
|
120
|
+
const ENTRY_TITLE = 'Touchstone 测试平台(Alt+T 开关)';
|
|
121
|
+
const ENTRY_GLYPH_STYLE = {
|
|
122
|
+
flex: '0 0 16px', width: 16, height: 16, display: 'inline-flex',
|
|
123
|
+
alignItems: 'center', justifyContent: 'center', fontSize: 16, lineHeight: '16px',
|
|
124
|
+
};
|
|
125
|
+
const ENTRY_LABEL_STYLE = {
|
|
126
|
+
minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// 交互态样式(悬停/开合/键盘聚焦环): 伪类没法写在 inline style 里, 落一张最小样式表。
|
|
130
|
+
// 取值逐条对齐 dsh 侧栏行(.hHd-Xa_panelRow:hover 与 :focus-visible)。
|
|
131
|
+
const ENTRY_CSS = [
|
|
132
|
+
'.ts-sb-entry{background:transparent}',
|
|
133
|
+
'.ts-sb-entry:hover,.ts-sb-entry.ts-sb-on{background:'
|
|
134
|
+
+ 'var(--dsw-alias-interactive-bg-hover,color-mix(in srgb,currentColor 8%,transparent))}',
|
|
135
|
+
'.ts-sb-entry:focus-visible{outline-width:var(--dsw-focus-ring-width,2px);outline-style:solid;'
|
|
136
|
+
+ 'outline-color:var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));'
|
|
137
|
+
+ 'outline-offset:-2px}',
|
|
138
|
+
].join('');
|
|
139
|
+
const ENTRY_CSS_ID = 'touchstone/sidebar-entry.css';
|
|
140
|
+
|
|
141
|
+
/** 幂等挂上入口行交互态样式表(形态与 dsh 插件自身的注入方式一致)。
|
|
142
|
+
* **失败一律吞掉**: 挂不上只是少了悬停/聚焦环的观感, 绝不能让侧栏入口或面板受影响
|
|
143
|
+
* (桩 DOM 自检环境里 document 面本就不全, 真机异常同理)。
|
|
144
|
+
* @returns 摘除函数(插件卸载时调用; 已存在/挂不上时返回空操作)。 */
|
|
145
|
+
function mountEntryStyle() {
|
|
146
|
+
try {
|
|
147
|
+
if (typeof document === 'undefined' || !document.head
|
|
148
|
+
|| typeof document.createElement !== 'function'
|
|
149
|
+
|| typeof document.querySelector !== 'function') {
|
|
150
|
+
return () => {};
|
|
151
|
+
}
|
|
152
|
+
if (document.querySelector('style[data-plugin-css=' + JSON.stringify(ENTRY_CSS_ID) + ']')) {
|
|
153
|
+
return () => {};
|
|
154
|
+
}
|
|
155
|
+
const tag = document.createElement('style');
|
|
156
|
+
tag.dataset.plugin = 'touchstone';
|
|
157
|
+
tag.dataset.pluginCss = ENTRY_CSS_ID;
|
|
158
|
+
tag.textContent = ENTRY_CSS;
|
|
159
|
+
document.head.appendChild(tag);
|
|
160
|
+
return () => tag.remove();
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.warn('[touchstone] 侧栏入口样式注入失败(只影响悬停/聚焦观感):', error);
|
|
163
|
+
return () => {};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
108
167
|
// 侧栏入口按钮(footer.action 槽; wide=侧栏展开态显示文字)
|
|
109
168
|
function Toggle(props) {
|
|
110
|
-
const wide = props && props.wide;
|
|
169
|
+
const wide = !!(props && props.wide);
|
|
111
170
|
const isOpen = useOpen();
|
|
171
|
+
const style = {
|
|
172
|
+
boxSizing: 'border-box', display: 'flex', alignItems: 'center',
|
|
173
|
+
border: 'none', cursor: 'pointer', textAlign: 'left',
|
|
174
|
+
color: 'var(--dsw-alias-label-primary)', // 无 fallback: 取不到即回落继承(见上注)
|
|
175
|
+
borderRadius: 'var(--dsw-radius-md, 8px)',
|
|
176
|
+
fontSize: 'inherit', // 侧栏根是 14px, 不再自带 13px
|
|
177
|
+
};
|
|
178
|
+
if (wide) {
|
|
179
|
+
style.gap = 8;
|
|
180
|
+
style.margin = '0 2px';
|
|
181
|
+
style.padding = '7px 8px';
|
|
182
|
+
style.minHeight = 36;
|
|
183
|
+
style.lineHeight = '22px';
|
|
184
|
+
style.flex = '1 1 auto';
|
|
185
|
+
style.minWidth = 0;
|
|
186
|
+
} else {
|
|
187
|
+
style.width = 36;
|
|
188
|
+
style.height = 36;
|
|
189
|
+
style.margin = 0;
|
|
190
|
+
style.padding = 0;
|
|
191
|
+
style.flex = 'none';
|
|
192
|
+
style.justifyContent = 'center';
|
|
193
|
+
}
|
|
112
194
|
return h('button', {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
title: 'Touchstone 测试平台(Alt+T 开关)',
|
|
195
|
+
type: 'button',
|
|
196
|
+
className: 'ts-sb-entry' + (isOpen ? ' ts-sb-on' : ''),
|
|
197
|
+
style,
|
|
198
|
+
title: ENTRY_TITLE,
|
|
199
|
+
'aria-label': ENTRY_TITLE,
|
|
200
|
+
'aria-expanded': isOpen,
|
|
120
201
|
onClick: () => setOpen(!isOpen),
|
|
121
|
-
},
|
|
202
|
+
},
|
|
203
|
+
h('span', { style: ENTRY_GLYPH_STYLE, 'aria-hidden': 'true' }, '🧪'),
|
|
204
|
+
wide ? h('span', { style: ENTRY_LABEL_STYLE }, isOpen ? '关闭 Touchstone' : 'Touchstone') : null);
|
|
122
205
|
}
|
|
123
206
|
|
|
124
207
|
// 关闭按钮自动隐藏(2026-10-03): 常显会盖住被嵌 SPA 侧栏底部的用户名, 所以平时完全隐藏,
|
|
@@ -330,6 +413,8 @@ function Panel() {
|
|
|
330
413
|
|
|
331
414
|
// cordis 客户端插件入口: 等 slots 服务就位后注册两个 UI 槽, 再挂快捷键
|
|
332
415
|
function apply(ctx) {
|
|
416
|
+
// 侧栏入口的交互态样式表(悬停/开合/聚焦环), 与本插件同生命周期
|
|
417
|
+
ctx.effect(() => mountEntryStyle(), 'touchstone: 侧栏入口样式');
|
|
333
418
|
const slots = ctx.get('slots');
|
|
334
419
|
if (!slots) return;
|
|
335
420
|
slots.inject('sidebar.footer.action', () => slots.register(
|
package/dshdriver.py
CHANGED
|
@@ -143,9 +143,30 @@ def available(timeout=HEALTH_TIMEOUT):
|
|
|
143
143
|
return False
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
def live_snapshot(timeout=10):
|
|
147
|
+
"""`/live` 的**原始响应**:`{sessions: [...], complete?: bool}`。
|
|
148
|
+
|
|
149
|
+
`complete`(A 批 2026-10-08 起真插件会上报)=插件是否已枚举宿主已有会话:
|
|
150
|
+
只有它为 true,**空表**才具备「宿主里确实没有会话」的语义(见 dshevents._align)。
|
|
151
|
+
旧装机副本没有该字段 ⇒ `complete` 缺省按未声明处理(空表=未知)。
|
|
152
|
+
|
|
153
|
+
为什么单列一个读口(2026-10-08 修正):`live()` 返回的是**行表**(历史调用点
|
|
154
|
+
server.py 直接迭代),而中枢对齐需要「行表 + 完整性声明」两件东西。旧写法拿
|
|
155
|
+
`live()` 的返回值当 dict 取 `.get("sessions")` ⇒ `/live` 非空时必抛
|
|
156
|
+
AttributeError,被 `except` 吞掉后中枢**永久「未对齐」**(`aligned()` 恒 False ⇒
|
|
157
|
+
看板 recover 不搬列、调和器缺席归位不触发)。契约由
|
|
158
|
+
`tests/test_session_visibility.py::test_live_readers_contract` 钉死。
|
|
159
|
+
"""
|
|
160
|
+
return _request("GET", "/live", timeout=timeout) or {}
|
|
161
|
+
|
|
162
|
+
|
|
146
163
|
def live():
|
|
147
|
-
"""当前可见会话表(含外部直跑会话):替代逐会话 REST 探测的实时态来源。
|
|
148
|
-
|
|
164
|
+
"""当前可见会话表(含外部直跑会话):替代逐会话 REST 探测的实时态来源。
|
|
165
|
+
|
|
166
|
+
返回**行表 list**(历史契约:server.py 的外部会话汇总直接迭代);需要「完整性
|
|
167
|
+
声明」的调用方(dshevents._align)用 `live_snapshot()`。
|
|
168
|
+
"""
|
|
169
|
+
return list((live_snapshot() or {}).get("sessions") or [])
|
|
149
170
|
|
|
150
171
|
|
|
151
172
|
def status(session_id):
|
|
@@ -245,9 +266,17 @@ def fork(session_id, at_seq=None):
|
|
|
245
266
|
return _request("POST", "/fork", payload, timeout=60)
|
|
246
267
|
|
|
247
268
|
|
|
248
|
-
def rename(session_id, title):
|
|
249
|
-
"""改会话标题(与 dsh 侧栏同一份标题)。返回 `{title}`。
|
|
250
|
-
|
|
269
|
+
def rename(session_id, title, timeout=REQUEST_TIMEOUT):
|
|
270
|
+
"""改会话标题(与 dsh 侧栏同一份标题)。返回 `{title, [external]}`。
|
|
271
|
+
|
|
272
|
+
`title` 是**宿主接受值**(可能被 `normalizeSessionTitle` 规范化/按 UTF-8 字节预算
|
|
273
|
+
截断),调用方据此回写卡面可保证两侧逐字一致(见 board.rename_card_session)。
|
|
274
|
+
|
|
275
|
+
`timeout` 可缩短:卡面改名是 best-effort 的同步链路(`PATCH /board/cards/<cid>`),
|
|
276
|
+
不能让驱动侧僵死把 HTTP worker 拖满默认 120s。
|
|
277
|
+
"""
|
|
278
|
+
return _request("POST", "/rename", {"session_id": session_id, "title": title},
|
|
279
|
+
timeout=timeout)
|
|
251
280
|
|
|
252
281
|
|
|
253
282
|
def set_model(session_id, model, provider="", reasoning_effort=""):
|
|
@@ -355,6 +384,46 @@ def unarchive(session_id, timeout=30):
|
|
|
355
384
|
return archive(session_id, archived=False, timeout=timeout)
|
|
356
385
|
|
|
357
386
|
|
|
387
|
+
# ---------- 看管声明(外部会话投递通道,C 批 T5 2026-10-10) ----------
|
|
388
|
+
|
|
389
|
+
# 看管声明超时(秒):`/watch` 是驱动侧**纯内存写**,正常毫秒级返回;而投递前置闸
|
|
390
|
+
# (`chat._external_preflight`)是在 HTTP 请求线程上同步调用它的——不能沿用普通请求
|
|
391
|
+
# 的 120s,否则驱动僵死会把用户的一次「发送」卡住两分钟。2s 与「声明不成功即拒投」
|
|
392
|
+
# 的语义相称(设计 §5.1)。
|
|
393
|
+
WATCH_TIMEOUT = 2
|
|
394
|
+
|
|
395
|
+
# 旧插件没有 `/watch` 端点时的状态码(未知驱动端点,见插件 _serve 的兜底 404)。
|
|
396
|
+
# 平台据此把「外部会话投递」降级为拒投并给「插件过旧」文案,池内一切功能不受影响。
|
|
397
|
+
WATCH_UNSUPPORTED = 404
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def watch_session(session_id, on=True):
|
|
401
|
+
"""声明 / 撤销「平台看管该会话」(驱动 `POST /watch`,幂等)。返回看管是否生效。
|
|
402
|
+
|
|
403
|
+
为什么需要(C 批设计 §3.1):外部会话(用户在 dsh GUI 里直跑/接管、`/live` 里
|
|
404
|
+
`owned:false`)既不在驱动池、也没有平台持有者,驱动侧因此不可能靠「在不在池内」
|
|
405
|
+
推断平台是否有意投递——**范围必须由平台显式划定**(宿主里与 Touchstone 无关的
|
|
406
|
+
会话多得是,无差别放开等于允许平台向任意会话注入消息与答案)。平台只对**平台确实
|
|
407
|
+
要投递的**会话声明看管:有看板卡的会话由 `board._ensure_watch` 常态声明;任务侧
|
|
408
|
+
会话在宿主上报为外部态(`owned:false`)时,由消息前置闸 `chat._external_preflight`
|
|
409
|
+
与作答/审批送达前的 `board._watch_before_delivery` 补声明。驱动据此放行 `/prompt`·
|
|
410
|
+
`/steer`·`/answer`·`/approval` 的池外分支。
|
|
411
|
+
|
|
412
|
+
幂等:重复声明同一 sid 不报错,驱动回执给的是 `watched` 的**当前实际状态**
|
|
413
|
+
(而非「这次有没有改动」),故本函数返回 `bool(resp["watched"])`——调用方按
|
|
414
|
+
「True 才算声明成功」处置。`on=False` 撤销(卡删除/归档后的回收,见
|
|
415
|
+
`board._watch_prune`)。
|
|
416
|
+
|
|
417
|
+
失败语义:**异常照抛**(`DshDriverError`:旧插件 404 / 驱动不可达 / 令牌错),
|
|
418
|
+
「是否吞异常降级」由调用方决定——`board._ensure_watch` 选择留痕 + 返回 False
|
|
419
|
+
且不重试(看管失败 ⇒ 投递前置闸拒投,见 `chat._external_preflight`)。
|
|
420
|
+
"""
|
|
421
|
+
resp = _request("POST", "/watch",
|
|
422
|
+
{"session_id": session_id, "on": bool(on)},
|
|
423
|
+
timeout=WATCH_TIMEOUT)
|
|
424
|
+
return bool((resp or {}).get("watched"))
|
|
425
|
+
|
|
426
|
+
|
|
358
427
|
def apply_session_defaults(session_id, model="", provider="",
|
|
359
428
|
reasoning_effort="", permission_mode="", log=None):
|
|
360
429
|
"""把项目级默认(思考等级 / 权限档)应用到刚建或刚续的会话上(best-effort)。
|
package/dshevents.py
CHANGED
|
@@ -61,6 +61,11 @@ class EventHub:
|
|
|
61
61
|
# 与 `_sessions` 同样遵守「断连=未知」不变量——None 时调用方一律不动作。
|
|
62
62
|
self._archived = None
|
|
63
63
|
self._connected = False
|
|
64
|
+
# 快照对齐是否**可信**(2026-10-08 批次,B):只有「(重)连时拿到过权威快照」
|
|
65
|
+
# 才置 True——空快照且插件未声明完整时保留旧表但置 False(见 `_align`)。
|
|
66
|
+
# 读口 `aligned()` = connected ∧ _aligned:调用方据此决定「能否按缺席推断
|
|
67
|
+
# 会话已结束」(`board._iw_once`)与「能否按实况搬列」(`board.recover`)。
|
|
68
|
+
self._aligned = False
|
|
64
69
|
self._last_seq = 0
|
|
65
70
|
self._frames = 0
|
|
66
71
|
self._reconnects = 0
|
|
@@ -101,11 +106,29 @@ class EventHub:
|
|
|
101
106
|
with self._cond:
|
|
102
107
|
return self._connected
|
|
103
108
|
|
|
109
|
+
def aligned(self):
|
|
110
|
+
"""注册表是否**可信**(= 链路在线 ∧ 曾用插件权威快照对齐过)。
|
|
111
|
+
|
|
112
|
+
为什么需要它(2026-10-08 批次 B,bug_report/20261008_1935):
|
|
113
|
+
插件重新 apply(热重载)时驱动实例重建、`/live` 会短暂返回空表——旧实现
|
|
114
|
+
`self._sessions = fresh` 直接清空注册表,调用方随即把「未知」当成
|
|
115
|
+
「会话已结束/空闲」:`board.recover()` 把**正在运行的卡**搬去待审核,
|
|
116
|
+
且调和器此后再也读不到该会话 ⇒ 不自愈。现在空快照保留旧表并置
|
|
117
|
+
`_aligned=False`,本读口给出「是否可信」的单点判据:
|
|
118
|
+
- False ⇒ 一律不据实况写列(未知 ≠ 空闲,同「断连=未知」不变量);
|
|
119
|
+
- True ⇒ 会话不在注册表即可判「已结束」(`board._iw_once` 的补口)。
|
|
120
|
+
插件半(A 批次)在 `/live` 里声明 `complete` 后,真·零会话的宿主也能可信。
|
|
121
|
+
"""
|
|
122
|
+
with self._cond:
|
|
123
|
+
return bool(self._connected and self._aligned)
|
|
124
|
+
|
|
104
125
|
def get(self, sid):
|
|
105
126
|
"""单会话实时态;未连接或未知会话返回 None(=未知,不是空闲)。
|
|
106
127
|
|
|
107
|
-
字段:`session_id/status/cwd/task/owned/interaction/
|
|
108
|
-
updated_at`——与 `dshdriver.status()` 同形,供 board
|
|
128
|
+
字段:`session_id/status/cwd/task/owned/origin/interaction/
|
|
129
|
+
last_turn_reason/updated_at`——与 `dshdriver.status()` 同形,供 board 直接
|
|
130
|
+
替换读口。`origin`(2026-10-07 增):`"subagent"` = dsh 子代理会话,
|
|
131
|
+
`""` = 未知(旧插件未上报,board 侧另有磁盘兜底判据)。
|
|
109
132
|
"""
|
|
110
133
|
if not sid:
|
|
111
134
|
return None
|
|
@@ -144,6 +167,7 @@ class EventHub:
|
|
|
144
167
|
"""诊断信息(/api 健康与测试断言用)。"""
|
|
145
168
|
with self._cond:
|
|
146
169
|
return {"connected": self._connected, "sessions": len(self._sessions),
|
|
170
|
+
"aligned": bool(self._connected and self._aligned),
|
|
147
171
|
"last_seq": self._last_seq, "frames": self._frames,
|
|
148
172
|
"reconnects": self._reconnects, "changed": self._changed,
|
|
149
173
|
"archived": None if self._archived is None else len(self._archived),
|
|
@@ -174,6 +198,24 @@ class EventHub:
|
|
|
174
198
|
self._cond.wait(timeout)
|
|
175
199
|
return self._changed != before
|
|
176
200
|
|
|
201
|
+
def wait_connected(self, timeout):
|
|
202
|
+
"""等(首)次连接就绪:就绪返回 True,超时返回 False。
|
|
203
|
+
|
|
204
|
+
启动补跑用(server 在 `dshevents.start()` 之后等它就绪,再对账 ext 行
|
|
205
|
+
——2026-10-07 僵尸占用修复):`board.recover()` 执行时中枢尚未 start,
|
|
206
|
+
那次 ext 对账必然「探测不可用」而保行,需要一次连接就绪后的补跑。
|
|
207
|
+
未配置驱动/宿主不可达时至多等满 timeout;绝不把「未连接」当成就绪
|
|
208
|
+
(不变量 1「断连=未知」)。
|
|
209
|
+
"""
|
|
210
|
+
deadline = time.monotonic() + max(0.0, float(timeout))
|
|
211
|
+
with self._cond:
|
|
212
|
+
while not self._connected:
|
|
213
|
+
remain = deadline - time.monotonic()
|
|
214
|
+
if remain <= 0:
|
|
215
|
+
return False
|
|
216
|
+
self._cond.wait(remain)
|
|
217
|
+
return True
|
|
218
|
+
|
|
177
219
|
# ---------- 内部:连接与折叠 ----------
|
|
178
220
|
|
|
179
221
|
def _run(self):
|
|
@@ -206,8 +248,20 @@ class EventHub:
|
|
|
206
248
|
新的状态轮询」。归档集取不到时保持「未知」(None),调用方一律不动作。
|
|
207
249
|
"""
|
|
208
250
|
try:
|
|
209
|
-
|
|
210
|
-
|
|
251
|
+
# 用 live_snapshot()(原始 dict)而不是 live()(行表 list)——2026-10-08 修正:
|
|
252
|
+
# 旧写法 `dshdriver.live() or {}` 把行表当 dict 取 `.get("sessions")`,
|
|
253
|
+
# `/live` 非空时必抛 AttributeError 被下面 except 吞掉 ⇒ 中枢**永久未对齐**
|
|
254
|
+
# (aligned() 恒 False,C/D 两处补口在真机等于失效)。契约见
|
|
255
|
+
# tests/test_session_visibility.py::test_live_readers_contract。
|
|
256
|
+
resp = dshdriver.live_snapshot() or {}
|
|
257
|
+
rows = resp.get("sessions") or []
|
|
258
|
+
# 插件完整声明(A 批次后新增,旧插件缺省 None=未声明):只有它才让
|
|
259
|
+
# 「空表」具备「宿主里确实没有会话」的语义,否则空表一律按未知处理。
|
|
260
|
+
complete = resp.get("complete") is True
|
|
261
|
+
except Exception as exc: # noqa: BLE001 — 对齐失败不阻断重连
|
|
262
|
+
self._set_aligned(False) # 取不到快照=不能确认任何事
|
|
263
|
+
print(f"[dshevents] /live 对齐失败({exc}):保留 {len(self._sessions)} 条"
|
|
264
|
+
f"既有会话、标记未对齐", flush=True)
|
|
211
265
|
return
|
|
212
266
|
try:
|
|
213
267
|
archived = {str(s) for s in (dshdriver.archived() or []) if s}
|
|
@@ -227,6 +281,9 @@ class EventHub:
|
|
|
227
281
|
"cwd": row.get("cwd") or old.get("cwd") or "",
|
|
228
282
|
"task": row.get("task") or old.get("task") or "",
|
|
229
283
|
"owned": bool(row.get("owned", old.get("owned", False))),
|
|
284
|
+
# origin 是会话固有属性(subagent / 空=未知):快照没带就保留
|
|
285
|
+
# 事件里学到的值,别把已认出的子代理会话在重连对齐时又变成未知
|
|
286
|
+
"origin": str(row.get("origin") or old.get("origin") or ""),
|
|
230
287
|
# /live 的 interaction 可能为 None(此刻无等待);已挂起的
|
|
231
288
|
# interaction 若快照里没带,保留事件里学到的值,避免抖动
|
|
232
289
|
"interaction": row.get("interaction", old.get("interaction")),
|
|
@@ -242,11 +299,34 @@ class EventHub:
|
|
|
242
299
|
"inbox": old.get("inbox") or [],
|
|
243
300
|
"updated_at": now,
|
|
244
301
|
}
|
|
245
|
-
|
|
302
|
+
# —— 快照可信判定(2026-10-08 批次 B)——
|
|
303
|
+
# 非空快照 = 权威整表;空快照只有插件声明完整(complete)才算权威空。
|
|
304
|
+
# 其余情况(热重载后驱动实例重建、不重枚举已有会话 ⇒ 空表)=**未知**:
|
|
305
|
+
# 旧表原样保留(宁可多等不可误放行),只把可信标志置 False——
|
|
306
|
+
# 调用方一律不据此推断忙/闲,也绝不据「缺席」判会话已结束。
|
|
307
|
+
trusted = bool(fresh) or complete
|
|
308
|
+
if trusted:
|
|
309
|
+
self._sessions = fresh
|
|
310
|
+
kept = len(self._sessions)
|
|
311
|
+
self._aligned = trusted
|
|
246
312
|
if archived is not None:
|
|
247
313
|
self._archived = archived
|
|
248
314
|
self._changed += 1
|
|
249
315
|
self._cond.notify_all()
|
|
316
|
+
if not trusted and kept:
|
|
317
|
+
# 留痕(插件形态落 <库目录>/plugin-backend.log):这是可诊断的降级态,
|
|
318
|
+
# 不是静默——旧表保留期间看板列不做忙/闲写入,直到下一次权威快照。
|
|
319
|
+
print(f"[dshevents] /live 空快照且插件未声明完整:保留 {kept} 条既有会话、"
|
|
320
|
+
f"标记未对齐(热重载后驱动实例重建的已知形态,见 "
|
|
321
|
+
f"bug_report/20261008_1935)", flush=True)
|
|
322
|
+
|
|
323
|
+
def _set_aligned(self, value):
|
|
324
|
+
"""置「快照可信」标志(变更才唤醒订阅者;调用方持锁外语义同 `_set_connected`)。"""
|
|
325
|
+
with self._cond:
|
|
326
|
+
if self._aligned != bool(value):
|
|
327
|
+
self._aligned = bool(value)
|
|
328
|
+
self._changed += 1
|
|
329
|
+
self._cond.notify_all()
|
|
250
330
|
|
|
251
331
|
def _set_connected(self, value):
|
|
252
332
|
with self._cond:
|
|
@@ -293,7 +373,8 @@ class EventHub:
|
|
|
293
373
|
item = self._sessions.get(sid)
|
|
294
374
|
if item is None:
|
|
295
375
|
item = {"session_id": sid, "status": "", "cwd": "", "task": "",
|
|
296
|
-
"owned": False, "
|
|
376
|
+
"owned": False, "origin": "",
|
|
377
|
+
"interaction": None,
|
|
297
378
|
"last_turn_reason": None, "last_seq": 0,
|
|
298
379
|
"usage": None, "permission": None, "inbox": [],
|
|
299
380
|
"updated_at": time.time()}
|
|
@@ -345,6 +426,9 @@ class EventHub:
|
|
|
345
426
|
"cache_read": int(data.get("cache_read") or 0)}
|
|
346
427
|
elif typ == "session/created":
|
|
347
428
|
item["cwd"] = data.get("cwd") or item["cwd"]
|
|
429
|
+
# 子代理会话标记(2026-10-07):插件在 `session/created` 里上报
|
|
430
|
+
# 会话头行的 origin。缺字段保持旧值/空串=未知,绝不推断。
|
|
431
|
+
item["origin"] = str(data.get("origin") or item.get("origin") or "")
|
|
348
432
|
elif typ == "session/disposed":
|
|
349
433
|
self._sessions.pop(sid, None)
|
|
350
434
|
self._changed += 1
|
|
@@ -374,6 +458,11 @@ def connected():
|
|
|
374
458
|
return HUB.connected()
|
|
375
459
|
|
|
376
460
|
|
|
461
|
+
def aligned():
|
|
462
|
+
"""注册表可信读口(在线 ∧ 曾用权威快照对齐);未对齐 ⇒ 调用方不写列、不推断。"""
|
|
463
|
+
return HUB.aligned()
|
|
464
|
+
|
|
465
|
+
|
|
377
466
|
def get(sid):
|
|
378
467
|
return HUB.get(sid)
|
|
379
468
|
|
|
@@ -430,6 +519,11 @@ def wait(timeout):
|
|
|
430
519
|
return HUB.wait(timeout)
|
|
431
520
|
|
|
432
521
|
|
|
522
|
+
def wait_connected(timeout):
|
|
523
|
+
"""等中枢(首)次连接就绪(单例薄壳):就绪 True / 超时 False。"""
|
|
524
|
+
return HUB.wait_connected(timeout)
|
|
525
|
+
|
|
526
|
+
|
|
433
527
|
def subscribe(callback):
|
|
434
528
|
return HUB.subscribe(callback)
|
|
435
529
|
|
package/export_cases.py
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|