personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

runner: pin journal root at ProcessLogWriter init to stop daemon fixture leak

Daemon output-stream threads spawned by ManagedProcess could outlive the
test that spawned them and flush post-teardown, after _SOLSTONE_JOURNAL_OVERRIDE
had been reverted. The day-rollover branch in write() re-resolved the journal
root via get_journal() on every flush, so those late writes silently landed
under tests/fixtures/journal/chronicle/<today>/health/ — hidden from the git-
status leak detector by existing .gitignore patterns.

Capture the resolved journal root once at ProcessLogWriter.__init__ and use
the pinned Path in _open_log, _update_symlinks, and the path property. No
instance method re-reads get_journal() after construction, so env-var drift
between spawn and flush can no longer redirect writes. _day_health_log_path
now takes journal_root as an explicit argument.

Regression test: tests/test_sense.py::test_process_log_writer_pins_journal_root_at_init
constructs a writer under journal_a, drifts the env to journal_b, triggers a
rollover flush, and asserts journal_b stays empty.

Scope id: solstone-fixture-leak-daemon-thread

Co-authored-by: OpenAI Codex <codex@openai.com>

+39 -5
+27
tests/test_sense.py
··· 210 210 assert len([line for line in lines if line]) == 50 211 211 212 212 213 + def test_process_log_writer_pins_journal_root_at_init(tmp_path, monkeypatch): 214 + """Env-var drift between construction and flush must not redirect writes.""" 215 + from think import runner 216 + 217 + journal_a = tmp_path / "a" 218 + journal_b = tmp_path / "b" 219 + journal_a.mkdir() 220 + journal_b.mkdir() 221 + 222 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_a)) 223 + monkeypatch.setattr(runner, "_current_day", lambda: "20241101") 224 + 225 + ref = "test_ref" 226 + writer = ProcessLogWriter(ref, "echo") 227 + 228 + # Drift: env var changes and day changes before the next flush. 229 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_b)) 230 + monkeypatch.setattr(runner, "_current_day", lambda: "20241102") 231 + 232 + writer.write("hello\n") 233 + writer.close() 234 + 235 + leaked_paths = list(journal_b.rglob("*")) 236 + assert not leaked_paths, f"writes leaked into drifted journal: {leaked_paths}" 237 + assert list(journal_a.rglob("*.log")) or list(journal_a.rglob("*echo*")) 238 + 239 + 213 240 def test_handler_process_cleanup(): 214 241 """Test HandlerProcess cleanup joins threads and closes logger.""" 215 242 mock_managed = MagicMock()
+12 -5
think/runner.py
··· 43 43 return datetime.now().strftime("%Y%m%d") 44 44 45 45 46 - def _day_health_log_path(day: str, ref: str, name: str) -> Path: 46 + def _day_health_log_path(journal_root: Path, day: str, ref: str, name: str) -> Path: 47 47 """Build path to day health log. 48 48 49 49 Returns: journal/chronicle/{day}/health/{ref}_{name}.log 50 50 """ 51 - return _get_journal_path() / CHRONICLE_DIR / day / "health" / f"{ref}_{name}.log" 51 + return journal_root / CHRONICLE_DIR / day / "health" / f"{ref}_{name}.log" 52 52 53 53 54 54 def _atomic_symlink(link_path: Path, target: str) -> None: ··· 98 98 - journal/health/{name}.log -> chronicle/{YYYYMMDD}/health/{ref}_{name}.log (journal-level) 99 99 100 100 When the day changes, automatically closes old file, opens new file, and updates symlinks. 101 + The journal root is resolved once at construction time and pinned for the 102 + lifetime of the writer. 101 103 """ 102 104 103 105 def __init__(self, ref: str, name: str, day: str | None = None): 104 106 self._ref = ref 105 107 self._name = name 108 + self._journal_root: Path = _get_journal_path() 106 109 self._pinned = day is not None 107 110 self._lock = threading.Lock() 108 111 self._current_day = day or _current_day() ··· 111 114 112 115 def _open_log(self): 113 116 """Open log file for current day.""" 114 - log_path = _day_health_log_path(self._current_day, self._ref, self._name) 117 + log_path = _day_health_log_path( 118 + self._journal_root, self._current_day, self._ref, self._name 119 + ) 115 120 log_path.parent.mkdir(parents=True, exist_ok=True) 116 121 return log_path.open("a", encoding="utf-8") 117 122 118 123 def _update_symlinks(self) -> None: 119 124 """Update day-level and journal-level symlinks to point to current log.""" 120 - journal = _get_journal_path() 125 + journal = self._journal_root 121 126 day_health = journal / CHRONICLE_DIR / self._current_day / "health" 122 127 log_filename = f"{self._ref}_{self._name}.log" 123 128 ··· 167 172 @property 168 173 def path(self) -> Path: 169 174 """Get current log file path.""" 170 - return _day_health_log_path(self._current_day, self._ref, self._name) 175 + return _day_health_log_path( 176 + self._journal_root, self._current_day, self._ref, self._name 177 + ) 171 178 172 179 173 180 @dataclass