Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,12 @@ def build_devflow_runs(project_root: Path, cost_by_slug: dict[str, float] | None
"exec": info.get("executor") or "-", "status": status,
"retry_count": retry_count, "duration": duration,
})
# 只有所有阶段都已完成或跳过时才算“已完成”;停在两个阶段之间(等待审批、
# 下一阶段尚未派发)的运行仍是进行中,不能因为没有 in_progress 就显示完成。
if overall == "completed" and any(
stage["status"] not in {"completed", "skipped"} for stage in stages_out
):
overall = "running"
# 按真实起止时间跨度算总时长(而不是逐阶段 duration 相加)——阶段之间可能
# 有重叠(比如 TASK-05 的 started_at 早于 SOLO 的 completed_at),相加会
# 把重叠部分重复计入,虚高于源数据本身反映的运行时长。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ def infer_identity_from_messages(messages: list[dict[str, Any]]) -> tuple[Option
next_target = _extract_next_target(payload)

if from_role == "main" and mailbox_role and mailbox_role != "main":
if not msg.get("is_shutdown"):
# A standby assignment only creates the member; it is not active yet.
if not msg.get("is_shutdown") and not msg.get("is_standby"):
current = mailbox_role
dispatched = mailbox_role
evidence = f"dispatch:{mailbox_name}"
Expand Down Expand Up @@ -521,6 +522,7 @@ def _normalize_message(item: Any, mailbox_name: str, mailbox_role: str | None, i
"ts": ts,
"index": index,
"is_shutdown": _is_shutdown_message(payload, summary, text),
"is_standby": _is_standby_message(payload, text),
}


