From 0b249fb1804fcf2aa6ec3d0a87d643705b73548a Mon Sep 17 00:00:00 2001 From: droidlyx Date: Mon, 4 May 2026 21:03:28 +0800 Subject: [PATCH] fix(artifact): auto-refresh quest_root SUMMARY.md on every record `SUMMARY.md` at the quest root is the canonical compact snapshot for external observers (operators inspecting quest progress without parsing events.jsonl, cross-quest agents browsing prior quests). To be useful it has to be current. The intended path was `refresh_summary` called from stage skill prose at meaningful checkpoints. In practice agents almost never call it: across 4 observed quests on the same studio, the count was 0 / 1 / 1 / 0 over 9-24h of active work. Agents have richer state in events.jsonl, `get_quest_state`, and memory cards, so the SUMMARY write is never on their critical path. Stop relying on agents to maintain a side-file. After this change, every successful `record(...)` writes a fresh SUMMARY.md to `quest_root` as a side effect (recursion-guarded against the `summary_refresh` report itself). The auto-refresh: - only writes `quest_root/SUMMARY.md` (NOT the worktree's), so it cannot leave a worktree dirty and block subsequent `git switch` / `git worktree` operations during normal artifact flows; - does not record a `summary_refresh` audit artifact (would pollute events.jsonl with one auto-refresh report per record). Explicit `refresh_summary()` calls still record the audit artifact (default `record_artifact=True`) and still write both worktree + quest_root SUMMARY.md, preserving the existing contract. Wraps in try/except so a refresh failure cannot abort the record. --- src/deepscientist/artifact/service.py | 57 ++++++++++++++++-------- tests/test_memory_and_artifact.py | 64 +++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/src/deepscientist/artifact/service.py b/src/deepscientist/artifact/service.py index 5637a908..f124ae6d 100644 --- a/src/deepscientist/artifact/service.py +++ b/src/deepscientist/artifact/service.py @@ -9283,6 +9283,16 @@ def record( closing_artifact_id=artifact_id, ) + if not (record["kind"] == "report" and record.get("report_type") == "summary_refresh"): + try: + self.refresh_summary( + quest_root, + reason=f"auto after {record['kind']} {artifact_id}", + record_artifact=False, + ) + except Exception: + pass + return { "ok": True, "artifact_id": artifact_id, @@ -14202,7 +14212,13 @@ def waive_baseline( "legacy_guidance": "Baseline gate waived. Continue carefully and keep the waiver rationale explicit downstream.", } - def refresh_summary(self, quest_root: Path, *, reason: str | None = None) -> dict: + def refresh_summary( + self, + quest_root: Path, + *, + reason: str | None = None, + record_artifact: bool = True, + ) -> dict: workspace_root = self._workspace_root_for(quest_root) recent = self.recent(quest_root, limit=20) latest_runs = [item for item in recent if item.get("kind") == "runs"][-5:] @@ -14229,27 +14245,30 @@ def refresh_summary(self, quest_root: Path, *, reason: str | None = None) -> dic lines.append(f"- `{payload.get('run_id') or payload.get('artifact_id')}`: {summary}") summary_body = "\n".join(lines).rstrip() + "\n" summary_path = workspace_root / "SUMMARY.md" - write_text(summary_path, summary_body) quest_root_summary_path = quest_root / "SUMMARY.md" - if quest_root_summary_path.resolve() != summary_path.resolve(): + if record_artifact: + write_text(summary_path, summary_body) + if quest_root_summary_path.resolve() != summary_path.resolve() or not record_artifact: write_text(quest_root_summary_path, summary_body) - artifact = self.record( - quest_root, - { - "kind": "report", - "status": "completed", - "report_type": "summary_refresh", - "report_id": generate_id("report"), - "summary": "Quest summary refreshed from recent artifacts.", - "reason": reason or "Summary refreshed after artifact updates.", - "paths": { - "summary_md": str(summary_path), - "quest_root_summary_md": str(quest_root_summary_path), + artifact: dict | None = None + if record_artifact: + artifact = self.record( + quest_root, + { + "kind": "report", + "status": "completed", + "report_type": "summary_refresh", + "report_id": generate_id("report"), + "summary": "Quest summary refreshed from recent artifacts.", + "reason": reason or "Summary refreshed after artifact updates.", + "paths": { + "summary_md": str(summary_path), + "quest_root_summary_md": str(quest_root_summary_path), + }, + "source": {"kind": "system", "role": "artifact"}, }, - "source": {"kind": "system", "role": "artifact"}, - }, - workspace_root=workspace_root, - ) + workspace_root=workspace_root, + ) return { "ok": True, "summary_path": str(summary_path), diff --git a/tests/test_memory_and_artifact.py b/tests/test_memory_and_artifact.py index e521388c..728012a8 100644 --- a/tests/test_memory_and_artifact.py +++ b/tests/test_memory_and_artifact.py @@ -927,6 +927,70 @@ def test_refresh_summary_writes_once_when_workspace_equals_quest_root(temp_home: assert (quest_root / "SUMMARY.md").exists() +def test_record_auto_refreshes_quest_root_summary_without_extra_artifact(temp_home: Path) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("auto refresh on record") + quest_root = Path(quest["quest_root"]) + artifact = ArtifactService(temp_home) + + summary_path = quest_root / "SUMMARY.md" + assert summary_path.read_text(encoding="utf-8").strip() != "" or not summary_path.exists() + pre_summary = summary_path.read_text(encoding="utf-8") if summary_path.exists() else "" + + result = artifact.record( + quest_root, + { + "kind": "report", + "status": "completed", + "report_id": "report-auto-1", + "summary": "first user-driven artifact", + "reason": "exercise auto-refresh hook", + "source": {"kind": "agent"}, + }, + ) + assert result["ok"] is True + + post_summary = summary_path.read_text(encoding="utf-8") + assert post_summary != pre_summary + assert "auto after report" in post_summary + assert result["artifact_id"] in post_summary + + summary_refresh_artifacts = [ + item for item in artifact.recent(quest_root, limit=20) + if item.get("kind") == "reports" + ] + summary_refresh_payloads = [] + for item in summary_refresh_artifacts: + payload = read_json(Path(item["path"]), {}) + if payload.get("report_type") == "summary_refresh": + summary_refresh_payloads.append(payload) + assert summary_refresh_payloads == [] + + +def test_record_skips_auto_refresh_when_record_is_summary_refresh(temp_home: Path) -> None: + ensure_home_layout(temp_home) + ConfigManager(temp_home).ensure_files() + quest_service = QuestService(temp_home, skill_installer=SkillInstaller(repo_root(), temp_home)) + quest = quest_service.create("no recursion") + quest_root = Path(quest["quest_root"]) + artifact = ArtifactService(temp_home) + + explicit = artifact.refresh_summary(quest_root, reason="explicit user-driven refresh") + assert explicit["ok"] is True + assert explicit["artifact"] is not None + + summary_refresh_payloads = [] + for item in artifact.recent(quest_root, limit=20): + if item.get("kind") != "reports": + continue + payload = read_json(Path(item["path"]), {}) + if payload.get("report_type") == "summary_refresh": + summary_refresh_payloads.append(payload) + assert len(summary_refresh_payloads) == 1 + + def test_artifact_interact_and_prepare_branch(temp_home: Path) -> None: ensure_home_layout(temp_home) ConfigManager(temp_home).ensure_files()