diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 1a5fe2e4..078fdcdc 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -719,17 +719,17 @@ const Dialogs = (() => { for (const r of runs) { const row = el("div", { class: "task-run-row" }); row.appendChild(el("span", { class: "task-run-time" }, formatRelativeTime(r.timestamp))); - let done = (typeof r.summary === "string" && r.summary) ? r.summary : ""; + // rant 2026-08-20T10:58:55:字段统一 work(原 summary/done) + let done = (typeof r.work === "string" && r.work) ? r.work : ""; row.appendChild(el("span", { class: "task-run-done" }, done || "-")); const flagCell = el("span", { class: "task-run-flag" }); if (r.recommend_slowdown) { flagCell.appendChild(el("span", { class: "task-badge task-run-badge-warn" }, _t("app.taskRunThrottle"))); - } else if (r.meaningful === false) { - flagCell.appendChild(el("span", { class: "task-badge task-run-badge-idle" }, _t("app.taskRunIdle"))); } row.appendChild(flagCell); - // rant 2026-08-19T18:25:14:原因列 —— 降频/空转判断的自然语言理由(vibe check reason)。 - let reason = (typeof r.reason === "string" && r.reason) ? r.reason : ""; + // rant 2026-08-19T18:25:14:原因列 —— 降频判断的自然语言理由(vibe check + // slowdown_reason,rant 2026-08-20T10:58:55 改名)。 + let reason = (typeof r.slowdown_reason === "string" && r.slowdown_reason) ? r.slowdown_reason : ""; row.appendChild(el("span", { class: "task-run-reason" }, reason || "-")); wrap.appendChild(row); } @@ -787,9 +787,11 @@ const Dialogs = (() => { } else { meta.appendChild(el("span", { class: "task-meta-item" }, _t("app.taskNoRunYet"))); } + // rant 2026-08-20T10:58:55:降频徽标改为只认 heartbeat_active(不再用 + // empty_cycles)—— 文案「已降频 · heartbeat {m}s」(app.taskThrottled)。 const sat = (t.saturation && typeof t.saturation === "object") ? t.saturation : null; - if (sat && sat.empty_cycles > 0 && sat.heartbeat_active) { - meta.appendChild(el("span", { class: "task-badge task-saturation-badge" }, _t("app.taskSaturation", { n: sat.empty_cycles, m: formatCountdown(sat.heartbeat_interval) }))); + if (sat && sat.heartbeat_active) { + meta.appendChild(el("span", { class: "task-badge task-saturation-badge" }, _t("app.taskThrottled", { m: formatCountdown(sat.heartbeat_interval) }))); } row.appendChild(meta); const actions = el("span", { class: "task-actions" }); diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 97e24a7a..32fe3fc6 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -410,14 +410,13 @@ const I18N = (() => { "app.taskRunningBadge": "运行中", "app.taskLastRun": "上次运行:{n}", "app.taskNoRunYet": "尚未运行", - "app.taskSaturation": "空转 {n} 轮,降频至 {m}s heartbeat", + "app.taskThrottled": "已降频 · heartbeat {m}s", "app.taskRunsEmpty": "暂无运行记录", "app.taskRunsColTime": "时间", "app.taskRunsColDone": "干了什么", "app.taskRunsColThrottle": "降频", "app.taskRunsColReason": "原因", "app.taskRunThrottle": "建议降频", - "app.taskRunIdle": "空转", "app.deletedSwitch": "这个对话已被删除,已帮你切到最近的对话。", "app.switched": "已切换对话。", "app.sessionDisconnected": "该会话连接已断开,正在自动重连…", @@ -840,14 +839,13 @@ const I18N = (() => { "app.taskRunningBadge": "running", "app.taskLastRun": "last run: {n}", "app.taskNoRunYet": "never ran", - "app.taskSaturation": "{n} empty cycles, throttled to {m}s heartbeat", + "app.taskThrottled": "throttled · {m}s heartbeat", "app.taskRunsEmpty": "No run records yet", "app.taskRunsColTime": "Time", "app.taskRunsColDone": "What was done", "app.taskRunsColThrottle": "Throttle", "app.taskRunsColReason": "Reason", "app.taskRunThrottle": "slowdown advised", - "app.taskRunIdle": "idle", "app.deletedSwitch": "This conversation was deleted — switched to the most recent one.", "app.switched": "Conversation switched.", "app.sessionDisconnected": "This session's connection is lost — reconnecting automatically…", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index b3d1fa97..28cdacfd 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -2651,17 +2651,17 @@ test("rant 21:32:32:任务卡点击展开最近运行子表(时间/干了什 { name: "emrg-task", type: "evolution", config: { project: "emrg" }, interval: 60, enabled: true, last_run_at: "2026-08-18T10:00:00", - last_cycle_summary: "修了双实例根因", + saturation: { heartbeat_interval: 480, heartbeat_active: true }, recent_runs: [ - { timestamp: "2026-08-18T10:00:00", summary: "修了双实例根因,提交 PR #854", - impact: ["cycle-ts-complete", "tools-executed=26"], meaningful: true, - recommend_slowdown: false, tool_count: 26, reason: "meaningful work" }, - { timestamp: "2026-08-18T09:00:00", summary: "", - impact: ["cycle-ts-complete", "tools-executed=3"], meaningful: false, - recommend_slowdown: false, tool_count: 3, reason: "" }, - { timestamp: "2026-08-18T08:00:00", summary: "NTE", - impact: ["cycle-ts-complete"], meaningful: false, - recommend_slowdown: true, tool_count: 0, reason: "empty cycles, no value" }, + { timestamp: "2026-08-18T10:00:00", work: "修了双实例根因,提交 PR #854", + impact: ["cycle-ts-complete", "tools-executed=26"], + recommend_slowdown: false, tool_count: 26, slowdown_reason: "" }, + { timestamp: "2026-08-18T09:00:00", work: "", + impact: ["cycle-ts-complete", "tools-executed=3"], + recommend_slowdown: false, tool_count: 3, slowdown_reason: "" }, + { timestamp: "2026-08-18T08:00:00", work: "NTE", + impact: ["cycle-ts-complete"], recommend_slowdown: true, tool_count: 0, + slowdown_reason: "长期无产出" }, ], }, { name: "fresh-task", type: "sync", config: { project: "docs" }, interval: 3600, enabled: true }, @@ -2680,22 +2680,25 @@ test("rant 21:32:32:任务卡点击展开最近运行子表(时间/干了什 await tick(); const detail = vm.runInContext(`document.getElementById("task-list").children[0].querySelector(".task-run-detail")`, ctx); assert.strictEqual(detail.classList.contains("hidden"), false, "点击后子表应展开"); - // 第一行 run:Agent 总结展示(自然语言) + // 第一行 run:Agent 总结展示(自然语言 work,rant 2026-08-20T10:58:55 字段统一) const doneTxt = vm.runInContext(`document.getElementById("task-list").children[0].querySelectorAll(".task-run-done")[0].textContent`, ctx); assert.strictEqual(doneTxt, "修了双实例根因,提交 PR #854", `子表应显示 Agent 总结,实际: ${doneTxt}`); - // 降频徽章:recommend_slowdown=true → 建议降频;meaningful=false → 空转 + // 降频徽章:recommend_slowdown=true → 建议降频(idle 空转徽章已删除,rant 10:58:55) const warnCount = vm.runInContext(`document.getElementById("task-list").children[0].querySelectorAll(".task-run-badge-warn").length`, ctx); assert.ok(warnCount >= 1, "recommend_slowdown=true 应显示建议降频徽章"); const idleCount = vm.runInContext(`document.getElementById("task-list").children[0].querySelectorAll(".task-run-badge-idle").length`, ctx); - assert.ok(idleCount >= 1, "meaningful=false 应显示空转徽章"); - // 无 summary → 显示 "-"(rant 2026-08-19T07:06:45 宿主定稿:不再 fallback impact 机器串) + assert.strictEqual(idleCount, 0, "meaningful 已删除,不应再出现空转徽章"); + // 一级卡片:heartbeat_active → 已降频徽章(app.taskThrottled,rant 10:58:55) + const satBadge = vm.runInContext(`document.getElementById("task-list").children[0].querySelectorAll(".task-saturation-badge").length`, ctx); + assert.strictEqual(satBadge, 1, "saturation.heartbeat_active=true 应显示已降频徽章"); + // 无 work → 显示 "-"(rant 2026-08-19T07:06:45 宿主定稿:不再 fallback impact 机器串) const doneTexts = vm.runInContext(`Array.from(document.getElementById("task-list").children[0].querySelectorAll(".task-run-done")).map((n) => n.textContent)`, ctx); - assert.strictEqual(doneTexts[1], "-", `无 summary 应显示 "-",实际: ${doneTexts[1]}`); - // rant 2026-08-19T18:25:14:原因列 —— 有 reason 显示原文,无 reason 显示 "-" + assert.strictEqual(doneTexts[1], "-", `无 work 应显示 "-",实际: ${doneTexts[1]}`); + // rant 2026-08-19T18:25:14:原因列 —— 有 slowdown_reason 显示原文,无则 "-" const reasonTexts = vm.runInContext(`Array.from(document.getElementById("task-list").children[0].querySelectorAll(".task-run-reason")).map((n) => n.textContent)`, ctx); - assert.strictEqual(reasonTexts[0], "meaningful work", `原因列应显示 reason,实际: ${reasonTexts[0]}`); - assert.strictEqual(reasonTexts[1], "-", `无 reason 应显示 "-",实际: ${reasonTexts[1]}`); - assert.strictEqual(reasonTexts[2], "empty cycles, no value", `原因列应显示降频原因,实际: ${reasonTexts[2]}`); + assert.strictEqual(reasonTexts[0], "-", `无 slowdown_reason 应显示 "-",实际: ${reasonTexts[0]}`); + assert.strictEqual(reasonTexts[1], "-", `无 slowdown_reason 应显示 "-",实际: ${reasonTexts[1]}`); + assert.strictEqual(reasonTexts[2], "长期无产出", `原因列应显示降频原因,实际: ${reasonTexts[2]}`); // rant 2026-08-19T18:25:14:一级列表不再显示"干了什么"(last_cycle_summary) const primarySummary = vm.runInContext(`document.getElementById("task-list").children[0].querySelectorAll(".task-meta-summary").length`, ctx); assert.strictEqual(primarySummary, 0, `一级列表不应显示 last_cycle_summary,实际数量: ${primarySummary}`); @@ -2921,11 +2924,10 @@ test("rant 10:45:52:任务行显示上次执行元信息 + 降频标识(rant { name: "with-run", type: "evolution", running: false, interval: 1800, next_run_in_seconds: 43, last_run_at: new Date(nowMs - 5 * 60_000).toISOString(), - last_cycle_summary: "tools-executed=24, cycle-complete", - saturation: { empty_cycles: 3, threshold: 5, heartbeat_interval: 7200, heartbeat_active: true } }, + saturation: { heartbeat_interval: 7200, heartbeat_active: true } }, { name: "never-run", type: "evolution", running: false, interval: 3600, - next_run_in_seconds: null, enabled: true, last_run_at: null, last_cycle_summary: null, - saturation: { empty_cycles: 0, threshold: 5, heartbeat_interval: 7200, heartbeat_active: false } }, + next_run_in_seconds: null, enabled: true, last_run_at: null, + saturation: { heartbeat_interval: 7200, heartbeat_active: false } }, ], listProjects: async () => [], }); @@ -2943,9 +2945,11 @@ test("rant 10:45:52:任务行显示上次执行元信息 + 降频标识(rant assert.ok(rows[0].includes("上次运行:5m ago"), `运行时间相对显示:${rows[0]}`); // rant 2026-08-19T18:25:14:一级列表不再显示"干了什么"(last_cycle_summary),移入点击展开的二级列表 assert.ok(!rows[0].includes("干了:"), `一级列表不应显示摘要:${rows[0]}`); - assert.ok(rows[0].includes("空转 3 轮"), `降频徽标:${rows[0]}`); + // rant 2026-08-20T10:58:55:降频徽标只认 heartbeat_active —— 「已降频 · heartbeat {m}s」 + // (7200s → 2h00m;不再有 empty_cycles "空转 N 轮" 文案) + assert.ok(rows[0].includes("已降频 · heartbeat 2h00m"), `降频徽标:${rows[0]}`); assert.ok(rows[1].includes("尚未运行"), `未运行提示:${rows[1]}`); - assert.ok(!rows[1].includes("空转"), `无降频时无徽标:${rows[1]}`); + assert.ok(!rows[1].includes("已降频"), `无降频时无徽标:${rows[1]}`); }); test("rant 2026-08-14T15:41:52:快速点击添加任务 —— 元数据未加载完也填充下拉 + 保存成功", async () => { diff --git a/emrg/protocol.py b/emrg/protocol.py index f026df3d..b3ad636d 100644 --- a/emrg/protocol.py +++ b/emrg/protocol.py @@ -128,15 +128,13 @@ class EvolutionLog: operations: list[str] = field(default_factory=list) upstream_contribution: Optional[dict] = None # rant 2026-08-18T21:32:32: the agent's own natural-language summary of - # what meaningful work was done this cycle (from the vibe check "done" - # field) + the vibe result flags, surfaced in GUI task recent-runs. - summary: str = "" - meaningful: Optional[bool] = None + # what was done this cycle, surfaced in GUI task recent-runs. + # Rant 2026-08-20T10:58:55 (host design-finalized): `summary` → `work`; + # `meaningful` deleted (agent no longer self-rates value); + # `reason` → `slowdown_reason` (only populated when recommend_slowdown). + work: str = "" recommend_slowdown: bool = False - # rant 2026-08-19T18:25:14: the vibe check's natural-language reason for - # the meaningful/slowdown judgment, surfaced in the GUI task recent-runs - # secondary list (时间/工作/降频建议/原因). - reason: str = "" + slowdown_reason: str = "" tool_count: int = 0 diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 0f721131..8a60e3ef 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1218,13 +1218,16 @@ async def _task_vibe_check(self, task_name: str, session_id: str, cwd: str, prompt: str = "", completion_summary: str = "") -> dict: """Structured LLM ask (Ask mode, no tools) about a finished task cycle. - Asks whether a just-finished scheduled task produced meaningful value. - The agent must answer in strict JSON: - ``{"meaningful": bool, "recommend_slowdown": bool, "reason": str, "done": str}``. + The agent must answer in strict JSON (rant 2026-08-20T10:58:55, + host design-finalized — fields unified to 3, meaningful deleted): + ``{"work": str, "recommend_slowdown": bool, "slowdown_reason": str}``. - ``done`` (rant 2026-08-18T21:32:32) is a natural-language summary of - what meaningful work was done this cycle, for humans to read in the - GUI task recent-runs table. Old models / old parsing omit it → "". + ``work`` (rant 2026-08-18T21:32:32 → renamed from ``done``) is a + natural-language summary of what was done this cycle, for humans to + read in the GUI task recent-runs table. ``recommend_slowdown`` is now + the ONLY slowdown switch: true → next run at heartbeat interval, + false → normal interval. ``slowdown_reason`` is required only when + recommend_slowdown is true. Old models / old parsing omit fields → "". Rant 2026-08-19T07:10:40 (root cause): the done frame used to carry an empty ``content``, so ``completion_summary`` here was empty and the @@ -1242,7 +1245,7 @@ async def _task_vibe_check(self, task_name: str, session_id: str, cwd: str, ``prompts/vibe_check.j2`` (same live-reload mechanism as system.j2). Raises on any failure (caller sends ``ok: false``); the scheduler - conservatively leaves its empty-cycle counter unchanged then. + conservatively leaves its slowdown state unchanged then. """ template = _get_jinja_env().get_template("vibe_check.j2") system = template.render( @@ -1285,10 +1288,9 @@ async def _task_vibe_check(self, task_name: str, session_id: str, cwd: str, if not isinstance(data, dict): raise ValueError("vibe check response is not a JSON object") return { - "meaningful": bool(data.get("meaningful")), + "work": str(data.get("work", ""))[:500], "recommend_slowdown": bool(data.get("recommend_slowdown")), - "reason": str(data.get("reason", ""))[:200], - "done": str(data.get("done", ""))[:500], + "slowdown_reason": str(data.get("slowdown_reason", ""))[:300], } def _build_system_prompt(self, session: Session | None = None) -> str: diff --git a/emrg/server/prompts/vibe_check.j2 b/emrg/server/prompts/vibe_check.j2 index e817505e..6cd51d26 100644 --- a/emrg/server/prompts/vibe_check.j2 +++ b/emrg/server/prompts/vibe_check.j2 @@ -5,27 +5,26 @@ Rant 2026-08-19T10:15:43 (host-finalized): the PRIMARY evidence is the task's own session history — the daemon loads it by the task's fixed session_id and passes it as the chat messages that follow this system - message. prompt/completion_summary below are only auxiliary context. #} + message. prompt/completion_summary below are only auxiliary context. + Rant 2026-08-20T10:58:55 (host design-finalized): fields unified to + work / recommend_slowdown / slowdown_reason — the agent no longer + self-rates "meaningful"; slowdown is decided solely by recommend_slowdown. #} 你是 EMRG 定时任务调度助手。刚完成一次定时任务「{{ task_name }}」。以下是该定时任务的 **完整会话记录**(任务要求 + Agent 的实际执行过程:工具调用、分析、memory 写入、最终回复)。 -请基于这些**真实记忆**做总结与判断:只有会话记录显示确实没做任何事(无回复、无动作) -才算空转;不要因为"没有 commit/PR"就武断判为无产出——工具执行、分析、排查、写 memory、 -决策等具体动作都是实际工作。 +请基于这些**真实记忆**做总结与判断:工具执行、分析、排查、写 memory、决策等具体动作都是实际工作; +不要因为"没有 commit/PR"就武断判为无产出。 请用 JSON 严格回答(不要任何其他文字),格式: -{"meaningful": true|false, "recommend_slowdown": true|false, "reason": "一句话原因", "done": "这次干了哪些有意义有价值的事"} +{"work": "本次具体做了什么(自然语言,可为空串)", "recommend_slowdown": true|false, "slowdown_reason": "一句话原因"} 字段要求: -- meaningful:这轮是否对项目产生了有意义的价值。只要会话记录显示执行了 - 具体动作(工具调用、分析、排查、写 memory、决策、产出等)即为 true;只有确实 - 空转(无回复、无任何动作)才为 false。 -- recommend_slowdown:若本任务长期无有意义产出,是否建议降频省 token - (true=建议降低检查频率)。 -- reason:简短中文原因(给降频判断用),引用实际信息说明依据。 -- done:从会话记录中**提炼**这次具体做了什么(工具动作、产出、分析, - 自然语言列举,如:执行了 N 次工具调用,git fetch 检查了 X、分析了 Y、写了 - memory Z)。**只要会话记录有实质内容就必须写具体动作**;会话记录确实为空、 - 纯空转才写空字符串 ""。 +- work:如实汇报本次具体做了什么(工具动作、产出、分析,自然语言列举,如:执行了 N 次 + 工具调用,git fetch 检查了 X、分析了 Y、写了 memory Z)。**只要会话记录有实质内容就必须写 + 具体动作**;会话记录确实为空、确实没干才写空字符串 "" 或"没干",诚实即可。 +- recommend_slowdown:是否建议降频省 token(true=下次以慢速 heartbeat 间隔运行, + false=保持正常频率)。由你根据本任务长期是否值得高频运行判断。 +- slowdown_reason:仅 recommend_slowdown=true 时必填;否则写空字符串 ""。简短中文原因, + 引用实际信息说明依据。 任务名称:{{ task_name }} (辅助上下文)任务要求:{{ prompt }} diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 7a242ac2..0796436b 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -230,22 +230,18 @@ def __init__( self.evolutions = restored # ── Saturation — slow down, never stop (rant 2026-08-09T09:35:55) ── - # 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) + # Rant 2026-08-20T10:58:55 (host design-finalized): the empty-cycle + # counter / slowdown-vote / threshold machinery is DELETED. Slowdown + # is decided solely by the agent's vibe-check recommend_slowdown: + # - true → _slowdown_active=True → next run at heartbeat interval + # - false → _slowdown_active=False → back to normal interval + # - vibe unavailable / truncated / aborted → state unchanged + # - Manual trigger (/trigger) → _slowdown_active=False (host + # manually paying attention = back to high frequency) + persist + # heartbeat = max(interval, min(interval*8, 8h)) — 60s task → 8min # - # Counter is persisted to disk to survive daemon restarts. - self._IDLE_HALT_THRESHOLD = 30 - self._SLOWDOWN_VOTES_TO_TIGHTEN = 3 - self._TIGHTENED_THRESHOLD = 10 + # The flag is persisted to disk (~/.emrg/saturation/.json) so + # it survives daemon restarts. # G129: 连续连接失败告警阈值——达到后升级为 ERROR(防静默吞掉, # rant 2026-08-09T08:03:46:GUI 测试覆盖真实 emrgd.port 致 10h 连不上)。 self._CONNECT_FAIL_ALERT = 3 @@ -253,7 +249,8 @@ def __init__( 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._slowdown_hits = self._load_saturation_state() + self._slowdown_active = False + self._slowdown_active = 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. @@ -287,32 +284,35 @@ def __init__( # ── Saturation state (restored from disk across daemon restarts) ── - def _load_saturation_state(self) -> tuple[int, int]: - """Restore (empty_cycles, slowdown_hits) from disk (daemon restarts).""" + def _load_saturation_state(self) -> bool: + """Restore the slowdown flag from disk (daemon restarts). + + Rant 2026-08-20T10:58:55 (host design-finalized): file content is + ``{"slowdown_active": bool}``. A daemon restart keeps a throttled + task at heartbeat cadence until the next vibe check says false or a + manual trigger resets it. Old files (empty_cycles/slowdown_hits) + simply read as False — no migration needed. + """ try: if self._saturation_file.exists(): data = json.loads(self._saturation_file.read_text(encoding="utf-8")) - count = int(data.get("empty_cycles", 0) or 0) - slowdown = int(data.get("slowdown_hits", 0) or 0) - if count > 0 or slowdown > 0: - self._logger.debug( - "TaskHandler[%s]: restored saturation state (%d empty cycles, %d slowdown votes)", - self.name, count, slowdown, + active = bool(data.get("slowdown_active", False)) + if active: + self._logger.info( + "TaskHandler[%s]: restored slowdown state (heartbeat active)", + self.name, ) - return count, slowdown + return active except Exception: pass - return 0, 0 + return False def _save_saturation_state(self) -> None: - """Persist (empty_cycles, slowdown_hits) to disk.""" + """Persist the slowdown flag to disk.""" try: self._saturation_file.write_text( - json.dumps( - {"empty_cycles": self._empty_cycles, - "slowdown_hits": self._slowdown_hits}, - ensure_ascii=False, - ), + json.dumps({"slowdown_active": self._slowdown_active}, + ensure_ascii=False), encoding="utf-8", ) except Exception: @@ -351,10 +351,9 @@ def _load_task_runs(self) -> list[EvolutionLog]: continue records.append(EvolutionLog( timestamp=str(data.get("timestamp") or ""), - summary=str(data.get("summary") or ""), - meaningful=data.get("meaningful"), + work=str(data.get("work") or ""), recommend_slowdown=bool(data.get("recommend_slowdown")), - reason=str(data.get("reason") or ""), + slowdown_reason=str(data.get("slowdown_reason") or ""), tool_count=int(data.get("tool_count") or 0), )) if records: @@ -383,10 +382,9 @@ def _append_task_run(self, log: EvolutionLog) -> None: with open(self._task_runs_file, "a", encoding="utf-8") as f: f.write(json.dumps({ "timestamp": log.timestamp, - "summary": log.summary, - "meaningful": log.meaningful, + "work": log.work, "recommend_slowdown": log.recommend_slowdown, - "reason": log.reason, + "slowdown_reason": log.slowdown_reason, "tool_count": log.tool_count, }, ensure_ascii=False) + "\n") # Trim to the last _TASK_RUNS_MAX records (rewrite in place only @@ -404,16 +402,6 @@ def _append_task_run(self, log: EvolutionLog) -> None: self.name, exc, ) - 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. @@ -427,12 +415,14 @@ async def run(self) -> None: ) while self._running: - # Saturation → wait at the heartbeat interval (low-frequency full - # cycle, rant 2026-08-09T09:35:55); otherwise the normal interval. - # Manual trigger wakes immediately either way. Never skip a cycle. + # Slowdown → wait at the heartbeat interval (low-frequency full + # cycle, rant 2026-08-09T09:35:55; flag set by vibe check + # recommend_slowdown, rant 2026-08-20T10:58:55); otherwise the + # normal interval. Manual trigger wakes immediately either way. + # Never skip a cycle. wait_timeout = ( self._heartbeat_interval() - if self._saturation_heartbeat_active() + if self._slowdown_active # Rant 2026-08-09T13:16:36: exponential backoff while the # daemon is unreachable — stops the retry/window storm. else self._connect_backoff() @@ -440,7 +430,7 @@ async def run(self) -> None: # Diagnostic log (rant 2026-08-18T20:48:45): expose which # scheduling mode drove the wait — normal | heartbeat | backoff — # so the saturation/backoff state machine is traceable end-to-end. - if self._empty_cycles >= self._saturation_threshold(): + if self._slowdown_active: _mode = "heartbeat" elif self._connect_failures > 0: _mode = "backoff" @@ -468,17 +458,17 @@ async def run(self) -> None: # Normal scheduled run pass - # Manual triggers always reset the saturation counter; otherwise - # saturated ticks keep running full cycles at heartbeat cadence. + # Manual triggers always clear the slowdown flag — the host + # manually paying attention means back to high frequency + # (rant 2026-08-20T10:58:55, host-confirmed semantics). if manual_trigger: - if self._empty_cycles >= self._saturation_threshold(): + if self._slowdown_active: self._logger.info( "TaskHandler[%s]: resumed via manual trigger " - "(was in saturation at %d empty cycles)", - self.name, self._empty_cycles, + "(was throttled at heartbeat cadence)", + self.name, ) - self._empty_cycles = 0 - self._slowdown_hits = 0 + self._slowdown_active = False self._save_saturation_state() self._logger.debug("TaskHandler[%s] tick", self.name) @@ -531,33 +521,30 @@ def status(self) -> dict: # GUI tasks panel can show when a task last ran, what it did, and # whether it is being throttled (saturation). All data is in-memory / # on disk already — no extra I/O beyond the in-memory evolutions list. + # Rant 2026-08-20T10:58:55: last_cycle_summary deleted (GUI primary + # list no longer uses it since rant 2026-08-19T18:25:14); last_run_at + # is derived from the restored evolutions and survives restarts via + # the task-runs JSONL. last_run_at: str | None = None - last_cycle_summary: str | None = None if self.evolutions: - last = self.evolutions[-1] - last_run_at = last.timestamp - # rant 2026-08-19T07:06:45 (host-finalized): NO machine impact - # fallback — empty summary shows as None (GUI renders "-"). - last_cycle_summary = last.summary if last.summary else None + last_run_at = self.evolutions[-1].timestamp # rant 2026-08-18T21:32:32: last 5 run records for the GUI accordion - # subtable — {timestamp, summary, impact, meaningful, - # recommend_slowdown, tool_count}; all in-memory, no extra I/O. + # subtable — {timestamp, work, impact, recommend_slowdown, + # slowdown_reason, tool_count} (fields unified rant 2026-08-20T10:58:55); + # all in-memory, no extra I/O. recent_runs = [] for log in self.evolutions[-5:]: recent_runs.append({ "timestamp": log.timestamp, - "summary": log.summary, + "work": log.work, "impact": list(log.impact), - "meaningful": log.meaningful, "recommend_slowdown": log.recommend_slowdown, - "reason": log.reason, + "slowdown_reason": log.slowdown_reason, "tool_count": log.tool_count, }) saturation = { - "empty_cycles": self._empty_cycles, - "threshold": self._saturation_threshold(), "heartbeat_interval": self._heartbeat_interval(), - "heartbeat_active": self._saturation_heartbeat_active(), + "heartbeat_active": self._slowdown_active, } return { "name": self.name, @@ -565,7 +552,6 @@ def status(self) -> dict: "next_run_in_seconds": remaining, "interval": self.interval, "last_run_at": last_run_at, - "last_cycle_summary": last_cycle_summary, "recent_runs": recent_runs, "saturation": saturation, } @@ -599,28 +585,19 @@ def _saturation_heartbeat_active(self) -> bool: """Whether this tick should run at the low-frequency heartbeat interval instead of the normal interval. - Replaces the old complete saturation halt (rant 2026-08-09T09:35:55): - at/above the empty-cycle threshold the handler keeps running full - cycles, just at a reduced cadence — never skipping. Saturation - judgment depends ONLY on the empty-cycle count (rant 2026-08-18T20:32:07): - no upstream network check — recovery happens naturally when a cycle - produces output and the empty counter resets. + Rant 2026-08-20T10:58:55 (host design-finalized): the flag is set + solely by the agent's vibe-check recommend_slowdown (true → throttled) + and cleared by a false recommendation or a manual trigger. Kept as a + method for call-site compatibility. """ - if self._empty_cycles < self._saturation_threshold(): - return False - self._logger.info( - "TaskHandler[%s]: saturation (%d empty cycles) — " - "running full cycle at heartbeat interval (%ds) — never halting", - self.name, self._empty_cycles, self._heartbeat_interval(), - ) - return True + return self._slowdown_active 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. + conservatively leaves the slowdown state unchanged. """ try: await ws.send(json.dumps({ @@ -650,10 +627,9 @@ async def _request_vibe_check(self, ws, prompt: str, completion_summary: str) -> return None result = frame.get("result") or {} return { - "meaningful": result.get("meaningful"), - "recommend_slowdown": result.get("recommend_slowdown"), - "reason": result.get("reason", ""), - "done": result.get("done", ""), + "work": str(result.get("work", "")), + "recommend_slowdown": bool(result.get("recommend_slowdown")), + "slowdown_reason": str(result.get("slowdown_reason", "")), } except Exception: self._logger.debug("TaskHandler[%s]: vibe check failed", self.name, exc_info=True) @@ -780,65 +756,40 @@ async def _run_evolution_cycle(self) -> None: except Exception: pass - # 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. + # Slowdown state machine (rant 2026-08-20T10:58:55, host design-finalized): + # the agent's recommend_slowdown is the ONLY slowdown switch — the old + # empty-cycle counter / slowdown-vote / threshold machinery is deleted. + # - vibe success + recommend=true → _slowdown_active=True (next run + # at heartbeat interval, persisted) + # - vibe success + recommend=false → _slowdown_active=False (normal + # interval, persisted) + # - vibe unavailable / truncated / aborted → state unchanged + # (conservative: neither throttle nor restore on a bad signal) 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 + if recommend: + self._slowdown_active = True self._save_saturation_state() self._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 "", + "TaskHandler[%s]: agent recommends slowdown — next run at " + "heartbeat interval (%ds)", + self.name, self._heartbeat_interval(), ) - elif meaningful is True: - if self._empty_cycles > 0 or self._slowdown_hits > 0: + else: + if self._slowdown_active: self._logger.info( - "TaskHandler[%s]: agent reported meaningful work, " - "resetting empty streak (%d) + slowdown votes (%d)", - self.name, self._empty_cycles, self._slowdown_hits, + "TaskHandler[%s]: agent recommends normal cadence — " + "restoring interval (%ds)", + self.name, self.interval, ) - self._empty_cycles = 0 - self._slowdown_hits = 0 + self._slowdown_active = False self._save_saturation_state() elif not error and not truncated: - # vibe check failed/timeout — conservative: don't count, don't reset + # vibe check failed/timeout — conservative: state unchanged self._logger.info( - "TaskHandler[%s]: vibe check unavailable — empty streak unchanged", + "TaskHandler[%s]: vibe check unavailable — slowdown state unchanged", self.name, ) - else: - 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]})" - self._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 # them would inflate the evolution count (GUI growth card / toast, @@ -863,34 +814,32 @@ async def _run_evolution_cycle(self) -> None: if truncated: impact.append("truncated=max-tool-rounds") - # rant 2026-08-18T21:32:32: persist the agent's own summary of what - # meaningful work was done (vibe check "done" field) + the vibe flags, - # so the GUI task recent-runs table shows real value, not a machine - # string. Rant 2026-08-19T07:06:45 (host-finalized): NO fallback to the - # completion first line — summary uses only the vibe check "done" - # field; empty stays empty (GUI shows "-"), never a machine fallback. - summary = "" - meaningful = None + # rant 2026-08-18T21:32:32: persist the agent's own natural-language + # summary of what was done this cycle (vibe check "work" field) + the + # vibe flags, so the GUI task recent-runs table shows real value, not + # a machine string. Rant 2026-08-19T07:06:45 (host-finalized): NO + # fallback to the completion first line — work uses only the vibe + # check "work" field; empty stays empty (GUI shows "-"). Field names + # unified to work/recommend_slowdown/slowdown_reason (rant + # 2026-08-20T10:58:55). + work = "" recommend = False - reason = "" + slowdown_reason = "" if vibe_result is not None: - summary = str(vibe_result.get("done") or "")[:500] - meaningful = vibe_result.get("meaningful") + work = str(vibe_result.get("work") or "")[:500] recommend = bool(vibe_result.get("recommend_slowdown")) - # rant 2026-08-19T18:25:14: keep the vibe check's reason for the - # meaningful/slowdown judgment so the GUI secondary list can show - # 原因 (time/work/throttle/reason). - reason = str(vibe_result.get("reason") or "")[:300] + # rant 2026-08-19T18:25:14: keep the vibe check's reason so the GUI + # secondary list can show 原因 (time/work/throttle/reason). + slowdown_reason = str(vibe_result.get("slowdown_reason") or "")[:300] log = EvolutionLog( timestamp=cycle_ts, trigger=f"evolution-{self.name}-{cycle_ts}", impact=impact, operations=["llm-reflection", "tool-execution", "self-improvement"], - summary=summary, - meaningful=meaningful, + work=work, recommend_slowdown=recommend, - reason=reason, + slowdown_reason=slowdown_reason, tool_count=tool_count, ) # Rant 2026-08-19T14:18:40 — no more evolution-*.json single-file diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 48005dff..fd2a4eab 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -543,11 +543,10 @@ def test_evolution_handler_status_last_run_fields(): st = handler.status() assert st["name"] == "test" assert st["last_run_at"] is None - assert st["last_cycle_summary"] is None - assert st["saturation"]["empty_cycles"] == 0 - assert "threshold" in st["saturation"] + # rant 2026-08-20T10:58:55: last_cycle_summary deleted; saturation is + # {heartbeat_interval, heartbeat_active} only + assert st["saturation"]["heartbeat_active"] is False assert "heartbeat_interval" in st["saturation"] - assert "heartbeat_active" in st["saturation"] # rant 2026-08-18T21:32:32: recent_runs present, empty before any run assert st["recent_runs"] == [] # after one evolution → last-run populated from the latest log @@ -557,41 +556,34 @@ def test_evolution_handler_status_last_run_fields(): impact=["tools-executed=24", "cycle-complete"], operations=["llm-reflection", "tool-execution"], )) - handler._empty_cycles = 3 + handler._slowdown_active = True st = handler.status() assert st["last_run_at"] == "2026-08-18T10:00:00" - # rant 2026-08-19T07:06:45: empty summary → None (no machine impact fallback) - assert st["last_cycle_summary"] is None - assert st["saturation"]["empty_cycles"] == 3 + assert st["saturation"]["heartbeat_active"] is True assert len(st["recent_runs"]) == 1 r0 = st["recent_runs"][0] assert r0["timestamp"] == "2026-08-18T10:00:00" - assert r0["summary"] == "" + assert r0["work"] == "" assert r0["impact"] == ["tools-executed=24", "cycle-complete"] - assert r0["meaningful"] is None assert r0["recommend_slowdown"] is False - assert r0["reason"] == "" + assert r0["slowdown_reason"] == "" assert r0["tool_count"] == 0 - # agent summary preferred over machine impact tags + # agent work summary preferred over machine impact tags handler.evolutions.append(EvolutionLog( timestamp="2026-08-18T11:00:00", trigger="evolution-test-ts", impact=["tools-executed=5", "cycle-complete"], operations=[], - summary="修了 stop_all 双实例根因,提交 PR #854", - meaningful=True, + work="修了 stop_all 双实例根因,提交 PR #854", recommend_slowdown=False, - reason="meaningful work done", + slowdown_reason="meaningful work done", tool_count=5, )) st = handler.status() - assert st["last_cycle_summary"] == "修了 stop_all 双实例根因,提交 PR #854" assert len(st["recent_runs"]) == 2, "recent_runs holds last 5 runs" - assert st["recent_runs"][1]["summary"] == "修了 stop_all 双实例根因,提交 PR #854" - assert st["recent_runs"][1]["meaningful"] is True - assert st["recent_runs"][1]["reason"] == "meaningful work done" + assert st["recent_runs"][1]["work"] == "修了 stop_all 双实例根因,提交 PR #854" + assert st["recent_runs"][1]["slowdown_reason"] == "meaningful work done" assert st["recent_runs"][1]["tool_count"] == 5 - assert st["recent_runs"][0]["meaningful"] is None def test_evolution_handler_recent_runs_capped_at_five(): @@ -635,10 +627,9 @@ def test_task_handler_task_runs_persist_across_restart(tmp_path): h1.evolutions.append(EvolutionLog( timestamp="2026-08-19T20:10:00", trigger="evolution-emrg-task-ts", - summary="fixed vibe-check 400, submitted PR #874", - meaningful=True, + work="fixed vibe-check 400, submitted PR #874", recommend_slowdown=False, - reason="meaningful work done", + slowdown_reason="", tool_count=7, )) h1._append_task_run(h1.evolutions[-1]) @@ -646,10 +637,9 @@ def test_task_handler_task_runs_persist_across_restart(tmp_path): h1.evolutions.append(EvolutionLog( timestamp="2026-08-19T20:20:00", trigger="evolution-emrg-task-ts2", - summary="", - meaningful=False, + work="", recommend_slowdown=True, - reason="too many empty cycles", + slowdown_reason="长期无产出", tool_count=0, )) h1._append_task_run(h1.evolutions[-1]) @@ -659,21 +649,20 @@ def test_task_handler_task_runs_persist_across_restart(tmp_path): assert len(h2.evolutions) == 2 first = h2.evolutions[0] assert first.timestamp == "2026-08-19T20:10:00" - assert first.summary == "fixed vibe-check 400, submitted PR #874" - assert first.meaningful is True - assert first.reason == "meaningful work done" + assert first.work == "fixed vibe-check 400, submitted PR #874" + assert first.recommend_slowdown is False + assert first.slowdown_reason == "" assert first.tool_count == 7 second = h2.evolutions[1] - assert second.summary == "" - assert second.meaningful is False + assert second.work == "" assert second.recommend_slowdown is True - assert second.reason == "too many empty cycles" + assert second.slowdown_reason == "长期无产出" # GUI secondary list shows the restored records runs = h2.status()["recent_runs"] assert [r["timestamp"] for r in runs] == [ "2026-08-19T20:10:00", "2026-08-19T20:20:00", ] - assert runs[1]["reason"] == "too many empty cycles" + assert runs[1]["slowdown_reason"] == "长期无产出" assert runs[1]["recommend_slowdown"] is True # JSONL file exists under logs/task-runs/.jsonl f = tmp_path / "logs" / "task-runs" / "emrg-task.jsonl" @@ -695,7 +684,7 @@ def test_task_handler_task_runs_capped_at_fifty(tmp_path): for i in range(60): h1.evolutions.append(EvolutionLog( timestamp=f"2026-08-19T20:{i % 60:02d}:00", - summary=f"run-{i}", + work=f"run-{i}", tool_count=i, )) h1._append_task_run(h1.evolutions[-1]) @@ -706,8 +695,8 @@ def test_task_handler_task_runs_capped_at_fifty(tmp_path): # fresh handler restores the most recent 50 h2 = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) assert len(h2.evolutions) == 50 - assert h2.evolutions[-1].summary == "run-59" - assert h2.evolutions[0].summary == "run-10" + assert h2.evolutions[-1].work == "run-59" + assert h2.evolutions[0].work == "run-10" finally: mod.config_dir = orig @@ -721,13 +710,13 @@ def test_task_handler_task_runs_corrupt_file_ignored(tmp_path): runs_dir = tmp_path / "logs" / "task-runs" runs_dir.mkdir(parents=True, exist_ok=True) (runs_dir / "emrg-task.jsonl").write_text( - "not-json-at-all\n{broken json\n{\"timestamp\": \"ok\", \"summary\": \"kept\"}\n", + "not-json-at-all\n{broken json\n{\"timestamp\": \"ok\", \"work\": \"kept\"}\n", encoding="utf-8", ) handler = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) # corrupt lines skipped; valid line kept assert len(handler.evolutions) == 1 - assert handler.evolutions[0].summary == "kept" + assert handler.evolutions[0].work == "kept" finally: mod.config_dir = orig @@ -744,12 +733,12 @@ def test_task_handler_task_runs_write_failure_tolerated(tmp_path): runs_dir = tmp_path / "logs" / "task-runs" runs_dir.mkdir(parents=True, exist_ok=True) (runs_dir / "emrg-task.jsonl").mkdir() # dir where the file should be - log = EvolutionLog(timestamp="2026-08-19T20:30:00", summary="x") + log = EvolutionLog(timestamp="2026-08-19T20:30:00", work="x") handler.evolutions.append(log) # must not raise; cycle continues with the in-memory record handler._append_task_run(log) assert len(handler.evolutions) == 1 - assert handler.evolutions[0].summary == "x" + assert handler.evolutions[0].work == "x" finally: mod.config_dir = orig @@ -1316,150 +1305,169 @@ def __getitem__(self, key): def test_evolution_cycle_truncated_not_empty_not_complete(tmp_path): - """Truncated done frame → flagged truncated, NOT an empty cycle, impact reflects it.""" + """Truncated done frame → flagged truncated, NOT a complete cycle, slowdown + state untouched (no vibe signal from a truncated round).""" handler, captured = _make_cycle_handler(tmp_path, frames=[ {"tool_name": "bash"}, {"request_id": "r1", "content": "Exceeded maximum tool call rounds (270).", "done": True, "delta": False, "session_id": "s"}, ]) asyncio.run(handler._run_evolution_cycle()) - assert handler._empty_cycles == 0, \ - "truncated cycle must not advance the idle-halt backoff" + assert handler._slowdown_active is False, \ + "truncated cycle must not touch the slowdown state (no vibe signal)" impact = captured["log"].impact assert any("truncated" in i for i in impact), impact assert "truncated=max-tool-rounds" in impact, impact assert not any(i.endswith("-complete") for i in impact), impact -def test_evolution_cycle_complete_agent_says_not_meaningful_is_empty(tmp_path): - """Clean completion + agent vibe check meaningful=false → empty cycle. - - Rant 2026-08-17T11:39:19: the AGENT (task_vibe_check structured answer) - decides emptiness, not git HEAD. - """ +def test_evolution_cycle_complete_agent_recommends_no_slowdown(tmp_path): + """Clean completion + vibe work empty + recommend_slowdown=false → normal + cadence maintained, work stays empty (rant 2026-08-20T10:58:55: the vibe + check's recommend_slowdown is the ONLY slowdown switch).""" handler, captured = _make_cycle_handler(tmp_path, frames=[ {"request_id": "r1", "content": "Done", "done": True, "delta": False, "session_id": "s"}, {"type": "vibe_check_result", "ok": True, - "result": {"meaningful": False, "recommend_slowdown": False, - "reason": "nothing to evolve"}}, + "result": {"work": "", "recommend_slowdown": False, + "slowdown_reason": "nothing to evolve"}}, ]) asyncio.run(handler._run_evolution_cycle()) - assert handler._empty_cycles == 1, \ - "agent-reported meaningless complete cycle is counted as empty" - impact = captured["log"].impact + assert handler._slowdown_active is False + log = captured["log"] + impact = log.impact assert any(i.endswith("-complete") for i in impact), impact assert any(i.startswith("cycle-") for i in impact), \ f"impact tag uses new cycle- prefix (rant 2026-08-12T18:03:26), got {impact}" assert "truncated=max-tool-rounds" not in impact, impact + assert log.work == "", "empty work stays empty (no completion fallback)" + assert log.recommend_slowdown is False -def test_evolution_cycle_complete_agent_says_meaningful_resets_streak(tmp_path): - """Agent reports meaningful work → empty streak + slowdown votes reset. - - A round that produced value (analysis/memory/decision without a commit) - must NOT count as empty — the git-HEAD heuristic's core false positive. - """ +def test_evolution_cycle_agent_work_restores_normal_cadence(tmp_path): + """Agent reports work + recommend_slowdown=false → a throttled handler is + restored to normal cadence; the work is persisted (rant 2026-08-20T10:58:55 + — recommend=false is the restore signal, no counter/vote machinery).""" handler, captured = _make_cycle_handler(tmp_path, frames=[ {"request_id": "r1", "content": "Analyzed the issue and wrote memory", "done": True, "delta": False, "session_id": "s"}, {"type": "vibe_check_result", "ok": True, - "result": {"meaningful": True, "recommend_slowdown": False, - "reason": "completed analysis", - "done": "分析了 scheduler 空转判定 bug,写了 memory 记录"}}, + "result": {"work": "分析了 scheduler 空转判定 bug,写了 memory 记录", + "recommend_slowdown": False, + "slowdown_reason": ""}}, ]) - handler._empty_cycles = 5 - handler._slowdown_hits = 2 + handler._slowdown_active = True # previously throttled asyncio.run(handler._run_evolution_cycle()) - assert handler._empty_cycles == 0, "meaningful work resets the empty streak" - assert handler._slowdown_hits == 0, "meaningful work resets slowdown votes" + assert handler._slowdown_active is False, \ + "recommend=false restores the normal cadence" assert "log" in captured - # rant 2026-08-18T21:32:32: agent's natural-language summary persisted log = captured["log"] - assert log.summary == "分析了 scheduler 空转判定 bug,写了 memory 记录" - assert log.meaningful is True + assert log.work == "分析了 scheduler 空转判定 bug,写了 memory 记录" assert log.recommend_slowdown is False + assert log.slowdown_reason == "" assert log.tool_count == 0 -def test_evolution_cycle_log_summary_no_completion_fallback(tmp_path): - """Rant 2026-08-19T07:06:45 (host-finalized): the summary uses ONLY the - vibe check "done" field — NO fallback to the completion first line. Empty - stays empty (GUI renders "-"), never a machine/rough fallback.""" +def test_evolution_cycle_log_work_no_completion_fallback(tmp_path): + """Rant 2026-08-19T07:06:45 (host-finalized): work uses ONLY the vibe + check "work" field — NO fallback to the completion first line. Empty stays + empty (GUI renders "-"), never a machine/rough fallback.""" handler, captured = _make_cycle_handler(tmp_path, frames=[ {"request_id": "r1", "content": "Reviewed PR and posted LGTM", "done": True, "delta": False, "session_id": "s"}, {"type": "vibe_check_result", "ok": True, - "result": {"meaningful": True, "recommend_slowdown": False, - "reason": "reviewed"}}, + "result": {"work": "", "recommend_slowdown": False, + "slowdown_reason": "reviewed"}}, ]) asyncio.run(handler._run_evolution_cycle()) log = captured["log"] - assert log.summary == "", \ - "missing done → summary stays empty (no completion fallback)" - assert log.meaningful is True + assert log.work == "", \ + "missing work → work stays empty (no completion fallback)" + assert log.recommend_slowdown is False - # vibe check entirely unavailable → summary stays empty, flags None/False + # vibe check entirely unavailable → work stays empty, flags False handler2, captured2 = _make_cycle_handler(tmp_path, frames=[ {"request_id": "r1", "content": "Done", "done": True, "delta": False, "session_id": "s"}, ]) asyncio.run(handler2._run_evolution_cycle()) log2 = captured2["log"] - assert log2.summary == "", "vibe unavailable → summary stays empty (no fallback)" - assert log2.meaningful is None + assert log2.work == "", "vibe unavailable → work stays empty (no fallback)" assert log2.recommend_slowdown is False assert log2.tool_count == 0 -def test_evolution_cycle_vibe_unavailable_streak_unchanged(tmp_path): - """Vibe check unavailable (timeout/failure) → counter neither advances nor resets. +def test_evolution_cycle_vibe_unavailable_state_unchanged(tmp_path): + """Vibe check unavailable (timeout/failure) → slowdown state unchanged. - Conservative: a failed question must not cause a wrong slowdown NOR a - wrong reset (rant 2026-08-17T11:39:19).""" + Conservative: a failed question must not cause a wrong throttle NOR a + wrong restore (rant 2026-08-20T10:58:55).""" handler, captured = _make_cycle_handler(tmp_path, frames=[ {"request_id": "r1", "content": "Done", "done": True, "delta": False, "session_id": "s"}, # no vibe_check_result frame → helper times out / connection closed ]) - handler._empty_cycles = 3 - handler._slowdown_hits = 1 + handler._slowdown_active = True asyncio.run(handler._run_evolution_cycle()) - assert handler._empty_cycles == 3, "vibe check failure must not advance the counter" - assert handler._slowdown_hits == 1, "vibe check failure must not reset votes" + assert handler._slowdown_active is True, \ + "vibe check failure must not touch the slowdown state" assert "log" in captured, "main task still completed normally" + assert captured["log"].work == "" + assert captured["log"].recommend_slowdown is False -def test_evolution_cycle_agent_recommend_slowdown_accumulates(tmp_path): - """recommend_slowdown votes accumulate; 3 votes tighten the threshold. +def test_evolution_cycle_recommend_slowdown_throttles(tmp_path): + """recommend_slowdown=true → _slowdown_active=True (heartbeat cadence); + the next cycle's recommend=false restores normal cadence (rant + 2026-08-20T10:58:55 — the vibe flag is the single switch).""" + handler, _ = _make_cycle_handler(tmp_path, frames=[ + {"request_id": "r1", "content": "Done", "done": True, + "delta": False, "session_id": "s"}, + {"type": "vibe_check_result", "ok": True, + "result": {"work": "", "recommend_slowdown": True, + "slowdown_reason": "长期无产出"}}, + ]) + asyncio.run(handler._run_evolution_cycle()) + assert handler._slowdown_active is True, \ + "recommend=true must throttle the next run to heartbeat cadence" + assert handler._saturation_heartbeat_active() is True + assert handler._heartbeat_interval() == 480 # 60s task → 8 min - The saturation threshold drops from 30 to 10 when the agent keeps saying - the task has no value (rant 2026-08-17T11:39:19).""" - for i in range(3): - handler, _ = _make_cycle_handler(tmp_path, frames=[ - {"request_id": "r1", "content": "Done", "done": True, - "delta": False, "session_id": "s"}, - {"type": "vibe_check_result", "ok": True, - "result": {"meaningful": False, "recommend_slowdown": True, - "reason": "long-term no value"}}, - ]) - asyncio.run(handler._run_evolution_cycle()) - assert handler._slowdown_hits == i + 1, handler._slowdown_hits - assert handler._empty_cycles == i + 1, handler._empty_cycles - # 3 votes → tightened threshold (30 → 10) - assert handler._saturation_threshold() == 10, "3 slowdown votes must tighten the threshold" - assert handler._saturation_threshold() < handler._IDLE_HALT_THRESHOLD + # second cycle: agent says value again → restore + handler2, captured2 = _make_cycle_handler(tmp_path, frames=[ + {"request_id": "r1", "content": "Done", "done": True, + "delta": False, "session_id": "s"}, + {"type": "vibe_check_result", "ok": True, + "result": {"work": "merged PR #880", "recommend_slowdown": False, + "slowdown_reason": ""}}, + ]) + asyncio.run(handler2._run_evolution_cycle()) + assert handler2._slowdown_active is False + assert captured2["log"].recommend_slowdown is False -def test_saturation_threshold_defaults_to_idle_halt(tmp_path): - """Below 3 slowdown votes the threshold stays at _IDLE_HALT_THRESHOLD (30).""" - handler = _make_handler(tmp_path, project="", path=str(tmp_path)) - assert handler._slowdown_hits == 0 - assert handler._saturation_threshold() == handler._IDLE_HALT_THRESHOLD - handler._slowdown_hits = 2 - assert handler._saturation_threshold() == handler._IDLE_HALT_THRESHOLD - handler._slowdown_hits = 3 - assert handler._saturation_threshold() == 10 +def test_slowdown_state_persisted_across_restart(tmp_path): + """_slowdown_active survives a daemon restart via the saturation file + (~/.emrg/saturation/.json, rant 2026-08-20T10:58:55).""" + from emrg.server import scheduler as mod + orig = mod.config_dir + try: + mod.config_dir = lambda: tmp_path + h1 = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) + h1._slowdown_active = True + h1._save_saturation_state() + # "daemon restart": a fresh handler over the same config_dir + h2 = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) + assert h2._slowdown_active is True, \ + "throttled state restored from disk" + # old-format file (no slowdown_active) reads as False — no migration + (tmp_path / "saturation" / "other.json").write_text( + '{"empty_cycles": 3, "slowdown_hits": 2}', encoding="utf-8") + h4 = TaskHandler(name="other", config={}, interval=60, identity=InstanceIdentity()) + assert h4._slowdown_active is False, \ + "legacy saturation files simply read as not throttled" + finally: + mod.config_dir = orig def test_evolution_cycle_aborted_error_not_counted(tmp_path): @@ -1470,19 +1478,21 @@ def test_evolution_cycle_aborted_error_not_counted(tmp_path): asyncio.run(handler._run_evolution_cycle()) assert "log" not in captured, "aborted cycle must not write an evolution log" assert handler.evolutions == [], "aborted cycle must not append to evolutions" - assert handler._empty_cycles == 0, \ - "aborted cycle must not advance the idle-halt backoff (agent never ran)" + assert handler._slowdown_active is False, \ + "aborted cycle must not touch the slowdown state (agent never ran)" -def test_evolution_cycle_aborted_resets_empty_streak(tmp_path): - """Aborted cycle resets a pre-existing empty streak (blocked ≠ NTE).""" +def test_evolution_cycle_aborted_leaves_slowdown_state(tmp_path): + """Aborted cycle leaves a pre-existing throttle flag untouched (blocked ≠ + a vibe signal; rant 2026-08-20T10:58:55 conservative rule).""" handler, captured = _make_cycle_handler(tmp_path, frames=[ {"error": "session busy"}, ]) - handler._empty_cycles = 5 + handler._slowdown_active = True asyncio.run(handler._run_evolution_cycle()) assert "log" not in captured - assert handler._empty_cycles == 0, "abort resets the streak (not a real empty cycle)" + assert handler._slowdown_active is True, \ + "abort must not clear the throttle flag (no vibe signal received)" # ── Connect-failure alerting (G129, rant 2026-08-09T08:03:46) ───── @@ -1507,7 +1517,7 @@ async def _refuse(): asyncio.run(handler._run_evolution_cycle()) assert "log" not in captured, "connect failure must not write an evolution log" assert handler.evolutions == [] - assert handler._empty_cycles == 0, "connect failure ≠ empty cycle" + assert handler._slowdown_active is False, "connect failure ≠ throttle signal" assert handler._connect_failures == handler._CONNECT_FAIL_ALERT # 第 3 次(达到阈值)必须出现 ERROR 告警,且提示检查 port 文件 error_msgs = [r.message for r in caplog.records if r.levelno >= logging.ERROR] @@ -1619,8 +1629,9 @@ def _original_connect_to_server(): # ── Saturation heartbeat: slow down, never stop (rant 2026-08-09T09:35:55) ─ # The old complete halt (skipping scheduled runs) is replaced by -# low-frequency full cycles: saturated ticks still run, just at the heartbeat -# interval. Upstream advance auto-resumes (counter reset, normal frequency). +# low-frequency full cycles: throttled ticks still run, just at the heartbeat +# interval. The throttle flag is set solely by the vibe check's +# recommend_slowdown (rant 2026-08-20T10:58:55). def test_heartbeat_interval_formula(tmp_path): """heartbeat = max(interval, min(interval*8, 8h)); long intervals unchanged.""" @@ -1639,69 +1650,75 @@ def test_heartbeat_interval_formula(tmp_path): assert handler._heartbeat_interval() == expected, (interval, expected) -def test_saturation_heartbeat_active_true_at_threshold(tmp_path): - """At/above threshold → heartbeat cadence (not skip), no network (rant - 2026-08-18T20:32:07 — upstream check removed).""" +def test_saturation_heartbeat_active_true_when_throttled(tmp_path): + """Throttle flag on → heartbeat cadence (not skip), no network (rant + 2026-08-18T20:32:07 — upstream check removed; flag from vibe check, rant + 2026-08-20T10:58:55).""" handler = _make_handler(tmp_path, project="", path=str(tmp_path)) - handler._empty_cycles = 30 # == _IDLE_HALT_THRESHOLD + handler._slowdown_active = True assert handler._saturation_heartbeat_active() is True - assert handler._empty_cycles == 30 # counter untouched assert handler._heartbeat_interval() == 480 # 60s task → 8 min def test_saturation_heartbeat_log_message_no_skip(tmp_path, caplog): - """Saturation log must say heartbeat, never 'skipping scheduled run'.""" + """A throttled cycle logs 'heartbeat interval', never 'skipping scheduled run'.""" import logging - handler = _make_handler(tmp_path, project="", path=str(tmp_path)) - handler._empty_cycles = 30 + handler, captured = _make_cycle_handler(tmp_path, frames=[ + {"request_id": "r1", "content": "Done", "done": True, + "delta": False, "session_id": "s"}, + {"type": "vibe_check_result", "ok": True, + "result": {"work": "", "recommend_slowdown": True, + "slowdown_reason": "长期无产出"}}, + ]) with caplog.at_level(logging.INFO, logger="emrg.server.scheduler"): - assert handler._saturation_heartbeat_active() is True + asyncio.run(handler._run_evolution_cycle()) + assert handler._slowdown_active is True msgs = " ".join(r.message for r in caplog.records) assert "skipping scheduled run" not in msgs, \ "old complete-halt log must not appear (rant 09:35:55)" - assert "heartbeat" in msgs and "never halting" in msgs, msgs + assert "heartbeat" in msgs, msgs + assert "log" in captured, "throttled tick must still run a full cycle" def test_saturation_heartbeat_makes_no_network_calls(tmp_path): """Saturation judgment never touches the network (rant 2026-08-18T20:32:07 — the old _remote_advanced ls-remote blocked the event loop; the check is - gone entirely, recovery happens via cycle output resetting the counter). + gone entirely, recovery happens via the next vibe check). scheduler no longer imports subprocess at all (rant 2026-08-19T14:20:52 deleted the self-heal git machinery) — no subprocess can be called.""" from emrg.server import scheduler as mod handler = _make_handler(tmp_path, project="", path=str(tmp_path)) - handler._empty_cycles = 30 + handler._slowdown_active = True assert not hasattr(mod, "subprocess"), \ "scheduler must not import subprocess anymore (self-heal deleted)" assert handler._saturation_heartbeat_active() is True - assert handler._empty_cycles == 30 -def test_saturation_heartbeat_false_below_threshold(tmp_path): - """Below threshold → normal interval (remote state irrelevant).""" +def test_saturation_heartbeat_false_when_normal(tmp_path): + """No throttle flag → normal interval (remote state irrelevant).""" handler = _make_handler(tmp_path, project="", path=str(tmp_path)) - handler._empty_cycles = 10 + handler._slowdown_active = False assert handler._saturation_heartbeat_active() is False - assert handler._empty_cycles == 10 -def test_saturated_tick_still_runs_full_cycle(tmp_path): - """Saturated handler runs a full cycle (never skipped) at heartbeat.""" +def test_throttled_tick_still_runs_full_cycle(tmp_path): + """Throttled handler runs a full cycle (never skipped) at heartbeat; a + recommend=false vibe result clears the throttle afterwards.""" handler, captured = _make_cycle_handler(tmp_path, frames=[ {"request_id": "r1", "content": "Done", "done": True, "delta": False, "session_id": "s"}, {"type": "vibe_check_result", "ok": True, - "result": {"meaningful": False, "recommend_slowdown": False, - "reason": "nothing to evolve"}}, + "result": {"work": "reviewed PR #879", "recommend_slowdown": False, + "slowdown_reason": ""}}, ]) - handler._empty_cycles = 30 # saturated + handler._slowdown_active = True # throttled asyncio.run(handler._run_evolution_cycle()) - assert "log" in captured, "saturated tick must still run a full cycle" - assert handler._empty_cycles == 31, \ - "NTE cycle during saturation keeps incrementing (heartbeat continues)" + assert "log" in captured, "throttled tick must still run a full cycle" + assert handler._slowdown_active is False, \ + "recommend=false restores normal cadence (heartbeat continues until then)" def test_list_tasks_logs_slow_handler(tmp_path, caplog): diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index d140c4c0..684c7c0d 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -189,7 +189,8 @@ class TestWSVibeCheck: The scheduler replaces its git-HEAD empty-cycle heuristic with an agent answer: the daemon runs a single Ask-mode LLM call (no tools, no history) - and returns a strict-JSON {meaningful, recommend_slowdown, reason} result. + and returns a strict-JSON {work, recommend_slowdown, slowdown_reason} + result (fields unified rant 2026-08-20T10:58:55, meaningful deleted). """ def test_vibe_check_returns_structured_result(self): @@ -199,7 +200,7 @@ async def _test(): try: async def fake_chat(messages, tools=None): # echo back a strict-JSON answer; fenced JSON tolerated - return {"content": '```json\n{"meaningful": false, "recommend_slowdown": true, "reason": "长期无产出", "done": "分析了双实例根因,提交 PR #854"}\n```'} + return {"content": '```json\n{"work": "分析了双实例根因,提交 PR #854", "recommend_slowdown": true, "slowdown_reason": "长期无产出"}\n```'} server.llm.chat = fake_chat ws = await connect_to_server() @@ -216,12 +217,10 @@ async def fake_chat(messages, tools=None): assert data.get("type") == "vibe_check_result" assert data.get("ok") is True result = data.get("result", {}) - assert result.get("meaningful") is False + # rant 2026-08-20T10:58:55: unified 3-field shape + assert result.get("work") == "分析了双实例根因,提交 PR #854" assert result.get("recommend_slowdown") is True - assert result.get("reason") == "长期无产出" - # rant 2026-08-18T21:32:32: natural-language "done" - # summary of what meaningful work was done this cycle - assert result.get("done") == "分析了双实例根因,提交 PR #854" + assert result.get("slowdown_reason") == "长期无产出" # the ask must carry the fixed system prompt + no tools sent = server.llm.chat assert sent is fake_chat @@ -263,7 +262,7 @@ async def _test(): async def fake_chat(messages, tools=None): seen["messages"] = messages - return {"content": '{"meaningful": true, "recommend_slowdown": false, "reason": "确实做了工作", "done": "fetch 上游 + 分析 PR + 写 memory"}' + return {"content": '{"work": "fetch 上游 + 分析 PR + 写 memory", "recommend_slowdown": false, "slowdown_reason": ""}' } server.llm.chat = fake_chat @@ -281,7 +280,7 @@ async def fake_chat(messages, tools=None): data = json.loads(frame) assert data.get("ok") is True result = data.get("result", {}) - assert result.get("meaningful") is True + assert result.get("work") == "fetch 上游 + 分析 PR + 写 memory" # The LLM must have received the session history # messages (primary evidence), not just the summary. msgs = seen.get("messages", []) @@ -335,7 +334,7 @@ async def _test(): async def fake_chat(messages, tools=None): seen["messages"] = messages - return {"content": '{"meaningful": false, "recommend_slowdown": false, "reason": "nt", "done": ""}'} + return {"content": '{"work": "", "recommend_slowdown": false, "slowdown_reason": "nt"}'} server.llm.chat = fake_chat ws = await connect_to_server() @@ -365,14 +364,14 @@ async def fake_chat(messages, tools=None): await cleanup() asyncio.run(_test()) - def test_vibe_check_missing_done_field_is_compatible(self): - """Old models / old parsing omit 'done' → empty string, no crash.""" + def test_vibe_check_missing_fields_is_compatible(self): + """Old models / old parsing omit work/slowdown_reason → empty, no crash.""" async def _test(): with tempfile.TemporaryDirectory() as tmp: server, _, cleanup = await _boot_server(Path(tmp)) try: async def fake_chat(messages, tools=None): - return {"content": '{"meaningful": true, "recommend_slowdown": false, "reason": "ok"}'} + return {"content": '{"recommend_slowdown": false}'} server.llm.chat = fake_chat ws = await connect_to_server() @@ -389,8 +388,9 @@ async def fake_chat(messages, tools=None): assert data.get("type") == "vibe_check_result" assert data.get("ok") is True result = data.get("result", {}) - assert result.get("done") == "" - assert result.get("meaningful") is True + assert result.get("work") == "" + assert result.get("recommend_slowdown") is False + assert result.get("slowdown_reason") == "" finally: await ws.close() finally: