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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (851) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (858) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
72 changes: 72 additions & 0 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,52 @@ async def _github_connect_web_cancel(self) -> None:
except (OSError, ValueError):
pass

async def _task_vibe_check(self, task_name: str, prompt: str, completion_summary: str) -> dict:
"""One-shot structured LLM ask (Ask mode, no tools, no history).

Asks whether a just-finished scheduled task produced meaningful value.
The agent must answer in strict JSON:
``{"meaningful": bool, "recommend_slowdown": bool, "reason": str}``.

Raises on any failure (caller sends ``ok: false``); the scheduler
conservatively leaves its empty-cycle counter unchanged then.
"""
system = (
"你是 EMRG 定时任务调度助手。刚完成一次定时任务「" + (task_name or "") + "」,"
"任务要求与最终回复摘要如下。\n"
"请用 JSON 严格回答(不要任何其他文字),格式:\n"
'{"meaningful": true|false, "recommend_slowdown": true|false, '
'"reason": "一句话原因"}\n'
"- meaningful:这轮是否对项目产生了有意义的价值(产出/提交/分析/决策/"
"维护动作都算;纯空转/无可做=NTE 算 false)\n"
"- recommend_slowdown:若本任务长期无有意义产出,是否建议降频省 token"
"(true=建议降低检查频率)\n"
"- reason:简短中文原因"
)
user = (
"任务名称:" + (task_name or "") + "\n"
"任务要求:" + (prompt or "")[:2000] + "\n"
"任务最终回复摘要:" + (completion_summary or "")[:3000]
)
msg = await self.llm.chat(
[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
tools=[],
)
content = (msg.get("content") or "").strip()
# Tolerate markdown fences if the model wraps the JSON in ```json ... ```
content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content).strip()
data = json.loads(content)
if not isinstance(data, dict):
raise ValueError("vibe check response is not a JSON object")
return {
"meaningful": bool(data.get("meaningful")),
"recommend_slowdown": bool(data.get("recommend_slowdown")),
"reason": str(data.get("reason", ""))[:200],
}

def _build_system_prompt(self, session: Session | None = None) -> str:
"""Build the system prompt via Jinja2 template.

Expand Down Expand Up @@ -1872,6 +1918,32 @@ async def _process_message(
logger.info("session rewound: %s at index %d (removed %d records)",
session_id, record_index, len(records) - len(truncated))

elif msg_type == "task_vibe_check":
# Rant 2026-08-17T11:39:19: scheduler asks the agent, after a
# scheduled task completes, whether the round produced meaningful
# value — replacing the git-HEAD empty-cycle heuristic (HEAD
# measures commits, not value: analysis/memory work without a
# commit was miscounted as empty, and a no-op round over someone
# else's push counted as work). One-shot Ask-mode LLM call (no
# tools, no session history) with a strict JSON contract.
task_name = msg.get("task_name", "")
prompt = msg.get("prompt", "")
summary = msg.get("completion_summary", "")
try:
result = await self._task_vibe_check(task_name, prompt, summary)
await self._send(ws, {
"type": "vibe_check_result",
"ok": True,
"result": result,
})
except Exception as e: # noqa: BLE001 — best-effort, never fatal
logger.warning("task_vibe_check failed: %s", e)
await self._send(ws, {
"type": "vibe_check_result",
"ok": False,
"error": str(e)[:200],
})

elif msg_type == "shutdown":
logger.info("shutdown requested by client")
await self._send(ws, {"type": "shutdown_ack"})
Expand Down
173 changes: 138 additions & 35 deletions emrg/server/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,24 +221,30 @@ def __init__(
self.evolutions: list[EvolutionLog] = []

# ── Saturation — slow down, never stop (rant 2026-08-09T09:35:55) ──
# Track consecutive cycles where git HEAD didn't advance (NTE).
# After _IDLE_HALT_THRESHOLD empty cycles, switch to low-frequency
# Track consecutive empty cycles (rant 2026-08-17T11:39:19: the agent
# itself answers whether a round was meaningful — git HEAD compares
# commits, not value, so it was removed as the empty-cycle oracle).
# After the threshold of empty cycles, switch to low-frequency
# heartbeat full cycles instead of the old complete halt:
# - Scheduled runs continue at heartbeat interval (never skipped)
# - heartbeat = max(interval, min(interval*8, 8h)) — 60s task → 8min
# - Manual trigger (/trigger) or upstream git HEAD advance restores
# the normal frequency immediately (counter reset to 0)
# - The agent's recommend_slowdown votes (3) tighten the threshold
# from 30 to 10 (host 2026-08-17T11:39:19)
#
# Counter is persisted to disk to survive daemon restarts.
self._IDLE_HALT_THRESHOLD = 30
self._SLOWDOWN_VOTES_TO_TIGHTEN = 3
self._TIGHTENED_THRESHOLD = 10
# G129: 连续连接失败告警阈值——达到后升级为 ERROR(防静默吞掉,
# rant 2026-08-09T08:03:46:GUI 测试覆盖真实 emrgd.port 致 10h 连不上)。
self._CONNECT_FAIL_ALERT = 3
self._connect_failures = 0
self._saturation_dir = config_dir() / "saturation"
self._saturation_dir.mkdir(parents=True, exist_ok=True)
self._saturation_file = self._saturation_dir / f"{self.name}.json"
self._empty_cycles = self._load_saturation_state()
self._empty_cycles, self._slowdown_hits = self._load_saturation_state()

# Resolve project path from config (new schema) or fall back to
# config.path for backward-compat with old tasks.yml entries.
Expand Down Expand Up @@ -596,32 +602,47 @@ def _ensure_origin_reachable(self) -> None:
self.name, (result.stderr.strip() or "")[:80], ssh_url,
)

def _load_saturation_state(self) -> int:
"""Restore _empty_cycles counter from disk (survives daemon restarts)."""
def _load_saturation_state(self) -> tuple[int, int]:
"""Restore (empty_cycles, slowdown_hits) from disk (daemon restarts)."""
try:
if self._saturation_file.exists():
data = json.loads(self._saturation_file.read_text(encoding="utf-8"))
count = data.get("empty_cycles", 0)
if count > 0:
count = int(data.get("empty_cycles", 0) or 0)
slowdown = int(data.get("slowdown_hits", 0) or 0)
if count > 0 or slowdown > 0:
logger.debug(
"TaskHandler[%s]: restored saturation state (%d empty cycles)",
self.name, count,
"TaskHandler[%s]: restored saturation state (%d empty cycles, %d slowdown votes)",
self.name, count, slowdown,
)
return count
return count, slowdown
except Exception:
pass
return 0
return 0, 0

def _save_saturation_state(self) -> None:
"""Persist _empty_cycles counter to disk."""
"""Persist (empty_cycles, slowdown_hits) to disk."""
try:
self._saturation_file.write_text(
json.dumps({"empty_cycles": self._empty_cycles}, ensure_ascii=False),
json.dumps(
{"empty_cycles": self._empty_cycles,
"slowdown_hits": self._slowdown_hits},
ensure_ascii=False,
),
encoding="utf-8",
)
except Exception:
pass

def _saturation_threshold(self) -> int:
"""Empty-cycle threshold before dropping to heartbeat cadence.

The agent's recommend_slowdown votes (3) tighten the threshold from
30 to 10 — the agent itself keeps reporting the task has no value
(rant 2026-08-17T11:39:19)."""
if self._slowdown_hits >= self._SLOWDOWN_VOTES_TO_TIGHTEN:
return self._TIGHTENED_THRESHOLD
return self._IDLE_HALT_THRESHOLD

async def run(self) -> None:
"""Run evolution cycles at configured interval.

Expand Down Expand Up @@ -666,13 +687,14 @@ async def run(self) -> None:
# Manual triggers always reset the saturation counter; otherwise
# saturated ticks keep running full cycles at heartbeat cadence.
if manual_trigger:
if self._empty_cycles >= self._IDLE_HALT_THRESHOLD:
if self._empty_cycles >= self._saturation_threshold():
logger.info(
"TaskHandler[%s]: resumed via manual trigger "
"(was in saturation at %d empty cycles)",
self.name, self._empty_cycles,
)
self._empty_cycles = 0
self._slowdown_hits = 0
self._save_saturation_state()

logger.debug("TaskHandler[%s] tick", self.name)
Expand Down Expand Up @@ -808,7 +830,7 @@ def _saturation_heartbeat_active(self) -> bool:
auto-resumes (counter reset, normal frequency), so a saturated handler
does not miss new work forever.
"""
if self._empty_cycles < self._IDLE_HALT_THRESHOLD:
if self._empty_cycles < self._saturation_threshold():
return False
if self._remote_advanced():
logger.info(
Expand All @@ -825,6 +847,48 @@ def _saturation_heartbeat_active(self) -> bool:
)
return True

async def _request_vibe_check(self, ws, prompt: str, completion_summary: str) -> dict | None:
"""Ask the daemon for a structured vibe check on the SAME connection.

Sends ``task_vibe_check`` and waits for ``vibe_check_result`` (~20s).
Fully defensive — any failure/timeout returns None; the caller
conservatively leaves the empty-cycle counter unchanged.
"""
try:
await ws.send(json.dumps({
"type": "task_vibe_check",
"session_id": self._session_id,
"task_name": self.name,
"prompt": (prompt or "")[:2000],
"completion_summary": (completion_summary or "")[:3000],
}, ensure_ascii=False))
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
remaining = max(0.5, deadline - time.monotonic())
try:
frame = json.loads(await asyncio.wait_for(ws.recv(), timeout=remaining))
except asyncio.TimeoutError:
break
except ConnectionClosed:
break
if frame.get("type") != "vibe_check_result":
continue
if not frame.get("ok"):
logger.warning(
"TaskHandler[%s]: vibe check error: %s",
self.name, (frame.get("error") or "")[:120],
)
return None
result = frame.get("result") or {}
return {
"meaningful": result.get("meaningful"),
"recommend_slowdown": result.get("recommend_slowdown"),
"reason": result.get("reason", ""),
}
except Exception:
logger.debug("TaskHandler[%s]: vibe check failed", self.name, exc_info=True)
return None

async def _run_evolution_cycle(self) -> None:

# Self-heal the evolution workspace first (rant 20:42 方案 C):
Expand All @@ -844,9 +908,6 @@ async def _run_evolution_cycle(self) -> None:
)
start_time = cycle_time

# Track git HEAD to detect empty (NTE) cycles
git_head_before = self._get_git_head()

try:
ws = await connect_to_server()
logger.info("TaskHandler[%s]: connected", self.name)
Expand Down Expand Up @@ -889,6 +950,7 @@ async def _run_evolution_cycle(self) -> None:
tool_count = 0
error = None
truncated = False
completion_content = ""

try:
await ws.send(task_msg)
Expand All @@ -908,6 +970,7 @@ async def _run_evolution_cycle(self) -> None:
# wrongly advancing the idle-halt backoff (mem repo lesson:
# truncation must be flagged, not silently treated as done).
content = resp.get("content") or ""
completion_content = content
truncated = "exceeded" in content.lower()
if truncated:
logger.warning(
Expand All @@ -932,6 +995,17 @@ async def _run_evolution_cycle(self) -> None:
self.name, error,
)
break

# Empty-cycle detection (rant 2026-08-17T11:39:19): after a clean
# completion, ask the agent via task_vibe_check whether the round
# was meaningful. Done on the SAME ws connection (daemon replies
# with vibe_check_result). Any failure → None → counter untouched.
vibe_result = None
if not error and not truncated:
vibe_result = await self._request_vibe_check(
ws, prompt=prompt,
completion_summary=completion_content[:3000],
)
except Exception as e:
logger.exception("TaskHandler[%s] error", self.name)
error = str(e)
Expand All @@ -941,35 +1015,64 @@ async def _run_evolution_cycle(self) -> None:
except Exception:
pass

# Detect empty cycles: git HEAD unchanged → no work was done.
# A truncated cycle is NOT empty — the agent wanted to work but hit
# the tool-round cap; counting it would wrongly back off the handler.
# Empty-cycle accounting (rant 2026-08-17T11:39:19): the AGENT decides
# whether the round was meaningful (task_vibe_check structured answer),
# not git HEAD — HEAD compares commits, so an agent that did analysis /
# memory work without a commit was miscounted as empty, and a no-op
# round over someone else's push counted as work.
# - meaningful: false → empty cycle (advance the backoff)
# - meaningful: true → reset the empty streak (+ slowdown votes)
# - recommend_slowdown: true → +1 slowdown vote (3 votes tighten
# the saturation threshold from 30 to 10)
# - vibe check unavailable (ok=false / timeout / parse error) →
# conservative: count unchanged (neither advance nor reset)
# A truncated cycle is NOT empty — the agent wanted to work but hit the
# tool-round cap; counting it would wrongly back off the handler.
# An aborted cycle (server error like "session busy", or an exception)
# is NOT empty either — the agent was blocked before reaching an NTE
# conclusion; counting it would also advance the idle-halt backoff.
git_head_after = self._get_git_head()
if (
not error
and not truncated
and git_head_before and git_head_after
and git_head_before == git_head_after
):
self._empty_cycles += 1
self._save_saturation_state()
logger.debug(
"TaskHandler[%s]: empty cycle #%d (HEAD=%s)",
self.name, self._empty_cycles, git_head_after[:8],
if not error and not truncated and vibe_result is not None:
meaningful = vibe_result.get("meaningful")
recommend = bool(vibe_result.get("recommend_slowdown"))
if meaningful is False:
self._empty_cycles += 1
if recommend:
self._slowdown_hits += 1
self._save_saturation_state()
logger.info(
"TaskHandler[%s]: empty cycle #%d (agent: %s%s)",
self.name, self._empty_cycles,
(vibe_result.get("reason") or "")[:100],
f"; slowdown votes {self._slowdown_hits}/{self._SLOWDOWN_VOTES_TO_TIGHTEN}"
if recommend else "",
)
elif meaningful is True:
if self._empty_cycles > 0 or self._slowdown_hits > 0:
logger.info(
"TaskHandler[%s]: agent reported meaningful work, "
"resetting empty streak (%d) + slowdown votes (%d)",
self.name, self._empty_cycles, self._slowdown_hits,
)
self._empty_cycles = 0
self._slowdown_hits = 0
self._save_saturation_state()
elif not error and not truncated:
# vibe check failed/timeout — conservative: don't count, don't reset
logger.info(
"TaskHandler[%s]: vibe check unavailable — empty streak unchanged",
self.name,
)
else:
if self._empty_cycles > 0:
reason = "truncated cycle" if truncated else "git HEAD changed"
if self._empty_cycles > 0 or self._slowdown_hits > 0:
reason = "truncated cycle" if truncated else "aborted cycle"
if error:
reason = f"aborted cycle ({error[:80]})"
logger.info(
"TaskHandler[%s]: %s, resetting empty streak",
self.name, reason,
)
self._empty_cycles = 0
self._slowdown_hits = 0
self._save_saturation_state()

# Aborted cycles are not evolutions: no log file, no count. Writing
Expand Down
Loading
Loading