Compare commits

...

6 Commits

Author SHA1 Message Date
cfdaily 9ec601d747 [moz] feat: Runaway Guard per-task dispatch 上限
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 1s
§15 Runaway Guard — per-task dispatch_count 上限,防止无限循环 dispatch

问题:mail/toolchain task 走 handler auto-working(跳过 claim),不受
claim_timeout 3 次重试兜底保护。如果反复 spawn 但永远到不了 done/failed,
会无限循环消耗资源(实际案例:2026-06-15 mention 重复投递事件)。

设计:
- tasks 表新增 dispatch_count 字段
- 每次 ticker 成功 dispatch 时递增
- dispatch_count >= 10 时自动标 failed(reason=runaway_guard)
- 覆盖所有非终态(pending/working/claimed)
- 参考 Hermes v0.13 §3 Per-Task 重试上限

改动文件:
- src/blackboard/db.py: _safe_add_column dispatch_count
- src/blackboard/models.py: Task dataclass 加 dispatch_count
- src/daemon/ticker.py: dispatch 递增 + _check_timeouts runaway guard
- docs/design/15-runaway-guard.md: 设计文档
- tests/integration/test_ticker_integration.py: E13 测试 3 个

测试:456 passed, 3 skipped
2026-06-16 23:10:27 +00:00
pangtong-fujunshi cc5c7f5ad1 Merge PR #80
Deploy / ci (push) Failing after 8s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-16 14:49:17 +00:00
cfdaily d6cb854f68 fix: mention 重复投递 + mail 失败通知竞态保护 + §14 设计文档同步
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 31s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
Bug 1: spawn_full_agent use_main_session 返回 None 导致 mention 重复投递
- 根因: use_main_session=True 时 session_id=None, return None 被 ticker
  _process_posts 误判为 spawn 失败, 每次 tick 都重试
- 修复: 引入 effective_sid = session_id or 'main', 统一用于
  _register_session / _monitor_process / return value

Bug 2: _mark_task failed 时未检查已完成状态导致误发投递失败通知
- 根因: spawner 标 failed 和 handler 标 done 竞态条件下, 已完成的
  mail task 被误发投递失败通知
- 修复: notify_mail_failed 调用前加防御性检查, 若 task 已 done 则跳过

设计文档: §13 三个 handler sections 列表同步 DeliveryChecklistSection
  及 GiteaConventionSection / WikiGuideSection, 更新 section 复用分析表
  及文件结构 section 计数
2026-06-15 09:48:09 +08:00
pangtong-fujunshi 1f373d5cb5 Merge PR #79
Deploy / ci (push) Failing after 8s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-15 00:06:56 +00:00
cfdaily a8c9d25857 [moz] feat(prompt): L0~L2 prompt improvements
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 13s
CI / notify-on-failure (pull_request) Successful in 0s
- L0 wiki-rule: 扩充检索路径(practices/concepts/docs/design/)+ 检索方式(index→summary→grep→full)
- L1 SOUL.md: 同步测试 + PR 审查(代码改动检查设计文档+测试脚本,PR/CI/CD 三重把关)
- L1 AGENTS.md: 新增测试规范段(生产隔离/残留清理/测试开发分离)
- L2 prompt_composer: 新增 DeliveryChecklistSection(executor/mail/toolchain handler 注册)
- 456 passed, 0 failed
2026-06-15 08:04:42 +08:00
pangtong-fujunshi 660ac4b659 Merge PR #78: [moz] feat(frontend): 工具链面板加 from/to 显示 + 筛选 + 修复事件类型未知
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Successful in 12s
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 1s
2026-06-14 09:13:55 +00:00
11 changed files with 294 additions and 11 deletions
+36 -3
View File
@@ -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 段 3Skill 全文)
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 可以抽一个 BaseApiSectioncurl 模板 + success_status 参数),其余 section 各自实现。
@@ -667,9 +700,9 @@ src/daemon/
├── task_type_registry.py # §3 + §4Protocol + 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 改动
+61
View File
@@ -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 recovery3 次)+ api_retry3 次)余量 = ~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 作为兜底)
+1
View File
@@ -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 (
+2
View File
@@ -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
+2 -2
View File
@@ -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。
+24
View File
@@ -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
View File
@@ -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:
+2 -1
View File
@@ -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:
+51
View File
@@ -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:
+2 -1
View File
@@ -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"