Expand Down Expand Up @@ -561,6 +563,14 @@ def _is_shutdown_message(payload: dict[str, Any] | None, summary: str, text: Any
return "shutdown" in summary_low or "shutdown request" in text_low


def _is_standby_message(payload: dict[str, Any] | None, text: Any) -> bool:
"""识别“先创建成员、待命等待唤醒”的消息;这类消息不代表成员已开始工作。"""
if isinstance(payload, dict) and (payload.get("event") or payload.get("dispatch_prompt")):
return False
text_low = text.lower() if isinstance(text, str) else ""
return "待命" in text_low or "standby" in text_low or "stand by" in text_low


def _parse_timestamp(value: Any) -> float:
"""把消息时间字段解析成 Unix 时间戳。"""
if not isinstance(value, str) or not value.strip():
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/agent-observability/scripts/core/cls_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ def load_config(cwd: str | None = None) -> CLSConfig:

def mirror_record(record: dict[str, Any], *, cwd: str | None = None) -> bool:
config = load_config(cwd)
if not (config.endpoint or config.topic_id):
# CLS mirroring was never configured: stay silent instead of writing a
# debug line into the skill directory for every local event.
return False
if not config.ready:
_debug_write(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,36 @@ def test_solo_run_includes_task05_and_marks_phase0_completed(self):
self.assertNotIn("TASK-01", stage_by_key) # SOLO 路径不该混进 medium/large 的阶段


class BuildDevflowRunsOverallStatusTests(unittest.TestCase):
"""E2E 发现:刚开始或停在两个阶段之间的运行(没有 in_progress、也没有 failed)
曾被显示为“已完成”。"""

def _overall(self, stages):
with tempfile.TemporaryDirectory() as td:
project_root = Path(td)
run_dir = project_root / "artifacts" / "between-stages"
run_dir.mkdir(parents=True)
(run_dir / "workflow-state.json").write_text(json.dumps({
"version": "1.3", "task_slug": "between-stages", "size_class": "medium",
"current_stage": "TASK-02", "stages": stages,
}), encoding="utf-8")
return bdd.build_devflow_runs(project_root)[0]["overall"]

def test_run_waiting_between_stages_is_not_completed(self):
stages = {
"TASK-01": {"status": "completed", "executor": "main", "retry_count": 0},
"TASK-02": {"status": "completed", "executor": "architect", "retry_count": 0},
"TASK-03": {"status": "pending", "executor": "developer", "retry_count": 0},
}
self.assertEqual(self._overall(stages), "running")

def test_fully_finished_run_is_completed(self):
names = ("TASK-01", "TASK-02", "TASK-03", "CODE-REVIEW", "TASK-04", "TASK-05")
stages = {name: {"status": "completed", "executor": "x", "retry_count": 0} for name in names}
stages["TASK-05"]["status"] = "skipped"
self.assertEqual(self._overall(stages), "completed")


class BuildDevflowRunsMediumWithLingeringSoloKeyTests(unittest.TestCase):
"""用户反馈复现:workflow-state.json 模板固定给每次运行都写一份 SOLO stage
(见 assets/workflow-state-template.json 里 SOLO.description 的说明:
Expand Down
28 changes: 28 additions & 0 deletions .claude/skills/agent-observability/tests/test_cls_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@


class CLSSinkTests(unittest.TestCase):
def setUp(self):
# Keep the suite hermetic: never write debug logs into the source tree and
# do not depend on the optional Node SDK being checked out next to the repo.
sandbox = tempfile.TemporaryDirectory()
self.addCleanup(sandbox.cleanup)
sdk_entry = Path(sandbox.name) / "tencentcloud-cls-sdk-js" / "dist" / "index.js"
sdk_entry.parent.mkdir(parents=True)
sdk_entry.write_text("module.exports = {};\n", encoding="utf-8")
for name, value in (
("sdk_entry_path", sdk_entry),
("debug_log_path", Path(sandbox.name) / "cls-push-debug.ndjson"),
):
patcher = mock.patch.object(cls_sink, name, return_value=value)
patcher.start()
self.addCleanup(patcher.stop)

def _write_cls_env(self, root: Path, *extra_lines: str) -> None:
lines = [
"CLS_TOPIC_ID=test-topic-id",
Expand Down Expand Up @@ -171,6 +187,18 @@ def test_mirror_record_writes_success_debug_when_uploader_succeeds(self):
self.assertEqual(entries[-1]["stage"], "uploader_ok")
self.assertEqual(entries[-1]["method"], "python_api_v3")

def test_unconfigured_cls_stays_silent(self):
with tempfile.TemporaryDirectory() as td:
debug_path = Path(td) / "debug.ndjson"
with mock.patch.object(cls_sink, "debug_log_path", return_value=debug_path), \
mock.patch.object(cls_sink.subprocess, "run") as run, \
mock.patch.dict(os.environ, {}, clear=True):
ok = cls_sink.mirror_record({"event": "start", "sid": "s-off"}, cwd=td)

self.assertFalse(ok)
run.assert_not_called()
self.assertFalse(debug_path.exists())

def test_emit_keeps_local_log_when_cls_push_raises(self):
with tempfile.TemporaryDirectory() as td:
log_path = Path(td) / "metrics.ndjson"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,12 @@ def build_devflow_runs(project_root: Path, cost_by_slug: dict[str, float] | None
"exec": info.get("executor") or "-", "status": status,
"retry_count": retry_count, "duration": duration,
})
# 只有所有阶段都已完成或跳过时才算“已完成”;停在两个阶段之间(等待审批、
# 下一阶段尚未派发)的运行仍是进行中,不能因为没有 in_progress 就显示完成。
if overall == "completed" and any(
stage["status"] not in {"completed", "skipped"} for stage in stages_out
):
overall = "running"
# 按真实起止时间跨度算总时长(而不是逐阶段 duration 相加)——阶段之间可能
# 有重叠(比如 TASK-05 的 started_at 早于 SOLO 的 completed_at),相加会
# 把重叠部分重复计入,虚高于源数据本身反映的运行时长。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ def infer_identity_from_messages(messages: list[dict[str, Any]]) -> tuple[Option
next_target = _extract_next_target(payload)

if from_role == "main" and mailbox_role and mailbox_role != "main":
if not msg.get("is_shutdown"):
# A standby assignment only creates the member; it is not active yet.
if not msg.get("is_shutdown") and not msg.get("is_standby"):
current = mailbox_role
dispatched = mailbox_role
evidence = f"dispatch:{mailbox_name}"
Expand Down Expand Up @@ -521,6 +522,7 @@ def _normalize_message(item: Any, mailbox_name: str, mailbox_role: str | None, i
"ts": ts,
"index": index,
"is_shutdown": _is_shutdown_message(payload, summary, text),
"is_standby": _is_standby_message(payload, text),
}


Expand Down Expand Up @@ -561,6 +563,14 @@ def _is_shutdown_message(payload: dict[str, Any] | None, summary: str, text: Any
return "shutdown" in summary_low or "shutdown request" in text_low


def _is_standby_message(payload: dict[str, Any] | None, text: Any) -> bool:
"""识别“先创建成员、待命等待唤醒”的消息;这类消息不代表成员已开始工作。"""
if isinstance(payload, dict) and (payload.get("event") or payload.get("dispatch_prompt")):
return False
text_low = text.lower() if isinstance(text, str) else ""
return "待命" in text_low or "standby" in text_low or "stand by" in text_low


def _parse_timestamp(value: Any) -> float:
"""把消息时间字段解析成 Unix 时间戳。"""
if not isinstance(value, str) or not value.strip():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ def load_config(cwd: str | None = None) -> CLSConfig:

def mirror_record(record: dict[str, Any], *, cwd: str | None = None) -> bool:
config = load_config(cwd)
if not (config.endpoint or config.topic_id):
# CLS mirroring was never configured: stay silent instead of writing a
# debug line into the skill directory for every local event.
return False
if not config.ready:
_debug_write(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,36 @@ def test_solo_run_includes_task05_and_marks_phase0_completed(self):
self.assertNotIn("TASK-01", stage_by_key) # SOLO 路径不该混进 medium/large 的阶段


class BuildDevflowRunsOverallStatusTests(unittest.TestCase):
"""E2E 发现:刚开始或停在两个阶段之间的运行(没有 in_progress、也没有 failed)
曾被显示为“已完成”。"""

def _overall(self, stages):
with tempfile.TemporaryDirectory() as td:
project_root = Path(td)
run_dir = project_root / "artifacts" / "between-stages"
run_dir.mkdir(parents=True)
(run_dir / "workflow-state.json").write_text(json.dumps({
"version": "1.3", "task_slug": "between-stages", "size_class": "medium",
"current_stage": "TASK-02", "stages": stages,
}), encoding="utf-8")
return bdd.build_devflow_runs(project_root)[0]["overall"]

def test_run_waiting_between_stages_is_not_completed(self):
stages = {
"TASK-01": {"status": "completed", "executor": "main", "retry_count": 0},
"TASK-02": {"status": "completed", "executor": "architect", "retry_count": 0},
"TASK-03": {"status": "pending", "executor": "developer", "retry_count": 0},
}
self.assertEqual(self._overall(stages), "running")

def test_fully_finished_run_is_completed(self):
names = ("TASK-01", "TASK-02", "TASK-03", "CODE-REVIEW", "TASK-04", "TASK-05")
stages = {name: {"status": "completed", "executor": "x", "retry_count": 0} for name in names}
stages["TASK-05"]["status"] = "skipped"
self.assertEqual(self._overall(stages), "completed")


class BuildDevflowRunsMediumWithLingeringSoloKeyTests(unittest.TestCase):
"""用户反馈复现:workflow-state.json 模板固定给每次运行都写一份 SOLO stage
(见 assets/workflow-state-template.json 里 SOLO.description 的说明:
Expand Down
28 changes: 28 additions & 0 deletions .codebuddy/skills/agent-observability/tests/test_cls_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@


class CLSSinkTests(unittest.TestCase):
def setUp(self):
# Keep the suite hermetic: never write debug logs into the source tree and
# do not depend on the optional Node SDK being checked out next to the repo.
sandbox = tempfile.TemporaryDirectory()
self.addCleanup(sandbox.cleanup)
sdk_entry = Path(sandbox.name) / "tencentcloud-cls-sdk-js" / "dist" / "index.js"
sdk_entry.parent.mkdir(parents=True)
sdk_entry.write_text("module.exports = {};\n", encoding="utf-8")
for name, value in (
("sdk_entry_path", sdk_entry),
("debug_log_path", Path(sandbox.name) / "cls-push-debug.ndjson"),
):
patcher = mock.patch.object(cls_sink, name, return_value=value)
patcher.start()
self.addCleanup(patcher.stop)

def _write_cls_env(self, root: Path, *extra_lines: str) -> None:
lines = [
"CLS_TOPIC_ID=test-topic-id",
Expand Down Expand Up @@ -171,6 +187,18 @@ def test_mirror_record_writes_success_debug_when_uploader_succeeds(self):
self.assertEqual(entries[-1]["stage"], "uploader_ok")
self.assertEqual(entries[-1]["method"], "python_api_v3")

def test_unconfigured_cls_stays_silent(self):
with tempfile.TemporaryDirectory() as td:
debug_path = Path(td) / "debug.ndjson"
with mock.patch.object(cls_sink, "debug_log_path", return_value=debug_path), \
mock.patch.object(cls_sink.subprocess, "run") as run, \
mock.patch.dict(os.environ, {}, clear=True):
ok = cls_sink.mirror_record({"event": "start", "sid": "s-off"}, cwd=td)

self.assertFalse(ok)
run.assert_not_called()
self.assertFalse(debug_path.exists())

def test_emit_keeps_local_log_when_cls_push_raises(self):
with tempfile.TemporaryDirectory() as td:
log_path = Path(td) / "metrics.ndjson"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,12 @@ def build_devflow_runs(project_root: Path, cost_by_slug: dict[str, float] | None
"exec": info.get("executor") or "-", "status": status,
"retry_count": retry_count, "duration": duration,
})
# 只有所有阶段都已完成或跳过时才算“已完成”;停在两个阶段之间(等待审批、
# 下一阶段尚未派发)的运行仍是进行中,不能因为没有 in_progress 就显示完成。
if overall == "completed" and any(
stage["status"] not in {"completed", "skipped"} for stage in stages_out
):
overall = "running"
# 按真实起止时间跨度算总时长(而不是逐阶段 duration 相加)——阶段之间可能
# 有重叠(比如 TASK-05 的 started_at 早于 SOLO 的 completed_at),相加会
# 把重叠部分重复计入,虚高于源数据本身反映的运行时长。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ def infer_identity_from_messages(messages: list[dict[str, Any]]) -> tuple[Option
next_target = _extract_next_target(payload)

if from_role == "main" and mailbox_role and mailbox_role != "main":
if not msg.get("is_shutdown"):
# A standby assignment only creates the member; it is not active yet.
if not msg.get("is_shutdown") and not msg.get("is_standby"):
current = mailbox_role
dispatched = mailbox_role
evidence = f"dispatch:{mailbox_name}"
Expand Down Expand Up @@ -521,6 +522,7 @@ def _normalize_message(item: Any, mailbox_name: str, mailbox_role: str | None, i
"ts": ts,
"index": index,
"is_shutdown": _is_shutdown_message(payload, summary, text),
"is_standby": _is_standby_message(payload, text),
}


Expand Down Expand Up @@ -561,6 +563,14 @@ def _is_shutdown_message(payload: dict[str, Any] | None, summary: str, text: Any
return "shutdown" in summary_low or "shutdown request" in text_low


def _is_standby_message(payload: dict[str, Any] | None, text: Any) -> bool:
"""识别“先创建成员、待命等待唤醒”的消息;这类消息不代表成员已开始工作。"""
if isinstance(payload, dict) and (payload.get("event") or payload.get("dispatch_prompt")):
return False
text_low = text.lower() if isinstance(text, str) else ""
return "待命" in text_low or "standby" in text_low or "stand by" in text_low


def _parse_timestamp(value: Any) -> float:
"""把消息时间字段解析成 Unix 时间戳。"""
if not isinstance(value, str) or not value.strip():
Expand Down
4 changes: 4 additions & 0 deletions .cursor/skills/agent-observability/scripts/core/cls_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,10 @@ def load_config(cwd: str | None = None) -> CLSConfig:

def mirror_record(record: dict[str, Any], *, cwd: str | None = None) -> bool:
config = load_config(cwd)
if not (config.endpoint or config.topic_id):
# CLS mirroring was never configured: stay silent instead of writing a
# debug line into the skill directory for every local event.
return False
if not config.ready:
_debug_write(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,36 @@ def test_solo_run_includes_task05_and_marks_phase0_completed(self):
self.assertNotIn("TASK-01", stage_by_key) # SOLO 路径不该混进 medium/large 的阶段


class BuildDevflowRunsOverallStatusTests(unittest.TestCase):
"""E2E 发现:刚开始或停在两个阶段之间的运行(没有 in_progress、也没有 failed)
曾被显示为“已完成”。"""

def _overall(self, stages):
with tempfile.TemporaryDirectory() as td:
project_root = Path(td)
run_dir = project_root / "artifacts" / "between-stages"
run_dir.mkdir(parents=True)
(run_dir / "workflow-state.json").write_text(json.dumps({
"version": "1.3", "task_slug": "between-stages", "size_class": "medium",
"current_stage": "TASK-02", "stages": stages,
}), encoding="utf-8")
return bdd.build_devflow_runs(project_root)[0]["overall"]

def test_run_waiting_between_stages_is_not_completed(self):
stages = {
"TASK-01": {"status": "completed", "executor": "main", "retry_count": 0},
"TASK-02": {"status": "completed", "executor": "architect", "retry_count": 0},
"TASK-03": {"status": "pending", "executor": "developer", "retry_count": 0},
}
self.assertEqual(self._overall(stages), "running")

def test_fully_finished_run_is_completed(self):
names = ("TASK-01", "TASK-02", "TASK-03", "CODE-REVIEW", "TASK-04", "TASK-05")
stages = {name: {"status": "completed", "executor": "x", "retry_count": 0} for name in names}
stages["TASK-05"]["status"] = "skipped"
self.assertEqual(self._overall(stages), "completed")


class BuildDevflowRunsMediumWithLingeringSoloKeyTests(unittest.TestCase):
"""用户反馈复现:workflow-state.json 模板固定给每次运行都写一份 SOLO stage
(见 assets/workflow-state-template.json 里 SOLO.description 的说明:
Expand Down
Loading
Loading