personal memory agent
0
fork

Configure Feed

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

service: wrapper v2 supervisor stdio redirect + doctor system-python import fix

The launchd plist hard-coded StandardOutPath/StandardErrorPath at install
time, so `sol config journal --move|--switch` left phantom health/ logs at
the old journal path. Move the redirect into the managed wrapper, gated on
`$1 = supervisor`, and bump the wrapper to managed-version 2. _logs reads
journal/health/service.log on both Darwin and Linux now.

Doctor's `from think.install_guard import parse_wrapper` was triggering
think/__init__.py's eager re-exports of detect_created / detect_transcript
/ planner, which transitively pull in frontmatter — unavailable under the
system python3 the Makefile uses. Gut the re-exports; verified zero
non-package callers (all real imports already use the submodule path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+306 -52
+1
docs/DOCTOR.md
··· 44 44 | What | Where | 45 45 |------|-------| 46 46 | Current service logs | `journal/health/{service}.log` (symlinks) | 47 + | Daemon stdout/stderr | `journal/health/service.log` (combined, append-only) | 47 48 | Day's process logs | `journal/{YYYYMMDD}/health/{ref}_{name}.log` | 48 49 | Agent execution | `journal/talents/<name>/*.jsonl` | 49 50 | Journal task log | `journal/task_log.txt` |
+1
tests/test_config_cli.py
··· 63 63 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 64 64 "journal": journal, 65 65 "sol_bin": sol_bin, 66 + "version": 2, 66 67 } 67 68 68 69
+31
tests/test_doctor.py
··· 81 81 return target 82 82 83 83 84 + def test_install_guard_import_succeeds_when_frontmatter_is_shadowed(tmp_path): 85 + shadow_dir = tmp_path / "shadow" 86 + shadow_dir.mkdir() 87 + (shadow_dir / "frontmatter.py").write_text( 88 + 'raise ImportError("blocked for test")\n', 89 + encoding="utf-8", 90 + ) 91 + env = os.environ.copy() 92 + pythonpath_parts = [str(shadow_dir), str(ROOT)] 93 + existing_pythonpath = env.get("PYTHONPATH") 94 + if existing_pythonpath: 95 + pythonpath_parts.append(existing_pythonpath) 96 + env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts) 97 + 98 + result = subprocess.run( 99 + [ 100 + sys.executable, 101 + "-c", 102 + "from think.install_guard import parse_wrapper; print('ok')", 103 + ], 104 + cwd=ROOT, 105 + capture_output=True, 106 + text=True, 107 + check=False, 108 + env=env, 109 + ) 110 + 111 + assert result.returncode == 0 112 + assert result.stdout.strip() == "ok" 113 + 114 + 84 115 class TestPythonVersion: 85 116 def test_ok(self, doctor): 86 117 result = doctor.python_version_check(args(doctor))
+210 -2
tests/test_install_guard.py
··· 4 4 from __future__ import annotations 5 5 6 6 import os 7 + import subprocess 7 8 from pathlib import Path 8 9 from unittest.mock import Mock 9 10 10 11 import pytest 11 12 12 13 from think import install_guard 14 + 15 + V1_WRAPPER_TEMPLATE = """\ 16 + #!/bin/sh 17 + # sol — managed by 'sol config'. Edits will be overwritten. 18 + # managed-version: 1 19 + : "${{SOLSTONE_JOURNAL:={journal}}}" 20 + export SOLSTONE_JOURNAL 21 + SOL_BIN='{sol_bin}' 22 + if [ ! -x "$SOL_BIN" ]; then 23 + printf 'sol: venv binary missing or not executable: %s\\n' "$SOL_BIN" >&2 24 + exit 127 25 + fi 26 + exec "$SOL_BIN" "$@" 27 + """ 13 28 14 29 15 30 @pytest.fixture ··· 61 76 return alias 62 77 63 78 79 + def render_v1_wrapper(journal: str, sol_bin: str) -> str: 80 + escaped_sol_bin = sol_bin.replace("'", "'\\''") 81 + return V1_WRAPPER_TEMPLATE.format(journal=journal, sol_bin=escaped_sol_bin) 82 + 83 + 84 + def make_v1_wrapper( 85 + home_root: Path, 86 + *, 87 + journal: str, 88 + sol_bin: str, 89 + mode: int = 0o755, 90 + ) -> Path: 91 + alias = home_root / ".local" / "bin" / "sol" 92 + alias.parent.mkdir(parents=True, exist_ok=True) 93 + alias.write_text(render_v1_wrapper(journal, sol_bin), encoding="utf-8") 94 + alias.chmod(mode) 95 + return alias 96 + 97 + 64 98 def other_target(tmp_path: Path) -> Path: 65 99 target = tmp_path / "other" / ".venv" / "bin" / "sol" 66 100 target.parent.mkdir(parents=True, exist_ok=True) ··· 90 124 91 125 def worktree_error(curdir: Path) -> str: 92 126 return f"ERROR: refusing to run from a git worktree ({curdir}). Run from the primary clone.\n" 127 + 128 + 129 + def write_executable_script(path: Path, content: str) -> Path: 130 + path.write_text(content, encoding="utf-8") 131 + path.chmod(0o755) 132 + return path 93 133 94 134 95 135 class TestWrapperHelpers: ··· 116 156 assert install_guard.parse_wrapper(content) == { 117 157 "journal": journal, 118 158 "sol_bin": sol_bin, 159 + "version": 2, 119 160 } 120 161 121 162 def test_render_wrapper_round_trip_tricky_paths(self): ··· 127 168 assert install_guard.parse_wrapper(content) == { 128 169 "journal": journal, 129 170 "sol_bin": sol_bin, 171 + "version": 2, 130 172 } 131 173 132 174 def test_render_wrapper_matches_spec_template(self): ··· 138 180 assert ( 139 181 content == "#!/bin/sh\n" 140 182 "# sol — managed by 'sol config'. Edits will be overwritten.\n" 141 - "# managed-version: 1\n" 183 + "# managed-version: 2\n" 142 184 ': "${SOLSTONE_JOURNAL:=/Users/jer/Documents/Solstone}"\n' 143 185 "export SOLSTONE_JOURNAL\n" 144 186 "SOL_BIN='/Users/jer/projects/solstone/.venv/bin/sol'\n" ··· 146 188 " printf 'sol: venv binary missing or not executable: %s\\n' \"$SOL_BIN\" >&2\n" 147 189 " exit 127\n" 148 190 "fi\n" 191 + 'if [ "$1" = "supervisor" ]; then\n' 192 + ' mkdir -p "$SOLSTONE_JOURNAL/health"\n' 193 + ' exec >>"$SOLSTONE_JOURNAL/health/service.log" 2>&1\n' 194 + "fi\n" 149 195 'exec "$SOL_BIN" "$@"\n' 150 196 ) 151 197 198 + def test_parse_wrapper_accepts_v1(self): 199 + journal = "/tmp/solstone" 200 + sol_bin = "/tmp/repo/.venv/bin/sol" 201 + 202 + content = render_v1_wrapper(journal, sol_bin) 203 + 204 + assert install_guard.parse_wrapper(content) == { 205 + "journal": journal, 206 + "sol_bin": sol_bin, 207 + "version": 1, 208 + } 209 + 210 + def test_parse_wrapper_accepts_v2(self): 211 + journal = "/tmp/solstone" 212 + sol_bin = "/tmp/repo/.venv/bin/sol" 213 + 214 + content = install_guard.render_wrapper(journal, sol_bin) 215 + 216 + assert install_guard.parse_wrapper(content) == { 217 + "journal": journal, 218 + "sol_bin": sol_bin, 219 + "version": 2, 220 + } 221 + 222 + def test_parse_wrapper_rejects_v3(self): 223 + content = install_guard.render_wrapper( 224 + "/tmp/solstone", "/tmp/repo/.venv/bin/sol" 225 + ) 226 + content = content.replace("# managed-version: 2", "# managed-version: 3") 227 + 228 + assert install_guard.parse_wrapper(content) is None 229 + 152 230 @pytest.mark.parametrize("char", ["$", "`", '"', "\\"]) 153 231 def test_validate_journal_path_for_wrapper_rejects_invalid_chars(self, char: str): 154 232 with pytest.raises(ValueError, match="shell-active character"): ··· 259 337 assert captured.out == "fresh\n" 260 338 assert captured.err == "" 261 339 262 - def test_check_reports_current_for_managed_wrapper_with_matching_paths( 340 + def test_check_reports_current_for_v2_wrapper_with_matching_paths( 263 341 self, home_root, tmp_path, capsys 264 342 ): 265 343 repo = make_repo(tmp_path) ··· 276 354 assert rc == 0 277 355 assert captured.out == "current\n" 278 356 assert captured.err == "" 357 + 358 + def test_check_reports_upgrade_for_v1_wrapper_with_matching_paths( 359 + self, home_root, tmp_path 360 + ): 361 + repo = make_repo(tmp_path) 362 + target = ensure_expected_target(repo) 363 + make_v1_wrapper( 364 + home_root, 365 + journal=install_guard._current_journal_for_alias(), 366 + sol_bin=str(target), 367 + ) 368 + 369 + state, token = install_guard.check_alias_detail(repo) 370 + 371 + assert state is install_guard.AliasState.OWNED 372 + assert token == "upgrade" 279 373 280 374 def test_check_reports_upgrade_for_legacy_symlink( 281 375 self, home_root, tmp_path, capsys ··· 386 480 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 387 481 "journal": install_guard._current_journal_for_alias(), 388 482 "sol_bin": str(target), 483 + "version": 2, 389 484 } 390 485 391 486 def test_install_refuses_foreign_regular_file_without_force( ··· 424 519 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 425 520 "journal": install_guard._current_journal_for_alias(), 426 521 "sol_bin": str(target), 522 + "version": 2, 427 523 } 428 524 assert os.access(alias, os.X_OK) 429 525 ··· 447 543 assert install_guard.parse_wrapper(first_content) == { 448 544 "journal": install_guard._current_journal_for_alias(), 449 545 "sol_bin": str(target), 546 + "version": 2, 450 547 } 451 548 549 + def test_v1_wrapper_upgrades_to_v2_end_to_end( 550 + self, home_root, tmp_path, monkeypatch, capsys 551 + ): 552 + repo = make_repo(tmp_path) 553 + target = ensure_expected_target(repo) 554 + alias = make_v1_wrapper( 555 + home_root, 556 + journal=install_guard._current_journal_for_alias(), 557 + sol_bin=str(target), 558 + ) 559 + 560 + assert install_guard.cmd_check(repo) == 0 561 + captured = capsys.readouterr() 562 + assert captured.out == "upgrade\n" 563 + assert captured.err == "" 564 + 565 + rc, out, err = run_main(monkeypatch, capsys, repo, "install") 566 + 567 + assert rc == 0 568 + assert out == "installed\npath: ~/.local/bin already on PATH\n" 569 + assert err == "" 570 + assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 571 + "journal": install_guard._current_journal_for_alias(), 572 + "sol_bin": str(target), 573 + "version": 2, 574 + } 575 + 576 + assert install_guard.cmd_check(repo) == 0 577 + captured = capsys.readouterr() 578 + assert captured.out == "current\n" 579 + assert captured.err == "" 580 + 452 581 def test_install_refuses_invalid_journal_path( 453 582 self, home_root, tmp_path, monkeypatch, capsys 454 583 ): ··· 488 617 assert install_guard.parse_wrapper(alias.read_text(encoding="utf-8")) == { 489 618 "journal": install_guard._current_journal_for_alias(), 490 619 "sol_bin": str(target), 620 + "version": 2, 491 621 } 492 622 append_mock.assert_called_once_with( 493 623 str(alias.parent), ··· 564 694 assert out == "" 565 695 assert err == worktree_error(repo) 566 696 assert not install_guard.alias_path().exists() 697 + 698 + 699 + class TestWrapperRedirect: 700 + @staticmethod 701 + def _wrapper_env() -> dict[str, str]: 702 + env = os.environ.copy() 703 + env.pop("SOLSTONE_JOURNAL", None) 704 + return env 705 + 706 + @staticmethod 707 + def _write_stub_sol_bin(tmp_path: Path) -> Path: 708 + return write_executable_script( 709 + tmp_path / "stub-sol", 710 + "#!/bin/sh\nprintf 'OUT %s\\n' \"$*\"\nprintf 'ERR %s\\n' \"$*\" >&2\n", 711 + ) 712 + 713 + @staticmethod 714 + def _write_wrapper(tmp_path: Path, *, journal: Path, sol_bin: Path) -> Path: 715 + return write_executable_script( 716 + tmp_path / "sol", 717 + install_guard.render_wrapper(str(journal), str(sol_bin)), 718 + ) 719 + 720 + def test_wrapper_redirects_supervisor_stdio_to_service_log(self, tmp_path): 721 + journal = tmp_path / "j" 722 + sol_bin = self._write_stub_sol_bin(tmp_path) 723 + wrapper = self._write_wrapper(tmp_path, journal=journal, sol_bin=sol_bin) 724 + 725 + result = subprocess.run( 726 + [str(wrapper), "supervisor", "5015"], 727 + capture_output=True, 728 + text=True, 729 + check=False, 730 + env=self._wrapper_env(), 731 + ) 732 + 733 + assert result.returncode == 0 734 + assert result.stdout == "" 735 + assert result.stderr == "" 736 + content = (journal / "health" / "service.log").read_text(encoding="utf-8") 737 + assert "OUT supervisor 5015" in content 738 + assert "ERR supervisor 5015" in content 739 + 740 + def test_wrapper_does_not_redirect_for_other_subcommands(self, tmp_path): 741 + journal = tmp_path / "j" 742 + sol_bin = self._write_stub_sol_bin(tmp_path) 743 + wrapper = self._write_wrapper(tmp_path, journal=journal, sol_bin=sol_bin) 744 + 745 + result = subprocess.run( 746 + [str(wrapper), "not-supervisor"], 747 + capture_output=True, 748 + text=True, 749 + check=False, 750 + env=self._wrapper_env(), 751 + ) 752 + 753 + assert result.returncode == 0 754 + assert result.stdout == "OUT not-supervisor\n" 755 + assert result.stderr == "ERR not-supervisor\n" 756 + assert not (journal / "health" / "service.log").exists() 757 + 758 + def test_wrapper_appends_not_truncates(self, tmp_path): 759 + journal = tmp_path / "j" 760 + sol_bin = self._write_stub_sol_bin(tmp_path) 761 + wrapper = self._write_wrapper(tmp_path, journal=journal, sol_bin=sol_bin) 762 + 763 + for _ in range(2): 764 + result = subprocess.run( 765 + [str(wrapper), "supervisor", "5015"], 766 + capture_output=True, 767 + text=True, 768 + check=False, 769 + env=self._wrapper_env(), 770 + ) 771 + assert result.returncode == 0 772 + 773 + content = (journal / "health" / "service.log").read_text(encoding="utf-8") 774 + assert content.count("OUT supervisor 5015") == 2
+30 -2
tests/test_service.py
··· 48 48 assert plist["EnvironmentVariables"] == env 49 49 assert plist["KeepAlive"] is True 50 50 assert plist["RunAtLoad"] is True 51 - assert "launchd-stdout.log" in plist["StandardOutPath"] 52 - assert "launchd-stderr.log" in plist["StandardErrorPath"] 51 + assert "StandardOutPath" not in plist 52 + assert "StandardErrorPath" not in plist 53 53 54 54 55 55 class TestSystemdUnit: ··· 77 77 assert "Environment=PATH=/usr/bin" in unit 78 78 assert "SOLSTONE_JOURNAL" not in unit 79 79 assert "WantedBy=default.target" in unit 80 + 81 + def test_no_stdio_redirection(self): 82 + env = { 83 + "HOME": "/home/test", 84 + "PATH": "/usr/bin", 85 + } 86 + 87 + unit = service._generate_systemd_unit(env) 88 + 89 + assert "StandardOutput" not in unit 90 + assert "StandardError" not in unit 91 + 92 + 93 + class TestLogs: 94 + def test_reads_service_log(self, monkeypatch, tmp_path, capsys): 95 + monkeypatch.setattr(sys, "platform", "darwin") 96 + health_dir = tmp_path / "health" 97 + health_dir.mkdir(parents=True) 98 + service_log = health_dir / "service.log" 99 + service_log.write_text("first line\nsecond line\n", encoding="utf-8") 100 + monkeypatch.setattr(service, "get_journal", lambda: str(tmp_path)) 101 + 102 + result = service._logs(follow=False) 103 + 104 + assert result == 0 105 + captured = capsys.readouterr() 106 + assert captured.out == "=== service.log ===\nfirst line\nsecond line\n\n" 107 + assert captured.err == "" 80 108 81 109 82 110 class TestEnvCollection:
-14
think/__init__.py
··· 2 2 # Copyright (c) 2026 sol pbc 3 3 4 4 """Think - Data processing, AI agent orchestration, and analysis for solstone.""" 5 - 6 - from .detect_created import detect_created 7 - from .detect_transcript import detect_transcript_json, detect_transcript_segment 8 - from .planner import generate_plan 9 - 10 - # Cluster functions not re-exported here to avoid circular imports 11 - # Import directly from think.cluster where needed 12 - 13 - __all__ = [ 14 - "detect_created", 15 - "detect_transcript_segment", 16 - "detect_transcript_json", 17 - "generate_plan", 18 - ]
+20 -7
think/install_guard.py
··· 13 13 from contextlib import contextmanager 14 14 from enum import Enum 15 15 from pathlib import Path 16 - from typing import Iterator 16 + from typing import Iterator, TypedDict 17 17 18 18 try: 19 19 import userpath # type: ignore[import-not-found] ··· 24 24 WRAPPER_TEMPLATE = """\ 25 25 #!/bin/sh 26 26 # sol — managed by 'sol config'. Edits will be overwritten. 27 - # managed-version: 1 27 + # managed-version: 2 28 28 : "${{SOLSTONE_JOURNAL:={journal}}}" 29 29 export SOLSTONE_JOURNAL 30 30 SOL_BIN='{sol_bin}' ··· 32 32 printf 'sol: venv binary missing or not executable: %s\\n' "$SOL_BIN" >&2 33 33 exit 127 34 34 fi 35 + if [ "$1" = "supervisor" ]; then 36 + mkdir -p "$SOLSTONE_JOURNAL/health" 37 + exec >>"$SOLSTONE_JOURNAL/health/service.log" 2>&1 38 + fi 35 39 exec "$SOL_BIN" "$@" 36 40 """ 37 41 38 - WRAPPER_MARKER = "# managed-version: 1" 39 - WRAPPER_VERSION = 1 42 + WRAPPER_MARKER = "# managed-version: 2" 43 + WRAPPER_VERSION = 2 40 44 41 - _RE_MARKER = re.compile(r"(?m)^# managed-version: 1$") 45 + _RE_MARKER = re.compile(r"(?m)^# managed-version: (?P<version>[12])$") 42 46 _RE_JOURNAL = re.compile(r'(?m)^: "\$\{SOLSTONE_JOURNAL:=(?P<journal>[^\n]*)\}"$') 43 47 _RE_SOL_BIN = re.compile(r"(?m)^SOL_BIN='(?P<sol_bin>(?:[^']|'\\'')*)'$") 44 48 ··· 52 56 CROSS_REPO = "cross_repo" 53 57 DANGLING = "dangling" 54 58 FOREIGN = "foreign" 59 + 60 + 61 + class ParsedWrapper(TypedDict): 62 + journal: str 63 + sol_bin: str 64 + version: int 55 65 56 66 57 67 def alias_path() -> Path: ··· 68 78 return WRAPPER_TEMPLATE.format(journal=journal, sol_bin=escaped_sol_bin) 69 79 70 80 71 - def parse_wrapper(content: str) -> dict[str, str] | None: 81 + def parse_wrapper(content: str) -> ParsedWrapper | None: 72 82 """Return embedded paths if the content is a managed wrapper.""" 73 - if not _RE_MARKER.search(content): 83 + marker_match = _RE_MARKER.search(content) 84 + if not marker_match: 74 85 return None 75 86 journal_match = _RE_JOURNAL.search(content) 76 87 sol_bin_match = _RE_SOL_BIN.search(content) ··· 79 90 return { 80 91 "journal": journal_match.group("journal"), 81 92 "sol_bin": sol_bin_match.group("sol_bin").replace("'\\''", "'"), 93 + "version": int(marker_match.group("version")), 82 94 } 83 95 84 96 ··· 180 192 if ( 181 193 parsed["journal"] == _current_journal_for_alias() 182 194 and parsed["sol_bin"] == str(expected_target(curdir)) 195 + and parsed["version"] == WRAPPER_VERSION 183 196 and (alias.stat().st_mode & 0o111) == 0o111 184 197 ): 185 198 return state, "current"
+13 -27
think/service.py
··· 109 109 110 110 def _generate_plist(env: dict[str, str], port: int = DEFAULT_SERVICE_PORT) -> bytes: 111 111 """Generate a launchd plist for the solstone supervisor.""" 112 - journal_path = str(Path(get_journal()).resolve()) 113 112 sol = _sol_bin() 114 113 115 114 plist = { ··· 118 117 "EnvironmentVariables": env, 119 118 "RunAtLoad": True, 120 119 "KeepAlive": True, 121 - "StandardOutPath": f"{journal_path}/health/launchd-stdout.log", 122 - "StandardErrorPath": f"{journal_path}/health/launchd-stderr.log", 123 120 } 124 121 return plistlib.dumps(plist) 125 122 ··· 477 474 478 475 479 476 def _logs(follow: bool = False) -> int: 480 - platform = _platform() 477 + _platform() 478 + journal_path = Path(get_journal()) 479 + service_log = journal_path / "health" / "service.log" 481 480 482 - if platform == "linux": 483 - cmd = ["journalctl", "--user", "-u", SYSTEMD_UNIT, "--no-pager", "-n", "100"] 484 - if follow: 485 - cmd.append("--follow") 486 - result = subprocess.run(cmd) 481 + if follow: 482 + if not service_log.exists(): 483 + print("No service log file found", file=sys.stderr) 484 + return 1 485 + result = subprocess.run(["/usr/bin/tail", "-f", str(service_log)]) 487 486 return result.returncode 488 487 else: 489 - journal_path = Path(get_journal()) 490 - stdout_log = journal_path / "health" / "launchd-stdout.log" 491 - stderr_log = journal_path / "health" / "launchd-stderr.log" 492 - 493 - if follow: 494 - logs_to_follow = [str(p) for p in [stdout_log, stderr_log] if p.exists()] 495 - if not logs_to_follow: 496 - print("No service log files found", file=sys.stderr) 497 - return 1 498 - result = subprocess.run(["/usr/bin/tail", "-f"] + logs_to_follow) 499 - return result.returncode 488 + if service_log.exists(): 489 + print(f"=== {service_log.name} ===") 490 + print(service_log.read_text(errors="replace")[-10000:]) 500 491 else: 501 - for log_path in [stdout_log, stderr_log]: 502 - if log_path.exists(): 503 - print(f"=== {log_path.name} ===") 504 - print(log_path.read_text(errors="replace")[-10000:]) 505 - else: 506 - print(f"=== {log_path.name} === (not found)") 507 - return 0 492 + print(f"=== {service_log.name} === (not found)") 493 + return 0 508 494 509 495 510 496 def _up(port: int = DEFAULT_SERVICE_PORT) -> int: