fix(memory): progress past completed no-op batches

This commit is contained in:
shixi-li
2026-07-27 02:00:41 +08:00
committed by Xubin Ren
parent b55b76d755
commit 15e42059bd
4 changed files with 98 additions and 33 deletions
+4 -5
View File
@@ -47,9 +47,9 @@ class MemoryStore:
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md.""" """Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
_DEFAULT_MAX_HISTORY = 1000 _DEFAULT_MAX_HISTORY = 1000
# Durable files whose real working-tree delta grounds Dream commit messages # Durable files whose real working-tree delta grounds Dream commit messages.
# and the cursor-advance gate. Deliberately excludes memory/.dream_cursor so # Deliberately excludes memory/.dream_cursor so progress bookkeeping never
# that advancing the cursor itself is never mistaken for a productive edit. # appears as a durable-memory edit in the audit record.
_DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md") _DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md")
# Per-file cap when embedding current contents into the Dream prompt. The # Per-file cap when embedding current contents into the Dream prompt. The
# durable files are tiny in practice (~5 KB total), but a runaway file must # durable files are tiny in practice (~5 KB total), but a runaway file must
@@ -586,8 +586,7 @@ class MemoryStore:
"""Structured summary of uncommitted changes to the durable memory files. """Structured summary of uncommitted changes to the durable memory files.
Returns "" when git is unavailable or no content file changed. This is Returns "" when git is unavailable or no content file changed. This is
the ground-truth input for diff-grounded Dream commit messages and for the ground-truth input for diff-grounded Dream commit messages.
gating cursor advance on real edits (never on LLM self-report).
""" """
if not self._git.is_initialized(): if not self._git.is_initialized():
return "" return ""
+15 -12
View File
@@ -1846,20 +1846,23 @@ def _run_gateway(
tools=store.build_dream_tools(), tools=store.build_dream_tools(),
on_progress=_silent, on_progress=_silent,
) )
# Ground truth: the real file delta, not the LLM's self-report. # The real file delta grounds the audit record; clean completion
# decides whether this history batch has finished processing.
diff_body = store.dream_content_diff() diff_body = store.dream_content_diff()
productive = bool(diff_body) or ( completed = MemoryStore.dream_run_completed(resp)
not store.git.is_initialized() if completed:
and MemoryStore.dream_run_completed(resp)
)
if productive:
store.set_last_dream_cursor(last_cursor) store.set_last_dream_cursor(last_cursor)
logger.info("Dream cron job completed, cursor advanced to {}", last_cursor) if diff_body:
elif MemoryStore.dream_run_completed(resp): logger.info(
logger.info( "Dream cron job completed, cursor advanced to {}",
"Dream cron job completed with no memory changes; " last_cursor,
"cursor not advanced", )
) else:
logger.info(
"Dream cron job completed with no memory changes; "
"cursor advanced to {}",
last_cursor,
)
else: else:
logger.warning( logger.warning(
"Dream cron job did not complete; cursor remains at {}", "Dream cron job did not complete; cursor remains at {}",
+8 -9
View File
@@ -437,17 +437,16 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
on_progress=_silent, on_progress=_silent,
) )
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
# Ground truth: the real file delta, not the LLM's self-report. # The real file delta grounds the audit record; clean completion
# decides whether this history batch has finished processing.
diff_body = store.dream_content_diff() diff_body = store.dream_content_diff()
productive = bool(diff_body) or ( completed = MemoryStore.dream_run_completed(resp)
not store.git.is_initialized() if completed:
and MemoryStore.dream_run_completed(resp)
)
if productive:
store.set_last_dream_cursor(last_cursor) store.set_last_dream_cursor(last_cursor)
content = f"Dream completed in {elapsed:.1f}s." if diff_body:
elif MemoryStore.dream_run_completed(resp): content = f"Dream completed in {elapsed:.1f}s."
content = f"Dream completed in {elapsed:.1f}s; no memory changes." else:
content = f"Dream completed in {elapsed:.1f}s; no memory changes."
else: else:
content = ( content = (
f"Dream did not complete after {elapsed:.1f}s; " f"Dream did not complete after {elapsed:.1f}s; "
+71 -7
View File
@@ -225,7 +225,7 @@ def _build_runnable_dream(
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dream_advances_cursor_when_diff_nonempty(tmp_path) -> None: async def test_dream_advances_cursor_when_diff_nonempty(tmp_path) -> None:
"""A real file delta => productive run => cursor advances (Tier 3).""" """A completed run with a real file delta advances the cursor."""
ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="SOUL.md: +1 -0") ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="SOUL.md: +1 -0")
await cmd_dream(ctx) await cmd_dream(ctx)
await asyncio.sleep(0) await asyncio.sleep(0)
@@ -233,19 +233,83 @@ async def test_dream_advances_cursor_when_diff_nonempty(tmp_path) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dream_keeps_cursor_on_completed_noop(tmp_path) -> None: async def test_dream_advances_cursor_on_completed_noop(tmp_path) -> None:
"""Completed run with no file changes must NOT advance the cursor, so the """A completed no-op has processed the batch and must not repeat it."""
history batch is reconsidered next run instead of silently swallowed."""
ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="") ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="")
await cmd_dream(ctx) await cmd_dream(ctx)
await asyncio.sleep(0) await asyncio.sleep(0)
assert store._last_dream_cursor == 5 # unchanged assert store._last_dream_cursor == 42
assert "no memory changes" in ctx.loop.bus.outbound[0].content
@pytest.mark.asyncio
async def test_dream_keeps_cursor_when_incomplete_with_diff(tmp_path) -> None:
"""An incomplete run remains retryable even if it left a partial edit."""
ctx, store = _build_runnable_dream(
tmp_path,
initialized=True,
content_diff="SOUL.md: +1 -0",
stop_reason="length",
)
await cmd_dream(ctx)
await asyncio.sleep(0)
assert store._last_dream_cursor == 5
assert "did not complete" in ctx.loop.bus.outbound[0].content
@pytest.mark.asyncio
async def test_dream_noop_batch_unlocks_following_history(tmp_path) -> None:
"""A no-op first batch must not starve later history entries."""
workspace = tmp_path / "workspace"
workspace.mkdir()
store = MemoryStore(workspace)
store.write_soul("# Soul")
store.write_memory("# Memory")
for index in range(1, 22):
store.append_history(f"entry-{index:02d}")
store.git.init()
processed_prompts: list[str] = []
async def process_direct(prompt, *args, **kwargs):
processed_prompts.append(prompt)
return OutboundMessage(
channel="cli",
chat_id="direct",
content="done",
metadata={"_stop_reason": "completed"},
)
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace(
bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
process_direct=process_direct,
)
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
await cmd_dream(ctx)
await asyncio.sleep(0)
assert len(processed_prompts) == 1
assert "entry-20" in processed_prompts[0]
assert "entry-21" not in processed_prompts[0]
assert store.get_last_dream_cursor() == 20
next_result = store.build_dream_prompt()
assert next_result is not None
next_prompt, next_cursor = next_result
assert next_cursor == 21
assert "entry-21" in next_prompt
assert "entry-01" not in next_prompt
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dream_non_git_falls_back_to_completion_gate(tmp_path) -> None: async def test_dream_non_git_falls_back_to_completion_gate(tmp_path) -> None:
"""Without git there is no diff signal; productivity falls back to the """Non-git workspaces use the same clean-completion gate."""
completion check so non-git workspaces keep working."""
ctx, store = _build_runnable_dream( ctx, store = _build_runnable_dream(
tmp_path, initialized=False, content_diff="", stop_reason="completed", tmp_path, initialized=False, content_diff="", stop_reason="completed",
) )