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
21 changes: 18 additions & 3 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -2096,7 +2096,8 @@ async def _github_connect_web_cancel(self) -> None:
pass

async def _task_vibe_check(self, task_name: str, session_id: str, cwd: str,
prompt: str = "", completion_summary: str = "") -> dict:
prompt: str = "", completion_summary: str = "",
cycle_started_at: str = "") -> dict:
"""Structured LLM ask (Ask mode, no tools) about a finished task cycle.

The agent must answer in strict JSON (rant 2026-08-20T10:58:55,
Expand Down Expand Up @@ -2138,10 +2139,20 @@ async def _task_vibe_check(self, task_name: str, session_id: str, cwd: str,
# Rant 2026-08-19T10:15:43: load the task's own session history by its
# fixed session_id (session files are organized by cwd). Recent N
# messages only — the whole session may be very long.
#
# `cycle_started_at` is the boundary this slice was missing: the session
# is reused by every run of this task, so "the last 100 messages" is
# mostly *previous* runs whenever this one was light on narrative. The
# summariser then describes the neighbour — measured 2026-09-30 on two
# consecutive `emrg-task` records whose `work` strings name the same
# merge, the second cycle's own findings absent — and `recommend_slowdown`
# rides the same call, so a mis-read run can also mis-cadence itself.
# The instant is the `task` frame's own `timestamp`, so no clock is read
# here; an absent value keeps the old behaviour (`records_since`).
if session_id and cwd:
try:
session = Session.load(session_id, Path(cwd))
history = session.get_messages_for_llm()
history = session.get_messages_for_llm(since=cycle_started_at)
if history:
# Rant 2026-08-19T19:25:56 (root cause): slicing the
# validated list can orphan a leading role:"tool" message
Expand Down Expand Up @@ -3305,9 +3316,13 @@ async def _process_message(
cwd = msg.get("cwd", "")
prompt = msg.get("prompt", "")
summary = msg.get("completion_summary", "")
# The instant this run was dispatched (the scheduler's `cycle_time`,
# which is also the run's id). Absent from an older scheduler, in
# which case the evidence is the whole session exactly as before.
cycle_started_at = msg.get("cycle_started_at", "")
try:
result = await self._task_vibe_check(
task_name, session_id, cwd, prompt, summary,
task_name, session_id, cwd, prompt, summary, cycle_started_at,
)
await self._send(ws, {
"type": "vibe_check_result",
Expand Down
10 changes: 9 additions & 1 deletion emrg/server/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2327,7 +2327,8 @@ def _saturation_heartbeat_active(self) -> bool:
"""
return self._slowdown_active

async def _request_vibe_check(self, ws, prompt: str, completion_summary: str) -> dict | None:
async def _request_vibe_check(self, ws, prompt: str, completion_summary: str,
cycle_started_at: 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``. Rant
Expand Down Expand Up @@ -2355,6 +2356,12 @@ async def _request_vibe_check(self, ws, prompt: str, completion_summary: str) ->
"task_name": self.name,
"prompt": (prompt or "")[:2000],
"completion_summary": (completion_summary or "")[:3000],
# The instant this cycle was dispatched — the same value the
# `task` frame carries as its `timestamp`, so the daemon can tell
# this run's records from the previous run's inside a session
# that both share. Without it the summariser reads the shared
# session and can describe the neighbour (measured 2026-09-30).
"cycle_started_at": cycle_started_at or "",
}, ensure_ascii=False))
while True:
try:
Expand Down Expand Up @@ -2668,6 +2675,7 @@ async def _run_evolution_cycle(self) -> str:
vibe_result = await self._request_vibe_check(
ws, prompt=prompt,
completion_summary=completion_content[:3000],
cycle_started_at=cycle_time.isoformat(),
)
except Exception as e:
self._logger.exception("TaskHandler[%s] error", self.name)
Expand Down
64 changes: 62 additions & 2 deletions emrg/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,61 @@ def generate_session_id(cwd: Path) -> str:
return prefix + suffix


def _parse_instant(value: object) -> datetime | None:
"""An ISO-8601 instant from a stored timestamp, or `None` if unreadable.

`None` is "could not measure", never "epoch": the caller keeps the record
when this returns `None`, because excluding evidence is the direction that
cannot be noticed.
"""
if not isinstance(value, str) or not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None


def records_since(records: list[dict], since: str) -> list[dict]:
"""The records from `since` (an ISO-8601 instant) onward — the run's own.

A task session is **one session for every run of that task**: `emrg-evolution-<name>`
is reused cycle after cycle, so its history holds all of them. Anything reading
"the recent history" as evidence about *this* run therefore reads the previous
run's work as if it were this one's, unless it knows where the run began. This
function is that knowledge, applied: `since` is the instant the run was dispatched,
and it is carried in the `task` frame the scheduler already sends, so no clock is
consulted and no state is kept.

Fail-open in both directions, deliberately:

* an empty or unparseable `since` returns the list **unchanged** — a window whose
start is unknown is the window the session always had, and guessing a boundary
would be worse than not having one;
* a record whose own timestamp is missing or unreadable is **kept** — dropping it
would delete evidence silently, and a summary that read one record too many is
the smaller error than one that cannot see a tool call at all.

Comparing a naive timestamp with an aware one raises, and a stored timestamp is
naive local time (`datetime.now().isoformat()`), so exactly one side being aware
is treated as unmeasurable rather than as an order.
"""
if not since:
return list(records)
cutoff = _parse_instant(since)
if cutoff is None:
return list(records)
kept: list[dict] = []
for record in records:
at = _parse_instant(record.get("timestamp"))
if at is None or (at.tzinfo is None) != (cutoff.tzinfo is None):
kept.append(record)
continue
if at >= cutoff:
kept.append(record)
return kept


def records_to_messages(records: list[dict]) -> list[dict]:
"""Convert stored history records to OpenAI-compatible messages.

Expand Down Expand Up @@ -465,7 +520,7 @@ def _read_history(self) -> list[dict]:
logger.warning("corrupt line in history.jsonl, skipping")
return records

def get_messages_for_llm(self) -> list[dict]:
def get_messages_for_llm(self, since: str = "") -> list[dict]:
"""This session's history, converted to OpenAI-compatible messages.

The conversion itself is :func:`records_to_messages`, which is a function
Expand All @@ -474,8 +529,13 @@ def get_messages_for_llm(self) -> list[dict]:
*candidate* record list it has not written anywhere, and asking with a
different conversion than the live request uses would be asking a
different question than the refusal answered.

`since` narrows the records first, through :func:`records_since`: a task
session is reused by every run of its task, so a reader that wants *this*
run's work has to say where it began. The default is the whole history,
which is what this method always returned.
"""
return records_to_messages(self._read_history())
return records_to_messages(records_since(self._read_history(), since))


# ── Compact ───────────────────────────────────────────────
Expand Down
Loading
Loading