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
33 changes: 18 additions & 15 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ def _redact(value):
from emrg.tools.submit_rant_tool import SubmitRantTool
from emrg.skills.loader import load_skills
from emrg.skills.registry import ensure_catalog_file, load_catalog_skills, skill_is_managed
from emrg.server.rants import append_rant
from emrg.server.rants import append_rant, list_rants
from emrg.server.scheduler import TaskScheduler
from emrg.server import logcontext

Expand Down Expand Up @@ -3006,22 +3006,25 @@ async def _process_message(
elif msg_type == "list_rants":
# Rant panel (rant 2026-08-13T14:10:14 P4): read ~/.emrg/rants.jsonl,
# optional status filter (pending/in_progress/completed/"" = all).
#
# The store's own reader, not a second copy of it. This branch used to open
# the path itself behind `self._rants_log.exists()` — which is true for a
# FIFO, a socket and a device node, on every one of which the `open` blocks
# until a peer appears and **raises nothing**, so the `except OSError` below
# was never reached and the panel could never be answered again
# (issue #2097, found by sweeping the *predicate* rather than the previous
# cycle's file list). `_read_rants` refuses a subject of another kind by name
# — `NotARegularFile` is an `OSError`, so that failure still takes the branch
# this frame has always had for a read it cannot do.
#
# The copy was also a second *behaviour*, not only a second guard: it
# appended whatever `json.loads` returned, so a legacy array row or a bare
# scalar reached `r.get(...)` and raised `AttributeError` — the format drift
# of 2026-08-18 has a live crash path here, which the store's
# `_normalize_rant` has always converted or skipped.
try:
filter_status = str(msg.get("status", "") or "").strip()
rants = []
if self._rants_log.exists():
with open(self._rants_log, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
r = json.loads(line)
except json.JSONDecodeError:
continue
if filter_status and r.get("status", "pending") != filter_status:
continue
rants.append(r)
rants = list_rants(self._rants_log, status=filter_status or None)
# 时间倒序(最新在前,面板列表惯例)
rants.sort(key=lambda r: r.get("timestamp", ""), reverse=True)
await self._send(ws, {
Expand Down
60 changes: 57 additions & 3 deletions emrg/server/rants.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
from datetime import datetime
from pathlib import Path

from emrg.memory import NotARegularFile
from emrg.tools.base import non_regular_kind

# Canonical field order: timestamp → project → status → progress → completed → message
# (project right after timestamp per user feedback; message last)
_RANT_FIELDS = ("timestamp", "project", "status", "progress", "completed", "message")
Expand Down Expand Up @@ -46,11 +49,43 @@ def _normalize_rant(raw) -> dict | None:
return None


def _rant_log_refusal(rants_log: Path, kind: str) -> str:
"""The one reason the store refuses a subject, shared by its reader and its writer."""
return (
f"{rants_log} is {kind}, not a regular file. The rant log is opened directly, and "
f"opening this one blocks until a peer appears — a FIFO waits for a writer, a socket "
f"for a connection, a device never ends — so the call would never return, and neither "
f"would the daemon, which runs the rant tool on its event loop."
)


def _read_rants(rants_log: Path) -> list[dict]:
"""Read all rant entries, tolerantly converting legacy array rows to dicts."""
"""Read all rant entries, tolerantly converting legacy array rows to dicts.

The subject's **kind** is asked before it is opened, because opening it is what decides
whether this call returns at all: a FIFO blocks at the *open* until a writer appears and
raises nothing, so a caller's `except OSError` cannot bound the consequence. This runs on
the daemon's event loop, so the block is the whole server.

Measured 2026-10-11 (`cyc20261011-114739`) on `cf9304c7`, one child process per arm under
an 8 s cap: `_read_rants` against a FIFO **did not return in 8 s**, where the same call
with the log absent answered at once.

A subject that is not a regular file is **refused**, not reported as empty: `[]` already
means "there are no rants" and its callers act on that reading, so answering a FIFO with
it would state a falsehood about the host's own feedback channel. `NotARegularFile` is an
`OSError`, which is what every caller of this store already handles.
"""
rants: list[dict] = []
if not rants_log.exists():
try:
kind = non_regular_kind(rants_log)
except OSError:
# Absent (or unstattable) — the empty list this function has always answered a
# missing log with. `non_regular_kind` *stats*, so absence reaches us as an OSError
# rather than as `None`, and it must keep meaning "no rants", not "refuse".
return rants
if kind:
raise NotARegularFile(_rant_log_refusal(rants_log, kind))
with open(rants_log, encoding="utf-8") as f:
for line in f:
line = line.strip()
Expand All @@ -68,7 +103,26 @@ def _read_rants(rants_log: Path) -> list[dict]:

def _write_rants(rants_log: Path, rants: list[dict]) -> None:
"""Sort by timestamp ascending and rewrite the file (dict 6-field order,
ensure_ascii=False — the ONLY writer for rants.jsonl)."""
ensure_ascii=False — the ONLY writer for rants.jsonl).

Asks the subject's kind before opening it, for the reason :func:`_read_rants` states —
and the writer needs its own guard rather than inheriting the reader's:
`open(rants_log, "w")` on a FIFO blocks until a **reader** appears, so a guard on the
read alone would not remove the block, only move it here (`append_rant` reads and then
writes, so both are on one path).

Absence is the writer's **normal** case, not a refusal: `non_regular_kind` stats its
subject and `stat` raises on a missing path, and this function exists to create it. That
branch is why the `except OSError` below is empty — without it the stat would raise on
every fresh install, and a caller's `except OSError` would swallow it into a write that
silently did not happen.
"""
try:
kind = non_regular_kind(rants_log)
except OSError:
kind = None # absent: this writer is about to create it
if kind:
raise NotARegularFile(_rant_log_refusal(rants_log, kind))
rants.sort(key=lambda r: r.get("timestamp", ""))
rants_log.parent.mkdir(parents=True, exist_ok=True)
with open(rants_log, "w", encoding="utf-8") as f:
Expand Down
14 changes: 12 additions & 2 deletions emrg/tools/submit_rant_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
update_rant,
)
from emrg.server.tool_types import ToolDefinition, ToolResult
from emrg.tools.base import ToolExecutor
from emrg.tools.base import ToolExecutor, non_regular_kind

_ACTIONS = ("submit", "list", "update", "cleanup")

Expand Down Expand Up @@ -282,12 +282,22 @@ def _registered_project_names(self) -> list[str]:
The daemon registers projects there on first use; the list is the
authoritative source for the rant ``project`` field. Any read error
degrades to "no candidates" (validation is advisory, never blocking).

The subject's kind is asked before it is opened: `exists()` is true for a FIFO, and
`read_text` on one blocks at the open and raises nothing, so the
``except Exception`` below cannot bound the consequence — this is a tool, so the
block is the daemon's event loop. Advisory reads degrade rather than refuse, which is
this function's existing contract with a missing file.
"""
try:
import yaml

p = self._rants_log().parent / "projects.yml"
if not p.exists():
try:
kind = non_regular_kind(p)
except OSError:
return [] # absent (or unstattable) — "no candidates", as before
if kind:
return []
data = yaml.safe_load(p.read_text(encoding="utf-8"))
if isinstance(data, list):
Expand Down
100 changes: 100 additions & 0 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -4718,3 +4718,103 @@ def test_a_resume_at_a_directory_meta_answers_instead_of_raising(tmp_path):
assert "error" in frames[0], frames[0]
assert "a directory" in frames[0]["error"], frames[0]["error"]
assert "not a regular file" in frames[0]["error"], frames[0]["error"]


# ── the rant panel's own reader ─────────────────────────────────────
#
# The family section above ("the subject has to be a regular file") names the daemon's
# readers of a path the agent or the client names. This is one more of them, and it was
# missed twice: once by issue #2073's sweep, and again by issue #2097 — whose own body
# cited `daemon.py` as already handling a failed `list_rants` read, because this branch
# has an `except OSError`. It has one, and it was unreachable: the guard was
# `self._rants_log.exists()`, which is true for a FIFO, so the `open` blocked and raised
# nothing (issue #2097, second half — the panel is driven by a **client frame**, so this
# is the member of the family with the shortest path from a click to a wedged daemon).
#
# The `list_rants` branch now calls the store's own reader instead of keeping a second
# copy. `_read_rants` refuses a subject of another kind by name
# (`NotARegularFile` is an `OSError`, so the frame's existing branch carries it), and
# the copy's *second* behaviour goes with it: it appended whatever `json.loads` returned,
# so a legacy array row — the 2026-08-18 format drift — or a bare scalar reached
# `r.get(...)` and raised `AttributeError`, which no `except OSError` catches.


@_needs_mkfifo
def test_the_rant_panel_at_a_named_pipe_answers_rather_than_wedging(tmp_path):
"""`list_rants` must answer the frame, not wait for a writer that never comes.

Written so that removing the guard makes it **hang** rather than pass, which is why
the call is bounded by `_within`: the failure mode is a call that never returns.
"""
server = _make_server()
fifo = tmp_path / "rants.jsonl"
os.mkfifo(fifo)
server._rants_log = fifo

frames = _within(5.0, lambda: _drive(server, {"type": "list_rants"}))

assert len(frames) == 1, f"expected one frame, got {frames!r}"
assert frames[0]["type"] == "rants_list", frames[0]
assert "error" in frames[0], frames[0]
assert "a FIFO (named pipe)" in frames[0]["error"], frames[0]["error"]
assert "not a regular file" in frames[0]["error"], frames[0]["error"]


def test_the_rant_panel_at_a_regular_file_still_answers_the_rows(tmp_path):
"""The control, and it is pipe-free on purpose so it runs on the Windows leg too.

The rows also come back **filtered and newest-first**, which is the panel's contract
that the shared reader must not have changed: it filters by `status` exactly where
the deleted loop did, and the sort stays this branch's own.
"""
server = _make_server()
log = tmp_path / "rants.jsonl"
rows = [
{"timestamp": "2026-10-11T09:00:00+08:00", "project": "emrg", "status": "pending",
"progress": None, "completed": None, "message": "older"},
{"timestamp": "2026-10-11T10:00:00+08:00", "project": "emrg", "status": "completed",
"progress": None, "completed": "2026-10-11T10:00:00+08:00", "message": "newer"},
]
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
server._rants_log = log

frames = _drive(server, {"type": "list_rants"})
assert len(frames) == 1, frames
assert "error" not in frames[0], frames[0]
assert [r["message"] for r in frames[0]["rants"]] == ["newer", "older"], frames[0]

frames = _drive(server, {"type": "list_rants", "status": "pending"})
assert [r["message"] for r in frames[0]["rants"]] == ["older"], frames[0]

frames = _drive(server, {"type": "list_rants", "status": "in_progress"})
assert frames[0]["rants"] == [], frames[0]


def test_the_rant_panel_survives_a_legacy_array_row_and_a_bare_scalar(tmp_path):
"""The second defect the shared reader fixes, and the one no timeout can read.

The deleted loop appended the raw `json.loads` result, so a row
`["<ts>", "emrg", "pending", null, null, "legacy"]` (the 2026-08-18 drift) or a bare
`5` reached `r.get(...)` and raised `AttributeError` out of the dispatcher — a frame
the client never gets, on a file the same code path writes. Measured in the reverse
direction: on the previous tree this frame raises.

Both rows are asserted at once because they are one defect — "the panel reads a
parsed value as if it were a rant" — and the store has always answered them: the
array row is converted field by field, the scalar skipped.
"""
server = _make_server()
log = tmp_path / "rants.jsonl"
log.write_text(
json.dumps(["2026-10-11T09:00:00+08:00", "emrg", "pending", None, None, "legacy"])
+ "\n" + "5\n" + "{not json}\n",
encoding="utf-8",
)
server._rants_log = log

frames = _drive(server, {"type": "list_rants"})

assert len(frames) == 1, f"expected one frame, got {frames!r}"
assert frames[0]["type"] == "rants_list", frames[0]
assert [r["message"] for r in frames[0]["rants"]] == ["legacy"], frames[0]
assert frames[0]["rants"][0]["project"] == "emrg", frames[0]
Loading
Loading