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
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request
- Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand)
- Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端"
- Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout)
- Unit tests `npm test` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711)
- Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/<name>.md` → fallback
- Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`)
Expand DownExpand 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` (983) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (987) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 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
1 change: 1 addition & 0 deletions emrg/gui/renderer/css/components.css
Original file line numberDiff line numberDiff line change
Expand Up@@ -822,6 +822,7 @@ dialog::backdrop {
}
.task-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 8px;
Expand Down
7 changes: 7 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2706,6 +2706,13 @@ test("rant 21:32:32:任务卡点击展开最近运行子表(时间/干了什
// 无 recent_runs → 占位文案
const emptyTxt = vm.runInContext(`document.getElementById("task-list").children[1].querySelector(".task-run-empty").textContent`, ctx);
assert.ok(emptyTxt.includes("暂无运行记录"), `无 recent_runs 应显示占位,实际: ${emptyTxt}`);
// rant 2026-08-19T20:49:52:.task-row 必须 flex-wrap —— 否则子表
// (flex-basis:100%) 被压到同一行右边,宿主实测"跑到右边了"
const taskCss = fs.readFileSync(path.join(__dirname, "..", "renderer", "css", "components.css"), "utf8");
const rowRule = taskCss.slice(taskCss.indexOf(".task-row"), taskCss.indexOf(".task-name"));
assert.ok(rowRule.includes("flex-wrap: wrap"), "task-row 应含 flex-wrap: wrap(子表换行到任务卡下方)");
const detailRule = taskCss.slice(taskCss.indexOf(".task-run-detail"), taskCss.indexOf(".task-run-detail.hidden"));
assert.ok(detailRule.includes("flex-basis: 100%"), "task-run-detail 应保持 flex-basis:100%(配合 flex-wrap 换行)");
});

test("P3:新增任务表单 —— 间隔 <60 客户端拒绝;≥60 提交 taskCreate", async () => {
Expand Down
106 changes: 103 additions & 3 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -218,6 +218,16 @@ def __init__(
self._logs_dir = config_dir() / "logs"
self._logs_dir.mkdir(parents=True, exist_ok=True)
self.evolutions: list[EvolutionLog] = []
# Rant 2026-08-19T20:50:36 (host Plan B): execution records persist to
# disk (~/.emrg/logs/task-runs/<task>.jsonl, append-only JSONL) so the
# GUI task recent-runs survive daemon restarts. In-memory
# self.evolutions stays the primary source (status() reads it); the
# JSONL is a durable copy restored on init (bounded to the last N).
self._task_runs_dir = self._logs_dir / "task-runs"
self._task_runs_file = self._task_runs_dir / f"{self.name}.jsonl"
restored = self._load_task_runs()
if restored:
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
Expand DownExpand Up@@ -308,6 +318,92 @@ def _save_saturation_state(self) -> None:
except Exception:
pass

# ── Task-run persistence (rant 2026-08-19T20:50:36, host Plan B) ──
# Execution records persist to ~/.emrg/logs/task-runs/<task>.jsonl
# (append-only JSONL, one line per cycle) so the GUI task recent-runs
# secondary list survives daemon restarts. self.evolutions (in-memory)
# stays the primary source for status(); the JSONL is a durable copy
# restored on init, bounded to the most recent _TASK_RUNS_MAX records.
_TASK_RUNS_MAX = 50

def _load_task_runs(self) -> list[EvolutionLog]:
"""Restore the most recent execution records from the task JSONL.

Fault-tolerant (rant 2026-08-19T20:50:36): a missing or corrupt file
yields an empty list (never raises); a single corrupt line is skipped
while the rest is kept.
"""
records: list[EvolutionLog] = []
try:
if self._task_runs_file.exists():
lines = self._task_runs_file.read_text(encoding="utf-8").splitlines()
for line in lines[-self._TASK_RUNS_MAX:]:
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except (ValueError, TypeError):
self._logger.warning(
"TaskHandler[%s]: skipping corrupt task-run line in %s",
self.name, self._task_runs_file,
)
continue
records.append(EvolutionLog(
timestamp=str(data.get("timestamp") or ""),
summary=str(data.get("summary") or ""),
meaningful=data.get("meaningful"),
recommend_slowdown=bool(data.get("recommend_slowdown")),
reason=str(data.get("reason") or ""),
tool_count=int(data.get("tool_count") or 0),
))
if records:
self._logger.info(
"TaskHandler[%s]: restored %d execution record(s) from %s",
self.name, len(records), self._task_runs_file,
)
except Exception:
# unreadable file → start empty, never crash the handler
records = []
self._logger.warning(
"TaskHandler[%s]: failed to read task-run file %s (starting empty)",
self.name, self._task_runs_file,
)
return records

def _append_task_run(self, log: EvolutionLog) -> None:
"""Append one execution record to the task JSONL (bounded append).

Writes a JSON line for the completed cycle, then trims the file to the
most recent _TASK_RUNS_MAX records. Fault-tolerant: a write failure
only logs a warning and never affects the running cycle.
"""
try:
self._task_runs_dir.mkdir(parents=True, exist_ok=True)
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,
"recommend_slowdown": log.recommend_slowdown,
"reason": log.reason,
"tool_count": log.tool_count,
}, ensure_ascii=False) + "\n")
# Trim to the last _TASK_RUNS_MAX records (rewrite in place only
# when over the cap, mirroring the old 27-file rotation).
try:
lines = self._task_runs_file.read_text(encoding="utf-8").splitlines()
if len(lines) > self._TASK_RUNS_MAX:
with open(self._task_runs_file, "w", encoding="utf-8") as f:
f.write("\n".join(lines[-self._TASK_RUNS_MAX:]) + "\n")
except Exception:
pass # trimming is best-effort; the append already succeeded
except Exception as exc:
self._logger.warning(
"TaskHandler[%s]: failed to persist task-run record: %s",
self.name, exc,
)

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

Expand DownExpand Up@@ -797,10 +893,14 @@ async def _run_evolution_cycle(self) -> None:
reason=reason,
tool_count=tool_count,
)
# Rant 2026-08-19T14:18:40 — no disk archival: the evolution log lives
# in the in-memory list only (GUI recent-runs + evolution_count both
# read self.evolutions); evolution-*.json was never consumed.
# Rant 2026-08-19T14:18:40 — no more evolution-*.json single-file
# archival (unconsumed). Rant 2026-08-19T20:50:36 (host Plan B):
# execution records persist as append-only JSONL per task
# (~/.emrg/logs/task-runs/<task>.jsonl) so the GUI recent-runs
# secondary list survives daemon restarts — a durable copy of the
# in-memory self.evolutions list, restored on init.
self.evolutions.append(log)
self._append_task_run(log)

def _build_evolution_prompt(self) -> str:
"""Build evolution prompt from a Jinja2 template.
Expand Down
140 changes: 140 additions & 0 deletions tests/test_scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -614,6 +614,146 @@ def test_evolution_handler_recent_runs_capped_at_five():
assert runs[-1]["timestamp"] == "2026-08-18T10:06:00"


# ── Task-run persistence (rant 2026-08-19T20:50:36, host Plan B) ─────
# Execution records append to ~/.emrg/logs/task-runs/<task>.jsonl so the GUI
# recent-runs survive daemon restarts; restored on handler init.


def test_task_handler_task_runs_persist_across_restart(tmp_path):
"""Appended run records are restored by a fresh handler (daemon restart).

Plan B (rant 2026-08-19T20:50:36): cycle records persist to
<config>/logs/task-runs/<task>.jsonl; a new TaskHandler over the same
config_dir loads them back into self.evolutions.
"""
from emrg.protocol import EvolutionLog
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.evolutions.append(EvolutionLog(
timestamp="2026-08-19T20:10:00",
trigger="evolution-emrg-task-ts",
summary="fixed vibe-check 400, submitted PR #874",
meaningful=True,
recommend_slowdown=False,
reason="meaningful work done",
tool_count=7,
))
h1._append_task_run(h1.evolutions[-1])
# second record
h1.evolutions.append(EvolutionLog(
timestamp="2026-08-19T20:20:00",
trigger="evolution-emrg-task-ts2",
summary="",
meaningful=False,
recommend_slowdown=True,
reason="too many empty cycles",
tool_count=0,
))
h1._append_task_run(h1.evolutions[-1])

# "daemon restart": a brand-new handler over the same config_dir
h2 = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity())
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.tool_count == 7
second = h2.evolutions[1]
assert second.summary == ""
assert second.meaningful is False
assert second.recommend_slowdown is True
assert second.reason == "too many empty cycles"
# 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]["recommend_slowdown"] is True
# JSONL file exists under logs/task-runs/<task>.jsonl
f = tmp_path / "logs" / "task-runs" / "emrg-task.jsonl"
assert f.exists()
lines = f.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 2
finally:
mod.config_dir = orig


def test_task_handler_task_runs_capped_at_fifty(tmp_path):
"""JSONL keeps only the most recent 50 records (bounded append)."""
from emrg.protocol import EvolutionLog
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())
for i in range(60):
h1.evolutions.append(EvolutionLog(
timestamp=f"2026-08-19T20:{i % 60:02d}:00",
summary=f"run-{i}",
tool_count=i,
))
h1._append_task_run(h1.evolutions[-1])

f = tmp_path / "logs" / "task-runs" / "emrg-task.jsonl"
lines = f.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 50, "file trimmed to the last 50 records"
# 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"
finally:
mod.config_dir = orig


def test_task_handler_task_runs_corrupt_file_ignored(tmp_path):
"""Corrupt/unreadable JSONL is ignored — handler starts empty, no crash."""
from emrg.server import scheduler as mod
orig = mod.config_dir
try:
mod.config_dir = lambda: 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",
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"
finally:
mod.config_dir = orig


def test_task_handler_task_runs_write_failure_tolerated(tmp_path):
"""A failed append only logs a warning — never breaks the running cycle."""
from emrg.protocol import EvolutionLog
from emrg.server import scheduler as mod
orig = mod.config_dir
try:
mod.config_dir = lambda: tmp_path
handler = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity())
# sabotage: make the target file path a directory → append raises
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")
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"
finally:
mod.config_dir = orig


def test_evolution_handler_default_owner():
"""When no git remote is detectable, falls back to EMRG defaults."""
handler = TaskHandler(
Expand Down
Loading