Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ec601d747 | |||
| cc5c7f5ad1 | |||
| d6cb854f68 | |||
| 1f373d5cb5 | |||
| a8c9d25857 | |||
| 660ac4b659 |
@@ -585,6 +585,18 @@ class PromptComposer:
|
||||
| 50-59 | 硬约束 | 安全红线、禁止行为 |
|
||||
| 60-69 | 扩展段 | 保留给未来使用 |
|
||||
|
||||
## 共性 Section(三 handler 共享)
|
||||
|
||||
以下三个 Section 在 `prompt_composer.py` 中统一定义,被 Task/Mail/Toolchain 三个 handler 共同注入:
|
||||
|
||||
| Section | priority | 用途 |
|
||||
|---------|----------|------|
|
||||
| `GiteaConventionSection` | 55 | Gitea Issue/PR 标题规范、分支命名、提交格式 |
|
||||
| `DeliveryChecklistSection` | 55 | 交付前检查清单(产出格式、验证项、必读文档) |
|
||||
| `WikiGuideSection` | 60 | Wiki 知识库检索指引(检索路径、优先级、知识缺口记录) |
|
||||
|
||||
设计意图:将跨 handler 的共性约束从各 handler 的 ConstraintsSection 中抽离,避免重复维护。
|
||||
|
||||
---
|
||||
|
||||
# §13 三个 Handler 的 Section 注册
|
||||
@@ -601,6 +613,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
RoleSkillSection(priority=30), # BootstrapBuilder 段 3(Skill 全文)
|
||||
TaskApiSection(priority=40), # API 操作指令,success_status="review"
|
||||
TaskConstraintsSection(priority=50), # 硬约束
|
||||
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
|
||||
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
|
||||
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
|
||||
]
|
||||
```
|
||||
|
||||
@@ -611,6 +626,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
| RoleSkillSection | BootstrapBuilder 段 3 | 个性:只有 task 读 Skill 全文 |
|
||||
| TaskApiSection | spawner `_build_api_section` | **共性基础 + 个性参数**(success_status) |
|
||||
| TaskConstraintsSection | BootstrapBuilder 段 4 | 个性:每种 task 约束不同 |
|
||||
| GiteaConventionSection | prompt_composer.py | **共性**:Gitea Issue/PR 规范 |
|
||||
| WikiGuideSection | prompt_composer.py | **共性**:Wiki 检索指引 |
|
||||
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
|
||||
|
||||
## MailHandler sections
|
||||
|
||||
@@ -620,6 +638,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
MailContextSection(priority=10), # from/to/title/text,区分 inform/request
|
||||
MailApiSection(priority=40), # API 操作指令,success_status="done"
|
||||
MailConstraintsSection(priority=50), # 硬约束(禁止状态转换命令等)
|
||||
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
|
||||
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
|
||||
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
|
||||
]
|
||||
```
|
||||
|
||||
@@ -628,6 +649,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
| MailContextSection | MAIL_INFORM_TEMPLATE / MAIL_REQUEST_TEMPLATE | 个性:邮件格式 |
|
||||
| MailApiSection | spawner `_build_api_section` 变体 | **共性基础 + 个性参数**(success_status="done",含 Mail API 指令) |
|
||||
| MailConstraintsSection | 模板中的 ⚠️ 约束 | 个性 |
|
||||
| GiteaConventionSection | prompt_composer.py | **共性**:Gitea Issue/PR 规范 |
|
||||
| WikiGuideSection | prompt_composer.py | **共性**:Wiki 检索指引 |
|
||||
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
|
||||
|
||||
## ToolchainHandler sections
|
||||
|
||||
@@ -637,6 +661,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
ToolchainContextSection(priority=10), # 事件类型 + 事件详情
|
||||
ToolchainApiSection(priority=40), # API 操作指令,success_status="done"
|
||||
ToolchainConstraintsSection(priority=50), # 硬约束
|
||||
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
|
||||
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
|
||||
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
|
||||
]
|
||||
```
|
||||
|
||||
@@ -645,6 +672,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
| ToolchainContextSection | toolchain_templates.py + md 文件 | 个性:事件格式 |
|
||||
| ToolchainApiSection | spawner `_build_api_section` 变体 | **共性基础 + 个性参数** |
|
||||
| ToolchainConstraintsSection | 新增 | 个性 |
|
||||
| GiteaConventionSection | prompt_composer.py | **共性**:Gitea Issue/PR 规范 |
|
||||
| WikiGuideSection | prompt_composer.py | **共性**:Wiki 检索指引 |
|
||||
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
|
||||
|
||||
## Section 复用分析
|
||||
|
||||
@@ -655,6 +685,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
| *ConstraintsSection | ✅ | ✅ | ✅ | ❌ 约束内容不同,各自实现 |
|
||||
| PriorOutputsSection | ✅ | ❌ | ❌ | 仅 task |
|
||||
| RoleSkillSection | ✅ | ❌ | ❌ | 仅 task |
|
||||
| GiteaConventionSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
|
||||
| WikiGuideSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
|
||||
| DeliveryChecklistSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
|
||||
|
||||
**结论**:ApiSection 可以抽一个 BaseApiSection(curl 模板 + success_status 参数),其余 section 各自实现。
|
||||
|
||||
@@ -667,9 +700,9 @@ src/daemon/
|
||||
├── task_type_registry.py # §3 + §4:Protocol + Registry
|
||||
├── prompt_composer.py # §12 PromptSection + PromptContext + PromptComposer
|
||||
├── base_task_handler.py # §16 BaseTaskHandler 基类
|
||||
├── task_handler.py # §13 TaskHandler(继承 BaseTaskHandler)+ 5 sections
|
||||
├── mail_handler.py # §13 MailHandler(继承 BaseTaskHandler)+ 3 sections
|
||||
├── toolchain_handler.py # §13 ToolchainHandler(继承 BaseTaskHandler)+ 3 sections
|
||||
├── task_handler.py # §13 TaskHandler(继承 BaseTaskHandler)+ 8 sections
|
||||
├── mail_handler.py # §13 MailHandler(继承 BaseTaskHandler)+ 6 sections
|
||||
├── toolchain_handler.py # §13 ToolchainHandler(继承 BaseTaskHandler)+ 6 sections
|
||||
├── dispatcher.py # §6 改动
|
||||
├── spawner.py # §6 改动
|
||||
├── ticker.py # §6 改动
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# §15 Runaway Guard — Per-Task Dispatch 上限
|
||||
|
||||
> 设计文档 v1.0 | 2026-06-16
|
||||
|
||||
## 问题
|
||||
|
||||
mail/toolchain task 走 handler auto-working(跳过 claim 阶段),不受 claim_timeout 的 3 次重试兜底保护。如果一个 auto-working task 反复 spawn 但永远到不了 done/failed,会无限循环消耗资源。
|
||||
|
||||
### 实际案例
|
||||
|
||||
2026-06-15 mention 重复投递事件:`spawn_full_agent` 在 `use_main_session=True` 时返回 `None`,ticker `_process_mentions` 误判为失败,每次 tick(30s)都重试。同一 mention 投递了 4 次,直到 retry_count 达到 mention_queue 的 5 次上限才停止。
|
||||
|
||||
直接根因已由 PR #80 修复,但如果类似 bug 再次出现,当前没有任何机制阻止 task 层面的无限循环。
|
||||
|
||||
## 设计
|
||||
|
||||
### 机制
|
||||
|
||||
tasks 表新增 `dispatch_count` 字段,每次 ticker 成功 dispatch 一个 task 时递增。当 `dispatch_count >= 10`(全局默认)时,自动标 failed。
|
||||
|
||||
### 默认值选择
|
||||
|
||||
全局默认 10 次。参考 Hermes v0.13 Best Practices §3 "Per-Task 重试上限":
|
||||
|
||||
- 简单任务重试 1 次
|
||||
- 复杂任务重试 3 次
|
||||
- crash recovery(3 次)+ api_retry(3 次)余量 = ~10 次
|
||||
|
||||
### 适用范围
|
||||
|
||||
所有 task 类型(task/mail/toolchain),所有非终态(pending/working/claimed)。
|
||||
|
||||
### 检查时机
|
||||
|
||||
在 `_check_timeouts` 方法开头,先于现有的 claimed/working 超时检查执行。
|
||||
|
||||
### 与现有机制的关系
|
||||
|
||||
| 机制 | 覆盖场景 | 触发动作 |
|
||||
|------|---------|---------|
|
||||
| claim_timeout retry_count >= 3 | 广播任务无人认领 | 升级庞统 |
|
||||
| crash_limit 3/30min | working 状态 crash | 标 failed |
|
||||
| api_retry_count | API 连续失败 | 标 failed |
|
||||
| 续杯 max_retries 3 | 续杯耗尽 | 标 failed |
|
||||
| working timeout | working 超时 | 标 failed 或 done |
|
||||
| **runaway_guard 10 次** | **任何状态的无限循环** | **标 failed** |
|
||||
|
||||
runaway_guard 是最后一道防线,覆盖所有其他机制遗漏的循环场景。
|
||||
|
||||
## 改动文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/blackboard/db.py` | `_safe_add_column(conn, "tasks", "dispatch_count", "INTEGER DEFAULT 0")` |
|
||||
| `src/blackboard/models.py` | Task dataclass 加 `dispatch_count: int = 0` |
|
||||
| `src/daemon/ticker.py` | `_dispatch_pending` / `_dispatch_reviews` 递增 dispatch_count;`_check_timeouts` 加 runaway guard 检查 |
|
||||
|
||||
## 参考
|
||||
|
||||
- Hermes v0.13 Kanban Best Practices §3 "Per-Task 重试上限"
|
||||
- 实际案例:2026-06-15 mention 重复投递事件(PR #80 修复了直接根因,runaway guard 作为兜底)
|
||||
@@ -117,6 +117,7 @@ def _migrate_v28(conn: sqlite3.Connection) -> None:
|
||||
|
||||
_safe_add_column(conn, "tasks", "round_count", "INTEGER DEFAULT 0")
|
||||
_safe_add_column(conn, "tasks", "resumed_from", "TEXT")
|
||||
_safe_add_column(conn, "tasks", "dispatch_count", "INTEGER DEFAULT 0")
|
||||
|
||||
# 3. checkpoints 表(M3)
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
|
||||
@@ -41,6 +41,8 @@ class Task:
|
||||
resumed_from: Optional[str] = None # 暂停前状态,恢复时回到原状态
|
||||
# v2.9 四相循环
|
||||
round_count: int = 0 # 庞统 review 轮次计数
|
||||
# §15 Runaway Guard
|
||||
dispatch_count: int = 0 # 被 ticker dispatch 的总次数
|
||||
# v2.8 归档
|
||||
archived: bool = False
|
||||
archived_at: Optional[str] = None
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.handler.mail")
|
||||
@@ -36,7 +36,7 @@ class MailHandler(BaseTaskHandler):
|
||||
return composer.compose(context)
|
||||
|
||||
def get_sections(self) -> list:
|
||||
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection()]
|
||||
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection(), DeliveryChecklistSection()]
|
||||
|
||||
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
|
||||
"""Mail 完成验证:区分 inform/request。
|
||||
|
||||
@@ -174,3 +174,27 @@ class WikiGuideSection:
|
||||
|
||||
def should_include(self, context: "PromptContext") -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeliveryChecklistSection — 交付检查清单
|
||||
# ---------------------------------------------------------------------------
|
||||
class DeliveryChecklistSection:
|
||||
"""交付检查清单 — 提醒 Agent 完成前同步关联成果物。"""
|
||||
|
||||
name: str = "delivery_checklist"
|
||||
priority: int = 55 # CONSTRAINTS(50) 和 EXTENSION(60) 之间
|
||||
|
||||
CHECKLIST_TEXT = (
|
||||
"## 交付检查\n"
|
||||
"完成代码改动前确认:\n"
|
||||
"- 改了实现 → docs/design/ 对应设计文档是否需要更新\n"
|
||||
"- 改了实现 → tests/ 是否有对应测试脚本需要更新\n"
|
||||
"- 所有成果物变更通过 PR 流程:PR review 把关设计合理性,CI 把关代码质量,CD 把关部署正确性\n"
|
||||
)
|
||||
|
||||
def render(self, context: "PromptContext") -> str:
|
||||
return self.CHECKLIST_TEXT
|
||||
|
||||
def should_include(self, context: "PromptContext") -> bool:
|
||||
return True
|
||||
|
||||
+22
-4
@@ -625,19 +625,24 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._register_session(session_id, agent_id, task_id, proc.pid,
|
||||
# use_main_session=True 时 session_id 为 None,但 _register_session 和
|
||||
# _monitor_process 需要一个非 None 的 key;同时 ticker 等调用方用
|
||||
# `result is not None` 判断 spawn 是否成功,返回 None 会被误判为失败。
|
||||
# 统一用 "main" 作为占位标识。
|
||||
effective_sid = session_id or "main"
|
||||
self._register_session(effective_sid, agent_id, task_id, proc.pid,
|
||||
broadcast_task_ids=broadcast_task_ids)
|
||||
logger.info("Spawned agent %s (session=%s, pid=%d)",
|
||||
agent_id, session_id, proc.pid)
|
||||
agent_id, effective_sid, proc.pid)
|
||||
|
||||
# Schedule monitor(传 wrapped_on_complete)
|
||||
asyncio.create_task(
|
||||
self._monitor_process(session_id, proc, agent_id, task_id,
|
||||
self._monitor_process(effective_sid, proc, agent_id, task_id,
|
||||
on_complete=_wrapped_on_complete,
|
||||
db_path=task_db_path or self.db_path)
|
||||
)
|
||||
|
||||
return session_id
|
||||
return effective_sid
|
||||
|
||||
except Exception as e:
|
||||
# spawn 失败也要 release counter
|
||||
@@ -1949,6 +1954,19 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
|
||||
try:
|
||||
from src.daemon.mail_notify import _is_mail_project, notify_mail_failed
|
||||
if _is_mail_project(db_path):
|
||||
# 防御性检查:如果 task 已经 done,不触发失败通知(竞态保护)
|
||||
# 场景:spawner 标 failed 和 handler 标 done 同时发生
|
||||
try:
|
||||
conn2 = get_connection(db_path)
|
||||
current_status = conn2.execute(
|
||||
"SELECT status FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
conn2.close()
|
||||
if current_status and current_status["status"] == "done":
|
||||
logger.info("Task %s already done, skipping mail failure notification", task_id)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Mail 失败:通知发件人,不 @pangtong
|
||||
notify_mail_failed(db_path, task_id, reason, detail)
|
||||
else:
|
||||
|
||||
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.handler")
|
||||
@@ -315,6 +315,7 @@ class TaskHandler(BaseTaskHandler):
|
||||
TaskConstraintsSection(),
|
||||
GiteaConventionSection(),
|
||||
WikiGuideSection(),
|
||||
DeliveryChecklistSection(),
|
||||
]
|
||||
|
||||
def build_prompt(self, context: PromptContext) -> str:
|
||||
|
||||
@@ -1084,6 +1084,19 @@ Parent Task ID: {parent_task.id}
|
||||
broadcast_ids = await self._broadcast_claim(broadcast_tasks, db_path, project_id)
|
||||
dispatched.extend(broadcast_ids)
|
||||
|
||||
# §15 Runaway Guard: 统一递增 dispatch_count
|
||||
if dispatched:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
for tid in dispatched:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = COALESCE(dispatch_count, 0) + 1 WHERE id=?",
|
||||
(tid,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return dispatched
|
||||
|
||||
async def _broadcast_claim(self, tasks: list, db_path: Path,
|
||||
@@ -1376,6 +1389,19 @@ Parent Task ID: {parent_task.id}
|
||||
except Exception:
|
||||
logger.exception("Review dispatch failed for %s", task.id)
|
||||
|
||||
# §15 Runaway Guard: 统一递增 dispatch_count (review)
|
||||
if dispatched:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
for tid in dispatched:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = COALESCE(dispatch_count, 0) + 1 WHERE id=?",
|
||||
(tid,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return dispatched
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1388,6 +1414,31 @@ Parent Task ID: {parent_task.id}
|
||||
reclaimed: List[str] = []
|
||||
now = datetime.utcnow() # UTC,与 SQLite datetime('now') 一致
|
||||
|
||||
# §15 Runaway Guard: per-task dispatch_count 上限检查
|
||||
# 覆盖所有状态,防止无限循环 dispatch
|
||||
MAX_DISPATCH_COUNT = 10
|
||||
for status_to_check in ("pending", "working", "claimed"):
|
||||
tasks_to_check = queries.tasks_by_status(status_to_check)
|
||||
for task in tasks_to_check:
|
||||
dispatch_count = getattr(task, 'dispatch_count', 0) or 0
|
||||
if dispatch_count >= MAX_DISPATCH_COUNT:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
ok = self._transition_status(
|
||||
conn, task.id, "failed",
|
||||
agent="daemon",
|
||||
detail={"reason": "runaway_guard",
|
||||
"dispatch_count": dispatch_count,
|
||||
"message": f"dispatch {dispatch_count} 次仍未完成,自动标 failed"},
|
||||
)
|
||||
if ok:
|
||||
reclaimed.append(task.id)
|
||||
logger.error(
|
||||
"Task %s: runaway guard triggered (dispatch_count=%d, status=%s), marking failed",
|
||||
task.id, dispatch_count, status_to_check)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# claimed 超时 → 重置为 pending(如果 retry_count >= 3 则升级庞统)
|
||||
claimed = queries.tasks_by_status("claimed")
|
||||
for task in claimed:
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
|
||||
from src.daemon.toolchain_templates import render_template, _TEMPLATE_MAP
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
@@ -252,6 +252,7 @@ class ToolchainHandler(BaseTaskHandler):
|
||||
ToolchainConstraintsSection(),
|
||||
GiteaConventionSection(),
|
||||
WikiGuideSection(),
|
||||
DeliveryChecklistSection(),
|
||||
]
|
||||
|
||||
def build_prompt(self, context: PromptContext) -> str:
|
||||
|
||||
@@ -543,3 +543,94 @@ class TestCheckTimeoutsUnified:
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-review-dead" not in reclaimed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E13: §15 Runaway Guard — per-task dispatch_count 上限
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunawayGuard:
|
||||
"""E13: dispatch_count >= 10 → 自动标 failed(覆盖所有非终态)"""
|
||||
|
||||
@pytest.fixture
|
||||
def guard_project(self, tmp_path):
|
||||
"""创建项目 + 任务"""
|
||||
data_root = tmp_path / "projects"
|
||||
registry = ProjectRegistry(data_root)
|
||||
registry.create_project("guard-proj", "Guard Test", agents=["agent-a"])
|
||||
db_path = data_root / "guard-proj" / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
return registry, db_path, bb
|
||||
|
||||
def test_runaway_guard_triggers_working(self, guard_project):
|
||||
"""E13.1: working 状态 dispatch_count >= 10 → 标 failed"""
|
||||
registry, db_path, bb = guard_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-runaway", title="Runaway Task", status="working",
|
||||
assigned_by="daemon", current_agent="agent-a",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = 10 WHERE id = ?", ("t-runaway",))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-runaway" in reclaimed
|
||||
task = Queries(db_path).task_by_id("t-runaway")
|
||||
assert task.status == "failed"
|
||||
|
||||
def test_runaway_guard_triggers_pending(self, guard_project):
|
||||
"""E13.2: pending 状态 dispatch_count >= 10 → 标 failed"""
|
||||
registry, db_path, bb = guard_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-pending-runaway", title="Pending Runaway", status="pending",
|
||||
assigned_by="daemon",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = 10 WHERE id = ?",
|
||||
("t-pending-runaway",))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-pending-runaway" in reclaimed
|
||||
task = Queries(db_path).task_by_id("t-pending-runaway")
|
||||
assert task.status == "failed"
|
||||
|
||||
def test_runaway_guard_not_triggered(self, guard_project):
|
||||
"""E13.3: dispatch_count < 10 → 正常流程不受影响"""
|
||||
registry, db_path, bb = guard_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-normal", title="Normal Task", status="working",
|
||||
assigned_by="daemon", current_agent="agent-a",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = 5 WHERE id = ?", ("t-normal",))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-normal" not in reclaimed
|
||||
task = Queries(db_path).task_by_id("t-normal")
|
||||
assert task.status == "working"
|
||||
|
||||
Reference in New Issue
Block a user