Wave 8: tech-debt audits, core unit tests, CLI pipe input (W11.1-W11.7)
- W11.1 context_plugin audit (architect-huang): 3 findings on ABI exception safety, strdup null checks, dead g_max_tokens variable. Rating: B. - W11.2 config audit (engineer-chen): identified 74-line TOML parser duplication between config_plugin and config_store, dual-store data isolation, dangling c_str() risk. Rating: C. - W11.3 event_bus + service_registry unit tests (qa-liu): 12 cases total, ctest coverage 2 -> 4 targets, 100% pass. - W11.4 CLI stdin pipe mode (engineer-zhao): isatty detection, single-shot inference path with exit codes 0/1/2/3. - W11.6 scripts/refresh_status.py (engineer-li): 431-line generator that scans 16 profile.md + 5 group.md to regenerate STATUS.md. - W11.7 destructive testing (qa-xu): 10 input scenarios PASS, found bin copy mismatch (BUG-1) plus 3 minor UX bugs for follow-up. Verified: cmake build 0 error, ctest 4/4 pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -21,14 +21,16 @@ performance_log:
|
||||
event: "入职 dstalk 团队"
|
||||
rating: ongoing
|
||||
- date: 2026-05-27
|
||||
event: "W9.8 修复 plugin_loader initialize_all() 首插件失败即终止缺陷"
|
||||
event: "W11.1 审计 context_plugin.cpp (289行,零Wave覆盖)"
|
||||
detail: |
|
||||
将 initialize_all() 从 fail-fast 改为 fail-continue:
|
||||
- 单插件 init 失败不再返回 -1,而是 log error + 标记失败 + 继续初始化其他插件
|
||||
- 依赖了 failed 插件的插件自动跳过,log warning
|
||||
- 拓扑序不变(Kahn 算法未修改)
|
||||
- 返回值语义: 0=全部成功, >0=失败插件数, <0=严重错误(循环依赖/host_api null)
|
||||
- 编译 0 error, smoke test 100% pass
|
||||
context_plugin 首次审计, 聚焦跨 DLL 堆合规 / ABI 契约 / 内存泄漏 / 并发安全:
|
||||
- 堆纪律: 完全合规 (0 处裸 malloc/free/strdup/new/delete), 无需迁移。所有跨边界分配使用 host->alloc/strdup。
|
||||
- ABI: 基本合规, 但违反 §5.3 (trim_impl 内 std::vector/std::string 可抛异常穿越 C ABI 边界→std::terminate)
|
||||
- 内存: 正常路径干净; OOM 路径 g_host->strdup 返回值未检查 (L138-141/L219-222), 8 处调用无 null guard
|
||||
- 并发: g_host 在 on_shutdown 与 trim_impl 间无同步访问, 隐式时序依赖 (评级 C)
|
||||
- Top3: (1) C++异常穿越ABI边界[严重] (2) strdup返回值未检查+泄漏[高] (3) g_max_tokens设置但无读取点→set_max_tokens是死API[中]
|
||||
- 综合评级: B (堆纪律A, ABI B, 内存B, 并发C)
|
||||
审计报告写入 agents/audits/W11.1-context-audit.md
|
||||
rating: completed
|
||||
current_groups: []
|
||||
---
|
||||
|
||||
164
agents/audits/W11.1-context-audit.md
Normal file
164
agents/audits/W11.1-context-audit.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# W11.1 Context Plugin Audit
|
||||
|
||||
**Auditor**: 黄岭 (architect-huang)
|
||||
**Date**: 2026-05-27
|
||||
**File**: plugins/context/src/context_plugin.cpp (289 行)
|
||||
**Wave Coverage**: 零 (从未被 Wave 流程审计)
|
||||
|
||||
---
|
||||
|
||||
## 1. 跨 DLL 堆合规性
|
||||
|
||||
状态: **合规** ✅
|
||||
|
||||
逐行检查所有 malloc/free/strdup/new/delete 调用点:
|
||||
|
||||
| 调用类型 | 搜索结果 | 判定 |
|
||||
|----------|----------|------|
|
||||
| `malloc()` | 0 处 | -- |
|
||||
| `free()` | 0 处 | -- |
|
||||
| `strdup()` (裸) | 0 处 | -- |
|
||||
| `new` (显式) | 0 处 | -- |
|
||||
| `delete` (显式) | 0 处 | -- |
|
||||
| `g_host->alloc()` | L135, L215 | 正确: host 堆分配 |
|
||||
| `g_host->strdup()` | L138-141, L219-222 | 正确: host 堆分配 |
|
||||
| `std::string` / `std::vector` | L90-127 等多处 | 插件内部使用, 不跨边界 |
|
||||
|
||||
**结论**: 所有跨 DLL 边界的分配均通过 `g_host->alloc()` / `g_host->strdup()`, 符合 §3.2 硬性规则。内部 C++ 类型 (std::string/std::vector) 仅用于 TrimMessage 局部数据, 不出 DLL 边界。W2.1 堆纪律已隐式合规, 无需迁移。
|
||||
|
||||
**注意**: L159/L168 使用 `std::fprintf(stderr, ...)`, 非内存操作, 无堆问题。
|
||||
|
||||
---
|
||||
|
||||
## 2. ABI 契约符合度 (plugin-abi.md §1-§7)
|
||||
|
||||
### §1 DSTALK_API_VERSION
|
||||
- L280: `DSTALK_API_VERSION` ✅
|
||||
- 导出函数签名 `dstalk_plugin_info_t* dstalk_plugin_init(void)` 与 `dstalk_plugin_init_fn` typedef 匹配 ✅
|
||||
|
||||
### §2 内存所有权
|
||||
- `g_info.name/version/description` 为静态字符串字面量, 生命周期覆盖 init_fn 调用 ✅ (§2.2)
|
||||
- 输出数组通过 `g_host->alloc` + `g_host->strdup` 分配, 调用方负责 `g_host->free` ✅ (§2.3)
|
||||
|
||||
### §3 跨 DLL 堆
|
||||
- 已在上节验证 ✅
|
||||
|
||||
### §4 register_service
|
||||
- 仅 `on_init` 内调用 (L268) ✅ (§4.1)
|
||||
- 版本号 1 ✅
|
||||
|
||||
### §5 on_init/on_shutdown
|
||||
- 函数签名匹配: `int (*)(const dstalk_host_api_t*)` / `void (*)(void)` ✅
|
||||
- **违反 §5.3 (异常安全)**: `on_init` / `context_count_tokens` / `context_trim` / `context_set_max_tokens` 均定义为 C 函数指针, 但底层 C++ 实现 (`trim_impl` L114-226) 使用了 `std::vector` / `std::string`, 二者均可抛出 `std::bad_alloc`。无任何 `try/catch` 包裹。若 OOM, 异常穿越 C ABI 边界 → `std::terminate()`, 违反 §5.3 约束。
|
||||
|
||||
受影响函数: `context_trim` → `trim_impl` (L114), `on_init` → `g_host->log` (可变参数, 低风险)
|
||||
|
||||
### §6 线程安全
|
||||
- 见第 4 节
|
||||
|
||||
### §7 依赖声明
|
||||
- L281: `{"session", nullptr, ...}`, NULL 终止 ✅
|
||||
- `on_init` 内通过 `host->query_service("session", 1)` 验证依赖可用 (L261-265) ✅
|
||||
|
||||
### 评级: 基本合规, 但因 §5.3 异常安全缺失降为 **B**
|
||||
|
||||
---
|
||||
|
||||
## 3. 内存/资源泄漏分析
|
||||
|
||||
### 3.1 服务注册
|
||||
- `on_init` 注册 "context" 服务 (L268), 静态 vtable `g_context_service` 无动态分配。
|
||||
- `on_shutdown` (L271-274) 未调用 `unregister_service` — **但合规**: 按 §4.4, host 在插件卸载时自动调用 `ServiceRegistry::unregister_service`。无需手动清理。
|
||||
|
||||
### 3.2 事件订阅
|
||||
- 无事件订阅, 无泄漏风险 ✅
|
||||
|
||||
### 3.3 trim_impl 内存分配路径
|
||||
三条返回路径分析:
|
||||
|
||||
| 路径 | 位置 | 内存状态 |
|
||||
|------|------|----------|
|
||||
| 单条消息超限, 提前返回 | L172-174 | `*out=nullptr`, 无分配, 局部 vector RAII 清理 ✅ |
|
||||
| 完整副本路径 (current ≤ max) | L133-144 | `alloc` + `strdup` x N, 全部返回给调用方 ✅ |
|
||||
| 裁剪后路径 | L213-225 | `alloc` + `strdup` x N, 全部返回给调用方 ✅ |
|
||||
|
||||
**发现**: `g_host->strdup` (L138-141, L219-222) 返回值未被检查。若 strdup 在循环中途失败返回 nullptr:
|
||||
- 前置已成功的 strdup 分配被**泄漏** (无记录, 无法逐个释放)
|
||||
- nullptr 被静默存入 message field, 函数返回 0 (成功)
|
||||
- 调用方解引用 nullptr 的 role/content → 崩溃
|
||||
|
||||
**结论**: 正常路径无泄漏, 但 OOM 路径存在资源泄漏 + 空指针隐患。
|
||||
|
||||
### 3.4 g_session 死引用
|
||||
- L261-266: `query_service("session")` 获取并存入 `g_session`
|
||||
- 全文搜索: `g_session` **从未被读取** (仅在 L272 `on_shutdown` 中置 null)
|
||||
- 不造成泄漏 (无额外分配), 但 dead code 暗示设计与实现脱节
|
||||
|
||||
### 评级: **B** (正常路径干净, OOM 路径脆弱)
|
||||
|
||||
---
|
||||
|
||||
## 4. 并发安全分析
|
||||
|
||||
### 4.1 共享状态清单
|
||||
|
||||
| 变量 | 写入点 | 读取点 | 同步 |
|
||||
|------|--------|--------|------|
|
||||
| `g_host` | L258 (on_init), L273 (on_shutdown) | L135/215 (trim_impl → alloc), L138-141/219-222 (strdup) | **无** |
|
||||
| `g_session` | L266 (on_init), L272 (on_shutdown) | 无读取 | **无** |
|
||||
| `g_max_tokens` | L244 (set_max_tokens) | 无读取 | **无** |
|
||||
|
||||
### 4.2 潜在竞争
|
||||
|
||||
**Race A: on_shutdown vs. trim_impl 并发** (g_host)
|
||||
- 线程 T1: `context_trim()` → `trim_impl()` 读取 `g_host` (非 null)
|
||||
- 线程 T2: `on_shutdown()` 写入 `g_host = nullptr`
|
||||
- T1 随后调用 `g_host->alloc()` → **空指针解引用**
|
||||
- 缓解因素: host 应保证 shutdown 前无 in-flight 服务调用。但 ABI 未显式保证, 属未定义行为边界。
|
||||
|
||||
**Race B: set_max_tokens vs. 未来的 getter** (g_max_tokens)
|
||||
- 当前无读取点, 不构成实际竞争。但 `set_max_tokens` 作为公开 API, 未来添加 getter 时需 `std::atomic` 保护。当前为**潜在债务**。
|
||||
|
||||
### 评级: **C** (g_host 读写的无同步访问是数据竞争, 虽有隐式时序假设但不可依赖)
|
||||
|
||||
---
|
||||
|
||||
## 5. 最严重发现 (Top 3)
|
||||
|
||||
### 发现 1 — [严重] C++ 异常穿越 ABI 边界 (违反 §5.3)
|
||||
- **行号**: L114-226 (trim_impl), L257-268 (on_init), L232-240 (vtable 函数)
|
||||
- **问题**: `trim_impl` 使用 `std::vector`/`std::string` 可抛 `std::bad_alloc`, 但无 `try/catch`。异常通过 C 函数指针边界 → `std::terminate()` → 进程崩溃。违反 plugin-abi §5.3 硬性约束。
|
||||
- **修复方向**: 在 `trim_impl` 体和 `on_init` 体加 `try { ... } catch (...) { return -1; }`, 或预分配 + nothrow 版本。
|
||||
|
||||
### 发现 2 — [高] strdup 返回值未检查, OOM 时静默失败 + 泄漏
|
||||
- **行号**: L138-141 (完整副本路径), L219-222 (裁剪后路径)
|
||||
- **问题**: 循环内连续 4 次 `g_host->strdup` 调用均未检查返回值。若某次返回 nullptr: (a) 前置成功的 strdup 分配泄漏 (无释放手段); (b) nullptr 存入 message field, 函数返回 0 (成功); (c) 调用方解引用 null role/content → 段错误。
|
||||
- **修复方向**: 每个 strdup 后检查返回值, 失败时逆序释放已分配字段, 再 `g_host->free(*out)`, 然后返回 -1。
|
||||
|
||||
### 发现 3 — [中] context_set_max_tokens 是死 API (g_max_tokens 未被读取)
|
||||
- **行号**: L21 (声明), L243-244 (写入)
|
||||
- **问题**: `context_set_max_tokens()` 写入 `g_max_tokens`, 但全文无任何代码路径读取该变量。`trim_impl` 使用参数 `max_tokens` (调用方直接传入), 不依赖全局状态。因此 `set_max_tokens` 调用对行为零影响——调用方期待的限制永不生效。
|
||||
- **修复方向**: 要么在 `context_trim` 中读取 `g_max_tokens` 作为默认值 (当调用方传 0 时), 要么删除该 API / 改为返回当前值。
|
||||
|
||||
---
|
||||
|
||||
## 6. 整体评级
|
||||
|
||||
| 维度 | 评级 |
|
||||
|------|------|
|
||||
| 跨 DLL 堆合规 | A (完全合规) |
|
||||
| ABI 契约符合度 | B (核心合规, §5.3 异常安全缺失) |
|
||||
| 内存/资源泄漏 | B (正常路径干净, OOM 脆弱) |
|
||||
| 并发安全 | C (g_host 无同步访问, 隐式时序依赖) |
|
||||
| **综合** | **B** |
|
||||
|
||||
**总评**: context_plugin 在堆纪律上意外地干净 (没有需要迁移的裸 malloc/free/strdup/new/delete), 说明编码者即使未读 plugin-abi 也遵循了正确模式。主要风险集中在异常安全 (可导致进程崩溃) 和 OOM 鲁棒性。无安全漏洞、无明确内存泄漏、无崩溃性逻辑错误。
|
||||
|
||||
---
|
||||
|
||||
## 7. 补充发现 (优先级低)
|
||||
|
||||
- **UTF-8 解码无越界保护** (L42-64, L96-104): 多字节序列 (2/3/4 字节) 的 `i += N` 假设后续字节有效, 若输入截断则读越界。对 token 估算影响极小 (仅计数值偏差), 非功能性 bug。
|
||||
- **token 计数逻辑重复** (L34-68 vs L91-106): `count_tokens_one_message` 与 `count_tokens_trim` 有 ~90% 重复, 在 C 字符串和 std::string 上各实现一套。维护风险 (两端需同步修改), 但不影响正确性。
|
||||
- **0xC0/0xC1 过短编码未识别** (L52, L100): UTF-8 标准中 0xC0/0xC1 是无效起始字节 (过短编码), 但仍计入 `other_chars`。仅影响 token 估算计数, 不影响功能。
|
||||
- **on_shutdown 不释放 g_info.dependencies** (L281): dependencies 是静态字符串数组, 无需释放, 无问题。
|
||||
108
agents/audits/W11.2-config-audit.md
Normal file
108
agents/audits/W11.2-config-audit.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# W11.2 Config Plugin / ConfigStore 职责与跨 DLL 堆审计
|
||||
|
||||
**审计人**: 陈风 (engineer-chen)
|
||||
**日期**: 2026-05-27
|
||||
**审计范围**: `plugins/config/src/config_plugin.cpp` (146行) + `dstalk-core/src/config_store.cpp` (83行)
|
||||
|
||||
---
|
||||
|
||||
## 1. 职责划分
|
||||
|
||||
| 文件 | 职责 | 所在层 |
|
||||
|------|------|--------|
|
||||
| `config_store.cpp` (dstalk::ConfigStore) | Host 自身的键值配置存储。`dstalk_init` 加载初始配置,通过 `host->config_get/set` 供所有插件调用 | core |
|
||||
| `config_plugin.cpp` (匿名 ConfigStore) | 注册 "config" 服务 (vtable `dstalk_config_service_t`),提供 `load_file` 运行时调用接口,供其他插件通过 `query_service` 发现 | plugin |
|
||||
|
||||
**结论**: 职责有交集但不完全重复——plugin 提供了 core 未暴露的 `load_file` 运行时接口。然而,两个 ConfigStore 的 TOML 解析逻辑(74行)**逐字符完全相同**,且两个 config 存储在运行时相互独立(host 的 config 与 plugin 的 config 是不同的 map),存在以下问题:
|
||||
|
||||
- 同一 key 在两个 store 中可能具有不同值
|
||||
- `host->config_get("plugin_dir")` 读的是 core store,而 `query_service("config")->get("plugin_dir")` 读的是 plugin store
|
||||
- 用户不知道该用哪个接口
|
||||
|
||||
**建议边界**: core 的 ConfigStore 作为唯一真相源;config plugin 不再持有自己的 store,改为将 `load_file` / `get` / `set` 委托给 `g_host->config_get` / `g_host->config_set`。`load_file` 则需要 core 侧新增 `config_load_file` 到 host API 或 config plugin 自行实现(仅保留解析逻辑,写入 host store)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 跨 DLL 堆合规
|
||||
|
||||
| 检查项 | config_store.cpp | config_plugin.cpp |
|
||||
|--------|-----------------|-------------------|
|
||||
| 直接 `malloc`/`free` | 无 | 无 |
|
||||
| 直接 `strdup` | 无 | 无 |
|
||||
| `new`/`delete` 跨边界 | 无(仅内部 STL 容器) | 无 |
|
||||
| `get()` 返回 `const char*` 的所有权 | 指向内部 `std::string`,调用方不得释放 | 同左 |
|
||||
| `set()` 的值类型 | `const char*` 输入,拷贝到 `std::string` | 同左 |
|
||||
|
||||
**结论**: **无跨 DLL 堆违规**。两个文件均完全使用 STL 容器(`std::unordered_map<std::string, std::string>`)管理内存,所有分配/释放均在各自 DLL 的 CRT 堆内完成。返回的 `const char*` 指向内部 string buffer,调用方只读不释放,符合 ABI 契约 2.1。
|
||||
|
||||
但需注意:`config_plugin.cpp:77` 返回的 `c_str()` 指向 plugin DLL 内部的 `std::string`。调用方若持有该指针跨越 plugin unload,将导致 use-after-free。这在当前设计中是低风险(config plugin 通常不会被卸载),但建议在 ABI 文档中明确说明。
|
||||
|
||||
---
|
||||
|
||||
## 3. 线程安全
|
||||
|
||||
| 文件 | 锁机制 | 评估 |
|
||||
|------|--------|------|
|
||||
| config_store.cpp | `mutable std::mutex`,get/set 均持锁 | get/set 单独调用安全 |
|
||||
| config_plugin.cpp | 同上(匿名类内 `std::mutex`) | 同左 |
|
||||
|
||||
**已知问题**:
|
||||
|
||||
- **Dangling pointer (config_store.cpp:72, config_plugin.cpp:77)**: `get()` 在锁内获取 `it->second.c_str()`,锁释放后返回。并发 `set()` 同一 key 会触发 `std::string` 重分配,使外部持有的指针悬垂。ABI 文档 6.4 已记录此风险但代码未做防护(如返回 `std::string` 副本或用 `host->strdup` 分配)。
|
||||
|
||||
- **load_file 非原子 (config_store.cpp:57-60, config_plugin.cpp:63-66)**: `load_file` 逐行解析,每写入一个 key-value 就释放锁。并发 `get()` 可观察到新旧配置混合的状态。设计上这是合理的(避免持锁做磁盘 I/O),但调用方需知晓。
|
||||
|
||||
- **ServiceRegistry 与 ConfigStore 之间无锁协调**: plugin 注册 "config" 服务时(`config_plugin.cpp:126`),ServiceRegistry 的写锁和 ConfigStore 的 mutex 是独立的。这在当前无问题(注册只存 vtable 指针),但如果未来 config service 的注册/卸载与 store 生命周期联动,需注意锁序。
|
||||
|
||||
---
|
||||
|
||||
## 4. 服务暴露
|
||||
|
||||
`config_plugin.cpp:124-127`:
|
||||
```cpp
|
||||
static int on_init(const dstalk_host_api_t* host) {
|
||||
g_host = host;
|
||||
return host->register_service("config", 1, &g_service);
|
||||
}
|
||||
```
|
||||
|
||||
- **正确**: 在 `on_init` 期间注册,符合 ABI 契约 4.1(仅此时可注册)
|
||||
- **正确**: vtable 为 static 全局变量,生命周期覆盖插件整个加载期(ABI 4.4)
|
||||
- **正确**: service name "config" 与 `dstalk_services.h:64` 定义一致
|
||||
- **注意**: 未检查 `register_service` 返回值。如果另一个插件也注册 "config",返回 -2 会被静默忽略
|
||||
- **去重**: ServiceRegistry 已实现重复注册检测(`service_registry.cpp:12-14`),但插件自身不感知失败
|
||||
|
||||
---
|
||||
|
||||
## 5. 三个最严重发现
|
||||
|
||||
### 发现 1: 完整 TOML 解析器代码重复 (Critical)
|
||||
- **config_plugin.cpp:16-90** 与 **config_store.cpp:10-83** 的解析逻辑完全相同
|
||||
- **影响**: 任何 bug 修复需双份维护;已发现 load_file 不处理 section 名前后空格、不支持 inline table 等问题,需在两个文件中分别修复
|
||||
- **修复方向**: config plugin 消除自己的 ConfigStore,改为委托 host store(通过 `g_host->config_get/set`),仅保留 `load_file` 且将解析结果写入 host store
|
||||
|
||||
### 发现 2: 双配置存储导致数据孤岛 (High)
|
||||
- **位置**: `host.cpp:176` 创建 `dstalk::ConfigStore`(host store);`config_plugin.cpp:98` 创建匿名 `ConfigStore`(plugin store)
|
||||
- **影响**: `host->config_get("key")` 和 `query_service("config")->get("key")` 返回不同数据,用户困惑
|
||||
- **修复方向**: 合并为唯一 store(建议保留 host 侧 `dstalk::ConfigStore`,config plugin 只做服务注册包装)
|
||||
|
||||
### 发现 3: get() 返回悬垂指针 (High)
|
||||
- **config_store.cpp:72** / **config_plugin.cpp:77**: `return it->second.c_str()` 在锁释放后,并发 set 同一 key 触发 realloc
|
||||
- **影响**: 调用方持有的 `const char*` 可能指向已释放内存,导致 crash 或静默数据损坏
|
||||
- **修复方向**: 选项 A — 返回值拷贝(调用方用 `host->strdup` 在锁内复制);选项 B — 文档明确规定调用方必须立即复制(当前 ABI spec 6.4 方向);选项 C — 用 `std::string` 作为返回值类型(需改 vtable 签名,涉及 API 版本 bump)
|
||||
|
||||
---
|
||||
|
||||
## 6. 整体评级
|
||||
|
||||
**评级: C**
|
||||
|
||||
**理由**: 无跨 DLL 堆违规(本次审计核心关注点通过),线程安全基础正确但 get() 存在悬垂指针缺陷。最严重的问题是 74 行 TOML 解析器完全重复 + 双存储架构混乱,属于设计层面而非实现 bug,但维护代价和用户困惑程度显著。建议优先解决发现 1 和 2(合并 store),发现 3 在合并时一并修复。
|
||||
|
||||
---
|
||||
|
||||
## 7. 附加建议
|
||||
|
||||
1. **config plugin 的 `on_init` 检查返回值** (config_plugin.cpp:126): `register_service` 返回 -2 时应有日志警告
|
||||
2. **load_file 前清空已有数据**: 当前 `load_file` 不先 `data_.clear()`,新文件与旧数据混合。如果这是预期行为(merge 语义),应在注释中说明
|
||||
3. **config_store.hpp 缺少 `load_file` 文档**: 未说明返回值约定(0=成功, -1=文件不存在/解析失败)
|
||||
53
agents/audits/W11.7-destructive-test.md
Normal file
53
agents/audits/W11.7-destructive-test.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# W11.7 破坏性输入测试报告
|
||||
|
||||
**测试人**: 徐磊 (qa-xu)
|
||||
**日期**: 2026-05-27
|
||||
**被测二进制**: `build/dstalk-cli/dstalk-cli.exe` (MD5: 803ca2ea)
|
||||
**源 Commit**: `004a81d` (Wave 7: W10.1-W10.4)
|
||||
**测试环境**: Windows 11 Pro, 无 API key, 无 config.toml, 无 plugins/
|
||||
|
||||
## 测试结果汇总
|
||||
|
||||
| # | 场景 | 结果 | 退出码 | 备注 |
|
||||
|---|------|------|--------|------|
|
||||
| 1 | 启动+EOF (空pipe) | PASS | 0 | 直接进入 shutdown,干净退出 |
|
||||
| 2 | 空字符串回车 | PASS | 0 | fgets→len==0→continue 正确跳过 |
|
||||
| 3 | 10000 字符超长行 | PASS | 0 | 截断警告正常触发,剩余字符 drain 正确 |
|
||||
| 4 | 纯空格/纯Tab | PASS | 0 | 被当 AI prompt,AI 不可用报错不崩溃 |
|
||||
| 5 | 未知 /command | PASS | 0 | "未知命令" 提示正常 |
|
||||
| 6 | /status + /history 无session | PASS | 0 | /status 正常显示默认值;/history 报 "session service unavailable" |
|
||||
| 7 | 中文+emoji prompt | PASS | 0 | UTF-8 无问题,AI 不可用报错不崩溃 |
|
||||
| 8 | ASCII 控制字符 \x01-\x08 | PASS | 0 | 被当 AI prompt 处理,无崩溃 |
|
||||
| 9 | /quit 后重启 | PASS | 0,0 | 两次启动无残留状态 |
|
||||
| 10 | 空 stdin (printf '') | PASS | 0 | fgets 立即返回 NULL,干净退出 |
|
||||
|
||||
**结论: 10/10 全部 PASS,零崩溃,零 hang。**
|
||||
|
||||
## 发现的 Bug
|
||||
|
||||
### BUG-1 [CRITICAL] build/bin/dstalk-cli.exe 是损坏副本
|
||||
|
||||
- **MD5**: d8e8c92b (67,072 bytes) vs 正常 803ca2ea (66,048 bytes)
|
||||
- **症状**: 所有命令(含 /help、/quit)被当 AI prompt 处理;任何输入退出码固定为 3;错误信息是英文("AI or session service unavailable")而非正常的中文
|
||||
- **推测**: CMake install/post-build 步骤拷贝了不同版本源码编译的产物,类似 PM-001 stale obj 模式
|
||||
- **影响**: 任何人若按文档路径 `build/bin/dstalk.exe` 运行将得到完全不可用版本
|
||||
|
||||
### BUG-2 [MEDIUM] /clear 在 session 不可用时谎报成功
|
||||
|
||||
- `main.cpp:168-172`: `if (g_session) g_session->clear()` 后无条件 `printf("[OK] 会话已清空")`
|
||||
- g_session==null 时也打印成功,误导用户
|
||||
|
||||
### BUG-3 [LOW] /context 在 session 不可用时静默无输出
|
||||
|
||||
- `main.cpp:175-185`: 仅 `if (g_session) {...}` 无 else 分支
|
||||
- 用户输入 /context 后零反馈,不知所措
|
||||
|
||||
### BUG-4 [LOW] /file write 裸命令被当未知命令
|
||||
|
||||
- `strncmp(line, "/file write ", 12)` 要求尾部空格,`/file write` (无参) 匹配失败落入"未知命令"
|
||||
- 应给用法提示而非未知命令
|
||||
|
||||
## 变更历史
|
||||
| 日期 | 版本 | 变更 |
|
||||
|------|------|------|
|
||||
| 2026-05-27 | 1.0 | W11.7 破坏性测试,10 场景全 PASS,发现 4 个 bug (1 critical) |
|
||||
@@ -33,5 +33,17 @@ performance_log:
|
||||
- "编译: 0 error; 测试: smoke test passed"
|
||||
- "发现: initialize_all() 在首个插件失败时停止,使后续插件无法初始化 (预存 bug, 非本次引入)"
|
||||
- "发现: deepseek/session 插件 Boost JSON 链接错误 (预存问题, 与本次修复无关)"
|
||||
- date: 2026-05-27
|
||||
event: "W11.2 - 审计 config_plugin / ConfigStore 职责划分与跨 DLL 堆合规"
|
||||
rating: success
|
||||
details:
|
||||
- "跨 DLL 堆: 无违规。两个文件均完全使用 STL 容器,无 malloc/free/strdup 直接调用"
|
||||
- "代码重复: config_plugin.cpp:16-90 与 config_store.cpp:10-83 的 TOML 解析器完全相同 (74行)"
|
||||
- "架构问题: 双 ConfigStore 导致数据孤岛 -- host->config_get 与 query_service('config')->get 读不同 store"
|
||||
- "悬垂指针: get() 返回 std::string::c_str() 后释放锁,并发 set 同 key 触发 realloc 导致 dangling"
|
||||
- "线程安全: mutex 基础正确,但 load_file 逐行持锁导致并发 get 可见部分新/旧混合配置"
|
||||
- "服务注册: on_init 内注册正确,但未检查 register_service 返回值 (重复注册返回 -2 被忽略)"
|
||||
- "评级: C (无跨 DLL 堆违规但代码重复 + 双 store 架构 + dangling pointer 需修复)"
|
||||
- "输出: agents/audits/W11.2-config-audit.md"
|
||||
current_groups: []
|
||||
---
|
||||
|
||||
@@ -23,5 +23,8 @@ performance_log:
|
||||
- date: 2026-05-27
|
||||
event: "W10.2: 创建 agents/STATUS.md 实时编制状态文档(63行),含员工状态表(16人)+ 工作组状态表(5组)+ Wave 进度,一次性读完 16 份 profile + 5 份 group + git log 后整理输出"
|
||||
rating: completed
|
||||
- date: 2026-05-27
|
||||
event: "W11.6: 编写 scripts/refresh_status.py 自动扫描 agents/*/profile.md 重新生成 agents/STATUS.md,支持 --dry-run,Python 3.8+ 标准库零依赖"
|
||||
rating: completed
|
||||
current_groups: []
|
||||
---
|
||||
|
||||
@@ -37,5 +37,8 @@ performance_log:
|
||||
event: "W9.6: CLI新增/history[N]命令,含三种边界处理;/status增加history count;build 0 error 0 warning;已用batch模式验证空history和无效N场景"
|
||||
rating: A
|
||||
current_groups:
|
||||
- grp-ai-plugins (待命)
|
||||
- grp-cli-ux (active)
|
||||
---
|
||||
- date: 2026-05-27
|
||||
event: "W11.4: 实现管道输入支持(grp-cli-ux B3),pipe_mode检测_isatty→读取全部stdin→单次chat→退出;空输入返回1提示empty prompt;0 error 0 warning编译通过;4/4测试100% pass"
|
||||
rating: A
|
||||
|
||||
@@ -23,5 +23,8 @@ performance_log:
|
||||
- date: 2026-05-27
|
||||
event: "W9.10: host_api 单元测试 (8 cases, tests/host_api_test.cpp)"
|
||||
rating: completed
|
||||
- date: 2026-05-27
|
||||
event: "W11.3: event_bus 单元测试 (6 cases, tests/event_bus_test.cpp) + service_registry 补充测试 (6 cases, tests/service_registry_test.cpp) — 提升 core 覆盖率,补边界/生命周期 case"
|
||||
rating: completed
|
||||
current_groups: []
|
||||
---
|
||||
|
||||
@@ -27,5 +27,8 @@ performance_log:
|
||||
- date: 2026-05-27
|
||||
event: "W10.4 创建 agents/POSTMORTEM.md 项目级踩坑记录(172行),收录 PM-001~PM-005 共5条事故7条防御性规则,覆盖 stale obj / Boost.JSON 链接 / 跨DLL堆释放 / plugin_loader fail-fast / push --force 未告知"
|
||||
rating: completed
|
||||
- date: 2026-05-27
|
||||
event: "W11.7 破坏性输入测试:build/dstalk-cli/dstalk-cli.exe (commit 004a81d) 10 场景全 PASS 零崩溃。发现 BUG-1 [CRITICAL] build/bin/ 下存在损坏副本 (MD5 d8e8c92b vs 正常 803ca2ea,命令解析全失效);BUG-2 /clear 谎报成功;BUG-3 /context 静默无输出;BUG-4 /file write 裸命令匹配失败。报告写入 agents/audits/W11.7-destructive-test.md"
|
||||
rating: completed
|
||||
current_groups: []
|
||||
---
|
||||
|
||||
@@ -349,19 +349,22 @@ int main(int argc, char* argv[])
|
||||
SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
|
||||
#endif
|
||||
|
||||
// ---- C1: batch 模式检测 ----
|
||||
// ---- C1: batch/pipe 模式检测 ----
|
||||
#ifdef _WIN32
|
||||
bool pipe_mode = (_isatty(_fileno(stdin)) == 0);
|
||||
#else
|
||||
bool pipe_mode = (isatty(fileno(stdin)) == 0);
|
||||
#endif
|
||||
bool batch_mode = false;
|
||||
if (!pipe_mode) {
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--batch") == 0) {
|
||||
batch_mode = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (!batch_mode && _isatty(_fileno(stdin)) == 0) batch_mode = true;
|
||||
#else
|
||||
if (!batch_mode && isatty(fileno(stdin)) == 0) batch_mode = true;
|
||||
#endif
|
||||
}
|
||||
if (pipe_mode) batch_mode = true;
|
||||
|
||||
// ---- B1: 安装 Ctrl+C 处理 ----
|
||||
#ifdef _WIN32
|
||||
@@ -435,6 +438,40 @@ int main(int argc, char* argv[])
|
||||
std::printf("\n");
|
||||
}
|
||||
|
||||
// ---- B3: 管道输入模式 (非交互) ----
|
||||
if (pipe_mode) {
|
||||
std::string input;
|
||||
char buf[4096];
|
||||
while (std::fgets(buf, sizeof(buf), stdin)) {
|
||||
input += buf;
|
||||
}
|
||||
if (input.empty()) {
|
||||
std::fprintf(stderr, "empty prompt\n");
|
||||
dstalk_shutdown();
|
||||
return 1;
|
||||
}
|
||||
if (!g_ai || !g_session) {
|
||||
std::fprintf(stderr, CLR_RED "[ERROR] AI or session service unavailable\n" CLR_RESET);
|
||||
dstalk_shutdown();
|
||||
return EXIT_SVC_UNAVAIL;
|
||||
}
|
||||
int history_count = 0;
|
||||
const dstalk_message_t* history = g_session->history(&history_count);
|
||||
dstalk_chat_result_t result = g_ai->chat(history, history_count, input.c_str(), nullptr);
|
||||
if (result.ok) {
|
||||
std::printf("%s\n", result.content ? result.content : "");
|
||||
g_ai->free_result(&result);
|
||||
dstalk_shutdown();
|
||||
return EXIT_OK;
|
||||
} else {
|
||||
std::fprintf(stderr, CLR_RED "[ERROR] AI error: %s\n" CLR_RESET,
|
||||
result.error ? result.error : "unknown");
|
||||
g_ai->free_result(&result);
|
||||
dstalk_shutdown();
|
||||
return EXIT_AI_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
char buffer[8192];
|
||||
while (true) {
|
||||
// B1: 检查退出标志
|
||||
|
||||
431
scripts/refresh_status.py
Normal file
431
scripts/refresh_status.py
Normal file
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Refresh agents/STATUS.md by scanning all profile.md and group files.
|
||||
|
||||
Usage:
|
||||
python scripts/refresh_status.py # Write agents/STATUS.md
|
||||
python scripts/refresh_status.py --dry-run # Print to stdout only
|
||||
|
||||
Requirements: Python 3.8+, standard library only.
|
||||
Parses YAML front matter from:
|
||||
- agents/<id>/profile.md (agent_id, name, role, current_groups, performance_log)
|
||||
- agents/groups/grp-*.md (group_id, name, lead, members, mission, active_tasks, status)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
# Enforce UTF-8 I/O on Windows (stdout/stderr may default to cp936/gbk)
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding='utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Path resolution
|
||||
# =============================================================================
|
||||
|
||||
def _repo_root():
|
||||
"""Project root (parent of this script's directory)."""
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _agents_dir():
|
||||
return _repo_root() / 'agents'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# YAML front matter helpers
|
||||
# =============================================================================
|
||||
|
||||
def _read_fm(filepath):
|
||||
"""Return front matter text between first pair of '---' lines, or None."""
|
||||
try:
|
||||
text = filepath.read_text(encoding='utf-8')
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
print(f"ERROR: Cannot read {filepath}: {e}", file=sys.stderr)
|
||||
return None
|
||||
m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
|
||||
if not m:
|
||||
print(f"WARNING: No YAML front matter in {filepath}", file=sys.stderr)
|
||||
return None
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _fm_scalar(fm, key):
|
||||
"""Return value of a top-level 'key: value' line."""
|
||||
m = re.search(rf'^{key}:\s*(.+)$', fm, re.MULTILINE)
|
||||
return m.group(1).strip() if m else None
|
||||
|
||||
|
||||
def _fm_list(fm, key):
|
||||
"""Return items of a top-level YAML list (key:\\n - item1\\n - item2)."""
|
||||
section = re.search(rf'^{key}:\s*\n((?: - .+\n?)*)', fm, re.MULTILINE)
|
||||
if not section:
|
||||
return []
|
||||
items = []
|
||||
for line in section.group(1).split('\n'):
|
||||
m = re.match(r' - (.+)', line)
|
||||
if m:
|
||||
items.append(m.group(1).strip())
|
||||
return items
|
||||
|
||||
|
||||
def _fm_performance_log(fm):
|
||||
"""Parse the performance_log YAML list into [{date,event,rating}, ...]."""
|
||||
entries = []
|
||||
log_match = re.search(r'^performance_log:', fm, re.MULTILINE)
|
||||
if not log_match:
|
||||
return entries
|
||||
|
||||
log_section = fm[log_match.start():]
|
||||
# Each entry starts with " - date:" (indent 2, dash)
|
||||
blocks = re.split(r'\n - ', log_section)
|
||||
# blocks[0] = "performance_log:" header; blocks[1:] = "date:...", "event:...", ...
|
||||
|
||||
for block in blocks[1:]:
|
||||
date_m = re.search(r'^\s*date:\s*(.+)$', block, re.MULTILINE)
|
||||
event_m = re.search(r'^\s*event:\s*["\']?([^"\'\n]+)', block, re.MULTILINE)
|
||||
rating_m = re.search(r'^\s*rating:\s*(\S+)', block, re.MULTILINE)
|
||||
|
||||
if date_m and event_m and rating_m:
|
||||
entries.append({
|
||||
'date': date_m.group(1).strip(),
|
||||
'event': event_m.group(1).strip(),
|
||||
'rating': rating_m.group(1).strip(),
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# File parsers
|
||||
# =============================================================================
|
||||
|
||||
def parse_profile(filepath):
|
||||
"""Parse a single profile.md. Returns dict or None."""
|
||||
fm = _read_fm(filepath)
|
||||
if fm is None:
|
||||
return None
|
||||
|
||||
agent_id = _fm_scalar(fm, 'agent_id')
|
||||
name = _fm_scalar(fm, 'name')
|
||||
role = _fm_scalar(fm, 'role')
|
||||
if not all([agent_id, name, role]):
|
||||
print(f"WARNING: Missing agent_id/name/role in {filepath}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
groups = _fm_list(fm, 'current_groups')
|
||||
perf_log = _fm_performance_log(fm)
|
||||
|
||||
return {
|
||||
'agent_id': agent_id,
|
||||
'name': name,
|
||||
'role': role,
|
||||
'groups_raw': groups, # raw strings from profile
|
||||
'perf_log': perf_log,
|
||||
}
|
||||
|
||||
|
||||
def parse_group(filepath):
|
||||
"""Parse a single grp-*.md. Returns dict or None."""
|
||||
fm = _read_fm(filepath)
|
||||
if fm is None:
|
||||
return None
|
||||
|
||||
gid = _fm_scalar(fm, 'group_id')
|
||||
name = _fm_scalar(fm, 'name')
|
||||
lead = _fm_scalar(fm, 'lead')
|
||||
mission = _fm_scalar(fm, 'mission')
|
||||
members = _fm_list(fm, 'members')
|
||||
active_tasks = _fm_list(fm, 'active_tasks')
|
||||
explicit_status = _fm_scalar(fm, 'status')
|
||||
standby = _fm_scalar(fm, 'standby')
|
||||
|
||||
if not all([gid, name, lead, mission]):
|
||||
print(f"WARNING: Missing required group fields in {filepath}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Determine display status
|
||||
if explicit_status:
|
||||
display_status = explicit_status
|
||||
elif standby and standby.lower() == 'true':
|
||||
display_status = '待命'
|
||||
elif active_tasks:
|
||||
display_status = '执行中'
|
||||
else:
|
||||
display_status = '待命'
|
||||
|
||||
return {
|
||||
'group_id': gid,
|
||||
'name': name,
|
||||
'lead': lead,
|
||||
'members': members,
|
||||
'mission': mission,
|
||||
'active_tasks': active_tasks,
|
||||
'status': display_status,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent status classification
|
||||
# =============================================================================
|
||||
|
||||
def _classify(perf_log):
|
||||
"""
|
||||
Determine agent status and contribution from perf_log.
|
||||
Returns (status, contribution_text, w_number):
|
||||
status -- 'working' | 'idle'
|
||||
contribution -- shortened event description
|
||||
w_number -- extracted W number (e.g. 'W10.2') or ''
|
||||
"""
|
||||
if not perf_log:
|
||||
return 'idle', '', ''
|
||||
|
||||
last = perf_log[-1]
|
||||
status = 'working' if last['rating'].lower() == 'ongoing' else 'idle'
|
||||
|
||||
w_match = re.search(r'[Ww](\d+\.\d+|\d+)', last['event'])
|
||||
w_num = f'W{w_match.group(1)}' if w_match else ''
|
||||
|
||||
desc = _shorten_event(last['event'])
|
||||
return status, desc, w_num
|
||||
|
||||
|
||||
def _shorten_event(text, max_len=72):
|
||||
"""Compress an event string into a one-line description."""
|
||||
text = text.strip().strip('"').strip("'")
|
||||
|
||||
# Preserve W prefix
|
||||
w_prefix = ''
|
||||
w_match = re.match(r'([Ww]\d+\.?\d*)', text)
|
||||
if w_match:
|
||||
w_prefix = w_match.group(1)
|
||||
text = text[w_match.end():]
|
||||
text = re.sub(r'^[::\-–\s]+', '', text)
|
||||
|
||||
# Strip "完成:"
|
||||
text = re.sub(r'^完成[::]\s*', '', text)
|
||||
|
||||
# Truncate at sentence-ending period
|
||||
if '。' in text:
|
||||
text = text.split('。')[0]
|
||||
|
||||
# If too long, break at a natural separator
|
||||
if len(text) > max_len:
|
||||
for sep in [',', ',', ';', ';', '、']:
|
||||
idx = text[:max_len].rfind(sep)
|
||||
if idx > max_len // 2:
|
||||
text = text[:idx]
|
||||
break
|
||||
else:
|
||||
text = text[:max_len - 3] + '...'
|
||||
|
||||
text = text.strip()
|
||||
if w_prefix:
|
||||
return f'{w_prefix} {text}'
|
||||
return text
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Group membership supplement
|
||||
# =============================================================================
|
||||
|
||||
def _supplement_groups(profiles, groups):
|
||||
"""
|
||||
For each agent, compute the union of profile current_groups and group
|
||||
memberships (so the '当前小组' column is complete even when profiles
|
||||
haven't been synced).
|
||||
Returns a dict: agent_id -> comma-separated group_id string.
|
||||
"""
|
||||
# profile-level groups (strip annotations in parens)
|
||||
profile_groups = {}
|
||||
for p in profiles:
|
||||
cleaned = []
|
||||
for g in p['groups_raw']:
|
||||
gid = re.sub(r'\s*\(.*\)', '', g).strip()
|
||||
if gid:
|
||||
cleaned.append(gid)
|
||||
profile_groups[p['agent_id']] = set(cleaned)
|
||||
|
||||
# group-level reverse lookup
|
||||
group_membership = {p['agent_id']: set() for p in profiles}
|
||||
for g in groups:
|
||||
for m in g['members']:
|
||||
if m in group_membership:
|
||||
group_membership[m].add(g['group_id'])
|
||||
|
||||
# union
|
||||
result = {}
|
||||
for p in profiles:
|
||||
aid = p['agent_id']
|
||||
union = profile_groups.get(aid, set()) | group_membership.get(aid, set())
|
||||
result[aid] = ', '.join(sorted(union)) if union else '--'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Wave aggregation
|
||||
# =============================================================================
|
||||
|
||||
def _collect_waves(profiles):
|
||||
"""Collect unique W numbers from all profiles. Returns (sorted_list, max)."""
|
||||
seen = set()
|
||||
for p in profiles:
|
||||
for entry in p['perf_log']:
|
||||
for m in re.finditer(r'[Ww](\d+\.\d+|\d+)', entry['event']):
|
||||
seen.add(m.group(0))
|
||||
|
||||
def _key(w):
|
||||
parts = re.match(r'[Ww](\d+)\.?(\d*)', w)
|
||||
major = int(parts.group(1)) if parts else 0
|
||||
minor = int(parts.group(2)) if parts and parts.group(2) else 0
|
||||
return (major, minor)
|
||||
|
||||
ordered = sorted(seen, key=_key)
|
||||
return ordered, ordered[-1] if ordered else 'N/A'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STATUS.md generator
|
||||
# =============================================================================
|
||||
|
||||
def generate_status_md(profiles, groups):
|
||||
"""Build the complete STATUS.md content string."""
|
||||
today = date.today().isoformat()
|
||||
n_agents = len(profiles)
|
||||
n_groups = len(groups)
|
||||
|
||||
# Supplement group memberships
|
||||
group_col = _supplement_groups(profiles, groups)
|
||||
|
||||
# Name lookup
|
||||
name_map = {p['agent_id']: p['name'] for p in profiles}
|
||||
|
||||
lines = []
|
||||
lines.append('# dstalk 实时编制状态')
|
||||
lines.append('')
|
||||
lines.append(f'> **最后更新**: {today}')
|
||||
lines.append(f'> **数据来源**: 由 `scripts/refresh_status.py` 自动扫描全部 {n_agents} 个 `agents/*/profile.md` + {n_groups} 个 `agents/groups/*.md` 生成。')
|
||||
lines.append('')
|
||||
|
||||
# ---- Table 1 ----
|
||||
lines.append(f'## 表 1:员工状态({n_agents} 人)')
|
||||
lines.append('')
|
||||
lines.append('| Agent ID | 姓名 | 角色 | 最近一次贡献 | perf_log | 当前小组 | 状态 |')
|
||||
lines.append('|---|---|---|---|---|---|---|')
|
||||
|
||||
for p in profiles:
|
||||
status, desc, _w = _classify(p['perf_log'])
|
||||
contrib = desc if desc else '--'
|
||||
cnt = str(len(p['perf_log']))
|
||||
groups_str = group_col.get(p['agent_id'], '--')
|
||||
status_str = 'working' if status == 'working' else 'idle'
|
||||
|
||||
lines.append(
|
||||
f'| {p["agent_id"]} | {p["name"]} | {p["role"]} | '
|
||||
f'{contrib} | {cnt} | {groups_str} | {status_str} |'
|
||||
)
|
||||
|
||||
lines.append('')
|
||||
lines.append('> **状态判定规则**: 基于 `performance_log` 最后一条的 `rating`——`ongoing` 视为 `working`,其余 (`A/A+/B/completed/done/success/good`) 视为 `idle`。')
|
||||
lines.append('')
|
||||
|
||||
# ---- Table 2 ----
|
||||
lines.append(f'## 表 2:工作组状态({n_groups} 组)')
|
||||
lines.append('')
|
||||
lines.append('| group_id | 名称 | lead | members | mission | active_tasks | 状态 |')
|
||||
lines.append('|---|---|---|---|---|---|---|')
|
||||
|
||||
for g in groups:
|
||||
lead_name = name_map.get(g['lead'], g['lead'])
|
||||
member_names = ', '.join(name_map.get(m, m) for m in g['members'])
|
||||
tasks = ', '.join(g['active_tasks']) if g['active_tasks'] else '--'
|
||||
|
||||
lines.append(
|
||||
f'| {g["group_id"]} | {g["name"]} | {lead_name} | {member_names} | '
|
||||
f'{g["mission"]} | {tasks} | {g["status"]} |'
|
||||
)
|
||||
|
||||
lines.append('')
|
||||
lines.append('> **成员列来源**: 以 `agents/groups/*.md` 为准(部分成员 profile 未同步更新 `current_groups`)。')
|
||||
lines.append('')
|
||||
|
||||
# ---- Wave Progress ----
|
||||
lines.append('## Wave 进度')
|
||||
lines.append('')
|
||||
all_waves, max_w = _collect_waves(profiles)
|
||||
lines.append(f'**已完成高水位**: {max_w}(基于 {n_agents} 份 profile.md 的 performance_log 聚合)')
|
||||
lines.append('')
|
||||
if all_waves:
|
||||
lines.append(f'**已发现 Wave 编号**: {", ".join(all_waves)}')
|
||||
lines.append('')
|
||||
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Refresh agents/STATUS.md from profile.md and group files.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Print output to stdout without writing STATUS.md'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
agents_dir = _agents_dir()
|
||||
if not agents_dir.is_dir():
|
||||
print(f'ERROR: agents/ directory not found at {agents_dir}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# ---- Scan profiles ----
|
||||
profiles = []
|
||||
for child in sorted(agents_dir.iterdir()):
|
||||
if not child.is_dir() or child.name.startswith('.') or child.name == 'groups':
|
||||
continue
|
||||
pf = child / 'profile.md'
|
||||
if pf.is_file():
|
||||
parsed = parse_profile(pf)
|
||||
if parsed:
|
||||
profiles.append(parsed)
|
||||
|
||||
if not profiles:
|
||||
print('ERROR: No valid profile.md files found', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# ---- Scan groups ----
|
||||
groups = []
|
||||
groups_dir = agents_dir / 'groups'
|
||||
if groups_dir.is_dir():
|
||||
for gf in sorted(groups_dir.glob('grp-*.md')):
|
||||
parsed = parse_group(gf)
|
||||
if parsed:
|
||||
groups.append(parsed)
|
||||
|
||||
# ---- Generate ----
|
||||
output = generate_status_md(profiles, groups)
|
||||
|
||||
if args.dry_run:
|
||||
print(output)
|
||||
else:
|
||||
status_path = agents_dir / 'STATUS.md'
|
||||
status_path.write_text(output, encoding='utf-8')
|
||||
print(f'Written: {status_path} ({len(profiles)} agents, {len(groups)} groups)',
|
||||
file=sys.stderr)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -34,3 +34,41 @@ target_link_libraries(dstalk-host-api-test
|
||||
)
|
||||
|
||||
add_test(NAME dstalk-host-api-test COMMAND dstalk-host-api-test)
|
||||
|
||||
# ============================================================
|
||||
# dstalk-event-bus-test — EventBus 单元测试
|
||||
# ============================================================
|
||||
|
||||
add_executable(dstalk-event-bus-test
|
||||
event_bus_test.cpp
|
||||
${CMAKE_SOURCE_DIR}/dstalk-core/src/event_bus.cpp
|
||||
)
|
||||
|
||||
target_include_directories(dstalk-event-bus-test
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src
|
||||
)
|
||||
|
||||
target_compile_features(dstalk-event-bus-test
|
||||
PRIVATE cxx_std_17
|
||||
)
|
||||
|
||||
add_test(NAME dstalk-event-bus-test COMMAND dstalk-event-bus-test)
|
||||
|
||||
# ============================================================
|
||||
# dstalk-service-registry-test — ServiceRegistry 补充单元测试
|
||||
# ============================================================
|
||||
|
||||
add_executable(dstalk-service-registry-test
|
||||
service_registry_test.cpp
|
||||
${CMAKE_SOURCE_DIR}/dstalk-core/src/service_registry.cpp
|
||||
)
|
||||
|
||||
target_include_directories(dstalk-service-registry-test
|
||||
PRIVATE ${CMAKE_SOURCE_DIR}/dstalk-core/src
|
||||
)
|
||||
|
||||
target_compile_features(dstalk-service-registry-test
|
||||
PRIVATE cxx_std_17
|
||||
)
|
||||
|
||||
add_test(NAME dstalk-service-registry-test COMMAND dstalk-service-registry-test)
|
||||
|
||||
133
tests/event_bus_test.cpp
Normal file
133
tests/event_bus_test.cpp
Normal file
@@ -0,0 +1,133 @@
|
||||
// ============================================================================
|
||||
// event_bus_test.cpp — EventBus 单元测试
|
||||
// ============================================================================
|
||||
// 测试: subscribe / unsubscribe / emit / 多订阅者 / 空总线
|
||||
// ============================================================================
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "event_bus.hpp"
|
||||
|
||||
// ---- 轻量断言 ----
|
||||
static int g_failures = 0;
|
||||
#define TCHECK(cond, msg) do { \
|
||||
if (cond) { \
|
||||
std::cout << "[OK] " << (msg) << "\n"; \
|
||||
} else { \
|
||||
std::cerr << "[FAIL] " << (msg) << "\n"; \
|
||||
g_failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ============================================================
|
||||
int main()
|
||||
{
|
||||
std::cout << "=== dstalk event_bus unit tests ===\n\n";
|
||||
|
||||
// ====================================================================
|
||||
// Test 1: subscribe + emit — 基本发布订阅流程
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::EventBus bus;
|
||||
int call_count = 0;
|
||||
int received_type = 0;
|
||||
|
||||
int id = bus.subscribe(42, [&](int event_type, const void* data) {
|
||||
call_count++;
|
||||
received_type = event_type;
|
||||
});
|
||||
TCHECK(id >= 1, "subscribe returns valid subscription ID");
|
||||
|
||||
int emitted = bus.emit(42, nullptr);
|
||||
TCHECK(emitted == 1, "emit returns 1 handler called");
|
||||
TCHECK(call_count == 1, "handler was invoked exactly once");
|
||||
TCHECK(received_type == 42, "handler received correct event_type");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 2: unsubscribe — 取消订阅后 handler 不再被调用
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::EventBus bus;
|
||||
int call_count = 0;
|
||||
|
||||
int id = bus.subscribe(10, [&](int, const void*) { call_count++; });
|
||||
bus.unsubscribe(id);
|
||||
|
||||
int emitted = bus.emit(10, nullptr);
|
||||
TCHECK(emitted == 0, "emit after unsubscribe returns 0");
|
||||
TCHECK(call_count == 0, "unsubscribed handler was NOT called");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 3: 多订阅者 — 同一事件多个 handler 按订阅顺序全部调用
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::EventBus bus;
|
||||
std::vector<int> order;
|
||||
|
||||
bus.subscribe(1, [&](int, const void*) { order.push_back(1); });
|
||||
bus.subscribe(1, [&](int, const void*) { order.push_back(2); });
|
||||
bus.subscribe(1, [&](int, const void*) { order.push_back(3); });
|
||||
|
||||
int emitted = bus.emit(1, nullptr);
|
||||
TCHECK(emitted == 3, "emit returns 3 handlers called");
|
||||
TCHECK(order.size() == 3, "all 3 handlers invoked");
|
||||
|
||||
// 验证订阅顺序 (FIFO: 按 subscribe 顺序触发)
|
||||
bool ordered = (order[0] == 1 && order[1] == 2 && order[2] == 3);
|
||||
TCHECK(ordered, "handlers invoked in subscription order (1,2,3)");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 4: 空总线 emit 不崩溃,返回 0
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::EventBus bus;
|
||||
int emitted = bus.emit(99, nullptr);
|
||||
TCHECK(emitted == 0, "emit on empty bus returns 0 (no crash)");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 5: 不同 event_type 独立分发 — 只触发匹配的 handler
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::EventBus bus;
|
||||
int count_a = 0, count_b = 0;
|
||||
|
||||
bus.subscribe(100, [&](int, const void*) { count_a++; });
|
||||
bus.subscribe(200, [&](int, const void*) { count_b++; });
|
||||
|
||||
bus.emit(100, nullptr);
|
||||
TCHECK(count_a == 1 && count_b == 0,
|
||||
"emit type=100 only triggers type-100 handler");
|
||||
|
||||
bus.emit(200, nullptr);
|
||||
TCHECK(count_a == 1 && count_b == 1,
|
||||
"emit type=200 only triggers type-200 handler");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 6: 退订不存在的 ID 不崩溃
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::EventBus bus;
|
||||
bus.unsubscribe(99999); // 不存在的 ID
|
||||
std::cout << "[OK] unsubscribe non-existent ID (99999) did not crash\n";
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 结果
|
||||
// ====================================================================
|
||||
std::cout << "\n";
|
||||
if (g_failures == 0) {
|
||||
std::cout << "=== All event_bus tests passed ===\n";
|
||||
return 0;
|
||||
} else {
|
||||
std::cerr << "=== " << g_failures << " event_bus test(s) FAILED ===\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
114
tests/service_registry_test.cpp
Normal file
114
tests/service_registry_test.cpp
Normal file
@@ -0,0 +1,114 @@
|
||||
// ============================================================================
|
||||
// service_registry_test.cpp — ServiceRegistry 单元测试(补充覆盖,不与 host_api_test 重叠)
|
||||
// ============================================================================
|
||||
// host_api_test 已覆盖: 重复注册(同名同版/同名异版)、查询不存在服务、版本不满足、
|
||||
// shutdown 后查询。本测试补充边界与生命周期路径。
|
||||
// ============================================================================
|
||||
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
#include "service_registry.hpp"
|
||||
|
||||
// ---- 轻量断言 ----
|
||||
static int g_failures = 0;
|
||||
#define TCHECK(cond, msg) do { \
|
||||
if (cond) { \
|
||||
std::cout << "[OK] " << (msg) << "\n"; \
|
||||
} else { \
|
||||
std::cerr << "[FAIL] " << (msg) << "\n"; \
|
||||
g_failures++; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
// ============================================================
|
||||
int main()
|
||||
{
|
||||
std::cout << "=== dstalk service_registry unit tests (supplement) ===\n\n";
|
||||
|
||||
// ====================================================================
|
||||
// Test 1: register_service(nullptr name) → -1
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::ServiceRegistry reg;
|
||||
void* vt = reinterpret_cast<void*>(0x10);
|
||||
int r = reg.register_service(nullptr, 1, vt);
|
||||
TCHECK(r == -1, "register_service(nullptr name) returns -1");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 2: register_service(nullptr vtable) → -1
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::ServiceRegistry reg;
|
||||
int r = reg.register_service("valid_name", 1, nullptr);
|
||||
TCHECK(r == -1, "register_service(nullptr vtable) returns -1");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 3: 完整生命周期 — register → query → unregister → query(nullptr)
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::ServiceRegistry reg;
|
||||
void* vt = reinterpret_cast<void*>(0xDEAD);
|
||||
|
||||
int r = reg.register_service("life", 3, vt);
|
||||
TCHECK(r == 0, "register_service(\"life\",3) returns 0");
|
||||
|
||||
void* q1 = reg.query_service("life", 2);
|
||||
TCHECK(q1 == vt, "query_service(\"life\",2) returns vtable after register");
|
||||
|
||||
reg.unregister_service("life");
|
||||
|
||||
void* q2 = reg.query_service("life", 1);
|
||||
TCHECK(q2 == nullptr, "query_service(\"life\",1) returns nullptr after unregister");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 4: unregister_service(nullptr name) 不崩溃(安全空操作)
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::ServiceRegistry reg;
|
||||
reg.unregister_service(nullptr);
|
||||
std::cout << "[OK] unregister_service(nullptr) did not crash\n";
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 5: 注册后重新注册同名 → 先 unregister 再 register 成功
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::ServiceRegistry reg;
|
||||
void* vt1 = reinterpret_cast<void*>(0xA);
|
||||
void* vt2 = reinterpret_cast<void*>(0xB);
|
||||
|
||||
reg.register_service("reborn", 1, vt1);
|
||||
reg.unregister_service("reborn");
|
||||
|
||||
int r = reg.register_service("reborn", 2, vt2);
|
||||
TCHECK(r == 0, "register_service after unregister same name returns 0");
|
||||
|
||||
void* q = reg.query_service("reborn", 2);
|
||||
TCHECK(q == vt2, "query_service after re-register returns new vtable");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Test 6: query_service(nullptr name) → nullptr
|
||||
// ====================================================================
|
||||
{
|
||||
dstalk::ServiceRegistry reg;
|
||||
void* q = reg.query_service(nullptr, 1);
|
||||
TCHECK(q == nullptr, "query_service(nullptr name) returns nullptr");
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 结果
|
||||
// ====================================================================
|
||||
std::cout << "\n";
|
||||
if (g_failures == 0) {
|
||||
std::cout << "=== All service_registry tests passed ===\n";
|
||||
return 0;
|
||||
} else {
|
||||
std::cerr << "=== " << g_failures << " service_registry test(s) FAILED ===\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user